refactor(input): own pointer and callback lifetime
Move camera pointer, framebuffer resize, and retained/devtools input edges behind focused reversible owners. Preserve input priority while making shutdown deactivate callbacks before live-session retirement and retry physical detach without stranding transport teardown.
This commit is contained in:
parent
d09e246d3a
commit
8b8afeefa3
42 changed files with 4029 additions and 461 deletions
|
|
@ -0,0 +1,287 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class CameraPointerInputControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void AttachFailureRollsBackRawPrefixAndLeavesCameraUnsubscribed(int failureIndex)
|
||||
{
|
||||
var surfaces = new[] { new RawSurface(), new RawSurface(), new RawSurface() };
|
||||
surfaces[failureIndex].FailAdd = true;
|
||||
CameraPointerInputController owner = Create(surfaces).Owner;
|
||||
|
||||
Assert.ThrowsAny<Exception>(owner.AttachRaw);
|
||||
|
||||
Assert.True(owner.IsDisposalComplete);
|
||||
Assert.All(surfaces, surface => Assert.Equal(0, surface.LiveEdges));
|
||||
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.AddCalls));
|
||||
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.RemoveCalls));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlyPointerUsesAbsoluteDeltaAndCaptureAdvancesPointerWithoutLooking()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.AttachRaw();
|
||||
float originalYaw = fixture.Camera.Fly.Yaw;
|
||||
float originalPitch = fixture.Camera.Fly.Pitch;
|
||||
|
||||
surface.Raise(new Vector2(10f, 5f));
|
||||
Assert.Equal(originalYaw - 10f * 0.003f, fixture.Camera.Fly.Yaw, 5);
|
||||
Assert.Equal(originalPitch - 5f * 0.003f, fixture.Camera.Fly.Pitch, 5);
|
||||
|
||||
fixture.Capture.Mouse = true;
|
||||
surface.Raise(new Vector2(100f, 100f));
|
||||
float capturedYaw = fixture.Camera.Fly.Yaw;
|
||||
float capturedPitch = fixture.Camera.Fly.Pitch;
|
||||
fixture.Capture.Mouse = false;
|
||||
surface.Raise(new Vector2(102f, 97f));
|
||||
|
||||
Assert.Equal(capturedYaw - 2f * 0.003f, fixture.Camera.Fly.Yaw, 5);
|
||||
Assert.Equal(capturedPitch + 3f * 0.003f, fixture.Camera.Fly.Pitch, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawMoveRaisedReentrantlyDuringAttachCannotEnterPartialOwner()
|
||||
{
|
||||
var surface = new RawSurface { RaiseDuringAdd = true };
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
float yaw = fixture.Camera.Fly.Yaw;
|
||||
|
||||
fixture.Owner.AttachRaw();
|
||||
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
|
||||
surface.Raise(new Vector2(3, 0));
|
||||
|
||||
Assert.NotEqual(yaw, fixture.Camera.Fly.Yaw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateGameplayBindDoesNotResubscribeAndFocusLossEndsMouseLook()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Owner.AttachRaw();
|
||||
int adds = surface.AddCalls;
|
||||
var mouseLook = new MouseLook(active: true);
|
||||
var frame = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
new DispatcherMovementInputSource(),
|
||||
mouseLook,
|
||||
new Combat());
|
||||
|
||||
fixture.Owner.BindGameplayFrame(frame);
|
||||
fixture.Owner.HandleFocusChanged(focused: false);
|
||||
|
||||
Assert.Equal(adds, surface.AddCalls);
|
||||
Assert.Equal(1, mouseLook.LifecycleEndCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivateSilencesCopiedRawAndCameraDelegatesBeforeDetach()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.AttachRaw();
|
||||
Action<Vector2> copied = surface.Callback!;
|
||||
float yaw = fixture.Camera.Fly.Yaw;
|
||||
|
||||
fixture.Owner.Deactivate();
|
||||
copied(new Vector2(100, 0));
|
||||
fixture.Camera.ToggleFly();
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
|
||||
Assert.True(fixture.Owner.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCloseCutoffDoesNotEndMouseLookUntilSessionRetirement()
|
||||
{
|
||||
var fixture = Create([new RawSurface()]);
|
||||
var mouseLook = new MouseLook(active: true);
|
||||
var frame = new GameplayInputFrameController(
|
||||
dispatcher: null,
|
||||
new DispatcherMovementInputSource(),
|
||||
mouseLook,
|
||||
new Combat());
|
||||
fixture.Owner.BindGameplayFrame(frame);
|
||||
fixture.Owner.AttachRaw();
|
||||
|
||||
fixture.Owner.Deactivate();
|
||||
fixture.Owner.Dispose();
|
||||
Assert.Equal(0, mouseLook.LifecycleEndCalls);
|
||||
|
||||
fixture.Owner.ReleaseMouseLookAfterSessionRetirement();
|
||||
Assert.Equal(1, mouseLook.LifecycleEndCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRawDetachRetriesOnlyPendingEdge()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
fixture.Owner.AttachRaw();
|
||||
surface.FailRemoveOnce = true;
|
||||
|
||||
fixture.Owner.Dispose();
|
||||
int removes = surface.RemoveCalls;
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Equal(2, surface.RemoveCalls);
|
||||
Assert.Equal(removes, surface.RemoveCalls);
|
||||
Assert.True(fixture.Owner.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeBeforeAttachMakesPointerOwnerTerminal()
|
||||
{
|
||||
var surface = new RawSurface();
|
||||
var fixture = Create([surface]);
|
||||
|
||||
fixture.Owner.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(fixture.Owner.AttachRaw);
|
||||
fixture.Owner.Dispose();
|
||||
Assert.Equal(0, surface.LiveEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollAndSensitivityPreserveModeSpecificPolicy()
|
||||
{
|
||||
var fixture = Create([new RawSurface()]);
|
||||
float orbitDistance = fixture.Camera.Orbit.Distance;
|
||||
|
||||
fixture.Owner.HandleScroll(InputAction.ScrollUp);
|
||||
string orbit = fixture.Owner.AdjustSensitivity(1.2f);
|
||||
fixture.Camera.ToggleFly();
|
||||
float flyBefore = fixture.Owner.ActiveSensitivity;
|
||||
string fly = fixture.Owner.AdjustSensitivity(1.2f);
|
||||
|
||||
Assert.Equal(Math.Clamp(orbitDistance - 20f, 50f, 2000f), fixture.Camera.Orbit.Distance);
|
||||
Assert.Equal("Orbit sens 1.200x", orbit);
|
||||
Assert.Equal("Fly sens 1.200x", fly);
|
||||
Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5);
|
||||
}
|
||||
|
||||
private static Fixture Create(IReadOnlyList<RawSurface> surfaces)
|
||||
{
|
||||
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
|
||||
var capture = new Capture();
|
||||
var mouse = new Mouse();
|
||||
var cursor = new Cursor();
|
||||
var owner = new CameraPointerInputController(
|
||||
surfaces,
|
||||
cursor,
|
||||
new HostQuiescenceGate(),
|
||||
capture,
|
||||
new LocalPlayerModeState(),
|
||||
camera,
|
||||
new ChaseCameraInputState(),
|
||||
mouse,
|
||||
new PointerPositionState(),
|
||||
new Clock());
|
||||
return new Fixture(owner, camera, capture, cursor);
|
||||
}
|
||||
|
||||
private sealed record Fixture(
|
||||
CameraPointerInputController Owner,
|
||||
CameraController Camera,
|
||||
Capture Capture,
|
||||
Cursor Cursor);
|
||||
|
||||
private sealed class RawSurface : IRawPointerSurface
|
||||
{
|
||||
public bool FailAdd { get; set; }
|
||||
public bool FailRemoveOnce { get; set; }
|
||||
public bool RaiseDuringAdd { get; set; }
|
||||
public int AddCalls { get; private set; }
|
||||
public int RemoveCalls { get; private set; }
|
||||
public int LiveEdges { get; private set; }
|
||||
public Action<Vector2>? Callback { get; private set; }
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
AddCalls++;
|
||||
Callback = callback;
|
||||
LiveEdges++;
|
||||
if (RaiseDuringAdd) callback(new Vector2(50, 25));
|
||||
if (FailAdd) throw new InvalidOperationException("add");
|
||||
}
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
RemoveCalls++;
|
||||
if (FailRemoveOnce)
|
||||
{
|
||||
FailRemoveOnce = false;
|
||||
throw new InvalidOperationException("remove");
|
||||
}
|
||||
if (ReferenceEquals(Callback, callback))
|
||||
{
|
||||
Callback = null;
|
||||
LiveEdges--;
|
||||
}
|
||||
}
|
||||
public void Raise(Vector2 position) => Callback?.Invoke(position);
|
||||
}
|
||||
|
||||
private sealed class Cursor : IPointerCursorModeTarget
|
||||
{
|
||||
public CursorMode CursorMode { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Capture : IInputCaptureSource
|
||||
{
|
||||
public bool Mouse { get; set; }
|
||||
public bool WantCaptureMouse => Mouse;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
public bool DevToolsWantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class Mouse : IMouseSource
|
||||
{
|
||||
#pragma warning disable CS0067
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
|
||||
private sealed class Clock : IInputMonotonicClock
|
||||
{
|
||||
public float NowSeconds => 1f;
|
||||
}
|
||||
|
||||
private sealed class MouseLook(bool active) : IMouseLookInputFrameController
|
||||
{
|
||||
public bool Active { get; } = active;
|
||||
public int LifecycleEndCalls { get; private set; }
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) => false;
|
||||
public void QueueRawDelta(float dx, float dy) { }
|
||||
public void Tick() { }
|
||||
public void EndAndRestoreCursor() { }
|
||||
public void EndForLifecycle() => LifecycleEndCalls++;
|
||||
public void ResetSession() { }
|
||||
}
|
||||
|
||||
private sealed class Combat : ICombatInputFrameController
|
||||
{
|
||||
public void Tick() { }
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation) { }
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation) => false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue