// BULLET HELL BOMZHARA - simulation step. The enemy is you: vodka is ammo AND sanity, blood is your last
// reserve, белочка spawns the ghosts. The bomzh loves VODKA only - every other drink is a demon. Roguelite
// level-ups between the killing. Your own bounced bottles are dangerous too.
import { state, player, bullets, enemies, parts, pickups, puddles, squirrel, cam, view, input, rememberHorror } from './state.js';
import { BAL, ARENA_INSET, BOOZE, UPGRADES } from './config.js';
import { clamp, len, rand, randInt, TAU } from './lib/math.js';
import { addTrauma } from './render/camera.js';
import { loadMapSolidDefs, hasStoredMapSolidDefs } from './map-colliders.js';
import { playSfx } from './audio.js';

export function arena() {
  return {
    x0: ARENA_INSET.side, y0: ARENA_INSET.top,
    x1: view.cssW - ARENA_INSET.side, y1: view.cssH - ARENA_INSET.bottom,
    cx: view.cssW * 0.5, cy: (ARENA_INSET.top + (view.cssH - ARENA_INSET.bottom)) * 0.5,
  };
}

var mapSolidDefs = null;
var mapColliderCache = [];
var mapSolidCache = [];
var mapEnemyHoleCache = [];
var playerMove = { x: 0, y: 0, hit: false, nx: 0, ny: 0 };
var enemyMove = { x: 0, y: 0, hit: false, nx: 0, ny: 0 };
var bulletResolve = { x: 0, y: 0, hit: false, nx: 0, ny: 0 };
var spawnResolve = { x: 0, y: 0, hit: false, nx: 0, ny: 0 };

function getMapSolidDefs() {
  if (!mapSolidDefs) mapSolidDefs = loadMapSolidDefs();
  return mapSolidDefs;
}

export function reloadMapSolidDefs() {
  mapSolidDefs = loadMapSolidDefs();
  mapColliderCache.length = 0;
  mapSolidCache.length = 0;
  mapEnemyHoleCache.length = 0;
  return mapSolidDefs;
}

export function mapSolidSource() {
  return hasStoredMapSolidDefs() ? 'localStorage' : 'default';
}

function fillMapRects(a, type, cache) {
  a = a || arena();
  var fw = a.x1 - a.x0, fh = a.y1 - a.y0;
  var defs = getMapSolidDefs();
  var n = 0;
  for (var i = 0; i < defs.length; i++) {
    var d = defs[i], kind = d.type === 'enemy-hole' ? 'enemy-hole' : 'solid';
    if (type && kind !== type) continue;
    var r = cache[n];
    if (!r) r = cache[n] = { id: d.id, type: kind, x0: 0, y0: 0, x1: 0, y1: 0, cx: 0, cy: 0, hw: 0, hh: 0 };
    r.id = d.id; r.type = kind;
    r.x0 = a.x0 + fw * d.x; r.y0 = a.y0 + fh * d.y;
    r.x1 = r.x0 + fw * d.w; r.y1 = r.y0 + fh * d.h;
    r.cx = (r.x0 + r.x1) * 0.5; r.cy = (r.y0 + r.y1) * 0.5;
    r.hw = (r.x1 - r.x0) * 0.5; r.hh = (r.y1 - r.y0) * 0.5;
    n++;
  }
  cache.length = n;
  return cache;
}

export function mapColliders(a) {
  return fillMapRects(a, '', mapColliderCache);
}

export function mapSolids(a) {
  return fillMapRects(a, 'solid', mapSolidCache);
}

export function mapEnemyHoles(a) {
  return fillMapRects(a, 'enemy-hole', mapEnemyHoleCache);
}

export function mapCircleBlocked(x, y, r, a) {
  var solids = mapSolids(a);
  for (var i = 0; i < solids.length; i++) {
    var s = solids[i];
    var cx = clamp(x, s.x0, s.x1), cy = clamp(y, s.y0, s.y1);
    var dx = x - cx, dy = y - cy;
    if (dx * dx + dy * dy < r * r) return true;
  }
  return false;
}

function circleOverlapsEnemyHole(x, y, r, a) {
  var holes = mapEnemyHoles(a);
  var pad = r * 0.85;
  for (var i = 0; i < holes.length; i++) {
    var h = holes[i];
    if (x >= h.x0 - pad && x <= h.x1 + pad && y >= h.y0 - pad && y <= h.y1 + pad) return true;
  }
  return false;
}

export function resolveCircleAgainstMap(x, y, r, a, out, allowEnemyHoles) {
  out = out || { x: 0, y: 0, hit: false, nx: 0, ny: 0 };
  out.x = x; out.y = y; out.hit = false; out.nx = 0; out.ny = 0;
  var solids = mapSolids(a);
  for (var i = 0; i < solids.length; i++) {
    var s = solids[i];
    if (allowEnemyHoles && circleOverlapsEnemyHole(out.x, out.y, r, a)) continue;
    var cx = clamp(out.x, s.x0, s.x1), cy = clamp(out.y, s.y0, s.y1);
    var dx = out.x - cx, dy = out.y - cy, d2 = dx * dx + dy * dy;
    if (d2 >= r * r) continue;
    var nx = 0, ny = 0, push = 0;
    if (d2 > 0.0001) {
      var d = Math.sqrt(d2);
      nx = dx / d; ny = dy / d; push = r - d + 0.05;
    } else {
      var left = Math.abs(out.x - s.x0), right = Math.abs(s.x1 - out.x);
      var top = Math.abs(out.y - s.y0), bottom = Math.abs(s.y1 - out.y);
      var m = Math.min(left, right, top, bottom);
      if (m === left) { nx = -1; push = r + left + 0.05; }
      else if (m === right) { nx = 1; push = r + right + 0.05; }
      else if (m === top) { ny = -1; push = r + top + 0.05; }
      else { ny = 1; push = r + bottom + 0.05; }
    }
    out.x += nx * push; out.y += ny * push;
    out.nx += nx; out.ny += ny; out.hit = true;
  }
  if (out.hit) {
    var nl = Math.hypot(out.nx, out.ny);
    if (nl > 0.0001) { out.nx /= nl; out.ny /= nl; }
  }
  return out;
}

function moveCircleOnMap(x, y, dx, dy, r, a, out, allowEnemyHoles, allowOffscreen) {
  out = out || { x: 0, y: 0, hit: false, nx: 0, ny: 0 };
  var minX = allowOffscreen ? -r - 96 : a.x0 + r;
  var minY = allowOffscreen ? -r - 96 : a.y0 + r;
  var maxX = allowOffscreen ? view.cssW + r + 96 : a.x1 - r;
  var maxY = allowOffscreen ? view.cssH + r + 96 : a.y1 - r;
  out.x = x + dx; out.y = y; out.hit = false; out.nx = 0; out.ny = 0;
  resolveCircleAgainstMap(out.x, out.y, r, a, out, allowEnemyHoles);
  var hit = out.hit, nx = out.nx, ny = out.ny;
  out.x = clamp(out.x, minX, maxX);
  out.y = clamp(out.y + dy, minY, maxY);
  resolveCircleAgainstMap(out.x, out.y, r, a, out, allowEnemyHoles);
  hit = hit || out.hit; nx += out.nx; ny += out.ny;
  out.x = clamp(out.x, minX, maxX);
  out.y = clamp(out.y, minY, maxY);
  resolveCircleAgainstMap(out.x, out.y, r, a, out, allowEnemyHoles);
  out.hit = hit || out.hit; out.nx += nx; out.ny += ny;
  var nl = Math.hypot(out.nx, out.ny);
  if (nl > 0.0001) { out.nx /= nl; out.ny /= nl; }
  return out;
}

function bounceBulletFromNormal(i, nx, ny) {
  var dot = bullets.vx[i] * nx + bullets.vy[i] * ny;
  if (dot >= -0.001) return false;
  bullets.vx[i] -= 2 * dot * nx;
  bullets.vy[i] -= 2 * dot * ny;
  return true;
}

function stepBulletThroughWorld(i, dt, a, r) {
  var speed = Math.max(1, Math.hypot(bullets.vx[i], bullets.vy[i]));
  var steps = Math.max(1, Math.ceil(speed * dt / Math.max(2, r * 0.55)));
  var subDt = dt / steps;
  var hit = false;
  for (var s = 0; s < steps; s++) {
    bullets.x[i] += bullets.vx[i] * subDt;
    bullets.y[i] += bullets.vy[i] * subDt;
    var stepHit = false;
    if (bullets.x[i] < a.x0 + r) { bullets.x[i] = a.x0 + r; bullets.vx[i] = Math.abs(bullets.vx[i]); stepHit = true; }
    else if (bullets.x[i] > a.x1 - r) { bullets.x[i] = a.x1 - r; bullets.vx[i] = -Math.abs(bullets.vx[i]); stepHit = true; }
    if (bullets.y[i] < a.y0 + r) { bullets.y[i] = a.y0 + r; bullets.vy[i] = Math.abs(bullets.vy[i]); stepHit = true; }
    else if (bullets.y[i] > a.y1 - r) { bullets.y[i] = a.y1 - r; bullets.vy[i] = -Math.abs(bullets.vy[i]); stepHit = true; }
    resolveCircleAgainstMap(bullets.x[i], bullets.y[i], r, a, bulletResolve);
    if (bulletResolve.hit) {
      bullets.x[i] = bulletResolve.x; bullets.y[i] = bulletResolve.y;
      if (bounceBulletFromNormal(i, bulletResolve.nx, bulletResolve.ny)) stepHit = true;
    }
    hit = hit || stepHit;
    if (stepHit && bullets.kind[i] === 2) break;
  }
  return hit;
}

function applyMaxVodka() { player.maxVodka = BAL.maxVodka + player.up.vodkaMax; }

export function startRun() {
  var a = arena();
  player.up = { shots: 1, fireMul: 1, bounce: 0, dmgMul: 1, vodkaMax: 0, lifesteal: 0, speedMul: 1, pierce: false, ifrBonus: 0, steady: 1, molotov: 0, ricochetSeek: 0, shieldMax: 0, ambulance: 0 };
  player.x = a.cx; player.y = a.cy; player.vx = player.vy = 0; player.face = 0;
  player.hp = player.maxHp = BAL.maxHp;
  applyMaxVodka(); player.vodka = BAL.startVodka;
  player.belochka = 0; player.fireCd = 0; player.recoil = 0; player.hurt = 0; player.iframe = 0;
  player.drinkGlow = 0; player.bloodFire = 0; player.shield = 0; player.shieldFlash = 0; player.shotSeq = 0; player.dead = false; player.step = 0;
  player.attackT = 0; player.attackMax = 0; player.attackSeq = 0;
  player.throwQueued = 0; player.throwT = 0; player.throwAim = 0; player.throwKind = 0; player.throwShots = 0; player.throwSpread = 0; player.throwJit = 0; player.throwMolotov = 0;
  bullets.count = 0; enemies.count = 0; parts.count = 0; parts.cursor = 0; pickups.count = 0; puddles.count = 0; puddles.cursor = 0;
  squirrel.active = false; squirrel.talkT = 0;
  state.score = 0; state.kills = 0; state.phantoms = 0; state.survived = 0;
  state.spawnCd = 0.8; state.hallucCd = 4; state.squirrelCd = 16;
  state.banner = ''; state.bannerT = 0; state.warp = 0; state.taughtBounce = false; state.taughtBlood = false; state.taughtMolotov = false;
  state.weirdCd = 8; state.weirdKind = ''; state.weirdT = 0; state.weirdMax = 0; state.weirdSeq = 0;
  state.sirenPulse = 0; state.blackoutPulse = 0; state.ambulancePulse = 0; state.possessedX = 0; state.possessedY = 0;
  state.windowPulse = 0; state.windX = 0; state.windY = 0; state.windowKillWarn = 0;
  state.xp = 0; state.xpNext = BAL.xpFirst; state.level = 1; state.upChoices = [];
  state.mode = 'PLAY'; state.t = 0;
}

// ---- particles ----
function addPart(x, y, vx, vy, r, life, col) {
  var i = parts.cursor;
  parts.x[i] = x; parts.y[i] = y; parts.vx[i] = vx; parts.vy[i] = vy;
  parts.r[i] = r; parts.life[i] = life; parts.max[i] = life; parts.col[i] = col;
  parts.cursor = (i + 1) % parts.x.length;
  if (parts.count < parts.x.length) parts.count++;
}
function burst(x, y, n, sp, r, col) { for (var i = 0; i < n; i++) { var a = rand(0, TAU), s = rand(0.3, 1) * sp; addPart(x, y, Math.cos(a) * s, Math.sin(a) * s, rand(0.6, 1.2) * r, rand(0.3, 0.7), col); } }

function removePuddle(i) {
  var n = --puddles.count;
  if (i !== n) {
    puddles.x[i] = puddles.x[n]; puddles.y[i] = puddles.y[n]; puddles.r[i] = puddles.r[n];
    puddles.life[i] = puddles.life[n]; puddles.max[i] = puddles.max[n]; puddles.phase[i] = puddles.phase[n];
  }
}
function spawnPuddle(x, y, power) {
  var i;
  if (puddles.count < puddles.x.length) i = puddles.count++;
  else { i = puddles.cursor; puddles.cursor = (puddles.cursor + 1) % puddles.x.length; }
  var p = 1 + Math.max(0, power || 0) * 0.18;
  puddles.x[i] = x; puddles.y[i] = y; puddles.r[i] = BAL.molotovRadius * p;
  puddles.life[i] = puddles.max[i] = BAL.molotovLife * (0.9 + p * 0.12);
  puddles.phase[i] = rand(0, TAU);
  burst(x, y, 18 + (power || 0) * 4, 260, 4.5, 5);
  burst(x, y, 7, 120, 5.5, 6);
  addTrauma(0.34);
  if (!state.taughtMolotov) {
    state.taughtMolotov = true;
    state.banner = 'бутылка разбилась. пол тоже пьёт.';
    state.bannerT = 2.7;
  }
}

function gainXp(v) {
  state.xp += v;
  if (state.mode === 'PLAY' && state.xp >= state.xpNext) levelUp();
}

// ---- enemies (the booze) ----
function chooseBooze() {
  var pool = [];
  for (var i = 0; i < BOOZE.length; i++) if (state.survived >= BOOZE[i].unlockT) pool.push(i);
  if (!pool.length) pool.push(0);
  return pool[(Math.random() * pool.length) | 0];
}

function holeSide(a, h) {
  var dTop = Math.abs(h.cy - a.y0);
  var dRight = Math.abs(a.x1 - h.cx);
  var dBottom = Math.abs(a.y1 - h.cy);
  var dLeft = Math.abs(h.cx - a.x0);
  var best = 0, bestD = dTop;
  if (dRight < bestD) { best = 1; bestD = dRight; }
  if (dBottom < bestD) { best = 2; bestD = dBottom; }
  if (dLeft < bestD) best = 3;
  return best;
}

function outsideEnemySpawnForHole(a, h, side, r) {
  var margin = r + 54;
  var x = h.cx, y = h.cy;
  if (side === 0) y = -margin;
  else if (side === 1) x = view.cssW + margin;
  else if (side === 2) y = view.cssH + margin;
  else x = -margin;
  return { x: x, y: y, gateX: h.cx, gateY: h.cy, side: side, id: h.id };
}

function edgePosForSide(a, side, r) {
  r = r || BAL.foeR;
  var holes = mapEnemyHoles(a);
  if (holes.length) {
    var candidates = [];
    for (var i = 0; i < holes.length; i++) if (holeSide(a, holes[i]) === side) candidates.push(holes[i]);
    if (!candidates.length) candidates = holes;
    var h = candidates[(Math.random() * candidates.length) | 0];
    return outsideEnemySpawnForHole(a, h, holeSide(a, h), r);
  }
  if (side === 0) return { x: a.cx + rand(-80, 80), y: -r - 54, gateX: a.cx, gateY: a.y0 + 42, side: side, id: 'fallback-top' };
  if (side === 1) return { x: view.cssW + r + 54, y: a.cy + rand(-80, 80), gateX: a.x1 - 42, gateY: a.cy, side: side, id: 'fallback-right' };
  if (side === 2) return { x: a.cx + rand(-80, 80), y: view.cssH + r + 54, gateX: a.cx, gateY: a.y1 - 42, side: side, id: 'fallback-bottom' };
  return { x: -r - 54, y: a.cy + rand(-80, 80), gateX: a.x0 + 42, gateY: a.cy, side: side, id: 'fallback-left' };
}
function nearestPlayerEdge(a) {
  var d = [
    player.y - a.y0,
    a.x1 - player.x,
    a.y1 - player.y,
    player.x - a.x0
  ];
  var best = 0, tie = [0];
  for (var i = 1; i < 4; i++) {
    if (d[i] < d[best] - 0.001) { best = i; tie.length = 0; tie.push(i); }
    else if (Math.abs(d[i] - d[best]) <= 0.001) tie.push(i);
  }
  return tie[(Math.random() * tie.length) | 0];
}
function edgePos(a, biasTowardPlayer, r) {
  var side = randInt(0, 3);
  if (biasTowardPlayer) {
    var target = nearestPlayerEdge(a);
    if (Math.random() < BAL.spawnPlayerSideChance) side = target;
    else { do { side = randInt(0, 3); } while (side === target); }
  }
  return edgePosForSide(a, side, r);
}
function spawnReal(type, atX, atY, scale) {
  if (enemies.count >= enemies.x.length) return;
  var bz = BOOZE[type], a = arena(); scale = scale || 1;
  var er = bz.r * scale * rand(0.92, 1.08);
  var pos = (atX === undefined) ? edgePos(a, true, er + 4) : { x: atX, y: atY };
  if (atX !== undefined && mapCircleBlocked(pos.x, pos.y, er * 0.88, a)) {
    resolveCircleAgainstMap(pos.x, pos.y, er * 0.88, a, spawnResolve, true);
    pos.x = spawnResolve.x; pos.y = spawnResolve.y;
  }
  var i = enemies.count++;
  enemies.x[i] = pos.x; enemies.y[i] = pos.y; enemies.vx[i] = 0; enemies.vy[i] = 0;
  enemies.real[i] = 1; enemies.type[i] = type;
  enemies.hp[i] = enemies.maxHp[i] = bz.hp * scale;
  enemies.r[i] = er;
  enemies.gateX[i] = pos.gateX || 0; enemies.gateY[i] = pos.gateY || 0; enemies.gateActive[i] = pos.gateX === undefined ? 0 : 1;
  enemies.phase[i] = rand(0, TAU); enemies.seed[i] = rand(0, 100); enemies.hitT[i] = 0;
}
function spawnHalluc() {
  if (enemies.count >= enemies.x.length) return;
  var a = arena(), pos = edgePos(a, false, BAL.foeR), i = enemies.count++;
  enemies.x[i] = pos.x; enemies.y[i] = pos.y; enemies.vx[i] = 0; enemies.vy[i] = 0;
  enemies.real[i] = 0; enemies.type[i] = 0;
  enemies.hp[i] = enemies.maxHp[i] = 1; enemies.r[i] = BAL.foeR * rand(0.85, 1.2);
  enemies.gateX[i] = 0; enemies.gateY[i] = 0; enemies.gateActive[i] = 0;
  enemies.phase[i] = rand(0, TAU); enemies.seed[i] = rand(0, 100); enemies.hitT[i] = 0;
}
function removeFoe(i) { var n = --enemies.count; if (i !== n) { for (var k in enemies) { var arr = enemies[k]; if (arr && arr.length) arr[i] = arr[n]; } } }

function dropPickup(x, y, kind) {
  if (pickups.count >= pickups.x.length) return;
  var p = pickups.count++;
  pickups.x[p] = x; pickups.y[p] = y; pickups.vy[p] = -0.6; pickups.r[p] = kind === 1 ? 12 : 13;
  pickups.phase[p] = rand(0, TAU); pickups.kind[p] = kind;
}

function killFoe(i) {
  var x = enemies.x[i], y = enemies.y[i], real = enemies.real[i], type = enemies.type[i], full = enemies.maxHp[i];
  if (real) {
    var bz = BOOZE[type];
    burst(x, y, 12, 180, 5, 1);
    state.kills++; state.score += 10;
    player.belochka = clamp(player.belochka - BAL.belochkaKillCalm, 0, 1);   // killing the demon steadies you
    if (player.up.lifesteal > 0) player.hp = Math.min(player.maxHp, player.hp + player.up.lifesteal * BAL.lifestealHeal);
    var roll = Math.random();
    if (roll < BAL.zakuskaDropChance) dropPickup(x, y, 1);
    else if (roll < BAL.zakuskaDropChance + BAL.vodkaDropChance) dropPickup(x, y, 0);
    // champagne fizzes into smaller ones (only full-size splits, no infinite chain)
    if (bz.split && full >= bz.hp * 0.9) for (var s = 0; s < bz.split; s++) spawnReal(type, x, y, 0.55);
    addTrauma(0.2);
    gainXp(bz.xp);
  } else {
    burst(x, y, 10, 140, 4, 2);
    state.phantoms++;
    player.belochka = clamp(player.belochka + BAL.belochkaPerPhantom, 0, 1);  // you shot a ghost -> losing it
  }
  removeFoe(i);
}

// ---- roguelite level-up ----
function pickUpgrades() {
  var avail = [];
  for (var i = 0; i < UPGRADES.length; i++) { if (player.up.pierce && UPGRADES[i].de.indexOf('насквозь') >= 0) continue; avail.push(i); }
  state.upChoices = []; var guard = 0;
  while (state.upChoices.length < 3 && guard++ < 100) { var k = avail[(Math.random() * avail.length) | 0]; if (state.upChoices.indexOf(k) < 0) state.upChoices.push(k); }
}
function levelUp() {
  state.level++; state.xp -= state.xpNext; if (state.xp < 0) state.xp = 0;
  state.xpNext = Math.ceil(state.xpNext * BAL.xpGrow) + 2;
  pickUpgrades();
  state.mode = 'UPGRADE';
}
export function chooseUpgrade(i) {
  var k = state.upChoices[i]; if (k == null) return;
  var shieldBefore = player.up.shieldMax;
  var ambulanceBefore = player.up.ambulance;
  UPGRADES[k].fn(player.up);
  applyMaxVodka();
  if (player.up.shieldMax > shieldBefore) {
    player.shield = Math.min(player.up.shieldMax, player.shield + 1);
    player.shieldFlash = 1;
  }
  if (player.up.ambulance > ambulanceBefore) {
    state.survived = Math.min(state.goalTime - 1, state.survived + BAL.ambulanceSkip);
    state.spawnCd = Math.min(state.spawnCd, 0.08);
    state.ambulancePulse = 1;
    for (var w = 0; w < BAL.ambulanceSpawnKick + player.up.ambulance; w++) spawnReal(chooseBooze());
    state.banner = 'скорая услышала. и не только она.';
    state.bannerT = 3.0;
    addTrauma(0.6);
  }
  player.vodka = clamp(player.vodka + 15, 0, player.maxVodka);   // a swig as a reward
  state.mode = 'PLAY';
}

// ---- weird visual events with gameplay teeth ----
function startWeird(kind) {
  state.weirdKind = kind;
  state.weirdT = kind === 'siren' ? 4.7 : (kind === 'possessed' ? 5.2 : (kind === 'window' ? 6.4 : 5.6));
  state.weirdMax = state.weirdT;
  state.weirdSeq++;
  if (kind === 'siren') {
    state.possessedX = 0; state.possessedY = 0;
    player.belochka = clamp(player.belochka - 0.08, 0, 1);
    state.banner = 'сирена близко. настоящие светятся.';
    state.bannerT = 2.5;
    addTrauma(0.28);
  } else if (kind === 'blackout') {
    state.possessedX = 0; state.possessedY = 0;
    rememberHorror('blackout');
    state.banner = 'темнота лезет в глаза.';
    state.bannerT = 2.4;
    addTrauma(0.42);
  } else if (kind === 'possessed') {
    rememberHorror('possessed');
    var dirs = [[1,0],[-1,0],[0,-1],[0,1]], d = dirs[randInt(0, dirs.length - 1)];
    state.possessedX = d[0]; state.possessedY = d[1];
    state.banner = d[0] > 0 ? 'палец сам держит D.' : (d[0] < 0 ? 'палец сам держит A.' : (d[1] < 0 ? 'палец сам держит W.' : 'палец сам держит S.'));
    state.bannerT = 2.6;
    addTrauma(0.36);
  } else if (kind === 'window') {
    state.possessedX = 0; state.possessedY = 0;
    rememberHorror('window');
    state.windX = rand(-0.18, 0.18); state.windY = -0.56;
    state.banner = 'окно распахнулось. не выходи.';
    state.bannerT = 3.0;
    addTrauma(0.5);
  }
}

function updateWeird(dt) {
  state.sirenPulse = 0; state.blackoutPulse = 0; state.windowPulse = 0;
  if (state.weirdKind) {
    state.weirdT -= dt;
    var phase = state.weirdMax > 0 ? clamp(state.weirdT / state.weirdMax, 0, 1) : 0;
    var throb = 0.55 + 0.45 * Math.sin(state.t * (state.weirdKind === 'siren' ? 18 : (state.weirdKind === 'window' ? 11 : 7)));
    if (state.weirdKind === 'siren') {
      state.sirenPulse = phase * throb;
      player.belochka = clamp(player.belochka - 0.012 * dt, 0, 1);
    } else if (state.weirdKind === 'blackout') {
      state.blackoutPulse = (0.3 + 0.7 * phase) * throb;
      player.belochka = clamp(player.belochka + 0.006 * dt, 0, 1);
    } else if (state.weirdKind === 'possessed') {
      player.belochka = clamp(player.belochka + 0.01 * dt, 0, 1);
    } else if (state.weirdKind === 'window') {
      state.windowPulse = (0.35 + 0.65 * phase) * throb;
      state.windowKillWarn = Math.max(0, state.windowKillWarn - dt * 2.5);
      player.belochka = clamp(player.belochka + 0.004 * dt, 0, 1);
    }
    if (state.weirdT <= 0) {
      state.weirdKind = ''; state.weirdT = 0; state.weirdMax = 0;
      state.possessedX = 0; state.possessedY = 0; state.windowPulse = 0; state.windX = 0; state.windY = 0;
      state.weirdCd = 10 + Math.random() * 6;
    }
    return;
  }
  state.weirdCd -= dt;
  if (state.weirdCd <= 0) {
    var cycle = ['siren', 'blackout', 'possessed', 'window'];
    var next = player.belochka > 0.62 ? 'siren' : cycle[state.weirdSeq % cycle.length];
    startWeird(next);
  }
}

// ---- firing ----
function spawnBullet(ang, kind) {
  if (bullets.count >= bullets.x.length) return;
  var b = bullets.count++;
  bullets.x[b] = player.x + Math.cos(ang) * (player.r + 4);
  bullets.y[b] = player.y + Math.sin(ang) * (player.r + 4);
  bullets.vx[b] = Math.cos(ang) * BAL.bulletSpeed; bullets.vy[b] = Math.sin(ang) * BAL.bulletSpeed;
  bullets.life[b] = 0; bullets.r[b] = BAL.bulletR; bullets.bounce[b] = 0; bullets.kind[b] = kind;
}
function nearestTarget() {
  var best = -1, bd = 1e9;
  for (var i = 0; i < enemies.count; i++) {
    var dx = enemies.x[i] - player.x, dy = enemies.y[i] - player.y, d = dx * dx + dy * dy;
    if (d < bd) { bd = d; best = i; }
  }
  return best;
}
function attackInterval(kind) {
  return (BAL.fireInterval / player.up.fireMul) * (kind === 1 ? 1 / BAL.bloodFireRate : 1);
}
function attackVisualDuration(kind) {
  return clamp(attackInterval(kind) * 0.9, 0.12, kind === 1 ? 0.74 : 0.48);
}
function queueThrow(aim, kind, shots, spread, molotovShot, jit, visualDuration) {
  player.throwQueued = 1;
  player.throwT = visualDuration * 0.9;
  player.throwAim = aim;
  player.throwKind = kind;
  player.throwShots = shots;
  player.throwSpread = spread;
  player.throwJit = jit;
  player.throwMolotov = molotovShot ? 1 : 0;
}
function releaseQueuedThrow() {
  if (!player.throwQueued) return;
  var shots = Math.max(1, player.throwShots | 0);
  playSfx('throw', player.throwMolotov ? 1.1 : 0.9);
  for (var k = 0; k < shots; k++) {
    var frac = shots === 1 ? 0 : (k / (shots - 1) - 0.5);
    var shotKind = (player.throwMolotov && k === ((shots - 1) * 0.5 | 0)) ? 2 : player.throwKind;
    spawnBullet(player.throwAim + frac * player.throwSpread * (shots - 1) + rand(-1, 1) * player.throwJit, shotKind);
  }
  addPart(player.x + Math.cos(player.throwAim) * player.r, player.y + Math.sin(player.throwAim) * player.r, Math.cos(player.throwAim) * 60, Math.sin(player.throwAim) * 60, 3, 0.18, player.throwKind === 1 ? 1 : 0);
  player.throwQueued = 0;
  player.throwT = 0;
}
function updateQueuedThrow(dt) {
  if (player.attackT > 0) {
    player.attackT = Math.max(0, player.attackT - dt);
    player.recoil = player.attackMax > 0 ? player.attackT / player.attackMax : 0;
  } else if (player.recoil > 0) {
    player.recoil = Math.max(0, player.recoil - dt * 5);
  }
  if (!player.throwQueued) return;
  player.throwT -= dt;
  if (player.throwT <= 0) releaseQueuedThrow();
}
function fire() {
  if (player.throwQueued || player.attackT > 0) return false;
  var bx = nearestTarget();
  if (bx < 0) { player.fireCd = 0.12; return false; }
  var aim = Math.atan2(enemies.y[bx] - player.y, enemies.x[bx] - player.x);
  player.face = aim;
  var jit = (0.05 + player.belochka * 0.6) * player.up.steady;   // drunk aim (steadied by ЯСНЫЙ ВЗГЛЯД)

  var kind;
  if (player.vodka >= BAL.vodkaPerShot) { player.vodka -= BAL.vodkaPerShot; kind = 0; }
  else if (player.hp - BAL.bloodPerShot >= BAL.bloodReserve) {   // never fire the shot that would kill you
    player.hp -= BAL.bloodPerShot; kind = 1; player.bloodFire = 1; player.belochka = clamp(player.belochka + 0.004, 0, 1);
    if (!state.taughtBlood) { state.taughtBlood = true; state.banner = 'водка кончилась. но в крови ещё найдётся.'; state.bannerT = 2.8; }
  }
  else { player.belochka = clamp(player.belochka + 0.02, 0, 1); player.fireCd = 0.4; return false; }

  var shots = kind === 1 ? 1 : player.up.shots, spread = 9 * Math.PI / 180;
  var molotovShot = false;
  if (kind === 0 && player.up.molotov > 0) {
    player.shotSeq++;
    var every = Math.max(3, BAL.molotovEvery - (player.up.molotov - 1) * 2);
    molotovShot = player.shotSeq % every === 0;
  }
  var visualDuration = attackVisualDuration(kind);
  queueThrow(aim, kind, shots, spread, molotovShot, jit, visualDuration);
  player.recoil = 1;
  player.attackMax = visualDuration;
  player.attackT = visualDuration;
  player.attackSeq++;
  player.fireCd = Math.max(attackInterval(kind), visualDuration);
  return true;
}

function removeBullet(i) { var n = --bullets.count; if (i !== n) { bullets.x[i] = bullets.x[n]; bullets.y[i] = bullets.y[n]; bullets.vx[i] = bullets.vx[n]; bullets.vy[i] = bullets.vy[n]; bullets.life[i] = bullets.life[n]; bullets.r[i] = bullets.r[n]; bullets.bounce[i] = bullets.bounce[n]; bullets.kind[i] = bullets.kind[n]; } }

function nearestRealEnemy(x, y) {
  var best = -1, bd = 1e12;
  for (var i = 0; i < enemies.count; i++) {
    if (!enemies.real[i]) continue;
    var dx = enemies.x[i] - x, dy = enemies.y[i] - y, d = dx * dx + dy * dy;
    if (d < bd) { bd = d; best = i; }
  }
  return best;
}

function bendRicochet(i, dt) {
  if (player.up.ricochetSeek <= 0 || bullets.bounce[i] <= 0) return;
  var j = nearestRealEnemy(bullets.x[i], bullets.y[i]);
  if (j < 0) return;
  var dx = enemies.x[j] - bullets.x[i], dy = enemies.y[j] - bullets.y[i];
  var d = Math.max(1, Math.hypot(dx, dy));
  var sp = Math.max(1, Math.hypot(bullets.vx[i], bullets.vy[i]));
  var pull = Math.min(0.22, BAL.ricochetSeek * player.up.ricochetSeek * dt);
  var vx = bullets.vx[i] / sp * (1 - pull) + dx / d * pull;
  var vy = bullets.vy[i] / sp * (1 - pull) + dy / d * pull;
  var nd = Math.max(0.001, Math.hypot(vx, vy));
  bullets.vx[i] = vx / nd * sp;
  bullets.vy[i] = vy / nd * sp;
  if ((state.tick + i) % 5 === 0) addPart(bullets.x[i], bullets.y[i], -bullets.vx[i] * 0.018, -bullets.vy[i] * 0.018, 2.6, 0.18, 0);
}

function updateBullets(dt) {
  var a = arena(), dmg = BAL.dmg * player.up.dmgMul, pierce = player.up.pierce;
  for (var i = bullets.count - 1; i >= 0; i--) {
    bendRicochet(i, dt);
    bullets.life[i] += dt;
    var r = bullets.r[i], hit = stepBulletThroughWorld(i, dt, a, r);
    if (hit) {
      playSfx('bottleImpact', bullets.kind[i] === 2 ? 0.8 : 0.55);
      if (bullets.bounce[i] < 255) bullets.bounce[i]++;
      addPart(bullets.x[i], bullets.y[i], 0, 0, 3, 0.2, bullets.kind[i] === 1 ? 1 : 0);
      if (!state.taughtBounce) { state.taughtBounce = true; state.banner = 'отскок. твои пули помнят стены.'; state.bannerT = 2.6; }
      if (bullets.kind[i] === 2) { spawnPuddle(bullets.x[i], bullets.y[i], player.up.molotov); removeBullet(i); continue; }
    }
    var gone = false;
    for (var j = enemies.count - 1; j >= 0; j--) {
      var dx = bullets.x[i] - enemies.x[j], dy = bullets.y[i] - enemies.y[j], rr = r + enemies.r[j];
      if (dx * dx + dy * dy <= rr * rr) {
        if (!enemies.real[j]) { playSfx('enemyHit', 0.35); killFoe(j); removeBullet(i); gone = true; break; }   // popping a ghost wastes the shot
        var hitDmg = bullets.bounce[i] ? dmg * (1 + player.up.bounce * 0.2) : dmg;
        if (bullets.kind[i] === 2) {
          playSfx('enemyHit', 0.55);
          spawnPuddle(bullets.x[i], bullets.y[i], player.up.molotov);
          enemies.hitT[j] = 0.16; enemies.hp[j] -= hitDmg * 0.65;
          if (enemies.hp[j] <= 0) killFoe(j);
          removeBullet(i); gone = true; break;
        }
        if (pierce) {
          if (enemies.hitT[j] > 0) continue;                                          // one hit per pass-through
          playSfx('enemyHit', 0.45);
          enemies.hitT[j] = 0.12; enemies.hp[j] -= hitDmg; burst(bullets.x[i], bullets.y[i], 3, 70, 3, 1);
          if (enemies.hp[j] <= 0) killFoe(j);
        } else {
          playSfx('enemyHit', 0.5);
          enemies.hitT[j] = 0.12; enemies.hp[j] -= hitDmg; burst(bullets.x[i], bullets.y[i], 4, 90, 3, 1);
          if (enemies.hp[j] <= 0) killFoe(j);
          removeBullet(i); gone = true; break;
        }
      }
    }
    if (gone) continue;
    if (i >= bullets.count) continue;
    // your OWN sloshed bottle can clip you after a bounce; it can kill if you keep ignoring it.
    if (bullets.bounce[i] >= 1 && player.hurt <= 0) {
      var pdx = bullets.x[i] - player.x, pdy = bullets.y[i] - player.y, pr = r + player.r * 0.82;
      if (pdx * pdx + pdy * pdy <= pr * pr) {
        playSfx('playerHurt', 0.75);
        player.hp -= BAL.selfGraze; player.hurt = 0.5 + player.up.ifrBonus;
        player.belochka = clamp(player.belochka + 0.015, 0, 1);
        burst(player.x, player.y, 8, 130, 3, 1); addTrauma(0.3); removeBullet(i); continue;
      }
    }
  }
}

function updatePuddles(dt) {
  if (!puddles.count) return;
  for (var p = puddles.count - 1; p >= 0; p--) {
    puddles.life[p] -= dt;
    puddles.phase[p] += dt * 2.2;
    if (puddles.life[p] <= 0) { removePuddle(p); continue; }
    var px = puddles.x[p], py = puddles.y[p], rr = puddles.r[p];
    var dmg = BAL.molotovDps * (1 + Math.max(0, player.up.molotov - 1) * 0.25) * dt;
    for (var e = enemies.count - 1; e >= 0; e--) {
      if (!enemies.real[e]) continue;
      var dx = enemies.x[e] - px, dy = enemies.y[e] - py, er = rr + enemies.r[e] * 0.35;
      if (dx * dx + dy * dy <= er * er) {
        enemies.hitT[e] = Math.max(enemies.hitT[e], 0.08);
        enemies.hp[e] -= dmg;
        if ((state.tick + e + p) % 9 === 0) addPart(enemies.x[e], enemies.y[e], rand(-16, 16), rand(-60, -8), 2.4, 0.24, 5);
        if (enemies.hp[e] <= 0) killFoe(e);
      }
    }
    var pdx = player.x - px, pdy = player.y - py, pr = rr * 0.72 + player.r;
    if (pdx * pdx + pdy * pdy <= pr * pr) {
      player.hp -= BAL.molotovSelfGraze * dt;
      player.belochka = clamp(player.belochka + 0.018 * dt, 0, 1);
      if ((state.tick + p) % 11 === 0) addPart(player.x, player.y, rand(-18, 18), rand(-40, -6), 2.2, 0.18, 5);
    }
  }
}

function enemyGateReached(i) {
  if (!enemies.gateActive[i]) return true;
  var dx = enemies.gateX[i] - enemies.x[i], dy = enemies.gateY[i] - enemies.y[i];
  var reach = Math.max(18, enemies.r[i] * 1.15);
  return dx * dx + dy * dy <= reach * reach;
}

function updateFoes(dt) {
  var a = arena();
  for (var i = enemies.count - 1; i >= 0; i--) {
    enemies.phase[i] += dt;
    if (enemies.hitT[i] > 0) enemies.hitT[i] -= dt;
    if (enemies.real[i]) {
      var bz = BOOZE[enemies.type[i]];
      var oldX = enemies.x[i], oldY = enemies.y[i];
      if (enemyGateReached(i)) enemies.gateActive[i] = 0;
      var tx = enemies.gateActive[i] ? enemies.gateX[i] : player.x;
      var ty = enemies.gateActive[i] ? enemies.gateY[i] : player.y;
      var dx = tx - enemies.x[i], dy = ty - enemies.y[i], d = Math.max(1, Math.hypot(dx, dy));
      var sirenDrag = state.weirdKind === 'siren' ? 0.82 : 1;
      var stepX = dx / d * bz.sp * sirenDrag * dt, stepY = dy / d * bz.sp * sirenDrag * dt;
      moveCircleOnMap(enemies.x[i], enemies.y[i], stepX, stepY, enemies.r[i] * 0.86, a, enemyMove, true, true);
      enemies.x[i] = enemyMove.x; enemies.y[i] = enemyMove.y;
      if (enemyGateReached(i)) enemies.gateActive[i] = 0;
      enemies.vx[i] = (enemies.x[i] - oldX) / Math.max(0.001, dt);
      enemies.vy[i] = (enemies.y[i] - oldY) / Math.max(0.001, dt);
      dx = player.x - enemies.x[i]; dy = player.y - enemies.y[i]; d = Math.max(1, Math.hypot(dx, dy));
      var rr = enemies.r[i] + player.r;
      if (dx * dx + dy * dy <= rr * rr && player.hurt <= 0) {
        if (player.shield > 0) {
          playSfx('bottleImpact', 1.05);
          player.shield--;
          player.shieldFlash = 1;
          player.hurt = 0.34 + player.up.ifrBonus;
          moveCircleOnMap(enemies.x[i], enemies.y[i], -dx / d * BAL.shieldBlastPush * 0.12, -dy / d * BAL.shieldBlastPush * 0.12, enemies.r[i] * 0.86, a, enemyMove, true, true);
          enemies.x[i] = enemyMove.x; enemies.y[i] = enemyMove.y;
          enemies.vx[i] = (enemies.x[i] - oldX) / Math.max(0.001, dt);
          enemies.vy[i] = (enemies.y[i] - oldY) / Math.max(0.001, dt);
          enemies.hp[i] -= BAL.shieldBlastDmg * (1 + Math.max(0, player.up.shieldMax - 1) * 0.2);
          burst(player.x, player.y, 24, 300, 4.2, 0);
          burst(player.x, player.y, 10, 210, 5, 3);
          addTrauma(0.7);
          state.banner = 'щит разбился бутылочным звоном.';
          state.bannerT = 1.7;
          if (enemies.hp[i] <= 0) killFoe(i);
          continue;
        }
        playSfx('playerHurt', 1);
        player.hp -= bz.touch; player.hurt = 0.6 + player.up.ifrBonus; addTrauma(0.35);   // enemy touch = the ONLY death
        var kx = -dx / d, ky = -dy / d; player.vx += kx * 220; player.vy += ky * 220; burst(player.x, player.y, 8, 150, 3, 1);
      }
    } else {
      var s = enemies.seed[i];
      var hx0 = enemies.x[i], hy0 = enemies.y[i];
      enemies.x[i] += Math.cos(enemies.phase[i] * 1.3 + s) * 26 * dt + (player.x - enemies.x[i]) * 0.18 * dt;
      enemies.y[i] += Math.sin(enemies.phase[i] * 1.1 + s) * 26 * dt + (player.y - enemies.y[i]) * 0.18 * dt;
      enemies.vx[i] = (enemies.x[i] - hx0) / Math.max(0.001, dt);
      enemies.vy[i] = (enemies.y[i] - hy0) / Math.max(0.001, dt);
      enemies.hp[i] -= dt * (state.weirdKind === 'siren' ? 0.28 : 0.06);
      if (enemies.hp[i] <= 0) removeFoe(i);
    }
  }
}

function updatePickups(dt) {
  for (var i = pickups.count - 1; i >= 0; i--) {
    pickups.phase[i] += dt; pickups.y[i] += pickups.vy[i]; pickups.vy[i] *= 0.94;
    var dx = pickups.x[i] - player.x, dy = pickups.y[i] - player.y, rr = pickups.r[i] + player.r;
    if (dx * dx + dy * dy <= rr * rr) {
      playSfx('pickup', pickups.kind[i] === 1 ? 0.85 : 1);
      if (pickups.kind[i] === 1) {                        // закуска: heal a bit of blood + sober up a touch
        player.hp = Math.min(player.maxHp, player.hp + BAL.healBlood);
        player.belochka = clamp(player.belochka - BAL.zakuskaCalm, 0, 1);
        player.drinkGlow = 1; burst(pickups.x[i], pickups.y[i], 10, 120, 3, 4);
        gainXp(BAL.zakuskaPickupXp);
        state.banner = '+ЗАКУСКА'; state.bannerT = 0.7;
      } else {                                            // vodka: ammo + sanity
        player.vodka = clamp(player.vodka + BAL.drinkRestoresVodka, 0, player.maxVodka);
        player.drinkGlow = 1; burst(pickups.x[i], pickups.y[i], 10, 120, 3, 0);
        gainXp(BAL.vodkaPickupXp);
        state.banner = '+ВОДКА'; state.bannerT = 0.7;
      }
      var n = --pickups.count; if (i !== n) { pickups.x[i] = pickups.x[n]; pickups.y[i] = pickups.y[n]; pickups.vy[i] = pickups.vy[n]; pickups.r[i] = pickups.r[n]; pickups.phase[i] = pickups.phase[n]; pickups.kind[i] = pickups.kind[n]; }
    }
  }
}

function updateParts(dt) {
  for (var i = parts.count - 1; i >= 0; i--) {
    parts.life[i] -= dt;
    if (parts.life[i] <= 0) { var n = --parts.count; if (i !== n) { parts.x[i] = parts.x[n]; parts.y[i] = parts.y[n]; parts.vx[i] = parts.vx[n]; parts.vy[i] = parts.vy[n]; parts.r[i] = parts.r[n]; parts.life[i] = parts.life[n]; parts.max[i] = parts.max[n]; parts.col[i] = parts.col[n]; } continue; }
    parts.x[i] += parts.vx[i] * dt; parts.y[i] += parts.vy[i] * dt; parts.vx[i] *= 0.92; parts.vy[i] *= 0.92;
  }
}

function updateSquirrel(dt) {
  var sq = squirrel; if (!sq.active) return;
  sq.t += dt; sq.talkT -= dt; var a = arena();
  sq.x += sq.vx * dt; sq.y += sq.vy * dt;
  if (sq.x < a.x0 + 18 || sq.x > a.x1 - 18) { sq.vx *= -1; sq.x = clamp(sq.x, a.x0 + 18, a.x1 - 18); }
  if (sq.y < a.y0 + 18 || sq.y > a.y1 - 18) { sq.vy *= -1; sq.y = clamp(sq.y, a.y0 + 18, a.y1 - 18); }
  if (sq.t > 7) sq.active = false;
}

function enterEnd(reason) {
  state.endReason = reason; player.dead = (reason === 'bled' || reason === 'window');
  state.mode = (reason === 'sane' || reason === 'wreck') ? 'WIN' : 'DEAD';
  if (state.score > state.best) state.best = state.score;
  addTrauma(1);
}

function checkWindowDeath(a) {
  if (state.weirdKind !== 'window') return false;
  var winW = Math.min((a.x1 - a.x0) * 0.24, 130);
  if (Math.abs(player.x - a.cx) < winW * 0.55 && player.y <= a.y0 + player.r + 9) {
    state.windowKillWarn = 1;
    player.hp = 0;
    enterEnd('window');
    return true;
  }
  return false;
}

export function update(dt) {
  state.t += dt; state.tick++;
  state.warp += (player.belochka - state.warp) * Math.min(1, dt * 3);
  if (state.mode !== 'PLAY') { if (view.shake > 0) view.shake = Math.max(0, view.shake - dt * 1.6); return; }

  var a = arena();
  updateWeird(dt);
  // movement
  var mx = input.moveX, my = input.moveY, spd = BAL.moveSpeed * player.up.speedMul;
  if (state.weirdKind === 'possessed') { mx += state.possessedX; my += state.possessedY; var ml = Math.hypot(mx, my); if (ml > 1) { mx /= ml; my /= ml; } }
  if (state.weirdKind === 'blackout') spd *= 0.76;
  if (state.weirdKind === 'window') { player.vx += state.windX * 120 * dt; player.vy += state.windY * 120 * dt; }
  player.vx += (mx * spd - player.vx) * Math.min(1, dt * 13);
  player.vy += (my * spd - player.vy) * Math.min(1, dt * 13);
  var wantX = player.x + player.vx * dt, wantY = player.y + player.vy * dt;
  moveCircleOnMap(player.x, player.y, player.vx * dt, player.vy * dt, player.r, a, playerMove);
  player.x = playerMove.x; player.y = playerMove.y;
  if (playerMove.hit) {
    if (Math.abs(player.x - wantX) > 0.4) player.vx *= 0.18;
    if (Math.abs(player.y - wantY) > 0.4) player.vy *= 0.18;
  }
  if (checkWindowDeath(a)) return;
  if (mx || my) player.step += dt * 9;
  if (player.iframe > 0) player.iframe -= dt;
  if (player.hurt > 0) player.hurt -= dt;
  updateQueuedThrow(dt);
  if (player.drinkGlow > 0) player.drinkGlow = Math.max(0, player.drinkGlow - dt * 1.5);
  if (player.bloodFire > 0) player.bloodFire = Math.max(0, player.bloodFire - dt * 3);
  if (player.shieldFlash > 0) player.shieldFlash = Math.max(0, player.shieldFlash - dt * 2.8);
  if (state.ambulancePulse > 0) state.ambulancePulse = Math.max(0, state.ambulancePulse - dt * 1.4);

  player.fireCd -= dt;
  var attackingBlockedByMotion = Math.hypot(mx, my) > 0.08 || Math.hypot(player.vx, player.vy) > 28;
  if (player.fireCd <= 0) {
    if (!attackingBlockedByMotion) fire();
    else player.fireCd = Math.min(0.12, BAL.fireInterval / Math.max(1, player.up.fireMul));
  }

  // белочка tracks vodka/sanity: above the danger line it recedes, below it climbs (faster the emptier)
  var vfrac = player.vodka / player.maxVodka, dl = BAL.belochkaDanger;
  if (vfrac >= dl) player.belochka -= BAL.belochkaCalm * dt * ((vfrac - dl) / (1 - dl));
  else player.belochka += BAL.belochkaLowVodka * dt * ((dl - vfrac) / dl);
  player.belochka = clamp(player.belochka, 0, 1);

  updateBullets(dt);
  updatePuddles(dt);
  updateFoes(dt);
  updatePickups(dt);
  updateParts(dt);
  updateSquirrel(dt);

  // "this is fine" easter egg when your own fire fills the room
  var armed = 0; for (var bi = 0; bi < bullets.count; bi++) if (bullets.bounce[bi] >= 1) armed++;

  // spawns ramp up over time
  state.spawnCd -= dt;
  if (state.spawnCd <= 0) {
    var cnt = 1 + (state.survived > BAL.multiSpawnAt ? 1 : 0) + (state.survived > BAL.multiSpawnAt * 2 ? 1 : 0);
    for (var sp = 0; sp < cnt; sp++) spawnReal(chooseBooze());
    state.spawnCd = Math.max(BAL.spawnEveryMin, BAL.spawnEvery0 - state.survived * BAL.spawnRamp);
  }
  if (player.belochka > 0.12) {
    state.hallucCd -= dt;
    if (state.hallucCd <= 0) { var nh = 1 + (player.belochka * 3 | 0); for (var h = 0; h < nh; h++) spawnHalluc(); state.hallucCd = BAL.hallucEvery / (0.4 + player.belochka * 2.2); }
  }
  state.squirrelCd -= dt;
  if (!squirrel.active && player.belochka > 0.45 && state.squirrelCd <= 0) {
    squirrel.active = true; squirrel.t = 0; squirrel.talkT = 3; squirrel.line = randInt(0, 3);
    squirrel.x = a.x0 + 24; squirrel.y = a.y0 + 24; squirrel.vx = 150; squirrel.vy = 110;
    state.squirrelCd = 18; state.banner = 'белочка пришла.'; state.bannerT = 2.2;
  }

  if (state.bannerT > 0) state.bannerT -= dt;
  if (view.shake > 0) view.shake = Math.max(0, view.shake - dt * 1.6);
  cam.x = a.cx + (view.shake > 0 ? view.shake * view.shake * 16 * Math.sin(state.t * 49) : 0);
  cam.y = a.cy + (view.shake > 0 ? view.shake * view.shake * 16 * Math.cos(state.t * 41) : 0);

  // win / lose. Blood can run out from enemy contact, bounced self-shots, fire, or the open window.
  state.survived += dt;
  if (player.hp <= 0) { player.hp = 0; enterEnd('bled'); }
  else if (state.survived >= state.goalTime) enterEnd(player.vodka >= 25 ? 'sane' : 'wreck');
}
