diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index a788959..ed15ddb 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -29,7 +29,7 @@ jobs:
- name: Prepare site
run: |
mkdir -p dist
- cp -R index.html browser-phone dist/
+ cp -R index.html browser-phone pirate-ship dist/
cp CNAME dist/CNAME
- name: Setup Pages
diff --git a/pirate-ship/README.md b/pirate-ship/README.md
new file mode 100644
index 0000000..d4bf23f
--- /dev/null
+++ b/pirate-ship/README.md
@@ -0,0 +1,70 @@
+# ☠ Pirate Cove — Sail the Jungle Isles
+
+A browser pirate-ship sailing game built with Three.js. No build step, no
+assets to download — everything (ship, islands, jungle, ocean) is generated
+procedurally in code. Open `index.html` from any static server and sail.
+
+## Controls
+
+| Input | Action |
+| --- | --- |
+| `W` / `S` | more / less sail (Anchored → Slow → Half → Full) |
+| `A` / `D` | rudder (turns scale with boat speed) |
+| `Space` | drop / weigh anchor |
+| `R` | reset ship to spawn |
+| `H` | toggle the help card |
+| mouse drag | orbit the camera |
+| scroll | zoom |
+
+Sail with the wind (see the dial, top right) for extra speed.
+
+## How the water physics works
+
+- **One wave model, two consumers.** `src/waves.js` defines a sum of Gerstner
+ (trochoidal) waves — swells, mid waves and chop. The exact same wave list is
+ packed into shader uniforms (GPU displaces the ocean mesh vertices and
+ computes analytic normals) and evaluated in JS for the physics. The ship
+ floats on precisely the surface you see.
+- **True height sampling.** Gerstner waves displace water horizontally as well
+ as vertically, so "height at (x, z)" needs the inverse of the horizontal
+ displacement — solved with a fast fixed-point iteration (≈ millimetre
+ accuracy, verified by tests).
+- **Buoyancy probes.** The hull carries 14 probes spread over its footprint.
+ Each samples the live wave field and contributes a depth-proportional
+ buoyancy force at its location — the ship naturally heaves, pitches, rolls,
+ and rides swells. Per-probe damping acts on velocity *relative to the moving
+ water surface*, which kills jitter without making the sea feel sticky.
+- **Sailing model.** Sail thrust (modulated by wind alignment), speed-dependent
+ rudder yaw, heel in turns and from beam wind, strong lateral keel drag,
+ quadratic hull drag, plus soft grounding against the islands' terrain field.
+- **Fixed timestep.** Physics runs at 60 Hz with an accumulator, independent of
+ render rate.
+
+## The environment
+
+- The ocean is a single radial mesh centred on the ship — dense near the
+ camera, geometrically coarser toward a ~7 km horizon. Small waves fade with
+ distance in the shader (no aliasing), fog hides the rim.
+- The water shader colours by true depth against the islands' terrain field:
+ turquoise shallows, navy deeps, crest foam from wave steepness, animated
+ shore foam, sun glints and a fresnel sky reflection.
+- Islands are radial-harmonic mounds shared by three consumers: terrain mesh,
+ physics collision, and the ocean shader's depth tint — all from
+ `src/islandField.js`.
+- Jungle: instanced palms (wind-swayed in the vertex shader), undergrowth,
+ rocks, drifting clouds, circling gulls, wake ribbon, bow spray and a
+ wave-conforming contact shadow under the hull.
+
+## Performance
+
+Designed to stay light: ~27 draw calls and ~430 k triangles in view, one
+2048 px shadow map, pixel ratio capped at 1.6, instancing for all vegetation.
+
+## Tests
+
+```
+node pirate-ship/test/physics.test.mjs
+```
+
+Covers wave-inversion accuracy, flotation stability, drive/steering behaviour
+and island grounding.
diff --git a/pirate-ship/index.html b/pirate-ship/index.html
new file mode 100644
index 0000000..6344df0
--- /dev/null
+++ b/pirate-ship/index.html
@@ -0,0 +1,198 @@
+
+
+
+
+
+ Pirate Cove — Sail the Jungle Isles
+
+
+
+
+
+
+
+
+
⛵
+
Hoisting the colours…
+
+
+
+ ☠ Pirate Cove
+
SAIL THE JUNGLE ISLES
+
+
+
+
☠ Pirate Cove
+
A cove of jungle isles, a ship of your own, and a fair wind.
+
W to make sail — A /D to steer
+
+
+
+
+
+
+
+
+
Sails
+
+ ⚓ Anchored
+
+
+
+
+
+
+ ⚓ anchor down
+ ⚠ run aground
+
+
+
+
+ W /S more / less sail · A /D rudder
+ Space anchor · R reset · H hide this
+ drag to look · scroll to zoom · sail with the wind for speed
+
+
+
+
+
diff --git a/pirate-ship/src/controls.js b/pirate-ship/src/controls.js
new file mode 100644
index 0000000..9d77e37
--- /dev/null
+++ b/pirate-ship/src/controls.js
@@ -0,0 +1,131 @@
+// ---------------------------------------------------------------------------
+// controls.js — keyboard sailing controls + third-person chase camera with
+// mouse orbit and wheel zoom. The camera auto-settles behind the ship.
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { heightAt } from './waves.js';
+import { terrainHeightAt } from './islandField.js';
+
+export class Controls {
+ constructor(body, camera, dom, hud) {
+ this.body = body;
+ this.camera = camera;
+ this.hud = hud;
+
+ this.yaw = body.heading + Math.PI; // camera behind the ship
+ this.pitch = 0.30;
+ this.dist = 34;
+ this.lastDragTime = -10;
+ this.keys = new Set();
+
+ this._camTarget = new THREE.Vector3();
+ this._desired = new THREE.Vector3();
+
+ window.addEventListener('keydown', (e) => {
+ if (e.repeat) {
+ this.keys.add(e.code);
+ return;
+ }
+ switch (e.code) {
+ case 'KeyW':
+ case 'ArrowUp':
+ body.changeSail(+1);
+ hud?.flashSail();
+ break;
+ case 'KeyS':
+ case 'ArrowDown':
+ body.changeSail(-1);
+ hud?.flashSail();
+ break;
+ case 'Space':
+ body.toggleAnchor();
+ e.preventDefault();
+ break;
+ case 'KeyR':
+ body.reset();
+ break;
+ case 'KeyH':
+ hud?.toggleHelp();
+ break;
+ }
+ this.keys.add(e.code);
+ hud?.dismissIntro();
+ });
+ window.addEventListener('keyup', (e) => this.keys.delete(e.code));
+ window.addEventListener('blur', () => this.keys.clear());
+
+ // mouse orbit
+ let dragging = false;
+ let lastX = 0;
+ let lastY = 0;
+ dom.addEventListener('pointerdown', (e) => {
+ dragging = true;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ hud?.dismissIntro();
+ });
+ window.addEventListener('pointermove', (e) => {
+ if (!dragging) return;
+ this.yaw -= (e.clientX - lastX) * 0.0052;
+ this.pitch = THREE.MathUtils.clamp(this.pitch + (e.clientY - lastY) * 0.0042, 0.04, 1.25);
+ lastX = e.clientX;
+ lastY = e.clientY;
+ this.lastDragTime = performance.now() / 1000;
+ });
+ window.addEventListener('pointerup', () => (dragging = false));
+ dom.addEventListener(
+ 'wheel',
+ (e) => {
+ e.preventDefault();
+ this.dist = THREE.MathUtils.clamp(this.dist * (e.deltaY > 0 ? 1.09 : 0.92), 12, 80);
+ },
+ { passive: false }
+ );
+ }
+
+ /** held-key rudder input -> physics */
+ applyInput() {
+ const left = this.keys.has('KeyA') || this.keys.has('ArrowLeft');
+ const right = this.keys.has('KeyD') || this.keys.has('ArrowRight');
+ this.body.rudderInput = (right ? 1 : 0) - (left ? 1 : 0);
+ }
+
+ updateCamera(dt, t) {
+ const body = this.body;
+ const now = performance.now() / 1000;
+
+ // settle behind the ship when the mouse has been idle
+ if (now - this.lastDragTime > 2.4) {
+ const targetYaw = body.heading + Math.PI;
+ let d = targetYaw - this.yaw;
+ while (d > Math.PI) d -= Math.PI * 2;
+ while (d < -Math.PI) d += Math.PI * 2;
+ this.yaw += d * Math.min(1, dt * 1.4);
+ }
+
+ const cp = Math.cos(this.pitch);
+ this._camTarget.set(body.pos.x, body.pos.y + 3.4, body.pos.z);
+ this._desired.set(
+ this._camTarget.x + Math.sin(this.yaw) * cp * this.dist,
+ this._camTarget.y + Math.sin(this.pitch) * this.dist,
+ this._camTarget.z + Math.cos(this.yaw) * cp * this.dist
+ );
+
+ // keep the camera above the waves and island terrain
+ const waterY = heightAt(this._desired.x, this._desired.z, t);
+ const groundY = terrainHeightAt(this._desired.x, this._desired.z);
+ this._desired.y = Math.max(this._desired.y, waterY + 1.6, groundY + 2.5);
+
+ // critically-damped style smoothing
+ const k = 1 - Math.exp(-dt * 7.5);
+ this.camera.position.lerp(this._desired, k);
+ this.camera.lookAt(this._camTarget);
+
+ // subtle speed kick
+ const speed = Math.abs(body.speed);
+ const targetFov = 55 + Math.min(speed, 14) * 0.5;
+ this.camera.fov += (targetFov - this.camera.fov) * Math.min(1, dt * 2);
+ this.camera.updateProjectionMatrix();
+ }
+}
diff --git a/pirate-ship/src/effects.js b/pirate-ship/src/effects.js
new file mode 100644
index 0000000..4d5e530
--- /dev/null
+++ b/pirate-ship/src/effects.js
@@ -0,0 +1,340 @@
+// ---------------------------------------------------------------------------
+// effects.js — stern wake ribbon, bow spray, contact "blob" shadow on the
+// water (custom water shader can't receive shadow maps), and ambient birds.
+// All effects ride the real wave surface via waves.sampleAt.
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { heightAt } from './waves.js';
+
+// --- wake ribbon -------------------------------------------------------------
+
+const WAKE_MAX = 72;
+const WAKE_LIFE = 7.0;
+
+const WAKE_FRAG = /* glsl */ `
+varying float vAlpha;
+varying vec2 vUv;
+uniform float uTime;
+void main() {
+ // soft edges + streaky procedural breakup
+ float edge = smoothstep(0.0, 0.32, vUv.x) * smoothstep(1.0, 0.68, vUv.x);
+ float streak = 0.72 + 0.28 * sin(vUv.y * 40.0 + vUv.x * 6.0);
+ float a = vAlpha * edge * streak;
+ gl_FragColor = vec4(0.92, 0.97, 1.0, a);
+ #include
+ #include
+}
+`;
+
+const WAKE_VERT = /* glsl */ `
+attribute float aAlpha;
+varying float vAlpha;
+varying vec2 vUv;
+void main() {
+ vAlpha = aAlpha;
+ vUv = uv;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}
+`;
+
+class WakeRibbon {
+ constructor(scene) {
+ this.points = []; // { x, z, birth, dirX, dirZ, w }
+ const geo = new THREE.BufferGeometry();
+ this.posAttr = new THREE.BufferAttribute(new Float32Array(WAKE_MAX * 2 * 3), 3);
+ this.alphaAttr = new THREE.BufferAttribute(new Float32Array(WAKE_MAX * 2), 1);
+ this.uvAttr = new THREE.BufferAttribute(new Float32Array(WAKE_MAX * 2 * 2), 2);
+ geo.setAttribute('position', this.posAttr);
+ geo.setAttribute('aAlpha', this.alphaAttr);
+ geo.setAttribute('uv', this.uvAttr);
+ const idx = [];
+ for (let i = 0; i < WAKE_MAX - 1; i++) {
+ const a = i * 2;
+ idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
+ }
+ geo.setIndex(idx);
+ geo.setDrawRange(0, 0);
+ this.geo = geo;
+ this.mesh = new THREE.Mesh(
+ geo,
+ new THREE.ShaderMaterial({
+ vertexShader: WAKE_VERT,
+ fragmentShader: WAKE_FRAG,
+ uniforms: { uTime: { value: 0 } },
+ transparent: true,
+ depthWrite: false,
+ side: THREE.DoubleSide,
+ })
+ );
+ this.mesh.frustumCulled = false;
+ this.mesh.renderOrder = 2;
+ scene.add(this.mesh);
+ }
+
+ update(body, t) {
+ const speed = Math.abs(body.speed);
+ // stern position in world space
+ const sx = body.pos.x - body._fwd.x * 14.5;
+ const sz = body.pos.z - body._fwd.z * 14.5;
+ const last = this.points[this.points.length - 1];
+ if (speed > 1.2 && (!last || Math.hypot(sx - last.x, sz - last.z) > 2.2)) {
+ this.points.push({
+ x: sx,
+ z: sz,
+ birth: t,
+ dirX: body._right.x,
+ dirZ: body._right.z,
+ str: Math.min(speed / 9, 1),
+ });
+ if (this.points.length > WAKE_MAX) this.points.shift();
+ }
+ // drop expired
+ while (this.points.length && t - this.points[0].birth > WAKE_LIFE) this.points.shift();
+
+ const n = this.points.length;
+ for (let i = 0; i < n; i++) {
+ const p = this.points[i];
+ const age = (t - p.birth) / WAKE_LIFE;
+ const w = (1.6 + age * 7.5) * 0.5;
+ const y = heightAt(p.x, p.z, t) + 0.1;
+ this.posAttr.setXYZ(i * 2, p.x - p.dirX * w, y, p.z - p.dirZ * w);
+ this.posAttr.setXYZ(i * 2 + 1, p.x + p.dirX * w, y, p.z + p.dirZ * w);
+ const a = (1 - age) * 0.55 * p.str;
+ this.alphaAttr.setX(i * 2, a);
+ this.alphaAttr.setX(i * 2 + 1, a);
+ this.uvAttr.setXY(i * 2, 0, i * 0.35);
+ this.uvAttr.setXY(i * 2 + 1, 1, i * 0.35);
+ }
+ this.posAttr.needsUpdate = true;
+ this.alphaAttr.needsUpdate = true;
+ this.uvAttr.needsUpdate = true;
+ this.geo.setDrawRange(0, Math.max(0, (n - 1) * 6));
+ }
+}
+
+// --- bow spray ----------------------------------------------------------------
+
+const SPRAY_N = 220;
+
+class BowSpray {
+ constructor(scene) {
+ this.parts = [];
+ for (let i = 0; i < SPRAY_N; i++) {
+ this.parts.push({ x: 0, y: -50, z: 0, vx: 0, vy: 0, vz: 0, life: 0, age: 1 });
+ }
+ this.cursor = 0;
+ const geo = new THREE.BufferGeometry();
+ this.posAttr = new THREE.BufferAttribute(new Float32Array(SPRAY_N * 3), 3);
+ geo.setAttribute('position', this.posAttr);
+ this.mesh = new THREE.Points(
+ geo,
+ new THREE.PointsMaterial({
+ color: 0xeef7fb,
+ size: 0.55,
+ transparent: true,
+ opacity: 0.7,
+ depthWrite: false,
+ sizeAttenuation: true,
+ })
+ );
+ this.mesh.frustumCulled = false;
+ this.mesh.renderOrder = 2;
+ scene.add(this.mesh);
+ }
+
+ update(body, t, dt) {
+ const speed = Math.abs(body.speed);
+ // spawn at the bow shoulders proportional to speed
+ if (speed > 4) {
+ const count = Math.min(4, Math.floor(speed * 0.45));
+ for (let k = 0; k < count; k++) {
+ const p = this.parts[this.cursor];
+ this.cursor = (this.cursor + 1) % SPRAY_N;
+ const side = Math.random() > 0.5 ? 1 : -1;
+ const bx = body.pos.x + body._fwd.x * 13.2 + body._right.x * side * 1.6;
+ const bz = body.pos.z + body._fwd.z * 13.2 + body._right.z * side * 1.6;
+ p.x = bx;
+ p.z = bz;
+ p.y = heightAt(bx, bz, t) + 0.3;
+ p.vx = body.vel.x * 0.55 + body._right.x * side * (1.2 + Math.random() * 1.6);
+ p.vz = body.vel.z * 0.55 + body._right.z * side * (1.2 + Math.random() * 1.6);
+ p.vy = 1.8 + Math.random() * 2.4 * Math.min(speed / 9, 1.2);
+ p.life = 0.7 + Math.random() * 0.7;
+ p.age = 0;
+ }
+ }
+ for (let i = 0; i < SPRAY_N; i++) {
+ const p = this.parts[i];
+ if (p.age >= p.life) {
+ this.posAttr.setXYZ(i, 0, -50, 0);
+ continue;
+ }
+ p.age += dt;
+ p.vy -= 9.81 * dt;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.z += p.vz * dt;
+ this.posAttr.setXYZ(i, p.x, p.y, p.z);
+ }
+ this.posAttr.needsUpdate = true;
+ }
+}
+
+// --- blob shadow under the ship -----------------------------------------------
+
+class BlobShadow {
+ constructor(scene) {
+ const NX = 10;
+ const NZ = 5;
+ this.NX = NX;
+ this.NZ = NZ;
+ const geo = new THREE.BufferGeometry();
+ this.posAttr = new THREE.BufferAttribute(new Float32Array((NX + 1) * (NZ + 1) * 3), 3);
+ const alpha = new Float32Array((NX + 1) * (NZ + 1));
+ const idx = [];
+ for (let j = 0; j <= NZ; j++) {
+ for (let i = 0; i <= NX; i++) {
+ const u = i / NX - 0.5;
+ const v = j / NZ - 0.5;
+ const d = Math.hypot(u * 2, v * 2);
+ alpha[j * (NX + 1) + i] = Math.max(0, 1 - d) * 0.34;
+ }
+ }
+ for (let j = 0; j < NZ; j++) {
+ for (let i = 0; i < NX; i++) {
+ const a = j * (NX + 1) + i;
+ const b = a + 1;
+ const c = a + NX + 1;
+ const d = c + 1;
+ idx.push(a, c, b, b, c, d);
+ }
+ }
+ geo.setAttribute('position', this.posAttr);
+ geo.setAttribute('aAlpha', new THREE.BufferAttribute(alpha, 1));
+ geo.setIndex(idx);
+ this.mesh = new THREE.Mesh(
+ geo,
+ new THREE.ShaderMaterial({
+ vertexShader: /* glsl */ `
+ attribute float aAlpha;
+ varying float vA;
+ void main() {
+ vA = aAlpha;
+ gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0);
+ }`,
+ fragmentShader: /* glsl */ `
+ varying float vA;
+ void main() { gl_FragColor = vec4(0.0, 0.05, 0.09, vA); }`,
+ transparent: true,
+ depthWrite: false,
+ side: THREE.DoubleSide,
+ })
+ );
+ this.mesh.frustumCulled = false;
+ this.mesh.renderOrder = 1;
+ scene.add(this.mesh);
+ }
+
+ update(body, t) {
+ const fx = body._fwd.x;
+ const fz = body._fwd.z;
+ const rx = body._right.x;
+ const rz = body._right.z;
+ const L = 33;
+ const W = 12;
+ for (let j = 0; j <= this.NZ; j++) {
+ for (let i = 0; i <= this.NX; i++) {
+ const u = (i / this.NX - 0.5) * L;
+ const v = (j / this.NZ - 0.5) * W;
+ const x = body.pos.x + fx * u + rx * v;
+ const z = body.pos.z + fz * u + rz * v;
+ const y = heightAt(x, z, t) + 0.14;
+ this.posAttr.setXYZ(j * (this.NX + 1) + i, x, y, z);
+ }
+ }
+ this.posAttr.needsUpdate = true;
+ }
+}
+
+// --- birds ---------------------------------------------------------------------
+
+const BIRD_N = 10;
+
+class Birds {
+ constructor(scene, timeUniform) {
+ // simple gull: two wing triangles
+ const verts = new Float32Array([
+ 0, 0, 0.55, 0, 0, -0.4, -1.6, 0.18, 0, // left wing
+ 0, 0, 0.55, 1.6, 0.18, 0, 0, 0, -0.4, // right wing
+ ]);
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
+ const mat = new THREE.MeshBasicMaterial({ color: 0x303a42, side: THREE.DoubleSide });
+ mat.onBeforeCompile = (shader) => {
+ shader.uniforms.uTime = timeUniform;
+ shader.vertexShader =
+ 'uniform float uTime;\n' +
+ shader.vertexShader.replace(
+ '#include ',
+ `#include
+ {
+ float phase = instanceMatrix[3][0] * 0.5 + instanceMatrix[3][2];
+ transformed.y += sin(uTime * 7.0 + phase) * abs(transformed.x) * 0.55;
+ }`
+ );
+ };
+ this.mesh = new THREE.InstancedMesh(geo, mat, BIRD_N);
+ this.mesh.frustumCulled = false;
+ this.birds = [];
+ for (let i = 0; i < BIRD_N; i++) {
+ const home = i < 5 ? { x: -250, z: 395 } : { x: 470, z: -290 };
+ this.birds.push({
+ home,
+ r: 60 + Math.random() * 120,
+ h: 30 + Math.random() * 35,
+ a: Math.random() * Math.PI * 2,
+ speed: 0.25 + Math.random() * 0.2,
+ scale: 1.6 + Math.random() * 1.2,
+ });
+ }
+ this._m = new THREE.Matrix4();
+ this._q = new THREE.Quaternion();
+ this._e = new THREE.Euler();
+ this._p = new THREE.Vector3();
+ this._s = new THREE.Vector3();
+ scene.add(this.mesh);
+ }
+
+ update(dt) {
+ for (let i = 0; i < BIRD_N; i++) {
+ const b = this.birds[i];
+ b.a += b.speed * dt;
+ const x = b.home.x + Math.cos(b.a) * b.r;
+ const z = b.home.z + Math.sin(b.a) * b.r;
+ this._e.set(0, -b.a, 0);
+ this._q.setFromEuler(this._e);
+ this._p.set(x, b.h + Math.sin(b.a * 3) * 4, z);
+ this._s.setScalar(b.scale);
+ this._m.compose(this._p, this._q, this._s);
+ this.mesh.setMatrixAt(i, this._m);
+ }
+ this.mesh.instanceMatrix.needsUpdate = true;
+ }
+}
+
+export class Effects {
+ constructor(scene, timeUniform) {
+ this.wake = new WakeRibbon(scene);
+ this.spray = new BowSpray(scene);
+ this.blob = new BlobShadow(scene);
+ this.birds = new Birds(scene, timeUniform);
+ }
+
+ update(body, t, dt) {
+ this.wake.update(body, t);
+ this.spray.update(body, t, dt);
+ this.blob.update(body, t);
+ this.birds.update(dt);
+ }
+}
diff --git a/pirate-ship/src/hud.js b/pirate-ship/src/hud.js
new file mode 100644
index 0000000..a071c99
--- /dev/null
+++ b/pirate-ship/src/hud.js
@@ -0,0 +1,69 @@
+// ---------------------------------------------------------------------------
+// hud.js — DOM heads-up display: knots, heading, sail setting, wind, hints.
+// ---------------------------------------------------------------------------
+
+import { SAIL_SETTINGS } from './physics.js';
+import { WIND } from './waves.js';
+
+const CARDINALS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
+
+export class Hud {
+ constructor() {
+ this.elSpeed = document.getElementById('hud-speed');
+ this.elHeading = document.getElementById('hud-heading');
+ this.elSail = document.getElementById('hud-sail');
+ this.elPips = [...document.querySelectorAll('.sail-pip')];
+ this.elWindArrow = document.getElementById('wind-arrow');
+ this.elAnchor = document.getElementById('hud-anchor');
+ this.elAground = document.getElementById('hud-aground');
+ this.elHelp = document.getElementById('help-card');
+ this.elIntro = document.getElementById('intro');
+ this._accum = 0;
+ this._sailFlash = 0;
+ }
+
+ dismissIntro() {
+ if (this.elIntro && !this.elIntro.classList.contains('hidden')) {
+ this.elIntro.classList.add('hidden');
+ }
+ }
+
+ toggleHelp() {
+ this.elHelp?.classList.toggle('hidden');
+ }
+
+ flashSail() {
+ this._sailFlash = 0.8;
+ this.elSail?.classList.add('flash');
+ }
+
+ update(body, camYaw, dt) {
+ this._accum += dt;
+ this._sailFlash -= dt;
+ if (this._sailFlash < 0) this.elSail?.classList.remove('flash');
+ if (this._accum < 0.12) return; // ~8 Hz is plenty for DOM
+ this._accum = 0;
+
+ const knots = Math.abs(body.speed) * 1.94384;
+ this.elSpeed.textContent = knots.toFixed(1);
+
+ let deg = (body.heading * 180) / Math.PI;
+ deg = ((deg % 360) + 360) % 360;
+ const card = CARDINALS[Math.round(deg / 45) % 8];
+ this.elHeading.textContent = `${card} ${deg.toFixed(0).padStart(3, '0')}°`;
+
+ const sail = body.anchored ? SAIL_SETTINGS[0] : body.sail;
+ this.elSail.textContent = body.anchored ? '⚓ Anchored' : sail.name;
+ this.elPips.forEach((pip, i) => {
+ pip.classList.toggle('on', !body.anchored && i < body.sailIndex);
+ });
+
+ this.elAnchor.classList.toggle('hidden', !body.anchored);
+ this.elAground.classList.toggle('hidden', !body.aground);
+
+ // wind arrow is drawn relative to the camera view direction
+ const windAngle = Math.atan2(WIND.dirX, WIND.dirZ);
+ const rel = windAngle - camYaw + Math.PI;
+ this.elWindArrow.style.transform = `rotate(${(-rel * 180) / Math.PI}deg)`;
+ }
+}
diff --git a/pirate-ship/src/islandField.js b/pirate-ship/src/islandField.js
new file mode 100644
index 0000000..9508610
--- /dev/null
+++ b/pirate-ship/src/islandField.js
@@ -0,0 +1,157 @@
+// ---------------------------------------------------------------------------
+// islandField.js — pure math describing the archipelago (no three.js).
+//
+// Every island is a radial mound whose shoreline radius wobbles with three
+// deterministic harmonics. The SAME function is used for:
+// * terrain mesh generation (islands.js)
+// * ship collision / grounding (physics.js)
+// * shallow-water tint + shore foam in the ocean shader (GLSL mirror below)
+// ---------------------------------------------------------------------------
+
+import { mulberry32, createFbm2D } from './noise.js';
+
+export const SEA_FLOOR_DEPTH = 16; // open-ocean seabed depth (m)
+export const BEACH_SLOPE = 0.085; // vertical rise per metre across the beach
+
+function makeIsland(cx, cz, r, height, seed) {
+ const rand = mulberry32(seed);
+ return {
+ cx,
+ cz,
+ r,
+ height,
+ seed,
+ beachW: 16 + r * 0.05,
+ w1: 0.05 + rand() * 0.07,
+ p1: rand() * Math.PI * 2,
+ w2: 0.03 + rand() * 0.05,
+ p2: rand() * Math.PI * 2,
+ w3: 0.02 + rand() * 0.035,
+ p3: rand() * Math.PI * 2,
+ };
+}
+
+// The cove: a large jungle arc to the south-west plus scattered islets,
+// leaving wide open channels for sailing. Ship spawns near the origin.
+export const ISLANDS = [
+ // main jungle island (three overlapping mounds form a crescent)
+ makeIsland(-520, -180, 230, 58, 101),
+ makeIsland(-330, -430, 262, 74, 202),
+ makeIsland(20, -560, 212, 52, 303),
+ // outlying islets
+ makeIsland(470, -290, 96, 26, 404),
+ makeIsland(640, 180, 122, 36, 505),
+ makeIsland(265, 430, 86, 22, 606),
+ makeIsland(-250, 395, 112, 30, 707),
+ makeIsland(-640, 270, 78, 18, 808),
+];
+
+export const SPAWN = { x: 90, z: 60, heading: Math.PI * 0.75 };
+
+/** Wobbly shoreline radius of an island at polar angle `a` from its centre. */
+export function shoreRadius(isl, a) {
+ return (
+ isl.r *
+ (1 +
+ isl.w1 * Math.sin(3 * a + isl.p1) +
+ isl.w2 * Math.sin(5 * a + isl.p2) +
+ isl.w3 * Math.sin(7 * a + isl.p3))
+ );
+}
+
+/** Terrain height of a single island at world (x, z). Sea level = 0. */
+export function islandHeightAt(isl, x, z) {
+ const dx = x - isl.cx;
+ const dz = z - isl.cz;
+ const d = Math.hypot(dx, dz);
+ const a = Math.atan2(dz, dx);
+ const reff = shoreRadius(isl, a);
+ // Gentle beach plane crossing sea level exactly at the shoreline...
+ let y = Math.min(BEACH_SLOPE * (reff - d), 2.2);
+ // ...plus the jungle mound rising further inland.
+ const u = 1 - d / reff;
+ if (u > 0.1) {
+ const m = Math.min((u - 0.1) / 0.9, 1);
+ y += isl.height * Math.pow(m, 1.6);
+ }
+ return Math.max(y, -SEA_FLOOR_DEPTH);
+}
+
+/** Combined terrain height (max over islands). -SEA_FLOOR_DEPTH in open water. */
+export function terrainHeightAt(x, z) {
+ let y = -SEA_FLOOR_DEPTH;
+ for (let i = 0; i < ISLANDS.length; i++) {
+ const isl = ISLANDS[i];
+ // cheap reject: outside the island's maximum possible footprint
+ const dx = x - isl.cx;
+ const dz = z - isl.cz;
+ const maxR = isl.r * 1.18 + SEA_FLOOR_DEPTH / BEACH_SLOPE;
+ if (dx * dx + dz * dz > maxR * maxR) continue;
+ const h = islandHeightAt(isl, x, z);
+ if (h > y) y = h;
+ }
+ return y;
+}
+
+// Jungle canopy density mask, 0 (clearing) .. 1 (dense canopy).
+// Shared by vegetation placement AND terrain colouring, so trees grow in
+// organic clusters and the ground visibly darkens beneath them.
+const _jungleFbm = createFbm2D(7777, 3);
+export function jungleDensityAt(x, z) {
+ const n = _jungleFbm(x * 0.016, z * 0.016) * 0.5 + 0.5;
+ const t = Math.min(Math.max((n - 0.36) / (0.72 - 0.36), 0), 1);
+ return t * t * (3 - 2 * t); // smoothstep
+}
+
+/** Finite-difference terrain gradient (uphill direction). */
+export function terrainGradientAt(x, z, out) {
+ const e = 2.0;
+ out.x = (terrainHeightAt(x + e, z) - terrainHeightAt(x - e, z)) / (2 * e);
+ out.z = (terrainHeightAt(x, z + e) - terrainHeightAt(x, z - e)) / (2 * e);
+ return out;
+}
+
+// --- GPU mirror --------------------------------------------------------------
+
+/** Pack island data for the ocean shader. */
+export function packIslands() {
+ const n = ISLANDS.length;
+ const a = new Float32Array(n * 4); // cx, cz, r, beachW
+ const b = new Float32Array(n * 4); // w1, p1, w2, p2
+ const c = new Float32Array(n * 4); // w3, p3, height, 0
+ ISLANDS.forEach((isl, i) => {
+ a.set([isl.cx, isl.cz, isl.r, isl.beachW], i * 4);
+ b.set([isl.w1, isl.p1, isl.w2, isl.p2], i * 4);
+ c.set([isl.w3, isl.p3, isl.height, 0], i * 4);
+ });
+ return { a, b, c, count: n };
+}
+
+/**
+ * GLSL mirror of the terrain function. Returns the approximate seabed/terrain
+ * height at a world position — used for shallow tint and shore foam bands.
+ */
+export const ISLAND_GLSL = /* glsl */ `
+float terrainHeight(vec2 p, vec4 islA[NUM_ISLANDS], vec4 islB[NUM_ISLANDS], vec4 islC[NUM_ISLANDS]) {
+ float y = -SEA_FLOOR_DEPTH;
+ for (int i = 0; i < NUM_ISLANDS; i++) {
+ vec2 d2 = p - islA[i].xy;
+ float d = length(d2);
+ float r = islA[i].z;
+ if (d > r * 1.18 + SEA_FLOOR_DEPTH / BEACH_SLOPE) continue;
+ float a = atan(d2.y, d2.x);
+ float reff = r * (1.0
+ + islB[i].x * sin(3.0 * a + islB[i].y)
+ + islB[i].z * sin(5.0 * a + islB[i].w)
+ + islC[i].x * sin(7.0 * a + islC[i].y));
+ float h = min(BEACH_SLOPE * (reff - d), 2.2);
+ float u = 1.0 - d / reff;
+ if (u > 0.1) {
+ float m = min((u - 0.1) / 0.9, 1.0);
+ h += islC[i].z * pow(m, 1.6);
+ }
+ y = max(y, h);
+ }
+ return y;
+}
+`;
diff --git a/pirate-ship/src/islands.js b/pirate-ship/src/islands.js
new file mode 100644
index 0000000..b99c306
--- /dev/null
+++ b/pirate-ship/src/islands.js
@@ -0,0 +1,162 @@
+// ---------------------------------------------------------------------------
+// islands.js — low-poly island terrain meshes built from the shared island
+// field (islandField.js), with vertex-colour terrain bands:
+// sand -> jungle greens -> rock on steep slopes.
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { ISLANDS, islandHeightAt, terrainHeightAt, jungleDensityAt } from './islandField.js';
+import { createFbm2D } from './noise.js';
+import { mulberry32 } from './noise.js';
+
+const C_SAND = new THREE.Color(0xe2cf96);
+const C_SAND_WET = new THREE.Color(0xb89f70);
+const C_JUNGLE_DEEP = new THREE.Color(0x17421f);
+const C_JUNGLE = new THREE.Color(0x2c6e33);
+const C_GRASS = new THREE.Color(0x67953f);
+const C_GRASS_DRY = new THREE.Color(0x8e9a4b);
+const C_DIRT = new THREE.Color(0x6e553a);
+const C_ROCK = new THREE.Color(0x8a8273);
+const C_ROCK_DARK = new THREE.Color(0x5f594d);
+const C_ROCK_HIGH = new THREE.Color(0x9b9488);
+
+export function buildIslands(scene) {
+ const fbm = createFbm2D(9001, 4);
+ const ridgeFbm = createFbm2D(7007, 3);
+ const colorFbm = createFbm2D(5005, 3);
+ const patchFbm = createFbm2D(3303, 2);
+ const parts = [];
+
+ for (const isl of ISLANDS) {
+ // per-island character: some are craggy, some gentle
+ const rnd = mulberry32(isl.seed * 7 + 3);
+ const bumpAmt = 3.2 + rnd() * 3.4;
+ const ridgeAmt = (0.35 + rnd() * 0.65) * Math.min(isl.height * 0.16, 9);
+ const tintShift = (rnd() - 0.5) * 0.10; // hue-ish variation between islands
+
+ const segs = Math.round(THREE.MathUtils.clamp(isl.r * 0.8, 56, 120));
+ const rings = Math.round(THREE.MathUtils.clamp(isl.r * 0.36, 24, 62));
+ const maxR = isl.r * 1.3; // extend below sea level so the shoreline is sealed
+ const verts = [];
+ const cols = [];
+ const idx = [];
+
+ for (let i = 0; i <= rings; i++) {
+ const tr = i / rings;
+ const rad = Math.pow(tr, 0.85) * maxR; // denser rings near the summit
+ for (let j = 0; j < segs; j++) {
+ const a = (j / segs) * Math.PI * 2;
+ const x = isl.cx + Math.cos(a) * rad;
+ const z = isl.cz + Math.sin(a) * rad;
+ let y = islandHeightAt(isl, x, z);
+ // interior detail — only well above the shoreline so the physics &
+ // shader field stays exact where it matters (the beach/collision)
+ if (y > 2.0) {
+ const inland = Math.min((y - 2.0) / 6.0, 1);
+ const elev = Math.min(y / isl.height, 1);
+ // rolling bumps
+ y += fbm(x * 0.02, z * 0.02) * bumpAmt * inland;
+ y += fbm(x * 0.07, z * 0.07) * 1.4 * inland;
+ // ridged crags toward the summit (sharp |noise| creases)
+ const ridge = 1 - Math.abs(ridgeFbm(x * 0.016, z * 0.016));
+ y += ridge * ridge * ridgeAmt * elev * elev * inland;
+ }
+ verts.push(x, y, z);
+ cols.push(0, 0, 0); // filled after normals are known
+ }
+ }
+ for (let i = 0; i < rings; i++) {
+ for (let j = 0; j < segs; j++) {
+ const j1 = (j + 1) % segs;
+ const a = i * segs + j;
+ const b = i * segs + j1;
+ const c = (i + 1) * segs + j;
+ const d = (i + 1) * segs + j1;
+ if (i === 0) {
+ // ring 0 is the degenerate centre: fan triangles only
+ idx.push(b, d, c);
+ } else {
+ idx.push(a, b, c, b, d, c);
+ }
+ }
+ }
+
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
+ geo.setIndex(idx);
+ geo.computeVertexNormals();
+
+ // colour by height, slope, and noise-driven biome patches
+ const pos = geo.getAttribute('position');
+ const nor = geo.getAttribute('normal');
+ const col = new Float32Array(pos.count * 3);
+ const c = new THREE.Color();
+ const tone = new THREE.Color();
+ const peak = isl.height * 1.15 + ridgeAmt;
+ for (let v = 0; v < pos.count; v++) {
+ const x = pos.getX(v);
+ const y = pos.getY(v);
+ const z = pos.getZ(v);
+ const slope = 1 - nor.getY(v); // 0 flat .. 1 vertical
+ const elev = Math.max(y, 0) / peak;
+ const n = colorFbm(x * 0.045, z * 0.045) * 0.5 + 0.5;
+ const patch = patchFbm(x * 0.02 + 31, z * 0.02 - 17) * 0.5 + 0.5;
+ const grain = colorFbm(x * 0.16, z * 0.16) * 0.5 + 0.5;
+
+ // wobble the vegetation line with noise so it isn't a straight contour
+ const sandLine = 1.7 + (patch - 0.5) * 1.5;
+ if (y < 0.5) {
+ // wet sand at the waterline
+ c.copy(C_SAND_WET).lerp(C_SAND, grain * 0.25);
+ } else if (y < sandLine) {
+ // dry beach with subtle grain so the band isn't flat
+ c.copy(C_SAND).lerp(C_SAND_WET, (1 - y * 0.45) * 0.25 + grain * 0.12);
+ } else {
+ // vegetation: grassy base broken by glades, dirt, dry highlands —
+ // then strongly darkened wherever the canopy mask says "jungle",
+ // which matches exactly where the trees are planted (fake AO)
+ c.copy(C_JUNGLE).lerp(C_GRASS, patch * 0.7);
+ if (patch < 0.22) c.lerp(C_DIRT, (0.22 - patch) / 0.22 * 0.5);
+ c.lerp(C_GRASS_DRY, THREE.MathUtils.clamp((elev - 0.45) * 1.6, 0, 0.55) * patch);
+ if (tintShift > 0) c.lerp(C_GRASS_DRY, tintShift);
+ else c.lerp(C_JUNGLE_DEEP, -tintShift);
+ const canopy = jungleDensityAt(x, z);
+ c.lerp(C_JUNGLE_DEEP, canopy * (0.55 + n * 0.2));
+ // blend sand->jungle across a noisy transition band
+ if (y < sandLine + 1.1) c.lerp(C_SAND, (sandLine + 1.1 - y) / 1.1 * 0.85);
+ }
+
+ // exposed rock: steep slopes and craggy summits
+ const rockiness = THREE.MathUtils.clamp((slope - 0.32) / 0.2, 0, 1) * (y > 1.2 ? 1 : 0);
+ const summitRock = THREE.MathUtils.clamp((elev - 0.72) * 4.0, 0, 1) * (slope > 0.18 ? 1 : 0.4);
+ const rk = Math.max(rockiness, summitRock);
+ if (rk > 0) {
+ tone.copy(elev > 0.6 ? C_ROCK_HIGH : C_ROCK).lerp(C_ROCK_DARK, n * 0.7);
+ c.lerp(tone, Math.min(rk, 1));
+ }
+
+ col[v * 3] = c.r;
+ col[v * 3 + 1] = c.g;
+ col[v * 3 + 2] = c.b;
+ }
+ geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
+ parts.push(geo);
+ }
+
+ const merged = mergeGeometries(parts);
+ // smooth shading: the radial grid has stretched cells, so flat shading
+ // produced huge ugly facets on open slopes. Colour noise does the styling.
+ const mat = new THREE.MeshStandardMaterial({
+ vertexColors: true,
+ roughness: 1.0,
+ metalness: 0.0,
+ });
+ const mesh = new THREE.Mesh(merged, mat);
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ scene.add(mesh);
+ return mesh;
+}
+
+export { terrainHeightAt };
diff --git a/pirate-ship/src/main.js b/pirate-ship/src/main.js
new file mode 100644
index 0000000..c54bbb2
--- /dev/null
+++ b/pirate-ship/src/main.js
@@ -0,0 +1,135 @@
+// ---------------------------------------------------------------------------
+// main.js — bootstrap + game loop.
+// Fixed 60 Hz physics with an accumulator; rendering at display rate.
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { ShipPhysics } from './physics.js';
+import { PirateShip } from './ship.js';
+import { Ocean } from './ocean.js';
+import { SkyEnv } from './sky.js';
+import { buildIslands } from './islands.js';
+import { buildVegetation } from './vegetation.js';
+import { Effects } from './effects.js';
+import { Controls } from './controls.js';
+import { Hud } from './hud.js';
+
+const FIXED_DT = 1 / 60;
+
+const canvas = document.getElementById('game');
+const renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: true,
+ powerPreference: 'high-performance',
+});
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6));
+renderer.setSize(window.innerWidth, window.innerHeight);
+renderer.outputColorSpace = THREE.SRGBColorSpace;
+renderer.toneMapping = THREE.ACESFilmicToneMapping;
+renderer.toneMappingExposure = 1.08;
+renderer.shadowMap.enabled = true;
+renderer.shadowMap.type = THREE.PCFShadowMap;
+
+const scene = new THREE.Scene();
+const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.5, 12000);
+camera.position.set(120, 14, 90);
+
+// shared time uniform for swaying vegetation / flapping birds
+const timeUniform = { value: 0 };
+
+const env = new SkyEnv(scene, renderer);
+const ocean = new Ocean(scene, env);
+buildIslands(scene);
+const veg = buildVegetation(scene, timeUniform);
+
+const body = new ShipPhysics();
+const ship = new PirateShip(scene);
+const effects = new Effects(scene, timeUniform);
+const hud = new Hud();
+const controls = new Controls(body, camera, canvas, hud);
+
+window.addEventListener('resize', () => {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+});
+
+// --- game loop ----------------------------------------------------------------
+
+let simTime = 0;
+let accumulator = 0;
+let last = performance.now();
+let fps = 60;
+
+function frame() {
+ requestAnimationFrame(frame);
+ // use performance.now() rather than the rAF timestamp: the latter can run
+ // on a virtualised clock (throttled/headless tabs) and lose real time
+ const now = performance.now();
+ let dt = (now - last) / 1000;
+ last = now;
+ if (dt > 0.25) dt = 0.25; // tab was hidden — don't spiral
+ fps += (1 / Math.max(dt, 1e-4) - fps) * 0.05;
+
+ controls.applyInput();
+ accumulator += dt;
+ // catch up physics, but never more than 5 steps per frame: on very slow
+ // machines we'd rather run slightly slow-motion than starve the renderer
+ let steps = 0;
+ while (accumulator >= FIXED_DT && steps < 5) {
+ simTime += FIXED_DT;
+ body.step(FIXED_DT, simTime);
+ accumulator -= FIXED_DT;
+ steps++;
+ }
+ if (accumulator > FIXED_DT) accumulator = FIXED_DT;
+
+ timeUniform.value = simTime;
+ ship.update(body, simTime, dt);
+ controls.updateCamera(dt, simTime);
+ ocean.update(simTime, body.pos.x, body.pos.z, camera.position);
+ env.update(dt, ship.group.position, camera.position);
+ effects.update(body, simTime, dt);
+ hud.update(body, controls.yaw, dt);
+
+ renderer.render(scene, camera);
+
+ if (!window.__firstFrameDone) {
+ window.__firstFrameDone = true;
+ document.getElementById('loading')?.classList.add('hidden');
+ }
+}
+requestAnimationFrame(frame);
+
+// --- debug / test hook ----------------------------------------------------------
+
+window.__game = {
+ body,
+ camera,
+ renderer,
+ scene,
+ ocean,
+ vegetationCount: veg.count,
+ get fps() {
+ return fps;
+ },
+ get state() {
+ return {
+ simTime,
+ x: body.pos.x,
+ y: body.pos.y,
+ z: body.pos.z,
+ heading: body.heading,
+ speed: body.speed,
+ sail: body.sailIndex,
+ anchored: body.anchored,
+ aground: body.aground,
+ fps,
+ drawCalls: renderer.info.render.calls,
+ triangles: renderer.info.render.triangles,
+ };
+ },
+ setSail(i) {
+ body.setSail(i);
+ },
+};
diff --git a/pirate-ship/src/noise.js b/pirate-ship/src/noise.js
new file mode 100644
index 0000000..720fc45
--- /dev/null
+++ b/pirate-ship/src/noise.js
@@ -0,0 +1,75 @@
+// Tiny seeded 2D value-noise + fBm. Dependency-free, deterministic.
+// Used for island terrain detail and vegetation scatter.
+
+export function mulberry32(seed) {
+ let a = seed >>> 0;
+ return function () {
+ a |= 0;
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function createNoise2D(seed = 1337) {
+ const rand = mulberry32(seed);
+ const SIZE = 256;
+ const perm = new Uint8Array(SIZE * 2);
+ const grads = new Float32Array(SIZE * 2);
+ const p = new Uint8Array(SIZE);
+ for (let i = 0; i < SIZE; i++) p[i] = i;
+ for (let i = SIZE - 1; i > 0; i--) {
+ const j = (rand() * (i + 1)) | 0;
+ const tmp = p[i];
+ p[i] = p[j];
+ p[j] = tmp;
+ }
+ for (let i = 0; i < SIZE * 2; i++) perm[i] = p[i & 255];
+ for (let i = 0; i < SIZE; i++) {
+ const a = rand() * Math.PI * 2;
+ grads[i * 2] = Math.cos(a);
+ grads[i * 2 + 1] = Math.sin(a);
+ }
+
+ function dotGrad(ix, iz, fx, fz) {
+ const g = perm[(ix & 255) + perm[iz & 255]];
+ return grads[g * 2] * fx + grads[g * 2 + 1] * fz;
+ }
+
+ const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
+
+ // Perlin-style gradient noise, output roughly in [-1, 1]
+ return function noise2D(x, z) {
+ const ix = Math.floor(x);
+ const iz = Math.floor(z);
+ const fx = x - ix;
+ const fz = z - iz;
+ const u = fade(fx);
+ const v = fade(fz);
+ const n00 = dotGrad(ix, iz, fx, fz);
+ const n10 = dotGrad(ix + 1, iz, fx - 1, fz);
+ const n01 = dotGrad(ix, iz + 1, fx, fz - 1);
+ const n11 = dotGrad(ix + 1, iz + 1, fx - 1, fz - 1);
+ const nx0 = n00 + (n10 - n00) * u;
+ const nx1 = n01 + (n11 - n01) * u;
+ return (nx0 + (nx1 - nx0) * v) * 1.9;
+ };
+}
+
+export function createFbm2D(seed = 1337, octaves = 4, lacunarity = 2.0, gain = 0.5) {
+ const noise = createNoise2D(seed);
+ return function fbm(x, z) {
+ let amp = 1;
+ let freq = 1;
+ let sum = 0;
+ let norm = 0;
+ for (let o = 0; o < octaves; o++) {
+ sum += noise(x * freq, z * freq) * amp;
+ norm += amp;
+ amp *= gain;
+ freq *= lacunarity;
+ }
+ return sum / norm;
+ };
+}
diff --git a/pirate-ship/src/ocean.js b/pirate-ship/src/ocean.js
new file mode 100644
index 0000000..c50746a
--- /dev/null
+++ b/pirate-ship/src/ocean.js
@@ -0,0 +1,234 @@
+// ---------------------------------------------------------------------------
+// ocean.js — ocean surface mesh + shader.
+//
+// Geometry: ONE radial grid centred on the ship — fine cells near the ship,
+// geometrically growing rings out to the horizon. No seams, no LOD popping,
+// one draw call. The shader displaces vertices with the same Gerstner sum the
+// physics samples (waves.js is the single source of truth).
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { GEO_WAVES, DETAIL_WAVES, packWaves, GERSTNER_GLSL } from './waves.js';
+import { packIslands, ISLAND_GLSL, SEA_FLOOR_DEPTH, BEACH_SLOPE } from './islandField.js';
+
+const RINGS = 132;
+const SEGS = 168;
+const INNER_CELL = 1.9; // metres, first ring spacing
+const GROWTH = 1.038; // geometric ring growth (reaches ~6.8 km, well past the fog)
+
+function buildRadialGrid() {
+ const radii = [0];
+ let r = 0;
+ let step = INNER_CELL;
+ for (let i = 0; i < RINGS; i++) {
+ r += step;
+ step *= GROWTH;
+ radii.push(r);
+ }
+ const verts = [];
+ for (let i = 0; i < radii.length; i++) {
+ for (let j = 0; j < SEGS; j++) {
+ const a = (j / SEGS) * Math.PI * 2;
+ verts.push(Math.cos(a) * radii[i], 0, Math.sin(a) * radii[i]);
+ }
+ }
+ const idx = [];
+ for (let i = 0; i < radii.length - 1; i++) {
+ for (let j = 0; j < SEGS; j++) {
+ const j1 = (j + 1) % SEGS;
+ const a = i * SEGS + j;
+ const b = i * SEGS + j1;
+ const c = (i + 1) * SEGS + j;
+ const d = (i + 1) * SEGS + j1;
+ idx.push(a, b, c, b, d, c);
+ }
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
+ geo.setIndex(idx);
+ return geo;
+}
+
+const VERT = /* glsl */ `
+#define NUM_GEO_WAVES ${GEO_WAVES.length}
+uniform float uTime;
+uniform vec3 uCamPos;
+uniform vec4 uWaveA[NUM_GEO_WAVES];
+uniform vec4 uWaveB[NUM_GEO_WAVES];
+
+varying vec3 vWorld;
+varying vec3 vNormal;
+varying float vCrest;
+varying float vFade;
+varying float vDist;
+
+${GERSTNER_GLSL}
+
+void main() {
+ vec3 wp = (modelMatrix * vec4(position, 1.0)).xyz;
+ float dist = distance(wp.xz, uCamPos.xz);
+ vDist = dist;
+ // overall fade used by foam/detail in the fragment stage
+ vFade = 1.0 - smoothstep(380.0, 2300.0, dist);
+ WaveOut w = gerstner(wp.xz, uTime, uWaveA, uWaveB, dist);
+ vec3 displaced = wp + w.disp;
+ vWorld = displaced;
+ vNormal = w.normal;
+ vCrest = w.crest;
+ gl_Position = projectionMatrix * viewMatrix * vec4(displaced, 1.0);
+}
+`;
+
+const FRAG = /* glsl */ `
+precision highp float;
+#define NUM_ISLANDS ${packIslands().count}
+#define NUM_DETAIL_WAVES ${DETAIL_WAVES.length}
+#define SEA_FLOOR_DEPTH ${SEA_FLOOR_DEPTH.toFixed(1)}
+#define BEACH_SLOPE ${BEACH_SLOPE}
+
+uniform float uTime;
+uniform vec3 uCamPos;
+uniform vec3 uSunDir;
+uniform vec3 uSunColor;
+uniform vec3 uDeepColor;
+uniform vec3 uShallowColor;
+uniform vec3 uSssColor;
+uniform vec3 uSkyZenith;
+uniform vec3 uSkyHorizon;
+uniform vec3 uFogColor;
+uniform float uFogDensity;
+uniform vec4 uDetailA[NUM_DETAIL_WAVES];
+uniform vec4 uDetailB[NUM_DETAIL_WAVES];
+uniform vec4 uIslA[NUM_ISLANDS];
+uniform vec4 uIslB[NUM_ISLANDS];
+uniform vec4 uIslC[NUM_ISLANDS];
+
+varying vec3 vWorld;
+varying vec3 vNormal;
+varying float vCrest;
+varying float vFade;
+varying float vDist;
+
+${ISLAND_GLSL}
+
+void main() {
+ // --- normal: geometry normal + tiny per-pixel ripples for sparkle.
+ // Detail ripples fade fast with distance (they'd just alias out there).
+ vec3 n = vNormal;
+ float detailFade = 1.0 - smoothstep(45.0, 220.0, vDist);
+ for (int i = 0; i < NUM_DETAIL_WAVES; i++) {
+ vec2 D = uDetailA[i].xy;
+ float k = uDetailA[i].z;
+ float om = uDetailA[i].w;
+ float amp = uDetailB[i].x * detailFade;
+ float f = k * dot(D, vWorld.xz) - om * uTime + uDetailB[i].z;
+ n.xz -= D * k * amp * cos(f) * 0.9;
+ }
+ n = normalize(n);
+
+ vec3 V = normalize(uCamPos - vWorld);
+ vec3 R = reflect(-V, n);
+ R.y = abs(R.y); // waves never reflect "below horizon"
+
+ // --- water body colour from depth (terrain field mirrors the islands)
+ float terrain = terrainHeight(vWorld.xz, uIslA, uIslB, uIslC);
+ float depth = max(vWorld.y - terrain, 0.0);
+ float shallow = exp(-depth * 0.18);
+ vec3 base = mix(uDeepColor, uShallowColor, clamp(shallow, 0.0, 1.0));
+
+ // subtle subsurface glow on sun-facing wave flanks
+ float sss = pow(max(dot(V, vec3(-uSunDir.x, 0.0, -uSunDir.z)), 0.0), 2.0)
+ * clamp(vWorld.y * 0.45 + 0.35, 0.0, 1.0) * (1.0 - n.y) * 2.4;
+ base += uSssColor * sss * vFade;
+
+ // --- sky reflection (matches the sky dome gradient) + sun
+ vec3 sky = mix(uSkyHorizon, uSkyZenith, pow(clamp(R.y, 0.0, 1.0), 0.6));
+ float sunSpec = pow(max(dot(R, uSunDir), 0.0), 760.0) * 4.0
+ + pow(max(dot(R, uSunDir), 0.0), 64.0) * 0.22;
+ sky += uSunColor * sunSpec;
+
+ float fresnel = 0.025 + 0.975 * pow(1.0 - max(dot(n, V), 0.0), 5.0);
+ vec3 col = mix(base, sky, fresnel);
+
+ // --- foam
+ float breakup = sin(vWorld.x * 1.45 + uTime * 1.9) * sin(vWorld.z * 1.62 - uTime * 1.6);
+ breakup = 0.65 + 0.35 * breakup;
+ float crestFoam = smoothstep(0.3, 0.72, vCrest / 0.28) * breakup;
+
+ float wob = sin(uTime * 1.25 + vWorld.x * 0.11 + vWorld.z * 0.085) * 0.35;
+ float shoreFoam = (1.0 - smoothstep(0.0, 1.4 + wob, depth - 0.2))
+ * (0.55 + 0.45 * sin(depth * 3.1 - uTime * 2.2));
+ shoreFoam += smoothstep(0.78, 1.0, sin(depth * 1.9 - uTime * 1.45)) *
+ (1.0 - smoothstep(0.0, 4.5, depth)) * 0.38;
+ shoreFoam = clamp(shoreFoam, 0.0, 1.0) * step(0.001, depth);
+
+ float foam = clamp(crestFoam * 0.75 + shoreFoam * 0.95, 0.0, 1.0) * vFade;
+ col = mix(col, vec3(0.96, 0.99, 1.0), foam * 0.85);
+
+ // --- fog (matches scene FogExp2)
+ float fogDist = distance(uCamPos, vWorld);
+ float fogF = 1.0 - exp(-fogDist * fogDist * uFogDensity * uFogDensity);
+ col = mix(col, uFogColor, fogF);
+
+ gl_FragColor = vec4(col, 1.0);
+ #include
+ #include
+}
+`;
+
+export class Ocean {
+ constructor(scene, env) {
+ const geoPack = packWaves(GEO_WAVES);
+ const detPack = packWaves(DETAIL_WAVES);
+ const isl = packIslands();
+
+ this.uniforms = {
+ uTime: { value: 0 },
+ uCamPos: { value: new THREE.Vector3() },
+ uWaveA: { value: toVec4Array(geoPack.a) },
+ uWaveB: { value: toVec4Array(geoPack.b) },
+ uDetailA: { value: toVec4Array(detPack.a) },
+ uDetailB: { value: toVec4Array(detPack.b) },
+ uIslA: { value: toVec4Array(isl.a) },
+ uIslB: { value: toVec4Array(isl.b) },
+ uIslC: { value: toVec4Array(isl.c) },
+ uSunDir: { value: env.sunDir },
+ uSunColor: { value: new THREE.Color(0xffe9c4) },
+ uDeepColor: { value: new THREE.Color(0x07335c) },
+ uShallowColor: { value: new THREE.Color(0x1ec3b4) },
+ uSssColor: { value: new THREE.Color(0x14b89c) },
+ uSkyZenith: { value: env.skyZenith },
+ uSkyHorizon: { value: env.skyHorizon },
+ uFogColor: { value: env.fogColor },
+ uFogDensity: { value: env.fogDensity },
+ };
+
+ this.material = new THREE.ShaderMaterial({
+ vertexShader: VERT,
+ fragmentShader: FRAG,
+ uniforms: this.uniforms,
+ side: THREE.FrontSide,
+ });
+
+ this.mesh = new THREE.Mesh(buildRadialGrid(), this.material);
+ this.mesh.frustumCulled = false; // grid always surrounds the camera
+ this.mesh.matrixAutoUpdate = false;
+ scene.add(this.mesh);
+ }
+
+ update(t, focusX, focusZ, camPos) {
+ this.uniforms.uTime.value = t;
+ this.uniforms.uCamPos.value.copy(camPos);
+ // follow the ship, snapped to the inner cell size to avoid vertex crawl
+ const s = INNER_CELL;
+ this.mesh.matrix.makeTranslation(Math.round(focusX / s) * s, 0, Math.round(focusZ / s) * s);
+ }
+}
+
+function toVec4Array(flat) {
+ const arr = [];
+ for (let i = 0; i < flat.length; i += 4) {
+ arr.push(new THREE.Vector4(flat[i], flat[i + 1], flat[i + 2], flat[i + 3]));
+ }
+ return arr;
+}
diff --git a/pirate-ship/src/physics.js b/pirate-ship/src/physics.js
new file mode 100644
index 0000000..c880700
--- /dev/null
+++ b/pirate-ship/src/physics.js
@@ -0,0 +1,343 @@
+// ---------------------------------------------------------------------------
+// physics.js — lightweight rigid-body ship physics (pure JS, no three.js).
+//
+// One dynamic body: the ship. Floats on the Gerstner ocean via buoyancy
+// probes spread over the hull footprint. Each probe samples the SAME wave
+// function the GPU renders, so motion matches the visible surface exactly.
+//
+// Mass-normalised (m = 1): forces are accelerations. Local frame:
+// +x = starboard (right), +y = up, +z = forward.
+// ---------------------------------------------------------------------------
+
+import { sampleAt } from './waves.js';
+import { terrainHeightAt, terrainGradientAt, SPAWN } from './islandField.js';
+
+const GRAV = 9.81;
+
+// --- minimal vec3 / quat helpers (allocation-free) ---------------------------
+function rotate(q, x, y, z, out) {
+ // v' = q * v * q^-1
+ const { x: qx, y: qy, z: qz, w: qw } = q;
+ const ix = qw * x + qy * z - qz * y;
+ const iy = qw * y + qz * x - qx * z;
+ const iz = qw * z + qx * y - qy * x;
+ const iw = -qx * x - qy * y - qz * z;
+ out.x = ix * qw + iw * -qx + iy * -qz - iz * -qy;
+ out.y = iy * qw + iw * -qy + iz * -qx - ix * -qz;
+ out.z = iz * qw + iw * -qz + ix * -qy - iy * -qx;
+ return out;
+}
+function rotateInv(q, x, y, z, out) {
+ const inv = { x: -q.x, y: -q.y, z: -q.z, w: q.w };
+ return rotate(inv, x, y, z, out);
+}
+
+// --- tuning -------------------------------------------------------------------
+export const TUNE = {
+ draft: 1.45, // probe depth ramp distance (m)
+ keelDepth: 1.8, // keel below COM (m), used for grounding
+ buoyTotal: 1.8, // total buoyancy at full draft, in multiples of gravity
+ vDamp: 1.7, // total vertical water damping
+ hDamp: 0.07, // horizontal water coupling (wave drift / surface friction)
+ maxThrust: 2.3, // full-sail acceleration (m/s^2)
+ dragFwd1: 0.02,
+ dragFwd2: 0.014,
+ dragLat1: 0.65,
+ dragLat2: 0.35,
+ rudderMax: 0.45, // rad
+ rudderRate: 1.6, // rad/s
+ rudderTorque: 2.1,
+ angDamp: { pitch: 50, roll: 13, yaw: 58 },
+ inertia: { pitch: 72, roll: 11, yaw: 78 },
+ heelTurn: 0.55, // outward heel while turning
+ heelWind: 0.5, // heel from beam wind on sails
+ keelRighting: 16, // artificial righting torque (anti-capsize)
+ anchorDrag: 1.4,
+ groundSpring: 15,
+ groundFriction: 4.5,
+};
+
+export const SAIL_SETTINGS = [
+ { name: 'Anchored', frac: 0 },
+ { name: 'Slow', frac: 0.35 },
+ { name: 'Half sail', frac: 0.68 },
+ { name: 'Full sail', frac: 1.0 },
+];
+
+// Buoyancy probes across the hull footprint (local space).
+// Wider mid-ship rows carry more displaced volume than bow/stern tips.
+function buildProbes() {
+ const probes = [];
+ const rows = [
+ { z: -11.5, w: 0.8 },
+ { z: -5.0, w: 1.0 },
+ { z: 1.5, w: 1.0 },
+ { z: 8.0, w: 0.88 },
+ ];
+ for (const row of rows) {
+ for (const x of [-2.55, 0, 2.55]) {
+ probes.push({ x, y: -1.0, z: row.z, w: row.w * (x === 0 ? 1.08 : 1) });
+ }
+ }
+ probes.push({ x: 0, y: -0.85, z: 12.6, w: 0.55 }); // bow tip
+ probes.push({ x: 0, y: -0.95, z: -13.2, w: 0.62 }); // stern tip
+ const sum = probes.reduce((s, p) => s + p.w, 0);
+ for (const p of probes) p.w /= sum;
+ return probes;
+}
+
+// Grounding contact circles along the keel line (local z offsets).
+const CONTACTS = [11, 4, -4, -11.5];
+
+export class ShipPhysics {
+ constructor() {
+ this.probes = buildProbes();
+ this.pos = { x: SPAWN.x, y: 0.4, z: SPAWN.z };
+ this.quat = { x: 0, y: Math.sin(SPAWN.heading / 2), z: 0, w: Math.cos(SPAWN.heading / 2) };
+ this.vel = { x: 0, y: 0, z: 0 };
+ this.omegaL = { x: 0, y: 0, z: 0 }; // angular velocity, LOCAL frame
+
+ this.sailIndex = 0;
+ this.rudderInput = 0; // +1 = starboard turn (D), -1 = port turn (A)
+ this.rudder = 0;
+ this.anchored = true;
+ this.aground = false;
+
+ this.wind = { x: Math.SQRT1_2, z: Math.SQRT1_2 };
+
+ // scratch objects (no per-step allocation)
+ this._f = { x: 0, y: 0, z: 0 };
+ this._tl = { x: 0, y: 0, z: 0 };
+ this._r = { x: 0, y: 0, z: 0 };
+ this._v = { x: 0, y: 0, z: 0 };
+ this._w = { x: 0, y: 0, z: 0 };
+ this._g = { x: 0, z: 0 };
+ this._sample = { height: 0, nx: 0, ny: 1, nz: 0, vx: 0, vy: 0, vz: 0 };
+ this._fwd = { x: 0, y: 0, z: 1 };
+ this._right = { x: 1, y: 0, z: 0 };
+ this._up = { x: 0, y: 1, z: 0 };
+ }
+
+ get speed() {
+ const f = this._fwd;
+ return this.vel.x * f.x + this.vel.y * f.y + this.vel.z * f.z;
+ }
+
+ get heading() {
+ return Math.atan2(this._fwd.x, this._fwd.z);
+ }
+
+ get sail() {
+ return SAIL_SETTINGS[this.sailIndex];
+ }
+
+ setSail(i) {
+ this.sailIndex = Math.max(0, Math.min(SAIL_SETTINGS.length - 1, i));
+ if (this.sailIndex > 0) this.anchored = false;
+ }
+
+ changeSail(delta) {
+ this.setSail(this.sailIndex + delta);
+ if (this.sailIndex === 0) this.anchored = true;
+ }
+
+ toggleAnchor() {
+ this.anchored = !this.anchored;
+ if (this.anchored) this.sailIndex = 0;
+ }
+
+ reset() {
+ this.pos.x = SPAWN.x;
+ this.pos.y = 0.4;
+ this.pos.z = SPAWN.z;
+ const h = SPAWN.heading;
+ this.quat.x = 0;
+ this.quat.y = Math.sin(h / 2);
+ this.quat.z = 0;
+ this.quat.w = Math.cos(h / 2);
+ this.vel.x = this.vel.y = this.vel.z = 0;
+ this.omegaL.x = this.omegaL.y = this.omegaL.z = 0;
+ this.sailIndex = 0;
+ this.anchored = true;
+ this.rudder = 0;
+ }
+
+ /** force F at world offset r from COM -> accumulate force + torque */
+ _applyAt(fx, fy, fz, rx, ry, rz, F, T) {
+ F.x += fx;
+ F.y += fy;
+ F.z += fz;
+ T.x += ry * fz - rz * fy;
+ T.y += rz * fx - rx * fz;
+ T.z += rx * fy - ry * fx;
+ }
+
+ step(dt, t) {
+ const T = TUNE;
+ const q = this.quat;
+ const F = this._f; // accumulated world force
+ const TQ = this._w; // accumulated world torque
+ F.x = F.y = F.z = 0;
+ TQ.x = TQ.y = TQ.z = 0;
+
+ // basis vectors
+ rotate(q, 0, 0, 1, this._fwd);
+ rotate(q, 1, 0, 0, this._right);
+ rotate(q, 0, 1, 0, this._up);
+ const fwd = this._fwd;
+ const right = this._right;
+ const up = this._up;
+
+ // world angular velocity (for probe velocities)
+ const omegaW = rotate(q, this.omegaL.x, this.omegaL.y, this.omegaL.z, { x: 0, y: 0, z: 0 });
+
+ // --- gravity
+ F.y -= GRAV;
+
+ // --- buoyancy + water damping per probe
+ let submergedFrac = 0;
+ for (let i = 0; i < this.probes.length; i++) {
+ const p = this.probes[i];
+ const r = rotate(q, p.x, p.y, p.z, this._r); // world offset from COM
+ const px = this.pos.x + r.x;
+ const py = this.pos.y + r.y;
+ const pz = this.pos.z + r.z;
+ const s = sampleAt(px, pz, t, this._sample);
+ const depth = s.height - py;
+ if (depth <= 0) continue;
+ const ramp = Math.min(depth / T.draft, 1.6);
+ submergedFrac += p.w * Math.min(ramp, 1);
+ // buoyant push straight up, applied at the probe -> natural righting
+ let fy = GRAV * T.buoyTotal * p.w * ramp;
+ // probe velocity relative to the moving water surface
+ const pvx = this.vel.x + omegaW.y * r.z - omegaW.z * r.y;
+ const pvy = this.vel.y + omegaW.z * r.x - omegaW.x * r.z;
+ const pvz = this.vel.z + omegaW.x * r.y - omegaW.y * r.x;
+ const wet = Math.min(ramp, 1);
+ fy -= (pvy - s.vy) * T.vDamp * p.w * wet;
+ const fx = -(pvx - s.vx) * T.hDamp * p.w * wet;
+ const fz = -(pvz - s.vz) * T.hDamp * p.w * wet;
+ this._applyAt(fx, fy, fz, r.x, r.y, r.z, F, TQ);
+ }
+ this.submergedFrac = submergedFrac;
+
+ // --- velocity decomposition
+ const vf = this.vel.x * fwd.x + this.vel.z * fwd.z; // forward speed (planar)
+ const vl = this.vel.x * right.x + this.vel.z * right.z; // lateral speed
+
+ // --- sails
+ const sailFrac = this.anchored ? 0 : SAIL_SETTINGS[this.sailIndex].frac;
+ if (sailFrac > 0) {
+ const windDot = this.wind.x * fwd.x + this.wind.z * fwd.z; // -1..1
+ const windFactor = 0.45 + 0.55 * Math.max(0, windDot * 0.5 + 0.5);
+ const thrust = T.maxThrust * sailFrac * windFactor * Math.min(1, submergedFrac * 3);
+ F.x += fwd.x * thrust;
+ F.z += fwd.z * thrust;
+ // beam wind heels the ship
+ const beam = this.wind.x * right.x + this.wind.z * right.z;
+ TQ.x += fwd.x * -beam * T.heelWind * sailFrac;
+ TQ.y += fwd.y * -beam * T.heelWind * sailFrac;
+ TQ.z += fwd.z * -beam * T.heelWind * sailFrac;
+ }
+
+ // --- hull drag
+ const dragF = T.dragFwd1 * vf + T.dragFwd2 * vf * Math.abs(vf);
+ const dragL = T.dragLat1 * vl + T.dragLat2 * vl * Math.abs(vl);
+ F.x -= fwd.x * dragF + right.x * dragL;
+ F.z -= fwd.z * dragF + right.z * dragL;
+ if (this.anchored) {
+ F.x -= this.vel.x * T.anchorDrag;
+ F.z -= this.vel.z * T.anchorDrag;
+ }
+
+ // --- rudder
+ const target = this.rudderInput * T.rudderMax;
+ const dr = target - this.rudder;
+ const maxStep = T.rudderRate * dt;
+ this.rudder += Math.max(-maxStep, Math.min(maxStep, dr));
+ // yaw torque scales with water flow over the rudder
+ TQ.y += this.rudder * vf * T.rudderTorque;
+ // heel outward in turns
+ const heel = -this.rudder * Math.max(0, vf) * T.heelTurn;
+ TQ.x += fwd.x * heel;
+ TQ.y += fwd.y * heel;
+ TQ.z += fwd.z * heel;
+
+ // --- keel righting assist (anti-capsize)
+ // Torque axis = cross(shipUp, worldUp) = (-up.z, 0, up.x): rotates the
+ // ship's up vector back toward world up.
+ TQ.x += -up.z * T.keelRighting;
+ TQ.z += up.x * T.keelRighting;
+
+ // --- grounding on islands (soft beaching)
+ this.aground = false;
+ for (let i = 0; i < CONTACTS.length; i++) {
+ const r = rotate(q, 0, -1.0, CONTACTS[i], this._r);
+ const px = this.pos.x + r.x;
+ const pz = this.pos.z + r.z;
+ const keelY = this.pos.y + r.y - (T.keelDepth - 1.0);
+ const ground = terrainHeightAt(px, pz);
+ const pen = ground - keelY;
+ if (pen <= 0) continue;
+ this.aground = true;
+ const capped = Math.min(pen, 1.0);
+ const g = terrainGradientAt(px, pz, this._g); // uphill
+ const gl = Math.hypot(g.x, g.z) || 1;
+ const dhx = -g.x / gl; // downhill (push back to sea)
+ const dhz = -g.z / gl;
+ // gentle spring push + lift, applied at the contact, and only while the
+ // ship still moves shoreward or sits still — never slingshots it out
+ const vDown = this.vel.x * dhx + this.vel.z * dhz; // speed already heading to sea
+ const springScale = vDown > 1.5 ? 0.15 : 1.0;
+ this._applyAt(
+ dhx * capped * T.groundSpring * springScale,
+ capped * T.groundSpring * 0.25,
+ dhz * capped * T.groundSpring * springScale,
+ r.x,
+ r.y,
+ r.z,
+ F,
+ TQ
+ );
+ // friction
+ F.x -= this.vel.x * T.groundFriction * Math.min(capped, 1);
+ F.z -= this.vel.z * T.groundFriction * Math.min(capped, 1);
+ }
+
+ // --- integrate linear
+ this.vel.x += F.x * dt;
+ this.vel.y += F.y * dt;
+ this.vel.z += F.z * dt;
+ this.pos.x += this.vel.x * dt;
+ this.pos.y += this.vel.y * dt;
+ this.pos.z += this.vel.z * dt;
+
+ // --- integrate angular (local frame, diagonal inertia)
+ const tl = rotateInv(q, TQ.x, TQ.y, TQ.z, this._tl);
+ const I = T.inertia;
+ const D = T.angDamp;
+ this.omegaL.x += ((tl.x - this.omegaL.x * D.pitch) / I.pitch) * dt;
+ this.omegaL.y += ((tl.y - this.omegaL.y * D.yaw) / I.yaw) * dt;
+ this.omegaL.z += ((tl.z - this.omegaL.z * D.roll) / I.roll) * dt;
+
+ // quaternion integration: dq = 0.5 * (omega_world) * q
+ const ow = rotate(q, this.omegaL.x, this.omegaL.y, this.omegaL.z, this._v);
+ const hx = ow.x * 0.5 * dt;
+ const hy = ow.y * 0.5 * dt;
+ const hz = ow.z * 0.5 * dt;
+ const nqx = q.x + (hx * q.w + hy * q.z - hz * q.y);
+ const nqy = q.y + (hy * q.w + hz * q.x - hx * q.z);
+ const nqz = q.z + (hz * q.w + hx * q.y - hy * q.x);
+ const nqw = q.w + (-hx * q.x - hy * q.y - hz * q.z);
+ const il = 1 / Math.hypot(nqx, nqy, nqz, nqw);
+ q.x = nqx * il;
+ q.y = nqy * il;
+ q.z = nqz * il;
+ q.w = nqw * il;
+
+ // refresh cached basis for getters
+ rotate(q, 0, 0, 1, this._fwd);
+ rotate(q, 1, 0, 0, this._right);
+ rotate(q, 0, 1, 0, this._up);
+ }
+}
diff --git a/pirate-ship/src/ship.js b/pirate-ship/src/ship.js
new file mode 100644
index 0000000..5278bd8
--- /dev/null
+++ b/pirate-ship/src/ship.js
@@ -0,0 +1,431 @@
+// ---------------------------------------------------------------------------
+// ship.js — procedural low-poly pirate galleon.
+// Local frame matches physics.js: +x starboard, +y up, +z forward (bow).
+// Origin = centre of mass (waterline-ish). Keel ~ -1.85, main deck ~ +2.2.
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+
+// Hull stations: z along the ship, w = half beam, keel/deck heights.
+const STATIONS = [
+ { z: -13.8, w: 2.3, keel: -0.2, deck: 3.6 }, // transom
+ { z: -11.0, w: 3.2, keel: -1.35, deck: 3.0 },
+ { z: -7.0, w: 3.7, keel: -1.7, deck: 2.45 },
+ { z: -2.0, w: 4.0, keel: -1.85, deck: 2.2 },
+ { z: 3.0, w: 3.85, keel: -1.8, deck: 2.2 },
+ { z: 7.5, w: 3.3, keel: -1.65, deck: 2.45 },
+ { z: 11.0, w: 2.3, keel: -1.3, deck: 2.9 },
+ { z: 13.6, w: 0.45, keel: -0.55, deck: 3.5 }, // bow tip (rising sheer)
+];
+
+// Cross-section profile from keel (s=0) to bulwark top (s=1): x and y fractions.
+const SECTION = [
+ [0.0, 0.0],
+ [0.5, 0.1],
+ [0.88, 0.42],
+ [1.0, 0.78],
+ [0.97, 1.0],
+ [0.92, 1.18], // bulwark lip above deck line
+];
+
+function buildHullGeometry() {
+ const verts = [];
+ const idx = [];
+ const ringSize = SECTION.length * 2 - 1; // mirrored, sharing the keel point
+
+ for (const st of STATIONS) {
+ const ring = [];
+ // port side, from bulwark down to keel
+ for (let i = SECTION.length - 1; i >= 0; i--) {
+ const [fx, fy] = SECTION[i];
+ ring.push([-st.w * fx, st.keel + (st.deck - st.keel) * fy, st.z]);
+ }
+ // starboard side, keel up to bulwark (skip duplicated keel point)
+ for (let i = 1; i < SECTION.length; i++) {
+ const [fx, fy] = SECTION[i];
+ ring.push([st.w * fx, st.keel + (st.deck - st.keel) * fy, st.z]);
+ }
+ for (const p of ring) verts.push(...p);
+ }
+ for (let s = 0; s < STATIONS.length - 1; s++) {
+ for (let i = 0; i < ringSize - 1; i++) {
+ const a = s * ringSize + i;
+ const b = a + 1;
+ const c = a + ringSize;
+ const d = b + ringSize;
+ idx.push(a, c, b, b, c, d);
+ }
+ }
+ // transom (stern cap)
+ const sternCenter = verts.length / 3;
+ const st0 = STATIONS[0];
+ verts.push(0, (st0.keel + st0.deck) / 2, st0.z);
+ for (let i = 0; i < ringSize - 1; i++) {
+ idx.push(i, i + 1, sternCenter);
+ }
+ // bow cap (otherwise the bow ring is a gaping hole)
+ const bowCenter = verts.length / 3;
+ const stN = STATIONS[STATIONS.length - 1];
+ const bowBase = (STATIONS.length - 1) * ringSize;
+ verts.push(0, (stN.keel + stN.deck) / 2, stN.z);
+ for (let i = 0; i < ringSize - 1; i++) {
+ idx.push(bowBase + i + 1, bowBase + i, bowCenter);
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
+ geo.setIndex(idx);
+ const non = geo.toNonIndexed();
+ non.computeVertexNormals();
+ return non;
+}
+
+function stationAt(z) {
+ // interpolate hull half-width / deck height along the ship
+ let a = STATIONS[0];
+ let b = STATIONS[STATIONS.length - 1];
+ for (let i = 0; i < STATIONS.length - 1; i++) {
+ if (z >= STATIONS[i].z && z <= STATIONS[i + 1].z) {
+ a = STATIONS[i];
+ b = STATIONS[i + 1];
+ break;
+ }
+ }
+ const t = (z - a.z) / (b.z - a.z || 1);
+ return {
+ w: a.w + (b.w - a.w) * t,
+ deck: a.deck + (b.deck - a.deck) * t,
+ keel: a.keel + (b.keel - a.keel) * t,
+ };
+}
+
+function buildDeckGeometry() {
+ // deck surface with slight camber, overlapping the hull sides (no slot)
+ const verts = [];
+ const idx = [];
+ STATIONS.forEach((st) => {
+ const w = st.w * 0.99;
+ verts.push(-w, st.deck, st.z, 0, st.deck + 0.18, st.z, w, st.deck, st.z);
+ });
+ for (let s = 0; s < STATIONS.length - 1; s++) {
+ const a = s * 3;
+ idx.push(a, a + 3, a + 1, a + 1, a + 3, a + 4, a + 1, a + 4, a + 2, a + 2, a + 4, a + 5);
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
+ geo.setIndex(idx);
+ const non = geo.toNonIndexed();
+ non.computeVertexNormals();
+ return non;
+}
+
+function cylinderBetween(r1, r2, from, to, radial = 6) {
+ const dir = new THREE.Vector3().subVectors(to, from);
+ const len = dir.length();
+ const geo = new THREE.CylinderGeometry(r1, r2, len, radial);
+ geo.translate(0, len / 2, 0);
+ const quat = new THREE.Quaternion().setFromUnitVectors(
+ new THREE.Vector3(0, 1, 0),
+ dir.clone().normalize()
+ );
+ geo.applyQuaternion(quat);
+ geo.translate(from.x, from.y, from.z);
+ return geo;
+}
+
+function makeSquareSail(width, height, billow) {
+ // hangs from its top edge (origin); billows toward the bow (+z)
+ const geo = new THREE.PlaneGeometry(width, height, 8, 6);
+ geo.translate(0, -height / 2, 0);
+ const p = geo.getAttribute('position');
+ for (let i = 0; i < p.count; i++) {
+ const u = p.getX(i) / width + 0.5;
+ const v = -p.getY(i) / height; // 0 top .. 1 bottom
+ p.setZ(i, Math.sin(Math.PI * u) * (0.25 + 0.75 * v) * billow);
+ // bottom corners pulled slightly inward (sheeted)
+ p.setX(i, p.getX(i) * (1 - v * 0.12));
+ }
+ geo.computeVertexNormals();
+ return geo;
+}
+
+function makeJibSail(billow) {
+ // triangular staysail between bowsprit and foremast, subdivided fan
+ const A = new THREE.Vector3(0, 0.4, 6.4); // tack near bowsprit tip (local to group)
+ const B = new THREE.Vector3(0, 8.2, -3.2); // head up the foremast stay
+ const C = new THREE.Vector3(0, 0.6, -3.4); // clew at deck
+ const segs = 6;
+ const verts = [];
+ const idx = [];
+ for (let i = 0; i <= segs; i++) {
+ const t = i / segs;
+ const top = new THREE.Vector3().lerpVectors(A, B, t);
+ const bot = new THREE.Vector3().lerpVectors(A, C, t);
+ for (let j = 0; j <= 2; j++) {
+ const v = j / 2;
+ const pt = new THREE.Vector3().lerpVectors(top, bot, v);
+ pt.x += Math.sin(Math.PI * t) * Math.sin(Math.PI * v) * billow;
+ verts.push(pt.x, pt.y, pt.z);
+ }
+ }
+ for (let i = 0; i < segs; i++) {
+ for (let j = 0; j < 2; j++) {
+ const a = i * 3 + j;
+ const b = a + 3;
+ idx.push(a, b, a + 1, a + 1, b, b + 1);
+ }
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
+ geo.setIndex(idx);
+ const non = geo.toNonIndexed();
+ non.computeVertexNormals();
+ return non;
+}
+
+function makeFlagTexture() {
+ const c = document.createElement('canvas');
+ c.width = 128;
+ c.height = 80;
+ const g = c.getContext('2d');
+ g.fillStyle = '#14100e';
+ g.fillRect(0, 0, 128, 80);
+ g.fillStyle = '#f2ead8';
+ // skull
+ g.beginPath();
+ g.arc(64, 32, 14, 0, Math.PI * 2);
+ g.fill();
+ g.fillRect(57, 40, 14, 8);
+ // eyes + nose
+ g.fillStyle = '#14100e';
+ g.beginPath();
+ g.arc(58, 30, 3.4, 0, Math.PI * 2);
+ g.arc(70, 30, 3.4, 0, Math.PI * 2);
+ g.fill();
+ g.beginPath();
+ g.moveTo(64, 35);
+ g.lineTo(61, 40);
+ g.lineTo(67, 40);
+ g.fill();
+ // crossbones
+ g.strokeStyle = '#f2ead8';
+ g.lineWidth = 5;
+ g.lineCap = 'round';
+ g.beginPath();
+ g.moveTo(38, 56);
+ g.lineTo(90, 68);
+ g.moveTo(90, 56);
+ g.lineTo(38, 68);
+ g.stroke();
+ const tex = new THREE.CanvasTexture(c);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ return tex;
+}
+
+export class PirateShip {
+ constructor(scene) {
+ this.group = new THREE.Group();
+
+ // DoubleSide: the hull is a shell — without it, the inner faces are
+ // culled and the ship looks like it has holes when seen over the rail.
+ const matHull = new THREE.MeshStandardMaterial({
+ color: 0x53381f,
+ roughness: 0.85,
+ flatShading: true,
+ side: THREE.DoubleSide,
+ });
+ const matWood = new THREE.MeshStandardMaterial({
+ color: 0x96704a,
+ roughness: 0.9,
+ flatShading: true,
+ side: THREE.DoubleSide, // deck is a sheet; masts/spars unaffected
+ });
+ const matDark = new THREE.MeshStandardMaterial({ color: 0x3a2a19, roughness: 0.9, flatShading: true });
+ const matGold = new THREE.MeshStandardMaterial({ color: 0xd8a93f, roughness: 0.45, metalness: 0.55 });
+ this.matSail = new THREE.MeshStandardMaterial({
+ color: 0xf0e6cf,
+ roughness: 0.9,
+ side: THREE.DoubleSide,
+ flatShading: true,
+ // faint warm glow fakes sunlight bleeding through the canvas
+ emissive: 0x9b8a66,
+ emissiveIntensity: 0.34,
+ });
+
+ // --- hull + deck
+ const hull = new THREE.Mesh(buildHullGeometry(), matHull);
+ const deck = new THREE.Mesh(buildDeckGeometry(), matWood);
+
+ // --- superstructure, keel, gold trim
+ const woodParts = [];
+ const darkParts = [];
+ const goldParts = [];
+
+ woodParts.push(new THREE.BoxGeometry(4.6, 1.7, 5.4).translate(0, 3.3, -10.6)); // quarterdeck cabin
+ woodParts.push(new THREE.BoxGeometry(3.2, 1.1, 3.6).translate(0, 3.0, 10.4)); // forecastle
+ darkParts.push(new THREE.BoxGeometry(0.5, 0.9, 15).translate(0, -2.0, -0.5)); // keel fin
+ darkParts.push(new THREE.BoxGeometry(0.4, 1.4, 0.9).translate(0, -1.6, -13.6)); // rudder
+ goldParts.push(new THREE.BoxGeometry(4.4, 0.28, 0.18).translate(0, 3.45, -13.95)); // stern trim
+ goldParts.push(new THREE.BoxGeometry(3.6, 0.2, 0.16).translate(0, 2.6, -14.0));
+
+ // cannons poking through the bulwarks, hugging the hull at each station
+ for (const side of [-1, 1]) {
+ for (const z of [-6.5, -2.5, 1.5, 5.5]) {
+ const st = stationAt(z);
+ const g = new THREE.CylinderGeometry(0.13, 0.16, 1.6, 6)
+ .rotateZ(Math.PI / 2)
+ .translate(side * (st.w - 0.2), st.deck - 0.55, z);
+ darkParts.push(g);
+ }
+ }
+
+ // --- masts & spars
+ const mastDefs = [
+ { z: -1.0, base: 2.2, top: 18.2, r: 0.3 }, // main
+ { z: 7.5, base: 2.4, top: 15.0, r: 0.24 }, // fore
+ { z: -9.5, base: 3.8, top: 13.2, r: 0.2 }, // mizzen
+ ];
+ for (const m of mastDefs) {
+ woodParts.push(
+ cylinderBetween(m.r * 0.55, m.r, new THREE.Vector3(0, m.base, m.z), new THREE.Vector3(0, m.top, m.z), 7)
+ );
+ // crow's nest on the main mast
+ if (m.z === -1.0) {
+ woodParts.push(new THREE.CylinderGeometry(0.55, 0.42, 0.55, 8, 1, true).translate(0, 14.4, m.z));
+ }
+ }
+ // bowsprit
+ woodParts.push(
+ cylinderBetween(0.09, 0.2, new THREE.Vector3(0, 3.4, 13.2), new THREE.Vector3(0, 6.6, 20.2), 6)
+ );
+
+ // yards (spars carrying the square sails)
+ const yards = [
+ { z: -1.0, y: 13.4, w: 8.2 },
+ { z: -1.0, y: 9.2, w: 10.4 },
+ { z: 7.5, y: 11.4, w: 6.6 },
+ { z: 7.5, y: 8.0, w: 8.4 },
+ ];
+ for (const yd of yards) {
+ woodParts.push(
+ new THREE.CylinderGeometry(0.09, 0.09, yd.w, 6).rotateZ(Math.PI / 2).translate(0, yd.y, yd.z)
+ );
+ }
+
+ const woodMesh = new THREE.Mesh(mergeGeometries(woodParts), matWood);
+ const darkMesh = new THREE.Mesh(mergeGeometries(darkParts), matDark);
+ const goldMesh = new THREE.Mesh(mergeGeometries(goldParts), matGold);
+
+ // --- sails (separate meshes so they can furl with the sail setting)
+ this.sails = [];
+ const sailDefs = [
+ { yard: yards[0], drop: 3.6, billow: 0.75 },
+ { yard: yards[1], drop: 4.4, billow: 1.05 },
+ { yard: yards[2], drop: 2.9, billow: 0.65 },
+ { yard: yards[3], drop: 3.6, billow: 0.9 },
+ ];
+ for (const sd of sailDefs) {
+ const sail = new THREE.Mesh(makeSquareSail(sd.yard.w * 0.92, sd.drop, sd.billow), this.matSail);
+ sail.position.set(0, sd.yard.y - 0.12, sd.yard.z);
+ sail.castShadow = true;
+ this.sails.push(sail);
+ this.group.add(sail);
+ }
+ const jibGroup = new THREE.Group();
+ jibGroup.position.set(0, 5.4, 13.4);
+ const jib = new THREE.Mesh(makeJibSail(0.9), this.matSail);
+ jib.castShadow = true;
+ jibGroup.add(jib);
+ this.jib = jib;
+ this.group.add(jibGroup);
+
+ // --- flag
+ this.flagGeo = new THREE.PlaneGeometry(2.6, 1.5, 10, 4);
+ this.flagGeo.translate(1.3, 0, 0); // streams from the pole
+ this.flagBase = this.flagGeo.getAttribute('position').array.slice();
+ const flag = new THREE.Mesh(
+ this.flagGeo,
+ new THREE.MeshStandardMaterial({ map: makeFlagTexture(), side: THREE.DoubleSide, roughness: 1 })
+ );
+ flag.position.set(0, 18.6, -1.0);
+ this.flag = flag;
+ this.group.add(flag);
+
+ // --- stern lantern
+ const lantern = new THREE.Mesh(
+ new THREE.OctahedronGeometry(0.32),
+ new THREE.MeshStandardMaterial({
+ color: 0xffd27a,
+ emissive: 0xffb84d,
+ emissiveIntensity: 1.4,
+ roughness: 0.3,
+ })
+ );
+ lantern.position.set(0, 4.6, -14.3);
+ this.group.add(lantern);
+
+ // --- rigging
+ const rig = [];
+ const addLine = (a, b) => rig.push(a.x, a.y, a.z, b.x, b.y, b.z);
+ const v = (x, y, z) => new THREE.Vector3(x, y, z);
+ for (const m of mastDefs) {
+ const hTop = m.top - 0.4;
+ for (const side of [-1, 1]) {
+ for (const dz of [-1.6, 0, 1.6]) {
+ addLine(v(0, hTop * 0.82, m.z), v(side * 3.1, 2.6, m.z + dz));
+ }
+ }
+ }
+ addLine(v(0, 18.0, -1), v(0, 6.5, 20.0)); // forestay to bowsprit
+ addLine(v(0, 14.8, 7.5), v(0, 6.5, 20.0));
+ addLine(v(0, 18.0, -1), v(0, 14.8, 7.5)); // main->fore stay
+ addLine(v(0, 13.0, -9.5), v(0, 18.0, -1)); // mizzen->main
+ addLine(v(0, 13.0, -9.5), v(0, 3.7, -13.8)); // backstay
+ const rigGeo = new THREE.BufferGeometry();
+ rigGeo.setAttribute('position', new THREE.Float32BufferAttribute(rig, 3));
+ const rigging = new THREE.LineSegments(
+ rigGeo,
+ new THREE.LineBasicMaterial({ color: 0x1c130c, transparent: true, opacity: 0.85 })
+ );
+
+ for (const mesh of [hull, deck, woodMesh, darkMesh, goldMesh]) {
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ this.group.add(mesh);
+ }
+ this.group.add(rigging);
+
+ this._sailAmount = 0;
+ scene.add(this.group);
+ }
+
+ /** Sync visuals with the physics body + animate sails/flag. */
+ update(body, t, dt) {
+ this.group.position.set(body.pos.x, body.pos.y, body.pos.z);
+ this.group.quaternion.set(body.quat.x, body.quat.y, body.quat.z, body.quat.w);
+
+ // sails furl/unfurl toward the current setting
+ const target = body.anchored ? 0 : body.sail.frac;
+ this._sailAmount += (target - this._sailAmount) * Math.min(1, dt * 1.8);
+ const a = this._sailAmount;
+ const sy = 0.08 + 0.92 * a;
+ for (const s of this.sails) {
+ s.scale.set(1, sy, 0.25 + 0.75 * a);
+ s.visible = a > 0.02;
+ }
+ this.jib.scale.setScalar(Math.max(0.001, a));
+ this.jib.visible = a > 0.02;
+
+ // flag streams in the wind (world-space wind direction -> local)
+ const windWorld = Math.atan2(1, 1); // wind blows toward +x+z
+ const heading = body.heading;
+ this.flag.rotation.y = windWorld - heading + Math.PI / 2;
+ const p = this.flagGeo.getAttribute('position');
+ const base = this.flagBase;
+ for (let i = 0; i < p.count; i++) {
+ const bx = base[i * 3];
+ p.setZ(i, Math.sin(bx * 2.4 - t * 8.0) * 0.16 * (bx / 2.6) + base[i * 3 + 2]);
+ }
+ p.needsUpdate = true;
+ }
+}
diff --git a/pirate-ship/src/sky.js b/pirate-ship/src/sky.js
new file mode 100644
index 0000000..14b3619
--- /dev/null
+++ b/pirate-ship/src/sky.js
@@ -0,0 +1,208 @@
+// ---------------------------------------------------------------------------
+// sky.js — sky dome, sun, clouds, lighting rig and fog.
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { WIND } from './waves.js';
+
+const SKY_VERT = /* glsl */ `
+varying vec3 vDir;
+void main() {
+ vDir = normalize(position);
+ vec4 wp = modelMatrix * vec4(position, 1.0);
+ gl_Position = projectionMatrix * viewMatrix * wp;
+}
+`;
+
+const SKY_FRAG = /* glsl */ `
+uniform vec3 uZenith;
+uniform vec3 uMid;
+uniform vec3 uHorizon;
+uniform vec3 uSunDir;
+uniform vec3 uSunColor;
+varying vec3 vDir;
+void main() {
+ vec3 d = normalize(vDir);
+ float h = clamp(d.y, 0.0, 1.0);
+ vec3 col = mix(uHorizon, uMid, smoothstep(0.0, 0.18, h));
+ col = mix(col, uZenith, smoothstep(0.12, 0.65, h));
+ float s = max(dot(d, uSunDir), 0.0);
+ col += uSunColor * (smoothstep(0.99955, 0.99985, s) * 3.0); // disc
+ col += uSunColor * pow(s, 240.0) * 0.55; // corona
+ col += uSunColor * pow(s, 9.0) * 0.16; // haze
+ gl_FragColor = vec4(col, 1.0);
+ #include
+ #include
+}
+`;
+
+// Camera-facing instanced cloud billboards (one draw call).
+const CLOUD_VERT = /* glsl */ `
+attribute vec2 aScale;
+varying vec2 vUv;
+varying float vAlpha;
+void main() {
+ vUv = uv;
+ vAlpha = aScale.y;
+ vec4 center = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
+ vec4 wc = modelMatrix * center;
+ vec3 camRight = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]);
+ vec3 camUp = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]);
+ vec3 wp = wc.xyz + (camRight * position.x + camUp * position.y) * aScale.x;
+ gl_Position = projectionMatrix * viewMatrix * vec4(wp, 1.0);
+}
+`;
+
+const CLOUD_FRAG = /* glsl */ `
+uniform sampler2D uMap;
+varying vec2 vUv;
+varying float vAlpha;
+void main() {
+ vec4 tex = texture2D(uMap, vUv);
+ gl_FragColor = vec4(tex.rgb, tex.a * vAlpha);
+ if (gl_FragColor.a < 0.01) discard;
+ #include
+ #include
+}
+`;
+
+function makeCloudTexture() {
+ const c = document.createElement('canvas');
+ c.width = c.height = 256;
+ const g = c.getContext('2d');
+ g.clearRect(0, 0, 256, 256);
+ // a few overlapping soft blobs make a puffy cumulus silhouette
+ const blobs = [
+ [128, 150, 70], [80, 160, 48], [180, 158, 52], [110, 120, 44],
+ [155, 118, 40], [60, 175, 30], [200, 178, 28],
+ ];
+ for (const [x, y, r] of blobs) {
+ const grad = g.createRadialGradient(x, y, 0, x, y, r);
+ grad.addColorStop(0, 'rgba(255,255,255,0.85)');
+ grad.addColorStop(0.65, 'rgba(250,252,255,0.45)');
+ grad.addColorStop(1, 'rgba(245,250,255,0)');
+ g.fillStyle = grad;
+ g.beginPath();
+ g.arc(x, y, r, 0, Math.PI * 2);
+ g.fill();
+ }
+ const tex = new THREE.CanvasTexture(c);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ return tex;
+}
+
+export class SkyEnv {
+ constructor(scene, renderer) {
+ this.sunDir = new THREE.Vector3(-0.42, 0.52, -0.74).normalize();
+ this.skyZenith = new THREE.Color(0x2570bd);
+ this.skyHorizon = new THREE.Color(0xd6ecf2);
+ this.skyMid = new THREE.Color(0x7db8e3);
+ this.fogColor = new THREE.Color(0xc9e4ee);
+ this.fogDensity = 0.0004;
+
+ scene.fog = new THREE.FogExp2(this.fogColor, this.fogDensity);
+ renderer.setClearColor(this.fogColor);
+
+ // --- dome
+ this.domeUniforms = {
+ uZenith: { value: this.skyZenith },
+ uMid: { value: this.skyMid },
+ uHorizon: { value: this.skyHorizon },
+ uSunDir: { value: this.sunDir },
+ uSunColor: { value: new THREE.Color(0xfff0cf) },
+ };
+ const dome = new THREE.Mesh(
+ new THREE.SphereGeometry(5200, 32, 18),
+ new THREE.ShaderMaterial({
+ vertexShader: SKY_VERT,
+ fragmentShader: SKY_FRAG,
+ uniforms: this.domeUniforms,
+ side: THREE.BackSide,
+ depthWrite: false,
+ fog: false,
+ })
+ );
+ dome.frustumCulled = false;
+ dome.renderOrder = -10;
+ this.dome = dome;
+ scene.add(dome);
+
+ // --- sun light + shadows (shadow box follows the ship in update())
+ const sun = new THREE.DirectionalLight(0xfff1d8, 2.7);
+ sun.castShadow = true;
+ sun.shadow.mapSize.set(2048, 2048);
+ sun.shadow.camera.near = 100;
+ sun.shadow.camera.far = 1400;
+ const ext = 120;
+ sun.shadow.camera.left = -ext;
+ sun.shadow.camera.right = ext;
+ sun.shadow.camera.top = ext;
+ sun.shadow.camera.bottom = -ext;
+ sun.shadow.bias = -0.0004;
+ sun.shadow.normalBias = 2.0;
+ this.sun = sun;
+ scene.add(sun);
+ scene.add(sun.target);
+
+ this.hemi = new THREE.HemisphereLight(0xa8d8ff, 0x3f6b50, 0.95);
+ scene.add(this.hemi);
+
+ // --- clouds
+ const cloudGeo = new THREE.PlaneGeometry(1, 0.55);
+ const N_CLOUDS = 18;
+ const cloudMat = new THREE.ShaderMaterial({
+ vertexShader: CLOUD_VERT,
+ fragmentShader: CLOUD_FRAG,
+ uniforms: { uMap: { value: makeCloudTexture() } },
+ transparent: true,
+ depthWrite: false,
+ fog: false,
+ });
+ const clouds = new THREE.InstancedMesh(cloudGeo, cloudMat, N_CLOUDS);
+ const scales = new Float32Array(N_CLOUDS * 2);
+ const m = new THREE.Matrix4();
+ this.cloudData = [];
+ for (let i = 0; i < N_CLOUDS; i++) {
+ const a = (i / N_CLOUDS) * Math.PI * 2 + (i % 3) * 0.41;
+ const r = 900 + ((i * 467) % 2100);
+ const x = Math.cos(a) * r;
+ const z = Math.sin(a) * r;
+ const y = 320 + ((i * 211) % 260);
+ m.makeTranslation(x, y, z);
+ clouds.setMatrixAt(i, m);
+ scales[i * 2] = 380 + ((i * 137) % 420);
+ scales[i * 2 + 1] = 0.5 + ((i * 31) % 40) / 100;
+ this.cloudData.push({ x, y, z });
+ }
+ cloudGeo.setAttribute('aScale', new THREE.InstancedBufferAttribute(scales, 2));
+ clouds.instanceMatrix.needsUpdate = true;
+ clouds.frustumCulled = false;
+ this.clouds = clouds;
+ this._cloudM = m;
+ scene.add(clouds);
+ }
+
+ update(dt, focus, camPos) {
+ // dome + clouds follow the camera so the horizon never ends
+ this.dome.position.set(camPos.x, 0, camPos.z);
+ // shadow box follows the ship
+ this.sun.position.copy(focus).addScaledVector(this.sunDir, 700);
+ this.sun.target.position.copy(focus);
+ // clouds drift downwind
+ for (let i = 0; i < this.cloudData.length; i++) {
+ const c = this.cloudData[i];
+ c.x += WIND.dirX * dt * 2.4;
+ c.z += WIND.dirZ * dt * 2.4;
+ // wrap around the camera
+ const dx = c.x - camPos.x;
+ const dz = c.z - camPos.z;
+ if (dx * dx + dz * dz > 3400 * 3400) {
+ c.x = camPos.x - dx * 0.96;
+ c.z = camPos.z - dz * 0.96;
+ }
+ this._cloudM.makeTranslation(c.x, c.y, c.z);
+ this.clouds.setMatrixAt(i, this._cloudM);
+ }
+ this.clouds.instanceMatrix.needsUpdate = true;
+ }
+}
diff --git a/pirate-ship/src/vegetation.js b/pirate-ship/src/vegetation.js
new file mode 100644
index 0000000..e79ef45
--- /dev/null
+++ b/pirate-ship/src/vegetation.js
@@ -0,0 +1,340 @@
+// ---------------------------------------------------------------------------
+// vegetation.js — instanced jungle: palms, undergrowth and rocks scattered on
+// the islands by height/slope. A handful of draw calls for hundreds of plants.
+// ---------------------------------------------------------------------------
+
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { ISLANDS, terrainHeightAt, terrainGradientAt, jungleDensityAt } from './islandField.js';
+import { mulberry32 } from './noise.js';
+
+const TRUNK = new THREE.Color(0x7a5a38);
+const TRUNK_D = new THREE.Color(0x5d4329);
+const FROND_A = new THREE.Color(0x2f8f3c);
+const FROND_B = new THREE.Color(0x57b54a);
+const LEAF_DARK = new THREE.Color(0x256e30);
+const CANOPY_A = new THREE.Color(0x1c4f24);
+const CANOPY_B = new THREE.Color(0x39752f);
+const COCONUT = new THREE.Color(0x4f3a22);
+const ROCK_C = new THREE.Color(0x878073);
+
+function colorize(geo, color, color2 = null, axis = 'y') {
+ const pos = geo.getAttribute('position');
+ const col = new Float32Array(pos.count * 3);
+ const c = new THREE.Color();
+ let min = Infinity;
+ let max = -Infinity;
+ for (let i = 0; i < pos.count; i++) {
+ const v = axis === 'y' ? pos.getY(i) : pos.getX(i);
+ min = Math.min(min, v);
+ max = Math.max(max, v);
+ }
+ for (let i = 0; i < pos.count; i++) {
+ const v = axis === 'y' ? pos.getY(i) : pos.getX(i);
+ const t = (v - min) / (max - min || 1);
+ c.copy(color);
+ if (color2) c.lerp(color2, t);
+ col[i * 3] = c.r;
+ col[i * 3 + 1] = c.g;
+ col[i * 3 + 2] = c.b;
+ }
+ geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
+ return geo;
+}
+
+/** A palm tree: bent trunk + radial drooping fronds + coconuts. */
+function makePalmGeometry(rand, height) {
+ const parts = [];
+ const bend = 1.2 + rand() * 1.6;
+ const bendDir = rand() * Math.PI * 2;
+ const bx = Math.cos(bendDir);
+ const bz = Math.sin(bendDir);
+
+ // trunk: cylinder bent along a parabola
+ const trunk = new THREE.CylinderGeometry(0.14, 0.26, height, 5, 6, true);
+ trunk.translate(0, height / 2, 0);
+ {
+ const p = trunk.getAttribute('position');
+ for (let i = 0; i < p.count; i++) {
+ const t = p.getY(i) / height;
+ p.setX(i, p.getX(i) + bx * bend * t * t);
+ p.setZ(i, p.getZ(i) + bz * bend * t * t);
+ }
+ }
+ parts.push(colorize(trunk, TRUNK_D, TRUNK));
+
+ const topX = bx * bend;
+ const topZ = bz * bend;
+
+ // fronds: drooping strips arranged radially around the crown
+ const nFronds = 7 + Math.floor(rand() * 3);
+ for (let f = 0; f < nFronds; f++) {
+ const frond = new THREE.PlaneGeometry(0.55, 3.0 + rand() * 1.2, 1, 4);
+ frond.rotateX(-Math.PI / 2); // lie flat, length along -z .. +z
+ const p = frond.getAttribute('position');
+ const len = 3.0;
+ for (let i = 0; i < p.count; i++) {
+ const t = Math.max(0, p.getZ(i) + len / 2) / len; // 0 at base, 1 at tip
+ p.setY(i, p.getY(i) - t * t * 2.0); // droop
+ p.setX(i, p.getX(i) * (1 - t * 0.75)); // taper
+ }
+ colorize(frond, FROND_A, FROND_B, 'y');
+ const a = (f / nFronds) * Math.PI * 2 + rand() * 0.5;
+ frond.translate(0, 0, 0.5);
+ frond.rotateZ((rand() - 0.5) * 0.25);
+ frond.rotateY(a);
+ frond.translate(topX, height + 0.25, topZ);
+ parts.push(frond);
+ }
+
+ // coconuts
+ for (let k = 0; k < 3; k++) {
+ const nut = new THREE.SphereGeometry(0.16, 5, 4);
+ const a = rand() * Math.PI * 2;
+ nut.translate(topX + Math.cos(a) * 0.3, height - 0.1, topZ + Math.sin(a) * 0.3);
+ parts.push(colorize(nut, COCONUT));
+ }
+
+ return mergeGeometries(parts);
+}
+
+/** Low leafy shrub: a couple of squashed blobs (reads well from any angle). */
+function makeShrubGeometry(rand, scale) {
+ const parts = [];
+ const blobs = 2 + Math.floor(rand() * 2);
+ for (let b = 0; b < blobs; b++) {
+ const r = (0.55 + rand() * 0.5) * scale;
+ // detail 0 (20 tris) is plenty for knee-high shrubs
+ const blob = new THREE.IcosahedronGeometry(r, 0);
+ const p = blob.getAttribute('position');
+ for (let i = 0; i < p.count; i++) {
+ const s = 0.8 + rand() * 0.4;
+ p.setXYZ(i, p.getX(i) * s * 1.1, p.getY(i) * s * 0.55, p.getZ(i) * s * 1.1);
+ }
+ blob.computeVertexNormals();
+ const a = rand() * Math.PI * 2;
+ const d = b === 0 ? 0 : (0.4 + rand() * 0.5) * scale;
+ blob.translate(Math.cos(a) * d, r * 0.4, Math.sin(a) * d);
+ parts.push(colorize(blob, CANOPY_A, FROND_B));
+ }
+ return mergeGeometries(parts);
+}
+
+/** Undergrowth: a star of leafy planes. */
+function makeBushGeometry(rand, scale, dark) {
+ const parts = [];
+ const blades = 6;
+ for (let i = 0; i < blades; i++) {
+ const w = (0.9 + rand() * 0.5) * scale;
+ const h = (1.0 + rand() * 0.6) * scale;
+ const leaf = new THREE.PlaneGeometry(w, h, 1, 2);
+ const p = leaf.getAttribute('position');
+ for (let v = 0; v < p.count; v++) {
+ const t = (p.getY(v) / h + 0.5);
+ p.setZ(v, t * t * 0.45 * scale); // curl outward
+ p.setX(v, p.getX(v) * (1 - t * 0.55));
+ }
+ colorize(leaf, dark ? LEAF_DARK : FROND_A, FROND_B, 'y');
+ leaf.translate(0, h * 0.42, 0.12 * scale);
+ leaf.rotateX(-0.5 - rand() * 0.35);
+ leaf.rotateY((i / blades) * Math.PI * 2 + rand());
+ parts.push(leaf);
+ }
+ return mergeGeometries(parts);
+}
+
+/** Broadleaf jungle canopy tree: trunk + clustered leaf blobs. */
+function makeCanopyTreeGeometry(rand, height) {
+ const parts = [];
+ const lean = (rand() - 0.5) * 0.8;
+ const leanDir = rand() * Math.PI * 2;
+ const lx = Math.cos(leanDir) * lean;
+ const lz = Math.sin(leanDir) * lean;
+
+ const trunk = new THREE.CylinderGeometry(0.22, 0.38, height, 6, 3);
+ trunk.translate(0, height / 2, 0);
+ {
+ const p = trunk.getAttribute('position');
+ for (let i = 0; i < p.count; i++) {
+ const t = p.getY(i) / height;
+ p.setX(i, p.getX(i) + lx * t * t * 2.2);
+ p.setZ(i, p.getZ(i) + lz * t * t * 2.2);
+ }
+ }
+ // IcosahedronGeometry blobs are non-indexed; merge requires consistency
+ parts.push(colorize(trunk.toNonIndexed(), TRUNK_D, TRUNK));
+
+ // canopy: wide, flattened, overlapping leaf masses (not "broccoli balls")
+ const blobs = 3 + Math.floor(rand() * 2);
+ for (let b = 0; b < blobs; b++) {
+ const r = 2.1 + rand() * 1.6;
+ const blob = new THREE.IcosahedronGeometry(r, 1);
+ const p = blob.getAttribute('position');
+ for (let i = 0; i < p.count; i++) {
+ const s = 0.78 + rand() * 0.44;
+ p.setXYZ(i, p.getX(i) * s * 1.15, p.getY(i) * s * 0.45, p.getZ(i) * s * 1.15);
+ }
+ blob.computeVertexNormals();
+ const a = rand() * Math.PI * 2;
+ const d = b === 0 ? 0 : 1.1 + rand() * 2.3;
+ blob.translate(
+ lx * 2.2 + Math.cos(a) * d,
+ height - 0.5 + (rand() - 0.35) * 1.6,
+ lz * 2.2 + Math.sin(a) * d
+ );
+ parts.push(colorize(blob, CANOPY_A, CANOPY_B));
+ }
+ return mergeGeometries(parts);
+}
+
+function makeRockGeometry(rand) {
+ const rock = new THREE.IcosahedronGeometry(1, 1);
+ const p = rock.getAttribute('position');
+ for (let i = 0; i < p.count; i++) {
+ const s = 0.75 + rand() * 0.5;
+ p.setXYZ(i, p.getX(i) * s, p.getY(i) * s * 0.7, p.getZ(i) * s);
+ }
+ rock.computeVertexNormals();
+ return colorize(rock, ROCK_C, new THREE.Color(0x9d968a));
+}
+
+export function buildVegetation(scene, timeUniform) {
+ const rand = mulberry32(4242);
+ const grad = { x: 0, z: 0 };
+
+ // --- gather placement points per kind
+ const palmPts = [];
+ const canopyPts = [];
+ const bushPts = [];
+ const rockPts = [];
+ for (const isl of ISLANDS) {
+ const nTry = Math.round((isl.r * isl.r) / 26);
+ for (let i = 0; i < nTry; i++) {
+ const a = rand() * Math.PI * 2;
+ const r = Math.sqrt(rand()) * isl.r * 1.02;
+ const x = isl.cx + Math.cos(a) * r;
+ const z = isl.cz + Math.sin(a) * r;
+ const y = terrainHeightAt(x, z);
+ terrainGradientAt(x, z, grad);
+ const slope = Math.hypot(grad.x, grad.z);
+ const roll = rand();
+ // same mask that darkens the ground; small islets get a floor so they
+ // never end up completely bald
+ const dens = Math.max(jungleDensityAt(x, z), isl.r < 140 ? 0.55 : 0);
+ // canopy masses follow the density clusters; palms own the coast band;
+ // undergrowth fills edges; rocks collect in clearings and beaches
+ if (y > 3.0 && y < isl.height * 0.9 && slope < 0.66 && dens > 0.45 && roll < 0.55 * dens) {
+ canopyPts.push({ x, y, z });
+ } else if (y > 1.9 && y < 9 && slope < 0.55 && roll < 0.45) {
+ palmPts.push({ x, y, z });
+ } else if (y > 1.6 && slope < 0.75 && roll < 0.35 + dens * 0.3) {
+ bushPts.push({ x, y, z });
+ } else if (y > 0.35 && y < 6 && dens < 0.35 && roll < 0.55) {
+ rockPts.push({ x, y, z });
+ }
+ }
+ }
+
+ const sets = [];
+
+ // --- palms: 3 variants, sway in the wind via a shader patch
+ const palmVariants = [
+ makePalmGeometry(mulberry32(11), 7.5),
+ makePalmGeometry(mulberry32(22), 9.5),
+ makePalmGeometry(mulberry32(33), 11.0),
+ ];
+ const palmMat = new THREE.MeshStandardMaterial({
+ vertexColors: true,
+ flatShading: true,
+ roughness: 1,
+ side: THREE.DoubleSide,
+ alphaTest: 0,
+ });
+ palmMat.onBeforeCompile = (shader) => {
+ shader.uniforms.uTime = timeUniform;
+ shader.vertexShader =
+ 'uniform float uTime;\n' +
+ shader.vertexShader.replace(
+ '#include ',
+ `#include
+ {
+ vec3 instPos = vec3(instanceMatrix[3][0], instanceMatrix[3][1], instanceMatrix[3][2]);
+ float phase = instPos.x * 0.31 + instPos.z * 0.27;
+ float swayAmt = pow(max(transformed.y, 0.0) / 10.0, 2.0);
+ transformed.x += sin(uTime * 1.15 + phase) * swayAmt * 0.55;
+ transformed.z += cos(uTime * 0.93 + phase * 1.3) * swayAmt * 0.45;
+ }`
+ );
+ };
+ palmVariants.forEach((geo, vi) => {
+ const pts = palmPts.filter((_, i) => i % palmVariants.length === vi);
+ sets.push(makeInstances(geo, palmMat, pts, rand, { minS: 0.8, maxS: 1.35, tilt: 0.1, sink: 0.25 }));
+ });
+
+ // --- jungle canopy trees (2 variants)
+ const canopyMat = new THREE.MeshStandardMaterial({
+ vertexColors: true,
+ flatShading: true,
+ roughness: 1,
+ });
+ const canopyVariants = [makeCanopyTreeGeometry(mulberry32(77), 6.5), makeCanopyTreeGeometry(mulberry32(88), 8.5)];
+ canopyVariants.forEach((geo, vi) => {
+ const pts = canopyPts.filter((_, i) => i % canopyVariants.length === vi);
+ sets.push(makeInstances(geo, canopyMat, pts, rand, { minS: 0.75, maxS: 1.5, tilt: 0.08, sink: 0.3 }));
+ });
+
+ // --- undergrowth: leafy shrubs (most) + fern stars (accents)
+ const shrubGeo = makeShrubGeometry(mulberry32(66), 1.6);
+ const fernGeo = makeBushGeometry(mulberry32(44), 1.2, false);
+ const bushMat = new THREE.MeshStandardMaterial({
+ vertexColors: true,
+ flatShading: true,
+ roughness: 1,
+ side: THREE.DoubleSide,
+ });
+ const shrubPts = bushPts.filter((_, i) => i % 3 !== 2);
+ const fernPts = bushPts.filter((_, i) => i % 3 === 2);
+ sets.push(makeInstances(shrubGeo, bushMat, shrubPts, rand, { minS: 0.7, maxS: 1.6, tilt: 0.1, sink: 0.25 }));
+ sets.push(makeInstances(fernGeo, bushMat, fernPts, rand, { minS: 0.7, maxS: 1.3, tilt: 0.12, sink: 0.1 }));
+
+ // --- rocks
+ const rockMat = new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 1 });
+ sets.push(makeInstances(makeRockGeometry(mulberry32(66)), rockMat, rockPts, rand, { minS: 0.5, maxS: 2.4, tilt: 0.4, sink: 0.4 }));
+
+ let count = 0;
+ for (const s of sets) {
+ if (!s) continue;
+ scene.add(s);
+ count += s.count;
+ }
+ return { count };
+}
+
+function makeInstances(geo, mat, pts, rand, opt) {
+ if (pts.length === 0) return null;
+ const mesh = new THREE.InstancedMesh(geo, mat, pts.length);
+ const m = new THREE.Matrix4();
+ const q = new THREE.Quaternion();
+ const e = new THREE.Euler();
+ const s = new THREE.Vector3();
+ const p = new THREE.Vector3();
+ const c = new THREE.Color();
+ pts.forEach((pt, i) => {
+ const scale = opt.minS + rand() * (opt.maxS - opt.minS);
+ e.set((rand() - 0.5) * opt.tilt * 2, rand() * Math.PI * 2, (rand() - 0.5) * opt.tilt * 2);
+ q.setFromEuler(e);
+ s.setScalar(scale);
+ p.set(pt.x, pt.y - opt.sink, pt.z);
+ m.compose(p, q, s);
+ mesh.setMatrixAt(i, m);
+ // subtle per-instance tint so foliage doesn't look copy-pasted
+ const b = 0.82 + rand() * 0.3;
+ c.setRGB(b * (0.94 + rand() * 0.12), b, b * (0.92 + rand() * 0.1));
+ mesh.setColorAt(i, c);
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ return mesh;
+}
diff --git a/pirate-ship/src/waves.js b/pirate-ship/src/waves.js
new file mode 100644
index 0000000..787e04f
--- /dev/null
+++ b/pirate-ship/src/waves.js
@@ -0,0 +1,204 @@
+// ---------------------------------------------------------------------------
+// waves.js — single source of truth for the ocean wave model.
+//
+// A sum of Gerstner (trochoidal) waves. The SAME wave list drives:
+// * the GPU ocean shader (vertex displacement + analytic normals), and
+// * the CPU physics sampler used for ship buoyancy.
+// so the ship floats on exactly the surface you see.
+//
+// Pure JS module (no three.js import) so it can be unit-tested in Node.
+// ---------------------------------------------------------------------------
+
+const G = 9.81;
+const TAU = Math.PI * 2;
+
+export const WIND = {
+ // Unit direction the wind blows TOWARD (XZ plane), plus speed in m/s.
+ dirX: Math.SQRT1_2,
+ dirZ: Math.SQRT1_2,
+ speed: 9,
+};
+
+function makeWave(angleDeg, wavelength, amplitude, steepness, speedMult = 1) {
+ const a = (angleDeg * Math.PI) / 180;
+ const k = TAU / wavelength;
+ return {
+ dirX: Math.cos(a),
+ dirZ: Math.sin(a),
+ k,
+ // Deep-water dispersion relation: omega = sqrt(g * k)
+ omega: Math.sqrt(G * k) * speedMult,
+ amp: amplitude,
+ steep: steepness, // per-wave Q in [0,1]; sum of Q*k*A must stay < 1
+ phase: (angleDeg * 7.3) % TAU, // deterministic de-correlated phase
+ };
+}
+
+// Wind blows toward +X+Z (45deg). Wave directions are spread around it.
+// GEO waves displace geometry AND drive physics. DETAIL waves are tiny
+// ripples evaluated per-pixel in the fragment shader only (sparkle), so the
+// physics surface still matches the rendered geometry.
+export const GEO_WAVES = [
+ makeWave(43, 95, 1.05, 0.62), // primary swell
+ makeWave(61, 64, 0.62, 0.65), // secondary swell
+ makeWave(27, 38, 0.34, 0.7),
+ makeWave(74, 23, 0.21, 0.72),
+ makeWave(8, 15, 0.13, 0.78),
+ makeWave(66, 9.5, 0.062, 0.8),
+ makeWave(30, 6.3, 0.03, 0.85),
+];
+
+export const DETAIL_WAVES = [
+ makeWave(49, 3.7, 0.022, 0.9),
+ makeWave(18, 2.5, 0.014, 0.9),
+ makeWave(78, 1.7, 0.009, 0.9),
+];
+
+// Safety check (dev aid): total steepness must stay below 1 or crests loop.
+export function totalSteepness(waves = GEO_WAVES) {
+ return waves.reduce((s, w) => s + w.steep * w.k * w.amp, 0);
+}
+
+// --- CPU evaluation ---------------------------------------------------------
+
+/**
+ * Evaluate the Gerstner sum at undisplaced position (x0, z0).
+ * Writes displaced position, normal and surface velocity into `out`.
+ */
+export function evaluateAt(x0, z0, t, out) {
+ let dx = 0;
+ let dy = 0;
+ let dz = 0;
+ let vx = 0;
+ let vy = 0;
+ let vz = 0;
+ let nx = 0;
+ let nz = 0;
+ let ny = 1;
+ for (let i = 0; i < GEO_WAVES.length; i++) {
+ const w = GEO_WAVES[i];
+ const f = w.k * (w.dirX * x0 + w.dirZ * z0) - w.omega * t + w.phase;
+ const c = Math.cos(f);
+ const s = Math.sin(f);
+ const qa = w.steep * w.amp;
+ dx += qa * w.dirX * c;
+ dz += qa * w.dirZ * c;
+ dy += w.amp * s;
+ // d/dt of the displacement (surface velocity at this material point)
+ vx += qa * w.dirX * s * w.omega;
+ vz += qa * w.dirZ * s * w.omega;
+ vy += -w.amp * c * w.omega;
+ // Analytic normal accumulation
+ const wka = w.k * w.amp;
+ nx -= w.dirX * wka * c;
+ nz -= w.dirZ * wka * c;
+ ny -= w.steep * wka * s;
+ }
+ out.x = x0 + dx;
+ out.y = dy;
+ out.z = z0 + dz;
+ out.vx = vx;
+ out.vy = vy;
+ out.vz = vz;
+ const invLen = 1 / Math.hypot(nx, ny, nz);
+ out.nx = nx * invLen;
+ out.ny = ny * invLen;
+ out.nz = nz * invLen;
+ return out;
+}
+
+const _scratch = { x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, nx: 0, ny: 1, nz: 0 };
+
+/**
+ * True water sample at a given WORLD (x, z): because Gerstner waves displace
+ * horizontally, we invert the horizontal displacement with a fixed-point
+ * iteration (converges fast while total steepness < 1).
+ * Writes { height, nx, ny, nz, vx, vy, vz } into `out` and returns it.
+ */
+export function sampleAt(x, z, t, out) {
+ let px = x;
+ let pz = z;
+ for (let iter = 0; iter < 3; iter++) {
+ evaluateAt(px, pz, t, _scratch);
+ px += x - _scratch.x;
+ pz += z - _scratch.z;
+ }
+ evaluateAt(px, pz, t, _scratch);
+ out.height = _scratch.y;
+ out.nx = _scratch.nx;
+ out.ny = _scratch.ny;
+ out.nz = _scratch.nz;
+ out.vx = _scratch.vx;
+ out.vy = _scratch.vy;
+ out.vz = _scratch.vz;
+ return out;
+}
+
+const _h = { height: 0, nx: 0, ny: 1, nz: 0, vx: 0, vy: 0, vz: 0 };
+
+/** Convenience: just the height at world (x, z). */
+export function heightAt(x, z, t) {
+ return sampleAt(x, z, t, _h).height;
+}
+
+// --- GPU data ---------------------------------------------------------------
+
+/**
+ * Pack a wave list into flat arrays for shader uniforms.
+ * waveA[i] = (dirX, dirZ, k, omega); waveB[i] = (amp, steep, phase, 0)
+ */
+export function packWaves(waves) {
+ const a = new Float32Array(waves.length * 4);
+ const b = new Float32Array(waves.length * 4);
+ waves.forEach((w, i) => {
+ a[i * 4 + 0] = w.dirX;
+ a[i * 4 + 1] = w.dirZ;
+ a[i * 4 + 2] = w.k;
+ a[i * 4 + 3] = w.omega;
+ b[i * 4 + 0] = w.amp;
+ b[i * 4 + 1] = w.steep;
+ b[i * 4 + 2] = w.phase;
+ b[i * 4 + 3] = 0;
+ });
+ return { a, b, count: waves.length };
+}
+
+/**
+ * GLSL snippet implementing the same Gerstner sum (geometry waves).
+ * Each wave fades out with camera distance proportionally to its wavelength,
+ * so short chop never aliases on the coarse distant mesh; the physics
+ * (sampled near the ship, well inside every fade range) stays exact.
+ */
+export const GERSTNER_GLSL = /* glsl */ `
+struct WaveOut { vec3 disp; vec3 normal; float crest; };
+
+WaveOut gerstner(vec2 p0, float t, vec4 waveA[NUM_GEO_WAVES], vec4 waveB[NUM_GEO_WAVES], float dist) {
+ vec3 disp = vec3(0.0);
+ vec3 n = vec3(0.0, 1.0, 0.0);
+ float crest = 0.0;
+ for (int i = 0; i < NUM_GEO_WAVES; i++) {
+ vec2 D = waveA[i].xy;
+ float k = waveA[i].z;
+ float om = waveA[i].w;
+ float L = 6.2831853 / k;
+ float fade = 1.0 - smoothstep(L * 30.0, L * 95.0, dist);
+ float amp = waveB[i].x * fade;
+ float q = waveB[i].y;
+ float f = k * dot(D, p0) - om * t + waveB[i].z;
+ float c = cos(f);
+ float s = sin(f);
+ float qa = q * amp;
+ disp.xz += qa * D * c;
+ disp.y += amp * s;
+ float wka = k * amp;
+ n.xz -= D * wka * c;
+ n.y -= q * wka * s;
+ crest += q * wka * s;
+ }
+ WaveOut o;
+ o.disp = disp;
+ o.normal = normalize(n);
+ o.crest = crest;
+ return o;
+}
+`;
diff --git a/pirate-ship/test/physics.test.mjs b/pirate-ship/test/physics.test.mjs
new file mode 100644
index 0000000..ba654ca
--- /dev/null
+++ b/pirate-ship/test/physics.test.mjs
@@ -0,0 +1,176 @@
+// Node test for the pure-math core: wave model + ship physics.
+// Run: node pirate-ship/test/physics.test.mjs
+import {
+ GEO_WAVES,
+ totalSteepness,
+ evaluateAt,
+ sampleAt,
+ heightAt,
+} from '../src/waves.js';
+import { ShipPhysics } from '../src/physics.js';
+import { terrainHeightAt } from '../src/islandField.js';
+import { SPAWN } from '../src/islandField.js';
+
+let failures = 0;
+function check(name, cond, detail = '') {
+ if (cond) {
+ console.log(` PASS ${name}`);
+ } else {
+ failures++;
+ console.error(` FAIL ${name} ${detail}`);
+ }
+}
+
+console.log('\n[1] Wave model sanity');
+const steep = totalSteepness(GEO_WAVES);
+check(`total steepness ${steep.toFixed(3)} < 0.9`, steep < 0.9);
+
+console.log('\n[2] Gerstner horizontal-displacement inversion accuracy');
+{
+ let maxErr = 0;
+ let maxHeightDiff = 0;
+ const out = { x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, nx: 0, ny: 1, nz: 0 };
+ const s = { height: 0, nx: 0, ny: 1, nz: 0, vx: 0, vy: 0, vz: 0 };
+ for (let i = 0; i < 500; i++) {
+ const x = (Math.random() - 0.5) * 1600;
+ const z = (Math.random() - 0.5) * 1600;
+ const t = Math.random() * 120;
+ // ground truth: invert with 12 iterations
+ let px = x;
+ let pz = z;
+ for (let k = 0; k < 12; k++) {
+ evaluateAt(px, pz, t, out);
+ px += x - out.x;
+ pz += z - out.z;
+ }
+ evaluateAt(px, pz, t, out);
+ const truth = out.y;
+ sampleAt(x, z, t, s);
+ maxHeightDiff = Math.max(maxHeightDiff, Math.abs(s.height - truth));
+ // also confirm the 12-iter inversion itself converged
+ maxErr = Math.max(maxErr, Math.hypot(out.x - x, out.z - z));
+ }
+ check(`12-iter inversion converges (residual ${maxErr.toExponential(2)} m < 1e-4)`, maxErr < 1e-4);
+ check(
+ `3-iter sampler matches truth (max diff ${(maxHeightDiff * 100).toFixed(3)} cm < 1 cm)`,
+ maxHeightDiff < 0.01
+ );
+}
+
+console.log('\n[3] Flotation: anchored ship settles into bounded wave-riding');
+{
+ const ship = new ShipPhysics();
+ ship.pos.y = 2.0; // drop from above
+ const dt = 1 / 60;
+ let t = 0;
+ let minY = Infinity;
+ let maxY = -Infinity;
+ let maxTrackErr = 0;
+ for (let i = 0; i < 60 * 60; i++) {
+ ship.step(dt, t);
+ t += dt;
+ if (i > 60 * 20) {
+ // after settling, track ship vs local water height
+ minY = Math.min(minY, ship.pos.y);
+ maxY = Math.max(maxY, ship.pos.y);
+ const w = heightAt(ship.pos.x, ship.pos.z, t);
+ maxTrackErr = Math.max(maxTrackErr, Math.abs(ship.pos.y - w));
+ }
+ }
+ const ok = Number.isFinite(ship.pos.y);
+ check('no NaN/Infinity after 60 s', ok);
+ check(`bounded heave (y in [${minY.toFixed(2)}, ${maxY.toFixed(2)}], span < 4 m)`, maxY - minY < 4);
+ check(
+ `rides the waves (max |shipY - waterY| = ${maxTrackErr.toFixed(2)} m < 2.5 m)`,
+ maxTrackErr < 2.5
+ );
+ check(`stays upright (up.y = ${ship._up.y.toFixed(3)} > 0.95)`, ship._up.y > 0.95);
+}
+
+console.log('\n[4] Drive: full sail accelerates to cruise');
+{
+ const ship = new ShipPhysics();
+ ship.setSail(3);
+ const dt = 1 / 60;
+ let t = 0;
+ const x0 = ship.pos.x;
+ const z0 = ship.pos.z;
+ let speedAt10 = 0;
+ for (let i = 0; i < 60 * 20; i++) {
+ ship.step(dt, t);
+ t += dt;
+ if (i === 60 * 10) speedAt10 = ship.speed;
+ }
+ const dist = Math.hypot(ship.pos.x - x0, ship.pos.z - z0);
+ check(`moved ${dist.toFixed(0)} m in 20 s (> 100 m)`, dist > 100);
+ check(`speed after 10 s = ${speedAt10.toFixed(1)} m/s (in 5..16)`, speedAt10 > 5 && speedAt10 < 16);
+ check(`did not capsize at speed (up.y = ${ship._up.y.toFixed(3)})`, ship._up.y > 0.9);
+}
+
+console.log('\n[5] Steering: rudder turns the ship the right way');
+{
+ const mk = (input) => {
+ const ship = new ShipPhysics();
+ ship.setSail(3);
+ const dt = 1 / 60;
+ let t = 0;
+ for (let i = 0; i < 60 * 8; i++) {
+ ship.step(dt, t); // get up to speed
+ t += dt;
+ }
+ const h0 = ship.heading;
+ ship.rudderInput = input;
+ for (let i = 0; i < 60 * 8; i++) {
+ ship.step(dt, t);
+ t += dt;
+ }
+ // unwrap heading delta to [-pi, pi]
+ let dh = ship.heading - h0;
+ while (dh > Math.PI) dh -= 2 * Math.PI;
+ while (dh < -Math.PI) dh += 2 * Math.PI;
+ return (dh * 180) / Math.PI;
+ };
+ const dRight = mk(+1);
+ const dLeft = mk(-1);
+ check(`D (input +1) turns starboard: ${dRight.toFixed(0)} deg > +25`, dRight > 25);
+ check(`A (input -1) turns port: ${dLeft.toFixed(0)} deg < -25`, dLeft < -25);
+}
+
+console.log('\n[6] Grounding: sailing into an island stops the ship (no tunnel)');
+{
+ const ship = new ShipPhysics();
+ // aim straight at the islet at (470, -290) from open water
+ ship.pos.x = 470;
+ ship.pos.z = -80;
+ const heading = Math.PI; // -z direction... heading = atan2(fwd.x, fwd.z); pi -> fwd=(0,0,-1)
+ ship.quat.x = 0;
+ ship.quat.y = Math.sin(heading / 2);
+ ship.quat.z = 0;
+ ship.quat.w = Math.cos(heading / 2);
+ ship.setSail(3);
+ const dt = 1 / 60;
+ let t = 0;
+ let grounded = false;
+ for (let i = 0; i < 60 * 60; i++) {
+ ship.step(dt, t);
+ t += dt;
+ if (ship.aground) grounded = true;
+ }
+ const terrain = terrainHeightAt(ship.pos.x, ship.pos.z);
+ check('ship ran aground at some point', grounded);
+ check(
+ `ship did not tunnel inland (terrain under ship = ${terrain.toFixed(1)} m < 3 m)`,
+ terrain < 3
+ );
+ check(`no NaN after grounding (${ship.pos.x.toFixed(0)}, ${ship.pos.z.toFixed(0)})`, Number.isFinite(ship.pos.x));
+ // reset works
+ ship.reset();
+ check('reset returns to spawn', Math.abs(ship.pos.x - SPAWN.x) < 1e-6 && ship.speed === 0);
+}
+
+console.log('');
+if (failures > 0) {
+ console.error(`${failures} test(s) FAILED`);
+ process.exit(1);
+}
+console.log('All physics tests passed.');