acdream/docs/superpowers/specs/2026-04-12-player-movement-design.md
Erik fe0c76364c docs(specs): Phase B.2 — player movement mode design
Tab-toggled player mode with WASD ground walking, A/D + mouse
turning, Z/X strafing, Shift for run, third-person chase camera,
local walk/run/turn/idle animations, and outbound MoveToState +
AutonomousPosition server messages. Uses PhysicsEngine from B.3
for collision-resolved positions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:05:07 +02:00

229 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase B.2 — Player Movement Mode Design
**Status:** Spec, 2026-04-12, brainstormed with user.
**Scope:** A new player-controlled movement mode where WASD walks the character on collision-resolved terrain, the server sees valid movement, other clients see the character at the correct position, and the character plays walk/run/turn/idle animations locally. Toggled with Tab; coexists with the existing fly and orbit camera modes.
**Parent:** `docs/plans/2026-04-11-roadmap.md` — Phase B (Gameplay), sub-piece B.2.
**Depends on:** Phase B.3 (physics collision engine) — already shipped.
## Goals
1. Walk the player character on the server with WASD controls.
2. The server accepts the movement (no rejection / rubber-banding).
3. Other clients (original AC client or another acdream instance) see the character at the correct position.
4. The character plays walk/run/turn/idle animations locally (not sliding).
5. Third-person chase camera follows behind the character.
6. Mouse + A/D keyboard turning rotates the character; the turn is sent to the server.
7. Toggle between player mode and fly mode with Tab.
## Non-goals
- Jump (physics arc + landing — adds gravity state complexity, deferred).
- Combat stance switching (deferred to Phase B.4+).
- Indoor movement (B.3's PhysicsEngine supports it; we just don't test it in the acceptance criteria).
- NPC/entity collision (only terrain + EnvCell floor collision via PhysicsEngine).
- Smooth position interpolation for other players' movement (existing `UpdatePosition` snap behavior is acceptable for now).
## Input Mapping (Player Mode Active)
| Key | Action |
|---|---|
| **W / S** | Walk forward / backward (~4 u/s) |
| **Shift + W / Shift + S** | Run forward / backward (~7 u/s) |
| **A / D** | Turn left / right (keyboard turning) |
| **Z / X** | Strafe left / right |
| **Mouse X** | Turn (mouse turning, same yaw axis as A/D) |
| **Mouse Y** | Adjust chase camera pitch (look up/down) |
| **Tab** | Exit player mode, return to fly mode |
Speed values are approximate — the exact values should match what ACE's `MovementHandler` accepts. Check `references/ACE/Source/ACE.Server/` and `references/holtburger/` for the validated run/walk rates.
## Outbound Server Messages
### `MoveToState` (GameAction, opcode 0xF61C)
Sent when the player's motion state *changes*: starts walking, stops, starts turning, changes speed (walk↔run), starts strafing.
**Payload (per holtburger `MoveToStateActionData`):**
- `RawMotionState`:
- `flags: RawMotionFlags` — bitmask of which fields are present
- `current_hold_key: Option<u32>` — hold key (Run = shift held)
- `current_style: Option<u32>` — combat stance (NonCombat for MVP)
- `forward_command: Option<u32>` — WalkForward (0x0005) / RunForward (0x0007)
- `forward_speed: Option<f32>` — movement speed multiplier
- `sidestep_command: Option<u32>` — SideStepRight (0x000F) / SideStepLeft (0x0010)
- `sidestep_speed: Option<f32>`
- `turn_command: Option<u32>` — TurnRight (0x000D) / TurnLeft (0x000E)
- `turn_speed: Option<f32>`
- `WorldPosition`:
- `cell_id: u32` — full landblock+cell ID (from PhysicsEngine.Resolve)
- `position: Vector3` — world XYZ
- `rotation: Quaternion` — world orientation
- Sequence numbers: `instance_sequence`, `server_control_sequence`, `teleport_sequence`, `force_position_sequence` (all u16)
- `contact_long_jump: u8` — ground contact flag
### `AutonomousPosition` (GameAction, opcode 0xF753)
Periodic heartbeat sent every ~200ms while the player is moving. Carries the current `WorldPosition` + sequence numbers + ground contact flag. Does NOT carry motion commands — the server already knows those from `MoveToState`.
### Wrapping in GameAction
Both messages are sent as `GameAction` (game message opcode 0xF7B1):
```
u32 game_message_opcode = 0xF7B1
u32 action_sequence = next game action sequence
u32 action_type_opcode = 0xF61C (MoveToState) or 0xF753 (AutonomousPosition)
... payload ...
```
This matches how `GameActionLoginComplete` (0x00A1) is already sent in Phase 4.10. The difference is that MoveToState/AutonomousPosition carry a structured payload after the action type opcode.
**Note on game action sequence:** holtburger increments a `game_action_sequence` counter per outbound game action. WorldSession already has `_fragmentSequence` for fragment-level sequencing; a new `_gameActionSequence` counter is needed for the action-level sequence field.
## Third-Person Chase Camera
New `ChaseCamera` class implementing `ICamera`:
- **Position:** behind + above the player at a configurable offset. In player-local space: `(-distance * cos(pitch), 0, distance * sin(pitch))` rotated by the player's yaw. Default distance ~8 units, default pitch ~20 degrees above horizontal.
- **LookAt:** the player's position + a small vertical offset (eye height, ~1.5 units).
- **Update per frame:** receives the player's current world position + yaw + mouse-Y-driven pitch delta. Recomputes the camera's world position from the offset math.
- **Projection:** same parameters as FlyCamera (FovY = PI/3, near = 1, far = 5000).
Camera does NOT collide with terrain/buildings — if the camera clips through a hill behind the player, that's acceptable for the MVP. Camera collision is Phase C polish.
## Walk Animation (Local)
When player mode is active:
- Detect which motion command the current input maps to:
- W held → `WalkForward` (0x45000005), or `RunForward` (0x44000007) if Shift held
- S held → `WalkForward` with negative speed (backward)
- Z held → `SideStepLeft` (0x65000010)
- X held → `SideStepRight` (0x6500000F)
- A held (no mouse) → `TurnLeft` (0x6500000E)
- D held (no mouse) → `TurnRight` (0x6500000D)
- Nothing held → `Ready` (0x41000003, idle breathing)
- Use `MotionResolver.GetIdleCycle` with the command as `commandOverride` to resolve the animation cycle from the player's motion table.
- Register/update the player entity in `_animatedEntities` so `TickAnimations` plays the resolved cycle with slerp interpolation (existing infrastructure from Phase 6.4/6.5).
- Only update the cycle when the motion command *changes* (idle→walk, walk→run, etc.) — don't re-resolve every frame.
**Priority when multiple keys are held:** forward/backward takes priority over sidestep; sidestep takes priority over turn. Matches retail client behavior.
## Architecture
### New Files
| File | Responsibility |
|---|---|
| `src/AcDream.App/Input/PlayerMovementController.cs` | Per-frame input → physics → position update + outbound message decision logic |
| `src/AcDream.App/Rendering/ChaseCamera.cs` | Third-person camera implementing `ICamera` |
| `src/AcDream.Core.Net/Messages/MoveToState.cs` | Outbound `MoveToState` message builder |
| `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` | Outbound `AutonomousPosition` heartbeat builder |
### Modified Files
| File | Changes |
|---|---|
| `src/AcDream.App/Rendering/GameWindow.cs` | Tab toggle, wire PlayerMovementController + ChaseCamera into OnUpdate/OnRender, local animation cycle switching, identify player entity by guid |
| `src/AcDream.App/Rendering/CameraController.cs` | Add chase camera as a third mode (orbit / fly / chase) |
| `src/AcDream.Core.Net/WorldSession.cs` | Add `_gameActionSequence` counter, add `SendGameAction(byte[])` method that wraps payload in the 0xF7B1 GameAction envelope with incrementing sequence |
### Data Flow (Per Frame, Player Mode)
```
OnUpdate:
1. Read keyboard (WASD/ZX/Shift/AD) + mouse delta
2. PlayerMovementController.Update(dt, inputState):
a. Compute yaw delta from mouse X + A/D keyboard turn
b. Update player yaw
c. Compute movement delta from W/S + Z/X in the player's
facing direction (yaw-rotated), scaled by walk/run speed * dt
d. PhysicsEngine.Resolve(currentPos, currentCellId, delta, stepUpHeight)
→ resolvedPos, resolvedCellId, isOnGround
e. Update playerEntity.Position = resolvedPos
f. Update playerEntity.Rotation = Quaternion.CreateFromAxisAngle(UnitZ, yaw)
g. Detect motion state change (compare current vs previous command)
h. If changed: build MoveToState → WorldSession.SendGameAction
i. Every ~200ms: build AutonomousPosition → WorldSession.SendGameAction
3. Update walk animation on playerEntity:
- If motion command changed: re-resolve cycle via MotionResolver
- Update _animatedEntities entry
4. ChaseCamera.Update(playerEntity.Position, yaw, cameraPitch)
OnRender:
- _cameraController.Active returns ChaseCamera when in player mode
- Frustum culling + terrain + static mesh draw as usual
```
### Player Entity Identification
The player's own character is identified by matching the `EntitySpawn.Guid` from the `CreateObject` received at login against the character's GUID from `CharacterList`. GameWindow already stores `_entitiesByServerGuid` — the player entity is `_entitiesByServerGuid[chosenCharacterGuid]`. Store the player's server guid as a field on GameWindow when `EnterWorld` is called.
### Coordinate Space for Server Messages
AC positions on the wire use `(landblock cell ID, local XY, Z, rotation)`. The cell ID is the full 32-bit value: `(landblockX << 24) | (landblockY << 16) | cellIndex`. `PhysicsEngine.Resolve` currently returns a cell index (0x00010x0040 outdoor, 0x0100+ indoor) without the landblock prefix. The message builder needs to compose the full cell ID from the entity's world position (to get the landblock coordinates) + the engine's cell index.
acdream's internal world coordinates are center-landblock-relative. Converting back to AC wire coordinates requires:
- `landblockX = _liveCenterX + floor(worldPos.X / 192)`
- `landblockY = _liveCenterY + floor(worldPos.Y / 192)`
- `localX = worldPos.X - (landblockX - _liveCenterX) * 192`
- `localY = worldPos.Y - (landblockY - _liveCenterY) * 192`
- `localZ = worldPos.Z`
- `cellId = (landblockX << 24) | (landblockY << 16) | resolvedCellIndex`
This is the inverse of the `OnLiveEntitySpawned` coordinate translation.
## Testing Strategy
### Unit Tests
- **`PlayerMovementControllerTests`** — with a fake PhysicsEngine:
- Forward input → position advances in facing direction
- Shift+forward → position advances at run speed (faster)
- Turn input → yaw changes
- Motion state change detection: idle→walk→idle transitions
- Heartbeat timing: AutonomousPosition sent every ~200ms during movement
- **`MoveToStateTests`** — message builder:
- Build with a known RawMotionState + WorldPosition → assert byte layout
- Verify GameAction envelope (0xF7B1 + sequence + 0xF61C)
- **`AutonomousPositionTests`** — message builder:
- Build with a known WorldPosition → assert byte layout
- Verify GameAction envelope (0xF7B1 + sequence + 0xF753)
- **`ChaseCameraTests`** — offset math:
- Player at origin, yaw=0 → camera at (-8, 0, ~3)
- Player at origin, yaw=PI/2 → camera at (0, -8, ~3)
- Pitch adjustment changes camera height
### Integration / Visual Verification
- Log in at Holtburg.
- Press Tab to enter player mode.
- Walk with W → character walks forward on terrain, walk animation plays.
- Shift+W → run animation, faster movement.
- A/D → character turns, camera follows.
- Release all keys → character returns to idle breathing.
- Open original AC client with different account → see acdream's character at the correct position, visually moving.
- ACE console shows no movement rejections or errors.
- Close acdream → graceful logout (already working).
## Acceptance Criteria
Phase B.2 is done when:
1. Tab toggles between fly mode and player mode.
2. WASD/ZX walks/strafes the character on collision-resolved terrain.
3. A/D + mouse turns the character.
4. Shift toggles walk/run speed.
5. Walk/run/turn/idle animations play correctly on the player's character.
6. The server accepts the movement (no ACE-side rejections in the console log).
7. Other clients see the character at the correct position.
8. Third-person chase camera follows the character smoothly.
9. All new unit tests pass. Total test count increases by ~15-20.
## Open Questions (Resolved During Implementation)
- **Exact walk/run speed values:** check ACE's `MovementHandler` for the server-side validation thresholds. Use holtburger's default speeds as a starting point.
- **RawMotionState flag encoding:** the exact bitmask layout comes from holtburger's `RawMotionFlags`. Implementation will read `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs` for the canonical encoding.
- **Sequence number management:** the four sequence counters (instance, server_control, teleport, force_position) need to be tracked per-session. Start at the values ACE sends in the initial handshake and increment as holtburger does. If exact behavior is unclear, start at 0 and adjust if ACE rejects.
- **WorldPosition rotation encoding:** AC wire quaternion order is (W, X, Y, Z). Our internal `System.Numerics.Quaternion` is (X, Y, Z, W). The message builder must swap the order — same transform already used in `OnLiveEntitySpawned`.