feat(ui): complete retail cursor state machine

Port every reachable ClientUISystem cursor branch through the production DAT enum map, preserve global-default versus widget-local event ordering, and surface the registered OS fallback. Route all four toolbar stance indicators through the same combat toggle command as the key binding, with golden DAT and transition conformance coverage.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 08:31:40 +02:00
parent 05f6222865
commit 6fcc510d5d
16 changed files with 535 additions and 164 deletions

View file

@ -1,3 +1,5 @@
using AcDream.Core.Combat;
namespace AcDream.App.UI;
/// <summary>
@ -9,6 +11,10 @@ namespace AcDream.App.UI;
public enum CursorFeedbackKind
{
Default,
Combat,
Use,
Examine,
Busy,
Text,
WindowMove,
ResizeHorizontal,
@ -23,7 +29,42 @@ public enum CursorFeedbackKind
TargetInvalid,
}
public readonly record struct CursorFeedback(CursorFeedbackKind Kind, UiCursorMedia Cursor = default)
/// <summary>Retail <c>Target_Mode</c> values consumed by UpdateCursorState.</summary>
public enum RetailCursorTargetMode
{
None = 0,
Use = 1,
Examine = 2,
UseTarget = 3,
}
/// <summary>
/// The global/default cursor selected by ClientUISystem. Widget-local cursor
/// media is a separate layer and may override this through CheckCursor.
/// </summary>
public enum RetailGlobalCursorKind
{
Default,
DefaultFound,
MeleeOrMissile,
MeleeOrMissileFound,
Magic,
MagicFound,
Examine,
ExamineFound,
Use,
UseFound,
Busy,
BusyFound,
TargetPending,
TargetValid,
TargetInvalid,
}
public readonly record struct CursorFeedback(
CursorFeedbackKind Kind,
UiCursorMedia Cursor = default,
RetailGlobalCursorKind GlobalKind = RetailGlobalCursorKind.Default)
{
public static CursorFeedback Default { get; } = new(CursorFeedbackKind.Default);
}
@ -37,19 +78,26 @@ public readonly record struct CursorFeedbackSnapshot(
bool HoverWindowMove = false,
bool HoverUi = false,
bool HoverTextEdit = false,
uint HoverTargetGuid = 0);
uint HoverTargetGuid = 0,
bool? HoverTargetCompatible = null,
int BusyCount = 0,
RetailCursorTargetMode TargetMode = RetailCursorTargetMode.None,
CombatMode CombatMode = CombatMode.NonCombat);
public sealed class CursorFeedbackController
{
private readonly ItemInteractionController? _itemInteraction;
private readonly Func<uint>? _worldTargetProvider;
private readonly Func<CombatMode> _combatModeProvider;
public CursorFeedbackController(
ItemInteractionController? itemInteraction = null,
Func<uint>? worldTargetProvider = null)
Func<uint>? worldTargetProvider = null,
Func<CombatMode>? combatModeProvider = null)
{
_itemInteraction = itemInteraction;
_worldTargetProvider = worldTargetProvider;
_combatModeProvider = combatModeProvider ?? (() => CombatMode.NonCombat);
}
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
@ -65,13 +113,18 @@ public sealed class CursorFeedbackController
// window occludes the world (no found object → pending). The one
// UI-side source retail-style cells contribute is an occupied item
// slot's own item.
uint hoverTarget = 0;
if (_itemInteraction?.IsTargetModeActive == true)
{
hoverTarget = hover is not null
RetailCursorTargetMode targetMode = _itemInteraction?.IsTargetModeActive == true
? RetailCursorTargetMode.UseTarget
: RetailCursorTargetMode.None;
uint hoverTarget = hover is null
? _worldTargetProvider?.Invoke() ?? 0u
: targetMode == RetailCursorTargetMode.UseTarget
? FindHoveredItemSlot(hover)?.ItemId ?? 0u
: _worldTargetProvider?.Invoke() ?? 0u;
}
: 0u;
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
&& hoverTarget != 0
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)
: null;
var snapshot = new CursorFeedbackSnapshot(
DragPayload: root.DragPayload,
@ -82,10 +135,14 @@ public sealed class CursorFeedbackController
HoverWindowMove: root.HoverWindowMove,
HoverUi: hover is not null,
HoverTextEdit: hover?.IsEditControl == true,
HoverTargetGuid: hoverTarget);
HoverTargetGuid: hoverTarget,
HoverTargetCompatible: hoverTargetCompatible,
BusyCount: _itemInteraction?.BusyCount ?? 0,
TargetMode: targetMode,
CombatMode: _combatModeProvider());
var kind = ResolveKind(snapshot);
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind));
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover), ResolveGlobalKind(snapshot));
return Current;
}
@ -96,7 +153,7 @@ public sealed class CursorFeedbackController
}
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
=> new(ResolveKind(snapshot));
=> new(ResolveKind(snapshot), GlobalKind: ResolveGlobalKind(snapshot));
private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot)
{
@ -119,11 +176,15 @@ public sealed class CursorFeedbackController
if (snapshot.WindowMoveActive)
return CursorFeedbackKind.WindowMove;
if (_itemInteraction?.IsTargetModeActive == true)
RetailCursorTargetMode targetMode = EffectiveTargetMode(snapshot);
if (targetMode == RetailCursorTargetMode.UseTarget)
{
if (snapshot.HoverTargetGuid != 0)
{
return _itemInteraction.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
bool compatible = snapshot.HoverTargetCompatible
?? _itemInteraction?.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
?? false;
return compatible
? CursorFeedbackKind.TargetValid
: CursorFeedbackKind.TargetInvalid;
}
@ -136,15 +197,78 @@ public sealed class CursorFeedbackController
return CursorFeedbackKind.TargetPending;
}
if (snapshot.BusyCount > 0)
return CursorFeedbackKind.Busy;
if (targetMode == RetailCursorTargetMode.Use)
return CursorFeedbackKind.Use;
if (targetMode == RetailCursorTargetMode.Examine)
return CursorFeedbackKind.Examine;
if (snapshot.HoverWindowMove)
return CursorFeedbackKind.WindowMove;
if (snapshot.HoverTextEdit)
return CursorFeedbackKind.Text;
if (snapshot.CombatMode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic)
return CursorFeedbackKind.Combat;
return CursorFeedbackKind.Default;
}
/// <summary>
/// Faithful port of ClientUISystem::UpdateCursorState @ 0x00564630.
/// The found-object pair is selected first; busy then target mode then
/// combat mode replace it in exactly retail's order.
/// </summary>
internal RetailGlobalCursorKind ResolveGlobalKind(CursorFeedbackSnapshot snapshot)
{
bool found = snapshot.HoverTargetGuid != 0;
if (snapshot.BusyCount > 0)
return found ? RetailGlobalCursorKind.BusyFound : RetailGlobalCursorKind.Busy;
switch (EffectiveTargetMode(snapshot))
{
case RetailCursorTargetMode.Use:
return found ? RetailGlobalCursorKind.UseFound : RetailGlobalCursorKind.Use;
case RetailCursorTargetMode.Examine:
return found ? RetailGlobalCursorKind.ExamineFound : RetailGlobalCursorKind.Examine;
case RetailCursorTargetMode.UseTarget:
if (!found)
return RetailGlobalCursorKind.TargetPending;
bool compatible = snapshot.HoverTargetCompatible
?? _itemInteraction?.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
?? false;
return compatible
? RetailGlobalCursorKind.TargetValid
: RetailGlobalCursorKind.TargetInvalid;
case RetailCursorTargetMode.None:
break;
default:
throw new ArgumentOutOfRangeException(nameof(snapshot), snapshot.TargetMode, "Unknown retail target mode.");
}
return snapshot.CombatMode switch
{
CombatMode.Melee or CombatMode.Missile => found
? RetailGlobalCursorKind.MeleeOrMissileFound
: RetailGlobalCursorKind.MeleeOrMissile,
CombatMode.Magic => found
? RetailGlobalCursorKind.MagicFound
: RetailGlobalCursorKind.Magic,
_ => found ? RetailGlobalCursorKind.DefaultFound : RetailGlobalCursorKind.Default,
};
}
private RetailCursorTargetMode EffectiveTargetMode(CursorFeedbackSnapshot snapshot)
=> snapshot.TargetMode == RetailCursorTargetMode.None
&& _itemInteraction?.IsTargetModeActive == true
? RetailCursorTargetMode.UseTarget
: snapshot.TargetMode;
private static CursorFeedbackKind KindForResize(ResizeEdges edges)
{
bool horizontal = (edges & (ResizeEdges.Left | ResizeEdges.Right)) != 0;
@ -163,90 +287,22 @@ public sealed class CursorFeedbackController
return CursorFeedbackKind.Default;
}
private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, CursorFeedbackKind kind)
private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover)
{
foreach (var stateName in CursorStateNamesForKind(kind))
{
var semantic = FindCursorForState(hover, stateName);
if (semantic.IsValid)
return semantic;
}
var capturedCursor = FindActiveCursor(captured);
// UIElementManager::CheckCursor @ 0x0045ABF0: captured element first,
// then the last-entered element, then the ClientUISystem default. It
// does not search ancestors or synthesize a different UI state.
var capturedCursor = captured?.ActiveCursor() ?? default;
if (capturedCursor.IsValid)
return capturedCursor;
var hoverCursor = FindActiveCursor(hover);
var hoverCursor = hover?.ActiveCursor() ?? default;
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)