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
+1387
-343
No files matched your search
@@ -635,90 +635,37 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
|
||||
if !resp.status().is_success() {
|
||||
return Err("unable to load servers".into());
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
let urls = data["servers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some((s["id"].as_i64()?, s["map_address"].as_str()?.to_owned())))
|
||||
.collect();
|
||||
*state.map_urls.lock().unwrap() = urls;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MapResource {
|
||||
status: u16,
|
||||
data: Option<String>,
|
||||
etag: Option<String>,
|
||||
}
|
||||
|
||||
/// Native transport for BlueMap settings and 2D tiles. Only paths under the
|
||||
/// BlueMap address supplied by the panel can be fetched.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_bluemap_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
|
||||
let base = reqwest::Url::parse(&format!("{}/", configured.trim_end_matches('/'))).map_err(err)?;
|
||||
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||
let root = base.clone();
|
||||
if !matches!(target.scheme(), "http" | "https")
|
||||
|| target.origin() != base.origin()
|
||||
|| !target.path().starts_with(root.path())
|
||||
|| target.username() != ""
|
||||
|| target.password().is_some()
|
||||
|| target.fragment().is_some()
|
||||
{
|
||||
return Err("map asset is outside the configured BlueMap site".into());
|
||||
}
|
||||
fetch_map_asset(target, etag, None).await
|
||||
}
|
||||
|
||||
/// What the launcher needs to offer a Live Map button for one game server.
|
||||
#[tauri::command]
|
||||
pub async fn get_livemap_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/livemap")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the live map").to_string());
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
async fn fetch_map_asset(target: reqwest::Url, etag: Option<String>, bearer: Option<String>) -> Res<MapResource> {
|
||||
use base64::Engine;
|
||||
if target.as_str().len() > 2048 {
|
||||
return Err("map asset URL is too long".into());
|
||||
/// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute,
|
||||
/// because the map loads them straight from the panel as images).
|
||||
#[tauri::command]
|
||||
pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(err)?;
|
||||
let mut request = client.get(target);
|
||||
if let Some(token) = bearer {
|
||||
request = request.bearer_auth(token);
|
||||
let mut info: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
if let Some(base) = info["tile_base"].as_str().map(str::to_owned) {
|
||||
info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base));
|
||||
}
|
||||
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Players now, plus the claims and pins the game server last sent.
|
||||
#[tauri::command]
|
||||
pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string());
|
||||
}
|
||||
let response = request.send().await.map_err(err)?;
|
||||
let status = response.status().as_u16();
|
||||
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if status == 304 {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
|
||||
return Err("BlueMap asset exceeds 64 MiB".into());
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(err)?;
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err("BlueMap asset exceeds 64 MiB".into());
|
||||
}
|
||||
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -97,8 +97,8 @@ pub fn run() {
|
||||
commands::get_player_stats,
|
||||
commands::get_leaderboard,
|
||||
commands::get_public_servers,
|
||||
commands::fetch_bluemap_resource,
|
||||
commands::get_livemap_info,
|
||||
commands::get_map_info,
|
||||
commands::get_map_overlay,
|
||||
commands::get_my_level,
|
||||
commands::get_my_quests,
|
||||
commands::get_reward_summaries,
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::settings::Settings;
|
||||
use scopenet_core::Layout;
|
||||
use scopenet_shared::LauncherManifest;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
|
||||
@@ -41,8 +40,6 @@ pub struct AppState {
|
||||
pub accounts: RwLock<AccountsFile>,
|
||||
pub secrets: Secrets,
|
||||
pub manifest: RwLock<Option<LauncherManifest>>,
|
||||
/// BlueMap addresses received from the panel, keyed by linked game server.
|
||||
pub map_urls: Mutex<HashMap<i64, String>>,
|
||||
/// Install/launch pipeline in progress (abortable).
|
||||
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||
pub game: Mutex<Vec<RunningGame>>,
|
||||
@@ -61,7 +58,6 @@ impl AppState {
|
||||
settings: RwLock::new(settings),
|
||||
accounts: RwLock::new(accounts),
|
||||
manifest: RwLock::new(manifest),
|
||||
map_urls: Mutex::new(HashMap::new()),
|
||||
task: Mutex::new(None),
|
||||
game: Mutex::new(Vec::new()),
|
||||
data_dir,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { X, ExternalLink, RefreshCw, LoaderCircle } from '@lucide/svelte';
|
||||
import { openUrl } from '../lib/tauri';
|
||||
import MapPlayerPanel from './MapPlayerPanel.svelte';
|
||||
|
||||
let { serverId, serverName, address, instanceId, onclose }: { serverId: number; serverName: string; address: string; instanceId: string; onclose: () => void } = $props();
|
||||
|
||||
let frame = $state<HTMLIFrameElement>();
|
||||
let loaded = $state(false);
|
||||
let selected = $state<{ uuid: string; name: string } | null>(null);
|
||||
let nonce = $state(0);
|
||||
|
||||
const origin = $derived.by(() => { try { return new URL(address).origin; } catch { return ''; } });
|
||||
|
||||
// The map page (BlueMap with SCOPENET's script) reports which player was clicked. Only that page may talk to us.
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (!frame || e.source !== frame.contentWindow || e.origin !== origin) return;
|
||||
const m = e.data;
|
||||
if (!m || m.source !== 'scopenet-map') return;
|
||||
if (m.type === 'ready') frame.contentWindow?.postMessage({ source: 'scopenet-launcher', type: 'hello' }, origin);
|
||||
else if (m.type === 'player' && typeof m.uuid === 'string' && /^[0-9a-fA-F-]{32,36}$/.test(m.uuid)) selected = { uuid: m.uuid, name: String(m.name ?? '').slice(0, 32) };
|
||||
else if (m.type === 'deselect') selected = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
});
|
||||
|
||||
const reload = () => { loaded = false; selected = null; nonce++; };
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === 'Escape' && (selected ? (selected = null) : onclose())} />
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
|
||||
<header class="glass">
|
||||
<div class="title">
|
||||
<span class="live" class:on={loaded}></span>
|
||||
<div><strong>{serverName}</strong><span class="muted">Live Map</span></div>
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
|
||||
<button class="ghost sm" onclick={() => openUrl(address)} title="Open the same map in your browser"><ExternalLink size={14} /> Browser</button>
|
||||
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
{#key nonce}
|
||||
<iframe
|
||||
bind:this={frame}
|
||||
title="{serverName} live map"
|
||||
src={address}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-pointer-lock"
|
||||
allow="fullscreen; clipboard-write"
|
||||
referrerpolicy="no-referrer"
|
||||
onload={() => (loaded = true)}
|
||||
></iframe>
|
||||
{/key}
|
||||
{#if !loaded}<div class="loading"><LoaderCircle class="spin" size={24} /> Loading the map…</div>{/if}
|
||||
{#if selected}
|
||||
<MapPlayerPanel {serverId} {instanceId} uuid={selected.uuid} nameHint={selected.name} onclose={() => (selected = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
|
||||
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
|
||||
.title { display: flex; align-items: center; gap: 0.7rem; }
|
||||
.title div { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.title .muted { font-size: 0.75rem; }
|
||||
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
|
||||
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
|
||||
.grow { flex: 1; }
|
||||
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; }
|
||||
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||
.loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.6rem; color: var(--muted); pointer-events: none; }
|
||||
</style>
|
||||
@@ -2,15 +2,15 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import { toast } from '../lib/store.svelte';
|
||||
import { activeAccount, toast } from '../lib/store.svelte';
|
||||
import type { GuildClaim } from '../lib/types';
|
||||
import BlueMapTiles from './BlueMapTiles.svelte';
|
||||
import ClaimTerrain from './ClaimTerrain.svelte';
|
||||
|
||||
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
|
||||
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
|
||||
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
|
||||
} = $props();
|
||||
type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean };
|
||||
type GameServer = { id: number; name: string; instance_id: string; map?: boolean };
|
||||
type Player = { x: number; z: number; dimension: string };
|
||||
let servers = $state<GameServer[]>([]);
|
||||
let serverId = $state<number | null>(null);
|
||||
@@ -32,9 +32,10 @@
|
||||
|
||||
async function refreshPosition(id: number) {
|
||||
try {
|
||||
const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id });
|
||||
const info = await invoke<{ players: Array<Player & { uuid: string }> }>('get_map_overlay', { serverId: id });
|
||||
if (serverId !== id) return;
|
||||
player = info.me ?? null;
|
||||
const mine = activeAccount()?.uuid?.toLowerCase();
|
||||
player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null;
|
||||
if (player && centeredServer !== id) {
|
||||
centeredServer = id;
|
||||
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
|
||||
@@ -134,20 +135,20 @@
|
||||
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedServer?.map_address}
|
||||
{#key `${serverId}:${selectedServer.map_address}`}
|
||||
<BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
||||
{#if selectedServer?.map}
|
||||
{#key serverId}
|
||||
<ClaimTerrain serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
||||
{claims} {myGuildId} {player} onpaint={paintChunks}
|
||||
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="notice">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</div>
|
||||
<div class="notice">The map is switched off for this server. An admin can turn it on under Servers in the panel.</div>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
|
||||
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
|
||||
{#if painting}<span>Updating claims…</span>{/if}
|
||||
{#if !player}<span class="hint">Player position appears when the server sends live positions to the panel.</span>{/if}
|
||||
{#if !player}<span class="hint">Your position appears once you are in game and the server's map is running.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+42
-82
@@ -1,113 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke, inTauri } from '../lib/tauri';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import type { GuildClaim } from '../lib/types';
|
||||
|
||||
type Resource = { status: number; data: string | null };
|
||||
type Site = { maps?: string[]; mapDataRoot?: string };
|
||||
type MapSettings = { lowres?: { tileSize?: [number, number]; lodFactor?: number; lodCount?: number } };
|
||||
type Point = { x: number; z: number };
|
||||
const cache = new Map<string, string>();
|
||||
const pending = new Set<string>();
|
||||
|
||||
let { address, serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
|
||||
address: string; serverId: number; dimension: string; centerX: number; centerZ: number;
|
||||
let { serverId, dimension, centerX, centerZ, claims, myGuildId, player, onpaint, oncenterchange }: {
|
||||
serverId: number; dimension: string; centerX: number; centerZ: number;
|
||||
claims: GuildClaim[]; myGuildId: string | null; player: { x: number; z: number; dimension: string } | null;
|
||||
onpaint: (chunks: Point[], mode: 'claim' | 'unclaim') => void;
|
||||
oncenterchange: (x: number, z: number) => void;
|
||||
} = $props();
|
||||
|
||||
const TILE = 256;
|
||||
let host: HTMLDivElement;
|
||||
let width = $state(800), height = $state(480);
|
||||
let pixelsPerBlock = $state(3);
|
||||
let worldX = $state(8), worldZ = $state(8);
|
||||
let mapRoot = $state('');
|
||||
let mapId = $state('');
|
||||
let tileBlocks = $state(32);
|
||||
let images = $state<Record<string, string>>({});
|
||||
let info = $state<{ ready: boolean; message: string; tile_base: string; token: string | null; token_ttl_secs: number; dimensions: Array<{ slug: string; bounds: { min_x: number; max_x: number; min_y: number; max_y: number } | null }> } | null>(null);
|
||||
let error = $state('');
|
||||
let loading = $state(true);
|
||||
let paint = $state<Point[]>([]);
|
||||
let pointer = $state<{ id: number; mode: 'claim' | 'unclaim' | 'pan'; x: number; y: number; last: Point | null } | null>(null);
|
||||
let broken = $state<Record<string, true>>({});
|
||||
|
||||
async function resource(url: string): Promise<Resource> {
|
||||
if (inTauri) return invoke<Resource>('fetch_bluemap_resource', { serverId, url, etag: null });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return { status: response.status, data: null };
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return { status: response.status, data: btoa(binary) };
|
||||
}
|
||||
|
||||
async function json<T>(url: string): Promise<T> {
|
||||
const response = await resource(url);
|
||||
if (response.status !== 200 || !response.data) throw new Error(`BlueMap returned ${response.status} for settings`);
|
||||
return JSON.parse(atob(response.data)) as T;
|
||||
}
|
||||
|
||||
function tilePath(x: number, z: number): string {
|
||||
const part = (n: number) => (n < 0 ? '-' : '') + Math.abs(n).toString().split('').join('/');
|
||||
return `x${part(x)}/z${part(z)}.png`;
|
||||
}
|
||||
|
||||
function chooseMap(ids: string[]): string {
|
||||
const slug = dimension.replace('minecraft:', '');
|
||||
if (slug === 'overworld') return ids.find((id) => !/nether|the_end|(^|[_-])end($|[_-])/i.test(id)) ?? '';
|
||||
return ids.find((id) => slug === 'the_nether' ? /nether/i.test(id) : /(^|[_-])end($|[_-])/i.test(id)) ?? '';
|
||||
}
|
||||
const slug = $derived(dimension.replace('minecraft:', '').replace(':', '__'));
|
||||
const here = $derived(info?.dimensions.find((d) => d.slug === slug) ?? null);
|
||||
|
||||
// Tile keys last an hour; renew well before they expire.
|
||||
$effect(() => {
|
||||
const url = address, dim = dimension;
|
||||
let alive = true;
|
||||
loading = true; error = ''; mapRoot = ''; mapId = ''; images = {};
|
||||
(async () => {
|
||||
const base = new URL(url.endsWith('/') ? url : `${url}/`);
|
||||
const site = await json<Site>(new URL('settings.json', base).href);
|
||||
const id = chooseMap(site.maps ?? []);
|
||||
if (!id) throw new Error(`BlueMap has no ${dim.replace('minecraft:', '')} map`);
|
||||
const root = new URL(`${(site.mapDataRoot || 'maps').replace(/\/$/, '')}/${encodeURIComponent(id)}/`, base).href;
|
||||
const settings = await json<MapSettings>(new URL('settings.json', root).href);
|
||||
if (!alive) return;
|
||||
tileBlocks = Math.max(16, Number(settings.lowres?.tileSize?.[0] ?? 32));
|
||||
mapRoot = root; mapId = id; loading = false;
|
||||
})().catch((e) => { if (alive) { error = e instanceof Error ? e.message : String(e); loading = false; } });
|
||||
return () => { alive = false; };
|
||||
const id = serverId;
|
||||
let alive = true, timer: ReturnType<typeof setTimeout>;
|
||||
loading = true; error = ''; broken = {};
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await invoke<NonNullable<typeof info>>('get_map_info', { serverId: id });
|
||||
if (!alive) return;
|
||||
info = next; loading = false; error = next.ready ? '' : next.message;
|
||||
timer = setTimeout(load, next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000);
|
||||
} catch (e) {
|
||||
if (!alive) return;
|
||||
loading = false; error = typeof e === 'string' ? e : (e as Error)?.message ?? 'Could not load the map';
|
||||
timer = setTimeout(load, 15000);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { alive = false; clearTimeout(timer); };
|
||||
});
|
||||
|
||||
const tileUrl = (x: number, y: number) => `${info!.tile_base}/${slug}/0/${x}/${y}.png?t=${info!.token}`;
|
||||
|
||||
$effect(() => { worldX = centerX * 16 + 8; worldZ = centerZ * 16 + 8; });
|
||||
|
||||
const left = $derived(worldX - width / (2 * pixelsPerBlock));
|
||||
const top = $derived(worldZ - height / (2 * pixelsPerBlock));
|
||||
const visibleTiles = $derived.by(() => {
|
||||
if (!mapRoot) return [] as { key: string; url: string; left: number; top: number; size: number }[];
|
||||
if (!info?.ready || !info.token || !here) return [] as { key: string; url: string; left: number; top: number; size: number }[];
|
||||
const out = [];
|
||||
const minX = Math.floor(left / tileBlocks), maxX = Math.floor((left + width / pixelsPerBlock) / tileBlocks);
|
||||
const minZ = Math.floor(top / tileBlocks), maxZ = Math.floor((top + height / pixelsPerBlock) / tileBlocks);
|
||||
const b = here.bounds;
|
||||
const minX = Math.floor(left / TILE), maxX = Math.floor((left + width / pixelsPerBlock) / TILE);
|
||||
const minZ = Math.floor(top / TILE), maxZ = Math.floor((top + height / pixelsPerBlock) / TILE);
|
||||
for (let z = minZ; z <= maxZ; z++) for (let x = minX; x <= maxX; x++) {
|
||||
const url = new URL(`tiles/1/${tilePath(x, z)}`, mapRoot).href;
|
||||
out.push({ key: url, url, left: (x * tileBlocks - left) * pixelsPerBlock,
|
||||
top: (z * tileBlocks - top) * pixelsPerBlock, size: tileBlocks * pixelsPerBlock });
|
||||
if (b && (x < b.min_x || x > b.max_x || z < b.min_y || z > b.max_y)) continue;
|
||||
const url = tileUrl(x, z);
|
||||
if (broken[url]) continue;
|
||||
out.push({ key: url, url, left: (x * TILE - left) * pixelsPerBlock, top: (z * TILE - top) * pixelsPerBlock, size: TILE * pixelsPerBlock });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const tiles = visibleTiles;
|
||||
for (const tile of tiles) {
|
||||
if (images[tile.key] || pending.has(tile.key)) continue;
|
||||
if (cache.has(tile.key)) { images = { ...images, [tile.key]: cache.get(tile.key)! }; continue; }
|
||||
pending.add(tile.key);
|
||||
resource(tile.url).then((r) => {
|
||||
if (r.status !== 200 || !r.data) return;
|
||||
const src = `data:image/png;base64,${r.data}`;
|
||||
cache.set(tile.key, src);
|
||||
if (cache.size > 512) cache.delete(cache.keys().next().value!);
|
||||
images = { ...images, [tile.key]: src };
|
||||
}).catch(() => {}).finally(() => pending.delete(tile.key));
|
||||
}
|
||||
});
|
||||
|
||||
const visibleClaims = $derived(claims.filter((c) => c.dimension === dimension &&
|
||||
c.chunk_x * 16 + 16 >= left && c.chunk_x * 16 <= left + width / pixelsPerBlock &&
|
||||
c.chunk_z * 16 + 16 >= top && c.chunk_z * 16 <= top + height / pixelsPerBlock));
|
||||
@@ -133,7 +94,7 @@
|
||||
}
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
if (!mapRoot) return;
|
||||
if (!info?.ready) return;
|
||||
if (e.button !== 0 && e.button !== 1 && e.button !== 2) return;
|
||||
e.preventDefault();
|
||||
const mode = e.button === 1 || e.altKey ? 'pan' : e.button === 2 ? 'unclaim' : 'claim';
|
||||
@@ -170,11 +131,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="BlueMap territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
|
||||
<div class="terrain" bind:this={host} role="button" tabindex="0" aria-label="Territory map; left drag to claim, right drag to unclaim, Alt drag to pan"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') onpaint([{ x: Math.floor(worldX / 16), z: Math.floor(worldZ / 16) }], 'claim'); }}
|
||||
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up} onwheel={wheel} oncontextmenu={(e) => e.preventDefault()}>
|
||||
{#each visibleTiles as tile (tile.key)}
|
||||
{#if images[tile.key]}<img class="tile" src={images[tile.key]} alt="" draggable="false" style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />{/if}
|
||||
<img class="tile" src={tile.url} alt="" draggable="false" onerror={() => (broken[tile.url] = true)} style:left="{tile.left}px" style:top="{tile.top}px" style:width="{tile.size}px" style:height="{tile.size}px" />
|
||||
{/each}
|
||||
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
|
||||
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
|
||||
@@ -191,10 +152,10 @@
|
||||
{/each}
|
||||
{#if playerHere}<div class="player" title="Your position: {Math.floor(playerHere.x)}, {Math.floor(playerHere.z)}"
|
||||
style:left="{(playerHere.x - left) * pixelsPerBlock}px" style:top="{(playerHere.z - top) * pixelsPerBlock}px"></div>{/if}
|
||||
{#if loading}<div class="message">Loading BlueMap tiles…</div>{/if}
|
||||
{#if error}<div class="message error">{error}</div>{/if}
|
||||
{#if loading}<div class="message">Loading the map…</div>{/if}
|
||||
{#if error && !loading}<div class="message">{error}</div>{/if}
|
||||
<div class="help">Left click or drag: claim · Right click or drag: unclaim · Alt drag: pan · Wheel: zoom</div>
|
||||
<div class="coords">{mapId} · {Math.floor(worldX)}, {Math.floor(worldZ)}</div>
|
||||
<div class="coords">{Math.floor(worldX)}, {Math.floor(worldZ)}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -208,7 +169,6 @@
|
||||
.preview.remove { background: #fb718590; }
|
||||
.player { position: absolute; width: 15px; height: 15px; margin: -7px; border: 3px solid white; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 0 3px #2563eb, 0 2px 9px #000; pointer-events: none; }
|
||||
.message { position: absolute; inset: 0; display: grid; place-items: center; color: white; background: #142333d9; pointer-events: none; }
|
||||
.error { color: #fda4af; }
|
||||
.help, .coords { position: absolute; bottom: 10px; padding: 7px 10px; border-radius: 7px; color: white; background: #08111ccc; font-size: 12px; pointer-events: none; }
|
||||
.help { left: 10px; }.coords { right: 10px; }
|
||||
</style>
|
||||
@@ -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);
|
||||
switch (cmd) {
|
||||
case 'get_public_servers':
|
||||
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_address: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
|
||||
case 'get_livemap_info':
|
||||
return { enabled: true, ready: true, message: '', me: null, players: 0,
|
||||
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
|
||||
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map: true, online: true, players_online: 42, players_max: 100 }] };
|
||||
case 'get_map_info':
|
||||
return { enabled: true, ready: false, message: 'Waiting for the game server to draw its world.', tile_size: 256, max_zoom: 6, tile_base: 'https://panel.example/api/map/1', token: null, token_ttl_secs: 3600, players: 0,
|
||||
dimensions: [] };
|
||||
case 'get_map_overlay':
|
||||
return { players: [], claims: [], pins: [], updated: null };
|
||||
case 'get_player_stats':
|
||||
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
|
||||
case 'get_leaderboard':
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import ServerStatus from '../components/ServerStatus.svelte';
|
||||
import NewsFeed from '../components/NewsFeed.svelte';
|
||||
import Console from '../components/Console.svelte';
|
||||
import BlueMapModal from '../components/BlueMapModal.svelte';
|
||||
import MapModal from '../components/MapModal.svelte';
|
||||
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
|
||||
import { loaderName, shortLoaderVersion } from '../lib/format';
|
||||
import { invoke, openUrl } from '../lib/tauri';
|
||||
@@ -13,8 +13,8 @@
|
||||
const b = $derived(app.manifest!.branding);
|
||||
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
|
||||
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
|
||||
// A game server linked to this instance with BlueMap configured.
|
||||
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
|
||||
// A game server linked to this instance that draws the SCOPENET Map.
|
||||
let mapServer = $state<{ id: number; name: string } | null>(null);
|
||||
let mapOpen = $state(false);
|
||||
$effect(() => {
|
||||
const id = inst?.id;
|
||||
@@ -22,8 +22,8 @@
|
||||
mapOpen = false;
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map_address?: string }> }>('get_public_servers')
|
||||
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map_address) ?? null; })
|
||||
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; map?: boolean }> }>('get_public_servers')
|
||||
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.map) ?? null; })
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
});
|
||||
@@ -57,7 +57,7 @@
|
||||
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
|
||||
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
|
||||
<button class="glass tool" onclick={() => (app.view = 'stats')} title="Player stats & leaderboards"><Trophy size={17} /></button>
|
||||
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open BlueMap"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
|
||||
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open the live map"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
|
||||
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -82,7 +82,7 @@
|
||||
</aside>
|
||||
|
||||
{#if app.consoleOpen}<Console />{/if}
|
||||
{#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
|
||||
{#if mapOpen && mapServer}<MapModal serverId={mapServer.id} serverName={mapServer.name} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["svelte", "vite/client"]
|
||||
"types": ["svelte", "vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": { "svelte": ["./node_modules/svelte"], "svelte/*": ["./node_modules/svelte/*"], "@scopenet/map": ["../shared/map/index.ts"], "@scopenet/map/*": ["../shared/map/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "../shared/map/**/*.ts", "../shared/map/**/*.svelte"]
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// https://tauri.app/start/frontend/vite/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
// The map viewer is shared with the admin panel (repo root: shared/map).
|
||||
resolve: { dedupe: ['svelte'], alias: { '@scopenet/map': fileURLToPath(new URL('../shared/map', import.meta.url)) } },
|
||||
clearScreen: false,
|
||||
server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } },
|
||||
server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] }, fs: { allow: ['..'] } },
|
||||
envPrefix: ['VITE_', 'TAURI_ENV_'],
|
||||
build: {
|
||||
target: 'es2022',
|
||||
|
||||
Reference in new issue
Block a user