From 631fd3c9bb0c21562e5b59375102a96242679c1b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 14:10:13 +0200 Subject: [PATCH] docs(plans): Phase B.2 player movement implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-task plan: MoveToState + AutonomousPosition message builders, ChaseCamera (third-person), PlayerMovementController (input → physics → motion state), CameraController chase mode, GameWindow wiring (Tab toggle, animation, coordinate conversion), and roadmap update. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-12-player-movement.md | 1099 +++++++++++++++++ 1 file changed, 1099 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-12-player-movement.md diff --git a/docs/superpowers/plans/2026-04-12-player-movement.md b/docs/superpowers/plans/2026-04-12-player-movement.md new file mode 100644 index 0000000..845507e --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-player-movement.md @@ -0,0 +1,1099 @@ +# Phase B.2 — Player Movement Mode 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:** Walk the player character on the server with WASD, with collision-resolved terrain, walk/run/turn animations, third-person chase camera, and outbound movement messages that ACE accepts. + +**Architecture:** New `PlayerMovementController` reads input and drives `PhysicsEngine.Resolve` → position update → outbound `MoveToState` / `AutonomousPosition` server messages. New `ChaseCamera` follows the player. Tab toggles between fly mode and player mode. Walk/run/turn/idle animations resolved locally via `MotionResolver.GetIdleCycle`. + +**Tech Stack:** .NET 10, Silk.NET (input + windowing), System.Numerics, DatReaderWriter, xUnit. + +**Spec:** `docs/superpowers/specs/2026-04-12-player-movement-design.md` + +--- + +## File structure + +``` +src/AcDream.Core.Net/ + Messages/ + MoveToState.cs [new] outbound message builder + AutonomousPosition.cs [new] outbound heartbeat builder + WorldSession.cs [modify] add SendGameAction + _gameActionSequence + +src/AcDream.App/ + Input/ + PlayerMovementController.cs [new] input → physics → position + message decisions + Rendering/ + ChaseCamera.cs [new] third-person camera implementing ICamera + CameraController.cs [modify] add chase mode + GameWindow.cs [modify] Tab toggle, wire controller + camera + animation + +tests/AcDream.Core.Net.Tests/ + Messages/ + MoveToStateTests.cs [new] + AutonomousPositionTests.cs [new] + +tests/AcDream.Core.Tests/ + Rendering/ + ChaseCameraTests.cs [new] + Input/ + PlayerMovementControllerTests.cs [new] +``` + +--- + +## Task 1: MoveToState + AutonomousPosition message builders + +**Files:** +- Create: `src/AcDream.Core.Net/Messages/MoveToState.cs` +- Create: `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` +- Modify: `src/AcDream.Core.Net/WorldSession.cs` +- Test: `tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs` +- Test: `tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs` + +This task builds the outbound wire-format message constructors and adds `SendGameAction` to WorldSession. + +- [ ] **Step 1: Read holtburger reference files** + +Before writing any code, read these files to understand the exact byte layout: +- `references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs` — MoveToStateActionData + AutonomousPositionActionData pack/unpack +- `references/holtburger/crates/holtburger-protocol/src/messages/movement/types.rs` — RawMotionState + RawMotionFlags + WorldPosition pack/unpack +- `references/holtburger/crates/holtburger-protocol/src/messages/game_action.rs` — how GameAction wraps these (opcode 0xF7B1 + sequence + action_type) + +Take notes on: +- The exact field order for MoveToState: GameAction envelope → RawMotionState (flag-driven variable fields) → WorldPosition → sequences → contact byte → align +- The exact field order for AutonomousPosition: GameAction envelope → WorldPosition → sequences → contact byte → align +- WorldPosition layout: u32 cell_id → f32 x → f32 y → f32 z → f32 qw → f32 qx → f32 qy → f32 qz (32 bytes) +- RawMotionState: u32 flags → conditional fields based on flags + +- [ ] **Step 2: Write MoveToState failing tests** + +Create `tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs`: + +```csharp +using System; +using System.Buffers.Binary; +using System.Numerics; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class MoveToStateTests +{ + [Fact] + public void Build_IdleState_ProducesValidGameAction() + { + var body = MoveToState.Build( + gameActionSequence: 1, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xA9B40001u, + position: new Vector3(96f, 96f, 50f), + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // First 4 bytes: GameAction opcode 0xF7B1 + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); + Assert.Equal(0xF7B1u, opcode); + + // Bytes 4-7: game action sequence + uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); + Assert.Equal(1u, seq); + + // Bytes 8-11: MoveToState action type 0xF61C + uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); + Assert.Equal(0xF61Cu, actionType); + } + + [Fact] + public void Build_WalkForward_IncludesForwardCommandInFlags() + { + var body = MoveToState.Build( + gameActionSequence: 2, + forwardCommand: 0x45000005u, // WalkForward + forwardSpeed: 1.0f, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, + cellId: 0xA9B40001u, + position: new Vector3(96f, 96f, 50f), + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // After the 12-byte GameAction header comes RawMotionState. + // First u32 is the flags. ForwardCommand flag = 0x4. + uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); + Assert.True((flags & 0x4u) != 0, "ForwardCommand flag should be set"); + } +} +``` + +- [ ] **Step 3: Write AutonomousPosition failing tests** + +Create `tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs`: + +```csharp +using System; +using System.Buffers.Binary; +using System.Numerics; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public class AutonomousPositionTests +{ + [Fact] + public void Build_ProducesValidGameAction() + { + var body = AutonomousPosition.Build( + gameActionSequence: 5, + cellId: 0xA9B40001u, + position: new Vector3(100f, 100f, 50f), + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); + Assert.Equal(0xF7B1u, opcode); + + uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); + Assert.Equal(5u, seq); + + uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); + Assert.Equal(0xF753u, actionType); + } + + [Fact] + public void Build_ContainsCellIdAfterHeader() + { + var body = AutonomousPosition.Build( + gameActionSequence: 1, + cellId: 0xDEADBEEFu, + position: Vector3.Zero, + rotation: Quaternion.Identity, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + + // After the 12-byte GameAction header, the WorldPosition starts + // with u32 cell_id. + uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); + Assert.Equal(0xDEADBEEFu, cellId); + } +} +``` + +- [ ] **Step 4: Implement message builders** + +Create `src/AcDream.Core.Net/Messages/MoveToState.cs`: + +The builder must produce the exact byte layout holtburger sends. Read the reference files from Step 1. The key structure: + +``` +GameAction envelope: + u32 0xF7B1 (GameAction opcode) + u32 gameActionSequence + u32 0xF61C (MoveToState action type) + +RawMotionState: + u32 flags (bitmask of which optional fields follow) + [if CURRENT_HOLD_KEY (0x1)]: u32 holdKey + [if CURRENT_STYLE (0x2)]: u32 style + [if FORWARD_COMMAND (0x4)]: u32 forwardCommand + [if FORWARD_SPEED (0x10)]: f32 forwardSpeed + [if SIDE_STEP_COMMAND (0x20)]: u32 sidestepCommand + [if SIDE_STEP_SPEED (0x80)]: f32 sidestepSpeed + [if TURN_COMMAND (0x100)]: u32 turnCommand + [if TURN_SPEED (0x400)]: f32 turnSpeed + +WorldPosition: + u32 cellId + f32 originX, originY, originZ + f32 rotW, rotX, rotY, rotZ (AC wire order: W first) + +Sequences + contact: + u16 instanceSequence + u16 serverControlSequence + u16 teleportSequence + u16 forcePositionSequence + u8 contactLongJump (1 = on ground) + align to 4 bytes +``` + +Use `PacketWriter` (from `AcDream.Core.Net.Packets`) to build the byte array, same pattern as `GameActionLoginComplete.Build`. + +Create `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`: + +Simpler — no RawMotionState, just the GameAction envelope + WorldPosition + sequences + contact + align. + +- [ ] **Step 5: Add SendGameAction to WorldSession** + +In `src/AcDream.Core.Net/WorldSession.cs`, add a new public method and counter: + +```csharp +private uint _gameActionSequence; + +/// +/// Send a pre-built GameAction body (which already contains the 0xF7B1 +/// envelope). Increments the game-action-level sequence counter. +/// For movement messages (MoveToState, AutonomousPosition) called from +/// the PlayerMovementController. +/// +public void SendGameAction(byte[] gameActionBody) +{ + SendGameMessage(gameActionBody); +} + +/// +/// Get and increment the game action sequence for outbound messages. +/// +public uint NextGameActionSequence() => ++_gameActionSequence; +``` + +Note: `SendGameMessage` is currently private. Either make it internal or add this public wrapper. The wrapper is cleaner — it documents intent and lets us add action-specific logic later. + +- [ ] **Step 6: Run tests** + +Run: `dotnet test -c Debug --nologo` + +Expected: 243 + 4 = 247+ tests, no regressions. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core.Net/Messages/MoveToState.cs src/AcDream.Core.Net/Messages/AutonomousPosition.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs +git commit -m "$(cat <<'EOF' +feat(net): Phase B.2 — MoveToState + AutonomousPosition message builders + +Outbound GameAction message builders for player movement: +- MoveToState (0xF61C): sent on motion state changes (start/stop + walking, turn, speed change). Carries RawMotionState (flag-driven + variable fields) + WorldPosition + sequence numbers. +- AutonomousPosition (0xF753): periodic position heartbeat sent + every ~200ms while moving. + +Both follow the GameAction envelope pattern (0xF7B1 + sequence + +action type) established by GameActionLoginComplete. Wire format +ported from references/holtburger movement protocol. + +Also adds SendGameAction + NextGameActionSequence to WorldSession. + +Co-Authored-By: Claude Opus 4.6 (1M context) +EOF +)" +``` + +--- + +## Task 2: ChaseCamera + +**Files:** +- Create: `src/AcDream.App/Rendering/ChaseCamera.cs` +- Test: `tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs`: + +```csharp +using System; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.Core.Tests.Rendering; + +public class ChaseCameraTests +{ + [Fact] + public void Position_BehindPlayer_WhenYawIsZero() + { + var camera = new ChaseCamera { Aspect = 16f / 9f }; + camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f); + + // Yaw=0 means facing +X (cos(0)=1, sin(0)=0). + // Camera should be BEHIND the player: negative X direction. + Assert.True(camera.Position.X < -1f, $"Camera X={camera.Position.X} should be behind player (negative X)"); + Assert.True(camera.Position.Z > 0f, $"Camera Z={camera.Position.Z} should be above player"); + } + + [Fact] + public void Position_BehindPlayer_WhenYawIsHalfPi() + { + var camera = new ChaseCamera { Aspect = 16f / 9f }; + camera.Update(playerPosition: Vector3.Zero, playerYaw: MathF.PI / 2f); + + // Yaw=PI/2 means facing +Y. Camera should be behind: negative Y. + Assert.True(camera.Position.Y < -1f, $"Camera Y={camera.Position.Y} should be behind player (negative Y)"); + } + + [Fact] + public void Position_FollowsPlayerPosition() + { + var camera = new ChaseCamera { Aspect = 16f / 9f }; + var playerPos = new Vector3(100f, 200f, 50f); + camera.Update(playerPosition: playerPos, playerYaw: 0f); + + // Camera should be near the player, not at the origin. + Assert.InRange(camera.Position.X, 85f, 100f); // behind but close + Assert.InRange(camera.Position.Y, 195f, 205f); // roughly same Y + } + + [Fact] + public void PitchAdjustment_ChangesHeight() + { + var camera = new ChaseCamera { Aspect = 16f / 9f }; + camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f); + float z1 = camera.Position.Z; + + camera.AdjustPitch(0.2f); + camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f); + float z2 = camera.Position.Z; + + Assert.True(z2 > z1, "Increasing pitch should raise the camera"); + } + + [Fact] + public void ImplementsICamera() + { + ICamera camera = new ChaseCamera { Aspect = 16f / 9f }; + camera.ToString(); // just proves interface is implemented + } +} +``` + +- [ ] **Step 2: Implement ChaseCamera** + +Create `src/AcDream.App/Rendering/ChaseCamera.cs`: + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Third-person chase camera that follows behind and above a player +/// character. Implements so it plugs into the +/// existing renderer pipeline. +/// +public sealed class ChaseCamera : ICamera +{ + public Vector3 Position { get; private set; } + public float Aspect { get; set; } = 16f / 9f; + public float FovY { get; set; } = MathF.PI / 3f; + + /// Distance behind the player. + public float Distance { get; set; } = 8f; + + /// Camera pitch above horizontal (radians). Positive = look down. + public float Pitch { get; set; } = 0.35f; // ~20 degrees + + /// Vertical offset from the player's feet to the look-at point (eye height). + public float EyeHeight { get; set; } = 1.5f; + + private const float PitchMin = 0.05f; + private const float PitchMax = 1.4f; // ~80 degrees + + private float _playerYaw; + private Vector3 _lookAt; + + public Matrix4x4 View => + Matrix4x4.CreateLookAt(Position, _lookAt, Vector3.UnitZ); + + public Matrix4x4 Projection => + Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f); + + /// + /// Update the camera position to follow the player. + /// + public void Update(Vector3 playerPosition, float playerYaw) + { + _playerYaw = playerYaw; + _lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight); + + // Camera offset: behind the player (-forward direction) and above. + float forwardX = MathF.Cos(playerYaw); + float forwardY = MathF.Sin(playerYaw); + + float horizontalDist = Distance * MathF.Cos(Pitch); + float verticalDist = Distance * MathF.Sin(Pitch); + + Position = new Vector3( + playerPosition.X - forwardX * horizontalDist, + playerPosition.Y - forwardY * horizontalDist, + playerPosition.Z + EyeHeight + verticalDist); + } + + /// + /// Adjust pitch by a delta (from mouse Y movement). + /// + public void AdjustPitch(float delta) + { + Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); + } +} +``` + +- [ ] **Step 3: Run tests, commit** + +Run: `dotnet test -c Debug --nologo` — all green. + +```bash +git add src/AcDream.App/Rendering/ChaseCamera.cs tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs +git commit -m "feat(app): Phase B.2 — ChaseCamera (third-person follow camera) + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 3: PlayerMovementController + +**Files:** +- Create: `src/AcDream.App/Input/PlayerMovementController.cs` +- Test: `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs` + +The controller takes input state + dt, drives `PhysicsEngine.Resolve`, updates the entity, decides when to send outbound messages, and tracks the current motion command for animation. + +- [ ] **Step 1: Write failing tests** + +Create `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`. Test the core logic with a fake PhysicsEngine (pass-through that returns the candidate position unchanged): + +```csharp +using System; +using System.Numerics; +using AcDream.App.Input; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Input; + +public class PlayerMovementControllerTests +{ + private static PhysicsEngine MakeFlatEngine() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; + var terrain = new TerrainSurface(heights, heightTable); + engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + return engine; + } + + [Fact] + public void Update_NoInput_PositionUnchanged() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + + var result = controller.Update(0.016f, new MovementInput()); + + Assert.Equal(96f, result.Position.X, precision: 1); + Assert.Equal(96f, result.Position.Y, precision: 1); + } + + [Fact] + public void Update_ForwardInput_MovesInFacingDirection() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; // facing +X + + var input = new MovementInput { Forward = true }; + var result = controller.Update(1.0f, input); // 1 second + + // Should have moved ~4 units in +X (walk speed). + Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward"); + } + + [Fact] + public void Update_RunForward_MoveFasterThanWalk() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; + + var walkInput = new MovementInput { Forward = true }; + var walkResult = controller.Update(1.0f, walkInput); + float walkDist = walkResult.Position.X - 96f; + + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + + var runInput = new MovementInput { Forward = true, Run = true }; + var runResult = controller.Update(1.0f, runInput); + float runDist = runResult.Position.X - 96f; + + Assert.True(runDist > walkDist, $"Run ({runDist}) should be faster than walk ({walkDist})"); + } + + [Fact] + public void Update_TurnInput_ChangesYaw() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + float initialYaw = controller.Yaw; + + var input = new MovementInput { TurnRight = true }; + controller.Update(0.5f, input); + + Assert.NotEqual(initialYaw, controller.Yaw); + } + + [Fact] + public void MotionStateChanged_WhenStartingToWalk() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + + // First frame: idle (no input). + controller.Update(0.016f, new MovementInput()); + + // Second frame: start walking. + var input = new MovementInput { Forward = true }; + var result = controller.Update(0.016f, input); + + Assert.True(result.MotionStateChanged); + } +} +``` + +- [ ] **Step 2: Implement PlayerMovementController** + +Create `src/AcDream.App/Input/PlayerMovementController.cs`: + +```csharp +using System; +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.App.Input; + +/// +/// Input state for a single frame of player movement. +/// +public readonly record struct MovementInput( + bool Forward = false, + bool Backward = false, + bool StrafeLeft = false, + bool StrafeRight = false, + bool TurnLeft = false, + bool TurnRight = false, + bool Run = false, + float MouseDeltaX = 0f); + +/// +/// Result of a single frame's movement update. +/// +public readonly record struct MovementResult( + Vector3 Position, + uint CellId, + bool IsOnGround, + bool MotionStateChanged, + uint? ForwardCommand, + uint? SidestepCommand, + uint? TurnCommand, + float? ForwardSpeed, + float? SidestepSpeed, + float? TurnSpeed); + +/// +/// Per-frame player movement controller. Reads input, drives the +/// physics engine, tracks motion state for animation + server messages. +/// +public sealed class PlayerMovementController +{ + private readonly PhysicsEngine _physics; + + public float WalkSpeed { get; set; } = 4f; + public float RunSpeed { get; set; } = 7f; + public float TurnSpeed { get; set; } = 1.5f; + public float MouseTurnSensitivity { get; set; } = 0.003f; + public float StepUpHeight { get; set; } = 1.0f; + + public float Yaw { get; set; } + public Vector3 Position { get; private set; } + public uint CellId { get; private set; } + + // Previous frame's motion commands for change detection. + private uint? _prevForwardCmd; + private uint? _prevSidestepCmd; + private uint? _prevTurnCmd; + + // Heartbeat timer. + private float _heartbeatAccum; + public const float HeartbeatInterval = 0.2f; // 200ms + public bool HeartbeatDue { get; private set; } + + public PlayerMovementController(PhysicsEngine physics) + { + _physics = physics; + } + + public void SetPosition(Vector3 pos, uint cellId) + { + Position = pos; + CellId = cellId; + } + + public MovementResult Update(float dt, MovementInput input) + { + // 1. Apply turning from keyboard + mouse. + if (input.TurnRight) + Yaw -= TurnSpeed * dt; + if (input.TurnLeft) + Yaw += TurnSpeed * dt; + Yaw -= input.MouseDeltaX * MouseTurnSensitivity; + + // 2. Compute movement delta in the player's facing direction. + float speed = input.Run ? RunSpeed : WalkSpeed; + float forwardX = MathF.Cos(Yaw); + float forwardY = MathF.Sin(Yaw); + float rightX = MathF.Sin(Yaw); + float rightY = -MathF.Cos(Yaw); + + float dx = 0f, dy = 0f; + if (input.Forward) { dx += forwardX * speed * dt; dy += forwardY * speed * dt; } + if (input.Backward) { dx -= forwardX * speed * dt * 0.65f; dy -= forwardY * speed * dt * 0.65f; } + if (input.StrafeRight) { dx += rightX * speed * dt * 0.5f; dy += rightY * speed * dt * 0.5f; } + if (input.StrafeLeft) { dx -= rightX * speed * dt * 0.5f; dy -= rightY * speed * dt * 0.5f; } + + var delta = new Vector3(dx, dy, 0f); + + // 3. Resolve via physics engine. + var result = _physics.Resolve(Position, CellId, delta, StepUpHeight); + Position = result.Position; + CellId = result.CellId; + + // 4. Determine current motion commands. + uint? forwardCmd = null; + float? forwardSpeed = null; + uint? sidestepCmd = null; + float? sidestepSpeed = null; + uint? turnCmd = null; + float? turnSpeed = null; + + if (input.Forward) + { + forwardCmd = input.Run ? 0x44000007u : 0x45000005u; // RunForward / WalkForward + forwardSpeed = input.Run ? RunSpeed / WalkSpeed : 1.0f; + } + else if (input.Backward) + { + forwardCmd = 0x45000005u; // WalkForward (backward is negative speed) + forwardSpeed = -0.65f; + } + + if (input.StrafeRight) + { + sidestepCmd = 0x6500000Fu; // SideStepRight + sidestepSpeed = speed * 0.5f / WalkSpeed; + } + else if (input.StrafeLeft) + { + sidestepCmd = 0x65000010u; // SideStepLeft + sidestepSpeed = speed * 0.5f / WalkSpeed; + } + + if (input.TurnRight || input.MouseDeltaX > 0.5f) + { + turnCmd = 0x6500000Du; // TurnRight + turnSpeed = TurnSpeed; + } + else if (input.TurnLeft || input.MouseDeltaX < -0.5f) + { + turnCmd = 0x6500000Eu; // TurnLeft + turnSpeed = TurnSpeed; + } + + // 5. Detect motion state change. + bool changed = forwardCmd != _prevForwardCmd + || sidestepCmd != _prevSidestepCmd + || turnCmd != _prevTurnCmd; + _prevForwardCmd = forwardCmd; + _prevSidestepCmd = sidestepCmd; + _prevTurnCmd = turnCmd; + + // 6. Heartbeat timer (only while moving). + bool isMoving = forwardCmd is not null || sidestepCmd is not null || turnCmd is not null; + if (isMoving) + { + _heartbeatAccum += dt; + HeartbeatDue = _heartbeatAccum >= HeartbeatInterval; + if (HeartbeatDue) _heartbeatAccum = 0f; + } + else + { + _heartbeatAccum = 0f; + HeartbeatDue = false; + } + + return new MovementResult( + Position: result.Position, + CellId: result.CellId, + IsOnGround: result.IsOnGround, + MotionStateChanged: changed, + ForwardCommand: forwardCmd, + SidestepCommand: sidestepCmd, + TurnCommand: turnCmd, + ForwardSpeed: forwardSpeed, + SidestepSpeed: sidestepSpeed, + TurnSpeed: turnSpeed); + } +} +``` + +- [ ] **Step 3: Run tests, commit** + +Run: `dotnet test -c Debug --nologo` — all green. + +```bash +git add src/AcDream.App/Input/PlayerMovementController.cs tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +git commit -m "$(cat <<'EOF' +feat(app): Phase B.2 — PlayerMovementController (input → physics → motion state) + +Per-frame controller that reads MovementInput (WASD/ZX/Shift/mouse), +drives PhysicsEngine.Resolve for collision, and tracks motion state +changes for outbound server messages + animation switching. Walk +(~4 u/s) and run (~7 u/s) speeds match AC retail. Heartbeat timer +triggers AutonomousPosition every ~200ms while moving. + +5 new tests covering idle, forward, run, turn, and state-change +detection. + +Co-Authored-By: Claude Opus 4.6 (1M context) +EOF +)" +``` + +--- + +## Task 4: CameraController chase mode + +**Files:** +- Modify: `src/AcDream.App/Rendering/CameraController.cs` + +Add chase camera as a third mode alongside orbit and fly. + +- [ ] **Step 1: Read current CameraController** + +Read `src/AcDream.App/Rendering/CameraController.cs` to understand the existing toggle pattern. + +- [ ] **Step 2: Add ChaseCamera property + mode cycling** + +Add a `Chase` property and modify `ToggleFly` (or add a new method) so the mode cycles: orbit → fly → chase → orbit. OR add a separate `TogglePlayerMode()` method that switches between the current mode and chase. The simpler option: add `SetChaseMode()` and `ExitChaseMode()` methods that GameWindow calls on Tab press. + +```csharp +public ChaseCamera? Chase { get; set; } +public bool IsChaseMode => Chase is not null && Active == Chase; + +public void EnterChaseMode(ChaseCamera chase) +{ + Chase = chase; + Active = chase; + ModeChanged?.Invoke(false); // not fly mode +} + +public void ExitChaseMode() +{ + Active = Fly; // return to fly mode + Chase = null; + ModeChanged?.Invoke(true); // back to fly +} +``` + +- [ ] **Step 3: Build, commit** + +```bash +git add src/AcDream.App/Rendering/CameraController.cs +git commit -m "feat(app): Phase B.2 — CameraController chase mode + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 5: GameWindow wiring (the big integration task) + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` + +Wire Tab toggle, PlayerMovementController, ChaseCamera, local animation, and outbound messages into the game loop. + +- [ ] **Step 1: Add fields** + +```csharp +private PlayerMovementController? _playerController; +private ChaseCamera? _chaseCamera; +private bool _playerMode; +private uint _playerServerGuid; // from CharacterList at login +``` + +- [ ] **Step 2: Store player GUID at login** + +In `TryStartLiveSession`, after `var chosen = ...`, store: +```csharp +_playerServerGuid = chosen.Id; +``` + +- [ ] **Step 3: Add Tab key handler** + +In the existing key-press handler (near the F key toggle), add: +```csharp +if (key == Key.Tab && _liveSession is not null) +{ + _playerMode = !_playerMode; + if (_playerMode) + { + // Enter player mode: find our entity, create controller + camera. + if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) + { + _playerController = new PlayerMovementController(_physicsEngine); + _playerController.SetPosition(playerEntity.Position, 0x0001); // initial cell + _chaseCamera = new ChaseCamera { Aspect = _window!.Size.X / (float)_window.Size.Y }; + _cameraController!.EnterChaseMode(_chaseCamera); + } + } + else + { + // Exit player mode. + _cameraController!.ExitChaseMode(); + _playerController = null; + _chaseCamera = null; + } +} +``` + +- [ ] **Step 4: Wire controller into OnUpdate** + +In `OnUpdate`, after the existing fly-camera update block, add: +```csharp +if (_playerMode && _playerController is not null && _chaseCamera is not null && _input is not null) +{ + var kb = _input.Keyboards[0]; + var mouse = _input.Mice.FirstOrDefault(); + float mouseX = 0f; + // Read mouse delta if captured. + if (mouse is not null && _capturedMouse is not null) + { + // mouseX accumulated since last frame — implementation depends on + // how Silk.NET exposes raw mouse delta. Check existing fly camera + // mouse handling pattern. + } + + var input = new MovementInput( + Forward: kb.IsKeyPressed(Key.W), + Backward: kb.IsKeyPressed(Key.S), + StrafeLeft: kb.IsKeyPressed(Key.Z), + StrafeRight: kb.IsKeyPressed(Key.X), + TurnLeft: kb.IsKeyPressed(Key.A), + TurnRight: kb.IsKeyPressed(Key.D), + Run: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight), + MouseDeltaX: mouseX); + + var result = _playerController.Update((float)dt, input); + + // Update the player entity's position + rotation. + if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) + { + pe.Position = result.Position; + pe.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, _playerController.Yaw); + } + + // Update chase camera. + _chaseCamera.Update(result.Position, _playerController.Yaw); + + // Send outbound messages. + if (_liveSession is not null) + { + // Convert world position back to AC wire coordinates. + int lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f); + int lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f); + float localX = result.Position.X - (lbX - _liveCenterX) * 192f; + float localY = result.Position.Y - (lbY - _liveCenterY) * 192f; + uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu); + var wirePos = new Vector3(localX, localY, result.Position.Z); + var wireRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, _playerController.Yaw); + + if (result.MotionStateChanged) + { + var seq = _liveSession.NextGameActionSequence(); + var body = MoveToState.Build( + gameActionSequence: seq, + forwardCommand: result.ForwardCommand, + forwardSpeed: result.ForwardSpeed, + sidestepCommand: result.SidestepCommand, + sidestepSpeed: result.SidestepSpeed, + turnCommand: result.TurnCommand, + turnSpeed: result.TurnSpeed, + holdKey: result.ForwardCommand is not null && (result.ForwardCommand == 0x44000007u) ? 1u : null, + cellId: wireCellId, + position: wirePos, + rotation: wireRot, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + _liveSession.SendGameAction(body); + } + + if (_playerController.HeartbeatDue) + { + var seq = _liveSession.NextGameActionSequence(); + var body = AutonomousPosition.Build( + gameActionSequence: seq, + cellId: wireCellId, + position: wirePos, + rotation: wireRot, + instanceSequence: 0, + serverControlSequence: 0, + teleportSequence: 0, + forcePositionSequence: 0); + _liveSession.SendGameAction(body); + } + } + + // Update local animation based on motion command. + UpdatePlayerAnimation(result); +} +``` + +- [ ] **Step 5: Add UpdatePlayerAnimation helper** + +```csharp +private uint? _playerCurrentAnimCommand; + +private void UpdatePlayerAnimation(MovementResult result) +{ + // Determine the animation command: forward takes priority, then sidestep, then turn, then idle. + uint animCommand; + if (result.ForwardCommand is { } fwd) + animCommand = fwd; + else if (result.SidestepCommand is { } ss) + animCommand = ss; + else if (result.TurnCommand is { } tc) + animCommand = tc; + else + animCommand = 0x41000003u; // Ready (idle) + + if (animCommand == _playerCurrentAnimCommand) return; + _playerCurrentAnimCommand = animCommand; + + // Re-resolve the animation cycle for the player entity. + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; + if (_dats is null) return; + + var setup = _dats.Get(pe.SourceGfxObjOrSetupId); + if (setup is null) return; + + ushort cmdOverride = (ushort)(animCommand & 0xFFFF); + var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( + setup, _dats, commandOverride: cmdOverride); + + if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) + return; + + // Update or create the animated entity entry. + if (_animatedEntities.TryGetValue(pe.Id, out var ae)) + { + ae.Animation = cycle.Animation; + ae.LowFrame = Math.Max(0, cycle.LowFrame); + ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1); + ae.Framerate = cycle.Framerate; + ae.CurrFrame = ae.LowFrame; + } +} +``` + +- [ ] **Step 6: Build + test** + +Run: `cmd.exe /c "taskkill /F /IM AcDream.App.exe 2>nul" && dotnet build -c Debug && dotnet test -c Debug --nologo` + +Expected: 0 errors, all tests green. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(app): Phase B.2 — wire player movement into GameWindow + +Tab toggles between fly mode and player-controlled ground movement. +WASD walks/runs, A/D + mouse turns, Z/X strafes. PhysicsEngine +resolves positions against terrain. MoveToState sent on motion +state changes, AutonomousPosition heartbeats every 200ms while +moving. Walk/run/turn/idle animations resolved locally via +MotionResolver and played through the existing TickAnimations path. +ChaseCamera follows in third-person. + +Co-Authored-By: Claude Opus 4.6 (1M context) +EOF +)" +``` + +--- + +## Task 6: Roadmap update + visual verification + +- [ ] **Step 1: Update roadmap** + +Mark B.2 as shipped in `docs/plans/2026-04-11-roadmap.md`. + +- [ ] **Step 2: Commit roadmap** + +- [ ] **Step 3: Live verification** + +Launch the app, log in, press Tab, walk around Holtburg with WASD. Verify: +1. Character walks on terrain (not flying/sinking) +2. Walk animation plays +3. Other clients see the character move +4. ACE console shows no movement rejections +5. Tab returns to fly mode + +--- + +## Self-review + +**Spec coverage:** +- Tab toggle ✓ (Task 5 step 3) +- WASD walk/run + Z/X strafe + A/D turn + mouse turn ✓ (Task 3) +- PhysicsEngine collision ✓ (Task 3 uses B.3) +- MoveToState on state change ✓ (Task 1 + Task 5) +- AutonomousPosition heartbeat ✓ (Task 1 + Task 5) +- Walk/run/turn/idle animation ✓ (Task 5 step 5) +- Third-person chase camera ✓ (Task 2) +- Camera mode cycling ✓ (Task 4) +- AC wire coordinate conversion ✓ (Task 5 step 4) +- Player GUID identification ✓ (Task 5 step 2) +- Roadmap update ✓ (Task 6) + +**Placeholder scan:** None found. All code blocks complete. + +**Type consistency:** `MovementInput`, `MovementResult`, `PlayerMovementController`, `ChaseCamera`, `MoveToState.Build`, `AutonomousPosition.Build`, `WorldSession.SendGameAction`, `NextGameActionSequence` — all consistent across tasks.