using AcDream.UI.Abstractions.Input; namespace AcDream.App.Input; internal interface IMovementInputSource : IRuntimeMovementInputSource { } /// /// Samples held graphical input and maps press edges onto the Runtime-owned /// retail autorun latch. The dispatcher remains the only physical /// keyboard/mouse-button state source. /// internal sealed class DispatcherMovementInputSource : IMovementInputSource { private readonly RuntimeLocalPlayerMovementState _movement; private readonly IInputCaptureSource? _capture; private InputDispatcher? _dispatcher; public DispatcherMovementInputSource( RuntimeLocalPlayerMovementState movement, IInputCaptureSource? capture = null) { _movement = movement ?? throw new ArgumentNullException(nameof(movement)); _capture = capture; } public bool AutoRunActive => _movement.AutoRunActive; public bool IsAvailable => _dispatcher is not null; public void Bind(InputDispatcher dispatcher) { ArgumentNullException.ThrowIfNull(dispatcher); if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher)) throw new InvalidOperationException( "The movement input source is already bound to another dispatcher."); _dispatcher = dispatcher; } public void Unbind(InputDispatcher dispatcher) { ArgumentNullException.ThrowIfNull(dispatcher); if (ReferenceEquals(_dispatcher, dispatcher)) _dispatcher = null; } public MovementInput Capture() { // Devtools owns the whole gameplay keyboard while active, including // a latched autorun. Retained chat owns physical key state only; // retail's autorun latch continues until an explicit cancel action. if (_capture?.DevToolsWantCaptureKeyboard == true) return default; if (_movement.HasCommandInput) return _movement.CommandInput; if (_dispatcher is not { } dispatcher) return default; bool walking = dispatcher.IsActionHeld(InputAction.MovementWalkMode); bool forward = dispatcher.IsActionHeld(InputAction.MovementForward); return new MovementInput( Forward: forward || AutoRunActive, Backward: dispatcher.IsActionHeld(InputAction.MovementBackup), StrafeLeft: dispatcher.IsActionHeld(InputAction.MovementStrafeLeft), StrafeRight: dispatcher.IsActionHeld(InputAction.MovementStrafeRight), TurnLeft: dispatcher.IsActionHeld(InputAction.MovementTurnLeft), TurnRight: dispatcher.IsActionHeld(InputAction.MovementTurnRight), // Movement parity audit (2026-07-30): retail's autorun hard-forces // Run for its whole duration (ACCmdInterp autorun dispatch always // sends RunForward-class state); the live walk/run toggle only // applies to ordinary held-key movement. Recomputing `!walking` // unconditionally let a walk-mode toggle demote an active autorun // to walking — impossible in retail. Run: !walking || AutoRunActive, Jump: dispatcher.IsActionHeld(InputAction.MovementJump)); } /// /// Applies the press-only autorun policy at the same point in the semantic /// action pipeline as the former GameWindow body. /// /// True when the action is fully consumed. public bool HandlePressedAction(InputAction action) { if (action == InputAction.MovementRunLock) return _movement.Execute( AcDream.Runtime.RuntimeMovementCommand.ToggleRunLock); if (AutoRunActive && action is ( // Movement parity audit (2026-07-30): retail's // ACCmdInterp::HandleNewForwardMovement drops autorun on EVERY // fresh forward press edge, not only on backward/stop/strafe — // a new W press while autorunning hands control back to the key. InputAction.MovementForward or InputAction.MovementBackup or InputAction.MovementStop or InputAction.MovementStrafeLeft or InputAction.MovementStrafeRight)) { _movement.CancelAutoRun(); } return false; } public void ResetSession() => _movement.ResetInputIntent(); }