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
|
|
@ -379,9 +379,16 @@ public sealed class GameWindow : IDisposable
|
|||
private uint? _playerCurrentAnimCommand;
|
||||
private float _playerCurrentAnimSpeed = 1f;
|
||||
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
|
||||
// Accumulated mouse X delta for player turning; written in mouse-move
|
||||
// callback, consumed + reset in OnUpdate each frame.
|
||||
// K.1b: this field is RESERVED — written when entering / leaving player
|
||||
// mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now
|
||||
// never consumed by MovementInput (mouse never drives character yaw —
|
||||
// K.1b regression-prevention). Kept around as plumbing for the future
|
||||
// K.2 MMB-mouse-look path which will re-enable mouse → character-yaw
|
||||
// when MMB is held. The pragma silences the dead-write warning until K.2
|
||||
// wires the read-side back in.
|
||||
#pragma warning disable CS0414 // assigned but never used — see comment above
|
||||
private float _playerMouseDeltaX;
|
||||
#pragma warning restore CS0414
|
||||
|
||||
// Mouse sensitivity multipliers — one per camera mode because the visual
|
||||
// feel is very different. Adjust via F8 / F9 for whichever mode is
|
||||
|
|
@ -396,10 +403,12 @@ public sealed class GameWindow : IDisposable
|
|||
// the orbited position (no snap back).
|
||||
private bool _rmbHeld;
|
||||
|
||||
// Phase K.1a — input architecture skeleton. Lives ALONGSIDE the
|
||||
// existing IsKeyPressed + KeyDown handlers; nothing subscribes to
|
||||
// dispatcher.Fired except a diagnostic logger. K.1b cuts the legacy
|
||||
// paths over.
|
||||
// Phase K.1b — single input path. Every keyboard/mouse-button reaction
|
||||
// flows through InputDispatcher.Fired (see OnInputAction below) or
|
||||
// IsActionHeld (per-frame polling for movement). The legacy direct
|
||||
// kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only
|
||||
// mouse.MouseMove survives as a direct handler because mouse-delta is
|
||||
// axis input, not chord input.
|
||||
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
|
||||
private AcDream.App.Input.SilkMouseSource? _mouseSource;
|
||||
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
|
||||
|
|
@ -500,233 +509,50 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_gl = GL.GetApi(_window!);
|
||||
_input = _window!.CreateInput();
|
||||
foreach (var kb in _input.Keyboards)
|
||||
kb.KeyDown += (_, key, _) =>
|
||||
{
|
||||
if (key == Key.F)
|
||||
_cameraController?.ToggleFly();
|
||||
else if (key == Key.F3)
|
||||
{
|
||||
// Dump player position + ALL entities (visible rendered) + shadow objs within 15m.
|
||||
// Lets us see visible-entity-without-collision gaps.
|
||||
System.Numerics.Vector3 pos;
|
||||
if (_playerMode && _playerController is not null)
|
||||
pos = _playerController.Position;
|
||||
else
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
|
||||
pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
|
||||
}
|
||||
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
|
||||
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
|
||||
Console.WriteLine(
|
||||
$"=== F3 DEBUG DUMP ===\n" +
|
||||
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
|
||||
$" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" +
|
||||
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
|
||||
|
||||
// Collect VISIBLE entities within 15m (from GpuWorldState)
|
||||
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
|
||||
foreach (var e in _worldState.Entities)
|
||||
{
|
||||
float dx = e.Position.X - pos.X;
|
||||
float dy = e.Position.Y - pos.Y;
|
||||
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
|
||||
}
|
||||
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
|
||||
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
|
||||
{
|
||||
float d = (e.Position - pos).Length();
|
||||
Console.WriteLine(
|
||||
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
|
||||
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
|
||||
}
|
||||
|
||||
// Collect shadow objects within 15m
|
||||
var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
|
||||
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
|
||||
{
|
||||
float dx = o.Position.X - pos.X;
|
||||
float dy = o.Position.Y - pos.Y;
|
||||
float d = MathF.Sqrt(dx * dx + dy * dy);
|
||||
if (d < 15f) sorted.Add((o, d));
|
||||
}
|
||||
sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
|
||||
Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
|
||||
foreach (var (o, d) in sorted.Take(12))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
|
||||
$"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
|
||||
}
|
||||
}
|
||||
else if (key == Key.F1)
|
||||
{
|
||||
// Phase I.2: F1 now toggles the entire ImGui DebugPanel
|
||||
// visibility. The old per-section toggles (F4/F5/F6) are
|
||||
// gone — sections are collapsing headers inside the
|
||||
// single window now.
|
||||
foreach (var panel in EnumerateDebugPanel())
|
||||
{
|
||||
panel.IsVisible = !panel.IsVisible;
|
||||
_debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}");
|
||||
}
|
||||
}
|
||||
else if (key == Key.F2)
|
||||
{
|
||||
// Real gameplay toggle — keeps the F2 keybind. Same
|
||||
// action is wired into the DebugPanel's
|
||||
// "Toggle collision wires" button via DebugVM.
|
||||
ToggleCollisionWires();
|
||||
}
|
||||
else if (key == Key.F7)
|
||||
{
|
||||
// Phase I.2: keep F7 as a hotkey alias for the
|
||||
// DebugPanel's "Cycle time of day" button.
|
||||
CycleTimeOfDay();
|
||||
}
|
||||
else if (key == Key.F10)
|
||||
{
|
||||
// Phase I.2: keep F10 as a hotkey alias for the
|
||||
// DebugPanel's "Cycle weather" button.
|
||||
CycleWeather();
|
||||
}
|
||||
else if (key == Key.F8 || key == Key.F9)
|
||||
{
|
||||
// Adjust whichever mode's sensitivity is currently active.
|
||||
// Multiplicative step (1.2x / /1.2x) so low values stay fine
|
||||
// grained and high values move in proportional chunks.
|
||||
string modeLabel;
|
||||
float current;
|
||||
if (_playerMode && _cameraController?.IsChaseMode == true)
|
||||
{ modeLabel = "Chase"; current = _sensChase; }
|
||||
else if (_cameraController?.IsFlyMode == true)
|
||||
{ modeLabel = "Fly"; current = _sensFly; }
|
||||
else
|
||||
{ modeLabel = "Orbit"; current = _sensOrbit; }
|
||||
|
||||
float next = (key == Key.F9) ? current * 1.2f : current / 1.2f;
|
||||
next = MathF.Min(3.0f, MathF.Max(0.005f, next));
|
||||
|
||||
if (modeLabel == "Chase") _sensChase = next;
|
||||
else if (modeLabel == "Fly") _sensFly = next;
|
||||
else _sensOrbit = next;
|
||||
|
||||
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
|
||||
}
|
||||
else if (key == Key.Escape)
|
||||
{
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
else if (_playerMode)
|
||||
{
|
||||
// Exit player mode on Escape too.
|
||||
_playerMode = false;
|
||||
_cameraController?.ExitChaseMode();
|
||||
_playerController = null;
|
||||
_chaseCamera = null;
|
||||
_playerCurrentAnimCommand = null;
|
||||
}
|
||||
else
|
||||
_window!.Close();
|
||||
}
|
||||
// Phase B.2: Tab toggles between fly and player-movement mode.
|
||||
// Only active when a live session is in-world and we have a
|
||||
// player entity spawned on screen.
|
||||
else if (key == Key.Tab && _liveSession is not null
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
{
|
||||
_playerMode = !_playerMode;
|
||||
if (_playerMode)
|
||||
{
|
||||
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
||||
{
|
||||
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
|
||||
// Read the real step height from the player's Setup dat.
|
||||
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
|
||||
if (playerSetup is not null)
|
||||
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
|
||||
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
|
||||
? playerSetup.StepUpHeight
|
||||
: 2f; // default human step height
|
||||
}
|
||||
else
|
||||
{
|
||||
_playerController.StepUpHeight = 2f; // default human step height
|
||||
}
|
||||
// Derive initial cell ID from the entity's world position.
|
||||
int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
|
||||
int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);
|
||||
float plocalX = playerEntity.Position.X - (plbX - _liveCenterX) * 192f;
|
||||
float plocalY = playerEntity.Position.Y - (plbY - _liveCenterY) * 192f;
|
||||
uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u;
|
||||
// Resolve the initial position through the physics engine to
|
||||
// get the correct terrain Z. The server-sent Z may be stale
|
||||
// from a previous ACE relocation. With indoor transitions
|
||||
// disabled, Resolve will always snap to outdoor terrain Z.
|
||||
var initResult = _physicsEngine.Resolve(
|
||||
playerEntity.Position, pinitCellId & 0xFFFFu,
|
||||
System.Numerics.Vector3.Zero, 100f); // huge step height for initial snap
|
||||
_playerController.SetPosition(initResult.Position, initResult.CellId);
|
||||
|
||||
// Option B (r03 §1.3): wire the player's sequencer current
|
||||
// cycle velocity into MotionInterpreter.get_state_velocity so
|
||||
// body physics uses MotionData.Velocity * speedMod instead of
|
||||
// the hardcoded RunAnimSpeed/WalkAnimSpeed. Keeps legs-per-meter
|
||||
// invariant regardless of which MotionTable drives the player
|
||||
// (Humanoid RunForward happens to bake Velocity=4.0 so the old
|
||||
// path looked right there, but the decompiled constant is a
|
||||
// MotionTable property, not a global).
|
||||
if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE)
|
||||
&& playerAE.Sequencer is { } playerSeq)
|
||||
{
|
||||
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
|
||||
}
|
||||
|
||||
// Derive initial yaw from the entity's rotation.
|
||||
// The render loop stores rotation as Yaw - PI/2 (to
|
||||
// compensate for AC models facing +Y at identity), so
|
||||
// we add PI/2 back when extracting to get the real yaw.
|
||||
var q = playerEntity.Rotation;
|
||||
float rawYaw = MathF.Atan2(
|
||||
2f * (q.W * q.Z + q.X * q.Y),
|
||||
1f - 2f * (q.Y * q.Y + q.Z * q.Z));
|
||||
_playerController.Yaw = rawYaw + MathF.PI / 2f;
|
||||
|
||||
_chaseCamera = new AcDream.App.Rendering.ChaseCamera
|
||||
{
|
||||
Aspect = _window!.Size.X / (float)_window.Size.Y,
|
||||
};
|
||||
_playerMouseDeltaX = 0f;
|
||||
_cameraController?.EnterChaseMode(_chaseCamera);
|
||||
// ModeChanged event fires from EnterChaseMode → OnCameraModeChanged
|
||||
// captures mouse in raw mode automatically.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Player entity not yet spawned — revert.
|
||||
_playerMode = false;
|
||||
Console.WriteLine($"live: Tab pressed but player entity 0x{_playerServerGuid:X8} not found yet");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_cameraController?.ExitChaseMode();
|
||||
_playerController = null;
|
||||
_chaseCamera = null;
|
||||
_playerCurrentAnimCommand = null;
|
||||
_playerMouseDeltaX = 0f;
|
||||
// ExitChaseMode fires ModeChanged → OnCameraModeChanged(true=fly) → raw mode stays ON
|
||||
}
|
||||
}
|
||||
};
|
||||
// Phase K.1b — every keyboard/mouse handler routes through the
|
||||
// InputDispatcher. The legacy direct kb.KeyDown / mouse.MouseDown
|
||||
// switches are gone; subscribers below own all game-side reactions.
|
||||
// We KEEP a direct mouse.MouseMove handler because mouse-delta is
|
||||
// axis input, not chord input — but with explicit WantCaptureMouse
|
||||
// gating and the previous "_playerMouseDeltaX +=" line dropped so
|
||||
// mouse delta NEVER drives character yaw (regression-prevention
|
||||
// per K.1b plan §D).
|
||||
var firstKb = _input.Keyboards.FirstOrDefault();
|
||||
var firstMouse = _input.Mice.FirstOrDefault();
|
||||
if (firstKb is not null && firstMouse is not null)
|
||||
{
|
||||
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
||||
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
||||
firstMouse,
|
||||
wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse,
|
||||
wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
|
||||
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
|
||||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||
_kbSource, _mouseSource, _keyBindings);
|
||||
_inputDispatcher.Fired += OnInputAction;
|
||||
}
|
||||
|
||||
// Mouse delta handler — kept direct because Silk.NET delivers mouse
|
||||
// moves as continuous (x, y) positions, not chord events. We gate
|
||||
// on WantCaptureMouse so panel hover never drives camera. The
|
||||
// previous "_playerMouseDeltaX += dx * sens" branch is GONE — mouse
|
||||
// never drives character yaw in K.1b. RMB-held orbit is wired via
|
||||
// the dispatcher's AcdreamRmbOrbitHold action (subscriber sets
|
||||
// _rmbHeld below).
|
||||
foreach (var mouse in _input.Mice)
|
||||
{
|
||||
mouse.MouseMove += (m, pos) =>
|
||||
{
|
||||
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
|
||||
// surviving direct-mouse handler. Suppresses RMB orbit /
|
||||
// FlyCamera look while ImGui has the mouse focus.
|
||||
if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|
||||
{
|
||||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
return;
|
||||
}
|
||||
if (_cameraController is null) return;
|
||||
|
||||
float dx = pos.X - _lastMouseX;
|
||||
|
|
@ -740,13 +566,18 @@ public sealed class GameWindow : IDisposable
|
|||
// Hold-RMB orbit: player stays the central point, camera
|
||||
// free-orbits around. X rotates around, Y pitches. On release
|
||||
// the camera STAYS at the new angle (no snap back).
|
||||
// K.1b: this is the ONLY mouse-delta path that affects
|
||||
// ANYTHING when in player mode — character yaw is
|
||||
// dispatcher-only (A/D keys). MMB mouse-look comes
|
||||
// back as hardcoded behavior in K.2.
|
||||
_chaseCamera.YawOffset -= dx * 0.004f * sens;
|
||||
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normal chase: X turns the character, Y pitches camera.
|
||||
_playerMouseDeltaX += dx * sens;
|
||||
// Without RMB held, mouse only pitches the chase
|
||||
// camera (Y-axis). Mouse X is dropped — character
|
||||
// turning is keyboard-only in K.1b.
|
||||
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
|
||||
}
|
||||
}
|
||||
|
|
@ -757,8 +588,12 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
else
|
||||
{
|
||||
// Orbit-camera mode (offline / pre-login): hold LMB to
|
||||
// drag-rotate. K.1b reads the mouse-button state via
|
||||
// IMouseSource so all "is button held" queries go
|
||||
// through the same abstraction the dispatcher uses.
|
||||
float sens = _sensOrbit;
|
||||
if (m.IsButtonPressed(MouseButton.Left))
|
||||
if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left))
|
||||
{
|
||||
_cameraController.Orbit.Yaw -= dx * 0.005f * sens;
|
||||
_cameraController.Orbit.Pitch = Math.Clamp(
|
||||
|
|
@ -769,69 +604,6 @@ public sealed class GameWindow : IDisposable
|
|||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
};
|
||||
|
||||
mouse.MouseDown += (_, btn) =>
|
||||
{
|
||||
if (btn == MouseButton.Right && _playerMode
|
||||
&& _cameraController?.IsChaseMode == true)
|
||||
{
|
||||
_rmbHeld = true;
|
||||
}
|
||||
};
|
||||
|
||||
mouse.MouseUp += (_, btn) =>
|
||||
{
|
||||
if (btn == MouseButton.Right)
|
||||
{
|
||||
// Camera stays at the orbited position — no snap back.
|
||||
_rmbHeld = false;
|
||||
}
|
||||
};
|
||||
|
||||
mouse.Scroll += (_, scroll) =>
|
||||
{
|
||||
if (_cameraController is null) return;
|
||||
|
||||
// Chase mode: mouse wheel zooms (adjusts camera distance).
|
||||
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
|
||||
{
|
||||
_chaseCamera.AdjustDistance(-scroll.Y * 0.8f);
|
||||
}
|
||||
// Fly mode: no scroll action (could adjust move speed later).
|
||||
else if (_cameraController.IsFlyMode)
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
// Orbit mode: wheel zooms the orbit camera.
|
||||
else
|
||||
{
|
||||
_cameraController.Orbit.Distance = Math.Clamp(
|
||||
_cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Phase K.1a — input dispatcher skeleton wired ALONGSIDE the
|
||||
// existing IsKeyPressed + KeyDown handlers above. The dispatcher
|
||||
// observes the same Silk.NET keyboard/mouse and fires high-level
|
||||
// InputAction events; in K.1a nothing actually drives behavior
|
||||
// off the dispatcher except a diagnostic Console.WriteLine, so
|
||||
// this is a no-op for the player. K.1b cuts the legacy paths
|
||||
// over to subscribe to dispatcher.Fired.
|
||||
var firstKb = _input.Keyboards.FirstOrDefault();
|
||||
var firstMouse = _input.Mice.FirstOrDefault();
|
||||
if (firstKb is not null && firstMouse is not null)
|
||||
{
|
||||
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
||||
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
||||
firstMouse,
|
||||
wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse,
|
||||
wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
|
||||
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
|
||||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||
_kbSource, _mouseSource, _keyBindings);
|
||||
_inputDispatcher.Fired += (action, activation) =>
|
||||
Console.WriteLine($"[input] {action} {activation}");
|
||||
}
|
||||
|
||||
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
||||
|
|
@ -3823,44 +3595,57 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
if (_cameraController is null || _input is null) return;
|
||||
|
||||
var kb = _input.Keyboards[0];
|
||||
|
||||
// Phase D.2a — suppress game-side WASD / interaction polling when
|
||||
// ImGui has keyboard focus (e.g. a text field is active). Without
|
||||
// this, typing "walk" into a chat field would actually walk.
|
||||
// Phase D.2a / K.1b — suppress game-side input polling when ImGui
|
||||
// has keyboard focus (e.g. a text field is active). The InputDispatcher
|
||||
// already gates KeyDown/MouseDown via WantCaptureKeyboard internally;
|
||||
// this guard adds defense-in-depth for the per-frame IsActionHeld
|
||||
// movement poll below (typing "walk" into a chat field shouldn't
|
||||
// walk).
|
||||
bool suppressGameInput =
|
||||
DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
|
||||
if (suppressGameInput) return;
|
||||
|
||||
if (_cameraController.IsFlyMode)
|
||||
{
|
||||
// K.1b: fly-camera input flows through the dispatcher. Reuses
|
||||
// movement actions (Forward/Backup/TurnLeft/TurnRight/Jump/
|
||||
// RunLock) — in fly mode "TurnLeft" semantically maps to
|
||||
// strafe-left because A/D in fly is strafe, not turn (mouse
|
||||
// delta drives fly heading instead).
|
||||
if (_inputDispatcher is null) return;
|
||||
_cameraController.Fly.Update(
|
||||
dt,
|
||||
w: kb.IsKeyPressed(Key.W),
|
||||
a: kb.IsKeyPressed(Key.A),
|
||||
s: kb.IsKeyPressed(Key.S),
|
||||
d: kb.IsKeyPressed(Key.D),
|
||||
up: kb.IsKeyPressed(Key.Space),
|
||||
down: kb.IsKeyPressed(Key.ControlLeft),
|
||||
boost: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight));
|
||||
w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
|
||||
a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
|
||||
s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
|
||||
d: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight),
|
||||
up: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump),
|
||||
down: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.AcdreamFlyDown),
|
||||
boost: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock));
|
||||
}
|
||||
else if (_playerMode && _playerController is not null && _chaseCamera is not null)
|
||||
{
|
||||
// Phase B.2: player movement mode — WASD walks/runs, A/D turns,
|
||||
// Z/X strafes, Shift runs, mouse X turns, mouse Y pitches camera.
|
||||
float consumedMouseDeltaX = _playerMouseDeltaX;
|
||||
_playerMouseDeltaX = 0f; // consume + reset so it doesn't accumulate
|
||||
// Phase B.2 / K.1b: player movement mode — every input flows
|
||||
// through the dispatcher. WASD walks/runs, A/D turns, Z/X
|
||||
// strafes, Shift runs, Space jumps. Mouse delta NEVER drives
|
||||
// character yaw (regression-prevention per K.1b plan §D);
|
||||
// MouseDeltaX is hardcoded 0f. RMB held still pans the chase
|
||||
// camera (handled in the mouse-move handler via _rmbHeld).
|
||||
// The _playerMouseDeltaX field is preserved as plumbing for the
|
||||
// future MMB-mouse-look behavior coming back in K.2.
|
||||
if (_inputDispatcher is null) return;
|
||||
_playerMouseDeltaX = 0f; // defensive: ensure no leakage even if some path writes it
|
||||
|
||||
var input = new AcDream.App.Input.MovementInput(
|
||||
Forward: kb.IsKeyPressed(Key.W),
|
||||
Backward: kb.IsKeyPressed(Key.S),
|
||||
StrafeLeft: kb.IsKeyPressed(Key.Z),
|
||||
StrafeRight: kb.IsKeyPressed(Key.X),
|
||||
TurnLeft: kb.IsKeyPressed(Key.A),
|
||||
TurnRight: kb.IsKeyPressed(Key.D),
|
||||
Run: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight),
|
||||
MouseDeltaX: consumedMouseDeltaX,
|
||||
Jump: kb.IsKeyPressed(Key.Space));
|
||||
Forward: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
|
||||
Backward: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
|
||||
StrafeLeft: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft),
|
||||
StrafeRight: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight),
|
||||
TurnLeft: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
|
||||
TurnRight: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight),
|
||||
Run: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock),
|
||||
MouseDeltaX: 0f, // K.1b: mouse never drives character yaw
|
||||
Jump: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump));
|
||||
|
||||
var result = _playerController.Update((float)dt, input);
|
||||
|
||||
|
|
@ -5139,6 +4924,307 @@ public sealed class GameWindow : IDisposable
|
|||
// in the DevToolsEnabled construction block above; null otherwise.
|
||||
private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel;
|
||||
|
||||
// ── K.1b: dispatcher action handler ──────────────────────────────────
|
||||
//
|
||||
// SINGLE place where every game-side keyboard/mouse-button reaction
|
||||
// lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp
|
||||
// handlers are gone; everything now flows through InputDispatcher.Fired
|
||||
// → here. New behaviors register a new InputAction in the enum + a
|
||||
// case in this switch + a binding in KeyBindings.
|
||||
|
||||
/// <summary>
|
||||
/// K.1b — multicast subscriber on <see cref="InputDispatcher.Fired"/>.
|
||||
/// Handles every game-side reaction to a keyboard/mouse-button chord.
|
||||
/// Per-frame held-state polling (movement WASD/Shift/Space) lives in
|
||||
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
|
||||
/// this method handles transitional Press/Release events only.
|
||||
/// </summary>
|
||||
private void OnInputAction(
|
||||
AcDream.UI.Abstractions.Input.InputAction action,
|
||||
AcDream.UI.Abstractions.Input.ActivationType activation)
|
||||
{
|
||||
// Diagnostic — kept from K.1a; helpful for K.1c verification.
|
||||
Console.WriteLine($"[input] {action} {activation}");
|
||||
|
||||
// RMB-orbit hold: track press/release transitions explicitly so
|
||||
// _rmbHeld is true exactly while the chord is held. Hold-type
|
||||
// chords also fire Press on key-down + Release on key-up; we
|
||||
// ignore the in-between Hold ticks here (the mouse-move handler
|
||||
// checks _rmbHeld each frame anyway).
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.AcdreamRmbOrbitHold)
|
||||
{
|
||||
if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press)
|
||||
_rmbHeld = _playerMode && _cameraController?.IsChaseMode == true;
|
||||
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
|
||||
_rmbHeld = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every
|
||||
// wheel tick. Press is the only activation type for wheel.
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp
|
||||
|| action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown)
|
||||
{
|
||||
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return;
|
||||
HandleScrollAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every other action fires on Press only (no Release / Hold side-
|
||||
// effects in the K.1b set). Filter out non-Press activations early
|
||||
// so subscribers that have Release-mode bindings don't accidentally
|
||||
// re-fire.
|
||||
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return;
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
|
||||
foreach (var panel in EnumerateDebugPanel())
|
||||
{
|
||||
panel.IsVisible = !panel.IsVisible;
|
||||
_debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}");
|
||||
}
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires:
|
||||
ToggleCollisionWires();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby:
|
||||
DumpPlayerAndNearbyEntities();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay:
|
||||
CycleTimeOfDay();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown:
|
||||
AdjustActiveSensitivity(1f / 1.2f);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp:
|
||||
AdjustActiveSensitivity(1.2f);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather:
|
||||
CycleWeather();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
|
||||
_cameraController?.ToggleFly();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
|
||||
TogglePlayerMode();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
else if (_playerMode)
|
||||
{
|
||||
_playerMode = false;
|
||||
_cameraController?.ExitChaseMode();
|
||||
_playerController = null;
|
||||
_chaseCamera = null;
|
||||
_playerCurrentAnimCommand = null;
|
||||
}
|
||||
else
|
||||
_window!.Close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: Tab handler extracted into a method so the dispatcher
|
||||
/// subscriber can call it. Same body as the previous Tab branch in
|
||||
/// the legacy direct keyboard handler — toggle player↔fly mode, set
|
||||
/// up <see cref="PlayerMovementController"/> + <see cref="ChaseCamera"/>
|
||||
/// when entering player mode, tear them down on exit.
|
||||
/// </summary>
|
||||
private void TogglePlayerMode()
|
||||
{
|
||||
// Phase B.2 guard: only active when a live session is in-world.
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
|
||||
_playerMode = !_playerMode;
|
||||
if (_playerMode)
|
||||
{
|
||||
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
||||
{
|
||||
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
|
||||
// Read the real step height from the player's Setup dat.
|
||||
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
|
||||
if (playerSetup is not null)
|
||||
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
|
||||
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
|
||||
? playerSetup.StepUpHeight
|
||||
: 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
_playerController.StepUpHeight = 2f;
|
||||
}
|
||||
int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
|
||||
int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);
|
||||
uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u;
|
||||
var initResult = _physicsEngine.Resolve(
|
||||
playerEntity.Position, pinitCellId & 0xFFFFu,
|
||||
System.Numerics.Vector3.Zero, 100f);
|
||||
_playerController.SetPosition(initResult.Position, initResult.CellId);
|
||||
|
||||
if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE)
|
||||
&& playerAE.Sequencer is { } playerSeq)
|
||||
{
|
||||
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
|
||||
}
|
||||
|
||||
var q = playerEntity.Rotation;
|
||||
float rawYaw = MathF.Atan2(
|
||||
2f * (q.W * q.Z + q.X * q.Y),
|
||||
1f - 2f * (q.Y * q.Y + q.Z * q.Z));
|
||||
_playerController.Yaw = rawYaw + MathF.PI / 2f;
|
||||
|
||||
_chaseCamera = new AcDream.App.Rendering.ChaseCamera
|
||||
{
|
||||
Aspect = _window!.Size.X / (float)_window.Size.Y,
|
||||
};
|
||||
// K.1b: _playerMouseDeltaX is no longer consumed by
|
||||
// MovementInput, but we still reset it here so any stale
|
||||
// accumulated value from a previous session doesn't leak
|
||||
// into a future code path that re-enables mouse-yaw.
|
||||
_playerMouseDeltaX = 0f;
|
||||
_cameraController?.EnterChaseMode(_chaseCamera);
|
||||
}
|
||||
else
|
||||
{
|
||||
_playerMode = false;
|
||||
Console.WriteLine($"live: Tab pressed but player entity 0x{_playerServerGuid:X8} not found yet");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_cameraController?.ExitChaseMode();
|
||||
_playerController = null;
|
||||
_chaseCamera = null;
|
||||
_playerCurrentAnimCommand = null;
|
||||
_playerMouseDeltaX = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
|
||||
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
|
||||
/// given factor and clamps to [0.005, 3.0].
|
||||
/// </summary>
|
||||
private void AdjustActiveSensitivity(float factor)
|
||||
{
|
||||
string modeLabel;
|
||||
float current;
|
||||
if (_playerMode && _cameraController?.IsChaseMode == true)
|
||||
{ modeLabel = "Chase"; current = _sensChase; }
|
||||
else if (_cameraController?.IsFlyMode == true)
|
||||
{ modeLabel = "Fly"; current = _sensFly; }
|
||||
else
|
||||
{ modeLabel = "Orbit"; current = _sensOrbit; }
|
||||
|
||||
float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor));
|
||||
if (modeLabel == "Chase") _sensChase = next;
|
||||
else if (modeLabel == "Fly") _sensFly = next;
|
||||
else _sensOrbit = next;
|
||||
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: F3 dump handler extracted into a method. Same body as the
|
||||
/// previous in-line F3 branch — prints the player's position +
|
||||
/// nearby visible entities + nearby shadow physics objects.
|
||||
/// </summary>
|
||||
private void DumpPlayerAndNearbyEntities()
|
||||
{
|
||||
System.Numerics.Vector3 pos;
|
||||
if (_playerMode && _playerController is not null)
|
||||
pos = _playerController.Position;
|
||||
else
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
|
||||
pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
|
||||
}
|
||||
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
|
||||
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
|
||||
Console.WriteLine(
|
||||
$"=== F3 DEBUG DUMP ===\n" +
|
||||
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
|
||||
$" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" +
|
||||
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
|
||||
|
||||
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
|
||||
foreach (var e in _worldState.Entities)
|
||||
{
|
||||
float dx = e.Position.X - pos.X;
|
||||
float dy = e.Position.Y - pos.Y;
|
||||
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
|
||||
}
|
||||
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
|
||||
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
|
||||
{
|
||||
float d = (e.Position - pos).Length();
|
||||
Console.WriteLine(
|
||||
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
|
||||
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
|
||||
}
|
||||
|
||||
var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
|
||||
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
|
||||
{
|
||||
float dx = o.Position.X - pos.X;
|
||||
float dy = o.Position.Y - pos.Y;
|
||||
float d = MathF.Sqrt(dx * dx + dy * dy);
|
||||
if (d < 15f) sorted.Add((o, d));
|
||||
}
|
||||
sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
|
||||
Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
|
||||
foreach (var (o, d) in sorted.Take(12))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
|
||||
$"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever
|
||||
/// camera distance is current — chase camera distance in player mode,
|
||||
/// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude
|
||||
/// is fixed-step (the previous proportional scroll.Y was lost when we
|
||||
/// moved scroll into the dispatcher, but the discrete step matches
|
||||
/// retail wheel feel).
|
||||
/// </summary>
|
||||
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
|
||||
{
|
||||
if (_cameraController is null) return;
|
||||
float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f;
|
||||
|
||||
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
|
||||
{
|
||||
// Chase mode: zoom (closer on ScrollUp).
|
||||
_chaseCamera.AdjustDistance(-dir * 0.8f);
|
||||
}
|
||||
else if (_cameraController.IsFlyMode)
|
||||
{
|
||||
// Fly mode: no-op (could adjust move speed later).
|
||||
}
|
||||
else
|
||||
{
|
||||
_cameraController.Orbit.Distance = Math.Clamp(
|
||||
_cameraController.Orbit.Distance - dir * 20f, 50f, 2000f);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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