using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Reflection; using System.Threading; using System.Threading.Channels; using Dalamud; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; using ImGuiNET; using ImGuiScene; using Lumina.Data; using Lumina.Excel; using Lumina.Excel.GeneratedSheets; using Microsoft.Msagl.Core.Geometry; using Microsoft.Msagl.Core.Geometry.Curves; using Microsoft.Msagl.Core.Layout; namespace QuestMap { internal class PluginUi : IDisposable { private static class Colours { internal static readonly uint Bg = ImGui.GetColorU32(new Vector4(0.13f, 0.13f, 0.13f, 1)); internal static readonly uint Bg2 = ImGui.GetColorU32(new Vector4(0.3f, 0.3f, 0.3f, 1)); internal static readonly uint Text = ImGui.GetColorU32(new Vector4(0.9f, 0.9f, 0.9f, 1)); internal static readonly uint Line = ImGui.GetColorU32(new Vector4(0.7f, 0.7f, 0.7f, 1)); internal static readonly uint Grid = ImGui.GetColorU32(new Vector4(0.1f, 0.1f, 0.1f, 1)); internal static readonly Vector4 NormalQuest = new(0.54f, 0.45f, 0.36f, 1); internal static readonly Vector4 MsqQuest = new(0.29f, 0.35f, 0.44f, 1); internal static readonly Vector4 BlueQuest = new(0.024F, 0.016f, 0.72f, 1); } private Plugin Plugin { get; } private string _filter = string.Empty; private Quest? Quest { get; set; } private GeometryGraph? Graph { get; set; } private Node? Centre { get; set; } private ChannelReader GraphChannel { get; } private CancellationTokenSource? CancellationTokenSource { get; set; } private HashSet InfoWindows { get; } = new(); private Dictionary Icons { get; } = new(); private List<(Quest, bool, string)> FilteredQuests { get; } = new(); internal bool Show; private bool _relayout; private Vector2 _offset = Vector2.Zero; private static readonly Vector2 TextOffset = new(5, 2); private const int GridSmall = 10; private const int GridLarge = 50; private bool _viewDrag; private Vector2 _lastDragPos; internal PluginUi(Plugin plugin, ChannelReader graphChannel) { this.Plugin = plugin; this.GraphChannel = graphChannel; this.Refilter(); this.Plugin.Interface.UiBuilder.Draw += this.Draw; this.Plugin.Interface.UiBuilder.OpenConfigUi += this.OpenConfig; } public void Dispose() { this.Plugin.Interface.UiBuilder.OpenConfigUi -= this.OpenConfig; this.Plugin.Interface.UiBuilder.Draw -= this.Draw; foreach (var icon in this.Icons.Values) { icon.Dispose(); } } private void OpenConfig() { this.Show = true; } private void Refilter() { this.FilteredQuests.Clear(); var filterLower = this._filter.ToLowerInvariant(); var filtered = this.Plugin.DataManager.GetExcelSheet()! .Where(quest => { if (quest.Name.ToString().Length == 0) { return false; } if (!this.Plugin.Config.ShowSeasonal && quest.Festival.Row != 0) { return false; } var completed = this.Plugin.Common.Functions.Journal.IsQuestCompleted(quest); if (!this.Plugin.Config.ShowCompleted && completed) { return false; } if (this.Plugin.Config.EmoteVis == Visibility.Only && !this.Plugin.Quests.EmoteRewards.ContainsKey(quest.RowId)) { return false; } if (this.Plugin.Config.ItemVis == Visibility.Only && !this.Plugin.Quests.ItemRewards.ContainsKey(quest.RowId)) { return false; } if (this.Plugin.Config.MinionVis == Visibility.Only) { if (!this.Plugin.Quests.ItemRewards.TryGetValue(quest.RowId, out var items)) { return false; } if (items.All(item => item.ItemUICategory.Row != 81)) { return false; } } if (this.Plugin.Config.ActionsVis == Visibility.Only && !this.Plugin.Quests.ActionRewards.ContainsKey(quest.RowId)) { return false; } if (this.Plugin.Config.InstanceVis == Visibility.Only && !this.Plugin.Quests.InstanceRewards.ContainsKey(quest.RowId)) { return false; } if (this.Plugin.Config.TribeVis == Visibility.Only && !this.Plugin.Quests.BeastRewards.ContainsKey(quest.RowId)) { return false; } if (this.Plugin.Config.JobVis == Visibility.Only && !this.Plugin.Quests.JobRewards.ContainsKey(quest.RowId)) { return false; } if (this._filter.Length == 0) { return true; } return quest.Name.ToString().ToLowerInvariant().Contains(filterLower) || this.Plugin.Quests.ItemRewards.TryGetValue(quest.RowId, out var items1) && items1.Any(item => item.Name.ToString().ToLowerInvariant().Contains(filterLower)) || this.Plugin.Quests.EmoteRewards.TryGetValue(quest.RowId, out var emote) && emote.Name.ToString().ToLowerInvariant().Contains(filterLower) || this.Plugin.Quests.ActionRewards.TryGetValue(quest.RowId, out var action) && action.Name.ToString().ToLowerInvariant().Contains(filterLower) || this.Plugin.Quests.InstanceRewards.TryGetValue(quest.RowId, out var instances) && instances.Any(instance => instance.Name.ToString().ToLowerInvariant().Contains(filterLower)) || this.Plugin.Quests.BeastRewards.TryGetValue(quest.RowId, out var tribe) && tribe.Name.ToString().ToLowerInvariant().Contains(filterLower) || this.Plugin.Quests.JobRewards.TryGetValue(quest.RowId, out var job) && job.Name.ToString().ToLowerInvariant().Contains(filterLower); }) .SelectMany(quest => { var drawItems = new List<(Quest, bool, string)> { (quest, false, $"{this.Convert(quest.Name)}##{quest.RowId}"), }; var allItems = this.Plugin.Config.ItemVis != Visibility.Hidden; var anyItemVisible = allItems || this.Plugin.Config.MinionVis != Visibility.Hidden; if (anyItemVisible && this.Plugin.Quests.ItemRewards.TryGetValue(quest.RowId, out var items)) { var toShow = items.Where(item => allItems || item.ItemUICategory.Row == 81); drawItems.AddRange(toShow.Select(item => (quest, true, $"{this.Convert(item.Name)}##item-{quest.RowId}-{item.RowId}"))); } if (this.Plugin.Config.EmoteVis != Visibility.Hidden && this.Plugin.Quests.EmoteRewards.TryGetValue(quest.RowId, out var emote)) { drawItems.Add((quest, true, $"{this.Convert(emote.Name)}##emote-{quest.RowId}-{emote.RowId}")); } if (this.Plugin.Config.ActionsVis != Visibility.Hidden && this.Plugin.Quests.ActionRewards.TryGetValue(quest.RowId, out var action)) { drawItems.Add((quest, true, $"{this.Convert(action.Name)}##action-{quest.RowId}-{action.RowId}")); } if (this.Plugin.Config.InstanceVis != Visibility.Hidden && this.Plugin.Quests.InstanceRewards.TryGetValue(quest.RowId, out var instances)) { drawItems.AddRange(instances.Select(instance => (quest, true, $"{this.Convert(instance.Name)}##instance-{quest.RowId}-{instance.RowId}"))); } if (this.Plugin.Config.TribeVis != Visibility.Hidden && this.Plugin.Quests.BeastRewards.TryGetValue(quest.RowId, out var tribe)) { drawItems.Add((quest, true, $"{this.Convert(tribe.Name)}##tribe-{quest.RowId}-{tribe.RowId}")); } if (this.Plugin.Config.JobVis != Visibility.Hidden && this.Plugin.Quests.JobRewards.TryGetValue(quest.RowId, out var job)) { drawItems.Add((quest, true, $"{this.Convert(job.Name)}##job-{quest.RowId}-{job.RowId}")); } return drawItems; }); this.FilteredQuests.AddRange(filtered); } private void Draw() { if (this.GraphChannel.TryRead(out var graph)) { this.Graph = graph.Graph; this.Centre = graph.Centre; this.CancellationTokenSource = null; } this.DrawInfoWindows(); this.DrawMainWindow(); } private void DrawMainWindow() { if (!this.Show) { return; } ImGui.SetNextWindowSize(new Vector2(675, 600), ImGuiCond.FirstUseEver); if (!ImGui.Begin(this.Plugin.Name, ref this.Show, ImGuiWindowFlags.MenuBar)) { ImGui.End(); return; } if (ImGui.BeginMenuBar()) { if (ImGui.BeginMenu("Options")) { var anyChanged = false; if (ImGui.BeginMenu("Quest list")) { anyChanged |= ImGui.MenuItem("Show completed quests", null, ref this.Plugin.Config.ShowCompleted); anyChanged |= ImGui.MenuItem("Show seasonal quests", null, ref this.Plugin.Config.ShowSeasonal); ImGui.EndMenu(); } if (ImGui.BeginMenu("Quest map")) { if (ImGui.MenuItem("Show arrowheads", null, ref this.Plugin.Config.ShowArrowheads)) { this._relayout = true; anyChanged = true; } if (ImGui.MenuItem("Condense final MSQ quests", null, ref this.Plugin.Config.CondenseMsq)) { this._relayout = true; anyChanged = true; } if (ImGui.MenuItem("Show redundant arrows", null, ref this.Plugin.Config.ShowRedundantArrows)) { this._relayout = true; anyChanged = true; } ImGui.EndMenu(); } void VisibilityItem(string name, string id, ref Visibility visibility) { if (!ImGui.BeginMenu(name)) { return; } foreach (var vis in (Visibility[]) Enum.GetValues(typeof(Visibility))) { if (!ImGui.MenuItem($"{vis}##{id}", null, visibility == vis)) { continue; } visibility = vis; anyChanged = true; } ImGui.EndMenu(); } if (ImGui.BeginMenu("Reward/unlock visibility")) { VisibilityItem("Emotes", "emote-vis", ref this.Plugin.Config.EmoteVis); VisibilityItem("Items", "item-vis", ref this.Plugin.Config.ItemVis); VisibilityItem("Minions", "minion-vis", ref this.Plugin.Config.MinionVis); VisibilityItem("Actions", "action-vis", ref this.Plugin.Config.ActionsVis); VisibilityItem("Instances", "instance-vis", ref this.Plugin.Config.InstanceVis); VisibilityItem("Beast tribes", "tribe-vis", ref this.Plugin.Config.TribeVis); VisibilityItem("Jobs", "job-vis", ref this.Plugin.Config.JobVis); ImGui.EndMenu(); } if (anyChanged) { this.Plugin.SaveConfig(); this.Refilter(); } ImGui.EndMenu(); } ImGui.EndMenuBar(); } if (ImGui.InputText("Filter", ref this._filter, 100)) { this.Refilter(); } if (ImGui.BeginChild("quest-list", new Vector2(ImGui.GetContentRegionAvail().X * .25f, -1), false, ImGuiWindowFlags.HorizontalScrollbar)) { ImGuiListClipperPtr clipper; unsafe { clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); } clipper.Begin(this.FilteredQuests.Count); while (clipper.Step()) { for (var row = clipper.DisplayStart; row < clipper.DisplayEnd; row++) { var (quest, indent, drawItem) = this.FilteredQuests[row]; void DrawSelectable(string name, Quest quest) { var completed = this.Plugin.Common.Functions.Journal.IsQuestCompleted(quest); if (completed) { Vector4 disabled; unsafe { disabled = *ImGui.GetStyleColorVec4(ImGuiCol.TextDisabled); } ImGui.PushStyleColor(ImGuiCol.Text, disabled); } var ret = ImGui.Selectable(name, this.Quest == quest); if (completed) { ImGui.PopStyleColor(); } if (!ret) { if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) { this.InfoWindows.Add(quest.RowId); } return; } this.Quest = quest; this._relayout = true; this.Graph = null; } if (indent) { ImGui.TreePush(); } DrawSelectable(drawItem, quest); if (indent) { ImGui.TreePop(); } } } clipper.Destroy(); ImGui.EndChild(); } ImGui.SameLine(); if (ImGui.BeginChild("quest-map", new Vector2(-1, -1))) { if (this.Quest != null && this.Graph == null) { ImGui.TextUnformatted("Generating map..."); } if (this.Graph != null) { this.DrawGraph(this.Graph); } ImGui.EndChild(); } if (this._relayout && this.Quest != null) { this.Graph = null; this.CancellationTokenSource?.Cancel(); this.CancellationTokenSource = this.Plugin.Quests.StartGraphRecalculation(this.Quest); this._relayout = false; } ImGui.End(); } private void DrawInfoWindows() { var remove = 0u; foreach (var id in this.InfoWindows) { var quest = this.Plugin.DataManager.GetExcelSheet()!.GetRow(id); if (quest == null) { continue; } if (this.DrawInfoWindow(quest)) { remove = id; } } if (remove > 0) { this.InfoWindows.Remove(remove); } } /// true if closing private bool DrawInfoWindow(Quest quest) { var open = true; if (!ImGui.Begin($"{this.Convert(quest.Name)}##{quest.RowId}", ref open, ImGuiWindowFlags.AlwaysAutoResize)) { ImGui.End(); return !open; } var completed = this.Plugin.Common.Functions.Journal.IsQuestCompleted(quest); ImGui.TextUnformatted($"Level: {quest.ClassJobLevel0}"); if (completed) { ImGui.PushFont(UiBuilder.IconFont); var check = FontAwesomeIcon.Check.ToIconString(); var width = ImGui.GetContentRegionAvail().X - ImGui.CalcTextSize(check).X; ImGui.SameLine(width); ImGui.TextUnformatted(check); ImGui.PopFont(); } TextureWrap? GetIcon(uint id) { if (this.Icons.TryGetValue(id, out var wrap)) { return wrap; } wrap = this.Plugin.DataManager.GetImGuiTextureIcon(this.Plugin.ClientState.ClientLanguage, id); if (wrap != null) { this.Icons[id] = wrap; } return wrap; } var textWrap = ImGui.GetFontSize() * 20f; if (quest.Icon != 0) { var header = GetIcon(quest.Icon); if (header != null) { textWrap = header.Width; ImGui.Image(header.ImGuiHandle, new Vector2(header.Width, header.Height)); } } var rewards = new List(); var paramGrow = this.Plugin.DataManager.GetExcelSheet()!.GetRow(quest.ClassJobLevel0); var xp = 0; if (paramGrow != null) { xp = quest.ExpFactor * paramGrow.ScaledQuestXP * paramGrow.QuestExpModifier / 100; } if (xp > 0) { rewards.Add($"Exp: {xp:N0}"); } if (quest.GilReward > 0) { rewards.Add($"Gil: {quest.GilReward:N0}"); } if (rewards.Count > 0) { ImGui.TextUnformatted(string.Join(" / ", rewards)); } ImGui.Separator(); void DrawItemRewards(string label, IEnumerable<(SeString name, uint icon, byte qty)> enumerable) { var items = enumerable.ToArray(); if (items.Length == 0) { return; } ImGui.TextUnformatted(label); var maxHeight = items .Select(entry => GetIcon(entry.icon)) .Where(image => image != null) .Max(image => image!.Height); var originalY = ImGui.GetCursorPosY(); foreach (var (name, icon, qty) in items) { var image = GetIcon(icon); if (image != null) { if (image.Height < maxHeight) { ImGui.SetCursorPosY(originalY + (maxHeight - image.Height) / 2f); } ImGui.Image(image.ImGuiHandle, new Vector2(image.Width, image.Height)); Util.Tooltip(name.ToString()); } if (qty > 1) { var oldSpacing = ImGui.GetStyle().ItemSpacing; ImGui.GetStyle().ItemSpacing = new Vector2(2, 0); ImGui.SameLine(); var qtyLabel = $"x{qty}"; var labelSize = ImGui.CalcTextSize(qtyLabel); ImGui.SetCursorPosY(originalY + (maxHeight - labelSize.Y) / 2f); ImGui.TextUnformatted(qtyLabel); ImGui.GetStyle().ItemSpacing = oldSpacing; } ImGui.SameLine(); ImGui.SetCursorPosY(originalY); } ImGui.Dummy(Vector2.Zero); ImGui.Separator(); } var additionalRewards = new List<(SeString name, uint icon, byte qty)>(); if (this.Plugin.Quests.JobRewards.TryGetValue(quest.RowId, out var job)) { // FIXME: figure out better way to find icon additionalRewards.Add((this.Convert(job.Name).ToString(), 62000 + job.RowId, 1)); } for (var i = 0; i < quest.ItemCatalyst.Length; i++) { var catalyst = quest.ItemCatalyst[i]; var amount = quest.ItemCountCatalyst[i]; if (catalyst.Row != 0) { additionalRewards.Add((this.Convert(catalyst.Value!.Name), catalyst.Value.Icon, amount)); } } foreach (var generalAction in quest.GeneralActionReward.Where(row => row.Row != 0)) { additionalRewards.Add((this.Convert(generalAction.Value!.Name), (uint) generalAction.Value.Icon, 1)); } if (this.Plugin.Quests.ActionRewards.TryGetValue(quest.RowId, out var action)) { additionalRewards.Add((this.Convert(action.Name), action.Icon, 1)); } if (this.Plugin.Quests.EmoteRewards.TryGetValue(quest.RowId, out var emote)) { additionalRewards.Add((this.Convert(emote.Name), emote.Icon, 1)); } if (quest.OtherReward.Row != 0) { additionalRewards.Add((this.Convert(quest.OtherReward.Value!.Name), quest.OtherReward.Value.Icon, 1)); } if (quest.ReputationReward > 0) { var beastTribe = quest.BeastTribe.Value; if (beastTribe != null) { additionalRewards.Add((this.Convert(beastTribe.NameRelation), beastTribe.Icon, quest.ReputationReward)); } } if (quest.TomestoneReward > 0) { var tomestone = this.Plugin.DataManager.GetExcelSheet()!.FirstOrDefault(row => row.Tomestones.Row == quest.TomestoneReward); var item = tomestone?.Item?.Value; if (item != null) { additionalRewards.Add((this.Convert(item.Name), item.Icon, quest.TomestoneCountReward)); } } if (quest.ItemRewardType is 0 or 1 or 3 or 5) { DrawItemRewards( "Rewards", quest.ItemReward0 .Zip(quest.ItemCountReward0, (id, qty) => (id, qty)) .Where(entry => entry.id != 0) .Select(entry => (item: this.Plugin.DataManager.GetExcelSheet()!.GetRow(entry.id), entry.qty)) .Where(entry => entry.item != null) .Select(entry => (this.Convert(entry.item!.Name), (uint) entry.item.Icon, entry.qty)) .Concat(additionalRewards) ); DrawItemRewards( "Optional rewards", quest.ItemReward1 .Zip(quest.ItemCountReward1, (row, qty) => (row, qty)) .Where(entry => entry.row.Row != 0) .Select(entry => (item: entry.row.Value, entry.qty)) .Where(entry => entry.item != null) .Select(entry => (this.Convert(entry.item!.Name), (uint) entry.item.Icon, entry.qty)) ); } if (this.Plugin.Quests.InstanceRewards.TryGetValue(quest.RowId, out var instances)) { ImGui.TextUnformatted("Instances"); foreach (var instance in instances) { var icon = instance.ContentType.Value?.Icon ?? 0; if (icon > 0) { var image = GetIcon(icon); if (image != null) { ImGui.Image(image.ImGuiHandle, new Vector2(image.Width, image.Height)); Util.Tooltip(this.Convert(instance.Name).ToString()); } } else { ImGui.TextUnformatted(this.Convert(instance.Name).ToString()); } } ImGui.Separator(); } if (this.Plugin.Quests.BeastRewards.TryGetValue(quest.RowId, out var tribe)) { ImGui.TextUnformatted("Beast tribe"); var image = GetIcon(tribe.Icon); if (image != null) { ImGui.Image(image.ImGuiHandle, new Vector2(image.Width, image.Height)); Util.Tooltip(this.Convert(tribe.Name).ToString()); } ImGui.Separator(); } var id = quest.RowId & 0xFFFF; var lang = this.Plugin.ClientState.ClientLanguage switch { ClientLanguage.English => Language.English, ClientLanguage.Japanese => Language.Japanese, ClientLanguage.German => Language.German, ClientLanguage.French => Language.French, _ => Language.English, }; var path = $"quest/{id.ToString("00000")[..3]}/{quest.Id.RawString.ToLowerInvariant()}"; // FIXME: this is gross, but lumina caches incorrectly this.Plugin.DataManager.Excel.RemoveSheetFromCache(); var sheet = this.Plugin.DataManager.Excel.GetType() .GetMethod("GetSheet", BindingFlags.Instance | BindingFlags.NonPublic)? // ReSharper disable once ConstantConditionalAccessQualifier .MakeGenericMethod(typeof(QuestData))? // ReSharper disable once ConstantConditionalAccessQualifier .Invoke(this.Plugin.DataManager.Excel, new object?[] { path, lang, null, }) as ExcelSheet; // default to english if reflection failed sheet ??= this.Plugin.DataManager.Excel.GetSheet(path); var firstData = sheet?.GetRow(0); if (firstData != null) { ImGui.PushTextWrapPos(textWrap); ImGui.TextUnformatted(this.Convert(firstData.Text).ToString()); ImGui.PopTextWrapPos(); } ImGui.Separator(); void OpenMap(Level? level) { if (level == null) { return; } var mapLink = new MapLinkPayload( level.Territory.Row, level.Map.Row, (int) (level.X * 1_000f), (int) (level.Z * 1_000f) ); this.Plugin.GameGui.OpenMapWithMapLink(mapLink); } var issuer = this.Plugin.DataManager.GetExcelSheet()!.GetRow(quest.IssuerStart)?.Singular ?? "Unknown"; var target = this.Plugin.DataManager.GetExcelSheet()!.GetRow(quest.TargetEnd)?.Singular ?? "Unknown"; ImGui.TextUnformatted(issuer); ImGui.PushFont(UiBuilder.IconFont); ImGui.SameLine(); ImGui.TextUnformatted(FontAwesomeIcon.ArrowRight.ToIconString()); ImGui.PopFont(); ImGui.SameLine(); ImGui.TextUnformatted(target); ImGui.Separator(); if (Util.IconButton(FontAwesomeIcon.MapMarkerAlt)) { OpenMap(quest.IssuerLocation.Value); } Util.Tooltip("Mark issuer on map"); ImGui.SameLine(); if (Util.IconButton(FontAwesomeIcon.Book)) { this.Plugin.Common.Functions.Journal.OpenQuest(quest); } Util.Tooltip("Open quest in Journal"); ImGui.SameLine(); if (Util.IconButton(FontAwesomeIcon.ProjectDiagram)) { this.Quest = quest; this._relayout = true; } Util.Tooltip("Show quest graph"); ImGui.End(); return !open; } private static Vector2 ConvertPoint(Point p) { return new((float) p.X, (float) p.Y); } private Vector2 GetTopLeft(GeometryObject item) { // imgui measures from top left as 0,0 return ConvertPoint(item.BoundingBox.RightTop) + this._offset; } private Vector2 GetBottomRight(GeometryObject item) { return ConvertPoint(item.BoundingBox.LeftBottom) + this._offset; } private void DrawGraph(GeometryGraph graph) { // now the fun (tm) begins var space = ImGui.GetContentRegionAvail(); var size = new Vector2(space.X, space.Y); var drawList = ImGui.GetWindowDrawList(); ImGui.BeginGroup(); ImGui.InvisibleButton("##NodeEmpty", size); var canvasTopLeft = ImGui.GetItemRectMin(); var canvasBottomRight = ImGui.GetItemRectMax(); if (this.Centre != null) { this._offset = ConvertPoint(this.Centre.Center) * -1 + (canvasBottomRight - canvasTopLeft) / 2; this.Centre = null; } drawList.PushClipRect(canvasTopLeft, canvasBottomRight, true); drawList.AddRectFilled(canvasTopLeft, canvasBottomRight, Colours.Bg); // ========= GRID ========= for (var i = 0; i < size.X / GridSmall; i++) { drawList.AddLine(new Vector2(canvasTopLeft.X + i * GridSmall, canvasTopLeft.Y), new Vector2(canvasTopLeft.X + i * GridSmall, canvasBottomRight.Y), Colours.Grid, 1.0f); } for (var i = 0; i < size.Y / GridSmall; i++) { drawList.AddLine(new Vector2(canvasTopLeft.X, canvasTopLeft.Y + i * GridSmall), new Vector2(canvasBottomRight.X, canvasTopLeft.Y + i * GridSmall), Colours.Grid, 1.0f); } for (var i = 0; i < size.X / GridLarge; i++) { drawList.AddLine(new Vector2(canvasTopLeft.X + i * GridLarge, canvasTopLeft.Y), new Vector2(canvasTopLeft.X + i * GridLarge, canvasBottomRight.Y), Colours.Grid, 2.0f); } for (var i = 0; i < size.Y / GridLarge; i++) { drawList.AddLine(new Vector2(canvasTopLeft.X, canvasTopLeft.Y + i * GridLarge), new Vector2(canvasBottomRight.X, canvasTopLeft.Y + i * GridLarge), Colours.Grid, 2.0f); } drawList.AddRect(canvasTopLeft, canvasBottomRight, Colours.Bg2); Vector2 ConvertDrawPoint(Point p) { var ret = canvasBottomRight - (ConvertPoint(p) + this._offset); return ret; } foreach (var edge in graph.Edges) { var start = canvasBottomRight - this.GetTopLeft(edge); if (IsHidden(edge, start)) { continue; } var curve = edge.Curve; switch (curve) { case Curve c: { foreach (var s in c.Segments) { switch (s) { case LineSegment l: drawList.AddLine( ConvertDrawPoint(l.Start), ConvertDrawPoint(l.End), Colours.Line, 3.0f ); break; case CubicBezierSegment cs: drawList.AddBezierCubic( ConvertDrawPoint(cs.B(0)), ConvertDrawPoint(cs.B(1)), ConvertDrawPoint(cs.B(2)), ConvertDrawPoint(cs.B(3)), Colours.Line, 3.0f ); break; } } break; } case LineSegment l: drawList.AddLine( ConvertDrawPoint(l.Start), ConvertDrawPoint(l.End), Colours.Line, 3.0f ); break; } void DrawArrow(Vector2 start, Vector2 end) { const float arrowAngle = 30f; var dir = end - start; var h = dir; dir /= dir.Length(); var s = new Vector2(-dir.Y, dir.X); s *= (float) (h.Length() * Math.Tan(arrowAngle * 0.5f * (Math.PI / 180f))); drawList.AddTriangleFilled( start + s, end, start - s, Colours.Line ); } if (edge.ArrowheadAtTarget) { DrawArrow( ConvertDrawPoint(edge.Curve.End), ConvertDrawPoint(edge.EdgeGeometry.TargetArrowhead.TipPosition) ); } if (edge.ArrowheadAtSource) { DrawArrow( ConvertDrawPoint(edge.Curve.Start), ConvertDrawPoint(edge.EdgeGeometry.SourceArrowhead.TipPosition) ); } } bool IsHidden(GeometryObject node, Vector2 start) { var width = (float) node.BoundingBox.Width; var height = (float) node.BoundingBox.Height; return start.X + width < canvasTopLeft.X || start.Y + height < canvasTopLeft.Y || start.X > canvasBottomRight.X || start.Y > canvasBottomRight.Y; } var drawn = new List<(Vector2, Vector2, uint)>(); foreach (var node in graph.Nodes) { var start = canvasBottomRight - this.GetTopLeft(node); if (IsHidden(node, start)) { continue; } var quest = (Quest) node.UserData; var colour = quest.EventIconType.Row switch { 1 => Colours.NormalQuest, // normal 3 => Colours.MsqQuest, // msq 8 => Colours.BlueQuest, // blue 10 => Colours.BlueQuest, // also blue _ => Colours.NormalQuest, }; var textColour = Colours.Text; var completed = this.Plugin.Common.Functions.Journal.IsQuestCompleted(quest.RowId); if (completed) { colour.W = .5f; textColour = (uint) ((0x80 << 24) | (textColour & 0xFFFFFF)); } var end = canvasBottomRight - this.GetBottomRight(node); drawn.Add((start, end, quest.RowId)); if (quest == this.Quest) { drawList.AddRect(start - Vector2.One, end + Vector2.One, Colours.Line, 5, ImDrawFlags.RoundCornersAll); } drawList.AddRectFilled(start, end, ImGui.GetColorU32(colour), 5, ImDrawFlags.RoundCornersAll); drawList.AddText(start + TextOffset, textColour, this.Convert(quest.Name).ToString()); } // HOW ABOUT DRAGGING THE VIEW? if (ImGui.IsItemActive()) { if (ImGui.IsMouseDragging(ImGuiMouseButton.Left)) { var d = ImGui.GetMouseDragDelta(); if (this._viewDrag) { var delta = d - this._lastDragPos; this._offset -= delta; } this._viewDrag = true; this._lastDragPos = d; } else { this._viewDrag = false; } } else { if (!this._viewDrag) { var left = ImGui.IsMouseReleased(ImGuiMouseButton.Left); var right = ImGui.IsMouseReleased(ImGuiMouseButton.Right); if (left || right) { var mousePos = ImGui.GetMousePos(); foreach (var (start, end, id) in drawn) { var inBox = mousePos.X >= start.X && mousePos.X <= end.X && mousePos.Y >= start.Y && mousePos.Y <= end.Y; if (!inBox) { continue; } if (left) { this.InfoWindows.Add(id); } if (right) { this.Plugin.Common.Functions.Journal.OpenQuest(id); } break; } } } this._viewDrag = false; } drawList.PopClipRect(); ImGui.EndGroup(); // ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 5); } private static readonly byte[] NewLinePayload = { 0x02, 0x10, 0x01, 0x03 }; private SeString Convert(Lumina.Text.SeString lumina) { var se = (SeString) lumina; for (var i = 0; i < se.Payloads.Count; i++) { switch (se.Payloads[i].Type) { case PayloadType.Unknown: if (se.Payloads[i].Encode().SequenceEqual(NewLinePayload)) { se.Payloads[i] = new TextPayload("\n"); } break; case PayloadType.RawText: if (se.Payloads[i] is TextPayload payload) { payload.Text = this.Plugin.Interface.Sanitizer.Sanitize(payload.Text); } break; } } return se; } } }