feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4aebf444d9
commit
81d9b3b37a
2 changed files with 165 additions and 8 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
|
@ -11,7 +12,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
|
||||
/// drag-into-bag / wield-drop wire are later sub-phases.
|
||||
/// </summary>
|
||||
public sealed class InventoryController
|
||||
public sealed class InventoryController : IItemListDragHandler
|
||||
{
|
||||
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
|
||||
public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
|
||||
|
|
@ -58,6 +59,7 @@ public sealed class InventoryController
|
|||
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square)
|
||||
private readonly Action<uint>? _sendUse;
|
||||
private readonly Action<uint>? _sendNoLongerViewing;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||
|
||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||
private const uint EncumbranceValProperty = 5u; // total carried burden
|
||||
|
|
@ -74,7 +76,8 @@ public sealed class InventoryController
|
|||
uint sideBagEmptySprite,
|
||||
uint mainPackEmptySprite,
|
||||
Action<uint>? sendUse,
|
||||
Action<uint>? sendNoLongerViewing)
|
||||
Action<uint>? sendNoLongerViewing,
|
||||
Action<uint, uint, int>? sendPutItemInContainer)
|
||||
{
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
|
|
@ -82,6 +85,7 @@ public sealed class InventoryController
|
|||
_strength = strength;
|
||||
_sendUse = sendUse;
|
||||
_sendNoLongerViewing = sendNoLongerViewing;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
|
||||
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
|
||||
_containerList = layout.FindElement(ContainerListId) as UiItemList;
|
||||
|
|
@ -121,6 +125,11 @@ public sealed class InventoryController
|
|||
if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite;
|
||||
if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite;
|
||||
|
||||
// B-Drag: register this controller as the drag handler on all three lists.
|
||||
_contentsGrid?.RegisterDragHandler(this);
|
||||
_containerList?.RegisterDragHandler(this);
|
||||
_topContainer?.RegisterDragHandler(this);
|
||||
|
||||
// Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4).
|
||||
_burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
|
||||
if (_burdenMeter is not null)
|
||||
|
|
@ -157,10 +166,11 @@ public sealed class InventoryController
|
|||
uint sideBagEmptySprite = 0u,
|
||||
uint mainPackEmptySprite = 0u,
|
||||
Action<uint>? sendUse = null,
|
||||
Action<uint>? sendNoLongerViewing = null)
|
||||
Action<uint>? sendNoLongerViewing = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
|
||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||
sendUse, sendNoLongerViewing);
|
||||
sendUse, sendNoLongerViewing, sendPutItemInContainer);
|
||||
|
||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
|
|
@ -237,6 +247,7 @@ public sealed class InventoryController
|
|||
_topContainer.Flush();
|
||||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||||
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
|
||||
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
|
||||
main.Clicked = () => OpenContainer(p);
|
||||
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
|
||||
_topContainer.AddItem(main);
|
||||
|
|
@ -262,13 +273,21 @@ public sealed class InventoryController
|
|||
: _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
|
||||
cell.SetItem(guid, tex);
|
||||
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
||||
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
|
||||
cell.DragRejectSprite = 0x060011F8u; // red circle
|
||||
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
|
||||
else cell.Clicked = () => SelectItem(guid);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
private static void AddEmptyCell(UiItemList list)
|
||||
=> list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve });
|
||||
{
|
||||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() };
|
||||
cell.DragAcceptSprite = 0x060011F7u;
|
||||
cell.DragRejectSprite = 0x060011F8u;
|
||||
list.AddItem(cell);
|
||||
}
|
||||
|
||||
/// <summary>Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay
|
||||
/// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill =
|
||||
|
|
@ -283,6 +302,63 @@ public sealed class InventoryController
|
|||
cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
|
||||
/// <summary>Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot
|
||||
/// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.</summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
|
||||
|
||||
/// <summary>Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always
|
||||
/// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full.</summary>
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
if (payload.ObjId == 0) return false;
|
||||
if (targetList == _contentsGrid) return true;
|
||||
if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false;
|
||||
return !IsContainerFull(targetCell.ItemId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
|
||||
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
uint item = payload.ObjId;
|
||||
if (item == 0) return;
|
||||
|
||||
uint container; int placement;
|
||||
if (targetList == _contentsGrid)
|
||||
{
|
||||
container = EffectiveOpen();
|
||||
placement = targetCell.ItemId != 0
|
||||
? (_objects.Get(targetCell.ItemId)?.ContainerSlot ?? targetCell.SlotIndex) // insert-before
|
||||
: _objects.GetContents(container).Count; // first empty = append
|
||||
}
|
||||
else if (targetList == _containerList || targetList == _topContainer)
|
||||
{
|
||||
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
|
||||
container = targetCell.ItemId; // the bag / main pack
|
||||
if (IsContainerFull(container)) return; // red already shown
|
||||
placement = _objects.GetContents(container).Count; // append into it
|
||||
}
|
||||
else return;
|
||||
|
||||
if (container == item) return; // never into itself
|
||||
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
}
|
||||
|
||||
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
|
||||
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
|
||||
private bool IsContainerFull(uint container)
|
||||
{
|
||||
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
|
||||
if (cap <= 0) return false;
|
||||
return _objects.GetContents(container).Count >= cap;
|
||||
}
|
||||
|
||||
/// <summary>Select an item (panel-wide green square) without changing the open container or
|
||||
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
|
||||
private void SelectItem(uint guid)
|
||||
|
|
|
|||
|
|
@ -51,12 +51,14 @@ public class InventoryControllerTests
|
|||
}
|
||||
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null)
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => strength, datFont: null,
|
||||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g));
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
|
||||
|
||||
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
|
||||
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
|
||||
|
|
@ -403,6 +405,85 @@ public class InventoryControllerTests
|
|||
Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container
|
||||
}
|
||||
|
||||
private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
SeedContained(objects, 0xB, Player, slot: 1);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
// Drag a fresh item 0xFFFF from elsewhere; drop on the grid cell holding 0xB (slot 1).
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container
|
||||
Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onEmptyGridCell_appendsToFirstEmpty()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onSideBagCell_movesIntoThatBag()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0)
|
||||
Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_fullSideBag_rejects_butGridAccepts()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1...
|
||||
SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red
|
||||
Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_isNoOp_itemStaysUntilServerConfirms()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xAu));
|
||||
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar)
|
||||
}
|
||||
|
||||
// Reads the text of the UiText caption child attached by the controller.
|
||||
private static string CaptionText(UiElement host)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue