// 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); } }