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`.
|
`GameWindow` from `RetailLiveFrameCoordinator`.
|
||||||
- [x] C — extract streaming-origin convergence, observer selection, dungeon
|
- [x] C — extract streaming-origin convergence, observer selection, dungeon
|
||||||
collapse, streaming tick, and rescued-entity rebucketing.
|
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.
|
movement-command or UI-capture semantics.
|
||||||
- [ ] E — extract the complete local-player teleport owner and wire it between
|
- [ ] E — extract the complete local-player teleport owner and wire it between
|
||||||
the existing post-network liveness and player-mode auto-entry phases.
|
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
|
GUID deletion, and session reset are pinned; all three corrected-diff reviews
|
||||||
are clean. `GameWindow.cs` is now 8,569 raw lines.
|
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
|
## 1. Outcome and non-goals
|
||||||
|
|
||||||
At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one
|
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;
|
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>
|
/// <summary>
|
||||||
/// Owns retail's basic-combat attack request and power-bar state machine.
|
/// Owns retail's basic-combat attack request and power-bar state machine.
|
||||||
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
|
/// 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;
|
public const float DesiredPowerStep = 1f / 6f;
|
||||||
|
|
||||||
private readonly CombatState _combat;
|
private readonly CombatState _combat;
|
||||||
private readonly Func<bool> _canStartAttack;
|
private readonly ICombatAttackOperations _operations;
|
||||||
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 Func<double> _now;
|
private readonly Func<double> _now;
|
||||||
|
|
||||||
private bool _buildInProgress;
|
private bool _buildInProgress;
|
||||||
|
|
@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable
|
||||||
Func<bool>? playerReadyForAttack = null,
|
Func<bool>? playerReadyForAttack = null,
|
||||||
Func<bool>? autoRepeatAttack = null,
|
Func<bool>? autoRepeatAttack = null,
|
||||||
Func<double>? now = 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));
|
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||||
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
|
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||||
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
|
|
||||||
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
|
|
||||||
_sendCancelAttack = sendCancelAttack ?? (() => { });
|
|
||||||
_isDualWield = isDualWield ?? (() => false);
|
|
||||||
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
|
|
||||||
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
|
|
||||||
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
|
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
|
||||||
|
|
||||||
_combat.CombatModeChanged += OnCombatModeChanged;
|
_combat.CombatModeChanged += OnCombatModeChanged;
|
||||||
|
|
@ -196,7 +252,7 @@ public sealed class CombatAttackController : IDisposable
|
||||||
&& !_repeatAttacking)
|
&& !_repeatAttacking)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_sendCancelAttack();
|
_operations.SendCancelAttack();
|
||||||
_repeatAttacking = false;
|
_repeatAttacking = false;
|
||||||
|
|
||||||
if (_buildInProgress)
|
if (_buildInProgress)
|
||||||
|
|
@ -219,7 +275,7 @@ public sealed class CombatAttackController : IDisposable
|
||||||
if (!_buildInProgress)
|
if (!_buildInProgress)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!_playerReadyForAttack())
|
if (!_operations.PlayerReadyForAttack)
|
||||||
{
|
{
|
||||||
if (_attackRequestInProgress)
|
if (_attackRequestInProgress)
|
||||||
{
|
{
|
||||||
|
|
@ -258,7 +314,7 @@ public sealed class CombatAttackController : IDisposable
|
||||||
private void StartAttackRequest()
|
private void StartAttackRequest()
|
||||||
{
|
{
|
||||||
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|
||||||
|| !_canStartAttack())
|
|| !_operations.CanStartAttack())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
|
// 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.
|
// player movement object and must run before any later attack send.
|
||||||
_attackRequestInProgress = true;
|
_attackRequestInProgress = true;
|
||||||
_requestedAttackPower = 1f;
|
_requestedAttackPower = 1f;
|
||||||
_prepareAttackRequest();
|
_operations.PrepareAttackRequest();
|
||||||
_currentBuildIsAutomatic = false;
|
_currentBuildIsAutomatic = false;
|
||||||
AttemptStartBuildingAttack();
|
AttemptStartBuildingAttack();
|
||||||
}
|
}
|
||||||
|
|
@ -276,7 +332,7 @@ public sealed class CombatAttackController : IDisposable
|
||||||
{
|
{
|
||||||
if (_buildInProgress
|
if (_buildInProgress
|
||||||
|| _attackServerResponsePending
|
|| _attackServerResponsePending
|
||||||
|| !_playerReadyForAttack())
|
|| !_operations.PlayerReadyForAttack)
|
||||||
return;
|
return;
|
||||||
StartPowerBarBuild();
|
StartPowerBarBuild();
|
||||||
}
|
}
|
||||||
|
|
@ -292,7 +348,7 @@ public sealed class CombatAttackController : IDisposable
|
||||||
{
|
{
|
||||||
if (!_buildInProgress)
|
if (!_buildInProgress)
|
||||||
return 0f;
|
return 0f;
|
||||||
double duration = _isDualWield()
|
double duration = _operations.IsDualWield
|
||||||
? DualWieldPowerUpSeconds
|
? DualWieldPowerUpSeconds
|
||||||
: AttackPowerUpSeconds;
|
: AttackPowerUpSeconds;
|
||||||
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
|
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)
|
private void ExecuteAttack(AttackHeight height, bool setServerPending)
|
||||||
{
|
{
|
||||||
StopBuild();
|
StopBuild();
|
||||||
if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f)))
|
if (!_operations.SendAttack(
|
||||||
|
height,
|
||||||
|
Math.Clamp(_requestedAttackPower, 0f, 1f)))
|
||||||
{
|
{
|
||||||
ResetPowerBar();
|
ResetPowerBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_autoRepeatAttack())
|
if (_operations.AutoRepeatAttack)
|
||||||
_repeatAttacking = true;
|
_repeatAttacking = true;
|
||||||
_attackServerResponsePending = setServerPending;
|
_attackServerResponsePending = setServerPending;
|
||||||
}
|
}
|
||||||
|
|
@ -330,7 +388,7 @@ public sealed class CombatAttackController : IDisposable
|
||||||
_repeatAttacking = false;
|
_repeatAttacking = false;
|
||||||
|
|
||||||
if (!_attackRequestInProgress
|
if (!_attackRequestInProgress
|
||||||
&& _autoRepeatAttack()
|
&& _operations.AutoRepeatAttack
|
||||||
&& _repeatAttacking)
|
&& _repeatAttacking)
|
||||||
{
|
{
|
||||||
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
|
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
|
||||||
|
|
@ -338,7 +396,7 @@ public sealed class CombatAttackController : IDisposable
|
||||||
ExecuteAttack(RequestedHeight, setServerPending: false);
|
ExecuteAttack(RequestedHeight, setServerPending: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_autoRepeatAttack() || !_repeatAttacking)
|
if (!_operations.AutoRepeatAttack || !_repeatAttacking)
|
||||||
{
|
{
|
||||||
_repeatAttacking = false;
|
_repeatAttacking = false;
|
||||||
ResetPowerBar();
|
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>
|
/// </summary>
|
||||||
public sealed class LocalPlayerOutboundController
|
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(
|
public LocalPlayerOutboundController(
|
||||||
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
|
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
|
||||||
{
|
{
|
||||||
_diagnostic = diagnostic
|
_diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic);
|
||||||
?? throw new ArgumentNullException(nameof(diagnostic));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -107,7 +112,7 @@ public sealed class LocalPlayerOutboundController
|
||||||
teleportSequence: session.TeleportSequence,
|
teleportSequence: session.TeleportSequence,
|
||||||
forcePositionSequence: session.ForcePositionSequence,
|
forcePositionSequence: session.ForcePositionSequence,
|
||||||
lastContact: contactByte);
|
lastContact: contactByte);
|
||||||
_diagnostic(
|
_diagnostic.OnOutbound(
|
||||||
"AP",
|
"AP",
|
||||||
sequence,
|
sequence,
|
||||||
movement,
|
movement,
|
||||||
|
|
@ -193,7 +198,7 @@ public sealed class LocalPlayerOutboundController
|
||||||
forcePositionSequence: session.ForcePositionSequence,
|
forcePositionSequence: session.ForcePositionSequence,
|
||||||
contact: contactByte != 0,
|
contact: contactByte != 0,
|
||||||
standingLongjump: false);
|
standingLongjump: false);
|
||||||
_diagnostic(
|
_diagnostic.OnOutbound(
|
||||||
"MTS",
|
"MTS",
|
||||||
sequence,
|
sequence,
|
||||||
movement,
|
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<bool> _canPresentPlayer;
|
||||||
private readonly Func<PlayerMovementController?> _getController;
|
private readonly Func<PlayerMovementController?> _getController;
|
||||||
private readonly Func<MovementInput> _captureInput;
|
private readonly IMovementInputSource _movementInput;
|
||||||
private readonly Func<uint> _resolveLocalEntityId;
|
private readonly Func<uint> _resolveLocalEntityId;
|
||||||
private readonly Action _handleTargeting;
|
private readonly Action _handleTargeting;
|
||||||
private readonly Func<bool> _isHidden;
|
private readonly Func<bool> _isHidden;
|
||||||
|
|
@ -53,7 +53,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
||||||
public RetailLocalPlayerFrameController(
|
public RetailLocalPlayerFrameController(
|
||||||
Func<bool> canPresentPlayer,
|
Func<bool> canPresentPlayer,
|
||||||
Func<PlayerMovementController?> getController,
|
Func<PlayerMovementController?> getController,
|
||||||
Func<MovementInput> captureInput,
|
IMovementInputSource movementInput,
|
||||||
Func<uint> resolveLocalEntityId,
|
Func<uint> resolveLocalEntityId,
|
||||||
Action handleTargeting,
|
Action handleTargeting,
|
||||||
Func<bool> isHidden,
|
Func<bool> isHidden,
|
||||||
|
|
@ -67,8 +67,8 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
||||||
?? throw new ArgumentNullException(nameof(canPresentPlayer));
|
?? throw new ArgumentNullException(nameof(canPresentPlayer));
|
||||||
_getController = getController
|
_getController = getController
|
||||||
?? throw new ArgumentNullException(nameof(getController));
|
?? throw new ArgumentNullException(nameof(getController));
|
||||||
_captureInput = captureInput
|
_movementInput = movementInput
|
||||||
?? throw new ArgumentNullException(nameof(captureInput));
|
?? throw new ArgumentNullException(nameof(movementInput));
|
||||||
_resolveLocalEntityId = resolveLocalEntityId
|
_resolveLocalEntityId = resolveLocalEntityId
|
||||||
?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
|
?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
|
||||||
_handleTargeting = handleTargeting
|
_handleTargeting = handleTargeting
|
||||||
|
|
@ -134,7 +134,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
|
||||||
|
|
||||||
MovementResult movement = hidden
|
MovementResult movement = hidden
|
||||||
? controller.TickHidden(deltaSeconds, _handleTargeting)
|
? controller.TickHidden(deltaSeconds, _handleTargeting)
|
||||||
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
|
: controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting);
|
||||||
|
|
||||||
_project(controller, movement, hidden);
|
_project(controller, movement, hidden);
|
||||||
_sendPreNetwork(controller, movement, hidden);
|
_sendPreNetwork(controller, movement, hidden);
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,15 @@ namespace AcDream.App.Input;
|
||||||
/// <see cref="InputDispatcher"/>.
|
/// <see cref="InputDispatcher"/>.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the
|
/// Production receives a typed capture source, keeping retained-UI and
|
||||||
/// constructor takes two delegates so the App.Rendering layer can
|
/// developer-UI ownership outside the Silk adapter. The public delegate
|
||||||
/// proxy <c>ImGui.GetIO().WantCaptureMouse</c> via whichever ImGui
|
/// overload remains as a compatibility seam for standalone consumers.
|
||||||
/// package is currently active without leaking the type onto the
|
|
||||||
/// abstraction interface.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SilkMouseSource : IMouseSource
|
public sealed class SilkMouseSource : IMouseSource
|
||||||
{
|
{
|
||||||
private readonly IMouse _mouse;
|
private readonly IMouse _mouse;
|
||||||
private readonly Func<bool> _wantCaptureMouse;
|
private readonly IInputCaptureSource _capture;
|
||||||
private readonly Func<bool> _wantCaptureKeyboard;
|
|
||||||
private float _lastX;
|
private float _lastX;
|
||||||
private float _lastY;
|
private float _lastY;
|
||||||
private bool _haveLastPos;
|
private bool _haveLastPos;
|
||||||
|
|
@ -32,10 +29,21 @@ public sealed class SilkMouseSource : IMouseSource
|
||||||
public event Action<float, float>? MouseMove;
|
public event Action<float, float>? MouseMove;
|
||||||
public event Action<float>? Scroll;
|
public event Action<float>? Scroll;
|
||||||
|
|
||||||
/// <summary>Caller-supplied probe for the current modifier mask. Reused
|
/// <summary>The keyboard source that owns the current modifier mask, so
|
||||||
/// from the keyboard source so mouse events carry consistent modifier
|
/// mouse events and keyboard events resolve identical chords.</summary>
|
||||||
/// state.</summary>
|
public IKeyboardSource? ModifierSource { get; set; }
|
||||||
public Func<ModifierMask> ModifierProbe { get; set; } = () => ModifierMask.None;
|
|
||||||
|
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(
|
public SilkMouseSource(
|
||||||
IMouse mouse,
|
IMouse mouse,
|
||||||
|
|
@ -43,11 +51,16 @@ public sealed class SilkMouseSource : IMouseSource
|
||||||
Func<bool> wantCaptureKeyboard)
|
Func<bool> wantCaptureKeyboard)
|
||||||
{
|
{
|
||||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||||
_wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse));
|
_capture = new DelegateInputCaptureSource(
|
||||||
_wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
|
wantCaptureMouse,
|
||||||
|
wantCaptureKeyboard);
|
||||||
|
Subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe());
|
private void Subscribe()
|
||||||
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe());
|
{
|
||||||
|
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers());
|
||||||
|
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers());
|
||||||
_mouse.MouseMove += (_, pos) =>
|
_mouse.MouseMove += (_, pos) =>
|
||||||
{
|
{
|
||||||
float dx, dy;
|
float dx, dy;
|
||||||
|
|
@ -69,8 +82,11 @@ public sealed class SilkMouseSource : IMouseSource
|
||||||
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
|
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ModifierMask ReadModifiers() =>
|
||||||
|
ModifierSource?.CurrentModifiers ?? ModifierMask.None;
|
||||||
|
|
||||||
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
|
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
|
||||||
|
|
||||||
public bool WantCaptureMouse => _wantCaptureMouse();
|
public bool WantCaptureMouse => _capture.WantCaptureMouse;
|
||||||
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
|
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,8 @@ internal interface ILiveInWorldSource
|
||||||
{
|
{
|
||||||
bool IsInWorld { get; }
|
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
|
internal sealed class LiveSessionController
|
||||||
: IDisposable,
|
: IDisposable,
|
||||||
ILiveSessionFramePhase,
|
ILiveSessionFramePhase,
|
||||||
ILiveInWorldSource
|
ILiveInWorldSource,
|
||||||
|
ILiveWorldSessionSource
|
||||||
{
|
{
|
||||||
private sealed class SessionScope(
|
private sealed class SessionScope(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
private readonly Func<WorldSession?> _session;
|
private readonly Func<WorldSession?> _session;
|
||||||
private readonly LiveEntityInboundAuthorityGate _authorityGate;
|
private readonly LiveEntityInboundAuthorityGate _authorityGate;
|
||||||
private readonly Action<WorldSession.EntityPositionUpdate> _aimTeleportDestination;
|
private readonly Action<WorldSession.EntityPositionUpdate> _aimTeleportDestination;
|
||||||
private readonly Action<WorldSession.EntityPositionUpdate, System.Numerics.Vector3>
|
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
||||||
_dumpMovementTruthServerEcho;
|
|
||||||
|
|
||||||
private PlayerMovementController? _playerController => _playerControllerSource();
|
private PlayerMovementController? _playerController => _playerControllerSource();
|
||||||
private EntityPhysicsHost? _playerHost => _playerHostSource();
|
private EntityPhysicsHost? _playerHost => _playerHostSource();
|
||||||
|
|
@ -106,7 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
Func<WorldSession?> session,
|
Func<WorldSession?> session,
|
||||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||||
Action<WorldSession.EntityPositionUpdate> aimTeleportDestination,
|
Action<WorldSession.EntityPositionUpdate> aimTeleportDestination,
|
||||||
Action<WorldSession.EntityPositionUpdate, System.Numerics.Vector3> dumpMovementTruthServerEcho)
|
IMovementTruthDiagnosticSink movementTruthDiagnostics)
|
||||||
{
|
{
|
||||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
|
|
@ -140,7 +139,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
liveEntities,
|
liveEntities,
|
||||||
publishTimestamps);
|
publishTimestamps);
|
||||||
_aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination));
|
_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();
|
internal void ResetSessionState() => _authorityGate.ResetSessionState();
|
||||||
|
|
@ -1106,7 +1106,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||||
? entity.Rotation
|
? entity.Rotation
|
||||||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||||
_dumpMovementTruthServerEcho(update, worldPos);
|
_movementTruthDiagnostics.OnServerEcho(update, worldPos);
|
||||||
|
|
||||||
bool remoteHardTeleport = update.Guid != _playerServerGuid
|
bool remoteHardTeleport = update.Guid != _playerServerGuid
|
||||||
&& timestamps.TeleportHookRequired;
|
&& timestamps.TeleportHookRequired;
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,17 @@ public sealed class GameWindow : IDisposable
|
||||||
private CameraController? _cameraController;
|
private CameraController? _cameraController;
|
||||||
private IMouse? _capturedMouse;
|
private IMouse? _capturedMouse;
|
||||||
private IDatReaderWriter? _dats;
|
private IDatReaderWriter? _dats;
|
||||||
private float _lastMouseX;
|
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
|
||||||
private float _lastMouseY;
|
private float _lastMouseX
|
||||||
|
{
|
||||||
|
get => _pointerPosition.X;
|
||||||
|
set => _pointerPosition.X = value;
|
||||||
|
}
|
||||||
|
private float _lastMouseY
|
||||||
|
{
|
||||||
|
get => _pointerPosition.Y;
|
||||||
|
set => _pointerPosition.Y = value;
|
||||||
|
}
|
||||||
private Shader? _meshShader;
|
private Shader? _meshShader;
|
||||||
private TextureCache? _textureCache;
|
private TextureCache? _textureCache;
|
||||||
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
|
/// <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 LiveEntityAnimationScheduler _liveAnimationScheduler = null!;
|
||||||
private LiveEntityAnimationPresenter _animationPresenter = null!;
|
private LiveEntityAnimationPresenter _animationPresenter = null!;
|
||||||
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
|
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.LocalPlayerOutboundController _localPlayerOutbound;
|
||||||
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
|
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
|
||||||
private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
|
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.
|
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
||||||
private AcDream.App.UI.UiHost? _uiHost;
|
private AcDream.App.UI.UiHost? _uiHost;
|
||||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
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.CombatAttackController? _combatAttackController;
|
||||||
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
|
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
|
||||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
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
|
// _panelHost does. Self-subscribes to CombatState in its ctor, so
|
||||||
// disposing isn't required (panel host holds the only ref).
|
// disposing isn't required (panel host holds the only ref).
|
||||||
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
|
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
|
||||||
// DevToolsEnabled + DumpMoveTruthEnabled now read through _options
|
// DevToolsEnabled reads through typed RuntimeOptions.
|
||||||
// (RuntimeOptions.DevTools / DumpMoveTruth). Kept the same names for
|
|
||||||
// local readability via expression-bodied properties.
|
|
||||||
private bool DevToolsEnabled => _options.DevTools;
|
private bool DevToolsEnabled => _options.DevTools;
|
||||||
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
|
|
||||||
|
|
||||||
// Slice 3: the reset transaction remains cached by the host, while the
|
// Slice 3: the reset transaction remains cached by the host, while the
|
||||||
// lifecycle controller owns each exact event/command/session generation.
|
// lifecycle controller owns each exact event/command/session generation.
|
||||||
|
|
@ -530,8 +542,17 @@ public sealed class GameWindow : IDisposable
|
||||||
get => _playerControllerSlot.Controller;
|
get => _playerControllerSlot.Controller;
|
||||||
set => _playerControllerSlot.Controller = value;
|
set => _playerControllerSlot.Controller = value;
|
||||||
}
|
}
|
||||||
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
|
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
|
||||||
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
|
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 readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new();
|
||||||
private bool _playerMode
|
private bool _playerMode
|
||||||
{
|
{
|
||||||
|
|
@ -549,21 +570,8 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
|
||||||
_characterOptions1 =
|
_characterOptions1 =
|
||||||
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
|
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
|
||||||
private MovementTruthOutbound? _lastMovementTruthOutbound;
|
|
||||||
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
|
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
|
// K-fix7 (2026-04-26): server-authoritative Run + Jump skill values
|
||||||
// received from PlayerDescription. -1 = "not yet received, fall back
|
// received from PlayerDescription. -1 = "not yet received, fall back
|
||||||
// to the controller's default (env-var or hardcoded 200/300)".
|
// 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
|
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
|
||||||
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
|
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
|
||||||
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
|
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
|
// Mouse sensitivity multipliers — one per camera mode because the visual
|
||||||
// feel is very different. Adjust via F8 / F9 for whichever mode is
|
// feel is very different. Adjust via F8 / F9 for whichever mode is
|
||||||
// currently active. Chase default is low because the character + camera
|
// currently active. Chase default is low because the character + camera
|
||||||
// rotating together is overwhelming at fly speeds.
|
// 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 _sensFly = 1.0f;
|
||||||
private float _sensOrbit = 1.0f;
|
private float _sensOrbit = 1.0f;
|
||||||
|
|
||||||
// Right-mouse-button held → free-orbit the chase camera around the
|
// Right-mouse-button held → free-orbit the chase camera around the
|
||||||
// player without turning the character. Release leaves the camera at
|
// player without turning the character. Release leaves the camera at
|
||||||
// the orbited position (no snap back).
|
// the orbited position (no snap back).
|
||||||
private bool _rmbHeld;
|
private bool _rmbHeld
|
||||||
|
{
|
||||||
// K-fix1 (2026-04-26): autorun is a TOGGLE — Press Q to start
|
get => _chaseCameraInput.RmbOrbitHeld;
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Phase K.2 — auto-enter player mode after a successful login. Armed
|
// Phase K.2 — auto-enter player mode after a successful login. Armed
|
||||||
// by ApplyLiveSessionEnteredWorld; ticked from
|
// by ApplyLiveSessionEnteredWorld; ticked from
|
||||||
|
|
@ -617,21 +614,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// the bool here.
|
// the bool here.
|
||||||
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
|
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
|
// Phase K.1b — single input path. Every keyboard/mouse-button reaction
|
||||||
// flows through InputDispatcher.Fired (see OnInputAction below) or
|
// flows through InputDispatcher.Fired (see OnInputAction below) or
|
||||||
// IsActionHeld (per-frame polling for movement). The legacy direct
|
// 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.SilkKeyboardSource? _kbSource;
|
||||||
private AcDream.App.Input.SilkMouseSource? _mouseSource;
|
private AcDream.App.Input.SilkMouseSource? _mouseSource;
|
||||||
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
|
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,
|
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
|
||||||
// falling back to the retail-faithful defaults if the file is missing
|
// 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
|
// 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)
|
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
_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;
|
_datDir = options.DatDir;
|
||||||
_worldGameState = worldGameState;
|
_worldGameState = worldGameState;
|
||||||
_worldEvents = worldEvents;
|
_worldEvents = worldEvents;
|
||||||
|
|
@ -785,16 +778,17 @@ public sealed class GameWindow : IDisposable
|
||||||
_liveEntities?.RebucketLiveEntity(serverGuid, landblockId),
|
_liveEntities?.RebucketLiveEntity(serverGuid, landblockId),
|
||||||
isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection,
|
isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection,
|
||||||
suspendShadow: SuspendLocalPlayerShadow);
|
suspendShadow: SuspendLocalPlayerShadow);
|
||||||
|
_movementTruthDiagnostics =
|
||||||
|
new AcDream.App.Input.MovementTruthDiagnosticController(
|
||||||
|
options.DumpMoveTruth,
|
||||||
|
_playerControllerSlot,
|
||||||
|
_localPlayerIdentity);
|
||||||
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
|
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
|
||||||
DumpMovementTruthOutbound);
|
_movementTruthDiagnostics);
|
||||||
_localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController(
|
_localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: CanAdvanceLocalPlayer,
|
canPresentPlayer: CanAdvanceLocalPlayer,
|
||||||
getController: () => _playerController,
|
getController: () => _playerController,
|
||||||
captureInput: () =>
|
movementInput: _movementInput,
|
||||||
{
|
|
||||||
_playerMouseDeltaX = 0f;
|
|
||||||
return CaptureMovementInput();
|
|
||||||
},
|
|
||||||
resolveLocalEntityId: () =>
|
resolveLocalEntityId: () =>
|
||||||
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
|
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
|
||||||
? entity.Id
|
? entity.Id
|
||||||
|
|
@ -904,30 +898,16 @@ public sealed class GameWindow : IDisposable
|
||||||
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
||||||
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
||||||
firstMouse,
|
firstMouse,
|
||||||
wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|
_inputCapture,
|
||||||
|| (_uiHost?.Root.WantsMouse ?? false),
|
_kbSource);
|
||||||
wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard)
|
|
||||||
|| (_uiHost?.Root.WantsKeyboard ?? false));
|
|
||||||
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
|
|
||||||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||||
_kbSource, _mouseSource, _keyBindings);
|
_kbSource, _mouseSource, _keyBindings);
|
||||||
|
_movementInput.Bind(_inputDispatcher);
|
||||||
|
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
|
||||||
_inputDispatcher.Fired += OnInputAction;
|
_inputDispatcher.Fired += OnInputAction;
|
||||||
Combat.CombatModeChanged += SetInputCombatScope;
|
Combat.CombatModeChanged += SetInputCombatScope;
|
||||||
SetInputCombatScope(Combat.CurrentMode);
|
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
|
// Phase K.2 — auto-enter player mode after EnterWorld
|
||||||
// succeeds. Predicates close over GameWindow state; the
|
// succeeds. Predicates close over GameWindow state; the
|
||||||
// entry callback flips into player mode via the same code
|
// 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)
|
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
|
||||||
{
|
{
|
||||||
float sens = _sensChase;
|
float sens = _sensChase;
|
||||||
if (_mouseLook is not null && _mouseLook.Active)
|
if (_gameplayInputFrame?.MouseLookActive == true)
|
||||||
{
|
{
|
||||||
// DirectInput may deliver several device records before
|
// DirectInput may deliver several device records before
|
||||||
// one retail GetInput pass. Accumulate raw motion here;
|
// one retail GetInput pass. Accumulate raw motion here;
|
||||||
// the update loop filters the aggregate exactly once.
|
// the update loop filters the aggregate exactly once.
|
||||||
_mouseLook.QueueDelta(dx, dy);
|
_gameplayInputFrame.QueueRawMouseDelta(dx, dy);
|
||||||
}
|
}
|
||||||
else if (_rmbHeld)
|
else if (_rmbHeld)
|
||||||
{
|
{
|
||||||
|
|
@ -1269,6 +1249,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// it fires, so the chase camera doesn't snap on top of
|
// it fires, so the chase camera doesn't snap on top of
|
||||||
// the fly camera mid-inspection.
|
// the fly camera mid-inspection.
|
||||||
_debugVm.ToggleFlyMode = ToggleFlyOrChase;
|
_debugVm.ToggleFlyMode = ToggleFlyOrChase;
|
||||||
|
_combatFeedback.ViewModel = _debugVm;
|
||||||
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
|
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
|
||||||
_panelHost.Register(_debugPanel);
|
_panelHost.Register(_debugPanel);
|
||||||
|
|
||||||
|
|
@ -1439,6 +1420,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_panelHost = null;
|
_panelHost = null;
|
||||||
_vitalsVm = null;
|
_vitalsVm = null;
|
||||||
_vitalsPanel = null;
|
_vitalsPanel = null;
|
||||||
|
_combatFeedback.ViewModel = null;
|
||||||
_debugVm = null;
|
_debugVm = null;
|
||||||
_debugPanel = null;
|
_debugPanel = null;
|
||||||
_chatPanel = null;
|
_chatPanel = null;
|
||||||
|
|
@ -1626,14 +1608,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// semantics even when the retail renderer is disabled.
|
// semantics even when the retail renderer is disabled.
|
||||||
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
|
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
|
||||||
Combat,
|
Combat,
|
||||||
CanStartLiveCombatAttack,
|
_combatAttackOperations);
|
||||||
SendLiveCombatAttack,
|
|
||||||
prepareAttackRequest: PreparePlayerForAttackRequest,
|
|
||||||
sendCancelAttack: () => LiveSession?.SendCancelAttack(),
|
|
||||||
isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
|
|
||||||
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
|
|
||||||
playerReadyForAttack: IsPlayerReadyForCombatAttack,
|
|
||||||
autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack);
|
|
||||||
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
|
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
|
||||||
Combat,
|
Combat,
|
||||||
_selection,
|
_selection,
|
||||||
|
|
@ -1702,6 +1677,7 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||||
|
_retainedInputCapture.Root = _uiHost.Root;
|
||||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
||||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||||
_itemInteractionController,
|
_itemInteractionController,
|
||||||
|
|
@ -1732,7 +1708,7 @@ public sealed class GameWindow : IDisposable
|
||||||
accountName: () => LiveSession?.Characters?.AccountName
|
accountName: () => LiveSession?.Characters?.AccountName
|
||||||
?? _options.LiveUser
|
?? _options.LiveUser
|
||||||
?? string.Empty,
|
?? string.Empty,
|
||||||
stopCompletely: PreparePlayerForAttackRequest,
|
stopCompletely: _combatAttackOperations.PrepareAttackRequest,
|
||||||
sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
|
sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
|
||||||
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
|
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
|
||||||
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||||
|
|
@ -2631,7 +2607,7 @@ public sealed class GameWindow : IDisposable
|
||||||
() => LiveSession,
|
() => LiveSession,
|
||||||
PublishLocalPhysicsTimestamps,
|
PublishLocalPhysicsTimestamps,
|
||||||
AimTeleportDestination,
|
AimTeleportDestination,
|
||||||
DumpMovementTruthServerEcho);
|
_movementTruthDiagnostics);
|
||||||
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
|
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
() => _playerServerGuid,
|
() => _playerServerGuid,
|
||||||
|
|
@ -2652,6 +2628,41 @@ public sealed class GameWindow : IDisposable
|
||||||
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
|
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
|
||||||
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
|
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
|
||||||
_liveSessionController = new AcDream.App.Net.LiveSessionController();
|
_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(
|
_streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
|
||||||
_options.LiveMode,
|
_options.LiveMode,
|
||||||
_localPlayerMode,
|
_localPlayerMode,
|
||||||
|
|
@ -2801,12 +2812,7 @@ public sealed class GameWindow : IDisposable
|
||||||
});
|
});
|
||||||
|
|
||||||
private void ResetSessionMouseCapture()
|
private void ResetSessionMouseCapture()
|
||||||
{
|
=> _gameplayInputFrame?.ResetSession();
|
||||||
bool wasActive = _mouseLook?.Active == true;
|
|
||||||
_mouseLook?.Release();
|
|
||||||
if (wasActive || _mouseLookSavedCursorMode.HasValue)
|
|
||||||
RestoreCursorAfterMouseLook();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ResetSessionPlayerPresentation()
|
private void ResetSessionPlayerPresentation()
|
||||||
{
|
{
|
||||||
|
|
@ -2822,10 +2828,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_playerHost = null;
|
_playerHost = null;
|
||||||
_chaseCamera = null;
|
_chaseCamera = null;
|
||||||
_retailChaseCamera = null;
|
_retailChaseCamera = null;
|
||||||
_playerMouseDeltaX = 0f;
|
_movementTruthDiagnostics.ResetSession();
|
||||||
_rmbHeld = false;
|
|
||||||
_autoRunActive = false;
|
|
||||||
_lastMovementTruthOutbound = null;
|
|
||||||
_localPlayerShadow.Clear();
|
_localPlayerShadow.Clear();
|
||||||
_spawnClaimRangeMemo = null;
|
_spawnClaimRangeMemo = null;
|
||||||
}
|
}
|
||||||
|
|
@ -3583,7 +3586,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|| !_teleportTransit.CanBegin(teleportSequence))
|
|| !_teleportTransit.CanBegin(teleportSequence))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
EndMouseLookAndRestoreCursor();
|
_gameplayInputFrame?.EndMouseLook();
|
||||||
// A fresh sequence is a new logical transit. Discard the prior
|
// A fresh sequence is a new logical transit. Discard the prior
|
||||||
// destination and presentation before this sequence can observe them;
|
// destination and presentation before this sequence can observe them;
|
||||||
// its destination PositionUpdate may arrive on a later network tick.
|
// 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
|
// Input callbacks feed the current object tick. Packets already read
|
||||||
// by Core.Net remain queued until the retail object/physics phase has
|
// by Core.Net remain queued until the retail object/physics phase has
|
||||||
// consumed that input and published its final pose.
|
// consumed that input and published its final pose.
|
||||||
_inputDispatcher?.Tick();
|
_gameplayInputFrame!.Tick(frameTiming);
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
// Drain pending live-session traffic AFTER streaming so any incoming
|
// Drain pending live-session traffic AFTER streaming so any incoming
|
||||||
// CreateObject events find their landblock already loaded in
|
// 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);
|
// character yaw (regression-prevention per K.1b plan §D);
|
||||||
// MouseDeltaX is hardcoded 0f. RMB held still pans the chase
|
// MouseDeltaX is hardcoded 0f. RMB held still pans the chase
|
||||||
// camera (handled in the mouse-move handler via _rmbHeld).
|
// 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;
|
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-style held-key offset integration. Only active when
|
||||||
// retail chase is selected; legacy camera ignores these.
|
// retail chase is selected; legacy camera ignores these.
|
||||||
|
|
@ -4069,7 +4027,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// walk speed.
|
// walk speed.
|
||||||
// * Q = AUTORUN TOGGLE: pressing Q latches forward-running
|
// * Q = AUTORUN TOGGLE: pressing Q latches forward-running
|
||||||
// until Q is pressed again. Handled in OnInputAction; here
|
// 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).
|
// * Mouse never drives character yaw (K.1b regression-prevention).
|
||||||
if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame))
|
if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame))
|
||||||
return;
|
return;
|
||||||
|
|
@ -4128,82 +4086,12 @@ public sealed class GameWindow : IDisposable
|
||||||
&& _inputDispatcher is not null
|
&& _inputDispatcher is not null
|
||||||
&& !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
|
&& !(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)
|
private void OnCameraModeChanged(bool _modeBool)
|
||||||
{
|
{
|
||||||
if (_mouseLook?.Active == true
|
if (_gameplayInputFrame?.MouseLookActive == true
|
||||||
&& _cameraController?.IsChaseMode != true)
|
&& _cameraController?.IsChaseMode != true)
|
||||||
{
|
{
|
||||||
EndMouseLookAndRestoreCursor();
|
_gameplayInputFrame?.EndMouseLook();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_input is null) return;
|
if (_input is null) return;
|
||||||
|
|
@ -4218,8 +4106,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// the only correct gate. Cursor visible by default in chase /
|
// the only correct gate. Cursor visible by default in chase /
|
||||||
// orbit modes; Raw cursor only in fly mode (continuous
|
// orbit modes; Raw cursor only in fly mode (continuous
|
||||||
// look-and-fly affordance). Mouse-look (raw mode) when MMB is
|
// look-and-fly affordance). Mouse-look (raw mode) when MMB is
|
||||||
// held is handled separately by HideCursorForMouseLook /
|
// held is handled separately by the gameplay input owner.
|
||||||
// RestoreCursorAfterMouseLook.
|
|
||||||
bool needsRawCursor = _cameraController?.IsFlyMode == true;
|
bool needsRawCursor = _cameraController?.IsFlyMode == true;
|
||||||
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
|
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
|
||||||
_capturedMouse = needsRawCursor ? mouse : null;
|
_capturedMouse = needsRawCursor ? mouse : null;
|
||||||
|
|
@ -6805,8 +6692,13 @@ public sealed class GameWindow : IDisposable
|
||||||
= AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default;
|
= AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default;
|
||||||
private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio
|
private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio
|
||||||
= AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default;
|
= AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default;
|
||||||
|
private readonly AcDream.App.Combat.GameplaySettingsState
|
||||||
|
_gameplaySettings = new();
|
||||||
private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay
|
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
|
private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat
|
||||||
= AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default;
|
= AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default;
|
||||||
private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter
|
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
|
// chords also fire Press on key-down + Release on key-up; we
|
||||||
// ignore the in-between Hold ticks here (the mouse-move handler
|
// ignore the in-between Hold ticks here (the mouse-move handler
|
||||||
// checks _rmbHeld each frame anyway).
|
// checks _rmbHeld each frame anyway).
|
||||||
if (action == AcDream.UI.Abstractions.Input.InputAction.AcdreamRmbOrbitHold)
|
if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true)
|
||||||
{
|
|
||||||
if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press)
|
|
||||||
_rmbHeld = _playerMode && _cameraController?.IsChaseMode == true;
|
|
||||||
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
|
|
||||||
_rmbHeld = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
// Phase K.2 — MMB-hold instant mouse-look. Press hides the
|
// Phase K.2 — MMB-hold instant mouse-look. Press hides the
|
||||||
// cursor + activates yaw drive; release restores. WantCapture
|
// cursor + activates yaw drive; release restores. WantCapture
|
||||||
// edge handling lives in OnUpdate; only Press needs to read it
|
// edge handling lives in OnUpdate; only Press needs to read it
|
||||||
// for the initial gate (defense in depth — the dispatcher
|
// for the initial gate (defense in depth — the dispatcher
|
||||||
// already filters on WantCaptureMouse in OnMouseDown).
|
// 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
|
// ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every
|
||||||
// wheel tick. Press is the only activation type for wheel.
|
// wheel tick. Press is the only activation type for wheel.
|
||||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp
|
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
|
// combat before the movement command enters CommandInterpreter. This
|
||||||
// notification does not consume the input; the movement owner below
|
// notification does not consume the input; the movement owner below
|
||||||
// still receives it normally.
|
// still receives it normally.
|
||||||
_combatAttackController?.HandleMovementInput(action, activation);
|
|
||||||
|
|
||||||
// Retail attack actions consume their full transition stream: key-down
|
// Retail attack actions consume their full transition stream: key-down
|
||||||
// starts the power build and key-up commits it. Handle them before the
|
// starts the power build and key-up commits it. Handle them before the
|
||||||
// generic Press-only gate below.
|
// generic Press-only gate below.
|
||||||
if (_combatAttackController?.HandleInputAction(action, activation) == true)
|
if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Every other action fires on Press only (no Release / Hold side-
|
// 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
|
// — retail-faithful: any deliberate movement input wins. (Pressing
|
||||||
// Forward AGAIN does NOT cancel — retail's autorun stays active
|
// Forward AGAIN does NOT cancel — retail's autorun stays active
|
||||||
// even when you press W; the two stack.)
|
// even when you press W; the two stack.)
|
||||||
if (action == AcDream.UI.Abstractions.Input.InputAction.MovementRunLock)
|
if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true)
|
||||||
{
|
|
||||||
_autoRunActive = !_autoRunActive;
|
|
||||||
return;
|
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)
|
switch (action)
|
||||||
{
|
{
|
||||||
|
|
@ -7313,7 +7142,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||||
else if (_playerMode)
|
else if (_playerMode)
|
||||||
{
|
{
|
||||||
EndMouseLookAndRestoreCursor();
|
_gameplayInputFrame?.EndMouseLook();
|
||||||
_playerMode = false;
|
_playerMode = false;
|
||||||
_cameraController?.ExitChaseMode();
|
_cameraController?.ExitChaseMode();
|
||||||
_playerController = null;
|
_playerController = null;
|
||||||
|
|
@ -7347,97 +7176,10 @@ public sealed class GameWindow : IDisposable
|
||||||
_debugVm?.AddToast(text);
|
_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(
|
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
|
||||||
AcDream.App.Input.MovementResult result) =>
|
AcDream.App.Input.MovementResult result) =>
|
||||||
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(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>
|
/// <summary>
|
||||||
/// Resolves retail's combat-camera target. ClientCombatSystem::
|
/// Resolves retail's combat-camera target. ClientCombatSystem::
|
||||||
/// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only
|
/// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only
|
||||||
|
|
@ -7531,12 +7273,11 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
EndMouseLookAndRestoreCursor();
|
_gameplayInputFrame?.EndMouseLook();
|
||||||
_cameraController?.ExitChaseMode();
|
_cameraController?.ExitChaseMode();
|
||||||
_playerController = null;
|
_playerController = null;
|
||||||
_chaseCamera = null;
|
_chaseCamera = null;
|
||||||
_retailChaseCamera = null;
|
_retailChaseCamera = null;
|
||||||
_playerMouseDeltaX = 0f;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8009,11 +7750,6 @@ public sealed class GameWindow : IDisposable
|
||||||
Aspect = _window!.Size.X / (float)_window.Size.Y,
|
Aspect = _window!.Size.X / (float)_window.Size.Y,
|
||||||
CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
|
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);
|
_cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera);
|
||||||
// K-fix1 (2026-04-26): latch the "we have entered chase at least
|
// K-fix1 (2026-04-26): latch the "we have entered chase at least
|
||||||
// once" flag so the live-mode pre-login render gate stops
|
// once" flag so the live-mode pre-login render gate stops
|
||||||
|
|
@ -8024,103 +7760,6 @@ public sealed class GameWindow : IDisposable
|
||||||
return true;
|
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>
|
/// <summary>
|
||||||
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
|
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
|
||||||
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
|
/// 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 ResourceShutdownStage("session dependents",
|
||||||
[
|
[
|
||||||
new("mouse capture", EndMouseLookAndRestoreCursor),
|
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()),
|
||||||
new("retail UI", () =>
|
new("retail UI", () =>
|
||||||
{
|
{
|
||||||
_retailUiRuntime?.Dispose();
|
_retailUiRuntime?.Dispose();
|
||||||
_retailUiRuntime = null;
|
_retailUiRuntime = null;
|
||||||
|
_retainedInputCapture.Root = null;
|
||||||
_uiHost = null;
|
_uiHost = null;
|
||||||
}),
|
}),
|
||||||
new("combat target", () =>
|
new("combat target", () =>
|
||||||
|
|
@ -8540,7 +8180,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private void OnFocusChanged(bool focused)
|
private void OnFocusChanged(bool focused)
|
||||||
{
|
{
|
||||||
if (!focused)
|
if (!focused)
|
||||||
EndMouseLookAndRestoreCursor();
|
_gameplayInputFrame?.EndMouseLook();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
|
||||||
|
|
@ -220,9 +220,9 @@ public sealed class InputDispatcher
|
||||||
if (action == InputAction.None) return false;
|
if (action == InputAction.None) return false;
|
||||||
// While a text field owns the keyboard ("write mode"), held game actions read as
|
// 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
|
// 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
|
// of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys;
|
||||||
// movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at
|
// the App movement owner separately pauses all movement for devtools capture while
|
||||||
// the call site) keeps driving the character, so chat doesn't cancel autorun.
|
// allowing retained-chat capture to preserve the retail autorun latch.
|
||||||
if (_mouse.WantCaptureKeyboard) return false;
|
if (_mouse.WantCaptureKeyboard) return false;
|
||||||
if (_automationHeldActions.Contains(action)) return true;
|
if (_automationHeldActions.Contains(action)) return true;
|
||||||
foreach (var b in _bindings.ForAction(action))
|
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(
|
var local = new RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: () => true,
|
canPresentPlayer: () => true,
|
||||||
getController: () => controller,
|
getController: () => controller,
|
||||||
captureInput: () => new MovementInput(Forward: true),
|
movementInput: Input(() => new MovementInput(Forward: true)),
|
||||||
resolveLocalEntityId: () => 7u,
|
resolveLocalEntityId: () => 7u,
|
||||||
handleTargeting: () => calls.Add("target"),
|
handleTargeting: () => calls.Add("target"),
|
||||||
isHidden: () => false,
|
isHidden: () => false,
|
||||||
|
|
@ -82,7 +82,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
||||||
var local = new RetailLocalPlayerFrameController(
|
var local = new RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: () => controller is not null,
|
canPresentPlayer: () => controller is not null,
|
||||||
getController: () => controller,
|
getController: () => controller,
|
||||||
captureInput: () => new MovementInput(Forward: true),
|
movementInput: Input(() => new MovementInput(Forward: true)),
|
||||||
resolveLocalEntityId: () => 9u,
|
resolveLocalEntityId: () => 9u,
|
||||||
handleTargeting: () => { },
|
handleTargeting: () => { },
|
||||||
isHidden: () => false,
|
isHidden: () => false,
|
||||||
|
|
@ -112,7 +112,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
||||||
var local = new RetailLocalPlayerFrameController(
|
var local = new RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: () => true,
|
canPresentPlayer: () => true,
|
||||||
getController: () => controller,
|
getController: () => controller,
|
||||||
captureInput: () => new MovementInput(),
|
movementInput: Input(() => new MovementInput()),
|
||||||
resolveLocalEntityId: () => 7u,
|
resolveLocalEntityId: () => 7u,
|
||||||
handleTargeting: () => { },
|
handleTargeting: () => { },
|
||||||
isHidden: () => false,
|
isHidden: () => false,
|
||||||
|
|
@ -143,7 +143,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
||||||
var local = new RetailLocalPlayerFrameController(
|
var local = new RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: () => true,
|
canPresentPlayer: () => true,
|
||||||
getController: () => controller,
|
getController: () => controller,
|
||||||
captureInput: () => new MovementInput(Forward: true),
|
movementInput: Input(() => new MovementInput(Forward: true)),
|
||||||
resolveLocalEntityId: () => 7u,
|
resolveLocalEntityId: () => 7u,
|
||||||
handleTargeting: () => positionSeenByTargetManager = controller.Position,
|
handleTargeting: () => positionSeenByTargetManager = controller.Position,
|
||||||
isHidden: () => false,
|
isHidden: () => false,
|
||||||
|
|
@ -169,11 +169,11 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
||||||
var local = new RetailLocalPlayerFrameController(
|
var local = new RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: () => true,
|
canPresentPlayer: () => true,
|
||||||
getController: () => controller,
|
getController: () => controller,
|
||||||
captureInput: () =>
|
movementInput: Input(() =>
|
||||||
{
|
{
|
||||||
inputCaptures++;
|
inputCaptures++;
|
||||||
return new MovementInput(Forward: true);
|
return new MovementInput(Forward: true);
|
||||||
},
|
}),
|
||||||
resolveLocalEntityId: () => 7u,
|
resolveLocalEntityId: () => 7u,
|
||||||
handleTargeting: () => throw new InvalidOperationException(
|
handleTargeting: () => throw new InvalidOperationException(
|
||||||
"Frozen object advanced its manager tail."),
|
"Frozen object advanced its manager tail."),
|
||||||
|
|
@ -204,7 +204,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
||||||
var local = new RetailLocalPlayerFrameController(
|
var local = new RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: () => true,
|
canPresentPlayer: () => true,
|
||||||
getController: () => controller,
|
getController: () => controller,
|
||||||
captureInput: () => new MovementInput(),
|
movementInput: Input(() => new MovementInput()),
|
||||||
resolveLocalEntityId: () => 7u,
|
resolveLocalEntityId: () => 7u,
|
||||||
handleTargeting: () => { },
|
handleTargeting: () => { },
|
||||||
isHidden: () => true,
|
isHidden: () => true,
|
||||||
|
|
@ -231,11 +231,11 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
||||||
var local = new RetailLocalPlayerFrameController(
|
var local = new RetailLocalPlayerFrameController(
|
||||||
canPresentPlayer: () => true,
|
canPresentPlayer: () => true,
|
||||||
getController: () => controller,
|
getController: () => controller,
|
||||||
captureInput: () =>
|
movementInput: Input(() =>
|
||||||
{
|
{
|
||||||
inputCaptures++;
|
inputCaptures++;
|
||||||
return new MovementInput(Forward: true);
|
return new MovementInput(Forward: true);
|
||||||
},
|
}),
|
||||||
resolveLocalEntityId: () => 7u,
|
resolveLocalEntityId: () => 7u,
|
||||||
handleTargeting: () => targeting++,
|
handleTargeting: () => targeting++,
|
||||||
isHidden: () => false,
|
isHidden: () => false,
|
||||||
|
|
@ -293,4 +293,13 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
||||||
Assert.True(clock.IsActive);
|
Assert.True(clock.IsActive);
|
||||||
return controller;
|
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));
|
Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow));
|
||||||
if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController))
|
if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController))
|
||||||
{
|
{
|
||||||
// Checkpoint B deliberately leaves this single legacy callback
|
// Checkpoint D replaced input capture with its typed owner.
|
||||||
// composition for D/F, which own input capture and the mutable
|
// Checkpoint F still owns the remaining player presentation
|
||||||
// player-mode/controller slot. No other phase owner may add one.
|
// callback composition. No other phase owner may add one.
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
ownerFields,
|
ownerFields,
|
||||||
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
ownerFields,
|
||||||
|
field => field.FieldType == typeof(Func<AcDream.App.Input.MovementInput>));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Assert.DoesNotContain(
|
Assert.DoesNotContain(
|
||||||
|
|
@ -364,7 +367,7 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"_streamingFrame.Tick();",
|
"_streamingFrame.Tick();",
|
||||||
"_inputDispatcher?.Tick();",
|
"_gameplayInputFrame!.Tick(frameTiming);",
|
||||||
"_liveFrameCoordinator.Tick(frameDelta);");
|
"_liveFrameCoordinator.Tick(frameDelta);");
|
||||||
Assert.DoesNotContain("_streamingController.Tick(observerCx", source,
|
Assert.DoesNotContain("_streamingController.Tick(observerCx", source,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
@ -373,6 +376,152 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
Assert.DoesNotContain("DrainRescued()", source, StringComparison.Ordinal);
|
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(
|
private static UpdateFrameOrchestrator Create(
|
||||||
List<string> calls,
|
List<string> calls,
|
||||||
RecordingTeardown? teardown = null,
|
RecordingTeardown? teardown = null,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue