SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed

This commit is contained in:
Claude committed 2026-10-01 01:15:05 +00:00
1 parent 19d111eeff
commit 280f9eaf1b
25 files changed
+1385 -341

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src/panel/web
COPY panel/web/package.json panel/web/package-lock.json ./
RUN npm ci --no-audit --no-fund
# The map viewer is shared with the launcher (imported as @scopenet/map).
COPY shared/ /src/shared/
COPY panel/web/ ./
RUN npm run build
+24 -77
View File
@@ -635,90 +635,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
if !resp.status().is_success() {
return Err("unable to load servers".into());
}
let data: serde_json::Value = resp.json().await.map_err(err)?;
let urls = data["servers"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some((s["id"].as_i64()?, s["map_address"].as_str()?.to_owned())))
.collect();
*state.map_urls.lock().unwrap() = urls;
Ok(data)
}
#[derive(Serialize)]
pub struct MapResource {
status: u16,
data: Option<String>,
etag: Option<String>,
}
/// Native transport for BlueMap settings and 2D tiles. Only paths under the
/// BlueMap address supplied by the panel can be fetched.
#[tauri::command]
pub async fn fetch_bluemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
let base = reqwest::Url::parse(&format!("{}/", configured.trim_end_matches('/'))).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
let root = base.clone();
if !matches!(target.scheme(), "http" | "https")
|| target.origin() != base.origin()
|| !target.path().starts_with(root.path())
|| target.username() != ""
|| target.password().is_some()
|| target.fragment().is_some()
{
return Err("map asset is outside the configured BlueMap site".into());
}
fetch_map_asset(target, etag, None).await
}
/// What the launcher needs to offer a Live Map button for one game server.
#[tauri::command]
pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?;
let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string());
}
resp.json().await.map_err(err)
}
async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
use base64::Engine;
if target.as_str().len() > 2048 {
return Err("map asset URL is too long".into());
/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute,
/// because the map loads them straight from the panel as images).
#[tauri::command]
pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?;
let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(err)?;
let mut request = client.get(target);
if let Some(token) = bearer {
request = request.bearer_auth(token);
let mut info: serde_json::Value = resp.json().await.map_err(err)?;
if let Some(base) = info["tile_base"].as_str().map(str::to_owned) {
info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base));
}
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
Ok(info)
}
let response = request.send().await.map_err(err)?;
let status = response.status().as_u16();
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
if status == 304 {
return Ok(MapResource { status, data: None, etag });
/// Players now, plus the claims and pins the game server last sent.
#[tauri::command]
pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?;
let resp = req.send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
}
if !response.status().is_success() {
return Ok(MapResource { status, data: None, etag });
}
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
return Err("BlueMap asset exceeds 64 MiB".into());
}
let bytes = response.bytes().await.map_err(err)?;
if bytes.len() > 64 * 1024 * 1024 {
return Err("BlueMap asset exceeds 64 MiB".into());
}
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
resp.json().await.map_err(err)
}
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -97,8 +97,8 @@ pub fn run() {
commands::get_player_stats,
commands::get_leaderboard,
commands::get_public_servers,
commands::fetch_bluemap_resource,
commands::get_livemap_info,
commands::get_map_info,
commands::get_map_overlay,
commands::get_my_level,
commands::get_my_quests,
commands::get_reward_summaries,
-4
View File
@@ -4,7 +4,6 @@ use crate::settings::Settings;
use scopenet_core::Layout;
use scopenet_shared::LauncherManifest;
use std::path::PathBuf;
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
@@ -41,8 +40,6 @@ pub struct AppState {
pub accounts: RwLock<AccountsFile>,
pub secrets: Secrets,
pub manifest: RwLock<Option<LauncherManifest>>,
/// BlueMap addresses received from the panel, keyed by linked game server.
pub map_urls: Mutex<HashMap<i64, String>>,
/// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>,
pub game: Mutex<Vec<RunningGame>>,
@@ -61,7 +58,6 @@ impl AppState {
settings: RwLock::new(settings),
accounts: RwLock::new(accounts),
manifest: RwLock::new(manifest),
map_urls: Mutex::new(HashMap::new()),
task: Mutex::new(None),
game: Mutex::new(Vec::new()),
data_dir,
@@ -1,80 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, ExternalLink, RefreshCw, LoaderCircle } from '@lucide/svelte';
import { openUrl } from '../lib/tauri';
import MapPlayerPanel from './MapPlayerPanel.svelte';
let { serverId, serverName, address, instanceId, onclose }: { serverId: number; serverName: string; address: string; instanceId: string; onclose: () => void } = $props();
let frame = $state<HTMLIFrameElement>();
let loaded = $state(false);
let selected = $state<{ uuid: string; name: string } | null>(null);
let nonce = $state(0);
const origin = $derived.by(() => { try { return new URL(address).origin; } catch { return ''; } });
// The map page (BlueMap with SCOPENET's script) reports which player was clicked. Only that page may talk to us.
function onMessage(e: MessageEvent) {
if (!frame || e.source !== frame.contentWindow || e.origin !== origin) return;
const m = e.data;
if (!m || m.source !== 'scopenet-map') return;
if (m.type === 'ready') frame.contentWindow?.postMessage({ source: 'scopenet-launcher', type: 'hello' }, origin);
else if (m.type === 'player' && typeof m.uuid === 'string' && /^[0-9a-fA-F-]{32,36}$/.test(m.uuid)) selected = { uuid: m.uuid, name: String(m.name ?? '').slice(0, 32) };
else if (m.type === 'deselect') selected = null;
}
onMount(() => {
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
});
const reload = () => { loaded = false; selected = null; nonce++; };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && (selected ? (selected = null) : onclose())} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={loaded}></span>
<div><strong>{serverName}</strong><span class="muted">Live Map</span></div>
</div>
<span class="grow"></span>
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
<button class="ghost sm" onclick={() => openUrl(address)} title="Open the same map in your browser"><ExternalLink size={14} /> Browser</button>
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#key nonce}
<iframe
bind:this={frame}
title="{serverName} live map"
src={address}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-pointer-lock"
allow="fullscreen; clipboard-write"
referrerpolicy="no-referrer"
onload={() => (loaded = true)}
></iframe>
{/key}
{#if !loaded}<div class="loading"><LoaderCircle class="spin" size={24} /> Loading the map…</div>{/if}
{#if selected}
<MapPlayerPanel {serverId} {instanceId} uuid={selected.uuid} nameHint={selected.name} onclose={() => (selected = null)} />
{/if}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.7rem; }
.title div { display: flex; flex-direction: column; line-height: 1.2; }
.title .muted { font-size: 0.75rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
.grow { flex: 1; }
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.6rem; color: var(--muted); pointer-events: none; }
</style>
+11 -10
View File
@@ -2,15 +2,15 @@
import { onMount } from 'svelte';
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte';
import { activeAccount, toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import BlueMapTiles from './BlueMapTiles.svelte';
import ClaimTerrain from './ClaimTerrain.svelte';
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
} = $props();
type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean };
type GameServer = { id: number; name: string; instance_id: string; map?: boolean };
type Player = { x: number; z: number; dimension: string };
let servers = $state<GameServer[]>([]);
let serverId = $state<number | null>(null);
@@ -32,9 +32,10 @@
async function refreshPosition(id: number) {
try {
const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id });
const info = await invoke<{ players: Array<Player & { uuid: string }> }>('get_map_overlay', { serverId: id });
if (serverId !== id) return;
player = info.me ?? null;
const mine = activeAccount()?.uuid?.toLowerCase();
player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null;
if (player && centeredServer !== id) {
centeredServer = id;
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
@@ -134,20 +135,20 @@
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
</div>
</div>
{#if selectedServer?.map_address}
{#key `${serverId}:${selectedServer.map_address}`}
<BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
{#if selectedServer?.map}
{#key serverId}
<ClaimTerrain serverId={selectedServer.id} {dimension} {centerX} {centerZ}
{claims} {myGuildId} {player} onpaint={paintChunks}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
{/key}
{:else}
<div class="notice">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</div>
<div class="notice">The map is switched off for this server. An admin can turn it on under Servers in the panel.</div>
{/if}
<div class="footer">
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
{#if painting}<span>Updating claims…</span>{/if}
{#if !player}<span class="hint">Player position appears when the server sends live positions to the panel.</span>{/if}
{#if !player}<span class="hint">Your position appears once you are in game and the server's map is running.</span>{/if}
</div>
</div>
@@ -1,113 +1,74 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke, inTauri } from '../lib/tauri';
import { invoke } from '../lib/tauri';
import type { GuildClaim } from '../lib/types';
type Resource = { status: number; data: string | null };
type Site = { maps?: string[]; mapDataRoot?: string };
type MapSettings = { lowres?: { tileSize?: [number, number]; lodFactor?: number; lodCount?: number } };
type Point = { x: number; z: number };
const cache = new Map<string, string>();
const pending = new Set<string>();
let { address, serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
address: string; serverId: number; dimension: string; centerX: number; centerZ: number;
let { serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
serverId: number; dimension: string; centerX: number; centerZ: number;
claims: GuildClaim[]; myGuildId: string | null; player: { x: number; z: number; dimension: string } | null;
onpaint: (chunks: Point[], mode: 'claim' | 'unclaim') => void;
oncenterchange: (x: number, z: number) => void;
} = $props();
const TILE = 256;
let host: HTMLDivElement;
let width = $state(800), height = $state(480);
let pixelsPerBlock = $state(3);
let worldX = $state(8), worldZ = $state(8);
let mapRoot = $state('');
let mapId = $state('');
let tileBlocks = $state(32);
let images = $state<Record<string, string>>({});
let info = $state<{ ready: boolean; message: string; tile_base: string; token: string | null; token_ttl_secs: number; dimensions: Array<{ slug: string; bounds: { min_x: number; max_x: number; min_y: number; max_y: number } | null }> } | null>(null);
let error = $state('');
let loading = $state(true);
let paint = $state<Point[]>([]);
let pointer = $state<{ id: number; mode: 'claim' | 'unclaim' | 'pan'; x: number; y: number; last: Point | null } | null>(null);
let broken = $state<Record<string, true>>({});
async function resource(url: string): Promise<Resource> {
if (inTauri) return invoke<Resource>('fetch_bluemap_resource', { serverId, url, etag: null });
const response = await fetch(url);
if (!response.ok) return { status: response.status, data: null };
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return { status: response.status, data: btoa(binary) };
}
async function json<T>(url: string): Promise<T> {
const response = await resource(url);
if (response.status !== 200 || !response.data) throw new Error(`BlueMap returned ${response.status} for settings`);
return JSON.parse(atob(response.data)) as T;
}
function tilePath(x: number, z: number): string {
const part = (n: number) => (n < 0 ? '-' : '') + Math.abs(n).toString().split('').join('/');
return `x${part(x)}/z${part(z)}.png`;
}
function chooseMap(ids: string[]): string {
const slug = dimension.replace('minecraft:', '');
if (slug === 'overworld') return ids.find((id) => !/nether|the_end|(^|[_-])end($|[_-])/i.test(id)) ?? '';
return ids.find((id) => slug === 'the_nether' ? /nether/i.test(id) : /(^|[_-])end($|[_-])/i.test(id)) ?? '';
}
const slug = $derived(dimension.replace('minecraft:', '').replace(':', '__'));
const here = $derived(info?.dimensions.find((d) => d.slug === slug) ?? null);
// Tile keys last an hour; renew well before they expire.
$effect(() => {
const url = address, dim = dimension;
let alive = true;
loading = true; error = ''; mapRoot = ''; mapId = ''; images = {};
(async () => {
const base = new URL(url.endsWith('/') ? url : `${url}/`);
const site = await json<Site>(new URL('settings.json', base).href);
const id = chooseMap(site.maps ?? []);
if (!id) throw new Error(`BlueMap has no ${dim.replace('minecraft:', '')} map`);
const root = new URL(`${(site.mapDataRoot || 'maps').replace(/\/$/, '')}/${encodeURIComponent(id)}/`, base).href;
const settings = await json<MapSettings>(new URL('settings.json', root).href);
const id = serverId;
let alive = true, timer: ReturnType<typeof setTimeout>;
loading = true; error = ''; broken = {};
const load = async () => {
try {
const next = await invoke<NonNullable<typeof info>>('get_map_info', { serverId: id });
if (!alive) return;
tileBlocks = Math.max(16, Number(settings.lowres?.tileSize?.[0] ?? 32));
mapRoot = root; mapId = id; loading = false;
})().catch((e) => { if (alive) { error = e instanceof Error ? e.message : String(e); loading = false; } });
return () => { alive = false; };
info = next; loading = false; error = next.ready ? '' : next.message;
timer = setTimeout(load, next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000);
} catch (e) {
if (!alive) return;
loading = false; error = typeof e === 'string' ? e : (e as Error)?.message ?? 'Could not load the map';
timer = setTimeout(load, 15000);
}
};
load();
return () => { alive = false; clearTimeout(timer); };
});
const tileUrl = (x: number, y: number) => `${info!.tile_base}/${slug}/0/${x}/${y}.png?t=${info!.token}`;
$effect(() => { worldX = centerX * 16 + 8; worldZ = centerZ * 16 + 8; });
const left = $derived(worldX - width / (2 * pixelsPerBlock));
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
const visibleTiles = $derived.by(() => {
if (!mapRoot) return [] as { key: string; url: string; left: number; top: number; size: number }[];
if (!info?.ready || !info.token || !here) return [] as { key: string; url: string; left: number; top: number; size: number }[];
const out = [];
const minX = Math.floor(left / tileBlocks), maxX = Math.floor((left + width / pixelsPerBlock) / tileBlocks);
const minZ = Math.floor(top / tileBlocks), maxZ = Math.floor((top + height / pixelsPerBlock) / tileBlocks);
const b = here.bounds;
const minX = Math.floor(left / TILE), maxX = Math.floor((left + width / pixelsPerBlock) / TILE);
const minZ = Math.floor(top / TILE), maxZ = Math.floor((top + height / pixelsPerBlock) / TILE);
for (let z = minZ; z <= maxZ; z++) for (let x = minX; x <= maxX; x++) {
const url = new URL(`tiles/1/${tilePath(x, z)}`, mapRoot).href;
out.push({ key: url, url, left: (x * tileBlocks - left) * pixelsPerBlock,
top: (z * tileBlocks - top) * pixelsPerBlock, size: tileBlocks * pixelsPerBlock });
if (b && (x < b.min_x || x > b.max_x || z < b.min_y || z > b.max_y)) continue;
const url = tileUrl(x, z);
if (broken[url]) continue;
out.push({ key: url, url, left: (x * TILE - left) * pixelsPerBlock, top: (z * TILE - top) * pixelsPerBlock, size: TILE * pixelsPerBlock });
}
return out;
});
$effect(() => {
const tiles = visibleTiles;
for (const tile of tiles) {
if (images[tile.key] || pending.has(tile.key)) continue;
if (cache.has(tile.key)) { images = { ...images, [tile.key]: cache.get(tile.key)! }; continue; }
pending.add(tile.key);
resource(tile.url).then((r) => {
if (r.status !== 200 || !r.data) return;
const src = `data:image/png;base64,${r.data}`;
cache.set(tile.key, src);
if (cache.size > 512) cache.delete(cache.keys().next().value!);
images = { ...images, [tile.key]: src };
}).catch(() => {}).finally(() => pending.delete(tile.key));
}
});
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
@@ -133,7 +94,7 @@
}
function down(e: PointerEvent) {
if (!mapRoot) return;
if (!info?.ready) return;
if (e.button !== 0 && e.button !== 1 && e.button !== 2) return;
e.preventDefault();
const mode = e.button === 1 || e.altKey ? 'pan' : e.button === 2 ? 'unclaim' : 'claim';
@@ -170,11 +131,11 @@
});
</script>
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="BlueMap territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="Territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
onkeydown={(e) => { if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }}
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}>
{#each visibleTiles as tile (tile.key)}
{#if images[tile.key]}<img class="tile" src={images[tile.key]} alt="" draggable="false" style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />{/if}
<img class="tile" src={tile.url} alt="" draggable="false" onerror={() => (broken[tile.url] = true)} style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />
{/each}
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
@@ -191,10 +152,10 @@
{/each}
{#if playerHere}<div class="player" title="Your position: {Math.floor(playerHere.x)}, {Math.floor(playerHere.z)}"
style:left="{(playerHere.x - left) * pixelsPerBlock}px" style:top="{(playerHere.z - top) * pixelsPerBlock}px"></div>{/if}
{#if loading}<div class="message">Loading BlueMap tiles…</div>{/if}
{#if error}<div class="message error">{error}</div>{/if}
{#if loading}<div class="message">Loading the map…</div>{/if}
{#if error && !loading}<div class="message">{error}</div>{/if}
<div class="help">Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom</div>
<div class="coords">{mapId} · {Math.floor(worldX)}, {Math.floor(worldZ)}</div>
<div class="coords">{Math.floor(worldX)}, {Math.floor(worldZ)}</div>
</div>
<style>
@@ -208,7 +169,6 @@
.preview.remove { background: #fb718590; }
.player { position: absolute; width: 15px; height: 15px; margin: -7px; border: 3px solid white; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 0 3px #2563eb, 0 2px 9px #000; pointer-events: none; }
.message { position: absolute; inset: 0; display: grid; place-items: center; color: white; background: #142333d9; pointer-events: none; }
.error { color: #fda4af; }
.help, .coords { position: absolute; bottom: 10px; padding: 7px 10px; border-radius: 7px; color: white; background: #08111ccc; font-size: 12px; pointer-events: none; }
.help { left: 10px; }.coords { right: 10px; }
</style>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, RefreshCw } from '@lucide/svelte';
import MapViewer from '@scopenet/map/MapViewer.svelte';
import { startFeed, type LivePlayer, type MapInfo, type MapOverlay } from '@scopenet/map';
import { invoke } from '../lib/tauri';
import { activeAccount, app } from '../lib/store.svelte';
import MapPlayerPanel from './MapPlayerPanel.svelte';
let { serverId, serverName, instanceId, onclose }: { serverId: number; serverName: string; instanceId: string; onclose: () => void } = $props();
let info = $state<MapInfo | null>(null);
let overlay = $state<MapOverlay | null>(null);
let error = $state('');
let nonce = $state(0);
const panel = $derived(activeAccount()?.panel_url ?? app.panelUrl);
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
const avatarUrl = (uuid: string) => (panel ? `${panel}/api/v1/avatar/${uuid}?size=64&v=${app.skinVersion}` : '');
// The feed reloads the overlay every couple of seconds and renews the tile key before it expires.
onMount(() => startFeed({
loadInfo: () => invoke<MapInfo>('get_map_info', { serverId }),
loadOverlay: () => invoke<MapOverlay>('get_map_overlay', { serverId }),
onInfo: (i) => { info = i; error = ''; },
onOverlay: (o) => { overlay = o; error = ''; },
onError: (m) => { error = m; }
}));
const reload = () => { info = null; overlay = null; nonce++; };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={!!info?.ready}></span>
<div><strong>{serverName}</strong><span class="muted">Live Map{#if overlay} · {overlay.players.length} online{/if}</span></div>
</div>
<span class="grow"></span>
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#key nonce}
<MapViewer {info} {overlay} {tileUrl} {avatarUrl} {error} selfUuid={activeAccount()?.uuid ?? null}>
{#snippet player(p: LivePlayer, close: () => void)}
<MapPlayerPanel {serverId} {instanceId} uuid={p.uuid} nameHint={p.name} onclose={close} />
{/snippet}
</MapViewer>
{/key}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.7rem; }
.title div { display: flex; flex-direction: column; line-height: 1.2; }
.title .muted { font-size: 0.75rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
.grow { flex: 1; }
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; --map-accent: var(--accent); }
</style>
+6 -4
View File
@@ -137,10 +137,12 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
await sleep(40);
switch (cmd) {
case 'get_public_servers':
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_address: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
case 'get_livemap_info':
return { enabled: true, ready: true, message: '', me: null, players: 0,
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] };
case 'get_map_info':
return { enabled: true, ready: false, message: 'Waiting for the game server to draw its world.', tile_size: 256, max_zoom: 6, tile_base: 'https://panel.example/api/map/1', token: null, token_ttl_secs: 3600, players: 0,
dimensions: [] };
case 'get_map_overlay':
return { players: [], claims: [], pins: [], updated: null };
case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
case 'get_leaderboard':
+7 -7
View File
@@ -4,7 +4,7 @@
import ServerStatus from '../components/ServerStatus.svelte';
import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte';
import BlueMapModal from '../components/BlueMapModal.svelte';
import MapModal from '../components/MapModal.svelte';
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format';
import { invoke, openUrl } from '../lib/tauri';
@@ -13,8 +13,8 @@
const b = $derived(app.manifest!.branding);
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
// A game server linked to this instance with BlueMap configured.
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
// A game server linked to this instance that draws the SCOPENET Map.
let mapServer = $state<{ id: number; name: string } | null>(null);
let mapOpen = $state(false);
$effect(() => {
const id = inst?.id;
@@ -22,8 +22,8 @@
mapOpen = false;
if (!id) return;
let alive = true;
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; })
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map?: boolean }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map) ?? null; })
.catch(() => {});
return () => { alive = false; };
});
@@ -57,7 +57,7 @@
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
<button class="glass tool" onclick={() => (app.view = 'stats')} title="Player stats & leaderboards"><Trophy size={17} /></button>
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open BlueMap"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open the live map"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
</div>
</section>
@@ -82,7 +82,7 @@
</aside>
{#if app.consoleOpen}<Console />{/if}
{#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
{#if mapOpen && mapServer}<MapModal serverId={mapServer.id} serverName={mapServer.name} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
</div>
<style>
+4 -2
View File
@@ -9,7 +9,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["svelte", "vite/client"]
"types": ["svelte", "vite/client"],
"baseUrl": ".",
"paths": { "svelte": ["./node_modules/svelte"], "svelte/*": ["./node_modules/svelte/*"], "@scopenet/map": ["../shared/map/index.ts"], "@scopenet/map/*": ["../shared/map/*"] }
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
"include": ["src/**/*.ts", "src/**/*.svelte", "../shared/map/**/*.ts", "../shared/map/**/*.svelte"]
}
+4 -1
View File
@@ -1,11 +1,14 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { fileURLToPath } from 'node:url';
// https://tauri.app/start/frontend/vite/
export default defineConfig({
plugins: [svelte()],
// The map viewer is shared with the admin panel (repo root: shared/map).
resolve: { dedupe: ['svelte'], alias: { '@scopenet/map': fileURLToPath(new URL('../shared/map', import.meta.url)) } },
clearScreen: false,
server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } },
server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] }, fs: { allow: ['..'] } },
envPrefix: ['VITE_', 'TAURI_ENV_'],
build: {
target: 'es2022',
@@ -1,52 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { MapPin, RefreshCw, Users } from '@lucide/svelte';
import { get, timeAgo } from '../lib/api';
import { toastError } from '../lib/toast.svelte';
import type { LiveMapStatus } from '../lib/types';
let { id, enabled, address = '' }: { id: number; enabled: boolean; address?: string } = $props();
let status = $state<LiveMapStatus | null>(null);
async function refresh() {
try { status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`); }
catch (e) { toastError(e); }
}
onMount(() => {
refresh();
const timer = setInterval(refresh, 10_000);
return () => clearInterval(timer);
});
</script>
<section class="card relay">
<div class="heading">
<h2><MapPin size={17} /> Live player positions</h2>
<span class="pill" class:ok={enabled}>{enabled ? 'On' : 'Off'}</span>
<button class="ghost icon" aria-label="Refresh positions" onclick={refresh}><RefreshCw size={15} /></button>
</div>
<p class="muted small">{address ? 'The launcher uses this server’s BlueMap for 2D terrain and these positions to center each player’s claim map.' : 'Add a BlueMap address to display 2D terrain in the launcher.'}</p>
{#if !enabled}
<p class="muted small">Turn on live player positions in this server’s settings to let players center the map on themselves.</p>
{:else if status}
<p class="summary"><Users size={15} /> {status.players} online {status.players === 1 ? 'position' : 'positions'}
{#if status.stats.last_players_at}<span class="muted small">· updated {timeAgo(status.stats.last_players_at)}</span>{/if}
</p>
{#if status.roster.length}
<div class="roster">
{#each status.roster as player (player.uuid)}
<span>{player.name} <small>{player.dimension.replace('minecraft:', '')} · {Math.floor(player.x)}, {Math.floor(player.z)}</small></span>
{/each}
</div>
{/if}
{/if}
</section>
<style>
.relay { display: flex; flex-direction: column; gap: 0.6rem; }
.heading, .heading h2, .summary { display: flex; align-items: center; gap: 0.5rem; }
.heading h2 { margin: 0 auto 0 0; font-size: 1rem; }
.summary { margin: 0; }
.roster { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.roster > span { display: flex; flex-direction: column; gap: 2px; padding: 0.5rem 0.7rem; border: 1px solid var(--line); border-radius: 8px; font-size: 0.83rem; }
.roster small { color: var(--muted); }
</style>
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Map as MapIcon, RefreshCw, RotateCcw, Image as ImageIcon, Users } from '@lucide/svelte';
import MapViewer from '@scopenet/map/MapViewer.svelte';
import { startFeed, type LivePlayer, type MapInfo, type MapOverlay } from '@scopenet/map';
import Avatar from './Avatar.svelte';
import PlayerLink from './PlayerLink.svelte';
import Modal from './Modal.svelte';
import { del, formatBytes, get, timeAgo } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { MapStatus } from '../lib/types';
let { id, enabled }: { id: number; enabled: boolean } = $props();
let status = $state<MapStatus | null>(null);
let info = $state<MapInfo | null>(null);
let overlay = $state<MapOverlay | null>(null);
let error = $state('');
let resetOpen = $state(false);
let resetting = $state(false);
let nonce = $state(0);
const totalTiles = $derived((status?.dimensions ?? []).reduce((a, d) => a + d.tiles, 0));
const totalBytes = $derived((status?.dimensions ?? []).reduce((a, d) => a + d.bytes, 0));
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
const avatarUrl = (uuid: string) => `/api/v1/avatar/${uuid}?size=64`;
async function refresh() {
try { status = await get<MapStatus>(`/api/admin/servers/${id}/map`); } catch (e) { toastError(e); }
}
onMount(() => {
refresh();
const timer = setInterval(refresh, 15_000);
const stop = enabled ? startFeed({
loadInfo: () => get<MapInfo>(`/api/v1/servers/${id}/map`),
loadOverlay: () => get<MapOverlay>(`/api/v1/servers/${id}/map/overlay`),
onInfo: (i) => { info = i; error = ''; },
onOverlay: (o) => { overlay = o; error = ''; },
onError: (m) => { error = m; }
}) : () => {};
return () => { clearInterval(timer); stop(); };
});
async function reset() {
resetting = true;
try {
await del(`/api/admin/servers/${id}/map`);
toast('Map cleared. The server will draw it again.');
resetOpen = false;
info = null; overlay = null; nonce++;
await refresh();
} catch (e) { toastError(e); } finally { resetting = false; }
}
</script>
<section class="card map-card">
<div class="heading">
<h2><MapIcon size={17} /> SCOPENET Map</h2>
<span class="pill" class:ok={enabled}>{enabled ? 'On' : 'Off'}</span>
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button>
<button class="ghost sm" onclick={() => (resetOpen = true)} disabled={!enabled}><RotateCcw size={14} /> Redraw world</button>
</div>
{#if !enabled}
<p class="muted small">The map is off for this server. Switch it on in the server's settings and the plugin or mod starts drawing the world.</p>
{:else}
<div class="stats">
<div><ImageIcon size={14} /><span>Tiles</span><strong>{totalTiles.toLocaleString()}</strong></div>
<div><span>Size</span><strong>{formatBytes(totalBytes)}</strong></div>
<div><Users size={14} /><span>Online</span><strong>{status?.players ?? 0}</strong></div>
<div><span>Last tiles</span><strong>{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}</strong></div>
<div><span>Last players</span><strong>{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}</strong></div>
</div>
{#if status?.dimensions.length}
<p class="dims muted small">{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}</p>
{/if}
<div class="stage">
{#key nonce}
<MapViewer {info} {overlay} {tileUrl} {avatarUrl} {error}>
{#snippet player(p: LivePlayer, close: () => void)}
<div class="pcard">
<button class="x" onclick={close} aria-label="Close">×</button>
<div class="who"><Avatar name={p.name} uuid={p.uuid} size={44} /><div><strong><PlayerLink uuid={p.uuid} name={p.name} /></strong><span>{p.dimension.replace('minecraft:', '').replace('the_', '')}</span></div></div>
<p>X {Math.round(p.x)} · Y {Math.round(p.y)} · Z {Math.round(p.z)}</p>
<code>/tp {p.name}</code>
</div>
{/snippet}
</MapViewer>
{/key}
</div>
{/if}
</section>
<Modal bind:open={resetOpen} title="Redraw the world?">
<p>This deletes every map tile stored on the panel. The game server notices and draws the world again from its region files, starting near the players. The map stays empty for a while on big worlds.</p>
<div class="actions">
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
<button class="danger" onclick={reset} disabled={resetting}>{resetting ? 'Clearing…' : 'Clear and redraw'}</button>
</div>
</Modal>
<style>
.map-card { display: flex; flex-direction: column; gap: 0.75rem; }
.heading { display: flex; align-items: center; gap: 0.5rem; }
.heading h2 { margin: 0 auto 0 0; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); gap: 0.6rem; }
.stats > div { display: flex; flex-direction: column; gap: 2px; padding: 0.55rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; font-size: 0.78rem; color: var(--muted); }
.stats strong { color: var(--text); font-size: 1.05rem; }
.dims { margin: 0; }
.stage { height: min(68vh, 640px); min-height: 380px; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); --map-accent: var(--accent); }
.pcard { height: 100%; display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; border-radius: 18px; background: rgba(14, 19, 34, 0.94); border: 1px solid rgba(255,255,255,0.12); backdrop-filter: blur(14px); color: #f4f6ff; position: relative; align-self: flex-start; height: auto; width: 100%; }
.pcard .who { display: flex; gap: 0.7rem; align-items: center; }
.pcard .who div { display: flex; flex-direction: column; }
.pcard .who span { color: #8d97b8; font-size: 0.8rem; text-transform: capitalize; }
.pcard p { margin: 0; color: #d4daf0; font-size: 0.85rem; }
.pcard code { align-self: flex-start; padding: 0.25rem 0.5rem; border-radius: 8px; background: rgba(255,255,255,0.08); font-size: 0.8rem; }
.pcard .x { position: absolute; top: 8px; right: 10px; width: 26px; height: 26px; border-radius: 50%; border: 0; background: rgba(255,255,255,0.08); color: #fff; cursor: pointer; }
.actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
</style>
+3 -4
View File
@@ -18,11 +18,10 @@
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
<label class="field">Launcher instance<select bind:value={draft.instance_id}><option value="">Unlinked</option>{#each instances as instance}<option value={instance.id}>{instance.name}</option>{/each}</select></label>
<label class="field">Map address (BlueMap)<input type="url" bind:value={draft.map_address} placeholder="https://map.example.com" autocomplete="off" /><span class="tiny muted">Where this server's BlueMap is served. Players get a <strong>Live Map</strong> button in the launcher that shows it inside the app, exactly as in a browser. Install BlueMap on the server, then leave this empty to hide the button.</span></label>
<Toggle
bind:checked={draft.live_map_enabled}
label="Live player positions"
help="The server plugin/mod sends live player positions to the panel so the launcher can center the BlueMap territory view on the player."
bind:checked={draft.map_enabled}
label="SCOPENET Map"
help="The server plugin/mod draws this world in the background and sends only what changed, with players, guild land and shops. Players get a Live Map button in the launcher and you get it here. Nothing else to install or host."
/>
<label class="field">Shared economy group<input bind:value={draft.economy_group} maxlength="32" placeholder="Leave empty for a separate economy" /><span class="tiny muted">Servers with the same group name share player balances and guild banks, so one wallet follows players across your network. Existing per-server balances aren't merged.</span></label>
<div class="field">
+7 -7
View File
@@ -93,8 +93,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
export interface GameServer {
instance_id: string;
live_map_enabled: boolean;
map_address: string;
map_enabled: boolean;
economy_group: string;
id: number;
name: string;
@@ -144,18 +143,19 @@ export interface ServerDetail {
export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] }
export interface LiveMapStatus {
/** What the admin map card shows: tiles per dimension plus when the game server last sent things. */
export interface MapStatus {
enabled: boolean;
ready: boolean;
message: string;
dimensions: { id: string; slug: string; label: string; available: boolean }[];
dimensions: { id: string; slug: string; label: string; tiles: number; bytes: number }[];
players: number;
world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] };
stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null };
epoch: number;
stats: { tiles_received: number; bytes_received: number; last_upload_at: string | null; last_players_at: string | null; last_overlay_at: string | null };
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
}
export type ServerDraft = { instance_id?: string; name: string; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
export type ServerDraft = { instance_id?: string; name: string; map_enabled: boolean; economy_group?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
export interface HostedDownload {
platform: 'windows' | 'mac' | 'linux';
+5 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import {
ArrowLeft, Map as MapIcon, Settings2, KeyRound, Trash2, Users, Gauge, Clock, UserRound, LogIn, LogOut, Skull, Award, Sword, MessageSquare,
ArrowLeft, Settings2, KeyRound, Trash2, Users, Gauge, Clock, UserRound, LogIn, LogOut, Skull, Award, Sword, MessageSquare,
CircleDot, Ban, Server,
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import IntegrationsPanel from '../components/IntegrationsPanel.svelte';
import LiveMapPanel from '../components/LiveMapPanel.svelte';
import MapPanel from '../components/MapPanel.svelte';
import Modal from '../components/Modal.svelte';
import ServerForm from '../components/ServerForm.svelte';
import TokenReveal from '../components/TokenReveal.svelte';
@@ -21,7 +21,7 @@
let sort = $state('playtime_secs');
let groups = $state<Group[]>([]);
let info = $state<AuthServerInfo | null>(null);
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: false });
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, map_enabled: false });
let editOpen = $state(false);
let token = $state<string | null>(null);
let tokenOpen = $state(false);
@@ -81,7 +81,7 @@
function openEdit() {
if (!s) return;
draft = { instance_id: s.instance_id, name: s.name, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, map_address: s.map_address, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
draft = { instance_id: s.instance_id, name: s.name, map_enabled: s.map_enabled, economy_group: s.economy_group, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
editOpen = true;
}
async function saveEdit() {
@@ -130,7 +130,6 @@
</p>
</div>
<div class="row">
{#if s.map_address}<a class="btn" href={s.map_address} target="_blank" rel="noopener noreferrer"><MapIcon size={16} /> Open map</a>{/if}
<button onclick={openEdit}><Settings2 size={16} /> Settings</button>
<button onclick={() => (regenOpen = true)}><KeyRound size={16} /> New token</button>
<button class="ghost icon" aria-label="Remove server" onclick={() => (deleteOpen = true)}><Trash2 size={16} /></button>
@@ -151,7 +150,7 @@
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
</div>
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} address={s.map_address} />
<MapPanel id={s.id} enabled={s.map_enabled} />
<IntegrationsPanel id={s.id} />
+2 -2
View File
@@ -11,7 +11,7 @@
let servers = $state<GameServer[] | null>(null);
let groups = $state<Group[]>([]);
let info = $state<AuthServerInfo | null>(null);
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: true });
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, map_enabled: true });
let createOpen = $state(false);
let creating = $state(false);
let created = $state<{ id: number; token: string } | null>(null);
@@ -29,7 +29,7 @@
const online = $derived(servers?.reduce((a, s) => a + s.players, 0) ?? 0);
function openCreate() {
draft = { name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: true };
draft = { name: '', access: 'all', allowed_groups: [], require_launcher: false, map_enabled: true };
createOpen = true;
}
+4 -2
View File
@@ -9,7 +9,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["svelte", "vite/client"]
"types": ["svelte", "vite/client"],
"baseUrl": ".",
"paths": { "svelte": ["./node_modules/svelte"], "svelte/*": ["./node_modules/svelte/*"], "@scopenet/map": ["../../shared/map/index.ts"], "@scopenet/map/*": ["../../shared/map/*"] }
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
"include": ["src/**/*.ts", "src/**/*.svelte", "../../shared/map/**/*.ts", "../../shared/map/**/*.svelte"]
}
+4
View File
@@ -1,12 +1,16 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { fileURLToPath } from 'node:url';
// In dev, API calls are proxied to a locally running panel (cargo run -p scopenet-panel).
const target = process.env.PANEL_URL ?? 'http://localhost:8080';
export default defineConfig({
plugins: [svelte()],
// The map viewer is shared with the launcher (repo root: shared/map).
resolve: { dedupe: ['svelte'], alias: { '@scopenet/map': fileURLToPath(new URL('../../shared/map', import.meta.url)) } },
server: {
fs: { allow: ['../..'] },
port: 5173,
proxy: {
'/api': target,
+238
View File
@@ -0,0 +1,238 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { MapView } from './engine';
import { dimSlug } from './types';
import type { LivePlayer, MapInfo, MapLayers, MapOverlay, MapSelection } from './types';
// The SCOPENET Map: tiles, claims, pins and players on one canvas. The parent feeds it data (see feed.ts) and decides what a
// click on a player does through the `player` snippet.
let {
info = null, overlay = null, tileUrl, avatarUrl, selfUuid = null, error = '', initialDimension = '',
player, toolbar, onplayer
}: {
info: MapInfo | null;
overlay: MapOverlay | null;
tileUrl: (slug: string, zoom: number, x: number, y: number) => string;
avatarUrl?: (uuid: string) => string;
selfUuid?: string | null;
error?: string;
initialDimension?: string;
/** Shown in the side card when a player is selected. */
player?: Snippet<[LivePlayer, () => void]>;
/** Extra buttons for the top bar. */
toolbar?: Snippet;
onplayer?: (p: LivePlayer | null) => void;
} = $props();
let canvas: HTMLCanvasElement;
let view: MapView | undefined;
let layers = $state<MapLayers>({ claims: true, pins: true, players: true });
let selection = $state<MapSelection | null>(null);
let dim = $state('');
let cursor = $state<{ x: number; z: number } | null>(null);
let hint = $state<string | null>(null);
let zoomText = $state('');
let listOpen = $state(false);
let following = $state<string | null>(null);
const dims = $derived((info?.dimensions ?? []).filter((d) => d.available));
const here = $derived((overlay?.players ?? []).filter((p) => dimSlug(p.dimension) === dim));
const everyone = $derived(overlay?.players ?? []);
const me = $derived(selfUuid ? everyone.find((p) => p.uuid.toLowerCase() === selfUuid.toLowerCase()) : undefined);
const waiting = $derived(!info || !info.ready);
const claimsHere = $derived((overlay?.claims ?? []).filter((c) => dimSlug(c.dimension) === dim).length);
const pinsHere = $derived((overlay?.pins ?? []).filter((p) => dimSlug(p.dimension) === dim).length);
onMount(() => {
view = new MapView(canvas, {
tileUrl: (s, z, x, y) => tileUrl(s, z, x, y),
avatarUrl: (u) => avatarUrl?.(u) ?? '',
selfUuid,
onselect: (s) => { selection = s; onplayer?.(s?.type === 'player' ? s.player : null); },
onhover: (h) => { cursor = h.cursor; hint = h.label; },
onview: (v) => { zoomText = v.scale >= 1 ? `${v.scale.toFixed(v.scale >= 10 ? 0 : 1)} px per block` : `${(1 / v.scale).toFixed(1).replace(/\.0$/, '')} blocks per px`; }
});
return () => view?.destroy();
});
$effect(() => { if (view && info) { view.setInfo(info); dim = view.currentDimension?.slug ?? ''; } });
$effect(() => { if (view && overlay) view.setOverlay(overlay); });
$effect(() => { view?.setLayers(layers); });
$effect(() => { view?.setSelf(selfUuid); });
$effect(() => { if (view && info && initialDimension) view.setDimension(initialDimension); });
function chooseDimension(slug: string) { view?.setDimension(slug); dim = view?.currentDimension?.slug ?? slug; following = null; }
function goTo(p: LivePlayer) {
if (dimSlug(p.dimension) !== dim) chooseDimension(p.dimension);
view?.flyTo(p.x, p.z, Math.max(view.getView().scale, 1.2));
view?.select({ type: 'player', player: p });
listOpen = false;
}
function toggleFollow(p: LivePlayer) {
following = following === p.uuid ? null : p.uuid;
view?.follow(following);
}
function findMe() { if (me) { goTo(me); } }
function toSpawn() {
const spawn = view?.pinsOfKind('spawn')[0];
if (spawn) view?.flyTo(spawn.x, spawn.z, 1);
}
const closeCard = () => { view?.select(null); following = null; view?.follow(null); };
const fmt = (n: number) => Math.round(n).toLocaleString();
</script>
<div class="map">
<canvas bind:this={canvas} aria-label="World map"></canvas>
<div class="top">
<div class="bar">
{#if dims.length > 1}
<div class="seg" role="tablist" aria-label="Dimension">
{#each dims as d}
<button role="tab" aria-selected={dim === d.slug} class:on={dim === d.slug} onclick={() => chooseDimension(d.slug)}>{d.label}</button>
{/each}
</div>
{:else if dims.length === 1}
<span class="chip solo">{dims[0].label}</span>
{/if}
<div class="chips" aria-label="Layers">
<button class="chip" class:on={layers.claims} onclick={() => (layers.claims = !layers.claims)} title="Guild land">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 4h16v16H4z" /><path d="M4 12h16M12 4v16" /></svg> Claims <b>{claimsHere}</b>
</button>
<button class="chip" class:on={layers.pins} onclick={() => (layers.pins = !layers.pins)} title="Spawn, warps, markets and shops">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21z" /><circle cx="12" cy="9.5" r="2.4" /></svg> Places <b>{pinsHere}</b>
</button>
<button class="chip" class:on={layers.players} onclick={() => (layers.players = !layers.players)} title="Players online">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-4 3.5-6 7-6s6.2 2 7 6" /></svg> Players <b>{here.length}</b>
</button>
</div>
{#if toolbar}{@render toolbar()}{/if}
</div>
<div class="right">
<button class="chip" class:on={listOpen} onclick={() => (listOpen = !listOpen)} disabled={!everyone.length}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg> Online <b>{everyone.length}</b>
</button>
{#if listOpen && everyone.length}
<ul class="plist" aria-label="Players online">
{#each everyone as p (p.uuid)}
<li>
<button onclick={() => goTo(p)}>
{#if avatarUrl}<img src={avatarUrl(p.uuid)} alt="" width="22" height="22" />{/if}
<span class="nm">{p.name}</span>
<span class="where">{dimSlug(p.dimension).replace('the_', '').replace('_', ' ')}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="bottom-left">
{#if hint}<span class="hint">{hint}</span>{/if}
<span class="coords">{#if cursor}X {fmt(cursor.x)} · Z {fmt(cursor.z)}{:else}&nbsp;{/if}<i>{zoomText}</i></span>
</div>
<div class="zoom">
{#if me}<button class="round" onclick={findMe} title="Find me" aria-label="Find me"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3.5" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" /></svg></button>{/if}
<button class="round" onclick={toSpawn} title="Go to spawn" aria-label="Go to spawn"><svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor"><path d="M12 2l2.9 6.3 6.9.7-5.2 4.6 1.5 6.8L12 17l-6.1 3.4 1.5-6.8L2.2 9l6.9-.7z" /></svg></button>
<div class="stack">
<button onclick={() => view?.zoomBy(1.6)} aria-label="Zoom in">+</button>
<button onclick={() => view?.zoomBy(1 / 1.6)} aria-label="Zoom out">−</button>
</div>
</div>
{#if selection}
<aside class="card" class:wide={selection.type === 'player' && !!player}>
<button class="x" onclick={closeCard} aria-label="Close">×</button>
{#if selection.type === 'player'}
{#if player}
{@render player(selection.player, closeCard)}
{:else}
<div class="head">
{#if avatarUrl}<img src={avatarUrl(selection.player.uuid)} alt="" width="44" height="44" />{/if}
<div><h3>{selection.player.name}</h3><span class="muted">{dimSlug(selection.player.dimension).replace('the_', '')}</span></div>
</div>
<p class="muted">X {fmt(selection.player.x)} · Y {fmt(selection.player.y)} · Z {fmt(selection.player.z)}</p>
<button class="primary" onclick={() => toggleFollow((selection as { player: LivePlayer }).player)}>{following === selection.player.uuid ? 'Stop following' : 'Follow on map'}</button>
{/if}
{:else if selection.type === 'pin'}
<h3>{selection.pin.label}</h3>
{#each selection.pin.lines as line}<p>{line}</p>{/each}
<p class="muted">X {fmt(selection.pin.x)} · Y {fmt(selection.pin.y)} · Z {fmt(selection.pin.z)}</p>
{:else}
<div class="head">
{#if selection.claim.icon}<img class="icon" src={selection.claim.icon} alt="" width="40" height="40" />{/if}
<div><h3 style:color={selection.claim.color}>{selection.claim.tag ? `[${selection.claim.tag}] ` : ''}{selection.claim.name}</h3>
<span class="muted">{selection.claim.chunks} chunk{selection.claim.chunks === 1 ? '' : 's'} here</span></div>
</div>
{#each selection.claim.lines.slice(1) as line}<p>{line}</p>{/each}
{/if}
</aside>
{/if}
{#if waiting}
<div class="veil">
<div class="spinner"></div>
<strong>{info?.enabled === false ? 'The map is off for this server' : 'Drawing the world…'}</strong>
<span>{error || info?.message || 'Connecting to the map.'}</span>
</div>
{:else if error}
<div class="toast" role="status">{error}</div>
{/if}
</div>
<style>
.map { position: relative; width: 100%; height: 100%; min-height: 320px; overflow: hidden; border-radius: inherit; background: #0d1220; color: #f4f6ff; font-family: inherit; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; }
.top { position: absolute; top: 12px; left: 12px; right: 12px; display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; pointer-events: none; }
.top > * { pointer-events: auto; }
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.seg { display: inline-flex; padding: 3px; border-radius: 999px; background: var(--map-glass, rgba(14, 19, 34, 0.82)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(12px); }
.seg button { border: 0; background: transparent; color: #aab3d0; padding: 6px 14px; border-radius: 999px; font: 600 12.5px/1 inherit; cursor: pointer; }
.seg button.on { background: var(--map-accent, #8b6cff); color: #fff; box-shadow: 0 2px 10px rgba(139, 108, 255, 0.45); }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid var(--map-line, rgba(255,255,255,0.12)); background: var(--map-glass, rgba(14, 19, 34, 0.82)); color: #aab3d0; font: 600 12.5px/1 inherit; cursor: pointer; backdrop-filter: blur(12px); transition: color .12s, border-color .12s, background .12s; }
.chip b { font-weight: 700; opacity: 0.8; }
.chip.on { color: #fff; border-color: color-mix(in srgb, var(--map-accent, #8b6cff) 70%, transparent); background: color-mix(in srgb, var(--map-accent, #8b6cff) 26%, rgba(14, 19, 34, 0.85)); }
.chip:disabled { opacity: 0.5; cursor: default; }
.chip.solo { color: #fff; cursor: default; }
.right { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.plist { list-style: none; margin: 0; padding: 6px; width: 230px; max-height: 320px; overflow: auto; border-radius: 16px; background: var(--map-glass, rgba(14, 19, 34, 0.92)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(14px); box-shadow: 0 14px 40px rgba(0,0,0,0.45); }
.plist button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: 600 13px/1 inherit; cursor: pointer; text-align: left; }
.plist button:hover { background: rgba(255,255,255,0.08); }
.plist img { border-radius: 6px; image-rendering: pixelated; }
.nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.where { font-size: 11px; color: #8d97b8; text-transform: capitalize; }
.bottom-left { position: absolute; left: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; pointer-events: none; }
.hint, .coords { padding: 6px 11px; border-radius: 999px; background: var(--map-glass, rgba(14, 19, 34, 0.82)); border: 1px solid var(--map-line, rgba(255,255,255,0.1)); font: 600 12px/1 inherit; backdrop-filter: blur(10px); }
.coords { color: #c3cbe6; font-variant-numeric: tabular-nums; }
.coords i { font-style: normal; color: #7f89ab; margin-left: 10px; }
.zoom { position: absolute; right: 12px; bottom: 12px; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
.round, .stack button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--map-line, rgba(255,255,255,0.12)); background: var(--map-glass, rgba(14, 19, 34, 0.86)); color: #e7ebff; cursor: pointer; backdrop-filter: blur(12px); font: 600 20px/1 inherit; }
.round { border-radius: 50%; }
.stack { display: flex; flex-direction: column; border-radius: 14px; overflow: hidden; border: 1px solid var(--map-line, rgba(255,255,255,0.12)); }
.stack button { border: 0; border-radius: 0; }
.stack button + button { border-top: 1px solid rgba(255,255,255,0.1); }
.round:hover, .stack button:hover { background: rgba(60, 70, 110, 0.9); }
.card { position: absolute; left: 12px; bottom: 62px; width: min(320px, calc(100% - 24px)); max-height: 55%; overflow: auto; padding: 14px 16px 16px; border-radius: 18px; background: var(--map-glass, rgba(14, 19, 34, 0.92)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(16px); box-shadow: 0 18px 50px rgba(0,0,0,0.5); animation: rise .16s ease; }
.card.wide { left: auto; right: 12px; bottom: 12px; top: 64px; width: min(340px, calc(100% - 24px)); max-height: none; padding: 0; background: transparent; border: 0; box-shadow: none; backdrop-filter: none; }
.card h3 { margin: 0 0 6px; font-size: 15px; }
.card p { margin: 3px 0; font-size: 13px; color: #d4daf0; }
.card .muted { color: #8d97b8; }
.head { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
.head img { border-radius: 10px; image-rendering: pixelated; }
.head img.icon { image-rendering: auto; border-radius: 50%; object-fit: cover; }
.x { position: absolute; top: 8px; right: 10px; width: 26px; height: 26px; border-radius: 50%; border: 0; background: rgba(255,255,255,0.08); color: #fff; font-size: 18px; line-height: 1; cursor: pointer; z-index: 2; }
.card.wide .x { display: none; }
.primary { margin-top: 10px; width: 100%; padding: 9px 14px; border: 0; border-radius: 12px; background: var(--map-accent, #8b6cff); color: #fff; font: 700 13px/1 inherit; cursor: pointer; }
.veil { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; text-align: center; padding: 24px; background: radial-gradient(circle at 50% 40%, rgba(139,108,255,0.14), rgba(9,12,22,0.92) 60%); }
.veil span { color: #98a2c4; max-width: 34ch; font-size: 13px; line-height: 1.5; }
.spinner { width: 34px; height: 34px; border-radius: 50%; border: 3px solid rgba(255,255,255,0.14); border-top-color: var(--map-accent, #8b6cff); animation: spin .9s linear infinite; margin-bottom: 6px; }
.toast { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(120, 30, 50, 0.9); font-size: 12.5px; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes rise { from { transform: translateY(6px); opacity: 0; } }
@media (max-width: 640px) { .top { flex-direction: column; } .right { align-items: flex-start; } }
</style>
+687
View File
@@ -0,0 +1,687 @@
import type { LivePlayer, MapBounds, MapClaim, MapDimension, MapInfo, MapLayers, MapOverlay, MapPin, MapSelection } from './types';
import { dimSlug } from './types';
/** A framework-free map renderer: pans and zooms the tile pyramid on a canvas and draws claims, pins and players on top. */
export interface Theme {
background: string;
grid: string;
label: string;
labelBg: string;
accent: string;
self: string;
}
const DARK: Theme = { background: '#0d1220', grid: 'rgba(255,255,255,0.045)', label: '#f4f6ff', labelBg: 'rgba(10,14,26,0.78)', accent: '#8b6cff', self: '#34d399' };
const PIN_COLORS: Record<string, string> = {
spawn: '#fbbf24', warp: '#38bdf8', home: '#a78bfa', guild_home: '#fb7185', market: '#34d399', shop: '#f59e0b'
};
export interface MapViewOptions {
tileUrl: (slug: string, zoom: number, x: number, y: number) => string;
avatarUrl?: (uuid: string) => string;
theme?: Partial<Theme>;
selfUuid?: string | null;
onselect?: (selection: MapSelection | null) => void;
onhover?: (hover: { cursor: { x: number; z: number } | null; label: string | null }) => void;
onview?: (view: { x: number; z: number; scale: number }) => void;
}
interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number }
const MIN_SCALE = 1 / 128;
const MAX_SCALE = 20;
const TILE = 256;
const MAX_LOADS = 10;
export class MapView {
private ctx: CanvasRenderingContext2D;
private theme: Theme;
private info: MapInfo | null = null;
private dimension: MapDimension | null = null;
private overlay: MapOverlay = { players: [], claims: [], pins: [] };
private layers: MapLayers = { claims: true, pins: true, players: true };
private tiles = new Map<string, Tile>();
private queue: string[] = [];
private loading = 0;
private icons = new Map<string, HTMLImageElement | 'failed'>();
private paths = new Map<string, Path2D>();
private width = 300;
private height = 200;
private dpr = 1;
private cx = 0;
private cz = 0;
private scale = 1;
private hoverId: string | null = null;
private selected: MapSelection | null = null;
private following: string | null = null;
private raf = 0;
private fly: { fromX: number; fromZ: number; fromS: number; toX: number; toZ: number; toS: number; start: number; ms: number } | null = null;
private pointers = new Map<number, { x: number; y: number }>();
private drag: { x: number; y: number; moved: number } | null = null;
private pinchStart: { dist: number; scale: number } | null = null;
private disposed = false;
private resizeObserver: ResizeObserver;
constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas is not available');
this.ctx = ctx;
this.theme = { ...DARK, ...(opts.theme ?? {}) };
canvas.style.touchAction = 'none';
canvas.addEventListener('pointerdown', this.down);
canvas.addEventListener('pointermove', this.move);
canvas.addEventListener('pointerup', this.up);
canvas.addEventListener('pointercancel', this.up);
canvas.addEventListener('pointerleave', this.leave);
canvas.addEventListener('wheel', this.wheel, { passive: false });
canvas.addEventListener('dblclick', this.dbl);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
this.resize();
}
// ---- public API --------------------------------------------------------------------------------------
setInfo(info: MapInfo) {
const tokenChanged = this.info?.token !== info.token || this.info?.tile_base !== info.tile_base;
this.info = info;
if (tokenChanged) {
// New tile key: tiles that failed earlier may work now; loaded ones stay.
for (const [k, t] of this.tiles) if (t.state !== 'ok') this.tiles.delete(k);
}
const keep = this.dimension ? info.dimensions.find((d) => d.slug === this.dimension!.slug) : undefined;
this.dimension = keep ?? info.dimensions[0] ?? null;
if (!keep && this.dimension) this.frameDimension();
this.invalidate();
}
setOverlay(overlay: MapOverlay) {
this.overlay = overlay;
this.paths.clear();
if (this.following) {
const p = overlay.players.find((q) => q.uuid === this.following);
if (p) {
if (dimSlug(p.dimension) !== this.dimension?.slug) this.setDimension(p.dimension);
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: p.x, toZ: p.z, toS: this.scale, start: performance.now(), ms: 700 };
}
}
if (this.selected?.type === 'player') {
const fresh = overlay.players.find((p) => p.uuid === (this.selected as { player: LivePlayer }).player.uuid);
if (fresh) this.selected = { type: 'player', player: fresh };
}
this.invalidate();
}
setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); }
setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); }
/** Switch dimension by id (`minecraft:the_nether`) or slug. */
setDimension(idOrSlug: string) {
const slug = dimSlug(idOrSlug);
const next = this.info?.dimensions.find((d) => d.slug === slug);
if (!next || next.slug === this.dimension?.slug) return;
this.dimension = next;
this.select(null);
this.frameDimension();
this.invalidate();
}
get currentDimension(): MapDimension | null { return this.dimension; }
follow(uuid: string | null) { this.following = uuid; if (uuid) this.invalidate(); }
flyTo(x: number, z: number, scale?: number) {
this.following = null;
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: x, toZ: z, toS: clamp(scale ?? this.scale, MIN_SCALE, MAX_SCALE), start: performance.now(), ms: 520 };
this.invalidate();
}
zoomBy(factor: number) {
this.fly = null;
this.zoomAround(this.width / 2, this.height / 2, factor, true);
}
select(selection: MapSelection | null) {
this.selected = selection;
this.opts.onselect?.(selection);
this.invalidate();
}
frameDimension() {
const b = this.dimension?.bounds;
if (!b) return;
this.fly = null;
const [minX, maxX, minZ, maxZ] = worldBounds(b);
this.cx = (minX + maxX) / 2;
this.cz = (minZ + maxZ) / 2;
const fit = Math.min(this.width / Math.max(1, maxX - minX), this.height / Math.max(1, maxZ - minZ)) * 0.9;
this.scale = clamp(fit, 0.05, 2);
this.emitView();
}
pinsOfKind(kind: string): MapPin[] { return this.overlay.pins.filter((p) => p.kind === kind && dimSlug(p.dimension) === this.dimension?.slug); }
getView() { return { x: this.cx, z: this.cz, scale: this.scale }; }
destroy() {
this.disposed = true;
cancelAnimationFrame(this.raf);
this.resizeObserver.disconnect();
const c = this.canvas;
c.removeEventListener('pointerdown', this.down);
c.removeEventListener('pointermove', this.move);
c.removeEventListener('pointerup', this.up);
c.removeEventListener('pointercancel', this.up);
c.removeEventListener('pointerleave', this.leave);
c.removeEventListener('wheel', this.wheel);
c.removeEventListener('dblclick', this.dbl);
this.tiles.clear();
}
// ---- geometry ---------------------------------------------------------------------------------------
private resize() {
const rect = this.canvas.getBoundingClientRect();
this.dpr = Math.min(window.devicePixelRatio || 1, 2.5);
this.width = Math.max(50, rect.width);
this.height = Math.max(50, rect.height);
this.canvas.width = Math.round(this.width * this.dpr);
this.canvas.height = Math.round(this.height * this.dpr);
this.invalidate();
}
private toScreen(x: number, z: number) { return { x: (x - this.cx) * this.scale + this.width / 2, y: (z - this.cz) * this.scale + this.height / 2 }; }
private toWorld(sx: number, sy: number) { return { x: (sx - this.width / 2) / this.scale + this.cx, z: (sy - this.height / 2) / this.scale + this.cz }; }
private zoomAround(sx: number, sy: number, factor: number, animate = false) {
const before = this.toWorld(sx, sy);
const target = clamp(this.scale * factor, MIN_SCALE, MAX_SCALE);
if (animate) {
// Keep the point under (sx, sy) fixed while easing the scale.
const toX = before.x - (sx - this.width / 2) / target;
const toZ = before.z - (sy - this.height / 2) / target;
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX, toZ, toS: target, start: performance.now(), ms: 220 };
} else {
this.scale = target;
this.cx = before.x - (sx - this.width / 2) / this.scale;
this.cz = before.z - (sy - this.height / 2) / this.scale;
}
this.following = null;
this.invalidate();
}
// ---- input --------------------------------------------------------------------------------------------
private localPoint(e: PointerEvent | WheelEvent | MouseEvent) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
private down = (e: PointerEvent) => {
this.canvas.setPointerCapture(e.pointerId);
const p = this.localPoint(e);
this.pointers.set(e.pointerId, p);
this.fly = null;
if (this.pointers.size === 1) this.drag = { x: p.x, y: p.y, moved: 0 };
if (this.pointers.size === 2) {
const [a, b] = [...this.pointers.values()];
this.pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale: this.scale };
this.drag = null;
}
};
private move = (e: PointerEvent) => {
const p = this.localPoint(e);
if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, p);
if (this.pinchStart && this.pointers.size === 2) {
const [a, b] = [...this.pointers.values()];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
this.zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, (this.pinchStart.scale * dist / this.pinchStart.dist) / this.scale);
return;
}
if (this.drag && this.pointers.has(e.pointerId)) {
const dx = p.x - this.drag.x, dy = p.y - this.drag.y;
this.drag.moved += Math.abs(dx) + Math.abs(dy);
this.drag.x = p.x; this.drag.y = p.y;
if (this.drag.moved > 3) {
this.cx -= dx / this.scale;
this.cz -= dy / this.scale;
this.following = null;
this.canvas.style.cursor = 'grabbing';
this.invalidate();
this.emitView();
}
return;
}
const hit = this.hitTest(p.x, p.y);
const id = hit ? hitId(hit) : null;
if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); }
this.canvas.style.cursor = hit ? 'pointer' : 'grab';
const w = this.toWorld(p.x, p.y);
this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null });
};
private up = (e: PointerEvent) => {
const wasClick = this.drag && this.drag.moved <= 3 && this.pointers.size === 1;
this.pointers.delete(e.pointerId);
if (this.pointers.size < 2) this.pinchStart = null;
if (wasClick) {
const p = this.localPoint(e);
const hit = this.hitTest(p.x, p.y);
this.select(hit);
}
if (this.pointers.size === 0) { this.drag = null; this.canvas.style.cursor = 'grab'; }
};
private leave = () => {
if (this.hoverId) { this.hoverId = null; this.invalidate(); }
this.opts.onhover?.({ cursor: null, label: null });
};
private wheel = (e: WheelEvent) => {
e.preventDefault();
const p = this.localPoint(e);
const delta = e.deltaMode === 1 ? e.deltaY * 18 : e.deltaY;
this.fly = null;
this.zoomAround(p.x, p.y, Math.exp(-delta * 0.0016));
this.emitView();
};
private dbl = (e: MouseEvent) => {
const p = this.localPoint(e);
if (this.hitTest(p.x, p.y)) return;
this.zoomAround(p.x, p.y, 2, true);
};
private emitView() { this.opts.onview?.({ x: this.cx, z: this.cz, scale: this.scale }); }
// ---- hit testing --------------------------------------------------------------------------------------
private visibleSlug() { return this.dimension?.slug ?? ''; }
private hitTest(sx: number, sy: number): MapSelection | null {
const slug = this.visibleSlug();
if (this.layers.players) {
let best: LivePlayer | null = null, bestD = 15 * 15;
for (const p of this.overlay.players) {
if (dimSlug(p.dimension) !== slug) continue;
const s = this.toScreen(p.x, p.z);
const d = (s.x - sx) ** 2 + (s.y - sy) ** 2;
if (d < bestD) { best = p; bestD = d; }
}
if (best) return { type: 'player', player: best };
}
if (this.layers.pins) {
let best: MapPin | null = null, bestD = 15 * 15;
for (const p of this.overlay.pins) {
if (dimSlug(p.dimension) !== slug) continue;
const s = this.toScreen(p.x, p.z);
const d = (s.x - sx) ** 2 + (s.y - sy) ** 2;
if (d < bestD) { best = p; bestD = d; }
}
if (best) return { type: 'pin', pin: best };
}
if (this.layers.claims) {
const w = this.toWorld(sx, sy);
for (const c of this.overlay.claims) {
if (dimSlug(c.dimension) !== slug) continue;
if (inside(c.outer, w.x, w.z) && !c.holes.some((h) => inside(h, w.x, w.z))) return { type: 'claim', claim: c };
}
}
return null;
}
// ---- tiles --------------------------------------------------------------------------------------------
private tileKey(slug: string, z: number, x: number, y: number) { return `${slug}/${z}/${x}/${y}`; }
private tileAt(slug: string, z: number, x: number, y: number, load: boolean): Tile | null {
const key = this.tileKey(slug, z, x, y);
const t = this.tiles.get(key);
if (t) {
t.used = performance.now();
if (t.state === 'missing' && load && performance.now() - t.at > 20000) { this.tiles.delete(key); return this.tileAt(slug, z, x, y, load); }
return t;
}
if (!load || !this.info?.token) return null;
const img = new Image();
img.decoding = 'async';
const tile: Tile = { img, state: 'loading', at: performance.now(), used: performance.now() };
this.tiles.set(key, tile);
this.queue.push(key);
this.pump();
this.evict();
return tile;
}
private pump() {
while (this.loading < MAX_LOADS && this.queue.length) {
const key = this.queue.pop()!; // newest first: what is on screen now matters most
const tile = this.tiles.get(key);
if (!tile || tile.state !== 'loading') continue;
const [slug, z, x, y] = key.split('/');
this.loading++;
tile.img.onload = () => { tile.state = 'ok'; this.loading--; this.pump(); this.invalidate(); };
tile.img.onerror = () => { tile.state = 'missing'; tile.at = performance.now(); this.loading--; this.pump(); };
tile.img.src = this.opts.tileUrl(slug, Number(z), Number(x), Number(y));
}
}
private evict() {
if (this.tiles.size <= 900) return;
const old = [...this.tiles.entries()].sort((a, b) => a[1].used - b[1].used).slice(0, 250);
for (const [k] of old) this.tiles.delete(k);
}
private inBounds(z: number, x: number, y: number): boolean {
const b = this.dimension?.bounds;
if (!b) return true;
const span = 2 ** z;
return x * span <= b.max_x && (x + 1) * span - 1 >= b.min_x && y * span <= b.max_y && (y + 1) * span - 1 >= b.min_y;
}
// ---- drawing ------------------------------------------------------------------------------------------
invalidate() {
if (this.disposed || this.raf) return;
this.raf = requestAnimationFrame((t) => { this.raf = 0; this.frame(t); });
}
private frame(now: number) {
if (this.fly) {
const f = this.fly;
const k = Math.min(1, (now - f.start) / f.ms);
const e = 1 - (1 - k) ** 3;
this.scale = f.fromS * (f.toS / f.fromS) ** e;
this.cx = f.fromX + (f.toX - f.fromX) * e;
this.cz = f.fromZ + (f.toZ - f.fromZ) * e;
if (k >= 1) this.fly = null;
this.emitView();
this.invalidate();
}
this.draw();
}
private draw() {
const ctx = this.ctx;
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
ctx.fillStyle = this.theme.background;
ctx.fillRect(0, 0, this.width, this.height);
if (!this.dimension || !this.info) return;
this.drawTiles(ctx);
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (this.layers.claims) this.drawClaims(ctx);
if (this.layers.pins) this.drawPins(ctx);
if (this.layers.players) this.drawPlayers(ctx);
}
private drawTiles(ctx: CanvasRenderingContext2D) {
const slug = this.dimension!.slug;
const maxZoom = this.info!.max_zoom;
const zoom = clamp(Math.floor(Math.log2(1 / this.scale)), 0, maxZoom);
const span = TILE * 2 ** zoom; // world blocks per tile
const px = span * this.scale; // screen pixels per tile
const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height);
const x0 = Math.floor(tl.x / span), x1 = Math.floor(br.x / span), y0 = Math.floor(tl.z / span), y1 = Math.floor(br.z / span);
ctx.imageSmoothingEnabled = this.scale < 1;
ctx.imageSmoothingQuality = 'high';
// A faint chunk grid when close enough to matter.
if (this.scale >= 3) this.drawGrid(ctx);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (!this.inBounds(zoom, tx, ty)) continue;
const dx = Math.floor((tx * span - this.cx) * this.scale + this.width / 2);
const dy = Math.floor((ty * span - this.cz) * this.scale + this.height / 2);
const dsize = Math.ceil(px) + 1; // overlap by a pixel so seams never show
const tile = this.tileAt(slug, zoom, tx, ty, true);
if (tile?.state === 'ok') { ctx.drawImage(tile.img, dx, dy, dsize, dsize); continue; }
// Not here yet: show the blurry parent underneath, then the real one replaces it.
for (let up = 1; zoom + up <= maxZoom; up++) {
const parent = this.tileAt(slug, zoom + up, tx >> up, ty >> up, tile?.state === 'loading' && up === 1);
if (parent?.state === 'ok') {
const part = TILE / 2 ** up;
const sx = (tx & (2 ** up - 1)) * part, sy = (ty & (2 ** up - 1)) * part;
ctx.drawImage(parent.img, sx, sy, part, part, dx, dy, dsize, dsize);
break;
}
}
}
}
}
private drawGrid(ctx: CanvasRenderingContext2D) {
const step = 16 * this.scale;
const a = this.toWorld(0, 0);
ctx.strokeStyle = this.theme.grid;
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + step; x += 16) {
const sx = Math.round((x - this.cx) * this.scale + this.width / 2) + 0.5;
ctx.moveTo(sx, 0); ctx.lineTo(sx, this.height);
}
for (let z = Math.floor(a.z / 16) * 16; (z - this.cz) * this.scale + this.height / 2 < this.height + step; z += 16) {
const sy = Math.round((z - this.cz) * this.scale + this.height / 2) + 0.5;
ctx.moveTo(0, sy); ctx.lineTo(this.width, sy);
}
ctx.stroke();
}
private claimPath(c: MapClaim): Path2D {
let p = this.paths.get(c.id);
if (!p) {
p = new Path2D();
for (const ring of [c.outer, ...c.holes]) {
ring.forEach(([x, z], i) => (i ? p!.lineTo(x, z) : p!.moveTo(x, z)));
p.closePath();
}
this.paths.set(c.id, p);
}
return p;
}
private drawClaims(ctx: CanvasRenderingContext2D) {
const slug = this.visibleSlug();
const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height);
const picked = this.selected?.type === 'claim' ? this.selected.claim.id : null;
for (const c of this.overlay.claims) {
if (dimSlug(c.dimension) !== slug || !c.outer.length) continue;
const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]);
if (Math.max(...xs) < tl.x || Math.min(...xs) > br.x || Math.max(...zs) < tl.z || Math.min(...zs) > br.z) continue;
const hot = c.id === this.hoverId || c.id === picked;
ctx.save();
ctx.setTransform(this.dpr * this.scale, 0, 0, this.dpr * this.scale, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale));
const path = this.claimPath(c);
ctx.fillStyle = withAlpha(c.color, hot ? 0.46 : 0.28);
ctx.fill(path, 'evenodd');
ctx.lineJoin = 'round';
ctx.lineWidth = (hot ? 3 : 2) / this.scale;
ctx.strokeStyle = withAlpha(c.color, 0.95);
ctx.stroke(path);
ctx.restore();
}
// Labels on top of all shapes, once they are big enough to read.
for (const c of this.overlay.claims) {
if (dimSlug(c.dimension) !== slug || !c.outer.length) continue;
const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]);
const w = (Math.max(...xs) - Math.min(...xs)) * this.scale, h = (Math.max(...zs) - Math.min(...zs)) * this.scale;
if (w < 46 || h < 30) continue;
const s = this.toScreen(c.label_x, c.label_z);
if (s.x < -80 || s.y < -40 || s.x > this.width + 80 || s.y > this.height + 40) continue;
const showName = w > 150 && h > 50;
this.chip(ctx, s.x, s.y, showName ? `${c.tag ? `[${c.tag}] ` : ''}${c.name}` : c.tag || c.name, c.color, c.icon);
}
}
private drawPins(ctx: CanvasRenderingContext2D) {
const slug = this.visibleSlug();
const picked = this.selected?.type === 'pin' ? this.selected.pin.id : null;
for (const p of this.overlay.pins) {
if (dimSlug(p.dimension) !== slug) continue;
const s = this.toScreen(p.x, p.z);
if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue;
const hot = p.id === this.hoverId || p.id === picked;
const color = PIN_COLORS[p.kind] ?? '#94a3b8';
const r = hot ? 14 : 12;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2);
ctx.fillStyle = color; ctx.fill();
ctx.restore();
ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke();
drawGlyph(ctx, p.kind, s.x, s.y, r * 0.58);
if (hot || this.scale >= 0.9) this.tag(ctx, s.x, s.y + r + 9, p.label);
}
}
private drawPlayers(ctx: CanvasRenderingContext2D) {
const slug = this.visibleSlug();
const picked = this.selected?.type === 'player' ? this.selected.player.uuid : null;
for (const p of this.overlay.players) {
if (dimSlug(p.dimension) !== slug) continue;
const s = this.toScreen(p.x, p.z);
if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue;
const me = !!this.opts.selfUuid && p.uuid.toLowerCase() === this.opts.selfUuid.toLowerCase();
const hot = p.uuid === this.hoverId || p.uuid === picked;
const ring = me ? this.theme.self : this.theme.accent;
const r = hot ? 14 : 12;
// facing direction: Minecraft yaw 0 = south (+z), 90 = west (-x)
const a = (p.yaw * Math.PI) / 180;
const fx = -Math.sin(a), fz = Math.cos(a);
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(Math.atan2(fz, fx));
ctx.beginPath(); ctx.moveTo(r + 9, 0); ctx.lineTo(r + 1, -5); ctx.lineTo(r + 1, 5); ctx.closePath();
ctx.fillStyle = ring; ctx.fill();
ctx.restore();
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2);
ctx.fillStyle = ring; ctx.fill();
ctx.restore();
const img = this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null;
ctx.save();
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip();
if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); }
else { ctx.fillStyle = '#1e293b'; ctx.fillRect(s.x - r, s.y - r, r * 2, r * 2); ctx.fillStyle = '#fff'; ctx.font = '700 12px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText((p.name[0] ?? '?').toUpperCase(), s.x, s.y + 1); }
ctx.restore();
this.tag(ctx, s.x, s.y - r - 12, p.name, me ? this.theme.self : undefined);
}
}
private icon(url: string): HTMLImageElement | null {
if (!url) return null;
const got = this.icons.get(url);
if (got === 'failed') return null;
if (got) return got.complete && got.naturalWidth ? got : null;
const img = new Image();
img.onload = () => this.invalidate();
img.onerror = () => this.icons.set(url, 'failed');
img.src = url;
this.icons.set(url, img);
return null;
}
/** A small rounded label with an optional round icon, centred on (x, y). */
private chip(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color: string, iconUrl: string) {
ctx.font = '700 12px system-ui, -apple-system, Segoe UI, sans-serif';
const img = iconUrl ? this.icon(iconUrl) : null;
const iconW = img ? 20 : 0;
const w = ctx.measureText(text).width + 18 + iconW, h = 24;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
roundRect(ctx, x - w / 2, y - h / 2, w, h, 12);
ctx.fillStyle = this.theme.labelBg; ctx.fill();
ctx.restore();
ctx.lineWidth = 1.5; ctx.strokeStyle = withAlpha(color, 0.9); ctx.stroke();
let left = x - w / 2 + 9;
if (img) {
ctx.save(); ctx.beginPath(); ctx.arc(left + 8, y, 8, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, left, y - 8, 16, 16); ctx.restore();
left += 20;
}
ctx.fillStyle = this.theme.label; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(text, left, y + 0.5);
}
private tag(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color?: string) {
ctx.font = '600 11px system-ui, -apple-system, Segoe UI, sans-serif';
const w = ctx.measureText(text).width + 12;
roundRect(ctx, x - w / 2, y - 9, w, 18, 9);
ctx.fillStyle = this.theme.labelBg; ctx.fill();
if (color) { ctx.lineWidth = 1; ctx.strokeStyle = color; ctx.stroke(); }
ctx.fillStyle = this.theme.label; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, x, y + 0.5);
}
}
// ---- helpers --------------------------------------------------------------------------------------------
function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
/** Zoom-0 tile bounds -> world block bounds [minX, maxX, minZ, maxZ]. */
function worldBounds(b: MapBounds): [number, number, number, number] {
return [b.min_x * TILE, (b.max_x + 1) * TILE, b.min_y * TILE, (b.max_y + 1) * TILE];
}
function hitId(s: MapSelection): string { return s.type === 'player' ? s.player.uuid : s.type === 'pin' ? s.pin.id : s.claim.id; }
function hitLabel(s: MapSelection): string { return s.type === 'player' ? s.player.name : s.type === 'pin' ? s.pin.label : `${s.claim.tag ? `[${s.claim.tag}] ` : ''}${s.claim.name}`; }
function inside(ring: [number, number][], x: number, z: number): boolean {
let in_ = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [ax, az] = ring[i], [bx, bz] = ring[j];
if ((az > z) !== (bz > z) && x < ((bx - ax) * (z - az)) / (bz - az) + ax) in_ = !in_;
}
return in_;
}
export function withAlpha(color: string, alpha: number): string {
const m = /^#?([0-9a-f]{6})$/i.exec(color.trim());
if (!m) return color;
const n = parseInt(m[1], 16);
return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/** A tiny white symbol for each kind of pin, drawn as shapes so no icon font is needed. */
function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: number, r: number) {
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#fff';
ctx.beginPath();
switch (kind) {
case 'spawn': // star
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r * 1.15; ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); }
ctx.closePath(); ctx.fill();
break;
case 'warp': // diamond with a hole
ctx.moveTo(0, -r * 1.2); ctx.lineTo(r * 0.95, 0); ctx.lineTo(0, r * 1.2); ctx.lineTo(-r * 0.95, 0); ctx.closePath(); ctx.fill();
break;
case 'home': // house
ctx.moveTo(-r * 1.1, 0); ctx.lineTo(0, -r * 1.1); ctx.lineTo(r * 1.1, 0); ctx.lineTo(r * 0.7, 0); ctx.lineTo(r * 0.7, r); ctx.lineTo(-r * 0.7, r); ctx.lineTo(-r * 0.7, 0); ctx.closePath(); ctx.fill();
break;
case 'guild_home': // flag
ctx.fillRect(-r * 0.8, -r * 1.1, r * 0.28, r * 2.2);
ctx.moveTo(-r * 0.52, -r * 1.1); ctx.lineTo(r * 1.0, -r * 0.5); ctx.lineTo(-r * 0.52, 0.1 * r); ctx.closePath(); ctx.fill();
break;
case 'market': // $
ctx.font = `800 ${Math.round(r * 2.3)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('$', 0, r * 0.1);
break;
default: // shop: a bag
ctx.moveTo(-r * 0.9, -r * 0.3); ctx.lineTo(r * 0.9, -r * 0.3); ctx.lineTo(r * 0.7, r * 1.0); ctx.lineTo(-r * 0.7, r * 1.0); ctx.closePath(); ctx.fill();
ctx.lineWidth = Math.max(1.2, r * 0.22); ctx.beginPath(); ctx.arc(0, -r * 0.3, r * 0.5, Math.PI, 0); ctx.stroke();
}
ctx.restore();
}
+51
View File
@@ -0,0 +1,51 @@
import type { MapInfo, MapOverlay } from './types';
export interface FeedOptions {
loadInfo: () => Promise<MapInfo>;
loadOverlay: () => Promise<MapOverlay>;
onInfo: (info: MapInfo) => void;
onOverlay: (overlay: MapOverlay) => void;
onError?: (message: string) => void;
/** How often players move on the map. */
overlayMs?: number;
}
/**
* Keeps a map fresh: the overlay (players, claims, pins) every couple of seconds while the window is visible, and the info
* (which carries the tile key) when it is about to expire. Returns a function that stops everything.
*/
export function startFeed(o: FeedOptions): () => void {
let stopped = false;
let infoTimer: ReturnType<typeof setTimeout> | undefined;
let overlayTimer: ReturnType<typeof setTimeout> | undefined;
let ready = false;
const fail = (e: unknown) => o.onError?.(typeof e === 'string' ? e : (e as { message?: string })?.message ?? 'Could not reach the map');
async function info() {
try {
const next = await o.loadInfo();
if (stopped) return;
ready = next.ready;
o.onInfo(next);
// The tile key is good for an hour: renew it with plenty of time to spare. While waiting for the first tiles, check often.
const wait = next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000;
infoTimer = setTimeout(info, wait);
} catch (e) {
fail(e);
if (!stopped) infoTimer = setTimeout(info, 10000);
}
}
async function overlay() {
if (stopped) return;
if (ready && !document.hidden) {
try { o.onOverlay(await o.loadOverlay()); } catch (e) { fail(e); }
}
if (!stopped) overlayTimer = setTimeout(overlay, o.overlayMs ?? 2500);
}
info();
overlayTimer = setTimeout(overlay, 600);
return () => { stopped = true; clearTimeout(infoTimer); clearTimeout(overlayTimer); };
}
+6
View File
@@ -0,0 +1,6 @@
export { MapView, withAlpha } from './engine';
export type { MapViewOptions, Theme } from './engine';
export { startFeed } from './feed';
export type { FeedOptions } from './feed';
export { dimSlug } from './types';
export type * from './types';
+89
View File
@@ -0,0 +1,89 @@
// What the panel's map endpoints return (see panel/server/src/routes/worldmap.rs), shared by the launcher and the admin panel.
export interface MapBounds { min_x: number; max_x: number; min_y: number; max_y: number }
export interface MapDimension {
id: string;
slug: string;
label: string;
available: boolean;
tiles: number;
bytes: number;
bounds: MapBounds | null;
}
export interface MapInfo {
enabled: boolean;
ready: boolean;
message: string;
tile_size: number;
max_zoom: number;
/** Where tiles live, e.g. `https://panel.example.com/api/map/3`. */
tile_base: string;
/** Short-lived key that goes on each tile address. */
token: string | null;
token_ttl_secs: number;
dimensions: MapDimension[];
players: number;
}
export interface LivePlayer {
uuid: string;
name: string;
dimension: string;
x: number;
y: number;
z: number;
yaw: number;
}
export interface MapClaim {
id: string;
guild_id: string;
name: string;
tag: string;
icon: string;
color: string;
dimension: string;
chunks: number;
label_x: number;
label_z: number;
outer: [number, number][];
holes: [number, number][][];
lines: string[];
}
export type PinKind = 'spawn' | 'warp' | 'home' | 'guild_home' | 'market' | 'shop';
export interface MapPin {
id: string;
kind: PinKind | string;
label: string;
dimension: string;
x: number;
y: number;
z: number;
lines: string[];
}
export interface MapOverlay {
players: LivePlayer[];
claims: MapClaim[];
pins: MapPin[];
updated?: string | null;
}
export type MapSelection =
| { type: 'player'; player: LivePlayer }
| { type: 'pin'; pin: MapPin }
| { type: 'claim'; claim: MapClaim };
export interface MapLayers { claims: boolean; pins: boolean; players: boolean }
/** Same naming as the panel: `minecraft:the_nether` -> `the_nether`, `mod:dim` -> `mod__dim`. */
export function dimSlug(id: string): string {
const trimmed = id.startsWith('minecraft:') ? id.slice(10) : id;
if (trimmed === 'nether') return 'the_nether';
if (trimmed === 'end') return 'the_end';
return trimmed.replace(':', '__');
}