308 lines
15 KiB
C#
308 lines
15 KiB
C#
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;
|
||
}
|
||
}
|