From cc5ab683ea77e27af47baa0d5d64f0347b75d36b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 15:54:12 +0200 Subject: [PATCH] =?UTF-8?q?docs(specs):=20Phase=20B.3=20Complete=20?= =?UTF-8?q?=E2=80=94=20movement=20and=20world=20navigation=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive spec for completing the physics/movement system: CellPortal-based indoor/outdoor/room-to-room transitions via sphere-plane intersection, multi-landblock boundary crossing, momentum-preserving jump with gravity arc, portal-space state machine for teleports, Setup.StepUpHeight hookup, and scenery-on-road exclusion fix. Replaces the current "outdoor heightmap sampler with disabled indoor transitions" with the full world-navigation system AC's client uses. 12 acceptance criteria, ~20 new unit tests planned. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-12-b3-complete-movement-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md diff --git a/docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md b/docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md new file mode 100644 index 0000000..5186d3b --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md @@ -0,0 +1,272 @@ +# Phase B.3 Complete — Movement and World Navigation Design + +**Status:** Spec, 2026-04-12, brainstormed with user. +**Scope:** Make the physics collision engine and movement system fully functional: CellPortal-based indoor/outdoor transitions, multi-landblock boundary crossing, jump with momentum-preserving gravity arc, portal-space state machine for teleports, Setup.StepUpHeight, and scenery-on-road fix. Replaces the current "outdoor heightmap sampler with disabled indoor transitions" with a complete world-navigation system. +**Parent:** `docs/plans/2026-04-11-roadmap.md` — Phase B (Gameplay), sub-piece B.3 completion. +**Depends on:** Phase B.2 (player movement mode) — already shipped. Phase B.3 partial (TerrainSurface, CellSurface, PhysicsEngine skeleton) — already shipped. + +## Goals + +1. Walk through a doorway into a building (outdoor→indoor via CellPortal plane crossing). +2. Walk between rooms inside a building (indoor→indoor via CellPortal). +3. Walk back out of a building (indoor→outdoor via CellPortal with OtherCellId=0xFFFF). +4. Walk from one landblock to the next seamlessly. +5. Jump while walking with momentum-preserving arc. +6. Jump off a ledge and land on terrain below (gravity when not on ground). +7. Enter a game portal → portal-space state → arrive at destination. +8. Step-height from Setup.StepUpHeight (not hardcoded). +9. No trees/stones on roads. +10. ACE console shows no movement rejection errors. + +## Non-goals + +- Charge jump (hold Space for higher jump) — deferred. +- Wall sliding (deflecting off walls at oblique angles) — deferred. +- Swimming / water depth detection — deferred. +- NPC/entity collision (only terrain + cell surfaces) — deferred. +- BSP tree acceleration for polygon lookups — brute-force polygon iteration is sufficient at cell-level polygon counts (< 20 per cell). + +## 1. Cell Transition System + +### Data model + +Each loaded EnvCell's portals are precomputed into portal planes at landblock-load time: + +```csharp +public readonly record struct PortalPlane( + Vector3 Normal, // plane normal (from portal polygon vertices) + float D, // plane distance (dot(normal, v0)) + uint TargetCellId, // OtherCellId (0xFFFF = outdoor) + uint OwnerCellId, // the EnvCell that owns this portal + PortalFlags Flags); // PortalSide flag for disambiguation +``` + +Stored in `PhysicsEngine` per landblock alongside the existing `TerrainSurface` + `CellSurface` entries: +```csharp +sealed record LandblockPhysics( + TerrainSurface Terrain, + IReadOnlyList Cells, + IReadOnlyList Portals, // NEW: all portals across all cells + float WorldOffsetX, + float WorldOffsetY); +``` + +Portal planes are computed at load time in `GameWindow.ApplyLoadedTerrainLocked` from `EnvCell.CellPortals` + `CellStruct.Polygons`: +1. For each EnvCell in the landblock +2. For each CellPortal in the cell +3. Look up `CellStruct.Polygons[portal.PolygonId]` +4. Extract the first 3 vertex positions (from `CellStruct.VertexArray`) +5. Transform to world space using `EnvCell.Position` (orientation + origin + landblock offset) +6. Compute plane: `normal = normalize(cross(v1-v0, v2-v0))`, `d = -dot(normal, v0)` +7. Store as `PortalPlane` with the portal's `OtherCellId`, `OwnerCellId`, and `Flags` + +### Three transition types + +**Outdoor → Indoor (entering a building):** +- Player is in an outdoor cell (cellId & 0xFFFF < 0x0100). +- Each frame, test the player's movement vector against all portal planes in the current landblock that belong to cells with `OtherCellId = 0xFFFF` (outside-facing portals — these are the building entry points viewed from outside). +- Crossing test: `sign(dot(normal, oldPos) + d) != sign(dot(normal, newPos) + d)`. +- On crossing: transition to the `OwnerCellId` of the portal (the EnvCell the doorway belongs to). Switch to that cell's `CellSurface` for Z resolution. + +**Indoor → Indoor (room to room):** +- Player is in an indoor cell (cellId & 0xFFFF >= 0x0100). +- Test only the current cell's portals (not all portals in the landblock). +- `OtherCellId != 0xFFFF` — points to the adjacent room. +- On crossing: transition to the target cell's `CellSurface`. + +**Indoor → Outdoor (exiting a building):** +- Same as indoor→indoor test, but `OtherCellId = 0xFFFF`. +- On crossing: switch back to `TerrainSurface.SampleZ` for Z resolution. Compute the outdoor cell ID from coordinates. + +### Outdoor → Outdoor (landblock crossing) + +No portals involved. When the candidate position falls outside the current landblock's [0, 192) local coordinate range, the existing `PhysicsEngine.Resolve` landblock-lookup loop picks up the adjacent landblock's `TerrainSurface`. This should work with the existing code — needs verification testing. + +## 2. Jump + Gravity + +### New state on PlayerMovementController + +```csharp +float VerticalVelocity; // current vertical speed (positive = up) +bool IsAirborne; // true from jump/fall until landing +const float JumpImpulse = 10f; // tunable initial upward velocity +const float Gravity = 20f; // downward acceleration per second squared +const float AirControlFactor = 0.2f; // horizontal steering while airborne +``` + +### Per-frame logic + +``` +1. If on ground AND Space pressed AND NOT airborne: + verticalVelocity = jumpImpulse + isAirborne = true + +2. If airborne: + verticalVelocity -= gravity * dt + candidateZ = position.Z + verticalVelocity * dt + + // Resolve ground Z at the candidate XY + groundZ = resolve terrain/floor Z at (candidateX, candidateY) + + if candidateZ <= groundZ: + // Landed + position.Z = groundZ + verticalVelocity = 0 + isAirborne = false + else: + position.Z = candidateZ + // Skip terrain Z snap — still in the air + + // Horizontal input at reduced speed + horizontal delta *= airControlFactor + +3. If NOT airborne AND resolved terrain Z is significantly below + current Z (> stepUpHeight gap): + // Walked off a ledge — enter falling state + isAirborne = true + verticalVelocity = 0 // start falling, not jumping +``` + +### Server interaction + +While airborne, still send `AutonomousPosition` heartbeats with the actual position. The server tracks the player's "in-air" state separately. `MoveToState` is sent at the start of the jump (motion command changes). + +## 3. Portal Space State Machine + +### States + +```csharp +enum PlayerState { InWorld, PortalSpace } +``` + +### Flow + +1. Server sends `PlayerTeleport(sequence)` game message. +2. `WorldSession.ProcessDatagram` fires a new `TeleportStarted(uint sequence)` event. +3. `GameWindow` handler: set `PlayerState = PortalSpace`. +4. In PortalSpace: + - All WASD/mouse movement input is ignored. + - No `MoveToState` / `AutonomousPosition` messages are sent. + - The player entity is optionally hidden (set a flag to skip rendering it). +5. Server sends `UpdatePosition` with the new landblock + position. +6. `OnLivePositionUpdated` handler detects the position is far from the current location (different landblock) and: + - Recenters the streaming controller on the new landblock. + - Resolves the new position through PhysicsEngine. + - Snaps the player entity to the resolved position. + - Sets `PlayerState = InWorld`. +7. Send `LoginComplete` (GameAction 0x00A1) to tell the server "I've finished loading" — same message as initial login, reused for teleport completion (per holtburger's PlayerTeleport handler). Reset the `_loginCompleteSent` latch to allow this. + +### TeleportStarted event on WorldSession + +New opcode handler in `ProcessDatagram`: +```csharp +else if (op == 0xF7B0u) // PlayerTeleport (GameEvent container) +{ + // Parse the teleport sequence from the event body. + // Fire TeleportStarted event. +} +``` + +Note: `PlayerTeleport` may arrive as a `GameEvent` (opcode 0xF7B0) with an inner event type, not as a standalone game message. Check holtburger's `GameEvent` enum for the exact inner opcode. Implementation will read the reference code to determine the wire layout. + +## 4. Remaining Fixes + +### Setup.StepUpHeight + +- `Setup` dat has a `float StepUpHeight` field. +- When entering player mode (Tab), read `setup.StepUpHeight` and set it on `PlayerMovementController.StepUpHeight`. +- If zero or missing, default to 2.0 (standard human height). + +### Scenery-on-road fix + +In `src/AcDream.Core/World/SceneryGenerator.cs`, the terrain type is extracted per vertex: +```csharp +uint terrainType = (uint)((raw >> 2) & 0x1F); +``` + +Road type in the raw terrain value has a specific bit pattern. Check if the vertex is a road terrain type and skip scenery generation: +```csharp +// Road check: terrain types with road flag set should not spawn scenery. +// The exact road type value needs to be verified against the +// Region.TerrainInfo dat data, but the road bit is typically +// indicated by specific terrain type indices that the original +// client excludes from scenery placement. +``` + +Implementation will verify the exact road terrain type value from the dat and add the exclusion check. + +### PhysicsEngine refactoring + +- Remove the `if (false)` disabled indoor-transition branch. +- Replace with real CellPortal plane-crossing logic from Section 1. +- Add `IReadOnlyList Portals` to `LandblockPhysics`. +- `Resolve` gains a `Vector3 oldPos` parameter (or derives it from `currentPos`) to test the movement vector against portal planes. +- Cell tracking: the engine needs to know which cell the player is currently in to test only that cell's portals (for indoor→indoor transitions). The `cellId` parameter already serves this purpose. + +## Testing Strategy + +### Unit tests (~20 new) + +**Portal plane extraction:** +- Given 3 known vertices, assert correct plane normal and distance. +- Test crossing detection: position on each side of the plane → `sign` differs. +- Test no crossing: positions on the same side → `sign` matches. + +**Indoor transition integration:** +- Construct a fake two-cell building (cell A with portal to cell B at a known plane). +- Start in cell A, move through the portal → assert cell ID changes to B. +- Move back through the portal → assert cell ID returns to A. + +**Outdoor→indoor transition:** +- Construct a fake EnvCell with an outside-facing portal (OtherCellId=0xFFFF). +- Start outdoors, move through the portal → assert cell ID changes to the EnvCell. + +**Indoor→outdoor transition:** +- Start inside the cell, move through the outside-facing portal → assert cell ID changes to outdoor. + +**Landblock boundary crossing:** +- Two adjacent landblocks registered in the engine. +- Walk from X=190 to X=194 → assert the engine picks up the second landblock's terrain Z. + +**Jump arc:** +- Start at Z=50 on flat terrain, jump → assert Z increases above 50. +- Continue frames → assert Z peaks then decreases. +- Assert landing: Z returns to 50, IsAirborne = false. + +**Falling off ledge:** +- Terrain drops from Z=50 to Z=30 at a step. Walk off the edge. +- Assert: IsAirborne becomes true, Z decreases toward 30, lands at Z=30. + +**Portal space state:** +- Simulate TeleportStarted → assert PlayerState = PortalSpace. +- Simulate UpdatePosition with new location → assert PlayerState = InWorld, position updated. + +**Scenery road exclusion:** +- Construct terrain data with a road-type vertex. +- Assert SceneryGenerator produces no scenery at that vertex. + +## Acceptance Criteria + +Phase B.3 Complete is done when ALL of the following pass: + +1. Walk outdoors on terrain following heightmap Z. +2. Walk from one landblock to the next seamlessly. +3. Walk through a doorway into a building (outdoor→indoor via CellPortal). +4. Walk around inside a building on the correct floor. +5. Walk back out of a building (indoor→outdoor via CellPortal). +6. Walk between rooms inside a building (indoor→indoor via CellPortal). +7. Jump while walking (momentum-preserving arc). +8. Jump off a ledge and land on terrain below. +9. Enter a game portal → portal-space state → arrive at destination. +10. Step-height read from Setup.StepUpHeight. +11. No trees/stones on roads. +12. ACE console shows no movement rejection errors. +13. All new unit tests pass (~20). Total test count increases. + +## Open Questions (resolved during implementation) + +- **Portal polygon vertex winding:** the plane normal direction determines which side is "inside." The `PortalFlags.PortalSide` flag disambiguates. Implementation will read ACViewer's `EnvCell.cs` to confirm the convention. +- **Outdoor→indoor portal detection radius:** testing every portal in the landblock each frame could be expensive with many buildings. If profiling shows this, add a spatial hash or AABB pre-filter. Expected to be fine for Holtburg (< 50 portals per landblock). +- **PlayerTeleport wire format:** may be a GameEvent (0xF7B0) with inner type, or a standalone message. Check holtburger's opcode table during implementation. +- **Exact road terrain type value:** verify from the Region.TerrainInfo dat during implementation. +- **Jump impulse / gravity tuning:** start with JumpImpulse=10, Gravity=20. Tune to match retail feel during visual verification.