feat(items): port retail external-container looting
Add the ClientUISystem ground-object lifecycle, authoritative root and nested ViewContents projections, replacement and close semantics, and the DAT-authored gmExternalContainerUI strip for chests and corpses. Route double-click loot and full or partial drag transfers through the shared retail item policy without optimistic external ownership. Remove the incorrect NoLongerViewingContents behavior from owned side packs and retire AP-106/#196. Release build succeeds and all 5,875 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
48a118db91
commit
20ce67b625
27 changed files with 1750 additions and 45 deletions
514
src/AcDream.App/UI/Layout/ExternalContainerController.cs
Normal file
514
src/AcDream.App/UI/Layout/ExternalContainerController.cs
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
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;
|
||||
|
||||
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);
|
||||
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue