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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue