Showcase

Boat game, built by MiniMax M3.1 Flash

Hostedvia OpenCodeOpenRouter
Running live in your browserNew tab
Speed
95tok/s
First token included
Written
254.5Ktokens
Time to build
44m 33s
MiniMax M3.1 Flash's builds and scores
Show the code the model wrote
HTML78,293 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>STORMRUNNER — Open Sea Combat</title>
<style>
  :root{
    --ink:#e8f1f7; --dim:#8fa6b4; --glass:rgba(8,17,25,.55); --line:rgba(140,190,220,.22);
    --hot:#ff6a4d; --gold:#ffcf6b; --good:#5ee0a0; --bad:#ff4d5e;
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;overflow:hidden;background:#05080c;color:var(--ink);
    font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;-webkit-font-smoothing:antialiased}
  canvas#view{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:none}
  #hud{position:fixed;inset:0;pointer-events:none;user-select:none}
  .panel{position:absolute;background:var(--glass);border:1px solid var(--line);
    border-radius:10px;padding:10px 13px;backdrop-filter:blur(9px) saturate(1.2);
    box-shadow:0 10px 30px rgba(0,0,0,.45)}
  .lbl{font-size:9.5px;letter-spacing:.22em;color:var(--dim);text-transform:uppercase}
  #tl{top:16px;left:16px;min-width:236px}
  #tr{top:16px;right:16px;min-width:150px;text-align:right}
  .bar{height:9px;border-radius:5px;background:rgba(255,255,255,.09);overflow:hidden;margin:5px 0 9px;
    border:1px solid rgba(255,255,255,.07)}
  .bar>i{display:block;height:100%;width:100%;border-radius:5px;transition:width .18s ease;
    background:linear-gradient(90deg,#2fd6a4,#7ff0c4);box-shadow:0 0 14px rgba(60,230,180,.55)}
  .row{display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.7;color:var(--dim)}
  .row b{color:var(--ink);font-weight:600}
  #bl{bottom:16px;left:16px;font-size:10.5px;line-height:1.85;color:var(--dim);max-width:330px}
  #bl kbd{background:rgba(255,255,255,.1);border:1px solid var(--line);border-bottom-width:2px;
    border-radius:4px;padding:1px 5px;color:var(--ink);font-size:9.5px}
  #alert{position:absolute;top:16%;left:50%;transform:translateX(-50%);text-align:center;
    font-size:clamp(18px,3.2vw,34px);letter-spacing:.24em;font-weight:700;color:var(--hot);
    text-shadow:0 0 26px rgba(255,80,60,.75),0 3px 10px rgba(0,0,0,.8);opacity:0;
    transition:opacity .25s ease;white-space:nowrap}
  #alert small{display:block;font-size:11px;letter-spacing:.3em;color:var(--gold);margin-top:8px;opacity:.9}
  #alert.on{opacity:1;animation:pulse .85s ease-in-out infinite alternate}
  @keyframes pulse{from{opacity:.45}to{opacity:1}}
  #cross{position:absolute;width:26px;height:26px;margin:-13px 0 0 -13px;pointer-events:none;
    opacity:.95;transition:opacity .2s}
  #cross:before,#cross:after{content:"";position:absolute;background:var(--gold);
    box-shadow:0 0 8px rgba(255,207,107,.85)}
  #cross:before{left:12px;top:0;width:2px;height:26px}
  #cross:after{top:12px;left:0;height:2px;width:26px}
  #cross i{position:absolute;inset:8px;border:1.5px solid var(--gold);border-radius:50%;
    box-shadow:0 0 8px rgba(255,207,107,.8)}
  #hit{position:fixed;inset:0;pointer-events:none;opacity:0;
    background:radial-gradient(ellipse at center,rgba(255,0,0,0) 35%,rgba(255,20,40,.62) 100%);
    transition:opacity .35s ease}
  #vig{position:fixed;inset:0;pointer-events:none;
    background:radial-gradient(ellipse at center,rgba(0,0,0,0) 52%,rgba(0,0,0,.55) 100%)}
  #ov{position:fixed;inset:0;display:grid;place-items:center;pointer-events:auto;
    background:radial-gradient(ellipse at 50% 40%,rgba(6,14,22,.72),rgba(2,5,9,.95));
    backdrop-filter:blur(4px);transition:opacity .4s ease}
  #ov.hide{opacity:0;pointer-events:none}
  .card{max-width:560px;padding:34px 40px;text-align:center;border:1px solid var(--line);
    border-radius:16px;background:rgba(7,14,21,.72);box-shadow:0 30px 80px rgba(0,0,0,.6)}
  .card h1{margin:0 0 4px;font-size:clamp(26px,5vw,44px);letter-spacing:.2em;font-weight:700;
    background:linear-gradient(180deg,#fff,#8fd6ff);-webkit-background-clip:text;background-clip:text;color:transparent}
  .card h2{margin:0 0 18px;font-size:11px;letter-spacing:.42em;color:var(--hot);text-transform:uppercase}
  .card p{font-size:12.5px;line-height:1.95;color:var(--dim);margin:14px 0}
  .card .keys{display:grid;grid-template-columns:auto 1fr;gap:7px 16px;font-size:11.5px;
    text-align:left;margin:20px auto;max-width:400px;color:var(--dim)}
  .card .keys b{color:var(--ink);font-weight:600;text-align:right;letter-spacing:.08em}
  .btn{margin-top:8px;pointer-events:auto;cursor:pointer;border:1px solid rgba(120,220,255,.45);
    background:linear-gradient(180deg,rgba(40,140,190,.35),rgba(20,80,120,.25));color:#eaf7ff;
    padding:12px 30px;border-radius:10px;font:inherit;font-size:12px;letter-spacing:.26em;
    text-transform:uppercase;transition:.18s}
  .btn:hover{background:linear-gradient(180deg,rgba(60,180,235,.5),rgba(30,110,160,.35));
    box-shadow:0 0 26px rgba(70,190,240,.4)}
  .score{font-size:34px;letter-spacing:.1em;color:var(--gold);margin:6px 0 2px}
  #err{position:fixed;inset:0;display:none;place-items:center;padding:40px;background:#080c11;
    color:#ff8080;font-size:13px;white-space:pre-wrap;overflow:auto;z-index:99}
  .tag{position:absolute;bottom:16px;right:16px;font-size:9.5px;letter-spacing:.18em;color:var(--dim);
    background:var(--glass);border:1px solid var(--line);border-radius:8px;padding:7px 11px}
</style>
</head>
<body>
<canvas id="view"></canvas>
<div id="vig"></div>
<div id="hit"></div>

<div id="hud">
  <div class="panel" id="tl">
    <div class="lbl">Hull Integrity</div>
    <div class="bar"><i id="hullFill"></i></div>
    <div class="row"><span>Speed</span><b id="speed">0.0 kn</b></div>
    <div class="row"><span>Sea State</span><b id="sea">1.2 m</b></div>
  </div>
  <div class="panel" id="tr">
    <div class="row"><span>Hostiles</span><b id="foes">0</b></div>
    <div class="row"><span>Salvage</span><b id="score">0</b></div>
    <div class="row"><span>Wave</span><b id="wstate">calm</b></div>
    <div class="row"><span>FPS</span><b id="fps">--</b></div>
  </div>
  <div class="panel" id="bl">
    <div><kbd>W</kbd><kbd>S</kbd> throttle &nbsp; <kbd>A</kbd><kbd>D</kbd> helm</div>
    <div><kbd>Mouse</kbd> aim &nbsp; <kbd>Click</kbd> fire &nbsp; <kbd>Space</kbd> all guns</div>
    <div><kbd>R</kbd> restart &nbsp; <kbd>P</kbd> pause &nbsp; <kbd>M</kbd> mute</div>
  </div>
  <div id="alert">INCOMING WAVE<small>BRACE FOR IMPACT</small></div>
  <div id="cross"><i></i></div>
  <div class="tag">STORMRUNNER · real-time wave field + buoyancy physics</div>
</div>

<div id="ov">
  <div class="card" id="card">
    <h1>STORMRUNNER</h1>
    <h2 id="ovSub">Open Sea Combat</h2>
    <div id="ovBody">
      <p>A living ocean: a full spectrum wave field with dispersion, choppy crests and
      whitecaps. Your hull rides it with real buoyancy, heave, pitch and roll.</p>
      <div class="keys">
        <b>W / S</b><span>Throttle ahead &amp; astern</span>
        <b>A / D</b><span>Helm — turn the wheel</span>
        <b>Mouse</b><span>Aim the bow gun</span>
        <b>Click / Space</b><span>Fire (hold to keep firing)</span>
        <b>R / P</b><span>Restart &amp; pause</span>
      </div>
      <p>Hostile gunboats are hunting you. And the sea is not on your side —
      watch for rogue waves.</p>
    </div>
    <div id="ovScore" style="display:none"><div class="score" id="finalScore">0</div><div class="lbl">Salvage recovered</div></div>
    <button class="btn" id="startBtn">Cast Off</button>
  </div>
</div>
<div id="err"></div>

<script>
window.__errs=[];
function showFatal(msg){
  const e=document.getElementById('err');
  if(!e||e.style.display==='grid') return;
  e.style.display='grid';
  e.textContent=String(msg);
}
window.addEventListener('error',e=>{
  window.__errs.push((e.message||String(e.error))+' @'+(e.filename||'').split('/').pop()+':'+(e.lineno||''));
  showFatal(window.__errs.join('\n'));
});
window.addEventListener('unhandledrejection',e=>{window.__errs.push('promise: '+e.reason);});
const _ce=console.error.bind(console);
console.error=(...a)=>{window.__errs.push('console: '+a.map(x=>String(x)).join(' ').slice(0,900));_ce(...a);};
</script>

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
  }
}
</script>

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

/* ═══════════════════════════════════════════════════════════════
   STORMRUNNER — physically-simulated open-sea combat
   Ocean: dispersive directional wave spectrum (JONSWAP) evaluated
   identically on CPU (buoyancy) and GPU (surface), with Gerstner
   horizontal chop, crest folding -> foam, plus travelling rogue
   wave packets.
   ═══════════════════════════════════════════════════════════════ */

const QS = new URLSearchParams(location.search);
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const lerp  = (a,b,t)=> a+(b-a)*t;
const smoothstep=(e0,e1,x)=>{const t=clamp((x-e0)/(e1-e0),0,1);return t*t*(3-2*t);};
const rand=(a=0,b=1)=>a+Math.random()*(b-a);
const randInt=(a,b)=>Math.floor(a+Math.random()*(b-a+1));
const TAU=Math.PI*2;
const G=9.81;
function angDiff(a,b){let d=(a-b+Math.PI)%TAU;if(d<0)d+=TAU;return d-Math.PI;}

const CFG = {
  NW: 36,                 // spectral components
  Lmin: 1.7, Lmax: 230,   // wavelength band (m)
  seaState: 0.52,         // amplitude scale
  windDir: 0.42,          // rad
  sunElev: 15.0,          // deg  (low golden sun)
  sunAzim: 0.55,          // rad
  fogDensity: 0.00072,
  gridHalf: 2600, gridN: 256, gridPow: 3.0,
  maxEnemies: 3,
  muzzleSpeed: 62,
};

/* ────────────────────────── wave field ────────────────────────── */
class WaveField{
  constructor(){
    this.n = CFG.NW;
    this.A = [];   // dirx, dirz, k, amp
    this.B = [];   // omega, phase, Q, 0
    this.build();
    this.rogue = {on:0, dx:0, dz:1, amp:0, k:0.16, cx:0, cz:0, sigma:48, v:13};
    this.variance = 0;
  }
  build(){
    const n=this.n, Lmin=CFG.Lmin, Lmax=CFG.Lmax;
    const U=9.6, wp=0.855*G/U, gamma=3.3, alpha=0.0081;
    const lr=Math.log(Lmax/Lmin), dOmega=lr/(n-1);
    let varSum=0,steep=0;
    for(let i=0;i<n;i++){
      const t=i/(n-1);
      const L=Lmin*Math.exp(lr*t);
      const k=TAU/L, w=Math.sqrt(G*k);
      const r=Math.exp(-Math.pow((w-wp)/(0.13*wp),2));
      const S=alpha*G*G/Math.pow(w,5)*Math.exp(-1.25*Math.pow(wp/w,4))*Math.pow(gamma,r);
      const dw=w*dOmega;
      let amp=Math.sqrt(Math.max(0,2*S*dw))*CFG.seaState;
      // directional spreading: long swell narrow, short chop wide
      const spread=lerp(0.34,1.25,t);
      const u=Math.random()*2-1;
      const ang=CFG.windDir+spread*Math.sign(u)*Math.pow(Math.abs(u),2.1);
      // a dedicated low-frequency swell train from a different bearing
      let dirA = ang;
      if(i<4){ const sw=CFG.windDir+1.25; dirA=sw+rand(-.25,.25); amp*=1.35; }
      const dx=Math.sin(dirA), dz=Math.cos(dirA);
      const phase=Math.random()*TAU;
      // Gerstner chop: keep sum(Q*A*k) < 1 so the horizontal map stays
      // non-degenerate, while letting the dominant swell form sharp crests.
      const Q=clamp(0.026/Math.max(amp*k,1e-6),0,1.15);
      this.A.push(new THREE.Vector4(dx,dz,k,amp));
      this.B.push(new THREE.Vector4(Math.sqrt(G*k),phase,Q,0));
      varSum += amp*amp*0.5;
      steep += Q*amp*k;
    }
    // normalise the total horizontal steepness so the inverse map
    // (world position -> parametric position) always converges
    if(steep>0.85){
      const f=0.85/steep;
      for(const B of this.B) B.z*=f;
    }
    this.variance=varSum;
    this.hs=4*Math.sqrt(varSum);
  }
  /* horizontal Gerstner displacement of the spectral sum at parametric q */
  disp(qx,qz,t,out){
    let dx=0,dy=0,dz=0;
    for(let i=0;i<this.n;i++){
      const A=this.A[i],B=this.B[i];
      const ph=A.z*(A.x*qx+A.y*qz)-B.x*t+B.y;
      const c=Math.cos(ph);
      dx+=B.z*A.w*A.x*c; dz+=B.z*A.w*A.y*c; dy+=A.w*Math.sin(ph);
    }
    out.x=dx;out.y=dy;out.z=dz;
  }
  /* rogue wave packet: gaussian-bounded travelling swell */
  rogueH(x,z,t,out){
    const R=this.rogue;
    if(R.on<0.5){ if(out){out.y=0;out.gx=0;out.gz=0;} return 0; }
    const rx=x-R.cx, rz=z-R.cz;
    const a=rx*R.dx+rz*R.dz;
    const c=-rx*R.dz+rz*R.dx;
    const s2=2*R.sigma*R.sigma;
    const g=Math.exp(-(a*a+c*c)/s2);
    const ck=Math.cos(R.k*a), sk=Math.sin(R.k*a);
    const h=R.amp*g*ck;
    const ga=-a/(R.sigma*R.sigma)*g, gc=-c/(R.sigma*R.sigma)*g;
    const dha=R.amp*(ga*ck-R.k*g*sk), dhc=R.amp*gc*ck;
    const gx=dha*R.dx+dhc*(-R.dz), gz=dha*R.dz+dhc*R.dx;
    if(out){out.y=h;out.gx=gx;out.gz=gz;}
    return h;
  }
  /* full evaluation at a parametric point: height, slope, folding, dH/dt */
  evalQ(qx,qz,t,out){
    let h=0,sx=0,sz=0,lxx=0,lzz=0,lxz=0,dydt=0;
    for(let i=0;i<this.n;i++){
      const A=this.A[i],B=this.B[i];
      const ph=A.z*(A.x*qx+A.y*qz)-B.x*t+B.y;
      const s=Math.sin(ph),c=Math.cos(ph);
      const ak=A.w*A.z;
      h+=A.w*s; sx+=ak*A.x*c; sz+=ak*A.y*c;
      lxx-=ak*A.z*A.x*A.x*s; lzz-=ak*A.z*A.y*A.y*s; lxz-=ak*A.z*A.x*A.y*s;
      dydt-=A.w*B.x*c;
    }
    const rg={y:0,gx:0,gz:0}; this.rogueH(qx,qz,t,rg);
    h+=rg.y; sx+=rg.gx; sz+=rg.gz;
    if(!isFinite(h)||!isFinite(sx)||!isFinite(sz)){ h=0;sx=0;sz=0; }
    out.y=h; out.dx=sx; out.dz=sz; out.dydt=dydt;
    out.J=(1+lxx)*(1+lzz)-lxz*lxz;
    return out;
  }
  /* world-space water height: invert the horizontal Gerstner mapping */
  heightAt(x,z,t,out){
    let qx=x, qz=z;
    const d={x:0,y:0,z:0};
    for(let it=0;it<6;it++){
      this.disp(qx,qz,t,d);
      const ex=x-(qx+d.x), ez=z-(qz+d.z);
      if(!isFinite(ex)||!isFinite(ez)||Math.abs(ex)+Math.abs(ez)>250){ qx=x; qz=z; break; }
      qx+=ex; qz+=ez;
      if(Math.abs(ex)+Math.abs(ez)<1e-4) break;
    }
    if(!isFinite(qx)||!isFinite(qz)){ qx=x; qz=z; }
    this.evalQ(qx,qz,t,out);
    out.qx=qx; out.qz=qz;
    return out;
  }
}
const wave = new WaveField();
const WS = {y:0,dx:0,dz:0,dydt:0,J:1,qx:0,qz:0};

/* ───────────────────── procedural water detail map ───────────────────── */
function hash2i(x,y,s){
  let n=(Math.imul(x,73856093)^Math.imul(y,19349663)^Math.imul(s,83492791))>>>0;
  n=Math.imul(n^(n>>>13),1274126177)>>>0;
  n=(n^(n>>>16))>>>0;
  return n/4294967295;
}
function vnoise(x,y,per,seed){
  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 m=(a,b)=>((a%b)+b)%b;
  const x0=m(xi,per), x1=m(xi+1,per), y0=m(yi,per), y1=m(yi+1,per);
  const a=hash2i(x0,y0,seed), b=hash2i(x1,y0,seed);
  const c=hash2i(x0,y1,seed), d=hash2i(x1,y1,seed);
  return lerp(lerp(a,b,u),lerp(c,d,u),v);
}
function makeWaterDetail(size){
  const h=new Float32Array(size*size);
  const oct=[{p:4,a:1},{p:8,a:.55},{p:16,a:.3},{p:32,a:.16},{p:64,a:.08}];
  for(let y=0;y<size;y++)for(let x=0;x<size;x++){
    let v=0,tot=0;
    for(const o of oct){ v+=o.a*vnoise(x/size*o.p,y/size*o.p,o.p,7+o.p); tot+=o.a; }
    h[y*size+x]=v/tot;
  }
  const data=new Uint8Array(size*size*4);
  const S=size;
  for(let y=0;y<size;y++)for(let x=0;x<size;x++){
    const l=h[y*S+((x-1+S)%S)], r=h[y*S+((x+1)%S)];
    const d=h[((y-1+S)%S)*S+x], u=h[((y+1)%S)*S+x];
    const nx=(l-r)*26, ny=(d-u)*26, nz=1;
    const len=Math.hypot(nx,ny,nz);
    const i=(y*S+x)*4;
    data[i]=clamp(Math.round((nx/len*.5+.5)*255),0,255);
    data[i+1]=clamp(Math.round((ny/len*.5+.5)*255),0,255);
    data[i+2]=clamp(Math.round((nz/len*.5+.5)*255),0,255);
    data[i+3]=clamp(Math.round(h[y*S+x]*255),0,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.needsUpdate=true;
  return tex;
}
function makeSpriteTexture(kind){
  const s=64,c=document.createElement('canvas');c.width=c.height=s;
  const g=c.getContext('2d');
  const grd=g.createRadialGradient(s/2,s/2,0,s/2,s/2,s/2);
  if(kind==='soft'){grd.addColorStop(0,'rgba(255,255,255,1)');grd.addColorStop(.4,'rgba(255,255,255,.5)');grd.addColorStop(1,'rgba(255,255,255,0)');}
  else{grd.addColorStop(0,'rgba(255,255,255,.95)');grd.addColorStop(.55,'rgba(255,255,255,.28)');grd.addColorStop(1,'rgba(255,255,255,0)');}
  g.fillStyle=grd;g.fillRect(0,0,s,s);
  const t=new THREE.CanvasTexture(c);t.needsUpdate=true;return t;
}

/* ────────────────────────────── audio ────────────────────────────── */
const Sound={
  ctx:null,master:null,muted:false,noise:null,
  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=.55; this.master.connect(this.ctx.destination);
    const len=this.ctx.sampleRate*2, buf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
    const ch=buf.getChannelData(0);
    for(let i=0;i<len;i++) ch[i]=Math.random()*2-1;
    this.noise=buf;
    this.startAmbience();
  },
  resume(){ if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); },
  startAmbience(){
    const src=this.ctx.createBufferSource(); src.buffer=this.noise; src.loop=true;
    const lp=this.ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=420; lp.Q.value=.6;
    const hp=this.ctx.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=110;
    const g=this.ctx.createGain(); g.gain.value=.055;
    const lfo=this.ctx.createOscillator(); lfo.frequency.value=.09;
    const lg=this.ctx.createGain(); lg.gain.value=.028;
    lfo.connect(lg); lg.connect(g.gain);
    src.connect(hp); hp.connect(lp); lp.connect(g); g.connect(this.master);
    src.start(); lfo.start();
  },
  cannon(power=1){
    if(!this.ctx||this.muted) return;
    const t=this.ctx.currentTime, dur=.9;
    const src=this.ctx.createBufferSource(); src.buffer=this.noise;
    const lp=this.ctx.createBiquadFilter(); lp.type='lowpass';
    lp.frequency.setValueAtTime(1800,t); lp.frequency.exponentialRampToValueAtTime(120,t+dur);
    const g=this.ctx.createGain();
    g.gain.setValueAtTime(.0001,t);
    g.gain.exponentialRampToValueAtTime(.55*power,t+.012);
    g.gain.exponentialRampToValueAtTime(.0001,t+dur);
    src.connect(lp); lp.connect(g); g.connect(this.master);
    src.start(t); src.stop(t+dur);
    const o=this.ctx.createOscillator(); o.type='sine';
    o.frequency.setValueAtTime(120,t); o.frequency.exponentialRampToValueAtTime(38,t+.5);
    const og=this.ctx.createGain();
    og.gain.setValueAtTime(.45*power,t); og.gain.exponentialRampToValueAtTime(.0001,t+.55);
    o.connect(og); og.connect(this.master); o.start(t); o.stop(t+.6);
  },
  splash(size=1){
    if(!this.ctx||this.muted) return;
    const t=this.ctx.currentTime,dur=.55*size;
    const src=this.ctx.createBufferSource(); src.buffer=this.noise;
    const bp=this.ctx.createBiquadFilter(); bp.type='bandpass';
    bp.frequency.setValueAtTime(1500,t); bp.frequency.exponentialRampToValueAtTime(380,t+dur);
    bp.Q.value=.8;
    const g=this.ctx.createGain();
    g.gain.setValueAtTime(.0001,t);
    g.gain.exponentialRampToValueAtTime(.24*size,t+.02);
    g.gain.exponentialRampToValueAtTime(.0001,t+dur);
    src.connect(bp); bp.connect(g); g.connect(this.master);
    src.start(t); src.stop(t+dur);
  },
  boom(){
    if(!this.ctx||this.muted) return;
    const t=this.ctx.currentTime,dur=1.4;
    const src=this.ctx.createBufferSource(); src.buffer=this.noise;
    const lp=this.ctx.createBiquadFilter(); lp.type='lowpass';
    lp.frequency.setValueAtTime(2600,t); lp.frequency.exponentialRampToValueAtTime(90,t+dur);
    const g=this.ctx.createGain();
    g.gain.setValueAtTime(.0001,t);
    g.gain.exponentialRampToValueAtTime(.7,t+.02);
    g.gain.exponentialRampToValueAtTime(.0001,t+dur);
    src.connect(lp); lp.connect(g); g.connect(this.master);
    src.start(t); src.stop(t+dur);
    const o=this.ctx.createOscillator(); o.type='sine';
    o.frequency.setValueAtTime(90,t); o.frequency.exponentialRampToValueAtTime(28,t+.8);
    const og=this.ctx.createGain();
    og.gain.setValueAtTime(.6,t); og.gain.exponentialRampToValueAtTime(.0001,t+1.0);
    o.connect(og); og.connect(this.master); o.start(t); o.stop(t+1.1);
  },
  roar(){
    if(!this.ctx||this.muted) return;
    const t=this.ctx.currentTime,dur=2.6;
    const src=this.ctx.createBufferSource(); src.buffer=this.noise; src.loop=true;
    const lp=this.ctx.createBiquadFilter(); lp.type='lowpass';
    lp.frequency.setValueAtTime(180,t); lp.frequency.linearRampToValueAtTime(900,t+dur*.5);
    lp.frequency.linearRampToValueAtTime(150,t+dur);
    const g=this.ctx.createGain();
    g.gain.setValueAtTime(.0001,t);
    g.gain.exponentialRampToValueAtTime(.42,t+.5);
    g.gain.exponentialRampToValueAtTime(.0001,t+dur);
    src.connect(lp); lp.connect(g); g.connect(this.master);
    src.start(t); src.stop(t+dur);
  },
  alarm(){
    if(!this.ctx||this.muted) return;
    const t=this.ctx.currentTime;
    for(let i=0;i<2;i++){
      const o=this.ctx.createOscillator(); o.type='square'; o.frequency.value=440;
      const g=this.ctx.createGain();
      g.gain.setValueAtTime(.0001,t+i*.28);
      g.gain.exponentialRampToValueAtTime(.07,t+i*.28+.02);
      g.gain.exponentialRampToValueAtTime(.0001,t+i*.28+.2);
      o.connect(g); g.connect(this.master); o.start(t+i*.28); o.stop(t+i*.28+.22);
    }
  }
};

/* ───────────────────────── renderer & scene ───────────────────────── */
const canvas=document.getElementById('view');
let renderer;
try{
  renderer=new THREE.WebGLRenderer({canvas,antialias:true,powerPreference:'high-performance',stencil:false});
}catch(e){ document.getElementById('err').style.display='grid';
  document.getElementById('err').textContent='WebGL unavailable:\n'+e.message; throw e; }
renderer.setPixelRatio(Math.min(devicePixelRatio,1.75));
renderer.setSize(innerWidth,innerHeight);
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=0.8;
renderer.outputColorSpace=THREE.SRGBColorSpace;

const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(56,innerWidth/innerHeight,0.35,20000);
camera.position.set(0,6,-14);

const bloomOn=QS.get('fx')!=='0';
const reflOn=QS.get('refl')!=='0';

const composer=new EffectComposer(renderer);
composer.addPass(new RenderPass(scene,camera));
let bloom=null;
if(bloomOn){
  bloom=new UnrealBloomPass(new THREE.Vector2(innerWidth,innerHeight),0.24,0.8,1.25);
  composer.addPass(bloom);
}
composer.addPass(new OutputPass());

/* ── sky, sun, environment ── */
const sunElev=CFG.sunElev, sunAzim=CFG.sunAzim;
const sunPos=new THREE.Vector3().setFromSphericalCoords(1,THREE.MathUtils.degToRad(90-sunElev),sunAzim);
const sunDir=sunPos.clone().normalize();

const sky=new Sky();
sky.scale.setScalar(45000);
sky.material.uniforms.turbidity.value=3.0;
sky.material.uniforms.rayleigh.value=2.5;
sky.material.uniforms.mieCoefficient.value=0.005;
sky.material.uniforms.mieDirectionalG.value=0.80;
sky.material.uniforms.sunPosition.value.copy(sunPos);
scene.add(sky);

const envSky=new Sky();
envSky.scale.setScalar(600);
envSky.material.uniforms.turbidity.value=3.0;
envSky.material.uniforms.rayleigh.value=2.5;
envSky.material.uniforms.mieCoefficient.value=0.005;
envSky.material.uniforms.mieDirectionalG.value=0.80;
envSky.material.uniforms.sunPosition.value.copy(sunPos);
const envScene=new THREE.Scene(); envScene.add(envSky);
const pmrem=new THREE.PMREMGenerator(renderer);
scene.environment=pmrem.fromScene(envScene,0,1,2000).texture;
envSky.geometry.dispose(); envSky.material.dispose();

const sunLight=new THREE.DirectionalLight(0xffd9a8,3.1);
sunLight.position.copy(sunPos).multiplyScalar(120);
scene.add(sunLight);
scene.add(new THREE.HemisphereLight(0x9fc4e8,0x0a1a24,0.55));
const fill=new THREE.DirectionalLight(0x7fb0ff,0.35);
fill.position.set(-sunPos.x,40,-sunPos.z);
scene.add(fill);

/* ── cloud layer ── */
const cloudMat=new THREE.ShaderMaterial({
  transparent:true, depthWrite:false, side:THREE.DoubleSide,
  uniforms:{uTime:{value:0},uSun:{value:sunDir.clone()},uCam:{value:new THREE.Vector3()},
            uTint:{value:new THREE.Color(0xfff4e4)},uShade:{value:new THREE.Color(0x76899f)}},
  vertexShader:`varying vec3 vW; varying vec2 vP;
    void main(){ vec4 wp=modelMatrix*vec4(position,1.0); vW=wp.xyz; vP=wp.xz;
      gl_Position=projectionMatrix*viewMatrix*wp; }`,
  fragmentShader:`precision highp float; varying vec3 vW; varying vec2 vP;
    uniform float uTime; uniform vec3 uSun,uCam,uTint,uShade;
    float h21(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
    float vn(vec2 p){ vec2 i=floor(p),f=fract(p); f=f*f*(3.-2.*f);
      float a=h21(i),b=h21(i+vec2(1,0)),c=h21(i+vec2(0,1)),d=h21(i+vec2(1,1));
      return mix(mix(a,b,f.x),mix(c,d,f.x),f.y); }
    float fbm(vec2 p){ float v=0.,a=.5; for(int i=0;i<5;i++){ v+=a*vn(p); p*=2.07; a*=.5;} return v; }
    void main(){
      vec2 p=vP*0.00030+vec2(uTime*0.0018,uTime*0.0011);
      float n=fbm(p);
      float m=fbm(p*2.7+11.3);
      float f=n*0.72+m*0.28;
      float cov=smoothstep(0.46,0.60,f);
      cov*=smoothstep(0.0,0.30,1.0-abs(vW.y-1300.0)/1300.0);
      float d=length(vW.xz-uCam.xz);
      cov*=smoothstep(30000.,7000.,d);
      if(cov<0.004) discard;
      // shade by how much of the cloud body is between the sun and this point
      float thick=smoothstep(0.46,0.72,f);
      vec3 col=mix(uTint,uShade,thick*0.85);
      float rim=pow(clamp(dot(normalize(vW-uCam),uSun),0.0,1.0),6.0);
      col+=vec3(1.0,0.85,0.6)*rim*1.6;
      gl_FragColor=vec4(col,cov*0.95);
    }`
});
const clouds=new THREE.Mesh(new THREE.PlaneGeometry(34000,34000,1,1),cloudMat);
clouds.rotation.x=-Math.PI/2; clouds.position.y=1300; clouds.renderOrder=-5;
scene.add(clouds);

/* ───────────────────── water surface (GPU) ───────────────────── */
const detailTex=makeWaterDetail(256);
const waveA=wave.A, waveB=wave.B;

const WAVE_COMMON=`
uniform float uTime;
uniform vec4 uWaveA[${CFG.NW}];
uniform vec4 uWaveB[${CFG.NW}];
uniform vec4 uRogueA;   // dirx, dirz, amp, k
uniform vec4 uRogueB;   // cx, cz, sigma, on
vec3 rogueAt(vec2 p){
  if(uRogueB.w<0.5) return vec3(0.0);
  vec2 d=uRogueA.xy; vec2 r=p-uRogueB.xy; vec2 pr=vec2(-d.y,d.x);
  float a=dot(r,d); float c=dot(r,pr);
  float s2=2.0*uRogueB.z*uRogueB.z;
  float g=exp(-(a*a+c*c)/s2);
  float ck=cos(uRogueA.w*a), sk=sin(uRogueA.w*a);
  float h=uRogueA.z*g*ck;
  float ga=-a/(uRogueB.z*uRogueB.z)*g;
  float gc=-c/(uRogueB.z*uRogueB.z)*g;
  vec2 grad=(uRogueA.z*(ga*ck-uRogueA.w*g*sk))*d+(uRogueA.z*gc*ck)*pr;
  return vec3(h,grad);
}`;

const waterUniforms={
  uTime:{value:0},
  uWaveA:{value:waveA},
  uWaveB:{value:waveB},
  uRogueA:{value:new THREE.Vector4(0,1,0,0.16)},
  uRogueB:{value:new THREE.Vector4(0,0,48,0)},
  uNoise:{value:detailTex},
  uRefl:{value:null},
  uReflMatrix:{value:new THREE.Matrix4()},
  uSunDir:{value:sunDir.clone()},
  uSunColor:{value:new THREE.Color(0xffd2a0)},
  uDeep:{value:new THREE.Color(0.010,0.062,0.082)},
  uHorizon:{value:new THREE.Color(0.44,0.57,0.70)},
  uFog:{value:0.00048},
  uReflStrength:{value:1.0},
  uReflDistort:{value:0.012}
};

const waterMat=new THREE.ShaderMaterial({
  uniforms:waterUniforms,
  vertexShader:`
    ${WAVE_COMMON}
    varying vec3 vWorld; varying vec2 vQ; varying float vDist; varying float vHeight;
    void main(){
      vec4 wp=modelMatrix*vec4(position,1.0);
      vec2 q=wp.xz;
      vec3 d=vec3(0.0);
      for(int i=0;i<${CFG.NW};i++){
        vec4 A=uWaveA[i]; vec4 B=uWaveB[i];
        float ph=A.z*dot(A.xy,q)-B.x*uTime+B.y;
        float s=sin(ph), c=cos(ph);
        d.xz+=A.xy*(B.z*A.w*c);
        d.y+=A.w*s;
      }
      d.y+=rogueAt(q).x;
      float dist=length(wp.xyz-cameraPosition);
      float fade=1.0-smoothstep(280.0,1150.0,dist);
      d*=fade;
      wp.xyz+=d;
      vQ=q; vWorld=wp.xyz; vDist=dist; vHeight=d.y;
      gl_Position=projectionMatrix*viewMatrix*wp;
    }`,
  fragmentShader:`
    precision highp float;
    ${WAVE_COMMON}
    uniform sampler2D uNoise; uniform sampler2D uRefl; uniform mat4 uReflMatrix;
    uniform vec3 uSunDir,uSunColor,uDeep,uHorizon;
    uniform float uFog,uReflStrength,uReflDistort;
    varying vec3 vWorld; varying vec2 vQ; varying float vDist; varying float vHeight;
    void main(){
      vec3 V=normalize(cameraPosition-vWorld);
      vec2 slope=vec2(0.0); float h=0.0,lxx=0.0,lzz=0.0,lxz=0.0;
      for(int i=0;i<${CFG.NW};i++){
        vec4 A=uWaveA[i]; vec4 B=uWaveB[i];
        float ph=A.z*dot(A.xy,vQ)-B.x*uTime+B.y;
        float s=sin(ph), c=cos(ph);
        float ak=A.w*A.z;
        h+=A.w*s; slope+=ak*A.xy*c;
        lxx-=ak*A.z*A.x*A.x*s; lzz-=ak*A.z*A.y*A.y*s; lxz-=ak*A.z*A.x*A.y*s;
      }
      vec3 rg=rogueAt(vQ);
      h+=rg.x; slope+=rg.yz;
      float J=(1.0+lxx)*(1.0+lzz)-lxz*lxz;

      // micro ripple detail (fades with distance to avoid aliasing)
      float dfade=1.0-smoothstep(60.0,1500.0,vDist);
      vec3 n1=texture2D(uNoise,vWorld.xz*0.44+vec2(uTime*0.020,uTime*0.012)).xyz;
      vec3 n2=texture2D(uNoise,vWorld.xz*0.135-vec2(uTime*0.010,uTime*0.016)).xyz;
      vec3 n3=texture2D(uNoise,vWorld.xz*0.033+vec2(uTime*0.004,-uTime*0.006)).xyz;
      vec3 n4=texture2D(uNoise,vWorld.xz*1.15+vec2(uTime*0.031,-uTime*0.022)).xyz;
      vec2 det=(n1.xy*0.95+n2.xy*1.10+n3.xy*0.80)*dfade;
      det+=n4.xy*0.42*clamp(1.0-vDist/28.0,0.0,1.0);

      vec3 N=normalize(vec3(-(slope.x+det.x),1.0,-(slope.y+det.y)));
      float fres=0.02+0.98*pow(1.0-clamp(dot(N,V),0.0,1.0),5.0);

      // planar reflection
      vec3 refl;
      if(uReflStrength>0.01){
        vec4 ruv=uReflMatrix*vec4(vWorld,1.0);
        float k=clamp(40.0/max(vDist,10.0),0.03,1.0);
        ruv.xy+=N.xz*uReflDistort*k*ruv.w;
        refl=texture2DProj(uRefl,ruv).rgb;
        refl=mix(uHorizon,refl,uReflStrength);
      } else { refl=uHorizon; }

      vec3 col=mix(uDeep,refl,fres);

      // sun glitter (GGX)
      vec3 L=uSunDir; vec3 H=normalize(L+V);
      float NoH=max(dot(N,H),0.0);
      float a=0.07+0.06*(1.0-dfade);
      float a2=a*a; float dd=NoH*NoH*(a2-1.0)+1.0;
      float D=a2/(3.14159*dd*dd);
      float spec=D*0.25*max(dot(N,L),0.0)*(0.035+0.965*fres);
      col+=uSunColor*spec*1.25;

      // sub-surface scattering on the back of crests
      float back=pow(max(dot(V,-L),0.0),3.5);
      float crest=clamp(h*1.4,0.0,1.0);
      col+=vec3(0.035,0.30,0.26)*back*crest*0.85;

      // whitecaps from crest folding
      float foam=smoothstep(0.58,0.06,J)*smoothstep(0.22,0.78,h);
      vec4 fn=texture2D(uNoise,vWorld.xz*0.62+vec2(uTime*0.004,uTime*0.003));
      float ftex=fn.a;
      foam*=smoothstep(0.40,0.80,ftex);
      // rogue wave churn
      float rgm=length(rg.yz);
      foam+=smoothstep(0.05,0.55,rgm)*0.75*smoothstep(-0.35,0.75,rg.x);
      foam=clamp(foam,0.0,1.0);
      col=mix(col,vec3(0.86,0.91,0.94),foam*0.66);

      // distance haze toward the sun
      float sunAmt=pow(max(dot(normalize(vec3(vWorld.x,0.0,vWorld.z)-vec3(cameraPosition.x,0.0,cameraPosition.z)),normalize(vec3(L.x,0.0,L.z))),0.0),5.0);
      vec3 haze=mix(uHorizon,uSunColor*0.85,sunAmt*0.55);
      float fogF=1.0-exp(-pow(vDist*uFog,2.0));
      col=mix(col,haze,fogF);
      gl_FragColor=vec4(col,1.0);
    }`
});

/* power-law grid: dense near the boat, reaching to the horizon */
function buildGrid(half,N,pow){
  const verts=(N+1)*(N+1);
  const pos=new Float32Array(verts*3);
  let k=0;
  for(let j=0;j<=N;j++){
    const t=(j/N)*2-1;
    const z=Math.sign(t)*Math.pow(Math.abs(t),pow)*half;
    for(let i=0;i<=N;i++){
      const s=(i/N)*2-1;
      const x=Math.sign(s)*Math.pow(Math.abs(s),pow)*half;
      pos[k++]=x; pos[k++]=0; pos[k++]=z;
    }
  }
  const idx=new Uint32Array(N*N*6);
  let m=0;
  for(let j=0;j<N;j++)for(let i=0;i<N;i++){
    const a=j*(N+1)+i,b=a+1,c=a+(N+1),d=c+1;
    idx[m++]=a;idx[m++]=c;idx[m++]=b; idx[m++]=b;idx[m++]=c;idx[m++]=d;
  }
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.BufferAttribute(pos,3));
  g.setIndex(new THREE.BufferAttribute(idx,1));
  g.boundingSphere=new THREE.Sphere(new THREE.Vector3(),half*1.5);
  return g;
}
const waterGeo=buildGrid(CFG.gridHalf,CFG.gridN,CFG.gridPow);
const water=new THREE.Mesh(waterGeo,waterMat);
water.frustumCulled=false;
water.renderOrder=1;
scene.add(water);

/* ───────────────────── planar reflection ───────────────────── */
let reflectionRT=null,virtualCam=null,reflMatrix=new THREE.Matrix4();
if(reflOn){
  const s=Math.min(1024,Math.max(512,Math.floor(innerWidth/2)));
  reflectionRT=new THREE.WebGLRenderTarget(s,Math.floor(s*innerHeight/innerWidth),{
    type:THREE.HalfFloatType,minFilter:THREE.LinearFilter,magFilter:THREE.LinearFilter,
    depthBuffer:true,stencilBuffer:false,samples:0});
  waterUniforms.uRefl.value=reflectionRT.texture;
  virtualCam=new THREE.PerspectiveCamera();
}
const _rp=new THREE.Vector3(),_cp=new THREE.Vector3(),_rot=new THREE.Matrix4(),
      _look=new THREE.Vector3(),_view=new THREE.Vector3(),_tgt=new THREE.Vector3(),
      _clipP=new THREE.Vector4(),_clipV=new THREE.Vector4(),_q=new THREE.Vector4();
const CLIP_BIAS=0.004;
function renderReflection(){
  if(!reflectionRT) return;
  _rp.set(0,0,0); _cp.setFromMatrixPosition(camera.matrixWorld);
  _rot.extractRotation(camera.matrixWorld);
  _view.subVectors(_rp,_cp).reflect(new THREE.Vector3(0,1,0)).negate().add(_rp);
  _look.set(0,0,-1).applyMatrix4(_rot).add(_cp);
  _tgt.subVectors(_rp,_look).reflect(new THREE.Vector3(0,1,0)).negate().add(_rp);
  virtualCam.position.copy(_view);
  virtualCam.up.set(0,1,0).applyMatrix4(_rot).reflect(new THREE.Vector3(0,1,0));
  virtualCam.lookAt(_tgt);
  virtualCam.far=camera.far; virtualCam.near=camera.near;
  virtualCam.updateMatrixWorld();
  virtualCam.projectionMatrix.copy(camera.projectionMatrix);
  reflMatrix.set(0.5,0,0,0.5, 0,0.5,0,0.5, 0,0,0.5,0.5, 0,0,0,1);
  reflMatrix.multiply(virtualCam.projectionMatrix);
  reflMatrix.multiply(virtualCam.matrixWorldInverse);
  waterUniforms.uReflMatrix.value.copy(reflMatrix);
  // oblique near-plane clip so nothing under the surface leaks in
  _clipP.set(0,1,0,0).applyMatrix4(virtualCam.matrixWorldInverse);
  _clipV.set(_clipP.x,_clipP.y,_clipP.z,_clipP.w);
  const pm=virtualCam.projectionMatrix;
  _q.x=(Math.sign(_clipV.x)+pm.elements[8])/pm.elements[0];
  _q.y=(Math.sign(_clipV.y)+pm.elements[9])/pm.elements[5];
  _q.z=-1.0; _q.w=(1.0+pm.elements[10])/pm.elements[14];
  _clipV.multiplyScalar(2.0/_clipV.dot(_q));
  pm.elements[2]=_clipV.x; pm.elements[6]=_clipV.y;
  pm.elements[10]=_clipV.z+1.0-CLIP_BIAS; pm.elements[14]=_clipV.w;
  virtualCam.projectionMatrixInverse.copy(pm).invert();

  water.visible=false;
  const oldTarget=renderer.getRenderTarget();
  renderer.setRenderTarget(reflectionRT);
  renderer.clear();
  renderer.render(scene,virtualCam);
  renderer.setRenderTarget(oldTarget);
  water.visible=true;
}

/* ───────────────────────── vessel geometry ───────────────────────── */
function buildHull(o){
  const M=o.sections*2, ST=o.stations;
  const halfB=o.beam*0.5;
  const bs=t=>t<0.34?Math.pow(t/0.34,0.52):1-0.20*Math.pow((t-0.34)/0.66,2);
  const ks=t=>t<0.22?(0.18+0.82*Math.pow(t/0.22,0.75)):(t<0.72?1:1-0.44*((t-0.72)/0.28));
  const ss=t=>0.82+0.55*Math.pow(1-t,1.7)-0.06*t;
  const pos=[],col=[],idx=[];
  const cHull=new THREE.Color(o.hull), cBoot=new THREE.Color(o.boot), cStripe=new THREE.Color(o.stripe);
  const cTransom=new THREE.Color(o.hull).multiplyScalar(0.55);
  for(let i=0;i<ST;i++){
    const t=i/(ST-1), hb=halfB*bs(t), ky=-o.draft*ks(t), sy=o.sheer*ss(t);
    for(let j=0;j<=M;j++){
      const p=j/M; let s,x;
      if(p<=0.5){s=1-2*p;x=-hb*Math.pow(s,1.3);}else{s=2*p-1;x=hb*Math.pow(s,1.3);}
      const y=ky+(sy-ky)*s;
      pos.push(x,y,(0.5-t)*o.length);
      let c;
      if(y<-0.03) c=cBoot;
      else if(y>sy-0.14) c=cStripe;
      else c=cHull;
      col.push(c.r,c.g,c.b);
    }
  }
  for(let i=0;i<ST-1;i++)for(let j=0;j<M;j++){
    const a=i*(M+1)+j,b=a+1,c=a+(M+1),d=c+1;
    idx.push(a,c,b,b,c,d);
  }
  // transom — its own vertices so the normals stay crisp
  {
    const t=1, hb=halfB*bs(t), ky=-o.draft*ks(t), sy=o.sheer*ss(t);
    const ring=[];
    for(let j=0;j<=M;j++){
      const p=j/M; let s,x;
      if(p<=0.5){s=1-2*p;x=-hb*Math.pow(s,1.3);}else{s=2*p-1;x=hb*Math.pow(s,1.3);}
      ring.push([x,ky+(sy-ky)*s]);
    }
    let cx=0,cy=0;
    for(const r of ring){cx+=r[0];cy+=r[1];}
    cx/=ring.length;cy/=ring.length;
    const zb=-o.length*0.5;
    const base=pos.length/3;
    pos.push(cx,cy,zb); col.push(cHull.r,cHull.g,cHull.b);
    for(const r of ring){
      pos.push(r[0],r[1],zb);
      const c=r[1]<-0.03?cBoot:cTransom;
      col.push(c.r,c.g,c.b);
    }
    for(let j=0;j<M;j++){ idx.push(base,base+2+j,base+1+j); }
    idx.push(base,base+1+M,base+1);
  }
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
  g.setAttribute('color',new THREE.Float32BufferAttribute(col,3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return g;
}
function buildDeck(o){
  const ST=o.stations, D=7, halfB=o.beam*0.5;
  const bs=t=>t<0.34?Math.pow(t/0.34,0.52):1-0.20*Math.pow((t-0.34)/0.66,2);
  const ss=t=>0.82+0.55*Math.pow(1-t,1.7)-0.06*t;
  const pos=[],uv=[],idx=[];
  for(let i=0;i<ST;i++){
    const t=i/(ST-1), hb=halfB*bs(t), sy=o.sheer*ss(t);
    for(let j=0;j<D;j++){
      const u=-1+2*j/(D-1);
      pos.push(hb*u, sy+0.07*(1-u*u), (0.5-t)*o.length);
      uv.push((u+1)*0.5, t*3);
    }
  }
  for(let i=0;i<ST-1;i++)for(let j=0;j<D-1;j++){
    const a=i*D+j,b=a+1,c=a+D,d=c+1;
    idx.push(a,b,c,b,d,c);
  }
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
  g.setAttribute('uv',new THREE.Float32BufferAttribute(uv,2));
  g.setIndex(idx); g.computeVertexNormals();
  return g;
}
function plankTexture(){
  const w=256,h=256,c=document.createElement('canvas');c.width=w;c.height=h;
  const g=c.getContext('2d');
  g.fillStyle='#6b4c30';g.fillRect(0,0,w,h);
  for(let i=0;i<8;i++){
    const y=i*(h/8);
    const v=0.82+Math.random()*0.36;
    g.fillStyle='rgb('+Math.round(107*v)+','+Math.round(76*v)+','+Math.round(48*v)+')';
    g.fillRect(0,y+1,w,h/8-2);
    g.strokeStyle='rgba(0,0,0,.34)';g.lineWidth=2;g.strokeRect(0,y,w,h/8);
    for(let k=0;k<10;k++){
      g.strokeStyle='rgba(0,0,0,.10)';g.lineWidth=1;
      g.beginPath();g.moveTo(Math.random()*w,y+2);g.lineTo(Math.random()*w,y+h/8-2);g.stroke();
    }
  }
  const t=new THREE.CanvasTexture(c);
  t.wrapS=t.wrapT=THREE.RepeatWrapping;t.colorSpace=THREE.SRGBColorSpace;
  t.anisotropy=4;
  return t;
}
const woodTex=plankTexture();

function makeRig(o){
  const g=new THREE.Group();
  const hullMat=new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.62,metalness:0.08});
  const deckMat=new THREE.MeshStandardMaterial({map:woodTex,roughness:0.86,metalness:0.02});
  const hull=new THREE.Mesh(buildHull(o),hullMat);
  const deck=new THREE.Mesh(buildDeck(o),deckMat);
  g.add(hull,deck);
  return g;
}

/* player sloop */
function buildPlayerBoat(){
  const o={length:10.4,beam:2.35,draft:0.95,sheer:1.38,hull:0x44525e,boot:0x6d2626,stripe:0xd8b25c,stations:22,sections:6};
  const g=makeRig(o);
  const metal=new THREE.MeshStandardMaterial({color:0x3a3f45,roughness:0.5,metalness:0.65});
  const dark=new THREE.MeshStandardMaterial({color:0x1a1d21,roughness:0.7,metalness:0.3});
  // cabin
  const cabin=new THREE.Mesh(new THREE.BoxGeometry(1.85,0.98,2.5),new THREE.MeshStandardMaterial({color:0x7b5a3c,roughness:0.78}));
  cabin.position.set(0,1.9,1.15); g.add(cabin);
  const roof=new THREE.Mesh(new THREE.BoxGeometry(2.05,0.1,2.7),dark); roof.position.set(0,2.42,1.15); g.add(roof);
  const window_=new THREE.Mesh(new THREE.BoxGeometry(1.9,0.26,0.06),new THREE.MeshStandardMaterial({color:0x14202a,roughness:0.25,metalness:0.5}));
  window_.position.set(0,2.02,2.42); g.add(window_);
  // mast + boom + furled sail
  const mast=new THREE.Mesh(new THREE.CylinderGeometry(0.075,0.1,8.2,10),new THREE.MeshStandardMaterial({color:0x6d5334,roughness:0.85}));
  mast.position.set(0,5.3,-0.5); g.add(mast);
  const boom=new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.06,3.4,8),new THREE.MeshStandardMaterial({color:0x6d5334,roughness:0.85}));
  boom.rotation.x=Math.PI/2; boom.position.set(0,2.35,-0.5); g.add(boom);
  const sail=new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.17,3.2,10),new THREE.MeshStandardMaterial({color:0xcfc2a4,roughness:0.95}));
  sail.rotation.x=Math.PI/2; sail.position.set(0,2.55,-0.5); g.add(sail);
  // cockpit well + helm
  const well=new THREE.Mesh(new THREE.BoxGeometry(1.5,0.42,1.9),new THREE.MeshStandardMaterial({color:0x3a2b1e,roughness:0.9}));
  well.position.set(0,1.62,-2.6); g.add(well);
  const wheel=new THREE.Mesh(new THREE.TorusGeometry(0.34,0.045,6,18),new THREE.MeshStandardMaterial({color:0x6b4a2c,roughness:0.85}));
  wheel.position.set(0,2.05,-1.2); wheel.rotation.x=0.5; g.add(wheel);
  for(let i=0;i<6;i++){
    const sp=new THREE.Mesh(new THREE.CylinderGeometry(0.022,0.022,0.66,5),new THREE.MeshStandardMaterial({color:0x6b4a2c,roughness:0.85}));
    sp.position.set(0,2.05,-1.2); sp.rotation.set(0.5,i*Math.PI/3,0,'YXZ'); g.add(sp);
  }
  // bowsprit
  const sprit=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.075,2.3,8),new THREE.MeshStandardMaterial({color:0x6d5334,roughness:0.85}));
  sprit.rotation.x=Math.PI/2; sprit.position.set(0,1.28,5.6); g.add(sprit);
  // standing rigging
  const ropeMat=new THREE.MeshStandardMaterial({color:0x2b2b2b,roughness:0.95});
  function stay(a,b,r=0.018){
    const A=new THREE.Vector3(...a),B=new THREE.Vector3(...b);
    const len=A.distanceTo(B),mid=A.clone().add(B).multiplyScalar(0.5);
    const m=new THREE.Mesh(new THREE.CylinderGeometry(r,r,len,5),ropeMat);
    m.position.copy(mid);
    m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),B.clone().sub(A).normalize());
    g.add(m);
  }
  stay([0,8.5,-0.5],[0,1.3,6.6],0.022);          // forestay to bowsprit
  stay([0,8.5,-0.5],[0,1.5,-4.6]);               // backstay
  stay([0,8.3,-0.5],[-1.0,1.45,1.4],0.015);      // shrouds
  stay([0,8.3,-0.5],[1.0,1.45,1.4],0.015);
  // bowsprit flag
  const jack=new THREE.Mesh(new THREE.PlaneGeometry(0.8,0.5),new THREE.MeshStandardMaterial({color:0xb8342f,roughness:0.9,side:THREE.DoubleSide}));
  jack.position.set(0.4,1.5,6.4); jack.rotation.y=0.2; g.add(jack);
  // rail stanchions
  const stG=new THREE.CylinderGeometry(0.035,0.035,0.6,6);
  for(let i=1;i<o.stations-1;i+=1){
    const t=i/(o.stations-1);
    const hb=o.beam*0.5*(t<0.34?Math.pow(t/0.34,0.52):1-0.20*Math.pow((t-0.34)/0.66,2));
    const sy=o.sheer*(0.82+0.55*Math.pow(1-t,1.7)-0.06*t);
    for(const s of [-1,1]){
      const m=new THREE.Mesh(stG,metal); m.position.set(s*hb*0.97,sy+0.28,(0.5-t)*o.length); g.add(m);
    }
  }
  // bow gun on a swivel carriage
  const pivot=new THREE.Group(); pivot.position.set(0,1.78,2.75); g.add(pivot);
  const carriage=new THREE.Mesh(new THREE.BoxGeometry(0.85,0.34,1.15),dark); carriage.position.y=-0.16; pivot.add(carriage);
  const elev=new THREE.Group(); elev.position.set(0,0.05,0); pivot.add(elev);
  const barrel=new THREE.Mesh(new THREE.CylinderGeometry(0.115,0.15,2.0,14),metal);
  barrel.rotation.x=Math.PI/2; barrel.position.set(0,0,0.75); elev.add(barrel);
  const band=new THREE.Mesh(new THREE.TorusGeometry(0.145,0.028,6,14),metal);
  band.position.set(0,0,1.45); elev.add(band);
  const muzzle=new THREE.Object3D(); muzzle.position.set(0,0,1.78); elev.add(muzzle);
  // flag
  const flagGeo=new THREE.PlaneGeometry(1.25,0.75,10,4);
  const flagMat=new THREE.MeshStandardMaterial({color:0xb8342f,roughness:0.9,side:THREE.DoubleSide});
  const flag=new THREE.Mesh(flagGeo,flagMat); flag.position.set(0.62,7.9,-0.5); g.add(flag);
  return {group:g,pivot,elev,muzzle,flag,flagBase:flagGeo.attributes.position.array.slice()};
}
/* hostile gunboat */
function buildEnemyBoat(){
  const o={length:6.4,beam:2.05,draft:0.62,sheer:0.6,hull:0x1c1a1c,boot:0x3b1010,stripe:0x7d1f1c,stations:18,sections:5};
  const g=makeRig(o);
  const metal=new THREE.MeshStandardMaterial({color:0x24201f,roughness:0.55,metalness:0.6});
  const deckHouse=new THREE.Mesh(new THREE.BoxGeometry(1.25,0.6,1.5),metal);
  deckHouse.position.set(0,0.9,0.9); g.add(deckHouse);
  const mast=new THREE.Mesh(new THREE.CylinderGeometry(0.055,0.075,4.6,8),metal);
  mast.position.set(0,2.9,0.1); g.add(mast);
  const sail=new THREE.Mesh(new THREE.PlaneGeometry(1.7,2.2,6,6),new THREE.MeshStandardMaterial({color:0x2a2422,roughness:1,side:THREE.DoubleSide}));
  sail.position.set(0,2.4,0.15); g.add(sail);
  const pivot=new THREE.Group(); pivot.position.set(0,0.82,1.75); g.add(pivot);
  const elev=new THREE.Group(); elev.position.set(0,0,0); pivot.add(elev);
  const barrel=new THREE.Mesh(new THREE.CylinderGeometry(0.085,0.11,1.45,10),metal);
  barrel.rotation.x=Math.PI/2; barrel.position.set(0,0,0.6); elev.add(barrel);
  const muzzle=new THREE.Object3D(); muzzle.position.set(0,0,1.4); elev.add(muzzle);
  return {group:g,pivot,elev,muzzle,sail};
}

/* ─────────────────────────── particles ─────────────────────────── */
function makeParticleSystem(max,blend){
  const geo=new THREE.BufferGeometry();
  const pos=new Float32Array(max*3),col=new Float32Array(max*3),siz=new Float32Array(max),alp=new Float32Array(max);
  geo.setAttribute('position',new THREE.BufferAttribute(pos,3));
  geo.setAttribute('aColor',new THREE.BufferAttribute(col,3));
  geo.setAttribute('aSize',new THREE.BufferAttribute(siz,1));
  geo.setAttribute('aAlpha',new THREE.BufferAttribute(alp,1));
  geo.boundingSphere=new THREE.Sphere(new THREE.Vector3(),1e6);
  const mat=new THREE.ShaderMaterial({
    transparent:true,depthWrite:false,blending:blend,
    uniforms:{uMap:{value:makeSpriteTexture(blend===THREE.AdditiveBlending?'soft':'hard')}},
    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.);
        gl_PointSize=clamp(aSize*(105.0/max(1.0,-mv.z)),1.0,180.0);gl_Position=projectionMatrix*mv;}`,
    fragmentShader:`uniform sampler2D uMap;varying float vA;varying vec3 vC;
      void main(){vec2 d=gl_PointCoord-0.5;float r=length(d);
        if(r>0.5||vA<=0.001)discard;
        float a=smoothstep(0.5,0.05,r)*vA;
        gl_FragColor=vec4(vC,a);}`
  });
  const pts=new THREE.Points(geo,mat); pts.frustumCulled=false; scene.add(pts);
  return {
    max,pos,col,siz,alp,geo,
    vel:new Float32Array(max*3),life:new Float32Array(max),ml:new Float32Array(max),
    drag:new Float32Array(max),grav:new Float32Array(max),cursor:0,
    spawn(x,y,z,vx,vy,vz,size,life,color,drag=1.2,grav=-9.8){
      const i=this.cursor;this.cursor=(this.cursor+1)%this.max;
      const k=i*3;
      this.pos[k]=x;this.pos[k+1]=y;this.pos[k+2]=z;
      this.vel[k]=vx;this.vel[k+1]=vy;this.vel[k+2]=vz;
      this.col[k]=color.r;this.col[k+1]=color.g;this.col[k+2]=color.b;
      this.siz[i]=size;this.life[i]=life;this.ml[i]=life;this.alp[i]=1;
      this.drag[i]=drag;this.grav[i]=grav;
    },
    update(dt){
      const p=this.pos,v=this.vel,l=this.life;
      for(let i=0;i<this.max;i++){
        if(l[i]<=0){ if(this.alp[i]!==0){this.alp[i]=0;} continue; }
        l[i]-=dt;
        const k=i*3;
        v[k+1]+=this.grav[i]*dt;
        const d=Math.exp(-this.drag[i]*dt);
        v[k]*=d;v[k+1]*=d;v[k+2]*=d;
        p[k]+=v[k]*dt;p[k+1]+=v[k+1]*dt;p[k+2]+=v[k+2]*dt;
        if(l[i]<=0) this.alp[i]=0;
        else this.alp[i]=Math.min(1,(l[i]/this.ml[i])*1.6);
      }
      this.geo.attributes.position.needsUpdate=true;
      this.geo.attributes.aColor.needsUpdate=true;
      this.geo.attributes.aSize.needsUpdate=true;
      this.geo.attributes.aAlpha.needsUpdate=true;
    }
  };
}
const spray=makeParticleSystem(2600,THREE.NormalBlending);
const sparks=makeParticleSystem(1200,THREE.AdditiveBlending);
const cWhite=new THREE.Color(0.92,0.96,0.98);
const cFoam=new THREE.Color(0.85,0.93,0.95);
const cFire=new THREE.Color(1.0,0.55,0.15);
const cSmoke=new THREE.Color(0.22,0.24,0.26);

/* ripples */
const ripplePool=[];
for(let i=0;i<26;i++){
  const m=new THREE.Mesh(new THREE.RingGeometry(0.72,1,40),
    new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:0,depthWrite:false,side:THREE.DoubleSide}));
  m.rotation.x=-Math.PI/2; m.visible=false; m.renderOrder=3; scene.add(m);
  ripplePool.push({m,age:0,life:0,size:1,x:0,z:0,active:false});
}
function spawnRipple(x,z,size,life=1.6){
  for(const r of ripplePool){
    if(!r.active){ r.active=true;r.age=0;r.life=life;r.size=size;r.x=x;r.z=z;r.m.visible=true;return r; }
  }
  return null;
}
function updateRipples(dt){
  for(const r of ripplePool){
    if(!r.active) continue;
    r.age+=dt;
    if(r.age>=r.life){ r.active=false;r.m.visible=false;continue; }
    const f=r.age/r.life;
    wave.heightAt(r.x,r.z,time,WS);
    r.m.position.set(r.x,WS.y+0.06,r.z);
    const s=r.size*(0.25+f*3.4);
    r.m.scale.set(s,s,s);
    r.m.material.opacity=(1-f)*(1-f)*0.6;
  }
}

/* wake ribbon */
class Wake{
  constructor(max=70){
    this.max=max;this.pts=[];
    const g=new THREE.BufferGeometry();
    this.vpos=new Float32Array(max*2*3);
    this.vcol=new Float32Array(max*2*4);
    g.setAttribute('position',new THREE.BufferAttribute(this.vpos,3));
    g.setAttribute('color',new THREE.BufferAttribute(this.vcol,4));
    const idx=[];
    for(let i=0;i<max-1;i++){ const a=i*2,b=a+1,c=a+2,d=a+3; idx.push(a,b,c,b,d,c); }
    g.setIndex(idx); g.boundingSphere=new THREE.Sphere(new THREE.Vector3(),1e6);
    this.geo=g;
    const tex=makeFoamTex(128);
    this.mesh=new THREE.Mesh(g,new THREE.MeshBasicMaterial({map:tex,vertexColors:true,transparent:true,depthWrite:false,side:THREE.DoubleSide,opacity:1.0}));
    this.mesh.frustumCulled=false; this.mesh.renderOrder=2; scene.add(this.mesh);
  }
  reset(){ this.pts.length=0; }
  push(x,z,rightX,rightZ){
    const last=this.pts[this.pts.length-1];
    if(last&&(last.x-x)**2+(last.z-z)**2<1.6) return;
    this.pts.push({x,z,rx:rightX,rz:rightZ,t:time});
    if(this.pts.length>this.max) this.pts.shift();
  }
  update(dt){
    const p=this.pts;
    for(let i=0;i<this.max;i++){
      const src=p[Math.min(i,p.length-1)];
      const k=i*6,c=i*8;
      if(!src){ this.vpos[k]=this.vpos[k+3]=0;this.vpos[k+1]=this.vpos[k+4]=-999;this.vpos[k+2]=this.vpos[k+5]=0;
        this.vcol[c+3]=this.vcol[c+7]=0; continue; }
      const age=time-src.t;
      const w=0.55+age*0.52;
      const fade=clamp(1-age/8,0,1);
      const lx=src.x-src.rx*w, lz=src.z-src.rz*w;
      const rx2=src.x+src.rx*w, rz2=src.z+src.rz*w;
      let yl,yr;
      if(i<p.length){
        wave.heightAt(lx,lz,time,WS); yl=WS.y+0.05;
        wave.heightAt(rx2,rz2,time,WS); yr=WS.y+0.05;
      } else { yl=this.vpos[k-3]; yr=this.vpos[k]; }
      this.vpos[k]=lx; this.vpos[k+1]=yl; this.vpos[k+2]=lz;
      this.vpos[k+3]=rx2; this.vpos[k+4]=yr; this.vpos[k+5]=rz2;
      for(let j=0;j<2;j++){
        this.vcol[c+j*4]=1;this.vcol[c+j*4+1]=1;this.vcol[c+j*4+2]=1;
        this.vcol[c+j*4+3]=fade*(j?0.16:0.46);
      }
    }
    this.geo.attributes.position.needsUpdate=true;
    this.geo.attributes.color.needsUpdate=true;
  }
}
function makeFoamTex(size){
  const h=new Float32Array(size*size);
  for(let y=0;y<size;y++)for(let x=0;x<size;x++){
    let v=0,a=1,tot=0;
    for(const o of [{p:4,a:1},{p:8,a:.5},{p:16,a:.25}]){ v+=o.a*vnoise(x/size*o.p,y/size*o.p,o.p,31); tot+=o.a; }
    h[y*size+x]=v/tot;
  }
  const data=new Uint8Array(size*size*4);
  for(let i=0;i<size*size;i++){
    const v=clamp(Math.round(h[i]*255),0,255);
    data[i*4]=v;data[i*4+1]=v;data[i*4+2]=v;data[i*4+3]=255;
  }
  const t=new THREE.DataTexture(data,size,size,THREE.RGBAFormat);
  t.wrapS=t.wrapT=THREE.RepeatWrapping;t.needsUpdate=true;return t;
}

/* ─────────────────────────── vessels ─────────────────────────── */
function mkWS(){return {y:0,dx:0,dz:0,dydt:0,J:1,qx:0,qz:0};}
const WS_C=mkWS(),WS_B=mkWS(),WS_S=mkWS(),WS_R=mkWS(),WS_L=mkWS();

class Vessel{
  constructor(o){
    this.len=o.len;this.beam=o.beam;this.draft=o.draft;
    this.group=o.group;this.rig=o.rig;
    this.pos=new THREE.Vector3();this.vel=new THREE.Vector3();
    this.yaw=0;this.yawRate=0;
    this.pitch=0;this.pitchVel=0;this.roll=0;this.rollVel=0;this.heaveVel=0;
    this.hp=o.hp;this.maxHp=o.hp;
    this.throttle=0;this.rudder=0;
    this.alive=true;this.sinking=false;this.sinkT=0;this.removed=false;
    this.engine=o.engine||16;this.turnRate=o.turnRate||2.1;
    this.hitR=o.beam*0.8;this.reload=0;this.slamCool=0;
  }
  updateSink(dt){
    this.sinkT+=dt;
    this.pos.y-=1.1*dt;
    this.roll+=0.55*dt;this.pitch+=0.16*dt;
    this.group.position.copy(this.pos);
    this.group.rotation.order='YXZ';
    this.group.rotation.set(-this.pitch,this.yaw,this.roll);
    if(this.pos.y<-8) this.removed=true;
  }
  update(dt,t){
    if(this.sinking){ this.updateSink(dt); return; }
    if(!isFinite(this.vel.x)||!isFinite(this.vel.z)||!isFinite(this.yaw)||!isFinite(this.pos.y)||!isFinite(this.yawRate)){
      this.vel.set(0,0,0);this.yaw=isFinite(this.yaw)?this.yaw:0;
      this.yawRate=0;this.pos.y=isFinite(this.pos.y)?this.pos.y:0;
    }
    const fx=Math.sin(this.yaw), fz=Math.cos(this.yaw);
    const rx=Math.cos(this.yaw), rz=-Math.sin(this.yaw);
    const L=this.len,B=this.beam;
    wave.heightAt(this.pos.x,this.pos.z,t,WS_C);
    wave.heightAt(this.pos.x+fx*L*0.40,this.pos.z+fz*L*0.40,t,WS_B);
    wave.heightAt(this.pos.x-fx*L*0.40,this.pos.z-fz*L*0.40,t,WS_S);
    wave.heightAt(this.pos.x+rx*B*0.45,this.pos.z+rz*B*0.45,t,WS_R);
    wave.heightAt(this.pos.x-rx*B*0.45,this.pos.z-rz*B*0.45,t,WS_L);

    const avg=(WS_B.y+WS_S.y+WS_R.y+WS_L.y)*0.25;
    // heave (buoyancy spring against the mean water level)
    const kh=27,ch=2*0.5*Math.sqrt(kh);
    this.heaveVel+=((avg-this.pos.y)*kh-this.heaveVel*ch)*dt;
    this.pos.y+=this.heaveVel*dt;
    // pitch follows the bow/stern water difference
    const pT=Math.atan2(WS_B.y-WS_S.y,L*0.8),wp=2.15,zp=0.30;
    this.pitchVel+=(wp*wp*(pT-this.pitch)-2*zp*wp*this.pitchVel)*dt;
    this.pitch+=this.pitchVel*dt;
    // roll follows the port/starboard water difference
    const rT=Math.atan2(WS_R.y-WS_L.y,B*0.9),wr=2.7,zr=0.27;
    this.rollVel+=(wr*wr*(rT-this.roll)-2*zr*wr*this.rollVel)*dt;
    this.roll+=this.rollVel*dt;

    // hydrodynamics
    const vF=this.vel.x*fx+this.vel.z*fz;
    const vL=this.vel.x*rx+this.vel.z*rz;
    let dF=-(0.040*vF*Math.abs(vF)+0.52*vF);
    let dL=-(0.60*vL*Math.abs(vL)+3.4*vL);
    const slopeF=WS_C.dx*fx+WS_C.dz*fz;
    dF+=clamp(-G*slopeF*0.18,-5,5);   // gravity down the wave face (limited)
    this.vel.x+=(fx*(this.throttle*this.engine+dF)+rx*dL)*dt;
    this.vel.z+=(fz*(this.throttle*this.engine+dF)+rz*dL)*dt;
    const flow=clamp(Math.abs(vF)/3.0,0,1)*Math.sign(vF||1);
    this.yawRate+=(this.rudder*this.turnRate*flow-this.yawRate*2.35)*dt;
    this.yaw+=this.yawRate*dt;
    this.pos.x+=this.vel.x*dt; this.pos.z+=this.vel.z*dt;
    if(!isFinite(this.pos.x)||!isFinite(this.pos.y)||!isFinite(this.pos.z)||!isFinite(this.pitch)||!isFinite(this.roll)){
      this.pos.set(0,0,0);this.vel.set(0,0,0);
      this.pitch=0;this.roll=0;this.pitchVel=0;this.rollVel=0;this.heaveVel=0;
    }

    this.group.position.copy(this.pos);
    this.group.rotation.order='YXZ';
    this.group.rotation.set(-this.pitch,this.yaw,this.roll);

    // spray: bow wave + slam on wave impact
    const speed=Math.hypot(this.vel.x,this.vel.z);
    if(speed>3.2&&Math.random()<speed*0.10){
      const bx=this.pos.x+fx*L*0.46,bz=this.pos.z+fz*L*0.46;
      spray.spawn(bx,this.pos.y+0.1,bz,-fx*speed*0.35+rand(-1.4,1.4),rand(1.4,3.4),
        -fz*speed*0.35+rand(-1.4,1.4),rand(0.4,0.95),rand(0.45,0.95),cFoam,1.7,-9.5);
    }
    this.slamCool-=dt;
    const relV=this.heaveVel-WS_C.dydt;
    if(relV<-2.0&&this.slamCool<=0&&speed>1.4){
      this.slamCool=0.25;
      const bx=this.pos.x+fx*L*0.45,bz=this.pos.z+fz*L*0.45;
      const n=Math.min(18,Math.floor(-relV*2.2));
      for(let i=0;i<n;i++){
        spray.spawn(bx+rand(-0.6,0.6),this.pos.y+0.25,bz+rand(-0.6,0.6),
          rand(-3,3)+fx*speed*0.2,rand(2.5,6.5),rand(-3,3)+fz*speed*0.2,
          rand(0.6,1.5),rand(0.6,1.3),cFoam,1.25,-9.8);
      }
      Sound.splash(clamp(-relV/3,0.3,1.2));
      spawnRipple(bx,bz,1.4,1.0);
    }
  }
  damage(amount,fromX,fromZ){
    if(!this.alive) return;
    this.hp-=amount;
    if(this.onHit) this.onHit(amount,fromX,fromZ);
    if(this.hp<=0){
      this.hp=0;this.alive=false;this.sinking=true;this.sinkT=0;
      if(this.onDeath) this.onDeath();
    }
  }
}

/* ─────────────────────────── projectiles ─────────────────────────── */
const ballGeo=new THREE.SphereGeometry(0.24,12,9);
const ballMat=new THREE.MeshStandardMaterial({color:0x141519,roughness:0.38,metalness:0.55});
const balls=[];
function spawnBall(pos,vel,owner,damage){
  const m=new THREE.Mesh(ballGeo,ballMat);
  m.position.copy(pos); scene.add(m);
  balls.push({mesh:m,pos:pos.clone(),vel:vel.clone(),owner,damage,dead:false,trail:0});
}
function removeBall(b){
  scene.remove(b.mesh);
}
function updateBalls(dt){
  for(let i=balls.length-1;i>=0;i--){
    const b=balls[i];
    b.vel.y-=9.81*dt;
    b.pos.addScaledVector(b.vel,dt);
    b.mesh.position.copy(b.pos);
    b.mesh.rotation.x+=dt*7;b.mesh.rotation.y+=dt*5;
    b.trail-=dt;
    if(b.trail<=0){
      b.trail=0.028;
      spray.spawn(b.pos.x,b.pos.y,b.pos.z,rand(-0.4,0.4),rand(0.2,0.9),rand(-0.4,0.4),
        rand(0.5,1.0),rand(0.5,1.1),cSmoke,1.5,0.6);
    }
    let dead=false;
    wave.heightAt(b.pos.x,b.pos.z,time,WS);
    if(b.pos.y<=WS.y+0.12){
      dead=true;
      const p=b.pos.clone(); p.y=WS.y;
      splash(p,clamp(1.4-Math.abs(b.vel.y)*0.05,0.4,1.6));
    }
    if(!dead){
      for(const v of vessels){
        if(v===b.owner||!v.alive) continue;
        const dx=b.pos.x-v.pos.x,dz=b.pos.z-v.pos.z;
        if(dx*dx+dz*dz<v.hitR*v.hitR){
          const ry=b.pos.y-v.pos.y;
          if(ry>-0.9&&ry<2.6){
            v.damage(b.damage,b.pos.x,b.pos.z);
            explosion(b.pos,1.0);
            dead=true;break;
          }
        }
      }
    }
    if(dead){ removeBall(b); balls.splice(i,1); }
  }
}
function splash(p,size=1){
  for(let i=0;i<Math.floor(16*size);i++){
    spray.spawn(p.x+rand(-0.5,0.5),p.y+0.1,p.z+rand(-0.5,0.5),
      rand(-3.4,3.4)*size,rand(2.5,7)*size,rand(-3.4,3.4)*size,
      rand(0.7,1.8)*size,rand(0.7,1.5),cFoam,1.0,-9.8);
  }
  spawnRipple(p.x,p.z,1.6*size,1.5);
  Sound.splash(size);
}
function explosion(p,size=1){
  for(let i=0;i<Math.floor(26*size);i++){
    sparks.spawn(p.x,p.y,p.z,rand(-6,6),rand(1,9),rand(-6,6),rand(0.6,1.7),rand(0.3,0.8),cFire,1.6,-11);
  }
  for(let i=0;i<Math.floor(18*size);i++){
    spray.spawn(p.x,p.y,p.z,rand(-3,3),rand(1,5),rand(-3,3),rand(1.6,3.4),rand(1.0,2.2),cSmoke,0.9,0.5);
  }
  Sound.boom();
}

/* ─────────────────────────── enemies ─────────────────────────── */
const enemies=[];
class Enemy extends Vessel{
  constructor(o){
    super(o);
    this.reload=rand(1.2,3.2);
    this.orbit=Math.random()<0.5?-1:1;
  }
  update(dt,t,player){
    if(this.sinking){ this.updateSink(dt); return; }
    const dx=player.pos.x-this.pos.x,dz=player.pos.z-this.pos.z;
    const dist=Math.hypot(dx,dz);
    const want=Math.atan2(dx,dz);
    let ty=want;
    if(dist<52) ty=want+this.orbit*1.05;
    else if(dist<85) ty=want+this.orbit*0.42;
    const d=angDiff(ty,this.yaw);
    this.rudder=clamp(d*1.9,-1,1);
    this.throttle=dist>100?1:(dist<48?-0.55:(dist<80?0.32:0.7));
    if(this.rig.pivot){
      const gy=Math.atan2(dx,dz);
      this.rig.pivot.rotation.y=angDiff(gy,this.yaw);
    }
    super.update(dt,t);
    this.reload-=dt;
    if(this.reload<=0&&dist<170&&player.alive){
      this.reload=rand(3.6,6.2);
      this.fire(player);
    }
  }
  fire(player){
    const mz=new THREE.Vector3();
    this.rig.muzzle.updateWorldMatrix(true,false);
    this.rig.muzzle.getWorldPosition(mz);
    const range=Math.hypot(player.pos.x-this.pos.x,player.pos.z-this.pos.z);
    const lead=clamp(range/58,0,2.2);
    const spread=1.6+range*0.045;
    const tx=player.pos.x+player.vel.x*lead+rand(-spread,spread);
    const tz=player.pos.z+player.vel.z*lead+rand(-spread,spread);
    const ddx=tx-mz.x,ddz=tz-mz.z;
    const d=Math.hypot(ddx,ddz);
    const ang=ballisticAngle(58,G,Math.max(d,1),0.6);
    const yaw=Math.atan2(ddx,ddz)+rand(-0.03,0.03);
    const dir=new THREE.Vector3(Math.sin(yaw)*Math.cos(ang),Math.sin(ang),Math.cos(yaw)*Math.cos(ang));
    spawnBall(mz,dir.multiplyScalar(58),this,9);
    this.vel.x-=Math.sin(yaw)*1.1;this.vel.z-=Math.cos(yaw)*1.1;
    Sound.cannon(0.55);
    for(let i=0;i<7;i++){
      spray.spawn(mz.x,mz.y,mz.z,rand(-2,2)+Math.sin(yaw)*4,rand(0.5,2.4),rand(-2,2)+Math.cos(yaw)*4,
        rand(0.7,1.5),rand(0.6,1.2),cSmoke,1.1,0.8);
    }
  }
}

/* ─────────────────────────── ballistic helper ─────────────────────────── */
function ballisticAngle(v0,g,d,dy){
  if(d<1) return 0.55;
  const v2=v0*v0;
  const disc=v2*v2-g*(g*d*d+2*dy*v2);
  if(disc<0) return 0.62;
  const root=Math.sqrt(disc);
  return Math.atan((v2-root)/(g*d));
}

/* ─────────────────────────── world state ─────────────────────────── */
let time=0,running=false,paused=false,score=0,gameOver=false,shake=0;
let vessels=[],player=null;
const wake=new Wake(48);

const pRig=buildPlayerBoat();
const playerVessel=new Vessel({len:10.4,beam:2.35,draft:0.88,group:pRig.group,rig:pRig,hp:120,engine:13.5,turnRate:2.05});
player=playerVessel;
player.onHit=(amt)=>{ hitFlash=Math.min(1,hitFlash+0.55); shake=Math.max(shake,0.55); };
vessels.push(player);
scene.add(pRig.group);
player.pos.set(0,0,0);

function spawnEnemy(){
  const rig=buildEnemyBoat();
  const e=new Enemy({len:6.4,beam:2.05,draft:0.62,group:rig.group,rig:rig,hp:52,engine:15,turnRate:1.85});
  const a=rand(0,TAU);
  const d=rand(150,215);
  e.pos.set(player.pos.x+Math.sin(a)*d,0,player.pos.z+Math.cos(a)*d);
  e.yaw=a+Math.PI;
  e.onDeath=()=>{
    score+=100;
    const p=e.pos.clone(); p.y+=0.6;
    explosion(p,1.6);
    for(let i=0;i<26;i++){
      spray.spawn(p.x+rand(-1,1),p.y,p.z+rand(-1,1),rand(-3,3),rand(1,4),rand(-3,3),rand(1,2),rand(1,2),cSmoke,1.0,0.4);
    }
    spawnRipple(e.pos.x,e.pos.z,3,2.2);
    Sound.boom();
  };
  enemies.push(e); vessels.push(e);
  scene.add(rig.group);
}
function clearEnemies(){
  for(const e of enemies){ scene.remove(e.group); }
  enemies.length=0;
  vessels=vessels.filter(v=>v!==player);
}

/* ─────────────────────────── wave events ─────────────────────────── */
const waveEvents={
  next:16,active:false,warned:false,hit:false,minD:1e9,
  reset(){ this.next=rand(14,24);this.active=false;this.warned=false;this.hit=false;this.minD=1e9;
    wave.rogue.on=0; waterUniforms.uRogueB.value.w=0;
    document.getElementById('alert').classList.remove('on'); },
  trigger(){
    const R=wave.rogue;
    const a=rand(0,TAU);
    R.dx=Math.sin(a);R.dz=Math.cos(a);
    R.v=rand(8,11);                       // deep-water swell speed, m/s
    R.sigma=rand(40,64);R.amp=rand(2.4,4.2);
    R.k=TAU/rand(34,58);
    const d=270;
    R.cx=player.pos.x-R.dx*d;R.cz=player.pos.z-R.dz*d;
    R.on=1;
    this.active=true;this.warned=false;this.hit=false;this.minD=1e9;
    this.next=time+rand(26,46);
  },
  update(dt){
    if(!this.active){
      if(time>this.next) this.trigger();
      return;
    }
    const R=wave.rogue;
    R.cx+=R.dx*R.v*dt;R.cz+=R.dz*R.v*dt;
    const d=Math.hypot(player.pos.x-R.cx,player.pos.z-R.cz);
    this.minD=Math.min(this.minD,d);
    if(!this.warned&&d<255){
      this.warned=true;
      const el=document.getElementById('alert');
      el.classList.add('on');
      Sound.alarm();
    }
    if(this.warned&&!this.hit){
      const el=document.getElementById('alert');
      const eta=Math.max(0,d/R.v);
      el.innerHTML='INCOMING WAVE<small>ROGUE SWELL — T-'+eta.toFixed(0)+'s</small>';
    }
    if(!this.hit&&d<R.sigma*1.1){
      this.hit=true;
      const el=document.getElementById('alert');
      el.classList.add('on');
      el.innerHTML='WAVE IMPACT<small>HULL UNDER HEAVY LOAD</small>';
      setTimeout(()=>{el.classList.remove('on');},2600);
      shake=Math.max(shake,1.6);
      Sound.roar();
      for(let i=0;i<44;i++){
        const a=rand(0,TAU);
        spray.spawn(player.pos.x+Math.sin(a)*2,player.pos.y+0.4,player.pos.z+Math.cos(a)*2,
          Math.sin(a)*rand(4,12),rand(3,11),Math.cos(a)*rand(4,12),rand(1,2.6),rand(0.8,1.6),cFoam,1.1,-9.8);
      }
      spawnRipple(player.pos.x,player.pos.z,4,2.4);
    }
    // the swell has passed us
    if(d>this.minD+45){
      this.active=false;R.on=0;
      waterUniforms.uRogueB.value.w=0;
      document.getElementById('alert').classList.remove('on');
    }
  }
};

/* ─────────────────────────── input ─────────────────────────── */
const keys=Object.create(null);
const mouse={nx:0,ny:0,cx:0,cy:0,down:false};
const crossEl=document.getElementById('cross');
function placeCross(x,y){ crossEl.style.left=x+'px'; crossEl.style.top=y+'px'; }
placeCross(innerWidth/2,innerHeight/2);
addEventListener('keydown',e=>{
  if(e.code==='Space') e.preventDefault();
  if(keys[e.code]) return;
  keys[e.code]=true;
  if(e.code==='KeyP'&&running&&!gameOver){ paused=!paused; showPause(paused); }
  if(e.code==='KeyM'){ Sound.muted=!Sound.muted; if(Sound.master) Sound.master.gain.value=Sound.muted?0:0.55; }
  if(e.code==='KeyR'&&gameOver) restart();
});
addEventListener('keyup',e=>{keys[e.code]=false;});
addEventListener('blur',()=>{for(const k in keys) keys[k]=false; mouse.down=false;});
canvas.addEventListener('mousemove',e=>{
  mouse.cx=e.clientX;mouse.cy=e.clientY;
  mouse.nx=(e.clientX/innerWidth)*2-1;
  mouse.ny=-(e.clientY/innerHeight)*2+1;
  crossEl.style.left=e.clientX+'px';crossEl.style.top=e.clientY+'px';
});
canvas.addEventListener('mousedown',e=>{ if(e.button===0){ mouse.down=true; Sound.resume(); } });
addEventListener('mouseup',()=>{mouse.down=false;});
addEventListener('contextmenu',e=>e.preventDefault());

/* ─────────────────────────── aim & camera ─────────────────────────── */
const aimPoint=new THREE.Vector3(0,0,80);
const camLook=new THREE.Vector3(0,0,0);
const ndc=new THREE.Vector2();
const ray=new THREE.Raycaster();
const aimMarker=new THREE.Mesh(new THREE.RingGeometry(1.5,1.75,36),
  new THREE.MeshBasicMaterial({color:0xffcf6b,transparent:true,opacity:0.75,depthWrite:false,side:THREE.DoubleSide}));
aimMarker.rotation.x=-Math.PI/2; aimMarker.renderOrder=4; scene.add(aimMarker);

const ARC_N=44;
const arcGeo=new THREE.BufferGeometry();
arcGeo.setAttribute('position',new THREE.BufferAttribute(new Float32Array(ARC_N*3),3));
const arc=new THREE.Points(arcGeo,new THREE.PointsMaterial({
  size:0.5,map:makeSpriteTexture('hard'),transparent:true,depthWrite:false,
  color:0xffd27a,opacity:0.9,sizeAttenuation:true}));
arc.frustumCulled=false; scene.add(arc);

const flash=new THREE.Sprite(new THREE.SpriteMaterial({
  map:makeSpriteTexture('soft'),color:0xffd9a0,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false}));
flash.scale.set(3,3,1); flash.visible=false; scene.add(flash);
let flashT=0;

function raySphere(ox,oy,oz,dx,dy,dz,cx,cy,cz,r){
  const ex=cx-ox,ey=cy-oy,ez=cz-oz;
  const b=ex*dx+ey*dy+ez*dz;
  const c=ex*ex+ey*ey+ez*ez-r*r;
  const disc=b*b-c;
  if(disc<0) return -1;
  const t=b-Math.sqrt(disc);
  return t>0?t:-1;
}
function updateAim(){
  ndc.set(mouse.nx,mouse.ny);
  ray.setFromCamera(ndc,camera);
  const o=ray.ray.origin,d=ray.ray.direction;
  let px=0,pz=0,got=false;
  // if the cursor is over a hull, aim straight at that hull
  let best=-1,bx=0,bz=0;
  for(const v of vessels){
    if(!v.alive) continue;
    const t=raySphere(o.x,o.y,o.z,d.x,d.y,d.z,v.pos.x,v.pos.y+0.6,v.pos.z,v.hitR*1.25);
    if(t>0&&(best<0||t<best)){ best=t; bx=v.pos.x; bz=v.pos.z; }
  }
  if(best>0){ px=bx; pz=bz; got=true; }
  else if(d.y<-1e-4){
    const tt=-o.y/d.y;
    if(tt>0&&tt<1500){ px=o.x+d.x*tt; pz=o.z+d.z*tt; got=true; }
  }
  if(!got){
    let hx=d.x,hz=d.z;
    const l=Math.hypot(hx,hz);
    if(l<1e-6){hx=0;hz=1;}else{hx/=l;hz/=l;}
    px=o.x+hx*340; pz=o.z+hz*340;
  }
  const dd=Math.hypot(px-player.pos.x,pz-player.pos.z);
  if(dd>330){const s=330/dd;px=player.pos.x+(px-player.pos.x)*s;pz=player.pos.z+(pz-player.pos.z)*s;}
  if(!isFinite(px)||!isFinite(pz)){px=player.pos.x;pz=player.pos.z+60;}
  aimPoint.set(px,0,pz);
  wave.heightAt(px,pz,time,WS);
  aimPoint.y=isFinite(WS.y)?WS.y:0;
}
function updateCamera(dt){
  const yaw=player.yaw;
  const ox=-12.0, oy=4.2;
  const tx=player.pos.x+Math.sin(yaw)*ox;
  const tz=player.pos.z+Math.cos(yaw)*ox;
  const ty=player.pos.y+oy;
  const k=1-Math.exp(-4.2*dt);
  camera.position.lerp(new THREE.Vector3(tx,ty,tz),k);
  const lx=player.pos.x+Math.sin(yaw)*11;
  const lz=player.pos.z+Math.cos(yaw)*11;
  const ly=player.pos.y+1.7;
  camLook.lerp(new THREE.Vector3(lx,ly,lz),1-Math.exp(-6*dt));
  camera.lookAt(camLook);
  camera.rotateZ(player.roll*0.32);
  if(shake>0.001){
    shake*=Math.exp(-3.4*dt);
    const s=shake*0.55;
    camera.position.x+=rand(-s,s);camera.position.y+=rand(-s,s)*0.7;camera.position.z+=rand(-s,s);
  } else shake=0;
  const speed=Math.hypot(player.vel.x,player.vel.z);
  const targetFov=56+speed*0.55;
  camera.fov+=(targetFov-camera.fov)*(1-Math.exp(-3*dt));
  camera.updateProjectionMatrix();
}
function updateGun(){
  const mz=new THREE.Vector3();
  pRig.group.updateMatrixWorld(true);
  pRig.muzzle.getWorldPosition(mz);
  const dx=aimPoint.x-mz.x,dz=aimPoint.z-mz.z;
  const d=Math.hypot(dx,dz);
  const ang=ballisticAngle(CFG.muzzleSpeed,G,Math.max(d,1),aimPoint.y-mz.y);
  const yaw=Math.atan2(dx,dz);
  pRig.pivot.rotation.y=angDiff(yaw,player.yaw);
  pRig.elev.rotation.x=-ang;
  pRig.group.updateMatrixWorld(true);
  pRig.muzzle.getWorldPosition(mz);
  // trajectory preview
  const pos=arcGeo.attributes.position.array;
  const vx=Math.sin(yaw)*Math.cos(ang)*CFG.muzzleSpeed;
  const vy=Math.sin(ang)*CFG.muzzleSpeed;
  const vz=Math.cos(yaw)*Math.cos(ang)*CFG.muzzleSpeed;
  let px=mz.x,py=mz.y,pz=mz.z,vvx=vx,vvy=vy,vvz=vz;
  const step=0.09;
  for(let i=0;i<ARC_N;i++){
    vvy-=9.81*step;
    px+=vvx*step;py+=vvy*step;pz+=vvz*step;
    pos[i*3]=px;pos[i*3+1]=py;pos[i*3+2]=pz;
    if(py<0&&i>2) break;
  }
  arcGeo.attributes.position.needsUpdate=true;
}
function playerFire(){
  if(player.reload>0||!player.alive||gameOver) return;
  player.reload=0.88;
  const mz=new THREE.Vector3();
  pRig.muzzle.getWorldPosition(mz);
  const dx=aimPoint.x-mz.x,dz=aimPoint.z-mz.z;
  const d=Math.hypot(dx,dz);
  const ang=ballisticAngle(CFG.muzzleSpeed,G,Math.max(d,1),aimPoint.y-mz.y);
  const yaw=Math.atan2(dx,dz);
  const dir=new THREE.Vector3(Math.sin(yaw)*Math.cos(ang),Math.sin(ang),Math.cos(yaw)*Math.cos(ang));
  spawnBall(mz,dir.multiplyScalar(CFG.muzzleSpeed),player,34);
  player.vel.x-=Math.sin(yaw)*3.6; player.vel.z-=Math.cos(yaw)*3.6;
  flash.position.copy(mz).addScaledVector(dir,0.4); flash.visible=true; flashT=0.1;
  Sound.cannon(1);
  for(let i=0;i<12;i++){
    spray.spawn(mz.x,mz.y,mz.z,rand(-2.4,2.4)+Math.sin(yaw)*5,rand(0.6,3.0),rand(-2.4,2.4)+Math.cos(yaw)*5,
      rand(0.8,1.8),rand(0.7,1.4),cSmoke,1.0,0.9);
  }
  for(let i=0;i<8;i++){
    sparks.spawn(mz.x,mz.y,mz.z,rand(-3,3)+Math.sin(yaw)*7,rand(0.5,2.6),rand(-3,3)+Math.cos(yaw)*7,
      rand(0.4,1.0),rand(0.15,0.35),cFire,2.2,-6);
  }
  shake=Math.max(shake,0.42);
}

/* ─────────────────────────── HUD ─────────────────────────── */
const el=id=>document.getElementById(id);
const hudHull=el('hullFill'),hudSpeed=el('speed'),hudFoes=el('foes'),hudScore=el('score'),
      hudSea=el('sea'),hudW=el('wstate'),hudFps=el('fps'),hitEl=el('hit');
let hitFlash=0;
function updateHUD(){
  const p=clamp(player.hp/player.maxHp,0,1);
  hudHull.style.width=(p*100).toFixed(1)+'%';
  hudHull.style.background=p>0.55?'linear-gradient(90deg,#2fd6a4,#7ff0c4)':
    p>0.25?'linear-gradient(90deg,#e8a33c,#ffd27a)':'linear-gradient(90deg,#d33,#ff7a7a)';
  hudSpeed.textContent=(Math.hypot(player.vel.x,player.vel.z)*1.9438).toFixed(1)+' kn';
  hudFoes.textContent=enemies.filter(e=>e.alive).length;
  hudScore.textContent=score;
  hudSea.textContent=wave.hs.toFixed(1)+' m';
  hudW.textContent=waveEvents.active?'ROGUE':(wave.hs>1.6?'rough':'moderate');
  hudFps.textContent=Math.round(fps);
  if(hitFlash>0){ hitFlash-=0.05; hitEl.style.opacity=Math.max(0,hitFlash).toFixed(3); }
}
function showPause(p){
  const ov=el('ov');
  if(p){ ov.classList.remove('hide'); el('card').innerHTML=
    '<h1>PAUSED</h1><h2>Hold station</h2><p>Press P to resume</p><button class="btn" id="startBtn">Resume</button>';
    document.getElementById('startBtn').onclick=()=>{paused=false;ov.classList.add('hide');};
  } else ov.classList.add('hide');
}

/* ─────────────────────────── game flow ─────────────────────────── */
function restart(){
  clearEnemies();
  for(const b of balls) removeBall(b);
  balls.length=0;
  player.pos.set(0,0,0);player.vel.set(0,0,0);player.yaw=0;player.yawRate=0;
  player.pitch=0;player.roll=0;player.heaveVel=0;player.hp=player.maxHp;
  player.alive=true;player.sinking=false;player.sinkT=0;player.removed=false;
  player.reload=0;player.group.visible=true;
  score=0;gameOver=false;time=0;waveEvents.reset();wake.reset();
  spray.cursor=0;spray.life.fill(0);spray.alp.fill(0);
  sparks.cursor=0;sparks.life.fill(0);sparks.alp.fill(0);
  el('ov').classList.add('hide');
  el('card').innerHTML=CARD_HTML;
  el('startBtn').onclick=()=>{};
  running=true;
  for(let i=0;i<ARC_N;i++){arcGeo.attributes.position.array[i*3+1]=-999;}
  arcGeo.attributes.position.needsUpdate=true;
}
const CARD_HTML=document.getElementById('card').innerHTML;
function gameOverScreen(){
  gameOver=true;
  const ov=el('ov');
  ov.classList.remove('hide');
  el('card').innerHTML=
    '<h1>SUNK</h1><h2>The sea claims another hull</h2>'+
    '<div class="score" id="finalScore">'+score+'</div><div class="lbl">Salvage recovered</div>'+
    '<p>Hostile gunboats broke your waterline.</p>'+
    '<button class="btn" id="startBtn">Refit &amp; Sail Again</button>';
  el('startBtn').onclick=restart;
}
el('startBtn').onclick=()=>{
  Sound.init();Sound.resume();
  el('ov').classList.add('hide');
  running=true;
};

/* ─────────────────────────── main loop ─────────────────────────── */
let last=performance.now()/1000,fps=60,fpsAcc=0,fpsN=0,spawnT=4;
function frame(){
  requestAnimationFrame(frame);
  const now=performance.now()/1000;
  let dt=now-last;last=now;
  if(dt>0.1)dt=0.1; if(dt<0)dt=0;
  fpsAcc+=dt;fpsN++;
  if(fpsAcc>0.5){fps=fpsN/fpsAcc;fpsAcc=0;fpsN=0;}

  if(running&&!paused){
    time+=dt;
    // player input
    const up=(keys.KeyW||keys.ArrowUp)?1:0, dn=(keys.KeyS||keys.ArrowDown)?1:0;
    const lf=(keys.KeyA||keys.ArrowLeft)?1:0, rt=(keys.KeyD||keys.ArrowRight)?1:0;
    if(player.alive){ player.throttle=up-dn; player.rudder=lf-rt; }
    player.reload-=dt;

    waveEvents.update(dt);
    player.update(dt,time);
    // enemies (they run their own AI + physics)
    for(const e of enemies){
      e.update(dt,time,player);
      if(e.sinking&&!e.sunkFlag){ e.sunkFlag=true; }
    }
    for(let i=enemies.length-1;i>=0;i--){
      const e=enemies[i];
      if(e.removed){
        scene.remove(e.group);
        enemies.splice(i,1);
        const vi=vessels.indexOf(e); if(vi>=0) vessels.splice(vi,1);
      }
    }
    if(player.sinking&&player.sinkT>2.6&&!gameOver) gameOverScreen();
    if(player.alive&&!gameOver&&mouse.down) playerFire();
    if(keys.Space&&player.alive&&!gameOver) playerFire();

    // spawn hostiles
    spawnT-=dt;
    if(spawnT<=0&&enemies.filter(e=>e.alive).length<CFG.maxEnemies){
      spawnT=rand(9,16);
      spawnEnemy();
    }
    updateBalls(dt);
    spray.update(dt);sparks.update(dt);
    updateRipples(dt);
    // wake trail
    if(player.alive){
      const rx=Math.cos(player.yaw),rz=-Math.sin(player.yaw);
      wake.push(player.pos.x,player.pos.z,rx,rz);
    }
    wake.update(dt);
    // flag flutter
    if(pRig.flag){
      const pa=pRig.flag.geometry.attributes.position;
      const base=pRig.flagBase;
      for(let i=0;i<pa.count;i++){
        const bx=base[i*3],by=base[i*3+1];
        const k=(bx+0.625)/1.25;
        pa.setY(i,by+Math.sin(time*6+k*7)*0.13*k);
        pa.setZ(i,Math.sin(time*5+k*5)*0.16*k);
      }
      pa.needsUpdate=true;
    }
    // flash decay
    if(flashT>0){ flashT-=dt; flash.scale.setScalar(2.5+flashT*16); } else flash.visible=false;

    updateCamera(dt);
    updateAim();
    updateGun();
    aimMarker.position.set(aimPoint.x,aimPoint.y+0.12,aimPoint.z);
    aimMarker.scale.setScalar(1+Math.sin(time*3)*0.04);
  }

  // scene uniforms
  water.position.set(player.pos.x,0,player.pos.z);
  waterUniforms.uTime.value=time;
  waterUniforms.uRogueA.value.set(wave.rogue.dx,wave.rogue.dz,wave.rogue.amp,wave.rogue.k);
  waterUniforms.uRogueB.value.set(wave.rogue.cx,wave.rogue.cz,wave.rogue.sigma,wave.rogue.on);
  clouds.position.set(camera.position.x,1300,camera.position.z);
  cloudMat.uniforms.uTime.value=time;
  cloudMat.uniforms.uCam.value.copy(camera.position);

  camera.updateMatrixWorld();
  if(reflectionRT) renderReflection();
  composer.render();
  updateHUD();
}
updateCamera(1);
updateAim();
updateGun();
frame();

addEventListener('resize',()=>{
  camera.aspect=innerWidth/innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth,innerHeight);
  composer.setSize(innerWidth,innerHeight);
  if(bloom) bloom.setSize(innerWidth,innerHeight);
  if(reflectionRT){
    const s=Math.min(1024,Math.max(512,Math.floor(innerWidth/2)));
    reflectionRT.setSize(s,Math.max(256,Math.floor(s*innerHeight/innerWidth)));
  }
});
window.__game={wave,player,enemies,balls,scene,renderer,camera,waveEvents,waterUniforms,
  spawnEnemy,fire:playerFire,keys,
  aimAt(x,z){aimPoint.set(x,0,z);},
  get aim(){return aimPoint;},
  get time(){return time;},
  get fps(){return fps;},
  get running(){return running;}};
</script>
</body>
</html>

Same prompt, other models

8 more
Compare side by side

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