using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Dalamud.Game.ClientState.Objects; using Dalamud.Interface.Windowing; using Dalamud.Plugin; using Dalamud.Plugin.Services; using ECommons; using ECommons.Schedulers; using ECommons.SplatoonAPI; using FFXIVClientStructs.FFXIV.Common.Math; using GatheringPathRenderer.Windows; using Questionable.Model; using Questionable.Model.Gathering; namespace GatheringPathRenderer; public sealed class RendererPlugin : IDalamudPlugin { private const long OnTerritoryChange = -2; private readonly WindowSystem _windowSystem = new(nameof(RendererPlugin)); private readonly List _colors = [0xFFFF2020, 0xFF20FF20, 0xFF2020FF, 0xFFFFFF20, 0xFFFF20FF, 0xFF20FFFF]; private readonly IDalamudPluginInterface _pluginInterface; private readonly IClientState _clientState; private readonly IPluginLog _pluginLog; private readonly EditorCommands _editorCommands; private readonly EditorWindow _editorWindow; private readonly List _gatheringLocations = []; public RendererPlugin(IDalamudPluginInterface pluginInterface, IClientState clientState, ICommandManager commandManager, IDataManager dataManager, ITargetManager targetManager, IChatGui chatGui, IObjectTable objectTable, IPluginLog pluginLog) { _pluginInterface = pluginInterface; _clientState = clientState; _pluginLog = pluginLog; _editorCommands = new EditorCommands(this, dataManager, commandManager, targetManager, clientState, chatGui); _editorWindow = new EditorWindow(this, _editorCommands, dataManager, targetManager, clientState, objectTable) { IsOpen = true }; _windowSystem.AddWindow(_editorWindow); _pluginInterface.GetIpcSubscriber("Questionable.ReloadData") .Subscribe(Reload); ECommonsMain.Init(pluginInterface, this, Module.SplatoonAPI); LoadGatheringLocationsFromDirectory(); _pluginInterface.UiBuilder.Draw += _windowSystem.Draw; _clientState.TerritoryChanged += TerritoryChanged; if (_clientState.IsLoggedIn) TerritoryChanged(_clientState.TerritoryType); } internal DirectoryInfo PathsDirectory { get { DirectoryInfo? solutionDirectory = _pluginInterface.AssemblyLocation.Directory?.Parent?.Parent?.Parent; if (solutionDirectory != null) { DirectoryInfo pathProjectDirectory = new DirectoryInfo(Path.Combine(solutionDirectory.FullName, "GatheringPaths")); if (pathProjectDirectory.Exists) return pathProjectDirectory; } throw new Exception("Unable to resolve project path"); } } internal void Reload() { LoadGatheringLocationsFromDirectory(); Redraw(); } private void LoadGatheringLocationsFromDirectory() { _gatheringLocations.Clear(); try { foreach (var expansionFolder in ExpansionData.ExpansionFolders.Values) LoadFromDirectory( new DirectoryInfo(Path.Combine(PathsDirectory.FullName, expansionFolder))); _pluginLog.Information( $"Loaded {_gatheringLocations.Count} gathering root locations from project directory"); } catch (Exception e) { _pluginLog.Error(e, "Failed to load paths from project directory"); } } private void LoadFromDirectory(DirectoryInfo directory) { if (!directory.Exists) return; _pluginLog.Information($"Loading locations from {directory}"); foreach (FileInfo fileInfo in directory.GetFiles("*.json")) { try { using FileStream stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read); LoadLocationFromStream(fileInfo, stream); } catch (Exception e) { throw new InvalidDataException($"Unable to load file {fileInfo.FullName}", e); } } foreach (DirectoryInfo childDirectory in directory.GetDirectories()) LoadFromDirectory(childDirectory); } private void LoadLocationFromStream(FileInfo fileInfo, Stream stream) { var locationNode = JsonNode.Parse(stream)!; GatheringRoot root = locationNode.Deserialize()!; _gatheringLocations.Add(new GatheringLocationContext(fileInfo, ushort.Parse(fileInfo.Name.Split('_')[0]), root)); } internal IEnumerable GetLocationsInTerritory(ushort territoryId) => _gatheringLocations.Where(x => x.Root.TerritoryId == territoryId); internal void Save(FileInfo targetFile, GatheringRoot root) { JsonSerializerOptions options = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, WriteIndented = true, }; using (var stream = File.Create(targetFile.FullName)) { var jsonNode = (JsonObject)JsonSerializer.SerializeToNode(root, options)!; var newNode = new JsonObject(); newNode.Add("$schema", "https://git.carvel.li/liza/Questionable/raw/branch/master/GatheringPaths/gatheringlocation-v1.json"); foreach (var (key, value) in jsonNode) newNode.Add(key, value?.DeepClone()); using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); newNode.WriteTo(writer, options); } Reload(); } private void TerritoryChanged(ushort territoryId) => Redraw(); internal void Redraw() { Splatoon.RemoveDynamicElements("GatheringPathRenderer"); var elements = GetLocationsInTerritory(_clientState.TerritoryType) .SelectMany(location => location.Root.Groups.SelectMany(group => group.Nodes.SelectMany(node => node.Locations .SelectMany(x => { bool isCone = false; int minimumAngle = 0; int maximumAngle = 0; if (_editorWindow.TryGetOverride(x.InternalId, out LocationOverride? locationOverride) && locationOverride != null) { if (locationOverride.IsCone()) { isCone = true; minimumAngle = locationOverride.MinimumAngle.GetValueOrDefault(); maximumAngle = locationOverride.MaximumAngle.GetValueOrDefault(); } } if (!isCone && x.IsCone()) { isCone = true; minimumAngle = x.MinimumAngle.GetValueOrDefault(); maximumAngle = x.MaximumAngle.GetValueOrDefault(); } var a = GatheringMath.CalculateLandingLocation(x, 0, 0); var b = GatheringMath.CalculateLandingLocation(x, 1, 1); return new List { new Element(isCone ? ElementType.ConeAtFixedCoordinates : ElementType.CircleAtFixedCoordinates) { refX = x.Position.X, refY = x.Position.Z, refZ = x.Position.Y, Filled = true, radius = x.CalculateMinimumDistance(), Donut = x.CalculateMaximumDistance() - x.CalculateMinimumDistance(), color = _colors[location.Root.Groups.IndexOf(group) % _colors.Count], Enabled = true, coneAngleMin = minimumAngle, coneAngleMax = maximumAngle, tether = false, }, new Element(ElementType.CircleAtFixedCoordinates) { refX = x.Position.X, refY = x.Position.Z, refZ = x.Position.Y, color = 0x00000000, Enabled = true, overlayText = $"{location.Root.Groups.IndexOf(group)} // {node.DataId} / {node.Locations.IndexOf(x)}", }, new Element(ElementType.CircleAtFixedCoordinates) { refX = a.X, refY = a.Z, refZ = a.Y, color = _colors[0], radius = 0.1f, Enabled = true, overlayText = "Min Angle" }, new Element(ElementType.CircleAtFixedCoordinates) { refX = b.X, refY = b.Z, refZ = b.Y, color = _colors[1], radius = 0.1f, Enabled = true, overlayText = "Max Angle" } }; })))) .ToList(); if (elements.Count == 0) { _pluginLog.Information("No new elements to render."); return; } _ = new TickScheduler(delegate { try { Splatoon.AddDynamicElements("GatheringPathRenderer", elements.ToArray(), new[] { OnTerritoryChange }); _pluginLog.Information($"Created {elements.Count} splatoon elements."); } catch (Exception e) { _pluginLog.Error(e, "Unable to create splatoon layer"); } }); } public void Dispose() { _clientState.TerritoryChanged -= TerritoryChanged; _pluginInterface.UiBuilder.Draw -= _windowSystem.Draw; Splatoon.RemoveDynamicElements("GatheringPathRenderer"); ECommonsMain.Dispose(); _pluginInterface.GetIpcSubscriber("Questionable.ReloadData") .Unsubscribe(Reload); _editorCommands.Dispose(); } internal sealed record GatheringLocationContext(FileInfo File, ushort Id, GatheringRoot Root); }