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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue