feat(render): Phase A8.F — RetailChaseCamera consumes the camera-collision probe

Add ICameraCollisionProbe? CollisionProbe { get; init; } to RetailChaseCamera.
Extend Update() with optional cellId/selfEntityId params (default 0) so all
existing callers compile unchanged. After the exponential-damping block (step 5)
and before publishing Position/View (step 6), sweep _dampedEye through the
probe when CameraDiagnostics.CollideCamera is true and a probe is wired in
(step 5b). The fade computation in step 7 then naturally uses the collided eye.
Null probe and cellId=0 both short-circuit cleanly. Three new xUnit tests
cover: probe-wired+flag-on publishes collided eye, flag-off skips probe,
null probe doesn't throw. All 30 RetailChaseCameraTests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 19:14:13 +02:00
parent fcea05f808
commit 319277a27b
2 changed files with 81 additions and 1 deletions

View file

@ -52,6 +52,14 @@ public sealed class RetailChaseCamera : ICamera
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
public float PivotHeight { get; set; } = 1.5f;
/// <summary>
/// Optional spring-arm collision probe. When set (and
/// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye
/// is swept from the head-pivot and stopped at the first wall. Null leaves
/// the eye uncollided (the default for tests and the legacy path).
/// </summary>
public ICameraCollisionProbe? CollisionProbe { get; init; }
/// <summary>Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.</summary>
public float PlayerTranslucency { get; private set; }
@ -89,7 +97,9 @@ public sealed class RetailChaseCamera : ICamera
Vector3 playerVelocity,
bool isOnGround,
Vector3 contactPlaneNormal,
float dt)
float dt,
uint cellId = 0,
uint selfEntityId = 0)
{
// 1. Push velocity into 5-frame ring, get average.
PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
@ -132,6 +142,14 @@ public sealed class RetailChaseCamera : ICamera
_dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
}
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
// (0x00453ce0) sweeps viewer_sphere from the head-pivot to the
// desired eye and uses the stopped position. Keeps the eye out of
// walls so the A8.F camera-cell + portal side-tests stay stable.
// A null probe or disabled flag leaves the eye unchanged.
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
_dampedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
// 6. Publish renderer surface.
Position = _dampedEye;
View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f));

View file

@ -445,4 +445,66 @@ public class RetailChaseCameraTests
cam.AdjustPitch(+10f);
Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch);
}
// ── Camera collision (A8.F) ───────────────────────────────────────
private sealed class FakeProbe : ICameraCollisionProbe
{
public int Calls;
public Vector3 ReturnEye;
public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
Calls++;
return ReturnEye;
}
}
[Fact]
public void Update_WithProbeAndFlagOn_PublishesCollidedEye()
{
CameraDiagnostics.CollideCamera = true;
var collided = new Vector3(1f, 2f, 3f);
var probe = new FakeProbe { ReturnEye = collided };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.True(probe.Calls >= 1);
Assert.Equal(collided, cam.Position);
}
[Fact]
public void Update_FlagOff_DoesNotConsultProbe()
{
CameraDiagnostics.CollideCamera = false;
var probe = new FakeProbe { ReturnEye = new Vector3(99f, 99f, 99f) };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.Equal(0, probe.Calls);
Assert.NotEqual(new Vector3(99f, 99f, 99f), cam.Position);
CameraDiagnostics.CollideCamera = true; // reset
}
[Fact]
public void Update_NullProbe_DoesNotThrow()
{
CameraDiagnostics.CollideCamera = true;
var cam = new RetailChaseCamera { CollisionProbe = null };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.NotEqual(default, cam.View);
}
}