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

@ -90,11 +90,11 @@ public static class DatWidgetFactory
// ── Meter ────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a <see cref="UiMeter"/> and populates its six 3-slice sprite ids by
/// reading the meter's grandchild image elements (format doc §11).
/// Builds a <see cref="UiMeter"/> and populates its sprite ids from the meter's
/// child/grandchild elements (format doc §11). Two shapes are handled:
///
/// <para>
/// Structure the importer produces for each meter (UIElement_Meter):
/// <b>3-slice shape</b> (vitals meters — 2 Type-3 containers, each with 3 image grandchildren):
/// <code>
/// meter (Type 7)
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
@ -106,13 +106,27 @@ public static class DatWidgetFactory
/// │ ├── center image (→ front-tile sprite)
/// │ ├── right-cap image (→ front-right sprite)
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController in Task 6)
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
/// </code>
/// </para>
///
/// <para>
/// <b>Single-image shape</b> (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3
/// child, no grandchildren): the back-track sprite is on the meter element's own DirectState;
/// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the
/// TILE slot (Back/FrontTile) with left/right caps 0, so <see cref="UiMeter.DrawHBar"/> tiles
/// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction.
/// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328)
/// <code>
/// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E]
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
/// </code>
/// </para>
///
/// <para>
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
/// They are bound to the live stat providers in Task 6 (VitalsController).
/// They are bound to the live stat providers by the controller (VitalsController /
/// SelectedObjectController).
/// </para>
/// </summary>
private static UiMeter BuildMeter(ElementInfo info,
@ -132,23 +146,53 @@ public static class DatWidgetFactory
.OrderBy(c => c.ReadOrder)
.ToList();
if (containers.Count != 2)
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 2) — bars may render as solid-color fallback.");
if (containers.Count >= 1)
{
var (l, t, r) = SliceIds(containers[0]);
m.BackLeft = l;
m.BackTile = t;
m.BackRight = r;
}
if (containers.Count >= 2)
{
var (l, t, r) = SliceIds(containers[1]);
m.FrontLeft = l;
m.FrontTile = t;
m.FrontRight = r;
// Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images
// (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher.
var (bl, bt, br) = SliceIds(containers[0]);
m.BackLeft = bl;
m.BackTile = bt;
m.BackRight = br;
var (fl, ft, fr) = SliceIds(containers[1]);
m.FrontLeft = fl;
m.FrontTile = ft;
m.FrontRight = fr;
}
else if (containers.Count == 1)
{
// Single-image shape used by the toolbar selected-object meters
// (health 0x100001A1, mana 0x100001A2).
// - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of
// info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E.
// - The fill sprite lives on the single Type-3 child's own DirectState ("" key of
// containers[0].StateMedia). e.g. health fill = 0x0600193F.
// The fill child has NO image grandchildren, so SliceIds would return all-zero —
// read the container's StateMedia directly instead.
//
// These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal,
// which retail renders as "tile at native width to fill the full element geometry"
// (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter::
// DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction).
// With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the
// sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and
// mapping the fill fraction to native width when nativeW < 140. The tile slot makes
// midW = full bar width, so the back tiles across all 140px and the front clips to
// 140*fraction correctly for any native sprite width (left/right caps unused = 0).
// (retail: gmToolbarUI::HandleSelectionChanged :198635 / UIElement_Meter::DrawChildren :123574)
m.BackLeft = 0;
m.BackTile = info.StateMedia.TryGetValue("", out var bm) ? bm.File : 0u;
m.BackRight = 0;
m.FrontLeft = 0;
m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u;
m.FrontRight = 0;
}
else
{
// Count == 0: no Type-3 containers at all — genuinely malformed meter dat.
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback.");
}
return m;