using System; using System.Numerics; using AcDream.App.UI; using AcDream.Core.Items; using AcDream.Core.Selection; 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 : IRetainedPanelController { // ── 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; /// Editable stack quantity (retail m_pStackSizeEntryBox). public const uint StackSizeEntryId = 0x100001A3; /// Horizontal stack quantity slider (retail m_pStackSizeSlider). public const uint StackSizeSliderId = 0x100001A4; /// 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; private readonly UiField? _stackSizeEntry; private readonly UiScrollbar? _stackSizeSlider; // ── 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; private readonly StackSplitQuantityState _splitQuantity; private readonly SelectionState _selection; private readonly Action> _unsubscribeHealthChanged; private readonly Action> _unsubscribeObjectUpdated; // ── 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 private bool _changingSplitFromSlider; private bool _disposed; /// White label color for the name line. private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f); private SelectedObjectController( ImportedLayout layout, SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, Func isHealthTarget, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated) { _isHealthTarget = isHealthTarget; _resolveName = name; _healthPercent = healthPercent; _hasHealth = hasHealth; _stackSize = stackSize; _sendQueryHealth = sendQueryHealth; _splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _unsubscribeHealthChanged = unsubscribeHealthChanged; _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; _stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField; _stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar; // 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; } if (_stackSizeEntry is not null) { _stackSizeEntry.Visible = false; _stackSizeEntry.Selectable = true; _stackSizeEntry.ClearOnSubmit = false; _stackSizeEntry.RecordHistory = false; _stackSizeEntry.CharacterFilter = static c => c is >= '0' and <= '9'; _stackSizeEntry.SelectAllOnFocus = true; _stackSizeEntry.OnSubmit = CommitStackEntry; _stackSizeEntry.OnFocusLost = CommitStackEntry; } if (_stackSizeSlider is not null) { _stackSizeSlider.Visible = false; _stackSizeSlider.Horizontal = true; _stackSizeSlider.SetScalarPosition(_splitQuantity.Ratio); _stackSizeSlider.ScalarChanged = OnStackSliderChanged; } // 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, OneLine = 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. _selection.Changed += OnSelectionTransition; _splitQuantity.Changed += OnSplitQuantityChanged; subscribeHealthChanged(OnHealthChanged); subscribeObjectUpdated(OnObjectUpdated); if (_selection.SelectedObjectId is { } initial) ApplySelection(initial); } /// /// Create and bind a to . /// Port of retail gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth. /// /// Imported toolbar layout (LayoutDesc 0x21000016). /// The single Core selected-object owner. /// 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 retail's NAME_APPROPRIATE display name for a guid. /// 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, SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, Func isHealthTarget, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated) => new SelectedObjectController( layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont, splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated); /// /// Port of gmToolbarUI::HandleSelectionChanged (:198635): /// clear-then-populate the selected-object strip on any selection change. /// private void ApplySelection(uint? guid) { // ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0) // + SetVisible(0) on the meters). ────────────────────────────────────── if (_healthMeter is not null) _healthMeter.Visible = false; if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false; _splitQuantity.Reset(1u); _currentName = null; _current = guid; if (guid is null) { // Deselect: clear the overlay flash immediately too. SetOverlayState(UiStateInfo.DirectStateId); _flashRemaining = 0; return; } uint g = guid.Value; // ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ── uint stackSize = _stackSize(g); string? objectName = _resolveName(g); _currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName) ? $"{stackSize} {objectName}" : objectName; // ── 3. Selection overlay: brief flash (retail container ObjectSelected // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── SetOverlayState(stackSize > 1u ? RetailUiStateIds.StackedItemSelected : RetailUiStateIds.ObjectSelected); _flashRemaining = FlashSeconds; // gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666: // stacks initialize to the full stack, show the numeric entry + horizontal // slider, and set the stacked selection state. Vendor-owned stack precedence // is intentionally absent until the vendor panel owns an active vendor id. if (stackSize > 1u) { _splitQuantity.Reset(stackSize); if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; } // ── 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(UiStateInfo.DirectStateId); // flash done → overlay back to blank } private void SetOverlayState(uint state) { _overlay?.TrySetRetailState(state); } private void CommitStackEntry(string text) => _splitQuantity.SetFromText(text); private void OnSplitQuantityChanged() { _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); // UIElement_Scrollbar owns its live thumb position while dragging and only // broadcasts message 0xA. Entry/selection changes explicitly write attr 0x86. if (!_changingSplitFromSlider) _stackSizeSlider?.SetScalarPosition(_splitQuantity.Ratio); } private void OnStackSliderChanged(float position) { _changingSplitFromSlider = true; try { _splitQuantity.SetFromSliderRatio(position); } finally { _changingSplitFromSlider = false; } } private void OnObjectUpdated(ClientObject updated) { if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum) ApplySelection(updated.ObjectId); } private void OnSelectionTransition(SelectionTransition transition) => ApplySelection(transition.SelectedObjectId); public void Dispose() { if (_disposed) return; _disposed = true; _selection.Changed -= OnSelectionTransition; _splitQuantity.Changed -= OnSplitQuantityChanged; _unsubscribeHealthChanged(OnHealthChanged); _unsubscribeObjectUpdated(OnObjectUpdated); } }