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:
parent
e8562fc4e2
commit
6636e50c2a
11 changed files with 851 additions and 30 deletions
208
src/AcDream.App/UI/Layout/SelectedObjectController.cs
Normal file
208
src/AcDream.App/UI/Layout/SelectedObjectController.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for the action bar's selected-object strip (ids 0x1000019F–0x100001A1).
|
||||
/// Analogue of retail <c>gmToolbarUI::HandleSelectionChanged</c>
|
||||
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// On selection change: clears the strip (name, overlay state, health meter), then
|
||||
/// if a guid is provided it sets the name, puts the overlay field into the appropriate
|
||||
/// state ("ObjectSelected" or "StackedItemSelected"), and for health-bearing targets
|
||||
/// shows the health meter and sends a <c>QueryHealth (0x01BF)</c> request.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Divergence — meter-visible timing.</strong>
|
||||
/// Retail makes the health meter visible from <c>RecvNotice_UpdateObjectHealth</c>
|
||||
/// (when the queried value arrives, cite HandleSelectionChanged:198757).
|
||||
/// acdream shows it immediately on select (fill polls <see cref="CombatState.GetHealthPercent"/>
|
||||
/// which returns 1.0 until the reply lands). Recorded in the divergence register.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Divergence — health-target gate approximation.</strong>
|
||||
/// Retail gates on <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>
|
||||
/// (cite HandleSelectionChanged:198754). acdream uses <c>IsLiveCreatureTarget</c>
|
||||
/// (the <c>ItemType.Creature</c> flag). Recorded in the divergence register.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SelectedObjectController
|
||||
{
|
||||
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
|
||||
/// <summary>Selected-object name element id.</summary>
|
||||
public const uint NameId = 0x1000019F;
|
||||
/// <summary>Selected-object overlay field element id (states: ObjectSelected / StackedItemSelected).</summary>
|
||||
public const uint OverlayId = 0x100001A0;
|
||||
/// <summary>Selected-object health meter element id.</summary>
|
||||
public const uint HealthMeterId = 0x100001A1;
|
||||
|
||||
// ── Found elements (any may be null for partial/test layouts) ───────────
|
||||
private readonly UiElement? _name;
|
||||
private readonly UiDatElement? _overlay;
|
||||
private readonly UiMeter? _healthMeter;
|
||||
|
||||
// ── Captured delegates ───────────────────────────────────────────────────
|
||||
private readonly Func<uint, bool> _isHealthTarget;
|
||||
private readonly Func<uint, string?> _name_;
|
||||
private readonly Func<uint, float> _healthPercent;
|
||||
private readonly Func<uint, uint> _stackSize;
|
||||
private readonly Action<uint> _sendQueryHealth;
|
||||
|
||||
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||
private uint? _current;
|
||||
private string? _currentName;
|
||||
|
||||
/// <summary>White label color for the name line.</summary>
|
||||
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
private SelectedObjectController(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
Func<uint, string?> name,
|
||||
Func<uint, float> healthPercent,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
_isHealthTarget = isHealthTarget;
|
||||
_name_ = name;
|
||||
_healthPercent = healthPercent;
|
||||
_stackSize = stackSize;
|
||||
_sendQueryHealth = sendQueryHealth;
|
||||
|
||||
// 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;
|
||||
|
||||
// This controller owns the health meter's initial-hidden state.
|
||||
if (_healthMeter is not null)
|
||||
{
|
||||
_healthMeter.Visible = false;
|
||||
// Fill polls live: _current holds the currently-selected guid (or null).
|
||||
// Returns 0f when nothing is selected (empty bar), healthPercent(g) otherwise.
|
||||
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
|
||||
}
|
||||
|
||||
// Attach a centered UiText child to the name element for the object name display.
|
||||
// Mirrors VitalsController.BindMeter's number attach (same decoration style).
|
||||
if (_name is not null)
|
||||
{
|
||||
var label = new UiText
|
||||
{
|
||||
Left = 0f,
|
||||
Top = 0f,
|
||||
Width = _name.Width,
|
||||
Height = _name.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
AcceptsFocus = false,
|
||||
IsEditControl = false,
|
||||
CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
// Returns a single white line when a name is available; empty otherwise.
|
||||
var n = _currentName;
|
||||
return string.IsNullOrEmpty(n)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(n, NameColor) };
|
||||
},
|
||||
};
|
||||
_name.AddChild(label);
|
||||
}
|
||||
|
||||
// Register the handler LAST so the initial state is fully set up first.
|
||||
subscribeSelectionChanged(OnSelectionChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create and bind a <see cref="SelectedObjectController"/> to <paramref name="layout"/>.
|
||||
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:198635</c>).
|
||||
/// </summary>
|
||||
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
|
||||
/// <param name="subscribeSelectionChanged">
|
||||
/// Called once with the controller's <see cref="OnSelectionChanged"/> handler.
|
||||
/// Typical host: <c>h => SelectionChanged += h</c> — keeps the controller
|
||||
/// decoupled from <c>GameWindow</c>.
|
||||
/// </param>
|
||||
/// <param name="isHealthTarget">
|
||||
/// Returns true for guids that should show a health meter (proxy for retail's
|
||||
/// <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>).
|
||||
/// </param>
|
||||
/// <param name="name">Returns the display name for a given guid (or null if unknown).</param>
|
||||
/// <param name="healthPercent">Returns the health fill fraction [0..1] for a given guid.</param>
|
||||
/// <param name="stackSize">Returns the stack size for a given guid (0 or 1 = non-stacked).</param>
|
||||
/// <param name="sendQueryHealth">
|
||||
/// Sends retail <c>QueryHealth (0x01BF)</c>; server replies with <c>UpdateHealth (0x01C0)</c>.
|
||||
/// May be a no-op when offline.
|
||||
/// </param>
|
||||
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
|
||||
public static SelectedObjectController Bind(
|
||||
ImportedLayout layout,
|
||||
Action<Action<uint?>> subscribeSelectionChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
Func<uint, string?> name,
|
||||
Func<uint, float> healthPercent,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
UiDatFont? datFont)
|
||||
{
|
||||
return new SelectedObjectController(
|
||||
layout, subscribeSelectionChanged,
|
||||
isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:198635</c>):
|
||||
/// clear-then-populate the selected-object strip on any selection change.
|
||||
/// Registered via <c>subscribeSelectionChanged</c> at bind time; called by
|
||||
/// <c>GameWindow.SelectionChanged</c> and by the despawn-clear path.
|
||||
/// </summary>
|
||||
public void OnSelectionChanged(uint? guid)
|
||||
{
|
||||
// ── 1. Clear first (retail: UIElement_Text::SetText + m_pSelObjectField->SetState(0)
|
||||
// + SetVisible(0) on the health meter). ──────────────────────────────────────
|
||||
if (_healthMeter is not null) _healthMeter.Visible = false;
|
||||
if (_overlay is not null) _overlay.ActiveState = "";
|
||||
_currentName = null;
|
||||
|
||||
// Update the backing current guid so the Fill closure reflects the new state.
|
||||
_current = guid;
|
||||
|
||||
// ── 2. Selection == null → strip stays cleared, done. ───────────────────────────
|
||||
if (guid is null) return;
|
||||
|
||||
uint g = guid.Value;
|
||||
|
||||
// ── 3. Selection != null — populate the strip. ──────────────────────────────────
|
||||
|
||||
// Name (displayed via the UiText child's LinesProvider reading _currentName).
|
||||
_currentName = _name_(g);
|
||||
|
||||
// Overlay state: "StackedItemSelected" for stacked items, "ObjectSelected" otherwise.
|
||||
// Retail ref: m_pSelObjectField->SetState(0x1000000b) = "ObjectSelected"
|
||||
// (acclient_2013_pseudo_c.txt:198754). Stack sprite id 0x06004CF4 confirmed in toolbar dump.
|
||||
if (_overlay is not null)
|
||||
_overlay.ActiveState = _stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected";
|
||||
|
||||
// Health meter: visible + QueryHealth for health-bearing targets.
|
||||
// Divergence: retail shows the meter only when RecvNotice_UpdateObjectHealth arrives;
|
||||
// acdream shows it immediately (fill reads GetHealthPercent = 1.0 until the reply).
|
||||
// Retail ref: CM_Combat::Event_QueryHealth (acclient_2013_pseudo_c.txt:198757).
|
||||
if (_isHealthTarget(g))
|
||||
{
|
||||
if (_healthMeter is not null) _healthMeter.Visible = true;
|
||||
_sendQueryHealth(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue