Live map: plugin/mod chunk+player sync, admin panel Live Map section, launcher transport
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
14 files changed
+769
-8
No files matched your search
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { Map as MapIcon, RefreshCw, Trash2, CircleCheck, CircleAlert, Users, Boxes, Cpu, Package, LoaderCircle } from '@lucide/svelte';
|
||||
import { del, formatBytes, get, timeAgo } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import type { LiveMapStatus } from '../lib/types';
|
||||
|
||||
let { id, enabled }: { id: number; enabled: boolean } = $props();
|
||||
|
||||
let status = $state<LiveMapStatus | null>(null);
|
||||
let dim = $state('overworld');
|
||||
let host = $state<HTMLDivElement>();
|
||||
let viewer: any = null;
|
||||
let loading = $state(false);
|
||||
let viewError = $state('');
|
||||
let resetOpen = $state(false);
|
||||
let loadedKey = '';
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`);
|
||||
if (!status.dimensions.some((d) => d.slug === dim)) dim = status.dimensions.find((d) => d.available)?.slug ?? 'overworld';
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void id;
|
||||
void enabled;
|
||||
refresh();
|
||||
const t = setInterval(refresh, 10000);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
|
||||
function closeViewer() {
|
||||
viewer?.dispose();
|
||||
viewer = null;
|
||||
loadedKey = '';
|
||||
}
|
||||
|
||||
async function openViewer() {
|
||||
if (!host || !status?.ready) return;
|
||||
const key = `${id}:${dim}`;
|
||||
if (loadedKey === key && viewer) return;
|
||||
closeViewer();
|
||||
loading = true;
|
||||
viewError = '';
|
||||
try {
|
||||
const [{ VantageViewer }, { worldFromHttp }] = await Promise.all([
|
||||
import('@thoughts-on-things/vantage-mc/three'),
|
||||
import('@thoughts-on-things/vantage-mc/core'),
|
||||
]);
|
||||
const url = `${location.origin}${status.base}/${dim}/v1/worlds/default/manifest.json`;
|
||||
const world = await worldFromHttp(url, { accessToken: session.token ?? undefined });
|
||||
const v = new VantageViewer(host, { view: 'top', players: { offline: false } });
|
||||
try {
|
||||
await v.load({ world, view: 'top' });
|
||||
} catch (e) {
|
||||
v.dispose();
|
||||
throw e;
|
||||
}
|
||||
viewer = v;
|
||||
loadedKey = key;
|
||||
} catch (e) {
|
||||
viewError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload whenever the dimension changes or the map becomes ready.
|
||||
$effect(() => {
|
||||
void dim;
|
||||
if (status?.ready && host) openViewer();
|
||||
});
|
||||
onDestroy(closeViewer);
|
||||
|
||||
async function reset() {
|
||||
try {
|
||||
await del(`/api/admin/servers/${id}/livemap`);
|
||||
closeViewer();
|
||||
resetOpen = false;
|
||||
toast('Live map data cleared — the server will re-upload its world');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const totalRegions = $derived(status?.world.dimensions.reduce((n, d) => n + d.regions, 0) ?? 0);
|
||||
const totalBytes = $derived(status?.world.dimensions.reduce((n, d) => n + d.bytes, 0) ?? 0);
|
||||
</script>
|
||||
|
||||
<section class="card live">
|
||||
<div class="section-title">
|
||||
<h2><MapIcon size={17} /> Live Map</h2>
|
||||
<span class="pill" class:ok={status?.ready} class:off={!enabled}>{!enabled ? 'Off' : status?.ready ? 'Live' : 'Setting up'}</span>
|
||||
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
|
||||
{#if !enabled}
|
||||
<p class="muted small">Turn on <strong>Live Map</strong> in this server's settings. The SCOPENET plugin/mod will then send chunks and player positions here, and launchers get a Live Map button.</p>
|
||||
{:else if status}
|
||||
<div class="checks">
|
||||
<div class="check" class:good={status.generator.installed}>
|
||||
{#if status.generator.installed}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Cpu size={13} /> Vantage generator</span>
|
||||
<small>{status.generator.installed ? status.generator.path : 'Not installed on the panel'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.assets.configured}>
|
||||
{#if status.assets.configured}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Package size={13} /> Minecraft assets</span>
|
||||
<small>{status.assets.configured ? status.assets.path : 'Set SCOPENET_VANTAGE_ASSETS'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.world.level_dat && totalRegions > 0}>
|
||||
{#if status.world.level_dat && totalRegions > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Boxes size={13} /> World data</span>
|
||||
<small>{totalRegions ? `${totalRegions} regions · ${formatBytes(totalBytes)} · ${status.stats.chunks_received.toLocaleString()} chunks this session` : 'Waiting for the server to upload'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.players > 0}>
|
||||
{#if status.players > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Users size={13} /> Player positions</span>
|
||||
<small>{status.stats.last_players_at ? `${status.players} live · updated ${timeAgo(status.stats.last_players_at)}` : 'Nothing received yet'}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !status.generator.installed || !status.assets.configured}
|
||||
<p class="hint-box small">
|
||||
The panel stores what the server sends, but rendering needs the Vantage generator and Minecraft client assets.
|
||||
Install them as described in <code>docs/live-map.md</code> — until then launchers show a “map is being set up” message.
|
||||
</p>
|
||||
{/if}
|
||||
{#if status.last_error}<p class="err small">Generator: {status.last_error}</p>{/if}
|
||||
|
||||
{#if status.ready}
|
||||
<div class="tabs">
|
||||
{#each status.dimensions as d (d.slug)}
|
||||
<button class="tab" class:on={dim === d.slug} disabled={!d.available} onclick={() => (dim = d.slug)}>{d.label}</button>
|
||||
{/each}
|
||||
<span class="grow"></span>
|
||||
<button class="ghost" onclick={() => (resetOpen = true)}><Trash2 size={14} /> Reset map data</button>
|
||||
</div>
|
||||
<div class="stage">
|
||||
<div class="viewer" bind:this={host}></div>
|
||||
{#if loading}<div class="overlay"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
|
||||
{#if viewError}<div class="overlay err">Map unavailable: {viewError}</div>{/if}
|
||||
</div>
|
||||
{:else if status.message}
|
||||
<p class="muted small">{status.message}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="skeleton"></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Modal bind:open={resetOpen} title="Reset map data?">
|
||||
<p class="muted">Deletes the mirrored world and rendered tiles for this server. The game server uploads everything again, which can take a while for big worlds.</p>
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
|
||||
<button class="danger" onclick={reset}><Trash2 size={16} /> Reset</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.live { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
|
||||
h2 { display: flex; align-items: center; gap: 8px; }
|
||||
.pill { margin-left: auto; padding: 3px 11px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; }
|
||||
.pill.ok { background: color-mix(in srgb, #6ee7b7 16%, transparent); color: #6ee7b7; }
|
||||
.pill.off { background: var(--bg-2); color: var(--muted); }
|
||||
.checks { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
|
||||
.check { display: grid; grid-template-columns: 18px 1fr; gap: 2px 10px; align-items: center; padding: 11px 13px; border-radius: 12px; background: var(--bg-2); color: #fcd34d; }
|
||||
.check.good { color: #6ee7b7; }
|
||||
.check span { color: var(--text); font-size: 0.85rem; font-weight: 560; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.check span :global(svg) { color: var(--muted); }
|
||||
.check small { grid-column: 2; color: var(--muted); font-size: 0.75rem; overflow-wrap: anywhere; }
|
||||
.hint-box { padding: 11px 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); color: var(--text-2); line-height: 1.5; }
|
||||
.err { color: #fda4af; }
|
||||
.tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.tab { padding: 7px 14px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||
.tab.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||
.tab:disabled { opacity: 0.4; }
|
||||
.grow { flex: 1; }
|
||||
.stage { position: relative; border-radius: 14px; overflow: hidden; background: #102026; height: min(62vh, 620px); min-height: 380px; }
|
||||
.viewer { position: absolute; inset: 0; }
|
||||
.viewer :global(canvas) { display: block; }
|
||||
.overlay { position: absolute; inset: 0; display: grid; place-items: center; grid-auto-flow: column; gap: 10px; justify-content: center; color: #e5e7eb; background: rgba(16, 32, 38, 0.72); pointer-events: none; }
|
||||
.overlay.err { color: #fda4af; text-align: center; padding: 24px; }
|
||||
.skeleton { height: 120px; border-radius: 12px; background: var(--bg-2); }
|
||||
</style>
|
||||
@@ -18,7 +18,15 @@
|
||||
|
||||
<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">Vantage live map manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional. Use a public Vantage render or a session-gated same-origin endpoint. The URL is visible to launcher users.</span></label>
|
||||
<Toggle
|
||||
bind:checked={draft.live_map_enabled}
|
||||
label="Live Map"
|
||||
help="The SCOPENET plugin/mod sends this server's chunks and player positions to the panel, which hosts a Vantage 3D map. Launchers show a Live Map button — nothing to run next to the server."
|
||||
/>
|
||||
<details class="adv">
|
||||
<summary>Use an external Vantage map instead</summary>
|
||||
<label class="field">Vantage manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional and only used when Live Map above is off. The URL is visible to launcher users.</span></label>
|
||||
</details>
|
||||
<div class="field">
|
||||
<span class="lbl">Who can join</span>
|
||||
<div class="opts">
|
||||
@@ -58,5 +66,8 @@
|
||||
.opt.on :global(svg) { color: var(--accent-2); }
|
||||
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
|
||||
.adv { border: 1px dashed var(--line, rgba(255, 255, 255, 0.12)); border-radius: 10px; padding: 10px 12px; }
|
||||
.adv summary { cursor: pointer; font-size: 0.85rem; color: var(--muted); }
|
||||
.adv[open] summary { margin-bottom: 10px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
</style>
|
||||
Reference in new issue
Block a user