using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Runtime.CompilerServices; using Dalamud.Bindings.ImGui; using Dalamud.Game.Text; using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Fate; using FFXIVClientStructs.FFXIV.Client.Game.Group; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.Interop; using Lumina.Excel.Sheets; using Mappy.Classes; using Mappy.Extensions; using KamiLib.Extensions; using FieldMarkerSheet = Lumina.Excel.Sheets.FieldMarker; namespace Mappy.MapRenderer; /// /// Draws map markers (POI, FATEs, quests, gathering, flag, etc.) on the minimap using the same data sources as the main map. /// public partial class MapRenderer { private const float MinimapIconSize = 28f; private const float MinimapIconScaleFromConfig = 1.0f; /// /// Draw all marker layers on the minimap. Call after the map texture and player are drawn. /// private void DrawMinimapMarkers( Vector2 contentTopLeft, Vector2 drawPosition, float scale, Vector2 size, float offsetX, float offsetY, float scaleFactor) { // Convert texture-space position (0..2048) to minimap content position. Vector2 TexToContent(float tx, float ty) => drawPosition + new Vector2(tx, ty) * scale; // Static POI / map markers (aetherytes, quest NPCs, etc.) DrawMinimapStaticMarkers(contentTopLeft, TexToContent, size); // FATEs from FateManager so they update without opening the Area Map DrawMinimapFatesFromFateManager(contentTopLeft, TexToContent, size, scaleFactor, offsetX, offsetY); // Event markers (non-FATE; FATEs drawn above from FateManager) DrawMinimapEventMarkers(contentTopLeft, TexToContent, size, scaleFactor, offsetX, offsetY, scale); // Gathering points DrawMinimapGatheringMarkers(contentTopLeft, TexToContent, size); // Party / alliance members (same marker as main map) DrawMinimapGroupMembers(contentTopLeft, TexToContent, size, scaleFactor, offsetX, offsetY); // User flag DrawMinimapFlag(contentTopLeft, TexToContent, scaleFactor, offsetX, offsetY); // Temporary (quest objectives, etc.) DrawMinimapTempMarkers(contentTopLeft, TexToContent, size, scaleFactor, offsetX, offsetY, scale); // Field markers (waymarks) DrawMinimapFieldMarkers(contentTopLeft, TexToContent, size, scaleFactor, offsetX, offsetY); } /// Max number of quest/objective direction arrows to show on the minimap (to avoid clutter). private const int MaximapQuestArrowLimit = 8; /// Same orange as the quest direction arrow, for the minimap quest radius circle (more transparent). private static readonly Vector4 MinimapQuestCircleFill = new(0.95f, 0.78f, 0.4f, 0.48f); private static readonly Vector4 MinimapQuestCircleOutline = new(0.45f, 0.28f, 0.08f, 0.5f); /// /// Draws direction arrows at the edge of the minimap pointing toward quest objectives and waymark in the current zone. /// Only shown when the target is off the minimap. Includes the quest/objective marker icon and default-style arrow shape. /// private unsafe void DrawMinimapQuestDirectionArrow( Vector2 contentTopLeft, Vector2 drawPosition, float scale, Vector2 size, Vector2 centerOffset, float offsetX, float offsetY, float scaleFactor, uint currentMapId) { var targets = GetAllQuestDirectionTargets(currentMapId, offsetX, offsetY, scaleFactor); if (targets.Count == 0) return; var radius = Math.Min(size.X, size.Y) * 0.5f; var arrowDist = radius - 4f; var centerScreen = contentTopLeft + centerOffset; var drawList = ImGui.GetWindowDrawList(); // Default UI-style arrowhead: slightly larger, fill + crisp outline (no shaft) const float arrowSize = 20f; const float baseHalf = 8f; const float headDepth = 5f; var colorHead = ImGui.GetColorU32(new Vector4(0.95f, 0.78f, 0.4f, 0.95f)); var colorOutline = ImGui.GetColorU32(new Vector4(0.45f, 0.28f, 0.08f, 1f)); var drawn = 0; foreach (var (tx, ty, arrowTooltip, questMarkerIconId) in targets) { if (drawn >= MaximapQuestArrowLimit) break; var targetInContent = drawPosition + new Vector2(tx, ty) * scale; var toTarget = targetInContent - centerOffset; var distToTarget = toTarget.Length(); if (distToTarget < 0.01f) continue; if (distToTarget <= radius) continue; // Target visible on minimap, no arrow var direction = toTarget / distToTarget; var arrowPos = centerScreen + direction * arrowDist; var cos = MathF.Cos(MathF.Atan2(direction.Y, direction.X)); var sin = MathF.Sin(MathF.Atan2(direction.Y, direction.X)); var perpX = -sin; var perpY = cos; var tipScreen = arrowPos + new Vector2(cos * arrowSize, sin * arrowSize); var baseBack = arrowPos - new Vector2(cos * headDepth, sin * headDepth); var base1Screen = baseBack + new Vector2(perpX * baseHalf, perpY * baseHalf); var base2Screen = baseBack - new Vector2(perpX * baseHalf, perpY * baseHalf); drawList.AddTriangleFilled(tipScreen, base1Screen, base2Screen, colorHead); drawList.AddTriangle(tipScreen, base1Screen, base2Screen, colorOutline, 2f); if (!string.IsNullOrEmpty(arrowTooltip)) { var minX = Math.Min(tipScreen.X, Math.Min(base1Screen.X, base2Screen.X)) - 4f; var minY = Math.Min(tipScreen.Y, Math.Min(base1Screen.Y, base2Screen.Y)) - 4f; var maxX = Math.Max(tipScreen.X, Math.Max(base1Screen.X, base2Screen.X)) + 4f; var maxY = Math.Max(tipScreen.Y, Math.Max(base1Screen.Y, base2Screen.Y)) + 4f; if (ImGui.IsMouseHoveringRect(new Vector2(minX, minY), new Vector2(maxX, maxY))) ImGui.SetTooltip(arrowTooltip); } drawn++; } } /// Collects all quest/flag direction targets in the current zone (flag, temp markers, cached temps, journal objectives). private unsafe List<(float tx, float ty, string? tooltip, uint? iconId)> GetAllQuestDirectionTargets(uint currentMapId, float offsetX, float offsetY, float scaleFactor) { var list = new List<(float tx, float ty, string? tooltip, uint? iconId)>(); var agent = AgentMap.Instance(); const float samePosEpsilon = 2f; // texture-space distance to consider same target void AddIfNew(float tx, float ty, string? tooltip, uint? iconId) { foreach (var (otx, oty, _, _) in list) if (Math.Abs(otx - tx) < samePosEpsilon && Math.Abs(oty - ty) < samePosEpsilon) return; list.Add((tx, ty, tooltip, iconId)); } if (agent->FlagMarkerCount > 0) { ref var flag = ref agent->FlagMapMarkers[0]; if (flag.TerritoryId == agent->CurrentMapId || flag.TerritoryId == agent->CurrentTerritoryId) { var tooltip = System.TooltipCache.GetValue(flag.MapMarker.IconId); if (string.IsNullOrEmpty(tooltip)) tooltip = "Flag"; AddIfNew(1024.0f + (flag.XFloat - offsetX) * scaleFactor, 1024.0f + (flag.YFloat - offsetY) * scaleFactor, tooltip, flag.MapMarker.IconId); } } if (agent->TempMapMarkerCount > 0) { var span = new Span(Unsafe.AsPointer(ref agent->TempMapMarkers[0]), agent->TempMapMarkerCount); var groups = span.ToArray().GroupBy(m => new Vector2(m.MapMarker.X, m.MapMarker.Y)); foreach (var group in groups) { var first = group.First(); var iconId = group.FirstOrNull(m => m.MapMarker.IconId is not (60493 or 0))?.MapMarker.IconId ?? first.MapMarker.IconId; if (iconId is 0 && group.Count() == 2 && first.Type == 4 && group.Last() is { Type: 6, MapMarker.IconId: 0 }) iconId = DrawHelpers.QuestionMarkIcon; var tooltip = first.TooltipText.ToString(); var tx = 1024.0f + (first.MapMarker.X / 16.0f - offsetX) * scaleFactor; var ty = 1024.0f + (first.MapMarker.Y / 16.0f - offsetY) * scaleFactor; AddIfNew(tx, ty, tooltip, iconId is 0 ? null : iconId); } } if (TempMarkerCache.TryGetValue(currentMapId, out var cached)) { foreach (var m in cached) { var tx = 1024.0f + (m.X / 16.0f - offsetX) * scaleFactor; var ty = 1024.0f + (m.Y / 16.0f - offsetY) * scaleFactor; AddIfNew(tx, ty, m.Tooltip, m.IconId); } } foreach (var (tx, ty, tooltip) in GetAllJournalQuestObjectivesInZone(currentMapId, offsetX, offsetY, scaleFactor)) AddIfNew(tx, ty, tooltip, 60470u); return list; } /// Get texture positions and tooltips for all quests in the journal that have an objective on the given map. private static unsafe List<(float tx, float ty, string? tooltip)> GetAllJournalQuestObjectivesInZone(uint currentMapId, float offsetX, float offsetY, float scaleFactor) { var result = new List<(float tx, float ty, string? tooltip)>(); try { var questSheet = Service.DataManager.GetExcelSheet(); if (questSheet is null) return result; foreach (var quest in QuestManager.Instance()->NormalQuests) { if (quest.QuestId is 0) continue; if (!questSheet.HasRow(quest.QuestId + 65536u)) continue; var questData = questSheet.GetRow(quest.QuestId + 65536u); var todoParam = questData.TodoParams.FirstOrDefault(p => p.ToDoCompleteSeq == quest.Sequence); var location = todoParam.ToDoLocation.FirstOrDefault(loc => loc is not { RowId: 0, ValueNullable: null }); if (location.ValueNullable is null || location.Value.Map.RowId != currentMapId) continue; var name = questData.Name.ExtractText(); if (string.IsNullOrWhiteSpace(name)) name = "Quest objective"; var worldX = location.Value.X; var worldZ = location.Value.Z; var tx = 1024.0f + (worldX - offsetX) * scaleFactor; var ty = 1024.0f + (worldZ - offsetY) * scaleFactor; result.Add((tx, ty, name)); } } catch { // Ignore missing sheet, invalid rows, etc. } return result; } /// Draws direction arrows for nearby FATEs at the edge of the minimap (purple). Only shown when the FATE is off the minimap. private static unsafe void DrawMinimapFateDirectionArrows( Vector2 contentTopLeft, Vector2 drawPosition, float scale, Vector2 size, Vector2 centerOffset, float offsetX, float offsetY, float scaleFactor) { if (Service.FateTable.Length is 0) return; var radius = Math.Min(size.X, size.Y) * 0.5f; var arrowDist = radius - 4f; var centerScreen = contentTopLeft + centerOffset; var drawList = ImGui.GetWindowDrawList(); var fateColor = new Vector4(0.55f, 0.28f, 0.62f, 0.95f); var fateOutline = new Vector4(0.28f, 0.14f, 0.32f, 1f); // Same UI-style arrowhead as quest: larger, fill + crisp outline const float arrowSize = 20f; const float baseHalf = 8f; const float headDepth = 5f; for (var i = 0; i < Service.FateTable.Length; i++) { var fateData = FateManager.Instance()->Fates[i]; var fate = fateData.Value; if (fate->IconId is 0) continue; var pos = new Vector2(fate->Location.X, fate->Location.Z); var tx = 1024.0f + (pos.X - offsetX) * scaleFactor; var ty = 1024.0f + (pos.Y - offsetY) * scaleFactor; var targetInContent = drawPosition + new Vector2(tx, ty) * scale; var toTarget = targetInContent - centerOffset; var distToTarget = toTarget.Length(); if (distToTarget < 0.01f) continue; if (distToTarget <= radius) continue; // FATE visible on minimap, no arrow var direction = toTarget / distToTarget; var arrowPos = centerScreen + direction * arrowDist; var cos = MathF.Cos(MathF.Atan2(direction.Y, direction.X)); var sin = MathF.Sin(MathF.Atan2(direction.Y, direction.X)); var perpX = -sin; var perpY = cos; var tipScreen = arrowPos + new Vector2(cos * arrowSize, sin * arrowSize); var baseBack = arrowPos - new Vector2(cos * headDepth, sin * headDepth); var base1Screen = baseBack + new Vector2(perpX * baseHalf, perpY * baseHalf); var base2Screen = baseBack - new Vector2(perpX * baseHalf, perpY * baseHalf); var fateColorU32 = ImGui.GetColorU32(fateColor); var fateOutlineU32 = ImGui.GetColorU32(fateOutline); drawList.AddTriangleFilled(tipScreen, base1Screen, base2Screen, fateColorU32); drawList.AddTriangle(tipScreen, base1Screen, base2Screen, fateOutlineU32, 2f); var tooltip = GetFateTooltip(fateData); var minX = Math.Min(tipScreen.X, Math.Min(base1Screen.X, base2Screen.X)) - 4f; var minY = Math.Min(tipScreen.Y, Math.Min(base1Screen.Y, base2Screen.Y)) - 4f; var maxX = Math.Max(tipScreen.X, Math.Max(base1Screen.X, base2Screen.X)) + 4f; var maxY = Math.Max(tipScreen.Y, Math.Max(base1Screen.Y, base2Screen.Y)) + 4f; if (ImGui.IsMouseHoveringRect(new Vector2(minX, minY), new Vector2(maxX, maxY))) ImGui.SetTooltip(tooltip); } } private static bool IsInMinimapBounds(Vector2 contentPos, Vector2 size, float margin) { // Use a large margin so we don't cull markers that are panned slightly off (zoomed in). return contentPos.X >= -margin && contentPos.Y >= -margin && contentPos.X <= size.X + margin && contentPos.Y <= size.Y + margin; } /// If the mouse is over the quest radius circle, show the quest tooltip. private static void ShowQuestRadiusTooltipIfHovered(Vector2 centerScreen, float markerRadius, float mapScale, float sizeFactor, string? tooltip) { if (markerRadius <= 1.0f || string.IsNullOrEmpty(tooltip)) return; var radiusPixels = markerRadius * mapScale * sizeFactor; if (radiusPixels < 0.5f) return; var mouse = ImGui.GetMousePos(); if ((mouse - centerScreen).Length() <= radiusPixels) ImGui.SetTooltip(tooltip); } /// Large margin so markers aren't culled when panned. private const float MinimapBoundsMargin = 200f; /// Cached static POI (aetheryte, repair, moogle, etc.) per map so we can draw them on the minimap when the Area Map is closed. private static readonly Dictionary> StaticMarkerCache = new(); /// Cached quest/objective (temp) markers per map; populated during silent refresh on quest accept/turn-in/objective update. private static readonly Dictionary> TempMarkerCache = new(); /// Cached non-FATE event markers per map; populated during silent refresh. private static readonly Dictionary> EventMarkerCache = new(); private readonly struct CachedStaticMarker { public readonly uint IconId; public readonly int X; public readonly int Y; public readonly string? Tooltip; public CachedStaticMarker(uint iconId, int x, int y, string? tooltip) { IconId = iconId; X = x; Y = y; Tooltip = tooltip; } } private readonly struct CachedTempMarker { public readonly uint IconId; public readonly int X; public readonly int Y; public readonly float Radius; public readonly string? Tooltip; public CachedTempMarker(uint iconId, int x, int y, float radius, string? tooltip) { IconId = iconId; X = x; Y = y; Radius = radius; Tooltip = tooltip; } } private readonly struct CachedEventMarker { public readonly uint IconId; public readonly float MapX; public readonly float MapY; public readonly float Radius; public readonly string? Tooltip; public CachedEventMarker(uint iconId, float mapX, float mapY, float radius, string? tooltip) { IconId = iconId; MapX = mapX; MapY = mapY; Radius = radius; Tooltip = tooltip; } } private unsafe void DrawMinimapStaticMarkers(Vector2 contentTopLeft, Func texToContent, Vector2 size) { var agent = AgentMap.Instance(); var mapId = agent->CurrentMapId; // When we have live data, update the cache for this map so we can draw static POI later when the map is closed. if (agent->MapMarkerCount > 0) { var list = new List(); for (var i = 0; i < agent->MapMarkerCount; i++) { ref var marker = ref agent->MapMarkers[i]; if (marker.MapMarker.IconId is 0) continue; var tooltipText = marker.MapMarker.Subtext.AsDalamudSeString(); var tooltip = (string.IsNullOrEmpty(tooltipText.TextValue) && System.SystemConfig.ShowMiscTooltips) ? System.TooltipCache.GetValue(marker.MapMarker.IconId) : tooltipText.ToString(); list.Add(new CachedStaticMarker(marker.MapMarker.IconId, marker.MapMarker.X, marker.MapMarker.Y, tooltip)); } StaticMarkerCache[mapId] = list; } // Draw from live data if available, otherwise from cache for current map. if (agent->MapMarkerCount > 0) { for (var i = 0; i < agent->MapMarkerCount; i++) { ref var marker = ref agent->MapMarkers[i]; if (marker.MapMarker.IconId is 0) continue; if (System.IconConfig.IconSettingMap.TryGetValue(marker.MapMarker.IconId, out var setting) && setting.Hide) continue; var tx = 1024.0f + marker.MapMarker.X / 16.0f; var ty = 1024.0f + marker.MapMarker.Y / 16.0f; var contentPos = texToContent(tx, ty); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; var tooltipText = marker.MapMarker.Subtext.AsDalamudSeString(); var tooltip = (string.IsNullOrEmpty(tooltipText.TextValue) && System.SystemConfig.ShowMiscTooltips) ? System.TooltipCache.GetValue(marker.MapMarker.IconId) : tooltipText.ToString(); DrawMinimapIcon(marker.MapMarker.IconId, contentPos + contentTopLeft, sizeScale: 1.5f, tooltip); } return; } if (!StaticMarkerCache.TryGetValue(mapId, out var cached)) return; foreach (var m in cached) { if (m.IconId is 0) continue; if (System.IconConfig.IconSettingMap.TryGetValue(m.IconId, out var setting) && setting.Hide) continue; var tx = 1024.0f + m.X / 16.0f; var ty = 1024.0f + m.Y / 16.0f; var contentPos = texToContent(tx, ty); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; DrawMinimapIcon(m.IconId, contentPos + contentTopLeft, sizeScale: 1.5f, m.Tooltip); } } private unsafe void DrawMinimapEventMarkers(Vector2 contentTopLeft, Func texToContent, Vector2 size, float scaleFactor, float offsetX, float offsetY, float scale) { var agent = AgentMap.Instance(); var mapId = agent->CurrentMapId; var groups = agent->EventMarkers.GroupBy(m => (m.DataId, m.Position.X, m.Position.Z)); var showRadius = System.SystemConfig.MinimapShowQuestAreaRadius; // Use minimap scale so the circle scales with minimap zoom (radiusPixels = markerRadius * scale * scaleFactor). var hasNonFate = false; var cacheList = new List(); foreach (var group in groups) { var first = group.First(); if ((MarkerType)first.MarkerType is MarkerType.Fate) continue; hasNonFate = true; var iconId = group.FirstOrNull(m => m.IconId is not 60493)?.IconId ?? first.IconId; if (iconId is 0) continue; var markerRadius = group.Max(m => m.Radius); if (HousingManager.Instance()->CurrentTerritory is not null) markerRadius = 0f; var pos = first.Position.AsMapVector(); var tooltip = GetEventMarkerTooltip(first); cacheList.Add(new CachedEventMarker(iconId, pos.X, pos.Y, markerRadius, tooltip)); var tx = 1024.0f + (pos.X - offsetX) * scaleFactor; var ty = 1024.0f + (pos.Y - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); var centerScreen = contentPos + contentTopLeft; if (showRadius) DrawHelpers.DrawRadiusCircle(centerScreen, markerRadius, scale, scaleFactor, MinimapQuestCircleFill, MinimapQuestCircleOutline); ShowQuestRadiusTooltipIfHovered(centerScreen, markerRadius, scale, scaleFactor, tooltip); if (System.IconConfig.IconSettingMap.TryGetValue(iconId, out var setting) && setting.Hide) continue; if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; DrawMinimapIcon(iconId, centerScreen, sizeScale: 1.5f, tooltip); } if (hasNonFate && cacheList.Count > 0) EventMarkerCache[mapId] = cacheList; else if (EventMarkerCache.TryGetValue(mapId, out var cached)) { foreach (var m in cached) { if (m.IconId is 0) continue; if (System.IconConfig.IconSettingMap.TryGetValue(m.IconId, out var setting) && setting.Hide) continue; var tx = 1024.0f + (m.MapX - offsetX) * scaleFactor; var ty = 1024.0f + (m.MapY - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); var centerScreen = contentPos + contentTopLeft; if (showRadius) DrawHelpers.DrawRadiusCircle(centerScreen, m.Radius, scale, scaleFactor, MinimapQuestCircleFill, MinimapQuestCircleOutline); ShowQuestRadiusTooltipIfHovered(centerScreen, m.Radius, scale, scaleFactor, m.Tooltip); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; DrawMinimapIcon(m.IconId, centerScreen, sizeScale: 1.5f, m.Tooltip); } } } /// Draw FATE markers from FateManager so they update without opening the Area Map. private unsafe void DrawMinimapFatesFromFateManager(Vector2 contentTopLeft, Func texToContent, Vector2 size, float scaleFactor, float offsetX, float offsetY) { if (Service.FateTable.Length is 0) return; for (var i = 0; i < Service.FateTable.Length; i++) { var fateData = FateManager.Instance()->Fates[i]; var fate = fateData.Value; if (fate->IconId is 0) continue; if (System.IconConfig.IconSettingMap.TryGetValue(fate->IconId, out var setting) && setting.Hide) continue; var pos = new Vector2(fate->Location.X, fate->Location.Z); var tx = 1024.0f + (pos.X - offsetX) * scaleFactor; var ty = 1024.0f + (pos.Y - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; var tooltip = GetFateTooltip(fateData); DrawMinimapIcon(fate->IconId, contentPos + contentTopLeft, sizeScale: 1.5f, tooltip); } } private static unsafe string GetFateTooltip(Pointer fateData) { try { var fate = fateData.Value; var name = fate->Name.ToString(); var title = !string.IsNullOrWhiteSpace(name) ? $"{name}\nLv. {fate->Level} FATE" : $"Lv. {fate->Level} FATE"; var timeRemaining = fateData.GetTimeRemaining(); if (fate->State is FateState.Running) { var progressLine = $"Progress: {fate->Progress}%"; if (timeRemaining > TimeSpan.Zero) title += $"\n{(fate->IsBonus ? "Exp Bonus! " : "")}{SeIconChar.Clock.ToIconString()} {timeRemaining:mm\\:ss} {progressLine}"; else title += $"\n{progressLine}"; } else if (fate->State is not FateState.Preparing) { title += $"\n{fate->State}"; } return title; } catch { return "FATE"; } } private static unsafe string GetEventMarkerTooltip(MapMarkerData marker) { try { // FATE path never touches marker.TooltipString (avoids AccessViolationException from stale pointers). if ((MarkerType)marker.MarkerType is MarkerType.Fate) { var fateData = FateManager.Instance()->Fates.FirstOrNull(fate => fate.Value->FateId == marker.DataId); if (fateData is not null) { var fatePtr = fateData.Value.Value; var name = fatePtr->Name.ToString(); var title = !string.IsNullOrWhiteSpace(name) ? $"{name}\nLv. {fatePtr->Level} FATE" : $"Lv. {fatePtr->Level} FATE"; var timeRemaining = fateData.Value.GetTimeRemaining(); if (fatePtr->State is FateState.Running) { var progressLine = $"Progress: {fatePtr->Progress}%"; if (timeRemaining > TimeSpan.Zero) title += $"\n{(fatePtr->IsBonus ? "Exp Bonus! " : "")}{SeIconChar.Clock.ToIconString()} {timeRemaining:mm\\:ss} {progressLine}"; else title += $"\n{progressLine}"; } else if (fatePtr->State is not FateState.Preparing) { title += $"\n{fatePtr->State}"; } return title; } return "FATE"; } // Other event markers: try to get name from Lumina (e.g. Quest by DataId) instead of "Lv. X Event". if (marker.DataId != 0) { try { var questRow = Service.DataManager.GetExcelSheet()?.GetRow(marker.DataId + 65536u); var name = questRow?.Name.ExtractText(); if (!string.IsNullOrWhiteSpace(name)) return name; } catch { } } return marker.RecommendedLevel is 0 ? "Event" : $"Lv. {marker.RecommendedLevel} Event"; } catch (AccessViolationException) { return string.Empty; } catch (NullReferenceException) { return string.Empty; } catch (Exception) { return string.Empty; } } private unsafe void DrawMinimapGatheringMarkers(Vector2 contentTopLeft, Func texToContent, Vector2 size) { var agent = AgentMap.Instance(); foreach (var marker in agent->MiniMapGatheringMarkers) { if (marker.ShouldRender is 0) continue; var iconId = marker.MapMarker.IconId; if (iconId is 0) continue; if (System.IconConfig.IconSettingMap.TryGetValue(iconId, out var setting) && setting.Hide) continue; var tx = 1024.0f + marker.MapMarker.X / 16.0f; var ty = 1024.0f + marker.MapMarker.Y / 16.0f; var contentPos = texToContent(tx, ty); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; var tooltipText = marker.MapMarker.Subtext.AsDalamudSeString(); var tooltip = (string.IsNullOrEmpty(tooltipText.TextValue) && System.SystemConfig.ShowMiscTooltips) ? System.TooltipCache.GetValue(marker.MapMarker.IconId) : tooltipText.ToString(); DrawMinimapIcon(iconId, contentPos + contentTopLeft, sizeScale: 1.5f, tooltip); } } /// Draw party and alliance members on the minimap. When a member is off the minimap circle, draw a faded marker at the rim. private unsafe void DrawMinimapGroupMembers(Vector2 contentTopLeft, Func texToContent, Vector2 size, float scaleFactor, float offsetX, float offsetY) { var agent = AgentMap.Instance(); var currentTerritoryId = agent->CurrentTerritoryId; var centerOffset = size * 0.5f; var radius = Math.Min(size.X, size.Y) * 0.5f; const float rimMargin = 2f; // place icon at the rim (minimal inset) const float offMapFadeAlpha = 0.5f; foreach (var partyMember in GroupManager.Instance()->MainGroup.PartyMembers[..(int)GroupManager.Instance()->MainGroup.MemberCount]) { if (partyMember.EntityId is 0xE0000000) continue; if (partyMember.TerritoryType != currentTerritoryId) continue; if (System.IconConfig.IconSettingMap.TryGetValue(60421, out var setting) && setting.Hide) continue; var pos = new Vector2(partyMember.Position.X, partyMember.Position.Z); var tx = 1024.0f + (pos.X - offsetX) * scaleFactor; var ty = 1024.0f + (pos.Y - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); var distFromCenter = (contentPos - centerOffset).Length(); var tooltip = $"Lv. {partyMember.Level} {partyMember.NameString}"; if (distFromCenter <= radius && IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) { DrawMinimapIcon(60421, contentPos + contentTopLeft, sizeScale: 1.5f, tooltip); } else if (distFromCenter > radius) { var direction = (contentPos - centerOffset) / distFromCenter; var rimPos = centerOffset + direction * (radius - rimMargin); DrawMinimapIcon(60421, rimPos + contentTopLeft, sizeScale: 1.5f, tooltip, fadeAlpha: offMapFadeAlpha); } } foreach (var allianceMember in GroupManager.Instance()->MainGroup.AllianceMembers) { if (allianceMember.EntityId is 0xE0000000) continue; if (allianceMember.TerritoryType != currentTerritoryId) continue; if (System.IconConfig.IconSettingMap.TryGetValue(60403, out var allianceSetting) && allianceSetting.Hide) continue; var pos = new Vector2(allianceMember.Position.X, allianceMember.Position.Z); var tx = 1024.0f + (pos.X - offsetX) * scaleFactor; var ty = 1024.0f + (pos.Y - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); var distFromCenter = (contentPos - centerOffset).Length(); var allianceTooltip = $"Lv. {allianceMember.Level} {allianceMember.NameString}"; if (distFromCenter <= radius && IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) { DrawMinimapIcon(60403, contentPos + contentTopLeft, sizeScale: 1.5f, allianceTooltip); } else if (distFromCenter > radius) { var direction = (contentPos - centerOffset) / distFromCenter; var rimPos = centerOffset + direction * (radius - rimMargin); DrawMinimapIcon(60403, rimPos + contentTopLeft, sizeScale: 1.5f, allianceTooltip, fadeAlpha: offMapFadeAlpha); } } } private unsafe void DrawMinimapFlag(Vector2 contentTopLeft, Func texToContent, float scaleFactor, float offsetX, float offsetY) { var agent = AgentMap.Instance(); if (agent->FlagMarkerCount is 0) return; ref var flag = ref agent->FlagMapMarkers[0]; if (flag.TerritoryId != agent->CurrentMapId) return; if (System.IconConfig.IconSettingMap.TryGetValue(flag.MapMarker.IconId, out var setting) && setting.Hide) return; var tx = 1024.0f + (flag.XFloat - offsetX) * scaleFactor; var ty = 1024.0f + (flag.YFloat - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); var flagTooltip = System.TooltipCache.GetValue(flag.MapMarker.IconId); if (string.IsNullOrEmpty(flagTooltip)) flagTooltip = "Flag"; DrawMinimapIcon(flag.MapMarker.IconId, contentPos + contentTopLeft, sizeScale: 1.5f, flagTooltip); } private unsafe void DrawMinimapTempMarkers(Vector2 contentTopLeft, Func texToContent, Vector2 size, float scaleFactor, float offsetX, float offsetY, float scale) { var agent = AgentMap.Instance(); var mapId = agent->CurrentMapId; var showRadius = System.SystemConfig.MinimapShowQuestAreaRadius; // Use minimap scale so the circle scales with minimap zoom and is not tied to area map zoom. // radiusPixels = markerRadius * scale * scaleFactor (same as position transform on minimap). if (agent->TempMapMarkerCount > 0) { var span = new Span(Unsafe.AsPointer(ref agent->TempMapMarkers[0]), agent->TempMapMarkerCount); var groups = span.ToArray().GroupBy(m => new Vector2(m.MapMarker.X, m.MapMarker.Y)); var cacheList = new List(); foreach (var group in groups) { var first = group.First(); var markerRadius = group.Max(m => m.MapMarker.Scale); var iconId = group.FirstOrNull(m => m.MapMarker.IconId is not (60493 or 0))?.MapMarker.IconId ?? first.MapMarker.IconId; if (iconId is 0 && group.Count() == 2 && first.Type == 4 && group.Last() is { Type: 6, MapMarker.IconId: 0 }) iconId = DrawHelpers.QuestionMarkIcon; if (iconId is 0) continue; var tooltip = first.TooltipText.ToString(); cacheList.Add(new CachedTempMarker(iconId, first.MapMarker.X, first.MapMarker.Y, markerRadius, tooltip)); var tx = 1024.0f + (first.MapMarker.X / 16.0f - offsetX) * scaleFactor; var ty = 1024.0f + (first.MapMarker.Y / 16.0f - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); var centerScreen = contentPos + contentTopLeft; if (showRadius) DrawHelpers.DrawRadiusCircle(centerScreen, markerRadius, scale, scaleFactor, MinimapQuestCircleFill, MinimapQuestCircleOutline); ShowQuestRadiusTooltipIfHovered(centerScreen, markerRadius, scale, scaleFactor, tooltip); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; if (System.IconConfig.IconSettingMap.TryGetValue(iconId, out var setting) && setting.Hide) continue; DrawMinimapIcon(iconId, centerScreen, sizeScale: 1.5f, tooltip); } if (cacheList.Count > 0) TempMarkerCache[mapId] = cacheList; return; } if (!TempMarkerCache.TryGetValue(mapId, out var cached)) return; foreach (var m in cached) { if (m.IconId is 0) continue; if (System.IconConfig.IconSettingMap.TryGetValue(m.IconId, out var setting) && setting.Hide) continue; var tx = 1024.0f + (m.X / 16.0f - offsetX) * scaleFactor; var ty = 1024.0f + (m.Y / 16.0f - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); var centerScreen = contentPos + contentTopLeft; if (showRadius) DrawHelpers.DrawRadiusCircle(centerScreen, m.Radius, scale, scaleFactor, MinimapQuestCircleFill, MinimapQuestCircleOutline); ShowQuestRadiusTooltipIfHovered(centerScreen, m.Radius, scale, scaleFactor, m.Tooltip); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; DrawMinimapIcon(m.IconId, centerScreen, sizeScale: 1.5f, m.Tooltip); } } private unsafe void DrawMinimapFieldMarkers(Vector2 contentTopLeft, Func texToContent, Vector2 size, float scaleFactor, float offsetX, float offsetY) { var agent = AgentMap.Instance(); if (agent->CurrentMapId != agent->SelectedMapId) return; var fieldMarkersSheet = Service.DataManager.GetExcelSheet().Where(m => m.MapIcon is not 0).ToList(); var markerSpan = MarkingController.Instance()->FieldMarkers; for (var i = 0; i < 8; i++) { if (markerSpan[i] is not { Active: true } marker) continue; if (i >= fieldMarkersSheet.Count) continue; var iconId = fieldMarkersSheet[i].MapIcon; if (iconId is 0) continue; if (System.IconConfig.IconSettingMap.TryGetValue(iconId, out var setting) && setting.Hide) continue; // Field marker position: world coords / 1000 * scaleFactor, then texture = 1024 + (world - offset) * scaleFactor var wx = marker.X / 1000.0f; var wz = marker.Z / 1000.0f; var tx = 1024.0f + (wx - offsetX) * scaleFactor; var ty = 1024.0f + (wz - offsetY) * scaleFactor; var contentPos = texToContent(tx, ty); if (!IsInMinimapBounds(contentPos, size, MinimapBoundsMargin)) continue; DrawMinimapIcon(iconId, contentPos + contentTopLeft, sizeScale: 1.5f, $"Waymark {i + 1}"); } } private void DrawMinimapIcon(uint iconId, Vector2 screenPos, float sizeScale = 1f, string? tooltip = null, float? fadeAlpha = null) { try { var texture = Service.TextureProvider.GetFromGameIcon(iconId).GetWrapOrEmpty(); var texSize = texture.Size; if (texSize.X <= 0 || texSize.Y <= 0) return; var iconScale = MinimapIconSize / Math.Max(texSize.X, texSize.Y) * MinimapIconScaleFromConfig * sizeScale; System.IconConfig.IconSettingMap.TryGetValue(iconId, out var setting); if (setting != null) iconScale *= setting.Scale; var size = texSize * iconScale; var half = size / 2f; var col = setting?.Color ?? Vector4.One; if (fadeAlpha is { } alpha) col.W *= alpha; var drawList = ImGui.GetWindowDrawList(); drawList.AddImage(texture.Handle, screenPos - half, screenPos + half, Vector2.Zero, Vector2.One, ImGui.GetColorU32(col)); if (!string.IsNullOrEmpty(tooltip) && ImGui.IsMouseHoveringRect(screenPos - half, screenPos + half)) ImGui.SetTooltip(tooltip); } catch (Dalamud.Interface.Textures.Internal.IconNotFoundException) { // Icon not in game data (e.g. 60494 HiRes), skip drawing } } }