Port retail's first-slot ShowPendingInPlayer path for double-click loot and carry the current owned-container destination through deferred pickup. Retire the previous ground-container view as soon as a replacement is requested so its range close cannot cancel ACE's active MoveTo chain. Preserve active-combat weapon intent across ACE's authoritative wand-to-missile stance tail while leaving peace-mode switches unchanged. Release build succeeds and all 5,885 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
556 lines
20 KiB
C#
556 lines
20 KiB
C#
using System;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Selection;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Retail <c>gmExternalContainerUI</c> controller for LayoutDesc 0x21000008.
|
|
/// Chests, corpses, and other world containers share this bottom-screen strip;
|
|
/// it is intentionally independent from the owned backpack window.
|
|
/// </summary>
|
|
public sealed class ExternalContainerController : IItemListDragHandler, IRetainedPanelController
|
|
{
|
|
public const uint LayoutId = 0x21000008u;
|
|
public const uint RootId = 0x10000063u;
|
|
public const uint TopContainerId = 0x10000064u;
|
|
public const uint ContainerListId = 0x10000067u;
|
|
public const uint CloseButtonId = 0x10000068u;
|
|
public const uint ContentsListId = 0x1000006Au;
|
|
public const uint ContentsScrollbarId = 0x1000006Bu;
|
|
|
|
private const float ItemCellSize = 32f;
|
|
private const float ContainerCellSize = 36f;
|
|
// User-directed retained default: the raw 800-pixel LayoutDesc remains
|
|
// horizontally reachable by resize/persistence but opens less wide.
|
|
internal const float DefaultContentWidth = 700f;
|
|
internal const float MinimumContentWidth = 160f;
|
|
|
|
private readonly ExternalContainerState _state;
|
|
private readonly ClientObjectTable _objects;
|
|
private readonly SelectionState _selection;
|
|
private readonly ItemInteractionController _itemInteraction;
|
|
private readonly StackSplitQuantityState _stackSplitQuantity;
|
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
|
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveDragIcon;
|
|
private readonly Action<uint> _sendUse;
|
|
private readonly Action<uint, uint, int> _sendPutItemInContainer;
|
|
private readonly Action<uint, uint, uint, uint> _sendSplitToContainer;
|
|
private readonly Func<uint, bool> _isWithinUseRange;
|
|
private readonly RetailWindowHandle _window;
|
|
private readonly UiItemList _topContainer;
|
|
private readonly UiItemList _containerList;
|
|
private readonly UiItemList _contentsList;
|
|
|
|
private uint _openContainer;
|
|
private bool _closeRequested;
|
|
private bool _disposed;
|
|
|
|
private ExternalContainerController(
|
|
ImportedLayout layout,
|
|
ExternalContainerState state,
|
|
ClientObjectTable objects,
|
|
SelectionState selection,
|
|
ItemInteractionController itemInteraction,
|
|
StackSplitQuantityState stackSplitQuantity,
|
|
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
|
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
|
|
Action<uint> sendUse,
|
|
Action<uint, uint, int> sendPutItemInContainer,
|
|
Action<uint, uint, uint, uint> sendSplitToContainer,
|
|
Func<uint, bool> isWithinUseRange,
|
|
RetailWindowHandle window,
|
|
uint contentsEmptySprite,
|
|
uint containerEmptySprite)
|
|
{
|
|
_state = state;
|
|
_objects = objects;
|
|
_selection = selection;
|
|
_itemInteraction = itemInteraction;
|
|
_stackSplitQuantity = stackSplitQuantity;
|
|
_resolveIcon = resolveIcon;
|
|
_resolveDragIcon = resolveDragIcon;
|
|
_sendUse = sendUse;
|
|
_sendPutItemInContainer = sendPutItemInContainer;
|
|
_sendSplitToContainer = sendSplitToContainer;
|
|
_isWithinUseRange = isWithinUseRange;
|
|
_window = window;
|
|
|
|
_topContainer = RequiredList(layout, TopContainerId);
|
|
_containerList = RequiredList(layout, ContainerListId);
|
|
_contentsList = RequiredList(layout, ContentsListId);
|
|
|
|
ConfigureList(_topContainer, ContainerCellSize, horizontalScroll: false, containerEmptySprite);
|
|
ConfigureList(_containerList, ContainerCellSize, horizontalScroll: true, containerEmptySprite);
|
|
ConfigureList(_contentsList, ItemCellSize, horizontalScroll: true, contentsEmptySprite);
|
|
_contentsList.RegisterDragHandler(this);
|
|
|
|
if (layout.FindElement(ContentsScrollbarId) is UiScrollbar scrollbar)
|
|
{
|
|
scrollbar.Model = _contentsList.Scroll;
|
|
scrollbar.Horizontal = true;
|
|
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
|
|
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
|
|
// factory treats all horizontal bars as scalar controls; this panel
|
|
// binds the authored model sprites explicitly.
|
|
scrollbar.TrackSprite = 0x06004C7Fu;
|
|
scrollbar.ThumbTopSprite = 0x06004C80u;
|
|
scrollbar.ThumbSprite = 0x06004C83u;
|
|
scrollbar.ThumbBotSprite = 0x06004C86u;
|
|
scrollbar.DownSprite = 0x06004C89u;
|
|
scrollbar.UpSprite = 0x06004C8Cu;
|
|
}
|
|
|
|
BindClose(layout, RequestClose);
|
|
|
|
_state.Changed += OnExternalContainerChanged;
|
|
_objects.ObjectAdded += OnObjectChanged;
|
|
_objects.ObjectUpdated += OnObjectChanged;
|
|
_objects.ObjectMoved += OnObjectMoved;
|
|
_objects.ObjectRemoved += OnObjectRemoved;
|
|
_objects.ContainerContentsReplaced += OnContentsReplaced;
|
|
_objects.Cleared += OnObjectsCleared;
|
|
_selection.Changed += OnSelectionChanged;
|
|
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
|
ClearLists();
|
|
}
|
|
|
|
public static ExternalContainerController Bind(
|
|
ImportedLayout layout,
|
|
ExternalContainerState state,
|
|
ClientObjectTable objects,
|
|
SelectionState selection,
|
|
ItemInteractionController itemInteraction,
|
|
StackSplitQuantityState stackSplitQuantity,
|
|
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
|
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
|
|
Action<uint> sendUse,
|
|
Action<uint, uint, int> sendPutItemInContainer,
|
|
Action<uint, uint, uint, uint> sendSplitToContainer,
|
|
Func<uint, bool> isWithinUseRange,
|
|
RetailWindowHandle window,
|
|
uint contentsEmptySprite = 0u,
|
|
uint containerEmptySprite = 0u)
|
|
=> new(
|
|
layout,
|
|
state,
|
|
objects,
|
|
selection,
|
|
itemInteraction,
|
|
stackSplitQuantity,
|
|
resolveIcon,
|
|
resolveDragIcon,
|
|
sendUse,
|
|
sendPutItemInContainer,
|
|
sendSplitToContainer,
|
|
isWithinUseRange,
|
|
window,
|
|
contentsEmptySprite,
|
|
containerEmptySprite);
|
|
|
|
/// <summary>
|
|
/// Retail's root authors only the tiled center surface; the common floating-window
|
|
/// bevel surrounds it. Its Left/Right edge rules stretch every horizontal list and
|
|
/// pin the close button, so this strip resizes on X only.
|
|
/// </summary>
|
|
internal static RetailWindowFrame.Options CreateWindowOptions(UiElement root)
|
|
=> new()
|
|
{
|
|
WindowName = WindowNames.ExternalContainer,
|
|
Chrome = RetailWindowChrome.NineSlice,
|
|
Left = root.Left,
|
|
Top = root.Top,
|
|
ContentWidth = Math.Min(root.Width, DefaultContentWidth),
|
|
ContentHeight = root.Height,
|
|
MinWidth = MinimumContentWidth + 2f * RetailChromeSprites.Border,
|
|
Visible = false,
|
|
Draggable = true,
|
|
Resizable = true,
|
|
ResizeX = true,
|
|
ResizeY = false,
|
|
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right,
|
|
ConstrainDragToParent = true,
|
|
ConstrainResizeToParent = true,
|
|
DrawChromeCenter = false,
|
|
};
|
|
|
|
public void Tick()
|
|
{
|
|
uint root = _state.CurrentContainerId;
|
|
if (root != 0u && _window.IsVisible && !_closeRequested && !_isWithinUseRange(root))
|
|
RequestClose();
|
|
}
|
|
|
|
public void RequestClose()
|
|
{
|
|
uint root = _state.CurrentContainerId;
|
|
if (root == 0u || _closeRequested)
|
|
return;
|
|
|
|
// gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0 sends
|
|
// Use(root), detaches the lists, and waits for event 0x0052. It does not
|
|
// send NoLongerViewingContents on this path.
|
|
_closeRequested = true;
|
|
_window.Hide();
|
|
ClearLists();
|
|
_sendUse(root);
|
|
}
|
|
|
|
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
|
{
|
|
if (payload.ObjId != 0u)
|
|
_selection.Select(payload.ObjId, SelectionChangeSource.ExternalContainer);
|
|
}
|
|
|
|
public ItemDragAcceptance OnDragOver(
|
|
UiItemList targetList,
|
|
UiItemSlot targetCell,
|
|
ItemDragPayload payload)
|
|
{
|
|
if (!ReferenceEquals(targetList, _contentsList)
|
|
|| payload.SourceKind == ItemDragSource.ShortcutBar
|
|
|| payload.ObjId == 0u
|
|
|| _openContainer == 0u
|
|
|| payload.ObjId == _openContainer)
|
|
return ItemDragAcceptance.Reject;
|
|
return ItemDragAcceptance.Accept;
|
|
}
|
|
|
|
public void HandleDropRelease(
|
|
UiItemList targetList,
|
|
UiItemSlot targetCell,
|
|
ItemDragPayload payload)
|
|
{
|
|
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
|
|
return;
|
|
if (_objects.Get(payload.ObjId) is not { } item)
|
|
return;
|
|
|
|
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
|
uint amount = _stackSplitQuantity.GetObjectSplitSize(
|
|
item.ObjectId,
|
|
_selection.SelectedObjectId ?? 0u,
|
|
fullStack);
|
|
int placement = targetCell.ItemId != 0u
|
|
? Math.Max(0, targetCell.SlotIndex)
|
|
: _objects.GetContents(_openContainer).Count;
|
|
|
|
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and
|
|
// UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not
|
|
// optimistically rewrite external ownership.
|
|
if (amount < fullStack)
|
|
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
|
|
else
|
|
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
|
|
}
|
|
|
|
private void OnExternalContainerChanged(ExternalContainerTransition transition)
|
|
{
|
|
if (transition.Kind == ExternalContainerTransitionKind.ReplacementRequested)
|
|
{
|
|
// Retail SetGroundObject(new) first publishes SetGroundObject(0)
|
|
// to the panel and only repopulates it when the new root's
|
|
// ViewContents is accepted.
|
|
_openContainer = 0u;
|
|
_closeRequested = false;
|
|
_window.Hide();
|
|
ClearLists();
|
|
return;
|
|
}
|
|
|
|
if (transition.ContainerId == 0u)
|
|
{
|
|
_openContainer = 0u;
|
|
_closeRequested = false;
|
|
_window.Hide();
|
|
ClearLists();
|
|
return;
|
|
}
|
|
|
|
_openContainer = transition.ContainerId;
|
|
_closeRequested = false;
|
|
ScrollToHome();
|
|
Populate();
|
|
_window.Show();
|
|
}
|
|
|
|
private void Populate()
|
|
{
|
|
uint root = _state.CurrentContainerId;
|
|
if (root == 0u)
|
|
{
|
|
ClearLists();
|
|
return;
|
|
}
|
|
if (_openContainer == 0u)
|
|
_openContainer = root;
|
|
|
|
using IDisposable topLayout = _topContainer.DeferLayout();
|
|
using IDisposable containerLayout = _containerList.DeferLayout();
|
|
using IDisposable contentsLayout = _contentsList.DeferLayout();
|
|
_topContainer.Flush();
|
|
_containerList.Flush();
|
|
_contentsList.Flush();
|
|
|
|
AddRootCell(root);
|
|
foreach (uint guid in _objects.GetContents(root))
|
|
{
|
|
if (IsContainer(_objects.Get(guid)))
|
|
AddContainerCell(guid);
|
|
}
|
|
|
|
foreach (uint guid in _objects.GetContents(_openContainer))
|
|
{
|
|
if (!IsContainer(_objects.Get(guid)))
|
|
AddContentsCell(guid);
|
|
}
|
|
ApplyIndicators();
|
|
}
|
|
|
|
private void AddRootCell(uint guid)
|
|
{
|
|
UiItemSlot cell = CreateCell(_topContainer, guid, ItemDragSource.Ground);
|
|
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
|
|
cell.DoubleClicked = RequestClose;
|
|
cell.IsOpenContainer = _openContainer == guid;
|
|
SetCapacity(cell, guid);
|
|
_topContainer.AddItem(cell);
|
|
}
|
|
|
|
private void AddContainerCell(uint guid)
|
|
{
|
|
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
|
|
cell.Clicked = () => HandlePrimaryClick(guid, () => OpenNestedContainer(guid));
|
|
SetCapacity(cell, guid);
|
|
_containerList.AddItem(cell);
|
|
}
|
|
|
|
private void AddContentsCell(uint guid)
|
|
{
|
|
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
|
|
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
|
|
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
|
|
cell.DragAcceptSprite = 0x060011F9u;
|
|
cell.DragRejectSprite = 0x060011F8u;
|
|
_contentsList.AddItem(cell);
|
|
}
|
|
|
|
private UiItemSlot CreateCell(UiItemList owner, uint guid, ItemDragSource source)
|
|
{
|
|
ClientObject? item = _objects.Get(guid);
|
|
uint icon = item is null ? 0u : _resolveIcon(
|
|
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
|
uint dragIcon = item is null ? 0u : _resolveDragIcon(
|
|
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
|
var cell = new UiItemSlot
|
|
{
|
|
SpriteResolve = owner.SpriteResolve,
|
|
SlotIndex = owner.GetNumUIItems(),
|
|
SourceKind = source,
|
|
};
|
|
cell.SetItem(guid, icon, dragIconTexture: dragIcon);
|
|
return cell;
|
|
}
|
|
|
|
private void OpenNestedContainer(uint guid)
|
|
{
|
|
Select(guid);
|
|
_openContainer = guid;
|
|
_contentsList.Scroll.SetScrollY(0);
|
|
Populate();
|
|
}
|
|
|
|
private void Select(uint guid)
|
|
=> _selection.Select(guid, SelectionChangeSource.ExternalContainer);
|
|
|
|
private void HandlePrimaryClick(uint guid, Action fallback)
|
|
{
|
|
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
|
|
return;
|
|
fallback();
|
|
}
|
|
|
|
private void ApplyIndicators()
|
|
{
|
|
ApplyIndicators(_topContainer);
|
|
ApplyIndicators(_containerList);
|
|
ApplyIndicators(_contentsList);
|
|
}
|
|
|
|
private void ApplyIndicators(UiItemList list)
|
|
{
|
|
for (int i = 0; i < list.GetNumUIItems(); i++)
|
|
{
|
|
if (list.GetItem(i) is not { } cell) continue;
|
|
bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId);
|
|
cell.Selected = cell.ItemId != 0u
|
|
&& cell.ItemId == _selection.SelectedObjectId
|
|
&& !pendingSource;
|
|
cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer;
|
|
}
|
|
}
|
|
|
|
private void SetCapacity(UiItemSlot cell, uint containerId)
|
|
{
|
|
int capacity = _objects.Get(containerId)?.ItemsCapacity ?? 0;
|
|
cell.CapacityFill = capacity <= 0
|
|
? -1f
|
|
: Math.Clamp(_objects.GetContents(containerId).Count / (float)capacity, 0f, 1f);
|
|
}
|
|
|
|
private void ClearLists()
|
|
{
|
|
_topContainer.Flush();
|
|
_containerList.Flush();
|
|
_contentsList.Flush();
|
|
ScrollToHome();
|
|
}
|
|
|
|
private void ScrollToHome()
|
|
{
|
|
_topContainer.Scroll.SetScrollY(0);
|
|
_containerList.Scroll.SetScrollY(0);
|
|
_contentsList.Scroll.SetScrollY(0);
|
|
}
|
|
|
|
private void OnObjectChanged(ClientObject item)
|
|
{
|
|
if (_window.IsVisible && Concerns(item))
|
|
Populate();
|
|
}
|
|
|
|
private void OnObjectMoved(ClientObjectMove move)
|
|
{
|
|
if (!_window.IsVisible) return;
|
|
uint root = _state.CurrentContainerId;
|
|
if ((move.Item is { } item && Concerns(item))
|
|
|| move.Previous.ContainerId == root
|
|
|| move.Current.ContainerId == root
|
|
|| move.Previous.ContainerId == _openContainer
|
|
|| move.Current.ContainerId == _openContainer)
|
|
{
|
|
Populate();
|
|
}
|
|
}
|
|
private void OnContentsReplaced(uint containerId)
|
|
{
|
|
if (_window.IsVisible
|
|
&& (containerId == _state.CurrentContainerId
|
|
|| containerId == _openContainer
|
|
|| ProjectionContains(_state.CurrentContainerId, containerId)))
|
|
Populate();
|
|
}
|
|
|
|
private void OnObjectRemoved(ClientObject item)
|
|
{
|
|
if (_selection.SelectedObjectId == item.ObjectId)
|
|
{
|
|
_selection.Clear(
|
|
SelectionChangeSource.System,
|
|
SelectionChangeReason.SelectedObjectRemoved);
|
|
}
|
|
|
|
if (item.ObjectId == _state.CurrentContainerId)
|
|
_state.ApplyClose(item.ObjectId);
|
|
else if (_window.IsVisible && Concerns(item))
|
|
Populate();
|
|
}
|
|
|
|
private void OnObjectsCleared()
|
|
{
|
|
_openContainer = 0u;
|
|
_closeRequested = false;
|
|
_window.Hide();
|
|
ClearLists();
|
|
}
|
|
|
|
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
|
private void OnInteractionStateChanged() => ApplyIndicators();
|
|
|
|
private static bool IsContainer(ClientObject? item)
|
|
=> item is not null
|
|
&& (item.ContainerTypeHint != 0u
|
|
|| item.Type.HasFlag(ItemType.Container)
|
|
|| item.ItemsCapacity > 0);
|
|
|
|
private bool Concerns(ClientObject item)
|
|
{
|
|
uint root = _state.CurrentContainerId;
|
|
return item.ObjectId == root
|
|
|| item.ObjectId == _openContainer
|
|
|| item.ContainerId == root
|
|
|| item.ContainerId == _openContainer
|
|
|| ProjectionContains(root, item.ObjectId)
|
|
|| ProjectionContains(_openContainer, item.ObjectId);
|
|
}
|
|
|
|
private bool ProjectionContains(uint containerId, uint itemId)
|
|
{
|
|
if (containerId == 0u || itemId == 0u) return false;
|
|
foreach (uint candidate in _objects.GetContents(containerId))
|
|
{
|
|
if (candidate == itemId) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static void ConfigureList(
|
|
UiItemList list,
|
|
float cellSize,
|
|
bool horizontalScroll,
|
|
uint emptySprite)
|
|
{
|
|
list.Columns = 1;
|
|
list.CellWidth = cellSize;
|
|
list.CellHeight = cellSize;
|
|
list.SingleRow = true;
|
|
list.HorizontalScroll = horizontalScroll;
|
|
list.FillVisibleEmptySlots = true;
|
|
if (emptySprite != 0u)
|
|
list.CellEmptySprite = emptySprite;
|
|
list.EmptySlotFactory = () => new UiItemSlot
|
|
{
|
|
SpriteResolve = list.SpriteResolve,
|
|
SourceKind = ItemDragSource.Ground,
|
|
DragAcceptSprite = 0x060011F9u,
|
|
DragRejectSprite = 0x060011F8u,
|
|
};
|
|
}
|
|
|
|
private static UiItemList RequiredList(ImportedLayout layout, uint id)
|
|
=> layout.FindElement(id) as UiItemList
|
|
?? throw new InvalidOperationException(
|
|
$"External-container LayoutDesc is missing ItemList 0x{id:X8}.");
|
|
|
|
private static void BindClose(ImportedLayout layout, Action close)
|
|
{
|
|
switch (layout.FindElement(CloseButtonId))
|
|
{
|
|
case UiButton button:
|
|
button.OnClick = close;
|
|
break;
|
|
case UiDatElement element:
|
|
element.ClickThrough = false;
|
|
element.OnClick = close;
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException(
|
|
"External-container LayoutDesc is missing its close button.");
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_state.Changed -= OnExternalContainerChanged;
|
|
_objects.ObjectAdded -= OnObjectChanged;
|
|
_objects.ObjectUpdated -= OnObjectChanged;
|
|
_objects.ObjectMoved -= OnObjectMoved;
|
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
|
_objects.ContainerContentsReplaced -= OnContentsReplaced;
|
|
_objects.Cleared -= OnObjectsCleared;
|
|
_selection.Changed -= OnSelectionChanged;
|
|
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
|
}
|
|
}
|