From fe0c76364cc9ee35ecb9beb4e3b2d19a50d52a4b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 14:05:07 +0200 Subject: [PATCH] =?UTF-8?q?docs(specs):=20Phase=20B.2=20=E2=80=94=20player?= =?UTF-8?q?=20movement=20mode=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-04-12-player-movement-design.md | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-12-player-movement-design.md diff --git a/docs/superpowers/specs/2026-04-12-player-movement-design.md b/docs/superpowers/specs/2026-04-12-player-movement-design.md new file mode 100644 index 0000000..d092b89 --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-player-movement-design.md @@ -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` — hold key (Run = shift held) + - `current_style: Option` — combat stance (NonCombat for MVP) + - `forward_command: Option` — WalkForward (0x0005) / RunForward (0x0007) + - `forward_speed: Option` — movement speed multiplier + - `sidestep_command: Option` — SideStepRight (0x000F) / SideStepLeft (0x0010) + - `sidestep_speed: Option` + - `turn_command: Option` — TurnRight (0x000D) / TurnLeft (0x000E) + - `turn_speed: Option` +- `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`.