5-task TDD plan: TerrainSurface (outdoor heightmap Z + cell ID), CellSurface (indoor floor polygon projection via barycentric interp), PhysicsEngine (top-level resolver with step-height + cell transitions), GameWindow integration (populate from streaming), and roadmap update. ~16 new unit tests with fake data. No dat files or rendering needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1095 lines
38 KiB
Markdown
1095 lines
38 KiB
Markdown
# Phase B.3 — Physics Collision Engine 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:** Build a pure-computation collision engine that resolves entity positions against terrain heightmaps and EnvCell floor polygons, handling outdoor/indoor cell transitions, step-height enforcement, and gravity reporting.
|
||
|
||
**Architecture:** Three components in `src/AcDream.Core/Physics/`: `TerrainSurface` (outdoor heightmap Z interpolation), `CellSurface` (indoor floor polygon projection), and `PhysicsEngine` (top-level resolver combining both with step-height + cell transitions). Populated by the streaming system; consumed by Phase B.2 (player movement, separate spec).
|
||
|
||
**Tech Stack:** .NET 10, System.Numerics, DatReaderWriter types, xUnit.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md`
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
```
|
||
src/AcDream.Core/
|
||
Physics/ [new folder]
|
||
TerrainSurface.cs [new] outdoor heightmap Z interpolation + cell ID
|
||
CellSurface.cs [new] indoor floor polygon projection
|
||
PhysicsEngine.cs [new] top-level resolver
|
||
ResolveResult.cs [new] result record
|
||
|
||
src/AcDream.App/
|
||
Rendering/GameWindow.cs [modify] populate physics engine from streaming
|
||
|
||
tests/AcDream.Core.Tests/
|
||
Physics/ [new folder]
|
||
TerrainSurfaceTests.cs [new]
|
||
CellSurfaceTests.cs [new]
|
||
PhysicsEngineTests.cs [new]
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: TerrainSurface (outdoor heightmap Z + cell ID)
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.Core/Physics/TerrainSurface.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs`
|
||
|
||
This extracts the existing `SampleTerrainZ` algorithm from GameWindow into a reusable class and adds outdoor cell ID computation.
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs`:
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
public class TerrainSurfaceTests
|
||
{
|
||
// A height table where index N maps to N * 1.0f (linear).
|
||
// Makes test assertions predictable: height byte 10 → Z = 10.0.
|
||
private static float[] LinearHeightTable()
|
||
{
|
||
var table = new float[256];
|
||
for (int i = 0; i < 256; i++) table[i] = i * 1.0f;
|
||
return table;
|
||
}
|
||
|
||
// A flat heightmap where every vertex is height byte 50.
|
||
private static byte[] FlatHeightmap(byte value = 50)
|
||
{
|
||
var heights = new byte[81];
|
||
Array.Fill(heights, value);
|
||
return heights;
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleZ_FlatTerrain_ReturnsSameValueEverywhere()
|
||
{
|
||
var surface = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
||
|
||
Assert.Equal(50f, surface.SampleZ(0f, 0f));
|
||
Assert.Equal(50f, surface.SampleZ(96f, 96f));
|
||
Assert.Equal(50f, surface.SampleZ(191f, 191f));
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleZ_SlopeAlongX_InterpolatesLinearly()
|
||
{
|
||
// Heights increase along X: column 0 = byte 10, column 8 = byte 90.
|
||
// Each column step is (90-10)/8 = 10 bytes.
|
||
var heights = new byte[81];
|
||
for (int x = 0; x < 9; x++)
|
||
for (int y = 0; y < 9; y++)
|
||
heights[x * 9 + y] = (byte)(10 + x * 10);
|
||
|
||
var surface = new TerrainSurface(heights, LinearHeightTable());
|
||
|
||
// At x=0 (vertex 0): Z = 10
|
||
Assert.Equal(10f, surface.SampleZ(0f, 96f), precision: 1);
|
||
// At x=96 (midpoint, vertex 4): Z = 50
|
||
Assert.Equal(50f, surface.SampleZ(96f, 96f), precision: 1);
|
||
// At x=192 (vertex 8): Z = 90
|
||
Assert.Equal(90f, surface.SampleZ(192f, 96f), precision: 1);
|
||
// At x=48 (between vertex 2 and 3): Z = 30 + 0.5 * 10 = 35
|
||
// vertex 2 = byte 30, vertex 3 = byte 40, midpoint = 35
|
||
Assert.Equal(35f, surface.SampleZ(60f, 96f), precision: 1);
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleZ_ClampsOutOfBounds()
|
||
{
|
||
var surface = new TerrainSurface(FlatHeightmap(42), LinearHeightTable());
|
||
|
||
// Negative coordinates clamp to 0
|
||
Assert.Equal(42f, surface.SampleZ(-10f, -10f));
|
||
// Beyond 192 clamps to boundary
|
||
Assert.Equal(42f, surface.SampleZ(300f, 300f));
|
||
}
|
||
|
||
[Fact]
|
||
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
|
||
{
|
||
var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
|
||
|
||
// Cell (0,0) at position (0,0) → cell ID 0x0001
|
||
Assert.Equal(0x0001u, surface.ComputeOutdoorCellId(0f, 0f));
|
||
}
|
||
|
||
[Fact]
|
||
public void ComputeOutdoorCellId_SecondColumn_ReturnsCorrect()
|
||
{
|
||
var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
|
||
|
||
// 24 units in X = cell (1, 0) → cell ID 0x0001 + 1*8 = 0x0009
|
||
Assert.Equal(0x0009u, surface.ComputeOutdoorCellId(24f, 0f));
|
||
}
|
||
|
||
[Fact]
|
||
public void ComputeOutdoorCellId_LastCell_Returns0x0040()
|
||
{
|
||
var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable());
|
||
|
||
// Cell (7,7) at position (191,191) → 0x0001 + 7*8 + 7 = 0x0040
|
||
Assert.Equal(0x0040u, surface.ComputeOutdoorCellId(191f, 191f));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — verify they fail**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TerrainSurfaceTests" --nologo`
|
||
|
||
Expected: FAIL with build error `TerrainSurface not found`.
|
||
|
||
- [ ] **Step 3: Implement TerrainSurface**
|
||
|
||
Create `src/AcDream.Core/Physics/TerrainSurface.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Outdoor terrain height resolver for a single landblock. Performs
|
||
/// bilinear interpolation of the 9×9 heightmap grid to produce the
|
||
/// ground Z at any (localX, localY) within the 192×192 landblock
|
||
/// footprint. Also computes the outdoor cell ID for AC's position
|
||
/// encoding.
|
||
///
|
||
/// <para>
|
||
/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined
|
||
/// and not reusable). The heightmap is indexed x-major:
|
||
/// <c>heights[x * 9 + y]</c>; each byte is a lookup into
|
||
/// <paramref name="heightTable"/> (256-entry float array from
|
||
/// Region.LandDefs.LandHeightTable).
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class TerrainSurface
|
||
{
|
||
private const int HeightmapSide = 9;
|
||
private const float CellSize = 24f;
|
||
private const int CellsPerSide = 8; // 192 / 24
|
||
|
||
private readonly byte[] _heights;
|
||
private readonly float[] _heightTable;
|
||
|
||
public TerrainSurface(byte[] heights, float[] heightTable)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(heights);
|
||
ArgumentNullException.ThrowIfNull(heightTable);
|
||
if (heights.Length < 81)
|
||
throw new ArgumentException("heights must have 81 entries", nameof(heights));
|
||
if (heightTable.Length < 256)
|
||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||
|
||
_heights = heights;
|
||
_heightTable = heightTable;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bilinear-interpolated terrain Z at (localX, localY) in
|
||
/// landblock-local coordinates (0..192 range).
|
||
/// </summary>
|
||
public float SampleZ(float localX, float localY)
|
||
{
|
||
float fx = Math.Clamp(localX / CellSize, 0f, HeightmapSide - 1f);
|
||
float fy = Math.Clamp(localY / CellSize, 0f, HeightmapSide - 1f);
|
||
|
||
int x0 = Math.Min((int)fx, HeightmapSide - 2);
|
||
int y0 = Math.Min((int)fy, HeightmapSide - 2);
|
||
int x1 = x0 + 1;
|
||
int y1 = y0 + 1;
|
||
float tx = fx - x0;
|
||
float ty = fy - y0;
|
||
|
||
float h00 = _heightTable[_heights[x0 * HeightmapSide + y0]];
|
||
float h10 = _heightTable[_heights[x1 * HeightmapSide + y0]];
|
||
float h01 = _heightTable[_heights[x0 * HeightmapSide + y1]];
|
||
float h11 = _heightTable[_heights[x1 * HeightmapSide + y1]];
|
||
|
||
float hx0 = h00 * (1 - tx) + h10 * tx;
|
||
float hx1 = h01 * (1 - tx) + h11 * tx;
|
||
return hx0 * (1 - ty) + hx1 * ty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Compute the outdoor cell ID for the given landblock-local position.
|
||
/// Outdoor cells are an 8×8 grid of 24×24-unit cells numbered
|
||
/// 0x0001..0x0040. Cell (0,0) at position (0,0) is 0x0001.
|
||
/// </summary>
|
||
public uint ComputeOutdoorCellId(float localX, float localY)
|
||
{
|
||
int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1);
|
||
int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1);
|
||
return (uint)(1 + cx * CellsPerSide + cy);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests — verify all pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TerrainSurfaceTests" --nologo`
|
||
|
||
Expected: PASS 6/6.
|
||
|
||
- [ ] **Step 5: Run full suite — no regressions**
|
||
|
||
Run: `dotnet test -c Debug --nologo`
|
||
|
||
Expected: 227+ tests green, no regressions.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/TerrainSurface.cs tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(core): Phase B.3 — TerrainSurface (outdoor heightmap Z + cell ID)
|
||
|
||
Extracts the bilinear heightmap interpolation from GameWindow's
|
||
inlined SampleTerrainZ into a reusable class. Also adds outdoor
|
||
cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040).
|
||
|
||
First component of the physics collision engine.
|
||
|
||
6 new tests, all green.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: CellSurface (indoor floor polygon projection)
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.Core/Physics/CellSurface.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs`
|
||
|
||
This is the most algorithmically complex component. It projects an XY point onto the floor polygons of an EnvCell's CellStruct and returns the Z.
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
public class CellSurfaceTests
|
||
{
|
||
/// <summary>
|
||
/// Build a minimal CellSurface representing a flat square floor
|
||
/// centered at (originX, originY) with the given half-size and Z.
|
||
/// The floor polygon is a quad: 4 vertices at the corners.
|
||
/// </summary>
|
||
private static CellSurface MakeFlatFloor(
|
||
uint cellId, float originX, float originY, float z,
|
||
float halfSize = 10f)
|
||
{
|
||
// 4 vertices forming a square floor at the given Z, in WORLD space.
|
||
var vertices = new Dictionary<ushort, Vector3>
|
||
{
|
||
[0] = new(originX - halfSize, originY - halfSize, z),
|
||
[1] = new(originX + halfSize, originY - halfSize, z),
|
||
[2] = new(originX + halfSize, originY + halfSize, z),
|
||
[3] = new(originX - halfSize, originY + halfSize, z),
|
||
};
|
||
|
||
// One quad polygon with 4 vertex IDs.
|
||
var polygonVertexIds = new List<List<short>>
|
||
{
|
||
new() { 0, 1, 2, 3 },
|
||
};
|
||
|
||
return new CellSurface(cellId, vertices, polygonVertexIds);
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleFloorZ_InsideFlat_ReturnsZ()
|
||
{
|
||
var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f);
|
||
|
||
float? z = surface.SampleFloorZ(50f, 50f);
|
||
|
||
Assert.NotNull(z);
|
||
Assert.Equal(10f, z!.Value, precision: 2);
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleFloorZ_OutsideFloor_ReturnsNull()
|
||
{
|
||
var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 5f);
|
||
|
||
float? z = surface.SampleFloorZ(100f, 100f);
|
||
|
||
Assert.Null(z);
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleFloorZ_AtEdge_ReturnsZ()
|
||
{
|
||
var surface = MakeFlatFloor(0x0100, originX: 50f, originY: 50f, z: 10f, halfSize: 10f);
|
||
|
||
// Right at the edge of the polygon.
|
||
float? z = surface.SampleFloorZ(60f, 50f);
|
||
|
||
Assert.NotNull(z);
|
||
Assert.Equal(10f, z!.Value, precision: 2);
|
||
}
|
||
|
||
[Fact]
|
||
public void SampleFloorZ_SlopedFloor_InterpolatesZ()
|
||
{
|
||
// A triangular floor that slopes from Z=0 to Z=20.
|
||
var vertices = new Dictionary<ushort, Vector3>
|
||
{
|
||
[0] = new(0f, 0f, 0f),
|
||
[1] = new(20f, 0f, 0f),
|
||
[2] = new(10f, 20f, 20f),
|
||
};
|
||
var polygons = new List<List<short>> { new() { 0, 1, 2 } };
|
||
var surface = new CellSurface(0x0100, vertices, polygons);
|
||
|
||
// At the centroid (10, 6.67): Z should be roughly 6.67
|
||
float? z = surface.SampleFloorZ(10f, 6.67f);
|
||
|
||
Assert.NotNull(z);
|
||
Assert.InRange(z!.Value, 5f, 8f); // approximate
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests — verify they fail**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellSurfaceTests" --nologo`
|
||
|
||
Expected: FAIL with build error `CellSurface not found`.
|
||
|
||
- [ ] **Step 3: Implement CellSurface**
|
||
|
||
Create `src/AcDream.Core/Physics/CellSurface.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Indoor floor resolver for a single EnvCell. Projects an XY point
|
||
/// onto the cell's floor polygons and returns the Z at that point.
|
||
///
|
||
/// <para>
|
||
/// Uses a simplified constructor that takes pre-transformed vertex
|
||
/// positions (world-space) and polygon vertex-id lists. The caller
|
||
/// is responsible for transforming CellStruct vertices from cell-local
|
||
/// space to world space using EnvCell.Position before constructing
|
||
/// this surface.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Floor polygon iteration is brute-force (no BSP). Cell polygon
|
||
/// counts are typically < 20, making this acceptable for the MVP.
|
||
/// Each polygon is fan-triangulated and tested via point-in-triangle
|
||
/// + barycentric Z interpolation.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class CellSurface
|
||
{
|
||
public uint CellId { get; }
|
||
|
||
private readonly List<(Vector3 A, Vector3 B, Vector3 C)> _triangles;
|
||
|
||
/// <summary>
|
||
/// Construct a CellSurface from pre-transformed vertex positions
|
||
/// and polygon definitions.
|
||
/// </summary>
|
||
/// <param name="cellId">The EnvCell dat id (e.g., 0xA9B40100).</param>
|
||
/// <param name="vertices">Vertex id → world-space position map.</param>
|
||
/// <param name="polygonVertexIds">
|
||
/// List of polygons, each a list of vertex IDs. Polygons with fewer
|
||
/// than 3 vertices are skipped. Quads and larger are fan-triangulated.
|
||
/// </param>
|
||
public CellSurface(
|
||
uint cellId,
|
||
Dictionary<ushort, Vector3> vertices,
|
||
List<List<short>> polygonVertexIds)
|
||
{
|
||
CellId = cellId;
|
||
_triangles = new List<(Vector3, Vector3, Vector3)>();
|
||
|
||
foreach (var polyVerts in polygonVertexIds)
|
||
{
|
||
if (polyVerts.Count < 3) continue;
|
||
|
||
// Resolve vertex positions.
|
||
var positions = new List<Vector3>(polyVerts.Count);
|
||
bool skip = false;
|
||
foreach (var vid in polyVerts)
|
||
{
|
||
if (!vertices.TryGetValue((ushort)vid, out var pos))
|
||
{
|
||
skip = true;
|
||
break;
|
||
}
|
||
positions.Add(pos);
|
||
}
|
||
if (skip) continue;
|
||
|
||
// Fan triangulation: (v0, v1, v2), (v0, v2, v3), ...
|
||
for (int i = 1; i < positions.Count - 1; i++)
|
||
{
|
||
_triangles.Add((positions[0], positions[i], positions[i + 1]));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Project (worldX, worldY) onto this cell's floor polygons and
|
||
/// return the Z. Returns null if outside all floor polygons.
|
||
/// </summary>
|
||
public float? SampleFloorZ(float worldX, float worldY)
|
||
{
|
||
foreach (var (a, b, c) in _triangles)
|
||
{
|
||
if (PointInTriangleXY(worldX, worldY, a, b, c, out float z))
|
||
return z;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Test if (px, py) falls inside triangle (a, b, c) projected onto
|
||
/// the XY plane. If inside, computes the barycentric Z interpolation
|
||
/// and returns it via <paramref name="z"/>.
|
||
/// </summary>
|
||
private static bool PointInTriangleXY(
|
||
float px, float py,
|
||
Vector3 a, Vector3 b, Vector3 c,
|
||
out float z)
|
||
{
|
||
z = 0;
|
||
|
||
// Barycentric coordinate computation in 2D (XY plane).
|
||
float v0x = c.X - a.X, v0y = c.Y - a.Y;
|
||
float v1x = b.X - a.X, v1y = b.Y - a.Y;
|
||
float v2x = px - a.X, v2y = py - a.Y;
|
||
|
||
float dot00 = v0x * v0x + v0y * v0y;
|
||
float dot01 = v0x * v1x + v0y * v1y;
|
||
float dot02 = v0x * v2x + v0y * v2y;
|
||
float dot11 = v1x * v1x + v1y * v1y;
|
||
float dot12 = v1x * v2x + v1y * v2y;
|
||
|
||
float denom = dot00 * dot11 - dot01 * dot01;
|
||
if (MathF.Abs(denom) < 1e-10f) return false; // degenerate triangle
|
||
|
||
float invDenom = 1f / denom;
|
||
float u = (dot11 * dot02 - dot01 * dot12) * invDenom;
|
||
float v = (dot00 * dot12 - dot01 * dot02) * invDenom;
|
||
|
||
if (u < -1e-6f || v < -1e-6f || u + v > 1f + 1e-6f)
|
||
return false;
|
||
|
||
// Barycentric Z interpolation.
|
||
z = a.Z * (1 - u - v) + b.Z * v + c.Z * u;
|
||
return true;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests — verify all pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellSurfaceTests" --nologo`
|
||
|
||
Expected: PASS 4/4.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/CellSurface.cs tests/AcDream.Core.Tests/Physics/CellSurfaceTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(core): Phase B.3 — CellSurface (indoor floor polygon projection)
|
||
|
||
Projects an XY point onto a cell's floor polygons via brute-force
|
||
triangle iteration + barycentric Z interpolation. Fan-triangulates
|
||
quads and larger polygons. Returns null when outside all floor
|
||
surfaces. Accepts pre-transformed world-space vertex positions so
|
||
the caller handles EnvCell coordinate transforms.
|
||
|
||
Second component of the physics collision engine.
|
||
|
||
4 new tests, all green.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: PhysicsEngine (top-level resolver + ResolveResult)
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.Core/Physics/ResolveResult.cs`
|
||
- Create: `src/AcDream.Core/Physics/PhysicsEngine.cs`
|
||
- Test: `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs`
|
||
|
||
The integration layer that combines terrain + cell surfaces with step-height enforcement and outdoor/indoor transitions.
|
||
|
||
- [ ] **Step 1: Create ResolveResult record**
|
||
|
||
Create `src/AcDream.Core/Physics/ResolveResult.cs`:
|
||
|
||
```csharp
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Result of <see cref="PhysicsEngine.Resolve"/>: the validated
|
||
/// position after collision, the cell the entity ended up in,
|
||
/// and whether they're standing on a surface.
|
||
/// </summary>
|
||
public readonly record struct ResolveResult(
|
||
Vector3 Position,
|
||
uint CellId,
|
||
bool IsOnGround);
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing tests for PhysicsEngine**
|
||
|
||
Create `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
public class PhysicsEngineTests
|
||
{
|
||
private static float[] LinearHeightTable()
|
||
{
|
||
var table = new float[256];
|
||
for (int i = 0; i < 256; i++) table[i] = i * 1.0f;
|
||
return table;
|
||
}
|
||
|
||
private static byte[] FlatHeightmap(byte value = 50)
|
||
{
|
||
var heights = new byte[81];
|
||
Array.Fill(heights, value);
|
||
return heights;
|
||
}
|
||
|
||
private PhysicsEngine MakeFlatEngine(float terrainZ = 50f)
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable());
|
||
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
return engine;
|
||
}
|
||
|
||
[Fact]
|
||
public void Resolve_FlatTerrain_ZMatchesTerrain()
|
||
{
|
||
var engine = MakeFlatEngine(terrainZ: 50f);
|
||
|
||
var result = engine.Resolve(
|
||
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f),
|
||
stepUpHeight: 2f);
|
||
|
||
Assert.Equal(50f, result.Position.Z, precision: 1);
|
||
Assert.True(result.IsOnGround);
|
||
}
|
||
|
||
[Fact]
|
||
public void Resolve_WalkUpSmallSlope_Accepted()
|
||
{
|
||
// Heights slope from 50 to 52 across X — small enough for step height.
|
||
var heights = new byte[81];
|
||
for (int x = 0; x < 9; x++)
|
||
for (int y = 0; y < 9; y++)
|
||
heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope
|
||
|
||
var engine = new PhysicsEngine();
|
||
var terrain = new TerrainSurface(heights, LinearHeightTable());
|
||
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
|
||
var result = engine.Resolve(
|
||
new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
|
||
stepUpHeight: 5f);
|
||
|
||
Assert.True(result.IsOnGround);
|
||
Assert.True(result.Position.Z >= 50f); // moved uphill
|
||
}
|
||
|
||
[Fact]
|
||
public void Resolve_StepUpExceedsHeight_MovementBlocked()
|
||
{
|
||
// Heights jump sharply: left half = 50, right half = 100.
|
||
var heights = new byte[81];
|
||
for (int x = 0; x < 9; x++)
|
||
for (int y = 0; y < 9; y++)
|
||
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100);
|
||
|
||
var engine = new PhysicsEngine();
|
||
var terrain = new TerrainSurface(heights, LinearHeightTable());
|
||
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
|
||
// Try to walk from the low side to the high side.
|
||
var result = engine.Resolve(
|
||
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
|
||
stepUpHeight: 2f);
|
||
|
||
// Movement should be blocked — Z delta (50→100) exceeds step height (2).
|
||
Assert.Equal(96f, result.Position.X, precision: 1); // didn't move
|
||
Assert.True(result.IsOnGround);
|
||
}
|
||
|
||
[Fact]
|
||
public void Resolve_EnterIndoorCell_TransitionsToCell()
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
||
|
||
// Indoor cell with a floor at Z=55 covering (40..60, 40..60).
|
||
var cellVerts = new Dictionary<ushort, Vector3>
|
||
{
|
||
[0] = new(40f, 40f, 55f),
|
||
[1] = new(60f, 40f, 55f),
|
||
[2] = new(60f, 60f, 55f),
|
||
[3] = new(40f, 60f, 55f),
|
||
};
|
||
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
|
||
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
|
||
|
||
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell },
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
|
||
// Walk from outdoor (30, 50) into the cell's floor area (50, 50).
|
||
var result = engine.Resolve(
|
||
new Vector3(30f, 50f, 50f), cellId: 0x0001, delta: new Vector3(20f, 0f, 0f),
|
||
stepUpHeight: 10f);
|
||
|
||
// Should transition to the indoor cell and snap to its floor Z.
|
||
Assert.Equal(0x0100u, result.CellId);
|
||
Assert.Equal(55f, result.Position.Z, precision: 1);
|
||
Assert.True(result.IsOnGround);
|
||
}
|
||
|
||
[Fact]
|
||
public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
||
|
||
var cellVerts = new Dictionary<ushort, Vector3>
|
||
{
|
||
[0] = new(40f, 40f, 55f),
|
||
[1] = new(60f, 40f, 55f),
|
||
[2] = new(60f, 60f, 55f),
|
||
[3] = new(40f, 60f, 55f),
|
||
};
|
||
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
|
||
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
|
||
|
||
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell },
|
||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||
|
||
// Start inside the cell, walk out.
|
||
var result = engine.Resolve(
|
||
new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
|
||
stepUpHeight: 10f);
|
||
|
||
// Should transition back to outdoor.
|
||
Assert.True(result.CellId < 0x0100u);
|
||
Assert.Equal(50f, result.Position.Z, precision: 1);
|
||
Assert.True(result.IsOnGround);
|
||
}
|
||
|
||
[Fact]
|
||
public void Resolve_NoSurfaceUnderEntity_NotOnGround()
|
||
{
|
||
var engine = new PhysicsEngine();
|
||
// No landblocks loaded — entity is floating in void.
|
||
|
||
var result = engine.Resolve(
|
||
new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero,
|
||
stepUpHeight: 2f);
|
||
|
||
Assert.False(result.IsOnGround);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement PhysicsEngine**
|
||
|
||
Create `src/AcDream.Core/Physics/PhysicsEngine.cs`:
|
||
|
||
The engine stores per-landblock terrain + cells and resolves movement. Implementation should follow the algorithm in the spec (Section 2, resolution algorithm steps 1-6). Key decisions:
|
||
|
||
- Store terrain surfaces in `Dictionary<uint, (TerrainSurface Terrain, IReadOnlyList<CellSurface> Cells, float WorldOffsetX, float WorldOffsetY)>`
|
||
- `AddLandblock` takes the canonical landblock id + TerrainSurface + cells + world offset
|
||
- `RemoveLandblock` removes the entry
|
||
- `Resolve` implements the outdoor/indoor logic:
|
||
- Compute candidate position
|
||
- Find which landblock the candidate is in (from world position → landblock offset)
|
||
- If outdoor: sample terrain Z, check cells for containment (outdoor→indoor transition), step-height check
|
||
- If indoor: sample current cell floor Z, if null → transition to outdoor, step-height check
|
||
- Return ResolveResult
|
||
|
||
The implementation MUST handle:
|
||
- Converting between world-space and landblock-local coordinates using `worldOffsetX/Y`
|
||
- The step-height check only blocks upward movement (downhill is always OK)
|
||
- When no landblock is found for the candidate position, return `isOnGround = false`
|
||
- Multi-cell overlap (two EnvCells at the same XY, different Z): pick the one closest to current Z
|
||
|
||
```csharp
|
||
// Skeleton — implementer fills in the Resolve body per the spec algorithm.
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
public sealed class PhysicsEngine
|
||
{
|
||
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
|
||
|
||
private sealed record LandblockPhysics(
|
||
TerrainSurface Terrain,
|
||
IReadOnlyList<CellSurface> Cells,
|
||
float WorldOffsetX,
|
||
float WorldOffsetY);
|
||
|
||
public void AddLandblock(uint landblockId, TerrainSurface terrain,
|
||
IReadOnlyList<CellSurface> cells, float worldOffsetX, float worldOffsetY)
|
||
{
|
||
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, worldOffsetX, worldOffsetY);
|
||
}
|
||
|
||
public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId);
|
||
|
||
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
|
||
{
|
||
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
|
||
|
||
// Find the landblock this candidate position falls in.
|
||
LandblockPhysics? physics = null;
|
||
uint landblockId = 0;
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = candidatePos.X - lb.WorldOffsetX;
|
||
float localY = candidatePos.Y - lb.WorldOffsetY;
|
||
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
|
||
{
|
||
physics = lb;
|
||
landblockId = kvp.Key;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (physics is null)
|
||
return new ResolveResult(candidatePos, cellId, IsOnGround: false);
|
||
|
||
float localCandX = candidatePos.X - physics.WorldOffsetX;
|
||
float localCandY = candidatePos.Y - physics.WorldOffsetY;
|
||
|
||
// Check if the candidate position falls on any indoor cell floor.
|
||
// Pick the cell whose floor Z is closest to the entity's current Z.
|
||
CellSurface? bestCell = null;
|
||
float? bestCellZ = null;
|
||
float bestZDist = float.MaxValue;
|
||
|
||
foreach (var cell in physics.Cells)
|
||
{
|
||
float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
|
||
if (floorZ is not null)
|
||
{
|
||
float dist = MathF.Abs(floorZ.Value - currentPos.Z);
|
||
if (dist < bestZDist)
|
||
{
|
||
bestCell = cell;
|
||
bestCellZ = floorZ;
|
||
bestZDist = dist;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Determine target surface Z and cell.
|
||
float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
|
||
float targetZ;
|
||
uint targetCellId;
|
||
|
||
bool currentlyIndoor = cellId >= 0x0100;
|
||
|
||
if (currentlyIndoor && bestCellZ is not null)
|
||
{
|
||
// Stay indoors on the best cell's floor.
|
||
targetZ = bestCellZ.Value;
|
||
targetCellId = bestCell!.CellId;
|
||
}
|
||
else if (currentlyIndoor && bestCellZ is null)
|
||
{
|
||
// Walked out of the current cell — transition to outdoor.
|
||
targetZ = terrainZ;
|
||
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
|
||
}
|
||
else if (!currentlyIndoor && bestCellZ is not null
|
||
&& MathF.Abs(bestCellZ.Value - currentPos.Z) < stepUpHeight + 2f)
|
||
{
|
||
// Walked into an indoor cell from outdoor — transition to indoor.
|
||
targetZ = bestCellZ.Value;
|
||
targetCellId = bestCell!.CellId;
|
||
}
|
||
else
|
||
{
|
||
// Stay outdoors on terrain.
|
||
targetZ = terrainZ;
|
||
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
|
||
}
|
||
|
||
// Step-height enforcement: block upward movement that exceeds the limit.
|
||
float zDelta = targetZ - currentPos.Z;
|
||
if (zDelta > stepUpHeight)
|
||
{
|
||
// Too steep to step up — reject horizontal movement.
|
||
return new ResolveResult(currentPos, cellId, IsOnGround: true);
|
||
}
|
||
|
||
// Encode the landblock ID into the cell ID.
|
||
uint fullCellId = (landblockId & 0xFFFF0000u) | (targetCellId & 0xFFFFu);
|
||
|
||
return new ResolveResult(
|
||
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
|
||
fullCellId,
|
||
IsOnGround: true);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests — verify all pass**
|
||
|
||
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineTests" --nologo`
|
||
|
||
Expected: PASS 6/6.
|
||
|
||
- [ ] **Step 5: Run full suite — no regressions**
|
||
|
||
Run: `dotnet test -c Debug --nologo`
|
||
|
||
Expected: 227 + 16 = 243+ tests green, no regressions.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.Core/Physics/ResolveResult.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(core): Phase B.3 — PhysicsEngine (top-level collision resolver)
|
||
|
||
Combines TerrainSurface + CellSurface into a single Resolve() API
|
||
that handles outdoor terrain walking, indoor floor walking,
|
||
outdoor↔indoor cell transitions, step-height enforcement, and
|
||
ground detection.
|
||
|
||
Step-height blocks upward Z deltas exceeding the limit (walls,
|
||
cliffs); downhill movement is always accepted. Indoor transitions
|
||
pick the cell whose floor Z is closest to the entity's current Z
|
||
(handles multi-story buildings). Reports IsOnGround=false when
|
||
no landblock or surface covers the entity's position (gravity
|
||
applied by the caller).
|
||
|
||
6 new tests covering flat terrain, slopes, step-height rejection,
|
||
indoor entry/exit, and void detection.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: GameWindow integration (populate engine from streaming)
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||
|
||
Wire the physics engine into the streaming pipeline so it's populated when landblocks load and cleaned up when they unload. Phase B.2 will consume it.
|
||
|
||
- [ ] **Step 1: Add `_physicsEngine` field to GameWindow**
|
||
|
||
Near the other streaming fields (`_streamer`, `_worldState`, etc.), add:
|
||
|
||
```csharp
|
||
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
|
||
```
|
||
|
||
- [ ] **Step 2: Populate in `ApplyLoadedTerrainLocked`**
|
||
|
||
After the existing `_worldState.SetLandblockAabb(...)` call, add:
|
||
|
||
```csharp
|
||
// Phase B.3: populate the physics collision engine for this landblock.
|
||
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(lb.Heightmap.Height, _heightTable);
|
||
|
||
// Build CellSurfaces for each EnvCell in this landblock (reuse the
|
||
// same EnvCell walk we do for the interior entity builder).
|
||
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
|
||
var lbInfoPhys = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
|
||
if (lbInfoPhys is not null && lbInfoPhys.NumCells > 0)
|
||
{
|
||
uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u;
|
||
for (uint cellOffset = 0; cellOffset < lbInfoPhys.NumCells; cellOffset++)
|
||
{
|
||
uint envCellId = firstCellId + cellOffset;
|
||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||
if (envCell is null || envCell.EnvironmentId == 0) continue;
|
||
|
||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||
if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
|
||
continue;
|
||
|
||
// Transform CellStruct vertices from cell-local to world space.
|
||
var worldVerts = new Dictionary<ushort, System.Numerics.Vector3>();
|
||
var cellOrigin = envCell.Position.Origin + new System.Numerics.Vector3(origin.X, origin.Y, 0f);
|
||
foreach (var kvp in cellStruct.VertexArray.Vertices)
|
||
{
|
||
var localPos = kvp.Value.Origin;
|
||
// Apply cell orientation + cell origin + landblock origin.
|
||
var rotated = System.Numerics.Vector3.Transform(localPos, envCell.Position.Orientation);
|
||
worldVerts[kvp.Key] = rotated + cellOrigin;
|
||
}
|
||
|
||
// Extract physics polygon vertex-id lists.
|
||
var polyVertIds = new List<List<short>>();
|
||
foreach (var poly in cellStruct.PhysicsPolygons.Values)
|
||
{
|
||
if (poly.VertexIds.Count >= 3)
|
||
polyVertIds.Add(new List<short>(poly.VertexIds));
|
||
}
|
||
|
||
if (polyVertIds.Count > 0)
|
||
{
|
||
cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(
|
||
envCellId, worldVerts, polyVertIds));
|
||
}
|
||
}
|
||
}
|
||
|
||
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
|
||
worldOffsetX: origin.X, worldOffsetY: origin.Y);
|
||
```
|
||
|
||
**IMPORTANT:** `origin` is the `Vector3` already computed earlier in `ApplyLoadedTerrainLocked` — it's the landblock's world offset. Verify it's in scope at the point where you're adding this code.
|
||
|
||
- [ ] **Step 3: Clean up in unload path**
|
||
|
||
Find where `_worldState.RemoveLandblock(...)` is called (in `StreamingController.Tick`'s drain of `LandblockStreamResult.Unloaded`). The controller calls `_state.RemoveLandblock(unloaded.LandblockId)`. The physics engine cleanup should happen alongside — but the controller doesn't have a reference to the physics engine.
|
||
|
||
Simplest: add a `removeTerrain` callback to `StreamingController` (same pattern as `applyTerrain`) OR let GameWindow handle it by subscribing to the `RemoveLandblock` action. OR just add a direct call in the controller's unload handler.
|
||
|
||
For the MVP: in `StreamingController.Tick`, add a second callback `Action<uint> removeTerrain` that GameWindow passes to call `_physicsEngine.RemoveLandblock(id)` AND `_terrain.RemoveLandblock(id)`. This mirrors the existing `applyTerrain` callback pattern.
|
||
|
||
- [ ] **Step 4: Build + test**
|
||
|
||
Run: `cmd.exe /c "taskkill /F /IM AcDream.App.exe 2>nul" && dotnet build -c Debug && dotnet test -c Debug --nologo`
|
||
|
||
Expected: 0 errors, 243+ tests green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Streaming/StreamingController.cs
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(app): Phase B.3 — wire PhysicsEngine into streaming pipeline
|
||
|
||
Populates the collision engine with TerrainSurface + CellSurface
|
||
entries when landblocks stream in, removes them when they stream
|
||
out. CellSurface vertices are transformed from cell-local to world
|
||
space using EnvCell.Position orientation + origin.
|
||
|
||
Phase B.2 (player movement mode) will call PhysicsEngine.Resolve()
|
||
to get collision-validated positions before sending them to the
|
||
server.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Roadmap update
|
||
|
||
- [ ] **Step 1: Move B.3 into the shipped table**
|
||
|
||
Open `docs/plans/2026-04-11-roadmap.md`, add B.3 to the shipped table, mark B.1 as already shipped (Phase 4.9), and update the quick-lookup.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/plans/2026-04-11-roadmap.md
|
||
git commit -m "docs: mark Phase B.3 (physics collision engine) as shipped
|
||
|
||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
**Spec coverage:**
|
||
- TerrainSurface.SampleZ (bilinear interpolation) — Task 1 ✓
|
||
- TerrainSurface.ComputeOutdoorCellId — Task 1 ✓
|
||
- CellSurface.SampleFloorZ (polygon projection + barycentric Z) — Task 2 ✓
|
||
- PhysicsEngine.Resolve (outdoor/indoor/transition/step-height/gravity) — Task 3 ✓
|
||
- Integration with streaming pipeline — Task 4 ✓
|
||
- Step-height enforcement — Task 3 tests ✓
|
||
- Outdoor→indoor transition — Task 3 tests ✓
|
||
- Indoor→outdoor transition — Task 3 tests ✓
|
||
- Gravity (isOnGround=false) — Task 3 tests ✓
|
||
- Multi-story handling (closest-Z cell) — Task 3 implementation ✓
|
||
- Roadmap update — Task 5 ✓
|
||
|
||
**Placeholder scan:** None found. All code blocks are complete.
|
||
|
||
**Type consistency:** `TerrainSurface(byte[], float[])`, `CellSurface(uint, Dictionary<ushort, Vector3>, List<List<short>>)`, `PhysicsEngine.AddLandblock(uint, TerrainSurface, IReadOnlyList<CellSurface>, float, float)`, `PhysicsEngine.Resolve(Vector3, uint, Vector3, float) → ResolveResult` — all consistent across tasks.
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-04-12-physics-collision-engine.md`. Two execution options:
|
||
|
||
**1. Subagent-Driven (recommended)** — I dispatch a fresh Sonnet subagent per task, review between tasks, fast iteration. Best for this plan because Tasks 1-3 are independent pure-computation modules with clear test suites.
|
||
|
||
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
|
||
|
||
Which approach?
|