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

@ -0,0 +1,255 @@
namespace AcDream.App.UI;
/// <summary>
/// Semantic cursor state for the retail-look UI. Retail stores actual cursor art
/// as MediaDescCursor on UI states; this controller owns the behavior decision so
/// the visual backend can apply dat cursors without
/// changing item/window interaction code.
/// </summary>
public enum CursorFeedbackKind
{
Default,
Text,
WindowMove,
ResizeHorizontal,
ResizeVertical,
ResizeDiagonalNwse,
ResizeDiagonalNesw,
Drag,
DragAccept,
DragReject,
TargetPending,
TargetValid,
TargetInvalid,
}
public readonly record struct CursorFeedback(CursorFeedbackKind Kind, UiCursorMedia Cursor = default)
{
public static CursorFeedback Default { get; } = new(CursorFeedbackKind.Default);
}
public readonly record struct CursorFeedbackSnapshot(
object? DragPayload = null,
UiItemSlot.DragAcceptState DragAccept = UiItemSlot.DragAcceptState.None,
ResizeEdges ActiveResizeEdges = ResizeEdges.None,
ResizeEdges HoverResizeEdges = ResizeEdges.None,
bool WindowMoveActive = false,
bool HoverWindowMove = false,
bool HoverUi = false,
bool HoverTextEdit = false,
uint HoverTargetGuid = 0);
public sealed class CursorFeedbackController
{
private readonly ItemInteractionController? _itemInteraction;
public CursorFeedbackController(ItemInteractionController? itemInteraction = null)
{
_itemInteraction = itemInteraction;
}
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
public CursorFeedback Update(UiRoot root)
{
ArgumentNullException.ThrowIfNull(root);
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
var snapshot = new CursorFeedbackSnapshot(
DragPayload: root.DragPayload,
DragAccept: FindHoveredItemSlot(hover)?.DragAcceptVisual ?? UiItemSlot.DragAcceptState.None,
ActiveResizeEdges: root.ActiveResizeEdges,
HoverResizeEdges: root.HoverResizeEdges,
WindowMoveActive: root.IsWindowMoveActive,
HoverWindowMove: root.HoverWindowMove,
HoverUi: hover is not null,
HoverTextEdit: hover?.IsEditControl == true,
HoverTargetGuid: ResolveUseTargetGuid(hover));
var kind = ResolveKind(snapshot);
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind));
return Current;
}
public CursorFeedback Update(CursorFeedbackSnapshot snapshot)
{
Current = Resolve(snapshot);
return Current;
}
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
=> new(ResolveKind(snapshot));
private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot)
{
if (snapshot.DragPayload is not null)
{
return snapshot.DragAccept switch
{
UiItemSlot.DragAcceptState.Accept => CursorFeedbackKind.DragAccept,
UiItemSlot.DragAcceptState.Reject => CursorFeedbackKind.DragReject,
_ => CursorFeedbackKind.Drag,
};
}
ResizeEdges resizeEdges = snapshot.ActiveResizeEdges != ResizeEdges.None
? snapshot.ActiveResizeEdges
: snapshot.HoverResizeEdges;
if (resizeEdges != ResizeEdges.None)
return KindForResize(resizeEdges);
if (snapshot.WindowMoveActive)
return CursorFeedbackKind.WindowMove;
if (_itemInteraction?.IsTargetModeActive == true)
{
if (snapshot.HoverTargetGuid != 0)
{
return _itemInteraction.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
? CursorFeedbackKind.TargetValid
: CursorFeedbackKind.TargetInvalid;
}
return snapshot.HoverUi
? CursorFeedbackKind.TargetInvalid
: CursorFeedbackKind.TargetPending;
}
if (snapshot.HoverWindowMove)
return CursorFeedbackKind.WindowMove;
if (snapshot.HoverTextEdit)
return CursorFeedbackKind.Text;
return CursorFeedbackKind.Default;
}
private static CursorFeedbackKind KindForResize(ResizeEdges edges)
{
bool horizontal = (edges & (ResizeEdges.Left | ResizeEdges.Right)) != 0;
bool vertical = (edges & (ResizeEdges.Top | ResizeEdges.Bottom)) != 0;
if (horizontal && vertical)
{
bool nwse =
((edges & ResizeEdges.Left) != 0 && (edges & ResizeEdges.Top) != 0)
|| ((edges & ResizeEdges.Right) != 0 && (edges & ResizeEdges.Bottom) != 0);
return nwse ? CursorFeedbackKind.ResizeDiagonalNwse : CursorFeedbackKind.ResizeDiagonalNesw;
}
if (horizontal) return CursorFeedbackKind.ResizeHorizontal;
if (vertical) return CursorFeedbackKind.ResizeVertical;
return CursorFeedbackKind.Default;
}
private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, CursorFeedbackKind kind)
{
foreach (var stateName in CursorStateNamesForKind(kind))
{
var semantic = FindCursorForState(hover, stateName);
if (semantic.IsValid)
return semantic;
}
var capturedCursor = FindActiveCursor(captured);
if (capturedCursor.IsValid)
return capturedCursor;
var hoverCursor = FindActiveCursor(hover);
if (hoverCursor.IsValid)
return hoverCursor;
return default;
}
private static IReadOnlyList<string> CursorStateNamesForKind(CursorFeedbackKind kind)
=> kind switch
{
CursorFeedbackKind.DragAccept => new[]
{
"Drag_rollover_accept",
"ItemSlot_DragOver_Accept",
"ItemSlot_DragOver_DropIn",
},
CursorFeedbackKind.TargetValid => new[]
{
"Drag_rollover_accept",
"ItemSlot_DragOver_Accept",
"ItemSlot_DragOver_DropIn",
"Csm_highlight",
},
CursorFeedbackKind.DragReject or CursorFeedbackKind.TargetInvalid => new[]
{
"Drag_rollover_reject",
"ItemSlot_DragOver_Reject",
"Csm_ghosted",
},
CursorFeedbackKind.Drag => new[]
{
"ItemSlot_DragOver_Normal",
"Drag_rollover_accept",
},
CursorFeedbackKind.TargetPending => new[]
{
"DDDMode",
"Csm_normal",
},
CursorFeedbackKind.WindowMove => new[]
{
"Csm_highlight",
"Csm_normal",
},
_ => Array.Empty<string>(),
};
private static UiCursorMedia FindCursorForState(UiElement? element, string stateName)
{
while (element is not null)
{
var cursor = element.CursorForState(stateName, allowFallback: false);
if (cursor.IsValid)
return cursor;
element = element.Parent;
}
return default;
}
private static UiCursorMedia FindActiveCursor(UiElement? element)
{
while (element is not null)
{
var cursor = element.ActiveCursor();
if (cursor.IsValid)
return cursor;
element = element.Parent;
}
return default;
}
private static UiItemSlot? FindHoveredItemSlot(UiElement? element)
{
while (element is not null)
{
if (element is UiItemSlot slot)
return slot;
element = element.Parent;
}
return null;
}
private static uint ResolveUseTargetGuid(UiElement? element)
{
while (element is not null)
{
uint provided = element.UseTargetGuidProvider?.Invoke() ?? 0u;
if (provided != 0)
return provided;
if (element is UiItemSlot { ItemId: not 0 } slot)
return slot.ItemId;
element = element.Parent;
}
return 0u;
}
}