using System; using System.Collections.Generic; using AcDream.App.UI; using AcDream.Core.Items; using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; /// /// 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). /// public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelController { public const uint DollViewportId = 0x100001D5u; public const uint DollDragMaskId = 0x100001D6u; // ── Slots-toggle public surface ─────────────────────────────────────────────────────────────── /// /// 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. /// public static readonly uint[] ArmorSlotElementIds = { 0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu, 0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u, }; /// /// 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. /// 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 _playerGuid; private readonly Func _iconIds; private readonly Func? _dragIconIds; private readonly Action? _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 _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 playerGuid, Func iconIds, SelectionState selection, Action? sendWield, uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction, PaperdollClickMap? clickMap, Func? dragIconIds, IReadOnlyDictionary? 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 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 playerGuid, Func iconIds, SelectionState selection, Action? sendWield = null, uint emptySlotSprite = 0u, UiDatFont? datFont = null, ItemInteractionController? itemInteraction = null, PaperdollClickMap? clickMap = null, Func? dragIconIds = null, IReadOnlyDictionary? 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(); /// 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). 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(); 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(); } /// /// Retail gmPaperDollUI::UpdateAetheria @ 0x004A3E50: 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. /// 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 ────────────────────────────────────────────────────────────────────── /// 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. 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); } /// Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask). /// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302). 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; } /// 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. 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); } /// /// Pushes the current 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. /// 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; } /// Detach event handlers (idempotent). 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; } } }