Integrate Vantage live map for guild claims [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 11:02:51 -04:00
1 parent 52de8c3031
commit 602e7fd0f0
17 files changed
+470 -21

No files matched your search

+32 -5
View File
@@ -7,6 +7,7 @@
import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte';
let {
instanceId,
@@ -22,7 +23,7 @@
onunclaim?: (claimId: number) => void;
} = $props();
let servers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string }>>([]);
let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0);
@@ -31,8 +32,13 @@
let claims = $state<GuildClaim[]>([]);
let loading = $state(false);
let claiming = $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);
const mapUrl = $derived(servers.find((s) => s.id === serverId)?.map_url || '');
$effect(() => { mapUrl; mapFailed = false; mapDimensions = []; });
const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
@@ -41,20 +47,23 @@
] as const;
async function loadClaims() {
const request = ++claimLoad;
if (!instanceId || !serverId) { claims = []; return; }
loading = true;
claims = [];
try {
claims = await invoke<GuildClaim[]>('get_guild_claims', {
const next = await invoke<GuildClaim[]>('get_guild_claims', {
instanceId,
serverId,
centerX,
centerZ,
dimension
});
if (request === claimLoad) claims = next;
} catch (e: any) {
toast(e?.message ?? 'Failed to load territory claims', 'error');
if (request === claimLoad) toast(e?.message ?? 'Failed to load territory claims', 'error');
} finally {
loading = false;
if (request === claimLoad) loading = false;
}
}
@@ -149,7 +158,8 @@
<button
class="dim-btn"
class:active={dimension === dim.id}
onclick={() => { dimension = dim.id; selectedChunk = null; }}
disabled={!!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(dim.id)}
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
>
<DimIcon size={14} />
<span>{dim.label}</span>
@@ -175,6 +185,22 @@
</div>
<!-- Main Chunk Map Canvas / Grid -->
{#if mapUrl && !mapFailed}
{#key `${serverId}:${mapUrl}`}
<VantageMap
world={mapUrl}
serverId={serverId!}
{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; }}
/>
{/key}
{:else}
<div class="grid-wrapper">
<div
class="chunk-grid"
@@ -214,6 +240,7 @@
{/each}
</div>
</div>
{/if}
<!-- Legend & Action Bar -->
<div class="map-footer">
+187
View File
@@ -0,0 +1,187 @@
<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, myGuildId, onselect, ondimension, ondimensions, oncenterchange, onerror }: {
world: string;
serverId: number;
claims: GuildClaim[];
dimension: string;
myGuildId: string | null;
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;
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 }>('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);
return vantage.VantageViewer.mount(host, { world: await source, view: 'top', players: { offline: false } });
}).then((v) => {
if (cancelled) { v.dispose(); return; }
viewer = v;
ready = true;
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 }) => {
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 (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" 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 :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>
+12
View File
@@ -124,6 +124,18 @@ async function fakeLaunch(id: string) {
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40);
switch (cmd) {
case 'get_public_servers':
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_url: '', online: true, players_online: 42, players_max: 100 }] };
case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
case 'get_leaderboard':
return { leaderboard: [], totals: { players: 0, playtime_secs: 0 }, sort: args?.sort ?? 'playtime_secs' };
case 'get_economy_balances':
return [{ server_id: 1, server_name: 'Survival SMP', balance: 2450.75, currency_symbol: '$' }];
case 'get_server_baltop':
return [{ rank: 1, uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', username: 'Notch', balance: 2450.75 }];
case 'get_transactions':
return [{ id: 1, server_id: 1, server_name: 'Survival SMP', from_uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', from_name: 'Notch', to_uuid: 'friend-1', to_name: 'Alex', amount: 150, description: 'Payment to Alex', created_at: '2026-09-29T15:30:00Z' }];
case 'bootstrap': {
const b: Bootstrap = {
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
+4 -4
View File
@@ -345,17 +345,17 @@ export interface ServerEconomyBalance {
server_name: string;
balance: number;
currency_symbol: string;
last_updated: string;
}
export interface EconomyTransaction {
id: number;
server_id: number;
server_name?: string;
sender_uuid: string;
recipient_uuid: string;
from_uuid: string;
from_name: string;
to_uuid: string;
to_name: string;
amount: number;
tx_type: string;
description: string;
created_at: string;
}
+3 -5
View File
@@ -427,9 +427,7 @@
<span class="currency-sym">{bal.currency_symbol || '$'}</span>
<span>{bal.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<div class="econ-card-footer tiny muted">
Updated {formatDate(bal.last_updated)}
</div>
<div class="econ-card-footer tiny muted">Live server balance</div>
</button>
{/each}
</div>
@@ -508,12 +506,12 @@
<span class="text-right">Amount</span>
</div>
{#each transactions.slice(0, 15) as tx (tx.id)}
{@const isSender = tx.sender_uuid === me?.uuid}
{@const isSender = tx.from_uuid === me?.uuid}
<div class="tx-row">
<span class="tx-type-pill badge" class:send={isSender} class:receive={!isSender}>
{#if isSender}<ArrowUpRight size={12} /> Sent{:else}<ArrowDownLeft size={12} /> Received{/if}
</span>
<span class="tx-desc" title={tx.description}>{tx.description || tx.tx_type}</span>
<span class="tx-desc" title={tx.description}>{tx.description || `${tx.from_name} → ${tx.to_name}`}</span>
<span class="tx-date tiny muted">{new Date(tx.created_at).toLocaleString()}</span>
<span class="tx-amount text-right" class:negative={isSender} class:positive={!isSender}>
{isSender ? '-' : '+'}${Math.abs(tx.amount).toFixed(2)}