Introduces the abstraction without changing user-visible behavior.
Existing keyboard/mouse handlers in GameWindow continue working
unchanged. The new InputDispatcher runs alongside, fires
InputAction events, and a diagnostic Console.WriteLine subscriber
proves the path is observable. K.1b cuts the existing handlers
over; K.1c flips bindings to retail.
New types in src/AcDream.UI.Abstractions/Input/:
- InputAction enum (~110 actions, doc-grouped by retail keymap
category: MovementCommands, ItemSelectionCommands, UICommands,
QuickslotCommands, Chat, Combat, Emotes, Camera, Scroll, Mouse
selection, plus Acdream-specific debug actions for the existing
F-key behaviors)
- KeyChord record struct (Silk.NET.Input.Key + ModifierMask + Device)
- ModifierMask [Flags] enum matching retail keymap bit values
(Shift=0x01, Ctrl=0x02, Alt=0x04, Win=0x08)
- ActivationType enum (Press, Release, Hold, DoubleClick, Analog)
- Binding record (chord -> action -> activation)
- InputScope enum with stack semantics (Always at bottom, Game on
top during normal play; Chat / EditField / Dialog / MeleeCombat /
MissileCombat / MagicCombat / Camera push as transient overlays)
- KeyBindings collection class with Find / ForAction / Add / Remove.
AcdreamCurrentDefaults() factory matches today's hardcoded binds
(W/S/A/D/Z/X movement, Shift run, F-key debug surface) so K.1a
doesn't change behavior. RetailDefaults() is K.1c's job; for now
it returns the same map.
- IKeyboardSource / IMouseSource - test-fakeable interfaces wrapping
Silk.NET. Both surface WantCaptureMouse / WantCaptureKeyboard
flags so the dispatcher can gate per ImGui state.
- InputDispatcher: multicast event Fired<InputAction, ActivationType>;
scope stack with PushScope/PopScope/ActiveScope; per-frame Tick()
fires Hold-type bindings for currently-held chords; mouse buttons
encoded as KeyChord with Device=1.
New adapters in src/AcDream.App/Input/:
- SilkKeyboardSource - Silk.NET IKeyboard wrapper, tracks held state
- SilkMouseSource - Silk.NET IMouse wrapper, proxies ImGui WantCapture
flags for both keyboard and mouse
GameWindow.cs:
- Constructs adapters + dispatcher in OnLoad
- Subscribes to dispatcher.Fired with diagnostic Console.WriteLine
("[input] {action} {activation}") so the path is observable in
launch.log without touching any actual game state
- Calls _inputDispatcher.Tick() per frame in OnUpdate
- Existing IsKeyPressed and event handlers unchanged
Memory crib at memory/project_input_pipeline.md describes the five
layers (Silk events -> Source interfaces -> Dispatcher -> Action
events -> Subscribers) with file paths + scope semantics + the K.1c
retail-defaults plan. Indexed in MEMORY.md.
Two deviations from plan, both documented:
1. InputDispatcher placed in UI.Abstractions/Input/ rather than
App/Input/ - it has no Silk dependencies (uses only the test-
fakeable interfaces) and the test fakes live in
UI.Abstractions.Tests. Mirrors LiveCommandBus precedent. Silk
adapters + GameWindow wiring stay in App.
2. WantCaptureKeyboard moved to IMouseSource alongside WantCaptureMouse
(the dispatcher needs both at the same point).
34 new tests covering KeyChord equality, ModifierMask flags,
KeyBindings lookup, dispatcher chord matching with modifier
mismatch rejection, Hold-type Press/Release transitions, Tick()
firing held bindings, scope stack push/pop with mismatched-pop
throwing, WantCapture* gating.
Solution total: 1118 green (243 Core.Net + 215 UI + 660 Core),
0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
4.1 KiB
C#
106 lines
4.1 KiB
C#
using System.Linq;
|
|
using AcDream.UI.Abstractions.Input;
|
|
using Silk.NET.Input;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Input;
|
|
|
|
public class KeyBindingsTests
|
|
{
|
|
[Fact]
|
|
public void Add_appends_to_All()
|
|
{
|
|
var b = new KeyBindings();
|
|
var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward);
|
|
b.Add(binding);
|
|
Assert.Single(b.All);
|
|
Assert.Equal(binding, b.All[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Remove_drops_binding()
|
|
{
|
|
var b = new KeyBindings();
|
|
var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward);
|
|
b.Add(binding);
|
|
Assert.True(b.Remove(binding));
|
|
Assert.Empty(b.All);
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_empties_all()
|
|
{
|
|
var b = new KeyBindings();
|
|
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
|
b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup));
|
|
b.Clear();
|
|
Assert.Empty(b.All);
|
|
}
|
|
|
|
[Fact]
|
|
public void Find_returns_matching_binding_by_chord_and_activation()
|
|
{
|
|
var b = new KeyBindings();
|
|
var press = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Press);
|
|
var release = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Release);
|
|
b.Add(press);
|
|
b.Add(release);
|
|
|
|
Assert.Equal(press, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press));
|
|
Assert.Equal(release, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Release));
|
|
}
|
|
|
|
[Fact]
|
|
public void Find_returns_null_when_no_match()
|
|
{
|
|
var b = new KeyBindings();
|
|
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
|
Assert.Null(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press));
|
|
}
|
|
|
|
[Fact]
|
|
public void ForAction_returns_all_chords_bound_to_action()
|
|
{
|
|
var b = new KeyBindings();
|
|
// Retail-style: W and Up both → MovementForward.
|
|
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
|
b.Add(new Binding(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward));
|
|
b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup));
|
|
|
|
var forward = b.ForAction(InputAction.MovementForward).ToList();
|
|
Assert.Equal(2, forward.Count);
|
|
Assert.Contains(forward, x => x.Chord.Key == Key.W);
|
|
Assert.Contains(forward, x => x.Chord.Key == Key.Up);
|
|
}
|
|
|
|
[Fact]
|
|
public void AcdreamCurrentDefaults_includes_WASD_movement()
|
|
{
|
|
var b = KeyBindings.AcdreamCurrentDefaults();
|
|
Assert.NotNull(b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press));
|
|
Assert.NotNull(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press));
|
|
Assert.NotNull(b.Find(new KeyChord(Key.A, ModifierMask.None), ActivationType.Press));
|
|
Assert.NotNull(b.Find(new KeyChord(Key.D, ModifierMask.None), ActivationType.Press));
|
|
Assert.NotNull(b.Find(new KeyChord(Key.Z, ModifierMask.None), ActivationType.Press));
|
|
Assert.NotNull(b.Find(new KeyChord(Key.X, ModifierMask.None), ActivationType.Press));
|
|
}
|
|
|
|
[Fact]
|
|
public void AcdreamCurrentDefaults_binds_shift_as_hold_for_run()
|
|
{
|
|
var b = KeyBindings.AcdreamCurrentDefaults();
|
|
var hold = b.Find(new KeyChord(Key.ShiftLeft, ModifierMask.None), ActivationType.Hold);
|
|
Assert.NotNull(hold);
|
|
Assert.Equal(InputAction.MovementRunLock, hold!.Value.Action);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailDefaults_proxies_AcdreamCurrentDefaults_in_K1a()
|
|
{
|
|
// K.1a stub — RetailDefaults() returns the acdream-current binds so
|
|
// K.1b cutover doesn't change behavior. K.1c flips this to the
|
|
// retail preset.
|
|
var retail = KeyBindings.RetailDefaults();
|
|
var current = KeyBindings.AcdreamCurrentDefaults();
|
|
Assert.Equal(current.All.Count, retail.All.Count);
|
|
}
|
|
}
|