7-task plan: PortalPlane math, PhysicsEngine portal transitions, populate portals from streaming, jump+gravity+falling, portal-space state machine, Setup.StepUpHeight + scenery road fix, and visual verification with 12 acceptance criteria. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
357 lines
14 KiB
Markdown
357 lines
14 KiB
Markdown
# Phase B.3 Complete — Movement and World Navigation 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:** Complete the physics collision engine with CellPortal-based indoor transitions, jump with gravity, portal-space teleport handling, and scenery/step-height fixes.
|
|
|
|
**Architecture:** Extend `PhysicsEngine.Resolve` with portal-plane crossing detection for indoor/outdoor transitions. Add vertical velocity + gravity to `PlayerMovementController`. Add a portal-space state machine triggered by `PlayerTeleport` server messages. Fix scenery road exclusion and step-height from Setup dat.
|
|
|
|
**Tech Stack:** .NET 10, System.Numerics, DatReaderWriter, xUnit.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-12-b3-complete-movement-design.md`
|
|
|
|
---
|
|
|
|
## File structure
|
|
|
|
```
|
|
src/AcDream.Core/Physics/
|
|
PortalPlane.cs [new] portal plane record + crossing test
|
|
PhysicsEngine.cs [modify] add portal planes to LandblockPhysics,
|
|
replace disabled indoor branch with
|
|
portal-plane crossing logic
|
|
CellSurface.cs [no change — portals live on PhysicsEngine]
|
|
|
|
src/AcDream.App/Input/
|
|
PlayerMovementController.cs [modify] add jump/gravity/airborne state,
|
|
Space input, ledge-fall detection
|
|
|
|
src/AcDream.Core.Net/
|
|
WorldSession.cs [modify] add PlayerTeleport handler + event
|
|
|
|
src/AcDream.App/Rendering/
|
|
GameWindow.cs [modify] portal-space state machine, populate
|
|
portal planes at load time, pass
|
|
Setup.StepUpHeight, wire Space input
|
|
|
|
src/AcDream.Core/World/
|
|
SceneryGenerator.cs [modify] add road terrain-type exclusion
|
|
|
|
tests/AcDream.Core.Tests/Physics/
|
|
PortalPlaneTests.cs [new]
|
|
PhysicsEngineTests.cs [modify] add portal transition + landblock
|
|
boundary tests
|
|
|
|
tests/AcDream.Core.Tests/Input/
|
|
PlayerMovementControllerTests.cs [modify] add jump/gravity/fall tests
|
|
|
|
tests/AcDream.Core.Tests/World/
|
|
SceneryGeneratorTests.cs [new] road exclusion test
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: PortalPlane (record + plane math + crossing test)
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.Core/Physics/PortalPlane.cs`
|
|
- Test: `tests/AcDream.Core.Tests/Physics/PortalPlaneTests.cs`
|
|
|
|
Pure math — no dependencies on the rest of the physics engine. Build the portal plane record with a static crossing-detection method.
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create tests covering: plane construction from 3 vertices, crossing detection (positions on opposite sides), no crossing (positions on same side), crossing at exact plane.
|
|
|
|
```csharp
|
|
// PortalPlaneTests.cs
|
|
[Fact] FromVertices_ComputesCorrectNormal()
|
|
[Fact] IsCrossing_PositionsOnOppositeSides_ReturnsTrue()
|
|
[Fact] IsCrossing_PositionsOnSameSide_ReturnsFalse()
|
|
[Fact] IsCrossing_StartOnPlane_ReturnsFalse()
|
|
```
|
|
|
|
- [ ] **Step 2: Implement PortalPlane**
|
|
|
|
Create `src/AcDream.Core/Physics/PortalPlane.cs`:
|
|
|
|
```csharp
|
|
namespace AcDream.Core.Physics;
|
|
|
|
public readonly record struct PortalPlane(
|
|
Vector3 Normal,
|
|
float D,
|
|
uint TargetCellId, // OtherCellId (0xFFFF = outdoor)
|
|
uint OwnerCellId, // the EnvCell that owns this portal
|
|
ushort Flags)
|
|
{
|
|
public static PortalPlane FromVertices(
|
|
Vector3 v0, Vector3 v1, Vector3 v2,
|
|
uint targetCellId, uint ownerCellId, ushort flags)
|
|
{
|
|
var edge1 = v1 - v0;
|
|
var edge2 = v2 - v0;
|
|
var normal = Vector3.Normalize(Vector3.Cross(edge1, edge2));
|
|
float d = -Vector3.Dot(normal, v0);
|
|
return new PortalPlane(normal, d, targetCellId, ownerCellId, flags);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test whether the movement from oldPos to newPos crosses this plane.
|
|
/// Returns true if the two positions are on opposite sides.
|
|
/// </summary>
|
|
public bool IsCrossing(Vector3 oldPos, Vector3 newPos)
|
|
{
|
|
float oldDist = Vector3.Dot(Normal, oldPos) + D;
|
|
float newDist = Vector3.Dot(Normal, newPos) + D;
|
|
// Different signs = crossed the plane. Ignore near-zero (on the plane).
|
|
return oldDist * newDist < 0f;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests, commit**
|
|
|
|
```bash
|
|
git commit -m "feat(core): Phase B.3 — PortalPlane (plane math + crossing detection)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: PhysicsEngine portal-based indoor transitions
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs`
|
|
- Modify: `tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs`
|
|
|
|
Replace the `if (false)` disabled branch with real CellPortal plane-crossing logic.
|
|
|
|
- [ ] **Step 1: Extend LandblockPhysics record**
|
|
|
|
Add `IReadOnlyList<PortalPlane> Portals` to the record and `AddLandblock` signature.
|
|
|
|
- [ ] **Step 2: Write failing tests**
|
|
|
|
Add to `PhysicsEngineTests.cs`:
|
|
```csharp
|
|
[Fact] Resolve_OutdoorThroughPortal_TransitionsToIndoor()
|
|
[Fact] Resolve_IndoorThroughPortal_TransitionsToOtherCell()
|
|
[Fact] Resolve_IndoorThroughExitPortal_TransitionsToOutdoor()
|
|
[Fact] Resolve_LandblockBoundary_PicksAdjacentTerrain()
|
|
```
|
|
|
|
Each test constructs a fake portal plane at a known position and asserts cell ID changes when the movement crosses it.
|
|
|
|
- [ ] **Step 3: Implement portal crossing in Resolve**
|
|
|
|
Replace the `if (false)` block. The logic:
|
|
|
|
For **outdoor** players: test all portals in the current landblock where `TargetCellId != 0xFFFF` (outside-facing portals have OwnerCellId = building entry; TargetCellId = 0xFFFF means the portal FACES outside, so from outside you enter the OwnerCellId). Actually, the outside-facing portals should be tested from the outdoor side — when `IsCrossing` is true, the player enters the `OwnerCellId`.
|
|
|
|
For **indoor** players: test only portals belonging to the current cell. Get them by filtering on `OwnerCellId == currentCellId`. On crossing, transition to `TargetCellId` (or outdoor if `TargetCellId == 0xFFFF`).
|
|
|
|
**IMPORTANT:** Read the spec section 1 carefully. Also read `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs` for the canonical transition logic. The `PortalFlags.PortalSide` flag may affect which direction the crossing triggers. Implementation should follow ACViewer's pattern.
|
|
|
|
- [ ] **Step 4: Update the existing indoor-transition test**
|
|
|
|
`Resolve_EnterIndoorCell_StaysOutdoor_BecauseTransitionDisabled` should be renamed to `Resolve_OutdoorThroughPortal_TransitionsToIndoor` and updated to use a portal plane instead.
|
|
|
|
- [ ] **Step 5: Run tests, commit**
|
|
|
|
```bash
|
|
git commit -m "feat(core): Phase B.3 — CellPortal-based indoor/outdoor transitions in PhysicsEngine"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Populate portal planes from streaming
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (ApplyLoadedTerrainLocked)
|
|
|
|
- [ ] **Step 1: Extract portal planes at landblock load time**
|
|
|
|
In `ApplyLoadedTerrainLocked`, after the existing CellSurface construction loop, add a second loop that extracts portal planes from each EnvCell:
|
|
|
|
For each EnvCell:
|
|
For each CellPortal in envCell.CellPortals:
|
|
Look up the polygon via `cellStruct.PhysicsPolygons[portal.PolygonId]` (note: the key type is ushort; cast PolygonId)
|
|
Get the first 3 vertex positions from the polygon's VertexIds
|
|
Transform to world space (same transform already used for CellSurface vertices)
|
|
Create a `PortalPlane.FromVertices(v0, v1, v2, portal.OtherCellId, envCellId & 0xFFFF, (ushort)portal.Flags)`
|
|
Add to a `List<PortalPlane>`
|
|
|
|
Pass the portal list to `_physicsEngine.AddLandblock`.
|
|
|
|
**IMPORTANT:** The portal's `PolygonId` indexes `CellStruct.Polygons` (rendering polygons), NOT `CellStruct.PhysicsPolygons`. Verify by reading the CellPortal definition — the PolygonId is used in ACViewer's `CellStructure.Polygons[portal.PolygonId]`. Check which dictionary it actually indexes. If it's the rendering Polygons dict, use that instead.
|
|
|
|
- [ ] **Step 2: Build + test (no new unit tests — verified via Task 2's integration tests + live run)**
|
|
|
|
```bash
|
|
git commit -m "feat(app): Phase B.3 — populate portal planes from streaming pipeline"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Jump + gravity + falling
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Input/PlayerMovementController.cs`
|
|
- Modify: `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (wire Space key)
|
|
|
|
- [ ] **Step 1: Add MovementInput.Jump field**
|
|
|
|
Add `bool Jump = false` to the `MovementInput` record.
|
|
|
|
- [ ] **Step 2: Add airborne state to PlayerMovementController**
|
|
|
|
Add fields:
|
|
```csharp
|
|
public float VerticalVelocity { get; private set; }
|
|
public bool IsAirborne { get; private set; }
|
|
public float JumpImpulse { get; set; } = 10f;
|
|
public float GravityAccel { get; set; } = 20f;
|
|
public float AirControlFactor { get; set; } = 0.2f;
|
|
```
|
|
|
|
- [ ] **Step 3: Implement jump/gravity in Update**
|
|
|
|
After horizontal movement resolution, add:
|
|
- On Space + onGround + !IsAirborne: set VerticalVelocity = JumpImpulse, IsAirborne = true
|
|
- If IsAirborne: apply gravity, check if landed (candidateZ <= groundZ)
|
|
- If !IsAirborne and resolved Z is significantly below current Z: enter falling state
|
|
- While airborne: reduce horizontal input by AirControlFactor
|
|
|
|
- [ ] **Step 4: Write failing tests**
|
|
|
|
```csharp
|
|
[Fact] Update_JumpOnFlatTerrain_BecomesAirborne()
|
|
[Fact] Update_AirborneFrame_ZIncreasesThenDecreases()
|
|
[Fact] Update_Landing_ReturnsToGround()
|
|
[Fact] Update_WalkOffLedge_BecomesFalling()
|
|
```
|
|
|
|
- [ ] **Step 5: Wire Space key in GameWindow**
|
|
|
|
In the player-mode OnUpdate block, add `Jump: kb.IsKeyPressed(Key.Space)` to the MovementInput constructor.
|
|
|
|
- [ ] **Step 6: Run tests, commit**
|
|
|
|
```bash
|
|
git commit -m "feat(app+core): Phase B.3 — jump with momentum-preserving gravity arc"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Portal space state machine
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.Core.Net/WorldSession.cs`
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
|
- Modify: `src/AcDream.App/Input/PlayerMovementController.cs`
|
|
|
|
- [ ] **Step 1: Add PlayerTeleport handler to WorldSession**
|
|
|
|
In `ProcessDatagram`, add a handler for the PlayerTeleport opcode. Check holtburger for the exact opcode — it may be a GameEvent (0xF7B0) with inner type. Add a `TeleportStarted` event on WorldSession.
|
|
|
|
- [ ] **Step 2: Add PlayerState enum to PlayerMovementController**
|
|
|
|
```csharp
|
|
public enum PlayerState { InWorld, PortalSpace }
|
|
public PlayerState State { get; private set; } = PlayerState.InWorld;
|
|
```
|
|
|
|
In `Update`: if State == PortalSpace, return immediately (no movement processed).
|
|
|
|
- [ ] **Step 3: Wire in GameWindow**
|
|
|
|
Subscribe to `TeleportStarted` → set controller State to PortalSpace.
|
|
In `OnLivePositionUpdated`: if currently in PortalSpace and position changed significantly, recenter streaming, snap position, set State to InWorld, send LoginComplete.
|
|
|
|
Reset `_loginCompleteSent` latch to allow re-sending after teleport.
|
|
|
|
- [ ] **Step 4: Run tests, commit**
|
|
|
|
```bash
|
|
git commit -m "feat(net+app): Phase B.3 — portal-space state machine for teleports"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Setup.StepUpHeight + scenery road fix
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
|
- Modify: `src/AcDream.Core/World/SceneryGenerator.cs`
|
|
- Test: `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs` (new)
|
|
|
|
- [ ] **Step 1: Read Setup.StepUpHeight when entering player mode**
|
|
|
|
In the Tab handler, after loading the Setup, set:
|
|
```csharp
|
|
_playerController.StepUpHeight = setup.StepUpHeight > 0f ? setup.StepUpHeight : 2f;
|
|
```
|
|
|
|
- [ ] **Step 2: Add road exclusion to SceneryGenerator**
|
|
|
|
In `SceneryGenerator.Generate`, after extracting `terrainType` at line 68, add a check to skip road terrain types. The exact road value needs verification from the dat — check `Region.TerrainInfo.TerrainTypes` for which indices are roads, or check if the raw terrain value has a road bit.
|
|
|
|
Read `references/ACViewer/` or `references/WorldBuilder/` for how they detect road terrain. The simplest approach: check `terrainType` against known road indices from the Region dat.
|
|
|
|
- [ ] **Step 3: Write a scenery road exclusion test**
|
|
|
|
Create `tests/AcDream.Core.Tests/World/SceneryGeneratorTests.cs` with a test that verifies no scenery is generated at a road vertex.
|
|
|
|
- [ ] **Step 4: Run all tests, commit**
|
|
|
|
```bash
|
|
git commit -m "fix(app+core): Phase B.3 — Setup.StepUpHeight + scenery road exclusion"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Visual verification + roadmap update
|
|
|
|
- [ ] **Step 1: Live verification**
|
|
|
|
Launch the app, log in, press Tab, and verify all 12 acceptance criteria from the spec:
|
|
1. Walk outdoors ✓ (already works)
|
|
2. Walk to next landblock
|
|
3. Walk into a building through a door
|
|
4. Walk inside a building
|
|
5. Walk back out
|
|
6. Walk between rooms
|
|
7. Jump while walking
|
|
8. Jump off a ledge
|
|
9. Enter a portal → arrive at destination
|
|
10. Step-height feels right
|
|
11. No trees on roads
|
|
12. ACE console clean
|
|
|
|
- [ ] **Step 2: Update roadmap**
|
|
|
|
Mark B.3 Complete as shipped in `docs/plans/2026-04-11-roadmap.md`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git commit -m "docs: mark Phase B.3 Complete (movement + world navigation) as shipped"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-review
|
|
|
|
**Spec coverage:**
|
|
- CellPortal indoor transitions (outdoor→indoor, indoor→indoor, indoor→outdoor) → Tasks 1, 2, 3 ✓
|
|
- Multi-landblock boundary crossing → Task 2 (test) ✓
|
|
- Jump + gravity + falling → Task 4 ✓
|
|
- Portal space state machine → Task 5 ✓
|
|
- Setup.StepUpHeight → Task 6 ✓
|
|
- Scenery road fix → Task 6 ✓
|
|
- All 12 acceptance criteria → Task 7 ✓
|
|
|
|
**Placeholder scan:** Tasks 2 and 3 have "read the reference code" instructions instead of exact code — this is intentional because the portal-plane crossing logic depends on ACViewer's exact convention for PortalFlags.PortalSide and polygon winding, which must be verified at implementation time. The subagent is instructed to read specific reference files.
|
|
|
|
**Type consistency:** `PortalPlane` record used in Tasks 1, 2, 3. `PlayerState` enum in Task 5. `MovementInput.Jump` in Task 4. All consistent.
|