Showcase

Boat game, built by Ling 3.1 Flash

Hostedvia OpenCodeOpenRouter
Running live in your browserNew tab
Show the code the model wrote
HTML52,718 characters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>NAVAL STORM — Gerstner Ocean Combat</title>
<style>
  :root{
    --accent:#6ee7ff; --danger:#ff5d5d; --panel:rgba(8,18,28,.62);
  }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{width:100%;height:100%;overflow:hidden;background:#04121d;
    font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
    user-select:none;-webkit-user-select:none;cursor:none}
  canvas{display:block}
  /* ---------- crosshair ---------- */
  #crosshair{position:fixed;left:0;top:0;width:34px;height:34px;pointer-events:none;z-index:40;
    transform:translate(-50%,-50%);mix-blend-mode:difference}
  #crosshair::before,#crosshair::after{content:"";position:absolute;inset:0;border:1.5px solid #fff;border-radius:50%}
  #crosshair::after{width:5px;height:5px;left:50%;top:50%;transform:translate(-50%,-50%);border:none;background:#fff;border-radius:50%}
  #crosshair .tick{position:absolute;background:#fff}
  #crosshair .t1{left:50%;top:-6px;width:1.5px;height:8px;transform:translateX(-50%)}
  #crosshair .t2{left:50%;bottom:-6px;width:1.5px;height:8px;transform:translateX(-50%)}
  #crosshair .t3{top:50%;left:-6px;height:1.5px;width:8px;transform:translateY(-50%)}
  #crosshair .t4{top:50%;right:-6px;height:1.5px;width:8px;transform:translateY(-50%)}
  /* ---------- HUD panels ---------- */
  .panel{position:fixed;z-index:30;color:#dff4ff;background:var(--panel);
    border:1px solid rgba(110,231,255,.25);border-radius:10px;
    padding:10px 14px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
    box-shadow:0 4px 24px rgba(0,0,0,.35)}
  #stats{top:16px;left:16px;min-width:230px}
  #stats .label{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);opacity:.85}
  #hpbar{height:10px;margin:6px 0 10px;border-radius:5px;background:rgba(255,255,255,.09);overflow:hidden}
  #hpfill{height:100%;width:100%;border-radius:5px;background:linear-gradient(90deg,#37e08b,#a7f3d0);
    transition:width .18s ease, background .3s}
  #hpfill.low{background:linear-gradient(90deg,#ff5d5d,#ffb4b4)}
  #stats .row{display:flex;justify-content:space-between;font-size:13px;gap:18px;line-height:1.7}
  #stats .val{font-weight:600;color:#fff;font-variant-numeric:tabular-nums}
  #enemies{top:16px;right:16px;text-align:right;min-width:150px}
  #enemies .big{font-size:26px;font-weight:700;color:#fff;line-height:1.1}
  #waveBanner{position:fixed;top:9%;left:50%;transform:translateX(-50%);z-index:35;
    padding:12px 26px;border-radius:12px;text-align:center;display:none;
    background:rgba(40,10,10,.72);border:1px solid rgba(255,120,90,.55);
    color:#ffd9c9;font-size:18px;font-weight:700;letter-spacing:.14em;
    text-shadow:0 0 18px rgba(255,120,60,.8);backdrop-filter:blur(6px);
    animation:pulse 1s infinite}
  @keyframes pulse{0%,100%{opacity:.92}50%{opacity:.55}}
  #waveBanner .sub{font-size:11px;font-weight:400;letter-spacing:.3em;color:#ffab8a;margin-top:4px}
  #impact{position:fixed;top:38%;left:50%;transform:translate(-50%,-50%);z-index:35;
    font-size:30px;font-weight:800;letter-spacing:.3em;color:#fff;opacity:0;pointer-events:none;
    text-shadow:0 0 24px rgba(140,220,255,.9)}
  #hint{bottom:16px;left:50%;transform:translateX(-50%);font-size:12px;letter-spacing:.08em;
    color:#bfe6f5;white-space:nowrap}
  #hint b{color:var(--accent)}
  #minimap{position:fixed;bottom:16px;right:16px;z-index:30;border-radius:10px;
    border:1px solid rgba(110,231,255,.25);background:rgba(6,14,24,.72);
    box-shadow:0 4px 24px rgba(0,0,0,.35)}
  #vignette{position:fixed;inset:0;pointer-events:none;z-index:25;opacity:0;
    box-shadow:inset 0 0 140px 60px rgba(255,30,20,.55);transition:opacity .12s}
  #lowhp{position:fixed;inset:0;pointer-events:none;z-index:24;opacity:0;
    box-shadow:inset 0 0 160px 70px rgba(255,0,0,.5)}
  #gameover{position:fixed;inset:0;z-index:50;display:none;align-items:center;justify-content:center;
    flex-direction:column;background:radial-gradient(ellipse at center,rgba(4,10,18,.55),rgba(2,6,12,.92));
    color:#eaf7ff;text-align:center;cursor:default}
  #gameover h1{font-size:52px;letter-spacing:.28em;font-weight:800;
    text-shadow:0 0 34px rgba(110,231,255,.6)}
  #gameover .score{font-size:20px;margin-top:14px;color:var(--accent);letter-spacing:.2em}
  #gameover .again{margin-top:26px;font-size:14px;letter-spacing:.18em;color:#9fd7ea;
    border:1px solid rgba(110,231,255,.4);padding:10px 22px;border-radius:8px;background:rgba(110,231,255,.08)}
  #startnote{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);z-index:30;
    font-size:12px;color:#8fd8ef;letter-spacing:.12em;background:var(--panel);
    padding:8px 16px;border-radius:8px;border:1px solid rgba(110,231,255,.2)}
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
</head>
<body>
<div id="crosshair"><div class="tick t1"></div><div class="tick t2"></div><div class="tick t3"></div><div class="tick t4"></div></div>
<div id="stats" class="panel">
  <div class="label">Hull Integrity</div>
  <div id="hpbar"><div id="hpfill"></div></div>
  <div class="row"><span>Speed</span><span class="val" id="speedVal">0 kn</span></div>
  <div class="row"><span>Score</span><span class="val" id="scoreVal">0</span></div>
  <div class="row"><span>Hostiles sunk</span><span class="val" id="killVal">0</span></div>
</div>
<div id="enemies" class="panel">
  <div class="label" style="font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--danger);opacity:.9">Hostiles</div>
  <div class="big" id="enemyCount">0</div>
</div>
<div id="waveBanner"><span id="waveBannerTxt">🌊 ROGUE WAVE INCOMING</span><div class="sub" id="waveBannerSub"></div></div>
<div id="impact">WAVE IMPACT</div>
<div id="hint" class="panel"><b>WASD</b> helm &nbsp;·&nbsp; <b>MOUSE</b> aim &nbsp;·&nbsp; <b>CLICK / SPACE</b> fire &nbsp;·&nbsp; <b>R</b> restart</div>
<canvas id="minimap" width="170" height="170"></canvas>
<div id="vignette"></div>
<div id="lowhp"></div>
<div id="startnote">🔊 click once to enable sound</div>
<div id="gameover">
  <h1>VESSEL SUNK</h1>
  <div class="score" id="finalScore"></div>
  <div class="again">PRESS&nbsp;<b>R</b>&nbsp;TO SAIL AGAIN</div>
</div>

<script type="module">
import * as THREE from 'three';

/* ============================================================
   WORLD CONSTANTS
============================================================ */
const G = 9.8;
const WORLD_R = 200;                 // playable radius
const MAP_D = 600;                   // water mesh size

// Gerstner wave set — mirrored exactly in the GLSL below (generated from this data)
const WAVES = [
  { dir: [ 1.00,  0.25], steep: 0.22, len: 62.0 },
  { dir: [ 0.75,  0.65], steep: 0.30, len: 33.0 },
  { dir: [-0.55,  0.85], steep: 0.34, len: 19.0 },
  { dir: [ 0.35, -0.95], steep: 0.28, len: 10.0 },
  { dir: [-0.90, -0.45], steep: 0.22, len:  5.2 },
].map(w => {
  const l = Math.hypot(w.dir[0], w.dir[1]);
  const dx = w.dir[0] / l, dz = w.dir[1] / l;
  const k = 2 * Math.PI / w.len;
  return { dx, dz, k, steep: w.steep, a: w.steep / k, omega: Math.sqrt(G * k) };
});

/* ============================================================
   RENDERER / SCENE / SKY
============================================================ */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const HORIZON = new THREE.Color(0.62, 0.74, 0.84);
const ZENITH  = new THREE.Color(0.10, 0.30, 0.60);
const SUN_COL = new THREE.Color(1.0, 0.93, 0.80);
const SUN_DIR = new THREE.Vector3(-0.55, 0.42, -0.72).normalize();
scene.fog = new THREE.Fog(new THREE.Color(HORIZON).multiplyScalar(0.96), 90, 560);

const camera = new THREE.PerspectiveCamera(56, innerWidth / innerHeight, 0.1, 2200);
camera.position.set(0, 8, -18);

// gradient sky dome with sun disc
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide, depthWrite: false, fog: false,
  uniforms: {
    uZenith: { value: ZENITH }, uHorizon: { value: HORIZON },
    uSunDir: { value: SUN_DIR }, uSunCol: { value: SUN_COL },
  },
  vertexShader: `
    varying vec3 vDir;
    void main(){ vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
  fragmentShader: `
    uniform vec3 uZenith, uHorizon, uSunDir, uSunCol;
    varying vec3 vDir;
    void main(){
      vec3 d = normalize(vDir);
      float h = d.y;
      vec3 col = mix(uHorizon, uZenith, pow(clamp(h,0.0,1.0), 0.55));
      col = mix(col, uHorizon * 0.35, smoothstep(0.0, -0.25, h));
      float s = max(dot(d, uSunDir), 0.0);
      col += uSunCol * (pow(s, 1200.0) * 6.0 + pow(s, 24.0) * 0.30 + pow(s, 4.0) * 0.10);
      gl_FragColor = vec4(col, 1.0);
      #include <tonemapping_fragment>
      #include <colorspace_fragment>
    }`
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(1000, 40, 20), skyMat));

// lights
const sun = new THREE.DirectionalLight(0xfff2dd, 2.4);
scene.add(sun); scene.add(sun.target);
scene.add(new THREE.HemisphereLight(0x9db8d6, 0x14303e, 0.85));

/* ============================================================
   OCEAN — Gerstner waves, fresnel, sun glitter, foam
============================================================ */
function waveGLSL() {
  let s = '';
  for (const w of WAVES) {
    s += `
    {
      vec2 D = vec2(${w.dx.toFixed(6)}, ${w.dz.toFixed(6)});
      float k = ${w.k.toFixed(6)};
      float a = ${w.a.toFixed(6)};
      float q = ${w.steep.toFixed(6)};
      float om = ${w.omega.toFixed(6)};
      float f = k * dot(D, xz) - om * t;
      float sn = sin(f); float cs = cos(f);
      float ka = k * a;
      disp.x += q * a * D.x * cs;
      disp.z += q * a * D.y * cs;
      disp.y += a * sn;
      nrm.x  -= D.x * ka * cs;
      nrm.z  -= D.y * ka * cs;
      nrm.y  -= q * ka * sn;
      sumA += a;
    }`;
  }
  return s;
}

const waterUniforms = {
  uTime:      { value: 0 },
  uSunDir:    { value: SUN_DIR },
  uSunCol:    { value: SUN_COL },
  uDeep:      { value: new THREE.Color(0.008, 0.045, 0.085) },
  uShallow:   { value: new THREE.Color(0.045, 0.27, 0.33) },
  uSky:       { value: HORIZON },
  // rogue wave packet
  uRogueDir:  { value: new THREE.Vector2(1, 0) },
  uRogueT0:   { value: -1000 },
  uRogueSpeed:{ value: 16 },
  uRogueA:    { value: 4.6 },
  uRogueK:    { value: 0.22 },
  uRogueSig2: { value: 256.0 },
  uRogueOn:   { value: 0 },
};

const waterMat = new THREE.ShaderMaterial({
  uniforms: waterUniforms,
  vertexShader: `
    uniform float uTime;
    uniform vec2 uRogueDir;
    uniform float uRogueT0, uRogueSpeed, uRogueA, uRogueK, uRogueSig2, uRogueOn;
    varying vec3 vWorldPos;
    varying vec3 vNormal;
    varying float vHeight;
    varying float vSumA;
    varying float vRogueEnv;
    void main(){
      vec4 wp = modelMatrix * vec4(position, 1.0);
      vec2 xz = wp.xz;
      float t = uTime;
      vec3 disp = vec3(0.0);
      vec3 nrm  = vec3(0.0, 1.0, 0.0);
      float sumA = 0.0;
      ${waveGLSL()}
      float rogueEnv = 0.0;
      if (uRogueOn > 0.5) {
        float s = dot(uRogueDir, xz);
        float tau = s - uRogueSpeed * (t - uRogueT0);
        float env = exp(-tau * tau / uRogueSig2);
        float phase = uRogueK * tau;
        rogueEnv = env;
        disp.y += uRogueA * sin(phase) * env;
        float dh = uRogueA * env * (uRogueK * cos(phase) - (2.0 * tau / uRogueSig2) * sin(phase));
        nrm.x -= uRogueDir.x * dh;
        nrm.z -= uRogueDir.y * dh;
      }
      wp.xz += disp.xz;
      wp.y  += disp.y;
      vWorldPos = wp.xyz;
      vNormal = nrm;
      vHeight = disp.y;
      vSumA = sumA;
      vRogueEnv = rogueEnv;
      gl_Position = projectionMatrix * viewMatrix * wp;
    }`,
  fragmentShader: `
    uniform vec3 uSunDir, uSunCol, uDeep, uShallow, uSky;
    uniform float uTime;
    varying vec3 vWorldPos;
    varying vec3 vNormal;
    varying float vHeight;
    varying float vSumA;
    varying float vRogueEnv;

    float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
    float noise(vec2 p){
      vec2 i = floor(p), f = fract(p);
      f = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash(i), hash(i + vec2(1,0)), f.x),
                 mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), f.x), f.y);
    }
    float fbm(vec2 p){
      float v = 0.0, a = 0.5;
      for (int i = 0; i < 4; i++){ v += a * noise(p); p *= 2.03; a *= 0.5; }
      return v;
    }
    void main(){
      vec3 N = normalize(vNormal);
      // fine detail normals
      float n1 = fbm(vWorldPos.xz * 0.45 + vec2(uTime * 0.45, uTime * 0.22));
      float n2 = fbm(vWorldPos.xz * 1.15 - vec2(uTime * 0.30, -uTime * 0.55));
      N = normalize(N + vec3(n1 - 0.5, 0.0, n2 - 0.5) * 0.38);

      vec3 V = normalize(cameraPosition - vWorldPos);
      float ndv = max(dot(N, V), 0.0);
      float fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);

      float hFac = clamp(vHeight / (vSumA * 1.35) + 0.5, 0.0, 1.0);
      vec3 water = mix(uDeep, uShallow, hFac * 0.65);

      // subsurface scattering toward the sun on lit crests
      float sss = pow(max(dot(V, -uSunDir), 0.0), 3.0) * max(hFac - 0.55, 0.0) * 2.4;
      water += uSunCol * sss * 0.30;

      vec3 col = mix(water, uSky, fresnel * 0.88);

      // sun glitter
      vec3 H = normalize(V + uSunDir);
      float ndh = max(dot(N, H), 0.0);
      col += uSunCol * (pow(ndh, 420.0) * 2.6 + pow(ndh, 40.0) * 0.22);

      // foam: crest whitecaps + rogue wave wash
      float foamN = fbm(vWorldPos.xz * 1.5 + vec2(uTime * 0.7, -uTime * 0.4));
      float crestFoam = smoothstep(0.66, 0.94, hFac) * smoothstep(0.32, 0.72, foamN);
      float rogueFoam = vRogueEnv * smoothstep(0.38, 0.8, foamN) * 1.35;
      float foam = clamp(crestFoam + rogueFoam, 0.0, 1.0);
      col = mix(col, vec3(0.93, 0.97, 0.99), foam);

      // distance fog to horizon
      float dist = length(cameraPosition - vWorldPos);
      float fogF = 1.0 - exp(-dist * 0.0032);
      col = mix(col, uSky, fogF);

      gl_FragColor = vec4(col, 1.0);
      #include <tonemapping_fragment>
      #include <colorspace_fragment>
    }`
});
const water = new THREE.Mesh(new THREE.PlaneGeometry(MAP_D, MAP_D, 300, 300), waterMat);
water.geometry.rotateX(-Math.PI / 2);
water.frustumCulled = false;
scene.add(water);

/* ---------- JS mirror of the shader wave field (for buoyancy) ---------- */
const rogue = {
  active: false, dir: new THREE.Vector2(1, 0), t0: 0,
  speed: 16, a: 4.6, k: 0.22, sig2: 256,
};
function waveHeight(x, z, t) {
  let h = 0;
  for (const w of WAVES) h += w.a * Math.sin(w.k * (w.dx * x + w.dz * z) - w.omega * t);
  if (rogue.active) {
    const s = rogue.dir.x * x + rogue.dir.y * z;
    const tau = s - rogue.speed * (t - rogue.t0);
    h += rogue.a * Math.sin(rogue.k * tau) * Math.exp(-tau * tau / rogue.sig2);
  }
  return h;
}
function waveSlope(x, z, t, out) {
  const e = 0.6;
  out.x = (waveHeight(x + e, z, t) - waveHeight(x - e, z, t)) / (2 * e);
  out.z = (waveHeight(x, z + e, t) - waveHeight(x, z - e, t)) / (2 * e);
  return out;
}

/* ============================================================
   AUDIO (procedural WebAudio)
============================================================ */
const AudioSys = {
  ctx: null, noiseBuf: null,
  ensure() {
    if (!this.ctx) {
      this.ctx = new (window.AudioContext || window.webkitAudioContext)();
      const len = this.ctx.sampleRate * 1.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;
    }
    if (this.ctx.state === 'suspended') this.ctx.resume();
  },
  _noise(dur, { type = 'lowpass', from = 800, to = 200, gain = 0.5, at = 0 }) {
    if (!this.ctx) return;
    const t = this.ctx.currentTime + at;
    const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuf; src.loop = true;
    const f = this.ctx.createBiquadFilter(); f.type = type;
    f.frequency.setValueAtTime(from, t);
    f.frequency.exponentialRampToValueAtTime(Math.max(40, to), t + dur);
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(gain, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    src.connect(f).connect(g).connect(this.ctx.destination);
    src.start(t); src.stop(t + dur + 0.05);
  },
  _tone(dur, { from = 100, to = 40, gain = 0.5, type = 'sine', at = 0 }) {
    if (!this.ctx) return;
    const t = this.ctx.currentTime + at;
    const o = this.ctx.createOscillator(); o.type = type;
    o.frequency.setValueAtTime(from, t);
    o.frequency.exponentialRampToValueAtTime(Math.max(20, to), t + dur);
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(gain, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    o.connect(g).connect(this.ctx.destination);
    o.start(t); o.stop(t + dur + 0.05);
  },
  cannon(dist) {
    const g = Math.max(0.08, 1 - dist / 130);
    this._noise(0.22, { from: 900, to: 150, gain: 0.45 * g });
    this._tone(0.28, { from: 110, to: 32, gain: 0.55 * g });
  },
  explosion(dist, big) {
    const g = Math.max(0.08, 1 - dist / 160) * (big ? 1 : 0.7);
    this._noise(big ? 0.9 : 0.55, { from: 700, to: 60, gain: 0.7 * g });
    this._tone(big ? 0.8 : 0.5, { from: 70, to: 24, gain: 0.6 * g });
  },
  splash(dist) {
    const g = Math.max(0.05, 1 - dist / 110);
    this._noise(0.3, { type: 'bandpass', from: 1400, to: 500, gain: 0.28 * g });
  },
  warning() {
    this._tone(0.5, { from: 65, to: 55, gain: 0.4 });
    this._tone(0.5, { from: 65, to: 55, gain: 0.4, at: 0.7 });
  },
  hit() { this._tone(0.15, { from: 300, to: 90, gain: 0.4, type: 'square' }); },
};

/* ============================================================
   PARTICLES
============================================================ */
class Particles {
  constructor(count, { gravity = -9, drag = 0, blending = THREE.NormalBlending } = {}) {
    this.count = count; this.n = 0;
    this.gravity = gravity; this.drag = drag;
    this.pos = new Float32Array(count * 3);
    this.vel = new Float32Array(count * 3);
    this.life = new Float32Array(count);
    this.maxLife = new Float32Array(count);
    this.size = new Float32Array(count);
    this.col = new Float32Array(count * 3);
    this.grow = new Float32Array(count);
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('aColor', new THREE.BufferAttribute(this.col, 3).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('aSize', new THREE.BufferAttribute(this.size, 1).setUsage(THREE.DynamicDrawUsage));
    this.alphaAttr = new THREE.BufferAttribute(new Float32Array(count), 1);
    this.alphaAttr.setUsage(THREE.DynamicDrawUsage);
    geo.setAttribute('aAlpha', this.alphaAttr);
    geo.setDrawRange(0, 0);
    const mat = new THREE.ShaderMaterial({
      transparent: true, depthWrite: false, blending,
      vertexShader: `
        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 * (260.0 / max(1.0, -mv.z));
          gl_Position = projectionMatrix * mv;
        }`,
      fragmentShader: `
        varying float vA; varying vec3 vC;
        void main(){
          float d = length(gl_PointCoord - 0.5);
          float a = smoothstep(0.5, 0.06, d) * vA;
          if (a < 0.012) discard;
          gl_FragColor = vec4(vC, a);
          #include <tonemapping_fragment>
          #include <colorspace_fragment>
        }`
    });
    this.points = new THREE.Points(geo, mat);
    this.points.frustumCulled = false;
    scene.add(this.points);
  }
  spawn(x, y, z, vx, vy, vz, life, size, r, g, b, grow = 0) {
    if (this.n >= this.count) return;
    const i = this.n++;
    this.pos[i*3] = x; this.pos[i*3+1] = y; this.pos[i*3+2] = z;
    this.vel[i*3] = vx; this.vel[i*3+1] = vy; this.vel[i*3+2] = vz;
    this.life[i] = life; this.maxLife[i] = life;
    this.size[i] = size; this.grow[i] = grow;
    this.col[i*3] = r; this.col[i*3+1] = g; this.col[i*3+2] = b;
  }
  update(dt) {
    let i = 0;
    while (i < this.n) {
      this.life[i] -= dt;
      if (this.life[i] <= 0) {
        const last = --this.n;
        if (i !== last) {
          for (let k = 0; k < 3; k++) {
            this.pos[i*3+k] = this.pos[last*3+k];
            this.vel[i*3+k] = this.vel[last*3+k];
            this.col[i*3+k] = this.col[last*3+k];
          }
          this.life[i] = this.life[last]; this.maxLife[i] = this.maxLife[last];
          this.size[i] = this.size[last]; this.grow[i] = this.grow[last];
        }
        continue;
      }
      const d = Math.exp(-this.drag * dt);
      this.vel[i*3] *= d; this.vel[i*3+1] = this.vel[i*3+1] * d + this.gravity * dt; this.vel[i*3+2] *= d;
      this.pos[i*3] += this.vel[i*3] * dt;
      this.pos[i*3+1] += this.vel[i*3+1] * dt;
      this.pos[i*3+2] += this.vel[i*3+2] * dt;
      const f = this.life[i] / this.maxLife[i];
      this.size[i] += this.grow[i] * dt;
      this.alphaAttr.array[i] = Math.min(1, f * 1.6) * (this.alphaBase ?? 1);
      i++;
    }
    const geo = this.points.geometry;
    geo.attributes.position.needsUpdate = true;
    geo.attributes.aColor.needsUpdate = true;
    geo.attributes.aSize.needsUpdate = true;
    this.alphaAttr.needsUpdate = true;
    geo.setDrawRange(0, this.n);
  }
}
const spray = new Particles(2600, { gravity: -9,  drag: 0.6 });                    // water spray
const wake  = new Particles(2600, { gravity: -0.6, drag: 1.6 });                   // wake foam
const fire  = new Particles(2200, { gravity: -4,  drag: 1.2, blending: THREE.AdditiveBlending }); // fire / tracers
wake.alphaBase = 0.4;

/* ---------- shockwave rings ---------- */
const rings = [];
{
  const g = new THREE.RingGeometry(0.86, 1.0, 48);
  g.rotateX(-Math.PI / 2);
  for (let i = 0; i < 10; i++) {
    const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({
      color: 0xdff4ff, transparent: true, opacity: 0, depthWrite: false }));
    m.visible = false; scene.add(m);
    rings.push({ mesh: m, life: 0, maxLife: 1, maxScale: 10 });
  }
}
function spawnRing(x, y, z, maxScale, color = 0xdff4ff, life = 0.9) {
  const r = rings.find(r => !r.mesh.visible) || rings[0];
  r.mesh.visible = true;
  r.mesh.position.set(x, y, z);
  r.mesh.material.color.setHex(color);
  r.life = life; r.maxLife = life; r.maxScale = maxScale;
}
function updateRings(dt) {
  for (const r of rings) {
    if (!r.mesh.visible) continue;
    r.life -= dt;
    if (r.life <= 0) { r.mesh.visible = false; continue; }
    const t = 1 - r.life / r.maxLife;
    const s = 1 + (r.maxScale - 1) * (1 - Math.pow(1 - t, 2.2));
    r.mesh.scale.setScalar(s);
    r.mesh.material.opacity = (1 - t) * 0.65;
  }
}

/* ---------- explosion flash lights ---------- */
const flashes = [];
for (let i = 0; i < 5; i++) {
  const l = new THREE.PointLight(0xffa040, 0, 90, 1.8);
  scene.add(l);
  flashes.push({ light: l, life: 0 });
}
function flash(pos, intensity = 260, color = 0xffa040) {
  const f = flashes.find(f => f.life <= 0) || flashes[0];
  f.light.position.copy(pos).add(new THREE.Vector3(0, 2, 0));
  f.light.color.setHex(color);
  f.light.intensity = intensity;
  f.life = 0.35;
}
function updateFlashes(dt) {
  for (const f of flashes) {
    if (f.life <= 0) continue;
    f.life -= dt;
    f.light.intensity *= Math.exp(-9 * dt);
    if (f.life <= 0) f.light.intensity = 0;
  }
}

/* ============================================================
   EFFECTS
============================================================ */
function explosion(pos, big = false) {
  const nF = big ? 90 : 45;
  for (let i = 0; i < nF; i++) {
    const th = Math.random() * Math.PI * 2, ph = Math.acos(2 * Math.random() - 1);
    const sp = (big ? 6 : 4) + Math.random() * (big ? 11 : 7);
    const heat = Math.random();
    fire.spawn(pos.x, pos.y + 0.5, pos.z,
      Math.sin(ph) * Math.cos(th) * sp, Math.abs(Math.cos(ph)) * sp * 0.9 + 2, Math.sin(ph) * Math.sin(th) * sp,
      0.4 + Math.random() * 0.7, 1.6 + Math.random() * 2.4,
      1.0, 0.35 + heat * 0.5, 0.08 + heat * 0.15, -1.2);
  }
  for (let i = 0; i < (big ? 26 : 14); i++) {
    spray.spawn(pos.x, pos.y + 0.4, pos.z,
      (Math.random() - 0.5) * 6, 2 + Math.random() * 6, (Math.random() - 0.5) * 6,
      0.8 + Math.random() * 1.2, 1.4 + Math.random() * 2,
      0.9, 0.92, 0.95, 1.5);
  }
  flash(pos, big ? 320 : 170, big ? 0xff8030 : 0xffa040);
  spawnRing(pos.x, pos.y + 0.3, pos.z, big ? 22 : 12, big ? 0xffb070 : 0xdff4ff, big ? 1.2 : 0.8);
  AudioSys.explosion(camera.position.distanceTo(pos), big);
}
function splash(pos, strength = 1) {
  const n = Math.round(12 + strength * 14);
  for (let i = 0; i < n; i++) {
    spray.spawn(pos.x + (Math.random() - 0.5) * 2, pos.y + 0.2, pos.z + (Math.random() - 0.5) * 2,
      (Math.random() - 0.5) * 3 * strength, (2.5 + Math.random() * 4) * strength, (Math.random() - 0.5) * 3 * strength,
      0.7 + Math.random() * 0.8, 1.2 + Math.random() * 1.8,
      0.88, 0.94, 0.98, 1.2);
  }
  spawnRing(pos.x, pos.y + 0.25, pos.z, 4 + strength * 3, 0xcfe8f5, 0.7);
  AudioSys.splash(camera.position.distanceTo(pos));
}

/* ============================================================
   BOATS
============================================================ */
function hullGeometry(len, beam, depth) {
  const hl = len / 2, hb = beam / 2;
  const s = new THREE.Shape();
  s.moveTo(0, hl);
  s.quadraticCurveTo(hb * 0.95, hl * 0.45, hb, -hl * 0.35);
  s.lineTo(hb, -hl * 0.82);
  s.quadraticCurveTo(hb * 0.55, -hl, 0, -hl);
  s.quadraticCurveTo(-hb * 0.55, -hl, -hb, -hl * 0.82);
  s.lineTo(-hb, -hl * 0.35);
  s.quadraticCurveTo(-hb * 0.95, hl * 0.45, 0, hl);
  const g = new THREE.ExtrudeGeometry(s, {
    depth, bevelEnabled: true, bevelThickness: 0.14, bevelSize: 0.1, bevelSegments: 2, steps: 1,
  });
  g.rotateX(Math.PI / 2); // shape-Y -> world Z (bow at +Z), extrusion -> -Y
  const p = g.attributes.position;
  for (let i = 0; i < p.count; i++) {
    const y = p.getY(i);
    const f = THREE.MathUtils.clamp(THREE.MathUtils.mapLinear(y, -depth, 0, 0.68, 1.0), 0.68, 1);
    p.setX(i, p.getX(i) * f);
  }
  g.computeVertexNormals();
  return g;
}

function createBoat({ hull = 0x2b3a4a, deck = 0x8fa3b5, accent = 0x2f6f9f, scale = 1 } = {}) {
  const group = new THREE.Group();
  group.rotation.order = 'YXZ';

  const hullMat = new THREE.MeshStandardMaterial({ color: hull, roughness: 0.55, metalness: 0.25 });
  const hullMesh = new THREE.Mesh(hullGeometry(9, 3.0, 1.35), hullMat);
  group.add(hullMesh);

  const deckMesh = new THREE.Mesh(hullGeometry(8.6, 2.7, 0.16),
    new THREE.MeshStandardMaterial({ color: deck, roughness: 0.7, metalness: 0.1 }));
  deckMesh.position.y = 0.1;
  group.add(deckMesh);

  const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.0, 2.4),
    new THREE.MeshStandardMaterial({ color: deck, roughness: 0.4, metalness: 0.2 }));
  cabin.position.set(0, 0.72, -1.1);
  group.add(cabin);

  const windshield = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.55, 0.12),
    new THREE.MeshStandardMaterial({ color: 0x1a2a38, roughness: 0.15, metalness: 0.6 }));
  windshield.position.set(0, 0.85, 0.12);
  windshield.rotation.x = -0.35;
  group.add(windshield);

  // gun turret
  const turret = new THREE.Group();
  turret.position.set(0, 0.35, 2.1);
  const base = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.65, 0.35, 16),
    new THREE.MeshStandardMaterial({ color: accent, roughness: 0.4, metalness: 0.5 }));
  base.position.y = 0.18;
  turret.add(base);
  const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.11, 2.6, 10),
    new THREE.MeshStandardMaterial({ color: 0x222831, roughness: 0.35, metalness: 0.7 }));
  barrel.rotation.x = Math.PI / 2;
  barrel.position.set(0, 0.42, 1.1);
  turret.add(barrel);
  const muzzle = new THREE.Object3D();
  muzzle.position.set(0, 0.42, 2.4);
  turret.add(muzzle);
  group.add(turret);

  // mast + flag
  const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.07, 2.2, 8),
    new THREE.MeshStandardMaterial({ color: 0x333a44, roughness: 0.5, metalness: 0.6 }));
  mast.position.set(0, 1.9, -2.2);
  group.add(mast);

  group.scale.setScalar(scale);
  scene.add(group);
  return { group, turret, muzzle, barrel };
}

function makeBoatState(isPlayer, opts) {
  const parts = createBoat(opts);
  return {
    ...parts,
    isPlayer,
    vel: new THREE.Vector3(),
    vy: 0,
    heading: 0, pitch: 0, roll: 0,
    hp: 100, maxHp: 100,
    fireCd: 0,
    dead: false, sinking: 0,
    lastDamage: -99,
    ai: isPlayer ? null : {
      orbitDir: Math.random() < 0.5 ? 1 : -1,
      fireTimer: 1.5 + Math.random() * 2,
      strafe: Math.random() * Math.PI * 2,
    },
    wakeAcc: 0,
  };
}

/* ---------- boat physics (buoyancy on the real wave field) ---------- */
const _slope = { x: 0, z: 0 };
function updateBoatPhysics(b, dt, t, thrust, turnInput) {
  if (b.dead) {
    // sinking
    b.sinking += dt;
    b.group.position.y -= dt * (0.8 + b.sinking * 0.6);
    b.group.rotation.z += dt * 0.15 * (b.ai ? b.ai.orbitDir : 1);
    b.group.rotation.x += dt * 0.06;
    return;
  }
  const p = b.group.position;
  const fx = Math.sin(b.heading), fz = Math.cos(b.heading);

  // steering
  const speed = Math.hypot(b.vel.x, b.vel.z);
  const turnScale = THREE.MathUtils.clamp(speed / 7, 0, 1) * (thrust < 0 ? -1 : 1);
  b.heading += turnInput * (b.isPlayer ? 1.55 : 1.15) * turnScale * dt;

  // thrust + drag
  b.vel.x += fx * thrust * dt;
  b.vel.z += fz * thrust * dt;
  const dragF = Math.exp(-(b.isPlayer ? 0.55 : 0.6) * dt);
  b.vel.x *= dragF; b.vel.z *= dragF;
  const maxSp = b.isPlayer ? 24 : 16.5;
  const sp2 = Math.hypot(b.vel.x, b.vel.z);
  if (sp2 > maxSp) { b.vel.x *= maxSp / sp2; b.vel.z *= maxSp / sp2; }

  // rogue wave shove
  if (rogue.active) {
    const s = rogue.dir.x * p.x + rogue.dir.y * p.z;
    const tau = s - rogue.speed * (t - rogue.t0);
    const env = Math.exp(-tau * tau / rogue.sig2);
    b.vel.x += rogue.dir.x * env * 7.5 * dt;
    b.vel.z += rogue.dir.y * env * 7.5 * dt;
  }

  p.x += b.vel.x * dt;
  p.z += b.vel.z * dt;

  // world bounds
  const rr = Math.hypot(p.x, p.z);
  if (rr > WORLD_R) {
    const nx = p.x / rr, nz = p.z / rr;
    p.x = nx * WORLD_R; p.z = nz * WORLD_R;
    const rad = b.vel.x * nx + b.vel.z * nz;
    if (rad > 0) { b.vel.x -= nx * rad; b.vel.z -= nz * rad; }
  }

  // buoyancy: spring toward the wave surface
  const hC = waveHeight(p.x, p.z, t);
  const targetY = hC;
  b.vy += ((targetY - p.y) * 30 - b.vy * 11) * dt;
  p.y += b.vy * dt;

  // pitch & roll from wave slopes (bow/stern, port/starboard)
  const hB = waveHeight(p.x + fx * 3.6, p.z + fz * 3.6, t);
  const hS = waveHeight(p.x - fx * 3.6, p.z - fz * 3.6, t);
  const rx = Math.cos(b.heading), rz = -Math.sin(b.heading);
  const hR = waveHeight(p.x + rx * 1.4, p.z + rz * 1.4, t);
  const hL = waveHeight(p.x - rx * 1.4, p.z - rz * 1.4, t);
  const pitchT = Math.atan2(hS - hB, 7.2) + THREE.MathUtils.clamp(-b.vy * 0.045, -0.35, 0.35);
  const rollT  = Math.atan2(hR - hL, 2.8) + THREE.MathUtils.clamp(turnInput * speed * 0.006, -0.2, 0.2);
  const k = Math.min(1, 5.5 * dt);
  b.pitch += (pitchT - b.pitch) * k;
  b.roll  += (rollT  - b.roll)  * k;
  b.group.rotation.y = b.heading;
  b.group.rotation.x = b.pitch;
  b.group.rotation.z = b.roll;

  // wake foam
  const speedNow = Math.hypot(b.vel.x, b.vel.z);
  if (speedNow > 3.5) {
    b.wakeAcc += dt * speedNow;
    while (b.wakeAcc > 0.55) {
      b.wakeAcc -= 0.55;
      const sx = p.x - fx * 4.2 + (Math.random() - 0.5) * 1.6;
      const sz = p.z - fz * 4.2 + (Math.random() - 0.5) * 1.6;
      wake.spawn(sx, waveHeight(sx, sz, t) + 0.15, sz,
        -fx * 1.5 + (Math.random() - 0.5) * 1.2, 0.4, -fz * 1.5 + (Math.random() - 0.5) * 1.2,
        1.4 + Math.random() * 1.6, 1.6 + Math.random() * 1.6,
        0.85, 0.92, 0.96, 2.2);
    }
  }
  // bow spray in heavy seas
  if (Math.abs(b.vy) > 3.2 || (rogue.active && b.vy > 2.5)) {
    for (let i = 0; i < 3; i++) {
      spray.spawn(p.x + fx * 4.2, p.y + 0.4, p.z + fz * 4.2,
        fx * 3 + (Math.random() - 0.5) * 3, 2 + Math.random() * 3.5, fz * 3 + (Math.random() - 0.5) * 3,
        0.6 + Math.random() * 0.6, 1.4 + Math.random() * 1.4,
        0.9, 0.95, 1.0, 0.8);
    }
  }
  // rogue wave slam event
  if (rogue.active && b.vy > 7.5 && !b.slammed) {
    b.slammed = true;
    if (b.isPlayer) {
      shake(0.9);
      damagePlayer(6 + Math.random() * 5, true);
      showImpact();
    }
    splash(new THREE.Vector3(p.x + fx * 2, p.y, p.z + fz * 2), 2.2);
  }
  if (rogue.active && b.vy < 3) b.slammed = false;
}

/* ============================================================
   SHELLS (ballistic projectiles)
============================================================ */
const shells = [];
const shellGeo = new THREE.SphereGeometry(0.26, 8, 6);
const shellMatP = new THREE.MeshBasicMaterial({ color: 0xffe9b0 });
const shellMatE = new THREE.MeshBasicMaterial({ color: 0xff6a5a });

function ballisticDir(from, to, speed) {
  const dx = to.x - from.x, dz = to.z - from.z;
  const dy = (to.y ?? 0) - from.y;
  const hd = Math.hypot(dx, dz);
  if (hd < 0.5) return new THREE.Vector3(0, 1, 0);
  const s2 = speed * speed;
  const disc = s2 * s2 - G * (G * hd * hd + 2 * dy * s2);
  const el = disc > 0 ? Math.atan((s2 - Math.sqrt(disc)) / (G * hd)) : Math.PI / 4;
  const ce = Math.cos(el);
  return new THREE.Vector3(dx / hd * ce, Math.sin(el), dz / hd * ce);
}

function fireShell(owner, target, speed) {
  const muzzlePos = new THREE.Vector3();
  owner.muzzle.getWorldPosition(muzzlePos);
  const dir = ballisticDir(muzzlePos, target, speed);
  const mesh = new THREE.Mesh(shellGeo, owner.isPlayer ? shellMatP : shellMatE);
  mesh.position.copy(muzzlePos);
  scene.add(mesh);
  shells.push({ pos: mesh.position, vel: dir.multiplyScalar(speed).add(owner.vel.clone().multiplyScalar(0.4)), mesh, owner, life: 9 });
  // muzzle flash
  flash(muzzlePos, 60, 0xffd080);
  for (let i = 0; i < 5; i++) {
    fire.spawn(muzzlePos.x, muzzlePos.y, muzzlePos.z,
      dir.x * 6 + (Math.random() - 0.5) * 2, dir.y * 6 + Math.random() * 2, dir.z * 6 + (Math.random() - 0.5) * 2,
      0.15 + Math.random() * 0.15, 1.2 + Math.random(), 1.0, 0.75, 0.35, -3);
  }
  AudioSys.cannon(camera.position.distanceTo(muzzlePos));
  if (owner.isPlayer) shake(0.12);
}

function updateShells(dt, t) {
  for (let i = shells.length - 1; i >= 0; i--) {
    const s = shells[i];
    s.life -= dt;
    s.vel.y -= G * dt;
    s.pos.addScaledVector(s.vel, dt);
    // tracer
    if (Math.random() < 0.6) {
      fire.spawn(s.pos.x, s.pos.y, s.pos.z, 0, 0, 0, 0.22, 0.7, 1.0, 0.6, 0.25, -2);
    }
    let kill = s.life <= 0;
    const h = waveHeight(s.pos.x, s.pos.z, t);
    if (s.pos.y <= h + 0.1) {
      splash(new THREE.Vector3(s.pos.x, h, s.pos.z), 0.9);
      kill = true;
    }
    if (!kill) {
      if (s.owner.isPlayer) {
        for (const e of enemies) {
          if (e.dead) continue;
          const d2 = s.pos.distanceToSquared(e.group.position);
          if (d2 < 12.5) {
            explosion(s.pos, false);
            damageEnemy(e, 24 + Math.random() * 8);
            kill = true; break;
          }
        }
      } else {
        const d2 = s.pos.distanceToSquared(player.group.position);
        if (d2 < 12.5) {
          explosion(s.pos, false);
          damagePlayer(12 + Math.random() * 6);
          kill = true;
        }
      }
    }
    if (kill) {
      scene.remove(s.mesh);
      shells.splice(i, 1);
    }
  }
}

/* ============================================================
   GAME STATE
============================================================ */
const player = makeBoatState(true, { hull: 0x24425e, deck: 0xa8bdd0, accent: 0x2f8f9f });
player.group.position.set(0, 0, 0);
player.hp = 100; player.maxHp = 100;

const enemies = [];
let score = 0, kills = 0, waveLevel = 1;
let gameOver = false;
let shakeAmp = 0;
let spawnTimer = 0;

function shake(a) { shakeAmp = Math.min(1.6, shakeAmp + a); }

function spawnEnemy() {
  const ang = Math.random() * Math.PI * 2;
  const dist = 85 + Math.random() * 45;
  const x = THREE.MathUtils.clamp(player.group.position.x + Math.sin(ang) * dist, -WORLD_R + 10, WORLD_R - 10);
  const z = THREE.MathUtils.clamp(player.group.position.z + Math.cos(ang) * dist, -WORLD_R + 10, WORLD_R - 10);
  const e = makeBoatState(false, {
    hull: 0x4a2323, deck: 0x9a7a6a, accent: 0x9f2f2f, scale: 0.92,
  });
  e.group.position.set(x, 0, z);
  e.heading = Math.atan2(player.group.position.x - x, player.group.position.z - z);
  e.hp = e.maxHp = 55 + waveLevel * 12;
  enemies.push(e);
}

function damageEnemy(e, dmg) {
  if (e.dead) return;
  e.hp -= dmg;
  AudioSys.hit();
  if (e.hp <= 0) {
    e.dead = true;
    explosion(new THREE.Vector3(e.group.position.x, e.group.position.y + 1, e.group.position.z), true);
    kills++;
    score += 100 + waveLevel * 25;
    if (kills % 5 === 0) waveLevel++;
  }
}

function damagePlayer(dmg, silent = false) {
  if (gameOver) return;
  player.hp -= dmg;
  player.lastDamage = elapsed;
  if (!silent) { shake(0.45); AudioSys.hit(); }
  vignetteFlash = 0.85;
  if (player.hp <= 0) {
    player.hp = 0;
    gameOver = true;
    explosion(new THREE.Vector3(player.group.position.x, player.group.position.y + 1, player.group.position.z), true);
    player.dead = true;
    document.getElementById('gameover').style.display = 'flex';
    document.getElementById('finalScore').textContent =
      `SCORE ${Math.round(score)}  ·  ${kills} KILLS  ·  SURVIVED ${Math.floor(elapsed)}s`;
  }
}

function resetGame() {
  for (const e of enemies) scene.remove(e.group);
  enemies.length = 0;
  for (const s of shells) scene.remove(s.mesh);
  shells.length = 0;
  player.dead = false; player.hp = player.maxHp = 100;
  player.group.position.set(0, 0, 0);
  player.vel.set(0, 0, 0); player.vy = 0;
  player.heading = 0; player.pitch = player.roll = 0;
  player.group.rotation.set(0, 0, 0);
  score = 0; kills = 0; waveLevel = 1;
  gameOver = false;
  rogue.active = false; waterUniforms.uRogueOn.value = 0;
  rogueTimer = 12;
  document.getElementById('gameover').style.display = 'none';
  for (let i = 0; i < 4; i++) spawnEnemy();
}

/* ============================================================
   INPUT
============================================================ */
const keys = {};
const mouse = { x: 0, y: 0, down: false };
addEventListener('keydown', e => {
  keys[e.code] = true;
  if (e.code === 'KeyR' && gameOver) { AudioSys.ensure(); resetGame(); }
  if (e.code === 'Space') e.preventDefault();
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('mousemove', e => {
  mouse.x = (e.clientX / innerWidth) * 2 - 1;
  mouse.y = -(e.clientY / innerHeight) * 2 + 1;
  const ch = document.getElementById('crosshair');
  ch.style.left = e.clientX + 'px';
  ch.style.top = e.clientY + 'px';
});
addEventListener('mousedown', e => {
  AudioSys.ensure();
  document.getElementById('startnote').style.display = 'none';
  if (e.button === 0) mouse.down = true;
});
addEventListener('mouseup', e => { if (e.button === 0) mouse.down = false; });
addEventListener('contextmenu', e => e.preventDefault());

/* ============================================================
   AIMING
============================================================ */
const aimPoint = new THREE.Vector3(0, 0, 50);
const aimRing = new THREE.Mesh(
  new THREE.RingGeometry(1.4, 1.75, 40).rotateX(-Math.PI / 2),
  new THREE.MeshBasicMaterial({ color: 0x6ee7ff, transparent: true, opacity: 0.6, depthWrite: false }));
scene.add(aimRing);

const _ray = new THREE.Raycaster();
const _ndc = new THREE.Vector2();
const _plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
function updateAim() {
  _ndc.set(mouse.x, mouse.y);
  _ray.setFromCamera(_ndc, camera);
  const hit = _ray.ray.intersectPlane(_plane, aimPoint);
  if (!hit) {
    // looking at the horizon — project far ahead
    aimPoint.copy(camera.position).addScaledVector(_ray.ray.direction, 250);
    aimPoint.y = 0;
  }
  const rr = Math.hypot(aimPoint.x, aimPoint.z);
  if (rr > WORLD_R + 20) { aimPoint.x *= (WORLD_R + 20) / rr; aimPoint.z *= (WORLD_R + 20) / rr; }
  aimRing.position.set(aimPoint.x, waveHeight(aimPoint.x, aimPoint.z, elapsed) + 0.2, aimPoint.z);
  const pulse = 1 + Math.sin(elapsed * 6) * 0.12;
  aimRing.scale.setScalar(pulse);
  aimRing.material.opacity = 0.45 + Math.sin(elapsed * 6) * 0.15;
}

/* ============================================================
   ROGUE WAVE SCHEDULER
============================================================ */
let rogueTimer = 14;
let impactShown = 0;
function updateRogue(dt) {
  rogueTimer -= dt;
  if (!rogue.active && rogueTimer <= 0) {
    rogue.active = true;
    const ang = Math.random() * Math.PI * 2;
    rogue.dir.set(Math.cos(ang), Math.sin(ang));
    rogue.t0 = elapsed + 2.5; // small lead-in so it enters the map
    waterUniforms.uRogueDir.value.copy(rogue.dir);
    waterUniforms.uRogueT0.value = rogue.t0;
    waterUniforms.uRogueOn.value = 1;
    AudioSys.warning();
  }
  if (rogue.active) {
    // warning banner while the packet is approaching
    const front = rogue.speed * (elapsed - rogue.t0);
    const sP = rogue.dir.x * player.group.position.x + rogue.dir.y * player.group.position.z;
    const distAhead = sP - front;
    const banner = document.getElementById('waveBanner');
    if (distAhead > -30 && distAhead < 400) {
      banner.style.display = 'block';
      const deg = Math.atan2(rogue.dir.x, rogue.dir.y) * 180 / Math.PI;
      document.getElementById('waveBannerSub').textContent =
        `▲ WALL OF WATER · ${Math.max(0, Math.round(distAhead))}m · DIR ${Math.round((deg + 360) % 360)}°`;
    } else {
      banner.style.display = 'none';
    }
    // deactivate once the packet has crossed the whole map
    if (front > MAP_D * 0.75 + 60) {
      rogue.active = false;
      waterUniforms.uRogueOn.value = 0;
      rogueTimer = 16 + Math.random() * 14;
      banner.style.display = 'none';
    }
  }
}

/* ============================================================
   HUD
============================================================ */
const el = id => document.getElementById(id);
let vignetteFlash = 0;
function showImpact() {
  const e = el('impact');
  e.style.transition = 'none'; e.style.opacity = 0.95;
  requestAnimationFrame(() => {
    e.style.transition = 'opacity 1.1s ease'; e.style.opacity = 0;
  });
}
function updateHUD() {
  const hpFrac = Math.max(0, player.hp / player.maxHp);
  const fill = el('hpfill');
  fill.style.width = (hpFrac * 100) + '%';
  fill.classList.toggle('low', hpFrac < 0.35);
  el('speedVal').textContent = (Math.hypot(player.vel.x, player.vel.z) * 1.9).toFixed(0) + ' kn';
  el('scoreVal').textContent = Math.round(score);
  el('killVal').textContent = kills;
  el('enemyCount').textContent = enemies.filter(e => !e.dead).length;
  vignetteFlash = Math.max(0, vignetteFlash - 0.03);
  const lowPulse = hpFrac < 0.3 ? (0.25 + 0.2 * Math.sin(elapsed * 5)) : 0;
  el('vignette').style.opacity = Math.min(1, vignetteFlash);
  el('lowhp').style.opacity = lowPulse;
}

/* ---------- minimap ---------- */
const mm = el('minimap').getContext('2d');
function drawMinimap() {
  const S = 170, R = 78, cx = S / 2, cy = S / 2;
  const scale = R / 130;
  mm.clearRect(0, 0, S, S);
  mm.save();
  mm.beginPath(); mm.arc(cx, cy, R, 0, Math.PI * 2); mm.clip();
  mm.fillStyle = 'rgba(10,26,40,0.9)'; mm.fillRect(0, 0, S, S);
  mm.strokeStyle = 'rgba(110,231,255,0.15)';
  for (const r of [R * 0.33, R * 0.66]) { mm.beginPath(); mm.arc(cx, cy, r, 0, Math.PI * 2); mm.stroke(); }
  // rogue wave front
  if (rogue.active) {
    const front = rogue.speed * (elapsed - rogue.t0);
    const px = player.group.position.x, pz = player.group.position.z;
    const a = rogue.dir.y, b = -rogue.dir.x; // perpendicular
    mm.strokeStyle = 'rgba(255,150,90,0.85)';
    mm.lineWidth = 3;
    mm.beginPath();
    for (let o = -160; o <= 160; o += 8) {
      // point on front line: base = dir * front, offset along perpendicular
      const bx = rogue.dir.x * front, bz = rogue.dir.y * front;
      const x = (bx + a * o - px) * scale + cx;
      const y = (bz + b * o - pz) * scale + cy;
      if (o === -160) mm.moveTo(x, y); else mm.lineTo(x, y);
    }
    mm.stroke();
    mm.lineWidth = 1;
  }
  const dot = (x, z, color, r) => {
    mm.fillStyle = color;
    mm.beginPath();
    mm.arc((x - player.group.position.x) * scale + cx, (z - player.group.position.z) * scale + cy, r, 0, Math.PI * 2);
    mm.fill();
  };
  for (const e of enemies) if (!e.dead) dot(e.group.position.x, e.group.position.z, '#ff5d5d', 3.2);
  // player arrow
  mm.save();
  mm.translate(cx, cy);
  mm.rotate(-player.heading + Math.PI);
  mm.fillStyle = '#6ee7ff';
  mm.beginPath(); mm.moveTo(0, -6); mm.lineTo(4.5, 5); mm.lineTo(0, 2.5); mm.lineTo(-4.5, 5); mm.closePath(); mm.fill();
  mm.restore();
  mm.restore();
  mm.strokeStyle = 'rgba(110,231,255,0.4)';
  mm.beginPath(); mm.arc(cx, cy, R, 0, Math.PI * 2); mm.stroke();
}

/* ============================================================
   ENEMY AI
============================================================ */
function updateEnemy(e, dt, t) {
  if (e.dead) { updateBoatPhysics(e, dt, t, 0, 0); return; }
  const pp = player.group.position;
  const ep = e.group.position;
  const dx = pp.x - ep.x, dz = pp.z - ep.z;
  const dist = Math.hypot(dx, dz);

  // desired point: orbit the player at ~38m, with separation from other enemies
  e.ai.strafe += dt * 0.35;
  const orbitR = 34 + Math.sin(e.ai.strafe * 0.7) * 8;
  const px = dx / (dist || 1), pz = dz / (dist || 1);
  let tx = pp.x - px * orbitR - pz * e.ai.orbitDir * orbitR * 0.5;
  let tz = pp.z - pz * orbitR + px * e.ai.orbitDir * orbitR * 0.5;
  for (const o of enemies) {
    if (o === e || o.dead) continue;
    const ox = ep.x - o.group.position.x, oz = ep.z - o.group.position.z;
    const od = Math.hypot(ox, oz);
    if (od < 14 && od > 0.01) { tx += ox / od * (14 - od) * 2; tz += oz / od * (14 - od) * 2; }
  }
  const desired = Math.atan2(tx - ep.x, tz - ep.z);
  let dh = desired - e.heading;
  while (dh > Math.PI) dh -= Math.PI * 2;
  while (dh < -Math.PI) dh += Math.PI * 2;
  const turn = THREE.MathUtils.clamp(dh * 2.5, -1, 1);
  const thrust = Math.abs(dh) > 1.9 ? 4 : (dist > orbitR + 8 ? 15 : 9);

  updateBoatPhysics(e, dt, t, thrust, turn);

  // turret tracks the player with a little lag
  const aimYaw = Math.atan2(pp.x - ep.x, pp.z - ep.z);
  let ty = aimYaw - e.heading;
  while (ty > Math.PI) ty -= Math.PI * 2;
  while (ty < -Math.PI) ty += Math.PI * 2;
  e.turret.rotation.y += (ty - e.turret.rotation.y) * Math.min(1, 3 * dt);

  // fire when roughly aimed
  e.ai.fireTimer -= dt;
  if (e.ai.fireTimer <= 0 && Math.abs(ty) < 0.12 && dist < 80 && !player.dead) {
    e.ai.fireTimer = 2.4 + Math.random() * 1.6;
    const T = dist / 38;
    const lead = 0.55;
    const target = new THREE.Vector3(
      pp.x + player.vel.x * T * lead + (Math.random() - 0.5) * 6,
      0.4,
      pp.z + player.vel.z * T * lead + (Math.random() - 0.5) * 6);
    fireShell(e, target, 40);
  }
}

/* ============================================================
   CAMERA
============================================================ */
const camPos = new THREE.Vector3(0, 9, -20);
function updateCamera(dt) {
  const p = player.group.position;
  const fx = Math.sin(player.heading), fz = Math.cos(player.heading);
  const speed = Math.hypot(player.vel.x, player.vel.z);
  // mouse-influenced offset
  const side = mouse.x * 5;
  const rx = Math.cos(player.heading), rz = -Math.sin(player.heading);
  const dist = 17 + speed * 0.12;
  const desired = new THREE.Vector3(
    p.x - fx * dist + rx * side,
    p.y + 7.5 + mouse.y * 1.5,
    p.z - fz * dist + rz * side);
  const k = 1 - Math.exp(-4.5 * dt);
  camPos.lerp(desired, k);
  shakeAmp *= Math.exp(-3.5 * dt);
  camera.position.copy(camPos);
  if (shakeAmp > 0.001) {
    camera.position.x += (Math.random() - 0.5) * shakeAmp;
    camera.position.y += (Math.random() - 0.5) * shakeAmp * 0.6;
    camera.position.z += (Math.random() - 0.5) * shakeAmp;
  }
  camera.lookAt(p.x + fx * 8, p.y + 1.5, p.z + fz * 8);
  // sun light follows the action
  sun.position.copy(p).addScaledVector(SUN_DIR, 150);
  sun.target.position.copy(p);
  sun.target.updateMatrixWorld();
}

/* ============================================================
   MAIN LOOP
============================================================ */
const clock = new THREE.Clock();
let elapsed = 0;
let fireHeld = 0;

function animate() {
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  elapsed += dt;

  // water time
  waterUniforms.uTime.value = elapsed;
  water.position.set(Math.round(player.group.position.x / 2) * 2, 0, Math.round(player.group.position.z / 2) * 2);

  if (!gameOver) {
    // --- player input ---
    const fwd = (keys.KeyW || keys.ArrowUp ? 1 : 0) - (keys.KeyS || keys.ArrowDown ? 1 : 0);
    const turn = (keys.KeyA || keys.ArrowLeft ? 1 : 0) - (keys.KeyD || keys.ArrowRight ? 1 : 0);
    const thrust = fwd >= 0 ? fwd * 24 : fwd * 11;
    updateBoatPhysics(player, dt, elapsed, thrust, turn);

    // --- firing ---
    player.fireCd -= dt;
    fireHeld = (mouse.down || keys.Space) ? fireHeld + dt : 0;
    if ((mouse.down || keys.Space) && player.fireCd <= 0) {
      player.fireCd = 0.42;
      fireShell(player, aimPoint, 58);
    }

    // turret aims at the mouse
    const pp = player.group.position;
    const aimYaw = Math.atan2(aimPoint.x - pp.x, aimPoint.z - pp.z);
    let ty = aimYaw - player.heading;
    while (ty > Math.PI) ty -= Math.PI * 2;
    while (ty < -Math.PI) ty += Math.PI * 2;
    player.turret.rotation.y += (ty - player.turret.rotation.y) * Math.min(1, 10 * dt);

    // health regen
    if (elapsed - player.lastDamage > 5 && player.hp > 0) {
      player.hp = Math.min(player.maxHp, player.hp + 2.2 * dt);
    }

    // enemies
    for (const e of enemies) updateEnemy(e, dt, elapsed);
    // cull fully-sunk enemies
    for (let i = enemies.length - 1; i >= 0; i--) {
      if (enemies[i].dead && enemies[i].sinking > 3.2) {
        scene.remove(enemies[i].group);
        enemies.splice(i, 1);
      }
    }
    // respawn hostiles
    spawnTimer -= dt;
    const alive = enemies.filter(e => !e.dead).length;
    const want = Math.min(6, 3 + Math.floor(waveLevel / 2) + 1);
    if (alive < want && spawnTimer <= 0) {
      spawnTimer = 4;
      spawnEnemy();
    }
  } else {
    // game over: keep seas alive, sink the player
    updateBoatPhysics(player, dt, elapsed, 0, 0);
    for (const e of enemies) updateEnemy(e, dt, elapsed);
  }

  updateAim();
  updateShells(dt, elapsed);
  updateRogue(dt);
  spray.update(dt);
  wake.update(dt);
  fire.update(dt);
  updateRings(dt);
  updateFlashes(dt);
  updateCamera(dt);
  updateHUD();
  drawMinimap();

  renderer.render(scene, camera);
}

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

// spawn the first hostile wave
for (let i = 0; i < 4; i++) spawnEnemy();

// debug / QA hook
window.__boatDebug = {
  snapshot: () => ({
    shells: shells.length, enemies: enemies.length, alive: enemies.filter(e => !e.dead).length,
    playerY: player.group.position.y, playerHp: player.hp,
    playerPos: { x: player.group.position.x, z: player.group.position.z },
    enemyInfo: enemies.map(e => ({
      x: Math.round(e.group.position.x), z: Math.round(e.group.position.z),
      vx: Math.round(e.vel.x), vz: Math.round(e.vel.z),
      hp: Math.round(e.hp), dead: e.dead,
    })),
    rogue: rogue.active, rogueFront: rogue.active ? rogue.speed * (elapsed - rogue.t0) : null,
    score, kills, elapsed,
  }),
  forceRogue: () => { rogueTimer = 0.05; },
  fire: () => { fireShell(player, aimPoint, 58); },
  killPlayer: () => damagePlayer(9999),
  restart: () => resetGame(),
  movePlayer: (x, z) => { player.group.position.x = x; player.group.position.z = z; },
};
animate();
</script>
</body>
</html>

Same prompt, other models

6 more
Compare side by side

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