mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
499 lines
16 KiB
C#
499 lines
16 KiB
C#
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
|
|
}
|
|
}
|