Visual gate: moving an item within a bag reshuffled every item ("the order is
not set"). Root cause: insert-before set ONLY the dragged item's ContainerSlot
to N, colliding with the item already at N; the sort-by-slot tie then reordered
the whole grid on every repaint. Fix: MoveItemOptimistic now does a proper
index-based INSERT (shift the others, renumber 0..N-1 gapless) like retail's
ItemList_InsertItem, and HandleDropRelease uses the target's GRID INDEX
(SlotIndex) as the placement rather than its raw ContainerSlot. Regression test
pins the shifted, gapless order. Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
491 lines
24 KiB
C#
491 lines
24 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.App.UI;
|
||
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).
|
||
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
|
||
/// drag-into-bag / wield-drop wire are later sub-phases.
|
||
/// </summary>
|
||
public sealed class InventoryController : IItemListDragHandler
|
||
{
|
||
// 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 const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll)
|
||
|
||
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);
|
||
|
||
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
||
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square)
|
||
private readonly Action<uint>? _sendUse;
|
||
private readonly Action<uint>? _sendNoLongerViewing;
|
||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||
|
||
// 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,
|
||
Action<uint>? sendUse,
|
||
Action<uint>? sendNoLongerViewing,
|
||
Action<uint, uint, int>? sendPutItemInContainer)
|
||
{
|
||
_objects = objects;
|
||
_playerGuid = playerGuid;
|
||
_iconIds = iconIds;
|
||
_strength = strength;
|
||
_sendUse = sendUse;
|
||
_sendNoLongerViewing = sendNoLongerViewing;
|
||
_sendPutItemInContainer = sendPutItemInContainer;
|
||
|
||
_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;
|
||
|
||
// B-Drag: register this controller as the drag handler on all three lists.
|
||
_contentsGrid?.RegisterDragHandler(this);
|
||
_containerList?.RegisterDragHandler(this);
|
||
_topContainer?.RegisterDragHandler(this);
|
||
|
||
// 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 " + OpenContainerName(), 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,
|
||
Action<uint>? sendUse = null,
|
||
Action<uint>? sendNoLongerViewing = null,
|
||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||
sendUse, sendNoLongerViewing, sendPutItemInContainer);
|
||
|
||
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 // the player object IS the burden source
|
||
|| o.ContainerId == p || o.WielderId == p
|
||
|| o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid
|
||
|| (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
|
||
}
|
||
|
||
public void Populate()
|
||
{
|
||
uint p = _playerGuid();
|
||
uint open = EffectiveOpen();
|
||
|
||
_contentsGrid?.Flush();
|
||
_containerList?.Flush();
|
||
|
||
// Side-bag column: ALWAYS the player's bags (constant across container switches; only the
|
||
// open/selected indicators move). Equipped items never appear here.
|
||
foreach (var guid in _objects.GetContents(p))
|
||
{
|
||
var item = _objects.Get(guid);
|
||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||
}
|
||
|
||
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
|
||
// no sub-bags, so the isBag skip is a no-op when a bag is open.)
|
||
foreach (var guid in _objects.GetContents(open))
|
||
{
|
||
var item = _objects.Get(guid);
|
||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
|
||
}
|
||
|
||
// Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24).
|
||
if (_contentsGrid is not null)
|
||
{
|
||
int cap = _objects.Get(open)?.ItemsCapacity ?? 0;
|
||
int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots);
|
||
while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid);
|
||
}
|
||
|
||
// Pad the side-bag column to capacity, clamped to the 7-slot column (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) AddEmptyCell(_containerList);
|
||
}
|
||
|
||
// Main-pack cell: the player's own container — clicking it opens/selects the main pack.
|
||
// Retail draws a CONSTANT backpack icon here, NOT the player's body icon: IconData::RenderIcons
|
||
// (0x0058d1ee) has an IsThePlayer() branch that draws a fixed backpack with m_itemType =
|
||
// TYPE_CONTAINER. Compose that backpack base over the Container type-underlay (the player
|
||
// object's own IconId is the character body, which would render wrong here). The backpack
|
||
// RenderSurface is 0x0600127E, VISUALLY CONFIRMED at the live gate 2026-06-22 (the earlier
|
||
// 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) was a green tile, not the
|
||
// pack — the index value was misreported). Retires AP-51.
|
||
if (_topContainer is not null)
|
||
{
|
||
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
|
||
_topContainer.Flush();
|
||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
|
||
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
|
||
main.Clicked = () => OpenContainer(p);
|
||
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
||
_topContainer.AddItem(main);
|
||
}
|
||
|
||
ApplyIndicators();
|
||
RefreshBurden();
|
||
}
|
||
|
||
/// <summary>The effective open container — the explicit one, or the player (main pack) by default.
|
||
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default
|
||
/// sentinel is 0; once the main pack is explicitly opened, <c>_openContainer</c> holds the player
|
||
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
|
||
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
||
|
||
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
|
||
/// item cell → select-only.</summary>
|
||
private void AddCell(UiItemList? list, uint guid, bool isContainer)
|
||
{
|
||
if (list is null) return;
|
||
var item = _objects.Get(guid);
|
||
uint tex = item is null ? 0u
|
||
: _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
|
||
cell.SetItem(guid, tex);
|
||
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
||
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
|
||
cell.DragRejectSprite = 0x060011F8u; // red circle
|
||
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
|
||
else cell.Clicked = () => SelectItem(guid);
|
||
list.AddItem(cell);
|
||
}
|
||
|
||
private static void AddEmptyCell(UiItemList list)
|
||
{
|
||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() };
|
||
cell.DragAcceptSprite = 0x060011F7u;
|
||
cell.DragRejectSprite = 0x060011F8u;
|
||
list.AddItem(cell);
|
||
}
|
||
|
||
/// <summary>Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay
|
||
/// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill =
|
||
/// GetNumContainedItems / itemsCapacity, clamped [0,1]. -1 hides the bar (non-container / unknown
|
||
/// capacity). For a CLOSED side bag the contents aren't indexed until it's opened (ViewContents),
|
||
/// so the bar reads empty until then — faithful to retail's known-children count.</summary>
|
||
private void SetCapacityBar(UiItemSlot cell, uint containerGuid)
|
||
{
|
||
int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
|
||
if (cap <= 0) { cell.CapacityFill = -1f; return; }
|
||
int n = _objects.GetContents(containerGuid).Count;
|
||
cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
|
||
}
|
||
|
||
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
|
||
/// <summary>Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot
|
||
/// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.</summary>
|
||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||
|
||
/// <summary>Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always
|
||
/// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full.</summary>
|
||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||
{
|
||
if (payload.ObjId == 0) return false;
|
||
if (targetList == _contentsGrid) return true;
|
||
if (targetList == _containerList || targetList == _topContainer)
|
||
{
|
||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false;
|
||
return !IsContainerFull(targetCell.ItemId);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
|
||
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
|
||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||
{
|
||
uint item = payload.ObjId;
|
||
if (item == 0) return;
|
||
|
||
uint container; int placement;
|
||
if (targetList == _contentsGrid)
|
||
{
|
||
container = EffectiveOpen();
|
||
placement = targetCell.ItemId != 0
|
||
? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot
|
||
: _objects.GetContents(container).Count; // first empty = append
|
||
}
|
||
else if (targetList == _containerList || targetList == _topContainer)
|
||
{
|
||
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
|
||
container = targetCell.ItemId; // the bag / main pack
|
||
if (IsContainerFull(container)) return; // red already shown
|
||
placement = _objects.GetContents(container).Count; // append into it
|
||
}
|
||
else return;
|
||
|
||
if (container == item) return; // never into itself
|
||
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||
}
|
||
|
||
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
|
||
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
|
||
private bool IsContainerFull(uint container)
|
||
{
|
||
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
|
||
if (cap <= 0) return false;
|
||
return _objects.GetContents(container).Count >= cap;
|
||
}
|
||
|
||
/// <summary>Select an item (panel-wide green square) without changing the open container or
|
||
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
|
||
private void SelectItem(uint guid)
|
||
{
|
||
if (guid == 0) return;
|
||
_selectedItem = guid;
|
||
ApplyIndicators();
|
||
}
|
||
|
||
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
||
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and
|
||
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail:
|
||
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
|
||
private void OpenContainer(uint guid)
|
||
{
|
||
if (guid == 0) return;
|
||
_selectedItem = guid; // the container cell is also the selected item
|
||
uint open = EffectiveOpen();
|
||
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
||
|
||
uint p = _playerGuid();
|
||
if (open != p && open != 0)
|
||
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
|
||
_openContainer = guid;
|
||
if (guid != p)
|
||
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
|
||
Populate(); // repaint the grid for the new open container
|
||
}
|
||
|
||
/// <summary>Stamp the open-container triangle + selected-item square onto every cell per the
|
||
/// retail keying (item.itemID == openContainerId / selectedItemId).</summary>
|
||
private void ApplyIndicators()
|
||
{
|
||
SetIndicators(_contentsGrid);
|
||
SetIndicators(_containerList);
|
||
SetIndicators(_topContainer);
|
||
}
|
||
|
||
private void SetIndicators(UiItemList? list)
|
||
{
|
||
if (list is null) return;
|
||
uint open = EffectiveOpen();
|
||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||
{
|
||
var cell = list.GetItem(i);
|
||
if (cell is null) continue;
|
||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
|
||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||
}
|
||
}
|
||
|
||
/// <summary>The open container's display name for the contents caption.</summary>
|
||
private string OpenContainerName()
|
||
{
|
||
uint open = EffectiveOpen();
|
||
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|