SOURCE / PINNED RELEASE
Made of little things.
Black Site
- Release
- 666bc7c74ebd…
- Author-recorded commit
- 05e95af79676…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391
Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
'use strict';
// BLACK SITE - 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);
}
