Rework BlueMap claiming and expand ranks guilds chat and warps

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

No files matched your search

+120 -475
View File
@@ -1,524 +1,169 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
MapPin, Globe, LoaderCircle, RefreshCw, Server
} from '@lucide/svelte';
import { Compass, Globe, LocateFixed, RefreshCw, Server as ServerIcon, Shield } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { abs, toast } from '../lib/store.svelte';
import { toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte';
import BlueMapTiles from './BlueMapTiles.svelte';
let {
instanceId,
myGuildId = null,
myGuildTag = '',
onclaim,
onunclaim
}: {
instanceId: string;
myGuildId?: string | null;
myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void;
onunclaim?: (claimId: number) => void;
let { instanceId, myGuildId = null, myGuildTag = '', onclaim, onunclaim }: {
instanceId: string; myGuildId?: string | null; myGuildTag?: string;
onclaim?: (claim: GuildClaim) => void; onunclaim?: (claimId: number) => void;
} = $props();
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string; live_map?: boolean }>>([]);
type GameServer = { id: number; name: string; instance_id: string; map_address?: string; live_map?: boolean };
type Player = { x: number; z: number; dimension: string };
let servers = $state<GameServer[]>([]);
let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0);
let centerZ = $state(0);
let radius = $state(5); // 11x11 grid
let centerX = $state(0), centerZ = $state(0);
let player = $state<Player | null>(null);
let claims = $state<GuildClaim[]>([]);
let loading = $state(false);
let claiming = $state(false);
let loading = $state(false), painting = $state(false);
let claimLoad = 0;
let hoveredChunk = $state<{ x: number; z: number } | null>(null);
let selectedChunk = $state<{ x: number; z: number } | null>(null);
let mapDimensions = $state<string[]>([]);
let mapFailed = $state(false);
// The panel's central live map (if switched on for this server) wins over an external Vantage URL.
type LiveInfo = { ready: boolean; base: string; dimensions: { id: string; available: boolean }[] };
let liveInfo = $state<LiveInfo | null>(null);
let centeredServer = 0;
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
const selectedServer = $derived(servers.find((s) => s.id === serverId));
$effect(() => {
const id = selectedServer?.live_map ? selectedServer.id : null;
liveInfo = null;
if (!id) return;
let alive = true;
invoke<LiveInfo>('get_livemap_info', { serverId: id }).then((i) => { if (alive) liveInfo = i; }).catch(() => {});
return () => { alive = false; };
});
const central = $derived(!!liveInfo?.ready);
const dimSlug = $derived(dimension.replace('minecraft:', ''));
const mapUrl = $derived(central && liveInfo ? abs(`${liveInfo.base}/${dimSlug}/v1/worlds/default/manifest.json`) ?? '' : selectedServer?.map_url || '');
const dimDisabled = (id: string) => central
? !liveInfo?.dimensions.find((d) => d.id === id)?.available
: !!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(id);
$effect(() => { mapUrl; mapFailed = false; if (!central) mapDimensions = []; });
const selectedServer = $derived(instanceServers.find((s) => s.id === serverId));
const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
{ id: 'minecraft:the_nether', label: 'The Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'The End', icon: Compass }
{ id: 'minecraft:the_nether', label: 'Nether', icon: Compass },
{ id: 'minecraft:the_end', label: 'End', icon: Compass }
] as const;
const myClaimsCount = $derived(claims.filter((c) => c.guild_id === myGuildId).length);
async function refreshPosition(id: number) {
try {
const info = await invoke<{ me?: Player | null }>('get_livemap_info', { serverId: id });
if (serverId !== id) return;
player = info.me ?? null;
if (player && centeredServer !== id) {
centeredServer = id;
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
centerX = Math.floor(player.x / 16);
centerZ = Math.floor(player.z / 16);
}
} catch { player = null; }
}
async function loadClaims() {
const request = ++claimLoad;
if (!instanceId || !serverId) { claims = []; return; }
if (!serverId || !instanceId) { claims = []; return; }
loading = true;
claims = [];
try {
const next = await invoke<GuildClaim[]>('get_guild_claims', {
instanceId,
serverId,
centerX,
centerZ,
dimension
});
const next = await invoke<GuildClaim[]>('get_guild_claims', { instanceId, serverId, dimension, centerX, centerZ });
if (request === claimLoad) claims = next;
} catch (e: any) {
if (request === claimLoad) toast(e?.message ?? 'Failed to load territory claims', 'error');
} finally {
if (request === claimLoad) loading = false;
}
if (request === claimLoad) toast(typeof e === 'string' ? e : e?.message ?? 'Could not load claims', 'error');
} finally { if (request === claimLoad) loading = false; }
}
function getClaimAt(x: number, z: number): GuildClaim | undefined {
return claims.find((c) => c.chunk_x === x && c.chunk_z === z && c.dimension === dimension);
}
const selectedClaim = $derived(
selectedChunk ? getClaimAt(selectedChunk.x, selectedChunk.z) : null
);
const myClaimsCount = $derived(
myGuildId ? claims.filter((c) => c.guild_id === myGuildId).length : 0
);
async function handleClaim() {
if (!selectedChunk || !myGuildId || !serverId) return;
claiming = true;
async function paintChunks(chunks: { x: number; z: number }[], mode: 'claim' | 'unclaim') {
if (!serverId || !myGuildId || painting) return;
const existing = new Map(claims.map((c) => [`${c.chunk_x}:${c.chunk_z}`, c]));
const wanted = chunks.filter(({ x, z }) => {
const current = existing.get(`${x}:${z}`);
return mode === 'claim' ? !current : current?.guild_id === myGuildId;
});
if (!wanted.length) return;
painting = true;
let done = 0;
try {
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId,
instanceId,
serverId,
dimension,
chunkX: selectedChunk.x,
chunkZ: selectedChunk.z
});
claims = [...claims, claim];
toast(`Successfully claimed chunk [${selectedChunk.x}, ${selectedChunk.z}]!`, 'ok');
onclaim?.(claim);
} catch (e: any) {
toast(e?.message ?? 'Failed to claim chunk', 'error');
} finally {
claiming = false;
}
for (const chunk of wanted) {
try {
if (mode === 'claim') {
const claim = await invoke<GuildClaim>('claim_guild_chunk', {
guildId: myGuildId, instanceId, serverId, dimension, chunkX: chunk.x, chunkZ: chunk.z
});
claims = [...claims, claim];
onclaim?.(claim);
} else {
const claim = existing.get(`${chunk.x}:${chunk.z}`)!;
await invoke('unclaim_guild_chunk', { claimId: claim.id });
claims = claims.filter((c) => c.id !== claim.id);
onunclaim?.(claim.id);
}
done++;
} catch (e: any) {
toast(`${done} of ${wanted.length} chunks updated. ${typeof e === 'string' ? e : e?.message ?? 'Claim failed'}`, 'error');
break;
}
}
if (done === wanted.length) toast(`${done} ${done === 1 ? 'chunk' : 'chunks'} ${mode === 'claim' ? 'claimed' : 'unclaimed'}`, 'ok');
} finally { painting = false; await loadClaims(); }
}
async function handleUnclaim() {
if (!selectedClaim) return;
claiming = true;
try {
await invoke('unclaim_guild_chunk', { claimId: selectedClaim.id });
claims = claims.filter((c) => c.id !== selectedClaim.id);
toast(`Unclaimed chunk [${selectedClaim.chunk_x}, ${selectedClaim.chunk_z}]`, 'ok');
onunclaim?.(selectedClaim.id);
selectedChunk = null;
} catch (e: any) {
toast(e?.message ?? 'Failed to unclaim chunk', 'error');
} finally {
claiming = false;
}
function centerOnPlayer() {
if (!player) return;
dimension = (dimensions.find((d) => d.id === player?.dimension)?.id ?? 'minecraft:overworld');
centerX = Math.floor(player.x / 16);
centerZ = Math.floor(player.z / 16);
}
function pan(dx: number, dz: number) {
centerX += dx;
centerZ += dz;
}
function resetCenter() {
centerX = 0;
centerZ = 0;
}
$effect(() => {
if (instanceId && dimension && serverId) {
loadClaims();
}
});
onMount(async () => {
const result = await invoke<{ servers: typeof servers }>('get_public_servers');
servers = result.servers;
onMount(() => {
invoke<{ servers: GameServer[] }>('get_public_servers').then((r) => { servers = r.servers; })
.catch((e: any) => { toast(typeof e === 'string' ? e : 'Could not load servers', 'error'); });
const timer = setInterval(() => { if (serverId) refreshPosition(serverId); }, 10_000);
return () => clearInterval(timer);
});
$effect(() => {
const available = servers.filter(s => s.instance_id === instanceId);
if (!available.some(s => s.id === serverId)) serverId = available[0]?.id ?? null;
const available = instanceServers;
if (!available.some((s) => s.id === serverId)) serverId = available[0]?.id ?? null;
});
$effect(() => { if (serverId) { player = null; centeredServer = 0; refreshPosition(serverId); } });
$effect(() => { if (serverId && instanceId && dimension) loadClaims(); });
</script>
<div class="chunk-map-container glass">
<div class="claim-map glass">
<div class="toolbar">
<div class="tb-group">
<div class="group">
{#if instanceServers.length > 1}
<label class="server-pick" title="Game server"><Server size={14} />
<select bind:value={serverId} aria-label="Game server">
{#each instanceServers as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{:else if instanceServers[0]}
<span class="chip server-chip"><Server size={13} /> {instanceServers[0].name}</span>
{/if}
<div class="dim-selector" role="tablist" aria-label="Dimension">
<label class="server"><ServerIcon size={14} /><select bind:value={serverId} aria-label="Game server">
{#each instanceServers as s}<option value={s.id}>{s.name}</option>{/each}
</select></label>
{:else if selectedServer}<span class="chip"><ServerIcon size={14} /> {selectedServer.name}</span>{/if}
<div class="dimensions" role="tablist" aria-label="Dimension">
{#each dimensions as dim}
{@const DimIcon = dim.icon}
<button
role="tab"
aria-selected={dimension === dim.id}
class="dim-btn"
class:active={dimension === dim.id}
disabled={dimDisabled(dim.id)}
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
>
<DimIcon size={14} />
<span>{dim.label}</span>
</button>
{@const Icon = dim.icon}
<button role="tab" aria-selected={dimension === dim.id} class:active={dimension === dim.id}
onclick={() => (dimension = dim.id)}><Icon size={14} /> {dim.label}</button>
{/each}
</div>
{#if central}<span class="chip live-chip"><span class="pulse"></span> Live map</span>{/if}
</div>
<div class="tb-group">
<span class="coord-label" title="Chunk at the centre of the view">Chunk {centerX}, {centerZ}</span>
<div class="pad">
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan west" aria-label="Pan west"><ArrowLeft size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan north" aria-label="Pan north"><ArrowUp size={14} /></button>
<button class="ghost icon sm" onclick={resetCenter} title="Back to the world origin" aria-label="Centre on origin"><MapPin size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan south" aria-label="Pan south"><ArrowDown size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan east" aria-label="Pan east"><ArrowRight size={14} /></button>
</div>
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims">
<RefreshCw size={14} class={loading ? 'spin' : ''} />
</button>
<div class="group">
{#if player}<span class="position">You: {Math.floor(player.x)}, {Math.floor(player.z)}</span>{:else}<span class="position">Player position unavailable</span>{/if}
<button class="ghost sm" onclick={centerOnPlayer} disabled={!player} title="Center on your current position"><LocateFixed size={15} /> Find me</button>
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims"><RefreshCw size={15} class={loading ? 'spin' : ''} /></button>
</div>
</div>
{#if !serverId}
<div class="notice"><Server size={16} /> Link a game server to this instance in the panel before claiming territory.</div>
{/if}
<!-- Main Chunk Map Canvas / Grid -->
{#if mapUrl && !mapFailed}
{#key `${serverId}:${mapUrl}`}
<VantageMap
world={mapUrl}
serverId={serverId!}
{central}
{claims}
{dimension}
{myGuildId}
onselect={(x, z) => { selectedChunk = { x, z }; }}
ondimension={(value) => { if (dimension !== value) { dimension = value; claims = []; selectedChunk = null; } }}
ondimensions={(value) => { mapDimensions = value; }}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }}
onerror={(message) => { toast(`Live map unavailable: ${message}. Showing chunk grid.`, 'error'); mapFailed = true; }}
/>
{#if selectedServer?.map_address}
{#key `${serverId}:${selectedServer.map_address}`}
<BlueMapTiles address={selectedServer.map_address} serverId={selectedServer.id} {dimension} {centerX} {centerZ}
{claims} {myGuildId} {player} onpaint={paintChunks}
oncenterchange={(x, z) => { centerX = x; centerZ = z; }} />
{/key}
{:else}
<div class="grid-wrapper">
<div
class="chunk-grid"
style:grid-template-columns="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
style:grid-template-rows="repeat({radius * 2 + 1}, minmax(36px, 1fr))"
>
{#each Array.from({ length: radius * 2 + 1 }, (_, r) => centerZ - radius + r) as cz}
{#each Array.from({ length: radius * 2 + 1 }, (_, c) => centerX - radius + c) as cx}
{@const claim = getClaimAt(cx, cz)}
{@const isMyClaim = claim && myGuildId && claim.guild_id === myGuildId}
{@const isOtherClaim = claim && (!myGuildId || claim.guild_id !== myGuildId)}
{@const isSelected = selectedChunk && selectedChunk.x === cx && selectedChunk.z === cz}
{@const isCenter = cx === 0 && cz === 0}
<button
class="chunk-cell"
class:my-claim={isMyClaim}
class:other-claim={isOtherClaim}
class:selected={isSelected}
class:is-center={isCenter}
onmouseenter={() => (hoveredChunk = { x: cx, z: cz })}
onmouseleave={() => (hoveredChunk = null)}
onclick={() => (selectedChunk = { x: cx, z: cz })}
title="Chunk [{cx}, {cz}] (Blocks {cx * 16}..{(cx + 1) * 16 - 1}, {cz * 16}..{(cz + 1) * 16 - 1})"
>
{#if isMyClaim}
<Shield size={16} class="claim-icon mine" />
<span class="cell-tag">{claim.guild_tag || myGuildTag || 'YOU'}</span>
{:else if isOtherClaim}
<Lock size={15} class="claim-icon rival" />
<span class="cell-tag">{claim.guild_tag || 'GUILD'}</span>
{:else if isCenter}
<span class="spawn-dot" title="World Origin"></span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
<div class="notice">Configure this server's BlueMap address in the panel to show its 2D terrain and claim land.</div>
{/if}
<!-- Legend & Action Bar -->
<div class="map-footer">
<div class="info-block">
{#if selectedChunk}
<div class="selected-details">
<span class="chunk-title">Selected: Chunk <strong>[{selectedChunk.x}, {selectedChunk.z}]</strong></span>
<span class="block-range muted tiny">
X: {selectedChunk.x * 16}..{(selectedChunk.x + 1) * 16 - 1}, Z: {selectedChunk.z * 16}..{(selectedChunk.z + 1) * 16 - 1}
</span>
{#if selectedClaim}
<div class="claim-status">
{#if myGuildId && selectedClaim.guild_id === myGuildId}
<span class="badge mine"><Shield size={12} /> Claimed by Your Guild</span>
<button
class="danger sm action-btn"
onclick={handleUnclaim}
disabled={claiming}
>
<Trash2 size={13} /> Unclaim
</button>
{:else}
<span class="badge rival"><Lock size={12} /> Claimed by {selectedClaim.guild_name || 'Rival Guild'} [{selectedClaim.guild_tag || '?'}]</span>
<span class="tiny muted protected-note">Grief Protection Active</span>
{/if}
</div>
{:else}
<div class="unclaimed-status">
<span class="badge unclaimed">Wilderness (Unclaimed)</span>
{#if myGuildId}
<button
class="primary sm action-btn"
onclick={handleClaim}
disabled={claiming}
>
{#if claiming}<LoaderCircle size={13} class="spin" />{:else}<Plus size={13} />{/if}
Claim Chunk
</button>
{:else}
<span class="tiny muted">Join or create a guild to claim land</span>
{/if}
</div>
{/if}
</div>
{:else if hoveredChunk}
<div class="hover-info">
<span>Chunk: <strong>[{hoveredChunk.x}, {hoveredChunk.z}]</strong></span>
<span class="muted tiny">Click to view details or claim</span>
</div>
{:else}
<div class="hint-info muted tiny">
<span>Click any grid square to inspect or claim chunks. Claimed chunks are protected in-game against block edits by other players.</span>
</div>
{/if}
</div>
<div class="legend">
<div class="legend-item"><span class="legend-box mine"></span><span>My Guild ({myClaimsCount})</span></div>
<div class="legend-item"><span class="legend-box rival"></span><span>Rival Guild</span></div>
<div class="legend-item"><span class="legend-box wild"></span><span>Wilderness</span></div>
</div>
<div class="footer">
<span><Shield size={14} /> Your guild: {myClaimsCount} visible claims</span>
<span class="legend"><i class="mine"></i> Your guild <i class="other"></i> Other guilds</span>
{#if painting}<span>Updating claims…</span>{/if}
{#if !player}<span class="hint">Player position appears when the server sends live positions to the panel.</span>{/if}
</div>
</div>
<style>
.chunk-map-container {
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 80%, transparent);
border: 1px solid var(--line);
user-select: none;
}
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.6rem 1rem; }
.tb-group { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.server-pick { position: relative; display: inline-flex; align-items: center; }
.server-pick :global(svg) { position: absolute; left: 0.7rem; color: var(--muted); pointer-events: none; }
.server-pick select { width: auto; min-width: 10rem; padding: 0.4rem 2rem 0.4rem 2rem; font-size: 0.82rem; font-weight: 560; border-radius: 99rem; }
.server-chip { padding: 0.4rem 0.8rem; border-radius: 99rem; font-size: 0.82rem; }
.live-chip { border-radius: 99rem; color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
.pulse { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 60%, transparent); animation: pulse 2s infinite; }
@keyframes pulse { 70% { box-shadow: 0 0 0 0.4rem transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
.dim-selector { display: flex; background: color-mix(in srgb, var(--bg) 60%, transparent); padding: 0.2rem; border-radius: 99rem; border: 1px solid var(--line); gap: 0.15rem; }
.dim-btn { display: flex; align-items: center; gap: 0.35rem; background: transparent; border: none; padding: 0.3rem 0.8rem; font-size: 0.8rem; font-weight: 560; color: var(--muted); border-radius: 99rem; transition: all 0.15s; }
.dim-btn:hover:not(:disabled) { color: var(--text); }
.dim-btn.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); }
.coord-label { font-size: 0.78rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.pad { display: inline-flex; padding: 0.15rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 60%, transparent); }
.notice { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); font-size: 0.85rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); }
.grid-wrapper {
overflow: auto;
display: flex;
justify-content: center;
padding: 0.5rem;
background: color-mix(in srgb, var(--bg) 80%, black);
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
max-height: 24rem;
}
.chunk-grid {
display: grid;
gap: 2px;
}
.chunk-cell {
width: 38px;
height: 38px;
padding: 0;
border-radius: 4px;
background: color-mix(in srgb, var(--surface) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--line) 40%, transparent);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
transition: transform 0.1s, border-color 0.15s, background 0.15s;
}
.chunk-cell:hover {
transform: scale(1.08);
z-index: 10;
border-color: var(--text);
background: color-mix(in srgb, var(--text) 12%, transparent);
}
.chunk-cell.selected {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent);
z-index: 15;
}
.chunk-cell.is-center::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: #f59e0b;
}
.chunk-cell.my-claim {
background: color-mix(in srgb, var(--success) 22%, transparent);
border-color: color-mix(in srgb, var(--success) 70%, transparent);
}
.chunk-cell.other-claim {
background: color-mix(in srgb, var(--danger) 22%, transparent);
border-color: color-mix(in srgb, var(--danger) 70%, transparent);
}
:global(.claim-icon.mine) {
color: var(--success);
}
:global(.claim-icon.rival) {
color: var(--danger);
}
.cell-tag {
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
margin-top: 1px;
max-width: 34px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
}
.map-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
padding-top: 0.5rem;
border-top: 1px solid var(--line);
}
.info-block {
flex: 1;
min-width: 14rem;
}
.selected-details {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.chunk-title {
font-size: 0.88rem;
}
.claim-status, .unclaimed-status {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.2rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.18rem 0.55rem;
border-radius: 99rem;
}
.badge.mine {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--success);
}
.badge.rival {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.badge.unclaimed {
background: color-mix(in srgb, var(--text) 10%, transparent);
color: var(--muted);
}
.protected-note {
font-style: italic;
}
.legend {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.76rem;
color: var(--muted);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.legend-box {
width: 12px;
height: 12px;
border-radius: 3px;
border: 1px solid transparent;
}
.legend-box.mine {
background: color-mix(in srgb, var(--success) 35%, transparent);
border-color: var(--success);
}
.legend-box.rival {
background: color-mix(in srgb, var(--danger) 35%, transparent);
border-color: var(--danger);
}
.legend-box.wild {
background: color-mix(in srgb, var(--surface) 80%, transparent);
border-color: var(--line);
}
.claim-map { display: flex; flex-direction: column; gap: 0.8rem; padding: 1rem; border: 1px solid var(--line); border-radius: var(--radius); }
.toolbar, .group, .footer, .server, .chip, .legend { display: flex; align-items: center; gap: 0.6rem; }
.toolbar, .footer { justify-content: space-between; flex-wrap: wrap; }
.group { flex-wrap: wrap; }
.server select { width: auto; min-width: 10rem; }
.dimensions { display: flex; gap: 0.2rem; padding: 0.2rem; background: color-mix(in srgb, var(--bg) 70%, transparent); border-radius: 99px; }
.dimensions button { display: flex; gap: 0.3rem; align-items: center; border: 0; border-radius: 99px; background: transparent; color: var(--muted); padding: 0.35rem 0.65rem; font-size: 0.8rem; }
.dimensions button.active { background: color-mix(in srgb, var(--accent) 24%, transparent); color: var(--text); }
.position, .footer, .notice { color: var(--muted); font-size: 0.8rem; }
.notice { min-height: 420px; display: grid; place-items: center; text-align: center; border-radius: 10px; background: #142333; padding: 1rem; }
.footer { border-top: 1px solid var(--line); padding-top: 0.65rem; }
.footer span { display: inline-flex; align-items: center; gap: 0.3rem; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.legend .mine { background: #4ade80; }.legend .other { background: #fb7185; }
</style>