diff --git a/docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md b/docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md new file mode 100644 index 0000000..5c298fe --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-physics-collision-engine-design.md @@ -0,0 +1,259 @@ +# Phase B.3 — Physics Collision Engine Design + +**Status:** Spec, 2026-04-12, brainstormed with user. +**Scope:** A pure-computation collision engine that resolves entity positions against terrain heightmaps, EnvCell floor polygons, and cell transitions. No rendering, no networking — those live in Phase B.2 (player movement mode, separate spec). This module is the foundation for all future ground-based movement in acdream. +**Parent:** `docs/plans/2026-04-11-roadmap.md` — Phase B (Gameplay), sub-piece B.3. + +## Goals + +1. Given a position + movement delta, resolve where the entity ends up on the ground. +2. Handle outdoor terrain (hills, slopes via heightmap bilinear interpolation). +3. Handle indoor floors (buildings, cellars via EnvCell CellStruct floor polygons). +4. Handle outdoor↔indoor transitions via CellPortal boundary detection. +5. Enforce step-height limits (stairs OK, walls blocked). +6. Apply gravity (entity falls when not on a surface). +7. All logic is unit-testable without dat files, rendering, or networking. + +## Non-goals + +- Full rigid-body physics (no bouncing, wall-sliding, momentum). +- Projectile collision (server-side in AC). +- NPC pathing (server-driven). +- Water depth / swimming (visual only). +- BSP tree traversal for polygon lookup. Floor polygon counts per cell are small (< 20); brute-force polygon iteration is sufficient for the MVP. If profiling shows this is a bottleneck later, BSP acceleration can be added behind the same API. + +## Architecture + +Three components in `src/AcDream.Core/Physics/`: + +### 1. `TerrainSurface` + +Outdoor ground resolver for a single landblock. + +**Construction:** takes a `LandBlock.Height[]` (81 bytes) + `float[] heightTable` (256 entries, region-global). + +**API:** +```csharp +public sealed class TerrainSurface +{ + public TerrainSurface(byte[] heights, float[] heightTable); + + /// + /// Bilinear-interpolated terrain Z at (localX, localY) in + /// landblock-local coordinates (0..192 range). Clamps to + /// the landblock bounds if the position is outside. + /// + public float SampleZ(float localX, float localY); + + /// + /// Compute the outdoor cell ID for the given local position. + /// Outdoor cells are a flat 8×8 grid of 24×24 unit cells + /// numbered 0x0001..0x0040. The cell at (0,0) is 0x0001. + /// + public uint ComputeOutdoorCellId(float localX, float localY); +} +``` + +The `SampleZ` algorithm is the same bilinear interpolation currently in `GameWindow.SampleTerrainZ` — extract it into this reusable class. Heights are indexed x-major: `heights[x * 9 + y]` where x and y are vertex indices (0..8), each vertex covers 24 world units. + +### 2. `CellSurface` + +Indoor floor resolver for a single EnvCell. + +**Construction:** takes an `EnvCell` (for position, orientation, portal list) + `CellStruct` (for floor polygons + vertex array). + +**API:** +```csharp +public sealed class CellSurface +{ + /// The EnvCell's dat id (e.g., 0xA9B40100). + public uint CellId { get; } + + /// The cell's world-space origin (from EnvCell.Position.Origin). + public Vector3 Origin { get; } + + /// Portal connections to adjacent cells. + public IReadOnlyList Portals { get; } + + public CellSurface(uint cellId, EnvCell envCell, CellStruct cellStruct); + + /// + /// Project (worldX, worldY) onto this cell's floor polygons and + /// return the Z height at that point. Returns null if the XY + /// doesn't fall within any floor polygon (the entity isn't over + /// this cell's floor). + /// + /// + /// Uses PhysicsPolygons (not rendering Polygons) — these define + /// the walkable surfaces. For each polygon, does a point-in- + /// triangle test (after fan triangulation) + barycentric Z + /// interpolation. Brute-force iteration over all polygons; + /// cell polygon counts are small (typically < 20) so BSP + /// acceleration is deferred. + /// + /// + public float? SampleFloorZ(float worldX, float worldY); +} + +/// +/// Minimal portal-connection info extracted from EnvCell.CellPortals. +/// +public readonly record struct CellPortalInfo( + uint TargetCellId, + Vector3 PortalOrigin, + Vector3 PortalNormal); +``` + +Floor polygon coordinate space: `CellStruct.VertexArray` positions are in cell-local space. `EnvCell.Position` (origin + orientation) transforms them to landblock-local space. The `SampleFloorZ` method transforms the query point into cell-local space, tests against the `PhysicsPolygons`, then returns the result in world space. + +### 3. `PhysicsEngine` + +Top-level resolver that owns the per-landblock terrain surfaces and per-cell indoor surfaces. + +**API:** +```csharp +public sealed class PhysicsEngine +{ + /// + /// Register terrain + EnvCell surfaces for a loaded landblock. + /// Called from the render thread in ApplyLoadedTerrain. + /// + public void AddLandblock( + uint landblockId, + TerrainSurface terrain, + IReadOnlyList cells); + + /// Remove all surfaces for an unloaded landblock. + public void RemoveLandblock(uint landblockId); + + /// + /// Resolve a movement step. Given the entity's current world + /// position + cell id + a movement delta + a step-up height limit, + /// return the valid resolved position and the cell id the entity + /// ends up in. + /// + public ResolveResult Resolve( + Vector3 currentPos, + uint currentCellId, + Vector3 delta, + float stepUpHeight); +} + +public readonly record struct ResolveResult( + Vector3 Position, + uint CellId, + bool IsOnGround); +``` + +**Resolution algorithm:** + +``` +1. Compute candidatePos = currentPos + delta (horizontal only; Z handled below) + +2. Determine which landblock the candidate is in: + landblockX = floor((candidatePos.X + centerOffsetX) / 192) + landblockY = floor((candidatePos.Y + centerOffsetY) / 192) + (The offset accounts for the world origin being the center landblock.) + +3. If currently in an OUTDOOR cell (cellId < 0x0100): + a. Sample terrain Z at candidatePos via TerrainSurface.SampleZ + b. Compute candidateZ = terrainZ + c. Step-height check: if |candidateZ - currentPos.Z| > stepUpHeight + AND candidateZ > currentPos.Z (going UP), reject horizontal + movement → keep currentPos.XY, keep currentPos.Z + d. Check if candidatePos crosses into any EnvCell: + for each CellSurface in this landblock: + if CellSurface.SampleFloorZ(candidateX, candidateY) is not null: + → transition to indoor: use cell's floor Z, set cellId to cell's id + e. If no transition: set Z = candidateZ, compute outdoor cellId + +4. If currently in an INDOOR cell (cellId >= 0x0100): + a. Sample floor Z from the current CellSurface + b. If SampleFloorZ returns null (walked off the edge of this cell's floor): + - Check CellPortals for a transition to an adjacent cell + - If a portal connects to another EnvCell: transition to that cell + - If a portal connects to outdoor: transition to outdoor (sample terrain Z) + - If no portal matches: reject movement (can't walk through walls) + c. If SampleFloorZ returns a value: + - Step-height check same as outdoor + - Set Z = floorZ + +5. Gravity: if !isOnGround (no surface found under the entity): + candidatePos.Z -= gravity * dt + (Gravity is applied by the caller in Phase B.2; the engine just + reports isOnGround = false when no surface is found.) + +6. Return ResolveResult(resolvedPos, resolvedCellId, isOnGround) +``` + +**Note on gravity:** the engine itself is stateless per call — it doesn't track falling velocity. It reports `isOnGround = false` when no surface is under the entity, and the caller (Phase B.2's movement mode) applies a gravity velocity accumulator between frames. This keeps the engine pure-functional and testable. + +**Note on world coordinates:** the engine works in acdream's world space (center-landblock-relative). `TerrainSurface` works in landblock-local space (0..192); the engine translates between the two using the landblock's world offset (same math as `ApplyLoadedTerrain`). + +## Integration with existing code + +### Population (streaming → physics) + +In `GameWindow.ApplyLoadedTerrainLocked`, after terrain mesh upload: + +```csharp +// Create physics surfaces for this landblock. +var terrainSurf = new TerrainSurface(lb.Heightmap.Height, _heightTable); + +var cellSurfaces = new List(); +// Walk the same EnvCells we walk for rendering (Phase 7.1 / Task 8). +// For each cell with an EnvironmentId: create CellSurface from +// envCell + cellStruct. +// ... (reuse the interior-walker pattern) ... + +_physicsEngine.AddLandblock(lb.LandblockId, terrainSurf, cellSurfaces); +``` + +In the unload path (when `RemoveLandblock` fires), also call `_physicsEngine.RemoveLandblock(lbId)`. + +### Consumption (Phase B.2 — future, not this spec) + +The player movement mode will call `_physicsEngine.Resolve(...)` once per frame with the player's WASD-derived delta, then update the player entity's position and send the result to the server. + +## Testing strategy + +All tests in `tests/AcDream.Core.Tests/Physics/`. No dat files needed — tests construct minimal fake data. + +### `TerrainSurfaceTests` +- Construct a `TerrainSurface` with a known 81-byte height array + a known 256-float height table. +- **Flat terrain:** all heights = same value → `SampleZ` returns that value everywhere. +- **Slope:** heights increase linearly across X → `SampleZ` at midpoints returns interpolated values. +- **Corner clamping:** positions outside 0..192 are clamped to the boundary. +- **Cell ID computation:** `(0, 0)` → 0x0001, `(23, 0)` → 0x0001, `(24, 0)` → 0x0009 (second column), `(191, 191)` → 0x0040 (last cell). + +### `CellSurfaceTests` +- Construct a minimal `CellStruct` with one floor polygon (a flat square at Z=10, vertices at ±5 in X/Y). +- **Inside polygon:** `SampleFloorZ(0, 0)` → 10.0. +- **Outside polygon:** `SampleFloorZ(100, 100)` → null. +- **Sloped floor:** polygon with varying Z → barycentric interpolation returns correct Z at midpoints. + +### `PhysicsEngineTests` +- **Walk on flat terrain:** delta=(1,0,0), terrain flat at Z=50 → resolved position has Z=50. +- **Walk up a hill:** terrain slopes from Z=50 to Z=60 → resolved Z follows the slope. +- **Step up stairs (within height):** small Z increase (< stepUpHeight) → accepted. +- **Blocked by wall (exceeds height):** large Z increase (> stepUpHeight) → horizontal movement rejected. +- **Outdoor→indoor transition:** walk into an EnvCell's floor area → cellId changes to indoor. +- **Indoor→outdoor transition:** walk out of an EnvCell → cellId changes to outdoor. +- **Not on ground:** position over empty space (between floors) → isOnGround = false. + +## Open questions (resolved during implementation) + +- **Portal boundary detection:** the exact algorithm for "did the entity cross a portal" depends on the `CellPortal` dat structure. Implementation will check `EnvCell.CellPortals` for portal geometry and test the movement vector against it. If the portal geometry is just a reference (not spatial), the fallback is "if SampleFloorZ returns null on the current cell but non-null on an adjacent cell, transition." +- **Multi-story buildings:** if two EnvCells overlap in XY (one above the other), the engine picks the one whose floor Z is closest to the entity's current Z. This handles the Holtburg foundry's upper floor vs ground floor. +- **Landblock boundary crossing:** when the entity walks from one landblock to another, the engine switches to the neighboring landblock's TerrainSurface. This requires the neighbor to be loaded (it should be — the streaming window keeps a radius around the observer). + +## Acceptance criteria + +Phase B.3 is done when: + +1. `TerrainSurface.SampleZ` matches `SampleTerrainZ`'s output for the same inputs. +2. `CellSurface.SampleFloorZ` correctly projects XY onto floor polygons and returns the interpolated Z. +3. `PhysicsEngine.Resolve` handles all test cases listed above (flat, slope, step, transition, gravity). +4. All tests pass with fake data — no dat files or rendering needed. +5. Integration hook exists in `GameWindow.ApplyLoadedTerrainLocked` to populate the engine from streamed landblocks. +6. Total test count increases by ~15-20.