Showcase

Boat game, built by Mistral Large 4

Hostedvia OpenCodeOpenRouter
Running live in your browserNew tab
Show the code the model wrote
HTML52,292 characters
<!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 &nbsp;·&nbsp; MOUSE — AIM CANNONS<br>
    LEFT CLICK / SPACE — FIRE &nbsp;·&nbsp; WHEEL — ZOOM &nbsp;·&nbsp; 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

6 more
Compare side by side

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