Replace the toolbar's PWD-bit approximation with retail ObjectIsAttackable composed with the exact player and pet short-circuits. Carry second-header PetOwner through CreateObject, session, and ClientObject state so self, pet, and Free-PK cases match retail while friendly NPCs and attackable non-creatures remain name-only. Co-Authored-By: Codex <codex@openai.com>
452 lines
21 KiB
C#
452 lines
21 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 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)
|
||
{
|
||
_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));
|
||
_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;
|
||
_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);
|
||
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>
|
||
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)
|
||
=> new SelectedObjectController(
|
||
layout, selection,
|
||
subscribeHealthChanged, unsubscribeHealthChanged,
|
||
subscribeItemManaChanged, unsubscribeItemManaChanged,
|
||
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
|
||
sendQueryHealth, manaPercent, sendQueryItemMana, 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)
|
||
{
|
||
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;
|
||
|
||
// ── 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 (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;
|
||
}
|
||
|
||
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);
|
||
_unsubscribeItemManaChanged(OnItemManaChanged);
|
||
_unsubscribeObjectUpdated(OnObjectUpdated);
|
||
}
|
||
}
|