diff --git a/Glamourer.GameData/Customization/ActorCustomization.cs b/Glamourer.GameData/Customization/ActorCustomization.cs index 8d54bda..cbfd3ac 100644 --- a/Glamourer.GameData/Customization/ActorCustomization.cs +++ b/Glamourer.GameData/Customization/ActorCustomization.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using Dalamud.Game.ClientState.Actors.Types; using Penumbra.GameData.Enums; namespace Glamourer.Customization @@ -13,6 +14,9 @@ namespace Glamourer.Customization public ref ActorCustomization Value => ref *Address; + + public LazyCustomization(ActorCustomization data) + => Address = &data; } @@ -22,7 +26,36 @@ namespace Glamourer.Customization public const int CustomizationOffset = 0x1898; public const int CustomizationBytes = 26; - private byte _race; + public static ActorCustomization Default = new() + { + Race = Race.Hyur, + Gender = Gender.Male, + BodyType = 1, + Height = 50, + Clan = SubRace.Midlander, + Face = 1, + Hairstyle = 1, + HighlightsOn = false, + SkinColor = 1, + EyeColorRight = 1, + HighlightsColor = 1, + FacialFeatures = 0, + TattooColor = 1, + Eyebrow = 1, + EyeColorLeft = 1, + EyeShape = 1, + Nose = 1, + Jaw = 1, + Mouth = 1, + LipColor = 1, + MuscleMass = 50, + TailShape = 1, + BustSize = 50, + FacePaint = 1, + FacePaintColor = 1, + }; + + public Race Race; private byte _gender; public byte BodyType; public byte Height; @@ -49,12 +82,6 @@ namespace Glamourer.Customization private byte _facePaint; public byte FacePaintColor; - public Race Race - { - get => (Race) (_race > (byte) Race.Midlander ? _race + 1 : _race); - set => _race = (byte) (value > Race.Highlander ? value - 1 : value); - } - public Gender Gender { get => (Gender) (_gender + 1); @@ -117,12 +144,21 @@ namespace Glamourer.Customization public unsafe void Read(IntPtr customizeAddress) { - fixed (byte* ptr = &_race) + fixed (Race* ptr = &Race) { Buffer.MemoryCopy(customizeAddress.ToPointer(), ptr, CustomizationBytes, CustomizationBytes); } } + public void Read(Actor actor) + => Read(actor.Address + CustomizationOffset); + + public ActorCustomization(Actor actor) + : this() + { + Read(actor.Address + CustomizationOffset); + } + public byte this[CustomizationId id] { get => id switch @@ -244,7 +280,7 @@ namespace Glamourer.Customization public unsafe void Write(IntPtr actorAddress) { - fixed (byte* ptr = &_race) + fixed (Race* ptr = &Race) { Buffer.MemoryCopy(ptr, (byte*) actorAddress + CustomizationOffset, CustomizationBytes, CustomizationBytes); } @@ -252,7 +288,7 @@ namespace Glamourer.Customization public unsafe void WriteBytes(byte[] array, int offset = 0) { - fixed (byte* ptr = &_race) + fixed (Race* ptr = &Race) { Marshal.Copy(new IntPtr(ptr), array, offset, CustomizationBytes); } diff --git a/Glamourer.GameData/Customization/CustomizationId.cs b/Glamourer.GameData/Customization/CustomizationId.cs index 19dc004..cbc15f9 100644 --- a/Glamourer.GameData/Customization/CustomizationId.cs +++ b/Glamourer.GameData/Customization/CustomizationId.cs @@ -68,7 +68,7 @@ namespace Glamourer.Customization _ => throw new ArgumentOutOfRangeException(nameof(customizationId), customizationId, null), }; - public static CharaMakeParams.MenuType ToType(this CustomizationId customizationId, Race race = Race.Midlander) + public static CharaMakeParams.MenuType ToType(this CustomizationId customizationId, Race race = Race.Hyur) => customizationId switch { CustomizationId.Race => CharaMakeParams.MenuType.IconSelector, diff --git a/Glamourer.zip b/Glamourer.zip index 2c8d1da..84df484 100644 Binary files a/Glamourer.zip and b/Glamourer.zip differ diff --git a/Glamourer/ActorExtensions.cs b/Glamourer/ActorExtensions.cs new file mode 100644 index 0000000..b2456dd --- /dev/null +++ b/Glamourer/ActorExtensions.cs @@ -0,0 +1,79 @@ +using Dalamud.Game.ClientState.Actors.Types; + +namespace Glamourer +{ + public static class ActorExtensions + { + public const int WetnessOffset = 0x19A5; + public const byte WetnessFlag = 0x10; + public const int StateFlagsOffset = 0x106C; + public const byte HatHiddenFlag = 0x01; + public const byte VisorToggledFlag = 0x10; + public const int AlphaOffset = 0x182C; + public const int WeaponHiddenOffset = 0xF64; + public const byte WeaponHiddenFlag = 0x02; + + public static unsafe bool IsWet(this Actor a) + => (*((byte*) a.Address + WetnessOffset) & WetnessFlag) != 0; + + public static unsafe bool SetWetness(this Actor a, bool value) + { + var current = a.IsWet(); + if (current == value) + return false; + + if (value) + *((byte*) a.Address + WetnessOffset) = (byte) (*((byte*) a.Address + WetnessOffset) | WetnessFlag); + else + *((byte*) a.Address + WetnessOffset) = (byte) (*((byte*) a.Address + WetnessOffset) & ~WetnessFlag); + return true; + } + + public static unsafe ref byte StateFlags(this Actor a) + => ref *((byte*) a.Address + StateFlagsOffset); + + public static bool SetStateFlag(this Actor a, bool value, byte flag) + { + var current = a.StateFlags(); + var previousValue = (current & flag) != 0; + if (previousValue == value) + return false; + + if (value) + a.StateFlags() = (byte) (current | flag); + else + a.StateFlags() = (byte) (current & ~flag); + return true; + } + + public static bool IsHatHidden(this Actor a) + => (a.StateFlags() & HatHiddenFlag) != 0; + + public static unsafe bool IsWeaponHidden(this Actor a) + => (a.StateFlags() & WeaponHiddenFlag) != 0 + && (*((byte*) a.Address + WeaponHiddenOffset) & WeaponHiddenFlag) != 0; + + public static bool IsVisorToggled(this Actor a) + => (a.StateFlags() & VisorToggledFlag) != 0; + + public static bool SetHatHidden(this Actor a, bool value) + => SetStateFlag(a, value, HatHiddenFlag); + + public static unsafe bool SetWeaponHidden(this Actor a, bool value) + { + var ret = SetStateFlag(a, value, WeaponHiddenFlag); + var val = *((byte*) a.Address + WeaponHiddenOffset); + if (value) + *((byte*) a.Address + WeaponHiddenOffset) = (byte) (val | WeaponHiddenFlag); + else + *((byte*) a.Address + WeaponHiddenOffset) = (byte) (val & ~WeaponHiddenFlag); + return ret || ((val & WeaponHiddenFlag) != 0) != value; + } + + public static bool SetVisorToggled(this Actor a, bool value) + => SetStateFlag(a, value, VisorToggledFlag); + + public static unsafe ref float Alpha(this Actor a) + => ref *(float*) ((byte*) a.Address + AlphaOffset); + } +} diff --git a/Glamourer/CharacterSave.cs b/Glamourer/CharacterSave.cs new file mode 100644 index 0000000..19fd66a --- /dev/null +++ b/Glamourer/CharacterSave.cs @@ -0,0 +1,275 @@ +using System; +using Dalamud.Game.ClientState.Actors.Types; +using Glamourer.Customization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Structs; + +namespace Glamourer +{ + public class CharacterSaveConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + => objectType == typeof(CharacterSave); + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + var s = token.ToObject(); + return CharacterSave.FromString(s!); + } + + public override bool CanWrite + => true; + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value != null) + { + var s = ((CharacterSave) value).ToBase64(); + serializer.Serialize(writer, s); + } + } + } + + [JsonConverter(typeof(CharacterSaveConverter))] + public class CharacterSave + { + public const byte CurrentVersion = 2; + public const byte TotalSizeVersion1 = 1 + 1 + 2 + 56 + ActorCustomization.CustomizationBytes; + public const byte TotalSizeVersion2 = 1 + 1 + 2 + 56 + ActorCustomization.CustomizationBytes + 4 + 1; + + public const byte TotalSize = TotalSizeVersion2; + + private readonly byte[] _bytes = new byte[TotalSize]; + + public CharacterSave() + { + _bytes[0] = CurrentVersion; + Alpha = 1.0f; + } + + public CharacterSave Copy() + { + var ret = new CharacterSave(); + _bytes.CopyTo((Span) ret._bytes); + return ret; + } + + public byte Version + => _bytes[0]; + + public bool WriteCustomizations + { + get => (_bytes[1] & 0x01) != 0; + set => _bytes[1] = (byte) (value ? _bytes[1] | 0x01 : _bytes[1] & ~0x01); + } + + public bool IsWet + { + get => (_bytes[1] & 0x02) != 0; + set => _bytes[1] = (byte) (value ? _bytes[1] | 0x02 : _bytes[1] & ~0x02); + } + + public bool SetHatState + { + get => (_bytes[1] & 0x04) != 0; + set => _bytes[1] = (byte)(value ? _bytes[1] | 0x04 : _bytes[1] & ~0x04); + } + + public bool SetWeaponState + { + get => (_bytes[1] & 0x08) != 0; + set => _bytes[1] = (byte)(value ? _bytes[1] | 0x08 : _bytes[1] & ~0x08); + } + + public bool SetVisorState + { + get => (_bytes[1] & 0x10) != 0; + set => _bytes[1] = (byte)(value ? _bytes[1] | 0x10 : _bytes[1] & ~0x10); + } + + public bool WriteProtected + { + get => (_bytes[1] & 0x20) != 0; + set => _bytes[1] = (byte)(value ? _bytes[1] | 0x20 : _bytes[1] & ~0x20); + } + + public byte StateFlags + { + get => _bytes[64 + ActorCustomization.CustomizationBytes]; + set => _bytes[64 + ActorCustomization.CustomizationBytes] = value; + } + + public bool HatState + { + get => (StateFlags & 0x01) == 0; + set => StateFlags = (byte) (value ? StateFlags & ~0x01 : StateFlags | 0x01); + } + + public bool VisorState + { + get => (StateFlags & 0x10) != 0; + set => StateFlags = (byte)(value ? StateFlags | 0x10 : StateFlags & ~0x10); + } + + public bool WeaponState + { + get => (StateFlags & 0x02) == 0; + set => StateFlags = (byte)(value ? StateFlags & ~0x02 : StateFlags | 0x02); + } + + public ActorEquipMask WriteEquipment + { + get => (ActorEquipMask) ((ushort) _bytes[2] | ((ushort) _bytes[3] << 8)); + set + { + _bytes[2] = (byte) ((ushort) value & 0xFF); + _bytes[3] = (byte) ((ushort) value >> 8); + } + } + + public unsafe float Alpha + { + get + { + fixed (byte* ptr = &_bytes[60 + ActorCustomization.CustomizationBytes]) + { + return *(float*) ptr; + } + } + set + { + fixed (byte* ptr = _bytes) + { + *(ptr + 60 + ActorCustomization.CustomizationBytes + 0) = *((byte*) &value + 0); + *(ptr + 60 + ActorCustomization.CustomizationBytes + 1) = *((byte*) &value + 1); + *(ptr + 60 + ActorCustomization.CustomizationBytes + 2) = *((byte*) &value + 2); + *(ptr + 60 + ActorCustomization.CustomizationBytes + 3) = *((byte*) &value + 3); + } + } + } + + public void Load(ActorCustomization customization) + { + WriteCustomizations = true; + customization.WriteBytes(_bytes, 4); + } + + public void Load(ActorEquipment equipment, ActorEquipMask mask = ActorEquipMask.All) + { + WriteEquipment = mask; + equipment.WriteBytes(_bytes, 4 + ActorCustomization.CustomizationBytes); + } + + public string ToBase64() + => Convert.ToBase64String(_bytes); + + private static void CheckSize(int length, int requiredLength) + { + if (length != requiredLength) + throw new Exception( + $"Can not parse Base64 string into CharacterSave:\n\tInvalid size {length} instead of {requiredLength}."); + } + + private static void CheckRange(int idx, byte value, byte min, byte max) + { + if (value < min || value > max) + throw new Exception( + $"Can not parse Base64 string into CharacterSave:\n\tInvalid value {value} in byte {idx}, should be in [{min},{max}]."); + } + + private static void CheckActorMask(byte val1, byte val2) + { + var mask = (ActorEquipMask)((ushort)val1 | ((ushort)val2 << 8)); + if (mask > ActorEquipMask.All) + throw new Exception($"Can not parse Base64 string into CharacterSave:\n\tInvalid value {mask} in byte 3 and 4."); + } + + public void LoadActor(Actor a) + { + WriteCustomizations = true; + Load(new ActorCustomization(a)); + + Load(new ActorEquipment(a), ActorEquipMask.All); + + SetHatState = true; + SetVisorState = true; + SetWeaponState = true; + StateFlags = a.StateFlags(); + + IsWet = a.IsWet(); + Alpha = a.Alpha(); + } + + public void Apply(Actor a) + { + if (WriteCustomizations) + Customizations.Write(a.Address); + if (WriteEquipment != ActorEquipMask.None) + Equipment.Write(a.Address, WriteEquipment, WriteEquipment); + a.SetWetness(IsWet); + a.Alpha() = Alpha; + if ((_bytes[1] & 0b11100) == 0b11100) + a.StateFlags() = StateFlags; + else + { + if (SetHatState) + a.SetHatHidden(HatState); + if (SetVisorState) + a.SetVisorToggled(VisorState); + if (SetWeaponState) + a.SetWeaponHidden(WeaponState); + } + } + + public void Load(string base64) + { + var bytes = Convert.FromBase64String(base64); + switch (bytes[0]) + { + case 1: + CheckSize(bytes.Length, TotalSizeVersion1); + CheckRange(2, bytes[1], 0, 1); + Alpha = 1.0f; + bytes[0] = CurrentVersion; + break; + case 2: + CheckSize(bytes.Length, TotalSizeVersion2); + CheckRange(2, bytes[1], 0, 0x3F); + break; + default: throw new Exception($"Can not parse Base64 string into CharacterSave:\n\tInvalid Version {bytes[0]}."); + } + CheckActorMask(bytes[2], bytes[3]); + bytes.CopyTo(_bytes, 0); + } + + public static CharacterSave FromString(string base64) + { + var ret = new CharacterSave(); + ret.Load(base64); + return ret; + } + + public unsafe ref ActorCustomization Customizations + { + get + { + fixed (byte* ptr = _bytes) + { + return ref *((ActorCustomization*) (ptr + 4)); + } + } + } + + public ActorEquipment Equipment + { + get + { + var ret = new ActorEquipment(); + ret.FromBytes(_bytes, 4 + ActorCustomization.CustomizationBytes); + return ret; + } + } + } +} diff --git a/Glamourer/Designs/Design.cs b/Glamourer/Designs/Design.cs new file mode 100644 index 0000000..9251a73 --- /dev/null +++ b/Glamourer/Designs/Design.cs @@ -0,0 +1,22 @@ +using Glamourer.FileSystem; + +namespace Glamourer.Designs +{ + public class Design : IFileSystemBase + { + public Folder Parent { get; set; } + public string Name { get; set; } + + public CharacterSave Data { get; set; } + + internal Design(Folder parent, string name) + { + Parent = parent; + Name = name; + Data = new CharacterSave(); + } + + public override string ToString() + => Name; + } +} diff --git a/Glamourer/Designs/DesignManager.cs b/Glamourer/Designs/DesignManager.cs new file mode 100644 index 0000000..908776f --- /dev/null +++ b/Glamourer/Designs/DesignManager.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using Dalamud.Plugin; +using Glamourer.FileSystem; +using Newtonsoft.Json; + +namespace Glamourer.Designs +{ + public class DesignManager + { + public const string FileName = "Designs.json"; + private readonly FileInfo _saveFile; + + public SortedList Designs = null!; + public FileSystem.FileSystem FileSystem { get; } = new(); + + public DesignManager(DalamudPluginInterface pi) + { + var saveFolder = new DirectoryInfo(pi.GetPluginConfigDirectory()); + if (!saveFolder.Exists) + Directory.CreateDirectory(saveFolder.FullName); + + _saveFile = new FileInfo(Path.Combine(saveFolder.FullName, FileName)); + + LoadFromFile(); + } + + private void BuildStructure() + { + FileSystem.Clear(); + var anyChanges = false; + foreach (var kvp in Designs.ToArray()) + { + var path = kvp.Key; + var save = kvp.Value; + + try + { + var (folder, name) = FileSystem.CreateAllFolders(path); + var design = new Design(folder, name) { Data = save }; + folder.FindOrAddChild(design); + var fixedPath = design.FullName(); + if (!string.Equals(fixedPath, path, StringComparison.InvariantCultureIgnoreCase)) + { + Designs.Remove(path); + Designs[fixedPath] = save; + anyChanges = true; + PluginLog.Debug($"Problem loading saved designs, {path} was renamed to {fixedPath}."); + } + } + catch (Exception e) + { + PluginLog.Error($"Problem loading saved designs, {path} was removed because:\n{e}"); + Designs.Remove(path); + } + } + + if (anyChanges) + SaveToFile(); + } + + private bool UpdateRoot(string oldPath, Design child) + { + var newPath = child.FullName(); + if (string.Equals(newPath, oldPath, StringComparison.InvariantCultureIgnoreCase)) + return false; + + Designs.Remove(oldPath); + Designs[child.FullName()] = child.Data; + return true; + } + + private void UpdateChild(string oldRootPath, string newRootPath, Design child) + { + var newPath = child.FullName(); + var oldPath = $"{oldRootPath}{newPath.Remove(0, newRootPath.Length)}"; + Designs.Remove(oldPath); + Designs[newPath] = child.Data; + } + + public void DeleteAllChildren(IFileSystemBase root, bool deleteEmpty) + { + if (root is Folder f) + { + foreach (var child in f.AllLeaves(SortMode.Lexicographical)) + Designs.Remove(child.FullName()); + } + var fullPath = root.FullName(); + root.Parent.RemoveChild(root, deleteEmpty); + Designs.Remove(fullPath); + + SaveToFile(); + } + + public void UpdateAllChildren(string oldPath, IFileSystemBase root) + { + var changes = false; + switch (root) + { + case Design d: + changes |= UpdateRoot(oldPath, d); + break; + case Folder f: + { + var newRootPath = root.FullName(); + if (!string.Equals(oldPath, newRootPath, StringComparison.InvariantCultureIgnoreCase)) + { + changes = true; + foreach (var descendant in f.AllLeaves(SortMode.Lexicographical).Where(l => l is Design).Cast()) + UpdateChild(oldPath, newRootPath, descendant); + } + + break; + } + } + + if (changes) + SaveToFile(); + } + + public void SaveToFile() + { + try + { + var data = JsonConvert.SerializeObject(Designs, Formatting.Indented); + File.WriteAllText(_saveFile.FullName, data); + } + catch (Exception e) + { + PluginLog.Error($"Could not write to save file {_saveFile.FullName}:\n{e}"); + } + } + + public void LoadFromFile() + { + _saveFile.Refresh(); + SortedList? designs = null; + if (_saveFile.Exists) + try + { + var data = File.ReadAllText(_saveFile.FullName); + designs = JsonConvert.DeserializeObject>(data); + } + catch (Exception e) + { + PluginLog.Error($"Could not load save file {_saveFile.FullName}:\n{e}"); + } + + if (designs == null) + { + Designs = new SortedList(); + SaveToFile(); + } + else + { + Designs = designs; + } + + BuildStructure(); + } + } +} diff --git a/Glamourer/FileSystem/FileSystem.cs b/Glamourer/FileSystem/FileSystem.cs new file mode 100644 index 0000000..ca71f74 --- /dev/null +++ b/Glamourer/FileSystem/FileSystem.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Glamourer.FileSystem +{ + public class FileSystem + { + public Folder Root { get; } = Folder.CreateRoot(); + + public void Clear() + => Root.Children.Clear(); + + // Find a specific child by its path from Root. + // Returns true if the folder was found, and false if not. + // The out parameter will contain the furthest existing folder. + public bool Find(string path, out IFileSystemBase child) + { + var split = Split(path); + var folder = Root; + child = Root; + foreach (var part in split) + { + if (!folder.FindChild(part, out var c)) + { + child = folder; + return false; + } + + child = c; + if (c is not Folder f) + return part == split.Last(); + + folder = f; + } + + return true; + } + + public Folder CreateAllFolders(IEnumerable names) + { + var last = Root; + foreach (var name in names) + last = last.FindOrCreateSubFolder(name).Item1; + return last; + } + + public (Folder, string) CreateAllFolders(string path) + { + if (!path.Any()) + return (Root, string.Empty); + + var split = Split(path); + if (split.Length == 1) + return (Root, path); + + return (CreateAllFolders(split.Take(split.Length - 1)), split.Last()); + } + + public bool Rename(IFileSystemBase child, string newName) + { + if (ReferenceEquals(child, Root)) + throw new InvalidOperationException("Can not rename root."); + + newName = FixName(newName); + if (child.Name == newName) + return false; + + if (child.Parent.FindChild(newName, out var preExisting)) + { + if (MergeIfFolders(child, preExisting, false)) + return true; + + throw new Exception($"Can not rename {child.Name} in {child.Parent.FullName()} to {newName} because {newName} already exists."); + } + + var parent = child.Parent; + parent.RemoveChildIgnoreEmpty(child); + child.Name = newName; + parent.FindOrAddChild(child); + return true; + } + + public bool Move(IFileSystemBase child, Folder newParent, bool deleteEmpty) + { + var oldParent = child.Parent; + if (ReferenceEquals(newParent, oldParent)) + return false; + + // Moving into its own subfolder or itself is not allowed. + if (child.IsFolder(out var f) + && (ReferenceEquals(newParent, f) + || newParent.FullName().StartsWith(f.FullName(), StringComparison.InvariantCultureIgnoreCase))) + return false; + + if (newParent.FindChild(child.Name, out var conflict)) + { + if (MergeIfFolders(child, conflict, deleteEmpty)) + return true; + + throw new Exception($"Can not move {child.Name} into {newParent.FullName()} because {conflict.FullName()} already exists."); + } + + oldParent.RemoveChild(child, deleteEmpty); + newParent.FindOrAddChild(child); + return true; + } + + public bool Merge(Folder source, Folder target, bool deleteEmpty) + { + if (ReferenceEquals(source, target)) + return false; + + if (!source.Children.Any()) + { + if (deleteEmpty) + { + source.Parent.RemoveChild(source, true); + return true; + } + + return false; + } + + while (source.Children.Count > 0) + Move(source.Children.First(), target, deleteEmpty); // Can throw. + + source.Parent.RemoveChild(source, deleteEmpty); + + return true; + } + + private bool MergeIfFolders(IFileSystemBase source, IFileSystemBase target, bool deleteEmpty) + { + if (source is Folder childF && target.IsFolder(out var preF)) + { + Merge(childF, preF, deleteEmpty); + return true; + } + + return false; + } + + private static string[] Split(string path) + => path.Split(new[] + { + '/', + }, StringSplitOptions.RemoveEmptyEntries); + + private static string FixName(string name) + => name.Replace('/', '\\'); + } +} diff --git a/Glamourer/FileSystem/FileSystemImGui.cs b/Glamourer/FileSystem/FileSystemImGui.cs new file mode 100644 index 0000000..a358be2 --- /dev/null +++ b/Glamourer/FileSystem/FileSystemImGui.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using Dalamud.Plugin; +using ImGuiNET; + +namespace Glamourer.FileSystem +{ + public static class FileSystemImGui + { + public const string DraggedObjectLabel = "FSDrag"; + + private static unsafe bool IsDropping(string name) + => ImGui.AcceptDragDropPayload(name).NativePtr != null; + + private static IFileSystemBase? _draggedObject = null; + + public static bool DragDropTarget(FileSystem fs, IFileSystemBase child, out string oldPath, out IFileSystemBase? draggedChild) + { + oldPath = string.Empty; + draggedChild = null; + var ret = false; + if (!ImGui.BeginDragDropTarget()) + return ret; + + if (IsDropping(DraggedObjectLabel)) + { + if (_draggedObject != null) + try + { + oldPath = _draggedObject.FullName(); + draggedChild = _draggedObject; + ret = fs.Move(_draggedObject, child.IsFolder(out var folder) ? folder : child.Parent, false); + } + catch (Exception e) + { + PluginLog.Error($"Could not drag {_draggedObject.Name} onto {child.FullName()}:\n{e}"); + } + + _draggedObject = null; + } + + ImGui.EndDragDropTarget(); + return ret; + } + + public static void DragDropSource(IFileSystemBase child) + { + if (!ImGui.BeginDragDropSource()) + return; + + ImGui.SetDragDropPayload(DraggedObjectLabel, IntPtr.Zero, 0); + ImGui.Text($"Moving {child.Name}..."); + _draggedObject = child; + ImGui.EndDragDropSource(); + } + } +} diff --git a/Glamourer/FileSystem/Folder.cs b/Glamourer/FileSystem/Folder.cs new file mode 100644 index 0000000..de5d699 --- /dev/null +++ b/Glamourer/FileSystem/Folder.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace Glamourer.FileSystem +{ + public enum SortMode + { + FoldersFirst = 0x00, + Lexicographical = 0x01, + } + + public class Folder : IFileSystemBase + { + public Folder Parent { get; set; } + public string Name { get; set; } + public readonly List Children = new(); + + public Folder(Folder parent, string name) + { + Parent = parent; + Name = name; + } + + public override string ToString() + => this.FullName(); + + // Return the number of all leaves with this folder in their path. + public int TotalDescendantLeaves() + { + var sum = 0; + foreach (var child in Children) + { + switch (child) + { + case Folder f: + sum += f.TotalDescendantLeaves(); + break; + case Link l: + sum += l.Data is Folder fl ? fl.TotalDescendantLeaves() : 1; + break; + default: + ++sum; + break; + } + } + + return sum; + } + + // Return all descendant mods in the specified order. + public IEnumerable AllLeaves(SortMode mode) + { + return GetSortedEnumerator(mode).SelectMany(f => + { + if (f.IsFolder(out var folder)) + return folder.AllLeaves(mode); + + return new[] + { + f, + }; + }); + } + + public IEnumerable AllChildren(SortMode mode) + => GetSortedEnumerator(mode); + + // Get an enumerator for actually sorted objects instead of folder-first objects. + private IEnumerable GetSortedEnumerator(SortMode mode) + { + switch (mode) + { + case SortMode.FoldersFirst: + foreach (var child in Children.Where(c => c.IsFolder())) + yield return child; + + foreach (var child in Children.Where(c => c.IsLeaf())) + yield return child; + + break; + case SortMode.Lexicographical: + foreach (var child in Children) + yield return child; + + break; + default: throw new InvalidEnumArgumentException(); + } + } + + internal static Folder CreateRoot() + => new(null!, ""); + + // Find a subfolder by name. Returns true and sets folder to it if it exists. + public bool FindChild(string name, out IFileSystemBase ret) + { + var idx = Search(name); + ret = idx >= 0 ? Children[idx] : this; + return idx >= 0; + } + + // Checks if an equivalent child to child already exists and returns its index. + // If it does not exist, inserts child as a child and returns the new index. + // Also sets this as childs parent. + public int FindOrAddChild(IFileSystemBase child) + { + var idx = Search(child); + if (idx >= 0) + return idx; + + idx = ~idx; + Children.Insert(idx, child); + child.Parent = this; + return idx; + } + + // Checks if an equivalent child to child already exists and throws if it does. + // If it does not exist, inserts child as a child and returns the new index. + // Also sets this as childs parent. + public int AddChild(IFileSystemBase child) + { + var idx = Search(child); + if (idx >= 0) + throw new Exception("Could not add child: Child of that name already exists."); + + idx = ~idx; + Children.Insert(idx, child); + child.Parent = this; + return idx; + } + + // Checks if a subfolder with the given name already exists and returns it and its index. + // If it does not exists, creates and inserts it and returns the new subfolder and its index. + public (Folder, int) FindOrCreateSubFolder(string name) + { + var subFolder = new Folder(this, name); + var idx = FindOrAddChild(subFolder); + var child = Children[idx]; + if (!child.IsFolder(out var folder)) + throw new Exception($"The child {name} already exists in {this.FullName()} but is not a folder."); + + return (folder, idx); + } + + // Remove child if it exists. + // If this folder is empty afterwards and deleteEmpty is true, remove it from its parent. + public void RemoveChild(IFileSystemBase child, bool deleteEmpty) + { + RemoveChildIgnoreEmpty(child); + if (deleteEmpty) + CheckEmpty(); + } + + private void CheckEmpty() + { + if (Children.Count == 0) + Parent?.RemoveChild(this, true); + } + + // Remove a child but do not remove this folder from its parent if it is empty afterwards. + internal void RemoveChildIgnoreEmpty(IFileSystemBase folder) + { + var idx = Search(folder); + if (idx < 0) + return; + + Children[idx].Parent = null!; + Children.RemoveAt(idx); + } + + private int Search(string name) + => Children.BinarySearch(new FileSystemObject(name), FolderStructureComparer.Default); + + private int Search(IFileSystemBase child) + => Children.BinarySearch(child, FolderStructureComparer.Default); + } +} diff --git a/Glamourer/FileSystem/IFolderStructure.cs b/Glamourer/FileSystem/IFolderStructure.cs new file mode 100644 index 0000000..f4bf01b --- /dev/null +++ b/Glamourer/FileSystem/IFolderStructure.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Glamourer.Designs; + +namespace Glamourer.FileSystem +{ + internal class FolderStructureComparer : IComparer + { + // Compare only the direct folder names since this is only used inside an enumeration of children of one folder. + public static int Cmp(IFileSystemBase x, IFileSystemBase y) + => ReferenceEquals(x, y) ? 0 : string.Compare(x.Name, y.Name, StringComparison.InvariantCultureIgnoreCase); + + public int Compare(IFileSystemBase x, IFileSystemBase y) + => Cmp(x, y); + + internal static readonly FolderStructureComparer Default = new(); + } + + public interface IFileSystemBase + { + public Folder Parent { get; set; } + public string Name { get; set; } + } + + public static class FileSystemExtensions + { + public static string FullName(this IFileSystemBase data) + => data.Parent?.Name.Any() ?? false ? $"{data.Parent.FullName()}/{data.Name}" : data.Name; + + public static bool IsLeaf(this IFileSystemBase data) + => data is not Folder && data is not Link { Data: Folder }; + + public static bool IsFolder(this IFileSystemBase data) + => data.IsFolder(out _); + + public static bool IsFolder(this IFileSystemBase data, out Folder folder) + { + switch (data) + { + case Folder f: + folder = f; + return true; + case Link { Data: Folder fl }: + folder = fl; + return true; + default: + folder = null!; + return false; + } + } + } + + + public class FileSystemObject : IFileSystemBase + { + public FileSystemObject(string name) + => Name = name; + + public Folder Parent { get; set; } = null!; + public string Name { get; set; } + + public string FullName() + => Name; + } +} diff --git a/Glamourer/FileSystem/Link.cs b/Glamourer/FileSystem/Link.cs new file mode 100644 index 0000000..e064982 --- /dev/null +++ b/Glamourer/FileSystem/Link.cs @@ -0,0 +1,20 @@ +using Glamourer.Designs; + +namespace Glamourer.FileSystem +{ + public class Link : IFileSystemBase + { + public Folder Parent { get; set; } + + public string Name { get; set; } + + public IFileSystemBase Data { get; } + + public Link(Folder parent, string name, IFileSystemBase data) + { + Parent = parent; + Name = name; + Data = data; + } + } +} diff --git a/Glamourer/Glamourer.csproj b/Glamourer/Glamourer.csproj index 16644ef..9249764 100644 --- a/Glamourer/Glamourer.csproj +++ b/Glamourer/Glamourer.csproj @@ -4,8 +4,8 @@ preview Glamourer Glamourer - 0.0.2.0 - 0.0.2.0 + 0.0.3.0 + 0.0.3.0 SoftOtter Glamourer Copyright © 2020 @@ -91,13 +91,13 @@ - - PreserveNewest - + - + + PreserveNewest + diff --git a/Glamourer/Glamourer.json b/Glamourer/Glamourer.json index f90f939..6556e2d 100644 --- a/Glamourer/Glamourer.json +++ b/Glamourer/Glamourer.json @@ -3,7 +3,7 @@ "Name": "Glamourer", "Description": "Adds functionality to change appearance of actors. Requires Penumbra to be installed and activated to work.", "InternalName": "Glamourer", - "AssemblyVersion": "0.0.2.0", + "AssemblyVersion": "0.0.3.0", "RepoUrl": "https://github.com/Ottermandias/Glamourer", "ApplicableVersion": "any", "DalamudApiLevel": 3, diff --git a/Glamourer/Gui/Interface.cs b/Glamourer/Gui/Interface.cs index 848e906..60456a3 100644 --- a/Glamourer/Gui/Interface.cs +++ b/Glamourer/Gui/Interface.cs @@ -2,397 +2,55 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; -using System.Reflection; -using System.Windows.Forms; using Dalamud.Game.ClientState.Actors; -using Dalamud.Game.ClientState.Actors.Types; -using Dalamud.Interface; -using Dalamud.Plugin; -using Glamourer.Customization; +using Glamourer.Designs; using ImGuiNET; -using Lumina.Excel.GeneratedSheets; -using Penumbra.Api; using Penumbra.GameData; using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.PlayerWatch; -using Race = Penumbra.GameData.Enums.Race; namespace Glamourer.Gui { - internal partial class Interface - { - // Push the stain color to type and if it is too bright, turn the text color black. - // Return number of pushed styles. - private static int PushColor(Stain stain, ImGuiCol type = ImGuiCol.Button) - { - ImGui.PushStyleColor(type, stain.RgbaColor); - if (stain.Intensity > 127) - { - ImGui.PushStyleColor(ImGuiCol.Text, 0xFF101010); - return 2; - } - - return 1; - } - - // Update actors without triggering PlayerWatcher Events, - // then manually redraw using Penumbra. - public void UpdateActors(Actor actor) - { - var newEquip = _playerWatcher.UpdateActorWithoutEvent(actor); - GlamourerPlugin.Penumbra?.RedrawActor(actor, RedrawType.WithSettings); - - // Special case for carrying over changes to the gPose actor to the regular player actor, too. - var gPose = _actors[GPoseActorId]; - var player = _actors[0]; - if (gPose != null && actor.Address == gPose.Address && player != null) - newEquip.Write(player.Address); - } - - // Go through a whole customization struct and fix up all settings that need fixing. - private static void FixUpAttributes(LazyCustomization customization) - { - var set = GlamourerPlugin.Customization.GetList(customization.Value.Clan, customization.Value.Gender); - foreach (CustomizationId id in Enum.GetValues(typeof(CustomizationId))) - { - switch (id) - { - case CustomizationId.Race: break; - case CustomizationId.Clan: break; - case CustomizationId.BodyType: break; - case CustomizationId.Gender: break; - case CustomizationId.FacialFeaturesTattoos: break; - case CustomizationId.HighlightsOnFlag: break; - case CustomizationId.Face: - if (customization.Value.Race != Race.Hrothgar) - goto default; - break; - default: - var count = set.Count(id); - if (customization.Value[id] >= count) - if (count == 0) - customization.Value[id] = 0; - else - customization.Value[id] = set.Data(id, 0).Value; - break; - } - } - } - - // Change a race and fix up all required customizations afterwards. - private static bool ChangeRace(LazyCustomization customization, SubRace clan) - { - if (clan == customization.Value.Clan) - return false; - - var race = clan.ToRace(); - customization.Value.Race = race; - customization.Value.Clan = clan; - - customization.Value.Gender = race switch - { - Race.Hrothgar => Gender.Male, - Race.Viera => Gender.Female, - _ => customization.Value.Gender, - }; - - FixUpAttributes(customization); - - return true; - } - - // Change a gender and fix up all required customizations afterwards. - private static bool ChangeGender(LazyCustomization customization, Gender gender) - { - if (gender == customization.Value.Gender) - return false; - - customization.Value.Gender = gender; - FixUpAttributes(customization); - - return true; - } - } - - internal partial class Interface - { - private const float ColorButtonWidth = 22.5f; - private const float ColorComboWidth = 140f; - private const float ItemComboWidth = 300f; - - private ComboWithFilter CreateDefaultStainCombo(IReadOnlyList stains) - => new("##StainCombo", ColorComboWidth, ColorButtonWidth, stains, - s => s.Name.ToString()) - { - Flags = ImGuiComboFlags.NoArrowButton | ImGuiComboFlags.HeightLarge, - PreList = () => - { - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0); - }, - PostList = () => { ImGui.PopStyleVar(3); }, - CreateSelectable = s => - { - var push = PushColor(s); - var ret = ImGui.Button($"{s.Name}##Stain{(byte) s.RowIndex}", - Vector2.UnitX * (ColorComboWidth - ImGui.GetStyle().ScrollbarSize)); - ImGui.PopStyleColor(push); - return ret; - }, - ItemsAtOnce = 12, - }; - - - private ComboWithFilter CreateItemCombo(EquipSlot slot, IReadOnlyList items) - => new($"{_equipSlotNames[slot]}##Equip", ItemComboWidth, ItemComboWidth, items, i => i.Name) - { - Flags = ImGuiComboFlags.HeightLarge, - }; - - private (ComboWithFilter, ComboWithFilter) CreateCombos(EquipSlot slot, IReadOnlyList items, - ComboWithFilter defaultStain) - => (CreateItemCombo(slot, items), new ComboWithFilter($"##{slot}Stain", defaultStain)); - } - - internal partial class Interface - { - private bool DrawStainSelector(ComboWithFilter stainCombo, EquipSlot slot, StainId stainIdx) - { - stainCombo.PostPreview = null; - if (_stains.TryGetValue((byte) stainIdx, out var stain)) - { - var previewPush = PushColor(stain, ImGuiCol.FrameBg); - stainCombo.PostPreview = () => ImGui.PopStyleColor(previewPush); - } - - if (stainCombo.Draw(string.Empty, out var newStain) && _player != null && !newStain.RowIndex.Equals(stainIdx)) - { - newStain.Write(_player.Address, slot); - return true; - } - - return false; - } - - private bool DrawItemSelector(ComboWithFilter equipCombo, Lumina.Excel.GeneratedSheets.Item? item) - { - var currentName = item?.Name.ToString() ?? "Nothing"; - if (equipCombo.Draw(currentName, out var newItem, _itemComboWidth) && _player != null && newItem.Base.RowId != item?.RowId) - { - newItem.Write(_player.Address); - return true; - } - - return false; - } - - private bool DrawEquip(EquipSlot slot, ActorArmor equip) - { - var (equipCombo, stainCombo) = _combos[slot]; - - var ret = DrawStainSelector(stainCombo, slot, equip.Stain); - ImGui.SameLine(); - var item = _identifier.Identify(equip.Set, new WeaponType(), equip.Variant, slot); - ret |= DrawItemSelector(equipCombo, item); - - return ret; - } - - private bool DrawWeapon(EquipSlot slot, ActorWeapon weapon) - { - var (equipCombo, stainCombo) = _combos[slot]; - - var ret = DrawStainSelector(stainCombo, slot, weapon.Stain); - ImGui.SameLine(); - var item = _identifier.Identify(weapon.Set, weapon.Type, weapon.Variant, slot); - ret |= DrawItemSelector(equipCombo, item); - - return ret; - } - } - - internal partial class Interface - { - private static bool DrawColorPickerPopup(string label, CustomizationSet set, CustomizationId id, out Customization.Customization value) - { - value = default; - if (!ImGui.BeginPopup(label, ImGuiWindowFlags.AlwaysAutoResize)) - return false; - - var ret = false; - var count = set.Count(id); - using var raii = new ImGuiRaii().PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) - .PushStyle(ImGuiStyleVar.FrameRounding, 0); - for (var i = 0; i < count; ++i) - { - var custom = set.Data(id, i); - if (ImGui.ColorButton((i + 1).ToString(), ImGui.ColorConvertU32ToFloat4(custom.Color))) - { - value = custom; - ret = true; - ImGui.CloseCurrentPopup(); - } - - if (i % 8 != 7) - ImGui.SameLine(); - } - - ImGui.EndPopup(); - return ret; - } - - private Vector2 _iconSize = Vector2.Zero; - private Vector2 _actualIconSize = Vector2.Zero; - private float _raceSelectorWidth = 0; - private float _inputIntSize = 0; - private float _comboSelectorSize = 0; - private float _percentageSize = 0; - private float _itemComboWidth = 0; - - private bool InputInt(string label, ref int value, int minValue, int maxValue) - { - var ret = false; - var tmp = value + 1; - ImGui.SetNextItemWidth(_inputIntSize); - if (ImGui.InputInt(label, ref tmp, 1) && tmp != value + 1 && tmp >= minValue && tmp <= maxValue) - { - value = tmp - 1; - ret = true; - } - - if (ImGui.IsItemHovered()) - ImGui.SetTooltip($"Input Range: [{minValue}, {maxValue}]"); - - return ret; - } - - private static (int, Customization.Customization) GetCurrentCustomization(LazyCustomization customization, CustomizationId id, - CustomizationSet set) - { - var current = set.DataByValue(id, customization.Value[id], out var custom); - if (current < 0) - { - PluginLog.Warning($"Read invalid customization value {customization.Value[id]} for {id}."); - current = 0; - custom = set.Data(id, 0); - } - - return (current, custom!.Value); - } - - private bool DrawColorPicker(string label, string tooltip, LazyCustomization customization, CustomizationId id, - CustomizationSet set) - { - var ret = false; - var count = set.Count(id); - - var (current, custom) = GetCurrentCustomization(customization, id, set); - - var popupName = $"Color Picker##{id}"; - if (ImGui.ColorButton($"{current + 1}##color_{id}", ImGui.ColorConvertU32ToFloat4(custom.Color), ImGuiColorEditFlags.None, - _actualIconSize)) - ImGui.OpenPopup(popupName); - - ImGui.SameLine(); - - using (var group = ImGuiRaii.NewGroup()) - { - if (InputInt($"##text_{id}", ref current, 1, count)) - { - customization.Value[id] = set.Data(id, current - 1).Value; - ret = true; - } - - - ImGui.Text(label); - if (tooltip.Any() && ImGui.IsItemHovered()) - ImGui.SetTooltip(tooltip); - } - - if (!DrawColorPickerPopup(popupName, set, id, out var newCustom)) - return ret; - - customization.Value[id] = newCustom.Value; - ret = true; - - return ret; - } - } - internal partial class Interface : IDisposable { - public const int GPoseActorId = 201; - private const string PluginName = "Glamourer"; + public const float SelectorWidth = 200; + public const float MinWindowWidth = 675; + public const int GPoseActorId = 201; + private const string PluginName = "Glamourer"; private readonly string _glamourerHeader; private readonly IReadOnlyDictionary _stains; - private readonly IReadOnlyDictionary> _equip; private readonly ActorTable _actors; private readonly IObjectIdentifier _identifier; private readonly Dictionary, ComboWithFilter)> _combos; - private readonly IPlayerWatcher _playerWatcher; private readonly ImGuiScene.TextureWrap? _legacyTattooIcon; private readonly Dictionary _equipSlotNames; + private readonly DesignManager _designs; + private readonly GlamourerPlugin _plugin; private bool _visible = false; + private bool _inGPose = false; - private Actor? _player; - - private static ImGuiScene.TextureWrap? GetLegacyTattooIcon() - { - using var resource = Assembly.GetExecutingAssembly().GetManifestResourceStream("Glamourer.LegacyTattoo.raw"); - if (resource != null) - { - var rawImage = new byte[resource.Length]; - resource.Read(rawImage, 0, (int) resource.Length); - return GlamourerPlugin.PluginInterface.UiBuilder.LoadImageRaw(rawImage, 192, 192, 4); - } - - return null; - } - - private static Dictionary GetEquipSlotNames() - { - var sheet = GlamourerPlugin.PluginInterface.Data.GetExcelSheet(); - var ret = new Dictionary(12) - { - [EquipSlot.MainHand] = sheet.GetRow(738)?.Text.ToString() ?? "Main Hand", - [EquipSlot.OffHand] = sheet.GetRow(739)?.Text.ToString() ?? "Off Hand", - [EquipSlot.Head] = sheet.GetRow(740)?.Text.ToString() ?? "Head", - [EquipSlot.Body] = sheet.GetRow(741)?.Text.ToString() ?? "Body", - [EquipSlot.Hands] = sheet.GetRow(750)?.Text.ToString() ?? "Hands", - [EquipSlot.Legs] = sheet.GetRow(742)?.Text.ToString() ?? "Legs", - [EquipSlot.Feet] = sheet.GetRow(744)?.Text.ToString() ?? "Feet", - [EquipSlot.Ears] = sheet.GetRow(745)?.Text.ToString() ?? "Ears", - [EquipSlot.Neck] = sheet.GetRow(746)?.Text.ToString() ?? "Neck", - [EquipSlot.Wrists] = sheet.GetRow(747)?.Text.ToString() ?? "Wrists", - [EquipSlot.RFinger] = sheet.GetRow(748)?.Text.ToString() ?? "Right Ring", - [EquipSlot.LFinger] = sheet.GetRow(749)?.Text.ToString() ?? "Left Ring", - }; - return ret; - } - - public Interface() + public Interface(GlamourerPlugin plugin) { + _plugin = plugin; + _designs = plugin.Designs; _glamourerHeader = GlamourerPlugin.Version.Length > 0 ? $"{PluginName} v{GlamourerPlugin.Version}###{PluginName}Main" : $"{PluginName}###{PluginName}Main"; - GlamourerPlugin.PluginInterface.UiBuilder.OnBuildUi += Draw; - GlamourerPlugin.PluginInterface.UiBuilder.OnOpenConfigUi += ToggleVisibility; + GlamourerPlugin.PluginInterface.UiBuilder.DisableGposeUiHide = true; + GlamourerPlugin.PluginInterface.UiBuilder.OnBuildUi += Draw; + GlamourerPlugin.PluginInterface.UiBuilder.OnOpenConfigUi += ToggleVisibility; _equipSlotNames = GetEquipSlotNames(); - _stains = GameData.Stains(GlamourerPlugin.PluginInterface); - _equip = GameData.ItemsBySlot(GlamourerPlugin.PluginInterface); - _identifier = Penumbra.GameData.GameData.GetIdentifier(GlamourerPlugin.PluginInterface); - _actors = GlamourerPlugin.PluginInterface.ClientState.Actors; - _playerWatcher = PlayerWatchFactory.Create(GlamourerPlugin.PluginInterface); + _stains = GameData.Stains(GlamourerPlugin.PluginInterface); + _identifier = Penumbra.GameData.GameData.GetIdentifier(GlamourerPlugin.PluginInterface); + _actors = GlamourerPlugin.PluginInterface.ClientState.Actors; var stainCombo = CreateDefaultStainCombo(_stains.Values.ToArray()); - _combos = _equip.ToDictionary(kvp => kvp.Key, kvp => CreateCombos(kvp.Key, kvp.Value, stainCombo)); + var equip = GameData.ItemsBySlot(GlamourerPlugin.PluginInterface); + _combos = equip.ToDictionary(kvp => kvp.Key, kvp => CreateCombos(kvp.Key, kvp.Value, stainCombo)); _legacyTattooIcon = GetLegacyTattooIcon(); } @@ -402,524 +60,37 @@ namespace Glamourer.Gui public void Dispose() { _legacyTattooIcon?.Dispose(); - _playerWatcher?.Dispose(); GlamourerPlugin.PluginInterface.UiBuilder.OnBuildUi -= Draw; GlamourerPlugin.PluginInterface.UiBuilder.OnOpenConfigUi -= ToggleVisibility; } - private string _currentActorName = ""; - - private SubRace _currentSubRace = SubRace.Midlander; - private Gender _currentGender = Gender.Male; - - private bool DrawListSelector(string label, string tooltip, LazyCustomization customization, CustomizationId id, - CustomizationSet set) - { - using var bigGroup = ImGuiRaii.NewGroup(); - var ret = false; - int current = customization.Value[id]; - var count = set.Count(id); - - ImGui.SetNextItemWidth(_comboSelectorSize * ImGui.GetIO().FontGlobalScale); - if (ImGui.BeginCombo($"##combo_{id}", $"{set.Option(id)} #{current + 1}")) - { - for (var i = 0; i < count; ++i) - { - if (ImGui.Selectable($"{set.Option(id)} #{i + 1}##combo", i == current) && i != current) - { - customization.Value[id] = (byte) i; - ret = true; - } - } - - ImGui.EndCombo(); - } - - ImGui.SameLine(); - if (InputInt($"##text_{id}", ref current, 1, count)) - { - customization.Value[id] = set.Data(id, current).Value; - ret = true; - } - - ImGui.SameLine(); - ImGui.Text(label); - if (tooltip.Any() && ImGui.IsItemHovered()) - ImGui.SetTooltip(tooltip); - - return ret; - } - - private static readonly Vector4 NoColor = new(1f, 1f, 1f, 1f); - private static readonly Vector4 RedColor = new(0.6f, 0.3f, 0.3f, 1f); - - private bool DrawMultiSelector(LazyCustomization customization, CustomizationSet set) - { - using var bigGroup = ImGuiRaii.NewGroup(); - var ret = false; - var count = set.Count(CustomizationId.FacialFeaturesTattoos); - using (var raii = ImGuiRaii.NewGroup()) - { - for (var i = 0; i < count; ++i) - { - var enabled = customization.Value.FacialFeature(i); - var feature = set.FacialFeature(set.Race == Race.Hrothgar ? customization.Value.Hairstyle : customization.Value.Face, i); - var icon = i == count - 1 - ? _legacyTattooIcon ?? GlamourerPlugin.Customization.GetIcon(feature.IconId) - : GlamourerPlugin.Customization.GetIcon(feature.IconId); - if (ImGui.ImageButton(icon.ImGuiHandle, _iconSize, Vector2.Zero, Vector2.One, (int) ImGui.GetStyle().FramePadding.X, - Vector4.Zero, - enabled ? NoColor : RedColor)) - { - ret = true; - customization.Value.FacialFeature(i, !enabled); - } - - if (ImGui.IsItemHovered()) - { - using var tt = ImGuiRaii.NewTooltip(); - ImGui.Image(icon.ImGuiHandle, new Vector2(icon.Width, icon.Height)); - } - - if (i % 4 != 3) - ImGui.SameLine(); - } - } - - ImGui.SameLine(); - using var group = ImGuiRaii.NewGroup(); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetTextLineHeightWithSpacing() + 3 * ImGui.GetStyle().ItemSpacing.Y / 2); - int value = customization.Value[CustomizationId.FacialFeaturesTattoos]; - if (InputInt($"##{CustomizationId.FacialFeaturesTattoos}", ref value, 1, 256)) - { - customization.Value[CustomizationId.FacialFeaturesTattoos] = (byte) value; - ret = true; - } - - ImGui.Text(set.Option(CustomizationId.FacialFeaturesTattoos)); - - return ret; - } - - private bool DrawIconPickerPopup(string label, CustomizationSet set, CustomizationId id, out Customization.Customization value) - { - value = default; - if (!ImGui.BeginPopup(label, ImGuiWindowFlags.AlwaysAutoResize)) - return false; - - var ret = false; - var count = set.Count(id); - using var raii = new ImGuiRaii().PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) - .PushStyle(ImGuiStyleVar.FrameRounding, 0); - for (var i = 0; i < count; ++i) - { - var custom = set.Data(id, i); - var icon = GlamourerPlugin.Customization.GetIcon(custom.IconId); - if (ImGui.ImageButton(icon.ImGuiHandle, _iconSize)) - { - value = custom; - ret = true; - ImGui.CloseCurrentPopup(); - } - - if (ImGui.IsItemHovered()) - { - using var tt = ImGuiRaii.NewTooltip(); - ImGui.Image(icon.ImGuiHandle, new Vector2(icon.Width, icon.Height)); - } - - if (i % 8 != 7) - ImGui.SameLine(); - } - - ImGui.EndPopup(); - return ret; - } - - private bool DrawIconSelector(string label, string tooltip, LazyCustomization customization, CustomizationId id, - CustomizationSet set) - { - using var bigGroup = ImGuiRaii.NewGroup(); - var ret = false; - var count = set.Count(id); - - var current = set.DataByValue(id, customization.Value[id], out var custom); - if (current < 0) - { - PluginLog.Warning($"Read invalid customization value {customization.Value[id]} for {id}."); - current = 0; - custom = set.Data(id, 0); - } - - var popupName = $"Style Picker##{id}"; - var icon = GlamourerPlugin.Customization.GetIcon(custom!.Value.IconId); - if (ImGui.ImageButton(icon.ImGuiHandle, _iconSize)) - ImGui.OpenPopup(popupName); - - if (ImGui.IsItemHovered()) - { - using var tt = ImGuiRaii.NewTooltip(); - ImGui.Image(icon.ImGuiHandle, new Vector2(icon.Width, icon.Height)); - } - - ImGui.SameLine(); - using var group = ImGuiRaii.NewGroup(); - if (InputInt($"##text_{id}", ref current, 1, count)) - { - customization.Value[id] = set.Data(id, current).Value; - ret = true; - } - - if (DrawIconPickerPopup(popupName, set, id, out var newCustom)) - { - customization.Value[id] = newCustom.Value; - ret = true; - } - - ImGui.Text(label); - if (tooltip.Any() && ImGui.IsItemHovered()) - ImGui.SetTooltip(tooltip); - - return ret; - } - - - private bool DrawPercentageSelector(string label, string tooltip, LazyCustomization customization, CustomizationId id, - CustomizationSet set) - { - using var bigGroup = ImGuiRaii.NewGroup(); - var ret = false; - int value = customization.Value[id]; - var count = set.Count(id); - ImGui.SetNextItemWidth(_percentageSize * ImGui.GetIO().FontGlobalScale); - if (ImGui.SliderInt($"##slider_{id}", ref value, 0, count - 1, "") && value != customization.Value[id]) - { - customization.Value[id] = (byte) value; - ret = true; - } - - ImGui.SameLine(); - --value; - if (InputInt($"##input_{id}", ref value, 0, count - 1)) - { - customization.Value[id] = (byte) (value + 1); - ret = true; - } - - ImGui.SameLine(); - ImGui.Text(label); - if (tooltip.Any() && ImGui.IsItemHovered()) - ImGui.SetTooltip(tooltip); - - return ret; - } - - private string ClanName(SubRace race, Gender gender) - { - if (gender == Gender.Female) - return race switch - { - SubRace.Midlander => GlamourerPlugin.Customization.GetName(CustomName.MidlanderM), - SubRace.Highlander => GlamourerPlugin.Customization.GetName(CustomName.HighlanderM), - SubRace.Wildwood => GlamourerPlugin.Customization.GetName(CustomName.WildwoodM), - SubRace.Duskwight => GlamourerPlugin.Customization.GetName(CustomName.DuskwightM), - SubRace.Plainsfolk => GlamourerPlugin.Customization.GetName(CustomName.PlainsfolkM), - SubRace.Dunesfolk => GlamourerPlugin.Customization.GetName(CustomName.DunesfolkM), - SubRace.SeekerOfTheSun => GlamourerPlugin.Customization.GetName(CustomName.SeekerOfTheSunM), - SubRace.KeeperOfTheMoon => GlamourerPlugin.Customization.GetName(CustomName.KeeperOfTheMoonM), - SubRace.Seawolf => GlamourerPlugin.Customization.GetName(CustomName.SeawolfM), - SubRace.Hellsguard => GlamourerPlugin.Customization.GetName(CustomName.HellsguardM), - SubRace.Raen => GlamourerPlugin.Customization.GetName(CustomName.RaenM), - SubRace.Xaela => GlamourerPlugin.Customization.GetName(CustomName.XaelaM), - SubRace.Helion => GlamourerPlugin.Customization.GetName(CustomName.HelionM), - SubRace.Lost => GlamourerPlugin.Customization.GetName(CustomName.LostM), - SubRace.Rava => GlamourerPlugin.Customization.GetName(CustomName.RavaF), - SubRace.Veena => GlamourerPlugin.Customization.GetName(CustomName.VeenaF), - _ => throw new ArgumentOutOfRangeException(nameof(race), race, null), - }; - - return race switch - { - SubRace.Midlander => GlamourerPlugin.Customization.GetName(CustomName.MidlanderF), - SubRace.Highlander => GlamourerPlugin.Customization.GetName(CustomName.HighlanderF), - SubRace.Wildwood => GlamourerPlugin.Customization.GetName(CustomName.WildwoodF), - SubRace.Duskwight => GlamourerPlugin.Customization.GetName(CustomName.DuskwightF), - SubRace.Plainsfolk => GlamourerPlugin.Customization.GetName(CustomName.PlainsfolkF), - SubRace.Dunesfolk => GlamourerPlugin.Customization.GetName(CustomName.DunesfolkF), - SubRace.SeekerOfTheSun => GlamourerPlugin.Customization.GetName(CustomName.SeekerOfTheSunF), - SubRace.KeeperOfTheMoon => GlamourerPlugin.Customization.GetName(CustomName.KeeperOfTheMoonF), - SubRace.Seawolf => GlamourerPlugin.Customization.GetName(CustomName.SeawolfF), - SubRace.Hellsguard => GlamourerPlugin.Customization.GetName(CustomName.HellsguardF), - SubRace.Raen => GlamourerPlugin.Customization.GetName(CustomName.RaenF), - SubRace.Xaela => GlamourerPlugin.Customization.GetName(CustomName.XaelaF), - SubRace.Helion => GlamourerPlugin.Customization.GetName(CustomName.HelionM), - SubRace.Lost => GlamourerPlugin.Customization.GetName(CustomName.LostM), - SubRace.Rava => GlamourerPlugin.Customization.GetName(CustomName.RavaF), - SubRace.Veena => GlamourerPlugin.Customization.GetName(CustomName.VeenaF), - _ => throw new ArgumentOutOfRangeException(nameof(race), race, null), - }; - } - - private bool DrawRaceSelector(LazyCustomization customization) - { - using var group = ImGuiRaii.NewGroup(); - var ret = false; - _currentSubRace = customization.Value.Clan; - ImGui.SetNextItemWidth(_raceSelectorWidth); - if (ImGui.BeginCombo("##subRaceCombo", ClanName(_currentSubRace, customization.Value.Gender))) - { - for (var i = 0; i < (int) SubRace.Veena; ++i) - { - if (ImGui.Selectable(ClanName((SubRace) i + 1, customization.Value.Gender), (int) _currentSubRace == i + 1)) - { - _currentSubRace = (SubRace) i + 1; - ret |= ChangeRace(customization, _currentSubRace); - } - } - - ImGui.EndCombo(); - } - - ImGui.Text( - $"{GlamourerPlugin.Customization.GetName(CustomName.Gender)} & {GlamourerPlugin.Customization.GetName(CustomName.Clan)}"); - - return ret; - } - - private bool DrawGenderSelector(LazyCustomization customization) - { - var ret = false; - ImGui.PushFont(UiBuilder.IconFont); - var icon = _currentGender == Gender.Male ? FontAwesomeIcon.Mars : FontAwesomeIcon.Venus; - var restricted = false; - if (customization.Value.Race == Race.Viera) - { - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.25f); - icon = FontAwesomeIcon.VenusDouble; - restricted = true; - } - else if (customization.Value.Race == Race.Hrothgar) - { - ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.25f); - icon = FontAwesomeIcon.MarsDouble; - restricted = true; - } - - if (ImGui.Button(icon.ToIconString(), _actualIconSize) && !restricted) - { - _currentGender = _currentGender == Gender.Male ? Gender.Female : Gender.Male; - ret = ChangeGender(customization, _currentGender); - } - - if (restricted) - ImGui.PopStyleVar(); - ImGui.PopFont(); - return ret; - } - - private bool DrawPicker(CustomizationSet set, CustomizationId id, LazyCustomization customization) - { - if (!set.IsAvailable(id)) - return false; - - switch (set.Type(id)) - { - case CharaMakeParams.MenuType.ColorPicker: return DrawColorPicker(set.OptionName[(int) id], "", customization, id, set); - case CharaMakeParams.MenuType.ListSelector: return DrawListSelector(set.OptionName[(int) id], "", customization, id, set); - case CharaMakeParams.MenuType.IconSelector: return DrawIconSelector(set.OptionName[(int) id], "", customization, id, set); - case CharaMakeParams.MenuType.MultiIconSelector: return DrawMultiSelector(customization, set); - case CharaMakeParams.MenuType.Percentage: return DrawPercentageSelector(set.OptionName[(int) id], "", customization, id, set); - } - - return false; - } - - private static readonly CustomizationId[] AllCustomizations = (CustomizationId[]) Enum.GetValues(typeof(CustomizationId)); - - private bool DrawStuff(LazyCustomization x) - { - _currentSubRace = x.Value.Clan; - _currentGender = x.Value.Gender; - var ret = DrawGenderSelector(x); - ImGui.SameLine(); - ret |= DrawRaceSelector(x); - - var set = GlamourerPlugin.Customization.GetList(_currentSubRace, _currentGender); - - - foreach (var id in AllCustomizations.Where(c => set.Type(c) == CharaMakeParams.MenuType.Percentage)) - ret |= DrawPicker(set, id, x); - - var odd = true; - foreach (var id in AllCustomizations.Where((c, i) => set.Type(c) == CharaMakeParams.MenuType.IconSelector)) - { - ret |= DrawPicker(set, id, x); - if (odd) - ImGui.SameLine(); - odd = !odd; - } - - if (!odd) - ImGui.NewLine(); - - ret |= DrawPicker(set, CustomizationId.FacialFeaturesTattoos, x); - - foreach (var id in AllCustomizations.Where(c => set.Type(c) == CharaMakeParams.MenuType.ListSelector)) - ret |= DrawPicker(set, id, x); - - odd = true; - foreach (var id in AllCustomizations.Where(c => set.Type(c) == CharaMakeParams.MenuType.ColorPicker)) - { - ret |= DrawPicker(set, id, x); - if (odd) - ImGui.SameLine(); - odd = !odd; - } - - if (!odd) - ImGui.NewLine(); - - var tmp = x.Value.HighlightsOn; - if (ImGui.Checkbox(set.Option(CustomizationId.HighlightsOnFlag), ref tmp) && tmp != x.Value.HighlightsOn) - { - x.Value.HighlightsOn = tmp; - ret = true; - } - - var xPos = _inputIntSize + _actualIconSize.X + 3 * ImGui.GetStyle().ItemSpacing.X; - ImGui.SameLine(xPos); - tmp = x.Value.FacePaintReversed; - if (ImGui.Checkbox($"{GlamourerPlugin.Customization.GetName(CustomName.Reverse)} {set.Option(CustomizationId.FacePaint)}", ref tmp) - && tmp != x.Value.FacePaintReversed) - { - x.Value.FacePaintReversed = tmp; - ret = true; - } - - tmp = x.Value.SmallIris; - if (ImGui.Checkbox($"{GlamourerPlugin.Customization.GetName(CustomName.IrisSmall)} {set.Option(CustomizationId.EyeColorL)}", - ref tmp) - && tmp != x.Value.SmallIris) - { - x.Value.SmallIris = tmp; - ret = true; - } - - if (x.Value.Race != Race.Hrothgar) - { - tmp = x.Value.Lipstick; - ImGui.SameLine(xPos); - if (ImGui.Checkbox(set.Option(CustomizationId.LipColor), ref tmp) && tmp != x.Value.Lipstick) - { - x.Value.Lipstick = tmp; - ret = true; - } - } - - return ret; - } - - private void Draw() { - ImGui.SetNextWindowSizeConstraints(Vector2.One * 450 * ImGui.GetIO().FontGlobalScale, + if (!_visible) + return; + + ImGui.SetNextWindowSizeConstraints(Vector2.One * MinWindowWidth * ImGui.GetIO().FontGlobalScale, Vector2.One * 5000 * ImGui.GetIO().FontGlobalScale); - if (!_visible || !ImGui.Begin(_glamourerHeader, ref _visible)) + if (!ImGui.Begin(_glamourerHeader, ref _visible)) return; try { - var inCombo = ImGui.BeginCombo("Actor", _currentActorName); - var idx = 0; - _player = null; - foreach (var actor in _actors.Where(a => a.ObjectKind == ObjectKind.Player)) - { - if (_currentActorName == actor.Name) - _player = actor; + using var raii = new ImGuiRaii(); + if (!raii.Begin(() => ImGui.BeginTabBar("##tabBar"), ImGui.EndTabBar)) + return; - if (inCombo && ImGui.Selectable($"{actor.Name}##{idx++}")) - _currentActorName = actor.Name; - } + _inGPose = _actors[GPoseActorId] != null; + _iconSize = Vector2.One * ImGui.GetTextLineHeightWithSpacing() * 2; + _actualIconSize = _iconSize + 2 * ImGui.GetStyle().FramePadding; + _comboSelectorSize = 4 * _actualIconSize.X + 3 * ImGui.GetStyle().ItemSpacing.X; + _percentageSize = _comboSelectorSize; + _inputIntSize = 2 * _actualIconSize.X + ImGui.GetStyle().ItemSpacing.X; + _raceSelectorWidth = _inputIntSize + _percentageSize - _actualIconSize.X; + _itemComboWidth = 6 * _actualIconSize.X + 4 * ImGui.GetStyle().ItemSpacing.X - ColorButtonWidth + 1; - if (_player == null) - { - _player = _actors[0]; - _currentActorName = _player?.Name ?? string.Empty; - } - - if (inCombo) - ImGui.EndCombo(); - - if (_player == _actors[0] && _actors[GPoseActorId] != null) - _player = _actors[GPoseActorId]; - if (_player == null || !GlamourerPlugin.PluginInterface.ClientState.Condition.Any()) - { - ImGui.TextColored(new Vector4(0.4f, 0.1f, 0.1f, 1f), - "No player character available."); - } - else - { - var equip = new ActorEquipment(_player); - _iconSize = Vector2.One * ImGui.GetTextLineHeightWithSpacing() * 2; - _actualIconSize = _iconSize + 2 * ImGui.GetStyle().FramePadding; - _comboSelectorSize = 4 * _actualIconSize.X + 3 * ImGui.GetStyle().ItemSpacing.X; - _percentageSize = _comboSelectorSize; - _inputIntSize = 2 * _actualIconSize.X + ImGui.GetStyle().ItemSpacing.X; - _raceSelectorWidth = _inputIntSize + _percentageSize - _actualIconSize.X; - _itemComboWidth = 6 * _actualIconSize.X + 4 * ImGui.GetStyle().ItemSpacing.X - ColorButtonWidth + 1; - var changes = false; - - if (ImGui.CollapsingHeader("Character Equipment")) - { - changes |= DrawWeapon(EquipSlot.MainHand, equip.MainHand); - changes |= DrawWeapon(EquipSlot.OffHand, equip.OffHand); - changes |= DrawEquip(EquipSlot.Head, equip.Head); - changes |= DrawEquip(EquipSlot.Body, equip.Body); - changes |= DrawEquip(EquipSlot.Hands, equip.Hands); - changes |= DrawEquip(EquipSlot.Legs, equip.Legs); - changes |= DrawEquip(EquipSlot.Feet, equip.Feet); - changes |= DrawEquip(EquipSlot.Ears, equip.Ears); - changes |= DrawEquip(EquipSlot.Neck, equip.Neck); - changes |= DrawEquip(EquipSlot.Wrists, equip.Wrists); - changes |= DrawEquip(EquipSlot.RFinger, equip.RFinger); - changes |= DrawEquip(EquipSlot.LFinger, equip.LFinger); - } - - var x = new LazyCustomization(_player!.Address); - if (ImGui.CollapsingHeader("Character Customization")) - changes |= DrawStuff(x); - - if (ImGui.Button("Copy to Clipboard")) - { - var save = new CharacterSave(); - save.Load(x.Value); - save.Load(equip); - Clipboard.SetText(save.ToBase64()); - } - - ImGui.SameLine(); - if (ImGui.Button("Apply from Clipboard")) - { - var text = Clipboard.GetText(); - if (text.Any()) - { - try - { - var save = CharacterSave.FromString(text); - save.Customizations.Write(_player.Address); - save.Equipment.Write(_player.Address); - changes = true; - } - catch (Exception e) - { - PluginLog.Information($"{e}"); - } - } - } - - if (changes) - UpdateActors(_player); - } + DrawActorTab(); + DrawSaves(); } finally { diff --git a/Glamourer/Gui/InterfaceActorPanel.cs b/Glamourer/Gui/InterfaceActorPanel.cs new file mode 100644 index 0000000..589a4d5 --- /dev/null +++ b/Glamourer/Gui/InterfaceActorPanel.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using System.Numerics; +using System.Windows.Forms; +using Dalamud.Interface; +using Dalamud.Plugin; +using Glamourer.Designs; +using Glamourer.FileSystem; +using ImGuiNET; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + private readonly CharacterSave _currentSave = new(); + private string _newDesignName = string.Empty; + private bool _keyboardFocus = false; + private const string DesignNamePopupLabel = "Save Design As..."; + private const uint RedHeaderColor = 0xFF1818C0; + private const uint GreenHeaderColor = 0xFF18C018; + + private void DrawActorHeader() + { + var color = _player == null ? RedHeaderColor : GreenHeaderColor; + var buttonColor = ImGui.GetColorU32(ImGuiCol.FrameBg); + using var raii = new ImGuiRaii() + .PushColor(ImGuiCol.Text, color) + .PushColor(ImGuiCol.Button, buttonColor) + .PushColor(ImGuiCol.ButtonHovered, buttonColor) + .PushColor(ImGuiCol.ButtonActive, buttonColor) + .PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0); + ImGui.Button($"{_currentActorName}##actorHeader", -Vector2.UnitX * 0.0001f); + } + + private static void DrawCopyClipboardButton(CharacterSave save) + { + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Clipboard.ToIconString())) + Clipboard.SetText(save.ToBase64()); + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Copy customization code to clipboard."); + } + + private bool DrawApplyClipboardButton() + { + ImGui.PushFont(UiBuilder.IconFont); + var applyButton = ImGui.Button(FontAwesomeIcon.Paste.ToIconString()) && _player != null; + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Apply customization code from clipboard."); + + if (!applyButton) + return false; + + var text = Clipboard.GetText(); + if (!text.Any()) + return false; + + try + { + var save = CharacterSave.FromString(text); + save.Apply(_player!); + } + catch (Exception e) + { + PluginLog.Information($"{e}"); + return false; + } + + return true; + } + + private void DrawSaveDesignButton() + { + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Save.ToIconString())) + OpenDesignNamePopup(DesignNameUse.SaveCurrent); + + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Save the current design."); + + DrawDesignNamePopup(DesignNameUse.SaveCurrent); + } + + private void DrawTargetPlayerButton() + { + if (ImGui.Button("Target Player")) + GlamourerPlugin.PluginInterface.ClientState.Targets.SetCurrentTarget(_player); + } + + private void DrawApplyToPlayerButton(CharacterSave save) + { + if (ImGui.Button("Apply to Self")) + { + var player = _inGPose + ? GlamourerPlugin.PluginInterface.ClientState.Actors[GPoseActorId] + : GlamourerPlugin.PluginInterface.ClientState.LocalPlayer; + var fallback = _inGPose ? GlamourerPlugin.PluginInterface.ClientState.LocalPlayer : null; + if (player != null) + { + save.Apply(player); + if (_inGPose) + save.Apply(fallback!); + _plugin.UpdateActors(player, fallback); + } + } + } + + private void DrawApplyToTargetButton(CharacterSave save) + { + if (ImGui.Button("Apply to Target")) + { + var player = GlamourerPlugin.PluginInterface.ClientState.Targets.CurrentTarget; + if (player != null) + { + var fallBackActor = _playerNames[player.Name]; + save.Apply(player); + if (fallBackActor != null) + save.Apply(fallBackActor); + _plugin.UpdateActors(player, fallBackActor); + } + } + } + + private void SaveNewDesign(CharacterSave save) + { + try + { + var (folder, name) = _designs.FileSystem.CreateAllFolders(_newDesignName); + if (name.Any()) + { + var newDesign = new Design(folder, name) { Data = save }; + folder.AddChild(newDesign); + _designs.Designs[newDesign.FullName()] = save; + _designs.SaveToFile(); + } + } + catch (Exception e) + { + PluginLog.Error($"Could not save new design {_newDesignName}:\n{e}"); + } + } + + private void DrawActorPanel() + { + ImGui.BeginGroup(); + DrawActorHeader(); + if (!ImGui.BeginChild("##actorData", -Vector2.One, true)) + return; + + DrawCopyClipboardButton(_currentSave); + ImGui.SameLine(); + var changes = DrawApplyClipboardButton(); + ImGui.SameLine(); + DrawSaveDesignButton(); + ImGui.SameLine(); + DrawApplyToPlayerButton(_currentSave); + if (!_inGPose) + { + ImGui.SameLine(); + DrawApplyToTargetButton(_currentSave); + if (_player != null) + { + ImGui.SameLine(); + DrawTargetPlayerButton(); + } + } + + + if (DrawCustomization(ref _currentSave.Customizations) && _player != null) + { + _currentSave.Customizations.Write(_player.Address); + changes = true; + } + + changes |= DrawEquip(_currentSave.Equipment); + changes |= DrawMiscellaneous(_currentSave, _player); + + if (_player != null && changes) + _plugin.UpdateActors(_player); + ImGui.EndChild(); + ImGui.EndGroup(); + } + } +} diff --git a/Glamourer/Gui/InterfaceActorSelector.cs b/Glamourer/Gui/InterfaceActorSelector.cs new file mode 100644 index 0000000..b8dc648 --- /dev/null +++ b/Glamourer/Gui/InterfaceActorSelector.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Game.ClientState.Actors; +using Dalamud.Game.ClientState.Actors.Types; +using Dalamud.Interface; +using ImGuiNET; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + private Actor? _player; + private string _currentActorName = string.Empty; + private string _actorFilter = string.Empty; + private string _actorFilterLower = string.Empty; + private readonly Dictionary _playerNames = new(400); + + private void DrawActorFilter() + { + using var raii = new ImGuiRaii() + .PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0); + ImGui.SetNextItemWidth(SelectorWidth * ImGui.GetIO().FontGlobalScale); + if (ImGui.InputTextWithHint("##actorFilter", "Filter Players...", ref _actorFilter, 32)) + _actorFilterLower = _actorFilter.ToLowerInvariant(); + } + + private void DrawActorSelectable(Actor actor, bool gPose) + { + var actorName = actor.Name; + if (!actorName.Any()) + return; + + if (_playerNames.ContainsKey(actorName)) + { + _playerNames[actorName] = actor; + return; + } + + _playerNames.Add(actorName, null); + + var label = gPose ? $"{actorName} (GPose)" : actorName; + if (!_actorFilterLower.Any() || actorName.ToLowerInvariant().Contains(_actorFilterLower)) + if (ImGui.Selectable(label, _currentActorName == actorName)) + { + _currentActorName = actorName; + _currentSave.LoadActor(actor); + _player = actor; + return; + } + + if (_currentActorName == actor.Name) + { + _currentSave.LoadActor(actor); + _player = actor; + } + } + + private void DrawSelectionButtons() + { + using var raii = new ImGuiRaii() + .PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0) + .PushFont(UiBuilder.IconFont); + Actor? select = null; + var buttonWidth = Vector2.UnitX * SelectorWidth / 2; + if (ImGui.Button(FontAwesomeIcon.UserCircle.ToIconString(), buttonWidth)) + select = GlamourerPlugin.PluginInterface.ClientState.LocalPlayer; + raii.PopFonts(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Select the local player character."); + ImGui.SameLine(); + raii.PushFont(UiBuilder.IconFont); + if (_inGPose) + { + raii.PushStyle(ImGuiStyleVar.Alpha, 0.5f); + ImGui.Button(FontAwesomeIcon.HandPointer.ToIconString(), buttonWidth); + raii.PopStyles(); + } + else + { + if (ImGui.Button(FontAwesomeIcon.HandPointer.ToIconString(), buttonWidth)) + select = GlamourerPlugin.PluginInterface.ClientState.Targets.CurrentTarget; + } + + raii.PopFonts(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Select the current target, if it is a player actor."); + + if (select == null || select.ObjectKind != ObjectKind.Player) + return; + + _player = select; + _currentActorName = _player.Name; + _currentSave.LoadActor(_player); + } + + private void DrawActorSelector() + { + ImGui.BeginGroup(); + DrawActorFilter(); + if (!ImGui.BeginChild("##actorSelector", + new Vector2(SelectorWidth * ImGui.GetIO().FontGlobalScale, -ImGui.GetFrameHeight() - 1), true)) + return; + + _playerNames.Clear(); + for (var i = GPoseActorId; i < GPoseActorId + 48; ++i) + { + var actor = _actors[i]; + if (actor == null) + break; + + if (actor.ObjectKind == ObjectKind.Player) + DrawActorSelectable(actor, true); + } + + for (var i = 0; i < GPoseActorId; i += 2) + { + var actor = _actors[i]; + if (actor != null && actor.ObjectKind == ObjectKind.Player) + DrawActorSelectable(actor, false); + } + + + using (var raii = new ImGuiRaii().PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) + { + ImGui.EndChild(); + } + + DrawSelectionButtons(); + ImGui.EndGroup(); + } + + private void DrawActorTab() + { + using var raii = new ImGuiRaii(); + if (!raii.Begin(() => ImGui.BeginTabItem("Current Players"), ImGui.EndTabItem)) + return; + + _player = null; + DrawActorSelector(); + + if (!_currentActorName.Any()) + return; + + ImGui.SameLine(); + DrawActorPanel(); + } + } +} diff --git a/Glamourer/Gui/InterfaceCustomization.cs b/Glamourer/Gui/InterfaceCustomization.cs new file mode 100644 index 0000000..ab4a911 --- /dev/null +++ b/Glamourer/Gui/InterfaceCustomization.cs @@ -0,0 +1,480 @@ +using System; +using System.Linq; +using System.Numerics; +using Dalamud.Interface; +using Dalamud.Plugin; +using Glamourer.Customization; +using ImGuiNET; +using Penumbra.GameData.Enums; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + private static bool DrawColorPickerPopup(string label, CustomizationSet set, CustomizationId id, out Customization.Customization value) + { + value = default; + if (!ImGui.BeginPopup(label, ImGuiWindowFlags.AlwaysAutoResize)) + return false; + + var ret = false; + var count = set.Count(id); + using var raii = new ImGuiRaii().PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0); + for (var i = 0; i < count; ++i) + { + var custom = set.Data(id, i); + if (ImGui.ColorButton((i + 1).ToString(), ImGui.ColorConvertU32ToFloat4(custom.Color))) + { + value = custom; + ret = true; + ImGui.CloseCurrentPopup(); + } + + if (i % 8 != 7) + ImGui.SameLine(); + } + + ImGui.EndPopup(); + return ret; + } + + private Vector2 _iconSize = Vector2.Zero; + private Vector2 _actualIconSize = Vector2.Zero; + private float _raceSelectorWidth = 0; + private float _inputIntSize = 0; + private float _comboSelectorSize = 0; + private float _percentageSize = 0; + private float _itemComboWidth = 0; + + private bool InputInt(string label, ref int value, int minValue, int maxValue) + { + var ret = false; + var tmp = value + 1; + ImGui.SetNextItemWidth(_inputIntSize); + if (ImGui.InputInt(label, ref tmp, 1) && tmp != value + 1 && tmp >= minValue && tmp <= maxValue) + { + value = tmp - 1; + ret = true; + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip($"Input Range: [{minValue}, {maxValue}]"); + + return ret; + } + + private static (int, Customization.Customization) GetCurrentCustomization(ref ActorCustomization customization, CustomizationId id, + CustomizationSet set) + { + var current = set.DataByValue(id, customization[id], out var custom); + if (set.IsAvailable(id) && current < 0) + { + PluginLog.Warning($"Read invalid customization value {customization[id]} for {id}."); + current = 0; + custom = set.Data(id, 0); + } + + return (current, custom!.Value); + } + + private bool DrawColorPicker(string label, string tooltip, ref ActorCustomization customization, CustomizationId id, + CustomizationSet set) + { + var ret = false; + var count = set.Count(id); + + var (current, custom) = GetCurrentCustomization(ref customization, id, set); + + var popupName = $"Color Picker##{id}"; + if (ImGui.ColorButton($"{current + 1}##color_{id}", ImGui.ColorConvertU32ToFloat4(custom.Color), ImGuiColorEditFlags.None, + _actualIconSize)) + ImGui.OpenPopup(popupName); + + ImGui.SameLine(); + + using (var group = ImGuiRaii.NewGroup()) + { + if (InputInt($"##text_{id}", ref current, 1, count)) + { + customization[id] = set.Data(id, current - 1).Value; + ret = true; + } + + + ImGui.Text(label); + if (tooltip.Any() && ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltip); + } + + if (!DrawColorPickerPopup(popupName, set, id, out var newCustom)) + return ret; + + customization[id] = newCustom.Value; + ret = true; + + return ret; + } + + private bool DrawListSelector(string label, string tooltip, ref ActorCustomization customization, CustomizationId id, + CustomizationSet set) + { + using var bigGroup = ImGuiRaii.NewGroup(); + var ret = false; + int current = customization[id]; + var count = set.Count(id); + + ImGui.SetNextItemWidth(_comboSelectorSize * ImGui.GetIO().FontGlobalScale); + if (ImGui.BeginCombo($"##combo_{id}", $"{set.Option(id)} #{current + 1}")) + { + for (var i = 0; i < count; ++i) + { + if (ImGui.Selectable($"{set.Option(id)} #{i + 1}##combo", i == current) && i != current) + { + customization[id] = (byte) i; + ret = true; + } + } + + ImGui.EndCombo(); + } + + ImGui.SameLine(); + if (InputInt($"##text_{id}", ref current, 1, count)) + { + customization[id] = set.Data(id, current).Value; + ret = true; + } + + ImGui.SameLine(); + ImGui.Text(label); + if (tooltip.Any() && ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltip); + + return ret; + } + + + private static readonly Vector4 NoColor = new(1f, 1f, 1f, 1f); + private static readonly Vector4 RedColor = new(0.6f, 0.3f, 0.3f, 1f); + + private bool DrawMultiSelector(ref ActorCustomization customization, CustomizationSet set) + { + using var bigGroup = ImGuiRaii.NewGroup(); + var ret = false; + var count = set.Count(CustomizationId.FacialFeaturesTattoos); + using (var _ = ImGuiRaii.NewGroup()) + { + for (var i = 0; i < count; ++i) + { + var enabled = customization.FacialFeature(i); + var feature = set.FacialFeature(set.Race == Race.Hrothgar ? customization.Hairstyle : customization.Face, i); + var icon = i == count - 1 + ? _legacyTattooIcon ?? GlamourerPlugin.Customization.GetIcon(feature.IconId) + : GlamourerPlugin.Customization.GetIcon(feature.IconId); + if (ImGui.ImageButton(icon.ImGuiHandle, _iconSize, Vector2.Zero, Vector2.One, (int) ImGui.GetStyle().FramePadding.X, + Vector4.Zero, + enabled ? NoColor : RedColor)) + { + ret = true; + customization.FacialFeature(i, !enabled); + } + + if (ImGui.IsItemHovered()) + { + using var tt = ImGuiRaii.NewTooltip(); + ImGui.Image(icon.ImGuiHandle, new Vector2(icon.Width, icon.Height)); + } + + if (i % 4 != 3) + ImGui.SameLine(); + } + } + + ImGui.SameLine(); + using var group = ImGuiRaii.NewGroup(); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetTextLineHeightWithSpacing() + 3 * ImGui.GetStyle().ItemSpacing.Y / 2); + int value = customization[CustomizationId.FacialFeaturesTattoos]; + if (InputInt($"##{CustomizationId.FacialFeaturesTattoos}", ref value, 1, 256)) + { + customization[CustomizationId.FacialFeaturesTattoos] = (byte) value; + ret = true; + } + + ImGui.Text(set.Option(CustomizationId.FacialFeaturesTattoos)); + + return ret; + } + + + private bool DrawIconPickerPopup(string label, CustomizationSet set, CustomizationId id, out Customization.Customization value) + { + value = default; + if (!ImGui.BeginPopup(label, ImGuiWindowFlags.AlwaysAutoResize)) + return false; + + var ret = false; + var count = set.Count(id); + using var raii = new ImGuiRaii().PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0); + for (var i = 0; i < count; ++i) + { + var custom = set.Data(id, i); + var icon = GlamourerPlugin.Customization.GetIcon(custom.IconId); + if (ImGui.ImageButton(icon.ImGuiHandle, _iconSize)) + { + value = custom; + ret = true; + ImGui.CloseCurrentPopup(); + } + + if (ImGui.IsItemHovered()) + { + using var tt = ImGuiRaii.NewTooltip(); + ImGui.Image(icon.ImGuiHandle, new Vector2(icon.Width, icon.Height)); + } + + if (i % 8 != 7) + ImGui.SameLine(); + } + + ImGui.EndPopup(); + return ret; + } + + private bool DrawIconSelector(string label, string tooltip, ref ActorCustomization customization, CustomizationId id, + CustomizationSet set) + { + using var bigGroup = ImGuiRaii.NewGroup(); + var ret = false; + var count = set.Count(id); + + var current = set.DataByValue(id, customization[id], out var custom); + if (current < 0) + { + PluginLog.Warning($"Read invalid customization value {customization[id]} for {id}."); + current = 0; + custom = set.Data(id, 0); + } + + var popupName = $"Style Picker##{id}"; + var icon = GlamourerPlugin.Customization.GetIcon(custom!.Value.IconId); + if (ImGui.ImageButton(icon.ImGuiHandle, _iconSize)) + ImGui.OpenPopup(popupName); + + if (ImGui.IsItemHovered()) + { + using var tt = ImGuiRaii.NewTooltip(); + ImGui.Image(icon.ImGuiHandle, new Vector2(icon.Width, icon.Height)); + } + + ImGui.SameLine(); + using var group = ImGuiRaii.NewGroup(); + if (InputInt($"##text_{id}", ref current, 1, count)) + { + customization[id] = set.Data(id, current).Value; + ret = true; + } + + if (DrawIconPickerPopup(popupName, set, id, out var newCustom)) + { + customization[id] = newCustom.Value; + ret = true; + } + + ImGui.Text(label); + if (tooltip.Any() && ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltip); + + return ret; + } + + + private bool DrawPercentageSelector(string label, string tooltip, ref ActorCustomization customization, CustomizationId id, + CustomizationSet set) + { + using var bigGroup = ImGuiRaii.NewGroup(); + var ret = false; + int value = customization[id]; + var count = set.Count(id); + ImGui.SetNextItemWidth(_percentageSize * ImGui.GetIO().FontGlobalScale); + if (ImGui.SliderInt($"##slider_{id}", ref value, 0, count - 1, "") && value != customization[id]) + { + customization[id] = (byte) value; + ret = true; + } + + ImGui.SameLine(); + --value; + if (InputInt($"##input_{id}", ref value, 0, count - 1)) + { + customization[id] = (byte) (value + 1); + ret = true; + } + + ImGui.SameLine(); + ImGui.Text(label); + if (tooltip.Any() && ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltip); + + return ret; + } + + private bool DrawRaceSelector(ref ActorCustomization customization) + { + using var group = ImGuiRaii.NewGroup(); + var ret = false; + ImGui.SetNextItemWidth(_raceSelectorWidth); + if (ImGui.BeginCombo("##subRaceCombo", ClanName(customization.Clan, customization.Gender))) + { + for (var i = 0; i < (int)SubRace.Veena; ++i) + { + if (ImGui.Selectable(ClanName((SubRace)i + 1, customization.Gender), (int)customization.Clan == i + 1)) + { + var race = (SubRace)i + 1; + ret |= ChangeRace(ref customization, race); + } + } + + ImGui.EndCombo(); + } + + ImGui.Text( + $"{GlamourerPlugin.Customization.GetName(CustomName.Gender)} & {GlamourerPlugin.Customization.GetName(CustomName.Clan)}"); + + return ret; + } + + private bool DrawGenderSelector(ref ActorCustomization customization) + { + var ret = false; + ImGui.PushFont(UiBuilder.IconFont); + var icon = customization.Gender == Gender.Male ? FontAwesomeIcon.Mars : FontAwesomeIcon.Venus; + var restricted = false; + if (customization.Race == Race.Viera) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.25f); + icon = FontAwesomeIcon.VenusDouble; + restricted = true; + } + else if (customization.Race == Race.Hrothgar) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.25f); + icon = FontAwesomeIcon.MarsDouble; + restricted = true; + } + + if (ImGui.Button(icon.ToIconString(), _actualIconSize) && !restricted) + { + var gender = customization.Gender == Gender.Male ? Gender.Female : Gender.Male; + ret = ChangeGender(ref customization, gender); + } + + if (restricted) + ImGui.PopStyleVar(); + ImGui.PopFont(); + return ret; + } + + private bool DrawPicker(CustomizationSet set, CustomizationId id, ref ActorCustomization customization) + { + if (!set.IsAvailable(id)) + return false; + + switch (set.Type(id)) + { + case CharaMakeParams.MenuType.ColorPicker: return DrawColorPicker(set.OptionName[(int)id], "", ref customization, id, set); + case CharaMakeParams.MenuType.ListSelector: return DrawListSelector(set.OptionName[(int)id], "", ref customization, id, set); + case CharaMakeParams.MenuType.IconSelector: return DrawIconSelector(set.OptionName[(int)id], "", ref customization, id, set); + case CharaMakeParams.MenuType.MultiIconSelector: return DrawMultiSelector(ref customization, set); + case CharaMakeParams.MenuType.Percentage: return DrawPercentageSelector(set.OptionName[(int)id], "", ref customization, id, set); + } + + return false; + } + + private static readonly CustomizationId[] AllCustomizations = (CustomizationId[])Enum.GetValues(typeof(CustomizationId)); + private bool DrawCustomization(ref ActorCustomization custom) + { + if (!ImGui.CollapsingHeader("Character Customization")) + return false; + + var ret = DrawGenderSelector(ref custom); + ImGui.SameLine(); + ret |= DrawRaceSelector(ref custom); + + var set = GlamourerPlugin.Customization.GetList(custom.Clan, custom.Gender); + + foreach (var id in AllCustomizations.Where(c => set.Type(c) == CharaMakeParams.MenuType.Percentage)) + ret |= DrawPicker(set, id, ref custom); + + var odd = true; + foreach (var id in AllCustomizations.Where((c, _) => set.Type(c) == CharaMakeParams.MenuType.IconSelector)) + { + ret |= DrawPicker(set, id, ref custom); + if (odd) + ImGui.SameLine(); + odd = !odd; + } + + if (!odd) + ImGui.NewLine(); + + ret |= DrawPicker(set, CustomizationId.FacialFeaturesTattoos, ref custom); + + foreach (var id in AllCustomizations.Where(c => set.Type(c) == CharaMakeParams.MenuType.ListSelector)) + ret |= DrawPicker(set, id, ref custom); + + odd = true; + foreach (var id in AllCustomizations.Where(c => set.Type(c) == CharaMakeParams.MenuType.ColorPicker)) + { + ret |= DrawPicker(set, id, ref custom); + if (odd) + ImGui.SameLine(); + odd = !odd; + } + + if (!odd) + ImGui.NewLine(); + + var tmp = custom.HighlightsOn; + if (ImGui.Checkbox(set.Option(CustomizationId.HighlightsOnFlag), ref tmp) && tmp != custom.HighlightsOn) + { + custom.HighlightsOn = tmp; + ret = true; + } + + var xPos = _inputIntSize + _actualIconSize.X + 3 * ImGui.GetStyle().ItemSpacing.X; + ImGui.SameLine(xPos); + tmp = custom.FacePaintReversed; + if (ImGui.Checkbox($"{GlamourerPlugin.Customization.GetName(CustomName.Reverse)} {set.Option(CustomizationId.FacePaint)}", ref tmp) + && tmp != custom.FacePaintReversed) + { + custom.FacePaintReversed = tmp; + ret = true; + } + + tmp = custom.SmallIris; + if (ImGui.Checkbox($"{GlamourerPlugin.Customization.GetName(CustomName.IrisSmall)} {set.Option(CustomizationId.EyeColorL)}", + ref tmp) + && tmp != custom.SmallIris) + { + custom.SmallIris = tmp; + ret = true; + } + + if (custom.Race != Race.Hrothgar) + { + tmp = custom.Lipstick; + ImGui.SameLine(xPos); + if (ImGui.Checkbox(set.Option(CustomizationId.LipColor), ref tmp) && tmp != custom.Lipstick) + { + custom.Lipstick = tmp; + ret = true; + } + } + + return ret; + } + } +} diff --git a/Glamourer/Gui/InterfaceDesigns.cs b/Glamourer/Gui/InterfaceDesigns.cs new file mode 100644 index 0000000..e81a16f --- /dev/null +++ b/Glamourer/Gui/InterfaceDesigns.cs @@ -0,0 +1,343 @@ +using System; +using System.Linq; +using System.Numerics; +using System.Windows.Forms; +using Dalamud.Interface; +using Dalamud.Plugin; +using Glamourer.Designs; +using Glamourer.FileSystem; +using ImGuiNET; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + private int _totalObject = 0; + + private Design? _selection = null; + private string _newChildName = string.Empty; + + private void DrawDesignSelector() + { + _totalObject = 0; + ImGui.BeginGroup(); + if (ImGui.BeginChild("##selector", new Vector2(SelectorWidth * ImGui.GetIO().FontGlobalScale, - ImGui.GetFrameHeight() - 1) , true)) + { + DrawFolderContent(_designs.FileSystem.Root, SortMode.FoldersFirst); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + DrawDesignSelectorButtons(); + ImGui.EndGroup(); + } + + private void DrawPasteClipboardButton() + { + if (_selection!.Data.WriteProtected) + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.5f); + + ImGui.PushFont(UiBuilder.IconFont); + var applyButton = ImGui.Button(FontAwesomeIcon.Paste.ToIconString()); + ImGui.PopFont(); + if (_selection!.Data.WriteProtected) + ImGui.PopStyleVar(); + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Overwrite with customization code from clipboard."); + + if (_selection!.Data.WriteProtected || !applyButton) + return; + + var text = Clipboard.GetText(); + if (!text.Any()) + return; + + try + { + _selection!.Data = CharacterSave.FromString(text); + _designs.SaveToFile(); + } + catch (Exception e) + { + PluginLog.Information($"{e}"); + } + } + + private void DrawNewFolderButton() + { + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.FolderPlus.ToIconString(), Vector2.UnitX * SelectorWidth / 5)) + OpenDesignNamePopup(DesignNameUse.NewFolder); + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Create a new, empty Folder."); + + DrawDesignNamePopup(DesignNameUse.NewFolder); + } + + private void DrawNewDesignButton() + { + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), Vector2.UnitX * SelectorWidth / 5)) + OpenDesignNamePopup(DesignNameUse.NewDesign); + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Create a new, empty Design."); + + DrawDesignNamePopup(DesignNameUse.NewDesign); + } + + private void DrawClipboardDesignButton() + { + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Paste.ToIconString(), Vector2.UnitX * SelectorWidth / 5)) + OpenDesignNamePopup(DesignNameUse.FromClipboard); + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Create a new design from the customization string in your clipboard."); + + DrawDesignNamePopup(DesignNameUse.FromClipboard); + } + + private void DrawDeleteDesignButton() + { + ImGui.PushFont(UiBuilder.IconFont); + var style = _selection == null; + if (style) + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.5f); + if (ImGui.Button(FontAwesomeIcon.Trash.ToIconString(), Vector2.UnitX * SelectorWidth / 5) && _selection != null) + { + _designs.DeleteAllChildren(_selection, false); + _selection = null; + } + + ImGui.PopFont(); + if (style) + ImGui.PopStyleVar(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Delete the currently selected Design."); + } + + private void DrawDuplicateDesignButton() + { + ImGui.PushFont(UiBuilder.IconFont); + if (_selection == null) + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.5f); + if (ImGui.Button(FontAwesomeIcon.Clone.ToIconString(), Vector2.UnitX * SelectorWidth / 5) && _selection != null) + OpenDesignNamePopup(DesignNameUse.DuplicateDesign); + ImGui.PopFont(); + if (_selection == null) + ImGui.PopStyleVar(); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Clone the currently selected Design."); + + DrawDesignNamePopup(DesignNameUse.DuplicateDesign); + } + + private void DrawDesignSelectorButtons() + { + using var raii = new ImGuiRaii() + .PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .PushStyle(ImGuiStyleVar.FrameRounding, 0f); + + DrawNewFolderButton(); + ImGui.SameLine(); + DrawNewDesignButton(); + ImGui.SameLine(); + DrawClipboardDesignButton(); + ImGui.SameLine(); + DrawDuplicateDesignButton(); + ImGui.SameLine(); + DrawDeleteDesignButton(); + } + + private void DrawDesignHeaderButtons() + { + DrawCopyClipboardButton(_selection!.Data); + ImGui.SameLine(); + DrawPasteClipboardButton(); + ImGui.SameLine(); + DrawApplyToPlayerButton(_selection!.Data); + if (!_inGPose) + { + ImGui.SameLine(); + DrawApplyToTargetButton(_selection!.Data); + } + + ImGui.SameLine(); + DrawCheckbox("Write Protected", _selection!.Data.WriteProtected, v => _selection!.Data.WriteProtected = v, false); + } + + private void DrawDesignPanel() + { + if (ImGui.BeginChild("##details", -Vector2.One * 0.001f, true)) + { + DrawDesignHeaderButtons(); + var data = _selection!.Data; + var prot = _selection!.Data.WriteProtected; + if (prot) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.8f); + data = data.Copy(); + } + + DrawGeneralSettings(data, prot); + var mask = data.WriteEquipment; + if (DrawEquip(data.Equipment, ref mask) && !prot) + { + data.WriteEquipment = mask; + _designs.SaveToFile(); + } + + if (DrawCustomization(ref data.Customizations) && !prot) + _designs.SaveToFile(); + + if (DrawMiscellaneous(data, null) && !prot) + _designs.SaveToFile(); + + if (prot) + ImGui.PopStyleVar(); + + ImGui.EndChild(); + } + } + + private void DrawSaves() + { + using var raii = new ImGuiRaii(); + raii.PushStyle(ImGuiStyleVar.IndentSpacing, 25f); + if (!raii.Begin(() => ImGui.BeginTabItem("Saves"), ImGui.EndTabItem)) + return; + + DrawDesignSelector(); + + if (_selection != null) + { + ImGui.SameLine(); + DrawDesignPanel(); + } + } + + private void DrawCheckbox(string label, bool value, Action setter, bool prot) + { + var tmp = value; + if (ImGui.Checkbox(label, ref tmp) && tmp != value) + { + setter(tmp); + if (!prot) + _designs.SaveToFile(); + } + } + + private void DrawGeneralSettings(CharacterSave data, bool prot) + { + ImGui.BeginGroup(); + DrawCheckbox("Apply Customizations", data.WriteCustomizations, v => data.WriteCustomizations = v, prot); + DrawCheckbox("Write Weapon State", data.SetWeaponState, v => data.SetWeaponState = v, prot); + ImGui.EndGroup(); + ImGui.SameLine(); + ImGui.BeginGroup(); + DrawCheckbox("Write Hat State", data.SetHatState, v => data.SetHatState = v, prot); + DrawCheckbox("Write Visor State", data.SetVisorState, v => data.SetVisorState = v, prot); + ImGui.EndGroup(); + } + + private void RenameChildInput(IFileSystemBase child) + { + ImGui.SetNextItemWidth(150); + if (!ImGui.InputTextWithHint("##fsNewName", "Rename...", ref _newChildName, 64, + ImGuiInputTextFlags.EnterReturnsTrue)) + return; + + if (_newChildName.Any() && _newChildName != child.Name) + try + { + var oldPath = child.FullName(); + if (_designs.FileSystem.Rename(child, _newChildName)) + _designs.UpdateAllChildren(oldPath, child); + } + catch (Exception e) + { + PluginLog.Error($"Could not rename {child.Name} to {_newChildName}:\n{e}"); + } + else if (child is Folder f) + try + { + var oldPath = child.FullName(); + if (_designs.FileSystem.Merge(f, f.Parent, true)) + _designs.UpdateAllChildren(oldPath, f.Parent); + } + catch (Exception e) + { + PluginLog.Error($"Could not merge folder {child.Name} into parent:\n{e}"); + } + + _newChildName = string.Empty; + } + + private void ContextMenu(IFileSystemBase child) + { + var label = $"##fsPopup{child.FullName()}"; + var renameLabel = $"{label}_rename"; + if (ImGui.BeginPopup(label)) + { + if (ImGui.MenuItem("Delete")) + _designs.DeleteAllChildren(child, false); + + RenameChildInput(child); + + if (child is Design d && ImGui.MenuItem("Copy to Clipboard")) + Clipboard.SetText(d.Data.ToBase64()); + + ImGui.EndPopup(); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + _newChildName = child.Name; + ImGui.OpenPopup(label); + } + } + + private void DrawFolderContent(Folder folder, SortMode mode) + { + foreach (var child in folder.AllChildren(mode).ToArray()) + { + if (child.IsFolder(out var subFolder)) + { + var treeNode = ImGui.TreeNodeEx($"{subFolder.Name}##{_totalObject}"); + DrawOrnaments(child); + + if (treeNode) + { + DrawFolderContent(subFolder, mode); + ImGui.TreePop(); + } + else + { + _totalObject += subFolder.TotalDescendantLeaves(); + } + } + else + { + ++_totalObject; + var selected = ImGui.Selectable($"{child.Name}##{_totalObject}", ReferenceEquals(child, _selection)); + DrawOrnaments(child); + + if (selected) + if (child is Design d) + _selection = d; + } + } + } + + private void DrawOrnaments(IFileSystemBase child) + { + FileSystemImGui.DragDropSource(child); + if (FileSystemImGui.DragDropTarget(_designs.FileSystem, child, out var oldPath, out var draggedFolder)) + _designs.UpdateAllChildren(oldPath, draggedFolder!); + ContextMenu(child); + } + } +} diff --git a/Glamourer/Gui/InterfaceEquipment.cs b/Glamourer/Gui/InterfaceEquipment.cs new file mode 100644 index 0000000..56233a9 --- /dev/null +++ b/Glamourer/Gui/InterfaceEquipment.cs @@ -0,0 +1,138 @@ +using ImGuiNET; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + private bool DrawStainSelector(ComboWithFilter stainCombo, EquipSlot slot, StainId stainIdx) + { + stainCombo.PostPreview = null; + if (_stains.TryGetValue((byte) stainIdx, out var stain)) + { + var previewPush = PushColor(stain, ImGuiCol.FrameBg); + stainCombo.PostPreview = () => ImGui.PopStyleColor(previewPush); + } + + if (stainCombo.Draw(string.Empty, out var newStain) && _player != null && !newStain.RowIndex.Equals(stainIdx)) + { + newStain.Write(_player.Address, slot); + return true; + } + + return false; + } + + private bool DrawItemSelector(ComboWithFilter equipCombo, Lumina.Excel.GeneratedSheets.Item? item) + { + var currentName = item?.Name.ToString() ?? "Nothing"; + if (equipCombo.Draw(currentName, out var newItem, _itemComboWidth) && _player != null && newItem.Base.RowId != item?.RowId) + { + newItem.Write(_player.Address); + return true; + } + + return false; + } + + private static bool DrawCheckbox(ActorEquipMask flag, ref ActorEquipMask mask) + { + var tmp = (uint) mask; + var ret = false; + if (ImGui.CheckboxFlags($"##flag_{(uint) flag}", ref tmp, (uint) flag) && tmp != (uint) mask) + { + mask = (ActorEquipMask) tmp; + ret = true; + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Enable writing this slot in this save."); + return ret; + } + + private bool DrawEquipSlot(EquipSlot slot, ActorArmor equip) + { + var (equipCombo, stainCombo) = _combos[slot]; + + var ret = DrawStainSelector(stainCombo, slot, equip.Stain); + ImGui.SameLine(); + var item = _identifier.Identify(equip.Set, new WeaponType(), equip.Variant, slot); + ret |= DrawItemSelector(equipCombo, item); + + return ret; + } + + private bool DrawEquipSlotWithCheck(EquipSlot slot, ActorArmor equip, ActorEquipMask flag, ref ActorEquipMask mask) + { + var ret = DrawCheckbox(flag, ref mask); + ImGui.SameLine(); + ret |= DrawEquipSlot(slot, equip); + return ret; + } + + private bool DrawWeapon(EquipSlot slot, ActorWeapon weapon) + { + var (equipCombo, stainCombo) = _combos[slot]; + + var ret = DrawStainSelector(stainCombo, slot, weapon.Stain); + ImGui.SameLine(); + var item = _identifier.Identify(weapon.Set, weapon.Type, weapon.Variant, slot); + ret |= DrawItemSelector(equipCombo, item); + + return ret; + } + + private bool DrawWeaponWithCheck(EquipSlot slot, ActorWeapon weapon, ActorEquipMask flag, ref ActorEquipMask mask) + { + var ret = DrawCheckbox(flag, ref mask); + ImGui.SameLine(); + ret |= DrawWeapon(slot, weapon); + return ret; + } + + private bool DrawEquip(ActorEquipment equip) + { + var ret = false; + if (ImGui.CollapsingHeader("Character Equipment")) + { + ret |= DrawWeapon(EquipSlot.MainHand, equip.MainHand); + ret |= DrawWeapon(EquipSlot.OffHand, equip.OffHand); + ret |= DrawEquipSlot(EquipSlot.Head, equip.Head); + ret |= DrawEquipSlot(EquipSlot.Body, equip.Body); + ret |= DrawEquipSlot(EquipSlot.Hands, equip.Hands); + ret |= DrawEquipSlot(EquipSlot.Legs, equip.Legs); + ret |= DrawEquipSlot(EquipSlot.Feet, equip.Feet); + ret |= DrawEquipSlot(EquipSlot.Ears, equip.Ears); + ret |= DrawEquipSlot(EquipSlot.Neck, equip.Neck); + ret |= DrawEquipSlot(EquipSlot.Wrists, equip.Wrists); + ret |= DrawEquipSlot(EquipSlot.RFinger, equip.RFinger); + ret |= DrawEquipSlot(EquipSlot.LFinger, equip.LFinger); + } + + return ret; + } + + private bool DrawEquip(ActorEquipment equip, ref ActorEquipMask mask) + { + var ret = false; + if (ImGui.CollapsingHeader("Character Equipment")) + { + ret |= DrawWeaponWithCheck(EquipSlot.MainHand, equip.MainHand, ActorEquipMask.MainHand, ref mask); + ret |= DrawWeaponWithCheck(EquipSlot.OffHand, equip.OffHand, ActorEquipMask.OffHand, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Head, equip.Head, ActorEquipMask.Head, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Body, equip.Body, ActorEquipMask.Body, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Hands, equip.Hands, ActorEquipMask.Hands, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Legs, equip.Legs, ActorEquipMask.Legs, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Feet, equip.Feet, ActorEquipMask.Feet, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Ears, equip.Ears, ActorEquipMask.Ears, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Neck, equip.Neck, ActorEquipMask.Neck, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.Wrists, equip.Wrists, ActorEquipMask.Wrists, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.RFinger, equip.RFinger, ActorEquipMask.RFinger, ref mask); + ret |= DrawEquipSlotWithCheck(EquipSlot.LFinger, equip.LFinger, ActorEquipMask.LFinger, ref mask); + } + + return ret; + } + } +} diff --git a/Glamourer/Gui/InterfaceHelpers.cs b/Glamourer/Gui/InterfaceHelpers.cs new file mode 100644 index 0000000..a7e2036 --- /dev/null +++ b/Glamourer/Gui/InterfaceHelpers.cs @@ -0,0 +1,208 @@ +using System; +using System.Linq; +using System.Windows.Forms; +using Dalamud.Game.ClientState.Actors.Types; +using Dalamud.Plugin; +using Glamourer.Customization; +using ImGuiNET; +using Penumbra.Api; +using Penumbra.GameData.Enums; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + // Push the stain color to type and if it is too bright, turn the text color black. + // Return number of pushed styles. + private static int PushColor(Stain stain, ImGuiCol type = ImGuiCol.Button) + { + ImGui.PushStyleColor(type, stain.RgbaColor); + if (stain.Intensity > 127) + { + ImGui.PushStyleColor(ImGuiCol.Text, 0xFF101010); + return 2; + } + + return 1; + } + + // Go through a whole customization struct and fix up all settings that need fixing. + private static void FixUpAttributes(ref ActorCustomization customization) + { + var set = GlamourerPlugin.Customization.GetList(customization.Clan, customization.Gender); + foreach (CustomizationId id in Enum.GetValues(typeof(CustomizationId))) + { + switch (id) + { + case CustomizationId.Race: break; + case CustomizationId.Clan: break; + case CustomizationId.BodyType: break; + case CustomizationId.Gender: break; + case CustomizationId.FacialFeaturesTattoos: break; + case CustomizationId.HighlightsOnFlag: break; + case CustomizationId.Face: + if (customization.Race != Race.Hrothgar) + goto default; + break; + default: + var count = set.Count(id); + if (set.DataByValue(id, customization[id], out var value) < 0) + if (count == 0) + customization[id] = 0; + else + customization[id] = set.Data(id, 0).Value; + break; + } + } + } + + // Change a race and fix up all required customizations afterwards. + private static bool ChangeRace(ref ActorCustomization customization, SubRace clan) + { + if (clan == customization.Clan) + return false; + + var race = clan.ToRace(); + customization.Race = race; + customization.Clan = clan; + + customization.Gender = race switch + { + Race.Hrothgar => Gender.Male, + Race.Viera => Gender.Female, + _ => customization.Gender, + }; + + FixUpAttributes(ref customization); + + return true; + } + + // Change a gender and fix up all required customizations afterwards. + private static bool ChangeGender(ref ActorCustomization customization, Gender gender) + { + if (gender == customization.Gender) + return false; + + customization.Gender = gender; + FixUpAttributes(ref customization); + + return true; + } + + private static string ClanName(SubRace race, Gender gender) + { + if (gender == Gender.Female) + return race switch + { + SubRace.Midlander => GlamourerPlugin.Customization.GetName(CustomName.MidlanderM), + SubRace.Highlander => GlamourerPlugin.Customization.GetName(CustomName.HighlanderM), + SubRace.Wildwood => GlamourerPlugin.Customization.GetName(CustomName.WildwoodM), + SubRace.Duskwight => GlamourerPlugin.Customization.GetName(CustomName.DuskwightM), + SubRace.Plainsfolk => GlamourerPlugin.Customization.GetName(CustomName.PlainsfolkM), + SubRace.Dunesfolk => GlamourerPlugin.Customization.GetName(CustomName.DunesfolkM), + SubRace.SeekerOfTheSun => GlamourerPlugin.Customization.GetName(CustomName.SeekerOfTheSunM), + SubRace.KeeperOfTheMoon => GlamourerPlugin.Customization.GetName(CustomName.KeeperOfTheMoonM), + SubRace.Seawolf => GlamourerPlugin.Customization.GetName(CustomName.SeawolfM), + SubRace.Hellsguard => GlamourerPlugin.Customization.GetName(CustomName.HellsguardM), + SubRace.Raen => GlamourerPlugin.Customization.GetName(CustomName.RaenM), + SubRace.Xaela => GlamourerPlugin.Customization.GetName(CustomName.XaelaM), + SubRace.Helion => GlamourerPlugin.Customization.GetName(CustomName.HelionM), + SubRace.Lost => GlamourerPlugin.Customization.GetName(CustomName.LostM), + SubRace.Rava => GlamourerPlugin.Customization.GetName(CustomName.RavaF), + SubRace.Veena => GlamourerPlugin.Customization.GetName(CustomName.VeenaF), + _ => throw new ArgumentOutOfRangeException(nameof(race), race, null), + }; + + return race switch + { + SubRace.Midlander => GlamourerPlugin.Customization.GetName(CustomName.MidlanderF), + SubRace.Highlander => GlamourerPlugin.Customization.GetName(CustomName.HighlanderF), + SubRace.Wildwood => GlamourerPlugin.Customization.GetName(CustomName.WildwoodF), + SubRace.Duskwight => GlamourerPlugin.Customization.GetName(CustomName.DuskwightF), + SubRace.Plainsfolk => GlamourerPlugin.Customization.GetName(CustomName.PlainsfolkF), + SubRace.Dunesfolk => GlamourerPlugin.Customization.GetName(CustomName.DunesfolkF), + SubRace.SeekerOfTheSun => GlamourerPlugin.Customization.GetName(CustomName.SeekerOfTheSunF), + SubRace.KeeperOfTheMoon => GlamourerPlugin.Customization.GetName(CustomName.KeeperOfTheMoonF), + SubRace.Seawolf => GlamourerPlugin.Customization.GetName(CustomName.SeawolfF), + SubRace.Hellsguard => GlamourerPlugin.Customization.GetName(CustomName.HellsguardF), + SubRace.Raen => GlamourerPlugin.Customization.GetName(CustomName.RaenF), + SubRace.Xaela => GlamourerPlugin.Customization.GetName(CustomName.XaelaF), + SubRace.Helion => GlamourerPlugin.Customization.GetName(CustomName.HelionM), + SubRace.Lost => GlamourerPlugin.Customization.GetName(CustomName.LostM), + SubRace.Rava => GlamourerPlugin.Customization.GetName(CustomName.RavaF), + SubRace.Veena => GlamourerPlugin.Customization.GetName(CustomName.VeenaF), + _ => throw new ArgumentOutOfRangeException(nameof(race), race, null), + }; + } + + private enum DesignNameUse + { + SaveCurrent, + NewDesign, + DuplicateDesign, + NewFolder, + FromClipboard, + } + + private void DrawDesignNamePopup(DesignNameUse use) + { + if (ImGui.BeginPopup($"{DesignNamePopupLabel}{use}")) + { + if (ImGui.InputText("##designName", ref _newDesignName, 64, ImGuiInputTextFlags.EnterReturnsTrue) + && _newDesignName.Any()) + { + switch (use) + { + case DesignNameUse.SaveCurrent: + SaveNewDesign(_currentSave); + break; + case DesignNameUse.NewDesign: + var empty = new CharacterSave(); + empty.Load(ActorCustomization.Default); + empty.WriteCustomizations = false; + SaveNewDesign(empty); + break; + case DesignNameUse.DuplicateDesign: + SaveNewDesign(_selection!.Data.Copy()); + break; + case DesignNameUse.NewFolder: + _designs.FileSystem.CreateAllFolders($"{_newDesignName}/a"); // Filename is just ignored, but all folders are created. + break; + case DesignNameUse.FromClipboard: + try + { + var text = Clipboard.GetText(); + var save = CharacterSave.FromString(text); + SaveNewDesign(save); + } + catch (Exception e) + { + PluginLog.Information($"Could not save new Design from Clipboard:\n{e}"); + } + + break; + } + + _newDesignName = string.Empty; + ImGui.CloseCurrentPopup(); + } + + if (_keyboardFocus) + { + ImGui.SetKeyboardFocusHere(); + _keyboardFocus = false; + } + + ImGui.EndPopup(); + } + } + + private void OpenDesignNamePopup(DesignNameUse use) + { + _newDesignName = string.Empty; + _keyboardFocus = true; + ImGui.OpenPopup($"{DesignNamePopupLabel}{use}"); + } + } +} diff --git a/Glamourer/Gui/InterfaceInitialization.cs b/Glamourer/Gui/InterfaceInitialization.cs new file mode 100644 index 0000000..c393ac3 --- /dev/null +++ b/Glamourer/Gui/InterfaceInitialization.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Numerics; +using System.Reflection; +using ImGuiNET; +using Penumbra.GameData.Enums; +using Lumina.Excel.GeneratedSheets; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + private const float ColorButtonWidth = 22.5f; + private const float ColorComboWidth = 140f; + private const float ItemComboWidth = 300f; + + private static ComboWithFilter CreateDefaultStainCombo(IReadOnlyList stains) + => new("##StainCombo", ColorComboWidth, ColorButtonWidth, stains, + s => s.Name.ToString()) + { + Flags = ImGuiComboFlags.NoArrowButton | ImGuiComboFlags.HeightLarge, + PreList = () => + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0); + }, + PostList = () => { ImGui.PopStyleVar(3); }, + CreateSelectable = s => + { + var push = PushColor(s); + var ret = ImGui.Button($"{s.Name}##Stain{(byte) s.RowIndex}", + Vector2.UnitX * (ColorComboWidth - ImGui.GetStyle().ScrollbarSize)); + ImGui.PopStyleColor(push); + return ret; + }, + ItemsAtOnce = 12, + }; + + private ComboWithFilter CreateItemCombo(EquipSlot slot, IReadOnlyList items) + => new($"{_equipSlotNames[slot]}##Equip", ItemComboWidth, ItemComboWidth, items, i => i.Name) + { + Flags = ImGuiComboFlags.HeightLarge, + }; + + private (ComboWithFilter, ComboWithFilter) CreateCombos(EquipSlot slot, IReadOnlyList items, + ComboWithFilter defaultStain) + => (CreateItemCombo(slot, items), new ComboWithFilter($"##{slot}Stain", defaultStain)); + + private static ImGuiScene.TextureWrap? GetLegacyTattooIcon() + { + using var resource = Assembly.GetExecutingAssembly().GetManifestResourceStream("Glamourer.LegacyTattoo.raw"); + if (resource != null) + { + var rawImage = new byte[resource.Length]; + resource.Read(rawImage, 0, (int) resource.Length); + return GlamourerPlugin.PluginInterface.UiBuilder.LoadImageRaw(rawImage, 192, 192, 4); + } + + return null; + } + + private static Dictionary GetEquipSlotNames() + { + var sheet = GlamourerPlugin.PluginInterface.Data.GetExcelSheet(); + var ret = new Dictionary(12) + { + [EquipSlot.MainHand] = sheet.GetRow(738)?.Text.ToString() ?? "Main Hand", + [EquipSlot.OffHand] = sheet.GetRow(739)?.Text.ToString() ?? "Off Hand", + [EquipSlot.Head] = sheet.GetRow(740)?.Text.ToString() ?? "Head", + [EquipSlot.Body] = sheet.GetRow(741)?.Text.ToString() ?? "Body", + [EquipSlot.Hands] = sheet.GetRow(742)?.Text.ToString() ?? "Hands", + [EquipSlot.Legs] = sheet.GetRow(744)?.Text.ToString() ?? "Legs", + [EquipSlot.Feet] = sheet.GetRow(745)?.Text.ToString() ?? "Feet", + [EquipSlot.Ears] = sheet.GetRow(746)?.Text.ToString() ?? "Ears", + [EquipSlot.Neck] = sheet.GetRow(747)?.Text.ToString() ?? "Neck", + [EquipSlot.Wrists] = sheet.GetRow(748)?.Text.ToString() ?? "Wrists", + [EquipSlot.RFinger] = sheet.GetRow(749)?.Text.ToString() ?? "Right Ring", + [EquipSlot.LFinger] = sheet.GetRow(750)?.Text.ToString() ?? "Left Ring", + }; + return ret; + } + } +} diff --git a/Glamourer/Gui/InterfaceMiscellaneous.cs b/Glamourer/Gui/InterfaceMiscellaneous.cs new file mode 100644 index 0000000..b14850d --- /dev/null +++ b/Glamourer/Gui/InterfaceMiscellaneous.cs @@ -0,0 +1,64 @@ +using System; +using Dalamud.Game.ClientState.Actors.Types; +using ImGuiNET; + +namespace Glamourer.Gui +{ + internal partial class Interface + { + private static bool DrawCheckMark(string label, bool value, Action setter) + { + var startValue = value; + if (ImGui.Checkbox(label, ref startValue) && startValue != value) + { + setter(startValue); + return true; + } + + return false; + } + + private static bool DrawMiscellaneous(CharacterSave save, Actor? player) + { + var ret = false; + if (!ImGui.CollapsingHeader("Miscellaneous")) + return ret; + + ret |= DrawCheckMark("Hat Visible", save.HatState, v => + { + save.HatState = v; + player?.SetHatHidden(!v); + }); + + ret |= DrawCheckMark("Weapon Visible", save.WeaponState, v => + { + save.WeaponState = v; + player?.SetWeaponHidden(!v); + }); + + ret |= DrawCheckMark("Visor Toggled", save.VisorState, v => + { + save.VisorState = v; + player?.SetVisorToggled(v); + }); + + ret |= DrawCheckMark("Is Wet", save.IsWet, v => + { + save.IsWet = v; + player?.SetWetness(v); + }); + + var alpha = save.Alpha; + if (ImGui.DragFloat("Alpha", ref alpha, 0.01f, 0f, 1f, "%.2f") && alpha != save.Alpha) + { + alpha = (float) Math.Round(alpha > 1 ? 1 : alpha < 0 ? 0 : alpha, 2); + save.Alpha = alpha; + ret = true; + if (player != null) + player.Alpha() = alpha; + } + + return ret; + } + } +} diff --git a/Glamourer/Main.cs b/Glamourer/Main.cs index ad4e953..3a6b063 100644 --- a/Glamourer/Main.cs +++ b/Glamourer/Main.cs @@ -1,144 +1,36 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Security.Cryptography; +using System.Windows.Forms; +using Dalamud.Game.ClientState.Actors.Types; using Dalamud.Game.Command; using Dalamud.Plugin; using Glamourer.Customization; +using Glamourer.Designs; +using Glamourer.FileSystem; using Glamourer.Gui; using ImGuiNET; +using Lumina.Data; using Penumbra.Api; -using Penumbra.GameData.Structs; -using CommandManager = Glamourer.Managers.CommandManager; +using Penumbra.PlayerWatch; namespace Glamourer { - public class CharacterSave - { - public const byte CurrentVersion = 1; - public const byte TotalSizeVersion1 = 1 + 1 + 2 + 56 + ActorCustomization.CustomizationBytes; - - public const byte TotalSize = TotalSizeVersion1; - - private readonly byte[] _bytes = new byte[TotalSize]; - - public CharacterSave() - => _bytes[0] = CurrentVersion; - - public byte Version - => _bytes[0]; - - public bool WriteCustomizations - { - get => _bytes[1] != 0; - set => _bytes[1] = (byte) (value ? 1 : 0); - } - - public ActorEquipMask WriteEquipment - { - get => (ActorEquipMask) ((ushort) _bytes[2] | ((ushort) _bytes[3] << 8)); - set - { - _bytes[2] = (byte) (((ushort) value) & 0xFF); - _bytes[3] = (byte) (((ushort) value) >> 8); - } - } - - public void Load(ActorCustomization customization) - { - WriteCustomizations = true; - customization.WriteBytes(_bytes, 4); - } - - public void Load(ActorEquipment equipment, ActorEquipMask mask = ActorEquipMask.All) - { - WriteEquipment = mask; - equipment.WriteBytes(_bytes, 4 + ActorCustomization.CustomizationBytes); - } - - public string ToBase64() - => System.Convert.ToBase64String(_bytes); - - public void Load(string base64) - { - var bytes = System.Convert.FromBase64String(base64); - switch (bytes[0]) - { - case 1: - if (bytes.Length != TotalSizeVersion1) - throw new Exception( - $"Can not parse Base64 string into CharacterSave:\n\tInvalid size {bytes.Length} instead of {TotalSizeVersion1}."); - if (bytes[1] != 0 && bytes[1] != 1) - throw new Exception( - $"Can not parse Base64 string into CharacterSave:\n\tInvalid value {bytes[1]} in byte 2, should be either 0 or 1."); - - var mask = (ActorEquipMask) ((ushort) bytes[2] | ((ushort) bytes[3] << 8)); - if (!Enum.IsDefined(typeof(ActorEquipMask), mask)) - throw new Exception($"Can not parse Base64 string into CharacterSave:\n\tInvalid value {mask} in byte 3 and 4."); - bytes.CopyTo(_bytes, 0); - break; - default: - throw new Exception($"Can not parse Base64 string into CharacterSave:\n\tInvalid Version {bytes[0]}."); - } - } - - public static CharacterSave FromString(string base64) - { - var ret = new CharacterSave(); - ret.Load(base64); - return ret; - } - - public unsafe ActorCustomization Customizations - { - get - { - var ret = new ActorCustomization(); - fixed (byte* ptr = _bytes) - { - ret.Read(new IntPtr(ptr) + 4); - } - - return ret; - } - } - - public ActorEquipment Equipment - { - get - { - var ret = new ActorEquipment(); - ret.FromBytes(_bytes, 4 + ActorCustomization.CustomizationBytes); - return ret; - } - } - } - - internal class Glamourer - { - private readonly DalamudPluginInterface _pluginInterface; - private readonly CommandManager _commands; - - public Glamourer(DalamudPluginInterface pi) - { - _pluginInterface = pi; - _commands = new CommandManager(_pluginInterface); - } - } - public class GlamourerPlugin : IDalamudPlugin { public const int RequiredPenumbraShareVersion = 1; + private const string HelpString = "[Copy|Apply|Save],[Name or PlaceHolder],"; + public string Name => "Glamourer"; public static DalamudPluginInterface PluginInterface = null!; - private Glamourer _glamourer = null!; private Interface _interface = null!; public static ICustomizationManager Customization = null!; + public DesignManager Designs = null!; + public IPlayerWatcher PlayerWatcher = null!; public static string Version = string.Empty; @@ -166,13 +58,19 @@ namespace Glamourer { if (button == MouseButton.Right && it is Lumina.Excel.GeneratedSheets.Item item) { - var actors = PluginInterface.ClientState.Actors; - var player = actors[Interface.GPoseActorId] ?? actors[0]; - if (player != null) + var actors = PluginInterface.ClientState.Actors; + var gPose = actors[Interface.GPoseActorId]; + var player = actors[0]; + var writeItem = new Item(item, string.Empty); + if (gPose != null) + { + writeItem.Write(gPose.Address); + UpdateActors(gPose, player); + } + else if (player != null) { - var writeItem = new Item(item, string.Empty); writeItem.Write(player.Address); - _interface.UpdateActors(player); + UpdateActors(player); } } } @@ -244,32 +142,187 @@ namespace Glamourer Customization = CustomizationManager.Create(PluginInterface); SetDalamud(PluginInterface); SetPlugins(PluginInterface); + Designs = new DesignManager(PluginInterface); GetPenumbra(); + PlayerWatcher = PlayerWatchFactory.Create(PluginInterface); - PluginInterface.CommandManager.AddHandler("/glamour", new CommandInfo(OnCommand) + PluginInterface.CommandManager.AddHandler("/glamourer", new CommandInfo(OnGlamourer) { - HelpMessage = "/penumbra - toggle ui\n/penumbra reload - reload mod file lists & discover any new mods", + HelpMessage = "Open or close the Glamourer window.", + }); + PluginInterface.CommandManager.AddHandler("/glamour", new CommandInfo(OnGlamour) + { + HelpMessage = $"Use Glamourer Functions: {HelpString}", }); - _glamourer = new Glamourer(PluginInterface); - _interface = new Interface(); + _interface = new Interface(this); } - public void OnCommand(string command, string arguments) + public void OnGlamourer(string command, string arguments) + => _interface?.ToggleVisibility(null!, null!); + + private Actor? GetActor(string name) { - if (GetPenumbra()) - Penumbra!.RedrawAll(RedrawType.WithSettings); - else - PluginLog.Information("Could not get Penumbra."); + var lowerName = name.ToLowerInvariant(); + return lowerName switch + { + "" => null, + "" => PluginInterface.ClientState.Actors[Interface.GPoseActorId] ?? PluginInterface.ClientState.LocalPlayer, + "self" => PluginInterface.ClientState.Actors[Interface.GPoseActorId] ?? PluginInterface.ClientState.LocalPlayer, + "" => PluginInterface.ClientState.Targets.CurrentTarget, + "target" => PluginInterface.ClientState.Targets.CurrentTarget, + "" => PluginInterface.ClientState.Targets.FocusTarget, + "focus" => PluginInterface.ClientState.Targets.FocusTarget, + "" => PluginInterface.ClientState.Targets.MouseOverTarget, + "mouseover" => PluginInterface.ClientState.Targets.MouseOverTarget, + _ => PluginInterface.ClientState.Actors.LastOrDefault( + a => string.Equals(a.Name, lowerName, StringComparison.InvariantCultureIgnoreCase)), + }; } + public void CopyToClipboard(Actor actor) + { + var save = new CharacterSave(); + save.LoadActor(actor); + Clipboard.SetText(save.ToBase64()); + } + + public void ApplyCommand(Actor actor, string target) + { + CharacterSave? save = null; + if (target.ToLowerInvariant() == "clipboard") + { + try + { + save = CharacterSave.FromString(Clipboard.GetText()); + } + catch (Exception) + { + PluginInterface.Framework.Gui.Chat.PrintError("Clipboard does not contain a valid customization string."); + } + } + else if (!Designs.FileSystem.Find(target, out var child) || child is not Design d) + { + PluginInterface.Framework.Gui.Chat.PrintError("The given path to a saved design does not exist or does not point to a design."); + } + else + { + save = d.Data; + } + + save?.Apply(actor); + UpdateActors(actor); + } + + public void SaveCommand(Actor actor, string path) + { + var save = new CharacterSave(); + save.LoadActor(actor); + try + { + var (folder, name) = Designs.FileSystem.CreateAllFolders(path); + var design = new Design(folder, name) { Data = save }; + folder.FindOrAddChild(design); + Designs.Designs.Add(design.FullName(), design.Data); + Designs.SaveToFile(); + } + catch (Exception e) + { + PluginInterface.Framework.Gui.Chat.PrintError("Could not save file:"); + PluginInterface.Framework.Gui.Chat.PrintError($" {e.Message}"); + } + } + + public void OnGlamour(string command, string arguments) + { + static void PrintHelp() + { + PluginInterface.Framework.Gui.Chat.Print("Usage:"); + PluginInterface.Framework.Gui.Chat.Print($" {HelpString}"); + } + + arguments = arguments.Trim(); + if (!arguments.Any()) + { + PrintHelp(); + return; + } + + var split = arguments.Split(new[] + { + ',', + }, 3, StringSplitOptions.RemoveEmptyEntries); + + if (split.Length < 2) + { + PrintHelp(); + return; + } + + var actor = GetActor(split[1]); + if (actor == null) + { + PluginInterface.Framework.Gui.Chat.Print($"Could not find actor for {split[1]}."); + return; + } + + switch (split[0].ToLowerInvariant()) + { + case "copy": + CopyToClipboard(actor); + return; + case "apply": + { + if (split.Length < 3) + { + PluginInterface.Framework.Gui.Chat.Print("Applying requires a name for the save to be applied or 'clipboard'."); + return; + } + ApplyCommand(actor, split[2]); + + return; + } + case "save": + { + if (split.Length < 3) + { + PluginInterface.Framework.Gui.Chat.Print("Saving requires a name for the save."); + return; + } + SaveCommand(actor, split[2]); + return; + } + default: + PrintHelp(); + return; + } + } + public void Dispose() { + PlayerWatcher?.Dispose(); UnregisterFunctions(); _interface?.Dispose(); PluginInterface.CommandManager.RemoveHandler("/glamour"); + PluginInterface.CommandManager.RemoveHandler("/glamourer"); PluginInterface.Dispose(); } + + // Update actors without triggering PlayerWatcher Events, + // then manually redraw using Penumbra. + public void UpdateActors(Actor actor, Actor? gPoseOriginalActor = null) + { + var newEquip = PlayerWatcher.UpdateActorWithoutEvent(actor); + Penumbra?.RedrawActor(actor, RedrawType.WithSettings); + + // Special case for carrying over changes to the gPose actor to the regular player actor, too. + if (gPoseOriginalActor != null) + { + newEquip.Write(gPoseOriginalActor.Address); + PlayerWatcher.UpdateActorWithoutEvent(gPoseOriginalActor); + Penumbra?.RedrawActor(gPoseOriginalActor, RedrawType.AfterGPoseWithSettings); + } + } } } diff --git a/Glamourer/Managers/CommandManager.cs b/Glamourer/Managers/CommandManager.cs deleted file mode 100644 index 68f1ab1..0000000 --- a/Glamourer/Managers/CommandManager.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using System.Text; -using Dalamud.Plugin; -using Glamourer.SeFunctions; - -namespace Glamourer.Managers -{ - public class CommandManager - { - private readonly ProcessChatBox _processChatBox; - private readonly Dalamud.Game.Command.CommandManager _dalamudCommands; - - private readonly IntPtr _uiModulePtr; - - public CommandManager(DalamudPluginInterface pi, BaseUiObject baseUiObject, GetUiModule getUiModule, ProcessChatBox processChatBox) - { - _dalamudCommands = pi.CommandManager; - _processChatBox = processChatBox; - _uiModulePtr = getUiModule.Invoke(Marshal.ReadIntPtr(baseUiObject.Address)); - } - - public CommandManager(DalamudPluginInterface pi) - : this(pi, new BaseUiObject(pi.TargetModuleScanner), new GetUiModule(pi.TargetModuleScanner), - new ProcessChatBox(pi.TargetModuleScanner)) - { } - - public bool Execute(string message) - { - // First try to process the command through Dalamud. - if (_dalamudCommands.ProcessCommand(message)) - { - PluginLog.Verbose("Executed Dalamud command \"{Message:l}\".", message); - return true; - } - - if (_uiModulePtr == IntPtr.Zero) - { - PluginLog.Error("Can not execute \"{Message:l}\" because no uiModulePtr is available.", message); - return false; - } - - // Then prepare a string to send to the game itself. - var (text, length) = PrepareString(message); - var payload = PrepareContainer(text, length); - - _processChatBox.Invoke(_uiModulePtr, payload, IntPtr.Zero, (byte) 0); - - Marshal.FreeHGlobal(payload); - Marshal.FreeHGlobal(text); - return false; - } - - private static (IntPtr, long) PrepareString(string message) - { - var bytes = Encoding.UTF8.GetBytes(message); - var mem = Marshal.AllocHGlobal(bytes.Length + 30); - Marshal.Copy(bytes, 0, mem, bytes.Length); - Marshal.WriteByte(mem + bytes.Length, 0); - return (mem, bytes.Length + 1); - } - - private static IntPtr PrepareContainer(IntPtr message, long length) - { - var mem = Marshal.AllocHGlobal(400); - Marshal.WriteInt64(mem, message.ToInt64()); - Marshal.WriteInt64(mem + 0x8, 64); - Marshal.WriteInt64(mem + 0x10, length); - Marshal.WriteInt64(mem + 0x18, 0); - return mem; - } - } -} diff --git a/Glamourer/SeFunctions/BaseUiObject.cs b/Glamourer/SeFunctions/BaseUiObject.cs deleted file mode 100644 index 9ad54f6..0000000 --- a/Glamourer/SeFunctions/BaseUiObject.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Dalamud.Game; - -namespace Glamourer.SeFunctions -{ - public sealed class BaseUiObject : SeAddressBase - { - public BaseUiObject(SigScanner sigScanner) - : base(sigScanner, "48 8B 0D ?? ?? ?? ?? 48 8D 54 24 ?? 48 83 C1 10 E8") - { } - } -} diff --git a/Glamourer/SeFunctions/GetUiModule.cs b/Glamourer/SeFunctions/GetUiModule.cs deleted file mode 100644 index 8404e50..0000000 --- a/Glamourer/SeFunctions/GetUiModule.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Dalamud.Game; - -namespace Glamourer.SeFunctions -{ - public delegate IntPtr GetUiModuleDelegate(IntPtr baseUiObj); - - public sealed class GetUiModule : SeFunctionBase - { - public GetUiModule(SigScanner sigScanner) - : base(sigScanner, "E8 ?? ?? ?? ?? 48 83 7F ?? 00 48 8B F0") - { } - } -} diff --git a/Glamourer/SeFunctions/ProcessChatBox.cs b/Glamourer/SeFunctions/ProcessChatBox.cs deleted file mode 100644 index 74e2872..0000000 --- a/Glamourer/SeFunctions/ProcessChatBox.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Dalamud.Game; - -namespace Glamourer.SeFunctions -{ - public delegate IntPtr ProcessChatBoxDelegate(IntPtr uiModule, IntPtr message, IntPtr unk1, byte unk2); - - public sealed class ProcessChatBox : SeFunctionBase - { - public ProcessChatBox(SigScanner sigScanner) - : base(sigScanner, "48 89 5C 24 ?? 57 48 83 EC 20 48 8B FA 48 8B D9 45 84 C9") - { } - } -} diff --git a/Glamourer/SeFunctions/SeAddressBase.cs b/Glamourer/SeFunctions/SeAddressBase.cs deleted file mode 100644 index fb4120f..0000000 --- a/Glamourer/SeFunctions/SeAddressBase.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Dalamud.Game; -using Dalamud.Plugin; - -namespace Glamourer.SeFunctions -{ - public class SeAddressBase - { - public readonly IntPtr Address; - - public SeAddressBase(SigScanner sigScanner, string signature, int offset = 0) - { - Address = sigScanner.GetStaticAddressFromSig(signature); - if (Address != IntPtr.Zero) - Address += offset; - var baseOffset = (ulong) Address.ToInt64() - (ulong) sigScanner.Module.BaseAddress.ToInt64(); - PluginLog.Debug($"{GetType().Name} address 0x{Address.ToInt64():X16}, baseOffset 0x{baseOffset:X16}."); - } - } -} diff --git a/Glamourer/SeFunctions/SeFunctionBase.cs b/Glamourer/SeFunctions/SeFunctionBase.cs deleted file mode 100644 index 38addd9..0000000 --- a/Glamourer/SeFunctions/SeFunctionBase.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using Dalamud.Game; -using Dalamud.Hooking; -using Dalamud.Plugin; - -namespace Glamourer.SeFunctions -{ - public class SeFunctionBase where T : Delegate - { - public IntPtr Address; - protected T? FuncDelegate; - - public SeFunctionBase(SigScanner sigScanner, int offset) - { - Address = sigScanner.Module.BaseAddress + offset; - PluginLog.Debug($"{GetType().Name} address 0x{Address.ToInt64():X16}, baseOffset 0x{offset:X16}."); - } - - public SeFunctionBase(SigScanner sigScanner, string signature, int offset = 0) - { - Address = sigScanner.ScanText(signature); - if (Address != IntPtr.Zero) - Address += offset; - var baseOffset = (ulong) Address.ToInt64() - (ulong) sigScanner.Module.BaseAddress.ToInt64(); - PluginLog.Debug($"{GetType().Name} address 0x{Address.ToInt64():X16}, baseOffset 0x{baseOffset:X16}."); - } - - public T? Delegate() - { - if (FuncDelegate != null) - return FuncDelegate; - - if (Address != IntPtr.Zero) - { - FuncDelegate = Marshal.GetDelegateForFunctionPointer(Address); - return FuncDelegate; - } - - PluginLog.Error($"Trying to generate delegate for {GetType().Name}, but no pointer available."); - return null; - } - - public dynamic? Invoke(params dynamic[] parameters) - { - if (FuncDelegate != null) - return FuncDelegate.DynamicInvoke(parameters); - - if (Address != IntPtr.Zero) - { - FuncDelegate = Marshal.GetDelegateForFunctionPointer(Address); - return FuncDelegate!.DynamicInvoke(parameters); - } - else - { - PluginLog.Error($"Trying to call {GetType().Name}, but no pointer available."); - return null; - } - } - - public Hook? CreateHook(T detour) - { - if (Address != IntPtr.Zero) - { - var hook = new Hook(Address, detour); - hook.Enable(); - PluginLog.Debug($"Hooked onto {GetType().Name} at address 0x{Address.ToInt64():X16}."); - return hook; - } - - PluginLog.Error($"Trying to create Hook for {GetType().Name}, but no pointer available."); - return null; - } - } -} diff --git a/repo.json b/repo.json index 82aa6b8..7f258ea 100644 --- a/repo.json +++ b/repo.json @@ -4,8 +4,8 @@ "Name": "Glamourer", "Description": "Adds functionality to change appearance of actors. Requires Penumbra to be installed and activated to work.", "InternalName": "Glamourer", - "AssemblyVersion": "0.0.2.0", - "TestingAssemblyVersion": "0.0.2.0", + "AssemblyVersion": "0.0.3.0", + "TestingAssemblyVersion": "0.0.3.0", "RepoUrl": "https://github.com/Ottermandias/Glamourer", "ApplicableVersion": "any", "DalamudApiLevel": 3,