fix(camera): complete retail in-head adjustment behavior

This commit is contained in:
Erik 2026-08-31 06:18:37 +02:00
parent 11e68aad82
commit 4d0379744b
2 changed files with 86 additions and 7 deletions

View file

@ -36,6 +36,8 @@ public sealed class RetailChaseCamera : ICamera
private const float RetailFirstPersonForward = 0.18f; private const float RetailFirstPersonForward = 0.18f;
private const float RetailLeaveHeadBack = 0.6f; private const float RetailLeaveHeadBack = 0.6f;
private const float RetailLeaveHeadUp = 0.5f; private const float RetailLeaveHeadUp = 0.5f;
private const float RetailInHeadDirectionStep = 0.200000003f;
private const float RetailInHeadDirectionLimit = 0.800000012f;
// CameraSet::{Farther,Closer,Raise,Lower,Rotate} constants. The named // CameraSet::{Farther,Closer,Raise,Lower,Rotate} constants. The named
// retail decomp carries CAMERA_MOUSELOOK_INC at 0x0079BC04 and the // retail decomp carries CAMERA_MOUSELOOK_INC at 0x0079BC04 and the
@ -199,7 +201,7 @@ public sealed class RetailChaseCamera : ICamera
// Keep in View snaps the camera behind the target and disables RMB orbit. // Keep in View snaps the camera behind the target and disables RMB orbit.
float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f; float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f;
(Vector3 targetEye, Vector3 targetForward) = _inHead (Vector3 targetEye, Vector3 targetForward) = _inHead
? ComputeInHeadPose(pivotWorld, heading) ? ComputeInHeadPose(pivotWorld, heading, _targetDirectionLocal)
: _targetDirectionLocal is { } localDirection : _targetDirectionLocal is { } localDirection
? ComputeTargetDirectionPose( ? ComputeTargetDirectionPose(
pivotWorld, heading, Distance, Pitch, localDirection) pivotWorld, heading, Distance, Pitch, localDirection)
@ -333,9 +335,12 @@ public sealed class RetailChaseCamera : ICamera
// Farther's exact in-head branch replaces (0,+0.18,0) with // Farther's exact in-head branch replaces (0,+0.18,0) with
// (0,-0.6,+0.5) and returns without applying the ordinary scale. // (0,-0.6,+0.5) and returns without applying the ordinary scale.
if (_inHead) // Closer has no matching escape branch: its scaled 0.18 m offset
// fails the 0.5 m near gate and therefore leaves first-person intact.
if (_inHead && adjustment > 0f)
{ {
_inHead = false; _inHead = false;
_targetDirectionLocal = null;
SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp); SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp);
return; return;
} }
@ -366,8 +371,16 @@ public sealed class RetailChaseCamera : ICamera
if (_inHead) if (_inHead)
{ {
_inHead = false; // CameraSet::{Raise,Lower} modify CameraManager::direction.z by
SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp); // 0.2 while InHead, clamp it to +/-0.8, and return. The viewer
// offset remains the authored +Y*0.18 head position.
Vector3 direction = _targetDirectionLocal ?? Vector3.UnitY;
direction.Z = Math.Clamp(
direction.Z + adjustment * RetailInHeadDirectionStep,
-RetailInHeadDirectionLimit,
RetailInHeadDirectionLimit);
_targetDirectionLocal = direction;
return;
} }
float candidatePitch = Pitch + adjustment * OffsetAngleRadians; float candidatePitch = Pitch + adjustment * OffsetAngleRadians;
@ -674,10 +687,22 @@ public sealed class RetailChaseCamera : ICamera
/// </summary> /// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose( internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose(
Vector3 pivotWorld, Vector3 pivotWorld,
Vector3 heading) Vector3 heading,
Vector3? targetDirectionLocal = null)
{ {
Vector3 forward = Vector3.Normalize(heading); Vector3 headingForward = Vector3.Normalize(heading);
return (pivotWorld + forward * RetailFirstPersonForward, forward); Vector3 targetForward = headingForward;
if (targetDirectionLocal is { } local)
{
var (frameForward, frameRight, frameUp) = BuildBasis(headingForward);
targetForward = Vector3.Normalize(
frameForward * local.Y
- frameRight * local.X
+ frameUp * local.Z);
}
return (
pivotWorld + headingForward * RetailFirstPersonForward,
targetForward);
} }
/// <summary> /// <summary>

View file

@ -618,6 +618,60 @@ public class RetailChaseCameraTests
Assert.Equal(MathF.Sqrt(0.6f * 0.6f + 0.5f * 0.5f), cam.Distance, 5); Assert.Equal(MathF.Sqrt(0.6f * 0.6f + 0.5f * 0.5f), cam.Distance, 5);
} }
[Fact]
public void AdjustingCloserInRetailFirstPerson_RefusesNearWriteAndStaysInHead()
{
var cam = new RetailChaseCamera();
cam.SetRetailFirstPersonView();
cam.AdjustDistance(-1f);
Assert.True(cam.IsInHead);
Assert.Equal(0.18f, cam.Distance, 5);
}
[Fact]
public void AdjustPitchInRetailFirstPerson_ChangesDirectionWithoutMovingEye()
{
var cam = new RetailChaseCamera();
cam.SetRetailFirstPersonView();
cam.AdjustPitch(+1f);
cam.Update(
playerPosition: Vector3.Zero,
playerYaw: 0f,
playerVelocity: Vector3.Zero,
isOnGround: true,
contactPlaneNormal: Vector3.UnitZ,
dt: 1f / 60f);
Assert.True(cam.IsInHead);
Assert.Equal(new Vector3(0.18f, 0f, 1.5f), cam.Position);
Vector3 forward = Vector3.Normalize(new Vector3(
-cam.View.M13,
-cam.View.M23,
-cam.View.M33));
Assert.True(forward.Z > 0f);
for (int i = 0; i < 10; i++)
cam.AdjustPitch(+1f);
cam.Update(
playerPosition: Vector3.Zero,
playerYaw: 0f,
playerVelocity: Vector3.Zero,
isOnGround: true,
contactPlaneNormal: Vector3.UnitZ,
dt: 1f);
forward = Vector3.Normalize(new Vector3(
-cam.View.M13,
-cam.View.M23,
-cam.View.M33));
Assert.Equal(
0.8f / MathF.Sqrt(1f + 0.8f * 0.8f),
forward.Z,
5);
}
[Fact] [Fact]
public void AdjustPitch_UsesRetailEightDegreeRotationAndPreservesLength() public void AdjustPitch_UsesRetailEightDegreeRotationAndPreservesLength()
{ {