171 lines
8.7 KiB
Svelte
171 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
|
|
import { invoke } from '../lib/tauri';
|
|
import { activeAccount, toast } from '../lib/store.svelte';
|
|
import type { GuildClaim } from '../lib/types';
|
|
import ClaimTerrain from './ClaimTerrain.svelte';
|
|
|
|
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
|
|
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
|
|
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
|
|
} = $props();
|
|
type GameServer = { id: number; name: string; instance_id: string; map?: 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), centerZ = $state(0);
|
|
let player = $state<Player | null>(null);
|
|
let claims = $state<GuildClaim[]>([]);
|
|
let loading = $state(false), painting = $state(false);
|
|
let claimLoad = 0;
|
|
let centeredServer = 0;
|
|
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
|
|
const selectedServer = $derived(instanceServers.find((s) => s.id === serverId));
|
|
const dimensions = [
|
|
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
|
|
{ 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<{ players: Array<Player & { uuid: string }> }>('get_map_overlay', { serverId: id });
|
|
if (serverId !== id) return;
|
|
const mine = activeAccount()?.uuid?.toLowerCase();
|
|
player = info.players.find((p) => p.uuid.toLowerCase() === mine) ?? null;
|
|
if (player && centeredServer !== id) {
|
|
centeredServer = id;
|
|
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
|
|
centerX = Math.floor(player.x / 16);
|
|
centerZ = Math.floor(player.z / 16);
|
|
}
|
|
} catch { player = null; }
|
|
}
|
|
|
|
async function loadClaims() {
|
|
const request = ++claimLoad;
|
|
if (!serverId || !instanceId) { claims = []; return; }
|
|
loading = true;
|
|
try {
|
|
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(typeof e === 'string' ? e : e?.message ?? 'Could not load claims', 'error');
|
|
} finally { if (request === claimLoad) loading = false; }
|
|
}
|
|
|
|
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 {
|
|
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(); }
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 = 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="claim-map glass">
|
|
<div class="toolbar">
|
|
<div class="group">
|
|
{#if instanceServers.length > 1}
|
|
<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 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>
|
|
</div>
|
|
<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 selectedServer?.map}
|
|
{#key serverId}
|
|
<ClaimTerrain serverId={selectedServer.id} {dimension} {centerX} {centerZ}
|
|
{claims} {myGuildId} {player} onpaint={paintChunks}
|
|
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
|
|
{/key}
|
|
{:else}
|
|
<div class="notice">The map is switched off for this server. An admin can turn it on under Servers in the panel.</div>
|
|
{/if}
|
|
<div class="footer">
|
|
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
|
|
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
|
|
{#if painting}<span>Updating claims…</span>{/if}
|
|
{#if !player}<span class="hint">Your position appears once you are in game and the server's map is running.</span>{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.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>
|