SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed
This commit is contained in:
25 files changed
+1387
-343
No files matched your search
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { MapView } from './engine';
|
||||
import { dimSlug } from './types';
|
||||
import type { LivePlayer, MapInfo, MapLayers, MapOverlay, MapSelection } from './types';
|
||||
|
||||
// The SCOPENET Map: tiles, claims, pins and players on one canvas. The parent feeds it data (see feed.ts) and decides what a
|
||||
// click on a player does through the `player` snippet.
|
||||
let {
|
||||
info = null, overlay = null, tileUrl, avatarUrl, selfUuid = null, error = '', initialDimension = '',
|
||||
player, toolbar, onplayer
|
||||
}: {
|
||||
info: MapInfo | null;
|
||||
overlay: MapOverlay | null;
|
||||
tileUrl: (slug: string, zoom: number, x: number, y: number) => string;
|
||||
avatarUrl?: (uuid: string) => string;
|
||||
selfUuid?: string | null;
|
||||
error?: string;
|
||||
initialDimension?: string;
|
||||
/** Shown in the side card when a player is selected. */
|
||||
player?: Snippet<[LivePlayer, () => void]>;
|
||||
/** Extra buttons for the top bar. */
|
||||
toolbar?: Snippet;
|
||||
onplayer?: (p: LivePlayer | null) => void;
|
||||
} = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let view: MapView | undefined;
|
||||
let layers = $state<MapLayers>({ claims: true, pins: true, players: true });
|
||||
let selection = $state<MapSelection | null>(null);
|
||||
let dim = $state('');
|
||||
let cursor = $state<{ x: number; z: number } | null>(null);
|
||||
let hint = $state<string | null>(null);
|
||||
let zoomText = $state('');
|
||||
let listOpen = $state(false);
|
||||
let following = $state<string | null>(null);
|
||||
|
||||
const dims = $derived((info?.dimensions ?? []).filter((d) => d.available));
|
||||
const here = $derived((overlay?.players ?? []).filter((p) => dimSlug(p.dimension) === dim));
|
||||
const everyone = $derived(overlay?.players ?? []);
|
||||
const me = $derived(selfUuid ? everyone.find((p) => p.uuid.toLowerCase() === selfUuid.toLowerCase()) : undefined);
|
||||
const waiting = $derived(!info || !info.ready);
|
||||
const claimsHere = $derived((overlay?.claims ?? []).filter((c) => dimSlug(c.dimension) === dim).length);
|
||||
const pinsHere = $derived((overlay?.pins ?? []).filter((p) => dimSlug(p.dimension) === dim).length);
|
||||
|
||||
onMount(() => {
|
||||
view = new MapView(canvas, {
|
||||
tileUrl: (s, z, x, y) => tileUrl(s, z, x, y),
|
||||
avatarUrl: (u) => avatarUrl?.(u) ?? '',
|
||||
selfUuid,
|
||||
onselect: (s) => { selection = s; onplayer?.(s?.type === 'player' ? s.player : null); },
|
||||
onhover: (h) => { cursor = h.cursor; hint = h.label; },
|
||||
onview: (v) => { zoomText = v.scale >= 1 ? `${v.scale.toFixed(v.scale >= 10 ? 0 : 1)} px per block` : `${(1 / v.scale).toFixed(1).replace(/\.0$/, '')} blocks per px`; }
|
||||
});
|
||||
return () => view?.destroy();
|
||||
});
|
||||
|
||||
$effect(() => { if (view && info) { view.setInfo(info); dim = view.currentDimension?.slug ?? ''; } });
|
||||
$effect(() => { if (view && overlay) view.setOverlay(overlay); });
|
||||
$effect(() => { view?.setLayers(layers); });
|
||||
$effect(() => { view?.setSelf(selfUuid); });
|
||||
$effect(() => { if (view && info && initialDimension) view.setDimension(initialDimension); });
|
||||
|
||||
function chooseDimension(slug: string) { view?.setDimension(slug); dim = view?.currentDimension?.slug ?? slug; following = null; }
|
||||
function goTo(p: LivePlayer) {
|
||||
if (dimSlug(p.dimension) !== dim) chooseDimension(p.dimension);
|
||||
view?.flyTo(p.x, p.z, Math.max(view.getView().scale, 1.2));
|
||||
view?.select({ type: 'player', player: p });
|
||||
listOpen = false;
|
||||
}
|
||||
function toggleFollow(p: LivePlayer) {
|
||||
following = following === p.uuid ? null : p.uuid;
|
||||
view?.follow(following);
|
||||
}
|
||||
function findMe() { if (me) { goTo(me); } }
|
||||
function toSpawn() {
|
||||
const spawn = view?.pinsOfKind('spawn')[0];
|
||||
if (spawn) view?.flyTo(spawn.x, spawn.z, 1);
|
||||
}
|
||||
const closeCard = () => { view?.select(null); following = null; view?.follow(null); };
|
||||
const fmt = (n: number) => Math.round(n).toLocaleString();
|
||||
</script>
|
||||
|
||||
<div class="map">
|
||||
<canvas bind:this={canvas} aria-label="World map"></canvas>
|
||||
|
||||
<div class="top">
|
||||
<div class="bar">
|
||||
{#if dims.length > 1}
|
||||
<div class="seg" role="tablist" aria-label="Dimension">
|
||||
{#each dims as d}
|
||||
<button role="tab" aria-selected={dim === d.slug} class:on={dim === d.slug} onclick={() => chooseDimension(d.slug)}>{d.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if dims.length === 1}
|
||||
<span class="chip solo">{dims[0].label}</span>
|
||||
{/if}
|
||||
<div class="chips" aria-label="Layers">
|
||||
<button class="chip" class:on={layers.claims} onclick={() => (layers.claims = !layers.claims)} title="Guild land">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 4h16v16H4z" /><path d="M4 12h16M12 4v16" /></svg> Claims <b>{claimsHere}</b>
|
||||
</button>
|
||||
<button class="chip" class:on={layers.pins} onclick={() => (layers.pins = !layers.pins)} title="Spawn, warps, markets and shops">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21z" /><circle cx="12" cy="9.5" r="2.4" /></svg> Places <b>{pinsHere}</b>
|
||||
</button>
|
||||
<button class="chip" class:on={layers.players} onclick={() => (layers.players = !layers.players)} title="Players online">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-4 3.5-6 7-6s6.2 2 7 6" /></svg> Players <b>{here.length}</b>
|
||||
</button>
|
||||
</div>
|
||||
{#if toolbar}{@render toolbar()}{/if}
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<button class="chip" class:on={listOpen} onclick={() => (listOpen = !listOpen)} disabled={!everyone.length}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg> Online <b>{everyone.length}</b>
|
||||
</button>
|
||||
{#if listOpen && everyone.length}
|
||||
<ul class="plist" aria-label="Players online">
|
||||
{#each everyone as p (p.uuid)}
|
||||
<li>
|
||||
<button onclick={() => goTo(p)}>
|
||||
{#if avatarUrl}<img src={avatarUrl(p.uuid)} alt="" width="22" height="22" />{/if}
|
||||
<span class="nm">{p.name}</span>
|
||||
<span class="where">{dimSlug(p.dimension).replace('the_', '').replace('_', ' ')}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-left">
|
||||
{#if hint}<span class="hint">{hint}</span>{/if}
|
||||
<span class="coords">{#if cursor}X {fmt(cursor.x)} · Z {fmt(cursor.z)}{:else} {/if}<i>{zoomText}</i></span>
|
||||
</div>
|
||||
|
||||
<div class="zoom">
|
||||
{#if me}<button class="round" onclick={findMe} title="Find me" aria-label="Find me"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3.5" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" /></svg></button>{/if}
|
||||
<button class="round" onclick={toSpawn} title="Go to spawn" aria-label="Go to spawn"><svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor"><path d="M12 2l2.9 6.3 6.9.7-5.2 4.6 1.5 6.8L12 17l-6.1 3.4 1.5-6.8L2.2 9l6.9-.7z" /></svg></button>
|
||||
<div class="stack">
|
||||
<button onclick={() => view?.zoomBy(1.6)} aria-label="Zoom in">+</button>
|
||||
<button onclick={() => view?.zoomBy(1 / 1.6)} aria-label="Zoom out">−</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selection}
|
||||
<aside class="card" class:wide={selection.type === 'player' && !!player}>
|
||||
<button class="x" onclick={closeCard} aria-label="Close">×</button>
|
||||
{#if selection.type === 'player'}
|
||||
{#if player}
|
||||
{@render player(selection.player, closeCard)}
|
||||
{:else}
|
||||
<div class="head">
|
||||
{#if avatarUrl}<img src={avatarUrl(selection.player.uuid)} alt="" width="44" height="44" />{/if}
|
||||
<div><h3>{selection.player.name}</h3><span class="muted">{dimSlug(selection.player.dimension).replace('the_', '')}</span></div>
|
||||
</div>
|
||||
<p class="muted">X {fmt(selection.player.x)} · Y {fmt(selection.player.y)} · Z {fmt(selection.player.z)}</p>
|
||||
<button class="primary" onclick={() => toggleFollow((selection as { player: LivePlayer }).player)}>{following === selection.player.uuid ? 'Stop following' : 'Follow on map'}</button>
|
||||
{/if}
|
||||
{:else if selection.type === 'pin'}
|
||||
<h3>{selection.pin.label}</h3>
|
||||
{#each selection.pin.lines as line}<p>{line}</p>{/each}
|
||||
<p class="muted">X {fmt(selection.pin.x)} · Y {fmt(selection.pin.y)} · Z {fmt(selection.pin.z)}</p>
|
||||
{:else}
|
||||
<div class="head">
|
||||
{#if selection.claim.icon}<img class="icon" src={selection.claim.icon} alt="" width="40" height="40" />{/if}
|
||||
<div><h3 style:color={selection.claim.color}>{selection.claim.tag ? `[${selection.claim.tag}] ` : ''}{selection.claim.name}</h3>
|
||||
<span class="muted">{selection.claim.chunks} chunk{selection.claim.chunks === 1 ? '' : 's'} here</span></div>
|
||||
</div>
|
||||
{#each selection.claim.lines.slice(1) as line}<p>{line}</p>{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
{#if waiting}
|
||||
<div class="veil">
|
||||
<div class="spinner"></div>
|
||||
<strong>{info?.enabled === false ? 'The map is off for this server' : 'Drawing the world…'}</strong>
|
||||
<span>{error || info?.message || 'Connecting to the map.'}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="toast" role="status">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map { position: relative; width: 100%; height: 100%; min-height: 320px; overflow: hidden; border-radius: inherit; background: #0d1220; color: #f4f6ff; font-family: inherit; }
|
||||
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; }
|
||||
.top { position: absolute; top: 12px; left: 12px; right: 12px; display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; pointer-events: none; }
|
||||
.top > * { pointer-events: auto; }
|
||||
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.seg { display: inline-flex; padding: 3px; border-radius: 999px; background: var(--map-glass, rgba(14, 19, 34, 0.82)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(12px); }
|
||||
.seg button { border: 0; background: transparent; color: #aab3d0; padding: 6px 14px; border-radius: 999px; font: 600 12.5px/1 inherit; cursor: pointer; }
|
||||
.seg button.on { background: var(--map-accent, #8b6cff); color: #fff; box-shadow: 0 2px 10px rgba(139, 108, 255, 0.45); }
|
||||
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 999px; border: 1px solid var(--map-line, rgba(255,255,255,0.12)); background: var(--map-glass, rgba(14, 19, 34, 0.82)); color: #aab3d0; font: 600 12.5px/1 inherit; cursor: pointer; backdrop-filter: blur(12px); transition: color .12s, border-color .12s, background .12s; }
|
||||
.chip b { font-weight: 700; opacity: 0.8; }
|
||||
.chip.on { color: #fff; border-color: color-mix(in srgb, var(--map-accent, #8b6cff) 70%, transparent); background: color-mix(in srgb, var(--map-accent, #8b6cff) 26%, rgba(14, 19, 34, 0.85)); }
|
||||
.chip:disabled { opacity: 0.5; cursor: default; }
|
||||
.chip.solo { color: #fff; cursor: default; }
|
||||
.right { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.plist { list-style: none; margin: 0; padding: 6px; width: 230px; max-height: 320px; overflow: auto; border-radius: 16px; background: var(--map-glass, rgba(14, 19, 34, 0.92)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(14px); box-shadow: 0 14px 40px rgba(0,0,0,0.45); }
|
||||
.plist button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: 600 13px/1 inherit; cursor: pointer; text-align: left; }
|
||||
.plist button:hover { background: rgba(255,255,255,0.08); }
|
||||
.plist img { border-radius: 6px; image-rendering: pixelated; }
|
||||
.nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.where { font-size: 11px; color: #8d97b8; text-transform: capitalize; }
|
||||
.bottom-left { position: absolute; left: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; pointer-events: none; }
|
||||
.hint, .coords { padding: 6px 11px; border-radius: 999px; background: var(--map-glass, rgba(14, 19, 34, 0.82)); border: 1px solid var(--map-line, rgba(255,255,255,0.1)); font: 600 12px/1 inherit; backdrop-filter: blur(10px); }
|
||||
.coords { color: #c3cbe6; font-variant-numeric: tabular-nums; }
|
||||
.coords i { font-style: normal; color: #7f89ab; margin-left: 10px; }
|
||||
.zoom { position: absolute; right: 12px; bottom: 12px; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||
.round, .stack button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--map-line, rgba(255,255,255,0.12)); background: var(--map-glass, rgba(14, 19, 34, 0.86)); color: #e7ebff; cursor: pointer; backdrop-filter: blur(12px); font: 600 20px/1 inherit; }
|
||||
.round { border-radius: 50%; }
|
||||
.stack { display: flex; flex-direction: column; border-radius: 14px; overflow: hidden; border: 1px solid var(--map-line, rgba(255,255,255,0.12)); }
|
||||
.stack button { border: 0; border-radius: 0; }
|
||||
.stack button + button { border-top: 1px solid rgba(255,255,255,0.1); }
|
||||
.round:hover, .stack button:hover { background: rgba(60, 70, 110, 0.9); }
|
||||
.card { position: absolute; left: 12px; bottom: 62px; width: min(320px, calc(100% - 24px)); max-height: 55%; overflow: auto; padding: 14px 16px 16px; border-radius: 18px; background: var(--map-glass, rgba(14, 19, 34, 0.92)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(16px); box-shadow: 0 18px 50px rgba(0,0,0,0.5); animation: rise .16s ease; }
|
||||
.card.wide { left: auto; right: 12px; bottom: 12px; top: 64px; width: min(340px, calc(100% - 24px)); max-height: none; padding: 0; background: transparent; border: 0; box-shadow: none; backdrop-filter: none; }
|
||||
.card h3 { margin: 0 0 6px; font-size: 15px; }
|
||||
.card p { margin: 3px 0; font-size: 13px; color: #d4daf0; }
|
||||
.card .muted { color: #8d97b8; }
|
||||
.head { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
|
||||
.head img { border-radius: 10px; image-rendering: pixelated; }
|
||||
.head img.icon { image-rendering: auto; border-radius: 50%; object-fit: cover; }
|
||||
.x { position: absolute; top: 8px; right: 10px; width: 26px; height: 26px; border-radius: 50%; border: 0; background: rgba(255,255,255,0.08); color: #fff; font-size: 18px; line-height: 1; cursor: pointer; z-index: 2; }
|
||||
.card.wide .x { display: none; }
|
||||
.primary { margin-top: 10px; width: 100%; padding: 9px 14px; border: 0; border-radius: 12px; background: var(--map-accent, #8b6cff); color: #fff; font: 700 13px/1 inherit; cursor: pointer; }
|
||||
.veil { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; text-align: center; padding: 24px; background: radial-gradient(circle at 50% 40%, rgba(139,108,255,0.14), rgba(9,12,22,0.92) 60%); }
|
||||
.veil span { color: #98a2c4; max-width: 34ch; font-size: 13px; line-height: 1.5; }
|
||||
.spinner { width: 34px; height: 34px; border-radius: 50%; border: 3px solid rgba(255,255,255,0.14); border-top-color: var(--map-accent, #8b6cff); animation: spin .9s linear infinite; margin-bottom: 6px; }
|
||||
.toast { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(120, 30, 50, 0.9); font-size: 12.5px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes rise { from { transform: translateY(6px); opacity: 0; } }
|
||||
@media (max-width: 640px) { .top { flex-direction: column; } .right { align-items: flex-start; } }
|
||||
</style>
|
||||
@@ -0,0 +1,687 @@
|
||||
import type { LivePlayer, MapBounds, MapClaim, MapDimension, MapInfo, MapLayers, MapOverlay, MapPin, MapSelection } from './types';
|
||||
import { dimSlug } from './types';
|
||||
|
||||
/** A framework-free map renderer: pans and zooms the tile pyramid on a canvas and draws claims, pins and players on top. */
|
||||
|
||||
export interface Theme {
|
||||
background: string;
|
||||
grid: string;
|
||||
label: string;
|
||||
labelBg: string;
|
||||
accent: string;
|
||||
self: string;
|
||||
}
|
||||
|
||||
const DARK: Theme = { background: '#0d1220', grid: 'rgba(255,255,255,0.045)', label: '#f4f6ff', labelBg: 'rgba(10,14,26,0.78)', accent: '#8b6cff', self: '#34d399' };
|
||||
|
||||
const PIN_COLORS: Record<string, string> = {
|
||||
spawn: '#fbbf24', warp: '#38bdf8', home: '#a78bfa', guild_home: '#fb7185', market: '#34d399', shop: '#f59e0b'
|
||||
};
|
||||
|
||||
export interface MapViewOptions {
|
||||
tileUrl: (slug: string, zoom: number, x: number, y: number) => string;
|
||||
avatarUrl?: (uuid: string) => string;
|
||||
theme?: Partial<Theme>;
|
||||
selfUuid?: string | null;
|
||||
onselect?: (selection: MapSelection | null) => void;
|
||||
onhover?: (hover: { cursor: { x: number; z: number } | null; label: string | null }) => void;
|
||||
onview?: (view: { x: number; z: number; scale: number }) => void;
|
||||
}
|
||||
|
||||
interface Tile { img: HTMLImageElement; state: 'loading' | 'ok' | 'missing'; at: number; used: number }
|
||||
|
||||
const MIN_SCALE = 1 / 128;
|
||||
const MAX_SCALE = 20;
|
||||
const TILE = 256;
|
||||
const MAX_LOADS = 10;
|
||||
|
||||
export class MapView {
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private theme: Theme;
|
||||
private info: MapInfo | null = null;
|
||||
private dimension: MapDimension | null = null;
|
||||
private overlay: MapOverlay = { players: [], claims: [], pins: [] };
|
||||
private layers: MapLayers = { claims: true, pins: true, players: true };
|
||||
private tiles = new Map<string, Tile>();
|
||||
private queue: string[] = [];
|
||||
private loading = 0;
|
||||
private icons = new Map<string, HTMLImageElement | 'failed'>();
|
||||
private paths = new Map<string, Path2D>();
|
||||
private width = 300;
|
||||
private height = 200;
|
||||
private dpr = 1;
|
||||
private cx = 0;
|
||||
private cz = 0;
|
||||
private scale = 1;
|
||||
private hoverId: string | null = null;
|
||||
private selected: MapSelection | null = null;
|
||||
private following: string | null = null;
|
||||
private raf = 0;
|
||||
private fly: { fromX: number; fromZ: number; fromS: number; toX: number; toZ: number; toS: number; start: number; ms: number } | null = null;
|
||||
private pointers = new Map<number, { x: number; y: number }>();
|
||||
private drag: { x: number; y: number; moved: number } | null = null;
|
||||
private pinchStart: { dist: number; scale: number } | null = null;
|
||||
private disposed = false;
|
||||
private resizeObserver: ResizeObserver;
|
||||
|
||||
constructor(private canvas: HTMLCanvasElement, private opts: MapViewOptions) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas is not available');
|
||||
this.ctx = ctx;
|
||||
this.theme = { ...DARK, ...(opts.theme ?? {}) };
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.addEventListener('pointerdown', this.down);
|
||||
canvas.addEventListener('pointermove', this.move);
|
||||
canvas.addEventListener('pointerup', this.up);
|
||||
canvas.addEventListener('pointercancel', this.up);
|
||||
canvas.addEventListener('pointerleave', this.leave);
|
||||
canvas.addEventListener('wheel', this.wheel, { passive: false });
|
||||
canvas.addEventListener('dblclick', this.dbl);
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.resizeObserver.observe(canvas);
|
||||
this.resize();
|
||||
}
|
||||
|
||||
// ---- public API --------------------------------------------------------------------------------------
|
||||
|
||||
setInfo(info: MapInfo) {
|
||||
const tokenChanged = this.info?.token !== info.token || this.info?.tile_base !== info.tile_base;
|
||||
this.info = info;
|
||||
if (tokenChanged) {
|
||||
// New tile key: tiles that failed earlier may work now; loaded ones stay.
|
||||
for (const [k, t] of this.tiles) if (t.state !== 'ok') this.tiles.delete(k);
|
||||
}
|
||||
const keep = this.dimension ? info.dimensions.find((d) => d.slug === this.dimension!.slug) : undefined;
|
||||
this.dimension = keep ?? info.dimensions[0] ?? null;
|
||||
if (!keep && this.dimension) this.frameDimension();
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
setOverlay(overlay: MapOverlay) {
|
||||
this.overlay = overlay;
|
||||
this.paths.clear();
|
||||
if (this.following) {
|
||||
const p = overlay.players.find((q) => q.uuid === this.following);
|
||||
if (p) {
|
||||
if (dimSlug(p.dimension) !== this.dimension?.slug) this.setDimension(p.dimension);
|
||||
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: p.x, toZ: p.z, toS: this.scale, start: performance.now(), ms: 700 };
|
||||
}
|
||||
}
|
||||
if (this.selected?.type === 'player') {
|
||||
const fresh = overlay.players.find((p) => p.uuid === (this.selected as { player: LivePlayer }).player.uuid);
|
||||
if (fresh) this.selected = { type: 'player', player: fresh };
|
||||
}
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); }
|
||||
|
||||
setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); }
|
||||
|
||||
/** Switch dimension by id (`minecraft:the_nether`) or slug. */
|
||||
setDimension(idOrSlug: string) {
|
||||
const slug = dimSlug(idOrSlug);
|
||||
const next = this.info?.dimensions.find((d) => d.slug === slug);
|
||||
if (!next || next.slug === this.dimension?.slug) return;
|
||||
this.dimension = next;
|
||||
this.select(null);
|
||||
this.frameDimension();
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
get currentDimension(): MapDimension | null { return this.dimension; }
|
||||
|
||||
follow(uuid: string | null) { this.following = uuid; if (uuid) this.invalidate(); }
|
||||
|
||||
flyTo(x: number, z: number, scale?: number) {
|
||||
this.following = null;
|
||||
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX: x, toZ: z, toS: clamp(scale ?? this.scale, MIN_SCALE, MAX_SCALE), start: performance.now(), ms: 520 };
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
zoomBy(factor: number) {
|
||||
this.fly = null;
|
||||
this.zoomAround(this.width / 2, this.height / 2, factor, true);
|
||||
}
|
||||
|
||||
select(selection: MapSelection | null) {
|
||||
this.selected = selection;
|
||||
this.opts.onselect?.(selection);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
frameDimension() {
|
||||
const b = this.dimension?.bounds;
|
||||
if (!b) return;
|
||||
this.fly = null;
|
||||
const [minX, maxX, minZ, maxZ] = worldBounds(b);
|
||||
this.cx = (minX + maxX) / 2;
|
||||
this.cz = (minZ + maxZ) / 2;
|
||||
const fit = Math.min(this.width / Math.max(1, maxX - minX), this.height / Math.max(1, maxZ - minZ)) * 0.9;
|
||||
this.scale = clamp(fit, 0.05, 2);
|
||||
this.emitView();
|
||||
}
|
||||
|
||||
pinsOfKind(kind: string): MapPin[] { return this.overlay.pins.filter((p) => p.kind === kind && dimSlug(p.dimension) === this.dimension?.slug); }
|
||||
|
||||
getView() { return { x: this.cx, z: this.cz, scale: this.scale }; }
|
||||
|
||||
destroy() {
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.resizeObserver.disconnect();
|
||||
const c = this.canvas;
|
||||
c.removeEventListener('pointerdown', this.down);
|
||||
c.removeEventListener('pointermove', this.move);
|
||||
c.removeEventListener('pointerup', this.up);
|
||||
c.removeEventListener('pointercancel', this.up);
|
||||
c.removeEventListener('pointerleave', this.leave);
|
||||
c.removeEventListener('wheel', this.wheel);
|
||||
c.removeEventListener('dblclick', this.dbl);
|
||||
this.tiles.clear();
|
||||
}
|
||||
|
||||
// ---- geometry ---------------------------------------------------------------------------------------
|
||||
|
||||
private resize() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.dpr = Math.min(window.devicePixelRatio || 1, 2.5);
|
||||
this.width = Math.max(50, rect.width);
|
||||
this.height = Math.max(50, rect.height);
|
||||
this.canvas.width = Math.round(this.width * this.dpr);
|
||||
this.canvas.height = Math.round(this.height * this.dpr);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
private toScreen(x: number, z: number) { return { x: (x - this.cx) * this.scale + this.width / 2, y: (z - this.cz) * this.scale + this.height / 2 }; }
|
||||
private toWorld(sx: number, sy: number) { return { x: (sx - this.width / 2) / this.scale + this.cx, z: (sy - this.height / 2) / this.scale + this.cz }; }
|
||||
|
||||
private zoomAround(sx: number, sy: number, factor: number, animate = false) {
|
||||
const before = this.toWorld(sx, sy);
|
||||
const target = clamp(this.scale * factor, MIN_SCALE, MAX_SCALE);
|
||||
if (animate) {
|
||||
// Keep the point under (sx, sy) fixed while easing the scale.
|
||||
const toX = before.x - (sx - this.width / 2) / target;
|
||||
const toZ = before.z - (sy - this.height / 2) / target;
|
||||
this.fly = { fromX: this.cx, fromZ: this.cz, fromS: this.scale, toX, toZ, toS: target, start: performance.now(), ms: 220 };
|
||||
} else {
|
||||
this.scale = target;
|
||||
this.cx = before.x - (sx - this.width / 2) / this.scale;
|
||||
this.cz = before.z - (sy - this.height / 2) / this.scale;
|
||||
}
|
||||
this.following = null;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
// ---- input --------------------------------------------------------------------------------------------
|
||||
|
||||
private localPoint(e: PointerEvent | WheelEvent | MouseEvent) {
|
||||
const r = this.canvas.getBoundingClientRect();
|
||||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
}
|
||||
|
||||
private down = (e: PointerEvent) => {
|
||||
this.canvas.setPointerCapture(e.pointerId);
|
||||
const p = this.localPoint(e);
|
||||
this.pointers.set(e.pointerId, p);
|
||||
this.fly = null;
|
||||
if (this.pointers.size === 1) this.drag = { x: p.x, y: p.y, moved: 0 };
|
||||
if (this.pointers.size === 2) {
|
||||
const [a, b] = [...this.pointers.values()];
|
||||
this.pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale: this.scale };
|
||||
this.drag = null;
|
||||
}
|
||||
};
|
||||
|
||||
private move = (e: PointerEvent) => {
|
||||
const p = this.localPoint(e);
|
||||
if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, p);
|
||||
if (this.pinchStart && this.pointers.size === 2) {
|
||||
const [a, b] = [...this.pointers.values()];
|
||||
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
this.zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, (this.pinchStart.scale * dist / this.pinchStart.dist) / this.scale);
|
||||
return;
|
||||
}
|
||||
if (this.drag && this.pointers.has(e.pointerId)) {
|
||||
const dx = p.x - this.drag.x, dy = p.y - this.drag.y;
|
||||
this.drag.moved += Math.abs(dx) + Math.abs(dy);
|
||||
this.drag.x = p.x; this.drag.y = p.y;
|
||||
if (this.drag.moved > 3) {
|
||||
this.cx -= dx / this.scale;
|
||||
this.cz -= dy / this.scale;
|
||||
this.following = null;
|
||||
this.canvas.style.cursor = 'grabbing';
|
||||
this.invalidate();
|
||||
this.emitView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const hit = this.hitTest(p.x, p.y);
|
||||
const id = hit ? hitId(hit) : null;
|
||||
if (id !== this.hoverId) { this.hoverId = id; this.invalidate(); }
|
||||
this.canvas.style.cursor = hit ? 'pointer' : 'grab';
|
||||
const w = this.toWorld(p.x, p.y);
|
||||
this.opts.onhover?.({ cursor: { x: w.x, z: w.z }, label: hit ? hitLabel(hit) : null });
|
||||
};
|
||||
|
||||
private up = (e: PointerEvent) => {
|
||||
const wasClick = this.drag && this.drag.moved <= 3 && this.pointers.size === 1;
|
||||
this.pointers.delete(e.pointerId);
|
||||
if (this.pointers.size < 2) this.pinchStart = null;
|
||||
if (wasClick) {
|
||||
const p = this.localPoint(e);
|
||||
const hit = this.hitTest(p.x, p.y);
|
||||
this.select(hit);
|
||||
}
|
||||
if (this.pointers.size === 0) { this.drag = null; this.canvas.style.cursor = 'grab'; }
|
||||
};
|
||||
|
||||
private leave = () => {
|
||||
if (this.hoverId) { this.hoverId = null; this.invalidate(); }
|
||||
this.opts.onhover?.({ cursor: null, label: null });
|
||||
};
|
||||
|
||||
private wheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const p = this.localPoint(e);
|
||||
const delta = e.deltaMode === 1 ? e.deltaY * 18 : e.deltaY;
|
||||
this.fly = null;
|
||||
this.zoomAround(p.x, p.y, Math.exp(-delta * 0.0016));
|
||||
this.emitView();
|
||||
};
|
||||
|
||||
private dbl = (e: MouseEvent) => {
|
||||
const p = this.localPoint(e);
|
||||
if (this.hitTest(p.x, p.y)) return;
|
||||
this.zoomAround(p.x, p.y, 2, true);
|
||||
};
|
||||
|
||||
private emitView() { this.opts.onview?.({ x: this.cx, z: this.cz, scale: this.scale }); }
|
||||
|
||||
// ---- hit testing --------------------------------------------------------------------------------------
|
||||
|
||||
private visibleSlug() { return this.dimension?.slug ?? ''; }
|
||||
|
||||
private hitTest(sx: number, sy: number): MapSelection | null {
|
||||
const slug = this.visibleSlug();
|
||||
if (this.layers.players) {
|
||||
let best: LivePlayer | null = null, bestD = 15 * 15;
|
||||
for (const p of this.overlay.players) {
|
||||
if (dimSlug(p.dimension) !== slug) continue;
|
||||
const s = this.toScreen(p.x, p.z);
|
||||
const d = (s.x - sx) ** 2 + (s.y - sy) ** 2;
|
||||
if (d < bestD) { best = p; bestD = d; }
|
||||
}
|
||||
if (best) return { type: 'player', player: best };
|
||||
}
|
||||
if (this.layers.pins) {
|
||||
let best: MapPin | null = null, bestD = 15 * 15;
|
||||
for (const p of this.overlay.pins) {
|
||||
if (dimSlug(p.dimension) !== slug) continue;
|
||||
const s = this.toScreen(p.x, p.z);
|
||||
const d = (s.x - sx) ** 2 + (s.y - sy) ** 2;
|
||||
if (d < bestD) { best = p; bestD = d; }
|
||||
}
|
||||
if (best) return { type: 'pin', pin: best };
|
||||
}
|
||||
if (this.layers.claims) {
|
||||
const w = this.toWorld(sx, sy);
|
||||
for (const c of this.overlay.claims) {
|
||||
if (dimSlug(c.dimension) !== slug) continue;
|
||||
if (inside(c.outer, w.x, w.z) && !c.holes.some((h) => inside(h, w.x, w.z))) return { type: 'claim', claim: c };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- tiles --------------------------------------------------------------------------------------------
|
||||
|
||||
private tileKey(slug: string, z: number, x: number, y: number) { return `${slug}/${z}/${x}/${y}`; }
|
||||
|
||||
private tileAt(slug: string, z: number, x: number, y: number, load: boolean): Tile | null {
|
||||
const key = this.tileKey(slug, z, x, y);
|
||||
const t = this.tiles.get(key);
|
||||
if (t) {
|
||||
t.used = performance.now();
|
||||
if (t.state === 'missing' && load && performance.now() - t.at > 20000) { this.tiles.delete(key); return this.tileAt(slug, z, x, y, load); }
|
||||
return t;
|
||||
}
|
||||
if (!load || !this.info?.token) return null;
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
const tile: Tile = { img, state: 'loading', at: performance.now(), used: performance.now() };
|
||||
this.tiles.set(key, tile);
|
||||
this.queue.push(key);
|
||||
this.pump();
|
||||
this.evict();
|
||||
return tile;
|
||||
}
|
||||
|
||||
private pump() {
|
||||
while (this.loading < MAX_LOADS && this.queue.length) {
|
||||
const key = this.queue.pop()!; // newest first: what is on screen now matters most
|
||||
const tile = this.tiles.get(key);
|
||||
if (!tile || tile.state !== 'loading') continue;
|
||||
const [slug, z, x, y] = key.split('/');
|
||||
this.loading++;
|
||||
tile.img.onload = () => { tile.state = 'ok'; this.loading--; this.pump(); this.invalidate(); };
|
||||
tile.img.onerror = () => { tile.state = 'missing'; tile.at = performance.now(); this.loading--; this.pump(); };
|
||||
tile.img.src = this.opts.tileUrl(slug, Number(z), Number(x), Number(y));
|
||||
}
|
||||
}
|
||||
|
||||
private evict() {
|
||||
if (this.tiles.size <= 900) return;
|
||||
const old = [...this.tiles.entries()].sort((a, b) => a[1].used - b[1].used).slice(0, 250);
|
||||
for (const [k] of old) this.tiles.delete(k);
|
||||
}
|
||||
|
||||
private inBounds(z: number, x: number, y: number): boolean {
|
||||
const b = this.dimension?.bounds;
|
||||
if (!b) return true;
|
||||
const span = 2 ** z;
|
||||
return x * span <= b.max_x && (x + 1) * span - 1 >= b.min_x && y * span <= b.max_y && (y + 1) * span - 1 >= b.min_y;
|
||||
}
|
||||
|
||||
// ---- drawing ------------------------------------------------------------------------------------------
|
||||
|
||||
invalidate() {
|
||||
if (this.disposed || this.raf) return;
|
||||
this.raf = requestAnimationFrame((t) => { this.raf = 0; this.frame(t); });
|
||||
}
|
||||
|
||||
private frame(now: number) {
|
||||
if (this.fly) {
|
||||
const f = this.fly;
|
||||
const k = Math.min(1, (now - f.start) / f.ms);
|
||||
const e = 1 - (1 - k) ** 3;
|
||||
this.scale = f.fromS * (f.toS / f.fromS) ** e;
|
||||
this.cx = f.fromX + (f.toX - f.fromX) * e;
|
||||
this.cz = f.fromZ + (f.toZ - f.fromZ) * e;
|
||||
if (k >= 1) this.fly = null;
|
||||
this.emitView();
|
||||
this.invalidate();
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private draw() {
|
||||
const ctx = this.ctx;
|
||||
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||
ctx.fillStyle = this.theme.background;
|
||||
ctx.fillRect(0, 0, this.width, this.height);
|
||||
if (!this.dimension || !this.info) return;
|
||||
this.drawTiles(ctx);
|
||||
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||
if (this.layers.claims) this.drawClaims(ctx);
|
||||
if (this.layers.pins) this.drawPins(ctx);
|
||||
if (this.layers.players) this.drawPlayers(ctx);
|
||||
}
|
||||
|
||||
private drawTiles(ctx: CanvasRenderingContext2D) {
|
||||
const slug = this.dimension!.slug;
|
||||
const maxZoom = this.info!.max_zoom;
|
||||
const zoom = clamp(Math.floor(Math.log2(1 / this.scale)), 0, maxZoom);
|
||||
const span = TILE * 2 ** zoom; // world blocks per tile
|
||||
const px = span * this.scale; // screen pixels per tile
|
||||
const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height);
|
||||
const x0 = Math.floor(tl.x / span), x1 = Math.floor(br.x / span), y0 = Math.floor(tl.z / span), y1 = Math.floor(br.z / span);
|
||||
ctx.imageSmoothingEnabled = this.scale < 1;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
// A faint chunk grid when close enough to matter.
|
||||
if (this.scale >= 3) this.drawGrid(ctx);
|
||||
for (let ty = y0; ty <= y1; ty++) {
|
||||
for (let tx = x0; tx <= x1; tx++) {
|
||||
if (!this.inBounds(zoom, tx, ty)) continue;
|
||||
const dx = Math.floor((tx * span - this.cx) * this.scale + this.width / 2);
|
||||
const dy = Math.floor((ty * span - this.cz) * this.scale + this.height / 2);
|
||||
const dsize = Math.ceil(px) + 1; // overlap by a pixel so seams never show
|
||||
const tile = this.tileAt(slug, zoom, tx, ty, true);
|
||||
if (tile?.state === 'ok') { ctx.drawImage(tile.img, dx, dy, dsize, dsize); continue; }
|
||||
// Not here yet: show the blurry parent underneath, then the real one replaces it.
|
||||
for (let up = 1; zoom + up <= maxZoom; up++) {
|
||||
const parent = this.tileAt(slug, zoom + up, tx >> up, ty >> up, tile?.state === 'loading' && up === 1);
|
||||
if (parent?.state === 'ok') {
|
||||
const part = TILE / 2 ** up;
|
||||
const sx = (tx & (2 ** up - 1)) * part, sy = (ty & (2 ** up - 1)) * part;
|
||||
ctx.drawImage(parent.img, sx, sy, part, part, dx, dy, dsize, dsize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawGrid(ctx: CanvasRenderingContext2D) {
|
||||
const step = 16 * this.scale;
|
||||
const a = this.toWorld(0, 0);
|
||||
ctx.strokeStyle = this.theme.grid;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + step; x += 16) {
|
||||
const sx = Math.round((x - this.cx) * this.scale + this.width / 2) + 0.5;
|
||||
ctx.moveTo(sx, 0); ctx.lineTo(sx, this.height);
|
||||
}
|
||||
for (let z = Math.floor(a.z / 16) * 16; (z - this.cz) * this.scale + this.height / 2 < this.height + step; z += 16) {
|
||||
const sy = Math.round((z - this.cz) * this.scale + this.height / 2) + 0.5;
|
||||
ctx.moveTo(0, sy); ctx.lineTo(this.width, sy);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
private claimPath(c: MapClaim): Path2D {
|
||||
let p = this.paths.get(c.id);
|
||||
if (!p) {
|
||||
p = new Path2D();
|
||||
for (const ring of [c.outer, ...c.holes]) {
|
||||
ring.forEach(([x, z], i) => (i ? p!.lineTo(x, z) : p!.moveTo(x, z)));
|
||||
p.closePath();
|
||||
}
|
||||
this.paths.set(c.id, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private drawClaims(ctx: CanvasRenderingContext2D) {
|
||||
const slug = this.visibleSlug();
|
||||
const tl = this.toWorld(0, 0), br = this.toWorld(this.width, this.height);
|
||||
const picked = this.selected?.type === 'claim' ? this.selected.claim.id : null;
|
||||
for (const c of this.overlay.claims) {
|
||||
if (dimSlug(c.dimension) !== slug || !c.outer.length) continue;
|
||||
const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]);
|
||||
if (Math.max(...xs) < tl.x || Math.min(...xs) > br.x || Math.max(...zs) < tl.z || Math.min(...zs) > br.z) continue;
|
||||
const hot = c.id === this.hoverId || c.id === picked;
|
||||
ctx.save();
|
||||
ctx.setTransform(this.dpr * this.scale, 0, 0, this.dpr * this.scale, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale));
|
||||
const path = this.claimPath(c);
|
||||
ctx.fillStyle = withAlpha(c.color, hot ? 0.46 : 0.28);
|
||||
ctx.fill(path, 'evenodd');
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = (hot ? 3 : 2) / this.scale;
|
||||
ctx.strokeStyle = withAlpha(c.color, 0.95);
|
||||
ctx.stroke(path);
|
||||
ctx.restore();
|
||||
}
|
||||
// Labels on top of all shapes, once they are big enough to read.
|
||||
for (const c of this.overlay.claims) {
|
||||
if (dimSlug(c.dimension) !== slug || !c.outer.length) continue;
|
||||
const xs = c.outer.map((p) => p[0]), zs = c.outer.map((p) => p[1]);
|
||||
const w = (Math.max(...xs) - Math.min(...xs)) * this.scale, h = (Math.max(...zs) - Math.min(...zs)) * this.scale;
|
||||
if (w < 46 || h < 30) continue;
|
||||
const s = this.toScreen(c.label_x, c.label_z);
|
||||
if (s.x < -80 || s.y < -40 || s.x > this.width + 80 || s.y > this.height + 40) continue;
|
||||
const showName = w > 150 && h > 50;
|
||||
this.chip(ctx, s.x, s.y, showName ? `${c.tag ? `[${c.tag}] ` : ''}${c.name}` : c.tag || c.name, c.color, c.icon);
|
||||
}
|
||||
}
|
||||
|
||||
private drawPins(ctx: CanvasRenderingContext2D) {
|
||||
const slug = this.visibleSlug();
|
||||
const picked = this.selected?.type === 'pin' ? this.selected.pin.id : null;
|
||||
for (const p of this.overlay.pins) {
|
||||
if (dimSlug(p.dimension) !== slug) continue;
|
||||
const s = this.toScreen(p.x, p.z);
|
||||
if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue;
|
||||
const hot = p.id === this.hoverId || p.id === picked;
|
||||
const color = PIN_COLORS[p.kind] ?? '#94a3b8';
|
||||
const r = hot ? 14 : 12;
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
|
||||
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color; ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.lineWidth = 2; ctx.strokeStyle = '#fff'; ctx.stroke();
|
||||
drawGlyph(ctx, p.kind, s.x, s.y, r * 0.58);
|
||||
if (hot || this.scale >= 0.9) this.tag(ctx, s.x, s.y + r + 9, p.label);
|
||||
}
|
||||
}
|
||||
|
||||
private drawPlayers(ctx: CanvasRenderingContext2D) {
|
||||
const slug = this.visibleSlug();
|
||||
const picked = this.selected?.type === 'player' ? this.selected.player.uuid : null;
|
||||
for (const p of this.overlay.players) {
|
||||
if (dimSlug(p.dimension) !== slug) continue;
|
||||
const s = this.toScreen(p.x, p.z);
|
||||
if (s.x < -30 || s.y < -30 || s.x > this.width + 30 || s.y > this.height + 30) continue;
|
||||
const me = !!this.opts.selfUuid && p.uuid.toLowerCase() === this.opts.selfUuid.toLowerCase();
|
||||
const hot = p.uuid === this.hoverId || p.uuid === picked;
|
||||
const ring = me ? this.theme.self : this.theme.accent;
|
||||
const r = hot ? 14 : 12;
|
||||
// facing direction: Minecraft yaw 0 = south (+z), 90 = west (-x)
|
||||
const a = (p.yaw * Math.PI) / 180;
|
||||
const fx = -Math.sin(a), fz = Math.cos(a);
|
||||
ctx.save();
|
||||
ctx.translate(s.x, s.y);
|
||||
ctx.rotate(Math.atan2(fz, fx));
|
||||
ctx.beginPath(); ctx.moveTo(r + 9, 0); ctx.lineTo(r + 1, -5); ctx.lineTo(r + 1, 5); ctx.closePath();
|
||||
ctx.fillStyle = ring; ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
|
||||
ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ring; ctx.fill();
|
||||
ctx.restore();
|
||||
const img = this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null;
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip();
|
||||
if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); }
|
||||
else { ctx.fillStyle = '#1e293b'; ctx.fillRect(s.x - r, s.y - r, r * 2, r * 2); ctx.fillStyle = '#fff'; ctx.font = '700 12px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText((p.name[0] ?? '?').toUpperCase(), s.x, s.y + 1); }
|
||||
ctx.restore();
|
||||
this.tag(ctx, s.x, s.y - r - 12, p.name, me ? this.theme.self : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private icon(url: string): HTMLImageElement | null {
|
||||
if (!url) return null;
|
||||
const got = this.icons.get(url);
|
||||
if (got === 'failed') return null;
|
||||
if (got) return got.complete && got.naturalWidth ? got : null;
|
||||
const img = new Image();
|
||||
img.onload = () => this.invalidate();
|
||||
img.onerror = () => this.icons.set(url, 'failed');
|
||||
img.src = url;
|
||||
this.icons.set(url, img);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A small rounded label with an optional round icon, centred on (x, y). */
|
||||
private chip(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color: string, iconUrl: string) {
|
||||
ctx.font = '700 12px system-ui, -apple-system, Segoe UI, sans-serif';
|
||||
const img = iconUrl ? this.icon(iconUrl) : null;
|
||||
const iconW = img ? 20 : 0;
|
||||
const w = ctx.measureText(text).width + 18 + iconW, h = 24;
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
|
||||
roundRect(ctx, x - w / 2, y - h / 2, w, h, 12);
|
||||
ctx.fillStyle = this.theme.labelBg; ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.lineWidth = 1.5; ctx.strokeStyle = withAlpha(color, 0.9); ctx.stroke();
|
||||
let left = x - w / 2 + 9;
|
||||
if (img) {
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(left + 8, y, 8, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, left, y - 8, 16, 16); ctx.restore();
|
||||
left += 20;
|
||||
}
|
||||
ctx.fillStyle = this.theme.label; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, left, y + 0.5);
|
||||
}
|
||||
|
||||
private tag(ctx: CanvasRenderingContext2D, x: number, y: number, text: string, color?: string) {
|
||||
ctx.font = '600 11px system-ui, -apple-system, Segoe UI, sans-serif';
|
||||
const w = ctx.measureText(text).width + 12;
|
||||
roundRect(ctx, x - w / 2, y - 9, w, 18, 9);
|
||||
ctx.fillStyle = this.theme.labelBg; ctx.fill();
|
||||
if (color) { ctx.lineWidth = 1; ctx.strokeStyle = color; ctx.stroke(); }
|
||||
ctx.fillStyle = this.theme.label; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, x, y + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers --------------------------------------------------------------------------------------------
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
|
||||
|
||||
/** Zoom-0 tile bounds -> world block bounds [minX, maxX, minZ, maxZ]. */
|
||||
function worldBounds(b: MapBounds): [number, number, number, number] {
|
||||
return [b.min_x * TILE, (b.max_x + 1) * TILE, b.min_y * TILE, (b.max_y + 1) * TILE];
|
||||
}
|
||||
|
||||
function hitId(s: MapSelection): string { return s.type === 'player' ? s.player.uuid : s.type === 'pin' ? s.pin.id : s.claim.id; }
|
||||
function hitLabel(s: MapSelection): string { return s.type === 'player' ? s.player.name : s.type === 'pin' ? s.pin.label : `${s.claim.tag ? `[${s.claim.tag}] ` : ''}${s.claim.name}`; }
|
||||
|
||||
function inside(ring: [number, number][], x: number, z: number): boolean {
|
||||
let in_ = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [ax, az] = ring[i], [bx, bz] = ring[j];
|
||||
if ((az > z) !== (bz > z) && x < ((bx - ax) * (z - az)) / (bz - az) + ax) in_ = !in_;
|
||||
}
|
||||
return in_;
|
||||
}
|
||||
|
||||
export function withAlpha(color: string, alpha: number): string {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(color.trim());
|
||||
if (!m) return color;
|
||||
const n = parseInt(m[1], 16);
|
||||
return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/** A tiny white symbol for each kind of pin, drawn as shapes so no icon font is needed. */
|
||||
function drawGlyph(ctx: CanvasRenderingContext2D, kind: string, x: number, y: number, r: number) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
switch (kind) {
|
||||
case 'spawn': // star
|
||||
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5, rr = i % 2 ? r * 0.45 : r * 1.15; ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); }
|
||||
ctx.closePath(); ctx.fill();
|
||||
break;
|
||||
case 'warp': // diamond with a hole
|
||||
ctx.moveTo(0, -r * 1.2); ctx.lineTo(r * 0.95, 0); ctx.lineTo(0, r * 1.2); ctx.lineTo(-r * 0.95, 0); ctx.closePath(); ctx.fill();
|
||||
break;
|
||||
case 'home': // house
|
||||
ctx.moveTo(-r * 1.1, 0); ctx.lineTo(0, -r * 1.1); ctx.lineTo(r * 1.1, 0); ctx.lineTo(r * 0.7, 0); ctx.lineTo(r * 0.7, r); ctx.lineTo(-r * 0.7, r); ctx.lineTo(-r * 0.7, 0); ctx.closePath(); ctx.fill();
|
||||
break;
|
||||
case 'guild_home': // flag
|
||||
ctx.fillRect(-r * 0.8, -r * 1.1, r * 0.28, r * 2.2);
|
||||
ctx.moveTo(-r * 0.52, -r * 1.1); ctx.lineTo(r * 1.0, -r * 0.5); ctx.lineTo(-r * 0.52, 0.1 * r); ctx.closePath(); ctx.fill();
|
||||
break;
|
||||
case 'market': // $
|
||||
ctx.font = `800 ${Math.round(r * 2.3)}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('$', 0, r * 0.1);
|
||||
break;
|
||||
default: // shop: a bag
|
||||
ctx.moveTo(-r * 0.9, -r * 0.3); ctx.lineTo(r * 0.9, -r * 0.3); ctx.lineTo(r * 0.7, r * 1.0); ctx.lineTo(-r * 0.7, r * 1.0); ctx.closePath(); ctx.fill();
|
||||
ctx.lineWidth = Math.max(1.2, r * 0.22); ctx.beginPath(); ctx.arc(0, -r * 0.3, r * 0.5, Math.PI, 0); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { MapInfo, MapOverlay } from './types';
|
||||
|
||||
export interface FeedOptions {
|
||||
loadInfo: () => Promise<MapInfo>;
|
||||
loadOverlay: () => Promise<MapOverlay>;
|
||||
onInfo: (info: MapInfo) => void;
|
||||
onOverlay: (overlay: MapOverlay) => void;
|
||||
onError?: (message: string) => void;
|
||||
/** How often players move on the map. */
|
||||
overlayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a map fresh: the overlay (players, claims, pins) every couple of seconds while the window is visible, and the info
|
||||
* (which carries the tile key) when it is about to expire. Returns a function that stops everything.
|
||||
*/
|
||||
export function startFeed(o: FeedOptions): () => void {
|
||||
let stopped = false;
|
||||
let infoTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let overlayTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ready = false;
|
||||
|
||||
const fail = (e: unknown) => o.onError?.(typeof e === 'string' ? e : (e as { message?: string })?.message ?? 'Could not reach the map');
|
||||
|
||||
async function info() {
|
||||
try {
|
||||
const next = await o.loadInfo();
|
||||
if (stopped) return;
|
||||
ready = next.ready;
|
||||
o.onInfo(next);
|
||||
// The tile key is good for an hour: renew it with plenty of time to spare. While waiting for the first tiles, check often.
|
||||
const wait = next.ready ? Math.max(30, Math.min(next.token_ttl_secs * 0.6, 1200)) * 1000 : 8000;
|
||||
infoTimer = setTimeout(info, wait);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
if (!stopped) infoTimer = setTimeout(info, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
async function overlay() {
|
||||
if (stopped) return;
|
||||
if (ready && !document.hidden) {
|
||||
try { o.onOverlay(await o.loadOverlay()); } catch (e) { fail(e); }
|
||||
}
|
||||
if (!stopped) overlayTimer = setTimeout(overlay, o.overlayMs ?? 2500);
|
||||
}
|
||||
|
||||
info();
|
||||
overlayTimer = setTimeout(overlay, 600);
|
||||
return () => { stopped = true; clearTimeout(infoTimer); clearTimeout(overlayTimer); };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { MapView, withAlpha } from './engine';
|
||||
export type { MapViewOptions, Theme } from './engine';
|
||||
export { startFeed } from './feed';
|
||||
export type { FeedOptions } from './feed';
|
||||
export { dimSlug } from './types';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,89 @@
|
||||
// What the panel's map endpoints return (see panel/server/src/routes/worldmap.rs), shared by the launcher and the admin panel.
|
||||
|
||||
export interface MapBounds { min_x: number; max_x: number; min_y: number; max_y: number }
|
||||
|
||||
export interface MapDimension {
|
||||
id: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
available: boolean;
|
||||
tiles: number;
|
||||
bytes: number;
|
||||
bounds: MapBounds | null;
|
||||
}
|
||||
|
||||
export interface MapInfo {
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
message: string;
|
||||
tile_size: number;
|
||||
max_zoom: number;
|
||||
/** Where tiles live, e.g. `https://panel.example.com/api/map/3`. */
|
||||
tile_base: string;
|
||||
/** Short-lived key that goes on each tile address. */
|
||||
token: string | null;
|
||||
token_ttl_secs: number;
|
||||
dimensions: MapDimension[];
|
||||
players: number;
|
||||
}
|
||||
|
||||
export interface LivePlayer {
|
||||
uuid: string;
|
||||
name: string;
|
||||
dimension: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
export interface MapClaim {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
dimension: string;
|
||||
chunks: number;
|
||||
label_x: number;
|
||||
label_z: number;
|
||||
outer: [number, number][];
|
||||
holes: [number, number][][];
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export type PinKind = 'spawn' | 'warp' | 'home' | 'guild_home' | 'market' | 'shop';
|
||||
|
||||
export interface MapPin {
|
||||
id: string;
|
||||
kind: PinKind | string;
|
||||
label: string;
|
||||
dimension: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface MapOverlay {
|
||||
players: LivePlayer[];
|
||||
claims: MapClaim[];
|
||||
pins: MapPin[];
|
||||
updated?: string | null;
|
||||
}
|
||||
|
||||
export type MapSelection =
|
||||
| { type: 'player'; player: LivePlayer }
|
||||
| { type: 'pin'; pin: MapPin }
|
||||
| { type: 'claim'; claim: MapClaim };
|
||||
|
||||
export interface MapLayers { claims: boolean; pins: boolean; players: boolean }
|
||||
|
||||
/** Same naming as the panel: `minecraft:the_nether` -> `the_nether`, `mod:dim` -> `mod__dim`. */
|
||||
export function dimSlug(id: string): string {
|
||||
const trimmed = id.startsWith('minecraft:') ? id.slice(10) : id;
|
||||
if (trimmed === 'nether') return 'the_nether';
|
||||
if (trimmed === 'end') return 'the_end';
|
||||
return trimmed.replace(':', '__');
|
||||
}
|
||||
Reference in new issue
Block a user