Losing window focus calls CameraPointerInputController.HandleFocusChanged -> MouseLookController.EndForLifecycle -> PlayerMovementController .EndMouseLook, and EnsurePublishedForRuntimeOperation throws when the controller exists but is not yet published (mid-login) or already retired (post-logout). A focus callback can land in either window, so a simple alt-tab during the login stream took the whole process down with an unhandled InvalidOperationException. Hit live during the Campaign A listening-session launches. EndAndRestoreCursor already guarded 'no controller'; publication state is the finer-grained form of the same condition, so the guard is completed with the new CanExecuteLiveMovement predicate (the exact lifecycle set EnsurePublishedForRuntimeOperation accepts) rather than wrapping the call in a catch. Cursor restore still runs unconditionally - presentation is always safe. Published-controller behaviour is unchanged. Also files issue #357: the login placement stall this session exposed (reveal ready=True, player Place edge never executes, world never opens). That one is NOT fixed here - full evidence chain, wire capture, and probe output are in the issue. It is a placement-domain bug and blocks the Campaign A listening gate. Suite: green except the known load-dependent measurement flake (RuntimeCollisionReportingStateTests allocation pin), which passes in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
9.1 KiB
C#
274 lines
9.1 KiB
C#
using AcDream.App.Net;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Core.Rendering;
|
|
using AcDream.UI.Abstractions.Input;
|
|
using Silk.NET.Input;
|
|
|
|
namespace AcDream.App.Input;
|
|
|
|
internal interface IInputMonotonicClock
|
|
{
|
|
float NowSeconds { get; }
|
|
}
|
|
|
|
internal sealed class EnvironmentInputMonotonicClock : IInputMonotonicClock
|
|
{
|
|
public float NowSeconds => (float)(Environment.TickCount64 / 1000.0);
|
|
}
|
|
|
|
internal interface IPointerPositionSource
|
|
{
|
|
float X { get; }
|
|
float Y { get; }
|
|
}
|
|
|
|
internal sealed class PointerPositionState : IPointerPositionSource
|
|
{
|
|
public float X { get; set; }
|
|
public float Y { get; set; }
|
|
}
|
|
|
|
internal interface IMouseLookCursor
|
|
{
|
|
bool HasSavedMode { get; }
|
|
void Hide();
|
|
void Restore();
|
|
}
|
|
|
|
internal interface IMouseLookInputFrameController
|
|
{
|
|
bool Active { get; }
|
|
bool HandlePointerAction(InputAction action, ActivationType activation);
|
|
void QueueRawDelta(float dx, float dy);
|
|
void Tick();
|
|
void EndAndRestoreCursor();
|
|
void EndForLifecycle();
|
|
void ResetSession();
|
|
}
|
|
|
|
internal sealed class SilkMouseLookCursor : IMouseLookCursor
|
|
{
|
|
private readonly IMouse _mouse;
|
|
private CursorMode? _savedMode;
|
|
|
|
public SilkMouseLookCursor(IMouse mouse) =>
|
|
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
|
|
|
public bool HasSavedMode => _savedMode.HasValue;
|
|
|
|
public void Hide()
|
|
{
|
|
_savedMode = _mouse.Cursor.CursorMode;
|
|
_mouse.Cursor.CursorMode = CursorMode.Hidden;
|
|
}
|
|
|
|
public void Restore()
|
|
{
|
|
_mouse.Cursor.CursorMode = _savedMode ?? CursorMode.Normal;
|
|
_savedMode = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The current chase-camera input targets. GameWindow keeps compatibility
|
|
/// properties over this one slot until checkpoint F moves camera ownership.
|
|
/// </summary>
|
|
internal interface IChaseCameraSource
|
|
{
|
|
ChaseCamera? Legacy { get; }
|
|
RetailChaseCamera? Retail { get; }
|
|
}
|
|
|
|
internal sealed class ChaseCameraInputState : IChaseCameraSource
|
|
{
|
|
public ChaseCamera? Legacy { get; set; }
|
|
public RetailChaseCamera? Retail { get; set; }
|
|
public float Sensitivity { get; set; } = 0.15f;
|
|
public bool RmbOrbitHeld { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owns instant mouse-look state, raw-sample timing/filtering, movement
|
|
/// transition sends, and cursor capture across every exit path.
|
|
/// </summary>
|
|
internal sealed class MouseLookController : IMouseLookInputFrameController
|
|
{
|
|
private readonly IMouseSource _mouseSource;
|
|
private readonly IPointerPositionSource _pointer;
|
|
private readonly ILocalPlayerModeSource _playerMode;
|
|
private readonly IRuntimeLocalPlayerControllerSource _playerController;
|
|
private readonly CameraController _camera;
|
|
private readonly ChaseCameraInputState _chase;
|
|
private readonly IMovementInputSource _movementInput;
|
|
private readonly LocalPlayerOutboundController _outbound;
|
|
private readonly ILiveWorldSessionSource _session;
|
|
private readonly IMouseLookCursor _cursor;
|
|
private readonly IInputMonotonicClock _clock;
|
|
private readonly MouseLookState _state;
|
|
private bool _lastWantCaptureMouse;
|
|
|
|
public MouseLookController(
|
|
IMouseSource mouseSource,
|
|
IPointerPositionSource pointer,
|
|
ILocalPlayerModeSource playerMode,
|
|
IRuntimeLocalPlayerControllerSource playerController,
|
|
CameraController camera,
|
|
ChaseCameraInputState chase,
|
|
IMovementInputSource movementInput,
|
|
LocalPlayerOutboundController outbound,
|
|
ILiveWorldSessionSource session,
|
|
IMouseLookCursor cursor,
|
|
IInputMonotonicClock clock)
|
|
{
|
|
_mouseSource = mouseSource ?? throw new ArgumentNullException(nameof(mouseSource));
|
|
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
|
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
|
_playerController = playerController
|
|
?? throw new ArgumentNullException(nameof(playerController));
|
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
|
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
|
_movementInput = movementInput
|
|
?? throw new ArgumentNullException(nameof(movementInput));
|
|
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
|
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
_state = new MouseLookState(ApplyHorizontalAdjustment);
|
|
}
|
|
|
|
public bool Active => _state.Active;
|
|
|
|
public bool HandlePointerAction(InputAction action, ActivationType activation)
|
|
{
|
|
if (action == InputAction.AcdreamRmbOrbitHold)
|
|
{
|
|
if (activation == ActivationType.Press)
|
|
_chase.RmbOrbitHeld = _playerMode.IsPlayerMode && _camera.IsChaseMode;
|
|
else if (activation == ActivationType.Release)
|
|
_chase.RmbOrbitHeld = false;
|
|
return true;
|
|
}
|
|
|
|
if (action != InputAction.CameraInstantMouseLook)
|
|
return false;
|
|
|
|
if (activation == ActivationType.Press)
|
|
Begin();
|
|
else if (activation == ActivationType.Release)
|
|
EndAndRestoreCursor();
|
|
return true;
|
|
}
|
|
|
|
public void QueueRawDelta(float dx, float dy) => _state.QueueDelta(dx, dy);
|
|
|
|
public void Tick()
|
|
{
|
|
bool wantCaptureMouse = _mouseSource.WantCaptureMouse;
|
|
if (wantCaptureMouse != _lastWantCaptureMouse)
|
|
{
|
|
if (wantCaptureMouse && _state.Active)
|
|
EndAndRestoreCursor();
|
|
_state.OnWantCaptureMouseChanged(wantCaptureMouse);
|
|
_lastWantCaptureMouse = wantCaptureMouse;
|
|
}
|
|
|
|
float nowSeconds = _clock.NowSeconds;
|
|
if (!_state.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
|
|
return;
|
|
|
|
PlayerMovementController? controller = _playerController.Controller;
|
|
if (rawX == 0f && rawY == 0f)
|
|
controller?.StopMouseDrift(_movementInput.Capture());
|
|
|
|
(float filteredX, float filteredY) =
|
|
CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is { } retail
|
|
? retail.FilterMouseDelta(rawX, rawY, weight: 0.5f, nowSec: nowSeconds)
|
|
: (rawX, rawY);
|
|
_state.ApplyDelta(filteredX, _chase.Sensitivity);
|
|
if (_chase.Retail is { } retailCamera)
|
|
retailCamera.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
|
|
else
|
|
_chase.Legacy?.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
|
|
}
|
|
|
|
public void EndAndRestoreCursor()
|
|
{
|
|
bool stateWasActive = _state.Active;
|
|
_state.Release();
|
|
|
|
// The null check alone is not enough: a controller can also be present
|
|
// but UNPUBLISHED (mid-login) or retired (post-logout), and a focus-loss
|
|
// callback can land in either window. EndMouseLook faults on both, so a
|
|
// simple alt-tab during login used to take the process down. Cursor
|
|
// restore below still runs — that is presentation and always safe.
|
|
PlayerMovementController? controller = _playerController.Controller;
|
|
if (controller is { CanExecuteLiveMovement: true }
|
|
&& controller.EndMouseLook(_movementInput.Capture()))
|
|
{
|
|
_outbound.TrySendMovement(
|
|
_session.CurrentSession,
|
|
controller,
|
|
controller.CaptureMovementResult(mouseLookEvent: false));
|
|
}
|
|
|
|
if (stateWasActive || _cursor.HasSavedMode)
|
|
_cursor.Restore();
|
|
}
|
|
|
|
public void EndForLifecycle()
|
|
{
|
|
EndAndRestoreCursor();
|
|
_chase.RmbOrbitHeld = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Session teardown releases presentation capture without emitting a
|
|
/// movement packet into the ending session, matching the prior reset edge.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
bool stateWasActive = _state.Active;
|
|
_state.Release();
|
|
_chase.RmbOrbitHeld = false;
|
|
_lastWantCaptureMouse = false;
|
|
if (stateWasActive || _cursor.HasSavedMode)
|
|
_cursor.Restore();
|
|
}
|
|
|
|
private void Begin()
|
|
{
|
|
PlayerMovementController? controller = _playerController.Controller;
|
|
if (!_playerMode.IsPlayerMode
|
|
|| !_camera.IsChaseMode
|
|
|| controller is not { State: PlayerState.InWorld })
|
|
{
|
|
return;
|
|
}
|
|
|
|
float nowSeconds = _clock.NowSeconds;
|
|
_state.Press(
|
|
_pointer.X,
|
|
_pointer.Y,
|
|
_mouseSource.WantCaptureMouse,
|
|
nowSeconds);
|
|
if (!_state.Active)
|
|
return;
|
|
|
|
if (!controller.BeginMouseLook(_movementInput.Capture()))
|
|
{
|
|
_state.Release();
|
|
return;
|
|
}
|
|
|
|
_outbound.TrySendMovement(
|
|
_session.CurrentSession,
|
|
controller,
|
|
controller.CaptureMovementResult(mouseLookEvent: false));
|
|
_cursor.Hide();
|
|
}
|
|
|
|
private void ApplyHorizontalAdjustment(float adjustment) =>
|
|
_playerController.Controller?.SubmitMouseTurnAdjustment(
|
|
adjustment,
|
|
_movementInput.Capture());
|
|
}
|