1/**2 * Urizen-tileset map renderer for the viewer's Watch tab.3 *4 * Renders 1:1 at 12px per map tile (mapSize 128 → 1536px canvas); the page5 * displays it through a mapping-software camera (zoom + center, applied as a6 * CSS transform inside the overflow-hidden host — see the map-view styles in7 * style.css). Wheel zooms at the cursor, drag pans, a corner minimap shows8 * the viewport when zoomed in. The camera is presentation-only: sim frames9 * still rasterize the full world. Terrain is rasterized once to an offscreen10 * layer and only redrawn when the terrain string changes (fog reveals, POV11 * switches); entities are composited over it every frame.12 */13import type { MapFrame } from '../sim/frames';14import type { ThreatKind, WarbandKind } from '@tribes/protocol';15import { log } from '../log';16import { tribeColor } from '../viz/palette';17import type { MapEffects } from './effects';18import { loadUrizen, TILE, type UrizenAtlas } from './urizen';19import {20 cellHash,21 FOG,22 isLandChar,23 pickDecor,24 pickVariant,25 SHORE,26 stepOf,27 TERRAIN,28} from './terrain-style';2930const TIER_GLYPH = ['settlement.camp', 'settlement.village', 'settlement.town', 'settlement.city'] as const;31const TIER_LABEL = ['camp', 'village', 'town', 'city'] as const;3233const WARBAND_GLYPH: Record<WarbandKind, string> = {34 infantry: 'warband.infantry',35 archers: 'warband.archers',36 cavalry: 'warband.cavalry',37};3839const THREAT_GLYPH: Record<ThreatKind, string> = {40 goblin_camp: 'threat.goblin_camp',41 skeleton_barrow: 'threat.skeleton_barrow',42 ogre_den: 'threat.ogre_den',43 demon_gate: 'threat.demon_gate',44};4546const THREAT_LABEL: Record<ThreatKind, string> = {47 goblin_camp: 'goblin camp',48 skeleton_barrow: 'skeleton barrow',49 ogre_den: 'ogre den',50 demon_gate: 'demon gate',51};5253function ctx2d(c: HTMLCanvasElement): CanvasRenderingContext2D {54 const ctx = c.getContext('2d');55 if (!ctx) throw new Error('2d canvas context unavailable');56 return ctx;57}5859function fmtCount(n: number): string {60 if (n >= 10_000) return `${Math.round(n / 1000)}k`;61 if (n >= 1_000) return `${(n / 1000).toFixed(1)}k`;62 return String(n);63}6465export function createMapView(66 container: HTMLElement,67 opts: { mapSize: number; names: string[]; effects?: MapEffects },68): {69 draw(frame: MapFrame): void;70 attachTooltips(getFrame: () => MapFrame | null): { dispose(): void };71 /** Reset the camera to the default zoom-to-fit, centered view. */72 resetView(): void;73 dispose(): void;74} {75 const { mapSize, names } = opts;76 const sizePx = mapSize * TILE;77 const tribeName = (t: number): string => names[t] ?? `tribe ${t}`;7879 const canvas = document.createElement('canvas');80 canvas.width = sizePx;81 canvas.height = sizePx;82 canvas.className = 'urizen-map';83 container.append(canvas);84 const ctx = ctx2d(canvas);85 ctx.imageSmoothingEnabled = false;8687 // ---- camera (OpenLayers-style pan/zoom; presentation-only) --------------88 // The camera never touches the world raster: it is a CSS transform on the89 // canvas inside the overflow-hidden host. `zoom` is screen px per world px90 // (1 = native 12px tiles); (cx, cy) is the world-px point under the91 // viewport center.92 const MAX_ZOOM = 8;93 const MIN_FIT_FACTOR = 0.8; // may zoom out slightly past fit, no further94 let hostW = 0;95 let hostH = 0;96 let zoom = 1;97 let cx = sizePx / 2;98 let cy = sizePx / 2;99 let userMoved = false; // once true, resizes keep the user's view (clamped)100 let panning = false;101102 const fitZoom = (): number =>103 hostW > 0 && hostH > 0 ? Math.min(hostW / sizePx, hostH / sizePx) : 1;104 const clampZoom = (z: number): number =>105 Math.min(MAX_ZOOM, Math.max(fitZoom() * MIN_FIT_FACTOR, z));106107 /** Keep the map on screen: no gap past an edge when zoomed in; an axis the108 * whole map fits on stays centered. */109 function clampCenter(): void {110 const halfW = hostW / (2 * zoom);111 const halfH = hostH / (2 * zoom);112 cx = halfW >= sizePx / 2 ? sizePx / 2 : Math.min(sizePx - halfW, Math.max(halfW, cx));113 cy = halfH >= sizePx / 2 ? sizePx / 2 : Math.min(sizePx - halfH, Math.max(halfH, cy));114 }115116 function applyCamera(): void {117 // Snap the translation to device pixels so nearest-neighbor scaling118 // doesn't shimmer on subpixel offsets.119 const dpr = window.devicePixelRatio || 1;120 const ox = Math.round((hostW / 2 - cx * zoom) * dpr) / dpr;121 const oy = Math.round((hostH / 2 - cy * zoom) * dpr) / dpr;122 canvas.style.transform = `translate3d(${ox}px, ${oy}px, 0) scale(${zoom})`;123 drawMinimap();124 }125126 /** Zoom to `z`, keeping the world point under host-relative (sx, sy) fixed. */127 function zoomAt(sx: number, sy: number, z: number): void {128 const nz = clampZoom(z);129 if (nz === zoom) return;130 const wx = (sx - hostW / 2) / zoom + cx;131 const wy = (sy - hostH / 2) / zoom + cy;132 zoom = nz;133 cx = wx - (sx - hostW / 2) / zoom;134 cy = wy - (sy - hostH / 2) / zoom;135 userMoved = true;136 clampCenter();137 applyCamera();138 }139140 function resetView(): void {141 userMoved = false;142 zoom = fitZoom();143 cx = sizePx / 2;144 cy = sizePx / 2;145 applyCamera();146 }147148 // wheel zoom, anchored at the cursor (trackpad pinch arrives as ctrl+wheel)149 const onWheel = (e: WheelEvent): void => {150 e.preventDefault();151 const rect = container.getBoundingClientRect();152 const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; // line → px153 zoomAt(e.clientX - rect.left, e.clientY - rect.top, zoom * Math.exp(-dy * 0.0015));154 };155156 // drag pan (mouse + touch via pointer events) and two-finger pinch zoom157 const pointers = new Map<number, { x: number; y: number }>();158 let pinchDist = 0;159 const dist = (): number => {160 const [a, b] = [...pointers.values()];161 return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;162 };163 const onPointerDown = (e: PointerEvent): void => {164 if (e.pointerType === 'mouse' && e.button !== 0) return;165 container.setPointerCapture(e.pointerId);166 pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });167 if (pointers.size === 1) {168 panning = true;169 container.classList.add('panning');170 } else if (pointers.size === 2) {171 pinchDist = dist();172 }173 };174 const onPointerMove = (e: PointerEvent): void => {175 const p = pointers.get(e.pointerId);176 if (!p) return;177 if (pointers.size === 2) {178 pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });179 const [a, b] = [...pointers.values()];180 if (!a || !b) return;181 const d = dist();182 if (pinchDist > 0 && d > 0) {183 const rect = container.getBoundingClientRect();184 zoomAt(185 (a.x + b.x) / 2 - rect.left,186 (a.y + b.y) / 2 - rect.top,187 zoom * (d / pinchDist),188 );189 }190 pinchDist = d;191 return;192 }193 cx -= (e.clientX - p.x) / zoom;194 cy -= (e.clientY - p.y) / zoom;195 pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });196 userMoved = true;197 clampCenter();198 applyCamera();199 };200 const onPointerEnd = (e: PointerEvent): void => {201 pointers.delete(e.pointerId);202 if (pointers.size < 2) pinchDist = 0;203 if (pointers.size === 0) {204 panning = false;205 container.classList.remove('panning');206 }207 };208 container.addEventListener('wheel', onWheel, { passive: false });209 container.addEventListener('pointerdown', onPointerDown);210 container.addEventListener('pointermove', onPointerMove);211 container.addEventListener('pointerup', onPointerEnd);212 container.addEventListener('pointercancel', onPointerEnd);213214 // refit (default view) or re-clamp (user view) whenever the pane resizes;215 // also fires when the pane first becomes visible with a real size.216 const ro = new ResizeObserver(() => {217 const r = container.getBoundingClientRect();218 if (r.width === 0 || r.height === 0) return; // pane hidden — keep state219 hostW = r.width;220 hostH = r.height;221 if (userMoved) {222 zoom = clampZoom(zoom);223 clampCenter();224 applyCamera();225 } else {226 resetView();227 }228 });229 ro.observe(container);230231 // ---- minimap: downscaled snapshot + viewport rectangle -------------------232 // Hidden while the whole map fits the pane (nothing to orient by).233 const MM_CSS = 140;234 const mmDpr = Math.max(1, Math.round(window.devicePixelRatio || 1));235 const minimap = document.createElement('canvas');236 minimap.className = 'map-minimap';237 minimap.width = MM_CSS * mmDpr;238 minimap.height = MM_CSS * mmDpr;239 minimap.style.display = 'none';240 container.append(minimap);241 const mmctx = ctx2d(minimap);242243 function drawMinimap(): void {244 const fits = zoom <= fitZoom() * 1.001;245 minimap.style.display = fits ? 'none' : '';246 if (fits) return;247 const s = minimap.width / sizePx;248 mmctx.fillStyle = '#000';249 mmctx.fillRect(0, 0, minimap.width, minimap.height);250 mmctx.drawImage(canvas, 0, 0, minimap.width, minimap.height);251 // dim the whole snapshot, then repaint the viewport region bright252 const ww = hostW / zoom; // viewport extent in world px253 const wh = hostH / zoom;254 const wx = cx - ww / 2;255 const wy = cy - wh / 2;256 mmctx.fillStyle = 'rgba(0, 0, 0, 0.45)';257 mmctx.fillRect(0, 0, minimap.width, minimap.height);258 mmctx.drawImage(canvas, wx, wy, ww, wh, wx * s, wy * s, ww * s, wh * s);259 mmctx.strokeStyle = '#e0a458'; // --accent260 mmctx.lineWidth = mmDpr;261 mmctx.strokeRect(wx * s + 0.5, wy * s + 0.5, ww * s, wh * s);262 }263264 // click/drag on the minimap recenters the camera265 let mmDrag = false;266 const mmRecenter = (e: PointerEvent): void => {267 const r = minimap.getBoundingClientRect();268 cx = ((e.clientX - r.left) / r.width) * sizePx;269 cy = ((e.clientY - r.top) / r.height) * sizePx;270 userMoved = true;271 clampCenter();272 applyCamera();273 };274 minimap.addEventListener('pointerdown', (e) => {275 e.stopPropagation(); // don't start a host pan276 e.preventDefault();277 minimap.setPointerCapture(e.pointerId);278 mmDrag = true;279 mmRecenter(e);280 });281 minimap.addEventListener('pointermove', (e) => {282 if (mmDrag) mmRecenter(e);283 });284 minimap.addEventListener('pointerup', () => { mmDrag = false; });285 minimap.addEventListener('pointercancel', () => { mmDrag = false; });286287 // Terrain layer cache — redrawn only when the terrain string (or atlas288 // availability) changes.289 const terrainLayer = document.createElement('canvas');290 terrainLayer.width = sizePx;291 terrainLayer.height = sizePx;292 const tctx = ctx2d(terrainLayer);293 tctx.imageSmoothingEnabled = false;294 let terrainKey = ''; // `${atlasReady}|${terrainString}`295296 let atlas: UrizenAtlas | null = null;297 let lastFrame: MapFrame | null = null;298 let disposed = false;299 loadUrizen().then(300 (a) => {301 if (disposed) return;302 atlas = a;303 if (lastFrame) draw(lastFrame); // re-render with glyphs now available304 },305 (err) => log.error('urizen atlas failed to load', err),306 );307308 /** True when the water cell at (tx, ty) touches land on a 4-neighbor side. */309 function nearLand(terrain: string, tx: number, ty: number): boolean {310 return (311 (tx > 0 && isLandChar(terrain[ty * mapSize + tx - 1] ?? '?')) ||312 (tx < mapSize - 1 && isLandChar(terrain[ty * mapSize + tx + 1] ?? '?')) ||313 (ty > 0 && isLandChar(terrain[(ty - 1) * mapSize + tx] ?? '?')) ||314 (ty < mapSize - 1 && isLandChar(terrain[(ty + 1) * mapSize + tx] ?? '?'))315 );316 }317318 // All per-cell choices below (background step, variant, decor, tint step) are319 // pure functions of cellHash(tx, ty), so this cached layer is deterministic:320 // it only runs when the terrain string changes and always redraws the same.321 /** Distant-glimpse categories -> a representative biome config to render322 * (at DISTANT_ALPHA, muted, no decor). '^' = highland silhouette. */323 const DISTANT_REP: Record<string, string> = { '.': '.', f: 'f', '^': 'h', w: 'w' };324 const DISTANT_ALPHA = 0.35;325326 function renderTerrain(terrain: string, distant: string): void {327 const key = `${atlas ? 1 : 0}|${terrain}|${distant}`;328 if (key === terrainKey) return;329 terrainKey = key;330 tctx.fillStyle = '#000';331 tctx.fillRect(0, 0, sizePx, sizePx);332 // Distant glimpse band first: categorical silhouettes at 35% alpha.333 if (distant.length === mapSize * mapSize) {334 tctx.globalAlpha = DISTANT_ALPHA;335 for (let ty = 0; ty < mapSize; ty++) {336 for (let tx = 0; tx < mapSize; tx++) {337 const dch = distant[ty * mapSize + tx] ?? '?';338 const rep = DISTANT_REP[dch];339 if (rep === undefined) continue;340 const t = TERRAIN[rep];341 if (!t || t === FOG) continue;342 tctx.fillStyle = t.bg[1] ?? t.bg[0]!;343 tctx.fillRect(tx * TILE, ty * TILE, TILE, TILE);344 const v = t.variants[0];345 if (atlas && v?.glyph) {346 atlas.draw(tctx, v.glyph, tx * TILE, ty * TILE, TILE, v.tints ? v.tints[1] ?? v.tints[0] : undefined);347 }348 }349 }350 tctx.globalAlpha = 1;351 }352 for (let ty = 0; ty < mapSize; ty++) {353 for (let tx = 0; tx < mapSize; tx++) {354 const ch = terrain[ty * mapSize + tx] ?? '?';355 const t = TERRAIN[ch] ?? FOG;356 if (t === FOG) continue; // stays the black clear fill357 const h = cellHash(tx, ty);358 let bg: string;359 let glyph: string | undefined;360 let tint: string | undefined;361 if (ch === 'w' && nearLand(terrain, tx, ty)) {362 // Shoreline: lighter water + foam-tinted ripples so coasts read.363 bg = stepOf(SHORE.bg, h, 0);364 glyph = 'terrain.water';365 tint = stepOf(SHORE.tints, h, 24);366 } else {367 bg = stepOf(t.bg, h, 0);368 const decor = pickDecor(t, h);369 if (decor) {370 glyph = decor.glyph;371 tint = decor.tint;372 } else {373 const v = pickVariant(t, h);374 glyph = v.glyph;375 if (v.tints) tint = stepOf(v.tints, h, 24);376 }377 }378 tctx.fillStyle = bg;379 tctx.fillRect(tx * TILE, ty * TILE, TILE, TILE);380 if (atlas && glyph) atlas.draw(tctx, glyph, tx * TILE, ty * TILE, TILE, tint);381 }382 }383 }384385 function drawBadge(x: number, y: number, text: string, color: string): void {386 ctx.font = '7px ui-monospace, monospace';387 const w = ctx.measureText(text).width + 2;388 const bx = x + TILE - w;389 const by = y + TILE - 7;390 ctx.fillStyle = 'rgba(0,0,0,0.75)';391 ctx.fillRect(bx, by, w, 7);392 ctx.fillStyle = color;393 ctx.textBaseline = 'top';394 ctx.fillText(text, bx + 1, by);395 }396397 function draw(frame: MapFrame): void {398 lastFrame = frame;399 if (frame.terrain.length !== mapSize * mapSize) return; // no map payload400 renderTerrain(frame.terrain, frame.distant);401 ctx.clearRect(0, 0, sizePx, sizePx);402 ctx.drawImage(terrainLayer, 0, 0);403404 // threats (below tribal assets)405 for (const th of frame.threats) {406 const x = th.x * TILE;407 const y = th.y * TILE;408 if (atlas) atlas.draw(ctx, THREAT_GLYPH[th.kind] ?? 'threat.goblin_camp', x, y, TILE);409 }410411 // caravans — tribe-tinted camel412 for (const c of frame.caravans) {413 if (atlas) atlas.draw(ctx, 'unit.caravan', c.x * TILE, c.y * TILE, TILE, tribeColor(c.tribe));414 }415416 // warbands — kind glyph tinted by tribe + size badge417 for (const w of frame.warbands) {418 const x = w.x * TILE;419 const y = w.y * TILE;420 const color = tribeColor(w.tribe);421 if (atlas) atlas.draw(ctx, WARBAND_GLYPH[w.kind] ?? 'warband.infantry', x, y, TILE, color);422 drawBadge(x, y, fmtCount(w.size), color);423 }424425 // territory borders — organic claim edges from the engine's terrain-cost426 // expansion. A cell edge is drawn where ownership changes; interiors get427 // a faint wash so contested frontiers and enclaves read at a glance.428 if (frame.claims.length === mapSize * mapSize) {429 ctx.save();430 ctx.lineWidth = 1;431 for (let ty = 0; ty < mapSize; ty++) {432 for (let tx = 0; tx < mapSize; tx++) {433 const c = frame.claims[ty * mapSize + tx]!;434 if (c === '.') continue;435 const color = tribeColor(Number(c));436 const x = tx * TILE;437 const y = ty * TILE;438 ctx.globalAlpha = 0.05;439 ctx.fillStyle = color;440 ctx.fillRect(x, y, TILE, TILE);441 ctx.globalAlpha = 0.6;442 ctx.strokeStyle = color;443 const at = (nx: number, ny: number): string =>444 nx < 0 || ny < 0 || nx >= mapSize || ny >= mapSize ? '.' : frame.claims[ny * mapSize + nx]!;445 ctx.beginPath();446 if (at(tx, ty - 1) !== c) { ctx.moveTo(x, y + 0.5); ctx.lineTo(x + TILE, y + 0.5); }447 if (at(tx, ty + 1) !== c) { ctx.moveTo(x, y + TILE - 0.5); ctx.lineTo(x + TILE, y + TILE - 0.5); }448 if (at(tx - 1, ty) !== c) { ctx.moveTo(x + 0.5, y); ctx.lineTo(x + 0.5, y + TILE); }449 if (at(tx + 1, ty) !== c) { ctx.moveTo(x + TILE - 0.5, y); ctx.lineTo(x + TILE - 0.5, y + TILE); }450 ctx.stroke();451 }452 }453 ctx.restore();454 }455456 // settlements — tribe-tinted ring + tier glyph + banner457 for (const s of frame.settlements) {458 const x = s.x * TILE;459 const y = s.y * TILE;460 const color = tribeColor(s.tribe);461 ctx.strokeStyle = color;462 ctx.lineWidth = 1.5;463 ctx.beginPath();464 ctx.arc(x + TILE / 2, y + TILE / 2, TILE / 2 + 0.75, 0, Math.PI * 2);465 ctx.stroke();466 if (atlas) {467 atlas.draw(ctx, TIER_GLYPH[s.tier] ?? 'settlement.camp', x, y, TILE);468 // small tribe-tinted banner flying off the top-right corner469 atlas.draw(ctx, 'banner.flag', x + TILE / 2, y - TILE / 2 + 1, TILE * 0.75, color);470 }471 }472473 // transient event effects (battles, …) — over entities, under the minimap474 opts.effects?.draw(ctx, atlas, performance.now());475476 drawMinimap(); // keep the corner snapshot in sync with the world477 }478479 function attachTooltips(getFrame: () => MapFrame | null): { dispose(): void } {480 const tip = document.createElement('div');481 tip.className = 'map-tooltip';482 tip.style.display = 'none';483 document.body.append(tip);484485 const setLines = (title: string, dim: string | null, accent: string | null): void => {486 tip.replaceChildren();487 const t = document.createElement('div');488 t.className = 'tt-title';489 t.textContent = title;490 tip.append(t);491 if (dim !== null) {492 const d = document.createElement('div');493 d.className = 'tt-dim';494 d.textContent = dim;495 tip.append(d);496 }497 tip.style.borderLeftColor = accent ?? 'var(--border)';498 };499500 const onMove = (e: MouseEvent): void => {501 const frame = getFrame();502 if (!frame || frame.terrain.length !== mapSize * mapSize || panning) {503 tip.style.display = 'none';504 return;505 }506 // getBoundingClientRect reflects the camera's CSS transform, so this507 // screen→tile mapping stays correct at any zoom/pan.508 const rect = canvas.getBoundingClientRect();509 const tx = Math.floor(((e.clientX - rect.left) / rect.width) * mapSize);510 const ty = Math.floor(((e.clientY - rect.top) / rect.height) * mapSize);511 if (tx < 0 || ty < 0 || tx >= mapSize || ty >= mapSize) {512 tip.style.display = 'none';513 return;514 }515516 const s = frame.settlements.find((v) => v.x === tx && v.y === ty);517 const w = frame.warbands.find((v) => v.x === tx && v.y === ty);518 const th = frame.threats.find((v) => v.x === tx && v.y === ty);519 const c = frame.caravans.find((v) => v.x === tx && v.y === ty);520 if (s) {521 const walls = s.wallLevel ? ` - walls L${s.wallLevel}` : '';522 setLines(523 `${TIER_LABEL[s.tier] ?? 'settlement'} — ${tribeName(s.tribe)}`,524 `pop ${fmtCount(s.pop)}${walls}`,525 tribeColor(s.tribe),526 );527 } else if (w) {528 setLines(`${w.kind} — ${tribeName(w.tribe)}`, `size ${fmtCount(w.size)}`, tribeColor(w.tribe));529 } else if (th) {530 setLines(THREAT_LABEL[th.kind] ?? th.kind, `strength ${fmtCount(th.strength)}`, '#c0392b');531 } else if (c) {532 setLines(`caravan — ${tribeName(c.tribe)}`, `${fmtCount(c.amount)} ${c.resource}`, tribeColor(c.tribe));533 } else {534 const ch = frame.terrain[ty * mapSize + tx] ?? '?';535 setLines((TERRAIN[ch] ?? FOG).label, null, null);536 }537538 tip.style.display = 'block';539 const pad = 14;540 let left = e.clientX + pad;541 let top = e.clientY + pad;542 const tw = tip.offsetWidth;543 const thh = tip.offsetHeight;544 if (left + tw > window.innerWidth - 4) left = e.clientX - tw - pad;545 if (top + thh > window.innerHeight - 4) top = e.clientY - thh - pad;546 tip.style.left = `${left}px`;547 tip.style.top = `${top}px`;548 };549550 const onLeave = (): void => {551 tip.style.display = 'none';552 };553554 canvas.addEventListener('mousemove', onMove);555 canvas.addEventListener('mouseleave', onLeave);556 return {557 dispose() {558 canvas.removeEventListener('mousemove', onMove);559 canvas.removeEventListener('mouseleave', onLeave);560 tip.remove();561 },562 };563 }564565 return {566 draw,567 attachTooltips,568 resetView,569 dispose() {570 disposed = true;571 ro.disconnect();572 container.removeEventListener('wheel', onWheel);573 container.removeEventListener('pointerdown', onPointerDown);574 container.removeEventListener('pointermove', onPointerMove);575 container.removeEventListener('pointerup', onPointerEnd);576 container.removeEventListener('pointercancel', onPointerEnd);577 container.classList.remove('panning');578 minimap.remove();579 canvas.remove();580 },581 };582}