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:
Erik 2026-04-25 23:43:11 +02:00
parent 84512d3c64
commit 256e9624bd
8 changed files with 887 additions and 334 deletions

View file

@ -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