Original symptom: jumping made the camera swing around the player
vertically — the basis tilted up/down with the player's Z velocity.
Root cause: ComputeHeading used the raw 3D velocity vector as the
heading direction. During a jump, velocity has a substantial Z
component (vy ≈ jump speed), and `normalize((vx, vy, vz))` produced
a heading pointing up. The basis tilted accordingly and the camera
went under/over the player.
Retail's actual ALIGN_WITH_PLANE algorithm (decomp at
acclient_2013_pseudo_c.txt:95644-95795) is different:
1. Velocity is only used as a gate. If |vx| AND |vy| > epsilon
(player is moving in XY), proceed; otherwise fall back to the
LOOK_IN_DIRECTION path (player's facing direction unchanged).
2. The base heading is `localtoglobalvec(player, (0, 1, 0))` —
the player's local +Y axis in world space, which in our
convention is `(cos yaw, sin yaw, 0)`.
3. Pick a surface normal:
grounded: contact_plane.N
airborne: (0, 0, 1) [world up]
4. Project the base heading onto the plane perpendicular to that
normal: projected = forward - normal * dot(forward, normal).
5. Normalize. Fall back to the base if projection collapses.
Behaviorally:
* Standing jump (vx≈0, vy≈0): gate fails → base heading. Camera
doesn't move with the jump.
* Running jump (vx, vy, vz all nonzero, airborne): projects onto
world up → no-op since base is already horizontal. Camera basis
stays horizontal; player visibly rises in frame.
* Walking uphill (grounded, slope normal tilted): projection
adds a Z component matching the slope angle. Camera basis tilts
with the terrain.
* Walking on flat ground: projection is a no-op. Camera basis
horizontal.
Surface changes:
* RetailChaseCamera.ComputeHeading gains `isOnGround` and
`contactPlaneNormal` parameters.
* RetailChaseCamera.Update gains the same two parameters and
threads them through.
* GameWindow's two Update call sites pass `result.IsOnGround` and
`_playerController.ContactPlane.Normal` (already exposed on
PlayerMovementController — no plumbing change there).
* Tests: 2 existing heading tests reshaped (Moving* and Uphill);
2 new tests added (AirborneJumping straight-up + running-jump);
1 renamed (SlopeAlignDisabled). Net 25 → 27 tests in
RetailChaseCameraTests; full AcDream.App.Tests: 39 → 41.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four new InputAction entries for held-key offset integration
(CameraZoomIn/Out, CameraRaise/Lower; default unbound). Six new
DebugVM mirror properties forwarding to CameraDiagnostics so the
upcoming "Chase camera" DebugPanel section can drive them live.
Also folds in four small cleanups from the Task 4 code review:
- Both CameraDiagnostics-mutating tests in CameraControllerTests now
use try/finally save/restore (consistency with Task-3 follow-up B)
- Drop unused `using System.Numerics` from CameraControllerTests
- Reword the XML doc on CameraController.Active to explain WHY both
cameras are held simultaneously (flag flip takes effect on the
next Active access without re-entry) rather than restating the
getter logic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Adds the per-frame Update(playerPos, yaw, velocity, dt) entrypoint
that composes the math primitives into a renderable View matrix +
PlayerTranslucency. State: 5-frame velocity ring, damped eye + forward
unit vector, first-frame snap flag, mouse-filter shared state.
Public surface: Distance/Pitch/YawOffset/PivotHeight tunables,
AdjustDistance/Pitch (with clamps), FilterMouseDelta entry, View +
Position + PlayerTranslucency outputs. 5 new integration tests, all
pass; total RetailChaseCamera test count 25.
Also folds in two minor cleanups from the Task 2 code review:
- AverageVelocity uses ring.Length instead of hardcoded 5
- Basis_NearVerticalHeading test asserts orthogonality of right & up
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven pure-math helpers in the new RetailChaseCamera class:
ComputeHeading (slope-align with flat fallback), BuildBasis (heading
→ orthonormal frame, near-vertical fallback), PushVelocity +
AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's
stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency
(linear ramp 0.20..0.45 m). 20 tests, all pass. State machine + Update()
land in the next commit.
Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>