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

@ -0,0 +1,115 @@
using System;
namespace AcDream.App.Input;
/// <summary>
/// Phase K.2 — one-shot guard that auto-enters player mode after a
/// successful login once every prerequisite is satisfied. Owned by
/// <c>GameWindow</c> and ticked each frame from <c>OnUpdate</c>.
///
/// <para>
/// Why is this its own class? The auto-entry has three independent
/// preconditions (live session reaches <c>InWorld</c>, the player
/// entity has been streamed into the world dictionary, and the player
/// movement controller is constructible) plus a manual-override path
/// (the user can flip into fly mode before the auto-entry fires —
/// their choice wins). All four interact with each other in a way
/// that's painful to test through GameWindow but trivial here against
/// fakes.
/// </para>
///
/// <para>
/// The public surface is:
/// <list type="bullet">
/// <item><see cref="Arm"/> — call after <c>EnterWorld</c> succeeds to
/// arm the entry trigger.</item>
/// <item><see cref="Cancel"/> — call when the user manually enters
/// fly mode (or any other code path that pre-empts the auto-entry).</item>
/// <item><see cref="TryEnter"/> — call once per frame; runs the
/// guard and fires the entry callback when armed AND every
/// precondition is satisfied; returns true on the firing tick.</item>
/// </list>
/// </para>
///
/// <para>
/// All preconditions are passed in as predicates so the class doesn't
/// pull in <c>WorldSession</c>, <c>PlayerMovementController</c>, or
/// any GameWindow-internal types — the unit test wires them to plain
/// boolean fields.
/// </para>
/// </summary>
public sealed class PlayerModeAutoEntry
{
private readonly Func<bool> _isLiveInWorld;
private readonly Func<bool> _isPlayerEntityPresent;
private readonly Func<bool> _isPlayerControllerReady;
private readonly Action _enterPlayerMode;
private bool _armed;
/// <summary>
/// Build an auto-entry guard.
/// </summary>
/// <param name="isLiveInWorld">True iff the live session is in the
/// <c>InWorld</c> state. Skip auto-entry when the session is null
/// or hasn't reached InWorld yet.</param>
/// <param name="isPlayerEntityPresent">True iff the player's
/// server-guid is already in the local entity dictionary (server
/// has streamed at least one CreateObject for the character).</param>
/// <param name="isPlayerControllerReady">True iff the per-frame
/// PlayerMovementController is set up. Stays true once player mode
/// is established; the auto-entry's job is to flip it from false
/// to true exactly once.</param>
/// <param name="enterPlayerMode">Action invoked on the firing
/// tick. The same routine the manual Tab handler invokes (fly →
/// player transition). Must construct the controller + chase
/// camera and switch the active camera; the auto-entry doesn't
/// reach inside.</param>
public PlayerModeAutoEntry(
Func<bool> isLiveInWorld,
Func<bool> isPlayerEntityPresent,
Func<bool> isPlayerControllerReady,
Action enterPlayerMode)
{
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
}
/// <summary>True iff <see cref="TryEnter"/> would still fire if the
/// preconditions become true. Flips false on a successful entry
/// (one-shot) or when <see cref="Cancel"/> is invoked.</summary>
public bool IsArmed => _armed;
/// <summary>
/// Arm the trigger. Call after <c>WorldSession.EnterWorld</c>
/// returns successfully. Calling again while already armed is a
/// no-op.
/// </summary>
public void Arm() => _armed = true;
/// <summary>
/// Disarm the trigger without firing the callback. Call when the
/// user has manually entered fly mode (or any other code path
/// that pre-empts the auto-entry) — the user's choice wins.
/// </summary>
public void Cancel() => _armed = false;
/// <summary>
/// Guard tick. If the trigger is armed AND every precondition is
/// satisfied, invokes <c>enterPlayerMode</c>, disarms, and
/// returns true. Returns false otherwise (no side effects).
/// </summary>
public bool TryEnter()
{
if (!_armed) return false;
if (!_isLiveInWorld()) return false;
if (!_isPlayerEntityPresent()) return false;
if (!_isPlayerControllerReady()) return false;
_armed = false;
_enterPlayerMode();
return true;
}
}

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

View file

@ -198,4 +198,12 @@ public interface IPanelRenderer
/// to force a scroll.
/// </summary>
void SetScrollHereY(float ratio);
/// <summary>
/// Request keyboard focus for the NEXT widget rendered. Used by
/// <c>ChatPanel</c> when Tab fires <c>ToggleChatEntry</c> — the
/// chat input gets focused programmatically so the user can begin
/// typing without clicking the field.
/// </summary>
void SetKeyboardFocusHere();
}

View file

@ -0,0 +1,124 @@
using System;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Phase K.2 — state machine for MMB-hold "instant mouse-look" mode
/// (retail's <c>CameraInstantMouseLook</c>). While active, mouse-X
/// delta drives the character's heading AND the chase camera yaw
/// together (combined drive — the camera "instantly" follows the
/// character because mouse-X moves the character, and the chase
/// camera always tracks the character). Mouse-Y is left to the
/// caller — typically pitches the chase camera only.
///
/// <para>
/// The class owns three transitions:
/// <list type="bullet">
/// <item><see cref="Press"/> — MMB pressed AND ImGui isn't hovering a
/// panel; activate, capture initial cursor position for restore.</item>
/// <item><see cref="Release"/> — MMB released; deactivate, signal
/// cursor restore.</item>
/// <item><see cref="OnWantCaptureMouseChanged"/> — ImGui took mouse
/// focus while we were active (e.g. a panel pop-up); deactivate AS IF
/// the user released the button so the cursor is restored.</item>
/// </list>
/// </para>
///
/// <para>
/// <see cref="ApplyDelta"/> is the per-frame mouse-move hook: when
/// active, scales <paramref name="dx"/> by sensitivity and feeds the
/// caller-supplied yaw mutator. The mutator is the only side-channel —
/// the class doesn't know about <c>PlayerMovementController</c> or
/// <c>ChaseCamera</c>.
/// </para>
/// </summary>
public sealed class MouseLookState
{
private readonly Action<float> _applyYawDelta;
/// <summary>True while MMB is held AND ImGui isn't capturing the
/// mouse. Mouse-X deltas drive yaw only when this is true.</summary>
public bool Active { get; private set; }
/// <summary>Cursor X at the moment <see cref="Press"/> activated.
/// Restore on release.</summary>
public float CapturedCursorX { get; private set; }
/// <summary>Cursor Y at the moment <see cref="Press"/> activated.</summary>
public float CapturedCursorY { get; private set; }
/// <summary>
/// Per-radian yaw multiplier applied to mouse-X delta. The same
/// chase-camera sensitivity factor used elsewhere in GameWindow is
/// folded in by the caller; this class only owns the constant
/// scale that converts pixels to radians. Default 0.004 matches
/// the K.1b RMB-orbit factor.
/// </summary>
public float SensitivityRadiansPerPixel { get; set; } = 0.004f;
public MouseLookState(Action<float> applyYawDelta)
{
_applyYawDelta = applyYawDelta ?? throw new ArgumentNullException(nameof(applyYawDelta));
}
/// <summary>
/// MMB press transition. Activates only when ImGui isn't capturing
/// the mouse — the dispatcher should already gate this, but the
/// guard adds defense in depth in case a binding fires through.
/// </summary>
/// <param name="cursorX">Current cursor X (captured for restore on release).</param>
/// <param name="cursorY">Current cursor Y.</param>
/// <param name="wantCaptureMouse">Mirror of
/// <c>ImGui.GetIO().WantCaptureMouse</c>. When true, the press is
/// ignored so a hover-over-panel MMB doesn't grab the cursor.</param>
public void Press(float cursorX, float cursorY, bool wantCaptureMouse)
{
if (wantCaptureMouse) return;
if (Active) return;
Active = true;
CapturedCursorX = cursorX;
CapturedCursorY = cursorY;
}
/// <summary>MMB release transition. Always deactivates if active.</summary>
public void Release()
{
if (!Active) return;
Active = false;
}
/// <summary>
/// Reactive deactivation when ImGui takes mouse focus mid-hold.
/// E.g. a tooltip pops open over the cursor while MMB is held —
/// we yield the cursor to the panel instead of staying captured.
/// </summary>
public void OnWantCaptureMouseChanged(bool wantCaptureMouse)
{
if (Active && wantCaptureMouse) Active = false;
}
/// <summary>
/// Apply a per-frame mouse-X delta. When active, scales by
/// <see cref="SensitivityRadiansPerPixel"/> times the
/// caller-supplied <paramref name="extraSensitivity"/> (typically
/// the chase-camera sens) and feeds it to the yaw mutator. Sign
/// matches retail: dragging the mouse RIGHT yaws the character to
/// the right (positive yaw delta).
/// </summary>
/// <param name="dx">Pixels of horizontal mouse motion since the
/// last frame.</param>
/// <param name="extraSensitivity">Multiplier (e.g. chase-camera
/// sensitivity) applied on top of the radians-per-pixel scale.</param>
public void ApplyDelta(float dx, float extraSensitivity)
{
if (!Active) return;
// Sign: dragging the mouse RIGHT (dx > 0) should yaw the
// character to the right. With the acdream Yaw convention
// (where Yaw 0 = +X, increasing to +Y), positive yaw is
// counter-clockwise viewed top-down — so dragging right means
// yaw goes DOWN (more clockwise). The dispatcher convention
// for chase YawOffset is `YawOffset -= dx * factor`; we keep
// the same sign here so character + camera rotate identically.
_applyYawDelta(-dx * SensitivityRadiansPerPixel * extraSensitivity);
}
}

View file

@ -42,6 +42,14 @@ public sealed class ChatPanel : IPanel
// entries without yanking the user's manual scroll.
private int _lastRenderedCount;
// Phase K.2: one-shot focus request for the chat input. Set by
// FocusInput() (driven by Tab → ToggleChatEntry); the next Render
// call emits SetKeyboardFocusHere immediately before the input
// field and clears the flag. Without the one-shot semantics, the
// panel would steal focus on every frame and the user could never
// click into another widget.
private bool _focusRequested;
public ChatPanel(ChatVM vm)
{
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
@ -56,6 +64,15 @@ public sealed class ChatPanel : IPanel
/// <inheritdoc />
public bool IsVisible { get; set; } = true;
/// <summary>
/// Phase K.2: request keyboard focus for the chat input on the
/// NEXT <see cref="Render"/>. One-shot — fires once and resets,
/// so callers (e.g. <c>GameWindow</c>'s Tab handler subscribing to
/// <c>ToggleChatEntry</c>) can drive it on a single key press
/// without trapping the user permanently in the input field.
/// </summary>
public void FocusInput() => _focusRequested = true;
/// <inheritdoc />
public void Render(PanelContext ctx, IPanelRenderer renderer)
{
@ -115,6 +132,15 @@ public sealed class ChatPanel : IPanel
// Phase I.4: input field. Backend implementation clears _input
// on submit per the IPanelRenderer contract.
renderer.Separator();
// Phase K.2: honor a pending FocusInput() request — emit
// SetKeyboardFocusHere immediately before the input widget so
// ImGui (or the future custom backend) applies it to that
// field. One-shot: clear the flag after firing.
if (_focusRequested)
{
renderer.SetKeyboardFocusHere();
_focusRequested = false;
}
if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
&& submitted is not null)
{

View file

@ -211,6 +211,12 @@ public sealed class DebugPanel : IPanel
r.SameLine();
if (r.Button("Toggle collision wires")) _vm.ToggleCollisionWires?.Invoke();
// Phase K.2 — explicit free-fly toggle button. Mirrors the
// legacy F-key alias but is discoverable to users who haven't
// memorized the Ctrl+F* debug bindings. Action handle owned
// by GameWindow; null-safe for tests / offline.
if (r.Button("Toggle Free-Fly Mode")) _vm.ToggleFlyMode?.Invoke();
r.Text(_vm.DebugWireframes ? "collision wires: ON" : "collision wires: OFF");
}

View file

@ -255,6 +255,15 @@ public sealed class DebugVM
/// </summary>
public Action? ToggleCollisionWires { get; set; }
/// <summary>
/// Phase K.2 — toggle the free-fly camera. Lets a user opt out of
/// the auto-entered chase camera (e.g. to inspect a remote part of
/// the world without the player following) without needing to find
/// the Ctrl+F* debug binding. Wired by <c>GameWindow</c> to the
/// same routine the legacy F-key fly toggle invokes.
/// </summary>
public Action? ToggleFlyMode { get; set; }
// ── Combat event ring + toast ring ─────────────────────────────────
/// <summary>

View file

@ -170,4 +170,7 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
/// <inheritdoc />
public void SetScrollHereY(float ratio) => ImGuiNET.ImGui.SetScrollHereY(ratio);
/// <inheritdoc />
public void SetKeyboardFocusHere() => ImGuiNET.ImGui.SetKeyboardFocusHere();
}

View file

@ -0,0 +1,155 @@
using AcDream.App.Input;
namespace AcDream.Core.Tests.Input;
/// <summary>
/// Phase K.2 — guard logic for auto-entering player mode at login.
/// The trigger fires exactly once when:
/// <list type="number">
/// <item>It has been <see cref="PlayerModeAutoEntry.Arm"/>'d (login
/// succeeded).</item>
/// <item>The live session is in <c>InWorld</c>.</item>
/// <item>The player entity has been streamed into the local
/// dictionary.</item>
/// <item>The player movement controller is ready to attach.</item>
/// </list>
/// All four predicates are passed as <see cref="Func{bool}"/>; tests
/// flip plain boolean fields and assert against an entry-counter that
/// the entry callback bumps.
/// </summary>
public sealed class AutoEnterPlayerModeTests
{
private sealed class State
{
public bool LiveInWorld;
public bool PlayerEntityPresent;
public bool PlayerControllerReady;
public int EnteredCount;
public PlayerModeAutoEntry Build() =>
new(
isLiveInWorld: () => LiveInWorld,
isPlayerEntityPresent: () => PlayerEntityPresent,
isPlayerControllerReady: () => PlayerControllerReady,
enterPlayerMode: () => EnteredCount++);
}
[Fact]
public void TryEnter_NotArmed_DoesNotFire()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
// Not armed → must NOT fire even though every precondition is true.
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.False(guard.IsArmed);
}
[Fact]
public void TryEnter_Armed_LiveNotInWorld_DoesNotFire()
{
var s = new State { LiveInWorld = false, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.True(guard.IsArmed);
}
[Fact]
public void TryEnter_Armed_PlayerEntityNotPresent_DoesNotFire()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = false, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.True(guard.IsArmed);
}
[Fact]
public void TryEnter_Armed_PlayerControllerNotReady_DoesNotFire()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = false };
var guard = s.Build();
guard.Arm();
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
Assert.True(guard.IsArmed);
}
[Fact]
public void TryEnter_AllConditionsSatisfied_FiresExactlyOnce()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
Assert.False(guard.IsArmed);
// Subsequent tick must not re-fire — one-shot semantics.
Assert.False(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}
[Fact]
public void TryEnter_FiresOnLaterTickWhenPreconditionsBecomeTrue()
{
var s = new State();
var guard = s.Build();
guard.Arm();
// Tick 1: only LiveInWorld true.
s.LiveInWorld = true;
Assert.False(guard.TryEnter());
// Tick 2: + PlayerEntityPresent.
s.PlayerEntityPresent = true;
Assert.False(guard.TryEnter());
// Tick 3: + PlayerControllerReady → fires.
s.PlayerControllerReady = true;
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}
[Fact]
public void Cancel_BeforeFiring_SuppressesAutoEntry()
{
// Manual fly-mode toggle BEFORE the auto-entry fires must
// disarm the trigger; the user's choice wins.
var s = new State();
var guard = s.Build();
guard.Arm();
// User opts out before any precondition is true.
guard.Cancel();
Assert.False(guard.IsArmed);
// Even when every precondition flips true, the guard stays
// silent — the user's manual fly-mode choice wins.
s.LiveInWorld = true;
s.PlayerEntityPresent = true;
s.PlayerControllerReady = true;
Assert.False(guard.TryEnter());
Assert.Equal(0, s.EnteredCount);
}
[Fact]
public void Arm_WhileAlreadyArmed_IsIdempotent()
{
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
var guard = s.Build();
guard.Arm();
guard.Arm(); // second Arm() — no-op.
Assert.True(guard.TryEnter());
Assert.Equal(1, s.EnteredCount);
}
}

View file

@ -162,4 +162,7 @@ internal sealed class FakePanelRenderer : IPanelRenderer
public void SetScrollHereY(float ratio)
=> Calls.Add(("SetScrollHereY", new object?[] { ratio }));
public void SetKeyboardFocusHere()
=> Calls.Add(("SetKeyboardFocusHere", Array.Empty<object?>()));
}

View file

@ -0,0 +1,166 @@
using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// Phase K.2 — MMB-hold instant mouse-look state machine. While
/// active, mouse-X drives the character's heading (and the chase
/// camera follows automatically because <c>ChaseCamera.Update</c>
/// reads the player yaw). The state machine guards activation on
/// ImGui's <c>WantCaptureMouse</c> so a panel-hovered MMB doesn't
/// hijack the cursor.
/// </summary>
public sealed class MmbMouseLookTests
{
private sealed class YawSink
{
public float Total;
public int ApplyCount;
public void Apply(float d) { Total += d; ApplyCount++; }
}
[Fact]
public void Press_ActivatesAndCapturesCursorPosition()
{
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(cursorX: 320f, cursorY: 240f, wantCaptureMouse: false);
Assert.True(ml.Active);
Assert.Equal(320f, ml.CapturedCursorX);
Assert.Equal(240f, ml.CapturedCursorY);
}
[Fact]
public void Release_DeactivatesWhenActive()
{
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.Release();
Assert.False(ml.Active);
}
[Fact]
public void Press_WhileImGuiCapturesMouse_DoesNotActivate()
{
// Defense in depth — the dispatcher already filters on
// WantCaptureMouse, but if a binding ever fires through the
// state machine itself must not turn on.
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: true);
Assert.False(ml.Active);
}
[Fact]
public void OnWantCaptureMouseChanged_TrueWhileActive_Deactivates()
{
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
Assert.True(ml.Active);
// ImGui takes mouse focus mid-hold (e.g. tooltip pop-up over
// the cursor) → suspend mouse-look so the panel gets the
// cursor.
ml.OnWantCaptureMouseChanged(wantCaptureMouse: true);
Assert.False(ml.Active);
}
[Fact]
public void OnWantCaptureMouseChanged_FalseWhileInactive_NoOp()
{
// Going from "ImGui captures" to "ImGui doesn't capture" must
// NOT auto-reactivate — only an explicit Press does.
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
ml.OnWantCaptureMouseChanged(wantCaptureMouse: false);
Assert.False(ml.Active);
}
[Fact]
public void ApplyDelta_WhileActive_DrivesYawSink()
{
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.01f };
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.ApplyDelta(dx: 10f, extraSensitivity: 1.0f);
// Sign: dragging right (positive dx) yaws the character right
// — by the acdream Yaw convention that's negative yaw delta.
// Magnitude: 10 * 0.01 * 1.0 = 0.1 rad.
Assert.Equal(1, sink.ApplyCount);
Assert.Equal(-0.1f, sink.Total, 5);
}
[Fact]
public void ApplyDelta_WhileInactive_DoesNothing()
{
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
// Never pressed.
ml.ApplyDelta(dx: 100f, extraSensitivity: 1.0f);
Assert.Equal(0, sink.ApplyCount);
Assert.Equal(0f, sink.Total);
}
[Fact]
public void ApplyDelta_AfterRelease_DoesNothing()
{
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.Release();
ml.ApplyDelta(dx: 50f, extraSensitivity: 1.0f);
Assert.Equal(0, sink.ApplyCount);
}
[Fact]
public void Press_WhileAlreadyActive_IsIdempotent()
{
// Repeated Press calls must not blow away the originally
// captured cursor position — the cursor-restore-on-release
// path needs the original anchor.
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply);
ml.Press(100f, 200f, wantCaptureMouse: false);
ml.Press(999f, 888f, wantCaptureMouse: false);
Assert.Equal(100f, ml.CapturedCursorX);
Assert.Equal(200f, ml.CapturedCursorY);
}
[Fact]
public void ApplyDelta_DriverDrivesCharacterAndCameraInOneSink()
{
// Combined drive: the test only exposes a single yaw mutator,
// and that's the design — the yaw mutator GameWindow passes
// updates _playerController.Yaw, and the chase camera reads
// the same yaw via ChaseCamera.Update(pos, playerYaw). So a
// single sink in this test models both behaviors.
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.005f };
ml.Press(0f, 0f, wantCaptureMouse: false);
ml.ApplyDelta(dx: 4f, extraSensitivity: 0.5f); // -0.01
ml.ApplyDelta(dx: -2f, extraSensitivity: 0.5f); // +0.005
Assert.Equal(2, sink.ApplyCount);
Assert.Equal(-0.005f, sink.Total, 5);
}
}

View file

@ -0,0 +1,71 @@
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Phase K.2 — Tab fires <see cref="AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry"/>,
/// which calls <see cref="ChatPanel.FocusInput"/>. The chat panel honors
/// the request on the very next <see cref="ChatPanel.Render"/> by emitting
/// a <c>SetKeyboardFocusHere</c> immediately before the input field. After
/// it fires once, subsequent renders without another <c>FocusInput</c>
/// call must not re-fire (one-shot semantics) — otherwise the chat field
/// would steal focus on every frame and the user could never click out.
/// </summary>
public sealed class ChatPanelFocusTests
{
private sealed class NullBus : AcDream.UI.Abstractions.ICommandBus
{
public void Publish<T>(T command) where T : notnull { }
}
[Fact]
public void FocusInput_NextRender_EmitsSetKeyboardFocusHereBeforeInput()
{
var panel = new ChatPanel(new ChatVM(new ChatLog()));
var renderer = new FakePanelRenderer();
panel.FocusInput();
panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
// Find the SetKeyboardFocusHere call; it must come before the
// InputTextSubmit call so ImGui applies the focus to that widget.
int focusIdx = -1, inputIdx = -1;
for (int i = 0; i < renderer.Calls.Count; i++)
{
if (renderer.Calls[i].Method == "SetKeyboardFocusHere") focusIdx = i;
else if (renderer.Calls[i].Method == "InputTextSubmit") inputIdx = i;
}
Assert.True(focusIdx >= 0, "ChatPanel must call SetKeyboardFocusHere when FocusInput requested.");
Assert.True(inputIdx >= 0, "ChatPanel must still render the InputTextSubmit field.");
Assert.True(focusIdx < inputIdx, "SetKeyboardFocusHere must precede the InputTextSubmit it targets.");
}
[Fact]
public void Render_WithoutFocusInputCall_DoesNotEmitSetKeyboardFocusHere()
{
var panel = new ChatPanel(new ChatVM(new ChatLog()));
var renderer = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetKeyboardFocusHere");
}
[Fact]
public void FocusInput_OnlyAffectsTheNextRender_OneShot()
{
var panel = new ChatPanel(new ChatVM(new ChatLog()));
// Frame 1 — FocusInput requested → expect a SetKeyboardFocusHere.
var r1 = new FakePanelRenderer();
panel.FocusInput();
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
Assert.Contains(r1.Calls, c => c.Method == "SetKeyboardFocusHere");
// Frame 2 — no further FocusInput call → must NOT re-fire.
var r2 = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
Assert.DoesNotContain(r2.Calls, c => c.Method == "SetKeyboardFocusHere");
}
}