feat(app): Phase B.2 — wire player movement into GameWindow

Tab toggles between fly mode and player-controlled ground movement
when a live session is in-world. WASD walks/runs, A/D + mouse X
turns the character, Z/X strafes, Shift runs. PhysicsEngine resolves
positions against terrain each frame.

MoveToState sent on motion state changes (start/stop walking,
direction change, speed change). AutonomousPosition heartbeat sent
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, mouse Y
adjusts camera pitch.

Tab on Escape also exits player mode gracefully. Mouse delta is
accumulated in the mouse-move callback and consumed+reset each
OnUpdate frame (no accumulation drift).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 14:33:53 +02:00
parent e5e1245efb
commit 4ce7b65ee8

View file

@ -93,6 +93,16 @@ public sealed class GameWindow : IDisposable
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
}
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
private bool _playerMode;
private uint _playerServerGuid;
private uint? _playerCurrentAnimCommand;
// Accumulated mouse X delta for player turning; written in mouse-move
// callback, consumed + reset in OnUpdate each frame.
private float _playerMouseDeltaX;
// Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline.
@ -163,9 +173,58 @@ public sealed class GameWindow : IDisposable
{
if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
{
// Exit player mode on Escape too.
_playerMode = false;
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
_playerCurrentAnimCommand = null;
}
else
_window!.Close();
}
// Phase B.2: Tab toggles between fly and player-movement mode.
// Only active when a live session is in-world and we have a
// player entity spawned on screen.
else if (key == Key.Tab && _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
{
_playerMode = !_playerMode;
if (_playerMode)
{
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
{
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
_playerController.SetPosition(playerEntity.Position, 0x0001u);
_playerController.Yaw = MathF.PI / 2f; // default facing +Y
_chaseCamera = new AcDream.App.Rendering.ChaseCamera
{
Aspect = _window!.Size.X / (float)_window.Size.Y,
};
_playerMouseDeltaX = 0f;
_cameraController?.EnterChaseMode(_chaseCamera);
// ModeChanged event fires from EnterChaseMode → OnCameraModeChanged
// captures mouse in raw mode automatically.
}
else
{
// Player entity not yet spawned — revert.
_playerMode = false;
Console.WriteLine($"live: Tab pressed but player entity 0x{_playerServerGuid:X8} not found yet");
}
}
else
{
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
_playerCurrentAnimCommand = null;
_playerMouseDeltaX = 0f;
// ExitChaseMode fires ModeChanged → OnCameraModeChanged(true=fly) → raw mode stays ON
}
}
};
foreach (var mouse in _input.Mice)
@ -174,20 +233,29 @@ public sealed class GameWindow : IDisposable
{
if (_cameraController is null) return;
if (_cameraController.IsFlyMode)
float dx = pos.X - _lastMouseX;
float dy = pos.Y - _lastMouseY;
if (_playerMode && _cameraController.IsChaseMode)
{
// Phase B.2: player mode — mouse X turns the character,
// mouse Y adjusts chase camera pitch.
// Accumulate X for the controller to consume in OnUpdate.
_playerMouseDeltaX += dx;
_chaseCamera?.AdjustPitch(dy * 0.005f);
}
else if (_cameraController.IsFlyMode)
{
// Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last.
float dx = pos.X - _lastMouseX;
float dy = pos.Y - _lastMouseY;
_cameraController.Fly.Look(dx, dy);
}
else
{
if (m.IsButtonPressed(MouseButton.Left))
{
_cameraController.Orbit.Yaw -= (pos.X - _lastMouseX) * 0.005f;
_cameraController.Orbit.Yaw -= dx * 0.005f;
_cameraController.Orbit.Pitch = Math.Clamp(
_cameraController.Orbit.Pitch + (pos.Y - _lastMouseY) * 0.005f,
_cameraController.Orbit.Pitch + dy * 0.005f,
0.1f, 1.5f);
}
}
@ -334,6 +402,7 @@ public sealed class GameWindow : IDisposable
}
var chosen = _liveSession.Characters.Characters[0];
_playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
_liveSession.EnterWorld(user, characterIndex: 0);
Console.WriteLine($"live: in world — CreateObject stream active " +
@ -1369,18 +1438,107 @@ public sealed class GameWindow : IDisposable
_liveSession?.Tick();
if (_cameraController is null || _input is null) return;
if (!_cameraController.IsFlyMode) return;
var kb = _input.Keyboards[0];
_cameraController.Fly.Update(
dt,
w: kb.IsKeyPressed(Key.W),
a: kb.IsKeyPressed(Key.A),
s: kb.IsKeyPressed(Key.S),
d: kb.IsKeyPressed(Key.D),
up: kb.IsKeyPressed(Key.Space),
down: kb.IsKeyPressed(Key.ControlLeft),
boost: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight));
if (_cameraController.IsFlyMode)
{
_cameraController.Fly.Update(
dt,
w: kb.IsKeyPressed(Key.W),
a: kb.IsKeyPressed(Key.A),
s: kb.IsKeyPressed(Key.S),
d: kb.IsKeyPressed(Key.D),
up: kb.IsKeyPressed(Key.Space),
down: kb.IsKeyPressed(Key.ControlLeft),
boost: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight));
}
else if (_playerMode && _playerController is not null && _chaseCamera is not null)
{
// Phase B.2: player movement mode — WASD walks/runs, A/D turns,
// Z/X strafes, Shift runs, mouse X turns, mouse Y pitches camera.
float consumedMouseDeltaX = _playerMouseDeltaX;
_playerMouseDeltaX = 0f; // consume + reset so it doesn't accumulate
var input = new AcDream.App.Input.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: consumedMouseDeltaX);
var result = _playerController.Update((float)dt, input);
// Update the player entity's position + rotation so it renders at
// the physics-resolved location each frame.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.Position = result.Position;
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw);
}
// Update chase camera.
_chaseCamera.Update(result.Position, _playerController.Yaw);
// Send outbound movement messages to the live server.
if (_liveSession is not null)
{
// Convert world position back to AC wire coordinates.
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
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 System.Numerics.Vector3(localX, localY, result.Position.Z);
var wireRot = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw);
if (result.MotionStateChanged)
{
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.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 == 0x44000007u ? 1u : (uint?)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 = AcDream.Core.Net.Messages.AutonomousPosition.Build(
gameActionSequence: seq,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
_liveSession.SendGameAction(body);
}
}
// Update the player entity's animation cycle to match current motion.
UpdatePlayerAnimation(result);
}
}
private void OnCameraModeChanged(bool isFlyMode)
@ -1546,6 +1704,48 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Phase B.2: switch the locally-controlled player entity's animation cycle
/// to match the current motion command. Only re-resolves when the command
/// actually changes (forward → run, idle → walk, etc.) to avoid re-building
/// the animation entry every frame.
/// </summary>
private void UpdatePlayerAnimation(AcDream.App.Input.MovementResult result)
{
if (_dats is null) return;
// Determine the animation command: forward takes priority, then sidestep,
// then turn, then idle (Ready 0x41000003).
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)
// Fast path: no change.
if (animCommand == _playerCurrentAnimCommand) return;
_playerCurrentAnimCommand = animCommand;
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
if (!_animatedEntities.TryGetValue(pe.Id, out var ae)) return;
ushort cmdOverride = (ushort)(animCommand & 0xFFFFu);
var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats, commandOverride: cmdOverride);
if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return;
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;
}
private void OnClosing()
{
// Phase A.1: join the streamer worker thread before tearing down GL