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:
Erik 2026-07-03 09:18:43 +02:00
parent e3fc7ac5ba
commit b7dc91a053
74 changed files with 6669 additions and 238 deletions

View file

@ -54,8 +54,15 @@ public sealed class ToolbarController : IItemListDragHandler
private static readonly uint[] CombatIndicatorIds =
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private const string ButtonClosedState = "Normal";
private const string ButtonOpenState = "Highlight";
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
private readonly UiButton? _characterButton;
private readonly UiButton? _inventoryButton;
private readonly ClientObjectTable _repo;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
@ -64,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler
private bool _storeLoaded;
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
@ -87,6 +95,7 @@ public sealed class ToolbarController : IItemListDragHandler
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? emptyDigits,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
@ -97,6 +106,7 @@ public sealed class ToolbarController : IItemListDragHandler
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_emptyDigits = emptyDigits;
_itemInteraction = itemInteraction;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
@ -120,6 +130,9 @@ public sealed class ToolbarController : IItemListDragHandler
for (int i = 0; i < CombatIndicatorIds.Length; i++)
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
@ -201,16 +214,44 @@ public sealed class ToolbarController : IItemListDragHandler
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? emptyDigits = null,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut);
c.Populate();
return c;
}
/// <summary>Wire the retail status-bar launch buttons to top-level window toggles.</summary>
public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter)
{
if (_inventoryButton is not null)
{
if (_itemInteraction is not null)
_inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid;
_inventoryButton.OnClick = () =>
{
if (_itemInteraction?.AcquireSelfTarget() == true) return;
toggleInventory?.Invoke();
};
}
if (_characterButton is not null)
_characterButton.OnClick = toggleCharacter;
}
public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open);
public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open);
private static void SetButtonOpen(UiButton? button, bool open)
{
if (button is not null)
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
}
/// <summary>
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
/// each shortcut entry that has a resolved item in the repository.
@ -320,7 +361,12 @@ public sealed class ToolbarController : IItemListDragHandler
list.Cell.Clicked = () =>
{
if (list.Cell.ItemId != 0)
_useItem(list.Cell.ItemId);
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(list.Cell.ItemId);
else
_useItem(list.Cell.ItemId);
}
};
}