Opus 5.5
Hostedvia Claude CodeOpenRouter
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>Dead Water — Naval Combat</title>
<style>
:root{
--ink:#e8f1f7;
--dim:#8fa6b6;
--hot:#ff6a3d;
--good:#54e6b4;
--warn:#ffcf4d;
--panel:rgba(6,14,22,.55);
--line:rgba(150,200,230,.22);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:#05080c;}
body{
font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
color:var(--ink);
-webkit-font-smoothing:antialiased;
cursor:none;
}
canvas{display:block}
#app{position:fixed;inset:0}
/* ---------- HUD ---------- */
#hud{position:fixed;inset:0;pointer-events:none;z-index:10;transition:opacity .4s}
#hud.dim{opacity:0}
.panel{
background:var(--panel);
border:1px solid var(--line);
border-radius:10px;
backdrop-filter:blur(8px) saturate(1.2);
-webkit-backdrop-filter:blur(8px) saturate(1.2);
}
#topleft{position:absolute;top:18px;left:18px;padding:12px 14px;min-width:232px}
.label{font-size:9px;letter-spacing:.22em;color:var(--dim);text-transform:uppercase}
.bar{height:9px;border-radius:5px;background:rgba(255,255,255,.1);overflow:hidden;margin-top:5px;position:relative}
.bar > i{display:block;height:100%;width:100%;transition:width .18s ease-out;border-radius:5px}
#hullfill{background:linear-gradient(90deg,#2fd79a,#8ef0c4)}
#heatfill{background:linear-gradient(90deg,#ffb03a,#ff5a2d)}
.row{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
.num{font-size:13px;font-variant-numeric:tabular-nums;color:var(--ink)}
.stack + .stack{margin-top:10px}
#topright{position:absolute;top:18px;right:18px;padding:12px 14px;text-align:right;min-width:150px}
#score{font-size:26px;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:.02em}
#wavecount{font-size:12px;color:var(--warn);margin-top:6px;letter-spacing:.1em}
#botleft{position:absolute;bottom:18px;left:18px;padding:10px 12px;font-size:10px;line-height:1.75;color:var(--dim);letter-spacing:.06em}
#botleft b{color:var(--ink);font-weight:600}
/* minimap */
#radarwrap{position:absolute;bottom:18px;right:18px;padding:8px;border-radius:50%}
#radar{display:block;border-radius:50%}
/* crosshair */
#cross{position:absolute;left:0;top:0;width:54px;height:54px;margin:-27px 0 0 -27px;will-change:transform}
#cross svg{display:block;overflow:visible}
.ch{stroke:#ffffff;stroke-width:1.6;fill:none;opacity:.92;
filter:drop-shadow(0 0 3px rgba(0,0,0,.85))}
.ch.hot{stroke:var(--hot)}
#chring{transition:opacity .15s}
/* alerts / vignette */
#alert{
position:absolute;left:50%;top:15%;transform:translateX(-50%);
font-size:15px;letter-spacing:.32em;color:var(--warn);text-transform:uppercase;
text-shadow:0 0 22px rgba(255,180,50,.65);opacity:0;transition:opacity .3s;white-space:nowrap
}
#vig{position:absolute;inset:0;opacity:0;transition:opacity .35s;
background:radial-gradient(ellipse at center,transparent 42%,rgba(190,25,25,.62) 100%)}
#flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;mix-blend-mode:screen}
/* ---------- overlays ---------- */
.screen{
position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:26px;text-align:center;
background:linear-gradient(180deg,rgba(4,12,20,.78) 0%,rgba(6,16,26,.52) 42%,rgba(3,9,15,.34) 100%);
backdrop-filter:blur(3px);cursor:pointer;
}
.screen.hide{display:none}
h1{
font-size:clamp(38px,7.5vw,78px);font-weight:800;letter-spacing:.16em;
background:linear-gradient(180deg,#fff,#7fc8e8 65%,#3d7fa0);
-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 0 60px rgba(90,180,230,.28)
}
.sub{color:var(--dim);font-size:12px;letter-spacing:.42em;text-transform:uppercase;margin-top:6px}
.keys{display:grid;grid-template-columns:auto auto;gap:9px 22px;font-size:12px;color:var(--dim);
padding:22px 30px;border:1px solid var(--line);border-radius:12px;background:rgba(6,14,22,.45)}
.keys b{color:var(--ink);font-weight:600;justify-self:end}
.cta{font-size:13px;letter-spacing:.3em;color:var(--good);animation:pulse 1.9s ease-in-out infinite;text-transform:uppercase}
@keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}
#gostats{font-size:14px;color:var(--dim);line-height:2}
#gostats b{color:var(--ink);font-size:22px}
#loading{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;
background:#05080c;color:var(--dim);font-size:11px;letter-spacing:.35em;text-transform:uppercase}
</style>
</head>
<body>
<div id="app"></div>
<div id="hud" class="dim">
<div id="topleft" class="panel">
<div class="stack">
<div class="row"><span class="label">Hull Integrity</span><span class="num" id="hullnum">100</span></div>
<div class="bar"><i id="hullfill"></i></div>
</div>
<div class="stack">
<div class="row"><span class="label">Gun Heat</span><span class="num" id="heatnum">0%</span></div>
<div class="bar"><i id="heatfill" style="width:0%"></i></div>
</div>
<div class="stack row">
<span class="label">Throttle</span><span class="num" id="thr">0</span>
<span class="label">Knots</span><span class="num" id="kts">0</span>
</div>
</div>
<div id="topright" class="panel">
<div class="label">Score</div>
<div id="score">0</div>
<div id="wavecount">WAVE 1 · 0 LEFT</div>
</div>
<div id="botleft" class="panel">
<b>W/S</b> or <b>↑/↓</b> throttle <b>A/D</b> or <b>←/→</b> rudder<br/>
<b>MOUSE</b> aim <b>CLICK</b> or <b>SPACE</b> fire <b>X</b> all stop <b>P</b> pause
</div>
<div id="radarwrap" class="panel"><canvas id="radar" width="150" height="150"></canvas></div>
<div id="cross">
<svg width="54" height="54" viewBox="0 0 54 54">
<circle id="chring" class="ch" cx="27" cy="27" r="15" opacity=".35"/>
<path class="ch" d="M27 4 v9 M27 41 v9 M4 27 h9 M41 27 h9"/>
<circle class="ch" cx="27" cy="27" r="1.7" fill="#fff" stroke="none"/>
</svg>
</div>
<div id="alert">⚠ ROGUE SWELL INBOUND</div>
<div id="vig"></div>
<div id="flash"></div>
</div>
<div id="start" class="screen hide">
<div>
<h1>DEAD WATER</h1>
<div class="sub">North Atlantic · Gun Run</div>
</div>
<div class="keys">
<b>W / S</b><span>Throttle ahead & astern (or ↑ / ↓)</span>
<b>A / D</b><span>Rudder port & starboard (or ← / →)</span>
<b>MOUSE</b><span>Traverse the deck gun</span>
<b>CLICK / SPACE</b><span>Fire — mind the heat</span>
<b>X</b><span>All stop</span>
</div>
<div class="cta">Click anywhere to set sail</div>
</div>
<div id="gameover" class="screen hide">
<div>
<h1 style="background:linear-gradient(180deg,#fff,#ff9f7a 65%,#a0402d);-webkit-background-clip:text;background-clip:text">SUNK</h1>
<div class="sub">Your boat went down with all hands</div>
</div>
<div id="gostats"></div>
<div class="cta">Click to sail again</div>
</div>
<div id="loading">Charting the water…</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { Sky } from 'three/addons/objects/Sky.js';
/* ═══════════════════════════════════════════════════════════════════════
0. CONSTANTS · WAVE SPECTRUM
The same six Gerstner components drive the GPU vertex shader and the
CPU-side buoyancy sampler, so what you see is exactly what you float on.
═══════════════════════════════════════════════════════════════════════ */
const G = 9.81;
// [ dirX, dirZ, steepness, wavelength ]
const WAVES = [
[ 1.00, 0.14, 0.205, 74.0 ],
[ 0.72, 0.69, 0.185, 39.0 ],
[-0.55, 0.84, 0.150, 21.0 ],
[ 0.31, -0.95, 0.110, 11.5 ],
[-0.92, -0.39, 0.080, 6.2 ],
[ 0.48, 0.88, 0.055, 3.1 ],
];
const NW = WAVES.length;
// pre-baked per-wave scalars for the CPU sampler
const W = WAVES.map(([dx, dz, s, L]) => {
const len = Math.hypot(dx, dz);
const k = (Math.PI * 2) / L;
return { dx: dx / len, dz: dz / len, s, k, c: Math.sqrt(G / k), a: s / k };
});
const waveUniform = WAVES.map(([dx, dz, s, L]) => {
const len = Math.hypot(dx, dz);
return new THREE.Vector4(dx / len, dz / len, s, L);
});
// Global swell multiplier — rogue waves ramp this up and back down.
const sea = { amp: 1.0, target: 1.0, time: 0 };
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const rand = (a, b) => a + Math.random() * (b - a);
const damp = (a, b, l, dt) => lerp(a, b, 1 - Math.exp(-l * dt));
/* ═══════════════════════════════════════════════════════════════════════
1. CPU WAVE SAMPLER
Gerstner waves displace horizontally as well as vertically, so finding
the surface above a given (x,z) needs a short fixed-point iteration.
═══════════════════════════════════════════════════════════════════════ */
const _disp = { x: 0, y: 0, z: 0 };
function gerstnerDisplace(px, pz, t, amp) {
let dx = 0, dy = 0, dz = 0;
for (let i = 0; i < NW; i++) {
const w = W[i];
const f = w.k * (w.dx * px + w.dz * pz - w.c * t);
const a = w.a * amp;
dx += w.dx * a * Math.cos(f);
dz += w.dz * a * Math.cos(f);
dy += a * Math.sin(f);
}
_disp.x = dx; _disp.y = dy; _disp.z = dz;
return _disp;
}
/** Height of the water surface directly above world (x,z). */
function waterHeight(x, z, t = sea.time, iters = 4) {
const amp = sea.amp;
let sx = x, sz = z;
for (let i = 0; i < iters; i++) {
const d = gerstnerDisplace(sx, sz, t, amp);
sx -= (sx + d.x - x);
sz -= (sz + d.z - z);
}
return gerstnerDisplace(sx, sz, t, amp).y;
}
const _n = new THREE.Vector3();
/** Surface normal at world (x,z), by finite difference. */
function waterNormal(x, z, t = sea.time) {
const e = 0.7;
const hL = waterHeight(x - e, z, t, 3), hR = waterHeight(x + e, z, t, 3);
const hD = waterHeight(x, z - e, t, 3), hU = waterHeight(x, z + e, t, 3);
return _n.set(hL - hR, 2 * e, hD - hU).normalize();
}
/* ═══════════════════════════════════════════════════════════════════════
2. RENDERER · SCENE · SKY · ENVIRONMENT
═══════════════════════════════════════════════════════════════════════ */
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({
antialias: true, powerPreference: 'high-performance', stencil: false,
});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.58;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.4, 40000);
camera.position.set(0, 14, 34);
const HAZE = new THREE.Color(0xb8c6cc);
scene.fog = new THREE.FogExp2(HAZE, 0.00135);
// --- atmospheric sky ---------------------------------------------------
const sky = new Sky();
sky.scale.setScalar(20000);
const skyU = sky.material.uniforms;
skyU.turbidity.value = 4.2;
skyU.rayleigh.value = 1.45;
skyU.mieCoefficient.value = 0.0062;
skyU.mieDirectionalG.value = 0.86;
scene.add(sky);
const SUN_ELEV = 9.5, SUN_AZIM = 152;
const sunDir = new THREE.Vector3().setFromSphericalCoords(
1,
THREE.MathUtils.degToRad(90 - SUN_ELEV),
THREE.MathUtils.degToRad(SUN_AZIM),
);
skyU.sunPosition.value.copy(sunDir);
const sunLight = new THREE.DirectionalLight(0xffd9a8, 3.1);
sunLight.position.copy(sunDir).multiplyScalar(220);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 50;
sunLight.shadow.camera.far = 480;
const sc = sunLight.shadow.camera;
sc.left = -70; sc.right = 70; sc.top = 70; sc.bottom = -70;
sunLight.shadow.bias = -0.0012;
sunLight.shadow.normalBias = 0.05;
scene.add(sunLight);
scene.add(sunLight.target);
scene.add(new THREE.HemisphereLight(0xbfd8ef, 0x1d3a4a, 0.55));
// --- capture the sky into a cube map (water reflections + PBR env) -----
const cubeRT = new THREE.WebGLCubeRenderTarget(512, { type: THREE.HalfFloatType });
const cubeCam = new THREE.CubeCamera(1, 90000, cubeRT);
cubeCam.update(renderer, scene);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromCubemap(cubeRT.texture).texture;
/* ═══════════════════════════════════════════════════════════════════════
3. PROCEDURAL WATER DETAIL TEXTURE
RGB = tangent-space normal, A = fBm scalar used for foam breakup.
Tileable, so no external assets are needed.
═══════════════════════════════════════════════════════════════════════ */
function makeWaterTexture(size = 256) {
const hash = (x, y, p) => {
x = ((x % p) + p) % p; y = ((y % p) + p) % p;
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
};
const vnoise = (x, y, p) => {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const a = hash(xi, yi, p), b = hash(xi + 1, yi, p);
const c = hash(xi, yi + 1, p), d = hash(xi + 1, yi + 1, p);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
};
const h = new Float32Array(size * size);
const octaves = [[4, 1.0], [8, 0.55], [16, 0.3], [32, 0.17], [64, 0.09]];
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let s = 0, n = 0;
for (const [p, w] of octaves) {
s += vnoise(x / size * p, y / size * p, p) * w;
n += w;
}
h[y * size + x] = s / n;
}
}
const data = new Uint8Array(size * size * 4);
const at = (x, y) => h[(((y % size) + size) % size) * size + (((x % size) + size) % size)];
const STR = 2.6;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = (at(x + 1, y) - at(x - 1, y)) * STR;
const dy = (at(x, y + 1) - at(x, y - 1)) * STR;
const l = Math.hypot(-dx, -dy, 1 / size * 16);
const nx = -dx / l, ny = -dy / l, nz = (1 / size * 16) / l;
const i = (y * size + x) * 4;
data[i] = (nx * 0.5 + 0.5) * 255;
data[i + 1] = (ny * 0.5 + 0.5) * 255;
data[i + 2] = (nz * 0.5 + 0.5) * 255;
data[i + 3] = clamp(at(x, y), 0, 1) * 255;
}
}
const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.magFilter = THREE.LinearFilter;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.generateMipmaps = true;
tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
tex.needsUpdate = true;
return tex;
}
const waterDetail = makeWaterTexture(256);
/* ═══════════════════════════════════════════════════════════════════════
4. THE OCEAN
A radial disk locked to the camera: dense tessellation underfoot, coarse
out to the horizon. Waves are evaluated from world position, so the sea
stays put in world space while the mesh slides along under it.
═══════════════════════════════════════════════════════════════════════ */
function makeOceanDisk(rings, segs, rMin, rMax, power) {
const pos = [], idx = [];
pos.push(0, 0, 0);
for (let i = 0; i < rings; i++) {
const t = i / (rings - 1);
const r = rMin + (rMax - rMin) * Math.pow(t, power);
for (let j = 0; j < segs; j++) {
const a = (j / segs) * Math.PI * 2;
pos.push(Math.cos(a) * r, 0, Math.sin(a) * r);
}
}
for (let j = 0; j < segs; j++) idx.push(0, 1 + ((j + 1) % segs), 1 + j);
for (let i = 0; i < rings - 1; i++) {
const A = 1 + i * segs, B = 1 + (i + 1) * segs;
for (let j = 0; j < segs; j++) {
const j2 = (j + 1) % segs;
idx.push(A + j, B + j2, B + j);
idx.push(A + j, A + j2, B + j2);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx);
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), rMax * 1.05);
return g;
}
const reflRT = new THREE.WebGLRenderTarget(
Math.min(1024, Math.floor(innerWidth * 0.6)),
Math.min(1024, Math.floor(innerHeight * 0.6)),
{ type: THREE.HalfFloatType, depthBuffer: true },
);
const waterUniforms = {
uTime: { value: 0 },
uAmp: { value: 1 },
uWaves: { value: waveUniform },
uSky: { value: cubeRT.texture },
uRefl: { value: reflRT.texture },
uReflMatrix: { value: new THREE.Matrix4() },
uDetail: { value: waterDetail },
uCamPos: { value: new THREE.Vector3() },
uSunDir: { value: sunDir.clone() },
uSunColor: { value: new THREE.Color(0xfff0d0) },
uDeep: { value: new THREE.Color(0x04141d) },
uShallow: { value: new THREE.Color(0x19707a) },
uSSS: { value: new THREE.Color(0x35b48d) },
uFoam: { value: new THREE.Color(0xdfeef5) },
uHaze: { value: HAZE.clone() },
};
const waterVert = /* glsl */`
#define NW ${NW}
uniform float uTime, uAmp;
uniform vec4 uWaves[NW];
uniform mat4 uReflMatrix;
varying vec3 vWorld;
varying vec3 vNrm;
varying vec4 vRefl;
varying float vCrest;
varying float vHeight;
varying float vDist;
void main() {
vec3 base = (modelMatrix * vec4(position, 1.0)).xyz;
vec3 disp = vec3(0.0);
vec3 tangent = vec3(1.0, 0.0, 0.0);
vec3 binormal = vec3(0.0, 0.0, 1.0);
float crest = 0.0;
for (int i = 0; i < NW; i++) {
vec2 d = uWaves[i].xy;
float st = uWaves[i].z * uAmp;
float k = 6.28318530718 / uWaves[i].w;
float c = sqrt(9.81 / k);
float a = st / k;
float f = k * (dot(d, base.xz) - c * uTime);
float sf = sin(f), cf = cos(f);
disp.x += d.x * a * cf;
disp.z += d.y * a * cf;
disp.y += a * sf;
tangent += vec3(-d.x * d.x * st * sf, d.x * st * cf, -d.x * d.y * st * sf);
binormal += vec3(-d.x * d.y * st * sf, d.y * st * cf, -d.y * d.y * st * sf);
crest += st * sf;
}
vec3 world = base + disp;
vWorld = world;
vNrm = normalize(cross(binormal, tangent));
vCrest = crest;
vHeight = disp.y;
vRefl = uReflMatrix * vec4(world, 1.0);
vec4 mv = viewMatrix * vec4(world, 1.0);
vDist = -mv.z;
gl_Position = projectionMatrix * mv;
}
`;
const waterFrag = /* glsl */`
uniform samplerCube uSky;
uniform sampler2D uRefl;
uniform sampler2D uDetail;
uniform vec3 uCamPos, uSunDir, uSunColor, uDeep, uShallow, uSSS, uFoam, uHaze;
uniform float uTime, uAmp;
varying vec3 vWorld;
varying vec3 vNrm;
varying vec4 vRefl;
varying float vCrest;
varying float vHeight;
varying float vDist;
vec3 detailNormal(vec2 p, float fade) {
vec3 n = vec3(0.0);
n += (texture2D(uDetail, p * 0.019 + vec2( uTime * 0.0085, uTime * 0.0061)).xyz * 2.0 - 1.0) * 1.00;
n += (texture2D(uDetail, p * 0.056 - vec2( uTime * 0.0140, uTime * 0.0092)).xyz * 2.0 - 1.0) * 0.62;
n += (texture2D(uDetail, p * 0.180 + vec2(-uTime * 0.0330, uTime * 0.0250)).xyz * 2.0 - 1.0) * 0.30 * fade;
n += (texture2D(uDetail, p * 0.520 + vec2( uTime * 0.0610, -uTime * 0.044)).xyz * 2.0 - 1.0) * 0.16 * fade * fade;
return n;
}
void main() {
vec3 V = normalize(uCamPos - vWorld);
// ---- normal: analytic Gerstner + scrolling detail, faded with distance
float fade = exp(-vDist * 0.0055);
float near = exp(-vDist * 0.0016);
vec3 d = detailNormal(vWorld.xz, fade);
vec3 N = normalize(vNrm + vec3(d.x, 0.0, d.y) * (0.16 + 0.46 * near));
N = normalize(mix(vec3(0.0, 1.0, 0.0), N, 0.30 + 0.70 * near));
if (N.y < 0.0) N = -N;
// ---- reflections: sky cube for everything, planar RT for the fleet
vec3 R = reflect(-V, N);
R.y = max(R.y, 0.015);
vec3 refl = textureCube(uSky, R).rgb;
vec2 ruv = vRefl.xy / max(vRefl.w, 0.0001);
vec2 warp = N.xz * (0.085 * near + 0.008);
vec2 wuv = ruv + warp;
float inside = step(0.0001, vRefl.w)
* smoothstep(0.0, 0.05, wuv.x) * smoothstep(1.0, 0.95, wuv.x)
* smoothstep(0.0, 0.05, wuv.y) * smoothstep(1.0, 0.95, wuv.y);
vec4 objRefl = texture2D(uRefl, wuv);
refl = mix(refl, objRefl.rgb, clamp(objRefl.a * inside * 0.85, 0.0, 1.0));
// ---- body colour: depth-ish gradient + sub-surface scattering on crests
float facing = clamp(dot(N, V), 0.0, 1.0);
vec3 body = mix(uDeep, uShallow, facing * 0.72 + 0.10);
float lift = clamp(vHeight / (2.4 * uAmp) * 0.5 + 0.35, 0.0, 1.0);
float through = pow(clamp(dot(V, -uSunDir) * 0.5 + 0.5, 0.0, 1.0), 4.0);
body += uSSS * through * lift * lift * 1.15;
body += uSunColor * 0.035 * max(0.0, N.y);
// ---- fresnel
float F = 0.02 + 0.98 * pow(1.0 - facing, 5.0);
vec3 col = mix(body, refl, clamp(F, 0.0, 1.0));
// ---- sun: tight disc glint + broad glitter lobe
vec3 H = normalize(uSunDir + V);
float ndh = max(dot(N, H), 0.0);
col += uSunColor * pow(ndh, 900.0) * 9.0;
col += uSunColor * pow(ndh, 90.0) * 0.16 * near;
// ---- whitecaps where the Gerstner surface starts to pinch
float noise = texture2D(uDetail, vWorld.xz * 0.06 + vec2(uTime * 0.01, uTime * 0.007)).a;
float noise2 = texture2D(uDetail, vWorld.xz * 0.21 - vec2(uTime * 0.02, uTime * 0.014)).a;
float breakup = noise * 0.65 + noise2 * 0.35;
float foam = smoothstep(0.50, 0.94, vCrest * (0.50 + breakup * 0.80));
foam *= smoothstep(0.0, 0.35, uAmp - 0.55) * (0.35 + 0.65 * near);
col = mix(col, uFoam * (0.62 + 0.38 * breakup), clamp(foam, 0.0, 0.80));
// ---- fade into the haze so the horizon line disappears
vec3 horizon = textureCube(uSky, normalize(vec3(-V.x, 0.035, -V.z))).rgb;
float f = 1.0 - exp(-vDist * 0.00085);
col = mix(col, mix(horizon, uHaze, 0.25), clamp(f, 0.0, 1.0));
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const waterMat = new THREE.ShaderMaterial({
uniforms: waterUniforms,
vertexShader: waterVert,
fragmentShader: waterFrag,
side: THREE.DoubleSide,
});
const ocean = new THREE.Mesh(makeOceanDisk(190, 340, 0.9, 9000, 2.7), waterMat);
ocean.frustumCulled = false;
ocean.renderOrder = 1;
scene.add(ocean);
/* --- planar reflector (mirror camera about y = 0) --------------------- */
const reflCam = new THREE.PerspectiveCamera();
const CLIP_ABOVE = [new THREE.Plane(new THREE.Vector3(0, 1, 0), 0.0)];
const NO_CLIP = [];
const _bias = new THREE.Matrix4().set(
0.5, 0, 0, 0.5,
0, 0.5, 0, 0.5,
0, 0, 0.5, 0.5,
0, 0, 0, 1,
);
const _up = new THREE.Vector3(), _look = new THREE.Vector3(), _rot = new THREE.Matrix4();
function renderReflection() {
_rot.extractRotation(camera.matrixWorld);
reflCam.position.set(camera.position.x, -camera.position.y, camera.position.z);
_look.set(0, 0, -1).applyMatrix4(_rot).add(camera.position);
_up.set(0, 1, 0).applyMatrix4(_rot);
reflCam.up.set(_up.x, -_up.y, _up.z);
reflCam.lookAt(_look.x, -_look.y, _look.z);
reflCam.fov = camera.fov;
reflCam.aspect = camera.aspect;
reflCam.near = camera.near;
reflCam.far = 1600;
reflCam.updateProjectionMatrix();
reflCam.updateMatrixWorld();
waterUniforms.uReflMatrix.value
.copy(_bias)
.multiply(reflCam.projectionMatrix)
.multiply(reflCam.matrixWorldInverse);
ocean.visible = false;
sky.visible = false;
const fog = scene.fog; scene.fog = null;
renderer.clippingPlanes = CLIP_ABOVE;
renderer.setRenderTarget(reflRT);
renderer.setClearColor(0x000000, 0);
renderer.clear();
renderer.render(scene, reflCam);
renderer.setRenderTarget(null);
renderer.clippingPlanes = NO_CLIP;
scene.fog = fog;
ocean.visible = true;
sky.visible = true;
}
/* ═══════════════════════════════════════════════════════════════════════
5. PARTICLE FX
Two pools sharing one update path: soft (spray, foam, smoke) and
additive (muzzle fire, embers, explosions).
═══════════════════════════════════════════════════════════════════════ */
const FX_VERT = /* glsl */`
attribute float aSize;
attribute float aAlpha;
attribute vec3 aColor;
varying float vA;
varying vec3 vC;
void main() {
vA = aAlpha; vC = aColor;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (320.0 / max(-mv.z, 0.1));
gl_Position = projectionMatrix * mv;
}
`;
const FX_FRAG = /* glsl */`
varying float vA;
varying vec3 vC;
void main() {
float d = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.08, d) * vA;
if (a < 0.004) discard;
gl_FragColor = vec4(vC, a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
class ParticlePool {
constructor(count, blending, depthWrite) {
this.n = count;
this.pos = new Float32Array(count * 3);
this.vel = new Float32Array(count * 3);
this.size = new Float32Array(count);
this.alpha = new Float32Array(count);
this.color = new Float32Array(count * 3);
this.life = new Float32Array(count);
this.max = new Float32Array(count);
this.drag = new Float32Array(count);
this.grav = new Float32Array(count);
this.grow = new Float32Array(count);
this.amax = new Float32Array(count);
this.cursor = 0;
for (let i = 0; i < count; i++) this.pos[i * 3 + 1] = -9999;
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
g.setAttribute('aSize', new THREE.BufferAttribute(this.size, 1));
g.setAttribute('aAlpha', new THREE.BufferAttribute(this.alpha, 1));
g.setAttribute('aColor', new THREE.BufferAttribute(this.color, 3));
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
this.mesh = new THREE.Points(g, new THREE.ShaderMaterial({
vertexShader: FX_VERT, fragmentShader: FX_FRAG,
transparent: true, depthWrite, blending,
}));
this.mesh.frustumCulled = false;
this.geo = g;
}
spawn(x, y, z, vx, vy, vz, size, life, col, drag = 0.7, grav = -9.8, grow = 0, amax = 1) {
const i = this.cursor; this.cursor = (this.cursor + 1) % this.n;
const i3 = i * 3;
this.pos[i3] = x; this.pos[i3 + 1] = y; this.pos[i3 + 2] = z;
this.vel[i3] = vx; this.vel[i3 + 1] = vy; this.vel[i3 + 2] = vz;
this.size[i] = size; this.life[i] = life; this.max[i] = life;
this.alpha[i] = 0; this.drag[i] = drag; this.grav[i] = grav; this.grow[i] = grow;
this.amax[i] = amax;
this.color[i3] = col.r; this.color[i3 + 1] = col.g; this.color[i3 + 2] = col.b;
}
update(dt) {
const { pos, vel, life, max, alpha, size, drag, grav, grow, amax } = this;
for (let i = 0; i < this.n; i++) {
if (life[i] <= 0) continue;
life[i] -= dt;
const i3 = i * 3;
if (life[i] <= 0) { alpha[i] = 0; pos[i3 + 1] = -9999; continue; }
const dmp = Math.exp(-drag[i] * dt);
vel[i3] *= dmp; vel[i3 + 2] *= dmp;
vel[i3 + 1] = vel[i3 + 1] * dmp + grav[i] * dt;
pos[i3] += vel[i3] * dt;
pos[i3 + 1] += vel[i3 + 1] * dt;
pos[i3 + 2] += vel[i3 + 2] * dt;
const t = life[i] / max[i];
alpha[i] = amax[i] * Math.min(t / 0.25, (1 - t) / 0.05 + 0.15, 1);
if (grow[i]) size[i] += grow[i] * dt;
}
this.geo.attributes.position.needsUpdate = true;
this.geo.attributes.aAlpha.needsUpdate = true;
this.geo.attributes.aSize.needsUpdate = true;
this.geo.attributes.aColor.needsUpdate = true;
}
}
const fxSoft = new ParticlePool(5000, THREE.NormalBlending, false);
const fxFire = new ParticlePool(2200, THREE.AdditiveBlending, false);
scene.add(fxSoft.mesh, fxFire.mesh);
const C_SPRAY = new THREE.Color(0xdfeff7);
const C_FOAM = new THREE.Color(0xffffff);
const C_SMOKE = new THREE.Color(0x8d939c);
const C_FIRE = new THREE.Color(0xffb04a);
const C_HOT = new THREE.Color(0xfff2c0);
function splash(x, y, z, power = 1, spread = 1) {
const n = Math.min(90, Math.round(16 * power));
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const r = Math.random() * spread;
fxSoft.spawn(
x + Math.cos(a) * r, y + rand(0, 0.4), z + Math.sin(a) * r,
Math.cos(a) * rand(1, 5) * power, rand(3, 11) * power, Math.sin(a) * rand(1, 5) * power,
rand(0.3, 0.95) * (0.7 + power * 0.25), rand(0.5, 1.5),
Math.random() < 0.7 ? C_SPRAY : C_FOAM, 0.55, -13, 0.6, 0.8,
);
}
}
function explode(x, y, z, power = 1) {
for (let i = 0; i < Math.round(34 * power); i++) {
const a = Math.random() * Math.PI * 2, e = Math.random() * 1.2;
const s = rand(5, 22) * power;
fxFire.spawn(x, y, z,
Math.cos(a) * Math.cos(e) * s, Math.sin(e) * s * 1.2 + 3, Math.sin(a) * Math.cos(e) * s,
rand(1.6, 4.4) * power, rand(0.25, 0.75),
Math.random() < 0.45 ? C_HOT : C_FIRE, 2.2, -4, 2.5);
}
for (let i = 0; i < Math.round(26 * power); i++) {
const a = Math.random() * Math.PI * 2;
fxSoft.spawn(x, y + rand(0, 2), z,
Math.cos(a) * rand(1, 7), rand(2, 9), Math.sin(a) * rand(1, 7),
rand(2.5, 6) * power, rand(1.4, 3.2), C_SMOKE, 0.9, 0.9, 3.2, 0.5);
}
splash(x, Math.max(y - 1, waterHeight(x, z)), z, power * 1.4, 2.5 * power);
}
function muzzleFlash(p, dir) {
for (let i = 0; i < 22; i++) {
const s = rand(6, 26);
fxFire.spawn(p.x, p.y, p.z,
dir.x * s + rand(-3, 3), dir.y * s + rand(-2, 3), dir.z * s + rand(-3, 3),
rand(0.9, 2.6), rand(0.08, 0.28), Math.random() < 0.5 ? C_HOT : C_FIRE, 4.5, -2, 4);
}
for (let i = 0; i < 14; i++) {
fxSoft.spawn(p.x, p.y, p.z,
dir.x * rand(2, 9) + rand(-2, 2), rand(0, 4), dir.z * rand(2, 9) + rand(-2, 2),
rand(1.4, 3.4), rand(0.7, 1.6), C_SMOKE, 1.1, 0.5, 3.0, 0.45);
}
}
/* ═══════════════════════════════════════════════════════════════════════
6. BOAT GEOMETRY
The hull is lofted from parametric stations: pointed raked bow, full
midships, flat transom, with sheer rising fore and aft.
═══════════════════════════════════════════════════════════════════════ */
function buildHull(L, B, draft, free) {
const NS = 30, NC = 14;
// NOTE: both of these feed fractional Math.pow, so the bases are clamped
// to >= 0 — at the stem rounding makes `bow` ~-2e-16 and pow() returns NaN.
const halfBeam = (s) => {
const bow = Math.max(0, 1 - Math.max(0, (s - 0.58) / 0.42));
const stern = Math.min(1, 0.80 + s * 3.2);
return (B * 0.5) * Math.pow(bow, 0.72) * stern;
};
const dr = (s) => draft * (0.42 + 0.58 *
Math.pow(Math.max(0, Math.sin(Math.PI * Math.pow(clamp(s, 0, 1), 0.85))), 0.6));
const sheer = (s) => free * (1 + 0.42 * Math.pow(2 * s - 1, 2));
const rake = (s) => Math.max(0, (s - 0.72) / 0.28);
const pos = [], idx = [];
const gunwale = []; // [ [x,y,z] port/starboard pairs ] for the deck cap
for (let i = 0; i <= NS; i++) {
const s = i / NS;
const z = (s - 0.5) * L;
const hb = halfBeam(s), d = dr(s), fy = sheer(s), rk = rake(s);
for (let j = 0; j <= NC; j++) {
const u = j / NC;
const x = hb * Math.pow(Math.sin(u * Math.PI * 0.5), 0.58);
const y = -d * Math.pow(Math.cos(u * Math.PI * 0.5), 1.45) + fy * u;
const zz = z + rk * u * L * 0.085;
pos.push(x, y, zz);
if (j === NC) gunwale.push([x, y, zz]);
}
}
const row = NC + 1;
const vid = (i, j) => i * row + j;
for (let i = 0; i < NS; i++) {
for (let j = 0; j < NC; j++) {
idx.push(vid(i, j), vid(i + 1, j + 1), vid(i + 1, j));
idx.push(vid(i, j), vid(i, j + 1), vid(i + 1, j + 1));
}
}
const starboardCount = pos.length / 3;
// mirror to port
for (let v = 0; v < starboardCount; v++) pos.push(-pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]);
const tri = idx.length;
for (let t = 0; t < tri; t += 3) {
idx.push(idx[t] + starboardCount, idx[t + 2] + starboardCount, idx[t + 1] + starboardCount);
}
// transom: close the stern between the two halves
for (let j = 0; j < NC; j++) {
const a = vid(0, j), b = vid(0, j + 1);
idx.push(a, a + starboardCount, b);
idx.push(b, a + starboardCount, b + starboardCount);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx);
g.computeVertexNormals();
return { geo: g, gunwale };
}
function buildDeck(gunwale) {
const pos = [], idx = [];
const n = gunwale.length;
for (const [x, y, z] of gunwale) { pos.push(x, y, z); pos.push(-x, y, z); }
for (let i = 0; i < n - 1; i++) {
const a = i * 2, b = i * 2 + 1, c = (i + 1) * 2, d = (i + 1) * 2 + 1;
idx.push(a, b, c); idx.push(b, d, c);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx);
g.computeVertexNormals();
return g;
}
const MAT = {
hullPlayer: new THREE.MeshStandardMaterial({ color: 0x2a3d4e, roughness: 0.45, metalness: 0.32 }),
hullEnemy: new THREE.MeshStandardMaterial({ color: 0x3a2320, roughness: 0.62, metalness: 0.2 }),
hullElite: new THREE.MeshStandardMaterial({ color: 0x1c1c22, roughness: 0.34, metalness: 0.55 }),
deck: new THREE.MeshStandardMaterial({ color: 0x8a6a45, roughness: 0.82, metalness: 0.02 }),
deckDark: new THREE.MeshStandardMaterial({ color: 0x4a3a2c, roughness: 0.88, metalness: 0.02 }),
white: new THREE.MeshStandardMaterial({ color: 0xe6ebee, roughness: 0.55, metalness: 0.08 }),
metal: new THREE.MeshStandardMaterial({ color: 0x6d757c, roughness: 0.34, metalness: 0.85 }),
gun: new THREE.MeshStandardMaterial({ color: 0x35393d, roughness: 0.42, metalness: 0.8 }),
glass: new THREE.MeshStandardMaterial({ color: 0x0c1a22, roughness: 0.08, metalness: 0.5,
emissive: 0x0b1c26, emissiveIntensity: 0.4 }),
red: new THREE.MeshStandardMaterial({ color: 0x8c2118, roughness: 0.6, metalness: 0.1 }),
ball: new THREE.MeshStandardMaterial({ color: 0x1b1d20, roughness: 0.35, metalness: 0.9,
emissive: 0xff5a1e, emissiveIntensity: 0.55 }),
};
function shadowify(root) {
root.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
}
/** A swept tube through a polyline — robust where a spline would degenerate. */
function tubeAlong(pts, radius, seg) {
const pos = [], idx = [];
const T = new THREE.Vector3(), N = new THREE.Vector3(), Bn = new THREE.Vector3();
const UP = new THREE.Vector3(0, 1, 0);
for (let i = 0; i < pts.length; i++) {
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
T.subVectors(b, a);
if (T.lengthSq() < 1e-10) T.set(0, 0, 1);
T.normalize();
N.crossVectors(UP, T);
if (N.lengthSq() < 1e-8) N.set(1, 0, 0); else N.normalize();
Bn.crossVectors(T, N).normalize();
for (let j = 0; j < seg; j++) {
const a2 = (j / seg) * Math.PI * 2;
const c = Math.cos(a2) * radius, d = Math.sin(a2) * radius;
pos.push(pts[i].x + N.x * c + Bn.x * d,
pts[i].y + N.y * c + Bn.y * d,
pts[i].z + N.z * c + Bn.z * d);
}
}
for (let i = 0; i < pts.length - 1; i++) {
for (let j = 0; j < seg; j++) {
const j2 = (j + 1) % seg;
const a = i * seg + j, b = i * seg + j2;
const c = (i + 1) * seg + j, d = (i + 1) * seg + j2;
idx.push(a, c, b, b, c, d);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setIndex(idx);
g.computeVertexNormals();
return g;
}
/** Minimal position-only geometry merge, so railings cost one draw call. */
function mergeGeos(list) {
let vCount = 0, iCount = 0;
for (const g of list) { vCount += g.attributes.position.count; iCount += g.index.count; }
const pos = new Float32Array(vCount * 3);
const nrm = new Float32Array(vCount * 3);
const idx = new Uint32Array(iCount);
let vo = 0, io = 0;
for (const g of list) {
pos.set(g.attributes.position.array, vo * 3);
if (g.attributes.normal) nrm.set(g.attributes.normal.array, vo * 3);
const gi = g.index.array;
for (let i = 0; i < gi.length; i++) idx[io + i] = gi[i] + vo;
vo += g.attributes.position.count; io += gi.length;
}
const out = new THREE.BufferGeometry();
out.setAttribute('position', new THREE.BufferAttribute(pos, 3));
out.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
out.setIndex(new THREE.BufferAttribute(idx, 1));
return out;
}
/** Builds a complete vessel. kind: 'player' | 'raider' | 'elite' */
function buildBoat(kind) {
const spec = {
player: { L: 13.5, B: 4.4, draft: 1.5, free: 2.0, hull: MAT.hullPlayer, deck: MAT.deck },
raider: { L: 11.4, B: 3.9, draft: 1.3, free: 1.8, hull: MAT.hullEnemy, deck: MAT.deckDark },
elite: { L: 16.0, B: 5.0, draft: 1.7, free: 2.3, hull: MAT.hullElite, deck: MAT.deckDark },
}[kind];
const root = new THREE.Group();
const { geo, gunwale } = buildHull(spec.L, spec.B, spec.draft, spec.free);
const hull = new THREE.Mesh(geo, spec.hull);
root.add(hull);
const deck = new THREE.Mesh(buildDeck(gunwale), spec.deck);
root.add(deck);
// deckhouse, sitting on the sheer aft of amidships
const deckY = spec.free * 1.02;
const cabinW = spec.B * 0.60, cabinH = spec.L * 0.155, cabinL = spec.L * 0.29;
const cabinZ = -spec.L * 0.11;
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(cabinW, cabinH, cabinL),
kind === 'player' ? MAT.white : MAT.hullEnemy,
);
cabin.position.set(0, deckY + cabinH * 0.5, cabinZ);
root.add(cabin);
const wind = new THREE.Mesh(
new THREE.BoxGeometry(cabinW * 1.015, cabinH * 0.38, cabinL * 1.015), MAT.glass);
wind.position.set(0, deckY + cabinH * 0.74, cabinZ);
root.add(wind);
const roof = new THREE.Mesh(new THREE.BoxGeometry(cabinW * 1.1, 0.11, cabinL * 1.1), MAT.metal);
roof.position.set(0, deckY + cabinH + 0.05, cabinZ);
root.add(roof);
// mast + radar bar
const mastH = spec.L * 0.30;
const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.085, mastH, 8), MAT.metal);
mast.position.set(0, deckY + cabinH + mastH * 0.5, cabinZ);
root.add(mast);
const radar = new THREE.Mesh(new THREE.BoxGeometry(spec.B * 0.30, 0.07, 0.16), MAT.white);
radar.position.set(0, deckY + cabinH + mastH * 0.94, cabinZ);
root.add(radar);
// railings along the sheer
const railPts = [];
for (let side = -1; side <= 1; side += 2) {
const pts = [];
for (let i = 2; i < gunwale.length - 1; i++) {
const [x, y, z] = gunwale[i];
const v = new THREE.Vector3(x * side, y + 0.42, z);
if (!pts.length || v.distanceTo(pts[pts.length - 1]) > 0.06) pts.push(v);
}
if (pts.length > 1) railPts.push(pts);
}
const railGeos = [];
for (const pts of railPts) {
railGeos.push(tubeAlong(pts, 0.035, 6));
for (let i = 0; i < pts.length; i += 4) {
const post = new THREE.CylinderGeometry(0.03, 0.03, 0.42, 5);
post.translate(pts[i].x, pts[i].y - 0.21, pts[i].z);
railGeos.push(post);
}
}
if (railGeos.length) root.add(new THREE.Mesh(mergeGeos(railGeos), MAT.metal));
// rub rail along the sheer line
const rubGeos = [];
for (let side = -1; side <= 1; side += 2) {
const pts = [];
for (let i = 1; i < gunwale.length; i++) {
const [x, y, z] = gunwale[i];
const v = new THREE.Vector3(x * side * 1.01, y - 0.14, z);
if (!pts.length || v.distanceTo(pts[pts.length - 1]) > 0.06) pts.push(v);
}
if (pts.length > 1) rubGeos.push(tubeAlong(pts, 0.085, 6));
}
if (rubGeos.length) {
root.add(new THREE.Mesh(mergeGeos(rubGeos), kind === 'player' ? MAT.red : MAT.white));
}
// ---- the gun: yaw ring + pitching barrel -----------------------------
const turret = new THREE.Group();
turret.position.set(0, spec.free * 1.06 + 0.22, spec.L * 0.26);
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.78, 0.42, 16), MAT.metal);
turret.add(base);
const shield = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.55, 0.9, 14, 1, false, -1.2, 2.4), MAT.gun);
shield.position.y = 0.5;
turret.add(shield);
const pitch = new THREE.Group();
pitch.position.y = 0.55;
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.115, 0.145, 3.3, 12), MAT.gun);
barrel.rotation.x = Math.PI / 2;
barrel.position.z = 1.5;
pitch.add(barrel);
const brake = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.17, 0.42, 12), MAT.gun);
brake.rotation.x = Math.PI / 2;
brake.position.z = 3.0;
pitch.add(brake);
turret.add(pitch);
root.add(turret);
shadowify(root);
return {
root, turret, pitch,
muzzleLocal: new THREE.Vector3(0, 0.55, 3.2),
spec,
};
}
/* ═══════════════════════════════════════════════════════════════════════
7. WAKE RIBBON
A fading foam trail that rides the wave surface behind each hull.
═══════════════════════════════════════════════════════════════════════ */
const WAKE_VERT = /* glsl */`
attribute float aAlpha;
varying float vA; varying vec2 vUv;
void main(){ vA = aAlpha; vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }
`;
const WAKE_FRAG = /* glsl */`
varying float vA; varying vec2 vUv;
void main(){
float edge = smoothstep(0.0, 0.42, vUv.x) * smoothstep(1.0, 0.58, vUv.x);
float a = vA * edge;
if (a < 0.004) discard;
gl_FragColor = vec4(vec3(0.94, 0.97, 1.0), a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
class Wake {
constructor(width = 3.2, maxPts = 64, lifespan = 7) {
this.maxPts = maxPts; this.lifespan = lifespan; this.width = width;
this.pts = [];
this.pos = new Float32Array(maxPts * 2 * 3);
this.uv = new Float32Array(maxPts * 2 * 2);
this.alpha = new Float32Array(maxPts * 2);
const idx = [];
for (let i = 0; i < maxPts - 1; i++) {
const a = i * 2;
idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
}
for (let i = 0; i < maxPts; i++) { this.uv[i * 4] = 0; this.uv[i * 4 + 2] = 1; }
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
g.setAttribute('uv', new THREE.BufferAttribute(this.uv, 2));
g.setAttribute('aAlpha', new THREE.BufferAttribute(this.alpha, 1));
g.setIndex(idx);
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
this.geo = g;
this.mesh = new THREE.Mesh(g, new THREE.ShaderMaterial({
vertexShader: WAKE_VERT, fragmentShader: WAKE_FRAG,
transparent: true, depthWrite: false, side: THREE.DoubleSide,
}));
this.mesh.frustumCulled = false;
this.mesh.renderOrder = 2;
}
reset() { this.pts.length = 0; this.geo.setDrawRange(0, 0); }
add(x, z, strength) {
const p = this.pts;
if (p.length && Math.hypot(x - p[p.length - 1].x, z - p[p.length - 1].z) < 1.1) {
p[p.length - 1].s = Math.max(p[p.length - 1].s, strength);
return;
}
p.push({ x, z, age: 0, s: strength });
if (p.length > this.maxPts) p.shift();
}
update(dt) {
const p = this.pts;
for (let i = p.length - 1; i >= 0; i--) {
p[i].age += dt;
if (p[i].age > this.lifespan) { p.splice(0, i + 1); break; }
}
const n = p.length;
if (n < 2) { this.geo.setDrawRange(0, 0); return; }
for (let i = 0; i < n; i++) {
const a = p[Math.max(0, i - 1)], b = p[Math.min(n - 1, i + 1)];
let dx = b.x - a.x, dz = b.z - a.z;
const l = Math.hypot(dx, dz) || 1;
dx /= l; dz /= l;
const px = -dz, pz = dx;
const t = p[i].age / this.lifespan;
const w = this.width * p[i].s * (0.35 + t * 1.5);
const y = waterHeight(p[i].x, p[i].z) + 0.09;
const i6 = i * 6;
this.pos[i6] = p[i].x - px * w; this.pos[i6 + 1] = y; this.pos[i6 + 2] = p[i].z - pz * w;
this.pos[i6 + 3] = p[i].x + px * w; this.pos[i6 + 4] = y; this.pos[i6 + 5] = p[i].z + pz * w;
const fade = (1 - t) * (1 - t) * (0.35 + 0.65 * p[i].s) * Math.min(1, i / 2);
this.alpha[i * 2] = fade; this.alpha[i * 2 + 1] = fade;
}
this.geo.attributes.position.needsUpdate = true;
this.geo.attributes.aAlpha.needsUpdate = true;
this.geo.setDrawRange(0, (n - 1) * 6);
}
}
/* ═══════════════════════════════════════════════════════════════════════
8. VESSEL — rigid body with sampled hydrostatics
Buoyancy is integrated over probe points on the hull; each probe gets
its own submergence, so the boat pitches, rolls and surfs for real.
═══════════════════════════════════════════════════════════════════════ */
const _v1 = new THREE.Vector3(), _v2 = new THREE.Vector3(), _v3 = new THREE.Vector3();
const _rt = new THREE.Vector3();
const _q1 = new THREE.Quaternion(), _m3 = new THREE.Matrix4();
class Vessel {
constructor(kind) {
const b = buildBoat(kind);
Object.assign(this, b);
this.kind = kind;
const { L, B, draft } = this.spec;
this.L = L; this.B = B;
this.mass = 900 * L * B * draft * 0.16 + 4200;
this.invI = new THREE.Vector3(
1 / (this.mass * L * L / 12 * 0.85), // pitch (about local X)
1 / (this.mass * L * L / 12 * 1.15), // yaw (about local Y)
1 / (this.mass * B * B / 12 * 2.4), // roll (about local Z)
);
this.pos = new THREE.Vector3();
this.vel = new THREE.Vector3();
this.quat = new THREE.Quaternion();
this.angVel = new THREE.Vector3();
// hull probes: two rows down the length plus keel fore/aft
this.probes = [];
for (let i = 0; i < 6; i++) {
const s = 0.09 + (i / 5) * 0.82;
const z = (s - 0.5) * L;
const bow = 1 - Math.max(0, (s - 0.58) / 0.42);
const hw = Math.max(0.12, B * 0.36 * Math.pow(bow, 0.72));
this.probes.push(new THREE.Vector3(-hw, -draft * 0.55, z));
this.probes.push(new THREE.Vector3( hw, -draft * 0.55, z));
}
this.probes.push(new THREE.Vector3(0, -draft * 0.92, -L * 0.34));
this.probes.push(new THREE.Vector3(0, -draft * 0.75, L * 0.34));
this.eqDepth = draft * 0.50;
this.buoyK = (this.mass * G) / (this.probes.length * this.eqDepth);
this.maxThrust = kind === 'elite' ? 24500 : kind === 'raider' ? 19500 : 21000;
this.throttle = 0;
this.rudder = 0;
this.hp = kind === 'player' ? 100 : kind === 'elite' ? 90 : 45;
this.maxHp = this.hp;
this.alive = true;
this.sinkT = 0;
this.submerged = 0;
this.slamCool = 0;
this.lastBowY = 0;
this.wake = new Wake(kind === 'elite' ? 4 : 3.1);
this._probeWorld = this.probes.map(() => new THREE.Vector3());
}
addTo(s) { s.add(this.root); s.add(this.wake.mesh); }
removeFrom(s) { s.remove(this.root); s.remove(this.wake.mesh); }
localToWorld(v, out) {
return out.copy(v).applyQuaternion(this.quat).add(this.pos);
}
/** velocity of a point given by world-space offset r from the CG */
pointVel(r, out) {
return out.copy(this.angVel).cross(r).add(this.vel);
}
step(dt) {
if (!this.alive) { this.sink(dt); return; }
const force = _v1.set(0, -this.mass * G, 0);
const torque = _v2.set(0, 0, 0);
const n = this.probes.length;
let subCount = 0;
const r = new THREE.Vector3(), pv = new THREE.Vector3(), f = new THREE.Vector3();
for (let i = 0; i < n; i++) {
const wp = this._probeWorld[i];
wp.copy(this.probes[i]).applyQuaternion(this.quat);
r.copy(wp);
wp.add(this.pos);
const h = waterHeight(wp.x, wp.z);
const depth = h - wp.y;
if (depth <= 0) continue;
subCount++;
const d = Math.min(depth, this.eqDepth * 2.6);
f.set(0, this.buoyK * d, 0);
// vertical damping at the probe — this is what kills the bobbing
this.pointVel(r, pv);
f.y -= pv.y * 1500 * Math.min(1, d / this.eqDepth);
force.add(f);
torque.add(_v3.copy(r).cross(f));
}
const sub = subCount / n;
this.submerged = sub;
if (sub > 0.02) {
// --- directional hydrodynamic drag in the hull frame
const vl = _v3.copy(this.vel).applyQuaternion(_q1.copy(this.quat).invert());
const dragL = new THREE.Vector3(
-Math.abs(vl.x) * vl.x * 900 * sub,
-Math.abs(vl.y) * vl.y * 700 * sub,
-Math.abs(vl.z) * vl.z * 62 * sub,
);
force.add(dragL.applyQuaternion(this.quat));
// --- propulsion off the transom
const fwd = _v3.set(0, 0, 1).applyQuaternion(this.quat);
const thrust = this.throttle * this.maxThrust * sub;
force.addScaledVector(fwd, thrust);
// --- rudder: torque scales with flow over the blade
const speed = this.vel.length();
const authority = 0.28 + 0.72 * Math.min(1, speed / 5.5);
const up = _v1.clone().set(0, 1, 0).applyQuaternion(this.quat);
torque.addScaledVector(up, -this.rudder * this.mass * 17.0 * authority * sub);
// --- the sea shoves you: gravity component along the wave face
const nrm = waterNormal(this.pos.x, this.pos.z);
force.x += nrm.x * this.mass * G * 0.26 * sub;
force.z += nrm.z * this.mass * G * 0.26 * sub;
// --- heel into the turn
const fw2 = _v3.set(0, 0, 1).applyQuaternion(this.quat);
torque.addScaledVector(fw2, this.rudder * speed * this.mass * 0.062 * sub);
}
// --- ballast keel: righting couple proportional to heel angle.
// Form stability alone lets a knockdown become a capsize, and an
// inverted hull has its rudder torque reversed — it sails away forever.
{
const bUp = _v3.set(0, 1, 0).applyQuaternion(this.quat);
const angle = Math.acos(clamp(bUp.y, -1, 1));
if (angle > 0.004) {
_rt.set(-bUp.z, 0, bUp.x); // cross(bUp, worldUp)
if (_rt.lengthSq() < 1e-9) _rt.set(1, 0, 0);
_rt.normalize();
torque.addScaledVector(_rt, this.mass * 11.0 * angle * Math.max(0.22, sub));
}
}
// integrate linear
this.vel.addScaledVector(force, dt / this.mass);
this.vel.multiplyScalar(Math.exp(-0.12 * dt));
this.pos.addScaledVector(this.vel, dt);
// integrate angular (local-frame inertia)
const tl = _v3.copy(torque).applyQuaternion(_q1.copy(this.quat).invert());
tl.x *= this.invI.x; tl.y *= this.invI.y; tl.z *= this.invI.z;
this.angVel.addScaledVector(tl.applyQuaternion(this.quat), dt);
this.angVel.multiplyScalar(Math.exp(-2.1 * dt));
_q1.set(this.angVel.x * dt * 0.5, this.angVel.y * dt * 0.5, this.angVel.z * dt * 0.5, 0)
.multiply(this.quat);
this.quat.set(
this.quat.x + _q1.x, this.quat.y + _q1.y,
this.quat.z + _q1.z, this.quat.w + _q1.w,
).normalize();
this.root.position.copy(this.pos);
this.root.quaternion.copy(this.quat);
this.spray(dt);
this.wake.add(this.pos.x, this.pos.z,
clamp(this.vel.length() / 9, 0.12, 1) * (0.4 + sub * 0.6));
this.wake.update(dt);
}
/** bow spray when the hull slams a wave, plus a constant bow wave */
spray(dt) {
this.slamCool -= dt;
const bow = this.localToWorld(_v1.set(0, -0.2, this.L * 0.44), _v2.clone());
const h = waterHeight(bow.x, bow.z);
const rel = h - bow.y;
const spd = this.vel.length();
if (rel > 0.12 && this.slamCool <= 0 && this.lastBowY - rel < -0.25) {
const p = Math.min(2.4, 0.6 + rel * 0.9 + spd * 0.09);
splash(bow.x, h, bow.z, p, 1.3);
this.slamCool = 0.14;
if (this === player && p > 1.3) { shake(p * 0.55); sfx.slam(p); }
}
this.lastBowY = rel;
// bow wave: foam peeling off both shoulders
if (spd > 1.8) {
let rate = dt * (16 + spd * 8);
while (rate > 0) {
if (rate < 1 && Math.random() > rate) break;
rate -= 1;
const side = Math.random() < 0.5 ? -1 : 1;
const t = 0.18 + Math.random() * 0.26;
const q = this.localToWorld(
_v1.set(side * this.B * (0.30 + t * 0.35), 0.0, this.L * t), _v2.clone());
const wy = waterHeight(q.x, q.z);
fxSoft.spawn(q.x, wy + 0.05, q.z,
side * rand(1.2, 3.8) + this.vel.x * 0.3, rand(0.5, 1.4 + spd * 0.18),
this.vel.z * 0.3 + rand(-1, 1),
rand(0.3, 0.85), rand(0.6, 1.3), C_FOAM, 1.0, -7.5, 0.7, 0.6);
}
// propeller wash off the transom
if (Math.random() < dt * 22 * Math.abs(this.throttle)) {
const q = this.localToWorld(_v1.set(rand(-0.7, 0.7), -0.3, -this.L * 0.48), _v2.clone());
const wy = waterHeight(q.x, q.z);
fxSoft.spawn(q.x, wy + 0.05, q.z, rand(-1.5, 1.5), rand(0.4, 2.2), rand(-1.5, 1.5),
rand(0.4, 1.0), rand(0.7, 1.4), C_FOAM, 1.1, -6.5, 0.9, 0.55);
}
}
}
sink(dt) {
this.sinkT += dt;
this.pos.y -= dt * (0.7 + this.sinkT * 0.45);
this.pos.addScaledVector(this.vel, dt);
this.vel.multiplyScalar(Math.exp(-1.4 * dt));
_q1.setFromAxisAngle(_v1.set(0, 0, 1), dt * 0.55 * this.sinkDir);
this.quat.multiply(_q1);
_q1.setFromAxisAngle(_v1.set(1, 0, 0), dt * 0.18);
this.quat.multiply(_q1);
this.root.position.copy(this.pos);
this.root.quaternion.copy(this.quat);
if (Math.random() < dt * 14) {
const wy = waterHeight(this.pos.x, this.pos.z);
fxSoft.spawn(this.pos.x + rand(-2, 2), wy + 0.2, this.pos.z + rand(-3, 3),
rand(-1, 1), rand(1, 4), rand(-1, 1), rand(1.5, 3.6), rand(1, 2.4), C_SMOKE, 0.8, 1.1, 2.2, 0.55);
}
this.wake.update(dt);
}
damage(amount, at) {
if (!this.alive) return;
this.hp -= amount;
if (at) explode(at.x, at.y, at.z, 0.7);
if (this.hp <= 0) {
this.hp = 0; this.alive = false;
this.sinkDir = Math.random() < 0.5 ? -1 : 1;
explode(this.pos.x, this.pos.y + 1, this.pos.z, 1.9);
sfx.boom(this === player ? 1.4 : 1.0, this.pos.distanceTo(camera.position));
}
}
}
/* ═══════════════════════════════════════════════════════════════════════
9. GUNNERY — ballistic shells
Shells are launched with the elevation that actually lands them on the
aim point, then flown under gravity. Lead your targets.
═══════════════════════════════════════════════════════════════════════ */
const SHELL_SPEED = 78;
function solveBallistic(from, to, speed, out) {
const dx = Math.hypot(to.x - from.x, to.z - from.z);
const dy = to.y - from.y;
const v2 = speed * speed;
const disc = v2 * v2 - G * (G * dx * dx + 2 * dy * v2);
const angle = disc < 0 ? Math.PI / 4 : Math.atan2(v2 - Math.sqrt(disc), G * dx);
const ux = (to.x - from.x) / (dx || 1), uz = (to.z - from.z) / (dx || 1);
const c = Math.cos(angle) * speed;
return out.set(ux * c, Math.sin(angle) * speed, uz * c);
}
const shellGeo = new THREE.SphereGeometry(0.28, 10, 8);
const shells = [];
function fireShell(owner, from, vel, dmg) {
const mesh = new THREE.Mesh(shellGeo, MAT.ball);
mesh.castShadow = true;
mesh.position.copy(from);
scene.add(mesh);
shells.push({
mesh, owner, dmg, life: 9,
vel: vel.clone(), pos: from.clone(), trail: 0,
});
}
function updateShells(dt) {
for (let i = shells.length - 1; i >= 0; i--) {
const s = shells[i];
s.life -= dt;
s.vel.y -= G * dt;
s.vel.multiplyScalar(Math.exp(-0.06 * dt));
s.pos.addScaledVector(s.vel, dt);
s.mesh.position.copy(s.pos);
s.trail -= dt;
if (s.trail <= 0) {
s.trail = 0.05;
fxSoft.spawn(s.pos.x, s.pos.y, s.pos.z, rand(-.3, .3), rand(0, .6), rand(-.3, .3),
rand(0.25, 0.55), rand(0.5, 1.0), C_SMOKE, 1.2, 0.5, 1.1, 0.24);
}
let dead = s.life <= 0;
if (!dead) {
for (const v of fleet) {
if (v === s.owner || !v.alive) continue;
_v1.copy(s.pos).sub(v.pos);
if (_v1.lengthSq() > 900) continue;
_v1.applyQuaternion(_q1.copy(v.quat).invert());
const e = (_v1.x / (v.B * 0.72)) ** 2 + (_v1.y / 3.2) ** 2 + (_v1.z / (v.L * 0.52)) ** 2;
if (e < 1) {
v.damage(s.dmg, s.pos);
if (v === player) { hurtFlash(); shake(1.5); }
else if (s.owner === player) { score += 15; popHit(); }
dead = true;
break;
}
}
}
if (!dead && s.pos.y < waterHeight(s.pos.x, s.pos.z)) {
const wy = waterHeight(s.pos.x, s.pos.z);
splash(s.pos.x, wy, s.pos.z, 1.5, 1.1);
sfx.splash(camera.position.distanceTo(s.pos));
dead = true;
}
if (dead) { scene.remove(s.mesh); shells.splice(i, 1); }
}
}
/* ═══════════════════════════════════════════════════════════════════════
10. AUDIO — everything synthesised, no assets
═══════════════════════════════════════════════════════════════════════ */
const sfx = {
ctx: null, master: null, noiseBuf: null, engineGain: null, engineOsc: null, on: false,
init() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.55;
this.master.connect(this.ctx.destination);
const len = this.ctx.sampleRate * 2;
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = this.noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
// ocean bed: looping filtered noise with a slow swell
const surf = this.ctx.createBufferSource();
surf.buffer = this.noiseBuf; surf.loop = true;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 460; lp.Q.value = 0.7;
const sg = this.ctx.createGain(); sg.gain.value = 0.12;
const lfo = this.ctx.createOscillator(); lfo.frequency.value = 0.09;
const lfoG = this.ctx.createGain(); lfoG.gain.value = 0.055;
lfo.connect(lfoG); lfoG.connect(sg.gain);
surf.connect(lp); lp.connect(sg); sg.connect(this.master);
surf.start(); lfo.start();
// engine
const eo = this.ctx.createOscillator();
eo.type = 'sawtooth'; eo.frequency.value = 42;
const ef = this.ctx.createBiquadFilter();
ef.type = 'lowpass'; ef.frequency.value = 220;
const eg = this.ctx.createGain(); eg.gain.value = 0;
eo.connect(ef); ef.connect(eg); eg.connect(this.master);
eo.start();
this.engineGain = eg; this.engineOsc = eo;
this.on = true;
},
noise(dur, freq, q, gain, type = 'lowpass') {
if (!this.on) return;
const t = this.ctx.currentTime;
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuf;
src.playbackRate.value = rand(0.8, 1.2);
const f = this.ctx.createBiquadFilter();
f.type = type; f.frequency.value = freq; f.Q.value = q;
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t); src.stop(t + dur + 0.05);
return f;
},
shot() {
if (!this.on) return;
const t = this.ctx.currentTime;
const f = this.noise(0.55, 1400, 0.6, 0.55);
if (f) { f.frequency.setValueAtTime(1600, t); f.frequency.exponentialRampToValueAtTime(90, t + 0.4); }
const o = this.ctx.createOscillator();
o.type = 'sine'; o.frequency.setValueAtTime(140, t);
o.frequency.exponentialRampToValueAtTime(34, t + 0.35);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.65, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.45);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 0.5);
},
boom(power = 1, dist = 0) {
if (!this.on) return;
const att = clamp(1 - dist / 420, 0.08, 1);
const t = this.ctx.currentTime;
const f = this.noise(1.3 * power, 700, 0.5, 0.75 * att);
if (f) { f.frequency.setValueAtTime(900, t); f.frequency.exponentialRampToValueAtTime(55, t + 0.9); }
const o = this.ctx.createOscillator();
o.type = 'sine'; o.frequency.setValueAtTime(95, t);
o.frequency.exponentialRampToValueAtTime(22, t + 0.8);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.85 * att, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 1.1);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t + 1.2);
},
splash(dist = 0) { this.noise(0.45, 2600, 0.8, clamp(0.28 - dist / 900, 0.02, 0.28), 'bandpass'); },
slam(p = 1) { this.noise(0.6, 900, 0.5, clamp(0.18 * p, 0.03, 0.4)); },
engine(load) {
if (!this.on) return;
this.engineGain.gain.value = 0.02 + load * 0.075;
this.engineOsc.frequency.value = 38 + load * 46;
},
};
/* ═══════════════════════════════════════════════════════════════════════
11. INPUT
═══════════════════════════════════════════════════════════════════════ */
const keys = Object.create(null);
const mouse = { x: innerWidth / 2, y: innerHeight / 2, down: false };
const ndc = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
/**
* Register a key under BOTH its layout-dependent value (e.key: 'w') and its
* physical position (e.code: 'KeyW'). On AZERTY/QWERTZ/Dvorak the WASD keys
* report entirely different characters, so binding to e.key alone leaves
* those players unable to steer at all.
*/
function keyIds(e, out) {
out.length = 0;
if (e.key) out.push(e.key.toLowerCase());
if (e.code) out.push(e.code);
return out;
}
const _ids = [];
const down = (...ids) => { for (const i of ids) if (keys[i]) return true; return false; };
function clearKeys() { for (const k in keys) keys[k] = false; mouse.down = false; }
addEventListener('keydown', e => {
for (const id of keyIds(e, _ids)) keys[id] = true;
if (e.code === 'Space' || e.key === ' ') e.preventDefault();
if (e.repeat) return;
if ((e.code === 'KeyP' || e.key.toLowerCase() === 'p') && state === 'play') paused = !paused;
});
addEventListener('keyup', e => { for (const id of keyIds(e, _ids)) keys[id] = false; });
addEventListener('blur', clearKeys);
addEventListener('mousemove', e => {
mouse.x = e.clientX; mouse.y = e.clientY;
crossEl.style.transform = `translate(${e.clientX}px,${e.clientY}px)`;
});
addEventListener('mousedown', e => {
if (e.button === 0) mouse.down = true;
try { window.focus(); } catch (_) {}
});
addEventListener('mouseup', e => { if (e.button === 0) mouse.down = false; });
addEventListener('contextmenu', e => e.preventDefault());
/**
* Where is the player pointing?
* 1. a hull under the cursor wins (snap the gun onto the target), otherwise
* 2. march the ray until it breaks the surface, then bisect.
* Marching beats Newton here: at 200 m the sight line is nearly horizontal,
* so a derivative-based solve slides hundreds of metres per iteration.
*/
const aimPoint = new THREE.Vector3();
let aimLocked = false;
function surfaceHit(o, d, maxT) {
const MARCH = 44;
let prevT = 0.6;
let prev = (o.y + d.y * prevT) - waterHeight(o.x + d.x * prevT, o.z + d.z * prevT, sea.time, 2);
if (prev <= 0) return prevT;
for (let i = 1; i <= MARCH; i++) {
const t = maxT * Math.pow(i / MARCH, 2.0);
const diff = (o.y + d.y * t) - waterHeight(o.x + d.x * t, o.z + d.z * t, sea.time, 2);
if (diff <= 0) {
let lo = prevT, hi = t;
for (let k = 0; k < 12; k++) {
const m = (lo + hi) * 0.5;
const md = (o.y + d.y * m) - waterHeight(o.x + d.x * m, o.z + d.z * m, sea.time, 2);
if (md > 0) lo = m; else hi = m;
}
return (lo + hi) * 0.5;
}
prevT = t; prev = diff;
}
return -1;
}
const _ray = new THREE.Vector3();
function updateAimPoint() {
ndc.set((mouse.x / innerWidth) * 2 - 1, -(mouse.y / innerHeight) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
const o = raycaster.ray.origin, d = raycaster.ray.direction;
const tw = surfaceHit(o, d, 900);
let best = tw > 0 ? tw : Infinity;
let target = null;
for (const e of enemies) {
if (!e.alive) continue;
_ray.copy(e.pos).sub(o);
const along = _ray.dot(d);
if (along < 4 || along > best + e.L) continue;
const perp = Math.sqrt(Math.max(0, _ray.lengthSq() - along * along));
if (perp < Math.max(7.0, e.L * 0.55)) { best = along; target = e; }
}
aimLocked = !!target;
if (target) { aimPoint.copy(target.pos); aimPoint.y += 0.8; return true; }
if (tw > 0) {
aimPoint.copy(o).addScaledVector(d, tw);
return true;
}
aimPoint.copy(o).addScaledVector(d, 900);
aimPoint.y = waterHeight(aimPoint.x, aimPoint.z);
return false;
}
/* ═══════════════════════════════════════════════════════════════════════
12. GAME STATE · PLAYER · ENEMY AI
═══════════════════════════════════════════════════════════════════════ */
let state = 'menu'; // menu | play | over
let paused = false;
let score = 0, kills = 0, waveNo = 0, waveTimer = 0;
let heat = 0, gunCool = 0, overheated = false;
let player = null;
const fleet = []; // every live vessel, player included
const enemies = [];
const shakeState = { amt: 0 };
function shake(a) { shakeState.amt = Math.min(2.6, shakeState.amt + a); }
function spawnPlayer() {
player = new Vessel('player');
player.pos.set(0, 0.6, 0);
player.addTo(scene);
fleet.push(player);
}
function spawnEnemy(kind, angle, dist) {
const e = new Vessel(kind);
e.pos.set(
player.pos.x + Math.cos(angle) * dist, 0.6,
player.pos.z + Math.sin(angle) * dist,
);
e.quat.setFromAxisAngle(_v1.set(0, 1, 0),
Math.atan2(-Math.cos(angle), -Math.sin(angle)));
e.ai = {
cool: rand(1.5, 4),
orbit: Math.random() < 0.5 ? 1 : -1,
range: kind === 'elite' ? rand(70, 95) : rand(45, 80),
accuracy: kind === 'elite' ? 3.2 : 6.5,
rof: kind === 'elite' ? 2.4 : 3.6,
dmg: kind === 'elite' ? 16 : 11,
turretYaw: 0, turretPitch: 0,
};
e.addTo(scene);
fleet.push(e); enemies.push(e);
return e;
}
function startWave() {
waveNo++;
const count = Math.min(9, 1 + Math.floor(waveNo * 0.8));
const elites = waveNo >= 3 ? Math.min(3, Math.floor((waveNo - 1) / 3)) : 0;
for (let i = 0; i < count; i++) {
const a = (i / count) * Math.PI * 2 + rand(-0.4, 0.4);
spawnEnemy(i < elites ? 'elite' : 'raider', a, rand(150, 230));
}
waveTimer = 5;
showAlert(`WAVE ${waveNo} — ${count} CONTACTS`, 2.4, '#ffcf4d');
}
/* ---------- player control ---------- */
const _aimDir = new THREE.Vector3(), _muzzle = new THREE.Vector3(), _shotV = new THREE.Vector3();
function controlPlayer(dt) {
const p = player;
if (!p.alive) return;
const fwd = down('w', 'KeyW', 'arrowup', 'ArrowUp') ? 1 : 0;
const back = down('s', 'KeyS', 'arrowdown', 'ArrowDown') ? 1 : 0;
const anchor = down('x', 'KeyX');
let target = fwd - back * 0.75;
if (anchor) target = 0;
p.throttle = damp(p.throttle, target, 2.2, dt);
if (anchor) p.vel.multiplyScalar(Math.exp(-1.6 * dt));
const left = down('a', 'KeyA', 'arrowleft', 'ArrowLeft') ? 1 : 0;
const right = down('d', 'KeyD', 'arrowright', 'ArrowRight') ? 1 : 0;
p.rudder = damp(p.rudder, right - left, 6.5, dt);
// --- traverse the gun toward the cursor
const onWater = updateAimPoint();
p.root.updateMatrixWorld(true);
p.turret.getWorldPosition(_muzzle);
solveBallistic(_muzzle, aimPoint, SHELL_SPEED, _aimDir);
const local = _v1.copy(_aimDir).applyQuaternion(_q1.copy(p.quat).invert());
const wantYaw = Math.atan2(local.x, local.z);
const wantPitch = Math.asin(clamp(local.y / (local.length() || 1), -1, 1));
let dy = wantYaw - p.turret.rotation.y;
while (dy > Math.PI) dy -= Math.PI * 2;
while (dy < -Math.PI) dy += Math.PI * 2;
p.turret.rotation.y += clamp(dy, -3.4 * dt, 3.4 * dt);
p.pitch.rotation.x = damp(p.pitch.rotation.x, -clamp(wantPitch, -0.25, 1.05), 9, dt);
// --- fire
gunCool -= dt;
heat = Math.max(0, heat - dt * (overheated ? 42 : 24));
if (overheated && heat <= 2) overheated = false;
const firing = mouse.down || down(' ', 'Space');
if (firing && gunCool <= 0 && !overheated && onWater) {
gunCool = 0.42;
heat += 12;
if (heat >= 100) { heat = 100; overheated = true; showAlert('GUN OVERHEATED', 1.2, '#ff6a3d'); }
p.root.updateMatrixWorld(true);
p.pitch.getWorldPosition(_muzzle);
const dir = _v3.set(0, 0, 1).applyQuaternion(p.pitch.getWorldQuaternion(_q1)).normalize();
_muzzle.addScaledVector(dir, 3.3);
_shotV.copy(dir).multiplyScalar(SHELL_SPEED).add(p.vel);
fireShell(p, _muzzle, _shotV, 24);
muzzleFlash(_muzzle, dir);
p.vel.addScaledVector(dir, -0.35);
p.angVel.addScaledVector(_v2.set(0, 0, 1).applyQuaternion(p.quat), -dir.x * 0.03);
shake(0.55);
sfx.shot();
}
}
/* ---------- enemy behaviour ---------- */
const _to = new THREE.Vector3(), _lead = new THREE.Vector3();
function controlEnemy(e, dt) {
if (!e.alive || !player.alive) { e.throttle = damp(e.throttle, 0, 1, dt); return; }
const ai = e.ai;
_to.copy(player.pos).sub(e.pos);
const dist = _to.length();
// steer: close to preferred range, then orbit
const bearing = Math.atan2(_to.x, _to.z);
const tangent = bearing + ai.orbit * Math.PI * 0.42;
const closing = dist > ai.range * 1.15;
const backing = dist < ai.range * 0.55;
const want = closing ? bearing : backing ? bearing + Math.PI : tangent;
const yaw = Math.atan2(
2 * (e.quat.w * e.quat.y + e.quat.x * e.quat.z),
1 - 2 * (e.quat.y * e.quat.y + e.quat.x * e.quat.x),
);
let err = want - yaw;
while (err > Math.PI) err -= Math.PI * 2;
while (err < -Math.PI) err += Math.PI * 2;
e.rudder = damp(e.rudder, clamp(-err * 1.5, -1, 1), 4, dt);
e.throttle = damp(e.throttle, closing ? 1 : backing ? 0.55 : 0.68, 1.4, dt);
// aim with lead
const flight = dist / (SHELL_SPEED * 0.82);
_lead.copy(player.vel).multiplyScalar(flight).add(player.pos);
_lead.y = waterHeight(_lead.x, _lead.z) + 1.0;
e.root.updateMatrixWorld(true);
e.turret.getWorldPosition(_muzzle);
solveBallistic(_muzzle, _lead, SHELL_SPEED, _aimDir);
const local = _v1.copy(_aimDir).applyQuaternion(_q1.copy(e.quat).invert());
const wantYaw = Math.atan2(local.x, local.z);
const wantPitch = Math.asin(clamp(local.y / (local.length() || 1), -1, 1));
let dy = wantYaw - e.turret.rotation.y;
while (dy > Math.PI) dy -= Math.PI * 2;
while (dy < -Math.PI) dy += Math.PI * 2;
e.turret.rotation.y += clamp(dy, -1.9 * dt, 1.9 * dt);
e.pitch.rotation.x = damp(e.pitch.rotation.x, -clamp(wantPitch, -0.25, 1.05), 5, dt);
ai.cool -= dt;
if (ai.cool <= 0 && dist < 150 && Math.abs(dy) < 0.09) {
ai.cool = ai.rof * rand(0.75, 1.3);
e.root.updateMatrixWorld(true);
e.pitch.getWorldPosition(_muzzle);
const dir = _v3.set(0, 0, 1).applyQuaternion(e.pitch.getWorldQuaternion(_q1)).normalize();
_muzzle.addScaledVector(dir, 3.3);
const spread = ai.accuracy / 100;
dir.x += rand(-spread, spread); dir.y += rand(-spread, spread) * 0.5; dir.z += rand(-spread, spread);
dir.normalize();
_shotV.copy(dir).multiplyScalar(SHELL_SPEED).add(e.vel);
fireShell(e, _muzzle, _shotV, ai.dmg);
muzzleFlash(_muzzle, dir);
sfx.boom(0.35, _muzzle.distanceTo(camera.position));
}
}
/* ---------- hull-to-hull collisions ---------- */
function collide(dt) {
for (let i = 0; i < fleet.length; i++) {
for (let j = i + 1; j < fleet.length; j++) {
const a = fleet[i], b = fleet[j];
if (!a.alive || !b.alive) continue;
_v1.copy(b.pos).sub(a.pos); _v1.y = 0;
const d = _v1.length();
const rad = (a.L + b.L) * 0.24;
if (d > rad || d < 0.001) continue;
_v1.divideScalar(d);
const push = (rad - d) * 0.5;
a.pos.addScaledVector(_v1, -push); b.pos.addScaledVector(_v1, push);
const rel = _v2.copy(b.vel).sub(a.vel).dot(_v1);
if (rel < -3) {
const impact = Math.min(18, -rel * 1.6);
a.damage(impact, null); b.damage(impact, null);
splash((a.pos.x + b.pos.x) / 2, waterHeight(a.pos.x, a.pos.z), (a.pos.z + b.pos.z) / 2, 1.6, 2);
if (a === player || b === player) { shake(1.2); hurtFlash(); }
sfx.slam(1.6);
}
a.vel.addScaledVector(_v1, rel * 0.4);
b.vel.addScaledVector(_v1, -rel * 0.4);
}
}
}
/* ---------- rogue swells ---------- */
let swellTimer = rand(18, 30), swellHold = 0;
function updateSea(dt) {
sea.time += dt;
if (swellHold > 0) {
swellHold -= dt;
if (swellHold <= 0) sea.target = 1.0;
} else {
swellTimer -= dt;
if (swellTimer <= 0 && state === 'play') {
swellTimer = rand(26, 46);
swellHold = rand(11, 17);
sea.target = rand(1.75, 2.15);
showAlert('⚠ ROGUE SWELL INBOUND', 3, '#ffcf4d');
sfx.noise(2.2, 300, 0.4, 0.3);
}
}
sea.amp = damp(sea.amp, sea.target, 0.45, dt);
waterUniforms.uAmp.value = sea.amp;
waterUniforms.uTime.value = sea.time;
}
/* ═══════════════════════════════════════════════════════════════════════
13. HUD
═══════════════════════════════════════════════════════════════════════ */
const $ = id => document.getElementById(id);
const crossEl = $('cross'), chRing = $('chring'), alertEl = $('alert');
const vigEl = $('vig'), flashEl = $('flash');
const startEl = $('start'), overEl = $('gameover'), loadEl = $('loading');
const radar = $('radar').getContext('2d');
let alertT = 0, hurtT = 0, hitT = 0;
function showAlert(text, dur, color) {
alertEl.textContent = text;
alertEl.style.color = color || '#ffcf4d';
alertEl.style.opacity = '1';
alertT = dur;
}
function hurtFlash() { hurtT = 0.55; }
function popHit() { hitT = 0.16; }
function updateHUD(dt) {
if (alertT > 0) { alertT -= dt; if (alertT <= 0) alertEl.style.opacity = '0'; }
if (hurtT > 0) { hurtT -= dt; vigEl.style.opacity = String(clamp(hurtT / 0.55, 0, 1) * 0.9); }
else if (player && player.alive) vigEl.style.opacity = String((1 - player.hp / 100) * 0.42);
if (hitT > 0) { hitT -= dt; chRing.setAttribute('opacity', '1'); chRing.classList.add('hot'); }
else {
chRing.setAttribute('opacity', (overheated || aimLocked) ? '0.95' : '0.35');
chRing.classList.toggle('hot', overheated || aimLocked);
}
if (!player) return;
const hp = Math.max(0, Math.round(player.hp));
$('hullnum').textContent = hp;
$('hullfill').style.width = hp + '%';
$('hullfill').style.background = hp > 55
? 'linear-gradient(90deg,#2fd79a,#8ef0c4)'
: hp > 25 ? 'linear-gradient(90deg,#e0b23a,#f5d976)'
: 'linear-gradient(90deg,#c9392a,#ef7a5c)';
$('heatnum').textContent = Math.round(heat) + '%';
$('heatfill').style.width = heat + '%';
$('thr').textContent = (player.throttle >= 0 ? '+' : '') + (player.throttle * 100).toFixed(0);
$('kts').textContent = (player.vel.length() * 1.944).toFixed(1);
$('score').textContent = score.toLocaleString();
$('wavecount').textContent = `WAVE ${waveNo} · ${enemies.filter(e => e.alive).length} LEFT`;
drawRadar();
}
function drawRadar() {
const c = radar, S = 150, R = S / 2, RANGE = 280;
c.clearRect(0, 0, S, S);
c.fillStyle = 'rgba(8,20,28,.55)'; c.beginPath(); c.arc(R, R, R - 1, 0, 7); c.fill();
c.strokeStyle = 'rgba(150,200,230,.22)'; c.lineWidth = 1;
for (const rr of [R / 3, (R / 3) * 2, R - 2]) { c.beginPath(); c.arc(R, R, rr, 0, 7); c.stroke(); }
const yaw = Math.atan2(
2 * (player.quat.w * player.quat.y + player.quat.x * player.quat.z),
1 - 2 * (player.quat.y * player.quat.y + player.quat.x * player.quat.x),
);
const cs = Math.cos(-yaw), sn = Math.sin(-yaw);
for (const e of enemies) {
if (!e.alive) continue;
let dx = e.pos.x - player.pos.x, dz = e.pos.z - player.pos.z;
let rx = dx * cs - dz * sn, rz = dx * sn + dz * cs;
const d = Math.hypot(rx, rz);
const k = Math.min(1, d / RANGE) * (R - 8) / (d || 1);
const px = R + rx * k, py = R - rz * k;
c.fillStyle = e.kind === 'elite' ? '#ff5a3c' : '#ffb03a';
c.beginPath(); c.arc(px, py, e.kind === 'elite' ? 3.6 : 2.8, 0, 7); c.fill();
}
c.fillStyle = '#54e6b4';
c.beginPath(); c.moveTo(R, R - 7); c.lineTo(R - 4.5, R + 5); c.lineTo(R + 4.5, R + 5); c.closePath(); c.fill();
}
/* ═══════════════════════════════════════════════════════════════════════
14. CAMERA
═══════════════════════════════════════════════════════════════════════ */
const camPos = new THREE.Vector3(0, 14, 34);
const camLook = new THREE.Vector3();
let menuAngle = 0, camYaw = 0;
function yawOf(q) {
return Math.atan2(2 * (q.w * q.y + q.x * q.z), 1 - 2 * (q.y * q.y + q.x * q.x));
}
function updateCamera(dt) {
if (state !== 'play') {
menuAngle += dt * 0.06;
const r = 38;
const cx = player ? player.pos.x : 0, cz = player ? player.pos.z : 0;
camPos.set(cx + Math.cos(menuAngle) * r,
(player ? player.pos.y : 0) + 9 + Math.sin(menuAngle * 0.7) * 2.5,
cz + Math.sin(menuAngle) * r);
camPos.y = Math.max(camPos.y, waterHeight(camPos.x, camPos.z) + 4.5);
// aim above the boat so she sits below the title card
camLook.set(cx, 11, cz);
if (player) { camLook.copy(player.pos); camLook.y += 10.0; }
camera.position.lerp(camPos, 1 - Math.exp(-2 * dt));
camera.lookAt(camLook);
return;
}
const yaw = yawOf(player.quat);
let dyaw = yaw - camYaw;
while (dyaw > Math.PI) dyaw -= Math.PI * 2;
while (dyaw < -Math.PI) dyaw += Math.PI * 2;
camYaw += dyaw * (1 - Math.exp(-5.5 * dt));
const fx = Math.sin(camYaw), fz = Math.cos(camYaw);
const speed = player.vel.length();
const back = 18.5 + speed * 0.55;
const up = 7.6 + speed * 0.16;
_v1.set(player.pos.x - fx * back, player.pos.y + up, player.pos.z - fz * back);
// nudge toward where the gun is pointing so the target stays on screen
_v2.copy(aimPoint).sub(player.pos); _v2.y = 0;
if (_v2.lengthSq() > 1) _v2.normalize().multiplyScalar(2.0); else _v2.set(0, 0, 0);
_v1.add(_v2);
_v1.y = Math.max(_v1.y, waterHeight(_v1.x, _v1.z) + 2.4);
camPos.lerp(_v1, 1 - Math.exp(-11.0 * dt));
_v3.copy(player.pos); _v3.y += 2.2;
_v3.lerp(aimPoint, 0.08);
camLook.lerp(_v3, 1 - Math.exp(-12 * dt));
camera.position.copy(camPos);
camera.lookAt(camLook);
// recoil / impact shake
if (shakeState.amt > 0.001) {
const a = shakeState.amt;
camera.position.x += rand(-a, a) * 0.42;
camera.position.y += rand(-a, a) * 0.42;
camera.rotateZ(rand(-a, a) * 0.012);
shakeState.amt = Math.max(0, a - dt * 3.4);
}
}
/* ═══════════════════════════════════════════════════════════════════════
15. GAME FLOW
═══════════════════════════════════════════════════════════════════════ */
function resetGame() {
for (const v of fleet) v.removeFrom(scene);
for (const s of shells) scene.remove(s.mesh);
fleet.length = 0; enemies.length = 0; shells.length = 0;
score = 0; kills = 0; waveNo = 0; waveTimer = 0;
heat = 0; overheated = false; gunCool = 0;
sea.amp = 1; sea.target = 1; swellHold = 0; swellTimer = rand(20, 32);
spawnPlayer();
camPos.set(0, 16, 40); camYaw = 0;
startWave();
}
function endGame() {
state = 'over';
$('gostats').innerHTML =
`SCORE <b>${score.toLocaleString()}</b><br>` +
`SHIPS SUNK <b>${kills}</b><br>` +
`REACHED <b>WAVE ${waveNo}</b>`;
overEl.classList.remove('hide');
document.getElementById('hud').classList.add('dim');
document.body.style.cursor = 'pointer';
}
function begin() {
sfx.init();
if (sfx.ctx && sfx.ctx.state === 'suspended') sfx.ctx.resume();
startEl.classList.add('hide');
overEl.classList.add('hide');
document.getElementById('hud').classList.remove('dim');
clearKeys();
try { window.focus(); } catch (_) {}
document.body.style.cursor = 'none';
resetGame();
state = 'play';
paused = false;
}
startEl.addEventListener('click', begin);
overEl.addEventListener('click', begin);
function updateFleet(dt) {
controlPlayer(dt);
for (const e of enemies) controlEnemy(e, dt);
for (const v of fleet) v.step(dt);
collide(dt);
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (!e.alive && !e.counted) { e.counted = true; kills++; score += 150; }
if (!e.alive && e.sinkT > 7) {
e.removeFrom(scene);
enemies.splice(i, 1);
const k = fleet.indexOf(e); if (k >= 0) fleet.splice(k, 1);
}
}
if (player.alive && enemies.filter(e => e.alive).length === 0) {
waveTimer -= dt;
if (waveTimer <= 0) { waveTimer = 5; startWave(); }
}
if (!player.alive && player.sinkT > 3 && state === 'play') endGame();
}
/* ═══════════════════════════════════════════════════════════════════════
16. MAIN LOOP
═══════════════════════════════════════════════════════════════════════ */
let last = performance.now();
let booted = false;
function frame(now) {
requestAnimationFrame(frame);
let dt = (now - last) / 1000;
last = now;
dt = Math.min(dt, 0.05);
const active = state === 'play' && !paused;
if (active) {
const steps = Math.min(3, Math.max(1, Math.ceil(dt / 0.014)));
const sdt = dt / steps;
for (let i = 0; i < steps; i++) { updateSea(sdt); updateFleet(sdt); }
updateShells(dt);
sfx.engine(Math.abs(player.throttle) * (player.alive ? 1 : 0));
} else if (!paused) {
updateSea(dt * 0.6);
if (player) { player.throttle = 0; player.rudder = 0; player.step(dt); }
sfx.engine(0);
} else {
sfx.engine(0);
}
if (paused) {
alertEl.textContent = 'PAUSED'; alertEl.style.color = '#8fa6b6';
alertEl.style.opacity = '1'; alertT = 0.1;
}
if (!paused) { fxSoft.update(dt); fxFire.update(dt); }
updateCamera(dt);
if (player) updateHUD(dt);
// ocean disk rides with the camera
ocean.position.set(camera.position.x, 0, camera.position.z);
sky.position.set(camera.position.x, 0, camera.position.z);
waterUniforms.uCamPos.value.copy(camera.position);
if (player) {
sunLight.target.position.copy(player.pos);
sunLight.position.copy(player.pos).addScaledVector(sunDir, 200);
sunLight.target.updateMatrixWorld();
}
camera.updateMatrixWorld();
renderReflection();
renderer.setClearColor(0x000000, 1);
renderer.render(scene, camera);
if (!booted) {
booted = true;
loadEl.style.display = 'none';
startEl.classList.remove('hide');
document.body.style.cursor = 'pointer';
}
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
reflRT.setSize(Math.min(1024, Math.floor(innerWidth * 0.6)),
Math.min(1024, Math.floor(innerHeight * 0.6)));
});
// a calm hull drifting on the menu sea
spawnPlayer();
player.pos.set(0, 0.6, 0);
state = 'menu';
requestAnimationFrame(frame);
</script>
</body>
</html>One email when a new model lands on the board or the showcase. Unsubscribe anytime.