refactor(input): extract the gameplay frame
This commit is contained in:
parent
0bc9fda9de
commit
c557038353
24 changed files with 2433 additions and 559 deletions
|
|
@ -4,6 +4,56 @@ using AcDream.UI.Abstractions.Input;
|
|||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
internal interface ICombatAttackOperations
|
||||
{
|
||||
bool CanStartAttack();
|
||||
void PrepareAttackRequest();
|
||||
bool SendAttack(AttackHeight height, float power);
|
||||
void SendCancelAttack();
|
||||
bool IsDualWield { get; }
|
||||
bool PlayerReadyForAttack { get; }
|
||||
bool AutoRepeatAttack { get; }
|
||||
}
|
||||
|
||||
internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
|
||||
{
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
|
||||
public DelegateCombatAttackOperations(
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
Action? prepareAttackRequest,
|
||||
Action? sendCancelAttack,
|
||||
Func<bool>? isDualWield,
|
||||
Func<bool>? playerReadyForAttack,
|
||||
Func<bool>? autoRepeatAttack)
|
||||
{
|
||||
_canStartAttack = canStartAttack
|
||||
?? throw new ArgumentNullException(nameof(canStartAttack));
|
||||
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
|
||||
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
|
||||
_sendCancelAttack = sendCancelAttack ?? (() => { });
|
||||
_isDualWield = isDualWield ?? (() => false);
|
||||
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
|
||||
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
|
||||
}
|
||||
|
||||
public bool CanStartAttack() => _canStartAttack();
|
||||
public void PrepareAttackRequest() => _prepareAttackRequest();
|
||||
public bool SendAttack(AttackHeight height, float power) =>
|
||||
_sendAttack(height, power);
|
||||
public void SendCancelAttack() => _sendCancelAttack();
|
||||
public bool IsDualWield => _isDualWield();
|
||||
public bool PlayerReadyForAttack => _playerReadyForAttack();
|
||||
public bool AutoRepeatAttack => _autoRepeatAttack();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail's basic-combat attack request and power-bar state machine.
|
||||
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
|
||||
|
|
@ -26,13 +76,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
public const float DesiredPowerStep = 1f / 6f;
|
||||
|
||||
private readonly CombatState _combat;
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
private readonly ICombatAttackOperations _operations;
|
||||
private readonly Func<double> _now;
|
||||
|
||||
private bool _buildInProgress;
|
||||
|
|
@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable
|
|||
Func<bool>? playerReadyForAttack = null,
|
||||
Func<bool>? autoRepeatAttack = null,
|
||||
Func<double>? now = null)
|
||||
: this(
|
||||
combat,
|
||||
new DelegateCombatAttackOperations(
|
||||
canStartAttack,
|
||||
sendAttack,
|
||||
prepareAttackRequest,
|
||||
sendCancelAttack,
|
||||
isDualWield,
|
||||
playerReadyForAttack,
|
||||
autoRepeatAttack),
|
||||
now)
|
||||
{
|
||||
}
|
||||
|
||||
internal CombatAttackController(
|
||||
CombatState combat,
|
||||
ICombatAttackOperations operations,
|
||||
Func<double>? now = null)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
|
||||
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
|
||||
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
|
||||
_sendCancelAttack = sendCancelAttack ?? (() => { });
|
||||
_isDualWield = isDualWield ?? (() => false);
|
||||
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
|
||||
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
|
||||
|
||||
_combat.CombatModeChanged += OnCombatModeChanged;
|
||||
|
|
@ -196,7 +252,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
&& !_repeatAttacking)
|
||||
return;
|
||||
|
||||
_sendCancelAttack();
|
||||
_operations.SendCancelAttack();
|
||||
_repeatAttacking = false;
|
||||
|
||||
if (_buildInProgress)
|
||||
|
|
@ -219,7 +275,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
if (!_buildInProgress)
|
||||
return;
|
||||
|
||||
if (!_playerReadyForAttack())
|
||||
if (!_operations.PlayerReadyForAttack)
|
||||
{
|
||||
if (_attackRequestInProgress)
|
||||
{
|
||||
|
|
@ -258,7 +314,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
private void StartAttackRequest()
|
||||
{
|
||||
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|
||||
|| !_canStartAttack())
|
||||
|| !_operations.CanStartAttack())
|
||||
return;
|
||||
|
||||
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
|
||||
|
|
@ -267,7 +323,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
// player movement object and must run before any later attack send.
|
||||
_attackRequestInProgress = true;
|
||||
_requestedAttackPower = 1f;
|
||||
_prepareAttackRequest();
|
||||
_operations.PrepareAttackRequest();
|
||||
_currentBuildIsAutomatic = false;
|
||||
AttemptStartBuildingAttack();
|
||||
}
|
||||
|
|
@ -276,7 +332,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
{
|
||||
if (_buildInProgress
|
||||
|| _attackServerResponsePending
|
||||
|| !_playerReadyForAttack())
|
||||
|| !_operations.PlayerReadyForAttack)
|
||||
return;
|
||||
StartPowerBarBuild();
|
||||
}
|
||||
|
|
@ -292,7 +348,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
{
|
||||
if (!_buildInProgress)
|
||||
return 0f;
|
||||
double duration = _isDualWield()
|
||||
double duration = _operations.IsDualWield
|
||||
? DualWieldPowerUpSeconds
|
||||
: AttackPowerUpSeconds;
|
||||
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
|
||||
|
|
@ -301,13 +357,15 @@ public sealed class CombatAttackController : IDisposable
|
|||
private void ExecuteAttack(AttackHeight height, bool setServerPending)
|
||||
{
|
||||
StopBuild();
|
||||
if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f)))
|
||||
if (!_operations.SendAttack(
|
||||
height,
|
||||
Math.Clamp(_requestedAttackPower, 0f, 1f)))
|
||||
{
|
||||
ResetPowerBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRepeatAttack())
|
||||
if (_operations.AutoRepeatAttack)
|
||||
_repeatAttacking = true;
|
||||
_attackServerResponsePending = setServerPending;
|
||||
}
|
||||
|
|
@ -330,7 +388,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
_repeatAttacking = false;
|
||||
|
||||
if (!_attackRequestInProgress
|
||||
&& _autoRepeatAttack()
|
||||
&& _operations.AutoRepeatAttack
|
||||
&& _repeatAttacking)
|
||||
{
|
||||
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
|
||||
|
|
@ -338,7 +396,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
ExecuteAttack(RequestedHeight, setServerPending: false);
|
||||
}
|
||||
|
||||
if (!_autoRepeatAttack() || !_repeatAttacking)
|
||||
if (!_operations.AutoRepeatAttack || !_repeatAttacking)
|
||||
{
|
||||
_repeatAttacking = false;
|
||||
ResetPowerBar();
|
||||
|
|
|
|||
116
src/AcDream.App/Combat/CombatAttackTargetSource.cs
Normal file
116
src/AcDream.App/Combat/CombatAttackTargetSource.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Focused automatic-attack target owner. It shares canonical selection and
|
||||
/// live-entity state without retaining the broader interaction controller.
|
||||
/// </summary>
|
||||
internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
|
||||
{
|
||||
private readonly SelectionState _selection;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly ILocalPlayerIdentitySource _player;
|
||||
|
||||
public CombatAttackTargetSource(
|
||||
SelectionState selection,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ClientObjectTable objects,
|
||||
ILocalPlayerIdentitySource player)
|
||||
{
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_liveEntities = liveEntities
|
||||
?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
}
|
||||
|
||||
public uint? SelectedObjectId => _selection.SelectedObjectId;
|
||||
|
||||
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
|
||||
{
|
||||
if (_selection.SelectedObjectId is { } selected
|
||||
&& IsHostileMonster(selected))
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
|
||||
if (!autoTarget)
|
||||
return null;
|
||||
|
||||
(uint Guid, float DistanceSquared)? closest = FindClosestHostileMonster();
|
||||
if (closest is not { } best)
|
||||
{
|
||||
_selection.Clear(SelectionChangeSource.Keyboard);
|
||||
return null;
|
||||
}
|
||||
|
||||
_selection.Select(best.Guid, SelectionChangeSource.Keyboard);
|
||||
string? name = _objects.Get(best.Guid)?.Name;
|
||||
string label = string.IsNullOrWhiteSpace(name)
|
||||
? $"0x{best.Guid:X8}"
|
||||
: name;
|
||||
Console.WriteLine(
|
||||
$"combat: selected target 0x{best.Guid:X8} {label} dist={MathF.Sqrt(best.DistanceSquared):F1}");
|
||||
return best.Guid;
|
||||
}
|
||||
|
||||
private (uint Guid, float DistanceSquared)? FindClosestHostileMonster()
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(
|
||||
_player.ServerGuid,
|
||||
out WorldEntity playerEntity))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
(uint Guid, float DistanceSquared)? best = null;
|
||||
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
|
||||
{
|
||||
if (!IsHostileMonster(guid))
|
||||
continue;
|
||||
|
||||
float distanceSquared = Vector3.DistanceSquared(
|
||||
entity.Position,
|
||||
playerEntity.Position);
|
||||
if (best is null || distanceSquared < best.Value.DistanceSquared)
|
||||
best = (guid, distanceSquared);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private bool IsHostileMonster(uint serverGuid)
|
||||
{
|
||||
uint playerGuid = _player.ServerGuid;
|
||||
if (serverGuid == playerGuid
|
||||
|| !_liveEntities.TryGetInteractionEligibleRecord(
|
||||
serverGuid,
|
||||
out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_liveEntities.TryGetAnimationRuntime(entity.Id, out var animation)
|
||||
&& animation.CurrentMotion == MotionCommand.Dead)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ClientObject? candidate = _objects.Get(serverGuid);
|
||||
return candidate is not null
|
||||
&& (candidate.Type & ItemType.Creature) != 0
|
||||
&& CombatTargetPolicy.IsHostileMonster(
|
||||
playerGuid,
|
||||
_objects.Get(playerGuid),
|
||||
candidate);
|
||||
}
|
||||
}
|
||||
180
src/AcDream.App/Combat/LiveCombatAttackOperations.cs
Normal file
180
src/AcDream.App/Combat/LiveCombatAttackOperations.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
internal interface ICombatAttackTargetSource
|
||||
{
|
||||
uint? SelectedObjectId { get; }
|
||||
uint? GetSelectedOrClosestCombatTarget(bool autoTarget);
|
||||
}
|
||||
|
||||
internal interface ICombatGameplaySettingsSource
|
||||
{
|
||||
bool AutoTarget { get; }
|
||||
bool AutoRepeatAttack { get; }
|
||||
}
|
||||
|
||||
internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
|
||||
{
|
||||
public GameplaySettings Value { get; set; } = GameplaySettings.Default;
|
||||
public bool AutoTarget => Value.AutoTarget;
|
||||
public bool AutoRepeatAttack => Value.AutoRepeatAttack;
|
||||
}
|
||||
|
||||
internal interface ICombatFeedbackSink
|
||||
{
|
||||
void Show(string message);
|
||||
}
|
||||
|
||||
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
||||
{
|
||||
public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; }
|
||||
public void Show(string message) => ViewModel?.AddToast(message);
|
||||
}
|
||||
|
||||
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
||||
{
|
||||
private ICombatAttackOperations? _owner;
|
||||
|
||||
public void Bind(ICombatAttackOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null && !ReferenceEquals(_owner, owner))
|
||||
throw new InvalidOperationException(
|
||||
"Combat attack operations are already bound.");
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
public bool CanStartAttack() => _owner?.CanStartAttack() == true;
|
||||
public void PrepareAttackRequest() => _owner?.PrepareAttackRequest();
|
||||
public bool SendAttack(AttackHeight height, float power) =>
|
||||
_owner?.SendAttack(height, power) == true;
|
||||
public void SendCancelAttack() => _owner?.SendCancelAttack();
|
||||
public bool IsDualWield => _owner?.IsDualWield == true;
|
||||
public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true;
|
||||
public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production combat request dependencies. The attack state machine retains
|
||||
/// this typed owner rather than callbacks into the application window.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatAttackOperations : ICombatAttackOperations
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly ICombatAttackTargetSource _targets;
|
||||
private readonly ICombatGameplaySettingsSource _settings;
|
||||
private readonly ILocalPlayerControllerSource _player;
|
||||
private readonly LocalPlayerOutboundController _outbound;
|
||||
private readonly ILiveInWorldSource _inWorld;
|
||||
private readonly ILiveWorldSessionSource _session;
|
||||
private readonly ICombatFeedbackSink _feedback;
|
||||
|
||||
public LiveCombatAttackOperations(
|
||||
CombatState combat,
|
||||
ICombatAttackTargetSource targets,
|
||||
ICombatGameplaySettingsSource settings,
|
||||
ILocalPlayerControllerSource player,
|
||||
LocalPlayerOutboundController outbound,
|
||||
ILiveInWorldSource inWorld,
|
||||
ILiveWorldSessionSource session,
|
||||
ICombatFeedbackSink feedback)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
||||
_inWorld = inWorld ?? throw new ArgumentNullException(nameof(inWorld));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_feedback = feedback ?? throw new ArgumentNullException(nameof(feedback));
|
||||
}
|
||||
|
||||
public bool IsDualWield =>
|
||||
_player.Controller?.Motion.InterpretedState.CurrentStyle
|
||||
== CombatInputPlanner.DualWieldCombatStyle;
|
||||
|
||||
public bool PlayerReadyForAttack
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_player.Controller is not { } controller)
|
||||
return false;
|
||||
var motion = controller.Motion.InterpretedState;
|
||||
return CombatInputPlanner.PlayerInReadyPositionForAttack(
|
||||
_combat.CurrentMode,
|
||||
motion.CurrentStyle,
|
||||
motion.ForwardCommand);
|
||||
}
|
||||
}
|
||||
|
||||
public bool AutoRepeatAttack => _settings.AutoRepeatAttack;
|
||||
|
||||
public bool CanStartAttack()
|
||||
{
|
||||
if (!_inWorld.IsInWorld)
|
||||
return false;
|
||||
|
||||
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
{
|
||||
_feedback.Show("Enter melee or missile combat first");
|
||||
Console.WriteLine(
|
||||
"combat: attack ignored; not in melee/missile combat mode");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null)
|
||||
{
|
||||
_feedback.Show("No monster target");
|
||||
Console.WriteLine("combat: attack ignored; no creature target found");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SendAttack(AttackHeight height, float power)
|
||||
{
|
||||
if (!CanStartAttack()
|
||||
|| _session.CurrentSession is not { } session
|
||||
|| _targets.SelectedObjectId is not { } target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
power = Math.Clamp(power, 0f, 1f);
|
||||
if (_combat.CurrentMode == CombatMode.Missile)
|
||||
{
|
||||
session.SendMissileAttack(target, height, power);
|
||||
Console.WriteLine(
|
||||
$"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
session.SendMeleeAttack(target, height, power);
|
||||
Console.WriteLine(
|
||||
$"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SendCancelAttack() =>
|
||||
_session.CurrentSession?.SendCancelAttack();
|
||||
|
||||
public void PrepareAttackRequest()
|
||||
{
|
||||
if (_player.Controller is not { } controller
|
||||
|| !controller.PrepareForAttackRequest())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_outbound.TrySendMovement(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
controller.CaptureMovementResult(mouseLookEvent: false));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue