From 0c1dd50890326189447e3012bdd5b2b7ce5f970e Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Thu, 28 Dec 2023 19:01:28 +0100 Subject: [PATCH] Add NPC appearance tab. --- Glamourer/Designs/DesignColors.cs | 15 +- Glamourer/Designs/DesignConverter.cs | 19 +- Glamourer/GameData/NpcCustomizeSet.cs | 13 +- Glamourer/Gui/Colors.cs | 6 +- .../CustomizationDrawer.GenderRace.cs | 7 +- Glamourer/Gui/Equipment/EquipDrawData.cs | 1 + Glamourer/Gui/MainWindow.cs | 10 +- .../Gui/Tabs/DebugTab/NpcAppearancePanel.cs | 9 +- .../Gui/Tabs/NpcTab/LocalNpcAppearanceData.cs | 141 +++++++++ Glamourer/Gui/Tabs/NpcTab/NpcFilter.cs | 45 +++ Glamourer/Gui/Tabs/NpcTab/NpcPanel.cs | 294 ++++++++++++++++++ Glamourer/Gui/Tabs/NpcTab/NpcSelector.cs | 95 ++++++ Glamourer/Gui/Tabs/NpcTab/NpcTab.cs | 19 ++ Glamourer/Gui/ToggleDrawData.cs | 19 ++ Glamourer/Gui/UiHelpers.cs | 16 +- Glamourer/Services/FilenameService.cs | 3 +- Glamourer/Services/ServiceManager.cs | 5 + OtterGui | 2 +- 18 files changed, 684 insertions(+), 35 deletions(-) create mode 100644 Glamourer/Gui/Tabs/NpcTab/LocalNpcAppearanceData.cs create mode 100644 Glamourer/Gui/Tabs/NpcTab/NpcFilter.cs create mode 100644 Glamourer/Gui/Tabs/NpcTab/NpcPanel.cs create mode 100644 Glamourer/Gui/Tabs/NpcTab/NpcSelector.cs create mode 100644 Glamourer/Gui/Tabs/NpcTab/NpcTab.cs diff --git a/Glamourer/Designs/DesignColors.cs b/Glamourer/Designs/DesignColors.cs index dc36e78..2a4f3f7 100644 --- a/Glamourer/Designs/DesignColors.cs +++ b/Glamourer/Designs/DesignColors.cs @@ -26,9 +26,9 @@ public class DesignColorUi public DesignColorUi(DesignColors colors, DesignManager designs, Configuration config) { - _colors = colors; - _designs = designs; - _config = config; + _colors = colors; + _designs = designs; + _config = config; } public void Draw() @@ -78,7 +78,7 @@ public class DesignColorUi { using var id = ImRaii.PushId(idx); ImGui.TableNextColumn(); - + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), buttonSize, tt, disabled, true)) { changeString = name; @@ -119,7 +119,7 @@ public class DesignColorUi changeString = _newName; changeValue = 0xFFFFFFFF; } - + if (changeString.Length > 0) { @@ -139,6 +139,7 @@ public class DesignColorUi newColor = color; return false; } + ImGuiUtil.HoverTooltip(tooltip); newColor = ImGui.ColorConvertFloat4ToU32(vec); @@ -148,12 +149,12 @@ public class DesignColorUi public class DesignColors : ISavable, IReadOnlyDictionary { - public const string AutomaticName = "Automatic"; + public const string AutomaticName = "Automatic"; public const string MissingColorName = "Missing Color"; public const uint MissingColorDefault = 0xFF0000D0; private readonly SaveService _saveService; - private readonly Dictionary _colors = new(); + private readonly Dictionary _colors = []; public uint MissingColor { get; private set; } = MissingColorDefault; public event Action? ColorChanged; diff --git a/Glamourer/Designs/DesignConverter.cs b/Glamourer/Designs/DesignConverter.cs index f7867c4..cd8924f 100644 --- a/Glamourer/Designs/DesignConverter.cs +++ b/Glamourer/Designs/DesignConverter.cs @@ -39,12 +39,18 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi => ShareBase64(state, EquipFlagExtensions.All, CustomizeFlagExtensions.All, CrestExtensions.All); public string ShareBase64(ActorState state, EquipFlag equipFlags, CustomizeFlag customizeFlags, CrestFlag crestFlags) + => ShareBase64(state.ModelData, equipFlags, customizeFlags, crestFlags); + + public string ShareBase64(in DesignData data, EquipFlag equipFlags, CustomizeFlag customizeFlags, CrestFlag crestFlags) { - var design = Convert(state, equipFlags, customizeFlags, crestFlags); + var design = Convert(data, equipFlags, customizeFlags, crestFlags); return ShareBase64(ShareJObject(design)); } public DesignBase Convert(ActorState state, EquipFlag equipFlags, CustomizeFlag customizeFlags, CrestFlag crestFlags) + => Convert(state.ModelData, equipFlags, customizeFlags, crestFlags); + + public DesignBase Convert(in DesignData data, EquipFlag equipFlags, CustomizeFlag customizeFlags, CrestFlag crestFlags) { var design = _designs.CreateTemporary(); design.ApplyEquip = equipFlags & EquipFlagExtensions.All; @@ -54,7 +60,7 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi design.SetApplyVisorToggle(design.DoApplyEquip(EquipSlot.Head)); design.SetApplyWeaponVisible(design.DoApplyEquip(EquipSlot.MainHand) || design.DoApplyEquip(EquipSlot.OffHand)); design.SetApplyWetness(true); - design.SetDesignData(_customize, state.ModelData); + design.SetDesignData(_customize, data); return design; } @@ -144,7 +150,7 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi } public IEnumerable<(EquipSlot Slot, EquipItem Item, StainId Stain)> FromDrawData(IReadOnlyList armors, - CharacterWeapon mainhand, CharacterWeapon offhand) + CharacterWeapon mainhand, CharacterWeapon offhand, bool skipWarnings) { if (armors.Count != 10) throw new ArgumentException("Invalid length of armor array."); @@ -156,7 +162,8 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi var item = _items.Identify(slot, armor.Set, armor.Variant); if (!item.Valid) { - Glamourer.Log.Warning($"Appearance data {armor} for slot {slot} invalid, item could not be identified."); + if (!skipWarnings) + Glamourer.Log.Warning($"Appearance data {armor} for slot {slot} invalid, item could not be identified."); item = ItemManager.NothingItem(slot); } @@ -164,7 +171,7 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi } var mh = _items.Identify(EquipSlot.MainHand, mainhand.Skeleton, mainhand.Weapon, mainhand.Variant); - if (!mh.Valid) + if (!skipWarnings && !mh.Valid) { Glamourer.Log.Warning($"Appearance data {mainhand} for mainhand weapon invalid, item could not be identified."); mh = _items.DefaultSword; @@ -173,7 +180,7 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi yield return (EquipSlot.MainHand, mh, mainhand.Stain); var oh = _items.Identify(EquipSlot.OffHand, offhand.Skeleton, offhand.Weapon, offhand.Variant, mh.Type); - if (!oh.Valid) + if (!skipWarnings && !oh.Valid) { Glamourer.Log.Warning($"Appearance data {offhand} for offhand weapon invalid, item could not be identified."); oh = _items.GetDefaultOffhand(mh); diff --git a/Glamourer/GameData/NpcCustomizeSet.cs b/Glamourer/GameData/NpcCustomizeSet.cs index bcd6c54..d364c1e 100644 --- a/Glamourer/GameData/NpcCustomizeSet.cs +++ b/Glamourer/GameData/NpcCustomizeSet.cs @@ -161,7 +161,7 @@ public class NpcCustomizeSet : IAsyncDataContainer, IReadOnlyList // Convert the NPCs to a dictionary of lists grouped by name. var groups = eNpcEquip.Concat(bNpcEquip).GroupBy(d => d.Name).ToDictionary(g => g.Key, g => g.ToList()); // Iterate through the sorted list. - foreach (var (name, duplicates) in groups.OrderBy(kvp => kvp.Key)) + foreach (var (_, duplicates) in groups.OrderBy(kvp => kvp.Key)) { // Remove any duplicate entries for a name with identical data. for (var i = 0; i < duplicates.Count; ++i) @@ -177,7 +177,7 @@ public class NpcCustomizeSet : IAsyncDataContainer, IReadOnlyList } } - // If there is only a single entry, add that. This does not take additional string memory through interning. + // If there is only a single entry, add that. if (duplicates.Count == 1) { _data.Add(duplicates[0]); @@ -185,13 +185,8 @@ public class NpcCustomizeSet : IAsyncDataContainer, IReadOnlyList } else { - // Add all distinct duplicates with their ID specified in the name. - _data.AddRange(duplicates - .Select(duplicate => duplicate with - { - Name = $"{name} ({(duplicate.Kind is ObjectKind.BattleNpc ? 'B' : 'E')}{duplicate.Id})", - })); - Memory += 96 * duplicates.Count + duplicates.Sum(d => d.Name.Length * 2); + _data.AddRange(duplicates); + Memory += 96 * duplicates.Count; } } diff --git a/Glamourer/Gui/Colors.cs b/Glamourer/Gui/Colors.cs index 873ec38..7addc45 100644 --- a/Glamourer/Gui/Colors.cs +++ b/Glamourer/Gui/Colors.cs @@ -28,6 +28,8 @@ public enum ColorId TriStateCheck, TriStateCross, TriStateNeutral, + BattleNpc, + EventNpc, } public static class Colors @@ -60,7 +62,9 @@ public static class Colors ColorId.QuickDesignBg => (0x00F0F0F0, "Quick Design Bar Window Background", "The color of the window background in the quick design bar." ), ColorId.TriStateCheck => (0xFF00D000, "Checkmark in Tri-State Checkboxes", "The color of the checkmark indicating positive change in tri-state checkboxes." ), ColorId.TriStateCross => (0xFF0000D0, "Cross in Tri-State Checkboxes", "The color of the cross indicating negative change in tri-state checkboxes." ), - ColorId.TriStateNeutral => (0xFFD0D0D0, "Dot in Tri-State Checkboxes", "The color of the dot indicating no change in tri-state checkboxes" ), + ColorId.TriStateNeutral => (0xFFD0D0D0, "Dot in Tri-State Checkboxes", "The color of the dot indicating no change in tri-state checkboxes." ), + ColorId.BattleNpc => (0xFFFFFFFF, "Battle NPC in NPC Tab", "The color of the names of battle NPCs in the NPC tab that do not have a more specific color assigned." ), + ColorId.EventNpc => (0xFFFFFFFF, "Event NPC in NPC Tab", "The color of the names of event NPCs in the NPC tab that do not have a more specific color assigned." ), _ => (0x00000000, string.Empty, string.Empty ), // @formatter:on }; diff --git a/Glamourer/Gui/Customization/CustomizationDrawer.GenderRace.cs b/Glamourer/Gui/Customization/CustomizationDrawer.GenderRace.cs index 4b9fab7..a50424c 100644 --- a/Glamourer/Gui/Customization/CustomizationDrawer.GenderRace.cs +++ b/Glamourer/Gui/Customization/CustomizationDrawer.GenderRace.cs @@ -82,8 +82,11 @@ public partial class CustomizationDrawer if (_customize.BodyType.Value == 1) return; - if (!ImGuiUtil.DrawDisabledButton($"Reset Body Type {_customize.BodyType.Value} to Default", - new Vector2(_raceSelectorWidth + _framedIconSize.X + ImGui.GetStyle().ItemSpacing.X, 0), string.Empty, _lockedRedraw)) + var label = _lockedRedraw + ? $"Body Type {_customize.BodyType.Value}" + : $"Reset Body Type {_customize.BodyType.Value} to Default"; + if (!ImGuiUtil.DrawDisabledButton(label, new Vector2(_raceSelectorWidth + _framedIconSize.X + ImGui.GetStyle().ItemSpacing.X, 0), + string.Empty, _lockedRedraw)) return; Changed |= CustomizeFlag.BodyType; diff --git a/Glamourer/Gui/Equipment/EquipDrawData.cs b/Glamourer/Gui/Equipment/EquipDrawData.cs index 8ea3972..1edd4ce 100644 --- a/Glamourer/Gui/Equipment/EquipDrawData.cs +++ b/Glamourer/Gui/Equipment/EquipDrawData.cs @@ -1,6 +1,7 @@ using System; using Glamourer.Designs; using Glamourer.Events; +using Glamourer.Services; using Glamourer.State; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; diff --git a/Glamourer/Gui/MainWindow.cs b/Glamourer/Gui/MainWindow.cs index a219134..565ef84 100644 --- a/Glamourer/Gui/MainWindow.cs +++ b/Glamourer/Gui/MainWindow.cs @@ -10,6 +10,7 @@ using Glamourer.Gui.Tabs.ActorTab; using Glamourer.Gui.Tabs.AutomationTab; using Glamourer.Gui.Tabs.DebugTab; using Glamourer.Gui.Tabs.DesignTab; +using Glamourer.Gui.Tabs.NpcTab; using Glamourer.Gui.Tabs.UnlocksTab; using ImGuiNET; using OtterGui.Custom; @@ -29,6 +30,7 @@ public class MainWindow : Window, IDisposable Automation = 4, Unlocks = 5, Messages = 6, + Npcs = 7, } private readonly Configuration _config; @@ -42,12 +44,14 @@ public class MainWindow : Window, IDisposable public readonly DesignTab Designs; public readonly AutomationTab Automation; public readonly UnlocksTab Unlocks; + public readonly NpcTab Npcs; public readonly MessagesTab Messages; public TabType SelectTab = TabType.None; public MainWindow(DalamudPluginInterface pi, Configuration config, SettingsTab settings, ActorTab actors, DesignTab designs, - DebugTab debugTab, AutomationTab automation, UnlocksTab unlocks, TabSelected @event, MessagesTab messages, DesignQuickBar quickBar) + DebugTab debugTab, AutomationTab automation, UnlocksTab unlocks, TabSelected @event, MessagesTab messages, DesignQuickBar quickBar, + NpcTab npcs) : base(GetLabel()) { pi.UiBuilder.DisableGposeUiHide = true; @@ -65,6 +69,7 @@ public class MainWindow : Window, IDisposable _event = @event; Messages = messages; _quickBar = quickBar; + Npcs = npcs; _config = config; _tabs = [ @@ -73,6 +78,7 @@ public class MainWindow : Window, IDisposable designs, automation, unlocks, + npcs, messages, debugTab, ]; @@ -117,6 +123,7 @@ public class MainWindow : Window, IDisposable TabType.Automation => Automation.Label, TabType.Unlocks => Unlocks.Label, TabType.Messages => Messages.Label, + TabType.Npcs => Npcs.Label, _ => ReadOnlySpan.Empty, }; @@ -128,6 +135,7 @@ public class MainWindow : Window, IDisposable if (label == Settings.Label) return TabType.Settings; if (label == Automation.Label) return TabType.Automation; if (label == Unlocks.Label) return TabType.Unlocks; + if (label == Npcs.Label) return TabType.Npcs; if (label == Messages.Label) return TabType.Messages; if (label == Debug.Label) return TabType.Debug; // @formatter:on diff --git a/Glamourer/Gui/Tabs/DebugTab/NpcAppearancePanel.cs b/Glamourer/Gui/Tabs/DebugTab/NpcAppearancePanel.cs index e0c7aa8..2ebf215 100644 --- a/Glamourer/Gui/Tabs/DebugTab/NpcAppearancePanel.cs +++ b/Glamourer/Gui/Tabs/DebugTab/NpcAppearancePanel.cs @@ -35,7 +35,7 @@ public class NpcAppearancePanel(NpcCombo _npcCombo, StateManager _state, ObjectM ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); var resetScroll = ImGui.InputTextWithHint("##npcFilter", "Filter...", ref _npcFilter, 64); - using var table = ImRaii.Table("npcs", 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, + using var table = ImRaii.Table("npcs", 6, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, new Vector2(-1, 400 * ImGuiHelpers.GlobalScale)); if (!table) return; @@ -46,6 +46,7 @@ public class NpcAppearancePanel(NpcCombo _npcCombo, StateManager _state, ObjectM ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthFixed, ImGuiHelpers.GlobalScale * 300); ImGui.TableSetupColumn("Kind", ImGuiTableColumnFlags.WidthFixed); + ImGui.TableSetupColumn("Id", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("Visor", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("Compare", ImGuiTableColumnFlags.WidthStretch); @@ -66,7 +67,7 @@ public class NpcAppearancePanel(NpcCombo _npcCombo, StateManager _state, ObjectM ImGui.TableNextColumn(); if (ImGuiUtil.DrawDisabledButton("Apply", Vector2.Zero, string.Empty, disabled)) { - foreach (var (slot, item, stain) in _designConverter.FromDrawData(data.Equip.ToArray(), data.Mainhand, data.Offhand)) + foreach (var (slot, item, stain) in _designConverter.FromDrawData(data.Equip.ToArray(), data.Mainhand, data.Offhand, true)) _state.ChangeEquip(state!, slot, item, stain, StateChanged.Source.Manual); _state.ChangeVisorState(state!, data.VisorToggled, StateChanged.Source.Manual); _state.ChangeCustomize(state!, data.Customize, CustomizeFlagExtensions.All, StateChanged.Source.Manual); @@ -80,6 +81,10 @@ public class NpcAppearancePanel(NpcCombo _npcCombo, StateManager _state, ObjectM ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted(data.Kind is ObjectKind.BattleNpc ? "B" : "E"); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(data.Id.Id.ToString()); + using (_ = ImRaii.PushFont(UiBuilder.IconFont)) { ImGui.TableNextColumn(); diff --git a/Glamourer/Gui/Tabs/NpcTab/LocalNpcAppearanceData.cs b/Glamourer/Gui/Tabs/NpcTab/LocalNpcAppearanceData.cs new file mode 100644 index 0000000..bb22a85 --- /dev/null +++ b/Glamourer/Gui/Tabs/NpcTab/LocalNpcAppearanceData.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using Glamourer.Designs; +using Glamourer.GameData; +using Glamourer.Services; +using ImGuiNET; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Glamourer.Gui.Tabs.NpcTab; + +public class LocalNpcAppearanceData : ISavable +{ + private readonly DesignColors _colors; + + public record struct Data(string Color = "", bool Favorite = false); + + private readonly Dictionary _data = []; + + public LocalNpcAppearanceData(DesignColors colors, SaveService saveService) + { + _colors = colors; + Load(saveService); + DataChanged += () => saveService.QueueSave(this); + } + + public bool IsFavorite(in NpcData data) + => _data.TryGetValue(ToKey(data), out var tuple) && tuple.Favorite; + + public (uint Color, bool Favorite) GetData(in NpcData data) + => _data.TryGetValue(ToKey(data), out var t) + ? (GetColor(t.Color, t.Favorite, data.Kind), t.Favorite) + : (GetColor(string.Empty, false, data.Kind), false); + + public string GetColor(in NpcData data) + => _data.TryGetValue(ToKey(data), out var t) ? t.Color : string.Empty; + + private uint GetColor(string color, bool favorite, ObjectKind kind) + { + if (color.Length == 0) + { + if (favorite) + return ColorId.FavoriteStarOn.Value(); + + return kind is ObjectKind.BattleNpc + ? ColorId.BattleNpc.Value() + : ColorId.EventNpc.Value(); + } + + if (_colors.TryGetValue(color, out var value)) + return value == 0 ? ImGui.GetColorU32(ImGuiCol.Text) : value; + + return _colors.MissingColor; + } + + public void ToggleFavorite(in NpcData data) + { + var key = ToKey(data); + if (_data.TryGetValue(key, out var t)) + { + if (t is { Color: "", Favorite: true }) + _data.Remove(key); + else + _data[key] = t with { Favorite = !t.Favorite }; + } + else + { + _data[key] = new Data(string.Empty, true); + } + + DataChanged.Invoke(); + } + + public void SetColor(in NpcData data, string color) + { + var key = ToKey(data); + if (_data.TryGetValue(key, out var t)) + { + if (!t.Favorite && color.Length == 0) + _data.Remove(key); + else + _data[key] = t with { Color = color }; + } + else if (color.Length != 0) + { + _data[key] = new Data(color); + } + + DataChanged.Invoke(); + } + + private static ulong ToKey(in NpcData data) + => (byte)data.Kind | ((ulong)data.Id.Id << 8); + + public event Action DataChanged = null!; + + public string ToFilename(FilenameService fileNames) + => fileNames.NpcAppearanceFile; + + public void Save(StreamWriter writer) + { + var jObj = new JObject() + { + ["Version"] = 1, + ["Data"] = JToken.FromObject(_data), + }; + using var j = new JsonTextWriter(writer); + j.Formatting = Formatting.Indented; + jObj.WriteTo(j); + } + + private void Load(SaveService save) + { + var file = save.FileNames.NpcAppearanceFile; + if (!File.Exists(file)) + return; + + try + { + var text = File.ReadAllText(file); + var jObj = JObject.Parse(text); + var version = jObj["Version"]?.ToObject() ?? 0; + switch (version) + { + case 1: + var data = jObj["Data"]?.ToObject>() ?? []; + _data.EnsureCapacity(data.Count); + foreach (var kvp in data) + _data.Add(kvp.Key, kvp.Value); + return; + default: throw new Exception("Invalid version {version}."); + } + } + catch (Exception ex) + { + Glamourer.Log.Error($"Could not read local NPC appearance data:\n{ex}"); + } + } +} diff --git a/Glamourer/Gui/Tabs/NpcTab/NpcFilter.cs b/Glamourer/Gui/Tabs/NpcTab/NpcFilter.cs new file mode 100644 index 0000000..f57985b --- /dev/null +++ b/Glamourer/Gui/Tabs/NpcTab/NpcFilter.cs @@ -0,0 +1,45 @@ +using System; +using Glamourer.Designs; +using Glamourer.GameData; +using OtterGui.Classes; + +namespace Glamourer.Gui.Tabs.NpcTab; + +public sealed class NpcFilter(LocalNpcAppearanceData _favorites) : FilterUtility +{ + protected override string Tooltip + => "Filter NPC appearances for those where their names contain the given substring.\n" + + "Enter i:[number] to filter for NPCs of certain IDs.\n" + + "Enter c:[string] to filter for NPC appearances set to specific colors."; + + protected override (LowerString, long, int) FilterChange(string input) + => input.Length switch + { + 0 => (LowerString.Empty, 0, -1), + > 1 when input[1] == ':' => + input[0] switch + { + 'i' or 'I' => input.Length == 2 ? (LowerString.Empty, 0, -1) : + long.TryParse(input.AsSpan(2), out var r) ? (LowerString.Empty, r, 1) : (LowerString.Empty, 0, -1), + 'c' or 'C' => input.Length == 2 ? (LowerString.Empty, 0, -1) : (new LowerString(input[2..]), 0, 2), + _ => (new LowerString(input), 0, 0), + }, + _ => (new LowerString(input), 0, 0), + }; + + public override bool ApplyFilter(in NpcData value) + => FilterMode switch + { + -1 => false, + 0 => Filter.IsContained(value.Name), + 1 => value.Id.Id == NumericalFilter, + 2 => Filter.IsContained(GetColor(value)), + _ => false, // Should never happen + }; + + private string GetColor(in NpcData value) + { + var color = _favorites.GetColor(value); + return color.Length == 0 ? DesignColors.AutomaticName : color; + } +} diff --git a/Glamourer/Gui/Tabs/NpcTab/NpcPanel.cs b/Glamourer/Gui/Tabs/NpcTab/NpcPanel.cs new file mode 100644 index 0000000..cd88f97 --- /dev/null +++ b/Glamourer/Gui/Tabs/NpcTab/NpcPanel.cs @@ -0,0 +1,294 @@ +using System; +using System.Linq; +using System.Numerics; +using Dalamud.Interface; +using Dalamud.Interface.Internal.Notifications; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using Glamourer.Designs; +using Glamourer.Events; +using Glamourer.Gui.Customization; +using Glamourer.Gui.Equipment; +using Glamourer.Gui.Tabs.DesignTab; +using Glamourer.Interop; +using Glamourer.State; +using ImGuiNET; +using Lumina.Data.Parsing.Scd; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using Penumbra.GameData.Enums; + +namespace Glamourer.Gui.Tabs.NpcTab; + +public class NpcPanel( + NpcSelector _selector, + LocalNpcAppearanceData _favorites, + CustomizationDrawer _customizeDrawer, + EquipmentDrawer _equipDrawer, + DesignConverter _converter, + DesignManager _designManager, + StateManager _state, + ObjectManager _objects, + DesignColors _colors) +{ + private readonly DesignColorCombo _colorCombo = new(_colors, true); + private string _newName = string.Empty; + private DesignBase? _newDesign; + + public void Draw() + { + using var group = ImRaii.Group(); + + DrawHeader(); + DrawPanel(); + } + + private void DrawHeader() + { + HeaderDrawer.Draw(_selector.HasSelection ? _selector.Selection.Name : "No Selection", ColorId.NormalDesign.Value(), + ImGui.GetColorU32(ImGuiCol.FrameBg), 2, ExportToClipboardButton(), SaveAsDesignButton(), FavoriteButton()); + SaveDesignDrawPopup(); + } + + private HeaderDrawer.Button FavoriteButton() + { + var (desc, color) = _favorites.IsFavorite(_selector.Selection) + ? ("Remove this NPC appearance from your favorites.", ColorId.FavoriteStarOn.Value()) + : ("Add this NPC Appearance to your favorites.", 0x80000000); + return new HeaderDrawer.Button + { + Icon = FontAwesomeIcon.Star, + OnClick = () => _favorites.ToggleFavorite(_selector.Selection), + Visible = _selector.HasSelection, + Description = desc, + TextColor = color, + }; + } + + private HeaderDrawer.Button ExportToClipboardButton() + => new() + { + Description = + "Copy the current NPCs appearance to your clipboard.\nHold Control to disable applying of customizations for the copied design.\nHold Shift to disable applying of gear for the copied design.", + Icon = FontAwesomeIcon.Copy, + OnClick = ExportToClipboard, + Visible = _selector.HasSelection, + }; + + private HeaderDrawer.Button SaveAsDesignButton() + => new() + { + Description = + "Save this NPCs appearance as a design.\nHold Control to disable applying of customizations for the saved design.\nHold Shift to disable applying of gear for the saved design.", + Icon = FontAwesomeIcon.Save, + OnClick = SaveDesignOpen, + Visible = _selector.HasSelection, + }; + + private void ExportToClipboard() + { + try + { + var (applyGear, applyCustomize, applyCrest) = UiHelpers.ConvertKeysToFlags(); + var data = ToDesignData(); + var text = _converter.ShareBase64(data, applyGear, applyCustomize, applyCrest); + ImGui.SetClipboardText(text); + } + catch (Exception ex) + { + Glamourer.Messager.NotificationMessage(ex, $"Could not copy {_selector.Selection.Name}'s data to clipboard.", + $"Could not copy data from NPC appearance {_selector.Selection.Kind} {_selector.Selection.Id.Id} to clipboard", + NotificationType.Error); + } + } + + private void SaveDesignOpen() + { + ImGui.OpenPopup("Save as Design"); + _newName = _selector.Selection.Name; + var (applyGear, applyCustomize, applyCrest) = UiHelpers.ConvertKeysToFlags(); + + var data = ToDesignData(); + _newDesign = _converter.Convert(data, applyGear, applyCustomize, applyCrest); + } + + private void SaveDesignDrawPopup() + { + if (!ImGuiUtil.OpenNameField("Save as Design", ref _newName)) + return; + + if (_newDesign != null && _newName.Length > 0) + _designManager.CreateClone(_newDesign, _newName, true); + _newDesign = null; + _newName = string.Empty; + } + + private void DrawPanel() + { + using var child = ImRaii.Child("##Panel", -Vector2.One, true); + if (!child || !_selector.HasSelection) + return; + + DrawButtonRow(); + DrawCustomization(); + DrawEquipment(); + DrawAppearanceInfo(); + } + + private void DrawButtonRow() + { + DrawApplyToSelf(); + ImGui.SameLine(); + DrawApplyToTarget(); + } + + private void DrawCustomization() + { + if (!ImGui.CollapsingHeader("Customization")) + return; + + _customizeDrawer.Draw(_selector.Selection.Customize, true, true); + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + } + + private void DrawEquipment() + { + if (!ImGui.CollapsingHeader("Equipment")) + return; + + _equipDrawer.Prepare(); + var designData = ToDesignData(); + + foreach (var slot in EquipSlotExtensions.EqdpSlots) + { + var data = new EquipDrawData(slot, designData) { Locked = true }; + _equipDrawer.DrawEquip(data); + } + + var mainhandData = new EquipDrawData(EquipSlot.MainHand, designData) { Locked = true }; + var offhandData = new EquipDrawData(EquipSlot.OffHand, designData) { Locked = true }; + _equipDrawer.DrawWeapons(mainhandData, offhandData, false); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + EquipmentDrawer.DrawMetaToggle(ToggleDrawData.FromValue(ActorState.MetaIndex.VisorState, _selector.Selection.VisorToggled)); + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + } + + private DesignData ToDesignData() + { + var selection = _selector.Selection; + var items = _converter.FromDrawData(selection.Equip.ToArray(), selection.Mainhand, selection.Offhand, true).ToArray(); + var designData = new DesignData { Customize = selection.Customize }; + foreach (var (slot, item, stain) in items) + { + designData.SetItem(slot, item); + designData.SetStain(slot, stain); + } + + return designData; + } + + private void DrawApplyToSelf() + { + var (id, data) = _objects.PlayerData; + if (!ImGuiUtil.DrawDisabledButton("Apply to Yourself", Vector2.Zero, + "Apply the current NPC appearance to your character.\nHold Control to only apply gear.\nHold Shift to only apply customizations.", + !data.Valid)) + return; + + if (_state.GetOrCreate(id, data.Objects[0], out var state)) + { + var (applyGear, applyCustomize, applyCrest) = UiHelpers.ConvertKeysToFlags(); + var design = _converter.Convert(ToDesignData(), applyGear, applyCustomize, applyCrest); + _state.ApplyDesign(design, state, StateChanged.Source.Manual); + } + } + + private void DrawApplyToTarget() + { + var (id, data) = _objects.TargetData; + var tt = id.IsValid + ? data.Valid + ? "Apply the current NPC appearance to your current target.\nHold Control to only apply gear.\nHold Shift to only apply customizations." + : "The current target can not be manipulated." + : "No valid target selected."; + if (!ImGuiUtil.DrawDisabledButton("Apply to Target", Vector2.Zero, tt, !data.Valid)) + return; + + if (_state.GetOrCreate(id, data.Objects[0], out var state)) + { + var (applyGear, applyCustomize, applyCrest) = UiHelpers.ConvertKeysToFlags(); + var design = _converter.Convert(ToDesignData(), applyGear, applyCustomize, applyCrest); + _state.ApplyDesign(design, state, StateChanged.Source.Manual); + } + } + + + private void DrawAppearanceInfo() + { + if (!ImGui.CollapsingHeader("Appearance Details")) + return; + + using var table = ImRaii.Table("Details", 2); + if (!table) + return; + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); + ImGui.TableSetupColumn("Type", ImGuiTableColumnFlags.WidthFixed, ImGui.CalcTextSize("Last Update Datem").X); + ImGui.TableSetupColumn("Data", ImGuiTableColumnFlags.WidthStretch); + + var selection = _selector.Selection; + CopyButton("NPC Name", selection.Name); + CopyButton("NPC ID", selection.Id.Id.ToString()); + ImGuiUtil.DrawFrameColumn("NPC Type"); + ImGui.TableNextColumn(); + var width = ImGui.GetContentRegionAvail().X; + ImGuiUtil.DrawTextButton(selection.Kind is ObjectKind.BattleNpc ? "Battle NPC" : "Event NPC", new Vector2(width, 0), + ImGui.GetColorU32(ImGuiCol.FrameBg)); + + ImGuiUtil.DrawFrameColumn("Color"); + var color = _favorites.GetColor(selection); + var colorName = color.Length == 0 ? DesignColors.AutomaticName : color; + ImGui.TableNextColumn(); + if (_colorCombo.Draw("##colorCombo", colorName, + "Associate a color with this NPC appearance. Right-Click to revert to automatic coloring.", + width - ImGui.GetStyle().ItemSpacing.X - ImGui.GetFrameHeight(), ImGui.GetTextLineHeight()) + && _colorCombo.CurrentSelection != null) + { + color = _colorCombo.CurrentSelection is DesignColors.AutomaticName ? string.Empty : _colorCombo.CurrentSelection; + _favorites.SetColor(selection, color); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _favorites.SetColor(selection, string.Empty); + color = string.Empty; + } + + if (_colors.TryGetValue(color, out var currentColor)) + { + ImGui.SameLine(); + if (DesignColorUi.DrawColorButton($"Color associated with {color}", currentColor, out var newColor)) + _colors.SetColor(color, newColor); + } + else if (color.Length != 0) + { + ImGui.SameLine(); + var size = new Vector2(ImGui.GetFrameHeight()); + using var font = ImRaii.PushFont(UiBuilder.IconFont); + ImGuiUtil.DrawTextButton(FontAwesomeIcon.ExclamationCircle.ToIconString(), size, 0, _colors.MissingColor); + ImGuiUtil.HoverTooltip("The color associated with this design does not exist."); + } + + return; + + static void CopyButton(string label, string text) + { + ImGuiUtil.DrawFrameColumn(label); + ImGui.TableNextColumn(); + if (ImGui.Button(text, new Vector2(ImGui.GetContentRegionAvail().X, 0))) + ImGui.SetClipboardText(text); + ImGuiUtil.HoverTooltip("Click to copy to clipboard."); + } + } +} diff --git a/Glamourer/Gui/Tabs/NpcTab/NpcSelector.cs b/Glamourer/Gui/Tabs/NpcTab/NpcSelector.cs new file mode 100644 index 0000000..10d2264 --- /dev/null +++ b/Glamourer/Gui/Tabs/NpcTab/NpcSelector.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Glamourer.GameData; +using ImGuiNET; +using OtterGui; +using OtterGui.Raii; +using ImGuiClip = OtterGui.ImGuiClip; + +namespace Glamourer.Gui.Tabs.NpcTab; + +public class NpcSelector : IDisposable +{ + private readonly NpcCustomizeSet _npcs; + private readonly LocalNpcAppearanceData _favorites; + + private NpcFilter _filter; + private readonly List _visibleOrdered = []; + private int _selectedGlobalIndex; + private bool _listDirty = true; + private Vector2 _defaultItemSpacing; + private float _width; + + + public NpcSelector(NpcCustomizeSet npcs, LocalNpcAppearanceData favorites) + { + _npcs = npcs; + _favorites = favorites; + _filter = new NpcFilter(_favorites); + _favorites.DataChanged += OnFavoriteChange; + } + + public void Dispose() + { + _favorites.DataChanged -= OnFavoriteChange; + } + + private void OnFavoriteChange() + => _listDirty = true; + + public void UpdateList() + { + if (!_listDirty) + return; + + _listDirty = false; + _visibleOrdered.Clear(); + var enumerable = _npcs.WithIndex(); + if (!_filter.IsEmpty) + enumerable = enumerable.Where(d => _filter.ApplyFilter(d.Value)); + var range = enumerable.OrderByDescending(d => _favorites.IsFavorite(d.Value)) + .ThenBy(d => d.Index) + .Select(d => d.Index); + _visibleOrdered.AddRange(range); + } + + public bool HasSelection + => _selectedGlobalIndex >= 0 && _selectedGlobalIndex < _npcs.Count; + + public NpcData Selection + => HasSelection ? _npcs[_selectedGlobalIndex] : default; + + public void Draw(float width) + { + _width = width; + using var group = ImRaii.Group(); + _defaultItemSpacing = ImGui.GetStyle().ItemSpacing; + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .Push(ImGuiStyleVar.FrameRounding, 0); + + if (_filter.Draw(width)) + _listDirty = true; + UpdateList(); + DrawSelector(); + } + + private void DrawSelector() + { + using var child = ImRaii.Child("##Selector", new Vector2(_width, ImGui.GetContentRegionAvail().Y), true); + if (!child) + return; + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, _defaultItemSpacing); + ImGuiClip.ClippedDraw(_visibleOrdered, DrawSelectable, ImGui.GetTextLineHeight()); + } + + private void DrawSelectable(int globalIndex) + { + using var id = ImRaii.PushId(globalIndex); + using var color = ImRaii.PushColor(ImGuiCol.Text, _favorites.GetData(_npcs[globalIndex]).Color); + if (ImGui.Selectable(_npcs[globalIndex].Name, _selectedGlobalIndex == globalIndex, ImGuiSelectableFlags.AllowItemOverlap)) + _selectedGlobalIndex = globalIndex; + } +} diff --git a/Glamourer/Gui/Tabs/NpcTab/NpcTab.cs b/Glamourer/Gui/Tabs/NpcTab/NpcTab.cs new file mode 100644 index 0000000..fb64227 --- /dev/null +++ b/Glamourer/Gui/Tabs/NpcTab/NpcTab.cs @@ -0,0 +1,19 @@ +using System; +using Dalamud.Interface.Utility; +using ImGuiNET; +using OtterGui.Widgets; + +namespace Glamourer.Gui.Tabs.NpcTab; + +public class NpcTab(NpcSelector _selector, NpcPanel _panel) : ITab +{ + public ReadOnlySpan Label + => "NPCs"u8; + + public void DrawContent() + { + _selector.Draw(200 * ImGuiHelpers.GlobalScale); + ImGui.SameLine(); + _panel.Draw(); + } +} diff --git a/Glamourer/Gui/ToggleDrawData.cs b/Glamourer/Gui/ToggleDrawData.cs index 5e7e813..3991893 100644 --- a/Glamourer/Gui/ToggleDrawData.cs +++ b/Glamourer/Gui/ToggleDrawData.cs @@ -100,4 +100,23 @@ public ref struct ToggleDrawData SetValue = setValue, }; } + + public static ToggleDrawData FromValue(ActorState.MetaIndex index, bool value) + { + var (label, tooltip) = index switch + { + ActorState.MetaIndex.HatState => ("Hat Visible", "Hide or show the characters head gear."), + ActorState.MetaIndex.VisorState => ("Visor Toggled", "Toggle the visor state of the characters head gear."), + ActorState.MetaIndex.WeaponState => ("Weapon Visible", "Hide or show the characters weapons when not drawn."), + ActorState.MetaIndex.Wetness => ("Force Wetness", "Force the character to be wet or not."), + _ => throw new Exception("Unsupported meta index."), + }; + return new ToggleDrawData + { + Label = label, + Tooltip = tooltip, + Locked = true, + CurrentValue = value, + }; + } } diff --git a/Glamourer/Gui/UiHelpers.cs b/Glamourer/Gui/UiHelpers.cs index d08fb18..2bc11c4 100644 --- a/Glamourer/Gui/UiHelpers.cs +++ b/Glamourer/Gui/UiHelpers.cs @@ -47,9 +47,15 @@ public static class UiHelpers public static bool DrawCheckbox(string label, string tooltip, bool value, out bool on, bool locked) { - using var disabled = ImRaii.Disabled(locked); - var ret = ImGuiUtil.Checkbox(label, string.Empty, value, v => value = v); - ImGuiUtil.HoverTooltip(tooltip); + bool ret; + using (var disabled = ImRaii.Disabled(locked)) + { + ret = ImGuiUtil.Checkbox("##" + label, string.Empty, value, v => value = v); + } + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + ImGuiUtil.HoverTooltip(tooltip, ImGuiHoveredFlags.AllowWhenDisabled); on = value; return ret; } @@ -58,7 +64,6 @@ public static class UiHelpers out bool newApply, bool locked) { var flags = (sbyte)(currentApply ? currentValue ? 1 : -1 : 0); - using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemInnerSpacing); using (_ = ImRaii.Disabled(locked)) { if (new TristateCheckbox(ColorId.TriStateCross.Value(), ColorId.TriStateCheck.Value(), ColorId.TriStateNeutral.Value()).Draw( @@ -80,7 +85,8 @@ public static class UiHelpers ImGuiUtil.HoverTooltip($"This attribute will be {(currentApply ? currentValue ? "enabled." : "disabled." : "kept as is.")}"); - ImGui.SameLine(); + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted(label); return (currentValue != newValue, currentApply != newApply); diff --git a/Glamourer/Services/FilenameService.cs b/Glamourer/Services/FilenameService.cs index 2ca573f..7f54f19 100644 --- a/Glamourer/Services/FilenameService.cs +++ b/Glamourer/Services/FilenameService.cs @@ -18,6 +18,7 @@ public class FilenameService public readonly string FavoriteFile; public readonly string DesignColorFile; public readonly string EphemeralConfigFile; + public readonly string NpcAppearanceFile; public FilenameService(DalamudPluginInterface pi) { @@ -32,9 +33,9 @@ public class FilenameService FavoriteFile = Path.Combine(ConfigDirectory, "favorites.json"); DesignColorFile = Path.Combine(ConfigDirectory, "design_colors.json"); EphemeralConfigFile = Path.Combine(ConfigDirectory, "ephemeral_config.json"); + NpcAppearanceFile = Path.Combine(ConfigDirectory, "npc_appearance_data.json"); } - public IEnumerable Designs() { if (!Directory.Exists(DesignDirectory)) diff --git a/Glamourer/Services/ServiceManager.cs b/Glamourer/Services/ServiceManager.cs index 9cbcd01..db3922c 100644 --- a/Glamourer/Services/ServiceManager.cs +++ b/Glamourer/Services/ServiceManager.cs @@ -11,6 +11,7 @@ using Glamourer.Gui.Tabs.ActorTab; using Glamourer.Gui.Tabs.AutomationTab; using Glamourer.Gui.Tabs.DebugTab; using Glamourer.Gui.Tabs.DesignTab; +using Glamourer.Gui.Tabs.NpcTab; using Glamourer.Gui.Tabs.UnlocksTab; using Glamourer.Interop; using Glamourer.Interop.Penumbra; @@ -131,6 +132,10 @@ public static class ServiceManagerA .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/OtterGui b/OtterGui index 15203ed..e58c3c1 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 15203edf1dba72713f508b798048c56ad969fb95 +Subproject commit e58c3c1240cda9d2d2b54f5ab7b8c729c1251fd4