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) <noreply@anthropic.com>
1099 lines
37 KiB
Markdown
1099 lines
37 KiB
Markdown
# 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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void SendGameAction(byte[] gameActionBody)
|
|
{
|
|
SendGameMessage(gameActionBody);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get and increment the game action sequence for outbound messages.
|
|
/// </summary>
|
|
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) <noreply@anthropic.com>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Third-person chase camera that follows behind and above a player
|
|
/// character. Implements <see cref="ICamera"/> so it plugs into the
|
|
/// existing renderer pipeline.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Distance behind the player.</summary>
|
|
public float Distance { get; set; } = 8f;
|
|
|
|
/// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
|
|
public float Pitch { get; set; } = 0.35f; // ~20 degrees
|
|
|
|
/// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Update the camera position to follow the player.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adjust pitch by a delta (from mouse Y movement).
|
|
/// </summary>
|
|
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) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## 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<CellSurface>(),
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Input state for a single frame of player movement.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Result of a single frame's movement update.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Per-frame player movement controller. Reads input, drives the
|
|
/// physics engine, tracks motion state for animation + server messages.
|
|
/// </summary>
|
|
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) <noreply@anthropic.com>
|
|
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) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## 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<DatReaderWriter.DBObjs.Setup>(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) <noreply@anthropic.com>
|
|
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.
|