Add NPC appearance tab.

This commit is contained in:
Ottermandias 2023-12-28 19:01:28 +01:00
parent 4b242bb3cf
commit 0c1dd50890
18 changed files with 684 additions and 35 deletions

View file

@ -26,9 +26,9 @@ public class DesignColorUi
public DesignColorUi(DesignColors colors, DesignManager designs, Configuration config) public DesignColorUi(DesignColors colors, DesignManager designs, Configuration config)
{ {
_colors = colors; _colors = colors;
_designs = designs; _designs = designs;
_config = config; _config = config;
} }
public void Draw() public void Draw()
@ -139,6 +139,7 @@ public class DesignColorUi
newColor = color; newColor = color;
return false; return false;
} }
ImGuiUtil.HoverTooltip(tooltip); ImGuiUtil.HoverTooltip(tooltip);
newColor = ImGui.ColorConvertFloat4ToU32(vec); newColor = ImGui.ColorConvertFloat4ToU32(vec);
@ -148,12 +149,12 @@ public class DesignColorUi
public class DesignColors : ISavable, IReadOnlyDictionary<string, uint> public class DesignColors : ISavable, IReadOnlyDictionary<string, uint>
{ {
public const string AutomaticName = "Automatic"; public const string AutomaticName = "Automatic";
public const string MissingColorName = "Missing Color"; public const string MissingColorName = "Missing Color";
public const uint MissingColorDefault = 0xFF0000D0; public const uint MissingColorDefault = 0xFF0000D0;
private readonly SaveService _saveService; private readonly SaveService _saveService;
private readonly Dictionary<string, uint> _colors = new(); private readonly Dictionary<string, uint> _colors = [];
public uint MissingColor { get; private set; } = MissingColorDefault; public uint MissingColor { get; private set; } = MissingColorDefault;
public event Action? ColorChanged; public event Action? ColorChanged;

View file

@ -39,12 +39,18 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi
=> ShareBase64(state, EquipFlagExtensions.All, CustomizeFlagExtensions.All, CrestExtensions.All); => ShareBase64(state, EquipFlagExtensions.All, CustomizeFlagExtensions.All, CrestExtensions.All);
public string ShareBase64(ActorState state, EquipFlag equipFlags, CustomizeFlag customizeFlags, CrestFlag crestFlags) 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)); return ShareBase64(ShareJObject(design));
} }
public DesignBase Convert(ActorState state, EquipFlag equipFlags, CustomizeFlag customizeFlags, CrestFlag crestFlags) 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(); var design = _designs.CreateTemporary();
design.ApplyEquip = equipFlags & EquipFlagExtensions.All; design.ApplyEquip = equipFlags & EquipFlagExtensions.All;
@ -54,7 +60,7 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi
design.SetApplyVisorToggle(design.DoApplyEquip(EquipSlot.Head)); design.SetApplyVisorToggle(design.DoApplyEquip(EquipSlot.Head));
design.SetApplyWeaponVisible(design.DoApplyEquip(EquipSlot.MainHand) || design.DoApplyEquip(EquipSlot.OffHand)); design.SetApplyWeaponVisible(design.DoApplyEquip(EquipSlot.MainHand) || design.DoApplyEquip(EquipSlot.OffHand));
design.SetApplyWetness(true); design.SetApplyWetness(true);
design.SetDesignData(_customize, state.ModelData); design.SetDesignData(_customize, data);
return design; return design;
} }
@ -144,7 +150,7 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi
} }
public IEnumerable<(EquipSlot Slot, EquipItem Item, StainId Stain)> FromDrawData(IReadOnlyList<CharacterArmor> armors, public IEnumerable<(EquipSlot Slot, EquipItem Item, StainId Stain)> FromDrawData(IReadOnlyList<CharacterArmor> armors,
CharacterWeapon mainhand, CharacterWeapon offhand) CharacterWeapon mainhand, CharacterWeapon offhand, bool skipWarnings)
{ {
if (armors.Count != 10) if (armors.Count != 10)
throw new ArgumentException("Invalid length of armor array."); 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); var item = _items.Identify(slot, armor.Set, armor.Variant);
if (!item.Valid) 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); 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); 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."); Glamourer.Log.Warning($"Appearance data {mainhand} for mainhand weapon invalid, item could not be identified.");
mh = _items.DefaultSword; mh = _items.DefaultSword;
@ -173,7 +180,7 @@ public class DesignConverter(ItemManager _items, DesignManager _designs, Customi
yield return (EquipSlot.MainHand, mh, mainhand.Stain); yield return (EquipSlot.MainHand, mh, mainhand.Stain);
var oh = _items.Identify(EquipSlot.OffHand, offhand.Skeleton, offhand.Weapon, offhand.Variant, mh.Type); 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."); Glamourer.Log.Warning($"Appearance data {offhand} for offhand weapon invalid, item could not be identified.");
oh = _items.GetDefaultOffhand(mh); oh = _items.GetDefaultOffhand(mh);

View file

@ -161,7 +161,7 @@ public class NpcCustomizeSet : IAsyncDataContainer, IReadOnlyList<NpcData>
// Convert the NPCs to a dictionary of lists grouped by name. // 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()); var groups = eNpcEquip.Concat(bNpcEquip).GroupBy(d => d.Name).ToDictionary(g => g.Key, g => g.ToList());
// Iterate through the sorted list. // 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. // Remove any duplicate entries for a name with identical data.
for (var i = 0; i < duplicates.Count; ++i) for (var i = 0; i < duplicates.Count; ++i)
@ -177,7 +177,7 @@ public class NpcCustomizeSet : IAsyncDataContainer, IReadOnlyList<NpcData>
} }
} }
// 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) if (duplicates.Count == 1)
{ {
_data.Add(duplicates[0]); _data.Add(duplicates[0]);
@ -185,13 +185,8 @@ public class NpcCustomizeSet : IAsyncDataContainer, IReadOnlyList<NpcData>
} }
else else
{ {
// Add all distinct duplicates with their ID specified in the name. _data.AddRange(duplicates);
_data.AddRange(duplicates Memory += 96 * duplicates.Count;
.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);
} }
} }

View file

@ -28,6 +28,8 @@ public enum ColorId
TriStateCheck, TriStateCheck,
TriStateCross, TriStateCross,
TriStateNeutral, TriStateNeutral,
BattleNpc,
EventNpc,
} }
public static class Colors 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.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.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.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 ), _ => (0x00000000, string.Empty, string.Empty ),
// @formatter:on // @formatter:on
}; };

View file

@ -82,8 +82,11 @@ public partial class CustomizationDrawer
if (_customize.BodyType.Value == 1) if (_customize.BodyType.Value == 1)
return; return;
if (!ImGuiUtil.DrawDisabledButton($"Reset Body Type {_customize.BodyType.Value} to Default", var label = _lockedRedraw
new Vector2(_raceSelectorWidth + _framedIconSize.X + ImGui.GetStyle().ItemSpacing.X, 0), string.Empty, _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; return;
Changed |= CustomizeFlag.BodyType; Changed |= CustomizeFlag.BodyType;

View file

@ -1,6 +1,7 @@
using System; using System;
using Glamourer.Designs; using Glamourer.Designs;
using Glamourer.Events; using Glamourer.Events;
using Glamourer.Services;
using Glamourer.State; using Glamourer.State;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;

View file

@ -10,6 +10,7 @@ using Glamourer.Gui.Tabs.ActorTab;
using Glamourer.Gui.Tabs.AutomationTab; using Glamourer.Gui.Tabs.AutomationTab;
using Glamourer.Gui.Tabs.DebugTab; using Glamourer.Gui.Tabs.DebugTab;
using Glamourer.Gui.Tabs.DesignTab; using Glamourer.Gui.Tabs.DesignTab;
using Glamourer.Gui.Tabs.NpcTab;
using Glamourer.Gui.Tabs.UnlocksTab; using Glamourer.Gui.Tabs.UnlocksTab;
using ImGuiNET; using ImGuiNET;
using OtterGui.Custom; using OtterGui.Custom;
@ -29,6 +30,7 @@ public class MainWindow : Window, IDisposable
Automation = 4, Automation = 4,
Unlocks = 5, Unlocks = 5,
Messages = 6, Messages = 6,
Npcs = 7,
} }
private readonly Configuration _config; private readonly Configuration _config;
@ -42,12 +44,14 @@ public class MainWindow : Window, IDisposable
public readonly DesignTab Designs; public readonly DesignTab Designs;
public readonly AutomationTab Automation; public readonly AutomationTab Automation;
public readonly UnlocksTab Unlocks; public readonly UnlocksTab Unlocks;
public readonly NpcTab Npcs;
public readonly MessagesTab Messages; public readonly MessagesTab Messages;
public TabType SelectTab = TabType.None; public TabType SelectTab = TabType.None;
public MainWindow(DalamudPluginInterface pi, Configuration config, SettingsTab settings, ActorTab actors, DesignTab designs, 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()) : base(GetLabel())
{ {
pi.UiBuilder.DisableGposeUiHide = true; pi.UiBuilder.DisableGposeUiHide = true;
@ -65,6 +69,7 @@ public class MainWindow : Window, IDisposable
_event = @event; _event = @event;
Messages = messages; Messages = messages;
_quickBar = quickBar; _quickBar = quickBar;
Npcs = npcs;
_config = config; _config = config;
_tabs = _tabs =
[ [
@ -73,6 +78,7 @@ public class MainWindow : Window, IDisposable
designs, designs,
automation, automation,
unlocks, unlocks,
npcs,
messages, messages,
debugTab, debugTab,
]; ];
@ -117,6 +123,7 @@ public class MainWindow : Window, IDisposable
TabType.Automation => Automation.Label, TabType.Automation => Automation.Label,
TabType.Unlocks => Unlocks.Label, TabType.Unlocks => Unlocks.Label,
TabType.Messages => Messages.Label, TabType.Messages => Messages.Label,
TabType.Npcs => Npcs.Label,
_ => ReadOnlySpan<byte>.Empty, _ => ReadOnlySpan<byte>.Empty,
}; };
@ -128,6 +135,7 @@ public class MainWindow : Window, IDisposable
if (label == Settings.Label) return TabType.Settings; if (label == Settings.Label) return TabType.Settings;
if (label == Automation.Label) return TabType.Automation; if (label == Automation.Label) return TabType.Automation;
if (label == Unlocks.Label) return TabType.Unlocks; if (label == Unlocks.Label) return TabType.Unlocks;
if (label == Npcs.Label) return TabType.Npcs;
if (label == Messages.Label) return TabType.Messages; if (label == Messages.Label) return TabType.Messages;
if (label == Debug.Label) return TabType.Debug; if (label == Debug.Label) return TabType.Debug;
// @formatter:on // @formatter:on

View file

@ -35,7 +35,7 @@ public class NpcAppearancePanel(NpcCombo _npcCombo, StateManager _state, ObjectM
ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
var resetScroll = ImGui.InputTextWithHint("##npcFilter", "Filter...", ref _npcFilter, 64); 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)); new Vector2(-1, 400 * ImGuiHelpers.GlobalScale));
if (!table) if (!table)
return; return;
@ -46,6 +46,7 @@ public class NpcAppearancePanel(NpcCombo _npcCombo, StateManager _state, ObjectM
ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthFixed, ImGuiHelpers.GlobalScale * 300); ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthFixed, ImGuiHelpers.GlobalScale * 300);
ImGui.TableSetupColumn("Kind", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("Kind", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("Id", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("Visor", ImGuiTableColumnFlags.WidthFixed); ImGui.TableSetupColumn("Visor", ImGuiTableColumnFlags.WidthFixed);
ImGui.TableSetupColumn("Compare", ImGuiTableColumnFlags.WidthStretch); ImGui.TableSetupColumn("Compare", ImGuiTableColumnFlags.WidthStretch);
@ -66,7 +67,7 @@ public class NpcAppearancePanel(NpcCombo _npcCombo, StateManager _state, ObjectM
ImGui.TableNextColumn(); ImGui.TableNextColumn();
if (ImGuiUtil.DrawDisabledButton("Apply", Vector2.Zero, string.Empty, disabled)) 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.ChangeEquip(state!, slot, item, stain, StateChanged.Source.Manual);
_state.ChangeVisorState(state!, data.VisorToggled, StateChanged.Source.Manual); _state.ChangeVisorState(state!, data.VisorToggled, StateChanged.Source.Manual);
_state.ChangeCustomize(state!, data.Customize, CustomizeFlagExtensions.All, 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.AlignTextToFramePadding();
ImGui.TextUnformatted(data.Kind is ObjectKind.BattleNpc ? "B" : "E"); ImGui.TextUnformatted(data.Kind is ObjectKind.BattleNpc ? "B" : "E");
ImGui.TableNextColumn();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(data.Id.Id.ToString());
using (_ = ImRaii.PushFont(UiBuilder.IconFont)) using (_ = ImRaii.PushFont(UiBuilder.IconFont))
{ {
ImGui.TableNextColumn(); ImGui.TableNextColumn();

View file

@ -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<ulong, Data> _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<int>() ?? 0;
switch (version)
{
case 1:
var data = jObj["Data"]?.ToObject<Dictionary<ulong, Data>>() ?? [];
_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}");
}
}
}

View file

@ -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<NpcData>
{
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;
}
}

View file

@ -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.");
}
}
}

View file

@ -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<int> _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;
}
}

View file

@ -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<byte> Label
=> "NPCs"u8;
public void DrawContent()
{
_selector.Draw(200 * ImGuiHelpers.GlobalScale);
ImGui.SameLine();
_panel.Draw();
}
}

View file

@ -100,4 +100,23 @@ public ref struct ToggleDrawData
SetValue = setValue, 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,
};
}
} }

View file

@ -47,9 +47,15 @@ public static class UiHelpers
public static bool DrawCheckbox(string label, string tooltip, bool value, out bool on, bool locked) public static bool DrawCheckbox(string label, string tooltip, bool value, out bool on, bool locked)
{ {
using var disabled = ImRaii.Disabled(locked); bool ret;
var ret = ImGuiUtil.Checkbox(label, string.Empty, value, v => value = v); using (var disabled = ImRaii.Disabled(locked))
ImGuiUtil.HoverTooltip(tooltip); {
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; on = value;
return ret; return ret;
} }
@ -58,7 +64,6 @@ public static class UiHelpers
out bool newApply, bool locked) out bool newApply, bool locked)
{ {
var flags = (sbyte)(currentApply ? currentValue ? 1 : -1 : 0); var flags = (sbyte)(currentApply ? currentValue ? 1 : -1 : 0);
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemInnerSpacing);
using (_ = ImRaii.Disabled(locked)) using (_ = ImRaii.Disabled(locked))
{ {
if (new TristateCheckbox(ColorId.TriStateCross.Value(), ColorId.TriStateCheck.Value(), ColorId.TriStateNeutral.Value()).Draw( 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.")}"); 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); ImGui.TextUnformatted(label);
return (currentValue != newValue, currentApply != newApply); return (currentValue != newValue, currentApply != newApply);

View file

@ -18,6 +18,7 @@ public class FilenameService
public readonly string FavoriteFile; public readonly string FavoriteFile;
public readonly string DesignColorFile; public readonly string DesignColorFile;
public readonly string EphemeralConfigFile; public readonly string EphemeralConfigFile;
public readonly string NpcAppearanceFile;
public FilenameService(DalamudPluginInterface pi) public FilenameService(DalamudPluginInterface pi)
{ {
@ -32,9 +33,9 @@ public class FilenameService
FavoriteFile = Path.Combine(ConfigDirectory, "favorites.json"); FavoriteFile = Path.Combine(ConfigDirectory, "favorites.json");
DesignColorFile = Path.Combine(ConfigDirectory, "design_colors.json"); DesignColorFile = Path.Combine(ConfigDirectory, "design_colors.json");
EphemeralConfigFile = Path.Combine(ConfigDirectory, "ephemeral_config.json"); EphemeralConfigFile = Path.Combine(ConfigDirectory, "ephemeral_config.json");
NpcAppearanceFile = Path.Combine(ConfigDirectory, "npc_appearance_data.json");
} }
public IEnumerable<FileInfo> Designs() public IEnumerable<FileInfo> Designs()
{ {
if (!Directory.Exists(DesignDirectory)) if (!Directory.Exists(DesignDirectory))

View file

@ -11,6 +11,7 @@ using Glamourer.Gui.Tabs.ActorTab;
using Glamourer.Gui.Tabs.AutomationTab; using Glamourer.Gui.Tabs.AutomationTab;
using Glamourer.Gui.Tabs.DebugTab; using Glamourer.Gui.Tabs.DebugTab;
using Glamourer.Gui.Tabs.DesignTab; using Glamourer.Gui.Tabs.DesignTab;
using Glamourer.Gui.Tabs.NpcTab;
using Glamourer.Gui.Tabs.UnlocksTab; using Glamourer.Gui.Tabs.UnlocksTab;
using Glamourer.Interop; using Glamourer.Interop;
using Glamourer.Interop.Penumbra; using Glamourer.Interop.Penumbra;
@ -131,6 +132,10 @@ public static class ServiceManagerA
.AddSingleton<ActorTab>() .AddSingleton<ActorTab>()
.AddSingleton<ActorSelector>() .AddSingleton<ActorSelector>()
.AddSingleton<ActorPanel>() .AddSingleton<ActorPanel>()
.AddSingleton<NpcPanel>()
.AddSingleton<NpcSelector>()
.AddSingleton<LocalNpcAppearanceData>()
.AddSingleton<NpcTab>()
.AddSingleton<MainWindow>() .AddSingleton<MainWindow>()
.AddSingleton<GenericPopupWindow>() .AddSingleton<GenericPopupWindow>()
.AddSingleton<GlamourerWindowSystem>() .AddSingleton<GlamourerWindowSystem>()

@ -1 +1 @@
Subproject commit 15203edf1dba72713f508b798048c56ad969fb95 Subproject commit e58c3c1240cda9d2d2b54f5ab7b8c729c1251fd4