From 256e9624bd42a1e6a3c07ae86604430352dd309f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 23:43:11 +0200 Subject: [PATCH] feat(input): #22 Phase K.1b - cut handlers over to dispatcher (single input path) Removes the parallel direct keyboard/mouse polling that K.1a left in GameWindow alongside the new dispatcher. Now every input flows through InputDispatcher; legacy IsKeyPressed/KeyDown/MouseDown/MouseUp/ Scroll handlers in GameWindow are deleted (~220-line refactor). Bindings remain acdream-current (W/S/A/D/Z/X movement, Shift run, F-key debug surface). K.1c flips them to retail. Pieces: - InputDispatcher.IsActionHeld(InputAction): per-frame held-state query for movement (W/X/A/D/Z/X/Shift/Space) so PlayerMovement- Controller can read action state without polling raw keys. Internally walks all bindings for the action; chord match requires modifier mask exactness. - InputAction adds AcdreamRmbOrbitHold (Hold-activation, RMB held drives chase-camera orbit) and AcdreamFlyDown (Ctrl held in fly mode for descent). - GameWindow OnInputAction subscriber replaces the entire KeyDown switch + per-mouse-button handlers. Single dispatcher event drives: - F1 AcdreamToggleDebugPanel - F2 AcdreamToggleCollisionWires - F3 AcdreamDumpNearby - F7 AcdreamCycleTimeOfDay - F8 AcdreamSensitivityDown - F9 AcdreamSensitivityUp - F10 AcdreamCycleWeather - F AcdreamToggleFlyMode - Tab AcdreamTogglePlayerMode (player/fly toggle - K.1c will reassign this to ToggleChatEntry) - Esc EscapeKey (cancel fly mode etc.) - Mouse wheel ScrollUp/ScrollDown (camera zoom) - RMB held (Hold) drives orbit; LMB drag still drives orbit camera; mouse position handled by surviving MouseMove handler which is gated on ImGui WantCaptureMouse. - MovementInput per-frame: reads from _inputDispatcher.IsActionHeld. MouseDeltaX hardcoded to 0f (mouse never drives character yaw). _playerMouseDeltaX field stays defined for chase-camera RMB-orbit but is never consumed by movement. - WantCaptureMouse explicit gate at the top of every surviving mouse handler in GameWindow (defense in depth - dispatcher already gates via IMouseSource.WantCaptureMouse). Movement-input boundary preserved: PlayerMovementController.Update still takes the same MovementInput struct. Existing PlayerMovementControllerTests continue green - no regression in motion-command byte production. Two deviations: 1. Scroll lost magnitude going through the dispatcher (fixed-step zoom). Acceptable - discrete wheel-tick matches retail feel anyway. 2. Movement chords are duplicated with both ModifierMask.None and ModifierMask.Shift (covering "shift held to run while walking forward" etc.) so the dispatcher's modifier-strict matching preserves the modifier-blind feel of the old IsKeyPressed polling. Will be reshaped cleanly in K.1c when retail's walk-modifier semantics flip (default = run, shift held = walk). 15 new tests: - InputDispatcherIsActionHeldTests: 7 cases covering chord-held + release + modifier-mismatch + multi-binding-for-action. - InputDispatcherTests: 3 scroll-action cases. - DispatcherToMovementIntegrationTests (Core.Tests): 5 cases proving FakeKeyboardSource.Press(W) -> dispatcher.IsActionHeld -> MovementInput.Forward -> PlayerMovementController produces the expected motion-command bytes. Includes the regression-prevention test that mouse-X delta value (zero vs nonzero) doesn't affect the motion bytes. Solution total: 1133 green (243 Core.Net + 225 UI + 665 Core), 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 722 ++++++++++-------- .../Input/InputAction.cs | 6 + .../Input/InputDispatcher.cs | 78 +- .../Input/KeyBindings.cs | 53 +- .../DispatcherToMovementIntegrationTests.cs | 197 +++++ .../Input/InputDispatcherIsActionHeldTests.cs | 133 ++++ .../Input/InputDispatcherTests.cs | 27 + .../Input/KeyBindingsTests.cs | 5 +- 8 files changed, 887 insertions(+), 334 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7aae2cd..2450265 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -379,9 +379,16 @@ public sealed class GameWindow : IDisposable private uint? _playerCurrentAnimCommand; private float _playerCurrentAnimSpeed = 1f; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character - // Accumulated mouse X delta for player turning; written in mouse-move - // callback, consumed + reset in OnUpdate each frame. + // K.1b: this field is RESERVED — written when entering / leaving player + // mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now + // never consumed by MovementInput (mouse never drives character yaw — + // K.1b regression-prevention). Kept around as plumbing for the future + // K.2 MMB-mouse-look path which will re-enable mouse → character-yaw + // when MMB is held. The pragma silences the dead-write warning until K.2 + // wires the read-side back in. +#pragma warning disable CS0414 // assigned but never used — see comment above private float _playerMouseDeltaX; +#pragma warning restore CS0414 // Mouse sensitivity multipliers — one per camera mode because the visual // feel is very different. Adjust via F8 / F9 for whichever mode is @@ -396,10 +403,12 @@ public sealed class GameWindow : IDisposable // the orbited position (no snap back). private bool _rmbHeld; - // Phase K.1a — input architecture skeleton. Lives ALONGSIDE the - // existing IsKeyPressed + KeyDown handlers; nothing subscribes to - // dispatcher.Fired except a diagnostic logger. K.1b cuts the legacy - // paths over. + // Phase K.1b — single input path. Every keyboard/mouse-button reaction + // flows through InputDispatcher.Fired (see OnInputAction below) or + // IsActionHeld (per-frame polling for movement). The legacy direct + // kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only + // mouse.MouseMove survives as a direct handler because mouse-delta is + // axis input, not chord input. private AcDream.App.Input.SilkKeyboardSource? _kbSource; private AcDream.App.Input.SilkMouseSource? _mouseSource; private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; @@ -500,233 +509,50 @@ public sealed class GameWindow : IDisposable _gl = GL.GetApi(_window!); _input = _window!.CreateInput(); - foreach (var kb in _input.Keyboards) - kb.KeyDown += (_, key, _) => - { - if (key == Key.F) - _cameraController?.ToggleFly(); - else if (key == Key.F3) - { - // Dump player position + ALL entities (visible rendered) + shadow objs within 15m. - // Lets us see visible-entity-without-collision gaps. - System.Numerics.Vector3 pos; - if (_playerMode && _playerController is not null) - pos = _playerController.Position; - else - { - System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv); - pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); - } - int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f); - int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f); - Console.WriteLine( - $"=== F3 DEBUG DUMP ===\n" + - $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + - $" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" + - $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); - // Collect VISIBLE entities within 15m (from GpuWorldState) - var visibleNearby = new List(); - foreach (var e in _worldState.Entities) - { - float dx = e.Position.X - pos.X; - float dy = e.Position.Y - pos.Y; - if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e); - } - Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}"); - foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12)) - { - float d = (e.Position - pos).Length(); - Console.WriteLine( - $" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " + - $"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}"); - } - - // Collect shadow objects within 15m - var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>(); - foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) - { - float dx = o.Position.X - pos.X; - float dy = o.Position.Y - pos.Y; - float d = MathF.Sqrt(dx * dx + dy * dy); - if (d < 15f) sorted.Add((o, d)); - } - sorted.Sort((a, b) => a.dist.CompareTo(b.dist)); - Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}"); - foreach (var (o, d) in sorted.Take(12)) - { - Console.WriteLine( - $" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " + - $"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}"); - } - } - else if (key == Key.F1) - { - // Phase I.2: F1 now toggles the entire ImGui DebugPanel - // visibility. The old per-section toggles (F4/F5/F6) are - // gone — sections are collapsing headers inside the - // single window now. - foreach (var panel in EnumerateDebugPanel()) - { - panel.IsVisible = !panel.IsVisible; - _debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}"); - } - } - else if (key == Key.F2) - { - // Real gameplay toggle — keeps the F2 keybind. Same - // action is wired into the DebugPanel's - // "Toggle collision wires" button via DebugVM. - ToggleCollisionWires(); - } - else if (key == Key.F7) - { - // Phase I.2: keep F7 as a hotkey alias for the - // DebugPanel's "Cycle time of day" button. - CycleTimeOfDay(); - } - else if (key == Key.F10) - { - // Phase I.2: keep F10 as a hotkey alias for the - // DebugPanel's "Cycle weather" button. - CycleWeather(); - } - else if (key == Key.F8 || key == Key.F9) - { - // Adjust whichever mode's sensitivity is currently active. - // Multiplicative step (1.2x / /1.2x) so low values stay fine - // grained and high values move in proportional chunks. - string modeLabel; - float current; - if (_playerMode && _cameraController?.IsChaseMode == true) - { modeLabel = "Chase"; current = _sensChase; } - else if (_cameraController?.IsFlyMode == true) - { modeLabel = "Fly"; current = _sensFly; } - else - { modeLabel = "Orbit"; current = _sensOrbit; } - - float next = (key == Key.F9) ? current * 1.2f : current / 1.2f; - next = MathF.Min(3.0f, MathF.Max(0.005f, next)); - - if (modeLabel == "Chase") _sensChase = next; - else if (modeLabel == "Fly") _sensFly = next; - else _sensOrbit = next; - - _debugVm?.AddToast($"{modeLabel} sens {next:F3}x"); - } - else if (key == Key.Escape) - { - 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); - // Read the real step height from the player's Setup dat. - if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); - if (playerSetup is not null) - _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); - _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) - ? playerSetup.StepUpHeight - : 2f; // default human step height - } - else - { - _playerController.StepUpHeight = 2f; // default human step height - } - // Derive initial cell ID from the entity's world position. - int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); - int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); - float plocalX = playerEntity.Position.X - (plbX - _liveCenterX) * 192f; - float plocalY = playerEntity.Position.Y - (plbY - _liveCenterY) * 192f; - uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; - // Resolve the initial position through the physics engine to - // get the correct terrain Z. The server-sent Z may be stale - // from a previous ACE relocation. With indoor transitions - // disabled, Resolve will always snap to outdoor terrain Z. - var initResult = _physicsEngine.Resolve( - playerEntity.Position, pinitCellId & 0xFFFFu, - System.Numerics.Vector3.Zero, 100f); // huge step height for initial snap - _playerController.SetPosition(initResult.Position, initResult.CellId); - - // Option B (r03 §1.3): wire the player's sequencer current - // cycle velocity into MotionInterpreter.get_state_velocity so - // body physics uses MotionData.Velocity * speedMod instead of - // the hardcoded RunAnimSpeed/WalkAnimSpeed. Keeps legs-per-meter - // invariant regardless of which MotionTable drives the player - // (Humanoid RunForward happens to bake Velocity=4.0 so the old - // path looked right there, but the decompiled constant is a - // MotionTable property, not a global). - if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) - && playerAE.Sequencer is { } playerSeq) - { - _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); - } - - // Derive initial yaw from the entity's rotation. - // The render loop stores rotation as Yaw - PI/2 (to - // compensate for AC models facing +Y at identity), so - // we add PI/2 back when extracting to get the real yaw. - var q = playerEntity.Rotation; - float rawYaw = MathF.Atan2( - 2f * (q.W * q.Z + q.X * q.Y), - 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); - _playerController.Yaw = rawYaw + MathF.PI / 2f; - - _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 - } - } - }; + // Phase K.1b — every keyboard/mouse handler routes through the + // InputDispatcher. The legacy direct kb.KeyDown / mouse.MouseDown + // switches are gone; subscribers below own all game-side reactions. + // We KEEP a direct mouse.MouseMove handler because mouse-delta is + // axis input, not chord input — but with explicit WantCaptureMouse + // gating and the previous "_playerMouseDeltaX +=" line dropped so + // mouse delta NEVER drives character yaw (regression-prevention + // per K.1b plan §D). + var firstKb = _input.Keyboards.FirstOrDefault(); + var firstMouse = _input.Mice.FirstOrDefault(); + if (firstKb is not null && firstMouse is not null) + { + _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); + _mouseSource = new AcDream.App.Input.SilkMouseSource( + firstMouse, + wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse, + wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); + _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; + _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( + _kbSource, _mouseSource, _keyBindings); + _inputDispatcher.Fired += OnInputAction; + } + // Mouse delta handler — kept direct because Silk.NET delivers mouse + // moves as continuous (x, y) positions, not chord events. We gate + // on WantCaptureMouse so panel hover never drives camera. The + // previous "_playerMouseDeltaX += dx * sens" branch is GONE — mouse + // never drives character yaw in K.1b. RMB-held orbit is wired via + // the dispatcher's AcdreamRmbOrbitHold action (subscriber sets + // _rmbHeld below). foreach (var mouse in _input.Mice) { mouse.MouseMove += (m, pos) => { + // K.1b §E: explicit WantCaptureMouse defense-in-depth on the + // surviving direct-mouse handler. Suppresses RMB orbit / + // FlyCamera look while ImGui has the mouse focus. + if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + { + _lastMouseX = pos.X; + _lastMouseY = pos.Y; + return; + } if (_cameraController is null) return; float dx = pos.X - _lastMouseX; @@ -740,13 +566,18 @@ public sealed class GameWindow : IDisposable // Hold-RMB orbit: player stays the central point, camera // free-orbits around. X rotates around, Y pitches. On release // the camera STAYS at the new angle (no snap back). + // K.1b: this is the ONLY mouse-delta path that affects + // ANYTHING when in player mode — character yaw is + // dispatcher-only (A/D keys). MMB mouse-look comes + // back as hardcoded behavior in K.2. _chaseCamera.YawOffset -= dx * 0.004f * sens; _chaseCamera.AdjustPitch(dy * 0.003f * sens); } else { - // Normal chase: X turns the character, Y pitches camera. - _playerMouseDeltaX += dx * sens; + // Without RMB held, mouse only pitches the chase + // camera (Y-axis). Mouse X is dropped — character + // turning is keyboard-only in K.1b. _chaseCamera.AdjustPitch(dy * 0.003f * sens); } } @@ -757,8 +588,12 @@ public sealed class GameWindow : IDisposable } else { + // Orbit-camera mode (offline / pre-login): hold LMB to + // drag-rotate. K.1b reads the mouse-button state via + // IMouseSource so all "is button held" queries go + // through the same abstraction the dispatcher uses. float sens = _sensOrbit; - if (m.IsButtonPressed(MouseButton.Left)) + if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left)) { _cameraController.Orbit.Yaw -= dx * 0.005f * sens; _cameraController.Orbit.Pitch = Math.Clamp( @@ -769,69 +604,6 @@ public sealed class GameWindow : IDisposable _lastMouseX = pos.X; _lastMouseY = pos.Y; }; - - mouse.MouseDown += (_, btn) => - { - if (btn == MouseButton.Right && _playerMode - && _cameraController?.IsChaseMode == true) - { - _rmbHeld = true; - } - }; - - mouse.MouseUp += (_, btn) => - { - if (btn == MouseButton.Right) - { - // Camera stays at the orbited position — no snap back. - _rmbHeld = false; - } - }; - - mouse.Scroll += (_, scroll) => - { - if (_cameraController is null) return; - - // Chase mode: mouse wheel zooms (adjusts camera distance). - if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) - { - _chaseCamera.AdjustDistance(-scroll.Y * 0.8f); - } - // Fly mode: no scroll action (could adjust move speed later). - else if (_cameraController.IsFlyMode) - { - // no-op - } - // Orbit mode: wheel zooms the orbit camera. - else - { - _cameraController.Orbit.Distance = Math.Clamp( - _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f); - } - }; - } - - // Phase K.1a — input dispatcher skeleton wired ALONGSIDE the - // existing IsKeyPressed + KeyDown handlers above. The dispatcher - // observes the same Silk.NET keyboard/mouse and fires high-level - // InputAction events; in K.1a nothing actually drives behavior - // off the dispatcher except a diagnostic Console.WriteLine, so - // this is a no-op for the player. K.1b cuts the legacy paths - // over to subscribe to dispatcher.Fired. - var firstKb = _input.Keyboards.FirstOrDefault(); - var firstMouse = _input.Mice.FirstOrDefault(); - if (firstKb is not null && firstMouse is not null) - { - _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); - _mouseSource = new AcDream.App.Input.SilkMouseSource( - firstMouse, - wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse, - wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); - _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; - _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( - _kbSource, _mouseSource, _keyBindings); - _inputDispatcher.Fired += (action, activation) => - Console.WriteLine($"[input] {action} {activation}"); } _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); @@ -3823,44 +3595,57 @@ public sealed class GameWindow : IDisposable if (_cameraController is null || _input is null) return; - var kb = _input.Keyboards[0]; - - // Phase D.2a — suppress game-side WASD / interaction polling when - // ImGui has keyboard focus (e.g. a text field is active). Without - // this, typing "walk" into a chat field would actually walk. + // Phase D.2a / K.1b — suppress game-side input polling when ImGui + // has keyboard focus (e.g. a text field is active). The InputDispatcher + // already gates KeyDown/MouseDown via WantCaptureKeyboard internally; + // this guard adds defense-in-depth for the per-frame IsActionHeld + // movement poll below (typing "walk" into a chat field shouldn't + // walk). bool suppressGameInput = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; if (suppressGameInput) return; if (_cameraController.IsFlyMode) { + // K.1b: fly-camera input flows through the dispatcher. Reuses + // movement actions (Forward/Backup/TurnLeft/TurnRight/Jump/ + // RunLock) — in fly mode "TurnLeft" semantically maps to + // strafe-left because A/D in fly is strafe, not turn (mouse + // delta drives fly heading instead). + if (_inputDispatcher is null) return; _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)); + w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward), + a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), + s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup), + d: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), + up: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump), + down: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.AcdreamFlyDown), + boost: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock)); } 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 + // Phase B.2 / K.1b: player movement mode — every input flows + // through the dispatcher. WASD walks/runs, A/D turns, Z/X + // strafes, Shift runs, Space jumps. Mouse delta NEVER drives + // character yaw (regression-prevention per K.1b plan §D); + // MouseDeltaX is hardcoded 0f. RMB held still pans the chase + // camera (handled in the mouse-move handler via _rmbHeld). + // The _playerMouseDeltaX field is preserved as plumbing for the + // future MMB-mouse-look behavior coming back in K.2. + if (_inputDispatcher is null) return; + _playerMouseDeltaX = 0f; // defensive: ensure no leakage even if some path writes it 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, - Jump: kb.IsKeyPressed(Key.Space)); + Forward: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward), + Backward: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup), + StrafeLeft: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft), + StrafeRight: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight), + TurnLeft: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), + TurnRight: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), + Run: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock), + MouseDeltaX: 0f, // K.1b: mouse never drives character yaw + Jump: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump)); var result = _playerController.Update((float)dt, input); @@ -5139,6 +4924,307 @@ public sealed class GameWindow : IDisposable // in the DevToolsEnabled construction block above; null otherwise. private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel; + // ── K.1b: dispatcher action handler ────────────────────────────────── + // + // SINGLE place where every game-side keyboard/mouse-button reaction + // lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp + // handlers are gone; everything now flows through InputDispatcher.Fired + // → here. New behaviors register a new InputAction in the enum + a + // case in this switch + a binding in KeyBindings. + + /// + /// K.1b — multicast subscriber on . + /// Handles every game-side reaction to a keyboard/mouse-button chord. + /// Per-frame held-state polling (movement WASD/Shift/Space) lives in + /// via ; + /// this method handles transitional Press/Release events only. + /// + private void OnInputAction( + AcDream.UI.Abstractions.Input.InputAction action, + AcDream.UI.Abstractions.Input.ActivationType activation) + { + // Diagnostic — kept from K.1a; helpful for K.1c verification. + Console.WriteLine($"[input] {action} {activation}"); + + // RMB-orbit hold: track press/release transitions explicitly so + // _rmbHeld is true exactly while the chord is held. Hold-type + // chords also fire Press on key-down + Release on key-up; we + // ignore the in-between Hold ticks here (the mouse-move handler + // checks _rmbHeld each frame anyway). + if (action == AcDream.UI.Abstractions.Input.InputAction.AcdreamRmbOrbitHold) + { + if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press) + _rmbHeld = _playerMode && _cameraController?.IsChaseMode == true; + else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release) + _rmbHeld = false; + return; + } + + // ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every + // wheel tick. Press is the only activation type for wheel. + if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp + || action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown) + { + if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return; + HandleScrollAction(action); + return; + } + + // Every other action fires on Press only (no Release / Hold side- + // effects in the K.1b set). Filter out non-Press activations early + // so subscribers that have Release-mode bindings don't accidentally + // re-fire. + if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return; + + switch (action) + { + case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: + foreach (var panel in EnumerateDebugPanel()) + { + panel.IsVisible = !panel.IsVisible; + _debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}"); + } + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires: + ToggleCollisionWires(); + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby: + DumpPlayerAndNearbyEntities(); + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay: + CycleTimeOfDay(); + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown: + AdjustActiveSensitivity(1f / 1.2f); + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp: + AdjustActiveSensitivity(1.2f); + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather: + CycleWeather(); + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode: + _cameraController?.ToggleFly(); + break; + + case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode: + TogglePlayerMode(); + break; + + case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: + if (_cameraController?.IsFlyMode == true) + _cameraController.ToggleFly(); // exit fly, release cursor + else if (_playerMode) + { + _playerMode = false; + _cameraController?.ExitChaseMode(); + _playerController = null; + _chaseCamera = null; + _playerCurrentAnimCommand = null; + } + else + _window!.Close(); + break; + } + } + + /// + /// K.1b: Tab handler extracted into a method so the dispatcher + /// subscriber can call it. Same body as the previous Tab branch in + /// the legacy direct keyboard handler — toggle player↔fly mode, set + /// up + + /// when entering player mode, tear them down on exit. + /// + private void TogglePlayerMode() + { + // Phase B.2 guard: only active when a live session is in-world. + if (_liveSession is null + || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + return; + + _playerMode = !_playerMode; + if (_playerMode) + { + if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) + { + _playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine); + // Read the real step height from the player's Setup dat. + if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); + if (playerSetup is not null) + _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); + _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) + ? playerSetup.StepUpHeight + : 2f; + } + else + { + _playerController.StepUpHeight = 2f; + } + int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); + int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); + uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; + var initResult = _physicsEngine.Resolve( + playerEntity.Position, pinitCellId & 0xFFFFu, + System.Numerics.Vector3.Zero, 100f); + _playerController.SetPosition(initResult.Position, initResult.CellId); + + if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) + && playerAE.Sequencer is { } playerSeq) + { + _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); + } + + var q = playerEntity.Rotation; + float rawYaw = MathF.Atan2( + 2f * (q.W * q.Z + q.X * q.Y), + 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); + _playerController.Yaw = rawYaw + MathF.PI / 2f; + + _chaseCamera = new AcDream.App.Rendering.ChaseCamera + { + Aspect = _window!.Size.X / (float)_window.Size.Y, + }; + // K.1b: _playerMouseDeltaX is no longer consumed by + // MovementInput, but we still reset it here so any stale + // accumulated value from a previous session doesn't leak + // into a future code path that re-enables mouse-yaw. + _playerMouseDeltaX = 0f; + _cameraController?.EnterChaseMode(_chaseCamera); + } + else + { + _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; + } + } + + /// + /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies + /// the currently-active mode's sensitivity (chase / fly / orbit) by the + /// given factor and clamps to [0.005, 3.0]. + /// + private void AdjustActiveSensitivity(float factor) + { + string modeLabel; + float current; + if (_playerMode && _cameraController?.IsChaseMode == true) + { modeLabel = "Chase"; current = _sensChase; } + else if (_cameraController?.IsFlyMode == true) + { modeLabel = "Fly"; current = _sensFly; } + else + { modeLabel = "Orbit"; current = _sensOrbit; } + + float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor)); + if (modeLabel == "Chase") _sensChase = next; + else if (modeLabel == "Fly") _sensFly = next; + else _sensOrbit = next; + _debugVm?.AddToast($"{modeLabel} sens {next:F3}x"); + } + + /// + /// K.1b: F3 dump handler extracted into a method. Same body as the + /// previous in-line F3 branch — prints the player's position + + /// nearby visible entities + nearby shadow physics objects. + /// + private void DumpPlayerAndNearbyEntities() + { + System.Numerics.Vector3 pos; + if (_playerMode && _playerController is not null) + pos = _playerController.Position; + else + { + System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv); + pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); + } + int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f); + int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f); + Console.WriteLine( + $"=== F3 DEBUG DUMP ===\n" + + $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + + $" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" + + $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); + + var visibleNearby = new List(); + foreach (var e in _worldState.Entities) + { + float dx = e.Position.X - pos.X; + float dy = e.Position.Y - pos.Y; + if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e); + } + Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}"); + foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12)) + { + float d = (e.Position - pos).Length(); + Console.WriteLine( + $" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " + + $"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}"); + } + + var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>(); + foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) + { + float dx = o.Position.X - pos.X; + float dy = o.Position.Y - pos.Y; + float d = MathF.Sqrt(dx * dx + dy * dy); + if (d < 15f) sorted.Add((o, d)); + } + sorted.Sort((a, b) => a.dist.CompareTo(b.dist)); + Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}"); + foreach (var (o, d) in sorted.Take(12)) + { + Console.WriteLine( + $" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " + + $"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}"); + } + } + + /// + /// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever + /// camera distance is current — chase camera distance in player mode, + /// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude + /// is fixed-step (the previous proportional scroll.Y was lost when we + /// moved scroll into the dispatcher, but the discrete step matches + /// retail wheel feel). + /// + private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action) + { + if (_cameraController is null) return; + float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f; + + if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) + { + // Chase mode: zoom (closer on ScrollUp). + _chaseCamera.AdjustDistance(-dir * 0.8f); + } + else if (_cameraController.IsFlyMode) + { + // Fly mode: no-op (could adjust move speed later). + } + else + { + _cameraController.Orbit.Distance = Math.Clamp( + _cameraController.Orbit.Distance - dir * 20f, 50f, 2000f); + } + } + private void OnClosing() { // Phase A.1: join the streamer worker thread before tearing down GL diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs index 6afd512..a922353 100644 --- a/src/AcDream.UI.Abstractions/Input/InputAction.cs +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -256,4 +256,10 @@ public enum InputAction AcdreamToggleFlyMode, /// Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c). AcdreamTogglePlayerMode, + /// Hold-RMB chase-camera orbit (debug-only, not user-rebindable). + /// Camera orbits around the player while held; never drives character yaw. + AcdreamRmbOrbitHold, + /// Fly-camera descend (Ctrl) — only meaningful while fly camera + /// is active. K.1b binds it to ControlLeft; K.1c may rebind. + AcdreamFlyDown, } diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index d955da4..06fd8cd 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -61,6 +61,71 @@ public sealed class InputDispatcher /// Topmost scope on the stack — what the dispatcher looks up first. public InputScope ActiveScope => _scopes.Peek(); + /// + /// Per-frame "is this action's chord currently held" query. Walks every + /// binding for the given action; returns true if any of them has its + /// chord currently held in the underlying keyboard/mouse state AND the + /// modifier mask matches. + /// + /// + /// Used by per-frame movement polling (MovementInput.Forward + /// etc.) which needs the held state right now, NOT just a press + /// transition. The event drives press/release/hold + /// transitions; drives steady-state polling. + /// + /// + /// + /// always returns false — there are no + /// bindings for it, and we don't want it to alias the "any unbound" + /// default to "always held". + /// + /// + public bool IsActionHeld(InputAction action) + { + if (action == InputAction.None) return false; + foreach (var b in _bindings.ForAction(action)) + { + if (IsChordHeld(b.Chord)) return true; + } + return false; + } + + /// True iff the given chord's primary key is currently down on + /// the appropriate device AND the keyboard's current modifier mask + /// equals the chord's required modifier mask exactly. Modifiers must + /// match precisely — Ctrl+A held does NOT count Shift+Ctrl+A as held. + private bool IsChordHeld(KeyChord chord) + { + if (chord.Device == 0) + { + if (!_keyboard.IsHeld(chord.Key)) return false; + } + else if (chord.Device == 1) + { + var btn = KeyToMouseButton(chord.Key); + if (btn is null || !_mouse.IsHeld(btn.Value)) return false; + } + else + { + // Unknown device — never held. + return false; + } + return _keyboard.CurrentModifiers == chord.Modifiers; + } + + /// Inverse of : decode a chord + /// key back to the original . Returns null + /// for keys that don't correspond to a mouse button. + private static MouseButton? KeyToMouseButton(Key key) => (int)key switch + { + -1001 => MouseButton.Left, + -1002 => MouseButton.Right, + -1003 => MouseButton.Middle, + -1004 => MouseButton.Button4, + -1005 => MouseButton.Button5, + _ => null, + }; + /// Push a scope onto the active stack. Top wins. public void PushScope(InputScope scope) => _scopes.Push(scope); @@ -184,12 +249,13 @@ public sealed class InputDispatcher private void OnScroll(float delta) { if (_mouse.WantCaptureMouse) return; - // Wheel ticks emit ScrollUp / ScrollDown actions if either chord - // is bound. We don't go through KeyBindings.Find here — wheel is - // a fixed mapping for now (rebindable in K.1c). - // Empty in K.1a — no subscribers; the action is observable via - // direct subscription if a future caller wants it. - _ = delta; + // K.1b: wheel ticks emit ScrollUp / ScrollDown depending on the + // sign of the delta. Magnitude is dropped — the action is a + // discrete press transition; subscribers apply a fixed-size step. + // (Plan-agent: rebindable in K.1c when KeyChord+wheel-axis support + // lands.) + if (delta > 0f) Fired?.Invoke(InputAction.ScrollUp, ActivationType.Press); + else if (delta < 0f) Fired?.Invoke(InputAction.ScrollDown, ActivationType.Press); } /// diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index 527b9f5..bb23b41 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -70,15 +70,41 @@ public sealed class KeyBindings { var b = new KeyBindings(); - // Movement (current acdream — wrong for retail but unchanged for K.1a) - b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); - b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); - b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); - b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight)); - b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft)); - b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight)); - b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementRunLock, ActivationType.Hold)); - b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); + // Movement (current acdream — wrong for retail but unchanged for K.1a/K.1b). + // + // Each movement key gets BOTH a bare-mods chord and a Shift-mods chord + // so the per-frame IsActionHeld query stays true while the user holds + // Shift (the acdream-current run-modifier). Before K.1b, movement + // polled IsKeyPressed(Key.W) directly — modifier-blind. K.1b's + // dispatcher does strict modifier matching, so we duplicate-bind here + // to preserve the modifier-blind feel until K.1c reshapes the whole + // table to retail defaults. + b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new(new KeyChord(Key.W, ModifierMask.Shift), InputAction.MovementForward)); + b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); + b.Add(new(new KeyChord(Key.S, ModifierMask.Shift), InputAction.MovementBackup)); + b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); + b.Add(new(new KeyChord(Key.A, ModifierMask.Shift), InputAction.MovementTurnLeft)); + b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight)); + b.Add(new(new KeyChord(Key.D, ModifierMask.Shift), InputAction.MovementTurnRight)); + b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft)); + b.Add(new(new KeyChord(Key.Z, ModifierMask.Shift), InputAction.MovementStrafeLeft)); + b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight)); + b.Add(new(new KeyChord(Key.X, ModifierMask.Shift), InputAction.MovementStrafeRight)); + // Run-modifier: Shift held triggers run. When ShiftLeft/ShiftRight + // is held, the keyboard's CurrentModifiers includes Shift — so the + // chord requires Modifiers=Shift. Both sides resolve to the same + // action so a held-poll on either side answers true. + b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); + b.Add(new(new KeyChord(Key.Space, ModifierMask.Shift), InputAction.MovementJump)); + + // Fly-camera descend — Ctrl held in fly mode lowers the camera. + // ControlLeft held delivers CurrentModifiers=Ctrl, so chord uses + // mask=Ctrl. Both Ctrl sides resolve to the same action. + b.Add(new(new KeyChord(Key.ControlLeft, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.ControlRight, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold)); // Acdream debug binds b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.AcdreamToggleDebugPanel)); @@ -90,6 +116,15 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather)); b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode)); b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode)); + b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey)); + + // K.1b mouse: RMB-hold drives camera-only orbit (never character yaw). + // Device=1 marks this as a mouse chord; the dispatcher routes + // _mouse.IsHeld(Right) through this chord for IsActionHeld lookup. + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right), ModifierMask.None, Device: 1), + InputAction.AcdreamRmbOrbitHold, + ActivationType.Hold)); return b; } diff --git a/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs new file mode 100644 index 0000000..dcb2c1f --- /dev/null +++ b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs @@ -0,0 +1,197 @@ +using System; +using System.Numerics; +using AcDream.App.Input; +using AcDream.Core.Physics; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; +using Xunit; + +namespace AcDream.Core.Tests.Input; + +/// +/// K.1b integration: drive the via the +/// public API a fake keyboard would, build a +/// from IsActionHeld queries (the new K.1b code path), and confirm +/// produces the same result +/// as feeding it the equivalent MovementInput directly. This is +/// the regression-prevention test for the "preserve the boundary" rule +/// in the K.1b plan: MovementInput stays as the contract; only the +/// SOURCE of input changes. +/// +public class DispatcherToMovementIntegrationTests +{ + /// Test double — the same fake the UI.Abstractions tests use, + /// duplicated here because it's internal in that assembly. + private sealed class FakeKb : IKeyboardSource + { + public event Action? KeyDown; + public event Action? KeyUp; + private readonly System.Collections.Generic.HashSet _held = new(); + public ModifierMask CurrentModifiers { get; set; } = ModifierMask.None; + public bool IsHeld(Key k) => _held.Contains(k); + public void Press(Key k, ModifierMask mods = ModifierMask.None) + { + CurrentModifiers = mods; + _held.Add(k); + KeyDown?.Invoke(k, mods); + } + public void Release(Key k, ModifierMask mods = ModifierMask.None) + { + CurrentModifiers = mods; + _held.Remove(k); + KeyUp?.Invoke(k, mods); + } + } + +#pragma warning disable CS0067 // events declared on the interface but unused in this fake + private sealed class FakeMouse : IMouseSource + { + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; + public bool IsHeld(MouseButton b) => false; + public bool WantCaptureMouse { get; set; } + public bool WantCaptureKeyboard { get; set; } + } +#pragma warning restore CS0067 + + private static PhysicsEngine MakeFlatEngine() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; + var terrain = new TerrainSurface(heights, heightTable); + engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), + Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); + return engine; + } + + private static MovementInput BuildInputFromDispatcher(InputDispatcher d) => + new MovementInput( + Forward: d.IsActionHeld(InputAction.MovementForward), + Backward: d.IsActionHeld(InputAction.MovementBackup), + StrafeLeft: d.IsActionHeld(InputAction.MovementStrafeLeft), + StrafeRight: d.IsActionHeld(InputAction.MovementStrafeRight), + TurnLeft: d.IsActionHeld(InputAction.MovementTurnLeft), + TurnRight: d.IsActionHeld(InputAction.MovementTurnRight), + Run: d.IsActionHeld(InputAction.MovementRunLock), + MouseDeltaX: 0f, // K.1b: mouse never drives character yaw + Jump: d.IsActionHeld(InputAction.MovementJump)); + + [Fact] + public void Dispatcher_W_held_produces_forward_motion() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; // facing +X + + var kb = new FakeKb(); + var mouse = new FakeMouse(); + var bindings = KeyBindings.AcdreamCurrentDefaults(); + var dispatcher = new InputDispatcher(kb, mouse, bindings); + + kb.Press(Key.W); + + var input = BuildInputFromDispatcher(dispatcher); + Assert.True(input.Forward); + Assert.False(input.Run); + + var result = controller.Update(1.0f, input); + Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward"); + } + + [Fact] + public void Dispatcher_W_then_Shift_gives_running_motion() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; + + var kb = new FakeKb(); + var mouse = new FakeMouse(); + var bindings = KeyBindings.AcdreamCurrentDefaults(); + var dispatcher = new InputDispatcher(kb, mouse, bindings); + + kb.Press(Key.W); + // Shift pressed alongside W — real keyboard delivers KeyDown(Shift, + // mods=Shift) and CurrentModifiers reflects Shift held. + kb.Press(Key.ShiftLeft, ModifierMask.Shift); + + var input = BuildInputFromDispatcher(dispatcher); + Assert.True(input.Forward); // duplicate (W, Shift) binding catches this + Assert.True(input.Run); // (ShiftLeft, Shift) binding + } + + [Fact] + public void Dispatcher_W_release_clears_forward() + { + var kb = new FakeKb(); + var mouse = new FakeMouse(); + var bindings = KeyBindings.AcdreamCurrentDefaults(); + var dispatcher = new InputDispatcher(kb, mouse, bindings); + + kb.Press(Key.W); + Assert.True(BuildInputFromDispatcher(dispatcher).Forward); + + kb.Release(Key.W); + Assert.False(BuildInputFromDispatcher(dispatcher).Forward); + } + + [Fact] + public void MovementInput_with_mouse_delta_zero_matches_input_with_mouse_delta_nonzero() + { + // K.1b regression-prevention: mouse delta no longer drives character + // yaw. Two MovementInputs identical except for MouseDeltaX produce + // identical motion-command bytes (ForwardCommand / ForwardSpeed / + // SidestepCommand / TurnCommand). Yaw still changes — but only by + // a hair from MouseDeltaX, which is dropped in K.1b. + // + // We construct the controller twice (separate state) so the previous + // frame's MouseDeltaX doesn't leak into the second run via Yaw. + var engineA = MakeFlatEngine(); + var ctrlA = new PlayerMovementController(engineA); + ctrlA.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + ctrlA.Yaw = 0f; + + var engineB = MakeFlatEngine(); + var ctrlB = new PlayerMovementController(engineB); + ctrlB.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + ctrlB.Yaw = 0f; + + var inputZero = new MovementInput(Forward: true, MouseDeltaX: 0f); + var inputJittered = new MovementInput(Forward: true, MouseDeltaX: 47.3f); + + var rA = ctrlA.Update(0.05f, inputZero); + var rB = ctrlB.Update(0.05f, inputJittered); + + Assert.Equal(rA.ForwardCommand, rB.ForwardCommand); + Assert.Equal(rA.SidestepCommand, rB.SidestepCommand); + Assert.Equal(rA.TurnCommand, rB.TurnCommand); + Assert.Equal(rA.ForwardSpeed, rB.ForwardSpeed); + } + + [Fact] + public void Dispatcher_no_keys_held_produces_idle_input() + { + var kb = new FakeKb(); + var mouse = new FakeMouse(); + var bindings = KeyBindings.AcdreamCurrentDefaults(); + var dispatcher = new InputDispatcher(kb, mouse, bindings); + + var input = BuildInputFromDispatcher(dispatcher); + Assert.False(input.Forward); + Assert.False(input.Backward); + Assert.False(input.StrafeLeft); + Assert.False(input.StrafeRight); + Assert.False(input.TurnLeft); + Assert.False(input.TurnRight); + Assert.False(input.Run); + Assert.False(input.Jump); + Assert.Equal(0f, input.MouseDeltaX); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs new file mode 100644 index 0000000..4ebedca --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// K.1b: per-frame held-key +/// polling. Movement (WASD/Shift/Space) needs "is W currently held this +/// frame" — that's IsActionHeld. The dispatcher's Fired events +/// drive press/release transitions; IsActionHeld drives the +/// per-frame MovementInput struct. +/// +public class InputDispatcherIsActionHeldTests +{ + private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, KeyBindings bindings) + Build() + { + var kb = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var bindings = new KeyBindings(); + var dispatcher = new InputDispatcher(kb, mouse, bindings); + return (dispatcher, kb, mouse, bindings); + } + + [Fact] + public void IsActionHeld_returns_true_while_bound_key_held() + { + var (dispatcher, kb, _, bindings) = Build(); + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + + kb.EmitKeyDown(Key.W, ModifierMask.None); + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); + + kb.EmitKeyUp(Key.W, ModifierMask.None); + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + } + + [Fact] + public void IsActionHeld_returns_false_when_no_binding_for_action() + { + var (dispatcher, kb, _, bindings) = Build(); + // No binding for MovementBackup at all. + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + + kb.EmitKeyDown(Key.W, ModifierMask.None); + + Assert.False(dispatcher.IsActionHeld(InputAction.MovementBackup)); + } + + [Fact] + public void IsActionHeld_modifier_mismatch_returns_false() + { + var (dispatcher, kb, _, bindings) = Build(); + bindings.Add(new Binding(new KeyChord(Key.A, ModifierMask.Ctrl), InputAction.SelectionExamine)); + + // A held without Ctrl — chord doesn't match. + kb.EmitKeyDown(Key.A, ModifierMask.None); + Assert.False(dispatcher.IsActionHeld(InputAction.SelectionExamine)); + + // Now release A and press Ctrl+A. + kb.EmitKeyUp(Key.A, ModifierMask.None); + kb.EmitKeyDown(Key.A, ModifierMask.Ctrl); + Assert.True(dispatcher.IsActionHeld(InputAction.SelectionExamine)); + } + + [Fact] + public void IsActionHeld_any_of_multiple_bindings_satisfies() + { + var (dispatcher, kb, _, bindings) = Build(); + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + bindings.Add(new Binding(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward)); + + kb.EmitKeyDown(Key.Up, ModifierMask.None); + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); + + kb.EmitKeyUp(Key.Up, ModifierMask.None); + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + + kb.EmitKeyDown(Key.W, ModifierMask.None); + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); + } + + [Fact] + public void IsActionHeld_works_for_mouse_button_chord() + { + var (dispatcher, _, mouse, bindings) = Build(); + var rmb = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1); + bindings.Add(new Binding(rmb, InputAction.AcdreamRmbOrbitHold, ActivationType.Hold)); + + Assert.False(dispatcher.IsActionHeld(InputAction.AcdreamRmbOrbitHold)); + + mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None); + Assert.True(dispatcher.IsActionHeld(InputAction.AcdreamRmbOrbitHold)); + + mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None); + Assert.False(dispatcher.IsActionHeld(InputAction.AcdreamRmbOrbitHold)); + } + + [Fact] + public void IsActionHeld_returns_false_for_None_action() + { + var (dispatcher, _, _, _) = Build(); + Assert.False(dispatcher.IsActionHeld(InputAction.None)); + } + + [Fact] + public void IsActionHeld_does_not_check_WantCaptureMouse() + { + // Per-frame held-state lookup is independent of UI capture: even + // with WantCaptureMouse=true a movement key already held when + // ImGui took focus continues to read as held until KeyUp. Press + // events ARE gated (the Press wouldn't fire while UI captures), + // but IsActionHeld answers the keyboard's underlying "is the + // physical key down right now" — which the legacy IsKeyPressed + // also did. The per-frame OnUpdate guard on + // ImGui.GetIO().WantCaptureKeyboard is what suppresses movement + // when chat is focused. + var (dispatcher, kb, mouse, bindings) = Build(); + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + kb.EmitKeyDown(Key.W, ModifierMask.None); + + mouse.WantCaptureMouse = true; + mouse.WantCaptureKeyboard = true; + + // Even with both capture flags set, IsActionHeld remains true + // because W is physically held. The dispatcher only suppresses + // press transitions. + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs index bedb5a5..6e6906a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -186,4 +186,31 @@ public class InputDispatcherTests dispatcher.Tick(); Assert.Empty(fired); } + + [Fact] + public void Scroll_positive_emits_ScrollUp_press() + { + var (_, _, mouse, _, fired) = Build(); + mouse.EmitScroll(1.0f); + Assert.Single(fired); + Assert.Equal((InputAction.ScrollUp, ActivationType.Press), fired[0]); + } + + [Fact] + public void Scroll_negative_emits_ScrollDown_press() + { + var (_, _, mouse, _, fired) = Build(); + mouse.EmitScroll(-1.0f); + Assert.Single(fired); + Assert.Equal((InputAction.ScrollDown, ActivationType.Press), fired[0]); + } + + [Fact] + public void WantCaptureMouse_suppresses_Scroll_events() + { + var (_, _, mouse, _, fired) = Build(); + mouse.WantCaptureMouse = true; + mouse.EmitScroll(1.0f); + Assert.Empty(fired); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs index 4faffa4..cfc3565 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs @@ -87,8 +87,11 @@ public class KeyBindingsTests [Fact] public void AcdreamCurrentDefaults_binds_shift_as_hold_for_run() { + // K.1b: when ShiftLeft is held the OS keyboard delivers + // CurrentModifiers=Shift, so the chord must be (ShiftLeft, Shift). + // Lookup with the matching modifier mask succeeds. var b = KeyBindings.AcdreamCurrentDefaults(); - var hold = b.Find(new KeyChord(Key.ShiftLeft, ModifierMask.None), ActivationType.Hold); + var hold = b.Find(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), ActivationType.Hold); Assert.NotNull(hold); Assert.Equal(InputAction.MovementRunLock, hold!.Value.Action); }