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)