Minecraft-Console-Client/MinecraftClient/Tui/MinimapControl.cs
2026-03-31 01:28:24 +08:00

1267 lines
48 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using MinecraftClient.Mapping;
namespace MinecraftClient.Tui
{
public enum CaveModeOption { auto, on, off }
/// <summary>
/// TUI minimap control rendered as a grid of TextBlocks using half-block characters.
/// Zoom is expressed as blocks-per-pixel (1 = 1:1, 16 = 16 blocks per pixel).
/// Entity names are drawn directly on the map below their icon.
/// </summary>
public class MinimapControl : UserControl
{
public const int MinZoom = 1;
public const int MaxZoom = 16;
public const int DefaultZoom = 2;
public const int DefaultWidth = 40;
public const int DefaultHeight = 40;
public const int DefaultRefreshMs = 1000;
public const int MinRefreshMs = 100;
public const int MaxRefreshMs = 5000;
private int _mapWidth;
private int _mapHeight;
private int _cellRows;
private int _blocksPerPixel = DefaultZoom;
private volatile bool _sampling;
private CancellationTokenSource? _cts;
private readonly NameDisplayConfig _nameConfig = new();
private TextBlock[,] _cells;
private readonly StackPanel _infoRow;
private readonly StackPanel _legendPanel;
private readonly Grid _mapGrid;
private readonly DispatcherTimer _timer;
private SampleResult? _lastResult;
private int _hoverCol = -1;
private int _hoverRow = -1;
private double _hoverGlobalX;
private double _hoverGlobalY;
public int BlocksPerPixel
{
get => _blocksPerPixel;
set => _blocksPerPixel = Math.Clamp(value, MinZoom, MaxZoom);
}
public NameDisplayConfig NameConfig => _nameConfig;
public TuiTooltipService? TooltipService { get; set; }
public MinimapPosition Position { get; set; } = MinimapPosition.top_right;
public CaveModeOption CaveMode { get; set; } = CaveModeOption.auto;
public int MapPixelWidth => _mapWidth;
public int MapPixelHeight => _mapHeight;
public int RefreshIntervalMs
{
get => (int)_timer.Interval.TotalMilliseconds;
set => _timer.Interval = TimeSpan.FromMilliseconds(Math.Clamp(value, MinRefreshMs, MaxRefreshMs));
}
public MinimapControl() : this(DefaultWidth, DefaultHeight) { }
public MinimapControl(int width, int height)
{
_mapWidth = Math.Max(10, width);
_mapHeight = Math.Max(4, height % 2 == 0 ? height : height + 1);
_cellRows = _mapHeight / 2;
_mapGrid = new Grid();
_cells = BuildGrid(_mapGrid, _cellRows, _mapWidth);
_infoRow = new StackPanel { Orientation = Orientation.Horizontal };
_legendPanel = new StackPanel { Orientation = Orientation.Horizontal };
var root = new StackPanel
{
Orientation = Orientation.Vertical,
Children = { _mapGrid, _infoRow, _legendPanel },
};
Content = root;
_mapGrid.PointerMoved += OnMapPointerMoved;
_mapGrid.PointerExited += OnMapPointerExited;
_timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(DefaultRefreshMs),
};
_timer.Tick += (_, _) => RequestSample();
}
public void Resize(int width, int height)
{
_mapWidth = Math.Max(10, width);
_mapHeight = Math.Max(4, height % 2 == 0 ? height : height + 1);
_cellRows = _mapHeight / 2;
_mapGrid.Children.Clear();
_mapGrid.RowDefinitions.Clear();
_mapGrid.ColumnDefinitions.Clear();
_cells = BuildGrid(_mapGrid, _cellRows, _mapWidth);
}
private static TextBlock[,] BuildGrid(Grid grid, int rows, int cols)
{
var cells = new TextBlock[rows, cols];
for (int r = 0; r < rows; r++)
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
for (int c = 0; c < cols; c++)
grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < cols; c++)
{
var tb = new TextBlock
{
Text = "\u2580",
Foreground = Brushes.Black,
Background = Brushes.Black,
Padding = new Thickness(0),
Margin = new Thickness(0),
FontSize = 1,
};
Grid.SetRow(tb, r);
Grid.SetColumn(tb, c);
grid.Children.Add(tb);
cells[r, c] = tb;
}
}
return cells;
}
public void Start()
{
_cts = new CancellationTokenSource();
_timer.Start();
RequestSample();
}
public void Stop()
{
_timer.Stop();
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
private void RequestSample()
{
if (_sampling) return;
if (McClient.Instance is not McClient client) return;
if (!client.GetTerrainEnabled()) return;
_sampling = true;
var ct = _cts?.Token ?? CancellationToken.None;
int bpp = _blocksPerPixel;
int w = _mapWidth;
int h = _mapHeight;
bool showPlayers = _nameConfig.Players;
bool showHostile = _nameConfig.Hostile;
bool showNeutral = _nameConfig.Neutral;
bool showPassive = _nameConfig.Passive;
var caveOpt = CaveMode;
Task.Run(() =>
{
try
{
var result = SampleTerrain(client, bpp, w, h,
showPlayers, showHostile, showNeutral, showPassive, caveOpt, ct);
if (ct.IsCancellationRequested) return;
Dispatcher.UIThread.Post(() =>
{
ApplyPixelBuffer(result, w, h);
UpdateInfoBarAndLegend(client, bpp, result.VisibleCategories, w,
result.CaveModeActive);
});
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
ConsoleIO.WriteLineFormatted($"\u00a7e[Minimap] Sample error: {ex.Message}");
}
finally
{
_sampling = false;
}
}, ct);
}
internal sealed class EntityLabel
{
public string Name = "";
public Color LabelColor;
public int PixelX;
public int PixelY;
}
internal sealed class PixelEntityInfo
{
public string Name = "";
public MobCategory Category;
public double X, Y, Z;
public float Health;
public float MaxHealth;
public int Priority;
}
private sealed class SampleResult
{
public Color[,] Pixels = null!;
public (char Ch, Color Fg, Color Bg)?[,] CharOverlay = null!;
public HashSet<MobCategory> VisibleCategories = [];
public int[,] Heights = null!;
public Material[,]? BlockTypes;
public List<(Material Mat, int Count)>?[,]? BlockSummary;
public List<PixelEntityInfo>?[,]? EntityMap;
public int PlayerBlockX;
public int PlayerBlockZ;
public int CenterX;
public int CenterY;
public int Bpp;
public bool CaveModeActive;
}
private static bool ShouldShowNameLocal(MobCategory cat,
bool showPlayers, bool showHostile, bool showNeutral, bool showPassive)
{
return cat switch
{
MobCategory.Player => showPlayers,
MobCategory.Hostile => showHostile,
MobCategory.Neutral => showNeutral,
MobCategory.Passive => showPassive,
_ => false,
};
}
private static SampleResult SampleTerrain(McClient client, int bpp, int mapW, int mapH,
bool showPlayers, bool showHostile, bool showNeutral, bool showPassive,
CaveModeOption caveOpt, CancellationToken ct)
{
var result = new SampleResult
{
Pixels = new Color[mapW, mapH],
CharOverlay = new (char, Color, Color)?[mapW, mapH / 2],
Heights = new int[mapW, mapH],
EntityMap = new List<PixelEntityInfo>?[mapW, mapH],
BlockTypes = bpp == 1 ? new Material[mapW, mapH] : null,
BlockSummary = bpp > 1 ? new List<(Material, int)>?[mapW, mapH] : null,
Bpp = bpp,
};
var world = client.GetWorld();
var playerLoc = client.GetCurrentLocation();
int playerBlockX = (int)Math.Floor(playerLoc.X);
int playerBlockZ = (int)Math.Floor(playerLoc.Z);
int playerBlockY = (int)Math.Floor(playerLoc.Y);
result.PlayerBlockX = playerBlockX;
result.PlayerBlockZ = playerBlockZ;
result.CenterX = mapW / 2;
result.CenterY = mapH / 2;
var dim = World.GetDimension();
int minY = dim.minY;
int scanTop = Math.Min(playerBlockY + 32, dim.maxY - 1);
bool caveMode = ResolveCaveMode(caveOpt, world, dim, playerBlockX, playerBlockY, playerBlockZ, scanTop);
result.CaveModeActive = caveMode;
var entities = client.GetEntityHandlingEnabled()
? client.GetEntities()
: null;
var entityPixels = new Dictionary<(int, int), (Color Color, int Priority)>();
int centerX = mapW / 2;
int centerY = mapH / 2;
var nameLabels = new List<EntityLabel>();
var uuidNameMap = client.GetOnlinePlayersWithUUID();
if (entities is not null)
{
int playerEntityId = client.GetPlayerEntityID();
foreach (var kvp in entities)
{
if (ct.IsCancellationRequested) return result;
var entity = kvp.Value;
var cat = MinimapEntityClassifier.Classify(entity.Type);
if (cat == MobCategory.NonLiving) continue;
if (kvp.Key == playerEntityId) continue;
if (!MinimapEntityClassifier.ShouldDisplay(cat, playerLoc.Y, entity.Location.Y))
continue;
double relX = (entity.Location.X - playerLoc.X) / bpp;
double relZ = (entity.Location.Z - playerLoc.Z) / bpp;
int px = (int)Math.Floor(relX) + centerX;
int py = (int)Math.Floor(relZ) + centerY;
if (px < 0 || px >= mapW || py < 0 || py >= mapH) continue;
var baseColor = MinimapEntityClassifier.GetBaseColor(cat);
Color color;
if (cat == MobCategory.Player)
color = baseColor;
else
color = MinimapEntityClassifier.ApplyDepthFade(baseColor, playerLoc.Y, entity.Location.Y);
int priority = MinimapEntityClassifier.GetPriority(cat);
var key = (px, py);
if (!entityPixels.TryGetValue(key, out var existing) || priority > existing.Priority)
entityPixels[key] = (color, priority);
result.VisibleCategories.Add(cat);
string eName = ResolveEntityName(client, entity, cat, uuidNameMap);
var pixelList = result.EntityMap![px, py] ??= [];
pixelList.Add(new PixelEntityInfo
{
Name = eName,
Category = cat,
X = entity.Location.X,
Y = entity.Location.Y,
Z = entity.Location.Z,
Health = entity.Health,
MaxHealth = -1,
Priority = priority,
});
if (ShouldShowNameLocal(cat, showPlayers, showHostile, showNeutral, showPassive))
{
string name = ResolveEntityName(client, entity, cat, uuidNameMap);
nameLabels.Add(new EntityLabel
{
Name = name,
LabelColor = color,
PixelX = px,
PixelY = py,
});
}
}
}
entityPixels[(centerX, centerY)] = (MinimapEntityClassifier.PlayerColor, 5);
result.VisibleCategories.Add(MobCategory.Player);
var selfList = result.EntityMap![centerX, centerY] ??= [];
selfList.Add(new PixelEntityInfo
{
Name = client.GetUsername(),
Category = MobCategory.Player,
X = playerLoc.X,
Y = playerLoc.Y,
Z = playerLoc.Z,
Health = client.GetHealth(),
MaxHealth = 20f,
Priority = 5,
});
ChunkColumn? cachedColumn = null;
int cachedChunkX = int.MinValue, cachedChunkZ = int.MinValue;
bool[,]? caveMask = caveMode ? new bool[mapW, mapH] : null;
for (int px = 0; px < mapW; px++)
{
for (int py = 0; py < mapH; py++)
{
if (ct.IsCancellationRequested) return result;
int baseX = playerBlockX + (px - centerX) * bpp;
int baseZ = playerBlockZ + (py - centerY) * bpp;
if (caveMode)
{
if (bpp == 1)
{
var (color, surfY, surfMat, inCave) = SampleColumnCave(
world, baseX, baseZ, playerBlockY, minY, dim.maxY - 1,
ref cachedColumn, ref cachedChunkX, ref cachedChunkZ);
result.Pixels[px, py] = color;
result.Heights[px, py] = surfY;
result.BlockTypes![px, py] = surfMat;
caveMask![px, py] = inCave;
}
else
{
var (color, surfY, matSum, inCave) = SampleAreaDominantCave(
world, baseX, baseZ, bpp, playerBlockY, minY, dim.maxY - 1,
result.BlockSummary is not null,
ref cachedColumn, ref cachedChunkX, ref cachedChunkZ);
result.Pixels[px, py] = color;
result.Heights[px, py] = surfY;
if (result.BlockSummary is not null)
result.BlockSummary[px, py] = matSum;
caveMask![px, py] = inCave;
}
}
else
{
if (bpp == 1)
{
var (color, surfY, surfMat) = SampleColumn(world, baseX, baseZ, scanTop, minY,
ref cachedColumn, ref cachedChunkX, ref cachedChunkZ);
result.Pixels[px, py] = color;
result.Heights[px, py] = surfY;
result.BlockTypes![px, py] = surfMat;
}
else
{
var (color, surfY, matSum) = SampleAreaDominant(world, baseX, baseZ, bpp,
scanTop, minY, result.BlockSummary is not null,
ref cachedColumn, ref cachedChunkX, ref cachedChunkZ);
result.Pixels[px, py] = color;
result.Heights[px, py] = surfY;
if (result.BlockSummary is not null)
result.BlockSummary[px, py] = matSum;
}
}
}
}
for (int px = 0; px < mapW; px++)
{
for (int py = 0; py < mapH; py++)
{
if (entityPixels.ContainsKey((px, py))) continue;
int northHeight = py > 0 ? result.Heights[px, py - 1] : result.Heights[px, py];
int delta = result.Heights[px, py] - northHeight;
result.Pixels[px, py] = MinimapColorMap.ApplyHeightShade(result.Pixels[px, py], delta);
}
}
if (caveMask is not null)
ApplyCaveBorder(result, caveMask, mapW, mapH, entityPixels);
foreach (var (key, info) in entityPixels)
{
var (px, py) = key;
if (px >= 0 && px < mapW && py >= 0 && py < mapH)
result.Pixels[px, py] = info.Color;
}
BakeNameLabels(result, nameLabels, mapW, mapH);
return result;
}
private static string ResolveEntityName(McClient client, Entity entity,
MobCategory cat, Dictionary<string, string>? uuidNameMap)
{
if (cat == MobCategory.Player)
{
if (!string.IsNullOrWhiteSpace(entity.Name))
return entity.Name;
if (entity.UUID != System.Guid.Empty)
{
var playerInfo = client.GetPlayerInfo(entity.UUID);
if (!string.IsNullOrWhiteSpace(playerInfo?.Name))
return playerInfo.Name;
if (uuidNameMap is not null &&
uuidNameMap.TryGetValue(entity.UUID.ToString(), out string? mapped) &&
!string.IsNullOrWhiteSpace(mapped))
return mapped;
}
return "Player";
}
if (!string.IsNullOrWhiteSpace(entity.Name))
return entity.Name;
return entity.Type.ToString();
}
private static void BakeNameLabels(SampleResult result, List<EntityLabel> labels,
int mapW, int mapH)
{
if (labels.Count == 0) return;
int cellRows = mapH / 2;
var occupied = new HashSet<(int col, int row)>();
labels.Sort((a, b) =>
{
int pa = MinimapEntityClassifier.GetPriority(
a.LabelColor == MinimapEntityClassifier.PlayerColor ? MobCategory.Player :
a.LabelColor == MinimapEntityClassifier.HostileColor ? MobCategory.Hostile :
a.LabelColor == MinimapEntityClassifier.NeutralColor ? MobCategory.Neutral : MobCategory.Passive);
int pb = MinimapEntityClassifier.GetPriority(
b.LabelColor == MinimapEntityClassifier.PlayerColor ? MobCategory.Player :
b.LabelColor == MinimapEntityClassifier.HostileColor ? MobCategory.Hostile :
b.LabelColor == MinimapEntityClassifier.NeutralColor ? MobCategory.Neutral : MobCategory.Passive);
return pb.CompareTo(pa);
});
foreach (var lbl in labels)
{
int cellRow = (lbl.PixelY / 2) + 1;
if (cellRow >= cellRows) cellRow = lbl.PixelY / 2 - 1;
if (cellRow < 0 || cellRow >= cellRows) continue;
int startCol = lbl.PixelX - lbl.Name.Length / 2;
startCol = Math.Clamp(startCol, 0, mapW - 1);
bool fits = true;
int endCol = Math.Min(startCol + lbl.Name.Length, mapW);
for (int c = startCol; c < endCol; c++)
{
if (occupied.Contains((c, cellRow)))
{
fits = false;
break;
}
}
if (!fits) continue;
for (int i = 0; i < lbl.Name.Length && startCol + i < mapW; i++)
{
int col = startCol + i;
occupied.Add((col, cellRow));
var bgTop = result.Pixels[col, cellRow * 2];
var bgBot = (cellRow * 2 + 1 < mapH)
? result.Pixels[col, cellRow * 2 + 1]
: bgTop;
var avgBg = Color.FromRgb(
(byte)((bgTop.R + bgBot.R) / 2),
(byte)((bgTop.G + bgBot.G) / 2),
(byte)((bgTop.B + bgBot.B) / 2));
result.CharOverlay[col, cellRow] = (lbl.Name[i], lbl.LabelColor, avgBg);
}
}
}
private static (Color color, int surfaceY, Material surfaceMat) SampleColumn(World world, int x, int z,
int scanTop, int minY,
ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ)
{
int chunkX = x >> 4;
int chunkZ = z >> 4;
if (chunkX != cachedChunkX || chunkZ != cachedChunkZ)
{
cachedColumn = world[chunkX, chunkZ];
cachedChunkX = chunkX;
cachedChunkZ = chunkZ;
}
if (cachedColumn is null)
return (MinimapColorMap.VoidColor, minY, Material.Air);
int waterDepth = 0;
bool inIce = false;
int surfaceY = minY;
Material topMat = Material.Air;
for (int y = scanTop; y >= minY; y--)
{
var loc = new Mapping.Location(x, y, z);
var chunk = cachedColumn.GetChunk(loc);
if (chunk is null) continue;
var block = chunk.GetBlock(loc);
var mat = block.Type;
if (MinimapColorMap.IsFullyTransparent(mat))
continue;
if (MinimapColorMap.IsWater(mat))
{
if (waterDepth == 0) { surfaceY = y; topMat = mat; }
waterDepth++;
continue;
}
if (MinimapColorMap.IsIce(mat) && !inIce)
{
if (waterDepth == 0) { surfaceY = y; topMat = mat; }
inIce = true;
continue;
}
if (waterDepth == 0 && !inIce) { surfaceY = y; topMat = mat; }
var baseColor = MinimapColorMap.GetBaseColor(mat);
if (waterDepth > 0)
baseColor = MinimapColorMap.BlendWaterColor(baseColor, waterDepth);
if (inIce)
baseColor = MinimapColorMap.BlendIceColor(baseColor);
return (baseColor, surfaceY, topMat);
}
if (waterDepth > 0)
return (MinimapColorMap.WaterColor, surfaceY, topMat);
return (MinimapColorMap.VoidColor, minY, Material.Air);
}
private static (Color color, int surfaceY, List<(Material Mat, int Count)>? matSummary)
SampleAreaDominant(World world, int baseX, int baseZ,
int size, int scanTop, int minY, bool collectMats,
ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ)
{
var colorCounts = new Dictionary<Color, (int Count, int SumY)>();
Dictionary<Material, int>? matCounts = collectMats ? [] : null;
int step = Math.Max(1, size / 3);
for (int dx = 0; dx < size; dx += step)
{
for (int dz = 0; dz < size; dz += step)
{
var (c, surfY, surfMat) = SampleColumn(world, baseX + dx, baseZ + dz, scanTop, minY,
ref cachedColumn, ref cachedChunkX, ref cachedChunkZ);
if (colorCounts.TryGetValue(c, out var existing))
colorCounts[c] = (existing.Count + 1, existing.SumY + surfY);
else
colorCounts[c] = (1, surfY);
if (matCounts is not null)
{
if (matCounts.TryGetValue(surfMat, out int mc))
matCounts[surfMat] = mc + 1;
else
matCounts[surfMat] = 1;
}
}
}
Color best = MinimapColorMap.VoidColor;
int bestCount = 0;
int avgY = minY;
foreach (var kvp in colorCounts)
{
if (kvp.Value.Count > bestCount)
{
bestCount = kvp.Value.Count;
best = kvp.Key;
avgY = kvp.Value.SumY / kvp.Value.Count;
}
}
List<(Material, int)>? summary = null;
if (matCounts is not null && matCounts.Count > 0)
{
summary = matCounts
.OrderByDescending(kv => kv.Value)
.Select(kv => (kv.Key, kv.Value))
.ToList();
}
return (best, avgY, summary);
}
/// <summary>
/// Determine whether cave mode should be active for this frame.
/// Mirrors VoxelMap's detection: hasCeiling dimensions always use cave mode,
/// otherwise check whether the player's column has a solid block above.
/// </summary>
private static bool ResolveCaveMode(CaveModeOption opt, World world, Dimension dim,
int playerX, int playerY, int playerZ, int scanTop)
{
if (opt == CaveModeOption.off) return false;
if (opt == CaveModeOption.on) return true;
if (dim.hasCeiling) return true;
for (int y = playerY + 2; y <= scanTop; y++)
{
var mat = world.GetBlock(new Mapping.Location(playerX, y, playerZ)).Type;
if (MinimapColorMap.IsLightBlocking(mat))
return true;
}
return false;
}
/// <summary>
/// Cave-mode column sampler. Starting from playerY, scans down through air
/// to find the first light-blocking block (the cave floor), or scans up if
/// the player is embedded in solid. Returns the floor block color with cave
/// darkening applied, plus an inCave flag indicating the column has a reachable
/// air pocket at the player's Y level.
/// </summary>
private static (Color color, int surfaceY, Material surfaceMat, bool inCave) SampleColumnCave(
World world, int x, int z, int playerY, int minY, int maxY,
ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ)
{
int chunkX = x >> 4;
int chunkZ = z >> 4;
if (chunkX != cachedChunkX || chunkZ != cachedChunkZ)
{
cachedColumn = world[chunkX, chunkZ];
cachedChunkX = chunkX;
cachedChunkZ = chunkZ;
}
if (cachedColumn is null)
return (MinimapColorMap.VoidColor, minY, Material.Air, false);
int caveFloorY = FindCaveFloorY(cachedColumn, x, z, playerY, minY, maxY);
if (caveFloorY == int.MinValue)
{
var fallback = SampleColumn(world, x, z, maxY, minY,
ref cachedColumn, ref cachedChunkX, ref cachedChunkZ);
return (MinimapColorMap.CaveSolidColor, fallback.surfaceY, fallback.surfaceMat, false);
}
var loc = new Mapping.Location(x, caveFloorY, z);
var chunk = cachedColumn.GetChunk(loc);
if (chunk is null)
return (MinimapColorMap.CaveSolidColor, caveFloorY, Material.Air, false);
var block = chunk.GetBlock(loc);
var mat = block.Type;
var color = MinimapColorMap.GetBaseColor(mat);
color = MinimapColorMap.ApplyCaveDarkening(color);
return (color, caveFloorY, mat, true);
}
/// <summary>
/// Find the cave floor Y at (x, z) by scanning from playerY.
/// If the block at playerY is air-like, scan down for the first solid block.
/// If the block at playerY is solid, scan up (up to playerY + 10) for the
/// first air block, then return that Y (the cave ceiling opening).
/// Returns int.MinValue if no cave floor is found.
/// </summary>
private static int FindCaveFloorY(ChunkColumn column, int x, int z, int playerY, int minY, int maxY)
{
var startLoc = new Mapping.Location(x, playerY, z);
var startChunk = column.GetChunk(startLoc);
bool startIsAir;
if (startChunk is null)
{
startIsAir = true;
}
else
{
var startMat = startChunk.GetBlock(startLoc).Type;
startIsAir = !MinimapColorMap.IsLightBlocking(startMat);
}
if (startIsAir)
{
for (int y = playerY - 1; y >= minY; y--)
{
var loc = new Mapping.Location(x, y, z);
var chunk = column.GetChunk(loc);
if (chunk is null) continue;
var mat = chunk.GetBlock(loc).Type;
if (MinimapColorMap.IsLightBlocking(mat))
return y;
}
return minY;
}
else
{
int upLimit = Math.Min(playerY + 10, maxY);
for (int y = playerY + 1; y <= upLimit; y++)
{
var loc = new Mapping.Location(x, y, z);
var chunk = column.GetChunk(loc);
if (chunk is null) continue;
var mat = chunk.GetBlock(loc).Type;
if (!MinimapColorMap.IsLightBlocking(mat))
{
for (int y2 = y - 1; y2 >= minY; y2--)
{
var loc2 = new Mapping.Location(x, y2, z);
var chunk2 = column.GetChunk(loc2);
if (chunk2 is null) continue;
var mat2 = chunk2.GetBlock(loc2).Type;
if (MinimapColorMap.IsLightBlocking(mat2))
return y2;
}
return minY;
}
}
return int.MinValue;
}
}
private static (Color color, int surfaceY, List<(Material Mat, int Count)>? matSummary, bool inCave)
SampleAreaDominantCave(World world, int baseX, int baseZ,
int size, int playerY, int minY, int maxY, bool collectMats,
ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ)
{
var colorCounts = new Dictionary<Color, (int Count, int SumY)>();
Dictionary<Material, int>? matCounts = collectMats ? [] : null;
int caveCount = 0;
int step = Math.Max(1, size / 3);
for (int dx = 0; dx < size; dx += step)
{
for (int dz = 0; dz < size; dz += step)
{
var (c, surfY, surfMat, inCave) = SampleColumnCave(
world, baseX + dx, baseZ + dz, playerY, minY, maxY,
ref cachedColumn, ref cachedChunkX, ref cachedChunkZ);
if (inCave) caveCount++;
if (colorCounts.TryGetValue(c, out var existing))
colorCounts[c] = (existing.Count + 1, existing.SumY + surfY);
else
colorCounts[c] = (1, surfY);
if (matCounts is not null)
{
if (matCounts.TryGetValue(surfMat, out int mc))
matCounts[surfMat] = mc + 1;
else
matCounts[surfMat] = 1;
}
}
}
Color best = MinimapColorMap.VoidColor;
int bestCount = 0;
int avgY = minY;
foreach (var kvp in colorCounts)
{
if (kvp.Value.Count > bestCount)
{
bestCount = kvp.Value.Count;
best = kvp.Key;
avgY = kvp.Value.SumY / kvp.Value.Count;
}
}
List<(Material, int)>? summary = null;
if (matCounts is not null && matCounts.Count > 0)
{
summary = matCounts
.OrderByDescending(kv => kv.Value)
.Select(kv => (kv.Key, kv.Value))
.ToList();
}
int totalSamples = 0;
foreach (var kvp in colorCounts)
totalSamples += kvp.Value.Count;
bool majorityInCave = caveCount * 2 >= totalSamples;
return (best, avgY, summary, majorityInCave);
}
/// <summary>
/// Draw a 1-pixel dark border around the boundary between cave-reachable pixels
/// and non-cave (solid/surface) pixels, giving the cave region a visible edge.
/// </summary>
private static void ApplyCaveBorder(SampleResult result, bool[,] caveMask,
int mapW, int mapH, Dictionary<(int, int), (Color, int)> entityPixels)
{
for (int px = 0; px < mapW; px++)
{
for (int py = 0; py < mapH; py++)
{
if (entityPixels.ContainsKey((px, py))) continue;
if (caveMask[px, py]) continue;
bool neighborInCave = false;
if (px > 0 && caveMask[px - 1, py]) neighborInCave = true;
if (!neighborInCave && px < mapW - 1 && caveMask[px + 1, py]) neighborInCave = true;
if (!neighborInCave && py > 0 && caveMask[px, py - 1]) neighborInCave = true;
if (!neighborInCave && py < mapH - 1 && caveMask[px, py + 1]) neighborInCave = true;
if (neighborInCave)
result.Pixels[px, py] = MinimapColorMap.CaveBorderColor;
}
}
}
private void ApplyPixelBuffer(SampleResult result, int w, int h)
{
int rows = h / 2;
for (int row = 0; row < rows && row < _cellRows; row++)
{
for (int col = 0; col < w && col < _mapWidth; col++)
{
var overlay = result.CharOverlay[col, row];
if (overlay is not null)
{
var (ch, fg, bg) = overlay.Value;
_cells[row, col].Text = ch.ToString();
_cells[row, col].Foreground = new SolidColorBrush(fg);
_cells[row, col].Background = new SolidColorBrush(bg);
}
else
{
var topColor = result.Pixels[col, row * 2];
var bottomColor = result.Pixels[col, row * 2 + 1];
_cells[row, col].Text = "\u2580";
_cells[row, col].Foreground = new SolidColorBrush(topColor);
_cells[row, col].Background = new SolidColorBrush(bottomColor);
}
}
}
_lastResult = result;
if (_hoverCol >= 0 && _hoverRow >= 0)
UpdateTooltip(_hoverCol, _hoverRow);
}
private void OnMapPointerMoved(object? sender, PointerEventArgs e)
{
var pos = e.GetPosition(_mapGrid);
int col = (int)pos.X;
int row = (int)pos.Y;
if (col < 0 || col >= _mapWidth || row < 0 || row >= _cellRows)
{
HideTooltip();
return;
}
_hoverCol = col;
_hoverRow = row;
if (this.VisualRoot is Visual root
&& _mapGrid.TranslatePoint(pos, root) is { } gp)
{
_hoverGlobalX = gp.X;
_hoverGlobalY = gp.Y;
}
else
{
_hoverGlobalX = pos.X;
_hoverGlobalY = pos.Y;
}
UpdateTooltip(col, row);
}
private void OnMapPointerExited(object? sender, PointerEventArgs e)
{
HideTooltip();
}
private void HideTooltip()
{
_hoverCol = -1;
_hoverRow = -1;
TooltipService?.Hide();
}
private void UpdateTooltip(int col, int row)
{
var svc = TooltipService;
var result = _lastResult;
if (svc is null || result is null) { svc?.Hide(); return; }
int bpp = result.Bpp;
int centerX = result.CenterX;
int centerY = result.CenterY;
int topPixelY = row * 2;
int botPixelY = row * 2 + 1;
int baseX = result.PlayerBlockX + (col - centerX) * bpp;
int baseZ_top = result.PlayerBlockZ + (topPixelY - centerY) * bpp;
int baseZ_bot = result.PlayerBlockZ + (botPixelY - centerY) * bpp;
var lines = new List<TuiTooltipLine>();
if (bpp == 1)
{
int surfY_top = (topPixelY < result.Heights.GetLength(1)) ? result.Heights[col, topPixelY] : 0;
int surfY_bot = (botPixelY < result.Heights.GetLength(1)) ? result.Heights[col, botPixelY] : 0;
string coordLine = baseZ_top == baseZ_bot
? $"{baseX}, {surfY_top}, {baseZ_top}"
: $"{baseX}, {surfY_top}, {baseZ_top} / {baseX}, {surfY_bot}, {baseZ_bot}";
lines.Add(new TuiTooltipLine { Text = coordLine, Foreground = Brushes.White });
if (result.BlockTypes is not null)
{
var mat_top = result.BlockTypes[col, topPixelY];
var mat_bot = (botPixelY < result.BlockTypes.GetLength(1))
? result.BlockTypes[col, botPixelY] : mat_top;
string blockLine = mat_top == mat_bot
? FormatMaterialName(mat_top)
: $"{FormatMaterialName(mat_top)} / {FormatMaterialName(mat_bot)}";
lines.Add(new TuiTooltipLine { Text = blockLine, Foreground = Brushes.LightGray });
}
}
else
{
int endX = baseX + bpp - 1;
int endZ_bot = baseZ_bot + bpp - 1;
string coordLine = $"X {baseX}~{endX} Z {baseZ_top}~{endZ_bot}";
lines.Add(new TuiTooltipLine { Text = coordLine, Foreground = Brushes.White });
AppendBlockSummaryLines(result, col, topPixelY, botPixelY, lines);
}
AppendEntityInfoLines(result, col, topPixelY, botPixelY, lines);
if (lines.Count == 0)
{
svc.Hide();
return;
}
bool preferRight = Position switch
{
MinimapPosition.top_left or MinimapPosition.bottom_left => true,
MinimapPosition.top_right or MinimapPosition.bottom_right => false,
_ => true,
};
double mx = _hoverGlobalX;
double my = _hoverGlobalY;
if (Position == MinimapPosition.center
&& this.VisualRoot is Visual root)
{
preferRight = mx < root.Bounds.Width / 2;
}
svc.Show(mx, my, lines, preferRight);
}
private void AppendBlockSummaryLines(SampleResult result, int col, int topPy, int botPy,
List<TuiTooltipLine> lines)
{
if (result.BlockSummary is null) return;
var merged = new Dictionary<Material, int>();
MergeBlockCounts(result.BlockSummary, col, topPy, merged);
if (botPy < result.BlockSummary.GetLength(1))
MergeBlockCounts(result.BlockSummary, col, botPy, merged);
if (merged.Count == 0) return;
var sorted = merged.OrderByDescending(kv => kv.Value).Take(4);
var parts = new List<string>();
foreach (var kv in sorted)
{
if (kv.Key == Material.Air && merged.Count > 1) continue;
parts.Add(kv.Value > 1
? $"{FormatMaterialName(kv.Key)} x{kv.Value}"
: FormatMaterialName(kv.Key));
}
if (parts.Count == 0) return;
lines.Add(new TuiTooltipLine
{
Text = string.Join(", ", parts),
Foreground = Brushes.LightGray,
});
}
private static void MergeBlockCounts(List<(Material Mat, int Count)>?[,] summary,
int px, int py, Dictionary<Material, int> target)
{
var list = summary[px, py];
if (list is null) return;
foreach (var (mat, count) in list)
{
if (target.TryGetValue(mat, out int c))
target[mat] = c + count;
else
target[mat] = count;
}
}
private static void AppendEntityInfoLines(SampleResult result, int col, int topPy, int botPy,
List<TuiTooltipLine> lines)
{
var entityMap = result.EntityMap;
if (entityMap is null) return;
var combined = new List<PixelEntityInfo>();
AddEntitiesFromPixel(entityMap, col, topPy, combined);
if (botPy < entityMap.GetLength(1))
AddEntitiesFromPixel(entityMap, col, botPy, combined);
if (combined.Count == 0) return;
combined.Sort((a, b) => b.Priority.CompareTo(a.Priority));
int shown = 0;
var seen = new HashSet<string>();
foreach (var ent in combined)
{
if (shown >= 4) break;
string key = $"{ent.Name}_{ent.Health:F0}";
if (!seen.Add(key)) continue;
var catColor = MinimapEntityClassifier.GetBaseColor(ent.Category);
string coordStr = $"({ent.X:F1}, {ent.Y:F1}, {ent.Z:F1})";
string hpStr = "";
if (ent.Health > 0)
{
hpStr = ent.MaxHealth > 0
? $" HP:{ent.Health:F0}/{ent.MaxHealth:F0}"
: $" HP:{ent.Health:F0}";
}
lines.Add(new TuiTooltipLine
{
Text = $"{ent.Name} {coordStr}{hpStr}",
Foreground = new SolidColorBrush(catColor),
});
shown++;
}
}
private static void AddEntitiesFromPixel(List<PixelEntityInfo>?[,] map,
int px, int py, List<PixelEntityInfo> target)
{
if (px >= 0 && px < map.GetLength(0) && py >= 0 && py < map.GetLength(1))
{
var list = map[px, py];
if (list is not null)
target.AddRange(list);
}
}
private static string FormatMaterialName(Material mat)
{
if (mat == Material.Air) return "Air";
string raw = mat.ToString();
return raw.Replace('_', ' ');
}
private void UpdateInfoBarAndLegend(McClient client, int bpp,
HashSet<MobCategory> categories, int mapW, bool caveModeActive)
{
var loc = client.GetCurrentLocation();
float yaw = client.GetYaw();
string arrow = GetDirectionArrow(yaw);
int x = (int)Math.Floor(loc.X);
int y = (int)Math.Floor(loc.Y);
int z = (int)Math.Floor(loc.Z);
string caveSuffix = caveModeActive ? " \u25bc" : "";
string coordPart = $"{x}, {y}, {z} {arrow} {bpp}:1{caveSuffix}";
var legendParts = new List<string>();
var legendColors = new List<Color>();
var sorted = categories
.Where(c => c != MobCategory.NonLiving)
.OrderByDescending(MinimapEntityClassifier.GetPriority);
int catCount = 0;
foreach (var cat in sorted)
{
if (catCount >= 4) break;
legendParts.Add(MinimapEntityClassifier.GetCategoryLabel(cat));
legendColors.Add(MinimapEntityClassifier.GetBaseColor(cat));
catCount++;
}
int legendLen = 0;
for (int i = 0; i < legendParts.Count; i++)
legendLen += 1 + legendParts[i].Length + (i > 0 ? 1 : 0);
bool fitsOnOneLine = legendParts.Count > 0
&& coordPart.Length + 2 + legendLen <= mapW;
_infoRow.Children.Clear();
_infoRow.Children.Add(new TextBlock
{
Text = coordPart,
Foreground = Brushes.Gray,
Padding = new Thickness(0),
});
if (fitsOnOneLine)
{
AppendLegendItems(_infoRow, legendParts, legendColors, leftMargin: 2);
_legendPanel.Children.Clear();
_legendPanel.IsVisible = false;
}
else
{
_legendPanel.IsVisible = legendParts.Count > 0;
_legendPanel.Children.Clear();
AppendLegendItems(_legendPanel, legendParts, legendColors, leftMargin: 0);
}
}
private static void AppendLegendItems(StackPanel panel,
List<string> parts, List<Color> colors, int leftMargin)
{
for (int i = 0; i < parts.Count; i++)
{
int ml = i == 0 ? leftMargin : 1;
panel.Children.Add(new TextBlock
{
Text = "\u25cf",
Foreground = new SolidColorBrush(colors[i]),
Padding = new Thickness(0),
Margin = ml > 0 ? new Thickness(ml, 0, 0, 0) : new Thickness(0),
});
panel.Children.Add(new TextBlock
{
Text = parts[i],
Foreground = Brushes.Gray,
Padding = new Thickness(0),
Margin = new Thickness(0),
});
}
}
private static string GetDirectionArrow(float yaw)
{
double normalized = ((yaw % 360) + 360) % 360;
int index = (int)Math.Round(normalized / 45.0) % 8;
return index switch
{
0 => "\u2193", // S
1 => "\u2199", // SW
2 => "\u2190", // W
3 => "\u2196", // NW
4 => "\u2191", // N
5 => "\u2197", // NE
6 => "\u2192", // E
7 => "\u2198", // SE
_ => "\u2193",
};
}
}
}