docs(render): Phase A8.F — camera-collision implementation plan
Bite-sized TDD plan for the swept-sphere camera collision: CollideCamera flag, ICameraCollisionProbe + PhysicsCameraCollisionProbe (reuses ResolveWithTransition), RetailChaseCamera slot-in, GameWindow wiring, Camera-menu toggle, visual acceptance. Also refines the spec from planning findings: the InitPath +radius sphere-center offset (ToSpherePath/FromSpherePath z-shift) and the deterministic probe test scope (z-offset round-trip + cellId==0 guard; collision correctness rides the existing sweep suite + visual). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9bdd50287b
commit
77a6331ecd
2 changed files with 643 additions and 11 deletions
623
docs/superpowers/plans/2026-05-29-a8f-camera-collision.md
Normal file
623
docs/superpowers/plans/2026-05-29-a8f-camera-collision.md
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
# A8.F Swept-Sphere Camera Collision — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stop the 3rd-person camera eye from clipping through walls by sweeping a 0.3 m collision sphere from the head-pivot to the desired eye and publishing the stopped position — porting retail's `SmartBox::update_viewer` spring arm. This stabilizes the A8.F indoor-visibility decisions (which key off the eye) and fixes the flap / missing-wall symptoms.
|
||||
|
||||
**Architecture:** A narrow `ICameraCollisionProbe` is injected into `RetailChaseCamera`. After the camera damps the desired eye and before it publishes, it asks the probe to sweep `pivot→eye`. The concrete `PhysicsCameraCollisionProbe` wraps the existing `PhysicsEngine.ResolveWithTransition`, which already collides against both indoor cell walls (`FindEnvCollisions`) and outdoor/baked GfxObj shells (`FindObjCollisions`). Gated by `CameraDiagnostics.CollideCamera` (default ON).
|
||||
|
||||
**Tech Stack:** C# / .NET 10, Silk.NET, xUnit. Spec: `docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md`.
|
||||
|
||||
**Reference (read before starting):**
|
||||
- Spec: `docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md`
|
||||
- Camera: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (eye `:113`, damp `:131`, publish `:136`, fade `:367`)
|
||||
- Engine: `src/AcDream.Core/Physics/PhysicsEngine.cs:589` (`ResolveWithTransition`, returns `sp.CheckPos` as `.Position` at `:846`/`:865`)
|
||||
- Sphere convention: `src/AcDream.Core/Physics/TransitionTypes.cs:517-547` (`InitPath` sets `LocalSphere[0].Origin = (0,0,radius)`)
|
||||
- Player self-skip: `src/AcDream.App/Input/PlayerMovementController.cs` (`CellId` `:133`, `LocalEntityId` `:144`)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `CameraDiagnostics.CollideCamera` flag
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Rendering/CameraDiagnostics.cs`
|
||||
- Test: `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `CameraDiagnosticsTests.cs` (inside the `CameraDiagnosticsTests` class):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void CollideCamera_DefaultOn_AndPersistsRuntimeChanges()
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true;
|
||||
Assert.True(CameraDiagnostics.CollideCamera);
|
||||
|
||||
CameraDiagnostics.CollideCamera = false;
|
||||
Assert.False(CameraDiagnostics.CollideCamera);
|
||||
|
||||
CameraDiagnostics.CollideCamera = true; // reset so other tests aren't poisoned
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests.CollideCamera_DefaultOn"`
|
||||
Expected: FAIL — compile error, `CollideCamera` does not exist.
|
||||
|
||||
- [ ] **Step 3: Add the property**
|
||||
|
||||
In `CameraDiagnostics.cs`, add after the `UseRetailChaseCamera` property (after line 28):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// When true (default), the chase camera sweeps a 0.3 m collision
|
||||
/// sphere from the head-pivot to the desired eye and stops it at the
|
||||
/// first wall (retail <c>SmartBox::update_viewer</c> spring arm), so
|
||||
/// the eye never sits behind/inside geometry. Initial state from
|
||||
/// <c>ACDREAM_CAMERA_COLLIDE</c>; default-on if unset, off only when
|
||||
/// explicitly set to <c>"0"</c>.
|
||||
/// </summary>
|
||||
public static bool CollideCamera { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_CAMERA_COLLIDE") != "0";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests.CollideCamera_DefaultOn"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.Core/Rendering/CameraDiagnostics.cs tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs
|
||||
git commit -m "feat(render): Phase A8.F — add CameraDiagnostics.CollideCamera flag (default on)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Camera-collision probe interface + `PhysicsCameraCollisionProbe`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/Rendering/ICameraCollisionProbe.cs`
|
||||
- Create: `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs`
|
||||
- Test: `tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class PhysicsCameraCollisionProbeTests
|
||||
{
|
||||
// The probe must convert the desired eye path (where the SPHERE CENTER
|
||||
// should travel) into the foot-capsule path InitPath expects (which offsets
|
||||
// sphere0 up by radius), then invert it on the result. Verify the round trip.
|
||||
[Fact]
|
||||
public void SpherePathOffset_RoundTrips()
|
||||
{
|
||||
var p = new Vector3(10f, 20f, 30f);
|
||||
const float r = 0.3f;
|
||||
|
||||
var path = PhysicsCameraCollisionProbe.ToSpherePath(p, r);
|
||||
Assert.Equal(p.Z - r, path.Z, 5);
|
||||
Assert.Equal(p.X, path.X, 5);
|
||||
Assert.Equal(p.Y, path.Y, 5);
|
||||
|
||||
var back = PhysicsCameraCollisionProbe.FromSpherePath(path, r);
|
||||
Assert.Equal(p.X, back.X, 5);
|
||||
Assert.Equal(p.Y, back.Y, 5);
|
||||
Assert.Equal(p.Z, back.Z, 5);
|
||||
}
|
||||
|
||||
// cellId == 0 means "no starting cell" — the probe must short-circuit and
|
||||
// return the desired eye without touching the engine.
|
||||
[Fact]
|
||||
public void SweepEye_NoStartingCell_ReturnsDesiredEyeUnchanged()
|
||||
{
|
||||
var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
|
||||
var pivot = new Vector3(0f, 0f, 1.5f);
|
||||
var eye = new Vector3(-2f, 0f, 2.2f);
|
||||
|
||||
var result = probe.SweepEye(pivot, eye, cellId: 0, selfEntityId: 0);
|
||||
|
||||
Assert.Equal(eye, result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PhysicsCameraCollisionProbeTests"`
|
||||
Expected: FAIL — `ICameraCollisionProbe` / `PhysicsCameraCollisionProbe` do not exist.
|
||||
|
||||
- [ ] **Step 3: Create the interface**
|
||||
|
||||
Create `src/AcDream.App/Rendering/ICameraCollisionProbe.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Sweeps a small sphere from the camera pivot (player head) toward the
|
||||
/// desired eye and returns the stopped (non-penetrating) eye. The seam that
|
||||
/// lets <see cref="RetailChaseCamera"/> collide its eye without depending on
|
||||
/// the physics engine directly (and stay unit-testable with a fake).
|
||||
/// </summary>
|
||||
public interface ICameraCollisionProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Roll a collision sphere from <paramref name="pivot"/> to
|
||||
/// <paramref name="desiredEye"/>; return the position it reaches without
|
||||
/// penetrating geometry. Returns <paramref name="desiredEye"/> unchanged
|
||||
/// when nothing blocks the path or when <paramref name="cellId"/> is 0.
|
||||
/// </summary>
|
||||
Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the implementation**
|
||||
|
||||
Create `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ICameraCollisionProbe"/> backed by the player's swept-sphere
|
||||
/// engine. Ports retail's <c>SmartBox::update_viewer</c> (0x00453ce0): sweep
|
||||
/// the 0.3 m <c>viewer_sphere</c> from the head-pivot to the desired eye via a
|
||||
/// <c>CTransition</c> and use the stopped position. Reusing
|
||||
/// <see cref="PhysicsEngine.ResolveWithTransition"/> collides against indoor
|
||||
/// cell walls (<c>FindEnvCollisions</c>) AND outdoor/baked GfxObj shells
|
||||
/// (<c>FindObjCollisions</c>) in one faithful path.
|
||||
/// </summary>
|
||||
public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
|
||||
{
|
||||
/// <summary>Retail <c>viewer_sphere</c> radius (acclient :93314).</summary>
|
||||
public const float ViewerSphereRadius = 0.3f;
|
||||
|
||||
private readonly PhysicsEngine _physics;
|
||||
|
||||
public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics;
|
||||
|
||||
public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
|
||||
{
|
||||
// No starting cell → nothing to sweep against; keep the desired eye.
|
||||
if (cellId == 0) return desiredEye;
|
||||
|
||||
// SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius)
|
||||
// (the player foot-capsule convention). Retail's viewer_sphere center is
|
||||
// (0,0,0), so shift the path DOWN by the radius to make the SPHERE CENTER
|
||||
// travel pivot→eye, then add it back to the swept stop position.
|
||||
Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius);
|
||||
Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius);
|
||||
|
||||
var r = _physics.ResolveWithTransition(
|
||||
currentPos: begin,
|
||||
targetPos: end,
|
||||
cellId: cellId,
|
||||
sphereRadius: ViewerSphereRadius,
|
||||
sphereHeight: 0f, // single sphere (no head sphere)
|
||||
stepUpHeight: 0f, // no step-up for a camera
|
||||
stepDownHeight: 0f, // no step-down / ground snap
|
||||
isOnGround: false, // no contact-plane / walkable semantics
|
||||
body: null, // no cross-frame persistence
|
||||
moverFlags: ObjectInfoState.None, // all targets collide; also keeps
|
||||
// camera sweeps out of the #98
|
||||
// IsPlayer capture filter
|
||||
movingEntityId: selfEntityId); // skip the player's own ShadowEntry
|
||||
|
||||
return FromSpherePath(r.Position, ViewerSphereRadius);
|
||||
}
|
||||
|
||||
/// <summary>Eye/pivot point → InitPath path point (subtract the sphere-center offset).</summary>
|
||||
internal static Vector3 ToSpherePath(Vector3 spherePoint, float radius)
|
||||
=> spherePoint - new Vector3(0f, 0f, radius);
|
||||
|
||||
/// <summary>InitPath path point → eye point (add the sphere-center offset back).</summary>
|
||||
internal static Vector3 FromSpherePath(Vector3 pathPoint, float radius)
|
||||
=> pathPoint + new Vector3(0f, 0f, radius);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PhysicsCameraCollisionProbeTests"`
|
||||
Expected: PASS (both tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/ICameraCollisionProbe.cs src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs tests/AcDream.App.Tests/Rendering/PhysicsCameraCollisionProbeTests.cs
|
||||
git commit -m "feat(render): Phase A8.F — PhysicsCameraCollisionProbe (swept-sphere eye via ResolveWithTransition)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire the probe into `RetailChaseCamera`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (property, `Update` signature, sweep call)
|
||||
- Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `RetailChaseCameraTests.cs` (inside the class). These need a fake probe and exercise `Update`:
|
||||
|
||||
```csharp
|
||||
// ── 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 };
|
||||
|
||||
// Should run with no collision and publish a valid view.
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests.Update_"`
|
||||
Expected: FAIL — `CollisionProbe` property and the `cellId`/`selfEntityId` `Update` parameters do not exist.
|
||||
|
||||
- [ ] **Step 3: Add the `CollisionProbe` property**
|
||||
|
||||
In `RetailChaseCamera.cs`, add to the public tunables region (after the `PivotHeight` property, around line 53):
|
||||
|
||||
```csharp
|
||||
/// <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; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend the `Update` signature**
|
||||
|
||||
In `RetailChaseCamera.cs`, change the `Update` signature (line 86-92) to add two optional params at the end:
|
||||
|
||||
```csharp
|
||||
public void Update(
|
||||
Vector3 playerPosition,
|
||||
float playerYaw,
|
||||
Vector3 playerVelocity,
|
||||
bool isOnGround,
|
||||
Vector3 contactPlaneNormal,
|
||||
float dt,
|
||||
uint cellId = 0,
|
||||
uint selfEntityId = 0)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Insert the sweep between damp and publish**
|
||||
|
||||
In `RetailChaseCamera.cs`, between the end of the damping block (line 133 `}`) and the `// 6. Publish renderer surface.` comment (line 135), insert:
|
||||
|
||||
```csharp
|
||||
|
||||
// 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);
|
||||
```
|
||||
|
||||
(The fade at step 7, line 140, already reads `_dampedEye`, so it now uses the collided eye automatically.)
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"`
|
||||
Expected: PASS (the new `Update_*` tests plus all existing `Heading_*` / `BuildBasis_*` tests).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
|
||||
git commit -m "feat(render): Phase A8.F — RetailChaseCamera consumes the camera-collision probe"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire the probe in `GameWindow`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (two camera constructions, one `Update` call)
|
||||
|
||||
No unit test — `GameWindow` wiring is verified by build + the visual acceptance in Task 7.
|
||||
|
||||
- [ ] **Step 1: Inject the probe at the first construction site**
|
||||
|
||||
In `GameWindow.cs`, the construction around line 10693 currently reads:
|
||||
|
||||
```csharp
|
||||
_retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
|
||||
{
|
||||
Aspect = _chaseCamera.Aspect,
|
||||
};
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```csharp
|
||||
_retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
|
||||
{
|
||||
Aspect = _chaseCamera.Aspect,
|
||||
CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Inject the probe at the second construction site**
|
||||
|
||||
In `GameWindow.cs`, the construction around line 10826 currently reads:
|
||||
|
||||
```csharp
|
||||
_retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
|
||||
{
|
||||
Aspect = _window!.Size.X / (float)_window.Size.Y,
|
||||
};
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```csharp
|
||||
_retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
|
||||
{
|
||||
Aspect = _window!.Size.X / (float)_window.Size.Y,
|
||||
CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass cell + self-entity into the per-frame `Update`**
|
||||
|
||||
In `GameWindow.cs`, the camera update around line 6862 currently ends with `dt: (float)dt);`. Change the call to:
|
||||
|
||||
```csharp
|
||||
_retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
|
||||
playerVelocity: _playerController.BodyVelocity,
|
||||
isOnGround: result.IsOnGround,
|
||||
contactPlaneNormal: _playerController.ContactPlane.Normal,
|
||||
dt: (float)dt,
|
||||
cellId: _playerController.CellId,
|
||||
selfEntityId: _playerController.LocalEntityId);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify the wiring compiles**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: build succeeds (0 errors).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(render): Phase A8.F — wire camera-collision probe + cell/self id into GameWindow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add the live-toggle menu item
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `Camera` ImGui menu, ~line 7934)
|
||||
|
||||
- [ ] **Step 1: Add the checkbox menu item**
|
||||
|
||||
In `GameWindow.cs`, the `Camera` menu (around line 7934) currently reads:
|
||||
|
||||
```csharp
|
||||
if (ImGuiNET.ImGui.BeginMenu("Camera"))
|
||||
{
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
string flyLabel = _cameraController.IsFlyMode
|
||||
? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
|
||||
if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
|
||||
ToggleFlyOrChase();
|
||||
}
|
||||
ImGuiNET.ImGui.EndMenu();
|
||||
}
|
||||
```
|
||||
|
||||
Insert the toggle before `ImGuiNET.ImGui.EndMenu();`:
|
||||
|
||||
```csharp
|
||||
if (ImGuiNET.ImGui.BeginMenu("Camera"))
|
||||
{
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
string flyLabel = _cameraController.IsFlyMode
|
||||
? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
|
||||
if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
|
||||
ToggleFlyOrChase();
|
||||
}
|
||||
// A8.F: spring-arm camera collision (live A/B toggle).
|
||||
if (ImGuiNET.ImGui.MenuItem("Collide Camera (spring arm)", "",
|
||||
AcDream.Core.Rendering.CameraDiagnostics.CollideCamera))
|
||||
AcDream.Core.Rendering.CameraDiagnostics.CollideCamera =
|
||||
!AcDream.Core.Rendering.CameraDiagnostics.CollideCamera;
|
||||
ImGuiNET.ImGui.EndMenu();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: build succeeds (0 errors).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(render): Phase A8.F — Camera menu toggle for spring-arm collision"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Correct the prior camera spec's collision note
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md` (lines 454-457)
|
||||
|
||||
- [ ] **Step 1: Mark the stale note as superseded**
|
||||
|
||||
In `2026-05-18-retail-chase-camera-design.md`, replace the bullet at lines 454-457:
|
||||
|
||||
```markdown
|
||||
- **Camera-vs-world collision.** Retail's per-frame update doesn't
|
||||
raycast world geometry (see investigation report 2026-05-18 in chat).
|
||||
The auto-fade handles "camera passes through player"; we don't
|
||||
attempt "camera collides with wall" — same as retail.
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```markdown
|
||||
- **Camera-vs-world collision.** ~~Retail's per-frame update doesn't
|
||||
raycast world geometry; we don't attempt "camera collides with wall"
|
||||
— same as retail.~~ **SUPERSEDED 2026-05-29:** this was a research
|
||||
error — retail DOES collide the camera in `SmartBox::update_viewer`
|
||||
(0x00453ce0), which the earlier pass missed by tracing only the
|
||||
desired-eye producer. Implemented as a swept-sphere spring arm; see
|
||||
`docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
|
||||
git commit -m "docs(render): Phase A8.F — supersede the old 'no camera collision' note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full verification + acceptance
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full build**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 2: Full test suite**
|
||||
|
||||
Run: `dotnet test`
|
||||
Expected: green. Note the App.Tests baseline should increase by the new camera tests; no regressions in Core/Net.
|
||||
|
||||
- [ ] **Step 3: Visual verification (the real acceptance — requires the user)**
|
||||
|
||||
Launch against the live ACE server with the A8.F branch on (PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
|
||||
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
|
||||
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
|
||||
$env:ACDREAM_A8_INDOOR_BRANCH="1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f-cameracollide.log"
|
||||
```
|
||||
|
||||
Walk `+Acdream` into a Holtburg cottage and down into its cellar, panning the camera through walls and crossing the doorway inside↔outside. Confirm:
|
||||
- the flap is gone — walls/ground stay solid while panning and while crossing the doorway;
|
||||
- back walls no longer go missing when looking through a window from outside;
|
||||
- the player fades (rather than the camera sitting inside the player mesh) when backed into a corner.
|
||||
|
||||
Then toggle `Collide Camera (spring arm)` off via the Camera menu (or relaunch with `ACDREAM_CAMERA_COLLIDE=0`) and confirm the flap returns — proving the fix is what closed it.
|
||||
|
||||
- [ ] **Step 4: Update the roadmap / milestones on visual pass**
|
||||
|
||||
After the user confirms the visual result, update the A8.F entry in `CLAUDE.md` (the M1.5 "currently working toward" block) and `docs/plans/2026-04-11-roadmap.md` shipped table to note the swept-sphere camera collision shipped + visual-verified, and move/close the related A8.F flap notes. Commit:
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/plans/2026-04-11-roadmap.md
|
||||
git commit -m "docs(render): Phase A8.F — camera collision shipped + visual-verified"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Do not** re-implement collision in the probe. The whole point of reusing
|
||||
`ResolveWithTransition` is that the env+obj sweep is already tested. The probe
|
||||
is param-marshalling + the z-offset round trip.
|
||||
- **Self-skip is load-bearing.** The sweep starts at the player's head, inside
|
||||
the player's own 0.48 m collision sphere / ShadowEntry. Passing
|
||||
`selfEntityId` (= `LocalEntityId`) is what stops the eye from snapping onto
|
||||
the head every frame. If the eye appears glued to the player, this is the
|
||||
first thing to check.
|
||||
- **Slide vs hard-stop (open question).** Reusing the transition gives the
|
||||
player path's edge-slide (the eye glides along a wall, no jitter). If visual
|
||||
verification shows the eye behaving oddly, read retail's `find_valid_position`
|
||||
and match its stop/slide semantics — but do not change the architecture for it.
|
||||
- **If the eye hugs/penetrates in a tight room**, the spec's optional
|
||||
`AdjustPosition` fallback (spec §7) is the escalation; add it only if needed.
|
||||
|
|
@ -130,9 +130,13 @@ public interface ICameraCollisionProbe
|
|||
public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
|
||||
{
|
||||
if (cellId == 0) return desiredEye; // no starting cell → can't sweep
|
||||
// InitPath offsets sphere0's center up by radius (foot-capsule convention),
|
||||
// but retail's viewer_sphere center is (0,0,0). Shift the path down by radius
|
||||
// so the SPHERE CENTER travels pivot→eye, then add it back to the result.
|
||||
var zoff = new Vector3(0f, 0f, 0.3f);
|
||||
var r = _physics.ResolveWithTransition(
|
||||
currentPos: pivot,
|
||||
targetPos: desiredEye,
|
||||
currentPos: pivot - zoff,
|
||||
targetPos: desiredEye - zoff,
|
||||
cellId: cellId,
|
||||
sphereRadius: 0.3f, // retail viewer_sphere radius
|
||||
sphereHeight: 0f, // single sphere (no head sphere)
|
||||
|
|
@ -144,7 +148,7 @@ public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint sel
|
|||
// camera sweeps out of the #98
|
||||
// IsPlayer capture filter
|
||||
movingEntityId: selfEntityId); // skip the player's own ShadowEntry
|
||||
return r.Position; // = sp.CheckPos, the swept stop position
|
||||
return r.Position + zoff; // r.Position = sp.CheckPos (path pt); + zoff = eye
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -234,11 +238,13 @@ inside the player mesh) when backed into a corner.
|
|||
- probe returns a pulled-in eye → published `Position`/`View` use the collided
|
||||
eye; fade increases.
|
||||
- `CollideCamera = false` → probe never consulted.
|
||||
- `PhysicsCameraCollisionProbe` against fixtures (reuse the issue-#98 cell +
|
||||
GfxObj fixtures already in the test tree):
|
||||
- clear path → eye unchanged;
|
||||
- wall/shell between pivot and desiredEye → eye stops short (does not penetrate);
|
||||
- `selfEntityId` set → sweep does not collide with the player's own ShadowEntry.
|
||||
- `PhysicsCameraCollisionProbe` deterministic units (no heavy fixture setup):
|
||||
- `ToSpherePath`/`FromSpherePath` z-offset round-trip;
|
||||
- `cellId == 0` guard → returns `desiredEye` unchanged.
|
||||
Collision correctness itself (eye stops at a wall/shell; self-skip) is already
|
||||
covered by the exhaustive `ResolveWithTransition` / `BSPQuery` suites and is
|
||||
confirmed end-to-end by the visual acceptance below — re-proving it at the probe
|
||||
layer would duplicate that coverage with brittle fixture wiring.
|
||||
|
||||
**Visual (acceptance):** with `ACDREAM_A8_INDOOR_BRANCH=1`, walk into a Holtburg
|
||||
cottage and its cellar:
|
||||
|
|
@ -263,9 +269,12 @@ Compare `ACDREAM_CAMERA_COLLIDE=0` vs default to confirm the flag isolates the f
|
|||
retail's `find_valid_position` during implementation and confirm whether it
|
||||
slides or hard-stops; match it. Both keep the eye out of walls, so this does
|
||||
not change the architecture.
|
||||
2. **`sphereHeight: 0f`.** Confirm `SpherePath.InitPath` with height 0 yields a
|
||||
single sphere (no degenerate coincident head sphere). If not, pass a tiny
|
||||
height or a single-sphere init.
|
||||
2. **`sphereHeight: 0f` — RESOLVED.** `SpherePath.InitPath` with height 0 yields a
|
||||
single sphere (`NumSphere = 1`, `TransitionTypes.cs:534-537`). It also offsets
|
||||
sphere0's center to `pathPos + (0,0,radius)` (foot-capsule convention) whereas
|
||||
retail's `viewer_sphere` center is (0,0,0); the probe compensates with the
|
||||
`ToSpherePath`/`FromSpherePath` z-shift (§5.1) so the sphere center travels
|
||||
pivot→eye.
|
||||
3. **Probe construction order.** The `PhysicsEngine` must exist before the
|
||||
`RetailChaseCamera` constructions at `GameWindow.cs:10693/:10826`; confirm
|
||||
lifetime ordering or make the probe field settable post-construction.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue