Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks. Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state. Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration. Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates. Co-Authored-By: Codex <noreply@openai.com>
722 lines
31 KiB
C#
722 lines
31 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.Combat;
|
||
using AcDream.Core.Items;
|
||
using AcDream.Core.Net.Messages;
|
||
|
||
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 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,
|
||
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);
|
||
_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);
|
||
// 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;
|
||
|
||
// 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();
|
||
}
|
||
|
||
private void OnRepositoryObjectChanged(ClientObject obj)
|
||
{
|
||
if (IsAmmoRelated(obj))
|
||
RefreshAmmo();
|
||
if (IsShortcutGuid(obj.ObjectId))
|
||
Populate();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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,
|
||
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, 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 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;
|
||
_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;
|
||
_repo.ObjectAdded -= OnRepositoryObjectChanged;
|
||
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
|
||
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
|
||
_repo.ObjectMoved -= OnRepositoryObjectMoved;
|
||
_repo.Cleared -= OnRepositoryCleared;
|
||
}
|
||
}
|