feat(D.5.1): ToolbarController — bind 18 slots, populate, deferred rebind, click-to-use
Port of gmToolbarUI::PostInit (slot wiring) + UpdateFromPlayerDesc (flush-and-bind shortcuts from PlayerDescription) + SetDelayedShortcutNum (deferred ItemAdded rebind) + UseShortcut (click → useItem callback). UiItemSlot gains Clicked (Action?) + OnEvent override (MouseDown → Clicked?.Invoke()) matching the retail UIElement_UIItem click dispatch pattern. UiEvent is a positional record struct so the OnEvent override reads e.Type (int) against UiEventType.MouseDown (const int 0x201) — confirmed from UiEvent.cs + UiText.cs before writing. Three tests green (populate bound slot, deferred rebind on ItemAdded, click fires useItem). Full suite: 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9327fb64bf
commit
383a969c70
3 changed files with 235 additions and 0 deletions
139
src/AcDream.App/UI/Layout/ToolbarController.cs
Normal file
139
src/AcDream.App/UI/Layout/ToolbarController.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
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
|
||||
{
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Elements hidden by default in retail gmToolbarUI::PostInit: the selected-object
|
||||
// vitals meters (health/stamina/mana bars that track your target) and the stack slider.
|
||||
// Ids confirmed from the toolbar LayoutDesc dump.
|
||||
private static readonly uint[] HiddenIds = { 0x100001A1, 0x100001A2, 0x100001A4 };
|
||||
|
||||
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
||||
private readonly ItemRepository _repo;
|
||||
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<uint, uint, uint, uint> _iconIds; // (iconId, underlayId, overlayId) → GL tex
|
||||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
||||
|
||||
private ToolbarController(
|
||||
ImportedLayout layout,
|
||||
ItemRepository repo,
|
||||
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
|
||||
Func<uint, uint, uint, uint> iconIds,
|
||||
Action<uint> useItem)
|
||||
{
|
||||
_repo = repo;
|
||||
_shortcuts = shortcuts;
|
||||
_iconIds = iconIds;
|
||||
_useItem = useItem;
|
||||
|
||||
for (int i = 0; i < SlotIds.Length; i++)
|
||||
{
|
||||
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
|
||||
if (_slots[i] is { } list)
|
||||
WireClick(list);
|
||||
}
|
||||
|
||||
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
|
||||
foreach (var id in HiddenIds)
|
||||
if (layout.FindElement(id) is { } e) e.Visible = false;
|
||||
|
||||
// Re-bind any deferred slot whenever the repo learns about a new/updated item.
|
||||
repo.ItemAdded += _ => Populate();
|
||||
repo.ItemPropertiesUpdated += _ => Populate();
|
||||
}
|
||||
|
||||
/// <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 (iconId, underlayId, overlayId) → GL texture handle.</param>
|
||||
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
|
||||
public static ToolbarController Bind(
|
||||
ImportedLayout layout,
|
||||
ItemRepository repo,
|
||||
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
|
||||
Func<uint, uint, uint, uint> iconIds,
|
||||
Action<uint> useItem)
|
||||
{
|
||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <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>ItemAdded</c> event re-fires this method when the item arrives
|
||||
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
|
||||
/// </summary>
|
||||
public void Populate()
|
||||
{
|
||||
// Clear all slot cells first (flush).
|
||||
foreach (var list in _slots) list?.Cell.Clear();
|
||||
|
||||
foreach (var sc in _shortcuts())
|
||||
{
|
||||
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
|
||||
if (sc.Index >= (uint)_slots.Length) continue;
|
||||
var list = _slots[(int)sc.Index];
|
||||
if (list is null) continue;
|
||||
|
||||
var item = _repo.GetItem(sc.ObjectGuid);
|
||||
if (item is null) continue; // deferred: ItemAdded will re-call Populate
|
||||
|
||||
uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId);
|
||||
list.Cell.SetItem(sc.ObjectGuid, tex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
_useItem(list.Cell.ItemId);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,17 @@ public sealed class UiItemSlot : UiElement
|
|||
|
||||
public void Clear() { ItemId = 0; IconTexture = 0; }
|
||||
|
||||
/// <summary>Invoked by <see cref="OnEvent"/> when a left-button-down lands on
|
||||
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
|
||||
public Action? Clicked { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (ItemId != 0 && IconTexture != 0)
|
||||
|
|
|
|||
85
tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
Normal file
85
tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class ToolbarControllerTests
|
||||
{
|
||||
private static readonly uint[] Row1 =
|
||||
{ 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF };
|
||||
private static readonly uint[] Row2 =
|
||||
{ 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF };
|
||||
|
||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots) FakeToolbar()
|
||||
{
|
||||
var dict = new Dictionary<uint, UiElement>();
|
||||
var slots = new Dictionary<uint, UiItemList>();
|
||||
var root = new UiPanel();
|
||||
foreach (var id in Row1) AddSlot(id);
|
||||
foreach (var id in Row2) AddSlot(id);
|
||||
return (new ImportedLayout(root, dict), slots);
|
||||
|
||||
void AddSlot(uint id)
|
||||
{
|
||||
var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
|
||||
dict[id] = list; slots[id] = list; root.AddChild(list);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_bindsShortcutToCorrectSlot()
|
||||
{
|
||||
var (layout, slots) = FakeToolbar();
|
||||
var repo = new ItemRepository();
|
||||
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
|
||||
ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_) => 0x77u, useItem: _ => { });
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
|
||||
Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture);
|
||||
Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredRebind_whenItemArrivesLate()
|
||||
{
|
||||
var (layout, slots) = FakeToolbar();
|
||||
var repo = new ItemRepository(); // item NOT present yet
|
||||
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||
|
||||
ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_) => 0x88u, useItem: _ => { });
|
||||
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet
|
||||
|
||||
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u });
|
||||
|
||||
Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_emitsUseForBoundItem()
|
||||
{
|
||||
var (layout, slots) = FakeToolbar();
|
||||
var repo = new ItemRepository();
|
||||
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
uint used = 0;
|
||||
|
||||
ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_) => 0x77u, useItem: g => used = g);
|
||||
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
|
||||
|
||||
Assert.Equal(0x5001u, used);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue