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,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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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