Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
RightAligned only applies on UiField's single-line renderer; the entry was falling into the multi-line path (ignoring the alignment AND re-scrolling its extents per keystroke — the reported edit flicker). A 14 px numeric entry is single-line by construction; OneLine = true routes it correctly. The 233 now sits flush against the slider per the authored HJustify=2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
572 lines
27 KiB
C#
572 lines
27 KiB
C#
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 0x1000019E–0x100001A4).
|
||
/// 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>) +
|
||
/// <c>RecvNotice_UpdateItemMana</c> (<c>:196188</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 sends
|
||
/// either <c>QueryHealth (0x01BF)</c> for health-bearing targets or
|
||
/// <c>QueryItemMana (0x0263)</c> for owned non-stack items. 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>
|
||
///
|
||
/// </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>Selected-object item-mana meter element id (retail m_pSelObjectManaMeter).</summary>
|
||
public const uint ManaMeterId = 0x100001A2;
|
||
/// <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 1–4; 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 UiMeter? _manaMeter;
|
||
private readonly UiField? _stackSizeEntry;
|
||
private readonly UiScrollbar? _stackSizeSlider;
|
||
|
||
// ── Captured delegates ───────────────────────────────────────────────────
|
||
private readonly Func<uint, bool> _isHealthTarget;
|
||
private readonly Func<uint, bool> _isOwnedByPlayer;
|
||
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 Func<uint, float> _manaPercent;
|
||
private readonly Action<uint> _sendQueryItemMana;
|
||
private readonly StackSplitQuantityState _splitQuantity;
|
||
private readonly SelectionState _selection;
|
||
private readonly Func<uint, bool> _isVendorSplitExempt;
|
||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
|
||
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,
|
||
Action<Action<uint, float, bool>> subscribeItemManaChanged,
|
||
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
|
||
Func<uint, bool> isHealthTarget,
|
||
Func<uint, bool> isOwnedByPlayer,
|
||
Func<uint, string?> name,
|
||
Func<uint, float> healthPercent,
|
||
Func<uint, bool> hasHealth,
|
||
Func<uint, uint> stackSize,
|
||
Action<uint> sendQueryHealth,
|
||
Func<uint, float> manaPercent,
|
||
Action<uint> sendQueryItemMana,
|
||
UiDatFont? datFont,
|
||
StackSplitQuantityState splitQuantity,
|
||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||
Action<Action<ClientObject>> unsubscribeObjectUpdated,
|
||
Func<uint, bool> isVendorSplitExempt)
|
||
{
|
||
_isHealthTarget = isHealthTarget;
|
||
_isOwnedByPlayer = isOwnedByPlayer;
|
||
_resolveName = name;
|
||
_healthPercent = healthPercent;
|
||
_hasHealth = hasHealth;
|
||
_stackSize = stackSize;
|
||
_sendQueryHealth = sendQueryHealth;
|
||
_manaPercent = manaPercent;
|
||
_sendQueryItemMana = sendQueryItemMana;
|
||
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
|
||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||
_isVendorSplitExempt = isVendorSplitExempt
|
||
?? throw new ArgumentNullException(nameof(isVendorSplitExempt));
|
||
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||
_unsubscribeItemManaChanged = unsubscribeItemManaChanged;
|
||
_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;
|
||
_manaMeter = layout.FindElement(ManaMeterId) 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 (_manaMeter is not null)
|
||
{
|
||
_manaMeter.Visible = false;
|
||
_manaMeter.Fill = () => _current is uint g ? _manaPercent(g) : (float?)0f;
|
||
}
|
||
|
||
if (_stackSizeEntry is not null)
|
||
{
|
||
_stackSizeEntry.Visible = false;
|
||
// #353: the entry is AUTHORED HJustify=2 (right) at X=0 W=50,
|
||
// flush against the slider at X=50 on the same row — the count
|
||
// reads right-adjacent to the bar, retail's look. UiField
|
||
// already supports it; the importer does not carry HJustify.
|
||
// OneLine is REQUIRED for the alignment: RightAligned only
|
||
// applies on the single-line draw path, and the multi-line
|
||
// path's per-keystroke scroll-extent churn was also the edit
|
||
// flicker the user reported. A 14 px numeric entry is
|
||
// single-line by construction.
|
||
_stackSizeEntry.OneLine = true;
|
||
_stackSizeEntry.RightAligned = true;
|
||
_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;
|
||
// #353: the name element is AUTHORED two lines tall (H=31 at
|
||
// W=140) — a long name wraps at the authored PIXEL width onto a
|
||
// second row instead of overflowing (user-verified retail
|
||
// behavior). Two stacked centered one-line labels reuse the
|
||
// existing centered draw path; the second draws nothing when
|
||
// the name fits.
|
||
float nameWidth = _name.Width;
|
||
var wrapFont = datFont;
|
||
Func<int, UiText.Line[]> lineFor = index =>
|
||
{
|
||
var n = _currentName;
|
||
if (string.IsNullOrEmpty(n))
|
||
return Array.Empty<UiText.Line>();
|
||
(string first, string second) = WrapNameTwoLines(n, nameWidth, wrapFont);
|
||
string text = index == 0 ? first : second;
|
||
return text.Length == 0
|
||
? Array.Empty<UiText.Line>()
|
||
: new[] { new UiText.Line(text, NameColor) };
|
||
};
|
||
for (int lineIndex = 0; lineIndex < 2; lineIndex++)
|
||
{
|
||
int captured = lineIndex;
|
||
var label = new UiText
|
||
{
|
||
Left = 0f,
|
||
Top = captured * NameBandHeight,
|
||
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 = () => lineFor(captured),
|
||
};
|
||
_name.AddChild(label);
|
||
}
|
||
}
|
||
|
||
// Register the handlers LAST so the initial state is fully set up first.
|
||
_selection.Changed += OnSelectionTransition;
|
||
_splitQuantity.Changed += OnSplitQuantityChanged;
|
||
subscribeHealthChanged(OnHealthChanged);
|
||
subscribeItemManaChanged(OnItemManaChanged);
|
||
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 => 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>
|
||
/// <param name="isVendorSplitExempt">
|
||
/// Slice 6.2: retail's <c>gmToolbarUI::HandleSelectionChanged</c> vendor
|
||
/// branch (<c>pc:198779-198790</c>) — true when the selected guid is
|
||
/// owned by the currently-open vendor (its <c>ClientObject.ContainerId</c>
|
||
/// equals <c>VendorState.VendorId</c>) AND its type intersects
|
||
/// <see cref="VendorSplitPolicy.SplitExemptMask"/>. When true, a stack
|
||
/// seeds to quantity 1 instead of the full authored stack size — see
|
||
/// <see cref="ApplySelection"/>. F2 (Slice 6 review):
|
||
/// <c>VendorUiController.ResolveBuyQuantity</c> answers the equivalent
|
||
/// LIVE question for vendor's own display text and Buy dispatch (the
|
||
/// CURRENT slider value, not the seed) via the SAME
|
||
/// <see cref="VendorSplitPolicy"/> mask, so the mask exists in exactly
|
||
/// one place (composed at <c>InteractionRetainedUiComposition</c>).
|
||
/// </param>
|
||
public static SelectedObjectController Bind(
|
||
ImportedLayout layout,
|
||
SelectionState selection,
|
||
Action<Action<uint, float>> subscribeHealthChanged,
|
||
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||
Action<Action<uint, float, bool>> subscribeItemManaChanged,
|
||
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
|
||
Func<uint, bool> isHealthTarget,
|
||
Func<uint, bool> isOwnedByPlayer,
|
||
Func<uint, string?> name,
|
||
Func<uint, float> healthPercent,
|
||
Func<uint, bool> hasHealth,
|
||
Func<uint, uint> stackSize,
|
||
Action<uint> sendQueryHealth,
|
||
Func<uint, float> manaPercent,
|
||
Action<uint> sendQueryItemMana,
|
||
UiDatFont? datFont,
|
||
StackSplitQuantityState splitQuantity,
|
||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||
Action<Action<ClientObject>> unsubscribeObjectUpdated,
|
||
Func<uint, bool> isVendorSplitExempt)
|
||
=> new SelectedObjectController(
|
||
layout, selection,
|
||
subscribeHealthChanged, unsubscribeHealthChanged,
|
||
subscribeItemManaChanged, unsubscribeItemManaChanged,
|
||
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
|
||
sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
|
||
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated,
|
||
isVendorSplitExempt);
|
||
|
||
/// <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)
|
||
{
|
||
bool selectionChanged = _current != guid;
|
||
|
||
// gmToolbarUI::HandleSelectionChanged @ 0x004BF3D1: changing away from
|
||
// a visible meter cancels its server query with object id zero.
|
||
if (selectionChanged)
|
||
{
|
||
if (_healthMeter?.Visible == true)
|
||
_sendQueryHealth(0);
|
||
if (_manaMeter?.Visible == true)
|
||
_sendQueryItemMana(0);
|
||
}
|
||
|
||
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
|
||
// + SetVisible(0) on the meters). ──────────────────────────────────────
|
||
if (selectionChanged)
|
||
{
|
||
if (_healthMeter is not null) _healthMeter.Visible = false;
|
||
if (_manaMeter is not null) _manaMeter.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;
|
||
|
||
if (VendorDiagnostics.DumpVendorEnabled)
|
||
{
|
||
Console.WriteLine(
|
||
$"[vendor-diag] ApplySelection guid=0x{g:X8} stackSizeOperand={stackSize} "
|
||
+ $"objectName={objectName ?? "null"} builtLabel={_currentName ?? "null"}");
|
||
}
|
||
|
||
// ── 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. Slice 6.2: the
|
||
// vendor-owned branch (pc:198779-198790, mask literal pc:198784)
|
||
// seeds splitSize (the INITIAL value) to 1 instead of the full stack
|
||
// when the selection is owned by the currently-open vendor AND its
|
||
// type intersects VendorSplitPolicy.SplitExemptMask — see the
|
||
// isVendorSplitExempt parameter doc. maxSplitSize (the slider's
|
||
// RANGE) is always the full authored stack size regardless of
|
||
// exemption (research doc §B.3: "Sets GenItemHolder::splitSize =
|
||
// seed, GenItemHolder::maxSplitSize = stackSize") — only the
|
||
// starting VALUE differs, not the ceiling.
|
||
if (stackSize > 1u)
|
||
{
|
||
bool vendorSplitExempt = _isVendorSplitExempt(g);
|
||
uint seed = vendorSplitExempt ? 1u : stackSize;
|
||
_splitQuantity.Reset(stackSize, initialValue: seed);
|
||
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
|
||
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
|
||
if (VendorDiagnostics.DumpVendorEnabled)
|
||
{
|
||
Console.WriteLine(
|
||
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=true "
|
||
+ $"isVendorSplitExempt={vendorSplitExempt} maxSplitSize={stackSize} seed={seed}");
|
||
}
|
||
}
|
||
else if (VendorDiagnostics.DumpVendorEnabled)
|
||
{
|
||
Console.WriteLine(
|
||
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=false "
|
||
+ $"failingPredicate=stackSize<=1u stackSize={stackSize}");
|
||
}
|
||
|
||
// ── 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 (stackSize <= 1u && _isHealthTarget(g))
|
||
{
|
||
if (selectionChanged)
|
||
_sendQueryHealth(g);
|
||
if (_hasHealth(g) && _healthMeter is not null)
|
||
_healthMeter.Visible = true;
|
||
}
|
||
else if (stackSize <= 1u && _isOwnedByPlayer(g))
|
||
{
|
||
if (selectionChanged)
|
||
_sendQueryItemMana(g);
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Port of <c>gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0</c>.
|
||
/// Invalid results cancel with object id zero; valid results reveal the meter.
|
||
/// </summary>
|
||
public void OnItemManaChanged(uint guid, float percent, bool valid)
|
||
{
|
||
if (_current != guid)
|
||
return;
|
||
|
||
if (!valid)
|
||
{
|
||
_sendQueryItemMana(0);
|
||
return;
|
||
}
|
||
|
||
if (_manaMeter is not null)
|
||
_manaMeter.Visible = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #353: greedy word wrap of the selected-object name into at most two
|
||
/// lines at the authored pixel width. A single word longer than the
|
||
/// width stays unbroken on its line (retail does not hyphenate). The
|
||
/// second line carries everything remaining — the authored element is
|
||
/// exactly two lines tall, so anything longer simply clips like retail.
|
||
/// </summary>
|
||
internal static (string First, string Second) WrapNameTwoLines(
|
||
string name,
|
||
float width,
|
||
UiDatFont? font)
|
||
{
|
||
if (font is null || font.MeasureWidth(name) <= width)
|
||
return (name, string.Empty);
|
||
|
||
int breakAt = -1;
|
||
for (int i = 0; i < name.Length; i++)
|
||
{
|
||
if (name[i] != ' ')
|
||
continue;
|
||
if (font.MeasureWidth(name[..i]) <= width)
|
||
breakAt = i;
|
||
else
|
||
break;
|
||
}
|
||
|
||
if (breakAt <= 0)
|
||
return (name, string.Empty);
|
||
return (name[..breakAt], name[(breakAt + 1)..].TrimStart());
|
||
}
|
||
|
||
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)
|
||
{
|
||
// Retail's CM_UI::SendNotice_SelectionChanged (0x00479F50) carries no
|
||
// selected-id payload. gmToolbarUI::HandleSelectionChanged therefore
|
||
// reads the live ACCWeenieObject::selectedID when its notice handler
|
||
// runs. This matters when an earlier handler (ClientCombatSystem::
|
||
// AutoTarget) selects a replacement reentrantly: the outer "cleared"
|
||
// notice must render that replacement, not stale transition data.
|
||
_ = transition;
|
||
ApplySelection(_selection.SelectedObjectId);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
_selection.Changed -= OnSelectionTransition;
|
||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||
_unsubscribeHealthChanged(OnHealthChanged);
|
||
_unsubscribeItemManaChanged(OnItemManaChanged);
|
||
_unsubscribeObjectUpdated(OnObjectUpdated);
|
||
}
|
||
}
|