feat(ui): port retail selected-item mana

Parse and route the complete 0x0264 guid/fraction/valid response, send exact 0x0263 item-mana queries, and reproduce retail meter visibility plus guid-zero cancellation for mana and health selection changes. Keep the behavior in Core state and the retained selected-object controller with wire/state/UI conformance coverage.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 11:16:21 +02:00
parent df460f94c3
commit fa1da02783
21 changed files with 402 additions and 38 deletions

View file

@ -7,15 +7,17 @@ using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Controller for the action bar's selected-object strip (ids 0x1000019E0x100001A1).
/// Controller for the action bar's selected-object strip (ids 0x1000019E0x100001A4).
/// Analogue of retail <c>gmToolbarUI::HandleSelectionChanged</c>
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>) +
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>).
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>) +
/// <c>RecvNotice_UpdateItemMana</c> (<c>:196188</c>).
///
/// <para>
/// On selection change: clears the strip (name, overlay flash, health meter), then if a
/// guid is provided it sets the name, flashes the selection overlay briefly, and (for
/// health-bearing targets) sends a <c>QueryHealth (0x01BF)</c> request. The Health meter
/// guid is provided it sets the name, flashes the selection overlay briefly, and sends
/// either <c>QueryHealth (0x01BF)</c> for health-bearing targets or
/// <c>QueryItemMana (0x0263)</c> for owned non-stack items. The Health meter
/// becomes visible only when the server actually reports health for the selected guid —
/// either an <c>UpdateHealth (0x01C0)</c> arrives (retail
/// <c>RecvNotice_UpdateObjectHealth</c> → <c>SetVisible(1)</c>) or the value is already
@ -54,6 +56,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
public const uint OverlayId = 0x100001A0;
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
public const uint HealthMeterId = 0x100001A1;
/// <summary>Selected-object item-mana meter element id (retail m_pSelObjectManaMeter).</summary>
public const uint ManaMeterId = 0x100001A2;
/// <summary>Editable stack quantity (retail m_pStackSizeEntryBox).</summary>
public const uint StackSizeEntryId = 0x100001A3;
/// <summary>Horizontal stack quantity slider (retail m_pStackSizeSlider).</summary>
@ -80,19 +84,24 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly UiElement? _name;
private readonly UiDatElement? _overlay;
private readonly UiMeter? _healthMeter;
private readonly UiMeter? _manaMeter;
private readonly UiField? _stackSizeEntry;
private readonly UiScrollbar? _stackSizeSlider;
// ── Captured delegates ───────────────────────────────────────────────────
private readonly Func<uint, bool> _isHealthTarget;
private readonly Func<uint, bool> _isOwnedByPlayer;
private readonly Func<uint, string?> _resolveName;
private readonly Func<uint, float> _healthPercent;
private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth;
private readonly Func<uint, float> _manaPercent;
private readonly Action<uint> _sendQueryItemMana;
private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
// ── Live state (read by closures on the per-frame draw path) ────────────
@ -110,32 +119,42 @@ public sealed class SelectedObjectController : IRetainedPanelController
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Action<Action<uint, float, bool>> subscribeItemManaChanged,
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
Func<uint, bool> isHealthTarget,
Func<uint, bool> isOwnedByPlayer,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
Func<uint, float> manaPercent,
Action<uint> sendQueryItemMana,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated)
{
_isHealthTarget = isHealthTarget;
_isOwnedByPlayer = isOwnedByPlayer;
_resolveName = name;
_healthPercent = healthPercent;
_hasHealth = hasHealth;
_stackSize = stackSize;
_sendQueryHealth = sendQueryHealth;
_manaPercent = manaPercent;
_sendQueryItemMana = sendQueryItemMana;
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_unsubscribeHealthChanged = unsubscribeHealthChanged;
_unsubscribeItemManaChanged = unsubscribeItemManaChanged;
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
// Find elements — silently skip absent ones (partial/test layouts).
_name = layout.FindElement(NameId);
_overlay = layout.FindElement(OverlayId) as UiDatElement;
_healthMeter = layout.FindElement(HealthMeterId) as UiMeter;
_manaMeter = layout.FindElement(ManaMeterId) as UiMeter;
_stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField;
_stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar;
@ -151,6 +170,11 @@ public sealed class SelectedObjectController : IRetainedPanelController
// Fill polls live: _current holds the currently-selected guid (or null).
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
}
if (_manaMeter is not null)
{
_manaMeter.Visible = false;
_manaMeter.Fill = () => _current is uint g ? _manaPercent(g) : (float?)0f;
}
if (_stackSizeEntry is not null)
{
@ -210,6 +234,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
_selection.Changed += OnSelectionTransition;
_splitQuantity.Changed += OnSplitQuantityChanged;
subscribeHealthChanged(OnHealthChanged);
subscribeItemManaChanged(OnItemManaChanged);
subscribeObjectUpdated(OnObjectUpdated);
if (_selection.SelectedObjectId is { } initial)
ApplySelection(initial);
@ -237,12 +262,17 @@ public sealed class SelectedObjectController : IRetainedPanelController
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Action<Action<uint, float, bool>> subscribeItemManaChanged,
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
Func<uint, bool> isHealthTarget,
Func<uint, bool> isOwnedByPlayer,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
Func<uint, float> manaPercent,
Action<uint> sendQueryItemMana,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
@ -250,7 +280,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
=> new SelectedObjectController(
layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged,
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont,
subscribeItemManaChanged, unsubscribeItemManaChanged,
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated);
/// <summary>
@ -259,9 +291,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// </summary>
private void ApplySelection(uint? guid)
{
bool selectionChanged = _current != guid;
// gmToolbarUI::HandleSelectionChanged @ 0x004BF3D1: changing away from
// a visible meter cancels its server query with object id zero.
if (selectionChanged)
{
if (_healthMeter?.Visible == true)
_sendQueryHealth(0);
if (_manaMeter?.Visible == true)
_sendQueryItemMana(0);
}
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
// + SetVisible(0) on the meters). ──────────────────────────────────────
if (_healthMeter is not null) _healthMeter.Visible = false;
if (selectionChanged)
{
if (_healthMeter is not null) _healthMeter.Visible = false;
if (_manaMeter is not null) _manaMeter.Visible = false;
}
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false;
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false;
_splitQuantity.Reset(1u);
@ -306,12 +354,18 @@ public sealed class SelectedObjectController : IRetainedPanelController
// ── 4. Health: query, and show the meter only if real health is already known.
// Otherwise the meter appears when OnHealthChanged fires for this guid
// (retail RecvNotice_UpdateObjectHealth :196213). ──────────────────────────
if (_isHealthTarget(g))
if (stackSize <= 1u && _isHealthTarget(g))
{
_sendQueryHealth(g);
if (selectionChanged)
_sendQueryHealth(g);
if (_hasHealth(g) && _healthMeter is not null)
_healthMeter.Visible = true;
}
else if (stackSize <= 1u && _isOwnedByPlayer(g))
{
if (selectionChanged)
_sendQueryItemMana(g);
}
}
/// <summary>
@ -339,6 +393,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
_overlay?.TrySetRetailState(state);
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0</c>.
/// Invalid results cancel with object id zero; valid results reveal the meter.
/// </summary>
public void OnItemManaChanged(uint guid, float percent, bool valid)
{
if (_current != guid)
return;
if (!valid)
{
_sendQueryItemMana(0);
return;
}
if (_manaMeter is not null)
_manaMeter.Visible = true;
}
private void CommitStackEntry(string text)
=> _splitQuantity.SetFromText(text);
@ -380,6 +453,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
_selection.Changed -= OnSelectionTransition;
_splitQuantity.Changed -= OnSplitQuantityChanged;
_unsubscribeHealthChanged(OnHealthChanged);
_unsubscribeItemManaChanged(OnItemManaChanged);
_unsubscribeObjectUpdated(OnObjectUpdated);
}
}

View file

@ -35,17 +35,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF,
};
// Elements hidden by default in retail gmToolbarUI::PostInit.
// Ids confirmed from the toolbar LayoutDesc dump.
// 0x100001A1 (health meter) is now OWNED by SelectedObjectController (D.5.3a) —
// it hides A1 at bind and shows it on a health-target selection, so A1 is removed
// from here to avoid double-ownership. 0x100001A2 (mana meter), 0x100001A3 (stack-size
// entry box) and 0x100001A4 (stack slider) are DEFERRED features (mana #140, stack-split
// UI) with no controller yet, so they stay hidden here — otherwise their dat sprites
// render as stray bars / a black box on the toolbar. Retail hides A3/A4 in
// gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742),
// showing them only for a stacked-item selection.
private static readonly uint[] HiddenIds = { 0x100001A2 };
// SelectedObjectController owns the health/mana meters and both stack controls,
// including retail's initial-hidden state and selection-driven visibility.
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
@ -161,10 +152,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
}
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669):
// exactly one indicator visible at a time. Default to NonCombat (peace) — the player
// always spawns in peace mode; retail has not yet called SetVisible when PostInit runs.