refactor(input): extract the gameplay frame
This commit is contained in:
parent
0bc9fda9de
commit
c557038353
24 changed files with 2433 additions and 559 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
|
||||
public DelegateCombatAttackOperations(
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
Action? prepareAttackRequest,
|
||||
Action? sendCancelAttack,
|
||||
Func<bool>? isDualWield,
|
||||
Func<bool>? playerReadyForAttack,
|
||||
Func<bool>? 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail's basic-combat attack request and power-bar state machine.
|
||||
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
|
||||
|
|
@ -26,13 +76,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
public const float DesiredPowerStep = 1f / 6f;
|
||||
|
||||
private readonly CombatState _combat;
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
private readonly ICombatAttackOperations _operations;
|
||||
private readonly Func<double> _now;
|
||||
|
||||
private bool _buildInProgress;
|
||||
|
|
@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable
|
|||
Func<bool>? playerReadyForAttack = null,
|
||||
Func<bool>? autoRepeatAttack = null,
|
||||
Func<double>? now = null)
|
||||
: this(
|
||||
combat,
|
||||
new DelegateCombatAttackOperations(
|
||||
canStartAttack,
|
||||
sendAttack,
|
||||
prepareAttackRequest,
|
||||
sendCancelAttack,
|
||||
isDualWield,
|
||||
playerReadyForAttack,
|
||||
autoRepeatAttack),
|
||||
now)
|
||||
{
|
||||
}
|
||||
|
||||
internal CombatAttackController(
|
||||
CombatState combat,
|
||||
ICombatAttackOperations operations,
|
||||
Func<double>? 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();
|
||||
|
|
|
|||
116
src/AcDream.App/Combat/CombatAttackTargetSource.cs
Normal file
116
src/AcDream.App/Combat/CombatAttackTargetSource.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Focused automatic-attack target owner. It shares canonical selection and
|
||||
/// live-entity state without retaining the broader interaction controller.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
180
src/AcDream.App/Combat/LiveCombatAttackOperations.cs
Normal file
180
src/AcDream.App/Combat/LiveCombatAttackOperations.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production combat request dependencies. The attack state machine retains
|
||||
/// this typed owner rather than callbacks into the application window.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
83
src/AcDream.App/Input/DispatcherMovementInputSource.cs
Normal file
83
src/AcDream.App/Input/DispatcherMovementInputSource.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IMovementInputSource
|
||||
{
|
||||
MovementInput Capture();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns held movement sampling and the retail autorun latch. The input
|
||||
/// dispatcher remains the only keyboard/mouse-button state source.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the press-only autorun policy at the same point in the semantic
|
||||
/// action pipeline as the former GameWindow body.
|
||||
/// </summary>
|
||||
/// <returns>True when the action is fully consumed.</returns>
|
||||
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;
|
||||
}
|
||||
87
src/AcDream.App/Input/GameplayInputFrameController.cs
Normal file
87
src/AcDream.App/Input/GameplayInputFrameController.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the complete pre-object input frame: held semantic dispatch, one raw
|
||||
/// mouse-look sample, and combat attack intent.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
74
src/AcDream.App/Input/InputCaptureSources.cs
Normal file
74
src/AcDream.App/Input/InputCaptureSources.cs
Normal file
|
|
@ -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<bool> _wantCaptureMouse;
|
||||
private readonly Func<bool> _wantCaptureKeyboard;
|
||||
|
||||
public DelegateInputCaptureSource(
|
||||
Func<bool> wantCaptureMouse,
|
||||
Func<bool> 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;
|
||||
}
|
||||
|
|
@ -11,13 +11,18 @@ namespace AcDream.App.Input;
|
|||
/// </summary>
|
||||
public sealed class LocalPlayerOutboundController
|
||||
{
|
||||
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
|
||||
private readonly IMovementTruthDiagnosticSink _diagnostic;
|
||||
|
||||
internal LocalPlayerOutboundController(IMovementTruthDiagnosticSink diagnostic)
|
||||
{
|
||||
_diagnostic = diagnostic
|
||||
?? throw new ArgumentNullException(nameof(diagnostic));
|
||||
}
|
||||
|
||||
public LocalPlayerOutboundController(
|
||||
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
|
||||
{
|
||||
_diagnostic = diagnostic
|
||||
?? throw new ArgumentNullException(nameof(diagnostic));
|
||||
_diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
262
src/AcDream.App/Input/MouseLookController.cs
Normal file
262
src/AcDream.App/Input/MouseLookController.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current chase-camera input targets. GameWindow keeps compatibility
|
||||
/// properties over this one slot until checkpoint F moves camera ownership.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns instant mouse-look state, raw-sample timing/filtering, movement
|
||||
/// transition sends, and cursor capture across every exit path.
|
||||
/// </summary>
|
||||
internal sealed class MouseLookController : IMouseLookInputFrameController
|
||||
{
|
||||
private readonly IMouseSource _mouseSource;
|
||||
private readonly IPointerPositionSource _pointer;
|
||||
private readonly ILocalPlayerModeSource _playerMode;
|
||||
private readonly 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session teardown releases presentation capture without emitting a
|
||||
/// movement packet into the ending session, matching the prior reset edge.
|
||||
/// </summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
bool stateWasActive = _state.Active;
|
||||
_state.Release();
|
||||
_chase.RmbOrbitHeld = false;
|
||||
_lastWantCaptureMouse = false;
|
||||
if (stateWasActive || _cursor.HasSavedMode)
|
||||
_cursor.Restore();
|
||||
}
|
||||
|
||||
private void Begin()
|
||||
{
|
||||
PlayerMovementController? controller = _playerController.Controller;
|
||||
if (!_playerMode.IsPlayerMode
|
||||
|| !_camera.IsChaseMode
|
||||
|| controller is not { State: PlayerState.InWorld })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float nowSeconds = _clock.NowSeconds;
|
||||
_state.Press(
|
||||
_pointer.X,
|
||||
_pointer.Y,
|
||||
_mouseSource.WantCaptureMouse,
|
||||
nowSeconds);
|
||||
if (!_state.Active)
|
||||
return;
|
||||
|
||||
if (!controller.BeginMouseLook(_movementInput.Capture()))
|
||||
{
|
||||
_state.Release();
|
||||
return;
|
||||
}
|
||||
|
||||
_outbound.TrySendMovement(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
controller.CaptureMovementResult(mouseLookEvent: false));
|
||||
_cursor.Hide();
|
||||
}
|
||||
|
||||
private void ApplyHorizontalAdjustment(float adjustment) =>
|
||||
_playerController.Controller?.SubmitMouseTurnAdjustment(
|
||||
adjustment,
|
||||
_movementInput.Capture());
|
||||
}
|
||||
161
src/AcDream.App/Input/MovementTruthDiagnosticController.cs
Normal file
161
src/AcDream.App/Input/MovementTruthDiagnosticController.cs
Normal file
|
|
@ -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<string, uint, MovementResult, Vector3, uint, byte>
|
||||
_outbound;
|
||||
|
||||
public DelegateMovementTruthDiagnosticSink(
|
||||
Action<string, uint, MovementResult, Vector3, uint, byte> 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
|||
{
|
||||
private readonly Func<bool> _canPresentPlayer;
|
||||
private readonly Func<PlayerMovementController?> _getController;
|
||||
private readonly Func<MovementInput> _captureInput;
|
||||
private readonly IMovementInputSource _movementInput;
|
||||
private readonly Func<uint> _resolveLocalEntityId;
|
||||
private readonly Action _handleTargeting;
|
||||
private readonly Func<bool> _isHidden;
|
||||
|
|
@ -53,7 +53,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
|||
public RetailLocalPlayerFrameController(
|
||||
Func<bool> canPresentPlayer,
|
||||
Func<PlayerMovementController?> getController,
|
||||
Func<MovementInput> captureInput,
|
||||
IMovementInputSource movementInput,
|
||||
Func<uint> resolveLocalEntityId,
|
||||
Action handleTargeting,
|
||||
Func<bool> 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);
|
||||
|
|
|
|||
|
|
@ -11,18 +11,15 @@ namespace AcDream.App.Input;
|
|||
/// <see cref="InputDispatcher"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the
|
||||
/// constructor takes two delegates so the App.Rendering layer can
|
||||
/// proxy <c>ImGui.GetIO().WantCaptureMouse</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SilkMouseSource : IMouseSource
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private readonly Func<bool> _wantCaptureMouse;
|
||||
private readonly Func<bool> _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<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
|
||||
/// <summary>Caller-supplied probe for the current modifier mask. Reused
|
||||
/// from the keyboard source so mouse events carry consistent modifier
|
||||
/// state.</summary>
|
||||
public Func<ModifierMask> ModifierProbe { get; set; } = () => ModifierMask.None;
|
||||
/// <summary>The keyboard source that owns the current modifier mask, so
|
||||
/// mouse events and keyboard events resolve identical chords.</summary>
|
||||
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<bool> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,3 +4,8 @@ internal interface ILiveInWorldSource
|
|||
{
|
||||
bool IsInWorld { get; }
|
||||
}
|
||||
|
||||
internal interface ILiveWorldSessionSource
|
||||
{
|
||||
AcDream.Core.Net.WorldSession? CurrentSession { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,8 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
|||
internal sealed class LiveSessionController
|
||||
: IDisposable,
|
||||
ILiveSessionFramePhase,
|
||||
ILiveInWorldSource
|
||||
ILiveInWorldSource,
|
||||
ILiveWorldSessionSource
|
||||
{
|
||||
private sealed class SessionScope(
|
||||
WorldSession session,
|
||||
|
|
|
|||
|
|
@ -58,8 +58,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
private readonly Func<WorldSession?> _session;
|
||||
private readonly LiveEntityInboundAuthorityGate _authorityGate;
|
||||
private readonly Action<WorldSession.EntityPositionUpdate> _aimTeleportDestination;
|
||||
private readonly Action<WorldSession.EntityPositionUpdate, System.Numerics.Vector3>
|
||||
_dumpMovementTruthServerEcho;
|
||||
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
||||
|
||||
private PlayerMovementController? _playerController => _playerControllerSource();
|
||||
private EntityPhysicsHost? _playerHost => _playerHostSource();
|
||||
|
|
@ -106,7 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
Func<WorldSession?> session,
|
||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||
Action<WorldSession.EntityPositionUpdate> aimTeleportDestination,
|
||||
Action<WorldSession.EntityPositionUpdate, System.Numerics.Vector3> 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/// <summary>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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientCombatSystem::StartAttackRequest</c> (0x0056C040)
|
||||
/// calls <c>MaybeStopCompletely</c>, 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase K.2: hide the system cursor while MMB instant mouse-look is
|
||||
/// held. Saves the previous CursorMode so <see cref="RestoreCursorAfterMouseLook"/>
|
||||
/// can put it back exactly. Skips when no mouse / no input — tests
|
||||
/// and headless runs stay clean.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
209
tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs
Normal file
209
tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs
Normal file
|
|
@ -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<WorldEntity>()));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(() => 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<string> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(() => 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<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? 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<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string> _calls;
|
||||
private readonly bool _consumes;
|
||||
|
||||
public FakeCombat(List<string> 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<string> _calls;
|
||||
private readonly bool _consumes;
|
||||
|
||||
public FakeMouseLook(
|
||||
List<string> 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<Key> _held = [];
|
||||
|
||||
public HeldKeyboard(params Key[] held)
|
||||
{
|
||||
foreach (Key key in held)
|
||||
_held.Add(key);
|
||||
}
|
||||
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
#pragma warning disable CS0067
|
||||
public event Action<Key, ModifierMask>? 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<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
public bool WantCaptureMouse => false;
|
||||
public bool WantCaptureKeyboard => false;
|
||||
}
|
||||
}
|
||||
309
tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs
Normal file
309
tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs
Normal file
|
|
@ -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<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
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<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
#pragma warning restore CS0067
|
||||
public bool WantCaptureMouse { get; set; }
|
||||
public bool WantCaptureKeyboard { get; set; }
|
||||
public bool IsHeld(MouseButton button) => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MovementInput> capture) =>
|
||||
new TestMovementInputSource(capture);
|
||||
|
||||
private sealed class TestMovementInputSource(Func<MovementInput> capture)
|
||||
: IMovementInputSource
|
||||
{
|
||||
public MovementInput Capture() => capture();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AcDream.App.Input.MovementInput>));
|
||||
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<MovementInput>", localPlayerSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("IMovementInputSource", localPlayerSource,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static UpdateFrameOrchestrator Create(
|
||||
List<string> calls,
|
||||
RecordingTeardown? teardown = null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue