Boat game, built by MiniMax M3.1 Flash
Hostedvia OpenCodeOpenRouter
95tok/s
First token included
254.5Ktokens
44m 33s
Show the code the model wroteHide the code
<!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 <kbd>A</kbd><kbd>D</kbd> helm</div>
<div><kbd>Mouse</kbd> aim <kbd>Click</kbd> fire <kbd>Space</kbd> all guns</div>
<div><kbd>R</kbd> restart <kbd>P</kbd> pause <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 & 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 & 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 & 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
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.