// src/AcDream.App/Rendering/CameraController.cs
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
public sealed class CameraController
{
internal readonly record struct CameraState(
int ModeCode,
ChaseCamera? Chase,
RetailChaseCamera? RetailChase);
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. Both the legacy and retail
/// chase cameras are held simultaneously so that flipping
/// takes effect
/// on the very next access to this property — no re-entry required,
/// no notification mechanism, no stale state.
///
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;
/// Retail m_fGameFOV (#389): the user-facing FOV the Config
/// slider sets in degrees, default 90°. The APPLIED per-camera vertical FOV
/// is derived from it and the current aspect via
/// — see that class's
/// doc for the decomp anchors.
public float GameFovRadians { get; private set; } = RetailFieldOfView.DefaultGameFovRadians;
private float _aspect = 16f / 9f;
public CameraController(OrbitCamera orbit, FlyCamera fly)
{
Orbit = orbit;
Fly = fly;
ApplyProjection();
}
public void ToggleFly()
{
_mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
ModeChanged?.Invoke(IsFlyMode);
}
///
/// Store both cameras simultaneously; picks
/// between them per-read via the flag — no re-entry needed on flip.
///
public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
{
Chase = legacy;
RetailChase = retail;
// #389: freshly attached chase cameras carry their own initialiser
// aspect/FOV — bring them onto the controller's current aspect +
// smartbox FOV immediately (before this, a resize while chase cameras
// were attached never reached them at all: SetAspect only updated
// Orbit/Fly, so the world rendered at the creation-time aspect
// stretched onto the new viewport — the 2026-08-13 "squished" gate
// report's second mechanism).
ApplyProjection();
_mode = Mode.Chase;
ModeChanged?.Invoke(IsChaseMode);
}
public void ExitChaseMode()
{
bool wasChaseMode = IsChaseMode;
Chase = null;
RetailChase = null;
if (_mode == Mode.Orbit)
return;
_mode = Mode.Fly;
if (wasChaseMode)
ModeChanged?.Invoke(IsFlyMode);
}
public void SetAspect(float aspect)
{
_aspect = aspect;
ApplyProjection();
}
/// Sets retail's m_fGameFOV (#389) and recomputes every
/// camera's applied FOV. Driven by the Config tab's Field of View value
/// through RuntimeSettingsStartupTargets.ApplyFieldOfView.
public void SetGameFov(float gameFovRadians)
{
GameFovRadians = gameFovRadians;
ApplyProjection();
}
/// Pushes the current aspect + smartbox-derived vertical FOV onto
/// every attached camera. When the law's result fails retail's
/// SetFOVRad gate (see ),
/// the cameras keep their previous FOV — retail's exact behavior — but the
/// aspect still propagates (retail's viewport aspect is likewise updated
/// independently of the FOV gate).
private void ApplyProjection()
{
bool fovAccepted = RetailFieldOfView.TryAppliedVerticalFov(
GameFovRadians, _aspect, out float fovY);
Orbit.Aspect = _aspect;
Fly.Aspect = _aspect;
if (Chase is { } chase) chase.Aspect = _aspect;
if (RetailChase is { } retailChase) retailChase.Aspect = _aspect;
if (!fovAccepted)
return;
Orbit.FovY = fovY;
Fly.FovY = fovY;
if (Chase is { } chaseFov) chaseFov.FovY = fovY;
if (RetailChase is { } retailChaseFov) retailChaseFov.FovY = fovY;
}
internal CameraState CaptureState() =>
new((int)_mode, Chase, RetailChase);
///
/// Restores a previously captured camera lifetime before notifying
/// subscribers. State remains restored even when a subscriber throws.
///
internal void RestoreState(CameraState state)
{
if (state.ModeCode < (int)Mode.Orbit || state.ModeCode > (int)Mode.Chase)
throw new ArgumentOutOfRangeException(nameof(state));
Chase = state.Chase;
RetailChase = state.RetailChase;
// #389: the aspect/game-FOV may have changed while these cameras were
// captured (a resize during a reset) — reconverge them before anyone
// reads a projection.
ApplyProjection();
_mode = (Mode)state.ModeCode;
ModeChanged?.Invoke(IsFlyMode || IsChaseMode);
}
}