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/game.js
'use strict';
// BLACK SITE - core game: state, simulation, enemy AI and rendering.

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1, VIEW_SCALE = 1;

const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const rand = (a, b) => a + Math.random() * (b - a);
const angDiff = (a, b) => { let d = a - b; while (d > Math.PI) d -= Math.PI * 2; while (d < -Math.PI) d += Math.PI * 2; return d; };
const turnToward = (cur, target, step) => { const d = angDiff(target, cur); return Math.abs(d) <= step ? target : cur + Math.sign(d) * step; };

const FOV = 100 * Math.PI / 180;         // player field of view: 100 degrees
const VIEW_RANGE = 1150;
const NEAR_SIGHT = 75;                   // radius of the circle around the player that also counts as in view
const ENEMY_SPREAD = 15 * Math.PI / 180; // enemy shots land anywhere in a 15 degree cone
const SUPPRESSED_SPREAD = 50 * Math.PI / 180; // ...or a 50 degree cone while under fire
const SUPPRESS_TIME = 2.5;
const ENEMY_DMG = 0.42;                  // enemy damage multiplier on weapon damage
const PLAYER_R = 13, ENEMY_R = 13;
const PLAYER_SPEED = 205, SPRINT_MULT = 1.6;
const HAULER_SPEED = 0.25; // the hauler's heavy pack: 25% of normal speed
// low cover (crates c, sandbags h, wrecks v, pipes p, barrels b, trees T) stops movement and bullets, not sight
const SOLID_MOVE = new Set(['#', 'c', 'h', 'v', 'p', 'K', 'o', 'w', 'b', 'T']); // water only blocks movement
const SOLID_SIGHT = new Set(['#', 'K', 'o']);
const SOLID_SHOT = new Set(['#', 'c', 'h', 'v', 'p', 'K', 'o', 'b', 'T']);
const EXTRACT_R = 110;
// Extraction: press F at the helipad to call the helicopter. It arrives after the difficulty's
// extractTime; meanwhile a reinforcement spawns every REINFORCE_EVERY seconds somewhere the
// player can't see. Once it lands the player has HELI_WAIT seconds to board it (F).
const HELI_WAIT = 60, REINFORCE_EVERY = 3, MAX_REINFORCEMENTS = 25, HELI_APPROACH = 6;
const BURN_DPS = 15, FIRE_TIME = 5, FIRE_R = 85;
const AFTERBURN_DPS = 10, AFTERBURN_TIME = 3; // keep burning after leaving the fire
const SMOKE_R = 150, SMOKE_TIME = 12;
const KILL_REWARD = 10;
const TIME_PENALTY = 50;  // taken off the payout for every full minute spent before extracting
const LOOT_RANGE = 70;
const FONT = "Bahnschrift, 'Arial Narrow', 'Segoe UI', sans-serif";
// Enemy weapons: no shotguns or Desert Eagles; roughly a third carry pistols.
const ENEMY_GUNS = ['ak47', 'ak47', 'ak47', 'm4a1', 'm4a1', 'mp5', 'mp5', 'm249', 'm1911', 'm1911', 'glock18', 'magnum'];
const HEARING_SCALE = 0.6; // enemies hear every noise at 60% of its full range
const DIRS8 = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];

const input = { keys: Object.create(null), mx: 0, my: 0, mdown: false, clicked: false };
const keyDown = c => !!input.keys[c];

let LEVEL = null, ART = null, G = null;

function resize() {
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  W = window.innerWidth; H = window.innerHeight;
  canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR);
  VIEW_SCALE = clamp(Math.min(W / 1280, H / 820), 0.55, 1.4);
  hurtVignette = null;
  frozenFrame = null;
}

// ---------------------------------------------------------------- world queries

function tileAt(tx, ty) {
  if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return '#';
  return LEVEL.grid[ty][tx];
}
function blockedTile(tx, ty) { return SOLID_MOVE.has(tileAt(tx, ty)); }

// Grid DDA ray cast. Returns the distance to the first tile in `solid`, capped at maxDist.
function castRay(x, y, dx, dy, maxDist, solid) {
  let tx = Math.floor(x / TILE), ty = Math.floor(y / TILE);
  if (solid.has(tileAt(tx, ty))) return 0;
  const stepX = dx > 0 ? 1 : -1, stepY = dy > 0 ? 1 : -1;
  const tdx = dx !== 0 ? Math.abs(TILE / dx) : Infinity;
  const tdy = dy !== 0 ? Math.abs(TILE / dy) : Infinity;
  let tmx = dx > 0 ? ((tx + 1) * TILE - x) / dx : dx < 0 ? (tx * TILE - x) / dx : Infinity;
  let tmy = dy > 0 ? ((ty + 1) * TILE - y) / dy : dy < 0 ? (ty * TILE - y) / dy : Infinity;
  for (;;) {
    let t;
    if (tmx < tmy) { tx += stepX; t = tmx; tmx += tdx; } else { ty += stepY; t = tmy; tmy += tdy; }
    if (t >= maxDist) return maxDist;
    if (solid.has(tileAt(tx, ty))) return t;
  }
}
function los(x0, y0, x1, y1, solid) {
  const d = Math.hypot(x1 - x0, y1 - y0);
  if (d < 1) return true;
  return castRay(x0, y0, (x1 - x0) / d, (y1 - y0) / d, d, solid) >= d - 1;
}
// Distance along a ray to a circle, or -1.
function segCircle(x, y, dx, dy, len, cx, cy, r) {
  const fx = cx - x, fy = cy - y;
  const t = fx * dx + fy * dy;
  const px = fx - dx * t, py = fy - dy * t;
  const d2 = px * px + py * py;
  if (d2 > r * r) return -1;
  const th = Math.sqrt(r * r - d2);
  let t0 = t - th;
  if (t0 < 0) { if (t + th < 0) return -1; t0 = 0; }
  return t0 <= len ? t0 : -1;
}

function resolveCircle(o, r) {
  const tx0 = Math.floor((o.x - r) / TILE), tx1 = Math.floor((o.x + r) / TILE);
  const ty0 = Math.floor((o.y - r) / TILE), ty1 = Math.floor((o.y + r) / TILE);
  for (let ty = ty0; ty <= ty1; ty++) for (let tx = tx0; tx <= tx1; tx++) {
    if (!blockedTile(tx, ty)) continue;
    const rx = tx * TILE, ry = ty * TILE;
    const cx = clamp(o.x, rx, rx + TILE), cy = clamp(o.y, ry, ry + TILE);
    const ddx = o.x - cx, ddy = o.y - cy, d2 = ddx * ddx + ddy * ddy;
    if (d2 >= r * r) continue;
    if (d2 > 1e-6) {
      const d = Math.sqrt(d2);
      o.x += ddx / d * (r - d); o.y += ddy / d * (r - d);
    } else {
      const l = o.x - rx, rr = rx + TILE - o.x, t = o.y - ry, b = ry + TILE - o.y, m = Math.min(l, rr, t, b);
      if (m === l) o.x = rx - r; else if (m === rr) o.x = rx + TILE + r; else if (m === t) o.y = ry - r; else o.y = ry + TILE + r;
    }
  }
}
function moveCircle(o, dx, dy, r) {
  o.x += dx; resolveCircle(o, r);
  o.y += dy; resolveCircle(o, r);
}

const RIPPLE_TIME = 0.9;

function onScreen(x, y) {
  return Math.abs(x - G.cam.x) < W / 2 / VIEW_SCALE && Math.abs(y - G.cam.y) < H / 2 / VIEW_SCALE;
}

// Smoke clouds block sight (not bullets) for everyone.
function smokeR(s) { return SMOKE_R * Math.min(1, (SMOKE_TIME - s.t) / 0.8) * Math.min(1, s.t / 1.5); }
function smokeLimit(x, y, dx, dy, max) {
  let d = max;
  for (const s of G.smokes) {
    const r = smokeR(s);
    if (r < 8) continue;
    const fx = s.x - x, fy = s.y - y;
    if (fx * fx + fy * fy < r * r) return Math.min(d, 28);
    const t = segCircle(x, y, dx, dy, d, s.x, s.y, r);
    if (t >= 0) d = t;
  }
  return d;
}
function sightDist(x, y, dx, dy, max) { return smokeLimit(x, y, dx, dy, castRay(x, y, dx, dy, max, SOLID_SIGHT)); }

function playerSees(x, y) {
  const p = G.player;
  if (p.dead) return false;
  const d = Math.hypot(x - p.x, y - p.y);
  if (d > VIEW_RANGE) return false;
  if (d > NEAR_SIGHT + ENEMY_R && Math.abs(angDiff(Math.atan2(y - p.y, x - p.x), p.angle)) > FOV / 2 + Math.atan2(ENEMY_R, d)) return false;
  if (d < 1) return true;
  return sightDist(p.x, p.y, (x - p.x) / d, (y - p.y) / d, d) >= d - 1;
}

// True when bullets from (px,py) can't reach any part of a body standing at (x,y).
function hiddenFrom(px, py, x, y) {
  const d = Math.hypot(x - px, y - py) || 1;
  const nx = -(y - py) / d * 16, ny = (x - px) / d * 16;
  return !los(px, py, x, y, SOLID_SHOT) && !los(px, py, x + nx, y + ny, SOLID_SHOT) && !los(px, py, x - nx, y - ny, SOLID_SHOT);
}

// One enemy per cover tile, so they don't shove each other back out into the open.
function coverTaken(c, self) {
  for (const o of G.enemies) if (o !== self && !o.dead && o.suppressT > 0 && o.coverTile === c) return true;
  return false;
}

// Path to the nearest tile (by walking distance, up to 8 steps) that is shielded from the shooter.
function findCover(e, px, py) {
  const N = MAP_W * MAP_H;
  const prev = new Int32Array(N).fill(-1), depth = new Uint8Array(N), q = new Int32Array(N);
  const s = Math.floor(e.y / TILE) * MAP_W + Math.floor(e.x / TILE);
  let qh = 0, qt = 0;
  prev[s] = s; q[qt++] = s;
  while (qh < qt) {
    const c = q[qh++];
    const cx = c % MAP_W, cy = (c / MAP_W) | 0, wx = (cx + 0.5) * TILE, wy = (cy + 0.5) * TILE;
    if (Math.hypot(wx - px, wy - py) > 150 && !coverTaken(c, e) && hiddenFrom(px, py, wx, wy)) {
      e.coverTile = c;
      const path = [];
      for (let k = c; k !== s; k = prev[k]) path.push({ x: (k % MAP_W + 0.5) * TILE, y: (((k / MAP_W) | 0) + 0.5) * TILE });
      path.reverse();
      if (!path.length) path.push({ x: wx, y: wy });
      return path;
    }
    if (depth[c] >= 8) continue;
    for (const [dx, dy] of DIRS8) {
      const nx = cx + dx, ny = cy + dy;
      if (blockedTile(nx, ny)) continue;
      if (dx && dy && (blockedTile(cx + dx, cy) || blockedTile(cx, cy + dy))) continue;
      const n = ny * MAP_W + nx;
      if (prev[n] !== -1) continue;
      prev[n] = c; depth[n] = depth[c] + 1; q[qt++] = n;
    }
  }
  return null;
}

// Every enemy near the line of fire takes cover.
// Enemies take ENEMY_REACT_DELAY seconds to react to the player's gunfire (being shot at,
// hit, or hearing it). Reactions of the same kind don't stack while one is pending.
const ENEMY_REACT_DELAY = 0.5;
function reactLater(e, kind, fn) {
  if (!e.pending) e.pending = {};
  if (!e.pending[kind]) e.pending[kind] = { t: ENEMY_REACT_DELAY, fn };
}

function suppress(px, py, aim, range) {
  const dx = Math.cos(aim), dy = Math.sin(aim);
  for (const e of G.enemies) {
    if (e.dead) continue;
    const fx = e.x - px, fy = e.y - py;
    const t = fx * dx + fy * dy;
    if (t < 0 || t > range) continue;
    if (Math.abs(fx * dy - fy * dx) > 90 + t * 0.12) continue;
    // already taking cover: keep them pinned; otherwise they react after a delay
    if (e.suppressT > 0) { e.suppressT = SUPPRESS_TIME; continue; }
    reactLater(e, 'suppress', () => {
      e.coverT = 0;
      e.suppressT = SUPPRESS_TIME;
      e.lastSeen = { x: px, y: py };
      if (e.state !== 'alert') { e.state = 'alert'; e.reactT = Math.max(e.reactT, 0.4); }
    });
  }
}

function findPath(x0, y0, x1, y1) {
  const sx = Math.floor(x0 / TILE), sy = Math.floor(y0 / TILE);
  const gx = Math.floor(x1 / TILE), gy = Math.floor(y1 / TILE);
  if (blockedTile(gx, gy)) return null;
  const prev = new Int32Array(MAP_W * MAP_H).fill(-1);
  const q = new Int32Array(MAP_W * MAP_H);
  const s = sy * MAP_W + sx, goal = gy * MAP_W + gx;
  let qh = 0, qt = 0;
  prev[s] = s; q[qt++] = s;
  while (qh < qt) {
    const c = q[qh++];
    if (c === goal) break;
    const cx = c % MAP_W, cy = (c / MAP_W) | 0;
    for (const [dx, dy] of DIRS8) {
      const nx = cx + dx, ny = cy + dy;
      if (blockedTile(nx, ny)) continue;
      if (dx && dy && (blockedTile(cx + dx, cy) || blockedTile(cx, cy + dy))) continue;
      const n = ny * MAP_W + nx;
      if (prev[n] !== -1) continue;
      prev[n] = c; q[qt++] = n;
    }
  }
  if (prev[goal] === -1) return null;
  const path = [];
  for (let c = goal; c !== s; c = prev[c]) path.push({ x: (c % MAP_W + 0.5) * TILE, y: (((c / MAP_W) | 0) + 0.5) * TILE });
  path.reverse();
  if (path.length) path[path.length - 1] = { x: x1, y: y1 };
  return path;
}
function randomNear(x, y, r) {
  const tx = Math.floor(x / TILE), ty = Math.floor(y / TILE);
  for (let i = 0; i < 15; i++) {
    const nx = tx + Math.round(rand(-r, r)), ny = ty + Math.round(rand(-r, r));
    if (!blockedTile(nx, ny)) return { x: (nx + 0.5) * TILE, y: (ny + 0.5) * TILE };
  }
  return null;
}

// ---------------------------------------------------------------- setup

// Weapons and items for a fresh deployment from the loadout.
function startingKit(loadout, charId) {
  const thr = loadout.throwable && PROFILE.unlocked.includes(loadout.throwable) ? makeItem(loadout.throwable) : null;
  if (charId === 'assaulter') {
    if (thr) thr.uses = GRENADE_SLOT_USES;
    return { weapons: [makeWeapon(loadout.primary), makeWeapon(loadout.secondary)], items: [makeItem('bandage'), null, null, null, thr] };
  }
  const items = [thr, makeItem('bandage')];
  while (items.length < itemSlotCount(charId)) items.push(null);
  return { weapons: [makeWeapon(loadout.primary), makeWeapon(loadout.secondary)], items };
}
// Ordinary item slots (the assaulter's grenade slot comes on top of these, at GRENADE_SLOT).
function itemSlotCount(charId) { return charId === 'hauler' ? 6 : 4; }
// Make carried items fit the current character's slots (the grenade slot comes and goes).
function fitItems(items, charId) {
  items = fitItemSlots(items, charId);
  if (charId === 'assaulter') armGrenadeSlot(items);
  return items;
}
// Carried layouts: 4 slots, 4 + the assaulter's grenade slot (5), or the hauler's 6.
// Items that no longer fit move into free slots; anything beyond capacity is left behind.
function fitItemSlots(items, charId) {
  const nade = items.length === 5 ? items[4] : null;
  const loose = items.length === 5 ? items.slice(0, 4) : items.slice();
  const n = itemSlotCount(charId);
  const out = loose.slice(0, n);
  while (out.length < n) out.push(null);
  const spill = loose.slice(n).filter(Boolean);
  if (charId === 'assaulter') out.push(null);
  if (nade) {
    if (charId === 'assaulter' && ITEMS[nade.id].kind === 'throwable') out[GRENADE_SLOT] = nade;
    else spill.unshift(nade);
  }
  for (const it of spill) {
    const free = out.findIndex((x, i) => !x && i < n);
    if (free < 0) break;
    out[free] = it;
  }
  return out;
}

// carry: { weapons, items, stage } when continuing a campaign; health and armour always start full.
function newGame(loadout, mapId, carry, difficulty) {
  const charId = CHARACTERS[CHAR_ID] ? CHAR_ID : 'assaulter';
  const kit = carry ? JSON.parse(JSON.stringify({ weapons: carry.weapons, items: carry.items })) : startingKit(loadout, charId);
  kit.items = fitItems(kit.items, charId);
  LEVEL = buildLevel(mapId);
  MAP_W = LEVEL.w; MAP_H = LEVEL.h;
  LEVEL.surface = computeSurfaces(LEVEL);
  ART = renderMapArt(LEVEL);
  const p = {
    x: (LEVEL.start[0] + 0.5) * TILE, y: (LEVEL.start[1] + 0.5) * TILE, angle: -Math.PI / 4,
    hp: 100, dead: false, armour: PROFILE.armour, armourHp: PROFILE.armour ? armourMax(PROFILE.armour) : 0,
    weapons: kit.weapons,
    items: kit.items,
    equip: kit.weapons[0] ? 'primary' : kit.weapons[1] ? 'secondary' : 'none', lastGun: 'primary', itemSlot: -1,
    fireCd: 0, reloadT: 0, reloadMax: 1, swapT: 0, throwCd: 0, useT: 0, useMax: 1, useSlot: -1,
    sprinting: false, sprintOut: 0, walk: 0, recoil: 0, flashT: 0, autoReload: false, stepT: 0,
  };
  G = {
    charId, charPal: CHARACTERS[charId].pal, knives: [], slashT: 0,
    campaign: !!carry, campaignStage: carry ? carry.stage : 0, diff: DIFFICULTIES[difficulty] || DIFFICULTIES.normal,
    player: p, enemies: [], ripples: [], bullets: [], grenades: [], fires: [], smokes: [], particles: [], lights: [], piles: [],
    toasts: [], floaters: [], dmgInd: [],
    time: 0, kills: 0, shots: 0, hits: 0, lastHitShot: 0, nextShot: 1, dmgTaken: 0,
    shake: 0, hitT: 0, hitKill: false, hurtFlash: 0, crossBloom: 0,
    corpses: [], crawlers: [], extractCalled: false,
    exfilState: 'idle', exfilT: 0, spawnT: 0, rotorT: 0, reinforcements: 0, smokeT: 0, minutesCharged: 0, penalty: 0,
    cam: { x: p.x, y: p.y }, invOpen: false, invPile: null,
    over: false, won: false, overT: 0, endShown: false, payout: 0,
  };
  // a fresh deployment starts with the character's bigger magazines and reserves
  if (!carry) for (const w of p.weapons) if (w) { w.mag = magSize(w.id); w.reserve = fullReserve(w.id); }
  for (const [tx, ty, f] of LEVEL.enemies) G.enemies.push(makeEnemy(tx, ty, f === 'p'));
  for (const c of LEVEL.caches) G.piles.push(makeCache(c));
  addRandomSpawns();
  toast('REACH THE EXTRACTION POINT', 4);
}

// A map can ask for extra enemies (a fraction of its placed ones) and supply caches at random
// reachable spots on every deployment (enemies never close to the insertion point).
function addRandomSpawns() {
  const [sx, sy] = LEVEL.start;
  const seen = new Uint8Array(MAP_W * MAP_H), q = [sy * MAP_W + sx], reach = [];
  seen[q[0]] = 1;
  while (q.length) {
    const c = q.pop(), cx = c % MAP_W, cy = (c / MAP_W) | 0;
    reach.push([cx, cy]);
    for (const [dx, dy] of DIRS8.slice(0, 4)) {
      const nx = cx + dx, ny = cy + dy, n = ny * MAP_W + nx;
      if (!seen[n] && !blockedTile(nx, ny)) { seen[n] = 1; q.push(n); }
    }
  }
  const taken = new Set([...G.enemies.map(e => Math.floor(e.x / TILE) + ',' + Math.floor(e.y / TILE)), ...G.piles.map(p => Math.floor(p.x / TILE) + ',' + Math.floor(p.y / TILE))]);
  const pickSpot = (ok) => {
    for (let i = 0; i < 300; i++) {
      const [tx, ty] = reach[(Math.random() * reach.length) | 0];
      if (taken.has(tx + ',' + ty) || !ok(tx, ty)) continue;
      taken.add(tx + ',' + ty);
      return [tx, ty];
    }
    return null;
  };
  const extraEnemies = Math.round(LEVEL.enemies.length * (LEVEL.spawns.extraEnemies || 0));
  for (let i = 0; i < extraEnemies; i++) {
    const s = pickSpot((tx, ty) => Math.hypot(tx - sx, ty - sy) > 18);
    if (s) G.enemies.push(makeEnemy(s[0], s[1], Math.random() < 0.6));
  }
  for (let i = 0; i < (LEVEL.spawns.extraCaches || 0); i++) {
    const s = pickSpot((tx, ty) => LEVEL.floor[ty][tx] === ',' && Math.hypot(tx - sx, ty - sy) > 8);
    if (s) G.piles.push(makeCache(s));
  }
}

function makeEnemy(tx, ty, patrol) {
  const x = (tx + 0.5) * TILE, y = (ty + 0.5) * TILE, wid = pick(ENEMY_GUNS);
  const a = Math.random() * Math.PI * 2;
  return {
    x, y, hx: x, hy: y, angle: a, look: a, lookT: rand(1, 3), moveAng: a,
    hp: 50, dead: false, weapon: wid, mag: WEAPONS[wid].mag, reloadT: 0, fireCd: 0, burst: 0,
    state: patrol ? 'patrol' : 'idle', patrol, path: null, pathT: 0, waitT: rand(0, 3),
    reactT: 0, lastSeen: null, lostT: 0, searchPt: null, searchT: 0,
    strafe: 0, strafeT: 0, stunT: 0, hurtT: 0, walk: 0, recoil: 0, sees: false,
    suppressT: 0, coverT: 0, coverTile: -1, rippleT: rand(0, 2),
  };
}

function makeCache([tx, ty, kind]) {
  const items = [];
  if (kind === 'start') items.push(makeItem('ammo'), makeItem('bandage'));
  else {
    if (kind === 'weapons') items.push(rollWeapon());
    const n = 2 + ((Math.random() * 2) | 0);
    for (let i = 0; i < n; i++) items.push(Math.random() < 0.12 ? rollWeapon() : rollItem());
  }
  while (items.length < 6) items.push(null);
  return { x: (tx + 0.5) * TILE, y: (ty + 0.5) * TILE, kind: 'cache', items };
}

// ---------------------------------------------------------------- feedback

// drops entries in place; these lists are compacted every frame
function prune(list, keep) {
  let n = 0;
  for (const item of list) if (keep(item)) list[n++] = item;
  list.length = n;
}
function toast(text, t = 2.2) { G.toasts.push({ text, t, max: t }); if (G.toasts.length > 4) G.toasts.shift(); }
function puff(x, y, n, o) {
  for (let i = 0; i < n; i++) {
    const a = o.dir !== undefined ? o.dir + rand(-o.cone, o.cone) : Math.random() * Math.PI * 2;
    const sp = rand(o.speed[0], o.speed[1]);
    const life = rand(o.life[0], o.life[1]);
    G.particles.push({
      x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life, max: life,
      size: rand(o.size[0], o.size[1]), grow: o.grow || 0, color: o.color, rgb: `rgb(${o.color})`, drag: o.drag || 4,
      clip: !!o.clip, glow: !!o.glow, rise: o.rise || 0,
    });
  }
}
function light(x, y, r, t, color, clip) { G.lights.push({ x, y, r, t, max: t, color, clip }); }

function paint(fn) { fn(ART.cx, false); fn(ART.gx, true); }
const pc = (hex, gray) => (gray ? grayCol(hex) : hex);

function paintBlood(x, y, size) {
  const blobs = [];
  for (let i = 0; i < 9; i++) blobs.push([x + rand(-size, size), y + rand(-size, size), rand(size * 0.25, size * 0.6)]);
  paint((c, g) => {
    c.globalAlpha = 0.8; c.fillStyle = pc('#4f0d0a', g);
    for (const [bx, by, r] of blobs) { c.beginPath(); c.arc(bx, by, r, 0, Math.PI * 2); c.fill(); }
    c.globalAlpha = 1;
  });
}
function paintScorch(x, y) {
  paint((c, g) => {
    const gr = c.createRadialGradient(x, y, 5, x, y, 75);
    gr.addColorStop(0, g ? 'rgba(5,5,6,0.85)' : 'rgba(10,8,6,0.85)'); gr.addColorStop(1, 'rgba(0,0,0,0)');
    c.fillStyle = gr; c.beginPath(); c.arc(x, y, 75, 0, Math.PI * 2); c.fill();
  });
}
function paintCasing(x, y) {
  const a = Math.random() * 3;
  paint((c, g) => { c.save(); c.translate(x, y); c.rotate(a); c.fillStyle = pc('#b8923a', g); c.fillRect(-2.5, -1, 5, 2); c.restore(); });
}
function paintHole(x, y) {
  paint((c) => { c.fillStyle = 'rgba(15,14,12,0.75)'; c.beginPath(); c.arc(x, y, 1.8, 0, Math.PI * 2); c.fill(); });
}
// A body sprawled on the ground with a pool of blood, painted into the map for good.
function paintBody(x, y, a, pal, weapon, seed) {
  seed = seed || ((Math.random() * 1e9) | 0);
  paintBlood(x + Math.cos(a) * 4, y + Math.sin(a) * 4, 20);
  paint((c, g) => drawSprawl(c, { x, y, a, pal: g ? grayPal(pal) : pal, seed, k: 1, weapon }));
}
// Small directional blood splat: droplets thrown along the bullet's path past the hit point.
function paintSpray(x, y, dir) {
  const drops = [];
  for (let i = 0; i < 7; i++) {
    const d = 3 + Math.random() * 30, a = dir + (Math.random() - 0.5) * 0.7;
    drops.push([x + Math.cos(a) * d, y + Math.sin(a) * d, Math.max(0.8, 3.2 - d * 0.08) * (0.6 + Math.random() * 0.6), a]);
  }
  paint((c, g) => {
    c.fillStyle = pc('#6a0f0b', g);
    c.globalAlpha = 0.8;
    for (const [dx, dy, r, a] of drops) { c.beginPath(); c.ellipse(dx, dy, r * 1.8, r, a, 0, Math.PI * 2); c.fill(); }
    c.globalAlpha = 1;
  });
}
// Smear left behind by a body being dragged along the ground.
function paintSmear(x, y, dir) {
  const w = 3 + Math.random() * 3, l = 7 + Math.random() * 5, ox = (Math.random() - 0.5) * 6;
  paint((c, g) => {
    c.fillStyle = pc('#5a0c09', g); c.globalAlpha = 0.5;
    c.beginPath(); c.ellipse(x - Math.sin(dir) * ox, y + Math.cos(dir) * ox, l, w, dir, 0, Math.PI * 2); c.fill();
    c.globalAlpha = 1;
  });
}

// ---------------------------------------------------------------- player actions

function weaponLevel(id) { return PROFILE.levels[id] || 0; }
function weaponDmgMult(id) { return RPM_UPGRADES[id] ? 1 : 1 + LEVEL_DMG_BONUS * weaponLevel(id); }
function weaponRpmMult(id) { return (1 + (RPM_UPGRADES[id] || 0) * weaponLevel(id)) * (G.charId === 'sniper' && id === 'barrett' ? 1.35 : 1); }
// character perks on weapon stats: the machine gunner's M249 and the sniper's Barrett
function magSize(id) {
  if (G.charId === 'gunner' && id === 'm249') return WEAPONS[id].mag + 50;
  if (G.charId === 'sniper' && id === 'barrett') return 8;
  return WEAPONS[id].mag;
}
function reloadTime(id) { return WEAPONS[id].reload * (G.charId === 'gunner' && id === 'm249' ? 0.6 : 1); }
function fullReserve(id) { return G.charId === 'sniper' && id === 'barrett' ? 40 : WEAPONS[id].reserve; }
// The assaulter's fifth item slot only takes grenades, and a grenade in it can be thrown 3 times.
const GRENADE_SLOT = 4, GRENADE_SLOT_USES = 3;
function hasGrenadeSlot() { return G.charId === 'assaulter'; }
// Any grenade put in the grenade slot gets its 3 throws (a part-used one keeps what it has left).
function armGrenadeSlot(items) {
  const it = items[GRENADE_SLOT];
  if (it && ITEMS[it.id].kind === 'throwable' && it.uses === undefined) it.uses = GRENADE_SLOT_USES;
}
function armourMax(id) { return ARMOURS[id].hp + ((PROFILE.armourLevels || {})[id] || 0) * ARMOUR_UPGRADE_HP; }
// Armour soaks part of each enemy bullet; whatever it soaks comes off the armour's health.
function applyArmour(dmg) {
  const p = G.player;
  const a = p.armour && ARMOURS[p.armour];
  if (!a || p.armourHp <= 0) return dmg;
  const absorbed = Math.min(a.reduce, Math.max(0, dmg - 1), p.armourHp);
  p.armourHp -= absorbed;
  G.armourHitT = 0.25;
  if (p.armourHp <= 0) { p.armourHp = 0; toast('ARMOUR BROKEN'); Sfx.armourBreak(); }
  return dmg - absorbed;
}

function curWeapon() {
  const p = G.player;
  if (p.equip === 'primary') return p.weapons[0];
  if (p.equip === 'secondary') return p.weapons[1];
  return null;
}
function cancelUse() { G.player.useT = 0; }

function equip(kind) {
  const p = G.player;
  if (p.dead) return;
  if (kind === 'primary' || kind === 'secondary') {
    const i = kind === 'primary' ? 0 : 1;
    if (!p.weapons[i]) { toast(`NO ${kind.toUpperCase()} WEAPON`); Sfx.deny(); return; }
    if (p.equip === kind) return;
    cancelUse(); p.reloadT = 0; p.autoReload = false;
    p.equip = kind; p.lastGun = kind; p.swapT = 0.35;
    Sfx.click();
    return;
  }
  const slots = [];
  p.items.forEach((it, i) => { if (it && ITEMS[it.id].kind === kind) slots.push(i); });
  if (!slots.length) { toast(kind === 'throwable' ? 'NO THROWABLES' : 'NO MEDICAL ITEMS'); Sfx.deny(); return; }
  let next = slots[0];
  if (p.equip === kind) next = slots[(slots.indexOf(p.itemSlot) + 1) % slots.length];
  cancelUse(); p.reloadT = 0; p.autoReload = false;
  p.equip = kind; p.itemSlot = next; p.swapT = 0.25;
  Sfx.click();
}
function revertToGun() {
  const p = G.player;
  if (p.lastGun === 'primary' && p.weapons[0]) p.equip = 'primary';
  else if (p.lastGun === 'secondary' && p.weapons[1]) p.equip = 'secondary';
  else if (p.weapons[0]) p.equip = 'primary';
  else if (p.weapons[1]) p.equip = 'secondary';
  else p.equip = 'none';
  p.swapT = 0.3;
}
// Keeps the equipped slot valid after the inventory changes.
function ensureEquip() {
  const p = G.player;
  if (p.equip === 'none' || (p.equip === 'primary' && !p.weapons[0]) || (p.equip === 'secondary' && !p.weapons[1])) {
    p.reloadT = 0; revertToGun();
  }
  if (p.equip === 'throwable' || p.equip === 'medical') {
    const it = p.items[p.itemSlot];
    if (!it || ITEMS[it.id].kind !== p.equip) {
      const idx = p.items.findIndex(x => x && ITEMS[x.id].kind === p.equip);
      if (idx >= 0) p.itemSlot = idx; else revertToGun();
    }
  }
}

function startReload() {
  const p = G.player, inst = curWeapon();
  if (!inst || p.reloadT > 0 || p.dead) return;
  const w = WEAPONS[inst.id];
  const mag = magSize(inst.id);
  if (inst.mag >= mag) return;
  if (inst.reserve <= 0) {
    const ai = p.items.findIndex(it => it && it.id === 'ammo');
    if (ai < 0) { toast('NO AMMO - FIND AN AMMO BOX'); Sfx.deny(); return; }
    p.items[ai] = null;
    inst.reserve += mag * 3;
    toast(`AMMO BOX OPENED  +${mag * 3}`);
  }
  cancelUse();
  p.reloadT = p.reloadMax = reloadTime(inst.id);
  Sfx.reload(inst.id, p.reloadT);
}
function finishReload() {
  const inst = curWeapon();
  if (!inst) return;
  const w = WEAPONS[inst.id];
  const take = Math.min(magSize(inst.id) - inst.mag, inst.reserve);
  inst.mag += take; inst.reserve -= take;
}

function mouseWorld() {
  return { x: (input.mx - W / 2) / VIEW_SCALE + G.cam.x, y: (input.my - H / 2) / VIEW_SCALE + G.cam.y };
}
function muzzleWorld(x, y, a, id) {
  const [mx, my] = muzzleLocal(id);
  return { x: x + Math.cos(a) * mx - Math.sin(a) * my, y: y + Math.sin(a) * mx + Math.cos(a) * my };
}

function fireGun() {
  const p = G.player, inst = curWeapon();
  if (!inst || p.reloadT > 0 || p.swapT > 0 || p.fireCd > 0 || p.sprintOut > 0) return;
  const w = WEAPONS[inst.id];
  if (!w.auto && !input.clicked) return;
  if (inst.mag <= 0) { if (input.clicked) { Sfx.click(); startReload(); } return; }
  inst.mag--;
  p.fireCd = 60 / (w.rpm * weaponRpmMult(inst.id));
  const m = mouseWorld();
  const aim = Math.atan2(m.y - p.y, m.x - p.x); // bullets go exactly where the cursor is
  p.angle = aim;
  const shot = G.nextShot++;
  for (let k = 0; k < w.pellets; k++) {
    const a = w.pellets > 1 ? aim + (k / (w.pellets - 1) - 0.5) * 2 * w.spread : aim;
    spawnBullet(p.x, p.y, a, w, 'player', shot, w.dmg * weaponDmgMult(inst.id));
  }
  G.shots++;
  const mz = muzzleWorld(p.x, p.y, aim, inst.id);
  light(mz.x, mz.y, 170, 0.06, '255,190,90', false);
  puff(mz.x, mz.y, 4, { dir: aim, cone: 0.35, speed: [80, 260], life: [0.04, 0.09], size: [2, 4], color: '255,210,120', glow: true });
  puff(mz.x, mz.y, 2, { dir: aim, cone: 0.5, speed: [20, 60], life: [0.3, 0.6], size: [3, 5], grow: 14, color: '150,150,150', drag: 3 });
  paintCasing(p.x + Math.cos(aim + 1.6) * rand(18, 32), p.y + Math.sin(aim + 1.6) * rand(18, 32));
  G.shake = Math.min(14, G.shake + w.kick);
  G.crossBloom = Math.min(1, G.crossBloom + 0.35);
  p.recoil = 1;
  Sfx.shot(inst.id, 1);
  makeNoise(p.x, p.y, w.snd === 'sniper' || w.snd === 'shotgun' ? 1150 : 850);
  suppress(p.x, p.y, aim, w.range);
  if (inst.mag === 0) p.autoReload = true;
}

function throwItem() {
  const p = G.player;
  if (!input.clicked || p.throwCd > 0 || p.swapT > 0) return;
  const it = p.items[p.itemSlot];
  if (!it) return;
  const m = mouseWorld();
  const dx = m.x - p.x, dy = m.y - p.y;
  const d = Math.min(Math.hypot(dx, dy), 480), a = Math.atan2(dy, dx);
  const T = 0.75, grav = 900, z0 = 14;
  G.grenades.push({
    x: p.x + Math.cos(a) * 14, y: p.y + Math.sin(a) * 14, vx: Math.cos(a) * d / T, vy: Math.sin(a) * d / T,
    z: z0, vz: (grav * T * T / 2 - z0) / T, grav, kind: it.id, fuse: { frag: 1.9, flash: 1.4, smoke: 1.6, molotov: 99 }[it.id], spin: 0,
  });
  if (hasGrenadeSlot()) armGrenadeSlot(p.items);
  if (it.uses > 1) it.uses--; else p.items[p.itemSlot] = null;
  p.throwCd = 0.6;
  Sfx.throwIt();
  ensureEquip();
}

function useMedical() {
  const p = G.player;
  if (!input.clicked || p.useT > 0 || p.swapT > 0) return;
  const it = p.items[p.itemSlot];
  if (!it) return;
  if (p.hp >= 100) { toast('HEALTH IS FULL'); Sfx.deny(); return; }
  p.useSlot = p.itemSlot;
  p.useT = p.useMax = ITEMS[it.id].use;
  Sfx.useMed(it.id);
}

function damagePlayer(dmg, sx, sy, burn) {
  const p = G.player;
  if (p.dead || G.over) return;
  p.hp -= dmg;
  G.dmgTaken += dmg;
  G.hurtFlash = Math.min(0.5, G.hurtFlash + (burn ? 0.08 : 0.25));
  if (!burn) {
    G.dmgInd.push({ a: Math.atan2(sy - p.y, sx - p.x), t: 1.3 });
    G.shake = Math.min(14, G.shake + 3);
    Sfx.hurt();
  }
  if (p.hp <= 0) {
    p.hp = 0; p.dead = true;
    paintBody(p.x, p.y, p.angle + Math.PI, G.charPal, null);
    Sfx.death();
    endGame(false);
  }
}

function endGame(won) {
  if (G.over) return;
  G.over = true; G.won = won; G.overT = won ? 1.2 : 2.0;
  if (G.invOpen) Inv.close();
  if (won) {
    let valuables = 0;
    for (const it of G.player.items) if (it && ITEMS[it.id].kind === 'valuable') valuables += ITEMS[it.id].value;
    G.valuables = Math.round(valuables * G.diff.money);
    G.payout = Math.max(0, G.valuables + Math.round(G.kills * KILL_REWARD * G.diff.money) - G.penalty);
  }
}

// ---------------------------------------------------------------- bullets & enemies

function spawnBullet(x, y, a, w, owner, shot, dmg) {
  G.bullets.push({
    x, y, px: x, py: y, sx: x, sy: y, dx: Math.cos(a), dy: Math.sin(a), speed: w.speed, travel: 0, max: w.range,
    dmg, owner, shot, pierce: !!w.pierce, hitSet: null, dead: false,
  });
}

function updateBullets(dt) {
  prune(G.bullets, b => !b.dead);
  for (const b of G.bullets) {
    const step = Math.min(b.speed * dt, b.max - b.travel);
    const wallD = castRay(b.x, b.y, b.dx, b.dy, step, SOLID_SHOT);
    let lim = wallD;
    if (b.owner === 'player') {
      const hits = [];
      for (const e of G.enemies) {
        if (e.dead || (b.hitSet && b.hitSet.has(e))) continue;
        const t = segCircle(b.x, b.y, b.dx, b.dy, lim, e.x, e.y, ENEMY_R + 1);
        if (t >= 0) hits.push([t, e]);
      }
      for (const c of G.crawlers) {
        if (c.done || (b.hitSet && b.hitSet.has(c))) continue;
        const t = segCircle(b.x, b.y, b.dx, b.dy, lim, c.x, c.y, 13);
        if (t >= 0) hits.push([t, c]);
      }
      hits.sort((a, c) => a[0] - c[0]);
      for (const [t, e] of hits) {
        if (e.crawler) hitCrawler(b, e, t); else hitEnemy(b, e, t);
        if (!b.pierce) { b.dead = true; lim = t; break; }
        (b.hitSet || (b.hitSet = new Set())).add(e);
        b.dmg *= 0.7;
      }
    } else {
      const p = G.player;
      if (!p.dead) {
        const t = segCircle(b.x, b.y, b.dx, b.dy, lim, p.x, p.y, PLAYER_R);
        if (t >= 0) {
          lim = t; b.dead = true;
          const hx = b.x + b.dx * t, hy = b.y + b.dy * t, dir = Math.atan2(b.dy, b.dx);
          if (p.armour && p.armourHp > 0) { // the plate takes it: sparks, no blood
            puff(hx, hy, 6, { dir: dir + Math.PI, cone: 1, speed: [60, 200], life: [0.08, 0.2], size: [1, 2], color: '255,220,150', glow: true });
            Sfx.armourHit();
          } else {
            puff(hx, hy, 8, { dir, cone: 0.6, speed: [40, 180], life: [0.2, 0.45], size: [1.5, 3], color: '140,15,12' });
            paintSpray(hx, hy, dir);
          }
          damagePlayer(applyArmour(b.dmg * G.diff.enemyDmg + G.diff.extraDmg), b.sx, b.sy);
        }
      }
    }
    b.px = b.x; b.py = b.y;
    b.x += b.dx * lim; b.y += b.dy * lim; b.travel += lim;
    if (!b.dead && wallD < step) {
      b.dead = true;
      const back = Math.atan2(-b.dy, -b.dx);
      puff(b.x, b.y, 5, { dir: back, cone: 1.1, speed: [60, 240], life: [0.08, 0.2], size: [1, 2.2], color: '255,200,120', glow: true, clip: b.owner !== 'player' });
      puff(b.x, b.y, 2, { dir: back, cone: 0.8, speed: [10, 50], life: [0.3, 0.7], size: [2, 4], grow: 10, color: '120,115,105', clip: b.owner !== 'player' });
      paintHole(b.x - b.dx * 1.5, b.y - b.dy * 1.5);
    }
    if (b.travel >= b.max) b.dead = true;
  }
}

function hitEnemy(b, e, t) {
  const hx = b.x + b.dx * t, hy = b.y + b.dy * t;
  puff(hx, hy, 8, { dir: Math.atan2(b.dy, b.dx), cone: 0.5, speed: [60, 220], life: [0.2, 0.5], size: [1.5, 3.2], color: '150,16,12' });
  paintSpray(hx, hy, Math.atan2(b.dy, b.dx));
  if (b.shot !== G.lastHitShot) { G.hits++; G.lastHitShot = b.shot; }
  damageEnemy(e, b.dmg);
}

function damageEnemy(e, dmg, burn) {
  if (e.dead) return;
  e.hp -= dmg;
  e.hurtT = 0.15;
  if (e.hp <= 0) { killEnemy(e); return; }
  if (!burn) { G.hitT = 0.2; G.hitKill = false; Sfx.hit(); }
  const p = G.player;
  const from = { x: p.x, y: p.y };
  if (e.state === 'alert') { e.lastSeen = from; return; }
  reactLater(e, 'hit', () => {
    e.lastSeen = from;
    if (e.state !== 'alert') { e.state = 'alert'; e.reactT = 0.3; e.path = null; e.pathT = 0; }
    e.look = Math.atan2(from.y - e.y, from.x - e.x);
  });
}

const CRAWL_CHANCE = 0.25, CRAWL_TIME = 30, CRAWL_SPEED = 22;

function killEnemy(e, forceCrawl) {
  e.dead = true;
  G.kills++;
  G.hitKill = true; G.hitT = 0.3;
  Sfx.kill();
  G.floaters.push({ text: `+$${+(KILL_REWARD * G.diff.money).toFixed(2)}`, t: 1 });
  const items = [makeWeapon(e.weapon, true)];
  if (Math.random() < 0.6) items.push(rollItem());
  while (items.length < 6) items.push(null);
  const p = G.player;
  const body = { x: e.x, y: e.y, seed: (Math.random() * 1e9) | 0, weapon: e.weapon, pal: PAL_ENEMY, items };
  if (forceCrawl || Math.random() < CRAWL_CHANCE) {
    // badly wounded: crawls away from the player, groaning, for 30 seconds before dying
    G.crawlers.push({ ...body, crawler: true, a: Math.atan2(e.y - p.y, e.x - p.x), t: CRAWL_TIME, phase: 0, voiceT: 0.2, trailT: 0, voice: rand(0.85, 1.2) });
  } else {
    // falls backwards away from the shot and sprawls out
    G.corpses.push({ ...body, a: Math.atan2(e.y - p.y, e.x - p.x) + rand(-0.7, 0.7), k: 0 });
    G.piles.push({ x: e.x, y: e.y, kind: 'body', items });
  }
}

// ---------------------------------------------------------------- cut throat: knife

const KNIFE_SPEED = 1100, KNIFE_RANGE = 650, MELEE_RANGE = 58;
// Q: slash an enemy within reach in front of you, otherwise throw a knife at the cursor.
function knifeAttack() {
  const p = G.player;
  if (G.charId !== 'cutthroat' || p.dead || G.over || G.invOpen || (p.knifeCd || 0) > 0) return;
  const inReach = o => {
    const d = Math.hypot(o.x - p.x, o.y - p.y);
    return d < MELEE_RANGE && (d < 30 || Math.abs(angDiff(Math.atan2(o.y - p.y, o.x - p.x), p.angle)) < 1.1) && los(p.x, p.y, o.x, o.y, SOLID_SHOT);
  };
  const target = G.enemies.find(e => !e.dead && inReach(e)) || G.crawlers.find(c => !c.done && inReach(c));
  if (target) {
    p.knifeCd = 0.45; G.slashT = 0.18;
    Sfx.slash();
    const a = Math.atan2(target.y - p.y, target.x - p.x);
    puff(target.x, target.y, 14, { dir: a, cone: 0.7, speed: [60, 240], life: [0.2, 0.5], size: [1.5, 3.4], color: '150,16,12' });
    paintSpray(target.x, target.y, a);
    knifeKill(target);
    return;
  }
  // throw
  p.knifeCd = 0.8;
  const m = mouseWorld(), a = Math.atan2(m.y - p.y, m.x - p.x);
  G.knives.push({ x: p.x, y: p.y, dx: Math.cos(a), dy: Math.sin(a), travel: 0, spin: 0 });
  Sfx.knifeThrow();
}
function knifeKill(o) {
  G.hitT = 0.3; G.hitKill = true;
  if (o.crawler) { finishCrawler(o); return; }
  if (G.player.x !== undefined) { const r = G.player; o.lastSeen = { x: r.x, y: r.y }; }
  killEnemy(o, true); // silent, and they are left crawling
}
function updateKnives(dt) {
  for (const k of G.knives) {
    const step = Math.min(KNIFE_SPEED * dt, KNIFE_RANGE - k.travel);
    let lim = castRay(k.x, k.y, k.dx, k.dy, step, SOLID_SHOT);
    const wall = lim < step;
    let hit = null;
    for (const list of [G.enemies, G.crawlers]) for (const o of list) {
      if (o.dead || o.done) continue;
      const t = segCircle(k.x, k.y, k.dx, k.dy, lim, o.x, o.y, ENEMY_R + 2);
      if (t >= 0 && t <= lim) { lim = t; hit = o; }
    }
    k.x += k.dx * lim; k.y += k.dy * lim; k.travel += lim; k.spin += dt * 30;
    if (hit) {
      k.dead = true;
      puff(k.x, k.y, 10, { dir: Math.atan2(k.dy, k.dx), cone: 0.6, speed: [60, 200], life: [0.2, 0.5], size: [1.5, 3], color: '150,16,12' });
      paintSpray(k.x, k.y, Math.atan2(k.dy, k.dx));
      Sfx.hit();
      knifeKill(hit);
    } else if (wall || k.travel >= KNIFE_RANGE) {
      k.dead = true;
      if (wall) Sfx.knifeClink();
      const a = Math.atan2(k.dy, k.dx) + (wall ? Math.PI + rand(-0.5, 0.5) : rand(-0.4, 0.4)), x = k.x - k.dx * 6, y = k.y - k.dy * 6;
      paint((c, g) => { c.save(); c.translate(x, y); c.rotate(a); c.fillStyle = pc('#2a2018', g); c.fillRect(-7, -1.5, 6, 3); c.fillStyle = pc('#b8bcc0', g); c.fillRect(-1, -1.2, 10, 2.4); c.restore(); });
    }
  }
  prune(G.knives, k => !k.dead);
  const p = G.player;
  if (p.knifeCd > 0) p.knifeCd -= dt;
  if (G.slashT > 0) G.slashT -= dt;
}
function drawKnives() {
  for (const k of G.knives) {
    ctx.save(); ctx.translate(k.x, k.y); ctx.rotate(k.spin);
    ctx.fillStyle = '#2a2018'; ctx.fillRect(-7, -1.5, 6, 3);
    ctx.fillStyle = '#d0d4d8'; ctx.fillRect(-1, -1.3, 11, 2.6);
    ctx.restore();
  }
  const p = G.player;
  if (G.slashT > 0 && !p.dead) { // the slash arc
    ctx.strokeStyle = `rgba(235,240,245,${G.slashT / 0.18 * 0.8})`; ctx.lineWidth = 3; ctx.lineCap = 'round';
    ctx.beginPath(); ctx.arc(p.x, p.y, 40, p.angle - 1, p.angle + 1); ctx.stroke();
  }
}

function hitCrawler(b, c, t) {
  const hx = b.x + b.dx * t, hy = b.y + b.dy * t;
  puff(hx, hy, 8, { dir: Math.atan2(b.dy, b.dx), cone: 0.5, speed: [60, 220], life: [0.2, 0.5], size: [1.5, 3.2], color: '150,16,12' });
  paintSpray(hx, hy, Math.atan2(b.dy, b.dx));
  G.hitT = 0.2; G.hitKill = false; Sfx.hit();
  finishCrawler(c);
}

function finishCrawler(c) {
  if (c.done) return;
  c.done = true;
  paintBody(c.x, c.y, c.a, c.pal, null, c.seed);
  G.piles.push({ x: c.x, y: c.y, kind: 'body', items: c.items });
}

function updateBodies(dt) {
  const p = G.player;
  for (const c of G.corpses) {
    c.k = Math.min(1, c.k + dt / 0.4);
    if (c.k >= 1) { c.settled = true; paintBody(c.x, c.y, c.a, c.pal, c.weapon, c.seed); }
  }
  prune(G.corpses, c => !c.settled);
  for (const c of G.crawlers) {
    if (c.done) continue;
    c.t -= dt;
    c.phase += dt * 5;
    c.a = turnToward(c.a, Math.atan2(c.y - p.y, c.x - p.x), 1.2 * dt);
    // pulls along in heaves, weaker as time runs out
    const sp = CRAWL_SPEED * (0.25 + 0.75 * Math.max(0, Math.sin(c.phase))) * Math.min(1, 0.35 + c.t / 15);
    moveCircle(c, Math.cos(c.a) * sp * dt, Math.sin(c.a) * sp * dt, 11);
    c.trailT -= dt;
    if (c.trailT <= 0) { c.trailT = 0.16; paintSmear(c.x - Math.cos(c.a) * 12, c.y - Math.sin(c.a) * 12, c.a); }
    c.voiceT -= dt;
    if (c.voiceT <= 0) {
      c.voiceT = rand(1.3, 2.6);
      const v = voiceVol(c.x, c.y);
      if (Math.random() < 0.55) Sfx.groan(v, c.voice); else Sfx.scream(v * 0.8, c.voice * 0.95, 0.6);
    }
    if (c.t <= 0) finishCrawler(c);
  }
  prune(G.crawlers, c => !c.done);
}

function drawBodies() {
  for (const c of G.corpses) drawSprawl(ctx, { x: c.x, y: c.y, a: c.a, pal: c.pal, seed: c.seed, k: c.k, weapon: c.weapon });
  for (const c of G.crawlers) drawSprawl(ctx, { x: c.x, y: c.y, a: c.a, pal: c.pal, seed: c.seed, crawl: c.phase });
}

// ---- footsteps: the sound depends on what the character is walking on
function computeSurfaces(L) {
  const st = L.theme.style;
  const groundSurface = st === 'grass' ? 'grass' : st === 'sand' ? 'sand' : st === 'snow' ? 'snow' : 'hard';
  const roadSurface = L.theme.markings === false ? 'sand' : 'hard';
  const s = L.floor.map(row => row.map(f => (f === '=' ? roadSurface : f === '.' ? groundSurface : 'hard')));
  const fill = ([x0, y0, x1, y1], v) => { for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) if (s[y] && s[y][x] !== undefined) s[y][x] = v; };
  // building floor styles match the renderer: wood, tile, concrete, carpet
  L.buildings.forEach((b, i) => fill(b.r, ['wood', 'hard', 'hard', 'carpet'][i % 4]));
  for (const r of L.paved || []) fill(r, 'wood');
  return s;
}
function surfaceAt(x, y) { const row = LEVEL.surface[Math.floor(y / TILE)]; return (row && row[Math.floor(x / TILE)]) || 'hard'; }
// true when the character has moved far enough for another footstep
function stepDue(o) {
  const d = o.lx === undefined ? 0 : Math.hypot(o.x - o.lx, o.y - o.ly);
  o.lx = o.x; o.ly = o.y;
  if (d > 40) return false;
  o.stepDist = (o.stepDist || 0) + d;
  if (o.stepDist < (o.sprinting ? 56 : 42)) return false;
  o.stepDist = 0;
  return true;
}
function updateFootsteps() {
  const p = G.player;
  if (!p.dead && stepDue(p)) Sfx.step(surfaceAt(p.x, p.y), (p.sprinting ? 0.5 : 0.3) * (G.charId === 'assaulter' ? 0.3 : 1));
  let budget = 3;
  for (const e of G.enemies) {
    if (e.dead || !stepDue(e) || budget <= 0) continue;
    const v = soundVol(e.x, e.y, 750, 0.45);
    if (v > 0.02) { Sfx.step(surfaceAt(e.x, e.y), v); budget--; }
  }
}
// Screams and groans fade off steeply with distance and are silent ~27 tiles away.
function voiceVol(x, y) {
  const p = G.player, k = clamp(1 - Math.hypot(p.x - x, p.y - y) / 1100, 0, 1);
  return k * k;
}
function soundVol(x, y, range, max, min = 0) {
  const p = G.player;
  return clamp((1 - Math.hypot(p.x - x, p.y - y) / range) * max, min, max);
}

function makeNoise(x, y, r) {
  r *= HEARING_SCALE;
  const p = G.player;
  for (const e of G.enemies) {
    if (e.dead || e.state === 'alert') continue;
    if (Math.hypot(e.x - x, e.y - y) > r) continue;
    const pt = { x: p.x + rand(-80, 80), y: p.y + rand(-80, 80) };
    reactLater(e, 'noise', () => {
      if (e.state === 'alert') return;
      e.state = 'search';
      e.searchPt = pt;
      e.searchT = 8;
      e.path = null;
      e.look = Math.atan2(y - e.y, x - e.x);
    });
  }
}

function followPath(e, spd, dt, exact) {
  if (!e.path || !e.path.length) return false;
  const t = e.path[0];
  const dx = t.x - e.x, dy = t.y - e.y, d = Math.hypot(dx, dy);
  if (d < (exact && e.path.length === 1 ? 1.5 : 8)) { e.path.shift(); return e.path.length > 0; }
  const step = Math.min(d, spd * dt);
  moveCircle(e, dx / d * step, dy / d * step, ENEMY_R);
  e.moveAng = Math.atan2(dy, dx);
  e.walk += step * 0.08;
  return true;
}

function enemyTryFire(e) {
  if (e.fireCd > 0 || e.reloadT > 0) return;
  const w = WEAPONS[e.weapon];
  if (e.mag <= 0) { e.reloadT = 2.4; Sfx.reload(e.weapon, 2.4, soundVol(e.x, e.y, 900, 0.6)); return; }
  if (e.burst <= 0) e.burst = w.auto ? 2 + ((Math.random() * 3) | 0) : 1;
  e.mag--; e.burst--;
  for (let k = 0; k < w.pellets; k++) {
    // any direction inside a 15 degree cone in front of the enemy
    const a = e.angle + (Math.random() - 0.5) * (e.suppressT > 0 ? SUPPRESSED_SPREAD : ENEMY_SPREAD);
    spawnBullet(e.x, e.y, a, w, 'enemy', 0, w.dmg * ENEMY_DMG);
  }
  // enemies fire much slower than the weapon allows: short, spaced bursts with long pauses
  e.fireCd = (e.burst > 0 ? (60 / w.rpm) * 3 : rand(1.5, 2.6) + (w.auto ? 0 : 1)) * G.diff.fireDelay;
  e.recoil = 1;
  const mz = muzzleWorld(e.x, e.y, e.angle, e.weapon);
  light(mz.x, mz.y, 150, 0.06, '255,190,90', true);
  puff(mz.x, mz.y, 3, { dir: e.angle, cone: 0.35, speed: [80, 240], life: [0.04, 0.08], size: [2, 4], color: '255,210,120', glow: true, clip: true });
  const p = G.player;
  Sfx.shot(e.weapon, soundVol(e.x, e.y, 1700, 0.8, 0.08));
}

function alertNearby(src) {
  const p = G.player;
  for (const e of G.enemies) {
    if (e === src || e.dead || e.state === 'alert') continue;
    if (Math.hypot(e.x - src.x, e.y - src.y) > 300) continue;
    e.state = 'search'; e.searchPt = { x: p.x, y: p.y }; e.searchT = 8; e.path = null;
  }
}

function updateEnemy(e, dt) {
  if (e.pending) for (const k in e.pending) {
    const r = e.pending[k];
    r.t -= dt;
    if (r.t <= 0) { delete e.pending[k]; r.fn(); }
  }
  const p = G.player;
  e.fireCd -= dt; e.stunT -= dt; e.hurtT -= dt; e.recoil = Math.max(0, e.recoil - dt * 6);
  if (e.reloadT > 0) { e.reloadT -= dt; if (e.reloadT <= 0) e.mag = WEAPONS[e.weapon].mag; }
  const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy) || 1, angTo = Math.atan2(dy, dx);

  let sees = false;
  // enemies can only spot the player while they are on screen
  if (!p.dead && e.stunT <= 0 && onScreen(e.x, e.y)) {
    const range = e.state === 'alert' ? 1000 : e.state === 'search' ? 800 : 650;
    if (d < range) {
      const cone = e.state === 'alert' ? 1.5 : e.state === 'search' ? 1.1 : 0.9;
      const near = d < (p.sprinting && G.charId !== 'assaulter' ? 160 : 90); // hearing footsteps up close
      if ((Math.abs(angDiff(angTo, e.angle)) < cone || near) && sightDist(e.x, e.y, dx / d, dy / d, d) >= d - 2) sees = true;
    }
  }
  e.sees = sees;
  if (sees) {
    if (e.state !== 'alert') { e.state = 'alert'; e.reactT = rand(0.35, 0.7); e.path = null; alertNearby(e); }
    e.lastSeen = { x: p.x, y: p.y }; e.lostT = 0;
  }
  const spd = e.stunT > 0 ? 35 : 120;

  // under fire: run for cover and shoot back wildly
  if (e.suppressT > 0) {
    e.suppressT -= dt;
    if (e.stunT <= 0) {
      e.coverT -= dt;
      if (e.coverT <= 0) { e.coverTile = -1; e.path = findCover(e, p.x, p.y); e.coverT = 0.8; }
      const moving = followPath(e, 150, dt, true);
      if (sees) e.angle = turnToward(e.angle, angTo, 4.5 * dt);
      else if (moving) e.angle = turnToward(e.angle, e.moveAng, 5 * dt);
      if (e.reactT > 0) e.reactT -= dt;
      else if (sees && Math.abs(angDiff(angTo, e.angle)) < 0.3 && castRay(e.x, e.y, dx / d, dy / d, d, SOLID_SHOT) >= d - 2) enemyTryFire(e);
      if (e.suppressT <= 0) { e.path = null; e.pathT = 0; e.coverTile = -1; }
      return;
    }
  }

  switch (e.state) {
    case 'idle': {
      e.lookT -= dt;
      if (e.lookT <= 0) { e.look = e.angle + rand(-1.6, 1.6); e.lookT = rand(1.5, 4); }
      e.angle = turnToward(e.angle, e.look, 1.4 * dt);
      break;
    }
    case 'patrol': {
      if (e.path && e.path.length) {
        followPath(e, 70, dt);
        e.angle = turnToward(e.angle, e.moveAng, 3 * dt);
      } else {
        e.waitT -= dt; e.lookT -= dt;
        if (e.lookT <= 0) { e.look = e.angle + rand(-1.4, 1.4); e.lookT = rand(1, 2.5); }
        e.angle = turnToward(e.angle, e.look, 1.4 * dt);
        if (e.waitT <= 0) {
          const t = randomNear(e.hx, e.hy, 7);
          if (t) e.path = findPath(e.x, e.y, t.x, t.y);
          e.waitT = rand(2, 5);
        }
      }
      break;
    }
    case 'search': {
      e.searchT -= dt;
      if (!e.path && e.searchPt) { e.path = findPath(e.x, e.y, e.searchPt.x, e.searchPt.y) || []; e.searchPt = null; }
      if (e.path && e.path.length) {
        followPath(e, 105, dt);
        e.angle = turnToward(e.angle, e.moveAng, 3.5 * dt);
      } else {
        e.lookT -= dt;
        if (e.lookT <= 0) { e.look = e.angle + rand(-2, 2); e.lookT = rand(0.8, 1.6); }
        e.angle = turnToward(e.angle, e.look, 2.5 * dt);
      }
      if (e.searchT <= 0) { e.state = e.patrol ? 'patrol' : 'idle'; e.hx = e.x; e.hy = e.y; e.path = null; }
      break;
    }
    case 'alert': {
      if (e.stunT > 0) { e.angle += dt * 2.5 * Math.sin(G.time * 4 + e.x); break; }
      e.pathT -= dt;
      if (sees) {
        e.angle = turnToward(e.angle, angTo, 4.5 * dt);
        const w = WEAPONS[e.weapon];
        const clear = castRay(e.x, e.y, dx / d, dy / d, d, SOLID_SHOT) >= d - 2;
        const want = Math.min(w.range * 0.7, 420);
        if (!clear || d > want) {
          if (e.pathT <= 0 || !e.path) { e.path = findPath(e.x, e.y, p.x, p.y); e.pathT = 0.6; }
          if (e.path) followPath(e, spd * 0.9, dt);
        } else {
          e.path = null;
          e.strafeT -= dt;
          if (e.strafeT <= 0) { e.strafe = Math.random() < 0.3 ? 0 : Math.random() < 0.5 ? -1 : 1; e.strafeT = rand(0.6, 1.6); }
          if (e.strafe) {
            const sa = angTo + Math.PI / 2 * e.strafe;
            moveCircle(e, Math.cos(sa) * 55 * dt, Math.sin(sa) * 55 * dt, ENEMY_R);
            e.walk += 55 * dt * 0.08;
          }
        }
        if (e.reactT > 0) e.reactT -= dt;
        else if (clear && Math.abs(angDiff(angTo, e.angle)) < 0.18 && d < w.range * 0.95) enemyTryFire(e);
      } else {
        e.lostT += dt;
        if (!e.path || e.pathT <= 0) { e.path = e.lastSeen ? findPath(e.x, e.y, e.lastSeen.x, e.lastSeen.y) : null; e.pathT = 1; }
        if (e.path && e.path.length) { followPath(e, spd, dt); e.angle = turnToward(e.angle, e.moveAng, 4 * dt); }
        else e.angle = turnToward(e.angle, e.look, 3 * dt);
        if (e.lostT > 5 || ((!e.path || !e.path.length) && e.lostT > 1.5)) { e.state = 'search'; e.searchT = 7; e.path = []; }
      }
      break;
    }
  }
}

function separate() {
  const es = G.enemies, p = G.player;
  for (let i = 0; i < es.length; i++) {
    const a = es[i];
    if (a.dead) continue;
    for (let j = i + 1; j < es.length; j++) {
      const b = es[j];
      if (b.dead) continue;
      const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);
      if (d > 0.01 && d < ENEMY_R * 2) {
        const push = (ENEMY_R * 2 - d) / 2;
        moveCircle(a, -dx / d * push, -dy / d * push, ENEMY_R);
        moveCircle(b, dx / d * push, dy / d * push, ENEMY_R);
      }
    }
    if (!p.dead) {
      const dx = a.x - p.x, dy = a.y - p.y, d = Math.hypot(dx, dy);
      if (d > 0.01 && d < ENEMY_R + PLAYER_R) moveCircle(a, dx / d * (ENEMY_R + PLAYER_R - d), dy / d * (ENEMY_R + PLAYER_R - d), ENEMY_R);
    }
  }
}

// ---------------------------------------------------------------- grenades

function updateGrenades(dt) {
  for (const g of G.grenades) {
    g.fuse -= dt;
    g.spin += Math.hypot(g.vx, g.vy) * dt * 0.05;
    if (g.z > 0 || g.vz > 0) {
      g.vz -= g.grav * dt; g.z += g.vz * dt;
      if (g.z <= 0) {
        g.z = 0;
        g.hit = true;
        if (Math.abs(g.vz) > 120) { g.vz = -g.vz * 0.3; Sfx.bounce(); } else g.vz = 0;
        g.vx *= 0.6; g.vy *= 0.6;
      }
    } else {
      const f = Math.pow(0.04, dt);
      g.vx *= f; g.vy *= f;
    }
    // airborne grenades sail over low cover
    const solid = g.z > 16 ? SOLID_SIGHT : SOLID_SHOT;
    const nx = g.x + g.vx * dt;
    if (solid.has(tileAt(Math.floor((nx + Math.sign(g.vx) * 4) / TILE), Math.floor(g.y / TILE)))) { g.vx *= -0.45; g.hit = true; Sfx.bounce(); } else g.x = nx;
    const ny = g.y + g.vy * dt;
    if (solid.has(tileAt(Math.floor(g.x / TILE), Math.floor((ny + Math.sign(g.vy) * 4) / TILE)))) { g.vy *= -0.45; g.hit = true; Sfx.bounce(); } else g.y = ny;
    // molotovs shatter on the first impact
    if (g.fuse <= 0 || (g.kind === 'molotov' && g.hit)) { g.dead = true; explode(g); }
  }
  prune(G.grenades, g => !g.dead);
}

function ignite(x, y) {
  G.fires.push({ x, y, t: FIRE_TIME, tick: 0 });
  paintScorch(x, y);
  puff(x, y, 18, { speed: [60, 260], life: [0.2, 0.5], size: [3, 7], color: '255,150,40', glow: true, drag: 5 });
  light(x, y, 320, 0.3, '255,140,40', false);
  const p = G.player;
  Sfx.ignite(clamp(1.2 - Math.hypot(p.x - x, p.y - y) / 1500, 0.15, 1));
  makeNoise(x, y, 500);
}

// smoke gradients are pre-rendered once per shade and drawn scaled, with the fade in globalAlpha
const smokeSprites = new Map();
function smokeSprite(key, size, stops) {
  let c = smokeSprites.get(key);
  if (!c) {
    c = mkCanvas(size, size);
    const g = c.getContext('2d'), r = size / 2;
    const gr = g.createRadialGradient(r, r, r * stops[0], r, r, r);
    for (let i = 1; i < stops.length; i += 2) gr.addColorStop(stops[i], stops[i + 1]);
    g.fillStyle = gr; g.fillRect(0, 0, size, size);
    smokeSprites.set(key, c);
  }
  return c;
}
function smokePuffSprite(shade) {
  return smokeSprite(shade, 96, [0.2, 0, `rgba(${shade},${shade + 2},${shade - 4},0.9)`, 1, `rgba(${shade},${shade + 2},${shade - 4},0)`]);
}
const smokeCoreSprite = () => smokeSprite('core', 256, [0, 0, 'rgba(165,167,162,0.95)', 0.8, 'rgba(165,167,162,0.85)', 1, 'rgba(165,167,162,0)']);

function popSmoke(x, y) {
  const puffs = [];
  for (let i = 0; i < 16; i++) {
    const a = Math.random() * Math.PI * 2, r = Math.sqrt(Math.random()) * 0.75;
    puffs.push({ ox: Math.cos(a) * r, oy: Math.sin(a) * r, s: rand(0.35, 0.6), spin: rand(-0.3, 0.3), shade: rand(150, 190) | 0 });
  }
  G.smokes.push({ x, y, t: SMOKE_TIME, puffs });
  const p = G.player;
  Sfx.smoke(clamp(1.2 - Math.hypot(p.x - x, p.y - y) / 1500, 0.15, 1));
}

function updateFires(dt) {
  const p = G.player;
  for (const f of G.fires) {
    f.t -= dt;
    f.tick -= dt;
    if (f.tick <= 0) {
      f.tick += 0.25;
      const dmg = BURN_DPS * 0.25;
      for (const e of G.enemies) if (!e.dead && Math.hypot(e.x - f.x, e.y - f.y) < FIRE_R + ENEMY_R * 0.5) damageEnemy(e, dmg, true);
      if (!p.dead && Math.hypot(p.x - f.x, p.y - f.y) < FIRE_R + PLAYER_R * 0.5) damagePlayer(dmg, f.x, f.y, true);
    }
    if (Math.random() < dt * 40) {
      const a = Math.random() * Math.PI * 2, r = Math.sqrt(Math.random()) * FIRE_R;
      puff(f.x + Math.cos(a) * r, f.y + Math.sin(a) * r, 1, { speed: [5, 30], life: [0.3, 0.7], size: [3, 7], grow: -4, color: Math.random() < 0.5 ? '255,140,30' : '255,200,70', glow: true, drag: 2, rise: 30 });
    }
    if (Math.random() < dt * 6) puff(f.x + rand(-FIRE_R, FIRE_R) * 0.6, f.y + rand(-FIRE_R, FIRE_R) * 0.6, 1, { speed: [5, 20], life: [1.2, 2], size: [6, 10], grow: 14, color: '40,36,32', drag: 1, rise: 25 });
  }
  prune(G.fires, f => f.t > 0);

  // Anyone the fire catches is set alight: once they are out of the flames they keep
  // burning for AFTERBURN_TIME seconds at AFTERBURN_DPS. Standing in the fire keeps it topped up.
  const inFire = (o, r) => G.fires.some(f => Math.hypot(o.x - f.x, o.y - f.y) < FIRE_R + r * 0.5);
  const burn = (o, r, hurt, clip) => {
    if (inFire(o, r)) { o.burnT = AFTERBURN_TIME; o.burnTick = 0.25; o.burnAcc = 0; }
    else if (o.burnT > 0) {
      // damage is dealt in 0.25s ticks for exactly the burn time that passed (3s -> 30 damage)
      const step = Math.min(dt, o.burnT);
      o.burnT -= step; o.burnAcc += step; o.burnTick -= dt;
      if (o.burnTick <= 0 || o.burnT <= 0) { hurt(AFTERBURN_DPS * o.burnAcc); o.burnAcc = 0; o.burnTick += 0.25; }
    }
    if (o.burnT > 0 && Math.random() < dt * 30) {
      puff(o.x + rand(-8, 8), o.y + rand(-8, 8), 1, { speed: [5, 25], life: [0.25, 0.5], size: [3, 6], grow: -5, color: Math.random() < 0.5 ? '255,140,30' : '255,200,70', glow: true, drag: 2, rise: 35, clip });
    }
  };
  for (const e of G.enemies) {
    if (e.dead) continue;
    burn(e, ENEMY_R, d => damageEnemy(e, d, true), true);
    if (e.burnT > 0 && !e.dead) {
      e.screamT = (e.screamT || 0) - dt;
      if (e.screamT <= 0) { e.screamT = rand(0.8, 1.2); Sfx.scream(voiceVol(e.x, e.y), e.voice || (e.voice = rand(0.85, 1.2))); }
    } else e.screamT = 0;
  }
  if (!p.dead) burn(p, PLAYER_R, d => damagePlayer(d, p.x, p.y, true), false);

  // enemies get out of the fire
  for (const e of G.enemies) {
    if (e.dead) continue;
    for (const f of G.fires) {
      const dx = e.x - f.x, dy = e.y - f.y, d = Math.hypot(dx, dy) || 1;
      if (d < FIRE_R + ENEMY_R) moveCircle(e, dx / d * 110 * dt, dy / d * 110 * dt, ENEMY_R);
    }
  }
  for (const s of G.smokes) s.t -= dt;
  prune(G.smokes, s => s.t > 0);
}

function explode(g) {
  if (g.kind === 'molotov') { ignite(g.x, g.y); return; }
  if (g.kind === 'smoke') { popSmoke(g.x, g.y); return; }
  const p = G.player;
  const pd = Math.hypot(p.x - g.x, p.y - g.y);
  if (g.kind === 'frag') {
    const R = 210;
    for (const e of G.enemies) {
      if (e.dead) continue;
      const d = Math.hypot(e.x - g.x, e.y - g.y);
      if (d < R && los(g.x, g.y, e.x, e.y, SOLID_SIGHT)) damageEnemy(e, 170 * (1 - d / R) + 10);
    }
    for (const c of G.crawlers) if (!c.done && Math.hypot(c.x - g.x, c.y - g.y) < R) finishCrawler(c);
    if (pd < R && los(g.x, g.y, p.x, p.y, SOLID_SIGHT)) damagePlayer(150 * (1 - pd / R), g.x, g.y);
    if (pd < R && !p.dead) Sfx.ring(5, 2); // caught in the blast: ears ring for 5s, then fade
    puff(g.x, g.y, 30, { speed: [100, 520], life: [0.15, 0.45], size: [2, 5], color: '255,170,60', glow: true, drag: 5 });
    puff(g.x, g.y, 22, { speed: [20, 160], life: [0.8, 1.8], size: [8, 16], grow: 30, color: '70,66,60', drag: 2.5, rise: 10 });
    puff(g.x, g.y, 16, { speed: [80, 300], life: [0.4, 0.9], size: [1.5, 3], color: '40,36,30', drag: 3 });
    light(g.x, g.y, 420, 0.3, '255,160,60', false);
    paintScorch(g.x, g.y);
    G.shake = Math.min(22, G.shake + clamp(18 - pd / 60, 3, 18));
    Sfx.explosion(clamp(1.2 - pd / 1500, 0.15, 1));
    makeNoise(g.x, g.y, 1300);
  } else {
    const R = 420;
    for (const e of G.enemies) {
      if (e.dead) continue;
      const d = Math.hypot(e.x - g.x, e.y - g.y);
      if (d < R && los(g.x, g.y, e.x, e.y, SOLID_SIGHT)) {
        e.stunT = 4.2 * (1 - (d / R) * 0.5);
        e.state = 'alert'; e.lastSeen = { x: p.x, y: p.y }; e.reactT = 0.6; e.path = null;
      }
    }
    if (pd < R && los(g.x, g.y, p.x, p.y, SOLID_SIGHT)) {
      const facing = Math.abs(angDiff(Math.atan2(g.y - p.y, g.x - p.x), p.angle)) < 1.3;
      p.flashT = Math.max(p.flashT, (facing ? 3.2 : 1.0) * (1 - (pd / R) * 0.6));
    }
    puff(g.x, g.y, 24, { speed: [100, 420], life: [0.1, 0.3], size: [2, 4], color: '255,255,240', glow: true, drag: 6 });
    puff(g.x, g.y, 10, { speed: [20, 90], life: [0.8, 1.6], size: [6, 10], grow: 22, color: '200,200,200', drag: 2.5 });
    light(g.x, g.y, 600, 0.35, '255,255,255', false);
    Sfx.flashbang(clamp(1.2 - pd / 1500, 0.15, 1));
    if (pd < R && !p.dead) Sfx.ring(5, 2);
    makeNoise(g.x, g.y, 900);
  }
}

// ---------------------------------------------------------------- loot

function nearestPile(range) {
  const p = G.player;
  let best = null, bd = range;
  for (const pl of G.piles) {
    if (!pl.items.some(Boolean) && pl.kind !== 'cache') continue;
    const d = Math.hypot(pl.x - p.x, pl.y - p.y);
    if (d < bd) { bd = d; best = pl; }
  }
  return best;
}
// F: call the helicopter / board it when at the pad, otherwise loot or open the inventory.
function interact() {
  if (G.over || G.player.dead) return;
  if (G.exfilState === 'idle' && atExfil()) { callExtraction(); return; }
  if (G.exfilState === 'landed' && atExfil(40)) { Sfx.extract(); endGame(true); return; }
  openInventory();
}
function openInventory() {
  if (G.over || G.player.dead) return;
  const pile = nearestPile(LOOT_RANGE);
  Inv.open(pile || { x: G.player.x, y: G.player.y, kind: 'ground', items: new Array(6).fill(null), virtual: true });
}

// ---------------------------------------------------------------- update

function updatePlayer(dt) {
  const p = G.player;
  p.fireCd -= dt; p.swapT -= dt; p.throwCd -= dt; p.sprintOut -= dt;
  p.flashT = Math.max(0, p.flashT - dt);
  p.recoil = Math.max(0, p.recoil - dt * 8);
  if (p.dead || G.over) return;

  if (!G.invOpen) {
    const m = mouseWorld();
    p.angle = Math.atan2(m.y - p.y, m.x - p.x);
  }
  let mx = 0, my = 0;
  if (keyDown('KeyW')) my -= 1;
  if (keyDown('KeyS')) my += 1;
  if (keyDown('KeyA')) mx -= 1;
  if (keyDown('KeyD')) mx += 1;
  const moving = mx !== 0 || my !== 0;
  p.sprinting = moving && (keyDown('ShiftLeft') || keyDown('ShiftRight')) && p.useT <= 0;
  if (p.sprinting) p.sprintOut = 0.15;
  const inst = curWeapon();
  const mob = inst ? WEAPONS[inst.id].mobility : 1.05;
  const spd = PLAYER_SPEED * mob * (p.sprinting ? SPRINT_MULT : 1) * (p.useT > 0 ? 0.55 : 1) * (G.charId === 'hauler' ? HAULER_SPEED : 1);
  if (moving) {
    const l = Math.hypot(mx, my);
    moveCircle(p, mx / l * spd * dt, my / l * spd * dt, PLAYER_R);
    p.walk += spd * dt * 0.06;
    if (p.sprinting) { p.stepT -= dt; if (p.stepT <= 0) { p.stepT = 0.4; makeNoise(p.x, p.y, G.charId === 'assaulter' ? 80 : 230); } }
  }

  if (p.reloadT > 0) {
    p.reloadT -= dt;
    if (p.reloadT <= 0) { p.reloadT = 0; finishReload(); }
  }
  if (p.autoReload && p.fireCd <= 0 && p.reloadT <= 0) {
    p.autoReload = false;
    const w = curWeapon();
    if (w && w.mag === 0 && (w.reserve > 0 || p.items.some(it => it && it.id === 'ammo'))) startReload();
  }
  if (p.useT > 0) {
    p.useT -= dt;
    if (p.useT <= 0) {
      p.useT = 0;
      const it = p.items[p.useSlot];
      if (it && ITEMS[it.id].kind === 'medical') {
        const heal = ITEMS[it.id].heal;
        p.hp = Math.min(100, p.hp + heal);
        p.items[p.useSlot] = null;
        Sfx.heal();
        toast(`+${heal} HEALTH`);
        ensureEquip();
      }
    }
  }

  if (!G.invOpen && (input.mdown || input.clicked)) {
    if (p.equip === 'primary' || p.equip === 'secondary') fireGun();
    else if (p.equip === 'throwable') throwItem();
    else if (p.equip === 'medical') useMedical();
  }

}

// ---------------------------------------------------------------- extraction

function exfilPos() { return { x: (LEVEL.exfil[0] + 0.5) * TILE, y: (LEVEL.exfil[1] + 0.5) * TILE }; }
function atExfil(extra = 0) { const e = exfilPos(), p = G.player; return Math.hypot(p.x - e.x, p.y - e.y) < EXTRACT_R + extra; }

function callExtraction() {
  G.exfilState = 'calling';
  G.extractCalled = true; // the time penalty stops for good
  G.exfilT = G.diff.extractTime;
  G.spawnT = REINFORCE_EVERY;
  Sfx.radio();
  toast(`EXTRACTION CALLED - HELICOPTER INBOUND IN ${fmtClock(G.exfilT)}`, 4);
  toast('ENEMY REINFORCEMENTS INCOMING', 4);
}

function fmtClock(s) { s = Math.max(0, Math.ceil(s)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }

// One enemy somewhere the player can't see, 14-26 tiles away, that can walk to the player.
function spawnReinforcement() {
  const p = G.player;
  if (G.enemies.filter(e => !e.dead && e.reinforcement).length >= MAX_REINFORCEMENTS) return false;
  for (let i = 0; i < 60; i++) {
    const a = Math.random() * Math.PI * 2, d = rand(14, 26) * TILE;
    const tx = Math.floor((p.x + Math.cos(a) * d) / TILE), ty = Math.floor((p.y + Math.sin(a) * d) / TILE);
    if (blockedTile(tx, ty)) continue;
    const cx = (tx + 0.5) * TILE, cy = (ty + 0.5) * TILE;
    if (playerSees(cx, cy)) continue;            // never in the player's line of sight
    if (!findPath(cx, cy, p.x, p.y)) continue;   // must be able to reach the player
    const e = makeEnemy(tx, ty, false);
    e.reinforcement = true;
    e.state = 'search'; e.searchPt = { x: p.x + rand(-60, 60), y: p.y + rand(-60, 60) }; e.searchT = 40; e.path = null;
    G.enemies.push(e);
    G.reinforcements++;
    return true;
  }
  return false;
}

function updateExtraction(dt) {
  if (G.over || G.exfilState === 'idle') return;
  const p = G.player, e = exfilPos();
  // reinforcements keep coming until the player is aboard; they head for the player
  G.spawnT -= dt;
  if (G.spawnT <= 0) {
    G.spawnT += REINFORCE_EVERY;
    spawnReinforcement();
    for (const o of G.enemies) if (o.reinforcement && !o.dead && o.state !== 'alert' && !o.path) { o.state = 'search'; o.searchPt = { x: p.x, y: p.y }; o.searchT = 40; }
  }
  G.exfilT -= dt;
  if (G.exfilState === 'calling' && G.exfilT <= 0) {
    G.exfilState = 'landed'; G.exfilT = HELI_WAIT;
    Sfx.radio();
  } else if (G.exfilState === 'landed' && G.exfilT <= 0) {
    G.exfilState = 'leaving'; G.exfilT = HELI_APPROACH;
    toast('YOU MISSED THE HELICOPTER - CALL IT AGAIN AT THE PAD', 5);
  } else if (G.exfilState === 'leaving' && G.exfilT <= 0) {
    G.exfilState = 'idle';
  }
  // rotor noise while the helicopter is close
  const h = heliPose();
  if (h) {
    G.rotorT -= dt;
    if (G.rotorT <= 0) { G.rotorT = 0.11; Sfx.rotor(clamp(1.1 - Math.hypot(p.x - h.x, p.y - h.y) / 1600, 0, 0.8) * (h.alt > 1.3 ? 0.6 : 1)); }
    if (h.alt < 1.25 && Math.random() < dt * 25) puff(e.x + rand(-120, 120), e.y + rand(-120, 120), 1, { dir: Math.random() * 6.3, cone: 0, speed: [60, 160], life: [0.4, 0.9], size: [4, 9], grow: 16, color: '150,140,120', drag: 2 });
  }
}

// Where the helicopter is drawn: flying in during the last seconds of the countdown, on the pad,
// or flying off again. alt is a draw scale (1 = on the ground).
function heliPose() {
  const e = exfilPos();
  const away = { x: e.x - 1500, y: e.y + 900 };
  if (G.exfilState === 'calling' && G.exfilT <= HELI_APPROACH) {
    const k = 1 - G.exfilT / HELI_APPROACH, s = 1 - (1 - k) * (1 - k);
    return { x: away.x + (e.x - away.x) * s, y: away.y + (e.y - away.y) * s, alt: 1 + (1 - s) * 0.9 };
  }
  if (G.exfilState === 'landed') return { x: e.x, y: e.y, alt: 1 };
  if (G.exfilState === 'leaving') {
    const k = 1 - G.exfilT / HELI_APPROACH, s = k * k;
    return { x: e.x + (e.x + 1500 - e.x) * s, y: e.y - 900 * s, alt: 1 + s * 0.9 };
  }
  return null;
}

function drawHeli(h) {
  const rot = G.time * 28, heading = -0.55;
  // shadow on the ground, offset more the higher it is
  const sh = (h.alt - 1) * 160;
  ctx.save(); ctx.translate(h.x + 10 + sh, h.y + 14 + sh * 0.6); ctx.rotate(heading);
  ctx.fillStyle = 'rgba(0,0,0,0.3)'; rrect(ctx, -52, -18, 84, 36, 16); ctx.fill(); ctx.fillRect(-115, -5, 64, 10);
  ctx.restore();
  ctx.save(); ctx.translate(h.x, h.y); ctx.scale(h.alt, h.alt); ctx.rotate(heading);
  // skids
  ctx.strokeStyle = '#1c1d1a'; ctx.lineWidth = 3; ctx.lineCap = 'round';
  seg(ctx, -40, -22, 28, -22); seg(ctx, -40, 22, 28, 22);
  // tail boom, fin, tail rotor
  ctx.fillStyle = '#343c2c'; ctx.fillRect(-118, -4, 70, 8);
  ctx.fillStyle = '#2c3326'; ctx.fillRect(-122, -14, 10, 28);
  ctx.save(); ctx.translate(-117, 15); ctx.rotate(rot * 1.6); ctx.fillStyle = 'rgba(20,20,20,0.8)'; ctx.fillRect(-12, -1.5, 24, 3); ctx.restore();
  // fuselage
  const g = ctx.createLinearGradient(0, -18, 0, 18); g.addColorStop(0, '#56614a'); g.addColorStop(1, '#2f3628');
  ctx.fillStyle = g; rrect(ctx, -54, -18, 86, 36, 17); ctx.fill();
  ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1.5; rrect(ctx, -54, -18, 86, 36, 17); ctx.stroke();
  ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(-30, -17, 2, 34); ctx.fillRect(-6, -17, 2, 34);   // door seams
  ctx.fillStyle = '#20251c'; ctx.fillRect(-28, -16, 20, 3); ctx.fillRect(-28, 13, 20, 3);          // open doors
  const gl = ctx.createLinearGradient(14, -14, 34, 14); gl.addColorStop(0, '#8fb0c4'); gl.addColorStop(1, '#34505f');
  ctx.fillStyle = gl; ctx.beginPath(); ctx.ellipse(20, 0, 13, 13, 0, -Math.PI / 2, Math.PI / 2); ctx.lineTo(14, 13); ctx.lineTo(14, -13); ctx.closePath(); ctx.fill();
  ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(20, -9, 6, 2);
  ctx.fillStyle = '#e8e6df'; ctx.font = 'bold 9px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('B-07', -42, 0);
  // main rotor: blur disc + blades
  ctx.fillStyle = 'rgba(20,20,20,0.12)'; ctx.beginPath(); ctx.arc(-10, 0, 108, 0, Math.PI * 2); ctx.fill();
  ctx.save(); ctx.translate(-10, 0); ctx.rotate(rot);
  ctx.fillStyle = 'rgba(18,18,18,0.85)';
  for (let k = 0; k < 4; k++) { ctx.rotate(Math.PI / 2); ctx.fillRect(4, -3.5, 104, 7); }
  ctx.restore();
  ell(ctx, -10, 0, 7, 7, '#2a2d26'); ell(ctx, -11, -1, 3, 3, '#5a5f52');
  // navigation lights
  if (Math.sin(G.time * 6) > 0) { ell(ctx, -120, 0, 3, 3, '#ff4030'); ell(ctx, 30, -16, 2.5, 2.5, '#5aff6a'); }
  ctx.restore();
}

function updateParticles(dt) {
  for (const q of G.particles) {
    q.life -= dt;
    const f = Math.exp(-q.drag * dt);
    q.vx *= f; q.vy *= f;
    q.x += q.vx * dt; q.y += q.vy * dt - q.rise * dt;
    q.size += q.grow * dt;
  }
  prune(G.particles, q => q.life > 0);
  for (const l of G.lights) l.t -= dt;
  prune(G.lights, l => l.t > 0);
}

function update(dt) {
  G.time += G.over ? 0 : dt;
  // every full minute without extracting takes TIME_PENALTY off this deployment's payout
  // (no time penalty in the campaign)
  if (!G.over && !G.campaign && !G.extractCalled && Math.floor(G.time / 60) > G.minutesCharged) {
    G.minutesCharged++;
    G.penalty += TIME_PENALTY;
    toast(`${G.minutesCharged} MIN WITHOUT EXTRACTING  -$${TIME_PENALTY}`, 3);
    Sfx.deny();
  }
  updatePlayer(dt);
  for (const e of G.enemies) if (!e.dead) updateEnemy(e, dt);
  updateBodies(dt);
  updateFootsteps();
  separate();
  // enemies outside the field of view give themselves away with a small ripple
  for (const e of G.enemies) {
    if (e.dead) continue;
    e.rippleT -= dt;
    if (e.rippleT <= 0) {
      e.rippleT = rand(1.3, 2.1);
      if (!playerSees(e.x, e.y)) G.ripples.push({ x: e.x, y: e.y, t: 0 });
    }
  }
  for (const r of G.ripples) r.t += dt;
  prune(G.ripples, r => r.t < RIPPLE_TIME);
  updateBullets(dt);
  updateGrenades(dt);
  updateKnives(dt);
  updateFires(dt);
  updateExtraction(dt);
  updateParticles(dt);

  // green flare smoke over the extraction point
  G.smokeT -= dt;
  if (heliPose()) G.smokeT = 1; // the marker flare burns out once the helicopter is on its way in
  if (G.smokeT <= 0) {
    G.smokeT = 0.08;
    const ex = (LEVEL.exfil[0] + 0.5) * TILE + 40, ey = (LEVEL.exfil[1] + 0.5) * TILE + 40;
    puff(ex, ey, 1, { dir: -1.9, cone: 0.5, speed: [10, 40], life: [2.5, 4], size: [8, 12], grow: 18, color: '90,200,90', drag: 0.6, rise: 18 });
  }

  for (const t of G.toasts) t.t -= dt;
  prune(G.toasts, t => t.t > 0);
  for (const f of G.floaters) f.t -= dt;
  prune(G.floaters, f => f.t > 0);
  for (const d of G.dmgInd) d.t -= dt;
  prune(G.dmgInd, d => d.t > 0);
  G.hitT -= dt;
  G.armourHitT = Math.max(0, (G.armourHitT || 0) - dt);
  G.hurtFlash = Math.max(0, G.hurtFlash - dt);
  G.crossBloom = Math.max(0, G.crossBloom - dt * 3);
  G.shake *= Math.exp(-10 * dt);

  if (G.invOpen && G.invPile && !G.invPile.virtual) {
    const p = G.player;
    if (Math.hypot(G.invPile.x - p.x, G.invPile.y - p.y) > LOOT_RANGE + 30) Inv.close();
  }

  const p = G.player;
  const look = G.invOpen || p.dead ? { x: 0, y: 0 } : { x: (input.mx - W / 2) / VIEW_SCALE * 0.3, y: (input.my - H / 2) / VIEW_SCALE * 0.3 };
  const k = 1 - Math.exp(-7 * dt);
  G.cam.x += (p.x + look.x - G.cam.x) * k;
  G.cam.y += (p.y + look.y - G.cam.y) * k;

  if (G.over && !G.endShown) {
    G.overT -= dt;
    if (G.overT <= 0) { G.endShown = true; UI.showEnd(); }
  }
}

// ---------------------------------------------------------------- render

// The player's sight: the field-of-view cone plus a small circle all the way around them.
// Both are cut off by walls and smoke. pts[0] and pts[cone] are the two edges of the cone.
function visibilityPolygon(px, py, ang) {
  const pts = [];
  const ray = (a, range) => {
    const dx = Math.cos(a), dy = Math.sin(a);
    let d = castRay(px, py, dx, dy, range, SOLID_SIGHT);
    if (d < range) d += 10; // show the face of the wall you're looking at
    d = smokeLimit(px, py, dx, dy, d);
    pts.push([px + dx * d, py + dy * d]);
  };
  const n = 150;
  for (let i = 0; i <= n; i++) ray(ang - FOV / 2 + (FOV * i) / n, VIEW_RANGE);
  const back = Math.PI * 2 - FOV, m = 60;
  for (let i = 1; i < m; i++) ray(ang + FOV / 2 + (back * i) / m, NEAR_SIGHT);
  pts.cone = n;
  return pts;
}

function drawPiles(dim) {
  for (const pl of G.piles) {
    const has = pl.items.some(Boolean);
    if (pl.kind === 'cache') {
      // military supply crate: lid, handles, latches, stencil; open and empty once looted
      ctx.save(); ctx.translate(pl.x, pl.y);
      const c = (col, grey) => (dim ? grey : col);
      ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(-14, -8, 34, 22);
      ctx.fillStyle = c('#4a5a35', '#3b3d3a'); ctx.fillRect(-17, -11, 34, 22);
      ctx.fillStyle = c('#5a6b42', '#474946'); ctx.fillRect(-17, -11, 34, 3);
      ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(-17, 9, 34, 2);
      ctx.fillStyle = c('#2a3220', '#2a2c2a'); ctx.fillRect(-20, -4, 3, 8); ctx.fillRect(17, -4, 3, 8);      // handles
      if (has) {
        ctx.fillStyle = c('#38452a', '#2c2e2c'); ctx.fillRect(-17, -11, 34, 7);                           // lid
        ctx.fillStyle = c('#8a8d84', '#5e5f5c'); ctx.fillRect(-12, -5, 4, 3); ctx.fillRect(8, -5, 4, 3);  // latches
        ctx.fillStyle = c('#d6b23a', '#6b6b66'); ctx.font = 'bold 7px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText('SUPPLY', 0, 4);
      } else {
        ctx.fillStyle = '#141612'; ctx.fillRect(-14, -8, 28, 16);                                            // open, empty
        ctx.fillStyle = c('#38452a', '#2c2e2c'); ctx.save(); ctx.translate(-17, -11); ctx.rotate(-0.35); ctx.fillRect(0, -7, 34, 6); ctx.restore();
      }
      ctx.restore();
    } else if (has && pl.kind !== 'body') {
      // dropped gear bag
      ell(ctx, pl.x + 2, pl.y + 3, 12, 9, 'rgba(0,0,0,0.3)');
      ell(ctx, pl.x, pl.y, 12, 9, dim ? '#3f403d' : '#4b4a3a');
      ctx.fillStyle = dim ? '#2f302d' : '#35342a'; ctx.fillRect(pl.x - 2, pl.y - 9, 4, 18);
      ell(ctx, pl.x - 4, pl.y - 3, 4, 3, dim ? '#55554f' : '#6d6a50');
    }
    if (!dim && has) {
      const pulse = 0.35 + Math.sin(G.time * 4) * 0.2;
      ctx.strokeStyle = `rgba(232,197,71,${pulse})`; ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(pl.x, pl.y, 20, 0, Math.PI * 2); ctx.stroke();
    }
  }
}

function drawLights(clipped) {
  ctx.globalCompositeOperation = 'lighter';
  for (const l of G.lights) {
    if (l.clip !== clipped) continue;
    const a = clamp(l.t / l.max, 0, 1) * 0.55;
    const gr = ctx.createRadialGradient(l.x, l.y, 0, l.x, l.y, l.r);
    gr.addColorStop(0, `rgba(${l.color},${a})`); gr.addColorStop(1, `rgba(${l.color},0)`);
    ctx.fillStyle = gr; ctx.fillRect(l.x - l.r, l.y - l.r, l.r * 2, l.r * 2);
  }
  ctx.globalCompositeOperation = 'source-over';
}

function drawParticles(clipped) {
  for (const q of G.particles) {
    if (q.clip !== clipped) continue;
    const a = clamp(q.life / q.max, 0, 1);
    ctx.fillStyle = q.rgb; ctx.globalAlpha = q.glow ? a : a * 0.7;
    ctx.beginPath(); ctx.arc(q.x, q.y, q.size, 0, Math.PI * 2); ctx.fill();
  }
  ctx.globalAlpha = 1;
}

const TRACER_STOPS = { player: ['rgba(255,230,150,0)', 'rgba(255,230,150,0.95)'], enemy: ['rgba(255,140,90,0)', 'rgba(255,140,90,0.95)'] };
function drawBullets(owner) {
  const [tail0, tail1] = owner === 'player' ? TRACER_STOPS.player : TRACER_STOPS.enemy;
  ctx.lineCap = 'round';
  for (const b of G.bullets) {
    if (b.owner !== owner) continue;
    const tail = Math.min(b.travel, 70);
    const tx = b.x - b.dx * tail, ty = b.y - b.dy * tail;
    const gr = ctx.createLinearGradient(tx, ty, b.x, b.y);
    gr.addColorStop(0, tail0); gr.addColorStop(1, tail1);
    ctx.strokeStyle = gr; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(b.x, b.y); ctx.stroke();
  }
}

function render() {
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.fillStyle = '#0b0c0d'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  if (!G) return;
  const p = G.player, s = VIEW_SCALE;
  const shx = (Math.random() - 0.5) * G.shake, shy = (Math.random() - 0.5) * G.shake;
  const ox = W / 2 - G.cam.x * s + shx, oy = H / 2 - G.cam.y * s + shy;
  ctx.setTransform(DPR * s, 0, 0, DPR * s, DPR * ox, DPR * oy);

  // everything outside the field of view is grayed out
  ctx.drawImage(ART.gray, 0, 0);
  drawPiles(true);

  const vis = visibilityPolygon(p.x, p.y, p.angle);
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(vis[0][0], vis[0][1]);
  for (let i = 1; i < vis.length; i++) ctx.lineTo(vis[i][0], vis[i][1]);
  ctx.closePath();
  ctx.clip();
  ctx.drawImage(ART.color, 0, 0);
  drawPiles(false);
  const gr = ctx.createRadialGradient(p.x, p.y, 80, p.x, p.y, VIEW_RANGE);
  gr.addColorStop(0, 'rgba(0,0,0,0)'); gr.addColorStop(1, 'rgba(0,0,0,0.5)');
  ctx.fillStyle = gr; ctx.fillRect(p.x - VIEW_RANGE, p.y - VIEW_RANGE, VIEW_RANGE * 2, VIEW_RANGE * 2);
  const cullX = W / 2 / s + 60, cullY = H / 2 / s + 60;
  ctx.font = `bold 16px ${FONT}`; ctx.textAlign = 'center';
  for (const e of G.enemies) {
    if (e.dead || Math.abs(e.x - G.cam.x) > cullX || Math.abs(e.y - G.cam.y) > cullY) continue;
    drawSoldier(ctx, { x: e.x, y: e.y, a: e.angle, pal: PAL_ENEMY, weapon: e.weapon, hold: 'gun', walk: e.walk, recoil: e.recoil, hurt: e.hurtT });
    if (e.stunT > 0 || e.state === 'alert' || e.state === 'search') {
      ctx.fillStyle = e.stunT > 0 ? '#e8e6df' : e.state === 'alert' ? '#e0402f' : '#e8c547';
      ctx.fillText(e.stunT > 0 ? '~' : e.state === 'alert' ? '!' : '?', e.x, e.y - 22);
    }
  }
  drawBodies();
  drawBullets('enemy');
  drawParticles(true);
  drawLights(true);
  ctx.restore();

  // fires
  for (const f of G.fires) {
    const fade = Math.min(1, f.t / 0.6, (FIRE_TIME - f.t) / 0.2);
    ctx.globalCompositeOperation = 'lighter';
    const fl = 0.85 + Math.random() * 0.15;
    const gr = ctx.createRadialGradient(f.x, f.y, 4, f.x, f.y, FIRE_R * 1.25);
    gr.addColorStop(0, `rgba(255,170,60,${0.55 * fade * fl})`);
    gr.addColorStop(0.65, `rgba(230,80,20,${0.35 * fade * fl})`);
    gr.addColorStop(1, 'rgba(200,40,10,0)');
    ctx.fillStyle = gr; ctx.beginPath(); ctx.arc(f.x, f.y, FIRE_R * 1.25, 0, Math.PI * 2); ctx.fill();
    ctx.globalCompositeOperation = 'source-over';
  }

  // ripples from unseen enemies
  for (const r of G.ripples) {
    const k = r.t / RIPPLE_TIME;
    ctx.strokeStyle = `rgba(255,255,255,${(1 - k) * 0.75})`;
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(r.x, r.y, 5 + k * 28, 0, Math.PI * 2); ctx.stroke();
    if (k > 0.25) {
      ctx.strokeStyle = `rgba(255,255,255,${(1 - k) * 0.4})`;
      ctx.beginPath(); ctx.arc(r.x, r.y, 5 + (k - 0.25) * 28, 0, Math.PI * 2); ctx.stroke();
    }
  }

  // field of view edges
  ctx.strokeStyle = 'rgba(230,240,210,0.14)'; ctx.lineWidth = 1.5 / s;
  ctx.beginPath();
  ctx.moveTo(vis[0][0], vis[0][1]); ctx.lineTo(p.x, p.y); ctx.lineTo(vis[vis.cone][0], vis[vis.cone][1]);
  ctx.stroke();

  // extraction zone
  const ex = (LEVEL.exfil[0] + 0.5) * TILE, ey = (LEVEL.exfil[1] + 0.5) * TILE;
  ctx.strokeStyle = `rgba(120,230,110,${0.45 + Math.sin(G.time * 3) * 0.2})`;
  ctx.lineWidth = 3; ctx.setLineDash([14, 10]);
  ctx.beginPath(); ctx.arc(ex, ey, EXTRACT_R, G.time * 0.3, G.time * 0.3 + Math.PI * 2); ctx.stroke();
  ctx.setLineDash([]);

  drawKnives();
  for (const g of G.grenades) {
    ell(ctx, g.x + 3, g.y + 3, 5, 4, 'rgba(0,0,0,0.35)');
    const r = 5 + g.z * 0.05;
    ctx.save(); ctx.translate(g.x, g.y - g.z * 0.35); ctx.rotate(g.spin);
    ell(ctx, 0, 0, r, r * 0.85, ({ frag: '#3f4a2e', flash: '#5b6066', smoke: '#5c6660', molotov: '#7a9a5a' })[g.kind]);
    ctx.fillStyle = '#9a9c95'; ctx.fillRect(-1.5, -r - 2, 3, 3);
    ctx.restore();
    if (g.kind === 'frag' && g.fuse < 1.2 && Math.sin(g.fuse * 30) > 0) ell(ctx, g.x, g.y - g.z * 0.35, 2, 2, '#ff4030');
  }
  drawBullets('player');
  if (!p.dead) {
    const hold = p.equip === 'primary' || p.equip === 'secondary' ? 'gun' : p.equip === 'throwable' ? 'grenade' : p.equip === 'medical' ? 'med' : null;
    const inst = curWeapon();
    drawSoldier(ctx, { x: p.x, y: p.y, a: p.angle, pal: G.charPal, weapon: inst ? inst.id : null, hold, walk: p.walk, recoil: p.recoil, hurt: G.hurtFlash > 0.3 ? 0.05 : 0, player: true });
  }
  // smoke clouds
  for (const sm of G.smokes) {
    const R = smokeR(sm), fade = Math.min(1, sm.t / 1.5);
    if (R < 2) continue;
    for (const q of sm.puffs) {
      const a = (SMOKE_TIME - sm.t) * q.spin;
      const px2 = sm.x + (q.ox * Math.cos(a) - q.oy * Math.sin(a)) * R, py2 = sm.y + (q.ox * Math.sin(a) + q.oy * Math.cos(a)) * R;
      const rr = q.s * R * 1.25;
      ctx.globalAlpha = fade;
      ctx.drawImage(smokePuffSprite(q.shade), px2 - rr, py2 - rr, rr * 2, rr * 2);
    }
    ctx.globalAlpha = fade;
    ctx.drawImage(smokeCoreSprite(), sm.x - R, sm.y - R, R * 2, R * 2);
  }
  ctx.globalAlpha = 1;

  // max level weapons carry a laser sight that points at the cursor
  const lw = curWeapon();
  if (lw && !p.dead && !G.over && weaponLevel(lw.id) >= MAX_WEAPON_LEVEL) {
    const mz = muzzleWorld(p.x, p.y, p.angle, lw.id), m = mouseWorld();
    const la = Math.atan2(m.y - mz.y, m.x - mz.x), dx = Math.cos(la), dy = Math.sin(la);
    let len = castRay(mz.x, mz.y, dx, dy, VIEW_RANGE, SOLID_SHOT);
    for (const e of G.enemies) {
      if (e.dead) continue;
      const t = segCircle(mz.x, mz.y, dx, dy, len, e.x, e.y, ENEMY_R);
      if (t >= 0) len = t;
    }
    const ex2 = mz.x + dx * len, ey2 = mz.y + dy * len;
    ctx.lineCap = 'round';
    ctx.strokeStyle = 'rgba(255,40,30,0.18)'; ctx.lineWidth = 4;
    ctx.beginPath(); ctx.moveTo(mz.x, mz.y); ctx.lineTo(ex2, ey2); ctx.stroke();
    ctx.strokeStyle = 'rgba(255,70,50,0.85)'; ctx.lineWidth = 1.2;
    ctx.beginPath(); ctx.moveTo(mz.x, mz.y); ctx.lineTo(ex2, ey2); ctx.stroke();
    ell(ctx, ex2, ey2, 5, 5, 'rgba(255,60,40,0.3)');
    ell(ctx, ex2, ey2, 2.2, 2.2, '#ff5a40');
  }
  drawParticles(false);
  drawLights(false);
  // the helicopter flies above everything else in the world
  const heli = heliPose();
  if (heli) drawHeli(heli);

  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  drawHUD(ox, oy);
}

// ---------------------------------------------------------------- HUD

let hudFont = '';
function hudText(text, x, y, size, color, align = 'left', weight = 600) {
  const font = `${weight} ${size}px ${FONT}`;
  if (font !== hudFont) { ctx.font = font; hudFont = font; }
  ctx.textAlign = align; ctx.textBaseline = 'alphabetic';
  ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillText(text, x + 1, y + 1);
  ctx.fillStyle = color; ctx.fillText(text, x, y);
}

let hurtVignette = null;
function drawHUD(ox, oy) {
  const p = G.player, s = VIEW_SCALE;
  hudFont = ''; // render() sets ctx.font elsewhere

  // low health / damage vignette
  const hurt = (1 - p.hp / 100) * 0.55 + G.hurtFlash;
  if (hurt > 0.02) {
    if (!hurtVignette) {
      hurtVignette = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.3, W / 2, H / 2, Math.max(W, H) * 0.75);
      hurtVignette.addColorStop(0, 'rgba(150,0,0,0)'); hurtVignette.addColorStop(1, 'rgb(150,0,0)');
    }
    ctx.fillStyle = hurtVignette; ctx.globalAlpha = Math.min(0.85, hurt);
    ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
  }

  // damage direction indicators
  for (const d of G.dmgInd) {
    const a = d.a;
    ctx.strokeStyle = `rgba(230,40,30,${Math.min(1, d.t) * 0.85})`;
    ctx.lineWidth = 8;
    ctx.beginPath(); ctx.arc(W / 2, H / 2, 120, a - 0.28, a + 0.28); ctx.stroke();
  }

  // extraction marker
  const ex = (LEVEL.exfil[0] + 0.5) * TILE, ey = (LEVEL.exfil[1] + 0.5) * TILE;
  let sx = ox + ex * s, sy = oy + ey * s;
  const meters = Math.round(Math.hypot(ex - p.x, ey - p.y) / 32);
  // off-screen: clamp to a box that keeps clear of the objective panel and the bottom HUD
  const bx0 = 40, bx1 = W - 40, by0 = 120, by1 = H - 180;
  const off = sx < bx0 || sy < by0 || sx > bx1 || sy > by1;
  if (off) {
    const a = Math.atan2(sy - H / 2, sx - W / 2), dx = Math.cos(a), dy = Math.sin(a);
    let k = Infinity;
    if (dx > 0) k = Math.min(k, (bx1 - W / 2) / dx);
    if (dx < 0) k = Math.min(k, (bx0 - W / 2) / dx);
    if (dy > 0) k = Math.min(k, (by1 - H / 2) / dy);
    if (dy < 0) k = Math.min(k, (by0 - H / 2) / dy);
    sx = W / 2 + dx * k; sy = H / 2 + dy * k;
    ctx.save(); ctx.translate(sx, sy); ctx.rotate(a);
    ctx.fillStyle = '#7fe36a'; ctx.beginPath(); ctx.moveTo(16, 0); ctx.lineTo(4, -8); ctx.lineTo(4, 8); ctx.closePath(); ctx.fill();
    ctx.restore();
  }
  ctx.save(); ctx.translate(sx, sy - (off ? 0 : 30)); ctx.rotate(Math.PI / 4);
  ctx.strokeStyle = '#7fe36a'; ctx.lineWidth = 2; ctx.strokeRect(-7, -7, 14, 14);
  ctx.restore();
  hudText('EXTRACT', sx, sy - (off ? 16 : 46), 12, '#7fe36a', 'center');
  hudText(`${meters}m`, sx, sy + (off ? 24 : -8), 12, '#cfe9c7', 'center');

  // objective
  ctx.fillStyle = 'rgba(10,12,10,0.55)'; ctx.fillRect(18, 18, 300, 96);
  ctx.fillStyle = '#a5d63f'; ctx.fillRect(18, 18, 3, 96);
  hudText(`${G.campaign ? `CAMPAIGN  ${G.campaignStage + 1} / ${MAPS.length}  -  ${LEVEL.name}` : 'OBJECTIVE'}  -  ${G.diff.name}`, 32, 38, 12, '#a5d63f');
  hudText(G.exfilState === 'calling' ? 'Survive until the helicopter arrives' : G.exfilState === 'landed' ? 'Board the helicopter' : 'Call extraction at the helipad', 32, 58, 16, '#e6eadf');
  const mm = Math.floor(G.time / 60), ss = Math.floor(G.time % 60);
  let val = 0;
  for (const it of p.items) if (it && ITEMS[it.id].kind === 'valuable') val += ITEMS[it.id].value;
  hudText(`${mm}:${String(ss).padStart(2, '0')}   KILLS ${G.kills}   INTEL $${Math.round(val * G.diff.money)}`, 32, 80, 13, '#8c978a');
  const left = Math.ceil(60 - (G.time % 60));
  const payoutNow = Math.max(0, Math.round((val + G.kills * KILL_REWARD) * G.diff.money) - G.penalty);
  if (G.campaign) hudText(`TO FUNDS HOLD $${payoutNow.toLocaleString()}`, 32, 102, 13, '#e8c547');
  else if (G.extractCalled) hudText(`PAYOUT $${payoutNow.toLocaleString()}   TIME PENALTY STOPPED`, 32, 102, 13, '#7fe36a');
  else hudText(`PAYOUT $${payoutNow.toLocaleString()}   -$${TIME_PENALTY} IN ${left}s`, 32, 102, 13, left <= 10 ? '#e0402f' : '#e8c547');

  // health
  const hx = 24, hy = H - 46;
  hudText('HEALTH', hx, hy - 10, 12, '#8c978a');
  ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(hx, hy, 260, 16);
  ctx.fillStyle = p.hp > 50 ? '#e6eadf' : p.hp > 25 ? '#e8c547' : '#e0402f';
  ctx.fillRect(hx + 2, hy + 2, 256 * (p.hp / 100), 12);
  ctx.fillStyle = 'rgba(0,0,0,0.5)';
  for (let i = 1; i < 10; i++) ctx.fillRect(hx + 2 + i * 25.6, hy + 2, 1, 12);
  hudText(String(Math.ceil(p.hp)), hx + 272, hy + 14, 22, '#e6eadf');
  if (p.burnT > 0) hudText(`BURNING ${p.burnT.toFixed(1)}s`, hx + 320, hy + 14, 15, Math.sin(G.time * 12) > 0 ? '#ff8a30' : '#ffc060', 'left', 700);
  if (p.armour) {
    const a = ARMOURS[p.armour], max = armourMax(p.armour), ay = hy - 44;
    const broken = p.armourHp <= 0;
    hudText(broken ? `${a.name.toUpperCase()}  -  BROKEN` : `${a.name.toUpperCase()}  -${a.reduce} DMG PER HIT`, hx, ay - 6, 12, broken ? '#e0402f' : '#8fb8d8');
    ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(hx, ay, 260, 10);
    ctx.fillStyle = G.armourHitT > 0 ? '#e6f2ff' : '#5f9fd6';
    ctx.fillRect(hx + 2, ay + 2, 256 * (p.armourHp / max), 6);
    hudText(`${Math.ceil(p.armourHp)}/${max}`, hx + 272, ay + 10, 13, broken ? '#e0402f' : '#8fb8d8');
  }

  // weapon + ammo
  const inst = curWeapon();
  const rx = W - 24;
  if (inst) {
    const w = WEAPONS[inst.id];
    const lvl = weaponLevel(inst.id);
    hudText(w.name.toUpperCase() + (lvl ? `  LV ${lvl}` : ''), rx, H - 92, 16, lvl >= MAX_WEAPON_LEVEL ? '#e8c547' : '#e6eadf', 'right');
    const resTxt = ` / ${inst.reserve}`;
    ctx.font = hudFont = `600 18px ${FONT}`;
    const rw = ctx.measureText(resTxt).width;
    hudText(resTxt, rx, H - 50, 18, '#8c978a', 'right');
    hudText(String(inst.mag), rx - rw, H - 50, 40, inst.mag === 0 ? '#e0402f' : inst.mag <= magSize(inst.id) * 0.25 ? '#e8c547' : '#e6eadf', 'right', 700);
    if (inst.mag === 0 && p.reloadT <= 0) hudText(inst.reserve > 0 || p.items.some(it => it && it.id === 'ammo') ? 'RELOAD [R]' : 'NO AMMO', W / 2, H / 2 + 60, 15, '#e0402f', 'center');
  } else if (p.equip === 'throwable' || p.equip === 'medical') {
    const it = p.items[p.itemSlot];
    if (it) {
      hudText(ITEMS[it.id].name.toUpperCase(), rx, H - 92, 16, '#e6eadf', 'right');
      hudText(p.equip === 'throwable' ? 'CLICK TO THROW' : 'CLICK TO USE', rx, H - 54, 18, '#a5d63f', 'right', 700);
    }
  } else hudText('UNARMED', rx, H - 54, 18, '#8c978a', 'right');

  // equip strip
  const throwN = p.items.reduce((n, it) => n + (it && ITEMS[it.id].kind === 'throwable' ? it.uses || 1 : 0), 0);
  const medN = p.items.filter(it => it && ITEMS[it.id].kind === 'medical').length;
  const strip = [
    ['1', p.weapons[0] ? WEAPONS[p.weapons[0].id].name : '-', p.equip === 'primary', p.weapons[0]],
    ['2', p.weapons[1] ? WEAPONS[p.weapons[1].id].name : '-', p.equip === 'secondary', p.weapons[1]],
    ['3', `THROW x${throwN}`, p.equip === 'throwable', throwN ? p.items[p.equip === 'throwable' ? p.itemSlot : p.items.findIndex(it => it && ITEMS[it.id].kind === 'throwable')] : null],
    ['4', `MED x${medN}`, p.equip === 'medical', medN ? p.items[p.equip === 'medical' ? p.itemSlot : p.items.findIndex(it => it && ITEMS[it.id].kind === 'medical')] : null],
  ];
  let bx = W - 24 - strip.length * 108 + 8;
  for (const [key, label, on, it] of strip) {
    const by = H - 150;
    ctx.fillStyle = on ? 'rgba(165,214,63,0.22)' : 'rgba(10,12,10,0.5)';
    ctx.fillRect(bx, by, 100, 40);
    ctx.strokeStyle = on ? '#a5d63f' : 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
    ctx.strokeRect(bx + 0.5, by + 0.5, 99, 39);
    if (it) {
      const im = iconImg(it);
      if (im.complete) {
        if (it.type === 'weapon') ctx.drawImage(im, bx + 18, by + 2, 80, 32);
        else ctx.drawImage(im, bx + 62, by + 3, 34, 34);
      }
    }
    hudText(key, bx + 5, by + 14, 12, on ? '#a5d63f' : '#8c978a', 'left', 700);
    hudText(label.length > 13 ? label.slice(0, 12) + '.' : label, bx + 5, by + 35, 11, '#e6eadf');
    bx += 108;
  }

  // reload / use progress
  const prog = p.reloadT > 0 ? [1 - p.reloadT / p.reloadMax, 'RELOADING'] : p.useT > 0 ? [1 - p.useT / p.useMax, 'APPLYING'] : null;
  if (prog) {
    ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(W / 2 - 70, H / 2 + 46, 140, 6);
    ctx.fillStyle = '#e6eadf'; ctx.fillRect(W / 2 - 70, H / 2 + 46, 140 * prog[0], 6);
    hudText(prog[1], W / 2, H / 2 + 40, 12, '#e6eadf', 'center');
  }

  // interaction prompt: extraction takes priority over loot
  if (!G.invOpen && !p.dead && !G.over) {
    const pile = nearestPile(LOOT_RANGE);
    const exLabel = G.exfilState === 'idle' && atExfil() ? 'CALL EXTRACTION' : G.exfilState === 'landed' && atExfil(40) ? 'BOARD HELICOPTER' : null;
    if (exLabel || pile) {
      const label = exLabel || (pile.kind === 'cache' ? 'SEARCH SUPPLY CACHE' : pile.kind === 'body' ? 'SEARCH BODY' : 'PICK UP ITEMS');
      ctx.fillStyle = 'rgba(10,12,10,0.65)'; ctx.fillRect(W / 2 - 120, H - 120, 240, 30);
      hudText(`[F]  ${label}`, W / 2, H - 100, 15, '#e8c547', 'center');
    }
  }

  // extraction countdown
  if ((G.exfilState === 'calling' || G.exfilState === 'landed') && !G.over) {
    const landed = G.exfilState === 'landed';
    const total = landed ? HELI_WAIT : G.diff.extractTime;
    const f = 1 - G.exfilT / total;
    const col = landed ? (G.exfilT < 15 && Math.sin(G.time * 8) > 0 ? '#e0402f' : '#7fe36a') : '#e8c547';
    hudText(landed ? `HELICOPTER ON THE PAD - BOARD [F]  ${fmtClock(G.exfilT)}` : `HELICOPTER INBOUND  ${fmtClock(G.exfilT)}`, W / 2, 140, 24, col, 'center', 700);
    ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(W / 2 - 180, 152, 360, 8);
    ctx.fillStyle = col; ctx.fillRect(W / 2 - 180, 152, 360 * (landed ? 1 - f : f), 8);
    if (!landed) hudText(`HOLD OUT - REINFORCEMENTS EVERY ${REINFORCE_EVERY}s  (${G.reinforcements} SO FAR)`, W / 2, 178, 13, '#c9a0a0', 'center');
  }

  // toasts
  let ty = 190;
  for (const t of G.toasts) {
    const a = Math.min(1, t.t * 2, (t.max - t.t) * 6 + 0.2);
    ctx.globalAlpha = a;
    hudText(t.text, W / 2, ty, 17, '#e6eadf', 'center', 700);
    ctx.globalAlpha = 1;
    ty += 26;
  }

  // crosshair + hitmarker
  if (!G.invOpen && !p.dead && !G.over) {
    const cx = input.mx, cy = input.my;
    const gap = 6 + G.crossBloom * 8 + (p.sprinting ? 8 : 0);
    ctx.strokeStyle = 'rgba(0,0,0,0.6)'; ctx.lineWidth = 4;
    const arms = [[1, 0], [-1, 0], [0, 1], [0, -1]];
    for (const [dx, dy] of arms) { ctx.beginPath(); ctx.moveTo(cx + dx * gap, cy + dy * gap); ctx.lineTo(cx + dx * (gap + 9), cy + dy * (gap + 9)); ctx.stroke(); }
    ctx.strokeStyle = p.sprinting ? 'rgba(230,234,223,0.4)' : '#e6eadf'; ctx.lineWidth = 2;
    for (const [dx, dy] of arms) { ctx.beginPath(); ctx.moveTo(cx + dx * gap, cy + dy * gap); ctx.lineTo(cx + dx * (gap + 9), cy + dy * (gap + 9)); ctx.stroke(); }
    ell(ctx, cx, cy, 1.5, 1.5, '#e6eadf');
    if (G.hitT > 0) {
      ctx.strokeStyle = G.hitKill ? `rgba(230,50,40,${Math.min(1, G.hitT * 5)})` : `rgba(255,255,255,${Math.min(1, G.hitT * 6)})`;
      ctx.lineWidth = 2.5;
      for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) { ctx.beginPath(); ctx.moveTo(cx + dx * 7, cy + dy * 7); ctx.lineTo(cx + dx * 15, cy + dy * 15); ctx.stroke(); }
    }
    for (const f of G.floaters) hudText(f.text, cx + 24, cy - 20 - (1 - f.t) * 30, 16, `rgba(232,197,71,${f.t})`, 'left', 700);
  }

  // flashbang
  if (p.flashT > 0) {
    ctx.fillStyle = `rgba(255,255,250,${Math.min(1, p.flashT / 1.4)})`;
    ctx.fillRect(0, 0, W, H);
  }
}

// ---------------------------------------------------------------- loop

let lastFrame = performance.now();
// outside play the scene is frozen, so it is only redrawn when the mode, game or size changes
let frozenFrame = null;
function frame(now) {
  const dt = Math.min(0.05, (now - lastFrame) / 1000);
  lastFrame = now;
  if (MODE === 'play' && G) update(dt);
  if (MODE === 'play' || !frozenFrame || frozenFrame.mode !== MODE || frozenFrame.g !== G) {
    render();
    frozenFrame = MODE === 'play' ? null : { mode: MODE, g: G };
  }
  input.clicked = false;
  requestAnimationFrame(frame);
}