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,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);
}
}