diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs index 9104323f..78ddb898 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -489,8 +489,9 @@ internal sealed class CameraPointerInputController rawY: dy, weight: 0.5f, nowSec: _clock.NowSeconds); - _chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity; - _chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity); + const float retailMouseScale = 0.0666666701f; + _chase.Retail.AdjustYaw(-filteredDx * sensitivity * retailMouseScale); + _chase.Retail.AdjustPitch(filteredDy * sensitivity * retailMouseScale); } else { diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs index d3b28f79..7606ef98 100644 --- a/src/AcDream.App/Input/MouseLookController.cs +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -188,7 +188,7 @@ internal sealed class MouseLookController : IMouseLookInputFrameController : (rawX, rawY); _state.ApplyDelta(filteredX, _chase.Sensitivity); if (_chase.Retail is { } retailCamera) - retailCamera.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity); + retailCamera.AdjustPitch(filteredY * 0.0666666701f * _chase.Sensitivity); else _chase.Legacy?.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity); } diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs index a0cb3202..0150ceb1 100644 --- a/src/AcDream.App/Rendering/CameraFrameController.cs +++ b/src/AcDream.App/Rendering/CameraFrameController.cs @@ -82,13 +82,13 @@ internal sealed class CameraFrameController : ICameraFramePhase if (input.ZoomOut) retail.AdjustDistance(+adjustment); if (input.Raise) - retail.AdjustPitch(+adjustment * 0.02f); + retail.AdjustPitch(+adjustment); if (input.Lower) - retail.AdjustPitch(-adjustment * 0.02f); + retail.AdjustPitch(-adjustment); if (input.RotateLeft) - retail.YawOffset += adjustment * 0.02f; + retail.AdjustYaw(+adjustment); if (input.RotateRight) - retail.YawOffset -= adjustment * 0.02f; + retail.AdjustYaw(-adjustment); } else { diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index b227fc60..fb02610b 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -34,6 +34,18 @@ public sealed class RetailChaseCamera : ICamera private const float RetailLookDownBack = 2f; private const float RetailMapBack = 450f; private const float RetailFirstPersonForward = 0.18f; + private const float RetailLeaveHeadBack = 0.6f; + private const float RetailLeaveHeadUp = 0.5f; + + // CameraSet::{Farther,Closer,Raise,Lower,Rotate} constants. The named + // retail decomp carries CAMERA_MOUSELOOK_INC at 0x0079BC04 and the + // eight-degree offset angle initialised at 0x006EABF0/0x0083D034. + internal const float OffsetScalePerAdjustment = 0.200000003f; + internal const float OffsetAngleRadians = 8f * MathF.PI / 180f; + internal const float MinimumOffsetLength = 0.5f; + internal const float MaximumHorizontalComponent = 10f; + internal const float MaximumVerticalComponent = 450f; + internal const float MinimumVerticalComponent = -1.8f; // ICamera surface. public Vector3 Position { get; private set; } @@ -106,12 +118,6 @@ public sealed class RetailChaseCamera : ICamera /// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow. public float PlayerTranslucency { get; private set; } - /// Clamp bounds carried over from legacy ChaseCamera. - public const float DistanceMin = 2f; - public const float DistanceMax = 40f; - public const float PitchMin = -0.7f; - public const float PitchMax = 1.4f; - // Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp // acclient_2013_pseudo_c.txt, 0x00456fcd–0x00457035). SnapEpsilon = 2 × // 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail @@ -311,27 +317,80 @@ public sealed class RetailChaseCamera : ICamera } /// - /// Adjust the camera distance (zoom) by a delta, clamped to - /// ... Mirrors - /// legacy ChaseCamera.AdjustDistance. + /// Apply one retail camera-adjustment unit to the viewer offset. + /// CameraSet::Farther/Closer (0x00458890/0x004586D0) + /// scale all three offset components multiplicatively by + /// 1 + adjustment * 0.2. Positive values move farther and + /// negative values move closer. Retail refuses the complete write when + /// any component or the 0.5 m near limit would be crossed; it does not + /// clamp an invented scalar distance. /// - public void AdjustDistance(float delta) + public void AdjustDistance(float adjustment) { ExitLookDownForAdjustment(); - ExitInHeadForAdjustment(); - Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); + if (!float.IsFinite(adjustment) || adjustment == 0f) + return; + + // 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) + { + _inHead = false; + SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp); + return; + } + + float scale = 1f + adjustment * OffsetScalePerAdjustment; + if (!(scale > 0f) || !float.IsFinite(scale)) + return; + + float candidateDistance = Distance * scale; + if (adjustment < 0f && !(candidateDistance > MinimumOffsetLength)) + return; + + TryWriteViewerOffset(candidateDistance, Pitch, preserveHorizontalSign: false); } /// - /// Adjust the camera pitch by a delta (radians), clamped to - /// ... Mirrors legacy - /// ChaseCamera.AdjustPitch. + /// Apply one retail camera-adjustment unit to the viewer-offset pitch. + /// CameraSet::Raise/Lower (0x00457980/0x00457CF0) + /// rotate the offset by eight degrees per unit while preserving its + /// length. A write that would cross the vertical axis or a component + /// limit is refused as a whole. /// - public void AdjustPitch(float delta) + public void AdjustPitch(float adjustment) { ExitLookDownForAdjustment(); - ExitInHeadForAdjustment(); - Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); + if (!float.IsFinite(adjustment) || adjustment == 0f) + return; + + if (_inHead) + { + _inHead = false; + SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp); + } + + float candidatePitch = Pitch + adjustment * OffsetAngleRadians; + TryWriteViewerOffset(Distance, candidatePitch, preserveHorizontalSign: true); + } + + /// + /// Rotate the viewer offset around local Z using retail's eight-degree + /// adjustment unit (CameraSet::Rotate @ 0x00458310). + /// + public void AdjustYaw(float adjustment) + { + ExitLookDownForAdjustment(); + if (!float.IsFinite(adjustment) || adjustment == 0f) + return; + + if (_inHead) + { + _inHead = false; + SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp); + } + + YawOffset += adjustment * OffsetAngleRadians; } public void SetRetailDefaultView() @@ -416,20 +475,52 @@ public sealed class RetailChaseCamera : ICamera RestoreLookDownView(); } - private void ExitInHeadForAdjustment() - { - if (!_inHead) - return; - _inHead = false; - Distance = DistanceMin; - } - private void SetViewerOffset(float back, float up) { Distance = MathF.Sqrt(back * back + up * up); Pitch = MathF.Atan2(up, back); } + private bool TryWriteViewerOffset( + float candidateDistance, + float candidatePitch, + bool preserveHorizontalSign) + { + if (!float.IsFinite(candidateDistance) + || !float.IsFinite(candidatePitch) + || !(candidateDistance > 0f)) + { + return false; + } + + float currentHorizontal = Distance * MathF.Cos(Pitch); + float candidateHorizontal = candidateDistance * MathF.Cos(candidatePitch); + if (preserveHorizontalSign + && ((currentHorizontal > 0f && candidateHorizontal < 0f) + || (currentHorizontal < 0f && candidateHorizontal > 0f))) + { + return false; + } + + // AC frame-local viewer_offset. Only magnitudes matter for Farther's + // X/Y limits, but including YawOffset is essential: at diagonal orbit + // angles retail can reach a longer boom before either component is 10. + float x = candidateHorizontal * MathF.Sin(YawOffset); + float y = -candidateHorizontal * MathF.Cos(YawOffset); + float z = candidateDistance * MathF.Sin(candidatePitch); + if (!(MathF.Abs(x) < MaximumHorizontalComponent) + || !(MathF.Abs(y) < MaximumHorizontalComponent) + || !(z < MaximumVerticalComponent) + || !(z > MinimumVerticalComponent)) + { + return false; + } + + Distance = candidateDistance; + Pitch = candidatePitch; + return true; + } + /// /// Public entry point for the mouse-input low-pass filter. Calls /// on each axis with shared state. diff --git a/src/AcDream.Core/Rendering/CameraDiagnostics.cs b/src/AcDream.Core/Rendering/CameraDiagnostics.cs index 02a8d448..0c8725d9 100644 --- a/src/AcDream.Core/Rendering/CameraDiagnostics.cs +++ b/src/AcDream.Core/Rendering/CameraDiagnostics.cs @@ -73,8 +73,9 @@ public static class CameraDiagnostics /// /// Per-second rate that held-key offset adjustments - /// (CameraZoomIn/Out, CameraRaise/Lower) integrate into the - /// camera's Distance / Pitch. Retail default 40.0. + /// (CameraZoomIn/Out, CameraRaise/Lower) feed into retail's + /// multiplicative zoom / eight-degree offset rotation. Retail + /// CameraManager::CameraManager @ 0x0045710A sets 40.0. /// public static float CameraAdjustmentSpeed { get; set; } = 40.0f; } diff --git a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs index bb2bd06f..9e2e0a7e 100644 --- a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs @@ -177,7 +177,7 @@ public sealed class CameraPointerInputControllerTests } [Fact] - public void ChaseMouseWheel_RetainsExtendedZoomOutRange() + public void ChaseMouseWheel_UsesRetailMultiplicativeZoomAndComponentLimit() { var fixture = Create([new RawSurface()]); var legacy = new ChaseCamera(); @@ -187,21 +187,24 @@ public sealed class CameraPointerInputControllerTests fixture.Chase.Retail = retail; fixture.Camera.EnterChaseMode(legacy, retail); - float before = CameraDiagnostics.UseRetailChaseCamera - ? retail.Distance - : legacy.Distance; - fixture.Owner.HandleScroll(InputAction.ScrollDown); - float afterOne = CameraDiagnostics.UseRetailChaseCamera - ? retail.Distance - : legacy.Distance; - for (int i = 0; i < 100; i++) + bool savedRetail = CameraDiagnostics.UseRetailChaseCamera; + try + { + CameraDiagnostics.UseRetailChaseCamera = true; + float before = retail.Distance; fixture.Owner.HandleScroll(InputAction.ScrollDown); - float afterMany = CameraDiagnostics.UseRetailChaseCamera - ? retail.Distance - : legacy.Distance; + float afterOne = retail.Distance; + for (int i = 0; i < 100; i++) + fixture.Owner.HandleScroll(InputAction.ScrollDown); - Assert.Equal(before + 0.8f, afterOne, 5); - Assert.Equal(40f, afterMany, 5); + Assert.Equal(before * (1f + 0.8f * RetailChaseCamera.OffsetScalePerAdjustment), afterOne, 5); + Assert.True(retail.Distance < 10.5f); + Assert.True(retail.Distance > 8f); + } + finally + { + CameraDiagnostics.UseRetailChaseCamera = savedRetail; + } } private static Fixture Create(IReadOnlyList surfaces) diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index 9cba7c08..7f052be6 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -537,14 +537,50 @@ public class RetailChaseCameraTests } [Fact] - public void AdjustDistance_ClampsToRange() + public void AdjustDistance_UsesRetailMultiplicativeScale() { var cam = new RetailChaseCamera { Distance = 5f }; - cam.AdjustDistance(-100f); - Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance); + cam.AdjustDistance(+1f); + Assert.Equal(6f, cam.Distance, 5); - cam.AdjustDistance(+200f); - Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance); + cam.AdjustDistance(-1f); + Assert.Equal(4.8f, cam.Distance, 5); + } + + [Fact] + public void AdjustDistance_RefusesWholeWriteAtRetailComponentAndNearLimits() + { + var far = new RetailChaseCamera + { + Distance = 9.9f, + Pitch = 0f, + YawOffset = 0f, + }; + far.AdjustDistance(+1f); + Assert.Equal(9.9f, far.Distance, 5); + + var near = new RetailChaseCamera + { + Distance = 0.55f, + Pitch = 0f, + }; + near.AdjustDistance(-1f); + Assert.Equal(0.55f, near.Distance, 5); + } + + [Fact] + public void AdjustDistance_DiagonalOrbitUsesIndependentRetailXAndYLimits() + { + var cam = new RetailChaseCamera + { + Distance = 12f, + Pitch = 0f, + YawOffset = MathF.PI / 4f, + }; + + cam.AdjustDistance(+0.5f); + + Assert.Equal(13.2f, cam.Distance, 5); } [Fact] @@ -579,18 +615,37 @@ public class RetailChaseCameraTests cam.AdjustDistance(1f); Assert.False(cam.IsInHead); - Assert.Equal(RetailChaseCamera.DistanceMin + 1f, cam.Distance); + Assert.Equal(MathF.Sqrt(0.6f * 0.6f + 0.5f * 0.5f), cam.Distance, 5); } [Fact] - public void AdjustPitch_ClampsToRange() + public void AdjustPitch_UsesRetailEightDegreeRotationAndPreservesLength() { - var cam = new RetailChaseCamera { Pitch = 0f }; - cam.AdjustPitch(-10f); - Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch); + var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; + cam.AdjustPitch(+1f); - cam.AdjustPitch(+10f); - Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch); + Assert.Equal(RetailChaseCamera.OffsetAngleRadians, cam.Pitch, 6); + Assert.Equal(5f, cam.Distance, 6); + } + + [Fact] + public void AdjustPitch_RefusesWholeWriteBelowRetailVerticalLimit() + { + var cam = new RetailChaseCamera { Distance = 3f, Pitch = -0.55f }; + float before = cam.Pitch; + + cam.AdjustPitch(-1f); + + Assert.Equal(before, cam.Pitch); + Assert.Equal(3f, cam.Distance); + } + + [Fact] + public void AdjustYaw_UsesRetailEightDegreeRotationUnit() + { + var cam = new RetailChaseCamera(); + cam.AdjustYaw(+1f); + Assert.Equal(RetailChaseCamera.OffsetAngleRadians, cam.YawOffset, 6); } // ── Camera collision (A8.F) ───────────────────────────────────────