diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index f5bbefc..ddeece5 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -12,7 +12,6 @@ const SEA_ROWS = 2; const TALUS = .3; // steepest step between neighbouring cells before loose ground slumps const CLIMB = .34; // steepest step a walker can climb const EVAPORATE = .0006; // metres of standing water lost per second in an isolated pool -const DRAIN = .25; // how fast a pool falls to its outlet once the sea withdraws export const SPECIES = { scraper: { code: 'SC', name: 'Scraper', kind: 'walker', speed: .4, max: 22, start: 3, min: 4, burn: .0055, radius: .28 }, @@ -82,7 +81,7 @@ export const footAt = (world, c) => c.alt ?? heightAt(world, c.x, c.z); export const onShelf = (world, x, z) => inBounds(x, z, 1) && heightAt(world, x, z) > -1.3; export const insidePool = (world, x, z) => onShelf(world, x, z); -// The shelf: gently uneven rock around low water, falling to the sea along the front, with one pool to begin. +// The shore: rock rising gently out of the sea toward the land, with one pool to begin. function terrainNoise(seed) { const hash = (i, j) => { let h = Math.imul(i, 374761393) + Math.imul(j, 668265263) + Math.imul(seed, 2246822519) | 0; @@ -97,40 +96,101 @@ function terrainNoise(seed) { return (x, z) => noise(x, z) * .5 + noise(x * 2.1 + 5.2, z * 2.1 - 1.3) * .3 + noise(x * 4.3 - 2, z * 4.3 + 7) * .2; } export const START_POOL = { x: -4, z: -3, r: 5.5 }; -// Every world grows its own shelf from its seed: its own grain, ledges, crevices, and boulders. The first pool is always -// in the same place, since that is where the story begins. The default world, 41, keeps the shelf it has always had. +// How high the shore stands at each distance from the sea, before its rocks: above the highest tides at the back, through +// the ground the tide covers and uncovers, to below the lowest ones at the front, and then off into deep water. +const SHORE = [[Z0, 1.1], [-14, .55], [-6, .12], [0, -.18], [6, -.52], [10, -.8], [13, -1.5], [16.5, -2.2]]; +function shoreAt(z) { + if (z <= SHORE[0][0]) return SHORE[0][1]; + for (let n = 1; n < SHORE.length; n++) { + const [z1, h1] = SHORE[n], [z0, h0] = SHORE[n - 1]; + if (z <= z1) return lerp(h0, h1, smooth(z0, z1, z) * .5 + (z - z0) / (z1 - z0) * .5); + } + return SHORE[SHORE.length - 1][1]; +} +// Every world grows its own shore from its seed: the grain of its rock, its ribs and cracks, its gullies and boulders. The +// first pool is always in the same place, since that is where the story begins. function shape(world, worldSeed = 41) { - const turn = worldSeed - 41; - const fbm = terrainNoise(902 + turn * 7919); - let seed = (1234 + turn * 104729) >>> 0; + const fbm = terrainNoise(902 + worldSeed * 7919); + let seed = (1234 + worldSeed * 104729) >>> 0; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296); - const grain = turn ? (rnd() - .5) * 1.4 : 0, count = turn ? 50 + Math.floor(rnd() * 40) : 70; - // Rounded boulders scattered over the shelf, some standing clear of high water. - const boulders = Array.from({ length: count }, () => ({ - x: X0 + 2 + rnd() * (X1 - X0 - 4), z: Z0 + 2 + rnd() * (11 - Z0), r: .5 + rnd() * rnd() * 1.6, h: .2 + rnd() * .5, - })).filter(b => Math.hypot(b.x - START_POOL.x, b.z - START_POOL.z) > START_POOL.r + 1.5); - const bedHash = n => { const v = Math.sin((n + turn * 13.7) * 91.7 + 3.1) * 43758.5453; return v - Math.floor(v); }; - const dip = { x: Math.cos(.45 + grain), z: Math.sin(.45 + grain) }; + const bedHash = n => { const v = Math.sin((n + worldSeed * 13.7) * 91.7 + 3.1) * 43758.5453; return v - Math.floor(v); }; + // The grain: the beds of the rock strike across the shore at a slant, so the gullies between them run in from the sea. + const strike = (rnd() < .5 ? 1 : -1) * (.4 + rnd() * .5); + const along = { x: Math.cos(strike), z: Math.sin(strike) }, across = { x: -Math.sin(strike), z: Math.cos(strike) }; + const spacing = 2.4 + rnd() * 1.4, joints = 4 + rnd() * 3; + // Surge channels cut in from the sea and wind up the shore; one of them comes up to the first pool. + const channels = Array.from({ length: 2 + Math.floor(rnd() * 2) }, () => ({ + x: X0 + 6 + rnd() * (X1 - X0 - 12), head: -1 - rnd() * 7, wind: rnd() * 6, width: .8 + rnd() * .7, depth: .28 + rnd() * .2, + })).filter(c => Math.abs(c.x - START_POOL.x) > START_POOL.r + 4); + const pool = { x: START_POOL.x + (rnd() - .5) * 2, head: START_POOL.z + START_POOL.r * .7, wind: rnd() * 6, width: .9, depth: .3 }; + channels.push(pool); + const channelX = (c, z) => c.x + (Math.sin(z * .21 + c.wind) * 1.4 + Math.sin(z * .09 + c.wind * 2.3) * 1.8) * smooth(c.head, c.head + 5, z); for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { const x = cellX(i), z = cellZ(j), k = j * COLS + i; - // Tilted bedding: the rock breaks into ledges along one grain, each rising gently and stepping down to the next. - const u = (x * dip.x + z * dip.z) * .55 + (fbm(x * .13 + 3, z * .13) - .5) * 2.4; - const bed = Math.floor(u), frac = u - bed; - const ledge = frac * .2 + bedHash(bed) * .08; - // Some bed joints have weathered into crevices. - const crevice = bedHash(bed + 17) > .55 ? -.28 * Math.exp(-(((frac - .04) / .05) ** 2)) : 0; - let h = -.14 + (fbm(x * .07, z * .07) - .5) * .4 + ledge + crevice + (fbm(x * .8, z * .8) - .5) * .08; - for (const b of boulders) { - const d = Math.hypot(x - b.x, z - b.z); - if (d < b.r) h = Math.max(h, -.05 + b.h * Math.sqrt(1 - (d / b.r) ** 2) + (fbm(x * 1.7, z * 1.7) - .5) * .06); + // Ribs along the grain: each bed rises gently to a crest and breaks off in a short scarp into the gully beyond. + const u = (x * across.x + z * across.z) / spacing + (fbm(x * .08 + 7, z * .08 - 3) - .5) * 2.6 + (fbm(x * .21 - 2, z * .21 + 5) - .5) * .7; + // Each rib stands higher in some stretches than others, and here and there wears down to almost nothing. + const bed = Math.floor(u), f = u - bed, height = (.08 + bedHash(bed) * .26) * smooth(.2, .65, fbm(x * .12 + bed * 3.1, z * .12 - bed * 1.7)); + const rib = f < .78 ? height * smooth(0, .78, f) ** 1.2 : height * (1 - smooth(.78, 1, f)); + // Cracks across the ribs, where the rock has jointed and weathered open. + const v = (x * along.x + z * along.z) / joints + (fbm(x * .11 - 4, z * .11 + 9) - .5) * 1.1; + const jf = v - Math.floor(v); + const crack = bedHash(Math.floor(v) + 31) > .3 ? -.13 * Math.exp(-(((jf - .5) / .045) ** 2)) : 0; + let h = shoreAt(z) + (fbm(x * .045, z * .045) - .5) * .4 + rib + crack + (fbm(x * .9, z * .9) - .5) * .07; + // The channels: a trough down the middle of each, shallowing out at its head. + for (const c of channels) { + if (z < c.head - 1) continue; + const d = Math.abs(x - channelX(c, z)); + if (d > c.width * 1.7) continue; + h -= c.depth * smooth(c.head - 1, c.head + 3, z) * (1 - smooth(c.width * .5, c.width * 1.7, d)); } - world.h[k] = lerp(h, -2.2, smooth(12, 21.5, z)); + world.h[k] = h; + } + // Boulders heaped in a few fields, mostly up the shore, and a few strays: each sits on the rock where it lies. + const fields = Array.from({ length: 4 + Math.floor(rnd() * 3) }, (_, n) => ({ x: X0 + 5 + rnd() * (X1 - X0 - 10), z: n < 3 ? Z0 + 3 + rnd() * 12 : -8 + rnd() * 14 })); + const boulders = []; + for (const g of fields) for (let n = 0, m = 5 + Math.floor(rnd() * 8); n < m; n++) { + const a = rnd() * Math.PI * 2, r = Math.sqrt(-2 * Math.log(1 - rnd() * .95)) * 1.8; + boulders.push({ x: g.x + Math.cos(a) * r, z: g.z + Math.sin(a) * r, r: .35 + rnd() * rnd() * 1.2, h: .25 + rnd() * .45 }); + } + for (let n = 0; n < 10; n++) boulders.push({ x: X0 + 3 + rnd() * (X1 - X0 - 6), z: Z0 + 3 + rnd() * (10 - Z0), r: .35 + rnd() * .5, h: .2 + rnd() * .3 }); + for (const b of boulders) { + if (Math.hypot(b.x - START_POOL.x, b.z - START_POOL.z) < START_POOL.r + 1.2 || b.z > 10) continue; + const ground = world.h[cellAt(b.x, b.z)], i0 = Math.floor((b.x - b.r - X0) / CELL), j0 = Math.floor((b.z - b.r - Z0) / CELL); + for (let j = Math.max(0, j0); j <= Math.min(ROWS - 1, j0 + Math.ceil(b.r * 2 / CELL) + 1); j++) + for (let i = Math.max(0, i0); i <= Math.min(COLS - 1, i0 + Math.ceil(b.r * 2 / CELL) + 1); i++) { + const d = Math.hypot(cellX(i) - b.x, cellZ(j) - b.z); + if (d < b.r) { const k = j * COLS + i; world.h[k] = Math.max(world.h[k], ground + b.h * Math.sqrt(1 - (d / b.r) ** 2) + (fbm(cellX(i) * 1.7, cellZ(j) * 1.7) - .5) * .06); } + } } - // The first pool: a deeper bowl where the story begins. + // The first pool: an uneven basin with steep rock sides and a level floor, ringed by a sill of rock that holds its water + // when the tide goes out, notched where its gully comes in. + const lobes = [rnd() * 6, rnd() * 6, rnd() * 6], inPool = new Uint8Array(N); for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { - const x = cellX(i), z = cellZ(j), k = j * COLS + i; - const r = Math.hypot(x - START_POOL.x, (z - START_POOL.z) * 1.25); - if (r < START_POOL.r * 1.3) world.h[k] = Math.min(world.h[k], lerp(-.9, world.h[k] + .02, smooth(0, START_POOL.r * 1.3, r) ** 1.4)); + const x = cellX(i), z = cellZ(j), k = j * COLS + i, dx = x - START_POOL.x, dz = z - START_POOL.z; + const a = Math.atan2(dz, dx), reach = START_POOL.r * (1 + .16 * Math.sin(2 * a + lobes[0]) + .1 * Math.sin(3 * a + lobes[1]) + .06 * Math.sin(5 * a + lobes[2]) + (fbm(x * .3, z * .3) - .5) * .25); + const d = Math.hypot(dx, dz * 1.15) / reach; + if (d > 1.3) continue; + const notch = Math.abs(x - channelX(pool, z)) < 1.1 && dz > 0; + // The sill: rock standing a little above the middle of the tide all round, lower at the notch. + const sill = (notch ? -.12 : -.02 + (fbm(x * .5, z * .5) - .5) * .12) * (1 - smooth(1, 1.3, d)); + if (d > .92) { world.h[k] = Math.max(world.h[k], lerp(sill, world.h[k], smooth(1, 1.3, d))); continue; } + inPool[k] = 1; + const floor = -.78 + (fbm(x * .6, z * .6) - .5) * .08; + world.h[k] = lerp(floor, Math.max(world.h[k], sill), smooth(.55, .92, d) ** 1.5); + } + // Out at the front the rock falls away into deep water. + for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { + const k = j * COLS + i, z = cellZ(j); + world.h[k] = lerp(world.h[k], shoreAt(z), smooth(10, 15, z)); + } + // Every other hollow has filled with sand up to its lip, so when the tide goes out only the first pool holds water, and + // a film too thin to count glistens where the rest were. Whatever else holds water is dug. + spill(world); + for (let k = 0; k < N; k++) { + if (inPool[k] || world.S[k] - world.h[k] <= .02) continue; + world.sand[k] = world.S[k] - .02 - world.h[k]; + world.h[k] += world.sand[k]; } } @@ -182,17 +242,126 @@ function spill(world) { world.spillDirty = false; } -// Water settles: cells the sea can reach rise and fall with it; enclosed hollows drain to their lip and slowly evaporate. -function water(world, dt) { - const { h, S, w, wall } = world; - const tide = waterLevel(world); +// Water flows. A shallow-water model on the grid, of the kind flood models use: the face between each pair of neighbouring +// cells carries a flow that the difference in water level across it drives and the roughness of the rock holds back, and +// each cell's water rises and falls with what flows in and out. The sea comes in across the seaward edge at the tide's +// level with a swell on it, so the tide floods in over the rock and drains away again, pools fill over their lips and are +// left standing, and waves run up the shelf and fall back. +const G = 9.81; // gravity, for the water +const ROUGHNESS = .008; // how hard the rock drags on water running over it (g over a Chezy coefficient of about 35, squared) +const THETA = .8; // how much of its own flow each face keeps rather than sharing its neighbours', which damps ringing +const HMIN = .002; // water thinner than this across a face does not flow +const VMAX = 2.5; // the fastest water runs, in metres a second +const SPONGE = 10; // rows along the seaward edge where the open sea is laid in +export const SEA_DEPTH = 2.2; +// The swell: a few long, low wave trains coming in off the sea at slight angles, at the speed the depth at the edge gives +// them. A storm heaps them up. +export const SWELL = [[.045, 7.4, .1, 0], [.028, 5.2, -.22, 1.9], [.014, 3.6, .38, 4.2]].map(([a, period, angle, phase]) => { + const w = Math.PI * 2 / period, k = w / Math.sqrt(G * SEA_DEPTH); + return { a, w, kx: k * Math.sin(angle), kz: k * Math.cos(angle), phase }; +}); +export const swellHeight = world => 1 + 2.5 * stormAt(world); +export function swellAt(x, z, time, height = 1) { + let e = 0; + for (const s of SWELL) e += s.a * Math.sin(s.kx * x + s.kz * (z - Z1) + s.w * time + s.phase); + return e * height; +} +const FLOOR = new Float32Array(N), QX = new Float32Array(N), QZ = new Float32Array(N), KEEP = new Float32Array(N); +const WETROW = new Uint8Array(ROWS), LIVE = new Uint8Array(ROWS); +function water(world, elapsed) { + // The water moves at most thirty times a second, however fast the frames come, which is plenty for waves this long. + world.waterDue = (world.waterDue || 0) + elapsed; + if (world.waterDue < 1 / 30 - 1e-9) return; + const dt = world.waterDue; + world.waterDue = 0; + const { h, w, wall, gate, qx, qz, S } = world; + // Water rests on the ground, on top of a wall of plates, or against a shut gate. Rows with no water in them or beside + // them have nothing to move, and are passed over. + for (let j = 0; j < ROWS; j++) { + let wet = 0; + for (let k = j * COLS, end = k + COLS; k < end; k++) { + const f = h[k] + (wall[k] > gate[k] ? wall[k] : gate[k]); + FLOOR[k] = f; + if (w[k] < f) w[k] = f; else if (w[k] > f + HMIN) wet = 1; + } + WETROW[j] = wet; + } + for (let j = 0; j < ROWS; j++) LIVE[j] = WETROW[j] | (j > 0 ? WETROW[j - 1] : 0) | (j < ROWS - 1 ? WETROW[j + 1] : 0); + const tide = waterLevel(world), lift = swellHeight(world), speed = Math.sqrt(G * SEA_DEPTH); + // Steps short enough that even a storm swell in the deepest water crosses only half a cell in one. + const steps = Math.ceil(dt / .05 - 1e-9), t = dt / steps, r = t / CELL; + for (let s = 0; s < steps; s++) { + QX.set(qx); QZ.set(qz); + // The flow across each face, east to west and then north to south. + for (let j = 0; j < ROWS; j++) { + const row = j * COLS; + if (!LIVE[j]) { qx.fill(0, row, row + COLS); continue; } + for (let i = 0; i < COLS - 1; i++) { + const k = row + i, n = k + 1, a = w[k], b = w[n]; + const depth = (a > b ? a : b) - (FLOOR[k] > FLOOR[n] ? FLOOR[k] : FLOOR[n]); + if (depth <= HMIN) { qx[k] = 0; continue; } + const q0 = THETA * QX[k] + (1 - THETA) * .5 * ((i > 0 ? QX[k - 1] : 0) + (i < COLS - 2 ? QX[k + 1] : 0)); + const q = (q0 - G * depth * t * (b - a) / CELL) / (1 + t * ROUGHNESS * Math.abs(q0) / (depth * depth)), cap = depth * VMAX; + qx[k] = q > cap ? cap : q < -cap ? -cap : q; + } + } + for (let j = 0; j < ROWS - 1; j++) { + const row = j * COLS; + if (!LIVE[j] && !LIVE[j + 1]) { qz.fill(0, row, row + COLS); continue; } + for (let i = 0; i < COLS; i++) { + const k = row + i, n = k + COLS, a = w[k], b = w[n]; + const depth = (a > b ? a : b) - (FLOOR[k] > FLOOR[n] ? FLOOR[k] : FLOOR[n]); + if (depth <= HMIN) { qz[k] = 0; continue; } + const q0 = THETA * QZ[k] + (1 - THETA) * .5 * ((j > 0 ? QZ[k - COLS] : 0) + (j < ROWS - 2 ? QZ[k + COLS] : 0)); + const q = (q0 - G * depth * t * (b - a) / CELL) / (1 + t * ROUGHNESS * Math.abs(q0) / (depth * depth)), cap = depth * VMAX; + qz[k] = q > cap ? cap : q < -cap ? -cap : q; + } + } + // No cell gives more water than it holds: where the flows out of one would drain it past dry, they are held back. + for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { + if (!LIVE[j]) { KEEP.fill(1, j * COLS, j * COLS + COLS); break; } + const k = j * COLS + i; + let out = 0; + if (qx[k] > 0) out += qx[k]; + if (i > 0 && qx[k - 1] < 0) out -= qx[k - 1]; + if (qz[k] > 0) out += qz[k]; + if (j > 0 && qz[k - COLS] < 0) out -= qz[k - COLS]; + const have = w[k] - FLOOR[k]; + KEEP[k] = out * r > have ? have / (out * r) : 1; + } + // Each cell's water changes by what flows in less what flows out, each flow held back by the cell it leaves. A cell's + // east and south faces are settled here; its west and north ones were settled with the cells before it. + for (let j = 0; j < ROWS; j++) { + if (!LIVE[j]) continue; + for (let i = 0, k = j * COLS; i < COLS; i++, k++) { + const x = qx[k], z = qz[k]; + const east = x > 0 ? x * KEEP[k] : x < 0 ? x * KEEP[k + 1] : 0, south = z > 0 ? z * KEEP[k] : z < 0 ? z * KEEP[k + COLS] : 0; + qx[k] = east; qz[k] = south; + let net = -east - south; + if (i > 0) net += qx[k - 1]; + if (j > 0) net += qz[k - COLS]; + const v = w[k] + net * r; + w[k] = v < FLOOR[k] ? FLOOR[k] : v; + } + } + // The open sea along the seaward edge: over the last rows the water is eased toward the tide with the swell on it, + // moving landward as the swell does, so the waves come in and whatever the shelf sends back runs out and is absorbed. + const time = world.time - dt + (s + 1) * t; + for (let j = ROWS - SPONGE; j < ROWS; j++) { + const f = (j - (ROWS - SPONGE) + 1) / SPONGE, ease = j === ROWS - 1 ? 1 : 1 - Math.exp(-t * 5 * f * f), z = cellZ(j); + for (let i = 0; i < COLS; i++) { + const k = j * COLS + i, swell = swellAt(cellX(i), z, time, lift), level = tide + swell; + if (level <= FLOOR[k]) continue; + w[k] += (level - w[k]) * ease; + if (j < ROWS - 1) qz[k] += (-swell * speed - qz[k]) * ease; + } + } + } + // Water cut off from the sea slowly evaporates, and a film left on the rock dries off. for (let k = 0; k < N; k++) { - let v = w[k]; - if (S[k] <= tide) v += (tide - v) * Math.min(1, dt * 1.5); - else { if (v > S[k]) v -= Math.min(v - S[k], DRAIN * dt); v -= EVAPORATE * dt; } - // Water rests on the ground, or on top of a wall of plates. - const floor = h[k] + wall[k]; - w[k] = v < floor ? floor : v; + if (S[k] <= tide || w[k] <= FLOOR[k]) continue; + const v = w[k] - EVAPORATE * dt; + w[k] = v < FLOOR[k] ? FLOOR[k] : v; } } @@ -288,7 +457,7 @@ function survey(world) { // Match pools to names by overlap with where each named pool last lay, best overlaps first, so a puddle never steals a pool's name. const bodies = []; const taken = new Set(); - const candidates = parts.filter(p => !p.sea && p.cells.length >= 60); + const candidates = parts.filter(p => !p.sea && p.cells.length >= 40); const pairs = []; for (const part of candidates) for (const named of world.names) { let n = 0; @@ -367,7 +536,7 @@ export function createWorld(seed = 41) { }; const world = { time: 0, nextObject: 0, nextCreature: 0, random, - h: new Float32Array(N), w: new Float32Array(N), S: new Float32Array(N), built: new Float32Array(N), + h: new Float32Array(N), w: new Float32Array(N), S: new Float32Array(N), built: new Float32Array(N), qx: new Float32Array(N), qz: new Float32Array(N), sand: new Float32Array(N), film: new Float32Array(N), plankton: new Float32Array(N), damp: new Float32Array(N), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), wall: new Float32Array(N), pin: new Uint8Array(N), machines: [], nextMachine: 0, blocks: new Map(), blockVersion: 0, structures: [], nextSupport: 0, gearTurn: 0, debris: [], wallCells: [], wallVersion: '', creatures: [], objects: [], ripples: [], events: [], census: [], ended: [], @@ -427,7 +596,10 @@ function spawn(world, sp, x, z, extra = {}) { size: sp === 'pylon' ? extra.size ?? 1 : 1, gesture: 0, strike: 0, eaten: 0, open: 1, pause: 0, dry: 0, stuck: 0, heading: world.random() * Math.PI * 2, aim: null, side: 1, born: world.time, alt: null, perch: null, pod: null, fallV: 0, }; - if (sp === 'tab') { const a = world.random() * Math.PI * 2; c.vx = Math.cos(a) * .4; c.vz = Math.sin(a) * .4; } + if (sp === 'tab') { + const a = world.random() * Math.PI * 2; c.vx = Math.cos(a) * .4; c.vz = Math.sin(a) * .4; + c.pool = extra.pool ?? world.names.find(p => p.mask[cellAt(x, z)])?.name ?? null; + } if (sp === 'collector' || sp === 'mason' || sp === 'borer' || sp === 'artificer') c.home = { x, z }; // An artificer's way of building, which its children inherit with small changes: how much it raises frames rather than // spreading them, and how much it favours balconies, dwellings, and solid footings. @@ -596,7 +768,7 @@ function breed(world, c) { } if (!at) return null; const style = c.style && Object.fromEntries(Object.entries(c.style).map(([k, v]) => [k, clamp(v + (world.random() - .5) * .3, .5, 1.6)])); - const child = spawn(world, c.sp, at.x, at.z, { gen: c.gen + 1, parent: c.id, energy: .42, size: 1, style }); + const child = spawn(world, c.sp, at.x, at.z, { gen: c.gen + 1, parent: c.id, energy: .42, size: 1, style, pool: c.pool }); if (!child) return null; c.energy -= .45; c.kids++; c.bred = world.time; child.angle = c.angle; child.heading = c.heading + (world.random() - .5) * 2; @@ -1373,12 +1545,13 @@ function roomy(world, c, r0, r1, wantWater) { } return best; } -// A new burrow site: dry shelf a fair walk from any standing water, so its burrow starts a pool of its own. +// A new burrow site: dry shore a fair walk from any standing water, but low enough for the tide to reach, so its burrow +// floods at high water and holds what it can when the sea goes out: a pool of its own. function settle(world, c) { for (let k = 0; k < 30; k++) { const a = world.random() * Math.PI * 2, r = 6 + world.random() * 9; - const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; - if (!onShelf(world, x, z) || z > 9 || depthAt(world, x, z) > .02) continue; + const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r, ground = heightAt(world, x, z); + if (!onShelf(world, x, z) || z > 9 || depthAt(world, x, z) > .02 || ground > TIDE_HIGH - .05 || ground < TIDE_LOW + .15) continue; let near = false; for (let n = 0; n < 8 && !near; n++) { const b = n / 8 * Math.PI * 2; near = depthAt(world, x + Math.cos(b) * 3, z + Math.sin(b) * 3) > .1; } if (!near) return { x, z }; @@ -1464,7 +1637,7 @@ function labour(world, c, dt) { } } else if (t.kind === 'dig') { if (heightAt(world, t.at.x, t.at.z) <= t.amount || c.spoil >= LOAD) { finish(world, c); return; } - const took = -reshape(world, t.at.x, t.at.z, -.09 * dt, .7); + const took = -reshape(world, t.at.x, t.at.z, -.09 * dt, t.amount === BURROW ? .95 : .7); c.spoil += took; c.energy = Math.min(1, c.energy + took * .9); c.dug += took; @@ -1748,8 +1921,20 @@ function moveSwimmer(world, c, dt) { const age = world.time - r.born, dx = c.x - r.x, dz = c.z - r.z, d = Math.hypot(dx, dz); if (age < 1.5 && d < 1.8 && d > 1e-3) { const push = (1.8 - d) * 5 * (1 - age / 1.5); ax += dx / d * push; az += dz / d * push; } } - // Feel for deeper water; the pull grows as the water here thins or the tide turns. - const worry = clamp((.45 - depth) / .4, 0, 1) + (tideRising(world.time) ? 0 : .3); + // Tide-pool fish keep a home pool. At high water the school ranges out over the flooded shore, and as the tide turns + // each finds its way back, so they are home before the pool is cut off. A fish without one, or whose pool is gone, + // takes the pool it is in. + let pool = c.pool && world.names.find(p => p.name === c.pool); + if (!pool) { pool = world.names.find(p => p.mask[cellAt(c.x, c.z)] && world.time - (p.seen ?? p.born) < 2) || null; c.pool = pool?.name ?? null; } + const falling = !tideRising(world.time), inside = pool && pool.mask[cellAt(c.x, c.z)]; + const homing = pool && falling && !(inside && distance(pool, c) < 3); + if (homing) { + const dx = pool.x - c.x, dz = pool.z - c.z, d = Math.hypot(dx, dz) || 1, pull = inside ? 1 : 2.8; + ax += dx / d * pull; az += dz / d * pull; + } + // Feel for deeper water; the pull grows as the water here thins or the tide turns. Heading home, only the water's + // thinning counts, or the shore's slope would lead the fish out to sea instead. + const worry = clamp((.45 - depth) / .4, 0, 1) + (falling && !homing ? .3 : 0); if (worry > .05) { let best = 0, bx = 0, bz = 0; for (let k = 0; k < 8; k++) { @@ -1784,7 +1969,7 @@ function moveSwimmer(world, c, dt) { } if (home && homeD > 1e-3) { const dx = home.x - c.x, dz = home.z - c.z, d = homeD, ring = .4 + home.reach * .8; - const hold = 1 - hunger * .75; + const hold = (1 - hunger * .75) * (homing ? .25 : 1); if (d > 7) { ax += dx / d * 1.3 * hold; az += dz / d * 1.3 * hold; } else { // Every fish around one structure circles it the same way, so they go round as one. diff --git a/public/tide-pool.js b/public/tide-pool.js index 5579a27..59a32ff 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1,7 +1,7 @@ import { createWorld, advanceWorld, offerObject, SPECIES, SPECIES_ORDER, CELL, COLS, ROWS, X0, Z0, X1, Z1, START_POOL, waterLevel, daylight, heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, BLOCK, BLOCK_Y0, blockKey, footAt, debrisAt, footing, stormAt, shineLantern, douseLantern, moonlight, -} from './tide-pool-world.js?v=36'; +} from './tide-pool-world.js?v=37'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -140,7 +140,8 @@ async function initialize() { const shared = { uWater: { value: -.35 }, uTime: { value: 0 }, uLight: { value: 1 }, uSun: { value: new T.Vector3(0, 1, 0) }, uView: { value: new T.Vector3(0, 1, 0) }, uRipples: { value: rippleVectors }, uGrid: { value: gridTexture }, uGridBox: { value: new T.Vector4(X0, Z0, X1 - X0, Z1 - Z0) } }; - const flowTexture = new T.DataTexture(new Uint16Array(COLS * ROWS * 2), COLS, ROWS, T.RGFormat, T.HalfFloatType); + // The water's motion from the simulation: how it runs (r, g) and the foam on it (b). + const flowTexture = new T.DataTexture(new Uint16Array(COLS * ROWS * 4), COLS, ROWS, T.RGBAFormat, T.HalfFloatType); flowTexture.magFilter = flowTexture.minFilter = T.LinearFilter; flowTexture.needsUpdate = true; shared.uFlow = { value: flowTexture }; // Night: the lamps that lay paths of light on the water, how far into night and dusk it is, and the haze toward the far edge. @@ -326,7 +327,7 @@ async function initialize() { float grain = .88 + .24 * (sin(vWorldC.x * .31 + sin(vWorldC.z * .23) * 2.) * sin(vWorldC.z * .27 - vWorldC.x * .11) * .5 + .5); // Tidal zonation: pale dry rock above the high-water line, darker stained rock below it where the sea reaches. float above = smoothstep(.12, .32, vWorldC.y); - vec3 rock = mix(vec3(.034, .03, .042), vec3(.08, .073, .088), above) * grain; + vec3 rock = mix(vec3(.034, .03, .042), vec3(.056, .05, .064), above) * grain; // Rock the tide has just left stays dark and wet and pales as it dries, so higher ground, uncovered first, dries first. rock = mix(rock, rock * vec3(.5, .46, .72), clamp(vCell.a, 0., 1.) * (1. - wet) * (1. - above)); vec3 tone = mix(rock, vec3(.048, .04, .068), wet); @@ -350,23 +351,38 @@ async function initialize() { uWakes: shared.uWakes, uPlankton: shared.uPlankton, uMoon: shared.uMoon, uHaze: shared.uHaze, uHazeZoom: shared.uHazeZoom, uHazeColor: shared.uHazeColor }, vertexShader: `${GRID} - uniform float uOpen, uTide, uTime, uStorm; varying vec3 vW; varying float vDepth; + uniform float uOpen, uTide, uTime, uStorm; varying vec3 vW; varying float vDepth; varying vec2 vSlope; void main() { vec3 p = position; vec4 cell = gridAt(p.xz); vDepth = mix(cell.r - cell.g, 2., uOpen); - // A slow swell lifts the surface a little where the water is deep enough to carry it. - float swell = (sin(p.x * .9 + uTime * .8) * .5 + sin(p.z * 1.3 - uTime * .6 + p.x * .4) * .5) * .018 * (1. + uStorm * 4.) * clamp(vDepth, 0., 1.); - p.y = mix(cell.r, uTide, uOpen) + .004 + swell; + // The water's own shape, as the simulation has it: its slope lights the waves, where it is deep enough to be water + // rather than a film on the rock. + const float e = .3; + vSlope = vec2(gridAt(p.xz + vec2(e, 0.)).r - gridAt(p.xz - vec2(e, 0.)).r, gridAt(p.xz + vec2(0., e)).r - gridAt(p.xz - vec2(0., e)).r) / + (2. * e) * smoothstep(.03, .2, vDepth) * (1. - uOpen); + p.y = mix(cell.r, uTide, uOpen) + .004; vec4 w = modelMatrix * vec4(p, 1.); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`, fragmentShader: ` - varying vec3 vW; varying float vDepth; uniform float uTime, uLight; uniform vec3 uSun, uView; + varying vec3 vW; varying float vDepth; varying vec2 vSlope; uniform float uTime, uLight; uniform vec3 uSun, uView; uniform vec4 uLamps[${LAMPS}]; uniform vec3 uLampColor, uHazeColor; uniform float uNight, uDusk, uHazeZoom, uStorm; uniform vec4 uHaze; uniform vec4 uWakes[${WAKES}]; uniform sampler2D uPlankton; uniform float uMoon; uniform sampler2D uFlow; uniform vec4 uGridBox; uniform float uOpen; ${WAVES} + float foamHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + // Foam lace: the walls between cells scattered over the surface, as the bubbles of spent foam draw them. + float lace(vec2 p) { + vec2 i = floor(p), f = p - i; + float d1 = 8., d2 = 8.; + for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) { + vec2 g = vec2(float(x), float(y)); + float d = length(g + vec2(foamHash(i + g), foamHash(i + g + 17.3)) - f); + if (d < d1) { d2 = d1; d1 = d; } else if (d < d2) d2 = d; + } + return 1. - smoothstep(.03, .14, d2 - d1); + } void main() { if (vDepth < .012) discard; float d = vDepth; @@ -374,11 +390,12 @@ async function initialize() { float px = length(fwidth(vW.xz)); float detail = 1. - smoothstep(.025, .11, px); // The ripples are carried along the current, two offset phases blended so the drift never resets visibly. - vec2 flow = texture2D(uFlow, (vW.xz - uGridBox.xy) / uGridBox.zw).rg * (1. - uOpen); + vec4 moving = texture2D(uFlow, (vW.xz - uGridBox.xy) / uGridBox.zw) * (1. - uOpen); + vec2 flow = moving.rg; float t1 = fract(uTime * .22), t2 = fract(uTime * .22 + .5); vec2 g1 = waveSlope(vW.xz - flow * t1 * 2.2, uTime, 1.4), g2 = waveSlope(vW.xz - flow * t2 * 2.2, uTime, 1.4); // A storm roughs the water up. - vec2 g = mix(g1, g2, abs(2. * t1 - 1.)) * smoothstep(.01, .25, d) * 1.3 * detail * (1. + uStorm * 1.6); + vec2 g = mix(g1, g2, abs(2. * t1 - 1.)) * smoothstep(.01, .25, d) * 1.3 * detail * (1. + uStorm * 1.6) + vSlope * 1.6; vec3 n = normalize(vec3(-g.x, 1., -g.y)); // Depth: a clear lavender-grey in the shallows, deep violet where the water is deep. float absorb = 1. - exp(-d * 1.3); @@ -459,6 +476,13 @@ async function initialize() { float lapping = sin(vW.x * 7.3 + sin(vW.z * 5.1 + uTime * .9) * 2. + uTime * 1.6) * .5 + .5; float edge = smoothstep(.0015, .006, fwidth(d)); float foam = (smoothstep(.06, .012, d) * edge * smoothstep(.35, .9, lapping) * (.5 + uStorm * .5) + streak * (1. + uStorm * 1.5)) * detail; + // Broken water: fresh foam lies thick where a wave has just broken or the water pours, and thins to a lace of + // bubble walls as it drifts off and fades. + if (moving.b > .04) { + vec2 fp = vW.xz * 3.1 - flow * t1 * 2.; + float walls = max(lace(fp), lace(fp * 2.2 + 5.3) * .7); + foam += smoothstep(.1, .7, moving.b) * mix(walls, 1., smoothstep(.55, .85, moving.b) * .8) * .7 * mix(.6, 1., detail); + } col = mix(col, vec3(.82, .78, .9) * (.5 + .5 * uLight), clamp(foam, 0., 1.)); float alpha = .1 + absorb * .5 + fres * 1.2 + foam + sparkle; // Colours above are chosen as they should look on screen; the pipeline expects linear light. @@ -513,33 +537,50 @@ async function initialize() { } shared.uWakes.value.forEach((v, i) => { const m = moving[i]; if (m) v.set(m.x, m.z, m.vx, m.vz); else v.set(0, 0, 0, 0); }); } - // The current: water runs toward the sea as the tide falls and inland as it rises, faster where it is shallow and narrow. - let flowClock = 0; - function uploadFlow(dt) { - flowClock -= dt; - if (flowClock > 0) return; - flowClock = .25; - const { seaDist: dist, w, h } = world, data = flowTexture.image.data; - const phase = world.time / 240 * Math.PI * 2 - 1.1; - const rate = Math.cos(phase); // positive while rising + // The current, as the simulation runs it: eased over a second or so, so the ripples it carries drift with the tide rather + // than shudder with every wave, held back among a structure's frames, and turned into an eddy behind a tide wheel. And + // foam: it gathers where a wave steepens and breaks in the shallows, where water pours fast, and at the edge of a surge, + // then drifts with the water and fades. Both follow the world's own clock, so nothing moves while it is paused. + const current = new Float32Array(cellCount * 2), foam = new Float32Array(cellCount), foamNext = new Float32Array(cellCount); + const lastSurface = new Float32Array(cellCount); + let flowWorld = null, flowTime = 0; + function uploadFlow() { + const { w, h, wall, qx, qz } = world, data = flowTexture.image.data; + if (flowWorld !== world) { flowWorld = world; flowTime = world.time; lastSurface.set(w); current.fill(0); foam.fill(0); } + const dt = Math.min(.25, world.time - flowTime); + if (dt <= 0) return; + flowTime = world.time; + const ease = 1 - Math.exp(-dt / 1.2), fade = Math.exp(-dt / 2.4); for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { - const k = j * COLS + i; - let fx = 0, fz = 0; - if (dist[k] >= 0) { - const at = n => (n >= 0 && n < cellCount && dist[n] >= 0 ? dist[n] : dist[k]); - const gx = (i < COLS - 1 ? at(k + 1) : dist[k]) - (i > 0 ? at(k - 1) : dist[k]); - const gz = (j < ROWS - 1 ? at(k + COLS) : dist[k]) - (j > 0 ? at(k - COLS) : dist[k]); - const len = Math.hypot(gx, gz) || 1; - // Faster in water deep enough to carry a current, slack across thin sheets on the ledges, - // held back among a structure's frames, and turned into an eddy behind a tide wheel. - const speed = rate * .75 * Math.min(1, (w[k] - h[k] - world.wall[k]) / .3) * drag[k]; - const ux = gx / len, uz = gz / len, turn = swirl[k] * Math.sign(rate); - fx = (ux * Math.cos(turn) - uz * Math.sin(turn)) * speed; fz = (ux * Math.sin(turn) + uz * Math.cos(turn)) * speed; + const k = j * COLS + i, d = w[k] - h[k] - wall[k]; + let u = 0, v = 0, froth = 0; + if (d > .008) { + const deep = Math.max(d, .03); + u = ((i > 0 ? qx[k - 1] : 0) + qx[k]) * .5 / deep; v = ((j > 0 ? qz[k - COLS] : 0) + qz[k]) * .5 / deep; + const speed = Math.hypot(u, v), rise = (w[k] - lastSurface[k]) / dt; + froth = smooth(.9, 1.5, speed / Math.sqrt(9.81 * deep)) * smooth(.02, .06, d) + smooth(.06, .16, rise) * (1 - smooth(.1, .45, d)); + const turn = swirl[k], c = Math.cos(turn) * drag[k], sn = Math.sin(turn) * drag[k]; + [u, v] = [u * c - v * sn, u * sn + v * c]; } - data[k * 2] = toHalf(fx); data[k * 2 + 1] = toHalf(fz); + lastSurface[k] = w[k]; + current[k * 2] += (u - current[k * 2]) * ease; current[k * 2 + 1] += (v - current[k * 2 + 1]) * ease; + foam[k] = d > .008 ? Math.min(.85, foam[k] * fade + froth * dt * 1.4) : 0; + } + // The foam is carried along: each cell takes the foam from where its water came from. + for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { + const k = j * COLS + i; + const fx = clamp(i - current[k * 2] * dt / CELL, 0, COLS - 1.001), fz = clamp(j - current[k * 2 + 1] * dt / CELL, 0, ROWS - 1.001); + const a = Math.floor(fx), b = Math.floor(fz), tx = fx - a, tz = fz - b, n = b * COLS + a; + foamNext[k] = (foam[n] * (1 - tx) + foam[n + 1] * tx) * (1 - tz) + (foam[n + COLS] * (1 - tx) + foam[n + COLS + 1] * tx) * tz; + } + foam.set(foamNext); + for (let k = 0; k < cellCount; k++) { + data[k * 4] = toHalf(clamp(current[k * 2], -2, 2)); data[k * 4 + 1] = toHalf(clamp(current[k * 2 + 1], -2, 2)); + data[k * 4 + 2] = toHalf(foam[k]); data[k * 4 + 3] = 0; } flowTexture.needsUpdate = true; } + const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }; // Shallow light drifts over the whole field, as in the first edition. const sheen = new T.Mesh(new T.PlaneGeometry(260, 160), new T.ShaderMaterial({ @@ -1554,7 +1595,7 @@ async function initialize() { shared.uHazeZoom.value = view.zoom; shared.uWater.value = waterLevel(world); uploadGrid(); - uploadFlow(1 / 30); + uploadFlow(); if (night > .02) { uploadPlankton(1 / 30); gatherWakes(); } rippleVectors.forEach((v, i) => { const r = world.ripples[i]; v.set(r?.x || 0, r?.z || 0, r?.born ?? -10, 0); }); // Sun by day, a cool low moon by night. diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index 9bda006..08d4045 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -32,9 +32,10 @@ test('the tide floods the first pool and leaves it standing', () => { for (let k = 0; k < world.h.length; k++) if (world.h[k] > -.6) { land++; if (world.w[k] - world.h[k] < WET) dry++; } assert.ok(dry / land > .6, `only ${Math.round(dry / land * 100)}% of the shelf is dry at low tide`); assert.ok(surfaceAt(world, pool.x, pool.z) > waterLevel(world) + .1, 'the pool stands above the sea'); - // Run to high water: the sea washes over the shelf and the pool joins it. + // Run to high water: the sea floods in over the sill, the pool fills, and joins it, give or take a passing wave. while (waterLevel(world) < TIDE_HIGH - .02) run(world, 2); - assert.ok(Math.abs(surfaceAt(world, pool.x, pool.z) - waterLevel(world)) < .05); + run(world, 10); + assert.ok(Math.abs(surfaceAt(world, pool.x, pool.z) - waterLevel(world)) < .12, 'the pool has joined the sea'); }); test('borers dig burrows and tunnels, pools form on their own, and the ground visibly changes', () => { @@ -85,6 +86,12 @@ test('a visitor offering draws a free specialist immediately, never one with a c const world = createWorld(); run(world, 205); for (const c of world.creatures) if (c.shell !== null) { world.objects.splice(world.objects.findIndex(o => o.id === c.shell), 1); c.shell = null; } + // Collectors put down whatever they are carrying, so one is free to answer. + for (const c of world.creatures) if (c.sp === 'collector' && c.carrying !== null) { + const o = world.objects.find(o => o.id === c.carrying); + if (o) { o.place = 'loose'; o.claimed = null; } + c.carrying = null; c.task = null; c.state = 'idle'; + } const brass = offerObject(world, 'brass', pool.x, pool.z); const collector = world.creatures.find(c => c.task?.object === brass.id); assert.equal(collector.sp, 'collector'); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index e44784b..991f9c1 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,6 +1,6 @@ const SPECIES = [ { id: "scraper", code: "SC", name: "Scraper", note: "Grazes the film on wet floors. Hides in shells, hollows, and walled homes, and sleeps in a pod up in a structure." }, - { id: "tab", code: "TB", name: "Tab", note: "Schools in open water, turning as one, and makes its home circling a structure, darting in among its frames when startled. Follows the plankton when hungry and feels for deeper water as the tide drains." }, + { id: "tab", code: "TB", name: "Tab", note: "Schools in open water, turning as one, and makes its home circling a structure, darting in among its frames when startled. Follows the plankton when hungry. Ranges out over the flooded shore at high water and swims home to its pool as the tide turns." }, { id: "pylon", code: "PY", name: "Pylon", note: "Stands still, filters the water, and catches tabs. Buds settle at the foot of structures." }, { id: "collector", code: "CL", name: "Collector", note: "Salvages husks and hoards brass in a corner, or up on a structure's deck." }, { id: "mason", code: "MS", name: "Mason", note: "Fills. Lifts spoil and stones and builds walled homes, filling in the hollow beneath first." }, @@ -129,7 +129,7 @@ export function TidePoolContent() {

Preparing the pool.

Inside the pool -

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. The tide's reach swells and shrinks with the moon, which waxes and wanes over forty minutes: springs come at full and new moon, so some low waters lay the shelf bare, and the rock stays dark where the water has just left it. Full-moon nights are silver; on moonless nights the plankton, the stars in still water, and the lit windows show best. Wherever the ground dips and cannot drain as the tide falls, water stays behind, and that is a pool. There is one pool to begin with. Borers dig burrows and tunnels, masons fill hollows and raise walls, and breakers knock walls down, so the pools grow, join, drain, and form on their own. Pools are named as they appear. Artificers build structures in the water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, solid plates, decks that reach out over the water, pods to live in, gears and tide wheels the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. A structure's footing is solid: the water goes around it, and the ground under it holds. Scrapers sleep in the pods, collectors keep hoards on the decks, and the tabs circle below and dart in among the frames when something startles them. Breakers work parts loose at the footing and artificers make them good again; when a footing gives way, everything it held falls. A tide wheel on a structure turns only while the water runs through it and stores its charge; the structure's lamps and windows burn at night only with a charged wheel nearby. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full, and a beacon, powered by a wheel, burns at night, feeds the plankton around it, and draws the tabs. Now and then a storm comes in off the sea: the water rises and runs rough, the waves batter whatever stands at the waterline, and the fish hide among the frames. Day turns to night every seven minutes, and the film on each floor grows only in wet light. At night the plankton glows wherever something moves through it.

+

Everything here is one surface: a rocky shore that the tide floods and drains every four minutes. The water is simulated: as the tide rises the sea comes in over the rock, up the gullies first, and pours over the lip of the pool; as it falls it drains away again. A swell rolls in off the sea all the while, and its waves break into foam on the rocks, bigger in a storm. The tide's reach swells and shrinks with the moon, which waxes and wanes over forty minutes: springs come at full and new moon, so some low waters lay the shore bare, and the rock stays dark where the water has just left it. Full-moon nights are silver; on moonless nights the plankton, the stars in still water, and the lit windows show best. Wherever the ground dips and cannot drain as the tide falls, water stays behind, and that is a pool. There is one pool to begin with. Borers dig burrows and tunnels, masons fill hollows and raise walls, and breakers knock walls down, so the pools grow, join, drain, and form on their own. Pools are named as they appear. Artificers build structures in the water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, solid plates, decks that reach out over the water, pods to live in, gears and tide wheels the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. A structure's footing is solid: the water goes around it, and the ground under it holds. Scrapers sleep in the pods, collectors keep hoards on the decks, and the tabs circle below and dart in among the frames when something startles them. Breakers work parts loose at the footing and artificers make them good again; when a footing gives way, everything it held falls. A tide wheel on a structure turns only while the water runs through it and stores its charge; the structure's lamps and windows burn at night only with a charged wheel nearby. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full, and a beacon, powered by a wheel, burns at night, feeds the plankton around it, and draws the tabs. Now and then a storm comes in off the sea: the water rises and runs rough, the waves batter whatever stands at the waterline, and the fish hide among the frames. Day turns to night every seven minutes, and the film on each floor grows only in wet light. At night the plankton glows wherever something moves through it.

@@ -142,7 +142,7 @@ export function TidePoolContent() {
{/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); }