feat(combat): port retail basic combat bar

Mount authored gmCombatUI, share one press/hold/release request state machine across DAT buttons and keybindings, and recover the exact 1.0s/0.8s power timing from matching retail x86. The same timer fixes jump charge, while ready-stance, response queueing, auto-repeat, layout binding, migration, and conformance coverage keep behavior architectural rather than panel-local.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 14:03:28 +02:00
parent 9958458318
commit 2215c76c7e
22 changed files with 1265 additions and 46 deletions

View file

@ -0,0 +1,357 @@
using System.Diagnostics;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Combat;
/// <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).
/// </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 Func<bool> _canStartAttack;
private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Func<bool> _isDualWield;
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
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,
Func<bool>? isDualWield = null,
Func<bool>? playerReadyForAttack = null,
Func<bool>? autoRepeatAttack = null,
Func<double>? now = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
_isDualWield = isDualWield ?? (() => false);
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
_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 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;
}
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>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 (!_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)
|| !_canStartAttack())
return;
_attackRequestInProgress = true;
_requestedAttackPower = 1f;
_currentBuildIsAutomatic = false;
AttemptStartBuildingAttack();
}
private void AttemptStartBuildingAttack()
{
if (_buildInProgress
|| _attackServerResponsePending
|| !_playerReadyForAttack())
return;
StartPowerBarBuild();
}
private void StartPowerBarBuild()
{
_buildInProgress = true;
_buildStartTime = _now();
_latestPowerBarLevel = 0f;
}
private float GetPowerBarLevel()
{
if (!_buildInProgress)
return 0f;
double duration = _isDualWield()
? DualWieldPowerUpSeconds
: AttackPowerUpSeconds;
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
}
private void ExecuteAttack(AttackHeight height, bool setServerPending)
{
StopBuild();
if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f)))
{
ResetPowerBar();
return;
}
if (_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
&& _autoRepeatAttack()
&& _repeatAttacking)
{
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
_requestedAttackPower = DesiredPower;
ExecuteAttack(RequestedHeight, setServerPending: false);
}
if (!_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();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_combat.CombatModeChanged -= OnCombatModeChanged;
_combat.AttackCommenced -= OnAttackCommenced;
_combat.AttackDone -= OnAttackDone;
}
}