Initial HSMappy release (fork of Mappy)

Made-with: Cursor
This commit is contained in:
2026-02-26 03:54:51 -05:00
commit 9659f7a7d1
72 changed files with 6625 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Threading.Tasks;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Lumina.Data.Files;
using Lumina.Excel.Sheets;
using Lumina.Extensions;
using Mappy.Classes;
namespace Mappy.MapRenderer;
public unsafe partial class MapRenderer : IDisposable
{
private const int MinimapCacheMaxEntries = 8;
private sealed class MinimapCacheEntry
{
public IDalamudTextureWrap? Texture;
public string PathKey = string.Empty;
public float ScaleFactor;
public float OffsetX;
public float OffsetY;
}
private readonly Dictionary<uint, MinimapCacheEntry> _minimapCache = new();
public static float Scale
{
get => System.SystemConfig.MapScale;
set => System.SystemConfig.MapScale = value;
}
public Vector2 DrawOffset { get; set; }
public Vector2 DrawPosition { get; private set; }
private IDalamudTextureWrap? blendedTexture;
private string blendedPath = string.Empty;
public MapRenderer()
{
LoadFogHooks();
}
public void Dispose()
{
foreach (var entry in _minimapCache.Values)
entry.Texture?.Dispose();
_minimapCache.Clear();
UnloadFogHooks();
}
public void CenterOnGameObject(IGameObject obj) => CenterOnCoordinate(new Vector2(obj.Position.X, obj.Position.Z));
public void CenterOnCoordinate(Vector2 coord) => DrawOffset = -coord * DrawHelpers.GetMapScaleFactor() + DrawHelpers.GetMapOffsetVector();
public void DrawBaseTexture()
{
UpdateScaleLimits();
UpdateDrawOffset();
DrawBackgroundTexture();
}
public void DrawDynamicElements()
{
DrawFogOfWar();
DrawMapMarkers();
}
private void UpdateScaleLimits() => Scale = Math.Clamp(Scale, 0.05f, 20.0f);
private void UpdateDrawOffset()
{
var childCenterOffset = ImGui.GetContentRegionAvail() / 2.0f;
var mapCenterOffset = new Vector2(1024.0f, 1024.0f) * Scale;
DrawPosition = childCenterOffset - mapCenterOffset + DrawOffset * Scale;
}
private void DrawBackgroundTexture()
{
if (AgentMap.Instance()->SelectedMapBgPath.Length is 0) {
var texture = Service.TextureProvider.GetFromGame($"{AgentMap.Instance()->SelectedMapPath.ToString()}.tex").GetWrapOrEmpty();
ImGui.SetCursorPos(DrawPosition);
ImGui.Image(texture.Handle, texture.Size * Scale);
}
else {
if (blendedPath != AgentMap.Instance()->SelectedMapBgPath.ToString()) {
fogTexture = null;
blendedTexture?.Dispose();
blendedTexture = LoadTexture();
blendedPath = AgentMap.Instance()->SelectedMapBgPath.ToString();
}
if (blendedTexture is not null) {
ImGui.SetCursorPos(DrawPosition);
ImGui.Image(blendedTexture.Handle, blendedTexture.Size * Scale);
}
}
}
/// <summary>
/// Draw map texture at a specific position and scale (for minimap).
/// </summary>
private void DrawBackgroundTextureAt(Vector2 drawPosition, float scale)
{
if (AgentMap.Instance()->SelectedMapBgPath.Length is 0) {
var texture = Service.TextureProvider.GetFromGame($"{AgentMap.Instance()->SelectedMapPath.ToString()}.tex").GetWrapOrEmpty();
ImGui.SetCursorPos(drawPosition);
ImGui.Image(texture.Handle, texture.Size * scale);
}
else {
if (blendedPath != AgentMap.Instance()->SelectedMapBgPath.ToString()) {
fogTexture = null;
blendedTexture?.Dispose();
blendedTexture = LoadTexture();
blendedPath = AgentMap.Instance()->SelectedMapBgPath.ToString();
}
if (blendedTexture is not null) {
ImGui.SetCursorPos(drawPosition);
ImGui.Image(blendedTexture.Handle, blendedTexture.Size * scale);
}
}
}
/// <summary>
/// Draw cached map texture for minimap (used when area map is closed).
/// </summary>
private void DrawMinimapCachedTextureAt(Vector2 drawPosition, float scale, IDalamudTextureWrap texture)
{
ImGui.SetCursorPos(drawPosition);
ImGui.Image(texture.Handle, texture.Size * scale);
}
/// <summary>
/// True if we have cached texture/transform for this map (e.g. after opening the area map once).
/// </summary>
public bool HasMinimapCacheFor(uint mapId) => _minimapCache.ContainsKey(mapId);
/// <summary>
/// Try to load map texture and transform from Lumina (Map sheet) so the minimap can draw without opening the area map.
/// Uses game map path conventions (ui/map/...) and Map.SizeFactor, Map.OffsetX/Y. On success, fills the cache for this map.
/// </summary>
public bool TryEnsureLuminaCacheFor(uint mapId)
{
if (mapId == 0 || _minimapCache.ContainsKey(mapId)) return true;
var map = Service.DataManager.GetExcelSheet<Map>().GetRow(mapId);
if (map.RowId == 0) return false;
var idStr = map.Id.ExtractText()?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(idStr)) return false;
// Try several path conventions so the minimap can show without ever requiring the user to open the Area Map.
var fileName = idStr.Replace("/", "");
var pathsToTry = new[]
{
$"ui/map/{idStr}/{fileName}_m.tex",
$"ui/map/{idStr}/{fileName}_l.tex",
$"ui/map/{idStr}/{fileName}.tex",
$"ui/uld/areamap/{mapId:D4}.tex",
$"ui/uld/areamap/{fileName}.tex",
};
IDalamudTextureWrap? texture = null;
foreach (var path in pathsToTry) {
texture = LoadSingleTexture(path);
if (texture is not null) break;
}
if (texture is null) return false;
TrimMinimapCacheToLimit();
var entry = _minimapCache[mapId] = new MinimapCacheEntry();
entry.PathKey = $"lumina:{mapId}";
entry.Texture = texture;
entry.ScaleFactor = map.SizeFactor / 100f;
entry.OffsetX = map.OffsetX;
entry.OffsetY = map.OffsetY;
return true;
}
private static IDalamudTextureWrap? LoadSingleTexture(string path)
{
var file = GetTexFile(path);
if (file is null) return null;
var bytes = file.GetRgbaImageData();
var w = file.Header.Width;
var h = file.Header.Height;
return Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(w, h), bytes);
}
/// <summary>
/// Draw minimap view (current map only, centered on player) into the current ImGui cursor region.
/// Prefers Lumina-loaded data so the minimap shows automatically without opening the Area Map.
/// If the user opens the main map (M), we cache that for a higher-quality display; otherwise we use Lumina when available.
/// </summary>
public void DrawMinimapContents(Vector2 size)
{
var agent = AgentMap.Instance();
if (Service.ObjectTable.LocalPlayer is not { } localPlayer) return;
var currentMapId = agent->CurrentMapId;
if (currentMapId == 0) return;
// Do not call OpenMapByMapId/RefreshMapMarkers from here: it opens the area map repeatedly.
// Markers are drawn from whatever the game has already populated (e.g. after opening the map once).
// When the game has the current map loaded (area map open or just closed), update our cache for this map.
if (agent->SelectedMapId == currentMapId && agent->SelectedMapPath.Length > 0) {
var bgPath = $"{agent->SelectedMapBgPath}.tex";
var fgPath = $"{agent->SelectedMapPath}.tex";
var pathKey = bgPath + "|" + fgPath;
if (!_minimapCache.TryGetValue(currentMapId, out var entry) || entry.PathKey != pathKey) {
TrimMinimapCacheToLimit();
entry = _minimapCache[currentMapId] = new MinimapCacheEntry();
entry.PathKey = pathKey;
entry.Texture?.Dispose();
entry.Texture = LoadTextureFromPaths(bgPath, fgPath);
}
if (entry.Texture is not null) {
entry.ScaleFactor = agent->SelectedMapSizeFactorFloat;
entry.OffsetX = agent->SelectedOffsetX;
entry.OffsetY = agent->SelectedOffsetY;
}
}
// If no cache yet, try loading from Lumina (Map sheet) so minimap works without opening the area map.
TryEnsureLuminaCacheFor(currentMapId);
// Draw from cache if we have it for the current map.
if (!_minimapCache.TryGetValue(currentMapId, out var cached) || cached.Texture is null)
return;
// Use the size passed by the minimap window (window size) so zoom/center is stable.
if (size.X <= 0 || size.Y <= 0) return;
var zoom = Math.Clamp(System.SystemConfig.MinimapZoom, 0.03f, 0.112f);
var mapSize = cached.Texture.Size.X;
// Scale so the map COVERS the view at zoom=1 (use max so no black bands at max zoom out).
var fitScale = Math.Max(size.X, size.Y) / mapSize;
var scale = fitScale / zoom;
// Ensure map always covers the view at any zoom (no black edges when zoomed in).
var minScale = Math.Max(size.X, size.Y) / mapSize;
scale = Math.Clamp(scale, minScale, 20.0f);
var playerCoord = new Vector2(localPlayer.Position.X, localPlayer.Position.Z);
var drawOffset = (-playerCoord + new Vector2(cached.OffsetX, cached.OffsetY)) * cached.ScaleFactor;
var centerOffset = size / 2.0f;
var mapCenterOffset = (cached.Texture.Size / 2f) * scale;
var drawPosition = centerOffset - mapCenterOffset + drawOffset * scale;
// Clamp so the map always fills the view (no black), but when zoomed in allow full pan so the player tracks.
var texSize = cached.Texture.Size * scale;
if (texSize.X > size.X && texSize.Y > size.Y) {
// Zoomed in: allow full pan range so the map can shift in any direction and the player stays at center.
drawPosition.X = Math.Clamp(drawPosition.X, size.X - texSize.X, texSize.X - size.X);
drawPosition.Y = Math.Clamp(drawPosition.Y, size.Y - texSize.Y, texSize.Y - size.Y);
} else {
// Zoomed out or exact fit: clamp so the map rect covers (0,0)-(size) to avoid black edges.
drawPosition.X = Math.Clamp(drawPosition.X, size.X - texSize.X, 0f);
drawPosition.Y = Math.Clamp(drawPosition.Y, size.Y - texSize.Y, 0f);
}
// Content top-left in screen space so player is drawn at the true center of the minimap (not offset by title bar).
var contentTopLeft = ImGui.GetCursorScreenPos();
DrawMinimapCachedTextureAt(drawPosition, scale, cached.Texture);
var centerScreen = contentTopLeft + centerOffset;
// Draw cone under markers so quest/FATE/POI markers stay visible on top of the cone.
DrawMinimapConeAtCenter(centerScreen, scale);
DrawMinimapMarkers(contentTopLeft, drawPosition, scale, size, cached.OffsetX, cached.OffsetY, cached.ScaleFactor);
DrawPlayerAtCenter(centerScreen, scale);
if (System.SystemConfig.MinimapShowQuestDirectionArrow)
DrawMinimapQuestDirectionArrow(contentTopLeft, drawPosition, scale, size, centerOffset, cached.OffsetX, cached.OffsetY, cached.ScaleFactor, currentMapId);
if (System.SystemConfig.MinimapShowFateDirectionArrows)
DrawMinimapFateDirectionArrows(contentTopLeft, drawPosition, scale, size, centerOffset, cached.OffsetX, cached.OffsetY, cached.ScaleFactor);
}
private void TrimMinimapCacheToLimit()
{
while (_minimapCache.Count >= MinimapCacheMaxEntries) {
var first = default(uint);
foreach (var k in _minimapCache.Keys) { first = k; break; }
if (_minimapCache.Remove(first, out var entry))
entry.Texture?.Dispose();
}
}
private static IDalamudTextureWrap? LoadTextureFromPaths(string vanillaBgPath, string vanillaFgPath)
{
var bgFile = GetTexFile(vanillaBgPath);
var fgFile = GetTexFile(vanillaFgPath);
if (bgFile is null || fgFile is null) return null;
var backgroundBytes = bgFile.GetRgbaImageData();
var foregroundBytes = fgFile.GetRgbaImageData();
Parallel.For(0, 2048 * 2048, i =>
{
var index = i * 4;
backgroundBytes[index + 0] = (byte)(backgroundBytes[index + 0] * foregroundBytes[index + 0] / 255);
backgroundBytes[index + 1] = (byte)(backgroundBytes[index + 1] * foregroundBytes[index + 1] / 255);
backgroundBytes[index + 2] = (byte)(backgroundBytes[index + 2] * foregroundBytes[index + 2] / 255);
});
return Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(2048, 2048), backgroundBytes);
}
private static IDalamudTextureWrap? LoadTexture()
{
var vanillaBgPath = $"{AgentMap.Instance()->SelectedMapBgPath.ToString()}.tex";
var vanillaFgPath = $"{AgentMap.Instance()->SelectedMapPath.ToString()}.tex";
var bgFile = GetTexFile(vanillaBgPath);
var fgFile = GetTexFile(vanillaFgPath);
if (bgFile is null || fgFile is null) {
Service.Log.Warning("Failed to load map textures");
return null;
}
var backgroundBytes = bgFile.GetRgbaImageData();
var foregroundBytes = fgFile.GetRgbaImageData();
// Blend textures together
Parallel.For(0, 2048 * 2048, i =>
{
var index = i * 4;
// Blend, R, G, B, skip A.
backgroundBytes[index + 0] = (byte)(backgroundBytes[index + 0] * foregroundBytes[index + 0] / 255);
backgroundBytes[index + 1] = (byte)(backgroundBytes[index + 1] * foregroundBytes[index + 1] / 255);
backgroundBytes[index + 2] = (byte)(backgroundBytes[index + 2] * foregroundBytes[index + 2] / 255);
});
return Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(2048, 2048), backgroundBytes);
}
private static TexFile? GetTexFile(string rawPath)
{
var path = Service.TextureSubstitutionProvider.GetSubstitutedPath(rawPath);
if (Path.IsPathRooted(path)) {
return Service.DataManager.GameData.GetFileFromDisk<TexFile>(path);
}
return Service.DataManager.GetFile<TexFile>(path);
}
private void DrawMapMarkers()
{
DrawStaticMapMarkers();
DrawDynamicMarkers();
DrawGameObjects();
DrawGroupMembers();
DrawTemporaryMarkers();
DrawGatheringMarkers();
DrawFieldMarkers();
DrawPlayer();
DrawStaticTextMarkers();
DrawFlag();
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Linq;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Lumina.Extensions;
using Mappy.Extensions;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private unsafe void DrawDynamicMarkers()
{
// Group together icons based on their dataId, this is because square enix shows circles then draws the actual icon overtop
var iconGroups = AgentMap.Instance()->EventMarkers.GroupBy(markers => (markers.DataId, markers.Position));
foreach (var group in iconGroups) {
// Make a copy of the first marker in the set, we will be mutating this copy.
var markerCopy = group.First();
// Get the actual iconId we want, typically the icon for the fate, not the circle
var correctIconId = group.FirstOrNull(marker => marker.IconId is not 60493);
markerCopy.IconId = correctIconId?.IconId ?? markerCopy.IconId;
// Get the actual radius value for this marker, typically the circle icon will have this value.
markerCopy.Radius = group.Max(marker => marker.Radius);
// Disable radius markings for housing areas
if (HousingManager.Instance()->CurrentTerritory is not null) {
markerCopy.Radius = 0.0f;
}
markerCopy.Draw(DrawPosition, Scale);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using System.Numerics;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Mappy.Classes;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private unsafe void DrawFlag()
{
if (AgentMap.Instance()->FlagMarkerCount is not 0 && AgentMap.Instance()->FlagMapMarkers[0].TerritoryId == AgentMap.Instance()->SelectedTerritoryId) {
ref var flagMarker = ref AgentMap.Instance()->FlagMapMarkers[0];
DrawHelpers.DrawMapMarker(new MarkerInfo
{
Position = new Vector2(flagMarker.XFloat, flagMarker.YFloat) * Scale * DrawHelpers.GetMapScaleFactor() + DrawHelpers.GetCombinedOffsetVector() * Scale,
IconId = flagMarker.MapMarker.IconId,
Offset = DrawPosition,
Scale = Scale,
});
}
}
}
+275
View File
@@ -0,0 +1,275 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Dalamud.Bindings.ImGui;
using Dalamud.Hooking;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Utility;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiLib.Classes;
using TerraFX.Interop.DirectX;
namespace Mappy.MapRenderer;
public unsafe partial class MapRenderer
{
private delegate void ImmediateContextProcessCommands(ImmediateContext* commands, RenderCommandBufferGroup* bufferGroup, uint a3);
[Signature("E8 ?? ?? ?? ?? 48 8B 4B 30 FF 15 ?? ?? ?? ??", DetourName = nameof(OnImmediateContextProcessCommands))]
private readonly Hook<ImmediateContextProcessCommands>? immediateContextProcessCommandsHook = null;
private bool requestUpdatedMaskingTexture;
private byte[]? maskingTextureBytes;
private byte[]? blockyFogBytes;
private IDalamudTextureWrap? fogTexture;
private int lastKnownDiscoveryFlags;
private readonly Stopwatch textureLoadStopwatch = new();
private static int CurrentDiscoveryFlags => AtkStage.Instance()->GetNumberArrayData(NumberArrayType.AreaMap2)->IntArray[2];
private void LoadFogHooks()
{
Service.Hooker.InitializeFromAttributes(this);
immediateContextProcessCommandsHook?.Enable();
}
private void UnloadFogHooks()
{
immediateContextProcessCommandsHook?.Dispose();
}
private void OnImmediateContextProcessCommands(ImmediateContext* commands, RenderCommandBufferGroup* bufferGroup, uint a3) =>
HookSafety.ExecuteSafe(() =>
{
// Delay by a certain number of frames because the game hasn't loaded the new texture yet.
if (requestUpdatedMaskingTexture && textureLoadStopwatch is { IsRunning: true, ElapsedMilliseconds: > 200 }) {
maskingTextureBytes = null;
maskingTextureBytes = GetPrebakedTextureBytes();
requestUpdatedMaskingTexture = false;
textureLoadStopwatch.Stop();
Task.Run(LoadFogTexture);
}
immediateContextProcessCommandsHook!.Original(commands, bufferGroup, a3);
}, Service.Log, "Exception during OnImmediateContextProcessCommands");
private void DrawFogOfWar()
{
if (!System.SystemConfig.ShowFogOfWar) return;
if (CurrentDiscoveryFlags == AgentMap.Instance()->SelectedMapDiscoveryFlag) return;
if (CurrentDiscoveryFlags == -1) return;
var flagsChanged = lastKnownDiscoveryFlags != CurrentDiscoveryFlags;
lastKnownDiscoveryFlags = CurrentDiscoveryFlags;
if (flagsChanged) {
Service.Log.Debug("[Fog of War] Discovery Bits Changed, updating fog texture.");
requestUpdatedMaskingTexture = true;
textureLoadStopwatch.Restart();
fogTexture = null;
}
if (fogTexture is not null) {
ImGui.SetCursorPos(DrawPosition);
ImGui.Image(fogTexture.Handle, fogTexture.Size * Scale);
}
else {
var defaultBackgroundTexture = Service.TextureProvider.GetFromGame($"{AgentMap.Instance()->SelectedMapBgPath.ToString()}.tex").GetWrapOrEmpty();
ImGui.SetCursorPos(DrawPosition);
ImGui.Image(defaultBackgroundTexture.Handle, defaultBackgroundTexture.Size * Scale);
}
}
private void DrawFogOfWarAt(Vector2 drawPosition, float scale)
{
if (!System.SystemConfig.ShowFogOfWar) return;
if (CurrentDiscoveryFlags == AgentMap.Instance()->SelectedMapDiscoveryFlag) return;
if (CurrentDiscoveryFlags == -1) return;
var flagsChanged = lastKnownDiscoveryFlags != CurrentDiscoveryFlags;
lastKnownDiscoveryFlags = CurrentDiscoveryFlags;
if (flagsChanged) {
Service.Log.Debug("[Fog of War] Discovery Bits Changed, updating fog texture.");
requestUpdatedMaskingTexture = true;
textureLoadStopwatch.Restart();
fogTexture = null;
}
if (fogTexture is not null) {
ImGui.SetCursorPos(drawPosition);
ImGui.Image(fogTexture.Handle, fogTexture.Size * scale);
}
else {
var defaultBackgroundTexture = Service.TextureProvider.GetFromGame($"{AgentMap.Instance()->SelectedMapBgPath.ToString()}.tex").GetWrapOrEmpty();
ImGui.SetCursorPos(drawPosition);
ImGui.Image(defaultBackgroundTexture.Handle, defaultBackgroundTexture.Size * scale);
}
}
private void LoadFogTexture()
{
var vanillaBgPath = $"{AgentMap.Instance()->SelectedMapBgPath.ToString()}.tex";
var bgFile = GetTexFile(vanillaBgPath);
if (bgFile is null) {
Service.Log.Warning("Failed to load map textures");
return;
}
// Load non-transparent background texture
var backgroundBytes = bgFile.GetRgbaImageData();
// Load alpha mapping
if (maskingTextureBytes is null) return;
var timer = Stopwatch.StartNew();
// Make background texture fully invisible
for (var index = 0; index < 2048 * 2048; index++) {
backgroundBytes[index * 4 + 3] = 0;
}
// Make non-transparent any section that the player has not-already explored
for (var x = 0; x < 128; x++)
for (var y = 0; y < 128; y++) {
var pixelIndex = (x + y * 128) * 4;
var targetPixel = (x + 2048 * y) * 4;
var redAmount = maskingTextureBytes[pixelIndex + 0] / 255.0f;
var greenAmount = maskingTextureBytes[pixelIndex + 1] / 255.0f;
var blueAmount = maskingTextureBytes[pixelIndex + 2] / 255.0f;
var maxAlpha = Math.Max(redAmount, Math.Max(greenAmount, blueAmount));
var alphaSum = (byte)(maxAlpha * 255);
if (alphaSum is not 0) {
const int scaleFactor = 16;
foreach (var xScalar in Enumerable.Range(0, scaleFactor))
foreach (var yScalar in Enumerable.Range(0, scaleFactor)) {
var scalingPixelTarget = targetPixel * scaleFactor + xScalar * 4 + yScalar * 2048 * 4;
backgroundBytes[scalingPixelTarget + 3] = alphaSum;
}
}
}
Service.Log.Debug($"Fog of War Calculated in {timer.ElapsedMilliseconds} ms");
blockyFogBytes = backgroundBytes;
fogTexture = Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(2048, 2048), backgroundBytes);
Task.Run(CleanupFogTexture);
}
private static byte[]? GetPrebakedTextureBytes()
{
var addon = Service.GameGui.GetAddonByName<AddonAreaMap>("AreaMap");
if (addon is null) return null;
var componentMap = (void*)Marshal.ReadIntPtr((nint)addon, 0x430);
if (componentMap is null) return null;
var texturePointer = (Texture*)Marshal.ReadIntPtr((nint)componentMap, 0x270);
if (texturePointer is null) return null;
var device = (ID3D11Device*)Service.PluginInterface.UiBuilder.DeviceHandle;
var texture = (ID3D11Texture2D*)texturePointer->D3D11Texture2D;
D3D11_TEXTURE2D_DESC description;
texture->GetDesc(&description);
description.ArraySize = 1;
description.BindFlags = 0;
description.MipLevels = 1;
description.MiscFlags = 0;
description.CPUAccessFlags = (uint)D3D11_CPU_ACCESS_FLAG.D3D11_CPU_ACCESS_READ;
description.Usage = D3D11_USAGE.D3D11_USAGE_STAGING;
ID3D11Texture2D* stagingTexture;
if (device->CreateTexture2D(&description, null, &stagingTexture)< 0)
return null;
ID3D11DeviceContext* context;
device->GetImmediateContext(&context);
context->CopyResource((ID3D11Resource*)stagingTexture, (ID3D11Resource*)texture);
D3D11_MAPPED_SUBRESOURCE mapped;
if (context->Map((ID3D11Resource*)stagingTexture, 0, D3D11_MAP.D3D11_MAP_READ, 0, &mapped) < 0)
{
context->Release();
stagingTexture->Release();
return null;
}
int bufferSize = (int)(description.Height * mapped.RowPitch);
byte[] pixelData = new byte[bufferSize];
Marshal.Copy((IntPtr)mapped.pData, pixelData, 0, bufferSize);
context->Unmap((ID3D11Resource*)stagingTexture, 0);
context->Release();
stagingTexture->Release();
return pixelData;
}
private void CleanupFogTexture()
{
if (blockyFogBytes is null) return;
var timer = Stopwatch.StartNew();
// Because we had to scale a 128x128 texture mapping onto a 2048x2048, it'll look very blurry, lets blend the alpha channel
const int blurRadius = 8;
for (var x = 0; x < 2048; x++)
for (var y = 0; y < 2048; y++) {
var pixelIndex = (x + y * 2048) * 4;
var alphaAverage = 0.0f;
var numAveraged = 0;
if (blockyFogBytes[pixelIndex + 3] == 255) continue;
for (var xBlur = -blurRadius; xBlur < -blurRadius + blurRadius * 2; ++xBlur) {
var currentX = x + xBlur;
if (currentX is < 0 or >= 2048) continue;
var currentPixelIndex = (currentX + y * 2048) * 4;
alphaAverage += blockyFogBytes[currentPixelIndex + 3];
numAveraged++;
}
for (var yBlur = -blurRadius; yBlur < -blurRadius + blurRadius * 2; ++yBlur) {
var currentY = y + yBlur;
if (currentY is < 0 or >= 2048) continue;
var currentPixelIndex = (x + currentY * 2048) * 4;
alphaAverage += blockyFogBytes[currentPixelIndex + 3];
numAveraged++;
}
var newAlpha = (byte)(alphaAverage / numAveraged);
blockyFogBytes[pixelIndex + 3] = newAlpha;
}
fogTexture = Service.TextureProvider.CreateFromRaw(RawImageSpecification.Rgba32(2048, 2048), blockyFogBytes);
Service.Log.Debug($"Texture Cleanup completed in {timer.ElapsedMilliseconds} ms");
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Linq;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using FFXIVClientStructs.FFXIV.Client.Game.Event;
using FFXIVClientStructs.FFXIV.Client.Game.Group;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using KamiLib.Extensions;
using Lumina.Excel.Sheets;
using Mappy.Classes;
using Mappy.Extensions;
using BattleNpcSubKind = Dalamud.Game.ClientState.Objects.Enums.BattleNpcSubKind;
using ObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private unsafe void DrawGameObjects()
{
if (AgentMap.Instance()->SelectedMapId != AgentMap.Instance()->CurrentMapId) return;
if (Service.ObjectTable is not { LocalPlayer: { } player }) return;
if (System.SystemConfig.ShowRadar) {
if ((Service.Condition.IsBoundByDuty() && System.SystemConfig.ShowRadarInDuties) || !Service.Condition.IsBoundByDuty()) {
DrawRadar(player);
}
}
foreach (var obj in Service.ObjectTable.Reverse()) {
if (!obj.IsTargetable) continue;
if (GroupManager.Instance()->MainGroup.IsEntityIdInParty(obj.EntityId)) continue;
if (GroupManager.Instance()->MainGroup.IsEntityIdInAlliance(obj.EntityId)) continue;
if (Vector3.Distance(obj.Position, player.Position) >= 150.0f) continue;
DrawHelpers.DrawMapMarker(new MarkerInfo
{
Position = (obj.GetMapPosition() -
DrawHelpers.GetMapOffsetVector() +
DrawHelpers.GetMapCenterOffsetVector()) * Scale,
Offset = DrawPosition,
Scale = Scale,
IconId = obj.ObjectKind switch
{
ObjectKind.Player when GroupManager.Instance()->MainGroup.MemberCount is 0 && System.SystemConfig.ShowPlayers => 60421,
ObjectKind.Player when System.SystemConfig.ShowPlayers => 60444,
ObjectKind.BattleNpc when IsBoss(obj) && obj.TargetObject is null => 60402,
ObjectKind.BattleNpc when IsBoss(obj) && obj.TargetObject is not null => 60401,
ObjectKind.BattleNpc when obj is { SubKind: (int)BattleNpcSubKind.Enemy, TargetObject: not null } => 60422,
ObjectKind.BattleNpc when obj is { SubKind: (int)BattleNpcSubKind.Enemy, TargetObject: null } => 60424,
ObjectKind.BattleNpc when obj.SubKind == (int)BattleNpcSubKind.Pet => 60961,
ObjectKind.Treasure => 60003,
ObjectKind.GatheringPoint => System.GatheringPointIconCache.GetValue(obj.BaseId),
ObjectKind.EventObj when IsAetherCurrent(obj) => 60653,
_ => 0
},
PrimaryText = () => GetTooltipForGameObject(obj),
});
}
}
private void DrawRadar(IPlayerCharacter gameObjectCenter)
{
var position = ImGui.GetWindowPos() +
DrawPosition +
(gameObjectCenter.GetMapPosition() -
DrawHelpers.GetMapOffsetVector() +
DrawHelpers.GetMapCenterOffsetVector()) * Scale;
ImGui.GetWindowDrawList().AddCircleFilled(position, 150.0f * Scale, ImGui.GetColorU32(System.SystemConfig.RadarColor));
ImGui.GetWindowDrawList().AddCircle(position, 150.0f * Scale, ImGui.GetColorU32(System.SystemConfig.RadarOutlineColor));
}
private string GetTooltipForGameObject(IGameObject obj)
{
return obj switch
{
IBattleNpc { Level: > 0 } battleNpc => $"Lv. {battleNpc.Level} {battleNpc.Name}",
IPlayerCharacter { Level: > 0 } playerCharacter => $"Lv. {playerCharacter.Level} {playerCharacter.Name}",
_ => obj.ObjectKind switch
{
ObjectKind.GatheringPoint => System.GatheringPointNameCache.GetValue((obj.BaseId, obj.Name.ToString())) ?? string.Empty,
ObjectKind.Treasure => obj.Name.ToString(),
ObjectKind.EventObj when IsAetherCurrent(obj) => obj.Name.ToString(),
_ => string.Empty
},
};
}
private unsafe bool IsAetherCurrent(IGameObject gameObject)
{
if (gameObject.ObjectKind is not ObjectKind.EventObj) return false;
var csEventObject = (GameObject*)gameObject.Address;
if (csEventObject is null) return false;
if (csEventObject->EventHandler is null) return false;
return csEventObject->EventHandler->Info.EventId.ContentId == EventHandlerContent.AetherCurrent;
}
private bool IsBoss(IGameObject chara) => Service.DataManager.GetExcelSheet<BNpcBase>().GetRow(chara.BaseId).Rank is 2 or 6;
}
@@ -0,0 +1,14 @@
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Mappy.Extensions;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private unsafe void DrawGatheringMarkers()
{
foreach (var marker in AgentMap.Instance()->MiniMapGatheringMarkers) {
marker.Draw(DrawPosition, Scale);
}
}
}
@@ -0,0 +1,44 @@
using System.Numerics;
using FFXIVClientStructs.FFXIV.Client.Game.Group;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Mappy.Classes;
namespace Mappy.MapRenderer;
public unsafe partial class MapRenderer
{
private void DrawGroupMembers()
{
foreach (var partyMember in GroupManager.Instance()->MainGroup.PartyMembers[..GroupManager.Instance()->MainGroup.MemberCount]) {
if (partyMember.EntityId is 0xE0000000) continue;
if (partyMember.TerritoryType != AgentMap.Instance()->SelectedTerritoryId) continue;
DrawHelpers.DrawMapMarker(new MarkerInfo
{
Position = (new Vector2(partyMember.Position.X, partyMember.Position.Z) * DrawHelpers.GetMapScaleFactor() -
DrawHelpers.GetMapOffsetVector() +
DrawHelpers.GetMapCenterOffsetVector()) * Scale,
Offset = DrawPosition,
Scale = Scale,
IconId = 60421,
PrimaryText = () => $"Lv. {partyMember.Level} {partyMember.NameString}",
});
}
foreach (var allianceMember in GroupManager.Instance()->MainGroup.AllianceMembers) {
if (allianceMember.EntityId is 0xE0000000) continue;
if (AgentMap.Instance()->SelectedMapId != AgentMap.Instance()->CurrentMapId) continue;
DrawHelpers.DrawMapMarker(new MarkerInfo
{
Position = (new Vector2(allianceMember.Position.X, allianceMember.Position.Z) * DrawHelpers.GetMapScaleFactor() -
DrawHelpers.GetMapOffsetVector() +
DrawHelpers.GetMapCenterOffsetVector()) * Scale,
Offset = DrawPosition,
Scale = Scale,
IconId = 60403,
PrimaryText = () => $"Lv. {allianceMember.Level} {allianceMember.NameString}",
});
}
}
}
@@ -0,0 +1,806 @@
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;
/// <summary>
/// Draws map markers (POI, FATEs, quests, gathering, flag, etc.) on the minimap using the same data sources as the main map.
/// </summary>
public partial class MapRenderer
{
private const float MinimapIconSize = 28f;
private const float MinimapIconScaleFromConfig = 1.0f;
/// <summary>
/// Draw all marker layers on the minimap. Call after the map texture and player are drawn.
/// </summary>
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);
}
/// <summary>Max number of quest/objective direction arrows to show on the minimap (to avoid clutter).</summary>
private const int MaximapQuestArrowLimit = 8;
/// <summary>Same orange as the quest direction arrow, for the minimap quest radius circle (more transparent).</summary>
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);
/// <summary>
/// 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.
/// </summary>
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++;
}
}
/// <summary>Collects all quest/flag direction targets in the current zone (flag, temp markers, cached temps, journal objectives).</summary>
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<TempMapMarker>(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;
}
/// <summary>Get texture positions and tooltips for all quests in the journal that have an objective on the given map.</summary>
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<Quest>();
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;
}
/// <summary>Draws direction arrows for nearby FATEs at the edge of the minimap (purple). Only shown when the FATE is off the minimap.</summary>
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;
}
/// <summary>If the mouse is over the quest radius circle, show the quest tooltip.</summary>
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);
}
/// <summary>Large margin so markers aren't culled when panned.</summary>
private const float MinimapBoundsMargin = 200f;
/// <summary>Cached static POI (aetheryte, repair, moogle, etc.) per map so we can draw them on the minimap when the Area Map is closed.</summary>
private static readonly Dictionary<uint, List<CachedStaticMarker>> StaticMarkerCache = new();
/// <summary>Cached quest/objective (temp) markers per map; populated during silent refresh on quest accept/turn-in/objective update.</summary>
private static readonly Dictionary<uint, List<CachedTempMarker>> TempMarkerCache = new();
/// <summary>Cached non-FATE event markers per map; populated during silent refresh.</summary>
private static readonly Dictionary<uint, List<CachedEventMarker>> 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<float, float, Vector2> 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<CachedStaticMarker>();
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<float, float, Vector2> 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<CachedEventMarker>();
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);
}
}
}
/// <summary>Draw FATE markers from FateManager so they update without opening the Area Map.</summary>
private unsafe void DrawMinimapFatesFromFateManager(Vector2 contentTopLeft, Func<float, float, Vector2> 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<FateContext> 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<Quest>()?.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<float, float, Vector2> 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);
}
}
/// <summary>Draw party and alliance members on the minimap. When a member is off the minimap circle, draw a faded marker at the rim.</summary>
private unsafe void DrawMinimapGroupMembers(Vector2 contentTopLeft, Func<float, float, Vector2> 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<float, float, Vector2> 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<float, float, Vector2> 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<TempMapMarker>(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<CachedTempMarker>();
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<float, float, Vector2> texToContent, Vector2 size, float scaleFactor, float offsetX, float offsetY)
{
var agent = AgentMap.Instance();
if (agent->CurrentMapId != agent->SelectedMapId) return;
var fieldMarkersSheet = Service.DataManager.GetExcelSheet<FieldMarkerSheet>().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
}
}
}
+170
View File
@@ -0,0 +1,170 @@
using System;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Arrays;
using Mappy.Classes;
using Mappy.Extensions;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private unsafe void DrawPlayer()
{
if (AgentMap.Instance()->SelectedMapId != AgentMap.Instance()->CurrentMapId) return;
if (Service.ObjectTable.LocalPlayer is { } localPlayer) {
var position = ImGui.GetWindowPos() +
DrawPosition +
(localPlayer.GetMapPosition() -
DrawHelpers.GetMapOffsetVector() +
DrawHelpers.GetMapCenterOffsetVector()) * Scale;
DrawLookLine(position);
DrawPlayerIcon(position);
}
}
private void DrawLookLine(Vector2 position)
{
var angle = GetCameraRotation();
var lineLength = System.SystemConfig.ConeSize * (System.SystemConfig.ScalePlayerCone ? 1.0f : Scale);
var halfConeAngle = DegreesToRadians(90.0f) / 2.0f;
DrawAngledLineFromCenter(position, lineLength, angle - halfConeAngle);
DrawAngledLineFromCenter(position, lineLength, angle + halfConeAngle);
DrawLineArcFromCenter(position, lineLength, angle);
DrawFilledSemiCircle(position, lineLength, angle);
}
private static void DrawAngledLineFromCenter(Vector2 center, float lineLength, float angle, Vector4? outlineColor = null)
{
var lineSegment = new Vector2(lineLength * MathF.Cos(angle), lineLength * MathF.Sin(angle));
var color = outlineColor ?? System.SystemConfig.PlayerConeOutlineColor;
ImGui.GetWindowDrawList().AddLine(center, center + lineSegment, ImGui.GetColorU32(color), 3.0f);
}
private static void DrawLineArcFromCenter(Vector2 center, float distance, float rotation, Vector4? outlineColor = null)
{
var halfConeAngle = DegreesToRadians(90.0f) / 2.0f;
var color = outlineColor ?? System.SystemConfig.PlayerConeOutlineColor;
var start = rotation - halfConeAngle;
var stop = rotation + halfConeAngle;
ImGui.GetWindowDrawList().PathArcTo(center, distance, start, stop);
ImGui.GetWindowDrawList().PathStroke(ImGui.GetColorU32(color), ImDrawFlags.None, 3.0f);
}
private static void DrawFilledSemiCircle(Vector2 center, float distance, float rotation)
{
var halfConeAngle = DegreesToRadians(90.0f) / 2.0f;
var coneColor = ImGui.GetColorU32(System.SystemConfig.PlayerConeColor);
var startAngle = rotation - halfConeAngle;
var stopAngle = rotation + halfConeAngle;
var startPosition = new Vector2(distance * MathF.Cos(rotation - halfConeAngle), distance * MathF.Sin(rotation - halfConeAngle));
ImGui.GetWindowDrawList().PathArcTo(center, distance, startAngle, stopAngle);
ImGui.GetWindowDrawList().PathLineTo(center);
ImGui.GetWindowDrawList().PathLineTo(center + startPosition);
ImGui.GetWindowDrawList().PathFillConvex(coneColor);
}
private static unsafe float GetCameraRotation() => -DegreesToRadians(AreaMapNumberArray.Instance()->ConeRotation) - 0.5f * MathF.PI;
private static float DegreesToRadians(float degrees) => MathF.PI / 180.0f * degrees;
private void DrawPlayerIcon(Vector2 position)
{
if (!System.SystemConfig.ShowPlayerIcon) return;
if (Service.ObjectTable is not { LocalPlayer: { } player }) return;
var texture = Service.TextureProvider.GetFromGameIcon(60443).GetWrapOrEmpty();
var angle = -player.Rotation + MathF.PI / 2.0f;
var scale = System.SystemConfig.ScaleWithZoom ? Scale : 1.0f;
scale *= System.SystemConfig.PlayerIconScale;
var vectors = GetRotationVectors(angle, position, texture.Size / 2.0f * scale);
ImGui.GetWindowDrawList().AddImageQuad(texture.Handle, vectors[0], vectors[1], vectors[2], vectors[3]);
}
/// <summary>
/// Draw only the minimap player cone (direction indicator). Call before DrawMinimapMarkers so markers draw on top of the cone.
/// </summary>
private void DrawMinimapConeAtCenter(Vector2 centerPos, float mapScale)
{
if (!System.SystemConfig.MinimapShowPlayerCone) return;
var angle = GetCameraRotation();
var lineLength = System.SystemConfig.ConeSize * 0.5f;
var halfConeAngle = DegreesToRadians(90.0f) / 2.0f;
DrawMinimapConeGradient(centerPos, lineLength, angle - halfConeAngle, angle + halfConeAngle);
var softWhite = new Vector4(1f, 1f, 1f, 0.2f);
DrawAngledLineFromCenter(centerPos, lineLength, angle - halfConeAngle, softWhite);
DrawAngledLineFromCenter(centerPos, lineLength, angle + halfConeAngle, softWhite);
DrawLineArcFromCenter(centerPos, lineLength, angle, softWhite);
}
/// <summary>
/// Draw player icon at center (for minimap). Cone is drawn earlier so markers can be drawn on top of it.
/// </summary>
private void DrawPlayerAtCenter(Vector2 centerPos, float mapScale)
{
if (Service.ObjectTable.LocalPlayer is not { } localPlayer) return;
if (!System.SystemConfig.ShowPlayerIcon) return;
var texture = Service.TextureProvider.GetFromGameIcon(60443).GetWrapOrEmpty();
var angle = -localPlayer.Rotation + MathF.PI / 2.0f;
var iconScale = System.SystemConfig.PlayerIconScale * 1.5f; // 1.5x for minimap visibility
var vectors = GetRotationVectors(angle, centerPos, texture.Size / 2.0f * iconScale);
ImGui.GetWindowDrawList().AddImageQuad(texture.Handle, vectors[0], vectors[1], vectors[2], vectors[3]);
}
/// <summary>Draw minimap cone as white light with radial gradient (bright at center, fading at edge). Kept quite transparent so markers underneath remain visible.</summary>
private static void DrawMinimapConeGradient(Vector2 center, float radius, float startAngle, float endAngle)
{
const int segments = 24;
const float maxAlpha = 0.18f;
var drawList = ImGui.GetWindowDrawList();
for (var j = segments - 1; j >= 0; j--) {
var rInner = radius * j / segments;
var rOuter = radius * (j + 1) / segments;
var t = (j + 0.5f) / segments;
var alpha = maxAlpha * (1f - t);
if (alpha <= 0f) continue;
var color = ImGui.GetColorU32(new Vector4(1f, 1f, 1f, alpha));
var outerStart = center + new Vector2(rOuter * MathF.Cos(startAngle), rOuter * MathF.Sin(startAngle));
var innerEnd = center + new Vector2(rInner * MathF.Cos(endAngle), rInner * MathF.Sin(endAngle));
drawList.PathClear();
drawList.PathLineTo(center);
drawList.PathLineTo(outerStart);
drawList.PathArcTo(center, rOuter, startAngle, endAngle);
drawList.PathLineTo(innerEnd);
drawList.PathArcTo(center, rInner, endAngle, startAngle);
drawList.PathFillConvex(color);
}
}
private static Vector2[] GetRotationVectors(float angle, Vector2 center, Vector2 size)
{
var cosA = MathF.Cos(angle + 0.5f * MathF.PI);
var sinA = MathF.Sin(angle + 0.5f * MathF.PI);
Vector2[] vectors =
[
center + ImRotate(new Vector2(-size.X * 0.5f, -size.Y * 0.5f), cosA, sinA),
center + ImRotate(new Vector2(+size.X * 0.5f, -size.Y * 0.5f), cosA, sinA),
center + ImRotate(new Vector2(+size.X * 0.5f, +size.Y * 0.5f), cosA, sinA),
center + ImRotate(new Vector2(-size.X * 0.5f, +size.Y * 0.5f), cosA, sinA),
];
return vectors;
}
private static Vector2 ImRotate(Vector2 v, float cosA, float sinA) => new(v.X * cosA - v.Y * sinA, v.X * sinA + v.Y * cosA);
}
+32
View File
@@ -0,0 +1,32 @@
using System.Linq;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Mappy.Extensions;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private unsafe void DrawStaticMapMarkers()
{
foreach (var index in Enumerable.Range(0, AgentMap.Instance()->MapMarkerCount)) {
ref var marker = ref AgentMap.Instance()->MapMarkers[index];
if (marker.MapMarker.IconId is 0) continue;
marker.Draw(DrawPosition, Scale);
}
}
private unsafe void DrawStaticTextMarkers()
{
foreach (var index in Enumerable.Range(0, AgentMap.Instance()->MapMarkerCount)) {
ref var marker = ref AgentMap.Instance()->MapMarkers[index];
if (marker.MapMarker.IconId is not 0) continue;
if (marker.MapMarker.Index is 0) continue;
if (marker.MapMarker.SubtextOrientation is 0) continue;
marker.Draw(DrawPosition, Scale);
}
}
}
@@ -0,0 +1,45 @@
using System;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using KamiLib.Extensions;
using Mappy.Classes;
using Mappy.Extensions;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private unsafe void DrawTemporaryMarkers()
{
if (AgentMap.Instance()->SelectedMapSub != AgentMap.Instance()->SelectedMapId) return;
// Group together icons based on their dataId, this is because square enix shows circles then draws the actual icon overtop
var validMarkers = new Span<TempMapMarker>(Unsafe.AsPointer(ref AgentMap.Instance()->TempMapMarkers[0]), AgentMap.Instance()->TempMapMarkerCount);
var iconGroups = validMarkers.ToArray().GroupBy(markers => new Vector2(markers.MapMarker.X, markers.MapMarker.Y));
foreach (var group in iconGroups) {
// Make a copy of the first marker in the set, we will be mutating this copy.
var markerCopy = group.First();
// Get the actual iconId we want, typically the icon for the marker, not the circle
var correctIconId = group.FirstOrNull(marker => marker.MapMarker.IconId is not (60493 or 0));
markerCopy.MapMarker.IconId = correctIconId?.MapMarker.IconId ?? markerCopy.MapMarker.IconId;
// Special handling for WKS Markers (in which both icon ids are 0)
if (markerCopy.MapMarker.IconId == 0 && group.Count() == 2)
{
if (markerCopy.Type == 4 && group.Last() is { Type: 6, MapMarker.IconId: 0 })
{
markerCopy.MapMarker.IconId = DrawHelpers.QuestionMarkIcon;
}
}
// Get the actual radius value for this marker, typically the circle icon will have this value.
markerCopy.MapMarker.Scale = group.Max(marker => marker.MapMarker.Scale);
markerCopy.Draw(DrawPosition, Scale);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using Mappy.Classes;
using FieldMarker = Lumina.Excel.Sheets.FieldMarker;
using MarkerInfo = Mappy.Classes.MarkerInfo;
namespace Mappy.MapRenderer;
public partial class MapRenderer
{
private readonly List<FieldMarker> fieldMarkers = Service.DataManager.GetExcelSheet<FieldMarker>().Where(marker => marker.MapIcon is not 0).ToList();
private unsafe void DrawFieldMarkers()
{
if (AgentMap.Instance()->CurrentMapId != AgentMap.Instance()->SelectedMapId) return;
var markerSpan = MarkingController.Instance()->FieldMarkers;
foreach (var index in Enumerable.Range(0, 8)) {
if (markerSpan[index] is { Active: true } marker) {
var markerPosition =
new Vector2(marker.X, marker.Z) / 1000.0f * DrawHelpers.GetMapScaleFactor()
- DrawHelpers.GetMapOffsetVector()
+ DrawHelpers.GetMapCenterOffsetVector();
DrawHelpers.DrawMapMarker(new MarkerInfo
{
Offset = DrawPosition,
Scale = Scale,
Position = markerPosition * Scale,
IconId = fieldMarkers[index].MapIcon,
});
}
}
}
}