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
|
|
@ -659,8 +659,12 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.UiHost? _uiHost;
|
||||
private AcDream.App.UI.Testing.RetailUiAutomationScriptRunner? _uiAutomationRunner;
|
||||
private AcDream.App.UI.CursorFeedbackController? _cursorFeedbackController;
|
||||
private RetailCursorManager? _retailCursorManager;
|
||||
// Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern).
|
||||
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
|
||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
|
||||
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
|
||||
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
|
||||
|
|
@ -764,6 +768,9 @@ public sealed class GameWindow : IDisposable
|
|||
// and out keeps the right skills.
|
||||
private int _lastSeenRunSkill = -1;
|
||||
private int _lastSeenJumpSkill = -1;
|
||||
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
|
||||
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
|
||||
// K.1b: this field is RESERVED — written when entering / leaving player
|
||||
// mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now
|
||||
// never consumed by MovementInput (mouse never drives character yaw —
|
||||
|
|
@ -1829,6 +1836,28 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
||||
Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
sendUse: g => _liveSession?.SendUse(g),
|
||||
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
|
||||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
sendDrop: item => _liveSession?.SendDropItem(item),
|
||||
toast: text => _debugVm?.AddToast(text));
|
||||
_cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(_itemInteractionController);
|
||||
_retailCursorManager = new RetailCursorManager(_dats!, _datLock);
|
||||
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
|
||||
Objects, LocalPlayer,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
activeToonName: () => _activeToonKey,
|
||||
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
|
||||
canSendRaise: () => _liveSession is not null
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
|
||||
sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost),
|
||||
sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost),
|
||||
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
|
||||
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
|
||||
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||
|
||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
|
||||
|
|
@ -2079,6 +2108,7 @@ public sealed class GameWindow : IDisposable
|
|||
peaceDigits: toolbarPeaceDigits,
|
||||
warDigits: toolbarWarDigits,
|
||||
emptyDigits: toolbarEmptyDigits,
|
||||
itemInteraction: _itemInteractionController,
|
||||
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
|
||||
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
|
||||
|
||||
|
|
@ -2176,6 +2206,47 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
|
||||
|
||||
// Phase D.2b-C: live Character window (Attributes / Skills / Titles) from
|
||||
// LayoutDesc 0x2100002E. Starts hidden and is toggled by the status-bar
|
||||
// character button; stats use the same retail-bound sheet shape as the studio
|
||||
// until the live character stat feed is wired.
|
||||
AcDream.App.UI.Layout.ImportedLayout? characterLayout;
|
||||
lock (_datLock)
|
||||
characterLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||
_dats!, 0x2100002Eu, ResolveChrome, vitalsDatFont, ResolveDatFont);
|
||||
if (characterLayout is not null)
|
||||
{
|
||||
AcDream.App.UI.Layout.CharacterStatController.Bind(
|
||||
characterLayout,
|
||||
data: _characterSheetProvider!.BuildSheet,
|
||||
datFont: vitalsDatFont,
|
||||
rowDatFont: ResolveDatFont(0x40000001u) ?? vitalsDatFont,
|
||||
spriteResolve: ResolveChrome,
|
||||
onRaiseRequest: _characterSheetProvider.HandleRaiseRequest,
|
||||
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Character));
|
||||
|
||||
// Retail-authored content is 380px tall but the skill list wants to
|
||||
// grow: bottom-edge-only resize up to 760px, X locked to the dat width.
|
||||
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
|
||||
_uiHost.Root, characterLayout.Root, ResolveChrome,
|
||||
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||
{
|
||||
Left = 540f,
|
||||
Top = 18f,
|
||||
MaxHeight = 760f,
|
||||
ResizeX = false,
|
||||
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
|
||||
Visible = false,
|
||||
ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
WindowName = AcDream.App.UI.WindowNames.Character,
|
||||
});
|
||||
|
||||
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
|
||||
}
|
||||
else Console.WriteLine("[D.2b-C] character: LayoutDesc 0x2100002E not found.");
|
||||
|
||||
// Drain plugin-registered markup panels (buffered before the GL
|
||||
// window opened) into the same UiRoot tree. A faulty plugin markup
|
||||
// file is isolated — logged + skipped, never crashes the client.
|
||||
|
|
@ -2294,13 +2365,16 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
|
||||
? (int?)sa.Current : null,
|
||||
datFont: vitalsDatFont,
|
||||
ownerName: _characterSheetProvider!.CharacterName,
|
||||
contentsEmptySprite: contentsEmpty,
|
||||
sideBagEmptySprite: sideBagEmpty,
|
||||
mainPackEmptySprite: mainPackEmpty,
|
||||
sendUse: g => _liveSession?.SendUse(g),
|
||||
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
itemInteraction: _itemInteractionController,
|
||||
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Inventory));
|
||||
|
||||
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
|
||||
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
|
||||
|
|
@ -2312,7 +2386,8 @@ public sealed class GameWindow : IDisposable
|
|||
// Empty equip slots show a visible frame (same square as the inventory grid) so every
|
||||
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
|
||||
emptySlotSprite: contentsEmpty,
|
||||
datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button
|
||||
datFont: vitalsDatFont,
|
||||
itemInteraction: _itemInteractionController); // Slice 2: caption the "Slots" toggle button
|
||||
|
||||
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
||||
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
||||
|
|
@ -2324,6 +2399,25 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
}
|
||||
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
|
||||
|
||||
_toolbarController?.BindWindowToggles(
|
||||
toggleInventory: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory),
|
||||
toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character));
|
||||
SyncToolbarWindowButtons();
|
||||
|
||||
if (_options.UiProbeEnabled)
|
||||
{
|
||||
void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
|
||||
var probe = new AcDream.App.UI.Testing.RetailUiAutomationProbe(
|
||||
_uiHost.Root,
|
||||
Objects,
|
||||
ProbeLog);
|
||||
_uiAutomationRunner = new AcDream.App.UI.Testing.RetailUiAutomationScriptRunner(
|
||||
probe,
|
||||
_options.UiProbeScript,
|
||||
_options.UiProbeDump,
|
||||
ProbeLog);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
||||
|
|
@ -2689,6 +2783,13 @@ public sealed class GameWindow : IDisposable
|
|||
// and jumps undershot retail by ~30 % at typical
|
||||
// attribute levels.
|
||||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||||
if (_characterSheetProvider is not null && _dats is not null)
|
||||
{
|
||||
_characterSheetProvider.SkillTable = skillTable;
|
||||
_characterSheetProvider.ExperienceTable =
|
||||
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
|
||||
_dats, Console.WriteLine);
|
||||
}
|
||||
|
||||
AcDream.Core.Net.GameEventWiring.WireAll(
|
||||
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
|
||||
|
|
@ -9498,6 +9599,8 @@ public sealed class GameWindow : IDisposable
|
|||
if (_options.RetailUi && _uiHost is not null)
|
||||
{
|
||||
_uiHost.Tick(deltaSeconds);
|
||||
_uiAutomationRunner?.Tick(deltaSeconds);
|
||||
UpdateRetailCursorFeedback();
|
||||
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
|
||||
}
|
||||
|
||||
|
|
@ -11654,6 +11757,42 @@ public sealed class GameWindow : IDisposable
|
|||
// first EnterWorld.
|
||||
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
|
||||
private string _activeToonKey = "default";
|
||||
|
||||
private bool ToggleRetailWindow(string name)
|
||||
{
|
||||
bool visible = _uiHost?.ToggleWindow(name) ?? false;
|
||||
SyncToolbarWindowButtons();
|
||||
return visible;
|
||||
}
|
||||
|
||||
private void CloseRetailWindow(string name)
|
||||
{
|
||||
_uiHost?.HideWindow(name);
|
||||
SyncToolbarWindowButtons();
|
||||
}
|
||||
|
||||
private void SyncToolbarWindowButtons()
|
||||
{
|
||||
if (_toolbarController is null || _uiHost is null) return;
|
||||
_toolbarController.SetInventoryOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Inventory));
|
||||
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character));
|
||||
}
|
||||
|
||||
private void UpdateRetailCursorFeedback()
|
||||
{
|
||||
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)
|
||||
return;
|
||||
|
||||
var feedback = _cursorFeedbackController.Update(_uiHost.Root);
|
||||
_retailCursorManager.Apply(_input.Mice, feedback);
|
||||
}
|
||||
|
||||
private void OnUiDragReleasedOutside(object payload, int x, int y)
|
||||
{
|
||||
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
|
||||
_itemInteractionController?.DropToWorld(itemPayload);
|
||||
}
|
||||
|
||||
// L.0 follow-up: persisted-settings cache populated by
|
||||
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
|
||||
// not gated on DevToolsEnabled). The Settings PANEL construction
|
||||
|
|
@ -12046,7 +12185,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
|
||||
// does not fire while the chat input holds focus. Null _uiHost =
|
||||
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
|
||||
_uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory);
|
||||
ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
|
||||
|
|
@ -12153,7 +12292,9 @@ public sealed class GameWindow : IDisposable
|
|||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||
_itemInteractionController.CancelTargetMode();
|
||||
else if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
else if (_playerMode)
|
||||
{
|
||||
|
|
@ -12291,6 +12432,12 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
if (picked is uint guid)
|
||||
{
|
||||
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||
{
|
||||
_itemInteractionController.AcquireTarget(guid);
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedGuid = guid;
|
||||
string label = DescribeLiveEntity(guid);
|
||||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
||||
|
|
@ -12324,6 +12471,8 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
else
|
||||
{
|
||||
if (_itemInteractionController?.IsTargetModeActive == true)
|
||||
return;
|
||||
_debugVm?.AddToast("Nothing to select");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
142
src/AcDream.App/Rendering/RetailCursorManager.cs
Normal file
142
src/AcDream.App/Rendering/RetailCursorManager.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Textures;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Silk.NET.Core;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Applies retail cursor feedback to Silk using dat MediaDescCursor art when available.</summary>
|
||||
public sealed class RetailCursorManager
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly object _datLock;
|
||||
private readonly RetailCursorResolver _globalCursors;
|
||||
private readonly Dictionary<uint, RawImage> _imagesBySurface = new();
|
||||
private readonly HashSet<uint> _missingSurfaces = new();
|
||||
private CursorFeedbackKind _lastKind = (CursorFeedbackKind)(-1);
|
||||
private UiCursorMedia _lastCursor;
|
||||
|
||||
public RetailCursorManager(DatCollection dats, object datLock)
|
||||
{
|
||||
_dats = dats;
|
||||
_datLock = datLock;
|
||||
_globalCursors = new RetailCursorResolver(dats, datLock);
|
||||
}
|
||||
|
||||
public void Apply(IEnumerable<IMouse> mice, CursorFeedback feedback)
|
||||
{
|
||||
if (feedback.Cursor.IsValid && TryGetImage(feedback.Cursor.File, out var image))
|
||||
{
|
||||
ApplyCustom(mice, feedback.Cursor, image);
|
||||
_lastKind = feedback.Kind;
|
||||
_lastCursor = feedback.Cursor;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_globalCursors.TryResolve(feedback.Kind, out var globalCursor)
|
||||
&& TryGetImage(globalCursor.File, out var globalImage))
|
||||
{
|
||||
ApplyCustom(mice, globalCursor, globalImage);
|
||||
_lastKind = feedback.Kind;
|
||||
_lastCursor = globalCursor;
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyStandard(mice, StandardCursorFor(feedback.Kind));
|
||||
_lastKind = feedback.Kind;
|
||||
_lastCursor = default;
|
||||
}
|
||||
|
||||
private void ApplyCustom(IEnumerable<IMouse> mice, UiCursorMedia cursorMedia, RawImage image)
|
||||
{
|
||||
if (_lastCursor.Equals(cursorMedia))
|
||||
return;
|
||||
|
||||
foreach (var mouse in mice)
|
||||
{
|
||||
var cursor = mouse.Cursor;
|
||||
cursor.Image = image;
|
||||
cursor.HotspotX = cursorMedia.HotspotX;
|
||||
cursor.HotspotY = cursorMedia.HotspotY;
|
||||
if (cursor.Type != CursorType.Custom)
|
||||
cursor.Type = CursorType.Custom;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStandard(IEnumerable<IMouse> mice, StandardCursor desired)
|
||||
{
|
||||
if (_lastCursor.Equals(default(UiCursorMedia)) && _lastKind != (CursorFeedbackKind)(-1)
|
||||
&& StandardCursorFor(_lastKind) == desired)
|
||||
return;
|
||||
|
||||
foreach (var mouse in mice)
|
||||
{
|
||||
var cursor = mouse.Cursor;
|
||||
var standard = desired;
|
||||
if (!cursor.IsSupported(standard))
|
||||
standard = StandardCursor.Arrow;
|
||||
if (!cursor.IsSupported(standard))
|
||||
continue;
|
||||
|
||||
if (cursor.Type != CursorType.Standard)
|
||||
cursor.Type = CursorType.Standard;
|
||||
if (cursor.StandardCursor != standard)
|
||||
cursor.StandardCursor = standard;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetImage(uint renderSurfaceId, out RawImage image)
|
||||
{
|
||||
if (_imagesBySurface.TryGetValue(renderSurfaceId, out image))
|
||||
return true;
|
||||
if (_missingSurfaces.Contains(renderSurfaceId))
|
||||
return false;
|
||||
|
||||
DecodedTexture? decoded = DecodeCursorSurface(renderSurfaceId);
|
||||
if (decoded is null || decoded.Width <= 0 || decoded.Height <= 0 || decoded.Rgba8.Length == 0)
|
||||
{
|
||||
_missingSurfaces.Add(renderSurfaceId);
|
||||
image = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
image = new RawImage(decoded.Width, decoded.Height, decoded.Rgba8);
|
||||
_imagesBySurface[renderSurfaceId] = image;
|
||||
return true;
|
||||
}
|
||||
|
||||
private DecodedTexture? DecodeCursorSurface(uint renderSurfaceId)
|
||||
{
|
||||
lock (_datLock)
|
||||
{
|
||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
||||
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
||||
return null;
|
||||
|
||||
Palette? palette = rs.DefaultPaletteId != 0
|
||||
? _dats.Get<Palette>(rs.DefaultPaletteId)
|
||||
: null;
|
||||
return SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
||||
}
|
||||
}
|
||||
|
||||
private static StandardCursor StandardCursorFor(CursorFeedbackKind kind)
|
||||
=> kind switch
|
||||
{
|
||||
CursorFeedbackKind.Text => StandardCursor.IBeam,
|
||||
CursorFeedbackKind.WindowMove => StandardCursor.ResizeAll,
|
||||
CursorFeedbackKind.ResizeHorizontal => StandardCursor.HResize,
|
||||
CursorFeedbackKind.ResizeVertical => StandardCursor.VResize,
|
||||
CursorFeedbackKind.ResizeDiagonalNwse => StandardCursor.NwseResize,
|
||||
CursorFeedbackKind.ResizeDiagonalNesw => StandardCursor.NeswResize,
|
||||
CursorFeedbackKind.Drag => StandardCursor.Hand,
|
||||
CursorFeedbackKind.DragAccept => StandardCursor.ResizeAll,
|
||||
CursorFeedbackKind.DragReject => StandardCursor.NotAllowed,
|
||||
CursorFeedbackKind.TargetPending => StandardCursor.Crosshair,
|
||||
CursorFeedbackKind.TargetValid => StandardCursor.ResizeAll,
|
||||
CursorFeedbackKind.TargetInvalid => StandardCursor.NotAllowed,
|
||||
_ => StandardCursor.Arrow,
|
||||
};
|
||||
}
|
||||
76
src/AcDream.App/Rendering/RetailCursorResolver.cs
Normal file
76
src/AcDream.App/Rendering/RetailCursorResolver.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.App.UI;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Resolves retail global cursor enum IDs through the portal EnumIDMap chain.</summary>
|
||||
internal sealed class RetailCursorResolver
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly object _datLock;
|
||||
private readonly Dictionary<uint, uint> _didByEnum = new();
|
||||
private readonly HashSet<uint> _missingEnums = new();
|
||||
|
||||
public RetailCursorResolver(DatCollection dats, object datLock)
|
||||
{
|
||||
_dats = dats;
|
||||
_datLock = datLock;
|
||||
}
|
||||
|
||||
public bool TryResolve(CursorFeedbackKind kind, out UiCursorMedia cursor)
|
||||
{
|
||||
cursor = default;
|
||||
if (!RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec))
|
||||
return false;
|
||||
|
||||
return TryResolve(spec, out cursor);
|
||||
}
|
||||
|
||||
internal bool TryResolve(RetailCursorSpec spec, out UiCursorMedia cursor)
|
||||
{
|
||||
cursor = default;
|
||||
if (!spec.IsValid)
|
||||
return false;
|
||||
|
||||
if (_didByEnum.TryGetValue(spec.EnumId, out uint cachedDid))
|
||||
{
|
||||
cursor = new UiCursorMedia(cachedDid, spec.HotspotX, spec.HotspotY);
|
||||
return true;
|
||||
}
|
||||
if (_missingEnums.Contains(spec.EnumId))
|
||||
return false;
|
||||
|
||||
uint did = ResolveDidByEnum(spec.EnumId);
|
||||
if (did == 0)
|
||||
{
|
||||
_missingEnums.Add(spec.EnumId);
|
||||
return false;
|
||||
}
|
||||
|
||||
cursor = new UiCursorMedia(did, spec.HotspotX, spec.HotspotY);
|
||||
_didByEnum[spec.EnumId] = did;
|
||||
return true;
|
||||
}
|
||||
|
||||
private uint ResolveDidByEnum(uint cursorEnum)
|
||||
{
|
||||
lock (_datLock)
|
||||
{
|
||||
uint masterDid = (uint)_dats.Portal.Header.MasterMapId;
|
||||
if (masterDid == 0)
|
||||
return 0;
|
||||
|
||||
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master) || master is null)
|
||||
return 0;
|
||||
|
||||
if (!master.ClientEnumToID.TryGetValue(RetailCursorCatalog.CursorEnumTable, out uint cursorMapDid))
|
||||
return 0;
|
||||
|
||||
if (!_dats.Portal.TryGet<EnumIDMap>(cursorMapDid, out var cursorMap) || cursorMap is null)
|
||||
return 0;
|
||||
|
||||
return cursorMap.ClientEnumToID.TryGetValue(cursorEnum, out uint did) ? did : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,9 @@ public sealed record RuntimeOptions(
|
|||
bool DumpLiveSpawns,
|
||||
int? LegacyStreamRadius,
|
||||
bool RetailUi,
|
||||
string? AcDir)
|
||||
string? AcDir,
|
||||
bool UiProbeDump,
|
||||
string? UiProbeScript)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -85,13 +87,18 @@ public sealed record RuntimeOptions(
|
|||
// top of the quality preset's radii. Null when unset or invalid.
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
||||
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
||||
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")));
|
||||
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
|
||||
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
||||
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")));
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
public bool HasLiveCredentials =>
|
||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||
|
||||
/// <summary>True when the opt-in retail UI automation probe should be constructed.</summary>
|
||||
public bool UiProbeEnabled => UiProbeDump || !string.IsNullOrEmpty(UiProbeScript);
|
||||
|
||||
private static bool IsExactlyOne(string? s)
|
||||
=> string.Equals(s, "1", StringComparison.Ordinal);
|
||||
|
||||
|
|
|
|||
|
|
@ -122,12 +122,14 @@ public static class SampleData
|
|||
/// synthetic character. Values are plausible retail-scale numbers so the
|
||||
/// report renders with well-proportioned text in all sections.
|
||||
/// </summary>
|
||||
public static CharacterSheet SampleCharacter() => new()
|
||||
public static CharacterSheet SampleCharacter() => SampleCharacter(null);
|
||||
|
||||
public static CharacterSheet SampleCharacter(string? name) => new()
|
||||
{
|
||||
Name = "Studio Player",
|
||||
Name = string.IsNullOrWhiteSpace(name) ? "Studio Player" : name,
|
||||
Level = 126,
|
||||
Race = "Aluvian",
|
||||
Heritage = "Aluvian Heritage",
|
||||
Gender = "Female",
|
||||
Heritage = "Aluvian",
|
||||
Title = "the Adventurer",
|
||||
BirthDate = "January 5, 2001",
|
||||
PlayTime = "2 years, 114 days, 4 hours",
|
||||
|
|
@ -166,6 +168,7 @@ public static class SampleData
|
|||
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
|
||||
// Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
|
||||
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
|
||||
AttributeRaise10Costs = new long[] { 0L, 950L, 1_000L, 0L, 1_100L, 1_050L, 900L, 880L, 1_120L },
|
||||
|
||||
// Real SkillTable icon IDs and train/specialize costs from client_portal.dat
|
||||
// SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time.
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
38
src/AcDream.App/UI/ItemEquipRules.cs
Normal file
38
src/AcDream.App/UI/ItemEquipRules.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
internal static class ItemEquipRules
|
||||
{
|
||||
// Retail CPlayerSystem::AutoWear / gmPaperDollUI inventory mask: 0x08007FFF.
|
||||
public const EquipMask AutoWearMask =
|
||||
EquipMask.HeadWear
|
||||
| EquipMask.ChestWear
|
||||
| EquipMask.AbdomenWear
|
||||
| EquipMask.UpperArmWear
|
||||
| EquipMask.LowerArmWear
|
||||
| EquipMask.HandWear
|
||||
| EquipMask.UpperLegWear
|
||||
| EquipMask.LowerLegWear
|
||||
| EquipMask.FootWear
|
||||
| EquipMask.ChestArmor
|
||||
| EquipMask.AbdomenArmor
|
||||
| EquipMask.UpperArmArmor
|
||||
| EquipMask.LowerArmArmor
|
||||
| EquipMask.UpperLegArmor
|
||||
| EquipMask.LowerLegArmor
|
||||
| EquipMask.Cloak;
|
||||
|
||||
public static bool IsAutoWearItem(ClientObject item)
|
||||
=> (item.ValidLocations & AutoWearMask) != EquipMask.None;
|
||||
|
||||
public static EquipMask ResolvePaperdollDropWieldMask(ClientObject item, EquipMask targetMask)
|
||||
{
|
||||
if ((item.ValidLocations & targetMask) == EquipMask.None)
|
||||
return EquipMask.None;
|
||||
|
||||
return IsAutoWearItem(item)
|
||||
? item.ValidLocations
|
||||
: item.ValidLocations & targetMask;
|
||||
}
|
||||
}
|
||||
383
src/AcDream.App/UI/ItemInteractionController.cs
Normal file
383
src/AcDream.App/UI/ItemInteractionController.cs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
using System;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
|
||||
/// target acquisition, and drag-out drops here instead of duplicating
|
||||
/// ItemHolder::UseObject fragments in each panel.
|
||||
/// </summary>
|
||||
public sealed class ItemInteractionController
|
||||
{
|
||||
private static readonly EquipMask[] AutoEquipOrder =
|
||||
{
|
||||
EquipMask.HeadWear,
|
||||
EquipMask.ChestWear,
|
||||
EquipMask.AbdomenWear,
|
||||
EquipMask.UpperArmWear,
|
||||
EquipMask.LowerArmWear,
|
||||
EquipMask.HandWear,
|
||||
EquipMask.UpperLegWear,
|
||||
EquipMask.LowerLegWear,
|
||||
EquipMask.FootWear,
|
||||
EquipMask.ChestArmor,
|
||||
EquipMask.AbdomenArmor,
|
||||
EquipMask.UpperArmArmor,
|
||||
EquipMask.LowerArmArmor,
|
||||
EquipMask.UpperLegArmor,
|
||||
EquipMask.LowerLegArmor,
|
||||
EquipMask.NeckWear,
|
||||
EquipMask.WristWearLeft,
|
||||
EquipMask.WristWearRight,
|
||||
EquipMask.FingerWearLeft,
|
||||
EquipMask.FingerWearRight,
|
||||
EquipMask.Shield,
|
||||
EquipMask.MissileAmmo,
|
||||
EquipMask.MeleeWeapon,
|
||||
EquipMask.MissileWeapon,
|
||||
EquipMask.Held,
|
||||
EquipMask.TwoHanded,
|
||||
EquipMask.TrinketOne,
|
||||
EquipMask.Cloak,
|
||||
EquipMask.SigilOne,
|
||||
EquipMask.SigilTwo,
|
||||
EquipMask.SigilThree,
|
||||
};
|
||||
|
||||
private const long RetailUseThrottleMs = 200;
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<long> _nowMs;
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint, uint>? _sendUseWithTarget;
|
||||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint>? _sendDrop;
|
||||
private readonly Action<string>? _toast;
|
||||
|
||||
private long _lastUseMs = long.MinValue / 2;
|
||||
|
||||
public ItemInteractionController(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint, uint>? sendUseWithTarget,
|
||||
Action<uint, uint>? sendWield,
|
||||
Action<uint>? sendDrop,
|
||||
Func<long>? nowMs = null,
|
||||
Action<string>? toast = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_sendUse = sendUse;
|
||||
_sendUseWithTarget = sendUseWithTarget;
|
||||
_sendWield = sendWield;
|
||||
_sendDrop = sendDrop;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
}
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
||||
public uint PlayerGuid => _playerGuid();
|
||||
|
||||
public uint PendingSourceItem { get; private set; }
|
||||
|
||||
public bool IsTargetModeActive => PendingSourceItem != 0;
|
||||
|
||||
public bool IsPendingSource(uint itemGuid)
|
||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||
|
||||
public bool IsCurrentTargetCompatible(uint targetGuid)
|
||||
{
|
||||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||
|
||||
var source = _objects.Get(PendingSourceItem);
|
||||
return source?.Useability is { } useability
|
||||
&& TargetCompatible(source, targetGuid, useability);
|
||||
}
|
||||
|
||||
public bool ActivateItem(uint itemGuid)
|
||||
{
|
||||
if (itemGuid == 0) return false;
|
||||
|
||||
if (IsTargetModeActive)
|
||||
return AcquireTarget(itemGuid);
|
||||
|
||||
var item = _objects.Get(itemGuid);
|
||||
if (item is null) return false;
|
||||
|
||||
if (!ConsumeUseThrottle())
|
||||
return true;
|
||||
|
||||
if (item.Useability is { } targetedUse
|
||||
&& ItemUseability.IsTargeted(targetedUse)
|
||||
&& SourceCompatible(item, targetedUse))
|
||||
{
|
||||
EnterTargetMode(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsContainer(item))
|
||||
{
|
||||
_sendUse?.Invoke(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryAutoWield(item))
|
||||
return true;
|
||||
|
||||
if (item.Useability is { } directUse
|
||||
&& ItemUseability.IsDirectUseable(directUse)
|
||||
&& SourceCompatible(item, directUse))
|
||||
{
|
||||
_sendUse?.Invoke(itemGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool AcquireTarget(uint targetGuid)
|
||||
{
|
||||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||
|
||||
uint sourceGuid = PendingSourceItem;
|
||||
ClearTargetMode();
|
||||
|
||||
var source = _objects.Get(sourceGuid);
|
||||
if (source?.Useability is not { } useability)
|
||||
return false;
|
||||
|
||||
if (!TargetCompatible(source, targetGuid, useability))
|
||||
return false;
|
||||
|
||||
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool AcquireSelfTarget()
|
||||
=> IsTargetModeActive && AcquireTarget(_playerGuid());
|
||||
|
||||
public void CancelTargetMode()
|
||||
{
|
||||
if (!IsTargetModeActive) return;
|
||||
ClearTargetMode();
|
||||
}
|
||||
|
||||
public bool DropToWorld(ItemDragPayload payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return false;
|
||||
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null)
|
||||
return false;
|
||||
|
||||
_objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1);
|
||||
_sendDrop?.Invoke(payload.ObjId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnterTargetMode(uint sourceGuid)
|
||||
{
|
||||
PendingSourceItem = sourceGuid;
|
||||
StateChanged?.Invoke();
|
||||
var name = _objects.Get(sourceGuid)?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
_toast?.Invoke($"Choose a target for the {name}");
|
||||
}
|
||||
|
||||
private void ClearTargetMode()
|
||||
{
|
||||
PendingSourceItem = 0;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private bool ConsumeUseThrottle()
|
||||
{
|
||||
long now = _nowMs();
|
||||
if (now - _lastUseMs < RetailUseThrottleMs)
|
||||
return false;
|
||||
_lastUseMs = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryAutoWield(ClientObject item)
|
||||
{
|
||||
if (item.ValidLocations == EquipMask.None
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
return false;
|
||||
|
||||
EquipMask mask = BestAvailableEquipMask(item);
|
||||
if (mask == EquipMask.None)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||
return false;
|
||||
|
||||
_sendWield?.Invoke(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
}
|
||||
|
||||
private EquipMask BestAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
if (ItemEquipRules.IsAutoWearItem(item))
|
||||
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
|
||||
|
||||
return FirstAvailableEquipMask(item);
|
||||
}
|
||||
|
||||
private bool AutoWearIsLegal(ClientObject item)
|
||||
{
|
||||
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
|
||||
if ((item.Priority & priorityMask) == 0)
|
||||
return true;
|
||||
|
||||
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations;
|
||||
return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null;
|
||||
}
|
||||
|
||||
private EquipMask FirstAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
foreach (var mask in AutoEquipOrder)
|
||||
{
|
||||
if ((item.ValidLocations & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (!EquipMaskOccupied(mask, item.ObjectId))
|
||||
return mask;
|
||||
}
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
|
||||
{
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
|
||||
{
|
||||
uint mask = 0;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.Priority;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
|
||||
{
|
||||
EquipMask mask = EquipMask.None;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.CurrentlyEquippedLocation;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
|
||||
{
|
||||
if (locationMask == EquipMask.None)
|
||||
return null;
|
||||
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if (!IsEquippedByPlayer(o))
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
|
||||
continue;
|
||||
if ((o.Priority & priority) != 0 || priority == 0)
|
||||
return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsContainer(ClientObject item)
|
||||
=> item.ContainerTypeHint != 0
|
||||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|| item.ItemsCapacity > 0;
|
||||
|
||||
private bool SourceCompatible(ClientObject source, uint useability)
|
||||
{
|
||||
uint flags = ItemUseability.SourceFlags(useability);
|
||||
if ((flags & ItemUseability.Remote) != 0) return true;
|
||||
if ((flags & ItemUseability.Viewed) != 0) return true;
|
||||
if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true;
|
||||
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true;
|
||||
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability)
|
||||
{
|
||||
uint flags = ItemUseability.TargetFlags(useability);
|
||||
uint player = _playerGuid();
|
||||
|
||||
if (targetGuid == source.ObjectId && (flags & ItemUseability.ObjSelf) != 0)
|
||||
return true;
|
||||
|
||||
if (targetGuid == player)
|
||||
return (flags & ItemUseability.Self) != 0;
|
||||
|
||||
var target = _objects.Get(targetGuid);
|
||||
if (target is null)
|
||||
return (flags & ItemUseability.Remote) != 0;
|
||||
|
||||
if (source.TargetType is { } targetType && targetType != 0
|
||||
&& ((uint)target.Type & targetType) == 0)
|
||||
return false;
|
||||
|
||||
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(target)) return true;
|
||||
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(target)) return true;
|
||||
if ((flags & ItemUseability.Viewed) != 0 && IsCarriedByPlayer(target)) return true;
|
||||
if ((flags & ItemUseability.Remote) != 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsWieldedByPlayer(ClientObject item)
|
||||
=> IsEquippedByPlayer(item);
|
||||
|
||||
private bool IsEquippedByPlayer(ClientObject item)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
return item.CurrentlyEquippedLocation != EquipMask.None
|
||||
&& (item.WielderId == player || item.ContainerId == player);
|
||||
}
|
||||
|
||||
private bool IsCarriedByPlayer(ClientObject item)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
uint container = item.ContainerId;
|
||||
for (int hops = 0; container != 0 && hops < 8; hops++)
|
||||
{
|
||||
if (container == player) return true;
|
||||
container = _objects.Get(container)?.ContainerId ?? 0u;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
68
src/AcDream.App/UI/Layout/CharacterIdentityText.cs
Normal file
68
src/AcDream.App/UI/Layout/CharacterIdentityText.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retail character identity display helpers for gmStatManagementUI.
|
||||
/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls
|
||||
/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0),
|
||||
/// then appends the current CharacterTitleTable title when one is active.
|
||||
/// </summary>
|
||||
internal static class CharacterIdentityText
|
||||
{
|
||||
public const uint GenderPropertyId = 0x71u;
|
||||
public const uint HeritageGroupPropertyId = 0xBCu;
|
||||
|
||||
public static string StatHeaderLine(CharacterSheet sheet)
|
||||
{
|
||||
string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage)
|
||||
? sheet.Heritage
|
||||
: sheet.Race;
|
||||
|
||||
string? title = StripLeadingArticle(sheet.Title);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sheet.Gender))
|
||||
return Join(heritage, title);
|
||||
return Join(sheet.Gender, heritage, title);
|
||||
}
|
||||
|
||||
public static string? GenderDisplayName(int gender) => gender switch
|
||||
{
|
||||
1 => "Male",
|
||||
2 => "Female",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch
|
||||
{
|
||||
1 => "Aluvian",
|
||||
2 => "Gharu'ndim",
|
||||
3 => "Sho",
|
||||
4 => "Viamontian",
|
||||
5 => "Umbraen",
|
||||
6 => "Gearknight",
|
||||
7 => "Tumerok",
|
||||
8 => "Lugian",
|
||||
9 => "Empyrean",
|
||||
10 => "Penumbraen",
|
||||
11 => "Undead",
|
||||
12 => "Olthoi",
|
||||
13 => "Olthoi",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string Join(params string?[] parts)
|
||||
{
|
||||
return string.Join(" ", parts
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p!.Trim()));
|
||||
}
|
||||
|
||||
private static string? StripLeadingArticle(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return null;
|
||||
|
||||
string trimmed = title.Trim();
|
||||
return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase)
|
||||
? trimmed[4..]
|
||||
: trimmed;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,10 +26,13 @@ public sealed class CharacterSheet
|
|||
/// <summary>Character level.</summary>
|
||||
public int Level { get; init; }
|
||||
|
||||
/// <summary>Gender display string, e.g. "Female". Null = omit.</summary>
|
||||
public string? Gender { get; init; }
|
||||
|
||||
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
|
||||
public string? Race { get; init; }
|
||||
|
||||
/// <summary>Heritage string, e.g. "Aluvian Heritage". Null = omit.</summary>
|
||||
/// <summary>Heritage group display string, e.g. "Aluvian". Null = omit.</summary>
|
||||
public string? Heritage { get; init; }
|
||||
|
||||
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
|
||||
|
|
@ -108,11 +111,11 @@ public sealed class CharacterSheet
|
|||
public long UnassignedXp { get; init; }
|
||||
|
||||
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
|
||||
// Retail formula: ExperienceToAttributeLevel(value + 1) − ExperienceToAttributeLevel(value).
|
||||
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
|
||||
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
|
||||
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
|
||||
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
|
||||
// Retail formula for x1: ExperienceToAttributeLevel(value + 1) − xpSpent.
|
||||
// Retail formula for x10: ExperienceToAttributeLevel(value + min(10, remaining)) − xpSpent.
|
||||
// Cost 0 means the attribute is at max or not trainable. Ordered to match AttrRows:
|
||||
// Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana.
|
||||
// Source: gmAttributeUI::GetCostToRaise/GetCostToRaise10 (0x0049cb80/0x0049cc70).
|
||||
|
||||
/// <summary>
|
||||
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
|
||||
|
|
@ -122,6 +125,12 @@ public sealed class CharacterSheet
|
|||
/// </summary>
|
||||
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>
|
||||
/// XP cost to raise each of the 9 attributes/vitals by up to 10 retail steps,
|
||||
/// in the same display order as <see cref="AttributeRaiseCosts"/>.
|
||||
/// </summary>
|
||||
public long[] AttributeRaise10Costs { get; init; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>
|
||||
/// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by
|
||||
/// advancement class, then sorts each group alphabetically by skill name.
|
||||
|
|
|
|||
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal file
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
|
||||
/// window and owns the raise-request flow (wire send + optimistic local
|
||||
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
|
||||
/// assembly + XP-curve math is feature logic, not wiring).
|
||||
///
|
||||
/// <para>Retail property ids and the decomp anchors for every field are
|
||||
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
|
||||
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
|
||||
///
|
||||
/// <para><b>State ownership:</b> optimistic debits go through the owning
|
||||
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
|
||||
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
|
||||
/// when the player object is in the table, else
|
||||
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
|
||||
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
|
||||
/// Never write the raw property dictionaries from UI code. The next server
|
||||
/// snapshot remains authoritative over every optimistic value.</para>
|
||||
/// </summary>
|
||||
public sealed class CharacterSheetProvider
|
||||
{
|
||||
/// <summary>PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp.</summary>
|
||||
private const uint UnassignedXpPropertyId = 2u;
|
||||
|
||||
/// <summary>
|
||||
/// Skill-credit debit fallback chain, most-specific first. Retail reads
|
||||
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
|
||||
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
|
||||
/// the live server population varies, so debit whichever is present.
|
||||
/// </summary>
|
||||
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly LocalPlayerState _localPlayer;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<string?>? _activeToonName;
|
||||
private readonly Func<string, CharacterSheet>? _fallbackSheet;
|
||||
private readonly Func<bool>? _canSendRaise;
|
||||
private readonly Action<uint, ulong>? _sendRaiseAttribute;
|
||||
private readonly Action<uint, ulong>? _sendRaiseVital;
|
||||
private readonly Action<uint, ulong>? _sendRaiseSkill;
|
||||
private readonly Action<uint, uint>? _sendTrainSkill;
|
||||
|
||||
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
|
||||
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
|
||||
|
||||
/// <summary>Portal ExperienceTable (0x0E000018) — set by the host once dats load.</summary>
|
||||
public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; }
|
||||
|
||||
public CharacterSheetProvider(
|
||||
ClientObjectTable objects,
|
||||
LocalPlayerState localPlayer,
|
||||
Func<uint> playerGuid,
|
||||
Func<string?>? activeToonName = null,
|
||||
Func<string, CharacterSheet>? fallbackSheet = null,
|
||||
Func<bool>? canSendRaise = null,
|
||||
Action<uint, ulong>? sendRaiseAttribute = null,
|
||||
Action<uint, ulong>? sendRaiseVital = null,
|
||||
Action<uint, ulong>? sendRaiseSkill = null,
|
||||
Action<uint, uint>? sendTrainSkill = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_activeToonName = activeToonName;
|
||||
_fallbackSheet = fallbackSheet;
|
||||
_canSendRaise = canSendRaise;
|
||||
_sendRaiseAttribute = sendRaiseAttribute;
|
||||
_sendRaiseVital = sendRaiseVital;
|
||||
_sendRaiseSkill = sendRaiseSkill;
|
||||
_sendTrainSkill = sendTrainSkill;
|
||||
}
|
||||
|
||||
// ── Sheet assembly ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
|
||||
public string CharacterName()
|
||||
{
|
||||
string? toon = _activeToonName?.Invoke();
|
||||
if (!string.IsNullOrWhiteSpace(toon) && toon != "default")
|
||||
return toon;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName)
|
||||
return objectName;
|
||||
return "Player";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the sheet from live state; falls back to the injected sample
|
||||
/// sheet (Studio data) until any live character data has arrived.
|
||||
/// Called per repaint by the Character window's bound widgets.
|
||||
/// </summary>
|
||||
public CharacterSheet BuildSheet()
|
||||
{
|
||||
if (!HasLiveData())
|
||||
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
|
||||
|
||||
var props = CurrentPlayerProperties();
|
||||
int level = props.GetInt(0x19u);
|
||||
long totalXp = props.GetInt64(1u);
|
||||
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
|
||||
var xp = ComputeLevelXp(level, totalXp);
|
||||
|
||||
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
|
||||
|
||||
return new CharacterSheet
|
||||
{
|
||||
Name = CharacterName(),
|
||||
Level = level,
|
||||
Gender = CharacterIdentityText.GenderDisplayName(
|
||||
props.GetInt(CharacterIdentityText.GenderPropertyId)),
|
||||
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
|
||||
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
|
||||
PkStatus = PkStatusText(props.GetInt(134u, 0)),
|
||||
TotalXp = totalXp,
|
||||
XpToNextLevel = xp.toNext,
|
||||
XpFraction = xp.fraction,
|
||||
|
||||
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
|
||||
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
|
||||
StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina),
|
||||
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
|
||||
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
|
||||
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
|
||||
|
||||
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
|
||||
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
|
||||
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
|
||||
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
|
||||
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
|
||||
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
|
||||
|
||||
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
|
||||
SpecializedSkillCredits = props.GetInt(0xC0u),
|
||||
SkillCredits = skillCredits,
|
||||
UnassignedXp = unassignedXp,
|
||||
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
|
||||
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
|
||||
Skills = BuildLiveCharacterSkills(),
|
||||
BurdenCurrent = props.GetInt(5u),
|
||||
BurdenMax = props.GetInt(96u),
|
||||
};
|
||||
}
|
||||
|
||||
private bool HasLiveData()
|
||||
{
|
||||
var props = CurrentPlayerProperties();
|
||||
return props.Ints.Count > 0
|
||||
|| props.Int64s.Count > 0
|
||||
|| _localPlayer.Skills.Count > 0
|
||||
|| _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null
|
||||
|| _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null;
|
||||
}
|
||||
|
||||
/// <summary>The player's canonical bundle: the live ClientObject's when the
|
||||
/// player object has arrived (CreateObject merge-upsert), else the
|
||||
/// PlayerDescription snapshot on LocalPlayerState.</summary>
|
||||
private PropertyBundle CurrentPlayerProperties()
|
||||
{
|
||||
uint guid = _playerGuid();
|
||||
return guid != 0u && _objects.Get(guid) is { } player
|
||||
? player.Properties
|
||||
: _localPlayer.Properties;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the portal ExperienceTable (0x0E000018), falling back to a
|
||||
/// type scan for older or odd dat collections. Failures are logged —
|
||||
/// never silently swallowed — and leave raise costs unavailable (0).
|
||||
/// </summary>
|
||||
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
|
||||
DatCollection dats, Action<string>? log = null)
|
||||
{
|
||||
if (dats is null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(0x0E000018u);
|
||||
if (table is not null) return table;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (uint id in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.ExperienceTable>())
|
||||
{
|
||||
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(id);
|
||||
if (table is not null) return table;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>XP still needed for the next level + fill fraction of the
|
||||
/// current level band (retail (cur−base)/(cap−base); CharacterSheet.XpFraction).</summary>
|
||||
private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp)
|
||||
{
|
||||
var levels = ExperienceTable?.Levels;
|
||||
if (levels is null || level < 0 || level + 1 >= levels.Length)
|
||||
return (0L, 0f);
|
||||
|
||||
long current = ClampToLong(levels[level]);
|
||||
long next = ClampToLong(levels[level + 1]);
|
||||
if (next <= current) return (0L, 0f);
|
||||
|
||||
long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp;
|
||||
long toNext = next - clampedXp;
|
||||
float fraction = (float)(clampedXp - current) / (next - current);
|
||||
return (toNext, fraction);
|
||||
}
|
||||
|
||||
/// <summary>Per-attribute/vital raise costs in retail display order
|
||||
/// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown.</summary>
|
||||
private long[] BuildAttributeRaiseCosts(int amount)
|
||||
{
|
||||
var xp = ExperienceTable;
|
||||
return new[]
|
||||
{
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus),
|
||||
AttributeRaiseCost(LocalPlayerState.AttributeKind.Self),
|
||||
VitalRaiseCost(LocalPlayerState.VitalKind.Health),
|
||||
VitalRaiseCost(LocalPlayerState.VitalKind.Stamina),
|
||||
VitalRaiseCost(LocalPlayerState.VitalKind.Mana),
|
||||
};
|
||||
|
||||
long AttributeRaiseCost(LocalPlayerState.AttributeKind kind)
|
||||
{
|
||||
var attr = _localPlayer.GetAttribute(kind);
|
||||
return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount);
|
||||
}
|
||||
|
||||
long VitalRaiseCost(LocalPlayerState.VitalKind kind)
|
||||
{
|
||||
var vital = _localPlayer.Get(kind);
|
||||
return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
|
||||
{
|
||||
var result = new List<CharacterSkill>();
|
||||
var skillTable = SkillTable;
|
||||
var xp = ExperienceTable;
|
||||
|
||||
foreach (var snapshot in _localPlayer.Skills.Values)
|
||||
{
|
||||
var advancement = AdvancementFromStatus(snapshot.Status);
|
||||
if (advancement == CharacterSkillAdvancementClass.Inactive)
|
||||
continue;
|
||||
|
||||
DatReaderWriter.Types.SkillBase? skillBase = null;
|
||||
if (skillTable?.Skills is not null)
|
||||
skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase);
|
||||
|
||||
string? name = skillBase?.Name.Value;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = $"Skill {snapshot.SkillId}";
|
||||
|
||||
uint icon = skillBase?.IconId.DataId ?? 0u;
|
||||
int trainedCost = skillBase?.TrainedCost ?? 0;
|
||||
int specializedCost = skillBase?.SpecializedCost ?? 0;
|
||||
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
|
||||
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
|
||||
|
||||
result.Add(new CharacterSkill(
|
||||
snapshot.SkillId,
|
||||
name,
|
||||
icon,
|
||||
advancement,
|
||||
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
|
||||
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
|
||||
IsUsableUntrained(snapshot.SkillId),
|
||||
trainedCost,
|
||||
specializedCost,
|
||||
raiseCost,
|
||||
raise10Cost));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch
|
||||
{
|
||||
1u => CharacterSkillAdvancementClass.Untrained,
|
||||
2u => CharacterSkillAdvancementClass.Trained,
|
||||
3u => CharacterSkillAdvancementClass.Specialized,
|
||||
_ => CharacterSkillAdvancementClass.Inactive,
|
||||
};
|
||||
|
||||
private static bool IsUsableUntrained(uint skillId) => skillId switch
|
||||
{
|
||||
18u or 37u or 38u or 39u or 40u => false,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private static long SkillRaiseCost(
|
||||
DatReaderWriter.DBObjs.ExperienceTable? xp,
|
||||
CharacterSkillAdvancementClass advancement,
|
||||
LocalPlayerState.SkillSnapshot skill,
|
||||
int amount)
|
||||
{
|
||||
if (xp is null) return 0L;
|
||||
uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized
|
||||
? xp.SpecializedSkills
|
||||
: xp.TrainedSkills;
|
||||
return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount);
|
||||
}
|
||||
|
||||
/// <summary>Cost to advance <paramref name="amount"/> ranks along a retail
|
||||
/// cumulative-XP curve: curve[target] − xpAlreadySpent, clamped at the
|
||||
/// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70).</summary>
|
||||
private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount)
|
||||
{
|
||||
if (curve is null || amount <= 0) return 0L;
|
||||
long maxIndex = curve.Length - 1L;
|
||||
if (maxIndex <= ranks) return 0L;
|
||||
long targetLong = Math.Min((long)ranks + amount, maxIndex);
|
||||
long targetXp = curve[(int)targetLong];
|
||||
long cost = targetXp - spentXp;
|
||||
return cost > 0 ? cost : 0L;
|
||||
}
|
||||
|
||||
private static long ClampToLong(ulong value) =>
|
||||
value > long.MaxValue ? long.MaxValue : (long)value;
|
||||
|
||||
private static string? PkStatusText(int status) => status switch
|
||||
{
|
||||
0x2 => "Non-Player Killer",
|
||||
0x4 => "Player Killer",
|
||||
0x40 => "Player Killer Lite",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
|
||||
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
|
||||
|
||||
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
|
||||
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;
|
||||
|
||||
private int VitalMax(LocalPlayerState.VitalKind kind) =>
|
||||
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
|
||||
|
||||
// ── Raise-request flow ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Send a raise/train action to the server and, when a send delegate
|
||||
/// fired, optimistically apply the local effect so the sheet stays
|
||||
/// current during the round trip. The next server snapshot remains
|
||||
/// authoritative (a rejected raise is corrected by the property echo —
|
||||
/// pending/rollback ledger tracked as a follow-up issue).
|
||||
/// </summary>
|
||||
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
if (request.Cost <= 0) return;
|
||||
if (_canSendRaise is not null && !_canSendRaise()) return;
|
||||
|
||||
bool sent = false;
|
||||
switch (request.Kind)
|
||||
{
|
||||
case CharacterStatController.RaiseTargetKind.Attribute:
|
||||
if (_sendRaiseAttribute is not null)
|
||||
{
|
||||
_sendRaiseAttribute(request.StatId, (ulong)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Vital:
|
||||
if (_sendRaiseVital is not null)
|
||||
{
|
||||
_sendRaiseVital(request.StatId, (ulong)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Skill:
|
||||
if (_sendRaiseSkill is not null)
|
||||
{
|
||||
_sendRaiseSkill(request.StatId, (ulong)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.TrainSkill:
|
||||
if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue)
|
||||
{
|
||||
_sendTrainSkill(request.StatId, (uint)request.Cost);
|
||||
sent = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (sent)
|
||||
ApplyLocalRaise(request);
|
||||
}
|
||||
|
||||
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
|
||||
{
|
||||
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
|
||||
ulong cost = (ulong)request.Cost;
|
||||
|
||||
switch (request.Kind)
|
||||
{
|
||||
case CharacterStatController.RaiseTargetKind.Attribute:
|
||||
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Vital:
|
||||
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.Skill:
|
||||
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
|
||||
SpendUnassignedExperience(request.Cost);
|
||||
break;
|
||||
case CharacterStatController.RaiseTargetKind.TrainSkill:
|
||||
if (_localPlayer.ApplySkillTraining(request.StatId))
|
||||
SpendSkillCredits(request.Cost);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SpendUnassignedExperience(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
|
||||
if (current <= 0) return;
|
||||
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
|
||||
current > cost ? current - cost : 0L);
|
||||
return;
|
||||
}
|
||||
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
|
||||
}
|
||||
|
||||
private void SpendSkillCredits(long cost)
|
||||
{
|
||||
if (cost <= 0) return;
|
||||
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
|
||||
uint guid = _playerGuid();
|
||||
if (guid != 0u && _objects.Get(guid) is { } player)
|
||||
{
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
|
||||
continue;
|
||||
_objects.UpdateIntProperty(guid, propertyId,
|
||||
current > debit ? current - debit : 0);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
foreach (uint propertyId in SkillCreditPropertyIds)
|
||||
{
|
||||
if (_localPlayer.DebitIntProperty(propertyId, debit))
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -209,6 +209,12 @@ public sealed class ChatWindowController
|
|||
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||
c.Transcript.DatFont = datFont;
|
||||
c.Transcript.Font = debugFont;
|
||||
// The imported Type-12 element may inherit HJustify=Center from its dat
|
||||
// prototype, which makes UiText use the static one-line label path. The
|
||||
// chat transcript must always use the scrollable multi-line path so it
|
||||
// caches layout for mouse selection and Ctrl+C.
|
||||
c.Transcript.Centered = false;
|
||||
c.Transcript.RightAligned = false;
|
||||
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ public static class DatWidgetFactory
|
|||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
};
|
||||
|
||||
e.DatElementId = info.Id;
|
||||
e.SetStateCursors(info.StateCursors);
|
||||
|
||||
// Propagate position + size (pixel-exact from the dat).
|
||||
e.Left = info.X;
|
||||
e.Top = info.Y;
|
||||
|
|
@ -151,6 +154,7 @@ public static class DatWidgetFactory
|
|||
{
|
||||
var m = new UiMeter
|
||||
{
|
||||
ElementId = info.Id,
|
||||
SpriteResolve = resolve,
|
||||
DatFont = datFont,
|
||||
};
|
||||
|
|
@ -290,6 +294,7 @@ public static class DatWidgetFactory
|
|||
|
||||
var t = new UiText
|
||||
{
|
||||
ElementId = info.Id,
|
||||
BackgroundSprite = bg,
|
||||
SpriteResolve = resolve,
|
||||
Centered = centered,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,12 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
||||
|
||||
/// <summary>
|
||||
/// Cursor per state: state name to (RenderSurface file id, hotspot).
|
||||
/// The "" key represents DirectState, mirroring <see cref="StateMedia"/>.
|
||||
/// </summary>
|
||||
public Dictionary<string, UiCursorMedia> StateCursors = new();
|
||||
|
||||
/// <summary>
|
||||
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
|
||||
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
|
||||
|
|
@ -160,8 +166,8 @@ public static class ElementReader
|
|||
/// (derived placement, not the base prototype's geometry).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
|
||||
/// entries override (or add) per state name key.
|
||||
/// <see cref="ElementInfo.StateMedia"/> and <see cref="ElementInfo.StateCursors"/>:
|
||||
/// base entries are the default; derived entries override (or add) per state name key.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
||||
|
|
@ -218,6 +224,9 @@ public static class ElementReader
|
|||
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
|
||||
foreach (var kv in derived.StateMedia)
|
||||
m.StateMedia[kv.Key] = kv.Value;
|
||||
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
|
||||
foreach (var kv in derived.StateCursors)
|
||||
m.StateCursors[kv.Key] = kv.Value;
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
|
||||
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
|
||||
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
|
||||
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
|
||||
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
|
||||
|
||||
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
|
||||
|
|
@ -45,6 +46,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Func<int?> _strength;
|
||||
private readonly Func<string>? _ownerName;
|
||||
|
||||
private readonly UiItemList? _contentsGrid;
|
||||
private readonly UiItemList? _containerList;
|
||||
|
|
@ -60,6 +62,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
|
||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||
private const uint EncumbranceValProperty = 5u; // total carried burden
|
||||
|
|
@ -71,21 +74,28 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
Func<string>? ownerName,
|
||||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite,
|
||||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer)
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
ItemInteractionController? itemInteraction,
|
||||
Action? onClose)
|
||||
{
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_iconIds = iconIds;
|
||||
_strength = strength;
|
||||
_ownerName = ownerName;
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_itemInteraction = itemInteraction;
|
||||
|
||||
WindowChromeController.BindCloseButton(layout, onClose);
|
||||
|
||||
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
||||
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
||||
|
|
@ -142,6 +152,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
// Captions: drive each host UiText directly with the known string (the caption
|
||||
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
|
||||
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.)
|
||||
AttachCaption(layout.FindElement(TitleTextId), () => "Inventory of " + OwnerName(), datFont);
|
||||
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
|
||||
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
|
||||
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
|
||||
|
|
@ -151,6 +162,8 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
|
||||
Populate();
|
||||
}
|
||||
|
|
@ -162,19 +175,23 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
UiDatFont? datFont,
|
||||
Func<string>? ownerName = null,
|
||||
uint contentsEmptySprite = 0u,
|
||||
uint sideBagEmptySprite = 0u,
|
||||
uint mainPackEmptySprite = 0u,
|
||||
Action<uint>? sendUse = null,
|
||||
Action<uint>? sendNoLongerViewing = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action? onClose = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer);
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
|
||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
|
|
@ -200,7 +217,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
bool isBag = IsBag(item);
|
||||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +227,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
bool isBag = IsBag(item);
|
||||
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +265,12 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||||
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
|
||||
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
|
||||
main.Clicked = () => OpenContainer(p);
|
||||
main.Clicked = () =>
|
||||
{
|
||||
if (_itemInteraction?.AcquireSelfTarget() == true) return;
|
||||
OpenContainer(p);
|
||||
};
|
||||
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
|
||||
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
||||
_topContainer.AddItem(main);
|
||||
}
|
||||
|
|
@ -261,6 +283,11 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default
|
||||
/// sentinel is 0; once the main pack is explicitly opened, <c>_openContainer</c> holds the player
|
||||
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
|
||||
private static bool IsBag(ClientObject item) =>
|
||||
item.ContainerTypeHint != 0u
|
||||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|| item.ItemsCapacity > 0;
|
||||
|
||||
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
||||
|
||||
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
|
||||
|
|
@ -276,6 +303,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
||||
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
|
||||
cell.DragRejectSprite = 0x060011F8u; // red circle
|
||||
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
|
||||
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
|
||||
else cell.Clicked = () => SelectItem(guid);
|
||||
list.AddItem(cell);
|
||||
|
|
@ -405,7 +433,8 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
var cell = list.GetItem(i);
|
||||
if (cell is null) continue;
|
||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
|
||||
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
|
||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource;
|
||||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||||
}
|
||||
}
|
||||
|
|
@ -417,6 +446,12 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
|
||||
}
|
||||
|
||||
private string OwnerName()
|
||||
{
|
||||
var name = _ownerName?.Invoke();
|
||||
return string.IsNullOrWhiteSpace(name) ? "Player" : name;
|
||||
}
|
||||
|
||||
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
|
||||
{
|
||||
if (host is null) return;
|
||||
|
|
@ -487,5 +522,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -394,19 +394,27 @@ public static class LayoutImporter
|
|||
|
||||
/// <summary>
|
||||
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
|
||||
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
|
||||
/// <c>info.StateMedia[name]</c>, read any <see cref="MediaDescCursor"/> into
|
||||
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
|
||||
/// </summary>
|
||||
private static void ReadState(StateDesc sd, string name, ElementInfo info)
|
||||
{
|
||||
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
|
||||
// are intentionally skipped — cursor behavior is Plan 2.
|
||||
bool imageRead = false;
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
if (m is MediaDescImage img && img.File != 0)
|
||||
if (!imageRead && m is MediaDescImage img && img.File != 0)
|
||||
{
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
break;
|
||||
imageRead = true;
|
||||
}
|
||||
|
||||
if (m is MediaDescCursor cursor && cursor.File != 0)
|
||||
{
|
||||
info.StateCursors[name] = new UiCursorMedia(
|
||||
cursor.File,
|
||||
checked((int)cursor.XHotspot),
|
||||
checked((int)cursor.YHotspot));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
|
||||
|
||||
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -85,9 +86,10 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
private PaperdollController(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
||||
uint emptySlotSprite, UiDatFont? datFont)
|
||||
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
|
||||
{
|
||||
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
||||
_itemInteraction = itemInteraction;
|
||||
|
||||
for (int i = 0; i < SlotMap.Length; i++)
|
||||
{
|
||||
|
|
@ -97,6 +99,13 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
list.Cell.SourceKind = ItemDragSource.Equipment;
|
||||
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
||||
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
||||
list.Cell.UseTargetGuidProvider = _playerGuid;
|
||||
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
|
||||
list.Cell.DoubleClicked = () =>
|
||||
{
|
||||
if (list.Cell.ItemId != 0)
|
||||
_itemInteraction?.ActivateItem(list.Cell.ItemId);
|
||||
};
|
||||
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
|
||||
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
|
||||
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
|
||||
|
|
@ -140,8 +149,9 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
public static PaperdollController Bind(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
|
||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null)
|
||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont);
|
||||
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
|
||||
ItemInteractionController? itemInteraction = null)
|
||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
|
|
@ -209,7 +219,7 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return;
|
||||
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
|
||||
EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList));
|
||||
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
|
||||
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
|
||||
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
|
||||
|
|
|
|||
108
src/AcDream.App/UI/Layout/RetailWindowFrame.cs
Normal file
108
src/AcDream.App/UI/Layout/RetailWindowFrame.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -54,8 +54,15 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
private static readonly uint[] CombatIndicatorIds =
|
||||
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
|
||||
|
||||
private const uint CharacterButtonId = 0x10000199u;
|
||||
private const uint InventoryButtonId = 0x100001B1u;
|
||||
private const string ButtonClosedState = "Normal";
|
||||
private const string ButtonOpenState = "Highlight";
|
||||
|
||||
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
|
||||
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
||||
private readonly UiButton? _characterButton;
|
||||
private readonly UiButton? _inventoryButton;
|
||||
private readonly ClientObjectTable _repo;
|
||||
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||
|
|
@ -64,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
private bool _storeLoaded;
|
||||
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
|
||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
||||
|
|
@ -87,6 +95,7 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
uint[]? peaceDigits,
|
||||
uint[]? warDigits,
|
||||
uint[]? emptyDigits,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null)
|
||||
{
|
||||
|
|
@ -97,6 +106,7 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
_peaceDigits = peaceDigits;
|
||||
_warDigits = warDigits;
|
||||
_emptyDigits = emptyDigits;
|
||||
_itemInteraction = itemInteraction;
|
||||
_sendAddShortcut = sendAddShortcut;
|
||||
_sendRemoveShortcut = sendRemoveShortcut;
|
||||
|
||||
|
|
@ -120,6 +130,9 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
for (int i = 0; i < CombatIndicatorIds.Length; i++)
|
||||
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
|
||||
|
||||
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
|
||||
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
|
||||
|
||||
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
|
||||
foreach (var id in HiddenIds)
|
||||
if (layout.FindElement(id) is { } e) e.Visible = false;
|
||||
|
|
@ -201,16 +214,44 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
uint[]? peaceDigits = null,
|
||||
uint[]? warDigits = null,
|
||||
uint[]? emptyDigits = null,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null)
|
||||
{
|
||||
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
|
||||
peaceDigits, warDigits, emptyDigits,
|
||||
peaceDigits, warDigits, emptyDigits, itemInteraction,
|
||||
sendAddShortcut, sendRemoveShortcut);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>Wire the retail status-bar launch buttons to top-level window toggles.</summary>
|
||||
public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter)
|
||||
{
|
||||
if (_inventoryButton is not null)
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
_inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid;
|
||||
_inventoryButton.OnClick = () =>
|
||||
{
|
||||
if (_itemInteraction?.AcquireSelfTarget() == true) return;
|
||||
toggleInventory?.Invoke();
|
||||
};
|
||||
}
|
||||
if (_characterButton is not null)
|
||||
_characterButton.OnClick = toggleCharacter;
|
||||
}
|
||||
|
||||
public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open);
|
||||
|
||||
public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open);
|
||||
|
||||
private static void SetButtonOpen(UiButton? button, bool open)
|
||||
{
|
||||
if (button is not null)
|
||||
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
|
||||
/// each shortcut entry that has a resolved item in the repository.
|
||||
|
|
@ -320,7 +361,12 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
list.Cell.Clicked = () =>
|
||||
{
|
||||
if (list.Cell.ItemId != 0)
|
||||
_useItem(list.Cell.ItemId);
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.ActivateItem(list.Cell.ItemId);
|
||||
else
|
||||
_useItem(list.Cell.ItemId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ public sealed class UiDatElement : UiElement
|
|||
/// Falls back to DirectState if the named state is absent.</summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
public override string ActiveCursorStateName => ActiveState;
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
|
|
|
|||
54
src/AcDream.App/UI/Layout/WindowChromeController.cs
Normal file
54
src/AcDream.App/UI/Layout/WindowChromeController.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Shared bindings for imported retail window chrome. The same max/min button id is
|
||||
/// reused by several LayoutDesc windows; per-window controllers decide what the click
|
||||
/// means for their window.
|
||||
/// </summary>
|
||||
internal static class WindowChromeController
|
||||
{
|
||||
public const uint MaxMinButtonId = 0x1000046Fu;
|
||||
public const uint InventoryCloseButtonId = 0x100001D2u;
|
||||
public const uint CharacterCloseButtonId = 0x1000022Au;
|
||||
|
||||
public static int BindCloseButton(ImportedLayout layout, Action? onClose)
|
||||
{
|
||||
if (onClose is null)
|
||||
return 0;
|
||||
|
||||
return BindCloseButton(layout.Root, onClose);
|
||||
}
|
||||
|
||||
private static int BindCloseButton(UiElement node, Action onClose)
|
||||
{
|
||||
int count = TryBindCloseButton(node, onClose) ? 1 : 0;
|
||||
foreach (var child in node.Children)
|
||||
count += BindCloseButton(child, onClose);
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool TryBindCloseButton(UiElement element, Action onClose)
|
||||
{
|
||||
switch (element)
|
||||
{
|
||||
case UiButton button when IsCloseButtonId(button.ElementId):
|
||||
button.OnClick = onClose;
|
||||
return true;
|
||||
|
||||
case UiDatElement datElement when IsCloseButtonId(datElement.ElementId):
|
||||
datElement.ClickThrough = false;
|
||||
datElement.CapturesPointerDrag = true;
|
||||
datElement.OnClick = onClose;
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCloseButtonId(uint id) =>
|
||||
id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId;
|
||||
}
|
||||
30
src/AcDream.App/UI/RetailCursorCatalog.cs
Normal file
30
src/AcDream.App/UI/RetailCursorCatalog.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Retail global cursor enum metadata used outside LayoutDesc widget states.</summary>
|
||||
internal static class RetailCursorCatalog
|
||||
{
|
||||
public const uint CursorEnumTable = 6;
|
||||
|
||||
// ClientUISystem::UpdateCursorState (0x00564630):
|
||||
// TARGET_MODE_USE_TARGET -> enum 0x27 when no SmartBox target is found,
|
||||
// enum 0x28 when ItemHolder::IsTargetCompatibleWithTargetingObject returns true,
|
||||
// enum 0x29 when it returns false. UIElementManager::SetCursor is called
|
||||
// with hotspot (14,14) for all three.
|
||||
public static bool TryGetGlobalCursor(CursorFeedbackKind kind, out RetailCursorSpec spec)
|
||||
{
|
||||
spec = kind switch
|
||||
{
|
||||
CursorFeedbackKind.TargetPending => new RetailCursorSpec(0x27u, 14, 14),
|
||||
CursorFeedbackKind.TargetValid => new RetailCursorSpec(0x28u, 14, 14),
|
||||
CursorFeedbackKind.TargetInvalid => new RetailCursorSpec(0x29u, 14, 14),
|
||||
_ => default,
|
||||
};
|
||||
|
||||
return spec.IsValid;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct RetailCursorSpec(uint EnumId, int HotspotX, int HotspotY)
|
||||
{
|
||||
public bool IsValid => EnumId != 0;
|
||||
}
|
||||
306
src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
Normal file
306
src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot row for the live retained-mode retail UI tree. Coordinates are
|
||||
/// absolute root/screen pixels.
|
||||
/// </summary>
|
||||
public sealed record RetailUiProbeElement(
|
||||
int Index,
|
||||
int Depth,
|
||||
string Path,
|
||||
string TypeName,
|
||||
string? Name,
|
||||
uint EventId,
|
||||
uint DatElementId,
|
||||
float X,
|
||||
float Y,
|
||||
float Width,
|
||||
float Height,
|
||||
bool Visible,
|
||||
bool Enabled,
|
||||
uint ItemId,
|
||||
int SlotIndex,
|
||||
ItemDragSource? SourceKind)
|
||||
{
|
||||
public int CenterX => (int)MathF.Round(X + Width * 0.5f);
|
||||
public int CenterY => (int)MathF.Round(Y + Height * 0.5f);
|
||||
}
|
||||
|
||||
public sealed record RetailUiProbeAssertion(bool Success, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Test/diagnostic harness for the retail-style UI. It drives <see cref="UiRoot"/>
|
||||
/// mouse events, never panel controllers directly, so automated checks exercise
|
||||
/// the same click, double-click, and drag-drop path as a player.
|
||||
/// </summary>
|
||||
public sealed class RetailUiAutomationProbe
|
||||
{
|
||||
private readonly UiRoot _root;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Action<string>? _log;
|
||||
private long _nowMs = 1;
|
||||
|
||||
public RetailUiAutomationProbe(
|
||||
UiRoot root,
|
||||
ClientObjectTable objects,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public IReadOnlyList<RetailUiProbeElement> Snapshot(bool visibleOnly = true)
|
||||
{
|
||||
var rows = new List<RetailUiProbeElement>();
|
||||
Walk(_root, "root", depth: 0, ancestorsVisible: true, visibleOnly, rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public string DumpText(bool visibleOnly = true)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var e in Snapshot(visibleOnly))
|
||||
{
|
||||
sb.Append('[').Append(e.Index.ToString(CultureInfo.InvariantCulture)).Append("] ");
|
||||
sb.Append(new string(' ', Math.Max(0, e.Depth) * 2));
|
||||
sb.Append(e.TypeName);
|
||||
if (e.DatElementId != 0) sb.Append(" dat=0x").Append(e.DatElementId.ToString("X8", CultureInfo.InvariantCulture));
|
||||
if (e.EventId != 0) sb.Append(" event=0x").Append(e.EventId.ToString("X8", CultureInfo.InvariantCulture));
|
||||
if (!string.IsNullOrEmpty(e.Name)) sb.Append(" name=").Append(e.Name);
|
||||
sb.Append(" rect=(").Append(F(e.X)).Append(',').Append(F(e.Y)).Append(',')
|
||||
.Append(F(e.Width)).Append(',').Append(F(e.Height)).Append(')');
|
||||
if (!e.Visible) sb.Append(" hidden");
|
||||
if (!e.Enabled) sb.Append(" disabled");
|
||||
if (e.ItemId != 0)
|
||||
{
|
||||
sb.Append(" item=0x").Append(e.ItemId.ToString("X8", CultureInfo.InvariantCulture));
|
||||
sb.Append(" slot=").Append(e.SlotIndex.ToString(CultureInfo.InvariantCulture));
|
||||
if (e.SourceKind is { } source) sb.Append(" source=").Append(source);
|
||||
}
|
||||
sb.Append(" path=").Append(e.Path);
|
||||
sb.AppendLine();
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public RetailUiProbeElement? FindByDatElementId(uint datElementId, bool visibleOnly = true)
|
||||
{
|
||||
foreach (var e in Snapshot(visibleOnly))
|
||||
if (e.DatElementId == datElementId && HasArea(e))
|
||||
return e;
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<RetailUiProbeElement> FindAllByDatElementId(uint datElementId, bool visibleOnly = true)
|
||||
{
|
||||
var matches = new List<RetailUiProbeElement>();
|
||||
foreach (var e in Snapshot(visibleOnly))
|
||||
if (e.DatElementId == datElementId)
|
||||
matches.Add(e);
|
||||
return matches;
|
||||
}
|
||||
|
||||
public RetailUiProbeElement? FindByItemId(
|
||||
uint itemGuid,
|
||||
ItemDragSource? sourceKind = null,
|
||||
bool visibleOnly = true)
|
||||
{
|
||||
if (itemGuid == 0) return null;
|
||||
foreach (var e in Snapshot(visibleOnly))
|
||||
{
|
||||
if (e.ItemId != itemGuid) continue;
|
||||
if (sourceKind is { } source && e.SourceKind != source) continue;
|
||||
if (!HasArea(e)) continue;
|
||||
return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool ClickElement(uint datElementId)
|
||||
{
|
||||
var target = FindByDatElementId(datElementId);
|
||||
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
|
||||
ClickAt(target.CenterX, target.CenterY);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
|
||||
{
|
||||
var target = FindByItemId(itemGuid, sourceKind);
|
||||
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
|
||||
ClickAt(target.CenterX, target.CenterY);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DoubleClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
|
||||
{
|
||||
var target = FindByItemId(itemGuid, sourceKind);
|
||||
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
|
||||
ClickAt(target.CenterX, target.CenterY);
|
||||
Advance(80);
|
||||
ClickAt(target.CenterX, target.CenterY);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DragItemToElement(uint itemGuid, uint datElementId, ItemDragSource? sourceKind = null)
|
||||
{
|
||||
var source = FindByItemId(itemGuid, sourceKind);
|
||||
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
|
||||
var target = FindByDatElementId(datElementId);
|
||||
if (target is null) return Fail($"drop target element 0x{datElementId:X8} not found");
|
||||
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DragItemToItem(uint sourceGuid, uint targetGuid, ItemDragSource? sourceKind = null)
|
||||
{
|
||||
var source = FindByItemId(sourceGuid, sourceKind);
|
||||
if (source is null) return Fail($"drag source item 0x{sourceGuid:X8} not found");
|
||||
var target = FindByItemId(targetGuid);
|
||||
if (target is null) return Fail($"drop target item 0x{targetGuid:X8} not found");
|
||||
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DragItemOutside(uint itemGuid, int x, int y, ItemDragSource? sourceKind = null)
|
||||
{
|
||||
var source = FindByItemId(itemGuid, sourceKind);
|
||||
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
|
||||
DragAt(source.CenterX, source.CenterY, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
public RetailUiProbeAssertion AssertItem(
|
||||
uint itemGuid,
|
||||
EquipMask? equippedLocation = null,
|
||||
uint? containerId = null,
|
||||
int? slot = null)
|
||||
{
|
||||
var item = _objects.Get(itemGuid);
|
||||
if (item is null)
|
||||
return Result(false, $"item 0x{itemGuid:X8} missing from object table");
|
||||
|
||||
if (equippedLocation is { } equip && item.CurrentlyEquippedLocation != equip)
|
||||
return Result(false,
|
||||
$"item 0x{itemGuid:X8} equip expected 0x{((uint)equip):X8}, got 0x{((uint)item.CurrentlyEquippedLocation):X8}");
|
||||
|
||||
if (containerId is { } container && item.ContainerId != container)
|
||||
return Result(false,
|
||||
$"item 0x{itemGuid:X8} container expected 0x{container:X8}, got 0x{item.ContainerId:X8}");
|
||||
|
||||
if (slot is { } slotValue && item.ContainerSlot != slotValue)
|
||||
return Result(false,
|
||||
$"item 0x{itemGuid:X8} slot expected {slotValue}, got {item.ContainerSlot}");
|
||||
|
||||
return Result(true, $"item 0x{itemGuid:X8} matched object-table assertion");
|
||||
}
|
||||
|
||||
private void Walk(
|
||||
UiElement element,
|
||||
string path,
|
||||
int depth,
|
||||
bool ancestorsVisible,
|
||||
bool visibleOnly,
|
||||
List<RetailUiProbeElement> rows)
|
||||
{
|
||||
bool effectiveVisible = ancestorsVisible && element.Visible;
|
||||
if (visibleOnly && !effectiveVisible) return;
|
||||
|
||||
if (element is UiItemList list)
|
||||
list.LayoutCells();
|
||||
|
||||
var pos = element.ScreenPosition;
|
||||
uint itemId = 0;
|
||||
int slotIndex = -1;
|
||||
ItemDragSource? sourceKind = null;
|
||||
if (element is UiItemSlot slot)
|
||||
{
|
||||
itemId = slot.ItemId;
|
||||
slotIndex = slot.SlotIndex;
|
||||
sourceKind = slot.SourceKind;
|
||||
}
|
||||
|
||||
rows.Add(new RetailUiProbeElement(
|
||||
rows.Count,
|
||||
depth,
|
||||
path,
|
||||
element.GetType().Name,
|
||||
element.Name,
|
||||
element.EventId,
|
||||
element.DatElementId,
|
||||
pos.X,
|
||||
pos.Y,
|
||||
element.Width,
|
||||
element.Height,
|
||||
effectiveVisible,
|
||||
element.Enabled,
|
||||
itemId,
|
||||
slotIndex,
|
||||
sourceKind));
|
||||
|
||||
var children = element.Children;
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
{
|
||||
var child = children[i];
|
||||
var childPath = $"{path}/{child.GetType().Name}[{i}]";
|
||||
if (child.DatElementId != 0)
|
||||
childPath += $"#0x{child.DatElementId:X8}";
|
||||
Walk(child, childPath, depth + 1, effectiveVisible, visibleOnly, rows);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClickAt(int x, int y)
|
||||
{
|
||||
Advance(16);
|
||||
_root.OnMouseMove(x, y);
|
||||
_root.OnMouseDown(UiMouseButton.Left, x, y);
|
||||
Advance(50);
|
||||
_root.OnMouseUp(UiMouseButton.Left, x, y);
|
||||
Advance(16);
|
||||
}
|
||||
|
||||
private void DragAt(int startX, int startY, int endX, int endY)
|
||||
{
|
||||
Advance(16);
|
||||
_root.OnMouseMove(startX, startY);
|
||||
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
|
||||
Advance(16);
|
||||
_root.OnMouseMove(startX + 5, startY + 5);
|
||||
Advance(16);
|
||||
_root.OnMouseMove(endX, endY);
|
||||
Advance(16);
|
||||
_root.OnMouseUp(UiMouseButton.Left, endX, endY);
|
||||
Advance(16);
|
||||
}
|
||||
|
||||
private void Advance(int ms)
|
||||
{
|
||||
_nowMs += ms;
|
||||
_root.Tick(ms / 1000.0, _nowMs);
|
||||
}
|
||||
|
||||
private static bool HasArea(RetailUiProbeElement element)
|
||||
=> element.Width > 0f && element.Height > 0f;
|
||||
|
||||
private bool Fail(string message)
|
||||
{
|
||||
_log?.Invoke(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
private RetailUiProbeAssertion Result(bool success, string message)
|
||||
{
|
||||
_log?.Invoke(message);
|
||||
return new RetailUiProbeAssertion(success, message);
|
||||
}
|
||||
|
||||
private static string F(float value)
|
||||
=> value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
}
|
||||
348
src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
Normal file
348
src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
|
||||
/// It is intended for local diagnostic launches, not for gameplay. Commands
|
||||
/// execute on render ticks and use the same <see cref="UiRoot"/> input path as
|
||||
/// physical mouse input.
|
||||
/// </summary>
|
||||
public sealed class RetailUiAutomationScriptRunner
|
||||
{
|
||||
private readonly RetailUiAutomationProbe _probe;
|
||||
private readonly Action<string> _log;
|
||||
private readonly List<ScriptCommand> _commands = new();
|
||||
private readonly bool _dumpOnStart;
|
||||
private readonly string? _loadError;
|
||||
private int _index;
|
||||
private int _activeIndex = -1;
|
||||
private long _elapsedMs;
|
||||
private long _commandStartMs;
|
||||
private bool _started;
|
||||
private bool _completed;
|
||||
|
||||
public RetailUiAutomationScriptRunner(
|
||||
RetailUiAutomationProbe probe,
|
||||
string? scriptPath,
|
||||
bool dumpOnStart,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
|
||||
_log = log ?? (_ => { });
|
||||
_dumpOnStart = dumpOnStart;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scriptPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
int lineNumber = 0;
|
||||
foreach (var raw in File.ReadAllLines(scriptPath))
|
||||
{
|
||||
lineNumber++;
|
||||
var line = StripComment(raw).Trim();
|
||||
if (line.Length == 0) continue;
|
||||
_commands.Add(new ScriptCommand(lineNumber, line, Split(line)));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loadError = $"failed to load UI probe script '{scriptPath}': {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Completed => _completed;
|
||||
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
if (_completed) return;
|
||||
|
||||
long addMs = Math.Max(1, (long)Math.Round(deltaSeconds * 1000.0));
|
||||
_elapsedMs += addMs;
|
||||
|
||||
if (!_started)
|
||||
{
|
||||
_started = true;
|
||||
if (_loadError is not null)
|
||||
{
|
||||
_log(_loadError);
|
||||
_completed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_dumpOnStart)
|
||||
_log(_probe.DumpText());
|
||||
|
||||
if (_commands.Count == 0)
|
||||
{
|
||||
_completed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_log($"running {_commands.Count} UI probe command(s)");
|
||||
}
|
||||
|
||||
int guard = 0;
|
||||
while (!_completed && _index < _commands.Count && guard++ < 8)
|
||||
{
|
||||
if (_activeIndex != _index)
|
||||
{
|
||||
_activeIndex = _index;
|
||||
_commandStartMs = _elapsedMs;
|
||||
}
|
||||
|
||||
var command = _commands[_index];
|
||||
bool finished = Execute(command);
|
||||
if (!finished) break;
|
||||
_index++;
|
||||
_activeIndex = -1;
|
||||
}
|
||||
|
||||
if (_index >= _commands.Count && !_completed)
|
||||
{
|
||||
_completed = true;
|
||||
_log("UI probe script complete");
|
||||
}
|
||||
}
|
||||
|
||||
private bool Execute(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length == 0) return true;
|
||||
|
||||
string verb = p[0].ToLowerInvariant();
|
||||
return verb switch
|
||||
{
|
||||
"dump" => DoDump(),
|
||||
"click" => DoClick(command),
|
||||
"doubleclick" => DoDoubleClick(command),
|
||||
"drag" => DoDrag(command),
|
||||
"wait" => DoWait(command),
|
||||
"sleep" => DoSleep(command),
|
||||
"assert" => DoAssert(command),
|
||||
_ => Stop(command, $"unknown command '{p[0]}'"),
|
||||
};
|
||||
}
|
||||
|
||||
private bool DoDump()
|
||||
{
|
||||
_log(_probe.DumpText());
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool DoClick(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length < 3) return Stop(command, "usage: click element <datId> | click item <guid> [source]");
|
||||
string target = p[1].ToLowerInvariant();
|
||||
if (target == "element")
|
||||
{
|
||||
if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'");
|
||||
return _probe.ClickElement(datId) || Stop(command, "click element failed");
|
||||
}
|
||||
if (target == "item")
|
||||
{
|
||||
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||
return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed");
|
||||
}
|
||||
return Stop(command, "usage: click element <datId> | click item <guid> [source]");
|
||||
}
|
||||
|
||||
private bool DoDoubleClick(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length < 3 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
|
||||
return Stop(command, "usage: doubleclick item <guid> [source]");
|
||||
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||
return _probe.DoubleClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "doubleclick item failed");
|
||||
}
|
||||
|
||||
private bool DoDrag(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
|
||||
return Stop(command, "usage: drag item <guid> element <datId> | drag item <guid> item <guid> | drag item <guid> outside <x> <y>");
|
||||
if (!TryParseUInt(p[2], out uint sourceGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||
|
||||
string target = p[3].ToLowerInvariant();
|
||||
if (target == "element")
|
||||
{
|
||||
if (!TryParseUInt(p[4], out uint datId)) return Stop(command, $"bad element id '{p[4]}'");
|
||||
return _probe.DragItemToElement(sourceGuid, datId, ParseSource(p, 5))
|
||||
|| Stop(command, "drag item to element failed");
|
||||
}
|
||||
|
||||
if (target == "item")
|
||||
{
|
||||
if (!TryParseUInt(p[4], out uint targetGuid)) return Stop(command, $"bad target item guid '{p[4]}'");
|
||||
return _probe.DragItemToItem(sourceGuid, targetGuid, ParseSource(p, 5))
|
||||
|| Stop(command, "drag item to item failed");
|
||||
}
|
||||
|
||||
if (target == "outside")
|
||||
{
|
||||
if (p.Length < 6) return Stop(command, "usage: drag item <guid> outside <x> <y> [source]");
|
||||
if (!TryParseInt(p[4], out int x)) return Stop(command, $"bad x coordinate '{p[4]}'");
|
||||
if (!TryParseInt(p[5], out int y)) return Stop(command, $"bad y coordinate '{p[5]}'");
|
||||
return _probe.DragItemOutside(sourceGuid, x, y, ParseSource(p, 6))
|
||||
|| Stop(command, "drag item outside failed");
|
||||
}
|
||||
|
||||
return Stop(command, "usage: drag item <guid> element/item/outside ...");
|
||||
}
|
||||
|
||||
private bool DoWait(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length < 2) return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
|
||||
|
||||
string target = p[1].ToLowerInvariant();
|
||||
if (target == "item")
|
||||
{
|
||||
if (p.Length < 3 || !TryParseUInt(p[2], out uint itemGuid)) return Stop(command, "usage: wait item <guid> [source] [timeoutMs]");
|
||||
if (_probe.FindByItemId(itemGuid, ParseSource(p, 3)) is not null) return true;
|
||||
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"item 0x{itemGuid:X8}");
|
||||
}
|
||||
|
||||
if (target == "element")
|
||||
{
|
||||
if (p.Length < 3 || !TryParseUInt(p[2], out uint datId)) return Stop(command, "usage: wait element <datId> [timeoutMs]");
|
||||
if (_probe.FindByDatElementId(datId) is not null) return true;
|
||||
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"element 0x{datId:X8}");
|
||||
}
|
||||
|
||||
if (target == "ms")
|
||||
return DoSleep(command);
|
||||
|
||||
return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
|
||||
}
|
||||
|
||||
private bool DoSleep(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
string value = p.Length >= 3 && string.Equals(p[0], "wait", StringComparison.OrdinalIgnoreCase) ? p[2]
|
||||
: p.Length >= 2 ? p[1] : "";
|
||||
if (!TryParseInt(value, out int ms) || ms < 0)
|
||||
return Stop(command, "usage: sleep <milliseconds> | wait ms <milliseconds>");
|
||||
return _elapsedMs - _commandStartMs >= ms;
|
||||
}
|
||||
|
||||
private bool DoAssert(ScriptCommand command)
|
||||
{
|
||||
var p = command.Parts;
|
||||
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
|
||||
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
|
||||
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
|
||||
|
||||
string kind = p[3].ToLowerInvariant();
|
||||
RetailUiProbeAssertion result;
|
||||
if (kind == "equip")
|
||||
{
|
||||
if (!TryParseUInt(p[4], out uint mask)) return Stop(command, $"bad equip mask '{p[4]}'");
|
||||
result = _probe.AssertItem(itemGuid, equippedLocation: (EquipMask)mask);
|
||||
}
|
||||
else if (kind == "container")
|
||||
{
|
||||
if (!TryParseUInt(p[4], out uint containerId)) return Stop(command, $"bad container id '{p[4]}'");
|
||||
result = _probe.AssertItem(itemGuid, containerId: containerId);
|
||||
}
|
||||
else if (kind == "slot")
|
||||
{
|
||||
if (!TryParseInt(p[4], out int slot)) return Stop(command, $"bad slot '{p[4]}'");
|
||||
result = _probe.AssertItem(itemGuid, slot: slot);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
|
||||
}
|
||||
|
||||
return result.Success || Stop(command, result.Message);
|
||||
}
|
||||
|
||||
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
|
||||
{
|
||||
if (_elapsedMs - _commandStartMs <= timeoutMs) return false;
|
||||
return Stop(command, $"timed out waiting for {label}");
|
||||
}
|
||||
|
||||
private bool Stop(ScriptCommand command, string message)
|
||||
{
|
||||
_log($"line {command.LineNumber}: {message}; command: {command.Text}");
|
||||
_completed = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string StripComment(string line)
|
||||
{
|
||||
int hash = line.IndexOf('#');
|
||||
int slashes = line.IndexOf("//", StringComparison.Ordinal);
|
||||
int cut = -1;
|
||||
if (hash >= 0) cut = hash;
|
||||
if (slashes >= 0) cut = cut >= 0 ? Math.Min(cut, slashes) : slashes;
|
||||
return cut >= 0 ? line[..cut] : line;
|
||||
}
|
||||
|
||||
private static string[] Split(string line)
|
||||
=> line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
private static ItemDragSource? ParseSource(string[] parts, int start)
|
||||
{
|
||||
for (int i = start; i < parts.Length; i++)
|
||||
if (TryParseSource(parts[i], out var source))
|
||||
return source;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseSource(string value, out ItemDragSource source)
|
||||
{
|
||||
switch (value.ToLowerInvariant())
|
||||
{
|
||||
case "inventory":
|
||||
case "pack":
|
||||
case "backpack":
|
||||
source = ItemDragSource.Inventory;
|
||||
return true;
|
||||
case "shortcut":
|
||||
case "shortcutbar":
|
||||
case "toolbar":
|
||||
source = ItemDragSource.ShortcutBar;
|
||||
return true;
|
||||
case "equipment":
|
||||
case "equip":
|
||||
case "paperdoll":
|
||||
source = ItemDragSource.Equipment;
|
||||
return true;
|
||||
case "ground":
|
||||
source = ItemDragSource.Ground;
|
||||
return true;
|
||||
default:
|
||||
source = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int TimeoutMs(string[] parts, int start, int defaultMs)
|
||||
{
|
||||
for (int i = start; i < parts.Length; i++)
|
||||
if (TryParseInt(parts[i], out int ms) && ms >= 0)
|
||||
return ms;
|
||||
return defaultMs;
|
||||
}
|
||||
|
||||
private static bool TryParseUInt(string value, out uint parsed)
|
||||
{
|
||||
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return uint.TryParse(value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed);
|
||||
return uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||
}
|
||||
|
||||
private static bool TryParseInt(string value, out int parsed)
|
||||
=> int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||
|
||||
private readonly record struct ScriptCommand(int LineNumber, string Text, string[] Parts);
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ public sealed class UiButton : UiElement
|
|||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
|
||||
public uint ElementId => _info.Id;
|
||||
|
||||
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
|
||||
public string? Label { get; set; }
|
||||
|
||||
|
|
@ -60,6 +63,8 @@ public sealed class UiButton : UiElement
|
|||
/// </summary>
|
||||
public string ActiveState { get; set; } = "";
|
||||
|
||||
public override string ActiveCursorStateName => ActiveState;
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ namespace AcDream.App.UI;
|
|||
[System.Flags]
|
||||
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
||||
|
||||
/// <summary>Retail dat cursor media attached to a UI state.</summary>
|
||||
public readonly record struct UiCursorMedia(uint File, int HotspotX, int HotspotY)
|
||||
{
|
||||
public bool IsValid => File != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for every UI widget in the retained-mode tree.
|
||||
///
|
||||
|
|
@ -40,9 +46,56 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
public uint EventId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dat LayoutDesc element id for widgets imported from retail UI data.
|
||||
/// Zero for runtime-created helper widgets. This is intentionally separate
|
||||
/// from <see cref="EventId"/>: event ids are runtime-local, while this id is
|
||||
/// stable across retail layout dumps, decomp references, and UI probes.
|
||||
/// </summary>
|
||||
public uint DatElementId { get; internal set; }
|
||||
|
||||
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
|
||||
|
||||
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
||||
public IReadOnlyDictionary<string, UiCursorMedia> StateCursors => _stateCursors;
|
||||
|
||||
/// <summary>Active state name used for cursor media. Stateful dat widgets override this.</summary>
|
||||
public virtual string ActiveCursorStateName => "";
|
||||
|
||||
public void SetStateCursors(IReadOnlyDictionary<string, UiCursorMedia> cursors)
|
||||
{
|
||||
_stateCursors.Clear();
|
||||
foreach (var kv in cursors)
|
||||
{
|
||||
if (kv.Value.IsValid)
|
||||
_stateCursors[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public UiCursorMedia ActiveCursor()
|
||||
=> CursorForState(ActiveCursorStateName, allowFallback: true);
|
||||
|
||||
public UiCursorMedia CursorForState(string stateName, bool allowFallback = true)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stateName)
|
||||
&& _stateCursors.TryGetValue(stateName, out var named)
|
||||
&& named.IsValid)
|
||||
return named;
|
||||
|
||||
if (!allowFallback)
|
||||
return default;
|
||||
|
||||
if (_stateCursors.TryGetValue("", out var direct) && direct.IsValid)
|
||||
return direct;
|
||||
if (_stateCursors.TryGetValue("Normal", out var normal) && normal.IsValid)
|
||||
return normal;
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
// ── Geometry ────────────────────────────────────────────────────────
|
||||
/// <summary>X in the parent's local pixel space.</summary>
|
||||
public float Left { get; set; }
|
||||
|
|
@ -130,6 +183,14 @@ public abstract class UiElement
|
|||
/// self-driven interior drag such as text selection). Default false.</summary>
|
||||
public virtual bool HandlesClick => false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional game-object target used by item-use cursor/target mode when this UI
|
||||
/// element represents a target that is not its visible item icon. Example:
|
||||
/// the status-bar backpack button and paperdoll both mean "self" for a
|
||||
/// health-kit-style use-with-target action.
|
||||
/// </summary>
|
||||
public Func<uint>? UseTargetGuidProvider { get; set; }
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ public sealed class UiHost : System.IDisposable
|
|||
/// <summary>Hide a registered window; returns false if the name is unknown.</summary>
|
||||
public bool HideWindow(string name) => Root.HideWindow(name);
|
||||
|
||||
/// <summary>Return the current visibility of a registered window.</summary>
|
||||
public bool IsWindowVisible(string name) => Root.IsWindowVisible(name);
|
||||
|
||||
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
|
||||
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,16 @@ using System.Collections.Generic;
|
|||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Cell order for a multi-column <see cref="UiItemList"/>.</summary>
|
||||
public enum UiItemListFlow
|
||||
{
|
||||
/// <summary>Retail listbox bit 0 set: index advances across columns, then down rows.</summary>
|
||||
RowMajor,
|
||||
|
||||
/// <summary>Retail listbox bit 0 clear: index advances down rows, then across columns.</summary>
|
||||
ColumnMajor,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
|
||||
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
|
||||
|
|
@ -82,9 +92,16 @@ public sealed class UiItemList : UiElement
|
|||
LayoutCells();
|
||||
}
|
||||
|
||||
/// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
|
||||
/// <summary>Grid columns. 1 = single column. Ignored in fill mode.</summary>
|
||||
public int Columns { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-column list flow. Retail UIElement_ListBox::UpdateLayout uses bit 0 of
|
||||
/// m_bitField: set = row-major, clear = column-major. Default preserves the
|
||||
/// existing toolbar/grid behavior; panel controllers select retail-specific flow.
|
||||
/// </summary>
|
||||
public UiItemListFlow Flow { get; set; } = UiItemListFlow.RowMajor;
|
||||
|
||||
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
|
||||
/// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid.</summary>
|
||||
public float CellWidth { get; set; }
|
||||
|
|
@ -107,8 +124,25 @@ public sealed class UiItemList : UiElement
|
|||
return (col * cellW, row * cellH);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pixel offset matching retail UIElement_ListBox::CalculateColumn/CalculateRow:
|
||||
/// row-major when bit 0 is set, column-major when it is clear.
|
||||
/// </summary>
|
||||
internal static (float x, float y) CellOffset(
|
||||
int index, int columns, int cellCount, UiItemListFlow flow, float cellW, float cellH)
|
||||
{
|
||||
int cols = columns < 1 ? 1 : columns;
|
||||
if (flow == UiItemListFlow.ColumnMajor)
|
||||
{
|
||||
int rows = Math.Max(1, RowCount(cellCount, cols));
|
||||
int col = index / rows, row = index % rows;
|
||||
return (col * cellW, row * cellH);
|
||||
}
|
||||
return CellOffset(index, cols, cellW, cellH);
|
||||
}
|
||||
|
||||
/// <summary>Position every cell per the current mode: fill (CellWidth<=0) sizes the single
|
||||
/// cell to the list; grid (CellWidth>0) tiles cells row-major, offset by the scroll position
|
||||
/// cell to the list; grid (CellWidth>0) tiles cells by <see cref="Flow"/>, offset by the scroll position
|
||||
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
|
||||
internal void LayoutCells()
|
||||
{
|
||||
|
|
@ -137,7 +171,7 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
for (int i = 0; i < _cells.Count; i++)
|
||||
{
|
||||
var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight);
|
||||
var (x, baseY) = CellOffset(i, cols, _cells.Count, Flow, CellWidth, CellHeight);
|
||||
float top = baseY - scrollY;
|
||||
var cell = _cells[i];
|
||||
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
|
||||
|
|
|
|||
|
|
@ -171,6 +171,9 @@ public sealed class UiItemSlot : UiElement
|
|||
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
|
||||
public Action? Clicked { get; set; }
|
||||
|
||||
/// <summary>Invoked by <see cref="OnEvent"/> when a double-click lands on a bound slot.</summary>
|
||||
public Action? DoubleClicked { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
|
|
@ -184,6 +187,9 @@ public sealed class UiItemSlot : UiElement
|
|||
case UiEventType.Click:
|
||||
Clicked?.Invoke();
|
||||
return true;
|
||||
case UiEventType.DoubleClick:
|
||||
DoubleClicked?.Invoke();
|
||||
return true;
|
||||
|
||||
case UiEventType.DragBegin:
|
||||
// Notify the source list's handler so it can lift (remove + wire) — retail
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public sealed class UiMeter : UiElement
|
||||
{
|
||||
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
|
||||
public uint ElementId { get; set; }
|
||||
|
||||
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
|
||||
public Func<float?> Fill { get; set; } = () => 0f;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ public class UiPanel : UiElement
|
|||
}
|
||||
else if (BackgroundColor.W > 0f)
|
||||
{
|
||||
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
|
||||
// Panel fills are backgrounds. Draw them through the sprite/fill
|
||||
// bucket so children that render as sprites/dat-font glyphs stay on
|
||||
// top in painter order; DrawRect flushes after sprites and would
|
||||
// cover text/icons.
|
||||
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
}
|
||||
|
||||
if (BorderColor.W > 0f && BorderThickness > 0f)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
109
src/AcDream.App/UI/UiScrollablePanel.cs
Normal file
109
src/AcDream.App/UI/UiScrollablePanel.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Simple vertical viewport for controller-built row lists. It shares the same
|
||||
/// pixel scroll model as chat text and item grids, and clips whole rows because
|
||||
/// the UI renderer does not have a scissor stack yet.
|
||||
/// </summary>
|
||||
public sealed class UiScrollablePanel : UiPanel
|
||||
{
|
||||
private readonly Dictionary<UiElement, float> _baseTops = new(ReferenceEqualityComparer.Instance);
|
||||
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
|
||||
public int LineHeight { get; set; } = 16;
|
||||
|
||||
public int ContentHeight { get; private set; }
|
||||
|
||||
public UiScrollablePanel()
|
||||
{
|
||||
BackgroundColor = Vector4.Zero;
|
||||
BorderColor = Vector4.Zero;
|
||||
}
|
||||
|
||||
public override void AddChild(UiElement child)
|
||||
{
|
||||
base.AddChild(child);
|
||||
Track(child);
|
||||
}
|
||||
|
||||
public override bool RemoveChild(UiElement child)
|
||||
{
|
||||
bool removed = base.RemoveChild(child);
|
||||
if (removed)
|
||||
{
|
||||
_baseTops.Remove(child);
|
||||
RecomputeContentHeight();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void ClearContent()
|
||||
{
|
||||
foreach (var child in Children.ToArray())
|
||||
RemoveChild(child);
|
||||
_baseTops.Clear();
|
||||
ContentHeight = 0;
|
||||
Scroll.SetScrollY(0);
|
||||
}
|
||||
|
||||
internal void LayoutScrollableChildren()
|
||||
{
|
||||
Scroll.LineHeight = Math.Max(1, LineHeight);
|
||||
Scroll.ContentHeight = ContentHeight;
|
||||
Scroll.ViewHeight = Math.Max(0, (int)MathF.Floor(Height));
|
||||
Scroll.SetScrollY(Scroll.ScrollY);
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!_baseTops.TryGetValue(child, out float baseTop))
|
||||
continue;
|
||||
|
||||
float top = baseTop - Scroll.ScrollY;
|
||||
child.Top = top;
|
||||
child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Scroll)
|
||||
{
|
||||
Scroll.ScrollByLines(-e.Data0);
|
||||
LayoutScrollableChildren();
|
||||
return true;
|
||||
}
|
||||
return base.OnEvent(e);
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
LayoutScrollableChildren();
|
||||
base.OnDraw(ctx);
|
||||
}
|
||||
|
||||
private void Track(UiElement child)
|
||||
{
|
||||
child.Anchors = AnchorEdges.None;
|
||||
_baseTops[child] = child.Top;
|
||||
RecomputeContentHeight();
|
||||
}
|
||||
|
||||
private void RecomputeContentHeight()
|
||||
{
|
||||
float bottom = 0f;
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!_baseTops.TryGetValue(child, out float top))
|
||||
continue;
|
||||
bottom = MathF.Max(bottom, top + child.Height);
|
||||
}
|
||||
ContentHeight = (int)MathF.Ceiling(bottom);
|
||||
Scroll.SetScrollY(Scroll.ScrollY);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,9 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public sealed class UiText : UiElement
|
||||
{
|
||||
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
|
||||
public uint ElementId { get; set; }
|
||||
|
||||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||
public readonly record struct Line(string Text, Vector4 Color);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue