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:
parent
da189103b8
commit
af74eac0c2
12 changed files with 972 additions and 66 deletions
115
src/AcDream.App/Input/PlayerModeAutoEntry.cs
Normal file
115
src/AcDream.App/Input/PlayerModeAutoEntry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
124
src/AcDream.UI.Abstractions/Input/MouseLookState.cs
Normal file
124
src/AcDream.UI.Abstractions/Input/MouseLookState.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
155
tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
Normal file
155
tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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?>()));
|
||||
}
|
||||
|
|
|
|||
166
tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs
Normal file
166
tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue