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>
This commit is contained in:
Erik 2026-04-12 14:05:07 +02:00
parent 0aaededdd7
commit fe0c76364c

View file

@ -0,0 +1,229 @@
# 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`.