Rework BlueMap claiming and expand ranks guilds chat and warps

This commit is contained in:
scoped committed 2026-09-30 18:09:18 -04:00
1 parent 434ed5338a
commit 6a2944a88e
68 files changed
+1453 -2500

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke, inTauri } 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;
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();
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 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);
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)) ?? '';
}
$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; };
});
$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 }[];
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);
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 });
}
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));
const playerHere = $derived(player?.dimension === dimension ? player : null);
function at(e: PointerEvent): Point {
const rect = host.getBoundingClientRect();
return { x: Math.floor((left + (e.clientX - rect.left) / pixelsPerBlock) / 16),
z: Math.floor((top + (e.clientY - rect.top) / pixelsPerBlock) / 16) };
}
function addStroke(next: Point) {
const last = pointer?.last ?? next;
const steps = Math.max(Math.abs(next.x - last.x), Math.abs(next.z - last.z));
const nextPaint = new Map(paint.map((p) => [`${p.x}:${p.z}`, p]));
for (let i = 0; i <= steps; i++) {
const x = Math.round(last.x + (next.x - last.x) * i / Math.max(1, steps));
const z = Math.round(last.z + (next.z - last.z) * i / Math.max(1, steps));
if (nextPaint.size < 128) nextPaint.set(`${x}:${z}`, { x, z });
}
paint = [...nextPaint.values()];
if (pointer) pointer.last = next;
}
function down(e: PointerEvent) {
if (!mapRoot) 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';
pointer = { id: e.pointerId, mode, x: e.clientX, y: e.clientY, last: null };
host.setPointerCapture(e.pointerId);
if (mode !== 'pan') { paint = []; addStroke(at(e)); }
}
function move(e: PointerEvent) {
if (!pointer || pointer.id !== e.pointerId) return;
if (pointer.mode === 'pan') {
worldX -= (e.clientX - pointer.x) / pixelsPerBlock;
worldZ -= (e.clientY - pointer.y) / pixelsPerBlock;
pointer.x = e.clientX; pointer.y = e.clientY;
} else addStroke(at(e));
}
function up(e: PointerEvent) {
if (!pointer || pointer.id !== e.pointerId) return;
const mode = pointer.mode;
if (host.hasPointerCapture(e.pointerId)) host.releasePointerCapture(e.pointerId);
pointer = null;
if (mode === 'pan') oncenterchange(Math.floor(worldX / 16), Math.floor(worldZ / 16));
else if (paint.length) onpaint(paint, mode);
paint = [];
}
function wheel(e: WheelEvent) {
e.preventDefault();
pixelsPerBlock = Math.max(1.5, Math.min(6, pixelsPerBlock * (e.deltaY < 0 ? 1.2 : 1 / 1.2)));
}
onMount(() => {
const observer = new ResizeObserver(([entry]) => { width = entry.contentRect.width; height = entry.contentRect.height; });
observer.observe(host);
return () => observer.disconnect();
});
</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"
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}
{/each}
<div class="grid" style:background-size="{16 * pixelsPerBlock}px {16 * pixelsPerBlock}px"
style:background-position="{-left * pixelsPerBlock}px {-top * pixelsPerBlock}px"></div>
{#each visibleClaims as claim (claim.id)}
<div class="claim" class:mine={claim.guild_id === myGuildId}
title="[{claim.chunk_x}, {claim.chunk_z}] {claim.guild_name}"
style:left="{(claim.chunk_x * 16 - left) * pixelsPerBlock}px" style:top="{(claim.chunk_z * 16 - top) * pixelsPerBlock}px"
style:width="{16 * pixelsPerBlock}px" style:height="{16 * pixelsPerBlock}px"></div>
{/each}
{#each paint as chunk (`${chunk.x}:${chunk.z}`)}
<div class="preview" class:remove={pointer?.mode === 'unclaim'}
style:left="{(chunk.x * 16 - left) * pixelsPerBlock}px" style:top="{(chunk.z * 16 - top) * pixelsPerBlock}px"
style:width="{16 * pixelsPerBlock}px" style:height="{16 * pixelsPerBlock}px"></div>
{/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}
<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>
<style>
.terrain { position: relative; height: min(62vh, 640px); min-height: 420px; overflow: hidden; background: #142333; border-radius: 10px; cursor: crosshair; touch-action: none; }
.tile { position: absolute; image-rendering: pixelated; pointer-events: none; }
.grid { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(to right, #ffffff42 1px, transparent 1px), linear-gradient(to bottom, #ffffff42 1px, transparent 1px); }
.claim, .preview { position: absolute; pointer-events: none; box-sizing: border-box; }
.claim { background: #fb71854a; border: 2px solid #fb7185; }
.claim.mine { background: #4ade804a; border-color: #4ade80; }
.preview { background: #4ade8090; border: 2px solid #fff; }
.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>
+120 -475
View File
@@ -1,524 +1,169 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
MapPin, Globe, LoaderCircle, RefreshCw, Server
} from '@lucide/svelte';
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { abs, toast } from '../lib/store.svelte';
import { toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte';
import BlueMapTiles from './BlueMapTiles.svelte';
let {
instanceId,
myGuildId = null,
myGuildTag = '',
onclaim,
onunclaim
}: {
instanceId: string;
myGuildId?: string | null;
myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void;
onunclaim?: (claimId: number) => void;
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
} = $props();
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string; live_map?: boolean }>>([]);
type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean };
type Player = { x: number; z: number; dimension: string };
let servers = $state<GameServer[]>([]);
let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0);
let centerZ = $state(0);
let radius = $state(5); // 11x11 grid
let centerX = $state(0), centerZ = $state(0);
let player = $state<Player | null>(null);
let claims = $state<GuildClaim[]>([]);
let loading = $state(false);
let claiming = $state(false);
let loading = $state(false), painting = $state(false);
let claimLoad = 0;
let hoveredChunk = $state<{ x: number; z: number } | null>(null);
let selectedChunk = $state<{ x: number; z: number } | null>(null);
let mapDimensions = $state<string[]>([]);
let mapFailed = $state(false);
// The panel's central live map (if switched on for this server) wins over an external Vantage URL.
type LiveInfo = { ready: boolean; base: string; dimensions: { id: string; available: boolean }[] };
let liveInfo = $state<LiveInfo | null>(null);
let centeredServer = 0;
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
const selectedServer = $derived(servers.find((s) => s.id === serverId));
$effect(() => {
const id = selectedServer?.live_map ? selectedServer.id : null;
liveInfo = null;
if (!id) return;
let alive = true;
invoke<LiveInfo>('get_livemap_info', { serverId: id }).then((i) => { if (alive) liveInfo = i; }).catch(() => {});
return () => { alive = false; };
});
const central = $derived(!!liveInfo?.ready);
const dimSlug = $derived(dimension.replace('minecraft:', ''));
const mapUrl = $derived(central && liveInfo ? abs(`${liveInfo.base}/${dimSlug}/v1/worlds/default/manifest.json`) ?? '' : selectedServer?.map_url || '');
const dimDisabled = (id: string) => central
? !liveInfo?.dimensions.find((d) => d.id === id)?.available
: !!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(id);
$effect(() => { mapUrl; mapFailed = false; if (!central) mapDimensions = []; });
const selectedServer = $derived(instanceServers.find((s) => s.id === serverId));
const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
{ id: 'minecraft:the_nether', label: 'The Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'The End', icon: Compass }
{ id: 'minecraft:the_nether', label: 'Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'End', icon: Compass }
] as const;
const myClaimsCount = $derived(claims.filter((c) => c.guild_id === myGuildId).length);
async function refreshPosition(id: number) {
try {
const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id });
if (serverId !== id) return;
player = info.me ?? null;
if (player && centeredServer !== id) {
centeredServer = id;
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
centerX = Math.floor(player.x / 16);
centerZ = Math.floor(player.z / 16);
}
} catch { player = null; }
}
async function loadClaims() {
const request = ++claimLoad;
if (!instanceId || !serverId) { claims = []; return; }
if (!serverId || !instanceId) { claims = []; return; }
loading = true;
claims = [];
try {
const next = await invoke<GuildClaim[]>('get_guild_claims', {
instanceId,
serverId,
centerX,
centerZ,
dimension
});
const next = await invoke<GuildClaim[]>('get_guild_claims', { instanceId, serverId, dimension, centerX, centerZ });
if (request === claimLoad) claims = next;
} catch (e: any) {
if (request === claimLoad) toast(e?.message ?? 'Failed to load territory claims', 'error');
} finally {
if (request === claimLoad) loading = false;
}
if (request === claimLoad) toast(typeof e === 'string' ? e : e?.message ?? 'Could not load claims', 'error');
} finally { if (request === claimLoad) loading = false; }
}
function getClaimAt(x: number, z: number): GuildClaim | undefined {
return claims.find((c) => c.chunk_x === x && c.chunk_z === z && c.dimension === dimension);
}
const selectedClaim = $derived(
selectedChunk ? getClaimAt(selectedChunk.x, selectedChunk.z) : null
);
const myClaimsCount = $derived(
myGuildId ? claims.filter((c) => c.guild_id === myGuildId).length : 0
);
async function handleClaim() {
if (!selectedChunk || !myGuildId || !serverId) return;
claiming = true;
async function paintChunks(chunks: { x: number; z: number }[], mode: 'claim' | 'unclaim') {
if (!serverId || !myGuildId || painting) return;
const existing = new Map(claims.map((c) => [`${c.chunk_x}:${c.chunk_z}`, c]));
const wanted = chunks.filter(({ x, z }) => {
const current = existing.get(`${x}:${z}`);
return mode === 'claim' ? !current : current?.guild_id === myGuildId;
});
if (!wanted.length) return;
painting = true;
let done = 0;
try {
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId,
instanceId,
serverId,
dimension,
chunkX: selectedChunk.x,
chunkZ: selectedChunk.z
});
claims = [...claims, claim];
toast(`Successfully claimed chunk [${selectedChunk.x}, ${selectedChunk.z}]!`, 'ok');
onclaim?.(claim);
} catch (e: any) {
toast(e?.message ?? 'Failed to claim chunk', 'error');
} finally {
claiming = false;
}
for (const chunk of wanted) {
try {
if (mode === 'claim') {
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId, instanceId, serverId, dimension, chunkX: chunk.x, chunkZ: chunk.z
});
claims = [...claims, claim];
onclaim?.(claim);
} else {
const claim = existing.get(`${chunk.x}:${chunk.z}`)!;
await invoke('unclaim_guild_chunk', { claimId: claim.id });
claims = claims.filter((c) => c.id !== claim.id);
onunclaim?.(claim.id);
}
done++;
} catch (e: any) {
toast(`${done} of ${wanted.length} chunks updated. ${typeof e === 'string' ? e : e?.message ?? 'Claim failed'}`, 'error');
break;
}
}
if (done === wanted.length) toast(`${done} ${done === 1 ? 'chunk' : 'chunks'} ${mode === 'claim' ? 'claimed' : 'unclaimed'}`, 'ok');
} finally { painting = false; await loadClaims(); }
}
async function handleUnclaim() {
if (!selectedClaim) return;
claiming = true;
try {
await invoke('unclaim_guild_chunk', { claimId: selectedClaim.id });
claims = claims.filter((c) => c.id !== selectedClaim.id);
toast(`Unclaimed chunk [${selectedClaim.chunk_x}, ${selectedClaim.chunk_z}]`, 'ok');
onunclaim?.(selectedClaim.id);
selectedChunk = null;
} catch (e: any) {
toast(e?.message ?? 'Failed to unclaim chunk', 'error');
} finally {
claiming = false;
}
function centerOnPlayer() {
if (!player) return;
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
centerX = Math.floor(player.x / 16);
centerZ = Math.floor(player.z / 16);
}
function pan(dx: number, dz: number) {
centerX += dx;
centerZ += dz;
}
function resetCenter() {
centerX = 0;
centerZ = 0;
}
$effect(() => {
if (instanceId && dimension && serverId) {
loadClaims();
}
});
onMount(async () => {
const result = await invoke<{ servers: typeof servers }>('get_public_servers');
servers = result.servers;
onMount(() => {
invoke<{ servers: GameServer[] }>('get_public_servers').then((r) => { servers = r.servers; })
.catch((e: any) => { toast(typeof e === 'string' ? e : 'Could not load servers', 'error'); });
const timer = setInterval(() => { if (serverId) refreshPosition(serverId); }, 10_000);
return () => clearInterval(timer);
});
$effect(() => {
const available = servers.filter(s => s.instance_id === instanceId);
if (!available.some(s => s.id === serverId)) serverId = available[0]?.id ?? null;
const available = instanceServers;
if (!available.some((s) => s.id === serverId)) serverId = available[0]?.id ?? null;
});
$effect(() => { if (serverId) { player = null; centeredServer = 0; refreshPosition(serverId); } });
$effect(() => { if (serverId && instanceId && dimension) loadClaims(); });
</script>
<div class="chunk-map-container glass">
<div class="claim-map glass">
<div class="toolbar">
<div class="tb-group">
<div class="group">
{#if instanceServers.length > 1}
<label class="server-pick" title="Game server"><Server size={14} />
<select bind:value={serverId} aria-label="Game server">
{#each instanceServers as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{:else if instanceServers[0]}
<span class="chip server-chip"><Server size={13} /> {instanceServers[0].name}</span>
{/if}
<div class="dim-selector" role="tablist" aria-label="Dimension">
<label class="server"><ServerIcon size={14} /><select bind:value={serverId} aria-label="Game server">
{#each instanceServers as s}<option value={s.id}>{s.name}</option>{/each}
</select></label>
{:else if selectedServer}<span class="chip"><ServerIcon size={14} /> {selectedServer.name}</span>{/if}
<div class="dimensions" role="tablist" aria-label="Dimension">
{#each dimensions as dim}
{@const DimIcon = dim.icon}
<button
role="tab"
aria-selected={dimension === dim.id}
class="dim-btn"
class:active={dimension === dim.id}
disabled={dimDisabled(dim.id)}
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
>
<DimIcon size={14} />
<span>{dim.label}</span>
</button>
{@const Icon = dim.icon}
<button role="tab" aria-selected={dimension === dim.id} class:active={dimension === dim.id}
onclick={() => (dimension = dim.id)}><Icon size={14} /> {dim.label}</button>
{/each}
</div>
{#if central}<span class="chip live-chip"><span class="pulse"></span> Live map</span>{/if}
</div>
<div class="tb-group">
<span class="coord-label" title="Chunk at the centre of the view">Chunk {centerX}, {centerZ}</span>
<div class="pad">
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan west" aria-label="Pan west"><ArrowLeft size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan north" aria-label="Pan north"><ArrowUp size={14} /></button>
<button class="ghost icon sm" onclick={resetCenter} title="Back to the world origin" aria-label="Centre on origin"><MapPin size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan south" aria-label="Pan south"><ArrowDown size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan east" aria-label="Pan east"><ArrowRight size={14} /></button>
</div>
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims">
<RefreshCw size={14} class={loading ? 'spin' : ''} />
</button>
<div class="group">
{#if player}<span class="position">You: {Math.floor(player.x)}, {Math.floor(player.z)}</span>{:else}<span class="position">Player position unavailable</span>{/if}
<button class="ghost sm" onclick={centerOnPlayer} disabled={!player} title="Center on your current position"><LocateFixed size={15} /> Find me</button>
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
</div>
</div>
{#if !serverId}
<div class="notice"><Server size={16} /> Link a game server to this instance in the panel before claiming territory.</div>
{/if}
<!-- Main Chunk Map Canvas / Grid -->
{#if mapUrl && !mapFailed}
{#key `${serverId}:${mapUrl}`}
<VantageMap
world={mapUrl}
serverId={serverId!}
{central}
{claims}
{dimension}
{myGuildId}
onselect={(x, z) => { selectedChunk = { x, z }; }}
ondimension={(value) => { if (dimension !== value) { dimension = value; claims = []; selectedChunk = null; } }}
ondimensions={(value) => { mapDimensions = value; }}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }}
onerror={(message) => { toast(`Live map unavailable: ${message}. Showing chunk grid.`, 'error'); mapFailed = true; }}
/>
{#if selectedServer?.map_address}
{#key `${serverId}:${selectedServer.map_address}`}
<BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
{claims} {myGuildId} {player} onpaint={paintChunks}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
{/key}
{:else}
<div class="grid-wrapper">
<div
class="chunk-grid"
style:grid-template-columns="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
style:grid-template-rows="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
>
{#each Array.from({ length: radius * 2 + 1 }, (_, r) => centerZ - radius + r) as cz}
{#each Array.from({ length: radius * 2 + 1 }, (_, c) => centerX - radius + c) as cx}
{@const claim = getClaimAt(cx, cz)}
{@const isMyClaim = claim && myGuildId && claim.guild_id === myGuildId}
{@const isOtherClaim = claim && (!myGuildId || claim.guild_id !== myGuildId)}
{@const isSelected = selectedChunk && selectedChunk.x === cx && selectedChunk.z === cz}
{@const isCenter = cx === 0 && cz === 0}
<button
class="chunk-cell"
class:my-claim={isMyClaim}
class:other-claim={isOtherClaim}
class:selected={isSelected}
class:is-center={isCenter}
onmouseenter={() => (hoveredChunk = { x: cx, z: cz })}
onmouseleave={() => (hoveredChunk = null)}
onclick={() => (selectedChunk = { x: cx, z: cz })}
title="Chunk [{cx}, {cz}] (Blocks {cx * 16}..{(cx + 1) * 16 - 1}, {cz * 16}..{(cz + 1) * 16 - 1})"
>
{#if isMyClaim}
<Shield size={16} class="claim-icon mine" />
<span class="cell-tag">{claim.guild_tag || myGuildTag || 'YOU'}</span>
{:else if isOtherClaim}
<Lock size={15} class="claim-icon rival" />
<span class="cell-tag">{claim.guild_tag || 'GUILD'}</span>
{:else if isCenter}
<span class="spawn-dot" title="World Origin"></span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
<div class="notice">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</div>
{/if}
<!-- Legend & Action Bar -->
<div class="map-footer">
<div class="info-block">
{#if selectedChunk}
<div class="selected-details">
<span class="chunk-title">Selected: Chunk <strong>[{selectedChunk.x}, {selectedChunk.z}]</strong></span>
<span class="block-range muted tiny">
X: {selectedChunk.x * 16}..{(selectedChunk.x + 1) * 16 - 1}, Z: {selectedChunk.z * 16}..{(selectedChunk.z + 1) * 16 - 1}
</span>
{#if selectedClaim}
<div class="claim-status">
{#if myGuildId && selectedClaim.guild_id === myGuildId}
<span class="badge mine"><Shield size={12} /> Claimed by Your Guild</span>
<button
class="danger sm action-btn"
onclick={handleUnclaim}
disabled={claiming}
>
<Trash2 size={13} /> Unclaim
</button>
{:else}
<span class="badge rival"><Lock size={12} /> Claimed by {selectedClaim.guild_name || 'Rival Guild'} [{selectedClaim.guild_tag || '?'}]</span>
<span class="tiny muted protected-note">Grief Protection Active</span>
{/if}
</div>
{:else}
<div class="unclaimed-status">
<span class="badge unclaimed">Wilderness (Unclaimed)</span>
{#if myGuildId}
<button
class="primary sm action-btn"
onclick={handleClaim}
disabled={claiming}
>
{#if claiming}<LoaderCircle size={13} class="spin" />{:else}<Plus size={13} />{/if}
Claim Chunk
</button>
{:else}
<span class="tiny muted">Join or create a guild to claim land</span>
{/if}
</div>
{/if}
</div>
{:else if hoveredChunk}
<div class="hover-info">
<span>Chunk: <strong>[{hoveredChunk.x}, {hoveredChunk.z}]</strong></span>
<span class="muted tiny">Click to view details or claim</span>
</div>
{:else}
<div class="hint-info muted tiny">
<span>Click any grid square to inspect or claim chunks. Claimed chunks are protected in-game against block edits by other players.</span>
</div>
{/if}
</div>
<div class="legend">
<div class="legend-item"><span class="legend-box mine"></span><span>My Guild ({myClaimsCount})</span></div>
<div class="legend-item"><span class="legend-box rival"></span><span>Rival Guild</span></div>
<div class="legend-item"><span class="legend-box wild"></span><span>Wilderness</span></div>
</div>
<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}
</div>
</div>
<style>
.chunk-map-container {
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 80%, transparent);
border: 1px solid var(--line);
user-select: none;
}
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.6rem 1rem; }
.tb-group { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.server-pick { position: relative; display: inline-flex; align-items: center; }
.server-pick :global(svg) { position: absolute; left: 0.7rem; color: var(--muted); pointer-events: none; }
.server-pick select { width: auto; min-width: 10rem; padding: 0.4rem 2rem 0.4rem 2rem; font-size: 0.82rem; font-weight: 560; border-radius: 99rem; }
.server-chip { padding: 0.4rem 0.8rem; border-radius: 99rem; font-size: 0.82rem; }
.live-chip { border-radius: 99rem; color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
.pulse { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 60%, transparent); animation: pulse 2s infinite; }
@keyframes pulse { 70% { box-shadow: 0 0 0 0.4rem transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
.dim-selector { display: flex; background: color-mix(in srgb, var(--bg) 60%, transparent); padding: 0.2rem; border-radius: 99rem; border: 1px solid var(--line); gap: 0.15rem; }
.dim-btn { display: flex; align-items: center; gap: 0.35rem; background: transparent; border: none; padding: 0.3rem 0.8rem; font-size: 0.8rem; font-weight: 560; color: var(--muted); border-radius: 99rem; transition: all 0.15s; }
.dim-btn:hover:not(:disabled) { color: var(--text); }
.dim-btn.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); }
.coord-label { font-size: 0.78rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.pad { display: inline-flex; padding: 0.15rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 60%, transparent); }
.notice { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); font-size: 0.85rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); }
.grid-wrapper {
overflow: auto;
display: flex;
justify-content: center;
padding: 0.5rem;
background: color-mix(in srgb, var(--bg) 80%, black);
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
max-height: 24rem;
}
.chunk-grid {
display: grid;
gap: 2px;
}
.chunk-cell {
width: 38px;
height: 38px;
padding: 0;
border-radius: 4px;
background: color-mix(in srgb, var(--surface) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
transition: transform 0.1s, border-color 0.15s, background 0.15s;
}
.chunk-cell:hover {
transform: scale(1.08);
z-index: 10;
border-color: var(--text);
background: color-mix(in srgb, var(--text) 12%, transparent);
}
.chunk-cell.selected {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent);
z-index: 15;
}
.chunk-cell.is-center::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: #f59e0b;
}
.chunk-cell.my-claim {
background: color-mix(in srgb, var(--success) 22%, transparent);
border-color: color-mix(in srgb, var(--success) 70%, transparent);
}
.chunk-cell.other-claim {
background: color-mix(in srgb, var(--danger) 22%, transparent);
border-color: color-mix(in srgb, var(--danger) 70%, transparent);
}
:global(.claim-icon.mine) {
color: var(--success);
}
:global(.claim-icon.rival) {
color: var(--danger);
}
.cell-tag {
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
margin-top: 1px;
max-width: 34px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
}
.map-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
padding-top: 0.5rem;
border-top: 1px solid var(--line);
}
.info-block {
flex: 1;
min-width: 14rem;
}
.selected-details {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.chunk-title {
font-size: 0.88rem;
}
.claim-status, .unclaimed-status {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.2rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.18rem 0.55rem;
border-radius: 99rem;
}
.badge.mine {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--success);
}
.badge.rival {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.badge.unclaimed {
background: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--muted);
}
.protected-note {
font-style: italic;
}
.legend {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.76rem;
color: var(--muted);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.legend-box {
width: 12px;
height: 12px;
border-radius: 3px;
border: 1px solid transparent;
}
.legend-box.mine {
background: color-mix(in srgb, var(--success) 35%, transparent);
border-color: var(--success);
}
.legend-box.rival {
background: color-mix(in srgb, var(--danger) 35%, transparent);
border-color: var(--danger);
}
.legend-box.wild {
background: color-mix(in srgb, var(--surface) 80%, transparent);
border-color: var(--line);
}
.claim-map { display: flex; flex-direction: column; gap: 0.8rem; padding: 1rem; border: 1px solid var(--line); border-radius: var(--radius); }
.toolbar, .group, .footer, .server, .chip, .legend { display: flex; align-items: center; gap: 0.6rem; }
.toolbar, .footer { justify-content: space-between; flex-wrap: wrap; }
.group { flex-wrap: wrap; }
.server select { width: auto; min-width: 10rem; }
.dimensions { display: flex; gap: 0.2rem; padding: 0.2rem; background: color-mix(in srgb, var(--bg) 70%, transparent); border-radius: 99px; }
.dimensions button { display: flex; gap: 0.3rem; align-items: center; border: 0; border-radius: 99px; background: transparent; color: var(--muted); padding: 0.35rem 0.65rem; font-size: 0.8rem; }
.dimensions button.active { background: color-mix(in srgb, var(--accent) 24%, transparent); color: var(--text); }
.position, .footer, .notice { color: var(--muted); font-size: 0.8rem; }
.notice { min-height: 420px; display: grid; place-items: center; text-align: center; border-radius: 10px; background: #142333; padding: 1rem; }
.footer { border-top: 1px solid var(--line); padding-top: 0.65rem; }
.footer span { display: inline-flex; align-items: center; gap: 0.3rem; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.legend .mine { background: #4ade80; }.legend .other { background: #fb7185; }
</style>
@@ -71,6 +71,15 @@
<div class="card col">
<strong>Vanilla game settings</strong>
<span class="help">Brightness, graphics, FOV, view distance, sound and other vanilla options are saved automatically when the game closes, then restored after instance updates.</span>
<label class="field">
<span class="row">Field of view <span class="spacer"></span><strong>{Math.round(70 + 40 * Number(app.settings.instance_game_options?.[inst.id]?.fov ?? '0'))}°</strong></span>
<input type="range" min="-1" max="1" step="0.025" value={app.settings.instance_game_options?.[inst.id]?.fov ?? '0'} oninput={(e) => {
app.settings!.instance_game_options[inst.id] ??= {};
app.settings!.instance_game_options[inst.id].fov = e.currentTarget.value;
invoke('set_instance_fov', { instanceId: inst.id, fov: Number(e.currentTarget.value) }).catch((err) => toast(errorText(err), 'error'));
}} />
<span class="help">Applied the next time you launch this instance.</span>
</label>
<span class="tiny muted">{Object.keys(app.settings.instance_game_options?.[inst.id] ?? {}).length} saved options for this instance</span>
<button class="sm" disabled={app.running.some((g) => g.instance_id === inst.id)} onclick={async () => {
try {
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, Globe, Flame, Sparkles, Users, LoaderCircle, RefreshCw, MapPinOff } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { abs } from '../lib/store.svelte';
import VantageMap from './VantageMap.svelte';
let { serverId, serverName, onclose }: { serverId: number; serverName: string; onclose: () => void } = $props();
type Info = {
enabled: boolean;
ready: boolean;
message: string;
base: string;
players: number;
dimensions: { id: string; slug: string; label: string; available: boolean }[];
};
let info = $state<Info | null>(null);
let slug = $state('overworld');
let error = $state('');
let failed = $state('');
let loaded = $state(false);
const icons: Record<string, any> = { overworld: Globe, the_nether: Flame, the_end: Sparkles };
const url = $derived(info?.ready ? abs(`${info.base}/${slug}/v1/worlds/default/manifest.json`) : null);
async function refresh() {
try {
info = await invoke<Info>('get_livemap_info', { serverId });
error = '';
if (!info.dimensions.find((d) => d.slug === slug)?.available) slug = info.dimensions.find((d) => d.available)?.slug ?? 'overworld';
} catch (e: any) {
error = typeof e === 'string' ? e : e?.message ?? 'Unable to reach the panel';
}
}
onMount(() => {
refresh();
// While the map is still being prepared, keep checking; afterwards only the roster count changes.
const t = setInterval(refresh, 15_000);
return () => clearInterval(t);
});
</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{info?.ready ? ` · ${info.players} ${info.players === 1 ? 'player' : 'players'} online` : ''}</span>
</div>
</div>
{#if info?.ready}
<nav class="dims">
{#each info.dimensions as d (d.slug)}
{@const Icon = icons[d.slug] ?? Globe}
<button class="dim" class:on={slug === d.slug} disabled={!d.available} onclick={() => { slug = d.slug; failed = ''; loaded = false; }}>
<Icon size={14} /> {d.label}
</button>
{/each}
</nav>
{/if}
{#if info?.ready}<span class="chip players"><Users size={13} /> {info.players}</span>{/if}
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#if url && !failed}
{#key `${serverId}:${slug}`}
<VantageMap
world={url}
{serverId}
central
fill
onready={() => (loaded = true)}
onerror={(m) => (failed = m)}
/>
{/key}
{#if !loaded}<div class="note"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
{:else}
<div class="note card">
{#if failed}
<MapPinOff size={30} />
<h3>Couldn't load the map</h3>
<p class="muted selectable">{failed}</p>
<button class="primary" onclick={() => { failed = ''; loaded = false; refresh(); }}><RefreshCw size={15} /> Try again</button>
{:else if error}
<MapPinOff size={30} />
<h3>Live Map unavailable</h3>
<p class="muted selectable">{error}</p>
<button class="primary" onclick={refresh}><RefreshCw size={15} /> Try again</button>
{:else if info}
<LoaderCircle class="spin" size={30} />
<h3>{info.enabled ? 'The map is being prepared' : 'Live Map is off'}</h3>
<p class="muted">{info.message || 'Hang on…'}</p>
<p class="tiny muted">This page checks again automatically.</p>
{:else}
<LoaderCircle class="spin" size={28} />
{/if}
</div>
{/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: 1rem; padding: 0.65rem 0.8rem 0.65rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.75rem; min-width: 0; margin-right: auto; }
.title div { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.title strong { font-size: 0.98rem; font-weight: 620; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.title .muted { font-size: 0.78rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 50%, transparent); flex-shrink: 0; }
.live.on { background: var(--success); box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--success) 22%, transparent); }
.dims { display: flex; gap: 0.3rem; padding: 0.2rem; border-radius: 99px; background: color-mix(in srgb, var(--text) 6%, transparent); }
.dim { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.85rem; border-radius: 99px; font-size: 0.82rem; border: 0; background: transparent; color: var(--muted); }
.dim:hover:not(:disabled) { color: var(--text); }
.dim.on { background: var(--accent); color: #fff; }
.dim:disabled { opacity: 0.35; }
.players { display: inline-flex; align-items: center; gap: 0.35rem; }
.close { flex-shrink: 0; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #102026; }
.stage :global(.vantage-map) { position: absolute; inset: 0; }
.note { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; max-width: 26rem; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; padding: 1.6rem 2rem; text-align: center; color: #e5e7eb; }
.stage > .note:not(.card) { inset: auto 1rem 1rem auto; margin: 0; flex-direction: row; padding: 0.55rem 0.9rem; border-radius: 99px; background: rgba(9, 18, 22, 0.8); font-size: 0.85rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); color: var(--text); }
.card h3 { font-size: 1.05rem; }
</style>
-208
View File
@@ -1,208 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import type * as Three from 'three';
import type { VantageViewer } from '@thoughts-on-things/vantage-mc/three';
import type { GuildClaim } from '../lib/types';
import { invoke, inTauri } from '../lib/tauri';
let { world, serverId, claims = [], dimension = 'minecraft:overworld', myGuildId = null, central = false, fill = false, onselect = () => {}, ondimension = () => {}, ondimensions = () => {}, oncenterchange = () => {}, onready = () => {}, onerror }: {
world: string;
serverId: number;
claims?: GuildClaim[];
dimension?: string;
myGuildId?: string | null;
/** Served by the panel's central live map (session-authenticated) rather than an external Vantage host. */
central?: boolean;
/** Fill the parent's height instead of using the compact territory size. */
fill?: boolean;
onselect?: (x: number, z: number) => void;
ondimension?: (dimension: 'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end') => void;
ondimensions?: (dimensions: string[]) => void;
oncenterchange?: (x: number, z: number) => void;
onready?: () => void;
onerror: (message: string) => void;
} = $props();
let host: HTMLDivElement;
let THREE: typeof import('three');
let viewer: VantageViewer | null = null;
let overlay: Three.Group | null = null;
let error = $state('');
let ready = $state(false);
function surface(x: number, z: number): number {
return (viewer?.controls.heightAt?.(x, z) ?? viewer?.controls.position.y ?? 64) + 2;
}
function label(text: string, mine: boolean): Three.Sprite {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 96;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = mine ? 'rgba(9, 65, 46, .94)' : 'rgba(70, 27, 37, .94)';
ctx.roundRect(0, 4, 512, 88, 15);
ctx.fill();
ctx.font = 'bold 35px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.fillText(text.slice(0, 28), 256, 48, 484);
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false, transparent: true }));
sprite.scale.set(24, 4.5, 1);
sprite.renderOrder = 1001;
return sprite;
}
function clearOverlay() {
if (!overlay || !viewer) return;
viewer.scene.remove(overlay);
overlay.traverse((node) => {
if (node instanceof THREE.Line) { node.geometry.dispose(); (node.material as Three.Material).dispose(); }
if (node instanceof THREE.Sprite) {
node.material.map?.dispose();
node.material.dispose();
}
});
overlay = null;
}
function drawClaims() {
if (!viewer || !ready) return;
clearOverlay();
const group = new THREE.Group();
for (const claim of claims.slice(0, 1000)) {
const mine = claim.guild_id === myGuildId;
const x = claim.chunk_x * 16;
const z = claim.chunk_z * 16;
const points: Three.Vector3[] = [];
for (let t = 0; t <= 16; t += 4) points.push(new THREE.Vector3(x + t, surface(x + t, z), z));
for (let t = 4; t <= 16; t += 4) points.push(new THREE.Vector3(x + 16, surface(x + 16, z + t), z + t));
for (let t = 12; t >= 0; t -= 4) points.push(new THREE.Vector3(x + t, surface(x + t, z + 16), z + 16));
for (let t = 12; t >= 0; t -= 4) points.push(new THREE.Vector3(x, surface(x, z + t), z + t));
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineBasicMaterial({ color: mine ? 0x4ade80 : 0xfb7185, depthTest: false, linewidth: 2 }));
line.renderOrder = 1000;
group.add(line);
const name = label(claim.guild_name || claim.guild_tag || 'Guild', mine);
name.position.set(x + 8, surface(x + 8, z + 8) + 4, z + 8);
group.add(name);
}
viewer.scene.add(group);
overlay = group;
viewer.invalidate();
}
$effect(() => { claims; myGuildId; drawClaims(); });
onMount(() => {
let cancelled = false;
let timer: ReturnType<typeof setInterval> | undefined;
let redraw: ReturnType<typeof setTimeout> | undefined;
let down: { x: number; y: number } | null = null;
let lastX = Number.NaN;
let lastZ = Number.NaN;
const downHandler = (e: PointerEvent) => { down = { x: e.clientX, y: e.clientY }; };
const upHandler = (e: PointerEvent) => {
if (!viewer || !down || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return;
const rect = viewer.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2((e.clientX - rect.left) / rect.width * 2 - 1, -(e.clientY - rect.top) / rect.height * 2 + 1);
const ray = new THREE.Raycaster();
ray.setFromCamera(ndc, viewer.camera);
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -viewer.controls.position.y);
const hit = new THREE.Vector3();
if (!ray.ray.intersectPlane(plane, hit)) return;
for (let i = 0; i < 4; i++) {
plane.constant = -surface(hit.x, hit.z);
if (!ray.ray.intersectPlane(plane, hit)) break;
}
onselect(Math.floor(hit.x / 16), Math.floor(hit.z / 16));
};
Promise.all([import('three'), import('@thoughts-on-things/vantage-mc/three'), import('@thoughts-on-things/vantage-mc/core')]).then(async ([three, vantage, core]) => {
THREE = three;
const source = inTauri ? core.worldFromHttp(world, { fetch: async (url, init) => {
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const headers = new Headers(init?.headers);
const result = await invoke<{ status: number; data: string | null; etag: string | null }>(central ? 'fetch_livemap_resource' : 'fetch_vantage_resource', {
serverId, url, etag: headers.get('if-none-match')
});
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const body = result.data ? Uint8Array.from(atob(result.data), (c) => c.charCodeAt(0)) : null;
return new Response(body, { status: result.status, headers: result.etag ? { etag: result.etag } : {} });
} }) : Promise.resolve(world);
const resolved = await source;
if (cancelled) return null;
const v = new vantage.VantageViewer(host, { view: 'top', players: { offline: false } });
try {
await v.load({ world: resolved, view: 'top' });
return v;
} catch (e) {
v.dispose();
throw e;
}
}).then((v) => {
if (!v) return;
if (cancelled) { v.dispose(); return; }
viewer = v;
ready = true;
onready();
// A central map serves one manifest per dimension, so the dimension is chosen by URL, not by the viewer.
if (!central) {
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [v.dimension?.id || 'minecraft:overworld']);
const initialId = v.dimension?.id;
if (initialId === 'minecraft:overworld' || initialId === 'minecraft:the_nether' || initialId === 'minecraft:the_end') ondimension(initialId);
}
const canvas = v.renderer.domElement;
canvas.addEventListener('pointerdown', downHandler);
canvas.addEventListener('pointerup', upHandler);
v.on('dimension', ({ current }) => {
if (central) return;
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [current?.id || 'minecraft:overworld']);
const id = current?.id;
if (id === 'minecraft:overworld' || id === 'minecraft:the_nether' || id === 'minecraft:the_end') ondimension(id);
drawClaims();
});
v.on('stats', () => {
if (redraw) clearTimeout(redraw);
redraw = setTimeout(drawClaims, 250);
});
timer = setInterval(() => {
const cx = Math.floor(v.controls.position.x / 16);
const cz = Math.floor(v.controls.position.z / 16);
if (!Number.isFinite(lastX) || Math.abs(cx - lastX) >= 8 || Math.abs(cz - lastZ) >= 8) {
lastX = cx; lastZ = cz; oncenterchange(cx, cz);
drawClaims();
}
}, 1000);
drawClaims();
}).catch((e) => { if (!cancelled) { error = e instanceof Error ? e.message : String(e); onerror(error); } });
return () => {
cancelled = true;
if (timer) clearInterval(timer);
if (redraw) clearTimeout(redraw);
viewer?.renderer.domElement.removeEventListener('pointerdown', downHandler);
viewer?.renderer.domElement.removeEventListener('pointerup', upHandler);
clearOverlay();
viewer?.dispose();
viewer = null;
};
});
$effect(() => {
if (!central && viewer && ready && viewer.dimension && viewer.dimension.id !== dimension && viewer.dimensions.some((d) => d.id === dimension)) {
void viewer.setDimension(dimension).catch((e) => { error = String(e); });
}
});
</script>
<div class="vantage-map" class:fill bind:this={host}>
{#if !ready && !error}<div class="map-message">Loading live terrain map…</div>{/if}
{#if error}<div class="map-message error">Map unavailable: {error}</div>{/if}
</div>
<style>
.vantage-map { height: min(60vh, 680px); min-height: 430px; position: relative; overflow: hidden; border-radius: 14px; background: #102026; }
.vantage-map.fill { height: 100%; min-height: 0; border-radius: 0; }
.vantage-map :global(canvas) { display: block; }
.map-message { position: absolute; z-index: 1; inset: 0; display: grid; place-items: center; padding: 24px; color: #e5e7eb; text-align: center; pointer-events: none; }
.error { background: #102026; color: #fda4af; }
</style>
+2 -2
View File
@@ -137,9 +137,9 @@ 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_url: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
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: false, message: 'The map generator isn\'t set up on the panel yet.', base: '/api/livemap/1', players: 0,
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 }] };
case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
+2 -1
View File
@@ -222,6 +222,7 @@ export interface Guild {
name: string;
tag: string;
description: string;
motd?: string;
icon_url: string | null;
banner_url: string | null;
leader_uuid: string;
@@ -234,7 +235,7 @@ export interface Guild {
export interface GuildMember {
uuid: string;
name: string;
role: 'leader' | 'officer' | 'member';
role: string;
joined_at: string;
online?: boolean;
}
+7
View File
@@ -12,6 +12,10 @@
{ usage: '/delhome <name>', does: 'Delete one of your homes.', perm: 'scopenet.command.delhome', aliases: ['rmhome'] },
{ usage: '/back', does: 'Return to where you were, or where you died.', perm: 'scopenet.command.back', aliases: ['return'] },
{ usage: '/warp [name]', does: 'Go to a server warp, or open the warps menu.', perm: 'scopenet.command.warp', aliases: ['warps'] },
{ usage: '/reqwarp <name>', does: 'Request a public warp at your current location.', perm: 'scopenet.command.reqwarp' },
{ usage: '/warprequests', does: 'Staff: list pending warp requests.', perm: 'scopenet.command.warp.manage' },
{ usage: '/warpapprove <name> · /warpdeny <name>', does: 'Staff: approve or deny a warp request.', perm: 'scopenet.command.warp.manage' },
{ usage: '/setwarp <name> · /delwarp <name>', does: 'Staff: create or delete public warps.', perm: 'scopenet.command.warp.manage' },
{ usage: '/rtp', does: 'Jump to a random safe spot in the wild.', perm: 'scopenet.command.rtp', aliases: ['wild', 'randomtp'] },
{ usage: '/tpa <player>', does: 'Ask to teleport to another player.', perm: 'scopenet.command.tpa' },
{ usage: '/tpaccept', does: 'Accept a teleport request.', perm: 'scopenet.command.tpaccept', aliases: ['tpyes'] },
@@ -43,6 +47,7 @@
{ usage: '/guild accept [tag] · /guild decline [tag]', does: 'Answer a guild invitation. You can also answer in the launcher.', perm: 'scopenet.command.guild.accept' },
{ usage: '/claim', does: 'Claim the chunk you stand in for your guild.', perm: 'scopenet.command.claim' },
{ usage: '/unclaim', does: 'Give up the chunk you stand in.', perm: 'scopenet.command.unclaim' },
{ usage: '/autoclaim on|off', does: 'Automatically claim new chunks as you walk.', perm: 'scopenet.command.claim' },
] },
{ title: 'Guild bank', blurb: 'Shared guild money. Officers and leaders can spend it.', cmds: [
{ usage: '/guild bank', does: 'Show the bank balance and recent activity.', perm: 'scopenet.command.guild.bank' },
@@ -54,6 +59,8 @@
] },
{ title: 'Info & admin', blurb: 'Status and help.', cmds: [
{ usage: '/playtime', does: 'Check your total and session playtime.', perm: 'scopenet.command.playtime', aliases: ['ontime'] },
{ usage: '/hand', does: 'Share your held item as an inspectable chat link.', perm: 'scopenet.command.hand' },
{ usage: '/nick <name|off>', does: 'Set or remove your chat nickname.', perm: 'scopenet.command.nick' },
{ usage: '/scopenet help', does: 'List SCOPENET commands.', perm: 'scopenet.command.scopenet.help', aliases: ['sn'] },
{ usage: '/scopenet status', does: 'Panel connection, features and plugin integrations (admins).', perm: 'scopenet.command.scopenet.status' },
{ usage: '/scopenet reload', does: 'Reload the plugin config (admins).', perm: 'scopenet.command.scopenet.reload' },
+108 -2
View File
@@ -17,7 +17,16 @@
let allGuilds = $state<Guild[]>([]);
let members = $state<GuildMember[]>([]);
let posts = $state<GuildPost[]>([]);
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet'>('territory');
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet' | 'settings'>('territory');
type GuildRole = { id: number; name: string; can_invite: boolean; can_kick: boolean; can_claim: boolean; can_post: boolean; can_manage: boolean };
type JoinRequest = { uuid: string; name: string; message: string; created_at: string };
let roles = $state<GuildRole[]>([]);
let joinRequests = $state<JoinRequest[]>([]);
let newRoleName = $state('');
let roleFlags = $state({ can_invite: false, can_kick: false, can_claim: false, can_post: true, can_manage: false });
let guildEdit = $state({ description: '', motd: '', icon_url: '', banner_url: '' });
const myRole = $derived(members.find((m) => m.uuid === activeAccount()?.uuid)?.role ?? '');
const canManageDetails = $derived(myRole === 'leader' || myRole === 'officer' || !!roles.find((r) => r.name === myRole)?.can_manage);
let loading = $state(false);
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
@@ -58,9 +67,14 @@
]);
members = mList;
posts = pList;
roles = await invoke<GuildRole[]>('get_guild_roles', { guildId: mine.id }).catch(() => []);
joinRequests = await invoke<JoinRequest[]>('get_guild_join_requests', { guildId: mine.id }).catch(() => []);
guildEdit = { description: mine.description ?? '', motd: mine.motd ?? '', icon_url: mine.icon_url ?? '', banner_url: mine.banner_url ?? '' };
} else {
members = [];
posts = [];
roles = [];
joinRequests = [];
}
} catch (e: any) {
toast(e?.message ?? 'Failed to load guild data', 'error');
@@ -85,6 +99,59 @@
}
}
async function requestJoin(guild: Guild) {
try {
await invoke('request_guild_join', { guildId: guild.id, message: '' });
toast(`Request sent to ${guild.name}`, 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function answerJoin(req: JoinRequest, accept: boolean) {
if (!myGuild) return;
try {
await invoke('respond_guild_join_request', { guildId: myGuild.id, uuid: req.uuid, accept });
toast(accept ? `${req.name} joined the guild` : 'Request declined', 'ok');
await loadInstanceGuildData();
} catch (e: any) { toast(String(e), 'error'); }
}
async function createRole() {
if (!myGuild || !newRoleName.trim()) return;
try {
await invoke('create_guild_role', { guildId: myGuild.id, role: { name: newRoleName.trim(), ...roleFlags } });
newRoleName = '';
roles = await invoke<GuildRole[]>('get_guild_roles', { guildId: myGuild.id });
toast('Guild role created', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function assignRole(member: GuildMember, role: string) {
if (!myGuild) return;
try {
await invoke('assign_guild_role', { guildId: myGuild.id, uuid: member.uuid, role });
member.role = role as GuildMember['role'];
toast('Guild role assigned', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function deleteRole(role: GuildRole) {
if (!myGuild) return;
try {
await invoke('delete_guild_role', { guildId: myGuild.id, roleId: role.id });
await loadInstanceGuildData();
toast('Guild role deleted', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
async function saveGuild() {
if (!myGuild) return;
try {
await invoke('update_guild', { guildId: myGuild.id, description: guildEdit.description, motd: guildEdit.motd, iconUrl: guildEdit.icon_url, bannerUrl: guildEdit.banner_url });
await loadInstanceGuildData();
toast('Guild details saved', 'ok');
} catch (e: any) { toast(String(e), 'error'); }
}
onMount(async () => {
loadInvites();
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
@@ -242,6 +309,7 @@
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
<Landmark size={15} /> Treasury
</button>
{#if canManageDetails}<button class:active={activeTab === 'settings'} onclick={() => (activeTab = 'settings')}>Guild settings</button>{/if}
</div>
</div>
@@ -270,6 +338,13 @@
<GuildWallet guild={myGuild} {members} servers={walletServers} instanceId={selectedInstId} />
{:else if activeTab === 'members'}
<div class="members-tab">
{#if joinRequests.length}
<div class="card"><h3>Join requests</h3>
{#each joinRequests as req}
<div class="row"><strong>{req.name}</strong><span>{req.message}</span><span class="spacer"></span><button class="sm" onclick={() => answerJoin(req, false)}>Decline</button><button class="sm primary" onclick={() => answerJoin(req, true)}>Accept</button></div>
{/each}
</div>
{/if}
<div class="members-grid">
{#each members as m (m.uuid)}
<div
@@ -292,16 +367,42 @@
<span class="badge leader-badge"><Crown size={11} /> Leader</span>
{:else if m.role === 'officer'}
<span class="badge officer-badge"><UserCheck size={11} /> Officer</span>
{:else}
{:else if m.role === 'member'}
<span class="badge member-role-badge">Member</span>
{:else}
<span class="badge member-role-badge">{m.role}</span>
{/if}
</div>
<span class="tiny muted">Joined {new Date(m.joined_at).toLocaleDateString()}</span>
{#if myGuild.leader_uuid === activeAccount()?.uuid && m.uuid !== activeAccount()?.uuid}
<select value={m.role} aria-label="Role for {m.name}" onchange={(e) => assignRole(m, e.currentTarget.value)}>
<option value="member">Member</option><option value="officer">Officer</option>
{#each roles as role}<option value={role.name}>{role.name}</option>{/each}
</select>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if activeTab === 'settings'}
<div class="card col">
<h3>Guild appearance</h3>
<label>Description<textarea bind:value={guildEdit.description} maxlength="500"></textarea></label>
<label>Message of the day<input bind:value={guildEdit.motd} maxlength="200" /></label>
<label>Icon URL<input bind:value={guildEdit.icon_url} type="url" /></label>
<label>Banner URL<input bind:value={guildEdit.banner_url} type="url" /></label>
<button class="primary" onclick={saveGuild}>Save guild details</button>
</div>
{#if myRole === 'leader'}<div class="card col">
<h3>Guild roles</h3>
{#each roles as role}
<div class="row"><strong>{role.name}</strong><span class="tiny muted">{[role.can_invite && 'Invite', role.can_kick && 'Kick', role.can_claim && 'Claim', role.can_post && 'Post', role.can_manage && 'Manage'].filter(Boolean).join(', ') || 'No extra permissions'}</span><span class="spacer"></span><button class="sm danger" onclick={() => deleteRole(role)}>Delete</button></div>
{/each}
<label>New role name<input bind:value={newRoleName} maxlength="24" /></label>
<div class="row"><label><input type="checkbox" bind:checked={roleFlags.can_invite} /> Invite</label><label><input type="checkbox" bind:checked={roleFlags.can_kick} /> Kick</label><label><input type="checkbox" bind:checked={roleFlags.can_claim} /> Claim</label><label><input type="checkbox" bind:checked={roleFlags.can_post} /> Post</label><label><input type="checkbox" bind:checked={roleFlags.can_manage} /> Manage</label></div>
<button class="primary" onclick={createRole}>Create role</button>
</div>{/if}
{:else if activeTab === 'feed'}
<div class="feed-tab">
<!-- Post Announcement Form -->
@@ -407,6 +508,7 @@
{#if g.description}
<p class="dir-desc">{g.description}</p>
{/if}
<button class="sm primary" onclick={() => requestJoin(g)}>Request to join</button>
</div>
{/each}
</div>
@@ -436,6 +538,10 @@
<span class="field-label">Banner Image URL (Optional)</span>
<input type="url" placeholder="https://example.com/banner.png" bind:value={newGuildBanner} />
</label>
<label>
<span class="field-label">Icon Image URL (Optional)</span>
<input type="url" placeholder="https://example.com/guild-icon.png" bind:value={newGuildIcon} />
</label>
</div>
{#snippet footer()}
<button class="ghost" onclick={() => (createModalOpen = false)}>Cancel</button>
+5 -6
View File
@@ -4,7 +4,6 @@
import ServerStatus from '../components/ServerStatus.svelte';
import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte';
import LiveMapModal from '../components/LiveMapModal.svelte';
import BlueMapModal from '../components/BlueMapModal.svelte';
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format';
@@ -14,7 +13,7 @@
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 the panel's Live Map switched on.
// A game server linked to this instance with BlueMap configured.
let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
let mapOpen = $state(false);
$effect(() => {
@@ -23,8 +22,8 @@
mapOpen = false;
if (!id) return;
let alive = true;
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; live_map?: boolean; map_address?: string }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && (s.map_address || s.live_map)) ?? null; })
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; })
.catch(() => {});
return () => { alive = false; };
});
@@ -58,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 the live 3D map"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open BlueMap"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
</div>
</section>
@@ -83,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)} />{:else if mapOpen && mapServer}<LiveMapModal serverId={mapServer.id} serverName={mapServer.name} onclose={() => (mapOpen = false)} />{/if}
{#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{/if}
</div>
<style>
-1
View File
@@ -750,7 +750,6 @@
<button class="sm link-btn" disabled={checking} onclick={checkUpdate}>{#if checking}<LoaderCircle class="spin" size={14} />{:else}<RefreshCw size={14} />{/if} Check for updates</button>
{/if}
<p class="tiny muted">Built with Tauri, Rust and Svelte. Minecraft is a trademark of Mojang Studios; this launcher is not affiliated with Mojang or Microsoft.</p>
{#if app.boot?.app.repo}<button class="sm link-btn" onclick={() => openUrl(`https://github.com/${app.boot?.app.repo}`)}>Source code</button>{/if}
</div>
{/if}
</div>