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]
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue