commit e0f96ce3cbb505b90a32a60cc78c1c9bf26784dc Author: ilitirit Date: Thu Mar 23 20:57:32 2023 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfcfd56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,350 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ diff --git a/CurrentDutyInfo.cs b/CurrentDutyInfo.cs new file mode 100644 index 0000000..cd82f70 --- /dev/null +++ b/CurrentDutyInfo.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Lumina.Excel.GeneratedSheets; + +namespace Expedience +{ + public class CurrentDutyInfo + { + public PlayerCharacter Player { get; set; } + public string PlaceName { get; set; } + public List Party { get; set; } + } +} diff --git a/Expedience.csproj b/Expedience.csproj new file mode 100644 index 0000000..31fe6b8 --- /dev/null +++ b/Expedience.csproj @@ -0,0 +1,69 @@ + + + + MgAl2O4 + 1.8.0.0 + Pat counter + (c) 2023 MgAl2O4 + https://github.com/MgAl2O4/ExpedienceDalamud + + + + net7.0-windows + x64 + 9.0 + true + false + false + true + true + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + + + + + + + + + + + + $(DalamudLibPath)Dalamud.dll + false + + + $(DalamudLibPath)ImGui.NET.dll + false + + + $(DalamudLibPath)ImGuiScene.dll + false + + + $(DalamudLibPath)FFXIVClientStructs.dll + false + + + $(DalamudLibPath)Lumina.dll + false + + + $(DalamudLibPath)Lumina.Excel.dll + false + + + + + + PreserveNewest + + + + + + + + diff --git a/Expedience.json b/Expedience.json new file mode 100644 index 0000000..81cb00a --- /dev/null +++ b/Expedience.json @@ -0,0 +1,11 @@ +{ + "Author": "ilitirit", + "Name": "Expedience", + "Punchline": "Ahahaha worthless buff", + "Description": "Records and uploads Duty Completion times", + "InternalName": "Expedience", + "ApplicableVersion": "any", + "Tags": [ "speed", "duty" ], + "CategoryTags": [ "social" ], + "DalamudApiLevel": 8 +} diff --git a/Expedience.sln b/Expedience.sln new file mode 100644 index 0000000..a7b196a --- /dev/null +++ b/Expedience.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33424.131 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Expedience", "Expedience.csproj", "{8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Debug|x64.ActiveCfg = Debug|x64 + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Debug|x64.Build.0 = Debug|x64 + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Release|x64.ActiveCfg = Release|x64 + {8F1BDDC2-28F2-4CE2-8FB0-8FC58B4F6D9F}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8C1CD108-3B08-43AC-9FD6-A37A6E06CD4B} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a697d04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 MgAl2O4 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..191ef2d --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Expedience +Dalamud plugin for recording and uploading duty completion times + diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..5c83d8e Binary files /dev/null and b/assets/icon.png differ diff --git a/packages.lock.json b/packages.lock.json new file mode 100644 index 0000000..467f0f2 --- /dev/null +++ b/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net7.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[2.1.11, )", + "resolved": "2.1.11", + "contentHash": "9qlAWoRRTiL/geAvuwR/g6Bcbrd/bJJgVnB/RurBiyKs6srsP0bvpoo8IK+Eg8EA6jWeM6/YJWs66w4FIAzqPw==" + } + } + } +} \ No newline at end of file diff --git a/plugin/Configuration.cs b/plugin/Configuration.cs new file mode 100644 index 0000000..cd9bb45 --- /dev/null +++ b/plugin/Configuration.cs @@ -0,0 +1,90 @@ +using Dalamud.Configuration; +using Dalamud.Plugin; +using System; +using System.Collections.Generic; + +namespace Expedience +{ + [Serializable] + public class EmoteDataConfig + { + public string OwnerName { get; set; } + public int EmoteId { get; set; } = 0; + public int Counter { get; set; } = 0; + + public bool IsValid() { return !string.IsNullOrEmpty(OwnerName) && (EmoteId > 0) && (Counter > 0); } + } + + [Serializable] + public class Configuration : IPluginConfiguration + { + private static int VersionLatest = 1; + + public int Version { get; set; } = VersionLatest; + + public Dictionary mapPats { internal get; set; } = new(); + + public List Emotes { get; set; } = new(); + + public bool showSpecialPats { get; set; } = true; + public bool showFlyText { get; set; } = true; + public bool showCounterUI { get; set; } = false; + public bool canTrackDotes { get; set; } = true; + + [NonSerialized] + private DalamudPluginInterface pluginInterface; + + public void Initialize(DalamudPluginInterface pluginInterface) + { + this.pluginInterface = pluginInterface; + + switch (Version) + { + case 0: + MigrateVersion0(); + break; + + default: break; + } + + Emotes.RemoveAll(x => !x.IsValid()); + } + + public void Save() + { + Emotes.RemoveAll(x => !x.IsValid()); + + pluginInterface.SavePluginConfig(this); + } + + public EmoteDataConfig FindOrAddEmote(string ownerName, int emoteId) + { + if (string.IsNullOrEmpty(ownerName) || emoteId <= 0) + { + return null; + } + + EmoteDataConfig emoteOb = Emotes.Find(x => (x.EmoteId == emoteId && x.OwnerName.Equals(ownerName, StringComparison.OrdinalIgnoreCase))); + if (emoteOb != null) + { + return emoteOb; + } + + emoteOb = new EmoteDataConfig() { OwnerName = ownerName, EmoteId = emoteId }; + Emotes.Add(emoteOb); + return emoteOb; + } + + private void MigrateVersion0() + { + foreach (var kvp in mapPats) + { + var emoteConfig = new EmoteDataConfig() { OwnerName = kvp.Key, EmoteId = EmoteReaderHooks.petEmoteId, Counter = kvp.Value }; + Emotes.Add(emoteConfig); + } + + mapPats.Clear(); + Version = VersionLatest; + } + } +} diff --git a/plugin/EmoteCounter.cs b/plugin/EmoteCounter.cs new file mode 100644 index 0000000..4240e35 --- /dev/null +++ b/plugin/EmoteCounter.cs @@ -0,0 +1,138 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Logging; +using System; +using System.Collections.Generic; + +namespace Expedience +{ + public class EmoteCounter + { + public Action OnChanged; + public bool isActive = true; + + public int counterEmoteId; + public int[] triggerEmoteIds; + + public string counterDesc; + public string counterDescPlural; + public string uiDesc; + + private Dictionary mapEmotesInZone = new(); + private string currentPlayerName; + + public void OnEmote(GameObject instigator, ushort emoteId) + { + bool canUse = emoteId == counterEmoteId; + if (triggerEmoteIds != null) + { + canUse = Array.FindIndex(triggerEmoteIds, x => x == emoteId) >= 0; + } + + if (canUse && isActive) + { + var canNotify = IncCounter(); + if (canNotify) + { + var playerInstigator = instigator as PlayerCharacter; + uint instigatorWorld = (playerInstigator != null) ? playerInstigator.HomeWorld.Id : 0; + + Service.counterBroadcast.SendMessage(counterDesc, emoteId, instigator.Name.ToString(), instigatorWorld); + } + + var instigatorName = (instigator != null) ? instigator.Name.ToString() : "??"; + if (mapEmotesInZone.TryGetValue(instigatorName, out int counter)) + { + mapEmotesInZone[instigatorName] = counter + 1; + } + else + { + mapEmotesInZone.Add(instigatorName, 1); + } + } + } + + public int GetCounter() + { + if (currentPlayerName == null) + { + currentPlayerName = GetCurrentPlayerName(); + } + + var emoteData = Service.pluginConfig.FindOrAddEmote(currentPlayerName, counterEmoteId); + if (emoteData != null) + { + return emoteData.Counter; + } + + return 0; + } + + public bool IncCounter() + { + if (currentPlayerName == null) + { + currentPlayerName = GetCurrentPlayerName(); + } + + var emoteData = Service.pluginConfig.FindOrAddEmote(currentPlayerName, counterEmoteId); + if (emoteData != null) + { + emoteData.Counter++; + Service.pluginConfig.Save(); + + OnChanged?.Invoke(emoteData.Counter); + return true; + } + + return false; + } + + public void OnLogout() + { + currentPlayerName = null; + } + + public void OnTerritoryChanged() + { + mapEmotesInZone.Clear(); + } + + public (string, int) GetTopEmotesInZone() + { + string maxPatsPlayer = null; + int maxPats = 0; + + foreach (var kvp in mapEmotesInZone) + { + if (kvp.Value > maxPats) + { + maxPats = kvp.Value; + maxPatsPlayer = kvp.Key; + } + } + + return (maxPatsPlayer, maxPats); + } + + public int GetEmotesInCurrentZone(string instigatorName) + { + if (mapEmotesInZone.TryGetValue(instigatorName, out int numPats)) + { + return numPats; + } + + return 0; + } + + private string GetCurrentPlayerName() + { + if (Service.clientState == null || Service.clientState.LocalPlayer == null || Service.clientState.LocalPlayer.Name == null) + { + return null; + } + + return Service.clientState.LocalPlayer.Name.TextValue; + } + } +} diff --git a/plugin/EmoteReaderHooks.cs b/plugin/EmoteReaderHooks.cs new file mode 100644 index 0000000..edb1759 --- /dev/null +++ b/plugin/EmoteReaderHooks.cs @@ -0,0 +1,69 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Hooking; +using Dalamud.Logging; +using System; +using System.Linq; + +namespace Expedience +{ + public class EmoteReaderHooks : IDisposable + { + public static ushort petEmoteId = 105; // TODO: read from lumina? + + public Action OnEmote; + + public delegate void OnEmoteFuncDelegate(ulong unk, ulong instigatorAddr, ushort emoteId, ulong targetId, ulong unk2); + private readonly Hook hookEmote; + + public bool IsValid = false; + + public EmoteReaderHooks() + { + try + { + var emoteFuncPtr = Service.sigScanner.ScanText("48 89 5c 24 08 48 89 6c 24 10 48 89 74 24 18 48 89 7c 24 20 41 56 48 83 ec 30 4c 8b 74 24 60 48 8b d9 48 81 c1 60 2f 00 00"); + hookEmote = Hook.FromAddress(emoteFuncPtr, OnEmoteDetour); + hookEmote.Enable(); + + IsValid = true; + } + catch (Exception ex) + { + PluginLog.Error(ex, "oh noes!"); + } + } + + public void Dispose() + { + hookEmote?.Dispose(); + IsValid = false; + } + + void OnEmoteDetour(ulong unk, ulong instigatorAddr, ushort emoteId, ulong targetId, ulong unk2) + { + // unk - some field of event framework singleton? doesn't matter here anyway + // PluginLog.Log($"Emote >> unk:{unk:X}, instigatorAddr:{instigatorAddr:X}, emoteId:{emoteId}, targetId:{targetId:X}, unk2:{unk2:X}"); + + if (Service.clientState.LocalPlayer != null) + { + if (targetId == Service.clientState.LocalPlayer.ObjectId) + { + var instigatorOb = Service.objectTable.FirstOrDefault(x => (ulong)x.Address == instigatorAddr); + if (instigatorOb != null) + { + bool canCount = (instigatorOb.ObjectId != targetId); +#if DEBUG + canCount = true; +#endif + if (canCount) + { + OnEmote?.Invoke(instigatorOb, emoteId); + } + } + } + } + + hookEmote.Original(unk, instigatorAddr, emoteId, targetId, unk2); + } + } +} diff --git a/plugin/PatCountUI.cs b/plugin/PatCountUI.cs new file mode 100644 index 0000000..3ad5f57 --- /dev/null +++ b/plugin/PatCountUI.cs @@ -0,0 +1,40 @@ +using Dalamud.Interface.Windowing; +using ImGuiNET; +using System; + +namespace Expedience +{ + public class PatCountUI : Window, IDisposable + { + public PatCountUI() : base("Pat Count") + { + IsOpen = false; + + Flags = ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.AlwaysAutoResize; + RespectCloseHotkey = false; + } + + public void Dispose() + { + } + + public override void Draw() + { + int pats = Service.patCounter.GetCounter(); + ImGui.Text($"{Service.patCounter.uiDesc}: {pats}"); + + // add more counters if they want to be there + foreach (var counter in Service.plugin.emoteCounters) + { + if (counter != null && counter != Service.patCounter && !string.IsNullOrEmpty(counter.uiDesc) && counter.isActive) + { + int numEmotes = counter.GetCounter(); + if (numEmotes > 0) + { + ImGui.Text($"{counter.uiDesc}: {numEmotes}"); + } + } + } + } + } +} diff --git a/plugin/Plugin.cs b/plugin/Plugin.cs new file mode 100644 index 0000000..5a44cca --- /dev/null +++ b/plugin/Plugin.cs @@ -0,0 +1,264 @@ +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.Gui.FlyText; +using Dalamud.Game.Gui.Toast; +using Dalamud.Interface.Windowing; +using Dalamud.Logging; +using Dalamud.Plugin; +using ImGuiScene; +using Lumina.Excel; +using Lumina.Excel.GeneratedSheets; +using System; +using System.Collections.Generic; + +namespace Expedience +{ + public class Plugin : IDalamudPlugin + { + public string Name => "Expedience"; + + private readonly WindowSystem windowSystem = new("Expedience"); + private PluginUI pluginUI; + private EmoteReaderHooks emoteReader; + private UIReaderVoteMvp uiReaderVoteMvp; + private UIReaderBannerMIP uiReaderBannerMIP; + private PluginWindowConfig windowConfig; + private PatCountUI patCountUI; + public readonly List emoteCounters = new(); + + private ExcelSheet _territories; + private Tuple _currentDuty; + + public Plugin(DalamudPluginInterface pluginInterface) + { + pluginInterface.Create(); + + Service.plugin = this; + + Service.pluginConfig = pluginInterface.GetPluginConfig() as Configuration ?? new Configuration(); + Service.pluginConfig.Initialize(pluginInterface); + + _territories = Service.DataManager.GetExcelSheet(); + + Service.patCounter = new EmoteCounter() + { + counterEmoteId = EmoteReaderHooks.petEmoteId, + counterDesc = "pat", + counterDescPlural = "pats", + uiDesc = "Head pats", + }; + + Service.DutyState.DutyStarted += (sender, args) => + { + var territory = Service.ClientState.TerritoryType; + var player = Service.ClientState.LocalPlayer; + + PlaceName place = _territories.GetRow(territory)?.PlaceName.Value; + foreach (var member in Service.PartyList) + { + + } + }; + + Service.DutyState.DutyCompleted += (sender, args) => + { + var territory = Service.ClientState.TerritoryType; + var place = _territories.GetRow(territory)?.PlaceName.Value; + + }; + + Service.patCounter.OnChanged += (num) => OnEmoteReward(Service.patCounter, num); + emoteCounters.Add(Service.patCounter); + + // two different emote ids? + Service.doteCounter = new EmoteCounter() + { + counterEmoteId = 146, + triggerEmoteIds = new int[] { 146, 147 }, + counterDesc = "dote", + counterDescPlural = "dotes", + uiDesc = "Ranged pats", + }; + Service.doteCounter.OnChanged += (num) => OnEmoteReward(Service.doteCounter, num); + Service.doteCounter.isActive = Service.pluginConfig.canTrackDotes; + emoteCounters.Add(Service.doteCounter); + + pluginUI = new PluginUI(); + pluginUI.overlayImage = LoadEmbeddedImage("fan-kit-lala.png"); + + uiReaderVoteMvp = new UIReaderVoteMvp(); + uiReaderBannerMIP = new UIReaderBannerMIP(); + + windowConfig = new PluginWindowConfig(); + windowSystem.AddWindow(windowConfig); + + patCountUI = new PatCountUI(); + windowSystem.AddWindow(patCountUI); + + Service.commandManager.AddHandler("/Expedience", new(OnCommand) { HelpMessage = "Show pat counter" }); + Service.commandManager.AddHandler("/patcount", new(OnCommand) { HelpMessage = "Show persistent pat counter" }); + pluginInterface.UiBuilder.Draw += OnDraw; + pluginInterface.UiBuilder.OpenConfigUi += OnOpenConfig; + + emoteReader = new EmoteReaderHooks(); + emoteReader.OnEmote += (instigator, emoteId) => emoteCounters.ForEach(x => x.OnEmote(instigator, emoteId)); + + Service.framework.Update += Framework_Update; + Service.ClientState.TerritoryChanged += ClientState_TerritoryChanged; + Service.ClientState.Logout += ClientState_Logout; + + if (Service.pluginConfig.showCounterUI) + { + patCountUI.IsOpen = true; + } + + Service.counterBroadcast = pluginInterface.GetIpcProvider("ExpedienceEmoteCounter"); + } + + private void Framework_Update(Framework framework) + { + float deltaSeconds = (float)framework.UpdateDelta.TotalSeconds; + uiReaderVoteMvp.Tick(deltaSeconds); + uiReaderBannerMIP.Tick(deltaSeconds); + } + + private void ClientState_TerritoryChanged(object sender, ushort e) + { + emoteCounters.ForEach(x => x.OnTerritoryChanged()); + } + + private void ClientState_Logout(object sender, EventArgs e) + { + emoteCounters.ForEach(x => x.OnLogout()); + } + + public void Dispose() + { + pluginUI.Dispose(); + + emoteReader.Dispose(); + windowSystem.RemoveAllWindows(); + + Service.commandManager.RemoveHandler("/Expedience"); + Service.commandManager.RemoveHandler("/patcount"); + Service.framework.Update -= Framework_Update; + Service.ClientState.TerritoryChanged -= ClientState_TerritoryChanged; + Service.ClientState.Logout -= ClientState_Logout; + } + + private void OnCommand(string command, string args) + { + if (command == "/Expedience") + { + DescribeCounter(Service.patCounter, false); + foreach (var counter in emoteCounters) + { + if (counter != Service.patCounter) + { + DescribeCounter(counter); + } + } + } + else if (command == "/patcount") + { + patCountUI.Toggle(); + } + } + + private void DescribeCounter(EmoteCounter counter, bool hideEmpty = true) + { + if (counter == null || string.IsNullOrEmpty(counter.counterDesc) || !counter.isActive) + { + return; + } + + int numEmotes = counter.GetCounter(); + if (numEmotes <= 0 && hideEmpty) + { + return; + } + + var useName = counter.counterDesc[0].ToString().ToUpper() + counter.counterDesc.Substring(1); + Service.chatGui.Print($"{useName} counter: {numEmotes}"); + + var (maxPlayerName, maxCount) = counter.GetTopEmotesInZone(); + if (maxCount > 0) + { + string countDesc = (maxCount == 1) ? counter.counterDesc : counter.counterDescPlural; + Service.chatGui.Print($"♥ {maxPlayerName}: {maxCount} {countDesc}"); + } + } + + private void OnDraw() + { + pluginUI.Draw(); + windowSystem.Draw(); + } + + private void OnOpenConfig() + { + windowConfig.IsOpen = true; + } + + public void OnShowCounterConfigChanged(bool wantsUI) + { + patCountUI.IsOpen = wantsUI; + } + + private void OnEmoteReward(EmoteCounter counter, int numEmotes) + { + // thresholds on: 5, 15, 25, 50, 75, ... + bool isSpecial = (numEmotes < 25) ? (numEmotes == 5 || numEmotes == 15) : ((numEmotes % 25) == 0); + if (isSpecial && Service.pluginConfig.showSpecialPats) + { + // pats get special rewards. + if (counter == Service.patCounter) + { + pluginUI.Show(); + } + + var useDesc = counter.counterDescPlural.ToUpper(); + Service.toastGui?.ShowQuest($"{numEmotes} {useDesc}!", new QuestToastOptions + { + Position = QuestToastPosition.Centre, + DisplayCheckmark = true, + IconId = 0, + PlaySound = true + }); + } + else if (Service.pluginConfig.showFlyText) + { + var useDesc = counter.counterDesc.ToUpper(); + Service.flyTextGui?.AddFlyText(FlyTextKind.NamedCriticalDirectHit, 0, (uint)numEmotes, 0, useDesc, " ", 0xff00ff00, 0, 0); + } + } + + private TextureWrap LoadEmbeddedImage(string name) + { + TextureWrap resultImage = null; + try + { + var myAssembly = GetType().Assembly; + var myAssemblyName = myAssembly.GetName().Name; + var resourceName = $"{myAssemblyName}.assets.{name}"; + + var resStream = myAssembly.GetManifestResourceStream(resourceName); + if (resStream != null && resStream.Length > 0) + { + var contentBytes = new byte[(int)resStream.Length]; + resStream.Read(contentBytes, 0, contentBytes.Length); + + resultImage = Service.pluginInterface.UiBuilder.LoadImage(contentBytes); + resStream.Close(); + } + } + catch (Exception ex) + { + PluginLog.Error(ex, "failed to load overlay image"); + } + + return resultImage; + } + } +} diff --git a/plugin/PluginUI.cs b/plugin/PluginUI.cs new file mode 100644 index 0000000..130f9f0 --- /dev/null +++ b/plugin/PluginUI.cs @@ -0,0 +1,101 @@ +using ImGuiNET; +using ImGuiScene; +using System; +using System.Numerics; + +namespace Expedience +{ + public class PluginUI : IDisposable + { + private enum AnimPhase + { + None, + Appear, + Keep, + Disappear, + } + + public TextureWrap overlayImage; + + private AnimPhase anim = AnimPhase.None; + private static readonly float[] animDuration = new float[] { 0.0f, 1.0f, 1.0f, 1.0f }; + private float animTimeRemaining = 0.0f; + + public void Show() + { + SetAnim(AnimPhase.Appear); + } + + public void Draw() + { + if (anim == AnimPhase.None || overlayImage == null) + { + return; + } + + float animPct = 1.0f - Math.Max(0.0f, animTimeRemaining / animDuration[(int)anim]); + + // draw image + { + var viewport = ImGui.GetMainViewport(); + var viewportCenter = viewport.GetCenter(); + var drawHalfSize = new Vector2(overlayImage.Width * 0.5f, overlayImage.Height * 0.5f); + + if (drawHalfSize.X > viewportCenter.X || drawHalfSize.Y > viewportCenter.Y) + { + drawHalfSize.Y = viewportCenter.Y * 3 / 4; + drawHalfSize.X = overlayImage.Width * drawHalfSize.Y / overlayImage.Height; + } + + if (anim == AnimPhase.Appear) + { + drawHalfSize *= AnimElastic(animPct); + } + + var drawAlpha = + (anim == AnimPhase.Appear) ? 1 - Math.Pow(1 - animPct, 4) : + (anim == AnimPhase.Disappear) ? (1.0f - animPct) : + 1.0f; + + uint drawColor = 0xffffff | (uint)(drawAlpha * 255) << 24; + + var drawList = ImGui.GetForegroundDrawList(); + drawList.AddImage(overlayImage.ImGuiHandle, viewportCenter - drawHalfSize, viewportCenter + drawHalfSize, Vector2.Zero, Vector2.One, drawColor); + } + + // state transitions + animTimeRemaining -= ImGui.GetIO().DeltaTime; + if (animTimeRemaining <= 0.0f) + { + if (anim == AnimPhase.Disappear) + { + SetAnim(AnimPhase.None); + } + else + { + SetAnim(anim + 1); + } + } + } + + private float AnimElastic(float alpha) + { + const float c4 = (float)(2 * Math.PI) / 3.0f; + + return (alpha == 0) ? 0.0f : + (alpha == 1) ? 1.0f : + (float)(Math.Pow(2, -10 * alpha) * Math.Sin((alpha * 10 - 0.75) * c4) + 1); + } + + private void SetAnim(AnimPhase anim) + { + this.anim = anim; + animTimeRemaining = animDuration[(int)anim]; + } + + public void Dispose() + { + overlayImage.Dispose(); + } + } +} diff --git a/plugin/PluginWindowConfig.cs b/plugin/PluginWindowConfig.cs new file mode 100644 index 0000000..4143cb0 --- /dev/null +++ b/plugin/PluginWindowConfig.cs @@ -0,0 +1,55 @@ +using Dalamud.Interface.Windowing; +using ImGuiNET; +using System; +using System.Numerics; + +namespace Expedience +{ + public class PluginWindowConfig : Window, IDisposable + { + public PluginWindowConfig() : base("Pat Config") + { + IsOpen = false; + + SizeConstraints = new WindowSizeConstraints() { MinimumSize = new Vector2(100, 0), MaximumSize = new Vector2(300, 3000) }; + Flags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoScrollbar; + } + + public void Dispose() + { + } + + public override void Draw() + { + bool showSpecialPats = Service.pluginConfig.showSpecialPats; + bool showFlyText = Service.pluginConfig.showFlyText; + bool showCounterOnScreen = Service.pluginConfig.showCounterUI; + bool canTrackDotes = Service.pluginConfig.canTrackDotes; + bool hasChanges = false; + + hasChanges = ImGui.Checkbox("Show notify on reaching pat goals", ref showSpecialPats) || hasChanges; + hasChanges = ImGui.Checkbox("Show fly text counter on each emote", ref showFlyText) || hasChanges; + hasChanges = ImGui.Checkbox("Show pat counter on screen", ref showCounterOnScreen) || hasChanges; + + ImGui.Separator(); + hasChanges = ImGui.Checkbox("Track emote: dote", ref canTrackDotes) || hasChanges; + + if (showCounterOnScreen != Service.pluginConfig.showCounterUI) + { + Service.plugin.OnShowCounterConfigChanged(showCounterOnScreen); + } + + if (hasChanges) + { + Service.pluginConfig.showSpecialPats = showSpecialPats; + Service.pluginConfig.showFlyText = showFlyText; + Service.pluginConfig.showCounterUI = showCounterOnScreen; + Service.pluginConfig.canTrackDotes = canTrackDotes; + + Service.pluginConfig.Save(); + + Service.doteCounter.isActive = canTrackDotes; + } + } + } +} diff --git a/plugin/Service.cs b/plugin/Service.cs new file mode 100644 index 0000000..cac31bb --- /dev/null +++ b/plugin/Service.cs @@ -0,0 +1,71 @@ +using Dalamud.Data; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Party; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.Command; +using Dalamud.Game.DutyState; +using Dalamud.Game.Gui; +using Dalamud.Game.Gui.FlyText; +using Dalamud.Game.Gui.Toast; +using Dalamud.IoC; +using Dalamud.Plugin; +using Dalamud.Plugin.Ipc; +using Lumina.Excel.GeneratedSheets; + +namespace Expedience +{ + internal class Service + { + public static Plugin plugin; + + public static Configuration pluginConfig; + + public static EmoteCounter patCounter; + public static EmoteCounter doteCounter; + public static ICallGateProvider counterBroadcast; + + [PluginService] + public static DalamudPluginInterface pluginInterface { get; private set; } = null!; + + [PluginService] + public static CommandManager commandManager { get; private set; } = null!; + + [PluginService] + public static FlyTextGui flyTextGui { get; private set; } = null!; + + [PluginService] + public static ToastGui toastGui { get; private set; } = null!; + + [PluginService] + public static ClientState ClientState { get; private set; } = null!; + + [PluginService] + public static ChatGui chatGui { get; private set; } = null!; + + [PluginService] + public static SigScanner sigScanner { get; private set; } = null!; + + [PluginService] + public static ObjectTable objectTable { get; private set; } = null!; + + [PluginService] + public static Framework framework { get; private set; } = null!; + + [PluginService] + public static GameGui gameGui { get; private set; } = null!; + + [PluginService] + public static DataManager DataManager { get; private set; } = null!; + + [PluginService] + public static DutyState DutyState { get; private set; } = null!; + + [PluginService] + public static PartyList PartyList { get; private set; } = null!; + + [PluginService] + public static InstanceContent InstanceContent { get; private set; } = null!; + + } +} diff --git a/plugin/UIReaderBannerMIP.cs b/plugin/UIReaderBannerMIP.cs new file mode 100644 index 0000000..4582b0c --- /dev/null +++ b/plugin/UIReaderBannerMIP.cs @@ -0,0 +1,107 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using MgAl2O4.Utils; +using System; +using System.Collections.Generic; + +namespace Expedience +{ + public class UIReaderBannerMIP + { + public const float UpdateInterval = 0.5f; + + private float updateTimeRemaining = 0.0f; + private IntPtr cachedAddonPtr; + + private Dictionary playerNames = new Dictionary(); + + public void Tick(float deltaSeconds) + { + updateTimeRemaining -= deltaSeconds; + if (updateTimeRemaining < 0.0f) + { + updateTimeRemaining = UpdateInterval; + UpdateAddon(); + } + } + + private unsafe void UpdateAddon() + { + var addonPtr = Service.gameGui.GetAddonByName("BannerMIP", 1); + var addonBaseNode = (AtkUnitBase*)addonPtr; + + if (addonBaseNode == null || addonBaseNode->RootNode == null || !addonBaseNode->RootNode->IsVisible) + { + // reset when closed + cachedAddonPtr = IntPtr.Zero; + playerNames.Clear(); + return; + } + + cachedAddonPtr = addonPtr; + + var level0 = GUINodeUtils.GetImmediateChildNodes(addonBaseNode->RootNode); + var listRoot = GUINodeUtils.PickNode(level0, 2, 4); + var listNodes = GUINodeUtils.GetImmediateChildNodes(listRoot); + var collectsPlayerNames = playerNames.Count == 0; + + if (listNodes != null) + { + for (int idx = 0; idx < listNodes.Length; idx++) + { + var innerList = GUINodeUtils.GetChildNode(listNodes[idx]); + if (innerList != null && innerList->IsVisible) + { + var nodeLastName = GUINodeUtils.PickChildNode(innerList, 20, 27); + var nodeFirstName = GUINodeUtils.PickChildNode(innerList, 21, 27); + var nodeCombined = GUINodeUtils.PickChildNode(innerList, 22, 27); + + UpdatePlayerNames(idx, collectsPlayerNames, nodeLastName, nodeFirstName, nodeCombined); + } + } + } + } + + private unsafe void UpdatePlayerNames(int entryIdx, bool collectsPlayerNames, AtkResNode* nodeLastName, AtkResNode* nodeFirstName, AtkResNode* nodeCombined) + { + if (nodeLastName == null || nodeFirstName == null || nodeCombined == null || + nodeLastName->Type != NodeType.Text || nodeFirstName->Type != NodeType.Text || nodeCombined->Type != NodeType.Text) + { + return; + } + + var lastName = GUINodeUtils.GetNodeText(nodeLastName); + if (lastName.Length == 0 || lastName.StartsWith("pats:")) + { + return; + } + + var playerName = ""; + + if (collectsPlayerNames) + { + var firstName = GUINodeUtils.GetNodeText(nodeFirstName); + playerName = $"{firstName} {lastName}"; + playerNames.Add(entryIdx, playerName); + } + else + { + playerNames.TryGetValue(entryIdx, out playerName); + } + + if (playerName.Length <= 1) // include separator + { + return; + } + + int numPats = Service.patCounter.GetEmotesInCurrentZone(playerName); + if (numPats > 0) + { + ((AtkTextNode*)nodeLastName)->SetText($"pats: {numPats}"); + + nodeCombined->Flags &= ~0x10; // hide + nodeLastName->Flags |= 0x10; // show + nodeFirstName->Flags |= 0x10; // show + } + } + } +} diff --git a/plugin/UIReaderVoteMvp.cs b/plugin/UIReaderVoteMvp.cs new file mode 100644 index 0000000..2dfccda --- /dev/null +++ b/plugin/UIReaderVoteMvp.cs @@ -0,0 +1,74 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using MgAl2O4.Utils; +using System; + +namespace Expedience +{ + public class UIReaderVoteMvp + { + public const float UpdateInterval = 0.5f; + + private float updateTimeRemaining = 0.0f; + private IntPtr cachedAddonPtr; + + public void Tick(float deltaSeconds) + { + updateTimeRemaining -= deltaSeconds; + if (updateTimeRemaining < 0.0f) + { + updateTimeRemaining = UpdateInterval; + UpdateAddon(); + } + } + + private unsafe void UpdateAddon() + { + var addonPtr = Service.gameGui.GetAddonByName("VoteMvp", 1); + var addonBaseNode = (AtkUnitBase*)addonPtr; + + if (addonBaseNode == null || addonBaseNode->RootNode == null || !addonBaseNode->RootNode->IsVisible) + { + // reset when closed + cachedAddonPtr = IntPtr.Zero; + return; + } + + // update once + if (cachedAddonPtr == addonPtr) + { + return; + } + + cachedAddonPtr = addonPtr; + + var childNodesL0 = GUINodeUtils.GetImmediateChildNodes(addonBaseNode->RootNode); + if (childNodesL0 != null) + { + foreach (var nodeL0 in childNodesL0) + { + var nodeL1 = GUINodeUtils.PickChildNode(nodeL0, 3, 7); + if (nodeL1 != null && nodeL1->Type == NodeType.Text) + { + var textNode = (AtkTextNode*)nodeL1; + var playerName = textNode->NodeText.ToString(); + + if (!playerName.Contains("pats ]") && !playerName.Contains("pat ]")) + { + int numPats = Service.patCounter.GetEmotesInCurrentZone(playerName); + if (numPats == 1) + { + playerName += " [ 1 pat ]"; + textNode->SetText(playerName); + } + else if (numPats > 1) + { + playerName += $" [ {numPats} pats ]"; + textNode->SetText(playerName); + } + } + } + } + } + } + } +} diff --git a/utils/GUINodeUtils.cs b/utils/GUINodeUtils.cs new file mode 100644 index 0000000..35cd0b9 --- /dev/null +++ b/utils/GUINodeUtils.cs @@ -0,0 +1,280 @@ +using Dalamud.Logging; +using FFXIVClientStructs.FFXIV.Component.GUI; +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.InteropServices; + +namespace MgAl2O4.Utils +{ + // Dalamud.Interface.UIDebug is amazing + + public class GUINodeUtils + { + public static unsafe AtkResNode* PickChildNode(AtkResNode* maybeCompNode, int childIdx, int expectedNumChildren) + { + if (maybeCompNode != null && (int)maybeCompNode->Type >= 1000) + { + var compNode = (AtkComponentNode*)maybeCompNode; + if (compNode->Component->UldManager.NodeListCount == expectedNumChildren && childIdx < expectedNumChildren) + { + return compNode->Component->UldManager.NodeList[childIdx]; + } + } + + return null; + } + + public static unsafe AtkResNode* PickChildNode(AtkComponentBase* compPtr, int childIdx, int expectedNumChildren) + { + if (compPtr != null && compPtr->UldManager.NodeListCount == expectedNumChildren && childIdx < expectedNumChildren) + { + return compPtr->UldManager.NodeList[childIdx]; + } + + return null; + } + + public static unsafe AtkResNode*[] GetImmediateChildNodes(AtkResNode* node) + { + var listAddr = new List(); + if (node != null && node->ChildNode != null) + { + listAddr.Add((ulong)node->ChildNode); + + node = node->ChildNode; + while (node->PrevSiblingNode != null) + { + listAddr.Add((ulong)node->PrevSiblingNode); + node = node->PrevSiblingNode; + } + } + + return ConvertToNodeArr(listAddr); + } + + public static unsafe AtkResNode*[] GetAllChildNodes(AtkResNode* node) + { + if (node != null) + { + var list = new List(); + RecursiveAppendChildNodes(node, list); + + return ConvertToNodeArr(list); + } + + return null; + } + + private static unsafe void RecursiveAppendChildNodes(AtkResNode* node, List listAddr) + { + if (node != null) + { + listAddr.Add((ulong)node); + + // step inside + if (node->ChildNode != null) + { + RecursiveAppendChildNodes(node->ChildNode, listAddr); + + AtkResNode* linkNode = node->ChildNode; + while (linkNode->PrevSiblingNode != null) + { + RecursiveAppendChildNodes(linkNode->PrevSiblingNode, listAddr); + linkNode = linkNode->PrevSiblingNode; + } + + // no need to check next siblings here? + } + } + } + + private static unsafe AtkResNode*[] ConvertToNodeArr(List listAddr) + { + if (listAddr.Count > 0) + { + var typedArr = new AtkResNode*[listAddr.Count]; + for (int idx = 0; idx < listAddr.Count; idx++) + { + typedArr[idx] = (AtkResNode*)listAddr[idx]; + } + + return typedArr; + } + + return null; + } + + public static unsafe AtkResNode* PickNode(AtkResNode*[] nodes, int nodeIdx, int expectedNumNodes) + { + if (nodes != null && nodes.Length == expectedNumNodes && nodeIdx < expectedNumNodes) + { + return nodes[nodeIdx]; + } + + return null; + } + + public static unsafe AtkResNode* GetChildNode(AtkResNode* node) + { + return node != null ? node->ChildNode : null; + } + + public static unsafe string GetNodeTexturePath(AtkResNode* maybeImageNode) + { + if (maybeImageNode != null && maybeImageNode->Type == NodeType.Image) + { + var imageNode = (AtkImageNode*)maybeImageNode; + if (imageNode->PartsList != null && imageNode->PartId <= imageNode->PartsList->PartCount) + { + var textureInfo = imageNode->PartsList->Parts[imageNode->PartId].UldAsset; + var texType = textureInfo->AtkTexture.TextureType; + if (texType == TextureType.Resource) + { + var texFileNameStdString = &textureInfo->AtkTexture.Resource->TexFileResourceHandle->ResourceHandle.FileName; + var texString = texFileNameStdString->Length < 16 + ? Marshal.PtrToStringAnsi((IntPtr)texFileNameStdString->Buffer) + : Marshal.PtrToStringAnsi((IntPtr)texFileNameStdString->BufferPtr); + + return texString; + } + } + } + + return null; + } + + public static unsafe string GetNodeText(AtkResNode* maybeTextNode) + { + if (maybeTextNode != null && maybeTextNode->Type == NodeType.Text) + { + var textNode = (AtkTextNode*)maybeTextNode; + var text = Marshal.PtrToStringUTF8(new IntPtr(textNode->NodeText.StringPtr)); + return text; + } + + return null; + } + + public static unsafe Vector2 GetNodePosition(AtkResNode* node) + { + var pos = new Vector2(node->X, node->Y); + var par = node->ParentNode; + while (par != null) + { + pos *= new Vector2(par->ScaleX, par->ScaleY); + pos += new Vector2(par->X, par->Y); + par = par->ParentNode; + } + + return pos; + } + + public static unsafe Vector2 GetNodeScale(AtkResNode* node) + { + if (node == null) return new Vector2(1, 1); + var scale = new Vector2(node->ScaleX, node->ScaleY); + while (node->ParentNode != null) + { + node = node->ParentNode; + scale *= new Vector2(node->ScaleX, node->ScaleY); + } + + return scale; + } + + public static unsafe (Vector2, Vector2) GetNodePosAndSize(AtkResNode* node) + { + if (node != null) + { + var pos = GetNodePosition(node); + var scale = GetNodeScale(node); + var size = new Vector2(node->Width * scale.X, node->Height * scale.Y); + + return (pos, size); + } + + return (Vector2.Zero, Vector2.Zero); + } + +#if DEBUG + private class ParsableNode + { + public ulong nodeAddr; + public string content; + public int childIdx; + public int numChildren; + public int depth; + public NodeType type; + public string debugPath; + } + + private static unsafe bool RecursiveAppendParsableChildNodes(AtkResNode* node, int depth, int childIdx, List list, string debugPath) + { + bool hasParsableChildNodes = false; + bool hasContent = false; + + if (node != null) + { + // check if this node is interesting for parser (empty string is still interesting) + string content = GetNodeText(node); + content = (content != null) ? content : GetNodeTexturePath(node); + hasContent = (content != null); + + int numChildNodes = 0; + int insertIdx = list.Count; + + if ((int)node->Type < 1000) + { + // step inside + if (node->ChildNode != null) + { + hasParsableChildNodes = RecursiveAppendParsableChildNodes(node->ChildNode, depth + 1, numChildNodes, list, debugPath + "," + numChildNodes); + numChildNodes++; + + AtkResNode* linkNode = node->ChildNode; + + while (linkNode->PrevSiblingNode != null) + { + var hasParsableSibling = RecursiveAppendParsableChildNodes(linkNode->PrevSiblingNode, depth + 1, numChildNodes, list, debugPath + "," + numChildNodes); + hasParsableChildNodes = hasParsableChildNodes || hasParsableSibling; + linkNode = linkNode->PrevSiblingNode; + numChildNodes++; + } + + // no need to check next siblings here? + } + } + else + { + var compNode = (AtkComponentNode*)node; + for (int idx = 0; idx < compNode->Component->UldManager.NodeListCount; idx++) + { + hasParsableChildNodes = RecursiveAppendParsableChildNodes(compNode->Component->UldManager.NodeList[idx], depth + 1, numChildNodes, list, debugPath + "," + numChildNodes); + numChildNodes++; + } + } + + if (hasParsableChildNodes || hasContent) + { + list.Insert(insertIdx, new ParsableNode() { nodeAddr = (ulong)node, content = content, childIdx = childIdx, numChildren = numChildNodes, depth = depth, debugPath = debugPath, type = node->Type }); + } + } + + return hasParsableChildNodes || hasContent; + } + + public static unsafe void LogParsableNodes(AtkResNode* node) + { + var list = new List(); + RecursiveAppendParsableChildNodes(node, 0, 0, list, ""); + + foreach (var entry in list) + { + var prefix = entry.depth > 0 ? new string(' ', entry.depth * 2) : ""; + PluginLog.Log($"{prefix}> '{entry.content}' idx:{entry.childIdx}, children:{entry.numChildren}, type:{entry.type}, addr:{entry.nodeAddr:X}, path:{entry.debugPath}"); + } + } +#endif // DEBUG + } +}