From 659c295c16c35712805458767ed2f605ab6813b4 Mon Sep 17 00:00:00 2001 From: Zeffuro Date: Sat, 20 Dec 2025 04:04:31 +0100 Subject: [PATCH] Initial project --- .gitignore | 1 + .gitmodules | 3 + AetherBags.sln | 6 + AetherBags.sln.DotSettings.user | 2 + AetherBags/.gitignore | 1 + AetherBags/Addons/AddonInventoryWindow.cs | 86 ++++++++++++ AetherBags/AetherBags.csproj | 21 ++- AetherBags/AetherBags.json | 6 - AetherBags/Extensions/AtkResNodeExtensions.cs | 16 +++ AetherBags/Extensions/AtkStageExtensions.cs | 34 +++++ .../Extensions/InventoryItemExtensions.cs | 113 ++++++++++++++++ AetherBags/Extensions/ItemExtensions.cs | 18 +++ AetherBags/Extensions/NodeBaseExtensions.cs | 12 ++ AetherBags/GlobalUsing.cs | 1 + AetherBags/Helpers/BackupHelper.cs | 124 ++++++++++++++++++ AetherBags/Inventory/CategoryInfo.cs | 11 ++ AetherBags/Inventory/InventoryState.cs | 63 +++++++++ AetherBags/Inventory/ItemInfo.cs | 60 +++++++++ AetherBags/Nodes/HybridDirectionalFlexNode.cs | 107 +++++++++++++++ AetherBags/Nodes/InventoryCategoryNode.cs | 90 +++++++++++++ AetherBags/Nodes/InventoryDragDropNode.cs | 54 ++++++++ AetherBags/Plugin.cs | 62 ++++++++- AetherBags/Services.cs | 7 +- AetherBags/System.cs | 8 ++ AetherBags/packages.lock.json | 30 +++++ KamiToolKit | 1 + 26 files changed, 923 insertions(+), 14 deletions(-) create mode 100644 .gitmodules create mode 100644 AetherBags.sln.DotSettings.user create mode 100644 AetherBags/.gitignore create mode 100644 AetherBags/Addons/AddonInventoryWindow.cs delete mode 100644 AetherBags/AetherBags.json create mode 100644 AetherBags/Extensions/AtkResNodeExtensions.cs create mode 100644 AetherBags/Extensions/AtkStageExtensions.cs create mode 100644 AetherBags/Extensions/InventoryItemExtensions.cs create mode 100644 AetherBags/Extensions/ItemExtensions.cs create mode 100644 AetherBags/Extensions/NodeBaseExtensions.cs create mode 100644 AetherBags/GlobalUsing.cs create mode 100644 AetherBags/Helpers/BackupHelper.cs create mode 100644 AetherBags/Inventory/CategoryInfo.cs create mode 100644 AetherBags/Inventory/InventoryState.cs create mode 100644 AetherBags/Inventory/ItemInfo.cs create mode 100644 AetherBags/Nodes/HybridDirectionalFlexNode.cs create mode 100644 AetherBags/Nodes/InventoryCategoryNode.cs create mode 100644 AetherBags/Nodes/InventoryDragDropNode.cs create mode 100644 AetherBags/System.cs create mode 100644 AetherBags/packages.lock.json create mode 160000 KamiToolKit diff --git a/.gitignore b/.gitignore index add57be..b285a22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ bin/ obj/ +.idea/ /packages/ riderModule.iml /_ReSharper.Caches/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..69a9f30 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "KamiToolKit"] + path = KamiToolKit + url = https://github.com/MidoriKami/KamiToolKit diff --git a/AetherBags.sln b/AetherBags.sln index 2ec8073..3e7404a 100644 --- a/AetherBags.sln +++ b/AetherBags.sln @@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AetherBags", "AetherBags\AetherBags.csproj", "{5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KamiToolKit", "KamiToolKit\KamiToolKit.csproj", "{0907374F-93F8-427F-AD0A-49DB4B0A3DD4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -12,5 +14,9 @@ Global {5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}.Debug|x64.Build.0 = Debug|x64 {5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}.Release|x64.ActiveCfg = Release|x64 {5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}.Release|x64.Build.0 = Release|x64 + {0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Debug|x64.ActiveCfg = Debug|x64 + {0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Debug|x64.Build.0 = Debug|x64 + {0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Release|x64.ActiveCfg = Release|x64 + {0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Release|x64.Build.0 = Release|x64 EndGlobalSection EndGlobal diff --git a/AetherBags.sln.DotSettings.user b/AetherBags.sln.DotSettings.user new file mode 100644 index 0000000..a250ab0 --- /dev/null +++ b/AetherBags.sln.DotSettings.user @@ -0,0 +1,2 @@ + + ForceIncluded \ No newline at end of file diff --git a/AetherBags/.gitignore b/AetherBags/.gitignore new file mode 100644 index 0000000..57f1cb2 --- /dev/null +++ b/AetherBags/.gitignore @@ -0,0 +1 @@ +/.idea/ \ No newline at end of file diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs new file mode 100644 index 0000000..4787062 --- /dev/null +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -0,0 +1,86 @@ +using System.Linq; +using System.Numerics; +using AetherBags.Extensions; +using AetherBags.Inventory; +using AetherBags.Nodes; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit; +using KamiToolKit.Classes; + +namespace AetherBags.Addons; + +public class AddonInventoryWindow : NativeAddon +{ + private InventoryCategoryNode _categoryNode; + private InventoryDragDropNode _dragDropNode; + protected override unsafe void OnSetup(AtkUnitBase* addon) + { + Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate); + _categoryNode = new InventoryCategoryNode + { + Position = ContentStartPosition, + Size = ContentSize, + Category = new CategoryInfo + { + Name = "AetherBags", + }, + Items = InventoryState.GetInventoryItems() + }; + _categoryNode.AttachNode(this); + /* + var data = InventoryState.GetInventoryItems().Find(item => item.Name.Contains("Cookie")); + + + if (data != null) + { + var item = data.Item; + _dragDropNode = new InventoryDragDropNode + { + Size = new Vector2(48), + IsVisible = true, + IconId = data.IconId, + AcceptedType = DragDropType.Nothing, + IsDraggable = false, + Payload = new DragDropPayload + { + Type = DragDropType.Item, + Int1 = (int)data.Item.Container, + Int2 = (int)data.Item.ItemId, + }, + IsClickable = true, + OnRollOver = node => node.ShowInventoryItemTooltip(data.Item.Container, data.Item.Slot), + OnRollOut = node => node.HideTooltip(), + OnClicked = _ => + { + + AgentInventoryContext* context = AgentInventoryContext.Instance(); + context->OpenForItemSlot(data.Item.Container, data.Item.Slot, 0, context->AddonId); + //item.UseItem(); + }, + ItemInfo = data + }; + _dragDropNode.AttachNode(this); + } + */ + + } + + protected override unsafe void OnUpdate(AtkUnitBase* addon) + { + + } + + private void OnInventoryUpdate(AddonEvent type, AddonArgs args) + { + + } + + protected override unsafe void OnFinalize(AtkUnitBase* addon) + { + Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate); + } +} \ No newline at end of file diff --git a/AetherBags/AetherBags.csproj b/AetherBags/AetherBags.csproj index 81bf000..34817a8 100644 --- a/AetherBags/AetherBags.csproj +++ b/AetherBags/AetherBags.csproj @@ -1,5 +1,24 @@ - + 1.0.0.0 + + + Zeffuro + AetherBags + AetherBags + Never think too hard about your bags again! + This plugin replaces your inventory with it's own categorified inventory addon. + https://github.com/Zeffuro/AetherBags + ui + true + + + + + + + + + diff --git a/AetherBags/AetherBags.json b/AetherBags/AetherBags.json deleted file mode 100644 index 0e9adda..0000000 --- a/AetherBags/AetherBags.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "Name": "AetherBags", - "Author": "", - "Description": "", - "Punchline": "" -} diff --git a/AetherBags/Extensions/AtkResNodeExtensions.cs b/AetherBags/Extensions/AtkResNodeExtensions.cs new file mode 100644 index 0000000..f5da790 --- /dev/null +++ b/AetherBags/Extensions/AtkResNodeExtensions.cs @@ -0,0 +1,16 @@ +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace AetherBags.Extensions; + +public static unsafe class AtkResNodeExtensions +{ + extension(ref AtkResNode node) + { + public void ShowInventoryItemTooltip(InventoryType container, short slot) { + fixed (AtkResNode* nodePointer = &node) { + AtkStage.Instance()->ShowInventoryItemTooltip(nodePointer, container, slot); + } + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/AtkStageExtensions.cs b/AetherBags/Extensions/AtkStageExtensions.cs new file mode 100644 index 0000000..63e7aea --- /dev/null +++ b/AetherBags/Extensions/AtkStageExtensions.cs @@ -0,0 +1,34 @@ +using FFXIVClientStructs.FFXIV.Client.Enums; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace AetherBags.Extensions; + + +public static unsafe class AtkStageExtensions +{ + extension(ref AtkStage stage) + { + public void ShowInventoryItemTooltip(AtkResNode* node, InventoryType container, short slot) + { + var tooltipArgs = stackalloc AtkTooltipManager.AtkTooltipArgs[1]; + tooltipArgs->Ctor(); + tooltipArgs->ItemArgs.Kind = DetailKind.InventoryItem; + tooltipArgs->ItemArgs.InventoryType = container; + tooltipArgs->ItemArgs.Slot = slot; + tooltipArgs->ItemArgs.BuyQuantity = -1; + tooltipArgs->ItemArgs.Flag1 = 0; + + var addon = RaptureAtkUnitManager.Instance()->GetAddonByNode(node); + if (addon is null) return; + + stage.TooltipManager.ShowTooltip( + AtkTooltipManager.AtkTooltipType.Item, + addon->Id, + node, + tooltipArgs + ); + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/InventoryItemExtensions.cs b/AetherBags/Extensions/InventoryItemExtensions.cs new file mode 100644 index 0000000..634fc69 --- /dev/null +++ b/AetherBags/Extensions/InventoryItemExtensions.cs @@ -0,0 +1,113 @@ +using System.Text.RegularExpressions; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using Lumina.Excel.Sheets; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Extensions; + +public static unsafe class InventoryItemExtensions { + extension(ref InventoryItem item) { + public uint IconId => item.GetIconId(); + public ReadOnlySeString Name => item.GetItemName(); + + private uint GetIconId() { + uint iconId = 0; + + if (item.GetEventItem() is { } eventItem) { + iconId = eventItem.Icon; + } + else if (item.GetItem() is { } regularItem) { + iconId = regularItem.Icon; + + if (item.IsHighQuality()) { + iconId += 1_000_000; + } + } + + return iconId; + } + + private ReadOnlySeString GetItemName() { + var itemId = item.GetItemId(); + var itemName = ItemUtil.GetItemName(itemId); + + return new Lumina.Text.SeStringBuilder() + .PushColorType(ItemUtil.GetItemRarityColorType(itemId)) + .Append(itemName) + .PopColorType() + .ToReadOnlySeString(); + } + + private Item? GetItem() { + var baseItemId = item.GetBaseItemId(); + + if (ItemUtil.IsNormalItem(baseItemId) && + Services.DataManager.GetExcelSheet().TryGetRow(baseItemId, out var baseItem)) { + return baseItem; + } + + return null; + } + + private EventItem? GetEventItem() { + var baseItemId = item.GetBaseItemId(); + + if (ItemUtil.IsEventItem(baseItemId) && + Services.DataManager.GetExcelSheet().TryGetRow(baseItemId, out var eventItem)) { + return eventItem; + } + + return null; + } + + public bool IsRegexMatch(string searchString) { + // Skip any data access if string is empty + if (searchString.IsNullOrEmpty()) return true; + + var isDescriptionSearch = searchString.StartsWith('$'); + + if (isDescriptionSearch) { + searchString = searchString[1..]; + } + + try { + var regex = new Regex(searchString,RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (ItemUtil.IsEventItem(item.GetBaseItemId())) { + if (!Services.DataManager.GetExcelSheet().TryGetRow(item.GetBaseItemId(), out var itemData)) return false; + + if (regex.IsMatch(item.ItemId.ToString())) return true; + if (regex.IsMatch(itemData.Name.ToString())) return true; + } + + else if (ItemUtil.IsNormalItem(item.GetBaseItemId())) { + if (!Services.DataManager.GetExcelSheet().TryGetRow(item.GetBaseItemId(), out var itemData)) return false; + + if (regex.IsMatch(item.ItemId.ToString())) return true; + if (regex.IsMatch(itemData.Name.ToString())) return true; + if (regex.IsMatch(itemData.Description.ToString()) && isDescriptionSearch) return true; + if (regex.IsMatch(itemData.LevelEquip.ToString())) return true; + if (regex.IsMatch(itemData.LevelItem.RowId.ToString())) return true; + } + } + catch (RegexParseException) { } + + return false; + } + + public void UseItem() + { + uint itemId = item.ItemId; + InventoryType type = item.GetInventoryType() == InventoryType.KeyItems + ? InventoryType.KeyItems + : InventoryType.Invalid; + + if (InventoryManager.Instance()->GetInventoryItemCount(itemId, true) > 0) + itemId += 1_000_000; + + AgentInventoryContext.Instance()->UseItem(itemId, type); + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/ItemExtensions.cs b/AetherBags/Extensions/ItemExtensions.cs new file mode 100644 index 0000000..1de9a4e --- /dev/null +++ b/AetherBags/Extensions/ItemExtensions.cs @@ -0,0 +1,18 @@ +using System.Numerics; +using KamiToolKit.Classes; +using Lumina.Excel.Sheets; + +namespace AetherBags.Extensions; + +public static class ItemExtensions { + extension(Item item) { + public Vector4 RarityColor => item.Rarity switch { + 7 => ColorHelper.GetColor(561), + 4 => ColorHelper.GetColor(555), + 3 => ColorHelper.GetColor(553), + 2 => ColorHelper.GetColor(551), + 1 => ColorHelper.GetColor(549), + _ => Vector4.One, + }; + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/NodeBaseExtensions.cs b/AetherBags/Extensions/NodeBaseExtensions.cs new file mode 100644 index 0000000..f43e2e1 --- /dev/null +++ b/AetherBags/Extensions/NodeBaseExtensions.cs @@ -0,0 +1,12 @@ +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit; + +namespace AetherBags.Extensions; + +public static unsafe class NodeBaseExtensions { + extension(NodeBase node) { + public void ShowInventoryItemTooltip(InventoryType container, short slot) + => AtkStage.Instance()->ShowInventoryItemTooltip(node, container, slot); + } +} \ No newline at end of file diff --git a/AetherBags/GlobalUsing.cs b/AetherBags/GlobalUsing.cs new file mode 100644 index 0000000..8cbabd6 --- /dev/null +++ b/AetherBags/GlobalUsing.cs @@ -0,0 +1 @@ +global using KamiToolKit.Extensions; \ No newline at end of file diff --git a/AetherBags/Helpers/BackupHelper.cs b/AetherBags/Helpers/BackupHelper.cs new file mode 100644 index 0000000..273e84a --- /dev/null +++ b/AetherBags/Helpers/BackupHelper.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Security.Cryptography; +using Dalamud.Plugin; + +namespace AetherBags.Helpers; + +// Taken and adapted for StatusTimers using zips from https://github.com/Caraxi/SimpleHeels/blob/0a0fe3c02a0a2c5a7c96b3304952d5078cd338aa/Plugin.cs#L392 +// Thanks Caraxi +public static class BackupHelper { + private const int MaxBackups = 10; + private const string Name = "StatusTimers"; + public static void DoConfigBackup(IDalamudPluginInterface pluginInterface) { + Services.Logger.Debug("Backup configuration start."); + try { + var configDirectory = pluginInterface.ConfigDirectory; + if (!configDirectory.Exists) { + return; + } + + var backupDir = Path.Join(configDirectory.Parent!.Parent!.FullName, "backups", Name); + var dir = new DirectoryInfo(backupDir); + if (!dir.Exists) { + dir.Create(); + } + + if (!dir.Exists) { + throw new Exception("Backup Directory does not exist"); + } + + var latestFile = new FileInfo(Path.Join(backupDir, $"{Name}.latest.zip")); + var tempFile = Path.Join(backupDir, $"{Name}.tmp.zip"); + + var needsBackup = false; + + if (latestFile.Exists) { + string lastBackupHash = ZipJsonHash(latestFile.FullName); + string currentConfigDirHash = DirJsonHash(configDirectory.FullName); + if (currentConfigDirHash != lastBackupHash) { + needsBackup = true; + } + } else { + needsBackup = true; + } + + if (!needsBackup) { + return; + } + + ZipFile.CreateFromDirectory(configDirectory.FullName, tempFile); + if (latestFile.Exists) { + var t = latestFile.LastWriteTime; + string archiveName = $"{Name}.{t.Year}{t.Month:00}{t.Day:00}{t.Hour:00}{t.Minute:00}{t.Second:00}.zip"; + string archivePath = Path.Join(backupDir, archiveName); + + bool moved = false; + for (int i = 0; i < 5 && !moved; i++) { + try { + File.Move(latestFile.FullName, archivePath); + moved = true; + } catch (IOException ioEx) when (i < 4) { + Services.Logger.Debug($"Move failed, retrying in 100ms: {ioEx.Message}"); + global::System.Threading.Thread.Sleep(100); + } + } + if (!moved) { + throw new IOException($"Could not move {latestFile.FullName} after several retries."); + } + } + + if (File.Exists(latestFile.FullName)) { + File.Delete(latestFile.FullName); + } + File.Move(tempFile, latestFile.FullName); + + var allBackups = dir.GetFiles().Where(f => f.Name.StartsWith($"{Name}.2") && f.Name.EndsWith(".zip")) + .OrderBy(f => f.LastWriteTime.Ticks).ToList(); + if (allBackups.Count > MaxBackups) { + Services.Logger.Debug($"Removing Oldest Backup: {allBackups[0].FullName}"); + File.Delete(allBackups[0].FullName); + } + } catch (Exception exception) { + Services.Logger.Warning(exception, "Backup Skipped"); + } + } + + private static string ComputeCombinedJsonHash(IEnumerable<(string name, byte[] contents)> files) { + using var sha256 = SHA256.Create(); + foreach (var file in files.OrderBy(f => f.name, StringComparer.OrdinalIgnoreCase)) { + sha256.TransformBlock(file.contents, 0, file.contents.Length, null, 0); + } + sha256.TransformFinalBlock(Array.Empty(), 0, 0); + return sha256.Hash != null ? BitConverter.ToString(sha256.Hash).Replace("-", "") : string.Empty; + } + + private static string DirJsonHash(string dirPath) => + ComputeCombinedJsonHash( + new DirectoryInfo(dirPath) + .GetFiles("*.json", SearchOption.TopDirectoryOnly) + .Where(f => !f.Name.EndsWith(".addon.json", StringComparison.OrdinalIgnoreCase)) + .Select(f => (f.Name, File.ReadAllBytes(f.FullName))) + ); + + private static string ZipJsonHash(string zipPath) { + byte[] zipBytes = File.ReadAllBytes(zipPath); + using var msZip = new MemoryStream(zipBytes); + using var zip = new ZipArchive(msZip, ZipArchiveMode.Read); + var files = zip.Entries + .Where(e => e.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + && !e.FullName.EndsWith(".addon.json", StringComparison.OrdinalIgnoreCase) + && !e.FullName.Contains("/")) + .Select(e => { + using var ms = new MemoryStream(); + using (var s = e.Open()) { + s.CopyTo(ms); + } + return (e.FullName, ms.ToArray()); + }); + return ComputeCombinedJsonHash(files); + } +} diff --git a/AetherBags/Inventory/CategoryInfo.cs b/AetherBags/Inventory/CategoryInfo.cs new file mode 100644 index 0000000..542e5ea --- /dev/null +++ b/AetherBags/Inventory/CategoryInfo.cs @@ -0,0 +1,11 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace AetherBags.Inventory; + +public class CategoryInfo +{ + public required string Name { get; set; } + public Vector4 Color { get; set; } = ColorHelper.GetColor(50); + public string Description { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/AetherBags/Inventory/InventoryState.cs b/AetherBags/Inventory/InventoryState.cs new file mode 100644 index 0000000..f82b6c8 --- /dev/null +++ b/AetherBags/Inventory/InventoryState.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using Dalamud.Game.Inventory; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +namespace AetherBags.Inventory; + +public static unsafe class InventoryState +{ + public static List StandardInventories => [ + InventoryType.Inventory1, + InventoryType.Inventory2, + InventoryType.Inventory3, + InventoryType.Inventory4, + InventoryType.EquippedItems, + InventoryType.ArmoryMainHand, + InventoryType.ArmoryHead, + InventoryType.ArmoryBody, + InventoryType.ArmoryHands, + InventoryType.ArmoryWaist, + InventoryType.ArmoryLegs, + InventoryType.ArmoryFeets, + InventoryType.ArmoryOffHand, + InventoryType.ArmoryEar, + InventoryType.ArmoryNeck, + InventoryType.ArmoryWrist, + InventoryType.ArmoryRings, + InventoryType.Currency, + InventoryType.Crystals, + InventoryType.ArmorySoulCrystal, + ]; + + public static bool Contains(this List inventoryTypes, GameInventoryType type) + => inventoryTypes.Contains((InventoryType)type); + + public static List GetInventoryItems() { + List inventories = [ InventoryType.Inventory1, InventoryType.Inventory2, InventoryType.Inventory3, InventoryType.Inventory4 ]; + List items = []; + + foreach (var inventory in inventories) { + var container = InventoryManager.Instance()->GetInventoryContainer(inventory); + + for (var index = 0; index < container->Size; ++index) { + ref var item = ref container->Items[index]; + if (item.ItemId is 0) continue; + + items.Add(item); + } + } + + List itemInfos = []; + itemInfos.AddRange(from itemGroups in items.GroupBy(item => item.ItemId) + where itemGroups.Key is not 0 + let item = itemGroups.First() + let itemCount = itemGroups.Sum(duplicateItem => duplicateItem.Quantity) + select new ItemInfo { + Item = item, ItemCount = itemCount, + }); + + return itemInfos; + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/ItemInfo.cs b/AetherBags/Inventory/ItemInfo.cs new file mode 100644 index 0000000..41a7e1b --- /dev/null +++ b/AetherBags/Inventory/ItemInfo.cs @@ -0,0 +1,60 @@ +using System; +using System.Numerics; +using System.Text.RegularExpressions; +using AetherBags.Extensions; +using FFXIVClientStructs.FFXIV.Client.Game; +using Lumina.Excel.Sheets; + +namespace AetherBags.Inventory; + +public class ItemInfo : IEquatable { + public required InventoryItem Item { get; set; } + public required int ItemCount { get; set; } + + private Item ItemData => Services.DataManager.GetExcelSheet().GetRow(Item.ItemId); + + public Vector4 RarityColor => ItemData.RarityColor; + + public uint IconId => ItemData.Icon; + + public string Name => ItemData.Name.ToString(); + + public int Level => ItemData.LevelEquip; + + public int ItemLevel => (int) ItemData.LevelItem.RowId; + + public int Rarity => ItemData.Rarity; + + public int UiCategory => (int) ItemData.ItemUICategory.RowId; + + private string Description => ItemData.Description.ToString(); + + public bool IsRegexMatch(string searchTerms) { + const RegexOptions regexOptions = RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; + + if (Regex.IsMatch(Name, searchTerms, regexOptions)) return true; + if (Regex.IsMatch(Description, searchTerms, regexOptions)) return true; + if (Regex.IsMatch(Level.ToString(), searchTerms, regexOptions)) return true; + if (Regex.IsMatch(ItemLevel.ToString(), searchTerms, regexOptions)) return true; + + return false; + } + + public bool Equals(ItemInfo? other) { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return Item.ItemId.Equals(other.Item.ItemId) && ItemCount == other.ItemCount; + } + + public override bool Equals(object? obj) { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((ItemInfo) obj); + } + + public override int GetHashCode() + // ReSharper disable NonReadonlyMemberInGetHashCode + => HashCode.Combine(Item.ItemId, ItemCount); + // ReSharper restore NonReadonlyMemberInGetHashCode +} \ No newline at end of file diff --git a/AetherBags/Nodes/HybridDirectionalFlexNode.cs b/AetherBags/Nodes/HybridDirectionalFlexNode.cs new file mode 100644 index 0000000..89e636b --- /dev/null +++ b/AetherBags/Nodes/HybridDirectionalFlexNode.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using KamiToolKit; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes +{ + public enum FlexGrowDirection + { + DownRight, + DownLeft, + UpRight, + UpLeft + } + + public class HybridDirectionalFlexNode : HybridDirectionalFlexNode { } + + public class HybridDirectionalFlexNode : LayoutListNode where T : NodeBase + { + + public FlexGrowDirection GrowDirection { get; + set { + field = value; + RecalculateLayout(); + } + } = FlexGrowDirection.DownRight; + + public int ItemsPerLine { + get; + set { + field = value; + RecalculateLayout(); + } + } = 1; + + public bool FillRowsFirst { + get; + set { + field = value; + RecalculateLayout(); + } + } = true; + + public float HorizontalPadding { + get; + set { + field = value; + RecalculateLayout(); + } + } = 1; + + public float VerticalPadding { + get; + set { + field = value; + RecalculateLayout(); + } + } = 1; + + protected override void InternalRecalculateLayout() + { + if (NodeList.Count == 0) { + return; + } + + int itemsPerLine = Math.Max(1, ItemsPerLine); + + float nodeWidth = NodeList.First().Width; + float nodeHeight = NodeList.First().Height; + + bool alignRight = GrowDirection is FlexGrowDirection.DownLeft or FlexGrowDirection.UpLeft; + bool alignBottom = GrowDirection is FlexGrowDirection.UpRight or FlexGrowDirection.UpLeft; + + float startX = alignRight ? Width : 0f; + float startY = alignBottom ? Height : 0f; + + int idx = 0; + foreach (var node in NodeList) + { + int row, col; + if (FillRowsFirst) + { + row = idx / itemsPerLine; + col = idx % itemsPerLine; + } + else + { + col = idx / itemsPerLine; + row = idx % itemsPerLine; + } + + float x = alignRight + ? startX - (col + 1) * nodeWidth - col * HorizontalPadding + : startX + col * (nodeWidth + HorizontalPadding); + + float y = alignBottom + ? startY - (row + 1) * nodeHeight - row * VerticalPadding + : startY + row * (nodeHeight + VerticalPadding); + + node.X = x; + node.Y = y; + AdjustNode(node); + idx++; + } + } + } +} diff --git a/AetherBags/Nodes/InventoryCategoryNode.cs b/AetherBags/Nodes/InventoryCategoryNode.cs new file mode 100644 index 0000000..b27c630 --- /dev/null +++ b/AetherBags/Nodes/InventoryCategoryNode.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AetherBags.Extensions; +using AetherBags.Inventory; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes; + +public class InventoryCategoryNode : SimpleComponentNode +{ + private readonly TextNode _categoryNameTextNode; + private readonly HybridDirectionalFlexNode _itemGridNode; + public InventoryCategoryNode() + { + _categoryNameTextNode = new TextNode + { + Size = new Vector2(100, 14), + AlignmentType = AlignmentType.Left + }; + _categoryNameTextNode.AttachNode(this); + + _itemGridNode = new HybridDirectionalFlexNode + { + Position = new Vector2(0, 16), + Size = new Vector2(240, 100), + FillRowsFirst = true, + ItemsPerLine = 10, + HorizontalPadding = 6, + VerticalPadding = 6, + }; + _itemGridNode.AttachNode(this); + } + + public required CategoryInfo Category + { + get; + set + { + field = value; + + _categoryNameTextNode.String = value.Name; + _categoryNameTextNode.TextColor = value.Color; + _categoryNameTextNode.TooltipString = value.Description; + } + } + + public required List Items + { + get; + set + { + field = value; + + UpdateItemGrid(); + } + } + + private bool UpdateItemGrid() + { + var listUpdated = _itemGridNode.SyncWithListData(Items, node => node.ItemInfo, data => CreateInventoryDragDropNode(data)); + return listUpdated; + } + + private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) + { + InventoryDragDropNode node = new InventoryDragDropNode + { + Size = new Vector2(40), + IsVisible = true, + IconId = data.IconId, + AcceptedType = DragDropType.Nothing, + IsDraggable = false, + Payload = new DragDropPayload + { + Type = DragDropType.Item, + Int1 = (int)data.Item.Container, + Int2 = (int)data.Item.ItemId, + }, + IsClickable = true, + OnRollOver = node => node.ShowInventoryItemTooltip(data.Item.Container, data.Item.Slot), + OnRollOut = node => node.HideTooltip(), + ItemInfo = data + }; + return node; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/InventoryDragDropNode.cs b/AetherBags/Nodes/InventoryDragDropNode.cs new file mode 100644 index 0000000..9df8e69 --- /dev/null +++ b/AetherBags/Nodes/InventoryDragDropNode.cs @@ -0,0 +1,54 @@ +using System.Numerics; +using AetherBags.Extensions; +using AetherBags.Inventory; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes; + +public class InventoryDragDropNode : DragDropNode +{ + private readonly TextNode _quantityTextNode; + public unsafe InventoryDragDropNode() + { + _quantityTextNode = new TextNode { + Size = new Vector2(40.0f, 12.0f), + Position = new Vector2(4.0f, 34.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + Color = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(51), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Right, + }; + _quantityTextNode.AttachNode(this); + CollisionNode.AddEvent(AtkEventType.MouseDown, OnItemMouseDown); + CollisionNode.AddEvent(AtkEventType.MouseClick, OnItemClicked); + } + + public required ItemInfo ItemInfo + { + get; + set + { + field = value; + _quantityTextNode.String = value.ItemCount.ToString(); + } + } + + private unsafe void OnItemMouseDown(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + InventoryItem item = ItemInfo.Item; + if (!atkEventData->IsRightClick) return; + + AgentInventoryContext* context = AgentInventoryContext.Instance(); + context->OpenForItemSlot(item.Container, item.Slot, 0, context->AddonId); + } + + private unsafe void OnItemClicked(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + InventoryItem item = ItemInfo.Item; + if (!atkEventData->IsLeftClick) return; + item.UseItem(); + } +} \ No newline at end of file diff --git a/AetherBags/Plugin.cs b/AetherBags/Plugin.cs index 21ac106..9617931 100644 --- a/AetherBags/Plugin.cs +++ b/AetherBags/Plugin.cs @@ -1,27 +1,77 @@ +using System.Numerics; +using AetherBags.Addons; +using AetherBags.Helpers; +using Dalamud.Game.Addon.Lifecycle; using Dalamud.Plugin; using Dalamud.Game.Command; +using KamiToolKit; namespace AetherBags; public class Plugin : IDalamudPlugin { - public const string CommandName = "/aetherbags"; - + private static string HelpDescription => "Opens your inventory."; public Plugin(IDalamudPluginInterface pluginInterface) { pluginInterface.Create(); - Services.CommandManager.AddHandler(CommandName, new CommandInfo(this.OnCommand) + BackupHelper.DoConfigBackup(pluginInterface); + + KamiToolKitLibrary.Initialize(pluginInterface); + + System.AddonInventoryWindow = new AddonInventoryWindow { - HelpMessage = "" + InternalName = "AetherBags", + Title = "AetherBags", + Size = new Vector2(750, 750), + }; + + Services.CommandManager.AddHandler("/aetherbags", new CommandInfo(OnCommand) + { + DisplayOrder = 1, + ShowInHelp = true, + HelpMessage = HelpDescription }); + Services.CommandManager.AddHandler("/ab", new CommandInfo(OnCommand) + { + DisplayOrder = 2, + ShowInHelp = true, + HelpMessage = HelpDescription + }); + Services.ClientState.Login += OnLogin; + + if (Services.ClientState.IsLoggedIn) { + Services.Framework.RunOnFrameworkThread(OnLogin); + } } public void Dispose() { - Services.CommandManager.RemoveHandler(CommandName); + Services.ClientState.Login -= OnLogin; + + Services.CommandManager.RemoveHandler("/aetherbags"); + Services.CommandManager.RemoveHandler("/ab"); + + KamiToolKitLibrary.Dispose(); } private void OnCommand(string command, string args) { - } \ No newline at end of file + switch (command) + { + case "/aetherbags": + case "/ab": + if(args.Length == 0) + System.AddonInventoryWindow.Toggle(); + if(args == "config") + System.AddonInventoryWindow.Toggle(); + break; + } + } + + private void OnLogin() { + #if DEBUG + System.AddonInventoryWindow.Toggle(); + #endif + } +} \ No newline at end of file diff --git a/AetherBags/Services.cs b/AetherBags/Services.cs index 759afeb..e31584c 100644 --- a/AetherBags/Services.cs +++ b/AetherBags/Services.cs @@ -6,6 +6,11 @@ namespace AetherBags; public class Services { - [PluginService] public static IDalamudPluginInterface PluginInterface { get; private set; } = null!; + [PluginService] public static IAddonLifecycle AddonLifecycle { get; set; } = null!; + [PluginService] public static IClientState ClientState { get; private set; } = null!; [PluginService] public static ICommandManager CommandManager { get; private set; } = null!; + [PluginService] public static IDataManager DataManager { get; set; } = null!; + [PluginService] public static IDalamudPluginInterface PluginInterface { get; private set; } = null!; + [PluginService] public static IFramework Framework { get; private set; } = null!; + [PluginService] public static IPluginLog Logger { get; private set; } = null!; } \ No newline at end of file diff --git a/AetherBags/System.cs b/AetherBags/System.cs new file mode 100644 index 0000000..2a267dd --- /dev/null +++ b/AetherBags/System.cs @@ -0,0 +1,8 @@ +using AetherBags.Addons; + +namespace AetherBags; + +public static class System +{ + public static AddonInventoryWindow AddonInventoryWindow { get; set; } = null!; +} \ No newline at end of file diff --git a/AetherBags/packages.lock.json b/AetherBags/packages.lock.json new file mode 100644 index 0000000..7dce951 --- /dev/null +++ b/AetherBags/packages.lock.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[14.0.1, )", + "resolved": "14.0.1", + "contentHash": "y0WWyUE6dhpGdolK3iKgwys05/nZaVf4ZPtIjpLhJBZvHxkkiE23zYRo7K7uqAgoK/QvK5cqF6l3VG5AbgC6KA==" + }, + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + }, + "SixLabors.ImageSharp": { + "type": "Transitive", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "kamitoolkit": { + "type": "Project", + "dependencies": { + "SixLabors.ImageSharp": "[3.1.12, )" + } + } + } + } +} \ No newline at end of file diff --git a/KamiToolKit b/KamiToolKit new file mode 160000 index 0000000..44ac1e0 --- /dev/null +++ b/KamiToolKit @@ -0,0 +1 @@ +Subproject commit 44ac1e0c3ae2bf6fb81870ced7c52dd7fb4e38c1