From 4d0379744b97720bc6e09483a3a04ed045c175df Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 31 Aug 2026 06:18:37 +0200 Subject: [PATCH] fix(camera): complete retail in-head adjustment behavior --- .../Rendering/RetailChaseCamera.cs | 39 +++++++++++--- .../Rendering/RetailChaseCameraTests.cs | 54 +++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index fb02610b..1c199666 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -36,6 +36,8 @@ public sealed class RetailChaseCamera : ICamera private const float RetailFirstPersonForward = 0.18f; private const float RetailLeaveHeadBack = 0.6f; 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 // 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. float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f; (Vector3 targetEye, Vector3 targetForward) = _inHead - ? ComputeInHeadPose(pivotWorld, heading) + ? ComputeInHeadPose(pivotWorld, heading, _targetDirectionLocal) : _targetDirectionLocal is { } localDirection ? ComputeTargetDirectionPose( 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 // (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; + _targetDirectionLocal = null; SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp); return; } @@ -366,8 +371,16 @@ public sealed class RetailChaseCamera : ICamera if (_inHead) { - _inHead = false; - SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp); + // CameraSet::{Raise,Lower} modify CameraManager::direction.z by + // 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; @@ -674,10 +687,22 @@ public sealed class RetailChaseCamera : ICamera /// internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose( Vector3 pivotWorld, - Vector3 heading) + Vector3 heading, + Vector3? targetDirectionLocal = null) { - Vector3 forward = Vector3.Normalize(heading); - return (pivotWorld + forward * RetailFirstPersonForward, forward); + Vector3 headingForward = Vector3.Normalize(heading); + 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); } /// diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index 7f052be6..dbc0c83a 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -618,6 +618,60 @@ public class RetailChaseCameraTests 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] public void AdjustPitch_UsesRetailEightDegreeRotationAndPreservesLength() {