From f37369cdda54cb77e43d36b9d9e6a597a55968c7 Mon Sep 17 00:00:00 2001 From: Knack117 Date: Fri, 30 Jan 2026 23:52:46 -0500 Subject: [PATCH] Initial release: HSUI v1.0.0.0 - HUD replacement with configurable hotbars Co-authored-by: Cursor --- .gitignore | 28 + Config/Attributes/ConfigTypeAttributes.cs | 682 ++++++++ Config/Attributes/SectionAttributes.cs | 30 + Config/ConfigurationManager.cs | 703 +++++++++ Config/ImportConfig.cs | 301 ++++ Config/OnChangeEventArgs.cs | 43 + Config/PluginConfigObject.cs | 272 ++++ Config/PluginConfigObjectConverter.cs | 310 ++++ Config/Profiles/ImportExportHelper.cs | 48 + Config/Profiles/Profile.cs | 113 ++ Config/Profiles/ProfilesManager.cs | 1024 ++++++++++++ Config/Tree/BaseNode.cs | 372 +++++ Config/Tree/ConfigPageNode.cs | 311 ++++ Config/Tree/FieldNode.cs | 214 +++ Config/Tree/Node.cs | 164 ++ Config/Tree/SectionNode.cs | 147 ++ Config/Tree/SubSectionNode.cs | 173 ++ Config/Windows/ChangelogWindow.cs | 77 + Config/Windows/GridWindow.cs | 56 + Config/Windows/MainConfigWindow.cs | 82 + Enums/BarTextureDrawMode.cs | 10 + Enums/BlendMode.cs | 14 + Enums/DamageType.cs | 14 + Enums/DrawAnchor.cs | 15 + Enums/ElementKind.cs | 58 + Enums/StrataLevel.cs | 13 + Extensions.cs | 139 ++ HSUI.csproj | 56 + HSUI.json | 11 + Helpers/ActionBarsHitTestHelper.cs | 78 + Helpers/ActionBarsManager.cs | 457 ++++++ Helpers/BarTexturesManager.cs | 197 +++ Helpers/ClipRectsHelper.cs | 440 ++++++ Helpers/ColorUtils.cs | 312 ++++ Helpers/DraggablesHelper.cs | 257 +++ Helpers/DrawHelper.cs | 384 +++++ Helpers/EncryptedStringsHelper.cs | 43 + Helpers/ExperienceHelper.cs | 115 ++ Helpers/FontsManager.cs | 233 +++ Helpers/GCDHelper.cs | 86 + Helpers/HonorificHelper.cs | 76 + Helpers/HudLayoutHashHelper.cs | 69 + Helpers/ImGuiHelper.cs | 286 ++++ Helpers/InputsHelper.cs | 621 ++++++++ Helpers/JobsHelper.cs | 710 +++++++++ Helpers/LastUsedCast.cs | 156 ++ Helpers/LayoutHelper.cs | 322 ++++ Helpers/MpTickHelper.cs | 98 ++ Helpers/PetRenamerHelper.cs | 85 + Helpers/PullTimerHelper.cs | 234 +++ Helpers/SmoothHPHelper.cs | 48 + Helpers/SpellHelper.cs | 76 + Helpers/TextTagsHelper.cs | 531 +++++++ Helpers/TexturesHelper.cs | 34 + Helpers/TooltipsHelper.cs | 296 ++++ Helpers/Utils.cs | 429 +++++ Helpers/WhosTalkingHelper.cs | 126 ++ Helpers/WotsitHelper.cs | 145 ++ Interface/Bars/BarConfig.cs | 79 + Interface/Bars/BarHud.cs | 210 +++ Interface/Bars/BarUtilities.cs | 441 ++++++ Interface/Bars/ChunkedBarConfig.cs | 83 + Interface/Bars/ProgressBarConfig.cs | 80 + Interface/Bars/Rect.cs | 23 + Interface/DraggableHudElement.cs | 302 ++++ Interface/EnemyList/EnemyListConfig.cs | 321 ++++ Interface/EnemyList/EnemyListHelper.cs | 88 ++ Interface/EnemyList/EnemyListHud.cs | 428 +++++ Interface/GeneralElements/ActionBarsHud.cs | 1394 +++++++++++++++++ .../GeneralElements/BarTexturesConfig.cs | 159 ++ Interface/GeneralElements/CastbarConfig.cs | 258 +++ Interface/GeneralElements/CastbarHud.cs | 509 ++++++ .../GeneralElements/ExperienceBarConfig.cs | 57 + Interface/GeneralElements/ExperienceBarHud.cs | 84 + Interface/GeneralElements/FontsConfig.cs | 401 +++++ .../GeneralElements/GCDIndicatorConfig.cs | 96 ++ Interface/GeneralElements/GCDIndicatorHud.cs | 247 +++ Interface/GeneralElements/GlobalColors.cs | 478 ++++++ .../GeneralElements/GlobalVisibilityConfig.cs | 54 + Interface/GeneralElements/GridConfig.cs | 42 + Interface/GeneralElements/HUDOptionsConfig.cs | 132 ++ Interface/GeneralElements/HotbarsConfig.cs | 215 +++ .../HotbarsVisibilityConfig.cs | 71 + Interface/GeneralElements/IconConfig.cs | 172 ++ Interface/GeneralElements/LabelConfig.cs | 227 +++ Interface/GeneralElements/LabelHud.cs | 258 +++ Interface/GeneralElements/LimitBreakConfig.cs | 35 + Interface/GeneralElements/LimitBreakHud.cs | 67 + Interface/GeneralElements/MPTickerConfig.cs | 75 + Interface/GeneralElements/MPTickerHud.cs | 110 ++ .../GeneralElements/PrimaryResourceConfig.cs | 139 ++ .../GeneralElements/PrimaryResourceHud.cs | 134 ++ Interface/GeneralElements/PullTimerConfig.cs | 33 + Interface/GeneralElements/PullTimerHud.cs | 53 + Interface/GeneralElements/ShadowConfig.cs | 22 + Interface/GeneralElements/UnitFrameConfig.cs | 401 +++++ Interface/GeneralElements/UnitFrameHud.cs | 528 +++++++ Interface/GeneralElements/VisibilityConfig.cs | 168 ++ .../GeneralElements/WindowClippingConfig.cs | 151 ++ Interface/HudElement.cs | 120 ++ Interface/HudHelper.cs | 506 ++++++ Interface/HudManager.cs | 786 ++++++++++ Interface/Jobs/AstrologianHud.cs | 273 ++++ Interface/Jobs/BardHud.cs | 489 ++++++ Interface/Jobs/BaseJobsConfig.cs | 60 + Interface/Jobs/BlackMageHud.cs | 588 +++++++ Interface/Jobs/BlueMageHud.cs | 313 ++++ Interface/Jobs/CraftersConfig.cs | 56 + Interface/Jobs/DancerHud.cs | 430 +++++ Interface/Jobs/DarkKnightHud.cs | 344 ++++ Interface/Jobs/DragoonHud.cs | 172 ++ Interface/Jobs/GatherersConfig.cs | 31 + Interface/Jobs/GunbreakerHud.cs | 129 ++ Interface/Jobs/JobConfig.cs | 41 + Interface/Jobs/JobHud.cs | 37 + Interface/Jobs/MachinistHud.cs | 257 +++ Interface/Jobs/MonkHud.cs | 464 ++++++ Interface/Jobs/NinjaHud.cs | 341 ++++ Interface/Jobs/PaladinHud.cs | 164 ++ Interface/Jobs/PictomancerHud.cs | 537 +++++++ Interface/Jobs/ReaperHud.cs | 246 +++ Interface/Jobs/RedMageHud.cs | 319 ++++ Interface/Jobs/SageHud.cs | 246 +++ Interface/Jobs/SamuraiHud.cs | 274 ++++ Interface/Jobs/ScholarHud.cs | 209 +++ Interface/Jobs/SummonerHud.cs | 429 +++++ Interface/Jobs/ViperHud.cs | 373 +++++ Interface/Jobs/WarriorHud.cs | 279 ++++ Interface/Jobs/WhiteMageHud.cs | 298 ++++ Interface/Nameplates/Nameplate.cs | 639 ++++++++ Interface/Nameplates/NameplateConfig.cs | 788 ++++++++++ Interface/Nameplates/NameplatesHud.cs | 232 +++ Interface/Nameplates/NameplatesManager.cs | 365 +++++ Interface/Party/PartyFramesBar.cs | 748 +++++++++ Interface/Party/PartyFramesCleanseTracker.cs | 80 + Interface/Party/PartyFramesConfig.cs | 852 ++++++++++ Interface/Party/PartyFramesCooldownListHud.cs | 295 ++++ Interface/Party/PartyFramesHud.cs | 465 ++++++ Interface/Party/PartyFramesInvulnTracker.cs | 111 ++ Interface/Party/PartyFramesMember.cs | 235 +++ Interface/Party/PartyFramesRaiseTracker.cs | 185 +++ Interface/Party/PartyManager.cs | 805 ++++++++++ Interface/Party/PartyOrderHelper.cs | 171 ++ Interface/Party/PartyReadyCheckHelper.cs | 156 ++ Interface/PartyCooldowns/PartyCooldown.cs | 220 +++ .../PartyCooldowns/PartyCooldownsConfig.cs | 884 +++++++++++ Interface/PartyCooldowns/PartyCooldownsHud.cs | 334 ++++ .../PartyCooldowns/PartyCooldownsManager.cs | 412 +++++ .../StatusEffects/CustomEffectsListHud.cs | 34 + .../StatusEffects/StatusEffectsListConfig.cs | 859 ++++++++++ .../StatusEffects/StatusEffectsListHud.cs | 589 +++++++ Media/Fonts/Expressway.ttf | Bin 0 -> 99820 bytes Media/Fonts/Roboto-Black.ttf | Bin 0 -> 168060 bytes Media/Fonts/Roboto-Light.ttf | Bin 0 -> 167000 bytes Media/Fonts/big-noodle-too.ttf | Bin 0 -> 85208 bytes Media/Images/banner_short.png | Bin 0 -> 45853 bytes Media/Images/banner_short_transparent.png | Bin 0 -> 71377 bytes Media/Images/banner_short_x150.png | Bin 0 -> 5497 bytes Media/Images/deafened.png | Bin 0 -> 4394 bytes Media/Images/icon.png | Bin 0 -> 26146 bytes Media/Images/muted.png | Bin 0 -> 3867 bytes Media/Images/speaking.png | Bin 0 -> 3509 bytes Media/Images/test.png | Bin 0 -> 1576 bytes Media/Images/textures/Aluminium.png | Bin 0 -> 2333 bytes Media/Images/textures/BantoBar.png | Bin 0 -> 5102 bytes Media/Images/textures/Bars.png | Bin 0 -> 1196 bytes Media/Images/textures/Bumps.png | Bin 0 -> 3695 bytes Media/Images/textures/Button.png | Bin 0 -> 1327 bytes Media/Images/textures/Charcoal.png | Bin 0 -> 5324 bytes Media/Images/textures/Cilo.png | Bin 0 -> 2123 bytes Media/Images/textures/Cloud.png | Bin 0 -> 4130 bytes Media/Images/textures/Dabs.png | Bin 0 -> 4208 bytes Media/Images/textures/Diagonal.png | Bin 0 -> 2109 bytes Media/Images/textures/Frost.png | Bin 0 -> 14075 bytes Media/Images/textures/Glass.png | Bin 0 -> 1919 bytes Media/Images/textures/Glass2.png | Bin 0 -> 2304 bytes Media/Images/textures/Glaze.png | Bin 0 -> 2483 bytes Media/Images/textures/Gloss.png | Bin 0 -> 1585 bytes Media/Images/textures/Graphite.png | Bin 0 -> 1070 bytes Media/Images/textures/Grid.png | Bin 0 -> 6573 bytes Media/Images/textures/Hatched.png | Bin 0 -> 4289 bytes Media/Images/textures/Healbot.png | Bin 0 -> 5858 bytes Media/Images/textures/LiteStep.png | Bin 0 -> 2728 bytes Media/Images/textures/Lyfe.png | Bin 0 -> 6563 bytes Media/Images/textures/Minimalist.png | Bin 0 -> 1273 bytes Media/Images/textures/Otravi.png | Bin 0 -> 1430 bytes Media/Images/textures/Rain.png | Bin 0 -> 2285 bytes Media/Images/textures/Rocks.png | Bin 0 -> 11161 bytes Media/Images/textures/Round.png | Bin 0 -> 1692 bytes Media/Images/textures/Runes.png | Bin 0 -> 6309 bytes Media/Images/textures/Skewed.png | Bin 0 -> 2409 bytes Media/Images/textures/Smudge.png | Bin 0 -> 7036 bytes Media/Images/textures/Striped.png | Bin 0 -> 5059 bytes Media/Images/textures/Water.png | Bin 0 -> 3997 bytes Media/Images/textures/Wisps.png | Bin 0 -> 2995 bytes Media/Images/textures/Xeon.png | Bin 0 -> 5717 bytes Media/Profiles/Default.HSUI | 1 + Media/Profiles/Default.delvui | 1 + Plugin.cs | 485 ++++++ README.md | 102 ++ changelog.md | 6 + pluginmaster.json | 23 + 202 files changed, 40137 insertions(+) create mode 100644 .gitignore create mode 100644 Config/Attributes/ConfigTypeAttributes.cs create mode 100644 Config/Attributes/SectionAttributes.cs create mode 100644 Config/ConfigurationManager.cs create mode 100644 Config/ImportConfig.cs create mode 100644 Config/OnChangeEventArgs.cs create mode 100644 Config/PluginConfigObject.cs create mode 100644 Config/PluginConfigObjectConverter.cs create mode 100644 Config/Profiles/ImportExportHelper.cs create mode 100644 Config/Profiles/Profile.cs create mode 100644 Config/Profiles/ProfilesManager.cs create mode 100644 Config/Tree/BaseNode.cs create mode 100644 Config/Tree/ConfigPageNode.cs create mode 100644 Config/Tree/FieldNode.cs create mode 100644 Config/Tree/Node.cs create mode 100644 Config/Tree/SectionNode.cs create mode 100644 Config/Tree/SubSectionNode.cs create mode 100644 Config/Windows/ChangelogWindow.cs create mode 100644 Config/Windows/GridWindow.cs create mode 100644 Config/Windows/MainConfigWindow.cs create mode 100644 Enums/BarTextureDrawMode.cs create mode 100644 Enums/BlendMode.cs create mode 100644 Enums/DamageType.cs create mode 100644 Enums/DrawAnchor.cs create mode 100644 Enums/ElementKind.cs create mode 100644 Enums/StrataLevel.cs create mode 100644 Extensions.cs create mode 100644 HSUI.csproj create mode 100644 HSUI.json create mode 100644 Helpers/ActionBarsHitTestHelper.cs create mode 100644 Helpers/ActionBarsManager.cs create mode 100644 Helpers/BarTexturesManager.cs create mode 100644 Helpers/ClipRectsHelper.cs create mode 100644 Helpers/ColorUtils.cs create mode 100644 Helpers/DraggablesHelper.cs create mode 100644 Helpers/DrawHelper.cs create mode 100644 Helpers/EncryptedStringsHelper.cs create mode 100644 Helpers/ExperienceHelper.cs create mode 100644 Helpers/FontsManager.cs create mode 100644 Helpers/GCDHelper.cs create mode 100644 Helpers/HonorificHelper.cs create mode 100644 Helpers/HudLayoutHashHelper.cs create mode 100644 Helpers/ImGuiHelper.cs create mode 100644 Helpers/InputsHelper.cs create mode 100644 Helpers/JobsHelper.cs create mode 100644 Helpers/LastUsedCast.cs create mode 100644 Helpers/LayoutHelper.cs create mode 100644 Helpers/MpTickHelper.cs create mode 100644 Helpers/PetRenamerHelper.cs create mode 100644 Helpers/PullTimerHelper.cs create mode 100644 Helpers/SmoothHPHelper.cs create mode 100644 Helpers/SpellHelper.cs create mode 100644 Helpers/TextTagsHelper.cs create mode 100644 Helpers/TexturesHelper.cs create mode 100644 Helpers/TooltipsHelper.cs create mode 100644 Helpers/Utils.cs create mode 100644 Helpers/WhosTalkingHelper.cs create mode 100644 Helpers/WotsitHelper.cs create mode 100644 Interface/Bars/BarConfig.cs create mode 100644 Interface/Bars/BarHud.cs create mode 100644 Interface/Bars/BarUtilities.cs create mode 100644 Interface/Bars/ChunkedBarConfig.cs create mode 100644 Interface/Bars/ProgressBarConfig.cs create mode 100644 Interface/Bars/Rect.cs create mode 100644 Interface/DraggableHudElement.cs create mode 100644 Interface/EnemyList/EnemyListConfig.cs create mode 100644 Interface/EnemyList/EnemyListHelper.cs create mode 100644 Interface/EnemyList/EnemyListHud.cs create mode 100644 Interface/GeneralElements/ActionBarsHud.cs create mode 100644 Interface/GeneralElements/BarTexturesConfig.cs create mode 100644 Interface/GeneralElements/CastbarConfig.cs create mode 100644 Interface/GeneralElements/CastbarHud.cs create mode 100644 Interface/GeneralElements/ExperienceBarConfig.cs create mode 100644 Interface/GeneralElements/ExperienceBarHud.cs create mode 100644 Interface/GeneralElements/FontsConfig.cs create mode 100644 Interface/GeneralElements/GCDIndicatorConfig.cs create mode 100644 Interface/GeneralElements/GCDIndicatorHud.cs create mode 100644 Interface/GeneralElements/GlobalColors.cs create mode 100644 Interface/GeneralElements/GlobalVisibilityConfig.cs create mode 100644 Interface/GeneralElements/GridConfig.cs create mode 100644 Interface/GeneralElements/HUDOptionsConfig.cs create mode 100644 Interface/GeneralElements/HotbarsConfig.cs create mode 100644 Interface/GeneralElements/HotbarsVisibilityConfig.cs create mode 100644 Interface/GeneralElements/IconConfig.cs create mode 100644 Interface/GeneralElements/LabelConfig.cs create mode 100644 Interface/GeneralElements/LabelHud.cs create mode 100644 Interface/GeneralElements/LimitBreakConfig.cs create mode 100644 Interface/GeneralElements/LimitBreakHud.cs create mode 100644 Interface/GeneralElements/MPTickerConfig.cs create mode 100644 Interface/GeneralElements/MPTickerHud.cs create mode 100644 Interface/GeneralElements/PrimaryResourceConfig.cs create mode 100644 Interface/GeneralElements/PrimaryResourceHud.cs create mode 100644 Interface/GeneralElements/PullTimerConfig.cs create mode 100644 Interface/GeneralElements/PullTimerHud.cs create mode 100644 Interface/GeneralElements/ShadowConfig.cs create mode 100644 Interface/GeneralElements/UnitFrameConfig.cs create mode 100644 Interface/GeneralElements/UnitFrameHud.cs create mode 100644 Interface/GeneralElements/VisibilityConfig.cs create mode 100644 Interface/GeneralElements/WindowClippingConfig.cs create mode 100644 Interface/HudElement.cs create mode 100644 Interface/HudHelper.cs create mode 100644 Interface/HudManager.cs create mode 100644 Interface/Jobs/AstrologianHud.cs create mode 100644 Interface/Jobs/BardHud.cs create mode 100644 Interface/Jobs/BaseJobsConfig.cs create mode 100644 Interface/Jobs/BlackMageHud.cs create mode 100644 Interface/Jobs/BlueMageHud.cs create mode 100644 Interface/Jobs/CraftersConfig.cs create mode 100644 Interface/Jobs/DancerHud.cs create mode 100644 Interface/Jobs/DarkKnightHud.cs create mode 100644 Interface/Jobs/DragoonHud.cs create mode 100644 Interface/Jobs/GatherersConfig.cs create mode 100644 Interface/Jobs/GunbreakerHud.cs create mode 100644 Interface/Jobs/JobConfig.cs create mode 100644 Interface/Jobs/JobHud.cs create mode 100644 Interface/Jobs/MachinistHud.cs create mode 100644 Interface/Jobs/MonkHud.cs create mode 100644 Interface/Jobs/NinjaHud.cs create mode 100644 Interface/Jobs/PaladinHud.cs create mode 100644 Interface/Jobs/PictomancerHud.cs create mode 100644 Interface/Jobs/ReaperHud.cs create mode 100644 Interface/Jobs/RedMageHud.cs create mode 100644 Interface/Jobs/SageHud.cs create mode 100644 Interface/Jobs/SamuraiHud.cs create mode 100644 Interface/Jobs/ScholarHud.cs create mode 100644 Interface/Jobs/SummonerHud.cs create mode 100644 Interface/Jobs/ViperHud.cs create mode 100644 Interface/Jobs/WarriorHud.cs create mode 100644 Interface/Jobs/WhiteMageHud.cs create mode 100644 Interface/Nameplates/Nameplate.cs create mode 100644 Interface/Nameplates/NameplateConfig.cs create mode 100644 Interface/Nameplates/NameplatesHud.cs create mode 100644 Interface/Nameplates/NameplatesManager.cs create mode 100644 Interface/Party/PartyFramesBar.cs create mode 100644 Interface/Party/PartyFramesCleanseTracker.cs create mode 100644 Interface/Party/PartyFramesConfig.cs create mode 100644 Interface/Party/PartyFramesCooldownListHud.cs create mode 100644 Interface/Party/PartyFramesHud.cs create mode 100644 Interface/Party/PartyFramesInvulnTracker.cs create mode 100644 Interface/Party/PartyFramesMember.cs create mode 100644 Interface/Party/PartyFramesRaiseTracker.cs create mode 100644 Interface/Party/PartyManager.cs create mode 100644 Interface/Party/PartyOrderHelper.cs create mode 100644 Interface/Party/PartyReadyCheckHelper.cs create mode 100644 Interface/PartyCooldowns/PartyCooldown.cs create mode 100644 Interface/PartyCooldowns/PartyCooldownsConfig.cs create mode 100644 Interface/PartyCooldowns/PartyCooldownsHud.cs create mode 100644 Interface/PartyCooldowns/PartyCooldownsManager.cs create mode 100644 Interface/StatusEffects/CustomEffectsListHud.cs create mode 100644 Interface/StatusEffects/StatusEffectsListConfig.cs create mode 100644 Interface/StatusEffects/StatusEffectsListHud.cs create mode 100644 Media/Fonts/Expressway.ttf create mode 100644 Media/Fonts/Roboto-Black.ttf create mode 100644 Media/Fonts/Roboto-Light.ttf create mode 100644 Media/Fonts/big-noodle-too.ttf create mode 100644 Media/Images/banner_short.png create mode 100644 Media/Images/banner_short_transparent.png create mode 100644 Media/Images/banner_short_x150.png create mode 100644 Media/Images/deafened.png create mode 100644 Media/Images/icon.png create mode 100644 Media/Images/muted.png create mode 100644 Media/Images/speaking.png create mode 100644 Media/Images/test.png create mode 100644 Media/Images/textures/Aluminium.png create mode 100644 Media/Images/textures/BantoBar.png create mode 100644 Media/Images/textures/Bars.png create mode 100644 Media/Images/textures/Bumps.png create mode 100644 Media/Images/textures/Button.png create mode 100644 Media/Images/textures/Charcoal.png create mode 100644 Media/Images/textures/Cilo.png create mode 100644 Media/Images/textures/Cloud.png create mode 100644 Media/Images/textures/Dabs.png create mode 100644 Media/Images/textures/Diagonal.png create mode 100644 Media/Images/textures/Frost.png create mode 100644 Media/Images/textures/Glass.png create mode 100644 Media/Images/textures/Glass2.png create mode 100644 Media/Images/textures/Glaze.png create mode 100644 Media/Images/textures/Gloss.png create mode 100644 Media/Images/textures/Graphite.png create mode 100644 Media/Images/textures/Grid.png create mode 100644 Media/Images/textures/Hatched.png create mode 100644 Media/Images/textures/Healbot.png create mode 100644 Media/Images/textures/LiteStep.png create mode 100644 Media/Images/textures/Lyfe.png create mode 100644 Media/Images/textures/Minimalist.png create mode 100644 Media/Images/textures/Otravi.png create mode 100644 Media/Images/textures/Rain.png create mode 100644 Media/Images/textures/Rocks.png create mode 100644 Media/Images/textures/Round.png create mode 100644 Media/Images/textures/Runes.png create mode 100644 Media/Images/textures/Skewed.png create mode 100644 Media/Images/textures/Smudge.png create mode 100644 Media/Images/textures/Striped.png create mode 100644 Media/Images/textures/Water.png create mode 100644 Media/Images/textures/Wisps.png create mode 100644 Media/Images/textures/Xeon.png create mode 100644 Media/Profiles/Default.HSUI create mode 100644 Media/Profiles/Default.delvui create mode 100644 Plugin.cs create mode 100644 README.md create mode 100644 changelog.md create mode 100644 pluginmaster.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25ef8a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Build results +bin/ +obj/ +[Bb]uild/ +[Dd]ebug/ +[Rr]elease/ + +# Visual Studio +.vs/ +*.user +*.suo +*.cache + +# Rider +.idea/ + +# User-specific +*.rsuser +*.userprefs + +# NuGet +packages/ +*.nupkg + +# Misc +*.log +*.tmp +*.temp diff --git a/Config/Attributes/ConfigTypeAttributes.cs b/Config/Attributes/ConfigTypeAttributes.cs new file mode 100644 index 0000000..4561c72 --- /dev/null +++ b/Config/Attributes/ConfigTypeAttributes.cs @@ -0,0 +1,682 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Reflection; + +namespace HSUI.Config.Attributes +{ + #region class attributes + [AttributeUsage(AttributeTargets.Class)] + public class ExportableAttribute : Attribute + { + public bool exportable; + + public ExportableAttribute(bool exportable) + { + this.exportable = exportable; + } + } + + public class ShareableAttribute : Attribute + { + public bool shareable; + + public ShareableAttribute(bool shareable) + { + this.shareable = shareable; + } + } + + public class ResettableAttribute : Attribute + { + public bool resettable; + + public ResettableAttribute(bool resettable) + { + this.resettable = resettable; + } + } + + [AttributeUsage(AttributeTargets.Class)] + public class DisableableAttribute : Attribute + { + public bool disableable; + + public DisableableAttribute(bool disableable) + { + this.disableable = disableable; + } + } + + [AttributeUsage(AttributeTargets.Class)] + public class DisableParentSettingsAttribute : Attribute + { + public readonly string[] DisabledFields; + + public DisableParentSettingsAttribute(params string[] fields) + { + this.DisabledFields = fields; + } + } + #endregion + + #region method attributes + [AttributeUsage(AttributeTargets.Method)] + public class ManualDrawAttribute : Attribute + { + } + #endregion + + #region field attributes + [AttributeUsage(AttributeTargets.Field)] + public abstract class ConfigAttribute : Attribute + { + public string friendlyName; + public bool isMonitored = false; + public bool separator = false; + public bool spacing = false; + public string? help = null; + + public ConfigAttribute(string friendlyName) + { + this.friendlyName = friendlyName; + } + + public bool Draw(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader = false) + { + bool result = DrawField(field, config, ID, collapsingHeader); + + if (help != null && ImGui.IsItemHovered()) + { + ImGui.SetTooltip(help); + } + + return result; + } + + public abstract bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader = false); + + protected string IDText(string? ID) => ID != null ? " ##" + ID : ""; + + protected void TriggerChangeEvent(PluginConfigObject config, string fieldName, object value, ChangeType type = ChangeType.None) + { + if (!isMonitored || config is not IOnChangeEventArgs eventObject) + { + return; + } + + eventObject.OnValueChanged(new OnChangeEventArgs(fieldName, (T)value, type)); + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class CheckboxAttribute : ConfigAttribute + { + public CheckboxAttribute(string friendlyName) : base(friendlyName) { } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + var disableable = config.Disableable; + + if (!disableable && friendlyName == "Enabled") + { + if (ID != null) + { + ImGui.Text(ID); + } + return false; + } + + bool? fieldVal = (bool?)field.GetValue(config); + bool boolVal = fieldVal.HasValue ? fieldVal.Value : false; + + if (ImGui.Checkbox(ID != null && friendlyName == "Enabled" && !collapsingHeader ? ID : friendlyName + IDText(ID), ref boolVal)) + { + field.SetValue(config, boolVal); + + TriggerChangeEvent(config, field.Name, boolVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] + public class RadioSelector : ConfigAttribute + { + private string[] _options; + + public string? Label { get; set; } + + public RadioSelector(params string[] options) : base(string.Join("_", options)) + { + _options = options; + } + + public RadioSelector(Type enumType) : this(enumType.IsEnum ? Enum.GetNames(enumType) : Array.Empty()) { } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + bool changed = false; + object? fieldVal = field.GetValue(config); + + int intVal = 0; + if (fieldVal != null) + { + intVal = (int)fieldVal; + } + + string? label = Label ?? friendlyName; + if (!string.IsNullOrEmpty(label) && label != string.Join("_", _options)) + { + ImGui.TextUnformatted(label); + ImGui.SameLine(); + } + + for (int i = 0; i < _options.Length; i++) + { + changed |= ImGui.RadioButton(_options[i], ref intVal, i); + if (i < _options.Length - 1) + { + ImGui.SameLine(); + } + } + + if (changed) + { + field.SetValue(config, intVal); + TriggerChangeEvent(config, field.Name, intVal); + } + + return changed; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class DragFloatAttribute : ConfigAttribute + { + public float min; + public float max; + public float velocity; + + public DragFloatAttribute(string friendlyName) : base(friendlyName) + { + min = 1f; + max = 1000f; + velocity = 1f; + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + float? fieldVal = (float?)field.GetValue(config); + float floatVal = fieldVal.HasValue ? fieldVal.Value : 0; + + if (ImGui.DragFloat(friendlyName + IDText(ID), ref floatVal, velocity, min, max)) + { + field.SetValue(config, floatVal); + + TriggerChangeEvent(config, field.Name, floatVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class DragIntAttribute : ConfigAttribute + { + public int min; + public int max; + public float velocity; + + public DragIntAttribute(string friendlyName) : base(friendlyName) + { + min = 1; + max = 1000; + velocity = 1; + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + int? fieldVal = (int?)field.GetValue(config); + int intVal = fieldVal.HasValue ? fieldVal.Value : 0; + + if (ImGui.DragInt(friendlyName + IDText(ID), ref intVal, velocity, min, max)) + { + field.SetValue(config, intVal); + + TriggerChangeEvent(config, field.Name, intVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class DragFloat2Attribute : ConfigAttribute + { + public float min; + public float max; + public float velocity; + + public DragFloat2Attribute(string friendlyName) : base(friendlyName) + { + min = 1f; + max = 1000f; + velocity = 1f; + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + Vector2? fieldVal = (Vector2?)field.GetValue(config); + Vector2 vectorVal = fieldVal.HasValue ? fieldVal.Value : Vector2.Zero; + + if (ImGui.DragFloat2(friendlyName + IDText(ID), ref vectorVal, velocity, min, max)) + { + field.SetValue(config, vectorVal); + + TriggerChangeEvent(config, field.Name, vectorVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class DragInt2Attribute : ConfigAttribute + { + public int min; + public int max; + public int velocity; + + public DragInt2Attribute(string friendlyName) : base(friendlyName) + { + min = 1; + max = 1000; + velocity = 1; + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + Vector2? fieldVal = (Vector2?)field.GetValue(config); + Vector2 vectorVal = fieldVal.HasValue ? fieldVal.Value : Vector2.Zero; + + if (ImGui.DragFloat2(friendlyName + IDText(ID), ref vectorVal, velocity, min, max)) + { + field.SetValue(config, vectorVal); + + TriggerChangeEvent(config, field.Name, vectorVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class InputTextAttribute : ConfigAttribute + { + public int maxLength; + public bool formattable = true; + + private string _searchText = ""; + + public InputTextAttribute(string friendlyName) : base(friendlyName) + { + this.friendlyName = friendlyName; + maxLength = 999; + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + string? fieldVal = (string?)field.GetValue(config); + string stringVal = fieldVal ?? ""; + string? finalValue = null; + + string popupId = ID != null ? "DelvUI_TextTagsList " + ID : "DelvUI_TextTagsList ##" + friendlyName; + + if (!formattable) + { + if (ImGui.InputText(friendlyName + IDText(ID), ref stringVal)) + { + finalValue = stringVal; + } + } + else + { + float scale = ImGuiHelpers.GlobalScale; + float width = ImGui.CalcItemWidth(); + float height = Math.Max(24 * scale, ImGui.CalcTextSize(stringVal, false, width).Y + 6 * scale); + Vector2 size = new Vector2(width, height); + + if (ImGui.InputTextMultiline(friendlyName + IDText(ID), ref stringVal, maxLength, size, ImGuiInputTextFlags.AllowTabInput)) + { + finalValue = stringVal; + } + + // text tags + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Pen.ToIconString() + "##" + ID)) + { + ImGui.OpenPopup(popupId); + } + ImGui.PopFont(); + + ImGuiHelper.SetTooltip("Text Tags"); + } + + var selectedTag = ImGuiHelper.DrawTextTagsList(popupId, ref _searchText); + if (selectedTag != null) + { + finalValue = stringVal + selectedTag; + } + + if (finalValue != null) + { + field.SetValue(config, finalValue); + TriggerChangeEvent(config, field.Name, finalValue); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class ColorEdit4Attribute : ConfigAttribute + { + public ColorEdit4Attribute(string friendlyName) : base(friendlyName) { } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + PluginConfigColor? colorVal = (PluginConfigColor?)field.GetValue(config); + Vector4 vector = (colorVal != null ? colorVal.Vector : Vector4.Zero); + + if (ImGui.ColorEdit4(friendlyName + IDText(ID), ref vector, ImGuiColorEditFlags.AlphaPreview | ImGuiColorEditFlags.AlphaBar)) + { + if (colorVal is null) + { + return false; + } + + colorVal.Vector = vector; + field.SetValue(config, colorVal); + + TriggerChangeEvent(config, field.Name, colorVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class ComboAttribute : ConfigAttribute + { + public string[] options; + + public ComboAttribute(string friendlyName, params string[] options) : base(friendlyName) + { + this.options = options; + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + object? fieldVal = field.GetValue(config); + + int intVal = 0; + if (fieldVal != null) + { + intVal = (int)fieldVal; + } + + if (ImGui.Combo(friendlyName + IDText(ID), ref intVal, options, 4)) + { + field.SetValue(config, intVal); + + TriggerChangeEvent(config, field.Name, intVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class DragDropHorizontalAttribute : ConfigAttribute + { + public string[] names; + + public DragDropHorizontalAttribute(string friendlyName, params string[] names) : base(friendlyName) + { + this.names = names; + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + ImGui.Text(friendlyName); + int[]? fieldVal = (int[]?)field.GetValue(config); + int[] order = fieldVal ?? Array.Empty(); + + for (int i = 0; i < order.Length; i++) + { + ImGui.SameLine(); + ImGui.Button(names[order[i]], new Vector2(100, 25)); + + if (ImGui.IsItemActive()) + { + float drag_dx = ImGui.GetMouseDragDelta(ImGuiMouseButton.Left).X; + + if ((drag_dx > 80.0f && i < order.Length - 1)) + { + var _curri = order[i]; + order[i] = order[i + 1]; + order[i + 1] = _curri; + field.SetValue(config, order); + ImGui.ResetMouseDragDelta(); + } + else if ((drag_dx < -80.0f && i > 0)) + { + var _curri = order[i]; + order[i] = order[i - 1]; + order[i - 1] = _curri; + field.SetValue(config, order); + ImGui.ResetMouseDragDelta(); + } + + TriggerChangeEvent(config, field.Name, order); + + return true; + } + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class FontAttribute : ConfigAttribute + { + public FontAttribute(string friendlyName = "Font and Size") : base(friendlyName) { } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + var fontsConfig = ConfigurationManager.Instance.GetConfigObject(); + if (fontsConfig == null) + { + return false; + } + + string? stringVal = (string?)field.GetValue(config); + + int index = stringVal == null || stringVal.Length == 0 || !fontsConfig.Fonts.ContainsKey(stringVal) ? -1 : + fontsConfig.Fonts.IndexOfKey(stringVal); + + if (index == -1) + { + if (fontsConfig.Fonts.ContainsKey(FontsConfig.DefaultBigFontKey)) + { + index = fontsConfig.Fonts.IndexOfKey(FontsConfig.DefaultBigFontKey); + } + else + { + index = 0; + } + } + + var options = fontsConfig.Fonts.Values.Select(fontData => fontData.Name + " " + fontData.Size.ToString()).ToArray(); + + if (ImGui.Combo(friendlyName + IDText(ID), ref index, options, 4)) + { + stringVal = fontsConfig.Fonts.Keys[index]; + field.SetValue(config, stringVal); + + TriggerChangeEvent(config, field.Name, stringVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class BarTextureAttribute : ConfigAttribute + { + public BarTextureAttribute(string friendlyName = "Bar Texture") : base(friendlyName) { } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + if (BarTexturesManager.Instance == null) + { + return false; + } + + List textures = BarTexturesManager.Instance.BarTextureNames.ToList(); + string? stringVal = (string?)field.GetValue(config); + + int index = 0; + if (stringVal != null && stringVal.Length > 0 && textures.Contains(stringVal)) + { + index = textures.IndexOf(stringVal); + } + + string[] options = textures.ToArray(); + + if (ImGui.Combo(friendlyName + IDText(ID), ref index, options, 10)) + { + stringVal = options[index]; + field.SetValue(config, stringVal); + + TriggerChangeEvent(config, field.Name, stringVal); + + return true; + } + + return false; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class AnchorAttribute : ComboAttribute + { + public AnchorAttribute(string friendlyName) + : base(friendlyName, new string[] { "Center", "Left", "Right", "Top", "TopLeft", "TopRight", "Bottom", "BottomLeft", "BottomRight" }) + { + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class BarTextureDrawModeAttribute : ComboAttribute + { + public BarTextureDrawModeAttribute(string friendlyName) + : base(friendlyName, new string[] { "Stretch", "Repeat Horizontal", "Repeat Vertical", "Repeat" }) + { + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class StrataLevelAttribute : ConfigAttribute + { + private string[] options = { "Lowest", "Low", "Mid-Low", "Mid", "Mid-High", "High", "Highest" }; + + public StrataLevelAttribute(string friendlyName) : base(friendlyName) + { + } + + public override bool DrawField(FieldInfo field, PluginConfigObject config, string? ID, bool collapsingHeader) + { + object? fieldVal = field.GetValue(config); + + int intVal = 0; + if (fieldVal != null) + { + intVal = (int)fieldVal; + } + + if (ImGui.Combo(friendlyName + IDText(ID), ref intVal, options, 4)) + { + field.SetValue(config, (StrataLevel?)intVal); + + TriggerChangeEvent(config, field.Name, intVal); + ConfigurationManager.Instance?.OnStrataLevelChanged(config); + + return true; + } + + return false; + } + } + #endregion + + #region field ordering attributes + [AttributeUsage(AttributeTargets.Field)] + public class OrderAttribute : Attribute + { + public int pos; + public string? collapseWith = "Enabled"; + + public OrderAttribute(int pos) + { + this.pos = pos; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class NestedConfigAttribute : OrderAttribute + { + public string friendlyName; + public bool separator = false; + public bool spacing = true; + public bool nest = true; + public bool collapsingHeader = true; + + public NestedConfigAttribute(string friendlyName, int pos) : base(pos) + { + this.friendlyName = friendlyName; + + } + } + + #endregion +} diff --git a/Config/Attributes/SectionAttributes.cs b/Config/Attributes/SectionAttributes.cs new file mode 100644 index 0000000..85471f7 --- /dev/null +++ b/Config/Attributes/SectionAttributes.cs @@ -0,0 +1,30 @@ +using System; + +namespace HSUI.Config.Attributes +{ + [AttributeUsage(AttributeTargets.Class)] + public class SectionAttribute : Attribute + { + public string SectionName; + public bool ForceAllowExport; + + public SectionAttribute(string name, bool forceAllowExport = false) + { + SectionName = name; + ForceAllowExport = forceAllowExport; + } + } + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class SubSectionAttribute : Attribute + { + public string SubSectionName; + public int Depth; + + public SubSectionAttribute(string subSectionName, int depth) + { + SubSectionName = subSectionName; + Depth = depth; + } + } +} diff --git a/Config/ConfigurationManager.cs b/Config/ConfigurationManager.cs new file mode 100644 index 0000000..f871615 --- /dev/null +++ b/Config/ConfigurationManager.cs @@ -0,0 +1,703 @@ +using Dalamud.Interface.Internal; +using Dalamud.Interface.Windowing; +using Dalamud.Logging; +using HSUI.Config.Profiles; +using HSUI.Config.Tree; +using HSUI.Config.Windows; +using HSUI.Helpers; +using HSUI.Interface; +using HSUI.Interface.EnemyList; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.Jobs; +using HSUI.Interface.Party; +using HSUI.Interface.PartyCooldowns; +using HSUI.Interface.StatusEffects; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace HSUI.Config +{ + public delegate void ConfigurationManagerEventHandler(ConfigurationManager configurationManager); + public delegate void StrataLevelsEventHandler(ConfigurationManager configurationManager, PluginConfigObject config); + public delegate void GlobalVisibilityEventHandler(ConfigurationManager configurationManager, VisibilityConfig config); + + public class ConfigurationManager : IDisposable + { + public static ConfigurationManager Instance { get; private set; } = null!; + + private BaseNode _configBaseNode; + private Dictionary _configBaseNodeByProfile; + public BaseNode ConfigBaseNode + { + get => _configBaseNode; + set + { + _configBaseNode = value; + _mainConfigWindow.node = value; + } + } + + private WindowSystem _windowSystem; + private MainConfigWindow _mainConfigWindow; + private ChangelogWindow _changelogWindow; + private GridWindow _gridWindow; + + public bool IsConfigWindowOpened => _mainConfigWindow.IsOpen; + public bool IsChangelogWindowOpened => _changelogWindow.IsOpen; + public bool ShowingModalWindow = false; + + public GradientDirection GradientDirection + { + get + { + var config = Instance.GetConfigObject(); + return config != null ? config.GradientDirection : GradientDirection.None; + } + } + + public bool OverrideDalamudStyle + { + get + { + HUDOptionsConfig config = Instance.GetConfigObject(); + return config != null ? config.OverrideDalamudStyle : true; + } + } + + public CultureInfo ActiveCultreInfo + { + get { + HUDOptionsConfig config = Instance.GetConfigObject(); + return config == null || config.UseRegionalNumberFormats ? CultureInfo.CurrentCulture : CultureInfo.InvariantCulture; + } + } + + public string ConfigDirectory; + + public string CurrentVersion => Plugin.Version; + public string? PreviousVersion { get; private set; } = null; + + private bool _needsProfileUpdate = false; + private bool _lockHUD = true; + + public bool LockHUD + { + get => _lockHUD; + set + { + if (_lockHUD == value) + { + return; + } + + _lockHUD = value; + _mainConfigWindow.IsOpen = value; + _gridWindow.IsOpen = !value; + + LockEvent?.Invoke(this); + + if (_lockHUD) + { + ConfigBaseNode.NeedsSave = true; + } + } + } + + public bool ShowHUD = true; + + public event ConfigurationManagerEventHandler? ResetEvent; + public event ConfigurationManagerEventHandler? LockEvent; + public event ConfigurationManagerEventHandler? ConfigClosedEvent; + public event StrataLevelsEventHandler? StrataLevelsChangedEvent; + public event GlobalVisibilityEventHandler? GlobalVisibilityEvent; + + public ConfigurationManager() + { + ConfigDirectory = Plugin.PluginInterface.GetPluginConfigDirectory(); + + _configBaseNodeByProfile = new Dictionary(); + _configBaseNode = new BaseNode(); + InitializeBaseNode(_configBaseNode); + _configBaseNode.ConfigObjectResetEvent += OnConfigObjectReset; + + _mainConfigWindow = new MainConfigWindow("HSUI Settings"); + _mainConfigWindow.node = _configBaseNode; + _mainConfigWindow.CloseAction = () => + { + ConfigClosedEvent?.Invoke(this); + + if (ConfigBaseNode.NeedsSave) + { + SaveConfigurations(); + } + + if (_needsProfileUpdate) + { + UpdateCurrentProfile(); + _needsProfileUpdate = false; + } + }; + + string changelog = LoadChangelog(); + _changelogWindow = new ChangelogWindow("HSUI Changelog v" + Plugin.Version, changelog); + _gridWindow = new GridWindow("Grid ##HSUI"); + + _windowSystem = new WindowSystem("HSUI_Windows"); + _windowSystem.AddWindow(_mainConfigWindow); + _windowSystem.AddWindow(_changelogWindow); + _windowSystem.AddWindow(_gridWindow); + + CheckVersion(); + + Plugin.ClientState.Logout += OnLogout; + Plugin.JobChangedEvent += OnJobChanged; + + _configBaseNode.CreateNodesIfNeeded(); + } + + ~ConfigurationManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigBaseNode.ConfigObjectResetEvent -= OnConfigObjectReset; + Plugin.ClientState.Logout -= OnLogout; + Plugin.JobChangedEvent -= OnJobChanged; + + Instance = null!; + } + + public static void Initialize() { Instance = new ConfigurationManager(); } + + private void OnConfigObjectReset(BaseNode sender) + { + ResetEvent?.Invoke(this); + } + + private void OnLogout(int type, int code) + { + SaveConfigurations(); + ProfilesManager.Instance?.SaveCurrentProfile(); + } + + private void OnJobChanged(uint jobId) + { + UpdateCurrentProfile(); + } + + private string LoadChangelog() + { + string path = Path.Combine(Plugin.AssemblyLocation, "changelog.md"); + + try + { + string fullChangelog = File.ReadAllText(path); + string versionChangelog = fullChangelog.Split("#", StringSplitOptions.RemoveEmptyEntries)[0]; + return versionChangelog.Replace(Plugin.Version, ""); + } + catch (Exception e) + { + Plugin.Logger.Error("Error loading changelog: " + e.Message); + } + + return ""; + } + + private void CheckVersion() + { + string path = Path.Combine(ConfigDirectory, "version"); + + bool needsBackup = false; + try + { + bool needsWrite = false; + + if (!File.Exists(path)) + { + needsWrite = true; + } + else + { + PreviousVersion = File.ReadAllText(path); + if (PreviousVersion != Plugin.Version) + { + needsWrite = true; + needsBackup = true; + } + } + + _changelogWindow.IsOpen = needsWrite; + _changelogWindow.AutoClose = true; + + if (needsWrite) + { + File.WriteAllText(path, Plugin.Version); + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error checking version: " + e.Message); + } + + try + { + if (needsBackup && PreviousVersion != null) + { + BackupFiles(PreviousVersion); + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error making backup: " + e.Message); + } + } + + private void BackupFiles(string version) + { + string backupsRoot = Path.Combine(ConfigDirectory, "Backups"); + if (!Directory.Exists(backupsRoot)) + { + Directory.CreateDirectory(backupsRoot); + } + + string backupPath = Path.Combine(backupsRoot, version); + + foreach (string folderPath in Directory.GetDirectories(ConfigDirectory, "*", SearchOption.AllDirectories)) + { + if (folderPath.Contains("Backups")) { continue; } + + Directory.CreateDirectory(folderPath.Replace(ConfigDirectory, backupPath)); + } + + foreach (string filePath in Directory.GetFiles(ConfigDirectory, "*.*", SearchOption.AllDirectories)) + { + File.Copy(filePath, filePath.Replace(ConfigDirectory, backupPath), true); + } + } + + #region strata + public void OnStrataLevelChanged(PluginConfigObject config) + { + StrataLevelsChangedEvent?.Invoke(this, config); + } + #endregion + + #region visibility + public void OnGlobalVisibilityChanged(VisibilityConfig config) + { + GlobalVisibilityEvent?.Invoke(this, config); + } + #endregion + + #region windows + public void ToggleConfigWindow() + { + _mainConfigWindow.Toggle(); + } + + public void OpenConfigWindow() + { + _mainConfigWindow.IsOpen = true; + } + + public void CloseConfigWindow() + { + _mainConfigWindow.IsOpen = false; + } + + public void OpenChangelogWindow() + { + _changelogWindow.IsOpen = true; + } + + public void Draw() + { + _windowSystem.Draw(); + } + + public void AddExtraSectionNode(SectionNode node) + { + ConfigBaseNode.AddExtraSectionNode(node); + } + #endregion + + #region config getters and setters + public PluginConfigObject GetConfigObjectForType(Type type) + { + MethodInfo? genericMethod = GetType().GetMethod("GetConfigObject"); + MethodInfo? method = genericMethod?.MakeGenericMethod(type); + return (PluginConfigObject)method?.Invoke(this, null)!; + } + public T GetConfigObject() where T : PluginConfigObject => ConfigBaseNode.GetConfigObject()!; + + public static PluginConfigObject GetDefaultConfigObjectForType(Type type) + { + MethodInfo? method = type.GetMethod("DefaultConfig", BindingFlags.Public | BindingFlags.Static); + return (PluginConfigObject)method?.Invoke(null, null)!; + } + + public ConfigPageNode GetConfigPageNode() where T : PluginConfigObject => ConfigBaseNode.GetConfigPageNode()!; + + public void SetConfigObject(PluginConfigObject configObject) => ConfigBaseNode.SetConfigObject(configObject); + + public List GetObjects() => ConfigBaseNode.GetObjects(); + #endregion + + #region load / save / profiles + public bool IsFreshInstall() + { + return Directory.GetDirectories(ConfigDirectory).Length == 0; + } + + public void LoadOrInitializeFiles() + { + try + { + if (!IsFreshInstall()) + { + LoadConfigurations(); + + // gotta save after initial load store possible version update changes right away + SaveConfigurations(true); + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error initializing configurations: " + e.Message); + + if (e.StackTrace != null) + { + Plugin.Logger.Error(e.StackTrace); + } + } + } + + public void ForceNeedsSave() + { + ConfigBaseNode.NeedsSave = true; + } + + public void LoadConfigurations() + { + PerformV2Migration(); + ConfigBaseNode.Load(ConfigDirectory); + } + + public void SaveConfigurations(bool forced = false) + { + if (!forced && !ConfigBaseNode.NeedsSave) + { + return; + } + + ConfigBaseNode.Save(ConfigDirectory); + + ProfilesManager.Instance?.SaveCurrentProfile(); + + ConfigBaseNode.NeedsSave = false; + } + + public void PerformV2Migration() + { + // create necessary folders + string[] newFolders = new string[] { "Other Elements", "Customization" }; + foreach (string folder in newFolders) + { + string path = Path.Combine(ConfigDirectory, folder); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + + // move files + Dictionary files = new Dictionary() + { + ["Misc\\Experience Bar.json"] = "Other Elements\\Experience Bar.json", + ["Misc\\GCD Indicator.json"] = "Other Elements\\GCD Indicator.json", + ["Misc\\Limit Break.json"] = "Other Elements\\Limit Break.json", + ["Misc\\MP Ticker.json"] = "Other Elements\\MP Ticker.json", + ["Misc\\Pull Timer.json"] = "Other Elements\\Pull Timer.json", + ["Misc\\Fonts.json"] = "Customization\\Fonts.json" + }; + + foreach (string key in files.Keys) + { + string v1Path = Path.Combine(ConfigDirectory, key); + string v2Path = Path.Combine(ConfigDirectory, files[key]); + + try + { + if (File.Exists(v1Path) && !File.Exists(v2Path)) + { + File.Move(v1Path, v2Path); + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error migrating file \"" + v1Path + "\" to v2 config structure: " + e.Message); + } + } + } + + public void UpdateCurrentProfile() + { + // dont update the profile on job change when the config window is opened + if (_mainConfigWindow.IsOpen) + { + _needsProfileUpdate = true; + return; + } + + ProfilesManager.Instance?.UpdateCurrentProfile(); + } + + public string? ExportCurrentConfigs() + { + return ConfigBaseNode.GetBase64String(); + } + + public void OnProfileDeleted(string profileName) + { + try + { + _configBaseNodeByProfile.Remove(profileName); + } + catch { } + } + + public bool ImportProfile(string oldProfileName, string profileName, string rawString, bool forceLoad = false) + { + // cache old profile + _configBaseNodeByProfile[oldProfileName] = ConfigBaseNode; + + // load profile from cache or from rawString + BaseNode? loadedNode = null; + if (forceLoad || !_configBaseNodeByProfile.TryGetValue(profileName, out loadedNode)) + { + ImportProfileNonCached(rawString, out loadedNode); + } + + if (loadedNode == null) { return false; } + + if (IsConfigWindowOpened || string.IsNullOrEmpty(loadedNode.SelectedOptionName)) + { + loadedNode.SelectedOptionName = ConfigBaseNode.SelectedOptionName; + loadedNode.RefreshSelectedNode(); + } + + ConfigBaseNode.ConfigObjectResetEvent -= OnConfigObjectReset; + ConfigBaseNode = loadedNode; + ConfigBaseNode.ConfigObjectResetEvent += OnConfigObjectReset; + + PerformV2Migration(); + + ResetEvent?.Invoke(this); + + return true; + } + + private bool ImportProfileNonCached(string rawString, out BaseNode? node) + { + List importStrings = new List(rawString.Trim().Split(new string[] { "|" }, StringSplitOptions.RemoveEmptyEntries)); + ImportData[] imports = importStrings.Select(str => new ImportData(str)).ToArray(); + + node = new BaseNode(); + InitializeBaseNode(node); + + Dictionary oldConfigObjects = new Dictionary(); + + foreach (ImportData importData in imports) + { + PluginConfigObject? config = importData.GetObject(); + if (config == null) + { + return false; + } + + if (!node.SetConfigObject(config)) + { + oldConfigObjects.Add(config.GetType(), config); + } + } + + if (ProfilesManager.Instance != null) + { + node.AddExtraSectionNode(ProfilesManager.Instance.ProfilesNode); + } + + return true; + } + #endregion + + #region initialization + private static void InitializeBaseNode(BaseNode node) + { + // creates node tree in the right order... + foreach (Type type in ConfigObjectTypes) + { + var genericMethod = node.GetType().GetMethod("GetConfigPageNode"); + var method = genericMethod?.MakeGenericMethod(type); + method?.Invoke(node, null); + } + } + + private static Type[] ConfigObjectTypes = new Type[] + { + // Unit Frames + typeof(PlayerUnitFrameConfig), + typeof(TargetUnitFrameConfig), + typeof(TargetOfTargetUnitFrameConfig), + typeof(FocusTargetUnitFrameConfig), + + // Mana Bars + typeof(PlayerPrimaryResourceConfig), + typeof(TargetPrimaryResourceConfig), + typeof(TargetOfTargetPrimaryResourceConfig), + typeof(FocusTargetPrimaryResourceConfig), + + // Castbars + typeof(PlayerCastbarConfig), + typeof(TargetCastbarConfig), + typeof(TargetOfTargetCastbarConfig), + typeof(FocusTargetCastbarConfig), + + // Buffs and Debuffs + typeof(PlayerBuffsListConfig), + typeof(PlayerDebuffsListConfig), + typeof(TargetBuffsListConfig), + typeof(TargetDebuffsListConfig), + typeof(FocusTargetBuffsListConfig), + typeof(FocusTargetDebuffsListConfig), + typeof(CustomEffectsListConfig), + + // Nameplates + typeof(NameplatesGeneralConfig), + typeof(PlayerNameplateConfig), + typeof(EnemyNameplateConfig), + typeof(PartyMembersNameplateConfig), + typeof(AllianceMembersNameplateConfig), + typeof(FriendPlayerNameplateConfig), + typeof(OtherPlayerNameplateConfig), + typeof(PetNameplateConfig), + typeof(NPCNameplateConfig), + typeof(MinionNPCNameplateConfig), + typeof(ObjectsNameplateConfig), + + // Party Frames + typeof(PartyFramesConfig), + typeof(PartyFramesHealthBarsConfig), + typeof(PartyFramesManaBarConfig), + typeof(PartyFramesCastbarConfig), + typeof(PartyFramesIconsConfig), + typeof(PartyFramesBuffsConfig), + typeof(PartyFramesDebuffsConfig), + typeof(PartyFramesTrackersConfig), + typeof(PartyFramesCooldownListConfig), + + // Party Cooldowns + typeof(PartyCooldownsConfig), + typeof(PartyCooldownsBarConfig), + typeof(PartyCooldownsDataConfig), + + // Enemy List + typeof(EnemyListConfig), + typeof(EnemyListHealthBarConfig), + typeof(EnemyListEnmityIconConfig), + typeof(EnemyListSignIconConfig), + typeof(EnemyListCastbarConfig), + typeof(EnemyListBuffsConfig), + typeof(EnemyListDebuffsConfig), + + // Job Specific Bars + typeof(PaladinConfig), + typeof(WarriorConfig), + typeof(DarkKnightConfig), + typeof(GunbreakerConfig), + + typeof(WhiteMageConfig), + typeof(ScholarConfig), + typeof(AstrologianConfig), + typeof(SageConfig), + + typeof(MonkConfig), + typeof(DragoonConfig), + typeof(NinjaConfig), + typeof(SamuraiConfig), + typeof(ReaperConfig), + typeof(ViperConfig), + + typeof(BardConfig), + typeof(MachinistConfig), + typeof(DancerConfig), + + typeof(BlackMageConfig), + typeof(SummonerConfig), + typeof(RedMageConfig), + typeof(BlueMageConfig), + typeof(PictomancerConfig), + + // Other Elements + typeof(ExperienceBarConfig), + typeof(GCDIndicatorConfig), + typeof(HotbarsConfig), + typeof(Hotbar1BarConfig), + typeof(Hotbar2BarConfig), + typeof(Hotbar3BarConfig), + typeof(Hotbar4BarConfig), + typeof(Hotbar5BarConfig), + typeof(Hotbar6BarConfig), + typeof(Hotbar7BarConfig), + typeof(Hotbar8BarConfig), + typeof(Hotbar9BarConfig), + typeof(Hotbar10BarConfig), + typeof(PullTimerConfig), + typeof(LimitBreakConfig), + typeof(MPTickerConfig), + + // Colors + typeof(TanksColorConfig), + typeof(HealersColorConfig), + typeof(MeleeColorConfig), + typeof(RangedColorConfig), + typeof(CastersColorConfig), + typeof(RolesColorConfig), + typeof(MiscColorConfig), + + // Customization + typeof(FontsConfig), + typeof(BarTexturesConfig), + + // Visibility + typeof(GlobalVisibilityConfig), + typeof(HotbarsVisibilityConfig), + + // Misc + typeof(HUDOptionsConfig), + typeof(WindowClippingConfig), + typeof(TooltipsConfig), + typeof(GridConfig), + + // Import + typeof(ImportConfig) + }; + #endregion + } +} \ No newline at end of file diff --git a/Config/ImportConfig.cs b/Config/ImportConfig.cs new file mode 100644 index 0000000..a2a737a --- /dev/null +++ b/Config/ImportConfig.cs @@ -0,0 +1,301 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Config.Profiles; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace HSUI.Interface +{ + [Disableable(false)] + [Exportable(false)] + [Shareable(false)] + [Resettable(false)] + [Section("Import")] + [SubSection("General", 0)] + public class ImportConfig : PluginConfigObject + { + private string _importString = ""; + private bool _importing = false; + private string? _errorMessage = null; + + private List? _importDataList = null; + private List? _importDataEnabled = null; + + public new static ImportConfig DefaultConfig() { return new ImportConfig(); } + + [ManualDraw] + public bool Draw(ref bool changed) + { + ImGui.Text("Import string:"); + + ImGui.InputText("", ref _importString, 999999); + + ImGui.Text("Here you can import specific parts of a profile.\nIf the string contains more than one part you will be able to select which parts you wish to import."); + + if (ImGui.Button("Import", new Vector2(560, 24))) + { + _importing = _importString.Length > 0; + } + + ImGuiHelper.DrawSeparator(1, 1); + ImGui.Text("To browse presets made by users of the HSUI community join our Discord and find the #profiles channel."); + + if (ImGui.Button("HSUI Discord", new Vector2(560, 24))) + { + Utils.OpenUrl("https://discord.gg/xzde5qQayh"); + } + + // error modal + if (_errorMessage != null) + { + if (ImGuiHelper.DrawErrorModal(_errorMessage)) + { + _importing = false; + _errorMessage = null; + } + + return false; + } + + // parse import string + if (_importing && _importDataList == null) + { + _errorMessage = Parse(); + } + + // confirmation modal + if (_importDataList != null && _importDataList.Count > 0) + { + var (didConfirm, didClose) = DrawImportConfirmationModal(); + + if (didConfirm) + { + _errorMessage = Import(); + + if (_errorMessage == null) + { + _importString = ""; + } + } + + if (didConfirm || didClose) + { + _importing = false; + _importDataList = null; + _importDataEnabled = null; + changed = true; + } + + return didConfirm && _errorMessage == null; + } + + return false; + } + + private string? Import() + { + if (_importDataList == null || _importDataEnabled == null) + { + return null; + } + + List configObjects = new List(_importDataList.Count); + + for (int i = 0; i < _importDataList.Count; i++) + { + if (i >= _importDataEnabled.Count || _importDataEnabled[i] == false) + { + continue; + } + + ImportData importData = _importDataList[i]; + PluginConfigObject? config = importData.GetObject(); + if (config == null) + { + return "Couldn't import \"" + importData.Name + "\""; + } + + configObjects.Add(config); + } + + foreach (PluginConfigObject config in configObjects) + { + ConfigurationManager.Instance.SetConfigObject(config); + } + + return null; + } + + private string? Parse() + { + string[] importStrings = _importString.Trim().Split(new string[] { "|" }, StringSplitOptions.RemoveEmptyEntries); + if (importStrings.Length == 0) + { + return null; + } + + _importDataList = new List(importStrings.Length); + _importDataEnabled = new List(importStrings.Length); + + foreach (var str in importStrings) + { + try + { + ImportData importData = new ImportData(str); + _importDataList.Add(importData); + _importDataEnabled.Add(true); + } + catch (Exception e) + { + _importDataList = null; + _importDataEnabled = null; + + return e is ArgumentException ? e.Message : "Invalid import string!"; + } + } + + return null; + } + + public (bool, bool) DrawImportConfirmationModal() + { + if (_importDataList == null || _importDataEnabled == null) + { + return (false, true); + } + + ConfigurationManager.Instance.ShowingModalWindow = true; + + bool didConfirm = false; + bool didClose = false; + + ImGui.OpenPopup("Import ##HSUI"); + + Vector2 center = ImGui.GetMainViewport().GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Appearing, new Vector2(0.5f, 0.5f)); + + bool p_open = true; // i've no idea what this is used for + + if (ImGui.BeginPopupModal("Import ##HSUI", ref p_open, ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove)) + { + float width = 300; + + ImGui.Text("Select which parts to import:"); + + ImGui.NewLine(); + if (ImGui.Button("Select All", new Vector2(width / 2f - 5, 24))) + { + for (int i = 0; i < _importDataEnabled.Count; i++) + { + _importDataEnabled[i] = true; + } + } + + ImGui.SameLine(); + if (ImGui.Button("Deselect All", new Vector2(width / 2f - 5, 24))) + { + for (int i = 0; i < _importDataEnabled.Count; i++) + { + _importDataEnabled[i] = false; + } + } + + ImGui.NewLine(); + float height = Math.Min(30 * _importDataList.Count, 400); + + ImGui.BeginChild("import checkboxes", new Vector2(width, height), false); + + for (int i = 0; i < _importDataList.Count; i++) + { + bool value = _importDataEnabled[i]; + if (ImGui.Checkbox(_importDataList[i].Name, ref value)) + { + _importDataEnabled[i] = value; + } + } + + ImGui.EndChild(); + + ImGui.NewLine(); + if (ImGui.Button("OK", new Vector2(width / 2f - 5, 24))) + { + ImGui.CloseCurrentPopup(); + didConfirm = true; + didClose = true; + } + + ImGui.SetItemDefaultFocus(); + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(width / 2f - 5, 24))) + { + ImGui.CloseCurrentPopup(); + didClose = true; + } + + ImGui.EndPopup(); + } + // close button on nav + else + { + didClose = true; + } + + if (didClose) + { + ConfigurationManager.Instance.ShowingModalWindow = false; + } + + return (didConfirm, didClose); + } + } + + public class ImportData + { + public readonly Type ConfigType; + public readonly string Name; + + public readonly string ImportString; + public readonly string JsonString; + + public ImportData(string base64String) + { + ImportString = base64String; + JsonString = ImportExportHelper.Base64DecodeAndDecompress(base64String); + + string? typeString = (string?)JObject.Parse(JsonString)["$type"]; + if (typeString == null) + { + throw new ArgumentException("Invalid type"); + } + + // Migrate DelvUI type names to HSUI for profile/import compatibility + if (typeString.Contains("DelvUI")) + { + typeString = typeString.Replace("DelvUI.", "HSUI.").Replace(", DelvUI", ", HSUI"); + } + + Type? type = Type.GetType(typeString); + if (type == null) + { + throw new ArgumentException("Invalid type: \"" + typeString + "\""); + } + + ConfigType = type; + Name = Utils.UserFriendlyConfigName(type.Name); + } + + public PluginConfigObject? GetObject() + { + MethodInfo? methodInfo = typeof(PluginConfigObject).GetMethod("LoadFromJsonString", BindingFlags.Public | BindingFlags.Static); + MethodInfo? function = methodInfo?.MakeGenericMethod(ConfigType); + return (PluginConfigObject?)function?.Invoke(this, new object[] { JsonString })!; + } + } +} diff --git a/Config/OnChangeEventArgs.cs b/Config/OnChangeEventArgs.cs new file mode 100644 index 0000000..392fec4 --- /dev/null +++ b/Config/OnChangeEventArgs.cs @@ -0,0 +1,43 @@ +using System; + +namespace HSUI.Config +{ + public delegate void ConfigValueChangeEventHandler(PluginConfigObject sender, OnChangeBaseArgs args); + + public enum ChangeType + { + None = 0, + ListAdd = 1, + ListRemove = 2 + } + + public class OnChangeBaseArgs : EventArgs + { + public string PropertyName { get; } + public ChangeType ChangeType { get; private set; } + + public OnChangeBaseArgs(string keyName, ChangeType type = ChangeType.None) + { + PropertyName = keyName; + ChangeType = type; + } + } + + public class OnChangeEventArgs : OnChangeBaseArgs + { + public T Value { get; } + + public OnChangeEventArgs(string keyName, T value, ChangeType type = ChangeType.None) : base(keyName, type) + { + Value = value; + } + } + + public interface IOnChangeEventArgs + { + public abstract event ConfigValueChangeEventHandler? ValueChangeEvent; + + public abstract void OnValueChanged(OnChangeBaseArgs e); + } + +} diff --git a/Config/PluginConfigObject.cs b/Config/PluginConfigObject.cs new file mode 100644 index 0000000..c391f44 --- /dev/null +++ b/Config/PluginConfigObject.cs @@ -0,0 +1,272 @@ +using HSUI.Config.Attributes; +using HSUI.Enums; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using System.Reflection; + +namespace HSUI.Config +{ + public abstract class PluginConfigObject : IOnChangeEventArgs + { + public string Version => Plugin.Version; + + [Checkbox("Enabled")] + [Order(0, collapseWith = null)] + public bool Enabled = true; + + #region convenience properties + [JsonIgnore] + public bool Exportable + { + get + { + ExportableAttribute? attribute = (ExportableAttribute?)GetType().GetCustomAttribute(typeof(ExportableAttribute), false); + return attribute == null || attribute.exportable; + } + } + + [JsonIgnore] + public bool Shareable + { + get + { + ShareableAttribute? attribute = (ShareableAttribute?)GetType().GetCustomAttribute(typeof(ShareableAttribute), false); + return attribute == null || attribute.shareable; + } + } + + [JsonIgnore] + public bool Resettable + { + get + { + ResettableAttribute? attribute = (ResettableAttribute?)GetType().GetCustomAttribute(typeof(ResettableAttribute), false); + return attribute == null || attribute.resettable; + } + } + + [JsonIgnore] + public bool Disableable + { + get + { + DisableableAttribute? attribute = (DisableableAttribute?)GetType().GetCustomAttribute(typeof(DisableableAttribute), false); + return attribute == null || attribute.disableable; + } + } + + [JsonIgnore] + public string[]? DisableParentSettings + { + get + { + DisableParentSettingsAttribute? attribute = (DisableParentSettingsAttribute?)GetType().GetCustomAttribute(typeof(DisableParentSettingsAttribute), true); + return attribute?.DisabledFields; + } + } + #endregion + + protected bool ColorEdit4(string label, ref PluginConfigColor color) + { + var vector = color.Vector; + + if (ImGui.ColorEdit4(label, ref vector)) + { + color.Vector = vector; + + return true; + } + + return false; + } + + public static PluginConfigObject DefaultConfig() + { + return null!; + } + + public List GetObjects() + { + List list = new List(); + + Type type = typeof(T); + if (this is T obj) + { + list.Add(obj); + } + + // iterate properties + PropertyInfo[] properties = GetType().GetProperties(); + foreach (PropertyInfo property in properties) + { + object? value = property.GetValue(this); + + if (value is T o) + { + list.Add(o); + } + else if (value is PluginConfigObject p) + { + list.AddRange(p.GetObjects()); + } + } + + // iterate fields + FieldInfo[] fields = GetType().GetFields(); + foreach (FieldInfo field in fields) + { + object? value = field.GetValue(this); + + if (value is T o) + { + list.Add(o); + } + else if (value is PluginConfigObject p) + { + list.AddRange(p.GetObjects()); + } + } + + return list; + } + + public T? Load(FileInfo fileInfo) where T : PluginConfigObject + { + return LoadFromJson(fileInfo.FullName); + } + + public static T? LoadFromJson(string path) where T : PluginConfigObject + { + if (!File.Exists(path)) { return null; } + + return LoadFromJsonString(File.ReadAllText(path)); + } + + public static T? LoadFromJsonString(string jsonString) where T : PluginConfigObject + { + JsonSerializerSettings settings = new JsonSerializerSettings(); + settings.ContractResolver = new PluginConfigObjectsContractResolver(); + + return JsonConvert.DeserializeObject(jsonString, settings); + } + + #region IOnChangeEventArgs + + // sending event outside of the config + public event ConfigValueChangeEventHandler? ValueChangeEvent; + + // received events from the node + public void OnValueChanged(OnChangeBaseArgs e) + { + ValueChangeEvent?.Invoke(this, e); + } + + #endregion + } + + public abstract class MovablePluginConfigObject : PluginConfigObject + { + [JsonIgnore] + public string ID; + + [StrataLevel("Strata Level")] + [Order(2)] + public StrataLevel? Strata; + + public StrataLevel StrataLevel => Strata ?? StrataLevel.LOWEST; + + [DragInt2("Position", min = -4000, max = 4000)] + [Order(5)] + public Vector2 Position = Vector2.Zero; + + public MovablePluginConfigObject() + { + ID = $"DelvUI_{GetType().Name}_{Guid.NewGuid()}"; + } + } + + public abstract class AnchorablePluginConfigObject : MovablePluginConfigObject + { + [DragInt2("Size", min = 1, max = 4000, isMonitored = true)] + [Order(10)] + public Vector2 Size; + + [Anchor("Anchor")] + [Order(15)] + public DrawAnchor Anchor = DrawAnchor.Center; + } + + public class PluginConfigColor + { + [JsonIgnore] private float[] _colorMapRatios = { -.8f, -.3f, .1f }; + + [JsonIgnore] private Vector4 _vector; + + public PluginConfigColor(Vector4 vector, float[]? colorMapRatios = null) + { + _vector = vector; + + if (colorMapRatios != null && colorMapRatios.Length >= 3) + { + _colorMapRatios = colorMapRatios; + } + + Update(); + } + + public static PluginConfigColor FromHex(uint hexColor) + { + // ARGB to ABGR + uint r = (hexColor >> 16) & 0xFF; + uint b = hexColor & 0xFF; + hexColor = (hexColor & 0xFF00FF00) | (b << 16) | r; + + return new PluginConfigColor(ImGui.ColorConvertU32ToFloat4(hexColor)); + } + + public PluginConfigColor WithAlpha(float alpha) + { + if (alpha == Vector.W) { return this; } + + return new PluginConfigColor(Vector.WithNewAlpha(alpha)); + } + + public static PluginConfigColor Empty => new(Vector4.Zero); + + public Vector4 Vector + { + get => _vector; + set + { + if (_vector == value) + { + return; + } + + _vector = value; + + Update(); + } + } + + [JsonIgnore] public uint Base { get; private set; } + + [JsonIgnore] public uint Background { get; private set; } + + [JsonIgnore] public uint TopGradient { get; private set; } + + [JsonIgnore] public uint BottomGradient { get; private set; } + + private void Update() + { + Base = ImGui.ColorConvertFloat4ToU32(_vector); + Background = ImGui.ColorConvertFloat4ToU32(_vector.AdjustColor(_colorMapRatios[0])); + TopGradient = ImGui.ColorConvertFloat4ToU32(_vector.AdjustColor(_colorMapRatios[1])); + BottomGradient = ImGui.ColorConvertFloat4ToU32(_vector.AdjustColor(_colorMapRatios[2])); + } + } +} diff --git a/Config/PluginConfigObjectConverter.cs b/Config/PluginConfigObjectConverter.cs new file mode 100644 index 0000000..91e69f6 --- /dev/null +++ b/Config/PluginConfigObjectConverter.cs @@ -0,0 +1,310 @@ +using Dalamud.Logging; +using HSUI.Interface.EnemyList; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.Party; +using HSUI.Interface.StatusEffects; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.Serialization; + +namespace HSUI.Config +{ + public abstract class PluginConfigObjectConverter : JsonConverter + { + protected Dictionary FieldConvertersMap = new Dictionary(); + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var genericMethod = GetType().GetMethod("ConvertJson"); + var method = genericMethod?.MakeGenericMethod(objectType); + return method?.Invoke(this, new object[] { reader, serializer }); + } + + public T? ConvertJson(JsonReader reader, JsonSerializer serializer) where T : PluginConfigObject + { + Type type = typeof(T); + T? config = null; + + try + { + ConstructorInfo? constructor = type.GetConstructor(new Type[] { }); + if (constructor != null) + { + config = (T?)Activator.CreateInstance(); + } + else + { + config = (T?)ConfigurationManager.GetDefaultConfigObjectForType(type); + } + + // last resource, hackily create an instance without calling the constructor + if (config == null) + { +#pragma warning disable SYSLIB0050 // Type or member is obsolete + config = (T)FormatterServices.GetUninitializedObject(type); +#pragma warning restore SYSLIB0050 // Type or member is obsolete + } + } + catch (Exception e) + { + Plugin.Logger.Error($"Error creating a {type.Name}: " + e.Message); + } + + if (config == null) { return null; } + + try + { + JObject? jsonObject = (JObject?)serializer.Deserialize(reader); + if (jsonObject == null) { return null; } + + Dictionary ValuesMap = new Dictionary(); + + // get values from json + foreach (JProperty property in jsonObject.Properties()) + { + string propertyName = property.Name; + object? value = null; + + // convert values if needed + if (FieldConvertersMap.TryGetValue(propertyName, out PluginConfigObjectFieldConverter? fieldConverter) && fieldConverter != null) + { + (propertyName, value) = fieldConverter.Convert(property.Value); + } + // read value from json + else + { + FieldInfo? field = type.GetField(propertyName); + if (field != null) + { + value = property.Value.ToObject(field.FieldType); + } + } + + if (value != null) + { + ValuesMap.Add(propertyName, value); + } + } + + // apply values + foreach (string key in ValuesMap.Keys) + { + string[] fields = key.Split(".", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + object? currentObject = config; + object value = ValuesMap[key]; + + for (int i = 0; i < fields.Length; i++) + { + FieldInfo? field = currentObject?.GetType().GetField(fields[i]); + if (field == null) { break; } + + if (i == fields.Length - 1) + { + try + { + field.SetValue(currentObject, value); + } + catch { } + } + else + { + currentObject = field.GetValue(currentObject); + } + } + } + } + catch (Exception e) + { + Plugin.Logger.Error($"Error deserializing {type.Name}: " + e.Message); + } + + return config; + } + + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value == null) { return; } + + JObject jsonObject = new JObject(); + Type type = value.GetType(); + jsonObject.Add("$type", type.FullName + ", HSUI"); + + FieldInfo[] fields = type.GetFields(); + + foreach (FieldInfo field in fields) + { + if (field.GetCustomAttribute() != null) { continue; } + + object? fieldValue = field.GetValue(value); + if (fieldValue != null) + { + jsonObject.Add(field.Name, JToken.FromObject(fieldValue, serializer)); + } + } + + jsonObject.WriteTo(writer); + } + } + + #region contract resolver + public class PluginConfigObjectsContractResolver : DefaultContractResolver + { + private static Dictionary ConvertersMap = new Dictionary() + { + [typeof(UnitFrameConfig)] = typeof(ColorByHealthFieldsConverter), + [typeof(PlayerUnitFrameConfig)] = typeof(ColorByHealthFieldsConverter), + [typeof(TargetUnitFrameConfig)] = typeof(ColorByHealthFieldsConverter), + [typeof(TargetOfTargetUnitFrameConfig)] = typeof(ColorByHealthFieldsConverter), + [typeof(FocusTargetUnitFrameConfig)] = typeof(ColorByHealthFieldsConverter), + + [typeof(PartyFramesColorsConfig)] = typeof(ColorByHealthFieldsConverter), + [typeof(PartyFramesRoleIconConfig)] = typeof(PartyFramesIconsConverter), + [typeof(PartyFramesLeaderIconConfig)] = typeof(PartyFramesIconsConverter), + [typeof(PartyFramesRaiseTrackerConfig)] = typeof(PartyFramesTrackerConfigConverter), + [typeof(PartyFramesInvulnTrackerConfig)] = typeof(PartyFramesTrackerConfigConverter), + [typeof(PartyFramesManaBarConfig)] = typeof(PartyFramesManaBarConfigConverter), + + [typeof(StatusEffectsBlacklistConfig)] = typeof(StatusEffectsBlacklistConfigConverter), + + [typeof(HUDOptionsConfig)] = typeof(HUDOptionsConfigConverter), + + [typeof(CastbarConfig)] = typeof(CastbarConfigConverter), + [typeof(UnitFrameCastbarConfig)] = typeof(CastbarConfigConverter), + [typeof(PlayerCastbarConfig)] = typeof(CastbarConfigConverter), + [typeof(TargetCastbarConfig)] = typeof(CastbarConfigConverter), + [typeof(TargetOfTargetCastbarConfig)] = typeof(CastbarConfigConverter), + [typeof(FocusTargetCastbarConfig)] = typeof(CastbarConfigConverter), + [typeof(PartyFramesCastbarConfig)] = typeof(CastbarConfigConverter), + [typeof(EnemyListCastbarConfig)] = typeof(CastbarConfigConverter), + }; + + protected override JsonObjectContract CreateObjectContract(Type objectType) + { + JsonObjectContract contract = base.CreateObjectContract(objectType); + + if (ConvertersMap.TryGetValue(objectType, out Type? converterType) && converterType != null) + { + contract.Converter = (JsonConverter?)Activator.CreateInstance(converterType); + } + + return contract; + } + } + #endregion + + #region field converters + public abstract class PluginConfigObjectFieldConverter + { + public readonly string NewFieldPath; + public PluginConfigObjectFieldConverter(string newFieldPath) + { + NewFieldPath = newFieldPath; + } + + public abstract (string, object) Convert(JToken token); + } + + public class NewTypeFieldConverter : PluginConfigObjectFieldConverter + where TOld : struct + where TNew : struct + { + private TNew DefaultValue; + private Func Func; + + public NewTypeFieldConverter(string newFieldPath, TNew defaultValue, Func func) : base(newFieldPath) + { + DefaultValue = defaultValue; + Func = func; + } + + public override (string, object) Convert(JToken token) + { + TNew result = DefaultValue; + + TOld? oldValue = token.ToObject(); + if (oldValue.HasValue) + { + result = Func(oldValue.Value); + } + + return (NewFieldPath, result); + } + } + + public class SameTypeFieldConverter : NewTypeFieldConverter where T : struct + { + public SameTypeFieldConverter(string newFieldPath, T defaultValue) + : base(newFieldPath, defaultValue, (oldValue) => { return oldValue; }) + { + } + } + + public class NewClassFieldConverter : PluginConfigObjectFieldConverter + where TOld : class + where TNew : class + { + private TNew DefaultValue; + private Func Func; + + public NewClassFieldConverter(string newFieldPath, TNew defaultValue, Func func) + : base(newFieldPath) + { + DefaultValue = defaultValue; + Func = func; + } + + public override (string, object) Convert(JToken token) + { + TNew result = DefaultValue; + + TOld? oldValue = token.ToObject(); + if (oldValue != null) + { + result = Func(oldValue); + } + + return (NewFieldPath, result); + } + } + + public class SameClassFieldConverter : NewClassFieldConverter where T : class + { + public SameClassFieldConverter(string newFieldPath, T defaultValue) + : base(newFieldPath, defaultValue, (oldValue) => { return oldValue; }) + { + } + } + + public class TypeToClassFieldConverter : PluginConfigObjectFieldConverter + where TOld : struct + where TNew : class + { + private TNew DefaultValue; + private Func Func; + + public TypeToClassFieldConverter(string newFieldPath, TNew defaultValue, Func func) : base(newFieldPath) + { + DefaultValue = defaultValue; + Func = func; + } + + public override (string, object) Convert(JToken token) + { + TNew result = DefaultValue; + + TOld? oldValue = token.ToObject(); + if (oldValue.HasValue) + { + result = Func(oldValue.Value); + } + + return (NewFieldPath, result); + } + } + #endregion +} diff --git a/Config/Profiles/ImportExportHelper.cs b/Config/Profiles/ImportExportHelper.cs new file mode 100644 index 0000000..508dc43 --- /dev/null +++ b/Config/Profiles/ImportExportHelper.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; +using System; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace HSUI.Config.Profiles +{ + public static class ImportExportHelper + { + public static string CompressAndBase64Encode(string jsonString) + { + using MemoryStream output = new(); + + using (DeflateStream gzip = new(output, CompressionLevel.Optimal)) + { + using StreamWriter writer = new(gzip, Encoding.UTF8); + writer.Write(jsonString); + } + + return Convert.ToBase64String(output.ToArray()); + } + + public static string Base64DecodeAndDecompress(string base64String) + { + var base64EncodedBytes = Convert.FromBase64String(base64String); + + using MemoryStream inputStream = new(base64EncodedBytes); + using DeflateStream gzip = new(inputStream, CompressionMode.Decompress); + using StreamReader reader = new(gzip, Encoding.UTF8); + var decodedString = reader.ReadToEnd(); + + return decodedString; + } + + public static string GenerateExportString(object obj) + { + JsonSerializerSettings settings = new JsonSerializerSettings + { + TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple, + TypeNameHandling = TypeNameHandling.Objects + }; + + var jsonString = JsonConvert.SerializeObject(obj, Formatting.Indented, settings); + return CompressAndBase64Encode(jsonString); + } + } +} diff --git a/Config/Profiles/Profile.cs b/Config/Profiles/Profile.cs new file mode 100644 index 0000000..399b116 --- /dev/null +++ b/Config/Profiles/Profile.cs @@ -0,0 +1,113 @@ +using HSUI.Helpers; +using System; +using System.Collections.Generic; + +namespace HSUI.Config.Profiles +{ + public class Profile + { + public string Name; + + public bool AutoSwitchEnabled = false; + public AutoSwitchData AutoSwitchData = new AutoSwitchData(); + public int HudLayout; + public bool AttachHudEnabled = false; + + public Profile(string name, bool autoSwitchEnabled = false, AutoSwitchData? autoSwitchData = null, bool attachHudEnabled = false, int hudLayout = 0) + { + Name = name; + + AutoSwitchEnabled = autoSwitchEnabled; + AutoSwitchData = autoSwitchData ?? AutoSwitchData; + + AttachHudEnabled = attachHudEnabled; + HudLayout = hudLayout; + } + } + + public class AutoSwitchData + { + public Dictionary> Map; + + public AutoSwitchData() + { + Map = new Dictionary>(); + + JobRoles[] roles = (JobRoles[])Enum.GetValues(typeof(JobRoles)); + + foreach (JobRoles role in roles) + { + int count = JobsHelper.JobsByRole[role].Count; + List list = new List(count); + + for (int i = 0; i < count; i++) + { + list.Add(false); + } + + Map.Add(role, list); + } + } + + public bool GetRoleEnabled(JobRoles role) + { + foreach (bool value in Map[role]) + { + if (!value) + { + return false; + } + } + + return true; + } + + public void SetRoleEnabled(JobRoles role, bool value) + { + for (int i = 0; i < Map[role].Count; i++) + { + Map[role][i] = value; + } + } + + public bool IsEnabled(JobRoles role, int index) + { + if (Map.TryGetValue(role, out List? list) && list != null) + { + if (index >= list.Count) + { + return false; + } + + return list[index]; + } + + return false; + } + + public bool ValidateRolesData() + { + bool changed = false; + + JobRoles[] roles = (JobRoles[])Enum.GetValues(typeof(JobRoles)); + + foreach (JobRoles role in roles) + { + int count = JobsHelper.JobsByRole[role].Count; + List list = Map[role]; + + if (list.Count < count) + { + for (int i = 0; i < count - list.Count; i++) + { + list.Add(false); + } + + changed = true; + } + } + + return changed; + } + } +} diff --git a/Config/Profiles/ProfilesManager.cs b/Config/Profiles/ProfilesManager.cs new file mode 100644 index 0000000..587d0f3 --- /dev/null +++ b/Config/Profiles/ProfilesManager.cs @@ -0,0 +1,1024 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Interface; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Logging; +using HSUI.Config.Attributes; +using HSUI.Config.Tree; +using HSUI.Helpers; +using HSUI.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; + +namespace HSUI.Config.Profiles +{ + public class ProfilesManager + { + #region Singleton + public readonly SectionNode ProfilesNode; + + private ProfilesManager() + { + // fake nodes + ProfilesNode = new SectionNode(); + ProfilesNode.Name = "Profiles"; + + NestedSubSectionNode subSectionNode = new NestedSubSectionNode(); + subSectionNode.Name = "General"; + subSectionNode.Depth = 0; + + ProfilesConfigPageNode configPageNode = new ProfilesConfigPageNode(); + + subSectionNode.Add(configPageNode); + ProfilesNode.Add(subSectionNode); + + ConfigurationManager.Instance.AddExtraSectionNode(ProfilesNode); + + // default profile + if (!Profiles.ContainsKey(DefaultProfileName)) + { + Profile defaultProfile = new Profile(DefaultProfileName); + Profiles.Add(DefaultProfileName, defaultProfile); + } + } + + private bool ResetProfileToDefault(string profileName, bool forced = false) + { + string endPath = Path.Combine(ProfilesPath, profileName + ".HSUI"); + + if (forced) + { + try + { + File.Delete(endPath); + } catch { } + } + + if (forced || !File.Exists(endPath)) + { + try + { + Directory.CreateDirectory(ProfilesPath); + File.Copy(MediaDefaultProfilePath, endPath); + + return true; + } + catch (Exception e) + { + Plugin.Logger.Error("Error copying default profile!: " + e.Message); + } + } + + return false; + } + + public static void Initialize() + { + bool attemptRepair = false; + + try + { + string jsonString = File.ReadAllText(JsonPath); + ProfilesManager? instance = JsonConvert.DeserializeObject(jsonString); + if (instance != null) + { + Instance = instance; + + bool needsSave = false; + foreach (Profile profile in Instance.Profiles.Values) + { + needsSave |= profile.AutoSwitchData.ValidateRolesData(); + } + + if (needsSave) + { + Instance.Save(); + } + } + } + catch + { + Instance = new ProfilesManager(); + attemptRepair = true; + } + + if (Instance == null) + { + Plugin.Logger.Error("Error initializing HSUI's profiles!!!"); + return; + } + + // attempt to reconstruct profile from files if the Profiles directory is missing + if (attemptRepair && + !ConfigurationManager.Instance.IsFreshInstall() && + !Directory.Exists(ProfilesPath)) + { + Instance.CurrentProfileName = "Restored Profile"; + + Profile defaultProfile = new Profile(Instance.CurrentProfileName); + Instance.Profiles.Add(Instance.CurrentProfileName, defaultProfile); + } + + // always make sure the default profile file is present + if (!File.Exists(DefaultProfilePath)) + { + if (Instance.ResetProfileToDefault(DefaultProfileName)) + { + if (Instance.CurrentProfileName == DefaultProfileName) + { + Instance.ReloadCurrentProfile(); + } + } + } + + Instance.UpdateSelectedIndex(); + Instance.InitializeDefaultImportData(); + } + + private void InitializeDefaultImportData() + { + string importString = File.ReadAllText(MediaDefaultProfilePath); + + string[] importStrings = importString.Trim().Split(new string[] { "|" }, StringSplitOptions.RemoveEmptyEntries); + if (importStrings.Length == 0) + { + return; + } + + foreach (var str in importStrings) + { + try + { + ImportData importData = new ImportData(str); + _defaultImportData.Add(importData); + } + catch { } + } + } + + public static ProfilesManager Instance { get; private set; } = null!; + + ~ProfilesManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Instance = null!; + } + #endregion + + private string _currentProfileName = "Default"; + public string CurrentProfileName + { + get => _currentProfileName; + set + { + if (_currentProfileName == value) + { + return; + } + + _currentProfileName = value; + + if (_currentProfileName == null || _currentProfileName.Length == 0) + { + _currentProfileName = DefaultProfileName; + } + + UpdateSelectedIndex(); + } + } + + [JsonIgnore] private static string ProfilesPath => Path.Combine(ConfigurationManager.Instance.ConfigDirectory, "Profiles"); + [JsonIgnore] private static string JsonPath => Path.Combine(ProfilesPath, "Profiles.json"); + [JsonIgnore] private static string MediaDefaultProfilePath => Path.Combine(Plugin.AssemblyLocation, "Media", "Profiles", DefaultProfileName + ".HSUI"); + + [JsonIgnore] private static string DefaultProfileName = "Default"; + [JsonIgnore] private static string DefaultProfilePath = Path.Combine(ProfilesPath, DefaultProfileName + ".HSUI"); + + [JsonIgnore] private List _defaultImportData = new List(); + + [JsonIgnore] private string _newProfileName = ""; + [JsonIgnore] private int _copyFromIndex = 0; + [JsonIgnore] private int _selectedProfileIndex = 0; + [JsonIgnore] private string? _errorMessage = null; + [JsonIgnore] private string? _deletingProfileName = null; + [JsonIgnore] private string? _resetingProfileName = null; + [JsonIgnore] private string? _renamingProfileName = null; + + [JsonIgnore] private FileDialogManager _fileDialogManager = new FileDialogManager(); + + public SortedList Profiles = new SortedList(); + + public ImportData? DefaultImportData(Type type) + { + return _defaultImportData.FirstOrDefault(o => o.ConfigType == type); + } + + public Profile CurrentProfile() + { + if (_currentProfileName == null || _currentProfileName.Length == 0) + { + _currentProfileName = DefaultProfileName; + } + + return Profiles[_currentProfileName]; + } + + public void SaveCurrentProfile() + { + if (ConfigurationManager.Instance == null) + { + return; + } + + try + { + Save(); + SaveCurrentProfile(ConfigurationManager.Instance.ExportCurrentConfigs()); + } + catch (Exception e) + { + Plugin.Logger.Error("Error saving profile: " + e.Message); + } + } + + public void SaveCurrentProfile(string? exportString) + { + if (exportString == null) + { + return; + } + + try + { + Directory.CreateDirectory(ProfilesPath); + + File.WriteAllText(CurrentProfilePath(), exportString); + } + catch (Exception e) + { + Plugin.Logger.Error("Error saving profile: " + e.Message); + } + } + + public bool LoadCurrentProfile(string oldProfile) + { + try + { + string importString = File.ReadAllText(CurrentProfilePath()); + return ConfigurationManager.Instance.ImportProfile(oldProfile, _currentProfileName, importString); + } + catch (Exception e) + { + Plugin.Logger.Error("Error loading profile: " + e.Message); + } + + return false; + } + + private bool ReloadCurrentProfile() + { + try + { + string importString = File.ReadAllText(CurrentProfilePath()); + if (ConfigurationManager.Instance.ImportProfile(_currentProfileName, _currentProfileName, importString, true)) + { + ConfigurationManager.Instance.SaveConfigurations(true); + return true; + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error re-loading profile: " + e.Message); + } + + return false; + } + + public void UpdateCurrentProfile() + { + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + if (player == null) + { + return; + } + + uint jobId = player.ClassJob.RowId; + Profile currentProfile = CurrentProfile(); + JobRoles role = JobsHelper.RoleForJob(jobId); + int index = JobsHelper.JobsByRole[role].IndexOf(jobId); + + if (index < 0) + { + return; + } + + // current profile is enabled for this job, do nothing + if (currentProfile.AutoSwitchEnabled && currentProfile.AutoSwitchData.IsEnabled(role, index)) + { + return; + } + + // find a profile that is enabled for this job + foreach (Profile profile in Profiles.Values) + { + if (!profile.AutoSwitchEnabled || profile == currentProfile) + { + continue; + } + + // found a valid profile, switch to it + if (profile.AutoSwitchData.IsEnabled(role, index)) + { + SwitchToProfile(profile.Name); + return; + } + } + } + + public void CheckUpdateSwitchCurrentProfile(string specifiedProfile) + { + // found a valid profile, switch to it + if (Profiles.ContainsKey(specifiedProfile)) + { + SwitchToProfile(specifiedProfile); + } + } + + private unsafe string? SwitchToProfile(string profile, bool save = true) + { + // save if needed before switching + if (save) + { + ConfigurationManager.Instance.SaveConfigurations(); + } + + string oldProfile = _currentProfileName; + _currentProfileName = profile; + Profile currentProfile = CurrentProfile(); + + if (currentProfile.AttachHudEnabled && currentProfile.HudLayout != 0) + { + AddonConfig.Instance()->ChangeHudLayout((uint)currentProfile.HudLayout - 1); + } + + if (!LoadCurrentProfile(oldProfile)) + { + _currentProfileName = oldProfile; + return "Couldn't load profile \"" + profile + "\"!"; + } + + UpdateSelectedIndex(); + + try + { + Save(); + FontsManager.Instance.BuildFonts(); + } + catch (Exception e) + { + Plugin.Logger.Error("Error saving profile: " + e.Message); + return "Couldn't load profile \"" + profile + "\"!"; + } + + return null; + } + + private void UpdateSelectedIndex() + { + _selectedProfileIndex = Math.Max(0, Profiles.IndexOfKey(_currentProfileName)); + } + + private string CurrentProfilePath() + { + return Path.Combine(ProfilesPath, _currentProfileName + ".HSUI"); + } + + private string? CloneProfile(string profileName, string newProfileName) + { + string srcPath = Path.Combine(ProfilesPath, profileName + ".HSUI"); + string dstPath = Path.Combine(ProfilesPath, newProfileName + ".HSUI"); + + return CloneProfile(profileName, srcPath, newProfileName, dstPath); + } + + private string? CloneProfile(string profileName, string srcPath, string newProfileName, string dstPath) + { + if (newProfileName.Length == 0) + { + return null; + } + + if (Profiles.Keys.Contains(newProfileName)) + { + return "A profile with the name \"" + newProfileName + "\" already exists!"; + } + + try + { + if (!File.Exists(srcPath)) + { + return "Couldn't find profile \"" + profileName + "\"!"; + } + + if (File.Exists(dstPath)) + { + return "A profile with the name \"" + newProfileName + "\" already exists!"; + } + + File.Copy(srcPath, dstPath); + Profile newProfile = new Profile(newProfileName); + Profiles.Add(newProfileName, newProfile); + + Save(); + } + catch (Exception e) + { + Plugin.Logger.Error("Error cloning profile: " + e.Message); + return "Error trying to clone profile \"" + profileName + "\"!"; + } + + return null; + } + + private string? RenameCurrentProfile(string newProfileName) + { + if (_currentProfileName == newProfileName || newProfileName.Length == 0) + { + return null; + } + + if (Profiles.ContainsKey(newProfileName)) + { + return "A profile with the name \"" + newProfileName + "\" already exists!"; + } + + string srcPath = Path.Combine(ProfilesPath, _currentProfileName + ".HSUI"); + string dstPath = Path.Combine(ProfilesPath, newProfileName + ".HSUI"); + + try + { + + if (File.Exists(dstPath)) + { + return "A profile with the name \"" + newProfileName + "\" already exists!"; + } + + File.Move(srcPath, dstPath); + + Profile profile = Profiles[_currentProfileName]; + profile.Name = newProfileName; + + Profiles.Remove(_currentProfileName); + Profiles.Add(newProfileName, profile); + + _currentProfileName = newProfileName; + + Save(); + } + catch (Exception e) + { + Plugin.Logger.Error("Error renaming profile: " + e.Message); + return "Error trying to rename profile \"" + _currentProfileName + "\"!"; + } + + return null; + } + + private string? Import(string newProfileName, string importString) + { + if (newProfileName.Length == 0) + { + return null; + } + + if (Profiles.Keys.Contains(newProfileName)) + { + return "A profile with the name \"" + newProfileName + "\" already exists!"; + } + + string dstPath = Path.Combine(ProfilesPath, newProfileName + ".HSUI"); + + try + { + if (File.Exists(dstPath)) + { + return "A profile with the name \"" + newProfileName + "\" already exists!"; + } + + File.WriteAllText(dstPath, importString); + + Profile newProfile = new Profile(newProfileName); + Profiles.Add(newProfileName, newProfile); + + string? errorMessage = SwitchToProfile(newProfileName, false); + + if (errorMessage != null) + { + Profiles.Remove(newProfileName); + File.Delete(dstPath); + Save(); + + return errorMessage; + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error importing profile: " + e.Message); + return "Error trying to import profile \"" + newProfileName + "\"!"; + } + + return null; + } + + private string? ImportFromClipboard(string newProfileName) + { + string importString = ImGui.GetClipboardText(); + if (importString.Length == 0) + { + return "Invalid import string!"; + } + + return Import(newProfileName, importString); + } + + private void ImportFromFile(string newProfileName) + { + if (newProfileName.Length == 0) + { + return; + } + + Action callback = (finished, path) => + { + try + { + if (finished && path.Length > 0) + { + string importString = File.ReadAllText(path); + _errorMessage = Import(newProfileName, importString); + + if (_errorMessage == null) + { + _newProfileName = ""; + } + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error reading import file: " + e.Message); + _errorMessage = "Error reading the file!"; + } + }; + + _fileDialogManager.OpenFileDialog("Select a HSUI Profile to import", "HSUI Profile{.HSUI}", callback); + } + + private void ExportToFile(string newProfileName) + { + if (newProfileName.Length == 0) + { + return; + } + + Action callback = (finished, path) => + { + try + { + string src = CurrentProfilePath(); + if (finished && path.Length > 0 && src != path) + { + File.Copy(src, path, true); + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error copying file: " + e.Message); + _errorMessage = "Error exporting the file!"; + } + }; + + _fileDialogManager.SaveFileDialog("Save Profile", "HSUI Profile{.HSUI}", newProfileName + ".HSUI", ".HSUI", callback); + } + + private string? DeleteProfile(string profileName) + { + if (!Profiles.ContainsKey(profileName)) + { + return "Couldn't find profile \"" + profileName + "\"!"; + } + + string path = Path.Combine(ProfilesPath, profileName + ".HSUI"); + + try + { + if (!File.Exists(path)) + { + return "Couldn't find profile \"" + profileName + "\"!"; + } + + File.Delete(path); + Profiles.Remove(profileName); + + Save(); + + ConfigurationManager.Instance.OnProfileDeleted(profileName); + + if (_currentProfileName == profileName) + { + return SwitchToProfile(DefaultProfileName, false); + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error deleting profile: " + e.Message); + return "Error trying to delete profile \"" + profileName + "\"!"; + } + + return null; + } + + private void Save() + { + string jsonString = JsonConvert.SerializeObject( + this, + Formatting.Indented, + new JsonSerializerSettings + { + TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple, + TypeNameHandling = TypeNameHandling.Objects + } + ); + + Directory.CreateDirectory(ProfilesPath); + File.WriteAllText(JsonPath, jsonString); + } + + public bool Draw(ref bool changed) + { + string[] profiles = Profiles.Keys.ToArray(); + + if (ImGui.BeginChild("Profiles", new Vector2(800, 600), false)) + { + if (Profiles.Count == 0) + { + ImGuiHelper.Tab(); + ImGui.Text("Profiles not found in \"%appdata%/Roaming/XIVLauncher/pluginConfigs/HSUI/Profiles/\""); + return false; + } + + ImGui.PushItemWidth(408); + ImGuiHelper.NewLineAndTab(); + if (ImGui.Combo("Active Profile", ref _selectedProfileIndex, profiles, 10)) + { + string newProfileName = profiles[_selectedProfileIndex]; + + if (_currentProfileName != newProfileName) + { + _errorMessage = SwitchToProfile(newProfileName); + } + } + + // reset + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button("\uf2f9", new Vector2(0, 0))) + { + _resetingProfileName = _currentProfileName; + } + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Reset"); + + if (_currentProfileName != DefaultProfileName) + { + // rename + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Pen.ToIconString())) + { + _renamingProfileName = _currentProfileName; + } + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Rename"); + + // delete + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (_currentProfileName != DefaultProfileName && ImGui.Button(FontAwesomeIcon.Trash.ToIconString())) + { + _deletingProfileName = _currentProfileName; + } + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Delete"); + } + + // export to string + ImGuiHelper.Tab(); + ImGui.SameLine(); + if (ImGui.Button("Export to Clipboard", new Vector2(200, 0))) + { + string? exportString = ConfigurationManager.Instance.ExportCurrentConfigs(); + if (exportString != null) + { + ImGui.SetClipboardText(exportString); + ImGui.OpenPopup("export_succes_popup"); + } + } + + // export success popup + if (ImGui.BeginPopup("export_succes_popup")) + { + ImGui.Text("Profile export string copied to clipboard!"); + ImGui.EndPopup(); + } + + ImGui.SameLine(); + if (ImGui.Button("Export to File", new Vector2(200, 0))) + { + ExportToFile(_currentProfileName); + } + + ImGuiHelper.NewLineAndTab(); + DrawAttachHudLayout(ref changed); + + ImGuiHelper.NewLineAndTab(); + DrawAutoSwitchSettings(ref changed); + + ImGuiHelper.DrawSeparator(1, 1); + ImGuiHelper.Tab(); + ImGui.Text("Create a new profile:"); + + ImGuiHelper.Tab(); + ImGui.PushItemWidth(408); + ImGui.InputText("Profile Name", ref _newProfileName, 200); + + ImGuiHelper.Tab(); + ImGui.PushItemWidth(200); + ImGui.Combo("", ref _copyFromIndex, profiles, 10); + + ImGui.SameLine(); + if (ImGui.Button("Copy", new Vector2(200, 0))) + { + _newProfileName = _newProfileName.Trim(); + if (_newProfileName.Length == 0) + { + ImGui.OpenPopup("import_error_popup"); + } + else + { + _errorMessage = CloneProfile(profiles[_copyFromIndex], _newProfileName); + + if (_errorMessage == null) + { + _errorMessage = SwitchToProfile(_newProfileName); + _newProfileName = ""; + } + } + } + + ImGuiHelper.NewLineAndTab(); + if (ImGui.Button("Import From Clipboard", new Vector2(200, 0))) + { + _newProfileName = _newProfileName.Trim(); + if (_newProfileName.Length == 0) + { + ImGui.OpenPopup("import_error_popup"); + } + else + { + _errorMessage = ImportFromClipboard(_newProfileName); + + if (_errorMessage == null) + { + _newProfileName = ""; + } + } + } + + ImGui.SameLine(); + if (ImGui.Button("Import From File", new Vector2(200, 0))) + { + _newProfileName = _newProfileName.Trim(); + if (_newProfileName.Length == 0) + { + ImGui.OpenPopup("import_error_popup"); + } + else + { + ImportFromFile(_newProfileName); + } + } + + // no name popup + if (ImGui.BeginPopup("import_error_popup")) + { + ImGui.Text("Please type a name for the new profile!"); + ImGui.EndPopup(); + } + } + + ImGui.EndChild(); + + // error message + if (_errorMessage != null) + { + if (ImGuiHelper.DrawErrorModal(_errorMessage)) + { + _errorMessage = null; + } + } + + // delete confirmation + if (_deletingProfileName != null) + { + string[] lines = new string[] { "Are you sure you want to delete the profile:", " - " + _deletingProfileName }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Delete?", lines); + + if (didConfirm) + { + _errorMessage = DeleteProfile(_deletingProfileName); + changed = true; + } + + if (didConfirm || didClose) + { + _deletingProfileName = null; + } + } + + // reset confirmation + if (_resetingProfileName != null) + { + string[] lines = new string[] { "Are you sure you want to reset the profile:", " - " + _resetingProfileName }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Reset?", lines); + + if (didConfirm) + { + ResetProfileToDefault(_resetingProfileName, true); + ReloadCurrentProfile(); + + changed = true; + } + + if (didConfirm || didClose) + { + _resetingProfileName = null; + } + } + + // rename modal + if (_renamingProfileName != null) + { + var (didConfirm, didClose) = ImGuiHelper.DrawInputModal("Rename", "Type a new name for the profile:", ref _renamingProfileName); + + if (didConfirm) + { + _errorMessage = RenameCurrentProfile(_renamingProfileName); + + changed = true; + } + + if (didConfirm || didClose) + { + _renamingProfileName = null; + } + } + + _fileDialogManager.Draw(); + + return false; + } + + private void DrawAutoSwitchSettings(ref bool changed) + { + Profile profile = CurrentProfile(); + + changed |= ImGui.Checkbox("Auto-Switch For Specific Jobs", ref profile.AutoSwitchEnabled); + + if (!profile.AutoSwitchEnabled) + { + return; + } + + AutoSwitchData data = profile.AutoSwitchData; + Vector2 cursorPos = ImGui.GetCursorPos() + new Vector2(14, 14); + Vector2 originalPos = cursorPos; + float maxY = 0; + + JobRoles[] roles = (JobRoles[])Enum.GetValues(typeof(JobRoles)); + + foreach (JobRoles role in roles) + { + if (role == JobRoles.Unknown) { continue; } + if (!data.Map.ContainsKey(role)) { continue; } + + bool roleValue = data.GetRoleEnabled(role); + string roleName = JobsHelper.RoleNames[role]; + + ImGui.SetCursorPos(cursorPos); + if (ImGui.Checkbox(roleName, ref roleValue)) + { + data.SetRoleEnabled(role, roleValue); + changed = true; + } + + cursorPos.Y += 40; + int jobCount = data.Map[role].Count; + + for (int i = 0; i < jobCount; i++) + { + maxY = Math.Max(cursorPos.Y, maxY); + uint jobId = JobsHelper.JobsByRole[role][i]; + bool jobValue = data.Map[role][i]; + string jobName = JobsHelper.JobNames[jobId]; + + ImGui.SetCursorPos(cursorPos); + if (ImGui.Checkbox(jobName, ref jobValue)) + { + data.Map[role][i] = jobValue; + changed = true; + } + + cursorPos.Y += 30; + } + + cursorPos.X += 100; + cursorPos.Y = originalPos.Y; + } + + ImGui.SetCursorPos(new Vector2(originalPos.X, maxY + 30)); + } + + private void DrawAttachHudLayout(ref bool changed) + { + Profile profile = CurrentProfile(); + + changed |= ImGui.Checkbox("Attach HUD Layout to this profile", ref profile.AttachHudEnabled); + + if (!profile.AttachHudEnabled) + { + profile.HudLayout = 0; + return; + } + + int hudLayout = profile.HudLayout; + + ImGui.Text("\u2514"); + + for (int i = 1; i <= 4; i++) + { + ImGui.SameLine(); + bool hudLayoutEnabled = hudLayout == i; + if (ImGui.Checkbox("Hud Layout " + i, ref hudLayoutEnabled)) + { + profile.HudLayout = i; + changed = true; + } + } + + } + } + + // fake config object + [Disableable(false)] + [Exportable(false)] + [Shareable(false)] + [Resettable(false)] + public class ProfilesConfig : PluginConfigObject + { + public new static ProfilesConfig DefaultConfig() { return new ProfilesConfig(); } + } + + // fake config page node + public class ProfilesConfigPageNode : ConfigPageNode + { + public ProfilesConfigPageNode() + { + ConfigObject = new ProfilesConfig(); + } + + public override bool Draw(ref bool changed) + { + return ProfilesManager.Instance?.Draw(ref changed) ?? false; + } + } +} diff --git a/Config/Tree/BaseNode.cs b/Config/Tree/BaseNode.cs new file mode 100644 index 0000000..606cb4c --- /dev/null +++ b/Config/Tree/BaseNode.cs @@ -0,0 +1,372 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Interface.Utility; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Config.Tree +{ + public delegate void ConfigObjectResetEventHandler(BaseNode sender); + + public class BaseNode : Node + { + public event ConfigObjectResetEventHandler? ConfigObjectResetEvent; + + private Dictionary _configPageNodesMap; + + public bool NeedsSave = false; + public string? SelectedOptionName = null; + + private List _extraNodes = new List(); + private List _nodes = new List(); + + public IReadOnlyCollection Sections => _nodes.AsReadOnly(); + + private float _scale => ImGuiHelpers.GlobalScale; + + public BaseNode() + { + _configPageNodesMap = new Dictionary(); + } + + public void AddExtraSectionNode(SectionNode node) + { + _extraNodes.Add(node); + + _nodes.Clear(); + CreateNodesIfNeeded(); + } + + public T? GetConfigObject() where T : PluginConfigObject + { + var pageNode = GetConfigPageNode(); + + return pageNode != null ? (T)pageNode.ConfigObject : null; + } + + public void RemoveConfigObject() where T : PluginConfigObject + { + if (_configPageNodesMap.ContainsKey(typeof(T))) + { + _configPageNodesMap.Remove(typeof(T)); + } + } + + public ConfigPageNode? GetConfigPageNode() where T : PluginConfigObject + { + if (_configPageNodesMap.TryGetValue(typeof(T), out var node)) + { + return node; + } + + var configPageNode = GetOrAddConfig(); + + if (configPageNode != null && configPageNode.ConfigObject != null) + { + _configPageNodesMap.Add(typeof(T), configPageNode); + + return configPageNode; + } + + return null; + } + + public void SetConfigPageNode(ConfigPageNode configPageNode) + { + if (configPageNode.ConfigObject == null) + { + return; + } + + _configPageNodesMap[configPageNode.ConfigObject.GetType()] = configPageNode; + } + + public bool SetConfigObject(PluginConfigObject configObject) + { + if (_configPageNodesMap.TryGetValue(configObject.GetType(), out ConfigPageNode? configPageNode)) + { + configPageNode.ConfigObject = configObject; + return true; + } + + return false; + } + + private bool PushStyles() + { + ImGui.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 1); //Scrollbar Radius + ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 1); //Tabs Radius Radius + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 1); //Intractable Elements Radius + ImGui.PushStyleVar(ImGuiStyleVar.GrabRounding, 1); //Gradable Elements Radius + ImGui.PushStyleVar(ImGuiStyleVar.PopupRounding, 1); //Popup Radius + ImGui.PushStyleVar(ImGuiStyleVar.ScrollbarSize, 10); //Popup Radius + + if (ConfigurationManager.Instance.OverrideDalamudStyle) + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(46f / 255f, 45f / 255f, 46f / 255f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .2f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .2f)); + + ImGui.PushStyleColor(ImGuiCol.Separator, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .4f)); + + ImGui.PushStyleColor(ImGuiCol.ScrollbarBg, new Vector4(20f / 255f, 21f / 255f, 20f / 255f, .7f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrab, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .7f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrabActive, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .7f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrabHovered, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .7f)); + + ImGui.PushStyleColor(ImGuiCol.Tab, new Vector4(46f / 255f, 45f / 255f, 46f / 255f, 1f)); + ImGui.PushStyleColor(ImGuiCol.TabActive, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .7f)); + ImGui.PushStyleColor(ImGuiCol.TabHovered, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .2f)); + ImGui.PushStyleColor(ImGuiCol.TabUnfocused, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .2f)); + + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, 1f)); + ImGui.PushStyleColor(ImGuiCol.CheckMark, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, 1f)); + + ImGui.PushStyleColor(ImGuiCol.TableBorderStrong, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, 1f)); + ImGui.PushStyleColor(ImGuiCol.TableBorderLight, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .4f)); + ImGui.PushStyleColor(ImGuiCol.TableHeaderBg, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, .2f)); + + return true; + } + + return false; + } + + private void PopStyles(bool popColors) + { + if (popColors) + { + ImGui.PopStyleColor(17); + } + + ImGui.PopStyleVar(6); + } + + public void CreateNodesIfNeeded() + { + if (_nodes.Count > 0) { return; } + + _nodes.AddRange(_children); + _nodes.AddRange(_extraNodes); + } + + public void RefreshSelectedNode() + { + if (_nodes.Count == 0) { return; } + + foreach (SectionNode node in _nodes.FindAll(x => x is SectionNode)) + { + node.Selected = node.Name == SelectedOptionName; + } + } + + public void Draw(float alpha) + { + CreateNodesIfNeeded(); + if (_nodes.Count == 0) { return; } + + bool changed = false; + bool didReset = false; + + bool popColors = PushStyles(); + + ImGui.BeginGroup(); // Middle section + { + ImGui.BeginGroup(); // Left + { + // banner + IDalamudTextureWrap? delvUiBanner = Plugin.BannerTexture?.GetWrapOrDefault(); + if (delvUiBanner != null) + { + ImGui.SetCursorPos(new Vector2(15 + 150 * _scale / 2f - delvUiBanner.Width / 2f, 5)); + ImGui.Image(delvUiBanner.Handle, new Vector2(delvUiBanner.Width, delvUiBanner.Height)); + } + + // version + ImGui.SetCursorPos(new Vector2(60 * _scale, 35)); + ImGui.Text($"v{Plugin.Version}"); + + + // section list + if (ImGui.BeginChild("left pane", new Vector2(150 * _scale, -10), true, ImGuiWindowFlags.NoScrollbar)) + { + + // if no section is selected, select the first + if (_nodes.Any() && _nodes.Find(o => o is SectionNode sectionNode && sectionNode.Selected) == null) + { + SectionNode? selectedSection = (SectionNode?)_nodes.Find(o => o is SectionNode sectionNode && sectionNode.Name == SelectedOptionName); + if (selectedSection != null) + { + selectedSection.Selected = true; + SelectedOptionName = selectedSection.Name; + } + else if (_nodes.Count > 0) + { + SectionNode node = (SectionNode)_nodes[0]; + node.Selected = true; + SelectedOptionName = node.Name; + } + } + + foreach (SectionNode selectionNode in _nodes) + { + if (ImGui.Selectable(selectionNode.Name, selectionNode.Selected)) + { + selectionNode.Selected = true; + SelectedOptionName = selectionNode.Name; + + foreach (SectionNode otherNode in _nodes.FindAll(x => x != selectionNode)) + { + otherNode.Selected = false; + } + } + + DrawExportResetContextMenu(selectionNode, selectionNode.Name); + } + + // changelog button + float y = ImGui.GetWindowHeight() - (30 * _scale); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(255f / 255f, 255f / 255f, 255f / 255f, alpha)); + ImGui.SetCursorPos(new Vector2(19 * _scale, y)); + if (ImGui.Button(FontAwesomeIcon.List.ToIconString(), new Vector2(24 * _scale, 24 * _scale))) + { + ConfigurationManager.Instance.OpenChangelogWindow(); + } + ImGui.PopStyleColor(); + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Changelog"); + + // discord button + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(114f / 255f, 137f / 255f, 218f / 255f, alpha)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(114f / 255f, 137f / 255f, 218f / 255f, alpha * .85f)); + ImGui.SetCursorPos(new Vector2(48 * _scale, y)); + if (ImGui.Button(FontAwesomeIcon.Link.ToIconString(), new Vector2(24 * _scale, 24 * _scale))) + { + Utils.OpenUrl("https://discord.gg/xzde5qQayh"); + } + ImGui.PopStyleColor(2); + ImGui.PopFont(); + ImGuiHelper.SetTooltip("HSUI Discord"); + + // discord button + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(255f / 255f, 94f / 255f, 91f / 255f, alpha)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(255f / 255f, 94f / 255f, 91f / 255f, alpha * .85f)); + ImGui.SetCursorPos(new Vector2(77 * _scale, y)); + if (ImGui.Button(FontAwesomeIcon.MugHot.ToIconString(), new Vector2(24 * _scale, 24 * _scale))) + { + Utils.OpenUrl("https://ko-fi.com/Tischel"); + } + ImGui.PopStyleColor(2); + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Tip the developer at ko-fi.com"); + } + + ImGui.EndChild(); + } + + ImGui.EndGroup(); // Left + + + didReset |= DrawResetModal(); + + ImGui.SameLine(); + + ImGui.BeginGroup(); // Right + { + foreach (SectionNode selectionNode in _nodes) + { + didReset |= selectionNode.Draw(ref changed, alpha); + } + } + + ImGui.EndGroup(); // Right + } + + ImGui.EndGroup(); // Middle section + + // close button + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, alpha)); + ImGui.SetCursorPos(new Vector2(ImGui.GetWindowWidth() - 28 * _scale, 5 * _scale)); + if (ImGui.Button(FontAwesomeIcon.Times.ToIconString(), new Vector2(22 * _scale, 22 * _scale))) + { + ConfigurationManager.Instance.CloseConfigWindow(); + } + ImGui.PopStyleColor(); + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Close"); + + // unlock button + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, alpha)); + ImGui.SetCursorPos(new Vector2(ImGui.GetWindowWidth() - 60 * _scale, 5 * _scale)); + string lockString = ConfigurationManager.Instance.LockHUD ? FontAwesomeIcon.Lock.ToIconString() : FontAwesomeIcon.LockOpen.ToIconString(); + if (ImGui.Button(lockString, new Vector2(22 * _scale, 22 * _scale))) + { + ConfigurationManager.Instance.LockHUD = !ConfigurationManager.Instance.LockHUD; + } + ImGui.PopStyleColor(); + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Unlock HUD"); + + // hide button + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(229f / 255f, 57f / 255f, 57f / 255f, alpha)); + ImGui.SetCursorPos(new Vector2(ImGui.GetWindowWidth() - 88 * _scale, 5 * _scale)); + string hideString = ConfigurationManager.Instance.ShowHUD ? FontAwesomeIcon.Eye.ToIconString() : FontAwesomeIcon.EyeSlash.ToIconString(); + if (ImGui.Button(hideString, new Vector2(26 * _scale, 22 * _scale))) + { + ConfigurationManager.Instance.ShowHUD = !ConfigurationManager.Instance.ShowHUD; + } + ImGui.PopStyleColor(); + ImGui.PopFont(); + ImGuiHelper.SetTooltip(ConfigurationManager.Instance.ShowHUD ? "Hide HUD" : "Show HUD"); + + PopStyles(popColors); + + if (didReset) + { + ConfigObjectResetEvent?.Invoke(this); + } + + NeedsSave |= changed | didReset; + } + + public ConfigPageNode? GetOrAddConfig() where T : PluginConfigObject + { + object[] attributes = typeof(T).GetCustomAttributes(true); + + foreach (object attribute in attributes) + { + if (attribute is SectionAttribute sectionAttribute) + { + foreach (SectionNode sectionNode in _children) + { + if (sectionNode.Name == sectionAttribute.SectionName) + { + return sectionNode.GetOrAddConfig(); + } + } + + SectionNode newNode = new(); + newNode.Name = sectionAttribute.SectionName; + newNode.ForceAllowExport = sectionAttribute.ForceAllowExport; + _children.Add(newNode); + + return newNode.GetOrAddConfig(); + } + } + + Type type = typeof(T); + throw new ArgumentException("The provided configuration object does not specify a section: " + type.Name); + } + } +} diff --git a/Config/Tree/ConfigPageNode.cs b/Config/Tree/ConfigPageNode.cs new file mode 100644 index 0000000..1cecc78 --- /dev/null +++ b/Config/Tree/ConfigPageNode.cs @@ -0,0 +1,311 @@ +using Dalamud.Logging; +using HSUI.Config.Attributes; +using HSUI.Config.Profiles; +using HSUI.Helpers; +using HSUI.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Reflection; + +namespace HSUI.Config.Tree +{ + public class ConfigPageNode : SubSectionNode + { + private PluginConfigObject _configObject = null!; + private List? _drawList = null; + private Dictionary _nestedConfigPageNodes = null!; + + public PluginConfigObject ConfigObject + { + get => _configObject; + set + { + _configObject = value; + GenerateNestedConfigPageNodes(); + _drawList = null; + } + } + + public override List GetObjects() + { + return _configObject.GetObjects(); + } + + private void GenerateNestedConfigPageNodes() + { + _nestedConfigPageNodes = new Dictionary(); + + FieldInfo[] fields = _configObject.GetType().GetFields(); + + foreach (var field in fields) + { + foreach (var attribute in field.GetCustomAttributes(true)) + { + if (attribute is not NestedConfigAttribute nestedConfigAttribute) + { + continue; + } + + var value = field.GetValue(_configObject); + + if (value is not PluginConfigObject nestedConfig) + { + continue; + } + + ConfigPageNode configPageNode = new(); + configPageNode.ConfigObject = nestedConfig; + configPageNode.Name = nestedConfigAttribute.friendlyName; + + if (nestedConfig.Disableable) + { + configPageNode.Name += "##" + nestedConfig.GetHashCode(); + } + + _nestedConfigPageNodes.Add(field.Name, configPageNode); + } + } + } + + public override string? GetBase64String() + { + if (!AllowShare()) + { + return null; + } + + return ImportExportHelper.GenerateExportString(ConfigObject); + } + + protected override bool AllowExport() + { + return ConfigObject.Exportable; + } + + protected override bool AllowShare() + { + return ConfigObject.Shareable; + } + + protected override bool AllowReset() + { + return ConfigObject.Resettable; + } + + public override bool Draw(ref bool changed) { return DrawWithID(ref changed); } + + private bool DrawWithID(ref bool changed, string? ID = null) + { + bool didReset = false; + + // Only do this stuff the first time the config page is loaded + if (_drawList is null) + { + _drawList = GenerateDrawList(); + } + + if (_drawList is not null) + { + foreach (var fieldNode in _drawList) + { + didReset |= fieldNode.Draw(ref changed); + } + } + + didReset |= DrawPortableSection(); + + ImGui.NewLine(); // fixes some long pages getting cut off + + return didReset; + } + + private List GenerateDrawList(string? ID = null) + { + Dictionary fieldMap = new Dictionary(); + + FieldInfo[] fields = ConfigObject.GetType().GetFields(); + foreach (var field in fields) + { + if (ConfigObject.DisableParentSettings != null && ConfigObject.DisableParentSettings.Contains(field.Name)) + { + continue; + } + + foreach (object attribute in field.GetCustomAttributes(true)) + { + if (attribute is NestedConfigAttribute nestedConfigAttribute && _nestedConfigPageNodes.TryGetValue(field.Name, out ConfigPageNode? node)) + { + var newNodes = node.GenerateDrawList(node.Name); + foreach (var newNode in newNodes) + { + newNode.Position = nestedConfigAttribute.pos; + newNode.Separator = nestedConfigAttribute.separator; + newNode.Spacing = nestedConfigAttribute.spacing; + newNode.ParentName = nestedConfigAttribute.collapseWith; + newNode.Nest = nestedConfigAttribute.nest; + newNode.CollapsingHeader = nestedConfigAttribute.collapsingHeader; + fieldMap.Add($"{node.Name}_{newNode.Name}", newNode); + } + } + else if (attribute is OrderAttribute orderAttribute) + { + var fieldNode = new FieldNode(field, ConfigObject, ID); + fieldNode.Position = orderAttribute.pos; + fieldNode.ParentName = orderAttribute.collapseWith; + if (fieldMap.TryGetValue(field.Name, out var existing) && existing is FieldNode existingFn && + existingFn.Field.DeclaringType != null && field.DeclaringType != null && + existingFn.Field.DeclaringType.IsAssignableFrom(field.DeclaringType) && + existingFn.Field.DeclaringType != field.DeclaringType) + { + fieldMap[field.Name] = fieldNode; + } + else if (!fieldMap.ContainsKey(field.Name)) + { + fieldMap.Add(field.Name, fieldNode); + } + } + } + } + + var manualDrawMethods = ConfigObject.GetType().GetMethods().Where(m => Attribute.IsDefined(m, typeof(ManualDrawAttribute), false)); + foreach (var method in manualDrawMethods) + { + string id = $"ManualDraw##{method.GetHashCode()}"; + fieldMap.Add(id, new ManualDrawNode(method, ConfigObject, id)); + } + + foreach (var configNode in fieldMap.Values) + { + if (configNode.ParentName is not null && + fieldMap.TryGetValue(configNode.ParentName, out ConfigNode? parentNode)) + { + if (!ConfigObject.Disableable && + parentNode.Name.Equals("Enabled") && + parentNode.ID is null) + { + continue; + } + + if (parentNode is FieldNode parentFieldNode) + { + parentFieldNode.CollapseControl = true; + parentFieldNode.AddChild(configNode.Position, configNode); + } + } + } + + var fieldNodes = fieldMap.Values.ToList(); + fieldNodes.RemoveAll(f => f.IsChild); + fieldNodes.Sort((x, y) => x.Position - y.Position); + return fieldNodes; + } + + private bool DrawPortableSection() + { + if (!AllowExport()) + { + return false; + } + + ImGuiHelper.DrawSeparator(2, 1); + + const float buttonWidth = 120; + + ImGui.BeginGroup(); + + float width = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; + ImGui.SetCursorPos(new Vector2(width / 2f - buttonWidth - 5, ImGui.GetCursorPosY())); + + if (ImGui.Button("Export", new Vector2(120, 24))) + { + var exportString = ImportExportHelper.GenerateExportString(ConfigObject); + ImGui.SetClipboardText(exportString); + } + + ImGui.SameLine(); + + if (ImGui.Button("Reset", new Vector2(120, 24))) + { + _nodeToReset = this; + _nodeToResetName = Utils.UserFriendlyConfigName(ConfigObject.GetType().Name); + } + + ImGui.NewLine(); + ImGui.EndGroup(); + + return DrawResetModal(); + } + + public override void Save(string path) + { + string[] splits = path.Split("\\", StringSplitOptions.RemoveEmptyEntries); + string directory = path.Replace(splits.Last(), ""); + Directory.CreateDirectory(directory); + + string finalPath = path + ".json"; + + try + { + File.WriteAllText( + finalPath, + JsonConvert.SerializeObject( + ConfigObject, + Formatting.Indented, + new JsonSerializerSettings { TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple, TypeNameHandling = TypeNameHandling.Objects } + ) + ); + } + catch (Exception e) + { + Plugin.Logger.Error("Error when saving config object: " + e.Message); + } + } + + public override void Load(string path) + { + if (ConfigObject is not PluginConfigObject) { return; } + + FileInfo finalPath = new(path + ".json"); + + // Use reflection to call the LoadForType method, this allows us to specify a type at runtime. + // While in general use this is important as the conversion from the superclass 'PluginConfigObject' to a specific subclass (e.g. 'BlackMageHudConfig') would + // be handled by Json.NET, when the plugin is reloaded with a different assembly (as is the case when using LivePluginLoader, or updating the plugin in-game) + // it fails. In order to fix this we need to specify the specific subclass, in order to do this during runtime we must use reflection to set the generic. + MethodInfo? methodInfo = ConfigObject.GetType().GetMethod("Load"); + MethodInfo? function = methodInfo?.MakeGenericMethod(ConfigObject.GetType()); + + object?[] args = new object?[] { finalPath }; + PluginConfigObject? config = (PluginConfigObject?)function?.Invoke(ConfigObject, args); + + ConfigObject = config ?? ConfigObject; + } + + public override void Reset() + { + Type type = ConfigObject.GetType(); + ImportData? importData = ProfilesManager.Instance?.DefaultImportData(type); + + if (importData == null) + { + Plugin.Logger.Error("Error finding default import data for type " + type.ToString()); + return; + } + + PluginConfigObject? config = importData.GetObject(); + if (config == null) + { + Plugin.Logger.Error("Error importing default import data for type " + type.ToString()); + return; + } + + ConfigObject = config; + } + + public override ConfigPageNode? GetOrAddConfig() => this; + } +} \ No newline at end of file diff --git a/Config/Tree/FieldNode.cs b/Config/Tree/FieldNode.cs new file mode 100644 index 0000000..4bb1988 --- /dev/null +++ b/Config/Tree/FieldNode.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Reflection; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; + +namespace HSUI.Config.Tree +{ + public abstract class ConfigNode + { + public bool CollapseControl { get; set; } + + public bool IsChild { get; set; } + + public string Name { get; private set; } + + public bool Nest { get; set; } = true; + + public string? ParentName { get; set; } + + public int Position { get; set; } = Int32.MaxValue; + + public bool Separator { get; set; } + + public bool Spacing { get; set; } + + public bool CollapsingHeader { get; set; } + + public string? ID { get; private set; } + + protected PluginConfigObject ConfigObject { get; set; } + + public ConfigNode(PluginConfigObject configObject, string? id, string name) + { + ConfigObject = configObject; + ID = id; + Name = name; + } + + public abstract bool Draw(ref bool changed, int depth = 0); + + protected void DrawSeparatorOrSpacing() + { + if (Separator) + { + ImGuiHelper.DrawSeparator(1, 1); + } + + if (Spacing) + { + ImGuiHelper.DrawSpacing(1); + } + } + + protected static void DrawNestIndicator(int depth) + { + // This draws the L shaped symbols and padding to the left of config items collapsible under a checkbox. + // Shift cursor to the right to pad for children with depth more than 1. + // 26 is an arbitrary value I found to be around half the width of a checkbox + ImGui.SetCursorPos(ImGui.GetCursorPos() + new Vector2(26, 0) * Math.Max((depth - 1), 0)); + + if (ConfigurationManager.Instance.OverrideDalamudStyle) + { + ImGui.TextColored(new Vector4(229f / 255f, 57f / 255f, 57f / 255f, 1f), "\u2514"); + } + else + { + ImGui.Text("\u2514"); + } + + ImGui.SameLine(); + } + + protected static ConfigAttribute? GetConfigAttribute(FieldInfo field) + { + return field.GetCustomAttributes(true).Where(a => a is ConfigAttribute).FirstOrDefault() as ConfigAttribute; + } + } + + public class FieldNode : ConfigNode + { + private SortedDictionary _childNodes; + private ConfigAttribute? _configAttribute; + private FieldInfo _mainField; + + public FieldInfo Field => _mainField; + + public FieldNode(FieldInfo mainField, PluginConfigObject configObject, string? id) : base(configObject, id, mainField.Name) + { + _mainField = mainField; + _childNodes = new SortedDictionary(); + + _configAttribute = GetConfigAttribute(mainField); + if (_configAttribute is not null) + { + Separator = _configAttribute.separator; + Spacing = _configAttribute.spacing; + } + } + + public void AddChild(int position, ConfigNode field) + { + field.IsChild = true; + + while (_childNodes.ContainsKey(position)) + { + position++; + } + + _childNodes.Add(position, field); + } + + public override bool Draw(ref bool changed, int depth = 0) + { + bool reset = false; + DrawSeparatorOrSpacing(); + + if (!Nest) + { + depth = 0; + } + + if (depth > 0) + { + DrawNestIndicator(depth); + } + + bool collapsing = CollapsingHeader && ConfigObject.Disableable; + + // Draw the ConfigAttribute + if (!collapsing) + { + DrawConfigAttribute(ref changed, _mainField); + } + + bool enabled = _mainField.GetValue(ConfigObject) as bool? ?? false; + + // Draw children + if (CollapseControl && Attribute.IsDefined(_mainField, typeof(CheckboxAttribute))) + { + if (collapsing) + { + if (ImGui.CollapsingHeader(ID + "##CollapsingHeader")) + { + DrawNestIndicator(depth); + DrawConfigAttribute(ref changed, _mainField); + + if (enabled) + { + reset |= DrawChildren(ref changed, depth); + } + } + } + else if (!collapsing && enabled) + { + ImGui.BeginGroup(); + reset |= DrawChildren(ref changed, depth); + ImGui.EndGroup(); + } + } + + return reset; + } + + private bool DrawChildren(ref bool changed, int depth) + { + bool reset = false; + + int childDepth = depth + 1; + foreach (ConfigNode child in _childNodes.Values) + { + if (child.Separator) + { + childDepth = 0; + } + + reset |= child.Draw(ref changed, childDepth); + } + + return reset; + } + + private void DrawConfigAttribute(ref bool changed, FieldInfo field) + { + if (_configAttribute is not null) + { + changed |= _configAttribute.Draw(field, ConfigObject, ID, CollapsingHeader); + } + } + } + + public class ManualDrawNode : ConfigNode + { + private MethodInfo _drawMethod; + + public ManualDrawNode(MethodInfo method, PluginConfigObject configObject, string? id) : base(configObject, id, id ?? "") + { + _drawMethod = method; + } + + public override bool Draw(ref bool changed, int depth = 0) + { + object[] args = new object[] { false }; + bool? result = (bool?)_drawMethod.Invoke(ConfigObject, args); + + bool arg = (bool)args[0]; + changed |= arg; + return result ?? false; + } + } +} \ No newline at end of file diff --git a/Config/Tree/Node.cs b/Config/Tree/Node.cs new file mode 100644 index 0000000..f620ce4 --- /dev/null +++ b/Config/Tree/Node.cs @@ -0,0 +1,164 @@ +using HSUI.Helpers; +using System.Collections.Generic; + +namespace HSUI.Config.Tree +{ + public abstract class Node + { + protected List _children = new List(); + public IReadOnlyList Children => _children.AsReadOnly(); + + public void Add(Node node) + { + _children.Add(node); + } + + #region reset + protected Node? _nodeToReset = null; + protected string? _nodeToResetName = null; + + public virtual List GetObjects() + { + List list = new List(); + + foreach (Node node in _children) + { + list.AddRange(node.GetObjects()); + } + + return list; + } + + protected void DrawExportResetContextMenu(Node node, string name) + { + if (_nodeToReset != null) + { + return; + } + + bool allowExport = node.AllowExport(); + bool allowReset = node.AllowReset(); + if (!allowExport && !allowReset) + { + return; + } + + _nodeToReset = ImGuiHelper.DrawExportResetContextMenu(node, allowExport, allowReset); + _nodeToResetName = name; + } + + protected virtual bool AllowExport() + { + foreach (Node child in _children) + { + if (child.AllowExport()) + { + return true; + } + } + + return false; + } + + protected virtual bool AllowShare() + { + foreach (Node child in _children) + { + if (child.AllowShare()) + { + return true; + } + } + + return false; + } + + protected virtual bool AllowReset() + { + foreach (Node child in _children) + { + if (child.AllowReset()) + { + return true; + } + } + + return false; + } + + protected bool DrawResetModal() + { + if (_nodeToReset == null || _nodeToResetName == null) + { + return false; + } + + string[] lines = new string[] { "Are you sure you want to reset \"" + _nodeToResetName + "\"?" }; + var (didReset, didClose) = ImGuiHelper.DrawConfirmationModal("Reset?", lines); + + if (didReset) + { + _nodeToReset.Reset(); + _nodeToReset = null; + } + else if (didClose) + { + _nodeToReset = null; + } + + return didReset; + } + + + public virtual void Reset() + { + foreach (Node child in _children) + { + child.Reset(); + } + } + #endregion + + #region save and load + public virtual void Save(string path) + { + foreach (Node child in _children) + { + child.Save(path); + } + } + + public virtual void Load(string path) + { + foreach (Node child in _children) + { + child.Load(path); + } + } + #endregion + + #region export + public virtual string? GetBase64String() + { + if (_children == null) + { + return ""; + } + + string base64String = ""; + + foreach (Node child in _children) + { + string? childString = child.GetBase64String(); + + if (childString != null && childString.Length > 0) + { + base64String += "|" + childString; + } + } + + return base64String; + } + #endregion + } +} diff --git a/Config/Tree/SectionNode.cs b/Config/Tree/SectionNode.cs new file mode 100644 index 0000000..0034f4b --- /dev/null +++ b/Config/Tree/SectionNode.cs @@ -0,0 +1,147 @@ +using HSUI.Config.Attributes; +using Dalamud.Bindings.ImGui; +using System; +using System.IO; +using System.Numerics; + +namespace HSUI.Config.Tree +{ + public class SectionNode : Node + { + public bool Selected; + public string Name = null!; + public bool ForceAllowExport = false; + public string? ForceSelectedTabName = null; + + public SectionNode() { } + + protected override bool AllowExport() + { + if (ForceAllowExport) { return true; } + + return base.AllowExport(); + } + + public bool Draw(ref bool changed, float alpha) + { + if (!Selected) + { + return false; + } + + bool didReset = false; + + ImGui.NewLine(); + + if (ImGui.BeginChild( + "DelvU_Settings_Tab", + new Vector2(0, -10), + false, + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse + )) + { + bool popColors = false; + if (ConfigurationManager.Instance.OverrideDalamudStyle) + { + ImGui.PushStyleColor(ImGuiCol.Tab, new Vector4(45f / 255f, 45f / 255f, 45f / 255f, alpha)); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(45f / 255f, 45f / 255f, 45f / 255f, alpha)); + popColors = true; + } + + if (ImGui.BeginTabBar("##Tabs", ImGuiTabBarFlags.None)) + { + foreach (SubSectionNode subSectionNode in _children) + { + if (ForceSelectedTabName != null) + { + bool a = subSectionNode.Name == ForceSelectedTabName; // no idea how this works + ImGuiTabItemFlags flag = subSectionNode.Name == ForceSelectedTabName ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None; + + if (!ImGui.BeginTabItem(subSectionNode.Name, ref a, flag)) + { + continue; + } + } + else + { + if (!ImGui.BeginTabItem(subSectionNode.Name)) + { + continue; + } + } + + DrawExportResetContextMenu(subSectionNode, subSectionNode.Name); + + ImGui.BeginChild("subconfig value", new Vector2(0, 0), true); + didReset |= subSectionNode.Draw(ref changed); + ImGui.EndChild(); + + ImGui.EndTabItem(); + } + + ImGui.EndTabBar(); + ForceSelectedTabName = null; + } + + if (popColors) + { + ImGui.PopStyleColor(2); + } + + didReset |= DrawResetModal(); + } + + ImGui.EndChild(); + + return didReset; + } + + public override void Save(string path) + { + foreach (SubSectionNode child in _children) + { + child.Save(Path.Combine(path, Name)); + } + } + + public override void Load(string path) + { + foreach (SubSectionNode child in _children) + { + child.Load(Path.Combine(path, Name)); + } + } + + public ConfigPageNode? GetOrAddConfig() where T : PluginConfigObject + { + object[] attributes = typeof(T).GetCustomAttributes(true); + + foreach (object attribute in attributes) + { + if (attribute is SubSectionAttribute subSectionAttribute) + { + foreach (SubSectionNode subSectionNode in _children) + { + if (subSectionNode.Name == subSectionAttribute.SubSectionName) + { + return subSectionNode.GetOrAddConfig(); + } + } + + if (subSectionAttribute.Depth == 0) + { + NestedSubSectionNode newNode = new(); + newNode.Name = subSectionAttribute.SubSectionName; + newNode.Depth = 0; + _children.Add(newNode); + + return newNode.GetOrAddConfig(); + } + } + } + + Type type = typeof(T); + throw new ArgumentException("The provided configuration object does not specify a sub-section: " + type.Name); + } + } +} diff --git a/Config/Tree/SubSectionNode.cs b/Config/Tree/SubSectionNode.cs new file mode 100644 index 0000000..d3460e6 --- /dev/null +++ b/Config/Tree/SubSectionNode.cs @@ -0,0 +1,173 @@ +using HSUI.Config.Attributes; +using Dalamud.Bindings.ImGui; +using System.IO; +using System.Numerics; +using System.Reflection; + +namespace HSUI.Config.Tree +{ + public abstract class SubSectionNode : Node + { + public string Name = null!; + public int Depth; + public string? ForceSelectedTabName = null; + + public abstract bool Draw(ref bool changed); + + public abstract ConfigPageNode? GetOrAddConfig() where T : PluginConfigObject; + } + + public class NestedSubSectionNode : SubSectionNode + { + public NestedSubSectionNode() { } + + public override bool Draw(ref bool changed) + { + bool didReset = false; + + if (_children.Count > 1) + { + ImGui.BeginChild( + "DelvUI_Tabs_" + Depth, + new Vector2(0, ImGui.GetWindowHeight() - 22), + false, + ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse + ); // Leave room for 1 line below us + + if (ImGui.BeginTabBar("##tabs" + Depth, ImGuiTabBarFlags.None)) + { + didReset |= DrawSubConfig(ref changed); + } + + ImGui.EndTabBar(); + + ImGui.EndChild(); + } + else + { + ImGui.BeginChild("item" + Depth + " view", new Vector2(0, ImGui.GetWindowHeight() - 20)); // Leave room for 1 line below us + + didReset |= DrawSubConfig(ref changed); + + ImGui.EndChild(); + } + + return didReset; + } + + public bool DrawSubConfig(ref bool changed) + { + bool didReset = false; + + foreach (SubSectionNode subSectionNode in _children) + { + if (subSectionNode is NestedSubSectionNode) + { + if (ForceSelectedTabName != null) + { + bool a = subSectionNode.Name == ForceSelectedTabName; // no idea how this works + ImGuiTabItemFlags flag = subSectionNode.Name == ForceSelectedTabName ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None; + + if (!ImGui.BeginTabItem(subSectionNode.Name, ref a, flag)) + { + continue; + } + } + else + { + if (!ImGui.BeginTabItem(subSectionNode.Name)) + { + continue; + } + } + + DrawExportResetContextMenu(subSectionNode, subSectionNode.Name); + + ImGui.BeginChild("subconfig" + Depth + " value", new Vector2(0, ImGui.GetWindowHeight())); + didReset |= subSectionNode.Draw(ref changed); + ImGui.EndChild(); + + ImGui.EndTabItem(); + } + else + { + didReset |= subSectionNode.Draw(ref changed); + } + } + + ForceSelectedTabName = null; + + didReset |= DrawResetModal(); + + return didReset; + } + + public override void Save(string path) + { + foreach (SubSectionNode child in _children) + { + child.Save(Path.Combine(path, Name)); + } + } + + public override void Load(string path) + { + foreach (SubSectionNode child in _children) + { + child.Load(Path.Combine(path, Name)); + } + } + + public override ConfigPageNode? GetOrAddConfig() + { + var type = typeof(T); + if (type == null) + { + return null; + } + + object[] attributes = type.GetCustomAttributes(true); + + foreach (object attribute in attributes) + { + if (attribute is SubSectionAttribute subSectionAttribute) + { + if (subSectionAttribute.Depth != Depth + 1) + { + continue; + } + + foreach (SubSectionNode subSectionNode in _children) + { + if (subSectionNode.Name == subSectionAttribute.SubSectionName) + { + return subSectionNode.GetOrAddConfig(); + } + } + + NestedSubSectionNode nestedSubSectionNode = new(); + nestedSubSectionNode.Name = subSectionAttribute.SubSectionName; + nestedSubSectionNode.Depth = Depth + 1; + _children.Add(nestedSubSectionNode); + + return nestedSubSectionNode.GetOrAddConfig(); + } + } + + foreach (SubSectionNode subSectionNode in _children) + { + if (subSectionNode.Name == type.FullName && subSectionNode is ConfigPageNode node) + { + return node; + } + } + + ConfigPageNode configPageNode = new(); + configPageNode.ConfigObject = ConfigurationManager.GetDefaultConfigObjectForType(type); + configPageNode.Name = type.FullName!; + _children.Add(configPageNode); + + return configPageNode; + } + } +} diff --git a/Config/Windows/ChangelogWindow.cs b/Config/Windows/ChangelogWindow.cs new file mode 100644 index 0000000..cc24b7f --- /dev/null +++ b/Config/Windows/ChangelogWindow.cs @@ -0,0 +1,77 @@ +using Dalamud.Interface.Windowing; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using System; +using System.Numerics; + +namespace HSUI.Config.Windows +{ + public class ChangelogWindow : Window + { + public string Changelog { get; set; } + private bool _needsToSetSize = true; + + public bool AutoClose = false; + private double _openTime = -1; + + private bool _popColors = false; + + public ChangelogWindow(string name, string changelog) : base(name) + { + Changelog = changelog; + } + + public override void PreDraw() + { + if (ConfigurationManager.Instance.OverrideDalamudStyle) + { + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(10f / 255f, 10f / 255f, 10f / 255f, 0.95f)); + _popColors = true; + } + + if (_needsToSetSize) + { + float height = ImGui.CalcTextSize(Changelog).Y + 100; + ImGui.SetNextWindowSize(new Vector2(500, Math.Min(height, 500)), ImGuiCond.FirstUseEver); + _needsToSetSize = false; + } + } + + public override void Draw() + { + Vector2 size = ImGui.GetWindowSize(); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + size.X - 24); + ImGui.TextWrapped(Changelog); + + if (AutoClose && + _openTime > 0 && + ImGui.GetTime() - _openTime > 10) + { + IsOpen = false; + } + } + + public override void PostDraw() + { + if (_popColors) + { + ImGui.PopStyleColor(); + _popColors = false; + } + } + + public override void OnOpen() + { + _openTime = ImGui.GetTime(); + } + + public override void OnClose() + { + if (AutoClose && InputsHelper.Instance != null) + { + AutoClose = false; + Plugin.LoadTime = ImGui.GetTime() - InputsHelper.InitializationDelay; + } + } + } +} diff --git a/Config/Windows/GridWindow.cs b/Config/Windows/GridWindow.cs new file mode 100644 index 0000000..7a1079f --- /dev/null +++ b/Config/Windows/GridWindow.cs @@ -0,0 +1,56 @@ +using Dalamud.Interface.Windowing; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System.Numerics; + +namespace HSUI.Config.Windows +{ + public class GridWindow : Window + { + private bool _popColors = false; + public GridWindow(string name) : base(name) + { + Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoScrollWithMouse; + Size = new Vector2(300, 200); + } + + public override void OnClose() + { + ConfigurationManager.Instance.LockHUD = true; + } + + public override void PreDraw() + { + if (ConfigurationManager.Instance.OverrideDalamudStyle) + { + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(10f / 255f, 10f / 255f, 10f / 255f, 0.95f)); + _popColors = true; + } + + ImGui.SetNextWindowFocus(); + } + + public override void Draw() + { + var configManager = ConfigurationManager.Instance; + var node = configManager.GetConfigPageNode(); + if (node == null) + { + return; + } + + ImGui.PushItemWidth(150); + bool changed = false; + node.Draw(ref changed); + } + + public override void PostDraw() + { + if (_popColors) + { + ImGui.PopStyleColor(); + _popColors = false; + } + } + } +} diff --git a/Config/Windows/MainConfigWindow.cs b/Config/Windows/MainConfigWindow.cs new file mode 100644 index 0000000..2b7897f --- /dev/null +++ b/Config/Windows/MainConfigWindow.cs @@ -0,0 +1,82 @@ +using Dalamud.Interface.Windowing; +using Dalamud.Logging; +using HSUI.Config.Tree; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Numerics; + +namespace HSUI.Config.Windows +{ + public class MainConfigWindow : Window + { + public BaseNode? node { get; set; } + public Action? CloseAction; + + private float _alpha = 1f; + private Vector2 _lastWindowPos = Vector2.Zero; + private Vector2 _size = new Vector2(1050, 750); + + private bool _popColors = false; + + public MainConfigWindow(string name) : base(name) + { + Flags = ImGuiWindowFlags.NoTitleBar; + Size = _size; + SizeCondition = ImGuiCond.FirstUseEver; + } + + public override void OnClose() + { + CloseAction?.Invoke(); + } + + private bool CheckWindowFocus() + { + Vector2 mousePos = ImGui.GetMousePos(); + Vector2 endPos = _lastWindowPos + _size; + + return mousePos.X >= _lastWindowPos.X && mousePos.X <= endPos.X && + mousePos.Y >= _lastWindowPos.Y && mousePos.Y <= endPos.Y; + } + + public override void PreDraw() + { + _alpha = 1; + + HUDOptionsConfig? config = ConfigurationManager.Instance.GetConfigObject(); + if (config?.DimConfigWindow == true) + { + _alpha = CheckWindowFocus() ? 1 : 0.5f; + } + + if (ConfigurationManager.Instance.OverrideDalamudStyle) + { + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0f / 255f, 0f / 255f, 0f / 255f, _alpha)); + ImGui.PushStyleColor(ImGuiCol.BorderShadow, new Vector4(0f / 255f, 0f / 255f, 0f / 255f, _alpha)); + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(20f / 255f, 21f / 255f, 20f / 255f, _alpha)); + _popColors = true; + } + + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 1); + } + + public override void Draw() + { + _lastWindowPos = ImGui.GetWindowPos(); + + if (_popColors) + { + ImGui.PopStyleColor(3); + _popColors = false; + } + + ImGui.PopStyleVar(2); + + node?.Draw(_alpha); + + _size = ImGui.GetWindowSize(); + } + } +} diff --git a/Enums/BarTextureDrawMode.cs b/Enums/BarTextureDrawMode.cs new file mode 100644 index 0000000..50590b7 --- /dev/null +++ b/Enums/BarTextureDrawMode.cs @@ -0,0 +1,10 @@ +namespace HSUI.Enums +{ + public enum BarTextureDrawMode + { + Stretch = 0, + RepeatHorizontal = 1, + RepeatVertical = 2, + Repeat = 3 + } +} diff --git a/Enums/BlendMode.cs b/Enums/BlendMode.cs new file mode 100644 index 0000000..d63c41f --- /dev/null +++ b/Enums/BlendMode.cs @@ -0,0 +1,14 @@ +namespace HSUI.Enums +{ + public enum BlendMode + { + LAB, + LChab, + XYZ, + RGB, + LChuv, + Luv, + Jzazbz, + JzCzhz + } +} diff --git a/Enums/DamageType.cs b/Enums/DamageType.cs new file mode 100644 index 0000000..dcc4bcd --- /dev/null +++ b/Enums/DamageType.cs @@ -0,0 +1,14 @@ +namespace HSUI.Enums +{ + public enum DamageType + { + Unknown = 0, + Slashing = 1, + Piercing = 2, + Blunt = 3, + Magic = 5, + Darkness = 6, + Physical = 7, + LimitBreak = 8 + } +} diff --git a/Enums/DrawAnchor.cs b/Enums/DrawAnchor.cs new file mode 100644 index 0000000..31c1eea --- /dev/null +++ b/Enums/DrawAnchor.cs @@ -0,0 +1,15 @@ +namespace HSUI.Enums +{ + public enum DrawAnchor + { + Center = 0, + Left = 1, + Right = 2, + Top = 3, + TopLeft = 4, + TopRight = 5, + Bottom = 6, + BottomLeft = 7, + BottomRight = 8 + } +} diff --git a/Enums/ElementKind.cs b/Enums/ElementKind.cs new file mode 100644 index 0000000..aa8a0a2 --- /dev/null +++ b/Enums/ElementKind.cs @@ -0,0 +1,58 @@ +using System; + +namespace HSUI.Enums; + +// Hashes from HUD layout (AddonNameHash). Sources: HSUI, HUDManager plugin +public enum ElementKind : uint +{ + Hotbar1 = 0xC48D3605, // _ActionBar_a + Hotbar2 = 0xFB7B6E1E, // _ActionBar01_a + Hotbar3 = 0xF93DD047, // _ActionBar02_a + Hotbar4 = 0xF8FFBA70, // _ActionBar03_a + Hotbar5 = 0xFDB0ACF5, // _ActionBar04_a + Hotbar6 = 0xFC72C6C2, // _ActionBar05_a + Hotbar7 = 0xFE34789B, // _ActionBar06_a + Hotbar8 = 0xFFF612AC, // _ActionBar07_a + Hotbar9 = 0xF4AA5591, // _ActionBar08_a + Hotbar10 = 0xF5683FA6, // _ActionBar09_a + PetHotbar = 0xD8D188FF, // _ActionBarEx_a + CrossHotbar = 0xBA81E8D1, // _ActionCross_a + LeftWCrossHotbar = 0x6665735D, // _ActionDoubleCrossL_a + RightWCrossHotbar = 0x70DDFD27, // _ActionDoubleCrossR_a + ParameterBar = 0x9818E23E, // _ParameterWidget - player HP/MP + TargetBar = 0x9139E9FD, // target info + FocusTargetBar = 0xC28E7D1F, + ProgressBar = 0xECB1E391, // cast bar + ExperienceBar = 0x21E32D4E, + LimitGauge = 0xC7A40A8A, + StatusEffects = 0x4A5B6A16, // player buffs/debuffs + StatusInfoEnhancements = 0x1F320624, + StatusInfoEnfeeblements = 0x1E7A7A83, + TargetInfoHp = 0xBD411F17, + TargetInfoProgressBar = 0xCB5BCCCF, + TargetInfoStatus = 0x070F4E6B, + PartyList = 0x3D3B34F9, + EnemyList = 0xB8C30AC5, +} + +public static class ElementKindHelper +{ + public static ElementKind ElementKindByHotBarId(int hotBarId) + { + return hotBarId switch + { + 0 => ElementKind.Hotbar1, + 1 => ElementKind.Hotbar2, + 2 => ElementKind.Hotbar3, + 3 => ElementKind.Hotbar4, + 4 => ElementKind.Hotbar5, + 5 => ElementKind.Hotbar6, + 6 => ElementKind.Hotbar7, + 7 => ElementKind.Hotbar8, + 8 => ElementKind.Hotbar9, + 9 => ElementKind.Hotbar10, + 10 => ElementKind.PetHotbar, + _ => throw new ArgumentOutOfRangeException(nameof(hotBarId)) + }; + } +} \ No newline at end of file diff --git a/Enums/StrataLevel.cs b/Enums/StrataLevel.cs new file mode 100644 index 0000000..9b2432e --- /dev/null +++ b/Enums/StrataLevel.cs @@ -0,0 +1,13 @@ +namespace HSUI.Enums +{ + public enum StrataLevel + { + LOWEST = 0, + LOW, + MID_LOW, + MID, + MID_HIGH, + HIGH, + HIGHEST, + } +} diff --git a/Extensions.cs b/Extensions.cs new file mode 100644 index 0000000..e266ab3 --- /dev/null +++ b/Extensions.cs @@ -0,0 +1,139 @@ +using System; +using System.Globalization; +using System.Numerics; +using System.Text; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.Text.SeStringHandling; +using HSUI.Config; +using HSUI.Interface.Bars; +using FFXIVClientStructs.FFXIV.Client.System.String; +using static System.Globalization.CultureInfo; + +namespace HSUI +{ + public static class Extensions + { + public static string Abbreviate(this string str) + { + if (str.Length > 20) { + string[] splits = str.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < splits.Length - 1; i++) + { + splits[i] = splits[i][0].ToString(); + } + + return string.Join(". ", splits).ToString(); + } + + return str; + } + + public static string FirstName(this string str) + { + string[] splits = str.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + return splits.Length > 0 ? splits[0] : ""; + } + + public static string LastName(this string str) + { + string[] splits = str.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + return splits.Length > 1 ? splits[^1] : ""; + } + + public static string Initials(this string str) + { + var initials = ""; + var firstName = FirstName(str); + var lastName = LastName(str); + + if (firstName.Length > 0) + { + initials = firstName[0] + "."; + } + + if (lastName.Length > 0) + { + initials += " " + lastName[0] + "."; + } + + return initials; + } + + public static string Truncated(this string str, int length = 0) + { + return length > 0 ? str.Substring(0, Math.Min(str.Length, length)) : str; + } + + public static Vector4 AdjustColor(this Vector4 vec, float correctionFactor) + { + float red = vec.X; + float green = vec.Y; + float blue = vec.Z; + + if (correctionFactor < 0) + { + correctionFactor = 1 + correctionFactor; + red *= correctionFactor; + green *= correctionFactor; + blue *= correctionFactor; + } + else + { + red = (1 - red) * correctionFactor + red; + green = (1 - green) * correctionFactor + green; + blue = (1 - blue) * correctionFactor + blue; + } + + return new Vector4(red, green, blue, vec.W); + } + + public static Vector4 WithNewAlpha(this Vector4 vec, float alpha) + { + return new Vector4(vec.X, vec.Y, vec.Z, alpha); + } + + public static string KiloFormat(this uint num) + { + return num switch + { + >= 100000000 => (num / 1000000.0).ToString("#,0M", ConfigurationManager.Instance.ActiveCultreInfo), + >= 1000000 => (num / 1000000.0).ToString("0.0", ConfigurationManager.Instance.ActiveCultreInfo) + "M", + >= 100000 => (num / 1000.0).ToString("#,0K", ConfigurationManager.Instance.ActiveCultreInfo), + >= 10000 => (num / 1000.0).ToString("0.0", ConfigurationManager.Instance.ActiveCultreInfo) + "K", + _ => num.ToString("#,0", ConfigurationManager.Instance.ActiveCultreInfo) + }; + } + + public static bool IsHorizontal(this BarDirection direction) + { + return direction == BarDirection.Right || direction == BarDirection.Left; + } + + public static bool IsInverted(this BarDirection direction) + { + return direction == BarDirection.Left || direction == BarDirection.Up; + } + + public static void Draw(this BarHud[] bars, Vector2 origin) + { + foreach (BarHud bar in bars) + { + bar.Draw(origin); + } + } + + public static string CheckForUpperCase(this string str) + { + var culture = CurrentCulture.TextInfo; + if (!string.IsNullOrEmpty(str) && char.IsLetter(str[0]) && !char.IsUpper(str[0])) + { + str = culture.ToTitleCase(str); + } + + return str; + } + + public static string Repeat(this string s, int n) + => new StringBuilder(s.Length * n).Insert(0, s, n).ToString(); + } +} diff --git a/HSUI.csproj b/HSUI.csproj new file mode 100644 index 0000000..210c21f --- /dev/null +++ b/HSUI.csproj @@ -0,0 +1,56 @@ + + + x64 + net10.0-windows + latest + x64 + Debug;Release + + + + HSUI + 1.0.0.0 + 1.0.0.0 + 1.0.0.0 + + + + Library + true + false + enable + false + + + + true + + + + dev + ../repos/Dalamud-master/ + + + + + $(AssemblySearchPaths); + $(DalamudLocal); + $(DalamudLibPath); + + + + + + + + + + + + + + + + + + diff --git a/HSUI.json b/HSUI.json new file mode 100644 index 0000000..ffbb9fe --- /dev/null +++ b/HSUI.json @@ -0,0 +1,11 @@ +{ + "Author": "Knack117", + "Name": "HSUI", + "InternalName": "HSUI", + "AssemblyVersion": "1.0.0.0", + "Description": "HSUI provides a highly configurable HUD replacement for FFXIV, recreated from DelvUI using KamiToolKit, FFXIVClientStructs, and Dalamud. Features unit frames, castbars, job gauges, nameplates, party frames, status effects, enemy list, configurable hotbars with drag-and-drop, and profiles.", + "ApplicableVersion": "any", + "RepoUrl": "https://github.com/Knack117/HSUI", + "Tags": ["UI", "HUD", "Unit Frames", "Nameplates", "Party Frames", "Hotbars"], + "Punchline": "A modern HUD replacement built for customization." +} diff --git a/Helpers/ActionBarsHitTestHelper.cs b/Helpers/ActionBarsHitTestHelper.cs new file mode 100644 index 0000000..b79aa72 --- /dev/null +++ b/Helpers/ActionBarsHitTestHelper.cs @@ -0,0 +1,78 @@ +using HSUI.Config; +using HSUI.Enums; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Numerics; + +namespace HSUI.Helpers +{ + /// + /// Hit test for HSUI hotbars. Used to determine if the cursor is over an HSUI hotbar + /// so we only intercept drag-drop releases when the user is actually dropping on our bars. + /// + public static class ActionBarsHitTestHelper + { + /// Returns true if the mouse cursor is over any visible HSUI hotbar. + public static bool IsMouseOverAnyHSUIHotbar() + { + try + { + var hotbarsConfig = ConfigurationManager.Instance?.GetConfigObject(); + if (hotbarsConfig == null || !hotbarsConfig.Enabled) + return false; + + var hudOptions = ConfigurationManager.Instance?.GetConfigObject(); + Vector2 origin = ImGui.GetMainViewport().Size / 2f; + if (hudOptions != null && hudOptions.UseGlobalHudShift) + origin += hudOptions.HudOffset; + + Vector2 mousePos = ImGui.GetMousePos(); + + HotbarBarConfig?[] barConfigs = new HotbarBarConfig?[] + { + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject(), + ConfigurationManager.Instance?.GetConfigObject() + }; + + foreach (var barConfig in barConfigs) + { + if (barConfig == null) continue; + + Vector2 barSize = ComputeBarSize(barConfig); + Vector2 topLeft = Utils.GetAnchoredPosition(origin + barConfig.Position, barSize, barConfig.Anchor); + + if (mousePos.X >= topLeft.X && mousePos.X < topLeft.X + barSize.X && + mousePos.Y >= topLeft.Y && mousePos.Y < topLeft.Y + barSize.Y) + { + return true; + } + } + + return false; + } + catch + { + return false; + } + } + + private static Vector2 ComputeBarSize(HotbarBarConfig config) + { + var (cols, _) = config.GetLayoutGrid(); + int effectiveCols = Math.Min(cols, config.SlotCount); + int effectiveRows = (config.SlotCount + effectiveCols - 1) / effectiveCols; + float w = effectiveCols * config.SlotSize.X + (effectiveCols - 1) * config.SlotPadding; + float h = effectiveRows * config.SlotSize.Y + (effectiveRows - 1) * config.SlotPadding; + return new Vector2(w, h); + } + } +} diff --git a/Helpers/ActionBarsManager.cs b/Helpers/ActionBarsManager.cs new file mode 100644 index 0000000..a4cb42f --- /dev/null +++ b/Helpers/ActionBarsManager.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using KamiToolKit.Controllers; + +namespace HSUI.Helpers +{ + public sealed class ActionBarsManager : IDisposable + { + public static ActionBarsManager Instance { get; private set; } = null!; + + private AddonController? _addonController; + + private ActionBarsManager() + { + _addonController = new AddonController("_ActionBar"); + _addonController.Enable(); + } + + public static void Initialize() + { + Instance = new ActionBarsManager(); + } + + public void Dispose() + { + _addonController?.Disable(); + _addonController = null; + Instance = null!; + } + + /// + /// Slot data for ImGui drawing. Updated each call from game state. + /// + public readonly struct SlotInfo + { + public uint IconId { get; } + public bool IsEmpty { get; } + public bool IsUsable { get; } + public int CooldownPercent { get; } + public int CooldownSecondsLeft { get; } + public uint ActionId { get; } + public RaptureHotbarModule.HotbarSlotType SlotType { get; } + /// Keybind hint from game (user's keybind settings). Empty if unavailable. + public string KeybindHint { get; } + + public SlotInfo(uint iconId, bool isEmpty, bool usable, int cooldownPct, int cooldownSecs, uint actionId = 0, RaptureHotbarModule.HotbarSlotType slotType = 0, string keybindHint = "") + { + IconId = iconId; + IsEmpty = isEmpty; + IsUsable = usable; + CooldownPercent = cooldownPct; + CooldownSecondsLeft = cooldownSecs; + ActionId = actionId; + SlotType = slotType; + KeybindHint = keybindHint ?? ""; + } + } + + /// + /// Reads keybind hint from HotbarSlot. Uses _keybindHint (slot display) then _popUpKeybindHint (trimmed) as fallback. + /// Offsets match RaptureHotbarModule.HotbarSlot: _keybindHint 0xA8, _popUpKeybindHint 0x88. + /// + private static unsafe string ReadKeybindHintFromSlot(RaptureHotbarModule.HotbarSlot* slot) + { + if (slot == null) return ""; + var sb = (byte*)slot; + string? h = Marshal.PtrToStringUTF8((IntPtr)(sb + 0xA8)); + if (!string.IsNullOrWhiteSpace(h)) + return h.Trim(); + string? p = Marshal.PtrToStringUTF8((IntPtr)(sb + 0x88)); + if (string.IsNullOrWhiteSpace(p)) return ""; + p = p!.Trim(); + if (p.StartsWith(" [", StringComparison.Ordinal) && p.EndsWith("]", StringComparison.Ordinal)) + p = p[2..^1].Trim(); + return p; + } + + /// + /// Reads hotbar slot data from RaptureHotbarModule. Returns up to slotCount slots. + /// hotbarIndex 1-10 maps to StandardHotbars 0-9. + /// + public unsafe List GetSlotData(int hotbarIndex, int slotCount) + { + var list = new List(slotCount); + var module = RaptureHotbarModule.Instance(); + if (module == null || !module->ModuleReady) + return list; + + var hotbars = module->StandardHotbars; + int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1; + int count = Math.Clamp(slotCount, 1, 12); + + ref var bar = ref hotbars[barIdx]; + for (int i = 0; i < count; i++) + { + var slot = bar.GetHotbarSlot((uint)i); + string keybind = ReadKeybindHintFromSlot(slot); + + if (slot == null) + { + list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, keybind)); + continue; + } + + if (slot->IsEmpty) + { + list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, keybind)); + continue; + } + + int secsLeft = 0; + int pct = slot->GetSlotActionCooldownPercentage(&secsLeft, 0); + bool usable = slot->IsSlotUsable(slot->ApparentSlotType, slot->ApparentActionId); + uint iconId = slot->IconId; + uint actionId = slot->ApparentActionId; + var slotType = slot->ApparentSlotType; + + list.Add(new SlotInfo(iconId, false, usable, pct, secsLeft, actionId, slotType, keybind)); + } + + return list; + } + + /// + /// Returns the default game keybind label for a hotbar slot (Hotbar 1: 1,2,...,0,-,=; Bar 2: Ctrl+1..12; etc.). + /// hotbarIndex 1–10, slotIndex 0–11. Used to mirror the default hotbar keybind display. + /// + public static string GetDefaultKeybindLabel(int hotbarIndex, int slotIndex) + { + int s = Math.Clamp(slotIndex, 0, 11); + string k = s switch + { + 0 => "1", 1 => "2", 2 => "3", 3 => "4", 4 => "5", 5 => "6", + 6 => "7", 7 => "8", 8 => "9", 9 => "0", 10 => "-", 11 => "=", + _ => (s + 1).ToString() + }; + int b = Math.Clamp(hotbarIndex, 1, 10); + return b switch + { + 1 => k, + 2 => "Ctrl+" + k, + 3 => "Shift+" + k, + 4 => "Alt+" + k, + _ => $"{b}-{s + 1}" + }; + } + + /// + /// Execute a hotbar slot. hotbarIndex 1-10, slotIndex 0-based. + /// + public unsafe bool ExecuteSlot(int hotbarIndex, int slotIndex) + { + var module = RaptureHotbarModule.Instance(); + if (module == null || !module->ModuleReady) + return false; + + int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1; + int slot = Math.Clamp(slotIndex, 0, 11); + module->ExecuteSlotById((uint)barIdx, (uint)slot); + return true; + } + + /// + /// Swap two hotbar slots. Supports cross-hotbar swap when hotbarA != hotbarB. + /// Uses CommandType/CommandId from the slot (not Apparent*) so items and macros swap correctly. + /// hotbarIndex 1-10, slot indices 0-based. + /// + /// When non-null, receives diagnostic info for logging. + public unsafe bool SwapSlots(int hotbarA, int slotA, int hotbarB, int slotB, Action? debugLog = null) + { + var module = RaptureHotbarModule.Instance(); + if (module == null || !module->ModuleReady) + { + debugLog?.Invoke($"[SwapSlots] FAIL: module null or not ready"); + return false; + } + + int barA = Math.Clamp(hotbarA, 1, 10); + int barB = Math.Clamp(hotbarB, 1, 10); + int a = Math.Clamp(slotA, 0, 11); + int b = Math.Clamp(slotB, 0, 11); + if (barA == barB && a == b) + { + debugLog?.Invoke($"[SwapSlots] NOOP: same slot bar={barA} slot={a}"); + return true; + } + + var slotPtrA = module->GetSlotById((uint)(barA - 1), (uint)a); + var slotPtrB = module->GetSlotById((uint)(barB - 1), (uint)b); + if (slotPtrA == null || slotPtrB == null) + { + debugLog?.Invoke($"[SwapSlots] FAIL: slotPtr null A={slotPtrA != null} B={slotPtrB != null}"); + return false; + } + + var typeA = slotPtrA->IsEmpty ? RaptureHotbarModule.HotbarSlotType.Empty : slotPtrA->CommandType; + var idA = slotPtrA->IsEmpty ? 0u : slotPtrA->CommandId; + var typeB = slotPtrB->IsEmpty ? RaptureHotbarModule.HotbarSlotType.Empty : slotPtrB->CommandType; + var idB = slotPtrB->IsEmpty ? 0u : slotPtrB->CommandId; + + debugLog?.Invoke($"[SwapSlots] BEFORE: A(bar{barA} slot{a}) type={typeA} id={idA} | B(bar{barB} slot{b}) type={typeB} id={idB}"); + + // Update live slots first (like game drag-drop), then persist + slotPtrA->Set(typeB, idB); + slotPtrA->LoadIconId(); + if (typeB == RaptureHotbarModule.HotbarSlotType.Item) + slotPtrA->LoadCostDataForSlot(true); + slotPtrB->Set(typeA, idA); + slotPtrB->LoadIconId(); + if (typeA == RaptureHotbarModule.HotbarSlotType.Item) + slotPtrB->LoadCostDataForSlot(true); + module->SetAndSaveSlot((uint)(barA - 1), (uint)a, typeB, idB); + module->SetAndSaveSlot((uint)(barB - 1), (uint)b, typeA, idA); + + // Read back to verify + slotPtrA = module->GetSlotById((uint)(barA - 1), (uint)a); + slotPtrB = module->GetSlotById((uint)(barB - 1), (uint)b); + if (slotPtrA != null && slotPtrB != null) + { + var afterA = slotPtrA->IsEmpty ? "empty" : $"{slotPtrA->CommandType} id={slotPtrA->CommandId}"; + var afterB = slotPtrB->IsEmpty ? "empty" : $"{slotPtrB->CommandType} id={slotPtrB->CommandId}"; + debugLog?.Invoke($"[SwapSlots] AFTER: A(bar{barA} slot{a}) {afterA} | B(bar{barB} slot{b}) {afterB}"); + } + return true; + } + + /// Dump HotbarSlot and RaptureMacroModule.Macro memory for a slot (for macro persistence debugging). + /// 1-10 + /// 0-11 + public unsafe void DumpMacroSlotMemoryToLog(int hotbarIndex, int slotIndex) + { + var hotbarModule = RaptureHotbarModule.Instance(); + if (hotbarModule == null || !hotbarModule->ModuleReady) + { + Plugin.Logger.Information("[HSUI Macro DBG] RaptureHotbarModule not ready"); + return; + } + int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1; + int slot = Math.Clamp(slotIndex, 0, 11); + var slotPtr = hotbarModule->GetSlotById((uint)barIdx, (uint)slot); + if (slotPtr == null) + { + Plugin.Logger.Information($"[HSUI Macro DBG] Bar {hotbarIndex} slot {slotIndex}: slot is null"); + return; + } + var ct = slotPtr->CommandType; + var cid = slotPtr->CommandId; + Plugin.Logger.Information($"[HSUI Macro DBG] Bar {hotbarIndex} slot {slotIndex}: CommandType={ct} CommandId={cid} ApparentActionId={slotPtr->ApparentActionId} ApparentSlotType={slotPtr->ApparentSlotType} IconId={slotPtr->IconId}"); + if (ct == RaptureHotbarModule.HotbarSlotType.Macro && cid is >= 1 and <= 200) + { + byte macroSet = (byte)((cid - 1) / 100); + byte macroIdx0Based = (byte)((cid - 1) % 100); + uint macroIdx1Based = (uint)(macroIdx0Based + 1); // GetMacro expects 1-based index + var macroModule = RaptureMacroModule.Instance(); + if (macroModule != null) + { + var macro = macroModule->GetMacro(macroSet, macroIdx1Based); + if (macro != null) + { + string name = macro->Name.ToString() ?? "(null)"; + Plugin.Logger.Information($"[HSUI Macro DBG] RaptureMacroModule.Macro set={macroSet} idx1Based={macroIdx1Based}: IconId={macro->IconId} MacroIconRowId={macro->MacroIconRowId} Name='{name}'"); + + // Raw byte dump (first 0x80 bytes: IconId, MacroIconRowId, start of Name/Utf8String) + var sb = new StringBuilder(); + var ptr = (byte*)macro; + for (int i = 0; i < 0x80 && i < 0x688; i += 16) + { + sb.Clear(); + sb.Append($"[HSUI Macro DBG] Macro+0x{i:X3}:"); + for (int j = 0; j < 16 && i + j < 0x80; j++) + sb.Append($" {(ptr[i + j]):X2}"); + Plugin.Logger.Information(sb.ToString()); + } + + // Try AddonMacro _macroName (offset 0x798, Utf8String=0x68 each, 100 entries) + try + { + var addonAddr = Plugin.GameGui?.GetAddonByName("Macro", 1).Address ?? IntPtr.Zero; + if (addonAddr != IntPtr.Zero) + { + var addon = (AddonMacro*)addonAddr; + int utf8Size = 0x68; + int nameBase = 0x798; + var namePtr = (Utf8String*)((byte*)addon + nameBase + macroIdx0Based * utf8Size); + string addonName = namePtr->ToString() ?? "(null)"; + Plugin.Logger.Information($"[HSUI Macro DBG] AddonMacro._macroName[{macroIdx0Based}]: '{addonName}'"); + } + else + Plugin.Logger.Information("[HSUI Macro DBG] AddonMacro not found or not open"); + } + catch (Exception ex) + { + Plugin.Logger.Information($"[HSUI Macro DBG] AddonMacro read failed: {ex.Message}"); + } + + // Try AgentMacro SelectedMacroSet/Index (only relevant when that macro is selected) + try + { + var agentModule = FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentModule.Instance(); + if (agentModule != null) + { + var macroAgent = agentModule->GetAgentByInternalId(FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentId.Macro); + if (macroAgent != null) + { + var agentMacro = (FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentMacro*)macroAgent; + Plugin.Logger.Information($"[HSUI Macro DBG] AgentMacro SelectedMacroSet={agentMacro->SelectedMacroSet} SelectedMacroIndex={agentMacro->SelectedMacroIndex}"); + if (agentMacro->SelectedMacroSet == macroSet && agentMacro->SelectedMacroIndex == macroIdx1Based) + { + string rawStr = agentMacro->RawMacroString.ToString() ?? "(null)"; + Plugin.Logger.Information($"[HSUI Macro DBG] AgentMacro (matches): RawMacroString(len={rawStr.Length})='{(rawStr.Length > 60 ? rawStr[..60] + "..." : rawStr)}'"); + } + } + else + Plugin.Logger.Information("[HSUI Macro DBG] AgentMacro agent is null"); + } + else + Plugin.Logger.Information("[HSUI Macro DBG] AgentModule.Instance() is null"); + } + catch (Exception ex) + { + Plugin.Logger.Information($"[HSUI Macro DBG] AgentMacro read failed: {ex.Message}"); + } + } + else + Plugin.Logger.Information($"[HSUI Macro DBG] RaptureMacroModule.GetMacro({macroSet},{macroIdx1Based}) returned null"); + } + else + Plugin.Logger.Information("[HSUI Macro DBG] RaptureMacroModule.Instance() is null"); + } + } + + /// Dump AddonMacro and AgentMacro state (open Macro menu first for best data). + public unsafe void DumpMacroMenuToLog() + { + Plugin.Logger.Information("[HSUI MacroMenu DBG] === Macro menu debug ==="); + + // AddonMacro + try + { + var addonAddr = Plugin.GameGui?.GetAddonByName("Macro", 1).Address ?? IntPtr.Zero; + if (addonAddr != IntPtr.Zero) + { + var addon = (AddonMacro*)addonAddr; + Plugin.Logger.Information($"[HSUI MacroMenu DBG] AddonMacro: SelectedPage={addon->SelectedPage} SelectedMacroIndex={addon->SelectedMacroIndex} DefaultIcon={addon->DefaultIcon}"); + const int utf8Size = 0x68; + const int nameBase = 0x798; + const int iconBase = 0x604; + const int createdBase = 0x3038; + for (int i = 0; i < 10; i++) + { + var namePtr = (Utf8String*)((byte*)addon + nameBase + i * utf8Size); + int icon = *(int*)((byte*)addon + iconBase + i * 4); + bool created = *((byte*)addon + createdBase + i) != 0; + string name = namePtr->ToString() ?? "(empty)"; + Plugin.Logger.Information($"[HSUI MacroMenu DBG] AddonMacro[{i}]: Name='{name}' Icon={icon} Created={created}"); + } + Plugin.Logger.Information("[HSUI MacroMenu DBG] ... (showing first 10 of 100)"); + } + else + Plugin.Logger.Information("[HSUI MacroMenu DBG] AddonMacro not found - open Macro menu (Character Config > Hotbars > Macros) first"); + } + catch (Exception ex) + { + Plugin.Logger.Information($"[HSUI MacroMenu DBG] AddonMacro failed: {ex.Message}"); + } + + // AgentMacro + try + { + var agentModule = FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentModule.Instance(); + if (agentModule != null) + { + var macroAgent = agentModule->GetAgentByInternalId(FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentId.Macro); + if (macroAgent != null) + { + var agent = (FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentMacro*)macroAgent; + Plugin.Logger.Information($"[HSUI MacroMenu DBG] AgentMacro: SelectedMacroSet={agent->SelectedMacroSet} (0=Individual,1=Shared) SelectedMacroIndex={agent->SelectedMacroIndex}"); + string raw = agent->RawMacroString.ToString() ?? "(null)"; + string parsed = agent->ParsedMacroString.ToString() ?? "(null)"; + Plugin.Logger.Information($"[HSUI MacroMenu DBG] RawMacroString(len={raw.Length}): '{(raw.Length > 80 ? raw[..80] + "..." : raw)}'"); + Plugin.Logger.Information($"[HSUI MacroMenu DBG] ParsedMacroString(len={parsed.Length}): '{(parsed.Length > 80 ? parsed[..80] + "..." : parsed)}'"); + Plugin.Logger.Information($"[HSUI MacroMenu DBG] MacroIconCount={agent->MacroIconCount}"); + + var clip = &agent->ClipboardMacro; + string clipName = clip->Name.ToString() ?? "(null)"; + Plugin.Logger.Information($"[HSUI MacroMenu DBG] ClipboardMacro: IconId={clip->IconId} MacroIconRowId={clip->MacroIconRowId} Name='{clipName}'"); + + var rm = RaptureMacroModule.Instance(); + if (rm != null) + { + var selectedMacro = rm->GetMacro(agent->SelectedMacroSet, agent->SelectedMacroIndex); + if (selectedMacro != null) + { + string rName = selectedMacro->Name.ToString() ?? "(null)"; + Plugin.Logger.Information($"[HSUI MacroMenu DBG] RaptureMacroModule.GetMacro({agent->SelectedMacroSet},{agent->SelectedMacroIndex}): IconId={selectedMacro->IconId} MacroIconRowId={selectedMacro->MacroIconRowId} Name='{rName}'"); + } + else + Plugin.Logger.Information($"[HSUI MacroMenu DBG] RaptureMacroModule.GetMacro returned null"); + } + } + else + Plugin.Logger.Information("[HSUI MacroMenu DBG] AgentMacro agent is null"); + } + else + Plugin.Logger.Information("[HSUI MacroMenu DBG] AgentModule.Instance() is null"); + } + catch (Exception ex) + { + Plugin.Logger.Information($"[HSUI MacroMenu DBG] AgentMacro failed: {ex.Message}"); + } + + Plugin.Logger.Information("[HSUI MacroMenu DBG] === end ==="); + } + + /// Dump all hotbar slot CommandType/CommandId to the log for SwapSlots diagnosis. + public unsafe void DumpSlotStateToLog() + { + var module = RaptureHotbarModule.Instance(); + if (module == null || !module->ModuleReady) { Plugin.Logger.Information("[HSUI HotbarSlots] Module not ready"); return; } + for (int bar = 1; bar <= 10; bar++) + { + var sb = new System.Text.StringBuilder(); + for (int s = 0; s < 12; s++) + { + var slot = module->GetSlotById((uint)(bar - 1), (uint)s); + if (slot == null) { sb.Append("? "); continue; } + if (slot->IsEmpty) { sb.Append("-- "); continue; } + sb.Append($"{slot->CommandType}:{slot->CommandId} "); + } + Plugin.Logger.Information($"[HSUI HotbarSlots] Bar {bar}: {sb}"); + } + } + + /// Clear a hotbar slot. hotbarIndex 1-10, slotIndex 0-based. + public unsafe bool ClearSlot(int hotbarIndex, int slotIndex) + { + var module = RaptureHotbarModule.Instance(); + if (module == null || !module->ModuleReady) return false; + int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1; + int slot = Math.Clamp(slotIndex, 0, 11); + var slotPtr = module->GetSlotById((uint)barIdx, (uint)slot); + if (slotPtr != null) + { + slotPtr->Set(RaptureHotbarModule.HotbarSlotType.Empty, 0); + slotPtr->LoadIconId(); + } + module->SetAndSaveSlot((uint)barIdx, (uint)slot, RaptureHotbarModule.HotbarSlotType.Empty, 0); + return true; + } + } +} diff --git a/Helpers/BarTexturesManager.cs b/Helpers/BarTexturesManager.cs new file mode 100644 index 0000000..c71db5d --- /dev/null +++ b/Helpers/BarTexturesManager.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using Dalamud.Interface; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Textures; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Logging; +using HSUI.Config; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; + +namespace HSUI.Helpers +{ + public struct BarTextureData + { + public string Name; + public string Path; + public bool IsCustom; + + public BarTextureData(string name, string path, bool isCustom) + { + Name = name; + Path = path; + IsCustom = isCustom; + } + } + + public class BarTexturesManager : IDisposable + { + #region Singleton + private BarTexturesManager(string basePath) + { + DefaultBarTexturesPath = Path.GetDirectoryName(basePath) + "\\Media\\Images\\textures\\"; + } + + public static void Initialize(string basePath) + { + Instance = new BarTexturesManager(basePath); + } + + public static BarTexturesManager Instance { get; private set; } = null!; + private BarTexturesConfig? _config; + + public void LoadConfig() + { + if (_config != null) + { + return; + } + + _config = ConfigurationManager.Instance.GetConfigObject(); + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + + ReloadTextures(); + } + + private void OnConfigReset(ConfigurationManager sender) + { + _config = sender.GetConfigObject(); + } + + ~BarTexturesManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + Instance = null!; + } + #endregion + + public readonly string DefaultBarTexturesPath; + public static readonly string DefaultBarTextureName = "Default"; + + public bool DefaultFontBuilt { get; private set; } + public ImFontPtr DefaultFont { get; private set; } = null; + + private List _textures = new List(); + public IReadOnlyCollection BarTextures => _textures.AsReadOnly(); + + private List _textureNames = new List(); + public IReadOnlyCollection BarTextureNames => _textureNames.AsReadOnly(); + + private Dictionary _cache = new(); + + public IDalamudTextureWrap? GetBarTexture(string? name) + { + if (name == null || name == DefaultBarTextureName) { return null; } + + // get cached texture + if (_cache.TryGetValue(name, out ISharedImmediateTexture? cachedTexture) && cachedTexture != null) + { + return cachedTexture.GetWrapOrDefault(); + } + + // lazy load + BarTextureData? data = _textures.FirstOrDefault(o => o.Name == name); + if (!data.HasValue) { return null; } + + if (File.Exists(data.Value.Path)) + { + try + { + ISharedImmediateTexture? texture = Plugin.TextureProvider.GetFromFile(data.Value.Path); + if (texture != null) + { + _cache.Add(name, texture); + } + + return texture?.GetWrapOrDefault(); + } + catch + (Exception ex) + { + Plugin.Logger.Warning($"Image failed to load. {data.Value.Path}: " + ex.Message); + } + } + + return null; + } + + public void ReloadTextures() + { + _textures.Clear(); + + // embedded textures + _textures.AddRange(TexturesFromPath(DefaultBarTexturesPath, true)); + + // custom textures + if (_config != null) + { + _textures.AddRange(TexturesFromPath(_config.ValidatedBarTexturesPath, true)); + } + + // sort by name + _textures = _textures.OrderBy(o => o.Name).ToList(); + + // default always first + _textures.Insert(0, new BarTextureData(DefaultBarTextureName, "", false)); + + _textureNames = _textures.Select(o => o.Name).ToList(); + } + + private List TexturesFromPath(string path, bool isCustom) + { + string[] textures; + try + { + string[] allowedExtensions = new string[] { ".png", ".tga" }; + textures = Directory + .GetFiles(path) + .Where(file => allowedExtensions.Any(file.ToLower().EndsWith)) + .ToArray(); + } + catch + { + textures = new string[0]; + } + + List result = new List(textures.Length); + + for (int i = 0; i < textures.Length; i++) + { + string name = SanitizedTextureName(textures[i].Replace(path, "")); + result.Add(new BarTextureData(name, textures[i], isCustom)); + } + + return result; + } + + private string SanitizedTextureName(string name) + { + return name + .Replace(".png", "") + .Replace(".PNG", "") + .Replace(".tga", "") + .Replace(".TGA", ""); + } + } +} diff --git a/Helpers/ClipRectsHelper.cs b/Helpers/ClipRectsHelper.cs new file mode 100644 index 0000000..1a012ba --- /dev/null +++ b/Helpers/ClipRectsHelper.cs @@ -0,0 +1,440 @@ +using HSUI.Config; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace HSUI.Helpers +{ + public class ClipRectsHelper + { + #region Singleton + private ClipRectsHelper() + { + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + OnConfigReset(ConfigurationManager.Instance); + + // other plugins can add clip rects for HSUI + // rect start point = vector.X, vector.Y + // rect end point = vector.Z, vector.W + _thirdPartyClipRects = Plugin.PluginInterface.GetOrCreateData>(_sharedDataId, () => new()); + } + + public static void Initialize() { Instance = new ClipRectsHelper(); } + + public static ClipRectsHelper Instance { get; private set; } = null!; + + ~ClipRectsHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + + Plugin.PluginInterface.RelinquishData(_sharedDataId); + + Instance = null!; + } + #endregion + + private WindowClippingConfig _config = null!; + + private void OnConfigReset(ConfigurationManager sender) + { + _config = sender.GetConfigObject(); + } + + public bool Enabled => _config.Enabled; + public WindowClippingMode? Mode => _config.Enabled ? _config.Mode : null; + + private List _clipRects = new List(); + private List _extraClipRects = new List(); + + private static Dictionary _thirdPartyClipRects = new(); + private static string _sharedDataId = "HSUI.ClipRects"; + + private static List _ignoredAddonNames = new List() + { + "_FocusTargetInfo", + }; + + private readonly string[] _hotbarAddonNames = { "_ActionBar", "_ActionBar01", "_ActionBar02", "_ActionBar03", "_ActionBar04", "_ActionBar05", "_ActionBar06", "_ActionBar07", "_ActionBar08", "_ActionBar09" }; + + public unsafe void Update() + { + if (!_config.Enabled) { return; } + + _clipRects.Clear(); + _extraClipRects.Clear(); + + // find clip rects for game windows + AtkStage* stage = AtkStage.Instance(); + if (stage == null) { return; } + + RaptureAtkUnitManager* manager = stage->RaptureAtkUnitManager; + if (manager == null) { return; } + + AtkUnitList* loadedUnitsList = &manager->AtkUnitManager.AllLoadedUnitsList; + if (loadedUnitsList == null) { return; } + + for (int i = 0; i < loadedUnitsList->Count; i++) + { + try + { + AtkUnitBase* addon = *(AtkUnitBase**)Unsafe.AsPointer(ref loadedUnitsList->Entries[i]); + if (addon == null || addon->RootNode == null || !addon->IsVisible || addon->WindowNode == null || addon->Scale == 0 || !addon->WindowNode->IsVisible()) + { + continue; + } + + string name = addon->NameString; + if (_ignoredAddonNames.Contains(name)) + { + continue; + } + + float margin = 5 * addon->Scale; + float bottomMargin = 13 * addon->Scale; + + Vector2 pos = new Vector2(addon->RootNode->X + margin, addon->RootNode->Y + margin); + Vector2 size = new Vector2( + addon->RootNode->Width * addon->Scale - margin, + addon->RootNode->Height * addon->Scale - bottomMargin + ); + + // just in case this causes weird issues / crashes (doubt it though...) + ClipRect clipRect = new ClipRect(pos, pos + size); + if (clipRect.Max.X < clipRect.Min.X || clipRect.Max.Y < clipRect.Min.Y) + { + continue; + } + + _clipRects.Add(clipRect); + } + catch { } + } + + if (_config.ThirdPartyClipRectsEnabled) + { + // find clip rects from other plugins + Dictionary dict = _thirdPartyClipRects; + foreach (Vector4 vector in dict.Values) + { + ClipRect clipRect = new ClipRect(new(vector.X, vector.Y), new(vector.Z, vector.W)); + _clipRects.Add(clipRect); + } + } + } + + private List ActiveClipRects() + { + return [.. _clipRects, .. _extraClipRects]; + } + + public void AddNameplatesClipRects() + { + if (!_config.NameplatesClipRectsEnabled) { return; } + + // target cast bar + ClipRect? targetCastbarClipRect = GetTargetCastbarClipRect(); + if (targetCastbarClipRect.HasValue) + { + _extraClipRects.Add(targetCastbarClipRect.Value); + } + + // hotbars + _extraClipRects.AddRange(GetHotbarsClipRects()); + + // chat bubbles + _extraClipRects.AddRange(GetNPCChatBubbleClipRect()); + _extraClipRects.AddRange(GetPlayerChatBubbleClipRect()); + } + + public void RemoveNameplatesClipRects() + { + _extraClipRects.Clear(); + } + + private unsafe ClipRect? GetTargetCastbarClipRect() + { + if (!_config.TargetCastbarClipRectEnabled) { return null; } + + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_TargetInfoCastBar", 1).Address; + if (addon == null || !addon->IsVisible) { return null; } + + AtkResNode* baseNode = addon->GetNodeById(2); + AtkImageNode* imageNode = addon->GetImageNodeById(7); + + if (baseNode == null || !baseNode->IsVisible()) { return null; } + if (imageNode == null || !imageNode->IsVisible()) { return null; } + + Vector2 pos = new Vector2( + addon->X + (baseNode->X * addon->Scale), + addon->Y + (baseNode->Y * addon->Scale) + ); + Vector2 size = new Vector2( + imageNode->Width * addon->Scale, + imageNode->Height * addon->Scale + ); + + return new ClipRect(pos, pos + size); + } + + private unsafe List GetHotbarsClipRects() + { + List rects = new List(); + if (!_config.HotbarsClipRectsEnabled) { return rects; } + + foreach (string addonName in _hotbarAddonNames) + { + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName(addonName, 1).Address; + if (addon == null || !addon->IsVisible) { continue; } + + AtkComponentNode* firstNode = addon->GetComponentNodeById(8); + AtkComponentNode* lastNode = addon->GetComponentNodeById(19); + + if (firstNode == null || lastNode == null) { continue; } + + + float margin = 10f * addon->Scale; + + Vector2 min = new Vector2( + addon->X + (firstNode->AtkResNode.X * addon->Scale) + margin, + addon->Y + (firstNode->AtkResNode.Y * addon->Scale) + margin + ); + Vector2 max = new Vector2( + addon->X + (lastNode->AtkResNode.X * addon->Scale) + (lastNode->AtkResNode.Width * addon->Scale) - margin, + addon->Y + (lastNode->AtkResNode.Y * addon->Scale) + (lastNode->AtkResNode.Height * addon->Scale) - margin + ); + + rects.Add(new ClipRect(min, max)); + } + + return rects; + } + + private unsafe List GetNPCChatBubbleClipRect() + { + List rects = new List(); + if (!_config.ChatBubblesNPCClipRectsEnabled) { return rects; } + + var addon = (AddonMiniTalk*) Plugin.GameGui.GetAddonByName("_MiniTalk").Address; + if (addon is null) + { + return rects; + } + + foreach (var talkBubble in addon->TalkBubbles) { + if (!talkBubble.ComponentNode->IsVisible()) + { + continue; + } + + AtkNineGridNode* bubbleNineGridNode = talkBubble.BubbleNineGridNode; + + Vector2 position = new Vector2( + bubbleNineGridNode->ScreenX, + bubbleNineGridNode->ScreenY + ); + Vector2 scale = GetNodeScale((AtkResNode*) bubbleNineGridNode, new Vector2(bubbleNineGridNode->ScaleX, bubbleNineGridNode->ScaleY)); + Vector2 size = new Vector2( + bubbleNineGridNode->Width, + bubbleNineGridNode->Height + ) * scale; + + rects.Add(new ClipRect(position, position + size)); + } + + return rects; + } + + public unsafe List GetPlayerChatBubbleClipRect() + { + List rects = new List(); + if (!_config.ChatBubblesPlayersClipRectsEnabled) { return rects; } + + AtkUnitBase* addon = (AtkUnitBase*) Plugin.GameGui.GetAddonByName("MiniTalkPlayer").Address; + if (addon is null) + { + return rects; + } + + foreach (var node in addon->UldManager.Nodes) { + if (node.Value is null) + { + continue; + } + + if (node.Value->GetNodeType() is not NodeType.Component || !node.Value->IsVisible()) + { + continue; + } + + AtkComponentNode* componentNode = (AtkComponentNode*)node.Value; + AtkComponentBase* component = componentNode->GetComponent(); + if (component is null) + { + continue; + } + + AtkResNode* bubbleNode = component->UldManager.SearchNodeById(4); + if (bubbleNode is null) + { + continue; + } + + Vector2 position = new Vector2( + componentNode->ScreenX, + componentNode->ScreenY + ); + Vector2 scale = GetNodeScale(bubbleNode, new Vector2(bubbleNode->ScaleX, bubbleNode->ScaleY)); + Vector2 size = new Vector2( + bubbleNode->Width, + bubbleNode->Height + ) * scale; + + rects.Add(new ClipRect(position, position + size)); + } + + return rects; + } + + public ClipRect? GetClipRectForArea(Vector2 pos, Vector2 size) + { + if (!_config.Enabled) { return null; } + + List rects = ActiveClipRects(); + + foreach (ClipRect clipRect in rects) + { + ClipRect area = new ClipRect(pos, pos + size); + if (clipRect.IntersectsWith(area)) + { + return clipRect; + } + } + + return null; + } + + public static ClipRect[] GetInvertedClipRects(ClipRect clipRect) + { + float maxX = ImGui.GetMainViewport().Size.X; + float maxY = ImGui.GetMainViewport().Size.Y; + + Vector2 aboveMin = new Vector2(0, 0); + Vector2 aboveMax = new Vector2(maxX, clipRect.Min.Y); + Vector2 leftMin = new Vector2(0, clipRect.Min.Y); + Vector2 leftMax = new Vector2(clipRect.Min.X, maxY); + + Vector2 rightMin = new Vector2(clipRect.Max.X, clipRect.Min.Y); + Vector2 rightMax = new Vector2(maxX, clipRect.Max.Y); + Vector2 belowMin = new Vector2(clipRect.Min.X, clipRect.Max.Y); + Vector2 belowMax = new Vector2(maxX, maxY); + + ClipRect[] invertedClipRects = new ClipRect[4]; + invertedClipRects[0] = new ClipRect(aboveMin, aboveMax); + invertedClipRects[1] = new ClipRect(leftMin, leftMax); + invertedClipRects[2] = new ClipRect(rightMin, rightMax); + invertedClipRects[3] = new ClipRect(belowMin, belowMax); + + return invertedClipRects; + } + + public bool IsPointClipped(Vector2 point) + { + if (!_config.Enabled) { return false; } + + List rects = ActiveClipRects(); + + foreach (ClipRect clipRect in rects) + { + if (clipRect.Contains(point)) + { + return true; + } + } + + return false; + } + + public static unsafe Vector2 GetNodeScale(AtkResNode* node, Vector2 currentScale) { + if (node is null) + { + return currentScale; + } + + if (node->ParentNode is not null) { + currentScale.X *= node->ParentNode->GetScaleX(); + currentScale.Y *= node->ParentNode->GetScaleY(); + + return GetNodeScale(node->ParentNode, currentScale); + } + + return currentScale; + } + } + + public struct ClipRect + { + public readonly Vector2 Min; + public readonly Vector2 Max; + + private readonly Rectangle Rectangle; + + public ClipRect(Vector2 min, Vector2 max) + { + Vector2 screenSize = ImGui.GetMainViewport().Size; + + Min = Clamp(min, Vector2.Zero, screenSize); + Max = Clamp(max, Vector2.Zero, screenSize); + + Vector2 size = Max - Min; + + Rectangle = new Rectangle((int)Min.X, (int)Min.Y, (int)size.X, (int)size.Y); + } + + public bool Contains(Vector2 point) + { + return Rectangle.Contains((int)point.X, (int)point.Y); + } + + public bool IntersectsWith(ClipRect other) + { + return Rectangle.IntersectsWith(other.Rectangle); + } + + public ClipRect? Intersect(ClipRect other) + { + float minX = Math.Max(Min.X, other.Min.X); + float minY = Math.Max(Min.Y, other.Min.Y); + float maxX = Math.Min(Max.X, other.Max.X); + float maxY = Math.Min(Max.Y, other.Max.Y); + if (minX >= maxX || minY >= maxY) return null; + return new ClipRect(new Vector2(minX, minY), new Vector2(maxX, maxY)); + } + + private static Vector2 Clamp(Vector2 vector, Vector2 min, Vector2 max) + { + return new Vector2(Math.Max(min.X, Math.Min(max.X, vector.X)), Math.Max(min.Y, Math.Min(max.Y, vector.Y))); + } + } +} diff --git a/Helpers/ColorUtils.cs b/Helpers/ColorUtils.cs new file mode 100644 index 0000000..182ac7b --- /dev/null +++ b/Helpers/ColorUtils.cs @@ -0,0 +1,312 @@ +using Colourful; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Interface.GeneralElements; +using System; +using System.Numerics; + +namespace HSUI.Helpers +{ + public static class ColorUtils + { + + //Build our converter objects and store them in a field. This will be used to convert our PluginConfigColors into different color spaces to be used for interpolation + private static readonly IColorConverter _rgbToLab = new ConverterBuilder().FromRGB().ToLab().Build(); + private static readonly IColorConverter _labToRgb = new ConverterBuilder().FromLab().ToRGB().Build(); + + private static readonly IColorConverter _rgbToLChab = new ConverterBuilder().FromRGB().ToLChab().Build(); + private static readonly IColorConverter _lchabToRgb = new ConverterBuilder().FromLChab().ToRGB().Build(); + + private static readonly IColorConverter _rgbToXyz = new ConverterBuilder().FromRGB(RGBWorkingSpaces.sRGB).ToXYZ(Illuminants.D65).Build(); + private static readonly IColorConverter _xyzToRgb = new ConverterBuilder().FromXYZ(Illuminants.D65).ToRGB(RGBWorkingSpaces.sRGB).Build(); + + private static readonly IColorConverter _rgbToLChuv = new ConverterBuilder().FromRGB().ToLChuv().Build(); + private static readonly IColorConverter _lchuvToRgb = new ConverterBuilder().FromLChuv().ToRGB().Build(); + + private static readonly IColorConverter _rgbToLuv = new ConverterBuilder().FromRGB().ToLuv().Build(); + private static readonly IColorConverter _luvToRgb = new ConverterBuilder().FromLuv().ToRGB().Build(); + + private static readonly IColorConverter _rgbToJzazbz = new ConverterBuilder().FromRGB().ToJzazbz().Build(); + private static readonly IColorConverter _jzazbzToRgb = new ConverterBuilder().FromJzazbz().ToRGB().Build(); + + private static readonly IColorConverter _rgbToJzCzhz = new ConverterBuilder().FromRGB().ToJzCzhz().Build(); + private static readonly IColorConverter _jzCzhzToRgb = new ConverterBuilder().FromJzCzhz().ToRGB().Build(); + + //Simple LinearInterpolation method. T = [0 , 1] + private static float LinearInterpolation(float left, float right, float t) + => left + ((right - left) * t); + + public static PluginConfigColor GetColorByScale(float i, ColorByHealthValueConfig config) => + GetColorByScale(i, config.LowHealthColorThreshold / 100f, config.FullHealthColorThreshold / 100f, config.LowHealthColor, config.FullHealthColor, config.MaxHealthColor, config.UseMaxHealthColor, config.BlendMode); + + //Method used to interpolate two PluginConfigColors + //i is scale [0 , 1] + //min and max are used for color thresholds. for instance return colorLeft if i < min or return ColorRight if i > max + public static PluginConfigColor GetColorByScale(float i, float min, float max, PluginConfigColor colorLeft, PluginConfigColor colorRight, PluginConfigColor colorMax, bool useMaxColor, BlendMode blendMode) + { + //Set our thresholds where the ratio is the range of values we will use for interpolation. + //Values outside this range will either return colorLeft or colorRight + float ratio = i; + if (min > 0 || max < 1) + { + if (i < min) + { + ratio = 0; + } + else if (i > max) + { + ratio = 1; + } + else + { + float range = max - min; + ratio = (i - min) / range; + } + } + + //Convert our PluginConfigColor to RGBColor + RGBColor rgbColorLeft = new RGBColor(colorLeft.Vector.X, colorLeft.Vector.Y, colorLeft.Vector.Z); + RGBColor rgbColorRight = new RGBColor(colorRight.Vector.X, colorRight.Vector.Y, colorRight.Vector.Z); + + //Interpolate our Alpha now + float alpha = LinearInterpolation(colorLeft.Vector.W, colorRight.Vector.W, ratio); + + if (ratio >= 1 && useMaxColor) + { + return new PluginConfigColor(new Vector4((float)colorMax.Vector.X, (float)colorMax.Vector.Y, (float)colorMax.Vector.Z, colorMax.Vector.W)); + } + + //Allow the users to select different blend modes since interpolating between two colors can result in different blending depending on the color space + //We convert our RGBColor values into different color spaces. We then interpolate each channel before converting the color back into RGBColor space + switch (blendMode) + { + case BlendMode.LAB: + { + //convert RGB to LAB + LabColor LabLeft = _rgbToLab.Convert(rgbColorLeft); + LabColor LabRight = _rgbToLab.Convert(rgbColorRight); + + RGBColor Lab2RGB = _labToRgb.Convert( + new LabColor( + LinearInterpolation((float)LabLeft.L, (float)LabRight.L, ratio), + LinearInterpolation((float)LabLeft.a, (float)LabRight.a, ratio), + LinearInterpolation((float)LabLeft.b, (float)LabRight.b, ratio) + ) + ); + + Lab2RGB.NormalizeIntensity(); + return new PluginConfigColor(new Vector4((float)Lab2RGB.R, (float)Lab2RGB.G, (float)Lab2RGB.B, alpha)); + } + + case BlendMode.LChab: + { + //convert RGB to LChab + LChabColor LChabLeft = _rgbToLChab.Convert(rgbColorLeft); + LChabColor LChabRight = _rgbToLChab.Convert(rgbColorRight); + + RGBColor LChab2RGB = _lchabToRgb.Convert( + new LChabColor( + LinearInterpolation((float)LChabLeft.L, (float)LChabRight.L, ratio), + LinearInterpolation((float)LChabLeft.C, (float)LChabRight.C, ratio), + LinearInterpolation((float)LChabLeft.h, (float)LChabRight.h, ratio) + ) + ); + + LChab2RGB.NormalizeIntensity(); + + return new PluginConfigColor(new Vector4((float)LChab2RGB.R, (float)LChab2RGB.G, (float)LChab2RGB.B, alpha)); + } + case BlendMode.XYZ: + { + //convert RGB to XYZ + XYZColor XYZLeft = _rgbToXyz.Convert(rgbColorLeft); + XYZColor XYZRight = _rgbToXyz.Convert(rgbColorRight); + + RGBColor XYZ2RGB = _xyzToRgb.Convert( + new XYZColor( + LinearInterpolation((float)XYZLeft.X, (float)XYZRight.X, ratio), + LinearInterpolation((float)XYZLeft.Y, (float)XYZRight.Y, ratio), + LinearInterpolation((float)XYZLeft.Z, (float)XYZRight.Z, ratio) + ) + ); + + XYZ2RGB.NormalizeIntensity(); + + return new PluginConfigColor(new Vector4((float)XYZ2RGB.R, (float)XYZ2RGB.G, (float)XYZ2RGB.B, alpha)); + } + case BlendMode.RGB: + { + //No conversion needed here because we are already working in RGB space + RGBColor newRGB = new RGBColor( + LinearInterpolation((float)rgbColorLeft.R, (float)rgbColorRight.R, ratio), + LinearInterpolation((float)rgbColorLeft.G, (float)rgbColorRight.G, ratio), + LinearInterpolation((float)rgbColorLeft.B, (float)rgbColorRight.B, ratio) + ); + + return new PluginConfigColor(new Vector4((float)newRGB.R, (float)newRGB.G, (float)newRGB.B, alpha)); + } + case BlendMode.LChuv: + { + //convert RGB to LChuv + LChuvColor LChuvLeft = _rgbToLChuv.Convert(rgbColorLeft); + LChuvColor LChuvRight = _rgbToLChuv.Convert(rgbColorRight); + + RGBColor LChuv2RGB = _lchuvToRgb.Convert( + new LChuvColor( + LinearInterpolation((float)LChuvLeft.L, (float)LChuvRight.L, ratio), + LinearInterpolation((float)LChuvLeft.C, (float)LChuvRight.C, ratio), + LinearInterpolation((float)LChuvLeft.h, (float)LChuvRight.h, ratio) + ) + ); + + LChuv2RGB.NormalizeIntensity(); + + return new PluginConfigColor(new Vector4((float)LChuv2RGB.R, (float)LChuv2RGB.G, (float)LChuv2RGB.B, alpha)); + } + + case BlendMode.Luv: + { + //convert RGB to Luv + LuvColor LuvLeft = _rgbToLuv.Convert(rgbColorLeft); + LuvColor LuvRight = _rgbToLuv.Convert(rgbColorRight); + + RGBColor Luv2RGB = _luvToRgb.Convert( + new LuvColor( + LinearInterpolation((float)LuvLeft.L, (float)LuvRight.L, ratio), + LinearInterpolation((float)LuvLeft.u, (float)LuvRight.u, ratio), + LinearInterpolation((float)LuvLeft.v, (float)LuvRight.v, ratio) + ) + ); + + Luv2RGB.NormalizeIntensity(); + + return new PluginConfigColor(new Vector4((float)Luv2RGB.R, (float)Luv2RGB.G, (float)Luv2RGB.B, alpha)); + + } + case BlendMode.Jzazbz: + { + //convert RGB to Jzazbz + JzazbzColor JzazbzLeft = _rgbToJzazbz.Convert(rgbColorLeft); + JzazbzColor JzazbzRight = _rgbToJzazbz.Convert(rgbColorRight); + + RGBColor Jzazbz2RGB = _jzazbzToRgb.Convert( + new JzazbzColor( + LinearInterpolation((float)JzazbzLeft.Jz, (float)JzazbzRight.Jz, ratio), + LinearInterpolation((float)JzazbzLeft.az, (float)JzazbzRight.az, ratio), + LinearInterpolation((float)JzazbzLeft.bz, (float)JzazbzRight.bz, ratio) + ) + ); + + Jzazbz2RGB.NormalizeIntensity(); + + return new PluginConfigColor(new Vector4((float)Jzazbz2RGB.R, (float)Jzazbz2RGB.G, (float)Jzazbz2RGB.B, alpha)); + } + case BlendMode.JzCzhz: + { + //convert RGB to JzCzhz + JzCzhzColor JzCzhzLeft = _rgbToJzCzhz.Convert(rgbColorLeft); + JzCzhzColor JzCzhzRight = _rgbToJzCzhz.Convert(rgbColorRight); + + RGBColor JzCzhz2RGB = _jzCzhzToRgb.Convert( + new JzCzhzColor( + LinearInterpolation((float)JzCzhzLeft.Jz, (float)JzCzhzRight.Jz, ratio), + LinearInterpolation((float)JzCzhzLeft.Cz, (float)JzCzhzRight.Cz, ratio), + LinearInterpolation((float)JzCzhzLeft.hz, (float)JzCzhzRight.hz, ratio) + ) + ); + + JzCzhz2RGB.NormalizeIntensity(); + + return new PluginConfigColor(new Vector4((float)JzCzhz2RGB.R, (float)JzCzhz2RGB.G, (float)JzCzhz2RGB.B, alpha)); + } + } + return new(Vector4.One); + } + + public static PluginConfigColor ColorForActor(IGameObject? actor) + { + if (actor == null || actor is not ICharacter character) + { + return GlobalColors.Instance.NPCNeutralColor; + } + + if (character.ObjectKind == ObjectKind.Player || + character.SubKind == 9 && character.ClassJob.RowId > 0) + { + return GlobalColors.Instance.SafeColorForJobId(character.ClassJob.RowId); + } + + bool isHostile = Utils.IsHostile(character); + + if (character is IBattleNpc npc) + { + if ((npc.BattleNpcKind == BattleNpcSubKind.Enemy || npc.BattleNpcKind == BattleNpcSubKind.BattleNpcPart) && isHostile) + { + return GlobalColors.Instance.NPCHostileColor; + } + else + { + return GlobalColors.Instance.NPCFriendlyColor; + } + } + + return isHostile ? GlobalColors.Instance.NPCNeutralColor : GlobalColors.Instance.NPCFriendlyColor; + } + + public static PluginConfigColor? ColorForCharacter( + IGameObject? gameObject, + uint currentHp = 0, + uint maxHp = 0, + bool useJobColor = false, + bool useRoleColor = false, + ColorByHealthValueConfig? colorByHealthConfig = null) + { + ICharacter? character = gameObject as ICharacter; + + if (useJobColor && character != null) + { + return ColorForActor(character); + } + else if (useRoleColor) + { + return character is IPlayerCharacter ? + GlobalColors.Instance.SafeRoleColorForJobId(character.ClassJob.RowId) : + ColorForActor(character); + } + else if (colorByHealthConfig != null && colorByHealthConfig.Enabled && character != null) + { + var scale = (float)currentHp / Math.Max(1, maxHp); + if (colorByHealthConfig.UseJobColorAsMaxHealth) + { + return GetColorByScale( + scale, + colorByHealthConfig.LowHealthColorThreshold / 100f, + colorByHealthConfig.FullHealthColorThreshold / 100f, + colorByHealthConfig.LowHealthColor, + colorByHealthConfig.FullHealthColor, + ColorForActor(character), + colorByHealthConfig.UseMaxHealthColor, + colorByHealthConfig.BlendMode + ); + } + else if (colorByHealthConfig.UseRoleColorAsMaxHealth) + { + return GetColorByScale(scale, + colorByHealthConfig.LowHealthColorThreshold / 100f, + colorByHealthConfig.FullHealthColorThreshold / 100f, + colorByHealthConfig.LowHealthColor, colorByHealthConfig.FullHealthColor, + character is IPlayerCharacter ? GlobalColors.Instance.SafeRoleColorForJobId(character.ClassJob.RowId) : ColorForActor(character), + colorByHealthConfig.UseMaxHealthColor, + colorByHealthConfig.BlendMode + ); + } + return GetColorByScale(scale, colorByHealthConfig); + } + + return null; + } + } +} diff --git a/Helpers/DraggablesHelper.cs b/Helpers/DraggablesHelper.cs new file mode 100644 index 0000000..e91cd5e --- /dev/null +++ b/Helpers/DraggablesHelper.cs @@ -0,0 +1,257 @@ +using HSUI.Config; +using HSUI.Enums; +using HSUI.Interface; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.Jobs; +using HSUI.Interface.StatusEffects; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Helpers +{ + public static class DraggablesHelper + { + public static void DrawGrid(GridConfig config, HUDOptionsConfig? hudConfig, DraggableHudElement? selectedElement) + { + ImGui.SetNextWindowPos(Vector2.Zero); + ImGui.SetNextWindowSize(ImGui.GetMainViewport().Size); + + ImGui.SetNextWindowBgAlpha(config.BackgroundAlpha); + + ImGui.Begin("DelvUI_grid", + ImGuiWindowFlags.NoTitleBar + | ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.AlwaysAutoResize + | ImGuiWindowFlags.NoInputs + | ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoBringToFrontOnFocus + | ImGuiWindowFlags.NoFocusOnAppearing + ); + + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 screenSize = ImGui.GetMainViewport().Size; + Vector2 offset = hudConfig != null && hudConfig.UseGlobalHudShift ? hudConfig.HudOffset : Vector2.Zero; + Vector2 center = screenSize / 2f + offset; + + // grid + if (config.ShowGrid) + { + int count = (int)(Math.Max(screenSize.X, screenSize.Y) / config.GridDivisionsDistance) / 2 + 1; + + for (int i = 0; i < count; i++) + { + var step = i * config.GridDivisionsDistance; + + drawList.AddLine(new Vector2(center.X + step, 0), new Vector2(center.X + step, screenSize.Y), 0x88888888); + drawList.AddLine(new Vector2(center.X - step, 0), new Vector2(center.X - step, screenSize.Y), 0x88888888); + + drawList.AddLine(new Vector2(0, center.Y + step), new Vector2(screenSize.X, center.Y + step), 0x88888888); + drawList.AddLine(new Vector2(0, center.Y - step), new Vector2(screenSize.X, center.Y - step), 0x88888888); + + if (config.GridSubdivisionCount > 1) + { + for (int j = 1; j < config.GridSubdivisionCount; j++) + { + var subStep = j * (config.GridDivisionsDistance / config.GridSubdivisionCount); + + drawList.AddLine(new Vector2(center.X + step + subStep, 0), new Vector2(center.X + step + subStep, screenSize.Y), 0x44888888); + drawList.AddLine(new Vector2(center.X - step - subStep, 0), new Vector2(center.X - step - subStep, screenSize.Y), 0x44888888); + + drawList.AddLine(new Vector2(0, center.Y + step + subStep), new Vector2(screenSize.X, center.Y + step + subStep), 0x44888888); + drawList.AddLine(new Vector2(0, center.Y - step - subStep), new Vector2(screenSize.X, center.Y - step - subStep), 0x44888888); + } + } + } + } + + // center lines + if (config.ShowCenterLines) + { + drawList.AddLine(new Vector2(center.X, 0), new Vector2(center.X, screenSize.Y), 0xAAFFFFFF); + drawList.AddLine(new Vector2(0, center.Y), new Vector2(screenSize.X, center.Y), 0xAAFFFFFF); + } + + if (config.ShowAnchorPoints && selectedElement != null) + { + Vector2 parentAnchorPos = center + selectedElement.ParentPos(); + Vector2 anchorPos = parentAnchorPos + selectedElement.GetConfig().Position; + + drawList.AddLine(parentAnchorPos, anchorPos, 0xAA0000FF, 2); + + var anchorSize = new Vector2(10, 10); + drawList.AddRectFilled(anchorPos - anchorSize / 2f, anchorPos + anchorSize / 2f, 0xAA0000FF); + } + + ImGui.End(); + } + + public static void DrawElements( + Vector2 origin, + HudHelper hudHelper, + IList elements, + JobHud? jobHud, + DraggableHudElement? selectedElement) + { + foreach (DraggableHudElement element in elements) + { + if (!hudHelper.IsElementHidden(element)) + { + element.PrepareForDraw(origin); + } + } + + jobHud?.PrepareForDraw(origin); + + bool clip = ConfigurationManager.Instance?.LockHUD == true && + ClipRectsHelper.Instance?.Enabled == true && + ClipRectsHelper.Instance?.Mode == WindowClippingMode.Performance; + + bool needsDraw = true; + + if (clip) + { + ClipRect? clipRect = ClipRectsHelper.Instance?.GetClipRectForArea(Vector2.Zero, ImGui.GetMainViewport().Size); + if (clipRect.HasValue) + { + needsDraw = false; + + ClipRect[] invertedClipRects = ClipRectsHelper.GetInvertedClipRects(clipRect.Value); + for (int i = 0; i < invertedClipRects.Length; i++) + { + ImGui.PushClipRect(invertedClipRects[i].Min, invertedClipRects[i].Max, false); + Draw(origin, hudHelper, elements, jobHud, selectedElement); + ImGui.PopClipRect(); + } + } + } + + if (needsDraw) + { + Draw(origin, hudHelper, elements, jobHud, selectedElement); + } + } + + private static void Draw( + Vector2 origin, + HudHelper hudHelper, + IList elements, + JobHud? jobHud, + DraggableHudElement? selectedElement) + { + bool canTakeInput = true; + bool jobHudNeedsDraw = jobHud != null && jobHud != selectedElement && !hudHelper.IsElementHidden(jobHud); + + // selected + if (selectedElement != null) + { + if (!hudHelper.IsElementHidden(selectedElement)) + { + selectedElement.CanTakeInputForDrag = true; + selectedElement.Draw(origin); + canTakeInput = !selectedElement.NeedsInputForDrag; + } + else if (selectedElement is IHudElementWithMouseOver elementWithMouseOver) + { + elementWithMouseOver.StopMouseover(); + } + } + + // all + foreach (DraggableHudElement element in elements) + { + if (element == selectedElement) { continue; } + + if (jobHudNeedsDraw && jobHud != null && element.GetConfig().StrataLevel > jobHud.GetConfig().StrataLevel) + { + jobHud.CanTakeInputForDrag = canTakeInput; + jobHud.Draw(origin); + jobHudNeedsDraw = false; + } + + if (!hudHelper.IsElementHidden(element)) + { + element.CanTakeInputForDrag = canTakeInput; + element.Draw(origin); + canTakeInput = !canTakeInput ? false : !element.NeedsInputForDrag; + } + else if (element is IHudElementWithMouseOver elementWithMouseOver) + { + elementWithMouseOver.StopMouseover(); + } + } + } + + public static bool DrawArrows(Vector2 position, Vector2 size, string tooltipText, out Vector2 offset) + { + offset = Vector2.Zero; + + var windowFlags = ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoTitleBar + | ImGuiWindowFlags.NoResize + | ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoSavedSettings; + + var margin = new Vector2(4, 0); + var windowSize = ArrowSize + margin * 2; + + // left, right, up, down + var positions = GetArrowPositions(position, size); + var offsets = new Vector2[] + { + new Vector2(-1, 0), + new Vector2(1, 0), + new Vector2(0, -1), + new Vector2(0, 1) + }; + + for (int i = 0; i < 4; i++) + { + var pos = positions[i] - margin; + + ImGui.SetNextWindowSize(windowSize, ImGuiCond.Always); + ImGui.SetNextWindowPos(pos); + + ImGui.Begin("DelvUI_draggablesArrow " + i.ToString(), windowFlags); + + // fake button + ImGuiP.ArrowButtonEx($"arrow button {i}", (ImGuiDir)i, new Vector2(ArrowSize.X, ArrowSize.Y)); + if (ImGui.IsMouseHoveringRect(pos, pos + windowSize)) + { + // track click manually to not deal with window focus stuff + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + offset = offsets[i]; + } + + // tooltip + TooltipsHelper.Instance.ShowTooltipOnCursor(tooltipText); + } + + ImGui.End(); + } + + return offset != Vector2.Zero; + } + + public static Vector2 ArrowSize = new Vector2(40, 40); + + public static Vector2[] GetArrowPositions(Vector2 position, Vector2 size) + { + return GetArrowPositions(position, size, ArrowSize); + } + + public static Vector2[] GetArrowPositions(Vector2 position, Vector2 size, Vector2 arrowSize) + { + return new Vector2[] + { + new Vector2(position.X - arrowSize.X + 10, position.Y + size.Y / 2f - arrowSize.Y / 2f - 2), + new Vector2(position.X + size.X - 8, position.Y + size.Y / 2f - arrowSize.Y / 2f - 2), + new Vector2(position.X + size.X / 2f - arrowSize.X / 2f + 2, position.Y - arrowSize.Y + 1), + new Vector2(position.X + size.X / 2f - arrowSize.X / 2f + 2, position.Y + size.Y - 7) + }; + } + } +} diff --git a/Helpers/DrawHelper.cs b/Helpers/DrawHelper.cs new file mode 100644 index 0000000..f06ea5f --- /dev/null +++ b/Helpers/DrawHelper.cs @@ -0,0 +1,384 @@ +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Interface.Utility; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using Lumina.Excel; +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace HSUI.Helpers +{ + public enum GradientDirection + { + None, + Right, + Left, + Up, + Down, + CenteredHorizonal + } + + public static class DrawHelper + { + private static uint[] ColorArray(PluginConfigColor color, GradientDirection gradientDirection) + { + return gradientDirection switch + { + GradientDirection.None => new[] { color.Base, color.Base, color.Base, color.Base }, + GradientDirection.Right => new[] { color.TopGradient, color.BottomGradient, color.BottomGradient, color.TopGradient }, + GradientDirection.Left => new[] { color.BottomGradient, color.TopGradient, color.TopGradient, color.BottomGradient }, + GradientDirection.Up => new[] { color.BottomGradient, color.BottomGradient, color.TopGradient, color.TopGradient }, + _ => new[] { color.TopGradient, color.TopGradient, color.BottomGradient, color.BottomGradient } + }; + } + + private static Vector2 GetBarTextureUV1Vector(Vector2 size, int textureWidth, int textureHeight, BarTextureDrawMode drawMode) + { + if (drawMode == BarTextureDrawMode.Stretch) { return new Vector2(1); } + + float x = drawMode == BarTextureDrawMode.RepeatVertical ? 1 : (float)size.X / textureWidth; + float y = drawMode == BarTextureDrawMode.RepeatHorizontal ? 1 : (float)size.Y / textureHeight; + + return new Vector2(x, y); + } + + public static void DrawBarTexture(Vector2 position, Vector2 size, PluginConfigColor color, string? name, BarTextureDrawMode drawMode, ImDrawListPtr drawList) + { + IDalamudTextureWrap? texture = BarTexturesManager.Instance?.GetBarTexture(name); + if (texture == null) + { + DrawGradientFilledRect(position, size, color, drawList); + return; + } + + Vector2 uv0 = new Vector2(0); + Vector2 uv1 = GetBarTextureUV1Vector(size, texture.Width, texture.Height, drawMode); + + drawList.AddImage(texture.Handle, position, position + size, uv0, uv1, color.Base); + } + + public static void DrawGradientFilledRect(Vector2 position, Vector2 size, PluginConfigColor color, ImDrawListPtr drawList) + { + GradientDirection gradientDirection = ConfigurationManager.Instance.GradientDirection; + DrawGradientFilledRect(position, size, color, drawList, gradientDirection); + } + + public static void DrawGradientFilledRect(Vector2 position, Vector2 size, PluginConfigColor color, ImDrawListPtr drawList, GradientDirection gradientDirection = GradientDirection.Down) + { + uint[]? colorArray = ColorArray(color, gradientDirection); + + if (gradientDirection == GradientDirection.CenteredHorizonal) + { + Vector2 halfSize = new(size.X, size.Y / 2f); + drawList.AddRectFilledMultiColor( + position, position + halfSize, + colorArray[0], colorArray[1], colorArray[2], colorArray[3] + ); + + Vector2 pos = position + new Vector2(0, halfSize.Y); + drawList.AddRectFilledMultiColor( + pos, pos + halfSize, + colorArray[3], colorArray[2], colorArray[1], colorArray[0] + ); + } + else + { + drawList.AddRectFilledMultiColor( + position, position + size, + colorArray[0], colorArray[1], colorArray[2], colorArray[3] + ); + } + } + + public static void DrawOutlinedText(string text, Vector2 pos, ImDrawListPtr drawList, int thickness = 1) + { + DrawOutlinedText(text, pos, 0xFFFFFFFF, 0xFF000000, drawList, thickness); + } + + public static void DrawOutlinedText(string text, Vector2 pos, uint color, uint outlineColor, ImDrawListPtr drawList, int thickness = 1) + { + // outline + for (int i = 1; i < thickness + 1; i++) + { + drawList.AddText(new Vector2(pos.X - i, pos.Y + i), outlineColor, text); + drawList.AddText(new Vector2(pos.X, pos.Y + i), outlineColor, text); + drawList.AddText(new Vector2(pos.X + i, pos.Y + i), outlineColor, text); + drawList.AddText(new Vector2(pos.X - i, pos.Y), outlineColor, text); + drawList.AddText(new Vector2(pos.X + i, pos.Y), outlineColor, text); + drawList.AddText(new Vector2(pos.X - i, pos.Y - i), outlineColor, text); + drawList.AddText(new Vector2(pos.X, pos.Y - i), outlineColor, text); + drawList.AddText(new Vector2(pos.X + i, pos.Y - i), outlineColor, text); + } + + // text + drawList.AddText(new Vector2(pos.X, pos.Y), color, text); + } + + public static void DrawShadowText(string text, Vector2 pos, uint color, uint shadowColor, ImDrawListPtr drawList, int offset = 1, int thickness = 1) + { + // TODO: Add parameter to allow to choose a direction + + // Shadow + for (int i = 0; i < thickness; i++) + { + drawList.AddText(new Vector2(pos.X + i + offset, pos.Y + i + offset), shadowColor, text); + } + + // Text + drawList.AddText(new Vector2(pos.X, pos.Y), color, text); + } + + public static void DrawIcon(dynamic row, Vector2 position, Vector2 size, bool drawBorder, bool cropIcon, int stackCount = 1) where T : struct, IExcelRow + { + IDalamudTextureWrap texture = TexturesHelper.GetTexture(row, (uint)Math.Max(0, stackCount - 1)); + if (texture == null) { return; } + + (Vector2 uv0, Vector2 uv1) = GetTexCoordinates(texture, size, cropIcon); + + ImGui.SetCursorPos(position); + ImGui.Image(texture.Handle, size, uv0, uv1); + + if (drawBorder) + { + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + drawList.AddRect(position, position + size, 0xFF000000); + } + } + + public static void DrawIcon(ImDrawListPtr drawList, dynamic row, Vector2 position, Vector2 size, bool drawBorder, bool cropIcon, int stackCount = 1) where T : struct, IExcelRow + { + IDalamudTextureWrap texture = TexturesHelper.GetTexture(row, (uint)Math.Max(0, stackCount - 1)); + if (texture == null) { return; } + + (Vector2 uv0, Vector2 uv1) = GetTexCoordinates(texture, size, cropIcon); + + drawList.AddImage(texture.Handle, position, position + size, uv0, uv1); + + if (drawBorder) + { + drawList.AddRect(position, position + size, 0xFF000000); + } + } + + public static void DrawIcon(uint iconId, Vector2 position, Vector2 size, bool drawBorder, ImDrawListPtr drawList) + { + DrawIcon(iconId, position, size, drawBorder, 0xFFFFFFFF, drawList); + } + + public static void DrawIcon(uint iconId, Vector2 position, Vector2 size, bool drawBorder, float alpha, ImDrawListPtr drawList) + { + uint a = (uint)(alpha * 255); + uint color = 0xFFFFFF + (a << 24); + DrawIcon(iconId, position, size, drawBorder, color, drawList); + } + + + public static void DrawIcon(uint iconId, Vector2 position, Vector2 size, bool drawBorder, uint color, ImDrawListPtr drawList) + { + IDalamudTextureWrap? texture = TexturesHelper.GetTextureFromIconId(iconId); + if (texture == null) { return; } + + drawList.AddImage(texture.Handle, position, position + size, Vector2.Zero, Vector2.One, color); + + if (drawBorder) + { + drawList.AddRect(position, position + size, 0xFF000000); + } + } + + public static (Vector2, Vector2) GetTexCoordinates(IDalamudTextureWrap texture, Vector2 size, bool cropIcon = true) + { + if (texture == null) + { + return (Vector2.Zero, Vector2.Zero); + } + + // Status = 24x32, show from 2,7 until 22,26 + //show from 0,0 until 24,32 for uncropped status icon + + float uv0x = cropIcon ? 4f : 1f; + float uv0y = cropIcon ? 14f : 1f; + + float uv1x = cropIcon ? 4f : 1f; + float uv1y = cropIcon ? 12f : 1f; + + Vector2 uv0 = new(uv0x / texture.Width, uv0y / texture.Height); + Vector2 uv1 = new(1f - uv1x / texture.Width, 1f - uv1y / texture.Height); + + return (uv0, uv1); + } + + public static void DrawIconCooldown(Vector2 position, Vector2 size, float elapsed, float total, ImDrawListPtr drawList) + { + float completion = elapsed / total; + int segments = (int)Math.Ceiling(completion * 4); + + Vector2 center = position + size / 2; + + //Define vertices for top, left, bottom, and right points relative to the center. + Vector2[] vertices = + [ + center with {Y = center.Y - size.Y}, // Top + center with {X = center.X - size.X}, // Left + center with {Y = center.Y + size.Y}, // Bottom + center with {X = center.X + size.X} // Right + ]; + + ImGui.PushClipRect(position, position + size, false); + for (int i = 0; i < segments; i++) + { + Vector2 v2 = vertices[i % 4]; + Vector2 v3 = vertices[(i + 1) % 4]; + + + if (i == segments - 1) + { // If drawing the last segment, adjust the second vertex based on the cooldown. + float angle = 2 * MathF.PI * (1 - completion); + float cos = MathF.Cos(angle); + float sin = MathF.Sin(angle); + + v3 = center + Vector2.Multiply(new Vector2(sin, -cos), size); + } + + drawList.AddTriangleFilled(center, v3, v2, 0xCC000000); + } + ImGui.PopClipRect(); + } + + public static void DrawOvershield(float shield, Vector2 cursorPos, Vector2 barSize, float height, bool useRatioForHeight, PluginConfigColor color, ImDrawListPtr drawList) + { + if (shield == 0) { return; } + + float h = useRatioForHeight ? barSize.Y / 100 * height : height; + + DrawGradientFilledRect(cursorPos, new Vector2(Math.Max(1, barSize.X * shield), h), color, drawList); + } + + public static void DrawShield(float shield, float hp, Vector2 cursorPos, Vector2 barSize, float height, bool useRatioForHeight, PluginConfigColor color, ImDrawListPtr drawList) + { + if (shield == 0) { return; } + + // on full hp just draw overshield + if (hp == 1) + { + DrawOvershield(shield, cursorPos, barSize, height, useRatioForHeight, color, drawList); + return; + } + + // hp portion + float h = useRatioForHeight ? barSize.Y / 100 * Math.Min(100, height) : height; + float missingHPRatio = 1 - hp; + float s = Math.Min(shield, missingHPRatio); + Vector2 shieldStartPos = cursorPos + new Vector2(Math.Max(1, barSize.X * hp), 0); + DrawGradientFilledRect(shieldStartPos, new Vector2(Math.Max(1, barSize.X * s), barSize.Y), color, drawList); + + // overshield + shield -= s; + if (shield <= 0) { return; } + + DrawGradientFilledRect(cursorPos, new Vector2(Math.Max(1, barSize.X * shield), h), color, drawList); + } + + public static void DrawInWindow(string name, Vector2 pos, Vector2 size, bool needsInput, Action drawAction) + { + const ImGuiWindowFlags windowFlags = ImGuiWindowFlags.NoTitleBar | + ImGuiWindowFlags.NoScrollbar | + ImGuiWindowFlags.NoBackground | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoResize; + + bool inputs = InputsHelper.Instance?.IsProxyEnabled == true ? false : needsInput; + + DrawInWindow(name, pos, size, inputs, false, windowFlags, drawAction); + } + + public static void DrawInWindow( + string name, + Vector2 pos, + Vector2 size, + bool needsInput, + bool needsWindow, + ImGuiWindowFlags windowFlags, + Action drawAction) + { + + if (!ClipRectsHelper.Instance.Enabled || ClipRectsHelper.Instance.Mode == WindowClippingMode.Performance) + { + drawAction(ImGui.GetWindowDrawList()); + return; + } + + windowFlags |= ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus; + + if (!needsInput) + { + windowFlags |= ImGuiWindowFlags.NoInputs; + } + + ClipRect? clipRect = ClipRectsHelper.Instance.GetClipRectForArea(pos, size); + + // no clipping needed + if (!ClipRectsHelper.Instance.Enabled || !clipRect.HasValue) + { + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + + if (!needsInput && !needsWindow) + { + drawAction(drawList); + return; + } + + ImGui.SetNextWindowPos(pos); + ImGui.SetNextWindowSize(size); + + bool begin = ImGui.Begin(name, windowFlags); + if (!begin) + { + ImGui.End(); + return; + } + + drawAction(drawList); + + ImGui.End(); + } + + // clip around game's window + else + { + // hide instead of clip? + if (ClipRectsHelper.Instance.Mode == WindowClippingMode.Hide) { return; } + + ImGuiWindowFlags flags = windowFlags; + if (needsInput && clipRect.Value.Contains(ImGui.GetMousePos())) + { + flags |= ImGuiWindowFlags.NoInputs; + } + + ClipRect[] invertedClipRects = ClipRectsHelper.GetInvertedClipRects(clipRect.Value); + for (int i = 0; i < invertedClipRects.Length; i++) + { + ImGui.SetNextWindowPos(pos); + ImGui.SetNextWindowSize(size); + ImGuiHelpers.ForceNextWindowMainViewport(); + + bool begin = ImGui.Begin(name + "_" + i, flags); + if (!begin) + { + ImGui.End(); + continue; + } + + ImGui.PushClipRect(invertedClipRects[i].Min, invertedClipRects[i].Max, false); + drawAction(ImGui.GetWindowDrawList()); + ImGui.PopClipRect(); + + ImGui.End(); + } + } + } + } +} diff --git a/Helpers/EncryptedStringsHelper.cs b/Helpers/EncryptedStringsHelper.cs new file mode 100644 index 0000000..550caab --- /dev/null +++ b/Helpers/EncryptedStringsHelper.cs @@ -0,0 +1,43 @@ +using FFXIVClientStructs.FFXIV.Client.LayoutEngine; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.Interop; +using FFXIVClientStructs.STD; +using System; +using System.Runtime.InteropServices; + +namespace HSUI.Helpers +{ + public static class EncryptedStringsHelper + { + public static unsafe string GetString(string original) + { + if (!original.StartsWith("_rsv_")) + { + return original; + } + + try + { + TempLayoutWorld* layoutWorld = (TempLayoutWorld*)LayoutWorld.Instance(); + StdMap> map = layoutWorld->RsvMap[0]; + Pointer demangled = map[new Utf8String(original)]; + if (demangled.Value != null && Marshal.PtrToStringUTF8((IntPtr)demangled.Value) is { } result) + { + return result; + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error reading rsv map:\n" + e.StackTrace); + } + + return original; + } + } + + [StructLayout(LayoutKind.Explicit, Size = 0x230)] + public unsafe struct TempLayoutWorld + { + [FieldOffset(0x220)] public StdMap>* RsvMap; + } +} diff --git a/Helpers/ExperienceHelper.cs b/Helpers/ExperienceHelper.cs new file mode 100644 index 0000000..08816b9 --- /dev/null +++ b/Helpers/ExperienceHelper.cs @@ -0,0 +1,115 @@ +using Dalamud.Logging; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System; +using System.Runtime.InteropServices; +using StructsFramework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework; + +namespace HSUI.Helpers +{ + public unsafe class ExperienceHelper + { + #region singleton + private static Lazy _lazyInstance = new Lazy(() => new ExperienceHelper()); + private RaptureAtkModule* _raptureAtkModule = null; + private const int ExperienceIndex = 2; + + public static ExperienceHelper Instance => _lazyInstance.Value; + + ~ExperienceHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _lazyInstance = new Lazy(() => new ExperienceHelper()); + } + #endregion + + public ExperienceHelper() + { + } + + public AddonExp* GetExpAddon() + { + return (AddonExp*)Plugin.GameGui.GetAddonByName("_Exp", 1).Address; + } + + public uint CurrentExp + { + get + { + AddonExp* addon = GetExpAddon(); + return addon != null ? addon->CurrentExp : 0; + } + } + + public uint RequiredExp + { + get + { + AddonExp* addon = GetExpAddon(); + return addon != null ? addon->RequiredExp : 0; + } + } + + public uint RestedExp + { + get + { + AddonExp* addon = GetExpAddon(); + return addon != null ? addon->RestedExp : 0; + } + } + + public float PercentExp + { + get + { + AddonExp* addon = GetExpAddon(); + return addon != null ? addon->CurrentExpPercent : 0; + } + } + + public unsafe bool IsMaxLevel() + { + UIModule* uiModule = StructsFramework.Instance()->GetUIModule(); + if (uiModule != null) + { + _raptureAtkModule = uiModule->GetRaptureAtkModule(); + } + + if (_raptureAtkModule == null || _raptureAtkModule->AtkModule.AtkArrayDataHolder.StringArrayCount <= ExperienceIndex) + { + return false; + } + + try + { + var stringArrayData = _raptureAtkModule->AtkModule.AtkArrayDataHolder.StringArrays[ExperienceIndex]; + var expStringArray = stringArrayData->StringArray[69]; + var expInfoString = MemoryHelper.ReadSeStringNullTerminated(new IntPtr(expStringArray)); + return expInfoString.TextValue.Contains("-/-"); + + } + catch (Exception e) + { + Plugin.Logger.Error("Error when receiving experience information: " + e.Message); + return false; + } + } + } +} diff --git a/Helpers/FontsManager.cs b/Helpers/FontsManager.cs new file mode 100644 index 0000000..1122aa6 --- /dev/null +++ b/Helpers/FontsManager.cs @@ -0,0 +1,233 @@ +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility; +using HSUI.Config; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace HSUI.Helpers +{ + public class FontScope : IDisposable + { + private readonly IFontHandle? _handle; + + public FontScope(IFontHandle? handle) + { + _handle = handle; + _handle?.Push(); + } + + public void Dispose() + { + _handle?.Pop(); + GC.SuppressFinalize(this); + } + } + + public class FontsManager : IDisposable + { + #region Singleton + private FontsManager(string basePath) + { + DefaultFontsPath = Path.GetDirectoryName(basePath) + "\\Media\\Fonts\\"; + } + + public static void Initialize(string basePath) + { + Instance = new FontsManager(basePath); + } + + public static FontsManager Instance { get; private set; } = null!; + private FontsConfig? _config; + + public void LoadConfig() + { + if (_config != null) + { + return; + } + + _config = ConfigurationManager.Instance.GetConfigObject(); + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + } + + private void OnConfigReset(ConfigurationManager sender) + { + _config = sender.GetConfigObject(); + } + + ~FontsManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + Instance = null!; + } + #endregion + + public readonly string DefaultFontsPath; + + public bool DefaultFontBuilt { get; private set; } + public IFontHandle? DefaultFont { get; private set; } = null!; + + private List _fonts = new List(); + public IReadOnlyCollection Fonts => _fonts.AsReadOnly(); + + public FontScope PushDefaultFont() + { + if (DefaultFontBuilt && DefaultFont != null) + { + return new FontScope(DefaultFont); + } + + return new FontScope(null); + } + + public FontScope PushFont(string? fontId) + { + if (fontId == null || _config == null || !_config.Fonts.ContainsKey(fontId)) + { + return new FontScope(null); + } + + var index = _config.Fonts.IndexOfKey(fontId); + if (index < 0 || index >= _fonts.Count) + { + return new FontScope(null); + } + + return new FontScope(_fonts[index]); + } + + public void ClearFonts() + { + foreach (IFontHandle font in _fonts) + { + font.Dispose(); + } + + _fonts.Clear(); + } + + public unsafe void BuildFonts() + { + ClearFonts(); + DefaultFontBuilt = false; + + FontsConfig config = ConfigurationManager.Instance.GetConfigObject(); + ImGuiIOPtr io = ImGui.GetIO(); + ushort[]? ranges = GetCharacterRanges(config, io); + + foreach (KeyValuePair fontData in config.Fonts) + { + bool isGameFont = config.GameFontMap.ContainsValue(fontData.Value.Name); + string path = DefaultFontsPath + fontData.Value.Name + ".ttf"; + + if (!File.Exists(path)) + { + path = config.ValidatedFontsPath + fontData.Value.Name + ".ttf"; + + if (!File.Exists(path) && !isGameFont) + { + continue; + } + } + + try + { + IFontHandle font; + + if (isGameFont) + { + GameFontFamily fontFamily = (GameFontFamily)Enum.Parse( + typeof(GameFontFamily), + config.GameFontMap.FirstOrDefault(x => x.Value == fontData.Value.Name).Key + ); + GameFontStyle style = new GameFontStyle(fontFamily, fontData.Value.Size); + + font = Plugin.UiBuilder.FontAtlas.NewGameFontHandle(style); + } + else + { + font = Plugin.UiBuilder.FontAtlas.NewDelegateFontHandle + ( + e => e.OnPreBuild + ( + tk => tk.AddFontFromFile + ( + path, + new SafeFontConfig + { + SizePx = fontData.Value.Size, + GlyphRanges = ranges + } + ) + ) + ); + } + + _fonts.Add(font); + + // save default font + if (fontData.Key == FontsConfig.DefaultBigFontKey) + { + DefaultFont = font; + DefaultFontBuilt = true; + } + } + catch (Exception ex) + { + Plugin.Logger.Error($"Error loading font from path {path}:\n{ex.Message}"); + } + + } + } + + private unsafe ushort[]? GetCharacterRanges(FontsConfig config, ImGuiIOPtr io) + { + if (!config.SupportChineseCharacters && !config.SupportKoreanCharacters && !config.SupportCyrillicCharacters) + { + return null; + } + + var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder()); + + if (config.SupportChineseCharacters) + { + // GetGlyphRangesChineseFull() includes Default + Hiragana, Katakana, Half-Width, Selection of 1946 Ideographs + // https://skia.googlesource.com/external/github.com/ocornut/imgui/+/v1.53/extra_fonts/README.txt + builder.AddRanges(io.Fonts.GetGlyphRangesChineseFull()); + } + + if (config.SupportKoreanCharacters) + { + builder.AddRanges(io.Fonts.GetGlyphRangesKorean()); + } + + if (config.SupportCyrillicCharacters) + { + builder.AddRanges(io.Fonts.GetGlyphRangesCyrillic()); + } + + return builder.BuildRangesToArray(); + } + } +} diff --git a/Helpers/GCDHelper.cs b/Helpers/GCDHelper.cs new file mode 100644 index 0000000..9572d48 --- /dev/null +++ b/Helpers/GCDHelper.cs @@ -0,0 +1,86 @@ +/* +Copyright(c) 2021 0ceal0t (https://github.com/0ceal0t/JobBars) +Modifications Copyright(c) 2021 HSUI +08/29/2021 - Extracted code to get the GCD state of player actions. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using FFXIVClientStructs.FFXIV.Client.Game; +using System.Collections.Generic; +using Dalamud.Game.ClientState.Objects.SubKinds; + +namespace HSUI.Helpers +{ + internal static class GCDHelper + { + private static readonly Dictionary JobActionIDs = new() + { + [JobIDs.GNB] = 16137, // Keen Edge + [JobIDs.WAR] = 31, // Heavy Swing + [JobIDs.MRD] = 31, // Heavy Swing + [JobIDs.DRK] = 3617, // Hard Slash + [JobIDs.PLD] = 9, // Fast Blade + [JobIDs.GLA] = 9, // Fast Blade + + [JobIDs.SCH] = 163, // Ruin + [JobIDs.AST] = 3596, // Malefic + [JobIDs.WHM] = 119, // Stone + [JobIDs.CNJ] = 119, // Stone + [JobIDs.SGE] = 24283, // Dosis + + [JobIDs.BRD] = 97, // Heavy Shot + [JobIDs.ARC] = 97, // Heavy Shot + [JobIDs.DNC] = 15989, // Cascade + [JobIDs.MCH] = 2866, // Split Shot + + [JobIDs.SMN] = 163, // Ruin + [JobIDs.ACN] = 163, // Ruin + [JobIDs.RDM] = 7504, // Riposte + [JobIDs.BLM] = 142, // Blizzard + [JobIDs.THM] = 142, // Blizzard + [JobIDs.PCT] = 34650, // Fire in Red + + [JobIDs.SAM] = 7477, // Hakaze + [JobIDs.NIN] = 2240, // Spinning Edge + [JobIDs.ROG] = 2240, // Spinning Edge + [JobIDs.MNK] = 53, // Bootshine + [JobIDs.PGL] = 53, // Bootshine + [JobIDs.DRG] = 75, // True Thrust + [JobIDs.LNC] = 75, // True Thrust + [JobIDs.RPR] = 24373, // Slice + [JobIDs.VPR] = 34606, // Steel Fangs + + [JobIDs.BLU] = 11385 // Water Cannon + }; + + public static unsafe bool GetGCDInfo(IPlayerCharacter player, out float timeElapsed, out float timeTotal, ActionType actionType = ActionType.Action) + { + if (player is null || !JobActionIDs.TryGetValue(player.ClassJob.RowId, out var actionId)) + { + timeElapsed = 0; + timeTotal = 0; + + return false; + } + + var actionManager = ActionManager.Instance(); + var adjustedId = actionManager->GetAdjustedActionId(actionId); + timeElapsed = actionManager->GetRecastTimeElapsed(actionType, adjustedId); + timeTotal = actionManager->GetRecastTime(actionType, adjustedId); + + return timeElapsed > 0; + } + } +} diff --git a/Helpers/HonorificHelper.cs b/Helpers/HonorificHelper.cs new file mode 100644 index 0000000..a948ad3 --- /dev/null +++ b/Helpers/HonorificHelper.cs @@ -0,0 +1,76 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Logging; +using Dalamud.Plugin.Ipc; +using Newtonsoft.Json; +using Lumina.Data.Parsing.Uld; +using System; +using System.Linq; + +namespace HSUI.Helpers +{ + + public class TitleData + { + public string Title = ""; + public bool IsPrefix = false; + } + + internal class HonorificHelper + { + private ICallGateSubscriber? _getCharacterTitle; + + #region Singleton + private HonorificHelper() + { + _getCharacterTitle = Plugin.PluginInterface.GetIpcSubscriber("Honorific.GetCharacterTitle"); + } + + public static void Initialize() { Instance = new HonorificHelper(); } + + public static HonorificHelper Instance { get; private set; } = null!; + + ~HonorificHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Instance = null!; + } + #endregion + + public TitleData? GetTitle(IGameObject? actor) + { + if (_getCharacterTitle == null || + actor == null || + actor.ObjectKind != ObjectKind.Player || + actor is not ICharacter character) + { + return null; + } + + try + { + string jsonData = _getCharacterTitle.InvokeFunc(character.ObjectIndex); + TitleData? titleData = JsonConvert.DeserializeObject(jsonData ?? string.Empty); + return titleData; + } + catch { } + + return null; + } + } +} diff --git a/Helpers/HudLayoutHashHelper.cs b/Helpers/HudLayoutHashHelper.cs new file mode 100644 index 0000000..da488e6 --- /dev/null +++ b/Helpers/HudLayoutHashHelper.cs @@ -0,0 +1,69 @@ +/* + * Computes HUD layout addon name hashes at runtime using the game's own hash function. + * AddonConfigEntry uses CRC32 of "name_a" - UIGlobals.ComputeAddonNameHash does this. + * This ensures correct hashes across game patches without hardcoded values. + */ + +using FFXIVClientStructs.FFXIV.Client.UI; +using System; +using System.Collections.Generic; + +namespace HSUI.Helpers +{ + public static class HudLayoutHashHelper + { + private static readonly Dictionary _cache = new(); + private static DateTime _lastResolveErrorLog = DateTime.MinValue; + private const double ResolveErrorLogIntervalSeconds = 10.0; + + /// Get AddonNameHash for a layout addon. Addon name is without _a suffix (e.g. "_ParameterWidget"). + public static uint GetHash(string addonName) + { + if (string.IsNullOrEmpty(addonName)) + return 0; + + if (_cache.TryGetValue(addonName, out var cached)) + return cached; + + try + { + uint hash = UIGlobals.ComputeAddonNameHash(addonName); + _cache[addonName] = hash; + return hash; + } + catch (Exception ex) + { + var now = DateTime.UtcNow; + if ((now - _lastResolveErrorLog).TotalSeconds >= ResolveErrorLogIntervalSeconds) + { + _lastResolveErrorLog = now; + Plugin.Logger.Warning($"[HSUI] HudLayoutHashHelper: resolver not ready (e.g. '{addonName}'): {ex.Message}"); + } + return 0; + } + } + + /// Dump all HudLayout addon names and their hashes to the log (for debugging). + public static void DumpHudLayoutAddonsToLog() + { + try + { + var span = FFXIVClientStructs.FFXIV.Client.UI.Misc.HudLayoutAddon.GetSpan(); + Plugin.Logger.Information("[HSUI] HudLayout addon names and hashes (name -> hash):"); + for (int i = 0; i < span.Length; i++) + { + ref var addon = ref span[i]; + if (!addon.AddonName.HasValue) continue; + string name = addon.AddonName.ToString() ?? "(null)"; + if (string.IsNullOrEmpty(name)) continue; + uint hash = GetHash(name); + Plugin.Logger.Information($" [{i}] {name} -> 0x{hash:X8}"); + } + } + catch (Exception ex) + { + Plugin.Logger.Error($"[HSUI] HudLayoutHashHelper.DumpHudLayoutAddonsToLog failed: {ex.Message}\n{ex.StackTrace}"); + } + } + } +} diff --git a/Helpers/ImGuiHelper.cs b/Helpers/ImGuiHelper.cs new file mode 100644 index 0000000..cdc8ab5 --- /dev/null +++ b/Helpers/ImGuiHelper.cs @@ -0,0 +1,286 @@ +using HSUI.Config; +using HSUI.Config.Tree; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Helpers +{ + public static class ImGuiHelper + { + public static void SetTooltip(string? message) + { + if (message == null) { return; } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip(message); + } + } + + public static void DrawSeparator(int topSpacing, int bottomSpacing) + { + DrawSpacing(topSpacing); + ImGui.Separator(); + DrawSpacing(bottomSpacing); + } + + public static void DrawSpacing(int spacingSize) + { + for (int i = 0; i < spacingSize; i++) + { + ImGui.NewLine(); + } + } + + public static void NewLineAndTab() + { + ImGui.NewLine(); + Tab(); + } + + public static void Tab() + { + ImGui.Text(" "); + ImGui.SameLine(); + } + + public static Node? DrawExportResetContextMenu(Node node, bool canExport, bool canReset) + { + Node? nodeToReset = null; + + if (ImGui.BeginPopupContextItem("ResetContextMenu")) + { + if (canExport && ImGui.Selectable("Export")) + { + var exportString = node.GetBase64String(); + ImGui.SetClipboardText(exportString ?? ""); + } + + if (canReset && ImGui.Selectable("Reset")) + { + ImGui.CloseCurrentPopup(); + nodeToReset = node; + } + + ImGui.EndPopup(); + } + + return nodeToReset; + } + + public static (bool, bool) DrawConfirmationModal(string title, string message) + { + return DrawConfirmationModal(title, new string[] { message }); + } + + public static (bool, bool) DrawConfirmationModal(string title, IEnumerable textLines) + { + ConfigurationManager.Instance.ShowingModalWindow = true; + + bool didConfirm = false; + bool didClose = false; + + ImGui.OpenPopup(title + " ##HSUI"); + + Vector2 center = ImGui.GetMainViewport().GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Appearing, new Vector2(0.5f, 0.5f)); + + bool p_open = true; // i've no idea what this is used for + + if (ImGui.BeginPopupModal(title + " ##HSUI", ref p_open, ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove)) + { + float width = 300; + float height = Math.Min((ImGui.CalcTextSize(" ").Y + 5) * textLines.Count(), 240); + + ImGui.BeginChild("confirmation_modal_message", new Vector2(width, height), false); + foreach (string text in textLines) + { + ImGui.Text(text); + } + ImGui.EndChild(); + + ImGui.NewLine(); + + if (ImGui.Button("OK", new Vector2(width / 2f - 5, 24))) + { + ImGui.CloseCurrentPopup(); + didConfirm = true; + didClose = true; + } + + ImGui.SetItemDefaultFocus(); + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(width / 2f - 5, 24))) + { + ImGui.CloseCurrentPopup(); + didClose = true; + } + + ImGui.EndPopup(); + } + // close button on nav + else + { + didClose = true; + } + + if (didClose) + { + ConfigurationManager.Instance.ShowingModalWindow = false; + } + + return (didConfirm, didClose); + } + + public static bool DrawErrorModal(string message) + { + ConfigurationManager.Instance.ShowingModalWindow = true; + + bool didClose = false; + ImGui.OpenPopup("Error ##HSUI"); + + Vector2 center = ImGui.GetMainViewport().GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Appearing, new Vector2(0.5f, 0.5f)); + + bool p_open = true; // i've no idea what this is used for + if (ImGui.BeginPopupModal("Error ##HSUI", ref p_open, ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove)) + { + ImGui.Text(message); + ImGui.NewLine(); + + var textSize = ImGui.CalcTextSize(message).X; + + if (ImGui.Button("OK", new Vector2(textSize, 24))) + { + ImGui.CloseCurrentPopup(); + didClose = true; + } + + ImGui.EndPopup(); + } + // close button on nav + else + { + didClose = true; + } + + if (didClose) + { + ConfigurationManager.Instance.ShowingModalWindow = false; + } + + return didClose; + } + + public static (bool, bool) DrawInputModal(string title, string message, ref string value) + { + ConfigurationManager.Instance.ShowingModalWindow = true; + + bool didConfirm = false; + bool didClose = false; + + ImGui.OpenPopup(title + " ##HSUI"); + + Vector2 center = ImGui.GetMainViewport().GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Appearing, new Vector2(0.5f, 0.5f)); + + bool p_open = true; // i've no idea what this is used for + + if (ImGui.BeginPopupModal(title + " ##HSUI", ref p_open, ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove)) + { + var textSize = ImGui.CalcTextSize(message).X; + + ImGui.Text(message); + + ImGui.PushItemWidth(textSize); + ImGui.InputText("", ref value, 64); + + ImGui.NewLine(); + if (ImGui.Button("OK", new Vector2(textSize / 2f - 5, 24))) + { + ImGui.CloseCurrentPopup(); + didConfirm = true; + didClose = true; + } + + ImGui.SetItemDefaultFocus(); + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(textSize / 2f - 5, 24))) + { + ImGui.CloseCurrentPopup(); + didClose = true; + } + + ImGui.EndPopup(); + } + // close button on nav + else + { + didClose = true; + } + + if (didClose) + { + ConfigurationManager.Instance.ShowingModalWindow = false; + } + + return (didConfirm, didClose); + } + + public static string? DrawTextTagsList(string name, ref string searchText) + { + string? selectedTag = null; + + ImGui.SetNextWindowSize(new(200, 300)); + + if (ImGui.BeginPopup(name, ImGuiWindowFlags.NoMove)) + { + if (!ImGui.IsAnyItemActive() && !ImGui.IsAnyItemFocused() && !ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + ImGui.SetKeyboardFocusHere(0); + } + + // search + ImGui.InputText("", ref searchText, 64); + + List keys = new List(); + keys.AddRange(TextTagsHelper.TextTags.Keys); + keys.AddRange(TextTagsHelper.ExpTags.Keys); + keys.AddRange(TextTagsHelper.CharaTextTags.Keys); + + foreach (string key in keys) + { + if (searchText.Length > 0 && !key.Contains(searchText)) + { + continue; + } + + // tag + if (ImGui.Selectable(key)) + { + selectedTag = key; + searchText = ""; + } + + // help tooltip + if (ImGui.IsItemHovered() && Plugin.ObjectTable.LocalPlayer != null) + { + string formattedText = TextTagsHelper.FormattedText(key, Plugin.ObjectTable.LocalPlayer); + + if (formattedText.Length > 0) + { + ImGui.SetTooltip("Example: " + formattedText); + } + } + } + + ImGui.EndPopup(); + } + + return selectedTag; + } + } +} diff --git a/Helpers/InputsHelper.cs b/Helpers/InputsHelper.cs new file mode 100644 index 0000000..632587b --- /dev/null +++ b/Helpers/InputsHelper.cs @@ -0,0 +1,621 @@ +/* +Copyright(c) 2021 attickdoor (https://github.com/attickdoor/MOActionPlugin) +Modifications Copyright(c) 2021 HSUI +09/21/2021 - Used original's code hooks and action validations while using +HSUI's own logic to select a target. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using HSUI.Config; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System.Runtime.CompilerServices; +using Dalamud.Bindings.ImGui; +using Lumina.Excel; +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using static FFXIVClientStructs.FFXIV.Client.Game.ActionManager; +using Action = Lumina.Excel.Sheets.Action; +using BattleNpcSubKind = Dalamud.Game.ClientState.Objects.Enums.BattleNpcSubKind; + +namespace HSUI.Helpers +{ + public unsafe class InputsHelper : IDisposable + { + private delegate bool UseActionDelegate(ActionManager* manager, ActionType actionType, uint actionId, ulong targetId, uint extraParam, UseActionMode mode, uint comboRouteId, bool* outOptAreaTargeted); + + private delegate byte ExecuteSlotByIdDelegate(RaptureHotbarModule* module, uint hotbarId, uint slotId); + + #region Singleton + private InputsHelper() + { + _sheet = Plugin.DataManager.GetExcelSheet(); + + //try + //{ + // /* + // Part of setUIMouseOverActorId disassembly signature + // .text:00007FF64830FD70 sub_7FF64830FD70 proc near + // .text:00007FF64830FD70 48 89 91 90 02 00+mov [rcx+290h], rdx + // .text:00007FF64830FD70 00 + // */ + + // _uiMouseOverActorHook = Plugin.GameInteropProvider.HookFromSignature( + // "E8 ?? ?? ?? ?? 48 8B 7C 24 ?? 4C 8B 74 24 ?? 83 FD 02", + // HandleUIMouseOverActorId + // ); + //} + //catch + //{ + // Plugin.Logger.Error("InputsHelper OnSetUIMouseoverActor Hook failed!!!"); + //} + + try + { + _requestActionHook = Plugin.GameInteropProvider.HookFromSignature( + ActionManager.Addresses.UseAction.String, + HandleRequestAction + ); + _requestActionHook?.Enable(); + } + catch + { + Plugin.Logger.Error("InputsHelper UseActionDelegate Hook failed!!!"); + } + + try + { + nint addr = (nint)RaptureHotbarModule.Addresses.ExecuteSlotById.Value; + if (addr != IntPtr.Zero) + { + _executeSlotByIdHook = Plugin.GameInteropProvider.HookFromAddress(addr, HandleExecuteSlotById); + _executeSlotByIdHook.Enable(); + Plugin.Logger.Info("[HSUI] ExecuteSlotById hook installed (drag-drop overwrite protection)"); + } + else + { + _executeSlotByIdHook = Plugin.GameInteropProvider.HookFromSignature( + "4C 8B C9 41 83 F8 10 73 45", + HandleExecuteSlotById + ); + _executeSlotByIdHook?.Enable(); + Plugin.Logger.Info("[HSUI] ExecuteSlotById hook installed via signature"); + } + } + catch (Exception ex) + { + Plugin.Logger.Error($"InputsHelper ExecuteSlotById Hook failed: {ex.Message}"); + } + + // mouseover setting + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + Plugin.Framework.Update += OnFrameworkUpdate; + + OnConfigReset(ConfigurationManager.Instance); + } + + public static void Initialize() { Instance = new InputsHelper(); } + + public static InputsHelper Instance { get; private set; } = null!; + + public static int InitializationDelay = 5; + + ~InputsHelper() + { + Dispose(false); + } + + public void Dispose() + { + Plugin.Logger.Info("\tDisposing InputsHelper..."); + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + Plugin.Framework.Update -= OnFrameworkUpdate; + + Plugin.Logger.Info("\t\tDisposing _requestActionHook: " + (_requestActionHook?.Address.ToString("X") ?? "null")); + _requestActionHook?.Disable(); + _requestActionHook?.Dispose(); + _executeSlotByIdHook?.Disable(); + _executeSlotByIdHook?.Dispose(); + + // give imgui the control of inputs again + RestoreWndProc(); + + Instance = null!; + } + #endregion + + private HUDOptionsConfig _config = null!; + + //private Hook? _uiMouseOverActorHook; + + private Hook? _requestActionHook; + private Hook? _executeSlotByIdHook; + + private ExcelSheet? _sheet; + + public bool HandlingMouseInputs { get; private set; } = false; + private IGameObject? _target = null; + private bool _ignoringMouseover = false; + + public bool IsProxyEnabled => _config.InputsProxyEnabled; + + public void ToggleProxy(bool enabled) + { + _config.InputsProxyEnabled = enabled; + ConfigurationManager.Instance.SaveConfigurations(); + } + + public void SetTarget(IGameObject? target, bool ignoreMouseover = false) + { + if (!IsProxyEnabled && + ClipRectsHelper.Instance?.IsPointClipped(ImGui.GetMousePos()) == false) + { + ImGui.SetNextFrameWantCaptureMouse(true); + } + + _target = target; + HandlingMouseInputs = true; + _ignoringMouseover = ignoreMouseover; + + if (!_ignoringMouseover) + { + long address = _target != null && _target.GameObjectId != 0 ? (long)_target.Address : 0; + SetGameMouseoverTarget(address); + } + } + + public void ClearTarget() + { + _target = null; + HandlingMouseInputs = false; + + SetGameMouseoverTarget(0); + } + + public void StartHandlingInputs() + { + HandlingMouseInputs = true; + } + + public void StopHandlingInputs() + { + HandlingMouseInputs = false; + _ignoringMouseover = false; + } + + private unsafe void SetGameMouseoverTarget(long address) + { + if (!_config.MouseoverEnabled || _config.MouseoverAutomaticMode || _ignoringMouseover) + { + return; + } + + UIModule* uiModule = Framework.Instance()->GetUIModule(); + if (uiModule == null) { return; } + + PronounModule* pronounModule = uiModule->GetPronounModule(); + if (pronounModule == null) { return; } + + pronounModule->UiMouseOverTarget = (GameObject*)address; + } + + private void OnConfigReset(ConfigurationManager sender) + { + _config = sender.GetConfigObject(); + } + + //private void HandleUIMouseOverActorId(long arg1, long arg2) + //{ + //Plugin.Logger.Log("MO: {0} - {1}", arg1.ToString("X"), arg2.ToString("X")); + //_uiMouseOverActorHook?.Original(arg1, arg2); + //} + + private bool HandleRequestAction( + ActionManager* manager, + ActionType actionType, + uint actionId, + ulong targetId, + uint extraParam, + UseActionMode mode, + uint comboRouteId, + bool* outOptAreaTargeted + ) + { + if (_requestActionHook == null) { return false; } + + // Block UseAction when we just placed this action via drag-drop (game may execute from drop via path that bypasses WndProc) + var (suppressActionId, suppressUntil) = _suppressUseActionForDrop; + if (suppressActionId != 0 && actionId == suppressActionId && ImGui.GetTime() < suppressUntil) + { + if (IsActionBarDragDropDebugEnabled()) + Plugin.Logger.Information($"[HSUI DragDrop DBG] UseAction SUPPRESSED actionId={actionId} (drop cooldown)"); + _suppressUseActionForDrop = (0, 0); + return false; + } + if (ImGui.GetTime() >= suppressUntil) + _suppressUseActionForDrop = (0, 0); + + if (_config.MouseoverEnabled && + _config.MouseoverAutomaticMode && + _target != null && + IsActionValid(actionId, _target) && + !_ignoringMouseover) + { + return _requestActionHook.Original(manager, actionType, actionId, _target.GameObjectId, extraParam, mode, comboRouteId, outOptAreaTargeted); + } + + return _requestActionHook.Original(manager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted); + } + + private byte HandleExecuteSlotById(RaptureHotbarModule* module, uint hotbarId, uint slotId) + { + var (suppressBar, suppressSlot, suppressUntil) = _suppressExecuteSlotByIdForDrop; + if (suppressBar < 10 && hotbarId == suppressBar && slotId == suppressSlot && ImGui.GetTime() < suppressUntil) + { + if (IsActionBarDragDropDebugEnabled()) + Plugin.Logger.Information($"[HSUI DragDrop DBG] ExecuteSlotById SUPPRESSED bar={hotbarId} slot={slotId} (drop cooldown)"); + _suppressExecuteSlotByIdForDrop = (99, 99, 0); + return 0; + } + if (ImGui.GetTime() >= suppressUntil) + _suppressExecuteSlotByIdForDrop = (99, 99, 0); + + return _executeSlotByIdHook != null ? _executeSlotByIdHook.Original(module, hotbarId, slotId) : (byte)0; + } + + private bool IsActionValid(ulong actionID, IGameObject? target) + { + if (target == null || actionID == 0 || _sheet == null) + { + return false; + } + + bool found = _sheet.TryGetRow((uint)actionID, out Action action); + if (!found) + { + return false; + } + + // handle actions that automatically switch to other actions + // ie GNB Continuation or SMN Egi Assaults + // these actions dont have an attack type or animation so in these cases + // we assume its a hostile spell + // if this doesn't work on all cases we can switch to a hardcoded list + // of special cases later + if (action.AttackType.RowId == 0 && action.AnimationStart.RowId == 0 && + (!action.CanTargetAlly && !action.CanTargetHostile && !action.CanTargetParty && action.CanTargetSelf)) + { + // special case for AST cards and SMN rekindle + if (actionID is 37019 or 37020 or 37021 or 25822) + { + return target is IPlayerCharacter or IBattleNpc { BattleNpcKind: BattleNpcSubKind.Chocobo }; + } + + return target is IBattleNpc npcTarget && npcTarget.BattleNpcKind == BattleNpcSubKind.Enemy; + } + + // friendly player (TODO: pvp? lol) + if (target is IPlayerCharacter) + { + return action.CanTargetAlly || action.CanTargetParty || action.CanTargetSelf; + } + + // friendly npc + if (target is IBattleNpc npc) + { + if (npc.BattleNpcKind != BattleNpcSubKind.Enemy) + { + return action.CanTargetAlly || action.CanTargetParty || action.CanTargetSelf; + } + } + + return action.CanTargetHostile; + } + + #region mouseover inputs proxy + private bool? _leftButtonClicked = null; + public bool LeftButtonClicked => _leftButtonClicked.HasValue ? + _leftButtonClicked.Value : + (IsProxyEnabled ? false : ImGui.IsMouseClicked(ImGuiMouseButton.Left)); + + private bool? _rightButtonClicked = null; + public bool RightButtonClicked => _rightButtonClicked.HasValue ? + _rightButtonClicked.Value : + (IsProxyEnabled ? false : ImGui.IsMouseClicked(ImGuiMouseButton.Right)); + + private bool _leftButtonWasDown = false; + private bool _rightButtonWasDown = false; + + + public void ClearClicks() + { + if (IsProxyEnabled) + { + WndProcDetour(_wndHandle, WM_LBUTTONUP, 0, 0); + WndProcDetour(_wndHandle, WM_RBUTTONUP, 0, 0); + } + } + + // wnd proc detour + // if we're "eating" inputs, we only process left and right clicks + // any other message is passed along to the ImGui scene + private IntPtr WndProcDetour(IntPtr hWnd, uint msg, ulong wParam, long lParam) + { + // When the game has an active hotbar-relevant drag (Action, Macro, Item, etc.) AND the cursor is over + // an HSUI hotbar, eat LBUTTONUP so the game doesn't interpret it as a click on the (hidden) default + // hotbar and execute the ability. Do NOT eat when cursor is over game UI (Character Config, etc.) — + // the game uses the same icon/drag system for config submenus, so we must only intercept when we're + // actually dropping on our hotbars. + if (msg == WM_LBUTTONUP && IsHotbarRelevantGameDrag() && ActionBarsHitTestHelper.IsMouseOverAnyHSUIHotbar()) + { + if (IsActionBarDragDropDebugEnabled()) + Plugin.Logger.Information("[HSUI DragDrop DBG] WndProc: EATING LBUTTONUP (hotbar drag over HSUI bar)"); + TryCancelGameDragDrop(); + ImGui.GetIO().AddMouseButtonEvent((int)ImGuiMouseButton.Left, false); + return (IntPtr)0; + } + + // eat left and right clicks? + if (HandlingMouseInputs && IsProxyEnabled) + { + switch (msg) + { + // mouse clicks + case WM_LBUTTONDOWN: + case WM_RBUTTONDOWN: + case WM_LBUTTONUP: + case WM_RBUTTONUP: + + // if there's not a game window covering the cursor location + // we eat the message and handle the inputs manually + if (ClipRectsHelper.Instance?.IsPointClipped(ImGui.GetMousePos()) == false) + { + _leftButtonClicked = _leftButtonWasDown && msg == WM_LBUTTONUP; + _rightButtonClicked = _rightButtonWasDown && msg == WM_RBUTTONUP; + + + _leftButtonWasDown = msg == WM_LBUTTONDOWN; + _rightButtonWasDown = msg == WM_RBUTTONDOWN; + + // never eat BUTTONUP messages to prevent clicks from getting stuck!!! + if (msg != WM_LBUTTONUP && msg != WM_RBUTTONUP) + { + // INPUT EATEN!!! + return (IntPtr)0; + } + } + // otherwise we let imgui handle the inputs + else + { + _leftButtonClicked = null; + _rightButtonClicked = null; + } + break; + } + } + + // call imgui's wnd proc + return (IntPtr)CallWindowProc(_imguiWndProcPtr, hWnd, msg, wParam, lParam); + } + + public void OnFrameworkUpdate(IFramework framework) + { + // Keep WndProc hooked when: proxy mode (for mouseover) OR we need to block game drag + // release (so dropping on HSUI action bar doesn't execute the ability). + bool needHook = IsProxyEnabled || ShouldBlockGameDragRelease(); + if (needHook && _wndProcPtr == IntPtr.Zero) + { + HookWndProc(); + // Only log when we actually installed (HookWndProc can return early during init delay) + if (_wndProcPtr != IntPtr.Zero && IsActionBarDragDropDebugEnabled()) + Plugin.Logger.Information("[HSUI DragDrop DBG] WndProc hook INSTALLED (needHook=true for drag-block)"); + } + else if (!needHook && _wndProcPtr != IntPtr.Zero) + RestoreWndProc(); + } + + private static bool ShouldBlockGameDragRelease() + { + try + { + var hotbarsConfig = ConfigurationManager.Instance?.GetConfigObject(); + return hotbarsConfig != null && hotbarsConfig.Enabled; + } + catch { return false; } + } + + private static bool IsActionBarDragDropDebugEnabled() + { + try + { + var configs = ConfigurationManager.Instance?.GetObjects(); + return configs != null && configs.Exists(c => c.DebugDragDrop); + } + catch { return false; } + } + + /// When we place an action via drag-drop, suppress the next UseAction for that action to prevent + /// the game from executing it (game may interpret drop as click via a path that bypasses WndProc). + private static (uint ActionId, double SuppressUntil) _suppressUseActionForDrop = (0, 0); + + public static void SuppressUseActionAfterDrop(uint actionId, int durationMs = 300) + { + _suppressUseActionForDrop = (actionId, ImGui.GetTime() + durationMs / 1000.0); + } + + /// Suppress ExecuteSlotById when we just placed via drag-drop (game hotbar click goes through this). + private static (uint HotbarId, uint SlotId, double SuppressUntil) _suppressExecuteSlotByIdForDrop = (99, 99, 0); + + public static void SuppressExecuteSlotByIdAfterDrop(uint hotbarId, uint slotId, int durationMs = 300) + { + _suppressExecuteSlotByIdForDrop = (hotbarId, slotId, ImGui.GetTime() + durationMs / 1000.0); + } + + public void OnFrameEnd() + { + _leftButtonClicked = null; + _rightButtonClicked = null; + } + + private void HookWndProc() + { + if (Plugin.LoadTime <= 0 || + ImGui.GetTime() - Plugin.LoadTime < InitializationDelay) + { + return; + } + + ulong processId = (ulong)Process.GetCurrentProcess().Id; + + IntPtr hWnd = IntPtr.Zero; + do + { + hWnd = FindWindowExW(IntPtr.Zero, hWnd, "FFXIVGAME", null); + if (hWnd == IntPtr.Zero) { return; } + + ulong wndProcessId = 0; + GetWindowThreadProcessId(hWnd, ref wndProcessId); + + if (wndProcessId == processId) + { + break; + } + + } while (hWnd != IntPtr.Zero); + + if (hWnd == IntPtr.Zero) { return; } + + _wndHandle = hWnd; + _wndProcDelegate = WndProcDetour; + _wndProcPtr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate); + _imguiWndProcPtr = SetWindowLongPtr(hWnd, GWL_WNDPROC, _wndProcPtr); + + Plugin.Logger.Info("Initializing HSUI Inputs v" + Plugin.Version); + Plugin.Logger.Info("\tHooking WndProc for window: " + hWnd.ToString("X")); + Plugin.Logger.Info("\tOld WndProc: " + _imguiWndProcPtr.ToString("X")); + } + + private void RestoreWndProc() + { + if (_wndHandle != IntPtr.Zero && _imguiWndProcPtr != IntPtr.Zero) + { + Plugin.Logger.Info("\t\tRestoring WndProc"); + Plugin.Logger.Info("\t\t\tOld _wndHandle = " + _wndHandle.ToString("X")); + Plugin.Logger.Info("\t\t\tOld _imguiWndProcPtr = " + _imguiWndProcPtr.ToString("X")); + + SetWindowLongPtr(_wndHandle, GWL_WNDPROC, _imguiWndProcPtr); + Plugin.Logger.Info("\t\t\tDone!"); + + _wndHandle = IntPtr.Zero; + _imguiWndProcPtr = IntPtr.Zero; + } + } + + private IntPtr _wndHandle = IntPtr.Zero; + private WndProcDelegate _wndProcDelegate = null!; + private IntPtr _wndProcPtr = IntPtr.Zero; + private IntPtr _imguiWndProcPtr = IntPtr.Zero; + + public delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, ulong wParam, long lParam); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] + public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + [DllImport("user32.dll", EntryPoint = "CallWindowProcW")] + public static extern long CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint Msg, ulong wParam, long lParam); + + [DllImport("user32.dll", EntryPoint = "FindWindowExW", SetLastError = true)] + public static extern IntPtr FindWindowExW(IntPtr hWndParent, IntPtr hWndChildAfter, [MarshalAs(UnmanagedType.LPWStr)] string? lpszClass, [MarshalAs(UnmanagedType.LPWStr)] string? lpszWindow); + + [DllImport("user32.dll", EntryPoint = "GetWindowThreadProcessId", SetLastError = true)] + public static extern ulong GetWindowThreadProcessId(IntPtr hWnd, ref ulong id); + + private const uint WM_LBUTTONDOWN = 513; + private const uint WM_LBUTTONUP = 514; + private const uint WM_RBUTTONDOWN = 516; + private const uint WM_RBUTTONUP = 517; + + private const int GWL_WNDPROC = -4; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe bool IsGameDragDropActive() + { + try + { + var stage = AtkStage.Instance(); + if (stage == null) return false; + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + return dm != null && dm->IsDragging; + } + catch { return false; } + } + + /// True when the game has an active drag that can be placed on a hotbar (Action, Macro, Item, etc.). + /// Used to avoid eating LBUTTONUP for other UI drags (e.g. Character Config menus). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe bool IsHotbarRelevantGameDrag() + { + try + { + if (!IsGameDragDropActive()) return false; + var stage = AtkStage.Instance(); + if (stage == null) return false; + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + var dd = dm->DragDrop1; + if (dd == null) return false; + var slotType = UIGlobals.GetHotbarSlotTypeFromDragDropType(dd->DragDropType); + return slotType != RaptureHotbarModule.HotbarSlotType.Empty; + } + catch { return false; } + } + + + private static unsafe void TryCancelGameDragDrop() + { + try + { + var stage = AtkStage.Instance(); + if (stage == null) return; + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + if (dm != null) + dm->CancelDragDrop(true, true); + } + catch { /* best-effort */ } + } + #endregion + } +} diff --git a/Helpers/JobsHelper.cs b/Helpers/JobsHelper.cs new file mode 100644 index 0000000..72abeaa --- /dev/null +++ b/Helpers/JobsHelper.cs @@ -0,0 +1,710 @@ +using Dalamud.Game.ClientState.Objects.Types; +using System.Collections.Generic; + +namespace HSUI.Helpers +{ + public enum JobRoles + { + Tank = 0, + Healer = 1, + DPSMelee = 2, + DPSRanged = 3, + DPSCaster = 4, + Crafter = 5, + Gatherer = 6, + Unknown + } + + public enum PrimaryResourceTypes + { + MP = 0, + CP = 1, + GP = 2, + None = 3 + } + + public static class JobsHelper + { + public static JobRoles RoleForJob(uint jobId) + { + if (JobRolesMap.TryGetValue(jobId, out var role)) + { + return role; + } + + return JobRoles.Unknown; + } + + public static bool IsJobARole(uint jobId, JobRoles role) + { + if (JobRolesMap.TryGetValue(jobId, out var r)) + { + return r == role; + } + + return false; + } + + public static bool IsJobTank(uint jobId) + { + return IsJobARole(jobId, JobRoles.Tank); + } + + public static bool IsJobWithCleanse(uint jobId, int level) + { + var isOnCleanseJob = _cleanseJobs.Contains(jobId); + + if (jobId == JobIDs.BRD && level < 35) + { + isOnCleanseJob = false; + } + + return isOnCleanseJob; + } + + private static readonly List _cleanseJobs = new List() + { + JobIDs.CNJ, + JobIDs.WHM, + JobIDs.SCH, + JobIDs.AST, + JobIDs.SGE, + JobIDs.BRD, + JobIDs.BLU + }; + + public static bool IsJobHealer(uint jobId) + { + return IsJobARole(jobId, JobRoles.Healer); + } + + public static bool IsJobDPS(uint jobId) + { + if (JobRolesMap.TryGetValue(jobId, out var r)) + { + return r == JobRoles.DPSMelee || r == JobRoles.DPSRanged || r == JobRoles.DPSCaster; + } + + return false; + } + + public static bool IsJobDPSMelee(uint jobId) + { + return IsJobARole(jobId, JobRoles.DPSMelee); + } + + public static bool IsJobDPSRanged(uint jobId) + { + return IsJobARole(jobId, JobRoles.DPSRanged); + } + + public static bool IsJobDPSCaster(uint jobId) + { + return IsJobARole(jobId, JobRoles.DPSCaster); + } + + public static bool IsJobCrafter(uint jobId) + { + return IsJobARole(jobId, JobRoles.Crafter); + } + + public static bool IsJobGatherer(uint jobId) + { + return IsJobARole(jobId, JobRoles.Gatherer); + } + + public static bool IsJobWithRaise(uint jobId, uint level) + { + var isOnRaiseJob = _raiseJobs.Contains(jobId); + + if ((jobId == JobIDs.RDM && level < 64) || level < 12) + { + isOnRaiseJob = false; + } + + return isOnRaiseJob; + } + + private static readonly List _raiseJobs = new List() + { + JobIDs.CNJ, + JobIDs.WHM, + JobIDs.SCH, + JobIDs.AST, + JobIDs.RDM, + JobIDs.SMN, + JobIDs.SGE + }; + + public static uint CurrentPrimaryResource(ICharacter? character) + { + if (character == null) + { + return 0; + } + + uint jobId = character.ClassJob.RowId; + + if (IsJobGatherer(jobId)) + { + return character.CurrentGp; + } + + if (IsJobCrafter(jobId)) + { + return character.CurrentCp; + } + + return character.CurrentMp; + } + + public static uint MaxPrimaryResource(ICharacter? character) + { + if (character == null) + { + return 0; + } + + uint jobId = character.ClassJob.RowId; + + if (IsJobGatherer(jobId)) + { + return character.MaxGp; + } + + if (IsJobCrafter(jobId)) + { + return character.MaxCp; + } + + return character.MaxMp; + } + + public static uint GPResourceRate(ICharacter? character) + { + if (character == null) + { + return 0; + } + + // Preferably I'd want to check the active traits because these traits are locked behind job quests, but no idea how to check traits. + + // Level 83 Trait 239 (MIN), 240 (BTN), 241 (FSH) + if (character.Level >= 83) + { + return 8; + } + + // Level 80 Trait 236 (MIN), 237 (BTN), 238 (FSH) + if (character.Level >= 80) + { + return 7; + } + + // Level 70 Trait 192 (MIN), 193 (BTN), 194 (FSH) + if (character.Level >= 70) + { + return 6; + } + + return 5; + } + + public static string TimeTillMaxGP(ICharacter? character) + { + if (character == null) + { + return ""; + } + + uint jobId = character.ClassJob.RowId; + + if (!IsJobGatherer(jobId)) + { + return ""; + } + + uint gpRate = GPResourceRate(character); + + if (character.CurrentGp == character.MaxGp) + { + return ""; + } + + // Since I'm not using a stopwatch or anything like MPTickHelper here the time will only update every 3 seconds, would be nice if the time ticks down every second. + float gpPerSecond = gpRate / 3f; + float secondsTillMax = (character.MaxGp - character.CurrentGp) / gpPerSecond; + + return $"{Utils.DurationToString(secondsTillMax)}"; + } + + public static uint IconIDForJob(uint jobId) + { + return jobId + 62000; + } + + public static uint IconIDForJob(uint jobId, uint style) + { + if (style < 2) + { + return IconIDForJob(jobId) + style * 100; + } + + ColorizedIconIDs.TryGetValue(jobId, out var iconID); + return iconID; + } + + public static uint RoleIconIDForJob(uint jobId, bool specificDPSIcons = false) + { + var role = RoleForJob(jobId); + + switch (role) + { + case JobRoles.Tank: return 62581; + case JobRoles.Healer: return 62582; + + case JobRoles.DPSMelee: + case JobRoles.DPSRanged: + case JobRoles.DPSCaster: + if (specificDPSIcons && SpecificDPSIcons.TryGetValue(jobId, out var iconId)) + { + return iconId; + } + else + { + return 62583; + } + + case JobRoles.Gatherer: + case JobRoles.Crafter: + return IconIDForJob(jobId); + } + + return 0; + } + + public static uint RoleIconIDForBattleCompanion => 62043; + + public static Dictionary JobRolesMap = new Dictionary() + { + // tanks + [JobIDs.GLA] = JobRoles.Tank, + [JobIDs.MRD] = JobRoles.Tank, + [JobIDs.PLD] = JobRoles.Tank, + [JobIDs.WAR] = JobRoles.Tank, + [JobIDs.DRK] = JobRoles.Tank, + [JobIDs.GNB] = JobRoles.Tank, + + // healers + [JobIDs.CNJ] = JobRoles.Healer, + [JobIDs.WHM] = JobRoles.Healer, + [JobIDs.SCH] = JobRoles.Healer, + [JobIDs.AST] = JobRoles.Healer, + [JobIDs.SGE] = JobRoles.Healer, + + // melee dps + [JobIDs.PGL] = JobRoles.DPSMelee, + [JobIDs.LNC] = JobRoles.DPSMelee, + [JobIDs.ROG] = JobRoles.DPSMelee, + [JobIDs.MNK] = JobRoles.DPSMelee, + [JobIDs.DRG] = JobRoles.DPSMelee, + [JobIDs.NIN] = JobRoles.DPSMelee, + [JobIDs.SAM] = JobRoles.DPSMelee, + [JobIDs.RPR] = JobRoles.DPSMelee, + [JobIDs.VPR] = JobRoles.DPSMelee, + + // ranged phys dps + [JobIDs.ARC] = JobRoles.DPSRanged, + [JobIDs.BRD] = JobRoles.DPSRanged, + [JobIDs.MCH] = JobRoles.DPSRanged, + [JobIDs.DNC] = JobRoles.DPSRanged, + + // ranged magic dps + [JobIDs.THM] = JobRoles.DPSCaster, + [JobIDs.ACN] = JobRoles.DPSCaster, + [JobIDs.BLM] = JobRoles.DPSCaster, + [JobIDs.SMN] = JobRoles.DPSCaster, + [JobIDs.RDM] = JobRoles.DPSCaster, + [JobIDs.BLU] = JobRoles.DPSCaster, + [JobIDs.PCT] = JobRoles.DPSCaster, + + // crafters + [JobIDs.CRP] = JobRoles.Crafter, + [JobIDs.BSM] = JobRoles.Crafter, + [JobIDs.ARM] = JobRoles.Crafter, + [JobIDs.GSM] = JobRoles.Crafter, + [JobIDs.LTW] = JobRoles.Crafter, + [JobIDs.WVR] = JobRoles.Crafter, + [JobIDs.ALC] = JobRoles.Crafter, + [JobIDs.CUL] = JobRoles.Crafter, + + // gatherers + [JobIDs.MIN] = JobRoles.Gatherer, + [JobIDs.BOT] = JobRoles.Gatherer, + [JobIDs.FSH] = JobRoles.Gatherer, + }; + + public static Dictionary> JobsByRole = new Dictionary>() + { + // tanks + [JobRoles.Tank] = new List() { + JobIDs.GLA, + JobIDs.MRD, + JobIDs.PLD, + JobIDs.WAR, + JobIDs.DRK, + JobIDs.GNB, + }, + + // healers + [JobRoles.Healer] = new List() + { + JobIDs.CNJ, + JobIDs.WHM, + JobIDs.SCH, + JobIDs.AST, + JobIDs.SGE + }, + + // melee dps + [JobRoles.DPSMelee] = new List() { + JobIDs.PGL, + JobIDs.LNC, + JobIDs.ROG, + JobIDs.MNK, + JobIDs.DRG, + JobIDs.NIN, + JobIDs.SAM, + JobIDs.RPR, + JobIDs.VPR + }, + + // ranged phys dps + [JobRoles.DPSRanged] = new List() + { + JobIDs.ARC, + JobIDs.BRD, + JobIDs.MCH, + JobIDs.DNC, + }, + + // ranged magic dps + [JobRoles.DPSCaster] = new List() + { + JobIDs.THM, + JobIDs.ACN, + JobIDs.BLM, + JobIDs.SMN, + JobIDs.RDM, + JobIDs.BLU, + JobIDs.PCT + }, + + // crafters + [JobRoles.Crafter] = new List() + { + JobIDs.CRP, + JobIDs.BSM, + JobIDs.ARM, + JobIDs.GSM, + JobIDs.LTW, + JobIDs.WVR, + JobIDs.ALC, + JobIDs.CUL, + }, + + // gatherers + [JobRoles.Gatherer] = new List() + { + JobIDs.MIN, + JobIDs.BOT, + JobIDs.FSH, + }, + + // unknown + [JobRoles.Unknown] = new List() + }; + + public static Dictionary JobNames = new Dictionary() + { + // tanks + [JobIDs.GLA] = "GLA", + [JobIDs.MRD] = "MRD", + [JobIDs.PLD] = "PLD", + [JobIDs.WAR] = "WAR", + [JobIDs.DRK] = "DRK", + [JobIDs.GNB] = "GNB", + + // melee dps + [JobIDs.PGL] = "PGL", + [JobIDs.LNC] = "LNC", + [JobIDs.ROG] = "ROG", + [JobIDs.MNK] = "MNK", + [JobIDs.DRG] = "DRG", + [JobIDs.NIN] = "NIN", + [JobIDs.SAM] = "SAM", + [JobIDs.RPR] = "RPR", + [JobIDs.VPR] = "VPR", + + // ranged phys dps + [JobIDs.ARC] = "ARC", + [JobIDs.BRD] = "BRD", + [JobIDs.MCH] = "MCH", + [JobIDs.DNC] = "DNC", + + // ranged magic dps + [JobIDs.THM] = "THM", + [JobIDs.ACN] = "ACN", + [JobIDs.BLM] = "BLM", + [JobIDs.SMN] = "SMN", + [JobIDs.RDM] = "RDM", + [JobIDs.BLU] = "BLU", + [JobIDs.PCT] = "PCT", + + // healers + [JobIDs.CNJ] = "CNJ", + [JobIDs.WHM] = "WHM", + [JobIDs.SCH] = "SCH", + [JobIDs.SGE] = "SGE", + [JobIDs.AST] = "AST", + + // crafters + [JobIDs.CRP] = "CRP", + [JobIDs.BSM] = "BSM", + [JobIDs.ARM] = "ARM", + [JobIDs.GSM] = "GSM", + [JobIDs.LTW] = "LTW", + [JobIDs.WVR] = "WVR", + [JobIDs.ALC] = "ALC", + [JobIDs.CUL] = "CUL", + + // gatherers + [JobIDs.MIN] = "MIN", + [JobIDs.BOT] = "BOT", + [JobIDs.FSH] = "FSH", + }; + + public static Dictionary JobFullNames = new Dictionary() + { + // tanks + [JobIDs.GLA] = "Gladiator", + [JobIDs.MRD] = "Marauder", + [JobIDs.PLD] = "Paladin", + [JobIDs.WAR] = "Warrior", + [JobIDs.DRK] = "Dark Knight", + [JobIDs.GNB] = "Gunbreaker", + + // melee dps + [JobIDs.PGL] = "Pugilist", + [JobIDs.LNC] = "Lancer", + [JobIDs.ROG] = "Rogue", + [JobIDs.MNK] = "Monk", + [JobIDs.DRG] = "Dragoon", + [JobIDs.NIN] = "Ninja", + [JobIDs.SAM] = "Samurai", + [JobIDs.RPR] = "Reaper", + [JobIDs.VPR] = "Viper", + + // ranged phys dps + [JobIDs.ARC] = "Archer", + [JobIDs.BRD] = "Bard", + [JobIDs.MCH] = "Machinist", + [JobIDs.DNC] = "Dancer", + + // ranged magic dps + [JobIDs.THM] = "Thaumaturge", + [JobIDs.ACN] = "Arcanist", + [JobIDs.BLM] = "Black Mage", + [JobIDs.SMN] = "Summoner", + [JobIDs.RDM] = "Red Mage", + [JobIDs.BLU] = "Blue Mage", + [JobIDs.PCT] = "Pictomancer", + + // healers + [JobIDs.CNJ] = "Conjurer", + [JobIDs.WHM] = "White Mage", + [JobIDs.SCH] = "Scholar", + [JobIDs.SGE] = "Sage", + [JobIDs.AST] = "Astrologian", + + // crafters + [JobIDs.CRP] = "Carpenter", + [JobIDs.BSM] = "Blacksmith", + [JobIDs.ARM] = "Armorer", + [JobIDs.GSM] = "Goldsmith", + [JobIDs.LTW] = "Leatherworker", + [JobIDs.WVR] = "Weaver", + [JobIDs.ALC] = "Alchemist", + [JobIDs.CUL] = "Culinarian", + + // gatherers + [JobIDs.MIN] = "Miner", + [JobIDs.BOT] = "Botanist", + [JobIDs.FSH] = "Fisher", + }; + + public static Dictionary RoleNames = new Dictionary() + { + [JobRoles.Tank] = "Tank", + [JobRoles.Healer] = "Healer", + [JobRoles.DPSMelee] = "Melee", + [JobRoles.DPSRanged] = "Ranged", + [JobRoles.DPSCaster] = "Caster", + [JobRoles.Crafter] = "Crafter", + [JobRoles.Gatherer] = "Gatherer", + [JobRoles.Unknown] = "Unknown" + }; + + public static Dictionary SpecificDPSIcons = new Dictionary() + { + // melee dps + [JobIDs.PGL] = 62584, + [JobIDs.LNC] = 62584, + [JobIDs.ROG] = 62584, + [JobIDs.MNK] = 62584, + [JobIDs.DRG] = 62584, + [JobIDs.NIN] = 62584, + [JobIDs.SAM] = 62584, + [JobIDs.RPR] = 62584, + [JobIDs.VPR] = 62584, + + // ranged phys dps + [JobIDs.ARC] = 62586, + [JobIDs.BRD] = 62586, + [JobIDs.MCH] = 62586, + [JobIDs.DNC] = 62586, + + // ranged magic dps + [JobIDs.THM] = 62587, + [JobIDs.ACN] = 62587, + [JobIDs.BLM] = 62587, + [JobIDs.SMN] = 62587, + [JobIDs.RDM] = 62587, + [JobIDs.BLU] = 62587, + [JobIDs.PCT] = 62587 + }; + + public static Dictionary ColorizedIconIDs = new Dictionary() + { + // tanks + [JobIDs.GLA] = 94022, + [JobIDs.MRD] = 94024, + [JobIDs.PLD] = 94079, + [JobIDs.WAR] = 94081, + [JobIDs.DRK] = 94123, + [JobIDs.GNB] = 94130, + + // melee dps + [JobIDs.PGL] = 92523, + [JobIDs.LNC] = 92525, + [JobIDs.ROG] = 92621, + [JobIDs.MNK] = 92580, + [JobIDs.DRG] = 92582, + [JobIDs.NIN] = 92622, + [JobIDs.SAM] = 92627, + [JobIDs.RPR] = 92632, + [JobIDs.VPR] = 92685, + + // ranged phys dps + [JobIDs.ARC] = 92526, + [JobIDs.BRD] = 92583, + [JobIDs.MCH] = 92625, + [JobIDs.DNC] = 92631, + + // ranged magic dps + [JobIDs.THM] = 92529, + [JobIDs.ACN] = 92530, + [JobIDs.BLM] = 92585, + [JobIDs.SMN] = 92586, + [JobIDs.RDM] = 92628, + [JobIDs.BLU] = 92629, + [JobIDs.PCT] = 92686, + + // healers + [JobIDs.CNJ] = 94528, + [JobIDs.WHM] = 94584, + [JobIDs.SCH] = 94587, + [JobIDs.SGE] = 94633, + [JobIDs.AST] = 94624, + + // crafters + [JobIDs.CRP] = 91031, + [JobIDs.BSM] = 91032, + [JobIDs.ARM] = 91033, + [JobIDs.GSM] = 91034, + [JobIDs.LTW] = 91034, + [JobIDs.WVR] = 91036, + [JobIDs.ALC] = 91037, + [JobIDs.CUL] = 91038, + + // gatherers + [JobIDs.MIN] = 91039, + [JobIDs.BOT] = 91039, + [JobIDs.FSH] = 91041, + }; + + public static Dictionary PrimaryResourceTypesByRole = new Dictionary() + { + [JobRoles.Tank] = PrimaryResourceTypes.MP, + [JobRoles.Healer] = PrimaryResourceTypes.MP, + [JobRoles.DPSMelee] = PrimaryResourceTypes.MP, + [JobRoles.DPSRanged] = PrimaryResourceTypes.MP, + [JobRoles.DPSCaster] = PrimaryResourceTypes.MP, + [JobRoles.Crafter] = PrimaryResourceTypes.CP, + [JobRoles.Gatherer] = PrimaryResourceTypes.GP, + [JobRoles.Unknown] = PrimaryResourceTypes.MP + }; + } + + public static class JobIDs + { + public const uint GLA = 1; + public const uint MRD = 3; + public const uint PLD = 19; + public const uint WAR = 21; + public const uint DRK = 32; + public const uint GNB = 37; + + public const uint CNJ = 6; + public const uint WHM = 24; + public const uint SCH = 28; + public const uint AST = 33; + public const uint SGE = 40; + + public const uint PGL = 2; + public const uint LNC = 4; + public const uint ROG = 29; + public const uint MNK = 20; + public const uint DRG = 22; + public const uint NIN = 30; + public const uint SAM = 34; + public const uint RPR = 39; + public const uint VPR = 41; + + public const uint ARC = 5; + public const uint BRD = 23; + public const uint MCH = 31; + public const uint DNC = 38; + + public const uint THM = 7; + public const uint ACN = 26; + public const uint BLM = 25; + public const uint SMN = 27; + public const uint RDM = 35; + public const uint BLU = 36; + public const uint PCT = 42; + + public const uint CRP = 8; + public const uint BSM = 9; + public const uint ARM = 10; + public const uint GSM = 11; + public const uint LTW = 12; + public const uint WVR = 13; + public const uint ALC = 14; + public const uint CUL = 15; + + public const uint MIN = 16; + public const uint BOT = 17; + public const uint FSH = 18; + } +} diff --git a/Helpers/LastUsedCast.cs b/Helpers/LastUsedCast.cs new file mode 100644 index 0000000..7035762 --- /dev/null +++ b/Helpers/LastUsedCast.cs @@ -0,0 +1,156 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Interface.Textures.TextureWraps; +using HSUI.Enums; +using FFXIVClientStructs.FFXIV.Client.Game; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Companion = Lumina.Excel.Sheets.Companion; + +namespace HSUI.Helpers +{ + public class LastUsedCast + { + private object? _lastUsedAction; + + public readonly bool Interruptible; + public readonly ActionType ActionType; + public readonly uint CastId; + private uint? _iconId; + + public string ActionText { get; private set; } = ""; + public DamageType DamageType { get; private set; } = DamageType.Unknown; + + public LastUsedCast(uint castId, ActionType actionType, bool interruptible) + { + CastId = castId; + ActionType = actionType; + Interruptible = interruptible; + + SetCastProperties(); + } + + private void SetCastProperties() + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + ObjectKind? targetKind = target?.ObjectKind; + + switch (targetKind) + { + case null: + break; + + case ObjectKind.Aetheryte: + ActionText = "Attuning..."; + _iconId = 112; + + return; + + case ObjectKind.EventObj: + case ObjectKind.EventNpc: + ActionText = "Interacting..."; + _iconId = null; + + return; + } + + _lastUsedAction = null; + if (CastId == 1 && ActionType != ActionType.Mount) + { + ActionText = "Interacting..."; + + return; + } + + ActionText = "Casting"; + _iconId = null; + + switch (ActionType) + { + case ActionType.PetAction: + case ActionType.Action: + case ActionType.BgcArmyAction: + case ActionType.PvPAction: + case ActionType.CraftAction: + case ActionType.EventAction: + Action? action = Plugin.DataManager.GetExcelSheet()?.GetRow(CastId); + ActionText = action?.Name.ToString() ?? ""; + DamageType = GetDamageType(action); + _lastUsedAction = action; + + break; + + case ActionType.Mount: + Mount? mount = Plugin.DataManager.GetExcelSheet()?.GetRow(CastId); + ActionText = mount?.Singular.ToString() ?? ""; + DamageType = DamageType.Unknown; + _lastUsedAction = mount; + break; + + case ActionType.EventItem: + case ActionType.Item: + Item? item = Plugin.DataManager.GetExcelSheet()?.GetRow(CastId); + ActionText = item?.Name.ToString() ?? "Using item..."; + DamageType = DamageType.Unknown; + _lastUsedAction = item; + break; + + case ActionType.Companion: + Companion? companion = Plugin.DataManager.GetExcelSheet()?.GetRow(CastId); + ActionText = companion?.Singular.ToString() ?? ""; + DamageType = DamageType.Unknown; + _lastUsedAction = companion; + break; + + default: + _lastUsedAction = null; + ActionText = "Casting..."; + DamageType = DamageType.Unknown; + break; + } + } + + private static DamageType GetDamageType(Action? action) + { + if (!action.HasValue) + { + return DamageType.Unknown; + } + + DamageType damageType = (DamageType)action.Value.AttackType.RowId; + + if (damageType != DamageType.Magic && damageType != DamageType.Darkness && damageType != DamageType.Unknown) + { + damageType = DamageType.Physical; + } + + return damageType; + } + + public IDalamudTextureWrap? GetIconTexture() + { + if (_iconId.HasValue) + { + return TexturesHelper.GetTexture(_iconId.Value); + } + else if (_lastUsedAction is Action action) + { + return TexturesHelper.GetTextureFromIconId(action.Icon); + } + else if (_lastUsedAction is Mount mount) + { + return TexturesHelper.GetTextureFromIconId(mount.Icon); + } + else if (_lastUsedAction is Item item) + { + return TexturesHelper.GetTextureFromIconId(item.Icon); + } + else if (_lastUsedAction is Companion companion) + { + return TexturesHelper.GetTextureFromIconId(companion.Icon); + } + + return null; + } + } +} diff --git a/Helpers/LayoutHelper.cs b/Helpers/LayoutHelper.cs new file mode 100644 index 0000000..e36037f --- /dev/null +++ b/Helpers/LayoutHelper.cs @@ -0,0 +1,322 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Helpers +{ + public struct LayoutInfo + { + public readonly uint TotalRowCount; + public readonly uint TotalColCount; + public readonly uint RealRowCount; + public readonly uint RealColCount; + public readonly Vector2 ContentSize; + + public LayoutInfo(uint totalRowCount, uint totalColCount, uint realRowCount, uint realColCount, Vector2 contentSize) + { + TotalRowCount = totalRowCount; + TotalColCount = totalColCount; + RealRowCount = realRowCount; + RealColCount = realColCount; + ContentSize = contentSize; + } + } + + public static class LayoutHelper + { + // Calculates rows and columns. Used for status effect lists and party frames. + public static LayoutInfo CalculateLayout( + Vector2 maxSize, + Vector2 itemSize, + uint count, + Vector2 padding, + bool fillRowsFirst + ) + { + uint rowCount = 1; + uint colCount = 1; + uint realRowCount = 1; + uint realColCount = 1; + + if (maxSize.X < itemSize.X) + { + colCount = count; + realColCount = count; + } + else if (maxSize.Y < itemSize.Y) + { + rowCount = count; + realRowCount = count; + } + else + { + if (fillRowsFirst) + { + float remainingWidth = maxSize.X; + colCount = 0; + while (remainingWidth > 0) + { + remainingWidth -= (itemSize.X + padding.X); + colCount++; + } + + if (itemSize.X * colCount + padding.X * (colCount - 1) > maxSize.X) + { + colCount = Math.Max(1, colCount - 1); + } + + rowCount = (uint)Math.Ceiling((double)count / colCount); + + int remaining = (int)(count - colCount); + while (remaining > 0) + { + realRowCount++; + remaining -= (int)colCount; + } + + realColCount = Math.Min(count, colCount); + } + else + { + float remainingHeight = maxSize.Y; + rowCount = 0; + while (remainingHeight > 0) + { + remainingHeight -= (itemSize.Y + padding.Y); + rowCount++; + } + + if (itemSize.Y * rowCount + padding.Y * (rowCount - 1) > maxSize.Y) + { + rowCount = Math.Max(1, rowCount - 1); + } + + colCount = (uint)Math.Ceiling((double)count / rowCount); + + int remaining = (int)(count - rowCount); + while (remaining > 0) + { + realColCount++; + remaining -= (int)rowCount; + } + + realRowCount = Math.Min(count, rowCount); + } + } + + Vector2 contentSize = new Vector2( + realColCount * itemSize.X + (realColCount - 1) * padding.X, + realRowCount * itemSize.Y + (realRowCount - 1) * padding.Y + ); + + return new LayoutInfo(rowCount, colCount, realRowCount, realColCount, contentSize); + } + + private static List DirectionOptionsValues = new List() + { + GrowthDirections.Right | GrowthDirections.Down, + GrowthDirections.Right | GrowthDirections.Up, + GrowthDirections.Left | GrowthDirections.Down, + GrowthDirections.Left | GrowthDirections.Up, + GrowthDirections.Centered | GrowthDirections.Up, + GrowthDirections.Centered | GrowthDirections.Down, + GrowthDirections.Centered | GrowthDirections.Left, + GrowthDirections.Centered | GrowthDirections.Right + }; + public static GrowthDirections GrowthDirectionsFromIndex(int index) + { + if (index > 0 && index < DirectionOptionsValues.Count) + { + return DirectionOptionsValues[index]; + } + + return DirectionOptionsValues[0]; + } + + public static int IndexFromGrowthDirections(GrowthDirections directions) + { + int index = DirectionOptionsValues.FindIndex(d => d == directions); + + return index > 0 ? index : 0; + } + + public static bool GetFillsRowsFirst(bool fallback, GrowthDirections directions) + { + if ((directions & GrowthDirections.Centered) != 0) + { + if ((directions & GrowthDirections.Up) != 0 || (directions & GrowthDirections.Down) != 0) + { + return true; + } + else if ((directions & GrowthDirections.Left) != 0 || (directions & GrowthDirections.Right) != 0) + { + return false; + } + } + + return fallback; + } + + public static void CalculateAxisDirections( + GrowthDirections growthDirections, + int row, + int col, + uint elementCount, + Vector2 size, + Vector2 iconSize, + Vector2 iconPadding, + out Vector2 direction, + out Vector2 offset) + { + if ((growthDirections & GrowthDirections.Centered) != 0) + { + if ((growthDirections & GrowthDirections.Up) != 0 || (growthDirections & GrowthDirections.Down) != 0) + { + int elementsPerRow = (int)(size.X / (iconSize.X + iconPadding.X)); + long elementsInRow = Math.Min(elementsPerRow, elementCount - (elementsPerRow * row)); + + direction.X = 1; + direction.Y = (growthDirections & GrowthDirections.Down) != 0 ? 1 : -1; + offset.X = -(iconSize.X + iconPadding.X) * elementsInRow / 2f; + offset.Y = direction.Y == 1 ? 0 : -iconSize.Y; + } + + else// if ((growthDirections & GrowthDirections.Left) != 0 || (growthDirections & GrowthDirections.Right) != 0) + { + int elementsPerCol = (int)(size.Y / (iconSize.Y + iconPadding.Y)); + long elementsInCol = Math.Min(elementsPerCol, elementCount - (elementsPerCol * col)); + + direction.X = (growthDirections & GrowthDirections.Left) != 0 ? -1 : 1; + direction.Y = 1; + offset.X = direction.X == 1 ? 0 : -iconSize.X; + offset.Y = -(iconSize.Y + iconPadding.Y) * elementsInCol / 2f; + } + } + else + { + direction.X = (growthDirections & GrowthDirections.Right) != 0 ? 1 : -1; + direction.Y = (growthDirections & GrowthDirections.Down) != 0 ? 1 : -1; + offset.X = direction.X == 1 ? 0 : -iconSize.X; + offset.Y = direction.Y == 1 ? 0 : -iconSize.Y; + } + } + + public static Vector2 CalculateStartPosition(Vector2 position, Vector2 size, GrowthDirections growthDirections) + { + Vector2 area = size; + if ((growthDirections & GrowthDirections.Left) != 0) + { + area.X = -area.X; + } + + if ((growthDirections & GrowthDirections.Up) != 0) + { + area.Y = -area.Y; + } + + Vector2 startPos = position; + if ((growthDirections & GrowthDirections.Centered) != 0) + { + if ((growthDirections & GrowthDirections.Up) != 0 || (growthDirections & GrowthDirections.Down) != 0) + { + startPos.X = position.X - size.X / 2f; + } + else if ((growthDirections & GrowthDirections.Left) != 0 || (growthDirections & GrowthDirections.Right) != 0) + { + startPos.Y = position.Y - size.Y / 2f; + } + } + + Vector2 endPos = position + area; + + if (endPos.X < position.X) + { + startPos.X = endPos.X; + } + + if (endPos.Y < position.Y) + { + startPos.Y = endPos.Y; + } + + return startPos; + } + + public static (List, Vector2, Vector2) CalculateIconPositions( + GrowthDirections directions, + uint count, + Vector2 position, + Vector2 size, + Vector2 iconSize, + Vector2 iconPadding, + bool fillRowsFirst, + LayoutInfo layoutInfo) + { + List list = new List(); + Vector2 minPos = new Vector2(float.MaxValue, float.MaxValue); + Vector2 maxPos = Vector2.Zero; + + int row = 0; + int col = 0; + + for (int i = 0; i < count; i++) + { + CalculateAxisDirections( + directions, + row, + col, + count, + size, + iconSize, + iconPadding, + out Vector2 direction, + out Vector2 offset + ); + + Vector2 pos = new Vector2( + position.X + offset.X + iconSize.X * col * direction.X + iconPadding.X * col * direction.X, + position.Y + offset.Y + iconSize.Y * row * direction.Y + iconPadding.Y * row * direction.Y + ); + + minPos.X = Math.Min(pos.X, minPos.X); + minPos.Y = Math.Min(pos.Y, minPos.Y); + maxPos.X = Math.Max(pos.X + iconSize.X, maxPos.X); + maxPos.Y = Math.Max(pos.Y + iconSize.Y, maxPos.Y); + + list.Add(pos); + + // rows / columns + if (fillRowsFirst) + { + col += 1; + if (col >= layoutInfo.TotalColCount) + { + col = 0; + row += 1; + } + } + else + { + row += 1; + if (row >= layoutInfo.TotalRowCount) + { + row = 0; + col += 1; + } + } + } + + return (list, minPos, maxPos); + } + } + + [Flags] + public enum GrowthDirections : short + { + Up = 1, + Down = 2, + Left = 4, + Right = 8, + Centered = 16, + } +} diff --git a/Helpers/MpTickHelper.cs b/Helpers/MpTickHelper.cs new file mode 100644 index 0000000..d3e6943 --- /dev/null +++ b/Helpers/MpTickHelper.cs @@ -0,0 +1,98 @@ +/* +Copyright(c) 2021 talimity (https://github.com/talimity/mptimer) +Modifications Copyright(c) 2021 HSUI +08/29/2021 - Mostly using original's code with minimal adaptations +for HSUI. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using Dalamud.Bindings.ImGui; +using System; +using System.Linq; +using Dalamud.Game; +using Dalamud.Plugin.Services; + +namespace HSUI.Helpers +{ + internal class MPTickHelper : IDisposable + { + public const double ServerTickRate = 3; + protected const float PollingRate = 1 / 30f; + private int _lastMpValue = -1; + protected double LastTickTime; + protected double LastUpdate; + + public MPTickHelper() + { + Plugin.Framework.Update += FrameworkOnOnUpdateEvent; + } + + public double LastTick => LastTickTime; + + private void FrameworkOnOnUpdateEvent(IFramework framework) + { + var player = Plugin.ObjectTable.LocalPlayer; + if (player is null) + { + return; + } + + var now = ImGui.GetTime(); + if (now - LastUpdate < PollingRate) + { + return; + } + + LastUpdate = now; + + var mp = player.CurrentMp; + + // account for lucid dreaming screwing up mp calculations + var lucidDreamingActive = Utils.StatusListForBattleChara(player).Any(e => e.StatusId == 1204); + + if (!lucidDreamingActive && _lastMpValue < mp) + { + LastTickTime = now; + } + else if (LastTickTime + ServerTickRate <= now) + { + LastTickTime += ServerTickRate; + } + + _lastMpValue = (int)mp; + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Plugin.Framework.Update -= FrameworkOnOnUpdateEvent; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + ~MPTickHelper() + { + Dispose(false); + } + } +} diff --git a/Helpers/PetRenamerHelper.cs b/Helpers/PetRenamerHelper.cs new file mode 100644 index 0000000..32b6132 --- /dev/null +++ b/Helpers/PetRenamerHelper.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.Types; + +namespace HSUI.Helpers +{ + internal class PetRenamerHelper + { + private Dictionary? PetNicknamesDictionary; + + #region Singleton + public static void Initialize() { Instance = new PetRenamerHelper(); } + + public static PetRenamerHelper Instance { get; private set; } = null!; + + public PetRenamerHelper() + { + AssignShares(); + } + + ~PetRenamerHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Plugin.PluginInterface.RelinquishData("PetRenamer.GameObjectRenameDict"); + + Instance = null!; + } + #endregion + + private void AssignShares() + { + try + { + PetNicknamesDictionary = Plugin.PluginInterface.GetOrCreateData("PetRenamer.GameObjectRenameDict", () => new Dictionary()); + } + catch { } + } + + private string? GetNameForActor(IGameObject actor) + { + if (PetNicknamesDictionary == null) + { + return null; + } + + if (PetNicknamesDictionary.TryGetValue(actor.GameObjectId, out string? nickname)) + { + return nickname; + } + + return null; + } + + public string? GetPetName(IGameObject? actor) + { + if (actor == null) + { + return null; + } + + if (actor.ObjectKind != ObjectKind.Companion && actor.ObjectKind != ObjectKind.BattleNpc) + { + return null; + } + + return GetNameForActor(actor); + } + } +} diff --git a/Helpers/PullTimerHelper.cs b/Helpers/PullTimerHelper.cs new file mode 100644 index 0000000..64f7db5 --- /dev/null +++ b/Helpers/PullTimerHelper.cs @@ -0,0 +1,234 @@ +/* +Copyright(c) 2021 xorus (https://github.com/xorus/EngageTimer) +Modifications Copyright(c) 2021 HSUI +09/21/2021 - Extracted code to hook the game's pulltimer functions. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.Runtime.InteropServices; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Hooking; +using Dalamud.Logging; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +namespace HSUI.Helpers +{ + public unsafe class PullTimerHelper + { + #region Singleton + private PullTimerHelper() + { + PullTimerState = new PullTimerState(); + + try + { + _countdownTimerHook = Plugin.GameInteropProvider.HookFromAddress( + AgentModule.Instance()->GetAgentByInternalId(AgentId.CountDownSettingDialog)->VirtualTable->Update, + CountdownTimerFunc); + _countdownTimerHook?.Enable(); + } + catch + { + Plugin.Logger.Error("PullTimeHelper CountdownTimer Hook failed!!!"); + } + } + public static void Initialize() { Instance = new PullTimerHelper(); } + public static PullTimerHelper Instance { get; private set; } = null!; + + ~PullTimerHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _countdownTimerHook?.Disable(); + _countdownTimerHook?.Dispose(); + + Instance = null!; + } + #endregion + + private DateTime _combatTimeEnd; + private DateTime _combatTimeStart; + + private ulong _agentData; + public bool CountDownRunning; + + private int _countDownStallTicks; + + private readonly Hook? _countdownTimerHook; + public float LastCountDownValue; + private bool _shouldRestartCombatTimer = true; + private bool _lastMaxValueSet = false; + + public readonly PullTimerState PullTimerState; + + public void Update() + { + if (PullTimerState.Mocked) + { + return; + } + + UpdateCountDown(); + UpdateEncounterTimer(); + PullTimerState.InInstance = Plugin.Condition[ConditionFlag.BoundByDuty]; + } + + private void CountdownTimerFunc(AgentInterface* agentInterface, uint frameCount) + { + _agentData = (ulong)agentInterface; + _countdownTimerHook?.Original(agentInterface, frameCount); + } + + private void UpdateEncounterTimer() + { + if (Plugin.Condition[ConditionFlag.InCombat]) + { + PullTimerState.InCombat = true; + if (_shouldRestartCombatTimer) + { + _shouldRestartCombatTimer = false; + _combatTimeStart = DateTime.Now; + } + + _combatTimeEnd = DateTime.Now; + } + else + { + PullTimerState.InCombat = false; + _shouldRestartCombatTimer = true; + } + + PullTimerState.CombatStart = _combatTimeStart; + PullTimerState.CombatDuration = _combatTimeEnd - _combatTimeStart; + PullTimerState.CombatEnd = _combatTimeEnd; + } + + private void UpdateCountDown() + { + PullTimerState.CountingDown = false; + + if (_agentData == 0) + { + return; + } + + byte countdownActive = Marshal.PtrToStructure((IntPtr)_agentData + 0x38); + if (countdownActive == 0) + { + _lastMaxValueSet = false; + return; + } + + float countDownPointerValue = Marshal.PtrToStructure((IntPtr)_agentData + 0x2c); + + // is last value close enough (workaround for floating point approx) + if (Math.Abs(countDownPointerValue - LastCountDownValue) < 0.001f) + { + _countDownStallTicks++; + } + else + { + _countDownStallTicks = 0; + CountDownRunning = true; + } + + if (_countDownStallTicks > 50) + { + CountDownRunning = false; + } + + if (countDownPointerValue > 0 && CountDownRunning) + { + PullTimerState.CountDownValue = countDownPointerValue; + PullTimerState.CountingDown = true; + } + + if (!_lastMaxValueSet && CountDownRunning) + { + PullTimerState.CountDownMax = countDownPointerValue; + _lastMaxValueSet = true; + } + else if (_lastMaxValueSet && countDownPointerValue <= 0) + { + _lastMaxValueSet = false; + } + + LastCountDownValue = countDownPointerValue; + } + } + + public class PullTimerState + { + private bool _inCombat; + private bool _countingDown; + public TimeSpan CombatDuration { get; set; } + public DateTime CombatEnd { get; set; } + public DateTime CombatStart { get; set; } + + public bool Mocked { get; set; } + + public bool InCombat + { + get => _inCombat; + set + { + if (_inCombat == value) + { + return; + } + + _inCombat = value; + InCombatChanged?.Invoke(this, EventArgs.Empty); + } + } + + public bool CountingDown + { + get => _countingDown; + set + { + if (_countingDown == value) + { + return; + } + + _countingDown = value; + CountingDownChanged?.Invoke(this, EventArgs.Empty); + } + } + + public bool InInstance { get; set; } + + public float CountDownValue { get; set; } = 0f; + public float CountDownMax { get; set; } = 0f; + public event EventHandler? InCombatChanged; + public event EventHandler? CountingDownChanged; + } +} diff --git a/Helpers/SmoothHPHelper.cs b/Helpers/SmoothHPHelper.cs new file mode 100644 index 0000000..3621e56 --- /dev/null +++ b/Helpers/SmoothHPHelper.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HSUI.Helpers +{ + public class SmoothHPHelper + { + private float? _startHp; + private float? _targetHp; + private float? _lastHp; + + public void Reset() + { + _startHp = null; + _targetHp = null; + _lastHp = null; + } + + public uint GetNextHp(int currentHp, int maxHp, float velocity) + { + if (!_startHp.HasValue || !_targetHp.HasValue || !_lastHp.HasValue) + { + _lastHp = currentHp; + _startHp = currentHp; + _targetHp = currentHp; + } + + if (currentHp != _lastHp) + { + _startHp = _lastHp; + _targetHp = currentHp; + } + + if (_startHp.HasValue && _targetHp.HasValue) + { + float delta = _targetHp.Value - _startHp.Value; + float offset = delta * velocity / 100f; + _startHp = Math.Clamp(_startHp.Value + offset, 0, maxHp); + } + + _lastHp = currentHp; + return _startHp.HasValue ? (uint)_startHp.Value : (uint)currentHp; + } + } +} diff --git a/Helpers/SpellHelper.cs b/Helpers/SpellHelper.cs new file mode 100644 index 0000000..e8b0438 --- /dev/null +++ b/Helpers/SpellHelper.cs @@ -0,0 +1,76 @@ +using FFXIVClientStructs.FFXIV.Client.Game; +using System; + +namespace HSUI.Helpers +{ + internal class SpellHelper + { + #region Singleton + private static Lazy _lazyInstance = new Lazy(() => new SpellHelper()); + + public static SpellHelper Instance => _lazyInstance.Value; + + ~SpellHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _lazyInstance = new Lazy(() => new SpellHelper()); + } + #endregion + + private readonly unsafe ActionManager* _actionManager; + + public unsafe SpellHelper() + { + _actionManager = ActionManager.Instance(); + } + + public unsafe uint GetSpellActionId(uint actionId) => _actionManager->GetAdjustedActionId(actionId); + + public unsafe float GetRecastTimeElapsed(uint actionId) => _actionManager->GetRecastTimeElapsed(ActionType.Action, GetSpellActionId(actionId)); + public unsafe float GetRealRecastTimeElapsed(uint actionId) => _actionManager->GetRecastTimeElapsed(ActionType.Action, actionId); + + public unsafe float GetRecastTime(uint actionId) => _actionManager->GetRecastTime(ActionType.Action, GetSpellActionId(actionId)); + public unsafe float GetRealRecastTime(uint actionId) => _actionManager->GetRecastTime(ActionType.Action, actionId); + + public unsafe uint GetLastUsedActionId() => _actionManager->Combo.Action; + + public float GetSpellCooldown(uint actionId) => Math.Abs(GetRecastTime(GetSpellActionId(actionId)) - GetRecastTimeElapsed(GetSpellActionId(actionId))); + public float GetRealSpellCooldown(uint actionId) => Math.Abs(GetRealRecastTime(actionId) - GetRealRecastTimeElapsed(actionId)); + + public int GetSpellCooldownInt(uint actionId) + { + int cooldown = (int)Math.Ceiling(GetSpellCooldown(actionId) % GetRecastTime(actionId)); + return Math.Max(0, cooldown); + } + + public int GetStackCount(int maxStacks, uint actionId) + { + int cooldown = GetSpellCooldownInt(actionId); + float recastTime = GetRecastTime(actionId); + + if (cooldown <= 0 || recastTime == 0) + { + return maxStacks; + } + + return maxStacks - (int)Math.Ceiling(cooldown / (recastTime / maxStacks)); + } + + public unsafe bool IsActionHighlighted(uint actionId, ActionType type = ActionType.Action) => _actionManager->IsActionHighlighted(type, actionId); + } +} diff --git a/Helpers/TextTagsHelper.cs b/Helpers/TextTagsHelper.cs new file mode 100644 index 0000000..88f9f3f --- /dev/null +++ b/Helpers/TextTagsHelper.cs @@ -0,0 +1,531 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using StructsBattleChara = FFXIVClientStructs.FFXIV.Client.Game.Character.BattleChara; + +namespace HSUI.Helpers +{ + public static class TextTagsHelper + { + public static void Initialize() + { + foreach (string key in HealthTextTags.Keys) + { + CharaTextTags.Add(key, (chara) => HealthTextTags[key](chara.CurrentHp, chara.MaxHp)); + } + + foreach (string key in ManaTextTags.Keys) + { + CharaTextTags.Add(key, (chara) => ManaTextTags[key](JobsHelper.CurrentPrimaryResource(chara), JobsHelper.MaxPrimaryResource(chara))); + } + } + + public static Dictionary> TextTags = new Dictionary>() + { + #region generic names + ["[name]"] = (actor, name, length, isPlayerName) => + ValidateName(actor, name). + Truncated(length). + CheckForUpperCase(), + + ["[name:first]"] = (actor, name, length, isPlayerName) => + ValidateName(actor, name). + FirstName(). + Truncated(length). + CheckForUpperCase(), + + ["[name:last]"] = (actor, name, length, isPlayerName) => + ValidateName(actor, name). + LastName(). + Truncated(length). + CheckForUpperCase(), + + ["[name:initials]"] = (actor, name, length, isPlayerName) => + ValidateName(actor, name). + Initials(). + Truncated(length). + CheckForUpperCase(), + + ["[name:abbreviate]"] = (actor, name, length, isPlayerName) => + ValidateName(actor, name). + Abbreviate(). + CheckForUpperCase(), + #endregion + + #region player names + ["[player_name]"] = (actor, name, length, isPlayerName) => + ValidatePlayerName(actor, name, isPlayerName). + Truncated(length). + CheckForUpperCase(), + + ["[player_name:first]"] = (actor, name, length, isPlayerName) => + ValidatePlayerName(actor, name, isPlayerName). + FirstName(). + Truncated(length). + CheckForUpperCase(), + + ["[player_name:last]"] = (actor, name, length, isPlayerName) => + ValidatePlayerName(actor, name, isPlayerName). + LastName(). + Truncated(length). + CheckForUpperCase(), + + ["[player_name:initials]"] = (actor, name, length, isPlayerName) => + ValidatePlayerName(actor, name, isPlayerName). + Initials(). + Truncated(length). + CheckForUpperCase(), + #endregion + + #region npc names + ["[npc_name]"] = (actor, name, length, isPlayerName) => + ValidateNPCName(actor, name, isPlayerName). + CheckForUpperCase(), + + ["[npc_name:first]"] = (actor, name, length, isPlayerName) => + ValidateNPCName(actor, name, isPlayerName). + FirstName(). + CheckForUpperCase(), + + ["[npc_name:last]"] = (actor, name, length, isPlayerName) => + ValidateNPCName(actor, name, isPlayerName). + LastName(). + CheckForUpperCase(), + + ["[npc_name:initials]"] = (actor, name, length, isPlayerName) => + ValidateNPCName(actor, name, isPlayerName). + Initials(). + CheckForUpperCase(), + #endregion + }; + + public static Dictionary> ExpTags = new Dictionary>() + { + #region experience + ["[exp:current]"] = (actor, name) => ExperienceHelper.Instance.CurrentExp.ToString(), + + ["[exp:current-formatted]"] = (actor, name) => ExperienceHelper.Instance.CurrentExp.ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[exp:current-short]"] = (actor, name) => ExperienceHelper.Instance.CurrentExp.KiloFormat(), + + ["[exp:required]"] = (actor, name) => ExperienceHelper.Instance.RequiredExp.ToString(), + + ["[exp:required-formatted]"] = (actor, name) => ExperienceHelper.Instance.RequiredExp.ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[exp:required-short]"] = (actor, name) => ExperienceHelper.Instance.RequiredExp.KiloFormat(), + + ["[exp:required-to-level]"] = (actor, name) => (ExperienceHelper.Instance.RequiredExp - ExperienceHelper.Instance.CurrentExp).ToString(), + + ["[exp:required-to-level-formatted]"] = (actor, name) => (ExperienceHelper.Instance.RequiredExp - ExperienceHelper.Instance.CurrentExp).ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[exp:required-to-level-short]"] = (actor, name) => (ExperienceHelper.Instance.RequiredExp - ExperienceHelper.Instance.CurrentExp).KiloFormat(), + + ["[exp:rested]"] = (actor, name) => ExperienceHelper.Instance.RestedExp.ToString(), + + ["[exp:rested-formatted]"] = (actor, name) => ExperienceHelper.Instance.RestedExp.ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[exp:rested-short]"] = (actor, name) => ExperienceHelper.Instance.RestedExp.KiloFormat(), + + ["[exp:percent]"] = (actor, name) => ExperienceHelper.Instance.PercentExp.ToString("N0"), + + ["[exp:percent-decimal]"] = (actor, name) => ExperienceHelper.Instance.PercentExp.ToString("N1", ConfigurationManager.Instance.ActiveCultreInfo), + #endregion + }; + + public static Dictionary> HealthTextTags = new Dictionary>() + { + #region health + ["[health:current]"] = (currentHp, maxHp) => currentHp.ToString(), + + ["[health:current-formatted]"] = (currentHp, maxHp) => currentHp.ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[health:current-short]"] = (currentHp, maxHp) => currentHp.KiloFormat(), + + ["[health:current-percent]"] = (currentHp, maxHp) => currentHp == maxHp ? "100" : (100f * currentHp / Math.Max(1, maxHp)).ToString("N0"), + + ["[health:current-percent-short]"] = (currentHp, maxHp) => currentHp == maxHp ? currentHp.KiloFormat() : (100f * currentHp / Math.Max(1, maxHp)).ToString("N0"), + + ["[health:max]"] = (currentHp, maxHp) => maxHp.ToString(), + + ["[health:max-formatted]"] = (currentHp, maxHp) => maxHp.ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[health:max-short]"] = (currentHp, maxHp) => maxHp.KiloFormat(), + + ["[health:percent]"] = (currentHp, maxHp) => (100f * currentHp / Math.Max(1, maxHp)).ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[health:percent-hidden]"] = (currentHp, maxHp) => currentHp == (0 | maxHp) ? "" : (100f * currentHp / Math.Max(1, maxHp)).ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[health:percent-decimal]"] = (currentHp, maxHp) => (100f * currentHp / Math.Max(1f, maxHp)).ToString("N1", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[health:percent-decimal-uniform]"] = (currentHp, maxHp) => ConsistentDigitPercentage(currentHp, maxHp), + + ["[health:deficit]"] = (currentHp, maxHp) => currentHp == maxHp ? "0" : $"-{maxHp - currentHp}", + + ["[health:deficit-formatted]"] = (currentHp, maxHp) => currentHp == maxHp ? "0" : "-" + (maxHp - currentHp).ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[health:deficit-short]"] = (currentHp, maxHp) => currentHp == maxHp ? "0" : $"-{(maxHp - currentHp).KiloFormat()}", + #endregion + }; + + public static Dictionary> ManaTextTags = new Dictionary>() + { + #region mana + ["[mana:current]"] = (currentMp, maxMp) => currentMp.ToString(), + + ["[mana:current-formatted]"] = (currentMp, maxMp) => currentMp.ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[mana:current-short]"] = (currentMp, maxMp) => currentMp.KiloFormat(), + + ["[mana:current-percent]"] = (currentMp, maxMp) => currentMp == maxMp ? "100" : (100f * currentMp / Math.Max(1, maxMp)).ToString("N0"), + + ["[mana:current-percent-short]"] = (currentMp, maxMp) => currentMp == maxMp ? currentMp.KiloFormat() : (100f * currentMp / Math.Max(1, maxMp)).ToString("N0"), + + ["[mana:max]"] = (currentMp, maxMp) => maxMp.ToString(), + + ["[mana:max-formatted]"] = (currentMp, maxMp) => maxMp.ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[mana:max-short]"] = (currentMp, maxMp) => maxMp.KiloFormat(), + + ["[mana:percent]"] = (currentMp, maxMp) => (100f * currentMp / Math.Max(1, maxMp)).ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[mana:percent-decimal]"] = (currentMp, maxMp) => (100f * currentMp / Math.Max(1, maxMp)).ToString("N1", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[mana:percent-decimal-uniform]"] = (currentMp, maxMp) => ConsistentDigitPercentage(currentMp, maxMp), + + ["[mana:deficit]"] = (currentMp, maxMp) => currentMp == maxMp ? "0" : $"-{maxMp - currentMp}", + + ["[mana:deficit-formatted]"] = (currentMp, maxMp) => currentMp == maxMp ? "0" : "-" + (maxMp - currentMp).ToString("N0", ConfigurationManager.Instance.ActiveCultreInfo), + + ["[mana:deficit-short]"] = (currentMp, maxMp) => currentMp == maxMp ? "0" : $"-{(maxMp - currentMp).KiloFormat()}", + #endregion + }; + + public static Dictionary> CharaTextTags = new Dictionary>() + { + #region misc + ["[distance]"] = (chara) => (chara.YalmDistanceX + 1).ToString(), + + ["[company]"] = (chara) => chara.CompanyTag.ToString(), + + ["[company-formatted]"] = (chara) => !String.IsNullOrEmpty(chara.CompanyTag.ToString()) ? $"«{chara.CompanyTag}»" : "", + + ["[level]"] = (chara) => chara.Level > 0 ? chara.Level.ToString() : "-", + + ["[level:adjusted]"] = (chara) => + { + if (chara is IBattleChara npc) + { + return GetZoneAdjustedLevel(npc, chara.Level); + } + + return chara.Level > 0 ? chara.Level.ToString() : "-"; + }, + + ["[level:hidden]"] = (chara) => (chara.Level > 1 && chara.Level < 100) ? chara.Level.ToString() : "", + + ["[job]"] = (chara) => JobsHelper.JobNames.TryGetValue(chara.ClassJob.RowId, out var jobName) ? jobName : "", + + ["[job-full]"] = (chara) => JobsHelper.JobFullNames.TryGetValue(chara.ClassJob.RowId, out var jobName) ? jobName : "", + + ["[time-till-max-gp]"] = JobsHelper.TimeTillMaxGP, + + ["[chocobo-time]"] = (chara) => + { + unsafe + { + if (chara is IBattleNpc npc && npc.BattleNpcKind == BattleNpcSubKind.Chocobo) + { + float seconds = UIState.Instance()->Buddy.CompanionInfo.TimeLeft; + if (seconds <= 0) + { + return ""; + } + + TimeSpan time = TimeSpan.FromSeconds(seconds); + return time.ToString(@"mm\:ss"); + } + } + return ""; + } + #endregion + }; + + public static Dictionary> TitleTextTags = new Dictionary>() + { + #region title + ["[title]"] = (title, length) => title.Truncated(length).CheckForUpperCase(), + + ["[title:first]"] = (title, length) => title.FirstName().Truncated(length).CheckForUpperCase(), + + ["[title:last]"] = (title, length) => title.LastName().Truncated(length).CheckForUpperCase(), + + ["[title:initials]"] = (title, length) => title.Initials().Truncated(length).CheckForUpperCase(), + #endregion + }; + + private static List>> NumericValuesTagMaps = new List>>() + { + HealthTextTags, + ManaTextTags + }; + + private static string ReplaceTagWithString( + string tag, + IGameObject? actor, + string? name = null, + uint? current = null, + uint? max = null, + bool? isPlayerName = null, + string? title = null) + { + int length = 0; + ParseLength(ref tag, ref length); + + if (TextTags.TryGetValue(tag, out Func? func) && func != null) + { + return func(actor, name, length, isPlayerName); + } + + if (ExpTags.TryGetValue(tag, out Func? expFunc) && expFunc != null) + { + return expFunc(actor, name); + } + + if (actor is ICharacter chara && + CharaTextTags.TryGetValue(tag, out Func? charaFunc) && charaFunc != null) + { + return charaFunc(chara); + } + else if (current.HasValue && max.HasValue) + { + foreach (var map in NumericValuesTagMaps) + { + if (map.TryGetValue(tag, out Func? numericFunc) && numericFunc != null) + { + return numericFunc(current.Value, max.Value); + } + } + } + + if (title != null && + TitleTextTags.TryGetValue(tag, out Func? titlefunc) && titlefunc != null) + { + return titlefunc(title, length); + } + + return ""; + } + + public static string FormattedText( + string text, + IGameObject? actor, + string? name = null, + uint? current = null, + uint? max = null, + bool? isPlayerName = null, + string? title = null) + { + bool isPlayer = (isPlayerName.HasValue && isPlayerName.Value == true) || + (actor != null && actor.ObjectKind == ObjectKind.Player); + + try + { + // grouping + List groups = ParseGroups(text); + string result = ""; + + foreach (string group in groups) + { + // tags + string groupText = ParseGroup(group, isPlayer); + + MatchCollection matches = Regex.Matches(groupText, @"\[(.*?)\]"); + string formattedGroupText = matches.Aggregate(groupText, (c, m) => + { + string formattedText = ReplaceTagWithString(m.Value, actor, name, current, max, isPlayerName, title); + return c.Replace(m.Value, formattedText); + }); + + result += formattedGroupText; + } + + return result; + } + catch (Exception e) + { + Plugin.Logger.Error(e.Message); + return text; + } + } + + private static List ParseGroups(string text) + { + MatchCollection matches = Regex.Matches(text, @"\{(.*?)\}"); + if (matches.Count == 0) + { + return new List() { text }; + } + + List result = new List(); + int index = 0; + + foreach (Match match in matches) + { + if (index < match.Index) + { + result.Add(text.Substring(0, match.Index - index)); + } + + result.Add(text.Substring(match.Index, match.Length)); + index = match.Index + match.Length; + } + + if (index < text.Length) + { + result.Add(text.Substring(index)); + } + + return result; + } + + private static string ParseGroup(string text, bool isPlayer) + { + if (!text.Contains("=")) + { + return text; + } + + if (isPlayer) + { + if (text.StartsWith("{player=")) + { + text = text.Substring(8); + } + else + { + return ""; + } + } + else + { + if (text.StartsWith("{npc=")) + { + text = text.Substring(5); + } + else + { + return ""; + } + } + + int groupEndIndex = text.IndexOf("}"); + if (groupEndIndex > 0) + { + text = text.Remove(groupEndIndex, 1); + } + + return text; + } + + private static void ParseLength(ref string tag, ref int length) + { + int index = tag.IndexOf("."); + if (index != -1) + { + string lengthString = tag.Substring(index + 1); + lengthString = lengthString.Substring(0, lengthString.Length - 1); + + try + { + length = int.Parse(lengthString); + } + catch { } + + tag = tag.Substring(0, tag.Length - lengthString.Length - 2) + "]"; + } + } + + private static string ValidateName(IGameObject? actor, string? name) + { + string? n = actor?.Name.ToString() ?? name; + + // Detour for PetRenamer + try + { + string? customPetName = PetRenamerHelper.Instance.GetPetName(actor); + n = customPetName ?? n; + } + catch { } + + return (n == null || n == "") ? "" : n; + } + + private static string ValidatePlayerName(IGameObject? actor, string? name, bool? isPlayerName = null) + { + if (isPlayerName.HasValue && isPlayerName.Value == false) + { + return ""; + } + else if (!isPlayerName.HasValue && actor?.ObjectKind != ObjectKind.Player) + { + return ""; + } + + return ValidateName(actor, name); + } + + private static string ValidateNPCName(IGameObject? actor, string? name, bool? isPlayerName = null) + { + if (isPlayerName.HasValue && isPlayerName.Value == true) + { + return ""; + } + else if (!isPlayerName.HasValue && actor?.ObjectKind == ObjectKind.Player) + { + return ""; + } + + return ValidateName(actor, name); + } + + private static string ConsistentDigitPercentage(float currentVal, float maxVal) + { + var rawPercentage = 100f * currentVal / Math.Max(1f, maxVal); + return rawPercentage >= 100 || rawPercentage <= 0 ? rawPercentage.ToString("N0") : rawPercentage.ToString("N1"); + } + + private static string GetZoneAdjustedLevel(IBattleChara? npc, int fallbackLevel) + { + if (npc == null) + { + return fallbackLevel > 0 ? fallbackLevel.ToString() : "-"; + } + + try + { + unsafe + { + StructsBattleChara* battleChara = (StructsBattleChara*)npc.Address; + ForayInfo forayInfo = battleChara->ForayInfo; + + if (forayInfo.Level > 0) + { + return forayInfo.Level.ToString(); + } + } + } + catch + { + Plugin.Logger.Error("Error in getting ZoneAdjustedLevel"); + } + + return fallbackLevel > 0 ? fallbackLevel.ToString() : "-"; + } + } +} \ No newline at end of file diff --git a/Helpers/TexturesHelper.cs b/Helpers/TexturesHelper.cs new file mode 100644 index 0000000..112f951 --- /dev/null +++ b/Helpers/TexturesHelper.cs @@ -0,0 +1,34 @@ +using Dalamud.Interface.Internal; +using Dalamud.Interface.Textures; +using Dalamud.Interface.Textures.TextureWraps; +using Lumina.Excel; +using static Dalamud.Plugin.Services.ITextureProvider; + +namespace HSUI.Helpers +{ + public class TexturesHelper + { + public static IDalamudTextureWrap? GetTexture(uint rowId, uint stackCount = 0, bool hdIcon = true) where T : struct, IExcelRow + { + ExcelSheet sheet = Plugin.DataManager.GetExcelSheet(); + return sheet == null ? null : GetTexture(sheet.GetRow(rowId), stackCount, hdIcon); + } + + public static IDalamudTextureWrap? GetTexture(dynamic row, uint stackCount = 0, bool hdIcon = true) where T : struct, IExcelRow + { + dynamic iconId = row.Icon; + return GetTextureFromIconId(iconId, stackCount, hdIcon); + } + + public static IDalamudTextureWrap? GetTextureFromIconId(uint iconId, uint stackCount = 0, bool hdIcon = true) + { + GameIconLookup lookup = new GameIconLookup(iconId + stackCount, false, hdIcon); + return Plugin.TextureProvider.GetFromGameIcon(lookup).GetWrapOrDefault(); + } + + public static IDalamudTextureWrap? GetTextureFromPath(string path) + { + return Plugin.TextureProvider.GetFromGame(path).GetWrapOrDefault(); + } + } +} diff --git a/Helpers/TooltipsHelper.cs b/Helpers/TooltipsHelper.cs new file mode 100644 index 0000000..c71ae0c --- /dev/null +++ b/Helpers/TooltipsHelper.cs @@ -0,0 +1,296 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Numerics; + +namespace HSUI.Helpers +{ + public class TooltipsHelper : IDisposable + { + #region Singleton + private TooltipsHelper() + { + } + + public static void Initialize() { Instance = new TooltipsHelper(); } + + public static TooltipsHelper Instance { get; private set; } = null!; + + ~TooltipsHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Instance = null!; + } + #endregion + + private float MaxWidth => 340 * ImGuiHelpers.GlobalScale; + private float Margin => 5 * ImGuiHelpers.GlobalScale; + + private TooltipsConfig _config => ConfigurationManager.Instance.GetConfigObject(); + + private string? _currentTooltipText = null; + private Vector2 _textSize; + private string? _currentTooltipTitle = null; + private Vector2 _titleSize; + private string? _previousRawText = null; + + private Vector2 _position; + private Vector2 _size; + + private bool _dataIsValid = false; + + public void ShowTooltipOnCursor(string text, string? title = null, uint id = 0, string name = "") + { + ShowTooltip(text, ImGui.GetMousePos(), title, id, name); + } + + public void ShowTooltip(string text, Vector2 position, string? title = null, uint id = 0, string name = "") + { + if (text == null) + { + if (_config.DebugTooltips) + Plugin.Logger.Information("[HSUI Tooltip DBG] ShowTooltip skipped: text is null"); + return; + } + + // remove styling tags from text + if (_previousRawText != text) + { + _currentTooltipText = text; + _previousRawText = text; + } + + // calcualte title size + _titleSize = Vector2.Zero; + if (title != null) + { + _currentTooltipTitle = title; + + if (_config.ShowSourceName && name.Length > 0) + { + _currentTooltipTitle += $" ({name})"; + } + + if (_config.ShowStatusIDs) + { + _currentTooltipTitle += " (ID: " + id + ")"; + } + + using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + { + _titleSize = ImGui.CalcTextSize(_currentTooltipTitle, false, MaxWidth); + _titleSize.Y += Margin; + } + } + + // calculate text size + using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + { + _textSize = ImGui.CalcTextSize(_currentTooltipText, false, MaxWidth); + } + + _size = new Vector2(Math.Max(_titleSize.X, _textSize.X) + Margin * 2, _titleSize.Y + _textSize.Y + Margin * 2); + + // position tooltip using the given coordinates as bottom center + position.X = position.X - _size.X / 2f; + position.Y = position.Y - _size.Y; + + // correct tooltips off screen + _position = ConstrainPosition(position, _size); + + _dataIsValid = true; + if (_config.DebugTooltips) + Plugin.Logger.Information($"[HSUI Tooltip DBG] ShowTooltip: title='{title}' textLen={text?.Length ?? 0} textPreview='{(text != null && text.Length > 80 ? text[..80] + "..." : text ?? "")}' pos=({_position.X:F0},{_position.Y:F0})"); + } + + public void RemoveTooltip() + { + _dataIsValid = false; + } + + public void Draw() + { + if (!_dataIsValid) + return; + if (ConfigurationManager.Instance.ShowingModalWindow) + { + if (_config.DebugTooltips) + Plugin.Logger.Information("[HSUI Tooltip DBG] Draw SKIPPED: ShowingModalWindow=true"); + return; + } + + // bg + ImGuiWindowFlags windowFlags = + ImGuiWindowFlags.NoTitleBar + | ImGuiWindowFlags.NoMove + | ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoInputs + | ImGuiWindowFlags.NoSavedSettings + | ImGuiWindowFlags.NoFocusOnAppearing; + + // imgui clips the left and right borders inside windows for some reason + // we make the window bigger so the actual drawable size is the expected one + var windowMargin = new Vector2(4, 0); + var windowPos = _position - windowMargin; + + ImGui.SetNextWindowPos(windowPos, ImGuiCond.Always); + ImGui.SetNextWindowSize(_size + windowMargin * 2); + + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0); + ImGui.Begin("DelvUI_tooltip", windowFlags); + var drawList = ImGui.GetWindowDrawList(); + + drawList.AddRectFilled(_position, _position + _size, _config.BackgroundColor.Base); + + if (_config.BorderConfig.Enabled) + { + drawList.AddRect(_position, _position + _size, _config.BorderConfig.Color.Base, 0, ImDrawFlags.None, _config.BorderConfig.Thickness); + } + + // no idea why i have to do this + float globalScaleCorrection = -15 + 15 * ImGuiHelpers.GlobalScale; + + if (_currentTooltipTitle != null) + { + // title + Vector2 cursorPos; + using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + { + cursorPos = new Vector2(windowMargin.X + _size.X / 2f - _titleSize.X / 2f, Margin); + ImGui.SetCursorPos(cursorPos); + ImGui.PushTextWrapPos(cursorPos.X + _titleSize.X + globalScaleCorrection + Margin); + ImGui.TextColored(_config.TitleColor.Vector, _currentTooltipTitle); + ImGui.PopTextWrapPos(); + } + + // text + using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + { + cursorPos = new Vector2(windowMargin.X + _size.X / 2f - _textSize.X / 2f, Margin + _titleSize.Y); + ImGui.SetCursorPos(cursorPos); + ImGui.PushTextWrapPos(cursorPos.X + _textSize.X + globalScaleCorrection + Margin); + ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText); + ImGui.PopTextWrapPos(); + } + } + else + { + // text + using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + { + var cursorPos = windowMargin + new Vector2(Margin, Margin); + var textWidth = _size.X - Margin * 2; + + ImGui.SetCursorPos(cursorPos); + ImGui.PushTextWrapPos(cursorPos.X + textWidth + globalScaleCorrection + Margin); + ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText); + ImGui.PopTextWrapPos(); + } + } + + ImGui.End(); + ImGui.PopStyleVar(); + + if (_config.DebugTooltips) + Plugin.Logger.Information($"[HSUI Tooltip DBG] Draw rendered tooltip at ({_position.X:F0},{_position.Y:F0})"); + RemoveTooltip(); + } + + private Vector2 ConstrainPosition(Vector2 position, Vector2 size) + { + var screenSize = ImGui.GetWindowViewport().Size; + + if (position.X < 0) + { + position.X = Margin; + } + else if (position.X + size.X > screenSize.X) + { + position.X = screenSize.X - size.X - Margin; + } + + if (position.Y < 0) + { + position.Y = Margin; + } + + return position; + } + } + + [Section("Misc")] + [SubSection("Tooltips", 0)] + public class TooltipsConfig : PluginConfigObject + { + public new static TooltipsConfig DefaultConfig() { return new TooltipsConfig(); } + + [Checkbox("Debug Tooltips")] + [Order(3)] + public bool DebugTooltips = false; + + [Checkbox("Show Status Effects IDs")] + [Order(5)] + public bool ShowStatusIDs = false; + + [Checkbox("Show Source Name")] + [Order(10)] + public bool ShowSourceName = false; + + [ColorEdit4("Background Color")] + [Order(15)] + public PluginConfigColor BackgroundColor = new PluginConfigColor(new(19f / 255f, 19f / 255f, 19f / 255f, 190f / 250f)); + + [ColorEdit4("Title Color")] + [Order(20)] + public PluginConfigColor TitleColor = new PluginConfigColor(new(255f / 255f, 210f / 255f, 31f / 255f, 100f / 100f)); + + [ColorEdit4("Text Color")] + [Order(35)] + public PluginConfigColor TextColor = new PluginConfigColor(new(255f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + + [NestedConfig("Border", 40, separator = false, spacing = true, collapsingHeader = false)] + public TooltipBorderConfig BorderConfig = new(); + } + + [Exportable(false)] + public class TooltipBorderConfig : PluginConfigObject + { + [ColorEdit4("Color")] + [Order(5)] + public PluginConfigColor Color = new(new Vector4(10f / 255f, 10f / 255f, 10f / 255f, 160f / 255f)); + + [DragInt("Thickness", min = 1, max = 100)] + [Order(10)] + public int Thickness = 4; + + public TooltipBorderConfig() + { + } + + public TooltipBorderConfig(PluginConfigColor color, int thickness) + { + Color = color; + Thickness = thickness; + } + } +} diff --git a/Helpers/Utils.cs b/Helpers/Utils.cs new file mode 100644 index 0000000..0280a26 --- /dev/null +++ b/Helpers/Utils.cs @@ -0,0 +1,429 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Statuses; +using Dalamud.Plugin.Services; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using StructsCharacter = FFXIVClientStructs.FFXIV.Client.Game.Character.Character; +using StructsCharacterManager = FFXIVClientStructs.FFXIV.Client.Game.Character.CharacterManager; +using StructsGameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; + +namespace HSUI.Helpers +{ + internal static class Utils + { + private static uint InvalidGameObjectId = 0xE0000000; + + public static IGameObject? GetBattleChocobo(IGameObject? player) + { + if (player == null) + { + return null; + } + + return GetBuddy(player.GameObjectId, BattleNpcSubKind.Chocobo); + } + + public static IGameObject? GetBuddy(ulong ownerId, BattleNpcSubKind kind) + { + // only the first 200 elements in the array are relevant due to the order in which SE packs data into the array + // we do a step of 2 because its always an actor followed by its companion + for (var i = 0; i < 200; i += 2) + { + var gameObject = Plugin.ObjectTable[i]; + + if (gameObject == null || gameObject.GameObjectId == InvalidGameObjectId || gameObject is not IBattleNpc battleNpc) + { + continue; + } + + if (battleNpc.BattleNpcKind == kind && battleNpc.OwnerId == ownerId) + { + return gameObject; + } + } + + return null; + } + + public static IGameObject? GetGameObjectByName(string name) + { + // only the first 200 elements in the array are relevant due to the order in which SE packs data into the array + // we do a step of 2 because its always an actor followed by its companion + for (int i = 0; i < 200; i += 2) + { + IGameObject? gameObject = Plugin.ObjectTable[i]; + + if (gameObject == null || gameObject.GameObjectId == InvalidGameObjectId || gameObject.GameObjectId == 0) + { + continue; + } + + if (gameObject.Name.ToString() == name) + { + return gameObject; + } + } + + return null; + } + + public static unsafe bool IsHostile(IGameObject obj) + { + StructsGameObject* gameObject = (StructsGameObject*)obj.Address; + byte plateType = gameObject->GetNamePlateColorType(); + + // 4, 5, 6: Enemy players in PvP + // 7: yellow, can be attacked, not engaged + // 8: dead + // 9: red, engaged with your party + // 10: purple, engaged with other party + // 11: orange, aggro'd to your party but not attacked yet + return plateType >= 4 && plateType <= 11; + } + + public static unsafe float ActorShieldValue(IGameObject? actor) + { + if (actor == null || actor is not ICharacter) + { + return 0f; + } + + StructsCharacter* chara = (StructsCharacter*)actor.Address; + return Math.Min(chara->CharacterData.ShieldValue, 100f) / 100f; + } + + public static bool IsActorCasting(IGameObject? actor) + { + if (actor is not IBattleChara chara) + { + return false; + } + + try + { + return chara.IsCasting; + } + catch { } + + return false; + } + + public static IEnumerable StatusListForActor(IGameObject? obj) + { + if (obj is IBattleChara chara) + { + return StatusListForBattleChara(chara); + } + + return new List(); + } + + public static IEnumerable StatusListForBattleChara(IBattleChara? chara) + { + List statusList = new List(); + if (chara == null) + { + return statusList; + } + + try + { + statusList = chara.StatusList.ToList(); + } + catch { } + + return statusList; + } + + public static string DurationToString(double duration, int decimalCount = 0) + { + if (duration == 0) + { + return ""; + } + + TimeSpan t = TimeSpan.FromSeconds(duration); + + if (t.Hours >= 1) { return t.Hours + "h"; } + if (t.Minutes >= 5) { return t.Minutes + "m"; } + if (t.Minutes >= 1) { return $"{t.Minutes}:{t.Seconds:00}"; } + + return duration.ToString("N" + decimalCount, ConfigurationManager.Instance.ActiveCultreInfo); + } + + public static IStatus? GetTankInvulnerabilityID(IBattleChara actor) + { + return StatusListForBattleChara(actor).FirstOrDefault(o => o.StatusId is 810 or 811 or 3255 or 1302 or 409 or 1836 or 82); + } + + public static bool IsOnCleanseJob() + { + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + + return player != null && JobsHelper.IsJobWithCleanse(player.ClassJob.RowId, player.Level); + } + + public static IGameObject? FindTargetOfTarget(IGameObject? target, IGameObject? player, IObjectTable actors) + { + if (target == null) + { + return null; + } + + // Dalamud for now has an issue where it is only able to get the target ID of + // NON-Networked objects through anything but GetTargetId on ClientStruct Gameobjects. + // The bypass converts all Dalamud GameObject Data to ClientStructs GameObject Data and handles it accordingly. + int actualTargetId = GetActualTargetId(target); + // The Object ID that gets returned from minions is in reality the index + // Checking for the correct object ID wouldn't work anyways as you would yet again run into the ObjectID = 0xE0000000 issue + if (actualTargetId >= 0 && actualTargetId < actors.Length) + { + return actors[actualTargetId]; + } + + if (target.TargetObjectId == 0 && player != null && player.TargetObjectId == 0) + { + return player; + } + + // only the first 200 elements in the array are relevant due to the order in which SE packs data into the array + // we do a step of 2 because its always an actor followed by its companion + for (int i = 0; i < 200; i += 2) + { + IGameObject? actor = actors[i]; + if (actor?.GameObjectId == target.TargetObjectId) + { + return actor; + } + } + + return null; + } + + /// + /// Gets the actual target ID of your targets target. + /// + /// Your target + /// Target ID of your targets targer. Returns -1 if old code should be ran. + private static unsafe int GetActualTargetId(IGameObject target) + { + // We only need to check for companions. + // Why not check target.TargetObject?.ObjectKind == ObjectKind.Companion? + // Due to the Non-Networked game object bug the game is unaware of what type the object should actually be + if (target.TargetObject?.ObjectKind != ObjectKind.Player) + { + return -1; + } + + // Here we get the ClientStruct Character of our target (aka the player we are targeting) + StructsCharacter targetChara = StructsCharacterManager.Instance()->LookupBattleCharaByEntityId(target.EntityId)->Character; + + // This method is key. GetTargetId() returns the targets player target ID. If it is converted to a hex string and starts with the number 4, it is a minion. + // Even though it is a minion, it still returns the players target ID. + ulong realTargetID = targetChara.GetTargetId(); + if (!realTargetID.ToString("X").StartsWith("4")) + { + return -1; + } + + // We look up the parents ClientStruct GameObject + StructsCharacter* realBattleChara = (StructsCharacter*)StructsCharacterManager.Instance()->LookupBattleCharaByEntityId((uint)realTargetID); + if (realBattleChara == null) + { + return -1; + } + + // And get the companion off of that + StructsGameObject* companionGameObject = (StructsGameObject*)realBattleChara->CompanionData.CompanionObject; + if (companionGameObject == null) + { + return -1; + } + + // We return the index of the object here. Why? + // Again due to the bug where ObjectID = 0xE0000000 + // The index does work and returns the exact minion index. + return companionGameObject->ObjectIndex; + } + + public static Vector2 GetAnchoredPosition(Vector2 position, Vector2 size, DrawAnchor anchor) + { + return anchor switch + { + DrawAnchor.Center => position - size / 2f, + DrawAnchor.Left => position + new Vector2(0, -size.Y / 2f), + DrawAnchor.Right => position + new Vector2(-size.X, -size.Y / 2f), + DrawAnchor.Top => position + new Vector2(-size.X / 2f, 0), + DrawAnchor.TopLeft => position, + DrawAnchor.TopRight => position + new Vector2(-size.X, 0), + DrawAnchor.Bottom => position + new Vector2(-size.X / 2f, -size.Y), + DrawAnchor.BottomLeft => position + new Vector2(0, -size.Y), + DrawAnchor.BottomRight => position + new Vector2(-size.X, -size.Y), + _ => position + }; + } + + public static string UserFriendlyConfigName(string configTypeName) => UserFriendlyString(configTypeName, "Config"); + + public static string UserFriendlyString(string str, string? remove) + { + string? s = remove != null ? str.Replace(remove, "") : str; + + Regex? regex = new(@" + (?<=[A-Z])(?=[A-Z][a-z]) | + (?<=[^A-Z])(?=[A-Z]) | + (?<=[A-Za-z])(?=[^A-Za-z])", + RegexOptions.IgnorePatternWhitespace); + + return regex.Replace(s, " "); + } + + public static void OpenUrl(string url) + { + try + { + Process.Start(url); + } + catch + { + try + { + // hack because of this: https://github.com/dotnet/corefx/issues/10361 + if (RuntimeInformation.IsOSPlatform(osPlatform: OSPlatform.Windows)) + { + url = url.Replace("&", "^&"); + Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", url); + } + } + catch (Exception e) + { + Plugin.Logger.Error("Error trying to open url: " + e.Message); + } + } + } + + public static unsafe bool? IsTargetCasting() + { + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_TargetInfo", 1).Address; + if (addon != null && addon->IsVisible) + { + AtkImageNode* imageNode = addon->GetImageNodeById(15); + return imageNode == null || imageNode->IsVisible(); + } + + addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_TargetInfoCastBar", 1).Address; + if (addon != null && addon->IsVisible) + { + AtkImageNode* imageNode = addon->GetImageNodeById(7); + return imageNode != null || imageNode->IsVisible(); + } + + return null; + } + + public static unsafe bool? IsFocusTargetCasting() + { + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_FocusTargetInfo", 1).Address; + if (addon != null && addon->IsVisible) + { + AtkTextNode* textNode = addon->GetTextNodeById(5); + return textNode == null || textNode->IsVisible(); + } + + return null; + } + + public static unsafe bool? IsEnemyInListCasting(int index) + { + if (index < 0 || index > 7) { return null; } + + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_EnemyList", 1).Address; + if (addon == null || !addon->IsVisible) { return null; } + + uint buttonId = (index == 0) ? 2u : (uint)(20000 + index); + AtkComponentButton* button = addon->GetComponentButtonById(buttonId); + if (button == null || button->AtkResNode == null || !button->AtkResNode->IsVisible()) + { + return false; + } + + AtkImageNode* imageNode = button->GetImageNodeById(8); + return imageNode == null || imageNode->IsVisible(); + } + + public static unsafe uint? SignIconIDForActor(IGameObject? actor) + { + if (actor == null) + { + return null; + } + + return SignIconIDForObjectID(actor.GameObjectId); + } + + public static unsafe uint? SignIconIDForObjectID(ulong objectId) + { + MarkingController* markingController = MarkingController.Instance(); + if (objectId == 0 || objectId == InvalidGameObjectId || markingController == null) + { + return null; + } + + for (int i = 0; i < 17; i++) + { + if (objectId == markingController->Markers[i]) + { + // attack1-5 + if (i <= 4) + { + return (uint)(61201 + i); + } + // attack6-8 + else if (i >= 14) + { + return (uint)(61201 + i - 9); + } + // shapes + else if (i >= 10) + { + return (uint)(61231 + i - 10); + } + // ignore1-2 + else if (i >= 8) + { + return (uint)(61221 + i - 8); + } + // bind1-3 + else if (i >= 5) + { + return (uint)(61211 + i - 5); + } + } + } + + return null; + } + + public static bool IsHealthLabel(LabelConfig config) + { + return config.GetText().Contains("[health"); + } + } +} diff --git a/Helpers/WhosTalkingHelper.cs b/Helpers/WhosTalkingHelper.cs new file mode 100644 index 0000000..a224570 --- /dev/null +++ b/Helpers/WhosTalkingHelper.cs @@ -0,0 +1,126 @@ +using Dalamud.Game.ClientState.Party; +using Dalamud.Interface; +using Dalamud.Interface.Internal; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Plugin.Ipc; +using HSUI.Config; +using HSUI.Config.Tree; +using HSUI.Interface.Party; +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using static System.Collections.Specialized.BitVector32; + +namespace HSUI.Helpers +{ + public enum WhosTalkingState : int + { + None = 0, + Speaking = 1, + Muted = 2, + Deafened = 3 + } + + public class WhosTalkingHelper + { + private readonly ICallGateSubscriber _getUserState; + private Dictionary _cachedStates = new Dictionary(); + + private string speakingPath = ""; + private string mutedPath = ""; + private string deafenedPath = ""; + + #region Singleton + private WhosTalkingHelper() + { + _getUserState = Plugin.PluginInterface.GetIpcSubscriber("WT.GetUserState"); + + try + { + string imagesPath = Path.Combine(Plugin.AssemblyLocation, "Media", "Images"); + + // speaking + speakingPath = Path.Combine(imagesPath, "speaking.png"); + + // muted + mutedPath = Path.Combine(imagesPath, "muted.png"); + + // deafened + deafenedPath = Path.Combine(imagesPath, "deafened.png"); + } + catch { } + } + + public static void Initialize() { Instance = new WhosTalkingHelper(); } + + public static WhosTalkingHelper Instance { get; private set; } = null!; + + ~WhosTalkingHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Instance = null!; + } + #endregion + + public void Update() + { + _cachedStates.Clear(); + + foreach (IPartyFramesMember member in PartyManager.Instance.GroupMembers) + { + if (member.Name.Length <= 0) { continue; } + + WhosTalkingState state = WhosTalkingState.None; + + try + { + state = (WhosTalkingState)_getUserState.InvokeFunc(member.Name); + } + catch { } + + if (!_cachedStates.ContainsKey(member.Name)) + { + _cachedStates.Add(member.Name, state); + } + } + } + + public WhosTalkingState GetUserState(string name) + { + if (_cachedStates.TryGetValue(name, out WhosTalkingState state)) + { + return state; + } + + return WhosTalkingState.None; + } + + public IDalamudTextureWrap? GetTextureForState(WhosTalkingState state) + { + switch (state) + { + case WhosTalkingState.Speaking: return Plugin.TextureProvider.GetFromFile(speakingPath).GetWrapOrDefault(); + case WhosTalkingState.Muted: return Plugin.TextureProvider.GetFromFile(mutedPath).GetWrapOrDefault(); + case WhosTalkingState.Deafened: return Plugin.TextureProvider.GetFromFile(deafenedPath).GetWrapOrDefault(); + } + + return null; + } + } +} diff --git a/Helpers/WotsitHelper.cs b/Helpers/WotsitHelper.cs new file mode 100644 index 0000000..ea258a9 --- /dev/null +++ b/Helpers/WotsitHelper.cs @@ -0,0 +1,145 @@ +using Dalamud.Plugin.Ipc; +using HSUI.Config; +using HSUI.Config.Tree; +using System; +using System.Collections.Generic; + +namespace HSUI.Helpers +{ + internal class WotsitHelper + { + private readonly ICallGateSubscriber _registerWithSearch; + private readonly ICallGateSubscriber _invoke; + private readonly ICallGateSubscriber _unregisterAll; + + private Dictionary _map = new Dictionary(); + + #region Singleton + private WotsitHelper() + { + _registerWithSearch = Plugin.PluginInterface.GetIpcSubscriber("FA.RegisterWithSearch"); + _unregisterAll = Plugin.PluginInterface.GetIpcSubscriber("FA.UnregisterAll"); + + _invoke = Plugin.PluginInterface.GetIpcSubscriber("FA.Invoke"); + _invoke.Subscribe(Invoke); + } + + public static void Initialize() { Instance = new WotsitHelper(); } + + public static WotsitHelper Instance { get; private set; } = null!; + + ~WotsitHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + + UnregisterAll(); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Instance = null!; + } + #endregion + + public void Update() + { + _map.Clear(); + if (!UnregisterAll()) + { + return; + } + + // sections + foreach (Node node in ConfigurationManager.Instance.ConfigBaseNode.Sections) + { + if (node is not SectionNode section) { continue; } + + string guid = _registerWithSearch.InvokeFunc( + Plugin.PluginInterface.InternalName, + "HSUI Settings: " + section.Name, + "HSUI " + section.Name, + 66472 + ); + + _map.Add(guid, (section, null, null)); + + // sub sections + foreach (SubSectionNode subSection in section.Children) + { + guid = _registerWithSearch.InvokeFunc( + Plugin.PluginInterface.InternalName, + "HSUI Settings: " + section.Name + " > " + subSection.Name, + "HSUI " + subSection.Name, + 66472 + ); + + _map.Add(guid, (section, subSection, null)); + + // nested sub sections + foreach (SubSectionNode nestedSubSection in subSection.Children) + { + if (nestedSubSection is not NestedSubSectionNode nestedNode) { continue; } + + guid = _registerWithSearch.InvokeFunc( + Plugin.PluginInterface.InternalName, + "HSUI Settings: " + section.Name + " > " + subSection.Name + " > " + nestedNode.Name, + "HSUI " + nestedNode.Name, + 66472 + ); + + _map.Add(guid, (section, subSection, nestedNode)); + } + } + } + } + + public void Invoke(string guid) + { + //_map.TryGetValue() + if (_map.TryGetValue(guid, out var value) && value.Item1 != null) + { + SectionNode section = value.Item1; + ConfigurationManager.Instance.ConfigBaseNode.SelectedOptionName = section.Name; + ConfigurationManager.Instance.ConfigBaseNode.RefreshSelectedNode(); + + SubSectionNode? subSectionNode = value.Item2; + if (subSectionNode != null) + { + section.ForceSelectedTabName = subSectionNode.Name; + + NestedSubSectionNode? nestedSubSectionNode = value.Item3; + if (nestedSubSectionNode != null) + { + subSectionNode.ForceSelectedTabName = nestedSubSectionNode.Name; + } + } + + ConfigurationManager.Instance.OpenConfigWindow(); + } + } + + public bool UnregisterAll() + { + try + { + _unregisterAll.InvokeFunc(Plugin.PluginInterface.InternalName); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/Interface/Bars/BarConfig.cs b/Interface/Bars/BarConfig.cs new file mode 100644 index 0000000..7ae3da7 --- /dev/null +++ b/Interface/Bars/BarConfig.cs @@ -0,0 +1,79 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using System.Numerics; +using HSUI.Interface.GeneralElements; +using HSUI.Enums; + +namespace HSUI.Interface.Bars +{ + [Exportable(false)] + public class BarConfig : AnchorablePluginConfigObject + { + [ColorEdit4("Background Color")] + [Order(16)] + public PluginConfigColor BackgroundColor = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 50f / 100f)); + + [ColorEdit4("Fill Color")] + [Order(25)] + public PluginConfigColor FillColor; + + [Combo("Fill Direction", new string[] { "Left", "Right", "Up", "Down" })] + [Order(30)] + public BarDirection FillDirection; + + [BarTexture("Bar Texture", spacing = true, help = "Default means the bar will be drawn using the global gradient configuration for bars found in Colors > Misc.")] + [Order(31)] + public string BarTextureName = ""; + + [BarTextureDrawMode("Draw Mode")] + [Order(32)] + public BarTextureDrawMode BarTextureDrawMode = BarTextureDrawMode.Stretch; + + [Checkbox("Show Border", spacing = true)] + [Order(35)] + public bool DrawBorder = true; + + [ColorEdit4("Border Color")] + [Order(36, collapseWith = nameof(DrawBorder))] + public PluginConfigColor BorderColor = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [DragInt("Border Thickness", min = 1, max = 10)] + [Order(37, collapseWith = nameof(DrawBorder))] + public int BorderThickness = 1; + + [NestedConfig("Shadow", 40, spacing = true)] + public ShadowConfig ShadowConfig = new ShadowConfig() { Enabled = false }; + + [Checkbox("Hide When Inactive", spacing = true)] + [Order(41)] + public bool HideWhenInactive = false; + + public BarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor, BarDirection fillDirection = BarDirection.Right) + { + Position = position; + Size = size; + FillColor = fillColor; + FillDirection = fillDirection; + } + } + + [Exportable(false)] + public class BarGlowConfig : PluginConfigObject + { + [ColorEdit4("Color")] + [Order(5)] + public PluginConfigColor Color = new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 50f / 100f)); + + [DragInt("Size", min = 1, max = 100)] + [Order(25)] + public int Size = 1; + } + + public enum BarDirection + { + Left, + Right, + Up, + Down + } +} diff --git a/Interface/Bars/BarHud.cs b/Interface/Bars/BarHud.cs new file mode 100644 index 0000000..fdb09fe --- /dev/null +++ b/Interface/Bars/BarHud.cs @@ -0,0 +1,210 @@ +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface.Bars +{ + public class BarHud + { + private string ID { get; set; } + + private Rect BackgroundRect { get; set; } = new Rect(); + + private List ForegroundRects { get; set; } = new List(); + + private List LabelHuds { get; set; } = new List(); + + private bool DrawBorder { get; set; } + + private PluginConfigColor? BorderColor { get; set; } + + private int BorderThickness { get; set; } + + private DrawAnchor Anchor { get; set; } + + private IGameObject? Actor { get; set; } + + private PluginConfigColor? GlowColor { get; set; } + + private int GlowSize { get; set; } + + private float? Current; + private float? Max; + + private ShadowConfig? ShadowConfig { get; set; } + + private string? BarTextureName { get; set; } + private BarTextureDrawMode BarTextureDrawMode { get; set; } + + public bool NeedsInputs = false; + + public BarHud( + string id, + bool drawBorder = true, + PluginConfigColor? borderColor = null, + int borderThickness = 1, + DrawAnchor anchor = DrawAnchor.TopLeft, + IGameObject? actor = null, + PluginConfigColor? glowColor = null, + int? glowSize = 1, + float? current = null, + float? max = null, + ShadowConfig? shadowConfig = null, + string? barTextureName = null, + BarTextureDrawMode barTextureDrawMode = BarTextureDrawMode.Stretch) + { + ID = id; + DrawBorder = drawBorder; + BorderColor = borderColor; + BorderThickness = borderThickness; + Anchor = anchor; + Actor = actor; + GlowColor = glowColor; + GlowSize = glowSize ?? 1; + Current = current; + Max = max; + ShadowConfig = shadowConfig; + BarTextureName = barTextureName; + BarTextureDrawMode = barTextureDrawMode; + } + + public BarHud(BarConfig config, IGameObject? actor = null, BarGlowConfig? glowConfig = null, float? current = null, float? max = null) + : this(config.ID, + config.DrawBorder, + config.BorderColor, + config.BorderThickness, + config.Anchor, + actor, + glowConfig?.Color, + glowConfig?.Size, + current, + max, + null, + config.BarTextureName, + config.BarTextureDrawMode) + { + BackgroundRect = new Rect(config.Position, config.Size, config.BackgroundColor); + ShadowConfig = config.ShadowConfig; + } + + public BarHud SetBackground(Rect rect) + { + BackgroundRect = rect; + return this; + } + + public BarHud AddForegrounds(params Rect[] rects) + { + ForegroundRects.AddRange(rects); + return this; + } + + public BarHud AddLabels(params LabelConfig[]? labels) + { + if (labels != null) + { + foreach (LabelConfig config in labels) + { + var labelHud = new LabelHud(config); + LabelHuds.Add(labelHud); + } + } + + return this; + } + + public BarHud SetGlow(PluginConfigColor color, int size = 1) + { + GlowColor = color; + GlowSize = size; + + return this; + } + + public void Draw(Vector2 origin) + { + var barPos = Utils.GetAnchoredPosition(origin, BackgroundRect.Size, Anchor); + var backgroundPos = barPos + BackgroundRect.Position; + + DrawRects(barPos, backgroundPos); + + // labels + foreach (LabelHud label in LabelHuds) + { + label.Draw(backgroundPos, BackgroundRect.Size, Actor, null, (uint?)Current, (uint?)Max); + } + } + + public List<(StrataLevel, Action)> GetDrawActions(Vector2 origin, StrataLevel strataLevel) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + + var barPos = Utils.GetAnchoredPosition(origin, BackgroundRect.Size, Anchor); + var backgroundPos = barPos + BackgroundRect.Position; + + drawActions.Add((strataLevel, () => + { + DrawRects(barPos, backgroundPos); + } + )); + + // labels + foreach (LabelHud label in LabelHuds) + { + drawActions.Add((label.GetConfig().StrataLevel, () => + { + label.Draw(backgroundPos, BackgroundRect.Size, Actor, null, (uint?)Current, (uint?)Max); + } + )); + } + + return drawActions; + } + + private void DrawRects(Vector2 barPos, Vector2 backgroundPos) + { + DrawHelper.DrawInWindow(ID, backgroundPos, BackgroundRect.Size, NeedsInputs, (drawList) => + { + // Draw background + drawList.AddRectFilled(backgroundPos, backgroundPos + BackgroundRect.Size, BackgroundRect.Color.Base); + + // Draw Shadow + if (ShadowConfig != null && ShadowConfig.Enabled) + { + // Right Side + drawList.AddRectFilled(backgroundPos + new Vector2(BackgroundRect.Size.X, ShadowConfig.Offset), backgroundPos + BackgroundRect.Size + new Vector2(ShadowConfig.Offset, ShadowConfig.Offset) + new Vector2(ShadowConfig.Thickness - 1, ShadowConfig.Thickness - 1), ShadowConfig.Color.Base); + + // Bottom Size + drawList.AddRectFilled(backgroundPos + new Vector2(ShadowConfig.Offset, BackgroundRect.Size.Y), backgroundPos + BackgroundRect.Size + new Vector2(ShadowConfig.Offset, ShadowConfig.Offset) + new Vector2(ShadowConfig.Thickness - 1, ShadowConfig.Thickness - 1), ShadowConfig.Color.Base); + } + + // Draw foregrounds + foreach (Rect rect in ForegroundRects) + { + DrawHelper.DrawBarTexture(barPos + rect.Position, rect.Size, rect.Color, BarTextureName, BarTextureDrawMode, drawList); + } + + // Draw Border + if (DrawBorder) + { + drawList.AddRect(backgroundPos, backgroundPos + BackgroundRect.Size, BorderColor?.Base ?? 0xFF000000, 0, ImDrawFlags.None, BorderThickness); + } + + // Draw Glow + if (GlowColor != null) + { + var glowPosition = new Vector2(backgroundPos.X - 1, backgroundPos.Y - 1); + var glowSize = new Vector2(BackgroundRect.Size.X + 2, BackgroundRect.Size.Y + 2); + + drawList.AddRect(glowPosition, glowPosition + glowSize, GlowColor.Base, 0, ImDrawFlags.None, GlowSize); + } + }); + } + } +} diff --git a/Interface/Bars/BarUtilities.cs b/Interface/Bars/BarUtilities.cs new file mode 100644 index 0000000..f10afb1 --- /dev/null +++ b/Interface/Bars/BarUtilities.cs @@ -0,0 +1,441 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Bars +{ + public class BarUtilities + { + public static BarHud GetProgressBar(ProgressBarConfig config, float current, float max, float min = 0f, IGameObject? actor = null, PluginConfigColor? fillColor = null, BarGlowConfig? barGlowConfig = null) + { + return GetProgressBar(config, config.ThresholdConfig, new LabelConfig[] { config.Label }, current, max, min, actor, fillColor, barGlowConfig); + } + + public static BarHud GetProgressBar( + BarConfig config, + ThresholdConfig? thresholdConfig, + LabelConfig[]? labelConfigs, + float current, + float max, + float min = 0f, + IGameObject? actor = null, + PluginConfigColor? fillColor = null, + BarGlowConfig? glowConfig = null, + PluginConfigColor? backgroundColor = null + ) + { + BarHud bar = new(config, actor, glowConfig, current, max); + + PluginConfigColor color = fillColor ?? config.FillColor; + if (thresholdConfig != null) + { + color = thresholdConfig.ChangeColor && thresholdConfig.IsActive(current) ? thresholdConfig.Color : color; + } + + Rect foreground = GetFillRect(config.Position, config.Size, config.FillDirection, color, current, max, min); + bar.AddForegrounds(foreground); + bar.AddLabels(labelConfigs); + + if (backgroundColor != null) + { + Rect bg = new Rect(config.Position, config.Size, backgroundColor); + bar.SetBackground(bg); + } + + AddThresholdMarker(bar, config, thresholdConfig, max, min); + + return bar; + } + + public static BarHud? GetProcBar( + ProgressBarConfig config, + IPlayerCharacter player, + uint statusId, + float maxDuration, + bool trackDuration = true) + { + return GetProcBar(config, player, new List { statusId }, new List { maxDuration }, trackDuration); + } + + public static BarHud? GetProcBar( + ProgressBarConfig config, + IPlayerCharacter player, + List statusIDs, + List maxDurations, + bool trackDuration = true) + { + if (statusIDs.Count == 0 || maxDurations.Count == 0) { return null; } + + IStatus? status = Utils.StatusListForBattleChara(player).FirstOrDefault(o => statusIDs.Contains(o.StatusId)); + if (status == null && config.HideWhenInactive) + { + return null; + } + + float duration = Math.Abs(status?.RemainingTime ?? 0); + + if (trackDuration) + { + int index = status != null ? statusIDs.IndexOf(status.StatusId) : 0; + config.Label.SetValue(duration); + return GetProgressBar(config, duration, maxDurations[index], 0, player); + } + + config.Label.SetText(""); + return GetBar(config, duration <= 0 ? 0 : 1, 1, 0); + } + + public static BarHud? GetDoTBar( + ProgressBarConfig config, + IPlayerCharacter player, + IGameObject? target, + uint statusId, + float maxDuration) + { + return GetDoTBar(config, player, target, new List { statusId }, new List { maxDuration }); + } + + public static BarHud? GetDoTBar( + ProgressBarConfig config, + IPlayerCharacter player, + IGameObject? target, + List statusIDs, + List maxDurations) + { + if (statusIDs.Count == 0 || maxDurations.Count == 0) { return null; } + + IStatus? status = null; + + if (target != null && target is IBattleChara targetChara) + { + status = Utils.StatusListForBattleChara(targetChara).FirstOrDefault(o => o.SourceId == player.GameObjectId && statusIDs.Contains(o.StatusId)); + } + + if (status == null && config.HideWhenInactive) + { + return null; + } + + int index = status != null ? statusIDs.IndexOf(status.StatusId) : 0; + float duration = Math.Abs(status?.RemainingTime ?? 0); + float maxDuration = maxDurations[index]; + + config.Label.SetValue(duration); + return GetProgressBar(config, duration, maxDuration, 0, player); + } + + private static void AddThresholdMarker(BarHud bar, BarConfig config, ThresholdConfig? thresholdConfig, float max, float min) + { + if (thresholdConfig == null || !thresholdConfig.Enabled || !thresholdConfig.ShowMarker) + { + return; + } + + float thresholdPercent = Math.Clamp(thresholdConfig.Value / (max - min), 0f, 1f); + Vector2 offset = GetFillDirectionOffset( + new Vector2(config.Size.X * thresholdPercent, config.Size.Y * thresholdPercent), + config.FillDirection + ); + + Vector2 markerSize = config.FillDirection.IsHorizontal() ? + new Vector2(thresholdConfig.MarkerSize, config.Size.Y) : + new Vector2(config.Size.X, thresholdConfig.MarkerSize); + + Vector2 markerPos = config.FillDirection.IsInverted() ? + config.Position + GetFillDirectionOffset(config.Size, config.FillDirection) - offset : + config.Position + offset; + + Vector2 anchoredPos = Utils.GetAnchoredPosition(markerPos, markerSize, config.FillDirection.IsHorizontal() ? DrawAnchor.Top : DrawAnchor.Left); + Rect marker = new(anchoredPos, markerSize, thresholdConfig.MarkerColor); + bar.AddForegrounds(marker); + } + + // Tuple is + public static BarHud[] GetChunkedBars( + ChunkedBarConfig config, + Tuple[] chunks, + IGameObject? actor, + BarGlowConfig glowConfig) + { + List chunksToGlowList = new(); + for (int i = 0; i < chunks.Length; i++) + { + chunksToGlowList.Add(chunks[i].Item2 >= 1f); + } + + return GetChunkedBars(config, chunks, actor, glowConfig, chunksToGlowList.ToArray()); + } + + public static BarHud[] GetChunkedBars( + ChunkedBarConfig config, + Tuple[] chunks, + IGameObject? actor, + BarGlowConfig? glowConfig = null, + bool[]? chunksToGlow = null) + { + BarHud[] bars = new BarHud[chunks.Length]; + Vector2 pos = Utils.GetAnchoredPosition(config.Position, config.Size, config.Anchor); + + for (int i = 0; i < chunks.Length; i++) + { + Vector2 chunkPos, chunkSize; + if (config.FillDirection.IsHorizontal()) + { + chunkSize = new Vector2((config.Size.X - config.Padding * (chunks.Length - 1)) / chunks.Length, config.Size.Y); + chunkPos = pos + new Vector2((chunkSize.X + config.Padding) * i, 0); + } + else + { + chunkSize = new Vector2(config.Size.X, (config.Size.Y - config.Padding * (chunks.Length - 1)) / chunks.Length); + chunkPos = pos + new Vector2(0, (chunkSize.Y + config.Padding) * i); + } + + Rect background = new(chunkPos, chunkSize, config.BackgroundColor); + Rect foreground = GetFillRect(chunkPos, chunkSize, config.FillDirection, chunks[i].Item1, chunks[i].Item2, 1f, 0f); + BarGlowConfig? glow = (glowConfig?.Enabled == true && chunksToGlow?[i] == true) ? glowConfig : null; + + bars[i] = new BarHud(config.ID + i, + config.DrawBorder, + config.BorderColor, + config.BorderThickness, + actor: actor, + glowColor: glow?.Color, + glowSize: glow?.Size, + barTextureName: config.BarTextureName, + barTextureDrawMode: config.BarTextureDrawMode, + shadowConfig: config.ShadowConfig + ); + bars[i].SetBackground(background); + bars[i].AddForegrounds(foreground); + + LabelConfig? label = chunks[i].Item3; + if (label is not null) + { + bars[i].AddLabels(label); + } + } + + return bars; + } + + public static BarHud[] GetChunkedBars( + ChunkedBarConfig config, + int chunks, + float current, + float max, + float min = 0f, + IGameObject? actor = null, + LabelConfig?[]? labels = null, + PluginConfigColor? fillColor = null, + PluginConfigColor? partialFillColor = null, + BarGlowConfig? glowConfig = null, + bool[]? chunksToGlow = null) + { + float chunkRange = (max - min) / chunks; + + var barChunks = new Tuple[chunks]; + for (int i = 0; i < chunks; i++) + { + int barIndex = config.FillDirection.IsInverted() ? chunks - i - 1 : i; + float chunkMin = min + chunkRange * i; + float chunkMax = min + chunkRange * (i + 1); + float chunkPercent = Math.Clamp((current - chunkMin) / (chunkMax - chunkMin), 0f, 1f); + + PluginConfigColor chunkColor = partialFillColor != null && current < chunkMax ? partialFillColor : fillColor ?? config.FillColor; + barChunks[barIndex] = new Tuple(chunkColor, chunkPercent, labels?[i]); + } + + if (glowConfig != null && chunksToGlow == null) + { + return GetChunkedBars(config, barChunks, actor, glowConfig); + } + + return GetChunkedBars(config, barChunks, actor, glowConfig, chunksToGlow); + } + + public static BarHud[] GetChunkedProgressBars( + ChunkedProgressBarConfig config, + int chunks, + float current, + float max, + float min = 0f, + IGameObject? actor = null, + BarGlowConfig? glowConfig = null, + PluginConfigColor? fillColor = null, + int thresholdChunk = 1, + bool[]? chunksToGlow = null, + int forceLabelIndex = -1) + { + var color = fillColor ?? config.FillColor; + + if (config.UseChunks) + { + NumericLabelConfig?[] labels = new NumericLabelConfig?[chunks]; + for (int i = 0; i < chunks; i++) + { + float chunkRange = (max - min) / chunks; + float chunkMin = min + chunkRange * i; + float chunkMax = min + chunkRange * (i + 1); + float chunkPercent = Math.Clamp((current - chunkMin) / (chunkMax - chunkMin), 0f, 1f); + + NumericLabelConfig? label = config.Label; + if (forceLabelIndex == -1) + { + switch (config.LabelMode) + { + case LabelMode.AllChunks: + label = config.Label.Clone(i); + label.SetValue(Math.Clamp(current - chunkMin, 0, chunkRange)); + break; + case LabelMode.ActiveChunk: + label = chunkPercent < 1f && chunkPercent > 0f ? config.Label.Clone(i) : null; + break; + }; + } + else + { + label = forceLabelIndex == i ? config.Label : null; + } + + labels[i] = label; + } + + var partialColor = config.UsePartialFillColor ? config.PartialFillColor : null; + return GetChunkedBars(config, chunks, current, max, min, actor, labels, color, partialColor, glowConfig, chunksToGlow); + } + + var threshold = GetThresholdConfigForChunk(config, thresholdChunk, chunks, min, max); + BarHud bar = GetProgressBar(config, threshold, new LabelConfig[] { config.Label }, current, max, min, actor, color, glowConfig); + return new BarHud[] { bar }; + } + + public static Rect[] GetShieldForeground( + ShieldConfig shieldConfig, + Vector2 pos, + Vector2 size, + Vector2 healthFillSize, + BarDirection fillDirection, + float shieldPercent, + float currentHp, + float maxHp, + PluginConfigColor? color = null) + { + float shieldValue = shieldPercent * maxHp; + float overshield = shieldConfig.FillHealthFirst ? Math.Max(shieldValue + currentHp - maxHp, 0f) : shieldValue; + float shieldSize = shieldConfig.Height; + PluginConfigColor c = color ?? shieldConfig.Color; + + if (!shieldConfig.HeightInPixels) + { + shieldSize = (fillDirection.IsHorizontal() ? size.Y : size.X) * shieldConfig.Height / 100f; + } + + var overshieldSize = fillDirection.IsHorizontal() + ? new Vector2(size.X, Math.Min(shieldSize, size.Y)) + : new Vector2(Math.Min(shieldSize, size.X), size.Y); + + Rect overshieldFill = GetFillRect(pos, overshieldSize, fillDirection, c, overshield, maxHp); + + if (shieldConfig.FillHealthFirst && currentHp < maxHp) + { + var shieldPos = fillDirection.IsInverted() ? pos : pos + GetFillDirectionOffset(healthFillSize, fillDirection); + var shieldFillSize = size - GetFillDirectionOffset(healthFillSize, fillDirection); + var healthFillShieldSize = fillDirection.IsHorizontal() + ? new Vector2(shieldFillSize.X, Math.Min(shieldSize, size.Y)) + : new Vector2(Math.Min(shieldSize, size.X), shieldFillSize.Y); + + Rect shieldFill = GetFillRect(shieldPos, healthFillShieldSize, fillDirection, c, shieldValue - overshield, maxHp - currentHp, 0f); + return new[] { overshieldFill, shieldFill }; + } + + return new[] { overshieldFill }; + } + + public static BarHud GetBar( + BarConfig Config, + float current, + float max, + float min = 0f, + IGameObject? actor = null, + PluginConfigColor? fillColor = null, + BarGlowConfig? glowConfig = null, + LabelConfig[]? labels = null) + { + Rect foreground = GetFillRect(Config.Position, Config.Size, Config.FillDirection, fillColor ?? Config.FillColor, current, max, min); + + BarHud bar = new BarHud(Config, actor, glowConfig); + bar.AddForegrounds(foreground); + bar.AddLabels(labels); + + return bar; + } + + /// + /// Gets the horizonal or vertical offset depending on the fill direction. + /// + public static Vector2 GetFillDirectionOffset(Vector2 size, BarDirection fillDirection) + { + return fillDirection.IsHorizontal() ? new(size.X, 0) : new(0, size.Y); + } + + public static Rect GetFillRect(Vector2 pos, Vector2 size, BarDirection fillDirection, PluginConfigColor color, float current, float max, float min = 0f) + { + float fillPercent = max == 0 ? 1f : Math.Clamp((current - min) / (max - min), 0f, 1f); + + Vector2 fillPos = Vector2.Zero; + Vector2 fillSize = fillDirection.IsHorizontal() ? new(size.X * fillPercent, size.Y) : new(size.X, size.Y * fillPercent); + if (fillDirection == BarDirection.Left) + { + fillPos = Utils.GetAnchoredPosition(new(size.X, 0), fillSize, DrawAnchor.TopRight); + } + else if (fillDirection == BarDirection.Up) + { + fillPos = Utils.GetAnchoredPosition(new(0, size.Y), fillSize, DrawAnchor.BottomLeft); + } + + return new Rect(pos + fillPos, fillSize, color); + } + + public static ThresholdConfig GetThresholdConfigForChunk(ChunkedProgressBarConfig config, int chunk, int chunks, float min, float max) => + new ThresholdConfig + { + ThresholdType = ThresholdType.Below, + Color = config.PartialFillColor, + Enabled = config.UsePartialFillColor, + Value = (max - min) / chunks * chunk, + ChangeColor = true, + ShowMarker = false + }; + + public static void AddShield(BarHud bar, BarConfig config, ShieldConfig shieldConfig, ICharacter character, Vector2 fillSize, PluginConfigColor? color = null) + { + if (shieldConfig.Enabled) + { + float shield = Utils.ActorShieldValue(character); + if (shield > 0f) + { + bar.AddForegrounds( + GetShieldForeground( + shieldConfig, + config.Position, + config.Size, + fillSize, + config.FillDirection, + shield, + character.CurrentHp, + character.MaxHp, + color) + ); + } + } + } + } +} diff --git a/Interface/Bars/ChunkedBarConfig.cs b/Interface/Bars/ChunkedBarConfig.cs new file mode 100644 index 0000000..66d5f73 --- /dev/null +++ b/Interface/Bars/ChunkedBarConfig.cs @@ -0,0 +1,83 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.GeneralElements; +using System.Numerics; + +namespace HSUI.Interface.Bars +{ + [Exportable(false)] + public class ChunkedBarConfig : BarConfig + { + [DragInt("Padding", min = -4000, max = 4000)] + [Order(45)] + public int Padding = 2; + + public ChunkedBarConfig( + Vector2 position, + Vector2 size, + PluginConfigColor fillColor, + int padding = 2) : base(position, size, fillColor) + { + Padding = padding; + } + } + + [Exportable(false)] + public class ChunkedProgressBarConfig : ChunkedBarConfig + { + [Checkbox("Show In Chunks", spacing = true)] + [Order(46)] + public bool UseChunks = true; + + [RadioSelector("Show Text on All Chunks", "Show Text on Active Chunk")] + [Order(47, collapseWith = nameof(UseChunks))] + public LabelMode LabelMode; + + [Checkbox("Use Partial Fill Color", spacing = true)] + [Order(50)] + public bool UsePartialFillColor = false; + + [ColorEdit4("Partial Fill Color")] + [Order(55, collapseWith = nameof(UsePartialFillColor))] + public PluginConfigColor PartialFillColor; + + [NestedConfig("Bar Text", 1000, separator = false, spacing = true)] + public NumericLabelConfig Label; + + public ChunkedProgressBarConfig( + Vector2 position, + Vector2 size, + PluginConfigColor fillColor, + int padding = 2, + PluginConfigColor? partialFillColor = null) : base(position, size, fillColor, padding) + { + Label = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + Label.Enabled = false; + + PartialFillColor = partialFillColor ?? new PluginConfigColor(new(180f / 255f, 180f / 255f, 180f / 255f, 100f / 100f)); + } + } + + [DisableParentSettings("LabelMode", "UsePartialFillColor", "PartialFillColor")] + [Exportable(false)] + public class StacksWithDurationBarConfig : ChunkedProgressBarConfig + { + public StacksWithDurationBarConfig( + Vector2 position, + Vector2 size, + PluginConfigColor fillColor, + int padding = 2, + PluginConfigColor? partialFillColor = null) : base(position, size, fillColor, padding) + { + UseChunks = true; + UsePartialFillColor = false; + } + } + + public enum LabelMode + { + AllChunks, + ActiveChunk + } +} diff --git a/Interface/Bars/ProgressBarConfig.cs b/Interface/Bars/ProgressBarConfig.cs new file mode 100644 index 0000000..feb41bb --- /dev/null +++ b/Interface/Bars/ProgressBarConfig.cs @@ -0,0 +1,80 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.GeneralElements; +using System.Numerics; + +namespace HSUI.Interface.Bars +{ + [Exportable(false)] + public class ProgressBarConfig : BarConfig + { + [NestedConfig("Threshold", 45)] + public ThresholdConfig ThresholdConfig = new ThresholdConfig(); + + [NestedConfig("Bar Text", 1000)] + public NumericLabelConfig Label; + + public ProgressBarConfig( + Vector2 position, + Vector2 size, + PluginConfigColor fillColor, + BarDirection fillDirection = BarDirection.Right, + PluginConfigColor? threshHoldColor = null, + float threshold = 0f) : base(position, size, fillColor, fillDirection) + { + Label = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + ThresholdConfig.Color = threshHoldColor ?? ThresholdConfig.Color; + ThresholdConfig.Value = threshold; + } + } + + [Exportable(false)] + public class ThresholdConfig : PluginConfigObject + { + [DragFloat("Threshold Value", min = 0f, max = 10000f)] + [Order(10)] + public float Value = 0f; + + [Checkbox("Change Color")] + [Order(15)] + public bool ChangeColor = true; + + [Combo("Activate Above/Below Threshold", "Above", "Below")] + [Order(20, collapseWith = nameof(ChangeColor))] + public ThresholdType ThresholdType = ThresholdType.Below; + + [ColorEdit4("Color")] + [Order(25, collapseWith = nameof(ChangeColor))] + public PluginConfigColor Color = new PluginConfigColor(new(230f / 255f, 33f / 255f, 33f / 255f, 100f / 100f)); + + [Checkbox("Show Threshold Marker")] + [Order(30)] + public bool ShowMarker = false; + + [DragInt("Threshold Marker Size", min = 0, max = 10000)] + [Order(35, collapseWith = nameof(ShowMarker))] + public int MarkerSize = 2; + + [ColorEdit4("Threshold Marker Color")] + [Order(40, collapseWith = nameof(ShowMarker))] + public PluginConfigColor MarkerColor = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + public bool IsActive(float current) + { + return Enabled && (ThresholdType == ThresholdType.Below && current < Value || + ThresholdType == ThresholdType.Above && current > Value); + } + + public ThresholdConfig() + { + Enabled = false; + } + } + + public enum ThresholdType + { + Above, + Below + } +} diff --git a/Interface/Bars/Rect.cs b/Interface/Bars/Rect.cs new file mode 100644 index 0000000..ada2af2 --- /dev/null +++ b/Interface/Bars/Rect.cs @@ -0,0 +1,23 @@ +using HSUI.Config; +using System.Numerics; + +namespace HSUI.Interface.Bars +{ + public class Rect + { + public Vector2 Position { get; set; } + + public Vector2 Size { get; set; } + + public PluginConfigColor Color { get; set; } + + public Rect(Vector2 pos, Vector2 size, PluginConfigColor? color = null) + { + Position = pos; + Size = size; + Color = color ?? new PluginConfigColor(new(0, 0, 0, 0)); + } + + public Rect() : this(new(0, 0), new(0, 0), new PluginConfigColor(new(0, 0, 0, 0))) { } + } +} diff --git a/Interface/DraggableHudElement.cs b/Interface/DraggableHudElement.cs new file mode 100644 index 0000000..1178b96 --- /dev/null +++ b/Interface/DraggableHudElement.cs @@ -0,0 +1,302 @@ +using Dalamud.Logging; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface +{ + public delegate void DraggableHudElementSelectHandler(DraggableHudElement element); + + public class DraggableHudElement : HudElement + { + public DraggableHudElement(MovablePluginConfigObject config, string? displayName = null) : base(config) + { + _displayName = displayName ?? ID; + } + + public event DraggableHudElementSelectHandler? SelectEvent; + public bool Selected = false; + + private string _displayName; + protected bool _windowPositionSet = false; + private Vector2 _lastWindowPos = Vector2.Zero; + private Vector2 _positionOffset; + private Vector2 _contentMargin = new Vector2(4, 0); + + private bool _draggingEnabled = false; + public bool DraggingEnabled + { + get => _draggingEnabled; + set + { + _draggingEnabled = value; + + if (_draggingEnabled) + { + _windowPositionSet = false; + _minPos = null; + _maxPos = null; + } + } + } + + public bool CanTakeInputForDrag = false; + public bool NeedsInputForDrag { get; private set; } = false; + + public virtual Vector2 ParentPos() { return Vector2.Zero; } // override + + protected sealed override void CreateDrawActions(Vector2 origin) + { + if (_draggingEnabled) + { + AddDrawAction(_config.StrataLevel, () => + { + DrawDraggableArea(origin); + }); + return; + } + + DrawChildren(origin); + } + + public virtual void DrawChildren(Vector2 origin) { } + + private bool CalculateNeedsInput(Vector2 pos, Vector2 size, bool selected) + { + Vector2 mousePos = ImGui.GetMousePos(); + + if (ImGui.IsMouseHoveringRect(pos, pos + size)) + { + return true; + } + + if (!selected) + { + return false; + } + + var arrowsPos = DraggablesHelper.GetArrowPositions(pos, size); + + foreach (Vector2 arrowPos in arrowsPos) + { + if (ImGui.IsMouseHoveringRect(arrowPos, arrowPos + DraggablesHelper.ArrowSize)) + { + return true; + } + } + + return false; + } + + protected virtual void DrawDraggableArea(Vector2 origin) + { + var windowFlags = ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoTitleBar + | ImGuiWindowFlags.NoResize + | ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoSavedSettings; + + // always update size + var size = MaxPos - MinPos + _contentMargin * 2; + ImGui.SetNextWindowSize(size, ImGuiCond.Always); + + // needs input? + NeedsInputForDrag = CanTakeInputForDrag && CalculateNeedsInput(_lastWindowPos, size, Selected); + + if (!NeedsInputForDrag) + { + windowFlags |= ImGuiWindowFlags.NoMove; + } + + // set initial position + if (!_windowPositionSet) + { + ImGui.SetNextWindowPos(origin + MinPos - _contentMargin); + _windowPositionSet = true; + + _positionOffset = _config.Position - MinPos + _contentMargin; + } + + // update config object position + ImGui.Begin(ID + "_dragArea", windowFlags); + var windowPos = ImGui.GetWindowPos(); + _lastWindowPos = windowPos; + _config.Position = windowPos + _positionOffset - origin; + + // check selection + var tooltipText = "x: " + _config.Position.X.ToString() + " y: " + _config.Position.Y.ToString(); + + if (NeedsInputForDrag && ImGui.IsMouseHoveringRect(windowPos, windowPos + size)) + { + bool cliked = ImGui.IsMouseClicked(ImGuiMouseButton.Left) || ImGui.IsMouseDown(ImGuiMouseButton.Left); + if (cliked && !Selected) + { + SelectEvent?.Invoke(this); + } + + // tooltip + TooltipsHelper.Instance.ShowTooltipOnCursor(tooltipText); + } + + // draw window + var drawList = ImGui.GetWindowDrawList(); + var contentPos = windowPos + _contentMargin; + var contentSize = size - _contentMargin * 2; + + // draw draggable indicators + drawList.AddRectFilled(contentPos, contentPos + contentSize, 0x88444444, 3); + + var lineColor = Selected ? 0xEEFFFFFF : 0x66FFFFFF; + drawList.AddRect(contentPos, contentPos + contentSize, lineColor, 3, ImDrawFlags.None, 2); + drawList.AddLine(contentPos + new Vector2(contentSize.X / 2f, 0), contentPos + new Vector2(contentSize.X / 2, contentSize.Y), lineColor); + drawList.AddLine(contentPos + new Vector2(0, contentSize.Y / 2f), contentPos + new Vector2(contentSize.X, contentSize.Y / 2), lineColor); + + ImGui.End(); + + // arrows + if (Selected) + { + if (DraggablesHelper.DrawArrows(windowPos, size, tooltipText, out var movement)) + { + _minPos = null; + _maxPos = null; + _config.Position += movement; + _windowPositionSet = false; + } + } + + // element name + var textSize = ImGui.CalcTextSize(_displayName); + var textColor = Selected ? 0xFFFFFFFF : 0xEEFFFFFF; + var textOutlineColor = Selected ? 0xFF000000 : 0xEE000000; + DrawHelper.DrawOutlinedText(_displayName, contentPos + contentSize / 2f - textSize / 2f, textColor, textOutlineColor, drawList); + } + + #region draggable area + protected Vector2? _minPos = null; + public Vector2 MinPos + { + get + { + if (_minPos != null) + { + return (Vector2)_minPos; + } + + var (positions, sizes) = ChildrenPositionsAndSizes(); + if (positions.Count == 0 || sizes.Count == 0) + { + return Vector2.Zero; + } + + float minX = float.MaxValue; + float minY = float.MaxValue; + + var anchorConfig = _config as AnchorablePluginConfigObject; + for (int i = 0; i < positions.Count; i++) + { + var pos = GetAnchoredPosition(positions[i], sizes[i], anchorConfig?.Anchor ?? DrawAnchor.Center); + minX = Math.Min(minX, pos.X); + minY = Math.Min(minY, pos.Y); + } + + _minPos = new Vector2(minX, minY); + return (Vector2)_minPos; + } + } + + protected Vector2? _maxPos = null; + public Vector2 MaxPos + { + get + { + if (_maxPos != null) + { + return (Vector2)_maxPos; + } + + var (positions, sizes) = ChildrenPositionsAndSizes(); + if (positions.Count == 0 || sizes.Count == 0) + { + return Vector2.Zero; + } + + float maxX = float.MinValue; + float maxY = float.MinValue; + + var anchorConfig = _config as AnchorablePluginConfigObject; + for (int i = 0; i < positions.Count; i++) + { + var pos = GetAnchoredPosition(positions[i], sizes[i], anchorConfig?.Anchor ?? DrawAnchor.Center) + sizes[i]; + maxX = Math.Max(maxX, pos.X); + maxY = Math.Max(maxY, pos.Y); + } + + _maxPos = new Vector2(maxX, maxY); + return (Vector2)_maxPos; + } + } + public void FlagDraggableAreaDirty() + { + _minPos = null; + _maxPos = null; + } + + protected virtual Vector2 GetAnchoredPosition(Vector2 position, Vector2 size, DrawAnchor anchor) + { + return Utils.GetAnchoredPosition(ParentPos() + position, size, anchor); + } + + protected virtual (List, List) ChildrenPositionsAndSizes() + { + return (new List(), new List()); + } + #endregion + } + + public abstract class ParentAnchoredDraggableHudElement : DraggableHudElement + { + public ParentAnchoredDraggableHudElement(MovablePluginConfigObject config, string? displayName = null) + : base(config, displayName) + { + } + + protected virtual bool AnchorToParent { get; } + protected virtual DrawAnchor ParentAnchor { get; } + public AnchorablePluginConfigObject? ParentConfig { get; set; } + + private Vector2? _lastParentPosition = null; + + private bool IsAnchored => AnchorToParent && ParentConfig != null; + + public override Vector2 ParentPos() + { + if (!IsAnchored) + { + return Vector2.Zero; + } + + Vector2 parentAnchoredPos = Utils.GetAnchoredPosition(ParentConfig!.Position, ParentConfig!.Size, ParentConfig!.Anchor); + return Utils.GetAnchoredPosition(parentAnchoredPos, -ParentConfig!.Size, ParentAnchor); + } + + protected override void DrawDraggableArea(Vector2 origin) + { + // if the parent moved, update own draggable area + if (IsAnchored && (_lastParentPosition == null || _lastParentPosition != ParentConfig!.Position)) + { + _windowPositionSet = false; + _minPos = null; + _maxPos = null; + _lastParentPosition = ParentConfig!.Position; + } + + base.DrawDraggableArea(origin); + } + } +} diff --git a/Interface/EnemyList/EnemyListConfig.cs b/Interface/EnemyList/EnemyListConfig.cs new file mode 100644 index 0000000..3dbae82 --- /dev/null +++ b/Interface/EnemyList/EnemyListConfig.cs @@ -0,0 +1,321 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.StatusEffects; +using Dalamud.Bindings.ImGui; +using System.Numerics; + +namespace HSUI.Interface.EnemyList +{ + public enum EnemyListGrowthDirection + { + Down = 0, + Up + } + + [Exportable(false)] + [Section("Enemy List", true)] + [SubSection("General", 0)] + public class EnemyListConfig : MovablePluginConfigObject + { + public new static EnemyListConfig DefaultConfig() + { + var config = new EnemyListConfig(); + Vector2 screenSize = ImGui.GetMainViewport().Size; + config.Position = new Vector2(screenSize.X * 0.2f, -screenSize.Y * 0.2f); + + return config; + } + + [Checkbox("Preview", isMonitored = true)] + [Order(4)] + public bool Preview = false; + + [Combo("Growth Direction", "Down", "Up", spacing = true)] + [Order(20)] + public EnemyListGrowthDirection GrowthDirection = EnemyListGrowthDirection.Down; + + [DragInt("Vertical Padding", min = 0, max = 500)] + [Order(25)] + public int VerticalPadding = 10; + } + + [Exportable(false)] + [DisableParentSettings("Position", "Anchor", "HideWhenInactive")] + [Section("Enemy List", true)] + [SubSection("Health Bar", 0)] + public class EnemyListHealthBarConfig : BarConfig + { + [NestedConfig("Name Label", 70)] + public EditableLabelConfig NameLabel = new EditableLabelConfig(new Vector2(-5, 12), "[name]", DrawAnchor.TopRight, DrawAnchor.BottomRight); + + [NestedConfig("Health Label", 80)] + public EditableLabelConfig HealthLabel = new EditableLabelConfig(new Vector2(30, 0), "[health:percent]%", DrawAnchor.Left, DrawAnchor.Left); + + [NestedConfig("Order Label", 90)] + public DefaultFontLabelConfig OrderLabel = new DefaultFontLabelConfig(new Vector2(5, 0), "", DrawAnchor.Left, DrawAnchor.Left); + + [NestedConfig("Colors", 100)] + public EnemyListHealthBarColorsConfig Colors = new EnemyListHealthBarColorsConfig(); + + [NestedConfig("Change Alpha Based on Range", 110)] + public EnemyListRangeConfig RangeConfig = new EnemyListRangeConfig(); + + [NestedConfig("Use Smooth Transitions", 120)] + public SmoothHealthConfig SmoothHealthConfig = new SmoothHealthConfig(); + + [NestedConfig("Custom Mouseover Area", 130)] + public MouseoverAreaConfig MouseoverAreaConfig = new MouseoverAreaConfig(); + + public new static EnemyListHealthBarConfig DefaultConfig() + { + Vector2 size = new Vector2(180, 40); + + var config = new EnemyListHealthBarConfig(Vector2.Zero, size, new PluginConfigColor(new(233f / 255f, 4f / 255f, 4f / 255f, 100f / 100f))); + config.Colors.ColorByHealth.Enabled = false; + + config.NameLabel.FontID = FontsConfig.DefaultMediumFontKey; + config.HealthLabel.FontID = FontsConfig.DefaultMediumFontKey; + + config.MouseoverAreaConfig.Enabled = false; + + return config; + } + + public EnemyListHealthBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor, BarDirection fillDirection = BarDirection.Right) + : base(position, size, fillColor, fillDirection) + { + } + } + + [Disableable(false)] + [Exportable(false)] + public class EnemyListHealthBarColorsConfig : PluginConfigObject + { + [NestedConfig("Color Based On Health Value", 30, collapsingHeader = false)] + public ColorByHealthValueConfig ColorByHealth = new ColorByHealthValueConfig(); + + [Checkbox("Highlight When Hovering With Cursor Or Soft Targeting", spacing = true)] + [Order(40)] + public bool ShowHighlight = true; + + [ColorEdit4("Highlight Color")] + [Order(41, collapseWith = nameof(ShowHighlight))] + public PluginConfigColor HighlightColor = new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 5f / 100f)); + + [Checkbox("Missing Health Color", spacing = true)] + [Order(45)] + public bool UseMissingHealthBar = false; + + [ColorEdit4("Color" + "##MissingHealth")] + [Order(46, collapseWith = nameof(UseMissingHealthBar))] + public PluginConfigColor HealthMissingColor = new PluginConfigColor(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Target Border Color", spacing = true)] + [Order(50)] + public PluginConfigColor TargetBordercolor = new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + + [DragInt("Target Border Thickness", min = 1, max = 10)] + [Order(51)] + public int TargetBorderThickness = 1; + + [Checkbox("Show Enmity Border Colors", spacing = true)] + [Order(60)] + public bool ShowEnmityBorderColors = true; + + [ColorEdit4("Enmity Leader Color")] + [Order(61, collapseWith = nameof(ShowEnmityBorderColors))] + public PluginConfigColor EnmityLeaderBorderColor = new PluginConfigColor(new Vector4(255f / 255f, 40f / 255f, 40f / 255f, 100f / 100f)); + + [ColorEdit4("Enmity Close To Leader Color")] + [Order(62, collapseWith = nameof(ShowEnmityBorderColors))] + public PluginConfigColor EnmitySecondBorderColor = new PluginConfigColor(new Vector4(255f / 255f, 175f / 255f, 40f / 255f, 100f / 100f)); + } + + [Exportable(false)] + public class EnemyListRangeConfig : PluginConfigObject + { + [DragInt("Range (yalms)", min = 1, max = 500)] + [Order(5)] + public int Range = 30; + + [DragFloat("Alpha", min = 1, max = 100)] + [Order(10)] + public float Alpha = 25; + + + public float AlphaForDistance(int distance, float alpha = 100f) + { + if (!Enabled) + { + return 100f; + } + + return distance > Range ? Alpha : alpha; + } + } + + [DisableParentSettings("FrameAnchor")] + [Exportable(false)] + [Section("Enemy List", true)] + [SubSection("Enmity Icon", 0)] + public class EnemyListEnmityIconConfig : IconConfig + { + [Anchor("Health Bar Anchor")] + [Order(16)] + public DrawAnchor HealthBarAnchor = DrawAnchor.TopLeft; + + public new static EnemyListEnmityIconConfig DefaultConfig() => + new EnemyListEnmityIconConfig(new Vector2(5), new Vector2(24), DrawAnchor.Center, DrawAnchor.TopLeft); + + public EnemyListEnmityIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + HealthBarAnchor = frameAnchor; + } + } + + [DisableParentSettings("FrameAnchor")] + [Exportable(false)] + [Section("Enemy List", true)] + [SubSection("Sign Icon", 0)] + public class EnemyListSignIconConfig : SignIconConfig + { + [Anchor("Health Bar Anchor")] + [Order(16)] + public DrawAnchor HealthBarAnchor = DrawAnchor.TopLeft; + + [Checkbox("Replace Order Label", help = "When enabled and if the enemy has a sign assigned, the sign icon will be drawn instead of the order label.")] + [Order(30)] + public bool ReplaceOrderLabel = true; + + public new static EnemyListSignIconConfig DefaultConfig() => + new EnemyListSignIconConfig(new Vector2(0), new Vector2(30), DrawAnchor.Center, DrawAnchor.Left); + + public EnemyListSignIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + HealthBarAnchor = frameAnchor; + } + } + + [DisableParentSettings("AnchorToUnitFrame", "UnitFrameAnchor", "HideWhenInactive", "FillDirection")] + [Exportable(false)] + [Section("Enemy List", true)] + [SubSection("Castbar", 0)] + public class EnemyListCastbarConfig : TargetCastbarConfig + { + public new static EnemyListCastbarConfig DefaultConfig() + { + var size = new Vector2(180, 10); + + var castNameConfig = new LabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center); + castNameConfig.FontID = FontsConfig.DefaultMediumFontKey; + var castTimeConfig = new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + castTimeConfig.Enabled = false; + castTimeConfig.FontID = FontsConfig.DefaultMediumFontKey; + castTimeConfig.NumberFormat = 1; + + var config = new EnemyListCastbarConfig(Vector2.Zero, size, castNameConfig, castTimeConfig); + config.HealthBarAnchor = DrawAnchor.Bottom; + config.Anchor = DrawAnchor.Bottom; + config.ShowIcon = false; + + return config; + } + + [Anchor("Health Bar Anchor")] + [Order(16)] + public DrawAnchor HealthBarAnchor = DrawAnchor.BottomLeft; + + public EnemyListCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + } + + [Exportable(false)] + [Section("Enemy List", true)] + [SubSection("Buffs", 0)] + public class EnemyListBuffsConfig : EnemyListStatusEffectsListConfig + { + public new static EnemyListBuffsConfig DefaultConfig() + { + var durationConfig = new LabelConfig(new Vector2(0, -4), "", DrawAnchor.Bottom, DrawAnchor.Center); + var stacksConfig = new LabelConfig(new Vector2(-3, 4), "", DrawAnchor.TopRight, DrawAnchor.Center); + stacksConfig.Color = new(Vector4.UnitW); + stacksConfig.OutlineColor = new(Vector4.One); + + var iconConfig = new StatusEffectIconConfig(durationConfig, stacksConfig); + iconConfig.DispellableBorderConfig.Enabled = false; + iconConfig.Size = new Vector2(24, 24); + + var pos = new Vector2(5, 8); + var size = new Vector2(iconConfig.Size.X * 4 + 6, iconConfig.Size.Y); + + var config = new EnemyListBuffsConfig(DrawAnchor.TopRight, pos, size, true, false, false, GrowthDirections.Right | GrowthDirections.Down, iconConfig); + config.Limit = 4; + config.ShowPermanentEffects = true; + config.IconConfig.DispellableBorderConfig.Enabled = false; + + return config; + } + + public EnemyListBuffsConfig(DrawAnchor anchor, Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(anchor, position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + [Exportable(false)] + [Section("Enemy List", true)] + [SubSection("Debuffs", 0)] + public class EnemyListDebuffsConfig : EnemyListStatusEffectsListConfig + { + public new static EnemyListDebuffsConfig DefaultConfig() + { + var durationConfig = new LabelConfig(new Vector2(0, -4), "", DrawAnchor.Bottom, DrawAnchor.Center); + var stacksConfig = new LabelConfig(new Vector2(-3, 4), "", DrawAnchor.TopRight, DrawAnchor.Center); + stacksConfig.Color = new(Vector4.UnitW); + stacksConfig.OutlineColor = new(Vector4.One); + + var iconConfig = new StatusEffectIconConfig(durationConfig, stacksConfig); + iconConfig.Size = new Vector2(24, 24); + + var pos = new Vector2(-5, 8); + var size = new Vector2(iconConfig.Size.X * 4 + 6, iconConfig.Size.Y); + + var config = new EnemyListDebuffsConfig(DrawAnchor.TopLeft, pos, size, false, true, false, GrowthDirections.Left | GrowthDirections.Down, iconConfig); + config.Limit = 4; + config.ShowPermanentEffects = true; + config.IconConfig.DispellableBorderConfig.Enabled = false; + + return config; + } + + public EnemyListDebuffsConfig(DrawAnchor anchor, Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(anchor, position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + public class EnemyListStatusEffectsListConfig : StatusEffectsListConfig + { + [Anchor("Health Bar Anchor")] + [Order(4)] + public DrawAnchor HealthBarAnchor = DrawAnchor.BottomLeft; + + public EnemyListStatusEffectsListConfig(DrawAnchor anchor, Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + HealthBarAnchor = anchor; + } + } +} diff --git a/Interface/EnemyList/EnemyListHelper.cs b/Interface/EnemyList/EnemyListHelper.cs new file mode 100644 index 0000000..0daf109 --- /dev/null +++ b/Interface/EnemyList/EnemyListHelper.cs @@ -0,0 +1,88 @@ +using Dalamud.Memory; +using HSUI.Helpers; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System; +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Client.UI.Arrays; +using StructsFramework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework; + +namespace HSUI.Interface.EnemyList +{ + public unsafe class EnemyListHelper + { + private List _enemiesData = new List(); + public IReadOnlyCollection EnemiesData => _enemiesData.AsReadOnly(); + public int EnemyCount => _enemiesData.Count; + + public void Update() + { + _enemiesData.Clear(); + + var enemyListNumberInstance = EnemyListNumberArray.Instance(); + var enemyNumberArrayEnemies = enemyListNumberInstance->Enemies; + int enemyCount = *(int*)((byte*)enemyListNumberInstance + 0x04); + //TODO: Change it to the correct property when it lands in CS + //int enemyCount = enemyListNumberInstance->Unk1; + + if(enemyCount == 0) + { + return; + } + + for (int i = 0; i < enemyCount; i++) + { + int entityId = enemyNumberArrayEnemies[i].EntityId; + int? letter = GetEnemyLetter(entityId, i); + int enmityLevel = GetEnmityLevelForIndex(i); + _enemiesData.Add(new EnemyListData(entityId, letter, enmityLevel)); + } + } + + private int? GetEnemyLetter(int objectId, int index) + { + var enemyStringArrayMembers = EnemyListStringArray.Instance()->Members; + if (enemyStringArrayMembers.IsEmpty || enemyStringArrayMembers.Length <= index) + { + return null; + } + + string name = enemyStringArrayMembers[index].EnemyName; + + bool isMarked = Utils.SignIconIDForObjectID((uint)objectId) != null; + char letterSymbol = isMarked && name.Length > 1 ? name[2] : name[0]; + return letterSymbol - 57457; + } + + private int GetEnmityLevelForIndex(int index) + { + // gets enmity level by checking texture in enemy list addon + + AtkUnitBase* enemyList = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_EnemyList", 1).Address; + if (enemyList == null || enemyList->RootNode == null) { return 0; } + + int id = index == 0 ? 2 : 20000 + index; // makes no sense but it is what it is (blame SE) + AtkResNode* node = enemyList->GetNodeById((uint)id); + if (node == null || node->GetComponent() == null) { return 0; } + + AtkImageNode* imageNode = (AtkImageNode*)node->GetComponent()->UldManager.SearchNodeById(13); + if (imageNode == null) { return 0; } + + return Math.Min(4, imageNode->PartId + 1); + } + } + + public struct EnemyListData + { + public int EntityId; + public int? LetterIndex; + public int EnmityLevel; + + public EnemyListData(int entityId, int? letterIndex, int enmityLevel) + { + EntityId = entityId; + LetterIndex = letterIndex; + EnmityLevel = enmityLevel; + } + } +} diff --git a/Interface/EnemyList/EnemyListHud.cs b/Interface/EnemyList/EnemyListHud.cs new file mode 100644 index 0000000..db6fb03 --- /dev/null +++ b/Interface/EnemyList/EnemyListHud.cs @@ -0,0 +1,428 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Interface.Textures.TextureWraps; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.StatusEffects; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.EnemyList +{ + public class EnemyListHud : DraggableHudElement, IHudElementWithMouseOver, IHudElementWithPreview + { + private EnemyListConfig Config => (EnemyListConfig)_config; + private EnemyListConfigs Configs; + + private EnemyListHelper _helper = new EnemyListHelper(); + + private List _smoothHPHelpers = new List(); + + private const int MaxEnemyCount = 8; + private List _previewValues = new List(MaxEnemyCount); + + private bool _wasHovering = false; + + private LabelHud _nameLabelHud; + private LabelHud _healthLabelHud; + private LabelHud _orderLabelHud; + private List _castbarHud; + private StatusEffectsListHud _buffsListHud; + private StatusEffectsListHud _debuffsListHud; + + private IDalamudTextureWrap? _iconsTexture => TexturesHelper.GetTextureFromPath("ui/uld/enemylist_hr1.tex"); + + public EnemyListHud(EnemyListConfig config, string displayName) : base(config, displayName) + { + Configs = EnemyListConfigs.GetConfigs(); + + config.ValueChangeEvent += OnConfigPropertyChanged; + + _nameLabelHud = new LabelHud(Configs.HealthBar.NameLabel); + _healthLabelHud = new LabelHud(Configs.HealthBar.HealthLabel); + _orderLabelHud = new LabelHud(Configs.HealthBar.OrderLabel); + + _castbarHud = new List(); + _buffsListHud = new StatusEffectsListHud(Configs.Buffs); + _debuffsListHud = new StatusEffectsListHud(Configs.Debuffs); + + for (int i = 0; i < MaxEnemyCount; i++) + { + _smoothHPHelpers.Add(new SmoothHPHelper()); + _castbarHud.Add(new EnemyListCastbarHud(Configs.CastBar)); + } + + UpdatePreview(); + } + + protected override void InternalDispose() + { + _config.ValueChangeEvent -= OnConfigPropertyChanged; + } + + private void OnConfigPropertyChanged(object sender, OnChangeBaseArgs args) + { + if (args.PropertyName == "Preview") + { + UpdatePreview(); + } + } + + private void UpdatePreview() + { + _previewValues.Clear(); + if (!Config.Preview) { return; } + + Random RNG = new Random((int)ImGui.GetTime()); + + for (int i = 0; i < MaxEnemyCount; i++) + { + _previewValues.Add(RNG.Next(0, 101) / 100f); + } + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + Vector2 size = new Vector2(Configs.HealthBar.Size.X, MaxEnemyCount * Configs.HealthBar.Size.Y + (MaxEnemyCount - 1) * Config.VerticalPadding); + Vector2 pos = Config.GrowthDirection == EnemyListGrowthDirection.Down ? Config.Position : Config.Position - new Vector2(0, size.Y); + + return (new List() { pos + size / 2f }, new List() { size }); + } + + public void StopPreview() + { + Config.Preview = false; + + foreach (EnemyListCastbarHud castbar in _castbarHud) + { + castbar.StopPreview(); + } + + _buffsListHud.StopPreview(); + _debuffsListHud.StopPreview(); + Configs.HealthBar.MouseoverAreaConfig.Preview = false; + Configs.SignIcon.Preview = false; + } + + public void StopMouseover() + { + if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled) { return; } + + _helper.Update(); + + int count = Math.Min(MaxEnemyCount, Config.Preview ? MaxEnemyCount : _helper.EnemyCount); + uint fakeMaxHp = 100000; + + ICharacter? mouseoverTarget = null; + bool hovered = false; + + for (int i = 0; i < count; i++) + { + // hp bar + ICharacter? character = Config.Preview ? null : Plugin.ObjectTable.SearchById((uint)_helper.EnemiesData.ElementAt(i).EntityId) as ICharacter; + + uint currentHp = Config.Preview ? (uint)(_previewValues[i] * fakeMaxHp) : character?.CurrentHp ?? fakeMaxHp; + uint maxHp = Config.Preview ? fakeMaxHp : character?.MaxHp ?? fakeMaxHp; + int enmityLevel = Config.Preview ? Math.Max(4, i + 1) : _helper.EnemiesData.ElementAt(i).EnmityLevel; + + if (Configs.HealthBar.SmoothHealthConfig.Enabled) + { + currentHp = _smoothHPHelpers[i].GetNextHp((int)currentHp, (int)maxHp, Configs.HealthBar.SmoothHealthConfig.Velocity); + } + + int direction = Config.GrowthDirection == EnemyListGrowthDirection.Down ? 1 : -1; + float y = Config.Position.Y + i * direction * Configs.HealthBar.Size.Y + i * direction * Config.VerticalPadding; + Vector2 pos = new Vector2(Config.Position.X, y); + + PluginConfigColor fillColor = GetColor(character, currentHp, maxHp); + PluginConfigColor bgColor = Configs.HealthBar.BackgroundColor; + if (Configs.HealthBar.RangeConfig.Enabled) + { + fillColor = GetDistanceColor(character, fillColor); + bgColor = GetDistanceColor(character, bgColor); + } + Rect background = new Rect(pos, Configs.HealthBar.Size, bgColor); + + PluginConfigColor borderColor = GetBorderColor(character, enmityLevel); + Rect healthFill = BarUtilities.GetFillRect(pos, Configs.HealthBar.Size, Configs.HealthBar.FillDirection, fillColor, currentHp, maxHp); + + BarHud bar = new BarHud( + Configs.HealthBar.ID + $"_{i}", + Configs.HealthBar.DrawBorder, + borderColor, + GetBorderThickness(character), + DrawAnchor.TopLeft, + current: currentHp, + max: maxHp, + shadowConfig: Configs.HealthBar.ShadowConfig, + barTextureName: Configs.HealthBar.BarTextureName, + barTextureDrawMode: Configs.HealthBar.BarTextureDrawMode + ); + + bar.NeedsInputs = true; + bar.SetBackground(background); + bar.AddForegrounds(healthFill); + + if (Configs.HealthBar.Colors.UseMissingHealthBar) + { + Vector2 healthMissingSize = Configs.HealthBar.Size - BarUtilities.GetFillDirectionOffset(healthFill.Size, Configs.HealthBar.FillDirection); + Vector2 healthMissingPos = Configs.HealthBar.FillDirection.IsInverted() ? pos : pos + BarUtilities.GetFillDirectionOffset(healthFill.Size, Configs.HealthBar.FillDirection); + PluginConfigColor? color = Configs.HealthBar.RangeConfig.Enabled ? GetDistanceColor(character, Configs.HealthBar.Colors.HealthMissingColor) : Configs.HealthBar.Colors.HealthMissingColor; + bar.AddForegrounds(new Rect(healthMissingPos, healthMissingSize, color)); + } + + // highlight + var (areaStart, areaEnd) = Configs.HealthBar.MouseoverAreaConfig.GetArea(origin + pos, Configs.HealthBar.Size); + bool isHovering = character != null && ImGui.IsMouseHoveringRect(areaStart, areaEnd); + bool isSoftTarget = character != null && character.EntityId == Plugin.TargetManager.SoftTarget?.EntityId; + if (isHovering || isSoftTarget) + { + if (Configs.HealthBar.Colors.ShowHighlight) + { + Rect highlight = new Rect(pos, Configs.HealthBar.Size, Configs.HealthBar.Colors.HighlightColor); + bar.AddForegrounds(highlight); + } + + mouseoverTarget = character; + hovered = isHovering; + } + + AddDrawActions(bar.GetDrawActions(origin, Configs.HealthBar.StrataLevel)); + + // mouseover area + BarHud? mouseoverAreaBar = Configs.HealthBar.MouseoverAreaConfig.GetBar( + pos, + Configs.HealthBar.Size, + Configs.HealthBar.ID + "_mouseoverArea" + ); + + if (mouseoverAreaBar != null) + { + AddDrawActions(mouseoverAreaBar.GetDrawActions(origin, StrataLevel.HIGHEST)); + } + + // enmity icon + if (_iconsTexture != null && Configs.EnmityIcon.Enabled) + { + var parentPos = Utils.GetAnchoredPosition(origin + pos, -Configs.HealthBar.Size, Configs.EnmityIcon.HealthBarAnchor); + var iconPos = Utils.GetAnchoredPosition(parentPos + Configs.EnmityIcon.Position, Configs.EnmityIcon.Size, Configs.EnmityIcon.Anchor); + int enmityIndex = Config.Preview ? Math.Min(3, i) : _helper.EnemiesData.ElementAt(i).EnmityLevel - 1; + + AddDrawAction(Configs.EnmityIcon.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID + "_enmityIcon", iconPos, Configs.EnmityIcon.Size, false, (drawList) => + { + float w = 48f / _iconsTexture.Width; + float h = 48f / _iconsTexture.Height; + Vector2 uv0 = new Vector2(w * enmityIndex, 0.48f); + Vector2 uv1 = new Vector2(w * (enmityIndex + 1), 0.48f + h); + drawList.AddImage(_iconsTexture.Handle, iconPos, iconPos + Configs.EnmityIcon.Size, uv0, uv1); + }); + }); + } + + // sign icon + uint? signIconId = null; + if (Configs.SignIcon.Enabled) + { + signIconId = Configs.SignIcon.IconID(character); + if (signIconId.HasValue) + { + var parentPos = Utils.GetAnchoredPosition(origin + pos, -Configs.HealthBar.Size, Configs.SignIcon.HealthBarAnchor); + var iconPos = Utils.GetAnchoredPosition(parentPos + Configs.SignIcon.Position, Configs.SignIcon.Size, Configs.SignIcon.Anchor); + + AddDrawAction(Configs.SignIcon.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID + "_signIcon", iconPos, Configs.SignIcon.Size, false, (drawList) => + { + DrawHelper.DrawIcon(signIconId.Value, iconPos, Configs.SignIcon.Size, false, drawList); + }); + }); + } + } + + // labels + string? name = Config.Preview ? "Fake Name" : null; + AddDrawAction(Configs.HealthBar.NameLabel.StrataLevel, () => + { + _nameLabelHud.Draw(origin + pos, Configs.HealthBar.Size, character, name, currentHp, maxHp); + }); + + AddDrawAction(Configs.HealthBar.HealthLabel.StrataLevel, () => + { + _healthLabelHud.Draw(origin + pos, Configs.HealthBar.Size, character, name, currentHp, maxHp); + }); + + if (!signIconId.HasValue || !Configs.SignIcon.ReplaceOrderLabel) + { + int letter = i; + if (!Config.Preview && _helper.EnemiesData.ElementAt(i).LetterIndex.HasValue) + { + letter = _helper.EnemiesData.ElementAt(i).LetterIndex!.Value; + } + + string str = char.ConvertFromUtf32(0xE071 + letter).ToString(); + AddDrawAction(Configs.HealthBar.OrderLabel.StrataLevel, () => + { + Configs.HealthBar.OrderLabel.SetText(str); + _orderLabelHud.Draw(origin + pos, Configs.HealthBar.Size); + }); + } + + // buffs / debuffs + var buffsPos = Utils.GetAnchoredPosition(origin + pos, -Configs.HealthBar.Size, Configs.Buffs.HealthBarAnchor); + AddDrawAction(Configs.Buffs.StrataLevel, () => + { + _buffsListHud.Actor = character; + _buffsListHud.PrepareForDraw(buffsPos); + _buffsListHud.Draw(buffsPos); + }); + + var debuffsPos = Utils.GetAnchoredPosition(origin + pos, -Configs.HealthBar.Size, Configs.Debuffs.HealthBarAnchor); + AddDrawAction(Configs.Debuffs.StrataLevel, () => + { + _debuffsListHud.Actor = character; + _debuffsListHud.PrepareForDraw(debuffsPos); + _debuffsListHud.Draw(debuffsPos); + }); + + // castbar + EnemyListCastbarHud castbar = _castbarHud[i]; + castbar.EnemyListIndex = i; + + var castbarPos = Utils.GetAnchoredPosition(origin + pos, -Configs.HealthBar.Size, Configs.CastBar.HealthBarAnchor); + AddDrawAction(Configs.CastBar.StrataLevel, () => + { + castbar.Actor = character; + castbar.PrepareForDraw(castbarPos); + castbar.Draw(castbarPos); + }); + } + + // mouseover + bool ignoreMouseover = Configs.HealthBar.MouseoverAreaConfig.Enabled && Configs.HealthBar.MouseoverAreaConfig.Ignore; + if (hovered && mouseoverTarget != null) + { + _wasHovering = true; + InputsHelper.Instance.SetTarget(mouseoverTarget, ignoreMouseover); + + // left click + if (InputsHelper.Instance.LeftButtonClicked) + { + Plugin.TargetManager.Target = mouseoverTarget; + } + } + else if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + } + + private PluginConfigColor GetColor(ICharacter? character, uint currentHp = 0, uint maxHp = 0) + { + if (Configs.HealthBar.Colors.ColorByHealth.Enabled && (character != null || Config.Preview)) + { + var scale = (float)currentHp / Math.Max(1, maxHp); + return ColorUtils.GetColorByScale(scale, Configs.HealthBar.Colors.ColorByHealth); + } + + return Configs.HealthBar.FillColor; + } + + private PluginConfigColor GetBorderColor(ICharacter? character, int enmityLevel) + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + if (character != null && target != null && character.EntityId == target.EntityId) + { + return Configs.HealthBar.Colors.TargetBordercolor; + } + + if (!Configs.HealthBar.Colors.ShowEnmityBorderColors) + { + return Configs.HealthBar.BorderColor; + } + + return enmityLevel switch + { + >= 3 => Configs.HealthBar.Colors.EnmityLeaderBorderColor, + >= 1 => Configs.HealthBar.Colors.EnmitySecondBorderColor, + _ => Configs.HealthBar.BorderColor + }; + } + + private int GetBorderThickness(ICharacter? character) + { + IGameObject? target = Plugin.TargetManager.Target; + if (character != null && character == target) + { + return Configs.HealthBar.Colors.TargetBorderThickness; + } + + return Configs.HealthBar.BorderThickness; + } + + private PluginConfigColor GetDistanceColor(ICharacter? character, PluginConfigColor color) + { + byte distance = character != null ? character.YalmDistanceX : byte.MaxValue; + float currentAlpha = color.Vector.W * 100f; + float alpha = Configs.HealthBar.RangeConfig.AlphaForDistance(distance, currentAlpha) / 100f; + + return color.WithAlpha(alpha); + } + } + + #region utils + public struct EnemyListConfigs + { + public EnemyListHealthBarConfig HealthBar; + public EnemyListEnmityIconConfig EnmityIcon; + public EnemyListSignIconConfig SignIcon; + public EnemyListCastbarConfig CastBar; + public EnemyListBuffsConfig Buffs; + public EnemyListDebuffsConfig Debuffs; + + public EnemyListConfigs( + EnemyListHealthBarConfig healthBar, + EnemyListEnmityIconConfig enmityIcon, + EnemyListSignIconConfig signIcon, + EnemyListCastbarConfig castBar, + EnemyListBuffsConfig buffs, + EnemyListDebuffsConfig debuffs) + { + HealthBar = healthBar; + EnmityIcon = enmityIcon; + SignIcon = signIcon; + CastBar = castBar; + Buffs = buffs; + Debuffs = debuffs; + } + + public static EnemyListConfigs GetConfigs() + { + return new EnemyListConfigs( + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject() + ); + } + } + #endregion +} diff --git a/Interface/GeneralElements/ActionBarsHud.cs b/Interface/GeneralElements/ActionBarsHud.cs new file mode 100644 index 0000000..b31a350 --- /dev/null +++ b/Interface/GeneralElements/ActionBarsHud.cs @@ -0,0 +1,1394 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.Gui; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using Dalamud.Utility; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Lumina.Text.ReadOnly; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Component.GUI; +using LuminaAction = Lumina.Excel.Sheets.Action; + +namespace HSUI.Interface.GeneralElements +{ + public class ActionBarsHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig + { + private HotbarBarConfig Config => (HotbarBarConfig)_config; + + private bool _wasDragging; + private uint _dragId; + private RaptureHotbarModule.HotbarSlotType _dragSlotType; + private int _pendingSlotIconIndex = -1; + private uint _pendingSlotIconId; + private int _pendingSlotIconFramesLeft; + /// When we process a game drop on a slot, we must suppress the InvisibleButton "click" that would trigger ExecuteSlot. + private int _lastFrameDroppedOnSlot = -1; + /// When we had a game drag and the user released, suppress all slot clicks this frame (release is not a click). + private bool _suppressSlotClicksAfterDragRelease; + + private const string SlotPayloadType = "HSUI_HOTBAR_SLOT"; + private const int SlotsPerBar = 12; + + private static int _imGuiDragSourceSlotId = -1; + private static bool _anyHotbarAcceptedDrop; + private static int _lastOverlayFrame = -1; + /// Deferred clear when release-outside: set when no overlay accepted, executed next frame. + private static int _pendingReleaseOutsideSlotId = -1; + /// To avoid PICKUP log spam: only log when we first start dragging a new slot. + private static int _lastLoggedPickupSlotId = -1; + + /// Encode hotbar (1-10) and slot (0-11) into internal slot id (0-119). + private static int ToSlotId(int hotbarIndex, int slotIndex) + { + int bar = Math.Clamp(hotbarIndex, 1, 10) - 1; + int slot = Math.Clamp(slotIndex, 0, 11); + return bar * SlotsPerBar + slot; + } + + /// Decode internal slot id to hotbar (1-10) and slot (0-11). Returns (-1,-1) if invalid. + private static (int hotbarIndex, int slotIndex) FromSlotId(int slotId) + { + if (slotId < 0 || slotId >= 10 * SlotsPerBar) return (-1, -1); + return (slotId / SlotsPerBar + 1, slotId % SlotsPerBar); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SlotDragPayload + { + public int SlotId; + } + + /// + /// Visibility for HSUI Action Bars is driven by Visibility → Hotbars (Hotbar 1–10), not the per-element config. + /// + public VisibilityConfig VisibilityConfig => GetHotbarVisibilityConfig(); + + private VisibilityConfig GetHotbarVisibilityConfig() + { + var hotbars = ConfigurationManager.Instance?.GetConfigObject(); + if (hotbars == null) return Config.VisibilityConfig; + var list = hotbars.GetHotbarConfigs(); + int idx = Config.HotbarIndex - 1; + if (idx < 0 || idx >= list.Count) return Config.VisibilityConfig; + return list[idx]; + } + + public IGameObject? Actor { get; set; } + + public ActionBarsHud(HotbarBarConfig config, string displayName) : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + var size = BarSize(); + return (new List { Config.Position }, new List { size }); + } + + private Vector2 BarSize() + { + var (cols, rows) = Config.GetLayoutGrid(); + int effectiveCols = Math.Min(cols, Config.SlotCount); + int effectiveRows = (Config.SlotCount + effectiveCols - 1) / effectiveCols; + float w = effectiveCols * Config.SlotSize.X + (effectiveCols - 1) * Config.SlotPadding; + float h = effectiveRows * Config.SlotSize.Y + (effectiveRows - 1) * Config.SlotPadding; + return new Vector2(w, h); + } + + private Vector2 GetSlotPosition(int slotIndex, Vector2 slotSize, int pad) + { + var (cols, _) = Config.GetLayoutGrid(); + int col = slotIndex % cols; + int row = slotIndex / cols; + return new Vector2(col * (slotSize.X + pad), row * (slotSize.Y + pad)); + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled || Actor == null) + return; + + if (ActionBarsManager.Instance == null) + return; + + _lastFrameDroppedOnSlot = -1; + _suppressSlotClicksAfterDragRelease = false; + + var barSize = BarSize(); + var topLeft = Utils.GetAnchoredPosition(origin + Config.Position, barSize, Config.Anchor); + var slotSize = Config.SlotSize; + int pad = Config.SlotPadding; + bool showCd = Config.ShowCooldownOverlay; + bool showCdNumbers = Config.ShowCooldownNumbers; + bool showBorder = Config.ShowBorder; + + AddDrawAction(Config.StrataLevel, () => + { + HandleDragDrop(topLeft, barSize, slotSize, pad); + // Refresh slot data after placement so overwrite shows correctly + var slots = ActionBarsManager.Instance.GetSlotData(Config.HotbarIndex, Config.SlotCount); + + void DrawBarContent(ImDrawListPtr drawList) + { + using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + { + for (int i = 0; i < slots.Count; i++) + { + var slot = slots[i]; + uint displayIconId = slot.IconId; + bool isEmpty = slot.IsEmpty; + bool usePending = _pendingSlotIconIndex == i && _pendingSlotIconId != 0; + if (usePending) + { + displayIconId = _pendingSlotIconId; + isEmpty = false; + } + + var slotOffset = GetSlotPosition(i, slotSize, pad); + var pos = topLeft + slotOffset; + var size = new Vector2(slotSize.X, slotSize.Y); + bool hovered = !isEmpty && ImGui.IsMouseHoveringRect(pos, pos + size); + + if (isEmpty) + { + drawList.AddRectFilled(pos, pos + size, 0x22000000); + if (showBorder) + drawList.AddRect(pos, pos + size, 0xFF444444); + } + else + { + uint color = hovered ? 0xFFFFFFFF : (slot.IsUsable ? 0xFFFFFFFF : 0xAA888888); + bool useHighlightBorder = hovered && showBorder; + bool isComboNext = Config.ShowComboHighlight && IsComboNextAction(slot); + DrawHelper.DrawIcon(displayIconId, pos, size, showBorder && !useHighlightBorder && !isComboNext, color, drawList); + + if (useHighlightBorder) + drawList.AddRect(pos, pos + size, 0xFF88CCFF, 0, ImDrawFlags.None, 2); + else if (isComboNext) + { + const uint gold = 0xFFFFD700; + for (int g = 5; g >= 1; g--) + drawList.AddRect(pos - new Vector2(g, g), pos + size + new Vector2(g, g), (uint)((byte)(70 * (6 - g)) << 24 | (gold & 0xFFFFFF)), 0, ImDrawFlags.None, 2f); + drawList.AddRect(pos, pos + size, gold, 0, ImDrawFlags.None, 4f); + } + + if (showCd && slot.CooldownPercent > 0 && _pendingSlotIconIndex != i) + { + float total = 100f; + float elapsed = total - slot.CooldownPercent; + DrawHelper.DrawIconCooldown(pos, size, elapsed, total, drawList); + if (showCdNumbers && slot.CooldownSecondsLeft > 0) + { + string cdText = slot.CooldownSecondsLeft.ToString(); + Vector2 textSize = ImGui.CalcTextSize(cdText); + Vector2 textPos = pos + (size - textSize) * 0.5f; + DrawHelper.DrawOutlinedText(cdText, textPos, 0xFFFFFFFF, 0xFF000000, drawList, 1); + } + } + } + + if (Config.ShowSlotNumbers) + { + string label = !string.IsNullOrWhiteSpace(slot.KeybindHint) + ? slot.KeybindHint + : ActionBarsManager.GetDefaultKeybindLabel(Config.HotbarIndex, i); + Vector2 labelSize = ImGui.CalcTextSize(label); + Vector2 labelPos = new Vector2(pos.X + size.X - labelSize.X - 2, pos.Y + size.Y - labelSize.Y - 2); + DrawHelper.DrawOutlinedText(label, labelPos, 0xFFFFFFFF, 0xFF000000, drawList, 1); + } + } + } + } + + if (IsGameDragging()) + { + var mp = ImGui.GetMousePos(); + float hole = 32f; + var cursorRect = new ClipRect(mp - new Vector2(hole), mp + new Vector2(hole)); + var barRect = new ClipRect(topLeft, topLeft + barSize); + var inverted = ClipRectsHelper.GetInvertedClipRects(cursorRect); + var drawList = ImGui.GetWindowDrawList(); + for (int i = 0; i < inverted.Length; i++) + { + var inter = barRect.Intersect(inverted[i]); + if (!inter.HasValue) continue; + var r = inter.Value; + ImGui.PushClipRect(r.Min, r.Max, false); + DrawBarContent(drawList); + ImGui.PopClipRect(); + } + } + else + { + DrawHelper.DrawInWindow(ID + "_actionbars", topLeft, barSize, false, DrawBarContent); + } + }); + + // Overlay at HIGHEST so it captures input above the bar; required for icon swap. + // Use unique ID per hotbar to avoid any cross-bar conflicts (bar 2–10 had input capture issues). + AddDrawAction(StrataLevel.HIGHEST, () => + { + var slots = ActionBarsManager.Instance.GetSlotData(Config.HotbarIndex, Config.SlotCount); + var flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings + | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus + | ImGuiWindowFlags.NoNav; + + ImGuiHelpers.ForceNextWindowMainViewport(); + ImGui.SetNextWindowPos(topLeft); + ImGui.SetNextWindowSize(barSize); + string overlayId = $"HSUI_Hotbar{Config.HotbarIndex}_input"; + if (!ImGui.Begin(overlayId, flags)) + { + ImGui.End(); + return; + } + + int frame = ImGui.GetFrameCount(); + if (frame != _lastOverlayFrame) + { + _lastOverlayFrame = frame; + _anyHotbarAcceptedDrop = false; + // Process deferred release-outside clear from previous frame (must run before overlays) + if (_pendingReleaseOutsideSlotId >= 0 && GetHotbarsConfig()?.GeneralOptions?.EnableReleaseOutsideToClear != false) + { + var (bar, slot) = FromSlotId(_pendingReleaseOutsideSlotId); + if (bar >= 1 && slot >= 0) + ActionBarsManager.Instance?.ClearSlot(bar, slot); + _pendingReleaseOutsideSlotId = -1; + } + } + + bool acceptedDrop = false; + for (int i = 0; i < Config.SlotCount; i++) + { + var slotOffset = GetSlotPosition(i, slotSize, pad); + var slotPos = topLeft + slotOffset; + ImGui.SetCursorScreenPos(slotPos); + bool clicked = ImGui.InvisibleButton($"##{overlayId}_slot_{i}", slotSize); + bool isHovered = ImGui.IsItemHovered(); + bool isActive = ImGui.IsItemActive(); + + bool shiftHeld = ImGui.GetIO().KeyShift; + + int targetSlotId = ToSlotId(Config.HotbarIndex, i); + + if (GetHotbarsConfig()?.GeneralOptions?.EnableShiftDragToRearrange != false && ImGui.BeginDragDropTarget()) + { + var payload = ImGui.AcceptDragDropPayload(SlotPayloadType); + if (payload != ImGuiPayloadPtr.Null && payload.IsDataType(SlotPayloadType) && TryGetSlotPayload(payload, out var src)) + { + acceptedDrop = true; + _anyHotbarAcceptedDrop = true; + _imGuiDragSourceSlotId = -1; + _pendingReleaseOutsideSlotId = -1; + _lastLoggedPickupSlotId = -1; + if (src.SlotId != targetSlotId) + { + var (srcBar, srcSlot) = FromSlotId(src.SlotId); + if (srcBar >= 1 && srcSlot >= 0) + { + ActionBarsManager.Instance?.SwapSlots(srcBar, srcSlot, Config.HotbarIndex, i, + Config.DebugDragDrop ? s => Plugin.Logger.Information($"[HSUI DragDrop DBG] {s}") : null); + } + } + } + ImGui.EndDragDropTarget(); + } + + if (GetHotbarsConfig()?.GeneralOptions?.EnableShiftDragToRearrange != false && !IsGameDragging() && shiftHeld && i < slots.Count && !slots[i].IsEmpty && isActive && ImGui.IsMouseDragging(ImGuiMouseButton.Left)) + { + if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.None)) + { + _imGuiDragSourceSlotId = targetSlotId; + if (Config.DebugDragDrop && _lastLoggedPickupSlotId != targetSlotId) + { + _lastLoggedPickupSlotId = targetSlotId; + Plugin.Logger.Information($"[HSUI DragDrop DBG] Shift+drag PICKUP: bar={Config.HotbarIndex} slot={i} slotId={targetSlotId}"); + } + SetSlotPayload(new SlotDragPayload { SlotId = targetSlotId }); + var icon = TexturesHelper.GetTextureFromIconId(slots[i].IconId); + if (icon != null) + ImGui.Image(icon.Handle, new Vector2(32, 32)); + ImGui.EndDragDropSource(); + } + } + + if (clicked && !acceptedDrop && !ImGui.IsMouseDragging(ImGuiMouseButton.Left) && i < slots.Count && !slots[i].IsEmpty) + { + if (_suppressSlotClicksAfterDragRelease || i == _lastFrameDroppedOnSlot) + { + if (Config.DebugDragDrop) + Plugin.Logger.Information($"[HSUI DragDrop DBG] Suppressed ExecuteSlot slot={i} suppressAfterDrag={_suppressSlotClicksAfterDragRelease} lastDroppedOn={_lastFrameDroppedOnSlot}"); + _suppressSlotClicksAfterDragRelease = false; + _lastFrameDroppedOnSlot = -1; + } + else if (shiftHeld) + { + ActionBarsManager.Instance?.ClearSlot(Config.HotbarIndex, i); + } + else + { + if (Config.DebugDragDrop) + Plugin.Logger.Information($"[HSUI DragDrop DBG] ExecuteSlot hotbar={Config.HotbarIndex} slot={i}"); + ActionBarsManager.Instance?.ExecuteSlot(Config.HotbarIndex, i); + } + } + + if (Config.ShowTooltips && isHovered && i < slots.Count) + { + var slot = slots[i]; + if (!slot.IsEmpty) + { + var (title, text) = GetSlotTooltip(slot); + if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text)) + { + string body = string.IsNullOrEmpty(text) ? title : text; + TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, ""); + if (IsTooltipDebugEnabled()) + Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (main overlay): slot={i} title='{title}'"); + } + } + } + } + + // Release-outside clear: defer to next frame so we run after all overlays have had a chance to accept + if (GetHotbarsConfig()?.GeneralOptions?.EnableReleaseOutsideToClear != false && ImGui.GetIO().KeyShift && _imGuiDragSourceSlotId >= 0 && + ImGui.IsMouseReleased(ImGuiMouseButton.Left) && !_anyHotbarAcceptedDrop) + { + if (Config.DebugDragDrop) + { + var (bar, slot) = FromSlotId(_imGuiDragSourceSlotId); + Plugin.Logger.Information($"[HSUI DragDrop DBG] Release outside: defer clear bar={bar} slot={slot}"); + } + _pendingReleaseOutsideSlotId = _imGuiDragSourceSlotId; + _imGuiDragSourceSlotId = -1; + _lastLoggedPickupSlotId = -1; + } + + ImGui.End(); + }); + } + + /// Tooltip-only overlay when click overlay is skipped (proxy on / HUD not locked). Uses NoInputs so it doesn't capture clicks. + private void DrawTooltipOnlyOverlay(Vector2 topLeft, Vector2 barSize, Vector2 slotSize, int pad) + { + if (!Config.ShowTooltips) + return; + var slots = ActionBarsManager.Instance?.GetSlotData(Config.HotbarIndex, Config.SlotCount); + if (slots == null) + return; + var flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings + | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus + | ImGuiWindowFlags.NoInputs; + ImGuiHelpers.ForceNextWindowMainViewport(); + ImGui.SetNextWindowPos(topLeft); + ImGui.SetNextWindowSize(barSize); + if (!ImGui.Begin(ID + "_actionbars_tooltip_only", flags)) + { + ImGui.End(); + return; + } + for (int i = 0; i < Config.SlotCount && i < slots.Count; i++) + { + var slotOffset = GetSlotPosition(i, slotSize, pad); + var slotPos = topLeft + slotOffset; + if (ImGui.IsMouseHoveringRect(slotPos, slotPos + slotSize)) + { + var slot = slots[i]; + if (!slot.IsEmpty) + { + var (title, text) = GetSlotTooltip(slot); + if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text)) + { + string body = string.IsNullOrEmpty(text) ? title : text; + TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, ""); + if (IsTooltipDebugEnabled()) + Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (overlay): slot={i} title='{title}'"); + } + } + break; + } + } + ImGui.End(); + } + + private static bool IsTooltipDebugEnabled() + { + try + { + return ConfigurationManager.Instance?.GetConfigObject()?.DebugTooltips == true; + } + catch { return false; } + } + + private static (uint LastActionId, string? LastDesc, double LastTime) _tooltipDebugLogState; + private static bool ShouldLogTooltipDebug(uint actionId, string name, string? desc) + { + double now = ImGui.GetTime(); + var (lastId, lastDesc, lastTime) = _tooltipDebugLogState; + if (actionId == lastId && desc == lastDesc && now - lastTime < 1f) + return false; + _tooltipDebugLogState = (actionId, desc, now); + return true; + } + + private static bool TryGetDebug() + { + try + { + var configs = ConfigurationManager.Instance?.GetObjects(); + return configs != null && configs.Exists(c => c.DebugDragDrop); + } + catch { return false; } + } + + private static (int Int1, int Int2, double LastTime) _lastItemPayloadLog; + private static bool ShouldLogItemPayload(int int1, int int2) + { + double now = ImGui.GetTime(); + var last = _lastItemPayloadLog; + if (int1 == last.Int1 && int2 == last.Int2 && now - last.LastTime < 0.5) + return false; + _lastItemPayloadLog = (int1, int2, now); + return true; + } + + private static (int Int1, int Int2, double LastTime) _lastMacroPayloadLog; + private static bool ShouldLogMacroPayload(int rawInt1, int rawInt2) + { + double now = ImGui.GetTime(); + var last = _lastMacroPayloadLog; + if (rawInt1 == last.Int1 && rawInt2 == last.Int2 && now - last.LastTime < 2.0) + return false; + _lastMacroPayloadLog = (rawInt1, rawInt2, now); + return true; + } + + private static HotbarsConfig? GetHotbarsConfig() => + ConfigurationManager.Instance?.GetConfigObject(); + + private static (int Type, int Int1, int Int2, double LastTime) _lastDragNoPayloadLog; + private static unsafe bool ShouldLogDragNoPayload(AtkDragDropManager* dm) + { + if (dm == null) return false; + var dd = dm->DragDrop1; + int type = dd != null ? (int)dd->DragDropType : 0; + int i1 = dm->PayloadContainer.Int1; + int i2 = dm->PayloadContainer.Int2; + double now = ImGui.GetTime(); + var last = _lastDragNoPayloadLog; + if (type == last.Type && i1 == last.Int1 && i2 == last.Int2 && now - last.LastTime < 0.5) + return false; + _lastDragNoPayloadLog = (type, i1, i2, now); + return true; + } + + private static unsafe bool IsGameDragging() + { + var stage = AtkStage.Instance(); + if (stage == null) return false; + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + return dm->IsDragging; + } + + private static unsafe bool TryGetSlotPayload(ImGuiPayloadPtr payload, out SlotDragPayload src) + { + if (payload.Data == null || payload.DataSize < sizeof(SlotDragPayload)) + { + src = default; + return false; + } + src = *(SlotDragPayload*)payload.Data; + return true; + } + + private static void SetSlotPayload(SlotDragPayload pl) + { + var type = new ImU8String(SlotPayloadType); + var data = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref pl, 1)); + ImGui.SetDragDropPayload(type, data, ImGuiCond.None); + type.Recycle(); + } + + private static bool IsComboNextAction(ActionBarsManager.SlotInfo slot) + { + if (slot.IsEmpty || slot.ActionId == 0) return false; + try + { + var slotType = slot.SlotType; + var actionType = slotType switch + { + RaptureHotbarModule.HotbarSlotType.Action => ActionType.Action, + RaptureHotbarModule.HotbarSlotType.GeneralAction => ActionType.GeneralAction, + RaptureHotbarModule.HotbarSlotType.CraftAction => ActionType.CraftAction, + RaptureHotbarModule.HotbarSlotType.PetAction => ActionType.PetAction, + _ => (ActionType?)null + }; + if (!actionType.HasValue) return false; + return SpellHelper.Instance.IsActionHighlighted(slot.ActionId, actionType.Value); + } + catch { return false; } + } + + private unsafe void HandleDragDrop(Vector2 topLeft, Vector2 barSize, Vector2 slotSize, int pad) + { + bool debug = Config.DebugDragDrop; + + // NOTE: Release-outside clear is done in the overlay only. HandleDragDrop runs before overlay + // processes AcceptDragDropPayload, so we must not clear here or we'd clear the source before + // the drop is processed (breaking swap/move when dropping on a slot). + + if (_pendingSlotIconIndex >= 0 && _pendingSlotIconFramesLeft > 0) + { + _pendingSlotIconFramesLeft--; + if (_pendingSlotIconFramesLeft <= 0) + _pendingSlotIconIndex = -1; + } + else if (_pendingSlotIconIndex >= 0) + { + _pendingSlotIconIndex = -1; + } + + var stage = AtkStage.Instance(); + if (stage == null) return; + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + bool dragging = dm->IsDragging; + + if (dragging) + { + if (GetHotbarsConfig()?.GeneralOptions?.EnableDragDropFromGame != false && TryGetDragPayload(out var slotType, out var id)) + { + _dragSlotType = slotType; + _dragId = id; + if (!_wasDragging && debug) + Plugin.Logger.Information($"[HSUI DragDrop] Game drag detected, payload: type={slotType} id={id}"); + _wasDragging = true; + + var mp = ImGui.GetMousePos(); + int idx = GetSlotIndexAtPosition(topLeft, barSize, slotSize, pad, Config.SlotCount, Config.GetLayoutGrid().Cols, mp, false); + if (idx >= 0) + { + InputsHelper.SuppressExecuteSlotByIdAfterDrop((uint)(Config.HotbarIndex - 1), (uint)idx, 500); + } + + if (ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + int releaseIdx = GetSlotIndexAtPosition(topLeft, barSize, slotSize, pad, Config.SlotCount, Config.GetLayoutGrid().Cols, mp, debug); + if (releaseIdx >= 0 && slotType != RaptureHotbarModule.HotbarSlotType.Empty && (id != 0 || slotType == RaptureHotbarModule.HotbarSlotType.Macro)) + { + if (!ImGui.GetIO().KeyShift) + { + try + { + var module = RaptureHotbarModule.Instance(); + if (module != null && module->ModuleReady) + { + uint barId = (uint)(Config.HotbarIndex - 1); + ref var displayBar = ref module->StandardHotbars[(int)barId]; + var displaySlot = displayBar.GetHotbarSlot((uint)releaseIdx); + if (displaySlot != null) + { + displaySlot->Set(slotType, id); + displaySlot->LoadIconId(); + } + module->SetAndSaveSlot(barId, (uint)releaseIdx, slotType, id); + uint iconId = GetIconIdForPayload(slotType, id); + if (iconId == 0) + { + var bar = module->StandardHotbars[(int)barId]; + var writtenSlot = bar.GetHotbarSlot((uint)releaseIdx); + if (writtenSlot != null) + iconId = (uint)writtenSlot->IconId; + } + if (iconId != 0) + { + _pendingSlotIconIndex = releaseIdx; + _pendingSlotIconId = iconId; + _pendingSlotIconFramesLeft = 300; + } + _lastFrameDroppedOnSlot = releaseIdx; + InputsHelper.SuppressExecuteSlotByIdAfterDrop(barId, (uint)releaseIdx, 300); + if (slotType == RaptureHotbarModule.HotbarSlotType.Action || + slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction || + slotType == RaptureHotbarModule.HotbarSlotType.CraftAction || + slotType == RaptureHotbarModule.HotbarSlotType.PetAction) + InputsHelper.SuppressUseActionAfterDrop(id, 300); + if (debug) + Plugin.Logger.Information($"[HSUI DragDrop] Placed {slotType} id={id} on slot {releaseIdx}"); + } + } + catch (Exception ex) + { + Plugin.Logger.Warning($"[HSUI DragDrop] Drop failed: {ex.Message}"); + } + } + try { dm->CancelDragDrop(true, true); } + catch { } + } + else + { + try { dm->CancelDragDrop(true, true); } + catch { } + } + _wasDragging = false; + } + } + else + { + _dragSlotType = RaptureHotbarModule.HotbarSlotType.Empty; + _dragId = 0; + _wasDragging = true; + if (ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + try { dm->CancelDragDrop(true, true); } + catch { } + _wasDragging = false; + } + if (debug && ShouldLogDragNoPayload(dm)) + { + try + { + var dd = dm->DragDrop1; + if (dd != null) + Plugin.Logger.Information($"[HSUI DragDrop DBG] Drag but no payload (clearing cache): DragDropType={dd->DragDropType} Int1={dm->PayloadContainer.Int1} Int2={dm->PayloadContainer.Int2} HoveredItem={Plugin.GameGui?.HoveredItem ?? 0}"); + } + catch { } + } + } + return; + } + + if (_wasDragging) + { + _suppressSlotClicksAfterDragRelease = true; + var mp = ImGui.GetMousePos(); + int idx = GetSlotIndexAtPosition(topLeft, barSize, slotSize, pad, Config.SlotCount, Config.GetLayoutGrid().Cols, mp, debug); + + if (idx >= 0) + { + var slotType = _dragSlotType; + var id = _dragId; + // Only use payload we captured during the drag. Do NOT fall back to TryGetDragPayload here: + // after we eat LBUTTONUP, the game drag is cancelled and HoveredAction/HoveredItem can be + // stale (e.g. from macro menu UI), causing wrong placements (macro drag -> GeneralAction placed). + // If we never got valid payload (e.g. Macro Int2=0), skip the place. + bool hasValidPayload = slotType != RaptureHotbarModule.HotbarSlotType.Empty && + (id != 0 || slotType == RaptureHotbarModule.HotbarSlotType.Macro); + + if (hasValidPayload) + { + bool shiftHeld = ImGui.GetIO().KeyShift; + if (shiftHeld) + { + try { dm->CancelDragDrop(true, true); } + catch (Exception ex) { Plugin.Logger.Warning($"[HSUI DragDrop] CancelDragDrop: {ex.Message}"); } + ActionBarsManager.Instance?.ClearSlot(Config.HotbarIndex, idx); + if (debug) + Plugin.Logger.Information($"[HSUI DragDrop] Shift+release: cleared slot {idx}"); + _lastFrameDroppedOnSlot = idx; + _wasDragging = false; + return; + } + + try + { + var module = RaptureHotbarModule.Instance(); + if (module != null && module->ModuleReady) + { + uint barId = (uint)(Config.HotbarIndex - 1); + ref var displayBar = ref module->StandardHotbars[(int)barId]; + var displaySlot = displayBar.GetHotbarSlot((uint)idx); + if (displaySlot != null) + { + displaySlot->Set(slotType, id); + displaySlot->LoadIconId(); + } + module->SetAndSaveSlot(barId, (uint)idx, slotType, id); + try { dm->CancelDragDrop(true, true); } + catch (Exception ex2) { Plugin.Logger.Warning($"[HSUI DragDrop] CancelDragDrop: {ex2.Message}"); } + + uint iconId = GetIconIdForPayload(slotType, id); + if (iconId == 0) + { + var bar = module->StandardHotbars[(int)barId]; + var writtenSlot = bar.GetHotbarSlot((uint)idx); + if (writtenSlot != null) + iconId = (uint)writtenSlot->IconId; + } + if (iconId != 0) + { + _pendingSlotIconIndex = idx; + _pendingSlotIconId = iconId; + _pendingSlotIconFramesLeft = 300; + } + _lastFrameDroppedOnSlot = idx; + InputsHelper.SuppressExecuteSlotByIdAfterDrop(barId, (uint)idx, 300); + if (slotType == RaptureHotbarModule.HotbarSlotType.Action || + slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction || + slotType == RaptureHotbarModule.HotbarSlotType.CraftAction || + slotType == RaptureHotbarModule.HotbarSlotType.PetAction) + InputsHelper.SuppressUseActionAfterDrop(id, 300); + if (debug) + Plugin.Logger.Information($"[HSUI DragDrop] Placed {slotType} id={id} on slot {idx}"); + } + } + catch (Exception ex) + { + Plugin.Logger.Warning($"[HSUI DragDrop] Drop processing failed: {ex.Message}\n{ex.StackTrace}"); + } + } + else if (debug) + { + Plugin.Logger.Information($"[HSUI DragDrop] Release over slot {idx} but invalid payload: type={slotType} id={id}"); + } + } + else if (debug) + { + Plugin.Logger.Information($"[HSUI DragDrop] Release outside bar area (idx={idx})"); + } + _wasDragging = false; + } + else if (!dragging) + { + _wasDragging = false; + } + } + + private const float DropMargin = 24f; + + private static int GetSlotIndexAtPosition(Vector2 topLeft, Vector2 barSize, Vector2 slotSize, int pad, int slotCount, int cols, Vector2 pos, bool debug = false) + { + // topLeft and InvisibleButtons use the same coordinate system as ImGui.SetCursorScreenPos/GetMousePos. + // Use strict bar bounds (no extended reach) so only one hotbar matches when bars are stacked. + var viewport = ImGui.GetMainViewport(); + Vector2 adjPos = pos - viewport.Pos; + + int TryHit(Vector2 p) + { + float barMinX = topLeft.X; + float barMaxX = topLeft.X + barSize.X; + float barMinY = topLeft.Y; + float barMaxY = topLeft.Y + barSize.Y; + if (p.X < barMinX || p.X >= barMaxX || p.Y < barMinY || p.Y >= barMaxY) + return -1; + int best = -1; + float bestDistSq = float.MaxValue; + for (int i = 0; i < slotCount; i++) + { + int col = i % cols; + int row = i / cols; + float cx = topLeft.X + col * (slotSize.X + pad) + slotSize.X * 0.5f; + float cy = topLeft.Y + row * (slotSize.Y + pad) + slotSize.Y * 0.5f; + float dx = p.X - cx, dy = p.Y - cy; + float distSq = dx * dx + dy * dy; + float slotHalfW = slotSize.X * 0.5f + DropMargin; + float slotHalfH = slotSize.Y * 0.5f + DropMargin; + bool inSlot = System.Math.Abs(dx) <= slotHalfW && System.Math.Abs(dy) <= slotHalfH; + if (inSlot && distSq < bestDistSq) { bestDistSq = distSq; best = i; } + } + return best; + } + + int idx = TryHit(pos); + if (idx < 0 && adjPos != pos) + idx = TryHit(adjPos); + + if (debug) + Plugin.Logger.Information($"[HSUI DragDrop DBG] HitTest: rawMouse=({pos.X:F1},{pos.Y:F1}) viewport.Pos=({viewport.Pos.X:F1},{viewport.Pos.Y:F1}) adjPos=({adjPos.X:F1},{adjPos.Y:F1}) slotIdx={idx}"); + return idx; + } + + private static unsafe uint GetActiveHotbarClassJobId() + { + var module = RaptureHotbarModule.Instance(); + if (module != null) + return module->ActiveHotbarClassJobId; + var player = Plugin.ObjectTable?.LocalPlayer; + return player != null ? (uint)player.ClassJob.RowId : 0; + } + + /// + /// Gets drag payload from game/Dalamud. When game reports Item drag, prioritize HoveredItem + /// (HoveredAction can be stale from previous hotbar hover). Preserves full item id including HQ flag. + /// + private static unsafe bool TryGetDragPayload(out RaptureHotbarModule.HotbarSlotType slotType, out uint id) + { + slotType = RaptureHotbarModule.HotbarSlotType.Empty; + id = 0; + DragDropType gameType = DragDropType.Nothing; + try + { + var stage = AtkStage.Instance(); + if (stage != null) + { + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + var dd = dm->DragDrop1; + if (dd != null) + gameType = dd->DragDropType; + } + } + catch { } + + bool isItemDrag = gameType is DragDropType.Item or DragDropType.Inventory_Item or DragDropType.RemoteInventory_Item + or DragDropType.ActionBar_Item or DragDropType.LetterEditor_Item; + + bool isMacroDrag = gameType is DragDropType.Macro or DragDropType.ActionBar_Macro; + if (isMacroDrag) + { + try + { + var stage = AtkStage.Instance(); + if (stage != null) + { + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + int rawInt1 = dm->PayloadContainer.Int1; + int rawInt2 = dm->PayloadContainer.Int2; + // Int1=1 or 49 -> Individual (set 0), Int1=48 -> Shared (set 1). Int2 = 1-based macro index (1=first). + // CommandId: 1–100 Individual, 101–200 Shared. RaptureMacroModule set 0=Individual, set 1=Shared. + int set = (rawInt1 == 48) ? 1 : 0; // 48=Shared, else (1,49,...)=Individual + id = (uint)((set * 100) + rawInt2); // Int2 1-based: 1->id1, 2->id2, 3->id3 + slotType = RaptureHotbarModule.HotbarSlotType.Macro; + if (TryGetDebug() && ShouldLogMacroPayload(rawInt1, rawInt2)) + Plugin.Logger.Information($"[HSUI DragDrop DBG] Macro payload: Int1={rawInt1} Int2={rawInt2} -> set={set} idx={rawInt2} id={id}"); + return id is >= 1 and <= 200; + } + } + catch { } + return false; + } + + if (isItemDrag) + { + var hi = Plugin.GameGui?.HoveredItem ?? 0; + if (hi != 0) + { + id = (uint)(hi & 0xFFFFFFFF); + slotType = RaptureHotbarModule.HotbarSlotType.Item; + return true; + } + // HoveredItem can be 0 when game clears it; fallback: resolve from PayloadContainer (Int1=container, Int2=slot) + if (TryGetItemIdFromInventoryPayload(out id, TryGetDebug())) + { + slotType = RaptureHotbarModule.HotbarSlotType.Item; + return true; + } + return false; + } + + if (TryGetDragPayloadFromGame(out slotType, out id)) + return true; + return TryGetDragPayloadFromDalamud(out slotType, out id); + } + + /// Map game PayloadContainer Int1 (agent/UI container id) to InventoryType. + private static InventoryType ContainerIdToInventoryType(int containerId) + { + return containerId switch + { + 4 => InventoryType.EquippedItems, + 7 => InventoryType.KeyItems, + 48 => InventoryType.Inventory1, + 49 => InventoryType.Inventory2, + 50 => InventoryType.Inventory3, + 51 => InventoryType.Inventory4, + 52 => InventoryType.RetainerPage1, + 53 => InventoryType.RetainerPage2, + 54 => InventoryType.RetainerPage3, + 55 => InventoryType.RetainerPage4, + 56 => InventoryType.RetainerPage5, + 57 => InventoryType.ArmoryMainHand, + 58 => InventoryType.ArmoryHead, + 59 => InventoryType.ArmoryBody, + 60 => InventoryType.ArmoryHands, + 61 => InventoryType.ArmoryLegs, + 62 => InventoryType.ArmoryFeets, + 63 => InventoryType.ArmoryOffHand, + 64 => InventoryType.ArmoryEar, + 65 => InventoryType.ArmoryNeck, + 66 => InventoryType.ArmoryWrist, + 67 => InventoryType.ArmoryRings, + 68 => InventoryType.ArmorySoulCrystal, + 69 => InventoryType.SaddleBag1, + 70 => InventoryType.SaddleBag2, + 71 => InventoryType.PremiumSaddleBag1, + 72 => InventoryType.PremiumSaddleBag2, + _ => InventoryType.Invalid + }; + } + + /// Resolve visual (containerId, slotIndex) to real (invType, slot) for main inventory via ItemOrderModule. + private static unsafe bool TryResolveToRealSlot(int containerId, int visualSlot, out InventoryType realInvType, out int realSlot) + { + realInvType = InventoryType.Invalid; + realSlot = -1; + try + { + var iom = ItemOrderModule.Instance(); + if (iom == null) return false; + var sorter = iom->InventorySorter; + if (sorter == null) return false; + int itemsPerPage = sorter->ItemsPerPage > 0 ? sorter->ItemsPerPage : 35; + int startIndex = containerId switch { 48 => 0, 49 => itemsPerPage, 50 => itemsPerPage * 2, 51 => itemsPerPage * 3, _ => -1 }; + if (startIndex < 0) return false; + int displayIndex = startIndex + visualSlot; + if (displayIndex < 0 || displayIndex >= sorter->Items.LongCount) return false; + var entry = sorter->Items[displayIndex].Value; + if (entry == null) return false; + realInvType = InventoryType.Inventory1 + entry->Page; + realSlot = entry->Slot; + return true; + } + catch { return false; } + } + + /// Resolve item ID from inventory when HoveredItem is 0. PayloadContainer Int1=container, Int2=slot. + /// Resolves through ItemOrderModule for main inventory (sorted bags) so we get the correct item. + private static unsafe bool TryGetItemIdFromInventoryPayload(out uint itemId, bool debug = false) + { + itemId = 0; + try + { + var stage = AtkStage.Instance(); + if (stage == null) return false; + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + int containerId = dm->PayloadContainer.Int1; + int slotIndex = dm->PayloadContainer.Int2; + InventoryType invType; + int slot; + if (containerId is 48 or 49 or 50 or 51 && TryResolveToRealSlot(containerId, slotIndex, out invType, out slot)) + { + if (debug && ShouldLogItemPayload(containerId, slotIndex)) + Plugin.Logger.Information($"[HSUI DragDrop DBG] Item payload: Int1={containerId} Int2={slotIndex} -> resolved to {invType} slot={slot}"); + } + else + { + invType = ContainerIdToInventoryType(containerId); + slot = slotIndex; + if (invType == InventoryType.Invalid) return false; + } + var inv = InventoryManager.Instance(); + if (inv == null) return false; + var item = inv->GetInventorySlot(invType, slot); + if (item == null || item->IsEmpty()) return false; + itemId = item->GetItemId(); + if (debug && itemId != 0 && ShouldLogItemPayload(containerId, slotIndex)) + Plugin.Logger.Information($"[HSUI DragDrop DBG] Item from inventory: Int1={containerId} Int2={slotIndex} -> {invType} slot={slot} itemId={itemId}"); + return itemId != 0; + } + catch { return false; } + } + + private static unsafe bool TryGetDragPayloadFromGame(out RaptureHotbarModule.HotbarSlotType slotType, out uint id) + { + slotType = RaptureHotbarModule.HotbarSlotType.Empty; + id = 0; + try + { + var stage = AtkStage.Instance(); + if (stage == null) return false; + var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); + var dd = dm->DragDrop1; + if (dd == null) return false; + slotType = UIGlobals.GetHotbarSlotTypeFromDragDropType(dd->DragDropType); + id = (uint)dm->PayloadContainer.Int2; + return slotType != RaptureHotbarModule.HotbarSlotType.Empty; + } + catch { return false; } + } + + private static bool TryGetDragPayloadFromDalamud(out RaptureHotbarModule.HotbarSlotType slotType, out uint id) + { + slotType = RaptureHotbarModule.HotbarSlotType.Empty; + id = 0; + var ha = Plugin.GameGui?.HoveredAction; + if (ha != null && ha.ActionKind != HoverActionKind.None && ha.ActionID != 0) + { + slotType = GetHotbarSlotTypeFromHoverActionKind(ha.ActionKind); + if (slotType != RaptureHotbarModule.HotbarSlotType.Empty) + { + id = ha.ActionID; + return true; + } + } + var hi = Plugin.GameGui?.HoveredItem ?? 0; + if (hi != 0) + { + id = (uint)(hi & 0xFFFFFFFF); + slotType = RaptureHotbarModule.HotbarSlotType.Item; + return true; + } + return false; + } + + private static RaptureHotbarModule.HotbarSlotType GetHotbarSlotTypeFromHoverActionKind(HoverActionKind kind) + { + return kind switch + { + HoverActionKind.Action => RaptureHotbarModule.HotbarSlotType.Action, + HoverActionKind.CraftingAction => RaptureHotbarModule.HotbarSlotType.CraftAction, + HoverActionKind.GeneralAction => RaptureHotbarModule.HotbarSlotType.GeneralAction, + HoverActionKind.MainCommand => RaptureHotbarModule.HotbarSlotType.MainCommand, + HoverActionKind.ExtraCommand => RaptureHotbarModule.HotbarSlotType.ExtraCommand, + HoverActionKind.Companion => RaptureHotbarModule.HotbarSlotType.Companion, + HoverActionKind.PetOrder => RaptureHotbarModule.HotbarSlotType.PetAction, + HoverActionKind.BuddyAction => RaptureHotbarModule.HotbarSlotType.BuddyAction, + HoverActionKind.Mount => RaptureHotbarModule.HotbarSlotType.Mount, + HoverActionKind.BgcArmyAction => RaptureHotbarModule.HotbarSlotType.BgcArmyAction, + HoverActionKind.Perform => RaptureHotbarModule.HotbarSlotType.PerformanceInstrument, + HoverActionKind.Ornament => RaptureHotbarModule.HotbarSlotType.Ornament, + HoverActionKind.Glasses => RaptureHotbarModule.HotbarSlotType.Glasses, + HoverActionKind.MYCTemporaryItem => RaptureHotbarModule.HotbarSlotType.LostFindsItem, + HoverActionKind.QuickChat => RaptureHotbarModule.HotbarSlotType.PvPQuickChat, + HoverActionKind.ActionComboRoute => RaptureHotbarModule.HotbarSlotType.PvPCombo, + _ => RaptureHotbarModule.HotbarSlotType.Empty + }; + } + + private static unsafe uint GetIconIdForPayload(RaptureHotbarModule.HotbarSlotType slotType, uint id) + { + if (id == 0) return 0; + if (slotType == RaptureHotbarModule.HotbarSlotType.Macro) + { + var macroModule = RaptureMacroModule.Instance(); + if (macroModule != null && id is >= 1 and <= 200) + { + uint set = (id - 1) / 100; + uint index1Based = ((id - 1) % 100) + 1; // GetMacro expects 1-based index + var macro = macroModule->GetMacro(set, index1Based); + if (macro != null) + return macro->IconId; + } + return 0; + } + var module = RaptureHotbarModule.Instance(); + if (module != null && module->ModuleReady) + { + var bar = module->StandardHotbars[0]; + var slot = bar.GetHotbarSlot(0); + if (slot != null) + { + int gameIcon = slot->GetIconIdForSlot(slotType, id); + if (gameIcon > 0) return (uint)gameIcon; + } + } + try + { + switch (slotType) + { + case RaptureHotbarModule.HotbarSlotType.GeneralAction: + var gaRow = Plugin.DataManager.GetExcelSheet()?.GetRow(id); + return gaRow.HasValue ? (uint)gaRow.Value.Icon : 0; + case RaptureHotbarModule.HotbarSlotType.Action: + case RaptureHotbarModule.HotbarSlotType.CraftAction: + case RaptureHotbarModule.HotbarSlotType.PetAction: + var action = Plugin.DataManager.GetExcelSheet()?.GetRow(id); + return action.HasValue ? (uint)action.Value.Icon : 0; + case RaptureHotbarModule.HotbarSlotType.Item: + uint baseItemId = id >= 1000000 ? id - 1000000 : id; + var item = Plugin.DataManager.GetExcelSheet()?.GetRow(baseItemId); + return item.HasValue ? (uint)item.Value.Icon : 0; + case RaptureHotbarModule.HotbarSlotType.Mount: + var mount = Plugin.DataManager.GetExcelSheet()?.GetRow(id); + return mount.HasValue ? (uint)mount.Value.Icon : 0; + case RaptureHotbarModule.HotbarSlotType.Companion: + var companion = Plugin.DataManager.GetExcelSheet()?.GetRow(id); + return companion.HasValue ? (uint)companion.Value.Icon : 0; + case RaptureHotbarModule.HotbarSlotType.Emote: + var emote = Plugin.DataManager.GetExcelSheet()?.GetRow(id); + return emote.HasValue ? (uint)emote.Value.Icon : 0; + default: + return 0; + } + } + catch { return 0; } + } + + private static unsafe (string title, string text) GetSlotTooltip(ActionBarsManager.SlotInfo slot) + { + // GeneralAction uses a different ID space: GeneralAction 7 = Teleport links to Action 5. Look up via GeneralAction sheet. + if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.GeneralAction) + { + if (Plugin.DataManager.GetExcelSheet()?.TryGetRow(slot.ActionId, out var gaRow) == true) + { + string name = gaRow.Name.ToString(); + string desc = ""; + try + { + string descRaw = gaRow.Description.ToDalamudString().ToString(); + if (!string.IsNullOrEmpty(descRaw)) + { + try + { + var evaluated = Plugin.SeStringEvaluator.Evaluate(gaRow.Description.AsSpan()); + desc = evaluated.ExtractText(); + if (string.IsNullOrEmpty(desc)) desc = descRaw; + } + catch { desc = descRaw; } + if (!string.IsNullOrEmpty(desc)) + desc = EncryptedStringsHelper.GetString(desc); + } + } + catch { /* ignore */ } + string statsLine = ""; + if (gaRow.Action.RowId != 0) + { + var actionRow = Plugin.DataManager.GetExcelSheet()?.GetRow(gaRow.Action.RowId); + if (actionRow.HasValue) + statsLine = TryGetActionStatsLine(actionRow.Value, includeRangeAndRadius: false); + } + if (!string.IsNullOrEmpty(statsLine)) + desc = string.IsNullOrEmpty(desc) ? statsLine : statsLine + "\n\n" + desc; + return (name, desc ?? ""); + } + } + + if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Action || + slot.SlotType == RaptureHotbarModule.HotbarSlotType.CraftAction || + slot.SlotType == RaptureHotbarModule.HotbarSlotType.PetAction) + { + var row = Plugin.DataManager.GetExcelSheet()?.GetRow(slot.ActionId); + if (row.HasValue) + { + string name = row.Value.Name.ToString(); + string desc = ""; + var descRow = Plugin.DataManager.GetExcelSheet()?.GetRow(slot.ActionId); + string descRaw = ""; + if (descRow.HasValue) + { + try + { + var descSeStr = descRow.Value.Description; + descRaw = descRow.Value.Description.ToDalamudString().ToString(); + try + { + var evaluated = Plugin.SeStringEvaluator.Evaluate(descSeStr.AsSpan()); + desc = evaluated.ExtractText(); + if (string.IsNullOrEmpty(desc)) desc = descRaw; + } + catch + { + desc = descRaw; + } + if (!string.IsNullOrEmpty(desc)) + desc = EncryptedStringsHelper.GetString(desc); + } + catch { /* ignore */ } + } + bool isCombatAction = IsCombatAction(slot.SlotType, row.Value); + int? potencyValue = isCombatAction ? TryGetPotencyValue(row.Value) : null; + string potencyLine = potencyValue.HasValue ? $"Potency: {potencyValue.Value}" : ""; + string statsLine = TryGetActionStatsLine(row.Value, includeRangeAndRadius: isCombatAction); + if (!string.IsNullOrEmpty(desc) && potencyValue.HasValue) + { + string before = desc; + desc = ReplacePotencyPlaceholderInDesc(desc, potencyValue.Value); + if (IsTooltipDebugEnabled() && before != desc) + Plugin.Logger.Information($"[HSUI Tooltip DBG] Replaced potency placeholder: actionId={slot.ActionId} value={potencyValue.Value}"); + } + else if (IsTooltipDebugEnabled() && !string.IsNullOrEmpty(desc) && desc.Contains("potency of", StringComparison.OrdinalIgnoreCase)) + Plugin.Logger.Information($"[HSUI Tooltip DBG] Potency placeholder NOT replaced: actionId={slot.ActionId} potencyValue={potencyValue?.ToString() ?? "null"} descSnippet='{(desc.Length > 60 ? desc[..60] + "..." : desc)}'"); + var statsParts = new List(); + if (!string.IsNullOrEmpty(potencyLine)) statsParts.Add(potencyLine); + if (!string.IsNullOrEmpty(statsLine)) statsParts.Add(statsLine); + if (statsParts.Count > 0) + desc = string.IsNullOrEmpty(desc) ? string.Join("\n", statsParts) : string.Join("\n", statsParts) + "\n\n" + desc; + + if (IsTooltipDebugEnabled() && ShouldLogTooltipDebug(slot.ActionId, name, desc)) + Plugin.Logger.Information($"[HSUI Tooltip DBG] GetSlotTooltip actionId={slot.ActionId} name='{name}' descRawLen={descRaw?.Length ?? 0} descLen={desc?.Length ?? 0} descRawPreview='{(descRaw?.Length > 0 ? (descRaw.Length > 50 ? descRaw[..50] + "..." : descRaw) : "(empty)")}' fullBodyPreview='{(desc != null && desc.Length > 100 ? desc[..100] + "..." : desc ?? "")}'"); + return (name, desc ?? ""); + } + } + + if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Macro) + { + try + { + var macroModule = RaptureMacroModule.Instance(); + if (macroModule != null && slot.ActionId is >= 1 and <= 200) + { + uint set = (slot.ActionId - 1) / 100; + uint index1Based = ((slot.ActionId - 1) % 100) + 1; // GetMacro expects 1-based index + var macro = macroModule->GetMacro(set, index1Based); + if (macro != null) + { + string name = macro->Name.ToString(); + if (string.IsNullOrWhiteSpace(name)) + name = (set == 0 ? "Individual" : "Shared") + " Macro " + index1Based; + var sb = new System.Text.StringBuilder(); + var lineCount = (int)macroModule->GetLineCount(macro); + for (int i = 0; i < lineCount && i < 15; i++) + { + ref var line = ref macro->Lines[i]; + if (!line.IsEmpty) + { + if (sb.Length > 0) sb.Append('\n'); + sb.Append(line.ToString()); + } + } + string desc = sb.ToString(); + return (name, desc); + } + } + } + catch { } + return ("Macro", ""); + } + + if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Item) + { + var row = Plugin.DataManager.GetExcelSheet()?.GetRow(slot.ActionId); + if (row.HasValue) + { + string name = row.Value.Name.ToString(); + try + { + string desc = row.Value.Description.ToDalamudString().ToString(); + if (!string.IsNullOrEmpty(desc)) + desc = EncryptedStringsHelper.GetString(desc); + return (name, desc); + } + catch + { + return (name, ""); + } + } + } + + return (slot.SlotType.ToString(), ""); + } + + private static int? TryGetPotencyValue(LuminaAction action) + { + try + { + var t = typeof(LuminaAction); + foreach (var name in new[] { "AttackPotency", "Effect1", "Effect2", "Effect3", "PrimaryEffect", "SecondaryEffect" }) + { + var prop = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance); + if (prop == null) continue; + var v = prop.GetValue(action); + if (v is int i && i > 0 && i <= 5000) return i; + if (v is uint u && u > 0 && u <= 5000) return (int)u; + } + // Fallback: try Omen row - potency may be stored there for weaponskills + try + { + var omenRef = action.Omen; + uint omenRowId = omenRef.RowId; + if (omenRowId != 0) + { + var omenSheet = Plugin.DataManager.GetExcelSheet(); + if (omenSheet != null && omenSheet.GetRow(omenRowId) is { } omenRow) + { + var ot = typeof(Omen); + foreach (var pname in new[] { "Effect1", "Effect2", "Effect3", "AttackPotency" }) + { + var op = ot.GetProperty(pname, BindingFlags.Public | BindingFlags.Instance); + if (op == null) continue; + var ov = op.GetValue(omenRow); + if (ov is int oi && oi >= 50 && oi <= 2000) return oi; + if (ov is uint ou && ou >= 50 && ou <= 2000) return (int)ou; + } + } + } + } + catch { /* ignore */ } + // Last resort: scan Action int/uint properties + foreach (var prop in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.PropertyType != typeof(int) && prop.PropertyType != typeof(uint)) continue; + var name = prop.Name; + if (name.Contains("Id") || name.Contains("Index") || name.Contains("Time") || + name.Contains("Range") || name.Contains("Radius") || name.Contains("Cost") || + name.Contains("Level") || name.Contains("Category") || name.Contains("Cast") || + name.Contains("Recast") || name.Contains("Omen")) + continue; + var v = prop.GetValue(action); + if (v is int i && i >= 50 && i <= 2000) return i; + if (v is uint u && u >= 50 && u <= 2000) return (int)u; + } + } + catch { /* ignore */ } + return null; + } + + private static string ReplacePotencyPlaceholderInDesc(string desc, int potencyValue) + { + if (string.IsNullOrEmpty(desc)) return desc; + string replaced = desc; + // Game uses placeholder that can render as ".", "?", etc. Try common patterns + replaced = System.Text.RegularExpressions.Regex.Replace(replaced, + @"(potency of\s+)[^0-9]{1,4}", + $"$1{potencyValue}", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (replaced == desc && desc.Contains("potency of .", StringComparison.OrdinalIgnoreCase)) + { + int idx = desc.IndexOf("potency of .", StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + replaced = desc[..(idx + 10)] + potencyValue + desc[(idx + 11)..]; + } + return replaced; + } + + /// Returns true if this action should show combat stats (potency, range, radius). False for Teleport, Return, Sprint, etc. + private static bool IsCombatAction(RaptureHotbarModule.HotbarSlotType slotType, LuminaAction action) + { + // GeneralAction: Teleport, Return, Sprint, Limit Break - never show combat stats + if (slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction) + return false; + // CraftAction: crafting actions have cast/recast but no combat range/radius + if (slotType == RaptureHotbarModule.HotbarSlotType.CraftAction) + return false; + // PetAction: usually combat, but some pets have non-combat skills - keep combat stats for now + // Action: check ActionCategory - category 4 (General) and similar are non-combat + try + { + var catRef = action.ActionCategory; + uint rowId = catRef.RowId; + if (rowId is 4 or 5) return false; // General, Other + } + catch { /* ignore */ } + return true; + } + + private static string TryGetActionStatsLine(LuminaAction action, bool includeRangeAndRadius = true) + { + try + { + var parts = new List(); + float castSec = action.Cast100ms / 100f; + float recastSec = action.Recast100ms / 100f; + if (castSec > 0) + parts.Add($"Cast: {castSec:F1}s"); + else + parts.Add("Cast: Instant"); + if (recastSec > 0) + parts.Add($"Recast: {recastSec:F1}s"); + if (includeRangeAndRadius) + { + int range = action.Range; + int effectRange = action.EffectRange; + if (range > 0) + parts.Add($"Range: {range}y"); + if (effectRange > 0) + parts.Add($"Radius: {effectRange}y"); + } + return parts.Count > 0 ? string.Join(" | ", parts) : ""; + } + catch { return ""; } + } + } +} diff --git a/Interface/GeneralElements/BarTexturesConfig.cs b/Interface/GeneralElements/BarTexturesConfig.cs new file mode 100644 index 0000000..5623115 --- /dev/null +++ b/Interface/GeneralElements/BarTexturesConfig.cs @@ -0,0 +1,159 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Memory.Exceptions; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Disableable(false)] + [Section("Customization")] + [SubSection("Bar Textures", 0)] + public class BarTexturesConfig : PluginConfigObject + { + public new static BarTexturesConfig DefaultConfig() { return new BarTexturesConfig(); } + + public string BarTexturesPath = "C:\\"; + + [JsonIgnore] public string ValidatedBarTexturesPath => ValidatePath(BarTexturesPath); + + [JsonIgnore] private int _inputBarTexture = 0; + [JsonIgnore] private int _drawModeIndex = 0; + [JsonIgnore] private Vector4 _color = new Vector4(229 / 255f, 57 / 255f, 57 / 255f, 1); + [JsonIgnore] private PluginConfigColor _pluginConfigColor = PluginConfigColor.FromHex(0xFFE53939); + [JsonIgnore] private FileDialogManager _fileDialogManager = new FileDialogManager(); + [JsonIgnore] private bool _applying = false; + + private string ValidatePath(string path) + { + if (path.EndsWith("\\") || path.EndsWith("/")) + { + return path; + } + + return path + "\\"; + } + + private void SelectFolder() + { + Action callback = (finished, path) => + { + if (finished && path.Length > 0) + { + BarTexturesPath = path; + BarTexturesManager.Instance?.ReloadTextures(); + } + }; + + _fileDialogManager.OpenFolderDialog("Select Bar Textures Folder", callback); + } + + [ManualDraw] + public bool Draw(ref bool changed) + { + if (BarTexturesManager.Instance == null) { return false; } + + string[] textureNames = BarTexturesManager.Instance.BarTextureNames.ToArray(); + string[] drawModes = new string[] { "Stretch", "Repeat Horizontal", "Repeat Vertical", "Repeat" }; + + if (ImGui.BeginChild("Bar Textures", new Vector2(800, 400), false, ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + ImGuiHelper.NewLineAndTab(); + ImGui.Text("Custom Bar Textures path"); + + ImGuiHelper.Tab(); + if (ImGui.InputText("", ref BarTexturesPath, 200, ImGuiInputTextFlags.EnterReturnsTrue)) + { + changed = true; + BarTexturesManager.Instance?.ReloadTextures(); + } + + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Folder.ToIconString(), new Vector2(0, 0))) + { + SelectFolder(); + } + ImGui.PopFont(); + + ImGuiHelper.NewLineAndTab(); + ImGui.Text("Preview"); + ImGuiHelper.Tab(); + ImGui.Combo("Bar Texture ##bar texture", ref _inputBarTexture, textureNames); + + ImGuiHelper.Tab(); + ImGui.Combo("Draw Mode", ref _drawModeIndex, drawModes); + + ImGuiHelper.Tab(); + if (ImGui.ColorEdit4("Color", ref _color)) + { + _pluginConfigColor = new PluginConfigColor(_color); + } + + if (textureNames.Length > _inputBarTexture) + { + // draw preview + ImGui.NewLine(); + ImGuiHelper.NewLineAndTab(); + Vector2 pos = ImGui.GetWindowPos() + ImGui.GetCursorPos(); + Vector2 size = new Vector2(512, 64); + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + + DrawHelper.DrawBarTexture( + pos, + size, + _pluginConfigColor, + textureNames[_inputBarTexture], + (BarTextureDrawMode)_drawModeIndex, + drawList + ); + + ImGuiHelper.DrawSpacing(3); + ImGuiHelper.NewLineAndTab(); + if (ImGui.Button("Apply to all bars", new Vector2(200, 30))) + { + _applying = true; + } + } + } + + ImGui.EndChild(); + + _fileDialogManager.Draw(); + + if (_applying) + { + string[] lines = new string[] { "This will replace the Bar Texture", "and Draw Mode for ALL bars!", "THIS CAN'T BE UNDONE!", "Are you sure?" }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Apply to ALL bars?", lines); + + if (didConfirm) + { + List barConfigs = ConfigurationManager.Instance.GetObjects(); + foreach (BarConfig barConfig in barConfigs) + { + barConfig.BarTextureName = textureNames[_inputBarTexture]; + barConfig.BarTextureDrawMode = (BarTextureDrawMode)_drawModeIndex; + } + + changed = true; + } + + if (didConfirm || didClose) + { + _applying = false; + } + } + + return false; + } + } +} diff --git a/Interface/GeneralElements/CastbarConfig.cs b/Interface/GeneralElements/CastbarConfig.cs new file mode 100644 index 0000000..21fe472 --- /dev/null +++ b/Interface/GeneralElements/CastbarConfig.cs @@ -0,0 +1,258 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.Bars; +using System; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Section("Castbars")] + [SubSection("Player", 0)] + public class PlayerCastbarConfig : UnitFrameCastbarConfig + { + [Checkbox("Use Job Color", spacing = true)] + [Order(19)] + public bool UseJobColor = false; + + [Checkbox("Slide Cast", spacing = true)] + [Order(60)] + public bool ShowSlideCast = true; + + [DragInt("Time (milliseconds)", min = 0, max = 10000)] + [Order(61, collapseWith = nameof(ShowSlideCast))] + public int SlideCastTime = 500; + + [ColorEdit4("Color ##SlidecastColor")] + [Order(62, collapseWith = nameof(ShowSlideCast))] + public PluginConfigColor SlideCastColor = new PluginConfigColor(new(190f / 255f, 28f / 255f, 57f / 255f, 100f / 100f)); + + public PlayerCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + + public new static PlayerCastbarConfig DefaultConfig() + { + var size = new Vector2(254, 24); + var pos = new Vector2(0, HUDConstants.PlayerCastbarY); + + var castNameConfig = new LabelConfig(new Vector2(5, 0), "", DrawAnchor.Left, DrawAnchor.Left); + var castTimeConfig = new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + castTimeConfig.NumberFormat = 1; + + return new PlayerCastbarConfig(pos, size, castNameConfig, castTimeConfig); + } + } + + [Section("Castbars")] + [SubSection("Target", 0)] + public class TargetCastbarConfig : UnitFrameCastbarConfig + { + [Checkbox("Interruptable Color", spacing = true)] + [Order(50)] + public bool ShowInterruptableColor = true; + + [ColorEdit4("Interruptable")] + [Order(51, collapseWith = nameof(ShowInterruptableColor))] + public PluginConfigColor InterruptableColor = new PluginConfigColor(new(255f / 255f, 87f / 255f, 113f / 255f, 100f / 100f)); + + [Checkbox("Damage Type Colors", spacing = true)] + [Order(60)] + public bool UseColorForDamageTypes = true; + + [ColorEdit4("Physical")] + [Order(61, collapseWith = nameof(UseColorForDamageTypes))] + public PluginConfigColor PhysicalDamageColor = new PluginConfigColor(new(190f / 255f, 28f / 255f, 57f / 255f, 100f / 100f)); + + [ColorEdit4("Magical")] + [Order(62, collapseWith = nameof(UseColorForDamageTypes))] + public PluginConfigColor MagicalDamageColor = new PluginConfigColor(new(0f / 255f, 72f / 255f, 179f / 255f, 100f / 100f)); + + [ColorEdit4("Darkness")] + [Order(63, collapseWith = nameof(UseColorForDamageTypes))] + public PluginConfigColor DarknessDamageColor = new PluginConfigColor(new(188f / 255f, 19f / 255f, 254f / 255f, 100f / 100f)); + + public TargetCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + public new static TargetCastbarConfig DefaultConfig() + { + var size = new Vector2(254, 24); + var pos = new Vector2(0, HUDConstants.BaseHUDOffsetY / 2f - size.Y / 2); + + var castNameConfig = new LabelConfig(new Vector2(5, 0), "", DrawAnchor.Left, DrawAnchor.Left); + var castTimeConfig = new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + castTimeConfig.NumberFormat = 1; + + return new TargetCastbarConfig(pos, size, castNameConfig, castTimeConfig); + } + } + + [Section("Castbars")] + [SubSection("Target of Target", 0)] + public class TargetOfTargetCastbarConfig : TargetCastbarConfig + { + public TargetOfTargetCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + public new static TargetOfTargetCastbarConfig DefaultConfig() + { + var size = new Vector2(120, 24); + var pos = new Vector2(0, -1); + + var castNameConfig = new LabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center); + var castTimeConfig = new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + castTimeConfig.Enabled = false; + castTimeConfig.NumberFormat = 1; + + var config = new TargetOfTargetCastbarConfig(pos, size, castNameConfig, castTimeConfig); + config.Anchor = DrawAnchor.Top; + config.AnchorToUnitFrame = true; + config.ShowIcon = false; + + return config; + } + } + + [Section("Castbars")] + [SubSection("Focus Target", 0)] + public class FocusTargetCastbarConfig : TargetCastbarConfig + { + public FocusTargetCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + public new static FocusTargetCastbarConfig DefaultConfig() + { + var size = new Vector2(120, 24); + var pos = new Vector2(0, -1); + + var castNameConfig = new LabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center); + var castTimeConfig = new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + castTimeConfig.Enabled = false; + castTimeConfig.NumberFormat = 1; + + var config = new FocusTargetCastbarConfig(pos, size, castNameConfig, castTimeConfig); + config.Anchor = DrawAnchor.Top; + config.AnchorToUnitFrame = true; + config.ShowIcon = false; + + return config; + } + } + + public abstract class UnitFrameCastbarConfig : CastbarConfig + { + [Checkbox("Anchor to Unit Frame")] + [Order(16)] + public bool AnchorToUnitFrame = false; + + [Anchor("Unit Frame Anchor")] + [Order(17, collapseWith = nameof(AnchorToUnitFrame))] + public DrawAnchor UnitFrameAnchor = DrawAnchor.Bottom; + + public UnitFrameCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + } + + [DisableParentSettings("HideWhenInactive")] + public abstract class CastbarConfig : BarConfig + { + [Checkbox("Preview")] + [Order(3)] + public bool Preview = false; + + [Checkbox("Show Ability Icon")] + [Order(4)] + public bool ShowIcon = true; + + [Checkbox("Reverse Fill Background Color")] + [Order(5)] + public bool UseReverseFill = false; + + [Checkbox("Show Current Cast Time + Max Cast Time")] + [Order(6)] + public bool ShowMaxCastTime = false; + + [Checkbox("Truncate Cast Name", help = "This will automatically truncate the cast name if it's too long and won't fit inside the bar.")] + [Order(7)] + public bool TruncateCastName = false; + + [Checkbox("Separate Icon", spacing = true)] + [Order(100)] + public bool SeparateIcon = false; + + [DragInt2("Custom Icon Position", min = -500, max = 500)] + [Order(101, collapseWith = nameof(SeparateIcon))] + public Vector2 CustomIconPosition = Vector2.Zero; + + [DragInt2("Custom Icon Size", min = 1, max = 500)] + [Order(101, collapseWith = nameof(SeparateIcon))] + public Vector2 CustomIconSize = new Vector2(40); + + [NestedConfig("Cast Name", 500)] + public LabelConfig CastNameLabel; + + [NestedConfig("Cast Time", 505)] + public NumericLabelConfig CastTimeLabel; + + [ColorEdit4("Color" + "##ReverseFill")] + [Order(515, collapseWith = nameof(UseReverseFill))] + public PluginConfigColor ReverseFillColor = new(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + public CastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, new PluginConfigColor(new(0f / 255f, 162f / 255f, 252f / 255f, 100f / 100f)), BarDirection.Right) + { + CastNameLabel = castNameConfig; + CastTimeLabel = castTimeConfig; + + Strata = StrataLevel.MID; + } + } + + public class CastbarConfigConverter : PluginConfigObjectConverter + { + public CastbarConfigConverter() + { + SameClassFieldConverter name = new SameClassFieldConverter( + "CastNameLabel", + new LabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center) + ); + + NewClassFieldConverter time = new NewClassFieldConverter( + "CastTimeLabel", + new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right), + (oldValue) => + { + NumericLabelConfig label = new NumericLabelConfig(oldValue.Position, "", oldValue.FrameAnchor, oldValue.TextAnchor); + label.Enabled = oldValue.Enabled; + label.FontID = oldValue.FontID; + label.NumberFormat = 1; + label.Color = oldValue.Color; + label.OutlineColor = oldValue.OutlineColor; + label.ShadowConfig = oldValue.ShadowConfig; + label.UseJobColor = oldValue.UseJobColor; + + return label; + }); + + FieldConvertersMap.Add("CastNameConfig", name); + FieldConvertersMap.Add("CastTimeConfig", time); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(CastbarConfig); + } + } +} diff --git a/Interface/GeneralElements/CastbarHud.cs b/Interface/GeneralElements/CastbarHud.cs new file mode 100644 index 0000000..8efcc0a --- /dev/null +++ b/Interface/GeneralElements/CastbarHud.cs @@ -0,0 +1,509 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Utility; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.EnemyList; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System; +using System.Collections.Generic; +using System.Numerics; +using LuminaAction = Lumina.Excel.Sheets.Action; +using StructsBattleChara = FFXIVClientStructs.FFXIV.Client.Game.Character.BattleChara; + +namespace HSUI.Interface.GeneralElements +{ + public class CastbarHud : ParentAnchoredDraggableHudElement, IHudElementWithActor, IHudElementWithAnchorableParent, IHudElementWithPreview + { + private CastbarConfig Config => (CastbarConfig)_config; + private readonly LabelHud _castNameLabel; + private readonly LabelHud _castTimeLabel; + + protected LastUsedCast? LastUsedCast; + + public IGameObject? Actor { get; set; } + + protected override bool AnchorToParent => Config is UnitFrameCastbarConfig { AnchorToUnitFrame: true }; + protected override DrawAnchor ParentAnchor => Config is UnitFrameCastbarConfig config ? config.UnitFrameAnchor : DrawAnchor.Center; + + public CastbarHud(CastbarConfig config, string? displayName = null) : base(config, displayName) + { + _castNameLabel = new LabelHud(config.CastNameLabel); + _castTimeLabel = new LabelHud(config.CastTimeLabel); + } + + public void StopPreview() + { + Config.Preview = false; + } + + protected override (List, List) ChildrenPositionsAndSizes() => (new List { Config.Position }, new List { Config.Size }); + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled) + { + return; + } + + if (!Config.Preview && + (Actor == null || Actor is not ICharacter || Actor.ObjectKind != ObjectKind.Player && Actor.ObjectKind != ObjectKind.BattleNpc)) + { + return; + } + + UpdateCurrentCast(out float currentCastTime, out float totalCastTime); + if (totalCastTime == 0 || currentCastTime >= totalCastTime) + { + return; + } + + if (!ShouldShow() && !Config.Preview) + { + return; + } + + Vector2 size = GetSize(); + IDalamudTextureWrap? iconTexture = LastUsedCast?.GetIconTexture(); + bool validIcon = Config.Preview ? true : iconTexture is not null; + Vector2 iconSize = Config.ShowIcon && validIcon && !Config.SeparateIcon ? new Vector2(size.Y, size.Y) : Vector2.Zero; + + PluginConfigColor fillColor = GetColor(); + Rect background = new(Config.Position, size, Config.BackgroundColor); + Rect progress = BarUtilities.GetFillRect(Config.Position, size, Config.FillDirection, fillColor, currentCastTime, totalCastTime); + + BarHud bar = new(Config, Actor);/**/ + bar.SetBackground(background); + + if (Config.UseReverseFill) + { + Vector2 reverseFillSize = size - BarUtilities.GetFillDirectionOffset(progress.Size, Config.FillDirection); + Vector2 reverseFillPos = Config.FillDirection.IsInverted() + ? Config.Position + : Config.Position + BarUtilities.GetFillDirectionOffset(progress.Size, Config.FillDirection); + + PluginConfigColor reverseFillColor = Config.ReverseFillColor; + bar.AddForegrounds(new Rect(reverseFillPos, reverseFillSize, reverseFillColor)); + } + + AddExtras(bar, totalCastTime, iconTexture); + + bar.AddForegrounds(progress); + + Vector2 pos = origin + ParentPos(); + AddDrawActions(bar.GetDrawActions(pos, Config.StrataLevel)); + + // icon + Vector2 startPos = Config.Position + Utils.GetAnchoredPosition(pos, size, Config.Anchor); + if (Config.ShowIcon && validIcon) + { + Vector2 finalIconPos = Config.SeparateIcon ? startPos + Config.CustomIconPosition : startPos; + Vector2 finalIconSize = Config.SeparateIcon ? Config.CustomIconSize : iconSize; + + AddDrawAction(Config.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID + "_icon", finalIconPos, finalIconSize, false, (drawList) => + { + ImGui.SetCursorPos(finalIconPos); + + IDalamudTextureWrap? texture = Config.Preview ? TexturesHelper.GetTexture(3577) : iconTexture; + if (texture != null) + { + ImGui.Image(texture.Handle, finalIconSize); + } + + if (Config.DrawBorder) + { + drawList.AddRect(finalIconPos, finalIconPos + finalIconSize, Config.BorderColor.Base, 0, ImDrawFlags.None, Config.BorderThickness); + } + }); + }); + } + + // cast time + bool isTimeLeftAnchored = Config.CastTimeLabel.TextAnchor is DrawAnchor.Left or DrawAnchor.TopLeft or DrawAnchor.BottomLeft; + Vector2 timePos = Config.ShowIcon && isTimeLeftAnchored ? startPos + new Vector2(iconSize.X, 0) : startPos; + float value = Config.Preview ? 0.5f : totalCastTime - currentCastTime; + + if (Config.ShowMaxCastTime) + { + string format = Config.CastTimeLabel.NumberFormat.ToString(); + Config.CastTimeLabel.SetText( + value.ToString("N" + format, ConfigurationManager.Instance.ActiveCultreInfo) + + " / " + + totalCastTime.ToString("N" + format, ConfigurationManager.Instance.ActiveCultreInfo) + ); + } + else + { + Config.CastTimeLabel.SetValue(value); + } + + AddDrawAction(Config.CastTimeLabel.StrataLevel, () => + { + _castTimeLabel.Draw(timePos, size, Actor); + }); + + // cast name + bool isNameLeftAnchored = Config.CastNameLabel.TextAnchor is DrawAnchor.Left or DrawAnchor.TopLeft or DrawAnchor.BottomLeft; + Vector2 namePos = Config.ShowIcon && isNameLeftAnchored ? startPos + new Vector2(iconSize.X, 0) : startPos; + + string original = CustomCastName() ?? (LastUsedCast?.ActionText ?? ""); + string castName = EncryptedStringsHelper.GetString(original).CheckForUpperCase() ?? original; + + if (Config.TruncateCastName) + { + castName = TruncatedCastName(castName) ?? castName; + } + + Config.CastNameLabel.SetText(Config.Preview ? "Cast Name" : castName); + + AddDrawAction(Config.CastNameLabel.StrataLevel, () => + { + _castNameLabel.Draw(namePos, size, Actor); + }); + } + + private unsafe void UpdateCurrentCast(out float currentCastTime, out float totalCastTime) + { + if (Config.Preview || Actor is not IBattleChara battleChara) + { + currentCastTime = Config.Preview ? 0.5f : 0f; + totalCastTime = 1f; + return; + } + + float current = 0; + float total = 0; + + try + { + current = battleChara.CurrentCastTime; + StructsBattleChara* chara = (StructsBattleChara*)battleChara.Address; + CastInfo* castInfo = chara->GetCastInfo(); + + if (castInfo != null) + { + total = castInfo->TotalCastTime; + } + } + catch + { + currentCastTime = 0; + totalCastTime = 0; + return; + } + + if (!Utils.IsActorCasting(battleChara) && current <= 0) + { + currentCastTime = 0; + totalCastTime = 0; + return; + } + + currentCastTime = current; + totalCastTime = total; + + uint currentCastId = battleChara.CastActionId; + ActionType currentCastType = (ActionType)battleChara.CastActionType; + + if (LastUsedCast == null || LastUsedCast.CastId != currentCastId || LastUsedCast.ActionType != currentCastType) + { + LastUsedCast = new LastUsedCast(currentCastId, currentCastType, battleChara.IsCastInterruptible); + } + } + + private string? TruncatedCastName(string text) + { + if (text.Length <= 5) + { + return null; + } + + LabelConfig castNamelabel = Config.CastNameLabel; + LabelConfig castTimeLabel = Config.CastTimeLabel; + + + Vector2 size; + + using (FontsManager.Instance.PushFont(castNamelabel.FontID)) + { + size = ImGui.CalcTextSize(text) * castNamelabel.GetFontScale(); + } + + float maxWidth = Config.Size.X; + + if (!Config.SeparateIcon) + { + maxWidth -= Config.Size.Y; + } + + if (Config.CastTimeLabel.Enabled) + { + using (FontsManager.Instance.PushFont(Config.CastTimeLabel.FontID)) + { + maxWidth -= (ImGui.CalcTextSize("XX.X") * castTimeLabel.GetFontScale()).X; + } + } + + if (size.X > maxWidth) + { + return TruncatedCastName(text.Substring(0, text.Length - 5) + "..."); + } + + return text; + } + + public virtual void AddExtras(BarHud bar, float totalCastTime, IDalamudTextureWrap? iconTexture) + { + // override + } + + public virtual string? CustomCastName() + { + // override + return null; + } + + public virtual PluginConfigColor GetColor() => Config.FillColor; + public virtual Vector2 GetSize() => Config.Size; + + public virtual bool ShouldShow() => true; + } + + public class PlayerCastbarHud : CastbarHud + { + private PlayerCastbarConfig Config => (PlayerCastbarConfig)_config; + + public PlayerCastbarHud(PlayerCastbarConfig config, string displayName) : base(config, displayName) + { + + } + + public override unsafe string? CustomCastName() + { + AddonCastBar* castBar = (AddonCastBar*)Plugin.GameGui.GetAddonByName("_CastBar", 1).Address; + if (castBar == null) { return null; } + + AtkTextNode* node = castBar->GetTextNodeById(4); + if (node == null) { return null; } + + return node->GetText().ExtractText(); + } + + public override void AddExtras(BarHud bar, float totalCastTime, IDalamudTextureWrap? iconTexture) + { + if (!Config.ShowSlideCast || Config.SlideCastTime <= 0 || Config.Preview) + { + return; + } + + Rect slideCast; + + if (Config.FillDirection.IsHorizontal()) + { + float slideCastWidth = Math.Min(Config.Size.X, Config.SlideCastTime / 1000f * Config.Size.X / totalCastTime); + Vector2 size = new(slideCastWidth, Config.Size.Y); + slideCast = new(Config.Position + Config.Size - size, size, Config.SlideCastColor); + + if (Config.FillDirection is BarDirection.Left) + { + bool validIcon = iconTexture is not null; + Vector2 iconSize = Config.ShowIcon && validIcon ? new Vector2(Config.Size.Y, Config.Size.Y) : Vector2.Zero; + slideCast = Config.ShowIcon ? new Rect(Config.Position, size + new Vector2(iconSize.X, 0), Config.SlideCastColor) : new Rect(Config.Position, size, Config.SlideCastColor); + } + } + else + { + float slideCastHeight = Math.Min(Config.Size.Y, Config.SlideCastTime / 1000f * Config.Size.Y / totalCastTime); + Vector2 size = new(Config.Size.X, slideCastHeight); + slideCast = new(Config.Position + Config.Size - size, size, Config.SlideCastColor); + + if (Config.FillDirection is BarDirection.Up) + { + slideCast = new(Config.Position, size, Config.SlideCastColor); + } + } + + bar.AddForegrounds(slideCast); + } + + public override PluginConfigColor GetColor() + { + if (!Config.UseJobColor || Actor is not ICharacter) + { + return Config.FillColor; + } + + ICharacter? chara = (ICharacter)Actor; + PluginConfigColor? color = GlobalColors.Instance.ColorForJobId(chara.ClassJob.RowId); + return color ?? Config.FillColor; + } + } + + public class TargetCastbarHud : CastbarHud + { + private TargetCastbarConfig Config => (TargetCastbarConfig)_config; + + public TargetCastbarHud(TargetCastbarConfig config, string? displayName = null) : base(config, displayName) + { + + } + + public override PluginConfigColor GetColor() + { + if (Config.ShowInterruptableColor && LastUsedCast?.Interruptible == true) + { + return Config.InterruptableColor; + } + + if (!Config.UseColorForDamageTypes) + { + return Config.FillColor; + } + + if (LastUsedCast != null) + { + switch (LastUsedCast.DamageType) + { + case DamageType.Physical: + case DamageType.Blunt: + case DamageType.Slashing: + case DamageType.Piercing: + return Config.PhysicalDamageColor; + + case DamageType.Magic: + return Config.MagicalDamageColor; + + case DamageType.Darkness: + return Config.DarknessDamageColor; + } + } + + return Config.FillColor; + } + + public override unsafe bool ShouldShow() + { + bool? targetCasting = Utils.IsTargetCasting(); + if (targetCasting.HasValue) + { + return targetCasting.Value; + } + + return true; + } + } + + public class FocusTargetCastbarHud : TargetCastbarHud + { + private TargetCastbarConfig Config => (TargetCastbarConfig)_config; + + public FocusTargetCastbarHud(TargetCastbarConfig config, string? displayName = null) : base(config, displayName) + { + + } + + public override unsafe bool ShouldShow() + { + bool? focusTargetCasting = Utils.IsFocusTargetCasting(); + if (focusTargetCasting.HasValue) + { + return focusTargetCasting.Value; + } + + return true; + } + } + + public class TargetOfTargetCastbarHud : TargetCastbarHud + { + private TargetCastbarConfig Config => (TargetCastbarConfig)_config; + + public TargetOfTargetCastbarHud(TargetCastbarConfig config, string? displayName = null) : base(config, displayName) + { + + } + + public override unsafe bool ShouldShow() + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + if (Actor == target) + { + bool? targetCasting = Utils.IsTargetCasting(); + if (targetCasting.HasValue) + { + return targetCasting.Value; + } + } + + IGameObject? focusTarget = Plugin.TargetManager.FocusTarget; + if (Actor == focusTarget) + { + bool? focusTargetCasting = Utils.IsFocusTargetCasting(); + if (focusTargetCasting.HasValue) + { + return focusTargetCasting.Value; + } + } + + return true; + } + } + + public class EnemyListCastbarHud : TargetCastbarHud + { + private EnemyListCastbarConfig Config => (EnemyListCastbarConfig)_config; + + public int EnemyListIndex = 0; + + public EnemyListCastbarHud(EnemyListCastbarConfig config, string? displayName = null) : base(config, displayName) + { + + } + + public override unsafe bool ShouldShow() + { + bool? casting = Utils.IsEnemyInListCasting(EnemyListIndex); + if (casting.HasValue) + { + return casting.Value; + } + + return true; + } + } + + public class NameplateCastbarHud : TargetOfTargetCastbarHud + { + private NameplateCastbarConfig Config => (NameplateCastbarConfig)_config; + + private Vector2 _customSize = new Vector2(0); + public Vector2 ParentSize { get; set; } = new Vector2(0); + + public NameplateCastbarHud(NameplateCastbarConfig config, string? displayName = null) : base(config, displayName) + { + _customSize = Config.Size; + } + + public override void DrawChildren(Vector2 origin) + { + // calculate size + float x = Config.MatchWidth ? ParentSize.X : Config.Size.X; + float y = Config.MatchHeight ? ParentSize.Y : Config.Size.Y; + _customSize = new Vector2(x, y); + + // draw + base.DrawChildren(origin); + } + + public override Vector2 GetSize() => _customSize; + } +} diff --git a/Interface/GeneralElements/ExperienceBarConfig.cs b/Interface/GeneralElements/ExperienceBarConfig.cs new file mode 100644 index 0000000..cf2c2b2 --- /dev/null +++ b/Interface/GeneralElements/ExperienceBarConfig.cs @@ -0,0 +1,57 @@ +using Dalamud.Interface; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.Bars; +using Dalamud.Bindings.ImGui; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Section("Other Elements")] + [SubSection("Experience Bar", 0)] + public class ExperienceBarConfig : BarConfig + { + [Checkbox("Hide When Downsynced")] + [Order(44, collapseWith = nameof(HideWhenInactive))] + public bool HideWhenDownsynced = false; + + [Checkbox("Use Job Color")] + [Order(45)] + public bool UseJobColor = false; + + [Checkbox("Show Rested Exp")] + [Order(50)] + public bool ShowRestedExp = true; + + [ColorEdit4("Rested Exp Color")] + [Order(55, collapseWith = nameof(ShowRestedExp))] + public PluginConfigColor RestedExpColor = new PluginConfigColor(new Vector4(110f / 255f, 197f / 255f, 207f / 255f, 50f / 100f)); + + [NestedConfig("Left Text", 60)] + public EditableLabelConfig LeftLabel; + + [NestedConfig("Right Text", 61)] + public EditableLabelConfig RightLabel; + + [NestedConfig("Sanctuary Icon", 62)] + public IconLabelConfig SanctuaryLabel = new IconLabelConfig(new Vector2(5, 0), FontAwesomeIcon.Moon, DrawAnchor.Right, DrawAnchor.Left); + + [NestedConfig("Visibility", 70)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public ExperienceBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor) + { + LeftLabel = new EditableLabelConfig(new Vector2(5, 0), "[job] Lv[level] EXP [exp:current-short]/[exp:required-short]", DrawAnchor.BottomLeft, DrawAnchor.TopLeft); + RightLabel = new EditableLabelConfig(new Vector2(-5, 0), "([exp:percent]%)", DrawAnchor.BottomRight, DrawAnchor.TopRight); + } + + public new static ExperienceBarConfig DefaultConfig() + { + return new ExperienceBarConfig( + new Vector2(0, -ImGui.GetMainViewport().Size.Y * 0.45f), + new Vector2(860, 10), + new PluginConfigColor(new Vector4(211f / 255f, 166f / 255f, 79f / 255f, 100f / 100f))); + } + } +} diff --git a/Interface/GeneralElements/ExperienceBarHud.cs b/Interface/GeneralElements/ExperienceBarHud.cs new file mode 100644 index 0000000..500764a --- /dev/null +++ b/Interface/GeneralElements/ExperienceBarHud.cs @@ -0,0 +1,84 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Interface; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using FFXIVClientStructs.FFXIV.Client.UI; +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace HSUI.Interface.GeneralElements +{ + public class ExperienceBarHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig + { + private ExperienceBarConfig Config => (ExperienceBarConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + + public IGameObject? Actor { get; set; } = null; + + private ExperienceHelper _helper = new ExperienceHelper(); + private IconLabelHud _sanctuaryLabel; + + public ExperienceBarHud(ExperienceBarConfig config, string displayName) : base(config, displayName) + { + Config.SanctuaryLabel.IconId = FontAwesomeIcon.Moon; + _sanctuaryLabel = new IconLabelHud(Config.SanctuaryLabel); + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List() { Config.Position }, new List() { Config.Size }); + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled || + Actor is null || + Config.HideWhenInactive && (Plugin.ObjectTable.LocalPlayer?.Level ?? 0) >= 100 || + (Config.HideWhenInactive && Config.HideWhenDownsynced && _helper.IsMaxLevel())) + { + return; + } + + uint current = ExperienceHelper.Instance.CurrentExp; + uint required = ExperienceHelper.Instance.RequiredExp; + uint rested = Config.ShowRestedExp ? ExperienceHelper.Instance.RestedExp : 0; + + // Exp progress bar + PluginConfigColor expFillColor = Config.UseJobColor ? ColorUtils.ColorForActor(Actor) : Config.FillColor; + Rect expBar = BarUtilities.GetFillRect(Config.Position, Config.Size, Config.FillDirection, expFillColor, current, required); + + // Rested exp bar + var restedPos = Config.FillDirection.IsInverted() ? Config.Position : Config.Position + BarUtilities.GetFillDirectionOffset(expBar.Size, Config.FillDirection); + var restedSize = Config.Size - BarUtilities.GetFillDirectionOffset(expBar.Size, Config.FillDirection); + Rect restedBar = BarUtilities.GetFillRect(restedPos, restedSize, Config.FillDirection, Config.RestedExpColor, rested, required, 0f); + + BarHud bar = new BarHud(Config, Actor); + bar.AddForegrounds(expBar, restedBar); + bar.AddLabels(Config.LeftLabel, Config.RightLabel); + + AddDrawActions(bar.GetDrawActions(origin, Config.StrataLevel)); + + // sanctuary icon + if (IsInSanctuary()) + { + AddDrawAction(Config.SanctuaryLabel.StrataLevel, () => + { + var pos = Utils.GetAnchoredPosition(origin, Config.Size, Config.Anchor); + _sanctuaryLabel.Draw(pos + Config.Position, Config.Size, Actor); + }); + } + } + + private unsafe bool IsInSanctuary() + { + AddonExp* addon = ExperienceHelper.Instance.GetExpAddon(); + if (addon == null) { return false; } + AtkImageNode* sanctuaryNode = addon->GetImageNodeById(3); + if (sanctuaryNode == null) { return false; } + + return sanctuaryNode->IsVisible(); + } + } +} diff --git a/Interface/GeneralElements/FontsConfig.cs b/Interface/GeneralElements/FontsConfig.cs new file mode 100644 index 0000000..1a9b41e --- /dev/null +++ b/Interface/GeneralElements/FontsConfig.cs @@ -0,0 +1,401 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiFileDialog; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using Dalamud.Interface.GameFonts; +using Dalamud.Logging; +using HSUI.Enums; +using HSUI.Interface.Bars; + +namespace HSUI.Interface.GeneralElements +{ + public struct FontData + { + public string Name; + public int Size; + + public FontData(string name, int size) + { + Name = name; + Size = size; + } + } + + [Disableable(false)] + [Section("Customization")] + [SubSection("Fonts", 0)] + public class FontsConfig : PluginConfigObject + { + public new static FontsConfig DefaultConfig() { return new FontsConfig(); } + + public string FontsPath = "C:\\"; + [JsonIgnore] public string ValidatedFontsPath => ValidatePath(FontsPath); + + public SortedList Fonts = new SortedList(); + public bool SupportChineseCharacters = false; + public bool SupportKoreanCharacters = false; + public bool SupportCyrillicCharacters = false; + [JsonIgnore] public readonly Dictionary GameFontMap = new Dictionary() + { + {"Axis", "axis-ffxiv"}, + {"Jupiter", "jupiter-ffxiv"}, + {"JupiterNumeric", "jupiter-numeric-ffxiv"}, + {"MiedingerMid", "meidinger-ffxiv"}, + {"Meidinger", "meidinger-numberic-ffxiv"}, + {"TrumpGothic", "trumpgothic-ffxiv"}, + + }; + + [JsonIgnore] public static readonly List DefaultFontsKeys = new List() { "Expressway_18", "Expressway_14", "Expressway_12" }; + + [JsonIgnore] public static string DefaultBigFontKey => DefaultFontsKeys[0]; + [JsonIgnore] public static string DefaultMediumFontKey => DefaultFontsKeys[1]; + [JsonIgnore] public static string DefaultSmallFontKey => DefaultFontsKeys[2]; + + [JsonIgnore] private int _inputFont = 0; + [JsonIgnore] private int _inputSize = 23; + + [JsonIgnore] private string[] _fonts = null!; + [JsonIgnore] private string[] _sizes = null!; + + [JsonIgnore] private int _removingIndex = -1; + [JsonIgnore] private int _applyingIndex = -1!; + + [JsonIgnore] private FileDialogManager _fileDialogManager = new FileDialogManager(); + + public FontsConfig() + { + ReloadFonts(); + + // default fonts + foreach (string key in DefaultFontsKeys) + { + if (!Fonts.ContainsKey(key)) + { + string[] str = key.Split("_", StringSplitOptions.RemoveEmptyEntries); + var defaultFont = new FontData(str[0], int.Parse(str[1])); + Fonts.Add(key, defaultFont); + } + } + + // sizes + _sizes = new string[100]; + for (int i = 0; i < _sizes.Length; i++) + { + _sizes[i] = (i + 1).ToString(); + } + } + + private bool IsDefaultFont(string key) + { + return DefaultFontsKeys.Contains(key); + } + + private string ValidatePath(string path) + { + if (path.EndsWith("\\") || path.EndsWith("/")) + { + return path; + } + + return path + "\\"; + } + + private string[] FontsFromPath(string path) + { + string[] fonts; + try + { + fonts = Directory.GetFiles(path, "*.ttf"); + } + catch + { + fonts = new string[0]; + } + + for (int i = 0; i < fonts.Length; i++) + { + fonts[i] = fonts[i] + .Replace(path, "") + .Replace(".ttf", "") + .Replace(".TTF", ""); + } + + return fonts; + } + + private string[] FontsFromGame() + { + string[] gameFontArray = Enum.GetNames(typeof(GameFontFamily)).Skip(1).ToArray(); + string[] fonts = new string[gameFontArray.Length]; + + for (int i = 0; i < gameFontArray.Length; i++) + { + fonts[i] = GameFontMap[gameFontArray[i]]; + } + + return fonts; + } + + private void ReloadFonts() + { + string defaultFontsPath = ValidatePath(FontsManager.Instance.DefaultFontsPath); + string[] defaultFonts = FontsFromPath(defaultFontsPath); + string[] gameFonts = FontsFromGame(); + string[] userFonts = FontsFromPath(ValidatedFontsPath); + + _fonts = new string[defaultFonts.Length + gameFonts.Length + userFonts.Length]; + defaultFonts.CopyTo(_fonts, 0); + gameFonts.CopyTo(_fonts, defaultFonts.Length); + userFonts.CopyTo(_fonts, defaultFonts.Length + gameFonts.Length); + } + + private bool AddNewEntry(int font, int size) + { + if (font < 0 || font > _fonts.Length) + { + return false; + } + + if (size <= 0 || size > _sizes.Length) + { + return false; + } + + string fontName = _fonts[font]; + string key = fontName + "_" + size.ToString(); + + if (Fonts.ContainsKey(key)) + { + return false; + } + + FontData fontData = new FontData(fontName, size); + Fonts.Add(key, fontData); + + FontsManager.Instance.BuildFonts(); + + return true; + } + + private void SelectFolder() + { + Action callback = (finished, path) => + { + if (finished && path.Length > 0) + { + FontsPath = path; + ReloadFonts(); + } + }; + + _fileDialogManager.OpenFolderDialog("Select Fonts Folder", callback); + } + + [ManualDraw] + public bool Draw(ref bool changed) + { + ImGuiTableFlags flags = + ImGuiTableFlags.RowBg | + ImGuiTableFlags.Borders | + ImGuiTableFlags.BordersOuter | + ImGuiTableFlags.BordersInner | + ImGuiTableFlags.ScrollY | + ImGuiTableFlags.SizingFixedSame; + + if (ImGui.BeginChild("Fonts", new Vector2(800, 500), false, ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (_fonts.Length == 0) + { + ImGuiHelper.Tab(); + ImGui.Text("Default font not found in \"%appdata%/Roaming/XIVLauncher/InstalledPlugins/HSUI/Media/Fonts/Expressway.ttf\""); + return false; + } + + ImGuiHelper.NewLineAndTab(); + if (ImGui.InputText("Path", ref FontsPath, 200, ImGuiInputTextFlags.EnterReturnsTrue)) + { + changed = true; + ReloadFonts(); + } + + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Folder.ToIconString(), new Vector2(0, 0))) + { + SelectFolder(); + } + ImGui.PopFont(); + + ImGuiHelper.Tab(); + ImGui.Combo("Font ##font", ref _inputFont, _fonts, 10); + + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button("\uf2f9", new Vector2(0, 0))) + { + ReloadFonts(); + } + ImGui.PopFont(); + + ImGuiHelper.Tab(); + ImGui.Combo("Size ##size", ref _inputSize, _sizes, 10); + + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), new Vector2(0, 0))) + { + changed |= AddNewEntry(_inputFont, _inputSize + 1); + } + ImGui.PopFont(); + + ImGuiHelper.NewLineAndTab(); + if (ImGui.BeginTable("table", 3, flags, new Vector2(326, 300))) + { + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch, 0, 0); + ImGui.TableSetupColumn("Size", ImGuiTableColumnFlags.WidthFixed, 0, 1); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 0, 2); + + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + + for (int i = 0; i < Fonts.Count; i++) + { + var key = Fonts.Keys[i]; + var fontData = Fonts.Values[i]; + + ImGui.PushID(i.ToString()); + ImGui.TableNextRow(ImGuiTableRowFlags.None); + + // icon + if (ImGui.TableSetColumnIndex(0)) + { + ImGui.Text(fontData.Name); + } + + // id + if (ImGui.TableSetColumnIndex(1)) + { + ImGui.Text(fontData.Size.ToString()); + } + + // remove + if (ImGui.TableSetColumnIndex(2)) + { + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Button, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, Vector4.Zero); + + if (ImGui.Button(FontAwesomeIcon.ArrowAltCircleUp.ToIconString())) + { + _applyingIndex = i; + } + + if (!IsDefaultFont(key)) + { + ImGui.SameLine(); + if (ImGui.Button(FontAwesomeIcon.Trash.ToIconString())) + { + _removingIndex = i; + } + } + + ImGui.PopFont(); + ImGui.PopStyleColor(3); + } + ImGui.PopID(); + } + + ImGui.EndTable(); + } + + ImGuiHelper.NewLineAndTab(); + if (ImGui.Checkbox("Support Chinese", ref SupportChineseCharacters)) + { + changed = true; + FontsManager.Instance.BuildFonts(); + } + + ImGui.SameLine(); + if (ImGui.Checkbox("Support Korean", ref SupportKoreanCharacters)) + { + changed = true; + FontsManager.Instance.BuildFonts(); + } + + ImGui.SameLine(); + if (ImGui.Checkbox("Support Cyrillic", ref SupportCyrillicCharacters)) + { + changed = true; + FontsManager.Instance.BuildFonts(); + } + } + + // apply confirmation + if (_applyingIndex >= 0) + { + string[] lines = new string[] { "Are you sure you want to apply this font", "to all labels using a font with the same size?" }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Apply to all labels?", lines); + + if (didConfirm) + { + var (key, font) = Fonts.ElementAt(_applyingIndex); + + List labelConfigs = ConfigurationManager.Instance.GetObjects(); + foreach (LabelConfig label in labelConfigs) + { + if (label.FontID != null && Fonts.TryGetValue(label.FontID, out FontData value)) + { + if (font.Size == value.Size) + { + label.FontID = key; + } + } + } + + changed = true; + ConfigurationManager.Instance.SaveConfigurations(forced: true); + } + + if (didConfirm || didClose) + { + _applyingIndex = -1; + } + } + + // delete confirmation + if (_removingIndex >= 0) + { + string[] lines = new string[] { "Are you sure you want to remove this font?" }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Remove custom font?", lines); + + if (didConfirm) + { + Fonts.RemoveAt(_removingIndex); + FontsManager.Instance.BuildFonts(); + changed = true; + } + + if (didConfirm || didClose) + { + _removingIndex = -1; + } + } + + ImGui.EndChild(); + + _fileDialogManager.Draw(); + + return false; + } + } +} diff --git a/Interface/GeneralElements/GCDIndicatorConfig.cs b/Interface/GeneralElements/GCDIndicatorConfig.cs new file mode 100644 index 0000000..036239d --- /dev/null +++ b/Interface/GeneralElements/GCDIndicatorConfig.cs @@ -0,0 +1,96 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.Bars; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [DisableParentSettings("Size")] + [Section("Other Elements")] + [SubSection("GCD Indicator", 0)] + public class GCDIndicatorConfig : AnchorablePluginConfigObject + { + [Checkbox("Always Show")] + [Order(3)] + public bool AlwaysShow = false; + + [Checkbox("Anchor To Mouse")] + [Order(4)] + public bool AnchorToMouse = false; + + [ColorEdit4("Background Color")] + [Order(16)] + public PluginConfigColor BackgroundColor = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 50f / 100f)); + + [ColorEdit4("Color")] + [Order(17)] + public PluginConfigColor FillColor = new PluginConfigColor(new(220f / 255f, 220f / 255f, 220f / 255f, 100f / 100f)); + + [Checkbox("Show Border")] + [Order(18)] + public bool ShowBorder = true; + + [Checkbox("Instant GCDs only", spacing = true)] + [Order(19)] + public bool InstantGCDsOnly = false; + + [Checkbox("Only show when under GCD Threshold", spacing = true)] + [Order(20)] + public bool LimitGCDThreshold = false; + + [DragFloat("GCD Threshold", velocity = 0.01f)] + [Order(21, collapseWith = nameof(LimitGCDThreshold))] + public float GCDThreshold = 1.50f; + + [Checkbox("Show GCD Queue Indicator", spacing = true)] + [Order(24)] + public bool ShowGCDQueueIndicator = true; + + [ColorEdit4("GCD Queue Color")] + [Order(25, collapseWith = nameof(ShowGCDQueueIndicator))] + public PluginConfigColor QueueColor = new PluginConfigColor(new(13f / 255f, 207f / 255f, 31f / 255f, 100f / 100f)); + + [Checkbox("Circular Mode", spacing = true)] + [Order(30)] + public bool CircularMode = false; + + [DragInt("Radius")] + [Order(35, collapseWith = nameof(CircularMode))] + public int CircleRadius = 40; + + [DragInt("Thickness")] + [Order(40, collapseWith = nameof(CircularMode))] + public int CircleThickness = 10; + + [DragInt("Start Angle", min = 0, max = 359)] + [Order(45, collapseWith = nameof(CircularMode))] + public int CircleStartAngle = 0; + + [Checkbox("Rotate CCW")] + [Order(50, collapseWith = nameof(CircularMode))] + public bool RotateCCW = false; + + [NestedConfig("Bar Mode", 45, collapsingHeader = false)] + public GCDBarConfig Bar = new GCDBarConfig( + new Vector2(0, HUDConstants.BaseHUDOffsetY + 21), + new Vector2(254, 8), + PluginConfigColor.Empty + ); + + [NestedConfig("Visibility", 70)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public new static GCDIndicatorConfig DefaultConfig() { return new GCDIndicatorConfig() { Enabled = false, Strata = StrataLevel.MID_HIGH }; } + } + + [DisableParentSettings("Position", "Anchor", "HideWhenInactive", "FillColor", "BackgroundColor", "DrawBorder")] + [Exportable(false)] + public class GCDBarConfig : BarConfig + { + public GCDBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor, BarDirection fillDirection = BarDirection.Right) + : base(position, size, fillColor, fillDirection) + { + } + } +} diff --git a/Interface/GeneralElements/GCDIndicatorHud.cs b/Interface/GeneralElements/GCDIndicatorHud.cs new file mode 100644 index 0000000..dcc12e9 --- /dev/null +++ b/Interface/GeneralElements/GCDIndicatorHud.cs @@ -0,0 +1,247 @@ +using System; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using Dalamud.Bindings.ImGui; +using System.Collections.Generic; +using System.Numerics; +using HSUI.Config; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Logging; + +namespace HSUI.Interface.GeneralElements +{ + public class GCDIndicatorHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig + { + private GCDIndicatorConfig Config => (GCDIndicatorConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + + public IGameObject? Actor { get; set; } = null; + + private bool _wasBarEnabled = true; + private bool _wasCircularModeEnabled = false; + private float _lastTotalCastTime = 0; + + public GCDIndicatorHud(GCDIndicatorConfig config, string displayName) : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + var (pos, size) = GetPositionAndSize(Vector2.Zero); + + if (Config.CircularMode) + { + pos -= size / 2f; + } + + return (new List() { pos }, new List() { size }); + } + + private (Vector2, Vector2) GetPositionAndSize(Vector2 origin) + { + Vector2 pos = Config.AnchorToMouse ? ImGui.GetMousePos() + Config.Position : origin + Config.Position; + Vector2 size = Config.Bar.Size; + + if (Config.CircularMode) + { + size = new Vector2(Config.CircleRadius * 2, Config.CircleRadius * 2); + pos += size / 2f; + } + + return (pos, size); + } + + protected override void DrawDraggableArea(Vector2 origin) + { + if (Config.AnchorToMouse) + { + return; + } + + base.DrawDraggableArea(origin); + } + + public override void DrawChildren(Vector2 origin) + { + CheckToggles(); + + if (!Config.Enabled || Actor == null || Actor is not IPlayerCharacter) + { + return; + } + + GCDHelper.GetGCDInfo((IPlayerCharacter)Actor, out var elapsed, out var total); + + if (!Config.AlwaysShow && total == 0) + { + _lastTotalCastTime = 0; + return; + } + + if (_lastTotalCastTime == 0 && Utils.IsActorCasting(Actor)) + { + _lastTotalCastTime = ((IBattleChara)Actor).TotalCastTime; + } + + var scale = elapsed / total; + if (scale <= 0) + { + _lastTotalCastTime = 0; + return; + } + + bool instantGCDsOnly = Config.InstantGCDsOnly && _lastTotalCastTime != 0; + bool thresholdGCDs = Config.LimitGCDThreshold && _lastTotalCastTime > Config.GCDThreshold; + + if (instantGCDsOnly || thresholdGCDs) + { + if (Config.AlwaysShow) + { + elapsed = 0; + total = 0; + } + else + { + return; + } + } + + Config.Bar.Position = Config.Position; + Config.Bar.Anchor = Config.Anchor; + Config.Bar.BackgroundColor = Config.BackgroundColor; + Config.Bar.FillColor = Config.FillColor; + Config.Bar.DrawBorder = Config.ShowBorder; + + if (Config.Bar.Enabled) + { + DrawNormalBar(origin, elapsed, total); + } + else + { + var (pos, size) = GetPositionAndSize(origin); + pos = Utils.GetAnchoredPosition(pos, size, Config.Anchor); + + AddDrawAction(_config.StrataLevel, () => + { + DrawCircularIndicator(pos, Config.CircleRadius, elapsed, total); + }); + } + } + + private void CheckToggles() + { + bool barEnabledChanged = _wasBarEnabled != Config.Bar.Enabled; + if (barEnabledChanged) + { + Config.CircularMode = !Config.Bar.Enabled; + } + else + { + bool circularModeChanged = _wasCircularModeEnabled != Config.CircularMode; + if (circularModeChanged) + { + Config.Bar.Enabled = !Config.CircularMode; + } + } + + _wasBarEnabled = Config.Bar.Enabled; + _wasCircularModeEnabled = Config.CircularMode; + } + + private void DrawCircularIndicator(Vector2 position, float radius, float current, float total) + { + total = Config.AlwaysShow && total == 0 ? 1 : total; + current = Config.AlwaysShow && current == 0 ? total : current; + + var size = new Vector2(radius * 2); + DrawHelper.DrawInWindow(ID, position - size / 2, size, false, (drawList) => + { + current = Math.Min(current, total); + + // controls how smooth the arc looks + const int segments = 100; + const float queueTime = 0.5f; + float startAngle = 0f; + float endAngle = 2f * (float)Math.PI; + float offset = (float)(-Math.PI / 2f + (Config.CircleStartAngle * (Math.PI / 180f))); + + if (Config.RotateCCW) + { + startAngle *= -1; + endAngle *= -1; + } + + if (Config.AlwaysShow && current == total) + { + drawList.PathArcTo(position, radius, startAngle + offset, endAngle + offset, segments); + drawList.PathStroke(Config.FillColor.Base, ImDrawFlags.None, Config.CircleThickness); + } + else + { + // always draw until the queue threshold + float progressAngle = Math.Min(current, total - (Config.ShowGCDQueueIndicator ? queueTime : 0f)) / total * endAngle; + + // drawing an arc with thickness to make it look like an annular sector + drawList.PathArcTo(position, radius, startAngle + offset, progressAngle + offset, segments); + drawList.PathStroke(Config.FillColor.Base, ImDrawFlags.None, Config.CircleThickness); + + // draw the queue indicator + if (Config.ShowGCDQueueIndicator && current > total - queueTime) + { + float oldAngle = progressAngle - 0.0003f * total * endAngle; + progressAngle = current / total * endAngle; + drawList.PathArcTo(position, radius, oldAngle + offset, progressAngle + offset, segments); + drawList.PathStroke(Config.QueueColor.Base, ImDrawFlags.None, Config.CircleThickness); + } + + // anything that remains is background + drawList.PathArcTo(position, radius, progressAngle + offset, endAngle + offset, segments); + drawList.PathStroke(Config.BackgroundColor.Base, ImDrawFlags.None, Config.CircleThickness); + } + + if (Config.ShowBorder) + { + drawList.PathArcTo(position, radius - Config.CircleThickness / 2f, 0, endAngle, segments); + drawList.PathStroke(0xFF000000, ImDrawFlags.None, 1); + + drawList.PathArcTo(position, radius + Config.CircleThickness / 2f, 0, endAngle, segments); + drawList.PathStroke(0xFF000000, ImDrawFlags.None, 1); + } + }); + } + + private void DrawNormalBar(Vector2 origin, float current, float total) + { + GCDBarConfig config = Config.Bar; + + Rect mainRect = BarUtilities.GetFillRect(config.Position, config.Size, config.FillDirection, config.FillColor, current, total, 0); + BarHud bar = new BarHud(config, null, null); + bar.AddForegrounds(mainRect); + + float currentPercent = current / total; + float percentNonQueue = total != 0 ? 1F - (500f / 1000f) / total : 0; + + if (percentNonQueue > 0 && currentPercent >= percentNonQueue && Config.ShowGCDQueueIndicator) + { + float scale = 1 - percentNonQueue; + Vector2 size = config.FillDirection.IsHorizontal() ? + new Vector2(config.Size.X * scale, config.Size.Y) : + new Vector2(config.Size.X, config.Size.Y * scale); + + Vector2 pos = config.Position; + if (config.FillDirection == BarDirection.Right) + { + pos.X += config.Size.X * percentNonQueue; + } + else if (config.FillDirection == BarDirection.Down) + { + pos.Y += config.Size.Y * percentNonQueue; + } + + Rect foreground = BarUtilities.GetFillRect(pos, size, config.FillDirection, Config.QueueColor, currentPercent - percentNonQueue, scale, 0); + bar.AddForegrounds(foreground); + } + + AddDrawActions(bar.GetDrawActions(origin, _config.StrataLevel)); + } + } +} diff --git a/Interface/GeneralElements/GlobalColors.cs b/Interface/GeneralElements/GlobalColors.cs new file mode 100644 index 0000000..6f4d904 --- /dev/null +++ b/Interface/GeneralElements/GlobalColors.cs @@ -0,0 +1,478 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Party; +using System; +using System.Collections.Generic; + +namespace HSUI.Interface.GeneralElements +{ + public class GlobalColors : IDisposable + { + #region Singleton + private MiscColorConfig _miscColorConfig = null!; + private RolesColorConfig _rolesColorConfig = null!; + + private Dictionary ColorMap = null!; + + private GlobalColors() + { + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + OnConfigReset(ConfigurationManager.Instance); + } + + private void OnConfigReset(ConfigurationManager sender) + { + _miscColorConfig = sender.GetConfigObject(); + _rolesColorConfig = sender.GetConfigObject(); + + var tanksColorConfig = sender.GetConfigObject(); + var healersColorConfig = sender.GetConfigObject(); + var meleeColorConfig = sender.GetConfigObject(); + var rangedColorConfig = sender.GetConfigObject(); + var castersColorConfig = sender.GetConfigObject(); + + ColorMap = new Dictionary() + { + // tanks + [JobIDs.GLA] = tanksColorConfig.GLAColor, + [JobIDs.MRD] = tanksColorConfig.MRDColor, + [JobIDs.PLD] = tanksColorConfig.PLDColor, + [JobIDs.WAR] = tanksColorConfig.WARColor, + [JobIDs.DRK] = tanksColorConfig.DRKColor, + [JobIDs.GNB] = tanksColorConfig.GNBColor, + + // healers + [JobIDs.CNJ] = healersColorConfig.CNJColor, + [JobIDs.WHM] = healersColorConfig.WHMColor, + [JobIDs.SCH] = healersColorConfig.SCHColor, + [JobIDs.AST] = healersColorConfig.ASTColor, + [JobIDs.SGE] = healersColorConfig.SGEColor, + + // melee + [JobIDs.PGL] = meleeColorConfig.PGLColor, + [JobIDs.LNC] = meleeColorConfig.LNCColor, + [JobIDs.ROG] = meleeColorConfig.ROGColor, + [JobIDs.MNK] = meleeColorConfig.MNKColor, + [JobIDs.DRG] = meleeColorConfig.DRGColor, + [JobIDs.NIN] = meleeColorConfig.NINColor, + [JobIDs.SAM] = meleeColorConfig.SAMColor, + [JobIDs.RPR] = meleeColorConfig.RPRColor, + [JobIDs.VPR] = meleeColorConfig.VPRColor, + + // ranged + [JobIDs.ARC] = rangedColorConfig.ARCColor, + [JobIDs.BRD] = rangedColorConfig.BRDColor, + [JobIDs.MCH] = rangedColorConfig.MCHColor, + [JobIDs.DNC] = rangedColorConfig.DNCColor, + + // casters + [JobIDs.THM] = castersColorConfig.THMColor, + [JobIDs.ACN] = castersColorConfig.ACNColor, + [JobIDs.BLM] = castersColorConfig.BLMColor, + [JobIDs.SMN] = castersColorConfig.SMNColor, + [JobIDs.RDM] = castersColorConfig.RDMColor, + [JobIDs.PCT] = castersColorConfig.PCTColor, + [JobIDs.BLU] = castersColorConfig.BLUColor, + + // crafters + [JobIDs.CRP] = _rolesColorConfig.HANDColor, + [JobIDs.BSM] = _rolesColorConfig.HANDColor, + [JobIDs.ARM] = _rolesColorConfig.HANDColor, + [JobIDs.GSM] = _rolesColorConfig.HANDColor, + [JobIDs.LTW] = _rolesColorConfig.HANDColor, + [JobIDs.WVR] = _rolesColorConfig.HANDColor, + [JobIDs.ALC] = _rolesColorConfig.HANDColor, + [JobIDs.CUL] = _rolesColorConfig.HANDColor, + + // gatherers + [JobIDs.MIN] = _rolesColorConfig.LANDColor, + [JobIDs.BOT] = _rolesColorConfig.LANDColor, + [JobIDs.FSH] = _rolesColorConfig.LANDColor + }; + } + + public static void Initialize() + { + Instance = new GlobalColors(); + } + + public static GlobalColors Instance { get; private set; } = null!; + + ~GlobalColors() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + Instance = null!; + } + #endregion + + public PluginConfigColor? ColorForJobId(uint jobId) => ColorMap.TryGetValue(jobId, out PluginConfigColor? color) ? color : null; + + public PluginConfigColor SafeColorForJobId(uint jobId) => ColorForJobId(jobId) ?? _miscColorConfig.NPCNeutralColor; + + public PluginConfigColor? RoleColorForJobId(uint jobId) + { + JobRoles role = JobsHelper.RoleForJob(jobId); + + return role switch + { + JobRoles.Tank => _rolesColorConfig.TankRoleColor, + JobRoles.Healer => _rolesColorConfig.HealerRoleColor, + JobRoles.DPSMelee => _rolesColorConfig.UseSpecificDPSColors ? _rolesColorConfig.MeleeDPSRoleColor : _rolesColorConfig.DPSRoleColor, + JobRoles.DPSRanged => _rolesColorConfig.UseSpecificDPSColors ? _rolesColorConfig.RangedDPSRoleColor : _rolesColorConfig.DPSRoleColor, + JobRoles.DPSCaster => _rolesColorConfig.UseSpecificDPSColors ? _rolesColorConfig.CasterDPSRoleColor : _rolesColorConfig.DPSRoleColor, + JobRoles.Gatherer => _rolesColorConfig.LANDColor, + JobRoles.Crafter => _rolesColorConfig.HANDColor, + _ => null + }; + } + + public PluginConfigColor SafeRoleColorForJobId(uint jobId) => RoleColorForJobId(jobId) ?? _miscColorConfig.NPCNeutralColor; + + public PluginConfigColor EmptyUnitFrameColor => _miscColorConfig.EmptyUnitFrameColor; + public PluginConfigColor EmptyColor => _miscColorConfig.EmptyColor; + public PluginConfigColor PartialFillColor => _miscColorConfig.PartialFillColor; + public PluginConfigColor NPCFriendlyColor => _miscColorConfig.NPCFriendlyColor; + public PluginConfigColor NPCHostileColor => _miscColorConfig.NPCHostileColor; + public PluginConfigColor NPCNeutralColor => _miscColorConfig.NPCNeutralColor; + } + + [Disableable(false)] + [Section("Colors")] + [SubSection("Tanks", 0)] + public class TanksColorConfig : PluginConfigObject + { + public new static TanksColorConfig DefaultConfig() { return new TanksColorConfig(); } + + [ColorEdit4("Paladin", spacing = true)] + [Order(5)] + public PluginConfigColor PLDColor = new PluginConfigColor(new(168f / 255f, 210f / 255f, 230f / 255f, 100f / 100f)); + + [ColorEdit4("Dark Knight")] + [Order(10)] + public PluginConfigColor DRKColor = new PluginConfigColor(new(209f / 255f, 38f / 255f, 204f / 255f, 100f / 100f)); + + [ColorEdit4("Warrior")] + [Order(15)] + public PluginConfigColor WARColor = new PluginConfigColor(new(207f / 255f, 38f / 255f, 33f / 255f, 100f / 100f)); + + [ColorEdit4("Gunbreaker")] + [Order(20)] + public PluginConfigColor GNBColor = new PluginConfigColor(new(121f / 255f, 109f / 255f, 48f / 255f, 100f / 100f)); + + [ColorEdit4("Gladiator", spacing = true)] + [Order(25)] + public PluginConfigColor GLAColor = new PluginConfigColor(new(168f / 255f, 210f / 255f, 230f / 255f, 100f / 100f)); + + [ColorEdit4("Marauder")] + [Order(30)] + public PluginConfigColor MRDColor = new PluginConfigColor(new(207f / 255f, 38f / 255f, 33f / 255f, 100f / 100f)); + } + + [Disableable(false)] + [Section("Colors")] + [SubSection("Healers", 0)] + public class HealersColorConfig : PluginConfigObject + { + public new static HealersColorConfig DefaultConfig() { return new HealersColorConfig(); } + + [ColorEdit4("Scholar", spacing = true)] + [Order(5)] + public PluginConfigColor SCHColor = new PluginConfigColor(new(134f / 255f, 87f / 255f, 255f / 255f, 100f / 100f)); + + [ColorEdit4("White Mage")] + [Order(10)] + public PluginConfigColor WHMColor = new PluginConfigColor(new(255f / 255f, 240f / 255f, 220f / 255f, 100f / 100f)); + + [ColorEdit4("Astrologian")] + [Order(15)] + public PluginConfigColor ASTColor = new PluginConfigColor(new(255f / 255f, 231f / 255f, 74f / 255f, 100f / 100f)); + + [ColorEdit4("Sage")] + [Order(20)] + public PluginConfigColor SGEColor = new PluginConfigColor(new(144f / 255f, 176f / 255f, 255f / 255f, 100f / 100f)); + + [ColorEdit4("Conjurer", spacing = true)] + [Order(25)] + public PluginConfigColor CNJColor = new PluginConfigColor(new(255f / 255f, 240f / 255f, 220f / 255f, 100f / 100f)); + } + + [Disableable(false)] + [Section("Colors")] + [SubSection("Melee", 0)] + public class MeleeColorConfig : PluginConfigObject + { + public new static MeleeColorConfig DefaultConfig() { return new MeleeColorConfig(); } + + [ColorEdit4("Monk", spacing = true)] + [Order(5)] + public PluginConfigColor MNKColor = new PluginConfigColor(new(214f / 255f, 156f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Ninja")] + [Order(10)] + public PluginConfigColor NINColor = new PluginConfigColor(new(175f / 255f, 25f / 255f, 100f / 255f, 100f / 100f)); + + [ColorEdit4("Dragoon")] + [Order(15)] + public PluginConfigColor DRGColor = new PluginConfigColor(new(65f / 255f, 100f / 255f, 205f / 255f, 100f / 100f)); + + [ColorEdit4("Samurai")] + [Order(20)] + public PluginConfigColor SAMColor = new PluginConfigColor(new(228f / 255f, 109f / 255f, 4f / 255f, 100f / 100f)); + + [ColorEdit4("Reaper")] + [Order(25)] + public PluginConfigColor RPRColor = new PluginConfigColor(new(150f / 255f, 90f / 255f, 144f / 255f, 100f / 100f)); + + [ColorEdit4("Viper")] + [Order(25)] + public PluginConfigColor VPRColor = new PluginConfigColor(new(16f / 255f, 130f / 255f, 16f / 255f, 100f / 100f)); + + [ColorEdit4("Pugilist", spacing = true)] + [Order(30)] + public PluginConfigColor PGLColor = new PluginConfigColor(new(214f / 255f, 156f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Rogue")] + [Order(35)] + public PluginConfigColor ROGColor = new PluginConfigColor(new(175f / 255f, 25f / 255f, 100f / 255f, 100f / 100f)); + + [ColorEdit4("Lancer")] + [Order(40)] + public PluginConfigColor LNCColor = new PluginConfigColor(new(65f / 255f, 100f / 255f, 205f / 255f, 100f / 100f)); + } + + [Disableable(false)] + [Section("Colors")] + [SubSection("Ranged", 0)] + public class RangedColorConfig : PluginConfigObject + { + public new static RangedColorConfig DefaultConfig() { return new RangedColorConfig(); } + + [ColorEdit4("Bard", spacing = true)] + [Order(5)] + public PluginConfigColor BRDColor = new PluginConfigColor(new(145f / 255f, 186f / 255f, 94f / 255f, 100f / 100f)); + + [ColorEdit4("Machinist")] + [Order(10)] + public PluginConfigColor MCHColor = new PluginConfigColor(new(110f / 255f, 225f / 255f, 214f / 255f, 100f / 100f)); + + [ColorEdit4("Dancer")] + [Order(15)] + public PluginConfigColor DNCColor = new PluginConfigColor(new(226f / 255f, 176f / 255f, 175f / 255f, 100f / 100f)); + + [ColorEdit4("Archer", separator = true)] + [Order(20)] + public PluginConfigColor ARCColor = new PluginConfigColor(new(145f / 255f, 186f / 255f, 94f / 255f, 100f / 100f)); + } + + [Disableable(false)] + [Section("Colors")] + [SubSection("Caster", 0)] + public class CastersColorConfig : PluginConfigObject + { + public new static CastersColorConfig DefaultConfig() { return new CastersColorConfig(); } + + [ColorEdit4("Black Mage", spacing = true)] + [Order(5)] + public PluginConfigColor BLMColor = new PluginConfigColor(new(165f / 255f, 121f / 255f, 214f / 255f, 100f / 100f)); + + [ColorEdit4("Summoner")] + [Order(10)] + public PluginConfigColor SMNColor = new PluginConfigColor(new(45f / 255f, 155f / 255f, 120f / 255f, 100f / 100f)); + + [ColorEdit4("Red Mage")] + [Order(15)] + public PluginConfigColor RDMColor = new PluginConfigColor(new(232f / 255f, 123f / 255f, 123f / 255f, 100f / 100f)); + + [ColorEdit4("Pictomancer")] + [Order(15)] + public PluginConfigColor PCTColor = new PluginConfigColor(new(252f / 255f, 146f / 255f, 225f / 255f, 100f / 100f)); + + [ColorEdit4("Blue Mage", spacing = true)] + [Order(20)] + public PluginConfigColor BLUColor = new PluginConfigColor(new(0f / 255f, 185f / 255f, 247f / 255f, 100f / 100f)); + + [ColorEdit4("Thaumaturge")] + [Order(25)] + public PluginConfigColor THMColor = new PluginConfigColor(new(165f / 255f, 121f / 255f, 214f / 255f, 100f / 100f)); + + [ColorEdit4("Arcanist")] + [Order(30)] + public PluginConfigColor ACNColor = new PluginConfigColor(new(45f / 255f, 155f / 255f, 120f / 255f, 100f / 100f)); + } + + [Disableable(false)] + [Section("Colors")] + [SubSection("Roles", 0)] + public class RolesColorConfig : PluginConfigObject + { + public new static RolesColorConfig DefaultConfig() { return new RolesColorConfig(); } + + [ColorEdit4("Tank")] + [Order(10)] + public PluginConfigColor TankRoleColor = new PluginConfigColor(new(21f / 255f, 28f / 255f, 100f / 255f, 100f / 100f)); + + [ColorEdit4("DPS")] + [Order(15)] + public PluginConfigColor DPSRoleColor = new PluginConfigColor(new(153f / 255f, 23f / 255f, 23f / 255f, 100f / 100f)); + + [ColorEdit4("Healer")] + [Order(20)] + public PluginConfigColor HealerRoleColor = new PluginConfigColor(new(46f / 255f, 125f / 255f, 50f / 255f, 100f / 100f)); + + [ColorEdit4("Disciple of the Land", spacing = true)] + [Order(25)] + public PluginConfigColor LANDColor = new PluginConfigColor(new(99f / 255f, 172f / 255f, 14f / 255f, 100f / 100f)); + + [ColorEdit4("Disciple of the Hand")] + [Order(30)] + public PluginConfigColor HANDColor = new PluginConfigColor(new(99f / 255f, 172f / 255f, 14f / 255f, 100f / 100f)); + + [Checkbox("Use Specific DPS Colors", spacing = true)] + [Order(35)] + public bool UseSpecificDPSColors = false; + + [ColorEdit4("Melee DPS")] + [Order(40, collapseWith = nameof(UseSpecificDPSColors))] + public PluginConfigColor MeleeDPSRoleColor = new PluginConfigColor(new(151f / 255f, 56f / 255f, 56f / 255f, 100f / 100f)); + + [ColorEdit4("Ranged DPS")] + [Order(40, collapseWith = nameof(UseSpecificDPSColors))] + public PluginConfigColor RangedDPSRoleColor = new PluginConfigColor(new(250f / 255f, 185f / 255f, 67f / 255f, 100f / 100f)); + + [ColorEdit4("Caster DPS")] + [Order(40, collapseWith = nameof(UseSpecificDPSColors))] + public PluginConfigColor CasterDPSRoleColor = new PluginConfigColor(new(154f / 255f, 82f / 255f, 193f / 255f, 100f / 100f)); + } + + [Disableable(false)] + [Section("Colors")] + [SubSection("Misc", 0)] + public class MiscColorConfig : PluginConfigObject + { + public new static MiscColorConfig DefaultConfig() { return new MiscColorConfig(); } + + [Combo("Gradient Type For Bars", "Flat Color", "Right", "Left", "Up", "Down", "Centered Horizontal", spacing = true)] + [Order(4)] + public GradientDirection GradientDirection = GradientDirection.Down; + + [ColorEdit4("Empty Unit Frame", separator = true)] + [Order(5)] + public PluginConfigColor EmptyUnitFrameColor = new PluginConfigColor(new(0f / 255f, 0f / 255f, 0f / 255f, 95f / 100f)); + + [ColorEdit4("Empty Bar")] + [Order(10)] + public PluginConfigColor EmptyColor = new PluginConfigColor(new(0f / 255f, 0f / 255f, 0f / 255f, 50f / 100f)); + + [ColorEdit4("Partially Filled Bar")] + [Order(15)] + public PluginConfigColor PartialFillColor = new PluginConfigColor(new(180f / 255f, 180f / 255f, 180f / 255f, 100f / 100f)); + + [ColorEdit4("NPC Friendly", separator = true)] + [Order(20)] + public PluginConfigColor NPCFriendlyColor = new PluginConfigColor(new(99f / 255f, 172f / 255f, 14f / 255f, 100f / 100f)); + + [ColorEdit4("NPC Hostile")] + [Order(25)] + public PluginConfigColor NPCHostileColor = new PluginConfigColor(new(233f / 255f, 4f / 255f, 4f / 255f, 100f / 100f)); + + [ColorEdit4("NPC Neutral")] + [Order(30)] + public PluginConfigColor NPCNeutralColor = new PluginConfigColor(new(218f / 255f, 157f / 255f, 46f / 255f, 100f / 100f)); + } + + [Exportable(false)] + public class ColorByHealthValueConfig : PluginConfigObject + { + + [Checkbox("Use Max Health Color")] + [Order(5)] + public bool UseMaxHealthColor = false; + + [ColorEdit4("Max Health Color")] + [Order(10, collapseWith = nameof(UseMaxHealthColor))] + public PluginConfigColor MaxHealthColor = new PluginConfigColor(new(18f / 255f, 18f / 255f, 18f / 255f, 100f / 100f)); + + [Checkbox("Job Color as Max Health Color")] + [Order(15, collapseWith = nameof(UseMaxHealthColor))] + public bool UseJobColorAsMaxHealth = false; + + [Checkbox("Job Role as Max Health Color")] + [Order(20, collapseWith = nameof(UseMaxHealthColor))] + public bool UseRoleColorAsMaxHealth = false; + + [ColorEdit4("High Health Color")] + [Order(25)] + public PluginConfigColor FullHealthColor = new PluginConfigColor(new(0f / 255f, 255f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Low Health Color")] + [Order(30)] + public PluginConfigColor LowHealthColor = new PluginConfigColor(new(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [DragFloat("Max Health Color Above Health %", min = 50f, max = 100f, velocity = 1f)] + [Order(35)] + public float FullHealthColorThreshold = 75f; + + [DragFloat("Low Health Color Below Health %", min = 0f, max = 50f, velocity = 1f)] + [Order(40)] + public float LowHealthColorThreshold = 25f; + + [Combo("Blend Mode", "LAB", "LChab", "XYZ", "RGB", "LChuv", "Luv", "Jzazbz", "JzCzhz")] + [Order(45)] + public BlendMode BlendMode = BlendMode.LAB; + } + + public class ColorByHealthFieldsConverter : PluginConfigObjectConverter + { + public ColorByHealthFieldsConverter() + { + SameTypeFieldConverter enabled = + new SameTypeFieldConverter("ColorByHealth.Enabled", false); + FieldConvertersMap.Add("UseColorBasedOnHealthValue", enabled); + + SameClassFieldConverter fullHealth = + new SameClassFieldConverter("ColorByHealth.FullHealthColor", new PluginConfigColor(new(0f / 255f, 255f / 255f, 0f / 255f, 100f / 100f))); + FieldConvertersMap.Add("FullHealthColor", fullHealth); + + SameClassFieldConverter lowHealth = + new SameClassFieldConverter("ColorByHealth.LowHealthColor", new PluginConfigColor(new(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f))); + FieldConvertersMap.Add("LowHealthColor", lowHealth); + + SameTypeFieldConverter fullThreshold = new SameTypeFieldConverter("ColorByHealth.FullHealthColorThreshold", 75f); + FieldConvertersMap.Add("FullHealthColorThreshold", fullThreshold); + + SameTypeFieldConverter lowThreshold = new SameTypeFieldConverter("ColorByHealth.LowHealthColorThreshold", 25f); + FieldConvertersMap.Add("LowHealthColorThreshold", lowThreshold); + + SameTypeFieldConverter blendMode = new SameTypeFieldConverter("ColorByHealth.BlendMode", BlendMode.LAB); + FieldConvertersMap.Add("blendMode", blendMode); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(PartyFramesColorsConfig) || + objectType == typeof(UnitFrameConfig) || + objectType == typeof(PlayerUnitFrameConfig) || + objectType == typeof(TargetUnitFrameConfig) || + objectType == typeof(TargetOfTargetUnitFrameConfig) || + objectType == typeof(FocusTargetUnitFrameConfig); + } + } +} diff --git a/Interface/GeneralElements/GlobalVisibilityConfig.cs b/Interface/GeneralElements/GlobalVisibilityConfig.cs new file mode 100644 index 0000000..821f3f8 --- /dev/null +++ b/Interface/GeneralElements/GlobalVisibilityConfig.cs @@ -0,0 +1,54 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Disableable(false)] + [Exportable(false)] + [Section("Visibility")] + [SubSection("Global", 0)] + public class GlobalVisibilityConfig : PluginConfigObject + { + public new static GlobalVisibilityConfig DefaultConfig() { return new GlobalVisibilityConfig(); } + + [NestedConfig("Visibility", 50, collapsingHeader = false)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + [JsonIgnore] + private bool _applying = false; + + [ManualDraw] + public bool Draw(ref bool changed) + { + ImGui.NewLine(); + + if (ImGui.Button("Apply to all elements", new Vector2(200, 30))) + { + _applying = true; + } + + if (_applying) + { + string[] lines = new string[] { "This will replace the visibility settings", "for ALL HSUI elements!", "Are you sure?" }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Apply?", lines); + + if (didConfirm) + { + ConfigurationManager.Instance.OnGlobalVisibilityChanged(VisibilityConfig); + changed = true; + } + + if (didConfirm || didClose) + { + _applying = false; + } + } + + return false; + } + } +} diff --git a/Interface/GeneralElements/GridConfig.cs b/Interface/GeneralElements/GridConfig.cs new file mode 100644 index 0000000..42ec9e1 --- /dev/null +++ b/Interface/GeneralElements/GridConfig.cs @@ -0,0 +1,42 @@ +using HSUI.Config; +using HSUI.Config.Attributes; + +namespace HSUI.Interface.GeneralElements +{ + [Exportable(false)] + [Section("Misc")] + [SubSection("Grid", 0)] + public class GridConfig : PluginConfigObject + { + public new static GridConfig DefaultConfig() + { + var config = new GridConfig(); + config.Enabled = false; + + return config; + } + + [DragFloat("Background Alpha", min = 0, max = 1, velocity = .05f)] + [Order(10)] + public float BackgroundAlpha = 0.3f; + + [Checkbox("Show Center Lines")] + [Order(15)] + public bool ShowCenterLines = true; + [Checkbox("Show Anchor Points")] + [Order(20)] + + public bool ShowAnchorPoints = true; + [Checkbox("Grid Divisions", spacing = true)] + [Order(25)] + public bool ShowGrid = true; + + [DragInt("Divisions Distance", min = 50, max = 500)] + [Order(30, collapseWith = nameof(ShowGrid))] + public int GridDivisionsDistance = 50; + + [DragInt("Subdivision Count", min = 1, max = 10)] + [Order(35, collapseWith = nameof(ShowGrid))] + public int GridSubdivisionCount = 4; + } +} diff --git a/Interface/GeneralElements/HUDOptionsConfig.cs b/Interface/GeneralElements/HUDOptionsConfig.cs new file mode 100644 index 0000000..6eb5a18 --- /dev/null +++ b/Interface/GeneralElements/HUDOptionsConfig.cs @@ -0,0 +1,132 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Disableable(false)] + [Section("Misc")] + [SubSection("HUD Options", 0)] + public class HUDOptionsConfig : PluginConfigObject + { + [Checkbox("Global HUD Position")] + [Order(5)] + public bool UseGlobalHudShift = false; + + [DragInt2("Position", min = -4000, max = 4000)] + [Order(6, collapseWith = nameof(UseGlobalHudShift))] + public Vector2 HudOffset = new(0, 0); + + [Checkbox("Dim HSUI's settings window when not focused")] + [Order(10)] + public bool DimConfigWindow = false; + + [Checkbox("Automatically disable HUD elements preview", help = "If enabled, all HUD elements preview modes are disabled when HSUI's setting window is closed.")] + [Order(11)] + public bool AutomaticPreviewDisabling = true; + + [Checkbox("Use HSUI style", help = "If enabled, HSUI will use its own style for the setting window instead of the general Dalamud style.")] + [Order(12)] + public bool OverrideDalamudStyle = true; + + [Checkbox("Mouseover", separator = true)] + [Order(15)] + public bool MouseoverEnabled = true; + + [Checkbox("Automatic Mode", help = + "When enabled: All your actions will automatically assume mouseover when your cursor is on top of a unit frame.\n" + + "Mouseover macros or other mouseover plugins are not necessary and WON'T WORK in this mode!\n\n" + + "When disabled: HSUI unit frames will behave like the game's ones.\n" + + "You'll need to use mouseover macros or other mouseover related plugins in this mode.")] + [Order(16, collapseWith = nameof(MouseoverEnabled))] + public bool MouseoverAutomaticMode = true; + + //[Checkbox("Support Special Mouse Clicks", isMonitored = true, spacing = true, help = + // "When enabled HSUI will attempt to support special mouse binds (mousewheel, M4, M5, etc) when the cursor\n" + + // "is hovering on top of HSUI's unit frames.\n\n" + + // "If you don't have actions bound to these mouse buttons, it is adviced that you leave this feature disabled.\n\n" + + // "This feature can cause some issues such as click inputs not working in HSUI, or through out the game.\n" + + // "If you run into these kinds of issues, you can try reloading HSUI, restarting the game, or disabling this feature.")] + //[Order(17)] + public bool InputsProxyEnabled = false; + + [Checkbox("Hide Default HUD When Replaced", isMonitored = true, separator = true, help = + "When enabled, HSUI automatically hides the default game HUD elements that HSUI replaces.\n" + + "For example: when HSUI hotbars are on, game hotbars are hidden; when HSUI unit frames are on, game parameter/target bars are hidden; etc.")] + [Order(38)] + public bool HideDefaultHudWhenReplaced = true; + + [Checkbox("Hide Default Job Gauges", isMonitored = true)] + [Order(40)] + public bool HideDefaultJobGauges = false; + + [Checkbox("Hide Default Castbar", isMonitored = true)] + [Order(45)] + public bool HideDefaultCastbar = false; + + [Checkbox("Hide Default Pulltimer", isMonitored = true)] + [Order(50)] + public bool HideDefaultPulltimer = false; + + [Checkbox("Use Regional Number Format", help = "When enabled, HSUI will use your system's regional format settings when showing numbers.\nWhen disabled, HSUI will use English number formatting instead.", separator = true)] + [Order(60)] + public bool UseRegionalNumberFormats = true; + + public new static HUDOptionsConfig DefaultConfig() => new(); + } + + public class HUDOptionsConfigConverter : PluginConfigObjectConverter + { + public HUDOptionsConfigConverter() + { + Func func = (value) => + { + Vector2[] array = new Vector2[4]; + for (int i = 0; i < 4; i++) + { + array[i] = value; + } + + return array; + }; + + TypeToClassFieldConverter castBar = new TypeToClassFieldConverter( + "CastBarOriginalPositions", + new Vector2[] { Vector2.Zero, Vector2.Zero, Vector2.Zero, Vector2.Zero }, + func + ); + + TypeToClassFieldConverter pullTimer = new TypeToClassFieldConverter( + "PulltimerOriginalPositions", + new Vector2[] { Vector2.Zero, Vector2.Zero, Vector2.Zero, Vector2.Zero }, + func + ); + + NewClassFieldConverter, Dictionary[]> jobGauge = + new NewClassFieldConverter, Dictionary[]>( + "JobGaugeOriginalPositions", + new Dictionary[] { new(), new(), new(), new() }, + (oldValue) => + { + Dictionary[] array = new Dictionary[4]; + for (int i = 0; i < 4; i++) + { + array[i] = oldValue; + } + + return array; + }); + + FieldConvertersMap.Add("CastBarOriginalPosition", castBar); + FieldConvertersMap.Add("PulltimerOriginalPosition", pullTimer); + FieldConvertersMap.Add("JobGaugeOriginalPosition", jobGauge); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(HUDOptionsConfig); + } + } +} diff --git a/Interface/GeneralElements/HotbarsConfig.cs b/Interface/GeneralElements/HotbarsConfig.cs new file mode 100644 index 0000000..7cf37db --- /dev/null +++ b/Interface/GeneralElements/HotbarsConfig.cs @@ -0,0 +1,215 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface; +using System.Numerics; +using Dalamud.Bindings.ImGui; + +namespace HSUI.Interface.GeneralElements +{ + /// + /// Config for a single hotbar (1-10). HotbarIndex is set by the containing config. + /// + public class HotbarBarConfig : AnchorablePluginConfigObject + { + internal int HotbarIndex { get; set; } = 1; + + [RadioSelector("12×1", "6×2", "4×3", "3×4", "2×6", "1×12", Label = "Bar Layout")] + [Order(20)] + public int BarLayout = 0; // 0=12x1, 1=6x2, 2=4x3, 3=3x4, 4=2x6, 5=1x12 + + /// Get (columns, rows) for the current BarLayout. + public (int Cols, int Rows) GetLayoutGrid() => BarLayout switch + { + 0 => (12, 1), + 1 => (6, 2), + 2 => (4, 3), + 3 => (3, 4), + 4 => (2, 6), + 5 => (1, 12), + _ => (12, 1) + }; + + [DragInt("Slot Count", min = 1, max = 12)] + [Order(21)] + public int SlotCount = 12; + + [DragInt2("Slot Size", min = 24, max = 96)] + [Order(22)] + public Vector2 SlotSize = new Vector2(40, 40); + + [DragInt("Slot Padding", min = 0, max = 16)] + [Order(23)] + public int SlotPadding = 2; + + [Checkbox("Show Cooldown Overlay")] + [Order(24)] + public bool ShowCooldownOverlay = true; + + [Checkbox("Show Cooldown Numbers")] + [Order(25)] + public bool ShowCooldownNumbers = true; + + [Checkbox("Show Border")] + [Order(26)] + public bool ShowBorder = true; + + [Checkbox("Show Tooltips")] + [Order(27)] + public bool ShowTooltips = true; + + [Checkbox("Show Keybinds")] + [Order(28)] + public bool ShowSlotNumbers = true; + + [Checkbox("Show Combo Highlight")] + [Order(29)] + public bool ShowComboHighlight = true; + + [Checkbox("Debug Drag & Drop")] + [Order(30)] + public bool DebugDragDrop = false; + + [NestedConfig("Visibility", 70)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public static HotbarBarConfig DefaultConfig(int hotbarIndex) + { + var config = new HotbarBarConfig { HotbarIndex = hotbarIndex }; + ApplyDefaults(config, hotbarIndex); + return config; + } + + protected static void ApplyDefaults(HotbarBarConfig config, int hotbarIndex) + { + var viewport = ImGui.GetMainViewport().Size; + float yOffset = 60 + (hotbarIndex - 1) * 50; + config.Position = new Vector2(0, -viewport.Y * 0.5f + yOffset); + config.Anchor = DrawAnchor.Top; + config.Size = new Vector2(12 * 40 + 11 * 2, 40); + config.HotbarIndex = hotbarIndex; + } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("General", 0)] + public class HotbarsConfig : PluginConfigObject + { + [Checkbox("Use HSUI Hotbars", help = "When enabled, HSUI hotbars replace the default game hotbars.")] + [Order(1, collapseWith = null)] + public new bool Enabled = true; + + [NestedConfig("Drag & Drop Options", 5, separator = true, collapseWith = null)] + public HotbarsGeneralOptionsConfig GeneralOptions = new(); + + public new static HotbarsConfig DefaultConfig() => new HotbarsConfig(); + } + + public class HotbarsGeneralOptionsConfig : PluginConfigObject + { + [Checkbox("Enable drag and drop from game UI", help = "When enabled, you can drag actions, macros, and items from the Actions menu, Macro menu, and Inventory onto HSUI hotbars.")] + [Order(1)] + public bool EnableDragDropFromGame = true; + + [Checkbox("Enable Shift+drag to rearrange", help = "When enabled, holding Shift and dragging a hotbar slot lets you swap it with another slot or rearrange your hotbar.")] + [Order(2)] + public bool EnableShiftDragToRearrange = true; + + [Checkbox("Enable release outside to clear slot", help = "When enabled, releasing a picked-up icon outside of HSUI hotbars clears that slot.")] + [Order(3)] + public bool EnableReleaseOutsideToClear = true; + + public new static HotbarsGeneralOptionsConfig DefaultConfig() => new(); + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 1", 0)] + public class Hotbar1BarConfig : HotbarBarConfig + { + public Hotbar1BarConfig() => HotbarIndex = 1; + public new static Hotbar1BarConfig DefaultConfig() { var c = new Hotbar1BarConfig(); ApplyDefaults(c, 1); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 2", 0)] + public class Hotbar2BarConfig : HotbarBarConfig + { + public Hotbar2BarConfig() => HotbarIndex = 2; + public new static Hotbar2BarConfig DefaultConfig() { var c = new Hotbar2BarConfig(); ApplyDefaults(c, 2); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 3", 0)] + public class Hotbar3BarConfig : HotbarBarConfig + { + public Hotbar3BarConfig() => HotbarIndex = 3; + public new static Hotbar3BarConfig DefaultConfig() { var c = new Hotbar3BarConfig(); ApplyDefaults(c, 3); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 4", 0)] + public class Hotbar4BarConfig : HotbarBarConfig + { + public Hotbar4BarConfig() => HotbarIndex = 4; + public new static Hotbar4BarConfig DefaultConfig() { var c = new Hotbar4BarConfig(); ApplyDefaults(c, 4); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 5", 0)] + public class Hotbar5BarConfig : HotbarBarConfig + { + public Hotbar5BarConfig() => HotbarIndex = 5; + public new static Hotbar5BarConfig DefaultConfig() { var c = new Hotbar5BarConfig(); ApplyDefaults(c, 5); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 6", 0)] + public class Hotbar6BarConfig : HotbarBarConfig + { + public Hotbar6BarConfig() => HotbarIndex = 6; + public new static Hotbar6BarConfig DefaultConfig() { var c = new Hotbar6BarConfig(); ApplyDefaults(c, 6); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 7", 0)] + public class Hotbar7BarConfig : HotbarBarConfig + { + public Hotbar7BarConfig() => HotbarIndex = 7; + public new static Hotbar7BarConfig DefaultConfig() { var c = new Hotbar7BarConfig(); ApplyDefaults(c, 7); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 8", 0)] + public class Hotbar8BarConfig : HotbarBarConfig + { + public Hotbar8BarConfig() => HotbarIndex = 8; + public new static Hotbar8BarConfig DefaultConfig() { var c = new Hotbar8BarConfig(); ApplyDefaults(c, 8); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 9", 0)] + public class Hotbar9BarConfig : HotbarBarConfig + { + public Hotbar9BarConfig() => HotbarIndex = 9; + public new static Hotbar9BarConfig DefaultConfig() { var c = new Hotbar9BarConfig(); ApplyDefaults(c, 9); return c; } + } + + [Exportable(false)] + [Section("Hotbars", true)] + [SubSection("Hotbar 10", 0)] + public class Hotbar10BarConfig : HotbarBarConfig + { + public Hotbar10BarConfig() => HotbarIndex = 10; + public new static Hotbar10BarConfig DefaultConfig() { var c = new Hotbar10BarConfig(); ApplyDefaults(c, 10); return c; } + } +} diff --git a/Interface/GeneralElements/HotbarsVisibilityConfig.cs b/Interface/GeneralElements/HotbarsVisibilityConfig.cs new file mode 100644 index 0000000..c4f6791 --- /dev/null +++ b/Interface/GeneralElements/HotbarsVisibilityConfig.cs @@ -0,0 +1,71 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HSUI.Interface.GeneralElements +{ + [Disableable(false)] + [Exportable(false)] + [Section("Visibility")] + [SubSection("Hotbars", 0)] + public class HotbarsVisibilityConfig : PluginConfigObject + { + public new static HotbarsVisibilityConfig DefaultConfig() { return new HotbarsVisibilityConfig(); } + + [NestedConfig("Hotbar 1", 50)] + public VisibilityConfig HotbarConfig1 = new VisibilityConfig(); + + [NestedConfig("Hotbar 2", 51)] + public VisibilityConfig HotbarConfig2 = new VisibilityConfig(); + + [NestedConfig("Hotbar 3", 52)] + public VisibilityConfig HotbarConfig3 = new VisibilityConfig(); + + [NestedConfig("Hotbar 4", 53)] + public VisibilityConfig HotbarConfig4 = new VisibilityConfig(); + + [NestedConfig("Hotbar 5", 54)] + public VisibilityConfig HotbarConfig5 = new VisibilityConfig(); + + [NestedConfig("Hotbar 6", 55)] + public VisibilityConfig HotbarConfig6 = new VisibilityConfig(); + + [NestedConfig("Hotbar 7", 56)] + public VisibilityConfig HotbarConfig7 = new VisibilityConfig(); + + [NestedConfig("Hotbar 8", 57)] + public VisibilityConfig HotbarConfig8 = new VisibilityConfig(); + + [NestedConfig("Hotbar 9", 58)] + public VisibilityConfig HotbarConfig9 = new VisibilityConfig(); + + [NestedConfig("Hotbar 10", 59)] + public VisibilityConfig HotbarConfig10 = new VisibilityConfig(); + + [NestedConfig("Cross Hotbar", 60)] + public VisibilityConfig HotbarConfigCross = new VisibilityConfig(); + + private List _configs; + public List GetHotbarConfigs() => _configs; + + public HotbarsVisibilityConfig() + { + _configs = new List() { + HotbarConfig1, + HotbarConfig2, + HotbarConfig3, + HotbarConfig4, + HotbarConfig5, + HotbarConfig6, + HotbarConfig7, + HotbarConfig8, + HotbarConfig9, + HotbarConfig10 + }; + } + } +} diff --git a/Interface/GeneralElements/IconConfig.cs b/Interface/GeneralElements/IconConfig.cs new file mode 100644 index 0000000..6cb6632 --- /dev/null +++ b/Interface/GeneralElements/IconConfig.cs @@ -0,0 +1,172 @@ +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Party; +using System; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + public class IconConfig : AnchorablePluginConfigObject + { + [Anchor("Frame Anchor")] + [Order(16)] + public DrawAnchor FrameAnchor = DrawAnchor.Center; + + // don't remove (used by json converter) + public IconConfig() + { + Strata = StrataLevel.MID_HIGH; + } + + public IconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + { + Position = position; + Size = size; + Anchor = anchor; + FrameAnchor = frameAnchor; + + Strata = StrataLevel.MID_HIGH; + } + } + + public class IconWithLabelConfig : IconConfig + { + [NestedConfig("Label", 20)] + public NumericLabelConfig NumericLabel = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + + public IconWithLabelConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + } + + public class RoleJobIconConfig : IconConfig + { + public RoleJobIconConfig() : base() { } + + public RoleJobIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + + [Combo("Style", "Style 1", "Style 2", "Style 3", spacing = true)] + [Order(25)] + public int Style = 0; + + [Checkbox("Use Role Icons", spacing = true)] + [Order(30)] + public bool UseRoleIcons = false; + + [Checkbox("Use Specific DPS Role Icons")] + [Order(35, collapseWith = nameof(UseRoleIcons))] + public bool UseSpecificDPSRoleIcons = false; + } + + public class SignIconConfig : IconConfig + { + public SignIconConfig() : base() { } + + public SignIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + + [Checkbox("Preview")] + [Order(35)] + public bool Preview = false; + + public uint? IconID(IGameObject? actor) + { + if (Preview) + { + return 61231; + } + + return Utils.SignIconIDForActor(actor); + } + } + + public class NameplateIconConfig : IconConfig + { + public NameplateIconConfig() : base() { } + + public NameplateIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + + [Combo("Nameplate Label Anchor", new string[] { "Name", "Title", "Highest", "Lowest" }, spacing = true)] + [Order(17)] + public NameplateLabelAnchor NameplateLabelAnchor = NameplateLabelAnchor.Name; + + [Checkbox("Prioritize Health Bar as Anchor when visible", help = "When enabled, the icon will anchor to the Health Bar if it's visible.\nIf the Health Bar disappears, it will anchor back to the desired label.")] + [Order(18)] + public bool PrioritizeHealthBarAnchor = false; + } + + public class NameplatePlayerIconConfig : NameplateIconConfig + { + public NameplatePlayerIconConfig() : base() { } + + public NameplatePlayerIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + + [Checkbox("Only show disconnected icon", spacing = true)] + [Order(19)] + public bool OnlyShowDisconnected = false; + + public bool ShouldDrawIcon(int iconId) + { + if (!OnlyShowDisconnected) { return true; } + + return (iconId >= 61503 && iconId <= 61505) || + (iconId >= 61553 && iconId <= 61555); + } + } + + public class NameplateRoleJobIconConfig : RoleJobIconConfig + { + public NameplateRoleJobIconConfig() : base() { } + + public NameplateRoleJobIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + + [Combo("Nameplate Label Anchor", new string[] { "Name", "Title", "Highest", "Lowest" }, spacing = true)] + [Order(17)] + public NameplateLabelAnchor NameplateLabelAnchor = NameplateLabelAnchor.Name; + + [Checkbox("Prioritize Health Bar as Anchor when visible", help = "When enabled, the icon will anchor to the Health Bar if it's visible.\nIf the Health Bar disappears, it will anchor back to the desired label.")] + [Order(18)] + public bool PrioritizeHealthBarAnchor = false; + } + + public class PartyFramesIconsConverter : PluginConfigObjectConverter + { + public PartyFramesIconsConverter() + { + SameTypeFieldConverter converter = new SameTypeFieldConverter("FrameAnchor", DrawAnchor.Center); + FieldConvertersMap.Add("HealthBarAnchor", converter); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(PartyFramesRoleIconConfig) || + objectType == typeof(PartyFramesLeaderIconConfig); + } + } + + public enum NameplateLabelAnchor + { + Name = 0, + Title = 1, + Highest = 2, + Lowest = 3 + } +} diff --git a/Interface/GeneralElements/LabelConfig.cs b/Interface/GeneralElements/LabelConfig.cs new file mode 100644 index 0000000..f72def1 --- /dev/null +++ b/Interface/GeneralElements/LabelConfig.cs @@ -0,0 +1,227 @@ +using Dalamud.Interface; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using Newtonsoft.Json; +using System; +using System.Globalization; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Exportable(false)] + public class EditableLabelConfig : LabelConfig + { + [InputText("Text")] + [Order(10)] + public string Text; + + public EditableLabelConfig(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) + : base(position, text, frameAnchor, textAnchor) + { + Text = text; + } + + public override string GetText() => Text; + + public override void SetText(string text) + { + Text = text; + } + } + + [Exportable(false)] + public class EditableNonFormattableLabelConfig : LabelConfig + { + [InputText("Text", formattable = false)] + [Order(10)] + public string Text; + + public EditableNonFormattableLabelConfig(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) + : base(position, text, frameAnchor, textAnchor) + { + Text = text; + } + + public override string GetText() => Text; + + public override void SetText(string text) + { + Text = text; + } + } + + [Exportable(false)] + public class NumericLabelConfig : LabelConfig + { + [Combo("Number Format", "No Decimals (i.e. \"12\")", "One Decimal (i.e. \"12.3\")", "Two Decimals (i.e. \"12.34\")")] + [Order(10)] + public int NumberFormat; + + [Combo("Rounding Mode", "Truncate", "Floor", "Ceil", "Round")] + [Order(15)] + public int NumberFunction; + + [Checkbox("Hide Text When Zero")] + [Order(65)] + public bool HideIfZero = false; + + public NumericLabelConfig(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) + : base(position, text, frameAnchor, textAnchor) + { + } + + public void SetValue(float value) + { + if (value == 0) + { + _text = HideIfZero ? string.Empty : "0"; + return; + } + + int aux = (int)Math.Pow(10, NumberFormat); + double textValue = value * aux; + + textValue = NumberFunction switch + { + 0 => Math.Truncate(textValue), + 1 => Math.Floor(textValue), + 2 => Math.Ceiling(textValue), + 3 => Math.Round(textValue), + var _ => Math.Truncate(textValue) + }; + + double v = textValue / aux; + _text = v.ToString($"F{NumberFormat}", ConfigurationManager.Instance.ActiveCultreInfo); + } + + public override NumericLabelConfig Clone(int index) => + new NumericLabelConfig(Position, _text, FrameAnchor, TextAnchor) + { + Color = Color, + OutlineColor = OutlineColor, + ShadowConfig = ShadowConfig, + ShowOutline = ShowOutline, + FontID = FontID, + UseJobColor = UseJobColor, + Enabled = Enabled, + HideIfZero = HideIfZero, + ID = ID + "_{index}" + }; + } + + [DisableParentSettings("FontID")] + [Exportable(false)] + public class IconLabelConfig : LabelConfig + { + [DragFloat("Scale", min = 1, max = 5, velocity = 0.05f)] + [Order(11)] + public float FontScale = 1; + + public FontAwesomeIcon IconId; + + public IconLabelConfig(Vector2 position, FontAwesomeIcon iconId, DrawAnchor frameAnchor, DrawAnchor textAnchor) : base(position, "", frameAnchor, textAnchor) + { + IconId = iconId; + } + + public override string GetText() => IconId.ToIconString(); + public override float GetFontScale() => FontScale; + } + + [DisableParentSettings("FontID")] + [Exportable(false)] + public class DefaultFontLabelConfig : LabelConfig + { + [DragFloat("Scale", min = 1, max = 5, velocity = 0.05f)] + [Order(11)] + public float FontScale = 1; + + public DefaultFontLabelConfig(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) + : base(position, text, frameAnchor, textAnchor) + { + } + + public override bool UseSystemFont() => true; + public override float GetFontScale() => FontScale; + } + + [Exportable(false)] + public class LabelConfig : MovablePluginConfigObject + { + [JsonIgnore] protected string _text; + + [Font] + [Order(15)] + public string? FontID = null; + + [Anchor("Frame Anchor")] + [Order(20)] + public DrawAnchor FrameAnchor = DrawAnchor.Center; + + [Anchor("Text Anchor")] + [Order(25)] + public DrawAnchor TextAnchor = DrawAnchor.TopLeft; + + [ColorEdit4("Color ##Text")] + [Order(30)] + public PluginConfigColor Color = new PluginConfigColor(Vector4.One); + + [Checkbox("Outline")] + [Order(35)] + public bool ShowOutline = true; + + [ColorEdit4("Color ##Outline")] + [Order(40, collapseWith = nameof(ShowOutline))] + public PluginConfigColor OutlineColor = new PluginConfigColor(Vector4.UnitW); + + [NestedConfig("Shadow", 45)] + public ShadowConfig ShadowConfig = new ShadowConfig() { Enabled = false }; + + [Checkbox("Use Job Color", spacing = true)] + [Order(60)] + public bool UseJobColor = false; + + [Checkbox("Use Role Color")] + [Order(65)] + public bool UseRoleColor = false; + + public LabelConfig(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) + { + Position = position; + _text = text; + FrameAnchor = frameAnchor; + TextAnchor = textAnchor; + Position = position; + + Strata = StrataLevel.HIGHEST; + } + + public virtual string GetText() => _text; + + public virtual void SetText(string text) + { + _text = text; + } + + public virtual PluginConfigColor GetColor() => Color; + + public virtual PluginConfigColor GetOutlineColor() => OutlineColor; + + public virtual bool UseSystemFont() => false; + public virtual float GetFontScale() => 1; + + public virtual LabelConfig Clone(int index) => + new LabelConfig(Position, _text, FrameAnchor, TextAnchor) + { + Color = Color, + OutlineColor = OutlineColor, + ShadowConfig = ShadowConfig, + ShowOutline = ShowOutline, + FontID = FontID, + UseJobColor = UseJobColor, + Enabled = Enabled, + ID = ID + "_{index}" + }; + } +} diff --git a/Interface/GeneralElements/LabelHud.cs b/Interface/GeneralElements/LabelHud.cs new file mode 100644 index 0000000..0730de5 --- /dev/null +++ b/Interface/GeneralElements/LabelHud.cs @@ -0,0 +1,258 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Interface; +using HSUI.Config; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using Lumina.Excel.Sheets; +using System; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + public class LabelHud : HudElement + { + private LabelConfig Config => (LabelConfig)_config; + + public LabelHud(LabelConfig config) : base(config) + { + } + + protected override void CreateDrawActions(Vector2 origin) + { + // unused + } + + public override void Draw(Vector2 origin) + { + Draw(origin); + } + + public virtual void Draw( + Vector2 origin, + Vector2? parentSize = null, + IGameObject? actor = null, + string? actorName = null, + uint? actorCurrentHp = null, + uint? actorMaxHp = null, + bool? isPlayerName = null, + string? title = null) + { + if (!Config.Enabled || Config.GetText() == null) + { + return; + } + + string? text = actor == null && actorName == null && actorCurrentHp == null && actorMaxHp == null && title == null ? + Config.GetText() : + TextTagsHelper.FormattedText(Config.GetText(), actor, actorName, actorCurrentHp, actorMaxHp, isPlayerName, title); + + DrawLabel(text, origin, parentSize ?? Vector2.Zero, actor); + } + + protected virtual void DrawLabel(string text, Vector2 parentPos, Vector2 parentSize, IGameObject? actor = null) + { + Vector2 size; + Vector2 pos; + + if (Config.UseSystemFont()) + { + ImGui.PushFont(UiBuilder.DefaultFont); + size = ImGui.CalcTextSize(text) * Config.GetFontScale(); + pos = Utils.GetAnchoredPosition(Utils.GetAnchoredPosition(parentPos + Config.Position, -parentSize, Config.FrameAnchor), size, Config.TextAnchor); + ImGui.PopFont(); + } + else + { + using (FontsManager.Instance.PushFont(Config.FontID)) + { + size = ImGui.CalcTextSize(text) * Config.GetFontScale(); + pos = Utils.GetAnchoredPosition(Utils.GetAnchoredPosition(parentPos + Config.Position, -parentSize, Config.FrameAnchor), size, Config.TextAnchor); + } + } + + DrawLabel(text, pos, size, Color(actor)); + } + + public void DrawLabel(string text, Vector2 pos, Vector2 size, PluginConfigColor color, float? alpha = null) + { + if (!Config.Enabled) { return; } + + PluginConfigColor fillColor = color; + PluginConfigColor shadowColor = Config.ShadowConfig.Color; + PluginConfigColor outlineColor = Config.GetOutlineColor(); + + if (alpha.HasValue) + { + fillColor = fillColor.WithAlpha(alpha.Value); + shadowColor = shadowColor.WithAlpha(alpha.Value); + outlineColor = outlineColor.WithAlpha(alpha.Value); + } + + Action action = (ImDrawListPtr drawList) => + { + if (Config.ShadowConfig.Enabled) + { + DrawHelper.DrawShadowText(text, pos, fillColor.Base, shadowColor.Base, drawList, Config.ShadowConfig.Offset, Config.ShadowConfig.Thickness); + } + + if (Config.ShowOutline) + { + DrawHelper.DrawOutlinedText(text, pos, fillColor.Base, outlineColor.Base, drawList); + } + + if (!Config.ShowOutline && !Config.ShadowConfig.Enabled) + { + drawList.AddText(pos, fillColor.Base, text); + } + }; + + DrawHelper.DrawInWindow(ID, pos, size, false, (drawList) => + { + if (Config.UseSystemFont()) + { + ImGui.SetWindowFontScale(Config.GetFontScale()); + ImGui.PushFont(UiBuilder.DefaultFont); + action(drawList); + ImGui.PopFont(); + ImGui.SetWindowFontScale(1); + } + else + { + using (FontsManager.Instance.PushFont(Config.FontID)) + { + action(drawList); + } + } + }); + } + + public virtual PluginConfigColor Color(IGameObject? actor = null) + { + switch (Config.UseJobColor) + { + case true when (actor is ICharacter || actor is IBattleNpc battleNpc && battleNpc.ClassJob.RowId > 0): + return ColorUtils.ColorForActor(actor); + case true when actor is not ICharacter: + return GlobalColors.Instance.NPCFriendlyColor; + } + + switch (Config.UseRoleColor) + { + case true when (actor is ICharacter || actor is IBattleNpc battleNpc && battleNpc.ClassJob.RowId > 0): + { + ICharacter? character = actor as ICharacter; + return character != null && character.ClassJob.RowId > 0 ? + GlobalColors.Instance.SafeRoleColorForJobId(character.ClassJob.RowId) : + ColorUtils.ColorForActor(character); + } + case true when actor is not ICharacter: + return GlobalColors.Instance.NPCFriendlyColor; + default: + return Config.GetColor(); + } + } + + public virtual (string, Vector2, Vector2, PluginConfigColor) PreCalculate( + Vector2 origin, + Vector2? parentSize = null, + IGameObject? actor = null, + string? actorName = null, + uint? actorCurrentHp = null, + uint? actorMaxHp = null, + bool? isPlayerName = null, + string? title = null) + { + if (!Config.Enabled || Config.GetText() == null) + { + return ("", Vector2.Zero, Vector2.Zero, Color(null)); + } + + string? text = actor == null && actorName == null && actorCurrentHp == null && actorMaxHp == null && title == null ? + Config.GetText() : + TextTagsHelper.FormattedText(Config.GetText(), actor, actorName, actorCurrentHp, actorMaxHp, isPlayerName, title); + + Vector2 pSize = parentSize ?? Vector2.Zero; + Vector2 size; + Vector2 pos; + + if (Config.UseSystemFont()) + { + ImGui.PushFont(UiBuilder.DefaultFont); + size = ImGui.CalcTextSize(text) * Config.GetFontScale(); + pos = Utils.GetAnchoredPosition(Utils.GetAnchoredPosition(origin + Config.Position, -pSize, Config.FrameAnchor), size, Config.TextAnchor); + ImGui.PopFont(); + } + else + { + using (FontsManager.Instance.PushFont(Config.FontID)) + { + size = ImGui.CalcTextSize(text) * Config.GetFontScale(); + pos = Utils.GetAnchoredPosition(Utils.GetAnchoredPosition(origin + Config.Position, -pSize, Config.FrameAnchor), size, Config.TextAnchor); + } + } + + return (text, pos, size, Color(actor)); + } + } + + public class IconLabelHud : LabelHud + { + private IconLabelConfig Config => (IconLabelConfig)_config; + + public IconLabelHud(IconLabelConfig config) : base(config) + { + } + + public override void Draw(Vector2 origin, + Vector2? parentSize = null, + IGameObject? actor = null, + string? actorName = null, + uint? actorCurrentHp = null, + uint? actorMaxHp = null, + bool? isPlayerName = null, + string? title = null) + { + string? text = Config.GetText(); + if (!Config.Enabled || text == null) + { + return; + } + + DrawLabel(text, origin, parentSize ?? Vector2.Zero, actor); + } + + protected override void DrawLabel(string text, Vector2 parentPos, Vector2 parentSize, IGameObject? actor = null) + { + ImGui.PushFont(UiBuilder.IconFont); + Vector2 size = ImGui.CalcTextSize(text) * Config.GetFontScale(); + Vector2 pos = Utils.GetAnchoredPosition(Utils.GetAnchoredPosition(parentPos + Config.Position, -parentSize, Config.FrameAnchor), size, Config.TextAnchor); + ImGui.PopFont(); + + DrawHelper.DrawInWindow(ID, pos, size, false, (drawList) => + { + ImGui.SetWindowFontScale(Config.GetFontScale()); + ImGui.PushFont(UiBuilder.IconFont); + + PluginConfigColor? color = Color(actor); + + if (Config.ShadowConfig.Enabled) + { + DrawHelper.DrawShadowText(text, pos, color.Base, Config.ShadowConfig.Color.Base, drawList, Config.ShadowConfig.Offset, Config.ShadowConfig.Thickness); + } + + if (Config.ShowOutline) + { + DrawHelper.DrawOutlinedText(text, pos, color.Base, Config.OutlineColor.Base, drawList); + } + + if (!Config.ShowOutline && !Config.ShadowConfig.Enabled) + { + drawList.AddText(pos, color.Base, text); + } + + ImGui.PopFont(); + ImGui.SetWindowFontScale(1); + }); + } + } +} \ No newline at end of file diff --git a/Interface/GeneralElements/LimitBreakConfig.cs b/Interface/GeneralElements/LimitBreakConfig.cs new file mode 100644 index 0000000..fadba19 --- /dev/null +++ b/Interface/GeneralElements/LimitBreakConfig.cs @@ -0,0 +1,35 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.Bars; +using Dalamud.Bindings.ImGui; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Section("Other Elements")] + [SubSection("Limit Break", 0)] + public class LimitBreakConfig : ChunkedProgressBarConfig + { + [NestedConfig("Visibility", 70)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public LimitBreakConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor) + { + } + + public new static LimitBreakConfig DefaultConfig() + { + var config = new LimitBreakConfig( + new Vector2(0, -ImGui.GetMainViewport().Size.Y * 0.4f), + new Vector2(500, 10), + new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 0f / 255f, 100f / 100f))); + + config.HideWhenInactive = true; + config.UsePartialFillColor = true; + config.PartialFillColor = new PluginConfigColor(new Vector4(0f / 255f, 181f / 255f, 255f / 255f, 100f / 100f)); + + return config; + } + } +} diff --git a/Interface/GeneralElements/LimitBreakHud.cs b/Interface/GeneralElements/LimitBreakHud.cs new file mode 100644 index 0000000..200d963 --- /dev/null +++ b/Interface/GeneralElements/LimitBreakHud.cs @@ -0,0 +1,67 @@ +using System; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.FFXIV.Client.UI; + +namespace HSUI.Interface.GeneralElements +{ + public class LimitBreakHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig + { + private LimitBreakConfig Config => (LimitBreakConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + + public IGameObject? Actor { get; set; } = null; + + public LimitBreakHud(LimitBreakConfig config, string displayName) : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List() { Config.Position }, new List() { Config.Size }); + } + + public override unsafe void DrawChildren(Vector2 origin) + { + Config.Label.SetText(""); + + if (!Config.Enabled) + { + return; + } + + LimitBreakController* lbController = LimitBreakController.Instance(); + AddonHWDAetherGauge* caGauge = (AddonHWDAetherGauge*) Plugin.GameGui.GetAddonByName("HWDAetherGauge", 1).Address; + + int currentLimitBreak = lbController->CurrentUnits; + int maxLimitBreak = lbController->BarUnits * lbController->BarCount; + int limitBreakChunks = lbController->BarCount; + + if (caGauge != null) + { + currentLimitBreak = caGauge->MaxGaugeValue; + maxLimitBreak = 1000; + limitBreakChunks = 5; + } + + int valuePerChunk = limitBreakChunks == 0 ? 0 : maxLimitBreak / limitBreakChunks; + int currentChunksFilled = valuePerChunk == 0 ? 0 : currentLimitBreak / valuePerChunk; + + if (Config.HideWhenInactive && limitBreakChunks == 0) + { + return; + } + + Config.Label.SetValue(currentChunksFilled); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config, limitBreakChunks, currentLimitBreak, maxLimitBreak); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.StrataLevel)); + } + } + } +} diff --git a/Interface/GeneralElements/MPTickerConfig.cs b/Interface/GeneralElements/MPTickerConfig.cs new file mode 100644 index 0000000..a404571 --- /dev/null +++ b/Interface/GeneralElements/MPTickerConfig.cs @@ -0,0 +1,75 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Interface.Bars; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [DisableParentSettings("Position")] + [Section("Other Elements")] + [SubSection("MP Ticker", 0)] + public class MPTickerConfig : MovablePluginConfigObject + { + [Checkbox("Hide on Full MP", spacing = false)] + [Order(15)] + public bool HideOnFullMP = true; + + [Checkbox("Enable Only for BLM")] + [Order(20)] + public bool EnableOnlyForBLM = false; + + [Checkbox("Show Only During Umbral Ice")] + [Order(25, collapseWith = nameof(EnableOnlyForBLM))] + public bool ShowOnlyDuringUmbralIce = true; + + [NestedConfig("MP Ticker Bar", 30)] + public MPTickerBarConfig Bar = new MPTickerBarConfig( + Vector2.Zero, + new Vector2(254, 8), + new PluginConfigColor(new(240f / 255f, 92f / 255f, 232f / 255f, 100f / 100f)) + ); + + [NestedConfig("Visibility", 70)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public new static MPTickerConfig DefaultConfig() + { + var config = new MPTickerConfig(); + config.Enabled = false; + config.Bar.Position = new Vector2(0, HUDConstants.BaseHUDOffsetY + 27); + + return config; + } + } + + [Disableable(false)] + [DisableParentSettings("HideWhenInactive")] + [Exportable(false)] + public class MPTickerBarConfig : BarConfig + { + [NestedConfig("Fire III Threshold (BLM only)", 50, separator = false, spacing = true)] + public MPTickerFire3ThresholdConfig Fire3Threshold = new MPTickerFire3ThresholdConfig(); + + public MPTickerBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + [DisableParentSettings("Value")] + public class MPTickerFire3ThresholdConfig : ThresholdConfig + { + [DragFloat("Estimated Fire III Cast Time", min = 0f, max = 10)] + [Order(11)] + public float Fire3CastTime = 1.5f; + + public MPTickerFire3ThresholdConfig() + { + Enabled = false; + ThresholdType = ThresholdType.Above; + ShowMarker = true; + MarkerColor = new(new Vector4(255f / 255f, 136f / 255f, 0 / 255f, 90f / 100f)); + } + } +} diff --git a/Interface/GeneralElements/MPTickerHud.cs b/Interface/GeneralElements/MPTickerHud.cs new file mode 100644 index 0000000..28c2595 --- /dev/null +++ b/Interface/GeneralElements/MPTickerHud.cs @@ -0,0 +1,110 @@ +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Interface.Bars; +using Dalamud.Game.ClientState.Objects.SubKinds; +using System.Linq; +using Dalamud.Game.ClientState.JobGauge.Types; + +namespace HSUI.Interface.GeneralElements +{ + public class MPTickerHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig + { + private MPTickerConfig Config => (MPTickerConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + + private MPTickHelper _mpTickHelper = null!; + public IGameObject? Actor { get; set; } = null; + + public MPTickerHud(MPTickerConfig config, string displayName) : base(config, displayName) { } + + protected override void InternalDispose() + { + _mpTickHelper?.Dispose(); + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List() { Config.Position + Config.Bar.Position }, + new List() { Config.Bar.Size }); + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled || Actor == null || Actor is not IPlayerCharacter player) + { + return; + } + + // full mp + if (Config.HideOnFullMP && player.CurrentMp >= player.MaxMp) + { + return; + } + + // BLM specific settings + if (Config.EnableOnlyForBLM) + { + if (player.ClassJob.RowId != JobIDs.BLM) + { + + return; + } + else + { + var gauge = Plugin.JobGauges.Get(); + if (Config.ShowOnlyDuringUmbralIce && !gauge.InUmbralIce) + { + return; + } + } + } + + _mpTickHelper ??= new MPTickHelper(); + + var now = ImGui.GetTime(); + var scale = (float)((now - _mpTickHelper.LastTick) / MPTickHelper.ServerTickRate); + + if (scale <= 0) + { + return; + } + + if (scale > 1) + { + scale = 1; + } + + MPTickerFire3ThresholdConfig? thresholdConfig = GetFire3ThresholdConfig(); + BarHud bar = BarUtilities.GetProgressBar(Config.Bar, thresholdConfig, null, scale, 1, 0, fillColor: Config.Bar.FillColor); + + AddDrawActions(bar.GetDrawActions(origin + Config.Position, _config.StrataLevel)); + } + + private MPTickerFire3ThresholdConfig? GetFire3ThresholdConfig() + { + if (Actor is not IPlayerCharacter player || player.ClassJob.RowId != JobIDs.BLM) + { + return null; + } + + MPTickerFire3ThresholdConfig config = Config.Bar.Fire3Threshold; + if (!config.Enabled) + { + return null; + } + + bool leyLinesActive = Utils.StatusListForBattleChara(player).Any(e => e.StatusId == 738); + float castTime = config.Fire3CastTime * (leyLinesActive ? 0.85f : 1f); + + // tick rate is 3s + // adding 0.3f as "safety net" + config.Value = (3 - castTime + 0.3f) / 3; + + return config; + } + } +} diff --git a/Interface/GeneralElements/PrimaryResourceConfig.cs b/Interface/GeneralElements/PrimaryResourceConfig.cs new file mode 100644 index 0000000..bf07983 --- /dev/null +++ b/Interface/GeneralElements/PrimaryResourceConfig.cs @@ -0,0 +1,139 @@ +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.Bars; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [DisableParentSettings("HideWhenInactive", "Label")] + [Section("Mana Bars")] + [SubSection("Player", 0)] + public class PlayerPrimaryResourceConfig : UnitFramePrimaryResourceConfig + { + public PlayerPrimaryResourceConfig(Vector2 position, Vector2 size) + : base(position, size) + { + + } + + public new static PlayerPrimaryResourceConfig DefaultConfig() + { + var size = new Vector2(HUDConstants.DefaultBigUnitFrameSize.X, 10); + var pos = new Vector2(0, 0); + + var config = new PlayerPrimaryResourceConfig(pos, size); + config.Anchor = DrawAnchor.Bottom; + + return config; + } + } + + [DisableParentSettings("HideWhenInactive", "Label")] + [Section("Mana Bars")] + [SubSection("Target", 0)] + public class TargetPrimaryResourceConfig : UnitFramePrimaryResourceConfig + { + public TargetPrimaryResourceConfig(Vector2 position, Vector2 size) + : base(position, size) + { + + } + + public new static TargetPrimaryResourceConfig DefaultConfig() + { + var size = new Vector2(HUDConstants.DefaultBigUnitFrameSize.X, 10); + var pos = new Vector2(0, 0); + + var config = new TargetPrimaryResourceConfig(pos, size); + config.Anchor = DrawAnchor.Bottom; + + return config; + } + } + + [DisableParentSettings("HideWhenInactive", "Label")] + [Section("Mana Bars")] + [SubSection("Target of Target", 0)] + public class TargetOfTargetPrimaryResourceConfig : UnitFramePrimaryResourceConfig + { + public TargetOfTargetPrimaryResourceConfig(Vector2 position, Vector2 size) + : base(position, size) + { + + } + + public new static TargetOfTargetPrimaryResourceConfig DefaultConfig() + { + var size = new Vector2(HUDConstants.DefaultSmallUnitFrameSize.X, 10); + var pos = new Vector2(0, 0); + + var config = new TargetOfTargetPrimaryResourceConfig(pos, size); + config.Anchor = DrawAnchor.Bottom; + + return config; + } + } + + [DisableParentSettings("HideWhenInactive", "Label")] + [Section("Mana Bars")] + [SubSection("Focus Target", 0)] + public class FocusTargetPrimaryResourceConfig : UnitFramePrimaryResourceConfig + { + public FocusTargetPrimaryResourceConfig(Vector2 position, Vector2 size) + : base(position, size) + { + + } + + public new static FocusTargetPrimaryResourceConfig DefaultConfig() + { + var size = new Vector2(HUDConstants.DefaultSmallUnitFrameSize.X, 10); + var pos = new Vector2(0, 0); + + var config = new FocusTargetPrimaryResourceConfig(pos, size); + config.Anchor = DrawAnchor.Bottom; + + return config; + } + } + + public abstract class UnitFramePrimaryResourceConfig : PrimaryResourceConfig + { + [Checkbox("Anchor to Unit Frame")] + [Order(16)] + public bool AnchorToUnitFrame = true; + + [Anchor("Unit Frame Anchor")] + [Order(17, collapseWith = nameof(AnchorToUnitFrame))] + public DrawAnchor UnitFrameAnchor = DrawAnchor.Bottom; + + [NestedConfig("Visibility", 1200)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public UnitFramePrimaryResourceConfig(Vector2 position, Vector2 size) + : base(position, size) + { + + } + } + + public abstract class PrimaryResourceConfig : ProgressBarConfig + { + [Checkbox("Use Job Color", spacing = true)] + [Order(19)] + public bool UseJobColor = false; + + [Checkbox("Hide When Full", spacing = true)] + [Order(41)] + public bool HidePrimaryResourceWhenFull = false; + + [NestedConfig("Label", 1000, separator = false, spacing = true)] + public EditableLabelConfig ValueLabel = new EditableLabelConfig(Vector2.Zero, "[mana:current]", DrawAnchor.Center, DrawAnchor.Center); + + public PrimaryResourceConfig(Vector2 position, Vector2 size) + : base(position, size, new(new(0 / 255f, 162f / 255f, 252f / 255f, 100f / 100f))) + { + Strata = StrataLevel.LOW; + } + } +} \ No newline at end of file diff --git a/Interface/GeneralElements/PrimaryResourceHud.cs b/Interface/GeneralElements/PrimaryResourceHud.cs new file mode 100644 index 0000000..7883506 --- /dev/null +++ b/Interface/GeneralElements/PrimaryResourceHud.cs @@ -0,0 +1,134 @@ +using HSUI.Config; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using System.Collections.Generic; +using System.Numerics; +using Dalamud.Game.ClientState.Objects.Types; +using System; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Enums; +using HSUI.Interface.Bars; +using HSUI.Interface.Party; + +namespace HSUI.Interface.GeneralElements +{ + public class PrimaryResourceHud : ParentAnchoredDraggableHudElement, IHudElementWithActor, IHudElementWithAnchorableParent, IHudElementWithVisibilityConfig + { + private PrimaryResourceConfig Config => (PrimaryResourceConfig)_config; + public VisibilityConfig? VisibilityConfig => Config is UnitFramePrimaryResourceConfig config ? config.VisibilityConfig : null; + + public PrimaryResourceTypes ResourceType = PrimaryResourceTypes.MP; + + private IGameObject? _actor; + public IGameObject? Actor + { + get => _actor; + set + { + if (value is IPlayerCharacter chara) + { + _actor = value; + + JobRoles role = JobsHelper.RoleForJob(chara.ClassJob.RowId); + ResourceType = JobsHelper.PrimaryResourceTypesByRole[role]; + } + else + { + _actor = null; + ResourceType = PrimaryResourceTypes.None; + } + } + } + + public IPartyFramesMember? PartyMember; + + protected override bool AnchorToParent => Config is UnitFramePrimaryResourceConfig config ? config.AnchorToUnitFrame : false; + protected override DrawAnchor ParentAnchor => Config is UnitFramePrimaryResourceConfig config ? config.UnitFrameAnchor : DrawAnchor.Center; + + public PrimaryResourceHud(PrimaryResourceConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List() { Config.Position }, new List() { Config.Size }); + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled) + { + return; + } + + if (PartyMember == null && (ResourceType == PrimaryResourceTypes.None || Actor == null || Actor is not IPlayerCharacter)) + { + return; + } + + ICharacter? chara = Actor != null ? (ICharacter)Actor : null; + uint current = chara == null ? PartyMember?.MP ?? 0 : 0; + uint max = chara == null ? PartyMember?.MaxMP ?? 0 : 0; + + if (chara != null) + { + GetResources(ref current, ref max, chara); + } + + if (Config.HidePrimaryResourceWhenFull && current == max) + { + return; + } + + BarHud bar = BarUtilities.GetProgressBar( + Config, + Config.ThresholdConfig, + new LabelConfig[] { Config.ValueLabel }, + current, + max, + 0, + chara, + GetColor() + ); + + Vector2 pos = origin + ParentPos(); + AddDrawActions(bar.GetDrawActions(pos, Config.StrataLevel)); + } + + private void GetResources(ref uint current, ref uint max, ICharacter actor) + { + switch (ResourceType) + { + case PrimaryResourceTypes.MP: + current = actor.CurrentMp; + max = actor.MaxMp; + break; + + case PrimaryResourceTypes.CP: + current = actor.CurrentCp; + max = actor.MaxCp; + break; + + case PrimaryResourceTypes.GP: + current = actor.CurrentGp; + max = actor.MaxGp; + break; + } + } + + public virtual PluginConfigColor GetColor() + { + if (!Config.UseJobColor) + { + return Config.FillColor; + } + + if (PartyMember != null) + { + return GlobalColors.Instance.SafeColorForJobId(PartyMember.JobId); + } + + return ColorUtils.ColorForActor(Actor); + } + } +} diff --git a/Interface/GeneralElements/PullTimerConfig.cs b/Interface/GeneralElements/PullTimerConfig.cs new file mode 100644 index 0000000..0d05145 --- /dev/null +++ b/Interface/GeneralElements/PullTimerConfig.cs @@ -0,0 +1,33 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Interface.Bars; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Section("Other Elements")] + [SubSection("Pull Timer", 0)] + public class PullTimerConfig : ProgressBarConfig + { + [Checkbox("Use Job Color")] + [Order(45)] + public bool UseJobColor = false; + + public PullTimerConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor) + { + } + + public new static PullTimerConfig DefaultConfig() + { + var config = new PullTimerConfig( + new Vector2(0, HUDConstants.BaseHUDOffsetY - 35), + new Vector2(254, 20), + new PluginConfigColor(new Vector4(233f / 255f, 4f / 255f, 4f / 255f, 100f / 100f))); + + config.HideWhenInactive = true; + + return config; + } + } +} diff --git a/Interface/GeneralElements/PullTimerHud.cs b/Interface/GeneralElements/PullTimerHud.cs new file mode 100644 index 0000000..04326aa --- /dev/null +++ b/Interface/GeneralElements/PullTimerHud.cs @@ -0,0 +1,53 @@ +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + public class PullTimerHud : DraggableHudElement, IHudElementWithActor + { + private PullTimerConfig Config => (PullTimerConfig)_config; + + public IGameObject? Actor { get; set; } = null; + + public PullTimerHud(PullTimerConfig config, string displayName) : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List() { Config.Position }, new List() { Config.Size }); + } + + public override void DrawChildren(Vector2 origin) + { + PullTimerState helper = PullTimerHelper.Instance.PullTimerState; + + Config.Label.SetValue(helper.CountDownValue); + + if (!helper.CountingDown) + { + Config.Label.SetText(""); + } + + if (!Config.Enabled || Actor is null) + { + return; + } + + if (Config.HideWhenInactive && !helper.CountingDown) + { + return; + } + + PluginConfigColor? fillColor = Config.UseJobColor ? ColorUtils.ColorForActor(Actor) : null; + + BarHud bar = BarUtilities.GetProgressBar(Config, + helper.CountDownValue, + helper.CountDownMax, 0F, Actor, fillColor); + + AddDrawActions(bar.GetDrawActions(origin, Config.StrataLevel)); + } + } +} diff --git a/Interface/GeneralElements/ShadowConfig.cs b/Interface/GeneralElements/ShadowConfig.cs new file mode 100644 index 0000000..8a582a3 --- /dev/null +++ b/Interface/GeneralElements/ShadowConfig.cs @@ -0,0 +1,22 @@ +using System.Numerics; +using HSUI.Config; +using HSUI.Config.Attributes; + +namespace HSUI.Interface.GeneralElements +{ + [Exportable(false)] + public class ShadowConfig : PluginConfigObject + { + [ColorEdit4("Color")] + [Order(5)] + public PluginConfigColor Color = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [DragInt("Thickness", min = 1, max = 20)] + [Order(10)] + public int Thickness = 1; + + [DragInt("Offset", min = 0, max = 20)] + [Order(15)] + public int Offset = 2; + } +} diff --git a/Interface/GeneralElements/UnitFrameConfig.cs b/Interface/GeneralElements/UnitFrameConfig.cs new file mode 100644 index 0000000..e846d21 --- /dev/null +++ b/Interface/GeneralElements/UnitFrameConfig.cs @@ -0,0 +1,401 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [DisableParentSettings("HideWhenInactive", "HideHealthIfPossible", "RangeConfig", "EnemyRangeConfig")] + [Section("Unit Frames")] + [SubSection("Player", 0)] + public class PlayerUnitFrameConfig : UnitFrameConfig + { + [NestedConfig("Tank Stance Indicator", 122, spacing = true)] + public TankStanceIndicatorConfig TankStanceIndicatorConfig = new TankStanceIndicatorConfig(); + + public PlayerUnitFrameConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig) + { + } + + public new static PlayerUnitFrameConfig DefaultConfig() + { + var size = HUDConstants.DefaultBigUnitFrameSize; + var pos = new Vector2(-HUDConstants.UnitFramesOffsetX - size.X / 2f, HUDConstants.BaseHUDOffsetY); + + var leftLabelConfig = new EditableLabelConfig(new Vector2(5, 0), "[name]", DrawAnchor.TopLeft, DrawAnchor.BottomLeft); + var rightLabelConfig = new EditableLabelConfig(new Vector2(-5, 0), "[health:current-short] | [health:percent]", DrawAnchor.TopRight, DrawAnchor.BottomRight); + var optionalLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center); + + var config = new PlayerUnitFrameConfig(pos, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig); + + return config; + } + } + + public enum TankStanceCorner + { + TopLeft = 0, + TopRight, + BottomLeft, + BottomRight + } + + [Exportable(false)] + public class TankStanceIndicatorConfig : PluginConfigObject + { + [Combo("Corner", "Top Left", "Top Right", "Bottom Left", "Bottom Right")] + [Order(5)] + public TankStanceCorner Corner = TankStanceCorner.BottomLeft; + + [DragFloat2("Size", min = 1, max = 500)] + [Order(10)] + public Vector2 Size = new Vector2(HUDConstants.DefaultBigUnitFrameSize.Y - 20, HUDConstants.DefaultBigUnitFrameSize.Y - 20); + + [DragInt("Thickness", min = 2, max = 20)] + [Order(15)] + public int Thickess = 4; + + [ColorEdit4("Active Color")] + [Order(20)] + public PluginConfigColor ActiveColor = new PluginConfigColor(new Vector4(0f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + + [ColorEdit4("Inactive Color")] + [Order(25)] + public PluginConfigColor InactiveColor = new PluginConfigColor(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Unit Frames")] + [SubSection("Target", 0)] + public class TargetUnitFrameConfig : UnitFrameConfig + { + public TargetUnitFrameConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig) + { + } + + public new static TargetUnitFrameConfig DefaultConfig() + { + var size = HUDConstants.DefaultBigUnitFrameSize; + var pos = new Vector2(HUDConstants.UnitFramesOffsetX + size.X / 2f, HUDConstants.BaseHUDOffsetY); + + var leftLabelConfig = new EditableLabelConfig(new Vector2(5, 0), "[health:current-short] | [health:percent]", DrawAnchor.TopLeft, DrawAnchor.BottomLeft); + var rightLabelConfig = new EditableLabelConfig(new Vector2(-5, 0), "[name]", DrawAnchor.TopRight, DrawAnchor.BottomRight); + var optionalLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center); + + return new TargetUnitFrameConfig(pos, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig); + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Unit Frames")] + [SubSection("Target of Target", 0)] + public class TargetOfTargetUnitFrameConfig : UnitFrameConfig + { + public TargetOfTargetUnitFrameConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig) + { + } + + public new static TargetOfTargetUnitFrameConfig DefaultConfig() + { + var size = HUDConstants.DefaultSmallUnitFrameSize; + var pos = new Vector2( + HUDConstants.UnitFramesOffsetX + HUDConstants.DefaultBigUnitFrameSize.X + 6 + size.X / 2f, + HUDConstants.BaseHUDOffsetY - 15 + ); + + var leftLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "[name]", DrawAnchor.Top, DrawAnchor.Bottom); + var rightLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.TopLeft); + var optionalLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.BottomLeft); + + return new TargetOfTargetUnitFrameConfig(pos, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig); + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Unit Frames")] + [SubSection("Focus Target", 0)] + public class FocusTargetUnitFrameConfig : UnitFrameConfig + { + public FocusTargetUnitFrameConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig) + { + } + + public new static FocusTargetUnitFrameConfig DefaultConfig() + { + var size = HUDConstants.DefaultSmallUnitFrameSize; + var pos = new Vector2( + -HUDConstants.UnitFramesOffsetX - HUDConstants.DefaultBigUnitFrameSize.X - 6 - size.X / 2f, + HUDConstants.BaseHUDOffsetY - 15 + ); + + var leftLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "[name]", DrawAnchor.Top, DrawAnchor.Bottom); + var rightLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center); + var optionalLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "", DrawAnchor.Bottom, DrawAnchor.Bottom); + + return new FocusTargetUnitFrameConfig(pos, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig); + } + } + + [DisableParentSettings("HideWhenInactive")] + public class UnitFrameConfig : BarConfig + { + [Checkbox("Use Job Color", spacing = true)] + [Order(45)] + public bool UseJobColor = true; + + [Checkbox("Use Role Color")] + [Order(46)] + public bool UseRoleColor = false; + + [NestedConfig("Color Based On Health Value", 50, collapsingHeader = false)] + public ColorByHealthValueConfig ColorByHealth = new ColorByHealthValueConfig(); + + [Checkbox("Job Color As Background Color", spacing = true)] + [Order(50)] + public bool UseJobColorAsBackgroundColor = false; + + [Checkbox("Role Color As Background Color")] + [Order(51)] + public bool UseRoleColorAsBackgroundColor = false; + + [Checkbox("Missing Health Color")] + [Order(55)] + public bool UseMissingHealthBar = false; + + [Checkbox("Job Color As Missing Health Color")] + [Order(56, collapseWith = nameof(UseMissingHealthBar))] + public bool UseJobColorAsMissingHealthColor = false; + + [Checkbox("Role Color As Missing Health Color")] + [Order(57, collapseWith = nameof(UseMissingHealthBar))] + public bool UseRoleColorAsMissingHealthColor = false; + + [ColorEdit4("Color" + "##MissingHealth")] + [Order(60, collapseWith = nameof(UseMissingHealthBar))] + public PluginConfigColor HealthMissingColor = new PluginConfigColor(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [Checkbox("Death Indicator Background Color", spacing = true)] + [Order(61)] + public bool UseDeathIndicatorBackgroundColor = false; + + [ColorEdit4("Color" + "##DeathIndicator")] + [Order(62, collapseWith = nameof(UseDeathIndicatorBackgroundColor))] + public PluginConfigColor DeathIndicatorBackgroundColor = new PluginConfigColor(new Vector4(204f / 255f, 3f / 255f, 3f / 255f, 50f / 100f)); + + [Checkbox("Tank Invulnerability", spacing = true)] + [Order(95)] + public bool ShowTankInvulnerability = true; + + [Checkbox("Tank Invulnerability Custom Color")] + [Order(100, collapseWith = nameof(ShowTankInvulnerability))] + public bool UseCustomInvulnerabilityColor = true; + + [ColorEdit4("Tank Invulnerability Color ##TankInvulnerabilityCustom")] + [Order(105, collapseWith = nameof(UseCustomInvulnerabilityColor))] + public PluginConfigColor CustomInvulnerabilityColor = new PluginConfigColor(new Vector4(211f / 255f, 235f / 255f, 215f / 245f, 50f / 100f)); + + [Checkbox("Walking Dead Custom Color")] + [Order(110, collapseWith = nameof(ShowTankInvulnerability))] + public bool UseCustomWalkingDeadColor = true; + + [ColorEdit4("Walking Dead Color ##TankWalkingDeadCustom")] + [Order(115, collapseWith = nameof(UseCustomWalkingDeadColor))] + public PluginConfigColor CustomWalkingDeadColor = new PluginConfigColor(new Vector4(158f / 255f, 158f / 255f, 158f / 255f, 50f / 100f)); + + [NestedConfig("Use Smooth Transitions", 120, collapsingHeader = false)] + public SmoothHealthConfig SmoothHealthConfig = new SmoothHealthConfig(); + + [Checkbox("Hide Health if Possible", spacing = true, help = "This will hide any label that has a health tag if the character doesn't have health (ie minions, friendly npcs, etc)")] + [Order(121)] + public bool HideHealthIfPossible = true; + + [NestedConfig("Left Text", 125)] + public EditableLabelConfig LeftLabelConfig = null!; + + [NestedConfig("Right Text", 130)] + public EditableLabelConfig RightLabelConfig = null!; + + [NestedConfig("Optional Text", 131)] + public EditableLabelConfig OptionalLabelConfig = null!; + + [NestedConfig("Role/Job Icon", 135)] + public RoleJobIconConfig RoleIconConfig = new RoleJobIconConfig( + new Vector2(5, 0), + new Vector2(30, 30), + DrawAnchor.Left, + DrawAnchor.Left + ); + + [NestedConfig("Sign Icon", 136)] + public SignIconConfig SignIconConfig = new SignIconConfig( + new Vector2(0, 0), + new Vector2(30, 30), + DrawAnchor.Center, + DrawAnchor.Top + ); + + [NestedConfig("Shields", 140)] + public ShieldConfig ShieldConfig = new ShieldConfig(); + + [NestedConfig("Change Friendly Alpha Based on Range", 145)] + public UnitFramesRangeConfig RangeConfig = new(); + + [NestedConfig("Change Enemy Alpha Based on Range", 146)] + public UnitFramesRangeConfig EnemyRangeConfig = new(); + + [NestedConfig("Custom Mouseover Area", 150)] + public MouseoverAreaConfig MouseoverAreaConfig = new MouseoverAreaConfig(); + + [NestedConfig("Visibility", 200)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public UnitFrameConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, new PluginConfigColor(new(40f / 255f, 40f / 255f, 40f / 255f, 100f / 100f))) + { + Position = position; + Size = size; + LeftLabelConfig = leftLabelConfig; + RightLabelConfig = rightLabelConfig; + OptionalLabelConfig = optionalLabelConfig; + BackgroundColor = new PluginConfigColor(new(0f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + RoleIconConfig.Enabled = false; + SignIconConfig.Enabled = false; + ColorByHealth.Enabled = false; + MouseoverAreaConfig.Enabled = false; + } + + public UnitFrameConfig() : base(Vector2.Zero, Vector2.Zero, PluginConfigColor.Empty) { } // don't remove + } + + [Exportable(false)] + public class ShieldConfig : PluginConfigObject + { + [DragInt("Thickness")] + [Order(5)] + public int Height = 26; // Should be 'Size' instead of 'Height' but leaving as is to avoid breaking configs + + [Checkbox("Thickness in Pixels")] + [Order(10)] + public bool HeightInPixels = false; + + [Checkbox("Fill Health First")] + [Order(15)] + public bool FillHealthFirst = true; + + [ColorEdit4("Color ##Shields")] + [Order(20)] + public PluginConfigColor Color = new PluginConfigColor(new Vector4(198f / 255f, 210f / 255f, 255f / 255f, 70f / 100f)); + } + + [Exportable(false)] + public class SmoothHealthConfig : PluginConfigObject + { + [DragFloat("Velocity", min = 1f, max = 100f)] + [Order(5)] + public float Velocity = 25f; + } + + [Exportable(false)] + public class MouseoverAreaConfig : PluginConfigObject + { + [Checkbox("Preview")] + [Order(5)] + public bool Preview = false; + + [Checkbox("Ignore Mouseover", help = "Enabling this will make it so this element is ignored by mouseover completely.\nThe area can still be defined for left and right clicks.")] + [Order(6)] + public bool Ignore = false; + + [DragInt2("Top Left Offset", min = -500, max = 500)] + [Order(10)] + public Vector2 TopLeftOffset = Vector2.Zero; + + [DragInt2("Bottom Right Offset", min = -500, max = 500)] + [Order(11)] + public Vector2 BottomRightOffset = Vector2.Zero; + + public MouseoverAreaConfig() + { + Enabled = false; + } + + public (Vector2, Vector2) GetArea(Vector2 pos, Vector2 size) + { + if (!Enabled) { return (pos, pos + size); } + + Vector2 start = pos + TopLeftOffset; + Vector2 end = pos + size + BottomRightOffset; + + return (start, end); + } + + public BarHud? GetBar(Vector2 pos, Vector2 size, string id, DrawAnchor anchor = DrawAnchor.TopLeft) + { + if (!Enabled || !Preview) { return null; } + + BarHud bar = new BarHud( + id, + true, + new(Vector4.One), + 2 + ); + + var barPos = Utils.GetAnchoredPosition(Vector2.Zero, size, anchor); + var (start, end) = GetArea(barPos + pos, size); + Rect background = new Rect(start, end - start, new(new(1, 1, 1, 0.5f))); + bar.SetBackground(background); + + return bar; + } + } + + [Exportable(false)] + public class UnitFramesRangeConfig : PluginConfigObject + { + [DragInt("Range (yalms)", min = 1, max = 500)] + [Order(5)] + public int Range = 30; + + [DragFloat("Alpha", min = 1, max = 100)] + [Order(10)] + public float Alpha = 24; + + [Checkbox("Use Additional Range Check")] + [Order(15)] + public bool UseAdditionalRangeCheck = false; + + [DragInt("Additional Range (yalms)", min = 1, max = 500)] + [Order(20, collapseWith = nameof(UseAdditionalRangeCheck))] + public int AdditionalRange = 15; + + [DragFloat("Additional Alpha", min = 1, max = 100)] + [Order(25, collapseWith = nameof(UseAdditionalRangeCheck))] + public float AdditionalAlpha = 60; + + public float AlphaForDistance(int distance, float alpha = 100f) + { + if (!Enabled) + { + return 100f; + } + + if (!UseAdditionalRangeCheck) + { + return distance > Range ? Alpha : alpha; + } + + if (Range > AdditionalRange) + { + return distance > Range ? Alpha : (distance > AdditionalRange ? AdditionalAlpha : alpha); + } + + return distance > AdditionalRange ? AdditionalAlpha : (distance > Range ? Alpha : alpha); + } + } +} diff --git a/Interface/GeneralElements/UnitFrameHud.cs b/Interface/GeneralElements/UnitFrameHud.cs new file mode 100644 index 0000000..b5e6fe5 --- /dev/null +++ b/Interface/GeneralElements/UnitFrameHud.cs @@ -0,0 +1,528 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using BattleChara = Dalamud.Game.ClientState.Objects.Types.IBattleChara; +using BattleNpcSubKind = Dalamud.Game.ClientState.Objects.Enums.BattleNpcSubKind; +using Character = Dalamud.Game.ClientState.Objects.Types.ICharacter; + +namespace HSUI.Interface.GeneralElements +{ + public unsafe class UnitFrameHud(UnitFrameConfig config, string displayName) + : DraggableHudElement(config, displayName), IHudElementWithActor, IHudElementWithMouseOver, IHudElementWithPreview, IHudElementWithVisibilityConfig + { + public UnitFrameConfig Config => (UnitFrameConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + + private SmoothHPHelper _smoothHPHelper = new SmoothHPHelper(); + + public IGameObject? Actor { get; set; } + + private bool _wasHovering = false; + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List() { Config.Position }, new List() { Config.Size }); + } + + public void StopPreview() + { + Config.MouseoverAreaConfig.Preview = false; + Config.SignIconConfig.Preview = false; + } + + public void StopMouseover() + { + if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled || Actor == null) + { + StopMouseover(); + return; + } + + DrawExtras(origin, Actor); + + if (Actor is Character character) + { + DrawCharacter(origin, character); + } + else + { + DrawFriendlyNPC(origin, Actor); + } + + // Check if mouse is hovering over the box properly + var startPos = Utils.GetAnchoredPosition(origin + Config.Position, Config.Size, Config.Anchor); + var (areaStart, areaEnd) = Config.MouseoverAreaConfig.GetArea(startPos, Config.Size); + bool isHovering = ImGui.IsMouseHoveringRect(areaStart, areaEnd); + bool ignoreMouseover = Config.MouseoverAreaConfig.Enabled && Config.MouseoverAreaConfig.Ignore; + + if (isHovering && !DraggingEnabled) + { + _wasHovering = true; + InputsHelper.Instance.SetTarget(Actor, ignoreMouseover); + + if (InputsHelper.Instance.LeftButtonClicked) + { + Plugin.TargetManager.Target = Actor; + } + else if (InputsHelper.Instance.RightButtonClicked) + { + AgentModule.Instance()->GetAgentHUD()->OpenContextMenuFromTarget((GameObject*)Actor.Address); + } + } + else if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + } + + protected virtual void DrawExtras(Vector2 origin, IGameObject? actor) + { + // override + } + + private void DrawCharacter(Vector2 pos, Character character) + { + uint currentHp = character.CurrentHp; + uint maxHp = character.MaxHp; + + // fixes weird bug with npcs + if (maxHp == 1) + { + currentHp = 1; + } + else if (Config.SmoothHealthConfig.Enabled) + { + currentHp = _smoothHPHelper.GetNextHp((int)currentHp, (int)maxHp, Config.SmoothHealthConfig.Velocity); + } + + PluginConfigColor fillColor = ColorUtils.ColorForCharacter( + character, + currentHp, + maxHp, + Config.UseJobColor, + Config.UseRoleColor, + Config.ColorByHealth + ) ?? Config.FillColor; + + Rect background = new Rect(Config.Position, Config.Size, BackgroundColor(character)); + if (Config.RangeConfig.Enabled || Config.EnemyRangeConfig.Enabled) + { + fillColor = GetDistanceColor(character, fillColor); + background.Color = GetDistanceColor(character, background.Color); + } + + Rect healthFill = BarUtilities.GetFillRect(Config.Position, Config.Size, Config.FillDirection, fillColor, currentHp, maxHp); + + BarHud bar = new BarHud(Config, character); + bar.NeedsInputs = true; + bar.SetBackground(background); + bar.AddForegrounds(healthFill); + bar.AddLabels(GetLabels(maxHp)); + + if (Config.UseMissingHealthBar) + { + Vector2 healthMissingSize = Config.Size - BarUtilities.GetFillDirectionOffset(healthFill.Size, Config.FillDirection); + Vector2 healthMissingPos = Config.FillDirection.IsInverted() + ? Config.Position + : Config.Position + BarUtilities.GetFillDirectionOffset(healthFill.Size, Config.FillDirection); + + PluginConfigColor missingHealthColor = Config.UseJobColorAsMissingHealthColor && character is BattleChara + ? GlobalColors.Instance.SafeColorForJobId(character!.ClassJob.RowId) + : Config.UseRoleColorAsMissingHealthColor && character is BattleChara + ? GlobalColors.Instance.SafeRoleColorForJobId(character!.ClassJob.RowId) + : Config.HealthMissingColor; + + if (Config.UseDeathIndicatorBackgroundColor && character is BattleChara { CurrentHp: <= 0 }) + { + missingHealthColor = Config.DeathIndicatorBackgroundColor; + } + + if (Config.UseCustomInvulnerabilityColor && character is BattleChara battleChara) + { + IStatus? tankInvuln = Utils.GetTankInvulnerabilityID(battleChara); + if (tankInvuln is not null) + { + missingHealthColor = Config.CustomInvulnerabilityColor; + } + } + + if (Config.RangeConfig.Enabled || Config.EnemyRangeConfig.Enabled) + { + missingHealthColor = GetDistanceColor(character, missingHealthColor); + } + + bar.AddForegrounds(new Rect(healthMissingPos, healthMissingSize, missingHealthColor)); + } + + // shield + BarUtilities.AddShield(bar, Config, Config.ShieldConfig, character, healthFill.Size); + + // draw action + AddDrawActions(bar.GetDrawActions(pos, Config.StrataLevel)); + + // mouseover area + BarHud? mouseoverAreaBar = Config.MouseoverAreaConfig.GetBar( + Config.Position, + Config.Size, + Config.ID + "_mouseoverArea", + Config.Anchor + ); + + if (mouseoverAreaBar != null) + { + AddDrawActions(mouseoverAreaBar.GetDrawActions(pos, StrataLevel.HIGHEST)); + } + + // role/job icon + if (Config.RoleIconConfig.Enabled && character is IPlayerCharacter) + { + uint jobId = character.ClassJob.RowId; + uint iconId = Config.RoleIconConfig.UseRoleIcons ? + JobsHelper.RoleIconIDForJob(jobId, Config.RoleIconConfig.UseSpecificDPSRoleIcons) : + JobsHelper.IconIDForJob(jobId, (uint)Config.RoleIconConfig.Style); + + if (iconId > 0) + { + var barPos = Utils.GetAnchoredPosition(pos, Config.Size, Config.Anchor); + var parentPos = Utils.GetAnchoredPosition(barPos + Config.Position, -Config.Size, Config.RoleIconConfig.FrameAnchor); + var iconPos = Utils.GetAnchoredPosition(parentPos + Config.RoleIconConfig.Position, Config.RoleIconConfig.Size, Config.RoleIconConfig.Anchor); + + AddDrawAction(Config.RoleIconConfig.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID + "_jobIcon", iconPos, Config.RoleIconConfig.Size, false, (drawList) => + { + DrawHelper.DrawIcon(iconId, iconPos, Config.RoleIconConfig.Size, false, drawList); + }); + }); + } + } + + // sign icon + if (Config.SignIconConfig.Enabled) + { + uint? iconId = Config.SignIconConfig.IconID(character); + if (iconId.HasValue) + { + var barPos = Utils.GetAnchoredPosition(pos, Config.Size, Config.Anchor); + var parentPos = Utils.GetAnchoredPosition(barPos + Config.Position, -Config.Size, Config.SignIconConfig.FrameAnchor); + var iconPos = Utils.GetAnchoredPosition(parentPos + Config.SignIconConfig.Position, Config.SignIconConfig.Size, Config.SignIconConfig.Anchor); + + AddDrawAction(Config.SignIconConfig.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID + "_signIcon", iconPos, Config.SignIconConfig.Size, false, (drawList) => + { + DrawHelper.DrawIcon(iconId.Value, iconPos, Config.SignIconConfig.Size, false, drawList); + }); + }); + } + } + } + + private LabelConfig[] GetLabels(uint maxHp) + { + List labels = new List(); + + if (Config.HideHealthIfPossible && maxHp <= 1) + { + if (!Utils.IsHealthLabel(Config.LeftLabelConfig)) + { + labels.Add(Config.LeftLabelConfig); + } + + if (!Utils.IsHealthLabel(Config.RightLabelConfig)) + { + labels.Add(Config.RightLabelConfig); + } + + if (!Utils.IsHealthLabel(Config.OptionalLabelConfig)) + { + labels.Add(Config.OptionalLabelConfig); + } + } + else + { + labels.Add(Config.LeftLabelConfig); + labels.Add(Config.RightLabelConfig); + labels.Add(Config.OptionalLabelConfig); + } + + return labels.ToArray(); + } + + + + private PluginConfigColor GetDistanceColor(Character? character, PluginConfigColor color) + { + byte distance = character != null ? character.YalmDistanceX : byte.MaxValue; + float currentAlpha = color.Vector.W * 100f; + float alpha = Config.RangeConfig.AlphaForDistance(distance, currentAlpha) / 100f; + + if (character is IBattleNpc { BattleNpcKind: BattleNpcSubKind.Enemy or BattleNpcSubKind.BattleNpcPart } && Config.EnemyRangeConfig.Enabled) + { + alpha = Config.EnemyRangeConfig.AlphaForDistance(distance, currentAlpha) / 100f; + } + + return color.WithAlpha(alpha); + } + + private unsafe void GetNPCHpValues(IGameObject? actor, out uint currentHp, out uint maxHp) + { + currentHp = 0; + maxHp = 0; + + var player = Plugin.ObjectTable.LocalPlayer; + if (player == null || actor == null || player.TargetObject == null || actor.GameObjectId != player.TargetObject.GameObjectId) + { + return; + } + + AtkUnitBase* TargetWidget = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_TargetInfoMainTarget", 1).Address; + if (TargetWidget != null) + { + AtkTextNode* textNode = TargetWidget->GetTextNodeById(11); + string integrityText = textNode->NodeText.ToString(); + + // not a gathering node or node at 100%, nothing to do + if (!integrityText.Contains("%")) + { + return; + } + + try + { + currentHp = Convert.ToUInt32((integrityText.Replace("%", ""))); + maxHp = 100; + } + catch { } + } + } + + private void DrawFriendlyNPC(Vector2 pos, IGameObject? actor) + { + GetNPCHpValues(actor, out uint currentHp, out uint maxHp); + + BarHud bar = new BarHud(Config, actor); + bar.AddLabels(GetLabels(0)); + + if (maxHp == 0) + { + bar.AddForegrounds(new Rect(Config.Position, Config.Size, ColorUtils.ColorForActor(actor))); + } + else + { + if (Config.SmoothHealthConfig.Enabled) + { + currentHp = _smoothHPHelper.GetNextHp((int)currentHp, (int)maxHp, Config.SmoothHealthConfig.Velocity); + } + + PluginConfigColor fillColor = ColorUtils.ColorForCharacter( + actor, + currentHp, + maxHp, + colorByHealthConfig: Config.ColorByHealth + ) ?? Config.FillColor; + + Rect background = new Rect(Config.Position, Config.Size, Config.BackgroundColor); + Rect healthFill = BarUtilities.GetFillRect(Config.Position, Config.Size, Config.FillDirection, fillColor, currentHp, maxHp); + + bar.NeedsInputs = true; + bar.SetBackground(background); + bar.AddForegrounds(healthFill); + } + + AddDrawActions(bar.GetDrawActions(pos, Config.StrataLevel)); + } + + private PluginConfigColor BackgroundColor(Character? chara) + { + if (Config.ShowTankInvulnerability && + !Config.UseMissingHealthBar && + chara is BattleChara battleChara) + { + IStatus? tankInvuln = Utils.GetTankInvulnerabilityID(battleChara); + + if (tankInvuln != null) + { + PluginConfigColor color; + if (Config.UseCustomInvulnerabilityColor) + { + color = Config.CustomInvulnerabilityColor; + } + else if (tankInvuln.StatusId == 811 && Config.UseCustomWalkingDeadColor) + { + color = Config.CustomWalkingDeadColor; + } + else + { + color = new PluginConfigColor(GlobalColors.Instance.SafeColorForJobId(chara.ClassJob.RowId).Vector.AdjustColor(-.8f)); + } + + return color; + } + } + + if (chara is BattleChara) + { + if (Config.UseJobColorAsBackgroundColor) + { + return GlobalColors.Instance.SafeColorForJobId(chara.ClassJob.RowId); + } + else if (Config.UseRoleColorAsBackgroundColor) + { + return GlobalColors.Instance.SafeRoleColorForJobId(chara.ClassJob.RowId); + } + else if (Config.UseDeathIndicatorBackgroundColor && chara.CurrentHp <= 0) + { + return Config.DeathIndicatorBackgroundColor; + } + else + { + return Config.BackgroundColor; + } + } + + return GlobalColors.Instance.EmptyUnitFrameColor; + } + } + + public class PlayerUnitFrameHud : UnitFrameHud + { + public new PlayerUnitFrameConfig Config => (PlayerUnitFrameConfig)_config; + + public PlayerUnitFrameHud(PlayerUnitFrameConfig config, string displayName) : base(config, displayName) + { + + } + + protected override void DrawExtras(Vector2 origin, IGameObject? actor) + { + TankStanceIndicatorConfig config = Config.TankStanceIndicatorConfig; + + if (!config.Enabled || actor is not IPlayerCharacter chara) { return; } + + uint jobId = chara.ClassJob.RowId; + if (JobsHelper.RoleForJob(jobId) != JobRoles.Tank) { return; } + + var tankStanceBuff = Utils.StatusListForBattleChara(chara).Where(o => + o.StatusId == 79 || // IRON WILL + o.StatusId == 91 || // DEFIANCE + o.StatusId == 392 || // ROYAL GUARD + o.StatusId == 393 || // IRON WILL + o.StatusId == 743 || // GRIT + o.StatusId == 1396 || // DEFIANCE + o.StatusId == 1397 || // GRIT + o.StatusId == 1833 // ROYAL GUARD + ); + + PluginConfigColor color = tankStanceBuff.Any() ? config.ActiveColor : config.InactiveColor; + + Vector2 pos = GetTankStanceCornerOrigin(origin); + var (verticalDir, horizontalDir) = GetTankStanceLinesDirections(); + + pos = new Vector2(pos.X + config.Thickess * -horizontalDir, pos.Y + config.Thickess * -verticalDir); + Vector2 vSize = new Vector2(config.Thickess * horizontalDir, (config.Size.Y + config.Thickess) * verticalDir); + Vector2 vEndPos = pos + vSize; + Vector2 hSize = new Vector2((config.Size.X + config.Thickess) * horizontalDir, config.Thickess * verticalDir); + Vector2 hEndPos = pos + hSize; + + Vector2 startPos = new Vector2(Math.Min(pos.X, hEndPos.X), Math.Min(pos.Y, hEndPos.Y)); + Vector2 endPos = new Vector2(Math.Max(pos.X, hEndPos.X), Math.Max(pos.Y, hEndPos.Y)); ; + + AddDrawAction(StrataLevel.LOWEST, () => + { + DrawHelper.DrawInWindow(ID + "_TankStance", startPos, endPos - startPos, false, (drawList) => + { + // TODO: clean up hacky math + // there's some 1px errors prob due to negative sizes + // couldn't figure it out so I did the hacky fixes + + // vertical + + drawList.AddRectFilled(pos, vEndPos, color.Base); + + if (config.Corner == TankStanceCorner.TopRight) + { + drawList.AddLine(pos, pos + new Vector2(0, vSize.Y + 1), 0xFF000000); + } + else + { + drawList.AddLine(pos, pos + new Vector2(0, vSize.Y), 0xFF000000); + } + + drawList.AddLine(pos + vSize, pos + vSize + new Vector2(-vSize.X, 0), 0xFF000000); + + // horizontal + drawList.AddRectFilled(pos, hEndPos, color.Base); + + if (config.Corner == TankStanceCorner.BottomLeft) + { + drawList.AddLine(pos, pos + new Vector2(hSize.X + 1, 0), 0xFF000000); + } + else + { + drawList.AddLine(pos, pos + new Vector2(hSize.X, 0), 0xFF000000); + } + + if (config.Corner == TankStanceCorner.BottomRight) + { + drawList.AddLine(pos + new Vector2(0, 1), pos + new Vector2(0, hSize.Y), 0xFF000000); + } + else + { + drawList.AddLine(pos, pos + new Vector2(0, hSize.Y), 0xFF000000); + } + + drawList.AddLine(pos + hSize, pos + hSize + new Vector2(0, -hSize.Y), 0xFF000000); + }); + }); + } + + private Vector2 GetTankStanceCornerOrigin(Vector2 origin) + { + var topLeft = Utils.GetAnchoredPosition(origin + Config.Position, Config.Size, Config.Anchor); + + return Config.TankStanceIndicatorConfig.Corner switch + { + TankStanceCorner.TopRight => topLeft + new Vector2(Config.Size.X - 1, 0), + TankStanceCorner.BottomLeft => topLeft + new Vector2(0, Config.Size.Y - 1), + TankStanceCorner.BottomRight => topLeft + Config.Size - Vector2.One, + _ => topLeft + }; + } + + private (int, int) GetTankStanceLinesDirections() + { + return Config.TankStanceIndicatorConfig.Corner switch + { + TankStanceCorner.TopLeft => (1, 1), + TankStanceCorner.TopRight => (1, -1), + TankStanceCorner.BottomLeft => (-1, 1), + _ => (-1, -1) + }; + } + } +} \ No newline at end of file diff --git a/Interface/GeneralElements/VisibilityConfig.cs b/Interface/GeneralElements/VisibilityConfig.cs new file mode 100644 index 0000000..a0d2c2f --- /dev/null +++ b/Interface/GeneralElements/VisibilityConfig.cs @@ -0,0 +1,168 @@ +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.Party; +using System.Linq; + +namespace HSUI.Interface +{ + [Exportable(false)] + public class VisibilityConfig : PluginConfigObject + { + [Checkbox("Hide outside of combat")] + [Order(5)] + public bool HideOutsideOfCombat = false; + + [Checkbox("Hide in combat")] + [Order(6)] + public bool HideInCombat = false; + + [Checkbox("Hide in Gold Saucer")] + [Order(7)] + public bool HideInGoldSaucer = false; + + [Checkbox("Hide while at full HP")] + [Order(8)] + public bool HideOnFullHP = false; + + [Checkbox("Hide when in duty")] + [Order(9)] + public bool HideInDuty = false; + + [Checkbox("Hide in Island Sanctuary")] + [Order(10)] + public bool HideInIslandSanctuary = false; + + [Checkbox("Hide in PvP")] + [Order(11)] + public bool HideInPvP = false; + + [Checkbox("Always show when in duty")] + [Order(20)] + public bool ShowInDuty = false; + + [Checkbox("Always show when weapon is drawn")] + [Order(21)] + public bool ShowOnWeaponDrawn = false; + + [Checkbox("Always show when crafting")] + [Order(22)] + public bool ShowWhileCrafting = false; + + [Checkbox("Always show when gathering")] + [Order(23)] + public bool ShowWhileGathering = false; + + [Checkbox("Always show while in a party")] + [Order(24)] + public bool ShowInParty = false; + + [Checkbox("Always show while in Island Sanctuary")] + [Order(25)] + public bool ShowInIslandSanctuary = false; + + [Checkbox("Always show while in PvP")] + [Order(26)] + public bool ShowInPvP = false; + + [Checkbox("Always show while target exists")] + [Order(27)] + public bool ShowWhileTargetExists = false; + + + private bool IsInCombat() => Plugin.Condition[ConditionFlag.InCombat]; + + private bool IsInDuty() => Plugin.Condition[ConditionFlag.BoundByDuty]; + + private bool IsCrafting() => Plugin.Condition[ConditionFlag.Crafting] || Plugin.Condition[ConditionFlag.ExecutingCraftingAction]; + + private bool IsGathering() => Plugin.Condition[ConditionFlag.Gathering] || Plugin.Condition[ConditionFlag.ExecutingGatheringAction]; + + private bool HasWeaponDrawn() => (Plugin.ObjectTable.LocalPlayer != null && Plugin.ObjectTable.LocalPlayer.StatusFlags.HasFlag(StatusFlags.WeaponOut)); + + private bool IsInGoldSaucer() => _goldSaucerIDs.Any(id => id == Plugin.ClientState.TerritoryType); + + private bool IsInIslandSanctuary() => Plugin.ClientState.TerritoryType == 1055; + + private readonly uint[] _goldSaucerIDs = { 144, 388, 389, 390, 391, 579, 792, 899, 941 }; + + public bool IsElementVisible(HudElement? element = null) + { + if (!Enabled) { return true; } + if (!ConfigurationManager.Instance.LockHUD) { return true; } + if (element != null && element.GetType() == typeof(PlayerCastbarHud)) { return true; } + if (element != null && !element.GetConfig().Enabled) { return false; } + + bool isInIslandSanctuary = IsInIslandSanctuary(); + bool isInDuty = IsInDuty() && !isInIslandSanctuary; + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + + // show + if (ShowInDuty && isInDuty) { return true; } + + if (ShowOnWeaponDrawn && HasWeaponDrawn()) { return true; } + + if (ShowWhileCrafting && IsCrafting()) { return true; } + + if (ShowWhileGathering && IsGathering()) { return true; } + + if (ShowInParty && PartyManager.Instance.MemberCount > 1) { return true; } + + if (ShowInIslandSanctuary && isInIslandSanctuary) { return true; } + + if (ShowInPvP && Plugin.ClientState.IsPvP) { return true; } + + if (ShowWhileTargetExists && player != null && player.TargetObject != null) { return true; } + + + // hide + if (HideOutsideOfCombat && !IsInCombat()) { return false; } + + if (HideInCombat && IsInCombat()) { return false; } + + if (HideInGoldSaucer && IsInGoldSaucer()) { return false; } + + if (HideOnFullHP && player != null && player.CurrentHp == player.MaxHp) { return false; } + + if (HideInDuty && isInDuty) { return false; } + + if (HideInIslandSanctuary && isInIslandSanctuary) { return false; } + + if (HideInPvP && Plugin.ClientState.IsPvP) { return false; } + + return true; + } + + public void CopyFrom(VisibilityConfig config) + { + Enabled = config.Enabled; + + HideOutsideOfCombat = config.HideOutsideOfCombat; + HideInCombat = config.HideInCombat; + HideInGoldSaucer = config.HideInGoldSaucer; + HideOnFullHP = config.HideOnFullHP; + HideInDuty = config.HideInDuty; + HideInIslandSanctuary = config.HideInIslandSanctuary; + HideInPvP = config.HideInPvP; + + ShowInDuty = config.ShowInDuty; + ShowOnWeaponDrawn = config.ShowOnWeaponDrawn; + ShowWhileCrafting = config.ShowWhileCrafting; + ShowWhileGathering = config.ShowWhileGathering; + ShowInParty = config.ShowInParty; + ShowInIslandSanctuary = config.ShowInIslandSanctuary; + ShowInPvP = config.ShowInPvP; + ShowWhileTargetExists = config.ShowWhileTargetExists; + } + + public VisibilityConfig() + { + Enabled = false; + } + } +} + + diff --git a/Interface/GeneralElements/WindowClippingConfig.cs b/Interface/GeneralElements/WindowClippingConfig.cs new file mode 100644 index 0000000..82a8139 --- /dev/null +++ b/Interface/GeneralElements/WindowClippingConfig.cs @@ -0,0 +1,151 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; + +namespace HSUI.Interface.GeneralElements +{ + public enum WindowClippingMode + { + Full, + Hide, + Performance + } + + [Exportable(false)] + [Disableable(false)] + [Section("Misc")] + [SubSection("Window Clipping", 0)] + public class WindowClippingConfig : PluginConfigObject + { + public new static WindowClippingConfig DefaultConfig() => new WindowClippingConfig(); + + public WindowClippingMode Mode = WindowClippingMode.Full; + + public bool NameplatesClipRectsEnabled = true; + public bool TargetCastbarClipRectEnabled = false; + public bool HotbarsClipRectsEnabled = false; + public bool ChatBubblesPlayersClipRectsEnabled = true; + public bool ChatBubblesNPCClipRectsEnabled = true; + + public bool ThirdPartyClipRectsEnabled = true; + + private bool _showConfirmationDialog = false; + + [ManualDraw] + public bool Draw(ref bool changed) + { + ImGuiHelper.NewLineAndTab(); + + if (ImGui.Checkbox("Enabled", ref Enabled)) + { + if (Enabled) + { + Enabled = false; + _showConfirmationDialog = true; + } + else + { + changed = true; + } + } + + // confirmation dialog + if (_showConfirmationDialog) + { + string[] lines = new string[] { "THIS FEATURE IS KNOWN TO CAUSE RANDOM", "CRASHES TO A SMALL PORTION OF USERS!!!", "Are you sure you want to enable it?" }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("WARNING!", lines); + + if (didConfirm) + { + Enabled = true; + changed = true; + } + + if (didConfirm || didClose) + { + _showConfirmationDialog = false; + } + } + + if (!Enabled) { return changed; } + + // mode + ImGuiHelper.NewLineAndTab(); + ImGui.SameLine(); + ImGui.Text("Mode: "); + + ImGui.SameLine(); + if (ImGui.RadioButton("Full", Mode == WindowClippingMode.Full)) + { + Mode = WindowClippingMode.Full; + } + + ImGui.SameLine(); + if (ImGui.RadioButton("Hide", Mode == WindowClippingMode.Hide)) + { + Mode = WindowClippingMode.Hide; + } + + ImGui.SameLine(); + if (ImGui.RadioButton("Performance", Mode == WindowClippingMode.Performance)) + { + Mode = WindowClippingMode.Performance; + } + + // nameplates + ImGui.NewLine(); + ImGuiHelper.NewLineAndTab(); + changed |= ImGui.Checkbox("Enable special clipping for Nameplates", ref NameplatesClipRectsEnabled); + ImGuiHelper.SetTooltip("When enabled, Nameplates will get covered by game UI elements that wouldn't normally cover HSUI elements."); + + if (NameplatesClipRectsEnabled) + { + ImGuiHelper.Tab(); ImGuiHelper.Tab(); + changed |= ImGui.Checkbox("Default Target Castbar", ref TargetCastbarClipRectEnabled); + ImGuiHelper.SetTooltip("When enabled, the game's target castbar will not be covered by HSUI Nameplates.\nFor players that prefer to use the default target cast bar over HSUI's."); + + ImGuiHelper.Tab(); ImGuiHelper.Tab(); + changed |= ImGui.Checkbox("Hotbars", ref HotbarsClipRectsEnabled); + ImGuiHelper.SetTooltip("When enabled, active hotbar will not be covered by HSUI Nameplates.\nNote that the way this is calculated is not perfect and it might not work well for hotbars that have empty slots."); + + ImGuiHelper.Tab(); ImGuiHelper.Tab(); + changed |= ImGui.Checkbox("NPC Chat Bubbles", ref ChatBubblesNPCClipRectsEnabled); + + ImGuiHelper.Tab(); ImGuiHelper.Tab(); + changed |= ImGui.Checkbox("Player Chat Bubbles", ref ChatBubblesPlayersClipRectsEnabled); + } + + // third party + ImGui.NewLine(); + ImGuiHelper.NewLineAndTab(); + changed |= ImGui.Checkbox("Enable clipping for other plugins", ref ThirdPartyClipRectsEnabled); + ImGuiHelper.SetTooltip("When enabled, other plugins' windows can also be clipped so HSUI elements don't cover them.\nPlease note that this requires the developer of each third party plugin to implement the feature."); + + // text + ImGui.NewLine(); + ImGuiHelper.NewLineAndTab(); + ImGui.SameLine(); + + switch (Mode) + { + case WindowClippingMode.Full: + ImGui.Text("HSUI will attempt to not cover game windows in this mode by clipping around them."); + break; + + case WindowClippingMode.Hide: + ImGui.Text("HSUI will attempt to not cover game windows in this mode by not drawing an element if its touching a game window."); + break; + + case WindowClippingMode.Performance: + ImGui.Text("Window Clipping functionality will be reduced in favor of performance.\nOnly one game window will be clipped at a time. This might yield unexpected / ugly results.\n\nNote: This mode won't work well with Nameplates."); + break; + } + + ImGuiHelper.NewLineAndTab(); + ImGui.Text("If you're experiencing random crashes or bad performance, we recommend you try a different mode\nor disable Window Clipping altogether"); + + return false; + } + } +} diff --git a/Interface/HudElement.cs b/Interface/HudElement.cs new file mode 100644 index 0000000..53e48af --- /dev/null +++ b/Interface/HudElement.cs @@ -0,0 +1,120 @@ +using HSUI.Config; +using System.Numerics; +using Dalamud.Game.ClientState.Objects.Types; +using System; +using System.Collections.Generic; +using HSUI.Enums; + +namespace HSUI.Interface +{ + public abstract class HudElement : IDisposable + { + protected MovablePluginConfigObject _config; + public MovablePluginConfigObject GetConfig() { return _config; } + + public string ID => _config.ID; + + private Dictionary> _drawActions = new Dictionary>(); + + public HudElement(MovablePluginConfigObject config) + { + _config = config; + } + + public void PrepareForDraw(Vector2 origin) + { + _drawActions.Clear(); + CreateDrawActions(origin); + } + + public virtual void Draw(Vector2 origin) + { + // iterate like this so it goes in order + StrataLevel[] levels = (StrataLevel[])Enum.GetValues(typeof(StrataLevel)); + foreach (StrataLevel key in levels) + { + _drawActions.TryGetValue(key, out List? drawActions); + if (drawActions == null) { continue; } + + foreach (Action drawAction in _drawActions[key]) + { + drawAction(); + } + } + } + + protected void AddDrawAction(StrataLevel strataLevel, Action drawAction) + { + _drawActions.TryGetValue(strataLevel, out List? drawActions); + + if (drawActions == null) + { + drawActions = new List(); + _drawActions.Add(strataLevel, drawActions); + } + + drawActions.Add(drawAction); + } + + protected void AddDrawActions(List<(StrataLevel, Action)> drawActions) + { + foreach ((StrataLevel strataLevel, Action drawAction) in drawActions) + { + AddDrawAction(strataLevel, drawAction); + } + } + + protected abstract void CreateDrawActions(Vector2 origin); + + ~HudElement() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + InternalDispose(); + } + + protected virtual void InternalDispose() + { + // override + } + } + + public interface IHudElementWithActor + { + public IGameObject? Actor { get; set; } + } + + public interface IHudElementWithAnchorableParent + { + public AnchorablePluginConfigObject? ParentConfig { get; set; } + } + + public interface IHudElementWithMouseOver + { + public void StopMouseover(); + } + + public interface IHudElementWithPreview + { + public void StopPreview(); + } + + public interface IHudElementWithVisibilityConfig + { + public VisibilityConfig? VisibilityConfig { get; } + } +} \ No newline at end of file diff --git a/Interface/HudHelper.cs b/Interface/HudHelper.cs new file mode 100644 index 0000000..16b69fe --- /dev/null +++ b/Interface/HudHelper.cs @@ -0,0 +1,506 @@ +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface.EnemyList; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.Party; +using HSUI.Interface.Jobs; +using HSUI.Interface.Nameplates; +using HSUI.Interface.StatusEffects; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System; +using System.Collections.Generic; +using System.Numerics; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; + +namespace HSUI.Interface +{ + public class HudHelper : IDisposable + { + private HUDOptionsConfig Config => ConfigurationManager.Instance.GetConfigObject(); + + private bool _firstUpdate = true; + + private Vector2 _castBarPos = Vector2.Zero; + private bool _hidingCastBar = false; + private Vector2 _pullTimerPos = Vector2.Zero; + private bool _hidingPullTimer = false; + + public HudHelper() + { + Config.ValueChangeEvent += ConfigValueChanged; + } + + ~HudHelper() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Config.ValueChangeEvent -= ConfigValueChanged; + + // Only restore defaults when already on framework thread. Skip RunOnFrameworkThread + // during unload—it can deadlock. Restore is best-effort; game state may be torn down. + if (Plugin.Framework.IsInFrameworkUpdateThread && Plugin.ObjectTable.LocalPlayer != null) + { + try + { + SetGameHudElementsHidden(Array.Empty(), true); + UpdateDefaultCastBar(true); + UpdateDefaultPulltimer(true); + UpdateDefaultNameplates(true); + UpdateJobGauges(true); + } + catch (Exception ex) + { + Plugin.Logger.Error($"Exception during HudHelper.Dispose restore: {ex.Message}"); + } + } + } + + public void Update() + { + try + { + if (_firstUpdate) + { + _firstUpdate = false; + UpdateDefaultCastBar(); + UpdateDefaultPulltimer(); + } + + UpdateJobGauges(); + UpdateDefaultHudElementsHidden(); + UpdateDefaultNameplates(); + } + catch (Exception ex) + { + var now = DateTime.UtcNow; + if ((now - _lastHudElementsErrorLog).TotalSeconds >= HudElementsErrorLogIntervalSeconds) + { + _lastHudElementsErrorLog = now; + Plugin.Logger.Warning($"[HSUI] HudHelper.Update: {ex.Message}"); + } + } + } + + public bool IsElementHidden(HudElement element) + { + IHudElementWithVisibilityConfig? e = element as IHudElementWithVisibilityConfig; + if (e == null || e.VisibilityConfig == null) { return false; } + + return !e.VisibilityConfig.IsElementVisible(element); + } + + private void ConfigValueChanged(object sender, OnChangeBaseArgs e) + { + switch (e.PropertyName) + { + case "HideDefaultHudWhenReplaced": + UpdateDefaultHudElementsHidden(); + break; + case "HideDefaultCastbar": + UpdateDefaultCastBar(); + break; + case "HideDefaultPulltimer": + UpdateDefaultPulltimer(); + break; + } + } + + private static DateTime _lastHudElementsErrorLog = DateTime.MinValue; + private const double HudElementsErrorLogIntervalSeconds = 10.0; + + private void UpdateDefaultHudElementsHidden() + { + try + { + UpdateDefaultHudElementsHiddenCore(); + } + catch (Exception ex) + { + var now = DateTime.UtcNow; + if ((now - _lastHudElementsErrorLog).TotalSeconds >= HudElementsErrorLogIntervalSeconds) + { + _lastHudElementsErrorLog = now; + Plugin.Logger.Warning($"[HSUI] HudHelper: skipped HUD hide update (resolver not ready): {ex.Message}"); + } + } + } + + private void UpdateDefaultHudElementsHiddenCore() + { + if (Plugin.Condition.Any( + ConditionFlag.OccupiedInEvent, + ConditionFlag.OccupiedInQuestEvent, + ConditionFlag.OccupiedInCutSceneEvent, + ConditionFlag.OccupiedSummoningBell, + ConditionFlag.Occupied, + ConditionFlag.Occupied30, + ConditionFlag.Occupied33, + ConditionFlag.Occupied38, + ConditionFlag.Occupied39, + ConditionFlag.WatchingCutscene, + ConditionFlag.WatchingCutscene78, + ConditionFlag.CreatingCharacter, + ConditionFlag.BetweenAreas, + ConditionFlag.BetweenAreas51, + ConditionFlag.BoundByDuty95, + ConditionFlag.ChocoboRacing, + ConditionFlag.PlayingLordOfVerminion) + || Plugin.ClientState.IsPvP) + { + return; + } + + if (!Config.HideDefaultHudWhenReplaced) + { + SetGameHudElementsHidden(Array.Empty(), false); + return; + } + + var hashesToHide = new List(); + void AddHashes(params string[] addonNames) + { + foreach (var name in addonNames) + { + var h = HudLayoutHashHelper.GetHash(name); + if (h != 0) + hashesToHide.Add(h); + } + } + + var hotbarsConfig = ConfigurationManager.Instance?.GetConfigObject(); + if (hotbarsConfig?.Enabled == true) + { + AddHashes("_ActionBar", "_ActionBar01", "_ActionBar02", "_ActionBar03", "_ActionBar04", + "_ActionBar05", "_ActionBar06", "_ActionBar07", "_ActionBar08", "_ActionBar09", "_ActionCross"); + } + + var playerUnitFrame = ConfigurationManager.Instance?.GetConfigObject(); + if (playerUnitFrame?.Enabled == true) + AddHashes("_ParameterWidget"); + + var targetUnitFrame = ConfigurationManager.Instance?.GetConfigObject(); + if (targetUnitFrame?.Enabled == true) + AddHashes("_TargetInfo", "_TargetInfoMainTarget", "_TargetInfoCastBar", "_TargetInfoBuffDebuff"); + + var focusUnitFrame = ConfigurationManager.Instance?.GetConfigObject(); + if (focusUnitFrame?.Enabled == true) + AddHashes("_FocusTargetInfo"); + + var playerCastbar = ConfigurationManager.Instance?.GetConfigObject(); + if (playerCastbar?.Enabled == true) + AddHashes("_CastBar"); + + var expBar = ConfigurationManager.Instance?.GetConfigObject(); + if (expBar?.Enabled == true) + AddHashes("_Exp"); + + var limitBreak = ConfigurationManager.Instance?.GetConfigObject(); + if (limitBreak?.Enabled == true) + AddHashes("_LimitBreak"); + + var playerBuffs = ConfigurationManager.Instance?.GetConfigObject(); + var playerDebuffs = ConfigurationManager.Instance?.GetConfigObject(); + if (playerBuffs?.Enabled == true || playerDebuffs?.Enabled == true) + AddHashes("_Status", "_StatusCustom0", "_StatusCustom1", "_StatusCustom2", "_StatusCustom3"); + + var partyFrames = ConfigurationManager.Instance?.GetConfigObject(); + if (partyFrames?.Enabled == true) + AddHashes("_PartyList"); + + var enemyList = ConfigurationManager.Instance?.GetConfigObject(); + if (enemyList?.Enabled == true) + AddHashes("_EnemyList"); + + var nameplatesConfig = ConfigurationManager.Instance?.GetConfigObject(); + if (nameplatesConfig?.Enabled == true) + AddHashes("NamePlate"); + + if (IsCurrentJobHudEnabled()) + { + foreach (var name in GetCurrentJobGaugeAddonNames()) + AddHashes(name); + } + + SetGameHudElementsHidden(hashesToHide.ToArray(), false); + } + + /// Visibility (ByteValue2) of game HUD elements before we hid them. Restored on disable/unload. + private readonly Dictionary _gameHudVisibilityBeforeHide = new(); + + private unsafe void SetGameHudElementsHidden(uint[] hashesToHide, bool forceRestore) + { + AddonConfig* config = AddonConfig.Instance(); + if (config == null || !config->IsLoaded || config->ActiveDataSet == null) + return; + + var hashSet = new HashSet(hashesToHide); + Span entries = config->ActiveDataSet->HudLayoutConfigEntries; + bool hasChanges = false; + + if (forceRestore || hashesToHide.Length == 0) + { + foreach (ref var entry in entries) + { + if (!_gameHudVisibilityBeforeHide.TryGetValue(entry.AddonNameHash, out byte saved)) + continue; + if (entry.ByteValue2 == saved) + continue; + entry.ByteValue2 = saved; + hasChanges = true; + } + _gameHudVisibilityBeforeHide.Clear(); + } + else + { + foreach (ref var entry in entries) + { + if (!hashSet.Contains(entry.AddonNameHash)) + continue; + if (!_gameHudVisibilityBeforeHide.ContainsKey(entry.AddonNameHash)) + _gameHudVisibilityBeforeHide[entry.AddonNameHash] = entry.ByteValue2; + if (entry.ByteValue2 == 0x0) + continue; + entry.ByteValue2 = 0x0; + hasChanges = true; + } + } + + if (hasChanges) + { + config->SaveFile(true); + config->ApplyHudLayout(); + } + } + + private unsafe void UpdateDefaultCastBar(bool forceVisible = false) + { + if (Config.HideDefaultCastbar && !_hidingCastBar) + { + Plugin.AddonLifecycle.RegisterListener(AddonEvent.PreDraw, "_CastBar", (addonEvent, args) => + { + AtkUnitBase* addon = (AtkUnitBase*)args.Addon.Address; + + if (!_hidingCastBar) + { + _castBarPos = new Vector2(addon->RootNode->GetXFloat(), addon->RootNode->GetYFloat()); + } + + addon->RootNode->SetPositionFloat(-9999.0f, -9999.0f); + }); + + _hidingCastBar = true; + } + else if ((forceVisible || !Config.HideDefaultCastbar) && _hidingCastBar) + { + Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, "_CastBar"); + + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_CastBar", 1).Address; + if (addon != null) + { + addon->RootNode->SetPositionFloat(_castBarPos.X, _castBarPos.Y); + } + + _hidingCastBar = false; + } + + return; + } + + private unsafe void UpdateDefaultPulltimer(bool forceVisible = false) + { + if (Config.HideDefaultPulltimer && !_hidingPullTimer) + { + Plugin.AddonLifecycle.RegisterListener(AddonEvent.PreDraw, "ScreenInfo_CountDown", (addonEvent, args) => + { + AtkUnitBase* addon = (AtkUnitBase*)args.Addon.Address; + + if (!_hidingPullTimer) + { + _pullTimerPos = new Vector2(addon->RootNode->GetXFloat(), addon->RootNode->GetYFloat()); + } + + addon->RootNode->SetPositionFloat(-9999.0f, -9999.0f); + }); + + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("ScreenInfo_CountDown", 1).Address; + if (addon != null) + { + _pullTimerPos = new Vector2(addon->RootNode->GetXFloat(), addon->RootNode->GetYFloat()); + addon->RootNode->SetPositionFloat(-9999, -9999); + } + + _hidingPullTimer = true; + } + else if ((forceVisible || !Config.HideDefaultPulltimer) && _hidingPullTimer) + { + Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, "ScreenInfo_CountDown"); + + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("ScreenInfo_CountDown", 1).Address; + if (addon != null) + { + addon->RootNode->SetPositionFloat(_pullTimerPos.X, _pullTimerPos.Y); + } + + _hidingPullTimer = false; + } + + return; + } + + private static readonly Dictionary _jobIdToConfigType = new() + { + [JobIDs.PLD] = typeof(PaladinConfig), [JobIDs.WAR] = typeof(WarriorConfig), + [JobIDs.DRK] = typeof(DarkKnightConfig), [JobIDs.GNB] = typeof(GunbreakerConfig), + [JobIDs.WHM] = typeof(WhiteMageConfig), [JobIDs.SCH] = typeof(ScholarConfig), + [JobIDs.AST] = typeof(AstrologianConfig), [JobIDs.SGE] = typeof(SageConfig), + [JobIDs.MNK] = typeof(MonkConfig), [JobIDs.DRG] = typeof(DragoonConfig), + [JobIDs.NIN] = typeof(NinjaConfig), [JobIDs.SAM] = typeof(SamuraiConfig), + [JobIDs.RPR] = typeof(ReaperConfig), [JobIDs.VPR] = typeof(ViperConfig), + [JobIDs.BRD] = typeof(BardConfig), [JobIDs.MCH] = typeof(MachinistConfig), + [JobIDs.DNC] = typeof(DancerConfig), [JobIDs.BLM] = typeof(BlackMageConfig), + [JobIDs.SMN] = typeof(SummonerConfig), [JobIDs.RDM] = typeof(RedMageConfig), + [JobIDs.BLU] = typeof(BlueMageConfig), [JobIDs.PCT] = typeof(PictomancerConfig), + }; + + private static bool IsCurrentJobHudEnabled() + { + var player = Plugin.ObjectTable.LocalPlayer; + if (player == null) return false; + if (!_jobIdToConfigType.TryGetValue(player.ClassJob.RowId, out var configType)) + return false; + var config = ConfigurationManager.Instance?.GetConfigObjectForType(configType) as JobConfig; + return config?.Enabled ?? false; + } + + private static IEnumerable GetCurrentJobGaugeAddonNames() + { + var player = Plugin.ObjectTable.LocalPlayer; + if (player == null) yield break; + if (!JobsHelper.JobNames.TryGetValue(player.ClassJob.RowId, out var jobName)) + yield break; + for (int i = 0; i < 4; i++) + { + string addonName = $"JobHud{jobName}{i}"; + if (_specialCases.TryGetValue(addonName, out var name) && name != null) + addonName = name; + yield return addonName; + } + } + + private bool _hidingNameplates = false; + private bool _nameplateVisibilityBeforeHide = true; + + private unsafe void UpdateDefaultNameplates(bool forceVisible = false) + { + var nameplatesConfig = ConfigurationManager.Instance?.GetConfigObject(); + bool shouldHide = !forceVisible && Config.HideDefaultHudWhenReplaced && (nameplatesConfig?.Enabled ?? false); + + var addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("NamePlate", 1).Address; + if (addon == null) return; + + if (shouldHide) + { + if (!_hidingNameplates) + { + _nameplateVisibilityBeforeHide = addon->IsVisible; + _hidingNameplates = true; + } + addon->IsVisible = false; + } + else + { + if (_hidingNameplates) + { + addon->IsVisible = _nameplateVisibilityBeforeHide; + _hidingNameplates = false; + } + } + } + + private static Dictionary _specialCases = new() + { + ["JobHudPCT0"] = "JobHudRPM0", + ["JobHudPCT1"] = "JobHudRPM1", + + ["JobHudNIN1"] = "JobHudNIN1v70", + + ["JobHudVPR0"] = "JobHudRDB0", + ["JobHudVPR1"] = "JobHudRDB1" + }; + + private unsafe void UpdateJobGauges(bool forceVisible = false) + { + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + if (player == null) { return; } + + string jobName = JobsHelper.JobNames[player.ClassJob.RowId]; + int i = 0; + bool stop = false; + + do + { + string addonName = $"JobHud{jobName}{i}"; + if (_specialCases.TryGetValue(addonName, out string? name) && name != null) + { + addonName = name; + } + + AtkUnitBase* addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName(addonName, 1).Address; + if (addon == null) + { + stop = true; + } + else + { + addon->IsVisible = forceVisible || !Config.HideDefaultJobGauges; + } + + i++; + } while (!stop); + } + } + + internal class StrataLevelComparer : IComparer where TKey : PluginConfigObject + { + public int Compare(TKey? a, TKey? b) + { + MovablePluginConfigObject? configA = a is MovablePluginConfigObject ? a as MovablePluginConfigObject : null; + MovablePluginConfigObject? configB = b is MovablePluginConfigObject ? b as MovablePluginConfigObject : null; + + if (configA == null && configB == null) { return 0; } + if (configA == null && configB != null) { return -1; } + if (configA != null && configB == null) { return 1; } + + if (configA!.StrataLevel == configB!.StrataLevel) + { + return configA.ID.CompareTo(configB.ID); + } + + if (configA.StrataLevel < configB.StrataLevel) + { + return -1; + } + + return 1; + } + } +} \ No newline at end of file diff --git a/Interface/HudManager.cs b/Interface/HudManager.cs new file mode 100644 index 0000000..d029c3d --- /dev/null +++ b/Interface/HudManager.cs @@ -0,0 +1,786 @@ +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface.EnemyList; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.Jobs; +using HSUI.Interface.Nameplates; +using HSUI.Interface.Party; +using HSUI.Interface.PartyCooldowns; +using HSUI.Interface.StatusEffects; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface +{ + public class HudManager : IDisposable + { + private GridConfig? _gridConfig; + private HUDOptionsConfig? _hudOptions; + private DraggableHudElement? _selectedElement = null; + + private SortedList _hudElements = null!; + private List _hudElementsUsingPlayer = null!; + private List _hudElementsUsingTarget = null!; + private List _hudElementsUsingTargetOfTarget = null!; + private List _hudElementsUsingFocusTarget = null!; + private List _hudElementsWithPreview = null!; + + private UnitFrameHud _playerUnitFrameHud = null!; + private UnitFrameHud _targetUnitFrameHud = null!; + private UnitFrameHud _totUnitFrameHud = null!; + private UnitFrameHud _focusTargetUnitFrameHud = null!; + + private PlayerCastbarHud _playerCastbarHud = null!; + private CustomEffectsListHud _customEffectsHud = null!; + private PrimaryResourceHud _playerManaBarHud = null!; + private JobHud? _jobHud = null; + + private Dictionary _jobsMap = null!; + private Dictionary _unsupportedJobsMap = null!; + private List _jobTypes = null!; + + private NameplatesHud _nameplatesHud = null!; + + private double _occupiedInQuestStartTime = -1; + + private HudHelper _hudHelper = new HudHelper(); + + public HudManager() + { + CreateJobsMap(); + + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + ConfigurationManager.Instance.LockEvent += OnHUDLockChanged; + ConfigurationManager.Instance.ConfigClosedEvent += OnConfingWindowClosed; + ConfigurationManager.Instance.StrataLevelsChangedEvent += OnStrataLevelsChanged; + ConfigurationManager.Instance.GlobalVisibilityEvent += OnGlobalVisibilityChanged; + + CreateHudElements(); + } + + ~HudManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _hudHelper.Dispose(); + + foreach (var element in _hudElements.Values) + { + try { element.Dispose(); } + catch (Exception ex) { Plugin.Logger.Error($"Error disposing HUD element {element.ID}: {ex.Message}"); } + } + try { _jobHud?.Dispose(); } + catch (Exception ex) { Plugin.Logger.Error($"Error disposing JobHud: {ex.Message}"); } + + _hudElements.Clear(); + _hudElementsUsingPlayer.Clear(); + _hudElementsUsingTarget.Clear(); + _hudElementsUsingTargetOfTarget.Clear(); + _hudElementsUsingFocusTarget.Clear(); + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + ConfigurationManager.Instance.LockEvent -= OnHUDLockChanged; + ConfigurationManager.Instance.ConfigClosedEvent -= OnConfingWindowClosed; + ConfigurationManager.Instance.StrataLevelsChangedEvent -= OnStrataLevelsChanged; + ConfigurationManager.Instance.GlobalVisibilityEvent -= OnGlobalVisibilityChanged; + } + + private void OnConfigReset(ConfigurationManager sender) + { + CreateHudElements(); + _jobHud = null; + } + + private void OnHUDLockChanged(ConfigurationManager sender) + { + var draggingEnabled = !sender.LockHUD; + + foreach (var element in _hudElements.Values) + { + element.DraggingEnabled = draggingEnabled; + element.Selected = false; + } + + if (_jobHud != null) + { + _jobHud.DraggingEnabled = draggingEnabled; + } + + _selectedElement = null; + } + + private void OnConfingWindowClosed(ConfigurationManager sender) + { + if (_hudOptions == null || !_hudOptions.AutomaticPreviewDisabling) + { + return; + } + + foreach (IHudElementWithPreview element in _hudElementsWithPreview) + { + element.StopPreview(); + } + + _nameplatesHud.StopPreview(); + } + + private void OnStrataLevelsChanged(ConfigurationManager sender, PluginConfigObject config) + { + SortedList tmp = new SortedList(new StrataLevelComparer()); + + foreach (DraggableHudElement element in _hudElements.Values) + { + tmp.Add(element.GetConfig(), element); + } + + _hudElements = tmp; + } + + private void OnGlobalVisibilityChanged(ConfigurationManager sender, VisibilityConfig config) + { + foreach (DraggableHudElement element in _hudElements.Values) + { + if (element is IHudElementWithVisibilityConfig e) + { + e.VisibilityConfig?.CopyFrom(config); + } + } + + foreach (Type jobType in _jobTypes) + { + JobConfig jobConfig = (JobConfig)ConfigurationManager.Instance.GetConfigObjectForType(jobType); + jobConfig.VisibilityConfig.CopyFrom(config); + } + } + + private void OnDraggableElementSelected(DraggableHudElement sender) + { + foreach (var element in _hudElements.Values) + { + element.Selected = element == sender; + } + + if (_jobHud != null) + { + _jobHud.Selected = _jobHud == sender; + } + + _selectedElement = sender; + } + + private void CreateHudElements() + { + _gridConfig = ConfigurationManager.Instance.GetConfigObject(); + _hudOptions = ConfigurationManager.Instance.GetConfigObject(); + + _hudElements = new SortedList(new StrataLevelComparer()); + _hudElementsUsingPlayer = new List(); + _hudElementsUsingTarget = new List(); + _hudElementsUsingTargetOfTarget = new List(); + _hudElementsUsingFocusTarget = new List(); + _hudElementsWithPreview = new List(); + + _nameplatesHud = new NameplatesHud(ConfigurationManager.Instance.GetConfigObject()); + + CreateUnitFrames(); + CreateManaBars(); + CreateCastbars(); + CreateStatusEffectsLists(); + CreateMiscElements(); + + foreach (var element in _hudElements.Values) + { + element.SelectEvent += OnDraggableElementSelected; + } + } + + private void CreateUnitFrames() + { + var playerUnitFrameConfig = ConfigurationManager.Instance.GetConfigObject(); + _playerUnitFrameHud = new PlayerUnitFrameHud(playerUnitFrameConfig, "Player"); + _hudElements.Add(playerUnitFrameConfig, _playerUnitFrameHud); + _hudElementsUsingPlayer.Add(_playerUnitFrameHud); + _hudElementsWithPreview.Add(_playerUnitFrameHud); + + var targetUnitFrameConfig = ConfigurationManager.Instance.GetConfigObject(); + _targetUnitFrameHud = new UnitFrameHud(targetUnitFrameConfig, "Target"); + _hudElements.Add(targetUnitFrameConfig, _targetUnitFrameHud); + _hudElementsUsingTarget.Add(_targetUnitFrameHud); + _hudElementsWithPreview.Add(_targetUnitFrameHud); + + var targetOfTargetUnitFrameConfig = ConfigurationManager.Instance.GetConfigObject(); + _totUnitFrameHud = new UnitFrameHud(targetOfTargetUnitFrameConfig, "Target of Target"); + _hudElements.Add(targetOfTargetUnitFrameConfig, _totUnitFrameHud); + _hudElementsUsingTargetOfTarget.Add(_totUnitFrameHud); + _hudElementsWithPreview.Add(_totUnitFrameHud); + + var focusTargetUnitFrameConfig = ConfigurationManager.Instance.GetConfigObject(); + _focusTargetUnitFrameHud = new UnitFrameHud(focusTargetUnitFrameConfig, "Focus Target"); + _hudElements.Add(focusTargetUnitFrameConfig, _focusTargetUnitFrameHud); + _hudElementsUsingFocusTarget.Add(_focusTargetUnitFrameHud); + _hudElementsWithPreview.Add(_focusTargetUnitFrameHud); + + var partyFramesConfig = ConfigurationManager.Instance.GetConfigObject(); + var partyFramesHud = new PartyFramesHud(partyFramesConfig, "Party Frames"); + _hudElements.Add(partyFramesConfig, partyFramesHud); + _hudElementsWithPreview.Add(partyFramesHud); + + var enemyListConfig = ConfigurationManager.Instance.GetConfigObject(); + var enemyListHud = new EnemyListHud(enemyListConfig, "Enemy List"); + _hudElements.Add(enemyListConfig, enemyListHud); + _hudElementsWithPreview.Add(enemyListHud); + } + + private void CreateManaBars() + { + var playerManaBarConfig = ConfigurationManager.Instance.GetConfigObject(); + _playerManaBarHud = new PrimaryResourceHud(playerManaBarConfig, "Player Mana Bar"); + _playerManaBarHud.ParentConfig = _playerUnitFrameHud.Config; + _hudElements.Add(playerManaBarConfig, _playerManaBarHud); + _hudElementsUsingPlayer.Add(_playerManaBarHud); + + var targetManaBarConfig = ConfigurationManager.Instance.GetConfigObject(); + var targetManaBarHud = new PrimaryResourceHud(targetManaBarConfig, "Target Mana Bar"); + targetManaBarHud.ParentConfig = _targetUnitFrameHud.Config; + _hudElements.Add(targetManaBarConfig, targetManaBarHud); + _hudElementsUsingTarget.Add(targetManaBarHud); + + var totManaBarConfig = ConfigurationManager.Instance.GetConfigObject(); + var totManaBarHud = new PrimaryResourceHud(totManaBarConfig, "ToT Mana Bar"); + totManaBarHud.ParentConfig = _totUnitFrameHud.Config; + _hudElements.Add(totManaBarConfig, totManaBarHud); + _hudElementsUsingTargetOfTarget.Add(totManaBarHud); + + var focusManaBarConfig = ConfigurationManager.Instance.GetConfigObject(); + var focusManaBarHud = new PrimaryResourceHud(focusManaBarConfig, "Focus Mana Bar"); + focusManaBarHud.ParentConfig = _focusTargetUnitFrameHud.Config; + _hudElements.Add(focusManaBarConfig, focusManaBarHud); + _hudElementsUsingFocusTarget.Add(focusManaBarHud); + } + + private void CreateCastbars() + { + var playerCastbarConfig = ConfigurationManager.Instance.GetConfigObject(); + _playerCastbarHud = new PlayerCastbarHud(playerCastbarConfig, "Player Castbar"); + _playerCastbarHud.ParentConfig = _playerUnitFrameHud.Config; + _hudElements.Add(playerCastbarConfig, _playerCastbarHud); + _hudElementsUsingPlayer.Add(_playerCastbarHud); + _hudElementsWithPreview.Add(_playerCastbarHud); + + var targetCastbarConfig = ConfigurationManager.Instance.GetConfigObject(); + var targetCastbar = new TargetCastbarHud(targetCastbarConfig, "Target Castbar"); + targetCastbar.ParentConfig = _targetUnitFrameHud.Config; + _hudElements.Add(targetCastbarConfig, targetCastbar); + _hudElementsUsingTarget.Add(targetCastbar); + _hudElementsWithPreview.Add(targetCastbar); + + var targetOfTargetCastbarConfig = ConfigurationManager.Instance.GetConfigObject(); + var targetOfTargetCastbar = new TargetOfTargetCastbarHud(targetOfTargetCastbarConfig, "ToT Castbar"); + targetOfTargetCastbar.ParentConfig = _totUnitFrameHud.Config; + _hudElements.Add(targetOfTargetCastbarConfig, targetOfTargetCastbar); + _hudElementsUsingTargetOfTarget.Add(targetOfTargetCastbar); + _hudElementsWithPreview.Add(targetOfTargetCastbar); + + var focusTargetCastbarConfig = ConfigurationManager.Instance.GetConfigObject(); + var focusTargetCastbar = new FocusTargetCastbarHud(focusTargetCastbarConfig, "Focus Castbar"); + focusTargetCastbar.ParentConfig = _focusTargetUnitFrameHud.Config; + _hudElements.Add(focusTargetCastbarConfig, focusTargetCastbar); + _hudElementsUsingFocusTarget.Add(focusTargetCastbar); + _hudElementsWithPreview.Add(focusTargetCastbar); + } + + private void CreateStatusEffectsLists() + { + var playerBuffsConfig = ConfigurationManager.Instance.GetConfigObject(); + var playerBuffs = new StatusEffectsListHud(playerBuffsConfig, "Buffs"); + playerBuffs.ParentConfig = _playerUnitFrameHud.Config; + _hudElements.Add(playerBuffsConfig, playerBuffs); + _hudElementsUsingPlayer.Add(playerBuffs); + _hudElementsWithPreview.Add(playerBuffs); + + var playerDebuffsConfig = ConfigurationManager.Instance.GetConfigObject(); + var playerDebuffs = new StatusEffectsListHud(playerDebuffsConfig, "Debuffs"); + playerDebuffs.ParentConfig = _playerUnitFrameHud.Config; + _hudElements.Add(playerDebuffsConfig, playerDebuffs); + _hudElementsUsingPlayer.Add(playerDebuffs); + _hudElementsWithPreview.Add(playerDebuffs); + + var targetBuffsConfig = ConfigurationManager.Instance.GetConfigObject(); + var targetBuffs = new StatusEffectsListHud(targetBuffsConfig, "Target Buffs"); + targetBuffs.ParentConfig = _targetUnitFrameHud.Config; + _hudElements.Add(targetBuffsConfig, targetBuffs); + _hudElementsUsingTarget.Add(targetBuffs); + _hudElementsWithPreview.Add(targetBuffs); + + var targetDebuffsConfig = ConfigurationManager.Instance.GetConfigObject(); + var targetDebuffs = new StatusEffectsListHud(targetDebuffsConfig, "Target Debuffs"); + targetDebuffs.ParentConfig = _targetUnitFrameHud.Config; + _hudElements.Add(targetDebuffsConfig, targetDebuffs); + _hudElementsUsingTarget.Add(targetDebuffs); + _hudElementsWithPreview.Add(targetDebuffs); + + var focusTargetBuffsConfig = ConfigurationManager.Instance.GetConfigObject(); + var focusTargetBuffs = new StatusEffectsListHud(focusTargetBuffsConfig, "focusTarget Buffs"); + focusTargetBuffs.ParentConfig = _focusTargetUnitFrameHud.Config; + _hudElements.Add(focusTargetBuffsConfig, focusTargetBuffs); + _hudElementsUsingFocusTarget.Add(focusTargetBuffs); + _hudElementsWithPreview.Add(focusTargetBuffs); + + var focusTargetDebuffsConfig = ConfigurationManager.Instance.GetConfigObject(); + var focusTargetDebuffs = new StatusEffectsListHud(focusTargetDebuffsConfig, "focusTarget Debuffs"); + focusTargetDebuffs.ParentConfig = _focusTargetUnitFrameHud.Config; + _hudElements.Add(focusTargetDebuffsConfig, focusTargetDebuffs); + _hudElementsUsingFocusTarget.Add(focusTargetDebuffs); + _hudElementsWithPreview.Add(focusTargetDebuffs); + + var custonEffectsConfig = ConfigurationManager.Instance.GetConfigObject(); + _customEffectsHud = new CustomEffectsListHud(custonEffectsConfig, "Custom Effects"); + _hudElements.Add(custonEffectsConfig, _customEffectsHud); + _hudElementsUsingPlayer.Add(_customEffectsHud); + _hudElementsWithPreview.Add(_customEffectsHud); + } + + private void CreateMiscElements() + { + var gcdIndicatorConfig = ConfigurationManager.Instance.GetConfigObject(); + var gcdIndicator = new GCDIndicatorHud(gcdIndicatorConfig, "GCD Indicator"); + _hudElements.Add(gcdIndicatorConfig, gcdIndicator); + _hudElementsUsingPlayer.Add(gcdIndicator); + + var mpTickerConfig = ConfigurationManager.Instance.GetConfigObject(); + var mpTicker = new MPTickerHud(mpTickerConfig, "MP Ticker"); + _hudElements.Add(mpTickerConfig, mpTicker); + _hudElementsUsingPlayer.Add(mpTicker); + + var expBarConfig = ConfigurationManager.Instance.GetConfigObject(); + var expBarHud = new ExperienceBarHud(expBarConfig, "Experience Bar"); + _hudElements.Add(expBarConfig, expBarHud); + _hudElementsUsingPlayer.Add(expBarHud); + + var pullTimerConfig = ConfigurationManager.Instance.GetConfigObject(); + var pullTimerHud = new PullTimerHud(pullTimerConfig, "Pull Timer"); + _hudElements.Add(pullTimerConfig, pullTimerHud); + _hudElementsUsingPlayer.Add(pullTimerHud); + + var limitBreakConfig = ConfigurationManager.Instance.GetConfigObject(); + var limitBreakHud = new LimitBreakHud(limitBreakConfig, "Limit Break"); + _hudElements.Add(limitBreakConfig, limitBreakHud); + + var hotbarsConfig = ConfigurationManager.Instance.GetConfigObject(); + HotbarBarConfig[] hotbarConfigs = new HotbarBarConfig[] + { + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject() + }; + for (int i = 0; i < hotbarConfigs.Length; i++) + { + var barConfig = hotbarConfigs[i]; + var barHud = new ActionBarsHud(barConfig, $"Hotbar {i + 1}"); + _hudElements.Add(barConfig, barHud); + _hudElementsUsingPlayer.Add(barHud); + } + + var partyCooldownsConfig = ConfigurationManager.Instance.GetConfigObject(); + var partyCooldownsHud = new PartyCooldownsHud(partyCooldownsConfig, "Party Cooldowns"); + _hudElements.Add(partyCooldownsConfig, partyCooldownsHud); + _hudElementsWithPreview.Add(partyCooldownsHud); + } + + public void Draw(uint jobId) + { + if (!FontsManager.Instance.DefaultFontBuilt) + { + Plugin.UiBuilder.FontAtlas.BuildFontsAsync(); + } + + try + { + PullTimerHelper.Instance.Update(); + } + catch { } + + TooltipsHelper.Instance.RemoveTooltip(); // remove tooltip from previous frame + + _hudHelper.Update(); + + if (!ShouldBeVisible()) + { + return; + } + + WhosTalkingHelper.Instance?.Update(); + + ClipRectsHelper.Instance.Update(); + + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(0, 0)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + + ImGuiHelpers.ForceNextWindowMainViewport(); + ImGui.SetNextWindowPos(Vector2.Zero); + ImGui.SetNextWindowSize(ImGui.GetMainViewport().Size); + + var begin = ImGui.Begin( + "HSUI_HUD", + ImGuiWindowFlags.NoTitleBar + | ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.AlwaysAutoResize + | ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoInputs + | ImGuiWindowFlags.NoBringToFrontOnFocus + | ImGuiWindowFlags.NoFocusOnAppearing + | ImGuiWindowFlags.NoSavedSettings + ); + + ImGui.PopStyleVar(3); + + if (!begin) + { + ImGui.End(); + return; + } + + UpdateJob(jobId); + AssignActors(); + + var origin = ImGui.GetMainViewport().Size / 2f; + if (_hudOptions != null && _hudOptions.UseGlobalHudShift) + { + origin += _hudOptions.HudOffset; + } + + // show only castbar during quest events + if (ShouldOnlyShowCastbar()) + { + _playerCastbarHud?.PrepareForDraw(origin); + _playerCastbarHud?.Draw(origin); + + ImGui.End(); + return; + } + + // grid + if (_gridConfig is not null && _gridConfig.Enabled) + { + DraggablesHelper.DrawGrid(_gridConfig, _hudOptions, _selectedElement); + } + + // nameplates + if (_nameplatesHud.GetConfig().Enabled) + { + ClipRectsHelper.Instance?.AddNameplatesClipRects(); + + _nameplatesHud.PrepareForDraw(origin); + _nameplatesHud.Draw(origin); + + ClipRectsHelper.Instance?.RemoveNameplatesClipRects(); + } + + // draw elements + lock (_hudElements) + { + DraggablesHelper.DrawElements(origin, _hudHelper, _hudElements.Values, _jobHud, _selectedElement); + } + + // tooltip + TooltipsHelper.Instance.Draw(); + + ImGui.End(); + } + + protected unsafe bool ShouldBeVisible() + { + if (!ConfigurationManager.Instance.ShowHUD || Plugin.ObjectTable.LocalPlayer == null) + { + return false; + } + + bool hudHidden = + Plugin.Condition[ConditionFlag.WatchingCutscene] || + Plugin.Condition[ConditionFlag.WatchingCutscene78] || + Plugin.Condition[ConditionFlag.OccupiedInCutSceneEvent] || + Plugin.Condition[ConditionFlag.CreatingCharacter] || + Plugin.Condition[ConditionFlag.BetweenAreas] || + Plugin.Condition[ConditionFlag.BetweenAreas51] || + Plugin.Condition[ConditionFlag.OccupiedSummoningBell]; + + if (hudHidden) + { + return false; + } + + AtkUnitBase* parameterWidget = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_ParameterWidget", 1).Address; + AtkUnitBase* fadeMiddleWidget = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("FadeMiddle", 1).Address; + + bool paramenterVisible = parameterWidget != null && parameterWidget->IsVisible; + bool fadeMiddleVisible = fadeMiddleWidget != null && fadeMiddleWidget->IsVisible; + + return paramenterVisible && !fadeMiddleVisible; + } + + protected bool ShouldOnlyShowCastbar() + { + // when in quest dialogs and events, hide everything except castbars + // this includes talking to npcs or interacting with quest related stuff + if (Plugin.Condition[ConditionFlag.OccupiedInQuestEvent] || + Plugin.Condition[ConditionFlag.OccupiedInEvent]) + { + // we have to wait a bit to avoid weird flickering when clicking shiny stuff + // we hide HSUI after half a second passed in this state + // interestingly enough, default hotbars seem to do something similar + var time = ImGui.GetTime(); + if (_occupiedInQuestStartTime > 0) + { + if (time - _occupiedInQuestStartTime > 0.5) + { + return true; + } + } + else + { + _occupiedInQuestStartTime = time; + } + } + else + { + _occupiedInQuestStartTime = -1; + } + + return false; + } + + private void UpdateJob(uint newJobId) + { + if (_jobHud != null && _jobHud.Config.JobId == newJobId) + { + return; + } + + JobConfig? config = null; + + // unsupported jobs + if (_unsupportedJobsMap.ContainsKey(newJobId) && _unsupportedJobsMap.TryGetValue(newJobId, out var type)) + { + config = (JobConfig)Activator.CreateInstance(type)!; + _jobHud = new JobHud(config); + } + + // supported jobs + if (_jobsMap.TryGetValue(newJobId, out var types)) + { + config = (JobConfig)ConfigurationManager.Instance.GetConfigObjectForType(types.ConfigType); + _jobHud = (JobHud)Activator.CreateInstance(types.HudType, config, types.DisplayName)!; + _jobHud.SelectEvent += OnDraggableElementSelected; + } + } + + private void AssignActors() + { + // player + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + foreach (var element in _hudElementsUsingPlayer) + { + element.Actor = player; + + if (_jobHud != null) + { + _jobHud.Actor = player; + } + } + + // target + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + foreach (var element in _hudElementsUsingTarget) + { + element.Actor = target; + + if (_customEffectsHud != null) + { + _customEffectsHud.TargetActor = target; + } + } + + // target of target + IGameObject? targetOfTarget = Utils.FindTargetOfTarget(target, player, Plugin.ObjectTable); + foreach (var element in _hudElementsUsingTargetOfTarget) + { + element.Actor = targetOfTarget; + } + + // focus + IGameObject? focusTarget = Plugin.TargetManager.FocusTarget; + foreach (var element in _hudElementsUsingFocusTarget) + { + element.Actor = focusTarget; + } + + // player mana bar + if (_jobHud != null && _playerManaBarHud != null && !_jobHud.Config.UseDefaultPrimaryResourceBar) + { + _playerManaBarHud.ResourceType = PrimaryResourceTypes.None; + } + } + + protected void CreateJobsMap() + { + _jobsMap = new Dictionary() + { + // tanks + [JobIDs.PLD] = new JobHudTypes(typeof(PaladinHud), typeof(PaladinConfig), "Paladin HUD"), + [JobIDs.WAR] = new JobHudTypes(typeof(WarriorHud), typeof(WarriorConfig), "Warrior HUD"), + [JobIDs.DRK] = new JobHudTypes(typeof(DarkKnightHud), typeof(DarkKnightConfig), "Dark Knight HUD"), + [JobIDs.GNB] = new JobHudTypes(typeof(GunbreakerHud), typeof(GunbreakerConfig), "Gunbreaker HUD"), + + // healers + [JobIDs.WHM] = new JobHudTypes(typeof(WhiteMageHud), typeof(WhiteMageConfig), "White Mage HUD"), + [JobIDs.SCH] = new JobHudTypes(typeof(ScholarHud), typeof(ScholarConfig), "Scholar HUD"), + [JobIDs.AST] = new JobHudTypes(typeof(AstrologianHud), typeof(AstrologianConfig), "Astrologian HUD"), + [JobIDs.SGE] = new JobHudTypes(typeof(SageHud), typeof(SageConfig), "Sage HUD"), + + // melee + [JobIDs.MNK] = new JobHudTypes(typeof(MonkHud), typeof(MonkConfig), "Monk HUD"), + [JobIDs.DRG] = new JobHudTypes(typeof(DragoonHud), typeof(DragoonConfig), "Dragoon HUD"), + [JobIDs.NIN] = new JobHudTypes(typeof(NinjaHud), typeof(NinjaConfig), "Ninja HUD"), + [JobIDs.SAM] = new JobHudTypes(typeof(SamuraiHud), typeof(SamuraiConfig), "Samurai HUD"), + [JobIDs.RPR] = new JobHudTypes(typeof(ReaperHud), typeof(ReaperConfig), "Reaper HUD"), + [JobIDs.VPR] = new JobHudTypes(typeof(ViperHud), typeof(ViperConfig), "Viper HUD"), + + // ranged + [JobIDs.BRD] = new JobHudTypes(typeof(BardHud), typeof(BardConfig), "Bard HUD"), + [JobIDs.MCH] = new JobHudTypes(typeof(MachinistHud), typeof(MachinistConfig), "Mechanic HUD"), + [JobIDs.DNC] = new JobHudTypes(typeof(DancerHud), typeof(DancerConfig), "Dancer HUD"), + + // casters + [JobIDs.BLM] = new JobHudTypes(typeof(BlackMageHud), typeof(BlackMageConfig), "Black Mage HUD"), + [JobIDs.SMN] = new JobHudTypes(typeof(SummonerHud), typeof(SummonerConfig), "Summoner HUD"), + [JobIDs.RDM] = new JobHudTypes(typeof(RedMageHud), typeof(RedMageConfig), "Red Mage HUD"), + [JobIDs.BLU] = new JobHudTypes(typeof(BlueMageHud), typeof(BlueMageConfig), "Blue Mage HUD"), + [JobIDs.PCT] = new JobHudTypes(typeof(PictomancerHud), typeof(PictomancerConfig), "Pictomancer HUD") + }; + + _unsupportedJobsMap = new Dictionary() + { + // base jobs + [JobIDs.GLA] = typeof(GladiatorConfig), + [JobIDs.MRD] = typeof(MarauderConfig), + [JobIDs.PGL] = typeof(PugilistConfig), + [JobIDs.LNC] = typeof(LancerConfig), + [JobIDs.ROG] = typeof(RogueConfig), + [JobIDs.ARC] = typeof(ArcherConfig), + [JobIDs.THM] = typeof(ThaumaturgeConfig), + [JobIDs.ACN] = typeof(ArcanistConfig), + [JobIDs.CNJ] = typeof(ConjurerConfig), + + // crafters + [JobIDs.CRP] = typeof(CarpenterConfig), + [JobIDs.BSM] = typeof(BlacksmithConfig), + [JobIDs.ARM] = typeof(ArmorerConfig), + [JobIDs.GSM] = typeof(GoldsmithConfig), + [JobIDs.LTW] = typeof(LeatherworkerConfig), + [JobIDs.WVR] = typeof(WeaverConfig), + [JobIDs.ALC] = typeof(AlchemistConfig), + [JobIDs.CUL] = typeof(CulinarianConfig), + + // gatherers + [JobIDs.MIN] = typeof(MinerConfig), + [JobIDs.BOT] = typeof(BotanistConfig), + [JobIDs.FSH] = typeof(FisherConfig) + }; + + _jobTypes = new List() + { + typeof(PaladinConfig), + typeof(WarriorConfig), + typeof(DarkKnightConfig), + typeof(GunbreakerConfig), + + typeof(WhiteMageConfig), + typeof(ScholarConfig), + typeof(AstrologianConfig), + typeof(SageConfig), + + typeof(MonkConfig), + typeof(DragoonConfig), + typeof(NinjaConfig), + typeof(SamuraiConfig), + typeof(ReaperConfig), + typeof(ViperConfig), + + typeof(BardConfig), + typeof(MachinistConfig), + typeof(DancerConfig), + + typeof(BlackMageConfig), + typeof(SummonerConfig), + typeof(RedMageConfig), + typeof(BlueMageConfig), + typeof(PictomancerConfig) + }; + } + } + + internal struct JobHudTypes + { + public Type HudType; + public Type ConfigType; + public string DisplayName; + + public JobHudTypes(Type hudType, Type configType, string displayName) + { + HudType = hudType; + ConfigType = configType; + DisplayName = displayName; + } + } + + public static class ForcedJob + { + internal static bool Enabled; + internal static uint ForcedJobId; + } + + internal static class HUDConstants + { + internal static int BaseHUDOffsetY = (int)(ImGui.GetMainViewport().Size.Y * 0.3f); + internal static int UnitFramesOffsetX = 160; + internal static int PlayerCastbarY = BaseHUDOffsetY - 13; + internal static int JobHudsBaseY = PlayerCastbarY - 14; + + internal static Vector2 DefaultBigUnitFrameSize = new Vector2(270, 50); + internal static Vector2 DefaultSmallUnitFrameSize = new Vector2(120, 20); + internal static Vector2 DefaultStatusEffectsListSize = new Vector2(292, 82); + + internal static Vector2 DefaultPlayerNameplateBarSize = new Vector2(120, 10); + internal static Vector2 DefaultEnemyNameplateBarSize = new Vector2(220, 26); + } +} diff --git a/Interface/Jobs/AstrologianHud.cs b/Interface/Jobs/AstrologianHud.cs new file mode 100644 index 0000000..ad63d1a --- /dev/null +++ b/Interface/Jobs/AstrologianHud.cs @@ -0,0 +1,273 @@ +using Dalamud.Game.ClientState.JobGauge.Enums; +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class AstrologianHud : JobHud + { + private readonly SpellHelper _spellHelper = new(); + private new AstrologianConfig Config => (AstrologianConfig)_config; + private static PluginConfigColor EmptyColor => GlobalColors.Instance.EmptyColor; + + private static readonly List DotIDs = new() { 1881, 843, 838 }; + private static readonly List DotDuration = new() { 30f, 30f, 18f }; + private const float STAR_MAX_DURATION = 10f; + private const float LIGHTSPEED_MAX_DURATION = 15f; + + public AstrologianHud(JobConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.CardsBar.Enabled) + { + positions.Add(Config.Position + Config.CardsBar.Position); + sizes.Add(Config.CardsBar.Size); + } + + if (Config.DotBar.Enabled) + { + positions.Add(Config.Position + Config.DotBar.Position); + sizes.Add(Config.DotBar.Size); + } + + if (Config.StarBar.Enabled) + { + positions.Add(Config.Position + Config.StarBar.Position); + sizes.Add(Config.StarBar.Size); + } + + if (Config.LightspeedBar.Enabled) + { + positions.Add(Config.Position + Config.LightspeedBar.Position); + sizes.Add(Config.LightspeedBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.CardsBar.Enabled) + { + DrawCardsBar(pos, player); + } + if (Config.DotBar.Enabled) + { + DrawDot(pos, player); + } + + if (Config.LightspeedBar.Enabled) + { + DrawLightspeed(pos, player); + } + + if (Config.StarBar.Enabled) + { + DrawStar(pos, player); + } + } + + private unsafe void DrawCardsBar(Vector2 origin, IPlayerCharacter player) + { + AstrologianCardsBarConfig config = Config.CardsBar; + PluginConfigColor emptyColor = PluginConfigColor.Empty; + + List> chunks = new(); + + uint play1 = ActionManager.Instance()->GetAdjustedActionId(37019); + PluginConfigColor play1Color = play1 == 37023 ? config.TheBalanceColor : (play1 == 37026 ? config.TheSpearColor : emptyColor); + chunks.Add(new(play1Color, 1, null)); + + uint play2 = ActionManager.Instance()->GetAdjustedActionId(37020); + PluginConfigColor play2Color = play2 == 37024 ? config.TheArrowColor : (play2 == 37027 ? config.TheBoleColor : emptyColor); + chunks.Add(new(play2Color, 1, null)); + + uint play3 = ActionManager.Instance()->GetAdjustedActionId(37021); + PluginConfigColor play3Color = play3 == 37025 ? config.TheSpireColor : (play3 == 37028 ? config.TheEwerColor : emptyColor); + chunks.Add(new(play3Color, 1, null)); + + if (player.Level >= 70) + { + uint minorArcana = ActionManager.Instance()->GetAdjustedActionId(37022); + PluginConfigColor minorArcanaColor = minorArcana == 7444 ? config.TheLordOfCrownsColor : (minorArcana == 7445 ? config.TheLadyOfCrownsColor : emptyColor); + chunks.Add(new(minorArcanaColor, 1, null)); + } + + BarHud[] bars = BarUtilities.GetChunkedBars(config, chunks.ToArray(), player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private void DrawDot(Vector2 origin, IPlayerCharacter player) + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + BarHud? bar = BarUtilities.GetDoTBar(Config.DotBar, player, target, DotIDs, DotDuration); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.DotBar.StrataLevel)); + } + } + + private void DrawLightspeed(Vector2 origin, IPlayerCharacter player) + { + float lightspeedDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 841 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (Config.LightspeedBar.HideWhenInactive && lightspeedDuration <= 0) + { + return; + } + + Config.LightspeedBar.Label.SetValue(lightspeedDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.LightspeedBar, lightspeedDuration, LIGHTSPEED_MAX_DURATION, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.LightspeedBar.StrataLevel)); + } + + private void DrawStar(Vector2 origin, IPlayerCharacter player) + { + float starPreCookingBuff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1224 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + float starPostCookingBuff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1248 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (Config.StarBar.HideWhenInactive && starPostCookingBuff <= 0f && starPreCookingBuff <= 0f) + { + return; + } + + float currentStarDuration = starPreCookingBuff > 0 ? STAR_MAX_DURATION - Math.Abs(starPreCookingBuff) : Math.Abs(starPostCookingBuff); + PluginConfigColor currentStarColor = starPreCookingBuff > 0 ? Config.StarBar.StarEarthlyColor : Config.StarBar.StarGiantColor; + + Config.StarBar.Label.SetValue(currentStarDuration); + + // Star Countdown after Star is ready + BarHud bar = BarUtilities.GetProgressBar(Config.StarBar, currentStarDuration, STAR_MAX_DURATION, 0f, player, currentStarColor, Config.StarBar.StarGlowConfig.Enabled && starPostCookingBuff > 0 ? Config.StarBar.StarGlowConfig : null); + AddDrawActions(bar.GetDrawActions(origin, Config.StarBar.StrataLevel)); + } + } + + [Section("Job Specific Bars")] + [SubSection("Healer", 0)] + [SubSection("Astrologian", 1)] + public class AstrologianConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.AST; + + public new static AstrologianConfig DefaultConfig() + { + var config = new AstrologianConfig(); + config.UseDefaultPrimaryResourceBar = true; + + return config; + } + + [NestedConfig("Cards Bar", 40)] + public AstrologianCardsBarConfig CardsBar = new( + new Vector2(0, 0), + new Vector2(254, 20) + ); + + [NestedConfig("Dot Bar", 40)] + public ProgressBarConfig DotBar = new( + new Vector2(-85, -29), + new Vector2(84, 14), + new PluginConfigColor(new Vector4(20f / 255f, 80f / 255f, 168f / 255f, 255f / 100f)) + ); + + [NestedConfig("Star Bar", 45)] + public AstrologianStarBarConfig StarBar = new( + new Vector2(0, -29), + new Vector2(84, 14) + ); + + [NestedConfig("Lightspeed Bar", 50)] + public ProgressBarConfig LightspeedBar = new( + new Vector2(85, -29), + new Vector2(84, 14), + new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 173f / 255f, 100f / 100f)) + ); + } + + [Exportable(false)] + [DisableParentSettings("FillColor", "UsePartialFillColor", "UseChunks", "PartialFillColor", "LabelMode", "HideWhenInactive")] + public class AstrologianCardsBarConfig : ChunkedBarConfig + { + [ColorEdit4("The Balance Color", spacing = true)] + [Order(201)] + public PluginConfigColor TheBalanceColor = PluginConfigColor.FromHex(0xFFBE423F); + + [ColorEdit4("The Arrow Color")] + [Order(201)] + public PluginConfigColor TheArrowColor = PluginConfigColor.FromHex(0xFF628AA7); + + [ColorEdit4("The Spire Color")] + [Order(201)] + public PluginConfigColor TheSpireColor = PluginConfigColor.FromHex(0xFFC8A348); + + [ColorEdit4("The Spear Color")] + [Order(201)] + public PluginConfigColor TheSpearColor = PluginConfigColor.FromHex(0xFF5673DF); + + [ColorEdit4("The Bole Color")] + [Order(201)] + public PluginConfigColor TheBoleColor = PluginConfigColor.FromHex(0xFF9ACB77); + + [ColorEdit4("The Ewer Color")] + [Order(201)] + public PluginConfigColor TheEwerColor = PluginConfigColor.FromHex(0xFF7FBDFF); + + [ColorEdit4("The Lord of Crowns Color")] + [Order(201)] + public PluginConfigColor TheLordOfCrownsColor = PluginConfigColor.FromHex(0xFFCA3640); + + [ColorEdit4("The Lady of Crowns Color")] + [Order(201)] + public PluginConfigColor TheLadyOfCrownsColor = PluginConfigColor.FromHex(0xFF974A97); + + public AstrologianCardsBarConfig(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + } + } + + [Exportable(false)] + [DisableParentSettings("FillColor")] + public class AstrologianStarBarConfig : ProgressBarConfig + { + [ColorEdit4("Earthly" + "##Star")] + [Order(402)] + public PluginConfigColor StarEarthlyColor = new(new Vector4(37f / 255f, 181f / 255f, 177f / 255f, 100f / 100f)); + + [ColorEdit4("Giant" + "##Star")] + [Order(403)] + public PluginConfigColor StarGiantColor = new(new Vector4(198f / 255f, 154f / 255f, 199f / 255f, 100f / 100f)); + + [NestedConfig("Giant Dominance Glow" + "##Star", 404, separator = false, spacing = true)] + public BarGlowConfig StarGlowConfig = new(); + public AstrologianStarBarConfig(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + } + } +} diff --git a/Interface/Jobs/BardHud.cs b/Interface/Jobs/BardHud.cs new file mode 100644 index 0000000..2e50ac8 --- /dev/null +++ b/Interface/Jobs/BardHud.cs @@ -0,0 +1,489 @@ +using Dalamud.Game.ClientState.JobGauge.Enums; +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class BardHud : JobHud + { + private readonly SpellHelper _spellHelper = new(); + private new BardConfig Config => (BardConfig)_config; + private PluginConfigColor EmptyColor => GlobalColors.Instance.EmptyColor; + + public BardHud(BardConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.SongGaugeBar.Enabled) + { + positions.Add(Config.Position + Config.SongGaugeBar.Position); + sizes.Add(Config.SongGaugeBar.Size); + } + + if (Config.SoulVoiceBar.Enabled) + { + positions.Add(Config.Position + Config.SoulVoiceBar.Position); + sizes.Add(Config.SoulVoiceBar.Size); + } + + if (Config.StacksBar.Enabled) + { + positions.Add(Config.Position + Config.StacksBar.Position); + sizes.Add(Config.StacksBar.Size); + } + + if (Config.CausticBiteDoTBar.Enabled) + { + positions.Add(Config.Position + Config.CausticBiteDoTBar.Position); + sizes.Add(Config.CausticBiteDoTBar.Size); + } + + if (Config.StormbiteDoTBar.Enabled) + { + positions.Add(Config.Position + Config.StormbiteDoTBar.Position); + sizes.Add(Config.StormbiteDoTBar.Size); + } + + if (Config.CodaBar.Enabled) + { + positions.Add(Config.Position + Config.CodaBar.Position); + sizes.Add(Config.CodaBar.Size); + } + + return (positions, sizes); + } + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.CausticBiteDoTBar.Enabled) + { + DrawCausticBiteDoTBar(pos, player); + } + + if (Config.StormbiteDoTBar.Enabled) + { + DrawStormbiteDoTBar(pos, player); + } + + HandleCurrentSong(pos, player); + + if (Config.SoulVoiceBar.Enabled) + { + DrawSoulVoiceBar(pos, player); + } + + if (Config.CodaBar.Enabled) + { + DrawCodaBar(pos, player); + } + } + + private void DrawCodaBar(Vector2 origin, IPlayerCharacter player) + { + BRDGauge gauge = Plugin.JobGauges.Get(); + var containsCoda = new[] { gauge.Coda.Contains(Song.Wanderer) ? 1 : 0, gauge.Coda.Contains(Song.Mage) ? 1 : 0, gauge.Coda.Contains(Song.Army) ? 1 : 0 }; + bool hasCoda = containsCoda.Any(o => o == 1); + + if (!Config.CodaBar.HideWhenInactive || hasCoda) + { + var order = Config.CodaBar.CodaOrder; + var colors = new[] { Config.CodaBar.WMColor, Config.CodaBar.MBColor, Config.CodaBar.APColor }; + + var coda = new Tuple[3]; + for (int i = 0; i < 3; i++) + { + coda[i] = new Tuple(colors[order[i]], containsCoda[order[i]], null); + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.CodaBar, coda, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.CodaBar.StrataLevel)); + } + } + } + + private static List CausticBiteDoTIDs = new List { 124, 1200 }; + private static List CausticBiteDoTDurations = new List { 45, 45 }; + + protected void DrawCausticBiteDoTBar(Vector2 origin, IPlayerCharacter player) + { + var target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.CausticBiteDoTBar, player, target, CausticBiteDoTIDs, CausticBiteDoTDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.CausticBiteDoTBar.StrataLevel)); + } + } + + private static List StormbiteDoTIDs = new List { 129, 1201 }; + private static List StormbiteDoTDurations = new List { 45, 45 }; + + protected void DrawStormbiteDoTBar(Vector2 origin, IPlayerCharacter player) + { + var target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.StormbiteDoTBar, player, target, StormbiteDoTIDs, StormbiteDoTDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.StormbiteDoTBar.StrataLevel)); + } + } + + private void HandleCurrentSong(Vector2 origin, IPlayerCharacter player) + { + BRDGauge gauge = Plugin.JobGauges.Get(); + byte songStacks = gauge.Repertoire; + Song song = gauge.Song; + ushort songTimer = gauge.SongTimer; + + switch (song) + { + case Song.Wanderer: + if (Config.StacksBar.Enabled && Config.StacksBar.ShowWMStacks) + { + DrawStacksBar( + origin, + player, + songStacks, + 3, + Config.StacksBar.WMStackColor, + Config.StacksBar.WMGlowConfig.Enabled && songStacks == 3 ? Config.StacksBar.WMGlowConfig : null + ); + } + + DrawSongTimerBar(origin, songTimer, Config.SongGaugeBar.WMColor, Config.SongGaugeBar.WMThreshold, player); + + break; + + case Song.Mage: + if (Config.StacksBar.Enabled && Config.StacksBar.ShowMBProc) + { + DrawBloodletterReady(origin, player); + } + + DrawSongTimerBar(origin, songTimer, Config.SongGaugeBar.MBColor, Config.SongGaugeBar.MBThreshold, player); + + break; + + case Song.Army: + if (Config.StacksBar.Enabled && Config.StacksBar.ShowAPStacks) + { + DrawStacksBar(origin, player, songStacks, 4, Config.StacksBar.APStackColor); + } + + DrawSongTimerBar(origin, songTimer, Config.SongGaugeBar.APColor, Config.SongGaugeBar.APThreshold, player); + + break; + + case Song.None: + if (Config.StacksBar.Enabled && !Config.StacksBar.HideWhenInactive) + { + DrawStacksBar(origin, player, 0, 3, Config.StacksBar.WMStackColor); + } + + DrawSongTimerBar(origin, 0, EmptyColor, Config.SongGaugeBar.ThresholdConfig, player); + + break; + + default: + if (Config.StacksBar.Enabled && !Config.StacksBar.HideWhenInactive) + { + DrawStacksBar(origin, player, 0, 3, Config.StacksBar.WMStackColor); + } + + DrawSongTimerBar(origin, 0, EmptyColor, Config.SongGaugeBar.ThresholdConfig, player); + + break; + } + } + + private void DrawBloodletterReady(Vector2 origin, IPlayerCharacter player) + { + int maxStacks = player.Level < 84 ? 2 : 3; + int maxCooldown = maxStacks * 15; + int cooldown = _spellHelper.GetSpellCooldownInt(110); + cooldown = player.Level < 84 ? Math.Max(0, cooldown - 15) : cooldown; + + int stacks = (maxCooldown - cooldown) / 15; + + DrawStacksBar(origin, player, stacks, maxStacks, Config.StacksBar.MBProcColor, + Config.StacksBar.MBGlowConfig.Enabled ? Config.StacksBar.MBGlowConfig : null); + } + + protected void DrawSongTimerBar(Vector2 origin, ushort songTimer, PluginConfigColor songColor, ThresholdConfig songThreshold, IPlayerCharacter player) + { + + if (Config.SongGaugeBar.HideWhenInactive && songTimer == 0 || !Config.SongGaugeBar.Enabled) + { + return; + } + + float duration = Math.Abs(songTimer / 1000f); + + Config.SongGaugeBar.Label.SetValue(duration); + Config.SongGaugeBar.ThresholdConfig = songThreshold; + + BarHud bar = BarUtilities.GetProgressBar(Config.SongGaugeBar, duration, 45f, 0f, player, songColor); + AddDrawActions(bar.GetDrawActions(origin, Config.SongGaugeBar.StrataLevel)); + } + + protected void DrawSoulVoiceBar(Vector2 origin, IPlayerCharacter player) + { + BardSoulVoiceBarConfig config = Config.SoulVoiceBar; + byte soulVoice = Plugin.JobGauges.Get().SoulVoice; + + if (config.HideWhenInactive && soulVoice == 0) + { + return; + } + + config.Label.SetValue(soulVoice); + + BarHud bar = BarUtilities.GetProgressBar( + config, + config.ThresholdConfig, + new LabelConfig[] { config.Label }, + soulVoice, + 100f, + 0f, + player, + config.FillColor, + soulVoice == 100f && config.GlowConfig.Enabled ? config.GlowConfig : null + ); + + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + + private void DrawStacksBar(Vector2 origin, IPlayerCharacter player, int amount, int max, PluginConfigColor stackColor, BarGlowConfig? glowConfig = null) + { + BardStacksBarConfig config = Config.StacksBar; + + config.FillColor = stackColor; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.StacksBar, max, amount, max, 0f, player, glowConfig: glowConfig); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.CodaBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Ranged", 0)] + [SubSection("Bard", 1)] + public class BardConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.BRD; + + public new static BardConfig DefaultConfig() + { + var config = new BardConfig(); + + config.SoulVoiceBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + + config.StormbiteDoTBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.StormbiteDoTBar.Label.TextAnchor = DrawAnchor.Left; + config.StormbiteDoTBar.Label.FrameAnchor = DrawAnchor.Left; + config.StormbiteDoTBar.Label.Position = new Vector2(2, 0); + + config.CausticBiteDoTBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.CausticBiteDoTBar.Label.TextAnchor = DrawAnchor.Right; + config.CausticBiteDoTBar.Label.FrameAnchor = DrawAnchor.Right; + config.CausticBiteDoTBar.Label.Position = new Vector2(-2, 0); + config.CausticBiteDoTBar.FillDirection = BarDirection.Left; + + config.SoulVoiceBar.ThresholdConfig.Enabled = true; + config.SoulVoiceBar.ThresholdConfig.Value = 80; + config.SoulVoiceBar.ThresholdConfig.ThresholdType = ThresholdType.Above; + config.SoulVoiceBar.ThresholdConfig.ChangeColor = true; + config.SoulVoiceBar.ThresholdConfig.Color = new PluginConfigColor(new Vector4(150f / 255f, 0f / 255f, 255f / 255f, 100f / 100f)); + + return config; + } + + [NestedConfig("Song Gauge Bar", 30)] + public BardSongBarConfig SongGaugeBar = new BardSongBarConfig( + new(0, -22), + new(254, 20), + new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 0f / 100f)) + ); + + [NestedConfig("Soul Voice Bar", 35)] + public BardSoulVoiceBarConfig SoulVoiceBar = new BardSoulVoiceBarConfig( + new(0, -5), + new(254, 10), + new PluginConfigColor(new Vector4(248f / 255f, 227f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Stacks Bar", 40)] + public BardStacksBarConfig StacksBar = new BardStacksBarConfig( + new(0, -39), + new(254, 10), + new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 0f / 100f)) + ); + + [NestedConfig("Caustic Bite Bar", 60)] + public ProgressBarConfig CausticBiteDoTBar = new ProgressBarConfig( + new(-64, -51), + new(126, 10), + new PluginConfigColor(new Vector4(182f / 255f, 68f / 255f, 235f / 255f, 100f / 100f)) + ); + + [NestedConfig("Stormbite Bar", 65)] + public ProgressBarConfig StormbiteDoTBar = new ProgressBarConfig( + new(64, -51), + new(126, 10), + new PluginConfigColor(new Vector4(72f / 255f, 117f / 255f, 202f / 255f, 100f / 100f)) + ); + + [NestedConfig("Coda Bar", 40)] + public BardCodaBarConfig CodaBar = new BardCodaBarConfig( + new(0, -63), + new(254, 10), + new PluginConfigColor(new Vector4(0, 0, 0, 0)) + ); + } + + [DisableParentSettings("FillColor", "ThresholdConfig")] + [Exportable(false)] + public class BardSongBarConfig : ProgressBarConfig + { + [ColorEdit4("Wanderer's Minuet" + "##Song")] + [Order(31)] + public PluginConfigColor WMColor = new(new Vector4(158f / 255f, 157f / 255f, 36f / 255f, 100f / 100f)); + + [ColorEdit4("Mage's Ballad" + "##Song")] + [Order(32)] + public PluginConfigColor MBColor = new(new Vector4(143f / 255f, 90f / 255f, 143f / 255f, 100f / 100f)); + + [ColorEdit4("Army's Paeon" + "##Song")] + [Order(33)] + public PluginConfigColor APColor = new(new Vector4(207f / 255f, 205f / 255f, 52f / 255f, 100f / 100f)); + + [NestedConfig("Wanderer's Minuet Threshold", 36, separator = false, spacing = true)] + public ThresholdConfig WMThreshold = new ThresholdConfig() + { + ChangeColor = true, + Enabled = true, + ThresholdType = ThresholdType.Below, + Value = 3 + }; + + [NestedConfig("Mage's Ballad Threshold", 37, separator = false, spacing = true)] + public ThresholdConfig MBThreshold = new ThresholdConfig() + { + ChangeColor = true, + Enabled = true, + ThresholdType = ThresholdType.Below, + Value = 14 + }; + + [NestedConfig("Army's Paeon Threshold", 38, separator = false, spacing = true)] + public ThresholdConfig APThreshold = new ThresholdConfig() + { + ChangeColor = true, + Enabled = true, + ThresholdType = ThresholdType.Below, + Value = 3 + }; + + public BardSongBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class BardSoulVoiceBarConfig : ProgressBarConfig + { + [NestedConfig("Show Glow", 39, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new BarGlowConfig(); + + public BardSoulVoiceBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [DisableParentSettings("FillColor")] + [Exportable(false)] + public class BardStacksBarConfig : ChunkedBarConfig + { + [Checkbox("Wanderer's Minuet Stacks", separator = false, spacing = true)] + [Order(51)] + public bool ShowWMStacks = true; + + [NestedConfig("Wanderer's Minuet Stacks Glow", 52, separator = false, spacing = true)] + public BarGlowConfig WMGlowConfig = new BarGlowConfig(); + + [Checkbox("Mage's Ballad Proc" + "##Stacks")] + [Order(53)] + public bool ShowMBProc = true; + + [NestedConfig("Mage's Ballad Proc Glow", 54, separator = false, spacing = true)] + public BarGlowConfig MBGlowConfig = new BarGlowConfig(); + + [Checkbox("Army's Paeon Stacks" + "##Stacks")] + [Order(56)] + public bool ShowAPStacks = true; + + [ColorEdit4("Wanderer's Minuet Stack" + "##Stacks")] + [Order(57)] + public PluginConfigColor WMStackColor = new(new Vector4(150f / 255f, 215f / 255f, 232f / 255f, 100f / 100f)); + + [ColorEdit4("Mage's Ballad Proc" + "##Stacks")] + [Order(58)] + public PluginConfigColor MBProcColor = new(new Vector4(199f / 255f, 46f / 255f, 46f / 255f, 100f / 100f)); + + [ColorEdit4("Army's Paeon Stack" + "##Stacks")] + [Order(59)] + public PluginConfigColor APStackColor = new(new Vector4(0f / 255f, 222f / 255f, 177f / 255f, 100f / 100f)); + + public BardStacksBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [DisableParentSettings("FillColor")] + [Exportable(false)] + public class BardCodaBarConfig : ChunkedBarConfig + { + [ColorEdit4("Wanderer's Minuet" + "##Coda", spacing = true)] + [Order(71)] + public PluginConfigColor WMColor = new(new Vector4(145f / 255f, 186f / 255f, 94f / 255f, 100f / 100f)); + + [ColorEdit4("Mage's Ballad" + "##Coda")] + [Order(72)] + public PluginConfigColor MBColor = new(new Vector4(143f / 255f, 90f / 255f, 143f / 255f, 100f / 100f)); + + [ColorEdit4("Army's Paeon" + "##Coda")] + [Order(73)] + public PluginConfigColor APColor = new(new Vector4(207f / 255f, 205f / 255f, 52f / 255f, 100f / 100f)); + + [DragDropHorizontal("Order", "Wanderer's Minuet", "Mage's Ballad", "Army's Paeon")] + [Order(74)] + public int[] CodaOrder = new int[] { 0, 1, 2 }; + + public BardCodaBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor, 2) { } + } +} diff --git a/Interface/Jobs/BaseJobsConfig.cs b/Interface/Jobs/BaseJobsConfig.cs new file mode 100644 index 0000000..58a9191 --- /dev/null +++ b/Interface/Jobs/BaseJobsConfig.cs @@ -0,0 +1,60 @@ +using HSUI.Helpers; +using Newtonsoft.Json; + +namespace HSUI.Interface.Jobs +{ + public class BaseJobsConfig : JobConfig + { + public override uint JobId => 0; + + public BaseJobsConfig() + { + UseDefaultPrimaryResourceBar = true; + } + } + + public class GladiatorConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.GLA; + } + + public class MarauderConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.MRD; + } + + public class PugilistConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.PGL; + } + + public class LancerConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.LNC; + } + + public class RogueConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.ROG; + } + + public class ArcherConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.ARC; + } + + public class ThaumaturgeConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.THM; + } + + public class ArcanistConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.ACN; + } + + public class ConjurerConfig : BaseJobsConfig + { + [JsonIgnore] public override uint JobId => JobIDs.CNJ; + } +} diff --git a/Interface/Jobs/BlackMageHud.cs b/Interface/Jobs/BlackMageHud.cs new file mode 100644 index 0000000..2039a6f --- /dev/null +++ b/Interface/Jobs/BlackMageHud.cs @@ -0,0 +1,588 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game.Gauge; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; + +namespace HSUI.Interface.Jobs +{ + public class BlackMageHud : JobHud + { + private new BlackMageConfig Config => (BlackMageConfig)_config; + + private static readonly List ThunderDoTIDs = new() { 161, 162, 163, 1210, 3871, 3872 }; + private static readonly List ThunderDoTDurations = new() { 24, 18, 27, 21, 30, 24 }; + + public BlackMageHud(BlackMageConfig config, string? displayName = null) : base(config, displayName) + { + + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.ManaBar.Enabled) + { + positions.Add(Config.Position + Config.ManaBar.Position); + sizes.Add(Config.ManaBar.Size); + } + + if (Config.StacksBar.Enabled) + { + positions.Add(Config.Position + Config.StacksBar.Position); + sizes.Add(Config.StacksBar.Size); + } + + if (Config.UmbralHeartBar.Enabled) + { + positions.Add(Config.Position + Config.UmbralHeartBar.Position); + sizes.Add(Config.UmbralHeartBar.Size); + } + + if (Config.TriplecastBar.Enabled) + { + positions.Add(Config.Position + Config.TriplecastBar.Position); + sizes.Add(Config.TriplecastBar.Size); + } + + if (Config.EnochianBar.Enabled) + { + positions.Add(Config.Position + Config.EnochianBar.Position); + sizes.Add(Config.EnochianBar.Size); + } + + if (Config.PolyglotBar.Enabled) + { + positions.Add(Config.Position + Config.PolyglotBar.Position); + sizes.Add(Config.PolyglotBar.Size); + } + + if (Config.ParadoxBar.Enabled) + { + positions.Add(Config.Position + Config.ParadoxBar.Position); + sizes.Add(Config.ParadoxBar.Size); + } + + if (Config.ThunderDoTBar.Enabled && !Config.ThunderDoTBar.HideWhenInactive) + { + positions.Add(Config.Position + Config.ThunderDoTBar.Position); + sizes.Add(Config.ThunderDoTBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.ManaBar.Enabled) + { + DrawManaBar(pos, player); + } + + if (Config.StacksBar.Enabled) + { + DrawStacksBar(pos); + } + + if (Config.UmbralHeartBar.Enabled) + { + DrawUmbralHeartBar(pos); + } + + if (Config.AstralSoulBar.Enabled) + { + DrawAstralSoulBar(pos); + } + + if (Config.TriplecastBar.Enabled) + { + DrawTripleCastBar(pos, player); + } + + if (Config.PolyglotBar.Enabled) + { + DrawPolyglotBar(pos, player); + } + + if (Config.ParadoxBar.Enabled) + { + DrawParadoxBar(pos, player); + } + + if (Config.EnochianBar.Enabled) + { + DrawEnochianBar(pos, player); + } + + if (Config.ThunderDoTBar.Enabled) + { + DrawThunderDoTBar(pos, player); + } + } + + protected unsafe void DrawManaBar(Vector2 origin, IPlayerCharacter player) + { + BlackMageManaBarConfig config = Config.ManaBar; + BLMGauge _gauge = Plugin.JobGauges.Get(); + BlackMageGaugeTmp* gauge = (BlackMageGaugeTmp*)_gauge.Address; + bool inUmbralIce = gauge->ElementStance < 0; + bool inAstralFire = gauge->ElementStance > 0; + + if (config.HideWhenInactive && !inAstralFire && !inUmbralIce && player.CurrentMp == player.MaxMp) + { + return; + } + + // value + config.ValueLabelConfig.SetValue(player.CurrentMp); + + bool drawTreshold = inAstralFire || !config.ThresholdConfig.ShowOnlyDuringAstralFire; + + PluginConfigColor fillColor = config.FillColor; + PluginConfigColor bgColor = config.BackgroundColor; + if (config.UseElementColor) + { + fillColor = inAstralFire ? config.FireColor : inUmbralIce ? config.IceColor : config.FillColor; + bgColor = inAstralFire ? config.FireBackgroundColor : inUmbralIce ? config.IceBackgroundColor : config.BackgroundColor; + } + + BarHud bar = BarUtilities.GetProgressBar( + config, + drawTreshold ? config.ThresholdConfig : null, + [config.ValueLabelConfig], + player.CurrentMp, + player.MaxMp, + 0, + player, + fillColor: fillColor, + backgroundColor: bgColor + ); + + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + + protected unsafe void DrawStacksBar(Vector2 origin) + { + BLMGauge _gauge = Plugin.JobGauges.Get(); + BlackMageGaugeTmp* gauge = (BlackMageGaugeTmp*)_gauge.Address; + int umbralIceStacks = gauge->UmbralStacks; + int astralFireStacks = gauge->AstralStacks; + + if (Config.StacksBar.HideWhenInactive && umbralIceStacks == 0 && astralFireStacks == 0) + { + return; + }; + + PluginConfigColor color = umbralIceStacks > 0 ? Config.StacksBar.IceColor : Config.StacksBar.FireColor; + int stacks = umbralIceStacks > 0 ? umbralIceStacks : astralFireStacks; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.StacksBar, 3, stacks, 3f, fillColor: color); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.StacksBar.StrataLevel)); + } + } + + protected unsafe void DrawUmbralHeartBar(Vector2 origin) + { + BLMGauge _gauge = Plugin.JobGauges.Get(); + BlackMageGaugeTmp* gauge = (BlackMageGaugeTmp*)_gauge.Address; + + if (Config.UmbralHeartBar.HideWhenInactive && gauge->UmbralHearts == 0) + { + return; + }; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.UmbralHeartBar, 3, gauge->UmbralHearts, 3f); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.UmbralHeartBar.StrataLevel)); + } + } + + protected unsafe void DrawAstralSoulBar(Vector2 origin) + { + BLMGauge gauge = Plugin.JobGauges.Get(); + BlackMageGaugeTmp* internalGauge = (BlackMageGaugeTmp*)gauge.Address; + int stacks = internalGauge->AstralSoulStacks; + const int maxStacks = 6; + + if (Config.AstralSoulBar.HideWhenInactive && stacks == 0) + { + return; + }; + + bool isFull = stacks == maxStacks; + BarGlowConfig? glow = isFull && Config.AstralSoulBar.GlowConfig.Enabled ? Config.AstralSoulBar.GlowConfig : null; + PluginConfigColor color = isFull ? Config.AstralSoulBar.FullStacksColor : Config.AstralSoulBar.FillColor; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.AstralSoulBar, maxStacks, stacks, maxStacks, fillColor: color, glowConfig: glow); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.AstralSoulBar.StrataLevel)); + } + } + + protected void DrawTripleCastBar(Vector2 origin, IPlayerCharacter player) + { + ushort stackCount = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1211)?.Param ?? 0; + int maxCount = 3; + + if (Config.TriplecastBar.CountSwiftcast) + { + bool hasSwiftcast = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 167) != null; + if (hasSwiftcast) + { + stackCount++; + maxCount = stackCount == 4 ? 4 : 3; + } + } + + if (Config.TriplecastBar.HideWhenInactive && stackCount == 0) + { + return; + }; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.TriplecastBar, maxCount, stackCount, maxCount); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.TriplecastBar.StrataLevel)); + } + } + + protected unsafe void DrawEnochianBar(Vector2 origin, IPlayerCharacter player) + { + BLMGauge _gauge = Plugin.JobGauges.Get(); + BlackMageGaugeTmp* gauge = (BlackMageGaugeTmp*)_gauge.Address; + + if (Config.EnochianBar.HideWhenInactive && !gauge->EnochianActive) + { + return; + } + + float timer = gauge->EnochianActive ? (30000f - gauge->EnochianTimer) : 0f; + Config.EnochianBar.Label.SetValue(timer / 1000); + + BarHud bar = BarUtilities.GetProgressBar(Config.EnochianBar, timer / 1000, 30, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.EnochianBar.StrataLevel)); + } + + protected unsafe void DrawPolyglotBar(Vector2 origin, IPlayerCharacter player) + { + BLMGauge _gauge = Plugin.JobGauges.Get(); + BlackMageGaugeTmp* gauge = (BlackMageGaugeTmp*)_gauge.Address; + + if (Config.PolyglotBar.HideWhenInactive && gauge->PolyglotStacks == 0) + { + return; + } + + // only 1 stack before level 80 + if (player.Level < 80) + { + BarGlowConfig? glow = gauge->PolyglotStacks == 1 && Config.PolyglotBar.GlowConfig.Enabled ? Config.PolyglotBar.GlowConfig : null; + BarHud bar = BarUtilities.GetBar(Config.PolyglotBar, gauge->PolyglotStacks, 1, 0, glowConfig: glow); + AddDrawActions(bar.GetDrawActions(origin, Config.PolyglotBar.StrataLevel)); + } + // 2-3 stacks after + else + { + int stacks = player.Level < 98 ? 2 : 3; + BarGlowConfig? glow = Config.PolyglotBar.GlowConfig.Enabled ? Config.PolyglotBar.GlowConfig : null; + BarHud[] bars = BarUtilities.GetChunkedBars(Config.PolyglotBar, stacks, gauge->PolyglotStacks, stacks, 0, glowConfig: glow); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.PolyglotBar.StrataLevel)); + } + } + } + + protected unsafe void DrawParadoxBar(Vector2 origin, IPlayerCharacter player) + { + BLMGauge _gauge = Plugin.JobGauges.Get(); + BlackMageGaugeTmp* gauge = (BlackMageGaugeTmp*)_gauge.Address; + bool inUmbralIce = gauge->ElementStance < 0; + bool inAstralFire = gauge->ElementStance > 0; + + if (Config.ParadoxBar.HideWhenInactive && !gauge->ParadoxActive) + { + return; + }; + + PluginConfigColor color = Config.ParadoxBar.FillColor; + if (Config.ParadoxBar.UseElementColor) + { + color = inUmbralIce ? Config.ParadoxBar.IceColor : (inAstralFire ? Config.ParadoxBar.FireColor : color); + } + + BarGlowConfig? glow = gauge->ParadoxActive && Config.ParadoxBar.GlowConfig.Enabled ? Config.ParadoxBar.GlowConfig : null; + BarHud bar = BarUtilities.GetBar(Config.ParadoxBar, gauge->ParadoxActive ? 1 : 0, 1, 0, fillColor: color, glowConfig: glow); + AddDrawActions(bar.GetDrawActions(origin, Config.ParadoxBar.StrataLevel)); + } + + protected void DrawThunderDoTBar(Vector2 origin, IPlayerCharacter player) + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.ThunderDoTBar, player, target, ThunderDoTIDs, ThunderDoTDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.ThunderDoTBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Caster", 0)] + [SubSection("Black Mage", 1)] + public class BlackMageConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.BLM; + + public new static BlackMageConfig DefaultConfig() + { + var config = new BlackMageConfig(); + + config.EnochianBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.EnochianBar.Label.TextAnchor = DrawAnchor.Left; + config.EnochianBar.Label.FrameAnchor = DrawAnchor.Left; + config.EnochianBar.Label.Position = new Vector2(2, 0); + + config.ThunderDoTBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.ThunderDoTBar.Label.TextAnchor = DrawAnchor.Left; + config.ThunderDoTBar.Label.FrameAnchor = DrawAnchor.Left; + config.ThunderDoTBar.Label.Position = new Vector2(2, 0); + + return config; + } + + [NestedConfig("Mana Bar", 30)] + public BlackMageManaBarConfig ManaBar = new BlackMageManaBarConfig( + new Vector2(0, -10), + new Vector2(254, 18), + new PluginConfigColor(new Vector4(234f / 255f, 95f / 255f, 155f / 255f, 100f / 100f)) + ); + + [NestedConfig("Umbral Ice / Astral Fire Bar", 31)] + public BlackMageStacksBarConfig StacksBar = new BlackMageStacksBarConfig( + new(-67, -27), + new(120, 10) + ); + + [NestedConfig("Umbral Heart Bar", 32)] + public ChunkedBarConfig UmbralHeartBar = new ChunkedBarConfig( + new(67, -27), + new(120, 10), + new PluginConfigColor(new Vector4(125f / 255f, 195f / 255f, 205f / 255f, 100f / 100f)) + ); + + [NestedConfig("Paradox Bar", 33)] + public BlackMageParadoxBarConfig ParadoxBar = new BlackMageParadoxBarConfig( + new(0, -27), + new(10, 10), + new PluginConfigColor(new Vector4(123f / 255f, 66f / 255f, 177f / 255f, 100f / 100f)) + ); + + [NestedConfig("Enochian Bar", 40)] + public ProgressBarConfig EnochianBar = new ProgressBarConfig( + new(-16, -41), + new(222, 14), + new PluginConfigColor(new Vector4(234f / 255f, 95f / 255f, 155f / 255f, 100f / 100f)) + ); + + [NestedConfig("Polyglot Bar", 45)] + public BlackMagePolyglotBarConfig PolyglotBar = new BlackMagePolyglotBarConfig( + new(112, -41), + new(30, 14), + new PluginConfigColor(new Vector4(234f / 255f, 95f / 255f, 155f / 255f, 100f / 100f)) + ); + + [NestedConfig("Astral Soul Bar", 45)] + public BlackMageAstralSoulBarConfig AstralSoulBar = new BlackMageAstralSoulBarConfig( + new(112, -41), + new(30, 14), + new PluginConfigColor(new Vector4(220f / 255f, 180f / 255f, 180f / 255f, 100f / 100f)) + ); + + [NestedConfig("Triplecast Bar", 50)] + public BlackMageTriplecastBarConfig TriplecastBar = new BlackMageTriplecastBarConfig( + new(0, -55), + new(254, 10), + new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Thunder DoT Bar", 60)] + public ProgressBarConfig ThunderDoTBar = new ProgressBarConfig( + new(64, -69), + new(126, 14), + new PluginConfigColor(new Vector4(67f / 255f, 187 / 255f, 255f / 255f, 100f / 100f)) + ); + } + + [Exportable(false)] + public class BlackMageManaBarConfig : BarConfig + { + [Checkbox("Use Element Color" + "##MP", spacing = true)] + [Order(50)] + public bool UseElementColor = true; + + [ColorEdit4("Ice Color" + "##MP")] + [Order(51, collapseWith = nameof(UseElementColor))] + public PluginConfigColor IceColor = new PluginConfigColor(new Vector4(69f / 255f, 115f / 255f, 202f / 255f, 100f / 100f)); + + [ColorEdit4("Ice Background Color" + "##MP")] + [Order(52, collapseWith = nameof(UseElementColor))] + public PluginConfigColor IceBackgroundColor = new PluginConfigColor(new Vector4(50f / 255f, 80f / 255f, 130f / 255f, 50f / 100f)); + + [ColorEdit4("Fire Color" + "##MP")] + [Order(53, collapseWith = nameof(UseElementColor))] + public PluginConfigColor FireColor = new PluginConfigColor(new Vector4(204f / 255f, 40f / 255f, 40f / 255f, 100f / 100f)); + + [ColorEdit4("Fire Background Color" + "##MP")] + [Order(54, collapseWith = nameof(UseElementColor))] + public PluginConfigColor FireBackgroundColor = new PluginConfigColor(new Vector4(120f / 255f, 30f / 255f, 30f / 255f, 50f / 100f)); + + [NestedConfig("Value Label", 60, separator = false, spacing = true)] + public NumericLabelConfig ValueLabelConfig = new NumericLabelConfig(new Vector2(2, 0), "", DrawAnchor.Left, DrawAnchor.Left); + + [NestedConfig("Threshold", 70, separator = false, spacing = true)] + public BlackMakeManaBarThresholdConfig ThresholdConfig = new BlackMakeManaBarThresholdConfig(); + + public BlackMageManaBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class BlackMakeManaBarThresholdConfig : ThresholdConfig + { + [Checkbox("Show Only During Astral Fire")] + [Order(5)] + public bool ShowOnlyDuringAstralFire = true; + + public BlackMakeManaBarThresholdConfig() + { + Enabled = true; + Value = 2400; + Color = new PluginConfigColor(new Vector4(240f / 255f, 120f / 255f, 10f / 255f, 100f / 100f)); + ShowMarker = true; + MarkerColor = new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + } + } + + [DisableParentSettings("FillColor", "FillDirection")] + [Exportable(false)] + public class BlackMageStacksBarConfig : ChunkedBarConfig + { + [ColorEdit4("Ice Color" + "##MP")] + [Order(26)] + public PluginConfigColor IceColor = new PluginConfigColor(new Vector4(69f / 255f, 115f / 255f, 202f / 255f, 100f / 100f)); + + [ColorEdit4("Fire Color" + "##MP")] + [Order(27)] + public PluginConfigColor FireColor = new PluginConfigColor(new Vector4(204f / 255f, 40f / 255f, 40f / 255f, 100f / 100f)); + + public BlackMageStacksBarConfig(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + } + } + + [Exportable(false)] + public class BlackMagePolyglotBarConfig : ChunkedBarConfig + { + [NestedConfig("Show Glow", 60, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new BarGlowConfig(); + + public BlackMagePolyglotBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class BlackMageParadoxBarConfig : BarConfig + { + [Checkbox("Use Element Color" + "##Paradox", spacing = true)] + [Order(50)] + public bool UseElementColor = true; + + [ColorEdit4("Ice Color" + "##Paradox")] + [Order(51, collapseWith = nameof(UseElementColor))] + public PluginConfigColor IceColor = new PluginConfigColor(new Vector4(69f / 255f, 115f / 255f, 202f / 255f, 100f / 100f)); + + [ColorEdit4("Fire Color" + "##Paradox")] + [Order(52, collapseWith = nameof(UseElementColor))] + public PluginConfigColor FireColor = new PluginConfigColor(new Vector4(204f / 255f, 40f / 255f, 40f / 255f, 100f / 100f)); + + [NestedConfig("Show Glow" + "##Paradox", 60, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new BarGlowConfig(); + + public BlackMageParadoxBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class BlackMageTriplecastBarConfig : ChunkedBarConfig + { + [Checkbox("Count Swiftcast" + "##Triplecast", spacing = true)] + [Order(50)] + public bool CountSwiftcast = false; + + public BlackMageTriplecastBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [DisableParentSettings("FillDirection")] + [Exportable(false)] + public class BlackMageAstralSoulBarConfig : ChunkedBarConfig + { + [ColorEdit4("Full Stacks Color")] + [Order(26)] + public PluginConfigColor FullStacksColor = new PluginConfigColor(new Vector4(255f / 255f, 200f / 255f, 160f / 255f, 100f / 100f)); + + [NestedConfig("Show Glow" + "##AstralSoul", 60, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new BarGlowConfig(); + + public BlackMageAstralSoulBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } +} + +[StructLayout(LayoutKind.Explicit, Size = 0x30)] +public struct BlackMageGaugeTmp +{ + [FieldOffset(0x08)] public short EnochianTimer; + [FieldOffset(0x0A)] public sbyte ElementStance; + [FieldOffset(0x0B)] public byte UmbralHearts; + [FieldOffset(0x0C)] public byte PolyglotStacks; + [FieldOffset(0x0D)] public EnochianFlags EnochianFlags; + + public int UmbralStacks => ElementStance >= 0 ? 0 : ElementStance * -1; + public int AstralStacks => ElementStance <= 0 ? 0 : ElementStance; + public bool EnochianActive => EnochianFlags.HasFlag(EnochianFlags.Enochian); + public bool ParadoxActive => EnochianFlags.HasFlag(EnochianFlags.Paradox); + public int AstralSoulStacks => ((int)EnochianFlags >> 2) & 7; +} \ No newline at end of file diff --git a/Interface/Jobs/BlueMageHud.cs b/Interface/Jobs/BlueMageHud.cs new file mode 100644 index 0000000..0b7aacd --- /dev/null +++ b/Interface/Jobs/BlueMageHud.cs @@ -0,0 +1,313 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class BlueMageHud : JobHud + { + private new BlueMageConfig Config => (BlueMageConfig)_config; + + public BlueMageHud(BlueMageConfig config, string? displayName = null) : base(config, displayName) + { + } + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.BleedBar.Enabled) + { + positions.Add(Config.Position + Config.BleedBar.Position); + sizes.Add(Config.BleedBar.Size); + } + + if (Config.WindburnBar.Enabled) + { + positions.Add(Config.Position + Config.WindburnBar.Position); + sizes.Add(Config.WindburnBar.Size); + } + + if (Config.SurpanakhaBar.Enabled) + { + positions.Add(Config.Position + Config.SurpanakhaBar.Position); + sizes.Add(Config.SurpanakhaBar.Size); + } + + if (Config.OffGuardBar.Enabled) + { + positions.Add(Config.Position + Config.OffGuardBar.Position); + sizes.Add(Config.OffGuardBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + if (Config.BleedBar.Enabled) + { + DrawBleedBar(pos, player); + } + + if (Config.WindburnBar.Enabled) + { + DrawWindburnBar(pos, player); + } + + if (Config.SurpanakhaBar.Enabled) + { + DrawSurpanakhaBar(pos, player); + } + + if (Config.OffGuardBar.Enabled) + { + DrawOffGuardBar(pos, player); + } + + if (Config.MoonFluteBar.Enabled) + { + DrawMoonFluteBar(pos, player); + } + + if (Config.SpellAmpBar.Enabled) + { + DrawSpellAmpBar(pos, player); + } + + if (Config.TingleBar.Enabled) + { + DrawTingleBar(pos, player); + } + } + + private static List BleedID = new List { 1714 }; + private static List BleedDurations = new List { 30, 60 }; + + protected void DrawBleedBar(Vector2 origin, IPlayerCharacter player) + { + var target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + BarHud? bar = BarUtilities.GetDoTBar(Config.BleedBar, player, target, BleedID, BleedDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.BleedBar.StrataLevel)); + } + } + + protected void DrawWindburnBar(Vector2 origin, IPlayerCharacter player) + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + bool dotExists = false; + + if (target != null && target is IBattleChara targetChara) + { + dotExists = Utils.StatusListForBattleChara(targetChara).FirstOrDefault(o => o.SourceId == player.GameObjectId && o.StatusId == 1723) != null; + } + + if (dotExists) + { + BarHud? bar = BarUtilities.GetDoTBar(Config.WindburnBar, player, target, 1723, 6f); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.WindburnBar.StrataLevel)); + } + } + else + { + float featherRainCD = SpellHelper.Instance.GetSpellCooldown(11426); + float max = 30f; + float current = max - featherRainCD; + + if (!Config.WindburnBar.HideWhenInactive || current < max) + { + Config.WindburnBar.Label.SetValue(max - current); + if (current == max) + { + Config.WindburnBar.Label.SetText("Ready"); + } + + BarHud bar = BarUtilities.GetProgressBar(Config.WindburnBar, current, max, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.WindburnBar.StrataLevel)); + } + } + } + + protected void DrawSurpanakhaBar(Vector2 origin, IPlayerCharacter player) + { + float surpanakhaCD = SpellHelper.Instance.GetSpellCooldown(18323); + float max = 120f; + float current = max - surpanakhaCD; + + if (!Config.SurpanakhaBar.HideWhenInactive || current < max) + { + Config.SurpanakhaBar.Label.SetValue((max - current) % 30); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.SurpanakhaBar, 4, current, max, 0f, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.SurpanakhaBar.StrataLevel)); + } + } + } + + protected void DrawOffGuardBar(Vector2 origin, IPlayerCharacter player) + { + var target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.OffGuardBar, player, target, 1717, 15f); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.OffGuardBar.StrataLevel)); + } + } + + protected void DrawMoonFluteBar(Vector2 origin, IPlayerCharacter player) + { + IStatus? buff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1718 or 1727 && o.RemainingTime > 0f); + if (!Config.MoonFluteBar.HideWhenInactive || buff is not null) + { + var buffColor = buff is not null ? buff.StatusId switch + { + 1718 => Config.MoonFluteBar.WaxingCrescentColor, + 1727 => Config.MoonFluteBar.WaningCrescentColor, + _ => Config.MoonFluteBar.WaxingCrescentColor + } : Config.MoonFluteBar.WaxingCrescentColor; + + float buffDuration = buff?.RemainingTime ?? 0f; + + Config.MoonFluteBar.Label.SetValue(buffDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.MoonFluteBar, buffDuration, 15f, 0, player, fillColor: buffColor); + AddDrawActions(bar.GetDrawActions(origin, Config.MoonFluteBar.StrataLevel)); + } + } + + protected void DrawSpellAmpBar(Vector2 origin, IPlayerCharacter player) + { + IStatus? buff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 2118 or 1716 && o.RemainingTime > 0f); + if (!Config.SpellAmpBar.HideWhenInactive || buff is not null) + { + var buffColor = buff is not null ? buff.StatusId switch + { + 2118 => Config.SpellAmpBar.BristleColor, + 1716 => Config.SpellAmpBar.WhistleColor, + _ => Config.SpellAmpBar.BristleColor + } : Config.SpellAmpBar.BristleColor; + + float buffDuration = buff?.RemainingTime ?? 0f; + + Config.SpellAmpBar.Label.SetValue(buffDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.SpellAmpBar, buffDuration, 30f, 0, player, fillColor: buffColor); + AddDrawActions(bar.GetDrawActions(origin, Config.SpellAmpBar.StrataLevel)); + } + } + + protected void DrawTingleBar(Vector2 origin, IPlayerCharacter player) + { + BarHud? bar = BarUtilities.GetProcBar(Config.TingleBar, player, 2492, 15f); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.TingleBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Caster", 0)] + [SubSection("Blue Mage", 1)] + public class BlueMageConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.BLU; + public new static BlueMageConfig DefaultConfig() + { + var config = new BlueMageConfig(); + config.UseDefaultPrimaryResourceBar = true; + + return config; + } + [NestedConfig("Bleed Bar", 40)] + public ProgressBarConfig BleedBar = new ProgressBarConfig( + new(-64, -55), + new(126, 14), + new PluginConfigColor(new Vector4(106f / 255f, 237f / 255f, 241f / 255f, 100f / 100f)), + BarDirection.Left + ); + [NestedConfig("Windburn Bar", 45)] + public ProgressBarConfig WindburnBar = new ProgressBarConfig( + new(64, -55), + new(126, 14), + new PluginConfigColor(new Vector4(50f / 255f, 93f / 255f, 37f / 255f, 100f / 100f)) + ); + [NestedConfig("Surpanakha Bar", 50)] + public ChunkedProgressBarConfig SurpanakhaBar = new ChunkedProgressBarConfig( + new(0, -39), + new(254, 14), + new PluginConfigColor(new Vector4(202f / 255f, 228f / 255f, 246f / 242f, 100f / 100f)) + + ); + [NestedConfig("Off-Guard Bar", 55)] + public ProgressBarConfig OffGuardBar = new ProgressBarConfig( + new(0, -23), + new(254, 14), + new PluginConfigColor(new Vector4(202f / 255f, 228f / 255f, 246f / 242f, 100f / 100f)) + ); + + [NestedConfig("Moon Flute Bar", 60)] + public MoonFluteBarConfig MoonFluteBar = new MoonFluteBarConfig( + new(0, -7), + new(84, 14), + new(new Vector4(128f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)) + ); + [NestedConfig("Spell Amp Bar", 65)] + public SpellAmpBarConfig SpellAmpBar = new SpellAmpBarConfig( + new(-86, -7), + new(82, 14), + new(new Vector4(128f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)) + ); + [NestedConfig("Tingle Bar", 70)] + public ProgressBarConfig TingleBar = new ProgressBarConfig( + new(86, -7), + new(82, 14), + new(new Vector4(128f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)) + ); + } + + [Exportable(false)] + public class MoonFluteBarConfig : ProgressBarConfig + { + [ColorEdit4("Waning Crescent Color")] + [Order(26)] + public PluginConfigColor WaxingCrescentColor = new(new Vector4(0f / 255f, 255f / 255f, 0f / 255f, 100f / 100f)); + [ColorEdit4("Waxing Crescent Color")] + [Order(27)] + public PluginConfigColor WaningCrescentColor = new(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + public MoonFluteBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + [Exportable(false)] + public class SpellAmpBarConfig : ProgressBarConfig + { + [ColorEdit4("Waning Crescent Color")] + [Order(26)] + public PluginConfigColor BristleColor = new(new Vector4(0f / 255f, 255f / 255f, 0f / 255f, 100f / 100f)); + [ColorEdit4("Waxing Crescent Color")] + [Order(27)] + public PluginConfigColor WhistleColor = new(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + public SpellAmpBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } +} diff --git a/Interface/Jobs/CraftersConfig.cs b/Interface/Jobs/CraftersConfig.cs new file mode 100644 index 0000000..adddefc --- /dev/null +++ b/Interface/Jobs/CraftersConfig.cs @@ -0,0 +1,56 @@ +using HSUI.Helpers; +using Newtonsoft.Json; + +namespace HSUI.Interface.Jobs +{ + public class CraftersConfig : JobConfig + { + public override uint JobId => 0; + + public CraftersConfig() + { + UseDefaultPrimaryResourceBar = true; + PrimaryResourceType = PrimaryResourceTypes.CP; + } + } + + public class CarpenterConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.CRP; + } + + public class BlacksmithConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.BSM; + } + + public class ArmorerConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.ARM; + } + + public class GoldsmithConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.GSM; + } + + public class LeatherworkerConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.LTW; + } + + public class WeaverConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.WVR; + } + + public class AlchemistConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.ALC; + } + + public class CulinarianConfig : CraftersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.CUL; + } +} diff --git a/Interface/Jobs/DancerHud.cs b/Interface/Jobs/DancerHud.cs new file mode 100644 index 0000000..3d80d68 --- /dev/null +++ b/Interface/Jobs/DancerHud.cs @@ -0,0 +1,430 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class DancerHud : JobHud + { + private new DancerConfig Config => (DancerConfig)_config; + + public DancerHud(DancerConfig config, string? displayName = null) : base(config, displayName) + { + + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.StandardFinishBar.Enabled) + { + positions.Add(Config.Position + Config.StandardFinishBar.Position); + sizes.Add(Config.StandardFinishBar.Size); + } + + if (Config.TechnicalFinishBar.Enabled) + { + positions.Add(Config.Position + Config.TechnicalFinishBar.Position); + sizes.Add(Config.TechnicalFinishBar.Position); + } + + if (Config.DevilmentBar.Enabled) + { + positions.Add(Config.Position + Config.DevilmentBar.Position); + sizes.Add(Config.DevilmentBar.Position); + } + + if (Config.EspritGauge.Enabled) + { + positions.Add(Config.Position + Config.EspritGauge.Position); + sizes.Add(Config.EspritGauge.Size); + } + + if (Config.FeatherGauge.Enabled) + { + positions.Add(Config.Position + Config.FeatherGauge.Position); + sizes.Add(Config.FeatherGauge.Size); + } + + if (Config.CascadeBar.Enabled) + { + positions.Add(Config.Position + Config.CascadeBar.Position); + sizes.Add(Config.CascadeBar.Position); + } + + if (Config.FountainBar.Enabled) + { + positions.Add(Config.Position + Config.FountainBar.Position); + sizes.Add(Config.FountainBar.Position); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.EspritGauge.Enabled) + { + DrawEspritBar(pos, player); + } + + if (Config.FeatherGauge.Enabled) + { + DrawFeathersBar(pos, player); + } + + if (Config.StandardFinishBar.Enabled) + { + DrawStandardBar(pos, player); + } + + if (Config.TechnicalFinishBar.Enabled) + { + DrawTechnicalBar(pos, player); + } + + if (Config.DevilmentBar.Enabled) + { + DrawDevilmentBar(pos, player); + } + + bool showingStepBar = false; + if (Config.StepsBar.Enabled) + { + showingStepBar = DrawStepBar(pos, player); + } + + if (!showingStepBar || !Config.StepsBar.HideProcs) + { + if (Config.CascadeBar.Enabled) { DrawProcBar(pos, player, Config.CascadeBar, 2693, 3017); } + if (Config.FountainBar.Enabled) { DrawProcBar(pos, player, Config.FountainBar, 2694, 3018); } + } + } + + private void DrawProcBar(Vector2 origin, IPlayerCharacter player, DancerProcBarConfig config, params uint[] statusIDs) + { + List durations = new List(); + for (int i = 0; i < statusIDs.Length; i++) + { + durations.Add(30f); + } + + BarHud? bar = BarUtilities.GetProcBar(config, player, statusIDs.ToList(), durations, !config.IgnoreBuffDuration); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe bool DrawStepBar(Vector2 origin, IPlayerCharacter player) + { + DNCGauge gauge = Plugin.JobGauges.Get(); + if (!gauge.IsDancing) + { + return false; + } + + List> chunks = new List>(); + List glows = new List(); + bool danceReady = true; + + for (var i = 0; i < 4; i++) + { + DNCStep step = (DNCStep)gauge.Steps[i]; + + if (step == DNCStep.None) + { + break; + } + + if (gauge.CompletedSteps == i) + { + glows.Add(true); + danceReady = false; + } + else + { + glows.Add(false); + } + + PluginConfigColor color = PluginConfigColor.Empty; + + switch (step) + { + case DNCStep.Emboite: + color = Config.StepsBar.EmboiteColor; + break; + + case DNCStep.Entrechat: + color = Config.StepsBar.EntrechatColor; + break; + + case DNCStep.Jete: + color = Config.StepsBar.JeteColor; + break; + + case DNCStep.Pirouette: + color = Config.StepsBar.PirouetteColor; + break; + } + + var tuple = new Tuple(color, 1, null); + chunks.Add(tuple); + } + + if (danceReady) + { + for (int i = 0; i < glows.Count; i++) + { + glows[i] = true; + } + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.StepsBar, chunks.ToArray(), player, Config.StepsBar.GlowConfig, glows.ToArray()); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.StepsBar.StrataLevel)); + } + + return true; + } + + private void DrawEspritBar(Vector2 origin, IPlayerCharacter player) + { + DNCGauge gauge = Plugin.JobGauges.Get(); + + if (Config.EspritGauge.HideWhenInactive && gauge.Esprit is 0) { return; } + + Config.EspritGauge.Label.SetValue(gauge.Esprit); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.EspritGauge, 2, gauge.Esprit, 100, 0f, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.EspritGauge.StrataLevel)); + } + } + + private void DrawFeathersBar(Vector2 origin, IPlayerCharacter player) + { + DNCGauge gauge = Plugin.JobGauges.Get(); + bool hasFlourishingBuff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1820 or 2021) != null; + bool[]? glows = null; + + if (Config.FeatherGauge.HideWhenInactive && gauge.Feathers is 0 && !hasFlourishingBuff) + { + return; + } + + if (Config.FeatherGauge.GlowConfig.Enabled) + { + glows = new bool[] { hasFlourishingBuff, hasFlourishingBuff, hasFlourishingBuff, hasFlourishingBuff }; + } + + BarGlowConfig? config = hasFlourishingBuff ? Config.FeatherGauge.GlowConfig : null; + BarHud[] bars = BarUtilities.GetChunkedBars(Config.FeatherGauge, 4, gauge.Feathers, 4, 0, player, glowConfig: config, chunksToGlow: glows); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.FeatherGauge.StrataLevel)); + } + } + + private void DrawTechnicalBar(Vector2 origin, IPlayerCharacter player) + { + IEnumerable devilmentBuff = Utils.StatusListForBattleChara(player).Where(o => o.StatusId is 1825 && o.SourceId == player.GameObjectId); + + float technicalFinishDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1822 or 2050 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.TechnicalFinishBar.HideWhenInactive || technicalFinishDuration > 0) + { + Config.TechnicalFinishBar.Label.SetValue(Math.Abs(technicalFinishDuration)); + + BarHud bar = BarUtilities.GetProgressBar(Config.TechnicalFinishBar, technicalFinishDuration, 20f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.TechnicalFinishBar.StrataLevel)); + } + } + + private void DrawDevilmentBar(Vector2 origin, IPlayerCharacter player) + { + float devilmentDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1825 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.DevilmentBar.HideWhenInactive || devilmentDuration > 0) + { + Config.DevilmentBar.Label.SetValue(Math.Abs(devilmentDuration)); + + BarHud bar = BarUtilities.GetProgressBar(Config.DevilmentBar, devilmentDuration, 20f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.DevilmentBar.StrataLevel)); + } + } + + private void DrawStandardBar(Vector2 origin, IPlayerCharacter player) + { + float standardFinishDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1821 or 2024 or 2105 or 2113 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.StandardFinishBar.HideWhenInactive || standardFinishDuration > 0) + { + Config.StandardFinishBar.Label.SetValue(Math.Abs(standardFinishDuration)); + + BarHud bar = BarUtilities.GetProgressBar(Config.StandardFinishBar, standardFinishDuration, 60f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.StandardFinishBar.StrataLevel)); + } + } + } + + public enum DNCStep : uint + { + None = 15998, + Emboite = 15999, + Entrechat = 16000, + Jete = 16001, + Pirouette = 16002 + } + + [Section("Job Specific Bars")] + [SubSection("Ranged", 0)] + [SubSection("Dancer", 1)] + public class DancerConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.DNC; + public new static DancerConfig DefaultConfig() + { + var config = new DancerConfig(); + + config.EspritGauge.UseChunks = false; + config.EspritGauge.Label.Enabled = true; + + config.CascadeBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.FountainBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + + return config; + } + + + [NestedConfig("Standard Finish Bar", 30)] + public ProgressBarConfig StandardFinishBar = new ProgressBarConfig( + new(0, -10), + new(254, 20), + new PluginConfigColor(new Vector4(0f / 255f, 193f / 255f, 95f / 255f, 100f / 100f)) + ); + + [NestedConfig("Technical Finish Bar", 35)] + public ProgressBarConfig TechnicalFinishBar = new ProgressBarConfig( + new(-64, -32), + new(126, 20), + new PluginConfigColor(new Vector4(255f / 255f, 9f / 255f, 102f / 255f, 100f / 100f)) + ); + + [NestedConfig("Devilment Bar", 40)] + public ProgressBarConfig DevilmentBar = new ProgressBarConfig( + new(64, -32), + new(126, 20), + new PluginConfigColor(new Vector4(52f / 255f, 78f / 255f, 29f / 255f, 100f / 100f)) + ); + + [NestedConfig("Esprit Gauge", 45)] + public ChunkedProgressBarConfig EspritGauge = new ChunkedProgressBarConfig( + new(0, -54), + new(254, 20), + new PluginConfigColor(new Vector4(72f / 255f, 20f / 255f, 99f / 255f, 100f / 100f)) + ); + + [NestedConfig("Feathers Gauge", 50)] + public DancerFeatherGaugeConfig FeatherGauge = new DancerFeatherGaugeConfig( + new(0, -71), + new(254, 10), + new PluginConfigColor(new Vector4(175f / 255f, 229f / 255f, 29f / 255f, 100f / 100f)) + ); + + [NestedConfig("Flourishing Symmetry Bar", 60)] + public DancerProcBarConfig CascadeBar = new DancerProcBarConfig( + new(-96, -83), + new(62, 10), + new(new Vector4(0f / 255f, 255f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Flourishing Flow Bar", 65)] + public DancerProcBarConfig FountainBar = new DancerProcBarConfig( + new(-32, -83), + new(62, 10), + new(new Vector4(255f / 255f, 215f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Steps Bar", 80)] + public DancerStepsBarConfig StepsBar = new DancerStepsBarConfig( + new(0, -83), + new(254, 10) + ); + } + + [Exportable(false)] + public class DancerFeatherGaugeConfig : ChunkedBarConfig + { + [NestedConfig("Glow on Flourishing Fan Dance", 1000, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new BarGlowConfig(); + + public DancerFeatherGaugeConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + GlowConfig.Color = new PluginConfigColor(new Vector4(255f / 255f, 215f / 255f, 0f / 255f, 100f / 100f)); + } + } + + [Exportable(false)] + public class DancerProcBarConfig : ProgressBarConfig + { + [Checkbox("Ignore Buff Duration")] + [Order(4)] + public bool IgnoreBuffDuration = true; + + public DancerProcBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [DisableParentSettings("FillColor", "HideWhenInactive")] + [Exportable(false)] + public class DancerStepsBarConfig : ChunkedBarConfig + { + [Checkbox("Hide Procs When Active")] + [Order(50)] + public bool HideProcs = true; + + [ColorEdit4("Emboite", spacing = true)] + [Order(55)] + public PluginConfigColor EmboiteColor = new(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Entrechat")] + [Order(60)] + public PluginConfigColor EntrechatColor = new(new Vector4(0f / 255f, 0f / 255f, 255f / 255f, 100f / 100f)); + + [ColorEdit4("Jete")] + [Order(65)] + public PluginConfigColor JeteColor = new(new Vector4(0f / 255f, 255f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Pirouette")] + [Order(70)] + public PluginConfigColor PirouetteColor = new(new Vector4(255f / 255f, 215f / 255f, 0f / 255f, 100f / 100f)); + + [NestedConfig("Glow", 75, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new BarGlowConfig(); + + public DancerStepsBarConfig(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + } + } +} \ No newline at end of file diff --git a/Interface/Jobs/DarkKnightHud.cs b/Interface/Jobs/DarkKnightHud.cs new file mode 100644 index 0000000..5434015 --- /dev/null +++ b/Interface/Jobs/DarkKnightHud.cs @@ -0,0 +1,344 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Game.ClientState.Statuses; + +namespace HSUI.Interface.Jobs +{ + public class DarkKnightHud : JobHud + { + private new DarkKnightConfig Config => (DarkKnightConfig)_config; + + public DarkKnightHud(DarkKnightConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.ManaBar.Enabled) + { + positions.Add(Config.Position + Config.ManaBar.Position); + sizes.Add(Config.ManaBar.Size); + } + + if (Config.BloodGauge.Enabled) + { + positions.Add(Config.Position + Config.BloodGauge.Position); + sizes.Add(Config.BloodGauge.Size); + } + + if (Config.DarksideBar.Enabled) + { + positions.Add(Config.Position + Config.DarksideBar.Position); + sizes.Add(Config.DarksideBar.Size); + } + + if (Config.BloodWeaponBar.Enabled) + { + positions.Add(Config.Position + Config.BloodWeaponBar.Position); + sizes.Add(Config.BloodWeaponBar.Size); + } + + if (Config.DeliriumBar.Enabled) + { + positions.Add(Config.Position + Config.DeliriumBar.Position); + sizes.Add(Config.DeliriumBar.Size); + } + + if (Config.LivingShadowBar.Enabled) + { + positions.Add(Config.Position + Config.LivingShadowBar.Position); + sizes.Add(Config.LivingShadowBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.ManaBar.Enabled) + { + DrawManaBar(pos, player); + } + + if (Config.BloodGauge.Enabled) + { + DrawBloodGauge(pos, player); + } + + if (Config.DarksideBar.Enabled) + { + DrawDarkside(pos, player); + } + + if (Config.BloodWeaponBar.Enabled) + { + DrawBloodWeaponBar(pos, player); + } + + if (Config.DeliriumBar.Enabled) + { + DrawDeliriumBar(pos, player); + } + + if (Config.LivingShadowBar.Enabled) + { + DrawLivingShadowBar(pos, player); + } + } + + private unsafe void DrawManaBar(Vector2 origin, IPlayerCharacter player) + { + DRKGauge gauge = Plugin.JobGauges.Get(); + + if (Config.ManaBar.HideWhenInactive && !gauge.HasDarkArts && player.CurrentMp == player.MaxMp) + { + return; + } + + Config.ManaBar.UsePartialFillColor = !gauge.HasDarkArts; + + Config.ManaBar.Label.SetValue(player.CurrentMp); + + // hardcoded 9k as maxMP so the chunks are each 3k since that's what a DRK wants to see + BarHud[] bars = BarUtilities.GetChunkedProgressBars( + Config.ManaBar, + gauge.HasDarkArts ? 1 : 3, + player.CurrentMp, + Config.ManaBar.ShowFullMana ? player.MaxMp : 9000, + 0f, + player, + null, + gauge.HasDarkArts ? Config.ManaBar.DarkArtsColor : Config.ManaBar.FillColor + ); + + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.ManaBar.StrataLevel)); + } + } + + private void DrawDarkside(Vector2 origin, IPlayerCharacter player) + { + DRKGauge gauge = Plugin.JobGauges.Get(); + if (Config.DarksideBar.HideWhenInactive && gauge.DarksideTimeRemaining == 0) + { + return; + }; + + float timer = Math.Abs(gauge.DarksideTimeRemaining) / 1000; + + Config.DarksideBar.Label.SetValue(timer); + + BarHud bar = BarUtilities.GetProgressBar(Config.DarksideBar, timer, 60, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.DarksideBar.StrataLevel)); + } + + private void DrawBloodGauge(Vector2 origin, IPlayerCharacter player) + { + DRKGauge gauge = Plugin.JobGauges.Get(); + if (Config.BloodGauge.HideWhenInactive && gauge.Blood <= 0) + { + return; + } + + Config.BloodGauge.Label.SetValue(gauge.Blood); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.BloodGauge, 2, gauge.Blood, 100, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.BloodGauge.StrataLevel)); + } + } + + private void DrawBloodWeaponBar(Vector2 origin, IPlayerCharacter player) + { + IStatus? bloodWeaponBuff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 742); + float duration = bloodWeaponBuff?.RemainingTime ?? 0f; + int stacks = bloodWeaponBuff?.Param ?? 0; + + if (Config.BloodWeaponBar.HideWhenInactive && duration <= 0) + { + return; + } + + var chunks = new Tuple[3]; + + for (int i = 0; i < 3; i++) + { + chunks[i] = new(Config.BloodWeaponBar.FillColor, i < stacks ? 1 : 0, i == 1 ? Config.BloodWeaponBar.Label : null); + } + + if(Config.BloodWeaponBar.FillDirection is BarDirection.Left or BarDirection.Up) + { + chunks = chunks.Reverse().ToArray(); + } + + Config.BloodWeaponBar.Label.SetValue(duration); + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.BloodWeaponBar, chunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.BloodWeaponBar.StrataLevel)); + } + } + + private void DrawDeliriumBar(Vector2 origin, IPlayerCharacter player) + { + IStatus? deliriumBuff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1972); + float deliriumDuration = Math.Max(0f, deliriumBuff?.RemainingTime ?? 0f); + int stacks = deliriumBuff?.Param ?? 0; + + if (Config.DeliriumBar.HideWhenInactive && deliriumDuration <= 0) + { + return; + } + + var chunks = new Tuple[3]; + for (int i = 0; i < 3; i++) + { + chunks[i] = new(Config.DeliriumBar.FillColor, i < stacks ? 1 : 0, i == 1 ? Config.DeliriumBar.Label : null); + } + + if(Config.DeliriumBar.FillDirection is BarDirection.Left or BarDirection.Up) + { + chunks = chunks.Reverse().ToArray(); + } + + Config.DeliriumBar.Label.SetValue(deliriumDuration); + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.DeliriumBar, chunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.DeliriumBar.StrataLevel)); + } + } + + private unsafe void DrawLivingShadowBar(Vector2 origin, IPlayerCharacter player) + { + DRKGauge gauge = Plugin.JobGauges.Get(); + + if (Config.LivingShadowBar.HideWhenInactive && gauge.ShadowTimeRemaining <= 0) + { + return; + } + + float timer = Math.Abs(gauge.ShadowTimeRemaining) / 1000; + Config.LivingShadowBar.Label.SetValue(timer); + + BarHud bar = BarUtilities.GetProgressBar(Config.LivingShadowBar, timer, 20, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.LivingShadowBar.StrataLevel)); + } + } + + [Section("Job Specific Bars")] + [SubSection("Tank", 0)] + [SubSection("Dark Knight", 1)] + public class DarkKnightConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.DRK; + public new static DarkKnightConfig DefaultConfig() + { + var config = new DarkKnightConfig(); + + config.ManaBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.ManaBar.UsePartialFillColor = true; + + config.DarksideBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.DarksideBar.ThresholdConfig.Enabled = true; + + config.BloodGauge.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.BloodGauge.UsePartialFillColor = true; + + return config; + } + + [NestedConfig("Mana Bar", 30)] + public DarkKnightManaBarConfig ManaBar = new DarkKnightManaBarConfig( + new Vector2(0, -61), + new Vector2(254, 10), + new PluginConfigColor(new Vector4(0f / 255f, 162f / 255f, 252f / 255f, 100f / 100f)) + ); + + [NestedConfig("Blood Gauge", 35)] + public ChunkedProgressBarConfig BloodGauge = new ChunkedProgressBarConfig( + new Vector2(0, -49), + new Vector2(254, 10), + new PluginConfigColor(new Vector4(216f / 255f, 0f / 255f, 73f / 255f, 100f / 100f)), + 2, + new PluginConfigColor(new Vector4(180f / 255f, 180f / 255f, 180f / 255f, 100f / 100f)) + ); + + [NestedConfig("Darkside Bar", 40)] + public ProgressBarConfig DarksideBar = new ProgressBarConfig( + new Vector2(0, -73), + new Vector2(254, 10), + new PluginConfigColor(new Vector4(209f / 255f, 38f / 255f, 73f / 204f, 100f / 100f)), + BarDirection.Right, + new PluginConfigColor(new Vector4(160f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)), + 5 + ); + + [NestedConfig("Blood Weapon Bar", 45)] + public DarkKnightChunkedBarConfig BloodWeaponBar = new DarkKnightChunkedBarConfig( + new Vector2(-64, -32), + new Vector2(126, 20), + new PluginConfigColor(new Vector4(160f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Delirium Bar", 50)] + public DarkKnightChunkedBarConfig DeliriumBar = new DarkKnightChunkedBarConfig( + new Vector2(64, -32), + new Vector2(126, 20), + new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Living Shadow Bar", 55)] + public ProgressBarConfig LivingShadowBar = new ProgressBarConfig( + new Vector2(0, -10), + new Vector2(254, 20), + new PluginConfigColor(new Vector4(255f / 255f, 105f / 255f, 205f / 255f, 100f / 100f)) + ); + } + + [Exportable(false)] + public class DarkKnightManaBarConfig : ChunkedProgressBarConfig + { + [ColorEdit4("Dark Arts Color" + "##MP")] + [Order(26)] + public PluginConfigColor DarkArtsColor = new PluginConfigColor(new Vector4(210f / 255f, 33f / 255f, 33f / 255f, 100f / 100f)); + + [Checkbox("Show mana values up to 10k (will break thresholds)", spacing = true)] + [Order(27)] + public bool ShowFullMana = false; + + public DarkKnightManaBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + [DisableParentSettings("UseChunks", "LabelMode")] + public class DarkKnightChunkedBarConfig : ChunkedProgressBarConfig + { + public DarkKnightChunkedBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } +} diff --git a/Interface/Jobs/DragoonHud.cs b/Interface/Jobs/DragoonHud.cs new file mode 100644 index 0000000..446f20e --- /dev/null +++ b/Interface/Jobs/DragoonHud.cs @@ -0,0 +1,172 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class DragoonHud : JobHud + { + private new DragoonConfig Config => (DragoonConfig)_config; + + private static readonly List ChaosThrustIDs = new() { 118, 1312, 2719 }; + private static readonly List ChaosThrustDurations = new() { 24, 24, 24 }; + + public DragoonHud(DragoonConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.ChaosThrustBar.Enabled) + { + positions.Add(Config.Position + Config.ChaosThrustBar.Position); + sizes.Add(Config.ChaosThrustBar.Size); + } + + if (Config.PowerSurgeBar.Enabled) + { + positions.Add(Config.Position + Config.PowerSurgeBar.Position); + sizes.Add(Config.PowerSurgeBar.Size); + } + + + if (Config.FirstmindsFocusBar.Enabled) + { + positions.Add(Config.Position + Config.FirstmindsFocusBar.Position); + sizes.Add(Config.FirstmindsFocusBar.Size); + } + + if (Config.LifeOfTheDragonBar.Enabled) + { + positions.Add(Config.Position + Config.LifeOfTheDragonBar.Position); + sizes.Add(Config.LifeOfTheDragonBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + var position = origin + Config.Position; + if (Config.ChaosThrustBar.Enabled) + { + DrawChaosThrustBar(position, player); + } + + if (Config.PowerSurgeBar.Enabled) + { + DrawPowerSurgeBar(position, player); + } + + if (Config.FirstmindsFocusBar.Enabled) + { + DrawFirstmindsFocusBars(position, player); + } + + if (Config.LifeOfTheDragonBar.Enabled) + { + DrawBloodOfTheDragonBar(position, player); + } + } + + private void DrawChaosThrustBar(Vector2 origin, IPlayerCharacter player) + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.ChaosThrustBar, player, target, ChaosThrustIDs, ChaosThrustDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.ChaosThrustBar.StrataLevel)); + } + } + + private void DrawFirstmindsFocusBars(Vector2 origin, IPlayerCharacter player) + { + DRGGauge gauge = Plugin.JobGauges.Get(); + + if (!Config.FirstmindsFocusBar.HideWhenInactive || gauge.FirstmindsFocusCount > 0) + { + BarHud[] bars = BarUtilities.GetChunkedBars(Config.FirstmindsFocusBar, 2, gauge.FirstmindsFocusCount, 2, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.FirstmindsFocusBar.StrataLevel)); + } + } + } + + private void DrawBloodOfTheDragonBar(Vector2 origin, IPlayerCharacter player) + { + DRGGauge gauge = Plugin.JobGauges.Get(); + float duration = gauge.LOTDTimer / 1000f; + + if (!Config.LifeOfTheDragonBar.HideWhenInactive || duration > 0f) + { + Config.LifeOfTheDragonBar.Label.SetValue(duration); + + BarHud bar = BarUtilities.GetProgressBar(Config.LifeOfTheDragonBar, duration, 20, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.LifeOfTheDragonBar.StrataLevel)); + } + } + + private void DrawPowerSurgeBar(Vector2 origin, IPlayerCharacter player) + { + var duration = Math.Abs(Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 2720)?.RemainingTime ?? 0f); + if (!Config.PowerSurgeBar.HideWhenInactive || duration > 0f) + { + Config.PowerSurgeBar.Label.SetValue(duration); + + BarHud bar = BarUtilities.GetProgressBar(Config.PowerSurgeBar, duration, 30f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.PowerSurgeBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Melee", 0)] + [SubSection("Dragoon", 1)] + public class DragoonConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.DRG; + public new static DragoonConfig DefaultConfig() { return new DragoonConfig(); } + + [NestedConfig("Chaos Thrust", 30)] + public ProgressBarConfig ChaosThrustBar = new ProgressBarConfig( + new(0, -76), + new(254, 20), + new(new Vector4(217f / 255f, 145f / 255f, 232f / 255f, 100f / 100f)) + ); + + [NestedConfig("Power Surge", 35)] + public ProgressBarConfig PowerSurgeBar = new ProgressBarConfig( + new(0, -54), + new(254, 20), + new(new Vector4(244f / 255f, 206f / 255f, 191f / 255f, 100f / 100f)) + ); + + [NestedConfig("Firstminds' Focus", 40)] + public ChunkedBarConfig FirstmindsFocusBar = new ChunkedBarConfig( + new(64, -32), + new(126, 20), + new PluginConfigColor(new(134f / 255f, 120f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Life of the Dragon", 45)] + public ProgressBarConfig LifeOfTheDragonBar = new ProgressBarConfig( + new(0, -10), + new(254, 20), + new(new Vector4(185f / 255f, 0f / 255f, 25f / 255f, 100f / 100f)) + ); + } +} diff --git a/Interface/Jobs/GatherersConfig.cs b/Interface/Jobs/GatherersConfig.cs new file mode 100644 index 0000000..2b45946 --- /dev/null +++ b/Interface/Jobs/GatherersConfig.cs @@ -0,0 +1,31 @@ +using HSUI.Helpers; +using Newtonsoft.Json; + +namespace HSUI.Interface.Jobs +{ + public class GatherersConfig : JobConfig + { + public override uint JobId => 0; + + public GatherersConfig() + { + UseDefaultPrimaryResourceBar = true; + PrimaryResourceType = PrimaryResourceTypes.GP; + } + } + + public class MinerConfig : GatherersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.MIN; + } + + public class BotanistConfig : GatherersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.BOT; + } + + public class FisherConfig : GatherersConfig + { + [JsonIgnore] public override uint JobId => JobIDs.FSH; + } +} diff --git a/Interface/Jobs/GunbreakerHud.cs b/Interface/Jobs/GunbreakerHud.cs new file mode 100644 index 0000000..85ae243 --- /dev/null +++ b/Interface/Jobs/GunbreakerHud.cs @@ -0,0 +1,129 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class GunbreakerHud : JobHud + { + private new GunbreakerConfig Config => (GunbreakerConfig)_config; + + public GunbreakerHud(GunbreakerConfig config, string? displayName = null) : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.PowderGauge.Enabled) + { + positions.Add(Config.Position + Config.PowderGauge.Position); + sizes.Add(Config.PowderGauge.Size); + } + + if (Config.NoMercy.Enabled) + { + positions.Add(Config.Position + Config.NoMercy.Position); + sizes.Add(Config.NoMercy.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + if (Config.PowderGauge.Enabled) + { + DrawPowderGauge(origin + Config.Position, player); + } + + if (Config.NoMercy.Enabled) + { + DrawNoMercyBar(origin + Config.Position, player); + } + } + + private void DrawPowderGauge(Vector2 origin, IPlayerCharacter player) + { + GNBGauge gauge = Plugin.JobGauges.Get(); + if (Config.PowderGauge.HideWhenInactive && gauge.Ammo == 0) + { + return; + } + + PluginConfigColor mainColor = Config.PowderGauge.FillColor; + PluginConfigColor extraColor = Config.PowderGauge.BloodfestExtraCartridgesColor; + int maxCartridges = player.Level >= 88 ? 3 : 2; + + List> chunks = new(); + for (int i = 1; i < maxCartridges + 1; i++) + { + PluginConfigColor color = (gauge.Ammo < i || gauge.Ammo - maxCartridges < i) ? mainColor : extraColor; + chunks.Add(new(color, i <= gauge.Ammo ? 1 : 0, null)); + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.PowderGauge, chunks.ToArray(), player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.PowderGauge.StrataLevel)); + } + } + + private void DrawNoMercyBar(Vector2 origin, IPlayerCharacter player) + { + float noMercyDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId == 1831 && o.RemainingTime > 0f)?.RemainingTime ?? 0f; + if (Config.NoMercy.HideWhenInactive && noMercyDuration <= 0) + { + return; + } + + Config.NoMercy.Label.SetValue(noMercyDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.NoMercy, noMercyDuration, 20f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.NoMercy.StrataLevel)); + } + } + + [Section("Job Specific Bars")] + [SubSection("Tank", 0)] + [SubSection("Gunbreaker", 1)] + public class GunbreakerConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.GNB; + public new static GunbreakerConfig DefaultConfig() { return new GunbreakerConfig(); } + + [NestedConfig("Powder Gauge", 30)] + public PowderGauge PowderGauge = new PowderGauge( + new(0, -32), + new(254, 20), + new(new Vector4(0f / 255f, 162f / 255f, 252f / 255f, 1f)) + ); + + [NestedConfig("No Mercy", 35)] + public ProgressBarConfig NoMercy = new ProgressBarConfig( + new(0, -10), + new(254, 20), + new(new Vector4(252f / 255f, 204f / 255f, 255f / 255f, 1f)) + ); + } + + public class PowderGauge : ChunkedBarConfig + { + [ColorEdit4("Bloodfest Extra Cartridges Color")] + [Order(26)] + public PluginConfigColor BloodfestExtraCartridgesColor = new(new Vector4(240f / 255f, 200f / 255f, 0, 1)); + + public PowderGauge(Vector2 position, Vector2 size, PluginConfigColor fillColor, int padding = 2) : base(position, size, fillColor, padding) + { + } + } +} \ No newline at end of file diff --git a/Interface/Jobs/JobConfig.cs b/Interface/Jobs/JobConfig.cs new file mode 100644 index 0000000..8ef0de6 --- /dev/null +++ b/Interface/Jobs/JobConfig.cs @@ -0,0 +1,41 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using Newtonsoft.Json; +using System; +using System.Reflection; + +namespace HSUI.Interface.Jobs +{ + public abstract class JobConfig : MovablePluginConfigObject + { + [JsonIgnore] + public abstract uint JobId { get; } + + [Checkbox("Show Generic Mana Bar")] + [Order(20)] + public bool UseDefaultPrimaryResourceBar = false; + + [NestedConfig("Visibility", 2000)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + [JsonIgnore] + public PrimaryResourceTypes PrimaryResourceType = PrimaryResourceTypes.MP; + + public new static JobConfig? DefaultConfig() + { + var type = MethodBase.GetCurrentMethod()?.DeclaringType; + if (type is null) + { + return null; + } + + return (JobConfig?)Activator.CreateInstance(type); + } + + public JobConfig() + { + Position.Y = HUDConstants.JobHudsBaseY; + } + } +} diff --git a/Interface/Jobs/JobHud.cs b/Interface/Jobs/JobHud.cs new file mode 100644 index 0000000..7aa39ab --- /dev/null +++ b/Interface/Jobs/JobHud.cs @@ -0,0 +1,37 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class JobHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig + { + protected IDalamudPluginInterface PluginInterface => Plugin.PluginInterface; + + public JobConfig Config => (JobConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + + public IGameObject? Actor { get; set; } = null; + protected IPlayerCharacter? Player => Actor is IPlayerCharacter ? (IPlayerCharacter)Actor : null; + + public JobHud(JobConfig config, string? displayName = null) : base(config, displayName) + { + } + + public override void DrawChildren(Vector2 origin) + { + if (Player == null || !_config.Enabled) + { + return; + } + + DrawJobHud(origin, Player); + } + + public virtual void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + // override + } + } +} diff --git a/Interface/Jobs/MachinistHud.cs b/Interface/Jobs/MachinistHud.cs new file mode 100644 index 0000000..849e612 --- /dev/null +++ b/Interface/Jobs/MachinistHud.cs @@ -0,0 +1,257 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Logging; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class MachinistHud : JobHud + { + private bool _robotMaxDurationSet; + private float _robotMaxDuration; + + private new MachinistConfig Config => (MachinistConfig)_config; + + public MachinistHud(MachinistConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.OverheatChunkedGauge.Enabled) + { + positions.Add(Config.Position + Config.OverheatChunkedGauge.Position); + sizes.Add(Config.OverheatChunkedGauge.Size); + } + + if (Config.HeatGauge.Enabled) + { + positions.Add(Config.Position + Config.HeatGauge.Position); + sizes.Add(Config.HeatGauge.Size); + } + + if (Config.BatteryGauge.Enabled) + { + positions.Add(Config.Position + Config.BatteryGauge.Position); + sizes.Add(Config.BatteryGauge.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.OverheatChunkedGauge.Enabled) + { + DrawOverheatBar(pos, player); + } + + if (Config.HeatGauge.Enabled) + { + DrawHeatGauge(pos, player); + } + + if (Config.BatteryGauge.Enabled) + { + DrawBatteryGauge(pos, player); + } + + if (Config.AutomatonBar.Enabled) + { + DrawAutomatonBar(pos, player); + } + + if (Config.WildfireBar.Enabled) + { + DrawWildfireBar(pos, player); + } + } + + private void DrawHeatGauge(Vector2 origin, IPlayerCharacter player) + { + MCHGauge gauge = Plugin.JobGauges.Get(); + + if (!Config.HeatGauge.HideWhenInactive || gauge.Heat > 0) + { + Config.HeatGauge.Label.SetValue(gauge.Heat); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.HeatGauge, 2, gauge.Heat, 100, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.HeatGauge.StrataLevel)); + } + } + } + + private void DrawBatteryGauge(Vector2 origin, IPlayerCharacter player) + { + MCHGauge gauge = Plugin.JobGauges.Get(); + + if (!Config.BatteryGauge.HideWhenInactive || gauge.Battery > 0) + { + Config.BatteryGauge.Label.SetValue(gauge.Battery); + + BarHud bar = BarUtilities.GetProgressBar(Config.BatteryGauge, gauge.Battery, 100f, 0f, player, Config.BatteryGauge.BatteryColor); + AddDrawActions(bar.GetDrawActions(origin, Config.BatteryGauge.StrataLevel)); + } + } + + private void DrawAutomatonBar(Vector2 origin, IPlayerCharacter player) + { + MCHGauge gauge = Plugin.JobGauges.Get(); + + if (!gauge.IsRobotActive && _robotMaxDurationSet) + { + _robotMaxDurationSet = false; + } + + if (!Config.AutomatonBar.HideWhenInactive || gauge.IsRobotActive) + { + float remaining = gauge.SummonTimeRemaining / 1000f; + if (!_robotMaxDurationSet && gauge.IsRobotActive) + { + _robotMaxDuration = remaining; + _robotMaxDurationSet = true; + } + + Config.AutomatonBar.Label.SetValue(remaining); + + BarHud bar = BarUtilities.GetProgressBar(Config.AutomatonBar, remaining, _robotMaxDuration > 0 ? _robotMaxDuration : 1f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.AutomatonBar.StrataLevel)); + } + } + + private void DrawOverheatBar(Vector2 origin, IPlayerCharacter player) + { + MCHGauge gauge = Plugin.JobGauges.Get(); + ushort stackCount = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 2688)?.Param ?? 0; + + if (!Config.OverheatChunkedGauge.HideWhenInactive || stackCount > 0) + { + LabelConfig[]? labels = null; + if (Config.OverheatChunkedGauge.DurationLabel.Enabled) + { + Config.OverheatChunkedGauge.DurationLabel.SetValue(gauge.OverheatTimeRemaining / 1000f); + labels = new LabelConfig[5]; + labels[2] = Config.OverheatChunkedGauge.DurationLabel; + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.OverheatChunkedGauge, 5, stackCount, 5, labels: labels ); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.OverheatChunkedGauge.StrataLevel)); + } + } + } + + private void DrawWildfireBar(Vector2 origin, IPlayerCharacter player) + { + float wildfireDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1946)?.RemainingTime ?? 0f; + + if (!Config.WildfireBar.HideWhenInactive || wildfireDuration > 0) + { + Config.WildfireBar.Label.SetValue(wildfireDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.WildfireBar, wildfireDuration, 10, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.WildfireBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Ranged", 0)] + [SubSection("Machinist", 1)] + public class MachinistConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.MCH; + public new static MachinistConfig DefaultConfig() + { + var config = new MachinistConfig(); + + config.HeatGauge.UsePartialFillColor = true; + + return config; + } + + [NestedConfig("Overheat Gauge", 30)] + public MachinistOverheatBarConfig OverheatChunkedGauge = new MachinistOverheatBarConfig( + new Vector2(0, -54), + new Vector2(254, 20), + new PluginConfigColor(new Vector4(255f / 255f, 239f / 255f, 14f / 255f, 100f / 100f)) + ); + + [NestedConfig("Heat Gauge", 35)] + public ChunkedProgressBarConfig HeatGauge = new ChunkedProgressBarConfig( + new Vector2(0, -32), + new Vector2(254, 20), + new PluginConfigColor(new Vector4(201f / 255f, 13f / 255f, 13f / 255f, 100f / 100f)), + 2, + new PluginConfigColor(new Vector4(180f / 255f, 180f / 255f, 180f / 255f, 100f / 100f)) + ); + + [NestedConfig("Battery Gauge", 40)] + public MachinistBatteryGaugeConfig BatteryGauge = new MachinistBatteryGaugeConfig( + new Vector2(-31, -10), + new Vector2(192, 20), + new PluginConfigColor(new Vector4(0, 0, 0, 0)) + ); + + [NestedConfig("Automaton Queen Bar", 45)] + public ProgressBarConfig AutomatonBar = new ProgressBarConfig( + new Vector2(97, -10), + new Vector2(60, 20), + new PluginConfigColor(new Vector4(153f / 255f, 0f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Wildfire Bar", 50)] + public ProgressBarConfig WildfireBar = new ProgressBarConfig( + new Vector2(0, -76), + new Vector2(254, 20), + new PluginConfigColor(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)), + BarDirection.Right, + new PluginConfigColor(new Vector4(180f / 255f, 180f / 255f, 180f / 255f, 100f / 100f)), + 50 + ); + } + + [DisableParentSettings("FillColor")] + [Exportable(false)] + public class MachinistBatteryGaugeConfig : ProgressBarConfig + { + [ColorEdit4("Battery Color", spacing = true)] + [Order(55)] + public PluginConfigColor BatteryColor = new(new Vector4(106f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + + public MachinistBatteryGaugeConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class MachinistOverheatBarConfig : ChunkedBarConfig + { + [NestedConfig("Duration Text", 1000)] + public NumericLabelConfig DurationLabel; + + public MachinistOverheatBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + DurationLabel = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + DurationLabel.HideIfZero = true; + } + } +} \ No newline at end of file diff --git a/Interface/Jobs/MonkHud.cs b/Interface/Jobs/MonkHud.cs new file mode 100644 index 0000000..b354ff0 --- /dev/null +++ b/Interface/Jobs/MonkHud.cs @@ -0,0 +1,464 @@ +using Dalamud.Game.ClientState.JobGauge.Enums; +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class MonkHud : JobHud + { + private new MonkConfig Config => (MonkConfig)_config; + + private string[] _chunkTexts = new string[] { "I", "II", "III" }; + + public MonkHud(MonkConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.ChakraBar.Enabled) + { + positions.Add(Config.Position + Config.ChakraBar.Position); + sizes.Add(Config.ChakraBar.Size); + } + + if (Config.BeastChakraStacksBar.Enabled) + { + positions.Add(Config.Position + Config.BeastChakraStacksBar.Position); + sizes.Add(Config.BeastChakraStacksBar.Size); + } + + if (Config.MastersGauge.Enabled) + { + positions.Add(Config.Position + Config.MastersGauge.Position); + sizes.Add(Config.MastersGauge.Size); + } + + if (Config.StancesBar.Enabled) + { + positions.Add((Config.Position + Config.StancesBar.Position)); + sizes.Add(Config.StancesBar.Size); + } + + if (Config.PerfectBalanceBar.Enabled) + { + positions.Add(Config.Position + Config.PerfectBalanceBar.Position); + sizes.Add(Config.PerfectBalanceBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + var position = origin + Config.Position; + + if (Config.ChakraBar.Enabled) + { + DrawChakraGauge(position, player); + } + + if (Config.BeastChakraStacksBar.Enabled) + { + DrawBeastChakraStacksBar(position, player); + } + + if (Config.MastersGauge.Enabled) + { + DrawMastersGauge(position, player); + } + + if (Config.StancesBar.Enabled) + { + DrawFormsBar(position, player); + } + + if (Config.PerfectBalanceBar.Enabled) + { + DrawPerfectBalanceBar(position, player); + } + } + + private void DrawChakraGauge(Vector2 origin, IPlayerCharacter player) + { + MNKGauge gauge = Plugin.JobGauges.Get(); + if (Config.ChakraBar.HideWhenInactive && gauge.Chakra == 0) + { + return; + } + + PluginConfigColor mainColor = Config.ChakraBar.FillColor; + PluginConfigColor extraColor = Config.ChakraBar.BrotherhoodExtraCharkaColor; + + List> chunks = new(); + for (int i = 1; i < 6; i++) + { + PluginConfigColor color = (gauge.Chakra < i || gauge.Chakra - 5 < i) ? mainColor : extraColor; + chunks.Add(new(color, i <= gauge.Chakra ? 1 : 0, null)); + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.ChakraBar, chunks.ToArray(), player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.ChakraBar.StrataLevel)); + } + } + + private unsafe void DrawBeastChakraStacksBar(Vector2 origin, IPlayerCharacter player) + { + MonkBeastChakraStacksBar config = Config.BeastChakraStacksBar; + MNKGauge gauge = Plugin.JobGauges.Get(); + int stacks = gauge.OpoOpoFury + gauge.RaptorFury + gauge.CoeurlFury; + + if (config.HideWhenInactive && stacks == 0) + { + return; + } + + PluginConfigColor empty = PluginConfigColor.Empty; + Tuple[] chunks = + [ + new(gauge.OpoOpoFury > 0 ? config.OpoopoColor : empty, 1, null), + new(gauge.RaptorFury > 0 ? config.RaptorColor : empty, 1, null), + new(gauge.CoeurlFury > 0 ? config.CoeurlColor : empty, 1, null), + new(gauge.CoeurlFury > 1 ? config.CoeurlColor : empty, 1, null), + ]; + + BarHud[] bars = BarUtilities.GetChunkedBars(config, chunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe void DrawMastersGauge(Vector2 origin, IPlayerCharacter player) + { + MNKGauge gauge = Plugin.JobGauges.Get(); + + if (Config.MastersGauge.HideWhenInactive && + gauge.Nadi == Nadi.None && + gauge.BeastChakra[0] == BeastChakra.None && + gauge.BeastChakra[1] == BeastChakra.None && + gauge.BeastChakra[2] == BeastChakra.None) + { + return; + } + + int[] order = Config.MastersGauge.ChakraOrder; + int[] hasChakra = + [ + gauge.Nadi.HasFlag(Nadi.Lunar) ? 1 : 0, + gauge.BeastChakra[0] != BeastChakra.None ? 1 : 0, + gauge.BeastChakra[0] != BeastChakra.None ? 1 : 0, + gauge.BeastChakra[0] != BeastChakra.None ? 1 : 0, + gauge.Nadi.HasFlag(Nadi.Solar) ? 1 : 0, + ]; + + PluginConfigColor[] colors = new[] + { + Config.MastersGauge.LunarNadiColor, + GetChakraColor(gauge.BeastChakra[0]), + GetChakraColor(gauge.BeastChakra[1]), + GetChakraColor(gauge.BeastChakra[2]), + Config.MastersGauge.SolarNadiColor + }; + + var chunks = new Tuple[5]; + for (int i = 0; i < chunks.Length; i++) + { + chunks[i] = new(colors[order[i]], hasChakra[order[i]], i == 2 ? Config.MastersGauge.BlitzTimerLabel : null); + } + + Config.MastersGauge.BlitzTimerLabel.SetValue(gauge.BlitzTimeRemaining / 1000); + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.MastersGauge, chunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.MastersGauge.StrataLevel)); + } + } + + private void DrawFormsBar(Vector2 origin, IPlayerCharacter player) + { + // formless fist + IStatus? formlessFist = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId == 2513); + if (formlessFist != null) + { + float remaining = Math.Abs(formlessFist.RemainingTime); + + BarHud bar = BarUtilities.GetProgressBar( + Config.StancesBar, + null, + new LabelConfig[] { Config.StancesBar.FormlessFistLabel }, + remaining, + 30f, + 0, + player, + Config.StancesBar.FormlessFistColor + ); + + Config.StancesBar.FormlessFistLabel.SetValue(remaining); + + AddDrawActions(bar.GetDrawActions(origin, Config.StancesBar.StrataLevel)); + return; + } + + // forms + IStatus? form = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 107 or 108 or 109); + if (Config.StancesBar.HideWhenInactive && form is null) + { + return; + } + + int activeFormIndex = form != null ? (int)form.StatusId - 107 : -1; + PluginConfigColor[] chunkColors = new PluginConfigColor[] + { + Config.StancesBar.OpoOpoColor, + Config.StancesBar.RaptorColor, + Config.StancesBar.CoeurlColor + }; + + LabelConfig[] chunkLabels = new LabelConfig[] + { + Config.StancesBar.FormLabel.Clone(0), + Config.StancesBar.FormLabel.Clone(1), + Config.StancesBar.FormLabel.Clone(2) + }; + + var chunks = new Tuple[3]; + for (int i = 0; i < chunks.Length; i++) + { + LabelConfig label = chunkLabels[i]; + label.SetText(_chunkTexts[i]); + + chunks[i] = new(chunkColors[i], activeFormIndex == i ? 1 : 0, label); + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.StancesBar, chunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.StancesBar.StrataLevel)); + } + } + + private void DrawPerfectBalanceBar(Vector2 origin, IPlayerCharacter player) + { + IStatus? perfectBalance = Utils.StatusListForBattleChara(player).Where(o => o.StatusId is 110 && o.RemainingTime > 0f).FirstOrDefault(); + float duration = perfectBalance?.RemainingTime ?? 0f; + float stacks = perfectBalance?.Param ?? 0f; + + if (Config.PerfectBalanceBar.HideWhenInactive && duration <= 0) + { + return; + } + + Tuple[] chunks = new Tuple[3]; + for (int i = 0; i < chunks.Length; i++) + { + chunks[i] = new( + Config.PerfectBalanceBar.FillColor, + i < stacks ? 1f : 0, + i == 1 ? Config.PerfectBalanceBar.PerfectBalanceLabel : null + ); + } + + Config.PerfectBalanceBar.PerfectBalanceLabel.SetValue(duration); + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.PerfectBalanceBar, chunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.PerfectBalanceBar.StrataLevel)); + } + } + + private PluginConfigColor GetChakraColor(BeastChakra chakra) => chakra switch + { + BeastChakra.OpoOpo => Config.MastersGauge.OpoopoChakraColor, + BeastChakra.Raptor => Config.MastersGauge.RaptorChakraColor, + BeastChakra.Coeurl => Config.MastersGauge.CoeurlChakraColor, + _ => new PluginConfigColor(new(0, 0, 0, 0)) + }; + } + + [Section("Job Specific Bars")] + [SubSection("Melee", 0)] + [SubSection("Monk", 1)] + public class MonkConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.MNK; + + public new static MonkConfig DefaultConfig() + { + var config = new MonkConfig(); + + config.StancesBar.Enabled = false; + config.MastersGauge.BlitzTimerLabel.HideIfZero = true; + config.PerfectBalanceBar.PerfectBalanceLabel.HideIfZero = true; + + return config; + } + + [NestedConfig("Chakra Bar", 35)] + public ChakraBar ChakraBar = new ChakraBar( + new(0, -32), + new(254, 20), + new(new Vector4(204f / 255f, 115f / 255f, 0f, 100f / 100f)) + ); + + [NestedConfig("Fury Stacks Bar", 40)] + public MonkBeastChakraStacksBar BeastChakraStacksBar = new MonkBeastChakraStacksBar( + new(0, -32), + new(254, 20) + ); + + [NestedConfig("Masterful Blitz Bar", 45)] + public MastersGauge MastersGauge = new MastersGauge( + new(0, -54), + new(254, 20) + ); + + [NestedConfig("Forms Bar", 50)] + public MonkStancesBarConfig StancesBar = new MonkStancesBarConfig( + new(0, -98), + new(254, 20) + ); + + [NestedConfig("Perfect Balance Bar", 55)] + public PerfectBalanceBar PerfectBalanceBar = new PerfectBalanceBar( + new(0, -76), + new(254, 20), + new(new Vector4(150f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)) + ); + } + + public class ChakraBar: ChunkedBarConfig + { + [ColorEdit4("Brotherhood Extra Chakra Color")] + [Order(26)] + public PluginConfigColor BrotherhoodExtraCharkaColor = new(new Vector4(204f / 255f, 0, 0, 1)); + + public ChakraBar(Vector2 position, Vector2 size, PluginConfigColor fillColor, int padding = 2) : base(position, size, fillColor, padding) + { + } + } + + public class PerfectBalanceBar : ChunkedBarConfig + { + [NestedConfig("Perfect Balance Duration Text", 50, spacing = true)] + public NumericLabelConfig PerfectBalanceLabel; + + public PerfectBalanceBar(Vector2 position, Vector2 size, PluginConfigColor fillColor, int padding = 2) : base(position, size, fillColor, padding) + { + PerfectBalanceLabel = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + } + } + + [DisableParentSettings("FillColor", "FillDirection")] + public class MonkBeastChakraStacksBar : ChunkedBarConfig + { + [ColorEdit4("Opo-opo Color")] + [Order(19)] + public PluginConfigColor OpoopoColor = PluginConfigColor.FromHex(0xFFFFB3D3); + + [ColorEdit4("Raptor Color")] + [Order(20)] + public PluginConfigColor RaptorColor = PluginConfigColor.FromHex(0xFFBF89E5); + + [ColorEdit4("Coeurl Color")] + [Order(21)] + public PluginConfigColor CoeurlColor = PluginConfigColor.FromHex(0xFF9AE7C0); + + public MonkBeastChakraStacksBar(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + } + } + + [DisableParentSettings("FillColor", "FillDirection")] + public class MastersGauge : ChunkedBarConfig + { + [ColorEdit4("Lunar Nadi Color")] + [Order(19)] + public PluginConfigColor LunarNadiColor = PluginConfigColor.FromHex(0xFFDA87FF); + + [ColorEdit4("Solar Nadi Color")] + [Order(20)] + public PluginConfigColor SolarNadiColor = PluginConfigColor.FromHex(0xFFFFFFCA); + + [ColorEdit4("Opo-opo Color")] + [Order(21)] + public PluginConfigColor OpoopoChakraColor = PluginConfigColor.FromHex(0xFFC1527E); + + [ColorEdit4("Raptor Color")] + [Order(22)] + public PluginConfigColor RaptorChakraColor = PluginConfigColor.FromHex(0xFF8C67BA); + + [ColorEdit4("Coeurl Color")] + [Order(23)] + public PluginConfigColor CoeurlChakraColor = PluginConfigColor.FromHex(0xFF326D5A); + + [DragDropHorizontal("Chakra Order", "Lunar Nadi", "Chakra 1", "Chakra 2", "Chakra 3", "Solar Nadi")] + [Order(24)] + public int[] ChakraOrder = new int[] { 0, 1, 2, 3, 4 }; + + [NestedConfig("Blitz Timer Text", 50, spacing = true)] + public NumericLabelConfig BlitzTimerLabel; + + public MastersGauge(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + BlitzTimerLabel = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + } + } + + [DisableParentSettings("FillColor")] + public class MonkStancesBarConfig : ChunkedBarConfig + { + [ColorEdit4("Opo-opo Color")] + [Order(19)] + public PluginConfigColor OpoOpoColor = PluginConfigColor.FromHex(0xFFFFB3D3); + + [ColorEdit4("Raptor Color")] + [Order(20)] + public PluginConfigColor RaptorColor = PluginConfigColor.FromHex(0xFFBF89E5); + + [ColorEdit4("Coeurl Color")] + [Order(21)] + public PluginConfigColor CoeurlColor = PluginConfigColor.FromHex(0xFF9AE7C0); + + [ColorEdit4("Formless Fist Color")] + [Order(22)] + public PluginConfigColor FormlessFistColor = PluginConfigColor.FromHex(0xFF514793); + + [NestedConfig("Form Number Text", 500, spacing = true)] + public LabelConfig FormLabel; + + [NestedConfig("Formless Fist Duration Text", 1000, separator = false, spacing = true)] + public NumericLabelConfig FormlessFistLabel; + + public MonkStancesBarConfig(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + FormLabel = new LabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + + FormlessFistLabel = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + FormlessFistLabel.Enabled = false; + } + } +} diff --git a/Interface/Jobs/NinjaHud.cs b/Interface/Jobs/NinjaHud.cs new file mode 100644 index 0000000..58aef20 --- /dev/null +++ b/Interface/Jobs/NinjaHud.cs @@ -0,0 +1,341 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game.Gauge; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class NinjaHud : JobHud + { + private new NinjaConfig Config => (NinjaConfig)_config; + + public NinjaHud(NinjaConfig config, string? displayName = null) : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.KazematoiBar.Enabled) + { + positions.Add(Config.Position + Config.KazematoiBar.Position); + sizes.Add(Config.KazematoiBar.Size); + } + + if (Config.NinkiBar.Enabled) + { + positions.Add(Config.Position + Config.NinkiBar.Position); + sizes.Add(Config.NinkiBar.Size); + } + + if (Config.TrickAttackBar.Enabled) + { + positions.Add(Config.Position + Config.TrickAttackBar.Position); + sizes.Add(Config.TrickAttackBar.Size); + } + + if (Config.SuitonBar.Enabled) + { + positions.Add(Config.Position + Config.SuitonBar.Position); + sizes.Add(Config.SuitonBar.Size); + } + + if (Config.MudraBar.Enabled) + { + positions.Add(Config.Position + Config.MudraBar.Position); + sizes.Add(Config.MudraBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + var pos = origin + Config.Position; + if (Config.MudraBar.Enabled) + { + DrawMudraBars(pos, player); + } + + if (Config.KazematoiBar.Enabled) + { + DrawKazematoiBar(pos, player); + } + + if (Config.NinkiBar.Enabled) + { + DrawNinkiGauge(pos, player); + } + + if (Config.TrickAttackBar.Enabled) + { + DrawTrickAttackBar(pos, player); + } + + if (Config.SuitonBar.Enabled) + { + DrawSuitonBar(pos, player); + } + } + + public (bool, bool, bool) GetMudraBuffs(IPlayerCharacter? player, out IStatus? ninjutsuBuff, out IStatus? kassatsuBuff, out IStatus? tcjBuff) + { + ninjutsuBuff = null; + kassatsuBuff = null; + tcjBuff = null; + + if (player is not null) + { + var statusList = Utils.StatusListForBattleChara(player); + foreach (IStatus status in statusList) + { + if (status.StatusId == 496) { ninjutsuBuff = status; } + if (status.StatusId == 497) { kassatsuBuff = status; } + if (status.StatusId == 1186) { tcjBuff = status; } + } + } + + return (ninjutsuBuff is not null, kassatsuBuff is not null, tcjBuff is not null); + } + + private void DrawMudraBars(Vector2 origin, IPlayerCharacter player) + { + var (hasNinjutsuBuff, hasKassatsuBuff, hasTCJBuff) = GetMudraBuffs(player, out IStatus? ninjutsuBuff, out IStatus? kassatsuBuff, out IStatus? tcjBuff); + + int mudraStacks = SpellHelper.Instance.GetStackCount(2, 2259); + float mudraCooldown = SpellHelper.Instance.GetSpellCooldown(2259); + + float current = 0f; + float max = 0f; + + // For some reason, the mudras may be on cooldown before the "Mudra" buff is applied. + // Mudra stack count is set to -2 when a mudra is in the middle of its re-cast timer, so we can check for that instead. + bool inNinjutsu = mudraStacks == -2 || hasNinjutsuBuff; + + if (hasTCJBuff || hasKassatsuBuff || inNinjutsu) + { + if (hasTCJBuff) + { + max = 6f; + current = tcjBuff is null || tcjBuff.RemainingTime < 0 ? max : tcjBuff.RemainingTime; + Config.MudraBar.Label.SetText(GenerateNinjutsuText(tcjBuff?.Param ?? 0, hasKassatsuBuff, hasTCJBuff)); + } + else if (hasKassatsuBuff) + { + max = 15f; + current = kassatsuBuff is null || kassatsuBuff.RemainingTime < 0 ? max : kassatsuBuff.RemainingTime; + Config.MudraBar.Label.SetText("KASSATSU"); + } + + if (inNinjutsu) + { + max = 6f; + current = ninjutsuBuff is null || ninjutsuBuff.RemainingTime < 0 ? max : ninjutsuBuff.RemainingTime; + Config.MudraBar.Label.SetText(GenerateNinjutsuText(ninjutsuBuff?.Param ?? 0, hasKassatsuBuff, hasTCJBuff)); + } + + PluginConfigColor fillColor = hasTCJBuff ? Config.MudraBar.TCJBarColor : hasKassatsuBuff ? Config.MudraBar.KassatsuBarColor : Config.MudraBar.FillColor; + Rect foreground = BarUtilities.GetFillRect(Config.MudraBar.Position, Config.MudraBar.Size, Config.MudraBar.FillDirection, fillColor, current, max); + + BarHud bar = new BarHud(Config.MudraBar, player); + bar.AddForegrounds(foreground); + bar.AddLabels(Config.MudraBar.Label); + + AddDrawActions(bar.GetDrawActions(origin, Config.MudraBar.StrataLevel)); + } + else + { + max = 40f; + current = max - mudraCooldown; + + if (!Config.MudraBar.HideWhenInactive || current < max) + { + Config.MudraBar.Label.SetValue((max - current) % 20); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.MudraBar, 2, current, max, 0f, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.MudraBar.StrataLevel)); + } + } + } + } + + private unsafe void DrawKazematoiBar(Vector2 origin, IPlayerCharacter player) + { + NinjaGauge* gauge = (NinjaGauge*)Plugin.JobGauges.Get().Address; + int stacks = gauge->Kazematoi; + + if (Config.KazematoiBar.HideWhenInactive && stacks == 0) + { + return; + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.KazematoiBar, 5, stacks, 5, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.KazematoiBar.StrataLevel)); + } + } + + private unsafe void DrawNinkiGauge(Vector2 origin, IPlayerCharacter player) + { + NinjaGauge* gauge = (NinjaGauge*)Plugin.JobGauges.Get().Address; + int ninki = gauge->Ninki; + + if (Config.NinkiBar.HideWhenInactive && ninki == 0) + { + return; + } + + Config.NinkiBar.Label.SetValue(ninki); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.NinkiBar, 2, ninki, 100, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.NinkiBar.StrataLevel)); + } + } + + private void DrawTrickAttackBar(Vector2 origin, IPlayerCharacter player) + { + IGameObject? actor = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + float trickDuration = Utils.StatusListForActor(actor).FirstOrDefault( + o => o.StatusId is 3254 or 3906 && o.SourceId == player.GameObjectId && o.RemainingTime > 0 + )?.RemainingTime ?? 0f; + + if (Config.TrickAttackBar.HideWhenInactive && trickDuration == 0) + { + return; + } + + Config.TrickAttackBar.Label.SetValue(trickDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.TrickAttackBar, trickDuration, 15f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.TrickAttackBar.StrataLevel)); + } + + private void DrawSuitonBar(Vector2 origin, IPlayerCharacter player) + { + float suitonDuration = Utils.StatusListForBattleChara(player).FirstOrDefault( + o => o.StatusId == 3848 && o.RemainingTime > 0 + )?.RemainingTime ?? 0f; + + if (Config.SuitonBar.HideWhenInactive && suitonDuration == 0) + { + return; + } + + Config.SuitonBar.Label.SetValue(suitonDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.SuitonBar, suitonDuration, 20f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.SuitonBar.StrataLevel)); + } + + private string GenerateNinjutsuText(ushort param, bool haveKassatsuBuff, bool haveTCJBuff) + { + return param switch + { + 1 or 2 or 3 => "FUMA SHURIKEN", + 6 or 7 => haveKassatsuBuff ? "GOKA MEKKYAKU" : "KATON", + 9 or 11 => "RAITON", + 13 or 14 => haveKassatsuBuff ? "HYOSHO RANRYU" : "HYOTON", + 27 or 30 => "HUTON", + 39 or 45 => "DOTON", + 54 or 57 => "SUITON", + _ => haveTCJBuff ? "TEN CHI JIN" : "", + }; + } + } + + [Section("Job Specific Bars")] + [SubSection("Melee", 0)] + [SubSection("Ninja", 1)] + public class NinjaConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.NIN; + + public new static NinjaConfig DefaultConfig() + { + var config = new NinjaConfig(); + + config.MudraBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.MudraBar.LabelMode = LabelMode.ActiveChunk; + + config.TrickAttackBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.TrickAttackBar.Enabled = false; + + config.SuitonBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.SuitonBar.Enabled = false; + + config.NinkiBar.UsePartialFillColor = true; + + return config; + } + + [NestedConfig("Mudra Bar", 30)] + public NinjaMudraBarConfig MudraBar = new NinjaMudraBarConfig( + new(0, -50), + new(254, 10), + new PluginConfigColor(new Vector4(211f / 255f, 166f / 255f, 75f / 242f, 100f / 100f)) + ); + + [NestedConfig("Kazematoi Bar", 35)] + public ChunkedBarConfig KazematoiBar = new ChunkedBarConfig( + new(0, -10), + new(254, 20), + PluginConfigColor.FromHex(0xFFABB6BD) + ); + + + [NestedConfig("Ninki Bar", 40)] + public ChunkedProgressBarConfig NinkiBar = new ChunkedProgressBarConfig( + new(0, -32), + new(254, 20), + new PluginConfigColor(new Vector4(137f / 255f, 82f / 255f, 236f / 255f, 100f / 100f)) + ); + + [NestedConfig("Trick Attack Bar", 45)] + public ProgressBarConfig TrickAttackBar = new ProgressBarConfig( + new(0, -63), + new(254, 10), + new PluginConfigColor(new Vector4(191f / 255f, 40f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Shadow Walker Bar", 50)] + public ProgressBarConfig SuitonBar = new ProgressBarConfig( + new(0, -75), + new(254, 10), + new PluginConfigColor(new Vector4(202f / 255f, 228f / 255f, 246f / 242f, 100f / 100f)) + ); + } + + [Exportable(false)] + public class NinjaMudraBarConfig : ChunkedProgressBarConfig + { + [ColorEdit4("Kassatsu Color", spacing = true)] + [Order(60)] + public PluginConfigColor KassatsuBarColor = new(new Vector4(239 / 255f, 123 / 255f, 222 / 242f, 100f / 100f)); + + [ColorEdit4("Ten Chi Jin Color")] + [Order(65)] + public PluginConfigColor TCJBarColor = new(new Vector4(181 / 255f, 33 / 255f, 41 / 242f, 100f / 100f)); + + public NinjaMudraBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor, 2) + { + Label.Enabled = true; + UsePartialFillColor = true; + } + } +} diff --git a/Interface/Jobs/PaladinHud.cs b/Interface/Jobs/PaladinHud.cs new file mode 100644 index 0000000..b6aa4fb --- /dev/null +++ b/Interface/Jobs/PaladinHud.cs @@ -0,0 +1,164 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Security.Principal; + +namespace HSUI.Interface.Jobs +{ + public class PaladinHud : JobHud + { + private new PaladinConfig Config => (PaladinConfig)_config; + + public PaladinHud(PaladinConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.OathGauge.Enabled) + { + positions.Add(Config.Position + Config.OathGauge.Position); + sizes.Add(Config.OathGauge.Size); + } + + if (Config.FightOrFlightBar.Enabled) + { + positions.Add(Config.Position + Config.FightOrFlightBar.Position); + sizes.Add(Config.FightOrFlightBar.Size); + } + + if (Config.RequiescatStacksBar.Enabled) + { + positions.Add(Config.Position + Config.RequiescatStacksBar.Position); + sizes.Add(Config.RequiescatStacksBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.OathGauge.Enabled) + { + DrawOathGauge(pos, player); + } + + if (Config.FightOrFlightBar.Enabled) + { + DrawFightOrFlightBar(pos, player); + } + + if (Config.RequiescatStacksBar.Enabled) + { + DrawRequiescatBar(pos, player); + } + } + + private void DrawOathGauge(Vector2 origin, IPlayerCharacter player) + { + PLDGauge gauge = Plugin.JobGauges.Get(); + + if (!Config.OathGauge.HideWhenInactive || gauge.OathGauge > 0) + { + Config.OathGauge.Label.SetValue(gauge.OathGauge); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.OathGauge, 2, gauge.OathGauge, 100, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.OathGauge.StrataLevel)); + } + } + } + + private void DrawFightOrFlightBar(Vector2 origin, IPlayerCharacter player) + { + float fightOrFlightDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 76)?.RemainingTime ?? 0f; + + if (!Config.FightOrFlightBar.HideWhenInactive || fightOrFlightDuration > 0) + { + Config.FightOrFlightBar.Label.SetValue(fightOrFlightDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.FightOrFlightBar, fightOrFlightDuration, 20f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.FightOrFlightBar.StrataLevel)); + } + } + + private void DrawRequiescatBar(Vector2 origin, IPlayerCharacter player) + { + IStatus? requiescatBuff = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1368); + float requiescatDuration = Math.Max(0f, requiescatBuff?.RemainingTime ?? 0f); + int stacks = requiescatBuff?.Param ?? 0; + + if (!Config.RequiescatStacksBar.HideWhenInactive || requiescatDuration > 0) + { + Config.RequiescatStacksBar.Label.SetValue(requiescatDuration); + + LabelConfig[] labels = new LabelConfig[4]; + labels[2] = Config.RequiescatStacksBar.Label; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.RequiescatStacksBar, 4, stacks, 4, labels: labels); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.RequiescatStacksBar.StrataLevel)); + } + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Tank", 0)] + [SubSection("Paladin", 1)] + public class PaladinConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.PLD; + + public new static PaladinConfig DefaultConfig() + { + var config = new PaladinConfig(); + + config.UseDefaultPrimaryResourceBar = true; + config.OathGauge.UsePartialFillColor = true; + config.RequiescatStacksBar.Label.Enabled = true; + + return config; + } + + [NestedConfig("Oath Gauge", 35)] + public ChunkedProgressBarConfig OathGauge = new ChunkedProgressBarConfig( + new Vector2(0, -54), + new Vector2(254, 20), + new PluginConfigColor(new Vector4(24f / 255f, 80f / 255f, 175f / 255f, 100f / 100f)), + 2, + new PluginConfigColor(new Vector4(180f / 255f, 180f / 255f, 180f / 255f, 100f / 100f)) + ); + + [NestedConfig("Fight or Flight Bar", 40)] + public ProgressBarConfig FightOrFlightBar = new ProgressBarConfig( + new Vector2(-64, -32), + new Vector2(126, 20), + new PluginConfigColor(new Vector4(240f / 255f, 50f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Requiescat Bar", 45)] + public ChunkedProgressBarConfig RequiescatStacksBar = new ChunkedProgressBarConfig( + new Vector2(64, -32), + new Vector2(126, 20), + new PluginConfigColor(new Vector4(61f / 255f, 61f / 255f, 255f / 255f, 100f / 100f)) + ); + } +} diff --git a/Interface/Jobs/PictomancerHud.cs b/Interface/Jobs/PictomancerHud.cs new file mode 100644 index 0000000..966e0f6 --- /dev/null +++ b/Interface/Jobs/PictomancerHud.cs @@ -0,0 +1,537 @@ +using Dalamud.Game.ClientState.JobGauge.Enums; +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text.Json.Serialization; + +namespace HSUI.Interface.Jobs +{ + public class PictomancerHud : JobHud + { + private new PictomancerConfig Config => (PictomancerConfig)_config; + + private static PluginConfigColor EmptyColor => GlobalColors.Instance.EmptyColor; + + public PictomancerHud(PictomancerConfig config, string? displayName = null) : base(config, displayName) + { + + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.PaletteBar.Enabled) + { + positions.Add(Config.Position + Config.PaletteBar.Position); + sizes.Add(Config.PaletteBar.Size); + } + + if (Config.PaintBar.Enabled) + { + positions.Add(Config.Position + Config.PaintBar.Position); + sizes.Add(Config.PaintBar.Size); + } + + if (Config.CreatureCanvasBar.Enabled) + { + positions.Add(Config.Position + Config.CreatureCanvasBar.Position); + sizes.Add(Config.CreatureCanvasBar.Size); + } + + if (Config.WeaponCanvasBar.Enabled) + { + positions.Add(Config.Position + Config.WeaponCanvasBar.Position); + sizes.Add(Config.WeaponCanvasBar.Size); + } + + if (Config.LandscapeCanvasBar.Enabled) + { + positions.Add(Config.Position + Config.LandscapeCanvasBar.Position); + sizes.Add(Config.LandscapeCanvasBar.Size); + } + + if (Config.HammerTimeBar.Enabled) + { + positions.Add(Config.Position + Config.HammerTimeBar.Position); + sizes.Add(Config.HammerTimeBar.Size); + } + + if (Config.HyperphantasiaBar.Enabled) + { + positions.Add(Config.Position + Config.HyperphantasiaBar.Position); + sizes.Add(Config.HyperphantasiaBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.PaletteBar.Enabled) + { + DrawPaletteBar(pos, player); + } + + if (Config.PaintBar.Enabled) + { + DrawPaintBar(pos, player); + } + + if (Config.CreatureCanvasBar.Enabled) + { + DrawCreatureCanvasBar(pos, player); + } + + if (Config.WeaponCanvasBar.Enabled) + { + DrawWeaponCanvasBar(pos, player); + } + + if (Config.LandscapeCanvasBar.Enabled) + { + DrawLandscapeCanvasBar(pos, player); + } + + if (Config.HammerTimeBar.Enabled) + { + DrawHammerTimeBar(pos, player); + } + + if (Config.HyperphantasiaBar.Enabled) + { + DrawHyperphantasiaBar(pos, player); + } + } + + protected unsafe void DrawPaletteBar(Vector2 origin, IPlayerCharacter player) + { + PictomancerPaletteBarConfig config = Config.PaletteBar; + PCTGauge gauge = Plugin.JobGauges.Get(); + + if (config.HideWhenInactive && gauge.PalleteGauge == 0) + { + return; + } + + bool isSubstractive = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 3674) != null; + + config.Label = config.PaletteLabel; + config.PaletteLabel.SubtractiveMode = isSubstractive; + config.Label.SetValue(gauge.PalleteGauge); + + PluginConfigColor fillColor = isSubstractive ? config.SubtractiveColor : config.FillColor; + + BarHud[] bars = BarUtilities.GetChunkedProgressBars( + config, + 2, + gauge.PalleteGauge, + 100, + 0, + player, + fillColor: fillColor + ); + + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe void DrawPaintBar(Vector2 origin, IPlayerCharacter player) + { + PictomancerPaintBarConfig config = Config.PaintBar; + PCTGauge gauge = Plugin.JobGauges.Get(); + + if (config.HideWhenInactive && gauge.Paint == 0) + { + return; + } + + bool hasBlackPaint = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 3691) != null; + Tuple[] chunks = new Tuple[5]; + + for (int i = 0; i < 5; i++) + { + PluginConfigColor color = PluginConfigColor.Empty; + if (i < gauge.Paint) + { + color = i == gauge.Paint - 1 && hasBlackPaint ? config.BlackPaintColor : config.WhitePaintColor; + } + chunks[i] = new(color, 1, null); + } + + BarHud[] bars = BarUtilities.GetChunkedBars(config, chunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe void DrawCreatureCanvasBar(Vector2 origin, IPlayerCharacter player) + { + PictomancerCreatureCanvasBarConfig config = Config.CreatureCanvasBar; + PCTGauge gauge = Plugin.JobGauges.Get(); + + if (config.HideWhenInactive && !gauge.CreatureMotifDrawn && gauge.CreatureFlags != 0) + { + return; + } + + // canvas + PluginConfigColor? canvasColor = null; + if (gauge.CanvasFlags.HasFlag(CanvasFlags.Maw)) + { + canvasColor = config.FangsColor; + } + else if (gauge.CanvasFlags.HasFlag(CanvasFlags.Claw)) + { + canvasColor = config.ClawColor; + } + else if (gauge.CanvasFlags.HasFlag(CanvasFlags.Wing)) + { + canvasColor = config.WingsColor; + } + else if(gauge.CanvasFlags.HasFlag(CanvasFlags.Pom)) + { + canvasColor = config.PomColor; + } + Tuple canvas = new( + canvasColor ?? PluginConfigColor.Empty, + canvasColor != null ? 1 : 0, + null + ); + + // part drawing + PluginConfigColor? drawingColor = null; + if (gauge.CreatureFlags.HasFlag(CreatureFlags.Claw)) + { + drawingColor = config.ClawColor; + } + else if (config.ShowEmptyPomWings && + gauge.CreatureFlags.HasFlag(CreatureFlags.Pom) && + gauge.CreatureFlags.HasFlag(CreatureFlags.Wings)) + { + drawingColor = PluginConfigColor.Empty; + } + else if (gauge.CreatureFlags.HasFlag(CreatureFlags.Wings)) + { + drawingColor = config.WingsColor; + } + else if(gauge.CreatureFlags.HasFlag(CreatureFlags.Pom)) + { + drawingColor = config.PomColor; + } + Tuple drawing = new( + drawingColor ?? PluginConfigColor.Empty, + drawingColor != null ? 1 : 0, + null + ); + + // portrait + PluginConfigColor? portraitColor = null; + if (gauge.CreatureFlags.HasFlag(CreatureFlags.MooglePortait)) + { + portraitColor = config.MoogleColor; + } + else if (gauge.CreatureFlags.HasFlag(CreatureFlags.MadeenPortrait)) + { + portraitColor = config.MadeenColor; + } + Tuple portrait = new( + portraitColor ?? PluginConfigColor.Empty, + portraitColor != null ? 1 : 0, + null + ); + + var chunks = new List>(); + chunks.Add(canvas); + if (!config.DontShowDrawing) + { + chunks.Add(drawing); + } + chunks.Add(portrait); + + BarHud[] bars = BarUtilities.GetChunkedBars(config, chunks.ToArray(), player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe void DrawWeaponCanvasBar(Vector2 origin, IPlayerCharacter player) + { + ChunkedBarConfig config = Config.WeaponCanvasBar; + PCTGauge gauge = Plugin.JobGauges.Get(); + + bool active = gauge.CanvasFlags.HasFlag(CanvasFlags.Weapon); + + if (config.HideWhenInactive && !active) + { + return; + } + + BarHud[] bars = BarUtilities.GetChunkedBars(config, 1, active ? 1 : 0, 1, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe void DrawLandscapeCanvasBar(Vector2 origin, IPlayerCharacter player) + { + ChunkedBarConfig config = Config.LandscapeCanvasBar; + PCTGauge gauge = Plugin.JobGauges.Get(); + + bool active = gauge.CanvasFlags.HasFlag(CanvasFlags.Landscape); + + if (config.HideWhenInactive && !active) + { + return; + } + + BarHud[] bars = BarUtilities.GetChunkedBars(config, 1, active ? 1 : 0, 1, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe void DrawHammerTimeBar(Vector2 origin, IPlayerCharacter player) + { + StacksWithDurationBarConfig config = Config.HammerTimeBar; + IStatus? status = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 3680); + int stacks = status?.Param ?? 0; + float time = Math.Abs(status?.RemainingTime ?? 0f); + + if (config.HideWhenInactive && stacks == 0) + { + return; + } + + config.Label.SetValue(time); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(config, 3, stacks, 3, 0, player, forceLabelIndex: 1); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + + private unsafe void DrawHyperphantasiaBar(Vector2 origin, IPlayerCharacter player) + { + StacksWithDurationBarConfig config = Config.HyperphantasiaBar; + IStatus? status = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 3688); + int stacks = status?.Param ?? 0; + float time = Math.Abs(status?.RemainingTime ?? 0f); + + if (config.HideWhenInactive && stacks == 0) + { + return; + } + + config.Label.SetValue(time); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(config, 5, stacks, 5, 0, player, forceLabelIndex: 2); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, config.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Caster", 0)] + [SubSection("Pictomancer", 1)] + public class PictomancerConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.PCT; + + public new static PictomancerConfig DefaultConfig() + { + var config = new PictomancerConfig(); + config.PaletteBar.UseChunks = false; + config.PaletteBar.Label.Enabled = true; + + return config; + } + + [NestedConfig("Palette Bar", 30)] + public PictomancerPaletteBarConfig PaletteBar = new PictomancerPaletteBarConfig( + new Vector2(0, -10), + new Vector2(152, 18), + PluginConfigColor.FromHex(0xFFC8C89F) + ); + + [NestedConfig("Paint Bar", 35)] + public PictomancerPaintBarConfig PaintBar = new PictomancerPaintBarConfig( + new(0, -26), + new(254, 10) + ); + + [NestedConfig("Creature Canvas Bar", 40)] + public PictomancerCreatureCanvasBarConfig CreatureCanvasBar = new PictomancerCreatureCanvasBarConfig( + new(0, -38), + new(254, 10) + ); + + [NestedConfig("Weapon Canvas Bar", 40)] + public ChunkedBarConfig WeaponCanvasBar = new ChunkedBarConfig( + new(-102.5f, -10), + new(49, 18), + PluginConfigColor.FromHex(0xFFC5616C) + ); + + [NestedConfig("Landscape Canvas Bar", 40)] + public ChunkedBarConfig LandscapeCanvasBar = new ChunkedBarConfig( + new(102.5f, -10), + new(49, 18), + PluginConfigColor.FromHex(0xFF8690E5) + ); + + [NestedConfig("Hammer Time Bar", 40)] + public StacksWithDurationBarConfig HammerTimeBar = new StacksWithDurationBarConfig( + new(0, -50), + new(254, 10), + PluginConfigColor.FromHex(0xFFFFFFFF) + ); + + [NestedConfig("Hyperphantasia Bar", 45)] + public StacksWithDurationBarConfig HyperphantasiaBar = new StacksWithDurationBarConfig( + new(0, -50), + new(254, 10), + PluginConfigColor.FromHex(0xFFFFFFFF) + ); + } + + [DisableParentSettings("Label")] + [Exportable(false)] + public class PictomancerPaletteBarConfig : ChunkedProgressBarConfig + { + [ColorEdit4("Subtractive Fill Color")] + [Order(27)] + public PluginConfigColor SubtractiveColor = PluginConfigColor.FromHex(0xFFAF6BAE); + + [NestedConfig("Bar Text", 1001, separator = false, spacing = true)] + public PictomancerPaletteLabelConfig PaletteLabel = new PictomancerPaletteLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + + public PictomancerPaletteBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + Label = PaletteLabel; + } + } + + [Exportable(false)] + public class PictomancerPaletteLabelConfig : NumericLabelConfig + { + [ColorEdit4("Subtractive Color")] + [Order(31)] + public PluginConfigColor SubtractiveColor = new PluginConfigColor(Vector4.UnitW); + + [ColorEdit4("Subtractive Color ##Outline")] + [Order(41, collapseWith = nameof(ShowOutline))] + public PluginConfigColor SubtractiveOutlineColor = new PluginConfigColor(Vector4.One); + + public PictomancerPaletteLabelConfig(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) + : base(position, text, frameAnchor, textAnchor) + { + } + + [JsonIgnore] public bool SubtractiveMode = false; + + public override PluginConfigColor GetColor() => SubtractiveMode ? SubtractiveColor : Color; + + public override PluginConfigColor GetOutlineColor() => SubtractiveMode ? SubtractiveOutlineColor : OutlineColor; + + public override PictomancerPaletteLabelConfig Clone(int index) => + new PictomancerPaletteLabelConfig(Position, _text, FrameAnchor, TextAnchor) + { + Color = Color, + SubtractiveColor = SubtractiveColor, + OutlineColor = OutlineColor, + SubtractiveOutlineColor = SubtractiveOutlineColor, + ShadowConfig = ShadowConfig, + ShowOutline = ShowOutline, + FontID = FontID, + UseJobColor = UseJobColor, + Enabled = Enabled, + HideIfZero = HideIfZero, + ID = ID + "_{index}" + }; + } + + [DisableParentSettings("FillColor")] + [Exportable(false)] + public class PictomancerPaintBarConfig : ChunkedBarConfig + { + [ColorEdit4("White Paint Color")] + [Order(26)] + public PluginConfigColor WhitePaintColor = PluginConfigColor.FromHex(0xFF00FFFF); + + [ColorEdit4("Black Paint Color")] + [Order(27)] + public PluginConfigColor BlackPaintColor = PluginConfigColor.FromHex(0xFFDB57DB); + + public PictomancerPaintBarConfig(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + } + } + + [DisableParentSettings("FillColor")] + [Exportable(false)] + public class PictomancerCreatureCanvasBarConfig : ChunkedBarConfig + { + [ColorEdit4("Pom Color")] + [Order(17)] + public PluginConfigColor PomColor = PluginConfigColor.FromHex(0xFFE69378); + + [ColorEdit4("Wings Color")] + [Order(18)] + public PluginConfigColor WingsColor = PluginConfigColor.FromHex(0xFFD38BE4); + + [ColorEdit4("Claw Color")] + [Order(19)] + public PluginConfigColor ClawColor = PluginConfigColor.FromHex(0xFFA16854); + + [ColorEdit4("Fangs Color")] + [Order(20)] + public PluginConfigColor FangsColor = PluginConfigColor.FromHex(0xFF80BFBD); + + [ColorEdit4("Moogle Color")] + [Order(21)] + public PluginConfigColor MoogleColor = PluginConfigColor.FromHex(0xFFA745C7); + + [ColorEdit4("Madeen Color")] + [Order(22)] + public PluginConfigColor MadeenColor = PluginConfigColor.FromHex(0xFF93Cf7D); + + [Checkbox("Don't Show Drawing", spacing = true, help = "When enabled, this hides the middle chunk that shows which creature parts were already drawn.")] + [Order(50)] + public bool DontShowDrawing = false; + + [Checkbox("Show Empty Drawing for Pom + Wings")] + [Order(51)] + public bool ShowEmptyPomWings = false; + + + public PictomancerCreatureCanvasBarConfig(Vector2 position, Vector2 size) + : base(position, size, PluginConfigColor.Empty) + { + } + } +} diff --git a/Interface/Jobs/ReaperHud.cs b/Interface/Jobs/ReaperHud.cs new file mode 100644 index 0000000..34d0113 --- /dev/null +++ b/Interface/Jobs/ReaperHud.cs @@ -0,0 +1,246 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class ReaperHud : JobHud + { + private new ReaperConfig Config => (ReaperConfig)_config; + + public ReaperHud(JobConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.DeathsDesignBar.Enabled) + { + positions.Add(Config.Position + Config.DeathsDesignBar.Position); + sizes.Add(Config.DeathsDesignBar.Size); + } + + if (Config.SoulBar.Enabled) + { + positions.Add(Config.Position + Config.SoulBar.Position); + sizes.Add(Config.SoulBar.Size); + } + + if (Config.ShroudBar.Enabled) + { + positions.Add(Config.Position + Config.ShroudBar.Position); + sizes.Add(Config.ShroudBar.Size); + } + + if (Config.DeathGauge.Enabled) + { + positions.Add(Config.Position + Config.DeathGauge.Position); + sizes.Add(Config.DeathGauge.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + RPRGauge gauge = Plugin.JobGauges.Get(); + + if (Config.DeathsDesignBar.Enabled) + { + DrawDeathsDesignBar(pos, player); + } + + if (Config.SoulBar.Enabled) + { + DrawSoulGauge(pos, gauge, player); + } + + if (Config.ShroudBar.Enabled) + { + DrawShroudGauge(pos, gauge, player); + } + + if (Config.DeathGauge.Enabled) + { + DrawDeathGauge(pos, gauge, player); + } + } + + private void DrawDeathsDesignBar(Vector2 origin, IPlayerCharacter player) + { + IGameObject? actor = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + float duration = 0f; + + if (actor is IBattleChara target) + { + duration = Utils.StatusListForBattleChara(target).FirstOrDefault(o => o.StatusId is 2586 && o.SourceId == player.GameObjectId && o.RemainingTime > 0)?.RemainingTime ?? 0f; + } + + if (!Config.DeathsDesignBar.HideWhenInactive || duration > 0) + { + Config.DeathsDesignBar.Label.SetValue(duration); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.DeathsDesignBar, 2, duration, 60f, 0f, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.DeathsDesignBar.StrataLevel)); + } + } + } + + private void DrawSoulGauge(Vector2 origin, RPRGauge gauge, IPlayerCharacter player) + { + float soul = gauge.Soul; + + if (!Config.SoulBar.HideWhenInactive || soul > 0) + { + Config.SoulBar.Label.SetValue(soul); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.SoulBar, 2, soul, 100f, 0f, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.SoulBar.StrataLevel)); + } + } + } + + private void DrawShroudGauge(Vector2 origin, RPRGauge gauge, IPlayerCharacter player) + { + float shroud = gauge.Shroud; + + if (!Config.ShroudBar.HideWhenInactive || shroud > 0) + { + Config.ShroudBar.Label.SetValue(shroud); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.ShroudBar, 2, shroud, 100f, 0f, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.ShroudBar.StrataLevel)); + } + } + } + + private void DrawDeathGauge(Vector2 origin, RPRGauge gauge, IPlayerCharacter player) + { + var lemureShroud = gauge.LemureShroud; + var voidShroud = gauge.VoidShroud; + + if (!Config.DeathGauge.HideWhenInactive || gauge.EnshroudedTimeRemaining > 0) + { + var deathChunks = new Tuple[5]; + + int i = 0; + for (; i < lemureShroud && i < deathChunks.Length; i++) + { + deathChunks[i] = new(Config.DeathGauge.LemureShroudColor, 1f, i == 2 ? Config.DeathGauge.EnshroudTimerLabel : null); + } + + for (; i < lemureShroud + voidShroud && i < deathChunks.Length; i++) + { + deathChunks[i] = new(Config.DeathGauge.VoidShroudColor, 1f, i == 2 ? Config.DeathGauge.EnshroudTimerLabel : null); + } + + for (; i < deathChunks.Length; i++) + { + deathChunks[i] = new(Config.DeathGauge.VoidShroudColor, 0f, i == 2 ? Config.DeathGauge.EnshroudTimerLabel : null); + } + + Config.DeathGauge.EnshroudTimerLabel.SetValue(gauge.EnshroudedTimeRemaining / 1000); + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.DeathGauge, deathChunks, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.DeathGauge.StrataLevel)); + } + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Melee", 0)] + [SubSection("Reaper", 1)] + public class ReaperConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.RPR; + + public new static ReaperConfig DefaultConfig() + { + var config = new ReaperConfig(); + config.DeathsDesignBar.UseChunks = false; + config.DeathsDesignBar.Label.Enabled = true; + + config.SoulBar.UseChunks = false; + config.SoulBar.Label.Enabled = true; + + config.ShroudBar.UseChunks = false; + config.ShroudBar.Label.Enabled = true; + + config.DeathGauge.EnshroudTimerLabel.HideIfZero = true; + + return config; + } + + [NestedConfig("Death's Design Bar", 35)] + public ChunkedProgressBarConfig DeathsDesignBar = new ChunkedProgressBarConfig( + new(0, -10), + new(254, 20), + new PluginConfigColor(new Vector4(145f / 255f, 0f / 255f, 25f / 255f, 100f / 100f)) + ); + + [NestedConfig("Soul Bar", 40)] + public ChunkedProgressBarConfig SoulBar = new ChunkedProgressBarConfig( + new(0, -32), + new(254, 20), + new PluginConfigColor(new Vector4(254f / 255f, 21f / 255f, 94f / 255f, 100f / 100f)) + ); + + [NestedConfig("Shroud Bar", 45)] + public ChunkedProgressBarConfig ShroudBar = new ChunkedProgressBarConfig( + new(0, -54), + new(254, 20), + new PluginConfigColor(new Vector4(0f / 255f, 176f / 255f, 196f / 255f, 100f / 100f)) + ); + + [NestedConfig("Death Gauge", 50)] + public DeathGauge DeathGauge = new DeathGauge( + new(0, -76), + new(254, 20), + new PluginConfigColor(new(0, 0, 0, 0)) + ); + } + + [DisableParentSettings("FillColor")] + public class DeathGauge : ChunkedBarConfig + { + [ColorEdit4("Lemure Shroud Color")] + [Order(21)] + public PluginConfigColor LemureShroudColor = new PluginConfigColor(new Vector4(0f / 255f, 176f / 255f, 196f / 255f, 100f / 100f)); + + [ColorEdit4("Void Shroud Color")] + [Order(22)] + public PluginConfigColor VoidShroudColor = new PluginConfigColor(new Vector4(150f / 255f, 90f / 255f, 144f / 255f, 100f / 100f)); + + [NestedConfig("Enshroud Duration Text", 50, spacing = true)] + public NumericLabelConfig EnshroudTimerLabel; + + public DeathGauge(Vector2 position, Vector2 size, PluginConfigColor fillColor, int padding = 2) : base(position, size, fillColor, padding) + { + EnshroudTimerLabel = new NumericLabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + } + } +} diff --git a/Interface/Jobs/RedMageHud.cs b/Interface/Jobs/RedMageHud.cs new file mode 100644 index 0000000..7d620cf --- /dev/null +++ b/Interface/Jobs/RedMageHud.cs @@ -0,0 +1,319 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class RedMageHud : JobHud + { + private new RedMageConfig Config => (RedMageConfig)_config; + + public RedMageHud(RedMageConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.BalanceBar.Enabled) + { + positions.Add(Config.Position + Config.BalanceBar.Position); + sizes.Add(Config.BalanceBar.Size); + } + + if (Config.WhiteManaBar.Enabled) + { + positions.Add(Config.Position + Config.WhiteManaBar.Position); + sizes.Add(Config.WhiteManaBar.Size); + } + + if (Config.BlackManaBar.Enabled) + { + positions.Add(Config.Position + Config.BlackManaBar.Position); + sizes.Add(Config.BlackManaBar.Size); + } + + if (Config.ManaStacksBar.Enabled) + { + positions.Add(Config.Position + Config.ManaStacksBar.Position); + sizes.Add(Config.ManaStacksBar.Size); + } + + if (Config.DualcastBar.Enabled) + { + positions.Add(Config.Position + Config.DualcastBar.Position); + sizes.Add(Config.DualcastBar.Size); + } + + if (Config.VerstoneBar.Enabled) + { + positions.Add(Config.Position + Config.VerstoneBar.Position); + sizes.Add(Config.VerstoneBar.Size); + } + + if (Config.VerfireBar.Enabled) + { + positions.Add(Config.Position + Config.VerfireBar.Position); + sizes.Add(Config.VerfireBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.BalanceBar.Enabled) + { + DrawBalanceBar(pos, player); + } + + if (Config.WhiteManaBar.Enabled) + { + DrawWhiteManaBar(pos, player); + } + + if (Config.BlackManaBar.Enabled) + { + DrawBlackManaBar(pos, player); + } + + if (Config.ManaStacksBar.Enabled) + { + DrawManaStacksBarBar(pos, player); + } + + if (Config.DualcastBar.Enabled) + { + DrawDualCastBar(pos, player); + } + + if (Config.VerstoneBar.Enabled) + { + DrawVerstoneBar(pos, player); + } + + if (Config.VerfireBar.Enabled) + { + DrawVerfireBar(pos, player); + } + } + + private void DrawBalanceBar(Vector2 origin, IPlayerCharacter player) + { + RDMGauge gauge = Plugin.JobGauges.Get(); + float whiteGauge = (float)Plugin.JobGauges.Get().WhiteMana; + float blackGauge = (float)Plugin.JobGauges.Get().BlackMana; + int scale = gauge.WhiteMana - gauge.BlackMana; + + PluginConfigColor color = Config.BalanceBar.FillColor; + int value = 0; + + if (whiteGauge >= 50 && blackGauge >= 50) + { + value = 1; + } + else if (scale >= 30) + { + color = Config.WhiteManaBar.FillColor; + value = 1; + } + else if (scale <= -30) + { + color = Config.BlackManaBar.FillColor; + value = 1; + } + + if (Config.BalanceBar.HideWhenInactive && value == 0) + { + return; + } + + BarHud bar = BarUtilities.GetBar(Config.BalanceBar, value, 1, 0, player, color); + AddDrawActions(bar.GetDrawActions(origin, Config.BalanceBar.StrataLevel)); + } + + private void DrawWhiteManaBar(Vector2 origin, IPlayerCharacter player) + { + byte mana = Plugin.JobGauges.Get().WhiteMana; + if (Config.WhiteManaBar.HideWhenInactive && mana == 0) + { + return; + } + + Config.WhiteManaBar.Label.SetValue(mana); + + BarHud bar = BarUtilities.GetProgressBar(Config.WhiteManaBar, mana, 100, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.WhiteManaBar.StrataLevel)); + } + + private void DrawBlackManaBar(Vector2 origin, IPlayerCharacter player) + { + byte mana = Plugin.JobGauges.Get().BlackMana; + if (Config.BlackManaBar.HideWhenInactive && mana == 0) + { + return; + } + + Config.BlackManaBar.Label.SetValue(mana); + + BarHud bar = BarUtilities.GetProgressBar(Config.BlackManaBar, mana, 100, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.BlackManaBar.StrataLevel)); + } + + private void DrawManaStacksBarBar(Vector2 origin, IPlayerCharacter player) + { + byte manaStacks = Plugin.JobGauges.Get().ManaStacks; + if (Config.ManaStacksBar.HideWhenInactive && manaStacks == 0) + { + return; + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.ManaStacksBar, 3, manaStacks, 3f, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.ManaStacksBar.StrataLevel)); + } + } + + private void DrawDualCastBar(Vector2 origin, IPlayerCharacter player) + { + float duration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1249)?.RemainingTime ?? 0f; + + if (Config.DualcastBar.HideWhenInactive && duration == 0) + { + return; + }; + + Config.DualcastBar.Label.SetValue(duration); + + BarHud bar = BarUtilities.GetProgressBar(Config.DualcastBar, duration, 15f, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.DualcastBar.StrataLevel)); + } + + private void DrawVerstoneBar(Vector2 origin, IPlayerCharacter player) + { + BarHud? bar = BarUtilities.GetProcBar(Config.VerstoneBar, player, 1235, 30); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.VerstoneBar.StrataLevel)); + } + } + + private void DrawVerfireBar(Vector2 origin, IPlayerCharacter player) + { + BarHud? bar = BarUtilities.GetProcBar(Config.VerfireBar, player, 1234, 30); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.VerfireBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Caster", 0)] + [SubSection("Red Mage", 1)] + public class RedMageConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.RDM; + public new static RedMageConfig DefaultConfig() + { + var config = new RedMageConfig(); + config.UseDefaultPrimaryResourceBar = true; + + config.WhiteManaBar.ThresholdConfig.Enabled = true; + config.WhiteManaBar.ThresholdConfig.ChangeColor = false; + config.WhiteManaBar.ThresholdConfig.ShowMarker = true; + config.WhiteManaBar.Label.TextAnchor = DrawAnchor.Right; + config.WhiteManaBar.Label.FrameAnchor = DrawAnchor.Right; + config.WhiteManaBar.Label.Position = new Vector2(-2, 0); + + config.BlackManaBar.ThresholdConfig.Enabled = true; + config.BlackManaBar.ThresholdConfig.ChangeColor = false; + config.BlackManaBar.ThresholdConfig.ShowMarker = true; + config.BlackManaBar.Label.TextAnchor = DrawAnchor.Left; + config.BlackManaBar.Label.FrameAnchor = DrawAnchor.Left; + config.BlackManaBar.Label.Position = new Vector2(2, 0); + + config.DualcastBar.Label.Enabled = false; + + config.VerstoneBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.VerstoneBar.Label.TextAnchor = DrawAnchor.Right; + config.VerstoneBar.Label.FrameAnchor = DrawAnchor.Right; + config.VerstoneBar.Label.Position = new Vector2(-2, 0); + + config.VerfireBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.VerfireBar.Label.TextAnchor = DrawAnchor.Left; + config.VerfireBar.Label.FrameAnchor = DrawAnchor.Left; + config.VerfireBar.Label.Position = new Vector2(2, 0); + + return config; + } + + [NestedConfig("Balance Bar", 30)] + public BarConfig BalanceBar = new BarConfig( + new(0, -10), + new(20, 20), + new PluginConfigColor(new(195f / 255f, 35f / 255f, 35f / 255f, 100f / 100f)) + ); + + [NestedConfig("White Mana Bar", 35)] + public ProgressBarConfig WhiteManaBar = new ProgressBarConfig( + new(-69.5f, -10), + new(115, 20), + new PluginConfigColor(new(221f / 255f, 212f / 255f, 212f / 255f, 100f / 100f)), + BarDirection.Left, + null, + 80 + ); + + [NestedConfig("Black Mana Bar", 40)] + public ProgressBarConfig BlackManaBar = new ProgressBarConfig( + new(69.5f, -10), + new(115, 20), + new PluginConfigColor(new(60f / 255f, 81f / 255f, 197f / 255f, 100f / 100f)), + threshold: 80 + ); + + [NestedConfig("Mana Stacks Bar", 45)] + public ChunkedBarConfig ManaStacksBar = new ChunkedBarConfig( + new(0, -27), + new(254, 10), + new PluginConfigColor(new(200f / 255f, 45f / 255f, 40f / 255f, 100f / 100f)) + ); + + [NestedConfig("Dualcast Bar", 50)] + public ProgressBarConfig DualcastBar = new ProgressBarConfig( + new(0, -41), + new(16, 14), + new PluginConfigColor(new(204f / 255f, 17f / 255f, 255f / 95f, 100f / 100f)) + ); + + [NestedConfig("Verstone Ready Bar", 55)] + public ProgressBarConfig VerstoneBar = new ProgressBarConfig( + new(-68.5f, -41), + new(117, 14), + new PluginConfigColor(new(228f / 255f, 188f / 255f, 145 / 255f, 90f / 100f)), + BarDirection.Left + ); + + [NestedConfig("Verfire Ready Bar", 60)] + public ProgressBarConfig VerfireBar = new ProgressBarConfig( + new(68.5f, -41), + new(117, 14), + new PluginConfigColor(new(238f / 255f, 119f / 255f, 17 / 255f, 90f / 100f)) + ); + } +} diff --git a/Interface/Jobs/SageHud.cs b/Interface/Jobs/SageHud.cs new file mode 100644 index 0000000..b46c502 --- /dev/null +++ b/Interface/Jobs/SageHud.cs @@ -0,0 +1,246 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Logging; + +namespace HSUI.Interface.Jobs +{ + public class SageHud : JobHud + { + private new SageConfig Config => (SageConfig)_config; + + private static readonly List DotIDs = new() { 2614, 2615, 2616, 3897 }; + private static readonly List DotDurations = new() { 30, 30, 30, 30 }; + + public SageHud(JobConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.AddersgallBar.Enabled) + { + positions.Add(Config.Position + Config.AddersgallBar.Position); + sizes.Add(Config.AddersgallBar.Size); + } + + if (Config.DotBar.Enabled) + { + positions.Add(Config.Position + Config.DotBar.Position); + sizes.Add(Config.DotBar.Size); + } + + if (Config.KeracholeBar.Enabled) + { + positions.Add(Config.Position + Config.KeracholeBar.Position); + sizes.Add(Config.KeracholeBar.Size); + } + + if (Config.PhysisBar.Enabled) + { + positions.Add(Config.Position + Config.PhysisBar.Position); + sizes.Add(Config.PhysisBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.AddersgallBar.Enabled) + { + DrawAddersgallBar(pos, player); + } + + if (Config.DotBar.Enabled) + { + DrawDotBar(pos, player); + } + + if (Config.KeracholeBar.Enabled) + { + DrawKeracholeBar(pos, player); + } + + if (Config.PhysisBar.Enabled) + { + DrawPhysisBar(pos, player); + } + } + + private void DrawDotBar(Vector2 origin, IPlayerCharacter player) + { + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.DotBar, player, target, DotIDs, DotDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.DotBar.StrataLevel)); + } + } + + private void DrawAddersgallBar(Vector2 origin, IPlayerCharacter player) + { + SGEGauge gauge = Plugin.JobGauges.Get(); + + const float addersgallCooldown = 20000f; + + float GetScale(int num, float timer) => num + (timer / addersgallCooldown); + float adderScale = GetScale(gauge.Addersgall, gauge.AddersgallTimer); + BarGlowConfig? glow = gauge.Eukrasia && Config.EukrasiaGlow ? Config.AddersgallBar.GlowConfig : null; + + if (!Config.AddersgallBar.HideWhenInactive || adderScale > 0) + { + BarHud[] bars = BarUtilities.GetChunkedBars(Config.AddersgallBar, 3, adderScale, 3, 0, player, partialFillColor: Config.AddersgallBar.PartialFillColor, glowConfig: glow, chunksToGlow: new[] { true, true, true }); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.AddersgallBar.StrataLevel)); + } + } + + if (!Config.AdderstingBar.HideWhenInactive && Config.AdderstingBar.Enabled || gauge.Addersting > 0) + { + int adderstingStacks = player.Level > 65 ? gauge.Addersting : 0; + BarHud[] bars = BarUtilities.GetChunkedBars(Config.AdderstingBar, 3, adderstingStacks, 3, 0, player, glowConfig: glow, chunksToGlow: new[] { true, true, true }); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.AdderstingBar.StrataLevel)); + } + } + } + + private void DrawPhysisBar(Vector2 origin, IPlayerCharacter player) + { + float physisDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 2617 or 2620 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.PhysisBar.HideWhenInactive || physisDuration > 0) + { + Config.PhysisBar.Label.SetValue(physisDuration); + BarHud bar = BarUtilities.GetProgressBar(Config.PhysisBar, physisDuration, 15f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.PhysisBar.StrataLevel)); + } + } + + private void DrawKeracholeBar(Vector2 origin, IPlayerCharacter player) + { + float keracholeDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 2618 or 2938 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + float holosDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 3003 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.KeracholeBar.HideWhenInactive || keracholeDuration > 0 || holosDuration > 0) + { + float duration = holosDuration > 0 ? holosDuration : keracholeDuration; + float maxDuration = holosDuration > 0 ? 20f : 15f; + + Config.KeracholeBar.Label.SetValue(duration); + BarHud bar = BarUtilities.GetProgressBar(Config.KeracholeBar, duration, maxDuration, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.KeracholeBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Healer", 0)] + [SubSection("Sage", 1)] + public class SageConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.SGE; + + public new static SageConfig DefaultConfig() + { + var config = new SageConfig(); + + config.UseDefaultPrimaryResourceBar = true; + config.DotBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + + return config; + } + + [Checkbox("Enable Eukrasia Glow", spacing = true)] + [Order(30)] + public bool EukrasiaGlow = true; + + [NestedConfig("Addersgall Bar", 35)] + public AddersgallBarConfig AddersgallBar = new AddersgallBarConfig( + new(-64, -32), + new(126, 20), + new PluginConfigColor(new(197f / 255f, 247f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Addersting Bar", 40)] + public AdderstingBarConfig AdderstingBar = new AdderstingBarConfig( + new(64, -32), + new(126, 20), + new PluginConfigColor(new(255f / 255f, 232f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Eukrasian Dosis Bar", 45)] + public ProgressBarConfig DotBar = new ProgressBarConfig( + new(0, -10), + new(254, 20), + new PluginConfigColor(new(41f / 255f, 142f / 255f, 144f / 255f, 100f / 100f)) + ); + + [NestedConfig("Kerachole / Holos Bar", 50)] + public ProgressBarConfig KeracholeBar = new ProgressBarConfig( + new(64, -52), + new(126, 15), + new PluginConfigColor(new(100f / 255f, 207f / 255f, 211f / 255f, 100f / 100f)) + ); + + [NestedConfig("Physis Bar", 55)] + public ProgressBarConfig PhysisBar = new ProgressBarConfig( + new(-64, -52), + new(126, 15), + new PluginConfigColor(new(26f / 255f, 167f / 255f, 109f / 255f, 100f / 100f)) + ); + } + + [Exportable(false)] + public class AddersgallBarConfig : ChunkedBarConfig + { + [NestedConfig("Glow Color (when Eukrasia active)", 60, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new(); + + [Checkbox("Use Partial Fill Color", spacing = true)] + [Order(65)] + public bool UsePartialFillColor = false; + + [ColorEdit4("Partial Fill Color")] + [Order(66, collapseWith = nameof(UsePartialFillColor))] + public PluginConfigColor PartialFillColor; + + public AddersgallBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + GlowConfig.Color = new PluginConfigColor(new(247f / 255f, 177f / 255f, 67f / 255f, 100f / 100f)); + PartialFillColor = new PluginConfigColor(new(197 / 255f, 247f / 255f, 255f / 255f, 50f / 100f)); + } + } + + [Exportable(false)] + public class AdderstingBarConfig : ChunkedBarConfig + { + [NestedConfig("Glow Color (when Eukrasia active)", 60, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new(); + + public AdderstingBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + GlowConfig.Color = new PluginConfigColor(new(247f / 255f, 177f / 255f, 67f / 255f, 100f / 100f)); + } + } +} diff --git a/Interface/Jobs/SamuraiHud.cs b/Interface/Jobs/SamuraiHud.cs new file mode 100644 index 0000000..d089cd1 --- /dev/null +++ b/Interface/Jobs/SamuraiHud.cs @@ -0,0 +1,274 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class SamuraiHud : JobHud + { + private new SamuraiConfig Config => (SamuraiConfig)_config; + private static readonly List HiganbanaIDs = new() { 1228, 1319 }; + private static readonly List HiganabaDurations = new() { 60f, 60f }; + + public SamuraiHud(SamuraiConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.KenkiBar.Enabled) + { + positions.Add(Config.Position + Config.KenkiBar.Position); + sizes.Add(Config.KenkiBar.Size); + } + + if (Config.ShifuBar.Enabled) + { + positions.Add(Config.Position + Config.ShifuBar.Position); + sizes.Add(Config.ShifuBar.Size); + } + + if (Config.JinpuBar.Enabled) + { + positions.Add(Config.Position + Config.JinpuBar.Position); + sizes.Add(Config.JinpuBar.Size); + } + + if (Config.HiganbanaBar.Enabled) + { + positions.Add(Config.Position + Config.HiganbanaBar.Position); + sizes.Add(Config.HiganbanaBar.Size); + } + + if (Config.SenBar.Enabled) + { + positions.Add(Config.Position + Config.SenBar.Position); + sizes.Add(Config.SenBar.Size); + } + + if (Config.MeditationBar.Enabled) + { + positions.Add(Config.Position + Config.MeditationBar.Position); + sizes.Add(Config.MeditationBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + if (Config.KenkiBar.Enabled) + { + DrawKenkiBar(origin + Config.Position, player); + } + + if (Config.ShifuBar.Enabled) + { + DrawShifuBar(origin + Config.Position, player); + } + + if (Config.JinpuBar.Enabled) + { + DrawJinpuBar(origin + Config.Position, player); + } + + if (Config.SenBar.Enabled) + { + DrawSenBar(origin + Config.Position, player); + } + + if (Config.MeditationBar.Enabled) + { + DrawMeditationBar(origin + Config.Position, player); + } + + if (Config.HiganbanaBar.Enabled) + { + DrawHiganbanaBar(origin + Config.Position, player); + } + } + + private void DrawKenkiBar(Vector2 origin, IPlayerCharacter player) + { + SAMGauge gauge = Plugin.JobGauges.Get(); + + if (!Config.KenkiBar.HideWhenInactive || gauge.Kenki > 0) + { + Config.KenkiBar.Label.SetValue(gauge.Kenki); + + BarHud bar = BarUtilities.GetProgressBar(Config.KenkiBar, gauge.Kenki, 100f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.KenkiBar.StrataLevel)); + } + } + + private void DrawShifuBar(Vector2 origin, IPlayerCharacter player) + { + float shifuDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1299)?.RemainingTime ?? 0f; + + if (!Config.ShifuBar.HideWhenInactive || shifuDuration > 0) + { + Config.ShifuBar.Label.SetValue(shifuDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.ShifuBar, shifuDuration, 40f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.ShifuBar.StrataLevel)); + } + } + + private void DrawJinpuBar(Vector2 origin, IPlayerCharacter player) + { + float jinpuDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1298)?.RemainingTime ?? 0f; + + if (!Config.JinpuBar.HideWhenInactive || jinpuDuration > 0) + { + Config.JinpuBar.Label.SetValue(jinpuDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.JinpuBar, jinpuDuration, 40f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.JinpuBar.StrataLevel)); + } + } + + private void DrawHiganbanaBar(Vector2 origin, IPlayerCharacter player) + { + var target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.HiganbanaBar, player, target, HiganbanaIDs, HiganabaDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.HiganbanaBar.StrataLevel)); + } + } + + private void DrawSenBar(Vector2 origin, IPlayerCharacter player) + { + SAMGauge gauge = Plugin.JobGauges.Get(); + if (!Config.SenBar.HideWhenInactive || gauge.HasSetsu || gauge.HasGetsu || gauge.HasKa) + { + var order = Config.SenBar.SenOrder; + var hasSen = new[] { gauge.HasSetsu ? 1 : 0, gauge.HasGetsu ? 1 : 0, gauge.HasKa ? 1 : 0 }; + var colors = new[] { Config.SenBar.SetsuColor, Config.SenBar.GetsuColor, Config.SenBar.KaColor }; + + var sen = new Tuple[3]; + for (int i = 0; i < 3; i++) + { + sen[i] = new Tuple(colors[order[i]], hasSen[order[i]], null); + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.SenBar, sen, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.SenBar.StrataLevel)); + } + } + } + + private void DrawMeditationBar(Vector2 origin, IPlayerCharacter player) + { + SAMGauge gauge = Plugin.JobGauges.Get(); + if (!Config.MeditationBar.HideWhenInactive || gauge.MeditationStacks > 0) + { + BarHud[] bars = BarUtilities.GetChunkedBars(Config.MeditationBar, 3, gauge.MeditationStacks, 3f, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.MeditationBar.StrataLevel)); + } + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Melee", 0)] + [SubSection("Samurai", 1)] + public class SamuraiConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.SAM; + + public new static SamuraiConfig DefaultConfig() + { + var config = new SamuraiConfig(); + + config.HiganbanaBar.ThresholdConfig.Enabled = true; + + return config; + } + + [NestedConfig("Sen Bar", 40)] + public SamuraiSenBarConfig SenBar = new SamuraiSenBarConfig( + new(0, -17), + new(254, 10), + new PluginConfigColor(new Vector4(0, 0, 0, 0)) + ); + + [NestedConfig("Fuka Bar", 45)] + public ProgressBarConfig ShifuBar = new ProgressBarConfig( + new(-64, -56), + new(126, 20), + new PluginConfigColor(new(219f / 255f, 211f / 255f, 136f / 255f, 100f / 100f)) + ); + + [NestedConfig("Fugetsu Bar", 50)] + public ProgressBarConfig JinpuBar = new ProgressBarConfig( + new(64, -56), + new(126, 20), + new PluginConfigColor(new(136f / 255f, 146f / 255f, 219f / 255f, 100f / 100f)) + ); + + [NestedConfig("Kenki Bar", 55)] + public ProgressBarConfig KenkiBar = new ProgressBarConfig( + new(0, -34), + new(254, 20), + new PluginConfigColor(new(255f / 255f, 82f / 255f, 82f / 255f, 53f / 100f)) + ); + + [NestedConfig("Higanbana Bar", 60)] + public ProgressBarConfig HiganbanaBar = new ProgressBarConfig( + new(0, -78), + new(254, 20), + new PluginConfigColor(new(237f / 255f, 141f / 255f, 7f / 255f, 100f / 100f)), + BarDirection.Right, + new PluginConfigColor(new(230f / 255f, 33f / 255f, 33f / 255f, 53f / 100f)), + 15f + ); + + [NestedConfig("Meditation Bar", 65)] + public ChunkedBarConfig MeditationBar = new ChunkedBarConfig( + new(0, -5), + new(254, 10), + new PluginConfigColor(new(247f / 255f, 163f / 255f, 89f / 255f, 100f / 100f)) + ); + } + + [DisableParentSettings("FillColor")] + [Exportable(false)] + public class SamuraiSenBarConfig : ChunkedBarConfig + { + [ColorEdit4("Setsu", spacing = true)] + [Order(60)] + public PluginConfigColor SetsuColor = new PluginConfigColor(new(89f / 255f, 234f / 255f, 247f / 255f, 100f / 100f)); + + [ColorEdit4("Getsu")] + [Order(65)] + public PluginConfigColor GetsuColor = new PluginConfigColor(new(89f / 255f, 126f / 255f, 247f / 255f, 100f / 100f)); + + [ColorEdit4("Ka")] + [Order(70)] + public PluginConfigColor KaColor = new PluginConfigColor(new(247f / 255f, 89f / 255f, 89f / 255f, 100f / 100f)); + + [DragDropHorizontal("Order", "Setsu", "Getsu", "Ka")] + [Order(75)] + public int[] SenOrder = new int[] { 0, 1, 2 }; + + public SamuraiSenBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor, 2) { } + } +} diff --git a/Interface/Jobs/ScholarHud.cs b/Interface/Jobs/ScholarHud.cs new file mode 100644 index 0000000..023fc11 --- /dev/null +++ b/Interface/Jobs/ScholarHud.cs @@ -0,0 +1,209 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class ScholarHud : JobHud + { + private new ScholarConfig Config => (ScholarConfig)_config; + + private static readonly List BioDoTIDs = new() { 179, 189, 1895 }; + private static readonly List BioDoTDurations = new() { 30, 30, 30 }; + + public ScholarHud(ScholarConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.AetherflowBar.Enabled) + { + positions.Add(Config.Position + Config.AetherflowBar.Position); + sizes.Add(Config.AetherflowBar.Size); + } + + if (Config.FairyGaugeBar.Enabled) + { + positions.Add(Config.Position + Config.FairyGaugeBar.Position); + sizes.Add(Config.FairyGaugeBar.Size); + } + + if (Config.BioBar.Enabled) + { + positions.Add(Config.Position + Config.BioBar.Position); + sizes.Add(Config.BioBar.Size); + } + + if (Config.SacredSoilBar.Enabled) + { + positions.Add(Config.Position + Config.SacredSoilBar.Position); + sizes.Add(Config.SacredSoilBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.BioBar.Enabled) + { + DrawBioBar(pos, player); + } + + if (Config.FairyGaugeBar.Enabled) + { + DrawFairyGaugeBar(pos, player); + } + + if (Config.AetherflowBar.Enabled) + { + DrawAetherBar(pos, player); + } + + if (Config.SacredSoilBar.Enabled) + { + DrawSacredSoilBar(pos, player); + } + } + + private void DrawBioBar(Vector2 origin, IPlayerCharacter player) + { + var target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.BioBar, player, target, BioDoTIDs, BioDoTDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.BioBar.StrataLevel)); + } + } + + private unsafe void DrawFairyGaugeBar(Vector2 origin, IPlayerCharacter player) + { + SCHGauge gauge = Plugin.JobGauges.Get(); + byte fairyGauge = gauge.FairyGauge; + float seraphDuration = gauge.SeraphTimer; + + if (Config.FairyGaugeBar.HideWhenInactive && fairyGauge == 0 && (seraphDuration == 0 || !Config.FairyGaugeBar.ShowSeraph)) + { + return; + } + + if (Config.FairyGaugeBar.ShowSeraph && seraphDuration > 0) + { + Config.FairyGaugeBar.Label.SetValue(seraphDuration / 1000); + + BarHud bar = BarUtilities.GetProgressBar(Config.FairyGaugeBar, seraphDuration / 1000, 22, 0, player, Config.FairyGaugeBar.SeraphColor); + AddDrawActions(bar.GetDrawActions(origin, Config.FairyGaugeBar.StrataLevel)); + } + else + { + Config.FairyGaugeBar.Label.SetValue(fairyGauge); + + BarHud bar = BarUtilities.GetProgressBar(Config.FairyGaugeBar, fairyGauge, 100, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.FairyGaugeBar.StrataLevel)); + } + } + + private void DrawAetherBar(Vector2 origin, IPlayerCharacter player) + { + ushort stackCount = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 304)?.Param ?? 0; + + if (Config.AetherflowBar.HideWhenInactive && stackCount == 0) + { + return; + }; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.AetherflowBar, 3, stackCount, 3, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.AetherflowBar.StrataLevel)); + } + } + + private void DrawSacredSoilBar(Vector2 origin, IPlayerCharacter player) + { + float sacredSoilDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 298 or 1944 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.SacredSoilBar.HideWhenInactive || sacredSoilDuration > 0) + { + Config.SacredSoilBar.Label.SetValue(sacredSoilDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.SacredSoilBar, sacredSoilDuration, 15f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.SacredSoilBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Healer", 0)] + [SubSection("Scholar", 1)] + public class ScholarConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.SCH; + public new static ScholarConfig DefaultConfig() + { + var config = new ScholarConfig(); + config.UseDefaultPrimaryResourceBar = true; + return config; + } + + [NestedConfig("Bio Bar", 30)] + public ProgressBarConfig BioBar = new ProgressBarConfig( + new(0, -10), + new(254, 20), + new(new Vector4(50f / 255f, 93f / 255f, 37f / 255f, 1f)) + ); + + [NestedConfig("Fairy Gauge", 35)] + public ScholarFairyGaugeBarConfig FairyGaugeBar = new ScholarFairyGaugeBarConfig( + new(0, -32), + new(254, 20), + new(new Vector4(69f / 255f, 199 / 255f, 164f / 255f, 100f / 100f)) + ); + + [NestedConfig("Aetherflow Bar", 40)] + public ChunkedBarConfig AetherflowBar = new ChunkedBarConfig( + new(0, -54), + new(254, 20), + new(new Vector4(0f / 255f, 255f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Sacred Soil Bar", 45)] + public ProgressBarConfig SacredSoilBar = new ProgressBarConfig( + new(-0, -76), + new(254, 20), + new PluginConfigColor(new(241f / 255f, 217f / 255f, 125f / 255f, 100f / 100f)) + ); + } + + [Exportable(false)] + public class ScholarFairyGaugeBarConfig : ProgressBarConfig + { + [Checkbox("Show Seraph", spacing = true)] + [Order(50)] + public bool ShowSeraph = true; + + [ColorEdit4("Color" + "##SeraphColor")] + [Order(55)] + public PluginConfigColor SeraphColor = new(new Vector4(232f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + + public ScholarFairyGaugeBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } +} diff --git a/Interface/Jobs/SummonerHud.cs b/Interface/Jobs/SummonerHud.cs new file mode 100644 index 0000000..e0d25da --- /dev/null +++ b/Interface/Jobs/SummonerHud.cs @@ -0,0 +1,429 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game.Gauge; +using FFXIVClientStructs.FFXIV.Common.Lua; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class SummonerHud : JobHud + { + private new SummonerConfig Config => (SummonerConfig)_config; + + public SummonerHud(SummonerConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.AetherflowBar.Enabled) + { + positions.Add(Config.Position + Config.AetherflowBar.Position); + sizes.Add(Config.AetherflowBar.Size); + } + + if (Config.TranceBar.Enabled) + { + positions.Add(Config.Position + Config.TranceBar.Position); + sizes.Add(Config.TranceBar.Size); + } + + if (Config.IfritBar.Enabled) + { + positions.Add(Config.Position + Config.IfritBar.Position); + sizes.Add(Config.IfritBar.Size); + } + + if (Config.TitanBar.Enabled) + { + positions.Add(Config.Position + Config.TitanBar.Position); + sizes.Add(Config.TitanBar.Size); + } + + if (Config.GarudaBar.Enabled) + { + positions.Add(Config.Position + Config.GarudaBar.Position); + sizes.Add(Config.GarudaBar.Size); + } + + if (Config.StacksBar.Enabled) + { + positions.Add(Config.Position + Config.StacksBar.Position); + sizes.Add(Config.StacksBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.AetherflowBar.Enabled) + { + DrawAetherBar(pos, player); + } + + if (Config.TranceBar.Enabled) + { + DrawTranceBar(pos, player); + } + + if (Config.IfritBar.Enabled) + { + DrawIfritBar(pos, player); + } + + if (Config.TitanBar.Enabled) + { + DrawTitanBar(pos, player); + } + + if (Config.GarudaBar.Enabled) + { + DrawGarudaBar(pos, player); + } + + if (Config.StacksBar.Enabled) + { + HandleAttunementStacks(pos, player); + } + } + + private unsafe void DrawIfritBar(Vector2 origin, IPlayerCharacter player) + { + SMNGauge gauge = Plugin.JobGauges.Get(); + int stackCount = gauge.IsIfritReady ? 1 : 0; + + if (!Config.IfritBar.HideWhenInactive || stackCount > 1) + { + BarHud[] bars = BarUtilities.GetChunkedBars(Config.IfritBar, 1, stackCount, 1, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.IfritBar.StrataLevel)); + } + } + } + + private void DrawTitanBar(Vector2 origin, IPlayerCharacter player) + { + SMNGauge gauge = Plugin.JobGauges.Get(); + int stackCount = gauge.IsTitanReady ? 1 : 0; + + if (!Config.TitanBar.HideWhenInactive || stackCount > 1) + { + BarHud[] bars = BarUtilities.GetChunkedBars(Config.TitanBar, 1, stackCount, 1, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.TitanBar.StrataLevel)); + } + } + } + + private void DrawGarudaBar(Vector2 origin, IPlayerCharacter player) + { + SMNGauge gauge = Plugin.JobGauges.Get(); + int stackCount = gauge.IsGarudaReady ? 1 : 0; + + if (!Config.GarudaBar.HideWhenInactive || stackCount > 1) + { + BarHud[] bars = BarUtilities.GetChunkedBars(Config.GarudaBar, 1, stackCount, 1, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.GarudaBar.StrataLevel)); + } + } + } + + private enum Primal + { + None = 0, + Ifrit = 1, + Titan = 2, + Garuda = 3 + } + + private unsafe void HandleAttunementStacks(Vector2 origin, IPlayerCharacter player) + { + SMNGauge gauge = Plugin.JobGauges.Get(); + + byte value = *((byte*)(new IntPtr(gauge.Address) + 0xE)); + Primal primal = (Primal)(value & 3); + int stacks = ((value >> 2) & 7); + + if (primal == Primal.Ifrit && Config.StacksBar.ShowIfritStacks) + { + DrawStacksBar(origin, player, stacks, 2, Config.StacksBar.IfritStackColor); + } + else if (primal == Primal.Titan && Config.StacksBar.ShowTitanStacks) + { + DrawStacksBar(origin, player, stacks, 4, Config.StacksBar.TitanStackColor); + } + else if (primal == Primal.Garuda && Config.StacksBar.ShowGarudaStacks) + { + DrawStacksBar(origin, player, stacks, 4, Config.StacksBar.GarudaStackColor); + } + else if (!Config.StacksBar.HideWhenInactive) + { + DrawStacksBar(origin, player, 0, 1, Config.StacksBar.FillColor); + } + } + + private void DrawAetherBar(Vector2 origin, IPlayerCharacter player) + { + ushort stackCount = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId == 304)?.Param ?? 0; + + if (Config.AetherflowBar.HideWhenInactive && stackCount == 0) + { + return; + } + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.AetherflowBar, 2, stackCount, 2, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.AetherflowBar.StrataLevel)); + } + } + + private unsafe void DrawTranceBar(Vector2 origin, IPlayerCharacter player) + { + SMNGauge gauge = Plugin.JobGauges.Get(); + PluginConfigColor tranceColor; + uint spellID = 0; + float maxDuration = 0f; + float currentCooldown = 0f; + float tranceDuration = 0f; + tranceColor = Config.TranceBar.FillColor; + + // Dawntrail Fixes + bool isSolarBahamutReady = gauge.AetherFlags.HasFlag(AetherFlags.None + 0x8) || // 0x8 Formerly Titan Attuned + gauge.AetherFlags.HasFlag(AetherFlags.None + 0xC); // 0xC Formerly Garuda Attuned + bool isPhoenixReady = gauge.AetherFlags.HasFlag(AetherFlags.None + 0x4); // 0x4 Formerly Ifrit Attuned + bool isNormalBahamutReady = !isSolarBahamutReady && !isPhoenixReady; // You'd think it would be 0x10, but thats unused now + + byte summonedPrimal = *((byte*)(new IntPtr(gauge.Address) + 0xE)); // Formally Attunement, now...? + Primal primal = (Primal)(summonedPrimal & 3); + + if (primal != Primal.None) + { + tranceColor = primal == Primal.Ifrit ? Config.TranceBar.IfritColor : primal == Primal.Titan ? Config.TranceBar.TitanColor : primal == Primal.Garuda ? Config.TranceBar.GarudaColor : Config.TranceBar.FillColor; + tranceDuration = gauge.AttunementTimerRemaining; + maxDuration = 30f; + } + else + { + if (isSolarBahamutReady) + { + tranceColor = Config.TranceBar.SolarBahamutColor; + tranceDuration = gauge.SummonTimerRemaining; + spellID = 36992; + maxDuration = 15f; + } + else if (isNormalBahamutReady) + { + tranceColor = Config.TranceBar.BahamutColor; + tranceDuration = gauge.SummonTimerRemaining; + spellID = 7427; + maxDuration = 15f; + } + else if (isPhoenixReady) + { + tranceColor = Config.TranceBar.PhoenixColor; + tranceDuration = gauge.SummonTimerRemaining; + spellID = 25831; + maxDuration = 15f; + } + } + + if (tranceDuration != 0) + { + if (gauge.AttunementTimerRemaining > 0 && Config.TranceBar.HidePrimals) + { + return; + } + + Config.TranceBar.Label.SetValue(tranceDuration / 1000f); + + BarHud bar = BarUtilities.GetProgressBar(Config.TranceBar, tranceDuration / 1000f, maxDuration, 0, player, tranceColor); + AddDrawActions(bar.GetDrawActions(origin, Config.TranceBar.StrataLevel)); + } + else + { + if (!Config.TranceBar.HideWhenInactive) + { + if (gauge.AttunementTimerRemaining == 0) + { + maxDuration = SpellHelper.Instance.GetRecastTime(spellID); + float tranceCooldown = SpellHelper.Instance.GetSpellCooldown(spellID); + currentCooldown = maxDuration - tranceCooldown; + + Config.TranceBar.Label.SetValue(maxDuration - currentCooldown); + if (currentCooldown == maxDuration) + { + Config.TranceBar.Label.SetText("READY"); + } + + BarHud bar = BarUtilities.GetProgressBar(Config.TranceBar, currentCooldown, maxDuration, 0, player, tranceColor); + AddDrawActions(bar.GetDrawActions(origin, Config.TranceBar.StrataLevel)); + } + } + } + } + + private void DrawStacksBar(Vector2 origin, IPlayerCharacter player, int amount, int max, PluginConfigColor stackColor, BarGlowConfig? glowConfig = null) + { + SummonerStacksBarConfig config = Config.StacksBar; + + config.FillColor = stackColor; + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.StacksBar, max, amount, max, 0f, player, glowConfig: glowConfig); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.StacksBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Caster", 0)] + [SubSection("Summoner", 1)] + public class SummonerConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.SMN; + public new static SummonerConfig DefaultConfig() + { + var config = new SummonerConfig(); + + config.TranceBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.UseDefaultPrimaryResourceBar = true; + + return config; + } + + [NestedConfig("Aetherflow Bar", 40)] + public ChunkedBarConfig AetherflowBar = new ChunkedBarConfig( + new(-0, -7), + new(254, 14), + new(new Vector4(255f / 255f, 177f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Trance Bar", 45)] + public SummonerTranceBarConfig TranceBar = new SummonerTranceBarConfig( + new(0, -23), + new(254, 14), + new(new Vector4(128f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Ifrit Bar", 50)] + public ChunkedBarConfig IfritBar = new ChunkedBarConfig( + new(-85, -39), + new(84, 14), + new(new Vector4(200f / 255f, 40f / 255f, 0f / 255f, 100f / 100f)) + ); + + [NestedConfig("Titan Bar", 55)] + public ChunkedBarConfig TitanBar = new ChunkedBarConfig( + new(0, -39), + new(84, 14), + new(new Vector4(210f / 255f, 150f / 255f, 26f / 255f, 100f / 100f)) + ); + + [NestedConfig("Garuda Bar", 60)] + public ChunkedBarConfig GarudaBar = new ChunkedBarConfig( + new(85, -39), + new(84, 14), + new(new Vector4(60f / 255f, 160f / 255f, 100f / 255f, 100f / 100f)) + ); + + [NestedConfig("Attunement Stacks Bar", 65)] + public SummonerStacksBarConfig StacksBar = new SummonerStacksBarConfig( + new(0, -55), + new(254, 14), + new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 0f / 100f)) + ); + + } + + + [Exportable(false)] + public class SummonerTranceBarConfig : ProgressBarConfig + { + [ColorEdit4("Bahamut Color")] + [Order(26)] + public PluginConfigColor BahamutColor = new(new Vector4(128f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + + [ColorEdit4("Phoenix Color")] + [Order(27)] + public PluginConfigColor PhoenixColor = new(new Vector4(240f / 255f, 100f / 255f, 10f / 255f, 100f / 100f)); + + [ColorEdit4("Solar Bahamut Color")] + [Order(28)] + public PluginConfigColor SolarBahamutColor = new(new Vector4(235f / 255f, 241f / 255f, 252f / 255f, 100f / 100f)); + + [ColorEdit4("Ifrit Color")] + [Order(29)] + public PluginConfigColor IfritColor = new(new Vector4(200f / 255f, 40f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Titan Color")] + [Order(30)] + public PluginConfigColor TitanColor = new(new Vector4(210f / 255f, 150f / 255f, 26f / 255f, 100f / 100f)); + + [ColorEdit4("Garuda Color")] + [Order(31)] + public PluginConfigColor GarudaColor = new(new Vector4(60f / 255f, 160f / 255f, 100f / 255f, 100f / 100f)); + + [Checkbox("Hide Primals")] + [Order(45)] + public bool HidePrimals = false; + + public SummonerTranceBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class SummonerStacksBarConfig : ChunkedBarConfig + { + [Checkbox("Ifrit Stacks", separator = false, spacing = false)] + [Order(51)] + public bool ShowIfritStacks = true; + + [Checkbox("Titan Stacks", separator = false, spacing = false)] + [Order(53)] + public bool ShowTitanStacks = true; + + [Checkbox("Garuda Stacks", separator = false, spacing = false)] + [Order(55)] + public bool ShowGarudaStacks = true; + + [ColorEdit4("Ifrit Stacks Color")] + [Order(56)] + public PluginConfigColor IfritStackColor = new(new Vector4(200f / 255f, 40f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Titan Stacks Color")] + [Order(57)] + public PluginConfigColor TitanStackColor = new(new Vector4(210f / 255f, 150f / 255f, 26f / 255f, 100f / 100f)); + + [ColorEdit4("Garuda Stacks Color")] + [Order(58)] + public PluginConfigColor GarudaStackColor = new(new Vector4(60f / 255f, 160f / 255f, 100f / 255f, 100f / 100f)); + + public SummonerStacksBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } +} diff --git a/Interface/Jobs/ViperHud.cs b/Interface/Jobs/ViperHud.cs new file mode 100644 index 0000000..86951d3 --- /dev/null +++ b/Interface/Jobs/ViperHud.cs @@ -0,0 +1,373 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class ViperHud : JobHud + { + private new ViperConfig Config => (ViperConfig)_config; + + public ViperHud(ViperConfig config, string? displayName = null) : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new List(); + List sizes = new List(); + + if (Config.RattlingCoilGauge.Enabled) + { + positions.Add(Config.Position + Config.RattlingCoilGauge.Position); + sizes.Add(Config.RattlingCoilGauge.Size); + } + + if (Config.Vipersight.Enabled) + { + positions.Add(Config.Position + Config.Vipersight.Position); + sizes.Add(Config.Vipersight.Size); + } + + if (Config.AnguineTribute.Enabled) + { + positions.Add(Config.Position + Config.AnguineTribute.Position); + sizes.Add(Config.AnguineTribute.Size); + } + + if (Config.SerpentOfferings.Enabled) + { + positions.Add(Config.Position + Config.SerpentOfferings.Position); + sizes.Add(Config.SerpentOfferings.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + if (Config.RattlingCoilGauge.Enabled) + { + DrawRattlingCoilGauge(origin + Config.Position, player); + } + + if (Config.Vipersight.Enabled) + { + DrawVipersightBar(origin + Config.Position, player); + } + + if (Config.SerpentOfferings.Enabled) + { + DrawSerpentOfferingsBar(origin + Config.Position, player); + } + + if (Config.AnguineTribute.Enabled) + { + DrawAnguineTributeGauge(origin + Config.Position, player); + } + } + + private unsafe void DrawVipersightBar(Vector2 origin, IPlayerCharacter player) + { + ViperCombo lastUsedActionId = (ViperCombo)SpellHelper.Instance.GetLastUsedActionId(); + ViperComboState comboState; + bool isAoE = false; + + switch (lastUsedActionId) + { + case ViperCombo.SteelMaw: + case ViperCombo.DreadMaw: + isAoE = true; + comboState = ViperComboState.Started; + break; + case ViperCombo.SteelFangs: + case ViperCombo.DreadFangs: + comboState = ViperComboState.Started; + break; + case ViperCombo.HuntersBite: + case ViperCombo.SwiftskinsBite: + isAoE = true; + comboState = ViperComboState.Finisher; + break; + case ViperCombo.HuntersSting: + case ViperCombo.SwiftskinsSting: + comboState = ViperComboState.Finisher; + break; + default: + comboState = ViperComboState.None; + break; + } + + if (Config.Vipersight.HideWhenInactive && comboState == ViperComboState.None) + { + return; + } + + uint leftId = SpellHelper.Instance.GetSpellActionId(isAoE ? (uint)ViperCombo.SteelMaw : (uint)ViperCombo.SteelFangs); + bool isLeftGlowing = SpellHelper.Instance.IsActionHighlighted(leftId); + + uint rightId = SpellHelper.Instance.GetSpellActionId(isAoE ? (uint)ViperCombo.DreadMaw : (uint)ViperCombo.DreadFangs); + bool isRightGlowing = SpellHelper.Instance.IsActionHighlighted(rightId); + + List> chunks = new(); + List glows = new(); + + Tuple empty = new(PluginConfigColor.Empty, 1, null); + Tuple start = new(Config.Vipersight.ComboStartColor, 1, null); + Tuple endFlank = new(Config.Vipersight.ComboEndFlankColor, 1, null); + Tuple endHind = new(Config.Vipersight.ComboEndHindColor, 1, null); + Tuple endAoE = new(Config.Vipersight.ComboEndAOEColor, 1, null); + + bool isFlankEnder = Utils.StatusListForBattleChara(player).Any(o => o.StatusId is 3645 or 3646); + bool isHindEnder = Utils.StatusListForBattleChara(player).Any(o => o.StatusId is 3647 or 3648); + bool noEnder = !isFlankEnder && !isHindEnder; + + switch (comboState) + { + case ViperComboState.None: + { + chunks = [empty, empty, empty, empty]; + glows = [false, isLeftGlowing, isRightGlowing, false]; + break; + } + case ViperComboState.Started: + { + chunks = [empty, start, start, empty]; + glows = [false, isLeftGlowing || isAoE, isRightGlowing || isAoE, false]; + break; + } + case ViperComboState.Finisher: + { + bool isFlankChain = lastUsedActionId == ViperCombo.HuntersSting; + bool isHindChain = lastUsedActionId == ViperCombo.SwiftskinsSting; + + Tuple end; + + if (isFlankEnder) + { + end = isHindChain ? endHind : endFlank; + } + else if (isHindEnder) + { + end = isFlankChain ? endFlank : endHind; + } + else + { + end = isFlankChain ? endFlank : isHindChain ? endHind : endAoE; + } + + chunks = [end, start, start, end]; + glows = [isLeftGlowing, isLeftGlowing, isRightGlowing, isRightGlowing]; + + break; + } + } + + if (Config.Vipersight.Invert) + { + chunks.Reverse(); + glows.Reverse(); + } + + BarHud[] bars = BarUtilities.GetChunkedBars( + Config.Vipersight, + chunks.ToArray(), + player, + Config.Vipersight.GlowConfig, + glows.ToArray() + ); + + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.Vipersight.StrataLevel)); + } + } + + private unsafe void DrawRattlingCoilGauge(Vector2 origin, IPlayerCharacter player) + { + VPRGauge gauge = Plugin.JobGauges.Get(); + + if (Config.RattlingCoilGauge.HideWhenInactive && gauge.RattlingCoilStacks <= 0) + { + return; + } + + int maxStacks = player.Level >= 88 ? 3 : 2; + BarHud[] bars = BarUtilities.GetChunkedBars(Config.RattlingCoilGauge, maxStacks, gauge.RattlingCoilStacks, maxStacks, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.RattlingCoilGauge.StrataLevel)); + } + } + + private unsafe void DrawAnguineTributeGauge(Vector2 origin, IPlayerCharacter player) + { + VPRGauge gauge = Plugin.JobGauges.Get(); + + if (Config.AnguineTribute.HideWhenInactive && gauge.AnguineTribute <= 0) + { + return; + } + + int maxStacks = player.Level >= 96 ? 5 : 4; + BarHud[] bars = BarUtilities.GetChunkedBars(Config.AnguineTribute, maxStacks, gauge.AnguineTribute, maxStacks, 0, player); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.AnguineTribute.StrataLevel)); + } + } + + private unsafe void DrawSerpentOfferingsBar(Vector2 origin, IPlayerCharacter player) + { + ViperConfig.SerpentOfferingsBarConfig config = Config.SerpentOfferings; + VPRGauge gauge = Plugin.JobGauges.Get(); + + float reawakenedDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 3670 or 4094 && o.RemainingTime > 0f)?.RemainingTime ?? 0f; + bool reAwakenedReady = Utils.StatusListForBattleChara(player).Any(o => o.StatusId is 3671) || gauge.SerpentOffering >= 50; + bool isReawakened = reawakenedDuration > 0; + bool showReawakened = isReawakened && config.EnableAwakenedTimer; + + float serpentOffering = showReawakened && isReawakened ? reawakenedDuration : gauge.SerpentOffering; + + if (Config.SerpentOfferings.HideWhenInactive && gauge.SerpentOffering <= 0) + { + return; + } + + Config.SerpentOfferings.Label.SetValue(serpentOffering); + + BarHud[] bars = BarUtilities.GetChunkedProgressBars( + config, + showReawakened ? 1 : 2, + showReawakened ? reawakenedDuration : serpentOffering, + showReawakened ? 30f : 100f, + fillColor: reAwakenedReady ? config.AwakenedColor : config.FillColor + ); ; + + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.SerpentOfferings.StrataLevel)); + } + } + } + + public enum ViperCombo + { + SteelFangs = 34606, + DreadFangs = 34607, + HuntersSting = 34608, + SwiftskinsSting = 34609, + SteelMaw = 34614, + DreadMaw = 34615, + HuntersBite = 34616, + SwiftskinsBite = 34617 + } + + public enum ViperComboState + { + None, + Started, + Finisher + } + + [Section("Job Specific Bars")] + [SubSection("Melee", 0)] + [SubSection("Viper", 1)] + public class ViperConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.VPR; + + public new static ViperConfig DefaultConfig() + { + var config = new ViperConfig(); + config.SerpentOfferings.UseChunks = false; + + return config; + } + + [NestedConfig("Vipersight Bar", 30)] + public VipersightBarConfig Vipersight = new VipersightBarConfig( + new(0, -10), + new(254, 10), + new(new Vector4(237f / 255f, 141f / 255f, 7f / 255f, 100f / 100f)) + ); + + [NestedConfig("Rattling Coil Bar", 40)] + public ChunkedBarConfig RattlingCoilGauge = new ChunkedBarConfig( + new(0, -34), + new(254, 10), + new(new Vector4(204f / 255f, 40f / 255f, 40f / 255f, 1f)) + ); + + [NestedConfig("Serpent Offerings Bar", 45)] + public SerpentOfferingsBarConfig SerpentOfferings = new SerpentOfferingsBarConfig( + new(0, -46), + new(254, 10), + new(new Vector4(69f / 255f, 115f / 255f, 202f / 255f, 1f)) + ); + + [NestedConfig("Anguine Tribute Bar", 50)] + public ChunkedBarConfig AnguineTribute = new ChunkedBarConfig( + new(0, -58), + new(254, 10), + new(new Vector4(69f / 255f, 115f / 255f, 202f / 255f, 1f)) + ); + + [Exportable(false)] + public class VipersightBarConfig : ChunkedBarConfig + { + [NestedConfig("Show Glow", 39, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new BarGlowConfig(); + + [ColorEdit4("Combo Start", spacing = true)] + [Order(41)] + public PluginConfigColor ComboStartColor = new(new Vector4(230f / 255f, 33f / 255f, 33f / 255f, 100f / 100f)); + + [ColorEdit4("Flank Ender")] + [Order(42)] + public PluginConfigColor ComboEndFlankColor = new(new Vector4(46f / 255f, 228f / 255f, 42f / 255f, 1f)); + + [ColorEdit4("Hind Ender")] + [Order(43)] + public PluginConfigColor ComboEndHindColor = new(new Vector4(230f / 255f, 33f / 255f, 33f / 255f, 1f)); + + [ColorEdit4("Grim/Default Ender")] + [Order(44)] + public PluginConfigColor ComboEndAOEColor = new(new Vector4(69f / 255f, 115f / 255f, 202f / 255f, 1f)); + + [Checkbox("Invert", spacing = true)] + [Order(45)] + public bool Invert = false; + + public VipersightBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class SerpentOfferingsBarConfig : ChunkedProgressBarConfig + { + [Checkbox("Enable Awakened Timer", spacing = true)] + [Order(46)] + public bool EnableAwakenedTimer = true; + + [ColorEdit4("Ready to Reawaken Color")] + [Order(47)] + public PluginConfigColor AwakenedColor = new(new Vector4(69f / 255f, 115f / 255f, 202f / 255f, 1f)); + + public SerpentOfferingsBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + } + } + } +} \ No newline at end of file diff --git a/Interface/Jobs/WarriorHud.cs b/Interface/Jobs/WarriorHud.cs new file mode 100644 index 0000000..04a0af4 --- /dev/null +++ b/Interface/Jobs/WarriorHud.cs @@ -0,0 +1,279 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using HSUI.Interface.GeneralElements; + +namespace HSUI.Interface.Jobs +{ + public class WarriorHud : JobHud + { + private new WarriorConfig Config => (WarriorConfig)_config; + + public WarriorHud(WarriorConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.SurgingTempestBar.Enabled) + { + positions.Add(Config.Position + Config.SurgingTempestBar.Position); + sizes.Add(Config.SurgingTempestBar.Size); + } + + if (Config.BeastGauge.Enabled) + { + positions.Add(Config.Position + Config.BeastGauge.Position); + sizes.Add(Config.BeastGauge.Size); + } + + if (Config.InnerReleaseBar.Enabled) + { + positions.Add(Config.Position + Config.InnerReleaseBar.Position); + sizes.Add(Config.InnerReleaseBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.SurgingTempestBar.Enabled) + { + DrawSurgingTempestBar(pos, player); + } + + if (Config.BeastGauge.Enabled) + { + DrawBeastGauge(pos, player); + } + + if (Config.InnerReleaseBar.Enabled) + { + DrawInnerReleaseBar(pos, player); + } + } + + private void DrawSurgingTempestBar(Vector2 origin, IPlayerCharacter player) + { + float surgingTempestDuration = Math.Abs(Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 2677)?.RemainingTime ?? 0f); + + if (!Config.SurgingTempestBar.HideWhenInactive || surgingTempestDuration > 0) + { + Config.SurgingTempestBar.Label.SetValue(surgingTempestDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.SurgingTempestBar, surgingTempestDuration, 60f, 0, player); + AddDrawActions(bar.GetDrawActions(origin, Config.SurgingTempestBar.StrataLevel)); + } + } + + private void DrawBeastGauge(Vector2 origin, IPlayerCharacter player) + { + WARGauge gauge = Plugin.JobGauges.Get(); + var nascentChaosDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1897)?.RemainingTime ?? 0f; + + if (!Config.BeastGauge.HideWhenInactive || gauge.BeastGauge > 0 || nascentChaosDuration > 0) + { + Config.BeastGauge.Label.SetValue(gauge.BeastGauge); + + var color = nascentChaosDuration == 0 ? Config.BeastGauge.BeastGaugeColor : Config.BeastGauge.NascentChaosColor; + BarHud[] bars = BarUtilities.GetChunkedProgressBars(Config.BeastGauge, 2, gauge.BeastGauge, 100, 0, player, fillColor: color); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.BeastGauge.StrataLevel)); + } + } + } + + private void DrawInnerReleaseBar(Vector2 origin, IPlayerCharacter player) + { + var innerReleaseStatus = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1177 or 86); + + float innerReleaseDuration = Math.Max(innerReleaseStatus?.RemainingTime ?? 0f, 0f); + int innerReleaseStacks = innerReleaseStatus?.Param ?? 0; + + BarGlowConfig? primalRendGlow = null; + if (Config.InnerReleaseBar.PrimalRendReadyGlowConfig.Enabled) + { + bool isPrimalRendReady = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 2624)?.RemainingTime > 0; + if (isPrimalRendReady) + { + primalRendGlow = Config.InnerReleaseBar.PrimalRendReadyGlowConfig; + } + } + + if (innerReleaseStacks == 0 && Config.InnerReleaseBar.ShowCooldown) + { + uint spellID = 7389; + float maxDuration = SpellHelper.Instance.GetRecastTime(spellID); + float cooldown = SpellHelper.Instance.GetSpellCooldown(spellID); + float currentCooldown = maxDuration - cooldown; + + Config.InnerReleaseBar.Label.SetValue(maxDuration - currentCooldown); + + if (currentCooldown == maxDuration) + { + if (!Config.InnerReleaseBar.HideWhenInactive) + { + BarHud[] bars = BarUtilities.GetChunkedProgressBars( + Config.InnerReleaseBar, + 1, + 1, + 1, + 0, + player, + fillColor: Config.InnerReleaseBar.CooldownFinishedColor, + glowConfig: primalRendGlow, + chunksToGlow: new[] { true } + ); + + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.InnerReleaseBar.StrataLevel)); + } + } + } + else + { + BarHud[] bars = BarUtilities.GetChunkedProgressBars( + Config.InnerReleaseBar, + 1, + currentCooldown, + maxDuration, + 0, + player, + fillColor: Config.InnerReleaseBar.CooldownInProgressColor, + glowConfig: primalRendGlow, + chunksToGlow: new[] { true } + ); + + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.InnerReleaseBar.StrataLevel)); + } + } + + return; + } + + if (!Config.InnerReleaseBar.HideWhenInactive || innerReleaseStacks > 0) + { + float innerReleaseMaxDuration = 15f; + var chunks = new Tuple[3]; + + for (int i = 0; i < 3; i++) + { + chunks[i] = new(Config.InnerReleaseBar.FillColor, i < innerReleaseStacks ? 1 : 0, i == 1 ? Config.InnerReleaseBar.Label : null); + } + + innerReleaseDuration = !Config.InnerReleaseBar.ShowBuffTimerOnActiveChunk + ? innerReleaseMaxDuration + : Math.Min(innerReleaseDuration, innerReleaseMaxDuration); + + Config.InnerReleaseBar.Label.SetValue(innerReleaseDuration); + + BarHud[] bars = BarUtilities.GetChunkedBars(Config.InnerReleaseBar, chunks, player, primalRendGlow, new[] { true, true, true }); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.InnerReleaseBar.StrataLevel)); + } + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Tank", 0)] + [SubSection("Warrior", 1)] + public class WarriorConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.WAR; + public new static WarriorConfig DefaultConfig() + { + var config = new WarriorConfig(); + + config.BeastGauge.UsePartialFillColor = true; + config.InnerReleaseBar.LabelMode = LabelMode.ActiveChunk; + config.InnerReleaseBar.PrimalRendReadyGlowConfig.Color = new PluginConfigColor(new Vector4(246f / 255f, 30f / 255f, 136f / 255f, 100f / 100f)); + + return config; + } + + [NestedConfig("Surging Tempest Bar", 30)] + public ProgressBarConfig SurgingTempestBar = new ProgressBarConfig( + new(0, -32), + new(254, 20), + new PluginConfigColor(new Vector4(255f / 255f, 136f / 255f, 146f / 255f, 100f / 100f)) + ); + + [NestedConfig("Beast Gauge", 35)] + public WarriorBeastGaugeConfig BeastGauge = new WarriorBeastGaugeConfig( + new(0, -10), + new(254, 20), + new PluginConfigColor(new Vector4(0, 0, 0, 0)) + ); + + [NestedConfig("Inner Release Bar", 40)] + public WarriorInnerReleaseBarConfig InnerReleaseBar = new WarriorInnerReleaseBarConfig( + new(0, -54), + new(254, 20), + new PluginConfigColor(new Vector4(255f / 255f, 136f / 255f, 146f / 255f, 100f / 100f)) + ); + } + + [DisableParentSettings("FillColor")] + [Exportable(false)] + public class WarriorBeastGaugeConfig : ChunkedProgressBarConfig + { + [ColorEdit4("Beast Gauge Color", spacing = true)] + [Order(65)] + public PluginConfigColor BeastGaugeColor = new(new Vector4(201f / 255f, 13f / 255f, 13f / 255f, 100f / 100f)); + + [ColorEdit4("Nascent Chaos Color")] + [Order(70)] + public PluginConfigColor NascentChaosColor = new(new Vector4(240f / 255f, 176f / 255f, 0f / 255f, 100f / 100f)); + + public WarriorBeastGaugeConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor) + { + } + } + + [Exportable(false)] + public class WarriorInnerReleaseBarConfig : ChunkedProgressBarConfig + { + [Checkbox("Show Buff Timer On Active Chunk", spacing = true)] + [Order(80)] + public bool ShowBuffTimerOnActiveChunk; + + [Checkbox("Show Inner Release Cooldown", spacing = true)] + [Order(85)] + public bool ShowCooldown; + + [ColorEdit4("Inner Release On Cooldown Color")] + [Order(90)] + public PluginConfigColor CooldownInProgressColor = new(new Vector4(240f / 255f, 176f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Inner Release Ready Color")] + [Order(95)] + public PluginConfigColor CooldownFinishedColor = new(new Vector4(38f / 255f, 192f / 255f, 94f / 255f, 100f / 100f)); + + [NestedConfig("Glow Color (when Primal Rend is ready)", 100, separator = false, spacing = true)] + public BarGlowConfig PrimalRendReadyGlowConfig = new(); + + public WarriorInnerReleaseBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) : base(position, size, fillColor) + { + } + } +} diff --git a/Interface/Jobs/WhiteMageHud.cs b/Interface/Jobs/WhiteMageHud.cs new file mode 100644 index 0000000..56f44b5 --- /dev/null +++ b/Interface/Jobs/WhiteMageHud.cs @@ -0,0 +1,298 @@ +using Dalamud.Game.ClientState.JobGauge.Types; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Jobs +{ + public class WhiteMageHud : JobHud + { + private new WhiteMageConfig Config => (WhiteMageConfig)_config; + + private static readonly List DiaIDs = new() { 143, 144, 1871 }; + private static readonly List DiaDurations = new() { 30, 30, 30 }; + + public WhiteMageHud(WhiteMageConfig config, string? displayName = null) : base(config, displayName) + { + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + List positions = new(); + List sizes = new(); + + if (Config.LilyBar.Enabled) + { + positions.Add(Config.Position + Config.LilyBar.Position); + sizes.Add(Config.LilyBar.Size); + } + + if (Config.DiaBar.Enabled) + { + positions.Add(Config.Position + Config.DiaBar.Position); + sizes.Add(Config.DiaBar.Size); + } + + if (Config.AsylumBar.Enabled) + { + positions.Add(Config.Position + Config.AsylumBar.Position); + sizes.Add(Config.AsylumBar.Size); + } + + if (Config.PresenceOfMindBar.Enabled) + { + positions.Add(Config.Position + Config.PresenceOfMindBar.Position); + sizes.Add(Config.PresenceOfMindBar.Size); + } + + if (Config.PlenaryBar.Enabled) + { + positions.Add(Config.Position + Config.PlenaryBar.Position); + sizes.Add(Config.PlenaryBar.Size); + } + + if (Config.TemperanceBar.Enabled) + { + positions.Add(Config.Position + Config.TemperanceBar.Position); + sizes.Add(Config.TemperanceBar.Size); + } + + return (positions, sizes); + } + + public override void DrawJobHud(Vector2 origin, IPlayerCharacter player) + { + Vector2 pos = origin + Config.Position; + + if (Config.LilyBar.Enabled) + { + DrawLilyBar(pos, player); + } + + if (Config.DiaBar.Enabled) + { + DrawDiaBar(pos, player); + } + + if (Config.AsylumBar.Enabled) + { + DrawAsylumBar(pos, player); + } + + if (Config.PresenceOfMindBar.Enabled) + { + DrawPresenceOfMindBar(pos, player); + } + + if (Config.PlenaryBar.Enabled) + { + DrawPlenaryBar(pos, player); + + } + if (Config.TemperanceBar.Enabled) + { + DrawTemperanceBar(pos, player); + } + } + + private void DrawDiaBar(Vector2 origin, IPlayerCharacter player) + { + var target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + + BarHud? bar = BarUtilities.GetDoTBar(Config.DiaBar, player, target, DiaIDs, DiaDurations); + if (bar != null) + { + AddDrawActions(bar.GetDrawActions(origin, Config.DiaBar.StrataLevel)); + } + } + + private void DrawLilyBar(Vector2 origin, IPlayerCharacter player) + { + WHMGauge gauge = Plugin.JobGauges.Get(); + + const float lilyCooldown = 20000f; + + float GetScale(int num, float timer) => num + (timer / lilyCooldown); + float lilyScale = GetScale(gauge.Lily, gauge.LilyTimer); + + if (!Config.LilyBar.HideWhenInactive || lilyScale > 0) + { + BarHud[] bars = BarUtilities.GetChunkedBars(Config.LilyBar, 3, lilyScale, 3, 0, player, partialFillColor: Config.LilyBar.PartialFillColor); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.LilyBar.StrataLevel)); + } + } + + if (!Config.BloodLilyBar.HideWhenInactive && Config.BloodLilyBar.Enabled || gauge.BloodLily > 0) + { + BarGlowConfig? glow = gauge.BloodLily == 3 ? Config.BloodLilyBar.GlowConfig : null; + BarHud[] bars = BarUtilities.GetChunkedBars(Config.BloodLilyBar, 3, gauge.BloodLily, 3, 0, player, glowConfig: glow, chunksToGlow: new[] { true, true, true }); + foreach (BarHud bar in bars) + { + AddDrawActions(bar.GetDrawActions(origin, Config.BloodLilyBar.StrataLevel)); + } + } + } + + private void DrawAsylumBar(Vector2 origin, IPlayerCharacter player) + { + float asylymDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 739 or 1911 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.AsylumBar.HideWhenInactive || asylymDuration > 0) + { + Config.AsylumBar.Label.SetValue(asylymDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.AsylumBar, asylymDuration, 24f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.AsylumBar.StrataLevel)); + } + } + + private void DrawPresenceOfMindBar(Vector2 origin, IPlayerCharacter player) + { + float presenceOfMindDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 157 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.PresenceOfMindBar.HideWhenInactive || presenceOfMindDuration > 0) + { + Config.PresenceOfMindBar.Label.SetValue(presenceOfMindDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.PresenceOfMindBar, presenceOfMindDuration, 15f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.PresenceOfMindBar.StrataLevel)); + } + } + + private void DrawPlenaryBar(Vector2 origin, IPlayerCharacter player) + { + float plenaryDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1219 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.PlenaryBar.HideWhenInactive || plenaryDuration > 0) + { + Config.PlenaryBar.Label.SetValue(plenaryDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.PlenaryBar, plenaryDuration, 10f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.PlenaryBar.StrataLevel)); + } + } + + private void DrawTemperanceBar(Vector2 origin, IPlayerCharacter player) + { + float temperanceDuration = Utils.StatusListForBattleChara(player).FirstOrDefault(o => o.StatusId is 1872 && o.SourceId == player.GameObjectId)?.RemainingTime ?? 0f; + + if (!Config.TemperanceBar.HideWhenInactive || temperanceDuration > 0) + { + Config.TemperanceBar.Label.SetValue(temperanceDuration); + + BarHud bar = BarUtilities.GetProgressBar(Config.TemperanceBar, temperanceDuration, 20f, 0f, player); + AddDrawActions(bar.GetDrawActions(origin, Config.TemperanceBar.StrataLevel)); + } + } + } + + [Section("Job Specific Bars")] + [SubSection("Healer", 0)] + [SubSection("White Mage", 1)] + public class WhiteMageConfig : JobConfig + { + [JsonIgnore] public override uint JobId => JobIDs.WHM; + public new static WhiteMageConfig DefaultConfig() + { + var config = new WhiteMageConfig(); + + config.UseDefaultPrimaryResourceBar = true; + + config.AsylumBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.PresenceOfMindBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.PlenaryBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + config.TemperanceBar.Label.FontID = FontsConfig.DefaultMediumFontKey; + + return config; + } + + [NestedConfig("Lily Bar", 30)] + public LilyBarConfig LilyBar = new LilyBarConfig( + new(-64, -32), + new(126, 20), + new PluginConfigColor(new(0f / 255f, 64f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Blood Lily Bar", 35)] + public BloodLilyBarConfig BloodLilyBar = new BloodLilyBarConfig( + new(64, -32), + new(126, 20), + new PluginConfigColor(new(199f / 255f, 40f / 255f, 9f / 255f, 100f / 100f)) + ); + + [NestedConfig("Dia Bar", 40)] + public ProgressBarConfig DiaBar = new ProgressBarConfig( + new(0, -10), + new(254, 20), + new PluginConfigColor(new(0f / 255f, 64f / 255f, 255f / 255f, 100f / 100f)) + ); + + [NestedConfig("Asylum Bar", 45)] + public ProgressBarConfig AsylumBar = new ProgressBarConfig( + new(-96, -52), + new(62, 15), + new PluginConfigColor(new(241f / 255f, 217f / 255f, 125f / 255f, 100f / 100f)) + ); + + [NestedConfig("Presence of Mind Bar", 50)] + public ProgressBarConfig PresenceOfMindBar = new ProgressBarConfig( + new(-32, -52), + new(62, 15), + new PluginConfigColor(new(213f / 255f, 124f / 255f, 97f / 255f, 100f / 100f)) + ); + + [NestedConfig("Plenary Bar", 55)] + public ProgressBarConfig PlenaryBar = new ProgressBarConfig( + new(32, -52), + new(62, 15), + new PluginConfigColor(new(26f / 255f, 167f / 255f, 109f / 255f, 100f / 100f)) + ); + + [NestedConfig("Temperance Bar", 60)] + public ProgressBarConfig TemperanceBar = new ProgressBarConfig( + new(96, -52), + new(62, 15), + new PluginConfigColor(new(100f / 255f, 207f / 255f, 211f / 255f, 100f / 100f)) + ); + } + + [Exportable(false)] + public class LilyBarConfig : ChunkedBarConfig + { + [Checkbox("Use Partial Fill Color", spacing = true)] + [Order(65)] + public bool UsePartialFillColor = false; + + [ColorEdit4("Partial Fill Color")] + [Order(66, collapseWith = nameof(UsePartialFillColor))] + public PluginConfigColor PartialFillColor; + + public LilyBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + PartialFillColor = new PluginConfigColor(new(0f / 255f, 64f / 255f, 255f / 255f, 50f / 100f)); + } + } + + [Exportable(false)] + public class BloodLilyBarConfig : ChunkedBarConfig + { + [NestedConfig("Glow Color (when Misery ready)", 60, separator = false, spacing = true)] + public BarGlowConfig GlowConfig = new(); + + public BloodLilyBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor) + : base(position, size, fillColor) + { + GlowConfig.Color = new PluginConfigColor(new(247f / 255f, 177f / 255f, 67f / 255f, 100f / 100f)); + } + } +} diff --git a/Interface/Nameplates/Nameplate.cs b/Interface/Nameplates/Nameplate.cs new file mode 100644 index 0000000..e6935ef --- /dev/null +++ b/Interface/Nameplates/Nameplate.cs @@ -0,0 +1,639 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.StatusEffects; +using Dalamud.Bindings.ImGui; +using System.Collections.Generic; +using System.Numerics; +using Action = System.Action; +using Character = Dalamud.Game.ClientState.Objects.Types.ICharacter; +using StructsCharacter = FFXIVClientStructs.FFXIV.Client.Game.Character.Character; + +namespace HSUI.Interface.Nameplates +{ + public class Nameplate + { + protected NameplateConfig _config; + public bool Enabled => _config.Enabled; + + protected LabelHud _nameLabelHud; + protected LabelHud _titleLabelHud; + + public Nameplate(NameplateConfig config) + { + _config = config; + + _nameLabelHud = new LabelHud(config.NameLabelConfig); + _titleLabelHud = new LabelHud(config.TitleLabelConfig); + } + + protected bool IsVisible(IGameObject? actor) + { + if (!_config.Enabled || + actor == null || + !_config.VisibilityConfig.IsElementVisible(null) || + (_config.OnlyShowWhenTargeted && actor.Address != Plugin.TargetManager.Target?.Address)) + { + return false; + } + + return true; + } + + public virtual List<(StrataLevel, Action)> GetElementsDrawActions(NameplateData data) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + if (!IsVisible(data.GameObject)) { return drawActions; } + + drawActions.AddRange(GetMainLabelDrawActions(data)); + + return drawActions; + } + + protected List<(StrataLevel, Action)> GetMainLabelDrawActions(NameplateData data, NameplateAnchor? barAnchor = null) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + Vector2 origin = _config.Position + (barAnchor?.Position ?? data.ScreenPosition); + + Vector2 swapOffset = Vector2.Zero; + if (_config.SwapLabelsWhenNeeded && (data.IsTitlePrefix || data.Title.Length == 0)) + { + swapOffset = _config.TitleLabelConfig.Position - _config.NameLabelConfig.Position; + } + + // name + float nameAlpha = _config.RangeConfig.AlphaForDistance(data.Distance, _config.NameLabelConfig.Color.Vector.W); + var (nameText, namePos, nameSize, nameColor) = _nameLabelHud.PreCalculate(origin + swapOffset, barAnchor?.Size, data.GameObject, data.Name, isPlayerName: data.Kind == ObjectKind.Player); + drawActions.Add((_config.NameLabelConfig.StrataLevel, () => + { + _nameLabelHud.DrawLabel(nameText, namePos, nameSize, nameColor, nameAlpha); + } + )); + + // title + float titleAlpha = _config.RangeConfig.AlphaForDistance(data.Distance, _config.TitleLabelConfig.Color.Vector.W); + var (titleText, titlePos, titleSize, titleColor) = _titleLabelHud.PreCalculate(origin - swapOffset, barAnchor?.Size, data.GameObject, title: data.Title); + if (data.Title.Length > 0) + { + drawActions.Add((_config.TitleLabelConfig.StrataLevel, () => + { + _titleLabelHud.DrawLabel(titleText, titlePos, titleSize, titleColor, titleAlpha); + } + )); + } + + return drawActions; + } + } + + public class NameplateWithBar : Nameplate + { + protected NameplateBarConfig BarConfig => ((NameplateWithBarConfig)_config).GetBarConfig(); + + private LabelHud _leftLabelHud; + private LabelHud _rightLabelHud; + private LabelHud _optionalLabelHud; + + public NameplateWithBar(NameplateConfig config) : base(config) + { + _leftLabelHud = new LabelHud(BarConfig.LeftLabelConfig); + _rightLabelHud = new LabelHud(BarConfig.RightLabelConfig); + _optionalLabelHud = new LabelHud(BarConfig.OptionalLabelConfig); + } + + public (bool, bool) GetMouseoverState(NameplateData data) + { + if (data.GameObject is not ICharacter character) { return (false, false); } + if (!BarConfig.IsVisible(character.CurrentHp, character.MaxHp) || BarConfig.DisableInteraction) + { + return (false, false); + } + + bool targeted = Plugin.TargetManager.Target?.Address == character.Address; + Vector2 barSize = BarConfig.GetSize(targeted); + + Vector2 origin = _config.Position + data.ScreenPosition; + Vector2 barPos = Utils.GetAnchoredPosition(origin, barSize, BarConfig.Anchor) + BarConfig.Position; + var (areaStart, areaEnd) = BarConfig.MouseoverAreaConfig.GetArea(barPos, barSize); + + bool isHovering = ImGui.IsMouseHoveringRect(areaStart, areaEnd); + bool ignoreMouseover = BarConfig.MouseoverAreaConfig.Enabled && BarConfig.MouseoverAreaConfig.Ignore; + + return (isHovering, ignoreMouseover); + } + + public unsafe List<(StrataLevel, Action)> GetBarDrawActions(NameplateData data) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + if (!IsVisible(data.GameObject)) { return drawActions; } + if (data.GameObject is not Character character) { return drawActions; } + + uint currentHp = character.CurrentHp; + uint maxHp = character.MaxHp; + + if (!BarConfig.IsVisible(currentHp, maxHp)) { return drawActions; } + + // colors + PluginConfigColor fillColor = GetFillColor(character, currentHp, maxHp); + fillColor = fillColor.WithAlpha(_config.RangeConfig.AlphaForDistance(data.Distance, fillColor.Vector.W)); + + PluginConfigColor bgColor = GetBackgroundColor(character); + bgColor = bgColor.WithAlpha(_config.RangeConfig.AlphaForDistance(data.Distance, bgColor.Vector.W)); + + bool targeted = character.Address == Plugin.TargetManager.Target?.Address; + PluginConfigColor borderColor = targeted ? BarConfig.TargetedBorderColor : BarConfig.BorderColor; + borderColor = borderColor.WithAlpha( + _config.RangeConfig.AlphaForDistance(data.Distance, BarConfig.BorderColor.Vector.W) + ); + + // bar + Vector2 barSize = BarConfig.GetSize(targeted); + Rect background = new Rect(BarConfig.Position, barSize, bgColor); + Rect healthFill = BarUtilities.GetFillRect(BarConfig.Position, barSize, BarConfig.FillDirection, fillColor, currentHp, maxHp); + + BarHud bar = new BarHud( + BarConfig.ID, + BarConfig.DrawBorder, + borderColor, + targeted ? BarConfig.TargetedBorderThickness : BarConfig.BorderThickness, + BarConfig.Anchor, + character, + current: currentHp, + max: maxHp, + shadowConfig: BarConfig.ShadowConfig, + barTextureName: BarConfig.BarTextureName, + barTextureDrawMode: BarConfig.BarTextureDrawMode + ); + + bar.NeedsInputs = true; + bar.SetBackground(background); + bar.AddForegrounds(healthFill); + + // shield + PluginConfigColor shieldColor = BarConfig.ShieldConfig.Color.WithAlpha( + _config.RangeConfig.AlphaForDistance(data.Distance, BarConfig.ShieldConfig.Color.Vector.W) + ); + BarUtilities.AddShield(bar, BarConfig, BarConfig.ShieldConfig, character, healthFill.Size, shieldColor); + + // draw bar + Vector2 origin = _config.Position + data.ScreenPosition; + drawActions.AddRange(bar.GetDrawActions(origin, _config.StrataLevel)); + + // mouseover area + BarHud? mouseoverAreaBar = BarConfig.MouseoverAreaConfig.GetBar( + BarConfig.Position, + barSize, + BarConfig.ID + "_mouseoverArea", + BarConfig.Anchor + ); + + if (mouseoverAreaBar != null) + { + drawActions.AddRange(mouseoverAreaBar.GetDrawActions(origin, StrataLevel.HIGHEST)); + } + + // labels + Vector2 barPos = Utils.GetAnchoredPosition(origin, barSize, BarConfig.Anchor) + BarConfig.Position; + LabelHud[] labels = GetLabels(maxHp); + foreach (LabelHud label in labels) + { + LabelConfig labelConfig = (LabelConfig)label.GetConfig(); + float alpha = _config.RangeConfig.AlphaForDistance(data.Distance, labelConfig.Color.Vector.W); + var (labelText, labelPos, labelSize, labelColor) = label.PreCalculate(barPos, barSize, data.GameObject, data.Name, currentHp, maxHp, data.Kind == ObjectKind.Player); + + drawActions.Add((labelConfig.StrataLevel, () => + { + label.DrawLabel(labelText, labelPos, labelSize, labelColor, alpha); + } + )); + } + + return drawActions; + } + + public override List<(StrataLevel, Action)> GetElementsDrawActions(NameplateData data) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + if (!IsVisible(data.GameObject)) { return drawActions; } + + NameplateAnchor? barAnchor = GetBarAnchor(data); + drawActions.AddRange(GetMainLabelDrawActions(data, barAnchor)); + + return drawActions; + } + + protected virtual NameplateAnchor? GetBarAnchor(NameplateData data) + { + if (data.GameObject is Character chara && + BarConfig.IsVisible(chara.CurrentHp, chara.MaxHp)) + { + bool targeted = Plugin.TargetManager.Target?.Address == data.GameObject.Address; + Vector2 size = BarConfig.GetSize(targeted); + Vector2 pos = Utils.GetAnchoredPosition(data.ScreenPosition + BarConfig.Position, size, BarConfig.Anchor); + + return new NameplateAnchor(pos, size); + } + + return null; + } + + private LabelHud[] GetLabels(uint maxHp) + { + List labels = new List(); + + if (BarConfig.HideHealthIfPossible && maxHp <= 0) + { + if (!Utils.IsHealthLabel(BarConfig.LeftLabelConfig)) + { + labels.Add(_leftLabelHud); + } + + if (!Utils.IsHealthLabel(BarConfig.RightLabelConfig)) + { + labels.Add(_rightLabelHud); + } + + if (!Utils.IsHealthLabel(BarConfig.OptionalLabelConfig)) + { + labels.Add(_optionalLabelHud); + } + } + else + { + labels.Add(_leftLabelHud); + labels.Add(_rightLabelHud); + labels.Add(_optionalLabelHud); + } + + return labels.ToArray(); + } + + protected virtual PluginConfigColor GetFillColor(Character character, uint currentHp, uint maxHp) + { + return ColorUtils.ColorForCharacter( + character, + currentHp, + maxHp, + false, + false, + BarConfig.ColorByHealth + ) ?? BarConfig.FillColor; + } + + protected virtual PluginConfigColor GetBackgroundColor(Character character) + { + return BarConfig.BackgroundColor; + } + } + + public class NameplateWithBarAndExtras : NameplateWithBar + { + public NameplateWithBarAndExtras(NameplateConfig config) : base(config) + { + } + + public override List<(StrataLevel, Action)> GetElementsDrawActions(NameplateData data) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + if (!IsVisible(data.GameObject)) { return drawActions; } + + NameplateAnchor? barAnchor = GetBarAnchor(data); + Vector2 origin = _config.Position + (barAnchor?.Position ?? data.ScreenPosition); + + Vector2 swapOffset = Vector2.Zero; + if (_config.SwapLabelsWhenNeeded && (data.IsTitlePrefix || data.Title.Length == 0)) + { + swapOffset = _config.TitleLabelConfig.Position - _config.NameLabelConfig.Position; + } + + // name + float nameAlpha = _config.RangeConfig.AlphaForDistance(data.Distance, _config.NameLabelConfig.Color.Vector.W); + var (nameText, namePos, nameSize, nameColor) = _nameLabelHud.PreCalculate(origin + swapOffset, barAnchor?.Size, data.GameObject, data.Name, isPlayerName: data.Kind == ObjectKind.Player); + drawActions.Add((_config.NameLabelConfig.StrataLevel, () => + { + _nameLabelHud.DrawLabel(nameText, namePos, nameSize, nameColor, nameAlpha); + } + )); + + // title + float titleAlpha = _config.RangeConfig.AlphaForDistance(data.Distance, _config.TitleLabelConfig.Color.Vector.W); + var (titleText, titlePos, titleSize, titleColor) = _titleLabelHud.PreCalculate(origin - swapOffset, barAnchor?.Size, data.GameObject, title: data.Title); + if (data.Title.Length > 0) + { + drawActions.Add((_config.TitleLabelConfig.StrataLevel, () => + { + _titleLabelHud.DrawLabel(titleText, titlePos, titleSize, titleColor, titleAlpha); + } + )); + } + + // extras anchor + NameplateExtrasAnchors extrasAnchors = new NameplateExtrasAnchors( + barAnchor, + _config.NameLabelConfig.Enabled ? new NameplateAnchor(namePos, nameSize) : null, + _config.TitleLabelConfig.Enabled && data.Title.Length > 0 ? new NameplateAnchor(titlePos, titleSize) : null + ); + + drawActions.AddRange(GetExtrasDrawActions(data, extrasAnchors)); + + return drawActions; + } + + protected virtual List<(StrataLevel, Action)> GetExtrasDrawActions(NameplateData data, NameplateExtrasAnchors anchors) + { + // override + return new List<(StrataLevel, Action)>(); + } + } + + public class NameplateWithPlayerBar : NameplateWithBarAndExtras + { + private NameplateWithPlayerBarConfig Config => (NameplateWithPlayerBarConfig)_config; + + public NameplateWithPlayerBar(NameplateWithPlayerBarConfig config) : base(config) + { + } + + protected override List<(StrataLevel, Action)> GetExtrasDrawActions(NameplateData data, NameplateExtrasAnchors anchors) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + if (data.GameObject is not IPlayerCharacter character) { return drawActions; } + + float alpha = _config.RangeConfig.AlphaForDistance(data.Distance); + + // role/job icon + if (Config.RoleIconConfig.Enabled) + { + NameplateAnchor? anchor = anchors.GetAnchor(Config.RoleIconConfig.NameplateLabelAnchor, Config.RoleIconConfig.PrioritizeHealthBarAnchor); + anchor = anchor ?? new NameplateAnchor(data.ScreenPosition, Vector2.Zero); + + uint jobId = character.ClassJob.RowId; + uint iconId = Config.RoleIconConfig.UseRoleIcons ? + JobsHelper.RoleIconIDForJob(jobId, Config.RoleIconConfig.UseSpecificDPSRoleIcons) : + JobsHelper.IconIDForJob(jobId, (uint)Config.RoleIconConfig.Style); + + if (iconId > 0) + { + var pos = Utils.GetAnchoredPosition(anchor.Value.Position, -anchor.Value.Size, Config.RoleIconConfig.FrameAnchor); + var iconPos = Utils.GetAnchoredPosition(pos + Config.RoleIconConfig.Position, Config.RoleIconConfig.Size, Config.RoleIconConfig.Anchor); + + drawActions.Add((Config.RoleIconConfig.StrataLevel, () => + { + DrawHelper.DrawInWindow(_config.ID + "_jobIcon", iconPos, Config.RoleIconConfig.Size, false, (drawList) => + { + DrawHelper.DrawIcon(iconId, iconPos, Config.RoleIconConfig.Size, false, alpha, drawList); + }); + } + )); + } + } + + // state icon + if (Config.StateIconConfig.Enabled && + data.NamePlateIconId > 0 && + Config.StateIconConfig.ShouldDrawIcon(data.NamePlateIconId)) + { + NameplateAnchor? anchor = anchors.GetAnchor(Config.StateIconConfig.NameplateLabelAnchor, Config.StateIconConfig.PrioritizeHealthBarAnchor); + anchor = anchor ?? new NameplateAnchor(data.ScreenPosition, Vector2.Zero); + + var pos = Utils.GetAnchoredPosition(anchor.Value.Position, -anchor.Value.Size, Config.StateIconConfig.FrameAnchor); + var iconPos = Utils.GetAnchoredPosition(pos + Config.StateIconConfig.Position, Config.StateIconConfig.Size, Config.StateIconConfig.Anchor); + + drawActions.Add((Config.StateIconConfig.StrataLevel, () => + { + DrawHelper.DrawInWindow(_config.ID + "_stateIcon", iconPos, Config.StateIconConfig.Size, false, (drawList) => + { + DrawHelper.DrawIcon((uint)data.NamePlateIconId, iconPos, Config.StateIconConfig.Size, false, alpha, drawList); + }); + } + )); + } + + return drawActions; + } + + protected override PluginConfigColor GetFillColor(Character character, uint currentHp, uint maxHp) + { + NameplatePlayerBarConfig config = (NameplatePlayerBarConfig)BarConfig; + + return ColorUtils.ColorForCharacter( + character, + currentHp, + maxHp, + config.UseJobColor, + config.UseRoleColor, + config.ColorByHealth + ) ?? config.FillColor; + } + + protected override PluginConfigColor GetBackgroundColor(Character character) + { + NameplatePlayerBarConfig config = (NameplatePlayerBarConfig)BarConfig; + + if (config.UseJobColorAsBackgroundColor) + { + return GlobalColors.Instance.SafeColorForJobId(character.ClassJob.RowId); + } + else if (config.UseRoleColorAsBackgroundColor) + { + return GlobalColors.Instance.SafeRoleColorForJobId(character.ClassJob.RowId); + } + + return config.BackgroundColor; + } + } + + public class NameplateWithEnemyBar : NameplateWithBarAndExtras + { + private NameplateWithEnemyBarConfig Config => (NameplateWithEnemyBarConfig)_config; + + private LabelHud _orderLabelHud; + private StatusEffectsListHud _debuffsHud; + private NameplateCastbarHud _castbarHud; + + public NameplateWithEnemyBar(NameplateWithEnemyBarConfig config) : base(config) + { + _orderLabelHud = new LabelHud(config.BarConfig.OrderLabelConfig); + _debuffsHud = new StatusEffectsListHud(config.DebuffsConfig); + _castbarHud = new NameplateCastbarHud(config.CastbarConfig); + } + + public void StopPreview() + { + _debuffsHud.StopPreview(); + _castbarHud.StopPreview(); + } + + protected override List<(StrataLevel, Action)> GetExtrasDrawActions(NameplateData data, NameplateExtrasAnchors anchors) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + if (data.GameObject is not Character character) { return drawActions; } + + NameplateEnemyBarConfig barConfig = Config.BarConfig; + NameplateAnchor? anchor = barConfig.IsVisible(character.CurrentHp, character.MaxHp) ? anchors.BarAnchor : anchors.NameLabelAnchor; + anchor = anchor ?? new NameplateAnchor(_config.Position + data.ScreenPosition, Vector2.Zero); + + // order label + float alpha = _config.RangeConfig.AlphaForDistance(data.Distance, barConfig.OrderLabelConfig.Color.Vector.W); + + barConfig.OrderLabelConfig.SetText(data.Order); + var (labelText, labelPos, labelSize, labelColor) = _orderLabelHud.PreCalculate(anchor.Value.Position, anchor.Value.Size, data.GameObject); + drawActions.Add((barConfig.OrderLabelConfig.StrataLevel, () => + { + _orderLabelHud.DrawLabel(labelText, labelPos, labelSize, labelColor, alpha); + } + )); + + // debuffs + Vector2 buffsPos = Utils.GetAnchoredPosition(anchor.Value.Position, -anchor.Value.Size, Config.DebuffsConfig.HealthBarAnchor); + drawActions.Add((Config.DebuffsConfig.StrataLevel, () => + { + _debuffsHud.Actor = character; + _debuffsHud.PrepareForDraw(buffsPos); + _debuffsHud.Draw(buffsPos); + } + )); + + // castbar + Vector2 castbarPos = Utils.GetAnchoredPosition(anchor.Value.Position, -anchor.Value.Size, Config.CastbarConfig.HealthBarAnchor); + drawActions.Add((Config.CastbarConfig.StrataLevel, () => + { + _castbarHud.ParentSize = anchor.Value.Size; + _castbarHud.Actor = character; + _castbarHud.PrepareForDraw(castbarPos); + _castbarHud.Draw(castbarPos); + } + )); + + // icon + if (Config.IconConfig.Enabled && data.NamePlateIconId > 0) + { + anchor = anchors.GetAnchor(Config.IconConfig.NameplateLabelAnchor, Config.IconConfig.PrioritizeHealthBarAnchor); + anchor = anchor ?? new NameplateAnchor(data.ScreenPosition, Vector2.Zero); + + var pos = Utils.GetAnchoredPosition(_config.Position + anchor.Value.Position, -anchor.Value.Size, Config.IconConfig.FrameAnchor); + var iconPos = Utils.GetAnchoredPosition(pos + Config.IconConfig.Position, Config.IconConfig.Size, Config.IconConfig.Anchor); + + drawActions.Add((Config.IconConfig.StrataLevel, () => + { + DrawHelper.DrawInWindow(_config.ID + "_enemyIcon", iconPos, Config.IconConfig.Size, false, (drawList) => + { + DrawHelper.DrawIcon((uint)data.NamePlateIconId, iconPos, Config.IconConfig.Size, false, alpha, drawList); + }); + } + )); + + } + + return drawActions; + } + + protected override unsafe PluginConfigColor GetFillColor(Character character, uint currentHp, uint maxHp) + { + NameplateEnemyBarConfig config = (NameplateEnemyBarConfig)BarConfig; + + bool targetingPlayer = character.TargetObjectId == Plugin.ObjectTable.LocalPlayer?.GameObjectId; + if (targetingPlayer && config.UseCustomColorWhenBeingTargeted) + { + return config.CustomColorWhenBeingTargeted; + } + + if (config.UseStateColor) + { + StructsCharacter* chara = (StructsCharacter*)character.Address; + byte nameplateColorId = chara->GetNamePlateColorType(); + + switch (nameplateColorId) { + case 7: return (character.StatusFlags & StatusFlags.Hostile) != 0 ? config.UnengagedHostileColor : config.UnengagedColor; + case 9: return config.EngagedColor; + case 10: return config.ClaimedColor; + case 11: return config.UnclaimedColor; + default: break; + } + } + + return base.GetFillColor(character, currentHp, maxHp); + } + } + + #region utils + public struct NameplateAnchor + { + public Vector2 Position; + public Vector2 Size; + + internal NameplateAnchor(Vector2 position, Vector2 size) + { + Position = position; + Size = size; + } + } + + public struct NameplateExtrasAnchors + { + public NameplateAnchor? BarAnchor; + public NameplateAnchor? NameLabelAnchor; + public NameplateAnchor? TitleLabelAnchor; + public NameplateAnchor? HighestLabelAnchor; + public NameplateAnchor? LowestLabelAnchor; + private NameplateAnchor? DefaultLabelAnchor; + + internal NameplateExtrasAnchors(NameplateAnchor? barAnchor, NameplateAnchor? nameLabelAnchor, NameplateAnchor? titleLabelAnchor) + { + BarAnchor = barAnchor; + NameLabelAnchor = nameLabelAnchor; + TitleLabelAnchor = titleLabelAnchor; + DefaultLabelAnchor = nameLabelAnchor; + + float nameY = -1; + if (nameLabelAnchor.HasValue) + { + nameY = nameLabelAnchor.Value.Position.Y; + } + + float titleY = -1; + if (titleLabelAnchor.HasValue) + { + titleY = titleLabelAnchor.Value.Position.Y; + } + + if (nameY == -1) + { + DefaultLabelAnchor = titleLabelAnchor; + } + else if (nameY < titleY) + { + HighestLabelAnchor = nameLabelAnchor; + LowestLabelAnchor = titleLabelAnchor; + } + else if (nameY > titleY) + { + HighestLabelAnchor = titleLabelAnchor; + LowestLabelAnchor = nameLabelAnchor; + } + } + + internal NameplateAnchor? GetAnchor(NameplateLabelAnchor label, bool prioritizeHealthBar) + { + if (prioritizeHealthBar && BarAnchor != null) { return BarAnchor; } + + NameplateAnchor? labelAnchor = null; + + switch (label) + { + case NameplateLabelAnchor.Name: labelAnchor = NameLabelAnchor; break; + case NameplateLabelAnchor.Title: labelAnchor = TitleLabelAnchor; break; + case NameplateLabelAnchor.Highest: labelAnchor = HighestLabelAnchor; break; + case NameplateLabelAnchor.Lowest: labelAnchor = LowestLabelAnchor; break; + } + + return labelAnchor ?? DefaultLabelAnchor; + } + #endregion + } +} diff --git a/Interface/Nameplates/NameplateConfig.cs b/Interface/Nameplates/NameplateConfig.cs new file mode 100644 index 0000000..6338534 --- /dev/null +++ b/Interface/Nameplates/NameplateConfig.cs @@ -0,0 +1,788 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.StatusEffects; +using System; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + public enum NameplatesOcclusionMode + { + None = 0, + Simple = 1, + Full + }; + + public enum NameplatesOcclusionType + { + Walls = 0, + WallsAndObjects = 1 + }; + + [DisableParentSettings("Strata", "Position")] + [Section("Nameplates")] + [SubSection("General", 0)] + public class NameplatesGeneralConfig : MovablePluginConfigObject + { + public new static NameplatesGeneralConfig DefaultConfig() => new NameplatesGeneralConfig(); + + [Combo("Occlusion Mode", new string[] { "Disabled", "Simple", "Full" }, help = "This controls wheter you'll see nameplates through walls and objects.\n\nDisabled: Nameplates will always be seen for units in range.\nSimple: Uses simple calculations to check if a nameplate is being covered by walls or objects. Use this for better performance.\nFull: Uses more complex calculations to check if a nameplate is being covered by walls or objects. Use this for better results.")] + [Order(10)] + public NameplatesOcclusionMode OcclusionMode = NameplatesOcclusionMode.Full; + + [Combo("Occlusion Type", new string[] { "Walls", "Walls and Objects" }, help = "This controls which kind of objects will cover nameplates.\n\n\nWalls: Default setting. Only walls will cover nameplates.\n\nWalls and Objects: Some objects like columns and trees will also cover nameplates.\nThis Occlusion Type can yield some unexpected results like nameplates for NPCs behind counters not being visible.")] + [Order(11)] + public NameplatesOcclusionType OcclusionType = NameplatesOcclusionType.Walls; + + [Checkbox("Try to keep nameplates on screen", spacing = true, help = "Disclaimer: HSUI relies heavily on the the game's default nameplates so this setting won't be a huge improvement.\nThis setting tries to prevent nameplates from being cutoff in the border of the screen, but it won't keep showing nameplates that the game wouldn't.")] + [Order(20)] + public bool ClampToScreen = true; + + [Checkbox("Always show nameplate for target")] + [Order(21)] + public bool AlwaysShowTargetNameplate = true; + + public int RaycastFlag() => OcclusionType == NameplatesOcclusionType.WallsAndObjects ? 0x2000 : 0x4000; + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Nameplates")] + [SubSection("Player", 0)] + public class PlayerNameplateConfig : NameplateWithPlayerBarConfig + { + public PlayerNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplatePlayerBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static PlayerNameplateConfig DefaultConfig() + { + return NameplatesHelper.GetNameplateWithBarConfig( + 0xFFD0E5E0, + 0xFF30444A, + HUDConstants.DefaultPlayerNameplateBarSize + ); + } + } + + [DisableParentSettings("HideWhenInactive", "TitleLabelConfig", "SwapLabelsWhenNeeded")] + [Section("Nameplates")] + [SubSection("Enemies", 0)] + public class EnemyNameplateConfig : NameplateWithEnemyBarConfig + { + public EnemyNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplateEnemyBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static EnemyNameplateConfig DefaultConfig() + { + EnemyNameplateConfig config = NameplatesHelper.GetNameplateWithBarConfig( + 0xFF993535, + 0xFF000000, + HUDConstants.DefaultEnemyNameplateBarSize + ); + + config.SwapLabelsWhenNeeded = false; + + config.NameLabelConfig.Position = new Vector2(-8, 0); + config.NameLabelConfig.Text = "Lv[level] [name]"; + config.NameLabelConfig.FrameAnchor = DrawAnchor.TopRight; + config.NameLabelConfig.TextAnchor = DrawAnchor.Right; + config.NameLabelConfig.Color = PluginConfigColor.FromHex(0xFFFFFFFF); + + config.BarConfig.LeftLabelConfig.Enabled = true; + config.BarConfig.OnlyShowWhenNotFull = false; + + // debuffs + LabelConfig durationConfig = new LabelConfig(new Vector2(0, -4), "", DrawAnchor.Bottom, DrawAnchor.Center); + durationConfig.FontID = FontsConfig.DefaultMediumFontKey; + + LabelConfig stacksConfig = new LabelConfig(new Vector2(-3, 4), "", DrawAnchor.TopRight, DrawAnchor.Center); + durationConfig.FontID = FontsConfig.DefaultMediumFontKey; + stacksConfig.Color = new(Vector4.UnitW); + stacksConfig.OutlineColor = new(Vector4.One); + + StatusEffectIconConfig iconConfig = new StatusEffectIconConfig(durationConfig, stacksConfig); + iconConfig.Size = new Vector2(30, 30); + iconConfig.DispellableBorderConfig.Enabled = false; + + Vector2 pos = new Vector2(2, -20); + Vector2 size = new Vector2(230, 70); + + EnemyNameplateStatusEffectsListConfig debuffs = new EnemyNameplateStatusEffectsListConfig( + DrawAnchor.TopLeft, + pos, + size, + false, + true, + false, + GrowthDirections.Right | GrowthDirections.Up, + iconConfig + ); + debuffs.Limit = 7; + debuffs.ShowPermanentEffects = true; + debuffs.IconConfig.DispellableBorderConfig.Enabled = false; + debuffs.IconPadding = new Vector2(1, 6); + debuffs.ShowOnlyMine = true; + debuffs.ShowTooltips = false; + debuffs.DisableInteraction = true; + config.DebuffsConfig = debuffs; + + // castbar + Vector2 castbarSize = new Vector2(config.BarConfig.Size.X, 10); + + LabelConfig castNameConfig = new LabelConfig(new Vector2(0, -1), "", DrawAnchor.Center, DrawAnchor.Center); + castNameConfig.FontID = FontsConfig.DefaultSmallFontKey; + + NumericLabelConfig castTimeConfig = new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + castTimeConfig.Enabled = false; + castTimeConfig.FontID = FontsConfig.DefaultSmallFontKey; + castTimeConfig.NumberFormat = 1; + + NameplateCastbarConfig castbarConfig = new NameplateCastbarConfig(Vector2.Zero, castbarSize, castNameConfig, castTimeConfig); + castbarConfig.HealthBarAnchor = DrawAnchor.BottomLeft; + castbarConfig.Anchor = DrawAnchor.TopLeft; + castbarConfig.ShowIcon = false; + config.CastbarConfig = castbarConfig; + + return config; + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Nameplates")] + [SubSection("Party Members", 0)] + public class PartyMembersNameplateConfig : NameplateWithPlayerBarConfig + { + public PartyMembersNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplatePlayerBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static PartyMembersNameplateConfig DefaultConfig() + { + PartyMembersNameplateConfig config = NameplatesHelper.GetNameplateWithBarConfig( + 0xFFD0E5E0, + 0xFF000000, + HUDConstants.DefaultPlayerNameplateBarSize + ); + + config.BarConfig.UseRoleColor = true; + config.NameLabelConfig.UseRoleColor = true; + config.TitleLabelConfig.UseRoleColor = true; + return config; + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Nameplates")] + [SubSection("Alliance Members", 0)] + public class AllianceMembersNameplateConfig : NameplateWithPlayerBarConfig + { + public AllianceMembersNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplatePlayerBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static AllianceMembersNameplateConfig DefaultConfig() + { + return NameplatesHelper.GetNameplateWithBarConfig( + 0xFF99BE46, + 0xFF3D4C1C, + HUDConstants.DefaultPlayerNameplateBarSize + ); + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Nameplates")] + [SubSection("Friends", 0)] + public class FriendPlayerNameplateConfig : NameplateWithPlayerBarConfig + { + public FriendPlayerNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplatePlayerBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static FriendPlayerNameplateConfig DefaultConfig() + { + return NameplatesHelper.GetNameplateWithBarConfig( + 0xFFEB6211, + 0xFF4A2008, + HUDConstants.DefaultPlayerNameplateBarSize + ); + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Nameplates")] + [SubSection("Other Players", 0)] + public class OtherPlayerNameplateConfig : NameplateWithPlayerBarConfig + { + public OtherPlayerNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplatePlayerBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static OtherPlayerNameplateConfig DefaultConfig() + { + return NameplatesHelper.GetNameplateWithBarConfig( + 0xFF91BBD8, + 0xFF33434E, + HUDConstants.DefaultPlayerNameplateBarSize + ); + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Nameplates")] + [SubSection("Pets", 0)] + public class PetNameplateConfig : NameplateWithNPCBarConfig + { + public PetNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplateBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static PetNameplateConfig DefaultConfig() + { + PetNameplateConfig config = NameplatesHelper.GetNameplateWithBarConfig( + 0xFFD1E5C8, + 0xFF2A2F28, + HUDConstants.DefaultPlayerNameplateBarSize + ); + config.OnlyShowWhenTargeted = true; + config.SwapLabelsWhenNeeded = false; + config.NameLabelConfig.Text = "Lv[level] [name]"; + config.NameLabelConfig.FontID = FontsConfig.DefaultSmallFontKey; + config.TitleLabelConfig.FontID = FontsConfig.DefaultSmallFontKey; + + return config; + } + } + + [DisableParentSettings("HideWhenInactive")] + [Section("Nameplates")] + [SubSection("NPCs", 0)] + public class NPCNameplateConfig : NameplateWithNPCBarConfig + { + public NPCNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplateBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig, barConfig) + { + } + + public new static NPCNameplateConfig DefaultConfig() + { + NPCNameplateConfig config = NameplatesHelper.GetNameplateWithBarConfig( + 0xFFD1E5C8, + 0xFF3A4b1E, + HUDConstants.DefaultPlayerNameplateBarSize + ); + config.NameLabelConfig.Position = new Vector2(0, -20); + config.TitleLabelConfig.Position = Vector2.Zero; + + return config; + } + } + + [DisableParentSettings("HideWhenInactive", "SwapLabelsWhenNeeded")] + [Section("Nameplates")] + [SubSection("Minions", 0)] + public class MinionNPCNameplateConfig : NameplateConfig + { + public MinionNPCNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig) + : base(position, nameLabel, titleLabelConfig) + { + } + + public new static MinionNPCNameplateConfig DefaultConfig() + { + MinionNPCNameplateConfig config = NameplatesHelper.GetNameplateConfig(0xFFFFFFFF, 0xFF000000); + config.OnlyShowWhenTargeted = true; + config.SwapLabelsWhenNeeded = false; + config.NameLabelConfig.Position = new Vector2(0, -17); + config.NameLabelConfig.FontID = FontsConfig.DefaultSmallFontKey; + config.TitleLabelConfig.Position = new Vector2(0, 0); + config.TitleLabelConfig.FontID = FontsConfig.DefaultSmallFontKey; + + return config; + } + } + + [DisableParentSettings("HideWhenInactive", "SwapLabelsWhenNeeded")] + [Section("Nameplates")] + [SubSection("Objects", 0)] + public class ObjectsNameplateConfig : NameplateConfig + { + public ObjectsNameplateConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig) + : base(position, nameLabel, titleLabelConfig) + { + } + + public new static ObjectsNameplateConfig DefaultConfig() + { + ObjectsNameplateConfig config = NameplatesHelper.GetNameplateConfig(0xFFFFFFFF, 0xFF000000); + config.SwapLabelsWhenNeeded = false; + + return config; + } + } + + public class NameplateConfig : MovablePluginConfigObject + { + [Checkbox("Only show when targeted")] + [Order(1)] + public bool OnlyShowWhenTargeted = false; + + [Checkbox("Swap Name and Title labels when needed", spacing = true, help = "This will swap the contents of these labels depending on if the title goes before or after the name of a player.")] + [Order(20)] + public bool SwapLabelsWhenNeeded = true; + + [NestedConfig("Name Label", 21)] + public EditableLabelConfig NameLabelConfig = null!; + + [NestedConfig("Title Label", 22)] + public EditableNonFormattableLabelConfig TitleLabelConfig = null!; + + [NestedConfig("Change Alpha Based on Range", 145)] + public NameplateRangeConfig RangeConfig = new(); + + [NestedConfig("Visibility", 200)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public NameplateConfig(Vector2 position, EditableLabelConfig nameLabelConfig, EditableNonFormattableLabelConfig titleLabelConfig) + : base() + { + Position = position; + NameLabelConfig = nameLabelConfig; + TitleLabelConfig = titleLabelConfig; + } + + public NameplateConfig() : base() { } // don't remove + } + + public interface NameplateWithBarConfig + { + public NameplateBarConfig GetBarConfig(); + } + + public class NameplateWithNPCBarConfig : NameplateConfig, NameplateWithBarConfig + { + [NestedConfig("Health Bar", 40)] + public NameplateBarConfig BarConfig = null!; + + public NameplateBarConfig GetBarConfig() => BarConfig; + + public NameplateWithNPCBarConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplateBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig) + { + BarConfig = barConfig; + } + + public NameplateWithNPCBarConfig() : base() { } // don't remove + } + + public class NameplateWithPlayerBarConfig : NameplateConfig, NameplateWithBarConfig + { + [NestedConfig("Health Bar", 40)] + public NameplatePlayerBarConfig BarConfig = null!; + + [NestedConfig("Role/Job Icon", 50)] + public NameplateRoleJobIconConfig RoleIconConfig = new NameplateRoleJobIconConfig( + new Vector2(-5, 0), + new Vector2(30, 30), + DrawAnchor.Right, + DrawAnchor.Left + ) + { Strata = StrataLevel.LOWEST }; + + [NestedConfig("Player State Icon", 55)] + public NameplatePlayerIconConfig StateIconConfig = new NameplatePlayerIconConfig( + new Vector2(5, 0), + new Vector2(30, 30), + DrawAnchor.Left, + DrawAnchor.Right + ) + { Strata = StrataLevel.LOWEST }; + + public NameplateBarConfig GetBarConfig() => BarConfig; + + public NameplateWithPlayerBarConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplatePlayerBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig) + { + BarConfig = barConfig; + } + + public NameplateWithPlayerBarConfig() : base() { } // don't remove + } + + public class NameplateWithEnemyBarConfig : NameplateConfig, NameplateWithBarConfig + { + [NestedConfig("Health Bar", 40)] + public NameplateEnemyBarConfig BarConfig = null!; + + [NestedConfig("Icon", 45)] + public NameplateIconConfig IconConfig = new NameplateIconConfig( + new Vector2(0, 0), + new Vector2(40, 40), + DrawAnchor.Right, + DrawAnchor.Left + ) + { PrioritizeHealthBarAnchor = true, Strata = StrataLevel.LOWEST }; + + [NestedConfig("Debuffs", 50)] + public EnemyNameplateStatusEffectsListConfig DebuffsConfig = null!; + + [NestedConfig("Castbar", 55)] + public NameplateCastbarConfig CastbarConfig = null!; + + public NameplateBarConfig GetBarConfig() => BarConfig; + + public NameplateWithEnemyBarConfig( + Vector2 position, + EditableLabelConfig nameLabel, + EditableNonFormattableLabelConfig titleLabelConfig, + NameplateEnemyBarConfig barConfig) + : base(position, nameLabel, titleLabelConfig) + { + BarConfig = barConfig; + } + + public NameplateWithEnemyBarConfig() : base() { } // don't remove + } + + [DisableParentSettings("HideWhenInactive")] + public class NameplateBarConfig : BarConfig + { + [Checkbox("Only Show when not at full Health")] + [Order(1)] + public bool OnlyShowWhenNotFull = true; + + [Checkbox("Hide Health when fully depleted", help = "This will hide the healthbar when the characters HP has been brought to zero")] + [Order(2)] + public bool HideHealthAtZero = true; + + [Checkbox("Disable Interaction")] + [Order(3)] + public bool DisableInteraction = false; + + [Checkbox("Use Different Size when targeted", spacing = true)] + [Order(31)] + public bool UseDifferentSizeWhenTargeted = false; + + [DragInt2("Size When Targeted", min = 1, max = 4000)] + [Order(32, collapseWith = nameof(UseDifferentSizeWhenTargeted))] + public Vector2 SizeWhenTargeted; + + [ColorEdit4("Targeted Border Color")] + [Order(38, collapseWith = nameof(DrawBorder))] + public PluginConfigColor TargetedBorderColor = PluginConfigColor.FromHex(0xFFFFFFFF); + + [DragInt("Targeted Border Thickness", min = 1, max = 10)] + [Order(39, collapseWith = nameof(DrawBorder))] + public int TargetedBorderThickness = 2; + + [NestedConfig("Color Based On Health Value", 50, collapsingHeader = false)] + public ColorByHealthValueConfig ColorByHealth = new ColorByHealthValueConfig(); + + [Checkbox("Hide Health if Possible", spacing = true, help = "This will hide any label that has a health tag if the character doesn't have health (ie minions, friendly npcs, etc)")] + [Order(121)] + public bool HideHealthIfPossible = true; + + [NestedConfig("Left Text", 125)] + public EditableLabelConfig LeftLabelConfig = null!; + + [NestedConfig("Right Text", 130)] + public EditableLabelConfig RightLabelConfig = null!; + + [NestedConfig("Optional Text", 131)] + public EditableLabelConfig OptionalLabelConfig = null!; + + [NestedConfig("Shields", 140)] + public ShieldConfig ShieldConfig = new ShieldConfig(); + + [NestedConfig("Custom Mouseover Area", 150)] + public MouseoverAreaConfig MouseoverAreaConfig = new MouseoverAreaConfig(); + + public NameplateBarConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, new PluginConfigColor(new(40f / 255f, 40f / 255f, 40f / 255f, 100f / 100f))) + { + Position = position; + Size = size; + LeftLabelConfig = leftLabelConfig; + RightLabelConfig = rightLabelConfig; + OptionalLabelConfig = optionalLabelConfig; + BackgroundColor = new PluginConfigColor(new(0f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + ColorByHealth.Enabled = false; + MouseoverAreaConfig.Enabled = false; + } + + public bool IsVisible(uint hp, uint maxHp) + { + return Enabled && (!OnlyShowWhenNotFull || hp < maxHp) && !(HideHealthAtZero && hp <= 0); + } + + public Vector2 GetSize(bool targeted) + { + return targeted && UseDifferentSizeWhenTargeted ? SizeWhenTargeted : Size; + } + + public NameplateBarConfig() : base(Vector2.Zero, Vector2.Zero, PluginConfigColor.Empty) { } // don't remove + } + + public class NameplatePlayerBarConfig : NameplateBarConfig + { + [Checkbox("Use Job Color", spacing = true)] + [Order(45)] + public bool UseJobColor = false; + + [Checkbox("Use Role Color")] + [Order(46)] + public bool UseRoleColor = false; + + [Checkbox("Job Color As Background Color", spacing = true)] + [Order(50)] + public bool UseJobColorAsBackgroundColor = false; + + [Checkbox("Role Color As Background Color")] + [Order(51)] + public bool UseRoleColorAsBackgroundColor = false; + + public NameplatePlayerBarConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig) + { + } + } + + public class NameplateEnemyBarConfig : NameplateBarConfig + { + [Checkbox("Use State Colors", spacing = true)] + [Order(45)] + public bool UseStateColor = true; + + [ColorEdit4("Unengaged")] + [Order(46, collapseWith = nameof(UseStateColor))] + public PluginConfigColor UnengagedColor = PluginConfigColor.FromHex(0xFFDA9D2E); + + [ColorEdit4("Unengaged (Hostile)")] + [Order(47, collapseWith = nameof(UseStateColor))] + public PluginConfigColor UnengagedHostileColor = PluginConfigColor.FromHex(0xFF994B35); + + [ColorEdit4("Engaged")] + [Order(48, collapseWith = nameof(UseStateColor))] + public PluginConfigColor EngagedColor = PluginConfigColor.FromHex(0xFF993535); + + [ColorEdit4("Claimed")] + [Order(49, collapseWith = nameof(UseStateColor))] + public PluginConfigColor ClaimedColor = PluginConfigColor.FromHex(0xFFEA93EA); + + [ColorEdit4("Unclaimed")] + [Order(50, collapseWith = nameof(UseStateColor))] + public PluginConfigColor UnclaimedColor = PluginConfigColor.FromHex(0xFFE5BB9E); + + [Checkbox("Use Custom Color when being targeted", spacing = true, help = "This will change the color of the bar when the enemy is targeting the player.")] + [Order(51)] + public bool UseCustomColorWhenBeingTargeted = false; + + [ColorEdit4("Targeted")] + [Order(52, collapseWith = nameof(UseCustomColorWhenBeingTargeted))] + public PluginConfigColor CustomColorWhenBeingTargeted = PluginConfigColor.FromHex(0xFFC4216D); + + [NestedConfig("Order Label", 132)] + public DefaultFontLabelConfig OrderLabelConfig = new DefaultFontLabelConfig(new Vector2(5, 0), "", DrawAnchor.Right, DrawAnchor.Left) + { + Strata = StrataLevel.LOWEST + }; + + public NameplateEnemyBarConfig(Vector2 position, Vector2 size, EditableLabelConfig leftLabelConfig, EditableLabelConfig rightLabelConfig, EditableLabelConfig optionalLabelConfig) + : base(position, size, leftLabelConfig, rightLabelConfig, optionalLabelConfig) + { + + } + } + + [Exportable(false)] + public class NameplateRangeConfig : PluginConfigObject + { + [DragInt("Fade start range (yalms)", min = 1, max = 500)] + [Order(5)] + public int StartRange = 50; + + [DragInt("Fade end range (yalms)", min = 1, max = 500)] + [Order(10)] + public int EndRange = 64; + + public float AlphaForDistance(float distance, float maxAlpha = 1f) + { + float diff = distance - StartRange; + if (!Enabled || diff <= 0) + { + return maxAlpha; + } + + float a = diff / (EndRange - StartRange); + return Math.Max(0, Math.Min(maxAlpha, 1 - a)); + } + } + + public class EnemyNameplateStatusEffectsListConfig : StatusEffectsListConfig + { + [Anchor("Health Bar Anchor")] + [Order(4)] + public DrawAnchor HealthBarAnchor = DrawAnchor.BottomLeft; + + public EnemyNameplateStatusEffectsListConfig(DrawAnchor anchor, Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + HealthBarAnchor = anchor; + } + } + + [DisableParentSettings("AnchorToUnitFrame", "UnitFrameAnchor", "HideWhenInactive", "FillDirection")] + public class NameplateCastbarConfig : TargetCastbarConfig + { + [Checkbox("Match Width with Health Bar")] + [Order(11)] + public bool MatchWidth = false; + + [Checkbox("Match Height with Health Bar")] + [Order(12)] + public bool MatchHeight = false; + + [Anchor("Health Bar Anchor")] + [Order(16)] + public DrawAnchor HealthBarAnchor = DrawAnchor.BottomLeft; + + public NameplateCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + } + + internal static class NameplatesHelper + { + internal static T GetNameplateConfig(uint bgColor, uint borderColor) where T : NameplateConfig + { + EditableLabelConfig nameLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "[name]", DrawAnchor.Top, DrawAnchor.Bottom) + { + Color = PluginConfigColor.FromHex(bgColor), + OutlineColor = PluginConfigColor.FromHex(borderColor), + FontID = FontsConfig.DefaultMediumFontKey + }; + + EditableNonFormattableLabelConfig titleLabelConfig = new EditableNonFormattableLabelConfig(new Vector2(0, -25), "<[title]>", DrawAnchor.Top, DrawAnchor.Bottom) + { + Color = PluginConfigColor.FromHex(bgColor), + OutlineColor = PluginConfigColor.FromHex(borderColor), + FontID = FontsConfig.DefaultMediumFontKey + }; + + return (T)Activator.CreateInstance(typeof(T), Vector2.Zero, nameLabelConfig, titleLabelConfig)!; + } + + internal static T GetNameplateWithBarConfig(uint bgColor, uint borderColor, Vector2 barSize) + where T : NameplateConfig + where B : NameplateBarConfig + { + EditableLabelConfig leftLabelConfig = new EditableLabelConfig(new Vector2(5, 0), "[health:current-short]", DrawAnchor.Left, DrawAnchor.Left) + { + Enabled = false, + FontID = FontsConfig.DefaultMediumFontKey, + Strata = StrataLevel.LOWEST + }; + EditableLabelConfig rightLabelConfig = new EditableLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right) + { + Enabled = false, + FontID = FontsConfig.DefaultMediumFontKey, + Strata = StrataLevel.LOWEST + }; + EditableLabelConfig optionalLabelConfig = new EditableLabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center) + { + Enabled = false, + FontID = FontsConfig.DefaultSmallFontKey, + Strata = StrataLevel.LOWEST + }; + + var barConfig = Activator.CreateInstance(typeof(B), new Vector2(0, -5), barSize, leftLabelConfig, rightLabelConfig, optionalLabelConfig)!; + if (barConfig is BarConfig bar) + { + bar.FillColor = PluginConfigColor.FromHex(bgColor); + bar.BackgroundColor = PluginConfigColor.FromHex(0xAA000000); + } + + if (barConfig is NameplateBarConfig nameplateBar) + { + nameplateBar.SizeWhenTargeted = nameplateBar.Size; + } + + EditableLabelConfig nameLabelConfig = new EditableLabelConfig(new Vector2(0, -20), "[name]", DrawAnchor.Top, DrawAnchor.Bottom) + { + Color = PluginConfigColor.FromHex(bgColor), + OutlineColor = PluginConfigColor.FromHex(borderColor), + FontID = FontsConfig.DefaultMediumFontKey, + Strata = StrataLevel.LOWEST + }; + EditableNonFormattableLabelConfig titleLabelConfig = new EditableNonFormattableLabelConfig(new Vector2(0, 0), "<[title]>", DrawAnchor.Top, DrawAnchor.Bottom) + { + Color = PluginConfigColor.FromHex(bgColor), + OutlineColor = PluginConfigColor.FromHex(borderColor), + FontID = FontsConfig.DefaultMediumFontKey, + Strata = StrataLevel.LOWEST + }; + + return (T)Activator.CreateInstance(typeof(T), Vector2.Zero, nameLabelConfig, titleLabelConfig, barConfig)!; + } + } +} diff --git a/Interface/Nameplates/NameplatesHud.cs b/Interface/Nameplates/NameplatesHud.cs new file mode 100644 index 0000000..5db44fd --- /dev/null +++ b/Interface/Nameplates/NameplatesHud.cs @@ -0,0 +1,232 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game.Control; +using FFXIVClientStructs.FFXIV.Client.Graphics; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Common.Component.BGCollision; +using System.Numerics; + +namespace HSUI.Interface.Nameplates +{ + internal class NameplatesHud : HudElement + { + private NameplatesGeneralConfig Config => (NameplatesGeneralConfig)_config; + + private NameplateWithPlayerBar _playerHud; + private NameplateWithEnemyBar _enemyHud; + private NameplateWithPlayerBar _partyMemberHud; + private NameplateWithPlayerBar _allianceMemberHud; + private NameplateWithPlayerBar _friendsHud; + private NameplateWithPlayerBar _otherPlayersHud; + private NameplateWithBar _petHud; + private NameplateWithBar _npcHud; + private Nameplate _minionNPCHud; + private Nameplate _objectHud; + + private bool _wasHovering; + + public NameplatesHud(NameplatesGeneralConfig config) : base(config) + { + ConfigurationManager manager = ConfigurationManager.Instance; + _playerHud = new NameplateWithPlayerBar(manager.GetConfigObject()); + _enemyHud = new NameplateWithEnemyBar(manager.GetConfigObject()); + _partyMemberHud = new NameplateWithPlayerBar(manager.GetConfigObject()); + _allianceMemberHud = new NameplateWithPlayerBar(manager.GetConfigObject()); + _friendsHud = new NameplateWithPlayerBar(manager.GetConfigObject()); + _otherPlayersHud = new NameplateWithPlayerBar(manager.GetConfigObject()); + _petHud = new NameplateWithBar(manager.GetConfigObject()); + _npcHud = new NameplateWithBar(manager.GetConfigObject()); + _minionNPCHud = new Nameplate(manager.GetConfigObject()); + _objectHud = new Nameplate(manager.GetConfigObject()); + } + + public void StopPreview() + { + _enemyHud.StopPreview(); + } + + public void StopMouseover() + { + if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + } + + protected override void CreateDrawActions(Vector2 origin) + { + if (!_config.Enabled || NameplatesManager.Instance == null) + { + StopMouseover(); + return; + } + + IGameObject? mouseoveredActor = null; + bool ignoreMouseover = false; + + foreach (NameplateData data in NameplatesManager.Instance.Data) + { + Nameplate? nameplate = GetNameplate(data); + if (nameplate == null || !nameplate.Enabled) { continue; } + + // raycasting + if (IsPointObstructed(data)) { continue; } + + if (nameplate is NameplateWithBar nameplateWithBar) + { + // draw bar + AddDrawActions(nameplateWithBar.GetBarDrawActions(data)); + + // find mouseovered nameplate + var (isHovering, ignore) = nameplateWithBar.GetMouseoverState(data); + if (isHovering) + { + mouseoveredActor = data.GameObject; + ignoreMouseover = ignore; + } + } + + // draw elements + AddDrawActions(nameplate.GetElementsDrawActions(data)); + } + + // mouseover + if (mouseoveredActor != null) + { + _wasHovering = true; + InputsHelper.Instance.SetTarget(mouseoveredActor, ignoreMouseover); + + if (InputsHelper.Instance.LeftButtonClicked) + { + Plugin.TargetManager.Target = mouseoveredActor; + InputsHelper.Instance.ClearClicks(); + } + else if (InputsHelper.Instance.RightButtonClicked) + { + InputsHelper.Instance.ClearClicks(); + } + } + else if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + } + + private unsafe Nameplate? GetNameplate(NameplateData data) + { + switch (data.Kind) + { + case ObjectKind.Player: + if (data.GameObject?.EntityId == Plugin.ObjectTable.LocalPlayer?.EntityId) + { + return _playerHud; + } + + if (data.GameObject is ICharacter character) + { + if ((character.StatusFlags & StatusFlags.PartyMember) != 0) // PartyMember + { + return _partyMemberHud; + } + else if ((character.StatusFlags & StatusFlags.AllianceMember) != 0) // AllianceMember + { + return _allianceMemberHud; + } + else if ((character.StatusFlags & StatusFlags.Friend) != 0) // Friend + { + return _friendsHud; + } + } + + return _otherPlayersHud; + + case ObjectKind.BattleNpc: + if (data.GameObject is IBattleNpc battleNpc) + { + if ((BattleNpcSubKind)battleNpc.SubKind == BattleNpcSubKind.Pet || + (BattleNpcSubKind)battleNpc.SubKind == BattleNpcSubKind.Chocobo) + { + return _petHud; + } + else if ((BattleNpcSubKind)battleNpc.SubKind == BattleNpcSubKind.Enemy || + (BattleNpcSubKind)battleNpc.SubKind == BattleNpcSubKind.BattleNpcPart) + { + return Utils.IsHostile(data.GameObject) ? _enemyHud : _npcHud; + } + else if (battleNpc.SubKind == 10) // island released minions + { + return _npcHud; + } + } + break; + + case ObjectKind.EventNpc: return _npcHud; + case ObjectKind.Companion: return _minionNPCHud; + default: return _objectHud; + } + + return null; + } + + private unsafe bool IsPointObstructed(NameplateData data) + { + if (data.GameObject == null) { return true; } + if (Config.OcclusionMode == NameplatesOcclusionMode.None || data.IgnoreOcclusion) { return false; } + + Camera camera = Control.Instance()->CameraManager.Camera->CameraBase.SceneCamera; + Vector3 cameraPos = camera.Object.Position; + + BGCollisionModule* collisionModule = Framework.Instance()->BGCollisionModule; + if (collisionModule == null) + { + return false; + } + + int flag = Config.RaycastFlag(); + int* flags = stackalloc int[] { flag, 0, flag, 0 }; + bool obstructed = false; + + // simple mode + if (Config.OcclusionMode == NameplatesOcclusionMode.Simple) + { + Vector3 direction = Vector3.Normalize(data.WorldPosition - cameraPos); + RaycastHit hit; + obstructed = collisionModule->RaycastMaterialFilter(&hit, &cameraPos, &direction, data.Distance, 1, flags); + } + // full mode + else + { + int obstructionCount = 0; + Vector2[] points = new Vector2[] + { + data.ScreenPosition + new Vector2(-30, 0), // left + data.ScreenPosition + new Vector2(30, 0), // right + }; + + foreach (Vector2 point in points) + { + Ray ray = camera.ScreenPointToRay(point); + RaycastHit hit; + + Vector3 origin = new Vector3(ray.Origin.X, ray.Origin.Y, ray.Origin.Z); + Vector3 direction = new Vector3(ray.Direction.X, ray.Direction.Y, ray.Direction.Z); + + if (collisionModule->RaycastMaterialFilter(&hit, &origin, &direction, data.Distance, 1, flags)) + { + obstructionCount++; + } + } + + obstructed = obstructionCount == points.Length; + } + + return obstructed; + } + } +} diff --git a/Interface/Nameplates/NameplatesManager.cs b/Interface/Nameplates/NameplatesManager.cs new file mode 100644 index 0000000..afde57b --- /dev/null +++ b/Interface/Nameplates/NameplatesManager.cs @@ -0,0 +1,365 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Memory; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using FFXIVClientStructs.FFXIV.Client.Game.Control; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; +using static FFXIVClientStructs.FFXIV.Client.UI.AddonNamePlate; +using static FFXIVClientStructs.FFXIV.Client.UI.RaptureAtkModule; +using static FFXIVClientStructs.FFXIV.Client.UI.UI3DModule; +using StructsFramework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework; +using StructsGameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; + +namespace HSUI.Interface.Nameplates +{ + internal class NameplatesManager : IDisposable + { + #region Singleton + public static NameplatesManager Instance { get; private set; } = null!; + private NameplatesGeneralConfig _config = null!; + + private NameplatesManager() + { + Plugin.ClientState.TerritoryChanged -= ClientStateOnTerritoryChangedEvent; + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + + OnConfigReset(ConfigurationManager.Instance); + } + + public static void Initialize() + { + Instance = new NameplatesManager(); + } + + ~NameplatesManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Plugin.ClientState.TerritoryChanged -= ClientStateOnTerritoryChangedEvent; + + Instance = null!; + } + + private void OnConfigReset(ConfigurationManager sender) + { + _config = sender.GetConfigObject(); + } + #endregion Singleton + + private const int NameplateCount = 50; + private const int NameplateDataArrayIndex = 4; // TODO: Rework to use NamePlateStringArray if it exists or use AtkStage.Instance()->GetStringArrayData(StringArrayType.NamePlate) + private Vector2 _averageNameplateSize = new Vector2(250, 150); + private List _data = new List(); + public IReadOnlyCollection Data => _data.AsReadOnly(); + + private NameplatesCache _cache = new NameplatesCache(50); + + private void ClientStateOnTerritoryChangedEvent(ushort territoryId) + { + _cache.Clear(); + } + + public unsafe void Update() + { + if (!_config.Enabled) { return; } + + UIModule* uiModule = StructsFramework.Instance()->GetUIModule(); + if (uiModule == null) { return; } + + UI3DModule* ui3DModule = uiModule->GetUI3DModule(); + if (ui3DModule == null) { return; } + + AddonNamePlate* addon = (AddonNamePlate*)Plugin.GameGui.GetAddonByName("NamePlate", 1).Address; + if (addon == null) { return; } + + RaptureAtkModule* atkModule = uiModule->GetRaptureAtkModule(); + if (atkModule == null || atkModule->AtkModule.AtkArrayDataHolder.StringArrayCount <= NameplateDataArrayIndex) { return; } + + StringArrayData* stringArray = atkModule->AtkModule.AtkArrayDataHolder.StringArrays[NameplateDataArrayIndex]; + Span infoArray = atkModule->NamePlateInfoEntries; + Camera camera = Control.Instance()->CameraManager.Camera->CameraBase.SceneCamera; + + IGameObject? target = Plugin.TargetManager.Target; + bool foundTarget = false; + NameplateData? targetData = null; + + _data = new List(); + int activeCount = ui3DModule->NamePlateObjectInfoCount; + + for (int i = 0; i < activeCount; i++) + { + try + { + ObjectInfo* objectInfo = ui3DModule->NamePlateObjectInfoPointers[i]; + if (objectInfo == null || objectInfo->NamePlateIndex >= NameplateCount) { continue; } + + // actor + StructsGameObject* obj = objectInfo->GameObject; + if (obj == null) { continue; } + + bool isTarget = false; + IGameObject? gameObject = Plugin.ObjectTable.CreateObjectReference(new IntPtr(obj)); + if (target != null && new IntPtr(obj) == target.Address) + { + isTarget = true; + foundTarget = true; + } + + // ui nameplate + NamePlateObject nameplateObject = addon->NamePlateObjectArray[objectInfo->NamePlateIndex]; + + // position + Vector2 screenPos = new Vector2( + nameplateObject.RootComponentNode->AtkResNode.X + nameplateObject.RootComponentNode->AtkResNode.Width / 2f, + nameplateObject.RootComponentNode->AtkResNode.Y + nameplateObject.RootComponentNode->AtkResNode.Height + ); + screenPos = ClampScreenPosition(screenPos); + + Vector3 worldPos = new Vector3(obj->Position.X, obj->Position.Y + obj->Height * 2.2f, obj->Position.Z); + + // distance + float distance = Vector3.Distance(camera.Object.Position, worldPos); + + // name + NamePlateInfo info = infoArray[objectInfo->NamePlateIndex]; + string name = info.Name.ToString(); + + // title + string title = info.Title.ToString(); + bool isTitlePrefix = info.IsPrefixTitle; + + // Get the title from Honorific, if it exists + TitleData? customTitleData = HonorificHelper.Instance?.GetTitle(gameObject); + if (customTitleData != null) + { + title = customTitleData.Title; + isTitlePrefix = customTitleData.IsPrefix; + } + + // state icon + int iconId = 0; + AtkUldAsset* textureInfo = nameplateObject.NameIcon->PartsList->Parts[nameplateObject.NameIcon->PartId].UldAsset; + if (textureInfo != null && textureInfo->AtkTexture.Resource != null) + { + iconId = (int)textureInfo->AtkTexture.Resource->IconId; + } + + // order + int arrayIndex = 200 + (activeCount - nameplateObject.Priority - 1); + string order = ""; + try + { + if (stringArray->AtkArrayData.Size > arrayIndex && stringArray->StringArray[arrayIndex] != null) + { + order = MemoryHelper.ReadSeStringNullTerminated(new IntPtr(stringArray->StringArray[arrayIndex])).ToString(); + } + } + catch { } + + NameplateData data = new NameplateData( + gameObject, + name, + title, + isTitlePrefix, + iconId, + order, + (ObjectKind)obj->ObjectKind, + obj->SubKind, + screenPos, + worldPos, + distance + ); + + if (isTarget) + { + targetData = data; + } + else + { + _data.Add(data); + } + + _cache.Add(obj->GetGameObjectId().ObjectId, data); + } + catch { } + } + + _data.Reverse(); + + // add target nameplate last + if (foundTarget && targetData.HasValue) + { + _data.Add(targetData.Value); + } + // create nameplate for target? + else if (_config.AlwaysShowTargetNameplate && target != null && !foundTarget) + { + StructsGameObject* obj = (StructsGameObject*)target.Address; + NameplateData? cachedData = _cache[(uint)target.GameObjectId]; + + Vector3 worldPos = new Vector3(target.Position.X, target.Position.Y + obj->Height * 2.2f, target.Position.Z); + float distance = Vector3.Distance(camera.Object.Position, worldPos); + + Plugin.GameGui.WorldToScreen(worldPos, out Vector2 screenPos); + screenPos = ClampScreenPosition(screenPos); + + targetData = new NameplateData( + target, + target.Name.ToString(), + cachedData?.Title ?? "", + cachedData?.IsTitlePrefix ?? true, + cachedData?.NamePlateIconId ?? 0, + cachedData?.Order ?? "", + target.ObjectKind, + target.SubKind, + screenPos, + worldPos, + distance, + true + ); + + _data.Add(targetData.Value); + } + } + + private Vector2 ClampScreenPosition(Vector2 pos) + { + if (!_config.ClampToScreen) { return pos; } + + Vector2 screenSize = ImGui.GetMainViewport().Size; + Vector2 nameplateSize = _averageNameplateSize / 2f; + float margin = 20; + + if (pos.X + nameplateSize.X > screenSize.X) + { + pos.X = screenSize.X - nameplateSize.X - margin; + } + else if (pos.X - nameplateSize.X < 0) + { + pos.X = nameplateSize.X + margin; + } + + if (pos.Y + nameplateSize.Y > screenSize.Y) + { + pos.Y = screenSize.Y - nameplateSize.Y - margin; + } + else if (pos.Y - nameplateSize.Y < 0) + { + pos.Y = nameplateSize.Y + margin; + } + + return pos; + } + } + + #region utils + public class NameplatesCache + { + + private int _limit; + private Dictionary _dict; + private Queue _queue; + + public NameplatesCache(int limit) + { + _limit = limit; + _dict = new Dictionary(limit); + _queue = new Queue(limit); + } + + public void Add(uint key, NameplateData data) + { + if (key == 0 || key == 0xE0000000) { return; } + + if (_dict.Count == _limit) + { + uint oldestKey = _queue.Dequeue(); + _dict.Remove(oldestKey); + } + + if (_dict.ContainsKey(key)) + { + _dict[key] = data; + } + else + { + _dict.Add(key, data); + _queue.Enqueue(key); + } + } + + public void Clear() + { + _dict.Clear(); + _queue.Clear(); + } + + public NameplateData? this[uint key] + { + get + { + if (_dict.TryGetValue(key, out NameplateData data)) + { + return data; + } + + return null; + } + } + } + + public struct NameplateData + { + public IGameObject? GameObject; + public string Name; + public string Title; + public bool IsTitlePrefix; + public int NamePlateIconId; + public string Order; + public ObjectKind Kind; + public byte SubKind; + public Vector2 ScreenPosition; + public Vector3 WorldPosition; + public float Distance; + public bool IgnoreOcclusion; + + public NameplateData(IGameObject? gameObject, string name, string title, bool isTitlePrefix, int namePlateIconId, string order, ObjectKind kind, byte subKind, Vector2 screenPosition, Vector3 worldPosition, float distance, bool ignoreOcclusion = false) + { + GameObject = gameObject; + Name = name; + Title = title; + IsTitlePrefix = isTitlePrefix; + NamePlateIconId = namePlateIconId; + Order = order; + Kind = kind; + SubKind = subKind; + ScreenPosition = screenPosition; + WorldPosition = worldPosition; + Distance = distance; + IgnoreOcclusion = ignoreOcclusion; + } + } + #endregion +} diff --git a/Interface/Party/PartyFramesBar.cs b/Interface/Party/PartyFramesBar.cs new file mode 100644 index 0000000..5df07bd --- /dev/null +++ b/Interface/Party/PartyFramesBar.cs @@ -0,0 +1,748 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Statuses; +using Dalamud.Interface.Textures.TextureWraps; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.StatusEffects; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface.Party +{ + public class PartyFramesBar + { + public delegate void PartyFramesBarEventHandler(PartyFramesBar bar); + public PartyFramesBarEventHandler? OpenContextMenuEvent; + + private PartyFramesConfigs _configs; + + private LabelHud _nameLabelHud; + private LabelHud _healthLabelHud; + private LabelHud _orderLabelHud; + private LabelHud _statusLabelHud; + private LabelHud _raiseLabelHud; + private LabelHud _invulnLabelHud; + private PrimaryResourceHud _manaBarHud; + private CastbarHud _castbarHud; + private StatusEffectsListHud _buffsListHud; + private StatusEffectsListHud _debuffsListHud; + private PartyFramesCooldownListHud _cooldownListHud; + + private IDalamudTextureWrap? _readyCheckTexture => + TexturesHelper.GetTextureFromPath("ui/uld/ReadyCheck_hr1.tex") ?? + TexturesHelper.GetTextureFromPath("ui/uld/ReadyCheck.tex"); + + public bool Visible = false; + public Vector2 Position; + + private SmoothHPHelper _smoothHPHelper = new SmoothHPHelper(); + + private bool _wasHovering = false; + + public IPartyFramesMember? Member; + + public PartyFramesBar(string id, PartyFramesConfigs configs) + { + _configs = configs; + + _nameLabelHud = new LabelHud(_configs.HealthBar.NameLabelConfig); + _healthLabelHud = new LabelHud(_configs.HealthBar.HealthLabelConfig); + _orderLabelHud = new LabelHud(_configs.HealthBar.OrderNumberConfig); + _statusLabelHud = new LabelHud(PlayerStatus.Label); + _raiseLabelHud = new LabelHud(RaiseTracker.Icon.NumericLabel); + _invulnLabelHud = new LabelHud(InvulnTracker.Icon.NumericLabel); + + _manaBarHud = new PrimaryResourceHud(_configs.ManaBar); + _castbarHud = new CastbarHud(_configs.CastBar); + _buffsListHud = new StatusEffectsListHud(_configs.Buffs); + _debuffsListHud = new StatusEffectsListHud(_configs.Debuffs); + + _cooldownListHud = new PartyFramesCooldownListHud(_configs.CooldownList); + } + + public PluginConfigColor GetColor(float scale) + { + if (Member == null || Member.MaxHP <= 0) + { + return _configs.HealthBar.ColorsConfig.OutOfReachBackgroundColor; + } + + bool cleanseCheck = true; + if (CleanseTracker.CleanseJobsOnly) + { + cleanseCheck = Utils.IsOnCleanseJob(); + } + + if (CleanseTracker.Enabled && CleanseTracker.ChangeHealthBarCleanseColor && Member.HasDispellableDebuff && cleanseCheck) + { + return CleanseTracker.HealthBarColor; + } + else if (_configs.HealthBar.ColorsConfig.ColorByHealth.Enabled) + { + if (_configs.HealthBar.ColorsConfig.ColorByHealth.UseJobColorAsMaxHealth) + { + return ColorUtils.GetColorByScale(scale, _configs.HealthBar.ColorsConfig.ColorByHealth.LowHealthColorThreshold / 100f, _configs.HealthBar.ColorsConfig.ColorByHealth.FullHealthColorThreshold / 100f, _configs.HealthBar.ColorsConfig.ColorByHealth.LowHealthColor, _configs.HealthBar.ColorsConfig.ColorByHealth.FullHealthColor, + GlobalColors.Instance.SafeColorForJobId(Member.JobId), _configs.HealthBar.ColorsConfig.ColorByHealth.UseMaxHealthColor, _configs.HealthBar.ColorsConfig.ColorByHealth.BlendMode); + } + else if (_configs.HealthBar.ColorsConfig.ColorByHealth.UseRoleColorAsMaxHealth) + { + return ColorUtils.GetColorByScale(scale, _configs.HealthBar.ColorsConfig.ColorByHealth.LowHealthColorThreshold / 100f, _configs.HealthBar.ColorsConfig.ColorByHealth.FullHealthColorThreshold / 100f, _configs.HealthBar.ColorsConfig.ColorByHealth.LowHealthColor, _configs.HealthBar.ColorsConfig.ColorByHealth.FullHealthColor, + GlobalColors.Instance.SafeRoleColorForJobId(Member.JobId), _configs.HealthBar.ColorsConfig.ColorByHealth.UseMaxHealthColor, _configs.HealthBar.ColorsConfig.ColorByHealth.BlendMode); + } + return ColorUtils.GetColorByScale(scale, _configs.HealthBar.ColorsConfig.ColorByHealth); + } + else if (Member.JobId > 0) + { + return _configs.HealthBar.ColorsConfig.UseRoleColors switch + { + true => GlobalColors.Instance.SafeRoleColorForJobId(Member.JobId), + _ => GlobalColors.Instance.SafeColorForJobId(Member.JobId) + }; + } + + return Member.Character?.ObjectKind switch + { + ObjectKind.BattleNpc => GlobalColors.Instance.NPCFriendlyColor, + _ => _configs.HealthBar.ColorsConfig.OutOfReachBackgroundColor + }; + } + + public void StopPreview() + { + _castbarHud.StopPreview(); + _buffsListHud.StopPreview(); + _debuffsListHud.StopPreview(); + _cooldownListHud.StopPreview(); + _configs.HealthBar.MouseoverAreaConfig.Preview = false; + } + + public void StopMouseover() + { + if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + } + + public void Dispose() + { + _cooldownListHud.Dispose(); + } + + public List<(StrataLevel, Action)> GetBarDrawActions(Vector2 origin, PluginConfigColor? borderColor = null) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + + if (!Visible || Member is null) + { + StopMouseover(); + return drawActions; + } + + // click + var (areaStart, areaEnd) = _configs.HealthBar.MouseoverAreaConfig.GetArea(Position, _configs.HealthBar.Size); + bool isHovering = ImGui.IsMouseHoveringRect(areaStart, areaEnd); + bool ignoreMouseover = _configs.HealthBar.MouseoverAreaConfig.Enabled && _configs.HealthBar.MouseoverAreaConfig.Ignore; + ICharacter? character = Member.Character; + + if (isHovering) + { + _wasHovering = true; + InputsHelper.Instance.SetTarget(character, ignoreMouseover); + + // left click + if (InputsHelper.Instance.LeftButtonClicked && character != null) + { + Plugin.TargetManager.Target = character; + } + // right click (context menu) + else if (InputsHelper.Instance.RightButtonClicked) + { + OpenContextMenuEvent?.Invoke(this); + } + } + else if (_wasHovering) + { + InputsHelper.Instance.ClearTarget(); + _wasHovering = false; + } + + // bg + PluginConfigColor bgColor = _configs.HealthBar.ColorsConfig.BackgroundColor; + if (Member.RaiseTime != null && RaiseTracker.Enabled && RaiseTracker.ChangeBackgroundColorWhenRaised) + { + bgColor = RaiseTracker.BackgroundColor; + } + else if (Member.InvulnStatus?.InvulnTime != null && InvulnTracker.Enabled && InvulnTracker.ChangeBackgroundColorWhenInvuln) + { + bgColor = Member.InvulnStatus?.InvulnId == 811 ? InvulnTracker.WalkingDeadBackgroundColor : InvulnTracker.BackgroundColor; + } + else if (_configs.HealthBar.ColorsConfig.UseDeathIndicatorBackgroundColor && Member.HP <= 0 && character != null) + { + bgColor = _configs.HealthBar.RangeConfig.Enabled + ? GetDistanceColor(character, _configs.HealthBar.ColorsConfig.DeathIndicatorBackgroundColor) + : _configs.HealthBar.ColorsConfig.DeathIndicatorBackgroundColor; + } + else if (_configs.HealthBar.ColorsConfig.UseJobColorAsBackgroundColor && character is IBattleChara) + { + bgColor = GlobalColors.Instance.SafeColorForJobId(character.ClassJob.RowId); + } + else if (_configs.HealthBar.ColorsConfig.UseRoleColorAsBackgroundColor && character is IBattleChara) + { + bgColor = _configs.HealthBar.RangeConfig.Enabled + ? GetDistanceColor(character, GlobalColors.Instance.SafeRoleColorForJobId(character.ClassJob.RowId)) + : GlobalColors.Instance.SafeRoleColorForJobId(character.ClassJob.RowId); + } + + Rect background = new Rect(Position, _configs.HealthBar.Size, bgColor); + + // hp + uint currentHp = Member.HP; + uint maxHp = Member.MaxHP; + + if (_configs.HealthBar.SmoothHealthConfig.Enabled) + { + currentHp = _smoothHPHelper.GetNextHp((int)currentHp, (int)maxHp, _configs.HealthBar.SmoothHealthConfig.Velocity); + } + + float hpScale = maxHp > 0 ? (float)currentHp / (float)maxHp : 1; + PluginConfigColor? hpColor = _configs.HealthBar.RangeConfig.Enabled && character != null + ? GetDistanceColor(character, GetColor(hpScale)) + : GetColor(hpScale); + + Rect healthFill = BarUtilities.GetFillRect(Position, _configs.HealthBar.Size, _configs.HealthBar.FillDirection, hpColor, currentHp, maxHp); + + // bar + int thickness = borderColor != null ? _configs.HealthBar.ColorsConfig.ActiveBorderThickness : _configs.HealthBar.ColorsConfig.InactiveBorderThickness; + + if (WhosTalkingIcon.ChangeBorders && Member.WhosTalkingState != WhosTalkingState.None) + { + thickness = WhosTalkingIcon.BorderThickness; + } + + borderColor = borderColor ?? GetBorderColor(character); + + BarHud bar = new BarHud( + _configs.HealthBar.ID, + _configs.HealthBar.ColorsConfig.ShowBorder, + borderColor, + thickness, + actor: character, + current: currentHp, + max: maxHp, + shadowConfig: _configs.HealthBar.ShadowConfig, + barTextureName: _configs.HealthBar.BarTextureName, + barTextureDrawMode: _configs.HealthBar.BarTextureDrawMode + ); + + bar.NeedsInputs = true; + bar.SetBackground(background); + bar.AddForegrounds(healthFill); + + // missing health + if (_configs.HealthBar.ColorsConfig.UseMissingHealthBar) + { + Vector2 healthMissingSize = _configs.HealthBar.Size - BarUtilities.GetFillDirectionOffset(healthFill.Size, _configs.HealthBar.FillDirection); + Vector2 healthMissingPos = _configs.HealthBar.FillDirection.IsInverted() ? Position : Position + BarUtilities.GetFillDirectionOffset(healthFill.Size, _configs.HealthBar.FillDirection); + + PluginConfigColor? missingHealthColor = _configs.HealthBar.ColorsConfig.UseJobColorAsMissingHealthColor && character is IBattleChara + ? GlobalColors.Instance.SafeColorForJobId(character!.ClassJob.RowId) + : _configs.HealthBar.ColorsConfig.UseRoleColorAsMissingHealthColor && character is IBattleChara + ? GlobalColors.Instance.SafeRoleColorForJobId(character!.ClassJob.RowId) + : _configs.HealthBar.ColorsConfig.HealthMissingColor; + + if (_configs.HealthBar.ColorsConfig.UseDeathIndicatorBackgroundColor && Member.HP <= 0 && character != null) + { + missingHealthColor = _configs.HealthBar.ColorsConfig.DeathIndicatorBackgroundColor; + } + + if (_configs.Trackers.Invuln.ChangeBackgroundColorWhenInvuln && character is IBattleChara battleChara) + { + IStatus? tankInvuln = Utils.GetTankInvulnerabilityID(battleChara); + if (tankInvuln is not null) + { + missingHealthColor = _configs.Trackers.Invuln.BackgroundColor; + } + } + + if (_configs.HealthBar.RangeConfig.Enabled) + { + missingHealthColor = GetDistanceColor(character, missingHealthColor); + } + + bar.AddForegrounds(new Rect(healthMissingPos, healthMissingSize, missingHealthColor)); + } + + // shield + if (_configs.HealthBar.ShieldConfig.Enabled) + { + if (Member.Shield > 0f) + { + bar.AddForegrounds( + BarUtilities.GetShieldForeground( + _configs.HealthBar.ShieldConfig, + Position, + _configs.HealthBar.Size, + healthFill.Size, + _configs.HealthBar.FillDirection, + Member.Shield, + currentHp, + maxHp + ) + ); + } + } + + // highlight + bool isSoftTarget = character != null && character == Plugin.TargetManager.SoftTarget; + if (_configs.HealthBar.ColorsConfig.ShowHighlight && (isHovering || isSoftTarget)) + { + Rect highlight = new Rect(Position, _configs.HealthBar.Size, _configs.HealthBar.ColorsConfig.HighlightColor); + bar.AddForegrounds(highlight); + } + + drawActions = bar.GetDrawActions(Vector2.Zero, _configs.HealthBar.StrataLevel); + + // mouseover area + BarHud? mouseoverAreaBar = _configs.HealthBar.MouseoverAreaConfig.GetBar( + Position, + _configs.HealthBar.Size, + _configs.HealthBar.ID + "_mouseoverArea" + ); + + if (mouseoverAreaBar != null) + { + drawActions.AddRange(mouseoverAreaBar.GetDrawActions(Vector2.Zero, StrataLevel.HIGHEST)); + } + + return drawActions; + } + + private PluginConfigColor GetBorderColor(ICharacter? character) + { + IGameObject? target = Plugin.TargetManager.Target ?? Plugin.TargetManager.SoftTarget; + + return character != null && character == target ? _configs.HealthBar.ColorsConfig.TargetBordercolor : _configs.HealthBar.ColorsConfig.BorderColor; + } + + private PluginConfigColor GetDistanceColor(ICharacter? character, PluginConfigColor color) + { + byte distance = character != null ? character.YalmDistanceX : byte.MaxValue; + float currentAlpha = color.Vector.W * 100f; + float alpha = _configs.HealthBar.RangeConfig.AlphaForDistance(distance, currentAlpha) / 100f; + + return color.WithAlpha(alpha); + } + + // need to separate elements that have their own window so clipping doesn't get messy + public List<(StrataLevel, Action)> GetElementsDrawActions(Vector2 origin) + { + List<(StrataLevel, Action)> drawActions = new List<(StrataLevel, Action)>(); + + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + if (!Visible || Member is null || player == null) + { + StopMouseover(); + return drawActions; + } + + ICharacter? character = Member.Character; + + // who's talking + bool drawingWhosTalking = false; + if (WhosTalkingIcon.Enabled && WhosTalkingIcon.Icon.Enabled && WhosTalkingIcon.EnabledForState(Member.WhosTalkingState)) + { + IDalamudTextureWrap? texture = WhosTalkingHelper.Instance.GetTextureForState(Member.WhosTalkingState); + + if (texture != null) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, WhosTalkingIcon.Icon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + WhosTalkingIcon.Icon.Position, WhosTalkingIcon.Icon.Size, WhosTalkingIcon.Icon.Anchor); + + drawActions.Add((WhosTalkingIcon.Icon.StrataLevel, () => + { + DrawHelper.DrawInWindow(WhosTalkingIcon.Icon.ID, iconPos, WhosTalkingIcon.Icon.Size, false, (drawList) => + { + ImGui.SetCursorPos(iconPos); + ImGui.Image(texture.Handle, WhosTalkingIcon.Icon.Size); + }); + } + )); + + drawingWhosTalking = true; + } + } + + // role/job icon + if (RoleIcon.Enabled && (!drawingWhosTalking || !WhosTalkingIcon.ReplaceRoleJobIcon)) + { + uint iconId = 0; + + // chocobo icon + if (character is IBattleNpc battleNpc && battleNpc.BattleNpcKind == BattleNpcSubKind.Chocobo) + { + iconId = JobsHelper.RoleIconIDForBattleCompanion + (uint)RoleIcon.Style * 100; + } + // role/job icon + else if (Member.JobId > 0) + { + iconId = RoleIcon.UseRoleIcons ? + JobsHelper.RoleIconIDForJob(Member.JobId, RoleIcon.UseSpecificDPSRoleIcons) : + JobsHelper.IconIDForJob(Member.JobId, (uint)RoleIcon.Style); + } + + if (iconId > 0) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, RoleIcon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + RoleIcon.Position, RoleIcon.Size, RoleIcon.Anchor); + + drawActions.Add((RoleIcon.StrataLevel, () => + { + DrawHelper.DrawInWindow(RoleIcon.ID, iconPos, RoleIcon.Size, false, (drawList) => + { + DrawHelper.DrawIcon(iconId, iconPos, RoleIcon.Size, false, drawList); + }); + } + )); + } + } + + // sign icon + if (SignIcon.Enabled) + { + uint? iconId = SignIcon.IconID(character); + if (iconId.HasValue) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, SignIcon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + SignIcon.Position, SignIcon.Size, SignIcon.Anchor); + + drawActions.Add((SignIcon.StrataLevel, () => + { + DrawHelper.DrawInWindow(SignIcon.ID, iconPos, SignIcon.Size, false, (drawList) => + { + DrawHelper.DrawIcon(iconId.Value, iconPos, SignIcon.Size, false, drawList); + }); + } + )); + } + } + + // leader icon + if (LeaderIcon.Enabled && Member.IsPartyLeader) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, LeaderIcon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + LeaderIcon.Position, LeaderIcon.Size, LeaderIcon.Anchor); + + drawActions.Add((LeaderIcon.StrataLevel, () => + { + DrawHelper.DrawInWindow(LeaderIcon.ID, iconPos, LeaderIcon.Size, false, (drawList) => + { + DrawHelper.DrawIcon(61521, iconPos, LeaderIcon.Size, false, drawList); + }); + } + )); + } + + // player status icon + if (PlayerStatus.Enabled && PlayerStatus.Icon.Enabled) + { + uint? iconId = IconIdForStatus(Member.Status); + if (iconId.HasValue) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, PlayerStatus.Icon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + PlayerStatus.Icon.Position, PlayerStatus.Icon.Size, PlayerStatus.Icon.Anchor); + + drawActions.Add((PlayerStatus.Icon.StrataLevel, () => + { + DrawHelper.DrawInWindow(PlayerStatus.Icon.ID, iconPos, PlayerStatus.Icon.Size, false, (drawList) => + { + DrawHelper.DrawIcon(iconId.Value, iconPos, PlayerStatus.Icon.Size, false, drawList); + }); + } + )); + } + } + + // ready check status icon + if (Member.ReadyCheckStatus != ReadyCheckStatus.None && ReadyCheckIcon.Enabled && ReadyCheckIcon.Icon.Enabled && _readyCheckTexture != null) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, ReadyCheckIcon.Icon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + ReadyCheckIcon.Icon.Position, ReadyCheckIcon.Icon.Size, ReadyCheckIcon.Icon.Anchor); + + drawActions.Add((ReadyCheckIcon.Icon.StrataLevel, () => + { + DrawHelper.DrawInWindow(ReadyCheckIcon.Icon.ID, iconPos, ReadyCheckIcon.Icon.Size, false, (drawList) => + { + Vector2 uv0 = new Vector2(0.5f * (int)Member.ReadyCheckStatus, 0f); + Vector2 uv1 = new Vector2(0.5f + 0.5f * (int)Member.ReadyCheckStatus, 1f); + drawList.AddImage(_readyCheckTexture.Handle, iconPos, iconPos + ReadyCheckIcon.Icon.Size, uv0, uv1); + }); + } + )); + } + + // raise icon + bool showingRaise = ShowingRaise(); + if (showingRaise && RaiseTracker.Icon.Enabled) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, RaiseTracker.Icon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + RaiseTracker.Icon.Position, RaiseTracker.Icon.Size, RaiseTracker.Icon.Anchor); + + drawActions.Add((RaiseTracker.Icon.StrataLevel, () => + { + DrawHelper.DrawInWindow(RaiseTracker.Icon.ID, iconPos, RaiseTracker.Icon.Size, false, (drawList) => + { + DrawHelper.DrawIcon(411, iconPos, RaiseTracker.Icon.Size, true, drawList); + }); + } + )); + } + + // invuln icon + bool showingInvuln = ShowingInvuln(); + if (showingInvuln && InvulnTracker.Icon.Enabled) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, InvulnTracker.Icon.FrameAnchor); + Vector2 iconPos = Utils.GetAnchoredPosition(parentPos + InvulnTracker.Icon.Position, InvulnTracker.Icon.Size, InvulnTracker.Icon.Anchor); + + drawActions.Add((InvulnTracker.Icon.StrataLevel, () => + { + DrawHelper.DrawInWindow(InvulnTracker.Icon.ID, iconPos, InvulnTracker.Icon.Size, false, (drawList) => + { + DrawHelper.DrawIcon(Member.InvulnStatus!.InvulnIcon, iconPos, InvulnTracker.Icon.Size, true, drawList); + }); + } + )); + } + + // mana + if (ShowMana()) + { + Vector2 parentPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, _configs.ManaBar.HealthBarAnchor); + drawActions.Add((_configs.ManaBar.StrataLevel, () => + { + _manaBarHud.Actor = character; + _manaBarHud.PartyMember = Member; + _manaBarHud.PrepareForDraw(parentPos); + _manaBarHud.Draw(parentPos); + } + )); + } + + // buffs / debuffs + Vector2 buffsPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, _configs.Buffs.HealthBarAnchor); + drawActions.Add((_configs.Buffs.StrataLevel, () => + { + _buffsListHud.Actor = character; + _buffsListHud.PrepareForDraw(buffsPos); + _buffsListHud.Draw(buffsPos); + } + )); + + Vector2 debuffsPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, _configs.Debuffs.HealthBarAnchor); + drawActions.Add((_configs.Debuffs.StrataLevel, () => + { + _debuffsListHud.Actor = character; + _debuffsListHud.PrepareForDraw(debuffsPos); + _debuffsListHud.Draw(debuffsPos); + } + )); + + // cooldown list + Vector2 cooldownListPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, _configs.CooldownList.HealthBarAnchor); + drawActions.Add((_configs.CooldownList.StrataLevel, () => + { + _cooldownListHud.Actor = character; + _cooldownListHud.PrepareForDraw(cooldownListPos); + _cooldownListHud.Draw(cooldownListPos); + } + )); + + // castbar + Vector2 castbarPos = Utils.GetAnchoredPosition(Position, -_configs.HealthBar.Size, _configs.CastBar.HealthBarAnchor); + drawActions.Add((_configs.CastBar.StrataLevel, () => + { + _castbarHud.Actor = character; + _castbarHud.PrepareForDraw(castbarPos); + _castbarHud.Draw(castbarPos); + } + )); + + // name + bool drawName = ShouldDrawName(character, showingRaise, showingInvuln); + if (drawName) + { + drawActions.Add((_configs.HealthBar.NameLabelConfig.StrataLevel, () => + { + bool? playerName = null; + if (character == null || character.ObjectKind == ObjectKind.Player) + { + playerName = true; + } + + _nameLabelHud.Draw(Position, _configs.HealthBar.Size, character, Member.Name, isPlayerName: playerName); + } + )); + } + + // health label + if (Member.MaxHP > 0) + { + drawActions.Add((_configs.HealthBar.HealthLabelConfig.StrataLevel, () => + { + _healthLabelHud.Draw(Position, _configs.HealthBar.Size, character, null, Member.HP, Member.MaxHP); + } + )); + } + + // order + if (character == null || character?.ObjectKind != ObjectKind.BattleNpc) + { + string str = char.ConvertFromUtf32(0xE090 + Member.Order).ToString(); + + drawActions.Add((_configs.HealthBar.OrderNumberConfig.StrataLevel, () => + { + _configs.HealthBar.OrderNumberConfig.SetText(str); + _orderLabelHud.Draw(Position, _configs.HealthBar.Size, character); + } + )); + } + + // status + string? statusString = StringForStatus(Member.Status); + if (PlayerStatus.Enabled && PlayerStatus.Label.Enabled && statusString != null) + { + drawActions.Add((PlayerStatus.Label.StrataLevel, () => + { + PlayerStatus.Label.SetText(statusString); + _statusLabelHud.Draw(Position, _configs.HealthBar.Size); + } + )); + } + + // raise label + if (showingRaise) + { + float duration = Math.Abs(Member.RaiseTime!.Value); + + drawActions.Add((RaiseTracker.Icon.NumericLabel.StrataLevel, () => + { + RaiseTracker.Icon.NumericLabel.SetValue(duration); + _raiseLabelHud.Draw(Position, _configs.HealthBar.Size); + } + )); + } + + // invuln label + if (showingInvuln) + { + float duration = Math.Abs(Member.InvulnStatus!.InvulnTime); + + drawActions.Add((InvulnTracker.Icon.NumericLabel.StrataLevel, () => + { + InvulnTracker.Icon.NumericLabel.SetValue(duration); + _invulnLabelHud.Draw(Position, _configs.HealthBar.Size); + } + )); + } + + return drawActions; + } + + private bool ShouldDrawName(ICharacter? character, bool showingRaise, bool showingInvuln) + { + if (showingRaise && RaiseTracker.HideNameWhenRaised) + { + return false; + } + + if (showingInvuln && InvulnTracker.HideNameWhenInvuln) + { + return false; + } + + if (Member != null && PlayerStatus.Enabled && PlayerStatus.HideName && Member.Status != PartyMemberStatus.None) + { + return false; + } + + if (Member != null && ReadyCheckIcon.Enabled && ReadyCheckIcon.HideName && Member.ReadyCheckStatus != ReadyCheckStatus.None) + { + return false; + } + + if (Utils.IsActorCasting(character) && _configs.CastBar.Enabled && _configs.CastBar.HideNameWhenCasting) + { + return false; + } + + return true; + } + + private bool ShowingRaise() => + Member != null && Member.RaiseTime.HasValue && RaiseTracker.Enabled && + (Member.RaiseTime.Value > 0 || RaiseTracker.KeepIconAfterCastFinishes); + + private bool ShowingInvuln() => Member != null && Member.InvulnStatus != null && InvulnTracker.Enabled && Member.InvulnStatus.InvulnTime > 0; + + private bool ShowMana() + { + if (Member == null) + { + return false; + } + + var isHealer = JobsHelper.IsJobHealer(Member.JobId); + + return _configs.ManaBar.Enabled && Member.MaxHP > 0 && _configs.ManaBar.ManaBarDisplayMode switch + { + PartyFramesManaBarDisplayMode.Always => true, + PartyFramesManaBarDisplayMode.HealersOnly => isHealer, + PartyFramesManaBarDisplayMode.HealersAndRaiseJobs => isHealer || JobsHelper.IsJobWithRaise(Member.JobId, Member.Level), + _ => true + }; + } + + private static uint? IconIdForStatus(PartyMemberStatus status) + { + return status switch + { + PartyMemberStatus.ViewingCutscene => 61508, + PartyMemberStatus.Offline => 61504, + PartyMemberStatus.Dead => 61502, + _ => null + }; + } + + private static string? StringForStatus(PartyMemberStatus status) + { + return status switch + { + PartyMemberStatus.ViewingCutscene => "[Viewing Cutscene]", + PartyMemberStatus.Offline => "[Offline]", + PartyMemberStatus.Dead => "[Dead]", + _ => null + }; + } + + #region convenience + private PartyFramesRoleIconConfig RoleIcon => _configs.Icons.Role; + private SignIconConfig SignIcon => _configs.Icons.Sign; + private PartyFramesLeaderIconConfig LeaderIcon => _configs.Icons.Leader; + private PartyFramesPlayerStatusConfig PlayerStatus => _configs.Icons.PlayerStatus; + private PartyFramesReadyCheckStatusConfig ReadyCheckIcon => _configs.Icons.ReadyCheckStatus; + private PartyFramesWhosTalkingConfig WhosTalkingIcon => _configs.Icons.WhosTalking; + private PartyFramesRaiseTrackerConfig RaiseTracker => _configs.Trackers.Raise; + private PartyFramesInvulnTrackerConfig InvulnTracker => _configs.Trackers.Invuln; + private PartyFramesCleanseTrackerConfig CleanseTracker => _configs.Trackers.Cleanse; + #endregion + } +} diff --git a/Interface/Party/PartyFramesCleanseTracker.cs b/Interface/Party/PartyFramesCleanseTracker.cs new file mode 100644 index 0000000..d640137 --- /dev/null +++ b/Interface/Party/PartyFramesCleanseTracker.cs @@ -0,0 +1,80 @@ +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Helpers; +using Dalamud.Game.ClientState.Statuses; + +namespace HSUI.Interface.Party +{ + public class PartyFramesCleanseTracker : IDisposable + { + private PartyFramesCleanseTrackerConfig _config = null!; + + public PartyFramesCleanseTracker() + { + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + OnConfigReset(ConfigurationManager.Instance); + } + + ~PartyFramesCleanseTracker() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + } + + public void OnConfigReset(ConfigurationManager sender) + { + _config = ConfigurationManager.Instance.GetConfigObject().Cleanse; + } + + public void Update(List partyMembers) + { + if (!_config.Enabled) + { + return; + } + + foreach (var member in partyMembers) + { + member.HasDispellableDebuff = false; + + if (member.Character is not IBattleChara battleChara) + { + continue; + } + + // check for disspellable debuff + IEnumerable statusList = Utils.StatusListForBattleChara(battleChara); + foreach (IStatus status in statusList) + { + if (!status.GameData.Value.CanDispel) + { + continue; + } + + // apply raise data based on buff + member.HasDispellableDebuff = true; + break; + } + } + } + } +} diff --git a/Interface/Party/PartyFramesConfig.cs b/Interface/Party/PartyFramesConfig.cs new file mode 100644 index 0000000..2ff64cb --- /dev/null +++ b/Interface/Party/PartyFramesConfig.cs @@ -0,0 +1,852 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.PartyCooldowns; +using HSUI.Interface.StatusEffects; +using Dalamud.Bindings.ImGui; +using System; +using System.Numerics; + +namespace HSUI.Interface.Party +{ + [Exportable(false)] + [Section("Party Frames", true)] + [SubSection("General", 0)] + public class PartyFramesConfig : MovablePluginConfigObject + { + public new static PartyFramesConfig DefaultConfig() + { + var config = new PartyFramesConfig(); + config.Position = new Vector2(-ImGui.GetMainViewport().Size.X / 3 - 180, -120); + + return config; + } + + [Checkbox("Preview", isMonitored = true)] + [Order(4)] + public bool Preview = false; + + [DragInt("Rows", spacing = true, isMonitored = true, min = 1, max = 8, velocity = 0.2f)] + [Order(10)] + public int Rows = 4; + + [DragInt("Columns", isMonitored = true, min = 1, max = 8, velocity = 0.2f)] + [Order(11)] + public int Columns = 2; + + [Anchor("Bars Anchor", isMonitored = true, spacing = true)] + [Order(15)] + public DrawAnchor BarsAnchor = DrawAnchor.TopLeft; + + [Checkbox("Fill Rows First", isMonitored = true)] + [Order(20)] + public bool FillRowsFirst = true; + + [Checkbox("Show When Solo", spacing = true)] + [Order(50)] + public bool ShowWhenSolo = false; + + [Checkbox("Show Chocobo", isMonitored = true)] + [Order(55)] + public bool ShowChocobo = true; + + [NestedConfig("Party Title Label", 60)] + public PartyFramesTitleLabel ShowPartyTitleConfig = new PartyFramesTitleLabel(Vector2.Zero, "", DrawAnchor.Left, DrawAnchor.Left); + + [NestedConfig("Visibility", 200)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + } + + [Exportable(false)] + [DisableParentSettings("FrameAnchor", "UseJobColor", "UseRoleColor")] + public class PartyFramesTitleLabel : LabelConfig + { + public PartyFramesTitleLabel(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) : base(position, text, frameAnchor, textAnchor) + { + } + } + + [Exportable(false)] + [Disableable(false)] + [DisableParentSettings("Position", "Anchor", "BackgroundColor", "FillColor", "HideWhenInactive", "DrawBorder", "BorderColor", "BorderThickness")] + [Section("Party Frames", true)] + [SubSection("Health Bar", 0)] + public class PartyFramesHealthBarsConfig : BarConfig + { + public new static PartyFramesHealthBarsConfig DefaultConfig() + { + var config = new PartyFramesHealthBarsConfig(Vector2.Zero, new(180, 80), PluginConfigColor.Empty); + config.MouseoverAreaConfig.Enabled = false; + + return config; + } + + [DragInt2("Padding", isMonitored = true, min = 0)] + [Order(31)] + public Vector2 Padding = new Vector2(0, 0); + + [NestedConfig("Name Label", 44)] + public EditableLabelConfig NameLabelConfig = new EditableLabelConfig(Vector2.Zero, "[name:initials].", DrawAnchor.Center, DrawAnchor.Center); + + [NestedConfig("Health Label", 45)] + public EditableLabelConfig HealthLabelConfig = new EditableLabelConfig(Vector2.Zero, "[health:current-short]", DrawAnchor.Right, DrawAnchor.Right); + + [NestedConfig("Order Label", 50)] + public DefaultFontLabelConfig OrderNumberConfig = new DefaultFontLabelConfig(new Vector2(2, 4), "", DrawAnchor.TopLeft, DrawAnchor.TopLeft); + + [NestedConfig("Colors", 55)] + public PartyFramesColorsConfig ColorsConfig = new PartyFramesColorsConfig(); + + [NestedConfig("Shield", 60)] + public ShieldConfig ShieldConfig = new ShieldConfig(); + + [NestedConfig("Change Alpha Based on Range", 65)] + public PartyFramesRangeConfig RangeConfig = new PartyFramesRangeConfig(); + + [NestedConfig("Use Smooth Transitions", 70)] + public SmoothHealthConfig SmoothHealthConfig = new SmoothHealthConfig(); + + [NestedConfig("Custom Mouseover Area", 75)] + public MouseoverAreaConfig MouseoverAreaConfig = new MouseoverAreaConfig(); + + public PartyFramesHealthBarsConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor, BarDirection fillDirection = BarDirection.Right) + : base(position, size, fillColor, fillDirection) + { + } + } + + [Disableable(false)] + [Exportable(false)] + public class PartyFramesColorsConfig : PluginConfigObject + { + [Checkbox("Show Border")] + [Order(4)] + public bool ShowBorder = true; + + [ColorEdit4("Border Color")] + [Order(5, collapseWith = nameof(ShowBorder))] + public PluginConfigColor BorderColor = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Target Border Color")] + [Order(6, collapseWith = nameof(ShowBorder))] + public PluginConfigColor TargetBordercolor = new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 100f / 100f)); + + [DragInt("Inactive Border Thickness", min = 1, max = 10, help = "This is the border thickness that will be used when the border is in the default state (aka not targetted, not showing enmity, etc).")] + [Order(6, collapseWith = nameof(ShowBorder))] + public int InactiveBorderThickness = 1; + + [DragInt("Active Border Thickness", min = 1, max = 10, help = "This is the border thickness that will be used when the border active (aka targetted, showing enmity, etc).")] + [Order(7, collapseWith = nameof(ShowBorder))] + public int ActiveBorderThickness = 1; + + [ColorEdit4("Background Color", spacing = true)] + [Order(15)] + public PluginConfigColor BackgroundColor = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 70f / 100f)); + + [ColorEdit4("Out of Reach Background Color", help = "This background color will be used when the player's data couldn't be retreived (i.e. player is disconnected)")] + [Order(15)] + public PluginConfigColor OutOfReachBackgroundColor = new PluginConfigColor(new Vector4(50f / 255f, 50f / 255f, 50f / 255f, 70f / 100f)); + + [Checkbox("Use Death Indicator Background Color", isMonitored = true, spacing = true)] + [Order(18)] + public bool UseDeathIndicatorBackgroundColor = false; + + [ColorEdit4("Death Indicator Background Color")] + [Order(19, collapseWith = nameof(UseDeathIndicatorBackgroundColor))] + public PluginConfigColor DeathIndicatorBackgroundColor = new PluginConfigColor(new Vector4(204f / 255f, 3f / 255f, 3f / 255f, 80f / 100f)); + + [Checkbox("Use Role Colors", isMonitored = true, spacing = true)] + [Order(20)] + public bool UseRoleColors = false; + + [NestedConfig("Color Based On Health Value", 30, collapsingHeader = false)] + public ColorByHealthValueConfig ColorByHealth = new ColorByHealthValueConfig(); + + [Checkbox("Highlight When Hovering With Cursor Or Soft Targeting", spacing = true)] + [Order(40)] + public bool ShowHighlight = true; + + [ColorEdit4("Highlight Color")] + [Order(45, collapseWith = nameof(ShowHighlight))] + public PluginConfigColor HighlightColor = new PluginConfigColor(new Vector4(255f / 255f, 255f / 255f, 255f / 255f, 5f / 100f)); + + + [Checkbox("Missing Health Color", spacing = true)] + [Order(46)] + public bool UseMissingHealthBar = false; + + [Checkbox("Job Color As Missing Health Color")] + [Order(47, collapseWith = nameof(UseMissingHealthBar))] + public bool UseJobColorAsMissingHealthColor = false; + + [Checkbox("Role Color As Missing Health Color")] + [Order(48, collapseWith = nameof(UseMissingHealthBar))] + public bool UseRoleColorAsMissingHealthColor = false; + + [ColorEdit4("Color" + "##MissingHealth")] + [Order(49, collapseWith = nameof(UseMissingHealthBar))] + public PluginConfigColor HealthMissingColor = new(new Vector4(255f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [Checkbox("Job Color As Background Color")] + [Order(50)] + public bool UseJobColorAsBackgroundColor = false; + + [Checkbox("Role Color As Background Color")] + [Order(51)] + public bool UseRoleColorAsBackgroundColor = false; + + [Checkbox("Show Enmity Border Colors", spacing = true)] + [Order(54)] + public bool ShowEnmityBorderColors = true; + + [ColorEdit4("Enmity Leader Color")] + [Order(55, collapseWith = nameof(ShowEnmityBorderColors))] + public PluginConfigColor EnmityLeaderBordercolor = new PluginConfigColor(new Vector4(255f / 255f, 40f / 255f, 40f / 255f, 100f / 100f)); + + [Checkbox("Show Second Enmity")] + [Order(60, collapseWith = nameof(ShowEnmityBorderColors))] + public bool ShowSecondEnmity = true; + + [Checkbox("Hide Second Enmity in Light Parties")] + [Order(65, collapseWith = nameof(ShowSecondEnmity))] + public bool HideSecondEnmityInLightParties = true; + + [ColorEdit4("Enmity Second Color")] + [Order(70, collapseWith = nameof(ShowSecondEnmity))] + public PluginConfigColor EnmitySecondBordercolor = new PluginConfigColor(new Vector4(255f / 255f, 175f / 255f, 40f / 255f, 100f / 100f)); + } + + [Exportable(false)] + public class PartyFramesRangeConfig : PluginConfigObject + { + [DragInt("Range (yalms)", min = 1, max = 500)] + [Order(5)] + public int Range = 30; + + [DragFloat("Alpha", min = 1, max = 100)] + [Order(10)] + public float Alpha = 25; + + [Checkbox("Use Additional Range Check")] + [Order(15)] + public bool UseAdditionalRangeCheck = false; + + [DragInt("Additional Range (yalms)", min = 1, max = 500)] + [Order(20, collapseWith = nameof(UseAdditionalRangeCheck))] + public int AdditionalRange = 15; + + [DragFloat("Additional Alpha", min = 1, max = 100)] + [Order(25, collapseWith = nameof(UseAdditionalRangeCheck))] + public float AdditionalAlpha = 60; + + public float AlphaForDistance(int distance, float alpha = 100f) + { + if (!Enabled) + { + return 100f; + } + + if (!UseAdditionalRangeCheck) + { + return distance > Range ? Alpha : alpha; + } + + if (Range > AdditionalRange) + { + return distance > Range ? Alpha : (distance > AdditionalRange ? AdditionalAlpha : alpha); + } + + return distance > AdditionalRange ? AdditionalAlpha : (distance > Range ? Alpha : alpha); + } + } + + public class PartyFramesManaBarConfigConverter : PluginConfigObjectConverter + { + public PartyFramesManaBarConfigConverter() + { + NewTypeFieldConverter converter; + converter = new NewTypeFieldConverter( + "PartyFramesManaBarDisplayMode", + PartyFramesManaBarDisplayMode.HealersOnly, + (oldValue) => + { + return oldValue ? PartyFramesManaBarDisplayMode.HealersOnly : PartyFramesManaBarDisplayMode.Always; + }); + + FieldConvertersMap.Add("ShowOnlyForHealers", converter); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(PartyFramesManaBarConfig); + } + } + + public enum PartyFramesManaBarDisplayMode + { + HealersAndRaiseJobs, + HealersOnly, + Always, + } + + [DisableParentSettings("HideWhenInactive", "Label")] + [Exportable(false)] + [Section("Party Frames", true)] + [SubSection("Mana Bar", 0)] + public class PartyFramesManaBarConfig : PrimaryResourceConfig + { + public new static PartyFramesManaBarConfig DefaultConfig() + { + var config = new PartyFramesManaBarConfig(Vector2.Zero, new(180, 6)); + config.HealthBarAnchor = DrawAnchor.Bottom; + config.Anchor = DrawAnchor.Bottom; + config.ValueLabel.Enabled = false; + return config; + } + + [Anchor("Health Bar Anchor")] + [Order(14)] + public DrawAnchor HealthBarAnchor = DrawAnchor.BottomLeft; + + [RadioSelector("Show For All Jobs With Raise", "Show Only For Healers", "Show For All Jobs")] + [Order(42)] + public PartyFramesManaBarDisplayMode ManaBarDisplayMode = PartyFramesManaBarDisplayMode.HealersOnly; + + public PartyFramesManaBarConfig(Vector2 position, Vector2 size) + : base(position, size) + { + } + } + + [Exportable(false)] + [Section("Party Frames", true)] + [SubSection("Castbar", 0)] + public class PartyFramesCastbarConfig : CastbarConfig + { + public new static PartyFramesCastbarConfig DefaultConfig() + { + var size = new Vector2(182, 10); + var pos = new Vector2(-1, 0); + + var castNameConfig = new LabelConfig(new Vector2(5, 0), "", DrawAnchor.Left, DrawAnchor.Left); + var castTimeConfig = new NumericLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + castTimeConfig.Enabled = false; + castTimeConfig.NumberFormat = 1; + + var config = new PartyFramesCastbarConfig(pos, size, castNameConfig, castTimeConfig); + config.HealthBarAnchor = DrawAnchor.BottomLeft; + config.Anchor = DrawAnchor.TopLeft; + config.ShowIcon = false; + config.Enabled = false; + + return config; + } + + [Checkbox("Hide Name When Casting")] + [Order(6)] + public bool HideNameWhenCasting = false; + + [Anchor("Health Bar Anchor")] + [Order(16)] + public DrawAnchor HealthBarAnchor = DrawAnchor.BottomLeft; + + public PartyFramesCastbarConfig(Vector2 position, Vector2 size, LabelConfig castNameConfig, NumericLabelConfig castTimeConfig) + : base(position, size, castNameConfig, castTimeConfig) + { + + } + } + + [Disableable(false)] + [Exportable(false)] + [Section("Party Frames", true)] + [SubSection("Icons", 0)] + public class PartyFramesIconsConfig : PluginConfigObject + { + public new static PartyFramesIconsConfig DefaultConfig() { return new PartyFramesIconsConfig(); } + + [NestedConfig("Role / Job", 10, separator = false)] + public PartyFramesRoleIconConfig Role = new PartyFramesRoleIconConfig( + new Vector2(20, 0), + new Vector2(20, 20), + DrawAnchor.TopLeft, + DrawAnchor.TopLeft + ); + + [NestedConfig("Sign", 11)] + public SignIconConfig Sign = new SignIconConfig( + new Vector2(0, -10), + new Vector2(30, 30), + DrawAnchor.Top, + DrawAnchor.Top + ); + + [NestedConfig("Leader", 12)] + public PartyFramesLeaderIconConfig Leader = new PartyFramesLeaderIconConfig( + new Vector2(-12, -12), + new Vector2(24, 24), + DrawAnchor.TopLeft, + DrawAnchor.TopLeft + ); + + [NestedConfig("Player Status", 13)] + public PartyFramesPlayerStatusConfig PlayerStatus = new PartyFramesPlayerStatusConfig(); + + [NestedConfig("Ready Check Status", 14)] + public PartyFramesReadyCheckStatusConfig ReadyCheckStatus = new PartyFramesReadyCheckStatusConfig(); + + [NestedConfig("Who's Talking", 15)] + public PartyFramesWhosTalkingConfig WhosTalking = new PartyFramesWhosTalkingConfig(); + } + + [Exportable(false)] + public class PartyFramesRoleIconConfig : RoleJobIconConfig + { + public PartyFramesRoleIconConfig() : base() { } + + public PartyFramesRoleIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + } + + [Exportable(false)] + public class PartyFramesLeaderIconConfig : IconConfig + { + public PartyFramesLeaderIconConfig() : base() { } + + public PartyFramesLeaderIconConfig(Vector2 position, Vector2 size, DrawAnchor anchor, DrawAnchor frameAnchor) + : base(position, size, anchor, frameAnchor) + { + } + } + + [Exportable(false)] + public class PartyFramesPlayerStatusConfig : PluginConfigObject + { + public new static PartyFramesPlayerStatusConfig DefaultConfig() + { + var config = new PartyFramesPlayerStatusConfig(); + config.Label.Enabled = false; + + return config; + } + + [Checkbox("Hide Name When Showing Status")] + [Order(5)] + public bool HideName = false; + + [NestedConfig("Icon", 10)] + public IconConfig Icon = new IconConfig( + new Vector2(0, 5), + new Vector2(16, 16), + DrawAnchor.Top, + DrawAnchor.Top + ); + + [NestedConfig("Label", 15)] + public LabelConfig Label = new LabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + } + + [Exportable(false)] + public class PartyFramesReadyCheckStatusConfig : PluginConfigObject + { + public new static PartyFramesReadyCheckStatusConfig DefaultConfig() => new PartyFramesReadyCheckStatusConfig(); + + [Checkbox("Hide Name When Showing Status")] + [Order(5)] + public bool HideName = false; + + [DragInt("Duration (seconds)", min = 1, max = 60, help = "Determines for how long the icons will show after a ready check is finished.")] + [Order(6)] + public int Duration = 10; + + [NestedConfig("Icon", 10)] + public IconConfig Icon = new IconConfig( + new Vector2(0, 0), + new Vector2(24, 24), + DrawAnchor.TopRight, + DrawAnchor.TopRight + ); + } + + [Exportable(false)] + public class PartyFramesWhosTalkingConfig : PluginConfigObject + { + public new static PartyFramesWhosTalkingConfig DefaultConfig() => new PartyFramesWhosTalkingConfig(); + + [Checkbox("Replace Role/Job Icon when active")] + [Order(5)] + public bool ReplaceRoleJobIcon = false; + + [Checkbox("Show Speaking State", spacing = true)] + [Order(10)] + public bool ShowSpeaking = true; + + [Checkbox("Show Muted State")] + [Order(10)] + public bool ShowMuted = true; + + [Checkbox("Show Deafened State")] + [Order(10)] + public bool ShowDeafened = true; + + [NestedConfig("Icon", 20)] + public IconConfig Icon = new IconConfig( + new Vector2(0, 0), + new Vector2(24, 24), + DrawAnchor.TopRight, + DrawAnchor.TopRight + ); + + [Checkbox("Change Health Bar Border when active", spacing = true, help = "Enabling this will override other border settings!")] + [Order(30)] + public bool ChangeBorders = false; + + [DragInt("Border Thickness", min = 1, max = 10)] + [Order(31, collapseWith = nameof(ChangeBorders))] + public int BorderThickness = 1; + + [ColorEdit4("Speaking Border Color")] + [Order(32, collapseWith = nameof(ChangeBorders))] + public PluginConfigColor SpeakingBorderColor = PluginConfigColor.FromHex(0xFF40BB40); + + [ColorEdit4("Muted Border Color")] + [Order(33, collapseWith = nameof(ChangeBorders))] + public PluginConfigColor MutedBorderColor = PluginConfigColor.FromHex(0xFF008080); + + [ColorEdit4("Deafened Border Color")] + [Order(34, collapseWith = nameof(ChangeBorders))] + public PluginConfigColor DeafenedBorderColor = PluginConfigColor.FromHex(0xFFFF4444); + + public bool EnabledForState(WhosTalkingState state) + { + switch (state) + { + case WhosTalkingState.Speaking: return ShowSpeaking; + case WhosTalkingState.Muted: return ShowMuted; + case WhosTalkingState.Deafened: return ShowDeafened; + } + + return false; + } + + public PluginConfigColor? ColorForState(WhosTalkingState state) + { + if (state == WhosTalkingState.Speaking && ShowSpeaking) { return SpeakingBorderColor; } + if (state == WhosTalkingState.Muted && ShowMuted) { return MutedBorderColor; } + if (ShowDeafened) { return DeafenedBorderColor; } + + return null; + } + } + + [Exportable(false)] + [Section("Party Frames", true)] + [SubSection("Buffs", 0)] + public class PartyFramesBuffsConfig : PartyFramesStatusEffectsListConfig + { + public new static PartyFramesBuffsConfig DefaultConfig() + { + var durationConfig = new LabelConfig(new Vector2(0, -4), "", DrawAnchor.Bottom, DrawAnchor.Center); + var stacksConfig = new LabelConfig(new Vector2(-3, 4), "", DrawAnchor.TopRight, DrawAnchor.Center); + stacksConfig.Color = new(Vector4.UnitW); + stacksConfig.OutlineColor = new(Vector4.One); + + var iconConfig = new StatusEffectIconConfig(durationConfig, stacksConfig); + iconConfig.DispellableBorderConfig.Enabled = false; + iconConfig.Size = new Vector2(24, 24); + + var pos = new Vector2(-2, 2); + var size = new Vector2(iconConfig.Size.X * 4 + 6, iconConfig.Size.Y); + + var config = new PartyFramesBuffsConfig(DrawAnchor.TopRight, pos, size, true, false, false, GrowthDirections.Left | GrowthDirections.Down, iconConfig); + config.Limit = 4; + + return config; + } + + public PartyFramesBuffsConfig(DrawAnchor anchor, Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(anchor, position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + [Exportable(false)] + [Section("Party Frames", true)] + [SubSection("Debuffs", 0)] + public class PartyFramesDebuffsConfig : PartyFramesStatusEffectsListConfig + { + public new static PartyFramesDebuffsConfig DefaultConfig() + { + var durationConfig = new LabelConfig(new Vector2(0, -4), "", DrawAnchor.Bottom, DrawAnchor.Center); + var stacksConfig = new LabelConfig(new Vector2(-3, 4), "", DrawAnchor.TopRight, DrawAnchor.Center); + stacksConfig.Color = new(Vector4.UnitW); + stacksConfig.OutlineColor = new(Vector4.One); + + var iconConfig = new StatusEffectIconConfig(durationConfig, stacksConfig); + iconConfig.Size = new Vector2(24, 24); + + var pos = new Vector2(-2, -2); + var size = new Vector2(iconConfig.Size.X * 4 + 6, iconConfig.Size.Y); + + var config = new PartyFramesDebuffsConfig(DrawAnchor.BottomRight, pos, size, false, true, false, GrowthDirections.Left | GrowthDirections.Up, iconConfig); + config.Limit = 4; + + return config; + } + + public PartyFramesDebuffsConfig(DrawAnchor anchor, Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(anchor, position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + public class PartyFramesStatusEffectsListConfig : StatusEffectsListConfig + { + [Anchor("Health Bar Anchor")] + [Order(4)] + public DrawAnchor HealthBarAnchor = DrawAnchor.BottomLeft; + + public PartyFramesStatusEffectsListConfig(DrawAnchor anchor, Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + HealthBarAnchor = anchor; + } + } + + [Disableable(false)] + [Exportable(false)] + [Section("Party Frames", true)] + [SubSection("Trackers", 0)] + public class PartyFramesTrackersConfig : PluginConfigObject + { + public new static PartyFramesTrackersConfig DefaultConfig() { return new PartyFramesTrackersConfig(); } + + [NestedConfig("Raise Tracker", 10, separator = false)] + public PartyFramesRaiseTrackerConfig Raise = new PartyFramesRaiseTrackerConfig(); + + [NestedConfig("Invulnerabilities Tracker", 15)] + public PartyFramesInvulnTrackerConfig Invuln = new PartyFramesInvulnTrackerConfig(); + + [NestedConfig("Cleanse Tracker", 15)] + public PartyFramesCleanseTrackerConfig Cleanse = new PartyFramesCleanseTrackerConfig(); + } + + [Exportable(false)] + public class PartyFramesRaiseTrackerConfig : PluginConfigObject + { + public new static PartyFramesRaiseTrackerConfig DefaultConfig() { return new PartyFramesRaiseTrackerConfig(); } + + [Checkbox("Hide Name When Raised")] + [Order(10)] + public bool HideNameWhenRaised = true; + + [Checkbox("Keep Icon After Cast Finishes")] + [Order(15)] + public bool KeepIconAfterCastFinishes = true; + + [Checkbox("Change Background Color When Raised", spacing = true)] + [Order(20)] + public bool ChangeBackgroundColorWhenRaised = true; + + [ColorEdit4("Raise Background Color")] + [Order(25, collapseWith = nameof(ChangeBackgroundColorWhenRaised))] + public PluginConfigColor BackgroundColor = new(new Vector4(211f / 255f, 235f / 255f, 215f / 245f, 50f / 100f)); + + [Checkbox("Change Border Color When Raised", spacing = true)] + [Order(30)] + public bool ChangeBorderColorWhenRaised = true; + + [ColorEdit4("Raise Border Color")] + [Order(35, collapseWith = nameof(ChangeBorderColorWhenRaised))] + public PluginConfigColor BorderColor = new(new Vector4(47f / 255f, 169f / 255f, 215f / 255f, 100f / 100f)); + + [NestedConfig("Icon", 50)] + public IconWithLabelConfig Icon = new IconWithLabelConfig( + new Vector2(0, 0), + new Vector2(50, 50), + DrawAnchor.Center, + DrawAnchor.Center + ); + } + + [Exportable(false)] + public class PartyFramesInvulnTrackerConfig : PluginConfigObject + { + public new static PartyFramesInvulnTrackerConfig DefaultConfig() { return new PartyFramesInvulnTrackerConfig(); } + + [Checkbox("Hide Name When Invuln is Up")] + [Order(10)] + public bool HideNameWhenInvuln = true; + + [Checkbox("Change Background Color When Invuln is Up", spacing = true)] + [Order(15)] + public bool ChangeBackgroundColorWhenInvuln = true; + + [ColorEdit4("Invuln Background Color")] + [Order(20, collapseWith = nameof(ChangeBackgroundColorWhenInvuln))] + public PluginConfigColor BackgroundColor = new(new Vector4(211f / 255f, 235f / 255f, 215f / 245f, 50f / 100f)); + + [Checkbox("Walking Dead Custom Color")] + [Order(25, collapseWith = nameof(ChangeBackgroundColorWhenInvuln))] + public bool UseCustomWalkingDeadColor = true; + + [ColorEdit4("Walking Dead Background Color")] + [Order(30, collapseWith = nameof(UseCustomWalkingDeadColor))] + public PluginConfigColor WalkingDeadBackgroundColor = new(new Vector4(158f / 255f, 158f / 255f, 158f / 255f, 50f / 100f)); + + [NestedConfig("Icon", 50)] + public IconWithLabelConfig Icon = new IconWithLabelConfig( + new Vector2(0, 0), + new Vector2(50, 50), + DrawAnchor.Center, + DrawAnchor.Center + ); + } + + public class PartyFramesTrackerConfigConverter : PluginConfigObjectConverter + { + public PartyFramesTrackerConfigConverter() + { + SameTypeFieldConverter pos = new SameTypeFieldConverter("Icon.Position", Vector2.Zero); + FieldConvertersMap.Add("Position", pos); + + SameTypeFieldConverter size = new SameTypeFieldConverter("Icon.Size", new Vector2(50, 50)); + FieldConvertersMap.Add("IconSize", size); + + SameTypeFieldConverter anchor = new SameTypeFieldConverter("Icon.Anchor", DrawAnchor.Center); + FieldConvertersMap.Add("Anchor", anchor); + + SameTypeFieldConverter frameAnchor = new SameTypeFieldConverter("Icon.FrameAnchor", DrawAnchor.Center); + FieldConvertersMap.Add("HealthBarAnchor", frameAnchor); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(PartyFramesRaiseTrackerConfig) || + objectType == typeof(PartyFramesInvulnTrackerConfig); + } + } + + [DisableParentSettings("Position", "Strata")] + [Exportable(false)] + public class PartyFramesCleanseTrackerConfig : MovablePluginConfigObject + { + public new static PartyFramesCleanseTrackerConfig DefaultConfig() { return new PartyFramesCleanseTrackerConfig(); } + + [Checkbox("Show only on jobs with cleanses", spacing = true)] + [Order(10)] + public bool CleanseJobsOnly = true; + + [Checkbox("Change Health Bar Color ", spacing = true)] + [Order(15)] + public bool ChangeHealthBarCleanseColor = true; + + [ColorEdit4("Health Bar Color")] + [Order(20, collapseWith = nameof(ChangeHealthBarCleanseColor))] + public PluginConfigColor HealthBarColor = new(new Vector4(255f / 255f, 0f / 255f, 104f / 255f, 100f / 100f)); + + [Checkbox("Change Border Color", spacing = true)] + [Order(25)] + public bool ChangeBorderCleanseColor = true; + + [ColorEdit4("Border Color")] + [Order(30, collapseWith = nameof(ChangeBorderCleanseColor))] + public PluginConfigColor BorderColor = new(new Vector4(255f / 255f, 0f / 255f, 104f / 255f, 100f / 100f)); + } + + [Exportable(false)] + [DisableParentSettings("Anchor")] + [Section("Party Frames", true)] + [SubSection("Cooldowns", 0)] + public class PartyFramesCooldownListConfig : AnchorablePluginConfigObject + { + public new static PartyFramesCooldownListConfig DefaultConfig() + { + PartyFramesCooldownListConfig config = new PartyFramesCooldownListConfig(); + config.Position = new Vector2(-2, 0); + config.Size = new Vector2(40 * 8 + 6, 40); + + return config; + } + + [Anchor("Health Bar Anchor")] + [Order(3)] + public DrawAnchor HealthBarAnchor = DrawAnchor.Left; + + [Checkbox("Tooltips", spacing = true)] + [Order(20)] + public bool ShowTooltips = true; + + [Checkbox("Preview", isMonitored = true)] + [Order(21)] + public bool Preview; + + [DragInt2("Icon Size", min = 1, max = 4000, spacing = true)] + [Order(30)] + public Vector2 IconSize = new Vector2(40, 40); + + [DragInt2("Icon Padding", min = 0, max = 500)] + [Order(31)] + public Vector2 IconPadding = new(4, 4); + + [Checkbox("Fill Rows First")] + [Order(32)] + public bool FillRowsFirst = true; + + [Combo("Icons Growth Direction", + "Right and Down", + "Right and Up", + "Left and Down", + "Left and Up", + "Centered and Up", + "Centered and Down", + "Centered and Left", + "Centered and Right" + )] + [Order(33)] + public int Directions = 3; // left & up + + [Checkbox("Show Border", spacing = true)] + [Order(35)] + public bool DrawBorder = true; + + [ColorEdit4("Border Color")] + [Order(36, collapseWith = nameof(DrawBorder))] + public PluginConfigColor BorderColor = new PluginConfigColor(new Vector4(0f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [DragInt("Border Thickness", min = 1, max = 10)] + [Order(37, collapseWith = nameof(DrawBorder))] + public int BorderThickness = 1; + + [Checkbox("Change Icon Border When Active")] + [Order(45, collapseWith = nameof(DrawBorder))] + public bool ChangeIconBorderWhenActive = true; + + [ColorEdit4("Icon Active Border Color")] + [Order(46, collapseWith = nameof(ChangeIconBorderWhenActive))] + public PluginConfigColor IconActiveBorderColor = new PluginConfigColor(new Vector4(255f / 255f, 200f / 255f, 35f / 255f, 100f / 100f)); + + [DragInt("Icon Active Border Thickness", min = 1, max = 10)] + [Order(47, collapseWith = nameof(ChangeIconBorderWhenActive))] + public int IconActiveBorderThickness = 3; + + [Checkbox("Change Label Color When Active", spacing = true)] + [Order(50)] + public bool ChangeLabelsColorWhenActive = false; + + [ColorEdit4("Label Active Color")] + [Order(51, collapseWith = nameof(ChangeLabelsColorWhenActive))] + public PluginConfigColor LabelsActiveColor = new PluginConfigColor(new Vector4(255f / 255f, 200f / 255f, 35f / 255f, 100f / 100f)); + + [NestedConfig("Time Label", 80)] + public PartyCooldownTimeLabelConfig TimeLabel = new PartyCooldownTimeLabelConfig(new Vector2(0, 0), "", DrawAnchor.Center, DrawAnchor.Center) { NumberFormat = 1 }; + } +} diff --git a/Interface/Party/PartyFramesCooldownListHud.cs b/Interface/Party/PartyFramesCooldownListHud.cs new file mode 100644 index 0000000..d29ed74 --- /dev/null +++ b/Interface/Party/PartyFramesCooldownListHud.cs @@ -0,0 +1,295 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using HSUI.Interface.PartyCooldowns; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.Party +{ + public class PartyFramesCooldownListHud : ParentAnchoredDraggableHudElement, IHudElementWithActor, IHudElementWithAnchorableParent, IHudElementWithPreview + { + private PartyFramesCooldownListConfig Config => (PartyFramesCooldownListConfig)_config; + private PartyCooldownsDataConfig _dataConfig = null!; + + private LabelHud _timeLabel; + private bool _needsUpdate = true; + private LayoutInfo _layoutInfo; + + private List _cooldowns = new List(); + private List? _fakeCooldowns = null; + + public IGameObject? Actor { get; set; } + + protected override bool AnchorToParent => true; + protected override DrawAnchor ParentAnchor => Config is PartyFramesCooldownListConfig config ? config.HealthBarAnchor : DrawAnchor.Center; + + public PartyFramesCooldownListHud(PartyFramesCooldownListConfig config, string? displayName = null) : base(config, displayName) + { + _timeLabel = new LabelHud(config.TimeLabel); + + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + PartyCooldownsManager.Instance.CooldownsChangedEvent += OnCooldownsChanged; + _config.ValueChangeEvent += OnConfigPropertyChanged; + + OnConfigReset(ConfigurationManager.Instance); + } + + private void OnConfigReset(ConfigurationManager sender) + { + if (_dataConfig != null) + _dataConfig.CooldownsDataChangedEvent -= OnCooldownsDataChanged; + _dataConfig = ConfigurationManager.Instance.GetConfigObject(); + _dataConfig.CooldownsDataChangedEvent += OnCooldownsDataChanged; + } + + protected override void InternalDispose() + { + ConfigurationManager.Instance?.ResetEvent -= OnConfigReset; + _config.ValueChangeEvent -= OnConfigPropertyChanged; + _dataConfig?.CooldownsDataChangedEvent -= OnCooldownsDataChanged; + PartyCooldownsManager.Instance?.CooldownsChangedEvent -= OnCooldownsChanged; + } + + private void OnConfigPropertyChanged(object? sender, OnChangeBaseArgs args) + { + if (args.PropertyName == "Preview") + { + UpdatePreview(); + } + } + + private unsafe void UpdatePreview() + { + if (!Config.Preview) + { + _fakeCooldowns = null; + return; + } + + var RNG = new Random((int)ImGui.GetTime()); + + _fakeCooldowns = new List(); + + for (int i = 0; i < 10; i++) + { + int index = RNG.Next(0, _dataConfig.Cooldowns.Count); + + PartyCooldown cooldown = new PartyCooldown(_dataConfig.Cooldowns[index], 0, 90, null); + + int rng = RNG.Next(100); + if (rng > 80) + { + cooldown.LastTimeUsed = ImGui.GetTime() - 30; + } + else if (rng > 50) + { + cooldown.LastTimeUsed = ImGui.GetTime() + 1; + } + + _fakeCooldowns.Add(cooldown); + } + } + + public void StopPreview() + { + Config.Preview = false; + UpdatePreview(); + } + + private void OnCooldownsDataChanged(PartyCooldownsDataConfig sender) + { + _needsUpdate = true; + } + + private void OnCooldownsChanged(PartyCooldownsManager sender) + { + _needsUpdate = true; + } + + private void UpdateCooldowns() + { + _cooldowns.Clear(); + + if (Actor == null || PartyCooldownsManager.Instance?.CooldownsMap == null) { return; } + + if (PartyCooldownsManager.Instance.CooldownsMap.TryGetValue((uint)Actor.GameObjectId, out Dictionary? dict) && dict != null) + { + _cooldowns = dict.Values.Where(o => o.Data.IsEnabledForPartyFrames()).ToList(); + } + + _cooldowns.Sort((a, b) => + { + int aOrder = a.Data.Column * 1000 + a.Data.Priority; + int bOrder = b.Data.Column * 1000 + b.Data.Priority; + + return aOrder.CompareTo(bOrder); + }); + + _needsUpdate = false; + } + + private void CalculateLayout(uint count) + { + if (count <= 0) { return; } + + _layoutInfo = LayoutHelper.CalculateLayout( + Config.Size, + Config.IconSize, + count, + Config.IconPadding, + LayoutHelper.GetFillsRowsFirst(Config.FillRowsFirst, LayoutHelper.GrowthDirectionsFromIndex(Config.Directions)) + ); + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled) { return; } + + if (_needsUpdate) + { + UpdateCooldowns(); + } + + List list = _fakeCooldowns != null ? _fakeCooldowns : _cooldowns; + + if (list.Count == 0) { return; } + + // area + GrowthDirections growthDirections = LayoutHelper.GrowthDirectionsFromIndex(Config.Directions); + Vector2 position = origin + GetAnchoredPosition(Config.Position, Config.Size, DrawAnchor.TopLeft); + Vector2 areaPos = LayoutHelper.CalculateStartPosition(position, Config.Size, growthDirections); + Vector2 margin = new Vector2(14, 10); + + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + + // calculate icon positions + uint count = (uint)list.Count; + CalculateLayout(count); + var (iconPositions, minPos, maxPos) = LayoutHelper.CalculateIconPositions( + growthDirections, + count, + position, + Config.Size, + Config.IconSize, + Config.IconPadding, + LayoutHelper.GetFillsRowsFirst(Config.FillRowsFirst, growthDirections), + _layoutInfo + ); + + // window + // imgui clips the left and right borders inside windows for some reason + // we make the window bigger so the actual drawable size is the expected one + Vector2 windowPos = minPos - margin; + Vector2 windowSize = maxPos - minPos; + + AddDrawAction(Config.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID, windowPos, windowSize + margin * 2, false, (drawList) => + { + // area + if (Config.Preview) + { + drawList.AddRectFilled(areaPos, areaPos + Config.Size, 0x88000000); + } + + for (int i = 0; i < count; i++) + { + Vector2 iconPos = iconPositions[i]; + PartyCooldown cooldown = list[i]; + + float cooldownTime = cooldown.CooldownTimeRemaining(); + float effectTime = cooldown.EffectTimeRemaining(); + + // icon + bool recharging = effectTime == 0 && cooldownTime > 0; + uint color = recharging ? 0xAAFFFFFF : 0xFFFFFFFF; + bool shouldDrawCooldown = ClipRectsHelper.Instance.GetClipRectForArea(iconPos, Config.IconSize) == null; + + DrawHelper.DrawIcon(cooldown.Data.IconId, iconPos, Config.IconSize, false, color, drawList); + + if (shouldDrawCooldown && effectTime == 0 && cooldownTime > 0) + { + DrawHelper.DrawIconCooldown(iconPos, Config.IconSize, cooldownTime, cooldown.Data.CooldownDuration, drawList); + } + + // border + if (Config.DrawBorder) + { + bool active = effectTime > 0 && Config.ChangeIconBorderWhenActive; + uint iconBorderColor = active ? Config.IconActiveBorderColor.Base : Config.BorderColor.Base; + int thickness = active ? Config.IconActiveBorderThickness : Config.BorderThickness; + drawList.AddRect(iconPos, iconPos + Config.IconSize, iconBorderColor, 0, ImDrawFlags.None, thickness); + } + } + }); + }); + + PartyCooldown? hoveringCooldown = null; + IGameObject? character = Actor; + + // labels need to be drawn separated since they have their own window for clipping + for (var i = 0; i < count; i++) + { + Vector2 iconPos = iconPositions[i]; + PartyCooldown cooldown = list[i]; + + float cooldownTime = cooldown.CooldownTimeRemaining(); + float effectTime = cooldown.EffectTimeRemaining(); + + PluginConfigColor? labelColor = effectTime > 0 && Config.ChangeLabelsColorWhenActive ? Config.LabelsActiveColor : null; + + // time + AddDrawAction(Config.TimeLabel.StrataLevel, () => + { + PluginConfigColor realColor = Config.TimeLabel.Color; + Config.TimeLabel.Color = labelColor ?? realColor; + Config.TimeLabel.SetText(""); + + if (effectTime > 0) + { + if (Config.TimeLabel.ShowEffectDuration) + { + Config.TimeLabel.SetValue(effectTime); + } + } + else if (cooldownTime > 0) + { + if (Config.TimeLabel.ShowRemainingCooldown) + { + Config.TimeLabel.SetText(Utils.DurationToString(cooldownTime, Config.TimeLabel.NumberFormat)); + } + } + + _timeLabel.Draw(iconPos, Config.IconSize, character); + Config.TimeLabel.Color = realColor; + }); + + // tooltips / interaction + if (ImGui.IsMouseHoveringRect(iconPos, iconPos + Config.IconSize)) + { + hoveringCooldown = cooldown; + } + } + + if (hoveringCooldown != null) + { + // tooltip + if (Config.ShowTooltips) + { + TooltipsHelper.Instance.ShowTooltipOnCursor( + hoveringCooldown.TooltipText(), + hoveringCooldown.Data.Name, + hoveringCooldown.Data.ActionId + ); + } + } + } + } +} diff --git a/Interface/Party/PartyFramesHud.cs b/Interface/Party/PartyFramesHud.cs new file mode 100644 index 0000000..12bf708 --- /dev/null +++ b/Interface/Party/PartyFramesHud.cs @@ -0,0 +1,465 @@ +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +namespace HSUI.Interface.Party +{ + public class PartyFramesHud : DraggableHudElement, IHudElementWithMouseOver, IHudElementWithPreview, IHudElementWithVisibilityConfig + { + private PartyFramesConfig Config => (PartyFramesConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + private PartyFramesConfigs Configs; + + private Vector2 _contentMargin = new Vector2(2, 2); + private static readonly int MaxMemberCount = 9; // 8 players + chocobo + + // layout + private Vector2 _origin; + private LayoutInfo _layoutInfo; + private uint _memberCount = 0; + private bool _layoutDirty = true; + + private readonly List bars; + private LabelHud _titleLabelHud; + + private bool Locked => !ConfigurationManager.Instance.IsConfigWindowOpened; + + + public PartyFramesHud(PartyFramesConfig config, string displayName) : base(config, displayName) + { + Configs = PartyFramesConfigs.GetConfigs(); + + config.ValueChangeEvent += OnLayoutPropertyChanged; + Configs.HealthBar.ValueChangeEvent += OnLayoutPropertyChanged; + Configs.HealthBar.ColorsConfig.ValueChangeEvent += OnLayoutPropertyChanged; + + bars = new List(MaxMemberCount); + for (int i = 0; i < bars.Capacity; i++) + { + PartyFramesBar bar = new PartyFramesBar("DelvUI_partyFramesBar" + i, Configs); + bar.OpenContextMenuEvent += OnOpenContextMenu; + + bars.Add(bar); + } + + _titleLabelHud = new LabelHud(config.ShowPartyTitleConfig); + + PartyManager.Instance.MembersChangedEvent += OnMembersChanged; + UpdateBars(Vector2.Zero); + } + + protected override void InternalDispose() + { + foreach (var bar in bars) + { + try { bar.Dispose(); } + catch (Exception ex) { Plugin.Logger.Error($"Error disposing PartyFramesBar: {ex.Message}"); } + } + bars.Clear(); + + _config.ValueChangeEvent -= OnLayoutPropertyChanged; + Configs.HealthBar.ValueChangeEvent -= OnLayoutPropertyChanged; + Configs.HealthBar.ColorsConfig.ValueChangeEvent -= OnLayoutPropertyChanged; + PartyManager.Instance.MembersChangedEvent -= OnMembersChanged; + } + + private unsafe void OnOpenContextMenu(PartyFramesBar bar) + { + if (bar.Member == null || Plugin.ObjectTable.LocalPlayer == null) + { + return; + } + + if (PartyManager.Instance.PartyListAddon == null || PartyManager.Instance.HudAgent == IntPtr.Zero) + { + return; + } + + int addonId = PartyManager.Instance.PartyListAddon->AtkUnitBase.Id; + AgentModule.Instance()->GetAgentHUD()->OpenContextMenuFromPartyAddon(addonId, bar.Member.Index); + } + + private void OnLayoutPropertyChanged(object sender, OnChangeBaseArgs args) + { + if (args.PropertyName == "Size" || + args.PropertyName == "FillRowsFirst" || + args.PropertyName == "BarsAnchor" || + args.PropertyName == "Padding" || + args.PropertyName == "Rows" || + args.PropertyName == "Columns") + { + _layoutDirty = true; + } + } + + private void OnMembersChanged(PartyManager sender) + { + UpdateBars(_origin); + } + + public void UpdateBars(Vector2 origin) + { + uint memberCount = PartyManager.Instance.MemberCount; + uint row = 0; + uint col = 0; + + for (int i = 0; i < bars.Count; i++) + { + PartyFramesBar bar = bars[i]; + if (i >= memberCount) + { + bar.Visible = false; + continue; + } + + // update bar + IPartyFramesMember member = PartyManager.Instance.SortedGroupMembers.ElementAt(i); + bar.Member = member; + bar.Visible = true; + + // anchor and position + CalculateBarPosition(origin, Size, out var x, out var y); + bar.Position = new Vector2( + x + Configs.HealthBar.Size.X * col + (Configs.HealthBar.Padding.X - 1) * col, + y + Configs.HealthBar.Size.Y * row + (Configs.HealthBar.Padding.Y - 1) * row + ); + + // layout + if (Config.FillRowsFirst) + { + col = col + 1; + if (col >= _layoutInfo.TotalColCount) + { + col = 0; + row = row + 1; + } + } + else + { + row = row + 1; + if (row >= _layoutInfo.TotalRowCount) + { + row = 0; + col = col + 1; + } + } + } + } + + private void CalculateBarPosition(Vector2 position, Vector2 spaceSize, out float x, out float y) + { + x = position.X; + y = position.Y; + + if (Config.BarsAnchor == DrawAnchor.Top || + Config.BarsAnchor == DrawAnchor.Center || + Config.BarsAnchor == DrawAnchor.Bottom) + { + x += (spaceSize.X - _layoutInfo.ContentSize.X) / 2f; + } + else if (Config.BarsAnchor == DrawAnchor.TopRight || + Config.BarsAnchor == DrawAnchor.Right || + Config.BarsAnchor == DrawAnchor.BottomRight) + { + x += spaceSize.X - _layoutInfo.ContentSize.X; + } + + if (Config.BarsAnchor == DrawAnchor.Left || + Config.BarsAnchor == DrawAnchor.Center || + Config.BarsAnchor == DrawAnchor.Right) + { + y += (spaceSize.Y - _layoutInfo.ContentSize.Y) / 2f; + } + else if (Config.BarsAnchor == DrawAnchor.BottomLeft || + Config.BarsAnchor == DrawAnchor.Bottom || + Config.BarsAnchor == DrawAnchor.BottomRight) + { + y += spaceSize.Y - _layoutInfo.ContentSize.Y; + } + } + + private void UpdateBarsPosition(Vector2 delta) + { + foreach (PartyFramesBar bar in bars) + { + bar.Position = bar.Position + delta; + } + } + + public void StopPreview() + { + Config.Preview = false; + PartyManager.Instance?.UpdatePreview(); + + foreach (PartyFramesBar bar in bars) + { + bar.StopPreview(); + } + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List() { Config.Position + Size / 2f }, new List() { Size }); + } + + public void StopMouseover() + { + foreach (PartyFramesBar bar in bars) + { + bar.StopMouseover(); + } + } + + private Vector2 Size => new Vector2( + Config.Columns * Configs.HealthBar.Size.X + (Config.Columns - 1) * Configs.HealthBar.Padding.X, + Config.Rows * Configs.HealthBar.Size.Y + (Config.Rows - 1) * Configs.HealthBar.Padding.Y + ); + + private void UpdateLayout(Vector2 origin) + { + Vector2 contentStartPos = origin + Config.Position; + uint count = PartyManager.Instance.MemberCount; + + if (_layoutDirty || _memberCount != count) + { + _layoutInfo = LayoutHelper.CalculateLayout( + Size, + Configs.HealthBar.Size, + count, + Configs.HealthBar.Padding, + Config.FillRowsFirst + ); + UpdateBars(contentStartPos); + } + else if (_origin != contentStartPos) + { + UpdateBarsPosition(contentStartPos - _origin); + } + + _layoutDirty = false; + _origin = contentStartPos; + _memberCount = count; + } + + public override void DrawChildren(Vector2 origin) + { + if (!_config.Enabled) + { + return; + } + + // area bg + if (Config.Preview) + { + AddDrawAction(StrataLevel.LOWEST, () => + { + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 bgPos = origin + Config.Position - _contentMargin; + Vector2 bgSize = Size + _contentMargin * 2; + + drawList.AddRectFilled(bgPos, bgPos + bgSize, 0x66000000); + drawList.AddRect(bgPos, bgPos + bgSize, 0x66FFFFFF); + }); + } + + uint count = PartyManager.Instance.MemberCount; + if (count < 1) + { + return; + } + + UpdateLayout(origin); + + // draw bars + // check borders to determine the order in which the bars are drawn + // which is necessary for grid-like party frames + + IGameObject? target = Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target; + int targetIndex = -1; + int enmityLeaderIndex = -1; + int enmitySecondIndex = -1; + + List raisedIndexes = new List(); + List cleanseIndexes = new List(); + List whosTalkingIndexes = new List(); + + for (int i = 0; i < count; i++) + { + IPartyFramesMember? member = bars[i].Member; + + if (member != null) + { + // whos talking + if (Configs.Icons.WhosTalking.ChangeBorders && member.WhosTalkingState != WhosTalkingState.None) + { + whosTalkingIndexes.Add(i); + continue; + } + + // target + if (target != null && member.ObjectId == target.GameObjectId) + { + targetIndex = i; + continue; + } + + // cleanse + bool cleanseCheck = true; + if (Configs.Trackers.Cleanse.CleanseJobsOnly) + { + cleanseCheck = Utils.IsOnCleanseJob(); + } + + if (Configs.Trackers.Cleanse.Enabled && Configs.Trackers.Cleanse.ChangeBorderCleanseColor && member.HasDispellableDebuff && cleanseCheck) + { + cleanseIndexes.Add(i); + continue; + } + + // raise + if (Configs.Trackers.Raise.Enabled && Configs.Trackers.Raise.ChangeBorderColorWhenRaised && member.RaiseTime.HasValue) + { + raisedIndexes.Add(i); + continue; + } + + // enmity + if (Configs.HealthBar.ColorsConfig.ShowEnmityBorderColors) + { + if (member.EnmityLevel == EnmityLevel.Leader) + { + enmityLeaderIndex = i; + continue; + } + else if (Configs.HealthBar.ColorsConfig.ShowSecondEnmity && member.EnmityLevel == EnmityLevel.Second && + (count > 4 || !Configs.HealthBar.ColorsConfig.HideSecondEnmityInLightParties)) + { + enmitySecondIndex = i; + continue; + } + } + } + + // no special border + AddDrawActions(bars[i].GetBarDrawActions(origin)); + } + + // special colors for borders + + // 2nd enmity + if (enmitySecondIndex >= 0) + { + AddDrawActions(bars[enmitySecondIndex].GetBarDrawActions(origin, Configs.HealthBar.ColorsConfig.EnmitySecondBordercolor)); + } + + // 1st enmity + if (enmityLeaderIndex >= 0) + { + AddDrawActions(bars[enmityLeaderIndex].GetBarDrawActions(origin, Configs.HealthBar.ColorsConfig.EnmityLeaderBordercolor)); + } + + // raise + foreach (int index in raisedIndexes) + { + AddDrawActions(bars[index].GetBarDrawActions(origin, Configs.Trackers.Raise.BorderColor)); + } + + // target + if (targetIndex >= 0) + { + AddDrawActions(bars[targetIndex].GetBarDrawActions(origin, Configs.HealthBar.ColorsConfig.TargetBordercolor)); + } + + // cleanseable debuff + foreach (int index in cleanseIndexes) + { + AddDrawActions(bars[index].GetBarDrawActions(origin, Configs.Trackers.Cleanse.BorderColor)); + } + + // whos talking + foreach (int index in whosTalkingIndexes) + { + IPartyFramesMember? member = bars[index].Member; + if (member != null) + { + AddDrawActions(bars[index].GetBarDrawActions(origin, Configs.Icons.WhosTalking.ColorForState(member.WhosTalkingState))); + } + else + { + AddDrawActions(bars[index].GetBarDrawActions(origin)); + } + } + + // extra elements + foreach (PartyFramesBar bar in bars) + { + AddDrawActions(bar.GetElementsDrawActions(origin)); + } + + AddDrawAction(Config.ShowPartyTitleConfig.StrataLevel, () => + { + Config.ShowPartyTitleConfig.SetText(PartyManager.Instance.PartyTitle); + _titleLabelHud.Draw(origin + Config.Position); + }); + } + } + + #region utils + public struct PartyFramesConfigs + { + public PartyFramesHealthBarsConfig HealthBar; + public PartyFramesManaBarConfig ManaBar; + public PartyFramesCastbarConfig CastBar; + public PartyFramesIconsConfig Icons; + public PartyFramesBuffsConfig Buffs; + public PartyFramesDebuffsConfig Debuffs; + public PartyFramesTrackersConfig Trackers; + public PartyFramesCooldownListConfig CooldownList; + + public PartyFramesConfigs( + PartyFramesHealthBarsConfig healthBar, + PartyFramesManaBarConfig manaBar, + PartyFramesCastbarConfig castBar, + PartyFramesIconsConfig icons, + PartyFramesBuffsConfig buffs, + PartyFramesDebuffsConfig debuffs, + PartyFramesTrackersConfig trackers, + PartyFramesCooldownListConfig cooldownList) + { + HealthBar = healthBar; + ManaBar = manaBar; + CastBar = castBar; + Icons = icons; + Buffs = buffs; + Debuffs = debuffs; + Trackers = trackers; + CooldownList = cooldownList; + } + + public static PartyFramesConfigs GetConfigs() + { + return new PartyFramesConfigs( + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject(), + ConfigurationManager.Instance.GetConfigObject() + ); + } + + + } + #endregion +} diff --git a/Interface/Party/PartyFramesInvulnTracker.cs b/Interface/Party/PartyFramesInvulnTracker.cs new file mode 100644 index 0000000..377ef62 --- /dev/null +++ b/Interface/Party/PartyFramesInvulnTracker.cs @@ -0,0 +1,111 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Statuses; +using HSUI.Config; +using HSUI.Helpers; +using System; +using System.Collections.Generic; + +namespace HSUI.Interface.Party +{ + public class InvulnStatus + { + public readonly uint InvulnIcon; + public readonly float InvulnTime; + public readonly uint InvulnId; + + public InvulnStatus(uint invulnIcon, float invulnTime, uint invulnId) + { + InvulnIcon = invulnIcon; + InvulnTime = invulnTime; + InvulnId = invulnId; + } + } + public class PartyFramesInvulnTracker : IDisposable + { + private PartyFramesInvulnTrackerConfig _config = null!; + + public PartyFramesInvulnTracker() + { + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + OnConfigReset(ConfigurationManager.Instance); + } + + ~PartyFramesInvulnTracker() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + } + + public void OnConfigReset(ConfigurationManager sender) + { + _config = ConfigurationManager.Instance.GetConfigObject().Invuln; + } + + public void Update(List partyMembers) + { + if (!_config.Enabled) + { + return; + } + + + foreach (var member in partyMembers) + { + + if (member.Character == null || member.ObjectId == 0) + { + member.InvulnStatus = null; + continue; + } + + if (member.Character is not IBattleChara battleChara || member.HP <= 0) + { + member.InvulnStatus = null; + continue; + } + + // check invuln buff + IStatus? tankInvuln = Utils.GetTankInvulnerabilityID(battleChara); + if (tankInvuln == null) + { + member.InvulnStatus = null; + continue; + } + + // apply invuln data based on buff + + member.InvulnStatus = new InvulnStatus(InvulnMap[tankInvuln.StatusId], tankInvuln.RemainingTime, tankInvuln.StatusId); + } + } + + #region invuln ids + //these need to be mapped instead + private static Dictionary InvulnMap = new Dictionary() + { + { 810, 003077 }, // LIVING DEAD + { 3255, 003077}, // UNDEAD REBIRTH + { 811, 003077 }, // WALKING DEAD + { 1302, 002502 }, // HALLOWED GROUND + { 82, 002502 }, // HALLOWED GROUND + { 409, 000266 }, // HOLMGANG + { 1836, 003416 } // SUPERBOLIDE + }; + + #endregion + } +} diff --git a/Interface/Party/PartyFramesMember.cs b/Interface/Party/PartyFramesMember.cs new file mode 100644 index 0000000..3478a1d --- /dev/null +++ b/Interface/Party/PartyFramesMember.cs @@ -0,0 +1,235 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Party; +using HSUI.Helpers; +using Dalamud.Bindings.ImGui; +using System; +using FFXIVClientStructs.FFXIV.Client.UI.Info; + +namespace HSUI.Interface.Party +{ + public enum EnmityLevel : byte + { + Leader = 1, + Second = 2, + Last = 255 + } + + public enum PartyMemberStatus : byte + { + None, + ViewingCutscene, + Offline, + Dead + } + + public unsafe class PartyFramesMember : IPartyFramesMember + { + protected IPartyMember? _partyMember = null; + private string _name = ""; + private uint _jobId = 0; + private uint _objectID = 0; + + public uint ObjectId => _partyMember != null ? _partyMember.EntityId : _objectID; + public ICharacter? Character { get; private set; } + public CrossRealmMember? CrossCharacter { get; private set; } + + public int Index { get; set; } + public int Order { get; set; } + public string Name => _partyMember != null ? _partyMember.Name.ToString() : (Character != null ? Character.Name.ToString() : _name); + public uint Level => _partyMember != null ? _partyMember.Level : (Character != null ? Character.Level : (uint)0); + public uint JobId => _partyMember != null ? _partyMember.ClassJob.RowId : (Character != null ? Character.ClassJob.RowId : _jobId); + public uint HP => _partyMember != null ? _partyMember.CurrentHP : (Character != null ? Character.CurrentHp : (uint)0); + public uint MaxHP => _partyMember != null ? _partyMember.MaxHP : (Character != null ? Character.MaxHp : (uint)0); + public uint MP => _partyMember != null ? _partyMember.CurrentMP : JobsHelper.CurrentPrimaryResource(Character); + public uint MaxMP => _partyMember != null ? _partyMember.MaxMP : JobsHelper.MaxPrimaryResource(Character); + public float Shield => Utils.ActorShieldValue(Character); + public EnmityLevel EnmityLevel { get; private set; } = EnmityLevel.Last; + public PartyMemberStatus Status { get; private set; } = PartyMemberStatus.None; + public ReadyCheckStatus ReadyCheckStatus { get; private set; } = ReadyCheckStatus.None; + public bool IsPartyLeader { get; private set; } = false; + public bool IsChocobo { get; private set; } = false; + public float? RaiseTime { get; set; } + public InvulnStatus? InvulnStatus { get; set; } + public bool HasDispellableDebuff { get; set; } = false; + public WhosTalkingState WhosTalkingState => WhosTalkingHelper.Instance?.GetUserState(Name) ?? WhosTalkingState.None; + + public PartyFramesMember(IPartyMember partyMember, int index, int order, EnmityLevel enmityLevel, PartyMemberStatus status, ReadyCheckStatus readyCheckStatus, bool isPartyLeader, bool isChocobo = false) + { + _partyMember = partyMember; + Index = index; + Order = order; + EnmityLevel = enmityLevel; + Status = status; + ReadyCheckStatus = readyCheckStatus; + IsPartyLeader = isPartyLeader; + IsChocobo = isChocobo; + + var gameObject = partyMember.GameObject; + if (gameObject is ICharacter character) + { + Character = character; + } + } + + public PartyFramesMember(ICharacter character, int index, int order, EnmityLevel enmityLevel, PartyMemberStatus status, ReadyCheckStatus readyCheckStatus, bool isPartyLeader, bool isChocobo = false) + { + Index = index; + Order = order; + EnmityLevel = enmityLevel; + Status = status; + ReadyCheckStatus = readyCheckStatus; + IsPartyLeader = isPartyLeader; + IsChocobo = isChocobo; + + _objectID = (uint)character.GameObjectId; + Character = character; + } + + public PartyFramesMember(uint objectId, int index, int order, EnmityLevel enmityLevel, PartyMemberStatus status, ReadyCheckStatus readyCheckStatus, bool isPartyLeader, bool isChocobo = false) + { + Index = index; + Order = order; + EnmityLevel = enmityLevel; + Status = status; + ReadyCheckStatus = readyCheckStatus; + IsPartyLeader = isPartyLeader; + IsChocobo = isChocobo; + + _objectID = objectId; + var gameObject = Plugin.ObjectTable.SearchById(ObjectId); + Character = gameObject is ICharacter ? (ICharacter)gameObject : null; + } + + public PartyFramesMember(CrossRealmMember member, int index, int order, PartyMemberStatus status, ReadyCheckStatus readyCheckStatus, bool isPartyLeader, bool isChocobo = false) + { + Index = index; + Order = order; + Status = status; + ReadyCheckStatus = readyCheckStatus; + IsPartyLeader = isPartyLeader; + IsChocobo = isChocobo; + + _objectID = (uint)member.EntityId; + CrossCharacter = member; + _name = member.NameString; + _jobId = member.ClassJobId; + } + + + public void Update(EnmityLevel enmityLevel, PartyMemberStatus status, ReadyCheckStatus readyCheckStatus, bool isPartyLeader, uint jobId = 0) + { + EnmityLevel = enmityLevel; + Status = status; + ReadyCheckStatus = readyCheckStatus; + IsPartyLeader = isPartyLeader; + + if (ObjectId == 0) + { + Character = null; + return; + } + + var gameObject = Plugin.ObjectTable.SearchById(ObjectId); + Character = gameObject is ICharacter ? (ICharacter)gameObject : null; + + if (jobId > 0) + { + _jobId = jobId; + } + else if (Character != null) + { + _jobId = Character.ClassJob.RowId; + } + + if (status == PartyMemberStatus.None && Character != null && MaxHP > 0 && HP <= 0) + { + Status = PartyMemberStatus.Dead; + } + } + } + + public class FakePartyFramesMember : IPartyFramesMember + { + public static readonly Random RNG = new Random((int)ImGui.GetTime()); + + public uint ObjectId => 0xE0000000; + public ICharacter? Character => null; + + public int Index { get; set; } + public int Order { get; set; } + public string Name { get; private set; } + public uint Level { get; private set; } + public uint JobId { get; private set; } + public uint HP { get; private set; } + public uint MaxHP { get; private set; } + public uint MP { get; private set; } + public uint MaxMP { get; private set; } + public float Shield { get; private set; } + public EnmityLevel EnmityLevel { get; private set; } + public PartyMemberStatus Status { get; private set; } + public ReadyCheckStatus ReadyCheckStatus { get; private set; } + public bool IsPartyLeader { get; } + public bool IsChocobo { get; } + public float? RaiseTime { get; set; } + public InvulnStatus? InvulnStatus { get; set; } + public bool HasDispellableDebuff { get; set; } + public WhosTalkingState WhosTalkingState { get; set; } + + public FakePartyFramesMember(int order) + { + Name = RNG.Next(0, 2) == 1 ? "Fake Name" : "FakeLonger MockedName"; + Index = order; + Order = order + 1; + Level = (uint)RNG.Next(1, 80); + JobId = (uint)RNG.Next(19, 41); + MaxHP = (uint)RNG.Next(90000, 150000); + HP = order == 2 || order == 3 ? 0 : (uint)(MaxHP * RNG.Next(50, 100) / 100f); + MaxMP = 10000; + MP = order == 2 || order == 3 ? 0 : (uint)(MaxMP * RNG.Next(100) / 100f); + Shield = order == 2 || order == 3 ? 0 : RNG.Next(30) / 100f; + EnmityLevel = order <= 1 ? (EnmityLevel)order + 1 : EnmityLevel.Last; + Status = order < 3 ? PartyMemberStatus.None : (order == 3 ? PartyMemberStatus.Dead : (PartyMemberStatus)RNG.Next(0, 3)); + ReadyCheckStatus = (ReadyCheckStatus)RNG.Next(0, 3); + IsPartyLeader = order == 0; + IsChocobo = RNG.Next(0, 8) == 1; + HasDispellableDebuff = RNG.Next(0, 2) == 1; + RaiseTime = order == 2 ? RNG.Next(0, 60) : null; + InvulnStatus = order == 0 ? new InvulnStatus(3077, RNG.Next(0, 10), 810) : null; + WhosTalkingState = (WhosTalkingState)RNG.Next(0, 4); + } + + public void Update(EnmityLevel enmityLevel, PartyMemberStatus status, ReadyCheckStatus readyCheckStatus, bool isPartyLeader, uint jobId = 0) + { + + } + } + + public interface IPartyFramesMember + { + public uint ObjectId { get; } + public ICharacter? Character { get; } + + public int Index { get; } + public int Order { get; } + public string Name { get; } + public uint Level { get; } + public uint JobId { get; } + public uint HP { get; } + public uint MaxHP { get; } + public uint MP { get; } + public uint MaxMP { get; } + public float Shield { get; } + public EnmityLevel EnmityLevel { get; } + public PartyMemberStatus Status { get; } + public ReadyCheckStatus ReadyCheckStatus { get; } + public bool IsPartyLeader { get; } + public bool IsChocobo { get; } + public float? RaiseTime { get; set; } + public InvulnStatus? InvulnStatus { get; set; } + public bool HasDispellableDebuff { get; set; } + + public WhosTalkingState WhosTalkingState { get; } + + public void Update(EnmityLevel enmityLevel, PartyMemberStatus status, ReadyCheckStatus readyCheckStatus, bool isPartyLeader, uint jobId = 0); + } +} diff --git a/Interface/Party/PartyFramesRaiseTracker.cs b/Interface/Party/PartyFramesRaiseTracker.cs new file mode 100644 index 0000000..5921826 --- /dev/null +++ b/Interface/Party/PartyFramesRaiseTracker.cs @@ -0,0 +1,185 @@ +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Helpers; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace HSUI.Interface.Party +{ + public class PartyFramesRaiseTracker : IDisposable + { + private PartyFramesRaiseTrackerConfig _config = null!; + + public PartyFramesRaiseTracker() + { + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + OnConfigReset(ConfigurationManager.Instance); + } + + ~PartyFramesRaiseTracker() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ConfigurationManager.Instance.ResetEvent -= OnConfigReset; + } + + public void OnConfigReset(ConfigurationManager sender) + { + _config = sender.GetConfigObject().Raise; + } + + public void Update(List partyMembers) + { + if (!_config.Enabled) + { + return; + } + + Dictionary deadAndNotRaised = new Dictionary(); + float? limitBreakTime = null; + Dictionary raiseTimeMap = new Dictionary(); + + foreach (var member in partyMembers) + { + if (member.Character == null || member.ObjectId == 0) + { + member.RaiseTime = null; + continue; + } + + if (member.HP > 0) + { + member.RaiseTime = null; + } + + if (member.Character is not IBattleChara battleChara) + { + continue; + } + + // check raise casts + if (Utils.IsActorCasting(battleChara)) + { + var remaining = Math.Max(0, battleChara.TotalCastTime - battleChara.CurrentCastTime); + + // check limit break + if (IsRaiseLimitBreakAction(battleChara.CastActionId) && + (limitBreakTime.HasValue && limitBreakTime.Value > remaining)) + { + limitBreakTime = remaining; + } + // check regular raise + else if (IsRaiseAction(battleChara.CastActionId)) + { + if (raiseTimeMap.TryGetValue((uint)battleChara.CastTargetObjectId, out float raiseTime)) + { + if (raiseTime > remaining) + { + raiseTimeMap[(uint)battleChara.CastTargetObjectId] = remaining; + } + } + else + { + raiseTimeMap.Add((uint)battleChara.CastTargetObjectId, remaining); + } + } + } + + // check raise buff + if (member.HP <= 0) + { + bool hasBuff = false; + + var statusList = Utils.StatusListForBattleChara(battleChara); + foreach (var status in statusList) + { + if (status == null || (status.StatusId != 148 && status.StatusId != 1140)) + { + continue; + } + + // apply raise data based on buff + member.RaiseTime = status.RemainingTime; + hasBuff = true; + break; + } + + if (!hasBuff) + { + deadAndNotRaised.Add(member.ObjectId, member); + } + } + } + + // apply raise data based on casts + foreach (var memberId in deadAndNotRaised.Keys) + { + var member = deadAndNotRaised[memberId]; + + if (raiseTimeMap.TryGetValue(memberId, out float raiseTime)) + { + if (limitBreakTime.HasValue && limitBreakTime.Value < raiseTime) + { + member.RaiseTime = limitBreakTime; + } + else + { + member.RaiseTime = raiseTime; + } + } + else + { + member.RaiseTime = limitBreakTime; // its fine if this is null here + } + } + } + + #region raise ids + private static bool IsRaiseLimitBreakAction(uint actionId) + { + return LimitBreakIds.Contains(actionId); + } + + private static bool IsRaiseAction(uint actionId) + { + return RaiseIds.Contains(actionId); + } + + + private static List RaiseIds = new List() + { + 173, // ACN, SMN, SCH + 125, // CNH, WHM + 3603, // AST + 18317, // BLU + 22345, // Lost Sacrifice, Bozja + 20730, // Lost Arise, Bozja + 12996, // Raise L, Eureka + 24287 // SGE + }; + + private static List LimitBreakIds = new List() + { + 208, // WHM + 4247, // SCH + 4248, // AST + 24859 // SGE + }; + #endregion + } +} diff --git a/Interface/Party/PartyManager.cs b/Interface/Party/PartyManager.cs new file mode 100644 index 0000000..9fb5370 --- /dev/null +++ b/Interface/Party/PartyManager.cs @@ -0,0 +1,805 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Memory; +using HSUI.Config; +using HSUI.Helpers; +using FFXIVClientStructs.FFXIV.Client.Game.Group; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Info; +using Lumina.Excel.Sheets; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Client.UI.Arrays; +using FFXIVClientStructs.FFXIV.Component.GUI; +using static FFXIVClientStructs.FFXIV.Client.Game.Group.GroupManager; +using DalamudPartyMember = Dalamud.Game.ClientState.Party.IPartyMember; +using StructsPartyMember = FFXIVClientStructs.FFXIV.Client.Game.Group.PartyMember; + +namespace HSUI.Interface.Party +{ + public delegate void PartyMembersChangedEventHandler(PartyManager sender); + + public unsafe class PartyManager : IDisposable + { + #region Singleton + public static PartyManager Instance { get; private set; } = null!; + private PartyFramesConfig _config = null!; + private PartyFramesIconsConfig _iconsConfig = null!; + + private PartyManager() + { + _readyCheckHelper = new PartyReadyCheckHelper(); + _raiseTracker = new PartyFramesRaiseTracker(); + _invulnTracker = new PartyFramesInvulnTracker(); + _cleanseTracker = new PartyFramesCleanseTracker(); + + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + + OnConfigReset(ConfigurationManager.Instance); + UpdatePreview(); + + // find offline string for active language + if (Plugin.DataManager.GetExcelSheet().TryGetRow(9836, out Addon row)) + { + _offlineString = row.Text.ToString(); + } + } + + public static void Initialize() + { + Instance = new PartyManager(); + } + + ~PartyManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _readyCheckHelper.Dispose(); + _raiseTracker.Dispose(); + _invulnTracker.Dispose(); + _cleanseTracker.Dispose(); + + _config.ValueChangeEvent -= OnConfigPropertyChanged; + + Instance = null!; + } + + private void OnConfigReset(ConfigurationManager sender) + { + if (_config != null) + { + _config.ValueChangeEvent -= OnConfigPropertyChanged; + } + + _config = sender.GetConfigObject(); + _config.ValueChangeEvent += OnConfigPropertyChanged; + + _iconsConfig = ConfigurationManager.Instance.GetConfigObject(); + } + + #endregion Singleton + + public AddonPartyList* PartyListAddon { get; private set; } = null; + public IntPtr HudAgent { get; private set; } = IntPtr.Zero; + + private RaptureAtkModule* _raptureAtkModule = null; + + private const int PartyListInfoOffset = 0x0D40; + private const int PartyListMemberRawInfoSize = 0x28; + + private const int PartyMembersInfoIndex = 12; // TODO: Should be reworked to use PartyMemberListStringArray.Instance() + + private List _groupMembers = new List(); + public IReadOnlyCollection GroupMembers => _groupMembers.AsReadOnly(); + + private List _sortedGroupMembers = new List(); + public IReadOnlyCollection SortedGroupMembers => _sortedGroupMembers.AsReadOnly(); + + public uint MemberCount => (uint)_groupMembers.Count; + + private string? _partyTitle = null; + public string PartyTitle => _partyTitle ?? ""; + + private int _groupMemberCount => GroupManager.Instance()->MainGroup.MemberCount; + private int _realMemberCount => PartyListAddon != null ? PartyListAddon->MemberCount : Plugin.PartyList.Length; + private int _realMemberAndChocoboCount => PartyListAddon != null ? PartyListAddon->MemberCount + Math.Max(1, (int)PartyListAddon->ChocoboCount) : Plugin.PartyList.Length; + + private Dictionary _prevDataMap = new(); + + private bool _wasRealGroup = false; + private bool _wasCrossWorld = false; + + private InfoProxyCrossRealm* _crossRealmInfo => InfoProxyCrossRealm.Instance(); + private Group _mainGroup => GroupManager.Instance()->MainGroup; + + private string _offlineString = "offline"; + + private PartyReadyCheckHelper _readyCheckHelper; + private PartyFramesRaiseTracker _raiseTracker; + private PartyFramesInvulnTracker _invulnTracker; + private PartyFramesCleanseTracker _cleanseTracker; + + public event PartyMembersChangedEventHandler? MembersChangedEvent; + + public bool Previewing => _config.Preview; + + public bool IsSoloParty() + { + if (!_config.ShowWhenSolo) { return false; } + + return _groupMembers.Count <= 1 || + (_groupMembers.Count == 2 && _config.ShowChocobo && + _groupMembers[1].Character is IBattleNpc npc && npc.BattleNpcKind == BattleNpcSubKind.Chocobo); + } + + public void Update() + { + // find party list hud agent + PartyListAddon = (AddonPartyList*)Plugin.GameGui.GetAddonByName("_PartyList", 1).Address; + HudAgent = Plugin.GameGui.FindAgentInterface(PartyListAddon); + + if (PartyListAddon == null || HudAgent == IntPtr.Zero) + { + if (_groupMembers.Count > 0) + { + _groupMembers.Clear(); + MembersChangedEvent?.Invoke(this); + } + + return; + } + + _raptureAtkModule = RaptureAtkModule.Instance(); + + // no need to update on preview mode + if (_config.Preview) + { + return; + } + + InternalUpdate(); + } + + private void InternalUpdate() + { + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + if (player is null || player is not IPlayerCharacter) + { + return; + } + + bool isCrossWorld = IsCrossWorldParty(); + + // ready check update + if (_iconsConfig.ReadyCheckStatus.Enabled) + { + _readyCheckHelper.Update(_iconsConfig.ReadyCheckStatus.Duration); + } + + try + { + // title + _partyTitle = GetPartyListTitle(); + + // solo + if (_realMemberCount <= 1 && PartyListAddon->TrustCount == 0) + { + if (_config.ShowWhenSolo) + { + UpdateSoloParty(player); + } + else if (_groupMembers.Count > 0) + { + _groupMembers.Clear(); + MembersChangedEvent?.Invoke(this); + } + + _wasRealGroup = false; + } + else + { + // player maps + Dictionary dataMap = GetMembersDataMap(player, isCrossWorld); + bool partyChanged = _prevDataMap.Count != dataMap.Count; + + if (!partyChanged) + { + foreach (string key in dataMap.Keys) + { + InternalMemberData newData = dataMap[key]; + if (!_prevDataMap.TryGetValue(key, out InternalMemberData? oldData) || + oldData == null || + newData.Order != oldData.Order) + { + partyChanged = true; + break; + } + } + } + + _prevDataMap = dataMap; + + // trust + if (PartyListAddon->TrustCount > 0) + { + UpdateTrustParty(player, dataMap, partyChanged); + } + // cross world party/alliance + else if (isCrossWorld) + { + UpdateCrossWorldParty(player, dataMap, partyChanged); + } + // regular party + else + { + UpdateRegularParty(player, dataMap, partyChanged); + } + + _wasRealGroup = true; + } + + UpdateTrackers(); + } + catch (Exception e) + { + Plugin.Logger.Warning(e.Message); + } + + _wasCrossWorld = isCrossWorld; + } + + private Dictionary GetMembersDataMap(IPlayerCharacter player, bool isCrossWorld) + { + Dictionary dataMap = new Dictionary(); + + if (_raptureAtkModule == null || _raptureAtkModule->AtkModule.AtkArrayDataHolder.StringArrayCount <= PartyMembersInfoIndex) + { + return dataMap; + } + + // raw info + int allianceNum = FindAlliance(player); + int count = isCrossWorld ? _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMemberCount : _realMemberCount + PartyListAddon->TrustCount; + for (int i = 0; i < count; i++) + { + InternalMemberData data = new InternalMemberData(); + data.Index = i; + + if (!isCrossWorld) + { + PartyListMemberRawInfo* info = (PartyListMemberRawInfo*)(HudAgent + (PartyListInfoOffset + PartyListMemberRawInfoSize * i)); + data.ObjectId = info->ObjectId; + data.ContentId = info->ContentId; + data.Name = info->Name; + data.Order = info->Order; + } + else + { + CrossRealmMember member = _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMembers[i]; + data.ObjectId = member.EntityId; + data.ContentId = (long)member.ContentId; + data.Name = member.NameString; + data.Order = i; + } + + if (!dataMap.ContainsKey(data.Name)) + { + dataMap.Add(data.Name, data); + } + } + + // status string + var stringArrayData = _raptureAtkModule->AtkModule.AtkArrayDataHolder.StringArrays[PartyMembersInfoIndex]; + for (int i = 0; i < count; i++) + { + int index = i * 5; + if (stringArrayData->AtkArrayData.Size <= index + 3 || + stringArrayData->StringArray[index] == null || + stringArrayData->StringArray[index + 3] == null) { break; } + + IntPtr ptr = new IntPtr(stringArrayData->StringArray[index]); + string name = MemoryHelper.ReadSeStringNullTerminated(ptr).ToString(); + + ptr = new IntPtr(stringArrayData->StringArray[index + 3]); + + string a = MemoryHelper.ReadSeStringNullTerminated(ptr).ToString(); + + + if (dataMap.TryGetValue(name, out InternalMemberData? data) && data != null) + { + data.Status = MemoryHelper.ReadSeStringNullTerminated(ptr).ToString(); + } + } + + return dataMap; + } + + private bool IsCrossWorldParty() + { + return _crossRealmInfo->IsCrossRealm && _crossRealmInfo->GroupCount > 0 && _mainGroup.MemberCount == 0; + } + + private ReadyCheckStatus GetReadyCheckStatus(ulong contentId) + { + return _readyCheckHelper.GetStatusForContentId(contentId); + } + + private void UpdateTrustParty(IPlayerCharacter player, Dictionary dataMap, bool forced) + { + bool softUpdate = true; + + if (_groupMembers.Count != dataMap.Count || forced) + { + _groupMembers.Clear(); + softUpdate = false; + } + + if (softUpdate) + { + foreach (IPartyFramesMember member in _groupMembers) + { + if (member.ObjectId == player.GameObjectId) + { + member.Update(EnmityForIndex(member.Index), PartyMemberStatus.None, ReadyCheckStatus.None, true, player.ClassJob.RowId); + } + else + { + member.Update(EnmityForTrustMemberIndex(member.Index - 1), PartyMemberStatus.None, ReadyCheckStatus.None, false, 0); + } + } + } + else + { + string[] keys = dataMap.Keys.ToArray(); + for (int i = 0; i < keys.Length; i++) + { + InternalMemberData data = dataMap[keys[i]]; + if (keys[i] == player.Name.ToString()) + { + PartyFramesMember playerMember = new PartyFramesMember(player, data.Index, data.Order, EnmityForIndex(i), PartyMemberStatus.None, ReadyCheckStatus.None, true); + _groupMembers.Add(playerMember); + } + else + { + ICharacter? trustChara = Utils.GetGameObjectByName(keys[i]) as ICharacter; + if (trustChara != null) + { + _groupMembers.Add(new PartyFramesMember(trustChara, data.Index, data.Order, EnmityForTrustMemberIndex(i), PartyMemberStatus.None, ReadyCheckStatus.None, false)); + } + } + } + } + + if (!softUpdate) + { + SortGroupMembers(player); + MembersChangedEvent?.Invoke(this); + } + } + + private void UpdateSoloParty(IPlayerCharacter player) + { + ICharacter? chocobo = null; + if (_config.ShowChocobo) + { + var gameObject = Utils.GetBattleChocobo(player); + if (gameObject != null && gameObject is ICharacter) + { + chocobo = (ICharacter)gameObject; + } + } + + bool needsUpdate = + _groupMembers.Count == 0 || + (_groupMembers.Count != 2 && _config.ShowChocobo && chocobo != null) || + (_groupMembers.Count > 1 && !_config.ShowChocobo) || + (_groupMembers.Count > 1 && chocobo == null) || + (_groupMembers.Count == 2 && _config.ShowChocobo && _groupMembers[1].ObjectId != chocobo?.EntityId); + + EnmityLevel playerEnmity = PartyListAddon->EnmityLeaderIndex == 0 ? EnmityLevel.Leader : EnmityLevel.Last; + + // for some reason chocobos never get a proper enmity value even though they have aggro + // if the player enmity is set to first, but the "leader index" is invalid + // we can pretty much deduce that the chocobo is the one with aggro + // this might fail on some cases when there are other players not in party hitting the same thing + // but the edge case is so minor we should be fine + EnmityLevel chocoboEnmity = PartyListAddon->EnmityLeaderIndex == -1 && PartyListAddon->PartyMembers[0].EmnityByte == 1 ? EnmityLevel.Leader : EnmityLevel.Last; + + if (needsUpdate) + { + _groupMembers.Clear(); + + _groupMembers.Add(new PartyFramesMember(player, 0, 0, playerEnmity, PartyMemberStatus.None, ReadyCheckStatus.None, true)); + + if (chocobo != null) + { + _groupMembers.Add(new PartyFramesMember(chocobo, 1, 1, chocoboEnmity, PartyMemberStatus.None, ReadyCheckStatus.None, false)); + } + + SortGroupMembers(player); + MembersChangedEvent?.Invoke(this); + } + else + { + for (int i = 0; i < _groupMembers.Count; i++) + { + _groupMembers[i].Update(i == 0 ? playerEnmity : chocoboEnmity, PartyMemberStatus.None, ReadyCheckStatus.None, i == 0, i == 0 ? player.ClassJob.RowId : 0); + } + } + } + + private void UpdateCrossWorldParty(IPlayerCharacter player, Dictionary dataMap, bool forced) + { + bool softUpdate = true; + int allianceNum = FindAlliance(player); + + int count = _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMemberCount; + if (!_wasCrossWorld || count != _groupMembers.Count || forced) + { + _groupMembers.Clear(); + softUpdate = false; + } + + // create new members array with cross world data + for (int i = 0; i < count; i++) + { + CrossRealmMember member = _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMembers[i]; + string memberName = member.NameString; + + if (!dataMap.TryGetValue(memberName, out InternalMemberData? data) || data == null) + { + continue; + } + + bool isPlayer = member.EntityId == player.EntityId; + bool isLeader = member.IsPartyLeader; + PartyMemberStatus status = data.Status != null ? StatusForCrossWorldMember(data.Status) : PartyMemberStatus.None; + ReadyCheckStatus readyCheckStatus = GetReadyCheckStatus(member.ContentId); + + if (softUpdate) + { + IPartyFramesMember groupMember = _groupMembers.ElementAt(i); + groupMember.Update(EnmityLevel.Last, status, readyCheckStatus, isLeader, member.ClassJobId); + } + else + { + PartyFramesMember partyMember = isPlayer ? + new PartyFramesMember(player, i, data.Order, EnmityLevel.Last, status, readyCheckStatus, isLeader) : + new PartyFramesMember(member, i, data.Order, status, readyCheckStatus, isLeader); + _groupMembers.Add(partyMember); + } + } + + if (!softUpdate) + { + SortGroupMembers(player); + MembersChangedEvent?.Invoke(this); + } + } + + private int FindAlliance(IPlayerCharacter player) + { + for (int i = 0; i < _crossRealmInfo->CrossRealmGroups.Length; i++) + { + for (int j = 0; j < _crossRealmInfo->CrossRealmGroups[i].GroupMemberCount; j++) + { + CrossRealmMember member = _crossRealmInfo->CrossRealmGroups[i].GroupMembers[j]; + if (member.EntityId == player.EntityId) + { + return i; + } + } + } + return 0; + } + + private void UpdateRegularParty(IPlayerCharacter player, Dictionary dataMap, bool forced) + { + bool softUpdate = true; + + if (!_wasRealGroup || _realMemberCount != _groupMembers.Count || forced) + { + _groupMembers.Clear(); + softUpdate = false; + } + + string[] keys = dataMap.Keys.ToArray(); + for (int i = 0; i < keys.Length; i++) + { + if (!dataMap.TryGetValue(keys[i], out InternalMemberData? data) || data == null) + { + continue; + } + + bool isPlayer = data.ObjectId == player.GameObjectId; + bool isLeader = IsPartyLeader(data.Order); + EnmityLevel enmity = EnmityForIndex(data.Index); + PartyMemberStatus status = data.Status != null ? StatusForMember(data.Status, data.Name) : PartyMemberStatus.None; + ReadyCheckStatus readyCheckStatus = GetReadyCheckStatus((ulong)data.ContentId); + + if (softUpdate) + { + IPartyFramesMember groupMember = _groupMembers.ElementAt(i); + groupMember.Update(enmity, status, readyCheckStatus, isLeader); + } + else + { + PartyFramesMember partyMember; + var member = GetDalamudPartyMember(data.Name); + if (member.HasValue && member.Value.Item1 is DalamudPartyMember dalamudPartyMember) + { + partyMember = new PartyFramesMember(dalamudPartyMember, i, data.Order, enmity, status, readyCheckStatus, isLeader); + } + else + { + partyMember = new PartyFramesMember(data.ObjectId, i, data.Order, enmity, status, readyCheckStatus, isLeader); + } + _groupMembers.Add(partyMember); + } + } + + // player's chocobo (always last) + if (_config.ShowChocobo) + { + IGameObject? companion = Utils.GetBattleChocobo(player); + + if (softUpdate && _groupMembers.FirstOrDefault(o => o.IsChocobo) is PartyFramesMember chocoboMember) + { + if (companion is ICharacter) + { + chocoboMember.Update(EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false); + } + else + { + _groupMembers.Remove(chocoboMember); + } + } + else if (companion is ICharacter companionCharacter) + { + _groupMembers.Add(new PartyFramesMember(companionCharacter, _groupMemberCount, 10, EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false, true)); + } + } + + if (!softUpdate) + { + SortGroupMembers(player); + MembersChangedEvent?.Invoke(this); + } + } + + + private void SortGroupMembers(IPlayerCharacter? player = null) + { + _sortedGroupMembers.Clear(); + _sortedGroupMembers.AddRange(_groupMembers); + + _sortedGroupMembers.Sort((a, b) => + { + if (a.Order == b.Order) + { + if (a.ObjectId == player?.GameObjectId) + { + return 1; + } + else if (b.ObjectId == player?.GameObjectId) + { + return -1; + } + + return a.Name.CompareTo(b.Name); + } + + if (a.Order < b.Order) + { + return -1; + } + + return 1; + }); + } + + private (DalamudPartyMember?, int)? GetDalamudPartyMember(string name) + { + for (int i = 0; i < Plugin.PartyList.Length; i++) + { + DalamudPartyMember? member = Plugin.PartyList[i]; + if (member != null && member.Name.ToString() == name) + { + return (member, i); + } + } + + return null; + } + + private void UpdateTrackers() + { + _raiseTracker.Update(_groupMembers); + _invulnTracker.Update(_groupMembers); + _cleanseTracker.Update(_groupMembers); + } + + #region utils + private bool IsPartyLeader(int index) + { + if (PartyListAddon == null) + { + return false; + } + + // we use the icon Y coordinate in the party list to know the index (lmao) + uint partyLeadIndex = (uint)PartyListAddon->LeaderMarkResNode->ChildNode->Y / 40; + return index == partyLeadIndex; + } + + private PartyMemberStatus StatusForCrossWorldMember(string statusStr) + { + // offline status + if (statusStr.Contains(_offlineString, StringComparison.InvariantCultureIgnoreCase)) + { + return PartyMemberStatus.Offline; + } + + return PartyMemberStatus.None; + } + + + private PartyMemberStatus StatusForMember(string statusStr, string name) + { + // offline status + if (statusStr.Contains(_offlineString, StringComparison.InvariantCultureIgnoreCase)) + { + return PartyMemberStatus.Offline; + } + + // viewing cutscene status + for (int i = 0; i < _mainGroup.MemberCount; i++) + { + if (_mainGroup.PartyMembers[i].NameString == name) + { + if ((_mainGroup.PartyMembers[i].Flags & 0x10) != 0) + { + return PartyMemberStatus.ViewingCutscene; + } + break; + } + } + + return PartyMemberStatus.None; + } + + private EnmityLevel EnmityForIndex(int index) + { + if (PartyListAddon == null || index < 0 || index > 7) + { + return EnmityLevel.Last; + } + + EnmityLevel enmityLevel = (EnmityLevel)PartyListAddon->PartyMembers[index].EmnityByte; + if (enmityLevel == EnmityLevel.Leader && PartyListAddon->EnmityLeaderIndex != index) + { + enmityLevel = EnmityLevel.Last; + } + + return enmityLevel; + } + + private EnmityLevel EnmityForTrustMemberIndex(int index) + { + if (PartyListAddon == null || index < 0 || index > 6) + { + return EnmityLevel.Last; + } + + return (EnmityLevel)PartyListAddon->TrustMembers[index].EmnityByte; + } + + private static unsafe string? GetPartyListTitle() + { + AgentModule* agentModule = AgentModule.Instance(); + if (agentModule == null) { return ""; } + + AgentHUD* agentHUD = agentModule->GetAgentHUD(); + if (agentHUD == null) { return ""; } + + Lumina.Excel.ExcelSheet sheet = Plugin.DataManager.GetExcelSheet(); + if (sheet.TryGetRow(agentHUD->PartyTitleAddonId, out Addon row)) + { + return row.Text.ToString(); + } + + return null; + } + #endregion + + #region events + private void OnConfigPropertyChanged(object sender, OnChangeBaseArgs args) + { + if (args.PropertyName == "Preview") + { + UpdatePreview(); + } + } + + public void UpdatePreview() + { + _iconsConfig.Sign.Preview = _config.Preview; + + if (!_config.Preview) + { + _groupMembers.Clear(); + MembersChangedEvent?.Invoke(this); + return; + } + + // fill list with fake members for UI testing + _groupMembers.Clear(); + + if (_config.Preview) + { + int count = FakePartyFramesMember.RNG.Next(4, 9); + for (int i = 0; i < count; i++) + { + _groupMembers.Add(new FakePartyFramesMember(i)); + } + } + + SortGroupMembers(); + MembersChangedEvent?.Invoke(this); + } + #endregion + } + + internal class InternalMemberData + { + internal uint ObjectId = 0; + internal long ContentId = 0; + internal string Name = ""; + internal uint JobId = 0; + internal int Order = 0; + internal int Index = 0; + internal string? Status = null; + + public InternalMemberData() + { + } + } + + [StructLayout(LayoutKind.Explicit, Size = 28)] + public unsafe struct PartyListMemberRawInfo + { + [FieldOffset(0x00)] public byte* NamePtr; + [FieldOffset(0x08)] public long ContentId; + [FieldOffset(0x10)] public uint ObjectId; + + // some kind of type + // 1 = player + // 2 = party member? + // 3 = unknown + // 4 = chocobo + // 5 = summon? + [FieldOffset(0x14)] public byte Type; + + [FieldOffset(0x18)] public byte Order; + + public string Name => Marshal.PtrToStringUTF8(new IntPtr(NamePtr)) ?? ""; + } +} diff --git a/Interface/Party/PartyOrderHelper.cs b/Interface/Party/PartyOrderHelper.cs new file mode 100644 index 0000000..6d619e1 --- /dev/null +++ b/Interface/Party/PartyOrderHelper.cs @@ -0,0 +1,171 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using HSUI.Helpers; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using System.Collections.Generic; + +namespace HSUI.Interface.Party +{ + public static class PartyOrderHelper + { + private enum PartySortingSetting + { + Tank_Healer_DPS = 0, + Tank_DPS_Healer = 1, + Healer_Tank_DPS = 2, + Healer_DPS_Tank = 3, + DPS_Tank_Healer = 4, + DPS_Healer_Tank = 5, + Count = 6 + } + + private class PartyRoles + { + internal int Tank; + internal int Healer; + internal int DPS; + internal int Other; + + public PartyRoles() + { + Tank = 0; + Healer = 0; + DPS = 0; + Other = 0; + } + + public PartyRoles(int tank, int healer, int dps, int other) + { + Tank = tank; + Healer = healer; + DPS = dps; + Other = other; + } + } + + // calcualates the position for the player if they select the + // option to always appear as the first of their current role + // in the party frames + public static int? GetRoleFirstOrder(List members) + { + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + if (player == null) { return null; } + + JobRoles role = JobsHelper.RoleForJob(player.ClassJob.RowId); + + PartySortingSetting? setting = GetPartySortingSetting(role); + if (!setting.HasValue) { return null; } + + PartyRoles rolesCount = GetPartyCountByRole(members); + PartyRoles roleWeights = GetRoleWeights(role, setting.Value); + + return rolesCount.Tank * roleWeights.Tank + + rolesCount.Healer * roleWeights.Healer + + rolesCount.DPS * roleWeights.DPS + + rolesCount.Other * roleWeights.Other; + } + + private static unsafe PartySortingSetting? GetPartySortingSetting(JobRoles role) + { + ConfigModule* config = ConfigModule.Instance(); + if (config == null) { return null; } + + ConfigOption option; + switch (role) + { + case JobRoles.Tank: option = ConfigOption.PartyListSortTypeTank; break; + + case JobRoles.Healer: option = ConfigOption.PartyListSortTypeHealer; break; + + case JobRoles.DPSMelee: + case JobRoles.DPSRanged: + case JobRoles.DPSCaster: option = ConfigOption.PartyListSortTypeDps; break; + + default: option = ConfigOption.PartyListSortTypeOther; break; + } + + Framework* framework = Framework.Instance(); + if (framework == null || framework->SystemConfig.SystemConfigBase.UiConfig.ConfigCount <= (int)option) { + return PartySortingSetting.Tank_Healer_DPS; + } + + uint value = framework->SystemConfig.SystemConfigBase.UiConfig.ConfigEntry[(int)option].Value.UInt; + if (value < 0 || value > (int)PartySortingSetting.Count) { return null; } + + return (PartySortingSetting)value; + } + + private static unsafe PartyRoles GetPartyCountByRole(List members) + { + PartyRoles rolesCount = new PartyRoles(); + + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + if (player == null) { return rolesCount; } + + foreach (IPartyFramesMember member in members) + { + if (member.ObjectId == player.GameObjectId) { continue; } + + JobRoles role = JobsHelper.RoleForJob(member.JobId); + switch (role) + { + case JobRoles.Tank: rolesCount.Tank++; break; + + case JobRoles.Healer: rolesCount.Healer++; break; + + case JobRoles.DPSMelee: + case JobRoles.DPSRanged: + case JobRoles.DPSCaster: rolesCount.DPS++; break; + + default: rolesCount.Other++; break; + } + } + + return rolesCount; + } + + private static unsafe PartyRoles GetRoleWeights(JobRoles role, PartySortingSetting setting) + { + if (role == JobRoles.Crafter || role == JobRoles.Gatherer || role == JobRoles.Unknown) + { + return new PartyRoles(1, 1, 1, 0); + } + + JobRoles mapRole = role == JobRoles.DPSRanged || role == JobRoles.DPSCaster ? JobRoles.DPSMelee : role; + return RoleWeights[mapRole][setting]; + } + + private static Dictionary> RoleWeights = new Dictionary>() + { + [JobRoles.Tank] = new Dictionary() + { + [PartySortingSetting.Tank_Healer_DPS] = new PartyRoles(), + [PartySortingSetting.Tank_DPS_Healer] = new PartyRoles(), + [PartySortingSetting.Healer_Tank_DPS] = new PartyRoles(0, 1, 0, 0), + [PartySortingSetting.Healer_DPS_Tank] = new PartyRoles(0, 1, 1, 0), + [PartySortingSetting.DPS_Tank_Healer] = new PartyRoles(0, 0, 1, 0), + [PartySortingSetting.DPS_Healer_Tank] = new PartyRoles(0, 1, 1, 0) + }, + + [JobRoles.Healer] = new Dictionary() + { + [PartySortingSetting.Tank_Healer_DPS] = new PartyRoles(1, 0, 0, 0), + [PartySortingSetting.Tank_DPS_Healer] = new PartyRoles(1, 0, 1, 0), + [PartySortingSetting.Healer_Tank_DPS] = new PartyRoles(), + [PartySortingSetting.Healer_DPS_Tank] = new PartyRoles(), + [PartySortingSetting.DPS_Tank_Healer] = new PartyRoles(1, 0, 1, 0), + [PartySortingSetting.DPS_Healer_Tank] = new PartyRoles(0, 0, 1, 0) + }, + + [JobRoles.DPSMelee] = new Dictionary() + { + [PartySortingSetting.Tank_Healer_DPS] = new PartyRoles(1, 1, 0, 0), + [PartySortingSetting.Tank_DPS_Healer] = new PartyRoles(1, 0, 0, 0), + [PartySortingSetting.Healer_Tank_DPS] = new PartyRoles(1, 1, 0, 0), + [PartySortingSetting.Healer_DPS_Tank] = new PartyRoles(0, 1, 0, 0), + [PartySortingSetting.DPS_Tank_Healer] = new PartyRoles(), + [PartySortingSetting.DPS_Healer_Tank] = new PartyRoles() + } + }; + } +} diff --git a/Interface/Party/PartyReadyCheckHelper.cs b/Interface/Party/PartyReadyCheckHelper.cs new file mode 100644 index 0000000..3ed4f4b --- /dev/null +++ b/Interface/Party/PartyReadyCheckHelper.cs @@ -0,0 +1,156 @@ +using Dalamud.Hooking; +using Dalamud.Logging; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Info; +using Dalamud.Bindings.ImGui; +using System; + +namespace HSUI.Interface.Party +{ + public enum ReadyCheckStatus + { + Ready = 0, + NotReady = 1, + None = 2 + } + + public class PartyReadyCheckHelper : IDisposable + { + private delegate void ReadyCheckDelegate(IntPtr ptr); + private Hook? _onReadyCheckStartHook; + private Hook? _onReadyCheckEndHook; + + private delegate void ActorControlDelegate(uint entityId, uint type, uint buffID, uint direct, uint actionId, uint sourceId, uint arg7, uint arg8, uint arg9, uint arg10, ulong targetId, byte arg12); + private Hook? _actorControlHook; + + private bool _readyCheckOngoing = false; + private double _lastReadyCheckEndTime = -1; + + + public unsafe PartyReadyCheckHelper() + { + try + { + _onReadyCheckStartHook = Plugin.GameInteropProvider.HookFromAddress( + AgentReadyCheck.MemberFunctionPointers.InitiateReadyCheck, + OnReadyCheckStart + ); + _onReadyCheckStartHook?.Enable(); + + _onReadyCheckEndHook = Plugin.GameInteropProvider.HookFromAddress( + AgentReadyCheck.MemberFunctionPointers.EndReadyCheck, + OnReadycheckEnd + ); + _onReadyCheckEndHook?.Enable(); + + _actorControlHook = Plugin.GameInteropProvider.HookFromSignature( + "E8 ?? ?? ?? ?? 0F B7 0B 83 E9 64", + OnActorControl + ); + _actorControlHook?.Enable(); + } + catch (Exception e) + { + Plugin.Logger.Error("Error initiating ready check sigs!!!\n" + e.Message); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _onReadyCheckStartHook?.Disable(); + _onReadyCheckStartHook?.Dispose(); + + _onReadyCheckEndHook?.Disable(); + _onReadyCheckEndHook?.Dispose(); + + _actorControlHook?.Disable(); + _actorControlHook?.Dispose(); + } + + private unsafe void OnReadyCheckStart(AgentReadyCheck *ptr) + { + _onReadyCheckStartHook?.Original(ptr); + _readyCheckOngoing = true; + _lastReadyCheckEndTime = -1; + } + + private unsafe void OnReadycheckEnd(AgentReadyCheck *ptr) + { + _onReadyCheckEndHook?.Original(ptr); + _lastReadyCheckEndTime = ImGui.GetTime(); + } + + private void OnActorControl(uint entityId, uint type, uint buffID, uint direct, uint actionId, uint sourceId, uint arg7, uint arg8, uint arg9, uint arg10, ulong targetId, byte arg12) + { + _actorControlHook?.Original(entityId, type, buffID, direct, actionId, sourceId, arg7, arg8, arg9, arg10, targetId, arg12); + + // I'm not exactly sure what id == 503 means, but its always triggered when the fight starts + // which is all I care about + if (type == 503) + { + _readyCheckOngoing = false; + } + } + + public void Update(double maxDuration) + { + if (_readyCheckOngoing && + _lastReadyCheckEndTime != -1 && + ImGui.GetTime() - _lastReadyCheckEndTime >= maxDuration) + { + _readyCheckOngoing = false; + } + } + + public unsafe ReadyCheckStatus GetStatusForContentId(ulong contentId) + { + if (!_readyCheckOngoing) + { + return ReadyCheckStatus.None; + } + + try + { + for (int i = 0; i < 8; i++) + { + ReadyCheckEntry entry = AgentReadyCheck.Instance()->ReadyCheckEntries[i]; + if (entry.ContentId == contentId) + { + return ParseStatus(entry); + } + } + } + catch { } + + return ReadyCheckStatus.None; + } + + private ReadyCheckStatus ParseStatus(ReadyCheckEntry entry) + { + if (entry.Status == FFXIVClientStructs.FFXIV.Client.UI.Agent.ReadyCheckStatus.Ready) + { + return ReadyCheckStatus.Ready; + } + else if (entry.Status == FFXIVClientStructs.FFXIV.Client.UI.Agent.ReadyCheckStatus.NotReady || + entry.Status == FFXIVClientStructs.FFXIV.Client.UI.Agent.ReadyCheckStatus.MemberNotPresent) + { + return ReadyCheckStatus.NotReady; + } + + return ReadyCheckStatus.None; + } + } +} + diff --git a/Interface/PartyCooldowns/PartyCooldown.cs b/Interface/PartyCooldowns/PartyCooldown.cs new file mode 100644 index 0000000..b299388 --- /dev/null +++ b/Interface/PartyCooldowns/PartyCooldown.cs @@ -0,0 +1,220 @@ +using HSUI.Helpers; +using HSUI.Interface.Party; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + +namespace HSUI.Interface.PartyCooldowns +{ + public enum PartyCooldownEnabled + { + PartyCooldownsAndPartyFrames = 0, + PartyCooldowns = 1, + PartyFrames = 2, + Disabled = 3 + } + + public class PartyCooldown + { + public readonly PartyCooldownData Data; + public readonly uint SourceId; + public readonly uint MemberLevel; + public readonly IPartyFramesMember? Member; + + public double LastTimeUsed = 0; + public double OverridenCooldownStartTime = -1; + public bool IgnoreNextUse = false; + + public PartyCooldown(PartyCooldownData data, uint sourceID, uint level, IPartyFramesMember? member) + { + Data = data; + SourceId = sourceID; + MemberLevel = level; + Member = member; + } + + public float EffectTimeRemaining() + { + int duration = GetEffectDuration(); + double timeSinceUse = ImGui.GetTime() - LastTimeUsed; + if (IgnoreNextUse || timeSinceUse > duration) + { + return 0; + } + + return duration - (float)timeSinceUse; + } + + public float CooldownTimeRemaining() + { + float cooldown = GetCooldown(); + double timeSinceUse = OverridenCooldownStartTime != -1 ? ImGui.GetTime() - OverridenCooldownStartTime : ImGui.GetTime() - LastTimeUsed; + + if (timeSinceUse > cooldown) + { + OverridenCooldownStartTime = -1; + LastTimeUsed = 0; + return 0; + } + + return cooldown - (float)timeSinceUse; + } + + public int GetCooldown() + { + // special case for troubadour, shield samba and tactician + if (Data.ActionId == 7405 || Data.ActionId == 16012 || Data.ActionId == 16889) + { + return MemberLevel < 88 ? Data.CooldownDuration : 90; + } + // special case for swiftcast + else if (Data.ActionId == 7561) + { + return MemberLevel < 94 ? Data.CooldownDuration : 40; + } + + return Data.CooldownDuration; + } + + public int GetEffectDuration() + { + // special case for reprisal, feint and addle + if (MemberLevel < 98) { return Data.EffectDuration; } + if (Data.ActionId != 7535 && Data.ActionId != 7549 && Data.ActionId != 7560) { return Data.EffectDuration; } + + return 15; + } + + public string TooltipText() + { + int duration = GetEffectDuration(); + string effectDuration = duration > 0 ? $"Duration: {duration}s \n" : ""; + return $"{effectDuration}Recast Time: {GetCooldown()}s"; + } + } + + public class PartyCooldownData : IEquatable + { + public PartyCooldownEnabled EnabledV2 = PartyCooldownEnabled.PartyCooldownsAndPartyFrames; + + public uint ActionId = 0; + public uint RequiredLevel = 0; + public uint DisabledAfterLevel = 0; + + public uint JobId = 0; // keep this for backwards compatibility + public List? JobIds = null; + + public JobRoles Role = JobRoles.Unknown; // keep this for backwards compatibility + public List? Roles = null; + + public HashSet ExcludedJobIds = new(); + + public int CooldownDuration = 0; + public int EffectDuration = 0; + + public int Priority = 0; + public int Column = 1; + + public List SharedActionIds = new(); + + [JsonIgnore] public uint IconId = 0; + [JsonIgnore] public string Name = ""; + [JsonIgnore] public string? OverriddenCooldownText = null; + [JsonIgnore] public string? OverriddenDurationText = null; + + public virtual bool IsUsableBy(uint jobId) + { + JobRoles roleForJob = JobsHelper.RoleForJob(jobId); + + if (Roles != null) + { + foreach (JobRoles role in Roles) + { + if (role == roleForJob) + { + return true; + } + } + + return false; + } + + if (Role != JobRoles.Unknown) + { + return Role == roleForJob; + } + + if (JobIds != null) + { + foreach (uint id in JobIds) + { + if (id == jobId) + { + return true; + } + } + } + + return JobId == jobId; + } + + public bool HasRole(JobRoles role) + { + if (Roles != null) + { + return Roles.Contains(role); + } + + if (Role != JobRoles.Unknown) + { + return Role == role; + } + + if (JobIds != null) + { + foreach (uint jobId in JobIds) + { + JobRoles roleForJob = JobsHelper.RoleForJob(jobId); + if (roleForJob == role) + { + return true; + } + } + + return false; + } + + return JobsHelper.RoleForJob(JobId) == role; + } + + public bool IsEnabledForPartyCooldowns() + { + return EnabledV2 == PartyCooldownEnabled.PartyCooldownsAndPartyFrames || + EnabledV2 == PartyCooldownEnabled.PartyCooldowns; + } + + public bool IsEnabledForPartyFrames() + { + return EnabledV2 == PartyCooldownEnabled.PartyCooldownsAndPartyFrames || + EnabledV2 == PartyCooldownEnabled.PartyFrames; + } + + public bool Equals(PartyCooldownData? other) + { + if (other == null) { return false; } + + return + ActionId == other.ActionId && + RequiredLevel == other.RequiredLevel && + DisabledAfterLevel == other.DisabledAfterLevel && + JobId == other.JobId && + (JobIds == null && other.JobIds == null || (JobIds != null && other.JobIds != null && JobIds.Equals(other.JobIds))) && + Role == other.Role && + (Roles == null && other.Roles == null || (Roles != null && other.Roles != null && Roles.Equals(other.Roles))) && + CooldownDuration == other.CooldownDuration && + EffectDuration == other.EffectDuration && + SharedActionIds == other.SharedActionIds; + } + } +} diff --git a/Interface/PartyCooldowns/PartyCooldownsConfig.cs b/Interface/PartyCooldowns/PartyCooldownsConfig.cs new file mode 100644 index 0000000..df1bafb --- /dev/null +++ b/Interface/PartyCooldowns/PartyCooldownsConfig.cs @@ -0,0 +1,884 @@ +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using LuminaAction = Lumina.Excel.Sheets.Action; + +namespace HSUI.Interface.PartyCooldowns +{ + public enum PartyCooldownsGrowthDirection + { + Down = 0, + Up, + Right, + Left + } + + [Exportable(false)] + [Section("Party Cooldowns", true)] + [SubSection("General", 0)] + public class PartyCooldownsConfig : MovablePluginConfigObject + { + public new static PartyCooldownsConfig DefaultConfig() + { + var config = new PartyCooldownsConfig(); + config.Position = new Vector2(-ImGui.GetMainViewport().Size.X / 2 + 100, -ImGui.GetMainViewport().Size.Y / 2 + 100); + + return config; + } + + [Checkbox("Preview", isMonitored = true)] + [Order(4)] + public bool Preview = false; + + [Combo("Sections Growth Direction", "Down", "Up", "Right", "Left", spacing = true)] + [Order(20)] + public PartyCooldownsGrowthDirection GrowthDirection = PartyCooldownsGrowthDirection.Down; + + [DragInt2("Padding", min = -1000, max = 1000)] + [Order(15)] + public Vector2 Padding = new Vector2(0, -1); + + [Checkbox("Tooltips", spacing = true)] + [Order(16)] + public bool ShowTooltips = true; + + [Checkbox("Show Only in Duties", spacing = true, isMonitored = true)] + [Order(20)] + public bool ShowOnlyInDuties = true; + + [Checkbox("Show When Solo", isMonitored = true)] + [Order(21)] + public bool ShowWhenSolo = false; + + [NestedConfig("Visibility", 200)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + } + + [Disableable(false)] + [Exportable(false)] + [DisableParentSettings("Position", "Anchor", "HideWhenInactive", "FillColor", "Background", "FillDirection")] + [Section("Party Cooldowns", true)] + [SubSection("Cooldown Bar", 0)] + public class PartyCooldownsBarConfig : BarConfig + { + [Checkbox("Show Bar", spacing = true)] + [Order(70)] + public bool ShowBar = true; + + [ColorEdit4("Available Color")] + [Order(71, collapseWith = nameof(ShowBar))] + public PluginConfigColor AvailableColor = new PluginConfigColor(new Vector4(0f / 255f, 150f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Available Background Color")] + [Order(72, collapseWith = nameof(ShowBar))] + public PluginConfigColor AvailableBackgroundColor = new PluginConfigColor(new Vector4(0f / 255f, 150f / 255f, 0f / 255f, 25f / 100f)); + + [ColorEdit4("Recharging Color")] + [Order(73, collapseWith = nameof(ShowBar))] + public PluginConfigColor RechargingColor = new PluginConfigColor(new Vector4(150f / 255f, 0f / 255f, 0f / 255f, 100f / 100f)); + + [ColorEdit4("Recharging Background Color")] + [Order(74, collapseWith = nameof(ShowBar))] + public PluginConfigColor RechargingBackgroundColor = new PluginConfigColor(new Vector4(150f / 255f, 0f / 255f, 0f / 255f, 25f / 100f)); + + [Checkbox("Use Job Colors")] + [Order(75, collapseWith = nameof(ShowBar))] + public bool UseJobColors = false; + + [Checkbox("Show Icon", spacing = true)] + [Order(80)] + public bool ShowIcon = true; + + [Checkbox("Show Icon Cooldown Animation")] + [Order(81, collapseWith = nameof(ShowIcon))] + public bool ShowIconCooldownAnimation = false; + + [Checkbox("Change Icon Border When Active")] + [Order(82, collapseWith = nameof(ShowIcon))] + public bool ChangeIconBorderWhenActive = false; + + [ColorEdit4("Icon Active Border Color")] + [Order(83, collapseWith = nameof(ChangeIconBorderWhenActive))] + public PluginConfigColor IconActiveBorderColor = new PluginConfigColor(new Vector4(255f / 255f, 200f / 255f, 35f / 255f, 100f / 100f)); + + [DragInt("Icon Active Border Thickness", min = 1, max = 10)] + [Order(84, collapseWith = nameof(ChangeIconBorderWhenActive))] + public int IconActiveBorderThickness = 3; + + [Checkbox("Change Labels Color When Active", spacing = true)] + [Order(85)] + public bool ChangeLabelsColorWhenActive = false; + + [ColorEdit4("Labels Active Color")] + [Order(86, collapseWith = nameof(ChangeLabelsColorWhenActive))] + public PluginConfigColor LabelsActiveColor = new PluginConfigColor(new Vector4(255f / 255f, 200f / 255f, 35f / 255f, 100f / 100f)); + + [NestedConfig("Name Label", 100)] + public EditableLabelConfig NameLabel = new EditableLabelConfig(new Vector2(5, 0), "[name:initials]", DrawAnchor.Left, DrawAnchor.Left); + + [NestedConfig("Time Label", 105)] + public PartyCooldownTimeLabelConfig TimeLabel = new PartyCooldownTimeLabelConfig(new Vector2(-5, 0), "", DrawAnchor.Right, DrawAnchor.Right); + + public new static PartyCooldownsBarConfig DefaultConfig() + { + Vector2 size = new Vector2(150, 24); + + PartyCooldownsBarConfig config = new PartyCooldownsBarConfig(Vector2.Zero, size, PluginConfigColor.Empty); + + config.NameLabel.FontID = FontsConfig.DefaultMediumFontKey; + config.TimeLabel.FontID = FontsConfig.DefaultMediumFontKey; + config.TimeLabel.NumberFormat = 1; + + return config; + } + + public PartyCooldownsBarConfig(Vector2 position, Vector2 size, PluginConfigColor fillColor, BarDirection fillDirection = BarDirection.Right) + : base(position, size, fillColor, fillDirection) + { + } + } + + public class PartyCooldownTimeLabelConfig : NumericLabelConfig + { + public PartyCooldownTimeLabelConfig(Vector2 position, string text, DrawAnchor frameAnchor, DrawAnchor textAnchor) + : base(position, text, frameAnchor, textAnchor) + { + } + + [Checkbox("Show Effect Duration", spacing = true)] + [Order(70)] + public bool ShowEffectDuration = true; + + [Checkbox("Show Remainin Cooldown")] + [Order(71)] + public bool ShowRemainingCooldown = true; + } + + [Exportable(false)] + [Disableable(false)] + [Section("Party Cooldowns", true)] + [SubSection("Cooldowns Tracked", 0)] + public class PartyCooldownsDataConfig : PluginConfigObject + { + public List Cooldowns = new List(); + + private List _removedIds = new() { 7398 }; + + private JobRoles _roleFilter = JobRoles.Unknown; + private uint _tankFilter = 0; + private uint _healerFilter = 0; + private uint _meleeFilter = 0; + private uint _rangedFilter = 0; + private uint _casterFilter = 0; + + private bool _needsPopupOpen = false; + private PartyCooldownData? _popupCooldown = null; + + public const int ColumnCount = 5; + + public delegate void CooldownsDataChangedEventHandler(PartyCooldownsDataConfig sender); + public event CooldownsDataChangedEventHandler? CooldownsDataChangedEvent; + + public delegate void CooldownsDataEnabledChangedEventHandler(PartyCooldownsDataConfig sender); + public event CooldownsDataEnabledChangedEventHandler? CooldownsDataEnabledChangedEvent; + + public new static PartyCooldownsDataConfig DefaultConfig() => new PartyCooldownsDataConfig(); + + private string[] _enabledOptions = new string[] { + "Enabled", + "Party Cooldowns Only", + "Party Frames Only", + "Disabled" + }; + + + public void UpdateDataIfNeeded() + { + bool needsSave = false; + + // remove old cooldowns that are not valid anymore + foreach (uint id in _removedIds) + { + PartyCooldownData? data = Cooldowns.FirstOrDefault(data => data.ActionId == id); + if (data != null) + { + Cooldowns.Remove(data); + } + } + + // update data using the game files + foreach (uint key in DefaultCooldowns.Keys) + { + PartyCooldownData? data = Cooldowns.FirstOrDefault(data => data.ActionId == key); + PartyCooldownData defaultData = DefaultCooldowns[key]; + + if (data == null) + { + Cooldowns.Add(defaultData); + needsSave = true; + } + else if (data != null && !data.Equals(defaultData)) + { + data.RequiredLevel = defaultData.RequiredLevel; + data.DisabledAfterLevel = defaultData.DisabledAfterLevel; + data.JobId = defaultData.JobId; + data.JobIds = defaultData.JobIds; + data.Role = defaultData.Role; + data.Roles = defaultData.Roles; + data.CooldownDuration = defaultData.CooldownDuration; + data.EffectDuration = defaultData.EffectDuration; + data.SharedActionIds = defaultData.SharedActionIds; + + needsSave = true; + } + } + + ExcelSheet? sheet = Plugin.DataManager.GetExcelSheet(); + ExcelSheet? descriptionsSheet = Plugin.DataManager.GetExcelSheet(); + + foreach (PartyCooldownData cooldown in Cooldowns) + { + LuminaAction? action = sheet?.GetRow(cooldown.ActionId); + if (!action.HasValue) { continue; } + + // get real cooldown from data + // keep hardcoded value for technical finish + if (action.Value.Recast100ms > 0 && cooldown.ActionId != 16004) + { + cooldown.CooldownDuration = action.Value.Recast100ms / 10; + } + + // not happy about this but didn't want to over-complicate things + // special case for troubadour, shield samba and tactician + if (cooldown.ActionId == 7405 || cooldown.ActionId == 16012 || cooldown.ActionId == 16889) + { + cooldown.OverriddenCooldownText = "90-120"; + } + + // reprisal, feint, addle + else if (cooldown.ActionId == 7535 || cooldown.ActionId == 7549 || cooldown.ActionId == 7560) + { + cooldown.OverriddenDurationText = "10-15"; + } + + // swiftcast + else if (cooldown.ActionId == 7561) + { + cooldown.OverriddenCooldownText = "40-60"; + } + + // special case for starry muse + if (cooldown.ActionId == 35349) + { + cooldown.IconId = 3826; + } + else + { + cooldown.IconId = action.Value.Icon; + } + + cooldown.Name = action.Value.Name.ToString(); + } + + if (needsSave) + { + ConfigurationManager.Instance.SaveConfigurations(true); + } + } + + [ManualDraw] + public bool Draw(ref bool changed) + { + ImGuiHelper.NewLineAndTab(); + + // filter + ImGui.SameLine(); + ImGui.Text("Filter: "); + + DrawFilter("All", JobRoles.Unknown); + DrawFilter("Tanks", JobRoles.Tank); + DrawFilter("Healers", JobRoles.Healer); + DrawFilter("Melee", JobRoles.DPSMelee); + DrawFilter("Ranged", JobRoles.DPSRanged); + DrawFilter("Casters", JobRoles.DPSCaster); + + DrawJobFilters(); + + // table + ImGuiHelper.NewLineAndTab(); + ImGui.SameLine(); + + var flags = + ImGuiTableFlags.RowBg | + ImGuiTableFlags.Borders | + ImGuiTableFlags.BordersOuter | + ImGuiTableFlags.BordersInner | + ImGuiTableFlags.ScrollY | + ImGuiTableFlags.SizingFixedSame; + + ExcelSheet? sheet = Plugin.DataManager.GetExcelSheet(); + var iconSize = new Vector2(30, 30); + + if (ImGui.BeginTable("##DelvUI_PartyCooldownsTable", 8, flags, new Vector2(900, 500))) + { + ImGui.TableSetupColumn("Enabled", ImGuiTableColumnFlags.WidthStretch, 22, 0); + ImGui.TableSetupColumn("Icon", ImGuiTableColumnFlags.WidthStretch, 5, 1); + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch, 24, 2); + ImGui.TableSetupColumn("Cooldown", ImGuiTableColumnFlags.WidthStretch, 8, 3); + ImGui.TableSetupColumn("Duration", ImGuiTableColumnFlags.WidthStretch, 8, 4); + ImGui.TableSetupColumn("Priority", ImGuiTableColumnFlags.WidthStretch, 11, 5); + ImGui.TableSetupColumn("Section", ImGuiTableColumnFlags.WidthStretch, 11, 6); + ImGui.TableSetupColumn("Exclude Jobs", ImGuiTableColumnFlags.WidthStretch, 11, 7); + + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + + foreach (PartyCooldownData cooldown in Cooldowns) + { + // apply filter + if (_roleFilter != JobRoles.Unknown) + { + if (!cooldown.HasRole(_roleFilter)) { continue; } + + if (_roleFilter == JobRoles.Tank && _tankFilter != 0 && !cooldown.IsUsableBy(_tankFilter)) { continue; } + if (_roleFilter == JobRoles.Healer && _healerFilter != 0 && !cooldown.IsUsableBy(_healerFilter)) { continue; } + if (_roleFilter == JobRoles.DPSMelee && _meleeFilter != 0 && !cooldown.IsUsableBy(_meleeFilter)) { continue; } + if (_roleFilter == JobRoles.DPSRanged && _rangedFilter != 0 && !cooldown.IsUsableBy(_rangedFilter)) { continue; } + if (_roleFilter == JobRoles.DPSCaster && _casterFilter != 0 && !cooldown.IsUsableBy(_casterFilter)) { continue; } + } + + LuminaAction? action = sheet?.GetRow(cooldown.ActionId); + + ImGui.PushID(cooldown.ActionId.ToString()); + ImGui.TableNextRow(ImGuiTableRowFlags.None, iconSize.Y); + + // enabled + if (ImGui.TableSetColumnIndex(0)) + { + ImGui.PushItemWidth(178); + ImGui.SetCursorPos(new Vector2(ImGui.GetCursorPosX() + 2, ImGui.GetCursorPosY() + 2)); + int enabled = (int)cooldown.EnabledV2; + if (ImGui.Combo($"##{cooldown.ActionId}_enabled", ref enabled, _enabledOptions)) + { + changed = true; + cooldown.EnabledV2 = (PartyCooldownEnabled)enabled; + CooldownsDataEnabledChangedEvent?.Invoke(this); + } + } + + // icon + if (ImGui.TableSetColumnIndex(1) && action != null) + { + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 3); + DrawHelper.DrawIcon(action, ImGui.GetCursorPos(), iconSize, false, false); + } + + // name + if (ImGui.TableSetColumnIndex(2)) + { + ImGui.Text(cooldown.Name); + } + + // cooldown + if (ImGui.TableSetColumnIndex(3)) + { + string cooldownText = cooldown.OverriddenCooldownText != null ? cooldown.OverriddenCooldownText : $"{cooldown.CooldownDuration}"; + ImGui.Text(cooldownText); + } + + // duration + if (ImGui.TableSetColumnIndex(4)) + { + string durationText = cooldown.OverriddenDurationText != null ? cooldown.OverriddenDurationText : $"{cooldown.EffectDuration}"; + ImGui.Text(durationText); + } + + // priority + if (ImGui.TableSetColumnIndex(5)) + { + ImGui.PushItemWidth(86); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 2); + + if (ImGui.DragInt($"##{cooldown.ActionId}_priority", ref cooldown.Priority, 1, 0, 100)) + { + cooldown.Priority = Math.Clamp(cooldown.Priority, 0, 100); + changed = true; + CooldownsDataChangedEvent?.Invoke(this); + } + + ImGuiHelper.SetTooltip("Priority determines which cooldows show first on the list."); + } + + // column + if (ImGui.TableSetColumnIndex(6)) + { + ImGui.PushItemWidth(86); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 2); + + if (ImGui.DragInt($"##{cooldown.ActionId}_column", ref cooldown.Column, 0.1f, 1, ColumnCount, "%i", ImGuiSliderFlags.NoInput)) + { + changed = true; + CooldownsDataChangedEvent?.Invoke(this); + } + + ImGuiHelper.SetTooltip("Allows to separate cooldowns in different columns."); + } + + // exlude + if (ImGui.TableSetColumnIndex(7) && (cooldown.Roles != null || cooldown.Role != JobRoles.Unknown)) + { + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 2); + + if (ImGui.Button("Select##Exclude", new Vector2(86, 24))) + { + _needsPopupOpen = true; + _popupCooldown = cooldown; + } + } + + ImGui.PopID(); + } + + ImGui.EndTable(); + } + + if (_needsPopupOpen) + { + ImGui.OpenPopup("Exclude Jobs##HSUI"); + _needsPopupOpen = false; + } + + if (_popupCooldown != null) + { + DrawJobsListForCooldown(_popupCooldown); + } + + return false; + } + + private List IgnoredJobIds = new List() + { + JobIDs.GLA, JobIDs.MRD, + JobIDs.CNJ, JobIDs.WHM, + JobIDs.PGL, JobIDs.LNC, JobIDs.ROG, + JobIDs.ARC, + JobIDs.THM, JobIDs.ACN, JobIDs.BLU, + }; + + private void DrawJobsListForCooldown(PartyCooldownData cooldown) + { + List roles = cooldown.Roles ?? new List() { cooldown.Role }; + + List jobIds = new List(); + foreach (JobRoles role in roles) + { + jobIds.AddRange(JobsHelper.JobsByRole[role]); + } + jobIds = jobIds.Where(id => !IgnoredJobIds.Contains(id)).ToList(); + + string title = cooldown.Name + ":"; + float width = Math.Max(100, ImGui.CalcTextSize(title).X + 10); + ImGui.SetNextWindowSize(new(width, jobIds.Count * 30 + 32)); + + if (ImGui.BeginPopup("Exclude Jobs##HSUI", ImGuiWindowFlags.NoMove)) + { + ImGui.Text(title); + + foreach (uint jobId in jobIds) + { + bool selected = !cooldown.ExcludedJobIds.Contains(jobId); + if (ImGui.Checkbox(JobsHelper.JobNames[jobId] + "##popup", ref selected)) + { + if (selected) + { + cooldown.ExcludedJobIds.Remove(jobId); + } + else + { + cooldown.ExcludedJobIds.Add(jobId); + } + + CooldownsDataEnabledChangedEvent?.Invoke(this); + } + } + + ImGui.EndPopup(); + } + } + + private void DrawFilter(string name, JobRoles role) + { + ImGui.SameLine(); + if (ImGui.RadioButton(name, _roleFilter == role)) + { + _roleFilter = role; + } + } + + private void DrawJobFilter(string name, uint job, ref uint filter) + { + ImGui.SameLine(); + if (ImGui.RadioButton(name, filter == job)) + { + filter = job; + } + } + + private void DrawJobFilters() + { + if (_roleFilter == JobRoles.Unknown) { return; } + + ImGui.Text("\t\t\t\t "); + + if (_roleFilter == JobRoles.Tank) + { + DrawJobFilter("All##tank", 0, ref _tankFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.PLD], JobIDs.PLD, ref _tankFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.WAR], JobIDs.WAR, ref _tankFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.DRK], JobIDs.DRK, ref _tankFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.GNB], JobIDs.GNB, ref _tankFilter); + } + else if (_roleFilter == JobRoles.Healer) + { + DrawJobFilter("All##healer", 0, ref _healerFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.WHM], JobIDs.WHM, ref _healerFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.SCH], JobIDs.SCH, ref _healerFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.AST], JobIDs.AST, ref _healerFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.SGE], JobIDs.SGE, ref _healerFilter); + } + else if (_roleFilter == JobRoles.DPSMelee) + { + DrawJobFilter("All##melee", 0, ref _meleeFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.MNK], JobIDs.MNK, ref _meleeFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.DRG], JobIDs.DRG, ref _meleeFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.NIN], JobIDs.NIN, ref _meleeFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.SAM], JobIDs.SAM, ref _meleeFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.RPR], JobIDs.RPR, ref _meleeFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.VPR], JobIDs.VPR, ref _meleeFilter); + } + else if (_roleFilter == JobRoles.DPSRanged) + { + DrawJobFilter("All##ranged", 0, ref _rangedFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.BRD], JobIDs.BRD, ref _rangedFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.MCH], JobIDs.MCH, ref _rangedFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.DNC], JobIDs.DNC, ref _rangedFilter); + } + else if (_roleFilter == JobRoles.DPSCaster) + { + DrawJobFilter("All##caster", 0, ref _casterFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.BLM], JobIDs.BLM, ref _casterFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.SMN], JobIDs.SMN, ref _casterFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.RDM], JobIDs.RDM, ref _casterFilter); + DrawJobFilter(JobsHelper.JobNames[JobIDs.PCT], JobIDs.PCT, ref _casterFilter); + } + } + + public static Dictionary DefaultCooldowns = new Dictionary() + { + // PARTY-WIDE EFFECTS + + // Default columns: + // 1. Role mitigations (Reprisal, Addle, Feint) + // 2. Job specific mitigations + // 3. Damage buffs / burst window cooldowns + // 4. Personal mitigations / immunities / externals + // 5. Misc (Provoke, Shirk, Switcast, Rescue, etc) + + // TANKS ------------------------------------------------------------------------------------------------- + [7535] = NewData(7535, JobRoles.Tank, 22, 60, 10, 100, 1, PartyCooldownEnabled.PartyFrames), // reprisal + [7531] = NewData(7531, JobRoles.Tank, 8, 90, 20, 50, 4, PartyCooldownEnabled.PartyFrames), // rampart + [7533] = NewData(7533, JobRoles.Tank, 15, 30, 1, 50, 5, PartyCooldownEnabled.PartyFrames), // provoke + [7537] = NewData(7537, JobRoles.Tank, 48, 120, 1, 50, 5, PartyCooldownEnabled.PartyFrames), // shirk + + // PLD + [30] = NewData(30, JobIDs.PLD, 50, 420, 10, 100, 4, PartyCooldownEnabled.PartyFrames), // hallowed ground + [3540] = NewData(3540, JobIDs.PLD, 56, 90, 30, 90, 2, PartyCooldownEnabled.PartyFrames), // divine veil + [7385] = NewData(7385, JobIDs.PLD, 70, 120, 18, 90, 2, PartyCooldownEnabled.PartyFrames), // passage of arms + [20] = NewData(20, JobIDs.PLD, 2, 60, 20, 10, 3, PartyCooldownEnabled.PartyFrames), // fight or flight + [22] = NewData(22, JobIDs.PLD, 52, 90, 10, 90, 4, PartyCooldownEnabled.PartyFrames), // bulwark + [27] = NewData(27, JobIDs.PLD, 45, 120, 12, 90, 4, PartyCooldownEnabled.PartyFrames), // cover + + [17] = NewData(17, JobIDs.PLD, 38, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 92), // sentinel + [36920] = NewData(36920, JobIDs.PLD, 92, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames), // guardian + + // WAR + [43] = NewData(43, JobIDs.WAR, 42, 240, 10, 100, 4, PartyCooldownEnabled.PartyFrames), // holmgang + [7388] = NewData(7388, JobIDs.WAR, 68, 90, 30, 90, 2, PartyCooldownEnabled.PartyFrames), // shake it off + [52] = NewData(52, JobIDs.WAR, 50, 60, 30, 10, 3, PartyCooldownEnabled.PartyFrames), // infuriate + [38] = NewData(38, JobIDs.WAR, 6, 60, 30, 10, 3, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 70), // berserk + [7389] = NewData(7389, JobIDs.WAR, 70, 60, 30, 10, 3, PartyCooldownEnabled.PartyFrames), // inner release + [40] = NewData(40, JobIDs.WAR, 30, 90, 10, 90, 4, PartyCooldownEnabled.PartyFrames), // thrill of battle + [3552] = NewData(3552, JobIDs.WAR, 58, 60, 15, 90, 4, PartyCooldownEnabled.PartyFrames), // equilibrium + + [3551] = NewData(3551, JobIDs.WAR, 56, 25, 8, 90, 4, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 82, sharedActionIds: new() { 16464 }), // raw intuition + [25751] = NewData(25751, JobIDs.WAR, 82, 25, 8, 90, 4, PartyCooldownEnabled.PartyFrames, sharedActionIds: new() { 16464 }), // bloodwhetting + [16464] = NewData(16464, JobIDs.WAR, 76, 25, 8, 90, 4, PartyCooldownEnabled.PartyFrames, sharedActionIds: new() { 3551, 25751 }), // nascent flash + + [44] = NewData(44, JobIDs.WAR, 38, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 92), // vengeance + [36923] = NewData(36923, JobIDs.WAR, 92, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames), // damnation + + // DRK + [3638] = NewData(3638, JobIDs.DRK, 50, 300, 10, 100, 4, PartyCooldownEnabled.PartyFrames), // living dead + [16471] = NewData(16471, JobIDs.DRK, 66, 90, 15, 90, 2, PartyCooldownEnabled.PartyFrames), // dark missionary + [16472] = NewData(16472, JobIDs.DRK, 80, 120, 20, 10, 3, PartyCooldownEnabled.PartyFrames), // living shadow + [3634] = NewData(3634, JobIDs.DRK, 45, 60, 10, 90, 4, PartyCooldownEnabled.PartyFrames), // dark mind + [25754] = NewData(25754, JobIDs.DRK, 82, 60, 10, 90, 4, PartyCooldownEnabled.PartyFrames), // oblation + + [3625] = NewData(3625, JobIDs.DRK, 35, 60, 15, 10, 3, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 68), // blood weapon + [7390] = NewData(7390, JobIDs.DRK, 68, 60, 15, 10, 3, PartyCooldownEnabled.PartyFrames), // delirium + + [3636] = NewData(3636, JobIDs.DRK, 38, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 92), // shadow wall + [36927] = NewData(36927, JobIDs.DRK, 92, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames), // shadowed vigil + + // GNB + [16152] = NewData(16152, JobIDs.GNB, 50, 360, 10, 100, 4, PartyCooldownEnabled.PartyFrames), // superbolide + [16160] = NewData(16160, JobIDs.GNB, 64, 90, 15, 90, 2, PartyCooldownEnabled.PartyFrames), // heart of light + [16164] = NewData(16164, JobIDs.GNB, 76, 120, 1, 10, 3, PartyCooldownEnabled.PartyFrames), // bloodfest + [16138] = NewData(16138, JobIDs.GNB, 2, 60, 20, 10, 3, PartyCooldownEnabled.PartyFrames), // no mercy + [16140] = NewData(16140, JobIDs.GNB, 6, 90, 20, 90, 4, PartyCooldownEnabled.PartyFrames), // camouflage + + [16161] = NewData(16161, JobIDs.GNB, 68, 25, 7, 90, 4, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 82), // heart of stone + [25758] = NewData(25758, JobIDs.GNB, 82, 25, 7, 90, 4, PartyCooldownEnabled.PartyFrames), // heart of corundum + + [16148] = NewData(16148, JobIDs.GNB, 38, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 92), // nebula + [36935] = NewData(36935, JobIDs.GNB, 92, 120, 15, 90, 4, PartyCooldownEnabled.PartyFrames), // great nebula + + // HEALER ------------------------------------------------------------------------------------------------- + [7571] = NewData(7571, JobRoles.Healer, 48, 120, 0, 80, 5, PartyCooldownEnabled.Disabled), // rescue + + // AST + [16552] = NewData(16552, JobIDs.AST, 50, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // divination + [3613] = NewData(3613, JobIDs.AST, 58, 60, 18, 80, 2, PartyCooldownEnabled.PartyFrames), // collective unconscious + [16553] = NewData(16553, JobIDs.AST, 60, 60, 15, 80, 2, PartyCooldownEnabled.PartyFrames), // celestial opposition + [7439] = NewData(7439, JobIDs.AST, 62, 60, 20, 80, 2, PartyCooldownEnabled.PartyFrames), // earthly star (stellar detonation = 8324) + [16559] = NewData(16559, JobIDs.AST, 80, 120, 20, 80, 2, PartyCooldownEnabled.PartyFrames), // neutral sect + [25873] = NewData(25873, JobIDs.AST, 86, 60, 8, 80, 2, PartyCooldownEnabled.PartyFrames), // exaltation + [25874] = NewData(25874, JobIDs.AST, 90, 180, 15, 80, 2, PartyCooldownEnabled.PartyFrames), // macrocosmos (microcosmos = 25875) + [3606] = NewData(3606, JobIDs.AST, 6, 90, 15, 10, 4, PartyCooldownEnabled.PartyFrames), // lightspeed + + // SCH + [7436] = NewData(7436, JobIDs.SCH, 66, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // chain stratagem + [805] = NewData(805, JobIDs.SCH, 40, 120, 20, 50, 2, PartyCooldownEnabled.PartyFrames), // fey illumination + [188] = NewData(188, JobIDs.SCH, 50, 30, 15, 80, 2, PartyCooldownEnabled.PartyFrames), // sacred soil + [3585] = NewData(3585, JobIDs.SCH, 56, 90, 1, 80, 2, PartyCooldownEnabled.PartyFrames), // deployment tactics + [25867] = NewData(25867, JobIDs.SCH, 86, 60, 10, 80, 2, PartyCooldownEnabled.PartyFrames), // protraction + [25868] = NewData(25868, JobIDs.SCH, 90, 120, 20, 80, 2, PartyCooldownEnabled.PartyFrames), // expedient + [7434] = NewData(7434, JobIDs.SCH, 62, 45, 1, 50, 4, PartyCooldownEnabled.PartyFrames), // excogitation + [37014] = NewData(37014, JobIDs.SCH, 100, 180, 20, 80, 2, PartyCooldownEnabled.PartyFrames), // seraphism + + // WHM + [16536] = NewData(16536, JobIDs.WHM, 80, 120, 20, 80, 2, PartyCooldownEnabled.PartyFrames), // temperance + [3569] = NewData(3569, JobIDs.WHM, 52, 90, 24, 50, 2, PartyCooldownEnabled.PartyFrames), // asylum + [25862] = NewData(25862, JobIDs.WHM, 90, 180, 15, 80, 2, PartyCooldownEnabled.PartyFrames), // liturgy of the bell + [136] = NewData(136, JobIDs.WHM, 30, 120, 15, 10, 4, PartyCooldownEnabled.PartyFrames), // presence of mind + [140] = NewData(140, JobIDs.WHM, 50, 180, 1, 10, 4, PartyCooldownEnabled.PartyFrames), // benediction + [3570] = NewData(3570, JobIDs.WHM, 60, 60, 1, 10, 4, PartyCooldownEnabled.PartyFrames), // tetragrammaton + [25861] = NewData(25861, JobIDs.WHM, 86, 60, 8, 10, 4, PartyCooldownEnabled.PartyFrames), // aquaveil + + // SGE + [24298] = NewData(24298, JobIDs.SGE, 50, 30, 15, 80, 2, PartyCooldownEnabled.PartyFrames), // kerachole + [24302] = NewData(24302, JobIDs.SGE, 60, 60, 15, 80, 2, PartyCooldownEnabled.PartyFrames), // physis ii + [24310] = NewData(24310, JobIDs.SGE, 76, 120, 20, 80, 2, PartyCooldownEnabled.PartyFrames), // holos + [24311] = NewData(24311, JobIDs.SGE, 80, 120, 15, 80, 2, PartyCooldownEnabled.PartyFrames), // panhaima + [24318] = NewData(24318, JobIDs.SGE, 90, 120, 1, 80, 2, PartyCooldownEnabled.PartyFrames), // pneuma + [24303] = NewData(24303, JobIDs.SGE, 62, 45, 15, 10, 4, PartyCooldownEnabled.PartyFrames), // taurochole + [24305] = NewData(24305, JobIDs.SGE, 70, 120, 15, 10, 4, PartyCooldownEnabled.PartyFrames), // haima + [24317] = NewData(24317, JobIDs.SGE, 86, 60, 10, 10, 4, PartyCooldownEnabled.PartyFrames), // krasis + [37035] = NewData(37035, JobIDs.SGE, 100, 180, 20, 80, 2, PartyCooldownEnabled.PartyFrames), // philosophia + + // MELEE ------------------------------------------------------------------------------------------------- + [7549] = NewData(7549, JobRoles.DPSMelee, 22, 90, 10, 100, 1, PartyCooldownEnabled.PartyFrames), // feint + [7542] = NewData(7542, JobRoles.DPSMelee, 12, 90, 20, 10, 4, PartyCooldownEnabled.PartyFrames), // bloodbath + + // SAM + // lol? + + // VPR + // lol? + + // NIN + [2248] = NewData(2248, JobIDs.NIN, 15, 120, 20, 30, 3, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 68), // mug + [36957] = NewData(36957, JobIDs.NIN, 68, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // dokumori + [2258] = NewData(2258, JobIDs.NIN, 18, 60, 15, 10, 3, PartyCooldownEnabled.PartyFrames, disabledAfterLevel: 92), // trick attack + [36958] = NewData(36958, JobIDs.NIN, 92, 60, 15, 10, 3, PartyCooldownEnabled.PartyFrames), // kunai's bane + [2241] = NewData(2241, JobIDs.NIN, 2, 120, 20, 20, 4, PartyCooldownEnabled.PartyFrames), // shade shift + + // DRG + [3557] = NewData(3557, JobIDs.DRG, 52, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // battle litany + [85] = NewData(85, JobIDs.DRG, 30, 60, 20, 10, 3, PartyCooldownEnabled.PartyFrames), // lance charge + + // MNK + [65] = NewData(65, JobIDs.MNK, 42, 90, 15, 50, 2, PartyCooldownEnabled.PartyFrames), // mantra + [7396] = NewData(7396, JobIDs.MNK, 70, 120, 20, 90, 3, PartyCooldownEnabled.PartyCooldowns), // brotherhood + [7395] = NewData(7395, JobIDs.MNK, 68, 60, 20, 10, 3, PartyCooldownEnabled.PartyFrames), // riddle of fire + [7394] = NewData(7394, JobIDs.MNK, 64, 120, 15, 20, 4, PartyCooldownEnabled.PartyFrames), // riddle of earth + + // RPR + [24405] = NewData(24405, JobIDs.RPR, 72, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // arcane circle + [24404] = NewData(24404, JobIDs.RPR, 40, 30, 5, 10, 4, PartyCooldownEnabled.PartyFrames), // arcane crest + + // RANGED ------------------------------------------------------------------------------------------------- + // BRD + [118] = NewData(118, JobIDs.BRD, 50, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // battle voice + [7405] = NewData(7405, JobIDs.BRD, 62, 90, 15, 70, 2, PartyCooldownEnabled.PartyFrames, "90-120"), // troubadour + [7408] = NewData(7408, JobIDs.BRD, 66, 120, 15, 40, 2, PartyCooldownEnabled.PartyFrames), // nature's minne + [25785] = NewData(25785, JobIDs.BRD, 90, 110, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // radiant finale + [101] = NewData(101, JobIDs.BRD, 4, 120, 20, 90, 3, PartyCooldownEnabled.PartyFrames), // raging strikes + + // DNC + [16012] = NewData(16012, JobIDs.DNC, 56, 90, 15, 70, 2, PartyCooldownEnabled.PartyFrames, "90-120"), // shield samba + [16004] = NewData(16004, JobIDs.DNC, 70, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // technical step / finish + [16011] = NewData(16011, JobIDs.DNC, 62, 120, 20, 90, 3, PartyCooldownEnabled.PartyFrames), // devilment + + // MCH + [16889] = NewData(16889, JobIDs.MCH, 56, 90, 15, 70, 2, PartyCooldownEnabled.PartyFrames, "90-120"), // tactician + [2887] = NewData(2887, JobIDs.MCH, 62, 120, 10, 70, 2, PartyCooldownEnabled.PartyFrames), // dismantle + + // CASTER ------------------------------------------------------------------------------------------------- + [7560] = NewData(7560, JobRoles.DPSCaster, 8, 90, 10, 100, 1, PartyCooldownEnabled.PartyFrames), // addle + + // RDM + [25857] = NewData(25857, JobIDs.RDM, 86, 120, 10, 70, 2, PartyCooldownEnabled.PartyCooldownsAndPartyFrames), // magick barrier + [7520] = NewData(7520, JobIDs.RDM, 58, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // embolden + + // SMN + [25801] = NewData(25801, JobIDs.SMN, 66, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // searing light + [25799] = NewData(25799, JobIDs.SMN, 2, 60, 30, 10, 3, PartyCooldownEnabled.PartyFrames), // radiant aegis + + // BLM + [3573] = NewData(3573, JobIDs.BLM, 52, 120, 30, 90, 3, PartyCooldownEnabled.PartyFrames), // ley lines + [157] = NewData(157, JobIDs.BLM, 38, 120, 20, 10, 4, PartyCooldownEnabled.PartyFrames), // manaward + + // PCT + [35349] = NewData(35349, JobIDs.PCT, 70, 120, 20, 30, 3, PartyCooldownEnabled.PartyCooldowns), // scenic muse + [34685] = NewData(34685, JobIDs.PCT, 10, 120, 10, 10, 4, PartyCooldownEnabled.PartyFrames), // tempera coat + + // MULTI-ROLE ------------------------------------------------------------------------------------------------- + [7541] = NewData(7541, new List() { JobRoles.DPSMelee, JobRoles.DPSRanged }, 8, 120, 0, 80, 4, PartyCooldownEnabled.PartyFrames), // second wind + [7561] = NewData(7561, new List() { JobRoles.Healer, JobRoles.DPSCaster }, 18, 60, 1, 80, 5, PartyCooldownEnabled.PartyFrames, null, new HashSet() { JobIDs.BLM, JobIDs.SMN, JobIDs.RDM }), // swiftcast + [7562] = NewData(7562, new List() { JobRoles.Healer, JobRoles.DPSCaster }, 14, 60, 21, 80, 5, PartyCooldownEnabled.Disabled), // lucid dreaming + }; + + #region helpers + private static PartyCooldownData NewData( + uint actionId, + uint jobId, + uint level, + int cooldown, + int effectDuration, + int priority, + int column, + PartyCooldownEnabled enabled, + string? overriddenCooldownText = null, + HashSet? excludedJobIds = null, + uint disabledAfterLevel = 0, + List? sharedActionIds = null) + { + PartyCooldownData data = NewData( + actionId, + level, + cooldown, + effectDuration, + priority, + column, + enabled, + overriddenCooldownText, + excludedJobIds, + disabledAfterLevel, + sharedActionIds + ); + + data.JobId = jobId; + data.Role = JobRoles.Unknown; + + return data; + } + + private static PartyCooldownData NewData(uint actionId, JobRoles role, uint level, int cooldown, int effectDuration, int priority, int column, PartyCooldownEnabled enabled, string? overriddenCooldownText = null, HashSet? excludedJobIds = null) + { + PartyCooldownData data = NewData( + actionId, + level, + cooldown, + effectDuration, + priority, + column, + enabled, + overriddenCooldownText, + excludedJobIds + ); + + data.JobId = 0; + data.Role = role; + + return data; + } + + private static PartyCooldownData NewData(uint actionId, List roles, uint level, int cooldown, int effectDuration, int priority, int column, PartyCooldownEnabled enabled, string? overriddenCooldownText = null, HashSet? excludedJobIds = null) + { + PartyCooldownData data = NewData( + actionId, + level, + cooldown, + effectDuration, + priority, + column, + enabled, + overriddenCooldownText, + excludedJobIds + ); + + data.JobId = 0; + data.Role = JobRoles.Unknown; + data.Roles = roles; + + return data; + } + + private static PartyCooldownData NewData( + uint actionId, + uint level, + int cooldown, + int effectDuration, + int priority, + int column, + PartyCooldownEnabled enabled, + string? overriddenCooldownText = null, + HashSet? excludedJobIds = null, + uint disabledAfterLevel = 0, + List? sharedActionIds = null + ) + { + PartyCooldownData data = new PartyCooldownData(); + data.ActionId = actionId; + data.RequiredLevel = level; + data.DisabledAfterLevel = disabledAfterLevel; + data.CooldownDuration = cooldown; + data.EffectDuration = effectDuration; + data.Priority = priority; + data.Column = column; + data.EnabledV2 = enabled; + data.OverriddenCooldownText = overriddenCooldownText; + data.SharedActionIds = sharedActionIds ?? new(); + + if (excludedJobIds != null) + { + data.ExcludedJobIds = excludedJobIds; + } + + return data; + } + #endregion + } +} + diff --git a/Interface/PartyCooldowns/PartyCooldownsHud.cs b/Interface/PartyCooldowns/PartyCooldownsHud.cs new file mode 100644 index 0000000..7a2c1dc --- /dev/null +++ b/Interface/PartyCooldowns/PartyCooldownsHud.cs @@ -0,0 +1,334 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface.PartyCooldowns +{ + public class PartyCooldownsHud : DraggableHudElement, IHudElementWithPreview, IHudElementWithVisibilityConfig + { + private PartyCooldownsConfig Config => (PartyCooldownsConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + private PartyCooldownsBarConfig _barConfig = null!; + private PartyCooldownsDataConfig _dataConfig = null!; + + private List> _cooldowns = new List>(); + + private LabelHud _nameLabelHud; + private LabelHud _timeLabelHud; + + public PartyCooldownsHud(PartyCooldownsConfig config, string displayName) : base(config, displayName) + { + _barConfig = ConfigurationManager.Instance.GetConfigObject(); + _dataConfig = ConfigurationManager.Instance.GetConfigObject(); + + _dataConfig.CooldownsDataChangedEvent += OnCooldownsDataChanged; + PartyCooldownsManager.Instance.CooldownsChangedEvent += OnCooldownsChanged; + + _nameLabelHud = new LabelHud(_barConfig.NameLabel); + _timeLabelHud = new LabelHud(_barConfig.TimeLabel); + + UpdateCooldowns(); + } + + protected override void InternalDispose() + { + _dataConfig.CooldownsDataChangedEvent -= OnCooldownsDataChanged; + PartyCooldownsManager.Instance.CooldownsChangedEvent -= OnCooldownsChanged; + } + + public void StopPreview() + { + Config.Preview = false; + PartyCooldownsManager.Instance?.UpdatePreview(); + } + + private void OnCooldownsDataChanged(PartyCooldownsDataConfig sender) + { + UpdateCooldowns(); + } + + private void OnCooldownsChanged(PartyCooldownsManager sender) + { + UpdateCooldowns(); + } + + private void UpdateCooldowns() + { + _cooldowns.Clear(); + + int columnCount = PartyCooldownsDataConfig.ColumnCount; + for (int i = 0; i < columnCount; i++) + { + _cooldowns.Add(new List()); + } + + foreach (Dictionary memberCooldownList in PartyCooldownsManager.Instance.CooldownsMap.Values) + { + foreach (PartyCooldown cooldown in memberCooldownList.Values) + { + if (!cooldown.Data.IsEnabledForPartyCooldowns()) { continue; } + + int columnIndex = Math.Min(columnCount - 1, cooldown.Data.Column - 1); + _cooldowns[columnIndex].Add(cooldown); + } + } + + foreach (List list in _cooldowns) + { + list.Sort((a, b) => + { + if (a.Data.Priority == b.Data.Priority) + { + if (a.Data.ActionId == b.Data.ActionId) + { + return a.SourceId.CompareTo(b.SourceId); + } + else + { + return a.Data.ActionId.CompareTo(b.Data.ActionId); + } + } + + if (a.Data.Priority < b.Data.Priority) + { + return -1; + } + + return 1; + }); + } + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + // hardcoded just for draggable area purposes + const int columnCount = 3; + const int rowCount = 4; + + Vector2 size = new Vector2( + _barConfig.Size.X * columnCount + Config.Padding.X * (columnCount - 1), + _barConfig.Size.Y * rowCount + Config.Padding.Y * (rowCount - 1)); + + Vector2 pos = Config.GrowthDirection == PartyCooldownsGrowthDirection.Down ? Config.Position : Config.Position - new Vector2(0, size.Y); + + return (new List() { pos + size / 2f }, new List() { size }); + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled) { return; } + + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + if (player == null) { return; } + + float offset = 0; + bool addedOffset = true; + bool isVertical = Config.GrowthDirection == PartyCooldownsGrowthDirection.Up || Config.GrowthDirection == PartyCooldownsGrowthDirection.Down; + + foreach (List list in _cooldowns) + { + if (list.Count == 0) { continue; } + + int i = 0; + + foreach (PartyCooldown cooldown in list) + { + if (!addedOffset) + { + offset += isVertical ? Config.Padding.X + _barConfig.Size.X : Config.Padding.Y + _barConfig.Size.Y; + addedOffset = true; + } + + string barId = _barConfig.ID + $"_{offset + i}"; + + // cooldown bar + float cooldownTime = cooldown.CooldownTimeRemaining(); + float effectTime = cooldown.EffectTimeRemaining(); + + float max = effectTime > 0 ? cooldown.GetEffectDuration() : cooldown.GetCooldown(); + float current = effectTime > 0 ? effectTime : cooldownTime; + + float sizeX = Math.Max(1, _barConfig.Size.X - _barConfig.Size.Y); + Vector2 size = new Vector2(sizeX, _barConfig.Size.Y); + + Vector2 pos; + + if (isVertical) + { + int direction = Config.GrowthDirection == PartyCooldownsGrowthDirection.Down ? 1 : -1; + pos = new Vector2( + Config.Position.X + size.Y + offset - 1, + Config.Position.Y + i * direction * size.Y + i * direction * Config.Padding.Y + ); + } + else + { + int direction = Config.GrowthDirection == PartyCooldownsGrowthDirection.Right ? 1 : -1; + pos = new Vector2( + size.Y + Config.Position.X + i * direction * size.X + i * direction * size.Y + i * direction * Config.Padding.X, + Config.Position.Y + offset - 1 + ); + } + + if (_barConfig.ShowBar) + { + PluginConfigColor fillColor = effectTime > 0 ? _barConfig.AvailableColor : _barConfig.RechargingColor; + PluginConfigColor bgColor = effectTime > 0 || cooldownTime == 0 ? _barConfig.AvailableBackgroundColor : _barConfig.RechargingBackgroundColor; + + if (_barConfig.UseJobColors) + { + uint? jobId = GetJobId(cooldown, player); + if (jobId.HasValue) + { + PluginConfigColor jobColor = GlobalColors.Instance.SafeColorForJobId(jobId.Value); + PluginConfigColor bgJobColor = jobColor.WithAlpha(40f / 100f); + PluginConfigColor rechargeJobColor = jobColor.WithAlpha(25f / 100f); + PluginConfigColor nonActive = PluginConfigColor.FromHex(0x88000000); + fillColor = effectTime > 0 ? jobColor : rechargeJobColor; + bgColor = effectTime > 0 || cooldownTime == 0 ? bgJobColor : nonActive; + } + } + + Rect background = new Rect(pos, size, bgColor); + Rect fill = BarUtilities.GetFillRect(pos, size, _barConfig.FillDirection, fillColor, current, max); + + BarHud bar = new BarHud( + barId, + _barConfig.DrawBorder, + _barConfig.BorderColor, + _barConfig.BorderThickness, + DrawAnchor.TopLeft, + current: current, + max: max, + barTextureName: _barConfig.BarTextureName, + barTextureDrawMode: _barConfig.BarTextureDrawMode + ); + + bar.SetBackground(background); + bar.AddForegrounds(fill); + + AddDrawActions(bar.GetDrawActions(origin, _barConfig.StrataLevel)); + } + + // icon + if (_barConfig.ShowIcon) + { + Vector2 iconPos = origin + new Vector2(pos.X - size.Y + 1, pos.Y); + Vector2 iconSize = new Vector2(size.Y); + bool recharging = effectTime == 0 && cooldownTime > 0; + bool shouldDrawCooldown = ClipRectsHelper.Instance.GetClipRectForArea(iconPos, iconSize) == null; + + AddDrawAction(_barConfig.StrataLevel, () => + { + DrawHelper.DrawInWindow(barId + "_icon", iconPos, iconSize, false, (drawList) => + { + uint color = recharging ? 0xAAFFFFFF : 0xFFFFFFFF; + DrawHelper.DrawIcon(cooldown.Data.IconId, iconPos, iconSize, false, color, drawList); + + // cooldown + if (shouldDrawCooldown && + _barConfig.ShowIconCooldownAnimation && + effectTime == 0 && + cooldownTime > 0) + { + DrawHelper.DrawIconCooldown(iconPos, iconSize, cooldownTime, cooldown.GetCooldown(), drawList); + } + + if (_barConfig.DrawBorder) + { + bool active = effectTime > 0 && _barConfig.ChangeIconBorderWhenActive; + uint iconBorderColor = active ? _barConfig.IconActiveBorderColor.Base : _barConfig.BorderColor.Base; + int thickness = active ? _barConfig.IconActiveBorderThickness : _barConfig.BorderThickness; + drawList.AddRect(iconPos, iconPos + iconSize, iconBorderColor, 0, ImDrawFlags.None, thickness); + } + }); + }); + } + + // name + PluginConfigColor? labelColor = effectTime > 0 && _barConfig.ChangeLabelsColorWhenActive ? _barConfig.LabelsActiveColor : null; + + ICharacter? character = cooldown.Member?.Character; + if (character == null && cooldown.SourceId == player.GameObjectId) + { + character = player; + } + + Vector2 labelPos = origin + pos; + AddDrawAction(_barConfig.NameLabel.StrataLevel, () => + { + PluginConfigColor realColor = _barConfig.NameLabel.Color; + _barConfig.NameLabel.Color = labelColor ?? realColor; + + string? name = character == null ? "Fake Name" : null; + _nameLabelHud.Draw(labelPos, size, character, name); + + _barConfig.NameLabel.Color = realColor; + }); + + // time + AddDrawAction(_barConfig.TimeLabel.StrataLevel, () => + { + PluginConfigColor realColor = _barConfig.TimeLabel.Color; + _barConfig.TimeLabel.Color = labelColor ?? realColor; + _barConfig.TimeLabel.SetText(""); + + if (effectTime > 0) + { + if (_barConfig.TimeLabel.ShowEffectDuration) + { + _barConfig.TimeLabel.SetValue(effectTime); + } + } + else if (cooldownTime > 0) + { + if (_barConfig.TimeLabel.ShowRemainingCooldown) + { + _barConfig.TimeLabel.SetText(Utils.DurationToString(cooldownTime, _barConfig.TimeLabel.NumberFormat)); + } + } + + _timeLabelHud.Draw(labelPos, size, character); + _barConfig.TimeLabel.Color = realColor; + }); + + // tooltip + pos = origin + new Vector2(pos.X - size.Y + 1, pos.Y); + if (Config.ShowTooltips && ImGui.IsMouseHoveringRect(pos, pos + _barConfig.Size)) + { + TooltipsHelper.Instance.ShowTooltipOnCursor( + cooldown.TooltipText(), + cooldown.Data.Name, + cooldown.Data.ActionId + ); + } + + i++; + } + + addedOffset = false; + } + } + + private uint? GetJobId(PartyCooldown cooldown, IPlayerCharacter player) + { + uint jobId = cooldown.Data.JobId; + if (jobId != 0) { return jobId; } + + if (cooldown.Member != null) { return cooldown.Member.JobId; } + + if (cooldown.SourceId == player.GameObjectId) { return player.ClassJob.RowId; } + + ICharacter? chara = Plugin.ObjectTable.SearchById(cooldown.SourceId) as ICharacter; + return chara?.ClassJob.RowId; + } + } +} diff --git a/Interface/PartyCooldowns/PartyCooldownsManager.cs b/Interface/PartyCooldowns/PartyCooldownsManager.cs new file mode 100644 index 0000000..a9022e6 --- /dev/null +++ b/Interface/PartyCooldowns/PartyCooldownsManager.cs @@ -0,0 +1,412 @@ +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Hooking; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface.Party; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Numerics; +using static FFXIVClientStructs.FFXIV.Client.Game.Character.ActionEffectHandler; + +namespace HSUI.Interface.PartyCooldowns +{ + public unsafe class PartyCooldownsManager + { + #region Singleton + public static PartyCooldownsManager Instance { get; private set; } = null!; + private PartyCooldownsConfig _config = null!; + private PartyCooldownsDataConfig _dataConfig = null!; + + private PartyCooldownsManager() + { + try + { + _onActionUsedHook = Plugin.GameInteropProvider.HookFromAddress( + ActionEffectHandler.MemberFunctionPointers.Receive, + OnActionUsed + ); + _onActionUsedHook?.Enable(); + } + catch + { + Plugin.Logger.Error("PartyCooldowns OnActionUsed Hook failed!!!"); + } + + try + { + _actorControlHook = Plugin.GameInteropProvider.HookFromSignature( + "E8 ?? ?? ?? ?? 0F B7 0B 83 E9 64", + OnActorControl + ); + _actorControlHook?.Enable(); + } + catch + { + Plugin.Logger.Error("PartyCooldowns OnActorControl Hook failed!!!"); + } + + PartyManager.Instance.MembersChangedEvent += OnMembersChanged; + ConfigurationManager.Instance.ResetEvent += OnConfigReset; + Plugin.JobChangedEvent += OnJobChanged; + Plugin.ClientState.TerritoryChanged += OnTerritoryChanged; + + ConfigReset(ConfigurationManager.Instance, false); + UpdatePreview(); + } + + public static void Initialize() + { + Instance = new PartyCooldownsManager(); + } + + ~PartyCooldownsManager() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _onActionUsedHook?.Disable(); + _onActionUsedHook?.Dispose(); + + _actorControlHook?.Disable(); + _actorControlHook?.Dispose(); + + PartyManager.Instance.MembersChangedEvent -= OnMembersChanged; + Plugin.JobChangedEvent -= OnJobChanged; + _config.ValueChangeEvent -= OnConfigPropertyChanged; + _dataConfig.CooldownsDataEnabledChangedEvent -= OnCooldownEnabledChanged; + Plugin.ClientState.TerritoryChanged -= OnTerritoryChanged; + + Instance = null!; + } + + private void OnConfigReset(ConfigurationManager sender) + { + ConfigReset(sender); + } + + private void ConfigReset(ConfigurationManager sender, bool forceUpdate = true) + { + if (_config != null) + { + _config.ValueChangeEvent -= OnConfigPropertyChanged; + } + + _config = sender.GetConfigObject(); + _config.ValueChangeEvent += OnConfigPropertyChanged; + + if (_dataConfig != null) + { + _dataConfig.CooldownsDataEnabledChangedEvent -= OnCooldownEnabledChanged; + } + + _dataConfig = sender.GetConfigObject(); + _dataConfig.CooldownsDataEnabledChangedEvent += OnCooldownEnabledChanged; + _dataConfig.UpdateDataIfNeeded(); + + if (forceUpdate) + { + ForcedUpdate(); + } + } + + #endregion Singleton + + private Hook? _onActionUsedHook; + + private delegate void ActorControlDelegate(uint entityId, uint type, uint buffID, uint direct, uint actionId, uint sourceId, uint arg7, uint arg8, uint arg9, uint arg10, ulong targetId, byte arg12); + private Hook? _actorControlHook; + + private Dictionary>? _oldMap; + private Dictionary> _cooldownsMap = new Dictionary>(); + public IReadOnlyDictionary> CooldownsMap => _cooldownsMap; + + private Dictionary _technicalStepMap = new Dictionary(); + + public delegate void PartyCooldownsChangedEventHandler(PartyCooldownsManager sender); + public event PartyCooldownsChangedEventHandler? CooldownsChangedEvent; + + private bool _wasInDuty = false; + + private void OnActorControl(uint entityId, uint type, uint buffID, uint direct, uint actionId, uint sourceId, uint arg7, uint arg8, uint arg9, uint arg10, ulong targetId, byte arg12) + { + _actorControlHook?.Original(entityId, type, buffID, direct, actionId, sourceId, arg7, arg8, arg9, arg10, targetId, arg12); + + // detect wipe fadeouts (not 100% reliable but good enough) + if (type == 0x4000000F) + { + ResetCooldowns(); + } + } + + private void ResetCooldowns() + { + foreach (uint actorId in _cooldownsMap.Keys) + { + foreach (PartyCooldown cooldown in _cooldownsMap[actorId].Values) + { + cooldown.LastTimeUsed = 0; + } + } + } + + private unsafe void OnActionUsed(uint actorId, Character* casterPtr, Vector3* targetPos, Header* header, TargetEffects* effects, GameObjectId* targetEntityIds) + { + _onActionUsedHook?.Original(actorId, casterPtr, targetPos, header, effects, targetEntityIds); + + // check if its an action + if ((ActionType)header->ActionType != ActionType.Action ) { return; } + + // check if its a member in the party + if (!_cooldownsMap.ContainsKey(actorId)) + { + // check if its a party member's pet + IGameObject? actor = Plugin.ObjectTable.SearchById(actorId); + + if (actor is IBattleNpc battleNpc && _cooldownsMap.ContainsKey(battleNpc.OwnerId)) + { + actorId = battleNpc.OwnerId; + } + else + { + actorId = 0; + } + } + + if (actorId <= 0) { return; } + + uint actionID = header->ActionId; + + // special case for starry muse > set id to scenic muse + if (actionID == 34675) + { + actionID = 35349; + } + + // special case for technical step / finish + // we detect when technical step is pressed and save the time + // so we can properly calculate the cooldown once finish is pressed + if (actionID == 16193 || actionID == 16194 || actionID == 16195 || actionID == 16196) + { + actionID = 16004; + } + + if (actionID == 15998) + { + _technicalStepMap[actorId] = ImGui.GetTime(); + } + else + { + // check if its an action we track + if (_cooldownsMap[actorId].TryGetValue(actionID, out PartyCooldown? cooldown) && cooldown != null) + { + // if its technical finish, we set the cooldown start time to + // the time when step was pressed + if (_technicalStepMap.TryGetValue(actorId, out double stepStartTime) && actionID == 16004) + { + cooldown.OverridenCooldownStartTime = stepStartTime; + _technicalStepMap.Remove(actorId); + } + + double now = ImGui.GetTime(); + cooldown.LastTimeUsed = now; + cooldown.IgnoreNextUse = false; + + foreach (uint id in cooldown.Data.SharedActionIds) + { + if (_cooldownsMap[actorId].TryGetValue(id, out PartyCooldown? sharedCooldown) && sharedCooldown != null) + { + sharedCooldown.LastTimeUsed = now; + sharedCooldown.IgnoreNextUse = true; + } + } + } + } + } + + public void ForcedUpdate() + { + OnMembersChanged(PartyManager.Instance); + } + + private void OnMembersChanged(PartyManager sender) + { + Plugin.Framework.RunOnFrameworkThread(() => + { + if (sender.Previewing || _config.Preview) { return; } + + _cooldownsMap.Clear(); + + if (_config.ShowOnlyInDuties && !Plugin.Condition[ConditionFlag.BoundByDuty]) + { + CooldownsChangedEvent?.Invoke(this); + return; + } + + // show when solo + if (sender.IsSoloParty() || sender.MemberCount == 0) + { + var player = Plugin.ObjectTable.LocalPlayer; + if (_config.ShowWhenSolo && player != null) + { + _cooldownsMap.Add((uint)player.GameObjectId, CooldownsForMember((uint)player.GameObjectId, player.ClassJob.RowId, player.Level, null)); + } + } + else if (!_config.ShowOnlyInDuties || Plugin.Condition[ConditionFlag.BoundByDuty]) + { + // add new members + foreach (IPartyFramesMember member in sender.GroupMembers) + { + if (member.ObjectId > 0) + { + _cooldownsMap.Add(member.ObjectId, CooldownsForMember(member)); + } + } + } + + CooldownsChangedEvent?.Invoke(this); + }); + } + + private Dictionary CooldownsForMember(IPartyFramesMember member) + { + return CooldownsForMember(member.ObjectId, member.JobId, member.Level, member); + } + + private Dictionary CooldownsForMember(uint objectId, uint jobId, uint level, IPartyFramesMember? member) + { + Dictionary cooldowns = new Dictionary(); + + foreach (PartyCooldownData data in _dataConfig.Cooldowns) + { + if (data.EnabledV2 != PartyCooldownEnabled.Disabled && + level >= data.RequiredLevel && + (data.DisabledAfterLevel == 0 || level < data.DisabledAfterLevel) && + data.IsUsableBy(jobId) && + !data.ExcludedJobIds.Contains(jobId)) + { + cooldowns.Add(data.ActionId, new PartyCooldown(data, objectId, level, member)); + } + } + + return cooldowns; + } + + #region events + private void OnConfigPropertyChanged(object sender, OnChangeBaseArgs args) + { + if (args.PropertyName == "Preview") + { + UpdatePreview(); + } + else if (args.PropertyName == "ShowWhenSolo" && PartyManager.Instance?.MemberCount == 0) + { + OnMembersChanged(PartyManager.Instance); + } + else if (args.PropertyName == "ShowOnlyInDuties" && PartyManager.Instance != null) + { + OnMembersChanged(PartyManager.Instance); + } + } + + private void OnJobChanged(uint jobId) + { + ForcedUpdate(); + } + + private void OnCooldownEnabledChanged(PartyCooldownsDataConfig config) + { + ForcedUpdate(); + } + + private void OnTerritoryChanged(ushort territoryId) + { + bool isInDuty = Plugin.Condition[ConditionFlag.BoundByDuty]; + if (_config.ShowOnlyInDuties && _wasInDuty != isInDuty) + { + ForcedUpdate(); + } + + _wasInDuty = isInDuty; + } + + public void UpdatePreview() + { + if (!_config.Preview) + { + if (_oldMap != null) + { + _cooldownsMap = _oldMap; + } + else + { + _cooldownsMap.Clear(); + } + + if (PartyManager.Instance.Previewing) + { + CooldownsChangedEvent?.Invoke(this); + } + else + { + OnMembersChanged(PartyManager.Instance); + } + return; + } + + if (PartyManager.Instance?.Previewing == false) + { + _oldMap = _cooldownsMap; + } + + _cooldownsMap.Clear(); + Random RNG = new Random((int)ImGui.GetTime()); + + for (uint i = 1; i < 9; i++) + { + Dictionary cooldowns = new Dictionary(); + + JobRoles role = i < 3 ? JobRoles.Tank : (i < 5 ? JobRoles.Healer : JobRoles.Unknown); + role = role == JobRoles.Unknown ? JobRoles.DPSMelee + RNG.Next(3) : role; + int jobCount = JobsHelper.JobsByRole[role].Count; + int jobIndex = RNG.Next(jobCount); + uint jobId = JobsHelper.JobsByRole[role][jobIndex]; + + _cooldownsMap.Add(i, CooldownsForMember(i, jobId, 90, null)); + + foreach (PartyCooldown cooldown in _cooldownsMap[i].Values) + { + int rng = RNG.Next(100); + if (rng > 80) + { + cooldown.LastTimeUsed = ImGui.GetTime() - 30; + } + else if (rng > 50) + { + cooldown.LastTimeUsed = ImGui.GetTime() + 1; + } + } + } + + CooldownsChangedEvent?.Invoke(this); + } + #endregion + } +} diff --git a/Interface/StatusEffects/CustomEffectsListHud.cs b/Interface/StatusEffects/CustomEffectsListHud.cs new file mode 100644 index 0000000..fb34eb0 --- /dev/null +++ b/Interface/StatusEffects/CustomEffectsListHud.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using Dalamud.Game.ClientState.Objects.Types; + +namespace HSUI.Interface.StatusEffects +{ + public class CustomEffectsListHud : StatusEffectsListHud + { + public CustomEffectsListHud(StatusEffectsListConfig config, string displayName) : base(config, displayName) + { + } + + public IGameObject? TargetActor { get; set; } = null!; + + protected override List StatusEffectsData() + { + var list = StatusEffectDataList(TargetActor); + list.AddRange(StatusEffectDataList(Actor)); + + // cull duplicate statuses from the same source + list = list.GroupBy(s => new { s.Status.StatusId, s.Status.SourceObject.Id }) + .Select(status => status.First()) + .ToList(); + + // show mine or permanent first + if (Config.ShowMineFirst || Config.ShowPermanentFirst) + { + return OrderByMineOrPermanentFirst(list); + } + + return list; + } + } +} diff --git a/Interface/StatusEffects/StatusEffectsListConfig.cs b/Interface/StatusEffects/StatusEffectsListConfig.cs new file mode 100644 index 0000000..6e2281b --- /dev/null +++ b/Interface/StatusEffects/StatusEffectsListConfig.cs @@ -0,0 +1,859 @@ +using Dalamud.Interface; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.Bars; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace HSUI.Interface.StatusEffects +{ + [Section("Buffs and Debuffs")] + [SubSection("Player Buffs", 0)] + public class PlayerBuffsListConfig : UnitFrameStatusEffectsListConfig + { + public new static PlayerBuffsListConfig DefaultConfig() + { + var screenSize = ImGui.GetMainViewport().Size; + var pos = new Vector2(screenSize.X * 0.38f, -screenSize.Y * 0.45f); + var iconConfig = new StatusEffectIconConfig(); + iconConfig.DispellableBorderConfig.Enabled = false; + + return new PlayerBuffsListConfig(pos, HUDConstants.DefaultStatusEffectsListSize, true, false, true, GrowthDirections.Left | GrowthDirections.Down, iconConfig); + } + + public PlayerBuffsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + [Section("Buffs and Debuffs")] + [SubSection("Player Debuffs", 0)] + public class PlayerDebuffsListConfig : UnitFrameStatusEffectsListConfig + { + public new static PlayerDebuffsListConfig DefaultConfig() + { + var screenSize = ImGui.GetMainViewport().Size; + var pos = new Vector2(screenSize.X * 0.38f, -screenSize.Y * 0.45f + HUDConstants.DefaultStatusEffectsListSize.Y); + var iconConfig = new StatusEffectIconConfig(); + + return new PlayerDebuffsListConfig(pos, HUDConstants.DefaultStatusEffectsListSize, false, true, true, GrowthDirections.Left | GrowthDirections.Down, iconConfig); + } + + public PlayerDebuffsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + [Section("Buffs and Debuffs")] + [SubSection("Target Buffs", 0)] + public class TargetBuffsListConfig : UnitFrameStatusEffectsListConfig + { + public new static TargetBuffsListConfig DefaultConfig() + { + var pos = new Vector2(0, -1); + var iconConfig = new StatusEffectIconConfig(); + iconConfig.DispellableBorderConfig.Enabled = false; + + var config = new TargetBuffsListConfig(pos, HUDConstants.DefaultStatusEffectsListSize, true, false, true, GrowthDirections.Right | GrowthDirections.Up, iconConfig); + config.AnchorToUnitFrame = true; + config.UnitFrameAnchor = DrawAnchor.TopLeft; + + return config; + } + + public TargetBuffsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + [Section("Buffs and Debuffs")] + [SubSection("Target Debuffs", 0)] + public class TargetDebuffsListConfig : UnitFrameStatusEffectsListConfig + { + public new static TargetDebuffsListConfig DefaultConfig() + { + var pos = new Vector2(0, -85); + var iconConfig = new StatusEffectIconConfig(); + iconConfig.DispellableBorderConfig.Enabled = false; + + var config = new TargetDebuffsListConfig(pos, HUDConstants.DefaultStatusEffectsListSize, false, true, true, GrowthDirections.Right | GrowthDirections.Up, iconConfig); + config.AnchorToUnitFrame = true; + config.UnitFrameAnchor = DrawAnchor.TopLeft; + + return config; + } + + public TargetDebuffsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + [Section("Buffs and Debuffs")] + [SubSection("Focus Target Buffs", 0)] + public class FocusTargetBuffsListConfig : UnitFrameStatusEffectsListConfig + { + public new static FocusTargetBuffsListConfig DefaultConfig() + { + var pos = new Vector2(0, -1); + var iconConfig = new StatusEffectIconConfig(); + iconConfig.DispellableBorderConfig.Enabled = false; + + var config = new FocusTargetBuffsListConfig(pos, HUDConstants.DefaultStatusEffectsListSize, true, false, true, GrowthDirections.Right | GrowthDirections.Up, iconConfig); + config.AnchorToUnitFrame = true; + config.UnitFrameAnchor = DrawAnchor.TopLeft; + + return config; + } + + public FocusTargetBuffsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + [Section("Buffs and Debuffs")] + [SubSection("Focus Target Debuffs", 0)] + public class FocusTargetDebuffsListConfig : UnitFrameStatusEffectsListConfig + { + public new static FocusTargetDebuffsListConfig DefaultConfig() + { + var pos = new Vector2(0, -85); + var iconConfig = new StatusEffectIconConfig(); + iconConfig.DispellableBorderConfig.Enabled = false; + + var config = new FocusTargetDebuffsListConfig(pos, HUDConstants.DefaultStatusEffectsListSize, false, true, true, GrowthDirections.Right | GrowthDirections.Up, iconConfig); + config.AnchorToUnitFrame = true; + config.UnitFrameAnchor = DrawAnchor.TopLeft; + + return config; + } + + public FocusTargetDebuffsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + public abstract class UnitFrameStatusEffectsListConfig : StatusEffectsListConfig + { + [Checkbox("Anchor to Unit Frame")] + [Order(16)] + public bool AnchorToUnitFrame = false; + + [Anchor("Unit Frame Anchor")] + [Order(17, collapseWith = nameof(AnchorToUnitFrame))] + public DrawAnchor UnitFrameAnchor = DrawAnchor.TopLeft; + + [NestedConfig("Visibility", 200)] + public VisibilityConfig VisibilityConfig = new VisibilityConfig(); + + public UnitFrameStatusEffectsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + public class StatusEffectsListConfig : MovablePluginConfigObject + { + public bool ShowBuffs; + public bool ShowDebuffs; + + [DragInt2("Size", min = 1, max = 4000)] + [Order(15)] + public Vector2 Size; + + [DragInt2("Icon Padding", min = 0, max = 500)] + [Order(19)] + public Vector2 IconPadding = new(2, 2); + + [Checkbox("Preview", isMonitored = true)] + [Order(20)] + public bool Preview; + + [Checkbox("Fill Rows First", spacing = true)] + [Order(25)] + public bool FillRowsFirst = true; + + [Combo("Icons Growth Direction", + "Right and Down", + "Right and Up", + "Left and Down", + "Left and Up", + "Centered and Up", + "Centered and Down", + "Centered and Left", + "Centered and Right" + )] + [Order(30)] + public int Directions; + + [DragInt("Limit (-1 for no limit)", min = -1, max = 1000)] + [Order(35)] + public int Limit = -1; + + [Checkbox("Permanent Effects", spacing = true)] + [Order(40)] + public bool ShowPermanentEffects; + + [Checkbox("Permanent Effects First")] + [Order(41)] + public bool ShowPermanentFirst; + + [Checkbox("Only My Effects", spacing = true)] + [Order(42)] + public bool ShowOnlyMine = false; + + [Checkbox("My Effects First")] + [Order(43)] + public bool ShowMineFirst = false; + + [Checkbox("Pet As Own Effect")] + [Order(44)] + public bool IncludePetAsOwn = false; + + [Checkbox("Sort by Duration", spacing = true, help = "If enabled, \"Permanent Effects First\" and \"My Effects First\" will be ignored!")] + [Order(45)] + public bool SortByDuration = false; + + [RadioSelector("Ascending", "Descending")] + [Order(46, collapseWith = nameof(SortByDuration))] + public StatusEffectDurationSortType DurationSortType = StatusEffectDurationSortType.Ascending; + + [Checkbox("Tooltips", spacing = true)] + [Order(47)] + public bool ShowTooltips = true; + + [Checkbox("Disable Interaction", help = "Enabling this will disable right clicking buffs off, or the shortcut to blacklist/whitelist a status effect.")] + [Order(48)] + public bool DisableInteraction = false; + + [Checkbox("Hide when Dead")] + [Order(49)] + public bool HideWhenDead = true; + + [NestedConfig("Icons", 65)] + public StatusEffectIconConfig IconConfig; + + [NestedConfig("Filter Status Effects", 70, separator = true, spacing = false, collapsingHeader = false)] + public StatusEffectsBlacklistConfig BlacklistConfig = new StatusEffectsBlacklistConfig(); + + + public StatusEffectsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + { + Position = position; + Size = size; + ShowBuffs = showBuffs; + ShowDebuffs = showDebuffs; + ShowPermanentEffects = showPermanentEffects; + + SetGrowthDirections(growthDirections); + + IconConfig = iconConfig; + + Strata = StrataLevel.HIGH; + } + + private void SetGrowthDirections(GrowthDirections growthDirections) + { + Directions = LayoutHelper.IndexFromGrowthDirections(growthDirections); + } + } + + [Exportable(false)] + [Disableable(false)] + public class StatusEffectIconConfig : PluginConfigObject + { + [DragInt2("Icon Size", min = 1, max = 1000)] + [Order(5)] + public Vector2 Size = new(40, 40); + + [Checkbox("Crop Icon", spacing = true)] + [Order(20)] + public bool CropIcon = true; + + [NestedConfig("Border", 25, collapseWith = nameof(CropIcon), collapsingHeader = false)] + public StatusEffectIconBorderConfig BorderConfig = new(); + + [NestedConfig("Shadow", 26, collapseWith = nameof(CropIcon), collapsingHeader = false)] + public ShadowConfig ShadowConfig = new ShadowConfig() { Enabled = false }; + + [NestedConfig("Dispellable Effects Border", 30, collapseWith = nameof(CropIcon), collapsingHeader = false)] + public StatusEffectIconBorderConfig DispellableBorderConfig = new(new PluginConfigColor(new Vector4(141f / 255f, 206f / 255f, 229f / 255f, 100f / 100f)), 2); + + [NestedConfig("My Effects Border", 35, collapseWith = nameof(CropIcon), collapsingHeader = false)] + public StatusEffectIconBorderConfig OwnedBorderConfig = new(new PluginConfigColor(new Vector4(35f / 255f, 179f / 255f, 69f / 255f, 100f / 100f)), 1); + + [NestedConfig("Duration", 50)] + public LabelConfig DurationLabelConfig; + + [NestedConfig("Stacks", 60)] + public LabelConfig StacksLabelConfig; + + public StatusEffectIconConfig(LabelConfig? durationLabelConfig = null, LabelConfig? stacksLabelConfig = null) + { + DurationLabelConfig = durationLabelConfig ?? StatusEffectsListsDefaults.DefaultDurationLabelConfig(); + StacksLabelConfig = stacksLabelConfig ?? StatusEffectsListsDefaults.DefaultStacksLabelConfig(); + } + } + + [Exportable(false)] + public class StatusEffectIconBorderConfig : PluginConfigObject + { + [ColorEdit4("Color")] + [Order(5)] + public PluginConfigColor Color = new(Vector4.UnitW); + + [DragInt("Thickness", min = 1, max = 100)] + [Order(10)] + public int Thickness = 1; + + public StatusEffectIconBorderConfig() + { + } + + public StatusEffectIconBorderConfig(PluginConfigColor color, int thickness) + { + Color = color; + Thickness = thickness; + } + } + + internal class StatusEffectsListsDefaults + { + internal static LabelConfig DefaultDurationLabelConfig() + { + return new LabelConfig(Vector2.Zero, "", DrawAnchor.Center, DrawAnchor.Center); + } + + internal static LabelConfig DefaultStacksLabelConfig() + { + var config = new LabelConfig(new Vector2(16, -11), "", DrawAnchor.Center, DrawAnchor.Center); + config.Color = new(Vector4.UnitW); + config.OutlineColor = new(Vector4.One); + config.ShadowConfig.Color = new(Vector4.One); + + return config; + } + } + + public enum FilterType + { + Blacklist, + Whitelist + } + + [Exportable(false)] + public class StatusEffectsBlacklistConfig : PluginConfigObject + { + [RadioSelector(typeof(FilterType))] + [Order(5)] + public FilterType FilterType; + + public SortedList List = new SortedList(); + + [JsonIgnore] private string? _errorMessage = null; + [JsonIgnore] private string? _importString = null; + [JsonIgnore] private bool _clearingList = false; + + private string KeyName(Status status) + { + return $"{status.Name}[{status.RowId.ToString()}]"; + } + + public bool StatusAllowed(Status status) + { + bool inList = List.Any(pair => pair.Value == status.RowId); + if ((inList && FilterType == FilterType.Blacklist) || (!inList && FilterType == FilterType.Whitelist)) + { + return false; + } + + return true; + } + + public bool AddNewEntry(Status? status) + { + if (status != null && status.HasValue && !List.ContainsKey(KeyName(status.Value))) + { + List.Add(KeyName(status.Value), status.Value.RowId); + _input = ""; + + return true; + } + + return false; + } + + private bool AddNewEntry(string input, ExcelSheet? sheet) + { + if (input.Length > 0 && sheet != null) + { + List statusToAdd = new List(); + + // try id + if (uint.TryParse(input, out uint uintValue)) + { + if (uintValue > 0) + { + Status? status = sheet.GetRow(uintValue); + if (status != null && status.HasValue) + { + statusToAdd.Add(status.Value); + } + } + } + + // try name + if (statusToAdd.Count == 0) + { + var enumerator = sheet.GetEnumerator(); + + while (enumerator.MoveNext()) + { + Status item = enumerator.Current; + if (item.Name.ToString().ToLower() == input.ToLower()) + { + statusToAdd.Add(item); + } + } + } + + bool added = false; + foreach (Status status in statusToAdd) + { + added |= AddNewEntry(status); + } + return added; + } + + return false; + } + + private string ExportList() + { + string exportString = ""; + + for (int i = 0; i < List.Keys.Count; i++) + { + exportString += List.Keys[i] + "|"; + exportString += List.Values[i] + "|"; + } + + return exportString; + } + + private string? ImportList(string importString) + { + SortedList tmpList = new SortedList(); + + try + { + string[] strings = importString.Trim().Split("|", StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < strings.Length; i += 2) + { + if (i + 1 >= strings.Length) + { + break; + } + + string key = strings[i]; + uint value = uint.Parse(strings[i + 1]); + + tmpList.Add(key, value); + } + } + catch + { + return "Error importing list!"; + } + + List = tmpList; + return null; + } + + [JsonIgnore] + private string _input = ""; + + [ManualDraw] + public bool Draw(ref bool changed) + { + if (!Enabled) + { + return false; + } + + var flags = + ImGuiTableFlags.RowBg | + ImGuiTableFlags.Borders | + ImGuiTableFlags.BordersOuter | + ImGuiTableFlags.BordersInner | + ImGuiTableFlags.ScrollY | + ImGuiTableFlags.SizingFixedSame; + + var sheet = Plugin.DataManager.GetExcelSheet(); + var iconSize = new Vector2(30, 30); + var indexToRemove = -1; + + if (ImGui.BeginChild("Filter Effects", new Vector2(0, 360), false, ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + ImGui.Text(" "); + ImGui.SameLine(); + ImGui.Text("Type an ID or Name"); + + ImGui.Text(" "); + ImGui.SameLine(); + ImGui.PushItemWidth(300); + if (ImGui.InputText("", ref _input, 64, ImGuiInputTextFlags.EnterReturnsTrue)) + { + changed |= AddNewEntry(_input, sheet); + ImGui.SetKeyboardFocusHere(-1); + } + + // add + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), new Vector2(0, 0))) + { + changed |= AddNewEntry(_input, sheet); + ImGui.SetKeyboardFocusHere(-2); + } + + // export + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 154); + if (ImGui.Button(FontAwesomeIcon.Upload.ToIconString(), new Vector2(0, 0))) + { + ImGui.SetClipboardText(ExportList()); + ImGui.OpenPopup("export_succes_popup"); + } + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Export List to Clipboard"); + + // export success popup + if (ImGui.BeginPopup("export_succes_popup")) + { + ImGui.Text("List exported to clipboard!"); + ImGui.EndPopup(); + } + + // import + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Download.ToIconString(), new Vector2(0, 0))) + { + _importString = ImGui.GetClipboardText(); + } + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Import List from Clipboard"); + + // clear + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button(FontAwesomeIcon.Trash.ToIconString(), new Vector2(0, 0))) + { + _clearingList = true; + } + ImGui.PopFont(); + ImGuiHelper.SetTooltip("Clear List"); + + ImGui.Text(" "); + ImGui.SameLine(); + + if (ImGui.BeginTable("table", 4, flags, new Vector2(583, List.Count > 0 ? 200 : 40))) + { + ImGui.TableSetupColumn("Icon", ImGuiTableColumnFlags.WidthFixed, 0, 0); + ImGui.TableSetupColumn("ID", ImGuiTableColumnFlags.WidthFixed, 0, 1); + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch, 0, 2); + ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 0, 3); + + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + + for (int i = 0; i < List.Count; i++) + { + var id = List.Values[i]; + var name = List.Keys[i]; + var row = sheet?.GetRow(id); + + if (_input != "" && !name.ToUpper().Contains(_input.ToUpper())) + { + continue; + } + + ImGui.PushID(i.ToString()); + ImGui.TableNextRow(ImGuiTableRowFlags.None, iconSize.Y); + + // icon + if (ImGui.TableSetColumnIndex(0)) + { + if (row != null) + { + DrawHelper.DrawIcon(row, ImGui.GetCursorPos(), iconSize, false, true); + } + } + + // id + if (ImGui.TableSetColumnIndex(1)) + { + ImGui.Text(id.ToString()); + } + + // name + if (ImGui.TableSetColumnIndex(2)) + { + ImGui.Text(row != null && row.HasValue ? row.Value.Name.ToString() : name); + } + + // remove + if (ImGui.TableSetColumnIndex(3)) + { + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Button, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, Vector4.Zero); + if (ImGui.Button(FontAwesomeIcon.Trash.ToIconString(), iconSize)) + { + changed = true; + indexToRemove = i; + } + ImGui.PopFont(); + ImGui.PopStyleColor(3); + } + ImGui.PopID(); + } + + ImGui.EndTable(); + } + ImGui.Text(" "); + ImGui.SameLine(); + ImGui.Text("Tip: You can [Ctrl + Alt + Shift] + Left Click on a status effect to automatically add it to the list."); + + } + + if (indexToRemove >= 0) + { + List.RemoveAt(indexToRemove); + } + + ImGui.EndChild(); + + // error message + if (_errorMessage != null) + { + if (ImGuiHelper.DrawErrorModal(_errorMessage)) + { + _errorMessage = null; + } + } + + // import confirmation + if (_importString != null) + { + string[] message = new string[] { + "All the elements in the list will be replaced.", + "Are you sure you want to import?" + }; + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Import?", message); + + if (didConfirm) + { + _errorMessage = ImportList(_importString); + changed = true; + } + + if (didConfirm || didClose) + { + _importString = null; + } + } + + // clear confirmation + if (_clearingList) + { + string message = "Are you sure you want to clear the list?"; + + var (didConfirm, didClose) = ImGuiHelper.DrawConfirmationModal("Clear List?", message); + + if (didConfirm) + { + List.Clear(); + changed = true; + } + + if (didConfirm || didClose) + { + _clearingList = false; + } + } + + return false; + } + } + + + public class StatusEffectsBlacklistConfigConverter : PluginConfigObjectConverter + { + public StatusEffectsBlacklistConfigConverter() + { + NewTypeFieldConverter converter; + converter = new NewTypeFieldConverter("FilterType", FilterType.Blacklist, (oldValue) => + { + return oldValue ? FilterType.Whitelist : FilterType.Blacklist; + }); + + FieldConvertersMap.Add("UseAsWhitelist", converter); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(StatusEffectsBlacklistConfig); + } + } + + [Section("Buffs and Debuffs")] + [SubSection("Custom Effects", 0)] + public class CustomEffectsListConfig : StatusEffectsListConfig + { + public new static CustomEffectsListConfig DefaultConfig() + { + var iconConfig = new StatusEffectIconConfig(); + iconConfig.DispellableBorderConfig.Enabled = false; + iconConfig.Size = new Vector2(30, 30); + + var pos = new Vector2(0, HUDConstants.BaseHUDOffsetY); + var size = new Vector2(250, iconConfig.Size.Y * 3 + 10); + + var config = new CustomEffectsListConfig(pos, size, true, true, false, GrowthDirections.Centered | GrowthDirections.Up, iconConfig); + config.Enabled = false; + config.Directions = 5; + + // pre-populated white list + config.BlacklistConfig.FilterType = FilterType.Whitelist; + + ExcelSheet? sheet = Plugin.DataManager.GetExcelSheet(); + if (sheet != null) + { + // Left Eye + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1184)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1454)); + + // Battle Litany + config.BlacklistConfig.AddNewEntry(sheet.GetRow(786)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1414)); + + // Brotherhood + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1185)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2174)); + + // Battle Voice + config.BlacklistConfig.AddNewEntry(sheet.GetRow(141)); + + // Devilment + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1825)); + + // Technical Finish + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1822)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2050)); + + // Standard Finish + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1821)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2024)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2105)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2113)); + + // Embolden + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1239)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1297)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2282)); + + // Devotion + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1213)); + + // Divination + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1878)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2034)); + + // Chain Stratagem + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1221)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1406)); + + // Radiant Finale + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2722)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2964)); + + // Arcane Circle + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2599)); + + // Searing Light + config.BlacklistConfig.AddNewEntry(sheet.GetRow(2703)); + + // Trick Attack + config.BlacklistConfig.AddNewEntry(sheet.GetRow(638)); + + // ------ AST Card Buffs ------- + // The Balance + config.BlacklistConfig.AddNewEntry(sheet.GetRow(829)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1338)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1882)); + + // The Bole + config.BlacklistConfig.AddNewEntry(sheet.GetRow(830)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1339)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1883)); + + // The Arrow + config.BlacklistConfig.AddNewEntry(sheet.GetRow(831)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1884)); + + // The Spear + config.BlacklistConfig.AddNewEntry(sheet.GetRow(832)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1885)); + + // The Ewer + config.BlacklistConfig.AddNewEntry(sheet.GetRow(833)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1340)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1886)); + + // The Spire + config.BlacklistConfig.AddNewEntry(sheet.GetRow(834)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1341)); + config.BlacklistConfig.AddNewEntry(sheet.GetRow(1887)); + } + + return config; + } + + public CustomEffectsListConfig(Vector2 position, Vector2 size, bool showBuffs, bool showDebuffs, bool showPermanentEffects, + GrowthDirections growthDirections, StatusEffectIconConfig iconConfig) + : base(position, size, showBuffs, showDebuffs, showPermanentEffects, growthDirections, iconConfig) + { + } + } + + public enum StatusEffectDurationSortType + { + Ascending, + Descending + } +} \ No newline at end of file diff --git a/Interface/StatusEffects/StatusEffectsListHud.cs b/Interface/StatusEffects/StatusEffectsListHud.cs new file mode 100644 index 0000000..075a490 --- /dev/null +++ b/Interface/StatusEffects/StatusEffectsListHud.cs @@ -0,0 +1,589 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Utility; +using HSUI.Config; +using HSUI.Enums; +using HSUI.Helpers; +using HSUI.Interface.GeneralElements; +using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.Game; +using LuminaStatus = Lumina.Excel.Sheets.Status; +using StatusStruct = FFXIVClientStructs.FFXIV.Client.Game.Status; + +namespace HSUI.Interface.StatusEffects +{ + public class StatusEffectsListHud : ParentAnchoredDraggableHudElement, IHudElementWithActor, IHudElementWithAnchorableParent, IHudElementWithPreview, IHudElementWithMouseOver, IHudElementWithVisibilityConfig + { + protected StatusEffectsListConfig Config => (StatusEffectsListConfig)_config; + public VisibilityConfig? VisibilityConfig => Config is UnitFrameStatusEffectsListConfig config ? config.VisibilityConfig : null; + + private LayoutInfo _layoutInfo; + + internal static int StatusEffectListsSize = 60; + private StatusStruct[]? _fakeEffects = null; + + private LabelHud _durationLabel; + private LabelHud _stacksLabel; + public IGameObject? Actor { get; set; } = null; + + private bool _wasHovering = false; + private bool NeedsSpecialInput => !ClipRectsHelper.Instance.Enabled || ClipRectsHelper.Instance.Mode == WindowClippingMode.Performance; + + protected override bool AnchorToParent => Config is UnitFrameStatusEffectsListConfig config ? config.AnchorToUnitFrame : false; + protected override DrawAnchor ParentAnchor => Config is UnitFrameStatusEffectsListConfig config ? config.UnitFrameAnchor : DrawAnchor.Center; + + public StatusEffectsListHud(StatusEffectsListConfig config, string? displayName = null) : base(config, displayName) + { + _config.ValueChangeEvent += OnConfigPropertyChanged; + + _durationLabel = new LabelHud(config.IconConfig.DurationLabelConfig); + _stacksLabel = new LabelHud(config.IconConfig.StacksLabelConfig); + + UpdatePreview(); + } + + ~StatusEffectsListHud() + { + _config.ValueChangeEvent -= OnConfigPropertyChanged; + } + + public void StopPreview() + { + Config.Preview = false; + UpdatePreview(); + } + + protected override (List, List) ChildrenPositionsAndSizes() + { + Vector2 pos = LayoutHelper.CalculateStartPosition(Config.Position, Config.Size, LayoutHelper.GrowthDirectionsFromIndex(Config.Directions)); + return (new List() { pos + Config.Size / 2f }, new List() { Config.Size }); + } + + public void StopMouseover() + { + if (_wasHovering && NeedsSpecialInput) + { + InputsHelper.Instance.StopHandlingInputs(); + _wasHovering = false; + } + } + + private uint CalculateLayout(List list) + { + var effectCount = (uint)list.Count; + var count = Config.Limit >= 0 ? Math.Min((uint)Config.Limit, effectCount) : effectCount; + + if (count <= 0) + { + return 0; + } + + _layoutInfo = LayoutHelper.CalculateLayout( + Config.Size, + Config.IconConfig.Size, + count, + Config.IconPadding, + LayoutHelper.GetFillsRowsFirst(Config.FillRowsFirst, LayoutHelper.GrowthDirectionsFromIndex(Config.Directions)) + ); + + return count; + } + + protected string GetStatusActorName(StatusStruct status) + { + var character = Plugin.ObjectTable.SearchById(status.SourceObject.Id); + return character == null ? "" : character.Name.ToString(); + } + + protected virtual List StatusEffectsData() + { + var list = StatusEffectDataList(Actor); + + // sort by duration + if (Config.SortByDuration) + { + list.Sort((a, b) => + { + float aTime = a.Data.IsPermanent || a.Data.IsFcBuff ? float.MaxValue : a.Status.RemainingTime; + float bTime = b.Data.IsPermanent || b.Data.IsFcBuff ? float.MaxValue : b.Status.RemainingTime; + + if (Config.DurationSortType == StatusEffectDurationSortType.Ascending) + { + return aTime.CompareTo(bTime); + } + else + { + return bTime.CompareTo(aTime); + } + }); + } + // show mine or permanent first + else if (Config.ShowMineFirst || Config.ShowPermanentFirst) + { + return OrderByMineOrPermanentFirst(list); + } + + return list; + } + + protected unsafe List StatusEffectDataList(IGameObject? actor) + { + List list = new List(); + IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer; + IBattleChara? character = null; + int count = StatusEffectListsSize; + + if (_fakeEffects == null) + { + if (actor == null || actor is not IBattleChara battleChara) + { + return list; + } + + if (Config.HideWhenDead && (battleChara.IsDead || battleChara.CurrentHp <= 0)) + { + return list; + } + + character = (IBattleChara)actor; + + try + { + count = Math.Min(count, character.StatusList.Length); + } + catch { } + } + else + { + count = Config.Limit == -1 ? _fakeEffects.Length : Math.Min(Config.Limit, _fakeEffects.Length); + } + + for (int i = 0; i < count; i++) + { + // status + StatusStruct* status = null; + + if (_fakeEffects != null) + { + var fakeStruct = _fakeEffects![i]; + status = &fakeStruct; + } + else + { + try + { + status = character?.StatusList[i] == null ? null : (StatusStruct*)character.StatusList[i]!.Address; + } + catch { } + } + + if (status == null || status->StatusId == 0) + { + continue; + } + + // data + LuminaStatus? data = null; + + if (_fakeEffects != null) + { + data = Plugin.DataManager.GetExcelSheet()?.GetRow(status->StatusId); + } + else + { + try + { + data = character?.StatusList[i]?.GameData.Value; + } catch { } + } + + if (data == null || !data.HasValue) + { + continue; + } + + // filter "invisible" status effects + if (data.Value.Icon == 0 || data.Value.Name.ToString().Length == 0) + { + continue; + } + + // dont filter anything on preview mode + if (_fakeEffects != null) + { + list.Add(new StatusEffectData(*status, data.Value)); + continue; + } + + // buffs + if (!Config.ShowBuffs && data.Value.StatusCategory == 1) + { + continue; + } + + // debuffs + if (!Config.ShowDebuffs && data.Value.StatusCategory != 1) + { + continue; + } + + // permanent + if (!Config.ShowPermanentEffects && data.Value.IsPermanent) + { + continue; + } + + // only mine + var mine = player?.GameObjectId == status->SourceObject.Id; + + if (Config.IncludePetAsOwn) + { + mine = player?.GameObjectId == status->SourceObject.Id || IsStatusFromPlayerPet(*status); + } + + if (Config.ShowOnlyMine && !mine) + { + continue; + } + + // blacklist + if (Config.BlacklistConfig.Enabled && !Config.BlacklistConfig.StatusAllowed(data.Value)) + { + continue; + } + + list.Add(new StatusEffectData(*status, data.Value)); + } + + return list; + } + + protected bool IsStatusFromPlayerPet(StatusStruct status) + { + var buddy = Plugin.BuddyList.PetBuddy; + + if (buddy == null) + { + return false; + } + + return buddy.EntityId == status.SourceObject.Id; + } + + protected List OrderByMineOrPermanentFirst(List list) + { + var player = Plugin.ObjectTable.LocalPlayer; + if (player == null) + { + return list; + } + + if (Config.ShowMineFirst && Config.ShowPermanentFirst) + { + return list.OrderByDescending(x => x.Status.SourceObject.Id == player.GameObjectId && x.Data.IsPermanent || x.Data.IsFcBuff) + .ThenByDescending(x => x.Status.SourceObject.Id == player.GameObjectId) + .ThenByDescending(x => x.Data.IsPermanent) + .ThenByDescending(x => x.Data.IsFcBuff) + .ToList(); + } + else if (Config.ShowMineFirst && !Config.ShowPermanentFirst) + { + return list.OrderByDescending(x => x.Status.SourceObject.Id == player.GameObjectId) + .ToList(); + } + else if (!Config.ShowMineFirst && Config.ShowPermanentFirst) + { + return list.OrderByDescending(x => x.Data.IsPermanent) + .ThenByDescending(x => x.Data.IsFcBuff) + .ToList(); + } + + return list; + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled) + { + return; + } + + if (_fakeEffects == null && (Actor == null || Actor.ObjectKind != ObjectKind.Player && Actor.ObjectKind != ObjectKind.BattleNpc)) + { + return; + } + + // calculate layout + List list = StatusEffectsData(); + + // area + GrowthDirections growthDirections = LayoutHelper.GrowthDirectionsFromIndex(Config.Directions); + Vector2 position = origin + GetAnchoredPosition(Config.Position, Config.Size, DrawAnchor.TopLeft); + Vector2 areaPos = LayoutHelper.CalculateStartPosition(position, Config.Size, growthDirections); + Vector2 margin = new Vector2(14, 10); + + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + + // no need to do anything else if there are no effects + if (list.Count == 0) + { + if (_wasHovering && NeedsSpecialInput) + { + _wasHovering = false; + InputsHelper.Instance.StopHandlingInputs(); + } + + return; + } + + // calculate icon positions + uint count = CalculateLayout(list); + var (iconPositions, minPos, maxPos) = LayoutHelper.CalculateIconPositions( + growthDirections, + count, + position, + Config.Size, + Config.IconConfig.Size, + Config.IconPadding, + LayoutHelper.GetFillsRowsFirst(Config.FillRowsFirst, growthDirections), + _layoutInfo + ); + + // window + // imgui clips the left and right borders inside windows for some reason + // we make the window bigger so the actual drawable size is the expected one + Vector2 windowPos = minPos - margin; + Vector2 windowSize = maxPos - minPos; + + AddDrawAction(Config.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID, windowPos, windowSize + margin * 2, !Config.DisableInteraction, (drawList) => + { + // area + if (Config.Preview) + { + drawList.AddRectFilled(areaPos, areaPos + Config.Size, 0x88000000); + } + + for (var i = 0; i < count; i++) + { + Vector2 iconPos = iconPositions[i]; + var statusEffectData = list[i]; + + // shadow + if (Config.IconConfig.ShadowConfig! != null && Config.IconConfig.ShadowConfig.Enabled) + { + // Right Side + drawList.AddRectFilled(iconPos + new Vector2(Config.IconConfig.Size.X, Config.IconConfig.ShadowConfig.Offset), iconPos + Config.IconConfig.Size + new Vector2(Config.IconConfig.ShadowConfig.Offset, Config.IconConfig.ShadowConfig.Offset) + new Vector2(Config.IconConfig.ShadowConfig.Thickness - 1, Config.IconConfig.ShadowConfig.Thickness - 1), Config.IconConfig.ShadowConfig.Color.Base); + + // Bottom Size + drawList.AddRectFilled(iconPos + new Vector2(Config.IconConfig.ShadowConfig.Offset, Config.IconConfig.Size.Y), iconPos + Config.IconConfig.Size + new Vector2(Config.IconConfig.ShadowConfig.Offset, Config.IconConfig.ShadowConfig.Offset) + new Vector2(Config.IconConfig.ShadowConfig.Thickness - 1, Config.IconConfig.ShadowConfig.Thickness - 1), Config.IconConfig.ShadowConfig.Color.Base); + } + + // icon + var cropIcon = Config.IconConfig.CropIcon; + int stackCount = cropIcon ? 1 : statusEffectData.Data.MaxStacks > 0 ? statusEffectData.Status.Param : 0; + DrawHelper.DrawIcon(drawList, statusEffectData.Data, iconPos, Config.IconConfig.Size, false, cropIcon, stackCount); + + // border + var borderConfig = GetBorderConfig(statusEffectData); + if (borderConfig != null && cropIcon) + { + drawList.AddRect(iconPos, iconPos + Config.IconConfig.Size, borderConfig.Color.Base, 0, ImDrawFlags.None, borderConfig.Thickness); + } + + // Draw dispell indicator above dispellable status effect on uncropped icons + if (borderConfig != null && !cropIcon && statusEffectData.Data.CanDispel) + { + var dispellIndicatorColor = new Vector4(141f / 255f, 206f / 255f, 229f / 255f, 100f / 100f); + // 24x32 + drawList.AddRectFilled( + iconPos + new Vector2(Config.IconConfig.Size.X * .07f, Config.IconConfig.Size.Y * .07f), + iconPos + new Vector2(Config.IconConfig.Size.X * .93f, Config.IconConfig.Size.Y * .14f), + ImGui.ColorConvertFloat4ToU32(dispellIndicatorColor), + 8f + ); + } + } + }); + }); + + StatusEffectData? hoveringData = null; + IGameObject? character = Actor; + + // labels need to be drawn separated since they have their own window for clipping + for (var i = 0; i < count; i++) + { + Vector2 iconPos = iconPositions[i]; + StatusEffectData statusEffectData = list[i]; + + // duration + if (Config.IconConfig.DurationLabelConfig.Enabled && + !statusEffectData.Data.IsPermanent && + !statusEffectData.Data.IsFcBuff) + { + AddDrawAction(Config.IconConfig.DurationLabelConfig.StrataLevel, () => + { + double duration = Math.Round(Math.Abs(statusEffectData.Status.RemainingTime)); + Config.IconConfig.DurationLabelConfig.SetText(Utils.DurationToString(duration)); + _durationLabel.Draw(iconPos, Config.IconConfig.Size, character); + }); + } + + // stacks + if (Config.IconConfig.StacksLabelConfig.Enabled && + statusEffectData.Data.MaxStacks > 0 && + statusEffectData.Status.Param > 0 && + !statusEffectData.Data.IsFcBuff) + { + AddDrawAction(Config.IconConfig.StacksLabelConfig.StrataLevel, () => + { + Config.IconConfig.StacksLabelConfig.SetText($"{statusEffectData.Status.Param}"); + _stacksLabel.Draw(iconPos, Config.IconConfig.Size, character); + }); + } + + // tooltips / interaction + if (ImGui.IsMouseHoveringRect(iconPos, iconPos + Config.IconConfig.Size)) + { + hoveringData = statusEffectData; + } + } + + if (hoveringData.HasValue) + { + StatusEffectData data = hoveringData.Value; + + if (NeedsSpecialInput) + { + _wasHovering = true; + InputsHelper.Instance.StartHandlingInputs(); + } + + // tooltip + if (Config.ShowTooltips) + { + TooltipsHelper.Instance.ShowTooltipOnCursor( + EncryptedStringsHelper.GetString(data.Data.Description.ToDalamudString().ToString()), + EncryptedStringsHelper.GetString(data.Data.Name.ToString()), + data.Status.StatusId, + GetStatusActorName(data.Status) + ); + } + + bool leftClick = InputsHelper.Instance.HandlingMouseInputs ? InputsHelper.Instance.LeftButtonClicked : ImGui.GetIO().MouseClicked[0]; + bool rightClick = InputsHelper.Instance.HandlingMouseInputs ? InputsHelper.Instance.RightButtonClicked : ImGui.GetIO().MouseClicked[1]; + + // remove buff on right click + bool isFromPlayer = data.Status.SourceObject.Id == Plugin.ObjectTable.LocalPlayer?.GameObjectId; + bool isTheEcho = data.Status.SourceObject.Id is 42 or 239; + + if (data.Data.StatusCategory == 1 && (isFromPlayer || isTheEcho) && rightClick) + { + StatusManager.ExecuteStatusOff(data.Status.StatusId, data.Status.SourceObject.ObjectId); + + if (NeedsSpecialInput) + { + _wasHovering = false; + InputsHelper.Instance.StopHandlingInputs(); + } + } + + // automatic add to black list with ctrl+alt+shift click + if (Config.BlacklistConfig.Enabled && + ImGui.GetIO().KeyCtrl && ImGui.GetIO().KeyAlt && ImGui.GetIO().KeyShift && leftClick) + { + Config.BlacklistConfig.AddNewEntry(data.Data); + ConfigurationManager.Instance.ForceNeedsSave(); + + if (NeedsSpecialInput) + { + _wasHovering = false; + InputsHelper.Instance.StopHandlingInputs(); + } + } + } + else if (_wasHovering && NeedsSpecialInput) + { + _wasHovering = false; + InputsHelper.Instance.StopHandlingInputs(); + } + } + + public StatusEffectIconBorderConfig? GetBorderConfig(StatusEffectData statusEffectData) + { + StatusEffectIconBorderConfig? borderConfig = null; + + bool isFromPlayerPet = false; + if (Config.IncludePetAsOwn) + { + isFromPlayerPet = IsStatusFromPlayerPet(statusEffectData.Status); + } + + if (Config.IconConfig.OwnedBorderConfig.Enabled && (statusEffectData.Status.SourceObject.Id == Plugin.ObjectTable.LocalPlayer?.GameObjectId || isFromPlayerPet)) + { + borderConfig = Config.IconConfig.OwnedBorderConfig; + } + else if (Config.IconConfig.DispellableBorderConfig.Enabled && statusEffectData.Data.CanDispel) + { + borderConfig = Config.IconConfig.DispellableBorderConfig; + } + else if (Config.IconConfig.BorderConfig.Enabled) + { + borderConfig = Config.IconConfig.BorderConfig; + } + + return borderConfig; + } + + private void OnConfigPropertyChanged(object? sender, OnChangeBaseArgs args) + { + if (args.PropertyName == "Preview") + { + UpdatePreview(); + } + } + + private unsafe void UpdatePreview() + { + if (!Config.Preview) + { + _fakeEffects = null; + return; + } + + var RNG = new Random((int)ImGui.GetTime()); + _fakeEffects = new StatusStruct[StatusEffectListsSize]; + + for (int i = 0; i < StatusEffectListsSize; i++) + { + var fakeStruct = new StatusStruct(); + + // forcing "triplecast" buff first to always be able to test stacks + fakeStruct.StatusId = i == 0 ? (ushort)1211 : (ushort)RNG.Next(1, 200); + fakeStruct.RemainingTime = RNG.Next(1, 30); + fakeStruct.Param = (byte)RNG.Next(1, 3); + fakeStruct.SourceObject.Id = 0; + + _fakeEffects[i] = fakeStruct; + } + } + } + + public struct StatusEffectData + { + public StatusStruct Status; + public LuminaStatus Data; + + public StatusEffectData(StatusStruct status, LuminaStatus data) + { + Status = status; + Data = data; + } + } +} diff --git a/Media/Fonts/Expressway.ttf b/Media/Fonts/Expressway.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d295ee20f8129aff1af4784be37459549523a501 GIT binary patch literal 99820 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

Y-^NPpfQ0gz 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

bOcXv zY#`}iFa(SMqd7JPOfqcv9vi;LhVQZAdu;d~o4T2|)3+E2Sn~v|c>>lv0c)P1Mi?oy z8&YUDq|k0i!4BH7gLZtC4PRx$SK07YHhh&0UuDBr+3;01H5$Z%^WXxw2ok|noEn`_6%*|fFBA!7a%yh?($kz+rw zy_xMTq(?b_jPu9YKS2uJvC($?p-toMFYP>tV?P<#*>-T;Nt(yLi|qnX$hN^(0Se&% zY3TmOA-x=^U?ku>ZF(?iRl|;5x9inGZO%Qw@z$U%Xb(DoFwhxv0o_1v?&|~kg8l%x z!s8|2@e=U0HhirOUu(nrCE#yu#N#Q%<0-`BDa7L``Xn#~Oarg-+!myEP~k#;ZA?nTGUF%Hl)&vRCbyvu z7q6O(S53yNCgW9;@v6y4q8CZ@B8grk(TgN{kvI<$=Rx8;NSp_WvmtRdB+iS(d676T z66ZzYyhxlEiSr_HUL?+o#Cef8FB0cP;=D+l7pd|fRUV|ugH(BtDi2cSL8@#>l^2Qf zAWb%;$%7<$kR&hC<3)OGNRJn(u^}}!?Xr=KmrusaCnG&xB*%v2@SYJUWZPiQSp}#d z5LAT=)j)Od0Q;>$ThJbK0AZjr=mNTdVc>c20vH3v86Kp-gA{m>0uNH)K?*!bfd?tD zAq6(1zy|ldaNYywy>Q+O$8B)jMt>%6BoN_Ofa6X$?u6q`IPQewPPpuZ%T74#G&y^c z^c48baKcF^Ty(-kCtP&GMJHTz!bPXDL{w0gfser_q5>Rr!a*k-bizR=9CX4dC!BJ^ zDJPtA!YL=5a>6MmoN~e`C!BJ^DJPtA!YL=5a>5NK)OSLCCzN+WWhWGNLSZM=bwXVy z)OA8#Clqx;Q7062LQy9abwW`m6m>#TClqx;Q7062LQy9abwVj8RB}QkClTgpWI3NU z(RJe@7B*jLM`S()IafFm#riZ+;z6#fVD@5{Z722OSZ~?|1IaYpj zjZb;|uKexx(`oK2Z9`Qza_K!T51s$6EmeUCy8>;g3bdsv(3Yy8To4tN1aOI#auTuX zWlEev$%%ZGJR?$Zv7JwPjq^op|4vIf7*sOy)ykrxT9xgZY(GY;v853S@BY1QM*j&u zAQvByix0@f2hdl<*ND5o|JZKB#^lBBz--R+7sYO&>FM>ZD|j8 z0bM~i&>cJto)H!42a2R0D3X4lNUa~o`hx*rAQ%pw2P41>U<#NDrhyqokv0>|0<*#E z^bRZ}zjuIaQ__c_ZKPbANoD&ID~AkbYo|CS+nI5s*iPD;6=-i(puJfE+014PzdZ8k zYjYN|UAEm>fqoI$_6#Pi%DYb0Ky~onv_*S!Pan`1^ascp{vsEDk&8^)fk@Q(VhiQnao{0yhy~E6^3N&s|r! z>ne9$<*uvTb(OoWa@SQxdWJLSw!HHNQd$jopKE3gHJRa{OfGnZYj}lgSn+G*SV)eA ztz?MltAM7U{D#z_CS4($u@y(5BS;w*EHD{*vRo+aPAP-9fm@u z*guUAq*bKJ_CYe~W#cq{?=*hzG`&^vP}5+?8*gx4wi_bZK5WFp2?xDi@o>YzyH{51ae2_j zSOq6ezzHv$*g~8hN1Pr@pc>>izD8SBi4?CYbS`WTog!F6JsPa(=S08cxP2)7V~wUM&Xifu-Pm#sgLu zTZqZyh?nDtm*a?+K3@V1+H$Ph3AEhT?JQp zADjIX;4C-~lDOtFed#uk3hW$nf?VSSoZSLfy>Qe^{2NF78wXc&;b<=0%!QL##JzEF za|@i@0vETy!BucDi+D5+E^Z+vjU)byBmRtoqpRTP3AlNJxAv^YHaNKrPOgTN$KWJ0 z{TQ3zWGtMFg_E(SZXF_xRZf-SUzafI@H$6oKo;aX1+ZCu8Ac zES!vmld*6z7ET_6ld*78+9MC#jD?%4;bs)vjD?%Aa8ueTsfVj?>!IZ6K+X*YL%;~| zf@#a7Zbrk=Y`D1wZZZ+Ju?($yhiU3ny2@$<=Uj zHJsc8CpW>#O>lA(oV*GrH^IqRIO&3utKsBTI2j8kV@(}BOL`t8aouHO8{FImH@Csf zShyJrH;=*1O>k4%SgE7Z&U)Z#EL@F+t1h_ef~zi5KWlSry0HnaZi1_u;Oc5qFXwW6 z3H#xsyTBe0$+^SKlXVmx4uqQr;h-H3+Toxb3n{Im2kYoTFYR#hAe=mi4xWO0c687V z=j?FK4(IG}&JO2N;hY`L+2NcW&e`Fd9nRUYp7~f#50*0z4%*?I9nKwubE$C74(IG} z>>#?A1E=h8$_}UOaLR64&M8J39I&f9X-(_`SJ~#daKa8J>~P{BoHz(44q_=iP~DDY z^g#84P}~m1?N~++`eTRUcC4cZ%jkj9b|`JfB6{#IlCpLvYsUh5uznsap9fvBWA!{( zJdZvL%rOq4Cw8c5hnjY%X@{Bzv3MS=okvWBn#_bngzD1`CzMTqva%;40o^Eoq6tvc z0Yx1py5UAQvZ1a6>L#EY1yDEv3MW9}1Sp&Ug%hCgIVhX}g%hA~0u)Yw!U<3~0o`yy z*@P0^D1gEVP}l*5W1w&X6iz77jRGj`KsU0ncG*xn0cs~e?F6WuP@)$F=!Fx#$VV?6 z=*2m7zzGKupnL+9Pk{0XP(A_5JD|J+$~({jCpzFn2b}1DvqT4+a3cX7aKjBpi4MpX zegZmB07nwwNCG&$tF}odm>lo#2b$UT$U zpNch`#{P81YZwbryMrD84y*Gywg4;y?|^p!<2)+kJn9n8e+oVWp9AVjKWjGJPE~hv zelOS$e&yIf5Csl{bAa(zH6A2@-@%{YCb4v&k%mO1ArWavL@E-Isx{>}GvH{;NIziz zW44#Gy^`%!fM;p6P3WV|MoQ978c5CmjoQqxFd zriWlX1k?i$frkM-nEIoj31|jdfX6{A(2IMY0ndVdU;r2do&!Sx?-kJRn@zuOHvPWY zNLVTomWqU>B4KH!Wqp%$3HvL^Zw=S%1(6&(42~ifPl#IhzBcG{J7P;;{OLt{3QD4! z0`&h{Nt9EF{udw}*UZ?mE!#4t>_#f15gAL4h0EiCFZzJvMiHFyLh%A9UI4`lptu)` zd!e!y>Uz;Z87F$tzd|TmV8)0^?B{a)DkZPV$YU-t?1=P?> ztqQ2sHEMN@Zv`6AljO5<4&KHJL{}W>M-aLZMC}~tMWAWjlF)->wo||r+R?r^j<Y zjJzGlZxB>ey zK5##fu^8{2AiF`xZV<8?gzN?(yFtiq5V9MD>;@sbLC9_pvKxf#2Eid2h06$BM&Huf zW$AB{5921tVo-^dlMy(cNLY9zr`=a5-&v(FGUhBMG`MX5-&wU&8<*y4Kld~nOuWR zZiRwdq25*~w-u^wEs2TNK&`FBK9R&ek;FccQ0){{+loAvjcpv%cq=vDN{zQt<2BUS zL2b8E(>2KDDQbF(uVUKJM;}UCp#!5Aacsx4oj{soj>=@AX<5`bM_eU_*Fj6akd~#8Iu}ysLh77HoeOzF9&dN{CvqA927wW@{0@=2fb8FM zv+rfQ01%~#YoG{R=iKk4e}WsJmCwAgS%cQjLu)s*c4M*gvDW#}+YP)~N{VRD^XZ!a7Mh6=7|Pur@_} ziTBsBl!3Hz<#XP}DIG5<9V4kuk3wxU;P1vsE^{sgWD%|LHkwj|rc?qo!DHZC z#x?#rX0pXNq5cTAf}g<8-~iYC591~3AKZHb+@d!CO{F(BkKWik4UMHYHjm!eJbGXA z=zYzj_cf2+*F0@F;H@%k1mMjwdSCPCea)lyHBXacEE~vcGmv8|$4TY*iX2;!<0z$L zC}qb_D$rvf$54VvA0Yjwv6J3h*9Y_k{Q-Q?2ZJG??6}G(_T|`$95Y$VcIlW2Z!*#g znny2a9zCFWd?93|2eUk2WCkuXe=Fz-dU32bDQ~U7>8LV&|50KE`@aC$_fHE2&POq( z62%DFMJ&-tEN-Isjotw&>0F&6B z&hbTz0W1dZfu-Pm@c;GL0MVaHe-iJUdQ~Do6)y75saIVKzNLR^9as-GfbYP)-UKQg zH`v2H`vBU2R7N3{QR*RZ1RMh=0CZA$XOOX>D8`1O7#oUGFMx|65tMza=>LCX2vJCQ zddV0sTE_hQ^xws5DgO)D1r?!~ry`GVwWEP4;n6Z8gn5UjHsU3H_Y zZgkX*j!Lg3+Y^C2B?BFmUMd6cw3nx4ps#M87Ko0z(NF1}_M@ZHGiC7PB%U0IuDa1x zH##akQwF67L{Hr4i5op}qbF|k#EtyBk$*Sx??(RJ$iEx;cO(C9di@!(%r* zcEe*gJa)rlHW;; z8Bs(TQIy6_q!C4=5hd!Ej0<@w%{59x`;Ul7Ms~!7lnDOtHe5}fETc6Uo%Lsbq?88U zXe&EbbZ2}>j_m(+e8?At{loZ>86hMPAtVrG$@fANh!7Hp5E6(G612;}22$|=_YsYz zQO-1|Hyi35rmPo;)smpxWy+cc_3BXGG$?r3jB-Y@J&tluAby)-JV&&cMs%1)beIN( zm`Rd6Dw5lHa+4z!Nw;&8IrU(qLB>jDa^S8w?uz3-pIqnL^J03bOP??A_{-I1-4dxA zUIr`%?}4S@eIt>&B~rIU>Xyg|+0(oc+XM6jy}>|ZI{FnZh8Wew^Z39o*PvPiOIQkThK82%C;pkI1`V@{nh2u}At2e+c zW4dNBs%w5A0F(z6!D=HMn?9YWWjcBnjxC>#?uDa!;aW839i%Q$01P7>{R>C`!m-iQ z(ZO(R^mKGE932ct2gCLDpaTd4ok17S4fN)^KAF8%T`WcRXhGS=^ zqod*IXE=6sIyxGTj)v<8p>-7nOQBpOLQSB}atX<~27c$Q)nHH=3sRNn{uwOA>!b^a zUKg_c4*GQtTmg2#IDszXa+zBexfPIG9=YX^TMoJ9kXsJ9WszGBxn+@C4!ISOTNb%x zky{qIWszGJx#f^s7T@9f%H@{N<#YQ4V~=tfy;V#J(FN~Oiq7cs7+O$$Xc<)F&Cht= zH_GR&lu82WCwg*uW;>T$|m0mm+gZnw(^aaxlgto9L5oj%aNyeN*F`Whx0EzA8RSy zdA?D6&X^~jXQXmUae*rc!vZ}HP?w$}zqpvXRN?6_Q=dRe@Uk$BD%8tQQAG>t7Kr9oQfe1Fu|7Xi z$NQ*bD0K{^q!X!UPwH8L(uOI0iVL7;lrpN=gDrUsYcz^_m!sYmO5Xz7UgSH7a>T&$ zJAy#!E59Y^#aQce#w9V7RDMG+1xnAQS7@QJmYN-+W>M7aocNXVk&M(uara?3a|8|? zrw{Lhv7cJzP|GvaGMQSQP(FggmyI)U@eEvyrZ=I!!GCnY`ae$(`$}lO7unbcMSb#e z5*eui7mh)P)6n1?bz2A*CQ!G<)a^z1m_psIDq|U+8_(GJWa9_wbW%xREH4Wg_2*7s zlo5aqgy2Wok&Yvs0Hz>A8Z~wE{9NwwVpn}{67A)_bSUiQo&xUS8$slQH;RyvPm%me zMDX+~QaWY=rqsu{ZztSK3b)BSmrCf0FyxQer99ndEbpXP+gnGf>_GJ)D%( z0X?$e|MQ$lF!plvDn|=AT1Xv|@ICp=sne7Dd!f&=uX8MM$#}|0ztrt-8kPz_zN!sG z`Vs|p4j!C9vMM9d1CaLEyWW$MV@7x0lhSDmRps8t(d0_jhA0J97OtW!R2fFF>x}K&~G_uHS}o^P${}P%as{UWi86nZZA*WZN;gVUxqthZkx5Da?MEp}e#RE#BIP!JlvbdQ_w_SlOor5|Kxq8 zKPXu8GdA5RG56g*UV8q&PmPFwzSc(%Lq@3P51p6uklyq6_hl)iYT4V$*6z;n(&yYc z^3Pj7%KNUnlPhvjmVQ_NIUlH5c(1FF%76U%?({ve>~sHeW6+&_V;I)*KmP8PwX$6Q zkGI`EX*lR97Y2QA@{r-goBh{6ga0^XtS`OXpf}#Q#5&2CXdE*5E(=>$iSHq{81xIk z_lmwNc@oksX<^g=9#hU=E;+aM-qbC9#=oaVE}qH!@y5)*{fzI?=kJXclxnFl)YylA z7TE1z<7ZwKUMg3b*%mb3T zzNg-K_PwwCZ;oP9&@|Ta?Lz^*OD$IkrT?2eeAgfaPBSOd&LIWJ#DDxr`y|1fU9`*u z-tRyB=vnw5em;8sU*z%c9#UeTZ1Ox_&9todxHkCCF=ODJ?Xp`X8Z2e^S<|AF=qldY z{1LAS-bBCsGo}?!Hom{T>$|4M-TVJ(udLPj`|EtthD=uB=~s=#Mz+ziI7jaJeo{k< z(>S-@*hB1Gdi3$qEtyI?Lxd)!=sVU+`PQM#&)9Hhhwpg)@mIpTduZg}-+FIO`y>(1 znvdU2l~T%2O5K^i%gK1P^bE1tKm4$Jr~cs*LCjh5E457qUl3A)IBW}V{bBD+ug;O3 zrC0tnH5!#3p$+r5zyG`S{@bjCvFl#vj5q%78UI;FjWx6z@A?^0f2AD#{rr4(M+{Y5a2UC)2JJ)Gf#ONouC?9c=;Mud?YERN%CpV$Z62P~Z{9ixOjW1?uFauFf<{^?Z5ItbdJyge> zdx-baS?Y@s%)j`MIqOhEbJn3pm~Ut&ImtPOnli)CA?6{HGbuLb9hQr<@e^4d=N*=0 z=0LFV4ofSMM%$(}vn9KDCS%il2mBjLC*~RAe`4}4KMe7doD*4eHD?;?Zq79Hv^mpI z4@J%~)Z3h6=vi})q5kF^Lj%k?h6b8*3^BtHa||)V5OWMMmoak;4K?Q&8pa$$%b2(H zW4_rLXU;J+!JJ*_MRRtcm(1CPUNL7EnrzN4G{u}#h)m(BTuZ07tyDdzk^SIqf?Qkng(sz_r7 zylTSEEO^yLIy2$b6dBBhS6gHoAV0QGv^hmZ_X?9kU6hV19M)XhvmHg$|L5CLXVp> z3O&J$LT8os=3GKgnR5wsHRlrQX3izl-JDCPmpPYEKXWdj{>&wGLm6n!A~edJMQDsU zi_ipf7NM8SS%h9TXAzoa&LR|U&LXtJoJHt+eXKrK*`$xx$1}T-oKa}AIipa7Iit`v zb4H;Z=8Qr+%^8Jun==aSF=rIoYtATiz?@O&pgE&ZlsTi&A#+Bdqvni4$IKapPMR|c zoib+>I&ID%**MX!R`ejsdBp;GE2J&gv}1XI8Ho7r!n|u1O70#0UwdaBXH{|K@v2kjHQh~1 zHw_IdO+Ygfox~-gA06X{BVsbaXtWpFNsNk!iYUmg z7+2Jff)Y233n4^MV7{krzkaU~GLAFz*UY=0d)|4q-Kx5$&aGRg-uZD9F#3*X#x{^6 z2mfD#(2S>Ww2b3FH;#X0{Et9KjN}M-Av=RK=7+2#>ViCU!8yFujpN8eD~zZ1=8ddT z)C(TH@O$hhk8lY7DgQv-9_5gzDe|JG(5NXIMNLs2bwn{5;$7OknWG#%@gdYSMOoAo z_#EPB6g5SAG{tV$9&ND)i;L-t_EBH7kNQH;7p1Jcw(_l9TVIBaIS<{@jg&|DBV0${ z9X(Qp9_i(ZeQ)0zFGYRuTh=)0l*Un~G)9}8MC{4e*@mtnB z`YmhaXY-%H4$*sAD}NJOrbE;+9io=$6t&C&Xqkidgp+scM7B4X%_X4d04#?53AUT+8VBEEhdhjbry3{GSE1)ng!xKhCZ_k%h95luxjGPh^oS zf`5`-d?JfwG5k|_N^2%h%hQx&{?eMs5?KOoUelV%QdtUbzSEk?^YT2rc~JA{!5662 z3RwZZwQL8qIS~Wjv)%>Vc^P^VHk6JZ9YSsLxRa>J~JMwS1PN9>_!}Dlo z_%5M~YZ?v=?4UJ|Ztw>OcG8+(H~6k-;UctfcWmE1LJ!v|^h6^UMvcrr{LsjSrjd!A z7$&-QVN$ple0uaUA^3YE{p>K?34Tp)a4Gzn-h_wIoADxMn)(-n-x_XpZSZn>8?m>C z+qvSLFb8}3-QjLh&JA<%D|QdKuua0eFppgKvJl=d)q2_3^D;Sq4B@s3KFkFk5R3qDejBK)K-EF%5WA@TjYgeyK9 zke09%k5o-6{oUkk`bGbY%Uf?P#!V1?YtPCq%OMKC-;_9ozYCKr22}ny= z8`ioqo+#My@V)wqYi-XJC_d_-8^Q+a{}%sJ%nNU`x1bfznD?NY!X{euK08V!d=Nh1 zikrh`>V_XX%KS8Z>e_;@u!UTBv!jMzg|E2!R=lni1pJr~{#SOO7KiO&J2m;5ov0Fa zgdLQ#lO3rNz6syJ?+&{ub5Gdga(Q~;#TxX408%m^JVG^A=3a_pS5VRLiU%#$V%G?N zvCTkoZ_ZAJJS|~QYZq<7za;atC3{=DXsMRMw_|UsXnU}IbF^H`;X7yt_>Sy#73~BL zWR7-b&#Qp9(*^!O_P&Z9qzAzt%pO?LL-Y{%uIz;sJyZ{cKa4%GqKE6@@ZH!ODakH!}rlX@O`zfOX-n%ByBuOkAgp1kA^=+kAXi{kA?52 z{os$&8l|+gkBm7>bchn zt$-huC5fItCtJgHUD(F5hMe z(CK*8E6~f>vyrDW@T^y$Gx`5$p3cIj(5%i0zTv_x`t$K}K zFOF>SOvC;R|&k^a*`}l#6r`UI3reCn*8% zhLnkSLu$KJm(sfD@DJElpJ&HOo-V^rU|U_T%i&+p7hG%eedwC%DqY1ck=44I($?VJ ztI*~y_RmGGzZ0HA+pTvpA8U)X!agDSs-sE!8`d&Exs zwP*S!NnHqB0lJ|zrBr4|JfE5ocqRE+^&%)9lP+QU~ z@^wL_F^?mE@@bP!y5H|4h2^uWr}J6z>?`dzdwqnZ{yu9h5@j!;^_l+{7`xl)kFBR< zj*RT5<4u;*M?RX%jso6VvX4Y(CF3^9XZk9Aq|jG(rDMJT&E1X7ndGurVDHYr_bzU%$Cv44Nn`9=`)`;?CWenngZHp+Ptv-w30Kuc>wo6 zbL>lca*?Uzu=OlETGr)Ahg*s1&%VOaahdNVRq`8s!;-%9&{N6nsPdo!ps@b>)#Tev-0;9&Uv)2H>eX({^{4N<1-m*ujLY3M{ZC%Vvbm_^5)2Wk1UbZJ>j^luW9$Vwt1 zR))39@ubPCKNB5o&*OAi-}gIxz5RbDbx6zSzQ41_jmdI?&tbBe+=+(YiENsDS|4Pd zF-cD)*Gtpvb>wUhX?NI0 zQAS^y?#=vyZSXO@?CPaA6V*`LCgUv^4W4N9q)e-2zCT~?aJnD7lv9(oftONDTI+R_ z&|G@*0BT-{w!r>NebZG=Jp&aQRp@6@_jU4M$d$tGkOUpRR^l_xvoz9D=r8nMEng;P`E2B2fOa~aXnU}AVlcax= zQDE}z=}n_1m4@nL)MVT&7)DK(dhTzT>#N-xeV#i%cXi>^!k-sDT=-n!hebt2LyOKW znpJdT(Y&HZi?%my-gHva1;tH@Cl_B`{JY{mlGAeMviiEy?erhXW&UNq-oNBu^y~au zzs9fjtNcn<;Cf0AR^tx#ANoJ}&He-bKI?ZI{d@jh*7V--Z-Y^=fi=E2{OkTT|Ehll zq@|DjCo)qmmsxU!%#a_;2ymCSu+sNu|GEDHG}*8GRxp^hfxxug|IL5xcle$D8<3cG z`Q3D|*WDsuF#&ijIiNA+g2j|CjpbN5fVIIwDblaOWojnPr37TAmLSu%0+Y9mw3D{B zp2TWW&(JIM4t+x3aAY_t9374c$A*64xNv+pA)FZchXJfIog4;+LE)5eYB((n4nx9F z)|`fg;oC6fO)G zg^R-_;nFaf)v2jrT9_U#3p2vZFe_XRknfcwyd+UV~xXpbv+n@H-I2`le^j7;%;+yxH)dIdkTxyGj54{ z7Ms;`pyw=e%h~aog(&RSfumE00F0dq{9jjx{_FU6G8U_^-467PA?fTg`!=tA-V2~G zSgm}HZ-h0fHzd~7s$j$N?Hl0P-Z=S|hvlP3`@`%~K23yNZ^0K@lugM!&S>6W4{XN+Pvh#=XF^JBe zf#&=rHkEDIQg&cB*(H0hl7x^7jlg+t40>}R=*`VSNoW~bh1Q{MXdgOY`8W{!Mptm0 zyVc__-;WIe^yTnSs0>xu5Ng5#*87)*<%TrF3jZor_SdkYUx$GV`tcvbtKs$VCM)<~ zum=AZR?%~{32Wm&W!3dMy&;vG;$O);w{w^=baex`Ycse{Hb1sGgy(MA*tRze&%Mrs z|6^A8u&nTNGvTr+E8GzK>dFZ(W`%dp3SXZUUYZHl)8Z_t(k-A;o&7<~cIx#FbR67&`KQyt&z8&)%q*w0`5_+wT#ttEAOLNv){oR5@?K( zPh!lL7-LrIT25*ZHd?rvts1KrGtp!Pt+RY(;qsT59*dW&)w`jUax1i&8Ez4?;&!YK zbj2Rumv#C;XqDVw zTD>o(t*d>o_t>hCALF04cuuQ*lNhojhU{IZrFC`sSIdh%#Oh@I&F%?k4LeD!PWMC8 zcViiRE%pR!VfxCi@v9130x{U($laiqOh(v;5o0%4EcO+<)3cz+N35gmY^cHxVYO~3 zCngD1Si7ud^PrVjyR5hDo>r@cB=YwVTq1wg_O$%f#J1b7z00*WRbjQVa(@G@#A;== zIu=^3zl0{`#-53CXZ;zk5XW`B)abtxUL_yLEB!vENz2oxG3K)v^M@Fdw04t)W5coX zvgK)OOusFrUudy97mBYuX!TzGV)vs8tC-cFR#N{8XpNY3SL=LeQvWC6vZZK`{SH1} z!$xW?7AQ-bzL%*O-#ErUWAU6OMTIfs*%;FB4q2P4bb`f*ji*YT2(1Yv&}w}EnwFjA z@U>W>tTpNDF|NtI!eYX!(1x<}XpFrq#@bk}#42ZZ>2hdVb`FHE!g^(OMgN3D?RVr3 zujdY{=df-Po89XsIaOl%t5Q#a*2vq?YW+9pg6x!fCLvXNF0@w1LM!zgXpN4CRyRm_ z79mx79<)}^hF0o0XbpD9y;Dviq)MkkYxN>%rCtK9(aF$;WnM@~l}>}!>c!AXy%bub zQ=nK2(ZRokGg)uSj+sMvejLgUyc9cvdZXX^vfl^4Qta^o1&mox3uZwT*hPjj_pu#) zXQQWV&ePRh!*Q6qj^l9VKeO3aWGKo;JvP_fi(jDi9DTTE-7^XaiEGTDu-d9& z5^DPjbHF~$P35KX>)x_#C&^;=d9368^33{_wgM1W!2Ok(=bk%}uw2ST+Wa%f%V1aG zO=2su(tx8}pJqu+bGoCUwCJlCFxTSDb6ZIzB#@Us|xVzx$ zTjvey+0$d$VbfxmVMk}ufuqa;esg^vv|-=OVTO)eACgZT*a&8)0xOD6CdnlB-AtG1 z*q?9FIn*O_JxbI$GM(^W=&gF2-Y%2$4!x7IhEduON}lM-WF%z{rbMYLag>bbsn(zr z>M|~?W@k~Wu}Eh&MzSF!Hz$|PO(-v2W3EAcsby9ztPa$)!4>p&y(5!`dfpk=s!EZG z*0IdCtZkN%%Oozb+O;9XbEV*sU+~L`DVGk+={iYg(qvMUaTktvKLPQ5 VmYg6bN`Dz3C&|f_Y<o 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%0A>D@z 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 z zP@wY>WnnL}BB|wDP41}WTg#Uc4(!w@utdY2eFt==h`toDWW&H`J2wg})iAIDN$H0B z>M0-lEblwuXa}K-QAgax$NdHz70+sEQwLTHtVLmcK0e|J-3s@~T9#=T*x}mNgNid?Lci@kDT$VH8+t}ABAD`v?nX6^Uz=CUc?a#OBl%S8=uP97!~tK{OU zTo`iEUY^&)M_wTp?dvP@&6;P$Tl`OLIR8ow5U+~2#R{a(t88}>=QvuiV7REr`!k+= ziKR@zm!iDp%QLbzq9Wr@p7^yDtzP0O>nY)>4ie?vbYZbZW3Ra;4{=Odb3Dxw@_V&~ z>oTsv(86_9lohiJ*R^8%^l(>?0UAWFALsfiY}#hhn2dW7b||&m_`5kFJ_wZjBK$ICDDn9K)MSjXBO+MqnoJ z5idVFyAQ|5iCN?^g0uUZ*UaGP6z(AJ-nHMR^h938?=hYd_>ij) z_nb~Tn!G2QcbUk(DPk$5(P|K_)2Z)Z4Ofur7GxrkXI*4<*3OPLk1j9I~E`GVzo-XK!Mc4kF< zkIk=PO%T0-p7b-zAZA6JW>&;mW(6BT+K-o*6=@-`yvExSiil)ZumR#(CNV1_m07`e zxXUs_{K4`evw|m~2t~myVu>DSS&3-sic(#v&eDheqY(9>j?knYXC1B0vX#<`6pBhZL*~a??N8ud$3^q*c-L^!qFy z=ujK~xC}Ixd?F(~3(=7Evb6N~Vkh%1=H1Lch#w@)@$c$!+ydWhhZla7UJ%HFZUBwN4+zBlBhav`k0BL>ZB1f-xgKTr%d=#@-EzK)YKVMMcIjCroW9}?aDze zWhtzxrm;-tMQ65-)waYv-U#WuJ&P29znY>w^NTJe{W=9G>~XQCVR z#9kbL_4lQ+McJl&OAEh?)n3fkzoAQpQtO;Pt}#heUHa}hk8goq8?-9 z;G}v=J)@pe&#M>JQ1!BUMZKm*sMpmfJnI-WR*hE^)nt5)RQ!y)co>;@7ddzp`S=p| z@goZGz8Mu_$jcp%p)?+YC*DLwt&--YRn@9%-kOhAQ>(4j(duc>;9E4(mbyF%Mkqcp zz4cWte?oc_(wES_)YeNcLfd5e4?k*qiu?iXCwTXuc1U^wjA|Tv((=dZkJN5xH??SZ z*s0|*CRj$VfOY+Wz7)=My<@A2l5OZe@Hkwg+;h9q;(UwqZV%jtwkl}7r^RLe%dO|R zkM{HO^J;y>PnD_vWxr0P)za7emirIzU)LhO)e`^xWxBK;>^HXMiZUzw+LS%x*T%0+ z>yQ1$`px$^=Re!CZp*eTJNS+DzwEiR<@r_x{+C-7lyfg{t>9iEz2XZ0*%jAT-0at; z^3p1Et4^pkwc65ZE2^#aUg5pg`*IDn#)_5!EdzW{`gzqHUTbKJ_&TR#8Ct$1mwsM# zzi1i2RzUp@&sd*X|IGbo6I*`K;O&-en|EojzR`5Qv8eDi&1*NW?KigB)MoePTJze} z>tjkjyLlH%e7@xuE#h0YZGA+}eqM6jRY%wU7U!E!mGo$FzKwf}@-2q9QCl4MPxp7W zQT?5y=GI+}_&u-ZkI(-3U*2!6x+0{zq&_>cp zQbo3L3rgTWyQp2|k)orL&a$;76=a?*Un18+6rJ~P(?7StpSNALeoXNq$`_@rKQ5%Y zr2K5Z<&rD?+Q9ecTOWZ!kLo08M_K2$-Xr@G{g5Lj-#%(x**9gsGyCRjNdf4ypK}X2 zmew@6w+$8yItCXS{a|HQL@D%>jH6tEeRYua7E{)jLe{rpOwy0}DI~m%nco>-p*j{- z58;hgdKsJTHD+JYTl5n(vETZmF$OT#!_OFk<`~A@kT>3zqdUG}Zj8;dg?#s+4Vt12 zj-o?;X7&>XqXDeRr+v37#dB^R)9I-n^c&{EgY4BgQT+1w|Gd9?UN{D!XiLp;Df zEI?6Cz^<`~$x2D3ByYNvQObyEj89Y*)0LV^P2RC=tTYv~r552!tF4vRVm`XWUo1eM zJTKluqjch(|9(n8v4}B-{^A2P%K)*M(S)I5iLylbP<*6(q+q@A6_#6Kg%YFO7N06{ zM0!}IBq~W_wUVJ^inWTN7~*raESmNUtdFKVqi>E4vQh1us-YU< zcg#U-0QsCr{DHnID;}V;JjFxwR(Vl?zVZ^9C^Z>}-lEiJ>?=U=)3$5d6@Ton z9ZDN*m$r)_0O&QPtsLi4+M(BuDjm>fCzP((W#^P0+Ij7Q(u;BX%Ss5 z0JPgpWgr?ZR(VrP&{CA4j9%H4(Q-se8H3hyD&rVk`a^k3E6@yOI^#yVGE*<5msaNL zp1P+pPcN@~DevmudKX5By6N4N-FgrG4dn-Ym_AH7#`yRw2kAk|9sP`cUP;j}@+FPCdZ>O$NoQ34s^Y-9 zj8HNepTDJK>2Z3TlC7udX^K-%*VC08omt7{pYi!v&!vQ)aZ5yiDC3sm4>}vSw6eex zln1AcTlyRj#5rE{>x%Ta^7OY#N)`P74Wbe)US8wtzglhZ3D^j>fJpY=05?H2+v%br zwnHV}F|5RUGnIHZsuFf_CEn4gsGkGp$yX5-3?HmGPot7359%2GMdO0s=i#yeF0z35&aDtUy(^Cws)Es0@kr_1K7mA{p4`~{LJYcmcErFM^JsGw2Ck0k47H0G`+3dHoGA5DWrCz%VcZj55-BUp8GI2gZYmU^18j zrctLEU>2A|pW8qg;75Hk*9Cy>oU@Y@p3{RkewOos!6oCA9s$ygQ}}Xz#tqSinD);b z4)KB!hy37KgKe8RaK!4`0uJR)UOL2wg9voGC9(Q=JAeJYp^W`a4!4SgwT z5a}5sML!456G`Y2Zwf_#bUf^*sZAiY$)qONs7W9-38W^O)FhCa1XB8Il%B7+g7Tmy z+qJ#0#WhNCjZ$2r6oHiD8l?!N6q%GFQ^&r*A(kg;Vzzb9b)qywIZ)E8s!Dw)r>tDeh^F)SwVs}+!ysSL(F%9{chI~vz zKBgfX(-`}zfILh?9;P7!rTj}n{-q)F(vW#l&ZQye(vWd!$hS1)TN?5$O<5v5v1B}z zkHE)Z8DqI$BV)FKZ@_l&EkLFsThfp%X~>o|WJ?;dB@Nkp_^Y5u802|v5kOK_(PXQ_@3!Vn8fj@X2ya3vR7ePnR z84Lu2zz{G3Yz8~QZtC3uDmkE%11dS7k^?F^ppXL!IiQdO3OS&V0}45ykOK-ippXL! zIiQdO3OS&V18O*+h68FipoRl#IG~0DYB->V18O*+h68FipoRl#IG~0DYB->V18O*+ zh68FipoRl#IG_e!sRlOS06D;bXBD7=vfydZ8u$bHAQW&w0S6RtKmi97;2Shx5EueR zfX!eh*iF5>(3=kQoCDqBK({#1Ee>>x1Kr|Ke+PenhuoQ3A%`8vTL&`IfqZlzj~vJ& z2lB{)JaQn99LOUF^2mV<;)@a70lqYrpuv}*C6}Njm!Kt=pe2`>obQ119dNz_&Ue81 z4mjTd=R4qh2b}MK^Br)$1I~BA`3^YW0q665JOE|!B&@{vECt>qQuPpU0{mj+qq*~x zB}T6DA@~S<43-(WXvjP?WFFcn4-J!t=Ey^9vQlJd* z06oCV;8oBI^a1@qe=q<*U41YZ3WkHN;0Sf=K<#bR-bU?h)ZRwzZPeaI?QPWFCXN_q zpnxY7ut5PE6tF=78x*iX0UH#sK>-^Sut5PE6tF=78x*iX0UH#sK>-^Sut5PE6tF=7 z8x*iX0UH#sK>-^Sut5PE6tF=78x*iX0UH#sK>-^Sut5PE6tF=78x*iX0UH#sK>-^S zut5PE6tF=78x*iX0UH#sK>-^Sut5PE6tF=78x*iX0UH)+9W-EDBLck1VfAn^?f4+doTriFa>)s1$!_BdoTriFa`Y*iQSfh&6a|_mV$nX zM88C08>OIQBC(HB&@++RCL;n}6RB~oc82w6){|M!CCz91H`ec?;WQ%xnUAus1ag zqlRJBFic#aKb7HM9!q(0xE!nms|;^y7)A}ls9_j245Nl&)G&-1hEcmPY8OWB!l+dk zH438^VU#|MQioCMFiIRoiNh#y7$pv)#9@>;j1q@Y;xI}aMv22HaTp~Iql9623+K?_ zst84+{(@z449OmgWRFF%$0FHdMQ?Z&o<`n93HTaGAB&$5htx{wg@g0y zUm^$OfqNhy{L1AXgtL{P$>^7p1?NP|~C}evSvOP)-G~)0Z z;_w^d@EhWgib+VtB(zp6S}PW<6^qu2MQg>PwPMj)v1qMWH4Iz@;UE&+05?G{_o1HZ zFW^`3JNNhlJT#*45906-;_wgR@DJkf5906-;*h{eXuDV~0C}{H?d_bqgMB+$-^KcF z(sLX~%4+A?zCa4yk=RLS##rq#+u z!9n&P24^`h7#^LE{^NgEpevl&4Ri-R!Ruh4kqw8(!r`%SxDyU{!r|F)xDyVKg{vL( z91l1;9gcRw(M~w}92}huN8908C*0|TD`VkGCtT@-3!QMC6RvZ@bxw3_3_3Oj9UFs= zjX}r8z==*c(FrFy;Y25#=!E04;kax#E*p-^hT~%4xL7#O3CB6%I42zEgyWoWoD+_7 z!f{SG&I!jk;W#H8=Y->&aGVpa%7&}5;i_!7DjTlKhO4sSs#v(n2}fnaO|fuOHk_0V zCpqCBC)^VY_c-C2ShyxuyKTgv^JCEYF>sF)&WVL{c*_Xnv2GAGKmjT!3o6nIm4Fv` zn(fxW9}w3RE^)#oPPoJgmpI`PC!FAf6P$2@6E1MV1=(;xHe8Sm7i7Z)*>FKNT#yYH z#KHx!a6v4s@1*6kX?Z6t@1(_JY4KQ|msyPfT0B7XKpqV=a%kaTS~!@N&7oC;Y1J%K z<6^7Pnm)8<4lNi=O9jwU0kl*AEfqjZ1<)!vv`P-Gl0&QH&?>>SN-)&Tfx0($?Q^Jo z4ztG$&0DcCNhcA+b zfL{$eJZpzv?eMD|ezn7|cKFo}zuMtVJG^O!H|_AI9p1FVgLZh(4iDPlK|4HXhX?KO zpdB8x!-IBs&<+pU;Xyk*Xom;w@R=RHvcp$)c*+j{*x?;Jykm!N?C^~pzOlnMc6h}O zuh`)gJG^3tSM2bL9bU1+D|UFr4zJkZ6+66Qhga;Zeh0Z~dl z2>wTJ6wQ^c{sA7!5iC)P5v(9au!0!D3esAFR-iTT1O9mV9YH718FT?%!ONlyqgz3Y zZUr&A6{PiIUvJO{^aXE%L0~W#0w#mE!4xoqFHg(_v%qXHmobk;G1Po?|T^A(gUAHzB9lqAiePb_7{5O z`dRh`gG)w`ew7iDYoZLJO+k7*X%a|hJ%|1GKtAI(zmwj_yOO;CJ|nh%rWgvcOgnIw z(gMDsZ4~xtbwPdbEWl3Ge&Jdjgm5lze&In51zA|NEkGy0Hdp0xRW4WMa#b!@<#JUn zSLO2L>P^1vCa)Yqif4rMy=Uf7)wowY7o_Svr0PAS=RI=FBgZ^)l>X;Eq~|>|=Lpgm zkOA^Q(VZ$9_mH0Vke>IDkoS;~_mGhHkdF6|j`t`@9`8(84I}{Y!w+eL9om+XNPnca z5suv(j?EiRX>F7?o6_1SskCduMFi(Z0q!i~p+pikg^lYS9LwTdCut7I1NT5a_{9jp zjt$3-4abfR7X?N*c5FB{Y`9XwNM+3II{L<@_!xJvVZ-r%BsH?JWy2Xui$HdrQ`)lL z4s_&LCysSye^+eZ?w~LGUk7i1!2s&g7TL;3@*RaF8I2^L#QBqr5Oi87IxQ5P7K%;_ zMW=3RJUQ9hGUzCW1EI!n}#!n7r_`_1Y>v+rjJz%Z&3PK(#LZ7SNAmO zUj=aP4vrm!TTXNC8CoTneHTCo`$MrRF0&pE@IE!^hsBWIHbT*Xq3FO+Es0}xkZxS3 zWo@R9mPeXzws1B++CBIw8=Ev7n>1Xn07q8DvwVuQ5@}^pFVZU5d(}BE{j+IoOCN0p zsr1ukk&Jb7NEa#mg4W{ku!zm}>2+0lEa6>kna1u^9 zN$ZEu!U?owCasuFEAr&SI7zD|&`Kwjmx(eUTVb-12!*qua5fY@2}Msr(UV#&ws(Nj ztcQ?BfEbVg@~CM=PziW}X{JY|QU zGT|p1HM$1x*r|<;+SsU#4gRph8+LfZ4sY1;y>sCWJ3Ns^4Q=p*lnXhuy`46;)4q1v zR?3MS+E&Vn9NILC_ROL!vuMXO+R#q>*=ajFwYJf2X|$Q0+S_O+J8fj=Ex$U9nbf0p z4M20y68IU3P$Us*B#6aqe+Jfr{ha#~=>gIRjx)N-vu{r*mB?FC6?sF-v#@6;icCg< zbJ_mgNaTfhPsU`@coMr744pc|kS)c4u95G(>8fW?5(W|h%q zbt%VJgEe3+pstMlq|rhN>LHFF0mr~`_MHT$z!`82@DxbB4x+$s;68YO?kj5~(z1!P zY$7e2K+7g*jo81Gk>=&3pRm1>^)Ff9$odw*y%;x2W85f>7Eh$56SQRZrL&&NJ}1a! z-*4=@FFf^9v}PIL0c5YOY9tap%v1LPH9>7q7ci=#KMVMBwB8sr1mLF_vN&cPQg zMK$`6AN{5cvfrgIoN!N$xXu^yqDZAqh+{n-4P&FPR6!0)S?%8P&@~U=RolsDDH&fPN?jJx=#9%l(SCyQXZ7eG39GC+v)7jB}GP> z^05N`?o*&Ln2IMf9n1uC=vzxkgGiCh)ass?K)>-QlpHpErLy!D;?A+}0Fct-I{Tyq ziKZXKupS5Ev3rvY-Wz1Uom?_`f|ASmzt9tZhmUpPK_Bse0yc$bRE3hou5@JGB~?=C z7gDmM(l64Gkty_xvh)iNWeDj=t|J;9{i1B4q_NW{%F-u1=mQ?~0T23s2YtYUKHxzg z@SqQPKtUV4?g6iR!0R4=l1x%-+2CCdC~AXmJ)o+Me+`v|s%R~2Jg#*~DfUIPFPZIh z(p-T31kZZFvmUyJEmZ-#>nTtfR54QF+cfyr1HScuZ$02!5BSytzV(1_J>Xjp_|^ly z^?+|Z;9Cz`M`|Xim83?Jk}XxAM?Q?>!iOG(5>9F%8*iukF?QA#s=vS(V+8BhS&t%( zHpfm=;k{IN@2<$j`_MsisOyhS(UG(>X)jVNPWUeszDtF&DezsYG8OBcaZ9Lm2j05_ z@7;m-QlOR`N0no!Dezt@R7!#O?n0qE@Lnnul4GbT@Lei=mx|oJ3*X&^@9t8w6l#`2 z%~Gh@9cq+9ZSL?ctRJOqYt|r{8l+HzWNMH^?@6HsDP|2aNpsPoI%sY_8%?GbDbymF zTBJ~m3~G@=Eo2Q+r~yydjbuunOzD#;ZBa>+DXA=>9NkTMqJ(pdWJ;9GGaWz1Dqo<_ zMvz9C+M9P4cy7Oee#WzHa?K#u3}})EP4b{g9yG~=91K!-f&Ap2S#bew z`PAYbrIk;Y@+qOTg7UFJ|L!SMK9*5F)=)myP(GGWK9*2EmQX(SVezL-`C1;aNh*R$ zzza+Rc!5|&`B+2wSVQ@|X`P84$7ewvWr_#z4Nt0(Ihn|tOiGbSf6b(?W%4hQKaT{u z-i>jJq7gtj`d2jCS2V&W-*u@D{%Mr&Hpk*XDq5a*DU^KpsT`;Zo&#SqD);9RKi(a{ zJAd+hl@pxzKaBRN#os;Q9SOYd47~0PyzUIV?hL%{47}_NyzC6T>cvR5!zJ?QT*oilsp%;z#@g5G|ZU)|N2A*yP@3vd4^{6AR?M zO1@4^DxYq=PWpI^Cs1r)`)A-9;|b)s&2C!%6k}z9jPc%}&+bAt?G?ZBmV*X#W3OU9 ziD0~^I(+8MJ|D(&YLdPLdUJd@=?E~2bH;#)tWRbC62^Nz1RsHq!7}jw^*9iggSrVo zEfwoQr6qZ5%Bco`uX#&j8~6rn2j7Cfd-_o{Ms%2KjskjwdV+I#GlMa}K=mvL1{VNy zQZIopa214uNN@w(1jV0p{QqyfDA4s(1zSKqQDJ-?ZVxn{rd%X-jTr@MkH?J4FlJPS zF{3it9nQ6Jj%(Z~kTK0bO^zG=&i+4u!CNc}P=V`73g2nwX~mP{M}c|`xY`%g0(C$= z@C;}G8i6L@InW%m1g*)BH*WOj!3&^0coB32yoICx=cg`#jGqSTLxAh4OW+gZP4EuA zERbFnMK23vTs8229&;*dN@VFtp-qs=PW~lT+VrT}u-*;uMA`I=_>!0Oevr^kB(#&a zb-FUP+6{DHqJP%#~?ZJznBj^kUf2SYy{3hL4WkwxeS19O zCZ7QPdBn|S75tYGH*~QTIa`XSwM)Q<;3M!cSVkYa4Prq8=eW~y$FLW!V%^-PrFzjq zeB6sNj=^S_Y`kpdl}}#MipVFgeDcaCuYB^7mO?&x<&#%FBQ~!0=6ysj-g@b4EEI1T ztHmIsua~haE@U)(p}Gg`1;2woz(Zr9W-(T4ZlDAx1gD_(? zJ$WI$cp<%PA)~qr8P#3LsO~~Wbry-}+)~LcmE2OvEtTBvl3ObO8g!K_OWv112Syo( zmD})+A}PrK7kCRqya-Q?z_NS=E2=W@tz74sh?A#jnLJ-ACqnU= z!vZ}{P?zo^v!H;wRN(G!QJ=EHFe*?dH|kWD-cXM6wWUY=K;53AZf&VsSxPy9`gNy% zr73B9<&}aQ=y-~6|7O!;o}vkt-eV7*a`op~ zzyMPDzT;#lJfAm27V*4vDY0UfVfh^4{m$bYcfH?no_~*&p_T87vrzv$BWD+kW7P02 zt$m5sj-j0nj%iQ-e_q_zgv!Q{V6560a8tpyE*iISxu{Vdk zIqc0t=0)?rpG@%-HBKR)RBD_;P47_CWa*=u+k=tvrra{)jt`fdXIA$`fei8MN|H?))u$>;Y9$;Nt*FF%v#s2!-~;#|z-& zh4Aq^@bLmD_C6F_55;2O<3;fCdiZz&W&8;~mK2;%%{S84lc1!H+DwF+YoX?k@bSCw zu?JLLQz#pbLcgQX?6T89#7?qR*gH*wP-3= zv{IPL?MM5J6iWJ+iBc3!Myhdxa+?#S74+6elaXX>WKX`_|K|z13FDqznrB=wvW@qR zZRT0>zt)2K2!k&+7(W~5;3ZieN^k5mkA)x!7UC|MAhP#6|ddn{mAtu>$1nW!WYHqm-}zB zg;)P(f-C>)(}f<7w~fz{lK*jflGlp2`hUFa(Luuo*WQJzO+Gg4$g_Vv zq0xl#P0{HFqmsrgmeIyGGIBz7=?qytOawoW3(!yv2vQ0j}TzHH( z@{02Ncd3z%MlvVfN%_lUFw$y##`0@ppRvj~imnp)O`gVHV+H-}QNJ*LLVD2$DRo`r zJvhzT=aZQ)&C)j!b=L} zlgS?@eT_dz0(OZdhFKzetlE=k~$f!*8Zi3`eCdqY|{^{nA+m821|I38j z3;8dn2<*+msYo^%*G=gafowTwL?G`>txlN^6rK6!6z`=dkK09Se@*}I()+J@6z%yt zTY(b3Q&iTX^NP|xb7|3jS6Yydk61Vr%CA#Jlq<^7xF}C>$&sRS|4qvOV*l%8bTUpD z%~|#|+8GVG%XBVrV-`j|W9eh*gGFtTnHg&f=HWTYb40;NwoA*s|L5udAV-UApW^p; zEW3;Y#?Qr%JYL6_6x8jmu~Awz#<#K`np1gGFCtt2I?=x;7v*Ox<0?~kbDkPmRquPfd8beS-)IH<_F9bUOy$D3+(&Ekq)I zO>1H~I=Cm#=y<#4SLP1HL3n__P#_HPl8o5M*ZX87gf3wAfzZ>8f$*vs z1EIGW1EG%@1EH@O1Azz##6Tbd0x=MXv`7qu0cH$@fy6*qPE@s(d>3()83SRA8U0|a z8U0|K8U0|q8U0|A8U0|g8U0|Y8U0|I8U0|o8U0{}8U0|E8U0`m(GR{AbIphcTZwpZ zPJC-dHaH|>?TAxmWP`J2WP@{NWP@NcvcY*`@1%+gW_*K-W_*JXGrmEn8QBoU*>OC%Ggr>eL^te)y3 zg?K&QBGrt6z#Bx`v%)Up9uPm1xCg{cAnpON5oFv0)r@ErH zyczePrWyC3mKpb;wi)-Jjv4o$u8c}dYy=tcpqUx*pd}FxE)x|&#yWV(jCIh-jCIi2 zjCIh(jCIh%jCIh;G7xpAlQs}aM6r-5Mo9=2sI-f@HQI}4`)3uie^xR3r?1&Red(Wd z=&N;^s}qf+9@@J;vqE2c2AS5Fxg=v|O}Mftvlsn~SYh<97DPFb5l_nUbljhF+Au#& zgoNjbiPe@_MoM7Ro8H$Bi>))Wm6#}9kc?fKE1Uh_-R%E5{l5<~qA#;X+>(BrBO|0d zWy*pQ$b!Lmbwij-AQgsEdKo9h&6EoYa$z4M<@=dcqG$a?*$yzPrlcrgN($YS6mF)Z z@G)gXc_c(U^-f{-K~C6MN=Z@GloZ57V0JSlMGYiHfvADBFoc^aFKU?bqJ}9iROE#h zBdt}42=TO1l{dyqAUhg!W)r1}sHrqXj#NdCG#BNS7D@}ED6}N5N-0yOlrm*XDWpk9 zj&>rhN<~wSD9Dk)Ts4IEXv!-?iK|l4jH^;bS*k1*zGiflD#~)CjISwWd`&4+%ak%@ zkTQv)wvwbI32$Ug1|u#G#lg5NZ=SO*qpov`0J7r9{<6+933Z~qV@!#Gh{+o=t zQWm-MA>U+JsxB23h!3`0Scpfwj-#Ke>xl}vLFMTUQHTSGnD#YKTPmB8S=^CRy9hhG zoA@jm((0fnLDaBA&J=YQb9ez6Pn6qJeLY; z5Kn31+B^3!D~c4efrd+tEx|(uC7yk&VKL%_5k>o_Dk@C_8_=gjVbq1wcJb9 zaxYcY(iyA%y;N=DrD~CvszqL^7I~>!=tA9fn zW;DUF+mqb!DXcmdxTn$HrFmOC!(G<9^+6-|EV{fF*vi?!-8Z_8SlHNvW-slYbI%2> zd851x9hc`p8-1?;v7rNe1A8vT?zjAJQ0TUyIc~)}=1t&Ex04#Z#d|i9l$qg0q?CtlA-IsZEpN()xLb(EIU61+Vt->jT~2dM(7kP7e<>eJ7H0kh5~CoC@BVm2y^2lheSvuwu^2>2f-FS60qh{0N)@ z-i;M>R=P`f@E+0wyr=X8?DYFf^cv%t?rr<#_2 z(hvL`IS2e)IT!pqbgXIVFa5y>$N=zxG7$WHbgpT+KrR5k5Z!B<_xfPkYKRP>m4?bt z@G7YSA11@VtEC$JQn?iTn|Q@^GF*m(j}YEaa+zEPK2k=4kCIW~qh&NWwvd2~5`rQf zB>-n&@uyg3qLD9@Sy=ulmf8FXQYdq<08}h<`6HxI#}KSVyh(1t=jP3FbI{t`&lH&_ z^UyNhCbvOnJi!`8EGUs`u`CWm?qhYKl_kQsTb9aF>h&}E86{aJ%P0djmGA_&T$baz za)qpb2Qrc&6#GiRdi0ngt7H{Q~B9 zDEeh$8|dM=LoSwH>!H>}iWtE(XS7lr&tdp@e9ch~-Sr%A7_nsBg2h!x%ndMJDg>4T z6LYC?^sj|Q`R~wUMg9uCfVykz#GBSdYoVJ9PYp1ve}4XrN#Ba_#tm_r z`g1ErWV%&&cqGtV%Q>|5+IXlG%%))ZgGji)FJg)@B}zq zH5$jm=L+>4Zmo0JvC{rE=*rXo+tz~bhDsup@omtaGrlzZkxZwwXgiM3{P~TR4e^=x z_FLO1>M_q@3{9tZkJ4%M9&P##Slq0CdckbflO#C#o>FUC5= z=n+`d*R6NC^O(k?Fn?qTsDO97Q?}Lc{G+xAOq5KUgA+LlSqIqb&0^T%*xg(U( z`aX{G`oEr|`yKlnmLcNLqo0%gMtM239xS(U-r18VPguY3Duz+ZMilLBS{@w_75YP;u=b)@UaIVkuR!xQmCHen zd*h&dX&d&pG@Ktwbj?%CSY)oY3j;ZZE%%GVnqkXb^=aDX%AaZ4@i@v|f~-LMOKBrZ z?aapv7<*ww@Xq1lO^?|%!NQuFsWXDb*G!pwZLsW` zY15|$%O}mcswP+o;i}-*9{$F|XQxb?J2lumWqQq&V5^7QJ$wuDtl<6W8v5}Jg?naC zt(g(*pFO)g!##dZps{Am>Duvq1R01f}ahGY}ldyflKA_yqnq3c-C)MMCLgjGP zUno>bYIwe_gFe!&(Z}g!>5V1RO1@jNs^rO%cS}o4hn7w#ol|;O>5|f4mF{hNeA7uy zSC%y?n^Jab*?*S3OiE3S4QaDK*l*smGtEn8vw6|HV4gS6nN4P+*IA{*hz(x*Pi=PObaoPer zMWpc)QDht2v+Z%r43^kZ`2wF2&Ft~E9Ip{A@S4{Oe}Qdnd)rQDlbB2D;d;7WuD9#s z&UAg^U zW}Zg6QEs%m+>LQ#-8grJyV8w!-*OY&Rqkq6BZu8JZlasyu65VB$?kf0gPX$K)HFBU z&2Tf_EH~TDadUCqd87Nb`$y)hzT<9kH@kmwx43_Hx4L=mHg~)GuKOOt8~yHlPM*hG z;fu0aUgFF171<)M%2vD=zAkUbZ)KZo=Nt5Q@}}&-pW$2bw!9<1mv`kobC|Ev5BMhC zB_GKj3Pxi}~wzV9T1GdKK zdW?vB{&p;g&-^-l++Jg6yKdUY-5KWXu$L?6YEKWj7hBh_g*{%j)8{``4sPiA`;}j# z9*?)r!1Jxzy*_HsPfCiTN`V$gfE&MdzrXD->G}Wfe*e{8|2q9!dwJNqLmBX)@6DfS zKSmzzP4;>1@%jDQw#@bH=83+&i+7Y-`M-$&TGY<}5Oi5pcu%V4Zw1w7jX;kz5-rwf zR_%^KlQj-4)|F_lz8B!D5${Id5AMbT_xxZX|G}&YeuKX1vEcDQ?fO3%JjI&ob*y(y zcsmT9$I5?$cS5vO*eMAbJQD`|qlv!ii(nt}M(z9`RP8n&ea2W!)_bpnrV+ZRUU(?# zYtAzL%s})_gUn!4Wyac@@!y#6Bx#qS%lWyz-#%b}VVBz#He<7Bbm}6{lCf7w`yd*f z)o5=XMN_jDt;{;_Pj(ZUmKW_y_7(f8eGMJUHayI}X?Nmv@?HBr-X=f7)8waURQ8}r z*@xETpgoL^1h>(y5#CrE<5{u<&yvktxohECxi+qyJHefZ-s5Dn8=dhn*)``I@_sZ3 zc!qQ&)MO!VLT$nL|%Y z6K2M5Vb1k-xfAE#{OQ7byMPfx=U^aDZ5Gc-N5?vbFgz{o+xmTBc-Arf9}@Io3HpSX zZg(c=>P@I2ANN9n-Yr4joS?Ui>2ir5C1rw@6sppk!e}QaZzyAdy$U~0SKAsp!FyFI zP@c3q@ld&$l=?;By&4ttz+=U%;DKNjeug#%Uz#RpG6&;}>J~E}pHq7oC!A#a*w%4^bU; zJqIOlJCrV}sYd6;NHm;3tF)gf-Tu+XN8c6H%a4H>dk?Ud5pF4?;`Yo9bVeKBhgo_D zHES1WDs&pexAQeVIt?xBy}(*D15t~v2Ft<+MdZ^wXloDYHH@q+x(2*n{@urID819K z*sLq^;g4!Khn61OqojD;Q;sat+J-XytENR8qGi(dX6*#9j&&qjru%`>(|8)Z9&Liw zFuL=rTxHQD5P}wt)OoF>Ji;mrANq!dqFvD^JqHMX_%iCchAdhLEp{#yYS@}Jf{;=ejycfC3?U#(LXot5VMb6^IYm6qykV6FTF80PC+ z#`B%Fr@w>m*Ez0{|A3yg@B2IbmyZ+i(}zCfBOmhLJ|wK|PNk#4(fpG9wA;tuc=tta5j)ECYt+e*`SaMThm;8GjG$srBfeG;Z`P zQ$D=04}VOb$uEi4IV&|9VzobIWFoN6l>=+#0bs;C>%i;LL1}HG`_ZpS zyDa{2&ymK;m7TuMW75U>%(`%2Np2NE2Fm#)QCR<%O zCu>!HW#l4Yo!thkm45@SOvaSUp=4zOuwKRjGjav6PR0Xk^J0#HlI2fPjX4gO;m=Wx z`7L06%t=tPG7VTS*8wwfJ+Mxu0P|yB3neSlf%P&On2{TRbutx*t`Hgg3ozyNrmUAa zmG{SKtiMaK9;g@ctq-exus6dBAAFQB3TnS^rDLAX!94aZ!R_p) zGyb`QRYmII@eD>lKR}Kx4whmaWixwk?%D8;f6vs=@;NvZYgnsiKCj zEL}&khE*QV^L=@FE~d@^`1x;hQ~cfw`a?^TFWhDxgI@**RYp0sDiH@CkGVLH`#6^x z?f+bWsTDT30~o&m+us?~`Q{+hNIvbJZCz1Uu2ueI0N$@Y4CgPmfh z604XMU!8cSJ!$!HEIXuOPdy(LcfC`;nt8vNNFn zQ0|fWvcOJ~g>o->4I{T98MBU%3VR}Bx(>FIIB|^DJcV? literal 0 HcmV?d00001 diff --git a/Media/Fonts/big-noodle-too.ttf b/Media/Fonts/big-noodle-too.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3158f4f4eb9915a848d9d0028a89cb5deec4e486 GIT binary patch literal 85208 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 Q@U;(KcuB%HeqG!8zaUFEod5s; literal 0 HcmV?d00001 diff --git a/Media/Images/banner_short.png b/Media/Images/banner_short.png new file mode 100644 index 0000000000000000000000000000000000000000..3781e430d29be603a9dc962067dc7efa21843ee8 GIT binary patch literal 45853 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 c^qVh>lW#}emTu?lDaDh?$FJP^KM{=M%>V!Z literal 0 HcmV?d00001 diff --git a/Media/Images/banner_short_transparent.png b/Media/Images/banner_short_transparent.png new file mode 100644 index 0000000000000000000000000000000000000000..80aa4ff6676fbd982c76aa4b1ad26ffc784ba596 GIT binary patch literal 71377 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 literal 0 HcmV?d00001 diff --git a/Media/Images/banner_short_x150.png b/Media/Images/banner_short_x150.png new file mode 100644 index 0000000000000000000000000000000000000000..a15a60a456ccdbe2505dc617715da57564bdc4c5 GIT binary patch literal 5497 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 literal 0 HcmV?d00001 diff --git a/Media/Images/muted.png b/Media/Images/muted.png new file mode 100644 index 0000000000000000000000000000000000000000..a7f9831c722a166a43a9e6f764e3687cb6faafe3 GIT binary patch literal 3867 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 VAVh(sp55P7ds}B@wROPh{{ch!v%UZT literal 0 HcmV?d00001 diff --git a/Media/Images/speaking.png b/Media/Images/speaking.png new file mode 100644 index 0000000000000000000000000000000000000000..5c590fed30bae6228414a48b8a26cea224fa3f17 GIT binary patch literal 3509 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 YLMqi1OO0k{?fytvTiBy&%zdN(1BS2}mH+?% literal 0 HcmV?d00001 diff --git a/Media/Images/test.png b/Media/Images/test.png new file mode 100644 index 0000000000000000000000000000000000000000..adbf90deec8cf0c5d773e9bb488b30ade4f4a118 GIT binary patch literal 1576 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 UyiOFbPxZsXpzWZ3?;CUQ52f+O#Q*>R literal 0 HcmV?d00001 diff --git a/Media/Images/textures/BantoBar.png b/Media/Images/textures/BantoBar.png new file mode 100644 index 0000000000000000000000000000000000000000..5759e8447032b286558c56061c6637751f7e1b0a GIT binary patch literal 5102 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 literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Bumps.png b/Media/Images/textures/Bumps.png new file mode 100644 index 0000000000000000000000000000000000000000..c0c067a8f9c9214b9156d9a215b2e2536d817fa6 GIT binary patch literal 3695 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 U?9%gfTYmvDs1wLi^D|NZ0dgI^NdN!< literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Button.png b/Media/Images/textures/Button.png new file mode 100644 index 0000000000000000000000000000000000000000..08505652d4b6bd0d977cb42ee7a22bcc8f5496a8 GIT binary patch literal 1327 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 HP7nVDAjsBZ literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Cilo.png b/Media/Images/textures/Cilo.png new file mode 100644 index 0000000000000000000000000000000000000000..344bd9bf3c7d2b032869a0222d80e2daf2f21a31 GIT binary patch literal 2123 zcmaJ?c~nz(77hqj1%_QLqw)xgpk#$a@+2Z;VG&XcC?G0?@CXsIkc3HCWRnq)2?{Os zNQ+>n3|dqyppGB{b`&B)DP$NcAWK28sDNximduLn;y_G@ z6_psTfZPy>4ffoi5J`x?AIKK)&@2rGE#nC_u@G+VG9il{3rT=Th{NTRk(2d}NPrtf zMg|l7vHn5|6vOpS5?NUbL}wQg$qB$?ajqDwD+cF`!Vy6iEQrSg zYX=Eu6GcUXOe%dX7knclVv4j{L8jIoaGPaNx=$=G6&D9l$qftF6cvqU|UK-p>SG*?;yUz9DizO^R8(Qabf9DeZkPA{o5KAHu z1qlRk>(dK}5l95$7=aL=P&Csb0S>`jew08ecGR?YO)Vti{soGniv&F2eSbjiM>M$L zJzcRlB85(aHN*Nzt~dhSnL;992{^2a2LZXpjrvIKYh3F8c(L5BtvYU5rAjX=tLe(h$?)t<)EyihwIiqGYQ+~Pe?4H7qe^QV zUYND0b@SCa`X&KAnykQk(^9jGb}ME&m815RmwwGZ6`QW(7Jz!BW?6zol*w;KP35MU zXW19}iKeQ_9Q7=G+_w-FyCUikDd^Fv^179p?yBarq?DAe?QDFk5_D5KN}LOVP{3%K z)5uFZ`D4ZEvC=)Hj2B7e(MG(X+P(voBi}icTb4MS{MRnei)W5G&lFbw6f)*=q~0;N z>_`~sGPvJaU4Dsv{o;fuoJ_V*S!JtdX?6Ml(kUAI*X)!}96mS6!_^drwaSgf`GY6) z5v4^>6{T6uCY(i1LE~=UmD#Klx#!#ab4JYkEH@eAwG{fjPyF5{Fi#h@-27blu;1ox zM^W#x`L;iKw0-f_D#PeiU@&-Ir@BdPv|KSUR~NJUIm2@6)O<-d5ccEky`e`Pl(scp zm;J$pA6m85^664edcjK1FZukL+Kzjbv4M_CJ*OD!LlL1>MyuQ1!k9M*A&uSC!A6@^ zCxrQD1BIASX8q4RrP5pXaNT#W@0Z3Md7D0f#^^QfxfPffA9Aw20TaH;*Rwh}+B_W- z!CU-fb!qrV?ZIn4*HGS(3&lRh1y2LVX@1$+M@vwyj)fNJ5MSG??c?+U^XyoKPUsVg zk@#?22*-4cW6MmvM{Fh3y1DNAzPe!#A@Uz6?cRw^F$Q?zb0&k03~ecnKQ7#^vO3dv zO{=qK{|@!b1E@LOfs{KzdhF@d_KW9lX{&mY-d&sho3yW6xksL8?&T8qY?+|U-@LKo z^-i&l4bMz9c}_iylvNhB-#^&0biq@$Xt-J#4KLvU!y4hS z%Obt)*6{>W^ZVNUBEgOymLKWyrVq7>8yt6~W<5TYkymqB8e#;XKf44On^e_;!F`2_ zWT1&B&wM?%0kojCc$~N$I;Ffx4|p~3&Y-VJja{y2o2$-a-TtcNKNI77Ef+J?`<X15cfn6Ru+7tq1?!MIus8U+CP? zHF%S^`ISBShIL-5JZMO0Vz#l5agUyU+P%ZNnPF)){J_`c4dt9t!LM61K4!bwtD<+v z&;wzIEwFVvzF|nNJG=BY5%FRhuF(*z(9vD>8dlHGZ#@Z&kJtyq|hZT)DhM)v$7T_r3=lc_!(Kg3C5Ml6T1T0Jmg5P%mbK zx?#FyVPS!wUF#wH_liyJe2&&WhYqKy@Sj^Y=0)^nEiL^r6FzIK67+e=GBO!&bkCkj zFLTywnwW?!Gm@OplBK3TdiO_r}AUCe${5~_r8+2`Re}P@k z+y&7c;!o{=HsqJZ`I+hF4I~bx>92N=ar(G-Z63aCOVd}oYwIKEoP(o!=Q`w?-@6Zu KLA~J_k@;W9LVP#? literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Cloud.png b/Media/Images/textures/Cloud.png new file mode 100644 index 0000000000000000000000000000000000000000..ba4524107ea00f7daaa762a201ed3fe6a61bcb64 GIT binary patch literal 4130 zcmaJ^X*`r|`<`s22q8OTklie{nX!c#OQML7WShYhGh=2jWGN}iGKQ!;7%3!WELjSL z7;7ndLiSw`*|TKn<$3FQ{~zA}dw;m^^E!X$ah%7we7Nr0XKl=R4~riL006v}7AQLa z;J}^<03Kr7dz0FsWqThn3fhTsj^IrR_8?&a#u$Pp7HH}3;e)lqdSF5VTCw^70Q(7? zy%WXB`ZUsu;IHAaAEQC_C+>3r^o^)Q53eg&3eXelgTotu7OLNXfH;f+$WhxGVofx~ z`r<4?N!W9tHuhejSG*7ykdYx!pNiZg@W)a-fK-1!JQ+zf0R7F2+_U%HU=Z-{5Xuz; z(7#1FS)T=(5=dB}wuX+n7gS3dsEg2m>S$^q5Nbde1PTX3;9#hxI#dU#1wq1Kz+Vq& zFB=KtjkH6V{mQj>G64BfC`2R}92^|15v-{}Ao+ly2m}HQfq`K#^}PsnatNN{K~=|- z<$g1uuw*Y1j!3}~@W6dW4^KiM#Q?OY^xq};6RoZPA;yz`HEOSAV5$cZ4Ap>u{r&gr z`a7CTvBUmH#(#|_+lLUbU^^_C5J>Xcn}@gDZ}494{&%ANz&$lc6u~RdAB(40q6|QL z9t{i*gGA}*YMDUL2s1Ps4uzso#%2h#sj-%}Da=d<1x2HOV=eJyiU-~c`x}e<4;KDE zu}D)A)`LPI*%JtUzo&QBmp~zqeF;RMspY|`1ZMY5;0!PCUpkG+bf2sW!R_p)8g7?IL z_lNs`hW*>Mw?X^nKdZZU_-FaC_`QuM?X9+Of{g?Kz>{o=GPb9F$++lTI$*|=A~pCk z$S~oXyo#ooJ0SXpLHKE$mbki~2C!uwJp4L*Yx!z*(JaHQAKfq6e5UhO%H+oI8=dX& zpeaF-=G~Re@3W4EiL)KMwy&|t05cCSH@ZFbK=AtpD>Zn zq`8$%(T%=H`3A-xq;^0o$G9m=q|tS%kLX61*lr1H8;skoR&M*S*edtrrJVG!`5O-M zLsHW#daQ1D2@Qoc)CWgI!n@PcB%6OAitLEyg%MQsQl4#5hJw8Y;c%AT-;z-hwcHmC)MG>Y2WtGAgkmA&c^8;BtR; zNNsz&_`q?gXzp^2rJyjKe!!8o1mhyHca{}nOx+k#J5}v)6qPbWZwVTAI zCMaNJm}=3-ux8cCu{0z4+Z%=&0V505GM*?~R)?I2jA4ZN+L)XIm;QVcF-cZcPVA!K zNHnKYV(ixXdXwwGh2Eq^_m4a}F{*@61~4fld%*ZTT-NcXXW-a!Q~nsouF#VUp0nIp z0-Sf;TcKC%o!6MqanDFw5+tIJ9Ldo>0ResKWCF!vH(dE1){P~9$>eZgiGr$K2`I?P;AIQkmOF4k zKdJxZX)UkI0x5@2gxznhGyIS!dZYV%JP}|~C(ycbgRFGYH_m=C!%;gBv+zjtCG1uh z6W@+t4}xY$mK70B@l=>sWeYNEuw7>ZJd*Ag#D&&yc8YdwE>uo4q20pEN~O7Y|G9~Q zQSSQ6`ip(n+!Rmi?Wky^H4CRp`JcQP_rx&kXKDz)Z42B|np<#E{}LBpc1FQzvz29$ z9`)k|5br2igL#DV?M{0B*&JqxFRC%*UYRLLZv6)p%bms6;Fh@+-Xi1QaR%eWSMU6| zQtm5v?R5CjvFY6@e(h5UWAel}*V}7k--s-Rwe3)B`DR}hgDZNGdrmxQ6QIR6te|+L z*$rN3(PD9Yt4`W`T`pDa+F{x!WvQtxxiX>ap5Tu{#o-La*V2+X?IP@pG2Q}j{^Xvw zy<`My)0*~PL09;~1+87-vawA*O?VZLd?SNnpQ*Iu_pO&16fgV5H{D)n4xESUXF;o!nkLudt9@cJZ6=d>tuAL}Xcuge z79kf)msXYX&e87(qXgI!`JXk^BT~P<5{*l@dqDS$RWylbKF-tQn(`*vi5z??B(K~s zeW!|;4HYLIs8Xd+s%6-K>mIQ_;O`3lGM=JR+kQ2vi@`ZFIZn1xUds~&4xiqLxq@43 zzf>${-HiUWe6QnDi;VH&n4CVU5!Mt-84@bL6cW!nYrZ^05C0V5i2dl^TI`nT*MZK3 zG!^*^Edf0d`JvdW{`Zd6T>4_hLC&#@bEOXY0SAm#PJgQUTyCCqs*J1jh|iBl>923WG>XuS zZ-;9W`qI>{_pN5d_I90nx^)OYEvza4&c~GO)IYGsDR`Q+evLdIKmXOORrqJM(r9hD zEd0nP=1{es)d5)>KvJ7h13I`r>+Sbzb>+qC*>5i27D+z!cx(e0o;O!HQB`ITCf5-e z`vI;0LNPnW;cisKr0e6aZX^JLJWEmHe*|pp5~$kzuvHqOQhT_fDYHTGQu)^eOXTMW zL#n!eZIMCZ$=+^AeU0(9prSapCm-9GA!iOx;vGM}PKt+S63-vyZp*f7oCa}$j-X|a zj&pt|FUUuKqFELbV6(Z@o#%_gqwx$PVP^Qu^)VjQM!QEq3<;EHi3Nw zm44VmRZ$ktr%5Ya<9VR8%7XcraIyflaA%=EMcg8{H)I1w`WQFH+Vc&Sq~3S-;QBL$ z?l_gL;HMKr5^+T%t+AIq&-v&tAAVk0n(XL!BDUPbDS8WV zmR(S$wr-!Hyg&6OUALqOq48|q9j>uUuMCEm{KRh>8(`J}PdmJw5nl2jd|bU3LTg9u(vc^2GY*i~q>-OchM z)}+DfI^QUtyU5LwTI0GpHlXUOIDSrZQhsdAE2k@g%($zT~+y!89Q*U0zNce*_c4lZ5@pH9B1 zNDncR+!;S6d@P$I3+lNPf4=MU0nLwq#X^_#Cj{r`jYnLDsDf74Kdv{Q9k~uF8XQqq z5y%c+eoY0@>ShGvqPxD8T{x4@RzVaXHzo|k)1+tFqv9~e5;Q*dnHtMkQ(mbd$_q{< zM!qUdK84R&1UspGaKKaA#fwIX0z_Wb?s;gmntH}7{5w*_4fTownxTbf%Y5^Ay|yXu zqM5tsnmjTHFgs~1lj$NA9+@#Zxf$@FO?0Q|tO4{V`?1sqsDST9qS5N0!}DJMY&EF31p!n5g1q{ijt<6$NaH*tFL{Rv4+y{NT*OnV($1^HIwf zf~#7md~xILpF@%*^qrEh6K52%&ZP4zXt`C`z021-T$!HxN9C&C(m>>`@7XBCF5nQF zeeq_YliF_K>Ke3$e$46P2JaRB+FRhawZId5f0LW1mg-5+bxf~`7_>&_pSJii<* zdO*YFll)rD5Bb{rSY-SfvgV9p>gBKj@Ut?W{?CY-@8VR(@Je2kzr3S&mhD_)zriWS zoZ!KlVg=s&MLT|?HY)I#vVv;JmrJzuo5^7>rOcEVD`#zZckXd)4bYXlGde-TY1CNm zlMCP1^D1e07Y!xh;hsq>)dI58>-9*p!LNhIDtoDh#+(c9wYWdD` zIlxdP-!55Xi8Fs(J*uvk?f@Hn{`_72j<+}V;a!X7;nE+iu{UMRAT2=i(Q6X42u5OlW5>Adbzgwz|HWR*^fDD{OLy-Kis{=fxO0 zWvW}GYVeC?&4<~$M`%)l6b3-EgnjJ7`K7O_&Z8}F*mwXldf;sOC;R*Rf9;lN8&r{r Hd(^)GMhk5g literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Dabs.png b/Media/Images/textures/Dabs.png new file mode 100644 index 0000000000000000000000000000000000000000..250708fe6b5dfa660230a3bc8163d693385a5a86 GIT binary patch literal 4208 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 F%aC7MVqjw7x>% z>3q9r_xz?c@$mN3E}p}+=GT67NpfNqJ@mNqwqmjkEUqqyU&$$v$L<+`IfAq*d4ds9 z^=Y9Aoha&Y<*S5lM@MMmT&(TFybGiPmcPSn7JsqZx=S^Bpc9vT?x9u#KPrdT{UdZ_ z$9bMP9~{D1LVuL5>$b;sEk3u=vPO0N@vRmF?`3&}1j3knMI^76XGk$F-Y8&@6JCi-xoFv`miAp?MU;v+C22zUO=a5tKjJFcp#m ze+u?9Bl==~FN}ToI`%%_7CaSyoyr* literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Diagonal.png b/Media/Images/textures/Diagonal.png new file mode 100644 index 0000000000000000000000000000000000000000..b41e49d5750a0d9590bd75cdb4cde9635086a3be GIT binary patch literal 2109 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 literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Glass.png b/Media/Images/textures/Glass.png new file mode 100644 index 0000000000000000000000000000000000000000..4216ad17b2531746194cd08e6beba88aa2557970 GIT binary patch literal 1919 zcmaJ?dstI-7(T!rE{lPf@NiR)M*rOOP-iAqX_`TG&M>x?l#FXm}|x1MSSf`olVZob&y@=X>Ap{Vu=fRLEr# z5;2$v001dDNvHq-7u*CuPgneHK1XZAKS5SerZpAQSqs!=6yRyGY!pm3sB=&Ss@4{6 zJcGsqfLpj;nQ6_Grf@WvfueTCQ0xYilM9GXw42nL4X72&MsxH=E;QBE3xRqq7s`m0 zQl%yVnyXJLGNY+QGNqNpCacrF)y;zGGrtBC`{1qB6^0tN*$=fE@sL0~E!rqjuI1ldw(w5si7qhvC%TuD78ZLuoQe`Gu{ z+M+Bpp|AqAU^cS`pNDSwJQ(lY|0i+=;%YcTOk*>kMr*Q=3*jD$R(iJ5i^i^Y_ic}RU0+vJXZe?mi50_j=+qnt(aMfVR`e@ljmYq z%#w?lK!LzHEd-3r&>OW_fn|lWy>n_&vpyfyip`h-eAFL~{s|iBd}b_D%od47ERj<` z!a`X57&f0N6!WP(mH?W=YM)U199I0FSQr-rJBRzPVb8np4RV@~R~J7#UVhYwZ@d{_ zZLT`M9=|bD$wHpe{_XY90`0KqdPRqX{^p&lfofb6} zzG?d4r9oBSv4r~1Yi(20BeUa0q?Vw7JZ8w2j^@cx?hWjZU0YF}c9T*g?3@loCRjVqiCF2Tz+gR|&vn3N+S$bK2 z*h16k^v|vhPRO=5l-1eet4GrLCEhgKzL)himm4?KYC>I!ZNm-eYcYGJPx#;!$(d~q z|BPo6wjW*6S9SA2)S0@HjW-_>+*Ira_Z}s6Y_W=bN5ywEsZp2ywXuMgZ+YQY(W;e5 z;Pe`zyk`>n#u4vLzxT1mH9Sr8-j24j$6bzDyvm!a?>kOxO^Q0(*0}xF{c2F`SUvcu zTfl0mIV?X=wl&ZXeD_s%fBulCZPMe&nB0GDYos>x!idRHRFy%kow)N{noN;7^mW?G zj^z(+z`mMODdZ*PyGBzsH_=zV0z+w@Gt2zz1Jb%n;!xkXZT+KnYFxjTSB`i2wj3&@ zh!=IGXD!;)_$>47;Gy8?eqg|}^x{38KWSVC@DBQ99Z=J$1LQTN*yE>;jTQPFSav$~ z{r0M+?;_>qy|oW3fXOYR`+NJ=5f0aL0@n-LN!4}lEp{0K^C}hZq_=Q>o_=xcdDo=& zP2QtLAMJ4mQtVIl{Th2_da7WT0V-hjwGZ3ZUwTlx<+!DJO|Z^^4innkz~GeJDQJBE5x*?(&G63W6tOV##(yOCit zJ_E}L_73H&hLPoyQgLsB>k{dcXZEq#EQF(JCINx8Aid><(do{49x6jyp?9Yr!TJ$w)icf0Q2S>7PD!NOQ z#_NQepRV`ExURV0+zw@%dq9gV$2X%hpIi=g=bx1>es0Nd?@}K6h9)uW=!GLc`K(ng z{q!W`s`R|EW0$DZEA^>E#!rqj^SgFT{NC-soS5yb3X=>0Hx2U3=isB$ymekxGwJ>b zjl*wcH3UU5_lJ?AYeKl*e;oFRSk*3*k-o2RuOp(81-h>92fmQMp1Low_`s1H0`l#h zFVs>?7?9+oaA8LfK@sBH-E_Ox^YFFJ+uRwYYY4fkepEHMn?77$-g}}sM6$l@_aL2# z_&QP@O2qiwp&$?FA|-+-E6$_+vM1z003o6?;M{I^lIKlHsS9wY^+nela$dOemzXS) K37h$asZpRvNJRxYbDTIb zIus#V7bA!KVmA5vB2 zq!7qOGFT*)fI3BfsB|YxMQJnrs|2y^tE@!vrA^v~VU&Cs28(vml{5?FaQ;73EdGjC zz+C9>eE+Ag!arIDVYrY&x>GLD4lZoDE|iQ+mqUD5D)*O4BWJtl6Cs7AiU_F;q| zBY~>|MG~PjO5yOC!{LzG5(Uhc2p~3tiqfi}MIs>?N5r`jX&%ljCXtE7G8wKkEQ{t$ zq|uoS9FB;^&vF@3!A>zGfoHkGzqu}Ra&_wWYOHY;OEr) z!WGV)3t>(!Mw<*qH{Aak_N+_0K|1s6>S_;PmmiX7H(st?Z3N*w4*(2hYzEC=dH=&XLi0NmZtm`vPn!>@)oQ;b!NI|qnk=(#=UW)WdnC7z_j}x$WFI)<)3*BKrKzV| znQ2P2+Igv_`z&-}jb~S4tYaSHO3BO}&DN~j$bNjTD8F_U_3G^f_06UD!3j?BH-+af zW0X;p;fS=~u^m5cP(MmP+t6pS!IT@eJ0@V5W%9=*1|_6^I+W{0JDe9B zw*)XN1WsM2Z59yMr=G#VOzY`0#+40gPp2#Q=hol4oZaDtK@1V!-17=;>Mv|qhASrJ zzA0-85Y88$RV8)yH5qa41&(Gub$DBkXj|;tadkpn;@6Mp-xT zdBs)5c_g=c+wt+7Eyo>fgzCeGv&v|Z;SC`cQ+}Flm%c9_++QGNht#^^IZidQGs)b; zLxnTQBy3mn%3PmI+@OrnjStMq6X(A$uG!$~_v>_hgy@jP!#-piK1@buzRg`_9&pjy z+k5Y{x<$?6m08kMM9dzlZRJ>;X+(r0Oi0;w)4|i+wKaR;^~Sp^ZUoTp`(mO)XIzZE zc3i=!MA3~CorUeeWyK|)s_23-PIuaeu#%BWZUbiCdgUVyz|Hrm{k?|qlSR3WkCW<) zdOhiK2N#OV9-=YwWw-aglOR?F(D=eA0&1xqaQWt!8k|*qTE4FOz>%UM-T*#ur|PCvN~+sQ=xBj`SypM+8Y;ZQmE{JFY1RyYQR}|FZxh&jSa0z_*RQYgJjq zHiJOpz;BVgUM~>^znh3EZqI`o(|Ydf?+Tt5pE}+7foj<_^5F?FyMSf(i=HLkZ^=1A z{pRbg_6sK?%9F>Nwy(=bWgW9~IB3{T6sMOWeqH3`%zGENYi$R`@ZwQfs{Z~#iUwa~ zB=O`QqMJJV31~^q25G#iFh)qiEgPHng+;dc=R-oDEhXHN=`;7{HH!!rvjZb-HFsJq zw$MvWBO~$>$L_ZXWOV?9 zTE@@&P+h+8nf)rOEA(96AP+azPWb#<|~aQNUSBOaLXmWJHg@gg}(8n&pR1%1ZC)lgy( zd;Z!65OI5i+Ii^1OKSY7wFgi9Ssrt~-9R-yR_>RhNQr&v`gBHvpLmW$BF+}Er%*S3 nu-K!nF?Z^EH@sqcW-$-A3>f9C^f_*(`zK*DH!{jSczgZ>%U-|g literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Glaze.png b/Media/Images/textures/Glaze.png new file mode 100644 index 0000000000000000000000000000000000000000..1c046b36906e65717b2a39996aa5a0becd9188ba GIT binary patch literal 2483 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 HJ}Bug!w(h> literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Gloss.png b/Media/Images/textures/Gloss.png new file mode 100644 index 0000000000000000000000000000000000000000..5b0416605966652c1f4b23271fe1322b5be4db2c GIT binary patch literal 1585 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K56gZfGWYKBE%|J@B#5JNMI6tkVJh3R1!7(L2 zDOJHUH!(dmC^a#qvhZZ84FdzST4qQ@NrbPDRdRl=ULr`1UPW#J$SejM`-+0ZVRWpPLKv7g%+1Nl+@n8CX>phg24% z>IbD3=a&{G1LGr28KxN+cK9s;;b`gNWMJ-UU})?J)9aa6T#}fVoC?#MiO_3?SFe?GQEFmIeo;t% zehw`50y6ST@{2R_3lyB4L9u12pb?&#my%yztO*KUsB2S;GRsm^+=}vZ6~N(Rm5Id- zhEC3oCKl$dZm#C8Aop7uIRZ&1BWE`g6E`Odp!*?uQ?U3Qs@DytUVWfr^g)q}6xlE# zU7%x;TbZ+0AsX7ANkBVz5R4@y$a5pGKFt8kCQt0?C za$w(ThOUnS9dhd><{O~uFIahk>)NsE4cH8${AgtN+Qzv-sWt`wX;S=`bq9&*Z3$kVGkA+mER(_>y;)$>u& zR)z*fjr^-T)tWWdpR7+=elW0Yjv=Ds*XOfYf7Y%&s_87>JLOqP=WG64uMI5%t9v&I7;fz04Z5%+Z`DEtvt`|PH|84} zJ7t(I@@|{vBN4I1RXy6@a+(TX_cdd`XTRmdgTe~DWM4f Daz;hH literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Graphite.png b/Media/Images/textures/Graphite.png new file mode 100644 index 0000000000000000000000000000000000000000..c8b5a22527ff421b15ee172680d50188e44f6f5d GIT binary patch literal 1070 zcmaJ=Pe>F|7=OE3nIwo1SX~}t^atCWnf>EBldJB|%<7gavM#uUP~*%~hjr#nGtYMP zq|qgU^3XAg4&5xGus=kE1w}+ir=UDYbc(2;Lx)Iuqr2L{oWq;%ef)mk_xt|5iv#^V z4fXr$0RRozUO5jyfSLf-1?lO$;qTMmKB5%KARZ-U)j=So;}Hb2raFf5NY$s#zD6AY z)EqJjMN-W5i5fP;s*eeKrtQZ99bKNSY9&PA2pTi2B=h~rGX@%Zk{OEUxSTDaaie$I zL4(u%1#P;d2|Cl&2|GNI1~3s(p=VB5uIMG1wYVa+eK*U%H3%ssnRQXc+yIoYgJ3+| z7Si}w943S?-xiGt!Z8@(_;!|SXZdJ|Zxdsj7>U5j$Ixt!J}TzrOeGh+C7E$TY>{Ql z<#M04 z78>}3kdoqUN=8X2zW#JX5K>%PiboY8$}4fE!qvB^y~54>m&;NytUug;hFx{h4f4&6 z)uoq>;_h`q+pW0?{A{$RH45u^x3(hM2@}2aV^M3&W!cav3 literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Grid.png b/Media/Images/textures/Grid.png new file mode 100644 index 0000000000000000000000000000000000000000..0435cff57049c99080d7ca628265bb729fece5c1 GIT binary patch literal 6573 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 K8jJ1>i literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Healbot.png b/Media/Images/textures/Healbot.png new file mode 100644 index 0000000000000000000000000000000000000000..5f274b77c5e47bab94029fdf2c37dc984acc6b20 GIT binary patch literal 5858 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 literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Lyfe.png b/Media/Images/textures/Lyfe.png new file mode 100644 index 0000000000000000000000000000000000000000..139f0664556b8932d56a750d0824e331d2c44b4f GIT binary patch literal 6563 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 ab&c4kA5XbHi&$;W&nTJb;b-D6j{gfr1jN|@ literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Otravi.png b/Media/Images/textures/Otravi.png new file mode 100644 index 0000000000000000000000000000000000000000..0ac5a1e7ecfc09fd676b163e95e3a2a7d800648b GIT binary patch literal 1430 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K56gZfGWYKBE%|J@B#5JNMI6tkVJh3R1!7(L2 zDOJHUH!(dmC^a#qvhZZ84FdzST4qQ@NrbPDRdRl=ULr`1UPW#J$SejM`-+0ZVRWpPLKv7g%+1Nl+@n8CX>phg24% z>IbD3=a&{G1LGr28KxN+cK9sYV7J{Y3^bO)9aa6T#}fVoC?#MiO_3^SFe?GQEFmIeo;t% zehw`50y6ST@{2R_3lyB4L9u12pb?&#my%yztO*KUsB2S;GRsm^+=}vZ6~N(Rm5Id- zuC7LI22L*KZmvMb0^RRuXzuK2=3?&bYT)K(;pV6W)tiFF?@+xKIQ8lS9itD5T%^c` z2?0|OhzU=+Kn^_dr{)1ud=W5ddq{VkWME)o^K@|xskrrK&cnPz20Sh0CiXXe?^kfL zVS8kzsInkaXt%F-#=GCfD|L=-to2=^diZPAU%^G;d|Zt$W)@h@6=A)&p5ff{;!n;l z{t*W489dy{6DkZ$yY!sJ>i5f}C?BX=c;I#a7FDaDf$JT{8JS-*cRw<}=Fj#wb?2tZn-7b&C0=j~_;Q=M-Ue8#&!+4*yl!#Cs=tjhm%)5F^1D?`=Gw3xKN0w!`CoDM74 z1r|j!X0QqfHGt6C13B)o=fCY|;A5ZLAtt+?L8xH@LkkeS2w-S!zTotxhSlMhQsB=6 zozXT=XY1wf_dR`M$=nxSJTvD%`knmVwfAMT<(YHBma|oKE*?6;x@_*rwzG9IotmdF zreylvzrlLgRqe*>q$Cx2ZHFzaQKhaQB-vkCv4w14xz9c$|MGr|!(nH=({uS5UoGV? z&B`*#*~0oL%t_$v$6a@lo`-&zlXc_6Tm4zfmZu41HMIg=#iGF2v5vK1XAC%6ivCOb zb=>7naCcYS_ugpT<;NG(rp_-k`}bLRHCxig$m_jN3U=T9A|-J5lllRMSK$SZlR^%@ P0+p1Wu6{1-oD!M<$-xO) literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Rain.png b/Media/Images/textures/Rain.png new file mode 100644 index 0000000000000000000000000000000000000000..8f9904a5b9e6857fd1525243d86a667c293259f3 GIT binary patch literal 2285 zcmaJ@X_M=AOf0+8=|6?SAwIUz#%vYI)*dd8kb+DEzX3u zGRY(v+Kg7FX=bKi=6(;2Hupn!&WCf}|Nr+q&-=W~hm#o`=jt2yQSem@` zSHKg2n7mRh6hWc@K3xJTv0@trYf!3fS_n^;K`oXf0uXWw5HD9TP~DfzD5PAw1Tx(NPbU(Q;|~hX zrjf?c1zex;T=0p3l0lG~j>GBodaT|Z3u@wV1R9No!xM2tA_hiaw8<(+Y{00r=m`Zb zpp|IkYDf;MkTyl}7ElKTG~}uXxk?J^wX1FI9d`?8f5rokO@bYhs6IRj z*XEz<&GqJyc{H*&iA*4oJW%6Y>1S#m=feK~4;KfU!P$oUKf|85f;Y$(ep+4l@M-x0 z6}<5pc(tb`3tl4-_M822{oAMw!Y5lIqZ7)bh#bLlG|4nY z@+SSkUZAO|xw*W)$KZLm_Epjl=k2RknM3I<)>F|-qE`+z>tez9;m*-zjZb^V29mt> z`xlk>e9`!@wv^ESq@uZ-Gq!b%Qn=LW#!1s}#EeMNin=__yH90aS~zlRa`1MAY?X{gOYrXj9Cnw%X3q~KNO|!GE1UzXBabkpI_S1 z;XHMjd$>Pim^%IOlhFgyJ{|gODYxu5d!@yrlLr}{KuPh2!Q&15cM5JtcRsOu73*?a*=wC+ zCx7qg-RNo080+?h`rmS?;TW7oUVYf5X!c)!xP=U+@Uv&FGzcwE=((>87S-@FkDdrU zl>hD9vltLMj*XeKW(6mFNkLV8H%-qUyC!=jXAG{-Vdr0!AD~Q~QHUh5@*Kh9Ue`;? zGm~>&gRW^dt*ei?>E@kSe{lBP4xbjIpd@|Mwyr6AhI20mbo2)n+(b|xTDsbucReZ$ zfzcCPP3E5>wzE~|vqp6##~!`cFPArn_^hL; zA9hytUaVSFTk~XZ--ab4$!iKQLHst(pe}nEx*U#_ifD{>9e}e|1D;yZFO4xkw)(*=DEmukuMzM zGK_6guVjRZoR)VwU--S~Wfrvawhw0H`s+2!1!sqx>&(|sTuj%_xx4*4DB5%0=N=AI z%G4F#87nK^PrEl3VMoZ2SuI2I@Z!9$ThOiPUTHqx#kRIQ5A!#Nx94;eFokQ|-AFNL zG?YEM_ww!gN3L7&A%*{`Upl|JtrC+xnwws`+|6Zi)#F6oT$jQObHKEa`emU#PBk}3 z)|{S$C4byTAa;FW%IZ5;j*eOO6t_#%?n=%JlC_+bN87u62px@{5l8fWnZF{i<{O87 zx)nvwDl6=!Fcs6UTwF6K3M{YQaffiKs7cBuw!1Vfxw2smrFO`0Wsm-iyA`wsuEi)R zVa$a+SdaQC`|su4em=_zs+*H4C3S1tvL!cu_|nA<8SAp}O9#TtopVk^PCxJJkYus$ zt6;f;!Vcrz8Mw^}2aDOJ2$iAi#kPU$_M8j9WEd6tqP$tA1aVj2w{_{8n#!U?-49;o zZMHaZ4;A)azf{0xwd_3I9=}&tl3%*b$??aLx(1ptCR_(5RrXk0^k2nNoS;1oin!)O zk#^k53~kn9s*nU9# Mc!As>ykocj7u~4bApigX literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Rocks.png b/Media/Images/textures/Rocks.png new file mode 100644 index 0000000000000000000000000000000000000000..ee8c868054f7af4d070a9e8a4fcfa7124f3b46db GIT binary patch literal 11161 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 literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Round.png b/Media/Images/textures/Round.png new file mode 100644 index 0000000000000000000000000000000000000000..5970c2bc0a0eacb7f879b719ee22b7d4cbb034ed GIT binary patch literal 1692 zcmaJ?X;2eq82&;`q?jO1Emsw`0cjj17fB$In2-xnxru-%M@TjhAlY@Z$bz7`@Qo%&-=W`H@i78 zKAP#_?*RZ{>SEN%08mI1K<;kj8@tGFB|m<+CLK>f%y@|bgF%!D$%P@E)sP1#!v<67 z>Rvb!0BVRupN^-;#Y&Bcm1A(la0n~v;sTL!0yP*5VI0bZ^DH(Q>q+}r7GyEWSZSg- zUL2}|^DQxD7@SfTuQ!$z8YL!{d?^%3NJ#=Kj2j@rT4b|J2^njWS4!G0HRQjp}D;gK~n%HKaY82TrE@42qd=8ImwYut>jJD&+@V_$N z7;V>=qA)iZwj;%uk(`G)Xc|oR?(KVf%UGm`W3rf}BBdr=EEI~g znurKKU!ztEL_(!d5T+GtReYgZFpbsO?6|>Zgr~8Vf3d>1Vx=k!HsA=RN06fF=_TeP zIAYI7P)Mb6O-ll?(=0Xm{?LK!b6W)3dvM48dDd#UHC%&e`;AE%c+OgY$d zEH$H7@VB|ul0P)EDD&Eu;Wq30*=Xmn`&z1h!(x9^-W)}0$gQ>1zxyqZws&{yux5YRl-1&Q%gP;_`0VO)MP|D%Jz^?ClOZQMleo)v)(clN;){)rCTbE`m*K zDxBdCKfr9OVr)B;HJ|=S=i^zPVD_cK1^Ne|u6Af%M!&}C@CGMWaqhc8ZS^@PY~m$* zN9ym$Dd^Ic(gpIh-(JYM;w<%_6SRQ&yeL5x(8u24HorDHE5cB_+%I;cvsF=b{evDN zv#_NN?ygm`L1+7xh7W;b#Ch+s_(5>u_lFAohtUZ3x-U+zJU`abGN(#;eBWQvHFQVT zYW2p`PHW~m`oNRXs)XL^ljk1amezF1GP^hVo^bd6Jn6hVqahEY^tgfco18$82JFuz zMC^BPVDNLa^s5{By5S^kgzzMODd3N=o}5*nsBNVt@%JRp!BZ7^=GGOrN9O>R@%pm~ zhU#m>{*~r0YgT~9OAo5t%15J&`GNft&6K;(y2eXe6<(Ssc{PQm9GzvQ9IGazz_Dlx zeJ*=!wDYrftX}4Gv*c`AGM?G&R>J_p9|aeGbLnRp|LCTHz=vK7sWG(X_=^17E?-)1NkuUAD6)|7c^H)G*!x07CA`^{jGPd<-<`6KGP>`(pnq|qr>Ois zJ%(9#3m)=!@IZ6?zT>AA+>V-p1ovNV(VB{U-1jJUw@?RC1oD~04$6Xn<=wg{|lWaUVT`Zv*91zc54Oz literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Runes.png b/Media/Images/textures/Runes.png new file mode 100644 index 0000000000000000000000000000000000000000..9a0f887e4da90a8467f0499c6a401d4a7e83a15f GIT binary patch literal 6309 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 JRK+&@e*m5S%Par@ literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Skewed.png b/Media/Images/textures/Skewed.png new file mode 100644 index 0000000000000000000000000000000000000000..b308a0d15bda71ea953fd3dc0eb919414dc8b189 GIT binary patch literal 2409 zcmbVOdpMJQAKx5$JXBZZn1{={g;X}1opanq$PiZ48`gHqVh7u_DQ1z=o1~P^s3$2( zJr0xdCQmx7LLNm%Nky@cbCh|f()0e&_5AVPf86))_q#uz&-Z*?zYKrh^^0^(bs-SQ zA}>!$00aV6FCj1;4fUJ<2wATFEEl>53j?_kLJ^%0Lfn|#?I6sHP2T|qfOKYDj2v`? zKr}h5pkQGz)rZL7vf=bO3|!3S&1pd#UBo;(BMKD4wu3uZ94BDtW-S0?F`a>ng$SdZ zfNxF(Q~hCNE+2$p;8;5b5{-f32yi6U0ZkyRg`w<`c!WJ3fpoA#Vu@&bA_@ij{s3xg zd}ahOfa3ApmU`y|L<)sGA_5^2iQpm!IG4Wzfg}(J2zwL)g|bs4>;!QfAzf_85m?SE zP(T5L&*BMLTn=nbk-nW9D|7`oTSF}JF z0RHX9Ptk&)I39=y00rDwK0{rP2+Mi0I(PqUXb!0MhUmv*@LqBsHS zKX4|CNu=P=WHO12_Hf5hkVtn5o=g-y)`feAX_I>A~l+VPA7hWc|bkYy^gZVxlm1NP9ZY zj!8#@b_4>3Zbv}Tu>=AW#6&PLz;{0L|H&Of?GR$FN&cs4=8x2!IJf*U0_x3=Ap$w- z$>FOB*cMV_BIXhc4oL--hy6$F|?}o@q&yD}0 zouZ6sa{FRBdid=vk6-om^{tOwG<{Stu(|eD6+NaSx3#A5K*Z$4L@p`{1g*gK^I=^b z*N#fG>mF{hu&@XzI?+2U=sZefbTXc|{->tqe5UdS-0(s8mhCjHvfy!*DsRMjllHLb zq)8&RC}C`k-j%0$^~0mW7h=b(;A)vH_uRdw4 zjNtmwl9G~!ZNBik1FXhhmZ?%rCNPON?%%)fxl$&R0r&5Hz9iGi0cwt;GWKhf|;Md;_skJ$r*gQO|xGZEhBAg%m;VdhR7D6q}i?Z9=ZP8YMv1ry2F*6b?^Q&5Vj;$aD7logIs~LzR|>?H@>xyO!5~ z_SXC@+}P!1`j$XlqIYtVrXF76Y3N#O(1h>h>)$pj#FQaVt=6AW;kjtjDS=H+|LxAY zy1kCk4&rNMJ?$o zYi*}ahm;?M^g^C~NZ9K4W?_NPt>OEoy|5-P7YF6z3F6^BX=!PV%DYAhYoIqn0z~&)zW`_QEL9j#=8glx?9z8NQvWZ)%|adND?dFUAubux>*W4bUFWeYCQSv zgM$|uzjQs~6@;~RH5TID6(4k9qKnLYYtLqEd)G_4dGkj0%X@v1DQ(8PDd2Y3S-HL% z)0BB+3HQ_6W8Nt3hw=#T_@WyR&9CG{Cx$LuXQ?xgHd0u1Me?hQ)?`6g`NaG84{U{| zunOy~t?d^>vTXfwdPF+>^?DLG9F8`a36$T}3fHp>+vsd+{(9|!)Slj^Lr|0*>zRjy z+wa5gd1&ntR2m)lw&XaZOx0|1=&#EgQ;b%oEd@=D?ybv_YXPUr8(XJWg*3`(LJz8y zwKTU*F?&s>v=W?>JP>%S9+{e|zCbA%MGt7pMyE-F3%RT^rlIMtR?sK)e?ko6buK+F zFRoh0TsyKhJu%N*_9UEFT0BvB#N5IHF;#8UL$-pVXMP{|(f}&k%YD9RV02Ghu!Y76 z*PK7m+Pu@WQg_-`zq?K@&&Y<*s?Kdnfgk7(F?=2Vhr*_Eh!=T=Lu>A?TAlE!sy~Xc z!u3kDZm`+nG}<$0gPw)|r6j1yW0FsDOG^tO2y*!nN;iq-nrOI2p>!k#r#P>&-FrAW zNN>aV(){aJ0~W+AJ?VNjwiKXB+Y1zIoqSh=Tah+j^8Z-kel`bsOhb9BQDacM?D!qG zO`mDg@-B-Nr_UUYuyLL>Nbb+9Nhql3Pbto@8Z>q;3rVOq%PXrWTJn}-IQC2ttgtYq z$~)X>6SU^A8My4@u(EjFK4>E5oE4&>&i#>5O7VVGS8uJh31ro-7td2CyT;}&KVI&> Klu}Z7(tiOA{|UJO literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Smudge.png b/Media/Images/textures/Smudge.png new file mode 100644 index 0000000000000000000000000000000000000000..df158eb33868c06888f179db5ea18fe77bcd8807 GIT binary patch literal 7036 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 L>#3Ei*aZI#TFb26aJ1@RT4A5041) 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 wBuW7MyndnXeczc6Gd=Yq07`73e%1J@8&egFUf literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Water.png b/Media/Images/textures/Water.png new file mode 100644 index 0000000000000000000000000000000000000000..ea7ef1f3dd6a48b742f940474dfca80a94cf849a GIT binary patch literal 3997 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 CJv0~q literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Wisps.png b/Media/Images/textures/Wisps.png new file mode 100644 index 0000000000000000000000000000000000000000..b15cb736240aa7b2487fa4850b858d76b73fcbb0 GIT binary patch literal 2995 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 CWoDuP literal 0 HcmV?d00001 diff --git a/Media/Images/textures/Xeon.png b/Media/Images/textures/Xeon.png new file mode 100644 index 0000000000000000000000000000000000000000..3157f8abf466da589de89c9c2b12afc2f14f7357 GIT binary patch literal 5717 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 "HSUI"; + + public static string Version { get; private set; } = ""; + + private HudManager _hudManager = null!; + + public delegate void JobChangedEventHandler(uint jobId); + public static event JobChangedEventHandler? JobChangedEvent; + private uint _jobId = 0; + + public static double LoadTime = -1; + + public Plugin( + IBuddyList buddyList, + IClientState clientState, + ICommandManager commandManager, + ICondition condition, + IDalamudPluginInterface pluginInterface, + IDataManager dataManager, + IFramework framework, + IGameGui gameGui, + IJobGauges jobGauges, + IObjectTable objectTable, + IPartyList partyList, + ISigScanner sigScanner, + IGameInteropProvider gameInteropProvider, + ITargetManager targetManager, + IPluginLog logger, + ITextureProvider textureProvider, + IAddonLifecycle addonLifecycle, + IChatGui chat, + ISeStringEvaluator seStringEvaluator) + { + BuddyList = buddyList; + ClientState = clientState; + CommandManager = commandManager; + Condition = condition; + PluginInterface = pluginInterface; + DataManager = dataManager; + Framework = framework; + GameGui = gameGui; + JobGauges = jobGauges; + ObjectTable = objectTable; + PartyList = partyList; + SigScanner = sigScanner; + GameInteropProvider = gameInteropProvider; + TargetManager = targetManager; + UiBuilder = PluginInterface.UiBuilder; + Logger = logger; + TextureProvider = textureProvider; + AddonLifecycle = addonLifecycle; + Chat = chat; + SeStringEvaluator = seStringEvaluator; + + if (pluginInterface.AssemblyLocation.DirectoryName != null) + { + AssemblyLocation = pluginInterface.AssemblyLocation.DirectoryName + "\\"; + } + else + { + AssemblyLocation = Assembly.GetExecutingAssembly().Location; + } + + Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0.0"; + + KamiToolKitLibrary.Initialize(pluginInterface); + + FontsManager.Initialize(AssemblyLocation); + BarTexturesManager.Initialize(AssemblyLocation); + LoadBanner(); + + ConfigurationManager.Initialize(); + ProfilesManager.Initialize(); + ConfigurationManager.Instance.LoadOrInitializeFiles(); + + FontsManager.Instance.LoadConfig(); + BarTexturesManager.Instance.LoadConfig(); + + ClipRectsHelper.Initialize(); + GlobalColors.Initialize(); + InputsHelper.Initialize(); + NameplatesManager.Initialize(); + PartyManager.Initialize(); + PartyCooldownsManager.Initialize(); + PullTimerHelper.Initialize(); + ActionBarsManager.Initialize(); + TextTagsHelper.Initialize(); + TooltipsHelper.Initialize(); + PetRenamerHelper.Initialize(); + HonorificHelper.Initialize(); + WotsitHelper.Initialize(); + WhosTalkingHelper.Initialize(); + + _hudManager = new HudManager(); + + UiBuilder.Draw += Draw; + UiBuilder.OpenConfigUi += OpenConfigUi; + + FontsManager.Instance.BuildFonts(); + + CommandManager.AddHandler( + "/hsui", + new CommandInfo(PluginCommand) + { + HelpMessage = "Opens the HSUI configuration window.\n" + + "/hsui toggle → Toggles HUD visibility.\n" + + "/hsui show → Shows HUD.\n" + + "/hsui hide → Hides HUD.\n" + + "/hsui toggledefaulthud → Toggles the game's Job Gauges visibility.\n" + + "/hsui forcejob → Forces HSUI to show the HUD for the given Job short name.\n" + + "/hsui profile → Switch to the given profile.\n" + + "/hsui mouse → Toggles special input handling for extra mouse buttons when hovering HSUI elements.\n" + + "/hsui debug dragdrop → Toggles debug logging for action bar drag & drop (all hotbars). Logs SwapSlots before/after, release-outside, item payload resolution.\n" + + "/hsui debug tooltips → Toggles debug logging for tooltips.\n" + + "/hsui debug hud → Dumps HudLayout addon names and hashes to the log (for HUD hiding).\n" + + "/hsui debug hotbarslots → Dumps all hotbar slot CommandType/CommandId to the log (for SwapSlots diagnosis).\n" + + "/hsui debug macro → Dumps HotbarSlot + RaptureMacroModule.Macro memory for the slot (macro persistence).", + ShowInHelp = true + } + ); + + CommandManager.AddHandler( + "/hui", + new CommandInfo(PluginCommand) + { + HelpMessage = "Opens the HSUI configuration window.\n" + + "/hui toggle → Toggles HUD visibility.\n" + + "/hui show → Shows HUD.\n" + + "/hui hide → Hides HUD.\n" + + "/hui toggledefaulthud → Toggles the game's Job Gauges visibility.\n" + + "/hui forcejob → Forces HSUI to show the HUD for the given Job short name.\n" + + "/hui profile → Switch to the given profile.\n" + + "/hui mouse → Toggles special input handling for extra mouse buttons when hovering HSUI elements.", + ShowInHelp = true + } + ); + + WotsitHelper.Instance?.Update(); + + if (ConfigurationManager.Instance?.IsChangelogWindowOpened == false) + { + LoadTime = ImGui.GetTime(); + } + } + + public void Dispose() + { + Logger.Info("Starting HSUI Dispose v" + Version); + Dispose(true); + GC.SuppressFinalize(this); + } + + private void LoadBanner() + { + string bannerImage = Path.Combine(Path.GetDirectoryName(AssemblyLocation.TrimEnd('\\')) ?? "", "Media", "Images", "banner_short_x150.png"); + + if (File.Exists(bannerImage)) + { + try + { + BannerTexture = TextureProvider.GetFromFile(bannerImage); + } + catch (Exception ex) + { + Logger.Error($"Image failed to load. {bannerImage}\n\n{ex}"); + } + } + else + { + Logger.Debug($"Image doesn't exist. {bannerImage}"); + } + } + + private void PluginCommand(string command, string arguments) + { + var configManager = ConfigurationManager.Instance; + + if (configManager.IsConfigWindowOpened && !configManager.LockHUD) + { + configManager.LockHUD = true; + } + else + { + bool printHUDStatus = false; + + switch (arguments) + { + case "toggle": + ConfigurationManager.Instance.ShowHUD = !ConfigurationManager.Instance.ShowHUD; + printHUDStatus = true; + break; + + case "toggledefaulthud": + HUDOptionsConfig config = ConfigurationManager.Instance.GetConfigObject(); + config.HideDefaultJobGauges = !config.HideDefaultJobGauges; + string defaultJobGaugeStr = config.HideDefaultJobGauges ? "hidden" : "visible"; + Chat.Print($"Default Job Gauges are {defaultJobGaugeStr}."); + break; + + case "show": + ConfigurationManager.Instance.ShowHUD = true; + printHUDStatus = true; + break; + + case "hide": + ConfigurationManager.Instance.ShowHUD = false; + printHUDStatus = true; + break; + + case { } argument when argument.StartsWith("mouse"): + string[] mouseArgs = argument.Split(" "); + if (mouseArgs.Length > 1) + { + if (mouseArgs[1] == "on") + InputsHelper.Instance?.ToggleProxy(true); + else if (mouseArgs[1] == "off") + InputsHelper.Instance?.ToggleProxy(false); + } + string mouseStr = InputsHelper.Instance?.IsProxyEnabled == true ? "enabled" : "disabled"; + Chat.Print($"HSUI special mouse handling is currently {mouseStr}."); + break; + + case { } argument when argument.StartsWith("forcejob"): + string[] args = argument.Split(" "); + if (args.Length > 1) + { + if (args[1].Equals("off", StringComparison.OrdinalIgnoreCase)) + { + ForcedJob.Enabled = false; + return; + } + var job = typeof(JobIDs).GetField(args[1].ToUpperInvariant()); + if (job != null) + { + ForcedJob.Enabled = true; + ForcedJob.ForcedJobId = (uint)(job.GetValue(null) ?? JobIDs.ACN); + } + } + break; + + case { } argument when argument.StartsWith("profile"): + string[] profile = argument.Split(" ", 2); + if (profile.Length > 1) + { + ProfilesManager.Instance?.CheckUpdateSwitchCurrentProfile(profile[1]); + } + break; + + case "debug dragdrop": + case "debug drag": + var hotbarConfigs = configManager.GetObjects(); + bool newState = hotbarConfigs.Count == 0 || !hotbarConfigs.Exists(c => c.DebugDragDrop); + foreach (var bar in hotbarConfigs) + bar.DebugDragDrop = newState; + configManager.SaveConfigurations(); + Chat.Print($"HSUI drag-drop debug logging is {(newState ? "ON" : "OFF")} for all hotbars."); + break; + + case "debug tooltips": + var tooltipsConfig = configManager.GetConfigObject(); + tooltipsConfig.DebugTooltips = !tooltipsConfig.DebugTooltips; + configManager.SaveConfigurations(); + Chat.Print($"HSUI tooltip debug logging is {(tooltipsConfig.DebugTooltips ? "ON" : "OFF")}."); + break; + + case "debug hud": + HSUI.Helpers.HudLayoutHashHelper.DumpHudLayoutAddonsToLog(); + Chat.Print("HSUI: HudLayout addon names and hashes dumped to the log (Dalamud log window or dev plugin)."); + break; + + case "debug hotbarslots": + HSUI.Helpers.ActionBarsManager.Instance?.DumpSlotStateToLog(); + Chat.Print("HSUI: Hotbar slot state dumped to the log."); + break; + + case "debug macromenu": + HSUI.Helpers.ActionBarsManager.Instance?.DumpMacroMenuToLog(); + Chat.Print("HSUI: Macro menu state dumped to the log (open Macro menu first for best data)."); + break; + + case { } arg when arg.StartsWith("debug macro"): + var macroParts = arg.Split(" ", StringSplitOptions.RemoveEmptyEntries); + if (macroParts.Length >= 4 && int.TryParse(macroParts[2], out int macroBar) && int.TryParse(macroParts[3], out int macroSlot)) + { + HSUI.Helpers.ActionBarsManager.Instance?.DumpMacroSlotMemoryToLog(macroBar, macroSlot); + Chat.Print($"HSUI: Macro slot memory for bar {macroBar} slot {macroSlot} dumped to the log."); + } + else + Chat.Print("Usage: /hsui debug macro (e.g. /hsui debug macro 1 2)"); + break; + + default: + configManager.ToggleConfigWindow(); + break; + } + + if (printHUDStatus) + { + string hudStr = ConfigurationManager.Instance.ShowHUD ? "visible" : "hidden"; + Chat.Print($"HSUI HUD is {hudStr}."); + } + } + } + + private void UpdateJob() + { + var player = ObjectTable.LocalPlayer; + if (player is null) return; + + uint newJobId = player.ClassJob.RowId; + if (ForcedJob.Enabled) + newJobId = ForcedJob.ForcedJobId; + + if (_jobId != newJobId) + { + _jobId = newJobId; + JobChangedEvent?.Invoke(_jobId); + } + } + + private static bool _drawInProgress; + + private void Draw() + { + if (_drawInProgress) return; + _drawInProgress = true; + try + { + UpdateJob(); + + UiBuilder.OverrideGameCursor = false; + + ConfigurationManager.Instance.Draw(); + try { NameplatesManager.Instance?.Update(); } + catch (Exception ex) { Logger.Warning($"NameplatesManager.Update: {ex.Message}"); } + try { PartyManager.Instance?.Update(); } + catch (Exception ex) { Logger.Warning($"PartyManager.Update: {ex.Message}"); } + + try + { + using (FontsManager.Instance.PushDefaultFont()) + { + _hudManager?.Draw(_jobId); + } + } + catch (Exception e) + { + Logger.Error("Something went wrong!:\n" + e.StackTrace); + } + + InputsHelper.Instance.OnFrameEnd(); + } + finally + { + _drawInProgress = false; + } + } + + private void OpenConfigUi() + { + ConfigurationManager.Instance.ToggleConfigWindow(); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + + // Stop UI callbacks first so no Draw/OpenConfigUi runs during teardown + try + { + Logger.Info("\tRemoving commands..."); + CommandManager.RemoveHandler("/hsui"); + CommandManager.RemoveHandler("/hui"); + } + catch (Exception e) { Logger.Error("Error removing commands: " + e.Message); } + + try + { + Logger.Info("\tUnsubscribing from UIBuilder events..."); + UiBuilder.Draw -= Draw; + UiBuilder.OpenConfigUi -= OpenConfigUi; + } + catch (Exception e) { Logger.Error("Error unsubscribing UIBuilder: " + e.Message); } + + try + { + Logger.Info("\tSaving configurations..."); + ConfigurationManager.Instance?.SaveConfigurations(true); + ConfigurationManager.Instance?.CloseConfigWindow(); + } + catch (Exception e) { Logger.Error("Error saving/closing config: " + e.Message); } + + TryDispose("InputsHelper", () => InputsHelper.Instance?.Dispose()); + TryDispose("HudManager", () => _hudManager?.Dispose()); + TryDispose("BarTexturesManager", () => BarTexturesManager.Instance?.Dispose()); + TryDispose("ClipRectsHelper", () => ClipRectsHelper.Instance?.Dispose()); + TryDispose("ExperienceHelper", () => ExperienceHelper.Instance?.Dispose()); + TryDispose("FontsManager", () => FontsManager.Instance?.Dispose()); + TryDispose("GlobalColors", () => GlobalColors.Instance?.Dispose()); + TryDispose("NameplatesManager", () => NameplatesManager.Instance?.Dispose()); + TryDispose("PartyCooldownsManager", () => PartyCooldownsManager.Instance?.Dispose()); + TryDispose("PartyManager", () => PartyManager.Instance?.Dispose()); + TryDispose("PullTimerHelper", () => PullTimerHelper.Instance?.Dispose()); + TryDispose("ActionBarsManager", () => ActionBarsManager.Instance?.Dispose()); + TryDispose("ProfilesManager", () => ProfilesManager.Instance?.Dispose()); + TryDispose("SpellHelper", () => SpellHelper.Instance?.Dispose()); + TryDispose("TooltipsHelper", () => TooltipsHelper.Instance?.Dispose()); + TryDispose("HonorificHelper", () => HonorificHelper.Instance?.Dispose()); + TryDispose("PetRenamerHelper", () => PetRenamerHelper.Instance?.Dispose()); + TryDispose("WotsitHelper", () => WotsitHelper.Instance?.Dispose()); + TryDispose("WhosTalkingHelper", () => WhosTalkingHelper.Instance?.Dispose()); + + try + { + Logger.Info("\tRebuilding fonts..."); + UiBuilder.FontAtlas.BuildFontsAsync(); + } + catch (Exception e) { Logger.Error("Error rebuilding fonts: " + e.Message); } + + try { KamiToolKitLibrary.Dispose(); } + catch (Exception e) { Logger.Error("Error disposing KamiToolKit: " + e.Message); } + + TryDispose("ConfigurationManager", () => ConfigurationManager.Instance?.Dispose()); + } + + private static void TryDispose(string name, Action dispose) + { + try + { + Logger.Info("\tDisposing " + name + "..."); + dispose(); + } + catch (Exception e) { Logger.Error("Error disposing " + name + ": " + e.Message); } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1b66b1 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# HSUI + +**A modern HUD replacement for Final Fantasy XIV, built for customization.** + +HSUI provides a highly configurable HUD replacement, recreated from DelvUI using KamiToolKit, FFXIVClientStructs, and Dalamud. Replace and customize your entire in-game HUD with a clean, flexible interface. + +![HSUI Banner](Media/Images/banner_short.png) + +--- + +## Features + +- **Unit Frames** — Player, target, focus target, target of target with customizable health bars, labels, and icons +- **Castbars** — Player and target castbars with interrupt indicators +- **Job Gauges** — All job-specific gauges (DRK, WAR, MNK, NIN, etc.) +- **Party Frames** — Configurable party list with health, buffs, debuffs, and cooldowns +- **Nameplates** — Customizable enemy and friendly nameplates +- **Status Effects** — Player and target buff/debuff display +- **Enemy List** — Enhanced enemy list with debuff tracking +- **Hotbars** — Configurable action bars with: + - Multiple layout options (12×1, 6×2, 4×3, 3×4, 2×6, 1×12) + - Drag-and-drop from game UI (Actions, Macros, Inventory) + - Shift+drag to rearrange, release outside to clear + - Cooldown overlays and combo highlighting +- **Profiles** — Save and load configurations, import from DelvUI +- **Other Elements** — Experience bar, GCD indicator, limit break, pull timer, MP ticker + +--- + +## Installation + +### Via Custom Plugin Repository (Recommended for FC/local installs) + +1. Open **XIVLauncher** → **Dalamud Settings** → **Experimental** +2. Add a custom plugin repository: + ``` + https://raw.githubusercontent.com/Knack117/HSUI/main/pluginmaster.json + ``` +3. Open the **Plugin Installer**, enable "Show custom plugin repositories", and install **HSUI** + +### Manual Installation + +1. Download the latest release from [Releases](https://github.com/Knack117/HSUI/releases) +2. Extract the contents to your Dalamud plugins folder: + - `%AppData%\XIVLauncher\installedPlugins\HSUI\` + +--- + +## Commands + +| Command | Description | +|--------|-------------| +| `/hsui` | Open HSUI settings | +| `/hui` | Alias for `/hsui` | + +--- + +## Building from Source + +### Prerequisites + +- .NET 10 SDK +- [KamiToolKit](https://github.com/KamiToolKit/KamiToolKit) (cloned to `../repos/KamiToolKit-master/`) + +### Build + +```bash +dotnet build -c Release +``` + +The built plugin will be in `bin/Release/`. + +--- + +## Project Structure + +``` +HSUI/ +├── Config/ # Configuration system +├── Enums/ # Shared enumerations +├── Helpers/ # Utilities and managers +├── Interface/ # HUD elements and configs +├── Media/ # Fonts, images, default profiles +├── HSUI.json # Plugin manifest +├── changelog.md # Version history +└── README.md +``` + +--- + +## Credits + +- **DelvUI** — Original design and inspiration +- **KamiToolKit** — UI framework +- **FFXIVClientStructs** — Game data structures +- **Dalamud** — Plugin framework + +--- + +## License + +This project uses code from [MOActionPlugin](https://github.com/attickdoor/MOActionPlugin) (GPL-3.0). See individual files for copyright notices. diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..576faf5 --- /dev/null +++ b/changelog.md @@ -0,0 +1,6 @@ +# 1.0.0.0 +- Initial release. HSUI recreation of DelvUI using KamiToolKit, FFXIVClientStructs, and Dalamud. +- Full HUD replacement: unit frames, castbars, job gauges, nameplates, party frames, status effects, enemy list, and more. +- Profiles, config import/export, and /hsui, /hui commands. +- Configurable hotbars: drag-and-drop from game UI (Actions, Macros, Inventory), multiple bar layouts (12×1, 6×2, 4×3, 3×4, 2×6, 1×12), Shift+drag to rearrange, release outside to clear. +- Hotbar tooltips with correct display for GeneralActions (e.g. Teleport), macros, and combat actions. diff --git a/pluginmaster.json b/pluginmaster.json new file mode 100644 index 0000000..bf101b7 --- /dev/null +++ b/pluginmaster.json @@ -0,0 +1,23 @@ +[ + { + "Author": "Knack117", + "Name": "HSUI", + "Punchline": "A modern HUD replacement built for customization.", + "Description": "HSUI provides a highly configurable HUD replacement for FFXIV, recreated from DelvUI using KamiToolKit, FFXIVClientStructs, and Dalamud. Features unit frames, castbars, job gauges, nameplates, party frames, status effects, enemy list, configurable hotbars with drag-and-drop, and profiles.", + "Changelog": "", + "InternalName": "HSUI", + "AssemblyVersion": "1.0.0.0", + "RepoUrl": "https://github.com/Knack117/HSUI", + "ApplicableVersion": "any", + "Tags": ["UI", "HUD", "Unit Frames", "Nameplates", "Party Frames", "Hotbars"], + "CategoryTags": ["UI"], + "DalamudApiLevel": 14, + "IconUrl": "https://raw.githubusercontent.com/Knack117/HSUI/main/Media/Images/icon.png", + "ImageUrls": [], + "DownloadLinkInstall": "https://github.com/Knack117/HSUI/releases/download/v1.0.0.0/latest.zip", + "IsHide": false, + "IsTestingExclusive": false, + "DownloadLinkTesting": "https://github.com/Knack117/HSUI/releases/download/v1.0.0.0/latest.zip", + "DownloadLinkUpdate": "https://github.com/Knack117/HSUI/releases/download/v1.0.0.0/latest.zip" + } +]