Files
SCOPENET-MC/shared/map/engine.ts
T
SCOPEDD 7809d47d39
CI / Real Minecraft installs (manual) (push) Skipped
CI / Rust, web and container (push) Canceled after 0s
CI / Windows launcher (push) Canceled after 0s
Server integrations / paper (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.20.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.21.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.1.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.3 (push) Canceled after 0s
Migrate CI to Gitea and improve live map diagnostics
2026-10-01 00:07:35 -04:00

773 lines
33 KiB
TypeScript

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 = 48;
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 showSkins = 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;
private texture: CanvasPattern | null = null;
private hoverBlock: { x: number; z: number } | null = null;
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(); }
setSkins(show: boolean) { this.showSkins = show; 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);
const block = { x: Math.floor(w.x), z: Math.floor(w.z) };
if (this.scale >= 8 && (!this.hoverBlock || this.hoverBlock.x !== block.x || this.hoverBlock.z !== block.z)) { this.hoverBlock = block; this.invalidate(); }
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.hoverBlock) { this.hoverId = null; this.hoverBlock = 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.scale >= 6) this.drawBlocks(ctx);
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.scale < 6) 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;
}
}
}
}
}
/**
* Close up, every block is its own cell: a fine texture, a light and a dark edge, stronger chunk lines, and an outline on the
* block under the cursor. The tiles only know each block's colour, so the texture is drawn on top rather than stored.
*/
private drawBlocks(ctx: CanvasRenderingContext2D) {
const strength = clamp((this.scale - 6) / 10, 0, 1);
if (!this.texture) this.texture = makeTexture(ctx);
if (this.texture) {
ctx.save();
ctx.globalAlpha = 0.55 * strength + 0.25;
ctx.globalCompositeOperation = 'overlay';
const k = this.scale / 16 * this.dpr;
// Pattern covers 8 x 8 blocks (128 px); align it to the world so blocks stay put while panning.
this.texture.setTransform(new DOMMatrix([k, 0, 0, k, this.dpr * (this.width / 2 - this.cx * this.scale), this.dpr * (this.height / 2 - this.cz * this.scale)]));
ctx.fillStyle = this.texture;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx.restore();
}
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
// Chunk borders every 16 blocks.
const a = this.toWorld(0, 0);
ctx.strokeStyle = 'rgba(255,255,255,0.34)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let x = Math.floor(a.x / 16) * 16; (x - this.cx) * this.scale + this.width / 2 < this.width + 20; 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 + 20; 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();
if (this.hoverBlock && this.scale >= 8) {
const s = this.toScreen(this.hoverBlock.x, this.hoverBlock.z);
ctx.lineWidth = 2;
ctx.strokeStyle = '#ffffff';
ctx.strokeRect(s.x + 1, s.y + 1, this.scale - 2, this.scale - 2);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
ctx.strokeRect(s.x - 0.5, s.y - 0.5, this.scale + 1, this.scale + 1);
}
}
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.showSkins && 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();
}
/**
* An 8 x 8 block texture sheet (16 px per block): fine noise that differs block to block, a light top-left edge and a dark
* bottom-right edge, as grey values for the "overlay" blend (mid grey = no change).
*/
function makeTexture(ctx: CanvasRenderingContext2D): CanvasPattern | null {
const c = document.createElement('canvas');
c.width = c.height = 128;
const g = c.getContext('2d');
if (!g) return null;
g.fillStyle = '#808080';
g.fillRect(0, 0, 128, 128);
let seed = 1234567;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
for (let by = 0; by < 8; by++) {
for (let bx = 0; bx < 8; bx++) {
const ox = bx * 16, oy = by * 16;
const base = 120 + Math.floor(rnd() * 16); // each block a touch lighter or darker
g.fillStyle = `rgb(${base},${base},${base})`;
g.fillRect(ox, oy, 16, 16);
for (let i = 0; i < 4; i++) { // 4 x 4 chunky speckles, like a texture
for (let j = 0; j < 4; j++) {
const v = base + Math.floor((rnd() - 0.5) * 34);
g.fillStyle = `rgb(${v},${v},${v})`;
g.fillRect(ox + i * 4, oy + j * 4, 4, 4);
}
}
g.fillStyle = 'rgba(255,255,255,0.55)'; g.fillRect(ox, oy, 16, 1); g.fillRect(ox, oy, 1, 16);
g.fillStyle = 'rgba(0,0,0,0.6)'; g.fillRect(ox, oy + 15, 16, 1); g.fillRect(ox + 15, oy, 1, 16);
}
}
return ctx.createPattern(c, 'repeat');
}