acdream/src/AcDream.App/UI/Layout/InventoryController.cs
Erik 8c719cd3e9 feat(ui): D.2b empty-slot art — InventoryController applies per-list empty sprites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:05:12 +02:00

308 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
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"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
// ChatWindowController). Both inventory + chat scrollbars inherit this base.
private const uint ScrollTrackSprite = 0x06004C5Fu;
private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap
private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap
private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button)
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
private const int ContentsColumns = 6;
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px)
private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots
private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items")
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,
uint contentsEmptySprite,
uint sideBagEmptySprite,
uint mainPackEmptySprite)
{
_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 = ContentsCellPx;
_contentsGrid.CellHeight = ContentsCellPx;
}
// Bind the gutter scrollbar to the contents grid's scroll model (the factory built
// 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does).
if (_contentsGrid is not null
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
{
bar.Model = _contentsGrid.Scroll;
bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor
bar.TrackSprite = ScrollTrackSprite;
bar.ThumbSprite = ScrollThumbSprite;
bar.ThumbTopSprite = ScrollThumbTop;
bar.ThumbBotSprite = ScrollThumbBot;
bar.UpSprite = ScrollUpSprite;
bar.DownSprite = ScrollDownSprite;
}
if (_containerList is not null)
{
_containerList.Columns = 1;
_containerList.CellWidth = BackpackCellPx;
_containerList.CellHeight = BackpackCellPx;
}
// Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e
// -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default.
if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite;
if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite;
if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite;
// 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: drive each host UiText directly with the known string (the caption
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (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,
uint contentsEmptySprite = 0u,
uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite);
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.ObjectId == p // B-Wire: the player object IS the burden source
|| 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). The
/// container index (<see cref="ClientObjectTable.GetContents"/>) IS retail's per-container
/// item list — items enter it via the CreateObject (Ingest) / server-move (MoveItem) flows
/// that call Reindex, slot-sorted.</summary>
public void Populate()
{
uint p = _playerGuid();
var contents = _objects.GetContents(p);
_contentsGrid?.Flush();
_containerList?.Flush();
foreach (var guid in contents)
{
var item = _objects.Get(guid);
if (item is null) continue;
// Equipped items belong on the paperdoll (Sub-phase C), never in the pack grid or
// the side-bag list. A mid-session self-wield routes an item through
// MoveItem(item, WielderGuid=player), so it transiently lands in GetContents(player);
// retail's gm3DItemsUI shows pack contents only, gated on the wielded location.
if (item.CurrentlyEquippedLocation != EquipMask.None) 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);
}
// Contents grid: show the FULL main-pack capacity (default 102) — occupied slots + empty
// frames — so the pack reads like retail (a fixed 102-slot grid you scroll), not just the
// loose items. The grid shows the SELECTED pack (default = the main pack / player); side-pack
// contents are a container-switch (later). Capacity = the player's ItemsCapacity, else 102.
if (_contentsGrid is not null)
{
int cap = _objects.Get(p)?.ItemsCapacity ?? 0;
int slots = cap > 0 ? cap : MainPackSlots;
while (_contentsGrid.GetNumUIItems() < slots)
_contentsGrid.AddItem(new UiItemSlot { SpriteResolve = _contentsGrid.SpriteResolve });
}
// Side-bag column: pad with empty slot frames up to the player's container capacity
// (clamped to the 7-slot column) so it reads like retail (bags on top, empty frames
// below) rather than one lone cell. Falls back to the full 7 slots when capacity is
// unknown (divergence AP-52).
if (_containerList is not null)
{
int capacity = _objects.Get(p)?.ContainersCapacity ?? 0;
int bags = _containerList.GetNumUIItems();
int slots = capacity > 0 ? capacity : SideBagSlots;
slots = Math.Max(slots, bags);
slots = Math.Min(slots, SideBagSlots);
while (_containerList.GetNumUIItems() < slots)
_containerList.AddItem(new UiItemSlot { SpriteResolve = _containerList.SpriteResolve });
}
// 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;
// The caption elements (0x100001D7 "Burden", 0x100001C5 "Contents of Backpack",
// 0x100001D8 "%") resolve to UiText (Type-0 inheriting a text base — confirmed live).
// Drive the host UiText DIRECTLY: it is already in the paint tree and renders, whereas
// a nested child UiText did not paint. Set it to a static centered single-line label.
if (host is UiText t)
{
t.Centered = true;
t.DatFont = datFont;
t.ClickThrough = true;
t.AcceptsFocus = false;
t.IsEditControl = false;
t.CapturesPointerDrag = false;
t.LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, CaptionColor) };
};
return;
}
// Fallback (non-UiText host): attach a centered label child.
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;
}
}