Add terrain minimap to dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user