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.
'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];
}
