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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-18 19:56:24 +02:00
parent 0c1403f2e6
commit e5a5916679
5 changed files with 187 additions and 58 deletions

View file

@ -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; }
/// <summary>
/// The renderer-facing active camera. In chase mode, returns
/// <see cref="RetailChase"/> when
/// <see cref="CameraDiagnostics.UseRetailChaseCamera"/> is true,
/// otherwise <see cref="Chase"/>. In fly mode returns
/// <see cref="Fly"/>; default is <see cref="Orbit"/>.
/// </summary>
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<bool>? 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)
/// <summary>
/// Enter chase mode with both candidate cameras. Both are held;
/// <see cref="Active"/> picks based on
/// <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.
/// </summary>
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;
}
}

View file

@ -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

View file

@ -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);