acdream/src/AcDream.App/UI/Layout/PaperdollController.cs
Erik edc9be3008 fix(paperdoll): port quest-gated Aetheria slots
Add the three authored blue, yellow, and red sigil backgrounds and drive their visibility from player PropertyInt.AetheriaBitfield bits 1/2/4 at login and on live updates, matching gmPaperDollUI::UpdateAetheria. Include the sigil equip masks in the shared slot table and narrow AP-108.

Co-Authored-By: Codex <codex@openai.com>
2026-07-13 09:55:20 +02:00

359 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the 24 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, IRetainedPanelController
{
public const uint DollViewportId = 0x100001D5u;
public const uint DollDragMaskId = 0x100001D6u;
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
/// <summary>
/// The 9 armor-slot element-ids whose Visible state the Slots button (0x100005BE) toggles.
/// Doll-view: hidden. Slot-view: shown. Source: gmPaperDollUI::ListenToElementMessage decomp
/// 175674-175706 — these are the only 9 ids that element flips. The 12 ordinary non-armor
/// lists remain visible in both views; the three Aetheria lists are independently unlock-gated.
/// </summary>
public static readonly uint[] ArmorSlotElementIds =
{
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
};
/// <summary>
/// View-state model for the Slots toggle (pure logic, no UI coupling).
/// Default is doll-view (SlotView == false): the 3-D character is visible,
/// the 9 armor slots are hidden. Calling Toggle() alternates between views.
/// </summary>
public sealed class PaperdollViewState
{
public bool SlotView { get; private set; } // false = doll-view (default)
public bool DollVisible => !SlotView;
public bool ArmorSlotsVisible => SlotView;
public void Toggle() => SlotView = !SlotView;
}
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly ItemInteractionController? _itemInteraction;
private readonly SelectionState _selection;
private readonly PaperdollClickMap? _clickMap;
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
private readonly List<(AetheriaUnlockState Bit, UiItemList List)> _aetheriaSlots = new();
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
private readonly PaperdollViewState _viewState = new();
private readonly List<UiItemList> _armorSlots = new();
private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent
private UiElement? _dollDragMask;
private bool _disposed;
private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
Action<uint, uint>? sendWield,
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction,
PaperdollClickMap? clickMap,
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds,
IReadOnlyDictionary<uint, uint>? emptySlotSprites)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
_dragIconIds = dragIconIds;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_clickMap = clickMap;
for (int i = 0; i < PaperdollSlotBackgrounds.Definitions.Length; i++)
{
var (element, mask, _, unlockBit) = PaperdollSlotBackgrounds.Definitions[i];
if (layout.FindElement(element) is not UiItemList list) continue;
list.RegisterDragHandler(this);
list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // definition position = equipped drag-payload SourceSlot
list.Cell.EmptySprite = emptySlotSprites is not null
&& emptySlotSprites.TryGetValue(element, out uint authoredSprite)
? authoredSprite
: emptySlotSprite;
list.Cell.Clicked = () =>
{
uint itemId = list.Cell.ItemId;
if (itemId != 0 && _itemInteraction?.OfferPrimaryClick(itemId)
is not null and not ItemPrimaryClickResult.NotActive)
return;
if (itemId != 0)
_selection.Select(itemId, SelectionChangeSource.Paperdoll);
};
list.Cell.DoubleClicked = () =>
{
if (list.Cell.ItemId != 0)
_itemInteraction?.ActivateItem(list.Cell.ItemId);
};
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
// 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));
if (unlockBit != AetheriaUnlockState.None)
_aetheriaSlots.Add((unlockBit, list));
}
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_selection.Changed += OnSelectionChanged;
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
foreach (var id in ArmorSlotElementIds)
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
_dollViewport = layout.FindElement(DollViewportId); // doll viewport (may be null until Slice 3)
// The authored map decides whether the pixel is a body hit and which worn
// item is visually upper there. Target-use deliberately substitutes self.
// The cursor remains pending over UI; retail resolves target cursors from
// the SmartBox world object rather than the paperdoll viewport.
Action<int, int> clickDoll = HandleDollClick;
if (_dollViewport is UiViewport doll)
doll.ClickedAt = clickDoll;
// Retail's authored click/drag mask sits above the viewport and owns
// doll hit-testing (gmPaperDollUI::ListenToElementMessage). Binding only
// the viewport cannot win a live hit against this full-height overlay.
switch (layout.FindElement(DollDragMaskId))
{
case UiButton dragMaskButton:
_dollDragMask = dragMaskButton;
dragMaskButton.OnClickAt = clickDoll;
break;
case UiDatElement dragMaskElement:
_dollDragMask = dragMaskElement;
dragMaskElement.ClickThrough = false;
dragMaskElement.OnClickAt = clickDoll;
break;
}
var slotsBtnEl = layout.FindElement(0x100005BEu);
if (slotsBtnEl is UiButton slotsBtn)
{
slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); };
// The dat element has no face sprite, so without a label the button is invisible. Give it a
// left-aligned WHITE "Slots" caption (retail is white, not gold) so it's findable + clickable.
if (datFont is not null)
{
slotsBtn.Label = "Slots";
slotsBtn.LabelFont = datFont;
slotsBtn.LabelColor = System.Numerics.Vector4.One; // white (was gold)
slotsBtn.LabelAlign = UiButton.LabelAlignment.Left; // sit at the left, before the slots
}
}
ApplyView(); // initial state = doll-view (armor slots hidden)
Populate();
}
public static PaperdollController Bind(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
ItemInteractionController? itemInteraction = null,
PaperdollClickMap? clickMap = null,
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null,
IReadOnlyDictionary<uint, uint>? emptySlotSprites = null)
=> new PaperdollController(
layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite,
datFont, itemInteraction, clickMap, dragIconIds, emptySlotSprites);
private void HandleDollClick(int x, int y)
{
EquipMask bodyLocation = _clickMap?.GetBodyLocation(x, y) ?? EquipMask.None;
uint hitObject = PaperdollSelectionPolicy.GetUpperInventoryObject(
_objects,
_playerGuid(),
bodyLocation);
if (hitObject == 0)
return;
// Retail targets self for any valid paperdoll body hit while target mode
// is active; ordinary clicks select the upper worn object or player fallback.
// gmPaperDollUI::ListenToElementMessage @ 0x004A5C30.
if (_itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive)
return;
_selection.Select(hitObject, SelectionChangeSource.Paperdoll);
}
private void OnObjectChanged(ClientObject o)
{
if (o.ObjectId == _playerGuid())
ApplyAetheriaVisibility();
else if (Concerns(o))
Populate();
}
private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
/// <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);
uint dragTex = _dragIconIds?.Invoke(
worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u;
list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex);
}
ApplyAetheriaVisibility();
ApplySelectionIndicators();
}
/// <summary>
/// Retail <c>gmPaperDollUI::UpdateAetheria @ 0x004A3E50</c>: property 0x142
/// bits 1/2/4 independently expose the blue/yellow/red sigil lists. Missing
/// player state is the locked state, matching PostInit before PlayerDescription.
/// </summary>
private void ApplyAetheriaVisibility()
{
AetheriaUnlockState unlocked = AetheriaUnlocks.Read(_objects.Get(_playerGuid()));
foreach (var (bit, list) in _aetheriaSlots)
list.Visible = (unlocked & bit) != AetheriaUnlockState.None;
}
private void ApplySelectionIndicators()
{
foreach (var (_, list) in _slots)
{
list.Cell.Selected = list.Cell.ItemId != 0
&& list.Cell.ItemId == _selection.SelectedObjectId;
}
}
private EquipMask MaskFor(UiItemList list)
{
foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask;
return EquipMask.None;
}
// ── IItemListDragHandler ──────────────────────────────────────────────────────────────────────
/// <summary>Selects the wielded item before the waiting mesh appears. The item itself stays put
/// until the server confirms, like the inventory grid and 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)
{
// UIElement_ItemList::ItemList_BeginDrag @ 0x004E32D0.
if (payload.ObjId != 0 && _selection.SelectedObjectId != payload.ObjId)
_selection.Select(payload.ObjId, SelectionChangeSource.Paperdoll);
}
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// gmPaperDollUI::OnItemListDragOver @ 0x004A4270 ignores shortcut
// aliases through its (DropItemFlags & 0xE) == 0 gate.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
var item = _objects.Get(payload.ObjId);
if (item is null)
return ItemDragAcceptance.Reject;
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None
? ItemDragAcceptance.Accept
: ItemDragAcceptance.Reject;
}
/// <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)
{
// gmPaperDollUI::HandleDropRelease @ 0x004A4D80 applies the same flag
// gate before accepting/wielding the physical object.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return;
var item = _objects.Get(payload.ObjId);
if (item is null) return;
EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList));
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
}
/// <summary>
/// Pushes the current <see cref="PaperdollViewState"/> onto the live widgets:
/// hides/shows the doll viewport and each armor slot according to the toggle.
/// Called once at construction (doll-view default) and on every button click.
/// </summary>
private void ApplyView()
{
if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible;
if (_dollDragMask is not null) _dollDragMask.Visible = _viewState.DollVisible;
foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible;
}
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
if (_dollViewport is UiViewport doll)
doll.ClickedAt = null;
switch (_dollDragMask)
{
case UiButton button:
button.OnClickAt = null;
break;
case UiDatElement element:
element.OnClickAt = null;
break;
}
}
}