Files
HSMappy/Mappy/MapRenderer/MapRenderer.Core.cs
T

398 lines
16 KiB
C#

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>
/// Returns the map transform (offsetX, offsetY, sizeFactor) for the current minimap map, for coordinate display.
/// Returns null if no cache exists for CurrentMapId.
/// </summary>
public (int offsetX, int offsetY, uint sizeFactor)? GetCurrentMinimapTransform()
{
var agent = AgentMap.Instance();
var currentMapId = agent->CurrentMapId;
if (currentMapId == 0 || !_minimapCache.TryGetValue(currentMapId, out var entry))
return null;
var sizeFactor = (uint)Math.Round(entry.ScaleFactor * 100f);
if (sizeFactor == 0) sizeFactor = 100;
return ((int)entry.OffsetX, (int)entry.OffsetY, sizeFactor);
}
/// <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 && agent->SelectedMapBgPath.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);
if (System.SystemConfig.MinimapShowFlagDirectionArrow)
DrawMinimapFlagDirectionArrow(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)
{
if (string.IsNullOrWhiteSpace(rawPath)) return null;
string path;
try {
path = Service.TextureSubstitutionProvider.GetSubstitutedPath(rawPath);
} catch {
return null;
}
if (string.IsNullOrWhiteSpace(path)) return null;
try {
if (Path.IsPathRooted(path)) {
return Service.DataManager.GameData.GetFileFromDisk<TexFile>(path);
}
return Service.DataManager.GetFile<TexFile>(path);
} catch {
return null;
}
}
private void DrawMapMarkers()
{
DrawStaticMapMarkers();
DrawDynamicMarkers();
DrawGameObjects();
DrawGroupMembers();
DrawTemporaryMarkers();
DrawGatheringMarkers();
DrawFieldMarkers();
DrawPlayer();
DrawStaticTextMarkers();
DrawMapNotes();
DrawMovementTrail();
DrawFlag();
}
}