diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
new file mode 100644
index 0000000..4a7f8b4
--- /dev/null
+++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
@@ -0,0 +1,115 @@
+using System;
+
+namespace AcDream.App.Input;
+
+///
+/// Phase K.2 — one-shot guard that auto-enters player mode after a
+/// successful login once every prerequisite is satisfied. Owned by
+/// GameWindow and ticked each frame from OnUpdate.
+///
+///
+/// Why is this its own class? The auto-entry has three independent
+/// preconditions (live session reaches InWorld, 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.
+///
+///
+///
+/// The public surface is:
+///
+/// — call after EnterWorld succeeds to
+/// arm the entry trigger.
+/// — call when the user manually enters
+/// fly mode (or any other code path that pre-empts the auto-entry).
+/// — 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.
+///
+///
+///
+///
+/// All preconditions are passed in as predicates so the class doesn't
+/// pull in WorldSession, PlayerMovementController, or
+/// any GameWindow-internal types — the unit test wires them to plain
+/// boolean fields.
+///
+///
+public sealed class PlayerModeAutoEntry
+{
+ private readonly Func _isLiveInWorld;
+ private readonly Func _isPlayerEntityPresent;
+ private readonly Func _isPlayerControllerReady;
+ private readonly Action _enterPlayerMode;
+
+ private bool _armed;
+
+ ///
+ /// Build an auto-entry guard.
+ ///
+ /// True iff the live session is in the
+ /// InWorld state. Skip auto-entry when the session is null
+ /// or hasn't reached InWorld yet.
+ /// True iff the player's
+ /// server-guid is already in the local entity dictionary (server
+ /// has streamed at least one CreateObject for the character).
+ /// 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.
+ /// 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.
+ public PlayerModeAutoEntry(
+ Func isLiveInWorld,
+ Func isPlayerEntityPresent,
+ Func 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));
+ }
+
+ /// True iff would still fire if the
+ /// preconditions become true. Flips false on a successful entry
+ /// (one-shot) or when is invoked.
+ public bool IsArmed => _armed;
+
+ ///
+ /// Arm the trigger. Call after WorldSession.EnterWorld
+ /// returns successfully. Calling again while already armed is a
+ /// no-op.
+ ///
+ public void Arm() => _armed = true;
+
+ ///
+ /// 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.
+ ///
+ public void Cancel() => _armed = false;
+
+ ///
+ /// Guard tick. If the trigger is armed AND every precondition is
+ /// satisfied, invokes enterPlayerMode, disarms, and
+ /// returns true. Returns false otherwise (no side effects).
+ ///
+ public bool TryEnter()
+ {
+ if (!_armed) return false;
+ if (!_isLiveInWorld()) return false;
+ if (!_isPlayerEntityPresent()) return false;
+ if (!_isPlayerControllerReady()) return false;
+
+ _armed = false;
+ _enterPlayerMode();
+ return true;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index e4a2731..bcb41d7 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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 +
/// 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).
///
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(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
}
}
+ ///
+ /// K.2: callback the
+ /// guard invokes once login + entity stream + controller readiness
+ /// have all converged. Sets _playerMode = true and runs the
+ /// same construction path the manual Tab handler uses. Predicates on
+ /// the guard already guarantee _entitiesByServerGuid contains
+ /// the player guid, so the inner TryGetValue is a fast-path success.
+ ///
+ 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}");
+ }
+ }
+
+ ///
+ /// K.2: shared "construct controller + chase camera + enter chase
+ /// mode" body extracted from the on-enter branch of
+ /// . Returns false when the player
+ /// entity isn't in _entitiesByServerGuid yet — caller must
+ /// reset _playerMode in that case.
+ ///
+ 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(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;
+ }
+
+ ///
+ /// Phase K.2: hide the system cursor while MMB instant mouse-look is
+ /// held. Saves the previous CursorMode so
+ /// can put it back exactly. Skips when no mouse / no input — tests
+ /// and headless runs stay clean.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
///
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs
index 0c7c311..ff45550 100644
--- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs
+++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs
@@ -198,4 +198,12 @@ public interface IPanelRenderer
/// to force a scroll.
///
void SetScrollHereY(float ratio);
+
+ ///
+ /// Request keyboard focus for the NEXT widget rendered. Used by
+ /// ChatPanel when Tab fires ToggleChatEntry — the
+ /// chat input gets focused programmatically so the user can begin
+ /// typing without clicking the field.
+ ///
+ void SetKeyboardFocusHere();
}
diff --git a/src/AcDream.UI.Abstractions/Input/MouseLookState.cs b/src/AcDream.UI.Abstractions/Input/MouseLookState.cs
new file mode 100644
index 0000000..09c1385
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/MouseLookState.cs
@@ -0,0 +1,124 @@
+using System;
+
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Phase K.2 — state machine for MMB-hold "instant mouse-look" mode
+/// (retail's CameraInstantMouseLook). 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.
+///
+///
+/// The class owns three transitions:
+///
+/// — MMB pressed AND ImGui isn't hovering a
+/// panel; activate, capture initial cursor position for restore.
+/// — MMB released; deactivate, signal
+/// cursor restore.
+/// — 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.
+///
+///
+///
+///
+/// is the per-frame mouse-move hook: when
+/// active, scales by sensitivity and feeds the
+/// caller-supplied yaw mutator. The mutator is the only side-channel —
+/// the class doesn't know about PlayerMovementController or
+/// ChaseCamera.
+///
+///
+public sealed class MouseLookState
+{
+ private readonly Action _applyYawDelta;
+
+ /// True while MMB is held AND ImGui isn't capturing the
+ /// mouse. Mouse-X deltas drive yaw only when this is true.
+ public bool Active { get; private set; }
+
+ /// Cursor X at the moment activated.
+ /// Restore on release.
+ public float CapturedCursorX { get; private set; }
+
+ /// Cursor Y at the moment activated.
+ public float CapturedCursorY { get; private set; }
+
+ ///
+ /// 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.
+ ///
+ public float SensitivityRadiansPerPixel { get; set; } = 0.004f;
+
+ public MouseLookState(Action applyYawDelta)
+ {
+ _applyYawDelta = applyYawDelta ?? throw new ArgumentNullException(nameof(applyYawDelta));
+ }
+
+ ///
+ /// 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.
+ ///
+ /// Current cursor X (captured for restore on release).
+ /// Current cursor Y.
+ /// Mirror of
+ /// ImGui.GetIO().WantCaptureMouse. When true, the press is
+ /// ignored so a hover-over-panel MMB doesn't grab the cursor.
+ public void Press(float cursorX, float cursorY, bool wantCaptureMouse)
+ {
+ if (wantCaptureMouse) return;
+ if (Active) return;
+ Active = true;
+ CapturedCursorX = cursorX;
+ CapturedCursorY = cursorY;
+ }
+
+ /// MMB release transition. Always deactivates if active.
+ public void Release()
+ {
+ if (!Active) return;
+ Active = false;
+ }
+
+ ///
+ /// 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.
+ ///
+ public void OnWantCaptureMouseChanged(bool wantCaptureMouse)
+ {
+ if (Active && wantCaptureMouse) Active = false;
+ }
+
+ ///
+ /// Apply a per-frame mouse-X delta. When active, scales by
+ /// times the
+ /// caller-supplied (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).
+ ///
+ /// Pixels of horizontal mouse motion since the
+ /// last frame.
+ /// Multiplier (e.g. chase-camera
+ /// sensitivity) applied on top of the radians-per-pixel scale.
+ 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);
+ }
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
index fe8939f..f3a4a07 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
@@ -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
///
public bool IsVisible { get; set; } = true;
+ ///
+ /// Phase K.2: request keyboard focus for the chat input on the
+ /// NEXT . One-shot — fires once and resets,
+ /// so callers (e.g. GameWindow's Tab handler subscribing to
+ /// ToggleChatEntry) can drive it on a single key press
+ /// without trapping the user permanently in the input field.
+ ///
+ public void FocusInput() => _focusRequested = true;
+
///
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)
{
diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
index 076faa2..e996a0d 100644
--- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
@@ -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");
}
diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
index 7765f95..9e914af 100644
--- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
@@ -255,6 +255,15 @@ public sealed class DebugVM
///
public Action? ToggleCollisionWires { get; set; }
+ ///
+ /// 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 GameWindow to the
+ /// same routine the legacy F-key fly toggle invokes.
+ ///
+ public Action? ToggleFlyMode { get; set; }
+
// ── Combat event ring + toast ring ─────────────────────────────────
///
diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
index 671e7b2..b18eec2 100644
--- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
+++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
@@ -170,4 +170,7 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
///
public void SetScrollHereY(float ratio) => ImGuiNET.ImGui.SetScrollHereY(ratio);
+
+ ///
+ public void SetKeyboardFocusHere() => ImGuiNET.ImGui.SetKeyboardFocusHere();
}
diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
new file mode 100644
index 0000000..aa1463c
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
@@ -0,0 +1,155 @@
+using AcDream.App.Input;
+
+namespace AcDream.Core.Tests.Input;
+
+///
+/// Phase K.2 — guard logic for auto-entering player mode at login.
+/// The trigger fires exactly once when:
+///
+/// It has been 'd (login
+/// succeeded).
+/// The live session is in InWorld.
+/// The player entity has been streamed into the local
+/// dictionary.
+/// The player movement controller is ready to attach.
+///
+/// All four predicates are passed as ; tests
+/// flip plain boolean fields and assert against an entry-counter that
+/// the entry callback bumps.
+///
+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);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs
index 5c67f6d..25f6c40 100644
--- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs
@@ -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