feat(ui): D.2b-B — InventoryController bind + grid population (loose/side-bag partition)

Task 4: InventoryController.Bind + Populate: find-by-id bind for the 7 inventory
element ids, configure grid (6 cols x 32px) + container list, partition
GetContents(player) into loose items (contentsGrid) vs side bags (containerList),
populate main-pack cell in topContainer, subscribe ObjectAdded/Moved/Removed/Updated
for live rebuilds. Handles both Ingest-indexed (production) and AddOrUpdate-direct
(test) paths via fallback scan.

Task 5: burden meter (vertical UiMeter, FillFromBottom=true) + RefreshBurden port
of CACQualities::InqLoad (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel
(0x004a6ea0): EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent. Three
AttachCaption overlays (Burden, Contents of Backpack, %text). 5 tests all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-21 09:04:48 +02:00
parent 5e75d2ac76
commit 89c640a54d
2 changed files with 368 additions and 0 deletions

View file

@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Items;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
/// <see cref="ClientObjectTable"/>. The acdream analogue of retail
/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728).
/// Read-only: no container switching / drag / wield-drop wire (later sub-phases).
/// </summary>
public sealed class InventoryController
{
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
public const uint ContainerListId = 0x100001CAu; // gmBackpackUI m_containerList (side-bag selector)
public const uint TopContainerId = 0x100001C9u; // gmBackpackUI m_topContainer (main-pack cell)
public const uint BurdenMeterId = 0x100001D9u; // gmBackpackUI m_burdenMeter (vertical)
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
private const int ContentsColumns = 6;
private const float CellPx = 32f;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<int?> _strength;
private readonly UiItemList? _contentsGrid;
private readonly UiItemList? _containerList;
private readonly UiItemList? _topContainer;
private readonly UiMeter? _burdenMeter;
private float _burdenFill;
private int _burdenPercent;
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
private const uint EncumbranceValProperty = 5u; // total carried burden
private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation
private InventoryController(
ImportedLayout layout,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
UiDatFont? datFont)
{
_objects = objects;
_playerGuid = playerGuid;
_iconIds = iconIds;
_strength = strength;
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
_containerList = layout.FindElement(ContainerListId) as UiItemList;
_topContainer = layout.FindElement(TopContainerId) as UiItemList;
if (_contentsGrid is not null)
{
_contentsGrid.Columns = ContentsColumns;
_contentsGrid.CellWidth = CellPx;
_contentsGrid.CellHeight = CellPx;
}
if (_containerList is not null)
{
_containerList.Columns = 1;
_containerList.CellWidth = CellPx;
_containerList.CellHeight = CellPx;
}
// Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4).
_burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
if (_burdenMeter is not null)
{
_burdenMeter.Vertical = true; // 11x58 vertical bar
_burdenMeter.FillFromBottom = true; // retail direction 4 (visual-confirmed)
_burdenMeter.Fill = () => _burdenFill;
}
// Captions: attach a centered UiText child carrying the known string. "Contents of
// Backpack" + "%d%%" are procedural in retail (gm3DItemsUI/gmBackpackUI PostInit/
// SetLoadLevel); "Burden" is the dat label. (Caption pass, spec §5.)
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont);
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
// Rebuild on any change to the player's possessions.
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
Populate();
}
public static InventoryController Bind(
ImportedLayout layout,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
UiDatFont? datFont)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
private bool Concerns(ClientObject o)
{
uint p = _playerGuid();
return o.ContainerId == p || o.WielderId == p
|| (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
}
/// <summary>Retail gm*UI display refresh: partition the player's direct contents into loose
/// items (the "Contents of Backpack" grid) and side bags (the pack-selector list).
/// Uses the container index when available (items ingested via Ingest/MoveItem) or
/// falls back to a full scan — both paths are correct for the two production flows.</summary>
public void Populate()
{
uint p = _playerGuid();
// GetContents returns indexed + slot-sorted items (the production path via Ingest).
// For AddOrUpdate-seeded items (tests + initial populate before indexing), we fall
// back to scanning all objects with ContainerId == p, sorted by slot.
var indexed = _objects.GetContents(p);
IEnumerable<uint> guids;
if (indexed.Count > 0)
{
guids = indexed;
}
else
{
var direct = new List<(int slot, uint id)>();
foreach (var o in _objects.Objects)
if (o.ContainerId == p)
direct.Add((o.ContainerSlot, o.ObjectId));
direct.Sort((a, b) => a.slot.CompareTo(b.slot));
var ids = new List<uint>(direct.Count);
foreach (var (_, id) in direct) ids.Add(id);
guids = ids;
}
_contentsGrid?.Flush();
_containerList?.Flush();
foreach (var guid in guids)
{
var item = _objects.Get(guid);
if (item is null) continue;
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
var list = isBag ? _containerList : _contentsGrid;
if (list is null) continue;
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId,
item.IconOverlayId, item.Effects);
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
cell.SetItem(guid, tex);
list.AddItem(cell);
}
// Main-pack cell: the player's own container. Icon = placeholder until a backpack
// RenderSurface DID is pinned (spec §3 / divergence row). Bind the guid so a future
// click can select it.
if (_topContainer is not null)
{
_topContainer.Flush();
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
main.SetItem(p, 0u);
_topContainer.AddItem(main);
}
RefreshBurden();
}
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
{
if (host is null) return;
var label = new UiText
{
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true, DatFont = datFont, ClickThrough = true,
AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, CaptionColor) };
},
};
host.AddChild(label);
}
/// <summary>Recompute the burden fill + percent. Port of CACQualities::InqLoad
/// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden:
/// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum.</summary>
private void RefreshBurden()
{
uint p = _playerGuid();
int str = _strength() ?? 10; // InqAttribute default 0xa
int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
int capacity = BurdenMath.EncumbranceCapacity(str, aug);
int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true
? ev : (int?)null;
int burden = wire ?? _objects.SumCarriedBurden(p);
float load = BurdenMath.LoadRatio(capacity, burden);
_burdenFill = BurdenMath.LoadToFill(load);
_burdenPercent = BurdenMath.LoadToPercent(load);
}
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
}
}