diff --git a/MinecraftClient/ChatBots/Map.cs b/MinecraftClient/ChatBots/Map.cs
index 930e057e..a686c39a 100644
--- a/MinecraftClient/ChatBots/Map.cs
+++ b/MinecraftClient/ChatBots/Map.cs
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
+using Avalonia.Threading;
using Brigadier.NET;
using Brigadier.NET.Builder;
using ImageMagick;
@@ -11,6 +12,7 @@ using MinecraftClient.CommandHandler;
using MinecraftClient.CommandHandler.Patch;
using MinecraftClient.Mapping;
using MinecraftClient.Scripting;
+using MinecraftClient.Tui;
using Tomlet.Attributes;
namespace MinecraftClient.ChatBots
@@ -142,7 +144,12 @@ namespace MinecraftClient.ChatBots
SaveToFile(map);
if (Config.Render_In_Console)
- RenderInConsole(map);
+ {
+ if (ConsoleIO.Backend is TuiConsoleBackend)
+ RenderInTui(map);
+ else
+ RenderInConsole(map);
+ }
return r.SetAndReturn(CmdResult.Status.Done);
}
@@ -213,7 +220,12 @@ namespace MinecraftClient.ChatBots
SaveToFile(map);
if (Config.Render_In_Console)
- RenderInConsole(map);
+ {
+ if (ConsoleIO.Backend is TuiConsoleBackend)
+ RenderInTui(map);
+ else
+ RenderInConsole(map);
+ }
}
}
@@ -342,6 +354,24 @@ namespace MinecraftClient.ChatBots
}
}
+ private static void RenderInTui(McMap map)
+ {
+ var view = TuiConsoleBackend.Instance?.GetView();
+ if (view is null)
+ return;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (view.HasOverlay && view.OverlayContent is MapOverlay existing)
+ {
+ existing.UpdateMap(map);
+ return;
+ }
+
+ view.ShowOverlay(new MapOverlay(map));
+ });
+ }
+
private static void RenderInConsole(McMap map)
{
StringBuilder sb = new();
@@ -447,109 +477,62 @@ namespace MinecraftClient.ChatBots
public DateTime LastUpdated { get; set; }
}
- internal class MapColors
+ ///
+ /// Map packet base color palette. Colors are loaded from the embedded
+ /// MinimapBlockColors.json resource (map_palette section) generated by
+ /// tools/gen_block_color_map.py, which parses MapColor.java.
+ ///
+ internal static class MapColors
{
- // When colors are updated in a new update, you can get them using the game code: net\minecraft\world\level\material\MaterialColor.java
- public static Dictionary Colors = new()
+ private static readonly Dictionary Colors;
+
+ private static readonly byte[] ShadeMultipliers = [180, 220, 255, 135];
+
+ static MapColors()
{
- //Color ID R G B
- {0, new byte[]{0, 0, 0}},
- {1, new byte[]{127, 178, 56}},
- {2, new byte[]{247, 233, 163}},
- {3, new byte[]{199, 199, 199}},
- {4, new byte[]{255, 0, 0}},
- {5, new byte[]{160, 160, 255}},
- {6, new byte[]{167, 167, 167}},
- {7, new byte[]{0, 124, 0}},
- {8, new byte[]{255, 255, 255}},
- {9, new byte[]{164, 168, 184}},
- {10, new byte[]{151, 109, 77}},
- {11, new byte[]{112, 112, 112}},
- {12, new byte[]{64, 64, 255}},
- {13, new byte[]{143, 119, 72}},
- {14, new byte[]{255, 252, 245}},
- {15, new byte[]{216, 127, 51}},
- {16, new byte[]{178, 76, 216}},
- {17, new byte[]{102, 153, 216}},
- {18, new byte[]{229, 229, 51}},
- {19, new byte[]{127, 204, 25}},
- {20, new byte[]{242, 127, 165}},
- {21, new byte[]{76, 76, 76}},
- {22, new byte[]{153, 153, 153}},
- {23, new byte[]{76, 127, 153}},
- {24, new byte[]{127, 63, 178}},
- {25, new byte[]{51, 76, 178}},
- {26, new byte[]{102, 76, 51}},
- {27, new byte[]{102, 127, 51}},
- {28, new byte[]{153, 51, 51}},
- {29, new byte[]{25, 25, 25}},
- {30, new byte[]{250, 238, 77}},
- {31, new byte[]{92, 219, 213}},
- {32, new byte[]{74, 128, 255}},
- {33, new byte[]{0, 217, 58}},
- {34, new byte[]{129, 86, 49}},
- {35, new byte[]{112, 2, 0}},
- {36, new byte[]{209, 177, 161}},
- {37, new byte[]{159, 82, 36}},
- {38, new byte[]{149, 87, 108}},
- {39, new byte[]{112, 108, 138}},
- {40, new byte[]{186, 133, 36}},
- {41, new byte[]{103, 117, 53}},
- {42, new byte[]{160, 77, 78}},
- {43, new byte[]{57, 41, 35}},
- {44, new byte[]{135, 107, 98}},
- {45, new byte[]{87, 92, 92}},
- {46, new byte[]{122, 73, 88}},
- {47, new byte[]{76, 62, 92}},
- {48, new byte[]{76, 50, 35}},
- {49, new byte[]{76, 82, 42}},
- {50, new byte[]{142, 60, 46}},
- {51, new byte[]{37, 22, 16}},
- {52, new byte[]{189, 48, 49}},
- {53, new byte[]{148, 63, 97}},
- {54, new byte[]{92, 25, 29}},
- {55, new byte[]{22, 126, 134}},
- {56, new byte[]{58, 142, 140}},
- {57, new byte[]{86, 44, 62}},
- {58, new byte[]{20, 180, 133}},
- {59, new byte[]{100, 100, 100}},
- {60, new byte[]{216, 175, 147}},
- {61, new byte[]{127, 167, 150}}
- };
+ Colors = new Dictionary();
+ try
+ {
+ using var stream = System.Reflection.Assembly.GetExecutingAssembly()
+ .GetManifestResourceStream("MinimapBlockColors.json");
+ if (stream is not null)
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(stream);
+ if (doc.RootElement.TryGetProperty("map_palette", out var palette))
+ {
+ foreach (var prop in palette.EnumerateObject())
+ {
+ if (!byte.TryParse(prop.Name, out byte id))
+ continue;
+ var arr = prop.Value;
+ Colors[id] = [
+ arr[0].GetByte(),
+ arr[1].GetByte(),
+ arr[2].GetByte()
+ ];
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ ConsoleIO.WriteLogLine($"[Map] Failed to load map palette: {ex.Message}");
+ }
+ }
public static ColorRGBA ColorByteToRGBA(byte receivedColorId)
{
- // Divide received color id by 4 to get the base color id
- // Much thanks to DevBobcorn
byte baseColorId = (byte)(receivedColorId >> 2);
- // Any new colors that we haven't added will be purple like in the missing CS: Source Texture
- if (!Colors.ContainsKey(baseColorId))
+ if (!Colors.TryGetValue(baseColorId, out byte[]? rgb))
return new(248, 0, 248, 255, true);
- byte shadeId = (byte)(receivedColorId % 4);
- byte shadeMultiplier = 255;
-
- switch (shadeId)
- {
- case 0:
- shadeMultiplier = 180;
- break;
-
- case 1:
- shadeMultiplier = 220;
- break;
-
- case 3:
- // NOTE: If we ever add map support below 1.8, this needs to be 220 before 1.8
- shadeMultiplier = 135;
- break;
- }
+ byte multiplier = ShadeMultipliers[receivedColorId & 3];
return new(
- r: (byte)((Colors[baseColorId][0] * shadeMultiplier) / 255),
- g: (byte)((Colors[baseColorId][1] * shadeMultiplier) / 255),
- b: (byte)((Colors[baseColorId][2] * shadeMultiplier) / 255),
+ r: (byte)(rgb[0] * multiplier / 255),
+ g: (byte)(rgb[1] * multiplier / 255),
+ b: (byte)(rgb[2] * multiplier / 255),
a: 255
);
}
diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs
index 9a9f0879..cd59d351 100644
--- a/MinecraftClient/Resources/Translations/Translations.Designer.cs
+++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs
@@ -1682,6 +1682,24 @@ namespace MinecraftClient {
}
}
+ ///
+ /// Looks up a localized string similar to Map #{0} ({1}x{2}) [{3}%].
+ ///
+ internal static string bot_map_tui_header {
+ get {
+ return ResourceManager.GetString("bot.map.tui_header", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Scroll=Zoom Drag=Pan +/-=Zoom Arrows=Pan Esc/E=Close.
+ ///
+ internal static string bot_map_tui_controls {
+ get {
+ return ResourceManager.GetString("bot.map.tui_controls", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to replay command.
///
diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx
index 18b25cea..5a9a5da7 100644
--- a/MinecraftClient/Resources/Translations/Translations.resx
+++ b/MinecraftClient/Resources/Translations/Translations.resx
@@ -660,6 +660,12 @@ cooldown: {6}
Sent a rendered image of a map with an id '{0}' to the Telegram via Telegram Bridge chat bot!
+
+ Map #{0} ({1}x{2}) [{3}%]
+
+
+ Scroll=Zoom Drag=Pan +/-=Zoom Arrows=Pan Esc/E=Close
+
replay command
diff --git a/MinecraftClient/Tui/MainTuiView.cs b/MinecraftClient/Tui/MainTuiView.cs
index 9affb94c..bee0bc84 100644
--- a/MinecraftClient/Tui/MainTuiView.cs
+++ b/MinecraftClient/Tui/MainTuiView.cs
@@ -118,6 +118,7 @@ namespace MinecraftClient.Tui
};
_commandInput.AddHandler(KeyDownEvent, OnCommandKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ _commandInput.AddHandler(TextInputEvent, OnCommandTextInput, Avalonia.Interactivity.RoutingStrategies.Tunnel);
_commandInput.TextChanged += OnCommandTextChanged;
var promptLabel = new TextBlock
@@ -487,18 +488,41 @@ namespace MinecraftClient.Tui
_commandInput.CaretIndex = pos;
}
+ private void OnCommandTextInput(object? sender, TextInputEventArgs e)
+ {
+ string? incoming = e.Text;
+ if (string.IsNullOrEmpty(incoming) || (!incoming.Contains('\n') && !incoming.Contains('\r')))
+ return;
+
+ e.Handled = true;
+
+ string[] lines = incoming.Split(["\r\n", "\r", "\n"], StringSplitOptions.None);
+
+ string prefix = _commandInput.Text ?? "";
+ for (int i = 0; i < lines.Length; i++)
+ {
+ string line = lines[i];
+ bool isLast = i == lines.Length - 1;
+
+ if (line.Length > 0 || prefix.Length > 0)
+ {
+ _acceptingSuggestion = true;
+ try { _commandInput.Text = prefix + line; }
+ finally { _acceptingSuggestion = false; }
+ }
+
+ if (!isLast)
+ {
+ SubmitCommand();
+ prefix = "";
+ }
+ }
+ }
+
private void OnCommandTextChanged(object? sender, TextChangedEventArgs e)
{
string text = _commandInput.Text ?? string.Empty;
- if (text.Contains('\n') || text.Contains('\r'))
- {
- string cleaned = text.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ');
- _commandInput.Text = cleaned;
- _commandInput.CaretIndex = cleaned.Length;
- return;
- }
-
if (_acceptingSuggestion || _tabCycling)
return;
@@ -1172,6 +1196,8 @@ namespace MinecraftClient.Tui
public bool HasOverlay => _overlayContent != null;
+ public Control? OverlayContent => _overlayContent;
+
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.Escape && _overlayContent != null)
diff --git a/MinecraftClient/Tui/MapOverlay.cs b/MinecraftClient/Tui/MapOverlay.cs
new file mode 100644
index 00000000..a52096f6
--- /dev/null
+++ b/MinecraftClient/Tui/MapOverlay.cs
@@ -0,0 +1,499 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MinecraftClient.ChatBots;
+
+namespace MinecraftClient.Tui
+{
+ ///
+ /// Fullscreen overlay that renders a Minecraft map with interactive zoom and pan.
+ /// Uses Unicode half-block characters where each terminal cell displays two vertical
+ /// pixels (foreground = top, background = bottom). Supports mouse wheel zoom with
+ /// center-anchoring, mouse drag panning, and keyboard navigation.
+ /// At 100% one terminal column = one map pixel wide; at 200% two columns = one pixel.
+ ///
+ internal sealed class MapOverlay : Panel
+ {
+ private const double MaxScale = 2.0;
+ private const double ZoomStep = 0.125;
+ private const double KeyPanStep = 4.0;
+ private const double OffsetEpsilon = 0.5;
+
+ private readonly TextBlock _mapBlock;
+ private readonly TextBlock _headerBlock;
+ private readonly TextBlock _controlsBlock;
+ private readonly TextBlock _cornerTL;
+ private readonly TextBlock _cornerTR;
+ private readonly TextBlock _cornerBL;
+ private readonly TextBlock _cornerBR;
+
+ private McMap _map = null!;
+ private double _scale = 1.0;
+ private double _offsetX;
+ private double _offsetY;
+ private double _fitScale = 1.0;
+
+ private bool _isDragging;
+ private double _dragStartX;
+ private double _dragStartY;
+ private double _dragStartOffsetX;
+ private double _dragStartOffsetY;
+ private bool _initialLayoutDone;
+
+ private static readonly IBrush IndicatorActive = Brushes.Yellow;
+ private static readonly IBrush IndicatorDim = new SolidColorBrush(Color.FromRgb(60, 60, 60));
+
+ public MapOverlay(McMap map)
+ {
+ ArgumentNullException.ThrowIfNull(map);
+
+ HorizontalAlignment = HorizontalAlignment.Stretch;
+ VerticalAlignment = VerticalAlignment.Stretch;
+ Focusable = true;
+ Background = Brushes.Black;
+
+ _mapBlock = new TextBlock
+ {
+ TextWrapping = TextWrapping.NoWrap,
+ Padding = new Thickness(0),
+ Margin = new Thickness(0),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ var border = new Border
+ {
+ BorderBrush = Brushes.White,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(0),
+ Child = _mapBlock,
+ };
+
+ _headerBlock = new TextBlock
+ {
+ Foreground = Brushes.White,
+ Background = Brushes.Black,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Top,
+ Padding = new Thickness(1, 0),
+ };
+
+ _controlsBlock = new TextBlock
+ {
+ Text = Translations.bot_map_tui_controls,
+ Foreground = Brushes.Gray,
+ Background = Brushes.Black,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Padding = new Thickness(1, 0),
+ };
+
+ _cornerTL = CreateCornerIndicator(HorizontalAlignment.Left, VerticalAlignment.Top, "\u25E4");
+ _cornerTR = CreateCornerIndicator(HorizontalAlignment.Right, VerticalAlignment.Top, "\u25E5");
+ _cornerBL = CreateCornerIndicator(HorizontalAlignment.Left, VerticalAlignment.Bottom, "\u25E3");
+ _cornerBR = CreateCornerIndicator(HorizontalAlignment.Right, VerticalAlignment.Bottom, "\u25E2");
+
+ Children.Add(border);
+ Children.Add(_headerBlock);
+ Children.Add(_controlsBlock);
+ Children.Add(_cornerTL);
+ Children.Add(_cornerTR);
+ Children.Add(_cornerBL);
+ Children.Add(_cornerBR);
+
+ AttachedToVisualTree += (_, _) =>
+ {
+ AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel);
+ AddHandler(TextInputEvent, OnTunnelTextInput, RoutingStrategies.Tunnel);
+ Focus();
+ };
+
+ DetachedFromVisualTree += (_, _) =>
+ {
+ RemoveHandler(KeyDownEvent, OnTunnelKeyDown);
+ RemoveHandler(TextInputEvent, OnTunnelTextInput);
+ };
+
+ _map = map;
+ _scale = double.MinValue;
+ UpdateHeaderText();
+ }
+
+ private static TextBlock CreateCornerIndicator(HorizontalAlignment hAlign, VerticalAlignment vAlign, string glyph)
+ {
+ return new TextBlock
+ {
+ Text = glyph,
+ Background = Brushes.Black,
+ Foreground = IndicatorDim,
+ HorizontalAlignment = hAlign,
+ VerticalAlignment = vAlign,
+ Padding = new Thickness(0),
+ };
+ }
+
+ public void UpdateMap(McMap map)
+ {
+ _map = map;
+
+ if (map.Colors is null || map.Width == 0 || map.Height == 0)
+ return;
+
+ RecalculateFitScale();
+ _scale = _fitScale;
+ _offsetX = 0;
+ _offsetY = 0;
+ UpdateHeaderText();
+ RenderViewport();
+ }
+
+ private void UpdateHeaderText()
+ {
+ string zoomText = _scale > 0 ? (_scale * 100).ToString("F1") : "0.0";
+ _headerBlock.Text = string.Format(Translations.bot_map_tui_header,
+ _map.MapId, _map.Width, _map.Height, zoomText);
+ }
+
+ protected override void OnSizeChanged(SizeChangedEventArgs e)
+ {
+ base.OnSizeChanged(e);
+
+ if (_map?.Colors is null || _map.Width == 0 || _map.Height == 0)
+ return;
+
+ RecalculateFitScale();
+
+ if (!_initialLayoutDone)
+ {
+ _scale = _fitScale;
+ _offsetX = 0;
+ _offsetY = 0;
+ _initialLayoutDone = true;
+ }
+ else
+ {
+ _scale = Math.Clamp(_scale, _fitScale, MaxScale);
+ }
+
+ ClampOffset();
+ UpdateHeaderText();
+ RenderViewport();
+ }
+
+ #region Scale / Offset
+
+ private void GetViewportCells(out int viewW, out int viewH)
+ {
+ viewW = Math.Max(1, (int)Bounds.Width - 2);
+ viewH = Math.Max(1, (int)Bounds.Height - 2);
+ }
+
+ private void RecalculateFitScale()
+ {
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double scaleX = (double)viewW / _map.Width;
+ double scaleY = (double)viewPixelsH / _map.Height;
+ _fitScale = Math.Min(scaleX, scaleY);
+
+ if (_fitScale > MaxScale)
+ _fitScale = MaxScale;
+ }
+
+ private void ClampOffset()
+ {
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double visibleMapW = viewW / _scale;
+ double visibleMapH = viewPixelsH / _scale;
+
+ double maxOffX = Math.Max(0, _map.Width - visibleMapW);
+ double maxOffY = Math.Max(0, _map.Height - visibleMapH);
+
+ _offsetX = Math.Clamp(_offsetX, 0, maxOffX);
+ _offsetY = Math.Clamp(_offsetY, 0, maxOffY);
+ }
+
+ private double NextStepScale(bool zoomIn)
+ {
+ if (zoomIn)
+ {
+ double next = Math.Floor(_scale / ZoomStep + 1.0 - 1e-9) * ZoomStep;
+ if (next <= _scale + 1e-9)
+ next += ZoomStep;
+ return Math.Clamp(next, _fitScale, MaxScale);
+ }
+ else
+ {
+ double prev = Math.Ceiling(_scale / ZoomStep - 1.0 + 1e-9) * ZoomStep;
+ if (prev >= _scale - 1e-9)
+ prev -= ZoomStep;
+ return Math.Clamp(prev, _fitScale, MaxScale);
+ }
+ }
+
+ private void ZoomAtCenter(bool zoomIn)
+ {
+ if (_map?.Colors is null) return;
+
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double centerMapX = _offsetX + (viewW / 2.0) / _scale;
+ double centerMapY = _offsetY + (viewPixelsH / 2.0) / _scale;
+
+ double newScale = NextStepScale(zoomIn);
+ if (Math.Abs(newScale - _scale) < 1e-12)
+ return;
+
+ _scale = newScale;
+
+ _offsetX = centerMapX - (viewW / 2.0) / _scale;
+ _offsetY = centerMapY - (viewPixelsH / 2.0) / _scale;
+ ClampOffset();
+ UpdateHeaderText();
+ RenderViewport();
+ }
+
+ #endregion
+
+ #region Rendering
+
+ private void RenderViewport()
+ {
+ if (_map?.Colors is null || _map.Width == 0 || _map.Height == 0)
+ return;
+
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ int mapW = _map.Width;
+ int mapH = _map.Height;
+ byte[] colors = _map.Colors;
+
+ int renderCols = Math.Min(viewW, (int)Math.Ceiling(mapW * _scale));
+ int renderPixelRows = Math.Min(viewPixelsH, (int)Math.Ceiling(mapH * _scale));
+ int renderTextRows = (renderPixelRows + 1) / 2;
+
+ _mapBlock.Inlines ??= [];
+ _mapBlock.Inlines.Clear();
+
+ double invScale = 1.0 / _scale;
+
+ for (int r = 0; r < renderTextRows; r++)
+ {
+ if (r > 0)
+ _mapBlock.Inlines.Add(new LineBreak());
+
+ IBrush? batchFg = null;
+ IBrush? batchBg = null;
+ int batchLen = 0;
+
+ for (int c = 0; c < renderCols; c++)
+ {
+ int srcX = Math.Clamp((int)(_offsetX + c * invScale), 0, mapW - 1);
+ int srcTopY = Math.Clamp((int)(_offsetY + (r * 2) * invScale), 0, mapH - 1);
+ int srcBotY = Math.Clamp((int)(_offsetY + (r * 2 + 1) * invScale), 0, mapH - 1);
+
+ ColorRGBA top = MapColors.ColorByteToRGBA(colors[srcX + srcTopY * mapW]);
+ ColorRGBA bot = MapColors.ColorByteToRGBA(colors[srcX + srcBotY * mapW]);
+
+ var fg = new SolidColorBrush(Color.FromRgb(top.R, top.G, top.B));
+ var bg = new SolidColorBrush(Color.FromRgb(bot.R, bot.G, bot.B));
+
+ if (batchLen > 0 && ColorsEqual(batchFg!, fg) && ColorsEqual(batchBg!, bg))
+ {
+ batchLen++;
+ }
+ else
+ {
+ if (batchLen > 0)
+ FlushBatch(batchFg!, batchBg!, batchLen);
+
+ batchFg = fg;
+ batchBg = bg;
+ batchLen = 1;
+ }
+ }
+
+ if (batchLen > 0)
+ FlushBatch(batchFg!, batchBg!, batchLen);
+ }
+
+ UpdateCornerIndicators();
+ }
+
+ private void FlushBatch(IBrush fg, IBrush bg, int count)
+ {
+ _mapBlock.Inlines!.Add(new Run(new string('\u2580', count))
+ {
+ Foreground = fg,
+ Background = bg,
+ });
+ }
+
+ private static bool ColorsEqual(IBrush a, IBrush b)
+ {
+ if (a is SolidColorBrush sa && b is SolidColorBrush sb)
+ return sa.Color == sb.Color;
+ return false;
+ }
+
+ private void UpdateCornerIndicators()
+ {
+ GetViewportCells(out int viewW, out int viewH);
+ int viewPixelsH = viewH * 2;
+
+ double visibleW = viewW / _scale;
+ double visibleH = viewPixelsH / _scale;
+
+ bool moreLeft = _offsetX > OffsetEpsilon;
+ bool moreTop = _offsetY > OffsetEpsilon;
+ bool moreRight = _offsetX + visibleW < _map.Width - OffsetEpsilon;
+ bool moreBottom = _offsetY + visibleH < _map.Height - OffsetEpsilon;
+
+ _cornerTL.Foreground = (moreLeft || moreTop) ? IndicatorActive : IndicatorDim;
+ _cornerTR.Foreground = (moreRight || moreTop) ? IndicatorActive : IndicatorDim;
+ _cornerBL.Foreground = (moreLeft || moreBottom) ? IndicatorActive : IndicatorDim;
+ _cornerBR.Foreground = (moreRight || moreBottom) ? IndicatorActive : IndicatorDim;
+ }
+
+ #endregion
+
+ #region Mouse interaction
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ ZoomAtCenter(e.Delta.Y > 0);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ var props = e.GetCurrentPoint(this).Properties;
+ if (!props.IsLeftButtonPressed)
+ return;
+
+ _isDragging = true;
+ var pos = e.GetPosition(this);
+ _dragStartX = pos.X;
+ _dragStartY = pos.Y;
+ _dragStartOffsetX = _offsetX;
+ _dragStartOffsetY = _offsetY;
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ if (!_isDragging) return;
+
+ var pos = e.GetPosition(this);
+ double dxCells = pos.X - _dragStartX;
+ double dyCells = pos.Y - _dragStartY;
+
+ _offsetX = _dragStartOffsetX - dxCells / _scale;
+ _offsetY = _dragStartOffsetY - dyCells * 2.0 / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ if (!_isDragging) return;
+
+ _isDragging = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+
+ #endregion
+
+ #region Keyboard interaction
+
+ private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key is Key.Escape or Key.E)
+ {
+ TuiConsoleBackend.Instance?.DismissOverlay();
+ e.Handled = true;
+ }
+ }
+
+ private void OnTunnelTextInput(object? sender, TextInputEventArgs e)
+ {
+ if (e.Text is "+" or "=")
+ {
+ ZoomAtCenter(true);
+ e.Handled = true;
+ }
+ else if (e.Text is "-")
+ {
+ ZoomAtCenter(false);
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Escape:
+ case Key.E:
+ TuiConsoleBackend.Instance?.DismissOverlay();
+ e.Handled = true;
+ return;
+
+ case Key.Add:
+ ZoomAtCenter(true);
+ e.Handled = true;
+ return;
+
+ case Key.Subtract:
+ ZoomAtCenter(false);
+ e.Handled = true;
+ return;
+
+ case Key.Left:
+ _offsetX -= KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
+
+ case Key.Right:
+ _offsetX += KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
+
+ case Key.Up:
+ _offsetY -= KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
+
+ case Key.Down:
+ _offsetY += KeyPanStep / _scale;
+ ClampOffset();
+ RenderViewport();
+ e.Handled = true;
+ return;
+ }
+
+ base.OnKeyDown(e);
+ }
+
+ #endregion
+ }
+}
diff --git a/MinecraftClient/Tui/MccBannerPanelBuilder.cs b/MinecraftClient/Tui/MccBannerPanelBuilder.cs
index db1bc0b1..c00050fc 100644
--- a/MinecraftClient/Tui/MccBannerPanelBuilder.cs
+++ b/MinecraftClient/Tui/MccBannerPanelBuilder.cs
@@ -123,50 +123,44 @@ namespace MinecraftClient.Tui
int cols = Pixels.GetLength(1);
int textRows = Pixels.GetLength(0) / 2;
- var pixelGrid = new Grid();
- for (int c = 0; c < cols; c++)
- pixelGrid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto));
- for (int r = 0; r < textRows; r++)
- pixelGrid.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Auto));
+ var panel = new StackPanel
+ {
+ Orientation = Orientation.Vertical,
+ Margin = new Thickness(0),
+ };
for (int row = 0; row < textRows; row++)
{
+ var line = new TextBlock { Padding = new Thickness(0), Margin = new Thickness(0) };
+
for (int col = 0; col < cols; col++)
{
var topColor = Pixels[row * 2, col];
var bottomColor = Pixels[row * 2 + 1, col];
- var cell = new TextBlock
+ if (row == 1 && col == 1)
+ {
+ line.Inlines!.Add(new Run(" \uff1e_")
+ {
+ Foreground = new SolidColorBrush(Color.FromRgb(255, 255, 255)),
+ Background = new SolidColorBrush(S),
+ });
+ col += 4;
+ topColor = Pixels[row * 2, col];
+ bottomColor = Pixels[row * 2 + 1, col];
+ }
+
+ line.Inlines!.Add(new Run("\u2580")
{
- Text = "\u2580",
Foreground = new SolidColorBrush(topColor),
Background = new SolidColorBrush(bottomColor),
- Padding = new Thickness(0),
- Margin = new Thickness(0),
- };
-
- Grid.SetRow(cell, row);
- Grid.SetColumn(cell, col);
- pixelGrid.Children.Add(cell);
+ });
}
+
+ panel.Children.Add(line);
}
- var prompt = new TextBlock
- {
- Text = " >_",
- Foreground = new SolidColorBrush(Color.FromRgb(255, 255, 255)),
- Background = new SolidColorBrush(S),
- Padding = new Thickness(0),
- Margin = new Thickness(0),
- HorizontalAlignment = HorizontalAlignment.Left,
- VerticalAlignment = VerticalAlignment.Top,
- };
- Grid.SetRow(prompt, 1);
- Grid.SetColumn(prompt, 1);
- Grid.SetColumnSpan(prompt, 4);
- pixelGrid.Children.Add(prompt);
-
- return pixelGrid;
+ return panel;
}
#endregion
diff --git a/MinecraftClient/Tui/MinimapBlockColors.json b/MinecraftClient/Tui/MinimapBlockColors.json
index af7d726c..aa833f29 100644
--- a/MinecraftClient/Tui/MinimapBlockColors.json
+++ b/MinecraftClient/Tui/MinimapBlockColors.json
@@ -3548,5 +3548,317 @@
"PackedIce",
"BlueIce",
"FrostedIce"
- ]
+ ],
+ "map_palette": {
+ "0": [
+ 0,
+ 0,
+ 0
+ ],
+ "1": [
+ 127,
+ 178,
+ 56
+ ],
+ "2": [
+ 247,
+ 233,
+ 163
+ ],
+ "3": [
+ 199,
+ 199,
+ 199
+ ],
+ "4": [
+ 255,
+ 0,
+ 0
+ ],
+ "5": [
+ 160,
+ 160,
+ 255
+ ],
+ "6": [
+ 167,
+ 167,
+ 167
+ ],
+ "7": [
+ 0,
+ 124,
+ 0
+ ],
+ "8": [
+ 255,
+ 255,
+ 255
+ ],
+ "9": [
+ 164,
+ 168,
+ 184
+ ],
+ "10": [
+ 151,
+ 109,
+ 77
+ ],
+ "11": [
+ 112,
+ 112,
+ 112
+ ],
+ "12": [
+ 64,
+ 64,
+ 255
+ ],
+ "13": [
+ 143,
+ 119,
+ 72
+ ],
+ "14": [
+ 255,
+ 252,
+ 245
+ ],
+ "15": [
+ 216,
+ 127,
+ 51
+ ],
+ "16": [
+ 178,
+ 76,
+ 216
+ ],
+ "17": [
+ 102,
+ 153,
+ 216
+ ],
+ "18": [
+ 229,
+ 229,
+ 51
+ ],
+ "19": [
+ 127,
+ 204,
+ 25
+ ],
+ "20": [
+ 242,
+ 127,
+ 165
+ ],
+ "21": [
+ 76,
+ 76,
+ 76
+ ],
+ "22": [
+ 153,
+ 153,
+ 153
+ ],
+ "23": [
+ 76,
+ 127,
+ 153
+ ],
+ "24": [
+ 127,
+ 63,
+ 178
+ ],
+ "25": [
+ 51,
+ 76,
+ 178
+ ],
+ "26": [
+ 102,
+ 76,
+ 51
+ ],
+ "27": [
+ 102,
+ 127,
+ 51
+ ],
+ "28": [
+ 153,
+ 51,
+ 51
+ ],
+ "29": [
+ 25,
+ 25,
+ 25
+ ],
+ "30": [
+ 250,
+ 238,
+ 77
+ ],
+ "31": [
+ 92,
+ 219,
+ 213
+ ],
+ "32": [
+ 74,
+ 128,
+ 255
+ ],
+ "33": [
+ 0,
+ 217,
+ 58
+ ],
+ "34": [
+ 129,
+ 86,
+ 49
+ ],
+ "35": [
+ 112,
+ 2,
+ 0
+ ],
+ "36": [
+ 209,
+ 177,
+ 161
+ ],
+ "37": [
+ 159,
+ 82,
+ 36
+ ],
+ "38": [
+ 149,
+ 87,
+ 108
+ ],
+ "39": [
+ 112,
+ 108,
+ 138
+ ],
+ "40": [
+ 186,
+ 133,
+ 36
+ ],
+ "41": [
+ 103,
+ 117,
+ 53
+ ],
+ "42": [
+ 160,
+ 77,
+ 78
+ ],
+ "43": [
+ 57,
+ 41,
+ 35
+ ],
+ "44": [
+ 135,
+ 107,
+ 98
+ ],
+ "45": [
+ 87,
+ 92,
+ 92
+ ],
+ "46": [
+ 122,
+ 73,
+ 88
+ ],
+ "47": [
+ 76,
+ 62,
+ 92
+ ],
+ "48": [
+ 76,
+ 50,
+ 35
+ ],
+ "49": [
+ 76,
+ 82,
+ 42
+ ],
+ "50": [
+ 142,
+ 60,
+ 46
+ ],
+ "51": [
+ 37,
+ 22,
+ 16
+ ],
+ "52": [
+ 189,
+ 48,
+ 49
+ ],
+ "53": [
+ 148,
+ 63,
+ 97
+ ],
+ "54": [
+ 92,
+ 25,
+ 29
+ ],
+ "55": [
+ 22,
+ 126,
+ 134
+ ],
+ "56": [
+ 58,
+ 142,
+ 140
+ ],
+ "57": [
+ 86,
+ 44,
+ 62
+ ],
+ "58": [
+ 20,
+ 180,
+ 133
+ ],
+ "59": [
+ 100,
+ 100,
+ 100
+ ],
+ "60": [
+ 216,
+ 175,
+ 147
+ ],
+ "61": [
+ 127,
+ 167,
+ 150
+ ]
+ }
}
\ No newline at end of file
diff --git a/tools/gen_block_color_map.py b/tools/gen_block_color_map.py
index 69cbb502..8bea845f 100644
--- a/tools/gen_block_color_map.py
+++ b/tools/gen_block_color_map.py
@@ -205,6 +205,28 @@ WATER_BLOCKS = ["Water"]
ICE_BLOCKS = ["Ice", "PackedIce", "BlueIce", "FrostedIce"]
+def build_map_palette(map_color_java: Path) -> dict[str, list[int]]:
+ """Build MapColor ID -> [R, G, B] palette for the Map bot (map packet rendering).
+
+ Returns a dict keyed by string IDs ("0", "1", ...) to keep JSON simple.
+ """
+ text = map_color_java.read_text()
+ palette: dict[str, list[int]] = {}
+
+ pattern = re.compile(
+ r'new\s+MapColor\(\s*(\d+)\s*,\s*(\d+)\s*\)')
+ for m in pattern.finditer(text):
+ cid = int(m.group(1))
+ raw = int(m.group(2))
+ r = (raw >> 16) & 0xFF
+ g = (raw >> 8) & 0xFF
+ b = raw & 0xFF
+ palette[str(cid)] = [r, g, b]
+
+ print(f" Built map_palette with {len(palette)} base color entries")
+ return dict(sorted(palette.items(), key=lambda x: int(x[0])))
+
+
def main():
if len(sys.argv) != 2:
print(__doc__)
@@ -249,12 +271,15 @@ def main():
block_colors = matched
print(f" {len(block_colors)} blocks matched to Material.cs entries")
+ map_palette = build_map_palette(map_color_java)
+
output = {
"version": root.name.replace("-decompiled", "").replace("-client", ""),
"colors": {k: list(v) for k, v in sorted(block_colors.items())},
"transparent": sorted(TRANSPARENT_BLOCKS),
"water": WATER_BLOCKS,
"ice": ICE_BLOCKS,
+ "map_palette": map_palette,
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)