acdream/src/AcDream.App/UI/Layout/SelectedObjectController.cs
Erik 6005c51c4d fix(ui): finish retail stack selector polish
Keep the horizontal thumb at its raw pointer position so both endpoints are reachable, honor the stack entry's authored right alignment, and preserve PublicWeenieDesc plural names from CreateObject through the object table. Port retail's singular s/es fallback when no plural was sent.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 10:22:11 +02:00

385 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Controller for the action bar's selected-object strip (ids 0x1000019E0x100001A1).
/// Analogue of retail <c>gmToolbarUI::HandleSelectionChanged</c>
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>) +
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>).
///
/// <para>
/// 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 <c>QueryHealth (0x01BF)</c> request. The Health meter
/// becomes visible only when the server actually reports health for the selected guid —
/// either an <c>UpdateHealth (0x01C0)</c> arrives (retail
/// <c>RecvNotice_UpdateObjectHealth</c> → <c>SetVisible(1)</c>) 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.
/// </para>
///
/// <para>
/// <strong>Retail element roles</strong> (PostInit, <c>:198119</c>): <c>m_pSelObjectField</c>
/// is the container <c>0x1000019E</c> whose <c>SetState(0x1000000b/0c)</c> drives a
/// 0.25s <c>Pause→Normal</c> 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 <c>0x100001A0</c> directly and reverts it after the same
/// <see cref="FlashSeconds"/> to reproduce the brief flash. The name element
/// <c>0x1000019F</c> 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).
/// </para>
///
/// <para>
/// <strong>Divergence — health-target gate approximation.</strong>
/// Retail sends <c>Event_QueryHealth</c> for <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>
/// (<c>:198754</c>). acdream uses <c>IsLiveCreatureTarget</c> (the <c>ItemType.Creature</c>
/// 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.
/// </para>
/// </summary>
public sealed class SelectedObjectController : IRetainedPanelController
{
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
/// <summary>Selected-object container / field element id (retail m_pSelObjectField).</summary>
public const uint ContainerId = 0x1000019E;
/// <summary>Selected-object name element id (retail m_pSelObjectName, UIElement_Text).</summary>
public const uint NameId = 0x1000019F;
/// <summary>Selected-object overlay element id (states: ObjectSelected / StackedItemSelected).</summary>
public const uint OverlayId = 0x100001A0;
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
public const uint HealthMeterId = 0x100001A1;
/// <summary>Editable stack quantity (retail m_pStackSizeEntryBox).</summary>
public const uint StackSizeEntryId = 0x100001A3;
/// <summary>Horizontal stack quantity slider (retail m_pStackSizeSlider).</summary>
public const uint StackSizeSliderId = 0x100001A4;
/// <summary>Selection-overlay flash duration — retail's container ObjectSelected state is a
/// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E).</summary>
private const double FlashSeconds = 0.25;
/// <summary>Z-order for the name so it draws OVER the overlay frame + health bar.
/// The strip's other children sit at ReadOrder 14; this floats the name to the top.</summary>
private const int NameZOrderOnTop = 1_000_000;
/// <summary>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).</summary>
private const int OverlayZOrder = NameZOrderOnTop - 1;
/// <summary>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.</summary>
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<uint, bool> _isHealthTarget;
private readonly Func<uint, string?> _resolveName;
private readonly Func<uint, float> _healthPercent;
private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth;
private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<ClientObject>> _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;
/// <summary>White label color for the name line.</summary>
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
private SelectedObjectController(
ImportedLayout layout,
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Func<uint, bool> isHealthTarget,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> 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<UiText.Line>()
: 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);
}
/// <summary>
/// Create and bind a <see cref="SelectedObjectController"/> to <paramref name="layout"/>.
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
/// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="selection">The single Core selected-object owner.</param>
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
/// (typical host: <c>h =&gt; Combat.HealthChanged += h</c>) — drives meter visibility.</param>
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
/// (proxy for retail's <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>).</param>
/// <param name="name">Returns retail's NAME_APPROPRIATE display name for a guid.</param>
/// <param name="healthPercent">Returns the health fill fraction [0..1] for a given guid.</param>
/// <param name="hasHealth">Returns true if real health has been received for a guid
/// (so a re-selected, already-known target shows its bar immediately).</param>
/// <param name="stackSize">Returns the stack size for a guid (0 or 1 = non-stacked).</param>
/// <param name="sendQueryHealth">Sends retail <c>QueryHealth (0x01BF)</c>; may be a no-op offline.</param>
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
public static SelectedObjectController Bind(
ImportedLayout layout,
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Func<uint, bool> isHealthTarget,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated)
=> new SelectedObjectController(
layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged,
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated);
/// <summary>
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
/// clear-then-populate the selected-object strip on any selection change.
/// </summary>
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;
}
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>): 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 <see cref="UiMeter.Fill"/> provider.
/// </summary>
public void OnHealthChanged(uint guid, float percent)
{
if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null)
_healthMeter.Visible = true;
}
/// <summary>Per-frame tick: reverts the selection overlay after the brief flash window.</summary>
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);
}
}