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
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// K.1b integration: drive the <see cref="InputDispatcher"/> via the
|
||||
/// public API a fake keyboard would, build a <see cref="MovementInput"/>
|
||||
/// from <c>IsActionHeld</c> queries (the new K.1b code path), and confirm
|
||||
/// <see cref="PlayerMovementController.Update"/> produces the same result
|
||||
/// as feeding it the equivalent <c>MovementInput</c> directly. This is
|
||||
/// the regression-prevention test for the "preserve the boundary" rule
|
||||
/// in the K.1b plan: <c>MovementInput</c> stays as the contract; only the
|
||||
/// SOURCE of input changes.
|
||||
/// </summary>
|
||||
public class DispatcherToMovementIntegrationTests
|
||||
{
|
||||
/// <summary>Test double — the same fake the UI.Abstractions tests use,
|
||||
/// duplicated here because it's <c>internal</c> in that assembly.</summary>
|
||||
private sealed class FakeKb : IKeyboardSource
|
||||
{
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
private readonly System.Collections.Generic.HashSet<Key> _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<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? 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<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(), 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: <see cref="InputDispatcher.IsActionHeld"/> per-frame held-key
|
||||
/// polling. Movement (WASD/Shift/Space) needs "is W currently held this
|
||||
/// frame" — that's IsActionHeld. The dispatcher's <c>Fired</c> events
|
||||
/// drive press/release transitions; <c>IsActionHeld</c> drives the
|
||||
/// per-frame <c>MovementInput</c> struct.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue