using System.Numerics; using AcDream.App.Rendering; using AcDream.Core.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; namespace AcDream.App.Input; /// /// Narrow raw-pointer surface. The production adapter keeps the Silk event /// accessor out of the camera policy and lets failure tests exercise an event /// add/remove without implementing Silk's complete mouse contract. /// internal interface IRawPointerSurface { void AddMouseMove(Action callback); void RemoveMouseMove(Action callback); } internal sealed class SilkRawPointerSurface : IRawPointerSurface { private readonly IMouse _mouse; private Action? _callback; private readonly Action _mouseMove; public SilkRawPointerSurface(IMouse mouse) { _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); _mouseMove = OnMouseMove; } public void AddMouseMove(Action callback) { ArgumentNullException.ThrowIfNull(callback); if (_callback is not null) throw new InvalidOperationException("The raw mouse surface is already attached."); // Publish before invoking the custom event accessor: an accessor may // perform its side effect and then throw, in which case rollback still // has the exact delegate needed to remove it. _callback = callback; _mouse.MouseMove += _mouseMove; } public void RemoveMouseMove(Action callback) { ArgumentNullException.ThrowIfNull(callback); if (!ReferenceEquals(_callback, callback)) return; _mouse.MouseMove -= _mouseMove; _callback = null; } private void OnMouseMove(IMouse _, Vector2 position) => _callback?.Invoke(position); } internal interface IPointerCursorModeTarget { CursorMode CursorMode { get; set; } } internal sealed class SilkPointerCursorModeTarget(IMouse mouse) : IPointerCursorModeTarget { private readonly IMouse _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); public CursorMode CursorMode { get => _mouse.Cursor.CursorMode; set => _mouse.Cursor.CursorMode = value; } } internal interface IPointerSensitivityCommands { string AdjustSensitivity(float factor); } /// /// Owns raw pointer subscriptions and the camera-facing pointer policy that /// previously lived in GameWindow. Physical event removal is retriable; logical /// deactivation is immediate, so a copied or stubborn Silk delegate cannot /// re-enter gameplay while shutdown is closing the live session. /// internal sealed class CameraPointerInputController : IDisposable, IPointerSensitivityCommands { private readonly IReadOnlyList _surfaces; private readonly IPointerCursorModeTarget _cursor; private readonly HostQuiescenceGate _quiescence; private readonly IInputCaptureSource _capture; private readonly LocalPlayerModeState _playerMode; private readonly CameraController _camera; private readonly ChaseCameraInputState _chase; private readonly IMouseSource _mouse; private readonly PointerPositionState _pointer; private readonly IInputMonotonicClock _clock; private readonly Action _mouseMoved; private readonly Action _cameraModeChanged; private readonly bool[] _surfaceAttached; private ResourceShutdownTransaction? _detach; private GameplayInputFrameController? _gameplayFrame; private bool _cameraAttached; private bool _attachStarted; private int _disposeRequested; private int _active; private float _flySensitivity = 1f; private float _orbitSensitivity = 1f; public CameraPointerInputController( IReadOnlyList surfaces, IPointerCursorModeTarget cursor, HostQuiescenceGate quiescence, IInputCaptureSource capture, LocalPlayerModeState playerMode, CameraController camera, ChaseCameraInputState chase, IMouseSource mouse, PointerPositionState pointer, IInputMonotonicClock clock) { _surfaces = surfaces ?? throw new ArgumentNullException(nameof(surfaces)); if (_surfaces.Any(static surface => surface is null)) throw new ArgumentException("Raw pointer surfaces cannot contain null.", nameof(surfaces)); _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); _capture = capture ?? throw new ArgumentNullException(nameof(capture)); _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); _camera = camera ?? throw new ArgumentNullException(nameof(camera)); _chase = chase ?? throw new ArgumentNullException(nameof(chase)); _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); _pointer = pointer ?? throw new ArgumentNullException(nameof(pointer)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); _surfaceAttached = new bool[_surfaces.Count]; _mouseMoved = OnMouseMoved; _cameraModeChanged = OnCameraModeChanged; } public static CameraPointerInputController Create( IReadOnlyList mice, HostQuiescenceGate quiescence, IInputCaptureSource capture, LocalPlayerModeState playerMode, CameraController camera, ChaseCameraInputState chase, IMouseSource mouse, PointerPositionState pointer, IInputMonotonicClock clock) { ArgumentNullException.ThrowIfNull(mice); if (mice.Count == 0) throw new ArgumentException("At least one mouse is required.", nameof(mice)); return new CameraPointerInputController( mice.Select(static candidate => (IRawPointerSurface)new SilkRawPointerSurface(candidate)).ToArray(), new SilkPointerCursorModeTarget(mice[0]), quiescence, capture, playerMode, camera, chase, mouse, pointer, clock); } public bool IsDisposalComplete => !_cameraAttached && _surfaceAttached.All(static attached => !attached); public float ActiveSensitivity { get { if (_playerMode.IsPlayerMode && _camera.IsChaseMode) return _chase.Sensitivity; if (_camera.IsFlyMode) return _flySensitivity; return _orbitSensitivity; } } public bool RmbOrbitHeld => _chase.RmbOrbitHeld; public void AttachRaw() { ObjectDisposedException.ThrowIf( Volatile.Read(ref _disposeRequested) != 0, this); if (_attachStarted) throw new InvalidOperationException("Raw pointer attachment has already started."); _attachStarted = true; try { for (int i = 0; i < _surfaces.Count; i++) { _surfaceAttached[i] = true; _surfaces[i].AddMouseMove(_mouseMoved); } _cameraAttached = true; _camera.ModeChanged += _cameraModeChanged; Volatile.Write(ref _active, 1); } catch (Exception attachError) { Deactivate(); try { EnsureDetachTransaction().CompleteOrThrow(); } catch (Exception rollbackError) { throw new AggregateException( "Raw pointer registration and rollback both failed.", new InvalidOperationException( "Raw pointer registration failed.", attachError), rollbackError); } throw new InvalidOperationException( "Raw pointer registration failed and was rolled back.", attachError); } } /// /// Completes the deliberate two-phase composition edge. Binding the frame /// owner never resubscribes or changes event priority. /// public void BindGameplayFrame(GameplayInputFrameController gameplayFrame) { ArgumentNullException.ThrowIfNull(gameplayFrame); if (_gameplayFrame is not null && !ReferenceEquals(_gameplayFrame, gameplayFrame)) { throw new InvalidOperationException( "The raw pointer owner is already bound to another gameplay frame."); } _gameplayFrame = gameplayFrame; } public IDisposable BindGameplayFrameOwned( GameplayInputFrameController gameplayFrame) { ArgumentNullException.ThrowIfNull(gameplayFrame); if (_gameplayFrame is not null) { throw new InvalidOperationException( "The raw pointer owner already has a gameplay frame."); } _gameplayFrame = gameplayFrame; return new GameplayFrameBinding(this, gameplayFrame); } public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame) { ArgumentNullException.ThrowIfNull(gameplayFrame); if (ReferenceEquals(_gameplayFrame, gameplayFrame)) _gameplayFrame = null; } private sealed class GameplayFrameBinding : IDisposable { private CameraPointerInputController? _owner; private readonly GameplayInputFrameController _expected; public GameplayFrameBinding( CameraPointerInputController owner, GameplayInputFrameController expected) { _owner = owner; _expected = expected; } public void Dispose() => Interlocked.Exchange(ref _owner, null)? .UnbindGameplayFrame(_expected); } public void HandleFocusChanged(bool focused) { if (!focused) _gameplayFrame?.EndMouseLook(); } public void HandleScroll(InputAction action) { float direction = action switch { InputAction.ScrollUp => 1f, InputAction.ScrollDown => -1f, _ => throw new ArgumentOutOfRangeException(nameof(action)), }; if (_playerMode.IsPlayerMode && _camera.IsChaseMode) { if (CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is not null) _chase.Retail.AdjustDistance(-direction * 0.8f); else _chase.Legacy?.AdjustDistance(-direction * 0.8f); } else if (!_camera.IsFlyMode) { _camera.Orbit.Distance = Math.Clamp( _camera.Orbit.Distance - direction * 20f, 50f, 2000f); } } public string AdjustSensitivity(float factor) { string mode; float current; if (_playerMode.IsPlayerMode && _camera.IsChaseMode) { mode = "Chase"; current = _chase.Sensitivity; } else if (_camera.IsFlyMode) { mode = "Fly"; current = _flySensitivity; } else { mode = "Orbit"; current = _orbitSensitivity; } float next = MathF.Min(3f, MathF.Max(0.005f, current * factor)); if (mode == "Chase") _chase.Sensitivity = next; else if (mode == "Fly") _flySensitivity = next; else _orbitSensitivity = next; return $"{mode} sens {next:F3}x"; } /// /// Immediate callback cutoff used before the live-session close. The /// gameplay mouse-look transition is retained until the session has /// retired so it cannot publish one last movement packet. /// public void Deactivate() { Interlocked.Exchange(ref _active, 0); _chase.RmbOrbitHeld = false; } /// Release presentation state only after live-session retirement. public void ReleaseMouseLookAfterSessionRetirement() => _gameplayFrame?.EndMouseLook(); public void Dispose() { Interlocked.Exchange(ref _disposeRequested, 1); Deactivate(); EnsureDetachTransaction().CompleteOrThrow(); } private void OnMouseMoved(Vector2 position) => _quiescence.Invoke(() => ProcessMouseMove(position)); private void ProcessMouseMove(Vector2 position) { if (Volatile.Read(ref _active) == 0) return; if (_capture.WantCaptureMouse) { _pointer.X = position.X; _pointer.Y = position.Y; return; } float dx = position.X - _pointer.X; float dy = position.Y - _pointer.Y; if (_playerMode.IsPlayerMode && _camera.IsChaseMode && _chase.Legacy is not null) { float sensitivity = _chase.Sensitivity; if (_gameplayFrame?.MouseLookActive == true) { _gameplayFrame.QueueRawMouseDelta(dx, dy); } else if (_chase.RmbOrbitHeld) { if (CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is not null) { var (filteredDx, filteredDy) = _chase.Retail.FilterMouseDelta( rawX: dx, rawY: dy, weight: 0.5f, nowSec: _clock.NowSeconds); _chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity; _chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity); } else { _chase.Legacy.YawOffset -= dx * 0.004f * sensitivity; _chase.Legacy.AdjustPitch(dy * 0.003f * sensitivity); } } } else if (_camera.IsFlyMode) { _camera.Fly.Look(dx * _flySensitivity, dy * _flySensitivity); } else if (_mouse.IsHeld(MouseButton.Left)) { _camera.Orbit.Yaw -= dx * 0.005f * _orbitSensitivity; _camera.Orbit.Pitch = Math.Clamp( _camera.Orbit.Pitch + dy * 0.005f * _orbitSensitivity, 0.1f, 1.5f); } _pointer.X = position.X; _pointer.Y = position.Y; } private void OnCameraModeChanged(bool _) => _quiescence.Invoke(ApplyCursorForCameraMode); private void ApplyCursorForCameraMode() { if (Volatile.Read(ref _active) == 0) return; if (_gameplayFrame?.MouseLookActive == true && !_camera.IsChaseMode) _gameplayFrame.EndMouseLook(); _cursor.CursorMode = _camera.IsFlyMode ? CursorMode.Raw : CursorMode.Normal; } private ResourceShutdownTransaction EnsureDetachTransaction() { if (_detach is not null) return _detach; var operations = new List { new("camera mode", RemoveCameraMode), }; for (int i = _surfaces.Count - 1; i >= 0; i--) { int index = i; operations.Add(new ResourceShutdownOperation( $"raw mouse {index}", () => RemoveSurface(index))); } _detach = new ResourceShutdownTransaction( new ResourceShutdownStage("raw pointer callbacks", operations.ToArray())); return _detach; } private void RemoveCameraMode() { if (!_cameraAttached) return; _camera.ModeChanged -= _cameraModeChanged; _cameraAttached = false; } private void RemoveSurface(int index) { if (!_surfaceAttached[index]) return; _surfaces[index].RemoveMouseMove(_mouseMoved); _surfaceAttached[index] = false; } }