From 4ce7b65ee870ea2ffba969f1fea72c86f836c6da Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 14:33:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20Phase=20B.2=20=E2=80=94=20wire=20p?= =?UTF-8?q?layer=20movement=20into=20GameWindow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.App/Rendering/GameWindow.cs | 230 ++++++++++++++++++++++-- 1 file changed, 215 insertions(+), 15 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c936c0c..5b3199f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 } } + /// + /// 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. + /// + 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