Map synthetic move and resize affordances to the exact DAT cursors, make chat top chrome movable, and replace stale primary-panel height caps with a dynamic screen-edge constraint. This keeps the retained wrapper adaptation aligned with retail Dragbar/Resizebar behavior.
359 lines
13 KiB
C#
359 lines
13 KiB
C#
using AcDream.Core.Combat;
|
|
|
|
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,
|
|
Combat,
|
|
Use,
|
|
Examine,
|
|
Busy,
|
|
Text,
|
|
WindowMove,
|
|
ResizeHorizontal,
|
|
ResizeVertical,
|
|
ResizeDiagonalNwse,
|
|
ResizeDiagonalNesw,
|
|
Drag,
|
|
DragAccept,
|
|
DragReject,
|
|
TargetPending,
|
|
TargetValid,
|
|
TargetInvalid,
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
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,
|
|
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<CombatMode>? combatModeProvider = null)
|
|
{
|
|
_itemInteraction = itemInteraction;
|
|
_worldTargetProvider = worldTargetProvider;
|
|
_combatModeProvider = combatModeProvider ?? (() => CombatMode.NonCombat);
|
|
}
|
|
|
|
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
|
|
|
|
public CursorFeedback Update(UiRoot root)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(root);
|
|
|
|
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
|
|
|
|
// Retail UpdateCursorState (0x00564630) keys the target-mode cursor off
|
|
// the SmartBox found object — the WORLD entity under the cursor. A UI
|
|
// 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.
|
|
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
|
|
uint hoverTarget = hover is null
|
|
? _worldTargetProvider?.Invoke() ?? 0u
|
|
: targetMode == RetailCursorTargetMode.UseTarget
|
|
? FindHoveredItemSlot(hover)?.ItemId ?? 0u
|
|
: 0u;
|
|
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
|
|
&& hoverTarget != 0
|
|
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)
|
|
: null;
|
|
|
|
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: hoverTarget,
|
|
HoverTargetCompatible: hoverTargetCompatible,
|
|
BusyCount: _itemInteraction?.BusyCount ?? 0,
|
|
TargetMode: targetMode,
|
|
CombatMode: _combatModeProvider());
|
|
|
|
var kind = ResolveKind(snapshot);
|
|
UiCursorMedia authoredCursor = ResolveCursor(root.Captured, hover);
|
|
UiCursorMedia cursor = ResolveEffectiveCursor(kind, snapshot, authoredCursor);
|
|
Current = new CursorFeedback(kind, cursor, ResolveGlobalKind(snapshot));
|
|
return Current;
|
|
}
|
|
|
|
public CursorFeedback Update(CursorFeedbackSnapshot snapshot)
|
|
{
|
|
Current = Resolve(snapshot);
|
|
return Current;
|
|
}
|
|
|
|
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
|
|
{
|
|
CursorFeedbackKind kind = ResolveKind(snapshot);
|
|
return new(
|
|
kind,
|
|
ResolveEffectiveCursor(kind, snapshot, authoredCursor: default),
|
|
ResolveGlobalKind(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;
|
|
|
|
RetailCursorTargetMode targetMode = EffectiveTargetMode(snapshot);
|
|
if (targetMode == RetailCursorTargetMode.UseTarget)
|
|
{
|
|
if (snapshot.HoverTargetGuid != 0)
|
|
{
|
|
bool compatible = snapshot.HoverTargetCompatible
|
|
?? _itemInteraction?.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
|
|
?? false;
|
|
return compatible
|
|
? CursorFeedbackKind.TargetValid
|
|
: CursorFeedbackKind.TargetInvalid;
|
|
}
|
|
|
|
// Retail UpdateCursorState (0x00564630), TARGET_MODE 3: no found
|
|
// object → the 0x27 four-arrows pending cursor — INCLUDING over
|
|
// UI chrome. Valid/invalid exist only with a target under the
|
|
// cursor. (The earlier HoverUi → Invalid arm was a non-retail
|
|
// invention — 2026-07-03 visual gate.)
|
|
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
|
|
? ModeFromInteraction(_itemInteraction)
|
|
: snapshot.TargetMode;
|
|
|
|
private static RetailCursorTargetMode ModeFromInteraction(
|
|
ItemInteractionController? interaction)
|
|
=> interaction?.InteractionState.Current.Kind switch
|
|
{
|
|
InteractionModeKind.Use => RetailCursorTargetMode.Use,
|
|
InteractionModeKind.Examine => RetailCursorTargetMode.Examine,
|
|
InteractionModeKind.UseItemOnTarget => RetailCursorTargetMode.UseTarget,
|
|
_ => RetailCursorTargetMode.None,
|
|
};
|
|
|
|
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)
|
|
{
|
|
// 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 = hover?.ActiveCursor() ?? default;
|
|
if (hoverCursor.IsValid)
|
|
return hoverCursor;
|
|
|
|
return default;
|
|
}
|
|
|
|
private static UiCursorMedia ResolveEffectiveCursor(
|
|
CursorFeedbackKind kind,
|
|
CursorFeedbackSnapshot snapshot,
|
|
UiCursorMedia authoredCursor)
|
|
{
|
|
bool syntheticControlOwnsPointer = snapshot.ActiveResizeEdges != ResizeEdges.None
|
|
|| snapshot.HoverResizeEdges != ResizeEdges.None
|
|
|| snapshot.WindowMoveActive;
|
|
|
|
// Retail CheckCursor gives the hovered/captured Resizebar or captured
|
|
// Dragbar first refusal. Our wrapper borders are geometry, not UIElements,
|
|
// so a hovered synthetic resize edge or active synthetic control must take
|
|
// that same precedence over the content under its five-pixel hit region.
|
|
if (syntheticControlOwnsPointer
|
|
&& RetailCursorCatalog.TryGetWindowControlCursor(kind, out UiCursorMedia capturedControl))
|
|
return capturedControl;
|
|
|
|
// Ordinary imported elements retain their authored MD_Data_Cursor exactly.
|
|
if (authoredCursor.IsValid)
|
|
return authoredCursor;
|
|
|
|
// Synthetic wrapper edges and the IA-12 whole-window drag region have no
|
|
// authored child, but use retail's exact control media and hotspot.
|
|
return RetailCursorCatalog.TryGetWindowControlCursor(kind, out UiCursorMedia fallback)
|
|
? fallback
|
|
: default;
|
|
}
|
|
|
|
private static UiItemSlot? FindHoveredItemSlot(UiElement? element)
|
|
{
|
|
while (element is not null)
|
|
{
|
|
if (element is UiItemSlot slot)
|
|
return slot;
|
|
element = element.Parent;
|
|
}
|
|
return null;
|
|
}
|
|
}
|