feat(input): #24 Phase K.2 - auto-enter player mode at login + MMB mouse-look + DebugPanel free-fly + Tab to chat-input focus

Five changes:

1. PlayerModeAutoEntry — testable guard class that fires once after
   EnterWorld + WorldSession.State.InWorld + player entity present +
   PlayerController.State == InWorld. GameWindow arms the entry
   after EnterWorld; per-frame Tick checks all four guards and
   invokes the same fly-to-player transition the Tab handler runs.
   User-initiated fly toggle (DebugPanel button) Cancel()s pending
   entry. Skip in offline mode (no ACDREAM_LIVE) — Holtburg orbit
   stays default for testing.

2. MouseLookState + KeyBindings.RetailDefaults() binds MMB Hold to
   InputAction.CameraInstantMouseLook. GameWindow subscribes:
   - Press: hide cursor, capture position, _mouseLookActive = true.
   - Release: restore cursor, deactivate.
   - WantCaptureMouse=true while held → suspend (release cursor).
   - MouseMove while active: combined drive — chase camera yaw +
     character heading move together (retail's signature mouse-look
     behavior). Camera Y still pitches camera-only.

3. DebugPanel "Toggle Free-Fly Mode" button via DebugVM.ToggleFlyMode
   action delegate — replaces the F-key as the primary discovery
   path for free-fly. Gated on DevToolsEnabled.

4. ChatPanel.FocusInput() one-shot + IPanelRenderer.SetKeyboardFocusHere
   primitive. GameWindow's ToggleChatEntry (Tab) subscriber calls
   _chatPanel.FocusInput() so Tab moves focus to the chat input
   field. Replaces the K.1c TODO stub.

5. WantCaptureMouse gating reinforcement on surviving mouse handlers
   (no new code; verified intact from K.1b).

21 new tests (8 PlayerModeAutoEntry, 10 MouseLookState, 3 ChatPanel
focus). 1183 total green. 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 09:20:17 +02:00
parent da189103b8
commit af74eac0c2
12 changed files with 972 additions and 66 deletions

View file

@ -403,6 +403,30 @@ public sealed class GameWindow : IDisposable
// the orbited position (no snap back).
private bool _rmbHeld;
// Phase K.2 — auto-enter player mode after a successful login. Armed
// by the EnterWorld branch in BeginLiveSessionAsync; ticked from
// OnUpdate; disarmed if the user manually enters fly mode (or any
// other path that pre-empts the chase camera). Skipped entirely
// offline (orbit camera stays the foreground). The class internally
// tracks IsArmed; we read it via the guard rather than mirroring
// the bool here.
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
// Phase K.2 — MMB-hold instant mouse-look state. Live throughout
// the session; flips Active on Press/Release. Defense-in-depth on
// ImGui's WantCaptureMouse — the dispatcher already filters, but
// OnWantCaptureMouseChanged also suspends the state if a panel
// claims focus mid-hold.
private AcDream.UI.Abstractions.Input.MouseLookState? _mouseLook;
// Tracks the previous WantCaptureMouse value so we can fire the
// changed-edge callback once per transition (vs every frame).
private bool _lastWantCaptureMouse;
// Cursor mode prior to entering MMB mouse-look. Restored on
// release so the user lands back in the same camera mode as
// before (raw for chase/fly, normal for orbit). Set non-null while
// mouse-look is active.
private Silk.NET.Input.CursorMode? _mouseLookSavedCursorMode;
// 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
@ -543,6 +567,31 @@ public sealed class GameWindow : IDisposable
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_inputDispatcher.Fired += OnInputAction;
// Phase K.2 — MMB-hold instant mouse-look. The yaw mutator
// drives _playerController.Yaw (the chase camera reads this
// automatically via ChaseCamera.Update). Active only while
// _playerController and _chaseCamera are live; the lambda
// safely no-ops outside player mode.
_mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState(
applyYawDelta: dYaw =>
{
if (_playerController is not null) _playerController.Yaw += dYaw;
});
// Phase K.2 — auto-enter player mode after EnterWorld
// succeeds. Predicates close over GameWindow state; the
// entry callback flips into player mode via the same code
// path TogglePlayerMode uses, just without the early-return
// when the entity isn't ready (the third predicate
// guarantees readiness before this fires).
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
isLiveInWorld: () => _liveSession is not null
&& _liveSession.CurrentState ==
AcDream.Core.Net.WorldSession.State.InWorld,
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
isPlayerControllerReady: () => true,
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
// Mouse delta handler — kept direct because Silk.NET delivers mouse
@ -573,23 +622,35 @@ public sealed class GameWindow : IDisposable
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
{
float sens = _sensChase;
if (_rmbHeld)
if (_mouseLook is not null && _mouseLook.Active)
{
// Phase K.2 — MMB instant mouse-look. dx drives
// character yaw via the MouseLookState callback
// (which mutates _playerController.Yaw); the chase
// camera tracks the character automatically because
// ChaseCamera.Update reads the player yaw. So mouse-X
// here goes ONLY through ApplyDelta — no separate
// YawOffset write. dy still pitches the camera only.
_mouseLook.ApplyDelta(dx, sens);
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
else if (_rmbHeld)
{
// 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.
// dispatcher-only (A/D keys). K.2: MMB mouse-look path
// above takes precedence when active.
_chaseCamera.YawOffset -= dx * 0.004f * sens;
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
else
{
// Without RMB held, mouse only pitches the chase
// camera (Y-axis). Mouse X is dropped — character
// turning is keyboard-only in K.1b.
// Without RMB or MMB held, mouse only pitches the
// chase camera (Y-axis). Mouse X is dropped —
// character turning is keyboard-only.
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
}
@ -743,8 +804,8 @@ public sealed class GameWindow : IDisposable
FpsProvider = () => (float)_lastFps,
PositionProvider = () => GetDebugPlayerPosition(),
};
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm));
_chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm);
_panelHost.Register(_chatPanel);
// Phase I.2: DebugPanel — replaces the deleted custom
// DebugOverlay (six floating panels + hint bar + toast).
@ -786,6 +847,19 @@ public sealed class GameWindow : IDisposable
_debugVm.CycleTimeOfDay = CycleTimeOfDay;
_debugVm.CycleWeather = CycleWeather;
_debugVm.ToggleCollisionWires = ToggleCollisionWires;
// Phase K.2: free-fly toggle button — same routine the
// legacy F-key alias hits. Cancels the one-shot
// auto-entry if the user opts out of player mode before
// it fires, so the chase camera doesn't snap on top of
// the fly camera mid-inspection.
_debugVm.ToggleFlyMode = () =>
{
// K.2: manual fly toggle pre-empts the auto-entry
// trigger (user's choice wins). Cancel is no-op when
// not yet armed.
_playerModeAutoEntry?.Cancel();
_cameraController?.ToggleFly();
};
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
_panelHost.Register(_debugPanel);
@ -800,6 +874,7 @@ public sealed class GameWindow : IDisposable
_vitalsVm = null;
_debugVm = null;
_debugPanel = null;
_chatPanel = null;
}
}
@ -1252,6 +1327,13 @@ public sealed class GameWindow : IDisposable
_worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
_liveSession.EnterWorld(user, characterIndex: 0);
// Phase K.2: arm auto-entry. The guard's predicates won't
// pass yet — the entity stream hasn't started — but the
// OnUpdate tick re-checks every frame and fires once
// everything converges (typically 100-300 ms after EnterWorld
// returns). User can pre-empt via DebugPanel "Toggle
// Free-Fly Mode" or Tab; both call Cancel() first.
_playerModeAutoEntry?.Arm();
Console.WriteLine($"live: in world — CreateObject stream active " +
$"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
}
@ -3605,6 +3687,31 @@ public sealed class GameWindow : IDisposable
// that actually consume the events.
_inputDispatcher?.Tick();
// Phase K.2 — re-evaluate WantCaptureMouse for the MMB
// mouse-look state machine. Detect rising/falling edges so the
// state suspends correctly when ImGui claims the cursor while
// MMB is held (e.g. a tooltip pop-up over the cursor). When the
// suspend deactivates an active session, restore the cursor so
// it doesn't get stuck hidden under a panel.
if (_mouseLook is not null)
{
bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
if (wcm != _lastWantCaptureMouse)
{
bool wasActive = _mouseLook.Active;
_mouseLook.OnWantCaptureMouseChanged(wcm);
if (wasActive && !_mouseLook.Active) RestoreCursorAfterMouseLook();
_lastWantCaptureMouse = wcm;
}
}
// Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no _liveSession → IsLiveInWorld
// predicate stays false). Cancelled by manual fly-toggle in
// OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel.
_playerModeAutoEntry?.TryEnter();
if (_cameraController is null || _input is null) return;
// Phase D.2a / K.1b — suppress game-side input polling when ImGui
@ -4936,6 +5043,11 @@ public sealed class GameWindow : IDisposable
// in the DevToolsEnabled construction block above; null otherwise.
private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel;
// Cached chat-panel reference so the dispatcher's ToggleChatEntry
// (Tab) handler can call FocusInput() programmatically. Set in the
// DevToolsEnabled construction block; null otherwise.
private AcDream.UI.Abstractions.Panels.Chat.ChatPanel? _chatPanel;
// ── K.1b: dispatcher action handler ──────────────────────────────────
//
// SINGLE place where every game-side keyboard/mouse-button reaction
@ -4972,6 +5084,29 @@ public sealed class GameWindow : IDisposable
return;
}
// Phase K.2 — MMB-hold instant mouse-look. Press hides the
// cursor + activates yaw drive; release restores. WantCapture
// edge handling lives in OnUpdate; only Press needs to read it
// for the initial gate (defense in depth — the dispatcher
// already filters on WantCaptureMouse in OnMouseDown).
if (action == AcDream.UI.Abstractions.Input.InputAction.CameraInstantMouseLook)
{
if (_mouseLook is null) return;
if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press)
{
bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
_mouseLook.Press(_lastMouseX, _lastMouseY, wcm);
if (_mouseLook.Active) HideCursorForMouseLook();
}
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
{
bool wasActive = _mouseLook.Active;
_mouseLook.Release();
if (wasActive) RestoreCursorAfterMouseLook();
}
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
@ -5031,12 +5166,13 @@ public sealed class GameWindow : IDisposable
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
// K.1c: Tab in retail focuses the chat input. Phase K.2
// wires this to ChatPanel.FocusInput() once the panel grows
// an explicit focus method; the current ImGui-backed chat
// panel takes focus on click. Press is logged via the
// [input] diagnostic above so the cutover is observable.
// TODO K.2: call _chatPanel.FocusInput() once available.
// K.2: Tab focuses the chat input. ChatPanel.FocusInput()
// sets a one-shot flag that emits SetKeyboardFocusHere on
// the next render. No-op when devtools/_chatPanel is null
// (offline / non-devtools build) — the dispatcher still
// logs the action via the [input] diagnostic above so the
// path is observable in either case.
_chatPanel?.FocusInput();
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
@ -5062,6 +5198,8 @@ public sealed class GameWindow : IDisposable
/// 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.
/// K.2: also disarms the auto-entry trigger when the user toggles
/// manually (their choice wins).
/// </summary>
private void TogglePlayerMode()
{
@ -5070,62 +5208,16 @@ public sealed class GameWindow : IDisposable
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
// Manual toggle pre-empts the K.2 auto-entry trigger regardless
// of direction — entering means "I'm in player mode now"; exiting
// means "I want fly, don't snap me back".
_playerModeAutoEntry?.Cancel();
_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
{
if (!EnterPlayerModeNow(loggingTag: "Tab"))
_playerMode = false;
Console.WriteLine($"live: Tab pressed but player entity 0x{_playerServerGuid:X8} not found yet");
}
}
else
{
@ -5137,6 +5229,134 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// K.2: callback the <see cref="AcDream.App.Input.PlayerModeAutoEntry"/>
/// guard invokes once login + entity stream + controller readiness
/// have all converged. Sets <c>_playerMode = true</c> and runs the
/// same construction path the manual Tab handler uses. Predicates on
/// the guard already guarantee <c>_entitiesByServerGuid</c> contains
/// the player guid, so the inner TryGetValue is a fast-path success.
/// </summary>
private void EnterPlayerModeFromAutoEntry()
{
_playerMode = true;
if (!EnterPlayerModeNow(loggingTag: "auto-entry"))
{
// Defense in depth: if construction failed (e.g. entity
// disappeared between predicate eval and here) drop back
// out cleanly. Re-arm so a later Tab still works.
_playerMode = false;
}
else
{
Console.WriteLine($"live: auto-entered player mode for 0x{_playerServerGuid:X8}");
}
}
/// <summary>
/// K.2: shared "construct controller + chase camera + enter chase
/// mode" body extracted from the on-enter branch of
/// <see cref="TogglePlayerMode"/>. Returns false when the player
/// entity isn't in <c>_entitiesByServerGuid</c> yet — caller must
/// reset <c>_playerMode</c> in that case.
/// </summary>
private bool EnterPlayerModeNow(string loggingTag)
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
{
Console.WriteLine($"live: {loggingTag} — player entity 0x{_playerServerGuid:X8} not found yet");
return false;
}
_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);
return true;
}
/// <summary>
/// Phase K.2: hide the system cursor while MMB instant mouse-look is
/// held. Saves the previous CursorMode so <see cref="RestoreCursorAfterMouseLook"/>
/// can put it back exactly. Skips when no mouse / no input — tests
/// and headless runs stay clean.
/// </summary>
private void HideCursorForMouseLook()
{
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
// Save previous mode (Normal in orbit, Raw in chase/fly) so the
// exact pre-hold mode is restored on release.
_mouseLookSavedCursorMode = mouse.Cursor.CursorMode;
mouse.Cursor.CursorMode = CursorMode.Hidden;
}
/// <summary>
/// Phase K.2: restore the saved cursor mode after MMB instant
/// mouse-look ends. Called from the Release branch and from the
/// WantCaptureMouse-edge suspend path so the cursor never gets
/// stuck hidden.
/// </summary>
private void RestoreCursorAfterMouseLook()
{
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
if (_mouseLookSavedCursorMode is { } saved)
{
mouse.Cursor.CursorMode = saved;
_mouseLookSavedCursorMode = null;
}
else
{
// Defense in depth: never observed the saved value, fall
// back to Normal so the user always gets a visible cursor.
mouse.Cursor.CursorMode = CursorMode.Normal;
}
}
/// <summary>
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the