mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
Merge pull request #3049 from BruceChenQAQ/master
feat: Add TUI support for /map
This commit is contained in:
commit
a40188cb09
8 changed files with 996 additions and 133 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
499
MinecraftClient/Tui/MapOverlay.cs
Normal file
499
MinecraftClient/Tui/MapOverlay.cs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue