mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
feat: Update map overlay to support interactive zoom and pan, and enhance header with zoom percentage display
This commit is contained in:
parent
0dde017ecc
commit
296070f7e4
3 changed files with 336 additions and 47 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue