feat: Update map overlay to support interactive zoom and pan, and enhance header with zoom percentage display

This commit is contained in:
BruceChen 2026-04-05 18:03:10 +08:00
parent 0dde017ecc
commit 296070f7e4
3 changed files with 336 additions and 47 deletions

View file

@ -1683,7 +1683,7 @@ namespace MinecraftClient {
}
/// <summary>
/// Looks up a localized string similar to Map #{0} ({1}x{2}).
/// Looks up a localized string similar to Map #{0} ({1}x{2}) [{3}%].
/// </summary>
internal static string bot_map_tui_header {
get {
@ -1692,7 +1692,7 @@ namespace MinecraftClient {
}
/// <summary>
/// Looks up a localized string similar to Press Escape or E to close.
/// 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 {

View file

@ -661,10 +661,10 @@ cooldown: {6}</value>
<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})</value>
<value>Map #{0} ({1}x{2}) [{3}%]</value>
</data>
<data name="bot.map.tui_controls" xml:space="preserve">
<value>Press Escape or E to close</value>
<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>

View file

@ -2,7 +2,6 @@ using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
@ -12,18 +11,42 @@ using MinecraftClient.ChatBots;
namespace MinecraftClient.Tui
{
/// <summary>
/// Fullscreen overlay that renders a Minecraft map using Unicode half-block
/// characters with Avalonia rich-text inlines. Each terminal cell displays two
/// vertical pixels via the upper-half-block glyph: foreground = top pixel,
/// background = bottom pixel. Title and controls text are embedded into the
/// border lines themselves to maximize the map display area.
/// 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 readonly ScrollViewer _scrollViewer;
private const double ZoomFactor = 1.25;
private const double MaxScale = 2.0;
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)
{
@ -40,13 +63,7 @@ namespace MinecraftClient.Tui
Padding = new Thickness(0),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Center,
};
_scrollViewer = new ScrollViewer
{
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Content = _mapBlock,
VerticalAlignment = VerticalAlignment.Center,
};
var border = new Border
@ -54,7 +71,7 @@ namespace MinecraftClient.Tui
BorderBrush = Brushes.White,
BorderThickness = new Thickness(1),
Padding = new Thickness(0),
Child = _scrollViewer,
Child = _mapBlock,
};
_headerBlock = new TextBlock
@ -76,64 +93,204 @@ namespace MinecraftClient.Tui
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);
};
UpdateMap(map);
_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)
{
_headerBlock.Text = string.Format(Translations.bot_map_tui_header, map.MapId, map.Width, map.Height);
_map = map;
if (map.Colors is null || map.Width == 0 || map.Height == 0)
return;
RenderMapInlines(map);
RecalculateFitScale();
_scale = _fitScale;
_offsetX = 0;
_offsetY = 0;
UpdateHeaderText();
RenderViewport();
}
/// <summary>
/// Renders the map pixel data as Avalonia <see cref="Run"/> inlines using
/// the upper-half-block character. Each terminal row represents two pixel
/// rows: foreground color = upper pixel, background color = lower pixel.
/// Adjacent cells with identical colors are batched into a single Run.
/// </summary>
private void RenderMapInlines(McMap map)
private void UpdateHeaderText()
{
int w = map.Width;
int h = map.Height;
byte[] colors = map.Colors!;
int zoomPercent = _scale > 0 ? (int)Math.Round(_scale * 100) : 0;
_headerBlock.Text = string.Format(Translations.bot_map_tui_header,
_map.MapId, _map.Width, _map.Height, zoomPercent);
}
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 void ZoomAtCenter(double factor)
{
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 = Math.Clamp(_scale * factor, _fitScale, MaxScale);
if ((_scale < 1.0 - 1e-9 && newScale > 1.0 + 1e-9) ||
(_scale > 1.0 + 1e-9 && newScale < 1.0 - 1e-9))
newScale = 1.0;
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();
for (int py = 0; py < h; py += 2)
double invScale = 1.0 / _scale;
for (int r = 0; r < renderTextRows; r++)
{
if (py > 0)
if (r > 0)
_mapBlock.Inlines.Add(new LineBreak());
IBrush? batchFg = null;
IBrush? batchBg = null;
int batchLen = 0;
for (int px = 0; px < w; px++)
for (int c = 0; c < renderCols; c++)
{
ColorRGBA top = MapColors.ColorByteToRGBA(colors[px + py * w]);
ColorRGBA bot = (py + 1 < h)
? MapColors.ColorByteToRGBA(colors[px + (py + 1) * w])
: top;
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));
@ -145,7 +302,7 @@ namespace MinecraftClient.Tui
else
{
if (batchLen > 0)
FlushBatch(_mapBlock, batchFg!, batchBg!, batchLen);
FlushBatch(batchFg!, batchBg!, batchLen);
batchFg = fg;
batchBg = bg;
@ -154,13 +311,15 @@ namespace MinecraftClient.Tui
}
if (batchLen > 0)
FlushBatch(_mapBlock, batchFg!, batchBg!, batchLen);
FlushBatch(batchFg!, batchBg!, batchLen);
}
UpdateCornerIndicators();
}
private static void FlushBatch(TextBlock tb, IBrush fg, IBrush bg, int count)
private void FlushBatch(IBrush fg, IBrush bg, int count)
{
tb.Inlines!.Add(new Run(new string('\u2580', count))
_mapBlock.Inlines!.Add(new Run(new string('\u2580', count))
{
Foreground = fg,
Background = bg,
@ -174,6 +333,80 @@ namespace MinecraftClient.Tui
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)
{
double factor = e.Delta.Y > 0 ? ZoomFactor : 1.0 / ZoomFactor;
ZoomAtCenter(factor);
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)
@ -183,16 +416,72 @@ namespace MinecraftClient.Tui
}
}
private void OnTunnelTextInput(object? sender, TextInputEventArgs e)
{
if (e.Text is "+" or "=")
{
ZoomAtCenter(ZoomFactor);
e.Handled = true;
}
else if (e.Text is "-")
{
ZoomAtCenter(1.0 / ZoomFactor);
e.Handled = true;
}
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key is Key.Escape or Key.E)
switch (e.Key)
{
TuiConsoleBackend.Instance?.DismissOverlay();
e.Handled = true;
return;
case Key.Escape:
case Key.E:
TuiConsoleBackend.Instance?.DismissOverlay();
e.Handled = true;
return;
case Key.Add:
ZoomAtCenter(ZoomFactor);
e.Handled = true;
return;
case Key.Subtract:
ZoomAtCenter(1.0 / ZoomFactor);
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
}
}