Initial HSMappy release (fork of Mappy)
Made-with: Cursor
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user