Merge pull request #3049 from BruceChenQAQ/master

feat: Add TUI support for /map
This commit is contained in:
BruceChen 2026-04-06 00:24:27 +08:00 committed by GitHub
commit a40188cb09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 996 additions and 133 deletions

View file

@ -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
/// <summary>
/// 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.
/// </summary>
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<byte, byte[]> Colors = new()
private static readonly Dictionary<byte, byte[]> 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<byte, byte[]>();
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
);
}

View file

@ -1682,6 +1682,24 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Map #{0} ({1}x{2}) [{3}%].
/// </summary>
internal static string bot_map_tui_header {
get {
return ResourceManager.GetString("bot.map.tui_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Scroll=Zoom Drag=Pan +/-=Zoom Arrows=Pan Esc/E=Close.
/// </summary>
internal static string bot_map_tui_controls {
get {
return ResourceManager.GetString("bot.map.tui_controls", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to replay command.
/// </summary>

View file

@ -660,6 +660,12 @@ cooldown: {6}</value>
<data name="bot.map.sent_to_telegram" xml:space="preserve">
<value>Sent a rendered image of a map with an id '{0}' to the Telegram via Telegram Bridge chat bot!</value>
</data>
<data name="bot.map.tui_header" xml:space="preserve">
<value>Map #{0} ({1}x{2}) [{3}%]</value>
</data>
<data name="bot.map.tui_controls" xml:space="preserve">
<value>Scroll=Zoom Drag=Pan +/-=Zoom Arrows=Pan Esc/E=Close</value>
</data>
<data name="bot.replayCapture.cmd" xml:space="preserve">
<value>replay command</value>
</data>

View file

@ -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)

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
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
}
}

View file

@ -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

View file

@ -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
]
}
}

View file

@ -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)