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:
parent
e5e1245efb
commit
4ce7b65ee8
1 changed files with 215 additions and 15 deletions
|
|
@ -93,6 +93,16 @@ public sealed class GameWindow : IDisposable
|
||||||
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
|
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
|
// Phase 4.7: optional live connection to an ACE server. Enabled only when
|
||||||
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
||||||
// the offline rendering pipeline.
|
// the offline rendering pipeline.
|
||||||
|
|
@ -163,9 +173,58 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
if (_cameraController?.IsFlyMode == true)
|
if (_cameraController?.IsFlyMode == true)
|
||||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
_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
|
else
|
||||||
_window!.Close();
|
_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)
|
foreach (var mouse in _input.Mice)
|
||||||
|
|
@ -174,20 +233,29 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
if (_cameraController is null) return;
|
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.
|
// 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);
|
_cameraController.Fly.Look(dx, dy);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (m.IsButtonPressed(MouseButton.Left))
|
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 = Math.Clamp(
|
||||||
_cameraController.Orbit.Pitch + (pos.Y - _lastMouseY) * 0.005f,
|
_cameraController.Orbit.Pitch + dy * 0.005f,
|
||||||
0.1f, 1.5f);
|
0.1f, 1.5f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -334,6 +402,7 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
var chosen = _liveSession.Characters.Characters[0];
|
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}");
|
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
_liveSession.EnterWorld(user, characterIndex: 0);
|
_liveSession.EnterWorld(user, characterIndex: 0);
|
||||||
Console.WriteLine($"live: in world — CreateObject stream active " +
|
Console.WriteLine($"live: in world — CreateObject stream active " +
|
||||||
|
|
@ -1369,18 +1438,107 @@ public sealed class GameWindow : IDisposable
|
||||||
_liveSession?.Tick();
|
_liveSession?.Tick();
|
||||||
|
|
||||||
if (_cameraController is null || _input is null) return;
|
if (_cameraController is null || _input is null) return;
|
||||||
if (!_cameraController.IsFlyMode) return;
|
|
||||||
|
|
||||||
var kb = _input.Keyboards[0];
|
var kb = _input.Keyboards[0];
|
||||||
_cameraController.Fly.Update(
|
|
||||||
dt,
|
if (_cameraController.IsFlyMode)
|
||||||
w: kb.IsKeyPressed(Key.W),
|
{
|
||||||
a: kb.IsKeyPressed(Key.A),
|
_cameraController.Fly.Update(
|
||||||
s: kb.IsKeyPressed(Key.S),
|
dt,
|
||||||
d: kb.IsKeyPressed(Key.D),
|
w: kb.IsKeyPressed(Key.W),
|
||||||
up: kb.IsKeyPressed(Key.Space),
|
a: kb.IsKeyPressed(Key.A),
|
||||||
down: kb.IsKeyPressed(Key.ControlLeft),
|
s: kb.IsKeyPressed(Key.S),
|
||||||
boost: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight));
|
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)
|
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()
|
private void OnClosing()
|
||||||
{
|
{
|
||||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue