diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index d10905cf..8388e1d8 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -20,7 +20,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. `GameWindow` from `RetailLiveFrameCoordinator`. - [x] C — extract streaming-origin convergence, observer selection, dungeon collapse, streaming tick, and rescued-entity rebucketing. -- [ ] D — extract dispatcher/raw-mouse/combat input sampling without changing +- [x] D — extract dispatcher/raw-mouse/combat input sampling without changing movement-command or UI-capture semantics. - [ ] E — extract the complete local-player teleport owner and wire it between the existing post-network liveness and player-mode auto-entry phases. @@ -61,6 +61,19 @@ and sealed/SeenOutside EnvCells, pending recenter, exact rescue rebucketing, GUID deletion, and session reset are pinned; all three corrected-diff reviews are clean. `GameWindow.cs` is now 8,569 raw lines. +Checkpoint D moved semantic hold delivery, movement sampling, autorun, +instant mouse-look, cursor capture, raw filtering/idle handling, and combat +intent into `GameplayInputFrameController` and focused typed owners. Devtools +keyboard capture pauses all gameplay movement without clearing the autorun +latch; retained chat suppresses held keys while autorun continues. Every +focus, teleport, camera/player-mode, reset, and shutdown boundary now releases +mouse-look through one lifecycle path and clears stale RMB orbit state. +Production combat requests, automatic hostile selection, UI-capture probes, +and movement-truth diagnostics no longer retain callbacks into `GameWindow`. +The 2,787-test App suite and complete 7,145-test Release suite pass (5 +fixture/environment skips); all three corrected-diff reviews are clean. +`GameWindow.cs` is now 8,205 raw lines. + ## 1. Outcome and non-goals At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.App/Combat/CombatAttackController.cs index 047aa8b2..34847e89 100644 --- a/src/AcDream.App/Combat/CombatAttackController.cs +++ b/src/AcDream.App/Combat/CombatAttackController.cs @@ -4,6 +4,56 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.App.Combat; +internal interface ICombatAttackOperations +{ + bool CanStartAttack(); + void PrepareAttackRequest(); + bool SendAttack(AttackHeight height, float power); + void SendCancelAttack(); + bool IsDualWield { get; } + bool PlayerReadyForAttack { get; } + bool AutoRepeatAttack { get; } +} + +internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations +{ + private readonly Func _canStartAttack; + private readonly Action _prepareAttackRequest; + private readonly Func _sendAttack; + private readonly Action _sendCancelAttack; + private readonly Func _isDualWield; + private readonly Func _playerReadyForAttack; + private readonly Func _autoRepeatAttack; + + public DelegateCombatAttackOperations( + Func canStartAttack, + Func sendAttack, + Action? prepareAttackRequest, + Action? sendCancelAttack, + Func? isDualWield, + Func? playerReadyForAttack, + Func? autoRepeatAttack) + { + _canStartAttack = canStartAttack + ?? throw new ArgumentNullException(nameof(canStartAttack)); + _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack)); + _prepareAttackRequest = prepareAttackRequest ?? (() => { }); + _sendCancelAttack = sendCancelAttack ?? (() => { }); + _isDualWield = isDualWield ?? (() => false); + _playerReadyForAttack = playerReadyForAttack ?? (() => true); + _autoRepeatAttack = autoRepeatAttack ?? (() => false); + } + + public bool CanStartAttack() => _canStartAttack(); + public void PrepareAttackRequest() => _prepareAttackRequest(); + public bool SendAttack(AttackHeight height, float power) => + _sendAttack(height, power); + public void SendCancelAttack() => _sendCancelAttack(); + public bool IsDualWield => _isDualWield(); + public bool PlayerReadyForAttack => _playerReadyForAttack(); + public bool AutoRepeatAttack => _autoRepeatAttack(); +} + /// /// Owns retail's basic-combat attack request and power-bar state machine. /// UI buttons and keyboard actions both enter through @@ -26,13 +76,7 @@ public sealed class CombatAttackController : IDisposable public const float DesiredPowerStep = 1f / 6f; private readonly CombatState _combat; - private readonly Func _canStartAttack; - private readonly Action _prepareAttackRequest; - private readonly Func _sendAttack; - private readonly Action _sendCancelAttack; - private readonly Func _isDualWield; - private readonly Func _playerReadyForAttack; - private readonly Func _autoRepeatAttack; + private readonly ICombatAttackOperations _operations; private readonly Func _now; private bool _buildInProgress; @@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable Func? playerReadyForAttack = null, Func? autoRepeatAttack = null, Func? now = null) + : this( + combat, + new DelegateCombatAttackOperations( + canStartAttack, + sendAttack, + prepareAttackRequest, + sendCancelAttack, + isDualWield, + playerReadyForAttack, + autoRepeatAttack), + now) + { + } + + internal CombatAttackController( + CombatState combat, + ICombatAttackOperations operations, + Func? now = null) { _combat = combat ?? throw new ArgumentNullException(nameof(combat)); - _canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack)); - _prepareAttackRequest = prepareAttackRequest ?? (() => { }); - _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack)); - _sendCancelAttack = sendCancelAttack ?? (() => { }); - _isDualWield = isDualWield ?? (() => false); - _playerReadyForAttack = playerReadyForAttack ?? (() => true); - _autoRepeatAttack = autoRepeatAttack ?? (() => false); + _operations = operations ?? throw new ArgumentNullException(nameof(operations)); _now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency); _combat.CombatModeChanged += OnCombatModeChanged; @@ -196,7 +252,7 @@ public sealed class CombatAttackController : IDisposable && !_repeatAttacking) return; - _sendCancelAttack(); + _operations.SendCancelAttack(); _repeatAttacking = false; if (_buildInProgress) @@ -219,7 +275,7 @@ public sealed class CombatAttackController : IDisposable if (!_buildInProgress) return; - if (!_playerReadyForAttack()) + if (!_operations.PlayerReadyForAttack) { if (_attackRequestInProgress) { @@ -258,7 +314,7 @@ public sealed class CombatAttackController : IDisposable private void StartAttackRequest() { if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode) - || !_canStartAttack()) + || !_operations.CanStartAttack()) return; // Retail StartAttackRequest (0x0056C040) stores request-in-progress @@ -267,7 +323,7 @@ public sealed class CombatAttackController : IDisposable // player movement object and must run before any later attack send. _attackRequestInProgress = true; _requestedAttackPower = 1f; - _prepareAttackRequest(); + _operations.PrepareAttackRequest(); _currentBuildIsAutomatic = false; AttemptStartBuildingAttack(); } @@ -276,7 +332,7 @@ public sealed class CombatAttackController : IDisposable { if (_buildInProgress || _attackServerResponsePending - || !_playerReadyForAttack()) + || !_operations.PlayerReadyForAttack) return; StartPowerBarBuild(); } @@ -292,7 +348,7 @@ public sealed class CombatAttackController : IDisposable { if (!_buildInProgress) return 0f; - double duration = _isDualWield() + double duration = _operations.IsDualWield ? DualWieldPowerUpSeconds : AttackPowerUpSeconds; return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d); @@ -301,13 +357,15 @@ public sealed class CombatAttackController : IDisposable private void ExecuteAttack(AttackHeight height, bool setServerPending) { StopBuild(); - if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f))) + if (!_operations.SendAttack( + height, + Math.Clamp(_requestedAttackPower, 0f, 1f))) { ResetPowerBar(); return; } - if (_autoRepeatAttack()) + if (_operations.AutoRepeatAttack) _repeatAttacking = true; _attackServerResponsePending = setServerPending; } @@ -330,7 +388,7 @@ public sealed class CombatAttackController : IDisposable _repeatAttacking = false; if (!_attackRequestInProgress - && _autoRepeatAttack() + && _operations.AutoRepeatAttack && _repeatAttacking) { if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f) @@ -338,7 +396,7 @@ public sealed class CombatAttackController : IDisposable ExecuteAttack(RequestedHeight, setServerPending: false); } - if (!_autoRepeatAttack() || !_repeatAttacking) + if (!_operations.AutoRepeatAttack || !_repeatAttacking) { _repeatAttacking = false; ResetPowerBar(); diff --git a/src/AcDream.App/Combat/CombatAttackTargetSource.cs b/src/AcDream.App/Combat/CombatAttackTargetSource.cs new file mode 100644 index 00000000..854698f4 --- /dev/null +++ b/src/AcDream.App/Combat/CombatAttackTargetSource.cs @@ -0,0 +1,116 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.World; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; + +namespace AcDream.App.Combat; + +/// +/// Focused automatic-attack target owner. It shares canonical selection and +/// live-entity state without retaining the broader interaction controller. +/// +internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource +{ + private readonly SelectionState _selection; + private readonly LiveEntityRuntime _liveEntities; + private readonly ClientObjectTable _objects; + private readonly ILocalPlayerIdentitySource _player; + + public CombatAttackTargetSource( + SelectionState selection, + LiveEntityRuntime liveEntities, + ClientObjectTable objects, + ILocalPlayerIdentitySource player) + { + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _liveEntities = liveEntities + ?? throw new ArgumentNullException(nameof(liveEntities)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + } + + public uint? SelectedObjectId => _selection.SelectedObjectId; + + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) + { + if (_selection.SelectedObjectId is { } selected + && IsHostileMonster(selected)) + { + return selected; + } + + if (!autoTarget) + return null; + + (uint Guid, float DistanceSquared)? closest = FindClosestHostileMonster(); + if (closest is not { } best) + { + _selection.Clear(SelectionChangeSource.Keyboard); + return null; + } + + _selection.Select(best.Guid, SelectionChangeSource.Keyboard); + string? name = _objects.Get(best.Guid)?.Name; + string label = string.IsNullOrWhiteSpace(name) + ? $"0x{best.Guid:X8}" + : name; + Console.WriteLine( + $"combat: selected target 0x{best.Guid:X8} {label} dist={MathF.Sqrt(best.DistanceSquared):F1}"); + return best.Guid; + } + + private (uint Guid, float DistanceSquared)? FindClosestHostileMonster() + { + if (!_liveEntities.TryGetWorldEntity( + _player.ServerGuid, + out WorldEntity playerEntity)) + { + return null; + } + + (uint Guid, float DistanceSquared)? best = null; + foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities) + { + if (!IsHostileMonster(guid)) + continue; + + float distanceSquared = Vector3.DistanceSquared( + entity.Position, + playerEntity.Position); + if (best is null || distanceSquared < best.Value.DistanceSquared) + best = (guid, distanceSquared); + } + return best; + } + + private bool IsHostileMonster(uint serverGuid) + { + uint playerGuid = _player.ServerGuid; + if (serverGuid == playerGuid + || !_liveEntities.TryGetInteractionEligibleRecord( + serverGuid, + out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return false; + } + + if (_liveEntities.TryGetAnimationRuntime(entity.Id, out var animation) + && animation.CurrentMotion == MotionCommand.Dead) + { + return false; + } + + ClientObject? candidate = _objects.Get(serverGuid); + return candidate is not null + && (candidate.Type & ItemType.Creature) != 0 + && CombatTargetPolicy.IsHostileMonster( + playerGuid, + _objects.Get(playerGuid), + candidate); + } +} diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs new file mode 100644 index 00000000..0d5ee7ee --- /dev/null +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -0,0 +1,180 @@ +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Combat; + +internal interface ICombatAttackTargetSource +{ + uint? SelectedObjectId { get; } + uint? GetSelectedOrClosestCombatTarget(bool autoTarget); +} + +internal interface ICombatGameplaySettingsSource +{ + bool AutoTarget { get; } + bool AutoRepeatAttack { get; } +} + +internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource +{ + public GameplaySettings Value { get; set; } = GameplaySettings.Default; + public bool AutoTarget => Value.AutoTarget; + public bool AutoRepeatAttack => Value.AutoRepeatAttack; +} + +internal interface ICombatFeedbackSink +{ + void Show(string message); +} + +internal sealed class CombatFeedbackSlot : ICombatFeedbackSink +{ + public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; } + public void Show(string message) => ViewModel?.AddToast(message); +} + +internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations +{ + private ICombatAttackOperations? _owner; + + public void Bind(ICombatAttackOperations owner) + { + ArgumentNullException.ThrowIfNull(owner); + if (_owner is not null && !ReferenceEquals(_owner, owner)) + throw new InvalidOperationException( + "Combat attack operations are already bound."); + _owner = owner; + } + + public bool CanStartAttack() => _owner?.CanStartAttack() == true; + public void PrepareAttackRequest() => _owner?.PrepareAttackRequest(); + public bool SendAttack(AttackHeight height, float power) => + _owner?.SendAttack(height, power) == true; + public void SendCancelAttack() => _owner?.SendCancelAttack(); + public bool IsDualWield => _owner?.IsDualWield == true; + public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true; + public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true; +} + +/// +/// Production combat request dependencies. The attack state machine retains +/// this typed owner rather than callbacks into the application window. +/// +internal sealed class LiveCombatAttackOperations : ICombatAttackOperations +{ + private readonly CombatState _combat; + private readonly ICombatAttackTargetSource _targets; + private readonly ICombatGameplaySettingsSource _settings; + private readonly ILocalPlayerControllerSource _player; + private readonly LocalPlayerOutboundController _outbound; + private readonly ILiveInWorldSource _inWorld; + private readonly ILiveWorldSessionSource _session; + private readonly ICombatFeedbackSink _feedback; + + public LiveCombatAttackOperations( + CombatState combat, + ICombatAttackTargetSource targets, + ICombatGameplaySettingsSource settings, + ILocalPlayerControllerSource player, + LocalPlayerOutboundController outbound, + ILiveInWorldSource inWorld, + ILiveWorldSessionSource session, + ICombatFeedbackSink feedback) + { + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _targets = targets ?? throw new ArgumentNullException(nameof(targets)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); + _inWorld = inWorld ?? throw new ArgumentNullException(nameof(inWorld)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _feedback = feedback ?? throw new ArgumentNullException(nameof(feedback)); + } + + public bool IsDualWield => + _player.Controller?.Motion.InterpretedState.CurrentStyle + == CombatInputPlanner.DualWieldCombatStyle; + + public bool PlayerReadyForAttack + { + get + { + if (_player.Controller is not { } controller) + return false; + var motion = controller.Motion.InterpretedState; + return CombatInputPlanner.PlayerInReadyPositionForAttack( + _combat.CurrentMode, + motion.CurrentStyle, + motion.ForwardCommand); + } + } + + public bool AutoRepeatAttack => _settings.AutoRepeatAttack; + + public bool CanStartAttack() + { + if (!_inWorld.IsInWorld) + return false; + + if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)) + { + _feedback.Show("Enter melee or missile combat first"); + Console.WriteLine( + "combat: attack ignored; not in melee/missile combat mode"); + return false; + } + + if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null) + { + _feedback.Show("No monster target"); + Console.WriteLine("combat: attack ignored; no creature target found"); + return false; + } + + return true; + } + + public bool SendAttack(AttackHeight height, float power) + { + if (!CanStartAttack() + || _session.CurrentSession is not { } session + || _targets.SelectedObjectId is not { } target) + { + return false; + } + + power = Math.Clamp(power, 0f, 1f); + if (_combat.CurrentMode == CombatMode.Missile) + { + session.SendMissileAttack(target, height, power); + Console.WriteLine( + $"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}"); + } + else + { + session.SendMeleeAttack(target, height, power); + Console.WriteLine( + $"combat: melee attack target=0x{target:X8} height={height} power={power:F2}"); + } + return true; + } + + public void SendCancelAttack() => + _session.CurrentSession?.SendCancelAttack(); + + public void PrepareAttackRequest() + { + if (_player.Controller is not { } controller + || !controller.PrepareForAttackRequest()) + { + return; + } + + _outbound.TrySendMovement( + _session.CurrentSession, + controller, + controller.CaptureMovementResult(mouseLookEvent: false)); + } +} diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs new file mode 100644 index 00000000..c5207ab7 --- /dev/null +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -0,0 +1,83 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface IMovementInputSource +{ + MovementInput Capture(); +} + +/// +/// Owns held movement sampling and the retail autorun latch. The input +/// dispatcher remains the only keyboard/mouse-button state source. +/// +internal sealed class DispatcherMovementInputSource : IMovementInputSource +{ + private readonly IInputCaptureSource? _capture; + private InputDispatcher? _dispatcher; + + public DispatcherMovementInputSource(IInputCaptureSource? capture = null) => + _capture = capture; + + public bool AutoRunActive { get; private set; } + + public void Bind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher)) + throw new InvalidOperationException( + "The movement input source is already bound to another dispatcher."); + _dispatcher = dispatcher; + } + + public MovementInput Capture() + { + // Devtools owns the whole gameplay keyboard while active, including + // a latched autorun. Retained chat owns physical key state only; + // retail's autorun latch continues until an explicit cancel action. + if (_capture?.DevToolsWantCaptureKeyboard == true) + return default; + + if (_dispatcher is not { } dispatcher) + return default; + + bool walking = dispatcher.IsActionHeld(InputAction.MovementWalkMode); + bool forward = dispatcher.IsActionHeld(InputAction.MovementForward); + return new MovementInput( + Forward: forward || AutoRunActive, + Backward: dispatcher.IsActionHeld(InputAction.MovementBackup), + StrafeLeft: dispatcher.IsActionHeld(InputAction.MovementStrafeLeft), + StrafeRight: dispatcher.IsActionHeld(InputAction.MovementStrafeRight), + TurnLeft: dispatcher.IsActionHeld(InputAction.MovementTurnLeft), + TurnRight: dispatcher.IsActionHeld(InputAction.MovementTurnRight), + Run: !walking, + Jump: dispatcher.IsActionHeld(InputAction.MovementJump)); + } + + /// + /// Applies the press-only autorun policy at the same point in the semantic + /// action pipeline as the former GameWindow body. + /// + /// True when the action is fully consumed. + public bool HandlePressedAction(InputAction action) + { + if (action == InputAction.MovementRunLock) + { + AutoRunActive = !AutoRunActive; + return true; + } + + if (AutoRunActive && action is ( + InputAction.MovementBackup + or InputAction.MovementStop + or InputAction.MovementStrafeLeft + or InputAction.MovementStrafeRight)) + { + AutoRunActive = false; + } + + return false; + } + + public void ResetSession() => AutoRunActive = false; +} diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs new file mode 100644 index 00000000..35bf16c6 --- /dev/null +++ b/src/AcDream.App/Input/GameplayInputFrameController.cs @@ -0,0 +1,87 @@ +using AcDream.App.Combat; +using AcDream.App.Update; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface ICombatInputFrameController +{ + void Tick(); + void HandleMovementInput(InputAction action, ActivationType activation); + bool HandleInputAction(InputAction action, ActivationType activation); +} + +internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameController +{ + private readonly CombatAttackController _owner; + + public CombatAttackInputFrameAdapter(CombatAttackController owner) => + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + public void Tick() => _owner.Tick(); + + public void HandleMovementInput(InputAction action, ActivationType activation) => + _owner.HandleMovementInput(action, activation); + + public bool HandleInputAction(InputAction action, ActivationType activation) => + _owner.HandleInputAction(action, activation); +} + +/// +/// Owns the complete pre-object input frame: held semantic dispatch, one raw +/// mouse-look sample, and combat attack intent. +/// +internal sealed class GameplayInputFrameController : IGameplayInputFramePhase +{ + private readonly InputDispatcher? _dispatcher; + private readonly DispatcherMovementInputSource _movement; + private readonly IMouseLookInputFrameController? _mouseLook; + private readonly ICombatInputFrameController _combat; + + public GameplayInputFrameController( + InputDispatcher? dispatcher, + DispatcherMovementInputSource movement, + IMouseLookInputFrameController? mouseLook, + ICombatInputFrameController combat) + { + _dispatcher = dispatcher; + _movement = movement ?? throw new ArgumentNullException(nameof(movement)); + _mouseLook = mouseLook; + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + } + + public bool MouseLookActive => _mouseLook?.Active == true; + + public void Tick(UpdateFrameTiming timing) + { + _ = timing; + _dispatcher?.Tick(); + _mouseLook?.Tick(); + _combat.Tick(); + } + + public bool HandlePointerAction(InputAction action, ActivationType activation) => + _mouseLook?.HandlePointerAction(action, activation) == true; + + public bool HandleCombatAction(InputAction action, ActivationType activation) + { + // ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0 aborts + // automatic combat before the movement command enters the interpreter. + _combat.HandleMovementInput(action, activation); + return _combat.HandleInputAction(action, activation); + } + + public bool HandlePressedMovementAction(InputAction action) => + _movement.HandlePressedAction(action); + + public void QueueRawMouseDelta(float dx, float dy) => + _mouseLook?.QueueRawDelta(dx, dy); + + public void EndMouseLook() => _mouseLook?.EndForLifecycle(); + + public void ResetSession() + { + _mouseLook?.ResetSession(); + _movement.ResetSession(); + } +} diff --git a/src/AcDream.App/Input/InputCaptureSources.cs b/src/AcDream.App/Input/InputCaptureSources.cs new file mode 100644 index 00000000..7ec77fe5 --- /dev/null +++ b/src/AcDream.App/Input/InputCaptureSources.cs @@ -0,0 +1,74 @@ +using AcDream.App.UI; + +namespace AcDream.App.Input; + +internal interface IInputCaptureSource +{ + bool WantCaptureMouse { get; } + bool WantCaptureKeyboard { get; } + bool DevToolsWantCaptureKeyboard { get; } +} + +internal sealed class DevToolsInputCaptureSource +{ + private readonly bool _enabled; + + public DevToolsInputCaptureSource(bool enabled) => _enabled = enabled; + + public bool WantCaptureMouse => + _enabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse; + + public bool WantCaptureKeyboard => + _enabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; +} + +internal sealed class RetainedUiInputCaptureSlot +{ + public UiRoot? Root { get; set; } + + public bool WantCaptureMouse => Root?.WantsMouse ?? false; + public bool WantCaptureKeyboard => Root?.WantsKeyboard ?? false; +} + +internal sealed class CompositeInputCaptureSource : IInputCaptureSource +{ + private readonly DevToolsInputCaptureSource _devTools; + private readonly RetainedUiInputCaptureSlot _retained; + + public CompositeInputCaptureSource( + DevToolsInputCaptureSource devTools, + RetainedUiInputCaptureSlot retained) + { + _devTools = devTools ?? throw new ArgumentNullException(nameof(devTools)); + _retained = retained ?? throw new ArgumentNullException(nameof(retained)); + } + + public bool WantCaptureMouse => + _devTools.WantCaptureMouse || _retained.WantCaptureMouse; + + public bool WantCaptureKeyboard => + DevToolsWantCaptureKeyboard || _retained.WantCaptureKeyboard; + + public bool DevToolsWantCaptureKeyboard => + _devTools.WantCaptureKeyboard; +} + +internal sealed class DelegateInputCaptureSource : IInputCaptureSource +{ + private readonly Func _wantCaptureMouse; + private readonly Func _wantCaptureKeyboard; + + public DelegateInputCaptureSource( + Func wantCaptureMouse, + Func wantCaptureKeyboard) + { + _wantCaptureMouse = wantCaptureMouse + ?? throw new ArgumentNullException(nameof(wantCaptureMouse)); + _wantCaptureKeyboard = wantCaptureKeyboard + ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard)); + } + + public bool WantCaptureMouse => _wantCaptureMouse(); + public bool WantCaptureKeyboard => _wantCaptureKeyboard(); + public bool DevToolsWantCaptureKeyboard => false; +} diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs index a0b8c6a1..7adc7903 100644 --- a/src/AcDream.App/Input/LocalPlayerOutboundController.cs +++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs @@ -11,13 +11,18 @@ namespace AcDream.App.Input; /// public sealed class LocalPlayerOutboundController { - private readonly Action _diagnostic; + private readonly IMovementTruthDiagnosticSink _diagnostic; + + internal LocalPlayerOutboundController(IMovementTruthDiagnosticSink diagnostic) + { + _diagnostic = diagnostic + ?? throw new ArgumentNullException(nameof(diagnostic)); + } public LocalPlayerOutboundController( Action diagnostic) { - _diagnostic = diagnostic - ?? throw new ArgumentNullException(nameof(diagnostic)); + _diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic); } /// @@ -107,7 +112,7 @@ public sealed class LocalPlayerOutboundController teleportSequence: session.TeleportSequence, forcePositionSequence: session.ForcePositionSequence, lastContact: contactByte); - _diagnostic( + _diagnostic.OnOutbound( "AP", sequence, movement, @@ -193,7 +198,7 @@ public sealed class LocalPlayerOutboundController forcePositionSequence: session.ForcePositionSequence, contact: contactByte != 0, standingLongjump: false); - _diagnostic( + _diagnostic.OnOutbound( "MTS", sequence, movement, diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs new file mode 100644 index 00000000..69c9efbe --- /dev/null +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -0,0 +1,262 @@ +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; + } +} + +/// +/// The current chase-camera input targets. GameWindow keeps compatibility +/// properties over this one slot until checkpoint F moves camera ownership. +/// +internal sealed class ChaseCameraInputState +{ + public ChaseCamera? Legacy { get; set; } + public RetailChaseCamera? Retail { get; set; } + public float Sensitivity { get; set; } = 0.15f; + public bool RmbOrbitHeld { get; set; } +} + +/// +/// Owns instant mouse-look state, raw-sample timing/filtering, movement +/// transition sends, and cursor capture across every exit path. +/// +internal sealed class MouseLookController : IMouseLookInputFrameController +{ + private readonly IMouseSource _mouseSource; + private readonly IPointerPositionSource _pointer; + private readonly ILocalPlayerModeSource _playerMode; + private readonly ILocalPlayerControllerSource _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, + ILocalPlayerControllerSource 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(); + + PlayerMovementController? controller = _playerController.Controller; + if (controller is not null && 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; + } + + /// + /// Session teardown releases presentation capture without emitting a + /// movement packet into the ending session, matching the prior reset edge. + /// + 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()); +} diff --git a/src/AcDream.App/Input/MovementTruthDiagnosticController.cs b/src/AcDream.App/Input/MovementTruthDiagnosticController.cs new file mode 100644 index 00000000..1ed2a296 --- /dev/null +++ b/src/AcDream.App/Input/MovementTruthDiagnosticController.cs @@ -0,0 +1,161 @@ +using System.Numerics; +using AcDream.Core.Net; + +namespace AcDream.App.Input; + +internal interface IMovementTruthDiagnosticSink +{ + void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte); + + void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition); + + void ResetSession(); +} + +internal sealed class MovementTruthDiagnosticController + : IMovementTruthDiagnosticSink +{ + private readonly bool _enabled; + private readonly ILocalPlayerControllerSource _player; + private readonly ILocalPlayerIdentitySource _identity; + private MovementTruthOutbound? _lastOutbound; + + private readonly record struct MovementTruthOutbound( + string Kind, + uint Sequence, + DateTime TimeUtc, + Vector3 LocalWorldPosition, + Vector3 WirePosition, + uint WireCellId, + bool IsOnGround, + byte ContactByte, + Vector3 Velocity); + + public MovementTruthDiagnosticController( + bool enabled, + ILocalPlayerControllerSource player, + ILocalPlayerIdentitySource identity) + { + _enabled = enabled; + _player = player ?? throw new ArgumentNullException(nameof(player)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + } + + public void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte) + { + if (!_enabled) + return; + + Vector3 velocity = _player.Controller?.BodyVelocity ?? Vector3.Zero; + _lastOutbound = new MovementTruthOutbound( + kind, + sequence, + DateTime.UtcNow, + result.Position, + wirePosition, + wireCellId, + result.IsOnGround, + contactByte, + velocity); + + Console.WriteLine(FormattableString.Invariant( + $"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}")); + } + + public void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition) + { + if (!_enabled || update.Guid != _identity.ServerGuid) + return; + + DateTime now = DateTime.UtcNow; + PlayerMovementController? controller = _player.Controller; + Vector3? localPosition = controller?.Position; + uint? localCellId = controller?.CellId; + Vector3? deltaLocal = localPosition.HasValue + ? serverWorldPosition - localPosition.Value + : null; + + string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-"; + string localCellText = localCellId.HasValue + ? FormattableString.Invariant($"0x{localCellId.Value:X8}") + : "-"; + string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-"; + string deltaLocalLength = deltaLocal.HasValue + ? FormattableString.Invariant($"{deltaLocal.Value.Length():F3}") + : "-"; + + string lastText = "-"; + if (_lastOutbound is { } last) + { + Vector3 deltaOut = serverWorldPosition - last.LocalWorldPosition; + double ageMs = (now - last.TimeUtc).TotalMilliseconds; + lastText = FormattableString.Invariant( + $"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}"); + } + + string state = controller?.State.ToString() ?? "-"; + string velocityText = update.Velocity.HasValue + ? Fmt(update.Velocity.Value) + : "-"; + + Console.WriteLine(FormattableString.Invariant( + $"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLength} serverVel={velocityText} state={state} lastOut={lastText}")); + } + + public void ResetSession() => _lastOutbound = null; + + private static string Fmt(Vector3 value) => + FormattableString.Invariant( + $"({value.X:F3},{value.Y:F3},{value.Z:F3})"); + + private static string FmtCmd(uint? command) => + command.HasValue + ? FormattableString.Invariant($"0x{command.Value:X8}") + : "-"; +} + +internal sealed class DelegateMovementTruthDiagnosticSink + : IMovementTruthDiagnosticSink +{ + private readonly Action + _outbound; + + public DelegateMovementTruthDiagnosticSink( + Action outbound) => + _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); + + public void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte) => + _outbound(kind, sequence, result, wirePosition, wireCellId, contactByte); + + public void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition) + { + } + + public void ResetSession() + { + } +} diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs index 51bb8fd7..a5599bdc 100644 --- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -16,7 +16,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram { private readonly Func _canPresentPlayer; private readonly Func _getController; - private readonly Func _captureInput; + private readonly IMovementInputSource _movementInput; private readonly Func _resolveLocalEntityId; private readonly Action _handleTargeting; private readonly Func _isHidden; @@ -53,7 +53,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram public RetailLocalPlayerFrameController( Func canPresentPlayer, Func getController, - Func captureInput, + IMovementInputSource movementInput, Func resolveLocalEntityId, Action handleTargeting, Func isHidden, @@ -67,8 +67,8 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram ?? throw new ArgumentNullException(nameof(canPresentPlayer)); _getController = getController ?? throw new ArgumentNullException(nameof(getController)); - _captureInput = captureInput - ?? throw new ArgumentNullException(nameof(captureInput)); + _movementInput = movementInput + ?? throw new ArgumentNullException(nameof(movementInput)); _resolveLocalEntityId = resolveLocalEntityId ?? throw new ArgumentNullException(nameof(resolveLocalEntityId)); _handleTargeting = handleTargeting @@ -134,7 +134,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram MovementResult movement = hidden ? controller.TickHidden(deltaSeconds, _handleTargeting) - : controller.Update(deltaSeconds, _captureInput(), _handleTargeting); + : controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting); _project(controller, movement, hidden); _sendPreNetwork(controller, movement, hidden); diff --git a/src/AcDream.App/Input/SilkMouseSource.cs b/src/AcDream.App/Input/SilkMouseSource.cs index 3669189f..39e29487 100644 --- a/src/AcDream.App/Input/SilkMouseSource.cs +++ b/src/AcDream.App/Input/SilkMouseSource.cs @@ -11,18 +11,15 @@ namespace AcDream.App.Input; /// . /// /// -/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the -/// constructor takes two delegates so the App.Rendering layer can -/// proxy ImGui.GetIO().WantCaptureMouse via whichever ImGui -/// package is currently active without leaking the type onto the -/// abstraction interface. +/// Production receives a typed capture source, keeping retained-UI and +/// developer-UI ownership outside the Silk adapter. The public delegate +/// overload remains as a compatibility seam for standalone consumers. /// /// public sealed class SilkMouseSource : IMouseSource { private readonly IMouse _mouse; - private readonly Func _wantCaptureMouse; - private readonly Func _wantCaptureKeyboard; + private readonly IInputCaptureSource _capture; private float _lastX; private float _lastY; private bool _haveLastPos; @@ -32,10 +29,21 @@ public sealed class SilkMouseSource : IMouseSource public event Action? MouseMove; public event Action? Scroll; - /// Caller-supplied probe for the current modifier mask. Reused - /// from the keyboard source so mouse events carry consistent modifier - /// state. - public Func ModifierProbe { get; set; } = () => ModifierMask.None; + /// The keyboard source that owns the current modifier mask, so + /// mouse events and keyboard events resolve identical chords. + public IKeyboardSource? ModifierSource { get; set; } + + internal SilkMouseSource( + IMouse mouse, + IInputCaptureSource capture, + IKeyboardSource modifierSource) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + ModifierSource = modifierSource + ?? throw new ArgumentNullException(nameof(modifierSource)); + Subscribe(); + } public SilkMouseSource( IMouse mouse, @@ -43,11 +51,16 @@ public sealed class SilkMouseSource : IMouseSource Func wantCaptureKeyboard) { _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); - _wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse)); - _wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard)); + _capture = new DelegateInputCaptureSource( + wantCaptureMouse, + wantCaptureKeyboard); + Subscribe(); + } - _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe()); - _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe()); + private void Subscribe() + { + _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers()); + _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers()); _mouse.MouseMove += (_, pos) => { float dx, dy; @@ -69,8 +82,11 @@ public sealed class SilkMouseSource : IMouseSource _mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y); } + private ModifierMask ReadModifiers() => + ModifierSource?.CurrentModifiers ?? ModifierMask.None; + public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button); - public bool WantCaptureMouse => _wantCaptureMouse(); - public bool WantCaptureKeyboard => _wantCaptureKeyboard(); + public bool WantCaptureMouse => _capture.WantCaptureMouse; + public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard; } diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs index 48121eb7..1482777c 100644 --- a/src/AcDream.App/Net/ILiveInWorldSource.cs +++ b/src/AcDream.App/Net/ILiveInWorldSource.cs @@ -4,3 +4,8 @@ internal interface ILiveInWorldSource { bool IsInWorld { get; } } + +internal interface ILiveWorldSessionSource +{ + AcDream.Core.Net.WorldSession? CurrentSession { get; } +} diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index 1f956f21..5e0ebebf 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -161,7 +161,8 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase, - ILiveInWorldSource + ILiveInWorldSource, + ILiveWorldSessionSource { private sealed class SessionScope( WorldSession session, diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 96f9c50b..69d10dfd 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -58,8 +58,7 @@ internal sealed class LiveEntityNetworkUpdateController private readonly Func _session; private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly Action _aimTeleportDestination; - private readonly Action - _dumpMovementTruthServerEcho; + private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics; private PlayerMovementController? _playerController => _playerControllerSource(); private EntityPhysicsHost? _playerHost => _playerHostSource(); @@ -106,7 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController Func session, Action publishTimestamps, Action aimTeleportDestination, - Action dumpMovementTruthServerEcho) + IMovementTruthDiagnosticSink movementTruthDiagnostics) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); @@ -140,7 +139,8 @@ internal sealed class LiveEntityNetworkUpdateController liveEntities, publishTimestamps); _aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination)); - _dumpMovementTruthServerEcho = dumpMovementTruthServerEcho ?? throw new ArgumentNullException(nameof(dumpMovementTruthServerEcho)); + _movementTruthDiagnostics = movementTruthDiagnostics + ?? throw new ArgumentNullException(nameof(movementTruthDiagnostics)); } internal void ResetSessionState() => _authorityGate.ResetSessionState(); @@ -1106,7 +1106,7 @@ internal sealed class LiveEntityNetworkUpdateController var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition ? entity.Rotation : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); - _dumpMovementTruthServerEcho(update, worldPos); + _movementTruthDiagnostics.OnServerEcho(update, worldPos); bool remoteHardTeleport = update.Guid != _playerServerGuid && timestamps.TeleportHookRequired; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b02100d8..018c1b79 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -34,8 +34,17 @@ public sealed class GameWindow : IDisposable private CameraController? _cameraController; private IMouse? _capturedMouse; private IDatReaderWriter? _dats; - private float _lastMouseX; - private float _lastMouseY; + private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new(); + private float _lastMouseX + { + get => _pointerPosition.X; + set => _pointerPosition.X = value; + } + private float _lastMouseY + { + get => _pointerPosition.Y; + set => _pointerPosition.Y = value; + } private Shader? _meshShader; private TextureCache? _textureCache; /// Phase N.4+: WB-backed rendering pipeline adapter. Always non-null @@ -156,6 +165,8 @@ public sealed class GameWindow : IDisposable private LiveEntityAnimationScheduler _liveAnimationScheduler = null!; private LiveEntityAnimationPresenter _animationPresenter = null!; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; + private readonly AcDream.App.Input.MovementTruthDiagnosticController + _movementTruthDiagnostics; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!; @@ -434,6 +445,10 @@ public sealed class GameWindow : IDisposable // Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.UiHost? _uiHost; private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime; + private readonly AcDream.App.Combat.CombatAttackOperationsSlot + _combatAttackOperations = new(); + private readonly AcDream.App.Combat.CombatFeedbackSlot + _combatFeedback = new(); private AcDream.App.Combat.CombatAttackController? _combatAttackController; private AcDream.App.Combat.CombatTargetController? _combatTargetController; private AcDream.App.UI.ItemInteractionController? _itemInteractionController; @@ -452,11 +467,8 @@ public sealed class GameWindow : IDisposable // _panelHost does. Self-subscribes to CombatState in its ctor, so // disposing isn't required (panel host holds the only ref). private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm; - // DevToolsEnabled + DumpMoveTruthEnabled now read through _options - // (RuntimeOptions.DevTools / DumpMoveTruth). Kept the same names for - // local readability via expression-bodied properties. + // DevToolsEnabled reads through typed RuntimeOptions. private bool DevToolsEnabled => _options.DevTools; - private bool DumpMoveTruthEnabled => _options.DumpMoveTruth; // Slice 3: the reset transaction remains cached by the host, while the // lifecycle controller owns each exact event/command/session generation. @@ -530,8 +542,17 @@ public sealed class GameWindow : IDisposable get => _playerControllerSlot.Controller; set => _playerControllerSlot.Controller = value; } - private AcDream.App.Rendering.ChaseCamera? _chaseCamera; - private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera; + private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new(); + private AcDream.App.Rendering.ChaseCamera? _chaseCamera + { + get => _chaseCameraInput.Legacy; + set => _chaseCameraInput.Legacy = value; + } + private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera + { + get => _chaseCameraInput.Retail; + set => _chaseCameraInput.Retail = value; + } private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new(); private bool _playerMode { @@ -549,21 +570,8 @@ public sealed class GameWindow : IDisposable private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; - private MovementTruthOutbound? _lastMovementTruthOutbound; private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); - private readonly record struct MovementTruthOutbound( - string Kind, - uint Sequence, - System.DateTime TimeUtc, - System.Numerics.Vector3 LocalWorldPosition, - uint LocalCellId, - System.Numerics.Vector3 WirePosition, - uint WireCellId, - bool IsOnGround, - byte ContactByte, - System.Numerics.Vector3 Velocity); - // K-fix7 (2026-04-26): server-authoritative Run + Jump skill values // received from PlayerDescription. -1 = "not yet received, fall back // to the controller's default (env-var or hardcoded 200/300)". @@ -577,36 +585,25 @@ public sealed class GameWindow : IDisposable // Phase D.2b-C — live character-sheet assembly + raise flow (extracted // feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider; - // K.1b: this field is RESERVED — written when entering / leaving player - // mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now - // never consumed by MovementInput (mouse never drives character yaw — - // K.1b regression-prevention). Kept around as plumbing for the future - // K.2 MMB-mouse-look path which will re-enable mouse → character-yaw - // when MMB is held. The pragma silences the dead-write warning until K.2 - // wires the read-side back in. -#pragma warning disable CS0414 // assigned but never used — see comment above - private float _playerMouseDeltaX; -#pragma warning restore CS0414 - // Mouse sensitivity multipliers — one per camera mode because the visual // feel is very different. Adjust via F8 / F9 for whichever mode is // currently active. Chase default is low because the character + camera // rotating together is overwhelming at fly speeds. - private float _sensChase = 0.15f; + private float _sensChase + { + get => _chaseCameraInput.Sensitivity; + set => _chaseCameraInput.Sensitivity = value; + } private float _sensFly = 1.0f; private float _sensOrbit = 1.0f; // Right-mouse-button held → free-orbit the chase camera around the // player without turning the character. Release leaves the camera at // the orbited position (no snap back). - private bool _rmbHeld; - - // K-fix1 (2026-04-26): autorun is a TOGGLE — Press Q to start - // forward-running, press Q again (or any movement-cancel key like - // X / S / Backward / Forward) to stop. Mirrors retail's - // AutoRun action. While true, MovementInput.Forward is forced - // true regardless of W's state. - private bool _autoRunActive; + private bool _rmbHeld + { + get => _chaseCameraInput.RmbOrbitHeld; + } // Phase K.2 — auto-enter player mode after a successful login. Armed // by ApplyLiveSessionEnteredWorld; ticked from @@ -617,21 +614,6 @@ public sealed class GameWindow : IDisposable // the bool here. private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry; - // Phase K.2 — MMB-hold instant mouse-look state. Live throughout - // the session; flips Active on Press/Release. Defense-in-depth on - // ImGui's WantCaptureMouse — the dispatcher already filters, but - // OnWantCaptureMouseChanged also suspends the state if a panel - // claims focus mid-hold. - private AcDream.UI.Abstractions.Input.MouseLookState? _mouseLook; - // Tracks the previous WantCaptureMouse value so we can fire the - // changed-edge callback once per transition (vs every frame). - private bool _lastWantCaptureMouse; - // Cursor mode prior to entering MMB mouse-look. Restored on - // release so the user lands back in the same camera mode as - // before (raw for chase/fly, normal for orbit). Set non-null while - // mouse-look is active. - private Silk.NET.Input.CursorMode? _mouseLookSavedCursorMode; - // Phase K.1b — single input path. Every keyboard/mouse-button reaction // flows through InputDispatcher.Fired (see OnInputAction below) or // IsActionHeld (per-frame polling for movement). The legacy direct @@ -641,6 +623,11 @@ public sealed class GameWindow : IDisposable private AcDream.App.Input.SilkKeyboardSource? _kbSource; private AcDream.App.Input.SilkMouseSource? _mouseSource; private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; + private readonly AcDream.App.Input.RetainedUiInputCaptureSlot _retainedInputCapture; + private readonly AcDream.App.Input.CompositeInputCaptureSource _inputCapture; + private readonly AcDream.App.Input.DispatcherMovementInputSource _movementInput; + private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor; + private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame; // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, // falling back to the retail-faithful defaults if the file is missing // or corrupt. This is THE single source of truth for the keymap at @@ -750,6 +737,12 @@ public sealed class GameWindow : IDisposable AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + _retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot(); + _inputCapture = new AcDream.App.Input.CompositeInputCaptureSource( + new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools), + _retainedInputCapture); + _movementInput = new AcDream.App.Input.DispatcherMovementInputSource( + _inputCapture); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; @@ -785,16 +778,17 @@ public sealed class GameWindow : IDisposable _liveEntities?.RebucketLiveEntity(serverGuid, landblockId), isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection, suspendShadow: SuspendLocalPlayerShadow); + _movementTruthDiagnostics = + new AcDream.App.Input.MovementTruthDiagnosticController( + options.DumpMoveTruth, + _playerControllerSlot, + _localPlayerIdentity); _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController( - DumpMovementTruthOutbound); + _movementTruthDiagnostics); _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController( canPresentPlayer: CanAdvanceLocalPlayer, getController: () => _playerController, - captureInput: () => - { - _playerMouseDeltaX = 0f; - return CaptureMovementInput(); - }, + movementInput: _movementInput, resolveLocalEntityId: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) ? entity.Id @@ -904,30 +898,16 @@ public sealed class GameWindow : IDisposable _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); _mouseSource = new AcDream.App.Input.SilkMouseSource( firstMouse, - wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) - || (_uiHost?.Root.WantsMouse ?? false), - wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard) - || (_uiHost?.Root.WantsKeyboard ?? false)); - _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; + _inputCapture, + _kbSource); _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); + _movementInput.Bind(_inputDispatcher); + _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); _inputDispatcher.Fired += OnInputAction; Combat.CombatModeChanged += SetInputCombatScope; SetInputCombatScope(Combat.CurrentMode); - // Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / - // 0x00458310): the callback submits a filtered horizontal - // adjustment to the player's MotionInterpreter. It must never - // mutate visible Yaw directly; doing so leaves ACE's authoritative - // heading stale and makes targeted attacks skip their server turn. - _mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState( - applyHorizontalAdjustment: adjustment => - { - _playerController?.SubmitMouseTurnAdjustment( - adjustment, - CaptureMovementInput()); - }); - // Phase K.2 — auto-enter player mode after EnterWorld // succeeds. Predicates close over GameWindow state; the // entry callback flips into player mode via the same code @@ -975,12 +955,12 @@ public sealed class GameWindow : IDisposable if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) { float sens = _sensChase; - if (_mouseLook is not null && _mouseLook.Active) + if (_gameplayInputFrame?.MouseLookActive == true) { // DirectInput may deliver several device records before // one retail GetInput pass. Accumulate raw motion here; // the update loop filters the aggregate exactly once. - _mouseLook.QueueDelta(dx, dy); + _gameplayInputFrame.QueueRawMouseDelta(dx, dy); } else if (_rmbHeld) { @@ -1269,6 +1249,7 @@ public sealed class GameWindow : IDisposable // it fires, so the chase camera doesn't snap on top of // the fly camera mid-inspection. _debugVm.ToggleFlyMode = ToggleFlyOrChase; + _combatFeedback.ViewModel = _debugVm; _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); _panelHost.Register(_debugPanel); @@ -1439,6 +1420,7 @@ public sealed class GameWindow : IDisposable _panelHost = null; _vitalsVm = null; _vitalsPanel = null; + _combatFeedback.ViewModel = null; _debugVm = null; _debugPanel = null; _chatPanel = null; @@ -1626,14 +1608,7 @@ public sealed class GameWindow : IDisposable // semantics even when the retail renderer is disabled. _combatAttackController = new AcDream.App.Combat.CombatAttackController( Combat, - CanStartLiveCombatAttack, - SendLiveCombatAttack, - prepareAttackRequest: PreparePlayerForAttackRequest, - sendCancelAttack: () => LiveSession?.SendCancelAttack(), - isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle - == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle, - playerReadyForAttack: IsPlayerReadyForCombatAttack, - autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack); + _combatAttackOperations); _combatTargetController = new AcDream.App.Combat.CombatTargetController( Combat, _selection, @@ -1702,6 +1677,7 @@ public sealed class GameWindow : IDisposable { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + _retainedInputCapture.Root = _uiHost.Root; _uiHost.Root.UiLocked = _persistedGameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, @@ -1732,7 +1708,7 @@ public sealed class GameWindow : IDisposable accountName: () => LiveSession?.Characters?.AccountName ?? _options.LiveUser ?? string.Empty, - stopCompletely: PreparePlayerForAttackRequest, + stopCompletely: _combatAttackOperations.PrepareAttackRequest, sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId), sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId), displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), @@ -2631,7 +2607,7 @@ public sealed class GameWindow : IDisposable () => LiveSession, PublishLocalPhysicsTimestamps, AimTeleportDestination, - DumpMovementTruthServerEcho); + _movementTruthDiagnostics); _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( _liveEntities, () => _playerServerGuid, @@ -2652,6 +2628,41 @@ public sealed class GameWindow : IDisposable // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. _liveWorldOrigin.SetPlaceholder(centerX, centerY); _liveSessionController = new AcDream.App.Net.LiveSessionController(); + _combatAttackOperations.Bind( + new AcDream.App.Combat.LiveCombatAttackOperations( + Combat, + new AcDream.App.Combat.CombatAttackTargetSource( + _selection, + _liveEntities!, + Objects, + _localPlayerIdentity), + _gameplaySettings, + _playerControllerSlot, + _localPlayerOutbound, + _liveSessionController, + _liveSessionController, + _combatFeedback)); + AcDream.App.Input.MouseLookController? mouseLookController = + _mouseSource is not null && _mouseLookCursor is not null + ? new AcDream.App.Input.MouseLookController( + _mouseSource, + _pointerPosition, + _localPlayerMode, + _playerControllerSlot, + _cameraController!, + _chaseCameraInput, + _movementInput, + _localPlayerOutbound, + _liveSessionController, + _mouseLookCursor, + new AcDream.App.Input.EnvironmentInputMonotonicClock()) + : null; + _gameplayInputFrame = new AcDream.App.Input.GameplayInputFrameController( + _inputDispatcher, + _movementInput, + mouseLookController, + new AcDream.App.Input.CombatAttackInputFrameAdapter( + _combatAttackController!)); _streamingFrame = new AcDream.App.Streaming.StreamingFrameController( _options.LiveMode, _localPlayerMode, @@ -2801,12 +2812,7 @@ public sealed class GameWindow : IDisposable }); private void ResetSessionMouseCapture() - { - bool wasActive = _mouseLook?.Active == true; - _mouseLook?.Release(); - if (wasActive || _mouseLookSavedCursorMode.HasValue) - RestoreCursorAfterMouseLook(); - } + => _gameplayInputFrame?.ResetSession(); private void ResetSessionPlayerPresentation() { @@ -2822,10 +2828,7 @@ public sealed class GameWindow : IDisposable _playerHost = null; _chaseCamera = null; _retailChaseCamera = null; - _playerMouseDeltaX = 0f; - _rmbHeld = false; - _autoRunActive = false; - _lastMovementTruthOutbound = null; + _movementTruthDiagnostics.ResetSession(); _localPlayerShadow.Clear(); _spawnClaimRangeMemo = null; } @@ -3583,7 +3586,7 @@ public sealed class GameWindow : IDisposable || !_teleportTransit.CanBegin(teleportSequence)) return; - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); // A fresh sequence is a new logical transit. Discard the prior // destination and presentation before this sequence can observe them; // its destination PositionUpdate may arrive on a later network tick. @@ -3856,49 +3859,7 @@ public sealed class GameWindow : IDisposable // Input callbacks feed the current object tick. Packets already read // by Core.Net remain queued until the retail object/physics phase has // consumed that input and published its final pose. - _inputDispatcher?.Tick(); - - // Phase K.2 — mouse-look is an input source for this object's - // movement tick, so sample it before the retail CPhysics/network - // barrier just like held keyboard actions. - if (_mouseLook is not null) - { - bool wantCaptureMouse = IsUiCapturingMouse(); - if (wantCaptureMouse != _lastWantCaptureMouse) - { - if (wantCaptureMouse && _mouseLook.Active) - EndMouseLookAndRestoreCursor(); - _mouseLook.OnWantCaptureMouseChanged(wantCaptureMouse); - _lastWantCaptureMouse = wantCaptureMouse; - } - - float nowSeconds = (float)(Environment.TickCount64 / 1000.0); - if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY)) - { - // GetInput synthesizes (0,0) only after >0.2 s idle. - // MouseLookHandler stops drift before filtering that sample; - // the filter tail may then start a smaller Rotate again. - if (rawX == 0f && rawY == 0f) - _playerController?.StopMouseDrift(CaptureMovementInput()); - - (float filteredX, float filteredY) = - AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera - && _retailChaseCamera is not null - ? _retailChaseCamera.FilterMouseDelta( - rawX, - rawY, - weight: 0.5f, - nowSec: nowSeconds) - : (rawX, rawY); - _mouseLook.ApplyDelta(filteredX, _sensChase); - if (_retailChaseCamera is not null) - _retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase); - else - _chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase); - } - } - - _combatAttackController?.Tick(); + _gameplayInputFrame!.Tick(frameTiming); // Drain pending live-session traffic AFTER streaming so any incoming // CreateObject events find their landblock already loaded in @@ -4043,10 +4004,7 @@ public sealed class GameWindow : IDisposable // character yaw (regression-prevention per K.1b plan §D); // MouseDeltaX is hardcoded 0f. RMB held still pans the chase // camera (handled in the mouse-move handler via _rmbHeld). - // The _playerMouseDeltaX field is preserved as plumbing for the - // future MMB-mouse-look behavior coming back in K.2. if (_inputDispatcher is null) return; - _playerMouseDeltaX = 0f; // defensive: ensure no leakage even if some path writes it // Retail-style held-key offset integration. Only active when // retail chase is selected; legacy camera ignores these. @@ -4069,7 +4027,7 @@ public sealed class GameWindow : IDisposable // walk speed. // * Q = AUTORUN TOGGLE: pressing Q latches forward-running // until Q is pressed again. Handled in OnInputAction; here - // we just OR _autoRunActive into the Forward flag. + // DispatcherMovementInputSource owns the autorun latch. // * Mouse never drives character yaw (K.1b regression-prevention). if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame)) return; @@ -4128,82 +4086,12 @@ public sealed class GameWindow : IDisposable && _inputDispatcher is not null && !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); - private void DumpMovementTruthOutbound( - string kind, - uint sequence, - AcDream.App.Input.MovementResult result, - System.Numerics.Vector3 wirePosition, - uint wireCellId, - byte contactByte) - { - if (!DumpMoveTruthEnabled) return; - - var velocity = _playerController?.BodyVelocity ?? System.Numerics.Vector3.Zero; - _lastMovementTruthOutbound = new MovementTruthOutbound( - kind, - sequence, - System.DateTime.UtcNow, - result.Position, - result.CellId, - wirePosition, - wireCellId, - result.IsOnGround, - contactByte, - velocity); - - Console.WriteLine(System.FormattableString.Invariant($"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}")); - } - - private void DumpMovementTruthServerEcho( - AcDream.Core.Net.WorldSession.EntityPositionUpdate update, - System.Numerics.Vector3 serverWorldPosition) - { - if (!DumpMoveTruthEnabled || update.Guid != _playerServerGuid) return; - - var now = System.DateTime.UtcNow; - var localPosition = _playerController?.Position; - var localCellId = _playerController?.CellId; - var deltaLocal = localPosition.HasValue - ? serverWorldPosition - localPosition.Value - : (System.Numerics.Vector3?)null; - - string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-"; - string localCellText = localCellId.HasValue - ? System.FormattableString.Invariant($"0x{localCellId.Value:X8}") - : "-"; - string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-"; - string deltaLocalLen = deltaLocal.HasValue - ? System.FormattableString.Invariant($"{deltaLocal.Value.Length():F3}") - : "-"; - - string lastText = "-"; - if (_lastMovementTruthOutbound is { } last) - { - var deltaOut = serverWorldPosition - last.LocalWorldPosition; - var ageMs = (now - last.TimeUtc).TotalMilliseconds; - lastText = System.FormattableString.Invariant($"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}"); - } - - string state = _playerController?.State.ToString() ?? "-"; - string velocityText = update.Velocity.HasValue ? Fmt(update.Velocity.Value) : "-"; - - Console.WriteLine(System.FormattableString.Invariant($"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLen} serverVel={velocityText} state={state} lastOut={lastText}")); - } - - private static string Fmt(System.Numerics.Vector3 v) => - System.FormattableString.Invariant($"({v.X:F3},{v.Y:F3},{v.Z:F3})"); - - private static string FmtCmd(uint? command) => - command.HasValue - ? System.FormattableString.Invariant($"0x{command.Value:X8}") - : "-"; - private void OnCameraModeChanged(bool _modeBool) { - if (_mouseLook?.Active == true + if (_gameplayInputFrame?.MouseLookActive == true && _cameraController?.IsChaseMode != true) { - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); } if (_input is null) return; @@ -4218,8 +4106,7 @@ public sealed class GameWindow : IDisposable // the only correct gate. Cursor visible by default in chase / // orbit modes; Raw cursor only in fly mode (continuous // look-and-fly affordance). Mouse-look (raw mode) when MMB is - // held is handled separately by HideCursorForMouseLook / - // RestoreCursorAfterMouseLook. + // held is handled separately by the gameplay input owner. bool needsRawCursor = _cameraController?.IsFlyMode == true; mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal; _capturedMouse = needsRawCursor ? mouse : null; @@ -6805,8 +6692,13 @@ public sealed class GameWindow : IDisposable = AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default; private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio = AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default; + private readonly AcDream.App.Combat.GameplaySettingsState + _gameplaySettings = new(); private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay - = AcDream.UI.Abstractions.Panels.Settings.GameplaySettings.Default; + { + get => _gameplaySettings.Value; + set => _gameplaySettings.Value = value; + } private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat = AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default; private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter @@ -7111,61 +7003,14 @@ public sealed class GameWindow : IDisposable // chords also fire Press on key-down + Release on key-up; we // ignore the in-between Hold ticks here (the mouse-move handler // checks _rmbHeld each frame anyway). - if (action == AcDream.UI.Abstractions.Input.InputAction.AcdreamRmbOrbitHold) - { - if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press) - _rmbHeld = _playerMode && _cameraController?.IsChaseMode == true; - else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release) - _rmbHeld = false; + if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true) return; - } // Phase K.2 — MMB-hold instant mouse-look. Press hides the // cursor + activates yaw drive; release restores. WantCapture // edge handling lives in OnUpdate; only Press needs to read it // for the initial gate (defense in depth — the dispatcher // already filters on WantCaptureMouse in OnMouseDown). - if (action == AcDream.UI.Abstractions.Input.InputAction.CameraInstantMouseLook) - { - if (_mouseLook is null) return; - if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press) - { - if (!_playerMode - || _cameraController?.IsChaseMode != true - || _playerController is not { State: AcDream.App.Input.PlayerState.InWorld }) - return; - - bool wcm = IsUiCapturingMouse(); - float nowSeconds = (float)(Environment.TickCount64 / 1000.0); - _mouseLook.Press(_lastMouseX, _lastMouseY, wcm, nowSeconds); - if (_mouseLook.Active) - { - if (_playerController is not { } controller - || !controller.BeginMouseLook(CaptureMovementInput())) - { - // Keep capture ownership atomic with the movement - // transition. A portal/state change between the outer - // gate and this call must not leave a hidden cursor - // with no active movement owner. - _mouseLook.Release(); - return; - } - - // ToggleMouseLook calls SendMovementEvent at the input - // boundary. This also preserves down+up events that occur - // before the next physics update. - TrySendPlayerMovementEvent( - controller.CaptureMovementResult(mouseLookEvent: false)); - HideCursorForMouseLook(); - } - } - else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release) - { - EndMouseLookAndRestoreCursor(); - } - return; - } - // ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every // wheel tick. Press is the only activation type for wheel. if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp @@ -7180,12 +7025,10 @@ public sealed class GameWindow : IDisposable // combat before the movement command enters CommandInterpreter. This // notification does not consume the input; the movement owner below // still receives it normally. - _combatAttackController?.HandleMovementInput(action, activation); - // Retail attack actions consume their full transition stream: key-down // starts the power build and key-up commits it. Handle them before the // generic Press-only gate below. - if (_combatAttackController?.HandleInputAction(action, activation) == true) + if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true) return; // Every other action fires on Press only (no Release / Hold side- @@ -7212,22 +7055,8 @@ public sealed class GameWindow : IDisposable // — retail-faithful: any deliberate movement input wins. (Pressing // Forward AGAIN does NOT cancel — retail's autorun stays active // even when you press W; the two stack.) - if (action == AcDream.UI.Abstractions.Input.InputAction.MovementRunLock) - { - _autoRunActive = !_autoRunActive; + if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true) return; - } - if (_autoRunActive - && (action == AcDream.UI.Abstractions.Input.InputAction.MovementBackup - || action == AcDream.UI.Abstractions.Input.InputAction.MovementStop - || action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft - || action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight)) - { - _autoRunActive = false; - // Fall through — these actions still need their normal handling - // (e.g. Stop is currently a no-op in the switch, but keeping the - // fall-through means future logic fires). - } switch (action) { @@ -7313,7 +7142,7 @@ public sealed class GameWindow : IDisposable _cameraController.ToggleFly(); // exit fly, release cursor else if (_playerMode) { - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); _playerMode = false; _cameraController?.ExitChaseMode(); _playerController = null; @@ -7347,97 +7176,10 @@ public sealed class GameWindow : IDisposable _debugVm?.AddToast(text); } - private bool IsPlayerReadyForCombatAttack() - { - if (_playerController is null) - return false; - var motion = _playerController.Motion.InterpretedState; - return AcDream.Core.Combat.CombatInputPlanner.PlayerInReadyPositionForAttack( - Combat.CurrentMode, - motion.CurrentStyle, - motion.ForwardCommand); - } - - private bool CanStartLiveCombatAttack() - { - if (_liveSessionController?.IsInWorld != true) - return false; - - if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)) - { - _debugVm?.AddToast("Enter melee or missile combat first"); - Console.WriteLine("combat: attack ignored; not in melee/missile combat mode"); - return false; - } - - uint? target = GetSelectedOrClosestCombatTarget(); - if (target is null) - { - _debugVm?.AddToast("No monster target"); - Console.WriteLine("combat: attack ignored; no creature target found"); - return false; - } - - return true; - } - - private bool SendLiveCombatAttack( - AcDream.Core.Combat.AttackHeight height, - float power) - { - if (!CanStartLiveCombatAttack()) - return false; - - AcDream.Core.Net.WorldSession? session = LiveSession; - if (session is null) - return false; - - uint target = _selection.SelectedObjectId!.Value; - power = Math.Clamp(power, 0f, 1f); - if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile) - { - session.SendMissileAttack(target, height, power); - Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}"); - } - else - { - session.SendMeleeAttack(target, height, power); - Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}"); - } - return true; - } - - /// - /// Retail ClientCombatSystem::StartAttackRequest (0x0056C040) - /// calls MaybeStopCompletely, whose successful path sends the - /// stopped movement state synchronously. Doing this in the host preserves - /// wire ordering: ACE sees the final player heading before the later - /// targeted attack request. - /// - private void PreparePlayerForAttackRequest() - { - if (_playerController is not { } controller - || !controller.PrepareForAttackRequest()) - return; - - TrySendPlayerMovementEvent( - controller.CaptureMovementResult(mouseLookEvent: false)); - } - internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState( AcDream.App.Input.MovementResult result) => AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result); - private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) - => _localPlayerOutbound.TrySendMovement( - LiveSession, - _playerController, - result); - - private uint? GetSelectedOrClosestCombatTarget() - => _selectionInteractions?.GetSelectedOrClosestCombatTarget( - _persistedGameplay.AutoTarget); - /// /// Resolves retail's combat-camera target. ClientCombatSystem:: /// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only @@ -7531,12 +7273,11 @@ public sealed class GameWindow : IDisposable } else { - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); _cameraController?.ExitChaseMode(); _playerController = null; _chaseCamera = null; _retailChaseCamera = null; - _playerMouseDeltaX = 0f; } } @@ -8009,11 +7750,6 @@ public sealed class GameWindow : IDisposable Aspect = _window!.Size.X / (float)_window.Size.Y, CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine), }; - // K.1b: _playerMouseDeltaX is no longer consumed by - // MovementInput, but we still reset it here so any stale - // accumulated value from a previous session doesn't leak - // into a future code path that re-enables mouse-yaw. - _playerMouseDeltaX = 0f; _cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera); // K-fix1 (2026-04-26): latch the "we have entered chase at least // once" flag so the live-mode pre-login render gate stops @@ -8024,103 +7760,6 @@ public sealed class GameWindow : IDisposable return true; } - /// - /// Both presentation stacks participate in the same gameplay-input gate. - /// A retained retail panel capturing the pointer must suspend instant - /// mouse-look just as an ImGui developer panel does. - /// - private bool IsUiCapturingMouse() - => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) - || (_uiHost?.Root.WantsMouse ?? false); - - private AcDream.App.Input.MovementInput CaptureMovementInput() - { - if (_inputDispatcher is null) - return default; - - bool walking = _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementWalkMode); - bool forward = _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementForward); - return new AcDream.App.Input.MovementInput( - Forward: forward || _autoRunActive, - Backward: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementBackup), - StrafeLeft: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft), - StrafeRight: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight), - TurnLeft: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), - TurnRight: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), - Run: !walking, - Jump: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementJump)); - } - - /// - /// Single idempotent teardown for every mouse-look exit path. Retail sends - /// the stopped movement state synchronously; doing so before nulling the - /// player controller preserves ACE's final authoritative heading. - /// - private void EndMouseLookAndRestoreCursor() - { - bool stateWasActive = _mouseLook?.Active == true; - _mouseLook?.Release(); - - if (_playerController is { } controller - && controller.EndMouseLook(CaptureMovementInput())) - { - TrySendPlayerMovementEvent( - controller.CaptureMovementResult(mouseLookEvent: false)); - } - - if (stateWasActive || _mouseLookSavedCursorMode.HasValue) - RestoreCursorAfterMouseLook(); - } - - /// - /// Phase K.2: hide the system cursor while MMB instant mouse-look is - /// held. Saves the previous CursorMode so - /// can put it back exactly. Skips when no mouse / no input — tests - /// and headless runs stay clean. - /// - private void HideCursorForMouseLook() - { - if (_input is null) return; - var mouse = _input.Mice.FirstOrDefault(); - if (mouse is null) return; - // Save previous mode (Normal in orbit, Raw in chase/fly) so the - // exact pre-hold mode is restored on release. - _mouseLookSavedCursorMode = mouse.Cursor.CursorMode; - mouse.Cursor.CursorMode = CursorMode.Hidden; - } - - /// - /// Phase K.2: restore the saved cursor mode after MMB instant - /// mouse-look ends. Called from the Release branch and from the - /// WantCaptureMouse-edge suspend path so the cursor never gets - /// stuck hidden. - /// - private void RestoreCursorAfterMouseLook() - { - if (_input is null) return; - var mouse = _input.Mice.FirstOrDefault(); - if (mouse is null) return; - if (_mouseLookSavedCursorMode is { } saved) - { - mouse.Cursor.CursorMode = saved; - _mouseLookSavedCursorMode = null; - } - else - { - // Defense in depth: never observed the saved value, fall - // back to Normal so the user always gets a visible cursor. - mouse.Cursor.CursorMode = CursorMode.Normal; - } - } - /// /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies /// the currently-active mode's sensitivity (chase / fly / orbit) by the @@ -8389,11 +8028,12 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("session dependents", [ - new("mouse capture", EndMouseLookAndRestoreCursor), + new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()), new("retail UI", () => { _retailUiRuntime?.Dispose(); _retailUiRuntime = null; + _retainedInputCapture.Root = null; _uiHost = null; }), new("combat target", () => @@ -8540,7 +8180,7 @@ public sealed class GameWindow : IDisposable private void OnFocusChanged(bool focused) { if (!focused) - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); } public void Dispose() diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 4c9dec74..2c5bd267 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -220,9 +220,9 @@ public sealed class InputDispatcher if (action == InputAction.None) return false; // While a text field owns the keyboard ("write mode"), held game actions read as // released: typing "swd" must not move the character. This is the polling-path twin - // of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven - // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at - // the call site) keeps driving the character, so chat doesn't cancel autorun. + // of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys; + // the App movement owner separately pauses all movement for devtools capture while + // allowing retained-chat capture to preserve the retail autorun latch. if (_mouse.WantCaptureKeyboard) return false; if (_automationHeldActions.Contains(action)) return true; foreach (var b in _bindings.ForAction(action)) diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs b/tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs new file mode 100644 index 00000000..80c3b5ea --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs @@ -0,0 +1,209 @@ +using System.Numerics; +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Combat; + +public sealed class CombatAttackTargetSourceTests +{ + private const uint Player = 0x5000_0001u; + + [Fact] + public void ExplicitSelectedHostileIsAcceptedWithoutAutoTarget() + { + var harness = new Harness(); + const uint target = 0x7000_0001u; + harness.Add(target, new Vector3(2f, 0f, 0f), attackable: true); + harness.Selection.Select(target, SelectionChangeSource.Keyboard); + + Assert.Equal( + target, + harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false)); + } + + [Fact] + public void AutoTargetUsesNearestEligibleLiveHostile() + { + var harness = new Harness(); + const uint valid = 0x7000_0010u; + harness.Add(valid, new Vector3(8f, 0f, 0f), attackable: true); + harness.Add(0x7000_0011u, new Vector3(2f, 0f, 0f), attackable: false); + WorldEntity dead = harness.Add( + 0x7000_0012u, + new Vector3(3f, 0f, 0f), + attackable: true); + harness.Runtime.SetAnimationRuntime( + dead.ServerGuid, + new Animation(dead, MotionCommand.Dead)); + WorldEntity hidden = harness.Add( + 0x7000_0013u, + new Vector3(4f, 0f, 0f), + attackable: true); + Assert.True(harness.Runtime.TryApplyState( + new SetState.Parsed( + hidden.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden), + InstanceSequence: 1, + StateSequence: 2), + out _)); + WorldEntity pending = harness.Add( + 0x7000_0014u, + new Vector3(5f, 0f, 0f), + attackable: true); + Assert.True(harness.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid)); + + uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget( + autoTarget: true); + + Assert.Equal(valid, selected); + Assert.Equal(valid, harness.Selection.SelectedObjectId); + } + + [Fact] + public void MissingAutoTargetClearsAnInvalidSelection() + { + var harness = new Harness(); + const uint nonHostile = 0x7000_0020u; + harness.Add(nonHostile, Vector3.UnitX, attackable: false); + harness.Selection.Select(nonHostile, SelectionChangeSource.Keyboard); + + Assert.Null(harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: true)); + + Assert.Null(harness.Selection.SelectedObjectId); + } + + private sealed class Harness + { + public ClientObjectTable Objects { get; } = new(); + public SelectionState Selection { get; } = new(); + public LiveEntityRuntime Runtime { get; } + public CombatAttackTargetSource Targets { get; } + + public Harness() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101_FFFFu, + new LandBlock(), + Array.Empty())); + Runtime = new LiveEntityRuntime(spatial, new Resources()); + var identity = new LocalPlayerIdentityState { ServerGuid = Player }; + Targets = new CombatAttackTargetSource( + Selection, + Runtime, + Objects, + identity); + Add(Player, Vector3.Zero, attackable: false, isPlayer: true); + } + + public WorldEntity Add( + uint guid, + Vector3 position, + bool attackable, + bool isPlayer = false) + { + Runtime.RegisterLiveEntity(Spawn(guid)); + WorldEntity entity = Runtime.MaterializeLiveEntity( + guid, + 0x0101_0001u, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = position, + Rotation = Quaternion.Identity, + Scale = 1f, + MeshRefs = [], + })!; + uint flags = attackable ? SelectedObjectHealthPolicy.BfAttackable : 0u; + if (isPlayer) + flags |= SelectedObjectHealthPolicy.BfPlayer; + Objects.AddOrUpdate(new ClientObject + { + ObjectId = guid, + Name = $"Object {guid:X8}", + Type = ItemType.Creature, + PublicWeenieBitfield = flags, + }); + return entity; + } + } + + private sealed record Animation(WorldEntity Entity, uint CurrentMotion) + : ILiveEntityAnimationRuntime; + + private sealed class Resources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) + { + } + public void Unregister(WorldEntity entity) + { + } + } + + private static WorldSession.EntitySpawn Spawn(uint guid) + { + var position = new CreateObject.ServerPosition( + 0x0101_0001u, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x0200_0001u, + MotionTableId: 0x0900_0001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1)); + return new WorldSession.EntitySpawn( + guid, + position, + 0x0200_0001u, + [], + [], + [], + null, + null, + "fixture", + null, + null, + 0x0900_0001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs new file mode 100644 index 00000000..41109816 --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs @@ -0,0 +1,151 @@ +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.Core.Combat; +using AcDream.Core.Net; + +namespace AcDream.App.Tests.Combat; + +public sealed class LiveCombatAttackOperationsTests +{ + [Fact] + public void UnboundSlotFailsClosedAndRejectsASecondOwner() + { + var slot = new CombatAttackOperationsSlot(); + var first = new FakeOperations(); + var second = new FakeOperations(); + + Assert.False(slot.CanStartAttack()); + Assert.False(slot.SendAttack(AttackHeight.Medium, 0.5f)); + Assert.False(slot.PlayerReadyForAttack); + + slot.Bind(first); + slot.Bind(first); + Assert.Throws(() => slot.Bind(second)); + } + + [Fact] + public void CanStartRequiresLiveWorldBeforeTargetResolution() + { + Harness harness = CreateHarness(); + harness.Targets.Acquired = 0x1234u; + + Assert.False(harness.Owner.CanStartAttack()); + + Assert.Equal(0, harness.Targets.ResolveCount); + } + + [Fact] + public void UnsupportedCombatModeUsesTypedFeedbackSink() + { + Harness harness = CreateHarness(inWorld: true); + + Assert.False(harness.Owner.CanStartAttack()); + + Assert.Equal(["Enter melee or missile combat first"], harness.Feedback.Messages); + Assert.Equal(0, harness.Targets.ResolveCount); + } + + [Fact] + public void SupportedModeResolvesTargetUsingLiveAutoTargetSetting() + { + Harness harness = CreateHarness(inWorld: true); + harness.Combat.SetCombatMode(CombatMode.Melee); + harness.Settings.AutoTarget = true; + harness.Targets.Acquired = 0x1234u; + harness.Targets.SelectedObjectId = 0x1234u; + + Assert.True(harness.Owner.CanStartAttack()); + + Assert.True(harness.Targets.LastAutoTarget); + Assert.Equal(1, harness.Targets.ResolveCount); + Assert.Empty(harness.Feedback.Messages); + } + + [Fact] + public void MissingTargetPreservesRetailUserFeedback() + { + Harness harness = CreateHarness(inWorld: true); + harness.Combat.SetCombatMode(CombatMode.Missile); + + Assert.False(harness.Owner.CanStartAttack()); + + Assert.Equal(["No monster target"], harness.Feedback.Messages); + } + + private static Harness CreateHarness(bool inWorld = false) + { + var combat = new CombatState(); + var targets = new FakeTargets(); + var settings = new FakeSettings(); + var player = new LocalPlayerControllerSlot(); + var live = new FakeLiveSource { IsInWorld = inWorld }; + var feedback = new FakeFeedback(); + var outbound = new LocalPlayerOutboundController( + static (_, _, _, _, _, _) => { }); + var owner = new LiveCombatAttackOperations( + combat, + targets, + settings, + player, + outbound, + live, + live, + feedback); + return new Harness(owner, combat, targets, settings, feedback); + } + + private sealed record Harness( + LiveCombatAttackOperations Owner, + CombatState Combat, + FakeTargets Targets, + FakeSettings Settings, + FakeFeedback Feedback); + + private sealed class FakeTargets : ICombatAttackTargetSource + { + public uint? SelectedObjectId { get; set; } + public uint? Acquired { get; set; } + public int ResolveCount { get; private set; } + public bool LastAutoTarget { get; private set; } + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) + { + ResolveCount++; + LastAutoTarget = autoTarget; + return Acquired; + } + } + + private sealed class FakeSettings : ICombatGameplaySettingsSource + { + public bool AutoTarget { get; set; } + public bool AutoRepeatAttack { get; set; } + } + + private sealed class FakeLiveSource : ILiveInWorldSource, ILiveWorldSessionSource + { + public bool IsInWorld { get; set; } + public WorldSession? CurrentSession => null; + } + + private sealed class FakeFeedback : ICombatFeedbackSink + { + public List Messages { get; } = []; + public void Show(string message) => Messages.Add(message); + } + + private sealed class FakeOperations : ICombatAttackOperations + { + public bool CanStartAttack() => true; + public void PrepareAttackRequest() + { + } + public bool SendAttack(AttackHeight height, float power) => true; + public void SendCancelAttack() + { + } + public bool IsDualWield => true; + public bool PlayerReadyForAttack => true; + public bool AutoRepeatAttack => true; + } +} diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs new file mode 100644 index 00000000..ceee9f13 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs @@ -0,0 +1,152 @@ +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class DispatcherMovementInputSourceTests +{ + [Fact] + public void UnboundSourceCapturesNeutralInput() + { + var source = new DispatcherMovementInputSource(); + + Assert.Equal(default, source.Capture()); + } + + [Fact] + public void CapturesDispatcherHeldStateAndRetailWalkModifier() + { + var (dispatcher, _, _) = CreateDispatcher(); + var source = new DispatcherMovementInputSource(); + source.Bind(dispatcher); + + Assert.True(dispatcher.TrySetAutomationActionHeld( + InputAction.MovementForward, + held: true)); + Assert.True(dispatcher.TrySetAutomationActionHeld( + InputAction.MovementWalkMode, + held: true)); + + MovementInput captured = source.Capture(); + + Assert.True(captured.Forward); + Assert.False(captured.Run); + } + + [Fact] + public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun() + { + var (dispatcher, _, mouse) = CreateDispatcher(); + var source = new DispatcherMovementInputSource(); + source.Bind(dispatcher); + dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true); + Assert.True(source.HandlePressedAction(InputAction.MovementRunLock)); + + mouse.WantCaptureKeyboard = true; + MovementInput captured = source.Capture(); + + Assert.True(captured.Forward); + Assert.True(source.AutoRunActive); + + source.HandlePressedAction(InputAction.MovementRunLock); + captured = source.Capture(); + Assert.False(captured.Forward); + } + + [Fact] + public void DevToolsKeyboardCapturePausesAutorunWithoutClearingItsLatch() + { + var capture = new FakeCapture(); + var (dispatcher, _, _) = CreateDispatcher(); + var source = new DispatcherMovementInputSource(capture); + source.Bind(dispatcher); + source.HandlePressedAction(InputAction.MovementRunLock); + + capture.DevToolsWantCaptureKeyboard = true; + Assert.Equal(default, source.Capture()); + Assert.True(source.AutoRunActive); + + capture.DevToolsWantCaptureKeyboard = false; + Assert.True(source.Capture().Forward); + } + + [Theory] + [InlineData(InputAction.MovementBackup)] + [InlineData(InputAction.MovementStop)] + [InlineData(InputAction.MovementStrafeLeft)] + [InlineData(InputAction.MovementStrafeRight)] + public void RetailCancelActionsClearAutorun(InputAction action) + { + var source = new DispatcherMovementInputSource(); + source.HandlePressedAction(InputAction.MovementRunLock); + + Assert.False(source.HandlePressedAction(action)); + + Assert.False(source.AutoRunActive); + } + + [Fact] + public void ForwardDoesNotCancelAutorunAndResetDoes() + { + var source = new DispatcherMovementInputSource(); + source.HandlePressedAction(InputAction.MovementRunLock); + + Assert.False(source.HandlePressedAction(InputAction.MovementForward)); + Assert.True(source.AutoRunActive); + + source.ResetSession(); + Assert.False(source.AutoRunActive); + } + + [Fact] + public void BindingIsIdempotentOnlyForTheSameDispatcher() + { + var source = new DispatcherMovementInputSource(); + var (first, _, _) = CreateDispatcher(); + var (second, _, _) = CreateDispatcher(); + + source.Bind(first); + source.Bind(first); + + Assert.Throws(() => source.Bind(second)); + } + + private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse) + CreateDispatcher() + { + var keyboard = new FakeKeyboard(); + var mouse = new FakeMouse(); + return (new InputDispatcher(keyboard, mouse, new KeyBindings()), keyboard, mouse); + } + + private sealed class FakeKeyboard : IKeyboardSource + { +#pragma warning disable CS0067 + public event Action? KeyDown; + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => false; + public ModifierMask CurrentModifiers => ModifierMask.None; + } + + private sealed class FakeMouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool WantCaptureKeyboard { get; set; } + public bool WantCaptureMouse { get; set; } + public bool IsHeld(MouseButton button) => false; + } + + private sealed class FakeCapture : IInputCaptureSource + { + public bool WantCaptureMouse { get; set; } + public bool WantCaptureKeyboard { get; set; } + public bool DevToolsWantCaptureKeyboard { get; set; } + } +} diff --git a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs new file mode 100644 index 00000000..ee4e984c --- /dev/null +++ b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs @@ -0,0 +1,194 @@ +using AcDream.App.Input; +using AcDream.App.Update; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class GameplayInputFrameControllerTests +{ + [Fact] + public void TickPreservesDispatcherMouseLookCombatOrder() + { + var calls = new List(); + var keyboard = new HeldKeyboard(Key.W); + var mouseSource = new NullMouse(); + var bindings = new KeyBindings(); + bindings.Add(new Binding( + new KeyChord(Key.W, ModifierMask.None), + InputAction.MovementForward, + ActivationType.Hold)); + var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings); + keyboard.Press(Key.W); + dispatcher.Fired += (_, activation) => + { + if (activation == ActivationType.Hold) + calls.Add("dispatcher"); + }; + var mouseLook = new FakeMouseLook(calls); + var combat = new FakeCombat(calls); + var controller = new GameplayInputFrameController( + dispatcher, + new DispatcherMovementInputSource(), + mouseLook, + combat); + + controller.Tick(new UpdateFrameTiming(1d / 60d, 1f / 60f, 1d / 60d)); + + Assert.Equal(["dispatcher", "mouse-look", "combat"], calls); + } + + [Fact] + public void MissingInputDevicesStillTicksCombat() + { + var calls = new List(); + var controller = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook: null, + new FakeCombat(calls)); + + controller.Tick(new UpdateFrameTiming(0d, 0f, 0d)); + + Assert.Equal(["combat"], calls); + } + + [Fact] + public void CombatActionFirstAbortsAutomaticAttackThenRoutesAction() + { + var calls = new List(); + var controller = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook: null, + new FakeCombat(calls, consumes: true)); + + bool consumed = controller.HandleCombatAction( + InputAction.MovementForward, + ActivationType.Press); + + Assert.True(consumed); + Assert.Equal(["combat-movement", "combat-action"], calls); + } + + [Fact] + public void ResetSessionReleasesMouseLookAndAutorun() + { + var calls = new List(); + var movement = new DispatcherMovementInputSource(); + movement.HandlePressedAction(InputAction.MovementRunLock); + var mouseLook = new FakeMouseLook(calls); + var controller = new GameplayInputFrameController( + dispatcher: null, + movement, + mouseLook, + new FakeCombat(calls)); + + controller.ResetSession(); + + Assert.False(movement.AutoRunActive); + Assert.Equal(["mouse-reset"], calls); + } + + [Fact] + public void PointerAndRawDeltaRemainOwnedByMouseLookController() + { + var calls = new List(); + var mouseLook = new FakeMouseLook(calls, active: true, consumes: true); + var controller = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook, + new FakeCombat(calls)); + + Assert.True(controller.MouseLookActive); + Assert.True(controller.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press)); + controller.QueueRawMouseDelta(3f, -2f); + controller.EndMouseLook(); + + Assert.Equal(["mouse-action", "mouse-delta", "mouse-lifecycle-end"], calls); + } + + private sealed class FakeCombat : ICombatInputFrameController + { + private readonly List _calls; + private readonly bool _consumes; + + public FakeCombat(List calls, bool consumes = false) + { + _calls = calls; + _consumes = consumes; + } + + public void Tick() => _calls.Add("combat"); + public void HandleMovementInput(InputAction action, ActivationType activation) => + _calls.Add("combat-movement"); + public bool HandleInputAction(InputAction action, ActivationType activation) + { + _calls.Add("combat-action"); + return _consumes; + } + } + + private sealed class FakeMouseLook : IMouseLookInputFrameController + { + private readonly List _calls; + private readonly bool _consumes; + + public FakeMouseLook( + List calls, + bool active = false, + bool consumes = false) + { + _calls = calls; + _consumes = consumes; + Active = active; + } + + public bool Active { get; } + public void Tick() => _calls.Add("mouse-look"); + public bool HandlePointerAction(InputAction action, ActivationType activation) + { + _calls.Add("mouse-action"); + return _consumes; + } + public void QueueRawDelta(float dx, float dy) => _calls.Add("mouse-delta"); + public void EndAndRestoreCursor() => _calls.Add("mouse-end"); + public void EndForLifecycle() => _calls.Add("mouse-lifecycle-end"); + public void ResetSession() => _calls.Add("mouse-reset"); + } + + private sealed class HeldKeyboard : IKeyboardSource + { + private readonly HashSet _held = []; + + public HeldKeyboard(params Key[] held) + { + foreach (Key key in held) + _held.Add(key); + } + + public event Action? KeyDown; +#pragma warning disable CS0067 + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => _held.Contains(key); + public ModifierMask CurrentModifiers => ModifierMask.None; + public void Press(Key key) => KeyDown?.Invoke(key, ModifierMask.None); + } + + private sealed class NullMouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } +} diff --git a/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs b/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs new file mode 100644 index 00000000..6c6e2889 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs @@ -0,0 +1,309 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Rendering; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class MouseLookControllerTests +{ + [Fact] + public void InstantMouseLookRequiresPlayerChaseAndInWorldState() + { + Harness harness = CreateHarness(); + + Assert.True(harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press)); + Assert.False(harness.Owner.Active); + + harness.Mode.IsPlayerMode = true; + harness.EnterChase(); + harness.Player.State = PlayerState.PortalSpace; + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press); + Assert.False(harness.Owner.Active); + + harness.Player.State = PlayerState.InWorld; + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press); + + Assert.True(harness.Owner.Active); + Assert.Equal(1, harness.Cursor.HideCount); + } + + [Fact] + public void ReleaseEndsMouseLookAndRestoresCursorExactlyOnce() + { + Harness harness = CreateActiveHarness(); + + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Release); + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Release); + + Assert.False(harness.Owner.Active); + Assert.Equal(1, harness.Cursor.RestoreCount); + Assert.False(harness.Player.EndMouseLook(new MovementInput())); + } + + [Fact] + public void UiCaptureTransitionEndsActiveMouseLook() + { + Harness harness = CreateActiveHarness(); + + harness.Mouse.WantCaptureMouse = true; + harness.Owner.Tick(); + + Assert.False(harness.Owner.Active); + Assert.Equal(1, harness.Cursor.RestoreCount); + Assert.False(harness.Player.EndMouseLook(new MovementInput())); + } + + [Fact] + public void SessionResetRestoresCaptureAndClearsRmbOrbitWithoutWireSend() + { + Harness harness = CreateActiveHarness(); + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.True(harness.Chase.RmbOrbitHeld); + + harness.Owner.ResetSession(); + + Assert.False(harness.Owner.Active); + Assert.False(harness.Chase.RmbOrbitHeld); + Assert.Equal(1, harness.Cursor.RestoreCount); + Assert.Null(harness.Session.CurrentSession); + } + + [Fact] + public void SixRawSamplesCrossRetailExtentGateAndSubmitTurnMotion() + { + Harness harness = CreateActiveHarness(); + + for (int i = 0; i < 6; i++) + { + harness.Clock.NowSeconds += 0.01f; + harness.Owner.QueueRawDelta(2f, 0f); + harness.Owner.Tick(); + } + + MovementResult result = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput()); + Assert.NotNull(result.TurnCommand); + } + + [Fact] + public void StrictIdleBoundaryStopsMouseDriftOnlyAfterPointTwoSeconds() + { + bool previousRetailCamera = CameraDiagnostics.UseRetailChaseCamera; + CameraDiagnostics.UseRetailChaseCamera = false; + try + { + Harness harness = CreateActiveHarness(); + for (int i = 0; i < 6; i++) + { + harness.Clock.NowSeconds += 0.01f; + harness.Owner.QueueRawDelta(2f, 0f); + harness.Owner.Tick(); + } + + MovementResult turning = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput(Run: true)); + Assert.NotNull(turning.TurnCommand); + + float boundary = harness.Clock.NowSeconds + + MouseLookState.IdleZeroDelaySeconds; + harness.Clock.NowSeconds = boundary; + harness.Owner.Tick(); + MovementResult held = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput(Run: true)); + Assert.NotNull(held.TurnCommand); + + harness.Clock.NowSeconds = boundary + 0.0001f; + harness.Owner.Tick(); + MovementResult stopped = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput(Run: true)); + Assert.Null(stopped.TurnCommand); + } + finally + { + CameraDiagnostics.UseRetailChaseCamera = previousRetailCamera; + } + } + + [Fact] + public void LifecycleExitAlsoClearsRmbOrbitLatch() + { + Harness harness = CreateActiveHarness(); + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.True(harness.Chase.RmbOrbitHeld); + + harness.Owner.EndForLifecycle(); + + Assert.False(harness.Owner.Active); + Assert.False(harness.Chase.RmbOrbitHeld); + Assert.False(harness.Player.EndMouseLook(new MovementInput())); + } + + [Fact] + public void RmbOrbitLatchOnlyEngagesInPlayerChaseMode() + { + Harness harness = CreateHarness(); + + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.False(harness.Chase.RmbOrbitHeld); + + harness.Mode.IsPlayerMode = true; + harness.EnterChase(); + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.True(harness.Chase.RmbOrbitHeld); + + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Release); + Assert.False(harness.Chase.RmbOrbitHeld); + } + + private static Harness CreateActiveHarness() + { + Harness harness = CreateHarness(); + harness.Mode.IsPlayerMode = true; + harness.EnterChase(); + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press); + Assert.True(harness.Owner.Active); + return harness; + } + + private static Harness CreateHarness() + { + PlayerMovementController player = CreatePlayer(); + var mode = new LocalPlayerModeState(); + var slot = new LocalPlayerControllerSlot { Controller = player }; + var camera = new CameraController(new OrbitCamera(), new FlyCamera()); + var chase = new ChaseCameraInputState(); + var mouse = new FakeMouse(); + var pointer = new PointerPositionState { X = 320f, Y = 240f }; + var movement = new FixedMovementInputSource(); + var cursor = new FakeCursor(); + var clock = new FakeClock(); + var session = new NullSessionSource(); + var outbound = new LocalPlayerOutboundController( + static (_, _, _, _, _, _) => { }); + var owner = new MouseLookController( + mouse, + pointer, + mode, + slot, + camera, + chase, + movement, + outbound, + session, + cursor, + clock); + return new Harness(owner, mode, player, camera, chase, mouse, cursor, clock, session); + } + + private static PlayerMovementController CreatePlayer() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u); + return controller; + } + + private sealed record Harness( + MouseLookController Owner, + LocalPlayerModeState Mode, + PlayerMovementController Player, + CameraController Camera, + ChaseCameraInputState Chase, + FakeMouse Mouse, + FakeCursor Cursor, + FakeClock Clock, + NullSessionSource Session) + { + public void EnterChase() => + Camera.EnterChaseMode(new ChaseCamera(), new RetailChaseCamera()); + } + + private sealed class FixedMovementInputSource : IMovementInputSource + { + public MovementInput Capture() => new(Run: true); + } + + private sealed class FakeClock : IInputMonotonicClock + { + public float NowSeconds { get; set; } + } + + private sealed class FakeCursor : IMouseLookCursor + { + public int HideCount { get; private set; } + public int RestoreCount { get; private set; } + public bool HasSavedMode { get; private set; } + public void Hide() + { + HideCount++; + HasSavedMode = true; + } + public void Restore() + { + RestoreCount++; + HasSavedMode = false; + } + } + + private sealed class NullSessionSource : ILiveWorldSessionSource + { + public WorldSession? CurrentSession => null; + } + + private sealed class FakeMouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool WantCaptureMouse { get; set; } + public bool WantCaptureKeyboard { get; set; } + public bool IsHeld(MouseButton button) => false; + } +} diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs index 058c4694..794f40e1 100644 --- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -18,7 +18,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(Forward: true), + movementInput: Input(() => new MovementInput(Forward: true)), resolveLocalEntityId: () => 7u, handleTargeting: () => calls.Add("target"), isHidden: () => false, @@ -82,7 +82,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => controller is not null, getController: () => controller, - captureInput: () => new MovementInput(Forward: true), + movementInput: Input(() => new MovementInput(Forward: true)), resolveLocalEntityId: () => 9u, handleTargeting: () => { }, isHidden: () => false, @@ -112,7 +112,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(), + movementInput: Input(() => new MovementInput()), resolveLocalEntityId: () => 7u, handleTargeting: () => { }, isHidden: () => false, @@ -143,7 +143,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(Forward: true), + movementInput: Input(() => new MovementInput(Forward: true)), resolveLocalEntityId: () => 7u, handleTargeting: () => positionSeenByTargetManager = controller.Position, isHidden: () => false, @@ -169,11 +169,11 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => + movementInput: Input(() => { inputCaptures++; return new MovementInput(Forward: true); - }, + }), resolveLocalEntityId: () => 7u, handleTargeting: () => throw new InvalidOperationException( "Frozen object advanced its manager tail."), @@ -204,7 +204,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(), + movementInput: Input(() => new MovementInput()), resolveLocalEntityId: () => 7u, handleTargeting: () => { }, isHidden: () => true, @@ -231,11 +231,11 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => + movementInput: Input(() => { inputCaptures++; return new MovementInput(Forward: true); - }, + }), resolveLocalEntityId: () => 7u, handleTargeting: () => targeting++, isHidden: () => false, @@ -293,4 +293,13 @@ public sealed class RetailLocalPlayerFrameControllerTests Assert.True(clock.IsActive); return controller; } + + private static IMovementInputSource Input(Func capture) => + new TestMovementInputSource(capture); + + private sealed class TestMovementInputSource(Func capture) + : IMovementInputSource + { + public MovementInput Capture() => capture(); + } } diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 770f334d..dd1a69eb 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -230,12 +230,15 @@ public sealed class UpdateFrameOrchestratorTests Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow)); if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController)) { - // Checkpoint B deliberately leaves this single legacy callback - // composition for D/F, which own input capture and the mutable - // player-mode/controller slot. No other phase owner may add one. + // Checkpoint D replaced input capture with its typed owner. + // Checkpoint F still owns the remaining player presentation + // callback composition. No other phase owner may add one. Assert.Contains( ownerFields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain( + ownerFields, + field => field.FieldType == typeof(Func)); continue; } Assert.DoesNotContain( @@ -364,7 +367,7 @@ public sealed class UpdateFrameOrchestratorTests AssertAppearsInOrder( source, "_streamingFrame.Tick();", - "_inputDispatcher?.Tick();", + "_gameplayInputFrame!.Tick(frameTiming);", "_liveFrameCoordinator.Tick(frameDelta);"); Assert.DoesNotContain("_streamingController.Tick(observerCx", source, StringComparison.Ordinal); @@ -373,6 +376,152 @@ public sealed class UpdateFrameOrchestratorTests Assert.DoesNotContain("DrainRescued()", source, StringComparison.Ordinal); } + [Fact] + public void GameWindow_DelegatesTheCompleteGameplayInputFrameBody() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new AcDream.App.Input.GameplayInputFrameController(", source); + Assert.Equal(1, CountOccurrences(source, "_gameplayInputFrame!.Tick(frameTiming);")); + Assert.DoesNotContain("_inputDispatcher?.Tick()", source, + StringComparison.Ordinal); + Assert.DoesNotContain("TryTakeRawSample", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_combatAttackController?.Tick()", source, + StringComparison.Ordinal); + Assert.DoesNotContain("CaptureMovementInput", source, + StringComparison.Ordinal); + Assert.DoesNotContain("EndMouseLookAndRestoreCursor", source, + StringComparison.Ordinal); + Assert.DoesNotContain("HideCursorForMouseLook", source, + StringComparison.Ordinal); + Assert.DoesNotContain("RestoreCursorAfterMouseLook", source, + StringComparison.Ordinal); + Assert.DoesNotContain("CanStartLiveCombatAttack", source, + StringComparison.Ordinal); + Assert.DoesNotContain("SendLiveCombatAttack", source, + StringComparison.Ordinal); + Assert.DoesNotContain("PreparePlayerForAttackRequest", source, + StringComparison.Ordinal); + Assert.DoesNotContain("DumpMovementTruthOutbound", source, + StringComparison.Ordinal); + Assert.DoesNotContain("DumpMovementTruthServerEcho", source, + StringComparison.Ordinal); + Assert.DoesNotContain("wantCaptureMouse: ()", source, + StringComparison.Ordinal); + Assert.Equal(5, CountOccurrences( + source, + "_gameplayInputFrame?.EndMouseLook();")); + Assert.Contains( + "new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())", + source, + StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "_teleportTransit.CanBegin(teleportSequence)", + "_gameplayInputFrame?.EndMouseLook();", + "_teleportTransit.QueueStart(teleportSequence)"); + AssertAppearsInOrder( + source, + "private void OnFocusChanged(bool focused)", + "if (!focused)", + "_gameplayInputFrame?.EndMouseLook();"); + } + + [Fact] + public void GameplayInputOwnersUseTypedSeamsWithoutGameWindowBackReferences() + { + Type[] owners = + [ + typeof(AcDream.App.Input.GameplayInputFrameController), + typeof(AcDream.App.Input.DispatcherMovementInputSource), + typeof(AcDream.App.Input.MouseLookController), + ]; + + foreach (Type owner in owners) + { + FieldInfo[] fields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + Assert.DoesNotContain( + typeof(AcDream.App.Input.MouseLookController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType == typeof(Silk.NET.Input.IMouse)); + + FieldInfo combatOperations = Assert.Single( + typeof(AcDream.App.Combat.CombatAttackController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_operations"); + Assert.Equal( + typeof(AcDream.App.Combat.ICombatAttackOperations), + combatOperations.FieldType); + Assert.DoesNotContain( + typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + FieldInfo combatTargets = Assert.Single( + typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_targets"); + Assert.Equal( + typeof(AcDream.App.Combat.ICombatAttackTargetSource), + combatTargets.FieldType); + Assert.DoesNotContain( + typeof(AcDream.App.Combat.CombatAttackTargetSource).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain( + typeof(AcDream.App.Combat.CombatAttackTargetSource).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType + == typeof(AcDream.App.Interaction.SelectionInteractionController)); + + FieldInfo outboundDiagnostics = Assert.Single( + typeof(AcDream.App.Input.LocalPlayerOutboundController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_diagnostic"); + Assert.Equal( + typeof(AcDream.App.Input.IMovementTruthDiagnosticSink), + outboundDiagnostics.FieldType); + Assert.DoesNotContain( + typeof(AcDream.App.Input.MovementTruthDiagnosticController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + + string mouseLookSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "MouseLookController.cs")); + AssertAppearsInOrder( + mouseLookSource, + "controller?.StopMouseDrift", + "retail.FilterMouseDelta", + "_state.ApplyDelta"); + + string localPlayerSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "RetailLocalPlayerFrameController.cs")); + Assert.DoesNotContain("Func", localPlayerSource, + StringComparison.Ordinal); + Assert.Contains("IMovementInputSource", localPlayerSource, + StringComparison.Ordinal); + } + private static UpdateFrameOrchestrator Create( List calls, RecordingTeardown? teardown = null,