From e5a5916679449a4fc9811364cd6b2bdb0ad34fd2 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 18 May 2026 19:56:24 +0200 Subject: [PATCH] feat(camera): CameraController carries both legacy + retail chase cams EnterChaseMode now takes (ChaseCamera, RetailChaseCamera); Active consults CameraDiagnostics.UseRetailChaseCamera to pick which to expose. Flag flip at runtime swaps cameras instantly (both are kept warm). GameWindow's two EnterChaseMode call sites get a temporary stub RetailChaseCamera; Task 7 wires proper construction + per-frame updates. Also folds in two minor cleanups from the Task 3 code review: - Update() discards the unused `right` axis from BuildBasis (no caller in the chase-cam math; viewer_offset.X is always 0) - The three CameraDiagnostics-mutating integration tests now save and restore the static state in try/finally to avoid ordering-dependent contamination Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/CameraController.cs | 64 ++++++++--- src/AcDream.App/Rendering/GameWindow.cs | 4 +- .../Rendering/RetailChaseCamera.cs | 2 +- .../Rendering/CameraControllerTests.cs | 67 +++++++++++ .../Rendering/RetailChaseCameraTests.cs | 108 +++++++++++------- 5 files changed, 187 insertions(+), 58 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs index c061450..1673ba0 100644 --- a/src/AcDream.App/Rendering/CameraController.cs +++ b/src/AcDream.App/Rendering/CameraController.cs @@ -1,47 +1,81 @@ // src/AcDream.App/Rendering/CameraController.cs +using AcDream.Core.Rendering; + namespace AcDream.App.Rendering; public sealed class CameraController { - public OrbitCamera Orbit { get; } - public FlyCamera Fly { get; } - public ChaseCamera? Chase { get; private set; } - public ICamera Active { get; private set; } - public bool IsFlyMode => Active == Fly; - public bool IsChaseMode => Chase is not null && Active == Chase; + public OrbitCamera Orbit { get; } + public FlyCamera Fly { get; } + public ChaseCamera? Chase { get; private set; } + public RetailChaseCamera? RetailChase { get; private set; } + + /// + /// The renderer-facing active camera. In chase mode, returns + /// when + /// is true, + /// otherwise . In fly mode returns + /// ; default is . + /// + public ICamera Active + { + get + { + if (_mode == Mode.Fly) return Fly; + if (_mode == Mode.Chase) + { + if (CameraDiagnostics.UseRetailChaseCamera && RetailChase is not null) + return RetailChase; + if (Chase is not null) return Chase; + } + return Orbit; + } + } + + public bool IsFlyMode => _mode == Mode.Fly; + public bool IsChaseMode => _mode == Mode.Chase; public event Action? ModeChanged; + private enum Mode { Orbit, Fly, Chase } + private Mode _mode = Mode.Orbit; + public CameraController(OrbitCamera orbit, FlyCamera fly) { Orbit = orbit; - Fly = fly; - Active = orbit; + Fly = fly; } public void ToggleFly() { - Active = IsFlyMode ? (ICamera)Orbit : Fly; + _mode = IsFlyMode ? Mode.Orbit : Mode.Fly; ModeChanged?.Invoke(IsFlyMode); } - public void EnterChaseMode(ChaseCamera chase) + /// + /// Enter chase mode with both candidate cameras. Both are held; + /// picks based on + /// . + /// + public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail) { - Chase = chase; - Active = chase; + Chase = legacy; + RetailChase = retail; + _mode = Mode.Chase; ModeChanged?.Invoke(IsChaseMode); } public void ExitChaseMode() { - Active = Fly; - Chase = null; + Chase = null; + RetailChase = null; + _mode = Mode.Fly; ModeChanged?.Invoke(IsFlyMode); } public void SetAspect(float aspect) { Orbit.Aspect = aspect; - Fly.Aspect = aspect; + Fly.Aspect = aspect; } } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e35a85e..2d219dc 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -9856,7 +9856,7 @@ public sealed class GameWindow : IDisposable && _playerMode && _chaseCamera is not null) { - _cameraController.EnterChaseMode(_chaseCamera); + _cameraController.EnterChaseMode(_chaseCamera, new RetailChaseCamera { Aspect = _chaseCamera.Aspect }); return; } _cameraController.ToggleFly(); @@ -9962,7 +9962,7 @@ public sealed class GameWindow : IDisposable // accumulated value from a previous session doesn't leak // into a future code path that re-enables mouse-yaw. _playerMouseDeltaX = 0f; - _cameraController?.EnterChaseMode(_chaseCamera); + _cameraController?.EnterChaseMode(_chaseCamera, new RetailChaseCamera { Aspect = _chaseCamera.Aspect }); // K-fix1 (2026-04-26): latch the "we have entered chase at least // once" flag so the live-mode pre-login render gate stops // suppressing the scene. From here on, the orbit camera (if the diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index fd6b4eb..1ee8750 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -93,7 +93,7 @@ public sealed class RetailChaseCamera : ICamera Vector3 heading = ComputeHeading(avgVel, playerYaw + YawOffset, CameraDiagnostics.AlignToSlope); // 3. Orthonormal heading-frame basis. - var (forward, right, up) = BuildBasis(heading); + var (forward, _, up) = BuildBasis(heading); // 4. Target pose. Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight); diff --git a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs new file mode 100644 index 0000000..d16b879 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs @@ -0,0 +1,67 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class CameraControllerTests +{ + private static (CameraController ctl, ChaseCamera legacy, RetailChaseCamera retail) MakeChaseFixture() + { + var orbit = new OrbitCamera(); + var fly = new FlyCamera(); + var ctl = new CameraController(orbit, fly); + var legacy = new ChaseCamera(); + var retail = new RetailChaseCamera(); + ctl.EnterChaseMode(legacy, retail); + return (ctl, legacy, retail); + } + + [Fact] + public void ChaseMode_WhenFlagOff_ActiveIsLegacy() + { + CameraDiagnostics.UseRetailChaseCamera = false; + var (ctl, legacy, _) = MakeChaseFixture(); + Assert.Same(legacy, ctl.Active); + Assert.True(ctl.IsChaseMode); + } + + [Fact] + public void ChaseMode_WhenFlagOn_ActiveIsRetail() + { + CameraDiagnostics.UseRetailChaseCamera = true; + var (ctl, _, retail) = MakeChaseFixture(); + Assert.Same(retail, ctl.Active); + Assert.True(ctl.IsChaseMode); + + // Reset. + CameraDiagnostics.UseRetailChaseCamera = false; + } + + [Fact] + public void ChaseMode_FlagFlipped_ActiveSwaps() + { + CameraDiagnostics.UseRetailChaseCamera = false; + var (ctl, legacy, retail) = MakeChaseFixture(); + Assert.Same(legacy, ctl.Active); + + CameraDiagnostics.UseRetailChaseCamera = true; + Assert.Same(retail, ctl.Active); + + CameraDiagnostics.UseRetailChaseCamera = false; + Assert.Same(legacy, ctl.Active); + } + + [Fact] + public void ExitChaseMode_ClearsBothCameras() + { + CameraDiagnostics.UseRetailChaseCamera = false; + var (ctl, _, _) = MakeChaseFixture(); + ctl.ExitChaseMode(); + + Assert.Null(ctl.Chase); + Assert.Null(ctl.RetailChase); + Assert.False(ctl.IsChaseMode); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index bc9774f..4a1ef00 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -273,62 +273,90 @@ public class RetailChaseCameraTests [Fact] public void FirstUpdate_SnapsToTarget() { - var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; - CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec + bool savedAlign = CameraDiagnostics.AlignToSlope; + try + { + var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; + CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec - cam.Update( - playerPosition: new Vector3(10f, 20f, 30f), - playerYaw: 0f, // forward = +X - playerVelocity: Vector3.Zero, - dt: 1f / 60f); + cam.Update( + playerPosition: new Vector3(10f, 20f, 30f), + playerYaw: 0f, // forward = +X + playerVelocity: Vector3.Zero, + dt: 1f / 60f); - // Expected target eye: - // pivot = (10, 20, 30+1.5=31.5) - // forward (yaw=0)= (1, 0, 0) - // right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0) - // up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1) - // viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0) - // eye = pivot + right*0 + forward*-5 + up*0 - // = (10 - 5, 20, 31.5) = (5, 20, 31.5) - Assert.Equal(5f, cam.Position.X, 4); - Assert.Equal(20f, cam.Position.Y, 4); - Assert.Equal(31.5f, cam.Position.Z, 4); + // Expected target eye: + // pivot = (10, 20, 30+1.5=31.5) + // forward (yaw=0)= (1, 0, 0) + // right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0) + // up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1) + // viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0) + // eye = pivot + right*0 + forward*-5 + up*0 + // = (10 - 5, 20, 31.5) = (5, 20, 31.5) + Assert.Equal(5f, cam.Position.X, 4); + Assert.Equal(20f, cam.Position.Y, 4); + Assert.Equal(31.5f, cam.Position.Z, 4); + } + finally + { + CameraDiagnostics.AlignToSlope = savedAlign; + } } [Fact] public void SecondUpdate_LerpsTowardTarget() { - var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; - CameraDiagnostics.AlignToSlope = false; - CameraDiagnostics.TranslationStiffness = 0.45f; - CameraDiagnostics.RotationStiffness = 0.45f; + bool savedAlign = CameraDiagnostics.AlignToSlope; + float savedTranslation = CameraDiagnostics.TranslationStiffness; + float savedRotation = CameraDiagnostics.RotationStiffness; + try + { + var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; + CameraDiagnostics.AlignToSlope = false; + CameraDiagnostics.TranslationStiffness = 0.45f; + CameraDiagnostics.RotationStiffness = 0.45f; - // First update at origin: dampedEye = (-5, 0, 1.5). - cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); - var firstEye = cam.Position; + // First update at origin: dampedEye = (-5, 0, 1.5). + cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); + var firstEye = cam.Position; - // Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5). - // alpha = 0.45 * (1/60) * 10 = 0.075. - // New eye = firstEye + 0.075 * (target - firstEye) - // = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5)) - // = (-5,0,1.5) + 0.075 * (10,0,0) - // = (-4.25, 0, 1.5) - cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); + // Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5). + // alpha = 0.45 * (1/60) * 10 = 0.075. + // New eye = firstEye + 0.075 * (target - firstEye) + // = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5)) + // = (-5,0,1.5) + 0.075 * (10,0,0) + // = (-4.25, 0, 1.5) + cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); - Assert.Equal(-4.25f, cam.Position.X, 3); - Assert.Equal(0f, cam.Position.Y, 4); - Assert.Equal(1.5f, cam.Position.Z, 4); + Assert.Equal(-4.25f, cam.Position.X, 3); + Assert.Equal(0f, cam.Position.Y, 4); + Assert.Equal(1.5f, cam.Position.Z, 4); + } + finally + { + CameraDiagnostics.AlignToSlope = savedAlign; + CameraDiagnostics.TranslationStiffness = savedTranslation; + CameraDiagnostics.RotationStiffness = savedRotation; + } } [Fact] public void Translucency_PropertyReflectsCurrentDampedDistance() { - var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f }; - CameraDiagnostics.AlignToSlope = false; + bool savedAlign = CameraDiagnostics.AlignToSlope; + try + { + var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f }; + CameraDiagnostics.AlignToSlope = false; - // Far from pivot — translucency should be 0. - cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); - Assert.Equal(0f, cam.PlayerTranslucency, 5); + // Far from pivot — translucency should be 0. + cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); + Assert.Equal(0f, cam.PlayerTranslucency, 5); + } + finally + { + CameraDiagnostics.AlignToSlope = savedAlign; + } } [Fact]