using System; using System.Numerics; using AcDream.App.UI; namespace AcDream.App.UI.Layout; /// /// Controller for the action bar's selected-object strip (ids 0x1000019E–0x100001A1). /// Analogue of retail gmToolbarUI::HandleSelectionChanged /// (docs/research/named-retail/acclient_2013_pseudo_c.txt:198635) + /// RecvNotice_UpdateObjectHealth (:196213). /// /// /// 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 QueryHealth (0x01BF) request. The Health meter /// becomes visible only when the server actually reports health for the selected guid — /// either an UpdateHealth (0x01C0) arrives (retail /// RecvNotice_UpdateObjectHealthSetVisible(1)) or the value is already /// cached. So a friendly NPC you have not assessed shows name-only (no bar), and a /// monster's bar appears after damage / a successful assess — matching retail. /// /// /// /// Retail element roles (PostInit, :198119): m_pSelObjectField /// is the container 0x1000019E whose SetState(0x1000000b/0c) drives a /// 0.25s Pause→Normal flash that cascades to the overlay child's green frame. /// acdream has no state-cascade / transition-animation system, so this controller drives /// the overlay element 0x100001A0 directly and reverts it after the same /// to reproduce the brief flash. The name element /// 0x1000019F is bumped to the top of the strip's z-order so it draws OVER the /// overlay frame and the health bar (retail draws the name over the bar — see the /// "Drudge Slinker" reference shot). /// /// /// /// Divergence — health-target gate approximation. /// Retail sends Event_QueryHealth for IsPlayer() || pet_owner || ObjectIsAttackable() /// (:198754). acdream uses IsLiveCreatureTarget (the ItemType.Creature /// flag) to gate the QueryHealth send. Visibility itself is health-data-driven (above), so /// the gate only affects whether we proactively query; recorded in the divergence register. /// /// public sealed class SelectedObjectController { // ── Element ids (toolbar LayoutDesc 0x21000016) ───────────────────────── /// Selected-object container / field element id (retail m_pSelObjectField). public const uint ContainerId = 0x1000019E; /// Selected-object name element id (retail m_pSelObjectName, UIElement_Text). public const uint NameId = 0x1000019F; /// Selected-object overlay element id (states: ObjectSelected / StackedItemSelected). public const uint OverlayId = 0x100001A0; /// Selected-object health meter element id (retail m_pSelObjectHealthMeter). public const uint HealthMeterId = 0x100001A1; /// Selection-overlay flash duration — retail's container ObjectSelected state is a /// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E). private const double FlashSeconds = 0.25; /// Z-order for the name so it draws OVER the overlay frame + health bar. /// The strip's other children sit at ReadOrder 1–4; this floats the name to the top. private const int NameZOrderOnTop = 1_000_000; /// Z-order for the selection-flash overlay — above the health meter (so the green /// flash isn't hidden by the bar) but below the name (so the name stays readable). private const int OverlayZOrder = NameZOrderOnTop - 1; /// Height (px) of the black name band at the top of the 31px bar sprite. The name /// label is constrained to this band (top-aligned) so the health bar shows below it — /// retail "name on the black, bar below". The bar sprite's colored region starts ~y14. private const float NameBandHeight = 15f; // ── 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 _isHealthTarget; private readonly Func _resolveName; private readonly Func _healthPercent; private readonly Func _hasHealth; private readonly Func _stackSize; private readonly Action _sendQueryHealth; // ── Live state (read by closures on the per-frame draw path) ──────────── private uint? _current; private string? _currentName; private double _flashRemaining; // > 0 while the selection overlay is flashing /// White label color for the name line. private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f); private SelectedObjectController( ImportedLayout layout, Action> subscribeSelectionChanged, Action> subscribeHealthChanged, Func isHealthTarget, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, UiDatFont? datFont) { _isHealthTarget = isHealthTarget; _resolveName = name; _healthPercent = healthPercent; _hasHealth = hasHealth; _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; // The selection-flash overlay must draw OVER the health meter (which spans the whole // strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e. // for players/monsters). Float it just below the name so the name stays readable. if (_overlay is not null) _overlay.ZOrder = OverlayZOrder; // 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). _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. The name is floated to the // top of the strip's z-order so it draws OVER the overlay frame and the health bar // (retail renders the object name over the bar). // // The bar sprite (0x0600193E/F, 146x31) carries a ~14px BLACK name band across its // TOP with the colored bar in the lower portion (confirmed from the dat). Retail // draws the object name in that black band with the health bar BELOW it — so the // label is TOP-aligned by constraining its height to the band, not centered over the // whole 31px strip (which overlapped the bar's middle). if (_name is not null) { _name.ZOrder = NameZOrderOnTop; var label = new UiText { Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, Centered = true, DatFont = datFont, ClickThrough = true, AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false, LinesProvider = () => { var n = _currentName; return string.IsNullOrEmpty(n) ? Array.Empty() : new[] { new UiText.Line(n, NameColor) }; }, }; _name.AddChild(label); } // Register the handlers LAST so the initial state is fully set up first. subscribeSelectionChanged(OnSelectionChanged); subscribeHealthChanged(OnHealthChanged); } /// /// Create and bind a to . /// Port of retail gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth. /// /// Imported toolbar layout (LayoutDesc 0x21000016). /// Called once with /// (typical host: h => SelectionChanged += h). /// Called once with /// (typical host: h => Combat.HealthChanged += h) — drives meter visibility. /// Returns true for guids that may show a health meter /// (proxy for retail's IsPlayer() || pet_owner || ObjectIsAttackable()). /// Returns the display name for a given guid (or null if unknown). /// Returns the health fill fraction [0..1] for a given guid. /// Returns true if real health has been received for a guid /// (so a re-selected, already-known target shows its bar immediately). /// Returns the stack size for a guid (0 or 1 = non-stacked). /// Sends retail QueryHealth (0x01BF); may be a no-op offline. /// Dat font for the name label; null = debug bitmap font fallback. public static SelectedObjectController Bind( ImportedLayout layout, Action> subscribeSelectionChanged, Action> subscribeHealthChanged, Func isHealthTarget, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, UiDatFont? datFont) => new SelectedObjectController( layout, subscribeSelectionChanged, subscribeHealthChanged, isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont); /// /// Port of gmToolbarUI::HandleSelectionChanged (:198635): /// clear-then-populate the selected-object strip on any selection change. /// public void OnSelectionChanged(uint? guid) { // ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0) // + SetVisible(0) on the meters). ────────────────────────────────────── if (_healthMeter is not null) _healthMeter.Visible = false; _currentName = null; _current = guid; if (guid is null) { // Deselect: clear the overlay flash immediately too. SetOverlayState(""); _flashRemaining = 0; return; } uint g = guid.Value; // ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ── _currentName = _resolveName(g); // ── 3. Selection overlay: brief flash (retail container ObjectSelected // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected"); _flashRemaining = FlashSeconds; // ── 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)) { _sendQueryHealth(g); if (_hasHealth(g) && _healthMeter is not null) _healthMeter.Visible = true; } } /// /// Port of gmToolbarUI::RecvNotice_UpdateObjectHealth (:196213): when the /// server reports health for the currently-selected guid, make the Health meter visible. /// The fill value is read live by the meter's provider. /// public void OnHealthChanged(uint guid, float percent) { if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null) _healthMeter.Visible = true; } /// Per-frame tick: reverts the selection overlay after the brief flash window. public void Tick(double deltaSeconds) { if (_flashRemaining <= 0) return; _flashRemaining -= deltaSeconds; if (_flashRemaining <= 0) SetOverlayState(""); // flash done → overlay back to blank } private void SetOverlayState(string state) { if (_overlay is not null) _overlay.ActiveState = state; } }