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
|
|
@ -840,6 +840,8 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
|
||||
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
|
||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
private readonly AcDream.Core.Items.ExternalContainerState _externalContainers = new();
|
||||
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
|
||||
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
|
||||
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
|
||||
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
|
||||
|
|
@ -2110,6 +2112,10 @@ public sealed class GameWindow : IDisposable
|
|||
_selection,
|
||||
autoTarget: () => _persistedGameplay.AutoTarget,
|
||||
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
|
||||
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
|
||||
_externalContainers,
|
||||
Objects,
|
||||
guid => _liveSession?.SendNoLongerViewingContents(guid));
|
||||
|
||||
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
||||
if (_options.RetailUi)
|
||||
|
|
@ -2146,7 +2152,11 @@ public sealed class GameWindow : IDisposable
|
|||
stackSplitQuantity: _stackSplitQuantity,
|
||||
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
sendSplitToContainer: (item, container, placement, amount) =>
|
||||
_liveSession?.SendStackableSplitToContainer(
|
||||
item, container, placement, amount),
|
||||
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
|
||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
_itemInteractionController,
|
||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||
|
|
@ -2358,7 +2368,6 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
|
||||
is { } strength ? (int?)strength.Current : null,
|
||||
guid => _liveSession?.SendUse(guid),
|
||||
guid => _liveSession?.SendNoLongerViewingContents(guid),
|
||||
(item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
(item, container, placement, amount) =>
|
||||
|
|
@ -2368,6 +2377,22 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSession?.SendStackableMerge(source, target, amount),
|
||||
_itemInteractionController,
|
||||
_selection),
|
||||
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
|
||||
_externalContainers,
|
||||
Objects,
|
||||
(type, icon, under, over, effects) =>
|
||||
iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
(type, icon, under, over, effects) =>
|
||||
iconComposer.GetDragIcon(type, icon, under, over, effects),
|
||||
_itemInteractionController,
|
||||
_selection,
|
||||
guid => _liveSession?.SendUse(guid),
|
||||
(item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||
(item, container, placement, amount) =>
|
||||
_liveSession?.SendStackableSplitToContainer(
|
||||
item, container, placement, amount),
|
||||
IsWithinExternalContainerUseRange),
|
||||
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
|
||||
cursorFeedbackController,
|
||||
retailCursorManager),
|
||||
|
|
@ -2863,6 +2888,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Attachment projections own GL-backed world registrations, so tear
|
||||
// them down before dropping the canonical GUID/timestamp snapshots.
|
||||
_equippedChildRenderer?.Clear();
|
||||
_externalContainers.Reset();
|
||||
Objects.Clear();
|
||||
SpellBook.Clear();
|
||||
_magicRuntime?.Reset();
|
||||
|
|
@ -3025,7 +3051,11 @@ public sealed class GameWindow : IDisposable
|
|||
},
|
||||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
onUseDone: error => _itemInteractionController?.CompleteUse(error),
|
||||
onUseDone: error =>
|
||||
{
|
||||
_externalContainers.ApplyUseDone(error);
|
||||
_itemInteractionController?.CompleteUse(error);
|
||||
},
|
||||
itemMana: ItemMana,
|
||||
onConfirmationRequest: request =>
|
||||
_retailUiRuntime?.HandleConfirmationRequest(request),
|
||||
|
|
@ -3038,7 +3068,8 @@ public sealed class GameWindow : IDisposable
|
|||
_characterOptions1 =
|
||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1,
|
||||
clientTime: ClientTimerNow);
|
||||
clientTime: ClientTimerNow,
|
||||
externalContainers: _externalContainers);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
|
|
@ -13266,6 +13297,9 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
if (IsExternalContainerTarget(guid))
|
||||
_externalContainers.RequestOpen(guid);
|
||||
|
||||
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
|
||||
// through the player's MoveToManager so close-range Use rotates the
|
||||
// body to face before the action fires. For FAR targets, ACE's
|
||||
|
|
@ -13482,6 +13516,40 @@ public sealed class GameWindow : IDisposable
|
|||
return distSq <= useRadius * useRadius;
|
||||
}
|
||||
|
||||
private bool IsWithinExternalContainerUseRange(uint targetGuid)
|
||||
{
|
||||
if (_playerController is null
|
||||
|| !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
||||
{
|
||||
// The server remains authoritative for forced close. An entity can be
|
||||
// temporarily absent from the visible-cell projection while its external
|
||||
// view is still valid; do not manufacture a range exit from missing render state.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!LastSpawns.TryGetValue(targetGuid, out var spawn)
|
||||
|| spawn.UseRadius is not > 0f)
|
||||
return true;
|
||||
float useRadius = spawn.UseRadius.Value;
|
||||
float dx = entity.Position.X - _playerController.Position.X;
|
||||
float dy = entity.Position.Y - _playerController.Position.Y;
|
||||
return dx * dx + dy * dy <= useRadius * useRadius;
|
||||
}
|
||||
|
||||
private bool IsExternalContainerTarget(uint guid)
|
||||
{
|
||||
if (Objects.Get(guid) is { } item
|
||||
&& (item.ContainerTypeHint != 0u
|
||||
|| item.Type.HasFlag(AcDream.Core.Items.ItemType.Container)
|
||||
|| item.ItemsCapacity > 0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return LastSpawns.TryGetValue(guid, out var spawn)
|
||||
&& (spawn.ObjectDescriptionFlags.GetValueOrDefault() & 0x2000u) != 0u;
|
||||
}
|
||||
|
||||
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
||||
{
|
||||
if (_playerController is not { } pc || pc.MoveTo is null) return;
|
||||
|
|
@ -14784,6 +14852,8 @@ public sealed class GameWindow : IDisposable
|
|||
_uiHost = null;
|
||||
_itemInteractionController?.Dispose();
|
||||
_itemInteractionController = null;
|
||||
_externalContainerLifecycle?.Dispose();
|
||||
_externalContainerLifecycle = null;
|
||||
_magicRuntime = null;
|
||||
_magicCatalog = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Action<uint, uint>? _sendWield;
|
||||
private readonly Action<uint>? _sendDrop;
|
||||
private readonly Action<uint, uint>? _sendSplitToWorld;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
|
||||
private readonly Action<uint, uint, uint>? _sendGive;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Func<bool> _readyForInventoryRequest;
|
||||
|
|
@ -39,6 +41,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Func<bool> _inNonCombatMode;
|
||||
private readonly Func<uint, bool> _isComponentPack;
|
||||
private readonly Action<uint>? _placeInBackpack;
|
||||
private readonly Action<uint>? _requestExternalContainer;
|
||||
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
||||
private readonly InteractionState _interactionState;
|
||||
private readonly Func<uint> _selectedObjectId;
|
||||
|
|
@ -78,7 +81,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
Action<uint, uint, uint>? sendGive = null,
|
||||
Func<bool>? dragOnPlayerOpensSecureTrade = null,
|
||||
Action<string>? systemMessage = null)
|
||||
Action<string>? systemMessage = null,
|
||||
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
|
||||
Action<uint>? requestExternalContainer = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -88,6 +93,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendWield = sendWield;
|
||||
_sendDrop = sendDrop;
|
||||
_sendSplitToWorld = sendSplitToWorld;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendSplitToContainer = sendSplitToContainer;
|
||||
_sendGive = sendGive;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
|
|
@ -98,6 +105,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_inNonCombatMode = inNonCombatMode ?? (() => false);
|
||||
_isComponentPack = isComponentPack ?? (_ => false);
|
||||
_placeInBackpack = placeInBackpack;
|
||||
_requestExternalContainer = requestExternalContainer;
|
||||
_auxiliaryAction = auxiliaryAction;
|
||||
_selectedObjectId = selectedObjectId ?? (() => 0u);
|
||||
_stackSplitQuantity = stackSplitQuantity;
|
||||
|
|
@ -416,6 +424,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
|
||||
acted |= _sendUseWithTarget is not null;
|
||||
break;
|
||||
case ItemPolicyActionKind.SetGroundObject:
|
||||
_requestExternalContainer?.Invoke(action.ObjectId);
|
||||
acted |= _requestExternalContainer is not null;
|
||||
break;
|
||||
case ItemPolicyActionKind.EnterTargetMode:
|
||||
EnterTargetMode(action.ObjectId);
|
||||
acted = true;
|
||||
|
|
@ -464,6 +476,18 @@ public sealed class ItemInteractionController : IDisposable
|
|||
action.ObjectId,
|
||||
(uint)Math.Max(1, action.Amount));
|
||||
break;
|
||||
case ItemPolicyActionKind.PlaceInContainer:
|
||||
{
|
||||
uint fullStack = (uint)Math.Max(
|
||||
1,
|
||||
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
|
||||
uint amount = (uint)Math.Max(1, action.Amount);
|
||||
if (amount < fullStack)
|
||||
_sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount);
|
||||
else
|
||||
_sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0);
|
||||
break;
|
||||
}
|
||||
case ItemPolicyActionKind.Reject:
|
||||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
private readonly Action<uint, uint, uint, uint>? _sendStackableSplitToContainer;
|
||||
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
||||
|
|
@ -84,7 +83,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint>? sendStackableSplitToContainer,
|
||||
Action<uint, uint, uint>? sendStackableMerge,
|
||||
|
|
@ -100,7 +98,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_strength = strength;
|
||||
_ownerName = ownerName;
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendStackableSplitToContainer = sendStackableSplitToContainer;
|
||||
_sendStackableMerge = sendStackableMerge;
|
||||
|
|
@ -212,7 +209,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint sideBagEmptySprite = 0u,
|
||||
uint mainPackEmptySprite = 0u,
|
||||
Action<uint>? sendUse = null,
|
||||
Action<uint>? sendNoLongerViewing = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||
Action<uint, uint, uint, uint>? sendStackableSplitToContainer = null,
|
||||
Action<uint, uint, uint>? sendStackableMerge = null,
|
||||
|
|
@ -224,7 +220,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
=> new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
|
||||
ownerName, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer,
|
||||
sendUse, sendPutItemInContainer,
|
||||
sendStackableSplitToContainer, sendStackableMerge,
|
||||
notifyMergeAttempt, itemInteraction,
|
||||
onClose, stackSplitQuantity);
|
||||
|
|
@ -529,7 +525,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
}
|
||||
|
||||
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
||||
// External-container contents are a temporary ViewContents projection.
|
||||
// Retail UIAttemptPutInContainer leaves them untouched until the server's
|
||||
// authoritative move response; only rearrangements within owned inventory
|
||||
// use the existing optimistic projection.
|
||||
if (payload.SourceKind != ItemDragSource.Ground)
|
||||
_objects.MoveItemOptimistic(item, container, placement);
|
||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
}
|
||||
|
||||
|
|
@ -588,8 +589,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
|
||||
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
|
||||
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and
|
||||
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail:
|
||||
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async).
|
||||
/// Owned packs are not ClientUISystem::groundObject and never send
|
||||
/// NoLongerViewingContents. Retail:
|
||||
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
|
||||
private void OpenContainer(uint guid)
|
||||
{
|
||||
|
|
@ -599,8 +601,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
|
||||
|
||||
uint p = _playerGuid();
|
||||
if (open != p && open != 0)
|
||||
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
|
||||
_openContainer = guid;
|
||||
if (guid != p)
|
||||
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
|
||||
|
|
|
|||
|
|
@ -118,13 +118,24 @@ public sealed record InventoryRuntimeBindings(
|
|||
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
|
||||
Func<int?> Strength,
|
||||
Action<uint>? SendUse,
|
||||
Action<uint>? SendNoLongerViewing,
|
||||
Action<uint, uint, int>? SendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
|
||||
Action<uint, uint, uint>? SendStackableMerge,
|
||||
ItemInteractionController ItemInteraction,
|
||||
SelectionState Selection);
|
||||
|
||||
public sealed record ExternalContainerRuntimeBindings(
|
||||
ExternalContainerState State,
|
||||
ClientObjectTable Objects,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
|
||||
ItemInteractionController ItemInteraction,
|
||||
SelectionState Selection,
|
||||
Action<uint> SendUse,
|
||||
Action<uint, uint, int> SendPutItemInContainer,
|
||||
Action<uint, uint, uint, uint> SendStackableSplitToContainer,
|
||||
Func<uint, bool> IsWithinUseRange);
|
||||
|
||||
public sealed record RetailUiPersistenceBindings(
|
||||
SettingsStore Store,
|
||||
Func<string> CharacterKey,
|
||||
|
|
@ -157,6 +168,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
ToolbarRuntimeBindings Toolbar,
|
||||
CharacterRuntimeBindings Character,
|
||||
InventoryRuntimeBindings Inventory,
|
||||
ExternalContainerRuntimeBindings ExternalContainer,
|
||||
RetailUiCursorBindings Cursor,
|
||||
ConfirmationRuntimeBindings Confirmations,
|
||||
StackSplitQuantityState StackSplitQuantity,
|
||||
|
|
@ -204,6 +216,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
MountExternalContainer();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
SyncToolbarWindowButtons();
|
||||
|
|
@ -215,7 +228,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
persistence.Store,
|
||||
persistence.CharacterKey,
|
||||
persistence.ScreenSize,
|
||||
stateManagedVisibilityWindows: [WindowNames.Combat, WindowNames.JumpPowerbar]);
|
||||
stateManagedVisibilityWindows:
|
||||
[
|
||||
WindowNames.Combat,
|
||||
WindowNames.JumpPowerbar,
|
||||
WindowNames.ExternalContainer,
|
||||
]);
|
||||
}
|
||||
|
||||
if (bindings.Probe.Enabled)
|
||||
|
|
@ -257,6 +275,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public UiViewport? PaperdollViewportWidget { get; private set; }
|
||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
public RetailDialogFactory? DialogFactory { get; private set; }
|
||||
public ExternalContainerController? ExternalContainerController { get; private set; }
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -283,6 +302,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
IndicatorBarController?.Tick();
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ExternalContainerController?.Tick();
|
||||
DialogFactory?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
|
|
@ -1577,7 +1597,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
InventoryController inventory = InventoryController.Bind(
|
||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
|
||||
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
||||
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
||||
contents, sideBag, mainPack, b.SendUse,
|
||||
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
|
||||
notifyMergeAttempt, b.ItemInteraction,
|
||||
() => CloseWindow(WindowNames.Inventory),
|
||||
|
|
@ -1596,6 +1616,83 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||||
}
|
||||
|
||||
private void MountExternalContainer()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
ExternalContainerController.LayoutId,
|
||||
ExternalContainerController.RootId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[D.2b] external container: LayoutDesc 0x21000008 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.ExternalContainer,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = root.Left,
|
||||
Top = root.Top,
|
||||
ContentWidth = root.Width,
|
||||
ContentHeight = root.Height,
|
||||
Visible = false,
|
||||
Draggable = false,
|
||||
Resizable = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
});
|
||||
|
||||
uint contentsEmpty;
|
||||
uint containerEmpty;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(
|
||||
_bindings.Assets.Dats,
|
||||
ExternalContainerController.LayoutId,
|
||||
ExternalContainerController.ContentsListId);
|
||||
containerEmpty = ItemListCellTemplate.ResolveEmptySprite(
|
||||
_bindings.Assets.Dats,
|
||||
ExternalContainerController.LayoutId,
|
||||
ExternalContainerController.ContainerListId);
|
||||
}
|
||||
|
||||
ExternalContainerRuntimeBindings b = _bindings.ExternalContainer;
|
||||
ExternalContainerController = ExternalContainerController.Bind(
|
||||
layout,
|
||||
b.State,
|
||||
b.Objects,
|
||||
b.Selection,
|
||||
b.ItemInteraction,
|
||||
StackSplitQuantity,
|
||||
b.ResolveIcon,
|
||||
b.ResolveDragIcon,
|
||||
b.SendUse,
|
||||
b.SendPutItemInContainer,
|
||||
b.SendStackableSplitToContainer,
|
||||
b.IsWithinUseRange,
|
||||
handle,
|
||||
contentsEmpty,
|
||||
containerEmpty);
|
||||
Host.WindowManager.AttachController(
|
||||
WindowNames.ExternalContainer,
|
||||
ExternalContainerController);
|
||||
Console.WriteLine(
|
||||
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
|
|
@ -140,6 +140,13 @@ public sealed class UiItemList : UiElement
|
|||
/// </summary>
|
||||
public bool SingleRow { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The single row uses the shared pixel scroll model as a horizontal offset.
|
||||
/// Retail's external-container list (LayoutDesc 0x21000008) is the authored
|
||||
/// horizontal ItemList + Scrollbar consumer.
|
||||
/// </summary>
|
||||
public bool HorizontalScroll { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
|
||||
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
|
||||
|
|
@ -206,6 +213,28 @@ public sealed class UiItemList : UiElement
|
|||
: Columns < 1 ? 1 : Columns;
|
||||
int cellH = (int)MathF.Round(CellHeight);
|
||||
|
||||
if (SingleRow && HorizontalScroll)
|
||||
{
|
||||
int cellW = Math.Max(1, (int)MathF.Round(CellWidth));
|
||||
Scroll.LineHeight = cellW;
|
||||
Scroll.ContentHeight = _cells.Count * cellW;
|
||||
Scroll.ViewHeight = (int)MathF.Floor(Width);
|
||||
Scroll.SetScrollY(Scroll.ScrollY);
|
||||
float scrollX = Scroll.ScrollY;
|
||||
|
||||
for (int i = 0; i < _cells.Count; i++)
|
||||
{
|
||||
float left = i * CellWidth - scrollX;
|
||||
UiItemSlot cell = _cells[i];
|
||||
cell.Left = left;
|
||||
cell.Top = 0f;
|
||||
cell.Width = CellWidth;
|
||||
cell.Height = CellHeight;
|
||||
cell.Visible = left < Width && left + CellWidth > 0f;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
|
||||
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
|
||||
// so the clamp uses the right max.
|
||||
|
|
@ -292,6 +321,16 @@ public sealed class UiItemList : UiElement
|
|||
public void ScrollItemIntoView(int index)
|
||||
{
|
||||
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
|
||||
if (SingleRow && HorizontalScroll)
|
||||
{
|
||||
float left = index * CellWidth;
|
||||
float right = left + CellWidth;
|
||||
if (left < Scroll.ScrollY)
|
||||
Scroll.SetScrollY((int)MathF.Floor(left));
|
||||
else if (right > Scroll.ScrollY + Width)
|
||||
Scroll.SetScrollY((int)MathF.Ceiling(right - Width));
|
||||
return;
|
||||
}
|
||||
int columns = Math.Max(1, Columns);
|
||||
int row = Flow == UiItemListFlow.ColumnMajor
|
||||
? index % Math.Max(1, RowCount(_cells.Count, columns))
|
||||
|
|
|
|||
|
|
@ -141,6 +141,11 @@ public sealed class UiScrollbar : UiElement
|
|||
if (SpriteResolve is not { } resolve) return;
|
||||
if (Horizontal)
|
||||
{
|
||||
if (ScalarChanged is null && Model is { } horizontalModel)
|
||||
{
|
||||
DrawHorizontalModel(ctx, resolve, horizontalModel);
|
||||
return;
|
||||
}
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
(float rangeLeft, float rangeWidth) = ScalarRangeRect();
|
||||
if (ScalarRangeSprite != 0)
|
||||
|
|
@ -196,6 +201,31 @@ public sealed class UiScrollbar : UiElement
|
|||
}
|
||||
}
|
||||
|
||||
private void DrawHorizontalModel(
|
||||
UiRenderContext ctx,
|
||||
Func<uint, (uint tex, int w, int h)> resolve,
|
||||
UiScrollable model)
|
||||
{
|
||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
||||
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, ButtonH, Height);
|
||||
DrawSprite(ctx, resolve, DownSprite, Width - ButtonH, 0f, ButtonH, Height);
|
||||
if (!model.HasOverflow) return;
|
||||
|
||||
float trackLeft = ButtonH;
|
||||
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
|
||||
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
|
||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
|
||||
{
|
||||
DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height);
|
||||
DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
|
||||
DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
|
||||
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
|
|
@ -263,6 +293,9 @@ public sealed class UiScrollbar : UiElement
|
|||
if (Horizontal && ScalarChanged is not null)
|
||||
return OnScalarEvent(e);
|
||||
|
||||
if (Horizontal && Model is not null)
|
||||
return OnHorizontalModelEvent(e);
|
||||
|
||||
if (Model is not { } m) return false;
|
||||
|
||||
switch (e.Type)
|
||||
|
|
@ -318,6 +351,50 @@ public sealed class UiScrollbar : UiElement
|
|||
return false;
|
||||
}
|
||||
|
||||
private bool OnHorizontalModelEvent(in UiEvent e)
|
||||
{
|
||||
UiScrollable m = Model!;
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
float x = e.Data1;
|
||||
if (x <= ButtonH) { m.ScrollByLines(-1); return true; }
|
||||
if (x >= Width - ButtonH) { m.ScrollByLines(1); return true; }
|
||||
|
||||
float trackLeft = ButtonH;
|
||||
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
|
||||
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
|
||||
if (x >= tx && x <= tx + tw)
|
||||
{
|
||||
_draggingThumb = true;
|
||||
_dragOffsetX = x - tx;
|
||||
}
|
||||
else
|
||||
{
|
||||
m.ScrollByPage(x < tx ? -1 : 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseMove when _draggingThumb:
|
||||
{
|
||||
float trackLeft = ButtonH;
|
||||
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
|
||||
float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio);
|
||||
float travel = MathF.Max(1f, trackLength - thumbWidth);
|
||||
float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel;
|
||||
m.SetPositionRatio(ratio);
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseUp:
|
||||
_draggingThumb = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool OnScalarEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public static class WindowNames
|
|||
public const string Character = "character";
|
||||
public const string CharacterInformation = "character-information";
|
||||
public const string Inventory = "inventory";
|
||||
public const string ExternalContainer = "external-container";
|
||||
public const string Chat = "chat";
|
||||
public const string Radar = "radar";
|
||||
public const string Combat = "combat";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the network side effect of replacing retail's current ground object.
|
||||
/// Presentation controllers observe the same state but do not own session
|
||||
/// lifecycle messages.
|
||||
/// </summary>
|
||||
public sealed class ExternalContainerLifecycleController : IDisposable
|
||||
{
|
||||
private readonly ExternalContainerState _state;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Action<uint> _sendNoLongerViewingContents;
|
||||
private bool _disposed;
|
||||
|
||||
public ExternalContainerLifecycleController(
|
||||
ExternalContainerState state,
|
||||
ClientObjectTable objects,
|
||||
Action<uint> sendNoLongerViewingContents)
|
||||
{
|
||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_sendNoLongerViewingContents = sendNoLongerViewingContents
|
||||
?? throw new ArgumentNullException(nameof(sendNoLongerViewingContents));
|
||||
_state.Changed += OnChanged;
|
||||
}
|
||||
|
||||
private void OnChanged(ExternalContainerTransition transition)
|
||||
{
|
||||
// ClientUISystem::SetGroundObject @ 0x00564510 always queues the old
|
||||
// root's contents tree for destruction when the ground object changes.
|
||||
if (transition.PreviousContainerId != 0u)
|
||||
_objects.StopViewingContentsTree(transition.PreviousContainerId);
|
||||
|
||||
// Only replacement emits Event_NoLongerViewingContents. The authored
|
||||
// close/range path instead sends Use(root) and waits for event 0x0052.
|
||||
if (transition.Kind == ExternalContainerTransitionKind.Replaced
|
||||
&& transition.PreviousContainerId != 0u)
|
||||
{
|
||||
_sendNoLongerViewingContents(transition.PreviousContainerId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_state.Changed -= OnChanged;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue