Port UIElement_ListBox's press-time selection ordering through the shared retained item-list contract. Inventory, loot, paperdoll, and physical shortcuts now update canonical selection before release or drag promotion, while target-mode consumption suppresses drag and release-time activation. Co-authored-by: Codex <codex@openai.com>
804 lines
34 KiB
C#
804 lines
34 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.Combat;
|
||
using AcDream.Core.Items;
|
||
using AcDream.Core.Net.Messages;
|
||
using AcDream.Core.Selection;
|
||
|
||
namespace AcDream.App.UI.Layout;
|
||
|
||
/// <summary>
|
||
/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data —
|
||
/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id,
|
||
/// populates them from the persisted PlayerDescription shortcuts
|
||
/// (UpdateFromPlayerDesc), re-binds deferred slots when an item's CreateObject
|
||
/// arrives (SetDelayedShortcutNum), and on click uses the bound item
|
||
/// (UseShortcut -> ItemHolder::UseObject -> use-item callback).
|
||
///
|
||
/// <para>
|
||
/// Retail reference: <c>gmToolbarUI::PostInit</c> grabs each slot widget by its
|
||
/// id, calls <c>UpdateFromPlayerDesc</c> to flush-and-bind shortcuts from the
|
||
/// PlayerDescription trailer, and hooks <c>OnEvent</c> for the Click case to fire
|
||
/// <c>UseShortcut</c>. The deferred-rebind path matches
|
||
/// <c>gmToolbarUI::SetDelayedShortcutNum</c> which re-tries binding after
|
||
/// <c>CreateObject</c> resolves a formerly-unknown guid.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelController
|
||
{
|
||
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
|
||
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
|
||
private static readonly uint[] SlotIds =
|
||
{
|
||
0x100001A7, 0x100001A8, 0x100001A9, 0x100001AA, 0x100001AB,
|
||
0x100001AC, 0x100001AD, 0x100001AE, 0x100001AF,
|
||
0x100006B7, 0x100006B8, 0x100006B9, 0x100006BA, 0x100006BB,
|
||
0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF,
|
||
};
|
||
|
||
// SelectedObjectController owns the health/mana meters and both stack controls,
|
||
// including retail's initial-hidden state and selection-driven visibility.
|
||
|
||
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
|
||
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
|
||
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669)
|
||
// SetVisible's exactly one element depending on the incoming mode.
|
||
private static readonly uint[] CombatIndicatorIds =
|
||
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||
|
||
private const uint PanelIdAttribute = 0x10000029u;
|
||
private const uint UseButtonId = 0x1000019Du;
|
||
private const uint ExamineButtonId = 0x100001A5u;
|
||
private const uint AmmoIndicatorId = 0x10000194u;
|
||
private const uint InventoryButtonId = 0x100001B1u;
|
||
private static readonly uint[] PanelButtonIds =
|
||
{ 0x10000197u, 0x10000198u, 0x10000199u, 0x1000055Au, 0x1000019Au, 0x1000019Bu, InventoryButtonId };
|
||
|
||
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
||
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
||
private readonly UiButton? _inventoryButton;
|
||
private readonly UiButton? _useButton;
|
||
private readonly UiButton? _examineButton;
|
||
private readonly UiButton? _ammoIndicator;
|
||
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
|
||
private readonly ClientObjectTable _repo;
|
||
private readonly CombatState? _combatState;
|
||
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts;
|
||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
|
||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
||
private readonly ShortcutStore _store = new();
|
||
private bool _storeLoaded;
|
||
private readonly Action<ShortcutEntry>? _sendAddShortcut;
|
||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||
private readonly ItemInteractionController? _itemInteraction;
|
||
private readonly Action<uint>? _selectItem;
|
||
private readonly Func<uint> _selectedObjectId;
|
||
private readonly SelectionState? _selection;
|
||
private readonly Func<uint>? _playerGuid;
|
||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||
private uint _ammoObjectId;
|
||
private bool _disposed;
|
||
|
||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
||
// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
|
||
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps ghosting.
|
||
// Occupancy branch (decomp 229481):
|
||
// occupied → regular 0x10000042 / ghosted 0x10000043
|
||
// empty → background digit 0x1000005e (stance-independent)
|
||
private uint[]? _regularDigits;
|
||
private uint[]? _ghostedDigits;
|
||
private uint[]? _emptyDigits;
|
||
private bool _shortcutsGhosted;
|
||
|
||
private ToolbarController(
|
||
ImportedLayout layout,
|
||
ClientObjectTable repo,
|
||
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
|
||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||
Action<uint> useItem,
|
||
CombatState? combatState,
|
||
uint[]? regularDigits,
|
||
uint[]? ghostedDigits,
|
||
uint[]? emptyDigits,
|
||
ItemInteractionController? itemInteraction = null,
|
||
Action<ShortcutEntry>? sendAddShortcut = null,
|
||
Action<uint>? sendRemoveShortcut = null,
|
||
Action? toggleCombat = null,
|
||
Action<uint>? selectItem = null,
|
||
Func<uint>? selectedObjectId = null,
|
||
SelectionState? selection = null,
|
||
Func<uint>? playerGuid = null,
|
||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||
UiDatFont? ammoFont = null,
|
||
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null)
|
||
{
|
||
_repo = repo;
|
||
_combatState = combatState;
|
||
_shortcuts = shortcuts;
|
||
_iconIds = iconIds;
|
||
_dragIconIds = dragIconIds;
|
||
_useItem = useItem;
|
||
_regularDigits = regularDigits;
|
||
_ghostedDigits = ghostedDigits;
|
||
_emptyDigits = emptyDigits;
|
||
_itemInteraction = itemInteraction;
|
||
_sendAddShortcut = sendAddShortcut;
|
||
_sendRemoveShortcut = sendRemoveShortcut;
|
||
_selectItem = selectItem;
|
||
_selectedObjectId = selectedObjectId ?? (() => 0u);
|
||
_selection = selection;
|
||
_playerGuid = playerGuid;
|
||
_sendPutItemInContainer = sendPutItemInContainer;
|
||
|
||
for (int i = 0; i < SlotIds.Length; i++)
|
||
{
|
||
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
|
||
if (_slots[i] is { } list)
|
||
{
|
||
WireClick(list);
|
||
list.PrimaryItemPressed = PressItem;
|
||
list.ExamineItemRequested = ExamineItem;
|
||
// B.1 drag-drop spine: this controller is the drop handler for every
|
||
// toolbar slot list; each cell knows its slot index + that it's a
|
||
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
|
||
list.RegisterDragHandler(this);
|
||
list.Cell.SlotIndex = i;
|
||
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
|
||
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
|
||
}
|
||
}
|
||
|
||
// Cache the four mutually-exclusive combat-mode indicator elements.
|
||
// Retail gmToolbarUI::ListenToElementMessage @ 0x004BEE90 handles
|
||
// Click (message 1) from ANY id 0x10000192..0x10000195 by calling
|
||
// ClientCombatSystem::ToggleCombatMode. Exactly one is visible, but all
|
||
// four retain the same command binding as stance changes swap the art.
|
||
for (int i = 0; i < CombatIndicatorIds.Length; i++)
|
||
{
|
||
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
|
||
if (_combatIndicators[i] is UiButton button)
|
||
button.OnClick = toggleCombat;
|
||
}
|
||
|
||
_ammoIndicator = layout.FindElement(AmmoIndicatorId) as UiButton;
|
||
if (_ammoIndicator is not null)
|
||
{
|
||
_ammoIndicator.LabelFont = ammoFont;
|
||
_ammoIndicator.LabelColor = System.Numerics.Vector4.One;
|
||
}
|
||
|
||
foreach (uint elementId in PanelButtonIds)
|
||
{
|
||
if (layout.FindElement(elementId) is UiButton panelButton
|
||
&& panelButton.TryGetEnumAttribute(PanelIdAttribute, out uint panelId))
|
||
_panelButtons.Add((panelId, panelButton));
|
||
}
|
||
|
||
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
|
||
if (_inventoryButton is not null)
|
||
{
|
||
// gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the
|
||
// authored child overlay's Accept state (0x10000046 = 0x060011F7).
|
||
// UiButton owns the retained drop-target seam; the panel owns policy.
|
||
_inventoryButton.ItemDragAcceptSprite = 0x060011F7u;
|
||
_inventoryButton.OnItemDragOver = InventoryButtonDragOver;
|
||
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
|
||
}
|
||
|
||
_useButton = layout.FindElement(UseButtonId) as UiButton;
|
||
_examineButton = layout.FindElement(ExamineButtonId) as UiButton;
|
||
if (_useButton is not null)
|
||
_useButton.OnClick = () => _itemInteraction?.UseSelectedOrEnterMode(_selectedObjectId());
|
||
if (_examineButton is not null)
|
||
_examineButton.OnClick = () => _itemInteraction?.ExamineSelectedOrEnterMode(_selectedObjectId());
|
||
|
||
// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669):
|
||
// exactly one indicator visible at a time. Default to NonCombat (peace) — the player
|
||
// always spawns in peace mode; retail has not yet called SetVisible when PostInit runs.
|
||
SetCombatMode(CombatMode.NonCombat);
|
||
|
||
// Wire live combat-mode changes if a CombatState was provided.
|
||
if (_combatState is not null)
|
||
_combatState.CombatModeChanged += SetCombatMode;
|
||
if (_selection is not null)
|
||
_selection.Changed += OnSelectionChanged;
|
||
|
||
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
|
||
// to our 18 shortcut guids — else every creature spawn in a busy zone
|
||
// needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern).
|
||
repo.ObjectAdded += OnRepositoryObjectChanged;
|
||
repo.ObjectUpdated += OnRepositoryObjectChanged;
|
||
repo.ObjectRemoved += OnRepositoryObjectChanged;
|
||
repo.ObjectMoved += OnRepositoryObjectMoved;
|
||
repo.Cleared += OnRepositoryCleared;
|
||
RefreshAmmo();
|
||
RefreshUseButton();
|
||
}
|
||
|
||
private void OnRepositoryObjectChanged(ClientObject obj)
|
||
{
|
||
if (IsAmmoRelated(obj))
|
||
RefreshAmmo();
|
||
if (IsShortcutGuid(obj.ObjectId))
|
||
Populate();
|
||
if (obj.ObjectId == _selectedObjectId())
|
||
RefreshUseButton();
|
||
}
|
||
|
||
private void OnRepositoryObjectMoved(ClientObjectMove move)
|
||
{
|
||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||
if (move.ItemId == _ammoObjectId
|
||
|| (player != 0
|
||
&& (move.Previous.ContainerId == player
|
||
|| move.Current.ContainerId == player
|
||
|| move.Previous.WielderId == player
|
||
|| move.Current.WielderId == player)))
|
||
RefreshAmmo();
|
||
if (IsShortcutGuid(move.ItemId))
|
||
Populate();
|
||
}
|
||
|
||
private void OnRepositoryCleared()
|
||
{
|
||
RefreshAmmo();
|
||
Populate();
|
||
RefreshUseButton();
|
||
}
|
||
|
||
private void OnSelectionChanged(SelectionTransition _)
|
||
=> RefreshUseButton();
|
||
|
||
private void RefreshUseButton()
|
||
{
|
||
if (_useButton is null)
|
||
return;
|
||
|
||
// Retail normally leaves state 1 active when selectedID == 0 so the
|
||
// click can enter generic TARGET_MODE_USE. acdream intentionally
|
||
// ghosts that empty state per the connected UX requirement; every
|
||
// selected-object classification below is the exact retail predicate.
|
||
_useButton.Enabled =
|
||
_itemInteraction?.IsToolbarUseEnabled(_selectedObjectId()) == true;
|
||
}
|
||
|
||
private bool IsAmmoRelated(ClientObject obj)
|
||
{
|
||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||
return obj.ObjectId == _ammoObjectId
|
||
|| (player != 0 && obj.ObjectId == player)
|
||
|| (player != 0
|
||
&& (obj.WielderId == player || obj.ContainerId == player)
|
||
&& (obj.CurrentlyEquippedLocation
|
||
& (EquipMask.MissileWeapon | EquipMask.MissileAmmo)) != 0);
|
||
}
|
||
|
||
private void RefreshAmmo()
|
||
{
|
||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||
IReadOnlyList<ClientObject> placements = player != 0
|
||
? _repo.GetEquippedBy(player)
|
||
: Array.Empty<ClientObject>();
|
||
|
||
ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(placements);
|
||
_ammoObjectId = result.ObjectId;
|
||
if (_ammoIndicator is not null)
|
||
{
|
||
_ammoIndicator.Label = result.IsVisible
|
||
? result.DisplayCount.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||
: null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
|
||
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
|
||
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
|
||
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
|
||
/// </summary>
|
||
private bool IsShortcutGuid(uint guid)
|
||
{
|
||
if (_storeLoaded)
|
||
{
|
||
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
|
||
if (_store.Get(s) == guid) return true;
|
||
return false;
|
||
}
|
||
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
|
||
foreach (var sc in _shortcuts())
|
||
if (sc.ObjectId == guid) return true;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Create and bind a <see cref="ToolbarController"/> to <paramref name="layout"/>.
|
||
/// Calls <see cref="Populate"/> immediately (binds whatever items are in the repo now).
|
||
/// Returns the controller so the caller can call <see cref="Populate"/> again
|
||
/// if the shortcut list is refreshed outside the repo-event path.
|
||
/// </summary>
|
||
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
|
||
/// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param>
|
||
/// <param name="shortcuts">Provider for the current shortcut bar list.</param>
|
||
/// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle.</param>
|
||
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
|
||
/// <param name="combatState">
|
||
/// Optional live combat state — when provided, the toolbar subscribes to
|
||
/// <see cref="CombatState.CombatModeChanged"/> and updates the four mutually-exclusive
|
||
/// combat-mode indicator elements accordingly.
|
||
/// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator).
|
||
/// </param>
|
||
/// <param name="regularDigits">
|
||
/// Regular digit DID array (property 0x10000042 from LayoutDesc 0x21000037 element
|
||
/// 0x1000034A under composite 0x10000346). Index i → slot label digit (i+1) RenderSurface id.
|
||
/// Null if the dat lookup failed (no digits drawn). Retail reference:
|
||
/// UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||
/// </param>
|
||
/// <param name="ghostedDigits">Ghosted digit DID array (property 0x10000043, same element).</param>
|
||
/// <param name="emptyDigits">
|
||
/// Empty-slot background digit DID array (property 0x1000005e, stance-independent).
|
||
/// Used when a slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
|
||
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty state).
|
||
/// Null if the dat lookup failed (empty slots draw no digit, which is safe).
|
||
/// </param>
|
||
public static ToolbarController Bind(
|
||
ImportedLayout layout,
|
||
ClientObjectTable repo,
|
||
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
|
||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||
Action<uint> useItem,
|
||
CombatState? combatState = null,
|
||
uint[]? regularDigits = null,
|
||
uint[]? ghostedDigits = null,
|
||
uint[]? emptyDigits = null,
|
||
ItemInteractionController? itemInteraction = null,
|
||
Action<ShortcutEntry>? sendAddShortcut = null,
|
||
Action<uint>? sendRemoveShortcut = null,
|
||
Action? toggleCombat = null,
|
||
Action<uint>? selectItem = null,
|
||
Func<uint>? selectedObjectId = null,
|
||
SelectionState? selection = null,
|
||
Func<uint>? playerGuid = null,
|
||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||
UiDatFont? ammoFont = null,
|
||
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null)
|
||
{
|
||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||
regularDigits, ghostedDigits, emptyDigits, itemInteraction,
|
||
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
|
||
selectedObjectId, selection, playerGuid, sendPutItemInContainer, ammoFont,
|
||
dragIconIds);
|
||
c.Populate();
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Binds all seven authored panel buttons by DAT panel id. Buttons whose panel
|
||
/// is not registered are ghosted, matching retail unavailable-panel behavior.
|
||
/// </summary>
|
||
public void BindPanelButtons(Func<uint, bool> isAvailable, Action<uint> togglePanel)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(isAvailable);
|
||
ArgumentNullException.ThrowIfNull(togglePanel);
|
||
|
||
foreach (var (panelId, button) in _panelButtons)
|
||
{
|
||
bool available = isAvailable(panelId);
|
||
button.Enabled = available;
|
||
button.OnClick = !available ? null : () =>
|
||
{
|
||
if (ReferenceEquals(button, _inventoryButton)
|
||
&& _itemInteraction?.OfferSelfPrimaryClick()
|
||
is not null and not ItemPrimaryClickResult.NotActive)
|
||
return;
|
||
togglePanel(panelId);
|
||
};
|
||
}
|
||
}
|
||
|
||
public void SetPanelOpen(uint panelId, bool open)
|
||
{
|
||
// gmToolbarUI::RecvNotice_SetPanelVisibility @ 0x004BD300 maps visible
|
||
// to state 6 (Highlight) and hidden to state 1 (Normal).
|
||
foreach (var entry in _panelButtons)
|
||
{
|
||
if (entry.PanelId != panelId || !entry.Button.Enabled) continue;
|
||
entry.Button.TrySetRetailState(open
|
||
? UiButtonStateMachine.Highlight
|
||
: UiButtonStateMachine.Normal);
|
||
return;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
|
||
/// each shortcut entry that has a resolved item in the repository.
|
||
/// Entries whose item is not yet in the repo are silently skipped here; the
|
||
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
|
||
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
|
||
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the
|
||
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
|
||
/// </summary>
|
||
public void Populate()
|
||
{
|
||
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
|
||
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
|
||
if (!_storeLoaded && _shortcuts().Count > 0)
|
||
{
|
||
_store.Load(_shortcuts());
|
||
_storeLoaded = true;
|
||
}
|
||
|
||
foreach (var list in _slots) list?.Cell.Clear();
|
||
|
||
for (int slot = 0; slot < _slots.Length; slot++)
|
||
{
|
||
ShortcutEntry? entry = _store.GetEntry(slot);
|
||
uint guid = entry?.ObjectId ?? 0u;
|
||
if (guid == 0) continue;
|
||
var list = _slots[slot];
|
||
if (list is null) continue;
|
||
var item = _repo.Get(guid);
|
||
if (item is null) continue; // deferred: ObjectAdded re-calls Populate
|
||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||
uint dragTex = _dragIconIds?.Invoke(
|
||
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
|
||
list.Cell.SetItem(guid, tex, entry, dragTex);
|
||
}
|
||
|
||
// Re-stamp slot number labels after any item change.
|
||
// Digit SPRITE SOURCE depends on occupancy (decomp UIElement_UIItem::SetShortcutNum:229481):
|
||
// occupied → regular 0x10000042 / ghosted 0x10000043; empty → background 0x1000005e.
|
||
// The digit is ALWAYS shown on top-row slots (SetVisible(1) at decomp 229511).
|
||
RestampShortcutNumbers();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Port of <c>gmToolbarUI::RecvNotice_SetCombatMode</c>
|
||
/// (acclient_2013_pseudo_c.txt:196632-196669): show exactly one of the four
|
||
/// mutually-exclusive combat-mode indicator elements and hide the other three.
|
||
/// Called at bind-time with <see cref="CombatMode.NonCombat"/> (the player
|
||
/// always starts in peace mode) and subsequently whenever
|
||
/// <see cref="CombatState.CombatModeChanged"/> fires.
|
||
/// </summary>
|
||
public void SetCombatMode(CombatMode mode)
|
||
{
|
||
// Index → mode mapping matches CombatIndicatorIds declaration order:
|
||
// 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
|
||
bool[] show =
|
||
{
|
||
mode == CombatMode.NonCombat,
|
||
mode == CombatMode.Melee,
|
||
mode == CombatMode.Missile,
|
||
mode == CombatMode.Magic,
|
||
};
|
||
|
||
for (int i = 0; i < _combatIndicators.Length; i++)
|
||
{
|
||
if (_combatIndicators[i] is { } e)
|
||
e.Visible = show[i];
|
||
}
|
||
|
||
// The bool passed to SetShortcutNum is m_bShortcutGhosted, not a peace/war
|
||
// selector. Retail ghosts physical-item shortcuts only in Magic mode.
|
||
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610.
|
||
_shortcutsGhosted = mode == CombatMode.Magic;
|
||
RestampShortcutNumbers();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Push digit-array references and shortcut-number state into every slot cell.
|
||
/// Top row (indices 0–8): SetShortcutNum(i, _shortcutsGhosted) — numbers 1–9 always shown
|
||
/// (the digit is ALWAYS visible, SetVisible(1) at decomp 229511; only the sprite
|
||
/// SOURCE differs by occupancy — see UIElement_UIItem::SetShortcutNum decomp 229481).
|
||
/// Bottom row (indices 9–17): ClearShortcutNum() — retail shows no numbers there.
|
||
/// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
|
||
/// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621).
|
||
/// Occupancy → source: occupied → regular 0x10000042 / ghosted 0x10000043;
|
||
/// empty → background 0x1000005e (decomp 229481/229493).
|
||
/// </summary>
|
||
private void RestampShortcutNumbers()
|
||
{
|
||
for (int i = 0; i < _slots.Length; i++)
|
||
{
|
||
var cell = _slots[i]?.Cell;
|
||
if (cell is null) continue;
|
||
cell.RegularDigits = _regularDigits;
|
||
cell.GhostedDigits = _ghostedDigits;
|
||
cell.EmptyDigits = _emptyDigits;
|
||
if (i < 9)
|
||
cell.SetShortcutNum(i, _shortcutsGhosted); // top row: slot labels 1–9 always shown
|
||
else
|
||
cell.ClearShortcutNum(); // bottom row: no slot labels
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Wire the <see cref="UiItemSlot.Clicked"/> callback on a slot cell so that
|
||
/// clicking a bound item fires <see cref="_useItem"/> with the slot's current guid.
|
||
/// Mirrors retail's <c>gmToolbarUI</c> click → <c>UseShortcut</c> dispatch.
|
||
/// </summary>
|
||
private void WireClick(UiItemList list)
|
||
{
|
||
list.Cell.Clicked = () =>
|
||
{
|
||
if (list.Cell.ItemId != 0)
|
||
{
|
||
if (_itemInteraction is not null)
|
||
_itemInteraction.ActivateItem(list.Cell.ItemId);
|
||
else
|
||
_useItem(list.Cell.ItemId);
|
||
}
|
||
};
|
||
}
|
||
|
||
private bool PressItem(uint itemId)
|
||
{
|
||
if (_itemInteraction?.OfferPrimaryClick(itemId)
|
||
is not null and not ItemPrimaryClickResult.NotActive)
|
||
return true;
|
||
|
||
if (_selectItem is not null)
|
||
_selectItem(itemId);
|
||
else
|
||
_selection?.Select(itemId, SelectionChangeSource.Toolbar);
|
||
return false;
|
||
}
|
||
|
||
private void ExamineItem(uint itemId)
|
||
{
|
||
if (_selectItem is not null)
|
||
_selectItem(itemId);
|
||
else
|
||
_selection?.Select(itemId, SelectionChangeSource.Toolbar);
|
||
_itemInteraction?.ExamineSelectedOrEnterMode(itemId);
|
||
}
|
||
|
||
private static ItemDragAcceptance InventoryButtonDragOver(ItemDragPayload payload)
|
||
=> payload.ObjId != 0 && payload.SourceKind != ItemDragSource.ShortcutBar
|
||
? ItemDragAcceptance.Accept
|
||
: ItemDragAcceptance.None;
|
||
|
||
private void HandleInventoryButtonDrop(ItemDragPayload payload)
|
||
{
|
||
// gmToolbarUI::HandleDropRelease @ 0x004BE7C0 inventory-button branch:
|
||
// only fresh physical items are placed into the player's main inventory.
|
||
// A shortcut alias remains neutral and its remove-on-lift result stands.
|
||
if (payload.ObjId == 0 || payload.SourceKind == ItemDragSource.ShortcutBar)
|
||
return;
|
||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||
if (player == 0 || _repo.Get(payload.ObjId) is null)
|
||
return;
|
||
if (_itemInteraction is not null)
|
||
{
|
||
Func<bool> dispatch = () =>
|
||
{
|
||
if (_sendPutItemInContainer is null)
|
||
return false;
|
||
_sendPutItemInContainer(payload.ObjId, player, 0);
|
||
return true;
|
||
};
|
||
if (_itemInteraction.IsOwnedByPlayer(payload.ObjId))
|
||
_itemInteraction.TryDispatchPendingBackpackPlacement(
|
||
payload.ObjId,
|
||
player,
|
||
0,
|
||
InventoryRequestKind.PutInContainer,
|
||
dispatch);
|
||
else
|
||
_itemInteraction.TryDispatchInventoryRequest(
|
||
InventoryRequestKind.Pickup,
|
||
payload.ObjId,
|
||
dispatch);
|
||
return;
|
||
}
|
||
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Port of <c>gmToolbarUI::UseShortcut @ 0x004BD350</c>. Target mode
|
||
/// is one-shot and precedes both normal use and selection.
|
||
/// </summary>
|
||
public bool UseShortcut(int slot, bool use)
|
||
{
|
||
if ((uint)slot >= _slots.Length || _slots[slot]?.Cell is not { } cell)
|
||
return false;
|
||
|
||
uint itemId = cell.ItemId;
|
||
if (_itemInteraction?.IsTargetModeActive == true)
|
||
{
|
||
if (itemId != 0)
|
||
_itemInteraction.OfferPrimaryClick(itemId);
|
||
else
|
||
_itemInteraction.CancelTargetMode();
|
||
return true;
|
||
}
|
||
|
||
if (itemId == 0)
|
||
return false;
|
||
|
||
if (use)
|
||
{
|
||
if (_itemInteraction is not null)
|
||
_itemInteraction.ActivateItem(itemId);
|
||
else
|
||
_useItem(itemId);
|
||
}
|
||
else
|
||
{
|
||
_selectItem?.Invoke(itemId);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Auto-slot form of retail <c>gmToolbarUI::CreateShortcutToItem @
|
||
/// 0x004BDAC0</c>, used by the global CreateShortcut action. It rejects
|
||
/// stuck/non-player objects, non-player creatures, unowned objects,
|
||
/// duplicates, and a full bar before emitting AddShortcut.
|
||
/// </summary>
|
||
public bool CreateShortcutToItem(uint itemId)
|
||
{
|
||
if (itemId == 0 || _repo.Get(itemId) is not { } item)
|
||
return false;
|
||
|
||
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
|
||
bool isPlayer = itemId == _itemInteraction?.PlayerGuid
|
||
|| (flags & PublicWeenieFlags.Player) != 0;
|
||
if ((flags & PublicWeenieFlags.Stuck) != 0 && !isPlayer)
|
||
return false;
|
||
if ((item.Type & ItemType.Creature) != 0 && !isPlayer)
|
||
return false;
|
||
if (_itemInteraction?.IsOwnedByPlayer(itemId) != true)
|
||
return false;
|
||
|
||
EnsureStoreLoadedForMutation();
|
||
for (int slot = 0; slot < _slots.Length; slot++)
|
||
if (_store.Get(slot) == itemId)
|
||
return false;
|
||
|
||
int empty = -1;
|
||
for (int slot = 0; slot < _slots.Length; slot++)
|
||
{
|
||
if (_store.IsEmpty(slot))
|
||
{
|
||
empty = slot;
|
||
break;
|
||
}
|
||
}
|
||
if (empty < 0)
|
||
return false;
|
||
|
||
var entry = new ShortcutEntry(empty, itemId, 0u);
|
||
_store.Set(entry);
|
||
_sendAddShortcut?.Invoke(entry);
|
||
Populate();
|
||
return true;
|
||
}
|
||
|
||
private void EnsureStoreLoadedForMutation()
|
||
{
|
||
if (_storeLoaded)
|
||
return;
|
||
_store.Load(_shortcuts());
|
||
_storeLoaded = true;
|
||
}
|
||
|
||
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
|
||
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
|
||
// Retail model (remove-on-lift / place-on-drop / no-restore):
|
||
// lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately)
|
||
// drop → AddShortcut (0x019C) + optional swap of evicted item into source
|
||
// off-bar release → the lift's removal stands (no restore)
|
||
// Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971
|
||
|
||
/// <inheritdoc/>
|
||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||
{
|
||
// UIElement_ItemList::ItemList_BeginDrag @ 0x004E32D0 selects first; the toolbar's
|
||
// RecvNotice_ItemListBeginDrag handler then removes the shortcut alias.
|
||
if (payload.ObjId != 0 && _selectedObjectId() != payload.ObjId)
|
||
_selectItem?.Invoke(payload.ObjId);
|
||
|
||
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
|
||
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
|
||
// drop onto a slot. Off-bar release leaves it removed.
|
||
_store.Remove(payload.SourceSlot);
|
||
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
|
||
Populate();
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||
=> payload.ObjId != 0 ? ItemDragAcceptance.Accept : ItemDragAcceptance.None;
|
||
|
||
/// <inheritdoc/>
|
||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||
{
|
||
EnsureStoreLoadedForMutation();
|
||
ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar
|
||
? ShortcutDropSource.ShortcutAlias
|
||
: ShortcutDropSource.FreshItem;
|
||
ShortcutEntry dragged = payload.Shortcut
|
||
?? new ShortcutEntry(payload.SourceSlot, payload.ObjId, 0u);
|
||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
|
||
_store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged);
|
||
|
||
ApplyShortcutPlan(plan);
|
||
Populate();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Toolbar notice port for <c>gmToolbarUI::RecvNotice_FullMergingItem @ 0x004BE9B0</c>.
|
||
/// The owning inventory pipeline calls this when ACE confirms a source stack was fully merged.
|
||
/// </summary>
|
||
public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId)
|
||
{
|
||
EnsureStoreLoadedForMutation();
|
||
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
|
||
_store.Snapshot(), oldObjectId, newObjectId);
|
||
ApplyShortcutPlan(plan);
|
||
if (plan.Length > 0)
|
||
Populate();
|
||
}
|
||
|
||
private void ApplyShortcutPlan(IReadOnlyList<ShortcutMutation> plan)
|
||
{
|
||
// Apply the already-validated transaction locally first, then reproduce the
|
||
// planner's exact Remove/Add wire order. Shortcut events have no rejection reply.
|
||
foreach (var mutation in plan)
|
||
{
|
||
if (mutation.Kind == ShortcutMutationKind.Remove)
|
||
_store.Remove(mutation.Slot);
|
||
else if (mutation.Entry is { } entry)
|
||
_store.Set(entry);
|
||
}
|
||
|
||
foreach (var mutation in plan)
|
||
{
|
||
if (mutation.Kind == ShortcutMutationKind.Remove)
|
||
_sendRemoveShortcut?.Invoke((uint)mutation.Slot);
|
||
else if (mutation.Entry is { } entry)
|
||
_sendAddShortcut?.Invoke(entry);
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
|
||
foreach (var (_, button) in _panelButtons)
|
||
button.OnClick = null;
|
||
foreach (var indicator in _combatIndicators)
|
||
if (indicator is UiButton button)
|
||
button.OnClick = null;
|
||
if (_useButton is not null)
|
||
_useButton.OnClick = null;
|
||
if (_examineButton is not null)
|
||
_examineButton.OnClick = null;
|
||
if (_inventoryButton is not null)
|
||
{
|
||
_inventoryButton.OnItemDragOver = null;
|
||
_inventoryButton.OnItemDrop = null;
|
||
}
|
||
|
||
if (_combatState is not null)
|
||
_combatState.CombatModeChanged -= SetCombatMode;
|
||
if (_selection is not null)
|
||
_selection.Changed -= OnSelectionChanged;
|
||
foreach (UiItemList? list in _slots)
|
||
if (list is not null)
|
||
{
|
||
list.PrimaryItemPressed = null;
|
||
list.ExamineItemRequested = null;
|
||
}
|
||
_repo.ObjectAdded -= OnRepositoryObjectChanged;
|
||
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
|
||
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
|
||
_repo.ObjectMoved -= OnRepositoryObjectMoved;
|
||
_repo.Cleared -= OnRepositoryCleared;
|
||
}
|
||
}
|