493 lines
15 KiB
Svelte
493 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import {
|
|
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
|
|
MapPin, Globe, LoaderCircle, CheckCircle2, AlertTriangle, RefreshCw
|
|
} from '@lucide/svelte';
|
|
import { invoke } from '../lib/tauri';
|
|
import { toast } from '../lib/store.svelte';
|
|
import type { GuildClaim } from '../lib/types';
|
|
|
|
let {
|
|
instanceId,
|
|
myGuildId = null,
|
|
myGuildTag = '',
|
|
onclaim,
|
|
onunclaim
|
|
}: {
|
|
instanceId: string;
|
|
myGuildId?: string | null;
|
|
myGuildTag?: string;
|
|
onclaim?: (claim: GuildClaim) => void;
|
|
onunclaim?: (claimId: number) => void;
|
|
} = $props();
|
|
|
|
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 claims = $state<GuildClaim[]>([]);
|
|
let loading = $state(false);
|
|
let claiming = $state(false);
|
|
let hoveredChunk = $state<{ x: number; z: number } | null>(null);
|
|
let selectedChunk = $state<{ x: number; z: number } | null>(null);
|
|
|
|
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 }
|
|
] as const;
|
|
|
|
async function loadClaims() {
|
|
if (!instanceId) return;
|
|
loading = true;
|
|
try {
|
|
claims = await invoke<GuildClaim[]>('get_guild_claims', {
|
|
instanceId,
|
|
dimension
|
|
});
|
|
} catch (e: any) {
|
|
toast(e?.message ?? 'Failed to load territory claims', 'error');
|
|
} finally {
|
|
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) return;
|
|
claiming = true;
|
|
try {
|
|
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
|
|
instanceId,
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 pan(dx: number, dz: number) {
|
|
centerX += dx;
|
|
centerZ += dz;
|
|
}
|
|
|
|
function resetCenter() {
|
|
centerX = 0;
|
|
centerZ = 0;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (instanceId && dimension) {
|
|
loadClaims();
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
loadClaims();
|
|
});
|
|
</script>
|
|
|
|
<div class="chunk-map-container glass">
|
|
<!-- Controls Bar -->
|
|
<div class="map-controls">
|
|
<div class="dim-selector">
|
|
{#each dimensions as dim}
|
|
{@const DimIcon = dim.icon}
|
|
<button
|
|
class="dim-btn"
|
|
class:active={dimension === dim.id}
|
|
onclick={() => { dimension = dim.id; selectedChunk = null; }}
|
|
>
|
|
<DimIcon size={14} />
|
|
<span>{dim.label}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="nav-controls">
|
|
<span class="coord-label">Center: [{centerX}, {centerZ}]</span>
|
|
<div class="nav-buttons">
|
|
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan North"><ArrowUp size={14} /></button>
|
|
<div class="nav-row">
|
|
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan West"><ArrowLeft size={14} /></button>
|
|
<button class="ghost icon sm" onclick={resetCenter} title="Center at (0,0)"><MapPin size={14} /></button>
|
|
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan East"><ArrowRight size={14} /></button>
|
|
</div>
|
|
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan South"><ArrowDown size={14} /></button>
|
|
</div>
|
|
<button class="ghost icon sm refresh-btn" onclick={loadClaims} title="Refresh Claims">
|
|
<RefreshCw size={14} class={loading ? 'spin' : ''} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Chunk Map Canvas / Grid -->
|
|
<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>
|
|
|
|
<!-- 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>
|
|
</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;
|
|
}
|
|
.map-controls {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.6rem;
|
|
}
|
|
.dim-selector {
|
|
display: flex;
|
|
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
|
padding: 0.2rem;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--line);
|
|
gap: 0.2rem;
|
|
}
|
|
.dim-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0.35rem 0.75rem;
|
|
font-size: 0.8rem;
|
|
font-weight: 560;
|
|
color: var(--muted);
|
|
border-radius: calc(var(--radius-sm) - 2px);
|
|
transition: all 0.15s;
|
|
}
|
|
.dim-btn.active {
|
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
|
color: var(--text);
|
|
}
|
|
.nav-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
}
|
|
.coord-label {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--muted);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.nav-buttons {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.1rem;
|
|
}
|
|
.nav-row {
|
|
display: flex;
|
|
gap: 0.1rem;
|
|
}
|
|
.refresh-btn {
|
|
margin-left: 0.3rem;
|
|
}
|
|
.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);
|
|
}
|
|
</style>
|