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) <noreply@anthropic.com>
This commit is contained in:
parent
84512d3c64
commit
256e9624bd
8 changed files with 887 additions and 334 deletions
|
|
@ -256,4 +256,10 @@ public enum InputAction
|
|||
AcdreamToggleFlyMode,
|
||||
/// <summary>Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c).</summary>
|
||||
AcdreamTogglePlayerMode,
|
||||
/// <summary>Hold-RMB chase-camera orbit (debug-only, not user-rebindable).
|
||||
/// Camera orbits around the player while held; never drives character yaw.</summary>
|
||||
AcdreamRmbOrbitHold,
|
||||
/// <summary>Fly-camera descend (Ctrl) — only meaningful while fly camera
|
||||
/// is active. K.1b binds it to ControlLeft; K.1c may rebind.</summary>
|
||||
AcdreamFlyDown,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,71 @@ public sealed class InputDispatcher
|
|||
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
|
||||
public InputScope ActiveScope => _scopes.Peek();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// Used by per-frame movement polling (<c>MovementInput.Forward</c>
|
||||
/// etc.) which needs the held state right now, NOT just a press
|
||||
/// transition. The <see cref="Fired"/> event drives press/release/hold
|
||||
/// transitions; <see cref="IsActionHeld"/> drives steady-state polling.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="InputAction.None"/> always returns false — there are no
|
||||
/// bindings for it, and we don't want it to alias the "any unbound"
|
||||
/// default to "always held".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Inverse of <see cref="MouseButtonToKey"/>: decode a chord
|
||||
/// key back to the original <see cref="MouseButton"/>. Returns null
|
||||
/// for keys that don't correspond to a mouse button.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Push a scope onto the active stack. Top wins.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue