Add terrain minimap to dashboard

This commit is contained in:
Tanner
2026-11-08 23:46:52 +00:00
parent 20e53537b1
commit 2d5bf2df22
7 changed files with 418 additions and 8 deletions
+79
View File
@@ -8,6 +8,7 @@ import threading
import time
import traceback
import sys
from math import floor
from pathlib import Path
import logging
@@ -158,6 +159,84 @@ def api_global():
return response
def _minimap_surface_cell(chunks, x, z):
"""Return the uppermost solid/water block in a loaded world column."""
from mosfet.info import blocks
chunk_x, chunk_z = floor(x / 16), floor(z / 16)
chunk = chunks.chunks.get((chunk_x, chunk_z))
if chunk is None:
return None
local_x, local_z = x - chunk_x * 16, z - chunk_z * 16
min_y = getattr(chunks, 'min_y', -64)
for section_y in sorted(chunk.sections, reverse=True):
if section_y * 16 + 15 < min_y:
continue
section = chunk.sections[section_y]
if section.get('non_empty_blocks') == 0:
continue
for y in range(section_y * 16 + 15, max(min_y, section_y * 16) - 1, -1):
state_id = chunk.get_block_at(local_x, y, local_z)
if state_id is None or state_id == blocks.AIR:
continue
if (state_id in blocks.NON_SOLID_IDS and
state_id not in blocks.WATER_IDS):
continue
return {'block': blocks.BLOCKS.get(state_id, 'unknown'), 'y': y}
return None
@app.route('/api/minimap')
def api_minimap():
position = g.get('pos') or g.get('player_position')
chunks = g.get('chunks')
if (not g.get('connected') or position is None or position is False or
chunks is None or not hasattr(chunks, 'chunks')):
return jsonify(ready=False, cells=[])
try:
radius = int(request.args.get('radius', 12))
except (TypeError, ValueError):
radius = 12
radius = max(4, min(32, radius))
try:
radius_x = int(request.args.get('radius_x', radius))
except (TypeError, ValueError):
radius_x = radius
try:
radius_z = int(request.args.get('radius_z', radius))
except (TypeError, ValueError):
radius_z = radius
radius_x = max(4, min(48, radius_x))
radius_z = max(4, min(32, radius_z))
center_x, center_z = floor(position.x), floor(position.z)
center_y = floor(position.y)
lock = getattr(chunks, 'lock', None)
if lock is not None:
lock.acquire()
try:
cells = [
[_minimap_surface_cell(chunks, center_x + dx, center_z + dz)
for dx in range(-radius_x, radius_x + 1)]
for dz in range(-radius_z, radius_z + 1)
]
finally:
if lock is not None:
lock.release()
ready = any(cell is not None for row in cells for cell in row)
return jsonify(
ready=ready,
center={'x': center_x, 'y': center_y, 'z': center_z},
yaw=float(g.get('yaw', 0.0) or 0.0),
radius=radius,
radius_x=radius_x,
radius_z=radius_z,
cells=cells if ready else [],
)
@app.route('/api/items')
def api_items():
from mosfet.info.items import ITEM_NAMES
+40 -1
View File
@@ -2,7 +2,9 @@ import importlib.util
from pathlib import Path
import threading
import unittest
from munch import Munch
from mosfet.info import blocks
from mosfet.protocol.types import Slot
@@ -32,7 +34,8 @@ class TestWebInventoryAPI(unittest.TestCase):
self.g = mosfet_web.g
self.original = {
key: self.g.get(key) for key in
('connected', 'game', 'inv', 'chest_viewers', 'time', 'time_base')
('connected', 'game', 'inv', 'chest_viewers', 'time', 'time_base',
'chunks', 'pos', 'yaw')
}
self.game = FakeGame()
self.g.connected = True
@@ -54,6 +57,42 @@ class TestWebInventoryAPI(unittest.TestCase):
item = response.get_json()['inv']['9']
self.assertEqual(item['components']['custom_name']['text'], 'Test Paper')
def test_minimap_api_returns_surface_blocks_centered_on_bot(self):
grass = blocks.MCD_BLOCKS['grass_block']['minStateId']
class FakeChunk:
sections = {
0: {'non_empty_blocks': 1},
1: {'non_empty_blocks': 0},
}
@staticmethod
def get_block_at(x, y, z):
return grass if (x, y, z) == (1, 5, 1) else blocks.AIR
class FakeChunks:
min_y = 0
chunks = {(0, 0): FakeChunk()}
self.g.connected = True
self.g.chunks = FakeChunks()
self.g.pos = Munch(x=1.5, y=6.0, z=1.5)
self.g.yaw = 90
response = self.client.get(
'/api/minimap?radius=4&radius_x=6&radius_z=4')
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertTrue(data['ready'])
self.assertEqual(data['center'], {'x': 1, 'y': 6, 'z': 1})
self.assertEqual(data['yaw'], 90)
self.assertEqual(data['radius_x'], 6)
self.assertEqual(data['radius_z'], 4)
self.assertEqual(len(data['cells']), 9)
self.assertEqual(len(data['cells'][0]), 13)
self.assertEqual(data['cells'][4][6], {'block': 'grass_block', 'y': 5})
def test_global_api_exposes_world_time_ticks(self):
self.g.time = 12345.5
self.g.time_base = 12345.5
+16 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
<script defer src="/static/js/dashboard.js?v=14"></script>
<link rel="stylesheet" href="/static/css/dashboard.css?v=19" />
<script defer src="/static/js/dashboard.js?v=19"></script>
</head>
<body>
<div class="shell">
@@ -72,6 +72,20 @@
</section>
<aside class="side-column">
<section id="minimap-panel" class="side-panel panel minimap-panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">WORLD · SURFACE</div><h2>Minimap</h2></div>
</div>
<canvas id="minimap-canvas" class="minimap-canvas" width="320" height="240" role="img" aria-label="Surface map centered on the bot"></canvas>
<div id="minimap-caption" class="minimap-caption">Waiting for world data</div>
<div class="minimap-controls" aria-label="Minimap controls">
<button id="minimap-zoom-out" type="button" aria-label="Zoom out" title="Zoom out">−</button>
<button id="minimap-zoom-in" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button id="minimap-north-lock" type="button" aria-label="Lock north to the top" aria-pressed="false" title="Lock north to the top">N ↑</button>
<button id="minimap-size" type="button" aria-label="Enlarge minimap" aria-pressed="false" title="Enlarge minimap">⤢</button>
</div>
</section>
<section class="side-panel panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div>
+16 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
<script defer src="/static/js/dashboard.js?v=14"></script>
<link rel="stylesheet" href="/static/css/dashboard.css?v=19" />
<script defer src="/static/js/dashboard.js?v=19"></script>
</head>
<body>
<div class="shell">
@@ -72,6 +72,20 @@
</section>
<aside class="side-column">
<section id="minimap-panel" class="side-panel panel minimap-panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">WORLD · SURFACE</div><h2>Minimap</h2></div>
</div>
<canvas id="minimap-canvas" class="minimap-canvas" width="320" height="240" role="img" aria-label="Surface map centered on the bot"></canvas>
<div id="minimap-caption" class="minimap-caption">Waiting for world data</div>
<div class="minimap-controls" aria-label="Minimap controls">
<button id="minimap-zoom-out" type="button" aria-label="Zoom out" title="Zoom out">−</button>
<button id="minimap-zoom-in" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button id="minimap-north-lock" type="button" aria-label="Lock north to the top" aria-pressed="false" title="Lock north to the top">N ↑</button>
<button id="minimap-size" type="button" aria-label="Enlarge minimap" aria-pressed="false" title="Enlarge minimap">⤢</button>
</div>
</section>
<section class="side-panel panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div>
+10
View File
@@ -136,6 +136,16 @@ h2 { margin: 3px 0 0; font-size: 20px; letter-spacing: -.01em; font-weight: 600;
.side-column { display: grid; gap: 8px; }
.side-panel { padding: 14px; }
.minimap-panel { min-width: 0; }
.minimap-panel.is-large { grid-column: 1 / -1; width: 100%; }
.minimap-canvas { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; margin: 2px auto 0; border: 1px solid #4b5549; border-radius: 2px; background: #111411; image-rendering: pixelated; }
.minimap-panel.is-large .minimap-canvas { aspect-ratio: 2 / 1; }
.minimap-caption { min-height: 14px; margin-top: 6px; color: var(--muted); text-align: center; font: 10px var(--mono); }
.minimap-controls { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; margin-top: 8px; }
.minimap-controls button { min-width: 0; min-height: 29px; padding: 3px 5px; border: 1px solid var(--border); border-radius: 3px; color: var(--text); background: #171a17; font: 11px var(--mono); cursor: pointer; }
.minimap-controls button:hover:not(:disabled), .minimap-controls button[aria-pressed="true"] { border-color: #68894e; color: var(--accent); background: #22291f; }
.minimap-controls button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.minimap-controls button:disabled { color: var(--subtle); cursor: not-allowed; }
.compact-heading { margin-bottom: 9px; }
.compact-heading h2 { font-size: 18px; }
.count-badge { min-width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 3px; color: var(--accent); font: 10px var(--mono); }
+241 -1
View File
@@ -2,6 +2,10 @@
'use strict';
const POLL_MS = 1000;
const MINIMAP_POLL_MS = 2000;
const MINIMAP_MIN_RADIUS = 4;
const MINIMAP_MAX_RADIUS = 24;
const MINIMAP_ZOOM_STEP = 4;
const $ = (id) => document.getElementById(id);
let itemNames = {};
let lastInventorySignature = '';
@@ -10,6 +14,11 @@
let selectedInventoryItem = null;
let menuAnchor = null;
let toastTimer = null;
let minimapRadius = 12;
let minimapNorthLocked = false;
let minimapLarge = false;
let minimapRequestPending = false;
let lastMinimapData = null;
const escapeHTML = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -71,6 +80,125 @@
return `${String(hour12).padStart(2, '0')}:${String(minute).padStart(2, '0')} ${period}`;
}
function minimapBlockColor(name) {
const block = String(name || '').replace(/^minecraft:/, '').toLowerCase();
if (/water|bubble_column/.test(block)) return '#3979a5';
if (/lava|magma|fire/.test(block)) return '#bd5d32';
if (/leaves|moss|grass_block|fern|azalea|flower|crop/.test(block)) return '#557c42';
if (/dirt|mud|podzol|mycelium|farmland|rooted_dirt/.test(block)) return '#806044';
if (/sand|snow|ice|clay|calcite|diorite|quartz/.test(block)) return '#c0b58b';
if (/log|wood|planks|stem|hyphae|bamboo/.test(block)) return '#896842';
if (/stone|deepslate|cobble|ore|tuff|andesite|granite|bedrock/.test(block)) return '#777d7d';
if (/netherrack|blackstone|basalt|nether/.test(block)) return '#76515a';
return '#7a806f';
}
function shadeMinimapColor(color, amount) {
const value = Number.parseInt(color.slice(1), 16);
const channels = [value >> 16, (value >> 8) & 0xff, value & 0xff]
.map((channel) => Math.max(0, Math.min(255, Math.round(channel * amount))));
return `rgb(${channels[0]} ${channels[1]} ${channels[2]})`;
}
function drawMinimap(data = lastMinimapData) {
const canvas = $('minimap-canvas');
if (!canvas) return;
const context = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
context.clearRect(0, 0, width, height);
context.fillStyle = '#111411';
context.fillRect(0, 0, width, height);
if (!data?.ready || !Array.isArray(data.cells)) {
$('minimap-caption').textContent = 'Waiting for loaded terrain';
updateMinimapControls();
return;
}
const radiusZ = Number(data.radius_z || data.radius || minimapRadius);
const radiusX = Number(data.radius_x || data.radius || minimapRadius);
const rows = radiusZ * 2 + 1;
const columns = radiusX * 2 + 1;
const cellWidth = width / columns;
const cellHeight = height / rows;
const yaw = Number(data.yaw || 0);
const mapRotation = minimapNorthLocked ? 0 : (180 - yaw) * Math.PI / 180;
context.save();
context.beginPath();
context.rect(0, 0, width, height);
context.clip();
context.translate(centerX, centerY);
context.rotate(mapRotation);
for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
const row = data.cells[rowIndex] || [];
for (let columnIndex = 0; columnIndex < columns; columnIndex++) {
const surface = row[columnIndex];
const x = -centerX + columnIndex * cellWidth;
const y = -centerY + rowIndex * cellHeight;
if (!surface) {
context.fillStyle = '#171b17';
} else {
const heightDelta = Number(surface.y) - Number(data.center?.y || 0);
const shade = Math.max(0.72, Math.min(1.28, 1 + heightDelta * 0.018));
context.fillStyle = shadeMinimapColor(
minimapBlockColor(surface.block), shade);
}
context.fillRect(x, y, Math.ceil(cellWidth) + 0.2,
Math.ceil(cellHeight) + 0.2);
}
}
context.restore();
context.save();
context.translate(centerX, centerY);
if (minimapNorthLocked) context.rotate((yaw + 180) * Math.PI / 180);
context.beginPath();
context.moveTo(0, -8);
context.lineTo(-5, 6);
context.lineTo(0, 3);
context.lineTo(5, 6);
context.closePath();
context.fillStyle = '#f1f3ef';
context.strokeStyle = '#151815';
context.lineWidth = 2;
context.fill();
context.stroke();
context.restore();
context.strokeStyle = '#566252';
context.lineWidth = 2;
context.strokeRect(1, 1, width - 2, height - 2);
$('minimap-caption').textContent =
`X ${data.center.x} · Z ${data.center.z} · ${columns}×${rows}`;
updateMinimapControls();
}
function minimapRadii() {
const canvas = $('minimap-canvas');
return {
x: Math.max(4, Math.min(48,
Math.round(minimapRadius * canvas.width / canvas.height))),
z: minimapRadius,
};
}
function updateMinimapControls() {
$('minimap-zoom-out').disabled = minimapRadius >= MINIMAP_MAX_RADIUS;
$('minimap-zoom-in').disabled = minimapRadius <= MINIMAP_MIN_RADIUS;
const northButton = $('minimap-north-lock');
northButton.setAttribute('aria-pressed', String(minimapNorthLocked));
northButton.title = minimapNorthLocked ? 'Rotate with the bot' : 'Lock north to the top';
northButton.setAttribute('aria-label', northButton.title);
const sizeButton = $('minimap-size');
sizeButton.setAttribute('aria-pressed', String(minimapLarge));
sizeButton.title = minimapLarge ? 'Reduce minimap size' : 'Enlarge minimap';
sizeButton.setAttribute('aria-label', sizeButton.title);
sizeButton.textContent = minimapLarge ? '⤡' : '⤢';
}
function textureCandidates(name) {
const id = String(name || '').replace(/^minecraft:/, '').toLowerCase();
if (!/^[a-z0-9_]+$/.test(id) || id === 'air') return [];
@@ -140,7 +268,6 @@
}
lastInventorySignature = signature;
hideItemTooltip();
closeInventoryMenu();
const grid = $('inventory-grid');
const items = [];
@@ -209,6 +336,7 @@
<div id="hotbar-inventory" class="hotbar-inventory">${slotsHTML(36, 44)}</div>
</div>
</div>`;
reanchorInventoryMenu(inv);
items.forEach(({slot, item}) => {
if (!item || !item.present) return;
@@ -374,6 +502,33 @@
positionFloating(menu, rect.left, rect.bottom + 4);
}
function reanchorInventoryMenu(inv) {
if (!selectedInventoryItem) return;
const {slot, itemId} = selectedInventoryItem;
const item = inv[String(slot)] || inv[slot];
if (!item?.present || item.item_id !== itemId) {
closeInventoryMenu();
return;
}
selectedInventoryItem.count = Number(item.item_count || 0);
const anchor = $('inventory-grid').querySelector(
`.inventory-slot[data-slot="${slot}"]`);
if (!anchor) {
closeInventoryMenu();
return;
}
menuAnchor = anchor;
anchor.setAttribute('aria-expanded', 'true');
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
$('inventory-menu-title').textContent = itemDisplayName(item, name);
$('inventory-menu-meta').textContent = `Slot ${slot} · ${selectedInventoryItem.count} item${selectedInventoryItem.count === 1 ? '' : 's'}`;
const rect = anchor.getBoundingClientRect();
const menu = $('inventory-menu');
menu.hidden = false;
positionFloating(menu, rect.left, rect.bottom + 4);
}
function showToast(message, isError = false) {
const toast = $('toast');
toast.textContent = message;
@@ -470,6 +625,10 @@
$('world-time').textContent = Number.isFinite(worldTime)
? `${Math.floor(worldTime).toLocaleString()} t · ${formatWorldClock(worldTime)}`
: '—';
if (lastMinimapData?.ready) {
lastMinimapData.yaw = Number(state.yaw || 0);
drawMinimap(lastMinimapData);
}
$('job-state').textContent = prettyName(state.job_state || 'idle').toUpperCase();
$('path-length').textContent = String(state.path?.length || 0);
$('entity-count').textContent = `${Object.keys(state.mobs || {}).length} mobs · ${Object.keys(state.objects || {}).length} drops`;
@@ -495,6 +654,43 @@
}
}
async function pollMinimap() {
if (!lastState?.connected || lastState.connection_status !== 'in_game') {
lastMinimapData = null;
drawMinimap(null);
return;
}
if (minimapRequestPending) return;
minimapRequestPending = true;
const requestedRadius = minimapRadius;
const requestedRadii = minimapRadii();
const requestKey = `${requestedRadii.x}:${requestedRadii.z}`;
try {
const response = await fetch(
`/api/minimap?radius=${requestedRadius}&radius_x=${requestedRadii.x}&radius_z=${requestedRadii.z}`,
{cache: 'no-store'},
);
if (!response.ok) throw new Error(`API ${response.status}`);
const data = await response.json();
const currentRadii = minimapRadii();
if (requestedRadius !== minimapRadius ||
requestKey !== `${currentRadii.x}:${currentRadii.z}` ||
!lastState?.connected) return;
lastMinimapData = data;
drawMinimap(data);
} catch (_) {
const currentRadii = minimapRadii();
if (requestedRadius === minimapRadius &&
requestKey === `${currentRadii.x}:${currentRadii.z}`) {
lastMinimapData = null;
drawMinimap(null);
$('minimap-caption').textContent = 'Map unavailable';
}
} finally {
minimapRequestPending = false;
}
}
async function poll() {
try {
const response = await fetch('/api/global', {cache: 'no-store'});
@@ -581,7 +777,51 @@
positionFloating($('inventory-menu'), rect.left, rect.bottom + 4);
});
$('minimap-zoom-out').addEventListener('click', () => {
minimapRadius = Math.min(MINIMAP_MAX_RADIUS,
minimapRadius + MINIMAP_ZOOM_STEP);
lastMinimapData = null;
drawMinimap();
pollMinimap();
});
$('minimap-zoom-in').addEventListener('click', () => {
minimapRadius = Math.max(MINIMAP_MIN_RADIUS,
minimapRadius - MINIMAP_ZOOM_STEP);
lastMinimapData = null;
drawMinimap();
pollMinimap();
});
$('minimap-north-lock').addEventListener('click', () => {
minimapNorthLocked = !minimapNorthLocked;
updateMinimapControls();
drawMinimap();
});
$('minimap-size').addEventListener('click', () => {
minimapLarge = !minimapLarge;
const panel = $('minimap-panel');
const canvas = $('minimap-canvas');
const contentGrid = document.querySelector('.content-grid');
const sideColumn = document.querySelector('.side-column');
if (minimapLarge) {
canvas.width = 640;
canvas.height = 320;
contentGrid.insertBefore(panel, contentGrid.firstElementChild);
} else {
canvas.width = 320;
canvas.height = 240;
sideColumn.insertBefore(panel, sideColumn.firstElementChild);
}
panel.classList.toggle('is-large', minimapLarge);
lastMinimapData = null;
updateMinimapControls();
drawMinimap();
pollMinimap();
});
loadItems();
drawMinimap(null);
poll();
pollMinimap();
window.setInterval(poll, POLL_MS);
window.setInterval(pollMinimap, MINIMAP_POLL_MS);
})();
+16 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#10130f" />
<meta name="description" content="Live Minecraft bot dashboard" />
<title>Mosfet · Minecraft Bot</title>
<link rel="stylesheet" href="/static/css/dashboard.css?v=16" />
<script defer src="/static/js/dashboard.js?v=14"></script>
<link rel="stylesheet" href="/static/css/dashboard.css?v=19" />
<script defer src="/static/js/dashboard.js?v=19"></script>
</head>
<body>
<div class="shell">
@@ -72,6 +72,20 @@
</section>
<aside class="side-column">
<section id="minimap-panel" class="side-panel panel minimap-panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">WORLD · SURFACE</div><h2>Minimap</h2></div>
</div>
<canvas id="minimap-canvas" class="minimap-canvas" width="320" height="240" role="img" aria-label="Surface map centered on the bot"></canvas>
<div id="minimap-caption" class="minimap-caption">Waiting for world data</div>
<div class="minimap-controls" aria-label="Minimap controls">
<button id="minimap-zoom-out" type="button" aria-label="Zoom out" title="Zoom out">−</button>
<button id="minimap-zoom-in" type="button" aria-label="Zoom in" title="Zoom in">+</button>
<button id="minimap-north-lock" type="button" aria-label="Lock north to the top" aria-pressed="false" title="Lock north to the top">N ↑</button>
<button id="minimap-size" type="button" aria-label="Enlarge minimap" aria-pressed="false" title="Enlarge minimap">⤢</button>
</div>
</section>
<section class="side-panel panel">
<div class="section-heading compact-heading">
<div><div class="eyebrow">MULTIPLAYER</div><h2>Players</h2></div>