485 lines
16 KiB
C#
485 lines
16 KiB
C#
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;
|
|
}
|
|
}
|