feat(ui): D.2b item interaction + retail cursors + live character sheet
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
|
|
@ -22,6 +22,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
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 TitleTextId = 0x100001D3u; // "Inventory of <name>"
|
||||
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
|
||||
|
||||
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
|
||||
|
|
@ -45,6 +46,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Func<int?> _strength;
|
||||
private readonly Func<string>? _ownerName;
|
||||
|
||||
private readonly UiItemList? _contentsGrid;
|
||||
private readonly UiItemList? _containerList;
|
||||
|
|
@ -60,6 +62,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
|
||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||
private const uint EncumbranceValProperty = 5u; // total carried burden
|
||||
|
|
@ -71,21 +74,28 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
Func<string>? ownerName,
|
||||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite,
|
||||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer)
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
ItemInteractionController? itemInteraction,
|
||||
Action? onClose)
|
||||
{
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_iconIds = iconIds;
|
||||
_strength = strength;
|
||||
_ownerName = ownerName;
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_itemInteraction = itemInteraction;
|
||||
|
||||
WindowChromeController.BindCloseButton(layout, onClose);
|
||||
|
||||
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
||||
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
||||
|
|
@ -142,6 +152,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
// 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(TitleTextId), () => "Inventory of " + OwnerName(), datFont);
|
||||
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
|
||||
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
|
||||
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
|
||||
|
|
@ -151,6 +162,8 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
|
||||
Populate();
|
||||
}
|
||||
|
|
@ -162,19 +175,23 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
UiDatFont? datFont,
|
||||
Func<string>? ownerName = null,
|
||||
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,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action? onClose = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer);
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
|
||||
|
||||
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(); }
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
|
||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
|
|
@ -200,7 +217,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
bool isBag = IsBag(item);
|
||||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +227,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
bool isBag = IsBag(item);
|
||||
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +265,12 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
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);
|
||||
main.Clicked = () =>
|
||||
{
|
||||
if (_itemInteraction?.AcquireSelfTarget() == true) return;
|
||||
OpenContainer(p);
|
||||
};
|
||||
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
|
||||
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
||||
_topContainer.AddItem(main);
|
||||
}
|
||||
|
|
@ -261,6 +283,11 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
/// 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 static bool IsBag(ClientObject item) =>
|
||||
item.ContainerTypeHint != 0u
|
||||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|| item.ItemsCapacity > 0;
|
||||
|
||||
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
||||
|
||||
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
|
||||
|
|
@ -276,6 +303,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
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
|
||||
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
|
||||
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
|
||||
else cell.Clicked = () => SelectItem(guid);
|
||||
list.AddItem(cell);
|
||||
|
|
@ -405,7 +433,8 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
var cell = list.GetItem(i);
|
||||
if (cell is null) continue;
|
||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
|
||||
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
|
||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource;
|
||||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||||
}
|
||||
}
|
||||
|
|
@ -417,6 +446,12 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
|
||||
}
|
||||
|
||||
private string OwnerName()
|
||||
{
|
||||
var name = _ownerName?.Invoke();
|
||||
return string.IsNullOrWhiteSpace(name) ? "Player" : name;
|
||||
}
|
||||
|
||||
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
|
||||
{
|
||||
if (host is null) return;
|
||||
|
|
@ -487,5 +522,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue