Faithful port of retail UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): each container cell (side bags + main pack) shows a vertical UIElement_Meter (element 0x10000347, back 0x06004D22 / fill 0x06004D23) filled to GetNumContainedItems / ItemsCapacity, clamped [0,1]; hidden for non-containers (CapacityFill=-1). Drawn procedurally on UiItemSlot like the triangle/square overlays (back full + front clipped bottom-up). Right-anchored flush to the cell edge (visual gate: the dat X=26 sat ~5px off the right edge). Visually confirmed 2026-06-22. Divergence AP-59; polish deferred to ISSUES #146 (exact rect/anchor, fill direction vs m_eDirection 0x6f, closed-bag lazy-load). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
308 lines
16 KiB
C#
308 lines
16 KiB
C#
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// One item-in-a-slot cell (port of retail UIElement_UIItem, class 0x10000032).
|
||
/// A behavioral LEAF: it draws the empty-slot sprite when unbound, else a
|
||
/// pre-composited icon texture (set by the controller). Holds the bound weenie
|
||
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
|
||
/// </summary>
|
||
public sealed class UiItemSlot : UiElement
|
||
{
|
||
public UiItemSlot() { ClickThrough = false; }
|
||
|
||
public override bool ConsumesDatChildren => true;
|
||
|
||
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
|
||
public uint ItemId { get; private set; }
|
||
|
||
/// <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>
|
||
public uint EmptySprite { get; set; } = 0x060074CFu;
|
||
|
||
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
|
||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||
|
||
public void SetItem(uint itemId, uint iconTexture)
|
||
{
|
||
ItemId = itemId;
|
||
IconTexture = iconTexture;
|
||
}
|
||
|
||
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
|
||
// digit image from a DID-array property: 0x10000042 (peace) / 0x10000043 (war),
|
||
// indexed by slot position. Each digit is a 32×32 PFID_A8R8G8B8 RenderSurface
|
||
// with the digit baked into the top-left corner (rest alpha=0), drawn Alphablend.
|
||
|
||
/// <summary>Slot position in the shortcut bar (0-indexed). -1 = no number (retail
|
||
/// SetVisible(0) when edi < 0). Top row: 0..8 → digits 1..9. Bottom row: -1.</summary>
|
||
public int ShortcutNum { get; private set; } = -1;
|
||
|
||
/// <summary>True = draw peace digit set; false = war digit set.</summary>
|
||
public bool ShortcutPeace { get; private set; } = true;
|
||
|
||
/// <summary>Peace digit DID array. Index i → digit (i+1) sprite RenderSurface id.
|
||
/// Injected by the controller after reading LayoutDesc 0x21000037.
|
||
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — occupied slot picks
|
||
/// property 0x10000042 (peace) or 0x10000043 (war) by stance.</summary>
|
||
public uint[]? PeaceDigits { get; set; }
|
||
|
||
/// <summary>War digit DID array. Same layout as PeaceDigits.
|
||
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229493) — war stance.</summary>
|
||
public uint[]? WarDigits { get; set; }
|
||
|
||
/// <summary>Empty-slot digit DID array (property 0x1000005e, stance-independent).
|
||
/// Used when the slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
|
||
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty).</summary>
|
||
public uint[]? EmptyDigits { get; set; }
|
||
|
||
/// <summary>Set the slot's shortcut position and combat stance so the correct digit
|
||
/// is drawn. Call with index 0..8 for the top row; pass peace=true for NonCombat.</summary>
|
||
public void SetShortcutNum(int index, bool peace)
|
||
{
|
||
ShortcutNum = index;
|
||
ShortcutPeace = peace;
|
||
}
|
||
|
||
/// <summary>Clear the shortcut number label (hides the digit).</summary>
|
||
public void ClearShortcutNum() { ShortcutNum = -1; }
|
||
|
||
/// <summary>
|
||
/// Returns the digit DID array that OnDraw will use, following the retail occupancy
|
||
/// branch in UIElement_UIItem::SetShortcutNum (decomp 229481):
|
||
/// occupied (ItemId != 0) → ShortcutPeace ? PeaceDigits : WarDigits (0x10000042/43)
|
||
/// empty (ItemId == 0) → EmptyDigits (0x1000005e, stance-independent)
|
||
/// Exposed as an internal method so unit tests can assert array selection without
|
||
/// needing a real render context.
|
||
/// </summary>
|
||
internal uint[]? ActiveDigitArray()
|
||
{
|
||
bool occupied = ItemId != 0;
|
||
return occupied ? (ShortcutPeace ? PeaceDigits : WarDigits) : EmptyDigits;
|
||
}
|
||
|
||
// ── Events / draw ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>Invoked by <see cref="OnEvent"/> when a left-button-down lands on
|
||
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
|
||
public Action? Clicked { get; set; }
|
||
|
||
/// <inheritdoc/>
|
||
public override bool OnEvent(in UiEvent e)
|
||
{
|
||
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;
|
||
}
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
// Draw the icon (filled slot) or the empty-slot border. Both paths fall through
|
||
// to the digit draw below; the slot label always shows on top-row slots.
|
||
if (ItemId != 0 && IconTexture != 0)
|
||
{
|
||
ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||
}
|
||
else if (SpriteResolve is not null && EmptySprite != 0)
|
||
{
|
||
var (tex, _, _) = SpriteResolve(EmptySprite);
|
||
if (tex != 0)
|
||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||
}
|
||
|
||
// Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
|
||
// Occupancy branch (decomp 229481):
|
||
// occupied (ItemId != 0) → peace/war digit set 0x10000042/43, split by stance
|
||
// empty (ItemId == 0) → background digit set 0x1000005e, stance-independent
|
||
// Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn
|
||
// full-cell Alphablend so the transparent region is invisible.
|
||
if (ShortcutNum >= 0 && SpriteResolve is not null)
|
||
{
|
||
var arr = ActiveDigitArray();
|
||
if (arr is not null && ShortcutNum < arr.Length)
|
||
{
|
||
uint did = arr[ShortcutNum];
|
||
if (did != 0)
|
||
{
|
||
var (tex, _, _) = SpriteResolve(did);
|
||
if (tex != 0)
|
||
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
}
|