refactor(input): extract the gameplay frame

This commit is contained in:
Erik 2026-07-22 01:38:40 +02:00
parent 0bc9fda9de
commit c557038353
24 changed files with 2433 additions and 559 deletions

View file

@ -0,0 +1,83 @@
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IMovementInputSource
{
MovementInput Capture();
}
/// <summary>
/// Owns held movement sampling and the retail autorun latch. The input
/// dispatcher remains the only keyboard/mouse-button state source.
/// </summary>
internal sealed class DispatcherMovementInputSource : IMovementInputSource
{
private readonly IInputCaptureSource? _capture;
private InputDispatcher? _dispatcher;
public DispatcherMovementInputSource(IInputCaptureSource? capture = null) =>
_capture = capture;
public bool AutoRunActive { get; private set; }
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 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 (_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),
Run: !walking,
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
}
/// <summary>
/// Applies the press-only autorun policy at the same point in the semantic
/// action pipeline as the former GameWindow body.
/// </summary>
/// <returns>True when the action is fully consumed.</returns>
public bool HandlePressedAction(InputAction action)
{
if (action == InputAction.MovementRunLock)
{
AutoRunActive = !AutoRunActive;
return true;
}
if (AutoRunActive && action is (
InputAction.MovementBackup
or InputAction.MovementStop
or InputAction.MovementStrafeLeft
or InputAction.MovementStrafeRight))
{
AutoRunActive = false;
}
return false;
}
public void ResetSession() => AutoRunActive = false;
}

View file

@ -0,0 +1,87 @@
using AcDream.App.Combat;
using AcDream.App.Update;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface ICombatInputFrameController
{
void Tick();
void HandleMovementInput(InputAction action, ActivationType activation);
bool HandleInputAction(InputAction action, ActivationType activation);
}
internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameController
{
private readonly CombatAttackController _owner;
public CombatAttackInputFrameAdapter(CombatAttackController owner) =>
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
public void Tick() => _owner.Tick();
public void HandleMovementInput(InputAction action, ActivationType activation) =>
_owner.HandleMovementInput(action, activation);
public bool HandleInputAction(InputAction action, ActivationType activation) =>
_owner.HandleInputAction(action, activation);
}
/// <summary>
/// Owns the complete pre-object input frame: held semantic dispatch, one raw
/// mouse-look sample, and combat attack intent.
/// </summary>
internal sealed class GameplayInputFrameController : IGameplayInputFramePhase
{
private readonly InputDispatcher? _dispatcher;
private readonly DispatcherMovementInputSource _movement;
private readonly IMouseLookInputFrameController? _mouseLook;
private readonly ICombatInputFrameController _combat;
public GameplayInputFrameController(
InputDispatcher? dispatcher,
DispatcherMovementInputSource movement,
IMouseLookInputFrameController? mouseLook,
ICombatInputFrameController combat)
{
_dispatcher = dispatcher;
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_mouseLook = mouseLook;
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
}
public bool MouseLookActive => _mouseLook?.Active == true;
public void Tick(UpdateFrameTiming timing)
{
_ = timing;
_dispatcher?.Tick();
_mouseLook?.Tick();
_combat.Tick();
}
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
_mouseLook?.HandlePointerAction(action, activation) == true;
public bool HandleCombatAction(InputAction action, ActivationType activation)
{
// ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0 aborts
// automatic combat before the movement command enters the interpreter.
_combat.HandleMovementInput(action, activation);
return _combat.HandleInputAction(action, activation);
}
public bool HandlePressedMovementAction(InputAction action) =>
_movement.HandlePressedAction(action);
public void QueueRawMouseDelta(float dx, float dy) =>
_mouseLook?.QueueRawDelta(dx, dy);
public void EndMouseLook() => _mouseLook?.EndForLifecycle();
public void ResetSession()
{
_mouseLook?.ResetSession();
_movement.ResetSession();
}
}

View file

@ -0,0 +1,74 @@
using AcDream.App.UI;
namespace AcDream.App.Input;
internal interface IInputCaptureSource
{
bool WantCaptureMouse { get; }
bool WantCaptureKeyboard { get; }
bool DevToolsWantCaptureKeyboard { get; }
}
internal sealed class DevToolsInputCaptureSource
{
private readonly bool _enabled;
public DevToolsInputCaptureSource(bool enabled) => _enabled = enabled;
public bool WantCaptureMouse =>
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
public bool WantCaptureKeyboard =>
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
}
internal sealed class RetainedUiInputCaptureSlot
{
public UiRoot? Root { get; set; }
public bool WantCaptureMouse => Root?.WantsMouse ?? false;
public bool WantCaptureKeyboard => Root?.WantsKeyboard ?? false;
}
internal sealed class CompositeInputCaptureSource : IInputCaptureSource
{
private readonly DevToolsInputCaptureSource _devTools;
private readonly RetainedUiInputCaptureSlot _retained;
public CompositeInputCaptureSource(
DevToolsInputCaptureSource devTools,
RetainedUiInputCaptureSlot retained)
{
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
}
public bool WantCaptureMouse =>
_devTools.WantCaptureMouse || _retained.WantCaptureMouse;
public bool WantCaptureKeyboard =>
DevToolsWantCaptureKeyboard || _retained.WantCaptureKeyboard;
public bool DevToolsWantCaptureKeyboard =>
_devTools.WantCaptureKeyboard;
}
internal sealed class DelegateInputCaptureSource : IInputCaptureSource
{
private readonly Func<bool> _wantCaptureMouse;
private readonly Func<bool> _wantCaptureKeyboard;
public DelegateInputCaptureSource(
Func<bool> wantCaptureMouse,
Func<bool> wantCaptureKeyboard)
{
_wantCaptureMouse = wantCaptureMouse
?? throw new ArgumentNullException(nameof(wantCaptureMouse));
_wantCaptureKeyboard = wantCaptureKeyboard
?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
}
public bool WantCaptureMouse => _wantCaptureMouse();
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
public bool DevToolsWantCaptureKeyboard => false;
}

View file

@ -11,13 +11,18 @@ namespace AcDream.App.Input;
/// </summary>
public sealed class LocalPlayerOutboundController
{
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
private readonly IMovementTruthDiagnosticSink _diagnostic;
internal LocalPlayerOutboundController(IMovementTruthDiagnosticSink diagnostic)
{
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
}
public LocalPlayerOutboundController(
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
{
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
_diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic);
}
/// <summary>
@ -107,7 +112,7 @@ public sealed class LocalPlayerOutboundController
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
lastContact: contactByte);
_diagnostic(
_diagnostic.OnOutbound(
"AP",
sequence,
movement,
@ -193,7 +198,7 @@ public sealed class LocalPlayerOutboundController
forcePositionSequence: session.ForcePositionSequence,
contact: contactByte != 0,
standingLongjump: false);
_diagnostic(
_diagnostic.OnOutbound(
"MTS",
sequence,
movement,

View file

@ -0,0 +1,262 @@
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Input;
internal interface IInputMonotonicClock
{
float NowSeconds { get; }
}
internal sealed class EnvironmentInputMonotonicClock : IInputMonotonicClock
{
public float NowSeconds => (float)(Environment.TickCount64 / 1000.0);
}
internal interface IPointerPositionSource
{
float X { get; }
float Y { get; }
}
internal sealed class PointerPositionState : IPointerPositionSource
{
public float X { get; set; }
public float Y { get; set; }
}
internal interface IMouseLookCursor
{
bool HasSavedMode { get; }
void Hide();
void Restore();
}
internal interface IMouseLookInputFrameController
{
bool Active { get; }
bool HandlePointerAction(InputAction action, ActivationType activation);
void QueueRawDelta(float dx, float dy);
void Tick();
void EndAndRestoreCursor();
void EndForLifecycle();
void ResetSession();
}
internal sealed class SilkMouseLookCursor : IMouseLookCursor
{
private readonly IMouse _mouse;
private CursorMode? _savedMode;
public SilkMouseLookCursor(IMouse mouse) =>
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
public bool HasSavedMode => _savedMode.HasValue;
public void Hide()
{
_savedMode = _mouse.Cursor.CursorMode;
_mouse.Cursor.CursorMode = CursorMode.Hidden;
}
public void Restore()
{
_mouse.Cursor.CursorMode = _savedMode ?? CursorMode.Normal;
_savedMode = null;
}
}
/// <summary>
/// The current chase-camera input targets. GameWindow keeps compatibility
/// properties over this one slot until checkpoint F moves camera ownership.
/// </summary>
internal sealed class ChaseCameraInputState
{
public ChaseCamera? Legacy { get; set; }
public RetailChaseCamera? Retail { get; set; }
public float Sensitivity { get; set; } = 0.15f;
public bool RmbOrbitHeld { get; set; }
}
/// <summary>
/// Owns instant mouse-look state, raw-sample timing/filtering, movement
/// transition sends, and cursor capture across every exit path.
/// </summary>
internal sealed class MouseLookController : IMouseLookInputFrameController
{
private readonly IMouseSource _mouseSource;
private readonly IPointerPositionSource _pointer;
private readonly ILocalPlayerModeSource _playerMode;
private readonly ILocalPlayerControllerSource _playerController;
private readonly CameraController _camera;
private readonly ChaseCameraInputState _chase;
private readonly IMovementInputSource _movementInput;
private readonly LocalPlayerOutboundController _outbound;
private readonly ILiveWorldSessionSource _session;
private readonly IMouseLookCursor _cursor;
private readonly IInputMonotonicClock _clock;
private readonly MouseLookState _state;
private bool _lastWantCaptureMouse;
public MouseLookController(
IMouseSource mouseSource,
IPointerPositionSource pointer,
ILocalPlayerModeSource playerMode,
ILocalPlayerControllerSource playerController,
CameraController camera,
ChaseCameraInputState chase,
IMovementInputSource movementInput,
LocalPlayerOutboundController outbound,
ILiveWorldSessionSource session,
IMouseLookCursor cursor,
IInputMonotonicClock clock)
{
_mouseSource = mouseSource ?? throw new ArgumentNullException(nameof(mouseSource));
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_playerController = playerController
?? throw new ArgumentNullException(nameof(playerController));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
_movementInput = movementInput
?? throw new ArgumentNullException(nameof(movementInput));
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
_session = session ?? throw new ArgumentNullException(nameof(session));
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_state = new MouseLookState(ApplyHorizontalAdjustment);
}
public bool Active => _state.Active;
public bool HandlePointerAction(InputAction action, ActivationType activation)
{
if (action == InputAction.AcdreamRmbOrbitHold)
{
if (activation == ActivationType.Press)
_chase.RmbOrbitHeld = _playerMode.IsPlayerMode && _camera.IsChaseMode;
else if (activation == ActivationType.Release)
_chase.RmbOrbitHeld = false;
return true;
}
if (action != InputAction.CameraInstantMouseLook)
return false;
if (activation == ActivationType.Press)
Begin();
else if (activation == ActivationType.Release)
EndAndRestoreCursor();
return true;
}
public void QueueRawDelta(float dx, float dy) => _state.QueueDelta(dx, dy);
public void Tick()
{
bool wantCaptureMouse = _mouseSource.WantCaptureMouse;
if (wantCaptureMouse != _lastWantCaptureMouse)
{
if (wantCaptureMouse && _state.Active)
EndAndRestoreCursor();
_state.OnWantCaptureMouseChanged(wantCaptureMouse);
_lastWantCaptureMouse = wantCaptureMouse;
}
float nowSeconds = _clock.NowSeconds;
if (!_state.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
return;
PlayerMovementController? controller = _playerController.Controller;
if (rawX == 0f && rawY == 0f)
controller?.StopMouseDrift(_movementInput.Capture());
(float filteredX, float filteredY) =
CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is { } retail
? retail.FilterMouseDelta(rawX, rawY, weight: 0.5f, nowSec: nowSeconds)
: (rawX, rawY);
_state.ApplyDelta(filteredX, _chase.Sensitivity);
if (_chase.Retail is { } retailCamera)
retailCamera.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
else
_chase.Legacy?.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
}
public void EndAndRestoreCursor()
{
bool stateWasActive = _state.Active;
_state.Release();
PlayerMovementController? controller = _playerController.Controller;
if (controller is not null && controller.EndMouseLook(_movementInput.Capture()))
{
_outbound.TrySendMovement(
_session.CurrentSession,
controller,
controller.CaptureMovementResult(mouseLookEvent: false));
}
if (stateWasActive || _cursor.HasSavedMode)
_cursor.Restore();
}
public void EndForLifecycle()
{
EndAndRestoreCursor();
_chase.RmbOrbitHeld = false;
}
/// <summary>
/// Session teardown releases presentation capture without emitting a
/// movement packet into the ending session, matching the prior reset edge.
/// </summary>
public void ResetSession()
{
bool stateWasActive = _state.Active;
_state.Release();
_chase.RmbOrbitHeld = false;
_lastWantCaptureMouse = false;
if (stateWasActive || _cursor.HasSavedMode)
_cursor.Restore();
}
private void Begin()
{
PlayerMovementController? controller = _playerController.Controller;
if (!_playerMode.IsPlayerMode
|| !_camera.IsChaseMode
|| controller is not { State: PlayerState.InWorld })
{
return;
}
float nowSeconds = _clock.NowSeconds;
_state.Press(
_pointer.X,
_pointer.Y,
_mouseSource.WantCaptureMouse,
nowSeconds);
if (!_state.Active)
return;
if (!controller.BeginMouseLook(_movementInput.Capture()))
{
_state.Release();
return;
}
_outbound.TrySendMovement(
_session.CurrentSession,
controller,
controller.CaptureMovementResult(mouseLookEvent: false));
_cursor.Hide();
}
private void ApplyHorizontalAdjustment(float adjustment) =>
_playerController.Controller?.SubmitMouseTurnAdjustment(
adjustment,
_movementInput.Capture());
}

View file

@ -0,0 +1,161 @@
using System.Numerics;
using AcDream.Core.Net;
namespace AcDream.App.Input;
internal interface IMovementTruthDiagnosticSink
{
void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte);
void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition);
void ResetSession();
}
internal sealed class MovementTruthDiagnosticController
: IMovementTruthDiagnosticSink
{
private readonly bool _enabled;
private readonly ILocalPlayerControllerSource _player;
private readonly ILocalPlayerIdentitySource _identity;
private MovementTruthOutbound? _lastOutbound;
private readonly record struct MovementTruthOutbound(
string Kind,
uint Sequence,
DateTime TimeUtc,
Vector3 LocalWorldPosition,
Vector3 WirePosition,
uint WireCellId,
bool IsOnGround,
byte ContactByte,
Vector3 Velocity);
public MovementTruthDiagnosticController(
bool enabled,
ILocalPlayerControllerSource player,
ILocalPlayerIdentitySource identity)
{
_enabled = enabled;
_player = player ?? throw new ArgumentNullException(nameof(player));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
}
public void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte)
{
if (!_enabled)
return;
Vector3 velocity = _player.Controller?.BodyVelocity ?? Vector3.Zero;
_lastOutbound = new MovementTruthOutbound(
kind,
sequence,
DateTime.UtcNow,
result.Position,
wirePosition,
wireCellId,
result.IsOnGround,
contactByte,
velocity);
Console.WriteLine(FormattableString.Invariant(
$"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}"));
}
public void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition)
{
if (!_enabled || update.Guid != _identity.ServerGuid)
return;
DateTime now = DateTime.UtcNow;
PlayerMovementController? controller = _player.Controller;
Vector3? localPosition = controller?.Position;
uint? localCellId = controller?.CellId;
Vector3? deltaLocal = localPosition.HasValue
? serverWorldPosition - localPosition.Value
: null;
string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-";
string localCellText = localCellId.HasValue
? FormattableString.Invariant($"0x{localCellId.Value:X8}")
: "-";
string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-";
string deltaLocalLength = deltaLocal.HasValue
? FormattableString.Invariant($"{deltaLocal.Value.Length():F3}")
: "-";
string lastText = "-";
if (_lastOutbound is { } last)
{
Vector3 deltaOut = serverWorldPosition - last.LocalWorldPosition;
double ageMs = (now - last.TimeUtc).TotalMilliseconds;
lastText = FormattableString.Invariant(
$"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}");
}
string state = controller?.State.ToString() ?? "-";
string velocityText = update.Velocity.HasValue
? Fmt(update.Velocity.Value)
: "-";
Console.WriteLine(FormattableString.Invariant(
$"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLength} serverVel={velocityText} state={state} lastOut={lastText}"));
}
public void ResetSession() => _lastOutbound = null;
private static string Fmt(Vector3 value) =>
FormattableString.Invariant(
$"({value.X:F3},{value.Y:F3},{value.Z:F3})");
private static string FmtCmd(uint? command) =>
command.HasValue
? FormattableString.Invariant($"0x{command.Value:X8}")
: "-";
}
internal sealed class DelegateMovementTruthDiagnosticSink
: IMovementTruthDiagnosticSink
{
private readonly Action<string, uint, MovementResult, Vector3, uint, byte>
_outbound;
public DelegateMovementTruthDiagnosticSink(
Action<string, uint, MovementResult, Vector3, uint, byte> outbound) =>
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
public void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte) =>
_outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
public void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition)
{
}
public void ResetSession()
{
}
}

View file

@ -16,7 +16,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
{
private readonly Func<bool> _canPresentPlayer;
private readonly Func<PlayerMovementController?> _getController;
private readonly Func<MovementInput> _captureInput;
private readonly IMovementInputSource _movementInput;
private readonly Func<uint> _resolveLocalEntityId;
private readonly Action _handleTargeting;
private readonly Func<bool> _isHidden;
@ -53,7 +53,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
public RetailLocalPlayerFrameController(
Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
Func<MovementInput> captureInput,
IMovementInputSource movementInput,
Func<uint> resolveLocalEntityId,
Action handleTargeting,
Func<bool> isHidden,
@ -67,8 +67,8 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
?? throw new ArgumentNullException(nameof(canPresentPlayer));
_getController = getController
?? throw new ArgumentNullException(nameof(getController));
_captureInput = captureInput
?? throw new ArgumentNullException(nameof(captureInput));
_movementInput = movementInput
?? throw new ArgumentNullException(nameof(movementInput));
_resolveLocalEntityId = resolveLocalEntityId
?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
_handleTargeting = handleTargeting
@ -134,7 +134,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
MovementResult movement = hidden
? controller.TickHidden(deltaSeconds, _handleTargeting)
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
: controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting);
_project(controller, movement, hidden);
_sendPreNetwork(controller, movement, hidden);

View file

@ -11,18 +11,15 @@ namespace AcDream.App.Input;
/// <see cref="InputDispatcher"/>.
///
/// <para>
/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the
/// constructor takes two delegates so the App.Rendering layer can
/// proxy <c>ImGui.GetIO().WantCaptureMouse</c> via whichever ImGui
/// package is currently active without leaking the type onto the
/// abstraction interface.
/// Production receives a typed capture source, keeping retained-UI and
/// developer-UI ownership outside the Silk adapter. The public delegate
/// overload remains as a compatibility seam for standalone consumers.
/// </para>
/// </summary>
public sealed class SilkMouseSource : IMouseSource
{
private readonly IMouse _mouse;
private readonly Func<bool> _wantCaptureMouse;
private readonly Func<bool> _wantCaptureKeyboard;
private readonly IInputCaptureSource _capture;
private float _lastX;
private float _lastY;
private bool _haveLastPos;
@ -32,10 +29,21 @@ public sealed class SilkMouseSource : IMouseSource
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
/// <summary>Caller-supplied probe for the current modifier mask. Reused
/// from the keyboard source so mouse events carry consistent modifier
/// state.</summary>
public Func<ModifierMask> ModifierProbe { get; set; } = () => ModifierMask.None;
/// <summary>The keyboard source that owns the current modifier mask, so
/// mouse events and keyboard events resolve identical chords.</summary>
public IKeyboardSource? ModifierSource { get; set; }
internal SilkMouseSource(
IMouse mouse,
IInputCaptureSource capture,
IKeyboardSource modifierSource)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
ModifierSource = modifierSource
?? throw new ArgumentNullException(nameof(modifierSource));
Subscribe();
}
public SilkMouseSource(
IMouse mouse,
@ -43,11 +51,16 @@ public sealed class SilkMouseSource : IMouseSource
Func<bool> wantCaptureKeyboard)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse));
_wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
_capture = new DelegateInputCaptureSource(
wantCaptureMouse,
wantCaptureKeyboard);
Subscribe();
}
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe());
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe());
private void Subscribe()
{
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers());
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers());
_mouse.MouseMove += (_, pos) =>
{
float dx, dy;
@ -69,8 +82,11 @@ public sealed class SilkMouseSource : IMouseSource
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
}
private ModifierMask ReadModifiers() =>
ModifierSource?.CurrentModifiers ?? ModifierMask.None;
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
public bool WantCaptureMouse => _wantCaptureMouse();
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
public bool WantCaptureMouse => _capture.WantCaptureMouse;
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
}