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

@ -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;