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>
This commit is contained in:
Erik 2026-04-12 14:27:07 +02:00
parent 84d7d06008
commit d9cd2b0b1d
2 changed files with 281 additions and 0 deletions

View file

@ -0,0 +1,178 @@
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);
}
}