diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs new file mode 100644 index 00000000..310e2239 --- /dev/null +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Items; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from +/// . 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). +/// +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" + + // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). + private const int ContentsColumns = 6; + private const float CellPx = 32f; + + private readonly ClientObjectTable _objects; + private readonly Func _playerGuid; + private readonly Func _iconIds; + private readonly Func _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 playerGuid, + Func iconIds, + Func strength, + UiDatFont? datFont) + { + _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 = CellPx; + _contentsGrid.CellHeight = CellPx; + } + if (_containerList is not null) + { + _containerList.Columns = 1; + _containerList.CellWidth = CellPx; + _containerList.CellHeight = CellPx; + } + + // 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: attach a centered UiText child carrying the known string. "Contents of + // Backpack" + "%d%%" are procedural in retail (gm3DItemsUI/gmBackpackUI PostInit/ + // SetLoadLevel); "Burden" is the dat label. (Caption pass, 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 playerGuid, + Func iconIds, + Func strength, + UiDatFont? datFont) + => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont); + + 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(); } + + /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. + private bool Concerns(ClientObject o) + { + uint p = _playerGuid(); + return o.ContainerId == p || o.WielderId == p + || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep + } + + /// 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). + /// Uses the container index when available (items ingested via Ingest/MoveItem) or + /// falls back to a full scan — both paths are correct for the two production flows. + public void Populate() + { + uint p = _playerGuid(); + + // GetContents returns indexed + slot-sorted items (the production path via Ingest). + // For AddOrUpdate-seeded items (tests + initial populate before indexing), we fall + // back to scanning all objects with ContainerId == p, sorted by slot. + var indexed = _objects.GetContents(p); + IEnumerable guids; + if (indexed.Count > 0) + { + guids = indexed; + } + else + { + var direct = new List<(int slot, uint id)>(); + foreach (var o in _objects.Objects) + if (o.ContainerId == p) + direct.Add((o.ContainerSlot, o.ObjectId)); + direct.Sort((a, b) => a.slot.CompareTo(b.slot)); + var ids = new List(direct.Count); + foreach (var (_, id) in direct) ids.Add(id); + guids = ids; + } + + _contentsGrid?.Flush(); + _containerList?.Flush(); + + foreach (var guid in guids) + { + var item = _objects.Get(guid); + if (item is null) 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); + } + + // 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 text, UiDatFont? datFont) + { + if (host is null) return; + 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() + : new[] { new UiText.Line(s, CaptionColor) }; + }, + }; + host.AddChild(label); + } + + /// 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. + 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); + } + + /// Detach event handlers (idempotent). + public void Dispose() + { + _objects.ObjectAdded -= OnObjectChanged; + _objects.ObjectMoved -= OnObjectMoved; + _objects.ObjectRemoved -= OnObjectChanged; + _objects.ObjectUpdated -= OnObjectChanged; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs new file mode 100644 index 00000000..99d5b34b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +public class InventoryControllerTests +{ + private const uint Player = 0x50000001u; + + // UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts + // (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children). + private sealed class TestElement : UiElement { } + + // Element ids (spec §1). + private const uint ContentsGrid = 0x100001C6u; + private const uint ContainerList = 0x100001CAu; + private const uint TopContainer = 0x100001C9u; + private const uint BurdenMeter = 0x100001D9u; + private const uint BurdenText = 0x100001D8u; + private const uint BurdenCaption = 0x100001D7u; + private const uint ContentsCaption = 0x100001C5u; + + private static (ImportedLayout layout, UiItemList grid, UiItemList containers, + UiItemList top, UiMeter meter, UiElement burdenText, + UiElement burdenCap, UiElement contentsCap) BuildLayout() + { + var grid = new UiItemList { Width = 192, Height = 96 }; + var containers = new UiItemList { Width = 36, Height = 252 }; + var top = new UiItemList { Width = 36, Height = 36 }; + var meter = new UiMeter { Width = 11, Height = 58 }; + var burdenText = new TestElement { Width = 36, Height = 15 }; + var burdenCap = new TestElement { Width = 36, Height = 15 }; + var contentsCap = new TestElement { Width = 192, Height = 15 }; + var root = new TestElement { Width = 300, Height = 362 }; + root.AddChild(grid); root.AddChild(containers); root.AddChild(top); + root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap); + root.AddChild(contentsCap); + var byId = new Dictionary + { + [ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top, + [BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap, + [ContentsCaption] = contentsCap, + }; + return (new ImportedLayout(root, byId), grid, containers, top, meter, + burdenText, burdenCap, contentsCap); + } + + private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, + int? strength = 100) + => InventoryController.Bind(layout, objects, () => Player, + iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests + strength: () => strength, datFont: null); + + [Fact] + public void Populate_fills_contents_grid_with_loose_items_only() + { + var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, + ContainerSlot = 0, Burden = 10 }); // loose + objects.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, + ContainerSlot = 1, Burden = 20 }); // loose + objects.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = Player, + ContainerSlot = 2, Type = ItemType.Container }); // side bag + + Bind(layout, objects); + + Assert.Equal(2, grid.GetNumUIItems()); // 2 loose + Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); + Assert.Equal(0xBu, grid.GetItem(1)!.ItemId); + Assert.Equal(1, containers.GetNumUIItems()); // 1 side bag + Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); + } + + [Fact] + public void Grid_is_six_columns_thirtytwo_px() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + Bind(layout, new ClientObjectTable()); + Assert.Equal(6, grid.Columns); + Assert.Equal(32f, grid.CellWidth); + Assert.Equal(32f, grid.CellHeight); + } + + [Fact] + public void ObjectAdded_for_player_item_rebuilds_grid() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + Bind(layout, objects); + Assert.Equal(0, grid.GetNumUIItems()); + + objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0, + null, null, null, 5, Player, null, null, null, null, null, null, null, null, null)); + + Assert.Equal(1, grid.GetNumUIItems()); + Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); + } + + [Fact] + public void Burden_meter_fill_and_percent_from_load() + { + var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + // Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%". + objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 }); + Bind(layout, objects, strength: 100); + + Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); + Assert.True(meter.Vertical); + // The % text caption child reads "50%". + Assert.Contains("50%", CaptionText(burdenText)); + } + + [Fact] + public void Captions_render_known_strings() + { + var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout(); + Bind(layout, new ClientObjectTable()); + Assert.Contains("Burden", CaptionText(burdenCap)); + Assert.Contains("Contents of Backpack", CaptionText(contentsCap)); + } + + // Reads the text of the UiText caption child attached by the controller. + private static string CaptionText(UiElement host) + { + foreach (var c in host.Children) + if (c is UiText t) + { + var lines = t.LinesProvider(); + if (lines.Count > 0) return lines[0].Text; + } + return ""; + } +}