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 _canStartAttack; private readonly Action _prepareAttackRequest; private readonly Func _sendAttack; private readonly Action _sendCancelAttack; private readonly Func _isDualWield; private readonly Func _playerReadyForAttack; private readonly Func _autoRepeatAttack; public DelegateCombatAttackOperations( Func canStartAttack, Func sendAttack, Action? prepareAttackRequest, Action? sendCancelAttack, Func? isDualWield, Func? playerReadyForAttack, Func? 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(); } /// /// Owns retail's basic-combat attack request and power-bar state machine. /// UI buttons and keyboard actions both enter through /// and ; the wire send remains a host callback. /// /// /// Retail references: ClientCombatSystem::Begin (0x0056A460), /// StartPowerBarBuild (0x0056ADB0), GetPowerBarLevel /// (0x0056ADE0), StartAttackRequest (0x0056C040), /// EndAttackRequest (0x0056C0E0), UseTime (0x0056C1F0), /// HandleAttackDoneEvent (0x0056C500), and /// SetRequestedAttackHeight (0x0056C8F0), and /// AbortAutomaticAttack (0x0056AE90). /// 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 _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 canStartAttack, Func sendAttack, Action? prepareAttackRequest = null, Action? sendCancelAttack = null, Func? isDualWield = null, Func? playerReadyForAttack = null, Func? autoRepeatAttack = null, Func? now = null) : this( combat, new DelegateCombatAttackOperations( canStartAttack, sendAttack, prepareAttackRequest, sendCancelAttack, isDualWield, playerReadyForAttack, autoRepeatAttack), now) { } internal CombatAttackController( CombatState combat, ICombatAttackOperations operations, Func? 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; /// The level retail publishes to the embedded combat meter. 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; } /// /// 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 HandleNewForwardMovement. Jump has its own equivalent cancel /// in ClientCombatSystem::CommenceJump (0x0056AF90). /// 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(); } /// /// Retail ClientCombatSystem::AbortAutomaticAttack (0x0056AE90). /// A new forward-movement command enters here through /// ACCmdInterp::HandleNewForwardMovement (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. /// public void AbortAutomaticAttack() { if (!_attackServerResponsePending && !_attackRequestInProgress && !_repeatAttacking) return; _operations.SendCancelAttack(); _repeatAttacking = false; if (_buildInProgress) ResetPowerBar(); StateChanged?.Invoke(); } /// /// Retail UseTime: publishes the live build and commits a released /// request once the ticking level reaches the power captured on release. /// 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(); } /// /// Restore the process-lived attack controller to retail's new-session /// defaults without sending a cancel packet through the displaced session. /// Mirrors ClientCombatSystem::Begin @ 0x0056A460. /// 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; } }