fix(ui): port exact paperdoll body selection

Resolve and sample retail's authored nine-color paperdoll click map, preserve local click coordinates, and select the stable highest-priority worn item with the player fallback. Keep targeted-use body clicks routed to self and pin both synthetic and live-DAT conformance.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 11:56:26 +02:00
parent 2644d1d527
commit 0b74d19475
16 changed files with 490 additions and 32 deletions

View file

@ -81,6 +81,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
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();
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
@ -94,11 +95,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
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)
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction,
PaperdollClickMap? clickMap)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_clickMap = clickMap;
for (int i = 0; i < SlotMap.Length; i++)
{
@ -142,13 +145,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
_dollViewport = layout.FindElement(DollViewportId); // doll viewport (may be null until Slice 3)
// The doll IS the player: clicking it during target-use self-targets
// like the equip cells do. (The CURSOR over it stays the pending
// four-arrows — retail resolves target cursors off the SmartBox world
// object only; UI hover is always pending. 2026-07-03 visual gate.)
Action offerSelf = () => { _itemInteraction?.OfferSelfPrimaryClick(); };
// 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.Clicked = offerSelf;
doll.ClickedAt = clickDoll;
// Retail's authored click/drag mask sits above the viewport and owns
// doll hit-testing (gmPaperDollUI::ListenToElementMessage). Binding only
@ -157,12 +160,12 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
{
case UiButton dragMaskButton:
_dollDragMask = dragMaskButton;
dragMaskButton.OnClick = offerSelf;
dragMaskButton.OnClickAt = clickDoll;
break;
case UiDatElement dragMaskElement:
_dollDragMask = dragMaskElement;
dragMaskElement.ClickThrough = false;
dragMaskElement.OnClick = offerSelf;
dragMaskElement.OnClickAt = clickDoll;
break;
}
@ -191,10 +194,31 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
ItemInteractionController? itemInteraction = null)
ItemInteractionController? itemInteraction = null,
PaperdollClickMap? clickMap = null)
=> new PaperdollController(
layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite,
datFont, itemInteraction);
datFont, itemInteraction, clickMap);
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 (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
@ -313,14 +337,14 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
if (_dollViewport is UiViewport doll)
doll.Clicked = null;
doll.ClickedAt = null;
switch (_dollDragMask)
{
case UiButton button:
button.OnClick = null;
button.OnClickAt = null;
break;
case UiDatElement element:
element.OnClick = null;
element.OnClickAt = null;
break;
}
}