diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 5322cb1..4afe4db 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -15,13 +15,13 @@ const EVAPORATE = .0006; // metres of standing water lost per second in an iso 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: 26, start: 10, min: 4, burn: .0055, radius: .28 }, - tab: { code: 'TB', name: 'Tab', kind: 'swimmer', speed: 1.05, max: 28, start: 12, min: 5, burn: .007, radius: .14 }, - pylon: { code: 'PY', name: 'Pylon', kind: 'sessile', max: 10, start: 4, min: 2, burn: .0025, radius: .3 }, - collector: { code: 'CL', name: 'Collector', kind: 'walker', speed: .95, max: 8, start: 3, min: 1, burn: .0035, radius: .38, core: true }, - mason: { code: 'MS', name: 'Mason', kind: 'walker', speed: .58, max: 8, start: 3, min: 2, burn: .0045, radius: .4, core: true }, - breaker: { code: 'BR', name: 'Breaker', kind: 'walker', speed: .75, max: 6, start: 2, min: 1, burn: .0042, radius: .48, core: true }, - borer: { code: 'BO', name: 'Borer', kind: 'walker', speed: .32, max: 8, start: 4, min: 2, burn: .004, radius: .34 }, + scraper: { code: 'SC', name: 'Scraper', kind: 'walker', speed: .4, max: 22, start: 3, min: 4, burn: .0055, radius: .28 }, + tab: { code: 'TB', name: 'Tab', kind: 'swimmer', speed: 1.05, max: 22, start: 5, min: 5, burn: .007, radius: .14 }, + pylon: { code: 'PY', name: 'Pylon', kind: 'sessile', max: 10, start: 0, debut: 25, min: 2, burn: .0025, radius: .3 }, + collector: { code: 'CL', name: 'Collector', kind: 'walker', speed: .95, max: 8, start: 0, debut: 100, min: 1, burn: .0035, radius: .38, core: true }, + mason: { code: 'MS', name: 'Mason', kind: 'walker', speed: .58, max: 8, start: 0, debut: 140, min: 2, burn: .0045, radius: .4, core: true }, + breaker: { code: 'BR', name: 'Breaker', kind: 'walker', speed: .75, max: 6, start: 0, debut: 200, min: 1, burn: .0042, radius: .48, core: true }, + borer: { code: 'BO', name: 'Borer', kind: 'walker', speed: .32, max: 7, start: 0, debut: 60, min: 2, burn: .004, radius: .34 }, }; export const SPECIES_ORDER = Object.keys(SPECIES); export const MATERIALS = ['brass', 'shell', 'pebble', 'feed']; @@ -31,8 +31,10 @@ const lerp = (a, b, t) => a + (b - a) * t; const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); }; const distance = (a, b) => Math.hypot(a.x - b.x, a.z - b.z); -export const tideOf = t => .5 + .5 * Math.sin(t / TIDE_PERIOD * Math.PI * 2); -export const tideRising = t => Math.cos(t / TIDE_PERIOD * Math.PI * 2) > 0; +// The shelf opens just after low water, so the first pool stands clear in dry rock while the tide begins to rise. +const TIDE_PHASE = -1.1; +export const tideOf = t => .5 + .5 * Math.sin(t / TIDE_PERIOD * Math.PI * 2 + TIDE_PHASE); +export const tideRising = t => Math.cos(t / TIDE_PERIOD * Math.PI * 2 + TIDE_PHASE) > 0; export const waterLevel = world => lerp(TIDE_LOW, TIDE_HIGH, tideOf(world.time)); export const daylight = world => { const s = Math.sin(world.time / DAY_PERIOD * Math.PI * 2 + .45); @@ -73,18 +75,30 @@ function terrainNoise(seed) { export const START_POOL = { x: -4, z: -3, r: 5.5 }; function shape(world) { const fbm = terrainNoise(902); + let seed = 1234; + const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296); + // Rounded boulders scattered over the shelf, some standing clear of high water. + const boulders = Array.from({ length: 70 }, () => ({ + 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 * 91.7 + 3.1) * 43758.5453; return v - Math.floor(v); }; + const dip = { x: Math.cos(.45), z: Math.sin(.45) }; 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; - // Mostly just under the high-water line, so the tide washes over the shelf and refills whatever is dug into it. - let h = -.02 + (fbm(x * .12, z * .12) - .5) * .42 + (fbm(x * .5 + 11, z * .5) - .5) * .12; + // 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); + } world.h[k] = lerp(h, -2.2, smooth(12, 21.5, z)); } - // Fill every natural pit up to its lip, with a hair of fall toward the sea, so the only hollow is the one we carve. - spill(world); - for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { - const k = j * COLS + i; - world.h[k] = Math.max(world.h[k], world.S[k] + (ROWS - j) * .0004); - } + // The first pool: a deeper bowl where the story begins. 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); @@ -231,29 +245,50 @@ function survey(world) { if (seen[n] < 0 && w[n] - h[n] >= WET) { seen[n] = id; cells.push(n); } } } - parts.push({ cells, sea, x: sx / cells.length, z: sz / cells.length }); + let deepest = 0; + for (const k of cells) deepest = Math.max(deepest, w[k] - h[k]); + parts.push({ cells, sea, deepest, x: sx / cells.length, z: sz / cells.length }); } - // Match each separate pool to a name by overlap with where each named pool last lay. + // 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(); - for (const part of parts) { - if (part.sea || part.cells.length < 40) continue; - let best = null, overlap = 0; - for (const named of world.names) { - if (taken.has(named.name)) continue; - let n = 0; - for (const k of part.cells) if (named.mask[k]) n++; - if (n > overlap) { overlap = n; best = named; } - } - if (!best || overlap < Math.min(12, part.cells.length * .3)) { + const candidates = parts.filter(p => !p.sea && p.cells.length >= 60); + const pairs = []; + for (const part of candidates) for (const named of world.names) { + let n = 0; + for (const k of part.cells) if (named.mask[k]) n++; + if (n >= Math.min(12, part.cells.length * .3)) pairs.push({ part, named, n }); + } + pairs.sort((a, b) => b.n - a.n); + const matched = new Map(); + for (const { part, named } of pairs) if (!matched.has(part) && !taken.has(named.name)) { matched.set(part, named); taken.add(named.name); } + // Before anything has a name, the pool the story begins in is named first. + if (!world.poolCount) { const first = cellAt(START_POOL.x, START_POOL.z); candidates.sort((a, b) => b.cells.includes(first) - a.cells.includes(first)); } + for (const part of candidates) { + let best = matched.get(part); + if (!best) { + // Only a real pool earns a new name: deep enough, and seen as the water withdraws, not a sheet the rising tide left on a ledge. + if (part.deepest < .25 || tideRising(world.time) && tideOf(world.time) > .3) continue; best = { name: roman(++world.poolCount), mask: new Uint8Array(N), born: world.time }; world.names.push(best); + taken.add(best.name); event(world, 'form', null, best.name); } - taken.add(best.name); best.mask.fill(0); for (const k of part.cells) best.mask[k] = 1; best.cells = part.cells.length; best.x = part.x; best.z = part.z; best.seen = world.time; + // The outlet is the lip: the ground at exactly the pool's spill level, where its water escapes as the tide falls. + let lip = Infinity; + for (const k of part.cells) lip = Math.min(lip, world.S[k]); + let outlet = null, near = Infinity, i0 = COLS, i1 = 0, j0 = ROWS, j1 = 0; + for (const k of part.cells) { const i = k % COLS, j = (k - i) / COLS; i0 = Math.min(i0, i); i1 = Math.max(i1, i); j0 = Math.min(j0, j); j1 = Math.max(j1, j); } + for (let j = Math.max(1, j0 - 8); j <= Math.min(ROWS - SEA_ROWS - 1, j1 + 8); j++) for (let i = Math.max(1, i0 - 8); i <= Math.min(COLS - 2, i1 + 8); i++) { + const k = j * COLS + i; + if (best.mask[k] || Math.abs(world.S[k] - lip) > .004 || Math.abs(h[k] - lip) > .004) continue; + const d = Math.hypot(cellX(i) - part.x, cellZ(j) - part.z); + if (d < near) { near = d; outlet = k; } + } + best.outlet = outlet === null ? null : { x: cellX(outlet % COLS), z: cellZ(Math.floor(outlet / COLS)), h: lip }; bodies.push(best); } // A name the sea has swallowed stays dormant; one unseen for more than a whole tide is retired. @@ -262,7 +297,21 @@ function survey(world) { event(world, 'vanish', null, n.name); return false; }); - world.bodies = bodies.map(b => ({ name: b.name, cells: b.cells, x: b.x, z: b.z, area: b.cells * CELL * CELL })); + world.bodies = bodies.map(b => ({ name: b.name, cells: b.cells, x: b.x, z: b.z, area: b.cells * CELL * CELL, outlet: b.outlet })); + // How far each wet cell is from the open sea through water: the tide flows along this, in as it rises and out as it falls. + const dist = world.seaDist; + dist.fill(-1); + const queue = []; + for (let k = (ROWS - SEA_ROWS) * COLS; k < N; k++) if (w[k] - h[k] >= WET) { dist[k] = 0; queue.push(k); } + for (let q = 0; q < queue.length; q++) { + const k = queue[q], i = k % COLS, j = (k - i) / COLS; + for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const ni = i + di, nj = j + dj; + if (ni < 0 || nj < 0 || ni >= COLS || nj >= ROWS) continue; + const n = nj * COLS + ni; + if (dist[n] < 0 && w[n] - h[n] >= WET) { dist[n] = dist[k] + 1; queue.push(n); } + } + } world.seaCells = parts.filter(p => p.sea).reduce((a, p) => a + p.cells.length, 0); } export function bodyAt(world, x, z) { @@ -283,12 +332,12 @@ 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), - film: new Float32Array(N), plankton: new Float32Array(N), + film: new Float32Array(N), plankton: new Float32Array(N), seaDist: new Float32Array(N).fill(-1), creatures: [], objects: [], ripples: [], events: [], census: [], ended: [], names: [], bodies: [], poolCount: 0, seaCells: 0, terrainVersion: 0, disturbed: new Set(), spillDirty: true, serials: Object.fromEntries(SPECIES_ORDER.map(s => [s, 0])), arrivals: Object.fromEntries(SPECIES_ORDER.map(s => [s, 0])), - nextWash: 45, nextCensus: 0, nextSurvey: 0, nextSpill: 0, step: 0, + nextWash: 45, nextCensus: 0, nextSurvey: 0, nextSpill: 0, step: 0, debuted: {}, }; shape(world); spill(world); @@ -315,7 +364,7 @@ export function createWorld(seed = 41) { const at = sp === 'tab' || sp === 'pylon' ? around(0, 4, true) : sp === 'borer' ? (n % 2 ? around(9, 15, false) : around(4.5, 7, null)) : around(0, 6.5, null); spawn(world, sp, at.x, at.z, { energy: .5 + random() * .3, size: 2 + Math.floor(random() * 2) }); } - for (const kind of ['brass', 'brass', 'shell', 'shell', 'pebble', 'pebble', 'pebble', 'shell']) { + for (const kind of ['shell', 'pebble', 'brass']) { const at = around(1, 6.5, null); addObject(world, kind, at.x, at.z, { height: 0 }); } @@ -337,7 +386,7 @@ function spawn(world, sp, x, z, extra = {}) { lifespan: 520 + world.random() * 420, gen: extra.gen ?? 1, parent: extra.parent ?? null, kids: 0, state: 'idle', task: null, timer: world.random(), path: [], carrying: null, shell: null, home: null, spoil: 0, dug: 0, 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, + heading: world.random() * Math.PI * 2, aim: null, side: 1, born: world.time, }; if (sp === 'tab') { const a = world.random() * Math.PI * 2; c.vx = Math.cos(a) * .4; c.vz = Math.sin(a) * .4; } if (sp === 'collector' || sp === 'mason' || sp === 'borer') c.home = { x, z }; @@ -459,10 +508,17 @@ function nearest(c, list, limit = Infinity) { return best; } const looseOf = (world, kinds) => world.objects.filter(o => o.place === 'loose' && o.claimed === null && o.age > .6 && kinds.includes(o.kind)); -const canBreed = (world, sp) => count(world, sp) < SPECIES[sp].max; +// After building a body a parent rests, and rests longer the more of its kind there already are, so numbers build gradually. +const REST = { scraper: 60, tab: 80, pylon: 90, collector: 90, mason: 90, breaker: 120, borer: 55 }; +const canBreed = (world, sp, c) => { + const n = count(world, sp); + if (n >= SPECIES[sp].max) return false; + const since = c ? world.time - (c.bred ?? c.born ?? -1e9) : Infinity; + return since > REST[sp] * (1 + 4 * n / SPECIES[sp].max); +}; function breed(world, c) { - if (!canBreed(world, c.sp)) return null; + if (!canBreed(world, c.sp, c)) return null; const S = SPECIES[c.sp]; let at = null; for (let i = 0; i < 16 && !at; i++) { @@ -478,7 +534,7 @@ function breed(world, c) { if (!at) return null; const child = spawn(world, c.sp, at.x, at.z, { gen: c.gen + 1, parent: c.id, energy: .42, size: 1 }); if (!child) return null; - c.energy -= .45; c.kids++; + c.energy -= .45; c.kids++; c.bred = world.time; child.angle = c.angle; child.heading = c.heading + (world.random() - .5) * 2; if (c.sp === 'tab') { child.vx = -c.vz; child.vz = c.vx; } if (c.sp === 'borer') child.home = settle(world, c) || child.home; @@ -558,7 +614,7 @@ function thinkScraper(world, c) { const shell = nearest(c, looseOf(world, ['shell']), 5); if (shell) return assign(world, c, 'wear', shell); } - if (c.energy > .88 && canBreed(world, 'scraper')) { work(c, 3); c.task = { kind: 'breed' }; return; } + if (c.energy > .88 && canBreed(world, 'scraper', c)) { work(c, 3); c.task = { kind: 'breed' }; return; } graze(world, c); } @@ -578,7 +634,7 @@ function thinkCollector(world, c) { if (food) return assign(world, c, 'eat', food); return graze(world, c, .6); } - if (hoardOf(world, c).length && c.energy > .75 && canBreed(world, 'collector')) return assign(world, c, 'breed', null, { at: c.home, patience: 40 }); + if (hoardOf(world, c).length && c.energy > .75 && canBreed(world, 'collector', c)) return assign(world, c, 'breed', null, { at: c.home, patience: 40 }); const source = brassSource(world, c, world.random() < .3); if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object); const curious = world.objects.filter(o => o.place === 'loose' && o.age < 25 && o.claimed === null && !(c.seen || []).includes(o.id)); @@ -616,10 +672,23 @@ function spoilHeap(world, c) { } return best; } +// A pool worth damming: near, and still leaking below the high-water line through its outlet. +function damSite(world, c) { + let best = null, score = 18; + for (const b of world.bodies) { + if (!b.outlet || b.outlet.h > TIDE_HIGH + .04) continue; + const d = distance(c, b.outlet); + if (d < score) { score = d; best = b; } + } + return best; +} function thinkMason(world, c) { if (c.spoil > 0 || c.carrying !== null) { const held = objectById(world, c.carrying); if (held?.kind === 'brass') { work(c, 3); c.task = { kind: 'breed' }; return; } + // Some loads go to raising a dam across a pool's outlet, so the pool keeps more water when the tide goes out. + const dam = world.random() < .65 && damSite(world, c); + if (dam) return assign(world, c, 'dam', null, { at: { x: dam.outlet.x, z: dam.outlet.z, pool: dam.name }, patience: 50, reach: .45 }); const spot = ringSpot(world, c); if (spot) return assign(world, c, 'build', null, { at: spot, patience: 50, reach: .45 }); // The home is finished: start another nearby, on ground that is neither built nor deep. @@ -631,7 +700,7 @@ function thinkMason(world, c) { if (c.energy < .4) c.hungry = true; if (c.energy > .85) c.hungry = false; if (c.hungry) return graze(world, c); - if (c.energy > .7 && canBreed(world, 'mason')) { + if (c.energy > .7 && canBreed(world, 'mason', c)) { const source = brassSource(world, c, world.random() < .55); if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); } @@ -646,7 +715,7 @@ function thinkMason(world, c) { function thinkBreaker(world, c) { if (c.carrying !== null) { work(c, 3); c.task = { kind: 'breed' }; return; } - if (c.energy > .85 && canBreed(world, 'breaker')) { + if (c.energy > .85 && canBreed(world, 'breaker', c)) { const source = brassSource(world, c, true); if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); } @@ -672,6 +741,18 @@ function thinkBreaker(world, c) { if (pylon) return assign(world, c, 'topple', null, { at: { x: pylon.x, z: pylon.z }, prey: pylon.id, reach: .55, patience: 25 }); } } + // Idle breakers go at set stone: a wall, a dam. + if (world.random() < .2) { + let best = null, score = -Infinity; + for (let k = 0; k < 30; k++) { + const a = world.random() * Math.PI * 2, r = world.random() * 14; + const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; + if (!onShelf(world, x, z)) continue; + const b = world.built[cellAt(x, z)]; + if (b > .15 && b - r * .02 > score) { score = b - r * .02; best = { x, z }; } + } + if (best) return assign(world, c, 'dismantle', null, { at: best, patience: 30, reach: .5 }); + } wander(world, c); } @@ -731,7 +812,7 @@ function thinkBorer(world, c) { return assign(world, c, 'dump', null, { at: { x: c.x, z: c.z }, patience: 3 }); } if (c.energy < .3) return graze(world, c, .8); - if (c.energy > .85 && canBreed(world, 'borer') && world.random() < .5) { work(c, 3); c.task = { kind: 'breed' }; return; } + if (c.energy > .85 && canBreed(world, 'borer', c) && world.random() < .5) { work(c, 3); c.task = { kind: 'breed' }; return; } if (heightAt(world, c.home.x, c.home.z) > BURROW + .08) return assign(world, c, 'dig', null, { at: c.home, amount: BURROW, patience: 30 }); // Tunnel: steer the heading gently, with a pull toward water that is not our own. if (c.dug > 14) { @@ -771,7 +852,7 @@ function arrive(world, c) { return; } return work(c, t.kind === 'eat' ? 2.6 : 1.4); - case 'deliver': case 'build': case 'dump': return work(c, 1.3); + case 'deliver': case 'build': case 'dump': case 'dam': return work(c, 1.3); case 'lift': return work(c, 1.8); case 'dig': return work(c, 30); case 'breed': return work(c, 3); @@ -857,6 +938,18 @@ function finish(world, c) { } break; } + case 'dam': { + const held = objectById(world, c.carrying); + let amount = c.spoil; + if (held) { amount += .25; c.carrying = null; removeObject(world, held); } + if (amount > 0) { + reshape(world, t.at.x, t.at.z, amount, .45, 1); + c.spoil = 0; + ripple(world, t.at.x, t.at.z); + event(world, 'dam', c, t.at.pool); + } + break; + } case 'dump': if (c.spoil > 0) { reshape(world, t.at.x, t.at.z, c.spoil, .9); event(world, 'dump', c); c.spoil = 0; } break; @@ -1030,7 +1123,25 @@ function environment(world, dt) { } // The open sea replaces what is lost, but only at high water. +// The shelf is introduced one species at a time. Each walks or drifts in near the first pool, where it can be seen arriving. +function debuts(world) { + for (const sp of SPECIES_ORDER) { + const S = SPECIES[sp]; + if (!S.debut || world.debuted[sp] || world.time < S.debut) continue; + world.debuted[sp] = true; + let at = null; + for (let n = 0; n < 60 && !at; n++) { + const a = Math.PI * (.2 + world.random() * .6), r = S.kind === 'sessile' ? 1 + world.random() * 3 : 6 + world.random() * 3; + const x = START_POOL.x + Math.cos(a) * r, z = START_POOL.z + Math.sin(a) * r; + if (S.kind === 'sessile' ? depthAt(world, x, z) > .3 : onShelf(world, x, z)) at = { x, z }; + } + const c = at && spawn(world, sp, at.x, at.z, { energy: .75, size: 2 }); + if (c) { world.arrivals[sp] = world.time; event(world, 'debut', c); } + } +} + function sea(world) { + debuts(world); if (tideOf(world.time) < .55) return; const landing = () => { for (let n = 0; n < 30; n++) { @@ -1040,6 +1151,7 @@ function sea(world) { return null; }; for (const sp of SPECIES_ORDER) { + if (SPECIES[sp].debut && !world.debuted[sp]) continue; if (count(world, sp) >= SPECIES[sp].min || world.time - world.arrivals[sp] < 40) continue; const at = landing(); const c = at && spawn(world, sp, at.x, at.z, { energy: .6, size: 2 }); @@ -1096,7 +1208,7 @@ export function advanceWorld(world, elapsed) { if (S.kind === 'swimmer') { const pl = world.plankton[k]; if (!dry) { c.energy = Math.min(1, c.energy + pl * .04 * dt); world.plankton[k] = Math.max(0, pl - .004 * dt); } - if (c.energy > .85 && canBreed(world, 'tab') && world.random() < dt * .08) breed(world, c); + if (c.energy > .85 && canBreed(world, 'tab', c) && world.random() < dt * .08) breed(world, c); moveSwimmer(world, c, dt); } else if (S.kind === 'sessile') { const pl = world.plankton[k]; @@ -1213,6 +1325,7 @@ export function goalText(world, c) { if (c.sp === 'collector' && t.circled && c.path.length > 1) return 'circling its find'; if (t.kind === 'dig') return t.amount === BURROW ? 'deepening its burrow' : c.aim ? 'tunnelling toward other water' : 'tunnelling outward'; if (t.kind === 'build' && t.at.fill) return 'filling in the hollow under its home'; + if (t.kind === 'dam') return `damming pool ${t.at.pool}`; if (t.kind === 'fetch') { const o = objectById(world, t.object); return `going for ${o?.kind || 'material'}${t.use === 'breed' ? ' to build a new body' : ''}`; diff --git a/public/tide-pool.css b/public/tide-pool.css index 3701be9..e1c99d1 100644 --- a/public/tide-pool.css +++ b/public/tide-pool.css @@ -72,7 +72,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-controls { position: absolute; z-index: 3; left: 50%; bottom: max(22px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; } .tide-hint { margin: 0 0 .7rem; font-size: .74rem; color: #b3aab8; text-align: center; transition: opacity .8s; pointer-events: none; } .tide-hint span { color: #7d7582; } -.tide-pool[data-engaged] .tide-hint { opacity: 0; } +.tide-pool[data-engaged] .tide-hint, .tide-pool:has(.tide-status[data-news]) .tide-hint { opacity: 0; } .tide-tools { display: flex; gap: 2px; padding: 3px; background: #0a090cb3; border: 1px solid #262329; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); } .tide-tools button { --glow: #d9d2dd; display: flex; align-items: center; gap: .55rem; min-height: 44px; padding: .5rem 1rem; color: #948b99; background: none; border: 0; font-size: .74rem; letter-spacing: .05em; transition: color .3s, box-shadow .3s, background .3s; } .tide-tools button:hover:not(:disabled) { color: #ddd6e1; } @@ -103,7 +103,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-utility button[aria-pressed="true"] { color: #e5e0e8; } .tide-status { position: absolute; left: 16px; right: 16px; bottom: 100px; text-align: center; margin: 0; font-size: .72rem; color: #b8aabf; pointer-events: none; transition: opacity .6s; } -.tide-pool[data-ready]:not([data-engaged]) .tide-status { opacity: 0; } +.tide-pool[data-ready]:not([data-engaged]) .tide-status:not([data-news]) { opacity: 0; } .tide-ticker { position: absolute; z-index: 2; left: clamp(16px, 3.4vw, 56px); bottom: 76px; width: min(300px, calc(50% - 300px)); margin: 0; padding: 0; list-style: none; font-size: .7rem; line-height: 1.55; color: #8d8592; pointer-events: none; } .tide-ticker li { transition: opacity 1s; } .tide-ticker time { font-variant-numeric: tabular-nums; color: #5f5963; margin-right: .5rem; } diff --git a/public/tide-pool.js b/public/tide-pool.js index 1da3439..79867ce 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, -} from './tide-pool-world.js?v=9'; +} from './tide-pool-world.js?v=12'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -48,6 +48,9 @@ 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); + flowTexture.magFilter = flowTexture.minFilter = T.LinearFilter; flowTexture.needsUpdate = true; + shared.uFlow = { value: flowTexture }; // The simulation grid as a texture: r = water surface, g = ground, b = set stone, a = film. // Sampled with a cubic B-spline (four bilinear taps) so shorelines and hollows come out round, not grid-shaped. const GRID = ` @@ -113,12 +116,12 @@ async function initialize() { float depth = local.r - vWorldC.y; float under = clamp(depth * 3., 0., 1.); // Water absorbs warm light first: deeper floors go darker and cooler. - vec3 absorbed = gl_FragColor.rgb * mix(vec3(.66, .64, .78), vec3(.3, .3, .44), clamp(depth * .9, 0., 1.)); + vec3 absorbed = gl_FragColor.rgb * mix(vec3(.66, .62, .8), vec3(.28, .26, .42), clamp(depth * .9, 0., 1.)); gl_FragColor.rgb = mix(gl_FragColor.rgb, absorbed, under); - gl_FragColor.rgb += vec3(.74, .66, .86) * caustic(vWorldC.xz, uTime * .5) * under * exp(-max(depth, 0.) * 1.6) * .11 * uLight; + gl_FragColor.rgb += vec3(.8, .72, .95) * caustic(vWorldC.xz, uTime * .5) * under * exp(-max(depth, 0.) * 1.8) * .15 * uLight; // A faint wet line where the water meets the floor, broken up so it reads as a lapping edge. float lap = exp(-pow(depth / .018, 2.)) * (.55 + .45 * sin(vWorldC.x * 6.3 + vWorldC.z * 4.1 + uTime * 1.4)) * smoothstep(.01, .06, column); - gl_FragColor.rgb += vec3(.8, .76, .9) * lap * .16 * uLight; + gl_FragColor.rgb += vec3(.82, .76, .92) * lap * .14 * uLight; #include `); }; return material; @@ -178,28 +181,38 @@ async function initialize() { geo.setIndex(new T.BufferAttribute(index, 1)); return geo; } + // The rock's shape, shared by its colour pass and its shadow pass so boulders and ledges cast true shadows. + const ROCK_HEAD = 'varying vec4 vCell;\nvarying float vBeyond;\n' + GRID + ` + float rockHash(vec2 p) { return fract(sin(dot(floor(p * 2.01), vec2(127.1, 311.7))) * 43758.5453); } + `; + const ROCK_BODY = `#include + vCell = gridAt(position.xz); + // How far outside the simulated shelf this point lies, on the landward sides only. + vec2 lo = uGridBox.xy, hi = uGridBox.xy + uGridBox.zw; + float out_ = max(max(lo.x - position.x, position.x - hi.x), lo.y - position.z); + vBeyond = clamp(out_ / 22., 0., 1.); + float dry = 1. - smoothstep(.0, .05, vCell.r - vCell.g); + // Visual relief only: a small broken texture on dry rock, rising into banks beyond the shelf. + float relief = (rockHash(position.xz) - .5) * .09 * dry; + float bank = smoothstep(0., 18., out_) * (1.2 + rockHash(position.xz * .37) * 1.4) + smoothstep(0., 3., out_) * .15; + transformed.y = vCell.g + relief + bank;`; + const rockDepth = new T.MeshDepthMaterial({ depthPacking: T.RGBADepthPacking }); + rockDepth.onBeforeCompile = shader => { + Object.assign(shader.uniforms, shared); + shader.vertexShader = ROCK_HEAD + shader.vertexShader.replace('#include ', ROCK_BODY); + }; const rockMaterial = submerge(new T.MeshLambertMaterial({ color: 0xffffff, flatShading: true }), shader => { - shader.vertexShader = 'varying vec4 vCell;\nvarying float vBeyond;\n' + GRID + ` - float rockHash(vec2 p) { return fract(sin(dot(floor(p * 2.01), vec2(127.1, 311.7))) * 43758.5453); } - ` + shader.vertexShader.replace('#include ', `#include - vCell = gridAt(position.xz); - // How far outside the simulated shelf this point lies, on the landward sides only. - vec2 lo = uGridBox.xy, hi = uGridBox.xy + uGridBox.zw; - float out_ = max(max(lo.x - position.x, position.x - hi.x), lo.y - position.z); - vBeyond = clamp(out_ / 22., 0., 1.); - float dry = 1. - smoothstep(.0, .05, vCell.r - vCell.g); - // Visual relief only: a small broken texture on dry rock, rising into banks beyond the shelf. - float relief = (rockHash(position.xz) - .5) * .09 * dry; - float bank = smoothstep(0., 18., out_) * (1.2 + rockHash(position.xz * .37) * 1.4) + smoothstep(0., 3., out_) * .15; - transformed.y = vCell.g + relief + bank;`); + shader.vertexShader = ROCK_HEAD + shader.vertexShader.replace('#include ', ROCK_BODY); shader.fragmentShader = 'varying vec4 vCell;\nvarying float vBeyond;\n' + shader.fragmentShader.replace('#include ', `#include { // Dry rock is dark slate; standing water lights the floor beneath it; set stone reads paler. float wet = smoothstep(.0, .45, vCell.r - vCell.g); // Broad, soft drifts of tone across the rock; the facets themselves carry the geometry. float grain = .88 + .24 * (sin(vWorldC.x * .31 + sin(vWorldC.z * .23) * 2.) * sin(vWorldC.z * .27 - vWorldC.x * .11) * .5 + .5); - vec3 rock = vec3(.03, .027, .036) * grain; - vec3 tone = mix(rock, vec3(.05, .042, .07), wet); + // 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(.03, .027, .037), vec3(.08, .073, .088), above) * grain; + vec3 tone = mix(rock, vec3(.048, .04, .068), wet); tone = mix(tone, vec3(.032, .027, .045), clamp(vCell.r - vCell.g - .7, 0., 1.) * .6); tone = mix(tone, vec3(.11, .1, .125) * grain, clamp(vCell.b, 0., 1.) * (1. - wet * .4)); // Far beyond the shelf the rock sinks into the dark. @@ -207,41 +220,66 @@ async function initialize() { }`); }); const ground = new T.Mesh(rockSheet(X0 - 44, Z0 - 44, X1 + 44, Z1 + 30, CELL), rockMaterial); - ground.frustumCulled = false; ground.receiveShadow = true; + ground.frustumCulled = false; ground.receiveShadow = true; ground.castShadow = true; + ground.customDepthMaterial = rockDepth; scene.add(ground); // Water: the same grid lifted to the water surface, fading out where the water thins to nothing. const waterMaterial = new T.ShaderMaterial({ transparent: true, depthWrite: false, uniforms: { uTime: shared.uTime, uLight: shared.uLight, uRipples: shared.uRipples, uSun: shared.uSun, uView: shared.uView, - uGrid: shared.uGrid, uGridBox: shared.uGridBox, uOpen: { value: 0 }, uTide: shared.uWater }, + uGrid: shared.uGrid, uGridBox: shared.uGridBox, uFlow: shared.uFlow, uOpen: { value: 0 }, uTide: shared.uWater }, vertexShader: `${GRID} - uniform float uOpen, uTide; varying vec3 vW; varying float vDepth; + uniform float uOpen, uTide, uTime; varying vec3 vW; varying float vDepth; void main() { vec3 p = position; vec4 cell = gridAt(p.xz); - p.y = mix(cell.r, uTide, uOpen) + .004; 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 * clamp(vDepth, 0., 1.); + p.y = mix(cell.r, uTide, uOpen) + .004 + swell; vec4 w = modelMatrix * vec4(p, 1.); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`, - fragmentShader: `precision mediump float; + fragmentShader: ` varying vec3 vW; varying float vDepth; uniform float uTime, uLight; uniform vec3 uSun, uView; + uniform sampler2D uFlow; uniform vec4 uGridBox; uniform float uOpen; ${WAVES} void main() { - if (vDepth < .015) discard; - vec2 g = waveSlope(vW.xz, uTime, 1.4) * smoothstep(.02, .3, vDepth); + if (vDepth < .012) discard; + float d = vDepth; + // How much world one pixel covers: fine detail fades out as the view pulls back, so the far water stays calm. + 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); + 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); + vec2 g = mix(g1, g2, abs(2. * t1 - 1.)) * smoothstep(.01, .25, d) * 1.3 * detail; vec3 n = normalize(vec3(-g.x, 1., -g.y)); - // More reflection where a ripple tilts away from the eye; clear where it faces it. + // Depth: a clear lavender-grey in the shallows, deep violet where the water is deep. + float absorb = 1. - exp(-d * 2.4); + vec3 body = mix(vec3(.13, .115, .18), vec3(.02, .017, .04), absorb) * (.55 + .45 * uLight); + vec3 r = reflect(-uView, n); + vec3 sky = mix(vec3(.09, .08, .12), vec3(.46, .42, .58), smoothstep(-.2, .9, r.y)) * (.35 + .65 * uLight); float fres = .02 + .98 * pow(1. - max(dot(n, uView), 0.), 5.); + vec3 col = mix(body, sky, clamp(fres * 2.2, 0., 1.)); vec3 h = normalize(uSun + uView); float nh = max(dot(n, h), 0.); - float patchy = smoothstep(.45, .85, sin(vW.x * .8 + uTime * .2) * sin(vW.z * .9 - uTime * .17) * .5 + .5); - float glint = pow(nh, 900.) * 1.1 * patchy + pow(nh, 40.) * .025; - vec3 deep = mix(vec3(.02, .02, .035), vec3(.045, .04, .07), uLight); - vec3 col = mix(deep, vec3(.42, .38, .54) * uLight, clamp(fres * 3., 0., 1.)) + vec3(1., .96, .92) * glint * uLight; - float shore = smoothstep(.015, .09, vDepth); - gl_FragColor = vec4(col, clamp(.2 + fres * 2.4 + glint, 0., .9) * shore); + float patchy = smoothstep(.4, .85, sin(vW.x * .8 + uTime * .2) * sin(vW.z * .9 - uTime * .17) * .5 + .5); + col += vec3(1., .95, 1.) * (pow(nh, 60.) * .07 + pow(nh, 900.) * 1.1 * patchy * detail) * uLight; + // Fast water carries thin streaks of foam in the direction it runs. + float speed = length(flow); + vec2 along = speed > .01 ? flow / speed : vec2(1., 0.); + vec2 q = vec2(dot(vW.xz, along), dot(vW.xz, vec2(-along.y, along.x))); + float streak = smoothstep(.55, 1.2, speed) * smoothstep(.7, .95, sin(q.y * 14. + sin(q.x * .8 - uTime * 2. * sign(speed + 1e-4)) * 2.) * .5 + .5) * .35 * detail; + // Where water meets rock it laps: a thin, broken line of foam, only at a real edge. + 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 + streak) * detail; + col = mix(col, vec3(.82, .78, .9) * (.5 + .5 * uLight), clamp(foam, 0., 1.)); + float alpha = .24 + absorb * .58 + fres * 1.2 + foam; + gl_FragColor = vec4(col, clamp(alpha, 0., .94) * smoothstep(.012, .04, d)); }`, }); const water = new T.Mesh(grid, waterMaterial); @@ -264,20 +302,45 @@ async function initialize() { } gridTexture.needsUpdate = true; } + // 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 + 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. + const speed = rate * .75 * Math.min(1, (w[k] - h[k]) / .3); + fx = gx / len * speed; fz = gz / len * speed; + } + data[k * 2] = toHalf(fx); data[k * 2 + 1] = toHalf(fz); + } + flowTexture.needsUpdate = true; + } // 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({ transparent: true, depthWrite: false, blending: T.AdditiveBlending, uniforms: { uTime: shared.uTime, uLight: shared.uLight, uRipples: shared.uRipples }, vertexShader: 'varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }', - fragmentShader: `precision mediump float; + fragmentShader: ` varying vec3 vW; uniform float uTime, uLight; uniform vec4 uRipples[12]; void main() { float t = uTime * .15; vec2 p = vW.xz; vec2 q = p + vec2(sin(p.y * 1.7 + t), cos(p.x * 1.3 - t)) * .23; float f = sin(q.x * 5. + sin(q.y * 3. + t)) + sin(q.y * 5. - t) + sin((q.x + q.y) * 3.5 + t * .7); - float light = pow(max(0., 1. - abs(f) * .7), 24.) * .03 * (.4 + .6 * uLight); + float light = pow(max(0., 1. - abs(f) * .7), 24.) * .012 * (.4 + .6 * uLight); for (int i = 0; i < 12; i++) { float age = uTime - uRipples[i].z; float d = length(p - uRipples[i].xy); @@ -727,10 +790,11 @@ async function initialize() { shared.uLight.value = glow; shared.uWater.value = waterLevel(world); uploadGrid(); + uploadFlow(1 / 30); 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. const dayAngle = world.time / 420 * Math.PI * 2; - sun.position.set(view.x - 10 + Math.cos(dayAngle) * 6, 18, view.z + 8 + Math.sin(dayAngle) * 4); + sun.position.set(view.x - 14 + Math.cos(dayAngle) * 5, 10, view.z - 4 + Math.sin(dayAngle) * 4); sun.target.position.set(view.x, 0, view.z); shared.uView.value.subVectors(camera.position, v1.set(view.x, -.5, view.z)).normalize(); // The water's glint light sits on the glitter path: mirrored about a near-level surface, so ripples catch it. @@ -831,6 +895,13 @@ async function initialize() { return b === 'sea' ? (short ? 'Sea' : 'in the sea') : b === 'puddle' ? (short ? 'Puddle' : 'in a puddle') : b ? (short ? b : `in pool ${b}`) : (short ? 'Shelf' : 'on the dry shelf'); }; const duration = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; + const DEBUT = { + pylon: 'It filters the water, and folds shut when the water is disturbed.', + borer: 'It digs.', + collector: 'It hoards brass.', + mason: 'It fills hollows and builds walls.', + breaker: 'It hunts.', + }; function eventText(e, subject) { const who = e.who === subject ? 'It' : e.who; switch (e.type) { @@ -847,6 +918,8 @@ async function initialize() { case 'shelter': return `${who} moved into a shell.`; case 'crack': return `${who} cracked ${e.other}'s shell.`; case 'arrive': return `${who} arrived from the sea.`; + case 'debut': return `A ${SPECIES[e.sp].name.toLowerCase()} has arrived. ${DEBUT[e.sp]}`; + case 'form': return `Pool ${e.detail} has formed.`; case 'inspect': return `${who} inspected ${e.detail}.`; case 'grow': return `${who} grew a segment.`; case 'wash': return `The tide brought ${e.detail}.`; @@ -932,7 +1005,7 @@ async function initialize() { const ticker = $('#tide-ticker'); let tickerSeen = 0; function tick() { - const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash'].includes(e.type)); + const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { @@ -943,6 +1016,8 @@ async function initialize() { li.append(time, text); ticker.append(li); chime(e.type, e.type === 'end' ? .05 : .035); + // Arrivals are news: they show in the status line even before the visitor has touched anything. + if (e.type === 'debut' && selected === null) { status.textContent = text; status.dataset.news = 'true'; } } while (ticker.children.length > 4) ticker.firstElementChild.remove(); } @@ -998,6 +1073,7 @@ async function initialize() { pebble: 'A mason will set it on a cairn.', feed: 'Film and plankton bloom where it lands.', }; function drop(x, z) { + delete status.dataset.news; if (!onShelf(world, x, z)) { status.textContent = 'Tap on the shelf, not beyond it.'; return; } const o = offerObject(world, tool, x, z); if (!o) { status.textContent = 'The pool is full of keepsakes. Begin again for a fresh pool.'; return; } diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index 6e08270..ac2dd41 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -10,14 +10,25 @@ function run(world, seconds) { } const pool = { x: START_POOL.x, z: START_POOL.z }; -test('the shelf begins with exactly one pool, and the tide floods it and leaves it standing', () => { +test('the shelf begins with one pool and a few creatures, and introduces each species one at a time', () => { const world = createWorld(); assert.deepEqual(world.bodies.map(b => b.name), ['I']); + assert.ok(world.creatures.length <= 10, `${world.creatures.length} creatures at the start`); + assert.deepEqual([...new Set(world.creatures.map(c => c.sp))].sort(), ['scraper', 'tab']); + const order = []; + for (let s = 0; s < 240; s++) { run(world, 1); for (const e of world.events) if (e.type === 'debut' && !order.includes(e.sp)) order.push(e.sp); } + assert.deepEqual(order, ['pylon', 'borer', 'collector', 'mason', 'breaker']); +}); + +test('the tide floods the first pool and leaves it standing', () => { + const world = createWorld(); assert.equal(bodyAt(world, pool.x, pool.z), 'I'); // Run to low water: the shelf dries, the pool keeps its water above its own lip. while (waterLevel(world) > TIDE_LOW + .02) run(world, 2); assert.ok(depthAt(world, pool.x, pool.z) > .3, 'the pool holds water at low tide'); - assert.ok(depthAt(world, pool.x + 12, pool.z) < WET, 'open shelf is dry at low tide'); + let dry = 0, land = 0; + 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. while (waterLevel(world) < TIDE_HIGH - .02) run(world, 2); @@ -27,6 +38,7 @@ test('the shelf begins with exactly one pool, and the tide floods it and leaves test('borers dig burrows and tunnels, pools form on their own, and the ground visibly changes', () => { const world = createWorld(); const before = Float32Array.from(world.h); + run(world, 62); const borer = world.creatures.find(c => c.sp === 'borer'); const home = { ...borer.home }; const seen = new Set(); @@ -42,11 +54,13 @@ test('borers dig burrows and tunnels, pools form on their own, and the ground vi assert.ok(seen.has('form') || world.bodies.length > 1, 'a new pool formed'); }); -test('masons fill hollows and raise walls, and slopes never stand steeper than the talus limit', () => { +test('masons fill hollows and raise walls, and reshaped ground never stands steeper than the talus limit', () => { const world = createWorld(7); + const original = Float32Array.from(world.h); const types = new Set(); for (let minute = 0; minute < 15; minute++) { run(world, 60); for (const e of world.events) types.add(e.type); } assert.ok(types.has('build') || types.has('fill'), 'a mason set material'); + assert.ok(types.has('dam'), 'a mason raised a dam across a pool outlet'); let built = 0; for (const b of world.built) if (b > .05) built++; assert.ok(built > 0, 'set stone is on the ground'); @@ -54,13 +68,19 @@ test('masons fill hollows and raise walls, and slopes never stand steeper than t const cols = 128, rows = world.h.length / cols; for (let j = 1; j < rows - 3; j++) for (let i = 1; i < cols - 2; i++) { const k = j * cols + i; - steepest = Math.max(steepest, Math.abs(world.h[k] - world.h[k + 1]), Math.abs(world.h[k] - world.h[k + cols])); + // Natural rock can be sheer; only ground the creatures have dug or piled must settle. + for (const n of [k + 1, k + cols]) { + if (Math.abs(world.h[k] - original[k]) < .05 || Math.abs(world.h[n] - original[n]) < .05) continue; + steepest = Math.max(steepest, Math.abs(world.h[k] - world.h[n])); + } } assert.ok(steepest < .45, `steepest step ${steepest.toFixed(2)}`); }); test('a visitor offering draws a free specialist immediately, never one with a carried task', () => { 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; } 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'); @@ -70,7 +90,7 @@ test('a visitor offering draws a free specialist immediately, never one with a c assert.equal(world.creatures.find(c => c.task?.object === shell.id).sp, 'scraper'); const pebble = offerObject(world, 'pebble', pool.x - 1, pool.z); assert.equal(world.creatures.find(c => c.task?.object === pebble.id).sp, 'mason'); - const carrier = world.creatures.find(c => c.sp === 'collector' && c !== collector); + const carrier = world.creatures.find(c => c.sp === 'breaker'); const held = dropObject(world, 'brass', pool.x, pool.z + 1); held.place = 'carried'; held.claimed = carrier.id; carrier.carrying = held.id; for (let i = 0; i < 6; i++) offerObject(world, 'brass', pool.x + i * .2, pool.z); @@ -161,6 +181,7 @@ test('carried objects stay with their sole owner, and core machines return brass test('pylons fold shut when the water is disturbed and reopen once it is still', () => { const world = createWorld(); + run(world, 26); const pylon = world.creatures.find(c => c.sp === 'pylon'); assert.equal(pylon.open, 1); dropObject(world, 'pebble', pylon.x + .3, pylon.z); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 7cac41a..0bc8f48 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -138,7 +138,7 @@ export function TidePoolContent() {

Rendered with Three.js. Software license.

- + ); } diff --git a/src/index.tsx b/src/index.tsx index 991e39d..695fc36 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -355,7 +355,7 @@ app.get("/artifacts", async (c) => { // Commissioned public artwork. Other artifact routes retain their existing privacy metadata. app.get("/artifacts/tide-pool", async (c) => { const stream = renderToReadableStream( - + );