refactor(runtime): own combat and magic intent
Move attack build/repeat state, combat-mode policy, authoritative auto-target transitions, and spell-cast intent beneath RuntimeActionState. Keep App as the input, world-query, DAT-policy, transport, and presentation adapter while preserving retail request and busy ordering. Add direct/graphical parity, reset, failure, and instance-isolation coverage. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
81b31857c6
commit
20df9d155d
49 changed files with 1949 additions and 634 deletions
|
|
@ -3,6 +3,20 @@ using AcDream.Runtime.Gameplay;
|
|||
|
||||
namespace AcDream.Runtime;
|
||||
|
||||
public readonly record struct RuntimeCombatAttackSnapshot(
|
||||
long Revision,
|
||||
AttackHeight RequestedHeight,
|
||||
float DesiredPower,
|
||||
float PowerBarLevel,
|
||||
bool BuildInProgress,
|
||||
bool RequestInProgress,
|
||||
float RequestedPower);
|
||||
|
||||
public readonly record struct RuntimeSpellCastSnapshot(
|
||||
long Revision,
|
||||
uint LastRequestedSpellId,
|
||||
uint LastRequestedTargetId);
|
||||
|
||||
public readonly record struct RuntimeActionSnapshot(
|
||||
long SelectionRevision,
|
||||
uint SelectedObjectId,
|
||||
|
|
@ -14,7 +28,9 @@ public readonly record struct RuntimeActionSnapshot(
|
|||
long InteractionRevision,
|
||||
InteractionModeKind InteractionMode,
|
||||
uint InteractionSourceObjectId,
|
||||
RuntimeInteractionTransactionSnapshot InteractionTransactions);
|
||||
RuntimeInteractionTransactionSnapshot InteractionTransactions,
|
||||
RuntimeCombatAttackSnapshot CombatAttack = default,
|
||||
RuntimeSpellCastSnapshot Magic = default);
|
||||
|
||||
public interface IRuntimeActionView
|
||||
{
|
||||
|
|
|
|||
|
|
@ -112,6 +112,19 @@ public interface IRuntimeCombatCommands
|
|||
RuntimeCommandResult Execute(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
RuntimeCombatCommand command);
|
||||
|
||||
RuntimeCommandResult ExecuteAttack(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in Gameplay.RuntimeCombatAttackInput command);
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeMagicCommand(uint SpellId);
|
||||
|
||||
public interface IRuntimeMagicCommands
|
||||
{
|
||||
RuntimeCommandResult Execute(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeMagicCommand command);
|
||||
}
|
||||
|
||||
public interface IRuntimeMovementCommands
|
||||
|
|
@ -251,6 +264,8 @@ public interface IGameRuntimeCommands
|
|||
|
||||
IRuntimeCombatCommands Combat { get; }
|
||||
|
||||
IRuntimeMagicCommands Magic { get; }
|
||||
|
||||
IRuntimeMovementCommands Movement { get; }
|
||||
|
||||
IRuntimeChatCommands Chat { get; }
|
||||
|
|
|
|||
|
|
@ -7,16 +7,17 @@ public readonly record struct RuntimeEventStamp(
|
|||
|
||||
public enum RuntimeCommandDomain
|
||||
{
|
||||
Session,
|
||||
Selection,
|
||||
Combat,
|
||||
Movement,
|
||||
Chat,
|
||||
Portal,
|
||||
InventoryState,
|
||||
Spellbook,
|
||||
Character,
|
||||
Social,
|
||||
Session = 0,
|
||||
Selection = 1,
|
||||
Combat = 2,
|
||||
Movement = 3,
|
||||
Chat = 4,
|
||||
Portal = 5,
|
||||
InventoryState = 6,
|
||||
Spellbook = 7,
|
||||
Character = 8,
|
||||
Social = 9,
|
||||
Magic = 10,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeLifecycleDelta(
|
||||
|
|
@ -176,7 +177,17 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
|
|||
$"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.OutboundCount}:" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken};" +
|
||||
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken}:" +
|
||||
$"{checkpoint.Actions.CombatAttack.Revision}:" +
|
||||
$"{(int)checkpoint.Actions.CombatAttack.RequestedHeight}:" +
|
||||
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.DesiredPower):X8}:" +
|
||||
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.PowerBarLevel):X8}:" +
|
||||
$"{checkpoint.Actions.CombatAttack.BuildInProgress}:" +
|
||||
$"{checkpoint.Actions.CombatAttack.RequestInProgress}:" +
|
||||
$"{BitConverter.SingleToInt32Bits(checkpoint.Actions.CombatAttack.RequestedPower):X8}:" +
|
||||
$"{checkpoint.Actions.Magic.Revision}:" +
|
||||
$"{checkpoint.Actions.Magic.LastRequestedSpellId:X8}:" +
|
||||
$"{checkpoint.Actions.Magic.LastRequestedTargetId:X8};" +
|
||||
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
|
||||
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
|
|
@ -8,6 +9,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
|
|||
bool IsDisposed,
|
||||
bool InternalSubscriptionsAttached,
|
||||
bool InteractionTransactionsDisposed,
|
||||
bool CombatAttackDisposed,
|
||||
bool CombatTargetDisposed,
|
||||
bool SpellCastReset,
|
||||
uint SelectedObjectId,
|
||||
uint PreviousObjectId,
|
||||
uint PreviousValidObjectId,
|
||||
|
|
@ -23,6 +27,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
|
|||
IsDisposed
|
||||
&& !InternalSubscriptionsAttached
|
||||
&& InteractionTransactionsDisposed
|
||||
&& CombatAttackDisposed
|
||||
&& CombatTargetDisposed
|
||||
&& SpellCastReset
|
||||
&& SelectedObjectId == 0u
|
||||
&& PreviousObjectId == 0u
|
||||
&& PreviousValidObjectId == 0u
|
||||
|
|
@ -33,9 +40,9 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for selection, combat
|
||||
/// notifications/mode, and temporary interaction target mode. Graphical and
|
||||
/// no-window hosts borrow these exact instances.
|
||||
/// Canonical presentation-independent owner for selection, interaction
|
||||
/// transactions, combat mode/attack/target intent, and spell-cast intent.
|
||||
/// Graphical and no-window hosts borrow these exact instances.
|
||||
/// </summary>
|
||||
public sealed class RuntimeActionState : IDisposable
|
||||
{
|
||||
|
|
@ -44,21 +51,52 @@ public sealed class RuntimeActionState : IDisposable
|
|||
private long _selectionRevision;
|
||||
private long _combatRevision;
|
||||
private long _interactionRevision;
|
||||
private long _combatIntentRevision;
|
||||
private long _magicIntentRevision;
|
||||
|
||||
public RuntimeActionState(InventoryTransactionState inventoryTransactions)
|
||||
public RuntimeActionState(
|
||||
InventoryTransactionState inventoryTransactions,
|
||||
Spellbook spellbook,
|
||||
IRuntimeCombatAttackOperations combatAttackOperations,
|
||||
IRuntimeCombatTargetOperations combatTargetOperations,
|
||||
IRuntimeCombatModeOperations combatModeOperations,
|
||||
IRuntimeSpellCastOperations spellCastOperations,
|
||||
Func<double>? now = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventoryTransactions);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(combatAttackOperations);
|
||||
ArgumentNullException.ThrowIfNull(combatTargetOperations);
|
||||
ArgumentNullException.ThrowIfNull(combatModeOperations);
|
||||
ArgumentNullException.ThrowIfNull(spellCastOperations);
|
||||
Selection = new SelectionState();
|
||||
Combat = new CombatState();
|
||||
Interaction = new InteractionState();
|
||||
Transactions = new RuntimeInteractionTransactionState(
|
||||
inventoryTransactions);
|
||||
CombatAttack = new RuntimeCombatAttackState(
|
||||
Combat,
|
||||
combatAttackOperations,
|
||||
now);
|
||||
CombatTarget = new RuntimeCombatTargetState(
|
||||
Combat,
|
||||
Selection,
|
||||
combatTargetOperations);
|
||||
CombatMode = new RuntimeCombatModeState(
|
||||
Combat,
|
||||
combatModeOperations);
|
||||
SpellCast = new RuntimeSpellCastState(
|
||||
spellbook,
|
||||
Selection,
|
||||
spellCastOperations);
|
||||
View = new ActionView(this);
|
||||
|
||||
Selection.Changed += OnSelectionChanged;
|
||||
Combat.CombatModeChanged += OnCombatModeChanged;
|
||||
Combat.HealthChanged += OnHealthChanged;
|
||||
Interaction.Changed += OnInteractionChanged;
|
||||
CombatAttack.StateChanged += OnCombatAttackChanged;
|
||||
SpellCast.StateChanged += OnSpellCastChanged;
|
||||
_internalSubscriptionsAttached = true;
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +104,10 @@ public sealed class RuntimeActionState : IDisposable
|
|||
public CombatState Combat { get; }
|
||||
public InteractionState Interaction { get; }
|
||||
public RuntimeInteractionTransactionState Transactions { get; }
|
||||
public RuntimeCombatAttackState CombatAttack { get; }
|
||||
public RuntimeCombatTargetState CombatTarget { get; }
|
||||
public RuntimeCombatModeState CombatMode { get; }
|
||||
public RuntimeSpellCastState SpellCast { get; }
|
||||
public IRuntimeActionView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
|
|
@ -73,6 +115,10 @@ public sealed class RuntimeActionState : IDisposable
|
|||
_disposed,
|
||||
_internalSubscriptionsAttached,
|
||||
Transactions.IsDisposed,
|
||||
CombatAttack.IsDisposed,
|
||||
CombatTarget.IsDisposed,
|
||||
SpellCast.LastRequestedSpellId is null
|
||||
&& SpellCast.LastRequestedTargetId is null,
|
||||
Selection.SelectedObjectId ?? 0u,
|
||||
Selection.PreviousObjectId ?? 0u,
|
||||
Selection.PreviousValidObjectId ?? 0u,
|
||||
|
|
@ -95,6 +141,8 @@ public sealed class RuntimeActionState : IDisposable
|
|||
List<Exception>? failures = null;
|
||||
Try(Transactions.ResetSession, ref failures);
|
||||
Try(Interaction.ResetSession, ref failures);
|
||||
Try(SpellCast.Reset, ref failures);
|
||||
Try(CombatAttack.ResetSession, ref failures);
|
||||
Try(() => Selection.Reset(), ref failures);
|
||||
Try(Combat.Clear, ref failures);
|
||||
if (failures is not null)
|
||||
|
|
@ -115,6 +163,8 @@ public sealed class RuntimeActionState : IDisposable
|
|||
{
|
||||
Try(Transactions.Dispose, ref failures);
|
||||
Try(Interaction.ResetSession, ref failures);
|
||||
Try(SpellCast.Reset, ref failures);
|
||||
Try(CombatAttack.ResetSession, ref failures);
|
||||
Try(() => Selection.Reset(), ref failures);
|
||||
Try(Combat.Clear, ref failures);
|
||||
}
|
||||
|
|
@ -124,6 +174,10 @@ public sealed class RuntimeActionState : IDisposable
|
|||
Combat.CombatModeChanged -= OnCombatModeChanged;
|
||||
Combat.HealthChanged -= OnHealthChanged;
|
||||
Interaction.Changed -= OnInteractionChanged;
|
||||
CombatAttack.StateChanged -= OnCombatAttackChanged;
|
||||
SpellCast.StateChanged -= OnSpellCastChanged;
|
||||
Try(CombatAttack.Dispose, ref failures);
|
||||
Try(CombatTarget.Dispose, ref failures);
|
||||
_internalSubscriptionsAttached = false;
|
||||
_disposed = true;
|
||||
}
|
||||
|
|
@ -148,6 +202,12 @@ public sealed class RuntimeActionState : IDisposable
|
|||
private void OnInteractionChanged(InteractionModeTransition _) =>
|
||||
Interlocked.Increment(ref _interactionRevision);
|
||||
|
||||
private void OnCombatAttackChanged() =>
|
||||
Interlocked.Increment(ref _combatIntentRevision);
|
||||
|
||||
private void OnSpellCastChanged() =>
|
||||
Interlocked.Increment(ref _magicIntentRevision);
|
||||
|
||||
private static void Try(Action action, ref List<Exception>? failures)
|
||||
{
|
||||
try
|
||||
|
|
@ -174,7 +234,19 @@ public sealed class RuntimeActionState : IDisposable
|
|||
Interlocked.Read(ref owner._interactionRevision),
|
||||
owner.Interaction.Current.Kind,
|
||||
owner.Interaction.Current.SourceObjectId,
|
||||
owner.Transactions.CaptureOwnership());
|
||||
owner.Transactions.CaptureOwnership(),
|
||||
new RuntimeCombatAttackSnapshot(
|
||||
Interlocked.Read(ref owner._combatIntentRevision),
|
||||
owner.CombatAttack.RequestedHeight,
|
||||
owner.CombatAttack.DesiredPower,
|
||||
owner.CombatAttack.PowerBarLevel,
|
||||
owner.CombatAttack.BuildInProgress,
|
||||
owner.CombatAttack.AttackRequestInProgress,
|
||||
owner.CombatAttack.RequestedAttackPower),
|
||||
new RuntimeSpellCastSnapshot(
|
||||
Interlocked.Read(ref owner._magicIntentRevision),
|
||||
owner.SpellCast.LastRequestedSpellId ?? 0u,
|
||||
owner.SpellCast.LastRequestedTargetId ?? 0u));
|
||||
|
||||
public bool TryGetHealth(uint objectId, out float healthPercent)
|
||||
{
|
||||
|
|
|
|||
497
src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs
Normal file
497
src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public enum RuntimeInputActivation
|
||||
{
|
||||
Press,
|
||||
Release,
|
||||
}
|
||||
|
||||
public enum RuntimeCombatAttackCommand
|
||||
{
|
||||
LowAttack,
|
||||
MediumAttack,
|
||||
HighAttack,
|
||||
DecreasePower,
|
||||
IncreasePower,
|
||||
AbortForMovement,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeCombatAttackInput(
|
||||
RuntimeCombatAttackCommand Command,
|
||||
RuntimeInputActivation Activation);
|
||||
|
||||
public interface IRuntimeCombatAttackOperations
|
||||
{
|
||||
bool CanStartAttack();
|
||||
void PrepareAttackRequest();
|
||||
bool SendAttack(AttackHeight height, float power);
|
||||
void SendCancelAttack();
|
||||
bool IsDualWield { get; }
|
||||
bool PlayerReadyForAttack { get; }
|
||||
bool AutoRepeatAttack { get; }
|
||||
}
|
||||
|
||||
internal sealed class DelegateRuntimeCombatAttackOperations
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
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 DelegateRuntimeCombatAttackOperations(
|
||||
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.
|
||||
/// Graphical controls and no-window hosts both enter through typed Runtime
|
||||
/// commands; the wire send remains a host callback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail references: <c>ClientCombatSystem::Begin</c> (0x0056A460),
|
||||
/// <c>StartPowerBarBuild</c> (0x0056ADB0), <c>GetPowerBarLevel</c>
|
||||
/// (0x0056ADE0), <c>StartAttackRequest</c> (0x0056C040),
|
||||
/// <c>EndAttackRequest</c> (0x0056C0E0), <c>UseTime</c> (0x0056C1F0),
|
||||
/// <c>HandleAttackDoneEvent</c> (0x0056C500), and
|
||||
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0), and
|
||||
/// <c>AbortAutomaticAttack</c> (0x0056AE90).
|
||||
/// </remarks>
|
||||
public sealed class RuntimeCombatAttackState : IDisposable
|
||||
{
|
||||
public const double AttackPowerUpSeconds = CombatInputPlanner.AttackPowerUpSeconds;
|
||||
public const double DualWieldPowerUpSeconds = CombatInputPlanner.DualWieldPowerUpSeconds;
|
||||
public const float InitialDesiredPower = 0.5f;
|
||||
public const float DesiredPowerStep = 1f / 6f;
|
||||
|
||||
private readonly CombatState _combat;
|
||||
private readonly IRuntimeCombatAttackOperations _operations;
|
||||
private readonly Func<double> _now;
|
||||
|
||||
private bool _buildInProgress;
|
||||
private double _buildStartTime;
|
||||
private bool _attackRequestInProgress;
|
||||
private bool _attackServerResponsePending;
|
||||
private bool _attackWhenResponseReceived;
|
||||
private float _attackWhenResponseReceivedPower;
|
||||
private bool _currentBuildIsAutomatic;
|
||||
private bool _repeatAttacking;
|
||||
private float _requestedAttackPower;
|
||||
private float _latestPowerBarLevel;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeCombatAttackState(
|
||||
CombatState combat,
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
Action? prepareAttackRequest = null,
|
||||
Action? sendCancelAttack = null,
|
||||
Func<bool>? isDualWield = null,
|
||||
Func<bool>? playerReadyForAttack = null,
|
||||
Func<bool>? autoRepeatAttack = null,
|
||||
Func<double>? now = null)
|
||||
: this(
|
||||
combat,
|
||||
new DelegateRuntimeCombatAttackOperations(
|
||||
canStartAttack,
|
||||
sendAttack,
|
||||
prepareAttackRequest,
|
||||
sendCancelAttack,
|
||||
isDualWield,
|
||||
playerReadyForAttack,
|
||||
autoRepeatAttack),
|
||||
now)
|
||||
{
|
||||
}
|
||||
|
||||
public RuntimeCombatAttackState(
|
||||
CombatState combat,
|
||||
IRuntimeCombatAttackOperations operations,
|
||||
Func<double>? now = null)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
|
||||
|
||||
_combat.CombatModeChanged += OnCombatModeChanged;
|
||||
_combat.AttackCommenced += OnAttackCommenced;
|
||||
_combat.AttackDone += OnAttackDone;
|
||||
}
|
||||
|
||||
public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium;
|
||||
public float DesiredPower { get; private set; } = InitialDesiredPower;
|
||||
public bool AttackRequestInProgress => _attackRequestInProgress;
|
||||
public float RequestedAttackPower => _requestedAttackPower;
|
||||
public bool BuildInProgress => _buildInProgress;
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
/// <summary>The level retail publishes to the embedded combat meter.</summary>
|
||||
public float PowerBarLevel => _buildInProgress
|
||||
? GetPowerBarLevel()
|
||||
: _latestPowerBarLevel;
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
||||
public bool HandleCommand(in RuntimeCombatAttackInput command)
|
||||
{
|
||||
AttackHeight? height = command.Command switch
|
||||
{
|
||||
RuntimeCombatAttackCommand.LowAttack => AttackHeight.Low,
|
||||
RuntimeCombatAttackCommand.MediumAttack => AttackHeight.Medium,
|
||||
RuntimeCombatAttackCommand.HighAttack => AttackHeight.High,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (height is not null)
|
||||
{
|
||||
if (command.Activation == RuntimeInputActivation.Press)
|
||||
PressAttack(height.Value);
|
||||
else if (command.Activation == RuntimeInputActivation.Release)
|
||||
ReleaseAttack();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command.Activation != RuntimeInputActivation.Press)
|
||||
return false;
|
||||
|
||||
if (command.Command == RuntimeCombatAttackCommand.DecreasePower)
|
||||
{
|
||||
StepDesiredPower(-1);
|
||||
return true;
|
||||
}
|
||||
if (command.Command == RuntimeCombatAttackCommand.IncreasePower)
|
||||
{
|
||||
StepDesiredPower(1);
|
||||
return true;
|
||||
}
|
||||
if (command.Command == RuntimeCombatAttackCommand.AbortForMovement)
|
||||
{
|
||||
AbortAutomaticAttack();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes the semantic inputs that retail turns into a new forward-motion
|
||||
/// command. Turns and sidesteps live in separate command lists and do not
|
||||
/// call <c>HandleNewForwardMovement</c>. Jump has its own equivalent cancel
|
||||
/// in <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
|
||||
/// </summary>
|
||||
public void SetDesiredPower(float power)
|
||||
{
|
||||
float value = Math.Clamp(power, 0f, 1f);
|
||||
if (Math.Abs(DesiredPower - value) < float.Epsilon)
|
||||
return;
|
||||
DesiredPower = value;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void PressAttack(AttackHeight height)
|
||||
{
|
||||
bool heightChanged = RequestedHeight != height;
|
||||
RequestedHeight = height;
|
||||
if (heightChanged || !_attackRequestInProgress)
|
||||
StartAttackRequest();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void ReleaseAttack()
|
||||
{
|
||||
if (!_attackRequestInProgress)
|
||||
return;
|
||||
|
||||
_attackRequestInProgress = false;
|
||||
float currentPower = GetPowerBarLevel();
|
||||
_requestedAttackPower = Math.Min(DesiredPower, currentPower);
|
||||
|
||||
if (_attackServerResponsePending)
|
||||
{
|
||||
_attackWhenResponseReceived = true;
|
||||
_attackWhenResponseReceivedPower = _requestedAttackPower;
|
||||
}
|
||||
else if (DesiredPower <= currentPower || _repeatAttacking)
|
||||
{
|
||||
ExecuteAttack(RequestedHeight, setServerPending: true);
|
||||
}
|
||||
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientCombatSystem::AbortAutomaticAttack</c> (0x0056AE90).
|
||||
/// A new forward-movement command enters here through
|
||||
/// <c>ACCmdInterp::HandleNewForwardMovement</c> (0x0058B1F0): notify the
|
||||
/// server, end repeat mode, and hide an in-progress combat power build.
|
||||
/// Request/pending flags are deliberately left for the normal server
|
||||
/// response, matching retail.
|
||||
/// </summary>
|
||||
public void AbortAutomaticAttack()
|
||||
{
|
||||
if (!_attackServerResponsePending
|
||||
&& !_attackRequestInProgress
|
||||
&& !_repeatAttacking)
|
||||
return;
|
||||
|
||||
_operations.SendCancelAttack();
|
||||
_repeatAttacking = false;
|
||||
|
||||
if (_buildInProgress)
|
||||
ResetPowerBar();
|
||||
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UseTime</c>: publishes the live build and commits a released
|
||||
/// request once the ticking level reaches the power captured on release.
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (_attackRequestInProgress
|
||||
&& !_buildInProgress
|
||||
&& !_attackServerResponsePending)
|
||||
AttemptStartBuildingAttack();
|
||||
|
||||
if (!_buildInProgress)
|
||||
return;
|
||||
|
||||
if (!_operations.PlayerReadyForAttack)
|
||||
{
|
||||
if (_attackRequestInProgress)
|
||||
{
|
||||
StopBuild();
|
||||
_latestPowerBarLevel = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
_repeatAttacking = false;
|
||||
ResetPowerBar();
|
||||
}
|
||||
StateChanged?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
float currentPower = GetPowerBarLevel();
|
||||
_latestPowerBarLevel = currentPower;
|
||||
if (!_attackRequestInProgress
|
||||
&& currentPower >= _requestedAttackPower)
|
||||
{
|
||||
_latestPowerBarLevel = Math.Min(_requestedAttackPower, currentPower);
|
||||
if (!_currentBuildIsAutomatic)
|
||||
ExecuteAttack(RequestedHeight, setServerPending: true);
|
||||
else
|
||||
StopBuild();
|
||||
}
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void StepDesiredPower(int direction)
|
||||
{
|
||||
int sixth = (int)MathF.Round(DesiredPower / DesiredPowerStep);
|
||||
SetDesiredPower((sixth + Math.Sign(direction)) * DesiredPowerStep);
|
||||
}
|
||||
|
||||
private void StartAttackRequest()
|
||||
{
|
||||
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|
||||
|| !_operations.CanStartAttack())
|
||||
return;
|
||||
|
||||
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
|
||||
// and requested power first, then FinishJump and
|
||||
// CommandInterpreter::MaybeStopCompletely. The host callback owns the
|
||||
// player movement object and must run before any later attack send.
|
||||
_attackRequestInProgress = true;
|
||||
_requestedAttackPower = 1f;
|
||||
_operations.PrepareAttackRequest();
|
||||
_currentBuildIsAutomatic = false;
|
||||
AttemptStartBuildingAttack();
|
||||
}
|
||||
|
||||
private void AttemptStartBuildingAttack()
|
||||
{
|
||||
if (_buildInProgress
|
||||
|| _attackServerResponsePending
|
||||
|| !_operations.PlayerReadyForAttack)
|
||||
return;
|
||||
StartPowerBarBuild();
|
||||
}
|
||||
|
||||
private void StartPowerBarBuild()
|
||||
{
|
||||
_buildInProgress = true;
|
||||
_buildStartTime = _now();
|
||||
_latestPowerBarLevel = 0f;
|
||||
}
|
||||
|
||||
private float GetPowerBarLevel()
|
||||
{
|
||||
if (!_buildInProgress)
|
||||
return 0f;
|
||||
double duration = _operations.IsDualWield
|
||||
? DualWieldPowerUpSeconds
|
||||
: AttackPowerUpSeconds;
|
||||
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
|
||||
}
|
||||
|
||||
private void ExecuteAttack(AttackHeight height, bool setServerPending)
|
||||
{
|
||||
StopBuild();
|
||||
if (!_operations.SendAttack(
|
||||
height,
|
||||
Math.Clamp(_requestedAttackPower, 0f, 1f)))
|
||||
{
|
||||
ResetPowerBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_operations.AutoRepeatAttack)
|
||||
_repeatAttacking = true;
|
||||
_attackServerResponsePending = setServerPending;
|
||||
}
|
||||
|
||||
private void OnAttackCommenced()
|
||||
{
|
||||
_attackServerResponsePending = true;
|
||||
if (!_attackRequestInProgress)
|
||||
{
|
||||
_latestPowerBarLevel = _requestedAttackPower;
|
||||
StopBuild(preserveLevel: true);
|
||||
}
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void OnAttackDone(uint _, uint weenieError)
|
||||
{
|
||||
_attackServerResponsePending = false;
|
||||
if (weenieError != 0)
|
||||
_repeatAttacking = false;
|
||||
|
||||
if (!_attackRequestInProgress
|
||||
&& _operations.AutoRepeatAttack
|
||||
&& _repeatAttacking)
|
||||
{
|
||||
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
|
||||
_requestedAttackPower = DesiredPower;
|
||||
ExecuteAttack(RequestedHeight, setServerPending: false);
|
||||
}
|
||||
|
||||
if (!_operations.AutoRepeatAttack || !_repeatAttacking)
|
||||
{
|
||||
_repeatAttacking = false;
|
||||
ResetPowerBar();
|
||||
}
|
||||
else if (_attackRequestInProgress)
|
||||
{
|
||||
AttemptStartBuildingAttack();
|
||||
}
|
||||
else
|
||||
{
|
||||
StartPowerBarBuild();
|
||||
_currentBuildIsAutomatic = true;
|
||||
}
|
||||
|
||||
if (_attackWhenResponseReceived)
|
||||
{
|
||||
float queuedPower = _attackWhenResponseReceivedPower;
|
||||
_attackWhenResponseReceived = false;
|
||||
_attackWhenResponseReceivedPower = 0f;
|
||||
StartAttackRequest();
|
||||
if (_attackRequestInProgress)
|
||||
{
|
||||
_requestedAttackPower = queuedPower;
|
||||
_attackRequestInProgress = false;
|
||||
ExecuteAttack(RequestedHeight, setServerPending: true);
|
||||
}
|
||||
}
|
||||
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void OnCombatModeChanged(CombatMode mode)
|
||||
{
|
||||
if (!CombatInputPlanner.SupportsTargetedAttack(mode))
|
||||
Reset();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void StopBuild(bool preserveLevel = false)
|
||||
{
|
||||
if (!preserveLevel && _buildInProgress)
|
||||
_latestPowerBarLevel = GetPowerBarLevel();
|
||||
_buildInProgress = false;
|
||||
_buildStartTime = 0d;
|
||||
}
|
||||
|
||||
private void ResetPowerBar()
|
||||
{
|
||||
StopBuild();
|
||||
_latestPowerBarLevel = 0f;
|
||||
_currentBuildIsAutomatic = false;
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
_attackRequestInProgress = false;
|
||||
_attackServerResponsePending = false;
|
||||
_attackWhenResponseReceived = false;
|
||||
_attackWhenResponseReceivedPower = 0f;
|
||||
_repeatAttacking = false;
|
||||
_requestedAttackPower = 0f;
|
||||
ResetPowerBar();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore the process-lived attack controller to retail's new-session
|
||||
/// defaults without sending a cancel packet through the displaced session.
|
||||
/// Mirrors <c>ClientCombatSystem::Begin @ 0x0056A460</c>.
|
||||
/// </summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
Reset();
|
||||
RequestedHeight = AttackHeight.Medium;
|
||||
DesiredPower = InitialDesiredPower;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_combat.CombatModeChanged -= OnCombatModeChanged;
|
||||
_combat.AttackCommenced -= OnAttackCommenced;
|
||||
_combat.AttackDone -= OnAttackDone;
|
||||
}
|
||||
}
|
||||
94
src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs
Normal file
94
src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public enum RuntimeCombatModeRequestStatus
|
||||
{
|
||||
Inactive,
|
||||
Rejected,
|
||||
Sent,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus Status,
|
||||
CombatMode Mode,
|
||||
string? Notice = null);
|
||||
|
||||
public interface IRuntimeCombatModeOperations
|
||||
{
|
||||
bool IsInWorld { get; }
|
||||
IReadOnlyList<ClientObject> GetOrderedEquipment();
|
||||
void NotifyExplicitCombatModeRequest();
|
||||
void SendChangeCombatMode(CombatMode mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-independent owner for retail's explicit combat-mode command.
|
||||
/// App supplies ordered equipment, transport, and the auto-wield cancellation
|
||||
/// edge; Runtime owns the exact default selection and local state transition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Port of <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c>
|
||||
/// and <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>.
|
||||
/// </remarks>
|
||||
public sealed class RuntimeCombatModeState
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly IRuntimeCombatModeOperations _operations;
|
||||
|
||||
public RuntimeCombatModeState(
|
||||
CombatState combat,
|
||||
IRuntimeCombatModeOperations operations)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_operations = operations
|
||||
?? throw new ArgumentNullException(nameof(operations));
|
||||
}
|
||||
|
||||
public RuntimeCombatModeRequestResult Toggle()
|
||||
{
|
||||
if (!_operations.IsInWorld)
|
||||
{
|
||||
return new RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus.Inactive,
|
||||
_combat.CurrentMode);
|
||||
}
|
||||
|
||||
// Every explicit user request supersedes auto-wield settlement,
|
||||
// including a request GetDefaultCombatMode later rejects.
|
||||
_operations.NotifyExplicitCombatModeRequest();
|
||||
|
||||
CombatMode currentMode = _combat.CurrentMode;
|
||||
CombatMode nextMode;
|
||||
if (currentMode != CombatMode.NonCombat)
|
||||
{
|
||||
nextMode = CombatMode.NonCombat;
|
||||
}
|
||||
else
|
||||
{
|
||||
DefaultCombatModeDecision decision =
|
||||
CombatInputPlanner.GetDefaultCombatModeDecision(
|
||||
_operations.GetOrderedEquipment());
|
||||
if (decision.IncompatibleHeldItem is { } held)
|
||||
{
|
||||
string notice =
|
||||
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
|
||||
return new RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus.Rejected,
|
||||
currentMode,
|
||||
notice);
|
||||
}
|
||||
|
||||
nextMode = CombatInputPlanner.ToggleMode(
|
||||
currentMode,
|
||||
decision.Mode);
|
||||
}
|
||||
|
||||
_operations.SendChangeCombatMode(nextMode);
|
||||
_combat.SetCombatMode(nextMode);
|
||||
return new RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus.Sent,
|
||||
nextMode);
|
||||
}
|
||||
}
|
||||
77
src/AcDream.Runtime/Gameplay/RuntimeCombatTargetState.cs
Normal file
77
src/AcDream.Runtime/Gameplay/RuntimeCombatTargetState.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public interface IRuntimeCombatTargetOperations
|
||||
{
|
||||
bool AutoTarget { get; }
|
||||
uint? SelectClosestTarget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns combat-target lifecycle around the shared selection state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail <c>ClientCombatSystem::RecvNotice_SelectionChanged</c>
|
||||
/// (0x0056BD80) invokes <c>AutoTarget</c> when selection becomes zero while
|
||||
/// Auto Target is enabled in Melee/Missile combat. Authoritative Dead motion
|
||||
/// makes the selected combat object unavailable and enters that same notice
|
||||
/// path.
|
||||
/// </remarks>
|
||||
public sealed class RuntimeCombatTargetState : IDisposable
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly IRuntimeCombatTargetOperations _operations;
|
||||
private bool _disposed;
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
public RuntimeCombatTargetState(
|
||||
CombatState combat,
|
||||
SelectionState selection,
|
||||
IRuntimeCombatTargetOperations operations)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_operations = operations
|
||||
?? throw new ArgumentNullException(nameof(operations));
|
||||
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after an accepted UpdateMotion has reached the entity's motion
|
||||
/// table. Dead is a persistent substate, so CurrentMotion is the
|
||||
/// authoritative availability signal rather than a damage prediction.
|
||||
/// </summary>
|
||||
public void OnMotionApplied(uint objectId, uint currentMotion)
|
||||
{
|
||||
if (currentMotion != MotionCommand.Dead
|
||||
|| _selection.SelectedObjectId != objectId)
|
||||
return;
|
||||
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.CombatTargetDied);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionTransition transition)
|
||||
{
|
||||
if (transition.SelectedObjectId is not null
|
||||
|| transition.Reason == SelectionChangeReason.SessionReset
|
||||
|| !_operations.AutoTarget
|
||||
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
return;
|
||||
|
||||
_operations.SelectClosestTarget();
|
||||
}
|
||||
}
|
||||
161
src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs
Normal file
161
src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using System;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public interface IRuntimeSpellCastOperations
|
||||
{
|
||||
uint LocalPlayerId { get; }
|
||||
bool CanSend { get; }
|
||||
bool HasRequiredComponents(uint spellId);
|
||||
bool IsTargetCompatible(
|
||||
uint targetId,
|
||||
SpellMetadata spell,
|
||||
bool showMessage);
|
||||
void StopCompletely();
|
||||
void SendUntargeted(uint spellId);
|
||||
void SendTargeted(uint targetId, uint spellId);
|
||||
void DisplayMessage(string message);
|
||||
void IncrementBusy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime owner for retail cast intent. It validates the canonical spellbook
|
||||
/// and selection, stops movement, and emits exactly one targeted or untargeted
|
||||
/// request. ACE owns every gameplay result after that boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
|
||||
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
|
||||
/// does not consume mana/components or start local effects.
|
||||
/// </remarks>
|
||||
public sealed class RuntimeSpellCastState
|
||||
{
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly IRuntimeSpellCastOperations _operations;
|
||||
|
||||
public RuntimeSpellCastState(
|
||||
Spellbook spellbook,
|
||||
SelectionState selection,
|
||||
IRuntimeSpellCastOperations operations)
|
||||
{
|
||||
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
}
|
||||
|
||||
public uint? LastRequestedSpellId { get; private set; }
|
||||
public uint? LastRequestedTargetId { get; private set; }
|
||||
public event Action? StateChanged;
|
||||
|
||||
public bool IsTargetReady(uint spellId)
|
||||
{
|
||||
if (!_spellbook.Knows(spellId)
|
||||
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
||||
return false;
|
||||
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
|
||||
return true;
|
||||
return _selection.SelectedObjectId is uint target and not 0u
|
||||
&& _operations.IsTargetCompatible(
|
||||
target,
|
||||
spell,
|
||||
showMessage: false);
|
||||
}
|
||||
|
||||
public CastRequestResult Cast(uint spellId)
|
||||
{
|
||||
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
||||
{
|
||||
_operations.DisplayMessage("You do not know that spell.");
|
||||
return CastRequestResult.UnknownSpell;
|
||||
}
|
||||
|
||||
if (!_operations.HasRequiredComponents(spellId))
|
||||
{
|
||||
_operations.DisplayMessage(
|
||||
"You do not have all of this spell's components.");
|
||||
return CastRequestResult.MissingComponents;
|
||||
}
|
||||
|
||||
uint? target;
|
||||
bool untargeted;
|
||||
if (spell.IsSelfTargeted)
|
||||
{
|
||||
uint playerId = _operations.LocalPlayerId;
|
||||
target = playerId == 0 ? null : playerId;
|
||||
untargeted = target is null;
|
||||
}
|
||||
else if (spell.IsUntargeted || spell.TargetMask == 0)
|
||||
{
|
||||
target = null;
|
||||
untargeted = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
target = _selection.SelectedObjectId;
|
||||
untargeted = false;
|
||||
if (target is null or 0)
|
||||
{
|
||||
_operations.DisplayMessage(
|
||||
"You must select a suitable target.");
|
||||
return CastRequestResult.NoTarget;
|
||||
}
|
||||
if (!_operations.IsTargetCompatible(
|
||||
target.Value,
|
||||
spell,
|
||||
showMessage: true))
|
||||
return CastRequestResult.IncompatibleTarget;
|
||||
}
|
||||
|
||||
if (!_operations.CanSend)
|
||||
{
|
||||
_operations.DisplayMessage("You cannot cast a spell right now.");
|
||||
return CastRequestResult.Unavailable;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_operations.StopCompletely();
|
||||
LastRequestedSpellId = spellId;
|
||||
LastRequestedTargetId = target;
|
||||
if (untargeted)
|
||||
_operations.SendUntargeted(spellId);
|
||||
else
|
||||
_operations.SendTargeted(target!.Value, spellId);
|
||||
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
|
||||
// busy reference only after Event_Cast has been emitted. The
|
||||
// matching server UseDone event decrements it.
|
||||
_operations.IncrementBusy();
|
||||
}
|
||||
catch
|
||||
{
|
||||
LastRequestedSpellId = null;
|
||||
LastRequestedTargetId = null;
|
||||
throw;
|
||||
}
|
||||
StateChanged?.Invoke();
|
||||
return CastRequestResult.Sent;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
bool changed = LastRequestedSpellId is not null
|
||||
|| LastRequestedTargetId is not null;
|
||||
LastRequestedSpellId = null;
|
||||
LastRequestedTargetId = null;
|
||||
if (changed)
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public enum CastRequestResult
|
||||
{
|
||||
Sent,
|
||||
UnknownSpell,
|
||||
NoTarget,
|
||||
IncompatibleTarget,
|
||||
MissingComponents,
|
||||
Unavailable,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue