Showcase

Boat game, built by Claude Haiku 5.5

Hostedvia Claude CodeOpenRouter
Running live in your browserNew tab
Show the code the model wrote
HTML29,993 characters
<!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> &nbsp;·&nbsp; Raiders <b id="foes">0</b></div>
  <div>Swells <b id="swells">0</b> &nbsp;·&nbsp; Time <b id="time">0:00</b></div>
</div>
<div id="help" class="panel">
  <kbd>W</kbd><kbd>S</kbd> throttle &nbsp; <kbd>A</kbd><kbd>D</kbd> steer<br>
  Mouse aim &nbsp; <kbd>LMB</kbd> / <kbd>Space</kbd> fire &nbsp; <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

6 more
Compare side by side

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.