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
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