<!-- SOLID CITY · part 3404 -->
perm[jj]] & 7]; q *= q; n += q * q * (g[0] * x0 + g[1] * y0); }
  q = 0.5 - x1 * x1 - y1 * y1; if (q > 0) { g = G2[perm[ii + i1 + perm[jj + j1]] & 7]; q *= q; n += q * q * (g[0] * x1 + g[1] * y1); }
  q = 0.5 - x2 * x2 - y2 * y2; if (q > 0) { g = G2[perm[ii + 1 + perm[jj + 1]] & 7]; q *= q; n += q * q * (g[0] * x2 + g[1] * y2); }
  return 70 * n;
}
function fbm(x, y, o) { let a = 0, f = 1, amp = 1, nm = 0; for (let k = 0; k < o; k++) { a += amp * snoise(x * f, y * f); nm += amp; f *= 2.03; amp *= 0.5; } return a / nm; }

/* the island chain, in CITY coordinates (main island + the big volcanoes, shapes from /city) */
const VOLC = [{ x: 10500, z: 2600, r: 7200, h: 3300 }, { x: 11800, z: -3900, r: 5200, h: 3500 }, { x: 4600, z: 700, r: 3200, h: 1700 }, { x: 7600, z: -7600, r: 3600, h: 1400 }, { x: 15600, z: 4200, r: 3200, h: 1100 }];
function cityHeight(x, z) {   // metres above sea
  const wob = fbm(x * 0.00022, z * 0.00022, 4) * 0.32;
  const c = 1 - Math.hypot(x - 8600, z) / 9800 + wob;
  let h = c > 0 ? 4 + 260 * Math.pow(c, 0.8) : c * 700;
  const land = smooth(c * 6 + 0.1);
  for (const V of VOLC) { const dd = Math.hypot(x - V.x, z - V.z) / V.r; if (dd < 1) { const q2 = 1 - dd * dd; h += V.h * q2 * q2 * Math.pow(1 - dd, 0.35) * land; } }
  if (land > 0) h += fbm(x * 0.0009, z * 0.0009, 4) * 160 * land * smooth(c * 2.5) + fbm(x * 0.004, z * 0.004, 2) * 14 * land;
  const r = Math.hypot(x, z); if (r < 1500) h = lerp(22, Math.max(h, 4), smooth((r - 820) / 680));
  return h;
}
/* ground y in mission coords (flat city = 0), including the planet's curve */
function groundY(x, z) { const cx = x - CITY.x, cz = z - CITY.z; const h = Math.hypot(cx, cz) < 820 ? 22 : cityHeight(cx, cz); return Math.max(h, 0) + SEA + curveDrop(x * x + z * z); }

/* ---------------- renderer ---------------- */
const canvas = $('gl');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: !MOBILE, powerPreference: 'high-performance', stencil: false });
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
const MAX_PR = Math.min(window.devicePixelRatio || 1, MOBILE ? 1.6 : 1.75), MIN_PR = MOBILE ? 0.62 : 0.7;
let pixelRatio = QS.get('dpr') ? +QS.get('dpr') : Math.min(MOBILE ? 1.3 : 1.5, MAX_PR);
renderer.setPixelRatio(pixelRatio);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(68, 1, 0.5, 170000);
scene.add(camera);
let baseFov = 68;
function vp() { const de = document.documentElement; return [de.clientWidth || innerWidth, de.clientHeight || innerHeight]; }
function fit() {
  const [w, h] = vp(); renderer.setSize(w, h, false); camera.aspect = w / h;
  // portrait phones: keep ~50 degrees of horizontal view so the battle is not a keyhole
  const hMin = 50 * Math.PI / 180; baseFov = Math.min(92, Math.max(64, 2 * Math.atan(Math.tan(hMin / 2) / camera.aspect) * 180 / Math.PI));
  camera.updateProjectionMatrix();
}
addEventListener('resize', fit); addEventListener('orientationchange', () => { setTimeout(fit, 200); setTimeout(fit, 700); });
if (window.visualViewport) visualViewport.addEventListener('resize', fit);
fit();

/* ---------------- canvas textures ---------------- */
function canvasTex(w, h, draw, srgb = true) { const c = document.createElement('canvas'); c.width = w; c.height = h; draw(c.getContext('2d'), w, h); const t = new THREE.CanvasTexture(c); if (srgb) t.colorSpace = THREE.SRGBColorSpace; return t; }
const TEX_GLOW = canvasTex(128, 128, (g, w) => { const r = g.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2); r.addColorStop(0, 'rgba(255,255,255,1)'); r.addColorStop(0.25, 'rgba(255,255,255,.55)'); r.addColorStop(0.6, 'rgba(255,255,255,.12)'); r.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = r; g.fillRect(0, 0, w, w); }, false);
const TEX_RING = canvasTex(128, 128, (g, w) => { const r = g.createRadialGradient(w / 2, w / 2, w * 0.28, w / 2, w / 2, w / 2); r.addColorStop(0, 'rgba(255,255,255,0)'); r.addColorStop(0.7, 'rgba(255,255,255,.9)'); r.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = r; g.fillRect(0, 0, w, w); }, false);
const TEX_PUFF = canvasTex(128, 128, (g, w) => {
  const pr = mulberry(7);
  for (let k = 0; k < 16; k++) { const x = w * (0.28 + pr() * 0.44), y = w * (0.32 + pr() * 0.4), rr = w * (0.14 + pr() * 0.18); const r = g.createRadialGradient(x, y, 0, x, y, rr); r.addColorStop(0, 'rgba(255,255,255,.42)'); r.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = r; g.fillRect(0, 0, w, w); }
}, false);
const TEX_WIN = canvasTex(64, 128, (g, w, h) => {
  g.fillStyle = '#000'; g.fillRect(0, 0, w, h); const pr = mulberry(11);
  for (let y = 4; y < h - 4; y += 8) for (let x = 3; x < w - 3; x += 8) { const v = pr(); if (v < 0.42) continue; g.fillStyle = v > 0.93 ? '#ff3048' : v > 0.7 ? '#ffd27a' : '#6a4a2a'; g.fillRect(x, y, 4, 4); }
});
const TEX_BEAM = canvasTex(4, 128, (g, w, h) => { const r = g.createLinearGradient(0, h, 0, 0); r.addColorStop(0, 'rgba(255,220,140,1)'); r.addColorStop(0.15