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,108 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Mounts an imported retail window's content into the shared 8-piece beveled
/// chrome (<see cref="UiNineSlicePanel"/>) and optionally registers it with
/// the <see cref="UiRoot"/> window manager. This is THE mount recipe every
/// retail window follows: frame sized content + 2×border, content inset by
/// the border with Draggable/Resizable off (the frame owns move/resize).
///
/// <para>The vitals/chat/toolbar/inventory windows still inline this recipe
/// in <c>GameWindow</c> (pre-extraction); new windows must mount through this
/// helper instead of copying the block again — see
/// docs/research/2026-07-02-ui-architecture-review.md (theme T1).</para>
/// </summary>
public static class RetailWindowFrame
{
/// <summary>Per-window placement + behavior knobs. Null-valued knobs keep
/// the widget defaults (<see cref="UiElement"/> / <see cref="UiNineSlicePanel"/>).</summary>
public sealed record Options
{
/// <summary>Initial window position (screen px).</summary>
public float Left { get; init; }
public float Top { get; init; }
/// <summary>Minimum frame size; null = lock to content + 2×border (non-shrinkable).</summary>
public float? MinWidth { get; init; }
public float? MinHeight { get; init; }
/// <summary>Maximum frame height while resizing; null = unbounded.</summary>
public float? MaxHeight { get; init; }
/// <summary>Allow horizontal resize (frame default: true).</summary>
public bool ResizeX { get; init; } = true;
/// <summary>Restrict which edges start a resize; null = all edges.</summary>
public ResizeEdges? ResizableEdges { get; init; }
/// <summary>Window translucency (retail SetDefaultOpacity analog).</summary>
public float Opacity { get; init; } = 1f;
/// <summary>Start shown or hidden (hidden windows toggle via the window manager).</summary>
public bool Visible { get; init; } = true;
/// <summary>How the content tracks the frame when it resizes.</summary>
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
/// <summary>Force the content's ClickThrough; null = leave as imported.</summary>
public bool? ContentClickThrough { get; init; }
/// <summary>Register the frame under this name in the UiRoot window
/// manager (<see cref="WindowNames"/>); null = unmanaged window.</summary>
public string? WindowName { get; init; }
}
/// <summary>
/// Wrap <paramref name="content"/> (sized by the importer) in the beveled
/// chrome, add it to <paramref name="root"/>, and register it when
/// <see cref="Options.WindowName"/> is set. Returns the frame.
/// </summary>
public static UiNineSlicePanel Mount(
UiRoot root,
UiElement content,
Func<uint, (uint handle, int w, int h)> resolveChrome,
Options options)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(resolveChrome);
ArgumentNullException.ThrowIfNull(options);
const int border = RetailChromeSprites.Border;
float contentW = content.Width;
float contentH = content.Height;
var frame = new UiNineSlicePanel(resolveChrome)
{
Left = options.Left,
Top = options.Top,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
MinWidth = options.MinWidth ?? contentW + 2 * border,
MinHeight = options.MinHeight ?? contentH + 2 * border,
ResizeX = options.ResizeX,
Opacity = options.Opacity,
Visible = options.Visible,
};
if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight;
if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges;
content.Left = border;
content.Top = border;
content.Width = contentW;
content.Height = contentH;
content.Anchors = options.ContentAnchors;
if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough;
content.Draggable = false; // the frame owns window move/resize
content.Resizable = false;
frame.AddChild(content);
root.AddChild(frame);
if (options.WindowName is { } name)
root.RegisterWindow(name, frame);
return frame;
}
}