merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch
Brings the 134 D.2b UI commits onto the physics/collision development line so main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open doors fully passable) + the full collision/streaming/dense-town-FPS arc meet the paperdoll/inventory work. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
01594b4cfd
95 changed files with 17201 additions and 167 deletions
|
|
@ -663,6 +663,10 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
|
||||
// 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).
|
||||
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
|
||||
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
|
||||
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
|
||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
|
||||
|
|
@ -2059,7 +2063,9 @@ public sealed class GameWindow : IDisposable
|
|||
combatState: Combat,
|
||||
peaceDigits: toolbarPeaceDigits,
|
||||
warDigits: toolbarWarDigits,
|
||||
emptyDigits: toolbarEmptyDigits);
|
||||
emptyDigits: toolbarEmptyDigits,
|
||||
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
|
||||
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
|
||||
|
||||
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
|
||||
// Analogue of retail gmToolbarUI::HandleSelectionChanged
|
||||
|
|
@ -2083,7 +2089,7 @@ public sealed class GameWindow : IDisposable
|
|||
// thickness on every side, giving an outer window of 310×132.
|
||||
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||||
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
|
||||
var toolbarFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome)
|
||||
{
|
||||
Left = 10, Top = 300,
|
||||
Width = toolbarContentW + 2 * toolbarBorder,
|
||||
|
|
@ -2096,14 +2102,59 @@ public sealed class GameWindow : IDisposable
|
|||
toolbarRoot.Top = toolbarBorder;
|
||||
toolbarRoot.Width = toolbarContentW;
|
||||
toolbarRoot.Height = toolbarContentH;
|
||||
// Anchor content to all four edges so it reflows if the frame is resized.
|
||||
// Anchor content to Left|Top|Right only — drop Bottom so the dat content
|
||||
// keeps its full height when the frame collapses; row 2 hides via Visible,
|
||||
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.)
|
||||
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
|
||||
// The frame is the draggable window; the content itself is not.
|
||||
toolbarRoot.ClickThrough = false;
|
||||
| AcDream.App.UI.AnchorEdges.Right;
|
||||
// The frame is the draggable window; the content itself is not. ClickThrough so the
|
||||
// content panel never CLAIMS a hit — its behavioral children (slots, indicators) are
|
||||
// hit children-first, and its empty areas fall through to the frame (move). Critically,
|
||||
// when collapsed the row-2 band is hidden, so below the collapsed frame the content has
|
||||
// no visible/hit children and ClickThrough lets clicks fall through (no phantom
|
||||
// window-drag from where row 2 used to be). Same pattern as the chat content panel.
|
||||
toolbarRoot.ClickThrough = true;
|
||||
toolbarRoot.Draggable = false;
|
||||
toolbarRoot.Resizable = false;
|
||||
toolbarFrame.AddChild(toolbarRoot);
|
||||
|
||||
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
|
||||
// and two-row height. CollapsedHeight is computed from the layout (just above row 2),
|
||||
// so there's no magic constant. Bottom-edge only; default expanded.
|
||||
// The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left
|
||||
// edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece
|
||||
// (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two
|
||||
// edge-pieces drawing as black pillars below the bar.
|
||||
uint[] row2Ids =
|
||||
{
|
||||
0x100006B6u,
|
||||
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
|
||||
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
|
||||
0x100006C0u,
|
||||
};
|
||||
var toolbarRow2 = new System.Collections.Generic.List<AcDream.App.UI.UiElement>();
|
||||
float minRow2Top = float.MaxValue;
|
||||
foreach (var id in row2Ids)
|
||||
if (toolbarLayout.FindElement(id) is { } e2)
|
||||
{
|
||||
toolbarRow2.Add(e2);
|
||||
if (e2.Top < minRow2Top) minRow2Top = e2.Top;
|
||||
}
|
||||
if (toolbarRow2.Count > 0)
|
||||
{
|
||||
float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height
|
||||
float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2
|
||||
toolbarFrame.CollapsedHeight = collapsedH;
|
||||
toolbarFrame.ExpandedHeight = expandedH;
|
||||
toolbarFrame.SecondRow = toolbarRow2;
|
||||
toolbarFrame.Resizable = true;
|
||||
toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only
|
||||
toolbarFrame.MinHeight = collapsedH;
|
||||
toolbarFrame.MaxHeight = expandedH;
|
||||
// Height stays expandedH (already set at construction) = default expanded.
|
||||
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
|
||||
}
|
||||
|
||||
_uiHost.Root.AddChild(toolbarFrame);
|
||||
|
||||
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
|
||||
|
|
@ -2131,6 +2182,125 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI),
|
||||
// via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/
|
||||
// 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts
|
||||
// HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138).
|
||||
AcDream.App.UI.Layout.ImportedLayout? invLayout;
|
||||
lock (_datLock)
|
||||
invLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||||
_dats!, 0x21000023u, ResolveChrome, vitalsDatFont);
|
||||
if (invLayout is not null)
|
||||
{
|
||||
var inventoryRoot = invLayout.Root;
|
||||
// Wrap the dat content in the universal 8-piece beveled window chrome — the
|
||||
// SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI
|
||||
// LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on
|
||||
// every side. The frame is the draggable window; the dat content sits inside it.
|
||||
const int invBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||||
float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height;
|
||||
var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
{
|
||||
Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position
|
||||
Width = invContentW + 2 * invBorder,
|
||||
Height = invContentH + 2 * invBorder,
|
||||
Opacity = 1.0f,
|
||||
Visible = false, // F12 toggles it
|
||||
Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned
|
||||
Draggable = true,
|
||||
};
|
||||
// Content offset by the border so it sits inside the chrome.
|
||||
inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder;
|
||||
inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH;
|
||||
// Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks
|
||||
// the frame) so a vertical resize grows the inner content.
|
||||
inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Bottom;
|
||||
inventoryRoot.Draggable = false;
|
||||
inventoryFrame.AddChild(inventoryRoot);
|
||||
|
||||
// Vertical-only resize: drag the BOTTOM edge to expand and see more of the pack.
|
||||
// ResizeX=false + ResizableEdges=Bottom blocks horizontal resize (user request).
|
||||
// MinHeight = the dat default (no shrink below it); MaxHeight = toward full screen.
|
||||
inventoryFrame.Resizable = true;
|
||||
inventoryFrame.ResizeX = false;
|
||||
inventoryFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom;
|
||||
inventoryFrame.MinHeight = invContentH + 2 * invBorder;
|
||||
inventoryFrame.MaxHeight = 560f;
|
||||
|
||||
// Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
|
||||
// + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the
|
||||
// paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight
|
||||
// grows → more rows + the scrollbar thumb ratio (view/content) reflects it.
|
||||
void StretchV(uint id)
|
||||
{
|
||||
if (invLayout!.FindElement(id) is { } el)
|
||||
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Bottom;
|
||||
}
|
||||
void PinTopLeft(uint id)
|
||||
{
|
||||
if (invLayout!.FindElement(id) is { } el)
|
||||
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top;
|
||||
}
|
||||
StretchV(0x100001D0u); // full-window backdrop
|
||||
StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container)
|
||||
StretchV(0x100001C6u); // contents grid (UiItemList)
|
||||
StretchV(0x100001C7u); // contents scrollbar
|
||||
PinTopLeft(0x100001CDu); // paperdoll (fixed at top)
|
||||
PinTopLeft(0x100001CEu); // side-bag column (fixed at top)
|
||||
|
||||
_uiHost.Root.AddChild(inventoryFrame);
|
||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame);
|
||||
|
||||
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
|
||||
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
|
||||
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
|
||||
|
||||
// Resolve each inventory list's empty-slot art from the dat cell template
|
||||
// (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e ->
|
||||
// catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in
|
||||
// gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022).
|
||||
uint contentsEmpty, sideBagEmpty, mainPackEmpty;
|
||||
lock (_datLock)
|
||||
{
|
||||
contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u);
|
||||
sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu);
|
||||
mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u);
|
||||
}
|
||||
Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}");
|
||||
|
||||
_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(
|
||||
invLayout, Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
strength: () => LocalPlayer.GetAttribute(
|
||||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
|
||||
? (int?)sa.Current : null,
|
||||
datFont: vitalsDatFont,
|
||||
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));
|
||||
|
||||
// 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).
|
||||
_paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind(
|
||||
invLayout, Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
// 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);
|
||||
|
||||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
}
|
||||
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
|
||||
}
|
||||
|
||||
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
||||
|
|
@ -2433,7 +2603,7 @@ public sealed class GameWindow : IDisposable
|
|||
// D.5.4: ingest CreateObject into the object table (upsert) and wire Delete +
|
||||
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
|
||||
// the table is populated before the render handler runs.
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects);
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||
|
|
@ -2531,7 +2701,8 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
|
||||
}
|
||||
},
|
||||
onShortcuts: list => Shortcuts = list);
|
||||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
|
|
@ -11709,6 +11880,13 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
switch (action)
|
||||
{
|
||||
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
|
||||
// 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);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
|
||||
foreach (var panel in EnumerateDebugPanel())
|
||||
{
|
||||
|
|
|
|||
25
src/AcDream.App/UI/IItemListDragHandler.cs
Normal file
25
src/AcDream.App/UI/IItemListDragHandler.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A panel controller implements this and registers itself on each of its
|
||||
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
|
||||
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
|
||||
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
|
||||
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
|
||||
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
|
||||
/// no-ops to reject.</para>
|
||||
/// </summary>
|
||||
public interface IItemListDragHandler
|
||||
{
|
||||
/// <summary>The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag
|
||||
/// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its
|
||||
/// model + wire so the source slot empties immediately. The item is "in hand" until
|
||||
/// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel.</summary>
|
||||
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
|
||||
|
||||
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
|
||||
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
|
||||
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
|
||||
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
|
||||
}
|
||||
24
src/AcDream.App/UI/ItemDragPayload.cs
Normal file
24
src/AcDream.App/UI/ItemDragPayload.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
|
||||
/// distinction (<c>flags & 0xE == 0</c> fresh-from-inventory vs
|
||||
/// <c>flags & 4</c> within-list reorder) expressed as a typed enum. The drop
|
||||
/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
|
||||
/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
|
||||
/// </summary>
|
||||
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
|
||||
/// mid-drag can't mutate it under us). Port of retail's <c>m_dragElement</c> +
|
||||
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
|
||||
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
|
||||
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
|
||||
/// same container id retail reads off the dragged element, single source of truth.</para>
|
||||
/// </summary>
|
||||
public sealed record ItemDragPayload(
|
||||
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
|
||||
ItemDragSource SourceKind, // what kind of slot it left
|
||||
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
|
||||
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost
|
||||
|
|
@ -77,8 +77,11 @@ public static class DatWidgetFactory
|
|||
e.Width = info.Width;
|
||||
e.Height = info.Height;
|
||||
|
||||
// Honor the dat's draw order so overlapping pieces (grip overlay over bevel chrome) layer correctly.
|
||||
e.ZOrder = (int)info.ReadOrder;
|
||||
// Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the
|
||||
// gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145);
|
||||
// ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's
|
||||
// element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder.
|
||||
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
|
||||
|
||||
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
|
||||
// UI layout engine uses for reflow.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ public sealed class ElementInfo
|
|||
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
|
||||
public uint ReadOrder;
|
||||
|
||||
/// <summary>Layer level from the dat (<c>ElementDesc.ZLevel</c>). Higher = drawn further
|
||||
/// BACK (a full-window backdrop at ZLevel 100 sits behind ZLevel-0 panels). The factory
|
||||
/// folds it into <see cref="UiElement.ZOrder"/> so ZLevel dominates and ReadOrder is the
|
||||
/// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary>
|
||||
public uint ZLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
|
||||
|
|
@ -152,6 +158,7 @@ public static class ElementReader
|
|||
Right = derived.Right,
|
||||
Bottom = derived.Bottom,
|
||||
ReadOrder = derived.ReadOrder,
|
||||
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
|
||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
|
|
|
|||
491
src/AcDream.App/UI/Layout/InventoryController.cs
Normal file
491
src/AcDream.App/UI/Layout/InventoryController.cs
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
|
||||
/// <see cref="ClientObjectTable"/>. The acdream analogue of retail
|
||||
/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728).
|
||||
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
|
||||
/// drag-into-bag / wield-drop wire are later sub-phases.
|
||||
/// </summary>
|
||||
public sealed class InventoryController : IItemListDragHandler
|
||||
{
|
||||
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
|
||||
public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
|
||||
public const uint ContainerListId = 0x100001CAu; // gmBackpackUI m_containerList (side-bag selector)
|
||||
public const uint TopContainerId = 0x100001C9u; // gmBackpackUI m_topContainer (main-pack cell)
|
||||
public const uint BurdenMeterId = 0x100001D9u; // gmBackpackUI m_burdenMeter (vertical)
|
||||
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 ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
|
||||
|
||||
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
|
||||
// ChatWindowController). Both inventory + chat scrollbars inherit this base.
|
||||
private const uint ScrollTrackSprite = 0x06004C5Fu;
|
||||
private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile
|
||||
private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap
|
||||
private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap
|
||||
private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button)
|
||||
private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button)
|
||||
|
||||
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
|
||||
private const int ContentsColumns = 6;
|
||||
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
|
||||
private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px)
|
||||
private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots
|
||||
private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items")
|
||||
private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll)
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
|
||||
private readonly Func<int?> _strength;
|
||||
|
||||
private readonly UiItemList? _contentsGrid;
|
||||
private readonly UiItemList? _containerList;
|
||||
private readonly UiItemList? _topContainer;
|
||||
private readonly UiMeter? _burdenMeter;
|
||||
|
||||
private float _burdenFill;
|
||||
private int _burdenPercent;
|
||||
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
|
||||
|
||||
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
||||
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square)
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
|
||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||
private const uint EncumbranceValProperty = 5u; // total carried burden
|
||||
private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation
|
||||
|
||||
private InventoryController(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
UiDatFont? datFont,
|
||||
uint contentsEmptySprite,
|
||||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer)
|
||||
{
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_iconIds = iconIds;
|
||||
_strength = strength;
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
|
||||
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
||||
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
||||
_topContainer = layout.FindElement(TopContainerId) as UiItemList;
|
||||
|
||||
if (_contentsGrid is not null)
|
||||
{
|
||||
_contentsGrid.Columns = ContentsColumns;
|
||||
_contentsGrid.CellWidth = ContentsCellPx;
|
||||
_contentsGrid.CellHeight = ContentsCellPx;
|
||||
}
|
||||
|
||||
// Bind the gutter scrollbar to the contents grid's scroll model (the factory built
|
||||
// 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does).
|
||||
if (_contentsGrid is not null
|
||||
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
|
||||
{
|
||||
bar.Model = _contentsGrid.Scroll;
|
||||
bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor
|
||||
bar.TrackSprite = ScrollTrackSprite;
|
||||
bar.ThumbSprite = ScrollThumbSprite;
|
||||
bar.ThumbTopSprite = ScrollThumbTop;
|
||||
bar.ThumbBotSprite = ScrollThumbBot;
|
||||
bar.UpSprite = ScrollUpSprite;
|
||||
bar.DownSprite = ScrollDownSprite;
|
||||
}
|
||||
if (_containerList is not null)
|
||||
{
|
||||
_containerList.Columns = 1;
|
||||
_containerList.CellWidth = BackpackCellPx;
|
||||
_containerList.CellHeight = BackpackCellPx;
|
||||
}
|
||||
|
||||
// Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e
|
||||
// -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default.
|
||||
if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite;
|
||||
if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite;
|
||||
if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite;
|
||||
|
||||
// B-Drag: register this controller as the drag handler on all three lists.
|
||||
_contentsGrid?.RegisterDragHandler(this);
|
||||
_containerList?.RegisterDragHandler(this);
|
||||
_topContainer?.RegisterDragHandler(this);
|
||||
|
||||
// Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4).
|
||||
_burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
|
||||
if (_burdenMeter is not null)
|
||||
{
|
||||
_burdenMeter.Vertical = true; // 11x58 vertical bar
|
||||
_burdenMeter.FillFromBottom = true; // retail direction 4 (visual-confirmed)
|
||||
_burdenMeter.Fill = () => _burdenFill;
|
||||
}
|
||||
|
||||
// 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(BurdenCaptionId), () => "Burden", datFont);
|
||||
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
|
||||
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
|
||||
|
||||
// Rebuild on any change to the player's possessions.
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
|
||||
Populate();
|
||||
}
|
||||
|
||||
public static InventoryController Bind(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
|
||||
Func<int?> strength,
|
||||
UiDatFont? datFont,
|
||||
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,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer);
|
||||
|
||||
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(); }
|
||||
|
||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
return o.ObjectId == p // the player object IS the burden source
|
||||
|| o.ContainerId == p || o.WielderId == p
|
||||
|| o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid
|
||||
|| (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
|
||||
}
|
||||
|
||||
public void Populate()
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
uint open = EffectiveOpen();
|
||||
|
||||
_contentsGrid?.Flush();
|
||||
_containerList?.Flush();
|
||||
|
||||
// Side-bag column: ALWAYS the player's bags (constant across container switches; only the
|
||||
// open/selected indicators move). Equipped items never appear here.
|
||||
foreach (var guid in _objects.GetContents(p))
|
||||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
if (isBag) AddCell(_containerList, guid, isContainer: true);
|
||||
}
|
||||
|
||||
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
|
||||
// no sub-bags, so the isBag skip is a no-op when a bag is open.)
|
||||
foreach (var guid in _objects.GetContents(open))
|
||||
{
|
||||
var item = _objects.Get(guid);
|
||||
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||||
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
|
||||
}
|
||||
|
||||
// Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24).
|
||||
if (_contentsGrid is not null)
|
||||
{
|
||||
int cap = _objects.Get(open)?.ItemsCapacity ?? 0;
|
||||
int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots);
|
||||
while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid);
|
||||
}
|
||||
|
||||
// Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52).
|
||||
if (_containerList is not null)
|
||||
{
|
||||
int capacity = _objects.Get(p)?.ContainersCapacity ?? 0;
|
||||
int bags = _containerList.GetNumUIItems();
|
||||
int slots = capacity > 0 ? capacity : SideBagSlots;
|
||||
slots = Math.Max(slots, bags);
|
||||
slots = Math.Min(slots, SideBagSlots);
|
||||
while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList);
|
||||
}
|
||||
|
||||
// Main-pack cell: the player's own container — clicking it opens/selects the main pack.
|
||||
// Retail draws a CONSTANT backpack icon here, NOT the player's body icon: IconData::RenderIcons
|
||||
// (0x0058d1ee) has an IsThePlayer() branch that draws a fixed backpack with m_itemType =
|
||||
// TYPE_CONTAINER. Compose that backpack base over the Container type-underlay (the player
|
||||
// object's own IconId is the character body, which would render wrong here). The backpack
|
||||
// RenderSurface is 0x0600127E, VISUALLY CONFIRMED at the live gate 2026-06-22 (the earlier
|
||||
// 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) was a green tile, not the
|
||||
// pack — the index value was misreported). Retires AP-51.
|
||||
if (_topContainer is not null)
|
||||
{
|
||||
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
|
||||
_topContainer.Flush();
|
||||
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);
|
||||
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
||||
_topContainer.AddItem(main);
|
||||
}
|
||||
|
||||
ApplyIndicators();
|
||||
RefreshBurden();
|
||||
}
|
||||
|
||||
/// <summary>The effective open container — the explicit one, or the player (main pack) by default.
|
||||
/// 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 uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
|
||||
|
||||
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
|
||||
/// item cell → select-only.</summary>
|
||||
private void AddCell(UiItemList? list, uint guid, bool isContainer)
|
||||
{
|
||||
if (list is null) return;
|
||||
var item = _objects.Get(guid);
|
||||
uint tex = item is null ? 0u
|
||||
: _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
|
||||
cell.SetItem(guid, tex);
|
||||
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
|
||||
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
|
||||
else cell.Clicked = () => SelectItem(guid);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
private static void AddEmptyCell(UiItemList list)
|
||||
{
|
||||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() };
|
||||
cell.DragAcceptSprite = 0x060011F7u;
|
||||
cell.DragRejectSprite = 0x060011F8u;
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
/// <summary>Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay
|
||||
/// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill =
|
||||
/// GetNumContainedItems / itemsCapacity, clamped [0,1]. -1 hides the bar (non-container / unknown
|
||||
/// capacity). For a CLOSED side bag the contents aren't indexed until it's opened (ViewContents),
|
||||
/// so the bar reads empty until then — faithful to retail's known-children count.</summary>
|
||||
private void SetCapacityBar(UiItemSlot cell, uint containerGuid)
|
||||
{
|
||||
int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
|
||||
if (cap <= 0) { cell.CapacityFill = -1f; return; }
|
||||
int n = _objects.GetContents(containerGuid).Count;
|
||||
cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
|
||||
/// <summary>Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot
|
||||
/// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.</summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||||
|
||||
/// <summary>Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always
|
||||
/// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full.</summary>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
if (payload.ObjId == 0) return false;
|
||||
if (targetList == _contentsGrid) return true;
|
||||
if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false;
|
||||
return !IsContainerFull(targetCell.ItemId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
|
||||
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
uint item = payload.ObjId;
|
||||
if (item == 0) return;
|
||||
|
||||
uint container; int placement;
|
||||
if (targetList == _contentsGrid)
|
||||
{
|
||||
container = EffectiveOpen();
|
||||
placement = targetCell.ItemId != 0
|
||||
? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot
|
||||
: _objects.GetContents(container).Count; // first empty = append
|
||||
}
|
||||
else if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
|
||||
container = targetCell.ItemId; // the bag / main pack
|
||||
if (IsContainerFull(container)) return; // red already shown
|
||||
placement = _objects.GetContents(container).Count; // append into it
|
||||
}
|
||||
else return;
|
||||
|
||||
if (container == item) return; // never into itself
|
||||
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
}
|
||||
|
||||
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
|
||||
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
|
||||
private bool IsContainerFull(uint container)
|
||||
{
|
||||
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
|
||||
if (cap <= 0) return false;
|
||||
return _objects.GetContents(container).Count >= cap;
|
||||
}
|
||||
|
||||
/// <summary>Select an item (panel-wide green square) without changing the open container or
|
||||
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
|
||||
private void SelectItem(uint guid)
|
||||
{
|
||||
if (guid == 0) return;
|
||||
_selectedItem = guid;
|
||||
ApplyIndicators();
|
||||
}
|
||||
|
||||
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
||||
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and
|
||||
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail:
|
||||
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
|
||||
private void OpenContainer(uint guid)
|
||||
{
|
||||
if (guid == 0) return;
|
||||
_selectedItem = guid; // the container cell is also the selected item
|
||||
uint open = EffectiveOpen();
|
||||
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
||||
|
||||
uint p = _playerGuid();
|
||||
if (open != p && open != 0)
|
||||
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
|
||||
_openContainer = guid;
|
||||
if (guid != p)
|
||||
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
|
||||
Populate(); // repaint the grid for the new open container
|
||||
}
|
||||
|
||||
/// <summary>Stamp the open-container triangle + selected-item square onto every cell per the
|
||||
/// retail keying (item.itemID == openContainerId / selectedItemId).</summary>
|
||||
private void ApplyIndicators()
|
||||
{
|
||||
SetIndicators(_contentsGrid);
|
||||
SetIndicators(_containerList);
|
||||
SetIndicators(_topContainer);
|
||||
}
|
||||
|
||||
private void SetIndicators(UiItemList? list)
|
||||
{
|
||||
if (list is null) return;
|
||||
uint open = EffectiveOpen();
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||
{
|
||||
var cell = list.GetItem(i);
|
||||
if (cell is null) continue;
|
||||
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
|
||||
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The open container's display name for the contents caption.</summary>
|
||||
private string OpenContainerName()
|
||||
{
|
||||
uint open = EffectiveOpen();
|
||||
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
|
||||
}
|
||||
|
||||
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
|
||||
{
|
||||
if (host is null) return;
|
||||
|
||||
// The caption elements (0x100001D7 "Burden", 0x100001C5 "Contents of Backpack",
|
||||
// 0x100001D8 "%") resolve to UiText (Type-0 inheriting a text base — confirmed live).
|
||||
// Drive the host UiText DIRECTLY: it is already in the paint tree and renders, whereas
|
||||
// a nested child UiText did not paint. Set it to a static centered single-line label.
|
||||
if (host is UiText t)
|
||||
{
|
||||
t.Centered = true;
|
||||
t.DatFont = datFont;
|
||||
t.ClickThrough = true;
|
||||
t.AcceptsFocus = false;
|
||||
t.IsEditControl = false;
|
||||
t.CapturesPointerDrag = false;
|
||||
t.LinesProvider = () =>
|
||||
{
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, CaptionColor) };
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback (non-UiText host): attach a centered label child.
|
||||
var label = new UiText
|
||||
{
|
||||
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||
Centered = true, DatFont = datFont, ClickThrough = true,
|
||||
AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, CaptionColor) };
|
||||
},
|
||||
};
|
||||
host.AddChild(label);
|
||||
}
|
||||
|
||||
/// <summary>Recompute the burden fill + percent. Port of CACQualities::InqLoad
|
||||
/// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden:
|
||||
/// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum.</summary>
|
||||
private void RefreshBurden()
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
int str = _strength() ?? 10; // InqAttribute default 0xa
|
||||
int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
|
||||
int capacity = BurdenMath.EncumbranceCapacity(str, aug);
|
||||
|
||||
int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true
|
||||
? ev : (int?)null;
|
||||
int burden = wire ?? _objects.SumCarriedBurden(p);
|
||||
|
||||
float load = BurdenMath.LoadRatio(capacity, burden);
|
||||
_burdenFill = BurdenMath.LoadToFill(load);
|
||||
_burdenPercent = BurdenMath.LoadToPercent(load);
|
||||
}
|
||||
|
||||
/// <summary>Detach event handlers (idempotent).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
}
|
||||
}
|
||||
159
src/AcDream.App/UI/Layout/ItemListCellTemplate.cs
Normal file
159
src/AcDream.App/UI/Layout/ItemListCellTemplate.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using System.Collections.Generic;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail
|
||||
/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 /
|
||||
/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &id) -> GetByEnum(0x10000038,5,0x23) [the
|
||||
/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned
|
||||
/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background.
|
||||
/// </summary>
|
||||
public static class ItemListCellTemplate
|
||||
{
|
||||
/// <summary>The shared UIItem cell-template catalog. Hardcoded: retail resolves it via
|
||||
/// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal);
|
||||
/// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032)
|
||||
/// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface).</summary>
|
||||
public const uint CatalogLayoutId = 0x21000037u;
|
||||
|
||||
private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id
|
||||
private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty)
|
||||
private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list
|
||||
/// element <paramref name="listElementId"/> in LayoutDesc <paramref name="listLayoutId"/>.
|
||||
/// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps
|
||||
/// the <see cref="UiItemSlot"/> default).
|
||||
/// </summary>
|
||||
public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId)
|
||||
{
|
||||
var listLd = dats.Get<LayoutDesc>(listLayoutId);
|
||||
if (listLd is null) return 0;
|
||||
var listElem = FindDesc(listLd, listElementId);
|
||||
if (listElem is null) return 0;
|
||||
|
||||
uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e
|
||||
if (protoId == 0) return 0;
|
||||
|
||||
var catalog = dats.Get<LayoutDesc>(CatalogLayoutId);
|
||||
if (catalog is null) return 0;
|
||||
var proto = FindDesc(catalog, protoId);
|
||||
if (proto is null) return 0;
|
||||
|
||||
// The empty-slot BACKGROUND is the m_elem_Icon's ItemSlot_Empty media, resolved THROUGH
|
||||
// BaseElement inheritance: the 36x36 container prototype (0x1000033F) nests its icon behind
|
||||
// an inner wrapper (0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so a raw
|
||||
// child search alone returns nothing for containers. We deliberately do NOT use the
|
||||
// prototype's DirectState child overlay: on the container prototype that child is the
|
||||
// open/selected-container TRIANGLE indicator (0x06005D9C), which retail draws ONLY on the
|
||||
// selected container (a deferred container-selection feature) — never as empty-cell art.
|
||||
// (Live visual gate 2026-06-22: frame-first stamped the triangle onto every empty cell.)
|
||||
return FindIconEmpty(catalog, proto, new HashSet<uint>());
|
||||
}
|
||||
|
||||
// ── attribute 0x1000000e: stored as EnumBaseProperty in the dat (Value is the prototype id) ──
|
||||
// Note: the spec anticipated DataIdBaseProperty/ArrayBaseProperty based on the font-DID pattern,
|
||||
// but the live dat uses EnumBaseProperty.Value (uint) — confirmed by runtime reflection.
|
||||
private static uint ReadCellTemplateId(ElementDesc elem)
|
||||
{
|
||||
uint id = ReadIdFromState(elem.StateDesc);
|
||||
if (id != 0) return id;
|
||||
foreach (var s in elem.States)
|
||||
{
|
||||
id = ReadIdFromState(s.Value);
|
||||
if (id != 0) return id;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static uint ReadIdFromState(StateDesc? sd)
|
||||
{
|
||||
if (sd?.Properties is null) return 0;
|
||||
return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0;
|
||||
}
|
||||
|
||||
private static uint ReadId(BaseProperty raw)
|
||||
{
|
||||
if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]);
|
||||
if (raw is DataIdBaseProperty did) return did.Value;
|
||||
if (raw is EnumBaseProperty ep) return ep.Value; // 0x1000000e is EnumBaseProperty in the live dat
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── prototype media: the m_elem_Icon (0x1000033B) ItemSlot_Empty, resolved through inheritance ──
|
||||
// Find the icon child's empty media within `element`'s subtree, following BaseElement edges
|
||||
// within the same catalog (cycle-guarded by `baseSeen`). The 32x32 contents prototype carries
|
||||
// 0x1000033B as a direct child; the 36x36 container prototype reaches it only via an inherited
|
||||
// base (0x10000340 -> 0x1000033E), so a raw child search alone returns nothing for containers.
|
||||
private static uint FindIconEmpty(LayoutDesc catalog, ElementDesc element, HashSet<uint> baseSeen)
|
||||
{
|
||||
if (element.ElementId == IconChildId)
|
||||
{
|
||||
uint e = IconEmptyState(element);
|
||||
if (e != 0) return e;
|
||||
}
|
||||
foreach (var kv in element.Children)
|
||||
{
|
||||
uint e = FindIconEmpty(catalog, kv.Value, baseSeen);
|
||||
if (e != 0) return e;
|
||||
}
|
||||
if (element.BaseElement != 0 && element.BaseLayoutId == CatalogLayoutId && baseSeen.Add(element.BaseElement))
|
||||
{
|
||||
var baseDesc = FindDesc(catalog, element.BaseElement);
|
||||
if (baseDesc is not null)
|
||||
{
|
||||
uint e = FindIconEmpty(catalog, baseDesc, baseSeen);
|
||||
if (e != 0) return e;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static uint IconEmptyState(ElementDesc icon)
|
||||
{
|
||||
foreach (var s in icon.States)
|
||||
if (s.Key.ToString() == ItemSlotEmpty)
|
||||
{
|
||||
uint f = StateImage(s.Value);
|
||||
if (f != 0) return f;
|
||||
}
|
||||
return DirectStateImage(icon); // some icons carry empty media on the DirectState
|
||||
}
|
||||
|
||||
private static uint DirectStateImage(ElementDesc d)
|
||||
=> d.StateDesc is null ? 0u : StateImage(d.StateDesc);
|
||||
|
||||
private static uint StateImage(StateDesc sd)
|
||||
{
|
||||
foreach (var m in sd.Media)
|
||||
if (m is MediaDescImage img && img.File != 0) return img.File;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── depth-first element search by id (LayoutImporter.FindDesc is private there) ──
|
||||
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
|
||||
{
|
||||
foreach (var kv in ld.Elements)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
|
||||
{
|
||||
if (d.ElementId == id) return d;
|
||||
foreach (var kv in d.Children)
|
||||
{
|
||||
var f = FindDescIn(kv.Value, id);
|
||||
if (f is not null) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +191,14 @@ public static class LayoutImporter
|
|||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
|
||||
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
|
||||
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
|
||||
/// (close button / title), elements with their own children, and childless style
|
||||
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
|
||||
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
|
||||
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
|
||||
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
|
||||
|
|
@ -204,6 +212,7 @@ public static class LayoutImporter
|
|||
// Read this element's own fields + media (no inheritance, no children yet).
|
||||
var self = ToInfo(d);
|
||||
var result = self;
|
||||
List<ElementInfo>? baseChildren = null;
|
||||
|
||||
// Apply BaseElement / BaseLayoutId inheritance if present.
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0
|
||||
|
|
@ -215,9 +224,9 @@ public static class LayoutImporter
|
|||
{
|
||||
// Recurse the base chain (already guarded by the HashSet add above).
|
||||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
// Derived fields override the base; result.Children is still empty here
|
||||
// — children are attached below from the DERIVED element's own tree.
|
||||
// Derived fields override the base; children are attached below.
|
||||
result = ElementReader.Merge(baseInfo, self);
|
||||
baseChildren = baseInfo.Children; // capture for the sub-window mount
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +235,28 @@ public static class LayoutImporter
|
|||
foreach (var kv in d.Children)
|
||||
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||||
|
||||
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
|
||||
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
|
||||
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
|
||||
// inert for media-bearing inheritors (close button/title) and childless style prototypes
|
||||
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
|
||||
if (baseChildren is not null
|
||||
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
|
||||
{
|
||||
result.Children.AddRange(baseChildren);
|
||||
|
||||
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
|
||||
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
|
||||
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
|
||||
// ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder − ZLevel·10000)
|
||||
// turns 1000 into ZOrder ≈ −10,000,000 — sinking the whole panel BEHIND the frame's
|
||||
// Alphablend backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpaints the
|
||||
// panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be
|
||||
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
|
||||
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
|
||||
result.ZLevel = self.ZLevel;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +283,7 @@ public static class LayoutImporter
|
|||
Right = d.RightEdge,
|
||||
Bottom = d.BottomEdge,
|
||||
ReadOrder = d.ReadOrder,
|
||||
ZLevel = d.ZLevel,
|
||||
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
|
||||
};
|
||||
|
||||
|
|
|
|||
172
src/AcDream.App/UI/Layout/PaperdollController.cs
Normal file
172
src/AcDream.App/UI/Layout/PaperdollController.cs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the ~21 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the
|
||||
/// inventory frame 0x21000023) to live equipped-item data and makes them drag-drop WIELD targets.
|
||||
/// The acdream analogue of gmPaperDollUI::PostInit + GetLocationInfoFromElementID (named-retail decomp
|
||||
/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2).
|
||||
/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid).
|
||||
/// </summary>
|
||||
public sealed class PaperdollController : IItemListDragHandler
|
||||
{
|
||||
// WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon.
|
||||
private const EquipMask WeaponSlotMask =
|
||||
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded;
|
||||
|
||||
// element-id → EquipMask (verified: dump paperdoll-0x21000024.txt ↔ deep-dive §3a ↔ acclient.h:3193).
|
||||
// The 3 Aetheria sigil slots (0x10000595/96/97) are SetVisible(0) in retail — skipped (Slice 1 scope).
|
||||
private static readonly (uint Element, EquipMask Mask)[] SlotMap =
|
||||
{
|
||||
(0x100005ABu, EquipMask.HeadWear), // 0x1
|
||||
(0x100001E2u, EquipMask.ChestWear), // 0x2
|
||||
(0x100001E3u, EquipMask.UpperLegWear), // 0x40
|
||||
(0x100005B0u, EquipMask.HandWear), // 0x20
|
||||
(0x100005B3u, EquipMask.FootWear), // 0x100
|
||||
(0x100005ACu, EquipMask.ChestArmor), // 0x200
|
||||
(0x100005ADu, EquipMask.AbdomenArmor), // 0x400
|
||||
(0x100005AEu, EquipMask.UpperArmArmor), // 0x800
|
||||
(0x100005AFu, EquipMask.LowerArmArmor), // 0x1000
|
||||
(0x100005B1u, EquipMask.UpperLegArmor), // 0x2000
|
||||
(0x100005B2u, EquipMask.LowerLegArmor), // 0x4000
|
||||
(0x100001DAu, EquipMask.NeckWear), // 0x8000
|
||||
(0x100001DBu, EquipMask.WristWearLeft), // 0x10000
|
||||
(0x100001DDu, EquipMask.WristWearRight), // 0x20000
|
||||
(0x100001DCu, EquipMask.FingerWearLeft), // 0x40000
|
||||
(0x100001DEu, EquipMask.FingerWearRight), // 0x80000
|
||||
(0x100001E1u, EquipMask.Shield), // 0x200000
|
||||
(0x100001E0u, EquipMask.MissileAmmo), // 0x800000 (LIKELY — gate-verify, AP row)
|
||||
(0x100001DFu, WeaponSlotMask), // 0x3500000 composite
|
||||
(0x1000058Eu, EquipMask.TrinketOne), // 0x4000000
|
||||
(0x100005E9u, EquipMask.Cloak), // 0x8000000
|
||||
};
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
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 List<(EquipMask Mask, UiItemList List)> _slots = new();
|
||||
|
||||
private PaperdollController(
|
||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
|
||||
uint emptySlotSprite)
|
||||
{
|
||||
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
|
||||
|
||||
for (int i = 0; i < SlotMap.Length; i++)
|
||||
{
|
||||
var (element, mask) = SlotMap[i];
|
||||
if (layout.FindElement(element) is not UiItemList list) continue;
|
||||
list.RegisterDragHandler(this);
|
||||
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
|
||||
// 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
|
||||
// 0x060011F9 / reject circle 0x060011F8 — the discrete-slot frames, NOT the inventory grid's
|
||||
// insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList;
|
||||
// no need to re-set them here.
|
||||
_slots.Add((mask, list));
|
||||
}
|
||||
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
|
||||
Populate();
|
||||
}
|
||||
|
||||
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)
|
||||
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite);
|
||||
|
||||
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(); }
|
||||
|
||||
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
||||
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
||||
/// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always
|
||||
/// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by
|
||||
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to
|
||||
/// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag).</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
return o.WielderId == p || o.ContainerId == p;
|
||||
}
|
||||
|
||||
public void Populate()
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
|
||||
// The player's equipped gear (one pass). Scoped to the player so a vendor's/NPC's wielded item
|
||||
// (which can also carry CurrentWieldedLocation) can't leak into the doll.
|
||||
var equipped = new List<ClientObject>();
|
||||
foreach (var o in _objects.Objects)
|
||||
if (o.CurrentlyEquippedLocation != EquipMask.None && (o.WielderId == p || o.ContainerId == p))
|
||||
equipped.Add(o);
|
||||
|
||||
foreach (var (mask, list) in _slots)
|
||||
{
|
||||
ClientObject? worn = null;
|
||||
foreach (var o in equipped)
|
||||
if ((o.CurrentlyEquippedLocation & mask) != EquipMask.None) { worn = o; break; }
|
||||
|
||||
if (worn is null) { list.Cell.Clear(); continue; }
|
||||
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
|
||||
list.Cell.SetItem(worn.ObjectId, tex);
|
||||
}
|
||||
}
|
||||
|
||||
private EquipMask MaskFor(UiItemList list)
|
||||
{
|
||||
foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask;
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler ──────────────────────────────────────────────────────────────────────
|
||||
/// <summary>No-op: a wielded item stays put until the server confirms (same as the inventory grid,
|
||||
/// unlike the toolbar's remove-on-lift). Unwield happens on DROP onto the pack grid — InventoryController.</summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||||
|
||||
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
|
||||
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return false;
|
||||
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None;
|
||||
}
|
||||
|
||||
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
|
||||
/// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the
|
||||
/// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
var item = _objects.Get(payload.ObjId);
|
||||
if (item is null) return;
|
||||
EquipMask wieldMask = item.ValidLocations & 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);
|
||||
}
|
||||
|
||||
/// <summary>Detach event handlers (idempotent).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <c>CreateObject</c> resolves a formerly-unknown guid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ToolbarController
|
||||
public sealed class ToolbarController : IItemListDragHandler
|
||||
{
|
||||
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
|
||||
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
|
||||
|
|
@ -60,6 +60,10 @@ public sealed class ToolbarController
|
|||
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
||||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
||||
private readonly AcDream.Core.Items.ShortcutStore _store = new();
|
||||
private bool _storeLoaded;
|
||||
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||
|
||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
||||
|
|
@ -82,7 +86,9 @@ public sealed class ToolbarController
|
|||
CombatState? combatState,
|
||||
uint[]? peaceDigits,
|
||||
uint[]? warDigits,
|
||||
uint[]? emptyDigits)
|
||||
uint[]? emptyDigits,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_shortcuts = shortcuts;
|
||||
|
|
@ -91,12 +97,23 @@ public sealed class ToolbarController
|
|||
_peaceDigits = peaceDigits;
|
||||
_warDigits = warDigits;
|
||||
_emptyDigits = emptyDigits;
|
||||
_sendAddShortcut = sendAddShortcut;
|
||||
_sendRemoveShortcut = sendRemoveShortcut;
|
||||
|
||||
for (int i = 0; i < SlotIds.Length; i++)
|
||||
{
|
||||
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
|
||||
if (_slots[i] is { } list)
|
||||
{
|
||||
WireClick(list);
|
||||
// B.1 drag-drop spine: this controller is the drop handler for every
|
||||
// toolbar slot list; each cell knows its slot index + that it's a
|
||||
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
|
||||
list.RegisterDragHandler(this);
|
||||
list.Cell.SlotIndex = i;
|
||||
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
|
||||
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the four mutually-exclusive combat-mode indicator elements.
|
||||
|
|
@ -125,11 +142,20 @@ public sealed class ToolbarController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids.
|
||||
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
|
||||
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
|
||||
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
|
||||
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
|
||||
/// </summary>
|
||||
private bool IsShortcutGuid(uint guid)
|
||||
{
|
||||
if (_storeLoaded)
|
||||
{
|
||||
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
|
||||
if (_store.Get(s) == guid) return true;
|
||||
return false;
|
||||
}
|
||||
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
|
||||
foreach (var sc in _shortcuts())
|
||||
if (sc.ObjectGuid == guid) return true;
|
||||
return false;
|
||||
|
|
@ -174,10 +200,13 @@ public sealed class ToolbarController
|
|||
CombatState? combatState = null,
|
||||
uint[]? peaceDigits = null,
|
||||
uint[]? warDigits = null,
|
||||
uint[]? emptyDigits = null)
|
||||
uint[]? emptyDigits = 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,
|
||||
sendAddShortcut, sendRemoveShortcut);
|
||||
c.Populate();
|
||||
return c;
|
||||
}
|
||||
|
|
@ -188,24 +217,31 @@ public sealed class ToolbarController
|
|||
/// Entries whose item is not yet in the repo are silently skipped here; the
|
||||
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
|
||||
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
|
||||
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the
|
||||
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
|
||||
/// </summary>
|
||||
public void Populate()
|
||||
{
|
||||
// Clear all slot cells first (flush).
|
||||
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
|
||||
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
|
||||
if (!_storeLoaded && _shortcuts().Count > 0)
|
||||
{
|
||||
_store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
|
||||
_storeLoaded = true;
|
||||
}
|
||||
|
||||
foreach (var list in _slots) list?.Cell.Clear();
|
||||
|
||||
foreach (var sc in _shortcuts())
|
||||
for (int slot = 0; slot < _slots.Length; slot++)
|
||||
{
|
||||
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
|
||||
if (sc.Index >= (uint)_slots.Length) continue;
|
||||
var list = _slots[(int)sc.Index];
|
||||
uint guid = _store.Get(slot);
|
||||
if (guid == 0) continue;
|
||||
var list = _slots[slot];
|
||||
if (list is null) continue;
|
||||
|
||||
var item = _repo.Get(sc.ObjectGuid);
|
||||
if (item is null) continue; // deferred: ObjectAdded will re-call Populate
|
||||
|
||||
var item = _repo.Get(guid);
|
||||
if (item is null) continue; // deferred: ObjectAdded re-calls Populate
|
||||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
list.Cell.SetItem(sc.ObjectGuid, tex);
|
||||
list.Cell.SetItem(guid, tex);
|
||||
}
|
||||
|
||||
// Re-stamp slot number labels after any item change.
|
||||
|
|
@ -287,4 +323,46 @@ public sealed class ToolbarController
|
|||
_useItem(list.Cell.ItemId);
|
||||
};
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
|
||||
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
|
||||
// Retail model (remove-on-lift / place-on-drop / no-restore):
|
||||
// lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately)
|
||||
// drop → AddShortcut (0x019C) + optional swap of evicted item into source
|
||||
// off-bar release → the lift's removal stands (no restore)
|
||||
// Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
|
||||
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
|
||||
// drop onto a slot. Off-bar release leaves it removed.
|
||||
_store.Remove(payload.SourceSlot);
|
||||
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
|
||||
Populate();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> payload.ObjId != 0;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
// Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5):
|
||||
// evict the target's occupant, place the dragged item there, and bump the evicted item into
|
||||
// the (now-vacated) source slot if it's free.
|
||||
int target = targetCell.SlotIndex;
|
||||
uint evicted = _store.Get(target); // RemoveShortCutInSlotNum(target)
|
||||
if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); }
|
||||
_store.Set(target, payload.ObjId); // AddShortcut(dragged, target)
|
||||
_sendAddShortcut?.Invoke((uint)target, payload.ObjId);
|
||||
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
|
||||
{ // displaced → vacated source slot
|
||||
_store.Set(payload.SourceSlot, evicted);
|
||||
_sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted);
|
||||
}
|
||||
Populate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
src/AcDream.App/UI/UiCollapsibleFrame.cs
Normal file
36
src/AcDream.App/UI/UiCollapsibleFrame.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A toolbar-frame variant that snaps its height between two stops — collapsed (row 2 hidden) and
|
||||
/// expanded (row 2 shown) — and toggles a set of "second-row" elements to match. Resized via the
|
||||
/// bottom edge (the mount sets <see cref="UiElement.ResizableEdges"/> = Bottom); each tick it resolves
|
||||
/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a
|
||||
/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17.
|
||||
/// </summary>
|
||||
public sealed class UiCollapsibleFrame : UiNineSlicePanel
|
||||
{
|
||||
public UiCollapsibleFrame(Func<uint, (uint, int, int)> resolve) : base(resolve) { }
|
||||
|
||||
public float CollapsedHeight { get; set; }
|
||||
public float ExpandedHeight { get; set; }
|
||||
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
|
||||
public IReadOnlyList<UiElement> SecondRow { get; set; } = Array.Empty<UiElement>();
|
||||
|
||||
/// <summary>True when the frame is at (or nearer) the expanded stop.</summary>
|
||||
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet — no snap
|
||||
bool expanded = IsExpanded;
|
||||
Height = expanded ? ExpandedHeight : CollapsedHeight; // snap the dragged height to a stop
|
||||
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
|
||||
}
|
||||
|
||||
/// <summary>Test hook — OnTick is protected. Drives one snap+visibility reconcile.</summary>
|
||||
internal void TickForTest(double dt) => OnTick(dt);
|
||||
}
|
||||
|
|
@ -113,15 +113,31 @@ public abstract class UiElement
|
|||
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
|
||||
public bool CapturesPointerDrag { get; set; }
|
||||
|
||||
/// <summary>If true, a left-press-and-move on this element starts a DRAG-DROP
|
||||
/// (<see cref="UiRoot"/> promotes to BeginDrag) rather than moving a Draggable
|
||||
/// ancestor window — so an item cell inside the toolbar frame drags the item, not
|
||||
/// the window. Distinct from <see cref="CapturesPointerDrag"/> (a self-driven
|
||||
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
|
||||
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
|
||||
public virtual bool IsDragSource => false;
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
||||
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
|
||||
public float MaxHeight { get; set; } = float.MaxValue;
|
||||
|
||||
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeX { get; set; } = true;
|
||||
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeY { get; set; } = true;
|
||||
|
||||
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
|
||||
/// Set to e.g. <see cref="ResizeEdges.Bottom"/> to allow only a bottom-edge drag (the collapse toolbar).</summary>
|
||||
public ResizeEdges ResizableEdges { get; set; } =
|
||||
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
||||
|
||||
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
||||
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
||||
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
|
||||
|
|
@ -203,6 +219,16 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
public virtual bool OnEvent(in UiEvent e) => false;
|
||||
|
||||
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
|
||||
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
|
||||
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
|
||||
public virtual object? GetDragPayload() => null;
|
||||
|
||||
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
|
||||
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
|
||||
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
|
||||
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Tooltip text for this widget. Retail fires event 0x07 after
|
||||
/// ~1000ms hover, then queries the widget's virtual "GetString"
|
||||
|
|
|
|||
|
|
@ -103,6 +103,14 @@ public sealed class UiHost : System.IDisposable
|
|||
_ => UiMouseButton.Left,
|
||||
};
|
||||
|
||||
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
||||
|
||||
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary>
|
||||
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
|
||||
|
||||
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
|
||||
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
TextRenderer.Dispose();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ public sealed class UiItemList : UiElement
|
|||
{
|
||||
private readonly List<UiItemSlot> _cells = new();
|
||||
|
||||
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
|
||||
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
|
||||
public UiItemList(Func<uint, (uint tex, int w, int h)>? spriteResolve = null)
|
||||
{
|
||||
SpriteResolve = spriteResolve;
|
||||
|
|
@ -24,6 +28,31 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
private uint _cellEmptySprite;
|
||||
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
|
||||
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
|
||||
/// <see cref="Layout.ItemListCellTemplate.ResolveEmptySprite"/>). 0 = leave the
|
||||
/// <see cref="UiItemSlot.EmptySprite"/> default (0x060074CF). Applied to every existing
|
||||
/// AND future cell.</summary>
|
||||
public uint CellEmptySprite
|
||||
{
|
||||
get => _cellEmptySprite;
|
||||
set
|
||||
{
|
||||
_cellEmptySprite = value;
|
||||
if (value != 0)
|
||||
foreach (var c in _cells) c.EmptySprite = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The drag handler this list routes drops to (a panel controller).
|
||||
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
|
||||
public IItemListDragHandler? DragHandler { get; private set; }
|
||||
|
||||
/// <summary>Register the panel's drag handler on this list. Retail:
|
||||
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
|
||||
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
|
||||
|
||||
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
|
||||
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
|
||||
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>
|
||||
|
|
@ -41,9 +70,81 @@ public sealed class UiItemList : UiElement
|
|||
public void AddItem(UiItemSlot cell)
|
||||
{
|
||||
cell.SpriteResolve ??= SpriteResolve;
|
||||
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
|
||||
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
|
||||
// The list lays cells out procedurally (grid offset + scroll clip), so cells must be
|
||||
// EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor
|
||||
// on every child after OnDraw, which would otherwise reset the scroll offset to each
|
||||
// cell's captured base position (the escaping-grid bug). Anchors=None makes LayoutCells
|
||||
// the sole authority over cell rects.
|
||||
cell.Anchors = AnchorEdges.None;
|
||||
_cells.Add(cell);
|
||||
AddChild(cell);
|
||||
LayoutCells();
|
||||
}
|
||||
|
||||
/// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
|
||||
public int Columns { get; set; } = 1;
|
||||
|
||||
/// <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; }
|
||||
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
|
||||
public float CellHeight { get; set; }
|
||||
|
||||
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
|
||||
/// <paramref name="columns"/> columns.</summary>
|
||||
public static int RowCount(int cellCount, int columns)
|
||||
{
|
||||
int cols = columns < 1 ? 1 : columns;
|
||||
return (cellCount + cols - 1) / cols;
|
||||
}
|
||||
|
||||
/// <summary>Row-major pixel offset of cell <paramref name="index"/> in a grid of
|
||||
/// <paramref name="columns"/> columns at the given cell pitch.</summary>
|
||||
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
|
||||
{
|
||||
int col = index % columns, row = index / columns;
|
||||
return (col * cellW, row * 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
|
||||
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
|
||||
internal void LayoutCells()
|
||||
{
|
||||
if (CellWidth <= 0f)
|
||||
{
|
||||
// Fill mode (the toolbar single cell): size the one cell to the list.
|
||||
if (_cells.Count > 0)
|
||||
{
|
||||
var c = _cells[0];
|
||||
c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
int cols = Columns < 1 ? 1 : Columns;
|
||||
int cellH = (int)MathF.Round(CellHeight);
|
||||
|
||||
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
|
||||
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
|
||||
// so the clamp uses the right max.
|
||||
Scroll.LineHeight = cellH > 0 ? cellH : 1;
|
||||
Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH;
|
||||
Scroll.ViewHeight = (int)MathF.Floor(Height);
|
||||
Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to the new max
|
||||
float scrollY = Scroll.ScrollY;
|
||||
|
||||
for (int i = 0; i < _cells.Count; i++)
|
||||
{
|
||||
var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight);
|
||||
float top = baseY - scrollY;
|
||||
var cell = _cells[i];
|
||||
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
|
||||
// Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully inside
|
||||
// [0, Height] draws; a partially-scrolled row is hidden.
|
||||
cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f;
|
||||
}
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
|
|
@ -52,16 +153,21 @@ public sealed class UiItemList : UiElement
|
|||
_cells.Clear();
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
|
||||
{
|
||||
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
|
||||
Scroll.ScrollByLines(-e.Data0);
|
||||
return true;
|
||||
}
|
||||
return base.OnEvent(e);
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// The factory sets THIS list's Width/Height AFTER construction, so the cell
|
||||
// (added in the ctor) starts 0x0. For the single-cell toolbar slot, keep the
|
||||
// cell sized to the list each frame; the cell paints itself in the children
|
||||
// pass that follows. (N-cell grid layout is the inventory phase.)
|
||||
if (_cells.Count > 0)
|
||||
{
|
||||
var cell = _cells[0];
|
||||
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
|
||||
}
|
||||
// The factory sets Width/Height AFTER construction, so re-layout each frame:
|
||||
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
|
||||
LayoutCells();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,56 @@ public sealed class UiItemSlot : UiElement
|
|||
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
||||
public uint IconTexture { get; private set; }
|
||||
|
||||
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
|
||||
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 1–9 label, -1 on the
|
||||
/// bottom row). Set by the controller; used as the drag payload's SourceSlot and to
|
||||
/// identify the drop TARGET slot.</summary>
|
||||
public int SlotIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
|
||||
/// flags). Controller overrides; default Inventory.</summary>
|
||||
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
|
||||
|
||||
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
|
||||
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
|
||||
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
|
||||
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
|
||||
/// state id 0x10000040).</summary>
|
||||
public uint DragRejectSprite { get; set; } = 0x060011F8u;
|
||||
|
||||
/// <summary>True when this cell is the OPEN container (its contents fill the grid). Draws the
|
||||
/// open-container triangle. Port of UIElement_ItemList::UpdateOpenContainerIndicator
|
||||
/// (0x004e3070) → SetOpenContainerState (0x004e1200): shown when item.itemID == openContainerId.</summary>
|
||||
public bool IsOpenContainer { get; set; }
|
||||
/// <summary>Open-container triangle sprite (element 0x10000450 on the container prototype
|
||||
/// 0x1000033F). Configurable; guard id != 0 before resolving.</summary>
|
||||
public uint OpenContainerSprite { get; set; } = 0x06005D9Cu;
|
||||
|
||||
/// <summary>True when this cell is the SELECTED item. Draws the green/yellow selection square.
|
||||
/// Port of UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0) → SetSelectedState
|
||||
/// (0x004e1240): shown when item.itemID == selectedItemId. Uniform across item + container cells.</summary>
|
||||
public bool Selected { get; set; }
|
||||
/// <summary>Selected-item square sprite (element 0x10000342 on the 32×32 item prototype
|
||||
/// 0x10000341; pixel-confirmed green/yellow frame). Drawn as a procedural overlay so it renders
|
||||
/// on the 36×36 container cell too (whose prototype lacks the square child).</summary>
|
||||
public uint SelectedSprite { get; set; } = 0x06004D21u;
|
||||
|
||||
/// <summary>Container fullness [0..1], or -1 = hidden (the cell is not a container, or its
|
||||
/// itemsCapacity is unknown/0). Port of UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a
|
||||
/// per-cell vertical UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only when
|
||||
/// isContainer && itemsCapacity > 0, fill = numContainedItems / itemsCapacity (meter attr 0x69).</summary>
|
||||
public float CapacityFill { get; set; } = -1f;
|
||||
/// <summary>Capacity-bar track sprite (meter 0x10000347 DirectState).</summary>
|
||||
public uint CapacityBackSprite { get; set; } = 0x06004D22u;
|
||||
/// <summary>Capacity-bar fill sprite (meter 0x10000347 front child 0x00000002 DirectState).</summary>
|
||||
public uint CapacityFrontSprite { get; set; } = 0x06004D23u;
|
||||
|
||||
/// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
|
||||
public enum DragAcceptState { None, Accept, Reject }
|
||||
private DragAcceptState _dragAccept = DragAcceptState.None;
|
||||
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
|
||||
internal DragAcceptState DragAcceptVisual => _dragAccept;
|
||||
|
||||
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
|
||||
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
|
||||
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
|
||||
|
|
@ -37,6 +87,30 @@ public sealed class UiItemSlot : UiElement
|
|||
|
||||
public void Clear() { ItemId = 0; IconTexture = 0; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetDragPayload()
|
||||
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override (uint tex, int w, int h)? GetDragGhost()
|
||||
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
|
||||
|
||||
/// <summary>An OCCUPIED slot is a drag source — a press-and-move picks up the item
|
||||
/// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
|
||||
/// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar
|
||||
/// movable by its empty cells / chrome. Drives <see cref="UiRoot"/>'s mousedown
|
||||
/// window-vs-item disambiguation (retail moves the window via a dragbar, never cells;
|
||||
/// our whole-window-drag approximation reconciles by gating on occupancy).</summary>
|
||||
public override bool IsDragSource => ItemId != 0;
|
||||
|
||||
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
|
||||
private UiItemList? FindList()
|
||||
{
|
||||
UiElement? e = Parent;
|
||||
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Shortcut number (slot label) ─────────────────────────────────────────
|
||||
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||||
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
|
||||
|
|
@ -100,7 +174,40 @@ public sealed class UiItemSlot : UiElement
|
|||
/// <inheritdoc/>
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
|
||||
switch (e.Type)
|
||||
{
|
||||
// Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a
|
||||
// drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
|
||||
// post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
|
||||
case UiEventType.MouseDown:
|
||||
return true; // consume the press; no use here
|
||||
case UiEventType.Click:
|
||||
Clicked?.Invoke();
|
||||
return true;
|
||||
|
||||
case UiEventType.DragBegin:
|
||||
// Notify the source list's handler so it can lift (remove + wire) — retail
|
||||
// RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first.
|
||||
if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp)
|
||||
lh.OnDragLift(liftList, this, lp);
|
||||
return true;
|
||||
|
||||
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
|
||||
_dragAccept = (FindList() is { DragHandler: { } h } list
|
||||
&& e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
|
||||
? DragAcceptState.Accept : DragAcceptState.Reject;
|
||||
return true;
|
||||
|
||||
case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
|
||||
_dragAccept = DragAcceptState.None;
|
||||
return true;
|
||||
|
||||
case UiEventType.DropReleased:
|
||||
_dragAccept = DragAcceptState.None;
|
||||
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
|
||||
dh.HandleDropRelease(dl, this, dp);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -139,5 +246,63 @@ public sealed class UiItemSlot : UiElement
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical
|
||||
// UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells
|
||||
// (CapacityFill >= 0). Track drawn full; fill clipped to the fraction from the BOTTOM (the
|
||||
// "how full" direction). Procedural — UiItemSlot is a behavioral leaf. Guard id != 0 first.
|
||||
if (CapacityFill >= 0f && SpriteResolve is not null)
|
||||
{
|
||||
const float by = 1f, bw = 5f, bh = 30f; // element 0x10000347 size (dat 5×30 at y=1)
|
||||
float bx = Width - bw; // flush to the cell's right edge (visual gate: the dat X=26 sat ~5px off the edge)
|
||||
if (CapacityBackSprite != 0)
|
||||
{
|
||||
var (bt, _, _) = SpriteResolve(CapacityBackSprite);
|
||||
if (bt != 0) ctx.DrawSprite(bt, bx, by, bw, bh, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
float f = Math.Clamp(CapacityFill, 0f, 1f);
|
||||
if (f > 0f && CapacityFrontSprite != 0)
|
||||
{
|
||||
var (ft, _, _) = SpriteResolve(CapacityFrontSprite);
|
||||
if (ft != 0)
|
||||
{
|
||||
// Bottom-up fill: draw the bottom f-fraction of the bar, sampling the matching
|
||||
// bottom slice of the front sprite (UV v from 1-f to 1).
|
||||
float fh = bh * f;
|
||||
ctx.DrawSprite(ft, bx, by + (bh - fh), bw, fh, 0f, 1f - f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square
|
||||
// (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays;
|
||||
// guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the
|
||||
// square; both under the transient drag overlay below.
|
||||
if (IsOpenContainer && SpriteResolve is not null && OpenContainerSprite != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(OpenContainerSprite);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(SelectedSprite);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
|
||||
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
|
||||
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
|
||||
if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
|
||||
{
|
||||
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
|
||||
if (id != 0)
|
||||
{
|
||||
var (tex, _, _) = SpriteResolve(id);
|
||||
if (tex != 0)
|
||||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ public sealed class UiMeter : UiElement
|
|||
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
|
||||
public uint FrontRight { get; set; }
|
||||
|
||||
/// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
|
||||
/// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
|
||||
public bool Vertical { get; set; }
|
||||
|
||||
/// <summary>For a vertical meter, fill grows from the bottom up (retail direction 4)
|
||||
/// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed.</summary>
|
||||
public bool FillFromBottom { get; set; } = true;
|
||||
|
||||
public UiMeter() { ClickThrough = true; }
|
||||
|
||||
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
|
||||
|
|
@ -71,6 +79,18 @@ public sealed class UiMeter : UiElement
|
|||
return (0f, 0f, w * pct, h);
|
||||
}
|
||||
|
||||
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the vertical fill rect
|
||||
/// (local px). <paramref name="fromBottom"/> true → the fill occupies the bottom
|
||||
/// <c>h*pct</c> px (retail direction 4); false → the top (direction 2).</summary>
|
||||
public static (float x, float y, float w, float h) ComputeVFillRect(
|
||||
float pct, float w, float h, bool fromBottom)
|
||||
{
|
||||
if (pct < 0f) pct = 0f;
|
||||
if (pct > 1f) pct = 1f;
|
||||
float fh = h * pct;
|
||||
return (0f, fromBottom ? h - fh : 0f, w, fh);
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
float? pct = Fill();
|
||||
|
|
@ -78,14 +98,25 @@ public sealed class UiMeter : UiElement
|
|||
|
||||
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
||||
{
|
||||
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
|
||||
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
|
||||
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
|
||||
// The front carries its own right-cap (shown at 100%); clipping below 100%
|
||||
// removes it and reveals the back track's right-cap — retail's scissor-fill.
|
||||
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||
if (Vertical)
|
||||
{
|
||||
// Track full height, fill clipped to the fraction along the fill direction.
|
||||
// The burden meter is a single-tile bar (BackTile/FrontTile, no caps).
|
||||
DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
|
||||
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
|
||||
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
|
||||
// The front carries its own right-cap (shown at 100%); clipping below 100%
|
||||
// removes it and reveals the back track's right-cap — retail's scissor-fill.
|
||||
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
|
||||
if (pct is not null && p > 0f)
|
||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -158,6 +189,28 @@ public sealed class UiMeter : UiElement
|
|||
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
|
||||
}
|
||||
|
||||
/// <summary>Draws a single-tile vertical bar slice. The track (<paramref name="isFill"/>
|
||||
/// false) spans the full height; the fill spans <paramref name="visibleH"/> px from the
|
||||
/// bottom (<paramref name="fromBottom"/>) or top, UV-cropped to that fraction of the
|
||||
/// sprite so the fill reveals the matching part of the art (retail
|
||||
/// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op.</summary>
|
||||
private void DrawVBar(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint tileId, float visibleH, bool fromBottom, bool isFill)
|
||||
{
|
||||
if (tileId == 0 || visibleH <= 0f) return;
|
||||
var (tex, _, _) = resolve(tileId);
|
||||
if (tex == 0) return;
|
||||
float w = Width, h = Height;
|
||||
if (visibleH > h) visibleH = h;
|
||||
float frac = h > 0f ? visibleH / h : 0f;
|
||||
// Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]).
|
||||
// Top-down / track: top of the rect AND top of the sprite (v in [0, frac]).
|
||||
float y = isFill && fromBottom ? h - visibleH : 0f;
|
||||
float v0 = isFill && fromBottom ? 1f - frac : 0f;
|
||||
float v1 = isFill && fromBottom ? 1f : frac;
|
||||
ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
|
||||
}
|
||||
|
||||
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
|
||||
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
||||
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace AcDream.App.UI;
|
|||
/// the widget is testable without GL. In production:
|
||||
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
|
||||
/// </summary>
|
||||
public sealed class UiNineSlicePanel : UiPanel
|
||||
public class UiNineSlicePanel : UiPanel
|
||||
{
|
||||
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
|
||||
public readonly record struct Rect(float X, float Y, float W, float H);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ 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; }
|
||||
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;
|
||||
private UiElement? _lastDragHoverTarget;
|
||||
private int _pressX, _pressY;
|
||||
private bool _dragCandidate;
|
||||
|
|
@ -141,9 +144,23 @@ public sealed class UiRoot : UiElement
|
|||
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
|
||||
ctx.BeginOverlayLayer();
|
||||
DrawOverlays(ctx);
|
||||
DrawDragGhost(ctx);
|
||||
ctx.EndOverlayLayer();
|
||||
}
|
||||
|
||||
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
|
||||
|
||||
/// <summary>Paint the drag ghost at the cursor. The texture comes from the snapshotted
|
||||
/// ghost captured in <see cref="BeginDrag"/> so UiRoot stays item-agnostic and the ghost
|
||||
/// survives the source cell emptying on lift; the ghost is NOT a tree element, so it
|
||||
/// never intercepts hit-tests.</summary>
|
||||
private void DrawDragGhost(UiRenderContext ctx)
|
||||
{
|
||||
if (_dragGhost is not { } g || g.tex == 0) return;
|
||||
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||
}
|
||||
|
||||
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
||||
|
||||
public void OnMouseMove(int x, int y)
|
||||
|
|
@ -159,7 +176,8 @@ public sealed class UiRoot : UiElement
|
|||
var (nx, ny, nw, nh) = ResizeRect(
|
||||
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
||||
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
|
||||
float.MaxValue, _resizeTarget.MaxHeight);
|
||||
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
||||
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
||||
return;
|
||||
|
|
@ -185,7 +203,7 @@ public sealed class UiRoot : UiElement
|
|||
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|
||||
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
|
||||
{
|
||||
BeginDrag(Captured, payload: null);
|
||||
BeginDrag(Captured);
|
||||
}
|
||||
}
|
||||
if (DragSource is not null)
|
||||
|
|
@ -230,6 +248,8 @@ public sealed class UiRoot : UiElement
|
|||
// A left-drag starting near an edge resizes; interior drag repositions;
|
||||
// otherwise it's a normal drag-drop candidate.
|
||||
var window = FindWindow(target);
|
||||
// Retail-faithful: pressing on a window raises it above its peers.
|
||||
if (window is not null) BringToFront(window);
|
||||
if (btn == UiMouseButton.Left && window is not null)
|
||||
{
|
||||
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
|
||||
|
|
@ -244,6 +264,17 @@ public sealed class UiRoot : UiElement
|
|||
_resizeMouseX = x; _resizeMouseY = y;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
else if (target.IsDragSource)
|
||||
{
|
||||
// A drag SOURCE (e.g. an occupied item cell) inside a Draggable window
|
||||
// starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic:
|
||||
// it only reads the IsDragSource flag (the cell decides occupancy). The
|
||||
// BeginDrag promotion happens on the >3px move (and cancels if the source's
|
||||
// GetDragPayload() returns null). Empty cells are NOT drag sources, so they
|
||||
// fall through to window.Draggable below (IA-12 whole-window-drag), keeping
|
||||
// the bar movable by its empty cells / chrome.
|
||||
_dragCandidate = true;
|
||||
}
|
||||
else if (target.CapturesPointerDrag)
|
||||
{
|
||||
// The pressed widget owns interior drags (e.g. text selection):
|
||||
|
|
@ -445,12 +476,64 @@ public sealed class UiRoot : UiElement
|
|||
public void SetCapture(UiElement e) => Captured = e;
|
||||
public void ReleaseCapture() => Captured = null;
|
||||
|
||||
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
|
||||
|
||||
private readonly Dictionary<string, UiElement> _windows = new();
|
||||
|
||||
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
|
||||
/// Does NOT add it to the tree — the caller mounts via AddChild and controls
|
||||
/// initial Visible. Idempotent (re-register replaces). The dict is the source
|
||||
/// of truth — independent of UiElement.Name (init-only, not set here).</summary>
|
||||
public void RegisterWindow(string name, UiElement window) => _windows[name] = window;
|
||||
|
||||
/// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
|
||||
public bool ShowWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = true;
|
||||
BringToFront(w);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
|
||||
public bool HideWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
if (w.Visible) { HideWindow(name); return false; }
|
||||
ShowWindow(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Raise a top-level window above its siblings by setting its ZOrder
|
||||
/// one past the current max among the OTHER top-level children. Used on Show
|
||||
/// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
|
||||
public void BringToFront(UiElement window)
|
||||
{
|
||||
int top = window.ZOrder;
|
||||
foreach (var c in Children)
|
||||
if (!ReferenceEquals(c, window))
|
||||
top = System.Math.Max(top, c.ZOrder + 1);
|
||||
window.ZOrder = top;
|
||||
}
|
||||
|
||||
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
|
||||
|
||||
private void BeginDrag(UiElement source, object? payload)
|
||||
private void BeginDrag(UiElement source)
|
||||
{
|
||||
var payload = source.GetDragPayload();
|
||||
if (payload is null) { _dragCandidate = false; return; }
|
||||
DragSource = source;
|
||||
DragPayload = payload;
|
||||
_dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e);
|
||||
}
|
||||
|
|
@ -483,16 +566,18 @@ public sealed class UiRoot : UiElement
|
|||
private void FinishDrag(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
var target = t ?? DragSource!;
|
||||
var accepted = t is not null && t != DragSource;
|
||||
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
|
||||
Data0: accepted ? 1 : 0,
|
||||
Data1: (int)lx, Data2: (int)ly,
|
||||
Payload: DragPayload);
|
||||
target.OnEvent(in e);
|
||||
|
||||
if (t is not null)
|
||||
{
|
||||
// Dropped on a real element — deliver DropReleased; the hit cell's handler places.
|
||||
// A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal.
|
||||
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
|
||||
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).
|
||||
DragSource = null;
|
||||
DragPayload = null;
|
||||
_dragGhost = null;
|
||||
_lastDragHoverTarget = null;
|
||||
}
|
||||
|
||||
|
|
@ -593,21 +678,23 @@ public sealed class UiRoot : UiElement
|
|||
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
|
||||
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
|
||||
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
|
||||
e &= w.ResizableEdges;
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
|
||||
/// move the origin so the opposite edge stays put.</summary>
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>) and
|
||||
/// (<paramref name="maxW"/>,<paramref name="maxH"/>). Left/Top edges move the
|
||||
/// origin so the opposite edge stays put.</summary>
|
||||
public static (float x, float y, float w, float h) ResizeRect(
|
||||
float startX, float startY, float startW, float startH,
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH)
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH)
|
||||
{
|
||||
float x = startX, y = startY, w = startW, h = startH;
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; }
|
||||
return (x, y, w, h);
|
||||
}
|
||||
|
||||
|
|
|
|||
8
src/AcDream.App/UI/WindowNames.cs
Normal file
8
src/AcDream.App/UI/WindowNames.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Canonical registry names for top-level retail-UI windows, so the
|
||||
/// mount, the window registry, and the toggle keybind all agree on one literal.</summary>
|
||||
public static class WindowNames
|
||||
{
|
||||
public const string Inventory = "inventory";
|
||||
}
|
||||
|
|
@ -65,7 +65,10 @@ public static class GameEventWiring
|
|||
// D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the
|
||||
// toolbar can populate itself at login without keeping a parser reference.
|
||||
// Optional so all existing callers and tests compile unchanged.
|
||||
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null)
|
||||
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
|
||||
// B-Wire: the local player's server guid. When provided, the PD handler upserts
|
||||
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
|
||||
Func<uint>? playerGuid = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -235,17 +238,65 @@ public static class GameEventWiring
|
|||
dispatcher.Register(GameEventType.WieldObject, e =>
|
||||
{
|
||||
var p = GameEvents.ParseWieldObject(e.Payload.Span);
|
||||
if (p is not null) items.MoveItem(
|
||||
if (p is null) return;
|
||||
items.MoveItem(
|
||||
p.Value.ItemGuid,
|
||||
newContainerId: p.Value.WielderGuid,
|
||||
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
|
||||
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
|
||||
});
|
||||
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
|
||||
{
|
||||
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
|
||||
if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid,
|
||||
newSlot: (int)p.Value.Placement);
|
||||
if (p is null) return;
|
||||
items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement);
|
||||
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
|
||||
});
|
||||
|
||||
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
|
||||
// opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and
|
||||
// record the new entries in order (ContainerSlot = index — ACE writes entries
|
||||
// OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController)
|
||||
// repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents.
|
||||
dispatcher.Register(GameEventType.ViewContents, e =>
|
||||
{
|
||||
var p = GameEvents.ParseViewContents(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
var guids = new uint[p.Value.Items.Count];
|
||||
for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid;
|
||||
items.ReplaceContents(p.Value.ContainerGuid, guids);
|
||||
});
|
||||
|
||||
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
|
||||
// to the world. Unparent it from its container (it's now a ground object) so
|
||||
// the inventory grid drops the cell; the object itself survives.
|
||||
dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
|
||||
{
|
||||
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
|
||||
if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u);
|
||||
});
|
||||
|
||||
// B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move.
|
||||
// Snap the item back to its pre-move slot. Log only when there was no pending move
|
||||
// (a server-initiated failure on a non-optimistic path).
|
||||
dispatcher.Register(GameEventType.InventoryServerSaveFailed, e =>
|
||||
{
|
||||
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
// B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot.
|
||||
if (!items.RollbackMove(p.Value.ItemGuid))
|
||||
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)");
|
||||
});
|
||||
|
||||
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
|
||||
// view. No table change (the view is UI-only, wired in container-open); log.
|
||||
dispatcher.Register(GameEventType.CloseGroundContainer, e =>
|
||||
{
|
||||
var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
|
||||
if (guid is not null)
|
||||
Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}");
|
||||
});
|
||||
|
||||
dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>
|
||||
{
|
||||
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
|
||||
|
|
@ -289,6 +340,13 @@ public static class GameEventWiring
|
|||
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
|
||||
if (p is null) return;
|
||||
|
||||
// B-Wire: deliver the player's OWN properties to the player ClientObject.
|
||||
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
|
||||
// CreateObject; the player's own stats legitimately come from PD.) Upsert
|
||||
// because PD can arrive before the player's CreateObject. Retires AP-48/AP-49.
|
||||
if (playerGuid is not null)
|
||||
items.UpsertProperties(playerGuid(), p.Value.Properties);
|
||||
|
||||
// K-fix13 (2026-04-26): build attrId → current map while
|
||||
// iterating attributes so the skill-formula resolver below
|
||||
// can apply (attr1.current * mult1 + attr2.current * mult2)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,23 @@ public static class DeleteObject
|
|||
{
|
||||
public const uint Opcode = 0xF747u;
|
||||
|
||||
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
|
||||
/// <param name="FromPickup">
|
||||
/// <see langword="true"/> when this delete was sourced from a
|
||||
/// <c>PickupEvent (0xF74A)</c> — the object left the 3-D world view but
|
||||
/// the weenie record still exists (it is moving into a container).
|
||||
/// <see langword="false"/> (default) when sourced from <c>DeleteObject
|
||||
/// (0xF747)</c> — the weenie was destroyed and must be evicted from
|
||||
/// <see cref="AcDream.Core.Items.ClientObjectTable"/>.
|
||||
/// Retail two-table model: PickupEvent removes from <c>object_table</c>;
|
||||
/// DeleteObject removes from <c>weenie_object_table</c>.
|
||||
/// </param>
|
||||
public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
|
||||
/// 4-byte opcode, matching every other parser in this namespace.
|
||||
/// The returned <see cref="Parsed.FromPickup"/> is always <see langword="false"/>
|
||||
/// — this parser handles true destroys only.
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
|
|
@ -34,6 +46,6 @@ public static class DeleteObject
|
|||
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
|
||||
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
|
||||
return new Parsed(guid, instanceSequence);
|
||||
return new Parsed(guid, instanceSequence, FromPickup: false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -343,19 +343,47 @@ public static class GameEvents
|
|||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
|
||||
}
|
||||
|
||||
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.</summary>
|
||||
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.
|
||||
/// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid,
|
||||
/// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed
|
||||
/// vs holtburger events.rs fixture (slot=3 type=1).</summary>
|
||||
public readonly record struct InventoryPutObjInContainer(
|
||||
uint ItemGuid,
|
||||
uint ContainerGuid,
|
||||
uint Placement);
|
||||
uint Placement,
|
||||
uint ContainerType);
|
||||
|
||||
public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 12) return null;
|
||||
if (payload.Length < 16) return null;
|
||||
return new InventoryPutObjInContainer(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
|
||||
}
|
||||
|
||||
/// <summary>0x0196 ViewContents: full contents list of a container you opened.
|
||||
/// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count.
|
||||
/// Client consumer: ClientUISystem::OnViewContents (PackableList<ContentProfile>).</summary>
|
||||
public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType);
|
||||
public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList<ViewContentsEntry> Items);
|
||||
|
||||
public static ViewContents? ParseViewContents(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
|
||||
int pos = 8;
|
||||
if ((long)payload.Length - pos < (long)count * 8) return null;
|
||||
var items = new ViewContentsEntry[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
items[i] = new ViewContentsEntry(guid, type);
|
||||
}
|
||||
return new ViewContents(containerGuid, items);
|
||||
}
|
||||
|
||||
// ── Other small-payload events ──────────────────────────────────────────
|
||||
|
|
@ -374,11 +402,17 @@ public static class GameEvents
|
|||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.</summary>
|
||||
public static uint? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
|
||||
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.
|
||||
/// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger
|
||||
/// events.rs:147 reads both fields.</summary>
|
||||
public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError);
|
||||
|
||||
public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
if (payload.Length < 8) return null;
|
||||
return new InventoryServerSaveFailed(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
}
|
||||
|
||||
/// <summary>0x0052 CloseGroundContainer: server closed a ground container view.</summary>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ public static class InventoryActions
|
|||
public const uint AddShortcutOpcode = 0x019Cu;
|
||||
public const uint RemoveShortcutOpcode = 0x019Du;
|
||||
public const uint TeleToPoiOpcode = 0x00B1u;
|
||||
public const uint GetAndWieldItemOpcode = 0x001Au;
|
||||
public const uint DropItemOpcode = 0x001Bu;
|
||||
public const uint NoLongerViewingContentsOpcode = 0x0195u;
|
||||
|
||||
/// <summary>
|
||||
/// Merge stack A into stack B of the same item type. Server validates
|
||||
|
|
@ -95,17 +98,20 @@ public static class InventoryActions
|
|||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Pin an item / spell to a quickbar slot.</summary>
|
||||
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32),
|
||||
/// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive
|
||||
/// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0.</summary>
|
||||
public static byte[] BuildAddShortcut(
|
||||
uint seq, uint slotIndex, uint objectType, uint targetId)
|
||||
uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
|
||||
return body;
|
||||
}
|
||||
|
||||
|
|
@ -130,4 +136,39 @@ public static class InventoryActions
|
|||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), poiId);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32.</summary>
|
||||
public static byte[] BuildDropItem(uint seq, uint itemGuid)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), DropItemOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Equip an item from inventory onto the doll. holtburger actions.rs
|
||||
/// GetAndWieldItemActionData = (itemGuid, equipMask).</summary>
|
||||
public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask)
|
||||
{
|
||||
byte[] body = new byte[20];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GetAndWieldItemOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid).</summary>
|
||||
public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), NoLongerViewingContentsOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs
Normal file
31
src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>InventoryRemoveObject (0x0024)</c> — a top-level GameMessage (UIQueue),
|
||||
/// NOT a GameEvent. The server tells the client an object left its inventory view
|
||||
/// (given away / sold / destroyed). The client drops it from object maintenance.
|
||||
///
|
||||
/// <para>Wire layout (ACE <c>GameMessageInventoryRemoveObject.cs</c>, size hint 8):</para>
|
||||
/// <code>
|
||||
/// u32 opcode = 0x0024
|
||||
/// u32 guid
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static class InventoryRemoveObject
|
||||
{
|
||||
public const uint Opcode = 0x0024u;
|
||||
|
||||
public readonly record struct Parsed(uint Guid);
|
||||
|
||||
/// <summary>Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation.</summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 8) return null; // 4 + 4
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]);
|
||||
return new Parsed(guid);
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs
Normal file
38
src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>PrivateUpdatePropertyInt (0x02CD)</c> — the server updates one
|
||||
/// <c>PropertyInt</c> on the player's OWN object. Unlike the sibling
|
||||
/// <see cref="PublicUpdatePropertyInt"/> (0x02CE), this carries NO guid (it
|
||||
/// implicitly targets the local player). Burden (<c>EncumbranceVal</c>, PropertyInt 5)
|
||||
/// changes ride this opcode on every pick-up / drop.
|
||||
///
|
||||
/// <para>Wire layout (ACE <c>GameMessagePrivateUpdatePropertyInt</c>):</para>
|
||||
/// <code>
|
||||
/// u32 opcode = 0x02CD
|
||||
/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4)
|
||||
/// u32 property // PropertyInt enum
|
||||
/// i32 value
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static class PrivateUpdatePropertyInt
|
||||
{
|
||||
public const uint Opcode = 0x02CDu;
|
||||
|
||||
public readonly record struct Parsed(uint Property, int Value);
|
||||
|
||||
/// <summary>Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation.</summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 13) return null; // 4 + 1 + 4 + 4
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
|
||||
int pos = 4;
|
||||
pos += 1; // sequence byte (not honored)
|
||||
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
|
||||
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
|
||||
return new Parsed(prop, value);
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core.Net/Messages/SetStackSize.cs
Normal file
38
src/AcDream.Core.Net/Messages/SetStackSize.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>SetStackSize (0x0197)</c> — a top-level GameMessage (UIQueue group),
|
||||
/// NOT a GameEvent. The server updates a stack's count + value after a merge / split.
|
||||
/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize.
|
||||
///
|
||||
/// <para>Wire layout (ACE <c>GameMessageSetStackSize.cs</c>, size hint 17):</para>
|
||||
/// <code>
|
||||
/// u32 opcode = 0x0197
|
||||
/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored
|
||||
/// u32 guid
|
||||
/// i32 stackSize
|
||||
/// i32 value
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static class SetStackSize
|
||||
{
|
||||
public const uint Opcode = 0x0197u;
|
||||
|
||||
public readonly record struct Parsed(uint Guid, int StackSize, int Value);
|
||||
|
||||
/// <summary>Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation.</summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
|
||||
int pos = 4;
|
||||
pos += 1; // sequence byte (not honored)
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
|
||||
int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4;
|
||||
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
|
||||
return new Parsed(guid, stack, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ namespace AcDream.Core.Net;
|
|||
/// <summary>
|
||||
/// Wires WorldSession GameMessage-level object events into the client object
|
||||
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
|
||||
/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite.
|
||||
/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
|
||||
/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject
|
||||
/// (0x0024) = inventory-view removal.
|
||||
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
|
||||
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
|
||||
/// </summary>
|
||||
|
|
@ -16,18 +18,44 @@ public static class ObjectTableWiring
|
|||
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
|
||||
/// to EntitySpawned so the table is populated before the render path runs.
|
||||
/// </summary>
|
||||
public static void Wire(WorldSession session, ClientObjectTable table)
|
||||
public static void Wire(WorldSession session, ClientObjectTable table, Func<uint>? playerGuid = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
|
||||
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
|
||||
session.EntityDeleted += d => table.Remove(d.Guid);
|
||||
// Retail two-table model:
|
||||
// PickupEvent (0xF74A) → object left the 3-D world view; the weenie persists
|
||||
// (it is moving into a container, e.g. unwield-to-pack). Remove the 3-D
|
||||
// render entity only — do NOT evict from ClientObjectTable.
|
||||
// DeleteObject (0xF747) → weenie is DESTROYED; evict from both tables.
|
||||
// FromPickup = true guards the weenie eviction so it only fires for true destroys.
|
||||
session.EntityDeleted += d => { if (!d.FromPickup) table.Remove(d.Guid); };
|
||||
|
||||
// B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just
|
||||
// UiEffects — the server is the authority on object properties. UpdateIntProperty
|
||||
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
|
||||
session.ObjectIntPropertyUpdated += u =>
|
||||
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
||||
|
||||
// B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the
|
||||
// local player. Route it to the player object so live EncumbranceVal updates the
|
||||
// burden bar. The player ClientObject is created at login by the PD UpsertProperties
|
||||
// call (which precedes any live 0x02CD), so UpdateIntProperty finds it. If it somehow
|
||||
// hasn't yet, this no-ops (UpdateIntProperty returns false on an unknown guid) rather
|
||||
// than creating a phantom — the next PD / CreateObject seeds it.
|
||||
session.PlayerIntPropertyUpdated += u =>
|
||||
{
|
||||
if (u.Property == ClientObjectTable.UiEffectsPropertyId)
|
||||
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
|
||||
if (playerGuid is not null)
|
||||
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
|
||||
};
|
||||
|
||||
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
|
||||
session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
|
||||
|
||||
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
|
||||
// view; drop it from the table (retail ClientUISystem removes it from maintenance).
|
||||
session.InventoryObjectRemoved += guid => table.Remove(guid);
|
||||
}
|
||||
|
||||
/// <summary>Translate the wire spawn into the table's merge patch.</summary>
|
||||
|
|
|
|||
|
|
@ -196,6 +196,25 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
|
||||
|
||||
/// <summary>Payload for <see cref="PlayerIntPropertyUpdated"/>: a PropertyInt change on
|
||||
/// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire).</summary>
|
||||
public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value);
|
||||
|
||||
/// <summary>Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one
|
||||
/// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.</summary>
|
||||
public event Action<PlayerIntPropertyUpdate>? PlayerIntPropertyUpdated;
|
||||
|
||||
/// <summary>Payload for <see cref="StackSizeUpdated"/>: SetStackSize (0x0197) — a stack's
|
||||
/// count + value after a merge / split.</summary>
|
||||
public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);
|
||||
|
||||
/// <summary>Fires when the session parses a SetStackSize (0x0197) top-level GameMessage.</summary>
|
||||
public event Action<StackSizeUpdate>? StackSizeUpdated;
|
||||
|
||||
/// <summary>Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left
|
||||
/// the player's inventory view.</summary>
|
||||
public event Action<uint>? InventoryObjectRemoved;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
|
||||
/// signalling that the player is entering portal space. The uint payload
|
||||
|
|
@ -817,16 +836,18 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
else if (op == PickupEvent.Opcode)
|
||||
{
|
||||
// ACE sends PickupEvent (0xF74A) instead of DeleteObject
|
||||
// when a player picks up a world item (Player_Tracking
|
||||
// .RemoveTrackedObject with fromPickup=true). Downstream
|
||||
// view-removal semantics are identical, so we adapt to
|
||||
// DeleteObject.Parsed and reuse the existing handler.
|
||||
// ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view
|
||||
// (player picks it up, or a player unwields gear that goes back to their pack).
|
||||
// The WEENIE is NOT destroyed — it is moving into a container. We adapt to
|
||||
// DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted)
|
||||
// still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring
|
||||
// to skip the ClientObjectTable eviction (retail two-table model: PickupEvent
|
||||
// targets object_table only; DeleteObject 0xF747 targets weenie_object_table).
|
||||
var parsed = PickupEvent.TryParse(body);
|
||||
if (parsed is not null)
|
||||
EntityDeleted?.Invoke(
|
||||
new DeleteObject.Parsed(
|
||||
parsed.Value.Guid, parsed.Value.InstanceSequence));
|
||||
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
|
||||
}
|
||||
else if (op == UpdateMotion.Opcode)
|
||||
{
|
||||
|
|
@ -987,6 +1008,25 @@ public sealed class WorldSession : IDisposable
|
|||
ObjectIntPropertyUpdated?.Invoke(
|
||||
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
|
||||
}
|
||||
else if (op == PrivateUpdatePropertyInt.Opcode)
|
||||
{
|
||||
var p = PrivateUpdatePropertyInt.TryParse(body);
|
||||
if (p is not null)
|
||||
PlayerIntPropertyUpdated?.Invoke(
|
||||
new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value));
|
||||
}
|
||||
else if (op == SetStackSize.Opcode)
|
||||
{
|
||||
var p = SetStackSize.TryParse(body);
|
||||
if (p is not null)
|
||||
StackSizeUpdated?.Invoke(
|
||||
new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value));
|
||||
}
|
||||
else if (op == InventoryRemoveObject.Opcode)
|
||||
{
|
||||
var p = InventoryRemoveObject.TryParse(body);
|
||||
if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid);
|
||||
}
|
||||
else if (op == GameEventEnvelope.Opcode)
|
||||
{
|
||||
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
|
||||
|
|
@ -1169,6 +1209,61 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
|
||||
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
|
||||
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
|
||||
}
|
||||
|
||||
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
|
||||
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
|
||||
public void SendRemoveShortcut(uint index)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
|
||||
}
|
||||
|
||||
/// <summary>Send DropItem (0x001B) — drop an item on the ground.</summary>
|
||||
public void SendDropItem(uint itemGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
|
||||
}
|
||||
|
||||
/// <summary>Send GetAndWieldItem (0x001A) — equip an item to an equip slot.</summary>
|
||||
public void SendGetAndWieldItem(uint itemGuid, uint equipMask)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask));
|
||||
}
|
||||
|
||||
/// <summary>Send NoLongerViewingContents (0x0195) — close a container view.</summary>
|
||||
public void SendNoLongerViewingContents(uint containerGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid));
|
||||
}
|
||||
|
||||
/// <summary>Send Use (0x0036) — open/use an object by guid (e.g. open a container in your
|
||||
/// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike
|
||||
/// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use.</summary>
|
||||
public void SendUse(uint targetGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
|
||||
}
|
||||
|
||||
/// <summary>Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement
|
||||
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
||||
/// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer.</summary>
|
||||
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
|
||||
}
|
||||
|
||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||
/// <remarks>
|
||||
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
||||
|
|
|
|||
|
|
@ -57,46 +57,48 @@ public enum ItemType : uint
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equipment slot bitmask. 31 slots from head to Aetheria. Paperdoll
|
||||
/// widget offsets <c>+0x604..+0x660</c> in the retail panel correspond
|
||||
/// to these bits 1:1 (see r06 §2 and UI slice 05 paperdoll section).
|
||||
/// Equipment slot bitmask — the verbatim retail <c>INVENTORY_LOC</c> enum
|
||||
/// (docs/research/named-retail/acclient.h:3193; identical to ACE's EquipMask).
|
||||
/// The wire (ValidLocations / CurrentWieldedLocation / WieldObject EquipLoc) delivers
|
||||
/// these exact bits. Pinned by EquipMaskTests — do NOT renumber.
|
||||
/// (The header's <c>CLOTHING_LOC</c> composite also sets bit 31, 0x80000000, which is
|
||||
/// not a named INVENTORY_LOC primitive and has no member here; <c>ALL_LOC</c> tops out at bit 30.)
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum EquipMask : uint
|
||||
{
|
||||
None = 0,
|
||||
HeadWear = 0x00000001,
|
||||
ChestWear = 0x00000002,
|
||||
AbdomenWear = 0x00000004,
|
||||
UpperArmWear = 0x00000008,
|
||||
LowerArmWear = 0x00000010,
|
||||
HandWear = 0x00000020,
|
||||
UpperLegWear = 0x00000040,
|
||||
LowerLegWear = 0x00000080,
|
||||
FootWear = 0x00000100,
|
||||
ChestArmor = 0x00000200,
|
||||
AbdomenArmor = 0x00000400,
|
||||
UpperArmArmor = 0x00000800,
|
||||
LowerArmArmor = 0x00001000,
|
||||
HandArmor = 0x00002000,
|
||||
UpperLegArmor = 0x00004000,
|
||||
LowerLegArmor = 0x00008000,
|
||||
FootArmor = 0x00010000,
|
||||
Necklace = 0x00020000,
|
||||
LeftBracelet = 0x00040000,
|
||||
RightBracelet = 0x00080000,
|
||||
LeftRing = 0x00100000,
|
||||
RightRing = 0x00200000,
|
||||
MeleeWeapon = 0x00400000,
|
||||
Shield = 0x00800000,
|
||||
MissileWeapon = 0x01000000,
|
||||
Held = 0x02000000, // lit torch, book in hand
|
||||
MissileAmmo = 0x04000000,
|
||||
Cloak = 0x08000000,
|
||||
TrinketOne = 0x10000000,
|
||||
AetheriaRed = 0x20000000,
|
||||
AetheriaYellow= 0x40000000,
|
||||
AetheriaBlue = 0x80000000u,
|
||||
None = 0x00000000,
|
||||
HeadWear = 0x00000001,
|
||||
ChestWear = 0x00000002,
|
||||
AbdomenWear = 0x00000004,
|
||||
UpperArmWear = 0x00000008,
|
||||
LowerArmWear = 0x00000010,
|
||||
HandWear = 0x00000020,
|
||||
UpperLegWear = 0x00000040,
|
||||
LowerLegWear = 0x00000080,
|
||||
FootWear = 0x00000100,
|
||||
ChestArmor = 0x00000200,
|
||||
AbdomenArmor = 0x00000400,
|
||||
UpperArmArmor = 0x00000800,
|
||||
LowerArmArmor = 0x00001000,
|
||||
UpperLegArmor = 0x00002000,
|
||||
LowerLegArmor = 0x00004000,
|
||||
NeckWear = 0x00008000,
|
||||
WristWearLeft = 0x00010000,
|
||||
WristWearRight = 0x00020000,
|
||||
FingerWearLeft = 0x00040000,
|
||||
FingerWearRight = 0x00080000,
|
||||
MeleeWeapon = 0x00100000,
|
||||
Shield = 0x00200000,
|
||||
MissileWeapon = 0x00400000,
|
||||
MissileAmmo = 0x00800000,
|
||||
Held = 0x01000000,
|
||||
TwoHanded = 0x02000000,
|
||||
TrinketOne = 0x04000000,
|
||||
Cloak = 0x08000000,
|
||||
SigilOne = 0x10000000,
|
||||
SigilTwo = 0x20000000,
|
||||
SigilThree = 0x40000000,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -216,6 +218,57 @@ public static class BurdenMath
|
|||
{
|
||||
public const int BurdenPerStrength = 150;
|
||||
|
||||
/// <summary>Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e).</summary>
|
||||
public const int AugBurdenPerRank = 30;
|
||||
/// <summary>Max augmentation bonus-burden contribution per Strength (retail clamp 0x96).</summary>
|
||||
public const int AugBurdenCap = 150;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>EncumbranceSystem::EncumbranceCapacity(strength, aug)</c>
|
||||
/// (named-retail decomp 256393, <c>0x004fcc00</c>):
|
||||
/// <c>strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength</c>.
|
||||
/// </summary>
|
||||
public static int EncumbranceCapacity(int strength, int aug)
|
||||
{
|
||||
if (strength <= 0) return 0;
|
||||
int bonus = aug * AugBurdenPerRank;
|
||||
if (bonus < 0) bonus = 0; // decomp: eax_2 < 0 -> base only
|
||||
if (bonus > AugBurdenCap) bonus = AugBurdenCap;
|
||||
return strength * BurdenPerStrength + bonus * strength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>EncumbranceSystem::Load(capacity, burden)</c> (decomp 256413,
|
||||
/// <c>0x004fcc40</c>): the encumbrance ratio <c>burden / capacity</c>
|
||||
/// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to
|
||||
/// <c>return burden</c>; the <c>cap <= 0</c> guard + single divide is unambiguous.
|
||||
/// Returns 0 when capacity <= 0 (no-data).
|
||||
/// </summary>
|
||||
public static float LoadRatio(int capacity, int burden)
|
||||
=> capacity <= 0 ? 0f : (float)burden / capacity;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
|
||||
/// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at
|
||||
/// 100% capacity, full at 300%.
|
||||
/// </summary>
|
||||
public static float LoadToFill(float load)
|
||||
{
|
||||
float fill = load / 3f;
|
||||
if (fill < 0f) return 0f;
|
||||
return fill > 1f ? 1f : fill;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmBackpackUI::SetLoadLevel</c> percent text (decomp 176542-176576): the
|
||||
/// percent is computed from the CLAMPED fill — <c>arg2 = load/3</c> is clamped to [0,1]
|
||||
/// (the 176544-176563 block sets <c>arg2 = 1.0</c> when fill >= 1) BEFORE
|
||||
/// <c>floor(arg2 * 300)</c>. So the number SATURATES at 300% (it does NOT read 400% at
|
||||
/// 4x capacity). Equivalent to <c>floor(LoadToFill(load) * 300)</c>.
|
||||
/// </summary>
|
||||
public static int LoadToPercent(float load)
|
||||
=> (int)System.MathF.Floor(LoadToFill(load) * 300f);
|
||||
|
||||
public static int ComputeMax(int strength, int bonusBurden)
|
||||
=> BurdenPerStrength * strength + strength * bonusBurden;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ public sealed class ClientObjectTable
|
|||
private readonly ConcurrentDictionary<uint, Container> _containers = new();
|
||||
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
|
||||
|
||||
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
|
||||
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
|
||||
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
|
||||
private readonly Dictionary<uint, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new();
|
||||
|
||||
/// <summary>Fires when an object is first added to the session.</summary>
|
||||
public event Action<ClientObject>? ObjectAdded;
|
||||
|
||||
|
|
@ -114,7 +119,9 @@ public sealed class ClientObjectTable
|
|||
/// Handle a server-driven move — called from
|
||||
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
|
||||
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
|
||||
/// and fires ObjectMoved.
|
||||
/// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the
|
||||
/// unwield path manages it (a wielded item is modeled as contained-by-the-wielder),
|
||||
/// so RollbackMove restores full pre-move state through this method alone.
|
||||
/// </summary>
|
||||
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
|
||||
EquipMask newEquipLocation = EquipMask.None)
|
||||
|
|
@ -130,6 +137,103 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and
|
||||
/// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm
|
||||
/// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/
|
||||
/// move) and WieldItemOptimistic (wield).</summary>
|
||||
private void RecordPending(uint itemId, ClientObject item)
|
||||
{
|
||||
if (_pendingMoves.TryGetValue(itemId, out var p))
|
||||
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1);
|
||||
else
|
||||
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot,
|
||||
item.CurrentlyEquippedLocation, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST
|
||||
/// time it moves, then <see cref="MoveItem"/> (immediate repaint via ObjectMoved). The wire
|
||||
/// PutItemInContainer is sent by the caller; <see cref="ConfirmMove"/>/<see cref="RollbackMove"/>
|
||||
/// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile.
|
||||
/// </summary>
|
||||
public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
// Snapshot the ORIGINAL position once + count OUTSTANDING optimistic moves of this item. The
|
||||
// count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME
|
||||
// item from clearing the snapshot while a later move is still unconfirmed — else a later
|
||||
// reject would find nothing pending and strand the item at the rejected position.
|
||||
RecordPending(itemId, item);
|
||||
|
||||
// Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both
|
||||
// containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with
|
||||
// the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint
|
||||
// (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list.
|
||||
uint oldContainer = item.ContainerId;
|
||||
item.ContainerId = newContainerId;
|
||||
item.CurrentlyEquippedLocation = EquipMask.None;
|
||||
|
||||
if (oldContainer != 0 && oldContainer != newContainerId
|
||||
&& _containerIndex.TryGetValue(oldContainer, out var srcList))
|
||||
{ srcList.Remove(itemId); RenumberContainer(srcList); } // keep the source gapless too
|
||||
|
||||
if (newContainerId != 0)
|
||||
{
|
||||
if (!_containerIndex.TryGetValue(newContainerId, out var dstList))
|
||||
_containerIndex[newContainerId] = dstList = new List<uint>();
|
||||
dstList.Remove(itemId); // same-container move: pull out first
|
||||
int idx = (newSlot < 0 || newSlot > dstList.Count) ? dstList.Count : newSlot;
|
||||
dstList.Insert(idx, itemId);
|
||||
RenumberContainer(dstList); // sequential ContainerSlots → no ties
|
||||
}
|
||||
else item.ContainerSlot = newSlot;
|
||||
|
||||
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
|
||||
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's
|
||||
/// WieldObject 0x0023 confirm does via <see cref="MoveItem"/> (acdream models a wielded item as
|
||||
/// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the
|
||||
/// confirmed state and <see cref="RollbackMove"/> via MoveItem fully restores pre-move state. Fires
|
||||
/// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023
|
||||
/// echo) / RollbackMove (on 0x00A0) reconcile.</summary>
|
||||
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
RecordPending(itemId, item);
|
||||
return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask);
|
||||
}
|
||||
|
||||
/// <summary>Assign each item in <paramref name="list"/> a sequential ContainerSlot (0..N-1) matching
|
||||
/// its list position — keeps a container gapless + collision-free so the grid order is stable.</summary>
|
||||
private void RenumberContainer(List<uint> list)
|
||||
{
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i;
|
||||
}
|
||||
|
||||
/// <summary>The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the
|
||||
/// outstanding count; drop the snapshot only once no optimistic moves of this item remain. No-op
|
||||
/// for a server-initiated move we never tracked.</summary>
|
||||
public void ConfirmMove(uint itemId)
|
||||
{
|
||||
if (!_pendingMoves.TryGetValue(itemId, out var p)) return;
|
||||
if (p.outstanding <= 1) _pendingMoves.Remove(itemId);
|
||||
else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
|
||||
}
|
||||
|
||||
/// <summary>The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its
|
||||
/// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles
|
||||
/// any still-outstanding moves). False if nothing was pending.</summary>
|
||||
public bool RollbackMove(uint itemId)
|
||||
{
|
||||
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
|
||||
_pendingMoves.Remove(itemId);
|
||||
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a server-driven remove (destroyed item, dropped into 3D
|
||||
/// space, stolen, etc).
|
||||
|
|
@ -139,6 +243,7 @@ public sealed class ClientObjectTable
|
|||
if (!_objects.TryRemove(itemId, out var item)) return false;
|
||||
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
|
||||
l.Remove(itemId);
|
||||
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
|
||||
ObjectRemoved?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -163,6 +268,32 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a <see cref="PropertyBundle"/> patch, creating the object if it does not
|
||||
/// exist yet. Used for the local player's own properties from PlayerDescription
|
||||
/// (0x0013), which may arrive BEFORE the player's CreateObject — unlike
|
||||
/// <see cref="UpdateProperties"/>, which no-ops on an unknown object. Fires
|
||||
/// ObjectAdded on create, else ObjectUpdated.
|
||||
/// </summary>
|
||||
public void UpsertProperties(uint guid, PropertyBundle incoming)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
bool existed = _objects.TryGetValue(guid, out var item);
|
||||
if (!existed || item is null)
|
||||
{
|
||||
item = new ClientObject { ObjectId = guid };
|
||||
_objects[guid] = item;
|
||||
}
|
||||
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
|
||||
if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
|
||||
/// object: store it in the bundle and, for known typed ints, mirror to the typed
|
||||
|
|
@ -179,6 +310,20 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and
|
||||
/// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the
|
||||
/// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...).
|
||||
/// </summary>
|
||||
public bool UpdateStackSize(uint guid, int stackSize, int value)
|
||||
{
|
||||
if (!_objects.TryGetValue(guid, out var item)) return false;
|
||||
item.StackSize = stackSize;
|
||||
item.Value = value;
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical CreateObject ingestion: create-if-absent, else patch the
|
||||
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
|
||||
|
|
@ -274,6 +419,85 @@ public sealed class ClientObjectTable
|
|||
_containerIndex.TryGetValue(containerId, out var l)
|
||||
? l.ToArray() : System.Array.Empty<uint>();
|
||||
|
||||
/// <summary>
|
||||
/// Replace a container's entire membership with <paramref name="guids"/> (in order) — the
|
||||
/// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a
|
||||
/// container. Members no longer present are detached (ContainerId → 0); new members are
|
||||
/// recorded with ContainerSlot = list index. ACE writes ViewContents entries
|
||||
/// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the
|
||||
/// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints.
|
||||
/// Retail consumer: ClientUISystem::OnViewContents.
|
||||
/// </summary>
|
||||
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(guids);
|
||||
if (containerId == 0) return;
|
||||
|
||||
var keep = new HashSet<uint>(guids);
|
||||
|
||||
// Detach prior members no longer present (they left the container server-side). GetContents
|
||||
// returns a snapshot, so mutating the index inside the loop is safe.
|
||||
foreach (var old in GetContents(containerId))
|
||||
{
|
||||
if (keep.Contains(old)) continue;
|
||||
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
|
||||
o.ContainerId = 0;
|
||||
Reindex(o, containerId);
|
||||
ObjectMoved?.Invoke(o, containerId, 0);
|
||||
}
|
||||
|
||||
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
|
||||
for (int i = 0; i < guids.Count; i++)
|
||||
{
|
||||
uint g = guids[i];
|
||||
bool existed = _objects.TryGetValue(g, out var obj);
|
||||
if (!existed || obj is null)
|
||||
{
|
||||
obj = new ClientObject { ObjectId = g };
|
||||
_objects[g] = obj;
|
||||
}
|
||||
uint oldContainer = obj.ContainerId;
|
||||
obj.ContainerId = containerId;
|
||||
obj.ContainerSlot = i;
|
||||
Reindex(obj, oldContainer);
|
||||
if (!existed) ObjectAdded?.Invoke(obj);
|
||||
else ObjectMoved?.Invoke(obj, oldContainer, containerId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Σ Burden over every object carried by <paramref name="ownerGuid"/>: items
|
||||
/// whose container chain roots at the owner (pack + side-bag contents, retail
|
||||
/// hierarchy is 2-deep) plus items wielded by the owner. The client-side
|
||||
/// equivalent of the server's <c>EncumbranceVal</c> (PropertyInt 5) — used by
|
||||
/// the inventory burden bar until the wire value is parsed (B-Wire). The hop
|
||||
/// cap (8) is a cycle guard; real chains are ≤2.
|
||||
/// </summary>
|
||||
public int SumCarriedBurden(uint ownerGuid)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (var o in _objects.Values)
|
||||
if (IsCarriedBy(o, ownerGuid))
|
||||
total += o.Burden;
|
||||
return total;
|
||||
}
|
||||
|
||||
// NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An
|
||||
// optimistically-wielded item (WieldItemOptimistic, before the server confirm) has
|
||||
// WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder).
|
||||
// A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone.
|
||||
private bool IsCarriedBy(ClientObject o, uint ownerGuid)
|
||||
{
|
||||
if (o.WielderId == ownerGuid) return true;
|
||||
uint c = o.ContainerId;
|
||||
for (int hops = 0; c != 0 && hops < 8; hops++)
|
||||
{
|
||||
if (c == ownerGuid) return true;
|
||||
c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush the table — typically called on logoff or teleport
|
||||
/// that drops the session's object state.
|
||||
|
|
@ -283,5 +507,6 @@ public sealed class ClientObjectTable
|
|||
_objects.Clear();
|
||||
_containers.Clear();
|
||||
_containerIndex.Clear();
|
||||
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
src/AcDream.Core/Items/ShortcutStore.cs
Normal file
34
src/AcDream.Core/Items/ShortcutStore.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
|
||||
/// <c>ShortCutManager::shortCuts_[18]</c> (acclient.h:36492). Holds the bound object guid per
|
||||
/// slot (0 = empty). Loaded from the login shortcut list, then mutated by drag-drop (lift
|
||||
/// removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the store
|
||||
/// stays the client's source of truth within a session (retail: client owns the array).
|
||||
/// Item shortcuts only — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model
|
||||
/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs.
|
||||
/// </summary>
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount];
|
||||
|
||||
/// <summary>Replace all slots from a (slot, objectGuid) sequence (item entries only;
|
||||
/// ObjGuid 0 and out-of-range slots are skipped).</summary>
|
||||
public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries)
|
||||
{
|
||||
Array.Clear(_objIds);
|
||||
foreach (var (slot, objGuid) in entries)
|
||||
if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid;
|
||||
}
|
||||
|
||||
/// <summary>Bound object guid at <paramref name="slot"/>, or 0 (empty / out of range).</summary>
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
||||
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
|
||||
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue