using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; 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 GatheringPathRenderer.Windows; using LLib.GameData; 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 = []; private EClassJob _currentClassJob; public RendererPlugin(IDalamudPluginInterface pluginInterface, IClientState clientState, ICommandManager commandManager, IDataManager dataManager, ITargetManager targetManager, IChatGui chatGui, IObjectTable objectTable, IPluginLog pluginLog) { _pluginInterface = pluginInterface; _clientState = clientState; _pluginLog = pluginLog; Configuration? configuration = (Configuration?)pluginInterface.GetPluginConfig(); if (configuration == null) { configuration = new Configuration(); pluginInterface.SavePluginConfig(configuration); } _editorCommands = new EditorCommands(this, dataManager, commandManager, targetManager, clientState, chatGui, configuration); _editorWindow = new EditorWindow(this, _editorCommands, dataManager, targetManager, clientState, objectTable) { IsOpen = true }; _windowSystem.AddWindow(_editorWindow); _currentClassJob = (EClassJob?)_clientState.LocalPlayer?.ClassJob.Id ?? EClassJob.Adventurer; _pluginInterface.GetIpcSubscriber("Questionable.ReloadData") .Subscribe(Reload); ECommonsMain.Init(pluginInterface, this, Module.SplatoonAPI); LoadGatheringLocationsFromDirectory(); _pluginInterface.UiBuilder.Draw += _windowSystem.Draw; _clientState.TerritoryChanged += TerritoryChanged; _clientState.ClassJobChanged += ClassJobChanged; 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.Steps.LastOrDefault()?.TerritoryId == territoryId); internal void Save(FileInfo targetFile, GatheringRoot root) { JsonSerializerOptions options = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, WriteIndented = true, TypeInfoResolver = new DefaultJsonTypeInfoResolver { Modifiers = { NoEmptyCollectionModifier } }, }; 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 { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Indented = true }); newNode.WriteTo(writer, options); } Reload(); } private static void NoEmptyCollectionModifier(JsonTypeInfo typeInfo) { foreach (var property in typeInfo.Properties) { if (typeof(ICollection).IsAssignableFrom(property.PropertyType)) { property.ShouldSerialize = (_, val) => val is ICollection { Count: > 0 }; } } } private void TerritoryChanged(ushort territoryId) => Redraw(); private void ClassJobChanged(uint classJobId) { _currentClassJob = (EClassJob)classJobId; Redraw(_currentClassJob); } internal void Redraw() => Redraw(_currentClassJob); private void Redraw(EClassJob classJob) { Splatoon.RemoveDynamicElements("GatheringPathRenderer"); if (!classJob.IsGatherer()) return; var elements = GetLocationsInTerritory(_clientState.TerritoryType) .SelectMany(location => location.Root.Groups.SelectMany(group => group.Nodes.SelectMany(node => node.Locations .SelectMany(x => { bool isUnsaved = false; bool isCone = false; int minimumAngle = 0; int maximumAngle = 0; if (_editorWindow.TryGetOverride(x.InternalId, out LocationOverride? locationOverride) && locationOverride != null) { isUnsaved = locationOverride.NeedsSave(); 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(); } #if false var a = GatheringMath.CalculateLandingLocation(x, 0, 0); var b = GatheringMath.CalculateLandingLocation(x, 1, 1); #endif 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 = locationOverride?.MinimumDistance ?? x.CalculateMinimumDistance(), Donut = (locationOverride?.MaximumDistance ?? x.CalculateMaximumDistance()) - (locationOverride?.MinimumDistance ?? 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 = 0xFFFFFFFF, radius = 0.1f, Enabled = true, overlayText = $"{location.Root.Groups.IndexOf(group)} // {node.DataId} / {node.Locations.IndexOf(x)}", overlayBGColor = isUnsaved ? 0xFF2020FF : 0xFF000000, }, #if false 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" } #endif }; })))) .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.ClassJobChanged -= ClassJobChanged; _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); }