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
|
|
@ -1,485 +0,0 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Core.Combat;
|
||||
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"/>
|
||||
/// and <see cref="ReleaseAttack"/>; 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 CombatAttackController : 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 ICombatAttackOperations _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 CombatAttackController(
|
||||
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 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));
|
||||
_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;
|
||||
|
||||
/// <summary>The level retail publishes to the embedded combat meter.</summary>
|
||||
public float PowerBarLevel => _buildInProgress
|
||||
? GetPowerBarLevel()
|
||||
: _latestPowerBarLevel;
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
AttackHeight? height = action switch
|
||||
{
|
||||
InputAction.CombatLowAttack => AttackHeight.Low,
|
||||
InputAction.CombatMediumAttack => AttackHeight.Medium,
|
||||
InputAction.CombatHighAttack => AttackHeight.High,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (height is not null)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
PressAttack(height.Value);
|
||||
else if (activation == ActivationType.Release)
|
||||
ReleaseAttack();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activation != ActivationType.Press)
|
||||
return false;
|
||||
|
||||
if (action == InputAction.CombatDecreaseAttackPower)
|
||||
{
|
||||
StepDesiredPower(-1);
|
||||
return true;
|
||||
}
|
||||
if (action == InputAction.CombatIncreaseAttackPower)
|
||||
{
|
||||
StepDesiredPower(1);
|
||||
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 HandleMovementInput(InputAction action, ActivationType activation)
|
||||
{
|
||||
if (activation != ActivationType.Press)
|
||||
return;
|
||||
|
||||
if (action is InputAction.MovementForward
|
||||
or InputAction.MovementBackup
|
||||
or InputAction.MovementRunLock
|
||||
or InputAction.MovementJump)
|
||||
{
|
||||
AbortAutomaticAttack();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
/// <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 CombatTargetController : IDisposable
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Func<bool> _autoTarget;
|
||||
private readonly Func<uint?> _selectClosestTarget;
|
||||
private bool _disposed;
|
||||
|
||||
public CombatTargetController(
|
||||
CombatState combat,
|
||||
SelectionState selection,
|
||||
Func<bool> autoTarget,
|
||||
Func<uint?> selectClosestTarget)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_autoTarget = autoTarget ?? throw new ArgumentNullException(nameof(autoTarget));
|
||||
_selectClosestTarget = selectClosestTarget
|
||||
?? throw new ArgumentNullException(nameof(selectClosestTarget));
|
||||
|
||||
_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
|
||||
|| !_autoTarget()
|
||||
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
return;
|
||||
|
||||
_selectClosestTarget();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
|
@ -54,11 +55,12 @@ internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
|||
public void Show(string message) => _viewModel?.AddToast(message);
|
||||
}
|
||||
|
||||
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
||||
internal sealed class CombatAttackOperationsSlot
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
private ICombatAttackOperations? _owner;
|
||||
private IRuntimeCombatAttackOperations? _owner;
|
||||
|
||||
public void Bind(ICombatAttackOperations owner)
|
||||
public void Bind(IRuntimeCombatAttackOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null && !ReferenceEquals(_owner, owner))
|
||||
|
|
@ -67,7 +69,7 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
_owner = owner;
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(ICombatAttackOperations owner)
|
||||
public IDisposable BindOwned(IRuntimeCombatAttackOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null)
|
||||
|
|
@ -77,7 +79,7 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
return new Binding(this, owner);
|
||||
}
|
||||
|
||||
private void Unbind(ICombatAttackOperations expected)
|
||||
private void Unbind(IRuntimeCombatAttackOperations expected)
|
||||
{
|
||||
if (ReferenceEquals(_owner, expected))
|
||||
_owner = null;
|
||||
|
|
@ -95,11 +97,11 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private CombatAttackOperationsSlot? _slot;
|
||||
private readonly ICombatAttackOperations _expected;
|
||||
private readonly IRuntimeCombatAttackOperations _expected;
|
||||
|
||||
public Binding(
|
||||
CombatAttackOperationsSlot slot,
|
||||
ICombatAttackOperations expected)
|
||||
IRuntimeCombatAttackOperations expected)
|
||||
{
|
||||
_slot = slot;
|
||||
_expected = expected;
|
||||
|
|
@ -114,7 +116,8 @@ internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
|||
/// 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
|
||||
internal sealed class LiveCombatAttackOperations
|
||||
: IRuntimeCombatAttackOperations
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly ICombatAttackTargetSource _targets;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.App.Net;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
|
@ -164,37 +165,96 @@ internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the input-facing combat-mode command. The choice itself remains the
|
||||
/// retail port in <see cref="CombatInputPlanner"/>:
|
||||
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and
|
||||
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
|
||||
/// authority; the local state update preserves the accepted immediate toolbar
|
||||
/// response while the server confirmation travels through the normal route.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
|
||||
internal sealed class RuntimeCombatModeOperationsSlot
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
private IRuntimeCombatModeOperations? _owner;
|
||||
|
||||
public IDisposable BindOwned(IRuntimeCombatModeOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null)
|
||||
throw new InvalidOperationException(
|
||||
"Runtime combat-mode operations are already bound.");
|
||||
_owner = owner;
|
||||
return new Binding(this, owner);
|
||||
}
|
||||
|
||||
private void Unbind(IRuntimeCombatModeOperations expected)
|
||||
{
|
||||
if (ReferenceEquals(_owner, expected))
|
||||
_owner = null;
|
||||
}
|
||||
|
||||
public bool IsInWorld => _owner?.IsInWorld == true;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
|
||||
_owner?.GetOrderedEquipment() ?? [];
|
||||
public void NotifyExplicitCombatModeRequest() =>
|
||||
_owner?.NotifyExplicitCombatModeRequest();
|
||||
public void SendChangeCombatMode(CombatMode mode) =>
|
||||
_owner?.SendChangeCombatMode(mode);
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private RuntimeCombatModeOperationsSlot? _slot;
|
||||
private readonly IRuntimeCombatModeOperations _expected;
|
||||
|
||||
public Binding(
|
||||
RuntimeCombatModeOperationsSlot slot,
|
||||
IRuntimeCombatModeOperations expected)
|
||||
{
|
||||
_slot = slot;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveCombatModeOperations
|
||||
: IRuntimeCombatModeOperations
|
||||
{
|
||||
private readonly ILiveCombatModeAuthority _authority;
|
||||
private readonly ICombatEquipmentSource _equipment;
|
||||
private readonly CombatState _combat;
|
||||
private readonly IExplicitCombatModeIntentSink _itemIntent;
|
||||
|
||||
public LiveCombatModeOperations(
|
||||
ILiveCombatModeAuthority authority,
|
||||
ICombatEquipmentSource equipment,
|
||||
IExplicitCombatModeIntentSink itemIntent)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
|
||||
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
|
||||
}
|
||||
|
||||
public bool IsInWorld => _authority.IsInWorld;
|
||||
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
|
||||
_equipment.GetOrderedEquipment();
|
||||
public void NotifyExplicitCombatModeRequest() =>
|
||||
_itemIntent.NotifyExplicitCombatModeRequest();
|
||||
public void SendChangeCombatMode(CombatMode mode) =>
|
||||
_authority.SendChangeCombatMode(mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App presentation adapter for Runtime's retail combat-mode decision.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeCombatModeCommandAdapter : ILiveCombatModeCommand
|
||||
{
|
||||
private readonly RuntimeCombatModeState _owner;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Action<string> _systemMessage;
|
||||
|
||||
public LiveCombatModeCommandController(
|
||||
ILiveCombatModeAuthority authority,
|
||||
ICombatEquipmentSource equipment,
|
||||
CombatState combat,
|
||||
IExplicitCombatModeIntentSink itemIntent,
|
||||
public RuntimeCombatModeCommandAdapter(
|
||||
RuntimeCombatModeState owner,
|
||||
Action<string>? log = null,
|
||||
Action<string>? toast = null,
|
||||
Action<string>? systemMessage = null)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
|
||||
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
_log = log ?? (_ => { });
|
||||
_toast = toast;
|
||||
_systemMessage = systemMessage ?? (_ => { });
|
||||
|
|
@ -202,48 +262,19 @@ internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
|
|||
|
||||
public void Toggle()
|
||||
{
|
||||
if (!_authority.IsInWorld)
|
||||
RuntimeCombatModeRequestResult result = _owner.Toggle();
|
||||
if (result.Status == RuntimeCombatModeRequestStatus.Inactive)
|
||||
return;
|
||||
|
||||
// Every explicit user combat request supersedes an in-flight auto-wield
|
||||
// settlement, including a request retail rejects before SetCombatMode.
|
||||
_itemIntent.NotifyExplicitCombatModeRequest();
|
||||
|
||||
CombatMode currentMode = _combat.CurrentMode;
|
||||
CombatMode nextMode;
|
||||
if (currentMode != CombatMode.NonCombat)
|
||||
if (result.Status == RuntimeCombatModeRequestStatus.Rejected)
|
||||
{
|
||||
// ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and
|
||||
// never queries default equipment on this branch.
|
||||
nextMode = CombatMode.NonCombat;
|
||||
}
|
||||
else
|
||||
{
|
||||
DefaultCombatModeDecision defaultDecision =
|
||||
CombatInputPlanner.GetDefaultCombatModeDecision(
|
||||
_equipment.GetOrderedEquipment());
|
||||
nextMode = CombatInputPlanner.ToggleMode(
|
||||
currentMode,
|
||||
defaultDecision.Mode);
|
||||
|
||||
// GetDefaultCombatMode prints this notice before SetCombatMode.
|
||||
// SetCombatMode then sees NonCombat == NonCombat and does no work.
|
||||
if (defaultDecision.IncompatibleHeldItem is { } held)
|
||||
{
|
||||
string notice =
|
||||
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
|
||||
_log($"combat: {notice}");
|
||||
_systemMessage(notice);
|
||||
return;
|
||||
}
|
||||
string notice = result.Notice ?? string.Empty;
|
||||
_log($"combat: {notice}");
|
||||
_systemMessage(notice);
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve the accepted command order: explicit intent was published
|
||||
// above, then send the request and publish local presentation.
|
||||
_authority.SendChangeCombatMode(nextMode);
|
||||
_combat.SetCombatMode(nextMode);
|
||||
|
||||
string message = $"Combat mode {nextMode}";
|
||||
string message = $"Combat mode {result.Mode}";
|
||||
_log($"combat: {message}");
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
|
|
|
|||
63
src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs
Normal file
63
src/AcDream.App/Combat/RuntimeCombatTargetOperationsSlot.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Early Runtime construction seam for the App-owned closest-hostile query.
|
||||
/// The slot owns no target identity or selection state.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeCombatTargetOperationsSlot
|
||||
: IRuntimeCombatTargetOperations
|
||||
{
|
||||
private IRuntimeCombatTargetOperations? _owner;
|
||||
|
||||
public IDisposable BindOwned(IRuntimeCombatTargetOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null)
|
||||
throw new InvalidOperationException(
|
||||
"Runtime combat-target operations are already bound.");
|
||||
_owner = owner;
|
||||
return new Binding(this, owner);
|
||||
}
|
||||
|
||||
private void Unbind(IRuntimeCombatTargetOperations expected)
|
||||
{
|
||||
if (ReferenceEquals(_owner, expected))
|
||||
_owner = null;
|
||||
}
|
||||
|
||||
public bool AutoTarget => _owner?.AutoTarget == true;
|
||||
public uint? SelectClosestTarget() => _owner?.SelectClosestTarget();
|
||||
|
||||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private RuntimeCombatTargetOperationsSlot? _slot;
|
||||
private readonly IRuntimeCombatTargetOperations _expected;
|
||||
|
||||
public Binding(
|
||||
RuntimeCombatTargetOperationsSlot slot,
|
||||
IRuntimeCombatTargetOperations expected)
|
||||
{
|
||||
_slot = slot;
|
||||
_expected = expected;
|
||||
}
|
||||
|
||||
public void Dispose() =>
|
||||
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveCombatTargetOperations(
|
||||
Func<bool> autoTarget,
|
||||
Func<uint?> selectClosestTarget)
|
||||
: IRuntimeCombatTargetOperations
|
||||
{
|
||||
private readonly Func<bool> _autoTarget = autoTarget
|
||||
?? throw new ArgumentNullException(nameof(autoTarget));
|
||||
private readonly Func<uint?> _selectClosestTarget = selectClosestTarget
|
||||
?? throw new ArgumentNullException(nameof(selectClosestTarget));
|
||||
|
||||
public bool AutoTarget => _autoTarget();
|
||||
public uint? SelectClosestTarget() => _selectClosestTarget();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue