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:
parent
2644d1d527
commit
0b74d19475
16 changed files with 490 additions and 32 deletions
92
src/AcDream.App/UI/Layout/PaperdollClickMap.cs
Normal file
92
src/AcDream.App/UI/Layout/PaperdollClickMap.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Textures;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// CPU-side body-part map used by retail's paperdoll drag mask. Ports
|
||||
/// <c>gmPaperDollUI::CreateClickMap @ 0x004A4850</c> and
|
||||
/// <c>gmPaperDollUI::GetPaperDollItemUnderMouse @ 0x004A4920</c>.
|
||||
/// </summary>
|
||||
public sealed class PaperdollClickMap
|
||||
{
|
||||
public const uint ClickMapEnum = 0x1000000Cu;
|
||||
public const uint InterfaceEnumCategory = 7u;
|
||||
|
||||
private readonly byte[] _rgba;
|
||||
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
|
||||
public PaperdollClickMap(byte[] rgba, int width, int height)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rgba);
|
||||
if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width));
|
||||
if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height));
|
||||
if (rgba.Length < checked(width * height * 4))
|
||||
throw new ArgumentException("The RGBA buffer is smaller than the declared map.", nameof(rgba));
|
||||
|
||||
_rgba = rgba;
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>DBObj::GetDIDByEnum(0x1000000C, 7)</c> through the portal master map
|
||||
/// and decodes the resulting RenderSurface for exact per-pixel sampling.
|
||||
/// The caller owns synchronization around <paramref name="dats"/>.
|
||||
/// </summary>
|
||||
public static PaperdollClickMap? Load(DatCollection dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|
||||
|| master is null
|
||||
|| !master.ClientEnumToID.TryGetValue(InterfaceEnumCategory, out uint subMapDid)
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(subMapDid, out var subMap)
|
||||
|| subMap is null
|
||||
|| !subMap.ClientEnumToID.TryGetValue(ClickMapEnum, out uint surfaceDid))
|
||||
return null;
|
||||
|
||||
if (!dats.Portal.TryGet<RenderSurface>(surfaceDid, out var surface)
|
||||
&& !dats.HighRes.TryGet<RenderSurface>(surfaceDid, out surface))
|
||||
return null;
|
||||
|
||||
Palette? palette = surface.DefaultPaletteId != 0
|
||||
? dats.Get<Palette>(surface.DefaultPaletteId)
|
||||
: null;
|
||||
DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(surface, palette);
|
||||
return decoded.Width > 1 && decoded.Height > 1
|
||||
? new PaperdollClickMap(decoded.Rgba8, decoded.Width, decoded.Height)
|
||||
: null;
|
||||
}
|
||||
|
||||
public EquipMask GetBodyLocation(int x, int y)
|
||||
{
|
||||
if ((uint)x >= (uint)Width || (uint)y >= (uint)Height)
|
||||
return EquipMask.None;
|
||||
|
||||
int pixel = (y * Width + x) * 4;
|
||||
byte r = _rgba[pixel];
|
||||
byte g = _rgba[pixel + 1];
|
||||
byte b = _rgba[pixel + 2];
|
||||
|
||||
return (r, g, b) switch
|
||||
{
|
||||
(0x00, 0x00, 0xFF) => EquipMask.HeadWear,
|
||||
(0x00, 0xFF, 0x00) => EquipMask.ChestWear | EquipMask.ChestArmor,
|
||||
(0xFF, 0x00, 0x00) => EquipMask.AbdomenWear | EquipMask.AbdomenArmor,
|
||||
(0x00, 0xFF, 0xFF) => EquipMask.UpperArmWear | EquipMask.UpperArmArmor,
|
||||
(0xFF, 0x00, 0xFF) => EquipMask.LowerArmWear | EquipMask.LowerArmArmor,
|
||||
(0xFF, 0xFF, 0x00) => EquipMask.UpperLegWear | EquipMask.UpperLegArmor,
|
||||
(0x00, 0x00, 0x80) => EquipMask.LowerLegWear | EquipMask.LowerLegArmor,
|
||||
(0x00, 0x80, 0x00) => EquipMask.HandWear,
|
||||
(0x80, 0x00, 0x00) => EquipMask.FootWear,
|
||||
_ => EquipMask.None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,10 +129,18 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
|
|||
/// elements (e.g. the chat Send / max-min buttons). Requires
|
||||
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
public Action<int, int>? OnClickAt { get; set; }
|
||||
|
||||
public override bool HandlesClick => OnClick is not null || OnClickAt is not null;
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
if (e.Type == UiEventType.Click && (OnClick is not null || OnClickAt is not null))
|
||||
{
|
||||
OnClick?.Invoke();
|
||||
OnClickAt?.Invoke(e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue