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

@ -71,6 +71,34 @@ public sealed class UiRoot : UiElement
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
public bool IsWindowMoveActive => _windowDragTarget is not null;
public ResizeEdges ActiveResizeEdges => _resizeTarget is not null ? _resizeEdges : ResizeEdges.None;
public ResizeEdges HoverResizeEdges
{
get
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
return window is { Resizable: true }
? HitEdges(window, MouseX, MouseY, ResizeGrip)
: ResizeEdges.None;
}
}
public bool HoverWindowMove
{
get
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
if (target is null || window is not { Draggable: true })
return false;
if (HoverResizeEdges != ResizeEdges.None)
return false;
return !target.IsDragSource
&& !target.CapturesPointerDrag
&& !target.HandlesClick;
}
}
private (uint tex, int w, int h)? _dragGhost;
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
@ -79,12 +107,16 @@ public sealed class UiRoot : UiElement
private bool _dragCandidate;
private UiElement? _windowDragTarget;
private int _windowDragOffX, _windowDragOffY;
private UiElement? _lastClickTarget;
private long _lastClickMs;
private int _lastClickX, _lastClickY;
private UiElement? _resizeTarget;
private ResizeEdges _resizeEdges;
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
private int _resizeMouseX, _resizeMouseY;
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
private const int DragDistanceThreshold = 3; // pixels, retail-observed
private const int DoubleClickDelayMs = 500;
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
@ -106,6 +138,9 @@ public sealed class UiRoot : UiElement
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
public event Action<int /*dy*/>? WorldScrollFallThrough;
/// <summary>Raised when a drag is released over no UI element.</summary>
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
@ -371,6 +406,24 @@ public sealed class UiRoot : UiElement
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in click);
long now = _nowMs != 0 ? _nowMs : Environment.TickCount64;
bool isDoubleClick =
ReferenceEquals(Captured, _lastClickTarget)
&& now - _lastClickMs <= DoubleClickDelayMs
&& Math.Abs(x - _lastClickX) <= DragDistanceThreshold
&& Math.Abs(y - _lastClickY) <= DragDistanceThreshold;
if (isDoubleClick)
{
var dbl = new UiEvent(Captured.EventId, Captured, UiEventType.DoubleClick,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in dbl);
}
_lastClickTarget = Captured;
_lastClickMs = now;
_lastClickX = x;
_lastClickY = y;
}
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
{
@ -504,6 +557,10 @@ public sealed class UiRoot : UiElement
return true;
}
/// <summary>Return the current visibility of a registered window.</summary>
public bool IsWindowVisible(string name)
=> _windows.TryGetValue(name, out var w) && w.Visible;
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
/// Returns the new IsVisible state (false for an unknown name).</summary>
public bool ToggleWindow(string name)
@ -575,7 +632,10 @@ public sealed class UiRoot : UiElement
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
t.OnEvent(in e);
}
// else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail).
else if (DragPayload is { } payload)
{
DragReleasedOutsideUi?.Invoke(payload, x, y);
}
DragSource = null;
DragPayload = null;
_dragGhost = null;