docs(specs): Phase B.3 Complete — movement and world navigation design
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) <noreply@anthropic.com>
This commit is contained in:
parent
8b4d69fa8d
commit
cc5ab683ea
1 changed files with 272 additions and 0 deletions
272
docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md
Normal file
272
docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md
Normal file
|
|
@ -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<CellSurface> Cells,
|
||||||
|
IReadOnlyList<PortalPlane> 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<PortalPlane> 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue