diff --git a/Glamourer.GameData/Customization/CustomizationManager.cs b/Glamourer.GameData/Customization/CustomizationManager.cs index 217a996..ec48863 100644 --- a/Glamourer.GameData/Customization/CustomizationManager.cs +++ b/Glamourer.GameData/Customization/CustomizationManager.cs @@ -3,37 +3,39 @@ using Dalamud.Data; using Dalamud.Plugin; using Penumbra.GameData.Enums; -namespace Glamourer.Customization +namespace Glamourer.Customization; + +public class CustomizationManager : ICustomizationManager { - public class CustomizationManager : ICustomizationManager + private static CustomizationOptions? _options; + + private CustomizationManager() + { } + + public static ICustomizationManager Create(DalamudPluginInterface pi, DataManager gameData) { - private static CustomizationOptions? _options; - - private CustomizationManager() - { } - - public static ICustomizationManager Create(DalamudPluginInterface pi, DataManager gameData) - { - _options ??= new CustomizationOptions(pi, gameData); - return new CustomizationManager(); - } - - public IReadOnlyList Races - => CustomizationOptions.Races; - - public IReadOnlyList Clans - => CustomizationOptions.Clans; - - public IReadOnlyList Genders - => CustomizationOptions.Genders; - - public CustomizationSet GetList(SubRace clan, Gender gender) - => _options!.GetList(clan, gender); - - public ImGuiScene.TextureWrap GetIcon(uint iconId) - => _options!.GetIcon(iconId); - - public string GetName(CustomName name) - => _options!.GetName(name); + _options ??= new CustomizationOptions(pi, gameData); + return new CustomizationManager(); } + + public IReadOnlyList Races + => CustomizationOptions.Races; + + public IReadOnlyList Clans + => CustomizationOptions.Clans; + + public IReadOnlyList Genders + => CustomizationOptions.Genders; + + public CustomizationSet GetList(SubRace clan, Gender gender) + => _options!.GetList(clan, gender); + + public ImGuiScene.TextureWrap GetIcon(uint iconId) + => _options!.GetIcon(iconId); + + public void RemoveIcon(uint iconId) + => _options!.RemoveIcon(iconId); + + public string GetName(CustomName name) + => _options!.GetName(name); } diff --git a/Glamourer.GameData/Customization/CustomizationOptions.cs b/Glamourer.GameData/Customization/CustomizationOptions.cs index 98ab426..bfc5d78 100644 --- a/Glamourer.GameData/Customization/CustomizationOptions.cs +++ b/Glamourer.GameData/Customization/CustomizationOptions.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Reflection; using Dalamud; using Dalamud.Data; -using Dalamud.Logging; using Dalamud.Plugin; using Dalamud.Utility; using Lumina.Excel; @@ -39,6 +38,9 @@ public partial class CustomizationOptions internal ImGuiScene.TextureWrap GetIcon(uint id) => _icons.LoadIcon(id); + internal void RemoveIcon(uint id) + => _icons.RemoveIcon(id); + private readonly IconStorage _icons; private static readonly int ListSize = Clans.Length * Genders.Length; @@ -416,7 +418,7 @@ public partial class CustomizationOptions // Obtain available hairstyles via reflection from the Hair sheet for the given subrace and gender. private CustomizeData[] GetHairStyles(SubRace race, Gender gender) { - var row = _hairSheet.GetRow(((uint)race - 1) * 2 - 1 + (uint)gender)!; + var row = _hairSheet.GetRow(((uint)race - 1) * 2 - 1 + (uint)gender)!; // Unknown30 is the number of available hairstyles. var hairList = new List(row.Unknown30); // Hairstyles can be found starting at Unknown66. @@ -431,14 +433,10 @@ public partial class CustomizationOptions // Hair Row from CustomizeSheet might not be set in case of unlockable hair. var hairRow = _customizeSheet.GetRow(customizeIdx); if (hairRow == null) - { hairList.Add(new CustomizeData(CustomizeIndex.Hairstyle, (CustomizeValue)i, customizeIdx)); - } else if (_options._icons.IconExists(hairRow.Icon)) - { hairList.Add(new CustomizeData(CustomizeIndex.Hairstyle, (CustomizeValue)hairRow.FeatureID, hairRow.Icon, (ushort)hairRow.RowId)); - } } return hairList.ToArray(); @@ -464,8 +462,8 @@ public partial class CustomizationOptions // Get face paints from the hair sheet via reflection. private CustomizeData[] GetFacePaints(SubRace race, Gender gender) { - var row = _hairSheet.GetRow(((uint)race - 1) * 2 - 1 + (uint)gender)!; - var paintList = new List(row.Unknown37); + var row = _hairSheet.GetRow(((uint)race - 1) * 2 - 1 + (uint)gender)!; + var paintList = new List(row.Unknown37); // Number of available face paints is at Unknown37. for (var i = 0; i < row.Unknown37; ++i) { @@ -480,13 +478,12 @@ public partial class CustomizationOptions var paintRow = _customizeSheet.GetRow(customizeIdx); // Facepaint Row from CustomizeSheet might not be set in case of unlockable facepaints. if (paintRow != null) - { paintList.Add(new CustomizeData(CustomizeIndex.FacePaint, (CustomizeValue)paintRow.FeatureID, paintRow.Icon, (ushort)paintRow.RowId)); - } else paintList.Add(new CustomizeData(CustomizeIndex.FacePaint, (CustomizeValue)i, customizeIdx)); } + return paintList.ToArray(); } diff --git a/Glamourer.GameData/Customization/CustomizeIndex.cs b/Glamourer.GameData/Customization/CustomizeIndex.cs index 0976370..3517ed0 100644 --- a/Glamourer.GameData/Customization/CustomizeIndex.cs +++ b/Glamourer.GameData/Customization/CustomizeIndex.cs @@ -1,4 +1,6 @@ -using System.Runtime.CompilerServices; +using System; +using System.Linq; +using System.Runtime.CompilerServices; namespace Glamourer.Customization; @@ -44,7 +46,10 @@ public enum CustomizeIndex : byte public static class CustomizationExtensions { - public const int NumIndices = ((int)CustomizeIndex.FacePaintColor + 1); + public const int NumIndices = (int)CustomizeIndex.FacePaintColor + 1; + + public static readonly CustomizeIndex[] All = Enum.GetValues() + .Where(v => v is not CustomizeIndex.Race and not CustomizeIndex.BodyType).ToArray(); [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public static (int ByteIdx, byte Mask) ToByteAndMask(this CustomizeIndex index) diff --git a/Glamourer.GameData/Customization/ICustomizationManager.cs b/Glamourer.GameData/Customization/ICustomizationManager.cs index 6e1cfe3..c1a1a1a 100644 --- a/Glamourer.GameData/Customization/ICustomizationManager.cs +++ b/Glamourer.GameData/Customization/ICustomizationManager.cs @@ -12,5 +12,6 @@ public interface ICustomizationManager public CustomizationSet GetList(SubRace race, Gender gender); public ImGuiScene.TextureWrap GetIcon(uint iconId); + public void RemoveIcon(uint iconId); public string GetName(CustomName name); } diff --git a/Glamourer/Events/ObjectUnlocked.cs b/Glamourer/Events/ObjectUnlocked.cs new file mode 100644 index 0000000..ccb4b18 --- /dev/null +++ b/Glamourer/Events/ObjectUnlocked.cs @@ -0,0 +1,34 @@ +using System; +using OtterGui.Classes; + +namespace Glamourer.Events; + +/// +/// Triggered when a new item or customization is unlocked. +/// +/// Parameter is the type of the unlocked object +/// Parameter is the id of the unlocked object. +/// Parameter is the timestamp of the unlock. +/// +/// +public sealed class ObjectUnlocked : EventWrapper, ObjectUnlocked.Priority> +{ + public enum Type + { + Item, + Customization, + } + + public enum Priority + { + /// + UnlockTable = 0, + } + + public ObjectUnlocked() + : base(nameof(ObjectUnlocked)) + { } + + public void Invoke(Type type, uint id, DateTimeOffset timestamp) + => Invoke(this, type, id, timestamp); +} diff --git a/Glamourer/Glamourer.cs b/Glamourer/Glamourer.cs index 754055b..1b8cd90 100644 --- a/Glamourer/Glamourer.cs +++ b/Glamourer/Glamourer.cs @@ -3,7 +3,6 @@ using Dalamud.Plugin; using Glamourer.Gui; using Glamourer.Interop; using Glamourer.Services; -using Lumina.Excel.GeneratedSheets; using Microsoft.Extensions.DependencyInjection; using OtterGui.Classes; using OtterGui.Log; @@ -36,7 +35,7 @@ public class Glamourer : IDalamudPlugin _services.GetRequiredService(); // call backup service. _services.GetRequiredService(); // initialize ui. _services.GetRequiredService(); // initialize commands. - _services.GetRequiredService(); + _services.GetRequiredService(); } catch { diff --git a/Glamourer/Gui/Tabs/AutomationTab/SetPanel.cs b/Glamourer/Gui/Tabs/AutomationTab/SetPanel.cs index 62041e2..453ed25 100644 --- a/Glamourer/Gui/Tabs/AutomationTab/SetPanel.cs +++ b/Glamourer/Gui/Tabs/AutomationTab/SetPanel.cs @@ -2,22 +2,32 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; +using System.Text; using Dalamud.Interface; using Glamourer.Automation; +using Glamourer.Customization; using Glamourer.Designs; using Glamourer.Interop; +using Glamourer.Services; using Glamourer.Structs; +using Glamourer.Unlocks; using ImGuiNET; +using Lumina.Excel.GeneratedSheets; using OtterGui; using OtterGui.Raii; using OtterGui.Widgets; +using Penumbra.GameData.Enums; +using Action = System.Action; namespace Glamourer.Gui.Tabs.AutomationTab; public class SetPanel { - private readonly AutoDesignManager _manager; - private readonly SetSelector _selector; + private readonly AutoDesignManager _manager; + private readonly SetSelector _selector; + private readonly ItemUnlockManager _itemUnlocks; + private readonly CustomizeUnlockManager _customizeUnlocks; + private readonly CustomizationService _customizations; private readonly DesignCombo _designCombo; private readonly JobGroupCombo _jobGroupCombo; @@ -27,12 +37,16 @@ public class SetPanel private Action? _endAction; - public SetPanel(SetSelector selector, AutoDesignManager manager, DesignManager designs, JobService jobs) + public SetPanel(SetSelector selector, AutoDesignManager manager, DesignManager designs, JobService jobs, ItemUnlockManager itemUnlocks, + CustomizeUnlockManager customizeUnlocks, CustomizationService customizations) { - _selector = selector; - _manager = manager; - _designCombo = new DesignCombo(_manager, designs); - _jobGroupCombo = new JobGroupCombo(manager, jobs); + _selector = selector; + _manager = manager; + _itemUnlocks = itemUnlocks; + _customizeUnlocks = customizeUnlocks; + _customizations = customizations; + _designCombo = new DesignCombo(_manager, designs); + _jobGroupCombo = new JobGroupCombo(manager, jobs); } private AutoDesignSet Selection @@ -92,7 +106,7 @@ public class SetPanel private void DrawDesignTable() { - using var table = ImRaii.Table("SetTable", 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollX | ImGuiTableFlags.ScrollY); + using var table = ImRaii.Table("SetTable", 6, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollX | ImGuiTableFlags.ScrollY); if (!table) return; @@ -101,6 +115,7 @@ public class SetPanel ImGui.TableSetupColumn("Design", ImGuiTableColumnFlags.WidthFixed, 220 * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("Application", ImGuiTableColumnFlags.WidthFixed, 5 * ImGui.GetFrameHeight() + 4 * 2 * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("Job Restrictions", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Warnings", ImGuiTableColumnFlags.WidthFixed, 4 * ImGui.GetFrameHeight() + 3 * 2 * ImGuiHelpers.GlobalScale); ImGui.TableHeadersRow(); foreach (var (design, idx) in Selection.Designs.WithIndex()) @@ -120,6 +135,8 @@ public class SetPanel DrawApplicationTypeBoxes(Selection, design, idx); ImGui.TableNextColumn(); _jobGroupCombo.Draw(Selection, design, idx); + ImGui.TableNextColumn(); + DrawWarnings(design, idx); } ImGui.TableNextColumn(); @@ -135,6 +152,79 @@ public class SetPanel _endAction = null; } + private void DrawWarnings(AutoDesign design, int idx) + { + var size = new Vector2(ImGui.GetFrameHeight()); + size.X += ImGuiHelpers.GlobalScale; + + var (equipFlags, customizeFlags, _, _, _, _) = design.ApplyWhat(); + equipFlags &= design.Design.ApplyEquip; + customizeFlags &= design.Design.ApplyCustomize; + var sb = new StringBuilder(); + foreach (var slot in EquipSlotExtensions.EqdpSlots.Append(EquipSlot.MainHand).Append(EquipSlot.OffHand)) + { + var flag = slot.ToFlag(); + if (!equipFlags.HasFlag(flag)) + continue; + + var item = design.Design.DesignData.Item(slot); + if (!_itemUnlocks.IsUnlocked(item.Id, out _)) + sb.AppendLine($"{item.Name} in {slot.ToName()} slot is not unlocked but should be applied."); + } + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(2 * ImGuiHelpers.GlobalScale, 0)); + + + static void DrawWarning(StringBuilder sb, uint color, Vector2 size, string suffix, string good) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale); + if (sb.Length > 0) + { + sb.Append(suffix); + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.DrawTextButton(FontAwesomeIcon.ExclamationCircle.ToIconString(), size, color); + } + + ImGuiUtil.HoverTooltip(sb.ToString()); + } + else + { + ImGuiUtil.DrawTextButton(string.Empty, size, 0); + ImGuiUtil.HoverTooltip(good); + } + } + + DrawWarning(sb, 0xA03030F0, size, "\nThese items will be skipped when applied automatically. To change this, see", + "All equipment to be applied is unlocked."); // TODO + + sb.Clear(); + var sb2 = new StringBuilder(); + var customize = design.Design.DesignData.Customize; + var set = _customizations.AwaitedService.GetList(customize.Clan, customize.Gender); + foreach (var type in CustomizationExtensions.All) + { + var flag = type.ToFlag(); + if (!customizeFlags.HasFlag(flag)) + continue; + + if (flag.RequiresRedraw()) + sb.AppendLine($"{type.ToDefaultName()} Customization can not be changed automatically."); // TODO + else if (type is CustomizeIndex.Hairstyle or CustomizeIndex.FacePaint + && set.DataByValue(type, customize[type], out var data, customize.Face) >= 0 + && !_customizeUnlocks.IsUnlocked(data!.Value, out _)) + sb2.AppendLine( + $"{type.ToDefaultName()} Customization {_customizeUnlocks.Unlockable[data.Value].Name} is not unlocked but should be applied."); + } + + ImGui.SameLine(); + DrawWarning(sb2, 0xA03030F0, size, "\nThese customizations will be skipped when applied automatically. To change this, see", + "All customizations to be applied are unlocked."); // TODO + ImGui.SameLine(); + DrawWarning(sb, 0xA030F0F0, size, "\nThese customizations will be skipped when applied automatically.", + "No customizations unable to be applied automatically are set to be applied."); // TODO + } + private void DrawDragDrop(AutoDesignSet set, int index) { const string dragDropLabel = "DesignDragDrop"; diff --git a/Glamourer/Gui/Tabs/UnlocksTab/UnlockOverview.cs b/Glamourer/Gui/Tabs/UnlocksTab/UnlockOverview.cs index 32d5f6c..afd5a1b 100644 --- a/Glamourer/Gui/Tabs/UnlocksTab/UnlockOverview.cs +++ b/Glamourer/Gui/Tabs/UnlocksTab/UnlockOverview.cs @@ -6,8 +6,11 @@ using Glamourer.Customization; using Glamourer.Services; using Glamourer.Unlocks; using ImGuiNET; +using OtterGui; +using OtterGui.Classes; using OtterGui.Raii; using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; namespace Glamourer.Gui.Tabs.UnlocksTab; @@ -18,55 +21,110 @@ public class UnlockOverview private readonly CustomizationService _customizations; private readonly CustomizeUnlockManager _customizeUnlocks; private readonly PenumbraChangedItemTooltip _tooltip; + private readonly TextureCache _textureCache; private static readonly Vector4 UnavailableTint = new(0.3f, 0.3f, 0.3f, 1.0f); + private FullEquipType _selected1 = FullEquipType.Unknown; + private SubRace _selected2 = SubRace.Unknown; + private Gender _selected3 = Gender.Unknown; + + private void DrawSelector() + { + using var child = ImRaii.Child("Selector", new Vector2(200 * ImGuiHelpers.GlobalScale, -1), true); + if (!child) + return; + + foreach (var type in Enum.GetValues()) + { + if (type.IsOffhandType() || !_items.ItemService.AwaitedService.TryGetValue(type, out var items) || items.Count == 0) + continue; + + if (ImGui.Selectable(type.ToName(), _selected1 == type)) + { + ClearIcons(_selected1); + _selected1 = type; + _selected2 = SubRace.Unknown; + _selected3 = Gender.Unknown; + } + } + + foreach (var clan in _customizations.AwaitedService.Clans) + { + foreach (var gender in _customizations.AwaitedService.Genders) + { + if (_customizations.AwaitedService.GetList(clan, gender).HairStyles.Count == 0) + continue; + + if (ImGui.Selectable($"{(gender is Gender.Male ? '♂' : '♀')} {clan.ToShortName()} Hair & Paint", + _selected2 == clan && _selected3 == gender)) + { + ClearIcons(_selected1); + _selected1 = FullEquipType.Unknown; + _selected2 = clan; + _selected3 = gender; + } + } + } + } + + private void ClearIcons(FullEquipType type) + { + if (!_items.ItemService.AwaitedService.TryGetValue(type, out var items)) + return; + + foreach (var item in items) + _customizations.AwaitedService.RemoveIcon(item.IconId); + } + public UnlockOverview(ItemManager items, CustomizationService customizations, ItemUnlockManager itemUnlocks, - CustomizeUnlockManager customizeUnlocks, PenumbraChangedItemTooltip tooltip) + CustomizeUnlockManager customizeUnlocks, PenumbraChangedItemTooltip tooltip, TextureCache textureCache) { _items = items; _customizations = customizations; _itemUnlocks = itemUnlocks; _customizeUnlocks = customizeUnlocks; _tooltip = tooltip; + _textureCache = textureCache; } public void Draw() { using var color = ImRaii.PushColor(ImGuiCol.Border, ImGui.GetColorU32(ImGuiCol.TableBorderStrong)); + DrawSelector(); + ImGui.SameLine(); + DrawPanel(); + } + + private void DrawPanel() + { using var child = ImRaii.Child("Panel", -Vector2.One, true); if (!child) return; - var iconSize = ImGuiHelpers.ScaledVector2(32); - foreach (var type in Enum.GetValues()) - DrawEquipTypeHeader(iconSize, type); - - iconSize = ImGuiHelpers.ScaledVector2(64); - foreach (var gender in _customizations.AwaitedService.Genders) - { - foreach (var clan in _customizations.AwaitedService.Clans) - DrawCustomizationHeader(iconSize, clan, gender); - } + if (_selected1 is not FullEquipType.Unknown) + DrawItems(); + else if (_selected2 is not SubRace.Unknown && _selected3 is not Gender.Unknown) + DrawCustomizations(); } - private void DrawCustomizationHeader(Vector2 iconSize, SubRace subRace, Gender gender) + private void DrawCustomizations() { - var set = _customizations.AwaitedService.GetList(subRace, gender); - if (set.HairStyles.Count == 0 && set.FacePaints.Count == 0) - return; + var set = _customizations.AwaitedService.GetList(_selected2, _selected3); - if (!ImGui.CollapsingHeader($"Unlockable {subRace.ToName()} {gender.ToName()} Customizations")) - return; + var spacing = IconSpacing; + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + var iconSize = ImGuiHelpers.ScaledVector2(128); + var iconsPerRow = IconsPerRow(iconSize.X, spacing.X); - using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - foreach (var customization in set.HairStyles.Concat(set.FacePaints)) + var counter = 0; + foreach (var customize in set.HairStyles.Concat(set.FacePaints)) { - if (!_customizeUnlocks.Unlockable.TryGetValue(customization, out var unlockData)) + if (!_customizeUnlocks.Unlockable.TryGetValue(customize, out var unlockData)) continue; - var unlocked = _customizeUnlocks.IsUnlocked(customization, out var time); - var icon = _customizations.AwaitedService.GetIcon(customization.IconId); + var unlocked = _customizeUnlocks.IsUnlocked(customize, out var time); + var icon = _customizations.AwaitedService.GetIcon(customize.IconId); ImGui.Image(icon.ImGuiHandle, iconSize, Vector2.Zero, Vector2.One, unlocked ? Vector4.One : UnavailableTint); if (ImGui.IsItemHovered()) @@ -76,37 +134,44 @@ public class UnlockOverview if (size.X >= iconSize.X && size.Y >= iconSize.Y) ImGui.Image(icon.ImGuiHandle, size); ImGui.TextUnformatted(unlockData.Name); - ImGui.TextUnformatted($"{customization.Index.ToDefaultName()} {customization.Value.Value}"); + ImGui.TextUnformatted($"{customize.Index.ToDefaultName()} {customize.Value.Value}"); ImGui.TextUnformatted(unlocked ? $"Unlocked on {time:g}" : "Not unlocked."); } - ImGui.SameLine(); - if (ImGui.GetContentRegionAvail().X < iconSize.X) - ImGui.NewLine(); + if (counter != iconsPerRow - 1) + { + ImGui.SameLine(); + ++counter; + } + else + { + counter = 0; + } } - - if (ImGui.GetCursorPosX() != 0) - ImGui.NewLine(); } - private void DrawEquipTypeHeader(Vector2 iconSize, FullEquipType type) + private void DrawItems() { - if (type.IsOffhandType() || !_items.ItemService.AwaitedService.TryGetValue(type, out var items) || items.Count == 0) + if (!_items.ItemService.AwaitedService.TryGetValue(_selected1, out var items)) return; - if (!ImGui.CollapsingHeader($"{type.ToName()}s")) - return; + var spacing = IconSpacing; + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + var iconSize = ImGuiHelpers.ScaledVector2(64); + var iconsPerRow = IconsPerRow(iconSize.X, spacing.X); + var numRows = (items.Count + iconsPerRow - 1) / iconsPerRow; + var numVisibleRows = (int)(Math.Ceiling(ImGui.GetContentRegionAvail().Y / (iconSize.Y + spacing.Y)) + 0.5f); - using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(2 * ImGuiHelpers.GlobalScale)); - foreach (var item in items) + void DrawItem(EquipItem item) { - if (!ImGui.IsItemVisible()) - { } + var unlocked = _itemUnlocks.IsUnlocked(item.Id, out var time); + var iconHandle = _textureCache.LoadIcon(item.IconId); + if (!iconHandle.HasValue) + return; - var unlocked = _itemUnlocks.IsUnlocked(item.Id, out var time); - var icon = _customizations.AwaitedService.GetIcon(item.IconId); + var (icon, size) = iconHandle.Value.Value; - ImGui.Image(icon.ImGuiHandle, iconSize, Vector2.Zero, Vector2.One, unlocked ? Vector4.One : UnavailableTint); + ImGui.Image(icon, iconSize, Vector2.Zero, Vector2.One, unlocked ? Vector4.One : UnavailableTint); if (ImGui.IsItemClicked()) { // TODO link @@ -117,10 +182,9 @@ public class UnlockOverview if (ImGui.IsItemHovered()) { - using var tt = ImRaii.Tooltip(); - var size = new Vector2(icon.Width, icon.Height); + using var tt = ImRaii.Tooltip(); if (size.X >= iconSize.X && size.Y >= iconSize.Y) - ImGui.Image(icon.ImGuiHandle, size); + ImGui.Image(icon, size); ImGui.TextUnformatted(item.Name); var slot = item.Type.ToSlot(); ImGui.TextUnformatted($"{item.Type.ToName()} ({slot.ToName()})"); @@ -133,13 +197,35 @@ public class UnlockOverview unlocked ? time == DateTimeOffset.MinValue ? "Always Unlocked" : $"Unlocked on {time:g}" : "Not Unlocked."); _tooltip.CreateTooltip(item, string.Empty, false); } + } - ImGui.SameLine(); - if (ImGui.GetContentRegionAvail().X < iconSize.X) - ImGui.NewLine(); + var skips = ImGuiClip.GetNecessarySkips(iconSize.Y + spacing.Y); + var end = Math.Min(numVisibleRows * iconsPerRow + skips * iconsPerRow, items.Count); + var counter = 0; + for (var idx = skips * iconsPerRow; idx < end; ++idx) + { + DrawItem(items[idx]); + if (counter != iconsPerRow - 1) + { + ImGui.SameLine(); + ++counter; + } + else + { + counter = 0; + } } if (ImGui.GetCursorPosX() != 0) ImGui.NewLine(); + var remainder = numRows - numVisibleRows - skips; + if (remainder > 0) + ImGuiClip.DrawEndDummy(remainder, iconSize.Y + spacing.Y); } + + private static Vector2 IconSpacing + => ImGuiHelpers.ScaledVector2(2); + + private static int IconsPerRow(float iconWidth, float iconSpacing) + => (int)(ImGui.GetContentRegionAvail().X / (iconWidth + iconSpacing)); } diff --git a/Glamourer/Gui/Tabs/UnlocksTab/UnlockTable.cs b/Glamourer/Gui/Tabs/UnlocksTab/UnlockTable.cs index d8e6940..e5e18ce 100644 --- a/Glamourer/Gui/Tabs/UnlocksTab/UnlockTable.cs +++ b/Glamourer/Gui/Tabs/UnlocksTab/UnlockTable.cs @@ -3,13 +3,14 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Numerics; -using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Interface; +using Glamourer.Events; using Glamourer.Services; using Glamourer.Structs; using Glamourer.Unlocks; using ImGuiNET; using OtterGui; +using OtterGui.Classes; using OtterGui.Raii; using OtterGui.Table; using Penumbra.GameData.Enums; @@ -17,35 +18,43 @@ using Penumbra.GameData.Structs; namespace Glamourer.Gui.Tabs.UnlocksTab; -public class UnlockTable : Table +public class UnlockTable : Table, IDisposable { - public UnlockTable(ItemManager items, CustomizationService customizations, ItemUnlockManager itemUnlocks, - PenumbraChangedItemTooltip tooltip) + private readonly ObjectUnlocked _event; + + public UnlockTable(ItemManager items, TextureCache cache, ItemUnlockManager itemUnlocks, + PenumbraChangedItemTooltip tooltip, ObjectUnlocked @event) : base("ItemUnlockTable", new ItemList(items), - new NameColumn(customizations, tooltip) { Label = "Item Name..." }, - new SlotColumn() { Label = "Equip Slot" }, - new TypeColumn() { Label = "Item Type..." }, - new UnlockDateColumn(itemUnlocks) { Label = "Unlocked" }, - new ItemIdColumn() { Label = "Item Id..." }, - new ModelDataColumn(items) { Label = "Model Data..." }) + new NameColumn(cache, tooltip) { Label = "Item Name..." }, + new SlotColumn() { Label = "Equip Slot" }, + new TypeColumn() { Label = "Item Type..." }, + new UnlockDateColumn(itemUnlocks) { Label = "Unlocked" }, + new ItemIdColumn() { Label = "Item Id..." }, + new ModelDataColumn(items) { Label = "Model Data..." }) { + _event = @event; Sortable = true; Flags |= ImGuiTableFlags.Hideable; + _event.Subscribe(OnObjectUnlock, ObjectUnlocked.Priority.UnlockTable); + cache.Logger = Glamourer.Log; } + public void Dispose() + => _event.Unsubscribe(OnObjectUnlock); + private sealed class NameColumn : ColumnString { - private readonly CustomizationService _customizations; + private readonly TextureCache _textures; private readonly PenumbraChangedItemTooltip _tooltip; public override float Width => 400 * ImGuiHelpers.GlobalScale; - public NameColumn(CustomizationService customizations, PenumbraChangedItemTooltip tooltip) + public NameColumn(TextureCache textures, PenumbraChangedItemTooltip tooltip) { - _customizations = customizations; - _tooltip = tooltip; - Flags |= ImGuiTableColumnFlags.NoHide | ImGuiTableColumnFlags.NoReorder; + _textures = textures; + _tooltip = tooltip; + Flags |= ImGuiTableColumnFlags.NoHide | ImGuiTableColumnFlags.NoReorder; } public override string ToName(EquipItem item) @@ -53,8 +62,11 @@ public class UnlockTable : Table public override void DrawColumn(EquipItem item, int _) { - var icon = _customizations.AwaitedService.GetIcon(item.IconId); - ImGui.Image(icon.ImGuiHandle, new Vector2(ImGui.GetFrameHeight())); + var iconHandle = _textures.LoadIcon(item.IconId); + if (iconHandle.HasValue) + ImGuiUtil.HoverIcon(iconHandle.Value, new Vector2(ImGui.GetFrameHeight())); + else + ImGui.Dummy(new Vector2(ImGui.GetFrameHeight())); ImGui.SameLine(); ImGui.AlignTextToFramePadding(); if (ImGui.Selectable(item.Name)) @@ -191,7 +203,7 @@ public class UnlockTable : Table public override int Compare(EquipItem lhs, EquipItem rhs) { var unlockedLhs = _unlocks.IsUnlocked(lhs.Id, out var timeLhs); - var unlockedRhs = _unlocks.IsUnlocked(lhs.Id, out var timeRhs); + var unlockedRhs = _unlocks.IsUnlocked(rhs.Id, out var timeRhs); var c1 = unlockedLhs.CompareTo(unlockedRhs); return c1 != 0 ? c1 : timeLhs.CompareTo(timeRhs); } @@ -273,4 +285,10 @@ public class UnlockTable : Table public int Count => _items.ItemService.AwaitedService.TotalItemCount(true); } + + private void OnObjectUnlock(ObjectUnlocked.Type _1, uint _2, DateTimeOffset _3) + { + FilterDirty = true; + SortDirty = true; + } } diff --git a/Glamourer/Services/ServiceManager.cs b/Glamourer/Services/ServiceManager.cs index c695acf..4db7874 100644 --- a/Glamourer/Services/ServiceManager.cs +++ b/Glamourer/Services/ServiceManager.cs @@ -54,7 +54,8 @@ public static class ServiceManager .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); private static IServiceCollection AddEvents(this IServiceCollection services) => services.AddSingleton() @@ -64,7 +65,8 @@ public static class ServiceManager .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); private static IServiceCollection AddData(this IServiceCollection services) => services.AddSingleton() diff --git a/Glamourer/Unlocks/CustomizeUnlockManager.cs b/Glamourer/Unlocks/CustomizeUnlockManager.cs index f077481..f3bc683 100644 --- a/Glamourer/Unlocks/CustomizeUnlockManager.cs +++ b/Glamourer/Unlocks/CustomizeUnlockManager.cs @@ -10,6 +10,7 @@ using Dalamud.Utility; using Dalamud.Utility.Signatures; using FFXIVClientStructs.FFXIV.Client.Game.UI; using Glamourer.Customization; +using Glamourer.Events; using Glamourer.Services; using Lumina.Excel.GeneratedSheets; @@ -17,8 +18,10 @@ namespace Glamourer.Unlocks; public class CustomizeUnlockManager : IDisposable, ISavable { - private readonly SaveService _saveService; - private readonly ClientState _clientState; + private readonly SaveService _saveService; + private readonly ClientState _clientState; + private readonly ObjectUnlocked _event; + private readonly Dictionary _unlocked = new(); public readonly IReadOnlyDictionary Unlockable; @@ -27,11 +30,12 @@ public class CustomizeUnlockManager : IDisposable, ISavable => _unlocked; public unsafe CustomizeUnlockManager(SaveService saveService, CustomizationService customizations, DataManager gameData, - ClientState clientState) + ClientState clientState, ObjectUnlocked @event) { SignatureHelper.Initialise(this); _saveService = saveService; _clientState = clientState; + _event = @event; Unlockable = CreateUnlockableCustomizations(customizations, gameData); Load(); _setUnlockLinkValueHook.Enable(); @@ -51,13 +55,13 @@ public class CustomizeUnlockManager : IDisposable, ISavable // All other customizations are not unlockable. if (data.Index is not CustomizeIndex.Hairstyle and not CustomizeIndex.FacePaint) { - time = DateTime.MinValue; + time = DateTimeOffset.MinValue; return true; } if (!Unlockable.TryGetValue(data, out var pair)) { - time = DateTime.MinValue; + time = DateTimeOffset.MinValue; return true; } @@ -74,8 +78,9 @@ public class CustomizeUnlockManager : IDisposable, ISavable } _unlocked.TryAdd(pair.Data, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - Save(); time = DateTimeOffset.UtcNow; + _event.Invoke(ObjectUnlocked.Type.Customization, pair.Data, time); + Save(); return true; } @@ -107,7 +112,10 @@ public class CustomizeUnlockManager : IDisposable, ISavable foreach (var (_, (id, _)) in Unlockable) { if (instance->IsUnlockLinkUnlocked(id) && _unlocked.TryAdd(id, time)) + { + _event.Invoke(ObjectUnlocked.Type.Customization, id, DateTimeOffset.FromUnixTimeMilliseconds(time)); ++count; + } } if (count <= 0) @@ -141,6 +149,7 @@ public class CustomizeUnlockManager : IDisposable, ISavable if (id != data || !_unlocked.TryAdd(id, time)) continue; + _event.Invoke(ObjectUnlocked.Type.Customization, id, DateTimeOffset.FromUnixTimeMilliseconds(time)); Save(); break; } @@ -161,16 +170,11 @@ public class CustomizeUnlockManager : IDisposable, ISavable => _saveService.QueueSave(this); public void Save(StreamWriter writer) - { } + => UnlockDictionaryHelpers.Save(writer, Unlocked); private void Load() - { - var file = ToFilename(_saveService.FileNames); - if (!File.Exists(file)) - return; - - _unlocked.Clear(); - } + => UnlockDictionaryHelpers.Load(ToFilename(_saveService.FileNames), _unlocked, id => Unlockable.Any(c => c.Value.Data == id), + "customization"); /// Create a list of all unlockable hairstyles and facepaints. private static Dictionary CreateUnlockableCustomizations(CustomizationService customizations, diff --git a/Glamourer/Unlocks/ItemUnlockManager.cs b/Glamourer/Unlocks/ItemUnlockManager.cs index 1493ff3..0c41926 100644 --- a/Glamourer/Unlocks/ItemUnlockManager.cs +++ b/Glamourer/Unlocks/ItemUnlockManager.cs @@ -7,6 +7,7 @@ using Dalamud.Game.ClientState; using Dalamud.Utility.Signatures; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.UI; +using Glamourer.Events; using Glamourer.Services; using Lumina.Excel.GeneratedSheets; using Cabinet = Lumina.Excel.GeneratedSheets.Cabinet; @@ -15,10 +16,12 @@ namespace Glamourer.Unlocks; public class ItemUnlockManager : ISavable, IDisposable { - private readonly SaveService _saveService; - private readonly ItemManager _items; - private readonly ClientState _clientState; - private readonly Framework _framework; + private readonly SaveService _saveService; + private readonly ItemManager _items; + private readonly ClientState _clientState; + private readonly Framework _framework; + private readonly ObjectUnlocked _event; + private readonly Dictionary _unlocked = new(); private bool _lastArmoireState; @@ -37,65 +40,20 @@ public class ItemUnlockManager : ISavable, IDisposable Cabinet = 0x08, } - public readonly record struct UnlockRequirements(uint Quest1, uint Quest2, uint Achievement, ushort State, UnlockType Type) - { - public override string ToString() - { - return Type switch - { - UnlockType.Quest1 => $"Quest {Quest1}", - UnlockType.Quest1 | UnlockType.Quest2 => $"Quests {Quest1} & {Quest2}", - UnlockType.Achievement => $"Achievement {Achievement}", - UnlockType.Quest1 | UnlockType.Achievement => $"Quest {Quest1} & Achievement {Achievement}", - UnlockType.Quest1 | UnlockType.Quest2 | UnlockType.Achievement => $"Quests {Quest1} & {Quest2}, Achievement {Achievement}", - UnlockType.Cabinet => $"Cabinet {Quest1}", - _ => string.Empty, - }; - } - - public unsafe bool IsUnlocked(ItemUnlockManager manager) - { - if (Type == 0) - return true; - - var uiState = UIState.Instance(); - if (uiState == null) - return false; - - bool CheckQuest(uint quest) - => uiState->IsUnlockLinkUnlockedOrQuestCompleted(quest); - - // TODO ClientStructs - bool CheckAchievement(uint achievement) - => false; - - return Type switch - { - UnlockType.Quest1 => CheckQuest(Quest1), - UnlockType.Quest1 | UnlockType.Quest2 => CheckQuest(Quest1) && CheckQuest(Quest2), - UnlockType.Achievement => CheckAchievement(Achievement), - UnlockType.Quest1 | UnlockType.Achievement => CheckQuest(Quest1) && CheckAchievement(Achievement), - UnlockType.Quest1 | UnlockType.Quest2 | UnlockType.Achievement => CheckQuest(Quest1) - && CheckQuest(Quest2) - && CheckAchievement(Achievement), - UnlockType.Cabinet => uiState->Cabinet.IsCabinetLoaded() && uiState->Cabinet.IsItemInCabinet((int)Quest1), - _ => false, - }; - } - } - public readonly IReadOnlyDictionary Unlockable; public IReadOnlyDictionary Unlocked => _unlocked; - public ItemUnlockManager(SaveService saveService, ItemManager items, ClientState clientState, DataManager gameData, Framework framework) + public ItemUnlockManager(SaveService saveService, ItemManager items, ClientState clientState, DataManager gameData, Framework framework, + ObjectUnlocked @event) { SignatureHelper.Initialise(this); _saveService = saveService; _items = items; _clientState = clientState; _framework = framework; + _event = @event; Unlockable = CreateUnlockData(gameData, items); Load(); _clientState.Login += OnLogin; @@ -166,7 +124,13 @@ public class ItemUnlockManager : ISavable, IDisposable var time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); bool AddItem(uint itemId) - => _items.ItemService.AwaitedService.TryGetValue(itemId, out var equip) && _unlocked.TryAdd(equip.Id, time); + { + if (!_items.ItemService.AwaitedService.TryGetValue(itemId, out var equip) || !_unlocked.TryAdd(equip.Id, time)) + return false; + + _event.Invoke(ObjectUnlocked.Type.Item, equip.Id, DateTimeOffset.FromUnixTimeMilliseconds(time)); + return true; + } var mirageManager = MirageManager.Instance(); var changes = false; @@ -200,7 +164,7 @@ public class ItemUnlockManager : ISavable, IDisposable var inventoryManager = InventoryManager.Instance(); if (inventoryManager != null) { - var type = ScannableInventories[_currentInventory]; + var type = ScannableInventories[_currentInventory]; var container = inventoryManager->GetInventoryContainer(type); if (container != null && container->Loaded != 0 && _currentInventoryIndex < container->Size) { @@ -217,6 +181,7 @@ public class ItemUnlockManager : ISavable, IDisposable _currentInventoryIndex = 0; } } + if (changes) Save(); } @@ -239,8 +204,12 @@ public class ItemUnlockManager : ISavable, IDisposable if (IsGameUnlocked(itemId)) { time = DateTimeOffset.UtcNow; - _unlocked.TryAdd(itemId, time.ToUnixTimeMilliseconds()); - Save(); + if (_unlocked.TryAdd(itemId, time.ToUnixTimeMilliseconds())) + { + _event.Invoke(ObjectUnlocked.Type.Item, itemId, time); + Save(); + } + return true; } @@ -269,8 +238,11 @@ public class ItemUnlockManager : ISavable, IDisposable var changes = false; foreach (var (itemId, unlock) in Unlockable) { - if (unlock.IsUnlocked(this)) - changes |= _unlocked.TryAdd(itemId, time); + if (unlock.IsUnlocked(this) && _unlocked.TryAdd(itemId, time)) + { + _event.Invoke(ObjectUnlocked.Type.Item, itemId, DateTimeOffset.FromUnixTimeMilliseconds(time)); + changes = true; + } } // TODO inventories @@ -286,16 +258,11 @@ public class ItemUnlockManager : ISavable, IDisposable => _saveService.DelaySave(this, TimeSpan.FromSeconds(10)); public void Save(StreamWriter writer) - { } + => UnlockDictionaryHelpers.Save(writer, Unlocked); private void Load() - { - var file = ToFilename(_saveService.FileNames); - if (!File.Exists(file)) - return; - - _unlocked.Clear(); - } + => UnlockDictionaryHelpers.Load(ToFilename(_saveService.FileNames), _unlocked, + id => _items.ItemService.AwaitedService.TryGetValue(id, out _), "item"); private void OnLogin(object? _, EventArgs _2) => Scan(); diff --git a/Glamourer/Unlocks/UnlockDictionaryHelpers.cs b/Glamourer/Unlocks/UnlockDictionaryHelpers.cs new file mode 100644 index 0000000..a8bee5e --- /dev/null +++ b/Glamourer/Unlocks/UnlockDictionaryHelpers.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Dalamud.Interface.Internal.Notifications; + +namespace Glamourer.Unlocks; + +public static class UnlockDictionaryHelpers +{ + public const int Magic = 0x00C0FFEE; + public const int Version = 1; + + public static void Save(StreamWriter writer, IReadOnlyDictionary data) + { + // Not using by choice, as this would close the stream prematurely. + var b = new BinaryWriter(writer.BaseStream); + b.Write(Magic); + b.Write(Version); + b.Write(data.Count); + foreach (var (id, timestamp) in data) + { + b.Write(id); + b.Write(timestamp); + } + + b.Flush(); + } + + public static void Load(string filePath, Dictionary data, Func validate, string type) + { + data.Clear(); + if (!File.Exists(filePath)) + return; + + try + { + using var fileStream = File.OpenRead(filePath); + using var b = new BinaryReader(fileStream); + var magic = b.ReadUInt32(); + bool revertEndian; + switch (magic) + { + case 0x00C0FFEE: + revertEndian = false; + break; + case 0xEEFFC000: + revertEndian = true; + break; + default: + Glamourer.Chat.NotificationMessage($"Loading unlocked {type}s failed: Invalid magic number.", "Warning", + NotificationType.Warning); + return; + } + + var version = b.ReadInt32(); + var skips = 0; + var now = DateTimeOffset.UtcNow; + switch (version) + { + case Version: + var count = b.ReadInt32(); + data.EnsureCapacity(count); + for (var i = 0; i < count; ++i) + { + var id = b.ReadUInt32(); + var timestamp = b.ReadInt64(); + if (revertEndian) + { + id = RevertEndianness(id); + timestamp = (long)RevertEndianness(timestamp); + } + + var date = DateTimeOffset.FromUnixTimeMilliseconds(timestamp); + if (!validate(id) + || date < DateTimeOffset.UnixEpoch + || date > now + || !data.TryAdd(id, timestamp)) + ++skips; + } + + if (skips > 0) + Glamourer.Chat.NotificationMessage($"Skipped {skips} unlocked {type}s while loading unlocked {type}s.", "Warning", + NotificationType.Warning); + + break; + default: + Glamourer.Chat.NotificationMessage($"Loading unlocked {type}s failed: Version {version} is unknown.", "Warning", + NotificationType.Warning); + return; + } + + Glamourer.Log.Debug($"[UnlockManager] Loaded {data.Count} unlocked {type}s."); + } + catch (Exception ex) + { + Glamourer.Chat.NotificationMessage(ex, $"Loading unlocked {type}s failed: Unknown Error.", $"Loading unlocked {type}s failed:\n", + "Error", NotificationType.Error); + } + } + + private static uint RevertEndianness(uint value) + => ((value & 0x000000FFU) << 24) | ((value & 0x0000FF00U) << 8) | ((value & 0x00FF0000U) >> 8) | ((value & 0xFF000000U) >> 24); + + private static ulong RevertEndianness(long value) + => (((ulong)value & 0x00000000000000FFU) << 56) + | (((ulong)value & 0x000000000000FF00U) << 40) + | (((ulong)value & 0x0000000000FF0000U) << 24) + | (((ulong)value & 0x00000000FF000000U) << 8) + | (((ulong)value & 0x000000FF00000000U) >> 8) + | (((ulong)value & 0x0000FF0000000000U) >> 24) + | (((ulong)value & 0x00FF000000000000U) >> 40) + | (((ulong)value & 0xFF00000000000000U) >> 56); +} diff --git a/Glamourer/Unlocks/UnlockRequirements.cs b/Glamourer/Unlocks/UnlockRequirements.cs new file mode 100644 index 0000000..01c6584 --- /dev/null +++ b/Glamourer/Unlocks/UnlockRequirements.cs @@ -0,0 +1,50 @@ +using FFXIVClientStructs.FFXIV.Client.Game.UI; + +namespace Glamourer.Unlocks; + +public readonly record struct UnlockRequirements(uint Quest1, uint Quest2, uint Achievement, ushort State, ItemUnlockManager.UnlockType Type) +{ + public override string ToString() + { + return Type switch + { + ItemUnlockManager.UnlockType.Quest1 => $"Quest {Quest1}", + ItemUnlockManager.UnlockType.Quest1 | ItemUnlockManager.UnlockType.Quest2 => $"Quests {Quest1} & {Quest2}", + ItemUnlockManager.UnlockType.Achievement => $"Achievement {Achievement}", + ItemUnlockManager.UnlockType.Quest1 | ItemUnlockManager.UnlockType.Achievement => $"Quest {Quest1} & Achievement {Achievement}", + ItemUnlockManager.UnlockType.Quest1 | ItemUnlockManager.UnlockType.Quest2 | ItemUnlockManager.UnlockType.Achievement => $"Quests {Quest1} & {Quest2}, Achievement {Achievement}", + ItemUnlockManager.UnlockType.Cabinet => $"Cabinet {Quest1}", + _ => string.Empty, + }; + } + + public unsafe bool IsUnlocked(ItemUnlockManager manager) + { + if (Type == 0) + return true; + + var uiState = UIState.Instance(); + if (uiState == null) + return false; + + bool CheckQuest(uint quest) + => uiState->IsUnlockLinkUnlockedOrQuestCompleted(quest); + + // TODO ClientStructs + bool CheckAchievement(uint achievement) + => false; + + return Type switch + { + ItemUnlockManager.UnlockType.Quest1 => CheckQuest(Quest1), + ItemUnlockManager.UnlockType.Quest1 | ItemUnlockManager.UnlockType.Quest2 => CheckQuest(Quest1) && CheckQuest(Quest2), + ItemUnlockManager.UnlockType.Achievement => CheckAchievement(Achievement), + ItemUnlockManager.UnlockType.Quest1 | ItemUnlockManager.UnlockType.Achievement => CheckQuest(Quest1) && CheckAchievement(Achievement), + ItemUnlockManager.UnlockType.Quest1 | ItemUnlockManager.UnlockType.Quest2 | ItemUnlockManager.UnlockType.Achievement => CheckQuest(Quest1) + && CheckQuest(Quest2) + && CheckAchievement(Achievement), + ItemUnlockManager.UnlockType.Cabinet => uiState->Cabinet.IsCabinetLoaded() && uiState->Cabinet.IsItemInCabinet((int)Quest1), + _ => false, + }; + } +}