diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 25fa2a9..0470311 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -52,6 +52,14 @@ public sealed class RetailChaseCamera : ICamera /// Height of look-at anchor above the player's feet (m). Retail default 1.5. public float PivotHeight { get; set; } = 1.5f; + /// + /// Optional spring-arm collision probe. When set (and + /// 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). + /// + public ICameraCollisionProbe? CollisionProbe { get; init; } + /// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow. 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)); diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index ed7bef4..234f3a7 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -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); + } }