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
{
///
/// 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.
///
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 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;
Task.Run(() =>
{
try
{
var result = SampleTerrain(client, bpp, w, h,
showPlayers, showHostile, showNeutral, showPassive, ct);
if (ct.IsCancellationRequested) return;
Dispatcher.UIThread.Post(() =>
{
ApplyPixelBuffer(result, w, h);
UpdateInfoBarAndLegend(client, bpp, result.VisibleCategories, w);
});
}
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 VisibleCategories = [];
public int[,] Heights = null!;
public Material[,]? BlockTypes;
public List<(Material Mat, int Count)>?[,]? BlockSummary;
public List?[,]? EntityMap;
public int PlayerBlockX;
public int PlayerBlockZ;
public int CenterX;
public int CenterY;
public int Bpp;
}
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,
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?[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);
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();
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;
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 (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);
}
}
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? 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 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();
Dictionary? 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);
}
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();
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 lines)
{
if (result.BlockSummary is null) return;
var merged = new Dictionary();
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();
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 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 lines)
{
var entityMap = result.EntityMap;
if (entityMap is null) return;
var combined = new List();
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();
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?[,] map,
int px, int py, List 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 categories, int mapW)
{
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 coordPart = $"{x}, {y}, {z} {arrow} {bpp}:1";
var legendParts = new List();
var legendColors = new List();
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 parts, List 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",
};
}
}
}