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
255
src/AcDream.App/UI/CursorFeedbackController.cs
Normal file
255
src/AcDream.App/UI/CursorFeedbackController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue