Boat game, built by Mistral Large 4
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.0">
<title>IRON TIDE — Three.js Naval Combat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #06121f; font-family: 'Courier New', monospace; }
canvas { display: block; cursor: none; touch-action: none; }
#crosshair { position: fixed; left: 0; top: 0; width: 28px; height: 28px; margin: -14px 0 0 -14px;
pointer-events: none; z-index: 30; mix-blend-mode: difference; transition: transform .05s; }
#crosshair::before, #crosshair::after { content: ''; position: absolute; }
#crosshair::before { left: 13px; top: 0; width: 2px; height: 28px;
background: linear-gradient(to bottom, #fff 0 10px, transparent 10px 18px, #fff 18px 28px); }
#crosshair::after { top: 13px; left: 0; height: 2px; width: 28px;
background: linear-gradient(to right, #fff 0 10px, transparent 10px 18px, #fff 18px 28px); }
#crosshair.locked { mix-blend-mode: normal; }
#crosshair.locked::before { background: linear-gradient(to bottom, #ff5555 0 10px, transparent 10px 18px, #ff5555 18px 28px); }
#crosshair.locked::after { background: linear-gradient(to right, #ff5555 0 10px, transparent 10px 18px, #ff5555 18px 28px); }
#crosshair.hidden { opacity: 0; }
.panel { position: fixed; top: 14px; padding: 8px 12px; background: rgba(6,18,31,.72);
border: 1px solid rgba(120,200,220,.35); color: #bfe3ee; letter-spacing: 2px; font-size: 13px; z-index: 20; }
#hullPanel { left: 14px; display: flex; align-items: center; gap: 10px; }
#hullPanel .bar { width: 180px; height: 12px; border: 1px solid rgba(120,200,220,.5); background: rgba(0,0,0,.45); }
#hullFill { height: 100%; width: 100%; background: linear-gradient(90deg, #2fbf71, #8fe6b4); transition: width .25s; }
#hullFill.low { background: linear-gradient(90deg, #c0392b, #ff8b7a); }
#statPanel { right: 14px; text-align: right; line-height: 1.7; }
#cdWrap { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); width: 170px; height: 6px;
border: 1px solid rgba(120,200,220,.5); background: rgba(6,18,31,.7); z-index: 20; }
#cdFill { height: 100%; width: 0%; background: #ffd27f; }
#vignette { position: fixed; inset: 0; pointer-events: none; z-index: 25;
background: radial-gradient(ellipse at center, transparent 55%, rgba(2,8,16,.55) 100%); }
#damageFlash { position: fixed; inset: 0; pointer-events: none; z-index: 26; opacity: 0;
background: radial-gradient(ellipse at center, transparent 35%, rgba(255,30,30,.55) 100%); }
#underwater { position: fixed; inset: 0; pointer-events: none; z-index: 24; opacity: 0;
background: rgba(4,40,70,.6); transition: opacity .35s; }
#watermark { position: fixed; right: 12px; bottom: 10px; font-size: 10px; letter-spacing: 2px;
color: rgba(159,196,212,.55); z-index: 20; }
#overlay { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 18px; background: rgba(3,10,20,.8); color: #cfe9f2; text-align: center; cursor: pointer; }
#overlay h1 { font-size: 52px; letter-spacing: 14px; color: #eaf6fa; text-shadow: 0 0 26px rgba(90,180,220,.65); }
#overlay p { font-size: 13px; letter-spacing: 3px; line-height: 2.1; color: #9fc4d4; }
#overlay button { margin-top: 6px; padding: 12px 36px; font: inherit; letter-spacing: 4px; color: #06121f;
background: #9fd4e4; border: none; cursor: pointer; }
#overlay button:hover { background: #c4ecf7; }
#overlay.hidden { display: none; }
#hud { transition: opacity .5s; }
</style>
</head>
<body>
<div id="hud">
<div class="panel" id="hullPanel">
<span>HULL</span>
<div class="bar"><div id="hullFill"></div></div>
</div>
<div class="panel" id="statPanel">
<div id="scoreEl">SCORE 0</div>
<div id="enemiesEl">ENEMIES 0</div>
</div>
<div id="cdWrap"><div id="cdFill"></div></div>
</div>
<div id="crosshair" class="hidden"></div>
<div id="vignette"></div>
<div id="damageFlash"></div>
<div id="underwater"></div>
<div id="watermark">IRON TIDE · three.js</div>
<div id="overlay">
<h1 id="ovTitle">IRON TIDE</h1>
<p id="ovText">
SURVIVE THE FLEET<br>
<br>
WASD / ARROWS — SAIL · MOUSE — AIM CANNONS<br>
LEFT CLICK / SPACE — FIRE · WHEEL — ZOOM · RIGHT-DRAG — ORBIT CAMERA
</p>
<button id="ovBtn">SORTIE</button>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
/* ============================== helpers ============================== */
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
const rand = (a, b) => a + Math.random() * (b - a);
/* ============================== textures ============================== */
function radialTex(stops, size = 64) {
const c = document.createElement('canvas'); c.width = c.height = size;
const x = c.getContext('2d');
const g = x.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
for (const s of stops) g.addColorStop(s[0], s[1]);
x.fillStyle = g; x.fillRect(0, 0, size, size);
return new THREE.CanvasTexture(c);
}
const dotTex = radialTex([[0,'rgba(255,255,255,1)'],[0.4,'rgba(255,255,255,.55)'],[1,'rgba(255,255,255,0)']]);
const flashTex = radialTex([[0,'rgba(255,220,160,1)'],[0.35,'rgba(255,140,60,.8)'],[1,'rgba(255,80,20,0)']], 64);
const blobTex = radialTex([[0,'rgba(0,0,0,.5)'],[0.6,'rgba(0,0,0,.22)'],[1,'rgba(0,0,0,0)']], 128);
/* ============================== renderer / scene ============================== */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.18;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x99b8cc);
scene.fog = new THREE.Fog(0x99b8cc, 130, 950);
const camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 0.1, 2600);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
const sunDir = new THREE.Vector3(120, 160, 80).normalize();
const sunLight = new THREE.DirectionalLight(0xfff0dc, 2.3);
sunLight.position.copy(sunDir).multiplyScalar(300);
scene.add(sunLight);
scene.add(new THREE.HemisphereLight(0xbfe0ff, 0x2c3a48, 0.6));
/* ============================== sky ============================== */
const skyUniforms = {
uSunDir: { value: sunDir },
uHorizon: { value: new THREE.Color(0.36, 0.49, 0.60) },
uZenith: { value: new THREE.Color(0.015, 0.05, 0.20) },
uTime: { value: 0 }
};
const sky = new THREE.Mesh(
new THREE.SphereGeometry(1200, 48, 28),
new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false,
uniforms: skyUniforms,
vertexShader: `
varying vec3 vDir;
void main(){ vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
varying vec3 vDir;
uniform vec3 uSunDir; uniform vec3 uHorizon; uniform vec3 uZenith; uniform float uTime;
float hash2(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123); }
float noise2(vec2 p){
vec2 i = floor(p); vec2 f = fract(p); f = f*f*(3.0-2.0*f);
return mix(mix(hash2(i), hash2(i+vec2(1.,0.)), f.x), mix(hash2(i+vec2(0.,1.)), hash2(i+vec2(1.,1.)), f.x), f.y);
}
float fbm(vec2 p){ float v=0.; float a=0.5;
for(int i=0;i<5;i++){ v += a*noise2(p); p = p*2.03 + vec2(19.7); a*=0.5; } return v; }
void main(){
vec3 d = normalize(vDir);
float h = clamp(d.y, 0.0, 1.0);
vec3 col = mix(uHorizon, uZenith, pow(h, 0.55));
float s = max(dot(d, uSunDir), 0.0);
col += vec3(1.0,0.82,0.6) * (pow(s,20.0)*0.35 + pow(s,480.0)*7.0);
if (d.y > 0.02) {
vec2 uv = d.xz / d.y;
float c = fbm(uv*1.4 + vec2(uTime*0.006, uTime*0.004));
c = smoothstep(0.42, 0.72, c);
col = mix(col, vec3(0.93,0.96,0.99), c*0.5);
}
gl_FragColor = vec4(col, 1.0);
}`
})
);
scene.add(sky);
/* ============================== gerstner waves (shared JS/GLSL) ============================== */
const WAVES = [
{ d: [ 1.00, 0.20], L: 90, A: 2.40, Q: 0.35 },
{ d: [ 0.80, -0.60], L: 46, A: 1.60, Q: 0.45 },
{ d: [-0.50, 0.85], L: 26, A: 1.05, Q: 0.50 },
{ d: [ 0.30, 0.95], L: 12, A: 0.55, Q: 0.55 },
{ d: [-0.85, -0.50], L: 7, A: 0.28, Q: 0.60 },
].map(w => { const l = Math.hypot(w.d[0], w.d[1]); return { dx: w.d[0]/l, dz: w.d[1]/l, L: w.L, A: w.A, Q: w.Q }; });
const SUM_A = WAVES.reduce((s, w) => s + w.A, 0);
function waterHeight(x, z, t) {
let h = 0;
for (const w of WAVES) {
const k = 2 * Math.PI / w.L, om = Math.sqrt(9.81 * k);
h += w.A * Math.sin(k * (w.dx * x + w.dz * z) - om * t);
}
return h;
}
const _ws = { nx: 0, ny: 1, nz: 0 };
function waterSample(x, z, t) {
let dPx_x = 1, dPx_y = 0, dPx_z = 0, dPz_x = 0, dPz_y = 0, dPz_z = 1;
for (const w of WAVES) {
const k = 2 * Math.PI / w.L, om = Math.sqrt(9.81 * k);
const th = k * (w.dx * x + w.dz * z) - om * t;
const C = Math.cos(th), S = Math.sin(th);
const kQA = k * w.Q * w.A;
dPx_x += -kQA * w.dx * w.dx * S; dPx_y += w.A * k * w.dx * C; dPx_z += -kQA * w.dx * w.dz * S;
dPz_x += -kQA * w.dx * w.dz * S; dPz_y += w.A * k * w.dz * C; dPz_z += -kQA * w.dz * w.dz * S;
}
const nx = dPz_y * dPx_z - dPz_z * dPx_y;
const ny = dPz_z * dPx_x - dPz_x * dPx_z;
const nz = dPz_x * dPx_y - dPz_y * dPx_x;
const il = 1 / Math.hypot(nx, ny, nz);
_ws.nx = nx * il; _ws.ny = ny * il; _ws.nz = nz * il;
return _ws;
}
/* ============================== water ============================== */
const waterUniforms = {
uTime: { value: 0 },
uCamPos: { value: new THREE.Vector3() },
uSunDir: { value: sunDir },
uSunColor:{ value: new THREE.Color(1.0, 0.90, 0.75) },
uDeep: { value: new THREE.Color(0.004, 0.030, 0.055) },
uShallow: { value: new THREE.Color(0.012, 0.075, 0.105) },
uFoam: { value: new THREE.Color(0.70, 0.79, 0.85) },
uHorizon: { value: new THREE.Color(0.36, 0.49, 0.60) },
uZenith: { value: new THREE.Color(0.015, 0.05, 0.20) },
uSumA: { value: SUM_A },
uDir: { value: WAVES.map(w => new THREE.Vector2(w.dx, w.dz)) },
uAmp: { value: WAVES.map(w => w.A) },
uLen: { value: WAVES.map(w => w.L) },
uQ: { value: WAVES.map(w => w.Q) }
};
const water = new THREE.Mesh(
new THREE.PlaneGeometry(1600, 1600, 440, 440),
new THREE.ShaderMaterial({
uniforms: waterUniforms,
vertexShader: `
uniform float uTime;
uniform vec2 uDir[5];
uniform float uAmp[5];
uniform float uLen[5];
uniform float uQ[5];
varying vec3 vWorldPos;
varying vec3 vNormal;
varying float vWaveY;
void main(){
vec3 p = position;
float py = 0.0;
vec3 dPx = vec3(1.0, 0.0, 0.0);
vec3 dPz = vec3(0.0, 0.0, 1.0);
for (int i = 0; i < 5; i++) {
vec2 d = uDir[i];
float A = uAmp[i];
float k = 6.28318530718 / uLen[i];
float w = sqrt(9.81 * k);
float th = k * (d.x * p.x + d.y * p.z) - w * uTime;
float C = cos(th); float S = sin(th);
float QA = uQ[i] * A;
p.x += QA * d.x * C;
p.z += QA * d.y * C;
py += A * S;
float kQA = k * QA;
dPx.x += -kQA * d.x * d.x * S;
dPx.y += A * k * d.x * C;
dPx.z += -kQA * d.x * d.y * S;
dPz.x += -kQA * d.x * d.y * S;
dPz.y += A * k * d.y * C;
dPz.z += -kQA * d.y * d.y * S;
}
p.y += py;
vec3 n = normalize(cross(dPz, dPx));
vNormal = normalMatrix * n;
vec4 wp = modelMatrix * vec4(p, 1.0);
vWorldPos = wp.xyz;
vWaveY = py;
gl_Position = projectionMatrix * viewMatrix * wp;
}`,
fragmentShader: `
varying vec3 vWorldPos;
varying vec3 vNormal;
varying float vWaveY;
uniform vec3 uCamPos;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform vec3 uDeep;
uniform vec3 uShallow;
uniform vec3 uFoam;
uniform vec3 uHorizon;
uniform vec3 uZenith;
uniform float uTime;
uniform float uSumA;
vec3 skyCol(vec3 dir){
float h = clamp(dir.y, 0.0, 1.0);
vec3 c = mix(uHorizon, uZenith, pow(h, 0.55));
float s = max(dot(dir, uSunDir), 0.0);
c += vec3(1.0,0.82,0.6) * (pow(s,24.0)*0.3 + pow(s,400.0)*8.0);
return c;
}
float sparkle(vec2 p){ return fract(sin(dot(p, vec2(12.9898,78.233))) * 43758.5453); }
void main(){
vec3 N = normalize(vNormal);
vec3 toCam = normalize(uCamPos - vWorldPos);
float dist = length(uCamPos - vWorldPos);
vec2 rp = vWorldPos.xz;
float t = uTime;
float r1 = sin(rp.x*2.3 + t*1.9) * sin(rp.y*1.9 - t*1.4);
float r2 = sin(rp.x*1.7 - t*1.2 + 1.7) * sin(rp.y*2.6 + t*1.7 + 0.6);
float dStr = clamp(1.0 - dist/350.0, 0.25, 1.0) * 0.16;
N = normalize(N + vec3(r1, 0.0, r2) * dStr);
float ndv = clamp(dot(N, toCam), 0.0, 1.0);
float fres = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
vec3 refl = reflect(-toCam, N);
vec3 sky = skyCol(refl);
float hNorm = clamp(vWaveY / uSumA * 0.5 + 0.5, 0.0, 1.0);
vec3 body = mix(uDeep, uShallow, hNorm);
float sunDiff = max(dot(N, uSunDir), 0.0);
vec3 col = body * (0.18 + 0.85 * sunDiff);
col = mix(col, sky, fres);
vec3 Hv = normalize(uSunDir + toCam);
float ndh = max(dot(N, Hv), 0.0);
col += uSunColor * pow(ndh, 600.0) * 2.4;
col += uSunColor * pow(ndh, 60.0) * 0.18;
float gl = sparkle(rp * 7.0 + vec2(t*3.0, t*2.0));
col += uSunColor * pow(ndh, 220.0) * gl * 0.55 * clamp(1.0 - dist/500.0, 0.0, 1.0);
float crest = smoothstep(0.62, 0.92, vWaveY / uSumA);
float slope = smoothstep(0.30, 0.55, length(N.xz));
float foam = clamp(crest + slope * 0.6, 0.0, 1.0);
foam *= 0.75 + 0.25 * sin(t*2.0 + rp.x*0.5 + rp.y*0.4);
col = mix(col, uFoam, foam * 0.85);
float fog = 1.0 - exp(-pow(dist / 520.0, 2.0));
col = mix(col, uHorizon, fog);
gl_FragColor = vec4(col, 1.0);
}`
})
);
scene.add(water);
/* ============================== particles ============================== */
class Particles {
constructor(blending) {
this.max = 1500;
this.list = [];
const geo = new THREE.BufferGeometry();
this.pArr = new Float32Array(this.max * 3);
this.cArr = new Float32Array(this.max * 3);
this.sArr = new Float32Array(this.max);
this.aArr = new Float32Array(this.max);
geo.setAttribute('position', new THREE.BufferAttribute(this.pArr, 3));
geo.setAttribute('color', new THREE.BufferAttribute(this.cArr, 3));
geo.setAttribute('aSize', new THREE.BufferAttribute(this.sArr, 1));
geo.setAttribute('aAlpha', new THREE.BufferAttribute(this.aArr, 1));
this.mat = new THREE.ShaderMaterial({
uniforms: { uMap: { value: dotTex } },
transparent: true, depthWrite: false, blending, vertexColors: true,
vertexShader: `
attribute float aSize; attribute float aAlpha;
varying float vAlpha; varying vec3 vColor;
void main(){
vAlpha = aAlpha; vColor = color;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = clamp(aSize, 1.0, 64.0);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
uniform sampler2D uMap; varying float vAlpha; varying vec3 vColor;
void main(){ gl_FragColor = vec4(vColor, vAlpha) * texture2D(uMap, gl_PointCoord); }`
});
this.points = new THREE.Points(geo, this.mat);
this.points.frustumCulled = false;
this.points.renderOrder = 4;
scene.add(this.points);
}
burst(x, y, z, n, speed, o = {}) {
for (let i = 0; i < n; i++) {
if (this.list.length >= this.max) break;
const a = Math.random() * Math.PI * 2, r = Math.sqrt(Math.random());
this.list.push({
x, y, z,
vx: Math.cos(a) * r * speed * (o.spread ?? 1),
vy: (o.up ?? 0.6) * speed * (0.4 + Math.random() * 0.9) + (o.vy ?? 0),
vz: Math.sin(a) * r * speed * (o.spread ?? 1),
life: (o.life ?? 0.8) * (0.6 + Math.random() * 0.7),
age: 0,
size: (o.size ?? 0.35) * (0.6 + Math.random()),
r: o.r ?? 0.9, g: o.g ?? 0.93, b: o.b ?? 0.97,
grav: o.grav ?? 1.0
});
}
}
update(dt) {
const arr = this.list;
for (let i = arr.length - 1; i >= 0; i--) {
const p = arr[i];
p.age += dt;
if (p.age >= p.life) { arr.splice(i, 1); continue; }
p.vy -= 9.81 * p.grav * dt;
const dr = Math.exp(-1.8 * dt);
p.vx *= dr; p.vz *= dr;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
}
const n = arr.length;
for (let i = 0; i < n; i++) {
const p = arr[i];
const k = p.age / p.life;
const d = Math.max(1, Math.hypot(p.x - camera.position.x, p.y - camera.position.y, p.z - camera.position.z));
this.pArr[i*3] = p.x; this.pArr[i*3+1] = p.y; this.pArr[i*3+2] = p.z;
this.cArr[i*3] = p.r; this.cArr[i*3+1] = p.g; this.cArr[i*3+2] = p.b;
this.sArr[i] = p.size * 700 / d;
this.aArr[i] = (1 - k) * (1 - k);
}
this.points.geometry.setDrawRange(0, n);
this.pArr.needsUpdate = true; this.cArr.needsUpdate = true;
this.sArr.needsUpdate = true; this.aArr.needsUpdate = true;
}
}
const foam = new Particles(THREE.NormalBlending);
const sparks = new Particles(THREE.AdditiveBlending);
function foamBurst(x, y, z, n, speed, opts = {}) {
foam.burst(x, y, z, n, speed, { up: 1.0, size: 0.3, life: 0.8, r: 0.88, g: 0.92, b: 0.96, grav: 0.9, ...opts });
}
/* ============================== boat builder ============================== */
function makeSail(w, h, belly, forward) {
const geo = new THREE.PlaneGeometry(w, h, 14, 18);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const u = pos.getX(i) / w + 0.5;
const v = pos.getY(i) / h + 0.5;
pos.setXYZ(i, (forward ? u : -u) * w, (v - 0.5) * h, Math.sin(Math.PI * u) * belly);
}
geo.computeVertexNormals();
return geo;
}
function makeCannon(side) {
const g = new THREE.Group();
const wood = new THREE.MeshStandardMaterial({ color: 0x4a3423, roughness: 0.7, envMapIntensity: 0.5 });
const metal = new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.4, metalness: 0.85, envMapIntensity: 0.7 });
const carriage = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.5, 0.75), wood);
carriage.position.y = 0.55; g.add(carriage);
for (const wx of [-0.26, 0.26]) for (const wz of [-0.5, 0.5]) {
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.16, 14), wood);
wheel.rotation.z = Math.PI / 2; wheel.position.set(wx, 0.18, wz); g.add(wheel);
}
const bg = new THREE.Group(); bg.position.y = 0.85;
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.13, 2.1, 10), metal);
barrel.rotation.z = -Math.PI / 2; barrel.position.x = 0.1; bg.add(barrel);
const muz = new THREE.Mesh(new THREE.TorusGeometry(0.13, 0.035, 8, 16), metal);
muz.rotation.y = Math.PI / 2; muz.position.x = 1.12; bg.add(muz);
const breech = new THREE.Mesh(new THREE.SphereGeometry(0.17, 10, 10), metal);
breech.position.x = -0.95; bg.add(breech);
bg.rotation.z = 0.45;
const marker = new THREE.Object3D(); marker.position.set(1.15, 0, 0);
bg.add(marker); g.add(bg);
return { group: g, barrel: bg, marker };
}
function buildBoat(o) {
const g = new THREE.Group();
const mat = (c, r = 0.6, m = 0.05) => new THREE.MeshStandardMaterial({ color: c, roughness: r, metalness: m, envMapIntensity: 0.5 });
const hullMat = new THREE.MeshStandardMaterial({ color: o.hull, roughness: 0.5, metalness: 0.15, emissive: 0xffffff, emissiveIntensity: 0, envMapIntensity: 0.6 });
const deckMat = mat(o.deck, 0.75);
const trimMat = mat(o.trim, 0.55);
const sailMat = new THREE.MeshStandardMaterial({ color: o.sail, roughness: 0.9, side: THREE.DoubleSide, envMapIntensity: 0.4 });
// hull
const pts = [[-15,0],[-15.2,2.6],[-14.4,4.2],[-11,5.3],[-3,5.7],[6,5.5],[12.5,4.4],[15.6,2.2],[16.4,0],[15.6,-2.2],[12.5,-4.4],[6,-5.5],[-3,-5.7],[-11,-5.3],[-14.4,-4.2],[-15.2,-2.6]];
const shape = new THREE.Shape();
shape.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i][0], pts[i][1]);
shape.closePath();
const hullGeo = new THREE.ExtrudeGeometry(shape, { depth: 3.0, bevelEnabled: true, bevelThickness: 0.6, bevelSize: 0.6, bevelSegments: 3, curveSegments: 20 });
hullGeo.rotateX(-Math.PI / 2);
const hull = new THREE.Mesh(hullGeo, hullMat);
hull.position.y = 0.6; g.add(hull);
// deck
const s = 0.84, ds = new THREE.Shape();
ds.moveTo(pts[0][0]*s, pts[0][1]*s);
for (let i = 1; i < pts.length; i++) ds.lineTo(pts[i][0]*s, pts[i][1]*s);
ds.closePath();
const deckGeo = new THREE.ExtrudeGeometry(ds, { depth: 0.22, bevelEnabled: false });
deckGeo.rotateX(-Math.PI / 2);
const deck = new THREE.Mesh(deckGeo, deckMat);
deck.position.y = 3.55; g.add(deck);
// cabin
const cabin = new THREE.Mesh(new THREE.BoxGeometry(5.4, 2.4, 4.6), mat(o.cabin, 0.6));
cabin.position.set(-3.5, 4.97, 0); g.add(cabin);
const roof = new THREE.Mesh(new THREE.BoxGeometry(5.8, 0.25, 5.0), trimMat);
roof.position.set(-3.5, 6.3, 0); g.add(roof);
const winMat = mat(0x101820, 0.25, 0.8);
for (const wx of [-5.6, -3.5, -1.4]) for (const wz of [2.31, -2.31]) {
const win = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.08), winMat);
win.position.set(wx, 5.15, wz); g.add(win);
}
// mast / boom / sails / flag / rigging
const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.2, 13.5, 10), trimMat);
mast.position.set(2.5, 10.52, 0); g.add(mast);
const nest = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.65, 0.7, 12), trimMat);
nest.position.set(2.5, 14.6, 0); g.add(nest);
const boom = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 9.5, 8), trimMat);
boom.rotation.z = -Math.PI / 2; boom.position.set(-2.2, 4.9, 0); g.add(boom);
const mainSail = new THREE.Mesh(makeSail(7.4, 10.2, 1.3, false), sailMat);
mainSail.position.set(2.45, 8.8, 0); g.add(mainSail);
const jib = new THREE.Mesh(makeSail(8.2, 9.3, 1.0, true), sailMat);
jib.position.set(2.75, 8.4, 0); g.add(jib);
const fShape = new THREE.Shape();
fShape.moveTo(0, 0); fShape.lineTo(0, 1.15); fShape.lineTo(1.7, 0.55); fShape.closePath();
const flag = new THREE.Mesh(new THREE.ShapeGeometry(fShape),
new THREE.MeshStandardMaterial({ color: o.flag, side: THREE.DoubleSide, roughness: 0.8, envMapIntensity: 0.3 }));
flag.position.set(2.62, 16.9, 0); g.add(flag);
const rigPts = [];
const mt = new THREE.Vector3(2.5, 17.2, 0);
for (const p of [[15.8,4.4,0],[-14.6,4.4,0],[-2,4.2,5.1],[-2,4.2,-5.1],[10,4.2,0]])
rigPts.push(mt, new THREE.Vector3(...p));
g.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(rigPts),
new THREE.LineBasicMaterial({ color: 0x2a2622, transparent: true, opacity: 0.7 })));
// lantern
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.5, 6), trimMat);
pole.position.set(-5.8, 6.55, 2.2); g.add(pole);
const lantern = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 10), new THREE.MeshBasicMaterial({ color: 0xffbb66 }));
lantern.position.set(-5.8, 6.9, 2.2); g.add(lantern);
// cannons
const cannons = [];
for (const side of [1, -1]) {
const c = makeCannon(side);
c.group.position.set(0.6, 4.0, side * 3.35);
g.add(c.group);
cannons.push({ side, group: c.group, barrel: c.barrel, marker: c.marker });
}
return { group: g, hullMat, cannons, lantern, pole };
}
function makeShadow() {
const m = new THREE.Mesh(new THREE.CircleGeometry(1, 24),
new THREE.MeshBasicMaterial({ map: blobTex, transparent: true, opacity: 0.4, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2 }));
m.rotation.order = 'YXZ';
m.rotation.x = -Math.PI / 2;
m.scale.set(12, 6.5, 1);
m.renderOrder = 1;
return m;
}
function makeHpBar() {
const c = document.createElement('canvas'); c.width = 64; c.height = 8;
const ctx = c.getContext('2d');
const tex = new THREE.CanvasTexture(c);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
spr.scale.set(3.2, 0.45, 1);
spr.renderOrder = 2;
return { sprite: spr, ctx, tex, draw(f) {
ctx.fillStyle = '#3a0d0d'; ctx.fillRect(0, 0, 64, 8);
ctx.fillStyle = '#8fe6a0'; ctx.fillRect(0, 0, Math.max(0, 64 * f), 8);
tex.needsUpdate = true;
}};
}
/* ============================== Boat class (buoyancy physics) ============================== */
class Boat {
constructor(x, z, yaw, o) {
this.isPlayer = !!o.isPlayer;
const built = buildBoat(o);
this.group = built.group;
this.hullMat = built.hullMat;
this.cannons = built.cannons;
this.group.rotation.order = 'YZX';
scene.add(this.group);
if (this.isPlayer) {
const pl = new THREE.PointLight(0xff9944, 5, 10, 2);
pl.position.set(-5.8, 6.9, 2.2);
this.group.add(pl);
}
this.pos = new THREE.Vector3(x, -1.0, z);
this.vel = new THREE.Vector3();
this.yaw = yaw; this.yawVel = 0;
this.pitch = 0; this.pitchVel = 0;
this.roll = 0; this.rollVel = 0;
this.vy = 0;
this.mass = 60;
this.hp = o.hp ?? 100;
this.maxHp = this.hp;
this.alive = true; this.dying = false; this.dead = false; this.sinkT = 0;
this.throttle = 0; this.steer = 0;
this.flash = 0;
this.buoys = [[12,0],[-12,0],[6,3.6],[6,-3.6],[-6,3.6],[-6,-3.6],[0,4.4],[0,-4.4]];
this.shadow = makeShadow();
scene.add(this.shadow);
if (!this.isPlayer) {
this.hpBar = makeHpBar();
this.hpBar.draw(1);
this.hpBar.sprite.position.set(0, 10.5, 0);
this.group.add(this.hpBar.sprite);
this.orbitAngle = Math.random() * Math.PI * 2;
this.orbitSide = Math.random() < 0.5 ? -1 : 1;
this.fireCd = 1.5 + Math.random() * 2;
this.cannonIndex = 0;
}
}
damage(d, at) {
if (this.dying || this.dead) return;
if (this.isPlayer && playerInvuln > 0) return;
this.hp -= d;
this.flash = 1;
if (this.isPlayer) {
playerInvuln = 1.1; flashT = 1;
shake = Math.max(shake, 0.7);
sfx('alarm'); updateHUD();
} else {
this.hpBar.draw(this.hp / this.maxHp);
sfx('hit', 0.5);
}
if (this.hp <= 0 && !this.dying) {
this.dying = true; this.sinkT = 0;
if (!this.isPlayer) this.hpBar.sprite.visible = false;
explosion(this.pos.x, this.pos.y + 1.5, this.pos.z, this.isPlayer ? 2.6 : 1.8);
sfx('explosion', this.isPlayer ? 0.8 : 0.5);
if (this.isPlayer) doGameOver();
else { score += 150; updateHUD(); }
}
}
update(dt, time) {
if (this.dying) {
this.sinkT += dt;
this.pos.y -= dt * 1.3;
this.roll += dt * (0.3 + this.sinkT * 0.3) * (0.5 + 0.5 * Math.sin(this.sinkT * 3));
this.pitch += dt * 0.2;
this.group.position.copy(this.pos);
this.group.rotation.set(this.roll * 2.2, this.yaw, this.pitch);
this.shadow.visible = false;
if (this.sinkT > 4.5) {
scene.remove(this.group); scene.remove(this.shadow);
this.dead = true;
}
return;
}
// ---- buoyancy: per-point spring forces (waves physically push the hull) ----
const c = Math.cos(this.yaw), s = Math.sin(this.yaw);
let Fy = -this.mass * 9.81, Tx = 0, Tz = 0;
for (const b of this.buoys) {
const wx = this.pos.x + b[0] * c + b[1] * s;
const wz = this.pos.z - b[0] * s + b[1] * c;
const h = waterHeight(wx, wz, time);
const sub = h - this.pos.y;
let F = sub > 0 ? 60 * sub : 0;
F -= 26 * this.vy;
Fy += F;
Tx += -( -b[0] * s + b[1] * c) * F;
Tz += (b[0] * c + b[1] * s) * F;
if (sub > 0.5 && Math.random() < dt * (1.5 + sub * 2.5))
foamBurst(wx, h, wz, 5 + sub * 6, 1.4 + sub * 0.5, { up: 1.4, size: 0.28, life: 0.7 });
}
this.vy += (Fy / this.mass) * dt;
this.pos.y += this.vy * dt;
this.pitchVel += (Tz / 4200 - this.pitch * 2.5 - this.pitchVel * 5.5) * dt;
this.rollVel += (Tx / 520 - this.roll * 2.5 - this.rollVel * 4.5) * dt;
this.pitch = clamp(this.pitch + this.pitchVel * dt, -0.55, 0.55);
this.roll = clamp(this.roll + this.rollVel * dt, -0.55, 0.55);
// ---- horizontal motion ----
const fwx = Math.cos(this.yaw), fwz = -Math.sin(this.yaw);
const rtx = Math.sin(this.yaw), rtz = Math.cos(this.yaw);
const vF = this.vel.x * fwx + this.vel.z * fwz;
const vR = this.vel.x * rtx + this.vel.z * rtz;
const aF = (this.throttle * 2600 - 6.2 * vF * Math.abs(vF)) / this.mass;
const aR = -vR * 7.0;
this.vel.x += (fwx * aF + rtx * aR) * dt;
this.vel.z += (fwz * aF + rtz * aR) * dt;
const speedF = Math.min(Math.abs(vF) / 12, 1);
this.yawVel += this.steer * 4.5 * (0.25 + 0.75 * speedF) * dt;
this.yawVel *= Math.exp(-2.0 * dt);
this.yawVel = clamp(this.yawVel, -1.1, 1.1);
this.yaw += this.yawVel * dt;
this.roll += this.steer * vF * 0.0022;
this.pos.x += this.vel.x * dt;
this.pos.z += this.vel.z * dt;
if (Math.abs(this.pos.x) > 620) this.vel.x -= Math.sign(this.pos.x) * 60 * dt;
if (Math.abs(this.pos.z) > 620) this.vel.z -= Math.sign(this.pos.z) * 60 * dt;
this.pos.x = clamp(this.pos.x, -700, 700);
this.pos.z = clamp(this.pos.z, -700, 700);
this.group.position.copy(this.pos);
this.group.rotation.set(this.roll, this.yaw, this.pitch);
this.shadow.position.set(this.pos.x, 0.2, this.pos.z);
this.shadow.rotation.y = this.yaw;
// ---- wake / bow wave ----
if (Math.abs(vF) > 2.5 && Math.random() < dt * 26) {
const wx = this.pos.x - fwx * 13 + rand(-2.5, 2.5);
const wz = this.pos.z - fwz * 13 + rand(-2.5, 2.5);
foamBurst(wx, waterHeight(wx, wz, time) + 0.05, wz, 2, 1.0, { up: 0.25, size: 0.3, life: 0.8, vy: 0.4 });
}
if (vF > 9 && Math.random() < dt * 14) {
const wx = this.pos.x + fwx * 15 + rand(-1.5, 1.5);
const wz = this.pos.z + fwz * 15 + rand(-1.5, 1.5);
foamBurst(wx, waterHeight(wx, wz, time), wz, 4, 2.6, { up: 1.6, size: 0.34, life: 0.6 });
}
for (const cn of this.cannons) cn.barrel.position.x *= Math.exp(-7 * dt);
if (this.flash > 0) {
this.flash = Math.max(0, this.flash - dt * 2.2);
this.hullMat.emissiveIntensity = this.flash * 0.8;
}
}
}
/* ============================== game globals ============================== */
let score = 0, time = 0;
let started = false, gameOverFlag = false;
let fireCd = 0, playerInvuln = 0, flashT = 0, shake = 0;
let spawnTimer = 3, barrelTimer = 8;
let orbitYaw = 0, orbitPitch = 0, camDist = 38, rmbDrag = false, firing = false;
const aim = new THREE.Vector3(120, 0, 0);
const mouse = new THREE.Vector2(0, 0);
const raycaster = new THREE.Raycaster();
const enemies = [], barrels = [], projectiles = [], flashes = [], rings = [];
const ballGeo = new THREE.SphereGeometry(0.22, 10, 10);
const ballMat = new THREE.MeshStandardMaterial({ color: 0x15151a, roughness: 0.35, metalness: 0.85, envMapIntensity: 0.7 });
const ringGeo = new THREE.RingGeometry(0.9, 1.0, 40);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.55, depthWrite: false, side: THREE.DoubleSide });
const aimRing = new THREE.Mesh(new THREE.RingGeometry(0.7, 0.95, 40),
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.55, depthWrite: false, side: THREE.DoubleSide }));
aimRing.rotation.x = -Math.PI / 2;
aimRing.renderOrder = 2;
scene.add(aimRing);
const player = new Boat(0, 0, 0, {
isPlayer: true, hp: 100,
hull: 0x8c2f22, deck: 0x9a7b52, cabin: 0xe8e0cc, trim: 0x5a3a28, sail: 0xf2ead8, flag: 0xd23b2f
});
/* ============================== effects ============================== */
function explosion(x, y, z, power = 1) {
sparks.burst(x, y, z, Math.floor(45 * power), 8 * power, { up: 0.7, size: 0.22, life: 0.7, r: 1.0, g: 0.55, b: 0.18, grav: 1.4 });
sparks.burst(x, y, z, Math.floor(20 * power), 4 * power, { up: 0.9, size: 0.3, life: 1.0, r: 0.9, g: 0.85, b: 0.6, grav: 0.8 });
foam.burst(x, y, z, Math.floor(18 * power), 3 * power, { up: 1.5, size: 0.4, life: 1.0, r: 0.85, g: 0.9, b: 0.95, grav: 0.6 });
const ring = new THREE.Mesh(ringGeo, ringMat.clone());
ring.position.set(x, y + 0.1, z);
ring.rotation.x = -Math.PI / 2;
ring.renderOrder = 2;
scene.add(ring);
rings.push({ m: ring, t: 0, s: 4 * power });
}
function fireCannon(boat, cn, target, elev, owner) {
const muzzle = new THREE.Vector3();
cn.marker.getWorldPosition(muzzle);
let dx = target.x - muzzle.x, dz = target.z - muzzle.z;
const dl = Math.hypot(dx, dz) || 1; dx /= dl; dz /= dl;
const vel = new THREE.Vector3(dx * Math.cos(elev), Math.sin(elev), dz * Math.cos(elev)).multiplyScalar(58);
vel.x += rand(-0.015, 0.015); vel.z += rand(-0.015, 0.015);
const mesh = new THREE.Mesh(ballGeo, ballMat);
mesh.position.copy(muzzle);
scene.add(mesh);
projectiles.push({ mesh, pos: muzzle.clone(), vel, owner, life: 14 });
const fl = new THREE.Sprite(new THREE.SpriteMaterial({ map: flashTex, color: 0xffb066, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
fl.position.copy(muzzle); fl.scale.set(0.5, 0.5, 1); fl.renderOrder = 3;
scene.add(fl); flashes.push({ s: fl, t: 0 });
foam.burst(muzzle.x, muzzle.y, muzzle.z, 5, 0.7, { up: 0.1, size: 0.2, life: 0.7, grav: -0.8, r: 0.45, g: 0.45, b: 0.5 });
cn.barrel.position.x = -0.45;
const fwx = Math.cos(boat.yaw), fwz = -Math.sin(boat.yaw);
boat.vel.x -= fwx * 0.22; boat.vel.z -= fwz * 0.22;
sfx('cannon', owner === 'player' ? 0.5 : 0.2);
if (owner === 'player') shake = Math.min(1, shake + 0.22);
}
function tryFire() {
if (fireCd > 0) return;
fireCd = 0.9;
const toAim = Math.atan2(-(aim.z - player.pos.z), aim.x - player.pos.x);
const rel = wrapAngle(toAim - player.yaw);
const cn = rel <= 0 ? player.cannons[0] : player.cannons[1];
fireCannon(player, cn, aim, 0.45, 'player');
}
/* ============================== enemies ============================== */
let enemyVariant = 0;
function enemyOpts() {
enemyVariant++;
const palettes = [
{ hull: 0x5a4632, deck: 0x8a6f4d, cabin: 0xd8d0bc, trim: 0x3a2c20, sail: 0xc9c2b2, flag: 0x1a1a1a },
{ hull: 0x37414b, deck: 0x7d6a52, cabin: 0xcfc8b8, trim: 0x232a30, sail: 0xb8b2a4, flag: 0x8c1d1d },
{ hull: 0x4a5a3a, deck: 0x8a6f4d, cabin: 0xd8d0bc, trim: 0x2e3a26, sail: 0xc2bcae, flag: 0x1a1a1a }
];
return { ...palettes[enemyVariant % 3], hp: 100 };
}
function spawnEnemy() {
const ang = Math.random() * Math.PI * 2, dist = rand(260, 440);
const x = clamp(player.pos.x + Math.cos(ang) * dist, -600, 600);
const z = clamp(player.pos.z + Math.sin(ang) * dist, -600, 600);
enemies.push(new Boat(x, z, ang + Math.PI, enemyOpts()));
}
function updateEnemy(e, dt) {
if (!player.alive || gameOverFlag) { e.throttle = 0.2; e.steer = 0; }
else {
const dx = player.pos.x - e.pos.x, dz = player.pos.z - e.pos.z;
const dist = Math.hypot(dx, dz) || 1;
e.orbitAngle += dt * 0.1;
const tx = player.pos.x + Math.cos(e.orbitAngle) * 95 * e.orbitSide;
const tz = player.pos.z + Math.sin(e.orbitAngle) * 95 * e.orbitSide;
const ddx = tx - e.pos.x, ddz = tz - e.pos.z;
const d = Math.hypot(ddx, ddz) || 1;
const diff = wrapAngle(Math.atan2(-ddz, ddx) - e.yaw);
e.steer = clamp(diff * 2.4, -1, 1);
e.throttle = d > 55 ? 1 : 0.3;
const toP = Math.atan2(-(player.pos.z - e.pos.z), player.pos.x - e.pos.x);
const rel = wrapAngle(toP - e.yaw);
for (const cn of e.cannons) {
const lo = cn.side > 0 ? -2.75 : -0.3, hi = cn.side > 0 ? 0.3 : 2.75;
const target = clamp(rel, lo, hi);
cn.group.rotation.y += (target - cn.group.rotation.y) * Math.min(1, dt * 5);
}
e.fireCd -= dt;
if (e.fireCd <= 0 && dist < 175) {
if (Math.abs(wrapAngle(toP - e.yaw)) < 2.2) {
const lead = dist / 58;
const px = player.pos.x + player.vel.x * lead;
const pz = player.pos.z + player.vel.z * lead;
const el = clamp(0.2 + dist * 0.0024, 0.2, 0.8);
fireCannon(e, e.cannons[e.cannonIndex++ % 2], new THREE.Vector3(px, 0, pz), el, 'enemy');
e.fireCd = 2.0 + Math.random() * 2.0;
} else e.fireCd = 0.3;
}
}
e.update(dt, time);
}
/* ============================== barrels ============================== */
function spawnBarrel(x, z) {
const g = new THREE.Group();
const wood = new THREE.MeshStandardMaterial({ color: 0x7a5a38, roughness: 0.8, envMapIntensity: 0.4 });
const band = new THREE.MeshStandardMaterial({ color: 0x2c2c30, roughness: 0.5, metalness: 0.8, envMapIntensity: 0.6 });
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 1.1, 14), wood);
const b1 = new THREE.Mesh(new THREE.CylinderGeometry(0.53, 0.53, 0.12, 14), band); b1.position.y = 0.32;
const b2 = b1.clone(); b2.position.y = -0.32;
g.add(body, b1, b2);
scene.add(g);
barrels.push({ g, x, z, phase: Math.random() * 6, alive: true });
}
/* ============================== collisions ============================== */
function collideBoats(dt) {
const list = [player, ...enemies.filter(e => !e.dying && !e.dead)];
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
const a = list[i], b = list[j];
const dx = b.pos.x - a.pos.x, dz = b.pos.z - a.pos.z;
const d = Math.hypot(dx, dz), minD = 16;
if (d < minD && d > 0.001) {
const nx = dx / d, nz = dz / d;
const rel = (a.vel.x - b.vel.x) * nx + (a.vel.z - b.vel.z) * nz;
const ov = minD - d;
a.pos.x -= nx * ov * 0.5; a.pos.z -= nz * ov * 0.5;
b.pos.x += nx * ov * 0.5; b.pos.z += nz * ov * 0.5;
if (rel > 0) {
const imp = rel * 0.5;
a.vel.x -= nx * imp; a.vel.z -= nz * imp;
b.vel.x += nx * imp; b.vel.z += nz * imp;
const dmg = clamp((rel - 6) * 2.0, 0, 26);
if (dmg > 3) {
const mx = (a.pos.x + b.pos.x) / 2, mz = (a.pos.z + b.pos.z) / 2;
foamBurst(mx, waterHeight(mx, mz, time), mz, 18, 3.4, { up: 1.8, size: 0.35, life: 0.9 });
sfx('hit', 0.5);
const at = new THREE.Vector3(mx, 1, mz);
a.damage(dmg, at); b.damage(dmg * 0.8, at);
}
}
}
}
}
/* ============================== audio (synthesized) ============================== */
let actx = null;
function ensureAudio() {
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; } }
if (actx && actx.state === 'suspended') { try { actx.resume(); } catch (e) {} }
}
function noiseBuf(dur) {
const buf = actx.createBuffer(1, Math.floor(actx.sampleRate * dur), actx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return buf;
}
function sfx(name, gain = 1) {
if (!actx) return;
try {
const t = actx.currentTime;
const mkGain = () => { const g = actx.createGain(); g.connect(actx.destination); return g; };
if (name === 'cannon') {
const src = actx.createBufferSource(); src.buffer = noiseBuf(0.45);
const lp = actx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 420;
const g = mkGain(); g.gain.setValueAtTime(0.6 * gain, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
src.connect(lp); lp.connect(g); src.start(t);
const o = actx.createOscillator();
o.frequency.setValueAtTime(130, t); o.frequency.exponentialRampToValueAtTime(45, t + 0.4);
const g2 = mkGain(); g2.gain.setValueAtTime(0.5 * gain, t); g2.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
o.connect(g2); o.start(t); o.stop(t + 0.5);
} else if (name === 'explosion') {
const src = actx.createBufferSource(); src.buffer = noiseBuf(1.0);
const lp = actx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900;
const g = mkGain(); g.gain.setValueAtTime(0.7 * gain, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.9);
src.connect(lp); lp.connect(g); src.start(t);
const o = actx.createOscillator();
o.frequency.setValueAtTime(70, t); o.frequency.exponentialRampToValueAtTime(30, t + 0.6);
const g2 = mkGain(); g2.gain.setValueAtTime(0.6 * gain, t); g2.gain.exponentialRampToValueAtTime(0.001, t + 0.6);
o.connect(g2); o.start(t); o.stop(t + 0.7);
} else if (name === 'splash') {
const src = actx.createBufferSource(); src.buffer = noiseBuf(0.3);
const hp = actx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 700;
const g = mkGain(); g.gain.setValueAtTime(0.35 * gain, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
src.connect(hp); hp.connect(g); src.start(t);
} else if (name === 'hit') {
const o = actx.createOscillator(); o.type = 'square';
o.frequency.setValueAtTime(900, t); o.frequency.exponentialRampToValueAtTime(120, t + 0.12);
const g = mkGain(); g.gain.setValueAtTime(0.2 * gain, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
o.connect(g); o.start(t); o.stop(t + 0.15);
} else if (name === 'alarm') {
const o = actx.createOscillator(); o.type = 'sawtooth';
o.frequency.setValueAtTime(520, t); o.frequency.exponentialRampToValueAtTime(180, t + 0.5);
const lp = actx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 1200;
const g = mkGain(); g.gain.setValueAtTime(0.12 * gain, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.5);
o.connect(lp); lp.connect(g); o.start(t); o.stop(t + 0.55);
} else if (name === 'horn') {
const o = actx.createOscillator(); o.type = 'sawtooth';
o.frequency.setValueAtTime(82, t); o.frequency.exponentialRampToValueAtTime(70, t + 1.0);
const lp = actx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
const g = mkGain();
g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(0.3 * gain, t + 0.15);
g.gain.exponentialRampToValueAtTime(0.001, t + 1.1);
o.connect(lp); lp.connect(g); o.start(t); o.stop(t + 1.2);
}
} catch (e) {}
}
/* ============================== input ============================== */
const keys = {};
const crosshair = document.getElementById('crosshair');
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('mousemove', e => {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
crosshair.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
if (rmbDrag) {
orbitYaw += e.movementX * 0.005;
orbitPitch = clamp(orbitPitch - e.movementY * 0.003, -0.15, 0.45);
}
});
window.addEventListener('mousedown', e => {
if (!started || gameOverFlag) return;
if (e.button === 0) { firing = true; ensureAudio(); }
if (e.button === 2) rmbDrag = true;
});
window.addEventListener('mouseup', e => {
if (e.button === 0) firing = false;
if (e.button === 2) rmbDrag = false;
});
window.addEventListener('wheel', e => {
camDist = clamp(camDist * (1 + e.deltaY * 0.0012), 16, 60);
}, { passive: true });
window.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ============================== HUD / flow ============================== */
const hullFill = document.getElementById('hullFill');
const scoreEl = document.getElementById('scoreEl');
const enemiesEl = document.getElementById('enemiesEl');
const cdFill = document.getElementById('cdFill');
const damageFlash = document.getElementById('damageFlash');
const underEl = document.getElementById('underwater');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovText = document.getElementById('ovText');
function updateHUD() {
hullFill.style.width = Math.max(0, player.hp) + '%';
hullFill.classList.toggle('low', player.hp < 35);
scoreEl.textContent = 'SCORE ' + score;
enemiesEl.textContent = 'ENEMIES ' + enemies.filter(e => !e.dying && !e.dead).length;
}
function doGameOver() {
gameOverFlag = true;
ovTitle.textContent = 'THE SEA TOOK YOU';
ovText.innerHTML = 'FINAL SCORE · ' + score + '<br><br>click to try again';
overlay.classList.remove('hidden');
crosshair.classList.add('hidden');
}
overlay.addEventListener('click', () => {
if (gameOverFlag) { location.reload(); return; }
if (started) return;
started = true;
overlay.classList.add('hidden');
crosshair.classList.remove('hidden');
ensureAudio();
sfx('horn', 0.5);
});
/* ============================== main loop ============================== */
const clock = new THREE.Clock();
const desiredCam = new THREE.Vector3();
const lookTarget = new THREE.Vector3();
function loop() {
requestAnimationFrame(loop);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
// input
if (started && !gameOverFlag && player.alive) {
player.throttle = (keys['KeyW'] || keys['ArrowUp'] ? 1 : 0) - (keys['KeyS'] || keys['ArrowDown'] ? 1 : 0);
player.steer = (keys['KeyA'] || keys['ArrowLeft'] ? 1 : 0) - (keys['KeyD'] || keys['ArrowRight'] ? 1 : 0);
if (firing || keys['Space']) tryFire();
} else if (!player.dying) {
player.throttle = 0; player.steer = 0;
}
// player cannon visual aim
if (player.alive) {
const toAim = Math.atan2(-(aim.z - player.pos.z), aim.x - player.pos.x);
const rel = wrapAngle(toAim - player.yaw);
for (const cn of player.cannons) {
const lo = cn.side > 0 ? -2.75 : -0.3, hi = cn.side > 0 ? 0.3 : 2.75;
const target = clamp(rel, lo, hi);
cn.group.rotation.y += (target - cn.group.rotation.y) * Math.min(1, dt * 8);
}
}
player.update(dt, time);
for (let i = enemies.length - 1; i >= 0; i--) {
updateEnemy(enemies[i], dt);
if (enemies[i].dead) enemies.splice(i, 1);
}
collideBoats(dt);
// projectiles
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.life -= dt;
p.vel.y -= 9.81 * dt;
p.pos.addScaledVector(p.vel, dt);
p.mesh.position.copy(p.pos);
let dead = false;
const h = waterHeight(p.pos.x, p.pos.z, time);
if (p.pos.y <= h + 0.05) {
foamBurst(p.pos.x, h, p.pos.z, 20, 3.2, { up: 1.6, size: 0.3, life: 0.8 });
sfx('splash', 0.3);
dead = true;
} else if (p.owner === 'player') {
for (const e of enemies) {
if (e.dying || e.dead) continue;
if (Math.hypot(p.pos.x - e.pos.x, p.pos.z - e.pos.z) < 4.5 && Math.abs(p.pos.y - (e.pos.y + 2)) < 4) {
e.damage(34, p.pos);
explosion(p.pos.x, p.pos.y, p.pos.z, 0.7);
dead = true; break;
}
}
if (!dead) for (const b of barrels) {
if (!b.alive) continue;
if (Math.hypot(p.pos.x - b.x, p.pos.z - b.z) < 2) {
b.alive = false; scene.remove(b.g);
foamBurst(b.x, 0.5, b.z, 16, 3, { up: 1.5, size: 0.3 });
explosion(b.x, 0.6, b.z, 0.5);
score += 10; updateHUD();
dead = true; break;
}
}
} else if (player.alive) {
if (Math.hypot(p.pos.x - player.pos.x, p.pos.z - player.pos.z) < 4.5 && Math.abs(p.pos.y - (player.pos.y + 2)) < 4.5) {
player.damage(22, p.pos);
explosion(p.pos.x, p.pos.y, p.pos.z, 0.8);
dead = true;
}
}
if (dead || p.life <= 0) { scene.remove(p.mesh); projectiles.splice(i, 1); }
}
// barrels
barrelTimer -= dt;
if (barrelTimer <= 0) {
barrelTimer = 12;
if (barrels.filter(b => b.alive).length < 8) {
const a = Math.random() * Math.PI * 2, r = rand(40, 220);
spawnBarrel(clamp(player.pos.x + Math.cos(a) * r, -650, 650), clamp(player.pos.z + Math.sin(a) * r, -650, 650));
}
}
for (const b of barrels) {
if (!b.alive) continue;
const smp = waterSample(b.x, b.z, time);
b.g.position.set(b.x, smp.ny * 0 + waterHeight(b.x, b.z, time) * 0 + (waterHeight(b.x, b.z, time)) + 0.25 + Math.sin(time * 1.8 + b.phase) * 0.06, b.z);
b.g.rotation.z = smp.nz * 0.25;
b.g.rotation.x = -smp.nx * 0.25;
}
// spawner
if (started && !gameOverFlag) {
spawnTimer -= dt;
if (spawnTimer <= 0) {
spawnTimer = rand(7, 14);
const cap = Math.min(6, 3 + Math.floor(score / 1200));
if (enemies.length < cap) spawnEnemy();
}
score += dt * 2;
}
// particles / effects
foam.update(dt);
sparks.update(dt);
for (let i = flashes.length - 1; i >= 0; i--) {
const f = flashes[i];
f.t += dt;
const k = f.t / 0.12;
if (k >= 1) { scene.remove(f.s); flashes.splice(i, 1); continue; }
f.s.scale.set(0.4 + k * 1.8, 0.4 + k * 1.8, 1);
f.s.material.opacity = 1 - k;
}
for (let i = rings.length - 1; i >= 0; i--) {
const r = rings[i];
r.t += dt;
const k = r.t / 0.6;
if (k >= 1) { scene.remove(r.m); rings.splice(i, 1); continue; }
const sc = r.s * (0.2 + k * k * 2.2);
r.m.scale.set(sc, sc, 1);
r.m.material.opacity = 0.55 * (1 - k);
}
// camera
const el = 0.36 + orbitPitch;
const back = player.yaw + orbitYaw;
desiredCam.set(
player.pos.x - Math.cos(back) * Math.cos(el) * camDist,
player.pos.y + 5 + Math.sin(el) * camDist,
player.pos.z + Math.sin(back) * Math.cos(el) * camDist
);
camera.position.lerp(desiredCam, 1 - Math.exp(-3.2 * dt));
shake *= Math.exp(-6 * dt);
camera.position.x += rand(-0.5, 0.5) * shake * 0.7;
camera.position.y += rand(-0.5, 0.5) * shake * 0.7;
lookTarget.set(player.pos.x, player.pos.y + 2.5, player.pos.z);
camera.lookAt(lookTarget);
// aim raycast onto water
raycaster.setFromCamera(mouse, camera);
if (raycaster.ray.direction.y < -0.01) {
const t = -raycaster.ray.origin.y / raycaster.ray.direction.y;
aim.set(
raycaster.ray.origin.x + raycaster.ray.direction.x * t,
0,
raycaster.ray.origin.z + raycaster.ray.direction.z * t
);
}
aimRing.position.set(aim.x, waterHeight(aim.x, aim.z, time) + 0.12, aim.z);
let locked = false;
for (const e of enemies)
if (!e.dying && !e.dead && Math.hypot(e.pos.x - aim.x, e.pos.z - aim.z) < 7) locked = true;
aimRing.material.color.set(locked ? 0xff5544 : 0xffffff);
crosshair.classList.toggle('locked', locked);
// shader uniforms
waterUniforms.uTime.value = time;
waterUniforms.uCamPos.value.copy(camera.position);
skyUniforms.uTime.value = time;
// HUD
fireCd = Math.max(0, fireCd - dt);
playerInvuln = Math.max(0, playerInvuln - dt);
flashT = Math.max(0, flashT - dt * 1.4);
damageFlash.style.opacity = flashT * 0.85;
cdFill.style.width = (Math.max(0, 1 - fireCd / 0.9) * 100) + '%';
const camH = waterHeight(camera.position.x, camera.position.z, time);
underEl.style.opacity = camera.position.y < camH - 0.3 ? 0.55 : 0;
updateHUD();
renderer.render(scene, camera);
}
// initial barrels
for (let i = 0; i < 8; i++) {
const a = Math.random() * Math.PI * 2, r = rand(30, 260);
spawnBarrel(Math.cos(a) * r, Math.sin(a) * r);
}
updateHUD();
loop();
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.