Opus 5.5

Hostedvia Claude CodeOpenRouter
Running live in your browserNew tab
Show the code the model wrote
HTML82,346 characters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>Dead Water — Naval Combat</title>
<style>
  :root{
    --ink:#e8f1f7;
    --dim:#8fa6b6;
    --hot:#ff6a3d;
    --good:#54e6b4;
    --warn:#ffcf4d;
    --panel:rgba(6,14,22,.55);
    --line:rgba(150,200,230,.22);
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%;overflow:hidden;background:#05080c;}
  body{
    font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
    color:var(--ink);
    -webkit-font-smoothing:antialiased;
    cursor:none;
  }
  canvas{display:block}
  #app{position:fixed;inset:0}

  /* ---------- HUD ---------- */
  #hud{position:fixed;inset:0;pointer-events:none;z-index:10;transition:opacity .4s}
  #hud.dim{opacity:0}
  .panel{
    background:var(--panel);
    border:1px solid var(--line);
    border-radius:10px;
    backdrop-filter:blur(8px) saturate(1.2);
    -webkit-backdrop-filter:blur(8px) saturate(1.2);
  }

  #topleft{position:absolute;top:18px;left:18px;padding:12px 14px;min-width:232px}
  .label{font-size:9px;letter-spacing:.22em;color:var(--dim);text-transform:uppercase}
  .bar{height:9px;border-radius:5px;background:rgba(255,255,255,.1);overflow:hidden;margin-top:5px;position:relative}
  .bar > i{display:block;height:100%;width:100%;transition:width .18s ease-out;border-radius:5px}
  #hullfill{background:linear-gradient(90deg,#2fd79a,#8ef0c4)}
  #heatfill{background:linear-gradient(90deg,#ffb03a,#ff5a2d)}
  .row{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
  .num{font-size:13px;font-variant-numeric:tabular-nums;color:var(--ink)}
  .stack + .stack{margin-top:10px}

  #topright{position:absolute;top:18px;right:18px;padding:12px 14px;text-align:right;min-width:150px}
  #score{font-size:26px;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:.02em}
  #wavecount{font-size:12px;color:var(--warn);margin-top:6px;letter-spacing:.1em}

  #botleft{position:absolute;bottom:18px;left:18px;padding:10px 12px;font-size:10px;line-height:1.75;color:var(--dim);letter-spacing:.06em}
  #botleft b{color:var(--ink);font-weight:600}

  /* minimap */
  #radarwrap{position:absolute;bottom:18px;right:18px;padding:8px;border-radius:50%}
  #radar{display:block;border-radius:50%}

  /* crosshair */
  #cross{position:absolute;left:0;top:0;width:54px;height:54px;margin:-27px 0 0 -27px;will-change:transform}
  #cross svg{display:block;overflow:visible}
  .ch{stroke:#ffffff;stroke-width:1.6;fill:none;opacity:.92;
       filter:drop-shadow(0 0 3px rgba(0,0,0,.85))}
  .ch.hot{stroke:var(--hot)}
  #chring{transition:opacity .15s}

  /* alerts / vignette */
  #alert{
    position:absolute;left:50%;top:15%;transform:translateX(-50%);
    font-size:15px;letter-spacing:.32em;color:var(--warn);text-transform:uppercase;
    text-shadow:0 0 22px rgba(255,180,50,.65);opacity:0;transition:opacity .3s;white-space:nowrap
  }
  #vig{position:absolute;inset:0;opacity:0;transition:opacity .35s;
       background:radial-gradient(ellipse at center,transparent 42%,rgba(190,25,25,.62) 100%)}
  #flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;mix-blend-mode:screen}

  /* ---------- overlays ---------- */
  .screen{
    position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;
    align-items:center;justify-content:center;gap:26px;text-align:center;
    background:linear-gradient(180deg,rgba(4,12,20,.78) 0%,rgba(6,16,26,.52) 42%,rgba(3,9,15,.34) 100%);
    backdrop-filter:blur(3px);cursor:pointer;
  }
  .screen.hide{display:none}
  h1{
    font-size:clamp(38px,7.5vw,78px);font-weight:800;letter-spacing:.16em;
    background:linear-gradient(180deg,#fff,#7fc8e8 65%,#3d7fa0);
    -webkit-background-clip:text;background-clip:text;color:transparent;
    text-shadow:0 0 60px rgba(90,180,230,.28)
  }
  .sub{color:var(--dim);font-size:12px;letter-spacing:.42em;text-transform:uppercase;margin-top:6px}
  .keys{display:grid;grid-template-columns:auto auto;gap:9px 22px;font-size:12px;color:var(--dim);
        padding:22px 30px;border:1px solid var(--line);border-radius:12px;background:rgba(6,14,22,.45)}
  .keys b{color:var(--ink);font-weight:600;justify-self:end}
  .cta{font-size:13px;letter-spacing:.3em;color:var(--good);animation:pulse 1.9s ease-in-out infinite;text-transform:uppercase}
  @keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}
  #gostats{font-size:14px;color:var(--dim);line-height:2}
  #gostats b{color:var(--ink);font-size:22px}
  #loading{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;
           background:#05080c;color:var(--dim);font-size:11px;letter-spacing:.35em;text-transform:uppercase}
</style>
</head>
<body>
<div id="app"></div>

<div id="hud" class="dim">
  <div id="topleft" class="panel">
    <div class="stack">
      <div class="row"><span class="label">Hull Integrity</span><span class="num" id="hullnum">100</span></div>
      <div class="bar"><i id="hullfill"></i></div>
    </div>
    <div class="stack">
      <div class="row"><span class="label">Gun Heat</span><span class="num" id="heatnum">0%</span></div>
      <div class="bar"><i id="heatfill" style="width:0%"></i></div>
    </div>
    <div class="stack row">
      <span class="label">Throttle</span><span class="num" id="thr">0</span>
      <span class="label">Knots</span><span class="num" id="kts">0</span>
    </div>
  </div>

  <div id="topright" class="panel">
    <div class="label">Score</div>
    <div id="score">0</div>
    <div id="wavecount">WAVE 1 · 0 LEFT</div>
  </div>

  <div id="botleft" class="panel">
    <b>W/S</b> or <b>&uarr;/&darr;</b> throttle &nbsp; <b>A/D</b> or <b>&larr;/&rarr;</b> rudder<br/>
    <b>MOUSE</b> aim &nbsp; <b>CLICK</b> or <b>SPACE</b> fire &nbsp; <b>X</b> all stop &nbsp; <b>P</b> pause
  </div>

  <div id="radarwrap" class="panel"><canvas id="radar" width="150" height="150"></canvas></div>

  <div id="cross">
    <svg width="54" height="54" viewBox="0 0 54 54">
      <circle id="chring" class="ch" cx="27" cy="27" r="15" opacity=".35"/>
      <path class="ch" d="M27 4 v9 M27 41 v9 M4 27 h9 M41 27 h9"/>
      <circle class="ch" cx="27" cy="27" r="1.7" fill="#fff" stroke="none"/>
    </svg>
  </div>

  <div id="alert">⚠ ROGUE SWELL INBOUND</div>
  <div id="vig"></div>
  <div id="flash"></div>
</div>

<div id="start" class="screen hide">
  <div>
    <h1>DEAD WATER</h1>
    <div class="sub">North Atlantic · Gun Run</div>
  </div>
  <div class="keys">
    <b>W / S</b><span>Throttle ahead &amp; astern &nbsp;(or &uarr; / &darr;)</span>
    <b>A / D</b><span>Rudder port &amp; starboard &nbsp;(or &larr; / &rarr;)</span>
    <b>MOUSE</b><span>Traverse the deck gun</span>
    <b>CLICK / SPACE</b><span>Fire — mind the heat</span>
    <b>X</b><span>All stop</span>
  </div>
  <div class="cta">Click anywhere to set sail</div>
</div>

<div id="gameover" class="screen hide">
  <div>
    <h1 style="background:linear-gradient(180deg,#fff,#ff9f7a 65%,#a0402d);-webkit-background-clip:text;background-clip:text">SUNK</h1>
    <div class="sub">Your boat went down with all hands</div>
  </div>
  <div id="gostats"></div>
  <div class="cta">Click to sail again</div>
</div>

<div id="loading">Charting the water…</div>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
import { Sky } from 'three/addons/objects/Sky.js';

/* ═══════════════════════════════════════════════════════════════════════
   0.  CONSTANTS · WAVE SPECTRUM
   The same six Gerstner components drive the GPU vertex shader and the
   CPU-side buoyancy sampler, so what you see is exactly what you float on.
   ═══════════════════════════════════════════════════════════════════════ */

const G = 9.81;

// [ dirX, dirZ, steepness, wavelength ]
const WAVES = [
  [ 1.00,  0.14, 0.205, 74.0 ],
  [ 0.72,  0.69, 0.185, 39.0 ],
  [-0.55,  0.84, 0.150, 21.0 ],
  [ 0.31, -0.95, 0.110, 11.5 ],
  [-0.92, -0.39, 0.080,  6.2 ],
  [ 0.48,  0.88, 0.055,  3.1 ],
];
const NW = WAVES.length;

// pre-baked per-wave scalars for the CPU sampler
const W = WAVES.map(([dx, dz, s, L]) => {
  const len = Math.hypot(dx, dz);
  const k = (Math.PI * 2) / L;
  return { dx: dx / len, dz: dz / len, s, k, c: Math.sqrt(G / k), a: s / k };
});
const waveUniform = WAVES.map(([dx, dz, s, L]) => {
  const len = Math.hypot(dx, dz);
  return new THREE.Vector4(dx / len, dz / len, s, L);
});

// Global swell multiplier — rogue waves ramp this up and back down.
const sea = { amp: 1.0, target: 1.0, time: 0 };

const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp  = (a, b, t) => a + (b - a) * t;
const rand  = (a, b) => a + Math.random() * (b - a);
const damp  = (a, b, l, dt) => lerp(a, b, 1 - Math.exp(-l * dt));

/* ═══════════════════════════════════════════════════════════════════════
   1.  CPU WAVE SAMPLER
   Gerstner waves displace horizontally as well as vertically, so finding
   the surface above a given (x,z) needs a short fixed-point iteration.
   ═══════════════════════════════════════════════════════════════════════ */

const _disp = { x: 0, y: 0, z: 0 };
function gerstnerDisplace(px, pz, t, amp) {
  let dx = 0, dy = 0, dz = 0;
  for (let i = 0; i < NW; i++) {
    const w = W[i];
    const f = w.k * (w.dx * px + w.dz * pz - w.c * t);
    const a = w.a * amp;
    dx += w.dx * a * Math.cos(f);
    dz += w.dz * a * Math.cos(f);
    dy += a * Math.sin(f);
  }
  _disp.x = dx; _disp.y = dy; _disp.z = dz;
  return _disp;
}

/** Height of the water surface directly above world (x,z). */
function waterHeight(x, z, t = sea.time, iters = 4) {
  const amp = sea.amp;
  let sx = x, sz = z;
  for (let i = 0; i < iters; i++) {
    const d = gerstnerDisplace(sx, sz, t, amp);
    sx -= (sx + d.x - x);
    sz -= (sz + d.z - z);
  }
  return gerstnerDisplace(sx, sz, t, amp).y;
}

const _n = new THREE.Vector3();
/** Surface normal at world (x,z), by finite difference. */
function waterNormal(x, z, t = sea.time) {
  const e = 0.7;
  const hL = waterHeight(x - e, z, t, 3), hR = waterHeight(x + e, z, t, 3);
  const hD = waterHeight(x, z - e, t, 3), hU = waterHeight(x, z + e, t, 3);
  return _n.set(hL - hR, 2 * e, hD - hU).normalize();
}

/* ═══════════════════════════════════════════════════════════════════════
   2.  RENDERER · SCENE · SKY · ENVIRONMENT
   ═══════════════════════════════════════════════════════════════════════ */

const app = document.getElementById('app');

const renderer = new THREE.WebGLRenderer({
  antialias: true, powerPreference: 'high-performance', stencil: false,
});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.58;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
app.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.4, 40000);
camera.position.set(0, 14, 34);

const HAZE = new THREE.Color(0xb8c6cc);
scene.fog = new THREE.FogExp2(HAZE, 0.00135);

// --- atmospheric sky ---------------------------------------------------
const sky = new Sky();
sky.scale.setScalar(20000);
const skyU = sky.material.uniforms;
skyU.turbidity.value = 4.2;
skyU.rayleigh.value = 1.45;
skyU.mieCoefficient.value = 0.0062;
skyU.mieDirectionalG.value = 0.86;
scene.add(sky);

const SUN_ELEV = 9.5, SUN_AZIM = 152;
const sunDir = new THREE.Vector3().setFromSphericalCoords(
  1,
  THREE.MathUtils.degToRad(90 - SUN_ELEV),
  THREE.MathUtils.degToRad(SUN_AZIM),
);
skyU.sunPosition.value.copy(sunDir);

const sunLight = new THREE.DirectionalLight(0xffd9a8, 3.1);
sunLight.position.copy(sunDir).multiplyScalar(220);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 50;
sunLight.shadow.camera.far = 480;
const sc = sunLight.shadow.camera;
sc.left = -70; sc.right = 70; sc.top = 70; sc.bottom = -70;
sunLight.shadow.bias = -0.0012;
sunLight.shadow.normalBias = 0.05;
scene.add(sunLight);
scene.add(sunLight.target);

scene.add(new THREE.HemisphereLight(0xbfd8ef, 0x1d3a4a, 0.55));

// --- capture the sky into a cube map (water reflections + PBR env) -----
const cubeRT = new THREE.WebGLCubeRenderTarget(512, { type: THREE.HalfFloatType });
const cubeCam = new THREE.CubeCamera(1, 90000, cubeRT);
cubeCam.update(renderer, scene);

const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromCubemap(cubeRT.texture).texture;

/* ═══════════════════════════════════════════════════════════════════════
   3.  PROCEDURAL WATER DETAIL TEXTURE
   RGB = tangent-space normal, A = fBm scalar used for foam breakup.
   Tileable, so no external assets are needed.
   ═══════════════════════════════════════════════════════════════════════ */

function makeWaterTexture(size = 256) {
  const hash = (x, y, p) => {
    x = ((x % p) + p) % p; y = ((y % p) + p) % p;
    let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
    h = Math.imul(h ^ (h >>> 13), 1274126177);
    return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
  };
  const vnoise = (x, y, p) => {
    const xi = Math.floor(x), yi = Math.floor(y);
    const xf = x - xi, yf = y - yi;
    const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
    const a = hash(xi, yi, p),     b = hash(xi + 1, yi, p);
    const c = hash(xi, yi + 1, p), d = hash(xi + 1, yi + 1, p);
    return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
  };

  const h = new Float32Array(size * size);
  const octaves = [[4, 1.0], [8, 0.55], [16, 0.3], [32, 0.17], [64, 0.09]];
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      let s = 0, n = 0;
      for (const [p, w] of octaves) {
        s += vnoise(x / size * p, y / size * p, p) * w;
        n += w;
      }
      h[y * size + x] = s / n;
    }
  }

  const data = new Uint8Array(size * size * 4);
  const at = (x, y) => h[(((y % size) + size) % size) * size + (((x % size) + size) % size)];
  const STR = 2.6;
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const dx = (at(x + 1, y) - at(x - 1, y)) * STR;
      const dy = (at(x, y + 1) - at(x, y - 1)) * STR;
      const l = Math.hypot(-dx, -dy, 1 / size * 16);
      const nx = -dx / l, ny = -dy / l, nz = (1 / size * 16) / l;
      const i = (y * size + x) * 4;
      data[i]     = (nx * 0.5 + 0.5) * 255;
      data[i + 1] = (ny * 0.5 + 0.5) * 255;
      data[i + 2] = (nz * 0.5 + 0.5) * 255;
      data[i + 3] = clamp(at(x, y), 0, 1) * 255;
    }
  }

  const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.magFilter = THREE.LinearFilter;
  tex.minFilter = THREE.LinearMipmapLinearFilter;
  tex.generateMipmaps = true;
  tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
  tex.needsUpdate = true;
  return tex;
}
const waterDetail = makeWaterTexture(256);

/* ═══════════════════════════════════════════════════════════════════════
   4.  THE OCEAN
   A radial disk locked to the camera: dense tessellation underfoot, coarse
   out to the horizon. Waves are evaluated from world position, so the sea
   stays put in world space while the mesh slides along under it.
   ═══════════════════════════════════════════════════════════════════════ */

function makeOceanDisk(rings, segs, rMin, rMax, power) {
  const pos = [], idx = [];
  pos.push(0, 0, 0);
  for (let i = 0; i < rings; i++) {
    const t = i / (rings - 1);
    const r = rMin + (rMax - rMin) * Math.pow(t, power);
    for (let j = 0; j < segs; j++) {
      const a = (j / segs) * Math.PI * 2;
      pos.push(Math.cos(a) * r, 0, Math.sin(a) * r);
    }
  }
  for (let j = 0; j < segs; j++) idx.push(0, 1 + ((j + 1) % segs), 1 + j);
  for (let i = 0; i < rings - 1; i++) {
    const A = 1 + i * segs, B = 1 + (i + 1) * segs;
    for (let j = 0; j < segs; j++) {
      const j2 = (j + 1) % segs;
      idx.push(A + j, B + j2, B + j);
      idx.push(A + j, A + j2, B + j2);
    }
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setIndex(idx);
  g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), rMax * 1.05);
  return g;
}

const reflRT = new THREE.WebGLRenderTarget(
  Math.min(1024, Math.floor(innerWidth * 0.6)),
  Math.min(1024, Math.floor(innerHeight * 0.6)),
  { type: THREE.HalfFloatType, depthBuffer: true },
);

const waterUniforms = {
  uTime:        { value: 0 },
  uAmp:         { value: 1 },
  uWaves:       { value: waveUniform },
  uSky:         { value: cubeRT.texture },
  uRefl:        { value: reflRT.texture },
  uReflMatrix:  { value: new THREE.Matrix4() },
  uDetail:      { value: waterDetail },
  uCamPos:      { value: new THREE.Vector3() },
  uSunDir:      { value: sunDir.clone() },
  uSunColor:    { value: new THREE.Color(0xfff0d0) },
  uDeep:        { value: new THREE.Color(0x04141d) },
  uShallow:     { value: new THREE.Color(0x19707a) },
  uSSS:         { value: new THREE.Color(0x35b48d) },
  uFoam:        { value: new THREE.Color(0xdfeef5) },
  uHaze:        { value: HAZE.clone() },
};

const waterVert = /* glsl */`
#define NW ${NW}
uniform float uTime, uAmp;
uniform vec4  uWaves[NW];
uniform mat4  uReflMatrix;

varying vec3  vWorld;
varying vec3  vNrm;
varying vec4  vRefl;
varying float vCrest;
varying float vHeight;
varying float vDist;

void main() {
  vec3 base = (modelMatrix * vec4(position, 1.0)).xyz;

  vec3 disp = vec3(0.0);
  vec3 tangent  = vec3(1.0, 0.0, 0.0);
  vec3 binormal = vec3(0.0, 0.0, 1.0);
  float crest = 0.0;

  for (int i = 0; i < NW; i++) {
    vec2  d  = uWaves[i].xy;
    float st = uWaves[i].z * uAmp;
    float k  = 6.28318530718 / uWaves[i].w;
    float c  = sqrt(9.81 / k);
    float a  = st / k;
    float f  = k * (dot(d, base.xz) - c * uTime);
    float sf = sin(f), cf = cos(f);

    disp.x += d.x * a * cf;
    disp.z += d.y * a * cf;
    disp.y += a * sf;

    tangent  += vec3(-d.x * d.x * st * sf,  d.x * st * cf, -d.x * d.y * st * sf);
    binormal += vec3(-d.x * d.y * st * sf,  d.y * st * cf, -d.y * d.y * st * sf);
    crest    += st * sf;
  }

  vec3 world = base + disp;
  vWorld  = world;
  vNrm    = normalize(cross(binormal, tangent));
  vCrest  = crest;
  vHeight = disp.y;
  vRefl   = uReflMatrix * vec4(world, 1.0);

  vec4 mv = viewMatrix * vec4(world, 1.0);
  vDist   = -mv.z;
  gl_Position = projectionMatrix * mv;
}
`;

const waterFrag = /* glsl */`
uniform samplerCube uSky;
uniform sampler2D   uRefl;
uniform sampler2D   uDetail;
uniform vec3  uCamPos, uSunDir, uSunColor, uDeep, uShallow, uSSS, uFoam, uHaze;
uniform float uTime, uAmp;

varying vec3  vWorld;
varying vec3  vNrm;
varying vec4  vRefl;
varying float vCrest;
varying float vHeight;
varying float vDist;

vec3 detailNormal(vec2 p, float fade) {
  vec3 n = vec3(0.0);
  n += (texture2D(uDetail, p * 0.019 + vec2( uTime * 0.0085, uTime * 0.0061)).xyz * 2.0 - 1.0) * 1.00;
  n += (texture2D(uDetail, p * 0.056 - vec2( uTime * 0.0140, uTime * 0.0092)).xyz * 2.0 - 1.0) * 0.62;
  n += (texture2D(uDetail, p * 0.180 + vec2(-uTime * 0.0330, uTime * 0.0250)).xyz * 2.0 - 1.0) * 0.30 * fade;
  n += (texture2D(uDetail, p * 0.520 + vec2( uTime * 0.0610, -uTime * 0.044)).xyz * 2.0 - 1.0) * 0.16 * fade * fade;
  return n;
}

void main() {
  vec3 V = normalize(uCamPos - vWorld);

  // ---- normal: analytic Gerstner + scrolling detail, faded with distance
  float fade = exp(-vDist * 0.0055);
  float near = exp(-vDist * 0.0016);
  vec3  d    = detailNormal(vWorld.xz, fade);
  vec3  N    = normalize(vNrm + vec3(d.x, 0.0, d.y) * (0.16 + 0.46 * near));
  N = normalize(mix(vec3(0.0, 1.0, 0.0), N, 0.30 + 0.70 * near));
  if (N.y < 0.0) N = -N;

  // ---- reflections: sky cube for everything, planar RT for the fleet
  vec3 R = reflect(-V, N);
  R.y = max(R.y, 0.015);
  vec3 refl = textureCube(uSky, R).rgb;

  vec2 ruv = vRefl.xy / max(vRefl.w, 0.0001);
  vec2 warp = N.xz * (0.085 * near + 0.008);
  vec2 wuv = ruv + warp;
  float inside = step(0.0001, vRefl.w)
    * smoothstep(0.0, 0.05, wuv.x) * smoothstep(1.0, 0.95, wuv.x)
    * smoothstep(0.0, 0.05, wuv.y) * smoothstep(1.0, 0.95, wuv.y);
  vec4 objRefl = texture2D(uRefl, wuv);
  refl = mix(refl, objRefl.rgb, clamp(objRefl.a * inside * 0.85, 0.0, 1.0));

  // ---- body colour: depth-ish gradient + sub-surface scattering on crests
  float facing = clamp(dot(N, V), 0.0, 1.0);
  vec3  body   = mix(uDeep, uShallow, facing * 0.72 + 0.10);

  float lift    = clamp(vHeight / (2.4 * uAmp) * 0.5 + 0.35, 0.0, 1.0);
  float through = pow(clamp(dot(V, -uSunDir) * 0.5 + 0.5, 0.0, 1.0), 4.0);
  body += uSSS * through * lift * lift * 1.15;
  body += uSunColor * 0.035 * max(0.0, N.y);

  // ---- fresnel
  float F = 0.02 + 0.98 * pow(1.0 - facing, 5.0);
  vec3 col = mix(body, refl, clamp(F, 0.0, 1.0));

  // ---- sun: tight disc glint + broad glitter lobe
  vec3  H = normalize(uSunDir + V);
  float ndh = max(dot(N, H), 0.0);
  col += uSunColor * pow(ndh, 900.0) * 9.0;
  col += uSunColor * pow(ndh, 90.0)  * 0.16 * near;

  // ---- whitecaps where the Gerstner surface starts to pinch
  float noise = texture2D(uDetail, vWorld.xz * 0.06 + vec2(uTime * 0.01, uTime * 0.007)).a;
  float noise2 = texture2D(uDetail, vWorld.xz * 0.21 - vec2(uTime * 0.02, uTime * 0.014)).a;
  float breakup = noise * 0.65 + noise2 * 0.35;
  float foam = smoothstep(0.50, 0.94, vCrest * (0.50 + breakup * 0.80));
  foam *= smoothstep(0.0, 0.35, uAmp - 0.55) * (0.35 + 0.65 * near);
  col = mix(col, uFoam * (0.62 + 0.38 * breakup), clamp(foam, 0.0, 0.80));

  // ---- fade into the haze so the horizon line disappears
  vec3 horizon = textureCube(uSky, normalize(vec3(-V.x, 0.035, -V.z))).rgb;
  float f = 1.0 - exp(-vDist * 0.00085);
  col = mix(col, mix(horizon, uHaze, 0.25), clamp(f, 0.0, 1.0));

  gl_FragColor = vec4(col, 1.0);

  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
`;

const waterMat = new THREE.ShaderMaterial({
  uniforms: waterUniforms,
  vertexShader: waterVert,
  fragmentShader: waterFrag,
  side: THREE.DoubleSide,
});

const ocean = new THREE.Mesh(makeOceanDisk(190, 340, 0.9, 9000, 2.7), waterMat);
ocean.frustumCulled = false;
ocean.renderOrder = 1;
scene.add(ocean);

/* --- planar reflector (mirror camera about y = 0) --------------------- */
const reflCam = new THREE.PerspectiveCamera();
const CLIP_ABOVE = [new THREE.Plane(new THREE.Vector3(0, 1, 0), 0.0)];
const NO_CLIP = [];
const _bias = new THREE.Matrix4().set(
  0.5, 0, 0, 0.5,
  0, 0.5, 0, 0.5,
  0, 0, 0.5, 0.5,
  0, 0, 0, 1,
);
const _up = new THREE.Vector3(), _look = new THREE.Vector3(), _rot = new THREE.Matrix4();

function renderReflection() {
  _rot.extractRotation(camera.matrixWorld);

  reflCam.position.set(camera.position.x, -camera.position.y, camera.position.z);
  _look.set(0, 0, -1).applyMatrix4(_rot).add(camera.position);
  _up.set(0, 1, 0).applyMatrix4(_rot);
  reflCam.up.set(_up.x, -_up.y, _up.z);
  reflCam.lookAt(_look.x, -_look.y, _look.z);

  reflCam.fov = camera.fov;
  reflCam.aspect = camera.aspect;
  reflCam.near = camera.near;
  reflCam.far = 1600;
  reflCam.updateProjectionMatrix();
  reflCam.updateMatrixWorld();

  waterUniforms.uReflMatrix.value
    .copy(_bias)
    .multiply(reflCam.projectionMatrix)
    .multiply(reflCam.matrixWorldInverse);

  ocean.visible = false;
  sky.visible = false;
  const fog = scene.fog; scene.fog = null;

  renderer.clippingPlanes = CLIP_ABOVE;
  renderer.setRenderTarget(reflRT);
  renderer.setClearColor(0x000000, 0);
  renderer.clear();
  renderer.render(scene, reflCam);
  renderer.setRenderTarget(null);
  renderer.clippingPlanes = NO_CLIP;

  scene.fog = fog;
  ocean.visible = true;
  sky.visible = true;
}

/* ═══════════════════════════════════════════════════════════════════════
   5.  PARTICLE FX
   Two pools sharing one update path: soft (spray, foam, smoke) and
   additive (muzzle fire, embers, explosions).
   ═══════════════════════════════════════════════════════════════════════ */

const FX_VERT = /* glsl */`
attribute float aSize;
attribute float aAlpha;
attribute vec3  aColor;
varying float vA;
varying vec3  vC;
void main() {
  vA = aAlpha; vC = aColor;
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
  gl_PointSize = aSize * (320.0 / max(-mv.z, 0.1));
  gl_Position = projectionMatrix * mv;
}
`;
const FX_FRAG = /* glsl */`
varying float vA;
varying vec3  vC;
void main() {
  float d = length(gl_PointCoord - 0.5);
  float a = smoothstep(0.5, 0.08, d) * vA;
  if (a < 0.004) discard;
  gl_FragColor = vec4(vC, a);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
`;

class ParticlePool {
  constructor(count, blending, depthWrite) {
    this.n = count;
    this.pos = new Float32Array(count * 3);
    this.vel = new Float32Array(count * 3);
    this.size = new Float32Array(count);
    this.alpha = new Float32Array(count);
    this.color = new Float32Array(count * 3);
    this.life = new Float32Array(count);
    this.max = new Float32Array(count);
    this.drag = new Float32Array(count);
    this.grav = new Float32Array(count);
    this.grow = new Float32Array(count);
    this.amax = new Float32Array(count);
    this.cursor = 0;

    for (let i = 0; i < count; i++) this.pos[i * 3 + 1] = -9999;

    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
    g.setAttribute('aSize', new THREE.BufferAttribute(this.size, 1));
    g.setAttribute('aAlpha', new THREE.BufferAttribute(this.alpha, 1));
    g.setAttribute('aColor', new THREE.BufferAttribute(this.color, 3));
    g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);

    this.mesh = new THREE.Points(g, new THREE.ShaderMaterial({
      vertexShader: FX_VERT, fragmentShader: FX_FRAG,
      transparent: true, depthWrite, blending,
    }));
    this.mesh.frustumCulled = false;
    this.geo = g;
  }

  spawn(x, y, z, vx, vy, vz, size, life, col, drag = 0.7, grav = -9.8, grow = 0, amax = 1) {
    const i = this.cursor; this.cursor = (this.cursor + 1) % this.n;
    const i3 = i * 3;
    this.pos[i3] = x; this.pos[i3 + 1] = y; this.pos[i3 + 2] = z;
    this.vel[i3] = vx; this.vel[i3 + 1] = vy; this.vel[i3 + 2] = vz;
    this.size[i] = size; this.life[i] = life; this.max[i] = life;
    this.alpha[i] = 0; this.drag[i] = drag; this.grav[i] = grav; this.grow[i] = grow;
    this.amax[i] = amax;
    this.color[i3] = col.r; this.color[i3 + 1] = col.g; this.color[i3 + 2] = col.b;
  }

  update(dt) {
    const { pos, vel, life, max, alpha, size, drag, grav, grow, amax } = this;
    for (let i = 0; i < this.n; i++) {
      if (life[i] <= 0) continue;
      life[i] -= dt;
      const i3 = i * 3;
      if (life[i] <= 0) { alpha[i] = 0; pos[i3 + 1] = -9999; continue; }
      const dmp = Math.exp(-drag[i] * dt);
      vel[i3] *= dmp; vel[i3 + 2] *= dmp;
      vel[i3 + 1] = vel[i3 + 1] * dmp + grav[i] * dt;
      pos[i3] += vel[i3] * dt;
      pos[i3 + 1] += vel[i3 + 1] * dt;
      pos[i3 + 2] += vel[i3 + 2] * dt;
      const t = life[i] / max[i];
      alpha[i] = amax[i] * Math.min(t / 0.25, (1 - t) / 0.05 + 0.15, 1);
      if (grow[i]) size[i] += grow[i] * dt;
    }
    this.geo.attributes.position.needsUpdate = true;
    this.geo.attributes.aAlpha.needsUpdate = true;
    this.geo.attributes.aSize.needsUpdate = true;
    this.geo.attributes.aColor.needsUpdate = true;
  }
}

const fxSoft = new ParticlePool(5000, THREE.NormalBlending, false);
const fxFire = new ParticlePool(2200, THREE.AdditiveBlending, false);
scene.add(fxSoft.mesh, fxFire.mesh);

const C_SPRAY = new THREE.Color(0xdfeff7);
const C_FOAM  = new THREE.Color(0xffffff);
const C_SMOKE = new THREE.Color(0x8d939c);
const C_FIRE  = new THREE.Color(0xffb04a);
const C_HOT   = new THREE.Color(0xfff2c0);

function splash(x, y, z, power = 1, spread = 1) {
  const n = Math.min(90, Math.round(16 * power));
  for (let i = 0; i < n; i++) {
    const a = Math.random() * Math.PI * 2;
    const r = Math.random() * spread;
    fxSoft.spawn(
      x + Math.cos(a) * r, y + rand(0, 0.4), z + Math.sin(a) * r,
      Math.cos(a) * rand(1, 5) * power, rand(3, 11) * power, Math.sin(a) * rand(1, 5) * power,
      rand(0.3, 0.95) * (0.7 + power * 0.25), rand(0.5, 1.5),
      Math.random() < 0.7 ? C_SPRAY : C_FOAM, 0.55, -13, 0.6, 0.8,
    );
  }
}

function explode(x, y, z, power = 1) {
  for (let i = 0; i < Math.round(34 * power); i++) {
    const a = Math.random() * Math.PI * 2, e = Math.random() * 1.2;
    const s = rand(5, 22) * power;
    fxFire.spawn(x, y, z,
      Math.cos(a) * Math.cos(e) * s, Math.sin(e) * s * 1.2 + 3, Math.sin(a) * Math.cos(e) * s,
      rand(1.6, 4.4) * power, rand(0.25, 0.75),
      Math.random() < 0.45 ? C_HOT : C_FIRE, 2.2, -4, 2.5);
  }
  for (let i = 0; i < Math.round(26 * power); i++) {
    const a = Math.random() * Math.PI * 2;
    fxSoft.spawn(x, y + rand(0, 2), z,
      Math.cos(a) * rand(1, 7), rand(2, 9), Math.sin(a) * rand(1, 7),
      rand(2.5, 6) * power, rand(1.4, 3.2), C_SMOKE, 0.9, 0.9, 3.2, 0.5);
  }
  splash(x, Math.max(y - 1, waterHeight(x, z)), z, power * 1.4, 2.5 * power);
}

function muzzleFlash(p, dir) {
  for (let i = 0; i < 22; i++) {
    const s = rand(6, 26);
    fxFire.spawn(p.x, p.y, p.z,
      dir.x * s + rand(-3, 3), dir.y * s + rand(-2, 3), dir.z * s + rand(-3, 3),
      rand(0.9, 2.6), rand(0.08, 0.28), Math.random() < 0.5 ? C_HOT : C_FIRE, 4.5, -2, 4);
  }
  for (let i = 0; i < 14; i++) {
    fxSoft.spawn(p.x, p.y, p.z,
      dir.x * rand(2, 9) + rand(-2, 2), rand(0, 4), dir.z * rand(2, 9) + rand(-2, 2),
      rand(1.4, 3.4), rand(0.7, 1.6), C_SMOKE, 1.1, 0.5, 3.0, 0.45);
  }
}

/* ═══════════════════════════════════════════════════════════════════════
   6.  BOAT GEOMETRY
   The hull is lofted from parametric stations: pointed raked bow, full
   midships, flat transom, with sheer rising fore and aft.
   ═══════════════════════════════════════════════════════════════════════ */

function buildHull(L, B, draft, free) {
  const NS = 30, NC = 14;
  // NOTE: both of these feed fractional Math.pow, so the bases are clamped
  // to >= 0 — at the stem rounding makes `bow` ~-2e-16 and pow() returns NaN.
  const halfBeam = (s) => {
    const bow = Math.max(0, 1 - Math.max(0, (s - 0.58) / 0.42));
    const stern = Math.min(1, 0.80 + s * 3.2);
    return (B * 0.5) * Math.pow(bow, 0.72) * stern;
  };
  const dr = (s) => draft * (0.42 + 0.58 *
    Math.pow(Math.max(0, Math.sin(Math.PI * Math.pow(clamp(s, 0, 1), 0.85))), 0.6));
  const sheer = (s) => free * (1 + 0.42 * Math.pow(2 * s - 1, 2));
  const rake = (s) => Math.max(0, (s - 0.72) / 0.28);

  const pos = [], idx = [];
  const gunwale = [];   // [ [x,y,z] port/starboard pairs ] for the deck cap

  for (let i = 0; i <= NS; i++) {
    const s = i / NS;
    const z = (s - 0.5) * L;
    const hb = halfBeam(s), d = dr(s), fy = sheer(s), rk = rake(s);
    for (let j = 0; j <= NC; j++) {
      const u = j / NC;
      const x = hb * Math.pow(Math.sin(u * Math.PI * 0.5), 0.58);
      const y = -d * Math.pow(Math.cos(u * Math.PI * 0.5), 1.45) + fy * u;
      const zz = z + rk * u * L * 0.085;
      pos.push(x, y, zz);
      if (j === NC) gunwale.push([x, y, zz]);
    }
  }
  const row = NC + 1;
  const vid = (i, j) => i * row + j;
  for (let i = 0; i < NS; i++) {
    for (let j = 0; j < NC; j++) {
      idx.push(vid(i, j), vid(i + 1, j + 1), vid(i + 1, j));
      idx.push(vid(i, j), vid(i, j + 1), vid(i + 1, j + 1));
    }
  }
  const starboardCount = pos.length / 3;
  // mirror to port
  for (let v = 0; v < starboardCount; v++) pos.push(-pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]);
  const tri = idx.length;
  for (let t = 0; t < tri; t += 3) {
    idx.push(idx[t] + starboardCount, idx[t + 2] + starboardCount, idx[t + 1] + starboardCount);
  }
  // transom: close the stern between the two halves
  for (let j = 0; j < NC; j++) {
    const a = vid(0, j), b = vid(0, j + 1);
    idx.push(a, a + starboardCount, b);
    idx.push(b, a + starboardCount, b + starboardCount);
  }

  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return { geo: g, gunwale };
}

function buildDeck(gunwale) {
  const pos = [], idx = [];
  const n = gunwale.length;
  for (const [x, y, z] of gunwale) { pos.push(x, y, z); pos.push(-x, y, z); }
  for (let i = 0; i < n - 1; i++) {
    const a = i * 2, b = i * 2 + 1, c = (i + 1) * 2, d = (i + 1) * 2 + 1;
    idx.push(a, b, c); idx.push(b, d, c);
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return g;
}

const MAT = {
  hullPlayer: new THREE.MeshStandardMaterial({ color: 0x2a3d4e, roughness: 0.45, metalness: 0.32 }),
  hullEnemy:  new THREE.MeshStandardMaterial({ color: 0x3a2320, roughness: 0.62, metalness: 0.2 }),
  hullElite:  new THREE.MeshStandardMaterial({ color: 0x1c1c22, roughness: 0.34, metalness: 0.55 }),
  deck:       new THREE.MeshStandardMaterial({ color: 0x8a6a45, roughness: 0.82, metalness: 0.02 }),
  deckDark:   new THREE.MeshStandardMaterial({ color: 0x4a3a2c, roughness: 0.88, metalness: 0.02 }),
  white:      new THREE.MeshStandardMaterial({ color: 0xe6ebee, roughness: 0.55, metalness: 0.08 }),
  metal:      new THREE.MeshStandardMaterial({ color: 0x6d757c, roughness: 0.34, metalness: 0.85 }),
  gun:        new THREE.MeshStandardMaterial({ color: 0x35393d, roughness: 0.42, metalness: 0.8 }),
  glass:      new THREE.MeshStandardMaterial({ color: 0x0c1a22, roughness: 0.08, metalness: 0.5,
                                               emissive: 0x0b1c26, emissiveIntensity: 0.4 }),
  red:        new THREE.MeshStandardMaterial({ color: 0x8c2118, roughness: 0.6, metalness: 0.1 }),
  ball:       new THREE.MeshStandardMaterial({ color: 0x1b1d20, roughness: 0.35, metalness: 0.9,
                                               emissive: 0xff5a1e, emissiveIntensity: 0.55 }),
};

function shadowify(root) {
  root.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
}

/** A swept tube through a polyline — robust where a spline would degenerate. */
function tubeAlong(pts, radius, seg) {
  const pos = [], idx = [];
  const T = new THREE.Vector3(), N = new THREE.Vector3(), Bn = new THREE.Vector3();
  const UP = new THREE.Vector3(0, 1, 0);
  for (let i = 0; i < pts.length; i++) {
    const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
    T.subVectors(b, a);
    if (T.lengthSq() < 1e-10) T.set(0, 0, 1);
    T.normalize();
    N.crossVectors(UP, T);
    if (N.lengthSq() < 1e-8) N.set(1, 0, 0); else N.normalize();
    Bn.crossVectors(T, N).normalize();
    for (let j = 0; j < seg; j++) {
      const a2 = (j / seg) * Math.PI * 2;
      const c = Math.cos(a2) * radius, d = Math.sin(a2) * radius;
      pos.push(pts[i].x + N.x * c + Bn.x * d,
               pts[i].y + N.y * c + Bn.y * d,
               pts[i].z + N.z * c + Bn.z * d);
    }
  }
  for (let i = 0; i < pts.length - 1; i++) {
    for (let j = 0; j < seg; j++) {
      const j2 = (j + 1) % seg;
      const a = i * seg + j, b = i * seg + j2;
      const c = (i + 1) * seg + j, d = (i + 1) * seg + j2;
      idx.push(a, c, b, b, c, d);
    }
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return g;
}

/** Minimal position-only geometry merge, so railings cost one draw call. */
function mergeGeos(list) {
  let vCount = 0, iCount = 0;
  for (const g of list) { vCount += g.attributes.position.count; iCount += g.index.count; }
  const pos = new Float32Array(vCount * 3);
  const nrm = new Float32Array(vCount * 3);
  const idx = new Uint32Array(iCount);
  let vo = 0, io = 0;
  for (const g of list) {
    pos.set(g.attributes.position.array, vo * 3);
    if (g.attributes.normal) nrm.set(g.attributes.normal.array, vo * 3);
    const gi = g.index.array;
    for (let i = 0; i < gi.length; i++) idx[io + i] = gi[i] + vo;
    vo += g.attributes.position.count; io += gi.length;
  }
  const out = new THREE.BufferGeometry();
  out.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  out.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
  out.setIndex(new THREE.BufferAttribute(idx, 1));
  return out;
}

/** Builds a complete vessel. kind: 'player' | 'raider' | 'elite' */
function buildBoat(kind) {
  const spec = {
    player: { L: 13.5, B: 4.4, draft: 1.5, free: 2.0, hull: MAT.hullPlayer, deck: MAT.deck },
    raider: { L: 11.4, B: 3.9, draft: 1.3, free: 1.8, hull: MAT.hullEnemy,  deck: MAT.deckDark },
    elite:  { L: 16.0, B: 5.0, draft: 1.7, free: 2.3, hull: MAT.hullElite,  deck: MAT.deckDark },
  }[kind];

  const root = new THREE.Group();
  const { geo, gunwale } = buildHull(spec.L, spec.B, spec.draft, spec.free);
  const hull = new THREE.Mesh(geo, spec.hull);
  root.add(hull);

  const deck = new THREE.Mesh(buildDeck(gunwale), spec.deck);
  root.add(deck);

  // deckhouse, sitting on the sheer aft of amidships
  const deckY = spec.free * 1.02;
  const cabinW = spec.B * 0.60, cabinH = spec.L * 0.155, cabinL = spec.L * 0.29;
  const cabinZ = -spec.L * 0.11;
  const cabin = new THREE.Mesh(
    new THREE.BoxGeometry(cabinW, cabinH, cabinL),
    kind === 'player' ? MAT.white : MAT.hullEnemy,
  );
  cabin.position.set(0, deckY + cabinH * 0.5, cabinZ);
  root.add(cabin);

  const wind = new THREE.Mesh(
    new THREE.BoxGeometry(cabinW * 1.015, cabinH * 0.38, cabinL * 1.015), MAT.glass);
  wind.position.set(0, deckY + cabinH * 0.74, cabinZ);
  root.add(wind);

  const roof = new THREE.Mesh(new THREE.BoxGeometry(cabinW * 1.1, 0.11, cabinL * 1.1), MAT.metal);
  roof.position.set(0, deckY + cabinH + 0.05, cabinZ);
  root.add(roof);

  // mast + radar bar
  const mastH = spec.L * 0.30;
  const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.085, mastH, 8), MAT.metal);
  mast.position.set(0, deckY + cabinH + mastH * 0.5, cabinZ);
  root.add(mast);
  const radar = new THREE.Mesh(new THREE.BoxGeometry(spec.B * 0.30, 0.07, 0.16), MAT.white);
  radar.position.set(0, deckY + cabinH + mastH * 0.94, cabinZ);
  root.add(radar);

  // railings along the sheer
  const railPts = [];
  for (let side = -1; side <= 1; side += 2) {
    const pts = [];
    for (let i = 2; i < gunwale.length - 1; i++) {
      const [x, y, z] = gunwale[i];
      const v = new THREE.Vector3(x * side, y + 0.42, z);
      if (!pts.length || v.distanceTo(pts[pts.length - 1]) > 0.06) pts.push(v);
    }
    if (pts.length > 1) railPts.push(pts);
  }
  const railGeos = [];
  for (const pts of railPts) {
    railGeos.push(tubeAlong(pts, 0.035, 6));
    for (let i = 0; i < pts.length; i += 4) {
      const post = new THREE.CylinderGeometry(0.03, 0.03, 0.42, 5);
      post.translate(pts[i].x, pts[i].y - 0.21, pts[i].z);
      railGeos.push(post);
    }
  }
  if (railGeos.length) root.add(new THREE.Mesh(mergeGeos(railGeos), MAT.metal));

  // rub rail along the sheer line
  const rubGeos = [];
  for (let side = -1; side <= 1; side += 2) {
    const pts = [];
    for (let i = 1; i < gunwale.length; i++) {
      const [x, y, z] = gunwale[i];
      const v = new THREE.Vector3(x * side * 1.01, y - 0.14, z);
      if (!pts.length || v.distanceTo(pts[pts.length - 1]) > 0.06) pts.push(v);
    }
    if (pts.length > 1) rubGeos.push(tubeAlong(pts, 0.085, 6));
  }
  if (rubGeos.length) {
    root.add(new THREE.Mesh(mergeGeos(rubGeos), kind === 'player' ? MAT.red : MAT.white));
  }

  // ---- the gun: yaw ring + pitching barrel -----------------------------
  const turret = new THREE.Group();
  turret.position.set(0, spec.free * 1.06 + 0.22, spec.L * 0.26);
  const base = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.78, 0.42, 16), MAT.metal);
  turret.add(base);
  const shield = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.55, 0.9, 14, 1, false, -1.2, 2.4), MAT.gun);
  shield.position.y = 0.5;
  turret.add(shield);

  const pitch = new THREE.Group();
  pitch.position.y = 0.55;
  const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.115, 0.145, 3.3, 12), MAT.gun);
  barrel.rotation.x = Math.PI / 2;
  barrel.position.z = 1.5;
  pitch.add(barrel);
  const brake = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.17, 0.42, 12), MAT.gun);
  brake.rotation.x = Math.PI / 2;
  brake.position.z = 3.0;
  pitch.add(brake);
  turret.add(pitch);
  root.add(turret);

  shadowify(root);

  return {
    root, turret, pitch,
    muzzleLocal: new THREE.Vector3(0, 0.55, 3.2),
    spec,
  };
}

/* ═══════════════════════════════════════════════════════════════════════
   7.  WAKE RIBBON
   A fading foam trail that rides the wave surface behind each hull.
   ═══════════════════════════════════════════════════════════════════════ */

const WAKE_VERT = /* glsl */`
attribute float aAlpha;
varying float vA; varying vec2 vUv;
void main(){ vA = aAlpha; vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }
`;
const WAKE_FRAG = /* glsl */`
varying float vA; varying vec2 vUv;
void main(){
  float edge = smoothstep(0.0, 0.42, vUv.x) * smoothstep(1.0, 0.58, vUv.x);
  float a = vA * edge;
  if (a < 0.004) discard;
  gl_FragColor = vec4(vec3(0.94, 0.97, 1.0), a);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
`;

class Wake {
  constructor(width = 3.2, maxPts = 64, lifespan = 7) {
    this.maxPts = maxPts; this.lifespan = lifespan; this.width = width;
    this.pts = [];
    this.pos = new Float32Array(maxPts * 2 * 3);
    this.uv = new Float32Array(maxPts * 2 * 2);
    this.alpha = new Float32Array(maxPts * 2);
    const idx = [];
    for (let i = 0; i < maxPts - 1; i++) {
      const a = i * 2;
      idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
    }
    for (let i = 0; i < maxPts; i++) { this.uv[i * 4] = 0; this.uv[i * 4 + 2] = 1; }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
    g.setAttribute('uv', new THREE.BufferAttribute(this.uv, 2));
    g.setAttribute('aAlpha', new THREE.BufferAttribute(this.alpha, 1));
    g.setIndex(idx);
    g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
    this.geo = g;
    this.mesh = new THREE.Mesh(g, new THREE.ShaderMaterial({
      vertexShader: WAKE_VERT, fragmentShader: WAKE_FRAG,
      transparent: true, depthWrite: false, side: THREE.DoubleSide,
    }));
    this.mesh.frustumCulled = false;
    this.mesh.renderOrder = 2;
  }
  reset() { this.pts.length = 0; this.geo.setDrawRange(0, 0); }
  add(x, z, strength) {
    const p = this.pts;
    if (p.length && Math.hypot(x - p[p.length - 1].x, z - p[p.length - 1].z) < 1.1) {
      p[p.length - 1].s = Math.max(p[p.length - 1].s, strength);
      return;
    }
    p.push({ x, z, age: 0, s: strength });
    if (p.length > this.maxPts) p.shift();
  }
  update(dt) {
    const p = this.pts;
    for (let i = p.length - 1; i >= 0; i--) {
      p[i].age += dt;
      if (p[i].age > this.lifespan) { p.splice(0, i + 1); break; }
    }
    const n = p.length;
    if (n < 2) { this.geo.setDrawRange(0, 0); return; }
    for (let i = 0; i < n; i++) {
      const a = p[Math.max(0, i - 1)], b = p[Math.min(n - 1, i + 1)];
      let dx = b.x - a.x, dz = b.z - a.z;
      const l = Math.hypot(dx, dz) || 1;
      dx /= l; dz /= l;
      const px = -dz, pz = dx;
      const t = p[i].age / this.lifespan;
      const w = this.width * p[i].s * (0.35 + t * 1.5);
      const y = waterHeight(p[i].x, p[i].z) + 0.09;
      const i6 = i * 6;
      this.pos[i6]     = p[i].x - px * w; this.pos[i6 + 1] = y; this.pos[i6 + 2] = p[i].z - pz * w;
      this.pos[i6 + 3] = p[i].x + px * w; this.pos[i6 + 4] = y; this.pos[i6 + 5] = p[i].z + pz * w;
      const fade = (1 - t) * (1 - t) * (0.35 + 0.65 * p[i].s) * Math.min(1, i / 2);
      this.alpha[i * 2] = fade; this.alpha[i * 2 + 1] = fade;
    }
    this.geo.attributes.position.needsUpdate = true;
    this.geo.attributes.aAlpha.needsUpdate = true;
    this.geo.setDrawRange(0, (n - 1) * 6);
  }
}

/* ═══════════════════════════════════════════════════════════════════════
   8.  VESSEL — rigid body with sampled hydrostatics
   Buoyancy is integrated over probe points on the hull; each probe gets
   its own submergence, so the boat pitches, rolls and surfs for real.
   ═══════════════════════════════════════════════════════════════════════ */

const _v1 = new THREE.Vector3(), _v2 = new THREE.Vector3(), _v3 = new THREE.Vector3();
const _rt = new THREE.Vector3();
const _q1 = new THREE.Quaternion(), _m3 = new THREE.Matrix4();

class Vessel {
  constructor(kind) {
    const b = buildBoat(kind);
    Object.assign(this, b);
    this.kind = kind;
    const { L, B, draft } = this.spec;
    this.L = L; this.B = B;

    this.mass = 900 * L * B * draft * 0.16 + 4200;
    this.invI = new THREE.Vector3(
      1 / (this.mass * L * L / 12 * 0.85),   // pitch  (about local X)
      1 / (this.mass * L * L / 12 * 1.15),   // yaw    (about local Y)
      1 / (this.mass * B * B / 12 * 2.4),    // roll   (about local Z)
    );

    this.pos = new THREE.Vector3();
    this.vel = new THREE.Vector3();
    this.quat = new THREE.Quaternion();
    this.angVel = new THREE.Vector3();

    // hull probes: two rows down the length plus keel fore/aft
    this.probes = [];
    for (let i = 0; i < 6; i++) {
      const s = 0.09 + (i / 5) * 0.82;
      const z = (s - 0.5) * L;
      const bow = 1 - Math.max(0, (s - 0.58) / 0.42);
      const hw = Math.max(0.12, B * 0.36 * Math.pow(bow, 0.72));
      this.probes.push(new THREE.Vector3(-hw, -draft * 0.55, z));
      this.probes.push(new THREE.Vector3( hw, -draft * 0.55, z));
    }
    this.probes.push(new THREE.Vector3(0, -draft * 0.92, -L * 0.34));
    this.probes.push(new THREE.Vector3(0, -draft * 0.75,  L * 0.34));

    this.eqDepth = draft * 0.50;
    this.buoyK = (this.mass * G) / (this.probes.length * this.eqDepth);

    this.maxThrust = kind === 'elite' ? 24500 : kind === 'raider' ? 19500 : 21000;
    this.throttle = 0;
    this.rudder = 0;
    this.hp = kind === 'player' ? 100 : kind === 'elite' ? 90 : 45;
    this.maxHp = this.hp;
    this.alive = true;
    this.sinkT = 0;
    this.submerged = 0;
    this.slamCool = 0;
    this.lastBowY = 0;

    this.wake = new Wake(kind === 'elite' ? 4 : 3.1);
    this._probeWorld = this.probes.map(() => new THREE.Vector3());
  }

  addTo(s) { s.add(this.root); s.add(this.wake.mesh); }
  removeFrom(s) { s.remove(this.root); s.remove(this.wake.mesh); }

  localToWorld(v, out) {
    return out.copy(v).applyQuaternion(this.quat).add(this.pos);
  }

  /** velocity of a point given by world-space offset r from the CG */
  pointVel(r, out) {
    return out.copy(this.angVel).cross(r).add(this.vel);
  }

  step(dt) {
    if (!this.alive) { this.sink(dt); return; }

    const force = _v1.set(0, -this.mass * G, 0);
    const torque = _v2.set(0, 0, 0);
    const n = this.probes.length;
    let subCount = 0;

    const r = new THREE.Vector3(), pv = new THREE.Vector3(), f = new THREE.Vector3();

    for (let i = 0; i < n; i++) {
      const wp = this._probeWorld[i];
      wp.copy(this.probes[i]).applyQuaternion(this.quat);
      r.copy(wp);
      wp.add(this.pos);

      const h = waterHeight(wp.x, wp.z);
      const depth = h - wp.y;
      if (depth <= 0) continue;
      subCount++;

      const d = Math.min(depth, this.eqDepth * 2.6);
      f.set(0, this.buoyK * d, 0);

      // vertical damping at the probe — this is what kills the bobbing
      this.pointVel(r, pv);
      f.y -= pv.y * 1500 * Math.min(1, d / this.eqDepth);

      force.add(f);
      torque.add(_v3.copy(r).cross(f));
    }

    const sub = subCount / n;
    this.submerged = sub;

    if (sub > 0.02) {
      // --- directional hydrodynamic drag in the hull frame
      const vl = _v3.copy(this.vel).applyQuaternion(_q1.copy(this.quat).invert());
      const dragL = new THREE.Vector3(
        -Math.abs(vl.x) * vl.x * 900 * sub,
        -Math.abs(vl.y) * vl.y * 700 * sub,
        -Math.abs(vl.z) * vl.z * 62  * sub,
      );
      force.add(dragL.applyQuaternion(this.quat));

      // --- propulsion off the transom
      const fwd = _v3.set(0, 0, 1).applyQuaternion(this.quat);
      const thrust = this.throttle * this.maxThrust * sub;
      force.addScaledVector(fwd, thrust);

      // --- rudder: torque scales with flow over the blade
      const speed = this.vel.length();
      const authority = 0.28 + 0.72 * Math.min(1, speed / 5.5);
      const up = _v1.clone().set(0, 1, 0).applyQuaternion(this.quat);
      torque.addScaledVector(up, -this.rudder * this.mass * 17.0 * authority * sub);

      // --- the sea shoves you: gravity component along the wave face
      const nrm = waterNormal(this.pos.x, this.pos.z);
      force.x += nrm.x * this.mass * G * 0.26 * sub;
      force.z += nrm.z * this.mass * G * 0.26 * sub;

      // --- heel into the turn
      const fw2 = _v3.set(0, 0, 1).applyQuaternion(this.quat);
      torque.addScaledVector(fw2, this.rudder * speed * this.mass * 0.062 * sub);
    }

    // --- ballast keel: righting couple proportional to heel angle.
    // Form stability alone lets a knockdown become a capsize, and an
    // inverted hull has its rudder torque reversed — it sails away forever.
    {
      const bUp = _v3.set(0, 1, 0).applyQuaternion(this.quat);
      const angle = Math.acos(clamp(bUp.y, -1, 1));
      if (angle > 0.004) {
        _rt.set(-bUp.z, 0, bUp.x);            // cross(bUp, worldUp)
        if (_rt.lengthSq() < 1e-9) _rt.set(1, 0, 0);
        _rt.normalize();
        torque.addScaledVector(_rt, this.mass * 11.0 * angle * Math.max(0.22, sub));
      }
    }

    // integrate linear
    this.vel.addScaledVector(force, dt / this.mass);
    this.vel.multiplyScalar(Math.exp(-0.12 * dt));
    this.pos.addScaledVector(this.vel, dt);

    // integrate angular (local-frame inertia)
    const tl = _v3.copy(torque).applyQuaternion(_q1.copy(this.quat).invert());
    tl.x *= this.invI.x; tl.y *= this.invI.y; tl.z *= this.invI.z;
    this.angVel.addScaledVector(tl.applyQuaternion(this.quat), dt);
    this.angVel.multiplyScalar(Math.exp(-2.1 * dt));

    _q1.set(this.angVel.x * dt * 0.5, this.angVel.y * dt * 0.5, this.angVel.z * dt * 0.5, 0)
       .multiply(this.quat);
    this.quat.set(
      this.quat.x + _q1.x, this.quat.y + _q1.y,
      this.quat.z + _q1.z, this.quat.w + _q1.w,
    ).normalize();

    this.root.position.copy(this.pos);
    this.root.quaternion.copy(this.quat);

    this.spray(dt);
    this.wake.add(this.pos.x, this.pos.z,
      clamp(this.vel.length() / 9, 0.12, 1) * (0.4 + sub * 0.6));
    this.wake.update(dt);
  }

  /** bow spray when the hull slams a wave, plus a constant bow wave */
  spray(dt) {
    this.slamCool -= dt;
    const bow = this.localToWorld(_v1.set(0, -0.2, this.L * 0.44), _v2.clone());
    const h = waterHeight(bow.x, bow.z);
    const rel = h - bow.y;
    const spd = this.vel.length();

    if (rel > 0.12 && this.slamCool <= 0 && this.lastBowY - rel < -0.25) {
      const p = Math.min(2.4, 0.6 + rel * 0.9 + spd * 0.09);
      splash(bow.x, h, bow.z, p, 1.3);
      this.slamCool = 0.14;
      if (this === player && p > 1.3) { shake(p * 0.55); sfx.slam(p); }
    }
    this.lastBowY = rel;

    // bow wave: foam peeling off both shoulders
    if (spd > 1.8) {
      let rate = dt * (16 + spd * 8);
      while (rate > 0) {
        if (rate < 1 && Math.random() > rate) break;
        rate -= 1;
        const side = Math.random() < 0.5 ? -1 : 1;
        const t = 0.18 + Math.random() * 0.26;
        const q = this.localToWorld(
          _v1.set(side * this.B * (0.30 + t * 0.35), 0.0, this.L * t), _v2.clone());
        const wy = waterHeight(q.x, q.z);
        fxSoft.spawn(q.x, wy + 0.05, q.z,
          side * rand(1.2, 3.8) + this.vel.x * 0.3, rand(0.5, 1.4 + spd * 0.18),
          this.vel.z * 0.3 + rand(-1, 1),
          rand(0.3, 0.85), rand(0.6, 1.3), C_FOAM, 1.0, -7.5, 0.7, 0.6);
      }
      // propeller wash off the transom
      if (Math.random() < dt * 22 * Math.abs(this.throttle)) {
        const q = this.localToWorld(_v1.set(rand(-0.7, 0.7), -0.3, -this.L * 0.48), _v2.clone());
        const wy = waterHeight(q.x, q.z);
        fxSoft.spawn(q.x, wy + 0.05, q.z, rand(-1.5, 1.5), rand(0.4, 2.2), rand(-1.5, 1.5),
          rand(0.4, 1.0), rand(0.7, 1.4), C_FOAM, 1.1, -6.5, 0.9, 0.55);
      }
    }
  }

  sink(dt) {
    this.sinkT += dt;
    this.pos.y -= dt * (0.7 + this.sinkT * 0.45);
    this.pos.addScaledVector(this.vel, dt);
    this.vel.multiplyScalar(Math.exp(-1.4 * dt));
    _q1.setFromAxisAngle(_v1.set(0, 0, 1), dt * 0.55 * this.sinkDir);
    this.quat.multiply(_q1);
    _q1.setFromAxisAngle(_v1.set(1, 0, 0), dt * 0.18);
    this.quat.multiply(_q1);
    this.root.position.copy(this.pos);
    this.root.quaternion.copy(this.quat);
    if (Math.random() < dt * 14) {
      const wy = waterHeight(this.pos.x, this.pos.z);
      fxSoft.spawn(this.pos.x + rand(-2, 2), wy + 0.2, this.pos.z + rand(-3, 3),
        rand(-1, 1), rand(1, 4), rand(-1, 1), rand(1.5, 3.6), rand(1, 2.4), C_SMOKE, 0.8, 1.1, 2.2, 0.55);
    }
    this.wake.update(dt);
  }

  damage(amount, at) {
    if (!this.alive) return;
    this.hp -= amount;
    if (at) explode(at.x, at.y, at.z, 0.7);
    if (this.hp <= 0) {
      this.hp = 0; this.alive = false;
      this.sinkDir = Math.random() < 0.5 ? -1 : 1;
      explode(this.pos.x, this.pos.y + 1, this.pos.z, 1.9);
      sfx.boom(this === player ? 1.4 : 1.0, this.pos.distanceTo(camera.position));
    }
  }
}

/* ═══════════════════════════════════════════════════════════════════════
   9.  GUNNERY — ballistic shells
   Shells are launched with the elevation that actually lands them on the
   aim point, then flown under gravity. Lead your targets.
   ═══════════════════════════════════════════════════════════════════════ */

const SHELL_SPEED = 78;

function solveBallistic(from, to, speed, out) {
  const dx = Math.hypot(to.x - from.x, to.z - from.z);
  const dy = to.y - from.y;
  const v2 = speed * speed;
  const disc = v2 * v2 - G * (G * dx * dx + 2 * dy * v2);
  const angle = disc < 0 ? Math.PI / 4 : Math.atan2(v2 - Math.sqrt(disc), G * dx);
  const ux = (to.x - from.x) / (dx || 1), uz = (to.z - from.z) / (dx || 1);
  const c = Math.cos(angle) * speed;
  return out.set(ux * c, Math.sin(angle) * speed, uz * c);
}

const shellGeo = new THREE.SphereGeometry(0.28, 10, 8);
const shells = [];

function fireShell(owner, from, vel, dmg) {
  const mesh = new THREE.Mesh(shellGeo, MAT.ball);
  mesh.castShadow = true;
  mesh.position.copy(from);
  scene.add(mesh);
  shells.push({
    mesh, owner, dmg, life: 9,
    vel: vel.clone(), pos: from.clone(), trail: 0,
  });
}

function updateShells(dt) {
  for (let i = shells.length - 1; i >= 0; i--) {
    const s = shells[i];
    s.life -= dt;
    s.vel.y -= G * dt;
    s.vel.multiplyScalar(Math.exp(-0.06 * dt));
    s.pos.addScaledVector(s.vel, dt);
    s.mesh.position.copy(s.pos);

    s.trail -= dt;
    if (s.trail <= 0) {
      s.trail = 0.05;
      fxSoft.spawn(s.pos.x, s.pos.y, s.pos.z, rand(-.3, .3), rand(0, .6), rand(-.3, .3),
        rand(0.25, 0.55), rand(0.5, 1.0), C_SMOKE, 1.2, 0.5, 1.1, 0.24);
    }

    let dead = s.life <= 0;

    if (!dead) {
      for (const v of fleet) {
        if (v === s.owner || !v.alive) continue;
        _v1.copy(s.pos).sub(v.pos);
        if (_v1.lengthSq() > 900) continue;
        _v1.applyQuaternion(_q1.copy(v.quat).invert());
        const e = (_v1.x / (v.B * 0.72)) ** 2 + (_v1.y / 3.2) ** 2 + (_v1.z / (v.L * 0.52)) ** 2;
        if (e < 1) {
          v.damage(s.dmg, s.pos);
          if (v === player) { hurtFlash(); shake(1.5); }
          else if (s.owner === player) { score += 15; popHit(); }
          dead = true;
          break;
        }
      }
    }

    if (!dead && s.pos.y < waterHeight(s.pos.x, s.pos.z)) {
      const wy = waterHeight(s.pos.x, s.pos.z);
      splash(s.pos.x, wy, s.pos.z, 1.5, 1.1);
      sfx.splash(camera.position.distanceTo(s.pos));
      dead = true;
    }

    if (dead) { scene.remove(s.mesh); shells.splice(i, 1); }
  }
}

/* ═══════════════════════════════════════════════════════════════════════
   10.  AUDIO — everything synthesised, no assets
   ═══════════════════════════════════════════════════════════════════════ */

const sfx = {
  ctx: null, master: null, noiseBuf: null, engineGain: null, engineOsc: null, on: false,

  init() {
    if (this.ctx) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    this.ctx = new AC();
    this.master = this.ctx.createGain();
    this.master.gain.value = 0.55;
    this.master.connect(this.ctx.destination);

    const len = this.ctx.sampleRate * 2;
    this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
    const d = this.noiseBuf.getChannelData(0);
    for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;

    // ocean bed: looping filtered noise with a slow swell
    const surf = this.ctx.createBufferSource();
    surf.buffer = this.noiseBuf; surf.loop = true;
    const lp = this.ctx.createBiquadFilter();
    lp.type = 'lowpass'; lp.frequency.value = 460; lp.Q.value = 0.7;
    const sg = this.ctx.createGain(); sg.gain.value = 0.12;
    const lfo = this.ctx.createOscillator(); lfo.frequency.value = 0.09;
    const lfoG = this.ctx.createGain(); lfoG.gain.value = 0.055;
    lfo.connect(lfoG); lfoG.connect(sg.gain);
    surf.connect(lp); lp.connect(sg); sg.connect(this.master);
    surf.start(); lfo.start();

    // engine
    const eo = this.ctx.createOscillator();
    eo.type = 'sawtooth'; eo.frequency.value = 42;
    const ef = this.ctx.createBiquadFilter();
    ef.type = 'lowpass'; ef.frequency.value = 220;
    const eg = this.ctx.createGain(); eg.gain.value = 0;
    eo.connect(ef); ef.connect(eg); eg.connect(this.master);
    eo.start();
    this.engineGain = eg; this.engineOsc = eo;
    this.on = true;
  },

  noise(dur, freq, q, gain, type = 'lowpass') {
    if (!this.on) return;
    const t = this.ctx.currentTime;
    const src = this.ctx.createBufferSource();
    src.buffer = this.noiseBuf;
    src.playbackRate.value = rand(0.8, 1.2);
    const f = this.ctx.createBiquadFilter();
    f.type = type; f.frequency.value = freq; f.Q.value = q;
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(gain, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    src.connect(f); f.connect(g); g.connect(this.master);
    src.start(t); src.stop(t + dur + 0.05);
    return f;
  },

  shot() {
    if (!this.on) return;
    const t = this.ctx.currentTime;
    const f = this.noise(0.55, 1400, 0.6, 0.55);
    if (f) { f.frequency.setValueAtTime(1600, t); f.frequency.exponentialRampToValueAtTime(90, t + 0.4); }
    const o = this.ctx.createOscillator();
    o.type = 'sine'; o.frequency.setValueAtTime(140, t);
    o.frequency.exponentialRampToValueAtTime(34, t + 0.35);
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.65, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.45);
    o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 0.5);
  },

  boom(power = 1, dist = 0) {
    if (!this.on) return;
    const att = clamp(1 - dist / 420, 0.08, 1);
    const t = this.ctx.currentTime;
    const f = this.noise(1.3 * power, 700, 0.5, 0.75 * att);
    if (f) { f.frequency.setValueAtTime(900, t); f.frequency.exponentialRampToValueAtTime(55, t + 0.9); }
    const o = this.ctx.createOscillator();
    o.type = 'sine'; o.frequency.setValueAtTime(95, t);
    o.frequency.exponentialRampToValueAtTime(22, t + 0.8);
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.85 * att, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 1.1);
    o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 1.2);
  },

  splash(dist = 0) { this.noise(0.45, 2600, 0.8, clamp(0.28 - dist / 900, 0.02, 0.28), 'bandpass'); },
  slam(p = 1) { this.noise(0.6, 900, 0.5, clamp(0.18 * p, 0.03, 0.4)); },
  engine(load) {
    if (!this.on) return;
    this.engineGain.gain.value = 0.02 + load * 0.075;
    this.engineOsc.frequency.value = 38 + load * 46;
  },
};

/* ═══════════════════════════════════════════════════════════════════════
   11.  INPUT
   ═══════════════════════════════════════════════════════════════════════ */

const keys = Object.create(null);
const mouse = { x: innerWidth / 2, y: innerHeight / 2, down: false };
const ndc = new THREE.Vector2();
const raycaster = new THREE.Raycaster();

/**
 * Register a key under BOTH its layout-dependent value (e.key: 'w') and its
 * physical position (e.code: 'KeyW'). On AZERTY/QWERTZ/Dvorak the WASD keys
 * report entirely different characters, so binding to e.key alone leaves
 * those players unable to steer at all.
 */
function keyIds(e, out) {
  out.length = 0;
  if (e.key) out.push(e.key.toLowerCase());
  if (e.code) out.push(e.code);
  return out;
}
const _ids = [];
const down = (...ids) => { for (const i of ids) if (keys[i]) return true; return false; };
function clearKeys() { for (const k in keys) keys[k] = false; mouse.down = false; }

addEventListener('keydown', e => {
  for (const id of keyIds(e, _ids)) keys[id] = true;
  if (e.code === 'Space' || e.key === ' ') e.preventDefault();
  if (e.repeat) return;
  if ((e.code === 'KeyP' || e.key.toLowerCase() === 'p') && state === 'play') paused = !paused;
});
addEventListener('keyup', e => { for (const id of keyIds(e, _ids)) keys[id] = false; });
addEventListener('blur', clearKeys);

addEventListener('mousemove', e => {
  mouse.x = e.clientX; mouse.y = e.clientY;
  crossEl.style.transform = `translate(${e.clientX}px,${e.clientY}px)`;
});
addEventListener('mousedown', e => {
  if (e.button === 0) mouse.down = true;
  try { window.focus(); } catch (_) {}
});
addEventListener('mouseup', e => { if (e.button === 0) mouse.down = false; });
addEventListener('contextmenu', e => e.preventDefault());

/**
 * Where is the player pointing?
 *  1. a hull under the cursor wins (snap the gun onto the target), otherwise
 *  2. march the ray until it breaks the surface, then bisect.
 * Marching beats Newton here: at 200 m the sight line is nearly horizontal,
 * so a derivative-based solve slides hundreds of metres per iteration.
 */
const aimPoint = new THREE.Vector3();
let aimLocked = false;

function surfaceHit(o, d, maxT) {
  const MARCH = 44;
  let prevT = 0.6;
  let prev = (o.y + d.y * prevT) - waterHeight(o.x + d.x * prevT, o.z + d.z * prevT, sea.time, 2);
  if (prev <= 0) return prevT;
  for (let i = 1; i <= MARCH; i++) {
    const t = maxT * Math.pow(i / MARCH, 2.0);
    const diff = (o.y + d.y * t) - waterHeight(o.x + d.x * t, o.z + d.z * t, sea.time, 2);
    if (diff <= 0) {
      let lo = prevT, hi = t;
      for (let k = 0; k < 12; k++) {
        const m = (lo + hi) * 0.5;
        const md = (o.y + d.y * m) - waterHeight(o.x + d.x * m, o.z + d.z * m, sea.time, 2);
        if (md > 0) lo = m; else hi = m;
      }
      return (lo + hi) * 0.5;
    }
    prevT = t; prev = diff;
  }
  return -1;
}

const _ray = new THREE.Vector3();
function updateAimPoint() {
  ndc.set((mouse.x / innerWidth) * 2 - 1, -(mouse.y / innerHeight) * 2 + 1);
  raycaster.setFromCamera(ndc, camera);
  const o = raycaster.ray.origin, d = raycaster.ray.direction;

  const tw = surfaceHit(o, d, 900);
  let best = tw > 0 ? tw : Infinity;
  let target = null;

  for (const e of enemies) {
    if (!e.alive) continue;
    _ray.copy(e.pos).sub(o);
    const along = _ray.dot(d);
    if (along < 4 || along > best + e.L) continue;
    const perp = Math.sqrt(Math.max(0, _ray.lengthSq() - along * along));
    if (perp < Math.max(7.0, e.L * 0.55)) { best = along; target = e; }
  }

  aimLocked = !!target;
  if (target) { aimPoint.copy(target.pos); aimPoint.y += 0.8; return true; }
  if (tw > 0) {
    aimPoint.copy(o).addScaledVector(d, tw);
    return true;
  }
  aimPoint.copy(o).addScaledVector(d, 900);
  aimPoint.y = waterHeight(aimPoint.x, aimPoint.z);
  return false;
}

/* ═══════════════════════════════════════════════════════════════════════
   12.  GAME STATE · PLAYER · ENEMY AI
   ═══════════════════════════════════════════════════════════════════════ */

let state = 'menu';          // menu | play | over
let paused = false;
let score = 0, kills = 0, waveNo = 0, waveTimer = 0;
let heat = 0, gunCool = 0, overheated = false;
let player = null;
const fleet = [];            // every live vessel, player included
const enemies = [];

const shakeState = { amt: 0 };
function shake(a) { shakeState.amt = Math.min(2.6, shakeState.amt + a); }

function spawnPlayer() {
  player = new Vessel('player');
  player.pos.set(0, 0.6, 0);
  player.addTo(scene);
  fleet.push(player);
}

function spawnEnemy(kind, angle, dist) {
  const e = new Vessel(kind);
  e.pos.set(
    player.pos.x + Math.cos(angle) * dist, 0.6,
    player.pos.z + Math.sin(angle) * dist,
  );
  e.quat.setFromAxisAngle(_v1.set(0, 1, 0),
    Math.atan2(-Math.cos(angle), -Math.sin(angle)));
  e.ai = {
    cool: rand(1.5, 4),
    orbit: Math.random() < 0.5 ? 1 : -1,
    range: kind === 'elite' ? rand(70, 95) : rand(45, 80),
    accuracy: kind === 'elite' ? 3.2 : 6.5,
    rof: kind === 'elite' ? 2.4 : 3.6,
    dmg: kind === 'elite' ? 16 : 11,
    turretYaw: 0, turretPitch: 0,
  };
  e.addTo(scene);
  fleet.push(e); enemies.push(e);
  return e;
}

function startWave() {
  waveNo++;
  const count = Math.min(9, 1 + Math.floor(waveNo * 0.8));
  const elites = waveNo >= 3 ? Math.min(3, Math.floor((waveNo - 1) / 3)) : 0;
  for (let i = 0; i < count; i++) {
    const a = (i / count) * Math.PI * 2 + rand(-0.4, 0.4);
    spawnEnemy(i < elites ? 'elite' : 'raider', a, rand(150, 230));
  }
  waveTimer = 5;
  showAlert(`WAVE ${waveNo} — ${count} CONTACTS`, 2.4, '#ffcf4d');
}

/* ---------- player control ---------- */
const _aimDir = new THREE.Vector3(), _muzzle = new THREE.Vector3(), _shotV = new THREE.Vector3();

function controlPlayer(dt) {
  const p = player;
  if (!p.alive) return;

  const fwd  = down('w', 'KeyW', 'arrowup', 'ArrowUp') ? 1 : 0;
  const back = down('s', 'KeyS', 'arrowdown', 'ArrowDown') ? 1 : 0;
  const anchor = down('x', 'KeyX');
  let target = fwd - back * 0.75;
  if (anchor) target = 0;
  p.throttle = damp(p.throttle, target, 2.2, dt);
  if (anchor) p.vel.multiplyScalar(Math.exp(-1.6 * dt));

  const left  = down('a', 'KeyA', 'arrowleft', 'ArrowLeft') ? 1 : 0;
  const right = down('d', 'KeyD', 'arrowright', 'ArrowRight') ? 1 : 0;
  p.rudder = damp(p.rudder, right - left, 6.5, dt);

  // --- traverse the gun toward the cursor
  const onWater = updateAimPoint();
  p.root.updateMatrixWorld(true);
  p.turret.getWorldPosition(_muzzle);
  solveBallistic(_muzzle, aimPoint, SHELL_SPEED, _aimDir);

  const local = _v1.copy(_aimDir).applyQuaternion(_q1.copy(p.quat).invert());
  const wantYaw = Math.atan2(local.x, local.z);
  const wantPitch = Math.asin(clamp(local.y / (local.length() || 1), -1, 1));

  let dy = wantYaw - p.turret.rotation.y;
  while (dy > Math.PI) dy -= Math.PI * 2;
  while (dy < -Math.PI) dy += Math.PI * 2;
  p.turret.rotation.y += clamp(dy, -3.4 * dt, 3.4 * dt);
  p.pitch.rotation.x = damp(p.pitch.rotation.x, -clamp(wantPitch, -0.25, 1.05), 9, dt);

  // --- fire
  gunCool -= dt;
  heat = Math.max(0, heat - dt * (overheated ? 42 : 24));
  if (overheated && heat <= 2) overheated = false;

  const firing = mouse.down || down(' ', 'Space');
  if (firing && gunCool <= 0 && !overheated && onWater) {
    gunCool = 0.42;
    heat += 12;
    if (heat >= 100) { heat = 100; overheated = true; showAlert('GUN OVERHEATED', 1.2, '#ff6a3d'); }

    p.root.updateMatrixWorld(true);
    p.pitch.getWorldPosition(_muzzle);
    const dir = _v3.set(0, 0, 1).applyQuaternion(p.pitch.getWorldQuaternion(_q1)).normalize();
    _muzzle.addScaledVector(dir, 3.3);
    _shotV.copy(dir).multiplyScalar(SHELL_SPEED).add(p.vel);

    fireShell(p, _muzzle, _shotV, 24);
    muzzleFlash(_muzzle, dir);
    p.vel.addScaledVector(dir, -0.35);
    p.angVel.addScaledVector(_v2.set(0, 0, 1).applyQuaternion(p.quat), -dir.x * 0.03);
    shake(0.55);
    sfx.shot();
  }
}

/* ---------- enemy behaviour ---------- */
const _to = new THREE.Vector3(), _lead = new THREE.Vector3();

function controlEnemy(e, dt) {
  if (!e.alive || !player.alive) { e.throttle = damp(e.throttle, 0, 1, dt); return; }
  const ai = e.ai;

  _to.copy(player.pos).sub(e.pos);
  const dist = _to.length();

  // steer: close to preferred range, then orbit
  const bearing = Math.atan2(_to.x, _to.z);
  const tangent = bearing + ai.orbit * Math.PI * 0.42;
  const closing = dist > ai.range * 1.15;
  const backing = dist < ai.range * 0.55;
  const want = closing ? bearing : backing ? bearing + Math.PI : tangent;

  const yaw = Math.atan2(
    2 * (e.quat.w * e.quat.y + e.quat.x * e.quat.z),
    1 - 2 * (e.quat.y * e.quat.y + e.quat.x * e.quat.x),
  );
  let err = want - yaw;
  while (err > Math.PI) err -= Math.PI * 2;
  while (err < -Math.PI) err += Math.PI * 2;

  e.rudder = damp(e.rudder, clamp(-err * 1.5, -1, 1), 4, dt);
  e.throttle = damp(e.throttle, closing ? 1 : backing ? 0.55 : 0.68, 1.4, dt);

  // aim with lead
  const flight = dist / (SHELL_SPEED * 0.82);
  _lead.copy(player.vel).multiplyScalar(flight).add(player.pos);
  _lead.y = waterHeight(_lead.x, _lead.z) + 1.0;

  e.root.updateMatrixWorld(true);
  e.turret.getWorldPosition(_muzzle);
  solveBallistic(_muzzle, _lead, SHELL_SPEED, _aimDir);
  const local = _v1.copy(_aimDir).applyQuaternion(_q1.copy(e.quat).invert());
  const wantYaw = Math.atan2(local.x, local.z);
  const wantPitch = Math.asin(clamp(local.y / (local.length() || 1), -1, 1));

  let dy = wantYaw - e.turret.rotation.y;
  while (dy > Math.PI) dy -= Math.PI * 2;
  while (dy < -Math.PI) dy += Math.PI * 2;
  e.turret.rotation.y += clamp(dy, -1.9 * dt, 1.9 * dt);
  e.pitch.rotation.x = damp(e.pitch.rotation.x, -clamp(wantPitch, -0.25, 1.05), 5, dt);

  ai.cool -= dt;
  if (ai.cool <= 0 && dist < 150 && Math.abs(dy) < 0.09) {
    ai.cool = ai.rof * rand(0.75, 1.3);
    e.root.updateMatrixWorld(true);
    e.pitch.getWorldPosition(_muzzle);
    const dir = _v3.set(0, 0, 1).applyQuaternion(e.pitch.getWorldQuaternion(_q1)).normalize();
    _muzzle.addScaledVector(dir, 3.3);
    const spread = ai.accuracy / 100;
    dir.x += rand(-spread, spread); dir.y += rand(-spread, spread) * 0.5; dir.z += rand(-spread, spread);
    dir.normalize();
    _shotV.copy(dir).multiplyScalar(SHELL_SPEED).add(e.vel);
    fireShell(e, _muzzle, _shotV, ai.dmg);
    muzzleFlash(_muzzle, dir);
    sfx.boom(0.35, _muzzle.distanceTo(camera.position));
  }
}

/* ---------- hull-to-hull collisions ---------- */
function collide(dt) {
  for (let i = 0; i < fleet.length; i++) {
    for (let j = i + 1; j < fleet.length; j++) {
      const a = fleet[i], b = fleet[j];
      if (!a.alive || !b.alive) continue;
      _v1.copy(b.pos).sub(a.pos); _v1.y = 0;
      const d = _v1.length();
      const rad = (a.L + b.L) * 0.24;
      if (d > rad || d < 0.001) continue;
      _v1.divideScalar(d);
      const push = (rad - d) * 0.5;
      a.pos.addScaledVector(_v1, -push); b.pos.addScaledVector(_v1, push);
      const rel = _v2.copy(b.vel).sub(a.vel).dot(_v1);
      if (rel < -3) {
        const impact = Math.min(18, -rel * 1.6);
        a.damage(impact, null); b.damage(impact, null);
        splash((a.pos.x + b.pos.x) / 2, waterHeight(a.pos.x, a.pos.z), (a.pos.z + b.pos.z) / 2, 1.6, 2);
        if (a === player || b === player) { shake(1.2); hurtFlash(); }
        sfx.slam(1.6);
      }
      a.vel.addScaledVector(_v1, rel * 0.4);
      b.vel.addScaledVector(_v1, -rel * 0.4);
    }
  }
}

/* ---------- rogue swells ---------- */
let swellTimer = rand(18, 30), swellHold = 0;
function updateSea(dt) {
  sea.time += dt;
  if (swellHold > 0) {
    swellHold -= dt;
    if (swellHold <= 0) sea.target = 1.0;
  } else {
    swellTimer -= dt;
    if (swellTimer <= 0 && state === 'play') {
      swellTimer = rand(26, 46);
      swellHold = rand(11, 17);
      sea.target = rand(1.75, 2.15);
      showAlert('⚠ ROGUE SWELL INBOUND', 3, '#ffcf4d');
      sfx.noise(2.2, 300, 0.4, 0.3);
    }
  }
  sea.amp = damp(sea.amp, sea.target, 0.45, dt);
  waterUniforms.uAmp.value = sea.amp;
  waterUniforms.uTime.value = sea.time;
}

/* ═══════════════════════════════════════════════════════════════════════
   13.  HUD
   ═══════════════════════════════════════════════════════════════════════ */

const $ = id => document.getElementById(id);
const crossEl = $('cross'), chRing = $('chring'), alertEl = $('alert');
const vigEl = $('vig'), flashEl = $('flash');
const startEl = $('start'), overEl = $('gameover'), loadEl = $('loading');
const radar = $('radar').getContext('2d');

let alertT = 0, hurtT = 0, hitT = 0;

function showAlert(text, dur, color) {
  alertEl.textContent = text;
  alertEl.style.color = color || '#ffcf4d';
  alertEl.style.opacity = '1';
  alertT = dur;
}
function hurtFlash() { hurtT = 0.55; }
function popHit() { hitT = 0.16; }

function updateHUD(dt) {
  if (alertT > 0) { alertT -= dt; if (alertT <= 0) alertEl.style.opacity = '0'; }
  if (hurtT > 0) { hurtT -= dt; vigEl.style.opacity = String(clamp(hurtT / 0.55, 0, 1) * 0.9); }
  else if (player && player.alive) vigEl.style.opacity = String((1 - player.hp / 100) * 0.42);

  if (hitT > 0) { hitT -= dt; chRing.setAttribute('opacity', '1'); chRing.classList.add('hot'); }
  else {
    chRing.setAttribute('opacity', (overheated || aimLocked) ? '0.95' : '0.35');
    chRing.classList.toggle('hot', overheated || aimLocked);
  }

  if (!player) return;
  const hp = Math.max(0, Math.round(player.hp));
  $('hullnum').textContent = hp;
  $('hullfill').style.width = hp + '%';
  $('hullfill').style.background = hp > 55
    ? 'linear-gradient(90deg,#2fd79a,#8ef0c4)'
    : hp > 25 ? 'linear-gradient(90deg,#e0b23a,#f5d976)'
              : 'linear-gradient(90deg,#c9392a,#ef7a5c)';
  $('heatnum').textContent = Math.round(heat) + '%';
  $('heatfill').style.width = heat + '%';
  $('thr').textContent = (player.throttle >= 0 ? '+' : '') + (player.throttle * 100).toFixed(0);
  $('kts').textContent = (player.vel.length() * 1.944).toFixed(1);
  $('score').textContent = score.toLocaleString();
  $('wavecount').textContent = `WAVE ${waveNo} · ${enemies.filter(e => e.alive).length} LEFT`;

  drawRadar();
}

function drawRadar() {
  const c = radar, S = 150, R = S / 2, RANGE = 280;
  c.clearRect(0, 0, S, S);
  c.fillStyle = 'rgba(8,20,28,.55)'; c.beginPath(); c.arc(R, R, R - 1, 0, 7); c.fill();
  c.strokeStyle = 'rgba(150,200,230,.22)'; c.lineWidth = 1;
  for (const rr of [R / 3, (R / 3) * 2, R - 2]) { c.beginPath(); c.arc(R, R, rr, 0, 7); c.stroke(); }

  const yaw = Math.atan2(
    2 * (player.quat.w * player.quat.y + player.quat.x * player.quat.z),
    1 - 2 * (player.quat.y * player.quat.y + player.quat.x * player.quat.x),
  );
  const cs = Math.cos(-yaw), sn = Math.sin(-yaw);

  for (const e of enemies) {
    if (!e.alive) continue;
    let dx = e.pos.x - player.pos.x, dz = e.pos.z - player.pos.z;
    let rx = dx * cs - dz * sn, rz = dx * sn + dz * cs;
    const d = Math.hypot(rx, rz);
    const k = Math.min(1, d / RANGE) * (R - 8) / (d || 1);
    const px = R + rx * k, py = R - rz * k;
    c.fillStyle = e.kind === 'elite' ? '#ff5a3c' : '#ffb03a';
    c.beginPath(); c.arc(px, py, e.kind === 'elite' ? 3.6 : 2.8, 0, 7); c.fill();
  }

  c.fillStyle = '#54e6b4';
  c.beginPath(); c.moveTo(R, R - 7); c.lineTo(R - 4.5, R + 5); c.lineTo(R + 4.5, R + 5); c.closePath(); c.fill();
}

/* ═══════════════════════════════════════════════════════════════════════
   14.  CAMERA
   ═══════════════════════════════════════════════════════════════════════ */

const camPos = new THREE.Vector3(0, 14, 34);
const camLook = new THREE.Vector3();
let menuAngle = 0, camYaw = 0;

function yawOf(q) {
  return Math.atan2(2 * (q.w * q.y + q.x * q.z), 1 - 2 * (q.y * q.y + q.x * q.x));
}

function updateCamera(dt) {
  if (state !== 'play') {
    menuAngle += dt * 0.06;
    const r = 38;
    const cx = player ? player.pos.x : 0, cz = player ? player.pos.z : 0;
    camPos.set(cx + Math.cos(menuAngle) * r,
               (player ? player.pos.y : 0) + 9 + Math.sin(menuAngle * 0.7) * 2.5,
               cz + Math.sin(menuAngle) * r);
    camPos.y = Math.max(camPos.y, waterHeight(camPos.x, camPos.z) + 4.5);
    // aim above the boat so she sits below the title card
    camLook.set(cx, 11, cz);
    if (player) { camLook.copy(player.pos); camLook.y += 10.0; }
    camera.position.lerp(camPos, 1 - Math.exp(-2 * dt));
    camera.lookAt(camLook);
    return;
  }

  const yaw = yawOf(player.quat);
  let dyaw = yaw - camYaw;
  while (dyaw > Math.PI) dyaw -= Math.PI * 2;
  while (dyaw < -Math.PI) dyaw += Math.PI * 2;
  camYaw += dyaw * (1 - Math.exp(-5.5 * dt));
  const fx = Math.sin(camYaw), fz = Math.cos(camYaw);
  const speed = player.vel.length();
  const back = 18.5 + speed * 0.55;
  const up = 7.6 + speed * 0.16;

  _v1.set(player.pos.x - fx * back, player.pos.y + up, player.pos.z - fz * back);

  // nudge toward where the gun is pointing so the target stays on screen
  _v2.copy(aimPoint).sub(player.pos); _v2.y = 0;
  if (_v2.lengthSq() > 1) _v2.normalize().multiplyScalar(2.0); else _v2.set(0, 0, 0);
  _v1.add(_v2);

  _v1.y = Math.max(_v1.y, waterHeight(_v1.x, _v1.z) + 2.4);
  camPos.lerp(_v1, 1 - Math.exp(-11.0 * dt));

  _v3.copy(player.pos); _v3.y += 2.2;
  _v3.lerp(aimPoint, 0.08);
  camLook.lerp(_v3, 1 - Math.exp(-12 * dt));

  camera.position.copy(camPos);
  camera.lookAt(camLook);

  // recoil / impact shake
  if (shakeState.amt > 0.001) {
    const a = shakeState.amt;
    camera.position.x += rand(-a, a) * 0.42;
    camera.position.y += rand(-a, a) * 0.42;
    camera.rotateZ(rand(-a, a) * 0.012);
    shakeState.amt = Math.max(0, a - dt * 3.4);
  }
}

/* ═══════════════════════════════════════════════════════════════════════
   15.  GAME FLOW
   ═══════════════════════════════════════════════════════════════════════ */

function resetGame() {
  for (const v of fleet) v.removeFrom(scene);
  for (const s of shells) scene.remove(s.mesh);
  fleet.length = 0; enemies.length = 0; shells.length = 0;
  score = 0; kills = 0; waveNo = 0; waveTimer = 0;
  heat = 0; overheated = false; gunCool = 0;
  sea.amp = 1; sea.target = 1; swellHold = 0; swellTimer = rand(20, 32);
  spawnPlayer();
  camPos.set(0, 16, 40); camYaw = 0;
  startWave();
}

function endGame() {
  state = 'over';
  $('gostats').innerHTML =
    `SCORE <b>${score.toLocaleString()}</b><br>` +
    `SHIPS SUNK <b>${kills}</b><br>` +
    `REACHED <b>WAVE ${waveNo}</b>`;
  overEl.classList.remove('hide');
  document.getElementById('hud').classList.add('dim');
  document.body.style.cursor = 'pointer';
}

function begin() {
  sfx.init();
  if (sfx.ctx && sfx.ctx.state === 'suspended') sfx.ctx.resume();
  startEl.classList.add('hide');
  overEl.classList.add('hide');
  document.getElementById('hud').classList.remove('dim');
  clearKeys();
  try { window.focus(); } catch (_) {}
  document.body.style.cursor = 'none';
  resetGame();
  state = 'play';
  paused = false;
}

startEl.addEventListener('click', begin);
overEl.addEventListener('click', begin);

function updateFleet(dt) {
  controlPlayer(dt);
  for (const e of enemies) controlEnemy(e, dt);
  for (const v of fleet) v.step(dt);
  collide(dt);

  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    if (!e.alive && !e.counted) { e.counted = true; kills++; score += 150; }
    if (!e.alive && e.sinkT > 7) {
      e.removeFrom(scene);
      enemies.splice(i, 1);
      const k = fleet.indexOf(e); if (k >= 0) fleet.splice(k, 1);
    }
  }

  if (player.alive && enemies.filter(e => e.alive).length === 0) {
    waveTimer -= dt;
    if (waveTimer <= 0) { waveTimer = 5; startWave(); }
  }

  if (!player.alive && player.sinkT > 3 && state === 'play') endGame();
}

/* ═══════════════════════════════════════════════════════════════════════
   16.  MAIN LOOP
   ═══════════════════════════════════════════════════════════════════════ */

let last = performance.now();
let booted = false;

function frame(now) {
  requestAnimationFrame(frame);
  let dt = (now - last) / 1000;
  last = now;
  dt = Math.min(dt, 0.05);

  const active = state === 'play' && !paused;

  if (active) {
    const steps = Math.min(3, Math.max(1, Math.ceil(dt / 0.014)));
    const sdt = dt / steps;
    for (let i = 0; i < steps; i++) { updateSea(sdt); updateFleet(sdt); }
    updateShells(dt);
    sfx.engine(Math.abs(player.throttle) * (player.alive ? 1 : 0));
  } else if (!paused) {
    updateSea(dt * 0.6);
    if (player) { player.throttle = 0; player.rudder = 0; player.step(dt); }
    sfx.engine(0);
  } else {
    sfx.engine(0);
  }

  if (paused) {
    alertEl.textContent = 'PAUSED'; alertEl.style.color = '#8fa6b6';
    alertEl.style.opacity = '1'; alertT = 0.1;
  }

  if (!paused) { fxSoft.update(dt); fxFire.update(dt); }
  updateCamera(dt);
  if (player) updateHUD(dt);

  // ocean disk rides with the camera
  ocean.position.set(camera.position.x, 0, camera.position.z);
  sky.position.set(camera.position.x, 0, camera.position.z);
  waterUniforms.uCamPos.value.copy(camera.position);

  if (player) {
    sunLight.target.position.copy(player.pos);
    sunLight.position.copy(player.pos).addScaledVector(sunDir, 200);
    sunLight.target.updateMatrixWorld();
  }

  camera.updateMatrixWorld();
  renderReflection();
  renderer.setClearColor(0x000000, 1);
  renderer.render(scene, camera);

  if (!booted) {
    booted = true;
    loadEl.style.display = 'none';
    startEl.classList.remove('hide');
    document.body.style.cursor = 'pointer';
  }
}

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
  reflRT.setSize(Math.min(1024, Math.floor(innerWidth * 0.6)),
                 Math.min(1024, Math.floor(innerHeight * 0.6)));
});

// a calm hull drifting on the menu sea
spawnPlayer();
player.pos.set(0, 0.6, 0);
state = 'menu';
requestAnimationFrame(frame);

</script>
</body>
</html>

One email when a new model lands on the board or the showcase. Unsubscribe anytime.