From 319277a27b9657808cfacb51412f0288bc1fd127 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 29 May 2026 19:14:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8.F=20=E2=80=94=20Reta?= =?UTF-8?q?ilChaseCamera=20consumes=20the=20camera-collision=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Rendering/RetailChaseCamera.cs | 20 +++++- .../Rendering/RetailChaseCameraTests.cs | 62 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) 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); + } }