SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed
This commit is contained in:
25 files changed
+1385
-341
No files matched your search
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
|||||||
WORKDIR /src/panel/web
|
WORKDIR /src/panel/web
|
||||||
COPY panel/web/package.json panel/web/package-lock.json ./
|
COPY panel/web/package.json panel/web/package-lock.json ./
|
||||||
RUN npm ci --no-audit --no-fund
|
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/ ./
|
COPY panel/web/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -635,90 +635,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
|
|||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err("unable to load servers".into());
|
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)
|
resp.json().await.map_err(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
|
/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute,
|
||||||
use base64::Engine;
|
/// because the map loads them straight from the panel as images).
|
||||||
if target.as_str().len() > 2048 {
|
#[tauri::command]
|
||||||
return Err("map asset URL is too long".into());
|
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()
|
let mut info: serde_json::Value = resp.json().await.map_err(err)?;
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
if let Some(base) = info["tile_base"].as_str().map(str::to_owned) {
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base));
|
||||||
.build()
|
|
||||||
.map_err(err)?;
|
|
||||||
let mut request = client.get(target);
|
|
||||||
if let Some(token) = bearer {
|
|
||||||
request = request.bearer_auth(token);
|
|
||||||
}
|
}
|
||||||
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
Ok(info)
|
||||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
|
||||||
}
|
}
|
||||||
let response = request.send().await.map_err(err)?;
|
|
||||||
let status = response.status().as_u16();
|
/// Players now, plus the claims and pins the game server last sent.
|
||||||
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
#[tauri::command]
|
||||||
if status == 304 {
|
pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||||
return Ok(MapResource { status, data: None, etag });
|
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() {
|
resp.json().await.map_err(err)
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ pub fn run() {
|
|||||||
commands::get_player_stats,
|
commands::get_player_stats,
|
||||||
commands::get_leaderboard,
|
commands::get_leaderboard,
|
||||||
commands::get_public_servers,
|
commands::get_public_servers,
|
||||||
commands::fetch_bluemap_resource,
|
commands::get_map_info,
|
||||||
commands::get_livemap_info,
|
commands::get_map_overlay,
|
||||||
commands::get_my_level,
|
commands::get_my_level,
|
||||||
commands::get_my_quests,
|
commands::get_my_quests,
|
||||||
commands::get_reward_summaries,
|
commands::get_reward_summaries,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use crate::settings::Settings;
|
|||||||
use scopenet_core::Layout;
|
use scopenet_core::Layout;
|
||||||
use scopenet_shared::LauncherManifest;
|
use scopenet_shared::LauncherManifest;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Mutex, RwLock};
|
use std::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
|
/// 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 accounts: RwLock<AccountsFile>,
|
||||||
pub secrets: Secrets,
|
pub secrets: Secrets,
|
||||||
pub manifest: RwLock<Option<LauncherManifest>>,
|
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).
|
/// Install/launch pipeline in progress (abortable).
|
||||||
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||||
pub game: Mutex<Vec<RunningGame>>,
|
pub game: Mutex<Vec<RunningGame>>,
|
||||||
@@ -61,7 +58,6 @@ impl AppState {
|
|||||||
settings: RwLock::new(settings),
|
settings: RwLock::new(settings),
|
||||||
accounts: RwLock::new(accounts),
|
accounts: RwLock::new(accounts),
|
||||||
manifest: RwLock::new(manifest),
|
manifest: RwLock::new(manifest),
|
||||||
map_urls: Mutex::new(HashMap::new()),
|
|
||||||
task: Mutex::new(None),
|
task: Mutex::new(None),
|
||||||
game: Mutex::new(Vec::new()),
|
game: Mutex::new(Vec::new()),
|
||||||
data_dir,
|
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>
|
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
|
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
|
||||||
import { invoke } from '../lib/tauri';
|
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 type { GuildClaim } from '../lib/types';
|
||||||
import BlueMapTiles from './BlueMapTiles.svelte';
|
import ClaimTerrain from './ClaimTerrain.svelte';
|
||||||
|
|
||||||
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
|
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
|
||||||
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
|
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
|
||||||
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
|
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
|
||||||
} = $props();
|
} = $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 };
|
type Player = { x: number; z: number; dimension: string };
|
||||||
let servers = $state<GameServer[]>([]);
|
let servers = $state<GameServer[]>([]);
|
||||||
let serverId = $state<number | null>(null);
|
let serverId = $state<number | null>(null);
|
||||||
@@ -32,9 +32,10 @@
|
|||||||
|
|
||||||
async function refreshPosition(id: number) {
|
async function refreshPosition(id: number) {
|
||||||
try {
|
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;
|
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) {
|
if (player && centeredServer !== id) {
|
||||||
centeredServer = id;
|
centeredServer = id;
|
||||||
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
|
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>
|
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if selectedServer?.map_address}
|
{#if selectedServer?.map}
|
||||||
{#key `${serverId}:${selectedServer.map_address}`}
|
{#key serverId}
|
||||||
<BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
<ClaimTerrain serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
||||||
{claims} {myGuildId} {player} onpaint={paintChunks}
|
{claims} {myGuildId} {player} onpaint={paintChunks}
|
||||||
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
|
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
|
||||||
{/key}
|
{/key}
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
|
<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>
|
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
|
||||||
{#if painting}<span>Updating claims…</span>{/if}
|
{#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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+41
-81
@@ -1,113 +1,74 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke, inTauri } from '../lib/tauri';
|
import { invoke } from '../lib/tauri';
|
||||||
import type { GuildClaim } from '../lib/types';
|
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 };
|
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 }: {
|
let { serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
|
||||||
address: string; serverId: number; dimension: string; centerX: number; centerZ: number;
|
serverId: number; dimension: string; centerX: number; centerZ: number;
|
||||||
claims: GuildClaim[]; myGuildId: string | null; player: { x: number; z: number; dimension: string } | null;
|
claims: GuildClaim[]; myGuildId: string | null; player: { x: number; z: number; dimension: string } | null;
|
||||||
onpaint: (chunks: Point[], mode: 'claim' | 'unclaim') => void;
|
onpaint: (chunks: Point[], mode: 'claim' | 'unclaim') => void;
|
||||||
oncenterchange: (x: number, z: number) => void;
|
oncenterchange: (x: number, z: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const TILE = 256;
|
||||||
let host: HTMLDivElement;
|
let host: HTMLDivElement;
|
||||||
let width = $state(800), height = $state(480);
|
let width = $state(800), height = $state(480);
|
||||||
let pixelsPerBlock = $state(3);
|
let pixelsPerBlock = $state(3);
|
||||||
let worldX = $state(8), worldZ = $state(8);
|
let worldX = $state(8), worldZ = $state(8);
|
||||||
let mapRoot = $state('');
|
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 mapId = $state('');
|
|
||||||
let tileBlocks = $state(32);
|
|
||||||
let images = $state<Record<string, string>>({});
|
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let paint = $state<Point[]>([]);
|
let paint = $state<Point[]>([]);
|
||||||
let pointer = $state<{ id: number; mode: 'claim' | 'unclaim' | 'pan'; x: number; y: number; last: Point | null } | null>(null);
|
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> {
|
const slug = $derived(dimension.replace('minecraft:', '').replace(':', '__'));
|
||||||
if (inTauri) return invoke<Resource>('fetch_bluemap_resource', { serverId, url, etag: null });
|
const here = $derived(info?.dimensions.find((d) => d.slug === slug) ?? 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)) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Tile keys last an hour; renew well before they expire.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const url = address, dim = dimension;
|
const id = serverId;
|
||||||
let alive = true;
|
let alive = true, timer: ReturnType<typeof setTimeout>;
|
||||||
loading = true; error = ''; mapRoot = ''; mapId = ''; images = {};
|
loading = true; error = ''; broken = {};
|
||||||
(async () => {
|
const load = async () => {
|
||||||
const base = new URL(url.endsWith('/') ? url : `${url}/`);
|
try {
|
||||||
const site = await json<Site>(new URL('settings.json', base).href);
|
const next = await invoke<NonNullable<typeof info>>('get_map_info', { serverId: id });
|
||||||
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);
|
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
tileBlocks = Math.max(16, Number(settings.lowres?.tileSize?.[0] ?? 32));
|
info = next; loading = false; error = next.ready ? '' : next.message;
|
||||||
mapRoot = root; mapId = id; loading = false;
|
timer = setTimeout(load, next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000);
|
||||||
})().catch((e) => { if (alive) { error = e instanceof Error ? e.message : String(e); loading = false; } });
|
} catch (e) {
|
||||||
return () => { alive = false; };
|
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; });
|
$effect(() => { worldX = centerX * 16 + 8; worldZ = centerZ * 16 + 8; });
|
||||||
|
|
||||||
const left = $derived(worldX - width / (2 * pixelsPerBlock));
|
const left = $derived(worldX - width / (2 * pixelsPerBlock));
|
||||||
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
|
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
|
||||||
const visibleTiles = $derived.by(() => {
|
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 out = [];
|
||||||
const minX = Math.floor(left / tileBlocks), maxX = Math.floor((left + width / pixelsPerBlock) / tileBlocks);
|
const b = here.bounds;
|
||||||
const minZ = Math.floor(top / tileBlocks), maxZ = Math.floor((top + height / pixelsPerBlock) / tileBlocks);
|
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++) {
|
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;
|
if (b && (x < b.min_x || x > b.max_x || z < b.min_y || z > b.max_y)) continue;
|
||||||
out.push({ key: url, url, left: (x * tileBlocks - left) * pixelsPerBlock,
|
const url = tileUrl(x, z);
|
||||||
top: (z * tileBlocks - top) * pixelsPerBlock, size: tileBlocks * pixelsPerBlock });
|
if (broken[url]) continue;
|
||||||
|
out.push({ key: url, url, left: (x * TILE - left) * pixelsPerBlock, top: (z * TILE - top) * pixelsPerBlock, size: TILE * pixelsPerBlock });
|
||||||
}
|
}
|
||||||
return out;
|
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 &&
|
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
|
||||||
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
|
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));
|
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
|
||||||
@@ -133,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function down(e: PointerEvent) {
|
function down(e: PointerEvent) {
|
||||||
if (!mapRoot) return;
|
if (!info?.ready) return;
|
||||||
if (e.button !== 0 && e.button !== 1 && e.button !== 2) return;
|
if (e.button !== 0 && e.button !== 1 && e.button !== 2) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const mode = e.button === 1 || e.altKey ? 'pan' : e.button === 2 ? 'unclaim' : 'claim';
|
const mode = e.button === 1 || e.altKey ? 'pan' : e.button === 2 ? 'unclaim' : 'claim';
|
||||||
@@ -170,11 +131,11 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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'); }}
|
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()}>
|
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}>
|
||||||
{#each visibleTiles as tile (tile.key)}
|
{#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}
|
{/each}
|
||||||
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
|
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
|
||||||
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
|
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
|
||||||
@@ -191,10 +152,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#if playerHere}<div class="player" title="Your position: {Math.floor(playerHere.x)}, {Math.floor(playerHere.z)}"
|
{#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}
|
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 loading}<div class="message">Loading the map…</div>{/if}
|
||||||
{#if error}<div class="message error">{error}</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="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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -208,7 +169,6 @@
|
|||||||
.preview.remove { background: #fb718590; }
|
.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; }
|
.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; }
|
.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, .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; }
|
.help { left: 10px; }.coords { right: 10px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -137,10 +137,12 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
|
|||||||
await sleep(40);
|
await sleep(40);
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case 'get_public_servers':
|
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 }] };
|
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] };
|
||||||
case 'get_livemap_info':
|
case 'get_map_info':
|
||||||
return { enabled: true, ready: true, message: '', me: null, players: 0,
|
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: [{ 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 }] };
|
dimensions: [] };
|
||||||
|
case 'get_map_overlay':
|
||||||
|
return { players: [], claims: [], pins: [], updated: null };
|
||||||
case 'get_player_stats':
|
case 'get_player_stats':
|
||||||
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
|
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
|
||||||
case 'get_leaderboard':
|
case 'get_leaderboard':
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import ServerStatus from '../components/ServerStatus.svelte';
|
import ServerStatus from '../components/ServerStatus.svelte';
|
||||||
import NewsFeed from '../components/NewsFeed.svelte';
|
import NewsFeed from '../components/NewsFeed.svelte';
|
||||||
import Console from '../components/Console.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 { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
|
||||||
import { loaderName, shortLoaderVersion } from '../lib/format';
|
import { loaderName, shortLoaderVersion } from '../lib/format';
|
||||||
import { invoke, openUrl } from '../lib/tauri';
|
import { invoke, openUrl } from '../lib/tauri';
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
const b = $derived(app.manifest!.branding);
|
const b = $derived(app.manifest!.branding);
|
||||||
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
|
// 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 };
|
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.
|
// A game server linked to this instance that draws the SCOPENET Map.
|
||||||
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
|
let mapServer = $state<{ id: number; name: string } | null>(null);
|
||||||
let mapOpen = $state(false);
|
let mapOpen = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = inst?.id;
|
const id = inst?.id;
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
mapOpen = false;
|
mapOpen = false;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers')
|
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_address) ?? null; })
|
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map) ?? null; })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { alive = false; };
|
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" 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" 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>
|
<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}
|
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#if app.consoleOpen}<Console />{/if}
|
{#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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"verbatimModuleSyntax": 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"]
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
// https://tauri.app/start/frontend/vite/
|
// https://tauri.app/start/frontend/vite/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
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,
|
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_'],
|
envPrefix: ['VITE_', 'TAURI_ENV_'],
|
||||||
build: {
|
build: {
|
||||||
target: 'es2022',
|
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>
|
|
||||||
@@ -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>
|
||||||
@@ -18,11 +18,10 @@
|
|||||||
|
|
||||||
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
|
<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">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
|
<Toggle
|
||||||
bind:checked={draft.live_map_enabled}
|
bind:checked={draft.map_enabled}
|
||||||
label="Live player positions"
|
label="SCOPENET Map"
|
||||||
help="The server plugin/mod sends live player positions to the panel so the launcher can center the BlueMap territory view on the player."
|
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>
|
<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">
|
<div class="field">
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
|
|||||||
|
|
||||||
export interface GameServer {
|
export interface GameServer {
|
||||||
instance_id: string;
|
instance_id: string;
|
||||||
live_map_enabled: boolean;
|
map_enabled: boolean;
|
||||||
map_address: string;
|
|
||||||
economy_group: string;
|
economy_group: string;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -144,18 +143,19 @@ export interface ServerDetail {
|
|||||||
|
|
||||||
export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] }
|
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;
|
enabled: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
dimensions: { id: string; slug: string; label: string; available: boolean }[];
|
dimensions: { id: string; slug: string; label: string; tiles: number; bytes: number }[];
|
||||||
players: number;
|
players: number;
|
||||||
world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] };
|
epoch: number;
|
||||||
stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null };
|
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 }[];
|
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 {
|
export interface HostedDownload {
|
||||||
platform: 'windows' | 'mac' | 'linux';
|
platform: 'windows' | 'mac' | 'linux';
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PlayerLink from '../components/PlayerLink.svelte';
|
import PlayerLink from '../components/PlayerLink.svelte';
|
||||||
import {
|
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,
|
CircleDot, Ban, Server,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import Avatar from '../components/Avatar.svelte';
|
import Avatar from '../components/Avatar.svelte';
|
||||||
import IntegrationsPanel from '../components/IntegrationsPanel.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 Modal from '../components/Modal.svelte';
|
||||||
import ServerForm from '../components/ServerForm.svelte';
|
import ServerForm from '../components/ServerForm.svelte';
|
||||||
import TokenReveal from '../components/TokenReveal.svelte';
|
import TokenReveal from '../components/TokenReveal.svelte';
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
let sort = $state('playtime_secs');
|
let sort = $state('playtime_secs');
|
||||||
let groups = $state<Group[]>([]);
|
let groups = $state<Group[]>([]);
|
||||||
let info = $state<AuthServerInfo | null>(null);
|
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 editOpen = $state(false);
|
||||||
let token = $state<string | null>(null);
|
let token = $state<string | null>(null);
|
||||||
let tokenOpen = $state(false);
|
let tokenOpen = $state(false);
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
function openEdit() {
|
function openEdit() {
|
||||||
if (!s) return;
|
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;
|
editOpen = true;
|
||||||
}
|
}
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
@@ -130,7 +130,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<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={openEdit}><Settings2 size={16} /> Settings</button>
|
||||||
<button onclick={() => (regenOpen = true)}><KeyRound size={16} /> New token</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>
|
<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 class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
|
||||||
</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} />
|
<IntegrationsPanel id={s.id} />
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
let servers = $state<GameServer[] | null>(null);
|
let servers = $state<GameServer[] | null>(null);
|
||||||
let groups = $state<Group[]>([]);
|
let groups = $state<Group[]>([]);
|
||||||
let info = $state<AuthServerInfo | null>(null);
|
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 createOpen = $state(false);
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let created = $state<{ id: number; token: string } | null>(null);
|
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);
|
const online = $derived(servers?.reduce((a, s) => a + s.players, 0) ?? 0);
|
||||||
|
|
||||||
function openCreate() {
|
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;
|
createOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"verbatimModuleSyntax": 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"]
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
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).
|
// 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';
|
const target = process.env.PANEL_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
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: {
|
server: {
|
||||||
|
fs: { allow: ['../..'] },
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': target,
|
'/api': target,
|
||||||
|
|||||||
@@ -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} {/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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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); };
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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(':', '__');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user