Fix moving retainers, add InventoryNotificationNode
This commit is contained in:
@@ -69,6 +69,14 @@ public class AddonInventoryWindow : NativeAddon
|
||||
float x = headerX + (headerW - size.X) * 0.5f;
|
||||
float y = headerY + (headerH - size.Y) * 0.5f;
|
||||
|
||||
InventoryNotificationNode notificationNode = new InventoryNotificationNode
|
||||
{
|
||||
Position = new Vector2(WindowNode!.X - 4f, WindowNode!.Y - 32f),
|
||||
Size = new Vector2(headerW, 28f),
|
||||
};
|
||||
notificationNode.AttachNode(this);
|
||||
//notificationNode.NotificationType = InventoryNotificationType.SaddleBag;
|
||||
|
||||
_searchInputNode = new TextInputWithHintNode
|
||||
{
|
||||
Position = new Vector2(x, y),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using AetherBags.Interop;
|
||||
using AetherBags.Inventory;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using KamiToolKit.Classes;
|
||||
using Lumina.Text.ReadOnly;
|
||||
@@ -6,7 +8,7 @@ using Lumina.Text;
|
||||
|
||||
namespace AetherBags.Extensions;
|
||||
|
||||
// TODO: Remove this when CS is merged into Dalamud.
|
||||
// TODO: Remove FixedInterface when CS is merged into Dalamud.
|
||||
public static unsafe class DragDropPayloadExtensions
|
||||
{
|
||||
public static DragDropPayload FromFixedInterface(AtkDragDropInterface* dragDropInterface)
|
||||
@@ -54,4 +56,59 @@ public static unsafe class DragDropPayloadExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension(DragDropPayload payload)
|
||||
{
|
||||
public bool IsValidInventoryPayload =>
|
||||
payload.Type is DragDropType.Inventory_Item
|
||||
or DragDropType.Inventory_Crystal
|
||||
or DragDropType.RemoteInventory_Item
|
||||
or DragDropType.Item;
|
||||
|
||||
public InventoryLocation InventoryLocation
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!payload.IsValidInventoryPayload) return default;
|
||||
|
||||
if (payload.Type == DragDropType.Inventory_Item)
|
||||
{
|
||||
return new InventoryLocation((InventoryType)payload.Int1, (ushort)payload.Int2);
|
||||
}
|
||||
|
||||
int containerId = payload.Int1;
|
||||
int uiSlot = payload.Int2;
|
||||
|
||||
InventoryType sourceContainer = InventoryType.GetInventoryTypeFromContainerId(containerId);
|
||||
|
||||
if (sourceContainer == 0)
|
||||
return new InventoryLocation(0, 0);
|
||||
|
||||
// Retainers have special handling: UI has 5 tabs × 35 slots, data has 7 pages × 25 slots
|
||||
if (sourceContainer.IsRetainer)
|
||||
{
|
||||
// Container IDs 52-56 = UI tabs 0-4
|
||||
int uiTabIndex = containerId - 52;
|
||||
|
||||
// Convert to global data index
|
||||
int globalDataIndex = (uiTabIndex * 35) + uiSlot;
|
||||
|
||||
// Calculate data page and slot
|
||||
int dataPage = globalDataIndex / 25;
|
||||
int dataSlot = globalDataIndex % 25;
|
||||
|
||||
InventoryType dataContainer = InventoryType.RetainerPage1 + (uint)dataPage;
|
||||
|
||||
// Now resolve through sorter for the actual storage location
|
||||
var (realContainer, realSlot) = dataContainer.GetRealItemLocation(dataSlot);
|
||||
return new InventoryLocation(realContainer, realSlot);
|
||||
}
|
||||
|
||||
// For non-retainers, use the standard resolution
|
||||
var (container, slot) = sourceContainer.GetRealItemLocation(uiSlot);
|
||||
return new InventoryLocation(container, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using AetherBags.Inventory;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
|
||||
@@ -17,6 +18,7 @@ public static unsafe class InventoryTypeExtensions
|
||||
InventoryType.Inventory2 => 49,
|
||||
InventoryType.Inventory3 => 50,
|
||||
InventoryType.Inventory4 => 51,
|
||||
// It's possible that these are actually UI IDs
|
||||
InventoryType.RetainerPage1 => 52,
|
||||
InventoryType.RetainerPage2 => 53,
|
||||
InventoryType.RetainerPage3 => 54,
|
||||
@@ -171,21 +173,21 @@ public static unsafe class InventoryTypeExtensions
|
||||
/// Resolves the real container and slot for this inventory type using ItemOrderModule.
|
||||
/// For sorted inventories, the visual slot differs from the actual storage slot.
|
||||
/// </summary>
|
||||
public (InventoryType Container, ushort Slot) GetRealItemLocation(int visualSlot)
|
||||
public InventoryLocation GetRealItemLocation(int visualSlot)
|
||||
{
|
||||
var sorter = inventoryType.GetInventorySorter;
|
||||
if (sorter == null)
|
||||
return (inventoryType, (ushort)visualSlot);
|
||||
return new InventoryLocation(inventoryType, (ushort)visualSlot);
|
||||
|
||||
int startIndex = inventoryType.GetInventoryStartIndex;
|
||||
int sorterIndex = startIndex + visualSlot;
|
||||
|
||||
if (sorterIndex < 0 || sorterIndex >= sorter->Items.LongCount)
|
||||
return (inventoryType, (ushort)visualSlot);
|
||||
return new InventoryLocation(inventoryType, (ushort)visualSlot);
|
||||
|
||||
var entry = sorter->Items[sorterIndex].Value;
|
||||
if (entry == null)
|
||||
return (inventoryType, (ushort)visualSlot);
|
||||
return new InventoryLocation(inventoryType, (ushort)visualSlot);
|
||||
|
||||
InventoryType baseType = inventoryType switch
|
||||
{
|
||||
@@ -200,45 +202,7 @@ public static unsafe class InventoryTypeExtensions
|
||||
InventoryType realContainer = baseType + entry->Page;
|
||||
ushort realSlot = entry->Slot;
|
||||
|
||||
return (realContainer, realSlot);
|
||||
}
|
||||
|
||||
public int GetVisualSlotFromReal(int realSlot)
|
||||
{
|
||||
var sorter = inventoryType.GetInventorySorter;
|
||||
if (sorter == null)
|
||||
return realSlot;
|
||||
|
||||
int startIndex = inventoryType.GetInventoryStartIndex;
|
||||
long itemCount = sorter->Items.LongCount;
|
||||
|
||||
// Search through the sorter to find which visual index maps to this real slot
|
||||
for (int visualIdx = 0; visualIdx < itemCount; visualIdx++)
|
||||
{
|
||||
var entry = sorter->Items[visualIdx]. Value;
|
||||
if (entry == null) continue;
|
||||
|
||||
// Calculate what container this entry belongs to
|
||||
InventoryType baseType = inventoryType switch
|
||||
{
|
||||
_ when inventoryType.IsMainInventory => InventoryType. Inventory1,
|
||||
_ when inventoryType.IsSaddleBag => inventoryType is InventoryType.SaddleBag1 or InventoryType.SaddleBag2
|
||||
? InventoryType. SaddleBag1
|
||||
: InventoryType.PremiumSaddleBag1,
|
||||
_ when inventoryType.IsRetainer => InventoryType.RetainerPage1,
|
||||
_ => inventoryType,
|
||||
};
|
||||
|
||||
InventoryType entryContainer = baseType + entry->Page;
|
||||
|
||||
if (entryContainer == inventoryType && entry->Slot == realSlot)
|
||||
{
|
||||
// Found it! Return visual index relative to the container's start
|
||||
return visualIdx - startIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return realSlot; // Fallback
|
||||
return new InventoryLocation(realContainer, realSlot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,24 +8,16 @@ namespace AetherBags. Helpers;
|
||||
|
||||
public static unsafe class InventoryMoveHelper
|
||||
{
|
||||
// Requires the visual UI slots instead of actual slots.
|
||||
public static void MoveItem(InventoryType sourceContainer, ushort sourceSlot, InventoryType destContainer, ushort destSlot)
|
||||
{
|
||||
Services.Logger.Debug($"[MoveItem] {sourceContainer}@{sourceSlot} -> {destContainer}@{destSlot}");
|
||||
InventoryManager.Instance()->MoveItemSlot(sourceContainer, sourceSlot, destContainer, destSlot, true);
|
||||
System.AddonInventoryWindow.ManualInventoryRefresh();
|
||||
return;
|
||||
bool isCrossContainerMove = ! sourceContainer.IsSameContainerGroup(destContainer);
|
||||
|
||||
if (isCrossContainerMove)
|
||||
{
|
||||
MoveItemViaAgent(sourceContainer, sourceSlot, destContainer, destSlot);
|
||||
}
|
||||
else
|
||||
{
|
||||
InventoryManager.Instance()->MoveItemSlot(sourceContainer, sourceSlot, destContainer, destSlot, true);
|
||||
}
|
||||
Services.Framework.DelayTicks(2);
|
||||
Services.Framework.RunOnFrameworkThread(System.AddonInventoryWindow.ManualInventoryRefresh);
|
||||
}
|
||||
|
||||
/*
|
||||
private static void MoveItemViaAgent(InventoryType sourceInventory, ushort sourceSlot, InventoryType destInventory, ushort destSlot)
|
||||
{
|
||||
uint sourceContainerId = sourceInventory.AgentItemContainerId;
|
||||
@@ -53,4 +45,5 @@ public static unsafe class InventoryMoveHelper
|
||||
RaptureAtkModule* atkModule = RaptureAtkModule.Instance();
|
||||
atkModule->HandleItemMove(retVal, atkValues, 4);
|
||||
}
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
|
||||
namespace AetherBags.Inventory;
|
||||
|
||||
public readonly record struct InventoryLocation(InventoryType Container, ushort Slot)
|
||||
{
|
||||
public static readonly InventoryLocation Invalid = new(0, 0);
|
||||
|
||||
public bool IsValid => Container != 0;
|
||||
|
||||
public override string ToString() => $"{Container}@{Slot}";
|
||||
}
|
||||
@@ -243,65 +243,24 @@ public class InventoryCategoryNode : SimpleComponentNode
|
||||
};
|
||||
}
|
||||
|
||||
private void OnPayloadAccepted(DragDropNode node, DragDropPayload payload, ItemInfo targetItemInfo)
|
||||
private void OnPayloadAccepted(DragDropNode _, DragDropPayload payload, ItemInfo targetItemInfo)
|
||||
{
|
||||
if (payload.Type != DragDropType.Item && payload.Type != DragDropType.Inventory_Item)
|
||||
Services.Logger.Debug($"[OnPayload] Received payload of type {payload.Type}, Int1={payload.Int1}, Int2={payload.Int2}, RefIndex={payload.ReferenceIndex}, Text={payload.Text}");
|
||||
if (!payload.IsValidInventoryPayload)
|
||||
return;
|
||||
|
||||
Services.Logger.Debug($"[OnPayload] Received payload of type {payload.Type}, Int1={payload.Int1}, Int2={payload.Int2}, RefIndex={payload.ReferenceIndex}, Text={payload.Text}");
|
||||
var (sourceContainer, sourceSlot) = ResolveSourceFromPayload(payload);
|
||||
InventoryLocation sourceLocation = payload.InventoryLocation;
|
||||
|
||||
if (sourceContainer == 0)
|
||||
if (!sourceLocation.IsValid)
|
||||
{
|
||||
Services.Logger.Warning($"[OnPayload] Could not resolve source from payload");
|
||||
return;
|
||||
}
|
||||
|
||||
InventoryType targetContainer = targetItemInfo.Item.Container;
|
||||
ushort targetSlot = (ushort)targetItemInfo.Item.Slot;
|
||||
InventoryLocation targetLocation = new InventoryLocation(targetItemInfo.Item.Container, (ushort)targetItemInfo.Item.Slot);
|
||||
|
||||
Services.Logger.Debug($"[OnPayload] Moving {sourceContainer}@{sourceSlot} -> {targetContainer}@{targetSlot}");
|
||||
Services.Logger.Debug($"[OnPayload] Moving {sourceLocation.ToString()} -> {targetLocation.ToString()}");
|
||||
|
||||
InventoryMoveHelper.MoveItem(sourceContainer, sourceSlot, targetContainer, targetSlot);
|
||||
}
|
||||
|
||||
private static (InventoryType Container, ushort Slot) ResolveSourceFromPayload(DragDropPayload payload)
|
||||
{
|
||||
if (payload.Type == DragDropType.Inventory_Item)
|
||||
{
|
||||
return ((InventoryType)payload.Int1, (ushort)payload.Int2);
|
||||
}
|
||||
|
||||
int containerId = payload.Int1;
|
||||
int uiSlot = payload.Int2;
|
||||
|
||||
InventoryType sourceContainer = InventoryType.GetInventoryTypeFromContainerId(containerId);
|
||||
|
||||
if (sourceContainer == 0)
|
||||
return (0, 0);
|
||||
|
||||
// Retainers have special handling: UI has 5 tabs × 35 slots, data has 7 pages × 25 slots
|
||||
if (sourceContainer. IsRetainer)
|
||||
{
|
||||
// Container IDs 52-56 = UI tabs 0-4
|
||||
int uiTabIndex = containerId - 52;
|
||||
|
||||
// Convert to global data index
|
||||
int globalDataIndex = (uiTabIndex * 35) + uiSlot;
|
||||
|
||||
// Calculate data page and slot
|
||||
int dataPage = globalDataIndex / 25;
|
||||
int dataSlot = globalDataIndex % 25;
|
||||
|
||||
InventoryType dataContainer = InventoryType.RetainerPage1 + (uint)dataPage;
|
||||
|
||||
// Now resolve through sorter for the actual storage location
|
||||
var (realContainer, realSlot) = dataContainer.GetRealItemLocation(dataSlot);
|
||||
return (realContainer, realSlot);
|
||||
}
|
||||
|
||||
// For non-retainers, use the standard resolution
|
||||
var (realContainerOther, realSlotOther) = sourceContainer.GetRealItemLocation(uiSlot);
|
||||
return (realContainerOther, realSlotOther);
|
||||
InventoryMoveHelper.MoveItem(sourceLocation.Container, sourceLocation.Slot, targetLocation.Container, targetLocation.Slot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using KamiToolKit;
|
||||
using KamiToolKit.Classes;
|
||||
using KamiToolKit.Classes.Timelines;
|
||||
using KamiToolKit.Nodes;
|
||||
using Lumina.Excel;
|
||||
using Lumina.Excel.Sheets;
|
||||
using Lumina.Text;
|
||||
using Lumina.Text.ReadOnly;
|
||||
|
||||
namespace AetherBags.Nodes.Inventory;
|
||||
|
||||
public sealed class InventoryNotificationNode : SimpleComponentNode
|
||||
{
|
||||
private readonly SimpleNineGridNode glowNode;
|
||||
private readonly TextNode titleTextNode;
|
||||
private readonly TextNode messageTextNode;
|
||||
|
||||
private Dictionary<InventoryNotificationType, InventoryNotificationInfo> notificationCache = null!;
|
||||
|
||||
public InventoryNotificationNode()
|
||||
{
|
||||
PopulateNotificationCache();
|
||||
|
||||
AddTimeline(ParentLabels);
|
||||
|
||||
glowNode = new SimpleNineGridNode {
|
||||
TexturePath = "ui/uld/Inventory.tex",
|
||||
TextureSize = new Vector2(56.0f, 56.0f),
|
||||
TextureCoordinates = new Vector2(88.0f, 0.0f),
|
||||
TopOffset = 10,
|
||||
BottomOffset = 10,
|
||||
LeftOffset = 26,
|
||||
RightOffset = 26,
|
||||
};
|
||||
glowNode.AttachNode(this);
|
||||
glowNode.AddTimeline(GlowKeyFrames);
|
||||
|
||||
titleTextNode = new TextNode
|
||||
{
|
||||
Position = new Vector2(0, 10f),
|
||||
FontType = FontType.MiedingerMed,
|
||||
FontSize = 18,
|
||||
TextColor = ColorHelper.GetColor(50),
|
||||
TextOutlineColor = ColorHelper.GetColor(37),
|
||||
TextFlags = TextFlags.Edge,
|
||||
AlignmentType = AlignmentType.Center,
|
||||
};
|
||||
titleTextNode.AttachNode(this);
|
||||
titleTextNode.AddTimeline(TextKeyFrames);
|
||||
|
||||
messageTextNode = new TextNode
|
||||
{
|
||||
Position = new Vector2(0, -10f),
|
||||
FontType = FontType.Axis,
|
||||
FontSize = 14,
|
||||
TextColor = ColorHelper.GetColor(50),
|
||||
TextOutlineColor = ColorHelper.GetColor(37),
|
||||
TextFlags = TextFlags.Edge,
|
||||
AlignmentType = AlignmentType.Center,
|
||||
};
|
||||
messageTextNode.AttachNode(this);
|
||||
messageTextNode.AddTimeline(TextKeyFrames);
|
||||
|
||||
Timeline?.PlayAnimation(17);
|
||||
}
|
||||
|
||||
protected override void OnSizeChanged()
|
||||
{
|
||||
base.OnSizeChanged();
|
||||
|
||||
glowNode.Size = Size with { Y = 40 };
|
||||
titleTextNode.Size = Size with { Y = 20 };
|
||||
messageTextNode.Size = Size with { Y = 16 };
|
||||
}
|
||||
|
||||
private void PopulateNotificationCache()
|
||||
{
|
||||
ExcelSheet<Addon> addonSheet = Services.DataManager.GetExcelSheet<Addon>();
|
||||
notificationCache = new Dictionary<InventoryNotificationType, InventoryNotificationInfo>
|
||||
{
|
||||
{
|
||||
InventoryNotificationType.SaddleBag,
|
||||
new InventoryNotificationInfo(addonSheet.GetRow(891).Text, addonSheet.GetRow(892).Text)
|
||||
},
|
||||
{
|
||||
InventoryNotificationType.RetainerEntrust,
|
||||
new InventoryNotificationInfo(addonSheet.GetRow(910).Text, addonSheet.GetRow(3573).Text)
|
||||
},
|
||||
{
|
||||
InventoryNotificationType.RetainerEquip,
|
||||
new InventoryNotificationInfo(addonSheet.GetRow(910).Text, addonSheet.GetRow(3585).Text)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public InventoryNotificationType NotificationType
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
if (value == InventoryNotificationType.None)
|
||||
{
|
||||
titleTextNode.String = string.Empty;
|
||||
messageTextNode.String = string.Empty;
|
||||
Timeline?.PlayAnimation(17); // Hide
|
||||
}
|
||||
else if (notificationCache.TryGetValue(value, out var texts))
|
||||
{
|
||||
titleTextNode.SeString = texts.Title;
|
||||
messageTextNode.SeString = texts.Message;
|
||||
Timeline?.PlayAnimation(101); // Show
|
||||
}
|
||||
}
|
||||
} = InventoryNotificationType.None;
|
||||
|
||||
// Future Zeff, this always goes on a parent
|
||||
private Timeline ParentLabels => new TimelineBuilder()
|
||||
.BeginFrameSet(1, 59)
|
||||
.AddLabel(1, 17, AtkTimelineJumpBehavior.PlayOnce, 0)
|
||||
.AddLabel(10, 101, AtkTimelineJumpBehavior.Start, 0)
|
||||
.AddLabel(25, 102, AtkTimelineJumpBehavior.Start, 0)
|
||||
.AddLabel(59, 0, AtkTimelineJumpBehavior.LoopForever, 102)
|
||||
.EndFrameSet()
|
||||
.Build();
|
||||
|
||||
// Future Zeff, this always goes on a child
|
||||
private Timeline GlowKeyFrames => new TimelineBuilder().BeginFrameSet(15, 59)
|
||||
.AddFrame(10, scale: new Vector2(1.4f, 1.0f), alpha: 0, addColor: new Vector3(128, 128, 128))
|
||||
.AddFrame(15, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(128, 128, 128))
|
||||
.AddFrame(21, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0))
|
||||
.AddFrame(40, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0))
|
||||
.AddFrame(46, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(10, 10, 10))
|
||||
.AddFrame(59, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0))
|
||||
.EndFrameSet()
|
||||
.Build();
|
||||
|
||||
// Future Zeff, this always goes on a child
|
||||
private Timeline TextKeyFrames => new TimelineBuilder().BeginFrameSet(15, 59)
|
||||
.AddFrame(15, alpha: 0, addColor: new Vector3(128, 128, 128))
|
||||
.AddFrame(18, alpha: 255, addColor: new Vector3(64, 64, 64))
|
||||
.AddFrame(25, alpha: 255, addColor: new Vector3(0, 0, 0))
|
||||
.AddFrame(40, alpha: 255, addColor: new Vector3(0, 0, 0))
|
||||
.AddFrame(46, alpha: 255, addColor: new Vector3(64, 64, 64))
|
||||
.AddFrame(59, alpha: 255, addColor: new Vector3(0, 0, 0))
|
||||
.EndFrameSet()
|
||||
.Build();
|
||||
|
||||
|
||||
private record InventoryNotificationInfo(ReadOnlySeString Title, ReadOnlySeString Message);
|
||||
}
|
||||
|
||||
public enum InventoryNotificationType
|
||||
{
|
||||
None,
|
||||
SaddleBag,
|
||||
RetainerEntrust,
|
||||
RetainerEquip
|
||||
}
|
||||
Reference in New Issue
Block a user