Back to Black Site
SOURCE / PINNED RELEASE

Made of little things.

Black Site

Release
666bc7c74ebd…
Author-recorded commit
05e95af79676…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391

Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/art.js
'use strict';
// BLACK SITE - procedural art: map pre-render, soldiers, weapon and item icons.

function mkCanvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
function srand(seed) {
  let s = (seed >>> 0) || 1;
  return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
}
function hexRgb(h) { const n = parseInt(h.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function grayCol(h) {
  const [r, g, b] = hexRgb(h);
  const l = Math.round((r * 0.3 + g * 0.59 + b * 0.11) * 0.5 + 6);
  return `rgb(${l},${l},${Math.min(255, Math.round(l * 1.07))})`;
}
function rgb(r, g, b) { return `rgb(${r | 0},${g | 0},${b | 0})`; }
function ell(c, x, y, rx, ry, fill) { c.fillStyle = fill; c.beginPath(); c.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); c.fill(); }
function seg(c, x0, y0, x1, y1) { c.beginPath(); c.moveTo(x0, y0); c.lineTo(x1, y1); c.stroke(); }
function rrect(c, x, y, w, h, r) {
  c.beginPath();
  c.moveTo(x + r, y);
  c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
  c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r);
  c.closePath();
}

// ---------------------------------------------------------------- soldiers

const PAL_PLAYER = { body: '#4d5b3a', vest: '#2e3527', helmet: '#5b6a45', sleeve: '#46533a', skin: '#c79b74', boot: '#1f211c', gun: '#18191b', accent: '#a5d63f', pack: '#3c4630', camo: '#3d4a2e' };
const PAL_ENEMY  = { body: '#6b5c45', vest: '#2a2420', helmet: '#2f2b27', sleeve: '#5f523d', skin: '#b48660', boot: '#1d1a17', gun: '#151516', accent: '#c3352a', pack: '#4a3f30', camo: '#584a36' };
function grayPal(p) { const o = {}; for (const k in p) o[k] = grayCol(p[k]); return o; }

const GUN_LEN = { m4a1: 30, ak47: 32, mp5: 23, spas12: 30, barrett: 42, m249: 34, m1911: 11, glock18: 11, deagle: 13, magnum: 13 };

// Local muzzle point (facing +x) for a weapon held by a soldier.
function muzzleLocal(id) {
  const len = GUN_LEN[id] || 28;
  return WEAPONS[id].slot === 'secondary' ? [12 + len, 0] : [4 + len, 2.75];
}

// o: { x, y, a, pal, weapon, hold: 'gun'|'grenade'|'med'|null, walk, recoil, hurt, player }
function drawSoldier(c, o) {
  const pal = o.pal;
  c.save();
  c.translate(o.x, o.y);
  ell(c, 4, 5, 17, 14, 'rgba(0,0,0,0.3)');
  c.rotate(o.a);
  const sw = Math.sin(o.walk || 0) * 5;
  // boots
  for (const [bx, by] of [[sw, -6], [-sw, 6]]) {
    ell(c, bx, by, 6.5, 4.2, pal.boot);
    ell(c, bx + 2, by - 1, 2.5, 1.5, 'rgba(255,255,255,0.08)');
  }
  // backpack
  c.fillStyle = pal.pack; rrect(c, -14, -8, 9, 16, 3); c.fill();
  c.fillStyle = 'rgba(0,0,0,0.25)'; c.fillRect(-13, -1, 7, 2);
  c.fillStyle = 'rgba(255,255,255,0.08)'; c.fillRect(-13, -7, 2, 14);

  const w = o.weapon ? WEAPONS[o.weapon] : null;
  const pistol = w && w.slot === 'secondary';
  const rk = -(o.recoil || 0) * 3;
  const len = o.weapon ? GUN_LEN[o.weapon] : 0;
  if (o.hold === 'gun' && w) {
    c.fillStyle = pal.gun;
    if (pistol) {
      c.fillRect(12 + rk, -2.5, len, 5);
      c.fillStyle = 'rgba(255,255,255,0.15)'; c.fillRect(12 + rk, -2.5, len, 1.2);
    } else {
      c.fillRect(4 + rk, 0, len, 5.5);
      c.fillRect(13 + rk, 5, 5, 5);                      // magazine
      c.fillStyle = '#2a2c2f'; c.fillRect(4 + rk + len - 3, 1, 4, 3.5); // muzzle
      c.fillStyle = 'rgba(255,255,255,0.14)'; c.fillRect(4 + rk, 0.5, len * 0.6, 1.5);
      c.fillStyle = '#0d0e0f'; c.fillRect(9 + rk, -1.5, 6, 2);      // optic
      if (o.weapon === 'barrett') { c.fillStyle = '#0d0e0f'; c.fillRect(8 + rk, -2.5, 14, 3); }
    }
  }
  c.strokeStyle = pal.sleeve; c.lineWidth = 5.5; c.lineCap = 'round';
  let hands;
  if (o.hold === 'gun' && w) {
    hands = pistol ? [[13 + rk, -1], [13 + rk, 1.5]] : [[9 + rk, 4], [21 + rk, 3]];
    seg(c, 0, 10, hands[0][0], hands[0][1]);
    seg(c, 0, -10, hands[1][0], hands[1][1]);
  } else if (o.hold === 'grenade' || o.hold === 'med') {
    hands = [[14, 7], [8, -8]];
    seg(c, 0, 10, 14, 7); seg(c, 0, -10, 8, -8);
    if (o.hold === 'grenade') { ell(c, 17, 7, 4, 4, '#3f4a2e'); ell(c, 16, 6, 1.5, 1.5, 'rgba(255,255,255,0.2)'); }
    else { c.fillStyle = '#e8e4da'; c.fillRect(13, 3, 9, 8); c.fillStyle = '#c3352a'; c.fillRect(16.5, 4, 2, 6); c.fillRect(14.5, 6, 6, 2); }
  } else {
    hands = [[4, 13], [4, -13]];
    seg(c, 0, 10, 4, 13); seg(c, 0, -10, 4, -13);
  }
  for (const [hx, hy] of hands) { ell(c, hx, hy, 3, 3, '#24261f'); ell(c, hx + 0.6, hy - 0.4, 1.4, 1.4, pal.skin); }

  // torso, shoulders and plate carrier
  ell(c, 0, 0, 9.5, 14.5, pal.body);
  ell(c, 1, -11, 4.5, 3.5, pal.sleeve); ell(c, 1, 11, 4.5, 3.5, pal.sleeve);
  c.fillStyle = pal.vest; rrect(c, -6, -10, 13, 20, 3); c.fill();
  c.fillStyle = 'rgba(0,0,0,0.25)';
  for (const py of [-8, -3, 2]) c.fillRect(3, py, 4, 4);       // magazine pouches
  c.fillStyle = 'rgba(255,255,255,0.07)'; c.fillRect(-5, -9, 2, 18);
  c.strokeStyle = 'rgba(0,0,0,0.35)'; c.lineWidth = 1; c.strokeRect(-5.5, -9.5, 12, 19);
  c.fillStyle = pal.accent; c.fillRect(-3, -14.5, 5, 3);
  // helmet with cover pattern
  ell(c, 1, 0, 8, 8, pal.helmet);
  c.fillStyle = pal.camo;
  for (const [hx, hy, r] of [[-2, -3, 2.2], [3, 3, 2], [4, -4, 1.6], [-3, 3, 1.5]]) { c.beginPath(); c.arc(hx, hy, r, 0, Math.PI * 2); c.fill(); }
  c.strokeStyle = 'rgba(0,0,0,0.35)'; c.lineWidth = 1; c.beginPath(); c.arc(1, 0, 7.6, 0, Math.PI * 2); c.stroke();
  ell(c, 2.5, -2, 3, 3.5, 'rgba(255,255,255,0.12)');
  if (o.player) { c.fillStyle = '#1a1c1a'; c.fillRect(6, -2, 3.5, 4); c.fillStyle = '#a5d63f'; c.fillRect(8.5, -1, 1, 2); } // NVG mount
  if (o.hurt > 0) ell(c, 0, 0, 11, 15, `rgba(255,70,60,${Math.min(0.6, o.hurt * 4)})`);
  c.restore();
}


// A body lying on the ground. o: { x, y, a, pal, seed, k: 0..1 fall progress, crawl: phase (crawling) }
function drawSprawl(c, o) {
  const pal = o.pal, k = o.k == null ? 1 : o.k, R = srand(o.seed || 1);
  const crawl = o.crawl;
  const legA = [(0.12 + R() * 0.45), (0.12 + R() * 0.45)];
  const armA = [(0.5 + R() * 1.3), (0.5 + R() * 1.4)];
  const headOff = (R() - 0.5) * 6, gunSide = R() < 0.5 ? 1 : -1, gunA = R() * 6.3, gunD = 18 + R() * 14;
  c.save(); c.translate(o.x, o.y); c.rotate(o.a);
  ell(c, 0, 3, 18 + 12 * k, 13, 'rgba(0,0,0,0.25)');
  c.lineCap = 'round'; c.lineJoin = 'round';
  const limb = (pts, w, col) => { c.strokeStyle = col; c.lineWidth = w; c.beginPath(); c.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) c.lineTo(pts[i][0], pts[i][1]); c.stroke(); };
  const at = (p0, a, l) => [p0[0] + Math.cos(a) * l, p0[1] + Math.sin(a) * l];
  // legs trail behind the body (-x)
  for (const [i, side] of [[0, 1], [1, -1]]) {
    if (crawl != null) {
      // army crawl: one knee drawn up and out while the other leg pushes straight back
      const pull = 0.5 + 0.5 * Math.sin(crawl + (side > 0 ? Math.PI / 2 : -Math.PI / 2));
      const hip = [-6, side * 4];
      const knee = at(hip, Math.PI - side * (0.35 + 0.75 * pull), 11);
      const foot = at(knee, Math.PI + side * (0.1 + 0.5 * pull), 11);
      limb([hip, knee, foot], 7, pal.body);
      ell(c, foot[0] - 2, foot[1], 4.5, 3.2, pal.boot);
    } else {
      const th = Math.PI - side * legA[i] * k;
      const hip = [-6 * k - 2, side * 4], foot = at(hip, th, 10 + 16 * k);
      limb([hip, foot], 7, pal.body);
      ell(c, foot[0] + Math.cos(th) * 3, foot[1] + Math.sin(th) * 3, 4.5, 3.5, pal.boot);
    }
  }
  // arms: flung out when dead; when crawling, elbows bend out and forearms claw forward
  for (const [i, side] of [[0, 1], [1, -1]]) {
    const sh = [8, side * 9];
    let hand;
    if (crawl != null) {
      const reach = 0.5 + 0.5 * Math.sin(crawl + (side > 0 ? 0 : Math.PI));
      const elbow = at(sh, side * (1.25 - 0.75 * reach), 10);
      hand = at(elbow, side * (-0.15 + 0.55 * (1 - reach)), 11 + 3 * reach);
      limb([sh, elbow, hand], 5.5, pal.sleeve);
    } else {
      hand = at(sh, side * armA[i] * k, 16);
      limb([sh, hand], 5.5, pal.sleeve);
    }
    ell(c, hand[0], hand[1], 3, 3, '#24261f'); ell(c, hand[0] + 0.5, hand[1], 1.5, 1.5, pal.skin);
  }
  // torso with plate carrier and backpack
  c.fillStyle = pal.body; rrect(c, -10, -10, 23, 20, 7); c.fill();
  c.fillStyle = pal.vest; rrect(c, -5, -8, 15, 16, 3); c.fill();
  c.fillStyle = 'rgba(0,0,0,0.25)'; for (const px of [-2, 3]) c.fillRect(px, -6, 4, 4);
  c.fillStyle = pal.pack; rrect(c, -9, -6, 6, 12, 2); c.fill();
  c.fillStyle = 'rgba(110,10,8,0.6)'; for (let i = 0; i < 3; i++) { c.beginPath(); c.arc(-4 + R() * 14, (R() - 0.5) * 14, 1.5 + R() * 2.5, 0, Math.PI * 2); c.fill(); }
  // head
  ell(c, 18, headOff, 7.5, 7.5, pal.helmet);
  c.fillStyle = pal.camo; c.beginPath(); c.arc(16, headOff - 2, 2, 0, Math.PI * 2); c.fill();
  ell(c, 19.5, headOff - 2, 2.5, 3, 'rgba(255,255,255,0.1)');
  // dropped weapon
  if (o.weapon && crawl == null && k > 0.6) {
    c.save(); c.translate(Math.cos(gunA) * gunD * 0.5 + 4, gunSide * gunD); c.rotate(gunA);
    c.fillStyle = pal.gun; c.fillRect(-13, -2.5, 26, 5); c.fillRect(-3, 2, 4, 4); c.restore();
  }
  c.restore();
}

// ---------------------------------------------------------------- map

const CAN_FILTER = (() => { try { return 'filter' in mkCanvas(1, 1).getContext('2d'); } catch (e) { return false; } })();

// Paint a mask at k cells per tile, blur it, and stretch it over the whole map (soft shadows, AO).
function softLayer(target, k, blur, alpha, paint) {
  const m = mkCanvas(MAP_W * k, MAP_H * k), mx = m.getContext('2d');
  paint(mx, k);
  let src = m;
  if (CAN_FILTER) {
    const b = mkCanvas(m.width, m.height), bx = b.getContext('2d');
    bx.filter = `blur(${blur}px)`;
    bx.drawImage(m, 0, 0);
    src = b;
  }
  target.save();
  target.globalAlpha = alpha;
  target.imageSmoothingEnabled = true;
  target.drawImage(src, 0, 0, MAP_W * TILE, MAP_H * TILE);
  target.restore();
}

// Random value noise stretched over the map: cheap large-scale colour variation.
function noiseLayer(target, R, k, amp, alpha, base) {
  const n = mkCanvas(MAP_W * k, MAP_H * k), nx = n.getContext('2d');
  const img = nx.createImageData(n.width, n.height), d = img.data;
  for (let i = 0; i < d.length; i += 4) {
    const v = (R() - 0.5) * amp;
    d[i] = base[0] + v; d[i + 1] = base[1] + v; d[i + 2] = base[2] + v * 0.85; d[i + 3] = 255;
  }
  nx.putImageData(img, 0, 0);
  target.save(); target.globalAlpha = alpha; target.imageSmoothingEnabled = true;
  target.drawImage(n, 0, 0, MAP_W * TILE, MAP_H * TILE);
  target.restore();
}

const WRECK_COLS = ['#5a3c2c', '#4a2e28', '#3a4450', '#6a5a3a', '#4c4c4a'];
const BARREL_COLS = [['#7a2f24', '#a4442f'], ['#2f4f6e', '#3f6a90'], ['#4c5a36', '#647648'], ['#a8862a', '#d0a83a']];

function renderMapArt(L) {
  const Wp = MAP_W * TILE, Hp = MAP_H * TILE, T = TILE;
  const c = mkCanvas(Wp, Hp), x = c.getContext('2d');
  const R = srand(hashStr(L.id) || 1);
  const th = L.theme, G0 = th.ground, RD = th.road, style = th.style;
  const at = (tx, ty) => (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H ? '#' : L.grid[ty][tx]);
  const fl = (tx, ty) => (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H ? '#' : L.floor[ty][tx]);
  const isWall = (tx, ty) => at(tx, ty) === '#';
  const tall = ch => ch === '#' || ch === 'K' || ch === 'o';
  const tiles = pred => { const out = []; for (let ty = 0; ty < MAP_H; ty++) for (let tx = 0; tx < MAP_W; tx++) if (pred(tx, ty)) out.push([tx, ty]); return out; };
  const randomTile = list => list[Math.floor(R() * list.length)];
  const ground = tiles((tx, ty) => fl(tx, ty) === '.');
  const roadT = tiles((tx, ty) => fl(tx, ty) === '=');

  // ---- ground: base, two noise octaves, blotches, grain
  x.fillStyle = rgb(...G0); x.fillRect(0, 0, Wp, Hp);
  noiseLayer(x, R, 1, 26, 1, G0);
  noiseLayer(x, R, 5, 12, 0.55, G0);
  // soft-edged dirt and wear patches
  for (let i = 0; i < 1400; i++) {
    const bx = R() * Wp, by = R() * Hp, r = 25 + R() * 120;
    const col = R() < 0.6 ? '20,18,12' : '255,250,230', a = R() < 0.6 ? 0.05 + R() * 0.09 : 0.03 + R() * 0.05;
    const g2 = x.createRadialGradient(bx, by, 0, bx, by, r);
    g2.addColorStop(0, `rgba(${col},${a})`); g2.addColorStop(1, `rgba(${col},0)`);
    x.fillStyle = g2; x.fillRect(bx - r, by - r, r * 2, r * 2);
  }
  for (let i = 0; i < 160000; i++) {
    const v = 0.5 + R() * 1.0;
    x.fillStyle = `rgba(${(G0[0] * v) | 0},${(G0[1] * v) | 0},${(G0[2] * v) | 0},${0.18 + R() * 0.3})`;
    x.fillRect(R() * Wp, R() * Hp, 1 + R() * 1.6, 1 + R() * 1.6);
  }
  // gravel
  for (let i = 0; i < (style === 'snow' ? 600 : 3500); i++) {
    const [tx, ty] = randomTile(ground); const gx = (tx + R()) * T, gy = (ty + R()) * T, r = 1 + R() * 2.4;
    const v = 0.6 + R() * 0.6;
    ell(x, gx + 0.8, gy + 0.8, r, r * 0.8, 'rgba(0,0,0,0.25)');
    ell(x, gx, gy, r, r * 0.8, rgb(G0[0] * v, G0[1] * v, G0[2] * v));
    ell(x, gx - r * 0.3, gy - r * 0.3, r * 0.4, r * 0.3, 'rgba(255,255,255,0.18)');
  }

  // ---- theme details on open ground
  if (style === 'grass') {
    for (let i = 0; i < 260; i++) { const [tx, ty] = randomTile(ground); x.fillStyle = `rgba(70,92,42,${0.08 + R() * 0.12})`; x.beginPath(); x.ellipse((tx + R()) * T, (ty + R()) * T, 25 + R() * 60, 18 + R() * 40, R() * 3, 0, Math.PI * 2); x.fill(); }
    x.lineWidth = 1.4;
    for (let i = 0; i < 4500; i++) {
      const [tx, ty] = randomTile(ground); const gx = (tx + R()) * T, gy = (ty + R()) * T;
      x.strokeStyle = `rgba(${60 + R() * 40},${85 + R() * 45},${35 + R() * 20},0.7)`;
      for (let k = 0; k < 5; k++) seg(x, gx + k * 2 - 4, gy, gx + k * 2 - 4 + (R() - 0.5) * 7, gy - 4 - R() * 7);
    }
    // bushes and puddles
    for (let i = 0; i < 70; i++) {
      const [tx, ty] = randomTile(ground); const bx = (tx + 0.5) * T, by = (ty + 0.5) * T;
      ell(x, bx + 4, by + 5, 15, 11, 'rgba(0,0,0,0.25)');
      for (let k = 0; k < 6; k++) ell(x, bx + (R() - 0.5) * 16, by + (R() - 0.5) * 12, 6 + R() * 6, 5 + R() * 5, rgb(48 + R() * 20, 70 + R() * 25, 32 + R() * 12));
      for (let k = 0; k < 5; k++) ell(x, bx + (R() - 0.5) * 14 - 2, by + (R() - 0.5) * 10 - 2, 2 + R() * 2, 2, 'rgba(160,190,110,0.35)');
    }
    for (let i = 0; i < 25; i++) {
      const [tx, ty] = randomTile(ground); const px = (tx + 0.5) * T, py = (ty + 0.5) * T;
      ell(x, px, py, 20 + R() * 20, 9 + R() * 8, 'rgba(40,52,58,0.55)');
      ell(x, px - 5, py - 3, 8, 3, 'rgba(180,200,210,0.18)');
    }
  } else if (style === 'sand') {
    x.lineWidth = 1.5;
    for (let i = 0; i < 6000; i++) {
      const [tx, ty] = randomTile(ground); const sx = (tx + R()) * T, sy = (ty + R()) * T, w = 10 + R() * 18;
      x.strokeStyle = R() < 0.5 ? 'rgba(255,240,205,0.16)' : 'rgba(90,66,36,0.14)';
      x.beginPath(); x.moveTo(sx, sy); x.quadraticCurveTo(sx + w / 2, sy - 3, sx + w, sy); x.stroke();
    }
    for (let i = 0; i < 260; i++) {
      const [tx, ty] = randomTile(ground); const rx = (tx + R()) * T, ry = (ty + R()) * T, r = 3 + R() * 6;
      ell(x, rx + 2, ry + 2, r, r * 0.7, 'rgba(60,40,20,0.3)'); ell(x, rx, ry, r, r * 0.7, rgb(120 + R() * 30, 104 + R() * 25, 80 + R() * 20)); ell(x, rx - r * 0.3, ry - r * 0.3, r * 0.4, r * 0.25, 'rgba(255,255,255,0.2)');
    }
    for (let i = 0; i < 60; i++) { // dry scrub
      const [tx, ty] = randomTile(ground); const bx = (tx + 0.5) * T, by = (ty + 0.5) * T;
      x.strokeStyle = 'rgba(95,80,45,0.8)'; x.lineWidth = 1.2;
      for (let k = 0; k < 9; k++) { const a = R() * 6.3, r = 6 + R() * 9; seg(x, bx, by, bx + Math.cos(a) * r, by + Math.sin(a) * r); }
    }
  } else if (style === 'snow') {
    // soft drifts and blue hollows
    for (let i = 0; i < 420; i++) {
      const [tx, ty] = randomTile(ground); const sx = (tx + R()) * T, sy = (ty + R()) * T, r = 25 + R() * 60;
      const g2 = x.createRadialGradient(sx, sy, 0, sx, sy, r);
      const col = R() < 0.6 ? '255,255,255' : '130,150,185';
      g2.addColorStop(0, `rgba(${col},${0.12 + R() * 0.12})`); g2.addColorStop(1, `rgba(${col},0)`);
      x.fillStyle = g2; x.fillRect(sx - r, sy - r, r * 2, r * 2);
    }
    for (let i = 0; i < 5000; i++) { x.fillStyle = `rgba(255,255,255,${0.4 + R() * 0.6})`; x.fillRect(R() * Wp, R() * Hp, 1, 1); }
    // footprint trails
    for (let i = 0; i < 18; i++) {
      let [tx, ty] = randomTile(ground); let fx = tx * T, fy = ty * T, a = R() * 6.3;
      for (let k = 0; k < 40; k++) {
        a += (R() - 0.5) * 0.3; fx += Math.cos(a) * 14; fy += Math.sin(a) * 14;
        const side = k % 2 ? 1 : -1;
        ell(x, fx - Math.sin(a) * 5 * side, fy + Math.cos(a) * 5 * side, 3.5, 2.2, 'rgba(120,135,160,0.35)');
      }
    }
  } else if (style === 'concrete') {
    x.fillStyle = 'rgba(0,0,0,0.13)';
    for (const [tx, ty] of ground) {
      if (tx % 3 === 0) x.fillRect(tx * T, ty * T, 1.5, T);
      if (ty % 3 === 0) x.fillRect(tx * T, ty * T, T, 1.5);
    }
    for (let i = 0; i < 220; i++) { // oil and rust stains
      const [tx, ty] = randomTile(ground); const sx = (tx + R()) * T, sy = (ty + R()) * T;
      x.fillStyle = R() < 0.6 ? `rgba(15,12,10,${0.15 + R() * 0.2})` : `rgba(110,60,30,${0.12 + R() * 0.15})`;
      for (let k = 0; k < 4; k++) { x.beginPath(); x.ellipse(sx + (R() - 0.5) * 20, sy + (R() - 0.5) * 20, 5 + R() * 18, 4 + R() * 12, R() * 3, 0, Math.PI * 2); x.fill(); }
    }
  }
  // cracks
  x.strokeStyle = style === 'snow' ? 'rgba(100,115,140,0.3)' : 'rgba(12,10,8,0.45)'; x.lineWidth = 1;
  for (let i = 0; i < 380; i++) {
    let cx = R() * Wp, cy = R() * Hp;
    x.beginPath(); x.moveTo(cx, cy);
    for (let k = 0; k < 6; k++) { cx += (R() - 0.5) * 34; cy += (R() - 0.5) * 34; x.lineTo(cx, cy); if (R() < 0.3) { x.moveTo(cx, cy); x.lineTo(cx + (R() - 0.5) * 20, cy + (R() - 0.5) * 20); x.moveTo(cx, cy); } }
    x.stroke();
  }

  // ---- roads / pavement
  const dirt = th.markings === false;
  for (const [tx, ty] of roadT) {
    const n = (R() - 0.5) * 6;
    x.fillStyle = rgb(RD[0] + n, RD[1] + n, RD[2] + n); x.fillRect(tx * T, ty * T, T, T);
    for (let k = 0; k < 40; k++) { const v = R(); x.fillStyle = v < 0.5 ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.08)'; x.fillRect(tx * T + R() * T, ty * T + R() * T, 1.3, 1.3); }
  }
  if (!dirt) {
    for (let i = 0; i < 90; i++) { // repair patches
      const [tx, ty] = randomTile(roadT); const w = 20 + R() * 50, h = 14 + R() * 30, rx = (tx + R()) * T - w / 2, ry = (ty + R()) * T - h / 2;
      x.fillStyle = 'rgba(15,15,17,0.18)'; x.fillRect(rx, ry, w, h);
      x.strokeStyle = 'rgba(0,0,0,0.25)'; x.lineWidth = 1; x.strokeRect(rx, ry, w, h);
    }
    for (let i = 0; i < 26; i++) { // manholes
      const [tx, ty] = randomTile(roadT); const mx = (tx + 0.5) * T, my = (ty + 0.5) * T;
      ell(x, mx, my, 12, 12, '#2a2b2d'); x.strokeStyle = '#4a4b4e'; x.lineWidth = 2; x.beginPath(); x.arc(mx, my, 11, 0, Math.PI * 2); x.stroke();
      x.strokeStyle = 'rgba(255,255,255,0.12)'; x.lineWidth = 1;
      for (let k = -8; k <= 8; k += 4) { seg(x, mx - 8, my + k, mx + 8, my + k); }
    }
    // curbs where the pavement meets open ground
    for (const [tx, ty] of roadT) {
      const px = tx * T, py = ty * T;
      for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) {
        const nb = fl(tx + dx, ty + dy);
        if (nb === '=' || nb === 'w' || nb === '#') continue;
        x.fillStyle = '#8e8b82';
        if (dy === -1) x.fillRect(px, py, T, 5); if (dy === 1) x.fillRect(px, py + T - 5, T, 5);
        if (dx === -1) x.fillRect(px, py, 5, T); if (dx === 1) x.fillRect(px + T - 5, py, 5, T);
        x.fillStyle = 'rgba(0,0,0,0.35)';
        if (dy === -1) x.fillRect(px, py + 5, T, 1.5); if (dy === 1) x.fillRect(px, py + T - 6.5, T, 1.5);
        if (dx === -1) x.fillRect(px + 5, py, 1.5, T); if (dx === 1) x.fillRect(px + T - 6.5, py, 1.5, T);
      }
    }
  }
  for (const [x0, y0, x1, y1] of L.roads) {
    const horiz = (x1 - x0) > (y1 - y0);
    if (dirt) { // tyre ruts
      x.strokeStyle = 'rgba(70,52,30,0.35)'; x.lineWidth = 6;
      for (const o of [-12, 12]) {
        x.beginPath();
        if (horiz) { const cy = ((y0 + y1 + 1) / 2) * T + o; x.moveTo(x0 * T, cy); for (let px = x0 * T; px < (x1 + 1) * T; px += 60) x.lineTo(px, cy + (R() - 0.5) * 6); }
        else { const cx = ((x0 + x1 + 1) / 2) * T + o; x.moveTo(cx, y0 * T); for (let py = y0 * T; py < (y1 + 1) * T; py += 60) x.lineTo(cx + (R() - 0.5) * 6, py); }
        x.stroke();
      }
      continue;
    }
    x.fillStyle = 'rgba(225,215,190,0.55)';
    if (horiz) {
      x.fillRect(x0 * T, y0 * T + 9, (x1 - x0 + 1) * T, 2); x.fillRect(x0 * T, (y1 + 1) * T - 11, (x1 - x0 + 1) * T, 2);
      x.fillStyle = 'rgba(222,184,60,0.8)'; const cy = ((y0 + y1 + 1) / 2) * T - 1.5;
      for (let px = x0 * T + 10; px < (x1 + 1) * T - 20; px += 56) x.fillRect(px, cy, 28, 3);
    } else {
      x.fillRect(x0 * T + 9, y0 * T, 2, (y1 - y0 + 1) * T); x.fillRect((x1 + 1) * T - 11, y0 * T, 2, (y1 - y0 + 1) * T);
      x.fillStyle = 'rgba(222,184,60,0.8)'; const cx = ((x0 + x1 + 1) / 2) * T - 1.5;
      for (let py = y0 * T + 10; py < (y1 + 1) * T - 20; py += 56) x.fillRect(cx, py, 3, 28);
    }
  }
  for (let i = 0; i < L.roads.length; i++) for (let j = i + 1; j < L.roads.length; j++) { // clean intersections
    const a = L.roads[i], b = L.roads[j];
    const ix0 = Math.max(a[0], b[0]), iy0 = Math.max(a[1], b[1]), ix1 = Math.min(a[2], b[2]), iy1 = Math.min(a[3], b[3]);
    if (ix0 > ix1 || iy0 > iy1) continue;
    x.fillStyle = rgb(RD[0] + 1, RD[1] + 1, RD[2] + 1); x.fillRect(ix0 * T, iy0 * T, (ix1 - ix0 + 1) * T, (iy1 - iy0 + 1) * T);
    if (!dirt) { // zebra crossing on each side
      x.fillStyle = 'rgba(230,225,210,0.5)';
      for (let k = 0; k < (ix1 - ix0 + 1) * T; k += 12) { x.fillRect(ix0 * T + k + 2, iy0 * T - 26, 7, 20); x.fillRect(ix0 * T + k + 2, (iy1 + 1) * T + 6, 7, 20); }
      for (let k = 0; k < (iy1 - iy0 + 1) * T; k += 12) { x.fillRect(ix0 * T - 26, iy0 * T + k + 2, 20, 7); x.fillRect((ix1 + 1) * T + 6, iy0 * T + k + 2, 20, 7); }
    }
  }
  // wooden piers
  for (const [x0, y0, x1, y1] of L.paved || []) {
    const px = x0 * T, py = y0 * T, w = (x1 - x0 + 1) * T, h = (y1 - y0 + 1) * T;
    for (let yy = py; yy < py + h; yy += 12) {
      const n = (R() - 0.5) * 16;
      x.fillStyle = rgb(104 + n, 82 + n, 58 + n); x.fillRect(px, yy, w, 12);
      x.fillStyle = 'rgba(0,0,0,0.35)'; x.fillRect(px, yy + 11, w, 1);
      for (let xx = px + R() * 90; xx < px + w; xx += 70 + R() * 70) { x.fillRect(xx, yy, 1, 12); x.fillStyle = 'rgba(30,25,20,0.6)'; x.fillRect(xx - 3, yy + 3, 1.5, 1.5); x.fillRect(xx + 3, yy + 7, 1.5, 1.5); x.fillStyle = 'rgba(0,0,0,0.35)'; }
      x.fillStyle = 'rgba(255,240,210,0.06)'; x.fillRect(px, yy + 1, w, 2);
    }
  }

  // ---- water: deeper away from the shore, waves, foam, pier posts
  const shoreDist = (tx, ty) => { for (let r = 1; r <= 4; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; const f = fl(tx + dx, ty + dy); if (f !== 'w' && f !== '#') return r; } return 5; };
  for (const [tx, ty] of tiles((a, b) => fl(a, b) === 'w')) {
    const px = tx * T, py = ty * T, k = (shoreDist(tx, ty) - 1) / 4;
    x.fillStyle = rgb(46 - k * 26 + (R() - 0.5) * 4, 88 - k * 44, 98 - k * 38); x.fillRect(px, py, T, T);
    x.strokeStyle = 'rgba(170,210,225,0.13)'; x.lineWidth = 1.4;
    for (let w2 = 0; w2 < 3; w2++) { const wy = py + 6 + R() * 28, wx = px + R() * 16; x.beginPath(); x.moveTo(wx, wy); x.quadraticCurveTo(wx + 6, wy - 3, wx + 12, wy); x.quadraticCurveTo(wx + 18, wy + 3, wx + 24, wy); x.stroke(); }
    for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) {
      const nb = fl(tx + dx, ty + dy);
      if (nb === 'w' || nb === '#') continue;
      for (let k2 = 0; k2 < 8; k2++) {
        const fx = dx ? (dx < 0 ? px + R() * 6 : px + T - R() * 6) : px + R() * T;
        const fy = dy ? (dy < 0 ? py + R() * 6 : py + T - R() * 6) : py + R() * T;
        ell(x, fx, fy, 2 + R() * 3, 1.5 + R() * 2, `rgba(225,240,245,${0.25 + R() * 0.35})`);
      }
      if (nb === '=') { // pier posts
        const cx = px + T / 2 + dx * -12, cy = py + T / 2 + dy * -12;
        ell(x, cx + 2, cy + 2, 6, 6, 'rgba(0,0,0,0.35)'); ell(x, cx, cy, 6, 6, '#4a3626'); ell(x, cx - 1.5, cy - 1.5, 2.5, 2.5, '#6a5038');
      }
    }
  }

  // ---- building floors and furnishing
  L.buildings.forEach((b, i) => {
    const [x0, y0, x1, y1] = b.r;
    const px = x0 * T, py = y0 * T, w = (x1 - x0 + 1) * T, h = (y1 - y0 + 1) * T;
    const st = i % 4;
    x.save(); x.beginPath(); x.rect(px, py, w, h); x.clip();
    if (st === 0) { // wood planks with grain
      for (let yy = py; yy < py + h; yy += 10) {
        const n = (R() - 0.5) * 16;
        x.fillStyle = rgb(96 + n, 72 + n, 48 + n); x.fillRect(px, yy, w, 10);
        x.strokeStyle = 'rgba(40,25,10,0.18)'; x.lineWidth = 1;
        for (let g2 = 0; g2 < w / 30; g2++) { const gx = px + R() * w; seg(x, gx, yy + 2 + R() * 6, gx + 10 + R() * 20, yy + 2 + R() * 6); }
        x.fillStyle = 'rgba(0,0,0,0.32)'; x.fillRect(px, yy + 9, w, 1);
        for (let xx = px + R() * 80; xx < px + w; xx += 60 + R() * 70) x.fillRect(xx, yy, 1, 10);
      }
    } else if (st === 1) { // ceramic tiles
      for (let yy = py; yy < py + h; yy += 20) for (let xx = px; xx < px + w; xx += 20) {
        const n = (R() - 0.5) * 10, alt = ((xx - px) / 20 + (yy - py) / 20) % 2 ? 6 : 0;
        x.fillStyle = rgb(88 + n + alt, 92 + n + alt, 94 + n + alt); x.fillRect(xx, yy, 20, 20);
        x.fillStyle = 'rgba(255,255,255,0.05)'; x.fillRect(xx + 1, yy + 1, 18, 2);
        x.fillStyle = 'rgba(0,0,0,0.35)'; x.fillRect(xx, yy, 20, 1); x.fillRect(xx, yy, 1, 20);
      }
    } else if (st === 2) { // poured concrete
      x.fillStyle = rgb(74, 76, 75); x.fillRect(px, py, w, h);
      for (let k = 0; k < 70; k++) { x.fillStyle = `rgba(${R() < 0.5 ? '20,20,20' : '150,150,145'},${0.04 + R() * 0.07})`; x.beginPath(); x.arc(px + R() * w, py + R() * h, 8 + R() * 40, 0, Math.PI * 2); x.fill(); }
      x.fillStyle = 'rgba(0,0,0,0.28)';
      for (let xx = px; xx < px + w; xx += 80) x.fillRect(xx, py, 1, h);
      for (let yy = py; yy < py + h; yy += 80) x.fillRect(px, yy, w, 1);
    } else { // worn carpet
      const base = [[92, 40, 38], [46, 58, 74], [70, 60, 44]][Math.floor(R() * 3)];
      x.fillStyle = rgb(...base); x.fillRect(px, py, w, h);
      for (let k = 0; k < w * h / 60; k++) { x.fillStyle = `rgba(${R() < 0.5 ? '0,0,0' : '255,255,255'},0.06)`; x.fillRect(px + R() * w, py + R() * h, 2, 2); }
      for (let k = 0; k < 12; k++) { x.fillStyle = 'rgba(0,0,0,0.12)'; x.beginPath(); x.arc(px + R() * w, py + R() * h, 10 + R() * 30, 0, Math.PI * 2); x.fill(); }
    }
    // rugs
    for (let k = 0; k < (w * h > 60000 ? 2 : 1); k++) {
      const rw = 70 + R() * 70, rh = 50 + R() * 50, rx = px + T + R() * Math.max(1, w - 2 * T - rw), ry = py + T + R() * Math.max(1, h - 2 * T - rh);
      const col = [[120, 40, 34], [40, 60, 90], [110, 90, 50]][Math.floor(R() * 3)];
      x.fillStyle = rgb(...col); x.fillRect(rx, ry, rw, rh);
      x.strokeStyle = 'rgba(230,210,170,0.45)'; x.lineWidth = 2; x.strokeRect(rx + 5, ry + 5, rw - 10, rh - 10);
      x.strokeStyle = 'rgba(0,0,0,0.3)'; x.lineWidth = 1; x.strokeRect(rx + 10, ry + 10, rw - 20, rh - 20);
      x.fillStyle = 'rgba(230,210,170,0.5)';
      for (let f = rx; f < rx + rw; f += 4) { x.fillRect(f, ry - 3, 1.5, 3); x.fillRect(f, ry + rh, 1.5, 3); }
    }
    // papers, debris, shell casings
    for (let k = 0; k < Math.max(8, w * h / 9000); k++) {
      x.save(); x.translate(px + 30 + R() * (w - 60), py + 30 + R() * (h - 60)); x.rotate(R() * 6.3);
      const r = R();
      if (r < 0.5) { x.fillStyle = 'rgba(225,220,205,0.75)'; x.fillRect(-4, -5, 8 + R() * 4, 10); x.fillStyle = 'rgba(40,40,40,0.4)'; for (let l = -3; l < 4; l += 2) x.fillRect(-3, l, 7, 0.8); }
      else if (r < 0.8) { x.fillStyle = 'rgba(40,36,30,0.65)'; x.beginPath(); x.moveTo(-5, -3); x.lineTo(4, -5); x.lineTo(6, 3); x.lineTo(-3, 5); x.closePath(); x.fill(); }
      else { x.fillStyle = 'rgba(190,215,225,0.5)'; x.beginPath(); x.moveTo(0, -4); x.lineTo(3, 2); x.lineTo(-2, 3); x.closePath(); x.fill(); }
      x.restore();
    }
    x.restore();
  });
  // furniture against interior walls: shelves, counters, cabinets
  for (const [tx, ty] of tiles((a, b) => fl(a, b) === ',' && at(a, b) === ',')) {
    if (R() > 0.13) continue;
    const sides = [[0, -1], [0, 1], [-1, 0], [1, 0]].filter(([dx, dy]) => isWall(tx + dx, ty + dy));
    if (!sides.length) continue;
    const [dx, dy] = sides[Math.floor(R() * sides.length)];
    const px = tx * T, py = ty * T, d = 11;
    const rx = dx === 1 ? px + T - d : px, ry = dy === 1 ? py + T - d : py;
    const rw = dx ? d : T, rh = dy ? d : T;
    const kind = R();
    x.fillStyle = 'rgba(0,0,0,0.3)'; x.fillRect(rx + 2, ry + 2, rw, rh);
    x.fillStyle = kind < 0.4 ? '#5a4632' : kind < 0.7 ? '#565c62' : '#6b6a60'; x.fillRect(rx, ry, rw, rh);
    x.fillStyle = 'rgba(255,255,255,0.1)'; x.fillRect(rx, ry, rw, 1.5); x.fillRect(rx, ry, 1.5, rh);
    if (kind < 0.4) { // bookshelf
      for (let k = 2; k < (dx ? rh : rw) - 3; k += 4) { x.fillStyle = rgb(80 + R() * 120, 40 + R() * 80, 30 + R() * 60); if (dx) x.fillRect(rx + 2, ry + k, rw - 4, 3); else x.fillRect(rx + k, ry + 2, 3, rh - 4); }
    } else if (kind < 0.7) { // filing cabinet
      x.fillStyle = 'rgba(0,0,0,0.35)'; for (let k = 8; k < (dx ? rh : rw); k += 10) { if (dx) x.fillRect(rx, ry + k, rw, 1); else x.fillRect(rx + k, ry, 1, rh); }
    } else { // counter with clutter
      for (let k = 0; k < 3; k++) { x.fillStyle = rgb(120 + R() * 100, 120 + R() * 100, 110 + R() * 90); x.fillRect(rx + 2 + R() * (rw - 7), ry + 2 + R() * (rh - 7), 4, 4); }
    }
  }

  // ---- outdoor clutter decals: trash, tyre marks, debris
  for (let i = 0; i < 160; i++) {
    const [tx, ty] = randomTile(ground); const dx2 = (tx + R()) * T, dy2 = (ty + R()) * T;
    const r = R();
    if (r < 0.3) { ell(x, dx2 + 2, dy2 + 2, 6, 5, 'rgba(0,0,0,0.3)'); ell(x, dx2, dy2, 6, 5, '#1e1f1d'); ell(x, dx2 - 2, dy2 - 2, 2, 1.5, 'rgba(255,255,255,0.15)'); }
    else if (r < 0.6) { x.save(); x.translate(dx2, dy2); x.rotate(R() * 6.3); x.fillStyle = rgb(120 + R() * 100, 100 + R() * 80, 80 + R() * 60); x.fillRect(-4, -3, 8, 6); x.restore(); }
    else { x.fillStyle = 'rgba(80,78,72,0.8)'; for (let k = 0; k < 6; k++) x.fillRect(dx2 + (R() - 0.5) * 18, dy2 + (R() - 0.5) * 18, 2 + R() * 4, 2 + R() * 3); }
  }

  // ---- ambient occlusion around tall walls, then cast shadows
  softLayer(x, 4, 3, 0.55, (m, k) => {
    m.fillStyle = '#000';
    for (let ty = 0; ty < MAP_H; ty++) for (let tx = 0; tx < MAP_W; tx++) if (tall(at(tx, ty))) m.fillRect(tx * k, ty * k, k, k);
  });
  softLayer(x, 5, 1.2, 0.42, (m, k) => {
    m.fillStyle = '#000';
    for (let ty = 0; ty < MAP_H; ty++) for (let tx = 0; tx < MAP_W; tx++) {
      const ch = at(tx, ty);
      if (ch === '#' || ch === 'K') m.fillRect(tx * k + 1, ty * k + 1, k, k);
      else if ('cvhpb'.includes(ch)) m.fillRect(tx * k + 1, ty * k + 1, k - 1, k - 1);
    }
    for (const [tx, ty, s] of L.tanks) { m.beginPath(); m.arc((tx + s / 2) * k + 1.2, (ty + s / 2) * k + 1.2, s / 2 * k, 0, Math.PI * 2); m.fill(); }
  });

  // ---- walls: concrete block tops, exposed edges, windows, weathering
  const inBuilding = L.buildings.map(b => b.r);
  const onPerimeter = (tx, ty) => inBuilding.some(([x0, y0, x1, y1]) => (tx === x0 || tx === x1 || ty === y0 || ty === y1) && tx >= x0 && tx <= x1 && ty >= y0 && ty <= y1);
  for (const [tx, ty] of tiles(isWall)) {
    const px = tx * T, py = ty * T, n = (R() - 0.5) * 10;
    const border = tx === 0 || ty === 0 || tx === MAP_W - 1 || ty === MAP_H - 1;
    const base = border ? [70, 68, 64] : style === 'sand' ? [150, 128, 96] : style === 'snow' ? [116, 120, 126] : [100, 96, 88];
    x.fillStyle = rgb(base[0] + n, base[1] + n, base[2] + n); x.fillRect(px, py, T, T);
    // block joints
    x.fillStyle = 'rgba(0,0,0,0.13)';
    for (let r = 0; r < 4; r++) { x.fillRect(px, py + r * 10, T, 1); const off = r % 2 ? 10 : 0; for (let cx = off; cx < T; cx += 20) x.fillRect(px + cx, py + r * 10, 1, 10); }
    for (let k = 0; k < 6; k++) { x.fillStyle = `rgba(0,0,0,${0.05 + R() * 0.08})`; x.fillRect(px + R() * 34, py + R() * 34, 2 + R() * 6, 2 + R() * 6); }
    if (!isWall(tx, ty - 1)) { x.fillStyle = 'rgba(255,250,235,0.45)'; x.fillRect(px, py, T, 3); }
    if (!isWall(tx - 1, ty)) { x.fillStyle = 'rgba(255,250,235,0.32)'; x.fillRect(px, py, 3, T); }
    if (!isWall(tx, ty + 1)) { x.fillStyle = 'rgba(0,0,0,0.5)'; x.fillRect(px, py + T - 4, T, 4); }
    if (!isWall(tx + 1, ty)) { x.fillStyle = 'rgba(0,0,0,0.4)'; x.fillRect(px + T - 4, py, 4, T); }
    // windows in outer building walls
    if (onPerimeter(tx, ty) && R() < 0.35) {
      const horiz = isWall(tx - 1, ty) && isWall(tx + 1, ty), vert = isWall(tx, ty - 1) && isWall(tx, ty + 1);
      if (horiz !== vert) {
        const [wx, wy, ww, wh] = horiz ? [px + 5, py + 15, 30, 10] : [px + 15, py + 5, 10, 30];
        x.fillStyle = '#2b2f32'; x.fillRect(wx - 1, wy - 1, ww + 2, wh + 2);
        const gl = x.createLinearGradient(wx, wy, wx + ww, wy + wh); gl.addColorStop(0, '#6f8ea0'); gl.addColorStop(1, '#3d5564');
        x.fillStyle = gl; x.fillRect(wx, wy, ww, wh);
        x.fillStyle = 'rgba(255,255,255,0.35)'; if (horiz) x.fillRect(wx + 2, wy + 2, ww * 0.4, 1.5); else x.fillRect(wx + 2, wy + 2, 1.5, wh * 0.4);
        x.fillStyle = '#2b2f32'; if (horiz) x.fillRect(wx + ww / 2, wy, 1.5, wh); else x.fillRect(wx, wy + wh / 2, ww, 1.5);
      }
    }
    if (style === 'snow' && !isWall(tx, ty - 1)) { x.fillStyle = 'rgba(240,245,250,0.85)'; x.fillRect(px, py, T, 6); for (let k = 0; k < 4; k++) ell(x, px + R() * T, py + 6, 4 + R() * 4, 2 + R() * 2, 'rgba(240,245,250,0.85)'); }
    if (style === 'grass' && R() < 0.12) { x.fillStyle = 'rgba(70,95,45,0.35)'; ell(x, px + R() * T, py + T - 4, 6 + R() * 6, 3, 'rgba(70,95,45,0.4)'); }
  }

  // ---- shipping containers
  for (const [x0, y0, x1, y1, col] of L.containers) {
    const px = x0 * T, py = y0 * T, w = (x1 - x0 + 1) * T, h = (y1 - y0 + 1) * T, horiz = w >= h;
    x.fillStyle = col; x.fillRect(px + 2, py + 2, w - 4, h - 4);
    const gr = horiz ? x.createLinearGradient(px, py, px, py + h) : x.createLinearGradient(px, py, px + w, py);
    gr.addColorStop(0, 'rgba(255,255,255,0.16)'); gr.addColorStop(0.5, 'rgba(255,255,255,0)'); gr.addColorStop(1, 'rgba(0,0,0,0.25)');
    x.fillStyle = gr; x.fillRect(px + 2, py + 2, w - 4, h - 4);
    for (let k = 7; k < (horiz ? w : h) - 6; k += 7) { // corrugation
      x.fillStyle = 'rgba(0,0,0,0.22)'; if (horiz) x.fillRect(px + k, py + 5, 2.5, h - 10); else x.fillRect(px + 5, py + k, w - 10, 2.5);
      x.fillStyle = 'rgba(255,255,255,0.08)'; if (horiz) x.fillRect(px + k + 2.5, py + 5, 1.5, h - 10); else x.fillRect(px + 5, py + k + 2.5, w - 10, 1.5);
    }
    // end doors with locking bars
    x.fillStyle = 'rgba(0,0,0,0.3)';
    if (horiz) { x.fillRect(px + w - 14, py + 3, 11, h - 6); x.fillStyle = '#8a8d90'; for (const k of [0.3, 0.7]) x.fillRect(px + w - 12, py + h * k - 1, 8, 2); }
    else { x.fillRect(px + 3, py + h - 14, w - 6, 11); x.fillStyle = '#8a8d90'; for (const k of [0.3, 0.7]) x.fillRect(px + w * k - 1, py + h - 12, 2, 8); }
    x.fillStyle = '#26282a'; for (const [cx, cy] of [[px + 2, py + 2], [px + w - 8, py + 2], [px + 2, py + h - 8], [px + w - 8, py + h - 8]]) x.fillRect(cx, cy, 6, 6);
    for (let k = 0; k < 10; k++) { x.fillStyle = 'rgba(110,55,25,0.35)'; const rx = px + 6 + R() * (w - 12), ry = py + 6 + R() * (h - 12); x.fillRect(rx, ry, 2 + R() * 3, 4 + R() * 12); }
    x.save(); x.translate(px + w / 2, py + h / 2); if (!horiz) x.rotate(Math.PI / 2);
    x.fillStyle = 'rgba(240,240,235,0.55)'; x.font = 'bold 13px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
    x.fillText(`BSCU ${1000 + Math.floor(R() * 9000)}`, 0, 0); x.restore();
    x.strokeStyle = 'rgba(0,0,0,0.6)'; x.lineWidth = 2; x.strokeRect(px + 3, py + 3, w - 6, h - 6);
  }

  // ---- fuel tanks
  for (const [tx0, ty0, s] of L.tanks) {
    const cx = (tx0 + s / 2) * T, cy = (ty0 + s / 2) * T, r = (s / 2) * T - 2;
    const gr = x.createRadialGradient(cx - r * 0.35, cy - r * 0.35, r * 0.1, cx, cy, r);
    gr.addColorStop(0, '#e0e2de'); gr.addColorStop(0.7, '#a6a9a4'); gr.addColorStop(1, '#7c7f7a');
    x.fillStyle = gr; x.beginPath(); x.arc(cx, cy, r, 0, Math.PI * 2); x.fill();
    x.strokeStyle = 'rgba(0,0,0,0.16)'; x.lineWidth = 2;
    for (const k of [0.82, 0.6, 0.36]) { x.beginPath(); x.arc(cx, cy, r * k, 0, Math.PI * 2); x.stroke(); }
    x.fillStyle = 'rgba(40,40,40,0.5)';
    for (let a = 0; a < Math.PI * 2; a += Math.PI / 18) x.fillRect(cx + Math.cos(a) * (r - 4) - 1, cy + Math.sin(a) * (r - 4) - 1, 2, 2); // rivets
    x.strokeStyle = '#55585a'; x.lineWidth = 1.5; x.beginPath(); x.arc(cx, cy, r + 3, -0.6, 0.6); x.stroke(); // catwalk rail
    x.strokeStyle = 'rgba(0,0,0,0.5)'; x.lineWidth = 2; x.beginPath(); x.arc(cx, cy, r - 1, 0, Math.PI * 2); x.stroke();
    // ladder, vent, manway
    x.fillStyle = '#4a4d48'; x.fillRect(cx + r * 0.2, cy - 3, r * 0.78, 6);
    x.fillStyle = '#6a6d68'; for (let k = cx + r * 0.25; k < cx + r * 0.95; k += 6) x.fillRect(k, cy - 3, 1.5, 6);
    ell(x, cx, cy, 9, 9, '#5d605b'); ell(x, cx, cy, 5, 5, '#3e413c');
    ell(x, cx - r * 0.45, cy + r * 0.3, 7, 7, '#6d706b');
    x.fillStyle = '#b8402e'; x.font = 'bold 15px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('FUEL', cx, cy + r * 0.62);
    x.save(); x.translate(cx - r * 0.55, cy - r * 0.45); x.rotate(Math.PI / 4); x.fillStyle = '#c9402e'; x.fillRect(-6, -6, 12, 12); x.restore();
  }

  // ---- cover objects
  const drawn = new Set();
  for (let ty = 0; ty < MAP_H; ty++) for (let tx = 0; tx < MAP_W; tx++) {
    const ch = at(tx, ty), px = tx * T, py = ty * T;
    if (ch === 'c') {
      const metal = R() < 0.35;
      const g2 = x.createLinearGradient(px, py, px + T, py + T);
      g2.addColorStop(0, metal ? '#5c6b48' : '#8a6a44'); g2.addColorStop(1, metal ? '#3e4a30' : '#634a2e');
      x.fillStyle = g2; x.fillRect(px + 3, py + 3, T - 6, T - 6);
      x.strokeStyle = metal ? '#26301c' : '#3a2a18'; x.lineWidth = 2; x.strokeRect(px + 4, py + 4, T - 8, T - 8);
      if (metal) {
        for (let k = 0; k < 5; k++) { x.fillStyle = 'rgba(0,0,0,0.2)'; x.fillRect(px + 7 + k * 6, py + 5, 2, T - 10); x.fillStyle = 'rgba(255,255,255,0.08)'; x.fillRect(px + 9 + k * 6, py + 5, 1, T - 10); }
        x.fillStyle = '#c9b23c'; x.fillRect(px + 10, py + 16, 20, 7);
        x.fillStyle = '#1b1c1e'; for (let k = 0; k < 4; k++) x.fillRect(px + 12 + k * 5, py + 16, 2, 7);
        x.fillStyle = '#2a2c2e'; x.fillRect(px + 5, py + 18, 3, 5); x.fillRect(px + T - 8, py + 18, 3, 5);
      } else {
        x.strokeStyle = 'rgba(50,32,15,0.28)'; x.lineWidth = 1;
        for (let k = 1; k < 4; k++) { x.fillStyle = 'rgba(0,0,0,0.25)'; x.fillRect(px + 4, py + 4 + k * 8, T - 8, 1); for (let g3 = 0; g3 < 2; g3++) { const gx = px + 6 + R() * 20; seg(x, gx, py + k * 8, gx + 8, py + k * 8 + 1); } }
        x.strokeStyle = '#5a4128'; x.lineWidth = 3.5; seg(x, px + 6, py + 6, px + T - 6, py + T - 6);
        x.fillStyle = '#2c2218'; for (const [nx, ny] of [[7, 7], [T - 9, 7], [7, T - 9], [T - 9, T - 9]]) x.fillRect(px + nx, py + ny, 2, 2);
        x.fillStyle = 'rgba(255,240,200,0.14)'; x.fillRect(px + 4, py + 4, T - 8, 2);
        if (R() < 0.3) { x.fillStyle = 'rgba(20,20,20,0.45)'; x.font = 'bold 8px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('FRAGILE', px + T / 2, py + T / 2 + 6); }
      }
    } else if (ch === 'h') {
      x.save(); x.beginPath(); x.rect(px - 2, py - 2, T + 4, T + 4); x.clip();
      for (const [ox, oy] of [[11, 12], [29, 12], [20, 28], [4, 28], [36, 28]]) {
        const g3 = x.createRadialGradient(px + ox - 3, py + oy - 3, 1, px + ox, py + oy, 11);
        g3.addColorStop(0, '#a8987a'); g3.addColorStop(1, '#6e6248');
        x.fillStyle = g3; x.beginPath(); x.ellipse(px + ox, py + oy, 10.5, 7.5, 0, 0, Math.PI * 2); x.fill();
        x.strokeStyle = '#4a4130'; x.lineWidth = 1.5; x.stroke();
        x.strokeStyle = 'rgba(60,50,35,0.6)'; x.lineWidth = 1; x.setLineDash([2, 2]);
        x.beginPath(); x.moveTo(px + ox - 7, py + oy); x.lineTo(px + ox + 7, py + oy); x.stroke(); x.setLineDash([]);
        x.fillStyle = 'rgba(40,32,20,0.5)'; x.fillRect(px + ox - 10, py + oy - 1.5, 3, 3);
      }
      if (style === 'snow') for (let k = 0; k < 3; k++) ell(x, px + R() * T, py + 6 + R() * 20, 5, 2.5, 'rgba(240,245,250,0.8)');
      x.restore();
    } else if (ch === 'b') {
      const [c0, c1] = BARREL_COLS[Math.floor(R() * BARREL_COLS.length)];
      const cx = px + T / 2, cy = py + T / 2;
      const g3 = x.createRadialGradient(cx - 5, cy - 5, 2, cx, cy, 16); g3.addColorStop(0, c1); g3.addColorStop(1, c0);
      x.fillStyle = g3; x.beginPath(); x.arc(cx, cy, 15, 0, Math.PI * 2); x.fill();
      x.strokeStyle = 'rgba(0,0,0,0.5)'; x.lineWidth = 2; x.stroke();
      x.strokeStyle = 'rgba(0,0,0,0.3)'; x.lineWidth = 1.5; x.beginPath(); x.arc(cx, cy, 11, 0, Math.PI * 2); x.stroke();
      ell(x, cx + 5, cy - 4, 2.5, 2.5, '#2a2a2a'); ell(x, cx - 6, cy + 5, 1.6, 1.6, '#2a2a2a');
      ell(x, cx - 5, cy - 6, 4, 2.5, 'rgba(255,255,255,0.2)');
      for (let k = 0; k < 3; k++) { x.fillStyle = 'rgba(110,55,25,0.4)'; x.fillRect(cx + (R() - 0.5) * 20, cy + (R() - 0.5) * 20, 3, 3); }
      if (c0 === '#a8862a') { x.fillStyle = '#1b1c1e'; x.font = 'bold 10px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('!', cx, cy + 1); }
    } else if (ch === 'p') {
      const hz = at(tx - 1, ty) === 'p' || at(tx + 1, ty) === 'p';
      x.fillStyle = '#34373a';
      if (hz) x.fillRect(px + 16, py + 6, 8, 28); else x.fillRect(px + 6, py + 16, 28, 8);
      const gr = hz ? x.createLinearGradient(px, py + 11, px, py + 29) : x.createLinearGradient(px + 11, py, px + 29, py);
      gr.addColorStop(0, '#565b60'); gr.addColorStop(0.35, '#c0c5ca'); gr.addColorStop(1, '#5b6065');
      x.fillStyle = gr; if (hz) x.fillRect(px, py + 11, T, 18); else x.fillRect(px + 11, py, 18, T);
      x.fillStyle = '#464b50'; if (hz) x.fillRect(px, py + 9, 5, 22); else x.fillRect(px + 9, py, 22, 5);
      x.fillStyle = '#2a2d30'; for (let k = 0; k < 3; k++) { if (hz) x.fillRect(px + 1.5, py + 11 + k * 7, 2, 2); else x.fillRect(px + 11 + k * 7, py + 1.5, 2, 2); }
      for (let k = 0; k < 3; k++) { x.fillStyle = 'rgba(120,60,25,0.35)'; if (hz) x.fillRect(px + R() * T, py + 13 + R() * 12, 3 + R() * 5, 2); else x.fillRect(px + 13 + R() * 12, py + R() * T, 2, 3 + R() * 5); }
      if (R() < 0.12) { const vx = px + T / 2, vy = py + T / 2; x.strokeStyle = '#b8402e'; x.lineWidth = 2.5; x.beginPath(); x.arc(vx, vy, 7, 0, Math.PI * 2); x.stroke(); seg(x, vx - 7, vy, vx + 7, vy); seg(x, vx, vy - 7, vx, vy + 7); }
    } else if (ch === 'T') {
      const cx = px + T / 2, cy = py + T / 2;
      ell(x, cx + 9, cy + 10, 26, 22, 'rgba(0,0,0,0.3)');
      if (style === 'snow') {
        for (let k = 0; k < 3; k++) {
          const r = 26 - k * 7; x.fillStyle = rgb(30 + k * 8, 58 + k * 10, 44 + k * 6);
          x.beginPath(); for (let a = 0; a < 16; a++) { const rr = a % 2 ? r * 0.65 : r; const ang = a / 16 * Math.PI * 2 + k * 0.2; x.lineTo(cx + Math.cos(ang) * rr, cy + Math.sin(ang) * rr); } x.closePath(); x.fill();
        }
        for (let k = 0; k < 8; k++) ell(x, cx + (R() - 0.5) * 34, cy + (R() - 0.5) * 34, 3 + R() * 3, 2, 'rgba(240,245,250,0.85)');
      } else {
        for (let k = 0; k < 9; k++) ell(x, cx + (R() - 0.5) * 26, cy + (R() - 0.5) * 26, 11 + R() * 7, 10 + R() * 6, rgb(42 + R() * 18, 66 + R() * 22, 30 + R() * 12));
        for (let k = 0; k < 12; k++) ell(x, cx + (R() - 0.5) * 30 - 3, cy + (R() - 0.5) * 30 - 3, 3 + R() * 3, 2.5, 'rgba(150,185,95,0.3)');
        ell(x, cx, cy, 3, 3, '#3a2a1c');
      }
    } else if (ch === 'v' && !drawn.has(tx + ',' + ty)) {
      // a wreck is a whole run of 'v' tiles
      let w = 1, h = 1;
      while (at(tx + w, ty) === 'v') w++;
      while (at(tx, ty + h) === 'v') h++;
      for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) drawn.add((tx + xx) + ',' + (ty + yy));
      const cw = w * T, chh = h * T, horiz = cw >= chh, col = WRECK_COLS[Math.floor(R() * WRECK_COLS.length)];
      x.save(); x.translate(px + cw / 2, py + chh / 2); if (!horiz) x.rotate(Math.PI / 2);
      const L2 = Math.max(cw, chh) - 6, W2 = Math.min(cw, chh) - 10;
      x.fillStyle = '#121212'; for (const [wx, wy] of [[-L2 * 0.32, -W2 / 2 - 2], [L2 * 0.32, -W2 / 2 - 2], [-L2 * 0.32, W2 / 2 - 3], [L2 * 0.32, W2 / 2 - 3]]) x.fillRect(wx - 6, wy, 12, 5); // tyres
      x.fillStyle = col; rrect(x, -L2 / 2, -W2 / 2, L2, W2, 9); x.fill();
      x.fillStyle = 'rgba(255,255,255,0.1)'; rrect(x, -L2 / 2 + 3, -W2 / 2 + 3, L2 - 6, W2 / 3, 6); x.fill();
      x.fillStyle = 'rgba(0,0,0,0.25)'; x.fillRect(-L2 / 2 + 6, -1, L2 * 0.25, 2); // hood seam
      x.fillStyle = '#2a3540'; rrect(x, -L2 * 0.12, -W2 / 2 + 4, L2 * 0.1, W2 - 8, 3); x.fill(); // windshield
      x.fillStyle = 'rgba(0,0,0,0.3)'; rrect(x, -L2 * 0.02, -W2 / 2 + 5, L2 * 0.3, W2 - 10, 4); x.fill(); // roof
      x.fillStyle = '#2a3540'; rrect(x, L2 * 0.29, -W2 / 2 + 5, L2 * 0.07, W2 - 10, 3); x.fill(); // rear window
      x.fillStyle = 'rgba(200,220,230,0.4)'; x.beginPath(); x.moveTo(-L2 * 0.1, -W2 / 2 + 6); x.lineTo(-L2 * 0.05, -W2 / 2 + 12); x.lineTo(-L2 * 0.08, -W2 / 2 + 14); x.closePath(); x.fill();
      x.fillStyle = '#1a1a1a'; x.fillRect(-L2 * 0.1, -W2 / 2 - 3, 4, 3); x.fillRect(-L2 * 0.1, W2 / 2, 4, 3); // mirrors
      for (let k = 0; k < 14; k++) { x.fillStyle = R() < 0.5 ? 'rgba(10,8,6,0.5)' : 'rgba(150,80,40,0.35)'; x.beginPath(); x.arc((R() - 0.5) * L2, (R() - 0.5) * W2, 2 + R() * 7, 0, Math.PI * 2); x.fill(); }
      x.strokeStyle = 'rgba(0,0,0,0.6)'; x.lineWidth = 1.5; rrect(x, -L2 / 2, -W2 / 2, L2, W2, 9); x.stroke();
      x.restore();
      for (let k = 0; k < 20; k++) ell(x, px + cw / 2 + (R() - 0.5) * (cw + 30), py + chh / 2 + (R() - 0.5) * (chh + 30), 1 + R(), 1, 'rgba(190,215,225,0.45)'); // glass
    }
  }

  // ---- helipad
  const hx = (L.exfil[0] + 0.5) * T, hy = (L.exfil[1] + 0.5) * T;
  ell(x, hx + 4, hy + 4, 92, 92, 'rgba(0,0,0,0.3)');
  const pg = x.createRadialGradient(hx - 30, hy - 30, 10, hx, hy, 92); pg.addColorStop(0, '#4a4f4a'); pg.addColorStop(1, '#343834');
  x.fillStyle = pg; x.beginPath(); x.arc(hx, hy, 90, 0, Math.PI * 2); x.fill();
  x.strokeStyle = 'rgba(0,0,0,0.35)'; x.lineWidth = 1; for (let k = -80; k <= 80; k += 40) { seg(x, hx + k, hy - 88, hx + k, hy + 88); seg(x, hx - 88, hy + k, hx + 88, hy + k); }
  x.strokeStyle = '#d6b23a'; x.lineWidth = 6; x.beginPath(); x.arc(hx, hy, 78, 0, Math.PI * 2); x.stroke();
  x.strokeStyle = 'rgba(255,255,255,0.7)'; x.lineWidth = 2; x.beginPath(); x.arc(hx, hy, 70, 0, Math.PI * 2); x.stroke();
  for (let a = 0; a < Math.PI * 2; a += Math.PI / 8) { const lx = hx + Math.cos(a) * 86, ly = hy + Math.sin(a) * 86; ell(x, lx, ly, 3.5, 3.5, '#1a1a1a'); ell(x, lx, ly, 2, 2, '#9be86a'); }
  x.fillStyle = '#e8e6df'; x.font = 'bold 70px Arial, sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle';
  x.fillText('H', hx, hy + 3);
  x.fillStyle = 'rgba(232,230,223,0.7)'; x.font = 'bold 12px Arial'; x.fillText('EXFIL', hx, hy + 52);
  x.strokeStyle = 'rgba(15,15,15,0.4)'; x.lineWidth = 7; x.beginPath(); x.arc(hx - 10, hy + 10, 60, 2.6, 3.4); x.stroke(); // skid marks

  // gray copy for the areas outside the field of view
  const g = mkCanvas(Wp, Hp), gx = g.getContext('2d');
  gx.drawImage(c, 0, 0);
  const img = gx.getImageData(0, 0, Wp, Hp), d = img.data;
  for (let i = 0; i < d.length; i += 4) {
    const l = (d[i] * 0.3 + d[i + 1] * 0.59 + d[i + 2] * 0.11) * 0.5 + 6;
    d[i] = l; d[i + 1] = l; d[i + 2] = Math.min(255, l * 1.07);
  }
  gx.putImageData(img, 0, 0);
  return { color: c, gray: g, cx: x, gx };
}

// ---------------------------------------------------------------- icons

// Side-view gun silhouettes. Muzzle points right.
const GUN_ART = {
  m4a1:    { kind: 'rifle', recv: 46, hand: 34, barrel: 22, stock: 'tube', mag: 'straight', optic: 'red', col: '#3a3d40', furn: '#3a3d40' },
  ak47:    { kind: 'rifle', recv: 48, hand: 30, barrel: 26, stock: 'solid', mag: 'curve', optic: 'none', col: '#2b2b2b', furn: '#6b4226' },
  mp5:     { kind: 'rifle', recv: 40, hand: 22, barrel: 10, stock: 'tube', mag: 'curve', optic: 'none', col: '#33363a', furn: '#33363a', short: true },
  spas12:  { kind: 'rifle', recv: 44, hand: 30, barrel: 30, stock: 'skeleton', mag: 'tube', optic: 'none', col: '#36383b', furn: '#36383b' },
  barrett: { kind: 'rifle', recv: 54, hand: 30, barrel: 40, stock: 'solid', mag: 'box', optic: 'scope', col: '#3a3b36', furn: '#3a3b36', bipod: true, brake: true },
  m249:    { kind: 'rifle', recv: 52, hand: 30, barrel: 26, stock: 'solid', mag: 'drum', optic: 'carry', col: '#2a2b2a', furn: '#3b4130', bipod: true },
  m1911:   { kind: 'pistol', slide: 54, col: '#55585c', grip: '#5a3a22' },
  glock18: { kind: 'pistol', slide: 50, col: '#3a3c40', grip: '#3a3c40', ext: true },
  deagle:  { kind: 'pistol', slide: 64, col: '#9a9c9e', grip: '#222', heavy: true },
  magnum:  { kind: 'revolver', slide: 60, col: '#6f7276', grip: '#6b4226' },
};

function drawWeaponIcon(id) {
  const a = GUN_ART[id];
  const c = mkCanvas(160, 64), x = c.getContext('2d');
  x.lineJoin = 'round';
  if (a.kind === 'rifle') {
    const sx = a.stock === 'none' ? 20 : (a.short ? 22 : 12);
    const rx = sx + 26, ry = 24, rh = 13;
    // stock
    x.fillStyle = a.furn;
    if (a.stock === 'solid') { x.beginPath(); x.moveTo(rx, ry + 2); x.lineTo(sx, ry + 4); x.lineTo(sx, ry + 18); x.lineTo(rx, ry + rh); x.closePath(); x.fill(); }
    else if (a.stock === 'tube') { x.fillRect(sx + 8, ry + 3, rx - sx - 8, 4); x.fillRect(sx, ry, 9, 16); }
    else if (a.stock === 'skeleton') { x.strokeStyle = a.furn; x.lineWidth = 3; x.beginPath(); x.moveTo(rx, ry + 3); x.lineTo(sx, ry + 4); x.lineTo(sx, ry + 17); x.lineTo(rx, ry + 10); x.stroke(); }
    // receiver
    x.fillStyle = a.col; x.fillRect(rx, ry, a.recv, rh);
    // handguard + barrel
    const hx = rx + a.recv;
    x.fillStyle = a.furn; x.fillRect(hx, ry + 1, a.hand, rh - 3);
    x.fillStyle = a.col; x.fillRect(hx + a.hand, ry + 4, a.barrel, 4);
    if (a.brake) x.fillRect(hx + a.hand + a.barrel - 6, ry + 1, 8, 10);
    // grip
    x.fillStyle = a.col; x.beginPath(); x.moveTo(rx + 8, ry + rh); x.lineTo(rx + 17, ry + rh); x.lineTo(rx + 13, ry + rh + 15); x.lineTo(rx + 5, ry + rh + 14); x.closePath(); x.fill();
    // magazine
    const mx = rx + a.recv - 20;
    x.fillStyle = a.mag === 'drum' ? '#4a5136' : a.col;
    if (a.mag === 'straight') x.fillRect(mx, ry + rh, 9, 17);
    else if (a.mag === 'curve') { x.beginPath(); x.moveTo(mx, ry + rh); x.lineTo(mx + 9, ry + rh); x.quadraticCurveTo(mx + 14, ry + rh + 12, mx + 20, ry + rh + 18); x.lineTo(mx + 11, ry + rh + 21); x.quadraticCurveTo(mx + 4, ry + rh + 12, mx, ry + rh); x.fill(); }
    else if (a.mag === 'box') x.fillRect(mx - 2, ry + rh, 13, 12);
    else if (a.mag === 'drum') { x.fillRect(mx - 6, ry + rh, 20, 16); }
    else if (a.mag === 'tube') { x.fillRect(hx - 6, ry + rh - 1, a.hand + 18, 5); x.fillStyle = '#3a3b3d'; x.fillRect(hx + 4, ry + rh - 2, 18, 8); }
    // optic
    x.fillStyle = '#151617';
    if (a.optic === 'red') { x.fillRect(rx + 12, ry - 9, 16, 9); x.fillStyle = '#c3352a'; x.fillRect(rx + 25, ry - 7, 2, 4); }
    else if (a.optic === 'scope') { x.fillRect(rx + 6, ry - 11, 40, 8); x.fillRect(rx + 2, ry - 13, 8, 12); x.fillRect(rx + 42, ry - 13, 8, 12); x.fillRect(rx + 22, ry - 4, 6, 4); }
    else if (a.optic === 'carry') { x.fillRect(rx + 10, ry - 7, 30, 4); x.fillRect(rx + 12, ry - 4, 4, 4); x.fillRect(rx + 34, ry - 4, 4, 4); }
    else { x.fillRect(hx + a.hand - 4, ry - 4, 3, 4); x.fillRect(rx + 4, ry - 3, 6, 3); }
    if (a.bipod) { x.strokeStyle = '#1a1a1a'; x.lineWidth = 2.5; seg(x, hx + a.hand - 4, ry + rh - 2, hx + a.hand + 8, ry + rh + 16); seg(x, hx + a.hand - 4, ry + rh - 2, hx + a.hand - 12, ry + rh + 16); }
    x.fillStyle = 'rgba(255,255,255,0.12)'; x.fillRect(rx, ry + 1, a.recv + a.hand, 2);
  } else {
    const sx = 40, sy = 20;
    if (a.kind === 'revolver') {
      x.fillStyle = a.col; x.fillRect(sx + 26, sy + 3, a.slide - 18, 8);
      x.fillRect(sx + 4, sy, 26, 15);
      x.fillStyle = '#4c4f53'; rrect(x, sx + 8, sy + 1, 18, 13, 4); x.fill();
      x.fillStyle = a.col; x.fillRect(sx - 2, sy - 3, 6, 6);
    } else {
      x.fillStyle = a.col; x.fillRect(sx, sy, a.slide, a.heavy ? 14 : 11);
      x.fillStyle = 'rgba(0,0,0,0.3)';
      for (let k = 0; k < 4; k++) x.fillRect(sx + 4 + k * 3, sy + 2, 1.5, 7);
      x.fillStyle = a.col; x.fillRect(sx + 6, sy + 10, 26, 6);
    }
    x.fillStyle = a.grip; x.beginPath(); x.moveTo(sx + 4, sy + 12); x.lineTo(sx + 20, sy + 12); x.lineTo(sx + 14, sy + 38); x.lineTo(sx - 2, sy + 36); x.closePath(); x.fill();
    if (a.ext) { x.fillStyle = '#18191a'; x.fillRect(sx - 1, sy + 34, 14, 12); }
    x.strokeStyle = a.col; x.lineWidth = 2; x.beginPath(); x.arc(sx + 24, sy + 16, 6, 0, Math.PI); x.stroke();
    x.fillStyle = 'rgba(255,255,255,0.18)'; x.fillRect(sx, sy, a.slide, 2);
  }
  return c;
}

function drawItemIcon(id) {
  const c = mkCanvas(64, 64), x = c.getContext('2d');
  x.lineJoin = 'round';
  if (id === 'frag') {
    ell(x, 32, 36, 15, 17, '#3f4a2e');
    x.strokeStyle = 'rgba(0,0,0,0.35)'; x.lineWidth = 1.5;
    for (let k = -2; k <= 2; k++) seg(x, 18, 36 + k * 6, 46, 36 + k * 6);
    seg(x, 32, 20, 32, 52);
    x.fillStyle = '#7d7f7a'; x.fillRect(26, 14, 12, 7);
    x.fillStyle = '#9a9c95'; x.fillRect(36, 15, 12, 4);
    x.strokeStyle = '#c9b23c'; x.lineWidth = 2; x.beginPath(); x.arc(24, 15, 5, 0, Math.PI * 2); x.stroke();
    ell(x, 27, 30, 4, 6, 'rgba(255,255,255,0.12)');
  } else if (id === 'flash') {
    x.fillStyle = '#5b6066'; rrect(x, 20, 18, 24, 38, 4); x.fill();
    x.fillStyle = '#2b5c8a'; x.fillRect(20, 26, 24, 5);
    x.fillStyle = '#1b1c1e';
    for (let r = 0; r < 3; r++) for (let k = 0; k < 3; k++) x.fillRect(24 + k * 7, 35 + r * 6, 3, 3);
    x.fillStyle = '#8b8f94'; x.fillRect(26, 11, 12, 8);
    x.strokeStyle = '#c9b23c'; x.lineWidth = 2; x.beginPath(); x.arc(22, 12, 5, 0, Math.PI * 2); x.stroke();
  } else if (id === 'medkit') {
    x.fillStyle = '#d8d4c8'; rrect(x, 10, 18, 44, 34, 5); x.fill();
    x.fillStyle = '#8f8b80'; x.fillRect(24, 12, 16, 7);
    x.fillStyle = '#c3352a'; x.fillRect(28, 24, 8, 22); x.fillRect(21, 31, 22, 8);
  } else if (id === 'bandage') {
    ell(x, 32, 34, 18, 18, '#e2dac6');
    x.strokeStyle = '#b3a98f'; x.lineWidth = 2;
    for (let r = 4; r <= 14; r += 5) { x.beginPath(); x.arc(32, 34, r, 0, Math.PI * 2); x.stroke(); }
    x.fillStyle = '#e2dac6'; x.fillRect(44, 40, 14, 10);
  } else if (id === 'ammo') {
    x.fillStyle = '#4c5a3b'; x.fillRect(8, 26, 48, 28);
    x.fillStyle = '#3a4530'; x.fillRect(8, 22, 48, 6);
    x.fillStyle = '#c9b23c'; x.font = 'bold 11px Arial'; x.textAlign = 'center'; x.fillText('7.62', 32, 44);
    for (let k = 0; k < 5; k++) { x.fillStyle = '#c9a03c'; x.fillRect(14 + k * 8, 8, 5, 14); x.fillStyle = '#8d5a2b'; x.fillRect(14 + k * 8, 6, 5, 5); }
  } else if (id === 'intel') {
    x.fillStyle = '#b89a5c'; x.fillRect(8, 16, 48, 38);
    x.fillStyle = '#c9ad6e'; x.fillRect(8, 12, 20, 6);
    x.fillStyle = '#eee8d8'; x.fillRect(12, 18, 40, 4);
    x.save(); x.translate(32, 38); x.rotate(-0.25);
    x.strokeStyle = '#c3352a'; x.lineWidth = 2; x.strokeRect(-20, -8, 40, 15);
    x.fillStyle = '#c3352a'; x.font = 'bold 9px Arial'; x.textAlign = 'center'; x.fillText('TOP SECRET', 0, 3);
    x.restore();
  } else if (id === 'drive') {
    x.fillStyle = '#26282b'; rrect(x, 12, 10, 40, 46, 4); x.fill();
    x.fillStyle = '#3d4044'; x.fillRect(16, 14, 32, 22);
    x.fillStyle = '#a5d63f'; x.fillRect(42, 46, 5, 4);
    x.fillStyle = '#c9b23c'; x.fillRect(16, 40, 18, 10);
    x.fillStyle = '#26282b'; x.font = 'bold 7px Arial'; x.fillText('ENC', 18, 48);
  } else if (id === 'molotov') {
    x.fillStyle = 'rgba(120,150,90,0.85)'; rrect(x, 20, 26, 24, 32, 6); x.fill();
    x.fillStyle = 'rgba(214,150,40,0.85)'; x.fillRect(21, 40, 22, 17);
    x.fillStyle = 'rgba(120,150,90,0.9)'; x.fillRect(27, 12, 10, 16);
    x.fillStyle = '#d8cfb8'; x.beginPath(); x.moveTo(27, 13); x.lineTo(24, 4); x.lineTo(33, 8); x.lineTo(37, 2); x.lineTo(37, 13); x.closePath(); x.fill();
    x.fillStyle = '#ff9a2a'; x.beginPath(); x.moveTo(26, 6); x.quadraticCurveTo(30, -4, 36, 4); x.quadraticCurveTo(32, 2, 30, 8); x.closePath(); x.fill();
    x.fillStyle = 'rgba(255,255,255,0.25)'; x.fillRect(23, 30, 3, 20);
  } else if (id === 'smoke') {
    x.fillStyle = '#5c6660'; rrect(x, 20, 16, 24, 40, 4); x.fill();
    x.fillStyle = '#a5d63f'; x.fillRect(20, 24, 24, 4);
    x.fillStyle = '#1b1c1e'; x.font = 'bold 8px Arial'; x.textAlign = 'center'; x.fillText('SMOKE', 32, 44);
    x.fillStyle = '#8b8f94'; x.fillRect(26, 9, 12, 8);
    x.strokeStyle = '#c9b23c'; x.lineWidth = 2; x.beginPath(); x.arc(22, 10, 5, 0, Math.PI * 2); x.stroke();
  }
  return c;
}

function drawArmourIcon(id) {
  const a = ARMOURS[id];
  const c = mkCanvas(64, 64), x = c.getContext('2d');
  x.fillStyle = a.col;
  x.beginPath();
  x.moveTo(14, 8); x.lineTo(24, 8); x.quadraticCurveTo(32, 18, 40, 8); x.lineTo(50, 8);
  x.lineTo(54, 22); x.lineTo(54, 56); x.lineTo(10, 56); x.lineTo(10, 22); x.closePath(); x.fill();
  x.fillStyle = 'rgba(0,0,0,0.25)';
  if (id !== 'soft') { rrect(x, 17, 22, 30, 24, 3); x.fill(); }
  x.fillRect(10, 48, 44, 3);
  x.fillStyle = 'rgba(255,255,255,0.1)';
  for (let k = 0; k < 3; k++) x.fillRect(14 + k * 13, 49, 9, 7);
  x.fillStyle = '#e6eadf'; x.font = 'bold 12px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
  x.fillText(a.tier, 32, 35);
  return c;
}

// Small top-down overview for the map selector (enemy positions are not shown).
function drawMapPreview(L) {
  const k = 3, c = mkCanvas(MAP_W * k, MAP_H * k), x = c.getContext('2d');
  const cols = { '#': '#a49e92', ',': '#5c5044', 'w': '#24485a', 'c': '#8a6a40', 'h': '#8a7c58', 'v': '#5a3c2c', 'p': '#9aa0a6', 'K': '#a0503a', 'o': '#c4c6c2', 'b': '#7a3a2a', 'T': '#3c5a2c' };
  for (let ty = 0; ty < MAP_H; ty++) for (let tx = 0; tx < MAP_W; tx++) {
    const ch = L.grid[ty][tx];
    x.fillStyle = cols[ch] || (ch === '=' ? rgb(...L.theme.road) : rgb(...L.theme.ground));
    x.fillRect(tx * k, ty * k, k, k);
  }
  x.fillStyle = '#7fe36a'; x.beginPath(); x.arc((L.start[0] + 0.5) * k, (L.start[1] + 0.5) * k, 5, 0, Math.PI * 2); x.fill();
  x.strokeStyle = '#e8c547'; x.lineWidth = 2; x.beginPath(); x.arc((L.exfil[0] + 0.5) * k, (L.exfil[1] + 0.5) * k, 9, 0, Math.PI * 2); x.stroke();
  x.fillStyle = '#e8c547'; x.font = 'bold 11px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
  x.fillText('H', (L.exfil[0] + 0.5) * k, (L.exfil[1] + 0.5) * k + 1);
  return c;
}

const ICON_URL = {}, ICON_IMG = {};
function iconKey(it) { return it.type[0] + ':' + it.id; }
function iconURL(it) {
  const k = iconKey(it);
  if (!ICON_URL[k]) ICON_URL[k] = (it.type === 'weapon' ? drawWeaponIcon(it.id) : it.type === 'armour' ? drawArmourIcon(it.id) : drawItemIcon(it.id)).toDataURL();
  return ICON_URL[k];
}
function iconImg(it) {
  const k = iconKey(it);
  if (!ICON_IMG[k]) { const im = new Image(); im.src = iconURL(it); ICON_IMG[k] = im; }
  return ICON_IMG[k];
}