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:
Erik 2026-06-22 14:32:31 +02:00
parent 842462e375
commit 7407a71d68
2 changed files with 234 additions and 44 deletions

View file

@ -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;

View file

@ -51,10 +51,19 @@ public class InventoryControllerTests
}
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
int? strength = 100)
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests
strength: () => strength, datFont: null);
strength: () => strength, datFont: null,
sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g));
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
{
t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity });
t.MoveItem(bag, Player, slot);
}
// Place an object in a container at a slot via the faithful production path:
// AddOrUpdate registers it, MoveItem sets ContainerId+ContainerSlot and calls Reindex
@ -253,6 +262,105 @@ public class InventoryControllerTests
Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell
}
[Fact]
public void ClickSideBag_sendsUse_andSwapsGridToBagContents()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item
SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1)
SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag
var uses = new List<uint>();
Bind(layout, objects, uses: uses);
// The side-bag column's first cell holds the bag guid; click it.
containers.GetItem(0)!.Clicked!();
Assert.Contains(0xCu, uses); // Use(bag) sent
Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents
Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity
}
[Fact]
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item
SeedBag(objects, 0xC, slot: 1);
SeedContained(objects, 0xB1, 0xC, slot: 0);
var uses = new List<uint>();
var closes = new List<uint>();
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
containers.GetItem(0)!.Clicked!(); // open the bag
top.GetItem(0)!.Clicked!(); // click the main-pack cell
Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent
Assert.DoesNotContain(Player, uses); // no Use for the main pack
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
Assert.Equal(102, grid.GetNumUIItems());
}
[Fact]
public void SwitchBetweenTwoBags_closesPrevious_opensNext()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC1, slot: 0);
SeedBag(objects, 0xC2, slot: 1);
var uses = new List<uint>();
var closes = new List<uint>();
Bind(layout, objects, uses: uses, closes: closes);
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2)
Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B)
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
}
[Fact]
public void OpenBag_marksTriangleAndSquare_onTheBagCell()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC, slot: 0);
Bind(layout, objects, uses: new List<uint>());
containers.GetItem(0)!.Clicked!();
Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag
Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item
}
[Fact]
public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer()
{
var (layout, grid, _, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0);
var uses = new List<uint>();
var closes = new List<uint>();
Bind(layout, objects, uses: uses, closes: closes);
grid.GetItem(0)!.Clicked!(); // select the loose item
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
Assert.Empty(uses); // selection sends no wire
Assert.Empty(closes);
}
[Fact]
public void Default_mainPackCell_isOpenContainer()
{
var (layout, _, _, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
Bind(layout, objects);
Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack
}
// Reads the text of the UiText caption child attached by the controller.
private static string CaptionText(UiElement host)
{