diff --git a/Peeping Tom/Peeping Tom.csproj b/Peeping Tom/Peeping Tom.csproj index 34e890b..08c28dd 100644 --- a/Peeping Tom/Peeping Tom.csproj +++ b/Peeping Tom/Peeping Tom.csproj @@ -72,6 +72,7 @@ True Resources.resx + diff --git a/Peeping Tom/Plugin.cs b/Peeping Tom/Plugin.cs index e670ebf..abad079 100644 --- a/Peeping Tom/Plugin.cs +++ b/Peeping Tom/Plugin.cs @@ -10,12 +10,14 @@ namespace PeepingTom { private Configuration config; private PluginUI ui; private HookManager hookManager; + private TargetWatcher watcher; public void Initialize(DalamudPluginInterface pluginInterface) { this.pi = pluginInterface ?? throw new ArgumentNullException(nameof(pluginInterface), "DalamudPluginInterface argument was null"); this.config = this.pi.GetPluginConfig() as Configuration ?? new Configuration(); this.config.Initialize(this.pi); - this.ui = new PluginUI(this, this.config, this.pi); + this.watcher = new TargetWatcher(this.pi, this.config); + this.ui = new PluginUI(this, this.config, this.pi, this.watcher); this.hookManager = new HookManager(this.pi, this.ui, this.config); this.pi.CommandManager.AddHandler("/ppeepingtom", new CommandInfo(this.OnCommand) { @@ -28,6 +30,7 @@ namespace PeepingTom { HelpMessage = "Alias for /ppeepingtom" }); + this.pi.Framework.OnUpdateEvent += this.watcher.OnFrameworkUpdate; this.pi.UiBuilder.OnBuildUi += this.DrawUI; this.pi.UiBuilder.OnOpenConfigUi += this.ConfigUI; } @@ -42,6 +45,8 @@ namespace PeepingTom { protected virtual void Dispose(bool includeManaged) { this.hookManager.Dispose(); + this.pi.Framework.OnUpdateEvent -= this.watcher.OnFrameworkUpdate; + this.watcher.Dispose(); this.pi.UiBuilder.OnBuildUi -= DrawUI; this.pi.UiBuilder.OnOpenConfigUi -= ConfigUI; this.pi.CommandManager.RemoveHandler("/ppeepingtom"); diff --git a/Peeping Tom/PluginUI.cs b/Peeping Tom/PluginUI.cs index bf21a6c..248a717 100644 --- a/Peeping Tom/PluginUI.cs +++ b/Peeping Tom/PluginUI.cs @@ -19,11 +19,9 @@ namespace PeepingTom { private readonly PeepingTomPlugin plugin; private readonly Configuration config; private readonly DalamudPluginInterface pi; + private readonly TargetWatcher watcher; - private readonly List previousTargeters = new List(); private Optional previousFocus = new Optional(); - private long soundLastPlayed = 0; - private int lastTargetAmount = 0; private bool visible = false; public bool Visible { @@ -37,16 +35,16 @@ namespace PeepingTom { set { this.settingsVisible = value; } } - public PluginUI(PeepingTomPlugin plugin, Configuration config, DalamudPluginInterface pluginInterface) { + public PluginUI(PeepingTomPlugin plugin, Configuration config, DalamudPluginInterface pluginInterface, TargetWatcher watcher) { this.plugin = plugin; this.config = config; this.pi = pluginInterface; + this.watcher = watcher; } public void Dispose() { this.Visible = false; this.SettingsVisible = false; - this.previousTargeters.Clear(); } public void Draw() { @@ -83,7 +81,7 @@ namespace PeepingTom { if (this.config.MarkTargeting) { PlayerCharacter player = this.pi.ClientState.LocalPlayer; if (player != null) { - PlayerCharacter[] targeting = this.GetTargeting(player); + IReadOnlyCollection targeting = this.watcher.CurrentTargeters; foreach (PlayerCharacter targeter in targeting) { MarkPlayer(targeter, this.config.TargetingColour, this.config.TargetingSize); } @@ -309,17 +307,8 @@ namespace PeepingTom { } private void ShowMainWindow() { - PlayerCharacter player = this.pi.ClientState.LocalPlayer; - if (player == null) { - return; - } - - PlayerCharacter[] targeting = this.GetTargeting(player); - if (this.config.PlaySoundOnTarget && targeting.Length > this.lastTargetAmount && this.CanPlaySound()) { - this.soundLastPlayed = Stopwatch.GetTimestamp(); - this.PlaySound(); - } - this.lastTargetAmount = targeting.Length; + IReadOnlyCollection targeting = this.watcher.CurrentTargeters; + ImGuiWindowFlags flags = ImGuiWindowFlags.AlwaysAutoResize; if (!this.config.AllowMovement) { flags |= ImGuiWindowFlags.NoMove; @@ -327,7 +316,7 @@ namespace PeepingTom { if (ImGui.Begin(this.plugin.Name, ref this.visible, flags)) { ImGui.Text("Targeting you"); bool anyHovered = false; - if (ImGui.ListBoxHeader("##targeting", targeting.Length, 5)) { + if (ImGui.ListBoxHeader("##targeting", targeting.Count, 5)) { // add the two first players for testing //foreach (PlayerCharacter p in this.pi.ClientState.Actors // .Where(actor => actor is PlayerCharacter) @@ -337,22 +326,11 @@ namespace PeepingTom { // this.AddEntry(new Targeter(p), p, ref anyHovered); //} foreach (PlayerCharacter targeter in targeting) { - if (this.config.KeepHistory) { - // add the targeter to the previous list - if (this.previousTargeters.Any(old => old.ActorId == targeter.ActorId)) { - this.previousTargeters.RemoveAll(old => old.ActorId == targeter.ActorId); - } - this.previousTargeters.Insert(0, new Targeter(targeter)); - } this.AddEntry(new Targeter(targeter), targeter, ref anyHovered); } if (this.config.KeepHistory) { - // only keep the configured number of previous targeters (ignoring ones that are currently targeting) - while (this.previousTargeters.Where(old => targeting.All(actor => actor.ActorId != old.ActorId)).Count() > this.config.NumHistory) { - this.previousTargeters.RemoveAt(this.previousTargeters.Count - 1); - } // get a list of the previous targeters that aren't currently targeting - Targeter[] previous = this.previousTargeters + Targeter[] previous = this.watcher.PreviousTargeters .Where(old => targeting.All(actor => actor.ActorId != old.ActorId)) .Take(this.config.NumHistory) .ToArray(); @@ -458,67 +436,5 @@ namespace PeepingTom { .Select(actor => actor as PlayerCharacter) .FirstOrDefault(); } - - private bool CanPlaySound() { - if (this.soundLastPlayed == 0) { - return true; - } - - long current = Stopwatch.GetTimestamp(); - long diff = current - this.soundLastPlayed; - // only play every 10 seconds? - float secs = (float)diff / Stopwatch.Frequency; - return secs >= this.config.SoundCooldown; - } - - private void PlaySound() { - SoundPlayer player; - if (this.config.SoundPath == null) { - player = new SoundPlayer(Properties.Resources.Target); - } else { - player = new SoundPlayer(this.config.SoundPath); - } - using (player) { - try { - player.Play(); - } catch (FileNotFoundException e) { - this.SendError($"Could not play sound: {e.Message}"); - } catch (InvalidOperationException e) { - this.SendError($"Could not play sound: {e.Message}"); - } - } - } - - private void SendError(string message) { - Payload[] payloads = { new TextPayload($"[Who's Looking] {message}") }; - this.pi.Framework.Gui.Chat.PrintChat(new XivChatEntry { - MessageBytes = new SeString(payloads).Encode(), - Type = XivChatType.ErrorMessage - }); - } - - private byte GetStatus(Actor actor) { - IntPtr statusPtr = this.pi.TargetModuleScanner.ResolveRelativeAddress(actor.Address, 0x1901); - return Marshal.ReadByte(statusPtr); - } - - private bool InCombat(Actor actor) { - return (GetStatus(actor) & 2) > 0; - } - - private bool InAlliance(Actor actor) { - return (GetStatus(actor) & 32) > 0; - } - - private PlayerCharacter[] GetTargeting(Actor player) { - return this.pi.ClientState.Actors - .Where(actor => actor.TargetActorID == player.ActorId && actor is PlayerCharacter) - .Select(actor => actor as PlayerCharacter) - .Where(actor => this.config.LogParty || this.pi.ClientState.PartyList.All(member => member.Actor?.ActorId != actor.ActorId)) - .Where(actor => this.config.LogAlliance || !this.InAlliance(actor)) - .Where(actor => this.config.LogInCombat || !this.InCombat(actor)) - .Where(actor => this.config.LogSelf || actor.ActorId != player.ActorId) - .ToArray(); - } } } diff --git a/Peeping Tom/TargetWatcher.cs b/Peeping Tom/TargetWatcher.cs new file mode 100644 index 0000000..4397802 --- /dev/null +++ b/Peeping Tom/TargetWatcher.cs @@ -0,0 +1,172 @@ +using Dalamud.Game.Chat; +using Dalamud.Game.Chat.SeStringHandling; +using Dalamud.Game.Chat.SeStringHandling.Payloads; +using Dalamud.Game.ClientState.Actors.Types; +using Dalamud.Game.Internal; +using Dalamud.Plugin; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Media; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace PeepingTom { + class TargetWatcher : IDisposable { + private readonly DalamudPluginInterface pi; + private readonly Configuration config; + + private long soundLastPlayed = 0; + private int lastTargetAmount = 0; + + public readonly Mutex currentMutex = new Mutex(); + private PlayerCharacter[] current = Array.Empty(); + public IReadOnlyCollection CurrentTargeters { + get { + this.currentMutex.WaitOne(); + PlayerCharacter[] current = (PlayerCharacter[])this.current.Clone(); + this.currentMutex.ReleaseMutex(); + return current; + } + } + + public readonly Mutex previousMutex = new Mutex(); + private readonly List previousTargeters = new List(); + public IReadOnlyCollection PreviousTargeters { + get { + this.previousMutex.WaitOne(); + Targeter[] previous = this.previousTargeters.ToArray(); + this.previousMutex.ReleaseMutex(); + return previous; + } + } + + public TargetWatcher(DalamudPluginInterface pi, Configuration config) { + this.pi = pi ?? throw new ArgumentNullException(nameof(pi), "DalamudPluginInterface cannot be null"); + this.config = config ?? throw new ArgumentNullException(nameof(config), "Configuration cannot be null"); + } + + public Out WithCurrent(Func, Out> func) { + this.currentMutex.WaitOne(); + Out output = func(this.current); + this.currentMutex.ReleaseMutex(); + return output; + } + + public void OnFrameworkUpdate(Framework framework) { + PlayerCharacter player = this.pi.ClientState.LocalPlayer; + if (player == null) { + return; + } + + // block until lease + this.currentMutex.WaitOne(); + + // get targeters and set a copy so we can release the mutex faster + PlayerCharacter[] current = this.GetTargeting(player); + this.current = (PlayerCharacter[])current.Clone(); + + // release + this.currentMutex.ReleaseMutex(); + + this.HandleHistory(current); + + // play sound if necessary + if (this.config.PlaySoundOnTarget && this.current.Length > this.lastTargetAmount && this.CanPlaySound()) { + this.soundLastPlayed = Stopwatch.GetTimestamp(); + this.PlaySound(); + } + this.lastTargetAmount = this.current.Length; + } + + private void HandleHistory(PlayerCharacter[] targeting) { + if (!this.config.KeepHistory) { + return; + } + + foreach (PlayerCharacter targeter in targeting) { + // add the targeter to the previous list + if (this.previousTargeters.Any(old => old.ActorId == targeter.ActorId)) { + this.previousTargeters.RemoveAll(old => old.ActorId == targeter.ActorId); + } + this.previousTargeters.Insert(0, new Targeter(targeter)); + } + + // only keep the configured number of previous targeters (ignoring ones that are currently targeting) + while (this.previousTargeters.Where(old => targeting.All(actor => actor.ActorId != old.ActorId)).Count() > this.config.NumHistory) { + this.previousTargeters.RemoveAt(this.previousTargeters.Count - 1); + } + } + + private PlayerCharacter[] GetTargeting(Actor player) { + return this.pi.ClientState.Actors + .Where(actor => actor.TargetActorID == player.ActorId && actor is PlayerCharacter) + .Select(actor => actor as PlayerCharacter) + .Where(actor => this.config.LogParty || this.pi.ClientState.PartyList.All(member => member.Actor?.ActorId != actor.ActorId)) + .Where(actor => this.config.LogAlliance || !this.InAlliance(actor)) + .Where(actor => this.config.LogInCombat || !this.InCombat(actor)) + .Where(actor => this.config.LogSelf || actor.ActorId != player.ActorId) + .ToArray(); + } + + private byte GetStatus(Actor actor) { + IntPtr statusPtr = this.pi.TargetModuleScanner.ResolveRelativeAddress(actor.Address, 0x1901); + return Marshal.ReadByte(statusPtr); + } + + private bool InCombat(Actor actor) { + return (GetStatus(actor) & 2) > 0; + } + + private bool InAlliance(Actor actor) { + return (GetStatus(actor) & 32) > 0; + } + + private bool CanPlaySound() { + if (this.soundLastPlayed == 0) { + return true; + } + + long current = Stopwatch.GetTimestamp(); + long diff = current - this.soundLastPlayed; + // only play every 10 seconds? + float secs = (float)diff / Stopwatch.Frequency; + return secs >= this.config.SoundCooldown; + } + + private void PlaySound() { + SoundPlayer player; + if (this.config.SoundPath == null) { + player = new SoundPlayer(Properties.Resources.Target); + } else { + player = new SoundPlayer(this.config.SoundPath); + } + using (player) { + try { + player.Play(); + } catch (FileNotFoundException e) { + this.SendError($"Could not play sound: {e.Message}"); + } catch (InvalidOperationException e) { + this.SendError($"Could not play sound: {e.Message}"); + } + } + } + + private void SendError(string message) { + Payload[] payloads = { new TextPayload($"[Who's Looking] {message}") }; + this.pi.Framework.Gui.Chat.PrintChat(new XivChatEntry { + MessageBytes = new SeString(payloads).Encode(), + Type = XivChatType.ErrorMessage + }); + } + + public void Dispose() { + this.currentMutex.Dispose(); + this.previousMutex.Dispose(); + } + } +}