Boat game, built by Ling 3.1 Flash
Hostedvia OpenCodeOpenRouter
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"/>
<title>NAVAL STORM — Gerstner Ocean Combat</title>
<style>
:root{
--accent:#6ee7ff; --danger:#ff5d5d; --panel:rgba(8,18,28,.62);
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#04121d;
font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
user-select:none;-webkit-user-select:none;cursor:none}
canvas{display:block}
/* ---------- crosshair ---------- */
#crosshair{position:fixed;left:0;top:0;width:34px;height:34px;pointer-events:none;z-index:40;
transform:translate(-50%,-50%);mix-blend-mode:difference}
#crosshair::before,#crosshair::after{content:"";position:absolute;inset:0;border:1.5px solid #fff;border-radius:50%}
#crosshair::after{width:5px;height:5px;left:50%;top:50%;transform:translate(-50%,-50%);border:none;background:#fff;border-radius:50%}
#crosshair .tick{position:absolute;background:#fff}
#crosshair .t1{left:50%;top:-6px;width:1.5px;height:8px;transform:translateX(-50%)}
#crosshair .t2{left:50%;bottom:-6px;width:1.5px;height:8px;transform:translateX(-50%)}
#crosshair .t3{top:50%;left:-6px;height:1.5px;width:8px;transform:translateY(-50%)}
#crosshair .t4{top:50%;right:-6px;height:1.5px;width:8px;transform:translateY(-50%)}
/* ---------- HUD panels ---------- */
.panel{position:fixed;z-index:30;color:#dff4ff;background:var(--panel);
border:1px solid rgba(110,231,255,.25);border-radius:10px;
padding:10px 14px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
box-shadow:0 4px 24px rgba(0,0,0,.35)}
#stats{top:16px;left:16px;min-width:230px}
#stats .label{font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);opacity:.85}
#hpbar{height:10px;margin:6px 0 10px;border-radius:5px;background:rgba(255,255,255,.09);overflow:hidden}
#hpfill{height:100%;width:100%;border-radius:5px;background:linear-gradient(90deg,#37e08b,#a7f3d0);
transition:width .18s ease, background .3s}
#hpfill.low{background:linear-gradient(90deg,#ff5d5d,#ffb4b4)}
#stats .row{display:flex;justify-content:space-between;font-size:13px;gap:18px;line-height:1.7}
#stats .val{font-weight:600;color:#fff;font-variant-numeric:tabular-nums}
#enemies{top:16px;right:16px;text-align:right;min-width:150px}
#enemies .big{font-size:26px;font-weight:700;color:#fff;line-height:1.1}
#waveBanner{position:fixed;top:9%;left:50%;transform:translateX(-50%);z-index:35;
padding:12px 26px;border-radius:12px;text-align:center;display:none;
background:rgba(40,10,10,.72);border:1px solid rgba(255,120,90,.55);
color:#ffd9c9;font-size:18px;font-weight:700;letter-spacing:.14em;
text-shadow:0 0 18px rgba(255,120,60,.8);backdrop-filter:blur(6px);
animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:.92}50%{opacity:.55}}
#waveBanner .sub{font-size:11px;font-weight:400;letter-spacing:.3em;color:#ffab8a;margin-top:4px}
#impact{position:fixed;top:38%;left:50%;transform:translate(-50%,-50%);z-index:35;
font-size:30px;font-weight:800;letter-spacing:.3em;color:#fff;opacity:0;pointer-events:none;
text-shadow:0 0 24px rgba(140,220,255,.9)}
#hint{bottom:16px;left:50%;transform:translateX(-50%);font-size:12px;letter-spacing:.08em;
color:#bfe6f5;white-space:nowrap}
#hint b{color:var(--accent)}
#minimap{position:fixed;bottom:16px;right:16px;z-index:30;border-radius:10px;
border:1px solid rgba(110,231,255,.25);background:rgba(6,14,24,.72);
box-shadow:0 4px 24px rgba(0,0,0,.35)}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:25;opacity:0;
box-shadow:inset 0 0 140px 60px rgba(255,30,20,.55);transition:opacity .12s}
#lowhp{position:fixed;inset:0;pointer-events:none;z-index:24;opacity:0;
box-shadow:inset 0 0 160px 70px rgba(255,0,0,.5)}
#gameover{position:fixed;inset:0;z-index:50;display:none;align-items:center;justify-content:center;
flex-direction:column;background:radial-gradient(ellipse at center,rgba(4,10,18,.55),rgba(2,6,12,.92));
color:#eaf7ff;text-align:center;cursor:default}
#gameover h1{font-size:52px;letter-spacing:.28em;font-weight:800;
text-shadow:0 0 34px rgba(110,231,255,.6)}
#gameover .score{font-size:20px;margin-top:14px;color:var(--accent);letter-spacing:.2em}
#gameover .again{margin-top:26px;font-size:14px;letter-spacing:.18em;color:#9fd7ea;
border:1px solid rgba(110,231,255,.4);padding:10px 22px;border-radius:8px;background:rgba(110,231,255,.08)}
#startnote{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);z-index:30;
font-size:12px;color:#8fd8ef;letter-spacing:.12em;background:var(--panel);
padding:8px 16px;border-radius:8px;border:1px solid rgba(110,231,255,.2)}
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
</head>
<body>
<div id="crosshair"><div class="tick t1"></div><div class="tick t2"></div><div class="tick t3"></div><div class="tick t4"></div></div>
<div id="stats" class="panel">
<div class="label">Hull Integrity</div>
<div id="hpbar"><div id="hpfill"></div></div>
<div class="row"><span>Speed</span><span class="val" id="speedVal">0 kn</span></div>
<div class="row"><span>Score</span><span class="val" id="scoreVal">0</span></div>
<div class="row"><span>Hostiles sunk</span><span class="val" id="killVal">0</span></div>
</div>
<div id="enemies" class="panel">
<div class="label" style="font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--danger);opacity:.9">Hostiles</div>
<div class="big" id="enemyCount">0</div>
</div>
<div id="waveBanner"><span id="waveBannerTxt">🌊 ROGUE WAVE INCOMING</span><div class="sub" id="waveBannerSub"></div></div>
<div id="impact">WAVE IMPACT</div>
<div id="hint" class="panel"><b>WASD</b> helm · <b>MOUSE</b> aim · <b>CLICK / SPACE</b> fire · <b>R</b> restart</div>
<canvas id="minimap" width="170" height="170"></canvas>
<div id="vignette"></div>
<div id="lowhp"></div>
<div id="startnote">🔊 click once to enable sound</div>
<div id="gameover">
<h1>VESSEL SUNK</h1>
<div class="score" id="finalScore"></div>
<div class="again">PRESS <b>R</b> TO SAIL AGAIN</div>
</div>
<script type="module">
import * as THREE from 'three';
/* ============================================================
WORLD CONSTANTS
============================================================ */
const G = 9.8;
const WORLD_R = 200; // playable radius
const MAP_D = 600; // water mesh size
// Gerstner wave set — mirrored exactly in the GLSL below (generated from this data)
const WAVES = [
{ dir: [ 1.00, 0.25], steep: 0.22, len: 62.0 },
{ dir: [ 0.75, 0.65], steep: 0.30, len: 33.0 },
{ dir: [-0.55, 0.85], steep: 0.34, len: 19.0 },
{ dir: [ 0.35, -0.95], steep: 0.28, len: 10.0 },
{ dir: [-0.90, -0.45], steep: 0.22, len: 5.2 },
].map(w => {
const l = Math.hypot(w.dir[0], w.dir[1]);
const dx = w.dir[0] / l, dz = w.dir[1] / l;
const k = 2 * Math.PI / w.len;
return { dx, dz, k, steep: w.steep, a: w.steep / k, omega: Math.sqrt(G * k) };
});
/* ============================================================
RENDERER / SCENE / SKY
============================================================ */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const HORIZON = new THREE.Color(0.62, 0.74, 0.84);
const ZENITH = new THREE.Color(0.10, 0.30, 0.60);
const SUN_COL = new THREE.Color(1.0, 0.93, 0.80);
const SUN_DIR = new THREE.Vector3(-0.55, 0.42, -0.72).normalize();
scene.fog = new THREE.Fog(new THREE.Color(HORIZON).multiplyScalar(0.96), 90, 560);
const camera = new THREE.PerspectiveCamera(56, innerWidth / innerHeight, 0.1, 2200);
camera.position.set(0, 8, -18);
// gradient sky dome with sun disc
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: {
uZenith: { value: ZENITH }, uHorizon: { value: HORIZON },
uSunDir: { value: SUN_DIR }, uSunCol: { value: SUN_COL },
},
vertexShader: `
varying vec3 vDir;
void main(){ vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
uniform vec3 uZenith, uHorizon, uSunDir, uSunCol;
varying vec3 vDir;
void main(){
vec3 d = normalize(vDir);
float h = d.y;
vec3 col = mix(uHorizon, uZenith, pow(clamp(h,0.0,1.0), 0.55));
col = mix(col, uHorizon * 0.35, smoothstep(0.0, -0.25, h));
float s = max(dot(d, uSunDir), 0.0);
col += uSunCol * (pow(s, 1200.0) * 6.0 + pow(s, 24.0) * 0.30 + pow(s, 4.0) * 0.10);
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(1000, 40, 20), skyMat));
// lights
const sun = new THREE.DirectionalLight(0xfff2dd, 2.4);
scene.add(sun); scene.add(sun.target);
scene.add(new THREE.HemisphereLight(0x9db8d6, 0x14303e, 0.85));
/* ============================================================
OCEAN — Gerstner waves, fresnel, sun glitter, foam
============================================================ */
function waveGLSL() {
let s = '';
for (const w of WAVES) {
s += `
{
vec2 D = vec2(${w.dx.toFixed(6)}, ${w.dz.toFixed(6)});
float k = ${w.k.toFixed(6)};
float a = ${w.a.toFixed(6)};
float q = ${w.steep.toFixed(6)};
float om = ${w.omega.toFixed(6)};
float f = k * dot(D, xz) - om * t;
float sn = sin(f); float cs = cos(f);
float ka = k * a;
disp.x += q * a * D.x * cs;
disp.z += q * a * D.y * cs;
disp.y += a * sn;
nrm.x -= D.x * ka * cs;
nrm.z -= D.y * ka * cs;
nrm.y -= q * ka * sn;
sumA += a;
}`;
}
return s;
}
const waterUniforms = {
uTime: { value: 0 },
uSunDir: { value: SUN_DIR },
uSunCol: { value: SUN_COL },
uDeep: { value: new THREE.Color(0.008, 0.045, 0.085) },
uShallow: { value: new THREE.Color(0.045, 0.27, 0.33) },
uSky: { value: HORIZON },
// rogue wave packet
uRogueDir: { value: new THREE.Vector2(1, 0) },
uRogueT0: { value: -1000 },
uRogueSpeed:{ value: 16 },
uRogueA: { value: 4.6 },
uRogueK: { value: 0.22 },
uRogueSig2: { value: 256.0 },
uRogueOn: { value: 0 },
};
const waterMat = new THREE.ShaderMaterial({
uniforms: waterUniforms,
vertexShader: `
uniform float uTime;
uniform vec2 uRogueDir;
uniform float uRogueT0, uRogueSpeed, uRogueA, uRogueK, uRogueSig2, uRogueOn;
varying vec3 vWorldPos;
varying vec3 vNormal;
varying float vHeight;
varying float vSumA;
varying float vRogueEnv;
void main(){
vec4 wp = modelMatrix * vec4(position, 1.0);
vec2 xz = wp.xz;
float t = uTime;
vec3 disp = vec3(0.0);
vec3 nrm = vec3(0.0, 1.0, 0.0);
float sumA = 0.0;
${waveGLSL()}
float rogueEnv = 0.0;
if (uRogueOn > 0.5) {
float s = dot(uRogueDir, xz);
float tau = s - uRogueSpeed * (t - uRogueT0);
float env = exp(-tau * tau / uRogueSig2);
float phase = uRogueK * tau;
rogueEnv = env;
disp.y += uRogueA * sin(phase) * env;
float dh = uRogueA * env * (uRogueK * cos(phase) - (2.0 * tau / uRogueSig2) * sin(phase));
nrm.x -= uRogueDir.x * dh;
nrm.z -= uRogueDir.y * dh;
}
wp.xz += disp.xz;
wp.y += disp.y;
vWorldPos = wp.xyz;
vNormal = nrm;
vHeight = disp.y;
vSumA = sumA;
vRogueEnv = rogueEnv;
gl_Position = projectionMatrix * viewMatrix * wp;
}`,
fragmentShader: `
uniform vec3 uSunDir, uSunCol, uDeep, uShallow, uSky;
uniform float uTime;
varying vec3 vWorldPos;
varying vec3 vNormal;
varying float vHeight;
varying float vSumA;
varying float vRogueEnv;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1,0)), f.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), f.x), f.y);
}
float fbm(vec2 p){
float v = 0.0, a = 0.5;
for (int i = 0; i < 4; i++){ v += a * noise(p); p *= 2.03; a *= 0.5; }
return v;
}
void main(){
vec3 N = normalize(vNormal);
// fine detail normals
float n1 = fbm(vWorldPos.xz * 0.45 + vec2(uTime * 0.45, uTime * 0.22));
float n2 = fbm(vWorldPos.xz * 1.15 - vec2(uTime * 0.30, -uTime * 0.55));
N = normalize(N + vec3(n1 - 0.5, 0.0, n2 - 0.5) * 0.38);
vec3 V = normalize(cameraPosition - vWorldPos);
float ndv = max(dot(N, V), 0.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
float hFac = clamp(vHeight / (vSumA * 1.35) + 0.5, 0.0, 1.0);
vec3 water = mix(uDeep, uShallow, hFac * 0.65);
// subsurface scattering toward the sun on lit crests
float sss = pow(max(dot(V, -uSunDir), 0.0), 3.0) * max(hFac - 0.55, 0.0) * 2.4;
water += uSunCol * sss * 0.30;
vec3 col = mix(water, uSky, fresnel * 0.88);
// sun glitter
vec3 H = normalize(V + uSunDir);
float ndh = max(dot(N, H), 0.0);
col += uSunCol * (pow(ndh, 420.0) * 2.6 + pow(ndh, 40.0) * 0.22);
// foam: crest whitecaps + rogue wave wash
float foamN = fbm(vWorldPos.xz * 1.5 + vec2(uTime * 0.7, -uTime * 0.4));
float crestFoam = smoothstep(0.66, 0.94, hFac) * smoothstep(0.32, 0.72, foamN);
float rogueFoam = vRogueEnv * smoothstep(0.38, 0.8, foamN) * 1.35;
float foam = clamp(crestFoam + rogueFoam, 0.0, 1.0);
col = mix(col, vec3(0.93, 0.97, 0.99), foam);
// distance fog to horizon
float dist = length(cameraPosition - vWorldPos);
float fogF = 1.0 - exp(-dist * 0.0032);
col = mix(col, uSky, fogF);
gl_FragColor = vec4(col, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
const water = new THREE.Mesh(new THREE.PlaneGeometry(MAP_D, MAP_D, 300, 300), waterMat);
water.geometry.rotateX(-Math.PI / 2);
water.frustumCulled = false;
scene.add(water);
/* ---------- JS mirror of the shader wave field (for buoyancy) ---------- */
const rogue = {
active: false, dir: new THREE.Vector2(1, 0), t0: 0,
speed: 16, a: 4.6, k: 0.22, sig2: 256,
};
function waveHeight(x, z, t) {
let h = 0;
for (const w of WAVES) h += w.a * Math.sin(w.k * (w.dx * x + w.dz * z) - w.omega * t);
if (rogue.active) {
const s = rogue.dir.x * x + rogue.dir.y * z;
const tau = s - rogue.speed * (t - rogue.t0);
h += rogue.a * Math.sin(rogue.k * tau) * Math.exp(-tau * tau / rogue.sig2);
}
return h;
}
function waveSlope(x, z, t, out) {
const e = 0.6;
out.x = (waveHeight(x + e, z, t) - waveHeight(x - e, z, t)) / (2 * e);
out.z = (waveHeight(x, z + e, t) - waveHeight(x, z - e, t)) / (2 * e);
return out;
}
/* ============================================================
AUDIO (procedural WebAudio)
============================================================ */
const AudioSys = {
ctx: null, noiseBuf: null,
ensure() {
if (!this.ctx) {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
const len = this.ctx.sampleRate * 1.2;
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = this.noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
}
if (this.ctx.state === 'suspended') this.ctx.resume();
},
_noise(dur, { type = 'lowpass', from = 800, to = 200, gain = 0.5, at = 0 }) {
if (!this.ctx) return;
const t = this.ctx.currentTime + at;
const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuf; src.loop = true;
const f = this.ctx.createBiquadFilter(); f.type = type;
f.frequency.setValueAtTime(from, t);
f.frequency.exponentialRampToValueAtTime(Math.max(40, to), t + dur);
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(f).connect(g).connect(this.ctx.destination);
src.start(t); src.stop(t + dur + 0.05);
},
_tone(dur, { from = 100, to = 40, gain = 0.5, type = 'sine', at = 0 }) {
if (!this.ctx) return;
const t = this.ctx.currentTime + at;
const o = this.ctx.createOscillator(); o.type = type;
o.frequency.setValueAtTime(from, t);
o.frequency.exponentialRampToValueAtTime(Math.max(20, to), t + dur);
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g).connect(this.ctx.destination);
o.start(t); o.stop(t + dur + 0.05);
},
cannon(dist) {
const g = Math.max(0.08, 1 - dist / 130);
this._noise(0.22, { from: 900, to: 150, gain: 0.45 * g });
this._tone(0.28, { from: 110, to: 32, gain: 0.55 * g });
},
explosion(dist, big) {
const g = Math.max(0.08, 1 - dist / 160) * (big ? 1 : 0.7);
this._noise(big ? 0.9 : 0.55, { from: 700, to: 60, gain: 0.7 * g });
this._tone(big ? 0.8 : 0.5, { from: 70, to: 24, gain: 0.6 * g });
},
splash(dist) {
const g = Math.max(0.05, 1 - dist / 110);
this._noise(0.3, { type: 'bandpass', from: 1400, to: 500, gain: 0.28 * g });
},
warning() {
this._tone(0.5, { from: 65, to: 55, gain: 0.4 });
this._tone(0.5, { from: 65, to: 55, gain: 0.4, at: 0.7 });
},
hit() { this._tone(0.15, { from: 300, to: 90, gain: 0.4, type: 'square' }); },
};
/* ============================================================
PARTICLES
============================================================ */
class Particles {
constructor(count, { gravity = -9, drag = 0, blending = THREE.NormalBlending } = {}) {
this.count = count; this.n = 0;
this.gravity = gravity; this.drag = drag;
this.pos = new Float32Array(count * 3);
this.vel = new Float32Array(count * 3);
this.life = new Float32Array(count);
this.maxLife = new Float32Array(count);
this.size = new Float32Array(count);
this.col = new Float32Array(count * 3);
this.grow = new Float32Array(count);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aColor', new THREE.BufferAttribute(this.col, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aSize', new THREE.BufferAttribute(this.size, 1).setUsage(THREE.DynamicDrawUsage));
this.alphaAttr = new THREE.BufferAttribute(new Float32Array(count), 1);
this.alphaAttr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('aAlpha', this.alphaAttr);
geo.setDrawRange(0, 0);
const mat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false, blending,
vertexShader: `
attribute float aSize; attribute float aAlpha; attribute vec3 aColor;
varying float vA; varying vec3 vC;
void main(){
vA = aAlpha; vC = aColor;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (260.0 / max(1.0, -mv.z));
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
varying float vA; varying vec3 vC;
void main(){
float d = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.06, d) * vA;
if (a < 0.012) discard;
gl_FragColor = vec4(vC, a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`
});
this.points = new THREE.Points(geo, mat);
this.points.frustumCulled = false;
scene.add(this.points);
}
spawn(x, y, z, vx, vy, vz, life, size, r, g, b, grow = 0) {
if (this.n >= this.count) return;
const i = this.n++;
this.pos[i*3] = x; this.pos[i*3+1] = y; this.pos[i*3+2] = z;
this.vel[i*3] = vx; this.vel[i*3+1] = vy; this.vel[i*3+2] = vz;
this.life[i] = life; this.maxLife[i] = life;
this.size[i] = size; this.grow[i] = grow;
this.col[i*3] = r; this.col[i*3+1] = g; this.col[i*3+2] = b;
}
update(dt) {
let i = 0;
while (i < this.n) {
this.life[i] -= dt;
if (this.life[i] <= 0) {
const last = --this.n;
if (i !== last) {
for (let k = 0; k < 3; k++) {
this.pos[i*3+k] = this.pos[last*3+k];
this.vel[i*3+k] = this.vel[last*3+k];
this.col[i*3+k] = this.col[last*3+k];
}
this.life[i] = this.life[last]; this.maxLife[i] = this.maxLife[last];
this.size[i] = this.size[last]; this.grow[i] = this.grow[last];
}
continue;
}
const d = Math.exp(-this.drag * dt);
this.vel[i*3] *= d; this.vel[i*3+1] = this.vel[i*3+1] * d + this.gravity * dt; this.vel[i*3+2] *= d;
this.pos[i*3] += this.vel[i*3] * dt;
this.pos[i*3+1] += this.vel[i*3+1] * dt;
this.pos[i*3+2] += this.vel[i*3+2] * dt;
const f = this.life[i] / this.maxLife[i];
this.size[i] += this.grow[i] * dt;
this.alphaAttr.array[i] = Math.min(1, f * 1.6) * (this.alphaBase ?? 1);
i++;
}
const geo = this.points.geometry;
geo.attributes.position.needsUpdate = true;
geo.attributes.aColor.needsUpdate = true;
geo.attributes.aSize.needsUpdate = true;
this.alphaAttr.needsUpdate = true;
geo.setDrawRange(0, this.n);
}
}
const spray = new Particles(2600, { gravity: -9, drag: 0.6 }); // water spray
const wake = new Particles(2600, { gravity: -0.6, drag: 1.6 }); // wake foam
const fire = new Particles(2200, { gravity: -4, drag: 1.2, blending: THREE.AdditiveBlending }); // fire / tracers
wake.alphaBase = 0.4;
/* ---------- shockwave rings ---------- */
const rings = [];
{
const g = new THREE.RingGeometry(0.86, 1.0, 48);
g.rotateX(-Math.PI / 2);
for (let i = 0; i < 10; i++) {
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({
color: 0xdff4ff, transparent: true, opacity: 0, depthWrite: false }));
m.visible = false; scene.add(m);
rings.push({ mesh: m, life: 0, maxLife: 1, maxScale: 10 });
}
}
function spawnRing(x, y, z, maxScale, color = 0xdff4ff, life = 0.9) {
const r = rings.find(r => !r.mesh.visible) || rings[0];
r.mesh.visible = true;
r.mesh.position.set(x, y, z);
r.mesh.material.color.setHex(color);
r.life = life; r.maxLife = life; r.maxScale = maxScale;
}
function updateRings(dt) {
for (const r of rings) {
if (!r.mesh.visible) continue;
r.life -= dt;
if (r.life <= 0) { r.mesh.visible = false; continue; }
const t = 1 - r.life / r.maxLife;
const s = 1 + (r.maxScale - 1) * (1 - Math.pow(1 - t, 2.2));
r.mesh.scale.setScalar(s);
r.mesh.material.opacity = (1 - t) * 0.65;
}
}
/* ---------- explosion flash lights ---------- */
const flashes = [];
for (let i = 0; i < 5; i++) {
const l = new THREE.PointLight(0xffa040, 0, 90, 1.8);
scene.add(l);
flashes.push({ light: l, life: 0 });
}
function flash(pos, intensity = 260, color = 0xffa040) {
const f = flashes.find(f => f.life <= 0) || flashes[0];
f.light.position.copy(pos).add(new THREE.Vector3(0, 2, 0));
f.light.color.setHex(color);
f.light.intensity = intensity;
f.life = 0.35;
}
function updateFlashes(dt) {
for (const f of flashes) {
if (f.life <= 0) continue;
f.life -= dt;
f.light.intensity *= Math.exp(-9 * dt);
if (f.life <= 0) f.light.intensity = 0;
}
}
/* ============================================================
EFFECTS
============================================================ */
function explosion(pos, big = false) {
const nF = big ? 90 : 45;
for (let i = 0; i < nF; i++) {
const th = Math.random() * Math.PI * 2, ph = Math.acos(2 * Math.random() - 1);
const sp = (big ? 6 : 4) + Math.random() * (big ? 11 : 7);
const heat = Math.random();
fire.spawn(pos.x, pos.y + 0.5, pos.z,
Math.sin(ph) * Math.cos(th) * sp, Math.abs(Math.cos(ph)) * sp * 0.9 + 2, Math.sin(ph) * Math.sin(th) * sp,
0.4 + Math.random() * 0.7, 1.6 + Math.random() * 2.4,
1.0, 0.35 + heat * 0.5, 0.08 + heat * 0.15, -1.2);
}
for (let i = 0; i < (big ? 26 : 14); i++) {
spray.spawn(pos.x, pos.y + 0.4, pos.z,
(Math.random() - 0.5) * 6, 2 + Math.random() * 6, (Math.random() - 0.5) * 6,
0.8 + Math.random() * 1.2, 1.4 + Math.random() * 2,
0.9, 0.92, 0.95, 1.5);
}
flash(pos, big ? 320 : 170, big ? 0xff8030 : 0xffa040);
spawnRing(pos.x, pos.y + 0.3, pos.z, big ? 22 : 12, big ? 0xffb070 : 0xdff4ff, big ? 1.2 : 0.8);
AudioSys.explosion(camera.position.distanceTo(pos), big);
}
function splash(pos, strength = 1) {
const n = Math.round(12 + strength * 14);
for (let i = 0; i < n; i++) {
spray.spawn(pos.x + (Math.random() - 0.5) * 2, pos.y + 0.2, pos.z + (Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 3 * strength, (2.5 + Math.random() * 4) * strength, (Math.random() - 0.5) * 3 * strength,
0.7 + Math.random() * 0.8, 1.2 + Math.random() * 1.8,
0.88, 0.94, 0.98, 1.2);
}
spawnRing(pos.x, pos.y + 0.25, pos.z, 4 + strength * 3, 0xcfe8f5, 0.7);
AudioSys.splash(camera.position.distanceTo(pos));
}
/* ============================================================
BOATS
============================================================ */
function hullGeometry(len, beam, depth) {
const hl = len / 2, hb = beam / 2;
const s = new THREE.Shape();
s.moveTo(0, hl);
s.quadraticCurveTo(hb * 0.95, hl * 0.45, hb, -hl * 0.35);
s.lineTo(hb, -hl * 0.82);
s.quadraticCurveTo(hb * 0.55, -hl, 0, -hl);
s.quadraticCurveTo(-hb * 0.55, -hl, -hb, -hl * 0.82);
s.lineTo(-hb, -hl * 0.35);
s.quadraticCurveTo(-hb * 0.95, hl * 0.45, 0, hl);
const g = new THREE.ExtrudeGeometry(s, {
depth, bevelEnabled: true, bevelThickness: 0.14, bevelSize: 0.1, bevelSegments: 2, steps: 1,
});
g.rotateX(Math.PI / 2); // shape-Y -> world Z (bow at +Z), extrusion -> -Y
const p = g.attributes.position;
for (let i = 0; i < p.count; i++) {
const y = p.getY(i);
const f = THREE.MathUtils.clamp(THREE.MathUtils.mapLinear(y, -depth, 0, 0.68, 1.0), 0.68, 1);
p.setX(i, p.getX(i) * f);
}
g.computeVertexNormals();
return g;
}
function createBoat({ hull = 0x2b3a4a, deck = 0x8fa3b5, accent = 0x2f6f9f, scale = 1 } = {}) {
const group = new THREE.Group();
group.rotation.order = 'YXZ';
const hullMat = new THREE.MeshStandardMaterial({ color: hull, roughness: 0.55, metalness: 0.25 });
const hullMesh = new THREE.Mesh(hullGeometry(9, 3.0, 1.35), hullMat);
group.add(hullMesh);
const deckMesh = new THREE.Mesh(hullGeometry(8.6, 2.7, 0.16),
new THREE.MeshStandardMaterial({ color: deck, roughness: 0.7, metalness: 0.1 }));
deckMesh.position.y = 0.1;
group.add(deckMesh);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.0, 2.4),
new THREE.MeshStandardMaterial({ color: deck, roughness: 0.4, metalness: 0.2 }));
cabin.position.set(0, 0.72, -1.1);
group.add(cabin);
const windshield = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.55, 0.12),
new THREE.MeshStandardMaterial({ color: 0x1a2a38, roughness: 0.15, metalness: 0.6 }));
windshield.position.set(0, 0.85, 0.12);
windshield.rotation.x = -0.35;
group.add(windshield);
// gun turret
const turret = new THREE.Group();
turret.position.set(0, 0.35, 2.1);
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.65, 0.35, 16),
new THREE.MeshStandardMaterial({ color: accent, roughness: 0.4, metalness: 0.5 }));
base.position.y = 0.18;
turret.add(base);
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.11, 2.6, 10),
new THREE.MeshStandardMaterial({ color: 0x222831, roughness: 0.35, metalness: 0.7 }));
barrel.rotation.x = Math.PI / 2;
barrel.position.set(0, 0.42, 1.1);
turret.add(barrel);
const muzzle = new THREE.Object3D();
muzzle.position.set(0, 0.42, 2.4);
turret.add(muzzle);
group.add(turret);
// mast + flag
const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.07, 2.2, 8),
new THREE.MeshStandardMaterial({ color: 0x333a44, roughness: 0.5, metalness: 0.6 }));
mast.position.set(0, 1.9, -2.2);
group.add(mast);
group.scale.setScalar(scale);
scene.add(group);
return { group, turret, muzzle, barrel };
}
function makeBoatState(isPlayer, opts) {
const parts = createBoat(opts);
return {
...parts,
isPlayer,
vel: new THREE.Vector3(),
vy: 0,
heading: 0, pitch: 0, roll: 0,
hp: 100, maxHp: 100,
fireCd: 0,
dead: false, sinking: 0,
lastDamage: -99,
ai: isPlayer ? null : {
orbitDir: Math.random() < 0.5 ? 1 : -1,
fireTimer: 1.5 + Math.random() * 2,
strafe: Math.random() * Math.PI * 2,
},
wakeAcc: 0,
};
}
/* ---------- boat physics (buoyancy on the real wave field) ---------- */
const _slope = { x: 0, z: 0 };
function updateBoatPhysics(b, dt, t, thrust, turnInput) {
if (b.dead) {
// sinking
b.sinking += dt;
b.group.position.y -= dt * (0.8 + b.sinking * 0.6);
b.group.rotation.z += dt * 0.15 * (b.ai ? b.ai.orbitDir : 1);
b.group.rotation.x += dt * 0.06;
return;
}
const p = b.group.position;
const fx = Math.sin(b.heading), fz = Math.cos(b.heading);
// steering
const speed = Math.hypot(b.vel.x, b.vel.z);
const turnScale = THREE.MathUtils.clamp(speed / 7, 0, 1) * (thrust < 0 ? -1 : 1);
b.heading += turnInput * (b.isPlayer ? 1.55 : 1.15) * turnScale * dt;
// thrust + drag
b.vel.x += fx * thrust * dt;
b.vel.z += fz * thrust * dt;
const dragF = Math.exp(-(b.isPlayer ? 0.55 : 0.6) * dt);
b.vel.x *= dragF; b.vel.z *= dragF;
const maxSp = b.isPlayer ? 24 : 16.5;
const sp2 = Math.hypot(b.vel.x, b.vel.z);
if (sp2 > maxSp) { b.vel.x *= maxSp / sp2; b.vel.z *= maxSp / sp2; }
// rogue wave shove
if (rogue.active) {
const s = rogue.dir.x * p.x + rogue.dir.y * p.z;
const tau = s - rogue.speed * (t - rogue.t0);
const env = Math.exp(-tau * tau / rogue.sig2);
b.vel.x += rogue.dir.x * env * 7.5 * dt;
b.vel.z += rogue.dir.y * env * 7.5 * dt;
}
p.x += b.vel.x * dt;
p.z += b.vel.z * dt;
// world bounds
const rr = Math.hypot(p.x, p.z);
if (rr > WORLD_R) {
const nx = p.x / rr, nz = p.z / rr;
p.x = nx * WORLD_R; p.z = nz * WORLD_R;
const rad = b.vel.x * nx + b.vel.z * nz;
if (rad > 0) { b.vel.x -= nx * rad; b.vel.z -= nz * rad; }
}
// buoyancy: spring toward the wave surface
const hC = waveHeight(p.x, p.z, t);
const targetY = hC;
b.vy += ((targetY - p.y) * 30 - b.vy * 11) * dt;
p.y += b.vy * dt;
// pitch & roll from wave slopes (bow/stern, port/starboard)
const hB = waveHeight(p.x + fx * 3.6, p.z + fz * 3.6, t);
const hS = waveHeight(p.x - fx * 3.6, p.z - fz * 3.6, t);
const rx = Math.cos(b.heading), rz = -Math.sin(b.heading);
const hR = waveHeight(p.x + rx * 1.4, p.z + rz * 1.4, t);
const hL = waveHeight(p.x - rx * 1.4, p.z - rz * 1.4, t);
const pitchT = Math.atan2(hS - hB, 7.2) + THREE.MathUtils.clamp(-b.vy * 0.045, -0.35, 0.35);
const rollT = Math.atan2(hR - hL, 2.8) + THREE.MathUtils.clamp(turnInput * speed * 0.006, -0.2, 0.2);
const k = Math.min(1, 5.5 * dt);
b.pitch += (pitchT - b.pitch) * k;
b.roll += (rollT - b.roll) * k;
b.group.rotation.y = b.heading;
b.group.rotation.x = b.pitch;
b.group.rotation.z = b.roll;
// wake foam
const speedNow = Math.hypot(b.vel.x, b.vel.z);
if (speedNow > 3.5) {
b.wakeAcc += dt * speedNow;
while (b.wakeAcc > 0.55) {
b.wakeAcc -= 0.55;
const sx = p.x - fx * 4.2 + (Math.random() - 0.5) * 1.6;
const sz = p.z - fz * 4.2 + (Math.random() - 0.5) * 1.6;
wake.spawn(sx, waveHeight(sx, sz, t) + 0.15, sz,
-fx * 1.5 + (Math.random() - 0.5) * 1.2, 0.4, -fz * 1.5 + (Math.random() - 0.5) * 1.2,
1.4 + Math.random() * 1.6, 1.6 + Math.random() * 1.6,
0.85, 0.92, 0.96, 2.2);
}
}
// bow spray in heavy seas
if (Math.abs(b.vy) > 3.2 || (rogue.active && b.vy > 2.5)) {
for (let i = 0; i < 3; i++) {
spray.spawn(p.x + fx * 4.2, p.y + 0.4, p.z + fz * 4.2,
fx * 3 + (Math.random() - 0.5) * 3, 2 + Math.random() * 3.5, fz * 3 + (Math.random() - 0.5) * 3,
0.6 + Math.random() * 0.6, 1.4 + Math.random() * 1.4,
0.9, 0.95, 1.0, 0.8);
}
}
// rogue wave slam event
if (rogue.active && b.vy > 7.5 && !b.slammed) {
b.slammed = true;
if (b.isPlayer) {
shake(0.9);
damagePlayer(6 + Math.random() * 5, true);
showImpact();
}
splash(new THREE.Vector3(p.x + fx * 2, p.y, p.z + fz * 2), 2.2);
}
if (rogue.active && b.vy < 3) b.slammed = false;
}
/* ============================================================
SHELLS (ballistic projectiles)
============================================================ */
const shells = [];
const shellGeo = new THREE.SphereGeometry(0.26, 8, 6);
const shellMatP = new THREE.MeshBasicMaterial({ color: 0xffe9b0 });
const shellMatE = new THREE.MeshBasicMaterial({ color: 0xff6a5a });
function ballisticDir(from, to, speed) {
const dx = to.x - from.x, dz = to.z - from.z;
const dy = (to.y ?? 0) - from.y;
const hd = Math.hypot(dx, dz);
if (hd < 0.5) return new THREE.Vector3(0, 1, 0);
const s2 = speed * speed;
const disc = s2 * s2 - G * (G * hd * hd + 2 * dy * s2);
const el = disc > 0 ? Math.atan((s2 - Math.sqrt(disc)) / (G * hd)) : Math.PI / 4;
const ce = Math.cos(el);
return new THREE.Vector3(dx / hd * ce, Math.sin(el), dz / hd * ce);
}
function fireShell(owner, target, speed) {
const muzzlePos = new THREE.Vector3();
owner.muzzle.getWorldPosition(muzzlePos);
const dir = ballisticDir(muzzlePos, target, speed);
const mesh = new THREE.Mesh(shellGeo, owner.isPlayer ? shellMatP : shellMatE);
mesh.position.copy(muzzlePos);
scene.add(mesh);
shells.push({ pos: mesh.position, vel: dir.multiplyScalar(speed).add(owner.vel.clone().multiplyScalar(0.4)), mesh, owner, life: 9 });
// muzzle flash
flash(muzzlePos, 60, 0xffd080);
for (let i = 0; i < 5; i++) {
fire.spawn(muzzlePos.x, muzzlePos.y, muzzlePos.z,
dir.x * 6 + (Math.random() - 0.5) * 2, dir.y * 6 + Math.random() * 2, dir.z * 6 + (Math.random() - 0.5) * 2,
0.15 + Math.random() * 0.15, 1.2 + Math.random(), 1.0, 0.75, 0.35, -3);
}
AudioSys.cannon(camera.position.distanceTo(muzzlePos));
if (owner.isPlayer) shake(0.12);
}
function updateShells(dt, t) {
for (let i = shells.length - 1; i >= 0; i--) {
const s = shells[i];
s.life -= dt;
s.vel.y -= G * dt;
s.pos.addScaledVector(s.vel, dt);
// tracer
if (Math.random() < 0.6) {
fire.spawn(s.pos.x, s.pos.y, s.pos.z, 0, 0, 0, 0.22, 0.7, 1.0, 0.6, 0.25, -2);
}
let kill = s.life <= 0;
const h = waveHeight(s.pos.x, s.pos.z, t);
if (s.pos.y <= h + 0.1) {
splash(new THREE.Vector3(s.pos.x, h, s.pos.z), 0.9);
kill = true;
}
if (!kill) {
if (s.owner.isPlayer) {
for (const e of enemies) {
if (e.dead) continue;
const d2 = s.pos.distanceToSquared(e.group.position);
if (d2 < 12.5) {
explosion(s.pos, false);
damageEnemy(e, 24 + Math.random() * 8);
kill = true; break;
}
}
} else {
const d2 = s.pos.distanceToSquared(player.group.position);
if (d2 < 12.5) {
explosion(s.pos, false);
damagePlayer(12 + Math.random() * 6);
kill = true;
}
}
}
if (kill) {
scene.remove(s.mesh);
shells.splice(i, 1);
}
}
}
/* ============================================================
GAME STATE
============================================================ */
const player = makeBoatState(true, { hull: 0x24425e, deck: 0xa8bdd0, accent: 0x2f8f9f });
player.group.position.set(0, 0, 0);
player.hp = 100; player.maxHp = 100;
const enemies = [];
let score = 0, kills = 0, waveLevel = 1;
let gameOver = false;
let shakeAmp = 0;
let spawnTimer = 0;
function shake(a) { shakeAmp = Math.min(1.6, shakeAmp + a); }
function spawnEnemy() {
const ang = Math.random() * Math.PI * 2;
const dist = 85 + Math.random() * 45;
const x = THREE.MathUtils.clamp(player.group.position.x + Math.sin(ang) * dist, -WORLD_R + 10, WORLD_R - 10);
const z = THREE.MathUtils.clamp(player.group.position.z + Math.cos(ang) * dist, -WORLD_R + 10, WORLD_R - 10);
const e = makeBoatState(false, {
hull: 0x4a2323, deck: 0x9a7a6a, accent: 0x9f2f2f, scale: 0.92,
});
e.group.position.set(x, 0, z);
e.heading = Math.atan2(player.group.position.x - x, player.group.position.z - z);
e.hp = e.maxHp = 55 + waveLevel * 12;
enemies.push(e);
}
function damageEnemy(e, dmg) {
if (e.dead) return;
e.hp -= dmg;
AudioSys.hit();
if (e.hp <= 0) {
e.dead = true;
explosion(new THREE.Vector3(e.group.position.x, e.group.position.y + 1, e.group.position.z), true);
kills++;
score += 100 + waveLevel * 25;
if (kills % 5 === 0) waveLevel++;
}
}
function damagePlayer(dmg, silent = false) {
if (gameOver) return;
player.hp -= dmg;
player.lastDamage = elapsed;
if (!silent) { shake(0.45); AudioSys.hit(); }
vignetteFlash = 0.85;
if (player.hp <= 0) {
player.hp = 0;
gameOver = true;
explosion(new THREE.Vector3(player.group.position.x, player.group.position.y + 1, player.group.position.z), true);
player.dead = true;
document.getElementById('gameover').style.display = 'flex';
document.getElementById('finalScore').textContent =
`SCORE ${Math.round(score)} · ${kills} KILLS · SURVIVED ${Math.floor(elapsed)}s`;
}
}
function resetGame() {
for (const e of enemies) scene.remove(e.group);
enemies.length = 0;
for (const s of shells) scene.remove(s.mesh);
shells.length = 0;
player.dead = false; player.hp = player.maxHp = 100;
player.group.position.set(0, 0, 0);
player.vel.set(0, 0, 0); player.vy = 0;
player.heading = 0; player.pitch = player.roll = 0;
player.group.rotation.set(0, 0, 0);
score = 0; kills = 0; waveLevel = 1;
gameOver = false;
rogue.active = false; waterUniforms.uRogueOn.value = 0;
rogueTimer = 12;
document.getElementById('gameover').style.display = 'none';
for (let i = 0; i < 4; i++) spawnEnemy();
}
/* ============================================================
INPUT
============================================================ */
const keys = {};
const mouse = { x: 0, y: 0, down: false };
addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyR' && gameOver) { AudioSys.ensure(); resetGame(); }
if (e.code === 'Space') e.preventDefault();
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('mousemove', e => {
mouse.x = (e.clientX / innerWidth) * 2 - 1;
mouse.y = -(e.clientY / innerHeight) * 2 + 1;
const ch = document.getElementById('crosshair');
ch.style.left = e.clientX + 'px';
ch.style.top = e.clientY + 'px';
});
addEventListener('mousedown', e => {
AudioSys.ensure();
document.getElementById('startnote').style.display = 'none';
if (e.button === 0) mouse.down = true;
});
addEventListener('mouseup', e => { if (e.button === 0) mouse.down = false; });
addEventListener('contextmenu', e => e.preventDefault());
/* ============================================================
AIMING
============================================================ */
const aimPoint = new THREE.Vector3(0, 0, 50);
const aimRing = new THREE.Mesh(
new THREE.RingGeometry(1.4, 1.75, 40).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial({ color: 0x6ee7ff, transparent: true, opacity: 0.6, depthWrite: false }));
scene.add(aimRing);
const _ray = new THREE.Raycaster();
const _ndc = new THREE.Vector2();
const _plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
function updateAim() {
_ndc.set(mouse.x, mouse.y);
_ray.setFromCamera(_ndc, camera);
const hit = _ray.ray.intersectPlane(_plane, aimPoint);
if (!hit) {
// looking at the horizon — project far ahead
aimPoint.copy(camera.position).addScaledVector(_ray.ray.direction, 250);
aimPoint.y = 0;
}
const rr = Math.hypot(aimPoint.x, aimPoint.z);
if (rr > WORLD_R + 20) { aimPoint.x *= (WORLD_R + 20) / rr; aimPoint.z *= (WORLD_R + 20) / rr; }
aimRing.position.set(aimPoint.x, waveHeight(aimPoint.x, aimPoint.z, elapsed) + 0.2, aimPoint.z);
const pulse = 1 + Math.sin(elapsed * 6) * 0.12;
aimRing.scale.setScalar(pulse);
aimRing.material.opacity = 0.45 + Math.sin(elapsed * 6) * 0.15;
}
/* ============================================================
ROGUE WAVE SCHEDULER
============================================================ */
let rogueTimer = 14;
let impactShown = 0;
function updateRogue(dt) {
rogueTimer -= dt;
if (!rogue.active && rogueTimer <= 0) {
rogue.active = true;
const ang = Math.random() * Math.PI * 2;
rogue.dir.set(Math.cos(ang), Math.sin(ang));
rogue.t0 = elapsed + 2.5; // small lead-in so it enters the map
waterUniforms.uRogueDir.value.copy(rogue.dir);
waterUniforms.uRogueT0.value = rogue.t0;
waterUniforms.uRogueOn.value = 1;
AudioSys.warning();
}
if (rogue.active) {
// warning banner while the packet is approaching
const front = rogue.speed * (elapsed - rogue.t0);
const sP = rogue.dir.x * player.group.position.x + rogue.dir.y * player.group.position.z;
const distAhead = sP - front;
const banner = document.getElementById('waveBanner');
if (distAhead > -30 && distAhead < 400) {
banner.style.display = 'block';
const deg = Math.atan2(rogue.dir.x, rogue.dir.y) * 180 / Math.PI;
document.getElementById('waveBannerSub').textContent =
`▲ WALL OF WATER · ${Math.max(0, Math.round(distAhead))}m · DIR ${Math.round((deg + 360) % 360)}°`;
} else {
banner.style.display = 'none';
}
// deactivate once the packet has crossed the whole map
if (front > MAP_D * 0.75 + 60) {
rogue.active = false;
waterUniforms.uRogueOn.value = 0;
rogueTimer = 16 + Math.random() * 14;
banner.style.display = 'none';
}
}
}
/* ============================================================
HUD
============================================================ */
const el = id => document.getElementById(id);
let vignetteFlash = 0;
function showImpact() {
const e = el('impact');
e.style.transition = 'none'; e.style.opacity = 0.95;
requestAnimationFrame(() => {
e.style.transition = 'opacity 1.1s ease'; e.style.opacity = 0;
});
}
function updateHUD() {
const hpFrac = Math.max(0, player.hp / player.maxHp);
const fill = el('hpfill');
fill.style.width = (hpFrac * 100) + '%';
fill.classList.toggle('low', hpFrac < 0.35);
el('speedVal').textContent = (Math.hypot(player.vel.x, player.vel.z) * 1.9).toFixed(0) + ' kn';
el('scoreVal').textContent = Math.round(score);
el('killVal').textContent = kills;
el('enemyCount').textContent = enemies.filter(e => !e.dead).length;
vignetteFlash = Math.max(0, vignetteFlash - 0.03);
const lowPulse = hpFrac < 0.3 ? (0.25 + 0.2 * Math.sin(elapsed * 5)) : 0;
el('vignette').style.opacity = Math.min(1, vignetteFlash);
el('lowhp').style.opacity = lowPulse;
}
/* ---------- minimap ---------- */
const mm = el('minimap').getContext('2d');
function drawMinimap() {
const S = 170, R = 78, cx = S / 2, cy = S / 2;
const scale = R / 130;
mm.clearRect(0, 0, S, S);
mm.save();
mm.beginPath(); mm.arc(cx, cy, R, 0, Math.PI * 2); mm.clip();
mm.fillStyle = 'rgba(10,26,40,0.9)'; mm.fillRect(0, 0, S, S);
mm.strokeStyle = 'rgba(110,231,255,0.15)';
for (const r of [R * 0.33, R * 0.66]) { mm.beginPath(); mm.arc(cx, cy, r, 0, Math.PI * 2); mm.stroke(); }
// rogue wave front
if (rogue.active) {
const front = rogue.speed * (elapsed - rogue.t0);
const px = player.group.position.x, pz = player.group.position.z;
const a = rogue.dir.y, b = -rogue.dir.x; // perpendicular
mm.strokeStyle = 'rgba(255,150,90,0.85)';
mm.lineWidth = 3;
mm.beginPath();
for (let o = -160; o <= 160; o += 8) {
// point on front line: base = dir * front, offset along perpendicular
const bx = rogue.dir.x * front, bz = rogue.dir.y * front;
const x = (bx + a * o - px) * scale + cx;
const y = (bz + b * o - pz) * scale + cy;
if (o === -160) mm.moveTo(x, y); else mm.lineTo(x, y);
}
mm.stroke();
mm.lineWidth = 1;
}
const dot = (x, z, color, r) => {
mm.fillStyle = color;
mm.beginPath();
mm.arc((x - player.group.position.x) * scale + cx, (z - player.group.position.z) * scale + cy, r, 0, Math.PI * 2);
mm.fill();
};
for (const e of enemies) if (!e.dead) dot(e.group.position.x, e.group.position.z, '#ff5d5d', 3.2);
// player arrow
mm.save();
mm.translate(cx, cy);
mm.rotate(-player.heading + Math.PI);
mm.fillStyle = '#6ee7ff';
mm.beginPath(); mm.moveTo(0, -6); mm.lineTo(4.5, 5); mm.lineTo(0, 2.5); mm.lineTo(-4.5, 5); mm.closePath(); mm.fill();
mm.restore();
mm.restore();
mm.strokeStyle = 'rgba(110,231,255,0.4)';
mm.beginPath(); mm.arc(cx, cy, R, 0, Math.PI * 2); mm.stroke();
}
/* ============================================================
ENEMY AI
============================================================ */
function updateEnemy(e, dt, t) {
if (e.dead) { updateBoatPhysics(e, dt, t, 0, 0); return; }
const pp = player.group.position;
const ep = e.group.position;
const dx = pp.x - ep.x, dz = pp.z - ep.z;
const dist = Math.hypot(dx, dz);
// desired point: orbit the player at ~38m, with separation from other enemies
e.ai.strafe += dt * 0.35;
const orbitR = 34 + Math.sin(e.ai.strafe * 0.7) * 8;
const px = dx / (dist || 1), pz = dz / (dist || 1);
let tx = pp.x - px * orbitR - pz * e.ai.orbitDir * orbitR * 0.5;
let tz = pp.z - pz * orbitR + px * e.ai.orbitDir * orbitR * 0.5;
for (const o of enemies) {
if (o === e || o.dead) continue;
const ox = ep.x - o.group.position.x, oz = ep.z - o.group.position.z;
const od = Math.hypot(ox, oz);
if (od < 14 && od > 0.01) { tx += ox / od * (14 - od) * 2; tz += oz / od * (14 - od) * 2; }
}
const desired = Math.atan2(tx - ep.x, tz - ep.z);
let dh = desired - e.heading;
while (dh > Math.PI) dh -= Math.PI * 2;
while (dh < -Math.PI) dh += Math.PI * 2;
const turn = THREE.MathUtils.clamp(dh * 2.5, -1, 1);
const thrust = Math.abs(dh) > 1.9 ? 4 : (dist > orbitR + 8 ? 15 : 9);
updateBoatPhysics(e, dt, t, thrust, turn);
// turret tracks the player with a little lag
const aimYaw = Math.atan2(pp.x - ep.x, pp.z - ep.z);
let ty = aimYaw - e.heading;
while (ty > Math.PI) ty -= Math.PI * 2;
while (ty < -Math.PI) ty += Math.PI * 2;
e.turret.rotation.y += (ty - e.turret.rotation.y) * Math.min(1, 3 * dt);
// fire when roughly aimed
e.ai.fireTimer -= dt;
if (e.ai.fireTimer <= 0 && Math.abs(ty) < 0.12 && dist < 80 && !player.dead) {
e.ai.fireTimer = 2.4 + Math.random() * 1.6;
const T = dist / 38;
const lead = 0.55;
const target = new THREE.Vector3(
pp.x + player.vel.x * T * lead + (Math.random() - 0.5) * 6,
0.4,
pp.z + player.vel.z * T * lead + (Math.random() - 0.5) * 6);
fireShell(e, target, 40);
}
}
/* ============================================================
CAMERA
============================================================ */
const camPos = new THREE.Vector3(0, 9, -20);
function updateCamera(dt) {
const p = player.group.position;
const fx = Math.sin(player.heading), fz = Math.cos(player.heading);
const speed = Math.hypot(player.vel.x, player.vel.z);
// mouse-influenced offset
const side = mouse.x * 5;
const rx = Math.cos(player.heading), rz = -Math.sin(player.heading);
const dist = 17 + speed * 0.12;
const desired = new THREE.Vector3(
p.x - fx * dist + rx * side,
p.y + 7.5 + mouse.y * 1.5,
p.z - fz * dist + rz * side);
const k = 1 - Math.exp(-4.5 * dt);
camPos.lerp(desired, k);
shakeAmp *= Math.exp(-3.5 * dt);
camera.position.copy(camPos);
if (shakeAmp > 0.001) {
camera.position.x += (Math.random() - 0.5) * shakeAmp;
camera.position.y += (Math.random() - 0.5) * shakeAmp * 0.6;
camera.position.z += (Math.random() - 0.5) * shakeAmp;
}
camera.lookAt(p.x + fx * 8, p.y + 1.5, p.z + fz * 8);
// sun light follows the action
sun.position.copy(p).addScaledVector(SUN_DIR, 150);
sun.target.position.copy(p);
sun.target.updateMatrixWorld();
}
/* ============================================================
MAIN LOOP
============================================================ */
const clock = new THREE.Clock();
let elapsed = 0;
let fireHeld = 0;
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
elapsed += dt;
// water time
waterUniforms.uTime.value = elapsed;
water.position.set(Math.round(player.group.position.x / 2) * 2, 0, Math.round(player.group.position.z / 2) * 2);
if (!gameOver) {
// --- player input ---
const fwd = (keys.KeyW || keys.ArrowUp ? 1 : 0) - (keys.KeyS || keys.ArrowDown ? 1 : 0);
const turn = (keys.KeyA || keys.ArrowLeft ? 1 : 0) - (keys.KeyD || keys.ArrowRight ? 1 : 0);
const thrust = fwd >= 0 ? fwd * 24 : fwd * 11;
updateBoatPhysics(player, dt, elapsed, thrust, turn);
// --- firing ---
player.fireCd -= dt;
fireHeld = (mouse.down || keys.Space) ? fireHeld + dt : 0;
if ((mouse.down || keys.Space) && player.fireCd <= 0) {
player.fireCd = 0.42;
fireShell(player, aimPoint, 58);
}
// turret aims at the mouse
const pp = player.group.position;
const aimYaw = Math.atan2(aimPoint.x - pp.x, aimPoint.z - pp.z);
let ty = aimYaw - player.heading;
while (ty > Math.PI) ty -= Math.PI * 2;
while (ty < -Math.PI) ty += Math.PI * 2;
player.turret.rotation.y += (ty - player.turret.rotation.y) * Math.min(1, 10 * dt);
// health regen
if (elapsed - player.lastDamage > 5 && player.hp > 0) {
player.hp = Math.min(player.maxHp, player.hp + 2.2 * dt);
}
// enemies
for (const e of enemies) updateEnemy(e, dt, elapsed);
// cull fully-sunk enemies
for (let i = enemies.length - 1; i >= 0; i--) {
if (enemies[i].dead && enemies[i].sinking > 3.2) {
scene.remove(enemies[i].group);
enemies.splice(i, 1);
}
}
// respawn hostiles
spawnTimer -= dt;
const alive = enemies.filter(e => !e.dead).length;
const want = Math.min(6, 3 + Math.floor(waveLevel / 2) + 1);
if (alive < want && spawnTimer <= 0) {
spawnTimer = 4;
spawnEnemy();
}
} else {
// game over: keep seas alive, sink the player
updateBoatPhysics(player, dt, elapsed, 0, 0);
for (const e of enemies) updateEnemy(e, dt, elapsed);
}
updateAim();
updateShells(dt, elapsed);
updateRogue(dt);
spray.update(dt);
wake.update(dt);
fire.update(dt);
updateRings(dt);
updateFlashes(dt);
updateCamera(dt);
updateHUD();
drawMinimap();
renderer.render(scene, camera);
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// spawn the first hostile wave
for (let i = 0; i < 4; i++) spawnEnemy();
// debug / QA hook
window.__boatDebug = {
snapshot: () => ({
shells: shells.length, enemies: enemies.length, alive: enemies.filter(e => !e.dead).length,
playerY: player.group.position.y, playerHp: player.hp,
playerPos: { x: player.group.position.x, z: player.group.position.z },
enemyInfo: enemies.map(e => ({
x: Math.round(e.group.position.x), z: Math.round(e.group.position.z),
vx: Math.round(e.vel.x), vz: Math.round(e.vel.z),
hp: Math.round(e.hp), dead: e.dead,
})),
rogue: rogue.active, rogueFront: rogue.active ? rogue.speed * (elapsed - rogue.t0) : null,
score, kills, elapsed,
}),
forceRogue: () => { rogueTimer = 0.05; },
fire: () => { fireShell(player, aimPoint, 58); },
killPlayer: () => damagePlayer(9999),
restart: () => resetGame(),
movePlayer: (x, z) => { player.group.position.x = x; player.group.position.z = z; },
};
animate();
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.