Boat game, built by Claude Haiku 5.5
Hostedvia Claude CodeOpenRouter
Show the code the model wroteHide the code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sea Wolf — Boat Combat</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #0b1c28; color: #eaf6ff;
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; user-select: none; }
canvas { position: fixed; inset: 0; display: block; cursor: none; }
.panel { position: fixed; background: rgba(8, 22, 34, .6); border-radius: 10px; padding: 12px 14px;
backdrop-filter: blur(6px); pointer-events: none; }
#hud { left: 16px; top: 16px; min-width: 230px; }
#hpTrack { height: 10px; border-radius: 5px; background: rgba(255,255,255,.15); overflow: hidden; margin: 6px 0 10px; }
#hpBar { height: 100%; width: 100%; background: linear-gradient(90deg, #ff5a4e, #ffd27a); transition: width .15s; }
#help { right: 16px; bottom: 16px; font-size: 12px; opacity: .85; line-height: 1.7; }
#warn { left: 50%; top: 18px; transform: translateX(-50%); color: #ffd27a; font-weight: 600; letter-spacing: 1px; opacity: 0; transition: opacity .4s; }
kbd { background: rgba(255,255,255,.15); border-radius: 4px; padding: 1px 6px; font: inherit; }
#flash { position: fixed; inset: 0; background: radial-gradient(ellipse at center, transparent 55%, rgba(255,40,30,.6)); opacity: 0; pointer-events: none; }
#crosshair { position: fixed; left: 0; top: 0; width: 30px; height: 30px; margin: -15px 0 0 -15px; box-sizing: border-box;
border: 2px solid rgba(255,255,255,.9); border-radius: 50%; box-shadow: 0 0 6px rgba(0,0,0,.4); pointer-events: none; }
#crosshair::after { content: ""; position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px 0 0 -2px; background: #fff; border-radius: 50%; }
#overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center;
background: rgba(4, 14, 22, .55); backdrop-filter: blur(3px); cursor: pointer; }
#overlay h1 { margin: 0 0 10px; font-size: 48px; letter-spacing: 3px; color: #ffd27a; }
#overlay p { margin: 6px 0; opacity: .9; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="flash"></div>
<div id="hud" class="panel">
<div>Hull <b id="hpTxt">100</b>%</div>
<div id="hpTrack"><div id="hpBar"></div></div>
<div>Score <b id="score">0</b> · Raiders <b id="foes">0</b></div>
<div>Swells <b id="swells">0</b> · Time <b id="time">0:00</b></div>
</div>
<div id="help" class="panel">
<kbd>W</kbd><kbd>S</kbd> throttle <kbd>A</kbd><kbd>D</kbd> steer<br>
Mouse aim <kbd>LMB</kbd> / <kbd>Space</kbd> fire <kbd>R</kbd> restart
</div>
<div id="warn" class="panel">⚠ SWELL INCOMING — HOLD ON</div>
<div id="crosshair"></div>
<div id="overlay">
<div>
<h1 id="title">SEA WOLF</h1>
<p id="subtitle">Keep your hull afloat and sink the raiders.</p>
<p id="hint">Click to start</p>
</div>
</div>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
// ===== Sea model: the same formulas drive the GPU surface and the CPU buoyancy =====
const GRAV = 9.81;
const MAX_PK = 4; // max simultaneous rogue swells
const AMB = [ // ambient swell trains: [dirX, dirZ, amplitude, wavelength]
[1.0, 0.35, 0.30, 42],
[0.55, 1.0, 0.20, 24],
[-0.45, 1.0, 0.14, 15],
[-1.0, -0.4, 0.10, 10],
[0.25, -1.0, 0.06, 8],
].map(([x, z, a, wl]) => {
const L = Math.hypot(x, z), k = 2 * Math.PI / wl;
return { dx: x / L, dz: z / L, a, k, om: Math.sqrt(GRAV * k) };
});
const swells = []; // rogue swell packets (see spawnSwell)
let T = 0; // sea clock in seconds
// Returns height h and its horizontal gradient (gx, gz) at (x, z) for time t.
function sea(x, z, t) {
let h = 0, gx = 0, gz = 0;
for (const w of AMB) {
const ph = w.k * (w.dx * x + w.dz * z) - w.om * t;
const c = Math.cos(ph), f = w.a * w.k * c;
h += w.a * Math.sin(ph);
gx += f * w.dx; gz += f * w.dz;
}
for (const p of swells) {
const sc = p.s0 + p.speed * (t - p.t0); // crest centre along travel direction
const sr = p.dx * x + p.dz * z - sc;
const l = -p.dz * x + p.dx * z - p.lc; // lateral offset
const e = p.amp * Math.exp(-(sr * sr) / (p.wa * p.wa) - (l * l) / (p.wl * p.wl));
const c = Math.cos(p.k * sr), s = Math.sin(p.k * sr);
const a1 = -2 * sr / (p.wa * p.wa), a2 = -2 * l / (p.wl * p.wl);
h += e * c;
const q1 = (a1 * c - p.k * s) * e, q2 = a2 * c * e;
gx += q1 * p.dx - q2 * p.dz;
gz += q1 * p.dz + q2 * p.dx;
}
return { h, gx, gz };
}
const f6 = v => v.toFixed(6);
const AMB_GLSL = AMB.map(w => `
{
float ph = ${f6(w.k)} * (${f6(w.dx)} * p.x + ${f6(w.dz)} * p.y) - ${f6(w.om)} * t;
float c = cos(ph);
r.x += ${f6(w.a)} * sin(ph);
float q = ${f6(w.a * w.k)} * c;
r.y += q * ${f6(w.dx)};
r.z += q * ${f6(w.dz)};
}`).join('\n');
const SEA_GLSL = `
uniform vec4 uPk0[${MAX_PK}]; // dir.xy, crest centre, amplitude
uniform vec4 uPk1[${MAX_PK}]; // wavenumber, along-width, lateral centre, lateral-width
vec3 sea(vec2 p, float t) {
vec3 r = vec3(0.0);
${AMB_GLSL}
for (int i = 0; i < ${MAX_PK}; i++) {
vec4 a = uPk0[i];
vec4 b = uPk1[i];
if (a.w <= 0.0) continue;
float sr = dot(p, a.xy) - a.z;
float l = dot(p, vec2(-a.y, a.x)) - b.z;
float e = a.w * exp(-(sr * sr) / (b.y * b.y) - (l * l) / (b.w * b.w));
float c = cos(b.x * sr), s = sin(b.x * sr);
float a1 = -2.0 * sr / (b.y * b.y), a2 = -2.0 * l / (b.w * b.w);
r.x += e * c;
float q1 = (a1 * c - b.x * s) * e, q2 = a2 * c * e;
r.y += q1 * a.x - q2 * a.y;
r.z += q1 * a.y + q2 * a.x;
}
return r;
}`;
const SKY_GLSL = `
vec3 skyColor(vec3 d) {
float y = clamp(d.y, 0.0, 1.0);
vec3 c = mix(vec3(0.72, 0.84, 0.93), vec3(0.16, 0.37, 0.62), pow(y, 0.45));
float sd = max(dot(d, uSunDir), 0.0);
c += vec3(1.0, 0.82, 0.55) * (0.25 * pow(sd, 6.0) + 3.0 * pow(sd, 600.0));
return c;
}`;
// ===== Renderer, scene, lights =====
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xb8d6ed, 250, 1400);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.5, 4000);
const SUN = new THREE.Vector3(-0.45, 0.42, -0.79).normalize();
const sun = new THREE.DirectionalLight(0xfff1d6, 2.6);
sun.position.copy(SUN).multiplyScalar(100);
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xcfe6ff, 0x1d3b4a, 0.9));
// ===== Sky dome (follows the camera) =====
const sky = new THREE.Mesh(new THREE.SphereGeometry(2000, 32, 16), new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false,
uniforms: { uSunDir: { value: SUN } },
vertexShader: `varying vec3 vDir;
void main() { vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `uniform vec3 uSunDir;
${SKY_GLSL}
varying vec3 vDir;
void main() { gl_FragColor = vec4(skyColor(normalize(vDir)), 1.0); }`,
}));
sky.renderOrder = -1;
scene.add(sky);
// ===== Water: displaced plane, per-pixel normals, fresnel reflection, sun glints, foam =====
const waterUniforms = {
uTime: { value: 0 },
uSunDir: { value: SUN },
uCam: { value: camera.position },
uPk0: { value: Array.from({ length: MAX_PK }, () => new THREE.Vector4()) },
uPk1: { value: Array.from({ length: MAX_PK }, () => new THREE.Vector4()) },
};
const water = new THREE.Mesh(new THREE.PlaneGeometry(1200, 1200, 300, 300), new THREE.ShaderMaterial({
uniforms: waterUniforms,
vertexShader: `
uniform float uTime;
${SEA_GLSL}
varying vec3 vW;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
wp.y += sea(wp.xz, uTime).x;
vW = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}`,
fragmentShader: `
uniform float uTime;
uniform vec3 uSunDir;
uniform vec3 uCam;
${SEA_GLSL}
${SKY_GLSL}
varying vec3 vW;
void main() {
vec3 s = sea(vW.xz, uTime);
vec3 n = normalize(vec3(-s.y, 1.0, -s.z));
vec3 V = normalize(uCam - vW);
float ndv = clamp(dot(n, V), 0.0, 1.0);
float F = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
vec3 sky = skyColor(reflect(-V, n));
vec3 body = mix(vec3(0.005, 0.06, 0.09), vec3(0.02, 0.23, 0.25), clamp(0.5 + 1.2 * s.x, 0.0, 1.0));
vec3 col = mix(body, sky * 0.95, F);
vec3 H = normalize(uSunDir + V);
col += vec3(1.0, 0.93, 0.75) * pow(max(dot(n, H), 0.0), 500.0) * 2.5;
float steep = length(s.yz);
float foam = smoothstep(0.22, 0.4, steep) + smoothstep(0.75, 1.1, s.x);
foam *= 0.6 + 0.4 * sin(vW.x * 1.7 + vW.z * 2.3 + uTime * 0.7);
col = mix(col, vec3(0.93, 0.96, 0.98), clamp(foam, 0.0, 0.9));
float fog = 1.0 - exp(-length(uCam - vW) * 0.0034);
col = mix(col, vec3(0.72, 0.84, 0.93), fog);
gl_FragColor = vec4(col, 1.0);
}`,
}));
water.rotation.x = -Math.PI / 2;
scene.add(water);
// Uploads the active swell packets to the shader.
function syncSwellUniforms() {
for (let i = 0; i < MAX_PK; i++) {
const p = swells[i];
if (p) {
waterUniforms.uPk0.value[i].set(p.dx, p.dz, p.s0 + p.speed * (T - p.t0), p.amp);
waterUniforms.uPk1.value[i].set(p.k, p.wa, p.lc, p.wl);
} else {
waterUniforms.uPk0.value[i].set(0, 0, 0, 0);
waterUniforms.uPk1.value[i].set(0, 1, 0, 1);
}
}
}
// ===== Ship models =====
const _v = new THREE.Vector3(), _v2 = new THREE.Vector3();
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
// Lofted V-section hull, bow at +Z. Two material groups: [hull sides, deck].
function hullGeometry(L, B, dH, D) {
const S = 24, K = 5; // stations along the length, half-section segments
const P = (i, j) => {
const s = i / S, u = (j - K) / K; // u in [-1, 1] across the beam
const hw = (B / 2) * (s < 0.55 ? 1 : Math.pow(Math.max(0, (1 - s) / 0.45), 0.8));
const deck = dH * (1 + 0.7 * s ** 3); // sheer rises toward the bow
const y = -D + (deck + D) * Math.pow(Math.abs(u), 1.25);
return new THREE.Vector3(hw * u, y, (s - 0.5) * L);
};
const hull = [], deck = [];
const push = (arr, a, b, c) => arr.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z);
for (let i = 0; i < S; i++) {
for (let j = 0; j < 2 * K; j++) {
const a = P(i, j), b = P(i, j + 1), c = P(i + 1, j), d = P(i + 1, j + 1);
push(hull, a, c, b); push(hull, b, c, d);
}
const a = P(i, 0), b = P(i + 1, 0), c = P(i, 2 * K), d = P(i + 1, 2 * K);
push(deck, a, c, b); push(deck, b, c, d); // closes the top between the two gunwales
}
const centre = new THREE.Vector3(0, dH, -L / 2); // transom fan
for (let j = 0; j < 2 * K; j++) push(hull, centre, P(0, j), P(0, j + 1));
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute([...hull, ...deck], 3));
geo.computeVertexNormals(); // non-indexed, so flat-shaded facets
geo.addGroup(0, hull.length / 3, 0);
geo.addGroup(hull.length / 3, deck.length / 3, 1);
return geo;
}
function buildShip({ L, B, dH, D, hullColor, deckColor, cabinColor, gun = 1 }) {
const group = new THREE.Group();
const hullMat = new THREE.MeshStandardMaterial({ color: hullColor, roughness: 0.5, side: THREE.DoubleSide, flatShading: true });
const deckMat = new THREE.MeshStandardMaterial({ color: deckColor, roughness: 0.8, side: THREE.DoubleSide, flatShading: true });
const cabinMat = new THREE.MeshStandardMaterial({ color: cabinColor, roughness: 0.6 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x20242b, roughness: 0.4, metalness: 0.5 });
group.add(new THREE.Mesh(hullGeometry(L, B, dH, D), [hullMat, deckMat]));
const cabin = new THREE.Mesh(new THREE.BoxGeometry(B * 0.7, 0.9 * gun, L * 0.28), cabinMat);
cabin.position.set(0, dH + 0.45 * gun, -L * 0.12);
group.add(cabin);
const turret = new THREE.Group(); // yaw
turret.position.set(0, dH * 1.3, L * 0.22);
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.38 * gun, 0.45 * gun, 0.25 * gun, 14), darkMat);
base.position.y = 0.12 * gun;
turret.add(base);
const pivot = new THREE.Group(); // elevation
pivot.position.y = 0.35 * gun;
turret.add(pivot);
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.14 * gun, 0.17 * gun, 2.1 * gun, 12), darkMat);
barrel.rotation.x = Math.PI / 2;
barrel.position.z = 1.0 * gun;
pivot.add(barrel);
const muzzle = new THREE.Object3D();
muzzle.position.z = 2.15 * gun;
pivot.add(muzzle);
group.add(turret);
return { group, turret, pivot, muzzle };
}
// ===== Player boat =====
const player = buildShip({ L: 7, B: 2.6, dH: 0.5, D: 0.9, hullColor: 0xf1ead8, deckColor: 0xb98a5a, cabinColor: 0xf4f4f0, gun: 1 });
const boatGroup = player.group;
scene.add(boatGroup);
const boat = { x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, yaw: 0, yawRate: 0, pitch: 0, roll: 0, pRate: 0, rRate: 0,
hp: 100, cool: 0, slamCD: 0 };
// ===== Buoyancy physics =====
// Hull is sampled at these local points. Each submerged point pushes up in proportion to its depth.
const BOAT_MASS = 1;
const SAMPLES = [[0, -0.55, 3.2], [0, -0.55, -3.0], [-1.1, -0.4, 0.4], [1.1, -0.4, 0.4],
[-1.1, -0.4, -1.8], [1.1, -0.4, -1.8], [0, -0.55, 0]];
const K_BUOY = 2.8; // spring stiffness per sample (equilibrium draft ~0.5)
const C_BUOY = 0.9; // vertical damping per sample
const I_PITCH = 4, I_ROLL = 1.0, ANG_DAMP = 3.5;
const THRUST = 10, BALL_SPEED = 55;
// Advances the boat by h seconds. The sea is sampled at the frame time T, so every substep sees the same waves.
function stepBoat(h, throttle, steer) {
const b = boat;
boatGroup.position.set(b.x, b.y, b.z);
boatGroup.rotation.set(b.pitch, b.yaw, b.roll, 'YXZ');
boatGroup.updateMatrixWorld(true);
let fy = 0, tauP = 0, tauR = 0;
for (const r of SAMPLES) {
const w = _v.set(r[0], r[1], r[2]).applyMatrix4(boatGroup.matrixWorld);
const depth = sea(w.x, w.z, T).h - w.y;
if (depth > 0) {
const f = Math.max(0, K_BUOY * depth - C_BUOY * b.vy);
fy += f;
tauP -= r[2] * f; // bow pushed up -> pitch the bow up
tauR += r[0] * f; // starboard pushed up -> roll
}
}
// Hard landing off a wave trough: hurt the hull
const prevVy = b.vy;
b.vy += (fy / BOAT_MASS - GRAV) * h;
b.y += b.vy * h;
if (prevVy < -4.5 && b.vy > -1 && b.slamCD <= 0) {
hurt((-prevVy - 4.5) * 2);
b.slamCD = 0.8;
shake = Math.max(shake, 0.3);
spray(b.x, sea(b.x, b.z, T).h, b.z, 30, 7);
}
b.pRate += (tauP / I_PITCH - ANG_DAMP * b.pRate) * h;
b.rRate += (tauR / I_ROLL - ANG_DAMP * b.rRate) * h;
b.pitch = THREE.MathUtils.clamp(b.pitch + b.pRate * h, -0.6, 0.6);
b.roll = THREE.MathUtils.clamp(b.roll + b.rRate * h, -0.6, 0.6);
// Horizontal: waves slide the hull downhill, the engine pushes it forward, water drags it.
const S0 = sea(b.x, b.z, T);
const fx = Math.sin(b.yaw), fz = Math.cos(b.yaw);
const thrust = throttle > 0 ? throttle * THRUST : throttle * THRUST * 0.5;
b.vx += (-0.6 * GRAV * S0.gx + thrust * fx) * h;
b.vz += (-0.6 * GRAV * S0.gz + thrust * fz) * h;
let vf = b.vx * fx + b.vz * fz; // forward speed
let vl = b.vx * fz - b.vz * fx; // sideways (keel) speed
vf *= Math.exp(-(0.25 + 0.03 * Math.abs(vf)) * h);
vl *= Math.exp(-3 * h);
b.vx = vf * fx + vl * fz;
b.vz = vf * fz - vl * fx;
const yawTarget = steer * THREE.MathUtils.clamp(0.4 + 0.09 * Math.abs(vf), 0, 1.8);
b.yawRate += (yawTarget - b.yawRate) * Math.min(1, 4 * h);
b.yaw += b.yawRate * h;
b.x += b.vx * h;
b.z += b.vz * h;
}
// ===== Spray particles =====
const MAX_SPRAY = 1500;
const sprayPos = new Float32Array(MAX_SPRAY * 3).fill(-999);
const sprayVel = new Float32Array(MAX_SPRAY * 3);
const sprayLife = new Float32Array(MAX_SPRAY);
let sprayIdx = 0;
const sprayGeo = new THREE.BufferGeometry();
sprayGeo.setAttribute('position', new THREE.BufferAttribute(sprayPos, 3));
scene.add(new THREE.Points(sprayGeo, new THREE.PointsMaterial({
color: 0xeaf7ff, size: 0.45, sizeAttenuation: true, transparent: true, opacity: 0.85, depthWrite: false })));
function spray(x, y, z, n, speed) {
for (let i = 0; i < n; i++) {
const k = sprayIdx++ % MAX_SPRAY, a = Math.random() * Math.PI * 2, s = speed * (0.4 + Math.random() * 0.8);
sprayPos.set([x, y, z], k * 3);
sprayVel.set([Math.cos(a) * s * 0.7, speed * (0.8 + Math.random() * 1.2), Math.sin(a) * s * 0.7], k * 3);
sprayLife[k] = 0.8 + Math.random() * 0.8;
}
}
function updateSpray(dt) {
for (let k = 0; k < MAX_SPRAY; k++) {
if (sprayLife[k] <= 0) continue;
sprayLife[k] -= dt;
sprayVel[k * 3 + 1] -= GRAV * dt;
for (let c = 0; c < 3; c++) sprayPos[k * 3 + c] += sprayVel[k * 3 + c] * dt;
if (sprayLife[k] <= 0) sprayPos[k * 3 + 1] = -999;
}
sprayGeo.attributes.position.needsUpdate = true;
}
// ===== Cannonballs =====
const balls = [];
const ballGeo = new THREE.SphereGeometry(0.28, 12, 10);
const ballMatP = new THREE.MeshStandardMaterial({ color: 0x15171b, roughness: 0.35, metalness: 0.7 });
const ballMatE = new THREE.MeshStandardMaterial({ color: 0xff7a2e, emissive: 0x7a2a00, roughness: 0.4 });
// Velocity that lands a ballistic shot on `to` at the given horizontal speed.
function ballisticVel(from, to, speed) {
const dx = to.x - from.x, dz = to.z - from.z, hd = Math.hypot(dx, dz) || 1;
const t = hd / speed;
const vy = (to.y - from.y + 0.5 * GRAV * t * t) / t;
return new THREE.Vector3(dx / hd * speed, vy, dz / hd * speed);
}
function fire(owner, from, v) {
const m = new THREE.Mesh(ballGeo, owner === 'p' ? ballMatP : ballMatE);
m.position.copy(from);
scene.add(m);
balls.push({ m, v, owner, life: 7 });
}
function updateBalls(dt) {
for (let i = balls.length - 1; i >= 0; i--) {
const b = balls[i], p = b.m.position;
b.v.y -= GRAV * dt;
p.addScaledVector(b.v, dt);
b.life -= dt;
let done = b.life <= 0;
const w = sea(p.x, p.z, T).h;
if (!done && p.y < w) { spray(p.x, w, p.z, 18, 7); done = true; }
if (!done && b.owner === 'e' && boat.hp > 0 && Math.hypot(p.x - boat.x, p.z - boat.z) < 2.4 && p.y < boat.y + 2) {
hurt(9);
spray(p.x, p.y, p.z, 22, 8);
done = true;
}
if (!done && b.owner === 'p') {
for (const f of foes) {
if (f.hp > 0 && Math.hypot(p.x - f.x, p.z - f.z) < 2.6 && p.y < f.y + 3) {
damageFoe(f, 1);
done = true;
break;
}
}
}
if (done) { scene.remove(b.m); balls.splice(i, 1); }
}
}
// ===== Raiders: gunboats that circle the player and shoot =====
const foes = [];
let score = 0, kills = 0;
function spawnFoe() {
const a = Math.random() * Math.PI * 2, d = 110 + Math.random() * 30;
const x = boat.x + Math.cos(a) * d, z = boat.z + Math.sin(a) * d;
const ship = buildShip({ L: 5.5, B: 2.1, dH: 0.45, D: 0.7, hullColor: 0x3a4150, deckColor: 0x2a2320, cabinColor: 0x6b1f1a, gun: 0.8 });
scene.add(ship.group);
foes.push({ x, z, y: 0, yaw: Math.atan2(boat.x - x, boat.z - z), speed: 11, yawRate: 0, pitch: 0, roll: 0,
hp: 2, cool: 1.5 + Math.random(), ram: 0, side: Math.random() < 0.5 ? 1 : -1, dead: 0, ship });
}
function damageFoe(f, n) {
const wasAlive = f.hp > 0;
f.hp -= n;
spray(f.x, sea(f.x, f.z, T).h, f.z, 25, 8);
if (wasAlive && f.hp <= 0) { score += 100; kills++; }
}
function updateFoes(dt) {
for (let i = foes.length - 1; i >= 0; i--) {
const f = foes[i];
if (f.hp <= 0) { // sinking
f.dead += dt;
f.y -= dt * 1.2;
f.roll += dt * 0.25;
f.ship.group.position.set(f.x, f.y, f.z);
f.ship.group.rotation.set(f.pitch, f.yaw, f.roll, 'YXZ');
if (f.dead > 4) { scene.remove(f.ship.group); foes.splice(i, 1); }
continue;
}
const dx = boat.x - f.x, dz = boat.z - f.z, d = Math.hypot(dx, dz);
// Steering: home in, and orbit at medium range
const want = Math.atan2(dx, dz) + (d < 60 ? 0.7 * f.side : 0);
const turn = wrapAngle(want - f.yaw);
f.yawRate += (THREE.MathUtils.clamp(turn * 1.8, -1.2, 1.2) - f.yawRate) * Math.min(1, 4 * dt);
f.yaw += f.yawRate * dt;
const wantSpeed = d > 45 ? 11 : (d < 28 ? -2 : 4);
f.speed += (wantSpeed - f.speed) * Math.min(1, 1.2 * dt);
f.x += Math.sin(f.yaw) * f.speed * dt;
f.z += Math.cos(f.yaw) * f.speed * dt;
// Ride the waves: height plus tilt from the local slope
const S = sea(f.x, f.z, T);
const sf = Math.sin(f.yaw), cf = Math.cos(f.yaw);
const gF = S.gx * sf + S.gz * cf, gR = S.gx * cf - S.gz * sf;
f.y = S.h - 0.25;
f.pitch += (-Math.atan(gF) * 0.8 - f.pitch) * Math.min(1, 6 * dt);
f.roll += (Math.atan(gR) * 0.8 - f.roll) * Math.min(1, 6 * dt);
f.ship.group.position.set(f.x, f.y, f.z);
f.ship.group.rotation.set(f.pitch, f.yaw, f.roll, 'YXZ');
f.ship.turret.rotation.y = wrapAngle(Math.atan2(dx, dz) - f.yaw);
// Ramming
f.ram -= dt;
if (d < 4.2 && f.ram <= 0) {
f.ram = 1;
hurt(5);
damageFoe(f, 1);
}
// Artillery with lead
f.cool -= dt;
if (f.cool <= 0 && d < 100 && f.hp > 0) {
f.cool = 4 + Math.random() * 2.5;
const tf = d / BALL_SPEED;
const target = new THREE.Vector3(boat.x + boat.vx * tf + (Math.random() - 0.5) * 3, 0,
boat.z + boat.vz * tf + (Math.random() - 0.5) * 3);
const muzzle = f.ship.muzzle.getWorldPosition(new THREE.Vector3());
fire('e', muzzle, ballisticVel(muzzle, target, BALL_SPEED));
}
}
}
// ===== Rogue swells: a crest packet that rolls in from a random direction and hits the boat =====
function spawnSwell() {
const th = Math.random() * Math.PI * 2;
const dx = Math.cos(th), dz = Math.sin(th); // travel direction
const sBoat = dx * boat.x + dz * boat.z;
const px = -dz, pz = dx; // lateral axis
swells.push({ dx, dz, s0: sBoat - 110, t0: T, speed: 10, k: 0.2, amp: 1.3 + Math.random() * 0.5,
wa: 16, wl: 26, lc: px * boat.x + pz * boat.z + (Math.random() * 2 - 1) * 6 });
if (swells.length > MAX_PK) swells.shift();
swellCount++;
warnT = 4;
}
// ===== Input =====
const keys = {};
addEventListener('keydown', e => {
keys[e.code] = true;
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
if (e.code === 'KeyR') restart();
});
addEventListener('keyup', e => { keys[e.code] = false; });
const mouse = { x: innerWidth / 2, y: innerHeight / 2, down: false };
const crosshair = document.getElementById('crosshair');
crosshair.style.left = mouse.x + 'px';
crosshair.style.top = mouse.y + 'px';
addEventListener('mousemove', e => {
mouse.x = e.clientX; mouse.y = e.clientY;
crosshair.style.left = mouse.x + 'px';
crosshair.style.top = mouse.y + 'px';
});
addEventListener('mousedown', e => { if (e.button === 0 && running) mouse.down = true; });
addEventListener('mouseup', e => { if (e.button === 0) mouse.down = false; });
// ===== Aim: mouse ray onto the sea =====
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), seaPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const aimPt = new THREE.Vector3(0, 0, -100);
const aimRing = new THREE.Mesh(new THREE.RingGeometry(1.1, 1.4, 40),
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.7, depthWrite: false }));
aimRing.rotation.x = -Math.PI / 2;
scene.add(aimRing);
function updateAim() {
ndc.set(mouse.x / innerWidth * 2 - 1, -(mouse.y / innerHeight) * 2 + 1);
ray.setFromCamera(ndc, camera);
if (!ray.ray.intersectPlane(seaPlane, aimPt)) ray.ray.at(300, aimPt);
aimRing.position.set(aimPt.x, sea(aimPt.x, aimPt.z, T).h + 0.1, aimPt.z);
}
function firePlayer() {
if (!running || boat.cool > 0) return;
boat.cool = 0.55;
const from = player.muzzle.getWorldPosition(new THREE.Vector3());
fire('p', from, ballisticVel(from, aimPt, BALL_SPEED));
shake = Math.max(shake, 0.12);
}
// ===== Damage, game state =====
let running = false, shake = 0, flashT = 0, warnT = 0, swellCount = 0, timeAlive = 0, foeTimer = 2, swellTimer = 5;
const hpBar = document.getElementById('hpBar'), hpTxt = document.getElementById('hpTxt'), scoreEl = document.getElementById('score'),
foesEl = document.getElementById('foes'), swellsEl = document.getElementById('swells'), timeEl = document.getElementById('time'),
flashEl = document.getElementById('flash'), warnEl = document.getElementById('warn'), overlay = document.getElementById('overlay'),
titleEl = document.getElementById('title'), subtitleEl = document.getElementById('subtitle'), hintEl = document.getElementById('hint');
function hurt(n) {
if (boat.hp <= 0) return;
boat.hp = Math.max(0, boat.hp - n);
flashT = 0.35;
shake = Math.max(shake, 0.35);
if (boat.hp <= 0) gameOver();
}
function gameOver() {
running = false;
titleEl.textContent = 'SUNK';
subtitleEl.textContent = `Score ${score} · ${kills} raiders sunk · ${timeText()} at sea`;
hintEl.textContent = 'Click or press R to sail again';
overlay.classList.remove('hidden');
}
function restart() {
Object.assign(boat, { x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, yaw: 0, yawRate: 0, pitch: 0, roll: 0,
pRate: 0, rRate: 0, hp: 100, cool: 0, slamCD: 0 });
for (const f of foes) scene.remove(f.ship.group);
foes.length = 0;
for (const b of balls) scene.remove(b.m);
balls.length = 0;
swells.length = 0;
score = 0; kills = 0; swellCount = 0; timeAlive = 0; foeTimer = 2; swellTimer = 5;
running = true;
overlay.classList.add('hidden');
}
overlay.addEventListener('click', () => { if (!running) restart(); });
function timeText() {
const s = Math.floor(timeAlive);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function updateHUD(dt) {
hpBar.style.width = boat.hp + '%';
hpTxt.textContent = Math.round(boat.hp);
scoreEl.textContent = score;
foesEl.textContent = foes.filter(f => f.hp > 0).length;
swellsEl.textContent = swellCount;
timeEl.textContent = timeText();
flashT = Math.max(0, flashT - dt);
flashEl.style.opacity = (flashT / 0.35) * 0.9;
warnT = Math.max(0, warnT - dt);
warnEl.style.opacity = warnT > 0 ? 1 : 0;
}
// ===== Camera: follows behind the boat =====
const camGoal = new THREE.Vector3(), camLook = new THREE.Vector3();
function updateCamera(dt) {
const fx = Math.sin(boat.yaw), fz = Math.cos(boat.yaw);
camGoal.set(boat.x - fx * 15, boat.y + 6.5, boat.z - fz * 15);
camera.position.lerp(camGoal, 1 - Math.exp(-4 * dt));
camera.position.x += (Math.random() - 0.5) * shake;
camera.position.y += (Math.random() - 0.5) * shake * 0.6;
camLook.set(boat.x + fx * 6, boat.y + 1.2, boat.z + fz * 6);
camera.lookAt(camLook);
sky.position.copy(camera.position);
shake *= Math.exp(-6 * dt);
}
// ===== Frame update =====
const FIXED_SUB = 5;
function update(dt) {
T += dt;
if (running) {
timeAlive += dt;
swellTimer -= dt;
foeTimer -= dt;
if (swellTimer <= 0) { spawnSwell(); swellTimer = 7 + Math.random() * 6; }
if (foeTimer <= 0 && foes.length < Math.min(7, 2 + Math.floor(kills / 3))) {
spawnFoe();
foeTimer = Math.max(3, 7 - kills * 0.4);
}
}
while (swells.length && T - swells[0].t0 > 32) swells.shift();
syncSwellUniforms();
waterUniforms.uTime.value = T;
const throttle = running ? (keys.KeyW || keys.ArrowUp ? 1 : 0) - (keys.KeyS || keys.ArrowDown ? 0.6 : 0) : 0;
const steer = running ? (keys.KeyA || keys.ArrowLeft ? 1 : 0) - (keys.KeyD || keys.ArrowRight ? 1 : 0) : 0;
const h = dt / FIXED_SUB;
for (let i = 0; i < FIXED_SUB; i++) stepBoat(h, throttle, steer);
boat.cool = Math.max(0, boat.cool - dt);
boat.slamCD = Math.max(0, boat.slamCD - dt);
// Turret: yaw toward the aim point, barrel elevation matches the ballistic arc
boatGroup.updateMatrixWorld(true);
updateAim();
const tw = player.turret.getWorldPosition(_v2);
player.turret.rotation.y = wrapAngle(Math.atan2(aimPt.x - tw.x, aimPt.z - tw.z) - boat.yaw);
const av = ballisticVel(player.muzzle.getWorldPosition(_v), aimPt, BALL_SPEED);
player.pivot.rotation.x = -Math.atan2(av.y, BALL_SPEED);
if (mouse.down) firePlayer();
// Foam and spray at the bow when moving fast
if (running && Math.hypot(boat.vx, boat.vz) > 5 && Math.random() < 0.5) {
const bow = _v.set(0, -0.2, 3.4).applyMatrix4(boatGroup.matrixWorld);
spray(bow.x, sea(bow.x, bow.z, T).h, bow.z, 2, 3);
}
for (const f of foes) if (f.hp > 0) {
// keep the boat from sitting inside a raider
const d = Math.hypot(boat.x - f.x, boat.z - f.z);
if (d < 4.2 && d > 0.01) {
const push = (4.2 - d) * 0.5;
boat.x += (boat.x - f.x) / d * push;
boat.z += (boat.z - f.z) / d * push;
}
}
updateFoes(dt);
updateBalls(dt);
updateSpray(dt);
updateCamera(dt);
updateHUD(dt);
}
let last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
update(dt);
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// Start with the boat already floating, so the first frame is not a drop.
boat.y = sea(0, 0, 0).h;
camera.position.set(0, 8, -15);
requestAnimationFrame(frame);
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.