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]