feat(D.5.3a): selected-object meter — Health bar + name on the action bar

Port of gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635).
When the player selects a world object the action bar's bottom strip shows the
object name + (for player/pet/attackable targets) a live Health meter; deselect
clears it. Mana (#140) + stack slider deferred.

- SelectedObjectController (new): clear-then-populate on selection change; sets
  name (UiText child, VitalsController pattern), overlay state (ObjectSelected /
  StackedItemSelected via UiDatElement.ActiveState), shows the health meter and
  sends QueryHealth for health targets. Subscribes via a delegate seam (no
  GameWindow coupling).
- GameWindow: _selectedGuid field -> SelectedGuid property + SelectionChanged
  event (fires on actual change only); 3 write sites converted, reads untouched.
  All selection-write paths (LMB pick, Tab/Q, despawn-clear via Tick()) run on
  the render thread, so the event-driven UI mutation is single-threaded.
- WorldSession.SendQueryHealth (0x01BF) — wraps SocialActions.BuildQueryHealth.
- DatWidgetFactory.BuildMeter: handle the single-image toolbar meter shape
  (back-track on the element's own DirectState, fill on one Type-3 child). The
  sprites go in the TILE slot (DrawMode=Normal tiles to full bar geometry per
  UIElement_Meter::DrawChildren) — a left-cap assignment would gap/clamp a
  sub-140px sprite. Vitals 3-slice path unchanged.
- ToolbarController.HiddenIds: A1 (health) now owned by SelectedObjectController;
  A2 (mana) + A4 (stack) stay hidden (deferred) so their dat back-tracks don't
  render as stray empty bars.

Adversarial Opus review found + fixed: the mana-meter orphan (A2 left unhidden)
and the meter tile-vs-cap render bug (C1). Divergence rows AP-46 (health gate
approximation: IsLiveCreatureTarget vs IsPlayer||pet||attackable) + AP-47
(meter shown on select vs on UpdateHealth reply). Spec §5 corrected.

Build + full test suite green (2,684 passed / 4 skipped). Health meter render
fidelity (full-width fill + fraction mapping) pending the user's visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-18 22:47:24 +02:00
parent e8562fc4e2
commit 6636e50c2a
11 changed files with 851 additions and 30 deletions

View file

@ -619,6 +619,8 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.UiHost? _uiHost;
// Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern).
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
@ -846,6 +848,21 @@ public sealed class GameWindow : IDisposable
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
private uint? _selectedGuid;
/// <summary>Fires when the selected world object changes (retail gmToolbarUI selection-change event,
/// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes.</summary>
private event Action<uint?>? SelectionChanged;
/// <summary>Currently-selected world object guid. The setter fires <see cref="SelectionChanged"/> only on
/// an actual change (dedup), so all writes go through here; reads may use the field directly.</summary>
private uint? SelectedGuid
{
get => _selectedGuid;
set
{
if (_selectedGuid == value) return;
_selectedGuid = value;
SelectionChanged?.Invoke(value);
}
}
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
// once the local auto-walk overlay reports arrival (body has finished
@ -2003,6 +2020,19 @@ public sealed class GameWindow : IDisposable
warDigits: toolbarWarDigits,
emptyDigits: toolbarEmptyDigits);
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
// Analogue of retail gmToolbarUI::HandleSelectionChanged
// (acclient_2013_pseudo_c.txt:198635).
_selectedObjectController = AcDream.App.UI.Layout.SelectedObjectController.Bind(
toolbarLayout,
subscribeSelectionChanged: h => SelectionChanged += h,
isHealthTarget: IsLiveCreatureTarget,
name: g => Objects.Get(g)?.Name,
healthPercent: g => Combat.GetHealthPercent(g),
stackSize: g => (uint)(Objects.Get(g)?.StackSize ?? 0),
sendQueryHealth: g => _liveSession?.SendQueryHealth(g),
datFont: vitalsDatFont);
var toolbarRoot = toolbarLayout.Root;
// Wrap the dat content in the universal 8-piece beveled window chrome —
// the SAME UiNineSlicePanel used by the vitals and chat windows. The
@ -3708,7 +3738,7 @@ public sealed class GameWindow : IDisposable
_entitiesByServerGuid.Remove(serverGuid);
_lastSpawnByGuid.Remove(serverGuid);
if (_selectedGuid == serverGuid)
_selectedGuid = null;
SelectedGuid = null;
if (logDelete)
_lightingSink?.UnregisterOwner(existingEntity.Id);
@ -11568,7 +11598,7 @@ public sealed class GameWindow : IDisposable
if (picked is uint guid)
{
_selectedGuid = guid;
SelectedGuid = guid;
string label = DescribeLiveEntity(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
// B.7 (2026-05-15): one-shot per-pick diagnostic so we can
@ -11958,7 +11988,7 @@ public sealed class GameWindow : IDisposable
bestGuid = guid;
}
_selectedGuid = bestGuid;
SelectedGuid = bestGuid;
if (bestGuid is { } selected)
{
string label = DescribeLiveEntity(selected);