Rework BlueMap claiming and expand ranks guilds chat and warps
This commit is contained in:
1 parent
434ed5338a
commit
6a2944a88e
68 files changed
+1453
-2500
No files matched your search
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user