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:
parent
9958458318
commit
2215c76c7e
22 changed files with 1265 additions and 46 deletions
357
src/AcDream.App/Combat/CombatAttackController.cs
Normal file
357
src/AcDream.App/Combat/CombatAttackController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -165,15 +165,14 @@ public sealed class PlayerMovementController
|
|||
// Jump charge state.
|
||||
private bool _jumpCharging;
|
||||
private float _jumpExtent;
|
||||
// K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is
|
||||
// not legible in the named decomp (the divisor was clobbered in
|
||||
// GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s
|
||||
// (full charge in 0.5s) feels matches retail muscle memory better
|
||||
// than the previous 1.0/s — a tap gives a noticeable hop, half-hold
|
||||
// a meaningful jump, full-hold the maximum extent. The vertical
|
||||
// velocity formula itself (height × 19.6 → vz) is unchanged and
|
||||
// matches retail byte-for-byte; only the time-to-fill is faster.
|
||||
private const float JumpChargeRate = 2.0f;
|
||||
// Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87
|
||||
// operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s.
|
||||
// Jump uses the same shared powerbar function, so its normal fill rate is
|
||||
// 1 extent/s and DualWieldCombat is 1/0.8 = 1.25 extent/s.
|
||||
private const float JumpChargeRate =
|
||||
1f / (float)AcDream.Core.Combat.CombatInputPlanner.AttackPowerUpSeconds;
|
||||
private const float DualWieldJumpChargeRate =
|
||||
1f / (float)AcDream.Core.Combat.CombatInputPlanner.DualWieldPowerUpSeconds;
|
||||
|
||||
// Airborne → grounded transition detection. Flipped on every frame where
|
||||
// the body transitions from airborne to on-walkable; used by the GameWindow
|
||||
|
|
@ -780,7 +779,11 @@ public sealed class PlayerMovementController
|
|||
// this line despite the W3 port.
|
||||
_motion.ChargeJump();
|
||||
}
|
||||
_jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
|
||||
float chargeRate = _motion.InterpretedState.CurrentStyle
|
||||
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle
|
||||
? DualWieldJumpChargeRate
|
||||
: JumpChargeRate;
|
||||
_jumpExtent = MathF.Min(_jumpExtent + dt * chargeRate, 1.0f);
|
||||
}
|
||||
else if (_jumpCharging)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -760,6 +760,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.UiHost? _uiHost;
|
||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
||||
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
|
||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
|
||||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||||
|
|
@ -1979,11 +1980,19 @@ public sealed class GameWindow : IDisposable
|
|||
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
|
||||
_samplerCache = new SamplerCache(_gl);
|
||||
|
||||
// Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing
|
||||
// UiHost retained-mode tree (dormant until now) + a first vitals panel.
|
||||
// Render-only: UiHost input is NOT yet bridged to the InputDispatcher
|
||||
// (next sub-phase), so the close button + window drag are inert. Coexists
|
||||
// with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged.
|
||||
// Retail ClientCombatSystem attack-request owner. This exists independently
|
||||
// of the retained UI so keyboard combat keeps the same press/hold/release
|
||||
// semantics even when the retail renderer is disabled.
|
||||
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
|
||||
Combat,
|
||||
CanStartLiveCombatAttack,
|
||||
SendLiveCombatAttack,
|
||||
isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
|
||||
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
|
||||
playerReadyForAttack: IsPlayerReadyForCombatAttack,
|
||||
autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack);
|
||||
|
||||
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
||||
if (_options.RetailUi)
|
||||
{
|
||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
|
|
@ -2116,6 +2125,9 @@ public sealed class GameWindow : IDisposable
|
|||
radarSnapshotProvider.BuildSnapshot,
|
||||
_selection,
|
||||
SetRetailUiLocked),
|
||||
Combat: new AcDream.App.UI.CombatRuntimeBindings(
|
||||
Combat,
|
||||
_combatAttackController),
|
||||
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
||||
Objects,
|
||||
() => Shortcuts,
|
||||
|
|
@ -8194,6 +8206,7 @@ public sealed class GameWindow : IDisposable
|
|||
// re-fire while their chord is held. K.1b adds the subscribers
|
||||
// that actually consume the events.
|
||||
_inputDispatcher?.Tick();
|
||||
_combatAttackController?.Tick();
|
||||
|
||||
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
|
||||
// on selection, then revert). No-op when nothing is flashing.
|
||||
|
|
@ -11730,6 +11743,12 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
// Retail attack actions consume their full transition stream: key-down
|
||||
// starts the power build and key-up commits it. Handle them before the
|
||||
// generic Press-only gate below.
|
||||
if (_combatAttackController?.HandleInputAction(action, activation) == true)
|
||||
return;
|
||||
|
||||
// Every other action fires on Press only (no Release / Hold side-
|
||||
// effects in the K.1b set). Filter out non-Press activations early
|
||||
// so subscribers that have Release-mode bindings don't accidentally
|
||||
|
|
@ -11853,18 +11872,6 @@ public sealed class GameWindow : IDisposable
|
|||
ToggleLiveCombatMode();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
|
||||
PickAndStoreSelection(useImmediately: false);
|
||||
break;
|
||||
|
|
@ -11928,17 +11935,28 @@ public sealed class GameWindow : IDisposable
|
|||
_debugVm?.AddToast(text);
|
||||
}
|
||||
|
||||
private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
|
||||
private bool IsPlayerReadyForCombatAttack()
|
||||
{
|
||||
if (_playerController is null)
|
||||
return false;
|
||||
var motion = _playerController.Motion.InterpretedState;
|
||||
return AcDream.Core.Combat.CombatInputPlanner.PlayerInReadyPositionForAttack(
|
||||
Combat.CurrentMode,
|
||||
motion.CurrentStyle,
|
||||
motion.ForwardCommand);
|
||||
}
|
||||
|
||||
private bool CanStartLiveCombatAttack()
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
return false;
|
||||
|
||||
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
|
||||
{
|
||||
_debugVm?.AddToast("Enter melee or missile combat first");
|
||||
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint? target = GetSelectedOrClosestCombatTarget();
|
||||
|
|
@ -11946,21 +11964,32 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_debugVm?.AddToast("No monster target");
|
||||
Console.WriteLine("combat: attack ignored; no creature target found");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
|
||||
const float FullBar = 1.0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool SendLiveCombatAttack(
|
||||
AcDream.Core.Combat.AttackHeight height,
|
||||
float power)
|
||||
{
|
||||
if (!CanStartLiveCombatAttack())
|
||||
return false;
|
||||
|
||||
uint target = _selection.SelectedObjectId!.Value;
|
||||
power = Math.Clamp(power, 0f, 1f);
|
||||
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
|
||||
{
|
||||
_liveSession.SendMissileAttack(target.Value, height, FullBar);
|
||||
Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
|
||||
_liveSession!.SendMissileAttack(target, height, power);
|
||||
Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_liveSession.SendMeleeAttack(target.Value, height, FullBar);
|
||||
Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
|
||||
_liveSession!.SendMeleeAttack(target, height, power);
|
||||
Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private uint? GetSelectedOrClosestCombatTarget()
|
||||
|
|
@ -13556,6 +13585,8 @@ public sealed class GameWindow : IDisposable
|
|||
// UI resource is still alive. UiHost disposes controllers before its renderer.
|
||||
_retailUiRuntime?.Dispose();
|
||||
_retailUiRuntime = null;
|
||||
_combatAttackController?.Dispose();
|
||||
_combatAttackController = null;
|
||||
_uiHost = null;
|
||||
_itemInteractionController?.Dispose();
|
||||
_itemInteractionController = null;
|
||||
|
|
|
|||
158
src/AcDream.App/UI/Layout/CombatUiController.cs
Normal file
158
src/AcDream.App/UI/Layout/CombatUiController.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported retail <c>gmCombatUI</c> layout to the client combat
|
||||
/// state machine. No panel geometry is synthesized here: every control is the
|
||||
/// authored element from LayoutDesc <c>0x21000073</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail references: <c>gmCombatUI::RecvNotice_AttackHeightChanged</c>
|
||||
/// (0x004CC080), <c>RecvNotice_SetPowerbarLevel</c> (0x004CC0E0),
|
||||
/// <c>RecvNotice_DesiredAttackPowerChanged</c> (0x004CC110),
|
||||
/// <c>ListenToElementMessage</c> (0x004CC430), and
|
||||
/// <c>RecvNotice_SetCombatMode</c> (0x004CC620).
|
||||
/// </remarks>
|
||||
public sealed class CombatUiController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000073u;
|
||||
public const uint BasicPanelId = 0x1000005Cu;
|
||||
public const uint AdvancedPanelId = 0x10000061u;
|
||||
public const uint PowerControlId = 0x1000004Fu;
|
||||
public const uint HighButtonId = 0x10000057u;
|
||||
public const uint MediumButtonId = 0x10000058u;
|
||||
public const uint LowButtonId = 0x10000059u;
|
||||
|
||||
private const uint MeleeState = 0x10000003u;
|
||||
private const uint MissileState = 0x10000004u;
|
||||
|
||||
private readonly UiElement _root;
|
||||
private readonly UiElement _basicPanel;
|
||||
private readonly UiElement _advancedPanel;
|
||||
private readonly UiScrollbar _powerControl;
|
||||
private readonly UiButton _high;
|
||||
private readonly UiButton _medium;
|
||||
private readonly UiButton _low;
|
||||
private readonly CombatState _combat;
|
||||
private readonly CombatAttackController _attacks;
|
||||
private readonly Action<bool> _setWindowVisible;
|
||||
private bool _disposed;
|
||||
|
||||
private CombatUiController(
|
||||
ImportedLayout layout,
|
||||
UiElement basicPanel,
|
||||
UiElement advancedPanel,
|
||||
UiScrollbar powerControl,
|
||||
UiButton high,
|
||||
UiButton medium,
|
||||
UiButton low,
|
||||
CombatState combat,
|
||||
CombatAttackController attacks,
|
||||
Action<bool> setWindowVisible)
|
||||
{
|
||||
_root = layout.Root;
|
||||
_basicPanel = basicPanel;
|
||||
_advancedPanel = advancedPanel;
|
||||
_powerControl = powerControl;
|
||||
_high = high;
|
||||
_medium = medium;
|
||||
_low = low;
|
||||
_combat = combat;
|
||||
_attacks = attacks;
|
||||
_setWindowVisible = setWindowVisible;
|
||||
|
||||
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
|
||||
// basic page. The advanced page belongs to the separate advanced-
|
||||
// combat flow and stays hidden in the basic panel.
|
||||
_basicPanel.Visible = true;
|
||||
_advancedPanel.Visible = false;
|
||||
|
||||
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
||||
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
|
||||
_powerControl.ScalarFill = () => _attacks.PowerBarLevel;
|
||||
|
||||
BindAttackButton(_high, AttackHeight.High);
|
||||
BindAttackButton(_medium, AttackHeight.Medium);
|
||||
BindAttackButton(_low, AttackHeight.Low);
|
||||
|
||||
_combat.CombatModeChanged += OnCombatModeChanged;
|
||||
_attacks.StateChanged += OnAttackStateChanged;
|
||||
SyncControls();
|
||||
}
|
||||
|
||||
public static CombatUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
CombatState combat,
|
||||
CombatAttackController attacks,
|
||||
Action<bool> setWindowVisible)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
ArgumentNullException.ThrowIfNull(attacks);
|
||||
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
||||
|
||||
if (layout.FindElement(BasicPanelId) is not { } basic
|
||||
|| layout.FindElement(AdvancedPanelId) is not { } advanced
|
||||
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|
||||
|| layout.FindElement(HighButtonId) is not UiButton high
|
||||
|| layout.FindElement(MediumButtonId) is not UiButton medium
|
||||
|| layout.FindElement(LowButtonId) is not UiButton low)
|
||||
return null;
|
||||
|
||||
return new CombatUiController(
|
||||
layout, basic, advanced, power, high, medium, low,
|
||||
combat, attacks, setWindowVisible);
|
||||
}
|
||||
|
||||
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
|
||||
|
||||
private void BindAttackButton(UiButton button, AttackHeight height)
|
||||
{
|
||||
button.OnPressed = () => _attacks.PressAttack(height);
|
||||
button.OnReleased = _attacks.ReleaseAttack;
|
||||
}
|
||||
|
||||
private void OnCombatModeChanged(CombatMode mode)
|
||||
{
|
||||
bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
|
||||
if (_root is IUiDatStateful stateful)
|
||||
{
|
||||
if (mode == CombatMode.Melee)
|
||||
stateful.TrySetRetailState(MeleeState);
|
||||
else if (mode == CombatMode.Missile)
|
||||
stateful.TrySetRetailState(MissileState);
|
||||
}
|
||||
_setWindowVisible(visible);
|
||||
SyncControls();
|
||||
}
|
||||
|
||||
private void OnAttackStateChanged() => SyncControls();
|
||||
|
||||
private void SyncControls()
|
||||
{
|
||||
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
||||
_high.Selected = _attacks.RequestedHeight == AttackHeight.High;
|
||||
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
|
||||
_low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
|
||||
}
|
||||
|
||||
public void OnShown() => SyncControls();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_combat.CombatModeChanged -= OnCombatModeChanged;
|
||||
_attacks.StateChanged -= OnAttackStateChanged;
|
||||
_powerControl.ScalarChanged = null;
|
||||
_powerControl.ScalarFill = () => null;
|
||||
_high.OnPressed = null;
|
||||
_high.OnReleased = null;
|
||||
_medium.OnPressed = null;
|
||||
_medium.OnReleased = null;
|
||||
_low.OnPressed = null;
|
||||
_low.OnReleased = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -160,6 +160,19 @@ public static class DatWidgetFactory
|
|||
.FirstOrDefault();
|
||||
bar.TrackSprite = track is null ? 0u : DefaultImage(track);
|
||||
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
|
||||
|
||||
// gmCombatUI's desired-power slider (0x1000004F) authors the
|
||||
// live charge as a nested Type-7 meter. UiScrollbar consumes its
|
||||
// DAT children, so retain the meter's fill image on the scalar
|
||||
// widget itself. The fill container is element 2; the Recklessness
|
||||
// overlay (0x100005EF) is deliberately not the charge image.
|
||||
ElementInfo? meter = info.Children.FirstOrDefault(child => child.Type == 7u);
|
||||
ElementInfo? fill = meter?.Children.FirstOrDefault(child => child.Id == 2u)
|
||||
?? meter?.Children
|
||||
.Where(child => DefaultImage(child) != 0u)
|
||||
.OrderByDescending(child => child.ReadOrder)
|
||||
.FirstOrDefault();
|
||||
bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill);
|
||||
return bar;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Combat;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.UI.Testing;
|
||||
|
|
@ -35,6 +36,10 @@ public sealed record RadarRuntimeBindings(
|
|||
SelectionState Selection,
|
||||
Action<bool> SetUiLocked);
|
||||
|
||||
public sealed record CombatRuntimeBindings(
|
||||
CombatState State,
|
||||
CombatAttackController Attacks);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
|
||||
|
|
@ -96,6 +101,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
VitalsRuntimeBindings Vitals,
|
||||
ChatRuntimeBindings Chat,
|
||||
RadarRuntimeBindings Radar,
|
||||
CombatRuntimeBindings Combat,
|
||||
ToolbarRuntimeBindings Toolbar,
|
||||
CharacterRuntimeBindings Character,
|
||||
InventoryRuntimeBindings Inventory,
|
||||
|
|
@ -125,6 +131,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountRadar();
|
||||
MountChat();
|
||||
MountToolbar();
|
||||
MountCombat();
|
||||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
|
|
@ -160,6 +167,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public CharacterSheetProvider CharacterSheetProvider => _bindings.Character.Provider;
|
||||
public ToolbarController? ToolbarController { get; private set; }
|
||||
public ToolbarInputController? ToolbarInputController { get; private set; }
|
||||
public CombatUiController? CombatUiController { get; private set; }
|
||||
public SelectedObjectController? SelectedObjectController { get; private set; }
|
||||
public UiViewport? PaperdollViewportWidget { get; private set; }
|
||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
|
|
@ -439,6 +447,54 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
|
||||
}
|
||||
|
||||
private void MountCombat()
|
||||
{
|
||||
ImportedLayout? layout = Import(CombatUiController.LayoutId);
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
CombatUiController? controller = Layout.CombatUiController.Bind(
|
||||
layout,
|
||||
_bindings.Combat.State,
|
||||
_bindings.Combat.Attacks,
|
||||
visible =>
|
||||
{
|
||||
if (visible) Host.ShowWindow(WindowNames.Combat);
|
||||
else Host.HideWindow(WindowNames.Combat);
|
||||
});
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M2] combat: required controls missing in LayoutDesc 0x21000073.");
|
||||
return;
|
||||
}
|
||||
|
||||
CombatUiController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Combat,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
|
||||
Top = Math.Max(0f, Host.Root.Height - root.Height - 10f),
|
||||
Visible = false,
|
||||
Resizable = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
controller.SyncVisibility();
|
||||
Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
|
||||
}
|
||||
|
||||
private (uint[] Peace, uint[] War, uint[]? Empty) LoadToolbarDigits()
|
||||
{
|
||||
uint[]? peace = null, war = null, empty = null;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional pointer transition handlers. These expose retail's distinct
|
||||
/// pressed/released element messages for controls such as the combat-height
|
||||
/// buttons, where mouse-down begins charging and mouse-up commits the attack.
|
||||
/// </summary>
|
||||
public Action? OnPressed { get; set; }
|
||||
public Action? OnReleased { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional position-aware click handler. Coordinates are local pixels in this button,
|
||||
/// matching retail's <c>UIElementMessageInfo.ptWindow</c> paperdoll hit-test input.
|
||||
|
|
@ -292,6 +300,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
_pointerOver = ContainsLocal(e.Data1, e.Data2);
|
||||
_pressed = true;
|
||||
UpdateVisualState();
|
||||
if (Enabled)
|
||||
OnPressed?.Invoke();
|
||||
if (HotClickEnabled && Enabled)
|
||||
{
|
||||
OnClick?.Invoke();
|
||||
|
|
@ -319,6 +329,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
_nextHotClickTime = double.NaN;
|
||||
if (_pressed && _pointerOver && Enabled && ToggleBehavior)
|
||||
_selected = !_selected;
|
||||
if (_pressed && Enabled)
|
||||
OnReleased?.Invoke();
|
||||
_pressed = false;
|
||||
UpdateVisualState();
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ public sealed class UiScrollbar : UiElement
|
|||
public Action<float>? ScalarChanged { get; set; }
|
||||
public bool Horizontal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional fill rendered beneath the scalar thumb. Retail's combat power
|
||||
/// control is a horizontal scrollbar containing a meter child; the importer
|
||||
/// folds that authored child into these properties because scrollbars consume
|
||||
/// their DAT children.
|
||||
/// </summary>
|
||||
public Func<float?> ScalarFill { get; set; } = () => null;
|
||||
public uint ScalarFillSprite { get; set; }
|
||||
|
||||
/// <summary>Programmatically set retail scrollbar attribute 0x86 without broadcasting.</summary>
|
||||
public void SetScalarPosition(float position)
|
||||
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
|
||||
|
|
@ -102,6 +111,11 @@ public sealed class UiScrollbar : UiElement
|
|||
if (Horizontal)
|
||||
{
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
if (ScalarFill() is float fill && ScalarFillSprite != 0)
|
||||
{
|
||||
float visibleWidth = Width * Math.Clamp(fill, 0f, 1f);
|
||||
DrawTiled(ctx, resolve, ScalarFillSprite, 0f, 0f, visibleWidth, Height);
|
||||
}
|
||||
float thumbWidth = ScalarThumbWidth(resolve);
|
||||
float travel = MathF.Max(0f, Width - thumbWidth);
|
||||
float x = travel * ScalarPosition;
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ public static class WindowNames
|
|||
public const string Inventory = "inventory";
|
||||
public const string Chat = "chat";
|
||||
public const string Radar = "radar";
|
||||
public const string Combat = "combat";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,38 @@ public enum CombatAttackAction
|
|||
/// </summary>
|
||||
public static class CombatInputPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail MotionStance_DualWieldCombat. GetPowerBarLevel (0x0056ADE0)
|
||||
/// selects the faster 0.8-second charge when this is the current style.
|
||||
/// </summary>
|
||||
public const uint DualWieldCombatStyle = 0x80000046u;
|
||||
public const uint ReadyForwardCommand = 0x41000003u;
|
||||
public const double AttackPowerUpSeconds = 1.0;
|
||||
public const double DualWieldPowerUpSeconds = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Attack-time branch of retail <c>PlayerInReadyPosition</c>
|
||||
/// (0x0056B820, arg2=true). Melee accepts a valid maneuver table;
|
||||
/// every player has one, while missile additionally requires one of
|
||||
/// retail's launcher/thrown styles to be at Ready.
|
||||
/// </summary>
|
||||
public static bool PlayerInReadyPositionForAttack(
|
||||
CombatMode mode,
|
||||
uint currentStyle,
|
||||
uint forwardCommand)
|
||||
{
|
||||
if (mode == CombatMode.Melee)
|
||||
return true;
|
||||
if (mode != CombatMode.Missile || forwardCommand != ReadyForwardCommand)
|
||||
return false;
|
||||
return currentStyle is 0x8000003Fu
|
||||
or 0x80000041u
|
||||
or 0x80000043u
|
||||
or 0x80000047u
|
||||
or 0x80000138u
|
||||
or 0x80000139u;
|
||||
}
|
||||
|
||||
private const EquipMask PrimaryWeaponLocations =
|
||||
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.TwoHanded;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
|
|||
/// </summary>
|
||||
public sealed class KeyBindings
|
||||
{
|
||||
private const int CurrentSchemaVersion = 2;
|
||||
private const int CurrentSchemaVersion = 3;
|
||||
|
||||
private readonly List<Binding> _bindings = new();
|
||||
|
||||
|
|
@ -244,9 +244,12 @@ public sealed class KeyBindings
|
|||
// Melee mode (active when MeleeCombat scope pushed).
|
||||
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower));
|
||||
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower));
|
||||
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack));
|
||||
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack));
|
||||
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack));
|
||||
// Retail HandleCombatAction consumes key-down AND key-up for attack
|
||||
// height actions: down starts charging, up ends the request. Hold is
|
||||
// our binding representation for that transition pair.
|
||||
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold));
|
||||
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold));
|
||||
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold));
|
||||
// Missile + Magic + Spell-tab — same chords; resolved by scope at
|
||||
// runtime per InputDispatcher's stack lookup. Add the bindings;
|
||||
// subscribers arrive in Phase L when CombatState.CurrentMode is
|
||||
|
|
@ -420,6 +423,7 @@ public sealed class KeyBindings
|
|||
}
|
||||
var chord = new KeyChord(silkKey, mods, device);
|
||||
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
|
||||
activation = MigrateCombatAttackActivation(version, action, activation);
|
||||
loaded.Add(new(chord, action, activation));
|
||||
}
|
||||
}
|
||||
|
|
@ -516,6 +520,25 @@ public sealed class KeyBindings
|
|||
: action;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema v2 stored the three attack-height commands as one-shot Press
|
||||
/// bindings. Retail requires both press and release, so old and customized
|
||||
/// chords retain their key while upgrading to Hold transition semantics.
|
||||
/// </summary>
|
||||
private static ActivationType MigrateCombatAttackActivation(
|
||||
int version,
|
||||
InputAction action,
|
||||
ActivationType activation)
|
||||
{
|
||||
if (version >= 3 || activation != ActivationType.Press)
|
||||
return activation;
|
||||
return action is InputAction.CombatLowAttack
|
||||
or InputAction.CombatMediumAttack
|
||||
or InputAction.CombatHighAttack
|
||||
? ActivationType.Hold
|
||||
: activation;
|
||||
}
|
||||
|
||||
private static ModifierMask ParseModifiers(JsonElement bindingEl)
|
||||
{
|
||||
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue