feat(D.2b): InventoryController container-switching + open/selected indicators
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
842462e375
commit
7407a71d68
2 changed files with 234 additions and 44 deletions
|
|
@ -37,6 +37,7 @@ public sealed class InventoryController
|
|||
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;
|
||||
|
|
@ -52,6 +53,11 @@ public sealed class InventoryController
|
|||
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;
|
||||
|
||||
// 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
|
||||
|
|
@ -65,12 +71,16 @@ public sealed class InventoryController
|
|||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite,
|
||||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite)
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing)
|
||||
{
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_iconIds = iconIds;
|
||||
_strength = strength;
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
|
||||
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
||||
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
||||
|
|
@ -123,7 +133,7 @@ public sealed class InventoryController
|
|||
// 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(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
|
||||
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
|
||||
|
||||
// Rebuild on any change to the player's possessions.
|
||||
|
|
@ -144,9 +154,12 @@ public sealed class InventoryController
|
|||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite = 0u,
|
||||
uint sideBagEmptySprite = 0u,
|
||||
uint mainPackEmptySprite = 0u)
|
||||
uint mainPackEmptySprite = 0u,
|
||||
Action<uint>? sendUse = null,
|
||||
Action<uint>? sendNoLongerViewing = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite);
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
|
|
@ -156,59 +169,49 @@ public sealed class InventoryController
|
|||
private bool Concerns(ClientObject o)
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
return o.ObjectId == p // B-Wire: the player object IS the burden source
|
||||
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
|
||||
}
|
||||
|
||||
/// <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);
|
||||
uint open = EffectiveOpen();
|
||||
|
||||
_contentsGrid?.Flush();
|
||||
_containerList?.Flush();
|
||||
|
||||
foreach (var guid in contents)
|
||||
// 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) 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;
|
||||
if (item is null || 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);
|
||||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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(p)?.ItemsCapacity ?? 0;
|
||||
int slots = cap > 0 ? cap : MainPackSlots;
|
||||
while (_contentsGrid.GetNumUIItems() < slots)
|
||||
_contentsGrid.AddItem(new UiItemSlot { SpriteResolve = _contentsGrid.SpriteResolve });
|
||||
int cap = _objects.Get(open)?.ItemsCapacity ?? 0;
|
||||
int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots);
|
||||
while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid);
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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;
|
||||
|
|
@ -216,24 +219,103 @@ public sealed class InventoryController
|
|||
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 });
|
||||
while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Main-pack cell: the player's own container — clicking it opens/selects the main pack.
|
||||
if (_topContainer is not null)
|
||||
{
|
||||
_topContainer.Flush();
|
||||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||||
main.SetItem(p, 0u);
|
||||
main.Clicked = () => OpenContainer(p);
|
||||
_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.</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);
|
||||
if (isContainer) cell.Clicked = () => OpenContainer(guid);
|
||||
else cell.Clicked = () => SelectItem(guid);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
private static void AddEmptyCell(UiItemList list)
|
||||
=> list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve });
|
||||
|
||||
/// <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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue