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:
parent
0aaededdd7
commit
fe0c76364c
1 changed files with 229 additions and 0 deletions
229
docs/superpowers/specs/2026-04-12-player-movement-design.md
Normal file
229
docs/superpowers/specs/2026-04-12-player-movement-design.md
Normal 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 (0x0001–0x0040 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue