diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 3c7350a..d142036 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -1,7 +1,170 @@ -// A bounded, synthetic ecology. Coordinates and time are independent of rendering. -export const LIMIT = 28; -export const insidePool = (x, z) => (x / 4.45) ** 2 + (z / 3.15) ** 2 < 1; +// A bounded, synthetic ecology across connected rock pools. +// Coordinates and time are independent of rendering. Every choice draws on the world's seeded random. + +export const CELL = .5; +export const TIDE_PERIOD = 240; +export const DAY_PERIOD = 420; +export const LIMIT = 72; + +// Five rock pools on a shelf: four along the shore, one seaward that the tide refills. +// Each is an ellipse with a few slow harmonics, so no two edges repeat. +const SHAPES = [ + { id: 0, name: 'I', cx: -16, cz: 0, rx: 4, rz: 3, depth: .55, wob: [.14, .4, .08, 1.3, .05, 2.1] }, + { id: 1, name: 'II', cx: -6, cz: -1, rx: 4, rz: 3, depth: .85, wob: [.1, 2.2, .12, .5, .06, 4] }, + { id: 2, name: 'III', cx: 4.5, cz: 0, rx: 4.5, rz: 3, depth: .75, wob: [.12, 1.1, .07, 2.8, .05, .3] }, + { id: 3, name: 'IV', cx: 14.5, cz: -1.5, rx: 3.5, rz: 2.5, depth: .45, wob: [.16, 3.1, .1, 1.7, .06, 5.2] }, + { id: 4, name: 'V', cx: 5, cz: 7.5, rx: 3, rz: 2.5, depth: 1.2, sea: true, wob: [.08, .9, .1, 4.2, .05, 1.1] }, +]; +const shapeR = (p, a) => 1 - p.wob[0] * (1 + Math.cos(2 * a + p.wob[1])) / 2 - + p.wob[2] * (1 + Math.cos(3 * a + p.wob[3])) / 2 - p.wob[4] * (1 + Math.cos(5 * a + p.wob[5])) / 2; +export const rho = (p, x, z) => { + const u = (x - p.cx) / p.rx, v = (z - p.cz) / p.rz; + return Math.hypot(u, v) / shapeR(p, Math.atan2(v, u)); +}; +// Approximate distance inside the rim; negative outside. +export const edgeDistance = (p, x, z) => (1 - rho(p, x, z)) * Math.min(p.rx, p.rz) * .9; + +export const POOLS = SHAPES.map(p => { + const pool = { ...p, x0: p.cx - p.rx, x1: p.cx + p.rx, z0: p.cz - p.rz, z1: p.cz + p.rz }; + pool.nx = Math.round((pool.x1 - pool.x0) / CELL); pool.nz = Math.round((pool.z1 - pool.z0) / CELL); + pool.mask = []; + for (let j = 0; j < pool.nz; j++) for (let i = 0; i < pool.nx; i++) + pool.mask.push(edgeDistance(pool, pool.x0 + (i + .5) * CELL, pool.z0 + (j + .5) * CELL) > .12 ? 1 : 0); + pool.cells = pool.mask.reduce((a, b) => a + b, 0); + return pool; +}); + +// Narrow gullies through the rock. Each runs well into both pools. +export const CHANNELS = [ + { a: 0, b: 1, x0: -13.8, x1: -8.8, z0: -1, z1: 0, sill: .4 }, + { a: 1, b: 2, x0: -3.2, x1: 1.2, z0: -1, z1: 0, sill: .5 }, + { a: 2, b: 3, x0: 7.8, x1: 12.2, z0: -1, z1: 0, sill: .35 }, + { a: 2, b: 4, x0: 4, x1: 5, z0: 1.8, z1: 6.2, sill: .6 }, +].map((c, id) => ({ ...c, id, axis: c.x1 - c.x0 > c.z1 - c.z0 ? 'x' : 'z' })); + +export const SPECIES = { + scraper: { code: 'SC', name: 'Scraper', kind: 'walker', speed: .4, max: 24, start: 12, min: 4, burn: .0055, radius: .28 }, + tab: { code: 'TB', name: 'Tab', kind: 'swimmer', speed: 1.05, max: 40, start: 16, min: 5, burn: .007, radius: .14 }, + pylon: { code: 'PY', name: 'Pylon', kind: 'sessile', max: 14, start: 5, min: 2, burn: .0025, radius: .3 }, + collector: { code: 'CL', name: 'Collector', kind: 'walker', speed: .85, max: 7, start: 3, min: 1, burn: .0055, radius: .38, core: true }, + mason: { code: 'MS', name: 'Mason', kind: 'walker', speed: .55, max: 7, start: 3, min: 1, burn: .005, radius: .4, core: true }, + breaker: { code: 'BR', name: 'Breaker', kind: 'walker', speed: .62, max: 6, start: 2, min: 1, burn: .0075, radius: .48, core: true }, +}; +export const SPECIES_ORDER = Object.keys(SPECIES); +export const MATERIALS = ['brass', 'shell', 'pebble', 'feed']; + +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +const lerp = (a, b, t) => a + (b - a) * t; const distance = (a, b) => Math.hypot(a.x - b.x, a.z - b.z); +const inRect = (x, z, r, m) => x > r.x0 + m && x < r.x1 - m && z > r.z0 + m && z < r.z1 - m; + +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; +export const waterLevel = world => -.6 + .5 * tideOf(world.time); +export const daylight = world => { + const s = Math.sin(world.time / DAY_PERIOD * Math.PI * 2 + .45); + const t = clamp((s + .25) / .6, 0, 1); + return .22 + .78 * t * t * (3 - 2 * t); +}; +export const poolWater = (world, p) => waterLevel(world) + POOLS[p].depth; +export const channelOpen = (world, ch) => waterLevel(world) + ch.sill > .12; + +export function poolAt(x, z, m = 0) { + for (const p of POOLS) if (inRect(x, z, p, -.01) && edgeDistance(p, x, z) > m) return p; + return null; +} +// Gullies wander a little: their width and centre line change along their length. +// How far inside a gully's edge a point sits; negative outside. +function gullyInset(c, x, z) { + const along = c.axis === 'x' ? x : z, across = c.axis === 'x' ? z : x; + if (along < (c.axis === 'x' ? c.x0 : c.z0) || along > (c.axis === 'x' ? c.x1 : c.z1)) return -Infinity; + const mid = (c.axis === 'x' ? c.z0 + c.z1 : c.x0 + c.x1) / 2 + Math.sin(along * 1.3 + c.id * 2) * .08; + const half = .52 + Math.sin(along * 2.3 + c.id) * .1 + Math.sin(along * 5.1 + c.id * 3) * .05; + return half - Math.abs(across - mid); +} +export const inGully = (c, x, z, m = 0) => gullyInset(c, x, z) > m; +function channelAt(x, z, m = 0) { + for (const c of CHANNELS) if (inGully(c, x, z, m)) return c; + return null; +} +export const walkable = (x, z, m = .2) => Number.isFinite(x + z) && !!(poolAt(x, z, m) || channelAt(x, z, m)); +export const insidePool = (x, z) => Number.isFinite(x + z) && !!poolAt(x, z, .3); + +// Sandy bowls that shelve toward the rim, cut by gullies at the sill depth. Null means dry rock. +const sand = (x, z) => .025 * Math.sin(x * 3.1 + z * 1.7) * Math.sin(z * 2.3 - x * .9); +export function basinFloor(x, z) { + let y = null; + const p = poolAt(x, z); + if (p) { + const t = clamp(edgeDistance(p, x, z) / 1.1, 0, 1); + y = -p.depth * t * t * (3 - 2 * t) + sand(x, z) * t; + } + // Gullies are soft troughs: full sill depth in the walkable middle, easing up to the field at the edge. + for (const c of CHANNELS) { + const inset = gullyInset(c, x, z); + if (inset <= 0) continue; + const t = clamp(inset / .22, 0, 1); + y = Math.min(y ?? 0, -c.sill * t * t * (3 - 2 * t) + sand(x, z) * .5 * t); + } + return y; +} +export const floorY = (x, z) => basinFloor(x, z) ?? 0; + +function pullIn(p, pt, m = .5) { + let { x, z } = pt; + for (let k = 0; k < 24 && edgeDistance(p, x, z) <= m; k++) { x += (p.cx - x) * .15; z += (p.cz - z) * .15; } + return { x, z, pool: p.id }; +} +function mouth(ch, poolId) { + const p = POOLS[poolId]; + const x = ch.axis === 'x' ? (p.cx < (ch.x0 + ch.x1) / 2 ? ch.x0 + .35 : ch.x1 - .35) : (ch.x0 + ch.x1) / 2; + const z = ch.axis === 'z' ? (p.cz < (ch.z0 + ch.z1) / 2 ? ch.z0 + .35 : ch.z1 - .35) : (ch.z0 + ch.z1) / 2; + return pullIn(p, { x, z }, .3); +} +// Breadth-first routes between pools; walkers may use a gully at any tide. +const ROUTES = POOLS.map(from => { + const previous = new Map([[from.id, null]]); + const queue = [from.id]; + while (queue.length) { + const p = queue.shift(); + for (const ch of CHANNELS) { + const next = ch.a === p ? ch.b : ch.b === p ? ch.a : null; + if (next === null || previous.has(next)) continue; + previous.set(next, { pool: p, ch }); + queue.push(next); + } + } + return POOLS.map(to => { + const hops = []; + for (let at = to.id; previous.get(at); at = previous.get(at).pool) hops.unshift({ ...previous.get(at), to: at }); + return hops; + }); +}); +export const hops = (a, b) => ROUTES[a][b].length; +function route(fromPool, to) { + const path = []; + for (const step of ROUTES[fromPool][to.pool]) path.push(mouth(step.ch, step.pool), mouth(step.ch, step.to)); + path.push({ x: to.x, z: to.z }); + return path; +} +const cost = (c, o) => distance(c, o) + hops(c.pool, o.pool) * 4; + +export const label = c => `${SPECIES[c.sp].code}-${String(c.serial).padStart(3, '0')}`; + +function randomPoint(world, pool, margin = .8) { + const p = POOLS[pool]; + for (let k = 0; k < 40; k++) { + const x = p.x0 + world.random() * (p.x1 - p.x0), z = p.z0 + world.random() * (p.z1 - p.z0); + if (edgeDistance(p, x, z) > margin) return { x, z, pool }; + } + return { x: p.cx, z: p.cz, pool }; +} +const cellIndex = (p, x, z) => { + const pool = POOLS[p]; + const i = clamp(Math.floor((x - pool.x0) / CELL), 0, pool.nx - 1), j = clamp(Math.floor((z - pool.z0) / CELL), 0, pool.nz - 1); + return j * pool.nx + i; +}; +export const filmAt = (world, p, x, z) => world.film[p][cellIndex(p, x, z)]; export function createWorld(seed = 41) { let randomState = seed >>> 0; @@ -10,187 +173,824 @@ export function createWorld(seed = 41) { return randomState / 4294967296; }; const world = { - time: 0, nextId: 0, objects: [], ripples: [], events: [], random, - creatures: [ - { role: 'thief', x: -2.4, z: -0.6, color: 0xb7a1c0 }, - { role: 'builder', x: 1.9, z: -0.8, color: 0xe0dccb }, - { role: 'dismantler', x: 0.3, z: 1.8, color: 0x97bab7 }, - ].map((c, i) => ({ ...c, id: i, angle: i * 2, speed: 0, phase: i * 2, - state: 'idle', timer: i * 0.7, target: null, carrying: null, - destination: null, visited: [], gesture: 0 })), + time: 0, nextObject: 0, nextCreature: 0, nextSite: 0, random, + creatures: [], objects: [], sites: [], ripples: [], events: [], census: [], ended: [], + serials: Object.fromEntries(SPECIES_ORDER.map(s => [s, 0])), + arrivals: Object.fromEntries(SPECIES_ORDER.map(s => [s, 0])), + film: POOLS.map(p => p.mask.map(m => m ? .15 + random() * .45 * (p.sea ? .5 : 1) : 0)), + plankton: POOLS.map(p => p.sea ? .8 : .45), + nextWash: 45, nextCensus: 0, }; + const homes = { scraper: [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 0, 2], tab: [1, 2, 4], pylon: [1, 2, 4, 2, 1], collector: [1, 2, 3], mason: [0, 1, 2], breaker: [1, 3] }; + for (const sp of SPECIES_ORDER) for (let i = 0; i < SPECIES[sp].start; i++) { + const list = homes[sp], pool = list[i % list.length]; + const at = randomPoint(world, pool, sp === 'pylon' ? 1.1 : .8); + spawn(world, sp, at.x, at.z, pool, { energy: .5 + random() * .3, size: 2 + Math.floor(random() * 2) }); + } // There is something to discover even if the visitor only watches. - dropObject(world, 'brass', -1.1, 0.3); - dropObject(world, 'shell', 0.9, 0.5); - dropObject(world, 'pebble', -0.3, -1.5); + const seeds = [['brass', 2], ['shell', 1], ['pebble', 1], ['pebble', 0], ['shell', 3], ['brass', 4], ['pebble', 2], ['shell', 0]]; + for (const [kind, pool] of seeds) { + const at = randomPoint(world, pool, 1); + addObject(world, kind, at.x, at.z, { height: 0 }); + } + takeCensus(world); return world; } -export function dropObject(world, kind, x, z) { - if (!['brass', 'shell', 'pebble'].includes(kind) || !insidePool(x, z)) return null; +function count(world, sp) { + let n = 0; + for (const c of world.creatures) if (c.sp === sp) n++; + return n; +} + +function spawn(world, sp, x, z, pool, extra = {}) { + const S = SPECIES[sp]; + if (count(world, sp) >= S.max || !poolAt(x, z)) return null; + const c = { + id: world.nextCreature++, sp, serial: ++world.serials[sp], x, z, y: 0, pool, angle: world.random() * Math.PI * 2, + vx: 0, vz: 0, speed: 0, phase: world.random() * 6, energy: extra.energy ?? .45, age: 0, + 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, site: null, size: sp === 'pylon' ? extra.size ?? 1 : 1, gesture: 0, strike: 0, eaten: 0, + }; + if (S.kind === 'swimmer') { + const angle = world.random() * Math.PI * 2; + c.vx = Math.cos(angle) * .4; c.vz = Math.sin(angle) * .4; + } + if (sp === 'collector') c.home = nook(world, pool); + world.creatures.push(c); + return c; +} + +// A collector keeps its hoard in the quietest free cove of its pool. +function nook(world, pool) { + const p = POOLS[pool]; + const corners = [.75, .25, -.25, -.75].map(f => { + const a = f * Math.PI, r = shapeR(p, a) * .72; + return pullIn(p, { x: p.cx + Math.cos(a) * p.rx * r, z: p.cz + Math.sin(a) * p.rz * r }, .6); + }); + const taken = world.creatures.filter(c => c.home).map(c => c.home); + const room = at => Math.min(99, ...taken.map(t => distance(t, at) + (t.pool === at.pool ? 0 : 99))); + corners.sort((a, b) => room(b) - room(a)); + return corners[0]; +} + +function addObject(world, kind, x, z, extra = {}) { + const pool = poolAt(x, z) || poolAt(clamp(x, -20, 18), clamp(z, -4, 10)); + if (!pool) return null; if (world.objects.length >= LIMIT) { - const removable = world.objects.find(o => o.claimed === null && o.place === 'loose'); + const removable = world.objects.find(o => o.claimed === null && o.place === 'loose' && o.kind !== 'brass') || + world.objects.find(o => o.claimed === null && o.place === 'loose'); if (!removable) return null; - world.objects.splice(world.objects.indexOf(removable), 1); + removeObject(world, removable); } - const object = { id: world.nextId++, kind, x, z, height: 1.8, age: 0, - claimed: null, place: 'loose', placedAt: world.time, rotation: world.random() * Math.PI * 2 }; + const object = { id: world.nextObject++, kind, x, z, pool: pool.id, height: extra.height ?? 1.8, age: 0, + claimed: null, place: extra.place ?? 'loose', owner: extra.owner ?? null, rotation: world.random() * Math.PI * 2 }; world.objects.push(object); - ripple(world, x, z); return object; } +function removeObject(world, o) { + const i = world.objects.indexOf(o); + if (i >= 0) world.objects.splice(i, 1); + for (const c of world.creatures) { + if (c.carrying === o.id) c.carrying = null; + if (c.shell === o.id) c.shell = null; + if (c.task && c.task.object === o.id) idle(world, c); + } +} +const objectById = (world, id) => id === null || id === undefined ? null : world.objects.find(o => o.id === id) || null; +export const creatureById = (world, id) => id === null || id === undefined ? null : world.creatures.find(c => c.id === id) || null; -// A visitor's offering interrupts wandering immediately, never a carried task. +export function dropObject(world, kind, x, z) { + if (!['brass', 'shell', 'pebble'].includes(kind) || !insidePool(x, z)) return null; + const o = addObject(world, kind, x, z); + if (o) ripple(world, x, z); + return o; +} + +// A visitor's offering draws one free specialist immediately, never one with a carried task. export function offerObject(world, kind, x, z) { + if (!MATERIALS.includes(kind) || !insidePool(x, z)) return null; + if (kind === 'feed') return feed(world, x, z); const object = dropObject(world, kind, x, z); if (!object) return null; - const preferred = kind === 'brass' ? 'thief' : 'builder'; - const candidates = world.creatures.filter(c => c.carrying === null); - candidates.sort((a, b) => (a.role === preferred ? -10 : 0) + distance(a, object) - - (b.role === preferred ? -10 : 0) - distance(b, object)); - const c = candidates[0]; + const wants = kind === 'brass' ? ['collector', 'mason', 'breaker'] : kind === 'shell' ? ['scraper'] : ['mason']; + const free = world.creatures.filter(c => wants.includes(c.sp) && c.carrying === null && + !(c.sp === 'scraper' && c.shell !== null)); + free.sort((a, b) => wants.indexOf(a.sp) - wants.indexOf(b.sp) || cost(a, object) - cost(b, object)); + const c = free[0]; if (c) { - release(c, world); - c.target = object.id; - object.claimed = c.id; - c.state = 'approach'; - c.destination = { x, z }; - c.gesture = -.2; + idle(world, c); + assign(world, c, kind === 'shell' ? 'wear' : 'fetch', object, { use: kind === 'brass' && c.sp !== 'collector' ? 'breed' : undefined }); + c.gesture = -.25; } + event(world, 'offer', null, kind); return object; } +function feed(world, x, z) { + const p = poolAt(x, z); + world.plankton[p.id] = Math.min(1, world.plankton[p.id] + .3); + const film = world.film[p.id], pool = POOLS[p.id]; + for (let j = 0; j < pool.nz; j++) for (let i = 0; i < pool.nx; i++) { + const d = Math.hypot(pool.x0 + (i + .5) * CELL - x, pool.z0 + (j + .5) * CELL - z); + if (d < 1.3 && pool.mask[j * pool.nx + i]) film[j * pool.nx + i] = Math.min(1, film[j * pool.nx + i] + .45 * (1 - d / 1.3)); + } + ripple(world, x, z); + const near = world.creatures.filter(c => c.sp === 'scraper' && c.pool === p.id && c.task?.kind !== 'flee') + .sort((a, b) => distance(a, { x, z }) - distance(b, { x, z })).slice(0, 3); + for (const c of near) { idle(world, c); assign(world, c, 'graze', null, { at: { x, z, pool: p.id } }); } + event(world, 'offer', null, 'feed'); + return { id: null, kind: 'feed', x, z, pool: p.id }; +} + function ripple(world, x, z) { world.ripples.push({ x, z, born: world.time }); if (world.ripples.length > 12) world.ripples.shift(); } -function event(world, type, creature, object) { - world.events.push({ type, role: creature.role, object: object.id, time: world.time }); - if (world.events.length > 60) world.events.shift(); -} -function release(c, world) { - const object = world.objects.find(o => o.id === c.target); - if (object && object.claimed === c.id) object.claimed = null; - c.target = null; - c.state = 'idle'; - c.timer = 1.2 + world.random() * 2; - c.destination = null; -} -function choose(c, world) { - const eligible = world.objects.filter(o => o.claimed === null && o.age > 1 && - (o.place === 'loose' || (c.role === 'dismantler' && o.place === 'shelter' && world.time - o.placedAt > 18)) && - !c.visited.includes(o.id)); - const score = o => distance(c, o) - - (c.role === 'thief' && o.kind === 'brass' ? 8 : 0) - - (c.role === 'builder' && o.kind !== 'brass' ? 6 : 0) - - (c.role === 'dismantler' && o.place === 'shelter' ? 12 : 0); - eligible.sort((a, b) => score(a) - score(b)); - if (eligible.length) { - const o = eligible[0]; - o.claimed = c.id; - c.target = o.id; - c.state = 'approach'; - c.destination = { x: o.x, z: o.z }; +function event(world, type, c, detail, other) { + world.events.push({ type, time: world.time, who: c ? label(c) : null, sp: c?.sp ?? null, detail: detail ?? null, other: other ?? null }); + if (world.events.length > 80) world.events.shift(); +} + +function idle(world, c) { + const o = objectById(world, c.task?.object); + if (o && o.claimed === c.id && o.place !== 'carried') o.claimed = null; + c.task = null; c.state = 'idle'; c.path = []; c.timer = .3 + world.random() * .6; +} +function assign(world, c, kind, object, extra = {}) { + const at = extra.at || (object ? { x: object.x, z: object.z, pool: object.pool } : { x: c.x, z: c.z, pool: c.pool }); + c.task = { kind, object: object ? object.id : null, prey: extra.prey ?? null, site: extra.site ?? null, use: extra.use ?? null, at }; + if (object && object.place !== 'carried') object.claimed = c.id; + c.path = route(c.pool, at); + c.state = 'move'; + c.reach = extra.reach ?? (object ? .42 : .2); + c.timer = extra.patience ?? 40; +} +function work(c, seconds) { c.state = 'work'; c.timer = seconds; c.path = []; } + +function sheltered(world, c) { + if (c.shell !== null) return true; + return world.sites.some(s => { + if (s.pool !== c.pool || s.blocks.length < 2 || distance(s, c) >= .85) return false; + const room = Math.floor(s.blocks.length / 3) + 1; + const closer = world.creatures.filter(o => o.sp === 'scraper' && o !== c && o.shell === null && distance(s, o) < distance(s, c)).length; + return closer < room; + }); +} +function nearest(world, c, list, limit = Infinity) { + let best = null, score = limit; + for (const o of list) { + const s = cost(c, o); + if (s < score) { score = s; best = o; } + } + 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; + +function breed(world, c) { + const S = SPECIES[c.sp]; + if (!canBreed(world, c.sp)) return null; + let at = null; + for (let i = 0; i < 8 && !at; i++) { + const a = world.random() * Math.PI * 2, r = S.kind === 'sessile' ? 1.2 + world.random() * 1.4 : .5; + const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; + const clear = S.kind !== 'sessile' || world.creatures.every(o => o.sp !== 'pylon' || distance(o, { x, z }) > 1); + if (poolAt(x, z, .7) && clear) at = { x, z }; + } + if (!at) return null; + const child = spawn(world, c.sp, at.x, at.z, poolAt(at.x, at.z).id, { gen: c.gen + 1, parent: c.id, energy: .42, size: 1 }); + if (!child) return null; + c.energy -= .45; c.kids++; + child.angle = c.angle; + if (c.sp === 'tab') { child.vx = -c.vz; child.vz = c.vx; } + event(world, 'birth', c, null, label(child)); + return child; +} + +function die(world, c, cause, by = null) { + const i = world.creatures.indexOf(c); + if (i < 0) return; + world.creatures.splice(i, 1); + for (const o of world.objects) if (o.claimed === c.id) o.claimed = null; + const carried = objectById(world, c.carrying); + if (carried) { carried.place = 'loose'; carried.height = .2; carried.pool = c.pool; } + const shell = objectById(world, c.shell); + if (shell) { shell.place = 'loose'; shell.owner = null; shell.x = c.x; shell.z = c.z; shell.pool = c.pool; } + const S = SPECIES[c.sp]; + if (poolAt(c.x, c.z) && cause !== 'caught') { + if (S.core) addObject(world, 'brass', c.x + .25, c.z, { height: .3 }); + addObject(world, S.core ? 'husk' : 'scrap', c.x, c.z, { height: .15 }); + } + world.ended.push({ id: c.id, label: label(c), sp: c.sp, cause, by, time: world.time }); + if (world.ended.length > 40) world.ended.shift(); + event(world, 'end', c, cause, by); + for (const other of world.creatures) if (other.task?.prey === c.id) idle(world, other); +} + +// Decisions. Each species reads the pool and chooses one task at a time. +function think(world, c) { + if (c.sp === 'scraper') return thinkScraper(world, c); + if (c.sp === 'collector') return thinkCollector(world, c); + if (c.sp === 'mason') return thinkMason(world, c); + if (c.sp === 'breaker') return thinkBreaker(world, c); +} + +function graze(world, c, efficiency = 1) { + const pool = POOLS[c.pool]; + const film = world.film[c.pool]; + let best = null, score = -Infinity; + for (let k = 0; k < 10; k++) { + const { x, z } = pullIn(pool, { x: c.x + (world.random() - .5) * 5, z: c.z + (world.random() - .5) * 5 }, .45); + const s = film[cellIndex(c.pool, x, z)] - Math.hypot(x - c.x, z - c.z) * .05; + if (s > score) { score = s; best = { x, z, pool: c.pool }; } + } + const here = avgFilm(world, c.pool); + if (here < .2 && world.random() < .4) { + const neighbours = CHANNELS.filter(ch => ch.a === c.pool || ch.b === c.pool).map(ch => ch.a === c.pool ? ch.b : ch.a); + const richest = neighbours.sort((a, b) => avgFilm(world, b) - avgFilm(world, a))[0]; + if (richest !== undefined && avgFilm(world, richest) > here + .1) best = randomPoint(world, richest, 1); + } + assign(world, c, 'graze', null, { at: best, patience: 25 }); + c.task.efficiency = efficiency; +} +export const avgFilm = (world, p) => world.film[p].reduce((a, b) => a + b, 0) / POOLS[p].cells; + +function wander(world, c) { + const pool = world.random() < .15 ? [...CHANNELS.filter(ch => ch.a === c.pool || ch.b === c.pool)] + .map(ch => ch.a === c.pool ? ch.b : ch.a)[Math.floor(world.random() * 2)] ?? c.pool : c.pool; + assign(world, c, 'wander', null, { at: randomPoint(world, pool, 1), patience: 30 }); +} + +function thinkScraper(world, c) { + const threat = world.creatures.find(b => b.sp === 'breaker' && b.pool === c.pool && distance(b, c) < 2.6); + if (threat && !sheltered(world, c)) { + const refuge = nearest(world, c, world.sites.filter(s => s.pool === c.pool && s.blocks.length >= 2 && distance(s, threat) > distance(s, c) * .7), 5); + if (refuge) return assign(world, c, 'hide', null, { at: { x: refuge.x + .5, z: refuge.z, pool: c.pool }, patience: 12 }); + const shell = nearest(world, c, looseOf(world, ['shell']).filter(o => o.pool === c.pool), 3); + if (shell) return assign(world, c, 'wear', shell); + const away = pullIn(POOLS[c.pool], { x: c.x + (c.x - threat.x) * 2, z: c.z + (c.z - threat.z) * 2 }, .5); + return assign(world, c, 'flee', null, { at: away, patience: 5 }); + } + if (c.shell === null && world.random() < .5) { + const shell = nearest(world, c, looseOf(world, ['shell']), 4); + if (shell) return assign(world, c, 'wear', shell); + } + if (c.energy > .88 && canBreed(world, 'scraper')) { work(c, 3); c.task = { kind: 'breed' }; return; } + graze(world, c); +} + +function hoardOf(world, c) { return world.objects.filter(o => o.place === 'hoard' && o.owner === c.id); } +function brassSource(world, c, allowSteal) { + const loose = nearest(world, c, looseOf(world, ['brass']), 24); + if (loose) return { object: loose, steal: false }; + if (!allowSteal) return null; + const hoarded = nearest(world, c, world.objects.filter(o => o.place === 'hoard' && o.owner !== c.id && o.claimed === null), 18); + return hoarded ? { object: hoarded, steal: true } : null; +} + +function thinkCollector(world, c) { + if (c.carrying !== null) return assign(world, c, 'deliver', null, { at: c.home, patience: 50 }); + if (c.energy < .6) { + const food = nearest(world, c, looseOf(world, ['husk', 'scrap']), 22); + if (food) return assign(world, c, 'eat', food); + return graze(world, c, .6); + } + const hoard = hoardOf(world, c); + if (hoard.length && c.energy > .75 && canBreed(world, 'collector')) { + 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)); + if (curious.length) return assign(world, c, 'inspect', nearest(world, c, curious) || curious[0]); + if (world.random() < .5) return graze(world, c, .45); + wander(world, c); +} + +function siteFor(world, c) { + const site = world.sites.find(s => s.id === c.site); + if (site && site.blocks.length < 12) return site; + if (world.sites.length >= 10) { + const open = world.sites.filter(s => s.blocks.length < 12).sort((a, b) => cost(c, a) - cost(c, b))[0]; + if (open) { c.site = open.id; return open; } + return null; + } + for (let i = 0; i < 12; i++) { + const at = randomPoint(world, c.pool, 1.1); + const clear = world.sites.every(s => distance(s, at) > 1.7) && + world.creatures.every(o => o.sp !== 'pylon' || distance(o, at) > 1) && + CHANNELS.every(ch => !(at.x > ch.x0 - .8 && at.x < ch.x1 + .8 && at.z > ch.z0 - .8 && at.z < ch.z1 + .8)); + if (!clear) continue; + const s = { id: world.nextSite++, pool: c.pool, x: at.x, z: at.z, blocks: [], builder: c.id, angle: Math.floor(world.random() * 4) * Math.PI / 2 }; + world.sites.push(s); + c.site = s.id; + return s; + } + return null; +} + +function thinkMason(world, c) { + if (c.carrying !== null && c.energy < .25) { + const held = objectById(world, c.carrying); + if (held) { held.place = 'loose'; held.claimed = null; held.owner = null; held.height = .1; } + c.carrying = null; + } + if (c.carrying !== null) { + const o = objectById(world, c.carrying); + if (o?.kind === 'brass') { work(c, 3); c.task = { kind: 'breed' }; return; } + const site = siteFor(world, c); + if (site) return assign(world, c, 'build', null, { at: { x: site.x - .45, z: site.z, pool: site.pool }, site: site.id, patience: 50 }); + const held = objectById(world, c.carrying); + if (held) { held.place = 'loose'; held.claimed = null; held.height = .1; } + c.carrying = null; + } + if (c.energy < .4) return graze(world, c); + if (c.energy > .8 && canBreed(world, 'mason')) { + const source = brassSource(world, c, world.random() < .5); + if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); + } + const material = c.energy > .5 && nearest(world, c, looseOf(world, ['pebble', 'scrap', 'husk']), 13); + if (material && siteFor(world, c)) return assign(world, c, 'fetch', material); + graze(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')) { + const source = brassSource(world, c, true); + if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); + } + if (c.energy < .92) { + const prey = world.creatures.filter(s => s.sp === 'scraper' && s.shell === null); + const target = nearest(world, c, prey, 20); + if (target) { + const refuge = world.sites.find(s => s.pool === target.pool && s.blocks.length >= 2 && distance(s, target) < .85); + if (refuge) return assign(world, c, 'dismantle', null, { at: { x: refuge.x + .55, z: refuge.z + .2, pool: refuge.pool }, site: refuge.id, patience: 25 }); + return assign(world, c, 'hunt', null, { at: { x: target.x, z: target.z, pool: target.pool }, prey: target.id, reach: .8, patience: 18 }); + } + if (c.energy < .3) { + const shelled = nearest(world, c, world.creatures.filter(s => s.sp === 'scraper' && s.shell !== null), 16); + if (shelled) return assign(world, c, 'crack', null, { at: { x: shelled.x, z: shelled.z, pool: shelled.pool }, prey: shelled.id, reach: .5, patience: 20 }); + const pylon = nearest(world, c, world.creatures.filter(s => s.sp === 'pylon'), 16); + if (pylon) return assign(world, c, 'topple', null, { at: { x: pylon.x, z: pylon.z, pool: pylon.pool }, prey: pylon.id, reach: .55, patience: 25 }); + } + } + const idleSite = world.sites.filter(s => s.blocks.length > 3); + if (idleSite.length && world.random() < .18) { + const s = idleSite[Math.floor(world.random() * idleSite.length)]; + return assign(world, c, 'dismantle', null, { at: { x: s.x + .55, z: s.z + .2, pool: s.pool }, site: s.id, patience: 30 }); + } + wander(world, c); +} + +// Arrival at a task's destination. +function arrive(world, c) { + const t = c.task; + const o = objectById(world, t.object); + switch (t.kind) { + case 'graze': return work(c, 2.5 + world.random() * 2); + case 'hide': return work(c, 4); + case 'flee': case 'wander': return idle(world, c); + case 'inspect': event(world, 'inspect', c, o?.kind); c.seen = [...(c.seen || []).slice(-10), t.object]; return work(c, 1.6); + case 'eat': case 'wear': case 'fetch': case 'steal': + if (!o || (o.place !== 'loose' && o.place !== 'hoard') || (o.claimed !== null && o.claimed !== c.id)) return idle(world, c); + return work(c, t.kind === 'eat' ? 2.6 : 1.4); + case 'deliver': case 'build': return work(c, 1.2); + case 'breed': return work(c, 3); + case 'dismantle': return work(c, 2.4); + case 'hunt': case 'crack': case 'topple': { + const prey = creatureById(world, t.prey); + if (!prey) return idle(world, c); + if (distance(prey, c) > c.reach + .35) return idle(world, c); + return work(c, t.kind === 'hunt' ? .5 : 2.2); + } + } + idle(world, c); +} + +// Completion of the work at a destination. +function finish(world, c) { + const t = c.task; + const o = objectById(world, t?.object); + if (!t) return idle(world, c); + switch (t.kind) { + case 'eat': + if (o && o.place === 'loose') { c.energy = Math.min(1, c.energy + (o.kind === 'husk' ? .5 : .28)); event(world, 'scavenge', c, o.kind); removeObject(world, o); } + break; + case 'wear': + if (o && o.place === 'loose' && c.shell === null) { o.place = 'worn'; o.owner = c.id; o.claimed = c.id; c.shell = o.id; event(world, 'shelter', c, 'shell'); } + break; + case 'fetch': case 'steal': + if (o && (o.place === 'loose' || o.place === 'hoard')) { + if (t.kind === 'steal') event(world, 'steal', c, o.kind, label(creatureById(world, o.owner) || { sp: 'collector', serial: 0 })); + else if (o.kind === 'brass') event(world, 'collect', c, 'brass'); + else event(world, 'gather', c, o.kind); + o.place = 'carried'; o.owner = c.id; o.claimed = c.id; c.carrying = o.id; + c.task = null; c.state = 'idle'; c.timer = 0; + return; + } + break; + case 'deliver': + if (o || c.carrying !== null) { + const held = objectById(world, c.carrying); + if (held) { + const k = hoardOf(world, c).length; + const home = POOLS[c.home.pool]; + const inward = { x: c.home.x < home.cx ? 1 : -1, z: c.home.z < home.cz ? 1 : -1 }; + held.place = 'hoard'; held.owner = c.id; held.claimed = null; held.height = 0; + held.x = c.home.x + (k % 3) * .3 * inward.x; held.z = c.home.z + Math.floor(k / 3) * .3 * inward.z; held.pool = c.home.pool; + c.carrying = null; + ripple(world, held.x, held.z); + event(world, 'hoard', c, held.kind); + } + } + break; + case 'build': { + const held = objectById(world, c.carrying); + const site = world.sites.find(s => s.id === t.site); + if (held && site && site.blocks.length < 12) { + site.blocks.push(held.kind); + c.carrying = null; + removeObject(world, held); + ripple(world, site.x, site.z); + event(world, 'build', c, held.kind); + } + break; + } + case 'dismantle': { + const site = world.sites.find(s => s.id === t.site); + if (site && site.blocks.length) { + const kind = site.blocks.pop(); + const a = world.random() * Math.PI * 2; + addObject(world, kind === 'husk' ? 'scrap' : kind, site.x + Math.cos(a) * .8, site.z + Math.sin(a) * .6, { height: .5 }); + if (!site.blocks.length) world.sites.splice(world.sites.indexOf(site), 1); + c.strike = 1; + event(world, 'dismantle', c, kind); + } + break; + } + case 'breed': { + if (SPECIES[c.sp].core) { + const core = c.carrying !== null ? objectById(world, c.carrying) : hoardOf(world, c)[0]; + if (core?.kind === 'brass' && canBreed(world, c.sp) && c.energy > .5) { + if (c.carrying === core.id) c.carrying = null; + removeObject(world, core); + breed(world, c); + } else if (c.carrying !== null) { + const held = objectById(world, c.carrying); + if (held) { held.place = 'loose'; held.claimed = null; held.height = .1; } + c.carrying = null; + } + } else if (c.energy > .7) breed(world, c); + break; + } + case 'hunt': { + const prey = creatureById(world, t.prey); + if (prey && prey.shell === null && distance(prey, c) < 1.1 && !sheltered(world, prey)) { + c.energy = Math.min(1, c.energy + .45); c.strike = 1; c.eaten++; + die(world, prey, 'eaten', label(c)); + } + break; + } + case 'crack': { + const prey = creatureById(world, t.prey); + const shell = objectById(world, prey?.shell); + if (prey && shell && distance(prey, c) < c.reach + .5) { + removeObject(world, shell); c.strike = 1; + event(world, 'crack', c, 'shell', label(prey)); + } + break; + } + case 'topple': { + const prey = creatureById(world, t.prey); + if (prey && distance(prey, c) < c.reach + .4) { + c.energy = Math.min(1, c.energy + .3); c.strike = 1; + die(world, prey, 'toppled', label(c)); + } + break; + } + } + idle(world, c); +} + +function moveWalker(world, c, dt) { + const S = SPECIES[c.sp]; + const wp = c.path[0]; + if (!wp) return false; + const d = distance(c, wp); + const reach = c.path.length === 1 ? c.reach : .18; + if (d <= reach) { + c.path.shift(); + if (!c.path.length) { arrive(world, c); return false; } + return true; + } + const desired = Math.atan2(wp.x - c.x, wp.z - c.z); + const turn = Math.atan2(Math.sin(desired - c.angle), Math.cos(desired - c.angle)); + c.angle += clamp(turn, -dt * 2.6, dt * 2.6); + const dry = poolWater(world, c.pool) < .05; + const urgent = c.task?.kind === 'flee' || c.task?.kind === 'hide' || c.task?.kind === 'hunt'; + const pace = S.speed * (c.carrying !== null ? .8 : 1) * (c.shell !== null ? .7 : 1) * (dry ? .7 : 1) * (urgent ? 1.25 : 1) * (c.energy < .1 ? .6 : 1); + c.speed += (pace - c.speed) * Math.min(1, dt * 3); + const step = Math.min(d, c.speed * dt); + // Travel follows the waypoint while the articulated body turns to catch up. + const nx = c.x + (wp.x - c.x) / d * step, nz = c.z + (wp.z - c.z) / d * step; + if (walkable(nx, nz)) { c.x = nx; c.z = nz; } + else if (walkable(nx, c.z)) c.x = nx; + else if (walkable(c.x, nz)) c.z = nz; + else c.path = []; + const p = poolAt(c.x, c.z); + if (p) c.pool = p.id; + return true; +} + +function moveSwimmer(world, c, dt) { + const S = SPECIES.tab; + const pool = POOLS[c.pool]; + const wd = poolWater(world, c.pool); + // Migrate before a pool drains, or toward richer water through an open channel. + if (!c.migrate || world.random() < dt * .05) { + c.migrate = null; + const exits = CHANNELS.filter(ch => (ch.a === c.pool || ch.b === c.pool) && channelOpen(world, ch)); + const falling = !tideRising(world.time); + let best = null, score = (wd < .32 && falling ? -1 : 0) + world.plankton[c.pool]; + for (const ch of exits) { + const to = ch.a === c.pool ? ch.b : ch.a; + const s = world.plankton[to] + (POOLS[to].depth > pool.depth ? .12 : 0) - .2; + if (s > score) { score = s; best = { ch: ch.id, to }; } + } + c.migrate = best; + } + let ax = 0, az = 0, n = 0, cx = 0, cz = 0, ux = 0, uz = 0; + for (const o of world.creatures) { + if (o === c || o.sp !== 'tab' || o.pool !== c.pool) continue; + const dx = o.x - c.x, dz = o.z - c.z, d = Math.hypot(dx, dz); + if (d > 1.4 || d < 1e-4) continue; + n++; cx += dx; cz += dz; ux += o.vx; uz += o.vz; + if (d < .35) { ax -= dx / d * (.35 - d) * 6; az -= dz / d * (.35 - d) * 6; } + } + if (n) { ax += cx / n * .6 + (ux / n - c.vx) * .9; az += cz / n * .6 + (uz / n - c.vz) * .9; } + for (const o of world.creatures) { + if (o.sp !== 'pylon' && o.sp !== 'breaker') continue; + const dx = c.x - o.x, dz = c.z - o.z, d = Math.hypot(dx, dz); + if (d < .9 && d > 1e-4) { ax += dx / d * (o.sp === 'pylon' ? .8 : 1.6); az += dz / d * (o.sp === 'pylon' ? .8 : 1.6); } + } + const migrating = c.migrate !== null; + if (migrating) { + const ch = CHANNELS[c.migrate.ch]; + const target = mouth(ch, c.migrate.to); + const dx = target.x - c.x, dz = target.z - c.z, d = Math.hypot(dx, dz) || 1; + ax += dx / d * 2.2; az += dz / d * 2.2; } else { - const angle = world.random() * Math.PI * 2; - const r = Math.sqrt(world.random()) * 0.8; - c.destination = { x: Math.cos(angle) * 4 * r, z: Math.sin(angle) * 2.9 * r }; - c.state = 'wander'; + // Shy of the shallows: turn toward open water near the rim. + const e = edgeDistance(pool, c.x, c.z); + if (e < 1.1) { + const dx = pool.cx - c.x, dz = pool.cz - c.z, d = Math.hypot(dx, dz) || 1; + ax += dx / d * (1.1 - e) * 3; az += dz / d * (1.1 - e) * 3; + } + } + const a = (world.random() - .5) * 2.2; + ax += Math.cos(c.phase * .3 + a) * .5; az += Math.sin(c.phase * .3 + a) * .5; + c.vx += ax * dt; c.vz += az * dt; + const sp = Math.hypot(c.vx, c.vz); + const max = S.speed * (wd < .15 ? .3 : 1), min = .25; + if (sp > max) { c.vx *= max / sp; c.vz *= max / sp; } + else if (sp < min && sp > 1e-5) { c.vx *= min / sp; c.vz *= min / sp; } + const nx = c.x + c.vx * dt, nz = c.z + c.vz * dt; + const ok = (x, z) => poolAt(x, z, .2) || (migrating && channelAt(x, z, .25)); + if (ok(nx, nz)) { c.x = nx; c.z = nz; } + else if (ok(nx, c.z)) { c.x = nx; c.vz *= -.6; } + else if (ok(c.x, nz)) { c.z = nz; c.vx *= -.6; } + else { c.vx *= -.6; c.vz *= -.6; } + const p = poolAt(c.x, c.z); + if (p && p.id !== c.pool) { c.pool = p.id; c.migrate = null; } + c.speed = Math.hypot(c.vx, c.vz); + const desired = Math.atan2(c.vx, c.vz); + c.angle += Math.atan2(Math.sin(desired - c.angle), Math.cos(desired - c.angle)) * Math.min(1, dt * 6); + const water = waterLevel(world), floor = floorY(c.x, c.z); + c.y = Math.max(floor + .08, Math.min(water - .07, floor + .12 + (water - floor) * (.35 + .25 * Math.sin(c.phase * .21)))); +} + +function environment(world, dt) { + const light = daylight(world); + const rising = tideRising(world.time), tide = tideOf(world.time); + for (const p of POOLS) { + const wet = poolWater(world, p.id) > .05; + const film = world.film[p.id]; + for (let i = 0; i < film.length; i++) { + if (!p.mask[i]) continue; + const a = film[i]; + film[i] = wet ? a + dt * .009 * light * (a + .04) * (1 - a) : a - dt * .0015 * a; + } + const pl = world.plankton[p.id]; + world.plankton[p.id] = clamp(pl + dt * ((wet ? .004 * light * (1 - pl) : -.01 * pl) + (p.sea ? .02 * tide * (1 - pl) * (rising ? 1.4 : .6) : 0)), 0, 1); + } + for (const ch of CHANNELS) { + if (!channelOpen(world, ch)) continue; + const flow = (world.plankton[ch.a] - world.plankton[ch.b]) * .03 * dt; + world.plankton[ch.a] -= flow; world.plankton[ch.b] += flow; } } +// The open sea replaces what is lost, but only at high water. +function sea(world) { + for (const s of [...world.sites]) { + if (!s.blocks.length || world.random() > .0025 * s.blocks.length) continue; + const kind = s.blocks.pop(); + const a = world.random() * Math.PI * 2; + addObject(world, kind === 'husk' ? 'scrap' : kind, s.x + Math.cos(a) * .7, s.z + Math.sin(a) * .5, { height: .4 }); + if (!s.blocks.length) world.sites.splice(world.sites.indexOf(s), 1); + event(world, 'weather', null, kind); + } + const tide = tideOf(world.time); + if (tide < .55) return; + const sea = POOLS.find(p => p.sea); + for (const sp of SPECIES_ORDER) { + if (count(world, sp) >= SPECIES[sp].min || world.time - world.arrivals[sp] < 40) continue; + const at = randomPoint(world, sea.id, 1); + const c = spawn(world, sp, at.x, sea.z1 - 1, sea.id, { energy: .6, size: 2 }); + if (c) { world.arrivals[sp] = world.time; event(world, 'arrive', c); } + } + if (world.time >= world.nextWash) { + world.nextWash = world.time + 50 + world.random() * 50; + const r = world.random(); + const kind = r < .3 ? 'brass' : r < .55 ? 'shell' : 'pebble'; + const at = randomPoint(world, sea.id, 1); + if (addObject(world, kind, at.x, sea.z1 - 1.2, { height: .8 })) event(world, 'wash', null, kind); + } +} + +function takeCensus(world) { + world.census.push({ t: Math.round(world.time), n: SPECIES_ORDER.map(s => count(world, s)) }); + if (world.census.length > 150) world.census.shift(); +} + export function advanceWorld(world, elapsed) { const dt = Math.max(0, Math.min(elapsed, 0.05)); if (!dt) return; world.time += dt; world.ripples = world.ripples.filter(r => world.time - r.born < 3); - for (const o of world.objects) { + environment(world, dt); + for (const o of [...world.objects]) { o.age += dt; - if (o.place !== 'carried') o.height = Math.max(0, o.height - dt * 2.8); + if (o.place !== 'carried' && o.place !== 'worn') o.height = Math.max(0, o.height - dt * 2.8); + // Loose remains corrode: husks break into scrap, and scrap is eventually taken by the water. + if (o.place === 'loose' && o.claimed === null) { + if (o.kind === 'husk' && o.age > 110) { o.kind = 'scrap'; o.age = 0; } + else if (o.kind === 'scrap' && o.age > 140) removeObject(world, o); + } } - for (const c of world.creatures) { - let object = world.objects.find(o => o.id === c.target); - if ((c.state === 'approach' || c.state === 'inspect') && !object) release(c, world); + const light = daylight(world); + for (const c of [...world.creatures]) { + if (!world.creatures.includes(c)) continue; + const S = SPECIES[c.sp]; + c.age += dt; + const wd = poolWater(world, c.pool); + const dry = wd < .05; + c.energy -= dt * S.burn * (1 + c.speed * .6) * (dry && S.kind !== 'swimmer' ? .5 : 1) * (S.kind === 'sessile' ? .6 + c.size * .2 : 1); + if (S.kind === 'swimmer' && wd < .1) c.energy -= dt * .05; + if (c.energy <= 0) { die(world, c, S.kind === 'swimmer' && wd < .1 ? 'stranded' : 'starved'); continue; } + if (c.age > c.lifespan) { die(world, c, 'wore out'); continue; } + c.strike = Math.max(0, c.strike - dt * 2.5); c.timer -= dt; - if (c.state === 'idle' && c.timer <= 0) { - choose(c, world); - object = world.objects.find(o => o.id === c.target); - } - if (c.state === 'inspect') { - c.gesture = Math.sin((1.7 - c.timer) * 5) * 0.14; - if (c.timer <= 0 && object) { - c.visited.push(object.id); - if (c.visited.length > LIMIT) c.visited.shift(); - const stealing = c.role === 'thief' && object.kind === 'brass'; - const building = c.role === 'builder' && object.kind !== 'brass'; - const dismantling = c.role === 'dismantler' && object.place === 'shelter'; - if (stealing || building || dismantling) { - c.carrying = object.id; - object.place = 'carried'; - c.state = 'carry'; - const count = world.objects.filter(o => o.place === (stealing ? 'hoard' : 'shelter')).length; - const a = count * 2.4; - c.destination = stealing ? { x: -3.0 + Math.cos(a) * 0.35, z: -1.1 + Math.sin(a) * 0.3 } : - building ? { x: 2.2 + Math.cos(a) * 0.65, z: -1.25 + Math.sin(a) * 0.48 } : - { x: (world.random() - 0.5) * 3, z: 1.65 + world.random() * 0.65 }; - event(world, stealing ? 'steal' : building ? 'gather' : 'dismantle', c, object); - } else release(c, world); + if (S.kind === 'swimmer') { + const pl = world.plankton[c.pool]; + if (!dry) { c.energy = Math.min(1, c.energy + pl * .04 * dt); world.plankton[c.pool] = Math.max(0, pl - .0006 * dt); } + if (c.energy > .85 && canBreed(world, 'tab') && world.random() < dt * .08) breed(world, c); + moveSwimmer(world, c, dt); + } else if (S.kind === 'sessile') { + const pl = world.plankton[c.pool]; + if (!dry) { c.energy = Math.min(1, c.energy + pl * .008 * light * dt); world.plankton[c.pool] = Math.max(0, pl - .0003 * dt); } + if (c.timer <= 0) { + c.timer = 1; + const reach = .45 + c.size * .1; + const tab = world.creatures.find(t => t.sp === 'tab' && t.pool === c.pool && distance(t, c) < reach); + if (tab && !dry && c.energy < .85 && world.random() < .16) { + c.energy = Math.min(1, c.energy + .22); c.strike = 1; c.eaten++; + die(world, tab, 'caught', label(c)); + } + if (c.energy > .72 && c.size < 5 && world.random() < .04) { c.size++; c.energy -= .1; event(world, 'grow', c); } + else if (c.energy < .18 && c.size > 1) { c.size--; c.energy += .06; } + if (c.size >= 4 && c.energy > .8 && world.random() < .04) breed(world, c); } - } else c.gesture *= Math.exp(-8 * dt); - - let moving = false; - if (c.destination && ['approach', 'carry', 'wander'].includes(c.state)) { - const d = distance(c, c.destination); - if (d > (c.state === 'approach' ? 0.44 : 0.12)) { - const desired = Math.atan2(c.destination.x - c.x, c.destination.z - c.z); - const turn = Math.atan2(Math.sin(desired - c.angle), Math.cos(desired - c.angle)); - c.angle += Math.max(-dt * 2, Math.min(dt * 2, turn)); - const pace = c.role === 'thief' ? 0.85 : c.role === 'builder' ? 0.55 : 0.43; - c.speed += (pace * (c.state === 'carry' ? 0.75 : 1) - c.speed) * Math.min(1, dt * 3); - // Travel follows the target while the articulated body turns to catch up. - c.x += (c.destination.x - c.x) / d * c.speed * dt; - c.z += (c.destination.z - c.z) / d * c.speed * dt; - moving = true; - } else if (c.state === 'approach') { - c.state = 'inspect'; c.timer = 1.7; - event(world, 'investigate', c, object); - } else if (c.state === 'carry' && object) { - object.x = c.destination.x; object.z = c.destination.z; object.height = 0; - object.place = c.role === 'thief' ? 'hoard' : c.role === 'builder' ? 'shelter' : 'loose'; - object.placedAt = world.time; - if (c.role === 'builder') { - // The dismantler may revisit a stone after it becomes architecture. - world.creatures[2].visited = world.creatures[2].visited.filter(id => id !== object.id); - event(world, 'build', c, object); + c.gesture = Math.sin(c.phase * .5) * .2; + } else { + if (c.state === 'idle' && c.timer <= 0) think(world, c); + else if (c.state === 'move') { + // Hunts re-aim at moving prey; fleeing prey re-check the threat. + if (c.task?.prey !== null && c.task?.prey !== undefined && c.path.length <= 1) { + const prey = creatureById(world, c.task.prey); + if (!prey) idle(world, c); + else if (distance(prey, c.task.at) > .5 && prey.pool === c.pool) { c.task.at = { x: prey.x, z: prey.z, pool: prey.pool }; c.path = [{ x: prey.x, z: prey.z }]; } } - if (c.role === 'dismantler') world.creatures[1].visited = world.creatures[1].visited.filter(id => id !== object.id); - ripple(world, object.x, object.z); - c.carrying = null; - release(c, world); - } else release(c, world); + if (c.sp === 'scraper' && c.timer % 1 < dt && c.task?.kind === 'graze') { + const threat = world.creatures.some(b => b.sp === 'breaker' && b.pool === c.pool && distance(b, c) < 2); + if (threat && !sheltered(world, c)) idle(world, c); + } + if (c.state === 'move' && c.timer <= 0) idle(world, c); + else if (c.state === 'move') moveWalker(world, c, dt); + } else if (c.state === 'work') { + const t = c.task; + if (t?.kind === 'graze' && !dry) { + const film = world.film[c.pool], i = cellIndex(c.pool, c.x, c.z); + const take = Math.min(film[i], .06 * dt); + film[i] -= take; + c.energy = Math.min(1, c.energy + take * (c.sp === 'scraper' ? 1.7 : 2.4) * (t.efficiency ?? 1)); + } + if (c.sp === 'scraper' && (t?.kind === 'graze' || t?.kind === 'hide') && c.timer % .5 < dt) { + const threat = world.creatures.some(b => b.sp === 'breaker' && b.pool === c.pool && distance(b, c) < 2); + if (threat && !sheltered(world, c)) { idle(world, c); c.timer = 0; } + } + c.gesture = Math.sin(c.age * 6) * .15; + if (c.state === 'work' && c.timer <= 0) finish(world, c); + } + if (c.state !== 'move') c.speed *= Math.exp(-8 * dt); + if (c.state !== 'work') c.gesture *= Math.exp(-6 * dt); } - if (!moving) c.speed *= Math.exp(-8 * dt); c.phase += dt * (1.2 + c.speed * 9); } - // Yield a little space rather than walking through another ceramic body. - for (let i = 0; i < world.creatures.length; i++) for (let j = i + 1; j < world.creatures.length; j++) { - const a = world.creatures[i], b = world.creatures[j]; + separate(world, dt); + for (const c of world.creatures) { + const carried = objectById(world, c.carrying); + if (carried) { + const reach = c.sp === 'breaker' ? .55 : c.sp === 'mason' ? 0 : .42; + carried.x = c.x + Math.sin(c.angle) * reach; + carried.z = c.z + Math.cos(c.angle) * reach; + carried.pool = c.pool; + carried.height = (c.sp === 'mason' ? .42 : .22) + Math.sin(c.phase) * .02; + } + const shell = objectById(world, c.shell); + if (shell) { shell.x = c.x; shell.z = c.z; shell.pool = c.pool; shell.height = .12; shell.rotation = c.angle; } + } + if (Math.floor(world.time) !== Math.floor(world.time - dt)) sea(world); + if (world.time >= world.nextCensus) { world.nextCensus = world.time + 4; takeCensus(world); } +} + +// Yield a little space rather than walking through another body. +function separate(world, dt) { + const walkers = world.creatures.filter(c => SPECIES[c.sp].kind === 'walker'); + for (let i = 0; i < walkers.length; i++) for (let j = i + 1; j < walkers.length; j++) { + const a = walkers[i], b = walkers[j]; + if (a.task?.prey === b.id || b.task?.prey === a.id) continue; + if (Math.abs(a.x - b.x) > 1.1 || Math.abs(a.z - b.z) > 1.1) continue; + const min = SPECIES[a.sp].radius + SPECIES[b.sp].radius; const d = distance(a, b); - if (d >= 1.12) continue; - const dx = d > .001 ? (a.x - b.x) / d : 1; - const dz = d > .001 ? (a.z - b.z) / d : 0; - const nudge = (1.12 - d) * Math.min(.5, dt * 3); + if (d >= min) continue; + const dx = d > .001 ? (a.x - b.x) / d : 1, dz = d > .001 ? (a.z - b.z) / d : 0; + const nudge = (min - d) * Math.min(.5, dt * 3); for (const [c, sign] of [[a, 1], [b, -1]]) { const x = c.x + dx * nudge * sign, z = c.z + dz * nudge * sign; - if (insidePool(x, z)) { c.x = x; c.z = z; } + if (walkable(x, z)) { c.x = x; c.z = z; } } } - for (const c of world.creatures) { - const carried = world.objects.find(o => o.id === c.carrying); - if (carried) { - carried.x = c.x + Math.sin(c.angle) * 0.72; - carried.z = c.z + Math.cos(c.angle) * 0.72; - carried.height = 0.43 + Math.sin(c.phase) * 0.035; + for (const c of walkers) for (const p of world.creatures) { + if (p.sp !== 'pylon' || Math.abs(p.x - c.x) > 1) continue; + const min = SPECIES[c.sp].radius + .22, d = distance(c, p); + if (d < min && d > .001) { + const x = c.x + (c.x - p.x) / d * (min - d), z = c.z + (c.z - p.z) / d * (min - d); + if (walkable(x, z)) { c.x = x; c.z = z; } } } } + +const TASK_TEXT = { + graze: 'grazing the film on the floor', hide: 'sheltering beside a cairn', flee: 'backing away from a breaker', + wander: 'walking the basin', inspect: 'inspecting a new arrival', eat: 'salvaging a husk', wear: 'moving into a shell', + fetch: 'going for', steal: 'lifting brass from another hoard', deliver: 'returning brass to its hoard', + build: 'setting a stone on its cairn', breed: 'building a new body', dismantle: 'pulling a cairn apart', hunt: 'stalking', + crack: 'cracking a shell open', topple: 'toppling a pylon', +}; +export function goalText(world, c) { + const S = SPECIES[c.sp]; + if (S.kind === 'swimmer') { + if (poolWater(world, c.pool) < .1) return 'stranded in draining water'; + if (c.migrate) return `swimming toward pool ${POOLS[c.migrate.to].name}`; + return 'schooling and filtering the water'; + } + if (S.kind === 'sessile') return c.strike > .2 ? 'closing on a catch' : 'filtering the water'; + const t = c.task; + if (!t) return c.carrying !== null ? 'deciding where to take its load' : 'pausing'; + if (t.kind === 'fetch') { + const o = objectById(world, t.object); + return `going for ${o?.kind || 'material'}${t.use === 'breed' ? ' to build a new body' : ''}`; + } + if (t.kind === 'hunt') { + const prey = creatureById(world, t.prey); + return prey ? `stalking ${label(prey)}` : 'stalking'; + } + return TASK_TEXT[t.kind] || t.kind; +} + +export function describeWorld(world) { + const tide = tideOf(world.time); + const n = SPECIES_ORDER.map(s => `${count(world, s)} ${SPECIES[s].name.toLowerCase()}${count(world, s) === 1 ? '' : 's'}`); + const hoard = world.objects.filter(o => o.place === 'hoard').length; + const blocks = world.sites.reduce((a, s) => a + s.blocks.length, 0); + const dry = POOLS.filter(p => poolWater(world, p.id) < .05).map(p => p.name); + const recent = world.events.filter(e => e.type === 'end' || e.type === 'birth').slice(-2).map(e => + e.type === 'birth' ? `${e.who} cast ${e.other}.` : `${e.who} ${e.detail === 'eaten' ? `was taken by ${e.other}` : e.detail === 'caught' ? `was caught by ${e.other}` : e.detail}.`); + return `${daylight(world) > .6 ? 'Day' : daylight(world) > .35 ? 'Dusk light' : 'Night'}, tide ${tide > .66 ? 'high' : tide < .33 ? 'low' : 'middling'} and ${tideRising(world.time) ? 'rising' : 'falling'}. ` + + `${dry.length ? `Pool ${dry.join(' and ')} ${dry.length > 1 ? 'are' : 'is'} dry. ` : ''}` + + `The pools hold ${n.join(', ')}. ${hoard} brass pieces hoarded; ${blocks} stones stacked in ${world.sites.length} cairns. ${recent.join(' ')}`; +} diff --git a/public/tide-pool.css b/public/tide-pool.css index 77f02a4..68f39bb 100644 --- a/public/tide-pool.css +++ b/public/tide-pool.css @@ -1,68 +1,157 @@ -/* Commissioned full-viewport edition: a fixed dark field with shared typography. */ +/* Full-viewport edition: a dark rocky shore under the site's hairline, square-cornered instruments. */ body:has(.tide-pool) { margin: 0; background: #0a0a0a; } .page-container:has(.tide-pool) { max-width: none; padding: 0; } body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { display: none; } -.tide-pool { position: relative; height: 100svh; min-height: 580px; overflow: hidden; color: #e5e5e5; --site-text: #e5e5e5; --site-border: #48444b; --site-link: #d4b9df; } -.tide-heading { position: absolute; z-index: 2; top: max(24px, env(safe-area-inset-top)); left: clamp(20px, 4vw, 64px); pointer-events: none; } -.tide-exit { pointer-events: auto; display: inline-flex; min-height: 44px; align-items: center; font-size: .8rem; } +.tide-pool { --ink: #e5e5e5; --muted: #9a949e; --line: #3a373d; --panel: #0c0b0dee; --accent: #d4b9df; + position: relative; height: 100svh; min-height: 560px; overflow: hidden; color: var(--ink); + --site-text: #e5e5e5; --site-border: #48444b; --site-link: #d4b9df; font-size: 14px; } .tide-pool a { color: #d3cbd6; text-underline-offset: .3em; text-decoration-color: #a58aad; } -.tide-heading h1 { margin: .45rem 0 .55rem; font: 400 clamp(2.6rem, 5.4vw, 5.7rem)/1.05 var(--site-font-display); letter-spacing: -.02em; } -.tide-heading p { margin: 0; color: #b0a9b5; font-size: .85rem; } +.tide-pool :is(button, a, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.tide-pool button { font: inherit; border-radius: 0; cursor: pointer; } +.tide-pool button:disabled { opacity: .45; cursor: default; } +.tide-kicker, .tide-hud dt, .tide-census h2, .tide-facts dt { margin: 0; font-size: .64rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); } + +.tide-heading { position: absolute; z-index: 2; top: max(20px, env(safe-area-inset-top)); left: clamp(16px, 3.4vw, 56px); pointer-events: none; } +.tide-exit { pointer-events: auto; display: inline-flex; min-height: 44px; align-items: center; font-size: .8rem; } +.tide-heading h1 { margin: .3rem 0 .4rem; font: 400 clamp(2.4rem, 5vw, 5.2rem)/1 var(--site-font-display); letter-spacing: -.01em; text-transform: uppercase; } +.tide-heading p { margin: 0; color: #b0a9b5; font-size: .82rem; } + .tide-figure { position: absolute; inset: 0; margin: 0; } .tide-stage { width: 100%; height: 100%; background: #0a0a0a; } .tide-stage canvas, .tide-still { display: block; width: 100%; height: 100%; } -.tide-stage canvas { position: absolute; inset: 0; touch-action: pan-y; cursor: crosshair; } +.tide-stage canvas { position: absolute; inset: 0; touch-action: none; cursor: grab; } +.tide-stage canvas.is-dragging { cursor: grabbing; } +.tide-stage canvas.is-dropping { cursor: crosshair; } .tide-stage [hidden] { display: none; } -.tide-stage canvas:focus-visible { outline: 2px solid #d4b9df; outline-offset: -7px; } -.tide-figure figcaption { position: absolute; bottom: 186px; left: 20px; right: 20px; text-align: center; color: #f0e7f3; font-size: clamp(.9rem, 1.5vw, 1.15rem); pointer-events: none; text-shadow: 0 2px 8px #000; } -.tide-figure figcaption span { display: block; color: #aaa1af; margin-top: .4rem; font-size: .78rem; } -.tide-controls { position: absolute; z-index: 2; left: 50%; bottom: max(35px, env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(560px, calc(100% - 40px)); } -.tide-controls button, #tide-describe { min-height: 46px; padding: .6rem .9rem; color: #ddd6e1; background: #0d0b10e8; border: 1px solid #48414d; font: inherit; font-size: .8rem; cursor: pointer; border-radius: 0; } -.tide-controls button:hover:not(:disabled), #tide-describe:hover { background: #28222e; } -.tide-pool :is(button, a, summary):focus-visible { outline: 2px solid #d4b9df; outline-offset: 3px; } -.tide-controls button:disabled, #tide-describe:disabled { opacity: .45; cursor: default; } -.tide-offering { display: flex; gap: .7rem; } -.tide-materials { display: flex; flex: 1; min-width: 0; } -.tide-materials button { display: flex; justify-content: center; align-items: center; gap: .5rem; flex: 1; } -.tide-materials button + button { border-left: 0; } -.tide-materials button[aria-pressed="true"] { color: #fff; box-shadow: inset 0 -2px #d4b9df; background: #211b28; } +.tide-label { position: absolute; left: 0; top: 0; z-index: 1; pointer-events: none; white-space: nowrap; font-size: .62rem; letter-spacing: .14em; text-transform: uppercase; color: #cfc8c0; text-shadow: 0 1px 4px #000; transition: opacity .3s; } +.tide-label b { font: 400 1.5rem/1 var(--site-font-display); letter-spacing: 0; margin-right: .45rem; vertical-align: -.2rem; color: #ece6df; } +.tide-stage canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -6px; } +.tide-figure figcaption { position: absolute; bottom: 168px; left: 16px; right: 16px; text-align: center; color: #f0e7f3; font-size: clamp(.9rem, 1.4vw, 1.05rem); pointer-events: none; text-shadow: 0 2px 8px #000; transition: opacity .6s; } +.tide-figure figcaption span { display: block; color: #aaa1af; margin-top: .35rem; font-size: .75rem; } +.tide-pool[data-engaged] figcaption, .tide-pool:has(.tide-inspect:not([hidden])) figcaption { opacity: 0; } + +/* Readout: clock, tide and census, set as a hairline table. */ +.tide-hud { position: absolute; z-index: 3; top: 76px; right: clamp(16px, 3.4vw, 56px); width: 244px; padding: .1rem .8rem .5rem; background: #0b0a0cd0; border: 1px solid var(--line); box-sizing: border-box; } +.tide-clock { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.tide-clock div { padding: .5rem 0 .55rem; } +.tide-clock div + div { padding-left: .7rem; border-left: 1px solid var(--line); } +.tide-clock dd { margin: .2rem 0 0; font-variant-numeric: tabular-nums; font-size: .92rem; } +.tide-census { margin-top: .9rem; } +.tide-census h2 { margin-bottom: .45rem; } +#tide-chart { display: block; width: 100%; height: 56px; border-bottom: 1px solid var(--line); } +.tide-census ul { list-style: none; margin: 0; padding: 0; } +.tide-census button { display: grid; grid-template-columns: 2.4rem 1fr auto; align-items: center; width: 100%; min-height: 30px; padding: 0; background: none; border: 0; border-bottom: 1px solid #1f1d21; color: #cfc8d3; text-align: left; font-size: .78rem; } +.tide-census button:hover:not(:disabled), .tide-census button[aria-current="true"] { color: #fff; } +.tide-census button[aria-current="true"] .tide-code { color: var(--accent); } +.tide-code { font-size: .66rem; letter-spacing: .12em; color: var(--muted); } +.tide-count { font-variant-numeric: tabular-nums; } + +/* The specimen sheet for a followed machine. */ +.tide-inspect { position: absolute; z-index: 3; right: clamp(16px, 3.4vw, 56px); bottom: 150px; width: 244px; padding: .9rem 1rem 1rem; background: var(--panel); border: 1px solid var(--line); } +.tide-inspect[hidden] { display: none; } +.tide-inspect h2 { margin: .25rem 0 .7rem; font: 400 1.9rem/1 var(--site-font-display); letter-spacing: .02em; } +.tide-facts { display: grid; grid-template-columns: 1fr 1fr; gap: .55rem .8rem; margin: 0 0 .8rem; } +.tide-facts dd { margin: .15rem 0 0; font-size: .82rem; font-variant-numeric: tabular-nums; } +.tide-meter { height: 6px; background: #1e1c20; } +.tide-meter span { display: block; height: 100%; width: 50%; background: var(--accent); } +.tide-energy { margin: .35rem 0 .7rem; font-size: .7rem; color: var(--muted); } +.tide-goal { margin: 0; font-size: .9rem; line-height: 1.45; } +.tide-carrying { margin: .3rem 0 0; font-size: .75rem; color: var(--muted); } +.tide-carrying:empty { display: none; } +.tide-log { list-style: none; margin: .75rem 0 0; padding: .6rem 0 0; border-top: 1px solid var(--line); font-size: .72rem; line-height: 1.5; color: #b9b1bd; max-height: 5.5em; overflow: hidden; } +.tide-log:empty { display: none; } +.tide-log time { color: var(--muted); font-variant-numeric: tabular-nums; margin-right: .45rem; } +.tide-inspect-actions { display: flex; gap: 0; margin-top: .9rem; } +.tide-inspect-actions button { flex: 1; min-height: 40px; padding: .4rem .6rem; color: #ddd6e1; background: none; border: 1px solid var(--line); font-size: .76rem; } +.tide-inspect-actions button + button { border-left: 0; } +.tide-inspect-actions button[aria-pressed="true"] { color: #161019; background: var(--accent); border-color: var(--accent); } +.tide-inspect.is-ended h2 { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1px; } + +/* Controls: one hard-edged strip, tools above, navigation below. */ +.tide-controls { position: absolute; z-index: 3; left: 50%; bottom: max(24px, env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(620px, calc(100% - 32px)); } +.tide-controls button { min-height: 44px; padding: .5rem .8rem; color: #ddd6e1; background: var(--panel); border: 1px solid var(--line); font-size: .78rem; } +.tide-controls button:hover:not(:disabled) { background: #221e26; } +.tide-tools { display: flex; } +.tide-tools button { display: flex; flex: 1; justify-content: center; align-items: center; gap: .45rem; min-width: 0; } +.tide-tools button + button { border-left: 0; } +.tide-tools button[aria-pressed="true"] { color: #161019; background: var(--accent); border-color: var(--accent); } +.tide-tools button[aria-pressed="true"] .tide-swatch { outline: 1px solid #161019; } .tide-swatch { width: 9px; height: 9px; flex-shrink: 0; background: #a6a4a6; } -.tide-shell { background: #e1d8e3; transform: rotate(30deg); } -.tide-brass { background: #b59b70; border-radius: 50%; } -#tide-drop { border-color: #c5a5d1; background: #d4b9df; color: #161019; min-width: 135px; } -#tide-drop:hover:not(:disabled) { background: #e7d1ef; } -.tide-playback { display: flex; margin-top: .6rem; gap: .35rem; } -.tide-playback button { border-color: transparent; background: #0b090de0; color: #b5abbc; } -.tide-playback button:last-child { margin-left: auto; } -.tide-playback button[aria-pressed="true"] { text-decoration: underline; text-underline-offset: .3em; color: #fff; } -.tide-status { position: absolute; left: 20px; right: 20px; bottom: 146px; text-align: center; margin: 0; font-size: .72rem; color: #c8b9ce; pointer-events: none; } -.tide-notes { position: absolute; z-index: 4; top: 30px; right: clamp(20px, 4vw, 64px); max-width: min(380px, calc(100% - 40px)); font-size: .8rem; color: #c7bdce; } +.tide-look { background: none; border: 1px solid currentColor; } +.tide-brass { background: #b59b70; } +.tide-shell { background: #e1d8e3; transform: rotate(45deg); } +.tide-pebble { background: #8a8f8c; } +.tide-feed { background: #6f8a78; } +.tide-playback { display: flex; margin-top: .45rem; gap: .45rem; } +.tide-playback > button { border-color: transparent; background: #0b0a0cd8; color: #b5abbc; } +.tide-playback > button[aria-pressed="true"] { color: #fff; text-decoration: underline; text-underline-offset: .3em; } +.tide-playback > button:last-child { margin-left: auto; } +.tide-basins, .tide-zoom { display: flex; } +.tide-basins button, .tide-zoom button { min-width: 38px; padding-inline: .45rem; font-family: var(--site-font-display); font-size: .9rem; } +.tide-basins button + button, .tide-zoom button + button { border-left: 0; } +.tide-basins button[aria-current="true"] { color: #fff; box-shadow: inset 0 -2px var(--accent); } + +.tide-status { position: absolute; left: 16px; right: 16px; bottom: 130px; text-align: center; margin: 0; font-size: .72rem; color: #c8b9ce; pointer-events: none; } +.tide-ticker { position: absolute; z-index: 2; left: clamp(16px, 3.4vw, 56px); bottom: max(28px, env(safe-area-inset-bottom)); width: min(300px, calc(50% - 340px)); margin: 0; padding: 0; list-style: none; font-size: .72rem; line-height: 1.55; color: #a9a1ad; pointer-events: none; } +.tide-ticker li { transition: opacity 1s; } +.tide-ticker time { font-variant-numeric: tabular-nums; color: #6f6973; margin-right: .5rem; } + +.tide-notes { position: absolute; z-index: 5; top: 20px; right: clamp(16px, 3.4vw, 56px); max-width: min(400px, calc(100% - 32px)); font-size: .8rem; color: #c7bdce; } .tide-notes summary { display: block; min-height: 44px; padding: .7rem 0; box-sizing: border-box; cursor: pointer; text-align: right; } -.tide-notes[open] { padding: 0 1rem 1rem; background: #121016f5; border: 1px solid #48414d; max-height: calc(100svh - 70px); overflow-y: auto; } -.tide-notes[open] summary { margin-bottom: 1rem; } -.tide-notes p { line-height: 1.7; margin: .7rem 0; } +.tide-notes[open] { padding: 0 1rem 1rem; background: #111013f7; border: 1px solid var(--line); max-height: calc(100svh - 60px); overflow-y: auto; } +.tide-notes[open] summary { margin-bottom: .6rem; } +.tide-notes p, .tide-notes li { line-height: 1.65; margin: .6rem 0; } +.tide-species { padding: 0; list-style: none; border-top: 1px solid var(--line); } +.tide-species li { margin: 0; padding: .45rem 0; border-bottom: 1px solid #1f1d21; } +.tide-species b { font-weight: 600; color: #eee7f1; } .tide-notes summary::after { content: ' +'; } .tide-notes[open] summary::after { content: ' −'; } +#tide-describe { min-height: 44px; padding: .5rem .9rem; color: #ddd6e1; background: #0d0b10; border: 1px solid var(--line); font-size: .8rem; } #tide-description:empty { display: none; } -.tide-pool noscript { position: absolute; left: 20px; bottom: 240px; max-width: 300px; } -@media (max-width: 600px) { - .tide-heading { top: max(12px, env(safe-area-inset-top)); } - .tide-heading h1 { margin-top: .35rem; font-size: 2.8rem; } - .tide-heading p { font-size: .75rem; } - .tide-notes { top: 20px; right: 20px; } - .tide-notes[open] { top: 12px; } - .tide-controls { width: calc(100% - 28px); bottom: max(18px, env(safe-area-inset-bottom)); } - .tide-materials button { padding: .5rem .4rem; font-size: .72rem; gap: .3rem; } - #tide-drop { min-width: 105px; padding-inline: .6rem; font-size: .75rem; } - .tide-playback button { font-size: .72rem; padding-inline: .6rem; } - .tide-figure figcaption { bottom: 171px; font-size: .9rem; } - .tide-status { bottom: 132px; font-size: .65rem; } +.tide-pool noscript { position: absolute; left: 16px; bottom: 240px; max-width: 300px; } + +@media (max-width: 1100px) { + .tide-ticker { display: none; } +} +@media (max-width: 720px) { + .tide-heading { top: max(8px, env(safe-area-inset-top)); } + .tide-heading h1 { font-size: 2.3rem; margin-top: .1rem; } + .tide-heading p { display: none; } + .tide-notes { top: 8px; right: 16px; } + .tide-hud { top: calc(max(8px, env(safe-area-inset-top)) + 92px); left: 16px; right: auto; width: auto; padding: .45rem .7rem; } + .tide-clock { grid-template-columns: repeat(3, auto); justify-content: start; border: 0; } + .tide-clock div { padding: 0 .9rem 0 0; } + .tide-clock div + div { padding-left: .9rem; } + .tide-census { display: none; } + .tide-inspect { left: 16px; right: 16px; width: auto; bottom: 150px; padding: .7rem .8rem .8rem; } + .tide-inspect h2 { font-size: 1.5rem; margin-bottom: .45rem; } + .tide-facts { grid-template-columns: repeat(4, auto); gap: .3rem .9rem; margin-bottom: .6rem; } + .tide-log { display: none; } + .tide-inspect-actions { margin-top: .6rem; } + .tide-controls { width: calc(100% - 24px); bottom: max(12px, env(safe-area-inset-bottom)); } + .tide-tools button { padding: .4rem .2rem; font-size: .7rem; gap: .3rem; } + .tide-playback { gap: .3rem; } + .tide-playback > button { font-size: .7rem; padding-inline: .45rem; } + .tide-zoom { display: none; } + .tide-basins button { min-width: 30px; min-height: 40px; padding-inline: .25rem; font-size: .8rem; } + .tide-playback > button { min-height: 40px; white-space: nowrap; padding-inline: .35rem; } + .tide-basins button { min-width: 28px; } + .tide-figure figcaption { bottom: 190px; font-size: .9rem; } + .tide-status { bottom: 112px; font-size: .66rem; } + .tide-pool:has(.tide-inspect:not([hidden])) .tide-status { display: none; } +} +@media (max-width: 360px) { + #tide-reset { display: none; } } -@media (max-height: 600px) and (min-width: 601px) { - .tide-pool { min-height: 420px; } - .tide-heading h1 { font-size: 2.5rem; } - .tide-heading p { display: none; } - .tide-controls { bottom: 8px; } - .tide-figure figcaption { bottom: 148px; } - .tide-status { bottom: 120px; } +@media (max-height: 620px) and (min-width: 721px) { + .tide-pool { min-height: 420px; } + .tide-heading h1 { font-size: 2.2rem; } + .tide-heading p { display: none; } + .tide-hud { top: 60px; } + .tide-census ul, .tide-census h2 { display: none; } + .tide-inspect { bottom: 118px; } + .tide-log { display: none; } + .tide-controls { bottom: 8px; } + .tide-figure figcaption { bottom: 132px; } + .tide-status { bottom: 104px; } } diff --git a/public/tide-pool.js b/public/tide-pool.js index 7266282..6e578e7 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1,208 +1,604 @@ -import { createWorld, advanceWorld, offerObject } from './tide-pool-world.js?v=2'; +import { + createWorld, advanceWorld, offerObject, POOLS, CHANNELS, SPECIES, SPECIES_ORDER, CELL, waterLevel, daylight, + floorY, basinFloor, edgeDistance, inGully, tideOf, tideRising, poolAt, insidePool, label, goalText, describeWorld, creatureById, avgFilm, +} from './tide-pool-world.js?v=3'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); -if (root) initialize().catch(() => { +if (root) initialize().catch(error => { + console.error(error); status.textContent = 'The still edition. Interactive 3D is unavailable in this browser.'; }); +const $ = s => root.querySelector(s); +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + async function initialize() { // Local, pinned modules. A failed import or unavailable WebGL leaves the still intact. const T = await import('/public/tide-pool-vendor/three.module.js'); - const canvas = document.querySelector('#tide-canvas'); - const stage = document.querySelector('#tide-stage'); - const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'low-power' }); - renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75)); + const canvas = $('#tide-canvas'); + const stage = $('#tide-stage'); + const mobile = matchMedia('(pointer: coarse)').matches; + const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: mobile ? 'low-power' : 'default' }); renderer.shadowMap.enabled = true; renderer.shadowMap.type = T.PCFSoftShadowMap; renderer.outputColorSpace = T.SRGBColorSpace; renderer.toneMapping = T.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.1; + renderer.toneMappingExposure = 1.05; const scene = new T.Scene(); - scene.background = new T.Color(0x0a090c); - scene.fog = new T.FogExp2(0x0a090c, .035); - const camera = new T.OrthographicCamera(-6, 6, 6, -6, 0.1, 80); - camera.position.set(0, 11.8, 13.5); - camera.lookAt(0, 0, 0); - scene.add(new T.HemisphereLight(0xd8ccdf, 0x141118, 1.5)); - const key = new T.DirectionalLight(0xf3eef5, 4.8); - key.position.set(-4, 9, 3); key.castShadow = true; - key.shadow.mapSize.set(1024, 1024); - Object.assign(key.shadow.camera, { left: -6, right: 6, top: 6, bottom: -6, near: 1, far: 25 }); - key.shadow.normalBias = 0.035; key.shadow.bias = -0.0003; - scene.add(key); - const fill = new T.DirectionalLight(0xc0b4cc, 1.8); - fill.position.set(4, 4, -5); scene.add(fill); - - const sphere = new T.SphereGeometry(1, 20, 12); - const cylinder = new T.CylinderGeometry(1, 1, 1, 20); - const box = new T.BoxGeometry(1, 1, 1); + scene.background = new T.Color(0x0a0a0a); + scene.fog = null; + const camera = new T.OrthographicCamera(-6, 6, 6, -6, 0.1, 140); + + // Light follows a slow day. Eyes and visors are unlit, so they read as signal lamps at night. + const hemi = new T.HemisphereLight(0xd8ccdf, 0x141118, 1.4); + scene.add(hemi); + const sun = new T.DirectionalLight(0xf4efe8, 3.8); + sun.castShadow = true; + sun.shadow.mapSize.set(mobile ? 1024 : 2048, mobile ? 1024 : 2048); + sun.shadow.normalBias = .03; sun.shadow.bias = -.0004; + scene.add(sun, sun.target); + const fill = new T.DirectionalLight(0xbfb3cc, 1.1); + fill.position.set(14, 6, 18); scene.add(fill); + + // Every lit surface darkens and cools under the water line and carries moving caustic light. + const shared = { uWater: { value: -.35 }, uTime: { value: 0 }, uLight: { value: 1 } }; + const CAUSTIC = ` + float caustic(vec2 p, float t) { + vec2 q = p * 1.7; + float f = sin(q.x * 2.1 + sin(q.y * 1.7 + t * .6)) + sin(q.y * 2.3 - t * .5 + sin(q.x * 1.3)) + sin((q.x + q.y) * 1.4 + t * .4); + return pow(max(0., 1. - abs(f) * .8), 10.); + }`; + function submerge(material) { + material.onBeforeCompile = shader => { + Object.assign(shader.uniforms, shared); + shader.vertexShader = 'varying vec3 vWorldC;\n' + shader.vertexShader.replace('#include ', `#include + vec4 cw = vec4(transformed, 1.); + #ifdef USE_INSTANCING + cw = instanceMatrix * cw; + #endif + vWorldC = (modelMatrix * cw).xyz;`); + shader.fragmentShader = 'varying vec3 vWorldC;\nuniform float uWater, uTime, uLight;\n' + CAUSTIC + '\n' + + shader.fragmentShader.replace('#include ', ` + float under = clamp((uWater - vWorldC.y) * 3., 0., 1.); + gl_FragColor.rgb = mix(gl_FragColor.rgb, gl_FragColor.rgb * vec3(.62, .6, .74), under * .65); + gl_FragColor.rgb += vec3(.72, .61, .82) * caustic(vWorldC.xz, uTime * .5) * under * .075 * uLight; + #include `); + }; + return material; + } + + // Seeded scenery: the shore is the same on every visit. + let scenerySeed = 902; + const rand = () => ((scenerySeed = (scenerySeed * 1664525 + 1013904223) >>> 0) / 4294967296); + const hash = (i, j) => { + let h = Math.imul(i, 374761393) + Math.imul(j, 668265263) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; + }; + const mix = (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); }; + function noise(x, z) { + const i = Math.floor(x), j = Math.floor(z), fx = x - i, fz = z - j; + const u = fx * fx * (3 - 2 * fx), v = fz * fz * (3 - 2 * fz); + return mix(mix(hash(i, j), hash(i + 1, j), u), mix(hash(i, j + 1), hash(i + 1, j + 1), u), v); + } + const fbm = (x, z) => noise(x, z) * .5 + noise(x * 2.1 + 5.2, z * 2.1 - 1.3) * .25 + noise(x * 4.3 - 2, z * 4.3 + 7) * .125 + noise(x * 8.7, z * 8.7) * .0625; + const rimDistance = (x, z) => { + let d = Infinity; + for (const p of POOLS) d = Math.min(d, -edgeDistance(p, x, z)); + for (const c of CHANNELS) if (inGully(c, x, z, -.9)) d = Math.min(d, inGully(c, x, z, -.3) ? .15 : inGully(c, x, z, -.6) ? .45 : .75); + return d; + }; + // An open, nearly level dark field. The pools are soft hollows in it, not holes in rock. + function rockHeight(x, z) { + const lift = smooth(0, 1.2, rimDistance(x, z)); + return .015 + lift * (fbm(x * .18, z * .18) - .45) * .12; + } + const heightAt = (x, z) => basinFloor(x, z) ?? rockHeight(x, z); + function grainTexture() { + const size = 256, cv = document.createElement('canvas'); + cv.width = cv.height = size; + const g = cv.getContext('2d'), img = g.createImageData(size, size); + for (let i = 0; i < size * size; i++) { + const v = 215 + (rand() - .5) * 36 + (rand() < .015 ? -60 : 0); + img.data.set([v, v, v, 255], i * 4); + } + g.putImageData(img, 0, 0); + const texture = new T.CanvasTexture(cv); + texture.colorSpace = T.SRGBColorSpace; + texture.wrapS = texture.wrapT = T.RepeatWrapping; + texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy()); + return texture; + } + + const unitBox = new T.BoxGeometry(1, 1, 1); const materials = new Map(); - function material(color, metal = false) { - const id = `${color}:${metal}`; - if (!materials.has(id)) materials.set(id, new T.MeshStandardMaterial({ color, metalness: metal ? 0.78 : 0.12, roughness: metal ? 0.29 : 0.48, flatShading: !metal })); + function lit(color, { metal = false, rough } = {}) { + const id = `${color}:${metal}:${rough}`; + if (!materials.has(id)) materials.set(id, submerge(new T.MeshStandardMaterial({ + color, metalness: metal ? .8 : .05, roughness: rough ?? (metal ? .32 : .78), flatShading: true }))); return materials.get(id); } - const brass = material(0x9d896b, true); - const dark = material(0x162225); - const ceramic = material(0xa7a0ab); - const meshes = new Set(); - function mesh(geometry, mat, parent, x, y, z, sx = 1, sy = sx, sz = sx) { - const m = new T.Mesh(geometry, mat); - m.position.set(x, y, z); m.scale.set(sx, sy, sz); - m.castShadow = true; m.receiveShadow = true; - parent.add(m); meshes.add(m); return m; - } - function ball(parent, mat, x, y, z, sx, sy = sx, sz = sx) { - return mesh(sphere, mat, parent, x, y, z, sx, sy, sz); - } - function rod(parent, from, to, radius, mat = brass) { - const a = new T.Vector3(...from), b = new T.Vector3(...to); - const m = mesh(cylinder, mat, parent, ...a.clone().add(b).multiplyScalar(0.5).toArray(), radius, a.distanceTo(b), radius); - m.quaternion.setFromUnitVectors(new T.Vector3(0, 1, 0), b.sub(a).normalize()); - return m; - } - function torus(parent, radius, tube, mat, x, y, z, sx = 1, sy = 1) { - const m = mesh(new T.TorusGeometry(radius, tube, 8, 80), mat, parent, x, y, z); - m.rotation.x = -Math.PI / 2; m.scale.set(sx, sy, 1); return m; - } - const ground = mesh(new T.PlaneGeometry(200, 200), material(0x0c0b0e), scene, 0, -0.62, 0); - ground.rotation.x = -Math.PI / 2; ground.castShadow = false; - const water = mesh(new T.PlaneGeometry(40, 40), new T.MeshStandardMaterial({ color: 0x151119, metalness: 0.65, roughness: 0.3 }), scene, 0, 0.025, 0); - water.rotation.x = -Math.PI / 2; water.castShadow = false; - // Shallow ripple light, rather than a transparent sheet hiding the residents. + const lamp = new T.MeshBasicMaterial({ color: 0xffffff }); + + const X0 = -25, X1 = 23, Z0 = -10.5, Z1 = 17, RES = mobile ? .24 : .16; + const terrainGeometry = new T.PlaneGeometry(X1 - X0, Z1 - Z0, Math.round((X1 - X0) / RES), Math.round((Z1 - Z0) / RES)); + terrainGeometry.rotateX(-Math.PI / 2); + terrainGeometry.translate((X0 + X1) / 2, 0, (Z0 + Z1) / 2); + { + const pos = terrainGeometry.attributes.position, uv = terrainGeometry.attributes.uv; + const colors = new Float32Array(pos.count * 3); + const field = new T.Color(0x0b0a0d), hollow = new T.Color(0x16141a), floorTone = new T.Color(0x2e2b34), deep = new T.Color(0x201e27); + const c = new T.Color(); + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i), z = pos.getZ(i); + const floor = basinFloor(x, z); + const y = floor ?? rockHeight(x, z); + pos.setY(i, y); + uv.setXY(i, x * .35, z * .35); + if (floor !== null) { + // Floors glow faintly toward their middles and dissolve into the field at the edge. + const e = Math.max(...POOLS.map(p => edgeDistance(p, x, z)), 0); + c.copy(hollow).lerp(floorTone, smooth(0, 1.6, e)).lerp(deep, clamp(-y - .6, 0, 1) * .6); + c.multiplyScalar(.9 + fbm(x * 1.3, z * 1.3) * .2); + } else { + c.copy(hollow).lerp(field, smooth(0, .9, rimDistance(x, z))); + } + c.toArray(colors, i * 3); + } + terrainGeometry.setAttribute('color', new T.BufferAttribute(colors, 3)); + terrainGeometry.computeVertexNormals(); + } + const terrain = new T.Mesh(terrainGeometry, submerge(new T.MeshStandardMaterial({ vertexColors: true, map: grainTexture(), roughness: .75, metalness: .15 }))); + terrain.castShadow = false; terrain.receiveShadow = true; + scene.add(terrain); + // The field continues past the modelled shore in every direction. + const beyondMaterial = submerge(new T.MeshStandardMaterial({ color: 0x0b0a0d, roughness: .75, metalness: .15 })); + for (const [x0, x1, z0, z1] of [[-200, X0, -200, 200], [X1, 200, -200, 200], [X0, X1, -200, Z0], [X0, X1, Z1, 200]]) { + const beyond = new T.Mesh(new T.PlaneGeometry(x1 - x0 + .02, z1 - z0 + .02), beyondMaterial); + beyond.rotation.x = -Math.PI / 2; beyond.position.set((x0 + x1) / 2, .005, (z0 + z1) / 2); beyond.receiveShadow = true; + scene.add(beyond); + } + + // One water sheet across the shore. Rock above the line occludes it. const rippleVectors = Array.from({ length: 12 }, () => new T.Vector4(0, 0, -10, 0)); - const waterLight = new T.ShaderMaterial({ + const waterMaterial = new T.ShaderMaterial({ transparent: true, depthWrite: false, - uniforms: { uTime: { value: 0 }, uRipples: { value: rippleVectors } }, - vertexShader: 'varying vec2 p; void main(){p=position.xy;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}', + uniforms: { uTime: shared.uTime, uLight: shared.uLight, uRipples: { value: rippleVectors } }, + vertexShader: 'varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }', fragmentShader: `precision mediump float; - varying vec2 p; uniform float uTime; uniform vec4 uRipples[12]; - void main(){ - float t=uTime*.15; - 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.)*.022; - for(int i=0;i<12;i++){ - float age=uTime-uRipples[i].z; - float d=length(p-vec2(uRipples[i].x,-uRipples[i].y)); - float ring=exp(-pow((d-age*.85)*13.,2.)); - light+=ring*max(0.,1.-age/3.)*.33*step(0.,age); + varying vec3 vW; uniform float uTime, uLight; uniform vec4 uRipples[12]; + ${CAUSTIC} + void main() { + float c = caustic(vW.xz * .9 + vec2(uTime * .02, 0.), uTime * .35); + float ring = 0.; + for (int i = 0; i < 12; i++) { + float age = uTime - uRipples[i].z; + float d = length(vW.xz - uRipples[i].xy); + ring += exp(-pow((d - age * .8) * 12., 2.)) * max(0., 1. - age / 3.) * step(0., age); } - gl_FragColor=vec4(.72,.61,.82,light); + vec3 base = mix(vec3(.03, .025, .045), vec3(.08, .07, .11), uLight); + vec3 col = base + vec3(.72, .61, .82) * (c * .05 * uLight + ring * .3); + gl_FragColor = vec4(col, .26 + c * .04 + ring * .22); }`, }); - const caustics = mesh(new T.PlaneGeometry(40, 40), waterLight, scene, 0, 0.04, 0); - caustics.rotation.x = -Math.PI / 2; - caustics.castShadow = false; caustics.receiveShadow = false; + // Shallow light drifts over the whole field, so the dark reads as open water rather than ground. + const sheen = new T.Mesh(new T.PlaneGeometry(220, 110), new T.ShaderMaterial({ + transparent: true, depthWrite: false, blending: T.AdditiveBlending, + uniforms: { uTime: shared.uTime, uLight: shared.uLight, uRipples: { value: rippleVectors } }, + vertexShader: waterMaterial.vertexShader, + fragmentShader: `precision mediump float; + 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.) * .05 * (.4 + .6 * uLight); + for (int i = 0; i < 12; i++) { + float age = uTime - uRipples[i].z; + float d = length(p - uRipples[i].xy); + light += exp(-pow((d - age * .85) * 13., 2.)) * max(0., 1. - age / 3.) * .3 * step(0., age); + } + gl_FragColor = vec4(vec3(.72, .61, .82) * light, 1.); + }`, + })); + sheen.rotation.x = -Math.PI / 2; sheen.position.set(0, .06, 25); sheen.renderOrder = 3; + scene.add(sheen); + const water = new T.Mesh(new T.PlaneGeometry(220, 110), waterMaterial); + water.rotation.x = -Math.PI / 2; water.position.set(0, -.35, 25); + water.renderOrder = 2; + scene.add(water); - // Fixed shore details are generated from a separate seed, never visitor data. - let scenerySeed = 902; - const rand = () => ((scenerySeed = (scenerySeed * 1664525 + 1013904223) >>> 0) / 4294967296); - for (let i = 0; i < 11; i++) { - const a = i / 11 * Math.PI * 2; - const height = .15 + rand() * .9; - const shard = mesh(new T.CylinderGeometry(.15, .42, 1, 5), material(i % 3 ? 0x242329 : 0x58555e), scene, - Math.cos(a) * 5.5, height / 2, Math.sin(a) * 4.3, 1, height, 1); - shard.rotation.y = a; shard.rotation.z = (rand() - .5) * .25; - } - // A little overhang where the collector stows its bright objects. - for (const x of [-3.55, -2.75]) mesh(box, material(0x36303e), scene, x, .28, -1.55, .25, .56, .55); - const overhang = mesh(box, material(0x68606e), scene, -3.2, .62, -1.5, 1.4, .12, .6); - overhang.rotation.z = .08; - // A quiet seed of architecture. Later stones are placed around it by the builder. - for (const x of [1.7, 2.7]) { - const support = mesh(box, ceramic, scene, x, .2, -1.6, .19, .4, .42); - support.rotation.z = x < 2 ? -.14 : .14; - } - const lintel = mesh(box, ceramic, scene, 2.2, .43, -1.6, 1.24, .13, .48); - lintel.rotation.y = -.07; - // Fine brass reeds, slightly uneven and deliberately sparse. - for (let i = 0; i < 5; i++) { - const x = 3.9 + rand() * .3, z = -1 + rand() * .7, h = .65 + rand() * 1.4; - mesh(box, material(0x34303b), scene, x, h / 2, z, .09, h, .16); - rod(scene, [x, .02, z], [x, h, z], .012); - } - function creatureModel(c) { - const group = new T.Group(); scene.add(group); - group.scale.setScalar(1.4); - const body = new T.Group(); group.add(body); - const porcelain = material(c.role === 'thief' ? 0x625c6b : c.role === 'builder' ? 0xc4bdc9 : 0x45434c); - const length = c.role === 'builder' ? .48 : .35; - ball(body, dark, 0, .24, 0, .3, .16, length); - const carapace = mesh(new T.IcosahedronGeometry(1, 1), porcelain, body, 0, .36, -.04, .38, c.role === 'thief' ? .38 : .25, length); - carapace.rotation.z = .12; - // Distinct ceramic plates and exposed spine fasteners. - if (c.role === 'builder') { - for (let i = 0; i < 3; i++) ball(body, porcelain, 0, .47, -.29 + i * .22, .31, .085, .12); - } else if (c.role === 'thief') { - mesh(box, brass, body, 0, .71, -.1, .028, .14, .25); - } else { - for (const side of [-1, 1]) ball(body, porcelain, side * .21, .42, -.13, .15, .16, .31); + // Loose stones on the floors, and a few sculptural outcrops standing far out in the dark. + const stones = [], outcrops = []; + for (let k = 0; k < 4000 && stones.length < 120; k++) { + const x = X0 + rand() * (X1 - X0), z = -5 + rand() * 16; + if (basinFloor(x, z) === null) continue; + stones.push({ x, z, s: .03 + rand() * rand() * .1, a: rand() * 6, tone: .6 + rand() * .5 }); + } + for (let k = 0; k < 3000 && outcrops.length < 26; k++) { + const x = X0 + rand() * (X1 - X0), z = Z0 + rand() * (Z1 - Z0); + const d = rimDistance(x, z); + if (d < 1.2 || d > 7 || outcrops.some(o => Math.hypot(o.x - x, o.z - z) < 2.2)) continue; + const h = .2 + rand() * rand() * 1.8; + outcrops.push({ x, z, h, r: .18 + rand() * .3, a: rand() * 6, lean: (rand() - .5) * .3, tone: rand() < .3 ? 1.6 : .8 + rand() * .3 }); + } + function scatter(geometry, material, list, place) { + const mesh = new T.InstancedMesh(geometry, material, list.length); + list.forEach((item, i) => { + place(item, dummy); + dummy.updateMatrix(); + mesh.setMatrixAt(i, dummy.matrix); + if (item.tone) mesh.setColorAt(i, tint.setScalar(item.tone)); + }); + mesh.castShadow = true; mesh.receiveShadow = true; + scene.add(mesh); + return mesh; + } + const dummy = new T.Object3D(); + const m4 = new T.Matrix4(), q = new T.Quaternion(), v1 = new T.Vector3(), v2 = new T.Vector3(), v3 = new T.Vector3(), s3 = new T.Vector3(); + const tint = new T.Color(); + scatter(new T.CylinderGeometry(.35, 1, 1, 5), lit(0x2a282f), outcrops, (s, o) => { + o.position.set(s.x, s.h / 2 - .05, s.z); o.rotation.set(0, s.a, s.lean); o.scale.set(s.r, s.h, s.r * .8); + }); + scatter(new T.IcosahedronGeometry(1, 0), lit(0x4a4650), stones, (s, o) => { + o.position.set(s.x, heightAt(s.x, s.z) + s.s * .3, s.z); o.rotation.set(s.a, s.a * 2, s.a * .5); o.scale.set(s.s * 1.2, s.s * .6, s.s); + }); + + // The film scrapers graze: fine specks of pale light on each floor that thicken as it grows. + const cells = []; + for (const p of POOLS) for (let j = 0; j < p.nz; j++) for (let i = 0; i < p.nx; i++) { + if (!p.mask[j * p.nx + i]) continue; + const x = p.x0 + (i + .5) * CELL + (hash(i, j + p.id * 97) - .5) * .3, z = p.z0 + (j + .5) * CELL + (hash(j, i + p.id * 31) - .5) * .3; + cells.push({ pool: p.id, index: j * p.nx + i, x, z, y: floorY(x, z) + .006, spin: hash(i * 7, j * 3) * 6, scale: .8 + hash(i + 3, j * 5) * .5 }); + } + const patch = new T.CircleGeometry(1, 6); patch.rotateX(-Math.PI / 2); + const film = new T.InstancedMesh(patch, new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: .55, depthWrite: false }), cells.length); + film.receiveShadow = true; + scene.add(film); + const thin = new T.Color(0x1c1a21), green = new T.Color(0x4d4658), bloom = new T.Color(0x8a7f99); + function paintFilm() { + cells.forEach((c, k) => { + const a = world.film[c.pool][c.index]; + const size = CELL * (.04 + .16 * a) * c.scale; + film.setMatrixAt(k, m4.compose(v1.set(c.x, c.y, c.z), q.setFromAxisAngle(v2.set(0, 1, 0), c.spin), s3.set(size, 1, size * .8))); + tint.copy(thin).lerp(green, Math.min(1, a * 1.3)); + if (a > .72) tint.lerp(bloom, (a - .72) / .28 * .6); + film.setColorAt(k, tint); + }); + film.instanceMatrix.needsUpdate = true; film.instanceColor.needsUpdate = true; + } + + // Pool names float as hairline labels, not as geometry. + const labels = POOLS.map(p => { + const el = document.createElement('span'); + el.className = 'tide-label'; el.setAttribute('aria-hidden', 'true'); + el.innerHTML = `${p.name} Pool · ${p.depth.toFixed(2)} m${p.sea ? ' · seaward' : ''}`; + stage.append(el); + return { el, x: p.cx - p.rx * .55, z: p.z0 - .25, y: rockHeight(p.cx - p.rx * .55, p.z0 - .25) }; + }); + + // Instanced parts. Each species is a rig of a few shared shapes, written into matrices every frame. + const hex = new T.CylinderGeometry(1, 1, 1, 6); + const dome = new T.SphereGeometry(1, 10, 5, 0, Math.PI * 2, 0, Math.PI / 2); + const stone = new T.IcosahedronGeometry(1, 0); + const parts = []; + function part(geometry, material, max, { shadow = true, colored = false } = {}) { + const mesh = new T.InstancedMesh(geometry, material, max); + mesh.castShadow = shadow; mesh.receiveShadow = true; mesh.count = 0; mesh.frustumCulled = false; + if (colored) mesh.setColorAt(0, tint.set(0xffffff)); + scene.add(mesh); + const p = { mesh, n: 0, max, colored }; + parts.push(p); + return p; + } + const brass = lit(0xa88d5f, { metal: true }); + const joints = lit(0xb89a66, { metal: true }); + const P = { + // Scraper: a low ribbed slab on treads with a brass blade. + scBody: part(unitBox, lit(0xb3aea6), 40), scCap: part(unitBox, lit(0x8e8a86), 40), scRib: part(unitBox, lit(0x3c3a40), 120), + scTread: part(unitBox, lit(0x252428), 80), scBlade: part(unitBox, brass, 40), + // Tab: a folded porcelain plate with a wagging fin. + tbBody: part(unitBox, lit(0xd9d3dc), 44), tbFin: part(unitBox, lit(0xa9a3ad), 132, { shadow: false }), tbKeel: part(unitBox, lit(0x2c2a30), 44), + // Pylon: stacked, alternately turned blocks with a crown of rods. + pyBase: part(unitBox, lit(0x2a282d), 16), pySeg: part(unitBox, lit(0x77737a), 96), pyTop: part(unitBox, lit(0xcfc9d2), 16), + pyRod: part(unitBox, joints, 96, { shadow: false }), + // Collector: a hexagonal carapace on six jointed legs with two long arms. + clBody: part(hex, lit(0x6b6274), 8), clHead: part(unitBox, lit(0x8d8496), 8), clFin: part(unitBox, brass, 8), + // Mason: a square body with a carrying tray. + msBody: part(unitBox, lit(0xd6d0c2), 8), msTray: part(unitBox, lit(0x2e2c31), 8), msHead: part(unitBox, lit(0xb4ad9f), 8), + // Breaker: a heavy block with a diamond ridge and two hammers. + brBody: part(unitBox, lit(0x3a393e), 8), brRidge: part(unitBox, lit(0x55535a), 8), brHammer: part(unitBox, lit(0x6a676f), 16), + limb: part(unitBox, lit(0x9a8a6a, { metal: true, rough: .45 }), 8 * 6 * 2 + 8 * 4 * 2 + 8 * 4 * 2 + 32 + 16), + knee: part(unitBox, joints, 8 * 6 + 8 * 4 * 2 + 32), + eye: part(unitBox, lamp, 40 + 44 + 96 + 16 + 8 + 8, { shadow: false, colored: true }), + // Objects and masonry. + brass: part(hex, brass, 80), brassBoss: part(unitBox, lit(0xe0c48a, { metal: true }), 80), + shell: part(dome, lit(0xd9ccb4, { rough: .55 }), 80), pebble: part(stone, lit(0x8a8f8c), 80), + husk: part(unitBox, lit(0x2e2c31), 160), scrap: part(unitBox, lit(0x4a4650), 80), + block: part(new T.IcosahedronGeometry(1, 0), lit(0xffffff), 130, { colored: true }), + crop: part(unitBox, new T.MeshBasicMaterial({ color: 0xd4b9df }), 16, { shadow: false }), + }; + + const base = new T.Matrix4(), local = new T.Matrix4(); + let cosA = 1, sinA = 0, bx = 0, by = 0, bz = 0; + function setBase(x, y, z, angle, scale = 1) { + bx = x; by = y; bz = z; cosA = Math.cos(angle); sinA = Math.sin(angle); + base.compose(v1.set(x, y, z), q.setFromAxisAngle(v2.set(0, 1, 0), angle), s3.set(scale, scale, scale)); + } + const toWorld = (out, lx, ly, lz) => out.set(bx + lx * cosA + lz * sinA, by + ly, bz - lx * sinA + lz * cosA); + function put(p, matrix, color) { + if (p.n >= p.max) return; + p.mesh.setMatrixAt(p.n, matrix); + if (color && p.colored) p.mesh.setColorAt(p.n, color); + p.n++; + } + function box(p, x, y, z, sx, sy, sz, rx = 0, ry = 0, rz = 0, color) { + dummy.position.set(x, y, z); dummy.rotation.set(rx, ry, rz); dummy.scale.set(sx, sy, sz); dummy.updateMatrix(); + put(p, local.multiplyMatrices(base, dummy.matrix), color); + } + const up = new T.Vector3(0, 1, 0); + function segment(p, a, b, thickness) { + const length = a.distanceTo(b); + if (length < 1e-4) return; + v3.subVectors(b, a).divideScalar(length); + q.setFromUnitVectors(up, v3); + put(p, m4.compose(v1.addVectors(a, b).multiplyScalar(.5), q, s3.set(thickness, length, thickness))); + } + function cube(p, at, size) { put(p, m4.compose(at, q.identity(), s3.set(size, size, size))); } + const hip = new T.Vector3(), knee = new T.Vector3(), foot = new T.Vector3(); + // A two-segment leg: the foot swings on the gait, lifts in the swing phase, and the knee bows outward. + function leg(c, lx, ly, lz, fx, fz, side, offset, { stride = .1, lift = .07, thick = .028, kneeUp = .1 } = {}) { + const swing = Math.sin(c.phase + offset), raise = Math.max(0, Math.cos(c.phase + offset)); + const moving = Math.min(1, c.speed * 3); + toWorld(hip, lx, ly, lz); + toWorld(foot, fx, 0, fz + swing * stride * moving); + foot.y = floorY(foot.x, foot.z) + .01 + raise * lift * moving; + toWorld(knee, (lx + fx) / 2 + side * .08, 0, (lz + fz) / 2 + swing * stride * .5 * moving); + knee.y = Math.max(hip.y, foot.y) + kneeUp; + segment(P.limb, hip, knee, thick); + segment(P.limb, knee, foot, thick * .8); + cube(P.knee, knee, thick * 1.7); + } + const eyeColor = new T.Color(), dimEye = new T.Color(0x3a3540), brightEye = new T.Color(0xe2c9ec), alarm = new T.Color(0xf0a8b8); + function eye(c) { + eyeColor.copy(dimEye).lerp(brightEye, clamp(c.energy * 1.4, 0, 1)); + if (c.energy < .15) eyeColor.lerp(alarm, .5 + .5 * Math.sin(world.time * 6)); + return eyeColor; + } + + const DRAW = { + scraper(c) { + const y = floorY(c.x, c.z) + Math.sin(c.phase * 2) * .006 * c.speed; + setBase(c.x, y, c.z, c.angle); + box(P.scBody, 0, .1, 0, .32, .1, .46); + box(P.scCap, 0, .18, -.03, .24, .07, .32, c.gesture * .2); + for (let k = 0; k < 3; k++) box(P.scRib, 0, .22, -.13 + k * .1, .2, .014, .03); + for (const side of [-1, 1]) box(P.scTread, side * .18, .055, 0, .07, .1, .44); + box(P.scBlade, 0, .04, .26, .3, .03, .05, 0, c.gesture * .6); + box(P.eye, 0, .15, .232, .12, .024, .02, 0, 0, 0, eye(c)); + }, + tab(c) { + setBase(c.x, c.y, c.z, c.angle); + const wag = Math.sin(c.phase * 3) * .45; + box(P.tbBody, 0, 0, 0, .07, .06, .24, 0, wag * .08); + box(P.tbKeel, 0, -.04, 0, .02, .025, .16); + dummy.position.set(0, 0, -.12); dummy.rotation.set(0, wag, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); + const tail = local.multiplyMatrices(base, dummy.matrix); + dummy.position.set(0, 0, -.05); dummy.rotation.set(0, 0, 0); dummy.scale.set(.012, .09, .1); dummy.updateMatrix(); + put(P.tbFin, m4.multiplyMatrices(tail, dummy.matrix)); + for (const side of [-1, 1]) box(P.tbFin, side * .06, 0, .04, .08, .006, .05, 0, 0, side * (.3 + Math.sin(c.phase * 2) * .15)); + box(P.eye, 0, .018, .115, .075, .014, .012, 0, 0, 0, eye(c)); + }, + pylon(c) { + const y = floorY(c.x, c.z); + setBase(c.x, y, c.z, c.id * .7); + box(P.pyBase, 0, .02, 0, .46, .04, .46); + const segments = c.size + 1; + let h = .04; + for (let k = 0; k < segments; k++) { + const w = .32 - k * .03, sh = .15; + box(k === segments - 1 ? P.pyTop : P.pySeg, 0, h + sh / 2, 0, w, sh, w, 0, (k % 2) * Math.PI / 4 + Math.sin(world.time * .3 + k) * .03); + h += sh; + } + const closing = c.strike; + for (let k = 0; k < 6; k++) { + const a = k / 6 * Math.PI * 2 + world.time * .08; + const pitch = .95 - closing * .7 + Math.sin(world.time * 1.3 + k + c.id) * .12; + const length = .26 + c.size * .05; + toWorld(hip, 0, h, 0); + foot.set(hip.x + Math.cos(a) * Math.sin(pitch) * length, hip.y + Math.cos(pitch) * length, hip.z + Math.sin(a) * Math.sin(pitch) * length); + segment(P.pyRod, hip, foot, .018); + put(P.eye, m4.compose(foot, q.identity(), s3.set(.035, .035, .035)), eye(c)); + } + }, + collector(c) { + const y = floorY(c.x, c.z) + Math.sin(c.phase * 2) * .01 * c.speed; + setBase(c.x, y, c.z, c.angle); + box(P.clBody, 0, .27, 0, .22, .13, .26, 0, Math.PI / 6); + box(P.clFin, 0, .37, -.02, .02, .08, .28); + box(P.clHead, 0, .26, .25, .16, .09, .1, c.gesture); + for (const side of [-1, 1]) box(P.eye, side * .05, .3, .302, .035, .03, .012, 0, 0, 0, eye(c)); + for (let k = 0; k < 3; k++) for (const side of [-1, 1]) { + leg(c, side * .17, .24, -.12 + k * .12, side * .42, -.22 + k * .22, side, k * 2.1 + (side > 0 ? Math.PI : 0), { thick: .024 }); + } + const holding = c.carrying !== null; + for (const side of [-1, 1]) { + toWorld(hip, side * .1, .24, .22); + toWorld(foot, side * (holding ? .07 : .09), holding ? .2 : .1 + Math.max(0, c.gesture) * .2, holding ? .4 : .44); + segment(P.limb, hip, foot, .022); + cube(P.knee, foot, .045); + } + }, + mason(c) { + const y = floorY(c.x, c.z) + Math.sin(c.phase * 2) * .008 * c.speed; + setBase(c.x, y, c.z, c.angle); + box(P.msBody, 0, .29, 0, .34, .2, .4); + box(P.msTray, 0, .405, 0, .38, .03, .38); + box(P.msHead, 0, .3, .245, .18, .1, .1, c.gesture); + box(P.eye, 0, .31, .298, .14, .022, .012, 0, 0, 0, eye(c)); + for (const [sx, sz, o] of [[-1, -1, 0], [1, -1, Math.PI], [-1, 1, Math.PI], [1, 1, 0]]) { + leg(c, sx * .16, .22, sz * .14, sx * .3, sz * .22, sx, o, { thick: .045, stride: .08, lift: .05, kneeUp: .07 }); + } + }, + breaker(c) { + const y = floorY(c.x, c.z) + Math.sin(c.phase * 2) * .012 * c.speed; + setBase(c.x, y, c.z, c.angle); + box(P.brBody, 0, .35, 0, .44, .24, .6); + box(P.brRidge, 0, .47, -.04, .2, .2, .48, 0, 0, Math.PI / 4); + box(P.eye, 0, .37, .302, .3, .028, .012, 0, 0, 0, eye(c)); + for (const [sx, sz, o] of [[-1, -1, 0], [1, -1, Math.PI], [-1, 1, Math.PI], [1, 1, 0]]) { + leg(c, sx * .22, .26, sz * .2, sx * .38, sz * .3, sx, o, { thick: .06, stride: .09, lift: .06, kneeUp: .08 }); + } + // Hammers rise with a strike, then fall back as it decays. + const raise = c.strike > 0 ? Math.sin(c.strike * Math.PI) : 0; + for (const side of [-1, 1]) { + toWorld(hip, side * .27, .38, .2); + toWorld(foot, side * .3, .16 + raise * .45, .44 - raise * .12); + segment(P.limb, hip, foot, .05); + cube(P.knee, hip, .07); + put(P.brHammer, m4.compose(foot, q.setFromAxisAngle(up, c.angle), s3.set(.13, .11, .16))); + } + }, + }; + + const blockColors = { pebble: new T.Color(0x8a867d), scrap: new T.Color(0x3a383d), husk: new T.Color(0x4a4650), shell: new T.Color(0xd9ccb4), brass: new T.Color(0xa88d5f) }; + function drawObjects() { + for (const o of world.objects) { + const worn = o.place === 'worn'; + const wearer = worn ? creatureById(world, o.owner) : null; + const y = (worn && wearer ? floorY(wearer.x, wearer.z) + .2 : floorY(o.x, o.z)) + (worn ? 0 : o.height); + setBase(o.x, y, o.z, o.rotation); + if (o.kind === 'brass') { box(P.brass, 0, .03, 0, .07, .06, .07); box(P.brassBoss, 0, .07, 0, .04, .02, .04); } + else if (o.kind === 'shell') worn ? box(P.shell, 0, 0, -.02, .2, .13, .26) : box(P.shell, 0, 0, 0, .14, .08, .12); + else if (o.kind === 'pebble') box(P.pebble, 0, .04, 0, .1, .055, .08); + else if (o.kind === 'husk') { + box(P.husk, -.06, .05, 0, .22, .08, .3, .2, 0, .3); + box(P.husk, .1, .03, .08, .14, .05, .14, -.3, .5, 0); + } else box(P.scrap, 0, .04, 0, .09, .07, .09, .3, 0, .2); } - for (const z of [-.2, 0, .2]) ball(body, brass, 0, .54, z, .026); - const head = new T.Group(); head.position.set(0, .32, length - .01); body.add(head); - ball(head, porcelain, 0, 0, 0, .19, .13, .19); - for (const side of [-1, 1]) { - rod(head, [side * .1, .06, .07], [side * .16, .2, .17], .017); - ball(head, brass, side * .16, .2, .17, .052); - ball(head, dark, side * .16, .21, .209, .026); + for (const s of world.sites) { + setBase(s.x, floorY(s.x, s.z), s.z, s.angle); + // Stones settle into a cairn: a wide base, narrowing as it rises. + s.blocks.forEach((kind, i) => { + const layer = Math.floor(i / 3), slot = i % 3, r = .13 * Math.max(.15, 1 - layer * .22); + const a = slot / 3 * Math.PI * 2 + layer * .9, j = hash(s.id * 13, i); + box(P.block, Math.cos(a) * r, .05 + layer * .085, Math.sin(a) * r, .12 - layer * .008, .06, .1, j * .4, j * 6, j * .3, blockColors[kind] || blockColors.pebble); + }); } - const legs = []; - for (let i = 0; i < 3; i++) for (const side of [-1, 1]) { - const leg = new T.Group(); leg.position.set(side * .24, .26, (i - 1) * .26); group.add(leg); - ball(leg, brass, 0, 0, 0, .068); - rod(leg, [0, 0, 0], [side * .23, -.035, .08], .033); - ball(leg, brass, side * .23, -.035, .08, .055); - rod(leg, [side * .23, -.035, .08], [side * .31, -.22, .15], .024); - ball(leg, porcelain, side * .31, -.22, .15, .065, .035, .10); - legs.push({ group: leg, side, offset: i * 2 + (side === 1 ? Math.PI : 0) }); + } + + function placeLabels() { + const bounds = stage.getBoundingClientRect(); + for (const l of labels) { + v1.set(l.x, l.y + .05, l.z).project(camera); + const sx = (v1.x + 1) / 2 * bounds.width, sy = (1 - v1.y) / 2 * bounds.height; + const show = view.zoom < 9 && sx > -40 && sx < bounds.width && sy > 150 && sy < bounds.height - 150; + l.el.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px)`; + l.el.style.opacity = show ? String(clamp((9 - view.zoom) / 3, 0, 1) * .8) : '0'; } - // Hinged front paddles reach toward a find and cradle it during carrying. - const claws = []; - for (const side of [-1, 1]) { - const claw = new T.Group(); claw.position.set(side * .2, .27, length); body.add(claw); - rod(claw, [0, 0, 0], [side * .08, -.05, .2], .027); - ball(claw, porcelain, side * .08, -.05, .24, .085, .06, .15); - claws.push({ group: claw, side }); + } + function drawCrop(c) { + // Registration marks around the followed machine, like a crop on a contact sheet. + const r = (SPECIES[c.sp].radius || .2) + .18; + const y = (SPECIES[c.sp].kind === 'swimmer' ? c.y - .08 : floorY(c.x, c.z)) + .02; + setBase(c.x, y, c.z, 0); + const len = r * .45, t = .018; + for (const sx of [-1, 1]) for (const sz of [-1, 1]) { + box(P.crop, sx * (r - len / 2), 0, sz * r, len, t, t); + box(P.crop, sx * r, 0, sz * (r - len / 2), t, t, len); } - return { group, body, head, legs, claws }; } + function drawMarker() { + if (!marker.visible) return; + const y = Math.max(floorY(cursor.x, cursor.z), waterLevel(world)) + .02; + setBase(cursor.x, y, cursor.z, 0); + const r = .24, t = .014; + for (const s of [-1, 1]) { box(P.crop, s * r, 0, 0, t, t, r * 2); box(P.crop, 0, 0, s * r, r * 2, t, t); } + } + let world = createWorld(); - const residents = world.creatures.map(creatureModel); - const objectMeshes = new Map(); - const shellGeometry = new T.SphereGeometry(1, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2); - function objectModel(o) { - const g = new T.Group(); scene.add(g); - g.scale.setScalar(1.6); - if (o.kind === 'brass') { - mesh(new T.IcosahedronGeometry(.14, 1), brass, g, 0, .12, 0); - torus(g, .075, .008, material(0xe6c88a, true), 0, .095, 0); - } else if (o.kind === 'shell') { - const shell = mesh(shellGeometry, material(0xd6c8b2), g, 0, .055, 0, .22, .12, .20); - for (let i = 0; i < 5; i++) { - const a = (i - 2) * .35; - rod(g, [0, .15, -.13], [Math.sin(a) * .16, .10, .14], .007, material(0x9c9483)); - } - shell.rotation.y = o.rotation; - } else ball(g, material(0x889a95), 0, .085, 0, .19, .12, .145); - return g; - } - const marker = torus(scene, .2, .012, new T.MeshBasicMaterial({ color: 0xe0c4eb }), 0, .075, .5); - marker.castShadow = false; marker.visible = true; - const impactMaterial = new T.MeshBasicMaterial({ color: 0xd4b9df, transparent: true, opacity: .9 }); - const impact = torus(scene, .3, .018, impactMaterial, 0, .085, 0); - impact.castShadow = false; impact.visible = false; - const touchLight = new T.PointLight(0xc9a7e1, 0, 4); - scene.add(touchLight); - let impactBorn = -10; - let cursor = { x: 0, z: .5 }; - let selected = 'brass'; + let selected = null, following = false, lastEnded = null; + let tool = 'brass'; + let cursor = { x: 4.5, z: 0 }; + const marker = { visible: false }; + + // Camera: an orthographic, slightly turned view with pan and zoom. + const YAW = -.3, PITCH = .74; + const view = { x: 4.5, z: .6, zoom: 5.4 }, goal = { ...view }; + const ZMIN = 1.4, ZMAX = 13; const reduced = matchMedia('(prefers-reduced-motion: reduce)'); - let paused = reduced.matches, visible = true, lost = false, frame = 0, previous = 0; + function placeCamera() { + const { width, height } = stage.getBoundingClientRect(); + const aspect = width / Math.max(1, height); + camera.left = -view.zoom * aspect; camera.right = view.zoom * aspect; + camera.top = view.zoom; camera.bottom = -view.zoom; + const dir = v2.set(Math.sin(YAW) * Math.cos(PITCH), Math.sin(PITCH), Math.cos(YAW) * Math.cos(PITCH)); + camera.position.set(view.x + dir.x * 50, -.5 + dir.y * 50, view.z + dir.z * 50); + camera.lookAt(view.x, -.5, view.z); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(); + const reach = view.zoom * Math.max(aspect, 1) + 3; + Object.assign(sun.shadow.camera, { left: -reach, right: reach, top: reach, bottom: -reach, near: 1, far: 90 }); + sun.shadow.camera.updateProjectionMatrix(); + } + function clampGoal() { + goal.zoom = clamp(goal.zoom, ZMIN, ZMAX); + goal.x = clamp(goal.x, -21, 19); + goal.z = clamp(goal.z, -5, 11); + } + function cameraSettled() { + return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002; + } + function easeCamera(dt) { + const k = reduced.matches ? 1 : 1 - Math.exp(-dt * 7); + view.x += (goal.x - view.x) * k; view.z += (goal.z - view.z) * k; view.zoom += (goal.zoom - view.zoom) * k; + placeCamera(); + } + + const raycaster = new T.Raycaster(); + const ground = new T.Plane(new T.Vector3(0, 1, 0), .35); + function groundAt(clientX, clientY, y = -.35) { + const bounds = canvas.getBoundingClientRect(); + const ndc = new T.Vector2((clientX - bounds.left) / bounds.width * 2 - 1, -(clientY - bounds.top) / bounds.height * 2 + 1); + raycaster.setFromCamera(ndc, camera); + ground.constant = -y; + const hit = raycaster.ray.intersectPlane(ground, new T.Vector3()); + return hit ? { x: hit.x, z: hit.z } : null; + } + // Refine the hit against the floor that is actually under the pointer. + function floorAt(clientX, clientY) { + let hit = groundAt(clientX, clientY, 0); + for (let i = 0; hit && i < 3; i++) { + const y = Math.max(heightAt(hit.x, hit.z), poolAt(hit.x, hit.z) ? waterLevel(world) : -9); + hit = groundAt(clientX, clientY, y); + } + return hit; + } + function screenOf(c) { + const y = SPECIES[c.sp].kind === 'swimmer' ? c.y : floorY(c.x, c.z) + .2; + v1.set(c.x, y, c.z).project(camera); + const bounds = canvas.getBoundingClientRect(); + return { x: (v1.x + 1) / 2 * bounds.width + bounds.left, y: (1 - v1.y) / 2 * bounds.height + bounds.top }; + } + function creatureAt(clientX, clientY) { + const bounds = canvas.getBoundingClientRect(); + const pixelsPerUnit = bounds.height / (view.zoom * 2); + let best = null, score = Infinity; + for (const c of world.creatures) { + const s = screenOf(c); + const d = Math.hypot(s.x - clientX, s.y - clientY); + const r = Math.max(16, (SPECIES[c.sp].radius + .1) * pixelsPerUnit); + if (d < r && d < score) { score = d; best = c; } + } + return best; + } + + const pauseButton = $('#tide-pause'), soundButton = $('#tide-sound'); + let paused = reduced.matches, visible = true, lost = false, frame = 0, previous = 0, dirty = true; let audio = null, sound = false; - const pauseButton = document.querySelector('#tide-pause'); - const soundButton = document.querySelector('#tide-sound'); function updateButtons() { pauseButton.textContent = paused ? 'Play' : 'Pause'; pauseButton.setAttribute('aria-pressed', String(paused)); @@ -215,175 +611,461 @@ async function initialize() { else audio.suspend().catch(() => {}); } let lastChime = -1; - function chime(kind) { + const TONES = { brass: 587.33, shell: 440, pebble: 293.66, feed: 349.23, birth: 659.25, end: 196, build: 329.63, steal: 523.25, arrive: 392 }; + function chime(kind, volume = .06) { if (!audio || !sound || audio.state !== 'running' || paused || !visible || document.hidden) return; const now = audio.currentTime; - if (now - lastChime < .15) return; + if (now - lastChime < .2) return; lastChime = now; - for (const [ratio, volume] of [[1, .07], [2.01, .018]]) { + for (const [ratio, v] of [[1, volume], [2.01, volume * .25]]) { const osc = audio.createOscillator(), gain = audio.createGain(); - osc.type = 'sine'; osc.frequency.value = ({ brass: 587.33, shell: 440, pebble: 293.66 }[kind] || 220) * ratio; + osc.type = 'sine'; osc.frequency.value = (TONES[kind] || 220) * ratio; gain.gain.setValueAtTime(0, now); - gain.gain.linearRampToValueAtTime(volume, now + .008); - gain.gain.exponentialRampToValueAtTime(.0001, now + 1.2); + gain.gain.linearRampToValueAtTime(v, now + .008); + gain.gain.exponentialRampToValueAtTime(.0001, now + 1.3); osc.connect(gain); gain.connect(audio.destination); - osc.start(now); osc.stop(now + 1.25); + osc.start(now); osc.stop(now + 1.35); osc.onended = () => { osc.disconnect(); gain.disconnect(); }; } } - let lastEventTime = -1; + function render() { - for (const [id, group] of objectMeshes) if (!world.objects.some(o => o.id === id)) { - scene.remove(group); - // Shared geometry/materials remain alive; coin rims are object-owned. - group.traverse(child => { if (child.isMesh) { meshes.delete(child); if (['TorusGeometry', 'IcosahedronGeometry'].includes(child.geometry.type)) child.geometry.dispose(); } }); - objectMeshes.delete(id); - } - for (const o of world.objects) { - if (!objectMeshes.has(o.id)) objectMeshes.set(o.id, objectModel(o)); - const m = objectMeshes.get(o.id); - m.position.set(o.x, .05 + o.height + (o.place === 'shelter' ? .1 : 0), o.z); - m.rotation.y = o.rotation; - m.rotation.z = o.place === 'shelter' ? .65 : 0; + shared.uTime.value = world.time; + const light = daylight(world); + shared.uLight.value = light; + const wl = waterLevel(world); + shared.uWater.value = wl; + water.position.y = wl; + 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.target.position.set(view.x, 0, view.z); + sun.intensity = .5 + 2.9 * light; + sun.color.setRGB(.78 + .2 * light, .76 + .18 * light, .9 + .03 * light); + hemi.intensity = .35 + .85 * light; + fill.intensity = .5 + .6 * light; + + + for (const p of parts) p.n = 0; + for (const c of world.creatures) DRAW[c.sp](c); + drawObjects(); + const focus = creatureById(world, selected); + if (focus) drawCrop(focus); + drawMarker(); + for (const p of parts) { + p.mesh.count = p.n; + p.mesh.instanceMatrix.needsUpdate = true; + if (p.colored && p.mesh.instanceColor) p.mesh.instanceColor.needsUpdate = true; } - world.creatures.forEach((c, i) => { - const m = residents[i]; - m.group.position.set(c.x, .05, c.z); m.group.rotation.y = c.angle; - m.body.position.y = Math.sin(c.phase * 2) * .016 * c.speed; - m.body.rotation.z = Math.sin(c.phase) * .045 * c.speed; - m.head.rotation.x = c.gesture; - m.head.rotation.y = Math.sin(world.time * .9 + i) * (c.state === 'idle' ? .18 : .03); - for (const leg of m.legs) { - leg.group.rotation.x = Math.sin(c.phase + leg.offset) * .3 * c.speed; - leg.group.rotation.z = leg.side * Math.max(0, Math.cos(c.phase + leg.offset)) * .22 * c.speed; - } - for (const claw of m.claws) claw.group.rotation.y = claw.side * (c.carrying !== null ? -.3 : .1 + c.gesture); - }); - waterLight.uniforms.uTime.value = world.time; - rippleVectors.forEach((v, i) => { - const r = world.ripples[i]; v.set(r?.x || 0, r?.z || 0, r?.born ?? -10, 0); - }); - marker.position.set(cursor.x, .075, cursor.z); - const impactAge = world.time - impactBorn; - impact.visible = impactAge < 1.8; - impact.scale.setScalar(1 + impactAge * 3); - impactMaterial.opacity = Math.max(0, .9 - impactAge * .5); - touchLight.intensity = Math.max(0, 3 - impactAge * 3); renderer.render(scene, camera); + placeLabels(); + dirty = false; } + + // Readouts refresh a few times a second, not every frame. + const timeOut = $('#tide-time'), tideOut = $('#tide-level'), basinOut = $('#tide-basin'); + const chart = $('#tide-chart'), chartContext = chart.getContext('2d'); + const counts = [...root.querySelectorAll('[data-species]')]; + const basinButtons = [...root.querySelectorAll('[data-basin]')]; + const clock = t => { + const hours = ((t / 420 * 24 + 7.7) % 24 + 24) % 24; + return `${String(Math.floor(hours)).padStart(2, '0')}:${String(Math.floor(hours % 1 * 60)).padStart(2, '0')}`; + }; + function readout() { + timeOut.textContent = clock(world.time); + const tide = tideOf(world.time); + tideOut.textContent = `${tideRising(world.time) ? '▲' : '▼'} ${Math.round(tide * 100)}%`; + const here = poolAt(view.x, view.z) || POOLS.reduce((a, p) => Math.hypot((p.x0 + p.x1) / 2 - view.x, (p.z0 + p.z1) / 2 - view.z) < + Math.hypot((a.x0 + a.x1) / 2 - view.x, (a.z0 + a.z1) / 2 - view.z) ? p : a); + basinOut.textContent = here.name; + basinButtons.forEach(b => b.setAttribute('aria-current', String(+b.dataset.basin === here.id))); + const focus = creatureById(world, selected); + const n = SPECIES_ORDER.map(s => world.creatures.filter(c => c.sp === s).length); + counts.forEach(b => { + b.querySelector('[data-count]').textContent = n[SPECIES_ORDER.indexOf(b.dataset.species)]; + b.setAttribute('aria-current', String(focus?.sp === b.dataset.species)); + }); + drawChart(focus?.sp); + inspect(); + } + function drawChart(highlight) { + const w = chart.width = chart.clientWidth * devicePixelRatio || 240, h = chart.height = 56 * devicePixelRatio; + const g = chartContext; + g.clearRect(0, 0, w, h); + const rows = world.census; + if (rows.length < 2) return; + const order = SPECIES_ORDER.map((s, i) => i).sort((a, b) => (SPECIES_ORDER[a] === highlight) - (SPECIES_ORDER[b] === highlight)); + for (const i of order) { + const max = SPECIES[SPECIES_ORDER[i]].max; + g.beginPath(); + rows.forEach((r, k) => { + const x = k / (Math.max(rows.length, 30) - 1) * w, y = h - 2 - r.n[i] / max * (h - 6); + if (k === 0) g.moveTo(x, y); else { g.lineTo(x, rows[k - 1] ? h - 2 - rows[k - 1].n[i] / max * (h - 6) : y); g.lineTo(x, y); } + }); + const on = SPECIES_ORDER[i] === highlight; + g.strokeStyle = on ? '#d4b9df' : highlight ? '#3d3a40' : ['#b3aea6', '#d9d3dc', '#77737a', '#8d8496', '#d6d0c2', '#5d5b62'][i]; + g.lineWidth = (on ? 2 : 1) * devicePixelRatio; + g.stroke(); + } + } + + // The specimen sheet follows one machine, and keeps its record once it stops. + const sheet = $('#tide-inspect'); + const fields = { kind: $('#tide-inspect-kind'), title: $('#tide-inspect-title'), gen: $('#tide-gen'), age: $('#tide-age'), + where: $('#tide-where'), lineage: $('#tide-lineage'), bar: $('#tide-energy-bar'), energy: $('#tide-energy'), + goal: $('#tide-goal'), carrying: $('#tide-carrying'), log: $('#tide-log') }; + const followButton = $('#tide-follow'); + const duration = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; + function eventText(e, subject) { + const who = e.who === subject ? 'It' : e.who; + switch (e.type) { + case 'birth': return `${who} cast ${e.other}.`; + case 'end': return e.detail === 'eaten' ? `${who} was taken by ${e.other}.` : e.detail === 'caught' ? `${who} was caught by ${e.other}.` : + e.detail === 'toppled' ? `${who} was toppled by ${e.other}.` : `${who} ${e.detail}.`; + case 'steal': return `${who} lifted brass from ${e.other}.`; + case 'hoard': return `${who} hoarded ${e.detail}.`; + case 'collect': return `${who} picked up brass.`; + case 'gather': return `${who} picked up ${e.detail}.`; + case 'build': return `${who} set a ${e.detail} block.`; + case 'dismantle': return `${who} knocked a block loose.`; + case 'scavenge': return `${who} salvaged a ${e.detail}.`; + 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 'inspect': return `${who} inspected ${e.detail}.`; + case 'grow': return `${who} grew a segment.`; + case 'wash': return `The tide brought ${e.detail}.`; + default: return null; + } + } + function inspect() { + if (selected === null) { sheet.hidden = true; return; } + const c = creatureById(world, selected); + sheet.hidden = false; + if (c) { + const S = SPECIES[c.sp]; + sheet.classList.remove('is-ended'); + fields.kind.textContent = `${S.name}${S.core ? ' · brass core' : ''}`; + fields.title.textContent = label(c); + fields.gen.textContent = String(c.gen); + fields.age.textContent = duration(c.age); + fields.where.textContent = POOLS[c.pool].name; + const parent = world.creatures.find(o => o.id === c.parent) || world.ended.find(o => o.id === c.parent); + fields.lineage.textContent = `${parent ? `of ${parent.label || label(parent)}` : c.gen === 1 ? 'founder' : 'of —'}${c.kids ? ` · ${c.kids} cast` : ''}`; + fields.bar.style.width = `${Math.round(clamp(c.energy, 0, 1) * 100)}%`; + fields.energy.textContent = `Energy ${Math.round(c.energy * 100)}%${c.energy < .15 ? ' · failing' : ''}${c.sp === 'pylon' ? ` · ${c.size + 1} segments` : ''}${c.eaten ? ` · ${c.eaten} taken` : ''}`; + fields.goal.textContent = goalText(world, c).replace(/^./, m => m.toUpperCase()) + '.'; + const held = world.objects.find(o => o.id === c.carrying); + fields.carrying.textContent = [held ? `Carrying ${held.kind}` : '', c.shell !== null ? 'Wearing a shell' : '', + c.sp === 'collector' ? `Hoard: ${world.objects.filter(o => o.place === 'hoard' && o.owner === c.id).length} brass` : ''].filter(Boolean).join(' · '); + lastEnded = null; + } else { + const record = world.ended.find(e => e.id === selected) || lastEnded; + if (!record) { release(); return; } + lastEnded = record; + sheet.classList.add('is-ended'); + fields.kind.textContent = `${SPECIES[record.sp].name} · stopped at ${clock(record.time)}`; + fields.title.textContent = record.label; + fields.goal.textContent = record.cause === 'eaten' ? `Taken by ${record.by}.` : record.cause === 'caught' ? `Caught by ${record.by}.` : + record.cause === 'toppled' ? `Toppled by ${record.by}.` : record.cause === 'stranded' ? 'Stranded when the basin drained.' : + record.cause === 'starved' ? 'Ran out of energy.' : 'Wore out with age.'; + fields.carrying.textContent = SPECIES[record.sp].core ? 'Its husk and brass core remain in the water.' : ''; + fields.bar.style.width = '0%'; + fields.energy.textContent = 'Energy 0%'; + if (following) setFollow(false); + } + const name = c ? label(c) : lastEnded?.label; + const lines = world.events.filter(e => e.who === name || e.other === name).slice(-4).reverse(); + fields.log.replaceChildren(...lines.map(e => { + const text = eventText(e, name); + if (!text) return null; + const li = document.createElement('li'), time = document.createElement('time'); + time.textContent = clock(e.time); + li.append(time, text); + return li; + }).filter(Boolean)); + } + function setFollow(on) { + following = on; + followButton.setAttribute('aria-pressed', String(on)); + followButton.textContent = on ? 'Following' : 'Follow'; + } + function select(c, { announce = true } = {}) { + selected = c.id; lastEnded = null; + setFollow(true); + if (goal.zoom > 3.6) goal.zoom = 3.2; + goal.x = c.x; goal.z = c.z; clampGoal(); + if (announce) status.textContent = `Following ${label(c)}, a ${SPECIES[c.sp].name.toLowerCase()}. ${goalText(world, c).replace(/^./, m => m.toUpperCase())}.`; + readout(); wake(); + } + function release() { + selected = null; lastEnded = null; setFollow(false); + sheet.hidden = true; readout(); wake(); + } + followButton.addEventListener('click', () => { + const c = creatureById(world, selected); + if (!c) return; + setFollow(!following); + if (following) { goal.x = c.x; goal.z = c.z; clampGoal(); } + wake(); + }); + $('#tide-release').addEventListener('click', () => { release(); status.textContent = 'Released. Tap any machine to follow it.'; canvas.focus(); }); + + // A quiet ticker of notable events near the view. + 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)); + if (!fresh.length) return; + tickerSeen = world.events.at(-1).time; + for (const e of fresh.slice(-3)) { + const text = eventText(e); + if (!text) continue; + const li = document.createElement('li'), time = document.createElement('time'); + time.textContent = clock(e.time); + li.append(time, text); + ticker.append(li); + chime(e.type, e.type === 'end' ? .05 : .035); + } + while (ticker.children.length > 4) ticker.firstElementChild.remove(); + } + + let readoutClock = 0, filmClock = 0, lastFrame = 0; function loop(now) { frame = 0; - if (paused || !visible || document.hidden || lost) { previous = 0; return; } - // Keep real-time behavior on slower mobile GPUs without destabilizing the solver. - if (previous) { - let elapsed = Math.min((now - previous) / 1000, .25); - while (elapsed > 0) { - const step = Math.min(elapsed, .05); - advanceWorld(world, step); - elapsed -= step; + if (!visible || document.hidden || lost) { previous = 0; return; } + const dt = lastFrame ? Math.min((now - lastFrame) / 1000, .1) : 1 / 60; + lastFrame = now; + if (!paused) { + // Keep real-time behavior on slower GPUs without destabilizing the solver. + if (previous) { + let elapsed = Math.min((now - previous) / 1000, .25); + while (elapsed > 0) { const step = Math.min(elapsed, .05); advanceWorld(world, step); elapsed -= step; } } + previous = now; + filmClock -= dt; readoutClock -= dt; + if (filmClock <= 0) { filmClock = .3; paintFilm(); } + if (readoutClock <= 0) { readoutClock = .4; readout(); tick(); } + } else previous = 0; + const focus = following && creatureById(world, selected); + if (focus) { + // On narrow screens the specimen sheet covers the lower half, so frame the machine above it. + const lift = stage.clientWidth < 720 ? view.zoom * .42 / Math.sin(PITCH) : 0; + const upScreen = v2.setFromMatrixColumn(camera.matrixWorld, 1); upScreen.y = 0; upScreen.normalize(); + goal.x = focus.x - upScreen.x * lift; goal.z = focus.z - upScreen.z * lift; clampGoal(); } - previous = now; - const newest = world.events.at(-1); - if (newest && newest.time > lastEventTime) { - lastEventTime = newest.time; - if (['build', 'steal', 'dismantle'].includes(newest.type)) chime(newest.type === 'steal' ? 'brass' : 'pebble'); - } - render(); frame = requestAnimationFrame(loop); + easeCamera(dt); + render(); + if (!paused || !cameraSettled()) frame = requestAnimationFrame(loop); + else lastFrame = 0; + } + function wake() { + dirty = true; + if (!frame && visible && !document.hidden && !lost) frame = requestAnimationFrame(loop); } function schedule() { syncSound(); - if (!paused && visible && !document.hidden && !lost && !frame) frame = requestAnimationFrame(loop); - else if (paused || !visible || document.hidden || lost) { - cancelAnimationFrame(frame); frame = 0; previous = 0; - } + if (visible && !document.hidden && !lost) wake(); + else { cancelAnimationFrame(frame); frame = 0; previous = 0; lastFrame = 0; } } function resize() { const { width, height } = stage.getBoundingClientRect(); - renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5, 1200 / width)); + renderer.setPixelRatio(Math.min(devicePixelRatio, mobile ? 1.5 : 2, 1800 / Math.max(1, width))); renderer.setSize(width, height, false); - const aspect = width / height; - const portrait = aspect < .85; - camera.position.set(portrait ? 12 : 0, 15, portrait ? 0 : 12); - camera.lookAt(0, 0, 0); - const halfW = portrait ? 3.5 : Math.max(5.3, aspect * 3.8); - camera.left = -halfW; camera.right = halfW; - const verticalOffset = portrait ? -.7 : -.45; - camera.top = halfW / aspect + verticalOffset; camera.bottom = -halfW / aspect + verticalOffset; - camera.updateProjectionMatrix(); render(); + if (width / height < .8 && goal.zoom === 5.4) goal.zoom = view.zoom = 7; + placeCamera(); wake(); } + + const names = { brass: 'Brass', shell: 'Shell', pebble: 'Pebble', feed: 'Feed' }; + const hints = { + look: 'Tap a machine to follow it.', brass: 'A collector will come for it.', shell: 'A scraper may move into it.', + pebble: 'A mason will set it on a cairn.', feed: 'Film and plankton bloom where it lands.', + }; function drop(x, z) { - const o = offerObject(world, selected, x, z); + if (!insidePool(x, z)) { status.textContent = 'Tap inside a pool.'; 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; } - if (paused) o.height = 0; - impactBorn = world.time; - impact.position.set(x, .085, z); - touchLight.position.set(x, 1.3, z); + if (paused && o.height) o.height = 0; root.dataset.engaged = 'true'; - status.textContent = `${selected === 'brass' ? 'Brass' : selected === 'shell' ? 'Shell' : 'Pebble'} dropped.${paused ? ' Press Play when you want to watch.' : ' Watch the creature turn toward it.'}`; - chime(selected); render(); + const pool = poolAt(x, z).name; + const drawn = o.id !== null && world.creatures.find(c => c.task?.object === o.id); + status.textContent = `${names[tool]} dropped in pool ${pool}.${drawn ? ` ${label(drawn)} turns toward it.` : ''}${paused ? ' Press Play when you want to watch.' : ''}`; + chime(tool); paintFilm(); readout(); wake(); } - const raycaster = new T.Raycaster(); - const plane = new T.Plane(new T.Vector3(0, 1, 0), -.06); - function point(event) { + + // Pointer: drag to pan, pinch or scroll to zoom, tap to select or drop. + const pointers = new Map(); + let gesture = null; + function panBy(dx, dy) { const bounds = canvas.getBoundingClientRect(); - const position = new T.Vector2((event.clientX - bounds.left) / bounds.width * 2 - 1, -(event.clientY - bounds.top) / bounds.height * 2 + 1); - raycaster.setFromCamera(position, camera); - const hit = raycaster.ray.intersectPlane(plane, new T.Vector3()); - if (!hit) return null; - const radius = Math.hypot(hit.x / 4.45, hit.z / 3.15); - const scale = radius > .9 ? .9 / radius : 1; - return { x: hit.x * scale, z: hit.z * scale }; - } - let down = null; - canvas.addEventListener('pointerdown', e => { down = { x: e.clientX, y: e.clientY }; }); - canvas.addEventListener('pointercancel', () => { down = null; }); - canvas.addEventListener('pointerup', e => { - if (!down || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 10) { down = null; return; } - down = null; const hit = point(e); - if (hit) { cursor = hit; drop(hit.x, hit.z); } - else status.textContent = 'Tap inside the water, or use the Drop button.'; + const unit = view.zoom * 2 / bounds.height; + const right = v1.setFromMatrixColumn(camera.matrixWorld, 0); right.y = 0; right.normalize(); + const upScreen = v2.setFromMatrixColumn(camera.matrixWorld, 1); upScreen.y = 0; upScreen.normalize(); + const fore = unit / Math.sin(PITCH); + goal.x -= right.x * dx * unit - upScreen.x * dy * fore; + goal.z -= right.z * dx * unit - upScreen.z * dy * fore; + view.x = goal.x; view.z = goal.z; + clampGoal(); view.x = goal.x; view.z = goal.z; + } + function zoomAt(factor, clientX, clientY) { + const before = clientX !== undefined && groundAt(clientX, clientY); + goal.zoom = clamp(goal.zoom * factor, ZMIN, ZMAX); + view.zoom = goal.zoom; + placeCamera(); + if (before && !following) { + const after = groundAt(clientX, clientY); + if (after) { goal.x += before.x - after.x; goal.z += before.z - after.z; clampGoal(); view.x = goal.x; view.z = goal.z; } + } + wake(); + } + canvas.addEventListener('pointerdown', e => { + canvas.setPointerCapture?.(e.pointerId); + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pointers.size === 1) gesture = { x: e.clientX, y: e.clientY, moved: false }; + else if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + gesture = { pinch: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, moved: true }; + } }); canvas.addEventListener('pointermove', e => { - if (e.pointerType !== 'mouse') return; - const hit = point(e); marker.visible = !!hit; - if (hit) cursor = hit; - if (paused) render(); + const known = pointers.get(e.pointerId); + if (!known) { + if (e.pointerType === 'mouse') { + const hit = floorAt(e.clientX, e.clientY); + const over = creatureAt(e.clientX, e.clientY); + marker.visible = !!hit && tool !== 'look' && !over && insidePool(hit.x, hit.z); + if (hit) cursor = hit; + canvas.classList.toggle('is-dropping', marker.visible); + canvas.style.cursor = over ? 'pointer' : ''; + wake(); + } + return; + } + const dx = e.clientX - known.x, dy = e.clientY - known.y; + known.x = e.clientX; known.y = e.clientY; + if (pointers.size === 1 && gesture) { + if (!gesture.moved && Math.hypot(e.clientX - gesture.x, e.clientY - gesture.y) > 6) { + gesture.moved = true; canvas.classList.add('is-dragging'); + if (following) setFollow(false); + } + if (gesture.moved) { panBy(dx, dy); wake(); } + } else if (pointers.size === 2 && gesture?.pinch) { + const [a, b] = [...pointers.values()]; + const d = Math.hypot(a.x - b.x, a.y - b.y), mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + if (!following) panBy(mid.x - gesture.mid.x, mid.y - gesture.mid.y); + zoomAt(gesture.pinch / Math.max(1, d), mid.x, mid.y); + gesture.pinch = d; gesture.mid = mid; + } }); - canvas.addEventListener('pointerleave', () => { marker.visible = false; if (paused) render(); }); - canvas.addEventListener('focus', () => { marker.visible = true; render(); }); - canvas.addEventListener('blur', () => { marker.visible = false; render(); }); + function endPointer(e, cancelled) { + if (!pointers.has(e.pointerId)) return; + pointers.delete(e.pointerId); + canvas.classList.remove('is-dragging'); + if (pointers.size) { gesture = null; return; } + const tap = gesture && !gesture.moved && !cancelled; + gesture = null; + if (!tap) return; + const hitCreature = creatureAt(e.clientX, e.clientY); + if (hitCreature) { select(hitCreature); return; } + const hit = floorAt(e.clientX, e.clientY); + if (tool === 'look') { + if (selected !== null) { release(); status.textContent = 'Released. Tap any machine to follow it.'; } + else status.textContent = 'Tap a machine to follow it, or choose a material to drop.'; + return; + } + if (hit) { cursor = hit; drop(hit.x, hit.z); } + } + canvas.addEventListener('pointerup', e => endPointer(e, false)); + canvas.addEventListener('pointercancel', e => endPointer(e, true)); + canvas.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !pointers.size) { marker.visible = false; wake(); } }); + canvas.addEventListener('wheel', e => { + e.preventDefault(); + const delta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + zoomAt(Math.exp(clamp(delta, -120, 120) * .0022), e.clientX, e.clientY); + }, { passive: false }); + canvas.addEventListener('focus', () => { if (tool !== 'look') marker.visible = true; wake(); }); + canvas.addEventListener('blur', () => { marker.visible = false; wake(); }); + + function stepSelection(direction) { + const list = [...world.creatures].sort((a, b) => a.x - b.x || a.z - b.z); + if (!list.length) return; + const i = list.findIndex(c => c.id === selected); + const next = i < 0 ? list.reduce((a, c) => Math.hypot(c.x - view.x, c.z - view.z) < Math.hypot(a.x - view.x, a.z - view.z) ? c : a) + : list[(i + direction + list.length) % list.length]; + select(next); + } canvas.addEventListener('keydown', e => { - if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' '].includes(e.key)) e.preventDefault(); - if (e.key === 'Enter' || e.key === ' ') { drop(cursor.x, cursor.z); return; } - const next = { ...cursor }; - // Screen-relative arrows also work with the rotated portrait camera. - const axis = new T.Vector3().setFromMatrixColumn(camera.matrixWorld, - e.key === 'ArrowLeft' || e.key === 'ArrowRight' ? 0 : 1); + const handled = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' ', '+', '=', '-', '_', '[', ']', 'Escape', '1', '2', '3', '4', '5']; + if (!handled.includes(e.key)) return; + e.preventDefault(); + if (e.key === 'Escape') { if (selected !== null) { release(); status.textContent = 'Released.'; } return; } + if (e.key === '[' || e.key === ']') return stepSelection(e.key === ']' ? 1 : -1); + if ('+=-_'.includes(e.key)) return zoomAt(e.key === '+' || e.key === '=' ? .8 : 1.25); + if ('12345'.includes(e.key)) return goToBasin(+e.key - 1); + if (e.key === 'Enter' || e.key === ' ') { + if (tool === 'look') { + const near = world.creatures.reduce((a, c) => !a || Math.hypot(c.x - cursor.x, c.z - cursor.z) < Math.hypot(a.x - cursor.x, a.z - cursor.z) ? c : a, null); + if (near && Math.hypot(near.x - cursor.x, near.z - cursor.z) < 1.5) select(near); + else status.textContent = 'No machine near the marker. Brackets step between machines.'; + } else drop(cursor.x, cursor.z); + return; + } + // Screen-relative arrows move the marker; with Shift they pan the view. + const axis = new T.Vector3().setFromMatrixColumn(camera.matrixWorld, e.key === 'ArrowLeft' || e.key === 'ArrowRight' ? 0 : 1); axis.y = 0; axis.normalize(); const direction = e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 1; - if (e.key.startsWith('Arrow')) { next.x += axis.x * .25 * direction; next.z += axis.z * .25 * direction; } - if ((next.x / 4.45) ** 2 + (next.z / 3.15) ** 2 < .96) cursor = next; - marker.visible = true; render(); + const step = e.shiftKey ? view.zoom * .25 : .25; + if (e.shiftKey) { + if (following) setFollow(false); + goal.x += axis.x * step * direction; goal.z += axis.z * step * direction; clampGoal(); + } else { + const next = { x: cursor.x + axis.x * step * direction, z: cursor.z + axis.z * step * direction }; + if (Number.isFinite(next.x) && (poolAt(next.x, next.z, .1) || !poolAt(cursor.x, cursor.z))) cursor = next; + if (Math.abs(cursor.x - goal.x) > view.zoom * .8 || Math.abs(cursor.z - goal.z) > view.zoom * .6) { goal.x = cursor.x; goal.z = cursor.z; clampGoal(); } + } + marker.visible = true; wake(); }); - root.querySelectorAll('[data-material]').forEach(button => button.addEventListener('click', () => { - selected = button.dataset.material; - root.querySelectorAll('[data-material]').forEach(b => b.setAttribute('aria-pressed', String(b === button))); - document.querySelector('#tide-drop').textContent = `Drop ${selected} ↓`; - const instructions = document.querySelector('#tide-instructions'); - instructions.replaceChildren(document.createTextNode(`Tap the water to drop ${selected}.`)); + + function goToBasin(i) { + const p = POOLS[i]; + if (following) setFollow(false); + goal.x = (p.x0 + p.x1) / 2; goal.z = (p.z0 + p.z1) / 2 + .3; goal.zoom = Math.min(Math.max(goal.zoom, 4.4), 6); + cursor = { x: goal.x, z: goal.z - .3 }; + clampGoal(); + const n = world.creatures.filter(c => c.pool === i).length; + const dry = waterLevel(world) + p.depth < .05; + status.textContent = `Pool ${p.name}. ${n} machine${n === 1 ? '' : 's'}, film ${Math.round(avgFilm(world, i) * 100)}%.${dry ? ' Dry at this tide.' : ''}`; + readout(); wake(); + } + basinButtons.forEach(b => b.addEventListener('click', () => goToBasin(+b.dataset.basin))); + $('#tide-zoom-in').addEventListener('click', () => zoomAt(.75)); + $('#tide-zoom-out').addEventListener('click', () => zoomAt(1.33)); + counts.forEach(b => b.addEventListener('click', () => { + // Step through the members of one species, starting with the one nearest the view. + const members = world.creatures.filter(c => c.sp === b.dataset.species) + .sort((a, c) => Math.hypot(a.x - view.x, a.z - view.z) - Math.hypot(c.x - view.x, c.z - view.z)); + if (!members.length) { status.textContent = `No ${SPECIES[b.dataset.species].name.toLowerCase()}s right now. The sea may bring one at high tide.`; return; } + const current = members.findIndex(c => c.id === selected); + select(current >= 0 ? members[(current + 1) % members.length] : members[0]); + })); + + const toolButtons = [...root.querySelectorAll('[data-tool]')]; + toolButtons.forEach(button => button.addEventListener('click', () => { + tool = button.dataset.tool; + toolButtons.forEach(b => b.setAttribute('aria-pressed', String(b === button))); + const instructions = $('#tide-instructions'); + instructions.replaceChildren(document.createTextNode(tool === 'look' ? 'Tap a machine to follow it.' : `Tap the water to drop ${tool}.`)); const hint = document.createElement('span'); - hint.textContent = selected === 'brass' ? 'The collector will come for it.' : 'The builder will make something of it.'; + hint.textContent = hints[tool]; instructions.append(hint); - status.textContent = paused ? 'The pool is paused. You can still leave an offering.' : `Or use the Drop ${selected} button below.`; + status.textContent = tool === 'look' ? hints.look : `${names[tool]} selected. ${hints[tool]}`; + marker.visible = tool !== 'look' && document.activeElement === canvas; + wake(); })); - document.querySelector('#tide-drop').addEventListener('click', () => { - cursor = { x: (world.random() - .5) * 4, z: (world.random() - .5) * 2.5 }; - drop(cursor.x, cursor.z); - }); pauseButton.addEventListener('click', () => { paused = !paused; updateButtons(); schedule(); - status.textContent = paused ? 'The pool is resting.' : 'The pool is awake.'; + status.textContent = paused ? 'The pool is resting. You can still pan, zoom, and follow.' : 'The pool is awake.'; }); soundButton.addEventListener('click', async () => { try { @@ -391,27 +1073,21 @@ async function initialize() { sound = !sound; if (sound) await audio.resume(); updateButtons(); syncSound(); - status.textContent = sound ? 'Sound on. Small tones accompany drops and discoveries.' : 'Sound off.'; + status.textContent = sound ? 'Sound on. Small tones mark drops, births, and endings.' : 'Sound off.'; if (sound) chime('shell'); } catch { sound = false; updateButtons(); status.textContent = 'Sound is unavailable. The pool can stay silent.'; } }); - document.querySelector('#tide-describe').addEventListener('click', () => { - const labels = { thief: 'Violet collector', builder: 'Ivory builder', dismantler: 'Dark dismantler' }; - const observations = world.creatures.map(c => { - const o = world.objects.find(object => object.id === c.target); - const activity = c.state === 'carry' ? `carrying ${o?.kind} ${c.role === 'thief' ? 'to the nook' : c.role === 'builder' ? 'to the shelter' : 'back into the open water'}` : - c.state === 'inspect' ? `investigating ${o?.kind}` : c.state === 'approach' ? `approaching ${o?.kind}` : c.state === 'wander' ? 'walking through the shallows' : 'resting'; - return `${labels[c.role]}: ${activity}.`; - }); - const hoard = world.objects.filter(o => o.place === 'hoard').length; - const shelter = world.objects.filter(o => o.place === 'shelter').length; - document.querySelector('#tide-description').textContent = `${paused ? 'Paused. ' : ''}${observations.join(' ')} ${hoard} brass pieces collected; ${shelter} objects arranged around the shelter.`; + $('#tide-describe').addEventListener('click', () => { + const c = creatureById(world, selected); + const focus = c ? ` You are following ${label(c)}, ${goalText(world, c)}.` : ''; + $('#tide-description').textContent = `${paused ? 'Paused. ' : ''}${describeWorld(world)}${focus}`; }); - document.querySelector('#tide-reset').addEventListener('click', () => { - world = createWorld(); lastEventTime = -1; impactBorn = -10; + $('#tide-reset').addEventListener('click', () => { + world = createWorld(); tickerSeen = 0; ticker.replaceChildren(); + release(); if (paused) world.objects.forEach(o => { o.height = 0; }); - status.textContent = 'A fresh pool. Three creatures, three small offerings.'; - render(); + status.textContent = 'A fresh pool. Five hollows, six species, a few offerings.'; + paintFilm(); readout(); wake(); }); reduced.addEventListener('change', () => { if (reduced.matches) { paused = true; updateButtons(); schedule(); status.textContent = 'Paused for reduced motion. Play is available when you want it.'; } @@ -422,15 +1098,19 @@ async function initialize() { canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; schedule(); canvas.hidden = true; root.querySelector('.tide-still').removeAttribute('hidden'); - root.querySelectorAll('.tide-controls button, #tide-describe').forEach(b => { b.disabled = true; }); + root.querySelectorAll('.tide-controls button, #tide-describe, [data-species]').forEach(b => { b.disabled = true; }); status.textContent = 'The 3D view was interrupted. Reload the page to return to the pool.'; }); window.addEventListener('pagehide', () => { visible = false; schedule(); }); window.addEventListener('pageshow', () => { visible = true; schedule(); }); + if (paused) world.objects.forEach(o => { o.height = 0; }); + paintFilm(); resize(); canvas.hidden = false; root.querySelector('.tide-still').setAttribute('hidden', ''); - root.querySelectorAll('.tide-controls button, #tide-describe').forEach(b => { b.disabled = false; }); - status.textContent = paused ? 'Paused for reduced motion. Press Play when you want to watch.' : 'Or use the Drop brass button below.'; - updateButtons(); schedule(); + root.querySelectorAll('.tide-controls button, #tide-describe, [data-species]').forEach(b => { b.disabled = false; }); + status.textContent = paused ? 'Paused for reduced motion. Press Play when you want to watch.' : 'Drag to look around. Tap a machine to follow it.'; + updateButtons(); readout(); schedule(); + // Test and debugging hook; read-only by convention. + window.__tidePool = { get world() { return world; }, select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; } diff --git a/scripts/test-tide-pool-browser.mjs b/scripts/test-tide-pool-browser.mjs index f409703..c9c80f6 100644 --- a/scripts/test-tide-pool-browser.mjs +++ b/scripts/test-tide-pool-browser.mjs @@ -22,7 +22,7 @@ try { const response = await page.goto(`${base}/artifacts/tide-pool?immersive-review=2`, { waitUntil: 'networkidle' }); assert.equal(response.status(), 200); assert.equal(response.headers()['x-robots-tag'], undefined); - await page.waitForFunction(() => !document.querySelector('#tide-drop').disabled); + await page.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled); assert.equal(await page.evaluate(() => window.__audio.length), 0); for (const [width, height] of [[1440,900],[1024,768],[768,1024],[393,852],[320,720],[844,430]]) { await page.setViewportSize({ width, height }); @@ -32,26 +32,36 @@ try { assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), width); for (const b of await page.locator('.tide-controls button').all()) { const rect = await b.boundingBox(); - assert.ok(rect.height >= 44 && rect.x >= 0 && rect.x + rect.width <= width); + assert.ok(rect.height >= 40 && rect.x >= 0 && rect.x + rect.width <= width); assert.ok(rect.y >= 0 && rect.y + rect.height <= height); } await page.screenshot({ path: `${out}/${width}x${height}.png` }); sizes.push({ width, height, fullscreen: true, overflow: false }); } await page.setViewportSize({ width: 1440, height: 900 }); - for (const kind of ['pebble','shell','brass']) { - await page.locator(`[data-material="${kind}"]`).click(); - assert.match(await page.locator('#tide-drop').textContent(), new RegExp(kind)); + for (const kind of ['pebble', 'shell', 'feed', 'brass']) { + await page.locator(`[data-tool="${kind}"]`).click(); assert.match(await page.locator('#tide-instructions').textContent(), new RegExp(kind)); - await page.locator('#tide-drop').click(); - assert.match(await page.locator('#tide-status').textContent(), /dropped/); + await page.locator('#tide-canvas').focus(); + await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter'); + assert.match(await page.locator('#tide-status').textContent(), /dropped in pool/); } - await page.locator('#tide-canvas').click({ position: { x: 720, y: 410 } }); assert.equal(await page.locator('[data-tide-pool]').getAttribute('data-engaged'), 'true'); - await page.screenshot({ path: `${out}/touch-response.png` }); - await page.locator('#tide-canvas').focus(); - await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter'); - assert.match(await page.locator('#tide-status').textContent(), /Brass dropped/); + await page.screenshot({ path: `${out}/offering-response.png` }); + // Follow a machine from the census, then release it. + await page.locator('[data-species="breaker"]').click(); + assert.ok(await page.locator('#tide-inspect').isVisible()); + assert.match(await page.locator('#tide-inspect-title').textContent(), /^BR-\d{3}$/); + await page.screenshot({ path: `${out}/following.png` }); + await page.locator('#tide-release').click(); + assert.ok(await page.locator('#tide-inspect').isHidden()); + // Zoom and pool navigation move the camera without leaving the page. + const before = await page.evaluate(() => ({ ...window.__tidePool.goal })); + await page.locator('#tide-zoom-out').click(); + await page.locator('[data-basin="0"]').click(); + const after = await page.evaluate(() => ({ ...window.__tidePool.goal })); + assert.ok(after.x < before.x - 5); + assert.match(await page.locator('#tide-status').textContent(), /^Pool I\./); await page.locator('#tide-sound').click(); await page.waitForFunction(() => window.__audio[0]?.state === 'running'); await page.locator('#tide-pause').click(); @@ -69,14 +79,15 @@ try { await page.locator('.tide-notes summary').click(); const watch = await page.evaluate(async () => { let collected = false, built = false, dismantled = false; + // In this edition: hoarded brass, standing cairns, and any machine that stopped. const start = performance.now(), observations = new Set(); while (performance.now() - start < 150000) { document.querySelector('#tide-describe').click(); const text = document.querySelector('#tide-description').textContent; observations.add(text); - collected ||= /[1-9] brass pieces/.test(text); - built ||= /[1-9] objects arranged/.test(text); - dismantled ||= /dismantler: carrying/.test(text); + collected ||= /[1-9]\d* brass pieces/.test(text); + built ||= /[1-9]\d* stones stacked/.test(text); + dismantled ||= /was taken by|was caught by|starved|wore out/.test(text); if (collected && built && dismantled) break; await new Promise(r => setTimeout(r, 700)); } @@ -92,31 +103,31 @@ try { const touch = await browser.newContext({ viewport: { width: 393, height: 852 }, isMobile: true, hasTouch: true, reducedMotion: 'reduce' }); const m = await touch.newPage(); await m.goto(`${base}/artifacts/tide-pool`, { waitUntil: 'networkidle' }); - await m.waitForFunction(() => !document.querySelector('#tide-drop').disabled); + await m.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled); assert.equal(await m.locator('#tide-pause').textContent(), 'Play'); await m.locator('#tide-canvas').focus(); for (let i = 0; i < 5; i++) await m.keyboard.press('ArrowRight'); await m.screenshot({ path: `${out}/portrait-keyboard-marker.png` }); await m.keyboard.press('Enter'); assert.match(await m.locator('#tide-status').textContent(), /Brass dropped/); - await m.locator('#tide-canvas').tap({ position: { x: 190, y: 360 } }); - assert.match(await m.locator('#tide-status').textContent(), /Brass dropped/); + await m.locator('#tide-canvas').tap({ position: { x: 196, y: 420 } }); + assert.match(await m.locator('#tide-status').textContent(), /dropped|Tap inside a pool|Following/); await m.screenshot({ path: `${out}/mobile-paused-touch.png` }); await m.locator('#tide-pause').tap(); await m.locator('.tide-notes summary').tap(); await m.locator('#tide-describe').tap(); - assert.match(await m.locator('#tide-description').textContent(), /collector:/); + assert.match(await m.locator('#tide-description').textContent(), /The pools hold/); await m.locator('.tide-notes summary').tap(); await m.evaluate(() => document.querySelector('#tide-canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()); await m.waitForFunction(() => document.querySelector('#tide-status').textContent.includes('interrupted')); assert.ok(await m.locator('.tide-still').isVisible()); - assert.ok(await m.locator('#tide-drop').isDisabled()); + assert.ok(await m.locator('[data-tool="brass"]').isDisabled()); await touch.close(); const fallback = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 393, height: 852 } }); const f = await fallback.newPage(); await f.goto(`${base}/artifacts/tide-pool`); assert.ok(await f.locator('.tide-still').isVisible()); await f.screenshot({ path: `${out}/no-javascript.png` }); await fallback.close(); - await writeFile(`${out}/receipt.json`, JSON.stringify({ base, at: new Date().toISOString(), sizes, errors, passed: ['fullscreen geometry', 'desktop and mobile touch', 'keyboard', 'material-specific instructions', 'instant offering response', 'pause pixel equality', 'opt-in audio', 'reduced motion', 'context loss', 'no-JS fallback'] }, null, 2)); + await writeFile(`${out}/receipt.json`, JSON.stringify({ base, at: new Date().toISOString(), sizes, errors, passed: ['fullscreen geometry', 'desktop and mobile touch', 'keyboard', 'material-specific instructions', 'instant offering response', 'follow and release', 'zoom and pool navigation', 'pause pixel equality', 'opt-in audio', 'reduced motion', 'context loss', 'no-JS fallback'] }, null, 2)); console.log(JSON.stringify({ passed: true, base, out, sizes })); } finally { await browser.close(); } diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index 8964708..6fb0654 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -1,57 +1,109 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWorld, advanceWorld, dropObject, offerObject, insidePool, LIMIT } from '../public/tide-pool-world.js'; +import { + createWorld, advanceWorld, dropObject, offerObject, insidePool, walkable, poolAt, floorY, waterLevel, poolWater, + channelOpen, hops, POOLS, CHANNELS, SPECIES, SPECIES_ORDER, LIMIT, TIDE_PERIOD, describeWorld, goalText, +} from '../public/tide-pool-world.js'; function run(world, seconds) { - for (let i = 0; i < seconds * 30; i++) advanceWorld(world, 1 / 30); + for (let i = 0; i < seconds * 20; i++) advanceWorld(world, 1 / 20); } +const at = p => ({ x: POOLS[p].cx, z: POOLS[p].cz }); -test('a visitor offering immediately attracts a free specialist without stealing a carried task', () => { +test('every pool is reachable, and each gully can be walked end to end', () => { + for (const a of POOLS) for (const b of POOLS) assert.ok(a.id === b.id || hops(a.id, b.id) > 0); + for (const c of CHANNELS) { + const mid = c.axis === 'x' ? (c.z0 + c.z1) / 2 : (c.x0 + c.x1) / 2; + for (let t = 0; t <= 1; t += .02) { + const x = c.axis === 'x' ? c.x0 - .6 + (c.x1 - c.x0 + 1.2) * t : mid; + const z = c.axis === 'z' ? c.z0 - .6 + (c.z1 - c.z0 + 1.2) * t : mid; + assert.ok(walkable(x, z), `gully ${c.id} blocked at ${x.toFixed(2)}, ${z.toFixed(2)}`); + } + } + for (const p of POOLS) assert.ok(Math.abs(floorY(p.cx, p.cz) + p.depth) < .05); +}); + +test('the tide opens gullies at high water and drains the shallow pools at low water', () => { + const world = createWorld(); + world.time = TIDE_PERIOD / 4; + assert.ok(CHANNELS.every(c => channelOpen(world, c))); + assert.ok(POOLS.every(p => poolWater(world, p.id) > .3)); + world.time = TIDE_PERIOD * 3 / 4; + assert.ok(CHANNELS.some(c => !channelOpen(world, c))); + assert.ok(poolWater(world, 3) < .05, 'pool IV dries'); + assert.ok(poolWater(world, 4) > .5, 'the seaward pool stays deep'); + assert.ok(waterLevel(world) < 0); +}); + +test('a visitor offering draws a free specialist immediately, never one with a carried task', () => { const world = createWorld(); - const brass = offerObject(world, 'brass', 0, 0); - assert.equal(world.creatures[0].target, brass.id); - assert.equal(world.creatures[0].state, 'approach'); - assert.equal(brass.claimed, 0); - const shell = offerObject(world, 'shell', 1, 0); - assert.equal(world.creatures[1].target, shell.id); - const newer = offerObject(world, 'shell', 1.5, 0); - assert.equal(shell.claimed, null); - assert.equal(world.creatures[1].target, newer.id); + const brass = offerObject(world, 'brass', at(2).x, at(2).z); + const collector = world.creatures.find(c => c.task?.object === brass.id); + assert.equal(collector.sp, 'collector'); + assert.equal(brass.claimed, collector.id); + const shell = offerObject(world, 'shell', at(1).x, at(1).z); + assert.equal(world.creatures.find(c => c.task?.object === shell.id).sp, 'scraper'); + const pebble = offerObject(world, 'pebble', at(0).x, at(0).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 held = dropObject(world, 'brass', at(3).x, at(3).z); + held.place = 'carried'; held.claimed = carrier.id; carrier.carrying = held.id; + for (let i = 0; i < 6; i++) offerObject(world, 'brass', at(2).x + i * .2, at(2).z); + assert.equal(carrier.carrying, held.id); + const before = world.plankton[2]; + assert.equal(offerObject(world, 'feed', at(2).x, at(2).z).kind, 'feed'); + assert.ok(world.plankton[2] > before); }); -test('a visitor-free world investigates, steals, builds, and gently dismantles', () => { +test('a visitor-free shore grazes, hunts, builds, hoards, breeds, and replaces what it loses', () => { const world = createWorld(); - run(world, 160); - const events = new Set(world.events.map(e => e.type)); - for (const type of ['investigate', 'steal', 'build', 'dismantle']) assert.ok(events.has(type), type); - assert.ok(world.objects.some(o => o.place === 'hoard' && o.kind === 'brass')); - for (const c of world.creatures) assert.ok(insidePool(c.x, c.z)); + const seen = new Set(); + for (let minute = 0; minute < 20; minute++) { + run(world, 60); + for (const e of world.events) seen.add(e.type === 'end' ? `end:${e.detail}` : e.type); + for (const c of world.creatures) { + if (SPECIES[c.sp].kind !== 'swimmer') assert.ok(walkable(c.x, c.z, 0) || poolAt(c.x, c.z), `${c.sp} left the water`); + assert.ok(poolAt(c.x, c.z) || walkable(c.x, c.z, 0)); + } + } + for (const type of ['birth', 'build', 'hoard', 'end:eaten', 'end:caught']) assert.ok(seen.has(type), type); + const maxima = SPECIES_ORDER.map((s, i) => Math.max(...world.census.map(r => r.n[i]))); + assert.ok(maxima.every(n => n > 0), 'every species is present at some point'); + assert.ok(world.creatures.some(c => c.gen > 1), 'lineages continue'); + assert.match(describeWorld(world), /The pools hold/); + for (const c of world.creatures) assert.equal(typeof goalText(world, c), 'string'); }); -test('deterministic reset and bounded object, ripple, event, and memory counts', () => { +test('deterministic reset and bounded object, ripple, event, census, and population counts', () => { const a = createWorld(12), b = createWorld(12); - for (let i = 0; i < 120; i++) { + for (let i = 0; i < 90; i++) { for (const world of [a, b]) { - dropObject(world, ['pebble', 'shell', 'brass'][i % 3], Math.sin(i) * 3, Math.cos(i) * 2); - run(world, 2); + const p = POOLS[i % POOLS.length]; + offerObject(world, ['pebble', 'shell', 'brass', 'feed'][i % 4], p.cx + Math.sin(i) * p.rx * .5, p.cz + Math.cos(i) * p.rz * .5); + run(world, 3); assert.ok(world.objects.length <= LIMIT); assert.ok(world.ripples.length <= 12); - assert.ok(world.events.length <= 60); - assert.ok(world.creatures.every(c => c.visited.length <= LIMIT)); + assert.ok(world.events.length <= 80); + assert.ok(world.census.length <= 150); + for (const s of SPECIES_ORDER) assert.ok(world.creatures.filter(c => c.sp === s).length <= SPECIES[s].max); assert.ok(world.objects.every(o => Number.isFinite(o.x + o.z + o.height))); + assert.ok(world.creatures.every(c => Number.isFinite(c.x + c.z + c.energy))); } } assert.deepEqual(a.objects, b.objects); assert.deepEqual(a.creatures, b.creatures); + assert.deepEqual(a.sites, b.sites); }); test('drops reject invalid inputs and never evict a claimed object', () => { const world = createWorld(); assert.equal(dropObject(world, 'unknown', 0, 0), null); - assert.equal(dropObject(world, 'brass', 5, 5), null); + assert.equal(offerObject(world, 'unknown', at(2).x, at(2).z), null); + assert.equal(dropObject(world, 'brass', 0, -8), null); assert.equal(dropObject(world, 'brass', NaN, 0), null); + assert.ok(!insidePool(-11, 5)); const first = world.objects[0]; first.claimed = 0; - for (let i = 0; i < 100; i++) dropObject(world, 'pebble', 0, 0); + for (let i = 0; i < 200; i++) dropObject(world, 'pebble', at(2).x, at(2).z); assert.equal(world.objects.length, LIMIT); assert.ok(world.objects.includes(first)); }); @@ -65,18 +117,27 @@ test('zero time preserves the world and long frames do not jump it forward', () assert.equal(world.time, .05); }); -test('carried objects stay with their sole owner, including under repeated drops', () => { +test('carried objects stay with their sole owner, and core machines return brass when they stop', () => { const world = createWorld(); - for (let i = 0; i < 5000; i++) { - if (i % 100 === 0) dropObject(world, ['brass', 'shell', 'pebble'][i / 100 % 3], 0, 0); - advanceWorld(world, 1 / 30); + for (let i = 0; i < 8000; i++) { + if (i % 150 === 0) { const p = POOLS[i / 150 % 5]; offerObject(world, ['brass', 'shell', 'pebble'][i / 150 % 3], p.cx, p.cz); } + advanceWorld(world, 1 / 20); const carried = world.creatures.filter(c => c.carrying !== null); assert.equal(new Set(carried.map(c => c.carrying)).size, carried.length); for (const c of carried) { const o = world.objects.find(o => o.id === c.carrying); assert.equal(o.place, 'carried'); assert.equal(o.claimed, c.id); - assert.ok(Math.hypot(o.x - c.x, o.z - c.z) < .75); + assert.ok(Math.hypot(o.x - c.x, o.z - c.z) < .7); } } + const mason = world.creatures.find(c => SPECIES[c.sp].core); + if (mason) { + mason.energy = 0; + const brass = world.objects.filter(o => o.kind === 'brass').length; + advanceWorld(world, .05); + assert.ok(!world.creatures.includes(mason)); + assert.ok(world.objects.filter(o => o.kind === 'brass').length >= brass, 'its brass core returns to the water'); + assert.ok(world.ended.some(e => e.id === mason.id && e.cause === 'starved')); + } }); diff --git a/src/components/artifacts.tsx b/src/components/artifacts.tsx index b243f40..99acd25 100644 --- a/src/components/artifacts.tsx +++ b/src/components/artifacts.tsx @@ -5,7 +5,7 @@ const artifacts = [ title: "Tide pool", kind: "INTERACTIVE ART", description: - "A small mechanical world. Leave an object in the water and see what the ceramic creatures make of it.", + "Five dark pools and six mechanical species. Leave something in the water, then follow who comes for it.", status: "PUBLIC", }, { diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 180fc80..ffa04a9 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,63 +1,141 @@ +const SPECIES = [ + { id: "scraper", code: "SC", name: "Scraper", note: "Grazes the pale film on the floor. Hides in shells and beside cairns." }, + { id: "tab", code: "TB", name: "Tab", note: "Schools in open water. Leaves a pool before it drains." }, + { id: "pylon", code: "PY", name: "Pylon", note: "Stands still, filters the water, and catches tabs." }, + { id: "collector", code: "CL", name: "Collector", note: "Salvages husks and hoards brass in a corner." }, + { id: "mason", code: "MS", name: "Mason", note: "Stacks pebbles and scrap into cairns." }, + { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers and pulls cairns apart to reach them." }, +]; + +const TOOLS = [ + { id: "look", label: "Look" }, + { id: "brass", label: "Brass" }, + { id: "shell", label: "Shell" }, + { id: "pebble", label: "Pebble" }, + { id: "feed", label: "Feed" }, +]; + export function TidePoolContent() { return (
← Artifacts

Tide pool

-

Leave something. See who comes.

+

Five pools in the dark. Leave something. Follow who comes.

- - Three angular mechanical creatures in dark water between sculptural stone outcrops. - - - - {[0, 1, 2, 3, 4].map(i => )} - {[{ x: 203, y: 248, c: '#776283' }, { x: 424, y: 237, c: '#c4bdc9' }, { x: 335, y: 329, c: '#686571' }].map(c => ( - - - - - + + Dark pools in an open field, joined by narrow gullies, with small mechanical creatures on their floors. + + + + {[{ x: 115, y: 280, rx: 80, ry: 55 }, { x: 315, y: 265, rx: 90, ry: 65 }, { x: 520, y: 275, rx: 75, ry: 50 }, { x: 345, y: 400, rx: 65, ry: 42 }].map(p => ( + + + + + ))} + + {[{ x: 100, y: 290, c: "#b3aea6" }, { x: 300, y: 255, c: "#6b6274" }, { x: 340, y: 290, c: "#3a393e" }, { x: 520, y: 270, c: "#d6d0c2" }, { x: 345, y: 405, c: "#b3aea6" }].map(c => ( + + + ))} - - + - +
-
Tap the water to drop brass. The collector will come for it.
+
Tap the water to drop brass. Drag to pan. Scroll or pinch to zoom. Tap a machine to follow it.
+ + + +
-
-
- - - -
- +
+ {TOOLS.map(t => ( + + ))}
+
+ {["I", "II", "III", "IV", "V"].map((name, i) => )} +
+
+ + +
+
); } diff --git a/src/index.tsx b/src/index.tsx index 75c28c8..f188043 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( - + ); -- 2.51.2 From dafa0846f25411757796af1e1580eb51dd2d9c52 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 22 Sep 2026 14:58:18 -0700 Subject: [PATCH 02/28] Give the tide pool eight connected pools in dark open water, realistic water, and legible creature behaviour. --- public/tide-pool-world.js | 193 ++++++++++---- public/tide-pool.css | 106 ++++---- public/tide-pool.js | 489 +++++++++++++++++++++++++---------- scripts/tide-pool.test.mjs | 66 ++++- src/components/artifacts.tsx | 2 +- src/components/tide-pool.tsx | 46 ++-- src/index.tsx | 2 +- 7 files changed, 642 insertions(+), 262 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index d142036..8a08640 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -6,17 +6,21 @@ export const TIDE_PERIOD = 240; export const DAY_PERIOD = 420; export const LIMIT = 72; -// Five rock pools on a shelf: four along the shore, one seaward that the tide refills. +// Eight pools in open water: a large central pool ringed by smaller ones, and a seaward pool the tide refills. // Each is an ellipse with a few slow harmonics, so no two edges repeat. const SHAPES = [ - { id: 0, name: 'I', cx: -16, cz: 0, rx: 4, rz: 3, depth: .55, wob: [.14, .4, .08, 1.3, .05, 2.1] }, - { id: 1, name: 'II', cx: -6, cz: -1, rx: 4, rz: 3, depth: .85, wob: [.1, 2.2, .12, .5, .06, 4] }, - { id: 2, name: 'III', cx: 4.5, cz: 0, rx: 4.5, rz: 3, depth: .75, wob: [.12, 1.1, .07, 2.8, .05, .3] }, - { id: 3, name: 'IV', cx: 14.5, cz: -1.5, rx: 3.5, rz: 2.5, depth: .45, wob: [.16, 3.1, .1, 1.7, .06, 5.2] }, - { id: 4, name: 'V', cx: 5, cz: 7.5, rx: 3, rz: 2.5, depth: 1.2, sea: true, wob: [.08, .9, .1, 4.2, .05, 1.1] }, + { id: 0, name: 'I', cx: -34, cz: -2, rx: 7, rz: 5.2, depth: .55, wob: [.14, .4, .08, 1.3, .05, 2.1] }, + { id: 1, name: 'II', cx: -15, cz: -12, rx: 7.5, rz: 5.5, depth: .85, wob: [.1, 2.2, .12, .5, .06, 4] }, + { id: 2, name: 'III', cx: 6, cz: -2, rx: 9, rz: 6.2, depth: .75, wob: [.12, 1.1, .07, 2.8, .05, .3] }, + { id: 3, name: 'IV', cx: 27, cz: -11, rx: 6.5, rz: 4.8, depth: .45, wob: [.16, 3.1, .1, 1.7, .06, 5.2] }, + { id: 4, name: 'V', cx: 12, cz: 22, rx: 6.5, rz: 5, depth: 1.2, sea: true, wob: [.08, .9, .1, 4.2, .05, 1.1] }, + { id: 5, name: 'VI', cx: -22, cz: 11, rx: 6, rz: 4.5, depth: .65, wob: [.12, 5.1, .08, 2.2, .06, .9] }, + { id: 6, name: 'VII', cx: 31, cz: 10, rx: 5.5, rz: 4.2, depth: .9, wob: [.1, 1.8, .12, 3.9, .05, 2.6] }, + { id: 7, name: 'VIII', cx: -5, cz: 15, rx: 5, rz: 3.8, depth: .5, wob: [.14, 2.7, .06, .2, .07, 4.4] }, ]; const shapeR = (p, a) => 1 - p.wob[0] * (1 + Math.cos(2 * a + p.wob[1])) / 2 - p.wob[2] * (1 + Math.cos(3 * a + p.wob[3])) / 2 - p.wob[4] * (1 + Math.cos(5 * a + p.wob[5])) / 2; +export { shapeR }; export const rho = (p, x, z) => { const u = (x - p.cx) / p.rx, v = (z - p.cz) / p.rz; return Math.hypot(u, v) / shapeR(p, Math.atan2(v, u)); @@ -34,21 +38,39 @@ export const POOLS = SHAPES.map(p => { return pool; }); -// Narrow gullies through the rock. Each runs well into both pools. +// Gullies curve from well inside one pool to well inside the next, so every mouth is open water. +// Together they make a loop round the central pool, two branches, and two routes to the sea. +const GULLY_SAMPLES = 12; +function gully(a, b, sill, bend) { + const [p, q] = [SHAPES[a], SHAPES[b]]; + const toward = (from, to) => { + const ang = Math.atan2((to.cz - from.cz) / from.rz, (to.cx - from.cx) / from.rx); + const r = shapeR(from, ang) * .62; + return { x: from.cx + Math.cos(ang) * from.rx * r, z: from.cz + Math.sin(ang) * from.rz * r }; + }; + const A = toward(p, q), B = toward(q, p); + const len = Math.hypot(B.x - A.x, B.z - A.z); + const ctrl = { x: (A.x + B.x) / 2 - (B.z - A.z) / len * bend, z: (A.z + B.z) / 2 + (B.x - A.x) / len * bend }; + const pts = []; + for (let i = 0; i <= GULLY_SAMPLES; i++) { + const t = i / GULLY_SAMPLES, u = 1 - t; + pts.push({ x: u * u * A.x + 2 * u * t * ctrl.x + t * t * B.x, z: u * u * A.z + 2 * u * t * ctrl.z + t * t * B.z }); + } + const xs = pts.map(p => p.x), zs = pts.map(p => p.z); + return { a, b, sill, pts, x0: Math.min(...xs) - 1, x1: Math.max(...xs) + 1, z0: Math.min(...zs) - 1, z1: Math.max(...zs) + 1 }; +} export const CHANNELS = [ - { a: 0, b: 1, x0: -13.8, x1: -8.8, z0: -1, z1: 0, sill: .4 }, - { a: 1, b: 2, x0: -3.2, x1: 1.2, z0: -1, z1: 0, sill: .5 }, - { a: 2, b: 3, x0: 7.8, x1: 12.2, z0: -1, z1: 0, sill: .35 }, - { a: 2, b: 4, x0: 4, x1: 5, z0: 1.8, z1: 6.2, sill: .6 }, -].map((c, id) => ({ ...c, id, axis: c.x1 - c.x0 > c.z1 - c.z0 ? 'x' : 'z' })); + gully(0, 1, .4, 2), gully(1, 2, .5, -1.5), gully(2, 7, .5, 1.5), gully(7, 5, .4, -1), gully(5, 0, .45, 1.5), + gully(2, 3, .35, 1.5), gully(2, 6, .55, -2), gully(6, 4, .6, 1.5), gully(7, 4, .5, -1), +].map((c, id) => ({ ...c, id })); export const SPECIES = { - scraper: { code: 'SC', name: 'Scraper', kind: 'walker', speed: .4, max: 24, start: 12, min: 4, burn: .0055, radius: .28 }, - tab: { code: 'TB', name: 'Tab', kind: 'swimmer', speed: 1.05, max: 40, start: 16, min: 5, burn: .007, radius: .14 }, - pylon: { code: 'PY', name: 'Pylon', kind: 'sessile', max: 14, start: 5, min: 2, burn: .0025, radius: .3 }, - collector: { code: 'CL', name: 'Collector', kind: 'walker', speed: .85, max: 7, start: 3, min: 1, burn: .0055, radius: .38, core: true }, - mason: { code: 'MS', name: 'Mason', kind: 'walker', speed: .55, max: 7, start: 3, min: 1, burn: .005, radius: .4, core: true }, - breaker: { code: 'BR', name: 'Breaker', kind: 'walker', speed: .62, max: 6, start: 2, min: 1, burn: .0075, radius: .48, core: true }, + scraper: { code: 'SC', name: 'Scraper', kind: 'walker', speed: .4, max: 30, start: 14, min: 4, burn: .0055, radius: .28 }, + tab: { code: 'TB', name: 'Tab', kind: 'swimmer', speed: 1.05, max: 32, start: 15, min: 5, burn: .007, radius: .14 }, + pylon: { code: 'PY', name: 'Pylon', kind: 'sessile', max: 12, start: 6, min: 2, burn: .0025, radius: .3 }, + collector: { code: 'CL', name: 'Collector', kind: 'walker', speed: .95, max: 9, start: 4, min: 1, burn: .0035, radius: .38, core: true }, + mason: { code: 'MS', name: 'Mason', kind: 'walker', speed: .58, max: 9, start: 4, min: 1, burn: .0045, radius: .4, core: true }, + breaker: { code: 'BR', name: 'Breaker', kind: 'walker', speed: .75, max: 8, start: 2, min: 1, burn: .0042, radius: .48, core: true }, }; export const SPECIES_ORDER = Object.keys(SPECIES); export const MATERIALS = ['brass', 'shell', 'pebble', 'feed']; @@ -74,15 +96,21 @@ export function poolAt(x, z, m = 0) { return null; } // Gullies wander a little: their width and centre line change along their length. -// How far inside a gully's edge a point sits; negative outside. +// How far inside a gully's edge a point sits; negative outside. Width swells and narrows along its length. +export const gullyHalf = (c, t) => 1 + Math.sin(t * 9 + c.id) * .14 + Math.sin(t * 23 + c.id * 3) * .06; function gullyInset(c, x, z) { - const along = c.axis === 'x' ? x : z, across = c.axis === 'x' ? z : x; - if (along < (c.axis === 'x' ? c.x0 : c.z0) || along > (c.axis === 'x' ? c.x1 : c.z1)) return -Infinity; - const mid = (c.axis === 'x' ? c.z0 + c.z1 : c.x0 + c.x1) / 2 + Math.sin(along * 1.3 + c.id * 2) * .08; - const half = .52 + Math.sin(along * 2.3 + c.id) * .1 + Math.sin(along * 5.1 + c.id * 3) * .05; - return half - Math.abs(across - mid); + if (x < c.x0 || x > c.x1 || z < c.z0 || z > c.z1) return -Infinity; + let best = Infinity, at = 0; + for (let i = 0; i < c.pts.length - 1; i++) { + const a = c.pts[i], b = c.pts[i + 1], dx = b.x - a.x, dz = b.z - a.z; + const u = clamp(((x - a.x) * dx + (z - a.z) * dz) / (dx * dx + dz * dz), 0, 1); + const d = Math.hypot(a.x + dx * u - x, a.z + dz * u - z); + if (d < best) { best = d; at = (i + u) / (c.pts.length - 1); } + } + return gullyHalf(c, at) - best; } export const inGully = (c, x, z, m = 0) => gullyInset(c, x, z) > m; +export { gullyInset }; function channelAt(x, z, m = 0) { for (const c of CHANNELS) if (inGully(c, x, z, m)) return c; return null; @@ -103,8 +131,9 @@ export function basinFloor(x, z) { for (const c of CHANNELS) { const inset = gullyInset(c, x, z); if (inset <= 0) continue; - const t = clamp(inset / .22, 0, 1); - y = Math.min(y ?? 0, -c.sill * t * t * (3 - 2 * t) + sand(x, z) * .5 * t); + // A rounded U across the whole width, so there is no crease where the floor meets the sides. + const t = clamp(inset / .9, 0, 1); + y = Math.min(y ?? 0, -c.sill * (1 - Math.cos(t * Math.PI)) / 2); } return y; } @@ -115,11 +144,11 @@ function pullIn(p, pt, m = .5) { for (let k = 0; k < 24 && edgeDistance(p, x, z) <= m; k++) { x += (p.cx - x) * .15; z += (p.cz - z) * .15; } return { x, z, pool: p.id }; } +// A gully's waypoints, ordered from one pool toward the other. +const gullyPath = (ch, fromPool) => ch.a === fromPool ? ch.pts : [...ch.pts].reverse(); function mouth(ch, poolId) { const p = POOLS[poolId]; - const x = ch.axis === 'x' ? (p.cx < (ch.x0 + ch.x1) / 2 ? ch.x0 + .35 : ch.x1 - .35) : (ch.x0 + ch.x1) / 2; - const z = ch.axis === 'z' ? (p.cz < (ch.z0 + ch.z1) / 2 ? ch.z0 + .35 : ch.z1 - .35) : (ch.z0 + ch.z1) / 2; - return pullIn(p, { x, z }, .3); + return pullIn(p, gullyPath(ch, poolId)[0], .3); } // Breadth-first routes between pools; walkers may use a gully at any tide. const ROUTES = POOLS.map(from => { @@ -143,11 +172,15 @@ const ROUTES = POOLS.map(from => { export const hops = (a, b) => ROUTES[a][b].length; function route(fromPool, to) { const path = []; - for (const step of ROUTES[fromPool][to.pool]) path.push(mouth(step.ch, step.pool), mouth(step.ch, step.to)); + for (const step of ROUTES[fromPool][to.pool]) { + path.push(mouth(step.ch, step.pool)); + path.push(...gullyPath(step.ch, step.pool).filter((_, i) => i % 2 === 0).slice(1, -1)); + path.push(mouth(step.ch, step.to)); + } path.push({ x: to.x, z: to.z }); return path; } -const cost = (c, o) => distance(c, o) + hops(c.pool, o.pool) * 4; +const cost = (c, o) => distance(c, o) * .7 + hops(c.pool, o.pool) * 4; export const label = c => `${SPECIES[c.sp].code}-${String(c.serial).padStart(3, '0')}`; @@ -181,7 +214,7 @@ export function createWorld(seed = 41) { plankton: POOLS.map(p => p.sea ? .8 : .45), nextWash: 45, nextCensus: 0, }; - const homes = { scraper: [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 0, 2], tab: [1, 2, 4], pylon: [1, 2, 4, 2, 1], collector: [1, 2, 3], mason: [0, 1, 2], breaker: [1, 3] }; + const homes = { scraper: [0, 1, 2, 3, 5, 6, 7, 2, 1, 0, 5, 2, 3, 7], tab: [1, 2, 4, 6, 2], pylon: [1, 2, 4, 6, 7, 2], collector: [1, 2, 5, 6], mason: [0, 2, 7, 3], breaker: [1, 6] }; for (const sp of SPECIES_ORDER) for (let i = 0; i < SPECIES[sp].start; i++) { const list = homes[sp], pool = list[i % list.length]; const at = randomPoint(world, pool, sp === 'pylon' ? 1.1 : .8); @@ -211,7 +244,7 @@ function spawn(world, sp, x, z, pool, extra = {}) { vx: 0, vz: 0, speed: 0, phase: world.random() * 6, energy: extra.energy ?? .45, age: 0, 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, site: null, size: sp === 'pylon' ? extra.size ?? 1 : 1, gesture: 0, strike: 0, eaten: 0, + home: null, site: null, size: sp === 'pylon' ? extra.size ?? 1 : 1, gesture: 0, strike: 0, eaten: 0, open: 1, pause: 0, }; if (S.kind === 'swimmer') { const angle = world.random() * Math.PI * 2; @@ -236,7 +269,7 @@ function nook(world, pool) { } function addObject(world, kind, x, z, extra = {}) { - const pool = poolAt(x, z) || poolAt(clamp(x, -20, 18), clamp(z, -4, 10)); + const pool = poolAt(x, z); if (!pool) return null; if (world.objects.length >= LIMIT) { const removable = world.objects.find(o => o.claimed === null && o.place === 'loose' && o.kind !== 'brass') || @@ -282,6 +315,8 @@ export function offerObject(world, kind, x, z) { if (c) { idle(world, c); assign(world, c, kind === 'shell' ? 'wear' : 'fetch', object, { use: kind === 'brass' && c.sp !== 'collector' ? 'breed' : undefined }); + // It stops, turns, and regards the offering before it sets off. + c.state = 'notice'; c.pause = .9; c.gesture = -.25; } event(world, 'offer', null, kind); @@ -412,7 +447,7 @@ function graze(world, c, efficiency = 1) { const richest = neighbours.sort((a, b) => avgFilm(world, b) - avgFilm(world, a))[0]; if (richest !== undefined && avgFilm(world, richest) > here + .1) best = randomPoint(world, richest, 1); } - assign(world, c, 'graze', null, { at: best, patience: 25 }); + assign(world, c, 'graze', null, { at: best, patience: 12 }); c.task.efficiency = efficiency; } export const avgFilm = (world, p) => world.film[p].reduce((a, b) => a + b, 0) / POOLS[p].cells; @@ -443,17 +478,17 @@ function thinkScraper(world, c) { function hoardOf(world, c) { return world.objects.filter(o => o.place === 'hoard' && o.owner === c.id); } function brassSource(world, c, allowSteal) { - const loose = nearest(world, c, looseOf(world, ['brass']), 24); + const loose = nearest(world, c, looseOf(world, ['brass']), 38); if (loose) return { object: loose, steal: false }; if (!allowSteal) return null; - const hoarded = nearest(world, c, world.objects.filter(o => o.place === 'hoard' && o.owner !== c.id && o.claimed === null), 18); + const hoarded = nearest(world, c, world.objects.filter(o => o.place === 'hoard' && o.owner !== c.id && o.claimed === null), 29); return hoarded ? { object: hoarded, steal: true } : null; } function thinkCollector(world, c) { if (c.carrying !== null) return assign(world, c, 'deliver', null, { at: c.home, patience: 50 }); if (c.energy < .6) { - const food = nearest(world, c, looseOf(world, ['husk', 'scrap']), 22); + const food = nearest(world, c, looseOf(world, ['husk', 'scrap']), 35); if (food) return assign(world, c, 'eat', food); return graze(world, c, .6); } @@ -472,7 +507,7 @@ function thinkCollector(world, c) { function siteFor(world, c) { const site = world.sites.find(s => s.id === c.site); if (site && site.blocks.length < 12) return site; - if (world.sites.length >= 10) { + if (world.sites.length >= 14) { const open = world.sites.filter(s => s.blocks.length < 12).sort((a, b) => cost(c, a) - cost(c, b))[0]; if (open) { c.site = open.id; return open; } return null; @@ -481,7 +516,7 @@ function siteFor(world, c) { const at = randomPoint(world, c.pool, 1.1); const clear = world.sites.every(s => distance(s, at) > 1.7) && world.creatures.every(o => o.sp !== 'pylon' || distance(o, at) > 1) && - CHANNELS.every(ch => !(at.x > ch.x0 - .8 && at.x < ch.x1 + .8 && at.z > ch.z0 - .8 && at.z < ch.z1 + .8)); + CHANNELS.every(ch => gullyInset(ch, at.x, at.z) < -1.2); if (!clear) continue; const s = { id: world.nextSite++, pool: c.pool, x: at.x, z: at.z, blocks: [], builder: c.id, angle: Math.floor(world.random() * 4) * Math.PI / 2 }; world.sites.push(s); @@ -511,7 +546,7 @@ function thinkMason(world, c) { const source = brassSource(world, c, world.random() < .5); if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); } - const material = c.energy > .5 && nearest(world, c, looseOf(world, ['pebble', 'scrap', 'husk']), 13); + const material = c.energy > .5 && nearest(world, c, looseOf(world, ['pebble', 'scrap', 'husk']), 21); if (material && siteFor(world, c)) return assign(world, c, 'fetch', material); graze(world, c); } @@ -524,16 +559,16 @@ function thinkBreaker(world, c) { } if (c.energy < .92) { const prey = world.creatures.filter(s => s.sp === 'scraper' && s.shell === null); - const target = nearest(world, c, prey, 20); + const target = nearest(world, c, prey, 32); if (target) { const refuge = world.sites.find(s => s.pool === target.pool && s.blocks.length >= 2 && distance(s, target) < .85); if (refuge) return assign(world, c, 'dismantle', null, { at: { x: refuge.x + .55, z: refuge.z + .2, pool: refuge.pool }, site: refuge.id, patience: 25 }); return assign(world, c, 'hunt', null, { at: { x: target.x, z: target.z, pool: target.pool }, prey: target.id, reach: .8, patience: 18 }); } if (c.energy < .3) { - const shelled = nearest(world, c, world.creatures.filter(s => s.sp === 'scraper' && s.shell !== null), 16); + const shelled = nearest(world, c, world.creatures.filter(s => s.sp === 'scraper' && s.shell !== null), 26); if (shelled) return assign(world, c, 'crack', null, { at: { x: shelled.x, z: shelled.z, pool: shelled.pool }, prey: shelled.id, reach: .5, patience: 20 }); - const pylon = nearest(world, c, world.creatures.filter(s => s.sp === 'pylon'), 16); + const pylon = nearest(world, c, world.creatures.filter(s => s.sp === 'pylon'), 26); if (pylon) return assign(world, c, 'topple', null, { at: { x: pylon.x, z: pylon.z, pool: pylon.pool }, prey: pylon.id, reach: .55, patience: 25 }); } } @@ -556,6 +591,16 @@ function arrive(world, c) { case 'inspect': event(world, 'inspect', c, o?.kind); c.seen = [...(c.seen || []).slice(-10), t.object]; return work(c, 1.6); case 'eat': case 'wear': case 'fetch': case 'steal': if (!o || (o.place !== 'loose' && o.place !== 'hoard') || (o.claimed !== null && o.claimed !== c.id)) return idle(world, c); + // Collectors walk a slow ring around a find before they touch it. + if (c.sp === 'collector' && !t.circled) { + t.circled = true; + const start = Math.atan2(c.z - o.z, c.x - o.x); + const ring = [1, 2, 3, 4, 5].map(k => ({ x: o.x + Math.cos(start + k * 1.2) * .55, z: o.z + Math.sin(start + k * 1.2) * .55 })) + .filter(p => walkable(p.x, p.z)); + c.path = [...ring, { x: o.x, z: o.z }]; + c.state = 'move'; + return; + } return work(c, t.kind === 'eat' ? 2.6 : 1.4); case 'deliver': case 'build': return work(c, 1.2); case 'breed': return work(c, 3); @@ -710,7 +755,17 @@ function moveSwimmer(world, c, dt) { const pool = POOLS[c.pool]; const wd = poolWater(world, c.pool); // Migrate before a pool drains, or toward richer water through an open channel. - if (!c.migrate || world.random() < dt * .05) { + // A tab caught in a gully without a plan carries on to the nearer end rather than stalling there. + const inPool = poolAt(c.x, c.z, .05); + if (!inPool && !c.migrate) { + const ch = channelAt(c.x, c.z, -.3) || CHANNELS.reduce((a, g) => gullyInset(g, c.x, c.z) > gullyInset(a, c.x, c.z) ? g : a); + const ends = [ch.a, ch.b].map(id => ({ id, d: distance(c, gullyPath(ch, id)[0]) })); + const [near, far] = ends[0].d < ends[1].d ? ends : [ends[1], ends[0]]; + const way = gullyPath(ch, far.id); + const i = way.reduce((best, pt, k) => distance(c, pt) < distance(c, way[best]) ? k : best, 0); + c.migrate = { ch: ch.id, to: near.id, from: far.id, i }; + } + if (inPool && (!c.migrate || world.random() < dt * .05)) { c.migrate = null; const exits = CHANNELS.filter(ch => (ch.a === c.pool || ch.b === c.pool) && channelOpen(world, ch)); const falling = !tideRising(world.time); @@ -718,7 +773,7 @@ function moveSwimmer(world, c, dt) { for (const ch of exits) { const to = ch.a === c.pool ? ch.b : ch.a; const s = world.plankton[to] + (POOLS[to].depth > pool.depth ? .12 : 0) - .2; - if (s > score) { score = s; best = { ch: ch.id, to }; } + if (s > score) { score = s; best = { ch: ch.id, to, from: c.pool, i: 0 }; } } c.migrate = best; } @@ -738,8 +793,12 @@ function moveSwimmer(world, c, dt) { } const migrating = c.migrate !== null; if (migrating) { + // Follow the gully's curve one waypoint at a time. const ch = CHANNELS[c.migrate.ch]; - const target = mouth(ch, c.migrate.to); + const way = gullyPath(ch, c.migrate.from ?? c.pool); + c.migrate.i = c.migrate.i ?? 0; + while (c.migrate.i < way.length - 1 && Math.hypot(way[c.migrate.i].x - c.x, way[c.migrate.i].z - c.z) < .6) c.migrate.i++; + const target = way[c.migrate.i]; const dx = target.x - c.x, dz = target.z - c.z, d = Math.hypot(dx, dz) || 1; ax += dx / d * 2.2; az += dz / d * 2.2; } else { @@ -750,6 +809,11 @@ function moveSwimmer(world, c, dt) { ax += dx / d * (1.1 - e) * 3; az += dz / d * (1.1 - e) * 3; } } + // Fresh ripples scatter the school. + for (const r of world.ripples) { + 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; } + } const a = (world.random() - .5) * 2.2; ax += Math.cos(c.phase * .3 + a) * .5; az += Math.sin(c.phase * .3 + a) * .5; c.vx += ax * dt; c.vz += az * dt; @@ -758,11 +822,16 @@ function moveSwimmer(world, c, dt) { if (sp > max) { c.vx *= max / sp; c.vz *= max / sp; } else if (sp < min && sp > 1e-5) { c.vx *= min / sp; c.vz *= min / sp; } const nx = c.x + c.vx * dt, nz = c.z + c.vz * dt; - const ok = (x, z) => poolAt(x, z, .2) || (migrating && channelAt(x, z, .25)); + const ok = (x, z) => poolAt(x, z, .2) || channelAt(x, z, .3); if (ok(nx, nz)) { c.x = nx; c.z = nz; } else if (ok(nx, c.z)) { c.x = nx; c.vz *= -.6; } else if (ok(c.x, nz)) { c.z = nz; c.vx *= -.6; } - else { c.vx *= -.6; c.vz *= -.6; } + else { + // Boxed in: turn toward open water instead of bouncing in place. + const aim = migrating ? gullyPath(CHANNELS[c.migrate.ch], c.migrate.from)[c.migrate.i] : { x: pool.cx, z: pool.cz }; + const dx = aim.x - c.x, dz = aim.z - c.z, d = Math.hypot(dx, dz) || 1; + c.vx = dx / d * .4; c.vz = dz / d * .4; + } const p = poolAt(c.x, c.z); if (p && p.id !== c.pool) { c.pool = p.id; c.migrate = null; } c.speed = Math.hypot(c.vx, c.vz); @@ -809,7 +878,7 @@ function sea(world) { for (const sp of SPECIES_ORDER) { if (count(world, sp) >= SPECIES[sp].min || world.time - world.arrivals[sp] < 40) continue; const at = randomPoint(world, sea.id, 1); - const c = spawn(world, sp, at.x, sea.z1 - 1, sea.id, { energy: .6, size: 2 }); + const c = spawn(world, sp, at.x, at.z, sea.id, { energy: .6, size: 2 }); if (c) { world.arrivals[sp] = world.time; event(world, 'arrive', c); } } if (world.time >= world.nextWash) { @@ -817,7 +886,7 @@ function sea(world) { const r = world.random(); const kind = r < .3 ? 'brass' : r < .55 ? 'shell' : 'pebble'; const at = randomPoint(world, sea.id, 1); - if (addObject(world, kind, at.x, sea.z1 - 1.2, { height: .8 })) event(world, 'wash', null, kind); + if (addObject(world, kind, at.x, at.z, { height: .8 })) event(world, 'wash', null, kind); } } @@ -861,12 +930,16 @@ export function advanceWorld(world, elapsed) { moveSwimmer(world, c, dt); } else if (S.kind === 'sessile') { const pl = world.plankton[c.pool]; - if (!dry) { c.energy = Math.min(1, c.energy + pl * .008 * light * dt); world.plankton[c.pool] = Math.max(0, pl - .0003 * dt); } + // Pylons fold shut when the water is disturbed and open only once it is still again. + const disturbed = dry || world.ripples.some(r => world.time - r.born < 2.5 && distance(r, c) < 2.2) || + world.creatures.some(b => b.sp === 'breaker' && distance(b, c) < 1.3); + c.open += ((disturbed ? 0 : 1) - c.open) * Math.min(1, dt * (disturbed ? 3 : .35)); + if (!dry) { c.energy = Math.min(1, c.energy + pl * .008 * light * c.open * dt); world.plankton[c.pool] = Math.max(0, pl - .0003 * dt); } if (c.timer <= 0) { c.timer = 1; const reach = .45 + c.size * .1; const tab = world.creatures.find(t => t.sp === 'tab' && t.pool === c.pool && distance(t, c) < reach); - if (tab && !dry && c.energy < .85 && world.random() < .16) { + if (tab && !dry && c.open > .6 && c.energy < .85 && world.random() < .16) { c.energy = Math.min(1, c.energy + .22); c.strike = 1; c.eaten++; die(world, tab, 'caught', label(c)); } @@ -877,7 +950,15 @@ export function advanceWorld(world, elapsed) { c.gesture = Math.sin(c.phase * .5) * .2; } else { if (c.state === 'idle' && c.timer <= 0) think(world, c); - else if (c.state === 'move') { + else if (c.state === 'notice') { + const at = c.task?.at; + if (at) { + const desired = Math.atan2(at.x - c.x, at.z - c.z); + c.angle += clamp(Math.atan2(Math.sin(desired - c.angle), Math.cos(desired - c.angle)), -dt * 3, dt * 3); + } + c.pause -= dt; + if (c.pause <= 0) c.state = c.task ? 'move' : 'idle'; + } else if (c.state === 'move') { // Hunts re-aim at moving prey; fleeing prey re-check the threat. if (c.task?.prey !== null && c.task?.prey !== undefined && c.path.length <= 1) { const prey = creatureById(world, c.task.prey); @@ -968,9 +1049,11 @@ export function goalText(world, c) { if (c.migrate) return `swimming toward pool ${POOLS[c.migrate.to].name}`; return 'schooling and filtering the water'; } - if (S.kind === 'sessile') return c.strike > .2 ? 'closing on a catch' : 'filtering the water'; + if (S.kind === 'sessile') return c.strike > .2 ? 'closing on a catch' : c.open < .35 ? 'folded shut, waiting for still water' : c.open < .9 ? 'opening again' : 'filtering the water'; const t = c.task; if (!t) return c.carrying !== null ? 'deciding where to take its load' : 'pausing'; + if (c.state === 'notice') return 'noticing something new in the water'; + if (c.sp === 'collector' && t.circled && c.path.length > 1) return 'circling its find'; 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 68f39bb..1703de2 100644 --- a/public/tide-pool.css +++ b/public/tide-pool.css @@ -9,7 +9,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-pool :is(button, a, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .tide-pool button { font: inherit; border-radius: 0; cursor: pointer; } .tide-pool button:disabled { opacity: .45; cursor: default; } -.tide-kicker, .tide-hud dt, .tide-census h2, .tide-facts dt { margin: 0; font-size: .64rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); } +.tide-kicker, .tide-hud dt, .tide-census summary, .tide-facts dt { margin: 0; font-size: .64rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); } .tide-heading { position: absolute; z-index: 2; top: max(20px, env(safe-area-inset-top)); left: clamp(16px, 3.4vw, 56px); pointer-events: none; } .tide-exit { pointer-events: auto; display: inline-flex; min-height: 44px; align-items: center; font-size: .8rem; } @@ -26,18 +26,19 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-label { position: absolute; left: 0; top: 0; z-index: 1; pointer-events: none; white-space: nowrap; font-size: .62rem; letter-spacing: .14em; text-transform: uppercase; color: #cfc8c0; text-shadow: 0 1px 4px #000; transition: opacity .3s; } .tide-label b { font: 400 1.5rem/1 var(--site-font-display); letter-spacing: 0; margin-right: .45rem; vertical-align: -.2rem; color: #ece6df; } .tide-stage canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -6px; } -.tide-figure figcaption { position: absolute; bottom: 168px; left: 16px; right: 16px; text-align: center; color: #f0e7f3; font-size: clamp(.9rem, 1.4vw, 1.05rem); pointer-events: none; text-shadow: 0 2px 8px #000; transition: opacity .6s; } -.tide-figure figcaption span { display: block; color: #aaa1af; margin-top: .35rem; font-size: .75rem; } -.tide-pool[data-engaged] figcaption, .tide-pool:has(.tide-inspect:not([hidden])) figcaption { opacity: 0; } /* Readout: clock, tide and census, set as a hairline table. */ -.tide-hud { position: absolute; z-index: 3; top: 76px; right: clamp(16px, 3.4vw, 56px); width: 244px; padding: .1rem .8rem .5rem; background: #0b0a0cd0; border: 1px solid var(--line); box-sizing: border-box; } -.tide-clock { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.tide-hud { position: absolute; z-index: 3; top: 76px; right: clamp(16px, 3.4vw, 56px); width: 244px; } +.tide-clock { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; border-top: 1px solid var(--line); } .tide-clock div { padding: .5rem 0 .55rem; } .tide-clock div + div { padding-left: .7rem; border-left: 1px solid var(--line); } .tide-clock dd { margin: .2rem 0 0; font-variant-numeric: tabular-nums; font-size: .92rem; } -.tide-census { margin-top: .9rem; } -.tide-census h2 { margin-bottom: .45rem; } +.tide-census { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.tide-census summary { display: flex; justify-content: space-between; align-items: center; min-height: 36px; cursor: pointer; list-style: none; } +.tide-census summary::-webkit-details-marker { display: none; } +.tide-census summary::after { content: '+'; font-size: .8rem; letter-spacing: 0; } +.tide-census[open] summary::after { content: '−'; } +.tide-census[open] { padding-bottom: .4rem; background: #0b0a0cc8; } #tide-chart { display: block; width: 100%; height: 56px; border-bottom: 1px solid var(--line); } .tide-census ul { list-style: none; margin: 0; padding: 0; } .tide-census button { display: grid; grid-template-columns: 2.4rem 1fr auto; align-items: center; width: 100%; min-height: 30px; padding: 0; background: none; border: 0; border-bottom: 1px solid #1f1d21; color: #cfc8d3; text-align: left; font-size: .78rem; } @@ -67,34 +68,45 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-inspect-actions button[aria-pressed="true"] { color: #161019; background: var(--accent); border-color: var(--accent); } .tide-inspect.is-ended h2 { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1px; } -/* Controls: one hard-edged strip, tools above, navigation below. */ -.tide-controls { position: absolute; z-index: 3; left: 50%; bottom: max(24px, env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(620px, calc(100% - 32px)); } -.tide-controls button { min-height: 44px; padding: .5rem .8rem; color: #ddd6e1; background: var(--panel); border: 1px solid var(--line); font-size: .78rem; } -.tide-controls button:hover:not(:disabled) { background: #221e26; } -.tide-tools { display: flex; } -.tide-tools button { display: flex; flex: 1; justify-content: center; align-items: center; gap: .45rem; min-width: 0; } -.tide-tools button + button { border-left: 0; } -.tide-tools button[aria-pressed="true"] { color: #161019; background: var(--accent); border-color: var(--accent); } -.tide-tools button[aria-pressed="true"] .tide-swatch { outline: 1px solid #161019; } -.tide-swatch { width: 9px; height: 9px; flex-shrink: 0; background: #a6a4a6; } -.tide-look { background: none; border: 1px solid currentColor; } -.tide-brass { background: #b59b70; } -.tide-shell { background: #e1d8e3; transform: rotate(45deg); } -.tide-pebble { background: #8a8f8c; } -.tide-feed { background: #6f8a78; } -.tide-playback { display: flex; margin-top: .45rem; gap: .45rem; } -.tide-playback > button { border-color: transparent; background: #0b0a0cd8; color: #b5abbc; } -.tide-playback > button[aria-pressed="true"] { color: #fff; text-decoration: underline; text-underline-offset: .3em; } -.tide-playback > button:last-child { margin-left: auto; } +/* Controls: a row of specimen tokens on dark glass. Navigation and playback sit quietly in the corners. */ +.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-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; } +.tide-tools button[aria-pressed="true"] { color: var(--glow); background: #16131a; box-shadow: inset 0 -1px var(--glow), 0 10px 22px -16px var(--glow); } +.tide-tools [data-tool="brass"] { --glow: #d6b980; } +.tide-tools [data-tool="shell"] { --glow: #ebe2d2; } +.tide-tools [data-tool="pebble"] { --glow: #b4b9b6; } +.tide-tools [data-tool="feed"] { --glow: #b3a6c6; } +.tide-token { width: 10px; height: 10px; flex-shrink: 0; opacity: .75; transition: opacity .3s, transform .3s; } +.tide-tools button[aria-pressed="true"] .tide-token { opacity: 1; transform: scale(1.2); } +.tide-look { border: 1px solid currentColor; border-radius: 50%; box-sizing: border-box; } +.tide-brass { background: linear-gradient(135deg, #e6cc92, #8f774c); clip-path: polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0 50%); } +.tide-shell { background: linear-gradient(#f0e8da, #b9ad98); border-radius: 50% 50% 15% 15%; height: 7px; } +.tide-pebble { background: #8a8f8c; border-radius: 45% 55% 50% 40%; height: 8px; } +.tide-feed { background: radial-gradient(circle, #cbbfdc 0 1.2px, transparent 1.6px) 0 0 / 5px 5px; } + +.tide-nav, .tide-utility { position: absolute; z-index: 3; bottom: max(22px, env(safe-area-inset-bottom)); display: flex; align-items: center; gap: .6rem; } +.tide-nav { right: clamp(16px, 3.4vw, 56px); } +.tide-utility { left: clamp(16px, 3.4vw, 56px); gap: 0; } +.tide-nav button, .tide-utility button { min-height: 44px; background: none; border: 0; color: #7f7784; } +.tide-nav button:hover:not(:disabled), .tide-utility button:hover:not(:disabled) { color: #e5e0e8; } .tide-basins, .tide-zoom { display: flex; } -.tide-basins button, .tide-zoom button { min-width: 38px; padding-inline: .45rem; font-family: var(--site-font-display); font-size: .9rem; } -.tide-basins button + button, .tide-zoom button + button { border-left: 0; } -.tide-basins button[aria-current="true"] { color: #fff; box-shadow: inset 0 -2px var(--accent); } +.tide-basins button, .tide-zoom button { min-width: 30px; padding: 0 .3rem; font-family: var(--site-font-display); font-size: .95rem; } +.tide-basins button[aria-current="true"] { color: #fff; box-shadow: inset 0 -1px var(--accent); } +.tide-zoom { border-left: 1px solid #2a272d; padding-left: .3rem; } +.tide-utility button { padding: 0 .7rem 0 0; font-size: .64rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; } +.tide-utility button + button { padding-left: .7rem; border-left: 1px solid #2a272d; } +.tide-utility button[aria-pressed="true"] { color: #e5e0e8; } -.tide-status { position: absolute; left: 16px; right: 16px; bottom: 130px; text-align: center; margin: 0; font-size: .72rem; color: #c8b9ce; pointer-events: none; } -.tide-ticker { position: absolute; z-index: 2; left: clamp(16px, 3.4vw, 56px); bottom: max(28px, env(safe-area-inset-bottom)); width: min(300px, calc(50% - 340px)); margin: 0; padding: 0; list-style: none; font-size: .72rem; line-height: 1.55; color: #a9a1ad; pointer-events: none; } +.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-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: #6f6973; margin-right: .5rem; } +.tide-ticker time { font-variant-numeric: tabular-nums; color: #5f5963; margin-right: .5rem; } .tide-notes { position: absolute; z-index: 5; top: 20px; right: clamp(16px, 3.4vw, 56px); max-width: min(400px, calc(100% - 32px)); font-size: .8rem; color: #c7bdce; } .tide-notes summary { display: block; min-height: 44px; padding: .7rem 0; box-sizing: border-box; cursor: pointer; text-align: right; } @@ -118,7 +130,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-heading h1 { font-size: 2.3rem; margin-top: .1rem; } .tide-heading p { display: none; } .tide-notes { top: 8px; right: 16px; } - .tide-hud { top: calc(max(8px, env(safe-area-inset-top)) + 92px); left: 16px; right: auto; width: auto; padding: .45rem .7rem; } + .tide-hud { top: calc(max(8px, env(safe-area-inset-top)) + 92px); left: 16px; right: auto; width: auto; } .tide-clock { grid-template-columns: repeat(3, auto); justify-content: start; border: 0; } .tide-clock div { padding: 0 .9rem 0 0; } .tide-clock div + div { padding-left: .9rem; } @@ -128,15 +140,18 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-facts { grid-template-columns: repeat(4, auto); gap: .3rem .9rem; margin-bottom: .6rem; } .tide-log { display: none; } .tide-inspect-actions { margin-top: .6rem; } - .tide-controls { width: calc(100% - 24px); bottom: max(12px, env(safe-area-inset-bottom)); } - .tide-tools button { padding: .4rem .2rem; font-size: .7rem; gap: .3rem; } - .tide-playback { gap: .3rem; } - .tide-playback > button { font-size: .7rem; padding-inline: .45rem; } + .tide-controls { left: 12px; right: 12px; transform: none; bottom: max(12px, env(safe-area-inset-bottom)); } + .tide-tools { width: 100%; box-sizing: border-box; } + .tide-tools button { flex: 1; justify-content: center; padding: .4rem .2rem; font-size: .68rem; gap: .35rem; } + .tide-nav, .tide-utility { bottom: calc(max(12px, env(safe-area-inset-bottom)) + 54px); } + .tide-nav { right: auto; left: 8px; bottom: auto; top: calc(max(8px, env(safe-area-inset-top)) + 128px); gap: .2rem; } + .tide-utility { left: 12px; } .tide-zoom { display: none; } - .tide-basins button { min-width: 30px; min-height: 40px; padding-inline: .25rem; font-size: .8rem; } - .tide-playback > button { min-height: 40px; white-space: nowrap; padding-inline: .35rem; } - .tide-basins button { min-width: 28px; } - .tide-figure figcaption { bottom: 190px; font-size: .9rem; } + .tide-basins button { min-width: 30px; font-size: .85rem; } + .tide-utility button { font-size: .58rem; letter-spacing: .1em; padding-right: .45rem; } + .tide-utility button + button { padding-left: .45rem; } + .tide-hint { margin-bottom: 3.6rem; font-size: .7rem; } + .tide-hint span { display: none; } .tide-status { bottom: 112px; font-size: .66rem; } .tide-pool:has(.tide-inspect:not([hidden])) .tide-status { display: none; } } @@ -148,10 +163,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .tide-heading h1 { font-size: 2.2rem; } .tide-heading p { display: none; } .tide-hud { top: 60px; } - .tide-census ul, .tide-census h2 { display: none; } - .tide-inspect { bottom: 118px; } + .tide-inspect { bottom: 90px; } .tide-log { display: none; } - .tide-controls { bottom: 8px; } - .tide-figure figcaption { bottom: 132px; } - .tide-status { bottom: 104px; } + .tide-status { bottom: 84px; } } diff --git a/public/tide-pool.js b/public/tide-pool.js index 6e578e7..fa7b119 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1,7 +1,7 @@ import { createWorld, advanceWorld, offerObject, POOLS, CHANNELS, SPECIES, SPECIES_ORDER, CELL, waterLevel, daylight, - floorY, basinFloor, edgeDistance, inGully, tideOf, tideRising, poolAt, insidePool, label, goalText, describeWorld, creatureById, avgFilm, -} from './tide-pool-world.js?v=3'; + floorY, basinFloor, edgeDistance, inGully, gullyInset, shapeR, gullyHalf, channelOpen, hops, tideOf, tideRising, poolAt, insidePool, label, goalText, describeWorld, creatureById, avgFilm, +} from './tide-pool-world.js?v=7'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -24,7 +24,7 @@ async function initialize() { renderer.shadowMap.type = T.PCFSoftShadowMap; renderer.outputColorSpace = T.SRGBColorSpace; renderer.toneMapping = T.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.05; + renderer.toneMappingExposure = 1.1; const scene = new T.Scene(); scene.background = new T.Color(0x0a0a0a); scene.fog = null; @@ -38,16 +38,44 @@ async function initialize() { sun.shadow.mapSize.set(mobile ? 1024 : 2048, mobile ? 1024 : 2048); sun.shadow.normalBias = .03; sun.shadow.bias = -.0004; scene.add(sun, sun.target); - const fill = new T.DirectionalLight(0xbfb3cc, 1.1); + const fill = new T.DirectionalLight(0xc0b4cc, 1.6); fill.position.set(14, 6, 18); scene.add(fill); // Every lit surface darkens and cools under the water line and carries moving caustic light. - const shared = { uWater: { value: -.35 }, uTime: { value: 0 }, uLight: { value: 1 } }; + const rippleVectors = Array.from({ length: 12 }, () => new T.Vector4(0, 0, -10, 0)); + 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 } }; + // Two warped layers multiplied together give the sharp, netted lines of real caustics. const CAUSTIC = ` + float causticLayer(vec2 p, float t) { + vec2 q = p + vec2(sin(p.y * 1.3 + t * .7), cos(p.x * 1.1 - t * .6)) * .35; + float f = sin(q.x * 2.3 + sin(q.y * 1.9 + t)) + sin(q.y * 2.6 - t * .8 + sin(q.x * 1.4)) + sin((q.x + q.y) * 1.6 + t * .5); + return pow(max(0., 1. - abs(f) * .75), 6.); + } float caustic(vec2 p, float t) { - vec2 q = p * 1.7; - float f = sin(q.x * 2.1 + sin(q.y * 1.7 + t * .6)) + sin(q.y * 2.3 - t * .5 + sin(q.x * 1.3)) + sin((q.x + q.y) * 1.4 + t * .4); - return pow(max(0., 1. - abs(f) * .8), 10.); + return clamp(causticLayer(p * 1.7, t) * 1.3 + causticLayer(p * 2.9 + 3.1, t * 1.3) * .8, 0., 1.6); + }`; + // Slope of a small sum of travelling waves, plus the rings left by drops. Shared by pools and open water. + const WAVES = ` + uniform vec4 uRipples[12]; + vec2 waveSlope(vec2 p, float t, float scale) { + // A slow warp keeps the wave trains from lining up into a lattice. + p += vec2(sin(p.y * .37 + t * .11) + sin(p.y * .71 - p.x * .23), cos(p.x * .29 - t * .13) + cos(p.x * .63 + p.y * .31)) * 1.1; + vec2 d1 = vec2(.96, .28), d2 = vec2(-.37, .93), d3 = vec2(.66, -.75), d4 = vec2(-.98, -.2), d5 = vec2(.2, -.98), d6 = vec2(.8, .6); + vec2 g = d1 * cos(dot(d1, p) * 2.9 * scale + t * 1.3) * .05 + + d2 * cos(dot(d2, p) * 4.3 * scale - t * 1.7) * .045 + + d3 * cos(dot(d3, p) * 6.7 * scale + t * 2.1) * .04 + + d4 * cos(dot(d4, p) * 9.1 * scale - t * 2.4) * .035 + + d5 * cos(dot(d5, p) * 13.3 * scale + t * 2.9) * .03 + + d6 * cos(dot(d6, p) * 17.9 * scale - t * 3.3) * .025; + for (int i = 0; i < 12; i++) { + float age = t - uRipples[i].z; + if (age <= 0. || age > 3.) continue; + vec2 dv = p - uRipples[i].xy; float d = length(dv) + 1e-4; + float x = (d - age * .8) * 12.; + g += dv / d * x * exp(-x * x) * .5 * (1. - age / 3.); + } + return g; }`; function submerge(material) { material.onBeforeCompile = shader => { @@ -60,9 +88,15 @@ async function initialize() { vWorldC = (modelMatrix * cw).xyz;`); shader.fragmentShader = 'varying vec3 vWorldC;\nuniform float uWater, uTime, uLight;\n' + CAUSTIC + '\n' + shader.fragmentShader.replace('#include ', ` - float under = clamp((uWater - vWorldC.y) * 3., 0., 1.); - gl_FragColor.rgb = mix(gl_FragColor.rgb, gl_FragColor.rgb * vec3(.62, .6, .74), under * .65); - gl_FragColor.rgb += vec3(.72, .61, .82) * caustic(vWorldC.xz, uTime * .5) * under * .075 * uLight; + float depth = uWater - 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.)); + 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; + // 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)); + gl_FragColor.rgb += vec3(.8, .76, .9) * lap * .16 * uLight; #include `); }; return material; @@ -92,26 +126,16 @@ async function initialize() { }; // An open, nearly level dark field. The pools are soft hollows in it, not holes in rock. function rockHeight(x, z) { - const lift = smooth(0, 1.2, rimDistance(x, z)); - return .015 + lift * (fbm(x * .18, z * .18) - .45) * .12; + return .015; } - const heightAt = (x, z) => basinFloor(x, z) ?? rockHeight(x, z); - function grainTexture() { - const size = 256, cv = document.createElement('canvas'); - cv.width = cv.height = size; - const g = cv.getContext('2d'), img = g.createImageData(size, size); - for (let i = 0; i < size * size; i++) { - const v = 215 + (rand() - .5) * 36 + (rand() < .015 ? -60 : 0); - img.data.set([v, v, v, 255], i * 4); - } - g.putImageData(img, 0, 0); - const texture = new T.CanvasTexture(cv); - texture.colorSpace = T.SRGBColorSpace; - texture.wrapS = texture.wrapT = T.RepeatWrapping; - texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy()); - return texture; + // Floor light: bright toward a pool's middle, a dimmer glow along gullies, and the larger of the two where they meet. + const TONE_FIELD = new T.Color(0x0c0b0e), TONE_FLOOR = new T.Color(0x3a3544), TONE_DEEP = new T.Color(0x2e2a37); + function toneAt(x, z, y, out) { + const pool = smooth(.1, 1.8, Math.max(0, ...POOLS.map(p => edgeDistance(p, x, z)))); + const gully = Math.max(0, ...CHANNELS.map(c => smooth(0, .45, gullyInset(c, x, z)))) * .55; + return out.copy(TONE_FIELD).lerp(TONE_FLOOR, Math.max(pool, gully)).lerp(TONE_DEEP, clamp(-y - .6, 0, 1) * .5); } - + const heightAt = (x, z) => basinFloor(x, z) ?? rockHeight(x, z); const unitBox = new T.BoxGeometry(1, 1, 1); const materials = new Map(); function lit(color, { metal = false, rough } = {}) { @@ -122,65 +146,136 @@ async function initialize() { } const lamp = new T.MeshBasicMaterial({ color: 0xffffff }); - const X0 = -25, X1 = 23, Z0 = -10.5, Z1 = 17, RES = mobile ? .24 : .16; + const X0 = -47, X1 = 43, Z0 = -23, Z1 = 33, RES = mobile ? .38 : .26; const terrainGeometry = new T.PlaneGeometry(X1 - X0, Z1 - Z0, Math.round((X1 - X0) / RES), Math.round((Z1 - Z0) / RES)); terrainGeometry.rotateX(-Math.PI / 2); terrainGeometry.translate((X0 + X1) / 2, 0, (Z0 + Z1) / 2); { const pos = terrainGeometry.attributes.position, uv = terrainGeometry.attributes.uv; const colors = new Float32Array(pos.count * 3); - const field = new T.Color(0x0b0a0d), hollow = new T.Color(0x16141a), floorTone = new T.Color(0x2e2b34), deep = new T.Color(0x201e27); + const field = TONE_FIELD; const c = new T.Color(); for (let i = 0; i < pos.count; i++) { const x = pos.getX(i), z = pos.getZ(i); const floor = basinFloor(x, z); - const y = floor ?? rockHeight(x, z); + // Under a gully the grid drops out of sight; a smooth ribbon that follows the curve replaces it. + // Only outside the pools: inside them the pool floor and the ribbon meet at the same height. + const underGully = CHANNELS.some(c => inGully(c, x, z, -.1)) && !POOLS.some(p => edgeDistance(p, x, z) > -.05); + const y = underGully ? Math.min(floor ?? 0, 0) - .6 : floor ?? rockHeight(x, z); pos.setY(i, y); uv.setXY(i, x * .35, z * .35); - if (floor !== null) { - // Floors glow faintly toward their middles and dissolve into the field at the edge. - const e = Math.max(...POOLS.map(p => edgeDistance(p, x, z)), 0); - c.copy(hollow).lerp(floorTone, smooth(0, 1.6, e)).lerp(deep, clamp(-y - .6, 0, 1) * .6); - c.multiplyScalar(.9 + fbm(x * 1.3, z * 1.3) * .2); - } else { - c.copy(hollow).lerp(field, smooth(0, .9, rimDistance(x, z))); - } + if (floor !== null) toneAt(x, z, y, c); + else c.copy(field); c.toArray(colors, i * 3); } terrainGeometry.setAttribute('color', new T.BufferAttribute(colors, 3)); terrainGeometry.computeVertexNormals(); } - const terrain = new T.Mesh(terrainGeometry, submerge(new T.MeshStandardMaterial({ vertexColors: true, map: grainTexture(), roughness: .75, metalness: .15 }))); + const terrain = new T.Mesh(terrainGeometry, submerge(new T.MeshLambertMaterial({ vertexColors: true }))); terrain.castShadow = false; terrain.receiveShadow = true; + // Gully ribbons: sampled along the curve and across its width, with heights taken from the same floor the walkers use. + { + const ACROSS = 24, ALONG = 110, positions = [], colors = [], index = []; + const c3 = new T.Color(); + for (const ch of CHANNELS) { + const base = positions.length / 3, n = ch.pts.length - 1; + const at = t => { + const f = t * n, i = Math.min(n - 1, Math.floor(f)), u = f - i; + return { x: ch.pts[i].x + (ch.pts[i + 1].x - ch.pts[i].x) * u, z: ch.pts[i].z + (ch.pts[i + 1].z - ch.pts[i].z) * u }; + }; + for (let j = 0; j <= ALONG; j++) { + const t = j / ALONG, p = at(t), a = at(Math.max(0, t - .01)), b = at(Math.min(1, t + .01)); + const len = Math.hypot(b.x - a.x, b.z - a.z) || 1, nx = -(b.z - a.z) / len, nz = (b.x - a.x) / len; + const half = gullyHalf(ch, t) + .5; + for (let k = 0; k <= ACROSS; k++) { + const s = (k / ACROSS * 2 - 1) * half, x = p.x + nx * s, z = p.z + nz * s; + const y = basinFloor(x, z) ?? rockHeight(x, z); + positions.push(x, y, z); + toneAt(x, z, y, c3); + colors.push(c3.r, c3.g, c3.b); + } + } + for (let j = 0; j < ALONG; j++) for (let k = 0; k < ACROSS; k++) { + const i0 = base + j * (ACROSS + 1) + k, i1 = i0 + ACROSS + 1; + index.push(i0, i1, i0 + 1, i0 + 1, i1, i1 + 1); + } + } + const geo = new T.BufferGeometry(); + geo.setAttribute('position', new T.Float32BufferAttribute(positions, 3)); + geo.setAttribute('color', new T.Float32BufferAttribute(colors, 3)); + geo.setIndex(index); + geo.computeVertexNormals(); + const ribbons = new T.Mesh(geo, submerge(new T.MeshLambertMaterial({ vertexColors: true, side: T.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -4 }))); + ribbons.receiveShadow = true; + scene.add(ribbons); + } scene.add(terrain); // The field continues past the modelled shore in every direction. - const beyondMaterial = submerge(new T.MeshStandardMaterial({ color: 0x0b0a0d, roughness: .75, metalness: .15 })); - for (const [x0, x1, z0, z1] of [[-200, X0, -200, 200], [X1, 200, -200, 200], [X0, X1, -200, Z0], [X0, X1, Z1, 200]]) { - const beyond = new T.Mesh(new T.PlaneGeometry(x1 - x0 + .02, z1 - z0 + .02), beyondMaterial); - beyond.rotation.x = -Math.PI / 2; beyond.position.set((x0 + x1) / 2, .005, (z0 + z1) / 2); beyond.receiveShadow = true; - scene.add(beyond); - } + // Open water: one flat, dark, metallic sheet catching the key light evenly, as in the first edition. + // It is cut away wherever a pool or gully opens, so the habitable hollows sit inside it. + const segments = CHANNELS.flatMap(c => c.pts.slice(1).map((b, i) => [c.pts[i], b, gullyHalf(c, i / (c.pts.length - 1)), gullyHalf(c, (i + 1) / (c.pts.length - 1))])); + const openWater = new T.MeshStandardMaterial({ color: 0x17131c, metalness: .4, roughness: .24, transparent: true }); + openWater.onBeforeCompile = shader => { + shader.uniforms.uPools = { value: POOLS.map(p => new T.Vector4(p.cx, p.cz, p.rx, p.rz)) }; + shader.uniforms.uWobA = { value: POOLS.map(p => new T.Vector3(p.wob[0], p.wob[1], p.wob[2])) }; + shader.uniforms.uWobB = { value: POOLS.map(p => new T.Vector3(p.wob[3], p.wob[4], p.wob[5])) }; + shader.uniforms.uSegs = { value: segments.map(([a, b]) => new T.Vector4(a.x, a.z, b.x, b.z)) }; + shader.uniforms.uHalf = { value: segments.map(([, , h0, h1]) => new T.Vector2(h0 + .16, h1 + .16)) }; + shader.uniforms.uTime = shared.uTime; shader.uniforms.uRipples = shared.uRipples; + shader.vertexShader = 'varying vec3 vOpen;\n' + shader.vertexShader.replace('#include ', `#include + vOpen = (modelMatrix * vec4(transformed, 1.)).xyz;`); + shader.fragmentShader = `varying vec3 vOpen; uniform float uTime; + ${WAVES} + uniform vec4 uPools[${POOLS.length}]; uniform vec3 uWobA[${POOLS.length}]; uniform vec3 uWobB[${POOLS.length}]; uniform vec4 uSegs[${segments.length}]; uniform vec2 uHalf[${segments.length}]; + float seaAlpha; + // The open water thins out softly over the last stretch before a pool or gully, rather than ending on a cut line. + void cutPools() { + seaAlpha = 1.; + for (int i = 0; i < ${POOLS.length}; i++) { + vec2 d = (vOpen.xz - uPools[i].xy) / uPools[i].zw; + float a = atan(d.y, d.x); + float r = 1. - uWobA[i].x * (1. + cos(2. * a + uWobA[i].y)) * .5 - uWobA[i].z * (1. + cos(3. * a + uWobB[i].x)) * .5 - uWobB[i].y * (1. + cos(5. * a + uWobB[i].z)) * .5; + seaAlpha = min(seaAlpha, smoothstep(.97, 1.14, length(d) / r)); + } + for (int i = 0; i < ${segments.length}; i++) { + vec2 a = uSegs[i].xy, ab = uSegs[i].zw - a; + float u = clamp(dot(vOpen.xz - a, ab) / dot(ab, ab), 0., 1.); + float h = mix(uHalf[i].x, uHalf[i].y, u); + seaAlpha = min(seaAlpha, smoothstep(h - .2, h + .35, length(vOpen.xz - a - ab * u))); + } + if (seaAlpha < .003) discard; + } + ` + shader.fragmentShader.replace('void main() {', 'void main() {\n cutPools();').replace('#include ', 'gl_FragColor.a *= seaAlpha;\n#include ') + .replace('#include ', `#include + { vec2 g = waveSlope(vOpen.xz * .55, uTime * .8, 1.) * .55; normal = normalize((viewMatrix * vec4(normalize(vec3(-g.x, 1., -g.y)), 0.)).xyz); }`); + }; + const sea = new T.Mesh(new T.PlaneGeometry(400, 400), openWater); + sea.rotation.x = -Math.PI / 2; sea.position.set(0, .03, 0); sea.receiveShadow = true; sea.renderOrder = 1; + scene.add(sea); - // One water sheet across the shore. Rock above the line occludes it. - const rippleVectors = Array.from({ length: 12 }, () => new T.Vector4(0, 0, -10, 0)); + // One water sheet over the pools. The open water above it hides it everywhere else. const waterMaterial = new T.ShaderMaterial({ transparent: true, depthWrite: false, - uniforms: { uTime: shared.uTime, uLight: shared.uLight, uRipples: { value: rippleVectors } }, + uniforms: { uTime: shared.uTime, uLight: shared.uLight, uRipples: shared.uRipples, uSun: shared.uSun, uView: shared.uView }, vertexShader: 'varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }', fragmentShader: `precision mediump float; - varying vec3 vW; uniform float uTime, uLight; uniform vec4 uRipples[12]; - ${CAUSTIC} + varying vec3 vW; uniform float uTime, uLight; uniform vec3 uSun, uView; + ${WAVES} void main() { - float c = caustic(vW.xz * .9 + vec2(uTime * .02, 0.), uTime * .35); - float ring = 0.; - for (int i = 0; i < 12; i++) { - float age = uTime - uRipples[i].z; - float d = length(vW.xz - uRipples[i].xy); - ring += exp(-pow((d - age * .8) * 12., 2.)) * max(0., 1. - age / 3.) * step(0., age); - } - vec3 base = mix(vec3(.03, .025, .045), vec3(.08, .07, .11), uLight); - vec3 col = base + vec3(.72, .61, .82) * (c * .05 * uLight + ring * .3); - gl_FragColor = vec4(col, .26 + c * .04 + ring * .22); + vec2 g = waveSlope(vW.xz, uTime, 1.4); + vec3 n = normalize(vec3(-g.x, 1., -g.y)); + // More reflection where a ripple tilts away from the eye; clear where it faces it. + float facing = max(dot(n, uView), 0.); + float fres = .02 + .98 * pow(1. - facing, 5.); + vec3 h = normalize(uSun + uView); + float nh = max(dot(n, h), 0.); + // Glints are rare and scattered: only the steepest facets, and only in patches that drift. + 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 sky = vec3(.42, .38, .54) * uLight; + vec3 col = mix(deep, sky, clamp(fres * 3., 0., 1.)) + vec3(1., .96, .92) * glint * uLight; + gl_FragColor = vec4(col, clamp(.2 + fres * 2.4 + glint, 0., .9)); }`, }); // Shallow light drifts over the whole field, so the dark reads as open water rather than ground. @@ -195,7 +290,7 @@ async function initialize() { 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.) * .05 * (.4 + .6 * uLight); + float light = pow(max(0., 1. - abs(f) * .7), 24.) * .035 * (.4 + .6 * uLight); for (int i = 0; i < 12; i++) { float age = uTime - uRipples[i].z; float d = length(p - uRipples[i].xy); @@ -213,17 +308,22 @@ async function initialize() { // Loose stones on the floors, and a few sculptural outcrops standing far out in the dark. const stones = [], outcrops = []; - for (let k = 0; k < 4000 && stones.length < 120; k++) { - const x = X0 + rand() * (X1 - X0), z = -5 + rand() * 16; + for (let k = 0; k < 4000 && stones.length < 220; k++) { + const x = X0 + rand() * (X1 - X0), z = Z0 + rand() * (Z1 - Z0); if (basinFloor(x, z) === null) continue; stones.push({ x, z, s: .03 + rand() * rand() * .1, a: rand() * 6, tone: .6 + rand() * .5 }); } - for (let k = 0; k < 3000 && outcrops.length < 26; k++) { - const x = X0 + rand() * (X1 - X0), z = Z0 + rand() * (Z1 - Z0); - const d = rimDistance(x, z); - if (d < 1.2 || d > 7 || outcrops.some(o => Math.hypot(o.x - x, o.z - z) < 2.2)) continue; - const h = .2 + rand() * rand() * 1.8; - outcrops.push({ x, z, h, r: .18 + rand() * .3, a: rand() * 6, lean: (rand() - .5) * .3, tone: rand() < .3 ? 1.6 : .8 + rand() * .3 }); + // Each pool keeps one loose arc of shards on its far side, tallest in the middle of the arc. + for (const p of POOLS) { + const centre = -Math.PI / 2 + (rand() - .5) * 1.4, span = .9 + rand() * .9, count = 3 + Math.floor(rand() * 3); + for (let k = 0; k < count; k++) { + const f = count > 1 ? k / (count - 1) : .5, a = centre - span / 2 + span * f; + const reach = 1 + .35 + rand() * .25; + const x = p.cx + Math.cos(a) * p.rx * shapeR(p, a) * reach, z = p.cz + Math.sin(a) * p.rz * shapeR(p, a) * reach; + if (rimDistance(x, z) < .5) continue; + const h = (.25 + Math.sin(f * Math.PI) * (.6 + rand() * .7)) * (p.sea ? .7 : 1); + outcrops.push({ x, z, h, r: .14 + rand() * .16, a: rand() * 6, lean: (rand() - .5) * .25, tone: .8 + rand() * .4 }); + } } function scatter(geometry, material, list, place) { const mesh = new T.InstancedMesh(geometry, material, list.length); @@ -240,35 +340,104 @@ async function initialize() { const dummy = new T.Object3D(); const m4 = new T.Matrix4(), q = new T.Quaternion(), v1 = new T.Vector3(), v2 = new T.Vector3(), v3 = new T.Vector3(), s3 = new T.Vector3(); const tint = new T.Color(); - scatter(new T.CylinderGeometry(.35, 1, 1, 5), lit(0x2a282f), outcrops, (s, o) => { + scatter(new T.CylinderGeometry(.35, 1, 1, 5), lit(0x4a4250), outcrops, (s, o) => { o.position.set(s.x, s.h / 2 - .05, s.z); o.rotation.set(0, s.a, s.lean); o.scale.set(s.r, s.h, s.r * .8); }); scatter(new T.IcosahedronGeometry(1, 0), lit(0x4a4650), stones, (s, o) => { o.position.set(s.x, heightAt(s.x, s.z) + s.s * .3, s.z); o.rotation.set(s.a, s.a * 2, s.a * .5); o.scale.set(s.s * 1.2, s.s * .6, s.s); }); - // The film scrapers graze: fine specks of pale light on each floor that thicken as it grows. - const cells = []; - for (const p of POOLS) for (let j = 0; j < p.nz; j++) for (let i = 0; i < p.nx; i++) { - if (!p.mask[j * p.nx + i]) continue; - const x = p.x0 + (i + .5) * CELL + (hash(i, j + p.id * 97) - .5) * .3, z = p.z0 + (j + .5) * CELL + (hash(j, i + p.id * 31) - .5) * .3; - cells.push({ pool: p.id, index: j * p.nx + i, x, z, y: floorY(x, z) + .006, spin: hash(i * 7, j * 3) * 6, scale: .8 + hash(i + 3, j * 5) * .5 }); + // A faint lit rim marks where each pool meets the field, with a slow highlight travelling round it. + const rimMaterial = new T.ShaderMaterial({ + transparent: true, depthWrite: false, blending: T.AdditiveBlending, side: T.DoubleSide, + uniforms: { uTime: shared.uTime, uLight: shared.uLight }, + vertexShader: `attribute float fade; attribute float angle; attribute float open; varying float vFade; varying float vAngle; varying float vOpen; varying vec3 vW; + void main(){ vFade = fade; vAngle = angle; vOpen = open; vec4 w = modelMatrix * vec4(position, 1.); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }`, + fragmentShader: `precision mediump float; uniform float uTime, uLight; varying float vFade; varying float vAngle; varying float vOpen; varying vec3 vW; + void main(){ + float edge = exp(-pow(vFade * 9., 2.)) * .13 + exp(-vFade * 3.5) * .018; + float travel = pow(max(0., sin(vAngle * 2. - uTime * .12 + vW.x * .05)), 18.) * .16 * exp(-vFade * 8.); + gl_FragColor = vec4(vec3(.8, .74, .9) * (edge + travel) * (.55 + .45 * uLight) * vOpen, 1.); + }`, + }); + for (const p of POOLS) { + const N = 160, positions = [], fades = [], angles = [], opens = [], index = []; + const width = .32 / (Math.min(p.rx, p.rz) * .9); + for (let i = 0; i <= N; i++) { + const a = i / N * Math.PI * 2, r = shapeR(p, a); + for (const [rho, f] of [[1.02, 0], [1 - width, 1]]) { + const x = p.cx + Math.cos(a) * p.rx * r * rho, z = p.cz + Math.sin(a) * p.rz * r * rho; + positions.push(x, heightAt(x, z) + .012, z); fades.push(f); angles.push(a); + // The rim breaks where a gully runs out of the pool. + opens.push(CHANNELS.some(ch => inGully(ch, x, z, -.35)) ? 0 : 1); + } + if (i < N) { const k = i * 2; index.push(k, k + 1, k + 2, k + 1, k + 3, k + 2); } + } + const geo = new T.BufferGeometry(); + geo.setAttribute('position', new T.Float32BufferAttribute(positions, 3)); + geo.setAttribute('fade', new T.Float32BufferAttribute(fades, 1)); + geo.setAttribute('angle', new T.Float32BufferAttribute(angles, 1)); + geo.setAttribute('open', new T.Float32BufferAttribute(opens, 1)); + geo.setIndex(index); + const rim = new T.Mesh(geo, rimMaterial); + rim.renderOrder = 4; + scene.add(rim); } - const patch = new T.CircleGeometry(1, 6); patch.rotateX(-Math.PI / 2); - const film = new T.InstancedMesh(patch, new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: .55, depthWrite: false }), cells.length); - film.receiveShadow = true; - scene.add(film); - const thin = new T.Color(0x1c1a21), green = new T.Color(0x4d4658), bloom = new T.Color(0x8a7f99); - function paintFilm() { - cells.forEach((c, k) => { - const a = world.film[c.pool][c.index]; - const size = CELL * (.04 + .16 * a) * c.scale; - film.setMatrixAt(k, m4.compose(v1.set(c.x, c.y, c.z), q.setFromAxisAngle(v2.set(0, 1, 0), c.spin), s3.set(size, 1, size * .8))); - tint.copy(thin).lerp(green, Math.min(1, a * 1.3)); - if (a > .72) tint.lerp(bloom, (a - .72) / .28 * .6); - film.setColorAt(k, tint); + + // Motes of light ride the current along open gullies: in from the sea as the tide rises, out as it falls. + const seaward = POOLS.findIndex(p => p.sea); + const motes = CHANNELS.flatMap(c => Array.from({ length: 7 }, (_, k) => ({ c, offset: k / 7 + rand() * .08, speed: .035 + rand() * .02, lift: rand() }))); + const moteMesh = new T.InstancedMesh(new T.CircleGeometry(1, 10).rotateX(-Math.PI / 2), + new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), motes.length); + moteMesh.frustumCulled = false; moteMesh.renderOrder = 5; + scene.add(moteMesh); + const moteColor = new T.Color(), moteBase = new T.Color(0xd9ccef); + function drawMotes(wl) { + const rising = tideRising(world.time); + motes.forEach((m, k) => { + const c = m.c, n = c.pts.length - 1; + // Water runs away from the sea on a rising tide and back toward it on a falling one. + const outward = hops(c.a, seaward) < hops(c.b, seaward); + const forward = rising === outward; + let t = ((m.offset + world.time * m.speed) % 1 + 1) % 1; + if (!forward) t = 1 - t; + const f = t * n, i = Math.min(n - 1, Math.floor(f)), u = f - i; + const x = c.pts[i].x + (c.pts[i + 1].x - c.pts[i].x) * u, z = c.pts[i].z + (c.pts[i + 1].z - c.pts[i].z) * u; + const y = Math.max(floorY(x, z), wl) + .03; + const open = channelOpen(world, c) ? 1 : .15; + const glow = Math.sin(t * Math.PI) * open * (.45 + .3 * m.lift); + moteMesh.setMatrixAt(k, m4.compose(v1.set(x, y, z), q.identity(), s3.setScalar(.045 + .03 * m.lift))); + moteMesh.setColorAt(k, moteColor.copy(moteBase).multiplyScalar(glow)); }); - film.instanceMatrix.needsUpdate = true; film.instanceColor.needsUpdate = true; + moteMesh.instanceMatrix.needsUpdate = true; moteMesh.instanceColor.needsUpdate = true; + } + + // Feed shows as a soft wash of light that spreads through the water and fades while the film takes it up. + const bloomTexture = (() => { + const cv = document.createElement('canvas'); cv.width = cv.height = 128; + const g = cv.getContext('2d'), grad = g.createRadialGradient(64, 64, 0, 64, 64, 64); + grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(.45, 'rgba(255,255,255,.45)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); + g.fillStyle = grad; g.fillRect(0, 0, 128, 128); + return new T.CanvasTexture(cv); + })(); + const blooms = []; + const bloomMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2).rotateX(-Math.PI / 2), + new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 8); + bloomMesh.frustumCulled = false; bloomMesh.renderOrder = 5; bloomMesh.count = 0; + bloomMesh.setColorAt(0, new T.Color()); + scene.add(bloomMesh); + const bloomColor = new T.Color(), bloomBase = new T.Color(0xc9d8d0); + function drawBlooms(wl) { + while (blooms.length && world.time - blooms[0].born > 9) blooms.shift(); + blooms.forEach((b, k) => { + const age = Math.max(0, world.time - b.born), t = Math.min(1, age / 9); + const size = .5 + 1.3 * (1 - Math.pow(1 - t, 3)); + const glow = Math.min(1, age * 1.5) * (1 - t) * .5; + bloomMesh.setMatrixAt(k, m4.compose(v1.set(b.x, Math.max(floorY(b.x, b.z), wl) + .025, b.z), q.setFromAxisAngle(v2.set(0, 1, 0), age * .15), s3.set(size, 1, size))); + bloomMesh.setColorAt(k, bloomColor.copy(bloomBase).multiplyScalar(glow)); + }); + bloomMesh.count = blooms.length; + bloomMesh.instanceMatrix.needsUpdate = true; bloomMesh.instanceColor.needsUpdate = true; } // Pool names float as hairline labels, not as geometry. @@ -320,6 +489,8 @@ async function initialize() { husk: part(unitBox, lit(0x2e2c31), 160), scrap: part(unitBox, lit(0x4a4650), 80), block: part(new T.IcosahedronGeometry(1, 0), lit(0xffffff), 130, { colored: true }), crop: part(unitBox, new T.MeshBasicMaterial({ color: 0xd4b9df }), 16, { shadow: false }), + contact: part(new T.RingGeometry(.88, 1, 40).rotateX(-Math.PI / 2), + new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 260, { shadow: false, colored: true }), }; const base = new T.Matrix4(), local = new T.Matrix4(); @@ -398,20 +569,21 @@ async function initialize() { box(P.pyBase, 0, .02, 0, .46, .04, .46); const segments = c.size + 1; let h = .04; + const open = c.open ?? 1; for (let k = 0; k < segments; k++) { - const w = .32 - k * .03, sh = .15; + const w = .32 - k * .03, sh = .15 * (.6 + .4 * open); box(k === segments - 1 ? P.pyTop : P.pySeg, 0, h + sh / 2, 0, w, sh, w, 0, (k % 2) * Math.PI / 4 + Math.sin(world.time * .3 + k) * .03); h += sh; } const closing = c.strike; for (let k = 0; k < 6; k++) { const a = k / 6 * Math.PI * 2 + world.time * .08; - const pitch = .95 - closing * .7 + Math.sin(world.time * 1.3 + k + c.id) * .12; - const length = .26 + c.size * .05; + const pitch = (.95 - closing * .7 + Math.sin(world.time * 1.3 + k + c.id) * .12 * open) * (.15 + .85 * open); + const length = (.26 + c.size * .05) * (.45 + .55 * open); toWorld(hip, 0, h, 0); foot.set(hip.x + Math.cos(a) * Math.sin(pitch) * length, hip.y + Math.cos(pitch) * length, hip.z + Math.sin(a) * Math.sin(pitch) * length); segment(P.pyRod, hip, foot, .018); - put(P.eye, m4.compose(foot, q.identity(), s3.set(.035, .035, .035)), eye(c)); + put(P.eye, m4.compose(foot, q.identity(), s3.set(.035, .035, .035)), eye(c).multiplyScalar(.35 + .65 * open)); } }, collector(c) { @@ -490,14 +662,36 @@ async function initialize() { } } + // Where a body meets the water it leaves a faint ring, and a moving one sheds wider rings behind it. + const ringColor = new T.Color(), ringBase = new T.Color(0xb9a6c8); + const HEIGHT = { scraper: .25, tab: .05, pylon: 0, collector: .4, mason: .45, breaker: .55 }; + function drawContacts(wl) { + for (const c of world.creatures) { + const S = SPECIES[c.sp]; + const floor = S.kind === 'swimmer' ? c.y : floorY(c.x, c.z); + const top = c.sp === 'pylon' ? floor + (c.size + 1) * .15 * (.6 + .4 * (c.open ?? 1)) + .1 : floor + HEIGHT[c.sp]; + const breaks = top > wl && floor < wl; + if (S.kind === 'swimmer' && wl - c.y > .12) continue; + const y = breaks ? wl + .004 : floor + .012; + const r = S.radius * (c.sp === 'tab' ? 1.6 : 1.25); + const strength = breaks ? .16 : .05; + put(P.contact, m4.compose(v1.set(c.x, y, c.z), q.identity(), s3.set(r, 1, r)), ringColor.copy(ringBase).multiplyScalar(strength)); + if (c.speed > .08) { + const t = (c.phase * .12 + c.id * .37) % 1; + const wide = r * (1 + t * 1.6); + put(P.contact, m4.compose(v1.set(c.x, y, c.z), q.identity(), s3.set(wide, 1, wide)), + ringColor.copy(ringBase).multiplyScalar(strength * (1 - t) * Math.min(1, c.speed * 2))); + } + } + } function placeLabels() { const bounds = stage.getBoundingClientRect(); for (const l of labels) { v1.set(l.x, l.y + .05, l.z).project(camera); const sx = (v1.x + 1) / 2 * bounds.width, sy = (1 - v1.y) / 2 * bounds.height; - const show = view.zoom < 9 && sx > -40 && sx < bounds.width && sy > 150 && sy < bounds.height - 150; + const show = view.zoom < 15 && sx > -40 && sx < bounds.width - 120 && sy > 150 && sy < bounds.height - (bounds.width < 720 ? 230 : 150); l.el.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px)`; - l.el.style.opacity = show ? String(clamp((9 - view.zoom) / 3, 0, 1) * .8) : '0'; + l.el.style.opacity = show ? String(clamp((15 - view.zoom) / 3, 0, 1) * .8) : '0'; } } function drawCrop(c) { @@ -511,24 +705,25 @@ async function initialize() { box(P.crop, sx * r, 0, sz * (r - len / 2), t, t, len); } } + const markerColor = new T.Color(0xe0c4eb); function drawMarker() { if (!marker.visible) return; const y = Math.max(floorY(cursor.x, cursor.z), waterLevel(world)) + .02; - setBase(cursor.x, y, cursor.z, 0); - const r = .24, t = .014; - for (const s of [-1, 1]) { box(P.crop, s * r, 0, 0, t, t, r * 2); box(P.crop, 0, 0, s * r, r * 2, t, t); } + put(P.contact, m4.compose(v1.set(cursor.x, y, cursor.z), q.identity(), s3.set(.24, 1, .24)), markerColor); } + let world = createWorld(); let selected = null, following = false, lastEnded = null; let tool = 'brass'; - let cursor = { x: 4.5, z: 0 }; + let cursor = { x: 6, z: -2 }; const marker = { visible: false }; // Camera: an orthographic, slightly turned view with pan and zoom. const YAW = -.3, PITCH = .74; - const view = { x: 4.5, z: .6, zoom: 5.4 }, goal = { ...view }; - const ZMIN = 1.4, ZMAX = 13; + const view = { x: 6, z: -1.4, zoom: 7.4 }, goal = { ...view }; + let intro = null; + const ZMIN = 1.4, ZMAX = 28; const reduced = matchMedia('(prefers-reduced-motion: reduce)'); function placeCamera() { const { width, height } = stage.getBoundingClientRect(); @@ -546,14 +741,16 @@ async function initialize() { } function clampGoal() { goal.zoom = clamp(goal.zoom, ZMIN, ZMAX); - goal.x = clamp(goal.x, -21, 19); - goal.z = clamp(goal.z, -5, 11); + goal.x = clamp(goal.x, -42, 38); + goal.z = clamp(goal.z, -18, 28); } function cameraSettled() { return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002; } function easeCamera(dt) { - const k = reduced.matches ? 1 : 1 - Math.exp(-dt * 7); + if (intro && performance.now() - intro.start > 1800 && !intro.released) { intro.released = true; Object.assign(goal, intro.to); } + if (intro?.released && cameraSettled()) intro = null; + const k = reduced.matches ? 1 : 1 - Math.exp(-dt * (intro?.released ? 1.1 : 7)); view.x += (goal.x - view.x) * k; view.z += (goal.z - view.z) * k; view.zoom += (goal.zoom - view.zoom) * k; placeCamera(); } @@ -632,7 +829,9 @@ async function initialize() { function render() { shared.uTime.value = world.time; const light = daylight(world); - shared.uLight.value = light; + // Day and night only tint the pool; it never goes dark. + const glow = .78 + .22 * light; + shared.uLight.value = glow; const wl = waterLevel(world); shared.uWater.value = wl; water.position.y = wl; @@ -641,16 +840,23 @@ async function initialize() { 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.target.position.set(view.x, 0, view.z); - sun.intensity = .5 + 2.9 * light; - sun.color.setRGB(.78 + .2 * light, .76 + .18 * light, .9 + .03 * light); - hemi.intensity = .35 + .85 * light; - fill.intensity = .5 + .6 * light; + 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. + const level = v2.set(.12 + Math.sin(dayAngle) * .05, 1, -.1).normalize(), eye = shared.uView.value; + shared.uSun.value.copy(level).multiplyScalar(2 * level.dot(eye)).sub(eye).normalize(); + sun.intensity = 4 + .8 * light; + sun.color.setRGB(.86 + .12 * light, .83 + .12 * light, .95); + hemi.intensity = 1.35 + .15 * light; + fill.intensity = 1.6; for (const p of parts) p.n = 0; for (const c of world.creatures) DRAW[c.sp](c); drawObjects(); const focus = creatureById(world, selected); + drawContacts(wl); + drawMotes(wl); + drawBlooms(wl); if (focus) drawCrop(focus); drawMarker(); for (const p of parts) { @@ -792,9 +998,11 @@ async function initialize() { followButton.textContent = on ? 'Following' : 'Follow'; } function select(c, { announce = true } = {}) { + intro = null; selected = c.id; lastEnded = null; + root.dataset.engaged = 'true'; setFollow(true); - if (goal.zoom > 3.6) goal.zoom = 3.2; + if (goal.zoom > 4) goal.zoom = 3.6; goal.x = c.x; goal.z = c.z; clampGoal(); if (announce) status.textContent = `Following ${label(c)}, a ${SPECIES[c.sp].name.toLowerCase()}. ${goalText(world, c).replace(/^./, m => m.toUpperCase())}.`; readout(); wake(); @@ -831,7 +1039,7 @@ async function initialize() { while (ticker.children.length > 4) ticker.firstElementChild.remove(); } - let readoutClock = 0, filmClock = 0, lastFrame = 0; + let readoutClock = 0, lastFrame = 0; function loop(now) { frame = 0; if (!visible || document.hidden || lost) { previous = 0; return; } @@ -844,8 +1052,7 @@ async function initialize() { while (elapsed > 0) { const step = Math.min(elapsed, .05); advanceWorld(world, step); elapsed -= step; } } previous = now; - filmClock -= dt; readoutClock -= dt; - if (filmClock <= 0) { filmClock = .3; paintFilm(); } + readoutClock -= dt; if (readoutClock <= 0) { readoutClock = .4; readout(); tick(); } } else previous = 0; const focus = following && creatureById(world, selected); @@ -873,7 +1080,7 @@ async function initialize() { const { width, height } = stage.getBoundingClientRect(); renderer.setPixelRatio(Math.min(devicePixelRatio, mobile ? 1.5 : 2, 1800 / Math.max(1, width))); renderer.setSize(width, height, false); - if (width / height < .8 && goal.zoom === 5.4) goal.zoom = view.zoom = 7; + if (width / height < .8 && goal.zoom === 7.4) goal.zoom = view.zoom = 10.5; placeCamera(); wake(); } @@ -890,8 +1097,13 @@ async function initialize() { root.dataset.engaged = 'true'; const pool = poolAt(x, z).name; const drawn = o.id !== null && world.creatures.find(c => c.task?.object === o.id); - status.textContent = `${names[tool]} dropped in pool ${pool}.${drawn ? ` ${label(drawn)} turns toward it.` : ''}${paused ? ' Press Play when you want to watch.' : ''}`; - chime(tool); paintFilm(); readout(); wake(); + if (tool === 'feed') { + blooms.push({ x, z, born: world.time }); + if (blooms.length > 8) blooms.shift(); + const grazers = world.creatures.filter(c => c.sp === 'scraper' && c.task?.kind === 'graze' && Math.hypot(c.task.at.x - x, c.task.at.z - z) < .5).length; + status.textContent = `Feed blooms in pool ${pool}.${grazers ? ` ${grazers} scraper${grazers === 1 ? '' : 's'} drift toward it.` : ''}${paused ? ' Press Play when you want to watch.' : ''}`; + } else status.textContent = `${names[tool]} dropped in pool ${pool}.${drawn ? ` ${label(drawn)} turns toward it.` : ''}${paused ? ' Press Play when you want to watch.' : ''}`; + chime(tool); readout(); wake(); } // Pointer: drag to pan, pinch or scroll to zoom, tap to select or drop. @@ -909,6 +1121,7 @@ async function initialize() { clampGoal(); view.x = goal.x; view.z = goal.z; } function zoomAt(factor, clientX, clientY) { + intro = null; const before = clientX !== undefined && groundAt(clientX, clientY); goal.zoom = clamp(goal.zoom * factor, ZMIN, ZMAX); view.zoom = goal.zoom; @@ -946,7 +1159,8 @@ async function initialize() { known.x = e.clientX; known.y = e.clientY; if (pointers.size === 1 && gesture) { if (!gesture.moved && Math.hypot(e.clientX - gesture.x, e.clientY - gesture.y) > 6) { - gesture.moved = true; canvas.classList.add('is-dragging'); + gesture.moved = true; canvas.classList.add('is-dragging'); intro = null; + root.dataset.engaged = 'true'; if (following) setFollow(false); } if (gesture.moved) { panBy(dx, dy); wake(); } @@ -996,13 +1210,13 @@ async function initialize() { select(next); } canvas.addEventListener('keydown', e => { - const handled = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' ', '+', '=', '-', '_', '[', ']', 'Escape', '1', '2', '3', '4', '5']; + const handled = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' ', '+', '=', '-', '_', '[', ']', 'Escape', '1', '2', '3', '4', '5', '6', '7', '8']; if (!handled.includes(e.key)) return; e.preventDefault(); if (e.key === 'Escape') { if (selected !== null) { release(); status.textContent = 'Released.'; } return; } if (e.key === '[' || e.key === ']') return stepSelection(e.key === ']' ? 1 : -1); if ('+=-_'.includes(e.key)) return zoomAt(e.key === '+' || e.key === '=' ? .8 : 1.25); - if ('12345'.includes(e.key)) return goToBasin(+e.key - 1); + if ('12345678'.includes(e.key)) return goToBasin(+e.key - 1); if (e.key === 'Enter' || e.key === ' ') { if (tool === 'look') { const near = world.creatures.reduce((a, c) => !a || Math.hypot(c.x - cursor.x, c.z - cursor.z) < Math.hypot(a.x - cursor.x, a.z - cursor.z) ? c : a, null); @@ -1028,14 +1242,15 @@ async function initialize() { }); function goToBasin(i) { + intro = null; const p = POOLS[i]; if (following) setFollow(false); - goal.x = (p.x0 + p.x1) / 2; goal.z = (p.z0 + p.z1) / 2 + .3; goal.zoom = Math.min(Math.max(goal.zoom, 4.4), 6); + goal.x = (p.x0 + p.x1) / 2; goal.z = (p.z0 + p.z1) / 2 + .3; goal.zoom = Math.min(Math.max(goal.zoom, 6.4), 9); cursor = { x: goal.x, z: goal.z - .3 }; clampGoal(); const n = world.creatures.filter(c => c.pool === i).length; const dry = waterLevel(world) + p.depth < .05; - status.textContent = `Pool ${p.name}. ${n} machine${n === 1 ? '' : 's'}, film ${Math.round(avgFilm(world, i) * 100)}%.${dry ? ' Dry at this tide.' : ''}`; + status.textContent = `Pool ${p.name}. ${n} machine${n === 1 ? '' : 's'}.${dry ? ' Dry at this tide.' : ''}`; readout(); wake(); } basinButtons.forEach(b => b.addEventListener('click', () => goToBasin(+b.dataset.basin))); @@ -1086,8 +1301,8 @@ async function initialize() { world = createWorld(); tickerSeen = 0; ticker.replaceChildren(); release(); if (paused) world.objects.forEach(o => { o.height = 0; }); - status.textContent = 'A fresh pool. Five hollows, six species, a few offerings.'; - paintFilm(); readout(); wake(); + status.textContent = 'A fresh pool. Eight hollows, six species, a few offerings.'; + readout(); wake(); }); reduced.addEventListener('change', () => { if (reduced.matches) { paused = true; updateButtons(); schedule(); status.textContent = 'Paused for reduced motion. Play is available when you want it.'; } @@ -1097,20 +1312,34 @@ async function initialize() { new ResizeObserver(resize).observe(stage); canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; schedule(); + delete root.dataset.ready; canvas.hidden = true; root.querySelector('.tide-still').removeAttribute('hidden'); - root.querySelectorAll('.tide-controls button, #tide-describe, [data-species]').forEach(b => { b.disabled = true; }); + root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]').forEach(b => { b.disabled = true; }); status.textContent = 'The 3D view was interrupted. Reload the page to return to the pool.'; }); window.addEventListener('pagehide', () => { visible = false; schedule(); }); window.addEventListener('pageshow', () => { visible = true; schedule(); }); if (paused) world.objects.forEach(o => { o.height = 0; }); - paintFilm(); resize(); + if (!reduced.matches) { + // Open close on the busiest spot in the central pool, then draw back to show the pool and its gullies. + const central = world.creatures.filter(c => c.pool === 2); + const busiest = central.reduce((a, c) => { + const n = central.filter(o => Math.hypot(o.x - c.x, o.z - c.z) < 2.5).length; + return n > a.n ? { c, n } : a; + }, { c: null, n: 0 }).c; + if (busiest) { + intro = { start: performance.now(), to: { ...goal } }; + Object.assign(view, { x: busiest.x, z: busiest.z, zoom: 2.6 }); Object.assign(goal, view); + placeCamera(); + } + } + root.dataset.ready = 'true'; canvas.hidden = false; root.querySelector('.tide-still').setAttribute('hidden', ''); - root.querySelectorAll('.tide-controls button, #tide-describe, [data-species]').forEach(b => { b.disabled = false; }); + root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]').forEach(b => { b.disabled = false; }); status.textContent = paused ? 'Paused for reduced motion. Press Play when you want to watch.' : 'Drag to look around. Tap a machine to follow it.'; updateButtons(); readout(); schedule(); // Test and debugging hook; read-only by convention. - window.__tidePool = { get world() { return world; }, select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; + window.__tidePool = { scene, draw: () => { easeCamera(1); render(); }, get world() { return world; }, select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; } diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index 6fb0654..2d398e2 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -13,12 +13,20 @@ const at = p => ({ x: POOLS[p].cx, z: POOLS[p].cz }); test('every pool is reachable, and each gully can be walked end to end', () => { for (const a of POOLS) for (const b of POOLS) assert.ok(a.id === b.id || hops(a.id, b.id) > 0); for (const c of CHANNELS) { - const mid = c.axis === 'x' ? (c.z0 + c.z1) / 2 : (c.x0 + c.x1) / 2; - for (let t = 0; t <= 1; t += .02) { - const x = c.axis === 'x' ? c.x0 - .6 + (c.x1 - c.x0 + 1.2) * t : mid; - const z = c.axis === 'z' ? c.z0 - .6 + (c.z1 - c.z0 + 1.2) * t : mid; + // Walk the gully's centre line from pool to pool in small steps. + for (let i = 0; i < c.pts.length - 1; i++) for (let t = 0; t < 1; t += .1) { + const x = c.pts[i].x + (c.pts[i + 1].x - c.pts[i].x) * t, z = c.pts[i].z + (c.pts[i + 1].z - c.pts[i].z) * t; assert.ok(walkable(x, z), `gully ${c.id} blocked at ${x.toFixed(2)}, ${z.toFixed(2)}`); } + assert.ok(poolAt(c.pts[0].x, c.pts[0].z, .3) && poolAt(c.pts.at(-1).x, c.pts.at(-1).z, .3), `gully ${c.id} ends in open water`); + } + for (let a = 0; a < POOLS.length; a++) for (let b = a + 1; b < POOLS.length; b++) { + const p = POOLS[a], q = POOLS[b]; + for (let k = 0; k < 64; k++) { + const ang = k / 64 * Math.PI * 2; + assert.ok(!poolAt(p.cx + Math.cos(ang) * p.rx * .98, p.cz + Math.sin(ang) * p.rz * .98) || + poolAt(p.cx + Math.cos(ang) * p.rx * .98, p.cz + Math.sin(ang) * p.rz * .98).id === p.id, `pools ${p.name} and ${q.name} overlap`); + } } for (const p of POOLS) assert.ok(Math.abs(floorY(p.cx, p.cz) + p.depth) < .05); }); @@ -99,9 +107,9 @@ test('drops reject invalid inputs and never evict a claimed object', () => { const world = createWorld(); assert.equal(dropObject(world, 'unknown', 0, 0), null); assert.equal(offerObject(world, 'unknown', at(2).x, at(2).z), null); - assert.equal(dropObject(world, 'brass', 0, -8), null); + assert.equal(dropObject(world, 'brass', -1, -14), null); assert.equal(dropObject(world, 'brass', NaN, 0), null); - assert.ok(!insidePool(-11, 5)); + assert.ok(!insidePool(-12, 9)); const first = world.objects[0]; first.claimed = 0; for (let i = 0; i < 200; i++) dropObject(world, 'pebble', at(2).x, at(2).z); assert.equal(world.objects.length, LIMIT); @@ -141,3 +149,49 @@ test('carried objects stay with their sole owner, and core machines return brass assert.ok(world.ended.some(e => e.id === mason.id && e.cause === 'starved')); } }); + +test('an offering is noticed before it is approached, and collectors circle a find before taking it', () => { + const world = createWorld(); + const brass = offerObject(world, 'brass', at(2).x, at(2).z); + const c = world.creatures.find(o => o.task?.object === brass.id); + assert.equal(c.state, 'notice'); + assert.match(goalText(world, c), /noticing/); + run(world, 1); + assert.equal(c.state, 'move'); + let circled = false; + for (let i = 0; i < 20 * 60 && c.carrying !== brass.id; i++) { + advanceWorld(world, 1 / 20); + circled ||= c.task?.circled === true; + } + assert.ok(circled, 'the collector circled the brass'); + assert.equal(c.carrying, brass.id); +}); + +test('pylons fold shut when the water is disturbed and reopen once it is still', () => { + const world = createWorld(); + const pylon = world.creatures.find(c => c.sp === 'pylon' && c.pool === 4); + assert.equal(pylon.open, 1); + dropObject(world, 'pebble', pylon.x + .3, pylon.z); + run(world, 1); + assert.ok(pylon.open < .3, `open ${pylon.open}`); + assert.match(goalText(world, pylon), /folded|opening/); + run(world, 15); + assert.ok(pylon.open > .6); +}); + +test('tabs keep moving and never stay trapped in a gully', () => { + const world = createWorld(7); + const last = new Map(); + let stuck = 0, samples = 0; + for (let s = 0; s < 20 * 600; s++) { + advanceWorld(world, .05); + if (s % 200 !== 0) continue; + for (const c of world.creatures.filter(c => c.sp === 'tab')) { + const p = last.get(c.id); + if (p) { samples++; if (Math.hypot(p.x - c.x, p.z - c.z) < .3) stuck++; } + last.set(c.id, { x: c.x, z: c.z }); + } + } + assert.ok(stuck / samples < .12, `${stuck} of ${samples} samples stalled`); + assert.ok(world.creatures.filter(c => c.sp === 'tab' && !poolAt(c.x, c.z)).length <= 8); +}); diff --git a/src/components/artifacts.tsx b/src/components/artifacts.tsx index 99acd25..ef54205 100644 --- a/src/components/artifacts.tsx +++ b/src/components/artifacts.tsx @@ -5,7 +5,7 @@ const artifacts = [ title: "Tide pool", kind: "INTERACTIVE ART", description: - "Five dark pools and six mechanical species. Leave something in the water, then follow who comes for it.", + "Eight dark pools and six mechanical species. Leave something in the water, then follow who comes for it.", status: "PUBLIC", }, { diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index ffa04a9..d43e6b1 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -21,13 +21,13 @@ export function TidePoolContent() {
← Artifacts

Tide pool

-

Five pools in the dark. Leave something. Follow who comes.

+

Eight pools in the dark. Leave something. Follow who comes.

- Dark pools in an open field, joined by narrow gullies, with small mechanical creatures on their floors. + Dark pools in open water, joined by curving gullies, with small mechanical creatures on their floors. @@ -48,9 +48,8 @@ export function TidePoolContent() { + aria-describedby="tide-instructions">Eight pools in the dark, joined by curving gullies. Small mechanical creatures graze, school, stack stones, hoard brass, and hunt as the tide rises and falls.
-
Tap the water to drop brass. Drag to pan. Scroll or pinch to zoom. Tap a machine to follow it.
+

Tap the water to drop brass. Drag to look around. Tap a machine to follow it.

{TOOLS.map(t => ( ))}
-
-
- {["I", "II", "III", "IV", "V"].map((name, i) => )} -
-
- - -
- - - +
+
+
+ {["I", "II", "III", "IV", "V", "VI", "VII", "VIII"].map((name, i) => )} +
+
+ +
+
+ + + +
- {["I", "II", "III", "IV", "V", "VI", "VII", "VIII"].map((name, i) => )} + {/* Filled in by the page with the pools that exist right now. */}
@@ -126,18 +127,18 @@ export function TidePoolContent() {
Inside the pool -

Eight pools in open dark water, ringed around a large central pool and joined by curving gullies. The tide fills and drains them every four minutes. At high water the gullies flood, light drifts along them with the current, and tabs can swim between pools. At low water the shallow pools and every rim dry out. Day turns to night every seven minutes, and the pale film on each floor grows only in wet light.

+

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

    {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}

Large machines need a brass core to cast a new body. When they stop, they leave a husk and give their core back. Brass, shells, and pebbles also wash in from the sea at high tide. Collectors hoard it, and others steal it.

Tap a machine to follow it. Choose a material, then tap water to drop it. Feed makes the film and plankton bloom where it lands. A purely synthetic, procedural artwork. Sound is optional and starts only when you turn it on. Nothing you do here is saved or sent.

-

Keyboard: focus the pool. Arrow keys move the marker. Shift and arrows pan. Plus and minus zoom. Enter or Space drops, or selects in Look mode. Brackets step between machines. Escape releases. Keys 1 to 8 jump to a pool. Reduced-motion settings start the world paused; you can choose to play.

+

Keyboard: focus the pool. Arrow keys move the marker. Shift and arrows pan. Plus and minus zoom. Enter or Space drops, or selects in Look mode. Brackets step between machines. Escape releases. Keys 1 to 9 jump to a pool. Reduced-motion settings start the world paused; you can choose to play.

Rendered with Three.js. Software license.

- + ); } -- 2.51.2 From d009428ffcec4e2204e9462160b7e869ca555bdf Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 22 Sep 2026 22:57:32 -0700 Subject: [PATCH 04/28] Spread the tide pool's creatures across the shelf and let masons feed, breed, and build. --- public/tide-pool-world.js | 58 ++++++++++++++++++++++++++++-------- public/tide-pool.js | 2 +- scripts/tide-pool.test.mjs | 5 +++- src/components/tide-pool.tsx | 2 +- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 22da2e7..5322cb1 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -19,7 +19,7 @@ export const SPECIES = { 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: 1, burn: .0045, radius: .4, 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 }, }; @@ -465,10 +465,13 @@ function breed(world, c) { if (!canBreed(world, c.sp)) return null; const S = SPECIES[c.sp]; let at = null; - for (let i = 0; i < 10 && !at; i++) { - const a = world.random() * Math.PI * 2, r = S.kind === 'sessile' ? 1.2 + world.random() * 1.4 : .5; + for (let i = 0; i < 16 && !at; i++) { + // A pylon's bud sometimes drifts far, like a spore, and settles in other standing water. + const far = S.kind === 'sessile' && world.random() < .45; + const a = world.random() * Math.PI * 2, r = S.kind === 'sessile' ? (far ? 5 + world.random() * 14 : 1.2 + world.random() * 1.4) : .5; const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; - const ok = S.kind === 'walker' ? onShelf(world, x, z) : depthAt(world, x, z) > (S.kind === 'sessile' ? .25 : .1); + const ok = S.kind === 'walker' ? onShelf(world, x, z) : + S.kind === 'sessile' ? depthAt(world, x, z) > .25 && world.S[cellAt(x, z)] > TIDE_LOW + .25 : depthAt(world, x, z) > .1; const clear = S.kind !== 'sessile' || world.creatures.every(o => o.sp !== 'pylon' || distance(o, { x, z }) > 1); if (ok && clear) at = { x, z }; } @@ -479,6 +482,7 @@ function breed(world, c) { 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; + if (c.sp === 'collector' || c.sp === 'mason') child.home = roomy(world, child, 4, 12, c.sp === 'mason') || child.home; event(world, 'birth', c, null, label(child)); return child; } @@ -514,12 +518,15 @@ function think(world, c) { } function graze(world, c, efficiency = 1) { + // Good film, not too far, and not already crowded by others of the same kind. A few samples look much further afield. let best = null, score = -Infinity; - for (let k = 0; k < 14; k++) { - const r = k < 8 ? 3 : 9; + for (let k = 0; k < 16; k++) { + const r = k < 8 ? 3 : k < 13 ? 9 : 18; const x = c.x + (world.random() - .5) * r * 2, z = c.z + (world.random() - .5) * r * 2; if (!onShelf(world, x, z)) continue; - const s = world.film[cellAt(x, z)] - Math.hypot(x - c.x, z - c.z) * .04; + let crowd = 0; + for (const o of world.creatures) if (o !== c && o.sp === c.sp && Math.abs(o.x - x) < 2.5 && Math.abs(o.z - z) < 2.5) crowd++; + const s = world.film[cellAt(x, z)] - Math.hypot(x - c.x, z - c.z) * .025 - crowd * .12; if (s > score) { score = s; best = { x, z }; } } assign(world, c, 'graze', null, { at: best || { x: c.x, z: c.z }, patience: 14 }); @@ -616,14 +623,16 @@ function thinkMason(world, c) { 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. - const next = { x: c.home.x + (world.random() - .5) * 6, z: c.home.z + (world.random() - .5) * 6 }; - c.home = onShelf(world, next.x, next.z) ? next : { x: c.x, z: c.z }; + c.home = roomy(world, c, 3, 12, true) || { x: c.x, z: c.z }; event(world, 'home', c); return idle(world, c); } - if (c.energy < .4) return graze(world, c); - if (c.energy > .8 && canBreed(world, 'mason')) { - const source = brassSource(world, c, world.random() < .5); + // Once hungry, a mason keeps feeding until it is properly full, which leaves it strong enough to breed. + 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')) { + const source = brassSource(world, c, world.random() < .55); if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); } if (c.energy > .45 && ringSpot(world, c)) { @@ -641,7 +650,7 @@ function thinkBreaker(world, c) { const source = brassSource(world, c, true); if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); } - if (c.energy < .92) { + if (c.energy < .7) { const target = nearest(c, world.creatures.filter(s => s.sp === 'scraper' && s.shell === null), 28); if (target) { // Prey inside a wall: break the wall down first. @@ -682,6 +691,23 @@ function nearbyWater(world, c, radius = 14) { } return best; } +// Somewhere to settle: on the shelf, clear of others of the same kind, and near water other than the crowded first pool when possible. +function roomy(world, c, r0, r1, wantWater) { + let best = null, score = -Infinity; + for (let k = 0; k < 24; k++) { + const a = world.random() * Math.PI * 2, r = r0 + world.random() * (r1 - r0); + const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; + if (!onShelf(world, x, z) || z > 10) continue; + let near = 99; + for (const o of world.creatures) if (o !== c && o.sp === c.sp && o.home) near = Math.min(near, distance(o.home, { x, z })); + let wet = 0; + for (let n = 0; n < 6; n++) { const b = n / 6 * Math.PI * 2; if (depthAt(world, x + Math.cos(b) * 2, z + Math.sin(b) * 2) > .15) wet++; } + const first = bodyAt(world, x, z) === 'I'; + const s = Math.min(near, 8) + (wantWater ? wet * .8 : 0) - (first ? 4 : 0) - (depthAt(world, x, z) > .5 ? 3 : 0); + if (s > score) { score = s; best = { x, z }; } + } + return best; +} // A new burrow site: dry shelf a fair walk from any standing water, so its burrow starts a pool of its own. function settle(world, c) { for (let k = 0; k < 30; k++) { @@ -953,6 +979,12 @@ function moveSwimmer(world, c, dt) { } if (best > depth) { ax += bx * 2.5 * worry; az += bz * 2.5 * worry; } } + // At high water the school roams the flooded shelf along a slowly turning heading. + const high = tideOf(world.time); + if (high > .55 && depth > .15) { + c.heading += (world.random() - .5) * dt * 1.2; + ax += Math.cos(c.heading) * 1.4 * (high - .55) * 2.2; az += Math.sin(c.heading) * 1.4 * (high - .55) * 2.2; + } const a = (world.random() - .5) * 2.2; ax += Math.cos(c.phase * .3 + a) * .5; az += Math.sin(c.phase * .3 + a) * .5; c.vx += ax * dt; c.vz += az * dt; diff --git a/public/tide-pool.js b/public/tide-pool.js index 323a921..4de7a05 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=8'; +} from './tide-pool-world.js?v=9'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index 3eaf822..6e08270 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -166,8 +166,11 @@ test('pylons fold shut when the water is disturbed and reopen once it is still', dropObject(world, 'pebble', pylon.x + .3, pylon.z); run(world, 1); assert.ok(pylon.open < .3, `open ${pylon.open}`); + // Leave it alone: no one else nearby to disturb the water. + world.creatures = [pylon]; + world.ripples = []; run(world, 15); - assert.ok(pylon.open > .6 || depthAt(world, pylon.x, pylon.z) < WET); + assert.ok(pylon.open > .6 || depthAt(world, pylon.x, pylon.z) < WET, `open ${pylon.open}`); }); test('a runaway shelf stays bounded: no ground below the digging floor and all water finite', () => { diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index bef8bf1..d613b3a 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.

- + ); } -- 2.51.2 From bc8f80f9128104a70a607284741b230173e793fd Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 22 Sep 2026 23:01:53 -0700 Subject: [PATCH 05/28] Give the tide pool shelf faceted rock that rises into banks, and keep mid-width controls from overlapping. --- public/tide-pool.css | 6 +++ public/tide-pool.js | 74 +++++++++++++++++++++++------------- src/components/tide-pool.tsx | 2 +- src/index.tsx | 2 +- 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/public/tide-pool.css b/public/tide-pool.css index 1703de2..3701be9 100644 --- a/public/tide-pool.css +++ b/public/tide-pool.css @@ -125,6 +125,12 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di @media (max-width: 1100px) { .tide-ticker { display: none; } } +/* Mid widths: the corner strips sit above the token strip instead of beside it. */ +@media (max-width: 1080px) and (min-width: 721px) { + .tide-nav, .tide-utility { bottom: calc(max(22px, env(safe-area-inset-bottom)) + 58px); } + .tide-hint { margin-bottom: 3.9rem; } + .tide-status { bottom: 136px; } +} @media (max-width: 720px) { .tide-heading { top: max(8px, env(safe-area-inset-top)); } .tide-heading h1 { font-size: 2.3rem; margin-top: .1rem; } diff --git a/public/tide-pool.js b/public/tide-pool.js index 4de7a05..1da3439 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -157,36 +157,58 @@ async function initialize() { return geo; } const grid = gridGeometry(2); - const ground = new T.Mesh(grid, submerge(new T.MeshLambertMaterial({ color: 0xffffff }), shader => { - // Heights and slopes come from the grid texture, so digging shows the moment it happens. - shader.vertexShader = 'varying vec4 vCell;\n' + GRID + shader.vertexShader - .replace('#include ', `#include - { float e = ${(CELL * .5).toFixed(3)}; - float hl = gridAt(position.xz - vec2(e, 0.)).g, hr = gridAt(position.xz + vec2(e, 0.)).g; - float hd = gridAt(position.xz - vec2(0., e)).g, hu = gridAt(position.xz + vec2(0., e)).g; - objectNormal = normalize(vec3(hl - hr, 2. * e, hd - hu)); }`) - .replace('#include ', `#include + // Rock: a coarser sheet that runs past the grid on the landward sides and is lit facet by facet. + function rockSheet(x0, z0, x1, z1, step) { + const gw = Math.round((x1 - x0) / step) + 1, gh = Math.round((z1 - z0) / step) + 1; + const pos = new Float32Array(gw * gh * 3), nor = new Float32Array(gw * gh * 3); + for (let b = 0; b < gh; b++) for (let a = 0; a < gw; a++) { + const k = (b * gw + a) * 3; + pos[k] = x0 + a * step; pos[k + 2] = z0 + b * step; nor[k + 1] = 1; + } + const index = new Uint32Array((gw - 1) * (gh - 1) * 6); + let n = 0; + for (let b = 0; b < gh - 1; b++) for (let a = 0; a < gw - 1; a++) { + const i0 = b * gw + a, i1 = i0 + 1, i2 = i0 + gw, i3 = i2 + 1; + // Alternate the diagonal so the facets make a woven pattern instead of parallel ridges. + index.set((a + b) % 2 ? [i0, i2, i1, i1, i2, i3] : [i0, i2, i3, i0, i3, i1], n); n += 6; + } + const geo = new T.BufferGeometry(); + geo.setAttribute('position', new T.BufferAttribute(pos, 3)); + geo.setAttribute('normal', new T.BufferAttribute(nor, 3)); + geo.setIndex(new T.BufferAttribute(index, 1)); + return geo; + } + 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); - transformed.y = vCell.g;`); - shader.fragmentShader = 'varying vec4 vCell;\n' + shader.fragmentShader.replace('#include ', `#include + // 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.fragmentShader = 'varying vec4 vCell;\nvarying float vBeyond;\n' + shader.fragmentShader.replace('#include ', `#include { - // Dry shelf is near black; standing water lights the floor beneath it; set stone reads paler. + // 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); - vec3 tone = mix(vec3(.0037, .0033, .0045), vec3(.045, .037, .062), wet); - tone = mix(tone, vec3(.03, .025, .042), clamp(vCell.r - vCell.g - .7, 0., 1.) * .6); - tone = mix(tone, vec3(.1, .09, .115), clamp(vCell.b, 0., 1.) * (1. - wet * .4)); - diffuseColor.rgb = tone; + // 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); + 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. + diffuseColor.rgb = tone * mix(1., .15, vBeyond); }`); - })); + }); + const ground = new T.Mesh(rockSheet(X0 - 44, Z0 - 44, X1 + 44, Z1 + 30, CELL), rockMaterial); ground.frustumCulled = false; ground.receiveShadow = true; scene.add(ground); - // The dry shelf continues past the modelled grid on the landward sides, in the same tone and light. - const beyondMaterial = new T.MeshLambertMaterial({ color: 0x0c0b0e }); - for (const [x0, x1, z0, z1] of [[-200, X0 + .1, -200, Z1 - 1], [X1 - .1, 200, -200, Z1 - 1], [X0, X1, -200, Z0 + .1]]) { - const beyond = new T.Mesh(new T.PlaneGeometry(x1 - x0, z1 - z0).rotateX(-Math.PI / 2), beyondMaterial); - beyond.position.set((x0 + x1) / 2, .02, (z0 + z1) / 2); beyond.receiveShadow = true; - scene.add(beyond); - } // Water: the same grid lifted to the water surface, fading out where the water thins to nothing. const waterMaterial = new T.ShaderMaterial({ @@ -230,9 +252,7 @@ async function initialize() { openSea.material.uniforms = { ...waterMaterial.uniforms, uOpen: { value: 1 } }; openSea.renderOrder = 2; scene.add(openSea); - const seaFloor = new T.Mesh(new T.PlaneGeometry(400, 200).rotateX(-Math.PI / 2).translate(0, -2.3, Z1 + 100 - CELL), lit(0x0c0b0e)); - scene.add(seaFloor); - + // Upload the simulation's grid: surface, ground, set stone, film. const cellCount = COLS * ROWS, gridData = gridTexture.image.data; const toHalf = T.DataUtils.toHalfFloat; diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index d613b3a..7cac41a 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 1dbe688..991e39d 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( - + ); -- 2.51.2 From d86f554f557ff75d7f91d98da3356dc350897fdf Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 22 Sep 2026 23:46:19 -0700 Subject: [PATCH 06/28] Introduce tide pool species one at a time on a rocky shelf, with flowing violet water and mason-built dams. --- public/tide-pool-world.js | 203 +++++++++++++++++++++++++++-------- public/tide-pool.css | 4 +- public/tide-pool.js | 150 +++++++++++++++++++------- scripts/tide-pool.test.mjs | 31 +++++- src/components/tide-pool.tsx | 2 +- src/index.tsx | 2 +- 6 files changed, 301 insertions(+), 91 deletions(-) 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( - + ); -- 2.51.2 From d701713150539cbe82675062923889d6527dc56e Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 23 Sep 2026 09:49:24 -0700 Subject: [PATCH 07/28] Add the artificer, which assembles sluice gates, tide wheels, and beacons that change the tide pool's water and light. --- public/tide-pool-world.js | 165 +++++++++++++++++++++++++++++++++-- public/tide-pool.js | 118 +++++++++++++++++++++++-- src/components/tide-pool.tsx | 5 +- 3 files changed, 272 insertions(+), 16 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 4afe4db..4d5e3ea 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -22,6 +22,7 @@ export const SPECIES = { 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 }, + artificer: { code: 'AR', name: 'Artificer', kind: 'walker', speed: .6, max: 4, start: 0, debut: 260, min: 1, burn: .004, radius: .36, core: true }, }; export const SPECIES_ORDER = Object.keys(SPECIES); export const MATERIALS = ['brass', 'shell', 'pebble', 'feed']; @@ -136,6 +137,7 @@ function spill(world) { } return [top, key]; }; + const gate = world.gate; for (let j = ROWS - SEA_ROWS; j < ROWS; j++) for (let i = 0; i < COLS; i++) { const k = j * COLS + i; S[k] = h[k]; push(k, h[k]); } while (heap.length) { const [k, key] = pop(); @@ -144,7 +146,8 @@ function spill(world) { 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, v = Math.max(key, h[n]); + // A shut gate stands in the water's way like ground. + const n = nj * COLS + ni, v = Math.max(key, h[n] + gate[n]); if (v < S[n]) { S[n] = v; push(n, v); } } } @@ -332,7 +335,8 @@ 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), seaDist: new Float32Array(N).fill(-1), + film: new Float32Array(N), plankton: new Float32Array(N), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), + machines: [], nextMachine: 0, 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])), @@ -389,7 +393,7 @@ function spawn(world, sp, x, z, extra = {}) { 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 }; + if (sp === 'collector' || sp === 'mason' || sp === 'borer' || sp === 'artificer') c.home = { x, z }; world.creatures.push(c); return c; } @@ -432,7 +436,7 @@ export function offerObject(world, kind, x, z) { if (kind === 'feed') return feed(world, x, z); const object = dropObject(world, kind, x, z); if (!object) return null; - const wants = kind === 'brass' ? ['collector', 'mason', 'breaker'] : kind === 'shell' ? ['scraper'] : ['mason']; + const wants = kind === 'brass' ? ['collector', 'artificer', 'mason', 'breaker'] : kind === 'shell' ? ['scraper'] : ['mason']; const free = world.creatures.filter(c => wants.includes(c.sp) && c.carrying === null && !(c.sp === 'scraper' && c.shell !== null)); free.sort((a, b) => wants.indexOf(a.sp) - wants.indexOf(b.sp) || distance(a, object) - distance(b, object)); const c = free[0]; @@ -566,6 +570,7 @@ function die(world, c, cause, by = null) { // Decisions. Each species reads the ground and water around it and chooses one task at a time. function think(world, c) { + if (c.sp === 'artificer') return thinkArtificer(world, c); if (c.sp === 'scraper') return thinkScraper(world, c); if (c.sp === 'collector') return thinkCollector(world, c); if (c.sp === 'mason') return thinkMason(world, c); @@ -741,7 +746,11 @@ 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. + // Idle breakers go at machines and set stone: a gate, a wheel, a wall, a dam. + if (world.random() < .04) { + const target = nearest(c, world.machines.filter(m => m.have >= m.need), 16); + if (target) { assign(world, c, 'wreck', null, { at: { x: target.x, z: target.z }, patience: 30, reach: .7 }); c.task.machine = target.id; return; } + } if (world.random() < .2) { let best = null, score = -Infinity; for (let k = 0; k < 30; k++) { @@ -756,6 +765,115 @@ function thinkBreaker(world, c) { wander(world, c); } +// Artificers build machines out of brass and salvage: sluice gates on pool outlets, tide wheels in the current, and beacons. +export const MACHINES = { + gate: { name: 'sluice gate', need: 4 }, + wheel: { name: 'tide wheel', need: 3 }, + beacon: { name: 'beacon', need: 3 }, +}; +const machineById = (world, id) => world.machines.find(m => m.id === id) || null; +function plan(world, c) { + if (world.machines.length >= 12) return null; + const near = (kind, at, r) => world.machines.some(m => m.kind === kind && distance(m, at) < r); + // A gate on the outlet of a pool that has none. + for (const b of [...world.bodies].sort((a, b) => distance(c, a) - distance(c, b))) { + if (b.outlet && !near('gate', b.outlet, 2) && distance(c, b.outlet) < 22) return { kind: 'gate', x: b.outlet.x, z: b.outlet.z, pool: b.name }; + } + // A wheel in the tideway beside any machine that has no power. + for (const m of world.machines) { + if (m.kind === 'wheel' || near('wheel', m, 9)) continue; + for (let k = 0; k < 30; k++) { + const a = world.random() * Math.PI * 2, r = 1.5 + world.random() * 5; + const x = m.x + Math.cos(a) * r, z = m.z + Math.sin(a) * r; + const g = heightAt(world, x, z); + if (onShelf(world, x, z) && g > -.45 && g < .05 && !world.machines.some(o => distance(o, { x, z }) < 1.2)) return { kind: 'wheel', x, z }; + } + } + // A beacon beside the artificer's home water. + if (!near('beacon', c.home, 12)) { + for (let k = 0; k < 30; k++) { + const a = world.random() * Math.PI * 2, r = 1 + world.random() * 4; + const x = c.home.x + Math.cos(a) * r, z = c.home.z + Math.sin(a) * r; + if (onShelf(world, x, z) && depthAt(world, x, z) < .1 && !world.machines.some(o => distance(o, { x, z }) < 1.5)) return { kind: 'beacon', x, z }; + } + } + return null; +} +function thinkArtificer(world, c) { + if (c.energy < .35) c.hungry = true; + if (c.energy > .8) c.hungry = false; + if (c.hungry) return graze(world, c, .8); + const held = objectById(world, c.carrying); + if (held?.kind === 'brass' && c.energy > .7 && canBreed(world, 'artificer', c)) { work(c, 3); c.task = { kind: 'breed' }; return; } + let project = machineById(world, c.project); + // Finish what is already standing before starting anything new. + if (!project || project.have >= project.need) { + project = nearest(c, world.machines.filter(m => m.have < m.need), 25); + if (project) c.project = project.id; + } + if (!project) { + const next = plan(world, c); + if (next) { + project = { id: world.nextMachine++, ...next, need: MACHINES[next.kind].need, have: 0, charge: 0, spin: 0, lit: 0, shut: 0, owner: c.id, angle: world.random() * Math.PI }; + world.machines.push(project); + c.project = project.id; + event(world, 'plan', c, next.kind); + } else project = null; + } + if (held && project) return assign(world, c, 'assemble', null, { at: { x: project.x, z: project.z }, patience: 50, reach: .6 }); + if (project) { + const part = nearest(c, looseOf(world, ['scrap', 'husk', 'brass', 'pebble']), 24); + if (part) return assign(world, c, 'fetch', part); + } + if (world.random() < .5) return graze(world, c, .8); + wander(world, c); +} +function machineAt(world, x, z, r) { return world.machines.find(m => distance(m, { x, z }) < r) || null; } +function setGate(world, m, height) { + const ci = Math.floor((m.x - X0) / CELL), cj = Math.floor((m.z - Z0) / CELL); + let changed = false; + for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) { + const i = ci + di, j = cj + dj; + if (i < 0 || j < 0 || i >= COLS || j >= ROWS) continue; + const k = j * COLS + i; + if (Math.abs(world.gate[k] - height) > 1e-4) { world.gate[k] = height; changed = true; } + } + if (changed) world.spillDirty = true; +} +// Finished machines work every step. +function machinery(world, dt) { + const rising = tideRising(world.time), rate = Math.abs(Math.cos(world.time / TIDE_PERIOD * Math.PI * 2 + TIDE_PHASE)); + const night = daylight(world) < .45; + for (const m of world.machines) { + const done = m.have >= m.need; + if (m.kind === 'gate') { + // Shut as the tide falls, holding the pool; open as it rises, letting the sea back in. + const want = done && !rising ? 1 : 0; + m.shut += (want - m.shut) * Math.min(1, dt * .8); + setGate(world, m, m.shut > .5 ? .42 : 0); + } else if (m.kind === 'wheel') { + const k = cellAt(m.x, m.z), flowing = done && world.seaDist[k] >= 0 ? rate * Math.min(1, (world.w[k] - world.h[k]) / .25) : 0; + m.spin += flowing * dt * 2.4; + m.charge = clamp(m.charge + flowing * dt * .02 - dt * .001, 0, 1); + } else if (m.kind === 'beacon') { + const wheel = done && world.machines.find(o => o.kind === 'wheel' && o.have >= o.need && o.charge > .03 && distance(o, m) < 9); + const want = wheel && night ? 1 : 0; + m.lit += (want - m.lit) * Math.min(1, dt * .6); + if (wheel && m.lit > .5) { + wheel.charge = Math.max(0, wheel.charge - dt * .004); + // The light feeds plankton in the water around it. + const ci = Math.floor((m.x - X0) / CELL), cj = Math.floor((m.z - Z0) / CELL); + for (let dj = -6; dj <= 6; dj++) for (let di = -6; di <= 6; di++) { + const i = ci + di, j = cj + dj; + if (i < 0 || j < 0 || i >= COLS || j >= ROWS || di * di + dj * dj > 36) continue; + const k = j * COLS + i; + if (world.w[k] - world.h[k] > WET) world.plankton[k] = Math.min(1, world.plankton[k] + dt * .006); + } + } + } + } +} + // Borers dig. A burrow at home first, then tunnels that wander outward, bending toward other water. // Spoil is carried a few steps aside and dumped, so every channel grows banks. export const BURROW = -.9, TUNNEL = -.5, LOAD = .7; @@ -856,7 +974,8 @@ function arrive(world, c) { case 'lift': return work(c, 1.8); case 'dig': return work(c, 30); case 'breed': return work(c, 3); - case 'dismantle': return work(c, 2.4); + case 'dismantle': case 'wreck': return work(c, 2.4); + case 'assemble': return work(c, 2.6); case 'hunt': case 'crack': case 'topple': { const prey = creatureById(world, t.prey); if (!prey || distance(prey, c) > c.reach + .35) return idle(world, c); @@ -957,6 +1076,28 @@ function finish(world, c) { if (t.amount === BURROW && heightAt(world, t.at.x, t.at.z) <= BURROW + .05) event(world, 'burrow', c); else event(world, 'dig', c); break; + case 'assemble': { + const held = objectById(world, c.carrying); + const m = machineById(world, c.project); + if (held && m && m.have < m.need) { + m.have = Math.min(m.need, m.have + (held.kind === 'brass' ? 2 : 1)); + c.carrying = null; + removeObject(world, held); + c.gesture = .3; + event(world, m.have >= m.need ? 'complete' : 'assemble', c, m.kind); + } + break; + } + case 'wreck': { + const m = machineById(world, t.machine); + if (m) { + m.have -= 1; c.strike = 1; + addObject(world, 'scrap', m.x + (world.random() - .5), m.z + (world.random() - .5), { height: .6 }); + event(world, 'wreck', c, m.kind); + if (m.have <= 0) { if (m.kind === 'gate') setGate(world, m, 0); world.machines.splice(world.machines.indexOf(m), 1); } + } + break; + } case 'dismantle': { const took = -reshape(world, t.at.x, t.at.z, -.18, .6); if (took > .02) { @@ -1072,6 +1213,12 @@ function moveSwimmer(world, c, dt) { } if (best > depth) { ax += bx * 2.5 * worry; az += bz * 2.5 * worry; } } + // At night a lit beacon draws the school toward its light. + for (const m of world.machines) { + if (m.kind !== 'beacon' || m.lit < .3) continue; + const dx = m.x - c.x, dz = m.z - c.z, d = Math.hypot(dx, dz); + if (d < 9 && d > 1.2) { ax += dx / d * m.lit * 1.2; az += dz / d * m.lit * 1.2; } + } // At high water the school roams the flooded shelf along a slowly turning heading. const high = tideOf(world.time); if (high > .55 && depth > .15) { @@ -1179,6 +1326,7 @@ export function advanceWorld(world, elapsed) { if (world.disturbed.size) slump(world); if (world.spillDirty && world.time >= world.nextSpill) { spill(world); world.nextSpill = world.time + .5; } water(world, dt); + machinery(world, dt); environment(world, dt); for (const o of [...world.objects]) { o.age += dt; @@ -1326,6 +1474,11 @@ export function goalText(world, c) { 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 === 'assemble' || (c.sp === 'artificer' && t.kind === 'fetch')) { + const m = machineById(world, c.project); + if (m) return t.kind === 'assemble' ? `assembling a ${MACHINES[m.kind].name}` : `salvaging parts for a ${MACHINES[m.kind].name}`; + } + if (t.kind === 'wreck') return 'wrecking a machine'; 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.js b/public/tide-pool.js index 79867ce..087065a 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=12'; + heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, +} from './tide-pool-world.js?v=13'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -380,6 +380,10 @@ async function initialize() { bloomMesh.instanceMatrix.needsUpdate = true; bloomMesh.instanceColor.needsUpdate = true; } + const glowMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2), new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 24); + glowMesh.frustumCulled = false; glowMesh.renderOrder = 6; glowMesh.count = 0; glowMesh.setColorAt(0, new T.Color()); + scene.add(glowMesh); + // Pools are named as they form; each name floats as a hairline label over its water. const labels = new Map(); function placeLabels() { @@ -437,9 +441,16 @@ async function initialize() { // Borer: a low segmented body on treads, a brass auger at the front, and a heap of spoil riding on its back. boBody: part(unitBox, lit(0x4e463f), 10), boPlate: part(unitBox, lit(0x6b5f55), 30), boTread: part(unitBox, lit(0x252428), 20), boAuger: part(new T.CylinderGeometry(0, 1, 1, 7), lit(0xa88d5f, { metal: true }), 10), boSpoil: part(new T.IcosahedronGeometry(1, 0), lit(0x2a2630), 10), - limb: part(unitBox, lit(0x9a8a6a, { metal: true, rough: .45 }), 8 * 6 * 2 + 8 * 4 * 2 + 8 * 4 * 2 + 32 + 16), - knee: part(unitBox, joints, 8 * 6 + 8 * 4 * 2 + 32), - eye: part(unitBox, lamp, 40 + 44 + 96 + 16 + 8 + 8 + 12, { shadow: false, colored: true }), + limb: part(unitBox, lit(0x9a8a6a, { metal: true, rough: .45 }), 380), + knee: part(unitBox, joints, 220), + eye: part(unitBox, lamp, 260, { shadow: false, colored: true }), + // Artificer: an upright hexagonal body on thin legs, a brass collar, a lamp-eyed head, a spool on its back. + arBody: part(hex, lit(0x4a4038), 6), arCollar: part(hex, lit(0xb89a66, { metal: true }), 6), arHead: part(unitBox, lit(0x5c5048), 6), + arSpool: part(hex, lit(0x3a3540), 6), arTool: part(unitBox, lit(0xd6b980, { metal: true }), 12), + // Machines: posts and beams of dark bronze, brass fittings, a gate plate, wheel rims and paddles, and lamps. + mcPost: part(unitBox, lit(0x4b4036, { metal: true, rough: .5 }), 120), mcBrass: part(unitBox, lit(0xb89a66, { metal: true }), 120), + mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 40), + mcPaddle: part(unitBox, lit(0x6b5f55), 200), mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 20, { shadow: false, colored: true }), // Objects and masonry. brass: part(hex, brass, 80), brassBoss: part(unitBox, lit(0xe0c48a, { metal: true }), 80), shell: part(dome, lit(0xd9ccb4, { rough: .55 }), 80), pebble: part(stone, lit(0x8a8f8c), 80), @@ -592,6 +603,29 @@ async function initialize() { }, }; + DRAW.artificer = c => { + const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .01 * c.speed; + setBase(c.x, y, c.z, c.angle); + for (const [sx, sz, o] of [[-1, -1, 0], [1, -1, Math.PI], [-1, 1, Math.PI], [1, 1, 0]]) { + leg(c, sx * .1, .34, sz * .08, sx * .26, sz * .2, sx, o, { thick: .026, stride: .1, lift: .07, kneeUp: .14 }); + } + box(P.arBody, 0, .56, 0, .13, .42, .13, 0, Math.PI / 6); + box(P.arCollar, 0, .79, 0, .16, .04, .16, 0, Math.PI / 6); + box(P.arHead, 0, .88, .02, .16, .1, .15, c.gesture * .5); + box(P.eye, 0, .89, .1, .12, .022, .012, 0, 0, 0, eye(c)); + box(P.arSpool, 0, .6, -.15, .07, .16, .07, 0, 0, Math.PI / 2); + // Long jointed arms; while assembling, the tool hand works in small quick strokes. + const working = c.state === 'work' && c.task?.kind === 'assemble'; + for (const side of [-1, 1]) { + const stroke = working ? Math.sin(c.age * 11 + side) * .06 : 0; + toWorld(hip, side * .13, .74, .05); + toWorld(knee, side * .2, .56, .2); + toWorld(foot, side * .12, .46 + stroke + c.gesture * .2, .4); + segment(P.limb, hip, knee, .024); segment(P.limb, knee, foot, .02); + cube(P.knee, knee, .04); + put(P.arTool, m4.compose(foot, q.setFromAxisAngle(up, c.angle + side), s3.set(.05, .05, .09))); + } + }; DRAW.borer = c => { const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .006 * c.speed; setBase(c.x, y, c.z, c.angle); @@ -610,6 +644,67 @@ async function initialize() { box(P.boSpoil, 0, .27 + s * .06, -.12, .2 * s, .12 * s, .24 * s, .3, c.id, 0); } }; + // Machines rise as they are assembled: frames first, fittings once complete. + const lampColor = new T.Color(), glowColor = new T.Color(), lampWarm = new T.Color(0xe6d0f2), hub = new T.Matrix4(); + function drawMachines() { + let glows = 0; + for (const m of world.machines) { + const done = m.have >= m.need, grow = Math.max(.15, m.have / m.need); + const y = heightAt(world, m.x, m.z); + setBase(m.x, y, m.z, m.angle); + if (m.kind === 'gate') { + for (const side of [-1, 1]) { + box(P.mcPost, side * .6, .45 * grow, 0, .1, .9 * grow, .12); + box(P.mcBrass, side * .6, .9 * grow + .02, 0, .13, .04, .15); + } + if (done) { + box(P.mcPost, 0, .93, 0, 1.3, .08, .1); + // The plate slides down to shut and up to open. + const plateY = .28 + (1 - m.shut) * .52; + box(P.mcPlate, 0, plateY, 0, 1.08, .5, .06); + for (const side of [-1, 1]) box(P.mcBrass, side * .3, plateY + .2, .04, .06, .06, .03); + box(P.mcBrass, 0, .99, 0, .14, .06, .14, 0, m.shut * 2); + } + } else if (m.kind === 'wheel') { + for (const side of [-1, 1]) box(P.mcPost, side * .42, .3 * grow, 0, .08, .6 * grow, .08); + if (done) { + box(P.mcBrass, 0, .5, 0, .9, .05, .05); + // The wheel turns with the current about its axle, which runs across between the posts. + dummy.position.set(0, .5, 0); dummy.rotation.set(m.spin, 0, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); + hub.multiplyMatrices(base, dummy.matrix); + for (const off of [-.2, .2]) { + dummy.position.set(off, 0, 0); dummy.rotation.set(0, Math.PI / 2, 0); dummy.scale.set(.42, .42, .42); dummy.updateMatrix(); + put(P.mcRim, m4.multiplyMatrices(hub, dummy.matrix)); + } + for (let k = 0; k < 8; k++) { + const a = k / 8 * Math.PI * 2; + dummy.position.set(0, Math.cos(a) * .36, Math.sin(a) * .36); dummy.rotation.set(-a, 0, 0); dummy.scale.set(.42, .03, .16); dummy.updateMatrix(); + put(P.mcPaddle, m4.multiplyMatrices(hub, dummy.matrix)); + } + put(P.mcLamp, m4.compose(toWorld(v3, .42, .66, 0), q.identity(), s3.setScalar(.05)), lampColor.set(0x3a3540).lerp(lampWarm, m.charge)); + } + } else if (m.kind === 'beacon') { + box(P.mcPost, 0, 1.1 * grow, 0, .09, 2.2 * grow, .09); + for (const side of [-1, 1]) box(P.mcPost, side * .2, .25 * grow, 0, .06, .5 * grow, .06, 0, 0, -side * .3); + if (done) { + for (let k = 0; k < 4; k++) box(P.mcBrass, Math.cos(k * Math.PI / 2) * .13, 2.35, Math.sin(k * Math.PI / 2) * .13, .025, .32, .025); + box(P.mcBrass, 0, 2.52, 0, .34, .03, .34); + const lampAt = toWorld(v3, 0, 2.35, 0).clone(); + put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.1)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + m.lit * .85)); + if (m.lit > .05 && glows < 22) { + // A halo at the lamp, and a pool of light on the water below. + glowMesh.setMatrixAt(glows, m4.compose(lampAt, camera.quaternion, s3.setScalar(.9 + m.lit * .5))); + glowMesh.setColorAt(glows++, glowColor.set(0xd8c4ec).multiplyScalar(m.lit * .7)); + const floor = Math.max(y, surfaceAt(world, m.x, m.z)) + .03; + glowMesh.setMatrixAt(glows, m4.compose(v1.set(m.x, floor, m.z), q.setFromAxisAngle(v2.set(1, 0, 0), -Math.PI / 2), s3.setScalar(3.2 * m.lit))); + glowMesh.setColorAt(glows++, glowColor.set(0xb9a6d8).multiplyScalar(m.lit * .35)); + } + } + } + } + glowMesh.count = glows; + glowMesh.instanceMatrix.needsUpdate = true; if (glowMesh.instanceColor) glowMesh.instanceColor.needsUpdate = true; + } function drawObjects() { for (const o of world.objects) { const worn = o.place === 'worn'; @@ -628,7 +723,7 @@ async function initialize() { // Where a body meets the water it leaves a faint ring, and a moving one sheds wider rings behind it. const ringColor = new T.Color(), ringBase = new T.Color(0xb9a6c8); - const HEIGHT = { scraper: .25, tab: .05, pylon: 0, collector: .4, mason: .45, breaker: .55, borer: .3 }; + const HEIGHT = { scraper: .25, tab: .05, pylon: 0, collector: .4, mason: .45, breaker: .55, borer: .3, artificer: .9 }; function drawContacts() { for (const c of world.creatures) { const S = SPECIES[c.sp]; @@ -809,6 +904,7 @@ async function initialize() { for (const p of parts) p.n = 0; for (const c of world.creatures) DRAW[c.sp](c); drawObjects(); + drawMachines(); const focus = creatureById(world, selected); drawContacts(); drawBlooms(); @@ -898,6 +994,7 @@ async function initialize() { const DEBUT = { pylon: 'It filters the water, and folds shut when the water is disturbed.', borer: 'It digs.', + artificer: 'It builds machines: sluice gates, tide wheels, and beacons.', collector: 'It hoards brass.', mason: 'It fills hollows and builds walls.', breaker: 'It hunts.', @@ -918,7 +1015,12 @@ 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 'debut': return `A${e.sp === 'artificer' ? 'n' : ''} ${SPECIES[e.sp].name.toLowerCase()} has arrived. ${DEBUT[e.sp]}`; + case 'plan': return `${who} began a ${MACHINES[e.detail].name}.`; + case 'assemble': return `${who} fitted a part to a ${MACHINES[e.detail].name}.`; + case 'complete': return `${who} finished a ${MACHINES[e.detail].name}.`; + case 'wreck': return `${who} wrecked a ${MACHINES[e.detail].name}.`; + case 'dam': return `${who} raised the dam on pool ${e.detail}.`; case 'form': return `Pool ${e.detail} has formed.`; case 'inspect': return `${who} inspected ${e.detail}.`; case 'grow': return `${who} grew a segment.`; @@ -1005,7 +1107,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', 'debut', 'form'].includes(e.type)); + const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form', 'complete', 'wreck', 'plan'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 0bc8f48..aa5d5d1 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -6,6 +6,7 @@ const SPECIES = [ { id: "mason", code: "MS", name: "Mason", note: "Fills. Lifts spoil and stones and builds walled homes, filling in the hollow beneath first." }, { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers and breaks walls down to reach them." }, { id: "borer", code: "BO", name: "Borer", note: "Digs. Sinks a burrow, then tunnels outward toward other water and banks the spoil beside it." }, + { id: "artificer", code: "AR", name: "Artificer", note: "Builds machines from brass and salvage: sluice gates that hold pools at low tide, tide wheels turned by the current, and beacons they power at night." }, ]; const TOOLS = [ @@ -127,7 +128,7 @@ export function TidePoolContent() {
Inside the pool -

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

+

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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 working machines on top: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

    {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}
@@ -138,7 +139,7 @@ export function TidePoolContent() {

Rendered with Three.js. Software license.

- + ); } -- 2.51.2 From c832023f86d494f058cca63df83c4ea1504521e8 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 23 Sep 2026 09:57:18 -0700 Subject: [PATCH 08/28] Light the tide pool with a sky environment, neutral tone mapping, terrain occlusion, and bloom; grow procedural reefs from settled pylons; fix a spill-level rounding loop behind shut gates. --- public/tide-pool-world.js | 74 ++++++++++++++++++++++++-- public/tide-pool.js | 100 ++++++++++++++++++++++++++++++----- src/components/tide-pool.tsx | 8 +-- src/index.tsx | 6 +++ 4 files changed, 170 insertions(+), 18 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 4d5e3ea..4bf1c4b 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -147,7 +147,8 @@ function spill(world) { const ni = i + di, nj = j + dj; if (ni < 0 || nj < 0 || ni >= COLS || nj >= ROWS) continue; // A shut gate stands in the water's way like ground. - const n = nj * COLS + ni, v = Math.max(key, h[n] + gate[n]); + // Rounded to the same 32-bit precision it is stored in, or a gate's added height could 'improve' a cell forever. + const n = nj * COLS + ni, v = Math.fround(Math.max(key, h[n] + gate[n])); if (v < S[n]) { S[n] = v; push(n, v); } } } @@ -336,7 +337,7 @@ export function createWorld(seed = 41) { 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), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), - machines: [], nextMachine: 0, + machines: [], nextMachine: 0, reefs: [], nextReef: 0, 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])), @@ -528,6 +529,12 @@ function breed(world, c) { for (let i = 0; i < 16 && !at; i++) { // A pylon's bud sometimes drifts far, like a spore, and settles in other standing water. const far = S.kind === 'sessile' && world.random() < .45; + // A pylon bud that drifts far prefers to settle on a reef. + const reef = far && world.reefs.length && world.random() < .6 ? world.reefs[Math.floor(world.random() * world.reefs.length)] : null; + if (reef) { + const node = reef.nodes[Math.floor(world.random() * reef.nodes.length)]; + if (depthAt(world, node.x, node.z) > .25) { at = { x: node.x + (world.random() - .5) * .6, z: node.z + (world.random() - .5) * .6 }; continue; } + } const a = world.random() * Math.PI * 2, r = S.kind === 'sessile' ? (far ? 5 + world.random() * 14 : 1.2 + world.random() * 1.4) : .5; const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; const ok = S.kind === 'walker' ? onShelf(world, x, z) : @@ -765,6 +772,55 @@ function thinkBreaker(world, c) { wander(world, c); } +// Reefs: procedural branching structures that grow segment by segment from a settled pylon, fed by the plankton around them. +// Branches climb toward the light, fork now and then, put out shelves, and spread flat when they reach the high-water line. +function foundReef(world, c) { + const y = heightAt(world, c.x, c.z); + const reef = { id: world.nextReef++, x: c.x, z: c.z, heart: c.id, grown: 0, nodes: [{ x: c.x, y, z: c.z, parent: -1, r: .16, tip: true, dx: 0, dy: 1, dz: 0, plate: false }] }; + // Several roots fan out from the base so the reef spreads before it rises. + for (let k = 0; k < 4; k++) { + const a = k / 4 * Math.PI * 2 + world.random(); + reef.nodes.push({ x: c.x + Math.cos(a) * .25, y: y + .1, z: c.z + Math.sin(a) * .25, parent: 0, r: .12, tip: true, dx: Math.cos(a) * .5, dy: .8, dz: Math.sin(a) * .5, plate: false }); + } + reef.nodes[0].tip = false; + world.reefs.push(reef); + event(world, 'reef', c); + return reef; +} +export const nearReef = (world, p) => { + let best = Infinity; + for (const r of world.reefs) { const d = distance(r, p); if (d < best) best = d; } + return best; +}; +function growReefs(world, dt) { + for (const reef of world.reefs) { + if (reef.nodes.length >= 260) continue; + let food = 0; + for (let k = 0; k < 6; k++) { const a = k / 6 * Math.PI * 2; food += world.plankton[cellAt(reef.x + Math.cos(a) * 1.5, reef.z + Math.sin(a) * 1.5)]; } + reef.grown += dt * (.08 + food / 6 * .32); + while (reef.grown >= 1 && reef.nodes.length < 260) { + reef.grown -= 1; + const tips = reef.nodes.map((n, i) => [n, i]).filter(([n]) => n.tip); + if (!tips.length) break; + // Lower tips grow first, so the reef fills out before it rises. + tips.sort((a, b) => a[0].y - b[0].y); + const [tip, index] = tips[Math.floor(world.random() ** 2 * tips.length)]; + const crown = tip.y > TIDE_HIGH - .1; + // Head up toward the light; at the crown, spread sideways instead. + let dx = tip.dx + (world.random() - .5) * .7, dy = crown ? -.05 : tip.dy + .45, dz = tip.dz + (world.random() - .5) * .7; + const len = Math.hypot(dx, dy, dz) || 1; + dx /= len; dy /= len; dz /= len; + const step = .2 + world.random() * .1; + const node = { x: tip.x + dx * step, y: tip.y + dy * step, z: tip.z + dz * step, parent: index, r: Math.max(.035, tip.r * .93), tip: true, dx, dy, dz, plate: false }; + if (!onShelf(world, node.x, node.z) || node.y < heightAt(world, node.x, node.z) || Math.hypot(node.x - reef.x, node.z - reef.z) > 4.2) { tip.tip = false; continue; } + if (crown && Math.hypot(node.x - reef.x, node.z - reef.z) > 2.6) node.tip = false; + tip.tip = world.random() < (crown ? .25 : .16); // Sometimes the old tip forks and keeps growing too. + if (!crown && world.random() < .07) node.plate = true; // A flat shelf. + reef.nodes.push(node); + } + } +} + // Artificers build machines out of brass and salvage: sluice gates on pool outlets, tide wheels in the current, and beacons. export const MACHINES = { gate: { name: 'sluice gate', need: 4 }, @@ -1213,6 +1269,14 @@ function moveSwimmer(world, c, dt) { } if (best > depth) { ax += bx * 2.5 * worry; az += bz * 2.5 * worry; } } + // A reef nearby is home: the school circles it in a slow orbit, drifting in and out among the branches. + for (const r of world.reefs) { + const dx = r.x - c.x, dz = r.z - c.z, d = Math.hypot(dx, dz); + if (d > 9 || d < 1e-3 || r.nodes.length < 6) continue; + const ring = 1.2 + Math.min(2.5, r.nodes.length / 60); + const pull = (d - ring) * .9, swirl = 1.1 * (c.id % 2 ? 1 : -1); + ax += dx / d * pull + (-dz / d) * swirl; az += dz / d * pull + (dx / d) * swirl; + } // At night a lit beacon draws the school toward its light. for (const m of world.machines) { if (m.kind !== 'beacon' || m.lit < .3) continue; @@ -1327,6 +1391,7 @@ export function advanceWorld(world, elapsed) { if (world.spillDirty && world.time >= world.nextSpill) { spill(world); world.nextSpill = world.time + .5; } water(world, dt); machinery(world, dt); + growReefs(world, dt); environment(world, dt); for (const o of [...world.objects]) { o.age += dt; @@ -1367,7 +1432,7 @@ export function advanceWorld(world, elapsed) { if (!dry) { c.energy = Math.min(1, c.energy + pl * .008 * light * c.open * dt); world.plankton[k] = Math.max(0, pl - .002 * dt); } if (c.timer <= 0) { c.timer = 1; - const tab = world.creatures.find(t => t.sp === 'tab' && distance(t, c) < .45 + c.size * .1); + const tab = world.creatures.find(t => t.sp === 'tab' && distance(t, c) < .45 + c.size * .1 && !(nearReef(world, t) < 1.3 && world.random() < .6)); if (tab && !dry && c.open > .6 && c.energy < .85 && world.random() < .16) { c.energy = Math.min(1, c.energy + .22); c.strike = 1; c.eaten++; die(world, tab, 'caught', label(c)); @@ -1375,6 +1440,9 @@ export function advanceWorld(world, elapsed) { if (c.energy > .72 && c.size < 5 && world.random() < .04) { c.size++; c.energy -= .1; event(world, 'grow', c); } else if (c.energy < .18 && c.size > 1) { c.size--; c.energy += .06; } if (c.size >= 4 && c.energy > .8 && world.random() < .04) breed(world, c); + // A mature pylon in deep water settles into the heart of a reef. + if (c.size >= 4 && c.energy > .6 && depthAt(world, c.x, c.z) > .45 && world.reefs.length < 4 && + !world.reefs.some(r => distance(r, c) < 6) && world.random() < .02) foundReef(world, c); } c.gesture = Math.sin(c.phase * .5) * .2; } else { diff --git a/public/tide-pool.js b/public/tide-pool.js index 087065a..d5a2980 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, -} from './tide-pool-world.js?v=13'; +} from './tide-pool-world.js?v=14'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -23,14 +23,50 @@ async function initialize() { renderer.shadowMap.enabled = true; renderer.shadowMap.type = T.PCFSoftShadowMap; renderer.outputColorSpace = T.SRGBColorSpace; - renderer.toneMapping = T.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.1; + // Khronos PBR Neutral: highlights roll off without clipping, and authored colours stay true rather than drifting grey. + renderer.toneMapping = T.NeutralToneMapping; + renderer.toneMappingExposure = 1.25; const scene = new T.Scene(); scene.background = new T.Color(0x0a0a0a); scene.fog = null; const camera = new T.OrthographicCamera(-6, 6, 6, -6, 0.1, 140); + // Bloom: lamps, eyes, and glints bleed softly past their edges. Without the add-ons, the scene renders plainly. + let composer = null; + try { + const base = '/public/tide-pool-vendor/jsm/postprocessing/'; + const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }, { OutputPass }] = await Promise.all( + ['EffectComposer', 'RenderPass', 'UnrealBloomPass', 'OutputPass'].map(name => import(`${base}${name}.js`))); + composer = new EffectComposer(renderer); + composer.addPass(new RenderPass(scene, camera)); + composer.addPass(new UnrealBloomPass(new T.Vector2(512, 512), mobile ? .35 : .45, .55, .72)); + composer.addPass(new OutputPass()); + } catch (error) { + console.warn('Bloom unavailable; rendering without it.', error); + composer = null; + } // Light follows a slow day. Eyes and visors are unlit, so they read as signal lamps at night. + // Image-based light: a generated violet dusk sky, prefiltered into an environment map that lights and reflects on every surface. + { + const sky = new T.Scene(); + sky.add(new T.Mesh(new T.SphereGeometry(10, 32, 16), new T.ShaderMaterial({ + side: T.BackSide, depthWrite: false, + vertexShader: 'varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }', + fragmentShader: `varying vec3 vDir; + void main() { + float up = vDir.y; + vec3 zenith = vec3(.16, .13, .28), horizon = vec3(.55, .48, .62), ground = vec3(.05, .045, .06); + vec3 col = up > 0. ? mix(horizon, zenith, pow(up, .6)) : mix(horizon * .5, ground, pow(-up, .4)); + // A soft sun low in the sky, where the key light comes from. + vec3 sunDir = normalize(vec3(-.7, .45, -.2)); + col += vec3(1., .88, .82) * pow(max(dot(vDir, sunDir), 0.), 24.) * 3.; + gl_FragColor = vec4(col, 1.); + }`, + }))); + const pmrem = new T.PMREMGenerator(renderer); + scene.environment = pmrem.fromScene(sky, .04).texture; + pmrem.dispose(); + } const hemi = new T.HemisphereLight(0xd8ccdf, 0x141118, 1.4); scene.add(hemi); const sun = new T.DirectionalLight(0xf4efe8, 3.8); @@ -182,7 +218,7 @@ async function initialize() { 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 + ` + const ROCK_HEAD = 'varying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\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 @@ -194,6 +230,13 @@ async function initialize() { 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; + // Ambient occlusion: where the ground around stands higher than here, less of the sky reaches in. + float around = 0.; + for (int k = 0; k < 6; k++) { + float a = float(k) * 1.0472; + around += max(gridAt(position.xz + vec2(cos(a), sin(a)) * .9).g - vCell.g, 0.); + } + vOcclude = clamp(1. - around / 6. * 2.2, .35, 1.); 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 }); @@ -203,7 +246,7 @@ async function initialize() { }; const rockMaterial = submerge(new T.MeshLambertMaterial({ color: 0xffffff, flatShading: true }), shader => { shader.vertexShader = ROCK_HEAD + shader.vertexShader.replace('#include ', ROCK_BODY); - shader.fragmentShader = 'varying vec4 vCell;\nvarying float vBeyond;\n' + shader.fragmentShader.replace('#include ', `#include + shader.fragmentShader = 'varying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\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); @@ -216,7 +259,7 @@ async function initialize() { 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. - diffuseColor.rgb = tone * mix(1., .15, vBeyond); + diffuseColor.rgb = tone * mix(1., .15, vBeyond) * vOcclude; }`); }); const ground = new T.Mesh(rockSheet(X0 - 44, Z0 - 44, X1 + 44, Z1 + 30, CELL), rockMaterial); @@ -279,7 +322,8 @@ async function initialize() { 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)); + // Colours above are chosen as they should look on screen; the pipeline expects linear light. + gl_FragColor = vec4(pow(max(col, 0.), vec3(2.2)), clamp(alpha, 0., .94) * smoothstep(.012, .04, d)); }`, }); const water = new T.Mesh(grid, waterMaterial); @@ -346,7 +390,7 @@ async function initialize() { float d = length(p - uRipples[i].xy); light += exp(-pow((d - age * .85) * 13., 2.)) * max(0., 1. - age / 3.) * .3 * step(0., age); } - gl_FragColor = vec4(vec3(.72, .61, .82) * light, 1.); + gl_FragColor = vec4(pow(vec3(.72, .61, .82) * light, vec3(2.2)) * 3., 1.); }`, })); sheen.rotation.x = -Math.PI / 2; sheen.position.set(0, .45, 0); sheen.renderOrder = 3; @@ -450,7 +494,11 @@ async function initialize() { // Machines: posts and beams of dark bronze, brass fittings, a gate plate, wheel rims and paddles, and lamps. mcPost: part(unitBox, lit(0x4b4036, { metal: true, rough: .5 }), 120), mcBrass: part(unitBox, lit(0xb89a66, { metal: true }), 120), mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 40), - mcPaddle: part(unitBox, lit(0x6b5f55), 200), mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 20, { shadow: false, colored: true }), + mcPaddle: part(unitBox, lit(0x6b5f55), 200), + // Reef: porcelain-violet branches, flat shelves, and softly glowing polyp tips. + reefLimb: part(new T.CylinderGeometry(.85, 1, 1, 6), lit(0x8b7f99, { rough: .6 }), 1100, { colored: true }), + reefShelf: part(new T.CylinderGeometry(1, 1, 1, 7), lit(0xa497b2, { rough: .6 }), 120), + reefPolyp: part(new T.IcosahedronGeometry(1, 0), lamp, 700, { shadow: false, colored: true }), mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 20, { shadow: false, colored: true }), // Objects and masonry. brass: part(hex, brass, 80), brassBoss: part(unitBox, lit(0xe0c48a, { metal: true }), 80), shell: part(dome, lit(0xd9ccb4, { rough: .55 }), 80), pebble: part(stone, lit(0x8a8f8c), 80), @@ -705,6 +753,30 @@ async function initialize() { glowMesh.count = glows; glowMesh.instanceMatrix.needsUpdate = true; if (glowMesh.instanceColor) glowMesh.instanceColor.needsUpdate = true; } + const reefColor = new T.Color(), reefBase = new T.Color(0x5e5468), reefTop = new T.Color(0xc9bcd6), polypColor = new T.Color(); + function drawReefs() { + for (const reef of world.reefs) { + const baseY = reef.nodes[0].y; + reef.nodes.forEach((n, i) => { + if (n.parent < 0) return; + const p = reef.nodes[n.parent]; + v1.set(p.x, p.y, p.z); v2.set(n.x, n.y, n.z); + const length = v1.distanceTo(v2); + v3.subVectors(v2, v1).divideScalar(length || 1); + q.setFromUnitVectors(up, v3); + // Paler toward the crown, darker at the roots. + reefColor.copy(reefBase).lerp(reefTop, clamp((n.y - baseY) / 1.1, 0, 1)); + put(P.reefLimb, m4.compose(v1.add(v2).multiplyScalar(.5), q, s3.set(n.r, length * 1.05, n.r)), reefColor); + if (n.plate) put(P.reefShelf, m4.compose(v2.set(n.x, n.y, n.z), q.setFromAxisAngle(up, i), s3.set(.28, .03, .24))); + if (n.tip) { + // Polyps breathe slowly, each on its own beat. + const pulse = .5 + .5 * Math.sin(world.time * 1.3 + i * 1.7); + put(P.reefPolyp, m4.compose(v2.set(n.x, n.y + .02, n.z), q.identity(), s3.setScalar(.035 + pulse * .015)), + polypColor.set(0x6e5c80).lerp(lampWarm, .25 + pulse * .45)); + } + }); + } + } function drawObjects() { for (const o of world.objects) { const worn = o.place === 'worn'; @@ -897,7 +969,8 @@ async function initialize() { shared.uSun.value.copy(level).multiplyScalar(2 * level.dot(eye)).sub(eye).normalize(); sun.intensity = 4 + .8 * light; sun.color.setRGB(.86 + .12 * light, .83 + .12 * light, .95); - hemi.intensity = 1.35 + .15 * light; + hemi.intensity = .7 + .15 * light; + scene.environmentIntensity = .55 + .6 * light; fill.intensity = 1.6; @@ -905,6 +978,7 @@ async function initialize() { for (const c of world.creatures) DRAW[c.sp](c); drawObjects(); drawMachines(); + drawReefs(); const focus = creatureById(world, selected); drawContacts(); drawBlooms(); @@ -915,7 +989,7 @@ async function initialize() { p.mesh.instanceMatrix.needsUpdate = true; if (p.colored && p.mesh.instanceColor) p.mesh.instanceColor.needsUpdate = true; } - renderer.render(scene, camera); + if (composer) composer.render(); else renderer.render(scene, camera); placeLabels(); dirty = false; } @@ -1016,6 +1090,7 @@ async function initialize() { case 'crack': return `${who} cracked ${e.other}'s shell.`; case 'arrive': return `${who} arrived from the sea.`; case 'debut': return `A${e.sp === 'artificer' ? 'n' : ''} ${SPECIES[e.sp].name.toLowerCase()} has arrived. ${DEBUT[e.sp]}`; + case 'reef': return `${who} settled into the heart of a reef.`; case 'plan': return `${who} began a ${MACHINES[e.detail].name}.`; case 'assemble': return `${who} fitted a part to a ${MACHINES[e.detail].name}.`; case 'complete': return `${who} finished a ${MACHINES[e.detail].name}.`; @@ -1107,7 +1182,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', 'debut', 'form', 'complete', 'wreck', 'plan'].includes(e.type)); + const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form', 'complete', 'wreck', 'plan', 'reef'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { @@ -1165,6 +1240,7 @@ async function initialize() { const { width, height } = stage.getBoundingClientRect(); renderer.setPixelRatio(Math.min(devicePixelRatio, mobile ? 1.5 : 2, 1800 / Math.max(1, width))); renderer.setSize(width, height, false); + if (composer) { composer.setPixelRatio(renderer.getPixelRatio()); composer.setSize(width, height); } if (width / height < .8 && goal.zoom === 7) goal.zoom = view.zoom = 10; placeCamera(); wake(); } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index aa5d5d1..3654bb4 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,7 +1,7 @@ const SPECIES = [ { id: "scraper", code: "SC", name: "Scraper", note: "Grazes the film on wet floors. Hides in shells, hollows, and walled homes." }, { id: "tab", code: "TB", name: "Tab", note: "Schools in open water. Feels for deeper water as the tide drains." }, - { id: "pylon", code: "PY", name: "Pylon", note: "Stands still, filters the water, and catches tabs." }, + { id: "pylon", code: "PY", name: "Pylon", note: "Stands still, filters the water, and catches tabs. A mature pylon in deep water settles into the heart of a reef." }, { id: "collector", code: "CL", name: "Collector", note: "Salvages husks and hoards brass in a corner." }, { id: "mason", code: "MS", name: "Mason", note: "Fills. Lifts spoil and stones and builds walled homes, filling in the hollow beneath first." }, { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers and breaks walls down to reach them." }, @@ -128,7 +128,7 @@ export function TidePoolContent() {
Inside the pool -

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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 working machines on top: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

+

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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. Reefs grow segment by segment around a settled pylon, fed by the plankton in the water, and the tabs make their homes circling them. Artificers build working machines on top: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

    {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}
@@ -139,7 +139,9 @@ export function TidePoolContent() {

Rendered with Three.js. Software license.

- + {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } diff --git a/src/index.tsx b/src/index.tsx index 695fc36..1f58a29 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -63,6 +63,12 @@ app.use( // Only the two pinned browser modules used by the tide pool, not node_modules broadly. app.get("/public/tide-pool-vendor/three.module.js", serveStatic({ path: "./node_modules/three/build/three.module.min.js" })); app.get("/public/tide-pool-vendor/three.core.min.js", serveStatic({ path: "./node_modules/three/build/three.core.min.js" })); +// The few post-processing add-ons the tide pool uses for bloom, served by name. +for (const file of ["postprocessing/EffectComposer.js", "postprocessing/RenderPass.js", "postprocessing/UnrealBloomPass.js", "postprocessing/OutputPass.js", + "postprocessing/Pass.js", "postprocessing/ShaderPass.js", "postprocessing/MaskPass.js", + "shaders/CopyShader.js", "shaders/LuminosityHighPassShader.js", "shaders/OutputShader.js"]) { + app.get(`/public/tide-pool-vendor/jsm/${file}`, serveStatic({ path: `./node_modules/three/examples/jsm/${file}` })); +} app.use("/public/*", serveStatic({ root: "./" })); app.use("/assets/*", serveStatic({ root: "./public" })); app.get("/favicon.ico", (c) => c.redirect("/public/favicon.ico", 308)); -- 2.51.2 From f72af41fd2e70b6e415860a194ac0760a3fe943e Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 23 Sep 2026 14:04:52 -0700 Subject: [PATCH 09/28] Replace the organic reef with procedural lattice towers that artificers design and build module by module; tabs make them home and pylons settle at their feet. --- public/tide-pool-world.js | 155 +++++++++++++++++++---------------- public/tide-pool.js | 111 ++++++++++++++++++------- src/components/tide-pool.tsx | 10 +-- 3 files changed, 169 insertions(+), 107 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 4bf1c4b..3eaadbe 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -337,7 +337,7 @@ export function createWorld(seed = 41) { 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), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), - machines: [], nextMachine: 0, reefs: [], nextReef: 0, + machines: [], nextMachine: 0, 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])), @@ -529,11 +529,12 @@ function breed(world, c) { for (let i = 0; i < 16 && !at; i++) { // A pylon's bud sometimes drifts far, like a spore, and settles in other standing water. const far = S.kind === 'sessile' && world.random() < .45; - // A pylon bud that drifts far prefers to settle on a reef. - const reef = far && world.reefs.length && world.random() < .6 ? world.reefs[Math.floor(world.random() * world.reefs.length)] : null; - if (reef) { - const node = reef.nodes[Math.floor(world.random() * reef.nodes.length)]; - if (depthAt(world, node.x, node.z) > .25) { at = { x: node.x + (world.random() - .5) * .6, z: node.z + (world.random() - .5) * .6 }; continue; } + // A pylon bud that drifts far prefers to settle at the foot of a tower. + const towers = far ? world.machines.filter(m => m.kind === 'tower' && m.have >= 4) : []; + if (towers.length && world.random() < .6) { + const t = towers[Math.floor(world.random() * towers.length)], a = world.random() * Math.PI * 2, r = t.reach * .5 + .4; + const x = t.x + Math.cos(a) * r, z = t.z + Math.sin(a) * r; + if (depthAt(world, x, z) > .25 && world.creatures.every(o => o.sp !== 'pylon' || distance(o, { x, z }) > .8)) { at = { x, z }; continue; } } const a = world.random() * Math.PI * 2, r = S.kind === 'sessile' ? (far ? 5 + world.random() * 14 : 1.2 + world.random() * 1.4) : .5; const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; @@ -772,65 +773,66 @@ function thinkBreaker(world, c) { wander(world, c); } -// Reefs: procedural branching structures that grow segment by segment from a settled pylon, fed by the plankton around them. -// Branches climb toward the light, fork now and then, put out shelves, and spread flat when they reach the high-water line. -function foundReef(world, c) { - const y = heightAt(world, c.x, c.z); - const reef = { id: world.nextReef++, x: c.x, z: c.z, heart: c.id, grown: 0, nodes: [{ x: c.x, y, z: c.z, parent: -1, r: .16, tip: true, dx: 0, dy: 1, dz: 0, plate: false }] }; - // Several roots fan out from the base so the reef spreads before it rises. - for (let k = 0; k < 4; k++) { - const a = k / 4 * Math.PI * 2 + world.random(); - reef.nodes.push({ x: c.x + Math.cos(a) * .25, y: y + .1, z: c.z + Math.sin(a) * .25, parent: 0, r: .12, tip: true, dx: Math.cos(a) * .5, dy: .8, dz: Math.sin(a) * .5, plate: false }); - } - reef.nodes[0].tip = false; - world.reefs.push(reef); - event(world, 'reef', c); - return reef; -} -export const nearReef = (world, p) => { - let best = Infinity; - for (const r of world.reefs) { const d = distance(r, p); if (d < best) best = d; } - return best; -}; -function growReefs(world, dt) { - for (const reef of world.reefs) { - if (reef.nodes.length >= 260) continue; - let food = 0; - for (let k = 0; k < 6; k++) { const a = k / 6 * Math.PI * 2; food += world.plankton[cellAt(reef.x + Math.cos(a) * 1.5, reef.z + Math.sin(a) * 1.5)]; } - reef.grown += dt * (.08 + food / 6 * .32); - while (reef.grown >= 1 && reef.nodes.length < 260) { - reef.grown -= 1; - const tips = reef.nodes.map((n, i) => [n, i]).filter(([n]) => n.tip); - if (!tips.length) break; - // Lower tips grow first, so the reef fills out before it rises. - tips.sort((a, b) => a[0].y - b[0].y); - const [tip, index] = tips[Math.floor(world.random() ** 2 * tips.length)]; - const crown = tip.y > TIDE_HIGH - .1; - // Head up toward the light; at the crown, spread sideways instead. - let dx = tip.dx + (world.random() - .5) * .7, dy = crown ? -.05 : tip.dy + .45, dz = tip.dz + (world.random() - .5) * .7; - const len = Math.hypot(dx, dy, dz) || 1; - dx /= len; dy /= len; dz /= len; - const step = .2 + world.random() * .1; - const node = { x: tip.x + dx * step, y: tip.y + dy * step, z: tip.z + dz * step, parent: index, r: Math.max(.035, tip.r * .93), tip: true, dx, dy, dz, plate: false }; - if (!onShelf(world, node.x, node.z) || node.y < heightAt(world, node.x, node.z) || Math.hypot(node.x - reef.x, node.z - reef.z) > 4.2) { tip.tip = false; continue; } - if (crown && Math.hypot(node.x - reef.x, node.z - reef.z) > 2.6) node.tip = false; - tip.tip = world.random() < (crown ? .25 : .16); // Sometimes the old tip forks and keeps growing too. - if (!crown && world.random() < .07) node.plate = true; // A flat shelf. - reef.nodes.push(node); - } - } -} - // Artificers build machines out of brass and salvage: sluice gates on pool outlets, tide wheels in the current, and beacons. export const MACHINES = { + tower: { name: 'tower', need: 0 }, gate: { name: 'sluice gate', need: 4 }, wheel: { name: 'tide wheel', need: 3 }, beacon: { name: 'beacon', need: 3 }, }; const machineById = (world, id) => world.machines.find(m => m.id === id) || null; +export const MODULE = .75, LEVEL = .65; +// A tower's design, drawn up once when it is begun: lattice frames on a broad base that narrows as it rises, +// with decks, cantilevered platforms, gear housings, pipes, and a lamp mast. Built bottom-up, one module per load. +function designTower(world) { + const r = world.random, mods = []; + const levels = 5 + Math.floor(r() * 4); + let cells = [[0, 0], [1, 0], [0, 1], [1, 1]]; + if (r() < .5) cells.push([2, 0], [2, 1]); + for (let k = 0; k < levels; k++) { + if (k >= 2 && cells.length > 1 && r() < .45) { const drop = Math.floor(r() * cells.length); cells = cells.filter((_, n) => n !== drop); } + if (k >= levels - 2 && cells.length > 2) cells = cells.slice(0, 2); + for (const [i, j] of cells) mods.push({ kind: 'frame', i, j, k }); + if (k % 2 === 1) for (const [i, j] of cells) mods.push({ kind: 'deck', i, j, k }); + // A cantilevered arm with its own platform. + if (k >= 2 && r() < .5) { + const [i, j] = cells[Math.floor(r() * cells.length)], [di, dj] = [[1, 0], [-1, 0], [0, 1], [0, -1]][Math.floor(r() * 4)]; + if (!cells.some(([a, b]) => a === i + di && b === j + dj)) mods.push({ kind: 'arm', i: i + di, j: j + dj, k, from: [i, j] }, { kind: 'deck', i: i + di, j: j + dj, k }); + } + if (r() < .4) { const [i, j] = cells[Math.floor(r() * cells.length)]; mods.push({ kind: 'gear', i, j, k, side: Math.floor(r() * 4) }); } + if (k < levels - 1 && r() < .35) { const [i, j] = cells[Math.floor(r() * cells.length)]; mods.push({ kind: 'pipe', i, j, k, corner: Math.floor(r() * 4) }); } + } + const [ti, tj] = cells[0]; + mods.push({ kind: 'mast', i: ti, j: tj, k: levels }); + const span = Math.max(...mods.map(m => Math.max(Math.abs(m.i), Math.abs(m.j)))) + 1; + return { mods, levels, reach: span * MODULE }; +} +export const nearTower = (world, p) => { + let best = Infinity; + for (const m of world.machines) if (m.kind === 'tower' && m.have >= 4) best = Math.min(best, distance(m, p)); + return best; +}; function plan(world, c) { - if (world.machines.length >= 12) return null; + if (world.machines.length >= 14) return null; const near = (kind, at, r) => world.machines.some(m => m.kind === kind && distance(m, at) < r); + // A tower in the deep water of a pool that has none, often before anything else. + if (world.machines.filter(m => m.kind === 'tower').length < 3 && world.random() < .6) { + for (const b of [...world.bodies].sort((a, b) => distance(c, a) - distance(c, b))) { + if (b.area < 6 || near('tower', b, 8) || distance(c, b) > 24) continue; + for (let k = 0; k < 30; k++) { + const a = world.random() * Math.PI * 2, r = world.random() * 2.5; + const x = b.x + Math.cos(a) * r, z = b.z + Math.sin(a) * r; + if (depthAt(world, x, z) > .4 && !world.machines.some(o => distance(o, { x, z }) < 4)) return { kind: 'tower', x, z, ...designTower(world) }; + } + } + // Otherwise any low ground the high tide covers deeply, away from other towers. + for (let k = 0; k < 40; k++) { + const a = world.random() * Math.PI * 2, r = 2 + world.random() * 12; + const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; + if (onShelf(world, x, z) && z < 10 && heightAt(world, x, z) < TIDE_HIGH - .55 && !near('tower', { x, z }, 8) && + !world.machines.some(o => distance(o, { x, z }) < 4)) return { kind: 'tower', x, z, ...designTower(world) }; + } + } // A gate on the outlet of a pool that has none. for (const b of [...world.bodies].sort((a, b) => distance(c, a) - distance(c, b))) { if (b.outlet && !near('gate', b.outlet, 2) && distance(c, b.outlet) < 22) return { kind: 'gate', x: b.outlet.x, z: b.outlet.z, pool: b.name }; @@ -870,7 +872,8 @@ function thinkArtificer(world, c) { if (!project) { const next = plan(world, c); if (next) { - project = { id: world.nextMachine++, ...next, need: MACHINES[next.kind].need, have: 0, charge: 0, spin: 0, lit: 0, shut: 0, owner: c.id, angle: world.random() * Math.PI }; + project = { id: world.nextMachine++, ...next, need: next.mods ? next.mods.length : MACHINES[next.kind].need, have: 0, charge: 0, spin: 0, lit: 0, shut: 0, owner: c.id, + angle: next.kind === 'tower' ? Math.floor(world.random() * 4) * Math.PI / 2 + .3 : world.random() * Math.PI, base: heightAt(world, next.x, next.z) }; world.machines.push(project); c.project = project.id; event(world, 'plan', c, next.kind); @@ -911,6 +914,10 @@ function machinery(world, dt) { const k = cellAt(m.x, m.z), flowing = done && world.seaDist[k] >= 0 ? rate * Math.min(1, (world.w[k] - world.h[k]) / .25) : 0; m.spin += flowing * dt * 2.4; m.charge = clamp(m.charge + flowing * dt * .02 - dt * .001, 0, 1); + } else if (m.kind === 'tower') { + // Gears turn once the tower is half built; the mast lamp burns at night once it is finished. + if (m.have >= m.need / 2) m.spin += dt * .7; + m.lit += ((done && night ? 1 : 0) - m.lit) * Math.min(1, dt * .5); } else if (m.kind === 'beacon') { const wheel = done && world.machines.find(o => o.kind === 'wheel' && o.have >= o.need && o.charge > .03 && distance(o, m) < 9); const want = wheel && night ? 1 : 0; @@ -1136,7 +1143,7 @@ function finish(world, c) { const held = objectById(world, c.carrying); const m = machineById(world, c.project); if (held && m && m.have < m.need) { - m.have = Math.min(m.need, m.have + (held.kind === 'brass' ? 2 : 1)); + m.have = Math.min(m.need, m.have + (held.kind === 'brass' ? 2 : 1) * (m.kind === 'tower' ? 2 : 1)); c.carrying = null; removeObject(world, held); c.gesture = .3; @@ -1269,13 +1276,21 @@ function moveSwimmer(world, c, dt) { } if (best > depth) { ax += bx * 2.5 * worry; az += bz * 2.5 * worry; } } - // A reef nearby is home: the school circles it in a slow orbit, drifting in and out among the branches. - for (const r of world.reefs) { - const dx = r.x - c.x, dz = r.z - c.z, d = Math.hypot(dx, dz); - if (d > 9 || d < 1e-3 || r.nodes.length < 6) continue; - const ring = 1.2 + Math.min(2.5, r.nodes.length / 60); - const pull = (d - ring) * .9, swirl = 1.1 * (c.id % 2 ? 1 : -1); - ax += dx / d * pull + (-dz / d) * swirl; az += dz / d * pull + (dx / d) * swirl; + // Most of the school takes a tower as home once one stands: they find their way back to it from anywhere, + // then circle it in a slow orbit, drifting in and out through its frames. + let home = null, homeD = Infinity; + if (c.id % 4) for (const r of world.machines) { + if (r.kind !== 'tower' || r.have < 6) continue; + const d = distance(r, c); + if (d < homeD) { home = r; homeD = d; } + } + if (home && homeD > 1e-3) { + const dx = home.x - c.x, dz = home.z - c.z, d = homeD, ring = .6 + home.reach * .7; + if (d > 7) { ax += dx / d * 1.3; az += dz / d * 1.3; } + else { + const pull = (d - ring) * .9, swirl = 1.1 * (c.id % 2 ? 1 : -1); + ax += dx / d * pull + (-dz / d) * swirl; az += dz / d * pull + (dx / d) * swirl; + } } // At night a lit beacon draws the school toward its light. for (const m of world.machines) { @@ -1287,7 +1302,8 @@ function moveSwimmer(world, c, dt) { const high = tideOf(world.time); if (high > .55 && depth > .15) { c.heading += (world.random() - .5) * dt * 1.2; - ax += Math.cos(c.heading) * 1.4 * (high - .55) * 2.2; az += Math.sin(c.heading) * 1.4 * (high - .55) * 2.2; + const roam = 1.4 * (high - .55) * 2.2 * (home ? .3 : 1); + ax += Math.cos(c.heading) * roam; az += Math.sin(c.heading) * roam; } const a = (world.random() - .5) * 2.2; ax += Math.cos(c.phase * .3 + a) * .5; az += Math.sin(c.phase * .3 + a) * .5; @@ -1391,7 +1407,6 @@ export function advanceWorld(world, elapsed) { if (world.spillDirty && world.time >= world.nextSpill) { spill(world); world.nextSpill = world.time + .5; } water(world, dt); machinery(world, dt); - growReefs(world, dt); environment(world, dt); for (const o of [...world.objects]) { o.age += dt; @@ -1432,7 +1447,8 @@ export function advanceWorld(world, elapsed) { if (!dry) { c.energy = Math.min(1, c.energy + pl * .008 * light * c.open * dt); world.plankton[k] = Math.max(0, pl - .002 * dt); } if (c.timer <= 0) { c.timer = 1; - const tab = world.creatures.find(t => t.sp === 'tab' && distance(t, c) < .45 + c.size * .1 && !(nearReef(world, t) < 1.3 && world.random() < .6)); + // Fish sheltering inside a tower's frames are hard to reach. + const tab = world.creatures.find(t => t.sp === 'tab' && distance(t, c) < .45 + c.size * .1 && !(nearTower(world, t) < 1.4 && world.random() < .6)); if (tab && !dry && c.open > .6 && c.energy < .85 && world.random() < .16) { c.energy = Math.min(1, c.energy + .22); c.strike = 1; c.eaten++; die(world, tab, 'caught', label(c)); @@ -1440,9 +1456,6 @@ export function advanceWorld(world, elapsed) { if (c.energy > .72 && c.size < 5 && world.random() < .04) { c.size++; c.energy -= .1; event(world, 'grow', c); } else if (c.energy < .18 && c.size > 1) { c.size--; c.energy += .06; } if (c.size >= 4 && c.energy > .8 && world.random() < .04) breed(world, c); - // A mature pylon in deep water settles into the heart of a reef. - if (c.size >= 4 && c.energy > .6 && depthAt(world, c.x, c.z) > .45 && world.reefs.length < 4 && - !world.reefs.some(r => distance(r, c) < 6) && world.random() < .02) foundReef(world, c); } c.gesture = Math.sin(c.phase * .5) * .2; } else { diff --git a/public/tide-pool.js b/public/tide-pool.js index d5a2980..b866592 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, -} from './tide-pool-world.js?v=14'; + heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, MODULE, LEVEL, +} from './tide-pool-world.js?v=18'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -493,12 +493,12 @@ async function initialize() { arSpool: part(hex, lit(0x3a3540), 6), arTool: part(unitBox, lit(0xd6b980, { metal: true }), 12), // Machines: posts and beams of dark bronze, brass fittings, a gate plate, wheel rims and paddles, and lamps. mcPost: part(unitBox, lit(0x4b4036, { metal: true, rough: .5 }), 120), mcBrass: part(unitBox, lit(0xb89a66, { metal: true }), 120), - mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 40), + mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 120), mcPaddle: part(unitBox, lit(0x6b5f55), 200), - // Reef: porcelain-violet branches, flat shelves, and softly glowing polyp tips. - reefLimb: part(new T.CylinderGeometry(.85, 1, 1, 6), lit(0x8b7f99, { rough: .6 }), 1100, { colored: true }), - reefShelf: part(new T.CylinderGeometry(1, 1, 1, 7), lit(0xa497b2, { rough: .6 }), 120), - reefPolyp: part(new T.IcosahedronGeometry(1, 0), lamp, 700, { shadow: false, colored: true }), mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 20, { shadow: false, colored: true }), + // Towers: violet steel lattice, grated decks, and brass fittings. + twFrame: part(unitBox, lit(0x4a4252, { metal: true, rough: .5 }), 1600), twDeck: part(unitBox, lit(0x6a6072, { metal: true, rough: .45 }), 260), + twBrass: part(unitBox, lit(0xb89a66, { metal: true }), 500), + mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 160, { shadow: false, colored: true }), // Objects and masonry. brass: part(hex, brass, 80), brassBoss: part(unitBox, lit(0xe0c48a, { metal: true }), 80), shell: part(dome, lit(0xd9ccb4, { rough: .55 }), 80), pebble: part(stone, lit(0x8a8f8c), 80), @@ -698,6 +698,7 @@ async function initialize() { let glows = 0; for (const m of world.machines) { const done = m.have >= m.need, grow = Math.max(.15, m.have / m.need); + if (m.kind === 'tower') { glows = drawTower(m, glows); continue; } const y = heightAt(world, m.x, m.z); setBase(m.x, y, m.z, m.angle); if (m.kind === 'gate') { @@ -753,29 +754,79 @@ async function initialize() { glowMesh.count = glows; glowMesh.instanceMatrix.needsUpdate = true; if (glowMesh.instanceColor) glowMesh.instanceColor.needsUpdate = true; } - const reefColor = new T.Color(), reefBase = new T.Color(0x5e5468), reefTop = new T.Color(0xc9bcd6), polypColor = new T.Color(); - function drawReefs() { - for (const reef of world.reefs) { - const baseY = reef.nodes[0].y; - reef.nodes.forEach((n, i) => { - if (n.parent < 0) return; - const p = reef.nodes[n.parent]; - v1.set(p.x, p.y, p.z); v2.set(n.x, n.y, n.z); - const length = v1.distanceTo(v2); - v3.subVectors(v2, v1).divideScalar(length || 1); - q.setFromUnitVectors(up, v3); - // Paler toward the crown, darker at the roots. - reefColor.copy(reefBase).lerp(reefTop, clamp((n.y - baseY) / 1.1, 0, 1)); - put(P.reefLimb, m4.compose(v1.add(v2).multiplyScalar(.5), q, s3.set(n.r, length * 1.05, n.r)), reefColor); - if (n.plate) put(P.reefShelf, m4.compose(v2.set(n.x, n.y, n.z), q.setFromAxisAngle(up, i), s3.set(.28, .03, .24))); - if (n.tip) { - // Polyps breathe slowly, each on its own beat. - const pulse = .5 + .5 * Math.sin(world.time * 1.3 + i * 1.7); - put(P.reefPolyp, m4.compose(v2.set(n.x, n.y + .02, n.z), q.identity(), s3.setScalar(.035 + pulse * .015)), - polypColor.set(0x6e5c80).lerp(lampWarm, .25 + pulse * .45)); + // A tower is drawn from its design, one module at a time up to however much has been built. + const BRACE = Math.hypot(MODULE, LEVEL), TILT = Math.atan2(MODULE, LEVEL); + function drawTower(m, glows) { + const floor = m.mods.filter(d => d.k === 0); + const ci = floor.reduce((a, d) => a + d.i, 0) / floor.length, cj = floor.reduce((a, d) => a + d.j, 0) / floor.length; + const y0 = Math.min(m.base ?? 0, heightAt(world, m.x, m.z)) - .05; + setBase(m.x, y0, m.z, m.angle); + const at = (i, j) => [(i - ci) * MODULE, (j - cj) * MODULE]; + const h = MODULE / 2, t = .06; + // A footing slab under the first level. + const [fx0, fz0] = at(Math.min(...floor.map(d => d.i)), Math.min(...floor.map(d => d.j))); + const [fx1, fz1] = at(Math.max(...floor.map(d => d.i)), Math.max(...floor.map(d => d.j))); + box(P.twDeck, (fx0 + fx1) / 2, .04, (fz0 + fz1) / 2, fx1 - fx0 + MODULE + .2, .12, fz1 - fz0 + MODULE + .2); + for (let n = 0; n < m.have && n < m.mods.length; n++) { + const d = m.mods[n], [x, z] = at(d.i, d.j), yb = d.k * LEVEL + .1, yt = yb + LEVEL; + if (d.kind === 'frame') { + for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) box(P.twFrame, x + sx * h, yb + LEVEL / 2, z + sz * h, t, LEVEL, t); + for (const s of [-1, 1]) { + box(P.twFrame, x, yt, z + s * h, MODULE, t * .8, t * .8); + box(P.twFrame, x + s * h, yt, z, t * .8, t * .8, MODULE); } - }); + // Cross-bracing on alternate faces, turning each level. + const flip = (d.i + d.j + d.k) % 2 ? 1 : -1; + box(P.twFrame, x + h, yb + LEVEL / 2, z, t * .6, BRACE, t * .6, flip * TILT, 0, 0); + box(P.twFrame, x - h, yb + LEVEL / 2, z, t * .6, BRACE, t * .6, -flip * TILT, 0, 0); + box(P.twFrame, x, yb + LEVEL / 2, z + h, t * .6, BRACE, t * .6, 0, 0, flip * TILT); + box(P.twFrame, x, yb + LEVEL / 2, z - h, t * .6, BRACE, t * .6, 0, 0, -flip * TILT); + } else if (d.kind === 'deck') { + for (const s of [-.3, 0, .3]) box(P.twDeck, x, yt + .03, z + s * MODULE, MODULE * .96, .03, MODULE * .24); + for (const s of [-1, 1]) box(P.twBrass, x + s * h * .96, yt + .09, z, .02, .09, MODULE * .9); + // A lit window in the deck house at night: somebody lives here. + if (m.lit > .05 && (d.i * 3 + d.j * 5 + d.k) % 3 === 0) + put(P.mcLamp, m4.compose(toWorld(v3, x, yt + .12, z), q.identity(), s3.setScalar(.04)), lampColor.set(0x3a3540).lerp(lampWarm, .3 + m.lit * .6)); + } else if (d.kind === 'arm') { + const [px, pz] = at(d.from[0], d.from[1]), mx = (x + px) / 2, mz = (z + pz) / 2, along = d.i !== d.from[0]; + for (const s of [-1, 1]) { + box(P.twFrame, along ? x : x + s * h, yt, along ? z + s * h : z, along ? MODULE : t * .8, t * .8, along ? t * .8 : MODULE); + // A strut under the cantilever back to the tower's face. + const ox = along ? 0 : s * h, oz = along ? s * h : 0; + box(P.twFrame, (mx + x) / 2 + ox, yt - LEVEL * .45, (mz + z) / 2 + oz, t * .6, BRACE * .9, t * .6, + along ? 0 : (d.j > d.from[1] ? -1 : 1) * TILT, 0, along ? (d.i > d.from[0] ? 1 : -1) * TILT : 0); + } + box(P.twFrame, x + (along ? (x - px) / 2 : 0) * .98, yt + LEVEL * .25, z + (along ? 0 : (z - pz) / 2) * .98, t, LEVEL * .5, t); + } else if (d.kind === 'gear') { + const a = d.side * Math.PI / 2, gx = x + Math.cos(a) * (h + .05), gz = z + Math.sin(a) * (h + .05); + dummy.position.set(gx, yb + LEVEL / 2, gz); dummy.rotation.set(0, -a + Math.PI / 2, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); + hub.multiplyMatrices(base, dummy.matrix); + for (const [r, spin] of [[.17, m.spin], [.09, -m.spin * 1.9]]) { + dummy.position.set(r === .17 ? 0 : .2, r === .17 ? 0 : .12, 0); dummy.rotation.set(0, 0, spin); dummy.scale.set(r, r, r * 1.6); dummy.updateMatrix(); + put(P.mcRim, m4.multiplyMatrices(hub, dummy.matrix)); + for (let k = 0; k < 4; k++) { + dummy.rotation.set(0, 0, spin + k * Math.PI / 4); dummy.scale.set(r * 2, .02, .02); dummy.updateMatrix(); + put(P.twBrass, m4.multiplyMatrices(hub, dummy.matrix)); + } + } + } else if (d.kind === 'pipe') { + const cx = d.corner & 1 ? h + .06 : -h - .06, cz = d.corner & 2 ? h + .06 : -h - .06; + box(P.twBrass, x + cx, yb + LEVEL, z + cz, .05, LEVEL * 2, .05); + box(P.twBrass, x + cx, yb + .05, z + cz, .08, .04, .08); + } else if (d.kind === 'mast') { + box(P.twFrame, x, yb + .7, z, .08, 1.4, .08); + for (let k = 0; k < 3; k++) box(P.twBrass, x, yb + .35 + k * .35, z, .26 - k * .06, .03, .26 - k * .06); + const lampAt = toWorld(v3, x, yb + 1.5, z).clone(); + put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.13)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + m.lit * .85)); + if (m.lit > .05 && glows < 22) { + glowMesh.setMatrixAt(glows, m4.compose(lampAt, camera.quaternion, s3.setScalar(.8 + m.lit * .5))); + glowMesh.setColorAt(glows++, glowColor.set(0xd8c4ec).multiplyScalar(m.lit * .7)); + glowMesh.setMatrixAt(glows, m4.compose(v1.set(m.x, surfaceAt(world, m.x, m.z) + .03, m.z), q.setFromAxisAngle(v2.set(1, 0, 0), -Math.PI / 2), s3.setScalar(4 * m.lit))); + glowMesh.setColorAt(glows++, glowColor.set(0xb9a6d8).multiplyScalar(m.lit * .35)); + } + } } + return glows; } function drawObjects() { for (const o of world.objects) { @@ -978,7 +1029,6 @@ async function initialize() { for (const c of world.creatures) DRAW[c.sp](c); drawObjects(); drawMachines(); - drawReefs(); const focus = creatureById(world, selected); drawContacts(); drawBlooms(); @@ -1090,7 +1140,6 @@ async function initialize() { case 'crack': return `${who} cracked ${e.other}'s shell.`; case 'arrive': return `${who} arrived from the sea.`; case 'debut': return `A${e.sp === 'artificer' ? 'n' : ''} ${SPECIES[e.sp].name.toLowerCase()} has arrived. ${DEBUT[e.sp]}`; - case 'reef': return `${who} settled into the heart of a reef.`; case 'plan': return `${who} began a ${MACHINES[e.detail].name}.`; case 'assemble': return `${who} fitted a part to a ${MACHINES[e.detail].name}.`; case 'complete': return `${who} finished a ${MACHINES[e.detail].name}.`; @@ -1182,7 +1231,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', 'debut', 'form', 'complete', 'wreck', 'plan', 'reef'].includes(e.type)); + const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form', 'complete', 'wreck', 'plan'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 3654bb4..702ce6e 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,12 +1,12 @@ const SPECIES = [ { id: "scraper", code: "SC", name: "Scraper", note: "Grazes the film on wet floors. Hides in shells, hollows, and walled homes." }, - { id: "tab", code: "TB", name: "Tab", note: "Schools in open water. Feels for deeper water as the tide drains." }, - { id: "pylon", code: "PY", name: "Pylon", note: "Stands still, filters the water, and catches tabs. A mature pylon in deep water settles into the heart of a reef." }, + { id: "tab", code: "TB", name: "Tab", note: "Schools in open water and makes its home circling a tower. Feels for deeper water as the tide drains." }, + { id: "pylon", code: "PY", name: "Pylon", note: "Stands still, filters the water, and catches tabs. Buds settle at the foot of towers." }, { id: "collector", code: "CL", name: "Collector", note: "Salvages husks and hoards brass in a corner." }, { id: "mason", code: "MS", name: "Mason", note: "Fills. Lifts spoil and stones and builds walled homes, filling in the hollow beneath first." }, { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers and breaks walls down to reach them." }, { id: "borer", code: "BO", name: "Borer", note: "Digs. Sinks a burrow, then tunnels outward toward other water and banks the spoil beside it." }, - { id: "artificer", code: "AR", name: "Artificer", note: "Builds machines from brass and salvage: sluice gates that hold pools at low tide, tide wheels turned by the current, and beacons they power at night." }, + { id: "artificer", code: "AR", name: "Artificer", note: "Builds machines from brass and salvage: lattice towers in deep water, sluice gates that hold pools at low tide, tide wheels turned by the current, and beacons they power at night." }, ]; const TOOLS = [ @@ -128,7 +128,7 @@ export function TidePoolContent() {
Inside the pool -

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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. Reefs grow segment by segment around a settled pylon, fed by the plankton in the water, and the tabs make their homes circling them. Artificers build working machines on top: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

+

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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 raise towers in deep water, one module at a time to a design of their own: lattice frames, decks, cantilevered arms, turning gears, and a lamp mast that burns at night. Every tower is different, and the tabs make their homes circling them. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

    {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}
@@ -141,7 +141,7 @@ export function TidePoolContent() {
{/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From a6f1be728f35177bacc1be798bd466fd88ea8282 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 23 Sep 2026 15:35:46 -0700 Subject: [PATCH 10/28] Build structures from small composable parts chosen by local rules instead of a drawn design, shed unsupported parts as scrap, and move the tide wheel onto structures as a part that powers their lights and beacons. --- public/tide-pool-world.js | 369 +++++++++++++++++++++++++++-------- public/tide-pool.js | 260 +++++++++++++++--------- scripts/tide-pool.test.mjs | 10 + src/components/tide-pool.tsx | 10 +- 4 files changed, 470 insertions(+), 179 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 3eaadbe..5345f9f 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -22,7 +22,7 @@ export const SPECIES = { 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 }, - artificer: { code: 'AR', name: 'Artificer', kind: 'walker', speed: .6, max: 4, start: 0, debut: 260, min: 1, burn: .004, radius: .36, core: true }, + artificer: { code: 'AR', name: 'Artificer', kind: 'walker', speed: .6, max: 4, start: 0, debut: 260, min: 2, burn: .004, radius: .36, core: true }, }; export const SPECIES_ORDER = Object.keys(SPECIES); export const MATERIALS = ['brass', 'shell', 'pebble', 'feed']; @@ -337,7 +337,7 @@ export function createWorld(seed = 41) { 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), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), - machines: [], nextMachine: 0, + machines: [], nextMachine: 0, blocks: new Map(), blockVersion: 0, structures: [], nextSupport: 0, gearTurn: 0, 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])), @@ -530,9 +530,9 @@ function breed(world, c) { // A pylon's bud sometimes drifts far, like a spore, and settles in other standing water. const far = S.kind === 'sessile' && world.random() < .45; // A pylon bud that drifts far prefers to settle at the foot of a tower. - const towers = far ? world.machines.filter(m => m.kind === 'tower' && m.have >= 4) : []; + const towers = far ? world.structures.filter(t => t.n >= 8) : []; if (towers.length && world.random() < .6) { - const t = towers[Math.floor(world.random() * towers.length)], a = world.random() * Math.PI * 2, r = t.reach * .5 + .4; + const t = towers[Math.floor(world.random() * towers.length)], a = world.random() * Math.PI * 2, r = t.reach + .3; const x = t.x + Math.cos(a) * r, z = t.z + Math.sin(a) * r; if (depthAt(world, x, z) > .25 && world.creatures.every(o => o.sp !== 'pylon' || distance(o, { x, z }) > .8)) { at = { x, z }; continue; } } @@ -754,7 +754,17 @@ 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 machines and set stone: a gate, a wheel, a wall, a dam. + // Idle breakers go at machines and set stone: a gate, a wheel, a wall, a dam, a structure's footing. + if (world.blocks.size && world.random() < .05) { + let best = null, score = Infinity; + for (const b of world.blocks.values()) { + const at = blockCenter(b), d = distance(at, c); + // They can only reach what stands near the ground. + if (d > 16 || blockBottom(b) > heightAt(world, at.x, at.z) + .6) continue; + if (d < score) { score = d; best = b; } + } + if (best) { const at = blockCenter(best); assign(world, c, 'unbolt', null, { at, patience: 30, reach: .7 }); c.task.block = best.key; return; } + } if (world.random() < .04) { const target = nearest(c, world.machines.filter(m => m.have >= m.need), 16); if (target) { assign(world, c, 'wreck', null, { at: { x: target.x, z: target.z }, patience: 30, reach: .7 }); c.task.machine = target.id; return; } @@ -773,80 +783,255 @@ function thinkBreaker(world, c) { wander(world, c); } -// Artificers build machines out of brass and salvage: sluice gates on pool outlets, tide wheels in the current, and beacons. +// Artificers build machines out of brass and salvage: sluice gates on pool outlets, and beacons powered by the tide wheels on their structures. export const MACHINES = { - tower: { name: 'tower', need: 0 }, gate: { name: 'sluice gate', need: 4 }, - wheel: { name: 'tide wheel', need: 3 }, beacon: { name: 'beacon', need: 3 }, }; const machineById = (world, id) => world.machines.find(m => m.id === id) || null; -export const MODULE = .75, LEVEL = .65; -// A tower's design, drawn up once when it is begun: lattice frames on a broad base that narrows as it rises, -// with decks, cantilevered platforms, gear housings, pipes, and a lamp mast. Built bottom-up, one module per load. -function designTower(world) { - const r = world.random, mods = []; - const levels = 5 + Math.floor(r() * 4); - let cells = [[0, 0], [1, 0], [0, 1], [1, 1]]; - if (r() < .5) cells.push([2, 0], [2, 1]); - for (let k = 0; k < levels; k++) { - if (k >= 2 && cells.length > 1 && r() < .45) { const drop = Math.floor(r() * cells.length); cells = cells.filter((_, n) => n !== drop); } - if (k >= levels - 2 && cells.length > 2) cells = cells.slice(0, 2); - for (const [i, j] of cells) mods.push({ kind: 'frame', i, j, k }); - if (k % 2 === 1) for (const [i, j] of cells) mods.push({ kind: 'deck', i, j, k }); - // A cantilevered arm with its own platform. - if (k >= 2 && r() < .5) { - const [i, j] = cells[Math.floor(r() * cells.length)], [di, dj] = [[1, 0], [-1, 0], [0, 1], [0, -1]][Math.floor(r() * 4)]; - if (!cells.some(([a, b]) => a === i + di && b === j + dj)) mods.push({ kind: 'arm', i: i + di, j: j + dj, k, from: [i, j] }, { kind: 'deck', i: i + di, j: j + dj, k }); +// Structures: artificers build out of small parts on a lattice of half-unit cells, one part at a time, choosing +// each by what is already around it. Frames bear load and must stand on ground or on other frames; decks span +// out from them and may overhang a few cells; pods, lamps, gears, and pipes fit onto what is there. No part is +// planned beyond the next one, so every structure grows its own shape, and one that loses its footing sheds what it can no longer hold. +export const BLOCK = CELL, BLOCK_Y0 = -2.5, BLOCK_LEVELS = 24, BLOCK_LIMIT = 900, SITES = 4, OVERHANG = 3; +export const BLOCK_KINDS = ['frame', 'deck', 'pod', 'gear', 'wheel', 'lamp', 'pipe']; +export const blockKey = (i, j, k) => (k * ROWS + j) * COLS + i; +export const blockCenter = b => ({ x: X0 + (b.i + .5) * CELL, z: Z0 + (b.j + .5) * CELL }); +export const blockBottom = b => BLOCK_Y0 + b.k * BLOCK; +const groundLevel = (world, i, j) => Math.floor((world.h[j * COLS + i] - BLOCK_Y0) / BLOCK); +const SIDES = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const MIX = { frame: .56, deck: .2, pod: .07, gear: .03, wheel: .04, lamp: .03, pipe: .07 }; +const bearing = b => !!b && (b.kind === 'frame' || b.kind === 'pipe'); +// How much strain a part of this kind at (i, j, k) would carry: 0 on the ground, one more per cell of overhang. Infinity if it cannot stand. +function strain(world, i, j, k, kind) { + const below = world.blocks.get(blockKey(i, j, k - 1)); + const grounded = k <= groundLevel(world, i, j); + if (kind === 'frame' || kind === 'pipe') { + if (grounded) return 0; + if (bearing(below)) return below.s; + if (kind === 'frame' && below?.kind === 'deck') return below.s + 1; + return Infinity; + } + if (kind === 'pod' || kind === 'lamp') return grounded ? 0 : below && below.kind !== 'gear' && below.kind !== 'wheel' && below.kind !== 'lamp' ? below.s : Infinity; + // Decks, gears, and wheels hang off the side of what is beside them. + let best = grounded ? 0 : bearing(below) || below?.kind === 'pod' ? below.s : Infinity; + for (const [di, dj] of SIDES) { + const n = world.blocks.get(blockKey(i + di, j + dj, k)); + if (!n) continue; + if (kind === 'gear' || kind === 'wheel' ? n.kind === 'frame' : n.kind === 'frame' || n.kind === 'deck') best = Math.min(best, n.s + 1); + } + return best; +} +function addBlock(world, i, j, k, kind, c, s) { + const key = blockKey(i, j, k); + const b = { key, i, j, k, kind, s, born: world.time, by: c ? c.id : -1, charge: 0, turn: 0 }; + world.blocks.set(key, b); + world.blockVersion++; + return b; +} +export function removeBlock(world, b, shed) { + world.blocks.delete(b.key); + world.blockVersion++; + if (shed) { + const at = blockCenter(b); + addObject(world, 'scrap', at.x + (world.random() - .5) * .6, at.z + (world.random() - .5) * .6, { height: .6 }); + } + bearLoads(world); +} +// Recompute every part's strain from the ground up; whatever can no longer stand falls as scrap. +function bearLoads(world) { + const order = [...world.blocks.values()].sort((a, b) => a.k - b.k); + for (const b of order) b.s = Infinity; + for (let pass = 0; pass < OVERHANG + 3; pass++) { + let changed = false; + for (const b of order) { + const s = strain(world, b.i, b.j, b.k, b.kind); + if (s < b.s) { b.s = s; changed = true; } + } + if (!changed) break; + } + const fallen = order.filter(b => !(b.s <= OVERHANG)); + if (!fallen.length) return 0; + for (const b of fallen) world.blocks.delete(b.key); + world.blockVersion++; + const at = blockCenter(fallen[0]); + for (let n = 0; n < Math.min(4, Math.ceil(fallen.length / 3)); n++) addObject(world, 'scrap', at.x + (world.random() - .5) * 2, at.z + (world.random() - .5) * 2, { height: .6 }); + if (fallen.length >= 3) event(world, 'collapse', null, fallen.length); + return fallen.length; +} +// Where an artificer builds: the structure it already works on, a neighbour's, or a new one in deep water. +function chooseSite(world, c) { + if (c.site && world.random() > .03 && (c.site.fresh || world.structures.some(t => distance(t, c.site) < t.reach + 2))) return c.site; + const open = world.structures.filter(t => t.n < 180 && distance(t, c) < 22); + if (open.length && (world.structures.length >= SITES || world.random() < .7)) { + const t = open[Math.floor(world.random() * open.length)]; + return (c.site = { x: t.x, z: t.z }); + } + if (world.structures.length >= SITES) return (c.site = null); + const clear = (x, z) => world.structures.every(t => distance(t, { x, z }) > t.reach + 6) && world.machines.every(m => distance(m, { x, z }) > 2.5); + for (const b of [...world.bodies].sort((a, b) => distance(c, a) - distance(c, b))) { + if (b.area < 6 || distance(c, b) > 24) continue; + for (let k = 0; k < 20; k++) { + const a = world.random() * Math.PI * 2, r = world.random() * 2.5; + const x = b.x + Math.cos(a) * r, z = b.z + Math.sin(a) * r; + if (depthAt(world, x, z) > .35 && clear(x, z)) return (c.site = { x, z, fresh: true }); + } + } + for (let k = 0; k < 40; k++) { + const a = world.random() * Math.PI * 2, r = 2 + world.random() * 12; + const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; + if (onShelf(world, x, z) && z < 10 && heightAt(world, x, z) < TIDE_HIGH - .5 && clear(x, z)) return (c.site = { x, z, fresh: true }); + } + return (c.site = null); +} +// Choose and place the next part near where the artificer stands, by local rules alone. +function placeBlock(world, c) { + if (world.blocks.size >= BLOCK_LIMIT) return null; + const ci = Math.floor((c.x - X0) / CELL), cj = Math.floor((c.z - Z0) / CELL); + const near = []; + for (const b of world.blocks.values()) if (Math.abs(b.i - ci) <= 5 && Math.abs(b.j - cj) <= 5) near.push(b); + if (!near.length) { + // Found a structure: a first frame footed in the ground here. + if (ci < 1 || cj < 1 || ci >= COLS - 1 || cj >= ROWS - 3) return null; + const k = groundLevel(world, ci, cj); + if (k < 0 || k >= BLOCK_LEVELS) return null; + if (c.site) c.site.fresh = false; + event(world, 'found', c); + return addBlock(world, ci, cj, k, 'frame', c, 0); + } + if (c.site) c.site.fresh = false; + const at = (i, j, k) => world.blocks.get(blockKey(i, j, k)); + const tally = { frame: 0, deck: 0, pod: 0, gear: 0, wheel: 0, lamp: 0, pipe: 0 }; + let top = 0; + for (const b of near) { tally[b.kind]++; top = Math.max(top, b.k); } + const high = Math.ceil((TIDE_HIGH - BLOCK_Y0) / BLOCK), low = Math.floor((TIDE_LOW - BLOCK_Y0) / BLOCK); + const seen = new Set(); + let best = null, score = -Infinity; + // Every open cell beside or above a part, and the foot of each neighbouring column, since the ground is uneven. + const options = []; + for (const b of near) for (const [di, dj, dk] of [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [1, 0, 'g'], [-1, 0, 'g'], [0, 1, 'g'], [0, -1, 'g']]) { + const i = b.i + di, j = b.j + dj; + if (i < 1 || j < 1 || i >= COLS - 1 || j >= ROWS - 3) continue; + const k = dk === 'g' ? groundLevel(world, i, j) : b.k + dk; + if (dk === 'g' && (b.k !== groundLevel(world, b.i, b.j) || Math.abs(k - b.k) > 2)) continue; + options.push([i, j, k]); + } + for (const [i, j, k] of options) { + if (k < 0 || k >= BLOCK_LEVELS) continue; + const key = blockKey(i, j, k); + if (seen.has(key) || world.blocks.has(key)) continue; + seen.add(key); + // Nothing sunk wholly into the ground. + const ground = groundLevel(world, i, j); + if (k < ground) continue; + const below = at(i, j, k - 1), above = at(i, j, k + 1); + let framesBeside = 0, decksBeside = 0; + for (const [a, e] of SIDES) { const n = at(i + a, j + e, k); if (n?.kind === 'frame') framesBeside++; else if (n?.kind === 'deck') decksBeside++; } + // The footprint of bearing parts one level down: structures taper as they climb. + let under = 0; + for (let a = -1; a <= 1; a++) for (let e = -1; e <= 1; e++) if (bearing(at(i + a, j + e, k - 1))) under++; + const dry = k >= high; + for (const kind of BLOCK_KINDS) { + const s = strain(world, i, j, k, kind); + if (!(s <= (kind === 'frame' ? 1 : OVERHANG))) continue; + let v; + if (kind === 'frame') { + // Spread a little along the ground, rise where the level beneath is broad, and climb up out of the water. + v = 1 + (k === ground ? .4 - tally.frame * .02 : under * .25 + (under >= 3 ? .5 : 0) - Math.max(0, k - high - 6) * .15) + (k < high + 2 ? .7 : 0) + framesBeside * .1 - s * .8; + } else if (kind === 'deck') { + // Decks reach out as balconies over the water, and seldom cap a column that could still rise. + v = (dry ? .8 : -.6) + (!below ? .5 - s * .2 : bearing(below) ? -.5 : 0) + decksBeside * .15; + } else if (kind === 'pod') { + // A small dwelling, above the tide, sheltered beside frames. + v = (dry ? .6 : -2) + framesBeside * .25 - tally.pod * .1 + (below?.kind === 'deck' ? .4 : 0) - SIDES.filter(([a, e]) => at(i + a, j + e, k)?.kind === 'pod').length * .35; + } else if (kind === 'lamp') { + v = k >= top && dry && !above && below?.kind !== 'lamp' ? .3 + (k - high) * .12 - tally.lamp * .7 : -3; + } else if (kind === 'wheel') { + // A tide wheel hangs off an outside frame, low enough for the current to turn it and charge it. + v = k >= low && k < high - 1 && framesBeside === 1 ? .6 - tally.wheel * .4 : -3; + } else if (kind === 'gear') { + // Gears go where the tide runs through. + v = k >= low && k < high && framesBeside ? .5 - tally.gear * .3 : -3; + } else { + v = framesBeside ? (below?.kind === 'pipe' ? .9 : .1) - tally.pipe * .12 : -3; + } + // A neighbourhood keeps a working mix: whatever is scarce nearby is wanted more. + v += (MIX[kind] - tally[kind] / near.length) * 4; + // The artificer favours what is within arm's reach, with a little whim. + v += world.random() * .9 - Math.hypot(i - ci, j - cj) * .1; + if (v > score) { score = v; best = { i, j, k, kind, s }; } } - if (r() < .4) { const [i, j] = cells[Math.floor(r() * cells.length)]; mods.push({ kind: 'gear', i, j, k, side: Math.floor(r() * 4) }); } - if (k < levels - 1 && r() < .35) { const [i, j] = cells[Math.floor(r() * cells.length)]; mods.push({ kind: 'pipe', i, j, k, corner: Math.floor(r() * 4) }); } } - const [ti, tj] = cells[0]; - mods.push({ kind: 'mast', i: ti, j: tj, k: levels }); - const span = Math.max(...mods.map(m => Math.max(Math.abs(m.i), Math.abs(m.j)))) + 1; - return { mods, levels, reach: span * MODULE }; + if (!best) return null; + return addBlock(world, best.i, best.j, best.k, best.kind, c, best.s); +} +// Group parts into structures for the creatures that live around them, and check their footing as the ground shifts. +function structuresStep(world, dt) { + const rate = Math.abs(Math.cos(world.time / TIDE_PERIOD * Math.PI * 2 + TIDE_PHASE)); + world.gearTurn += dt * (.3 + rate * 1.6); + // Tide wheels turn only while the water runs through them, and store what they turn as charge. + for (const b of world.blocks.values()) { + if (b.kind !== 'wheel') continue; + const k = b.j * COLS + b.i, depth = world.w[k] - blockBottom(b) - BLOCK / 2; + const flowing = world.seaDist[k] >= 0 ? rate * clamp(depth / .2 + .5, 0, 1) : 0; + b.turn += flowing * dt * 2.4; + b.charge = clamp(b.charge + flowing * dt * .02 - dt * .001, 0, 1); + } + if (world.time >= world.nextSupport) { + world.nextSupport = world.time + 4; + if (world.blocks.size) bearLoads(world); + } + if (world.structuresVersion === world.blockVersion) return; + world.structuresVersion = world.blockVersion; + const cells = new Map(); + for (const b of world.blocks.values()) { + const key = b.j * COLS + b.i, cell = cells.get(key); + if (cell) { cell.n++; cell.top = Math.max(cell.top, b.k); } else cells.set(key, { i: b.i, j: b.j, n: 1, top: b.k }); + } + const seen = new Set(), out = []; + for (const [key] of cells) { + if (seen.has(key)) continue; + const group = [], queue = [key]; + seen.add(key); + while (queue.length) { + const g = cells.get(queue.pop()); + group.push(g); + for (let a = -1; a <= 1; a++) for (let e = -1; e <= 1; e++) { + const n = (g.j + e) * COLS + g.i + a; + if (!seen.has(n) && cells.has(n)) { seen.add(n); queue.push(n); } + } + } + let n = 0, x = 0, z = 0, top = 0; + for (const g of group) { n += g.n; x += g.i * g.n; z += g.j * g.n; top = Math.max(top, g.top); } + x = X0 + (x / n + .5) * CELL; z = Z0 + (z / n + .5) * CELL; + let reach = 0; + for (const g of group) reach = Math.max(reach, Math.hypot(X0 + (g.i + .5) * CELL - x, Z0 + (g.j + .5) * CELL - z)); + out.push({ x, z, n, reach: reach + CELL / 2, top: BLOCK_Y0 + (top + 1) * BLOCK }); + } + world.structures = out; +} +// The nearest charged tide wheel within reach, if any, to power a light. +export function poweredBy(world, p, reach) { + let best = null, score = reach; + for (const b of world.blocks.values()) { + if (b.kind !== 'wheel' || b.charge < .03) continue; + const d = distance(blockCenter(b), p); + if (d < score) { score = d; best = b; } + } + return best; } -export const nearTower = (world, p) => { +export const nearStructure = (world, p) => { let best = Infinity; - for (const m of world.machines) if (m.kind === 'tower' && m.have >= 4) best = Math.min(best, distance(m, p)); + for (const t of world.structures) if (t.n >= 6) best = Math.min(best, Math.max(0, distance(t, p) - t.reach)); return best; }; function plan(world, c) { if (world.machines.length >= 14) return null; const near = (kind, at, r) => world.machines.some(m => m.kind === kind && distance(m, at) < r); - // A tower in the deep water of a pool that has none, often before anything else. - if (world.machines.filter(m => m.kind === 'tower').length < 3 && world.random() < .6) { - for (const b of [...world.bodies].sort((a, b) => distance(c, a) - distance(c, b))) { - if (b.area < 6 || near('tower', b, 8) || distance(c, b) > 24) continue; - for (let k = 0; k < 30; k++) { - const a = world.random() * Math.PI * 2, r = world.random() * 2.5; - const x = b.x + Math.cos(a) * r, z = b.z + Math.sin(a) * r; - if (depthAt(world, x, z) > .4 && !world.machines.some(o => distance(o, { x, z }) < 4)) return { kind: 'tower', x, z, ...designTower(world) }; - } - } - // Otherwise any low ground the high tide covers deeply, away from other towers. - for (let k = 0; k < 40; k++) { - const a = world.random() * Math.PI * 2, r = 2 + world.random() * 12; - const x = c.x + Math.cos(a) * r, z = c.z + Math.sin(a) * r; - if (onShelf(world, x, z) && z < 10 && heightAt(world, x, z) < TIDE_HIGH - .55 && !near('tower', { x, z }, 8) && - !world.machines.some(o => distance(o, { x, z }) < 4)) return { kind: 'tower', x, z, ...designTower(world) }; - } - } // A gate on the outlet of a pool that has none. for (const b of [...world.bodies].sort((a, b) => distance(c, a) - distance(c, b))) { if (b.outlet && !near('gate', b.outlet, 2) && distance(c, b.outlet) < 22) return { kind: 'gate', x: b.outlet.x, z: b.outlet.z, pool: b.name }; } - // A wheel in the tideway beside any machine that has no power. - for (const m of world.machines) { - if (m.kind === 'wheel' || near('wheel', m, 9)) continue; - for (let k = 0; k < 30; k++) { - const a = world.random() * Math.PI * 2, r = 1.5 + world.random() * 5; - const x = m.x + Math.cos(a) * r, z = m.z + Math.sin(a) * r; - const g = heightAt(world, x, z); - if (onShelf(world, x, z) && g > -.45 && g < .05 && !world.machines.some(o => distance(o, { x, z }) < 1.2)) return { kind: 'wheel', x, z }; - } - } // A beacon beside the artificer's home water. if (!near('beacon', c.home, 12)) { for (let k = 0; k < 30; k++) { @@ -863,6 +1048,16 @@ function thinkArtificer(world, c) { if (c.hungry) return graze(world, c, .8); const held = objectById(world, c.carrying); if (held?.kind === 'brass' && c.energy > .7 && canBreed(world, 'artificer', c)) { work(c, 3); c.task = { kind: 'breed' }; return; } + // Most of the time an artificer adds parts to a structure; now and then it turns to a working machine. + if (c.building === undefined || world.random() < .08) c.building = world.random() < .75; + if (c.building) { + const site = chooseSite(world, c); + if (site) { + if (held) return assign(world, c, 'erect', null, { at: site, patience: 50, reach: .7 }); + const part = nearest(c, looseOf(world, ['scrap', 'husk', 'brass', 'pebble']), 24); + if (part) return assign(world, c, 'fetch', part); + } + } let project = machineById(world, c.project); // Finish what is already standing before starting anything new. if (!project || project.have >= project.need) { @@ -872,13 +1067,13 @@ function thinkArtificer(world, c) { if (!project) { const next = plan(world, c); if (next) { - project = { id: world.nextMachine++, ...next, need: next.mods ? next.mods.length : MACHINES[next.kind].need, have: 0, charge: 0, spin: 0, lit: 0, shut: 0, owner: c.id, - angle: next.kind === 'tower' ? Math.floor(world.random() * 4) * Math.PI / 2 + .3 : world.random() * Math.PI, base: heightAt(world, next.x, next.z) }; + project = { id: world.nextMachine++, ...next, need: MACHINES[next.kind].need, have: 0, charge: 0, spin: 0, lit: 0, shut: 0, owner: c.id, angle: world.random() * Math.PI }; world.machines.push(project); c.project = project.id; event(world, 'plan', c, next.kind); } else project = null; } + if (held && !project) { const site = chooseSite(world, c); if (site) return assign(world, c, 'erect', null, { at: site, patience: 50, reach: .7 }); } if (held && project) return assign(world, c, 'assemble', null, { at: { x: project.x, z: project.z }, patience: 50, reach: .6 }); if (project) { const part = nearest(c, looseOf(world, ['scrap', 'husk', 'brass', 'pebble']), 24); @@ -901,6 +1096,7 @@ function setGate(world, m, height) { } // Finished machines work every step. function machinery(world, dt) { + structuresStep(world, dt); const rising = tideRising(world.time), rate = Math.abs(Math.cos(world.time / TIDE_PERIOD * Math.PI * 2 + TIDE_PHASE)); const night = daylight(world) < .45; for (const m of world.machines) { @@ -910,16 +1106,8 @@ function machinery(world, dt) { const want = done && !rising ? 1 : 0; m.shut += (want - m.shut) * Math.min(1, dt * .8); setGate(world, m, m.shut > .5 ? .42 : 0); - } else if (m.kind === 'wheel') { - const k = cellAt(m.x, m.z), flowing = done && world.seaDist[k] >= 0 ? rate * Math.min(1, (world.w[k] - world.h[k]) / .25) : 0; - m.spin += flowing * dt * 2.4; - m.charge = clamp(m.charge + flowing * dt * .02 - dt * .001, 0, 1); - } else if (m.kind === 'tower') { - // Gears turn once the tower is half built; the mast lamp burns at night once it is finished. - if (m.have >= m.need / 2) m.spin += dt * .7; - m.lit += ((done && night ? 1 : 0) - m.lit) * Math.min(1, dt * .5); } else if (m.kind === 'beacon') { - const wheel = done && world.machines.find(o => o.kind === 'wheel' && o.have >= o.need && o.charge > .03 && distance(o, m) < 9); + const wheel = done && poweredBy(world, m, 9); const want = wheel && night ? 1 : 0; m.lit += (want - m.lit) * Math.min(1, dt * .6); if (wheel && m.lit > .5) { @@ -1038,7 +1226,7 @@ function arrive(world, c) { case 'dig': return work(c, 30); case 'breed': return work(c, 3); case 'dismantle': case 'wreck': return work(c, 2.4); - case 'assemble': return work(c, 2.6); + case 'assemble': case 'erect': return work(c, 2.6); case 'hunt': case 'crack': case 'topple': { const prey = creatureById(world, t.prey); if (!prey || distance(prey, c) > c.reach + .35) return idle(world, c); @@ -1143,7 +1331,7 @@ function finish(world, c) { const held = objectById(world, c.carrying); const m = machineById(world, c.project); if (held && m && m.have < m.need) { - m.have = Math.min(m.need, m.have + (held.kind === 'brass' ? 2 : 1) * (m.kind === 'tower' ? 2 : 1)); + m.have = Math.min(m.need, m.have + (held.kind === 'brass' ? 2 : 1)); c.carrying = null; removeObject(world, held); c.gesture = .3; @@ -1151,6 +1339,28 @@ function finish(world, c) { } break; } + case 'erect': { + const held = objectById(world, c.carrying); + if (!held) break; + // Brass is worth four parts, anything else three. + let placed = 0; + for (let n = 0; n < (held.kind === 'brass' ? 4 : 3); n++) if (placeBlock(world, c)) placed++; + if (placed) { + c.carrying = null; removeObject(world, held); + c.gesture = .3; + event(world, 'erect', c, placed); + } + break; + } + case 'unbolt': { + const b = world.blocks.get(t.block); + if (b) { + c.strike = 1; + removeBlock(world, b, true); + event(world, 'unbolt', c, b.kind); + } + break; + } case 'wreck': { const m = machineById(world, t.machine); if (m) { @@ -1279,13 +1489,13 @@ function moveSwimmer(world, c, dt) { // Most of the school takes a tower as home once one stands: they find their way back to it from anywhere, // then circle it in a slow orbit, drifting in and out through its frames. let home = null, homeD = Infinity; - if (c.id % 4) for (const r of world.machines) { - if (r.kind !== 'tower' || r.have < 6) continue; + if (c.id % 4) for (const r of world.structures) { + if (r.n < 8) continue; const d = distance(r, c); if (d < homeD) { home = r; homeD = d; } } if (home && homeD > 1e-3) { - const dx = home.x - c.x, dz = home.z - c.z, d = homeD, ring = .6 + home.reach * .7; + const dx = home.x - c.x, dz = home.z - c.z, d = homeD, ring = .4 + home.reach * .8; if (d > 7) { ax += dx / d * 1.3; az += dz / d * 1.3; } else { const pull = (d - ring) * .9, swirl = 1.1 * (c.id % 2 ? 1 : -1); @@ -1448,7 +1658,7 @@ export function advanceWorld(world, elapsed) { if (c.timer <= 0) { c.timer = 1; // Fish sheltering inside a tower's frames are hard to reach. - const tab = world.creatures.find(t => t.sp === 'tab' && distance(t, c) < .45 + c.size * .1 && !(nearTower(world, t) < 1.4 && world.random() < .6)); + const tab = world.creatures.find(t => t.sp === 'tab' && distance(t, c) < .45 + c.size * .1 && !(nearStructure(world, t) < 1.4 && world.random() < .6)); if (tab && !dry && c.open > .6 && c.energy < .85 && world.random() < .16) { c.energy = Math.min(1, c.energy + .22); c.strike = 1; c.eaten++; die(world, tab, 'caught', label(c)); @@ -1555,11 +1765,14 @@ export function goalText(world, c) { 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 (c.sp === 'artificer' && t.kind === 'fetch' && c.building) return 'salvaging parts for a structure'; if (t.kind === 'assemble' || (c.sp === 'artificer' && t.kind === 'fetch')) { const m = machineById(world, c.project); if (m) return t.kind === 'assemble' ? `assembling a ${MACHINES[m.kind].name}` : `salvaging parts for a ${MACHINES[m.kind].name}`; } if (t.kind === 'wreck') return 'wrecking a machine'; + if (t.kind === 'erect') return 'adding parts to a structure'; + if (t.kind === 'unbolt') return 'tearing a part off a structure'; 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.js b/public/tide-pool.js index b866592..2cdfa9a 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, MODULE, LEVEL, -} from './tide-pool-world.js?v=18'; + heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, BLOCK, BLOCK_Y0, blockKey, +} from './tide-pool-world.js?v=21'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -456,12 +456,13 @@ async function initialize() { const dome = new T.SphereGeometry(1, 10, 5, 0, Math.PI * 2, 0, Math.PI / 2); const stone = new T.IcosahedronGeometry(1, 0); const parts = []; - function part(geometry, material, max, { shadow = true, colored = false } = {}) { + function part(geometry, material, max, { shadow = true, colored = false, fixed = false } = {}) { const mesh = new T.InstancedMesh(geometry, material, max); mesh.castShadow = shadow; mesh.receiveShadow = true; mesh.count = 0; mesh.frustumCulled = false; if (colored) mesh.setColorAt(0, tint.set(0xffffff)); scene.add(mesh); - const p = { mesh, n: 0, max, colored }; + // Fixed parts keep their instances between frames and are redrawn only when what they show changes. + const p = { mesh, n: 0, max, colored, fixed }; parts.push(p); return p; } @@ -493,12 +494,15 @@ async function initialize() { arSpool: part(hex, lit(0x3a3540), 6), arTool: part(unitBox, lit(0xd6b980, { metal: true }), 12), // Machines: posts and beams of dark bronze, brass fittings, a gate plate, wheel rims and paddles, and lamps. mcPost: part(unitBox, lit(0x4b4036, { metal: true, rough: .5 }), 120), mcBrass: part(unitBox, lit(0xb89a66, { metal: true }), 120), - mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 120), + mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 80), mcPaddle: part(unitBox, lit(0x6b5f55), 200), - // Towers: violet steel lattice, grated decks, and brass fittings. - twFrame: part(unitBox, lit(0x4a4252, { metal: true, rough: .5 }), 1600), twDeck: part(unitBox, lit(0x6a6072, { metal: true, rough: .45 }), 260), - twBrass: part(unitBox, lit(0xb89a66, { metal: true }), 500), - mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 160, { shadow: false, colored: true }), + // Structure parts: violet steel lattice, grated decks, panelled pods, and brass fittings. + bkFrame: part(unitBox, lit(0x4a4252, { metal: true, rough: .5 }), 9000, { fixed: true }), + bkDeck: part(unitBox, lit(0x6a6072, { metal: true, rough: .45 }), 4000, { fixed: true }), + bkPanel: part(unitBox, lit(0x7c7288, { rough: .6 }), 600, { fixed: true }), + bkBrass: part(unitBox, lit(0xb89a66, { metal: true }), 2500, { fixed: true }), + bkGear: part(unitBox, lit(0xb89a66, { metal: true }), 400), + mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 400, { shadow: false, colored: true }), // Objects and masonry. brass: part(hex, brass, 80), brassBoss: part(unitBox, lit(0xe0c48a, { metal: true }), 80), shell: part(dome, lit(0xd9ccb4, { rough: .55 }), 80), pebble: part(stone, lit(0x8a8f8c), 80), @@ -663,7 +667,7 @@ async function initialize() { box(P.eye, 0, .89, .1, .12, .022, .012, 0, 0, 0, eye(c)); box(P.arSpool, 0, .6, -.15, .07, .16, .07, 0, 0, Math.PI / 2); // Long jointed arms; while assembling, the tool hand works in small quick strokes. - const working = c.state === 'work' && c.task?.kind === 'assemble'; + const working = c.state === 'work' && (c.task?.kind === 'assemble' || c.task?.kind === 'erect'); for (const side of [-1, 1]) { const stroke = working ? Math.sin(c.age * 11 + side) * .06 : 0; toWorld(hip, side * .13, .74, .05); @@ -698,7 +702,6 @@ async function initialize() { let glows = 0; for (const m of world.machines) { const done = m.have >= m.need, grow = Math.max(.15, m.have / m.need); - if (m.kind === 'tower') { glows = drawTower(m, glows); continue; } const y = heightAt(world, m.x, m.z); setBase(m.x, y, m.z, m.angle); if (m.kind === 'gate') { @@ -714,24 +717,6 @@ async function initialize() { for (const side of [-1, 1]) box(P.mcBrass, side * .3, plateY + .2, .04, .06, .06, .03); box(P.mcBrass, 0, .99, 0, .14, .06, .14, 0, m.shut * 2); } - } else if (m.kind === 'wheel') { - for (const side of [-1, 1]) box(P.mcPost, side * .42, .3 * grow, 0, .08, .6 * grow, .08); - if (done) { - box(P.mcBrass, 0, .5, 0, .9, .05, .05); - // The wheel turns with the current about its axle, which runs across between the posts. - dummy.position.set(0, .5, 0); dummy.rotation.set(m.spin, 0, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); - hub.multiplyMatrices(base, dummy.matrix); - for (const off of [-.2, .2]) { - dummy.position.set(off, 0, 0); dummy.rotation.set(0, Math.PI / 2, 0); dummy.scale.set(.42, .42, .42); dummy.updateMatrix(); - put(P.mcRim, m4.multiplyMatrices(hub, dummy.matrix)); - } - for (let k = 0; k < 8; k++) { - const a = k / 8 * Math.PI * 2; - dummy.position.set(0, Math.cos(a) * .36, Math.sin(a) * .36); dummy.rotation.set(-a, 0, 0); dummy.scale.set(.42, .03, .16); dummy.updateMatrix(); - put(P.mcPaddle, m4.multiplyMatrices(hub, dummy.matrix)); - } - put(P.mcLamp, m4.compose(toWorld(v3, .42, .66, 0), q.identity(), s3.setScalar(.05)), lampColor.set(0x3a3540).lerp(lampWarm, m.charge)); - } } else if (m.kind === 'beacon') { box(P.mcPost, 0, 1.1 * grow, 0, .09, 2.2 * grow, .09); for (const side of [-1, 1]) box(P.mcPost, side * .2, .25 * grow, 0, .06, .5 * grow, .06, 0, 0, -side * .3); @@ -751,78 +736,156 @@ async function initialize() { } } } + glows = drawFittings(glows); glowMesh.count = glows; glowMesh.instanceMatrix.needsUpdate = true; if (glowMesh.instanceColor) glowMesh.instanceColor.needsUpdate = true; } - // A tower is drawn from its design, one module at a time up to however much has been built. - const BRACE = Math.hypot(MODULE, LEVEL), TILT = Math.atan2(MODULE, LEVEL); - function drawTower(m, glows) { - const floor = m.mods.filter(d => d.k === 0); - const ci = floor.reduce((a, d) => a + d.i, 0) / floor.length, cj = floor.reduce((a, d) => a + d.j, 0) / floor.length; - const y0 = Math.min(m.base ?? 0, heightAt(world, m.x, m.z)) - .05; - setBase(m.x, y0, m.z, m.angle); - const at = (i, j) => [(i - ci) * MODULE, (j - cj) * MODULE]; - const h = MODULE / 2, t = .06; - // A footing slab under the first level. - const [fx0, fz0] = at(Math.min(...floor.map(d => d.i)), Math.min(...floor.map(d => d.j))); - const [fx1, fz1] = at(Math.max(...floor.map(d => d.i)), Math.max(...floor.map(d => d.j))); - box(P.twDeck, (fx0 + fx1) / 2, .04, (fz0 + fz1) / 2, fx1 - fx0 + MODULE + .2, .12, fz1 - fz0 + MODULE + .2); - for (let n = 0; n < m.have && n < m.mods.length; n++) { - const d = m.mods[n], [x, z] = at(d.i, d.j), yb = d.k * LEVEL + .1, yt = yb + LEVEL; - if (d.kind === 'frame') { - for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) box(P.twFrame, x + sx * h, yb + LEVEL / 2, z + sz * h, t, LEVEL, t); - for (const s of [-1, 1]) { - box(P.twFrame, x, yt, z + s * h, MODULE, t * .8, t * .8); - box(P.twFrame, x + s * h, yt, z, t * .8, t * .8, MODULE); + // Structures are drawn part by part. Each part joins up with its neighbours: shared posts and beams are drawn + // once, bracing crosses only outside faces, and rails run only along open deck edges. + const H = BLOCK / 2, BRACE = BLOCK * Math.SQRT2, QUARTER = Math.PI / 4; + let drawnWorld = null, drawnVersion = -1, fittings = [], wheels = []; + function rebuildStructures() { + drawnWorld = world; drawnVersion = world.blockVersion; + for (const p of parts) if (p.fixed) p.n = 0; + const at = (i, j, k) => world.blocks.get(blockKey(i, j, k)); + const is = (b, ...kinds) => !!b && kinds.includes(b.kind); + fittings = []; wheels = []; + for (const b of world.blocks.values()) { + const x = X0 + (b.i + .5) * CELL, z = Z0 + (b.j + .5) * CELL, y = BLOCK_Y0 + b.k * BLOCK; + setBase(x, y, z, 0); + const side = (di, dj, k = b.k) => at(b.i + di, b.j + dj, k); + const below = at(b.i, b.j, b.k - 1), above = at(b.i, b.j, b.k + 1); + if (b.kind === 'frame') { + const t = .055; + // A post at each corner, drawn by the first frame, in a fixed order, among the four that share it. + for (const sx of [-1, 1]) for (const sz of [-1, 1]) { + const owners = [[0, 0], [sx, 0], [0, sz], [sx, sz]].map(([a, e]) => [b.i + a, b.j + e]).sort((p, q) => p[0] - q[0] || p[1] - q[1]); + const first = owners.find(([i, j]) => is(at(i, j, b.k), 'frame')); + if (first[0] === b.i && first[1] === b.j) box(P.bkFrame, sx * H, H, sz * H, t, BLOCK, t); + } + for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const n = side(di, dj), open = !is(n, 'frame'), mine = open || di + dj > 0; + const along = dj !== 0; + // Top beams, and bottom beams where nothing bears from below. + if (mine) { + box(P.bkFrame, di * H, BLOCK, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); + if (!is(below, 'frame', 'pipe')) box(P.bkFrame, di * H, 0, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); + } + // A brace across each outside face, turning with the level so the lattice zigzags. + if (open) { + const flip = (b.i + b.j + b.k) % 2 ? 1 : -1; + if (along) box(P.bkFrame, 0, H, dj * H, t * .55, BRACE, t * .55, 0, 0, flip * QUARTER); + else box(P.bkFrame, di * H, H, 0, t * .55, BRACE, t * .55, flip * QUARTER, 0, 0); + } + } + } else if (b.kind === 'deck') { + // Grated planks across the floor of the cell. + for (const o of [-.33, 0, .33]) box(P.bkDeck, 0, .02, o * BLOCK, BLOCK * .98, .03, BLOCK * .27); + box(P.bkDeck, 0, -.02, 0, BLOCK * .98, .03, .04); + for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const n = side(di, dj); + if (is(n, 'deck', 'frame', 'pod')) continue; + // A rail along each open edge, on posts at its ends. + const along = dj !== 0; + box(P.bkBrass, di * H * .94, .2, dj * H * .94, along ? BLOCK : .02, .02, along ? .02 : BLOCK); + for (const e of [-1, 1]) box(P.bkDeck, along ? e * H * .94 : di * H * .94, .1, along ? dj * H * .94 : e * H * .94, .025, .2, .025); } - // Cross-bracing on alternate faces, turning each level. - const flip = (d.i + d.j + d.k) % 2 ? 1 : -1; - box(P.twFrame, x + h, yb + LEVEL / 2, z, t * .6, BRACE, t * .6, flip * TILT, 0, 0); - box(P.twFrame, x - h, yb + LEVEL / 2, z, t * .6, BRACE, t * .6, -flip * TILT, 0, 0); - box(P.twFrame, x, yb + LEVEL / 2, z + h, t * .6, BRACE, t * .6, 0, 0, flip * TILT); - box(P.twFrame, x, yb + LEVEL / 2, z - h, t * .6, BRACE, t * .6, 0, 0, -flip * TILT); - } else if (d.kind === 'deck') { - for (const s of [-.3, 0, .3]) box(P.twDeck, x, yt + .03, z + s * MODULE, MODULE * .96, .03, MODULE * .24); - for (const s of [-1, 1]) box(P.twBrass, x + s * h * .96, yt + .09, z, .02, .09, MODULE * .9); - // A lit window in the deck house at night: somebody lives here. - if (m.lit > .05 && (d.i * 3 + d.j * 5 + d.k) % 3 === 0) - put(P.mcLamp, m4.compose(toWorld(v3, x, yt + .12, z), q.identity(), s3.setScalar(.04)), lampColor.set(0x3a3540).lerp(lampWarm, .3 + m.lit * .6)); - } else if (d.kind === 'arm') { - const [px, pz] = at(d.from[0], d.from[1]), mx = (x + px) / 2, mz = (z + pz) / 2, along = d.i !== d.from[0]; - for (const s of [-1, 1]) { - box(P.twFrame, along ? x : x + s * h, yt, along ? z + s * h : z, along ? MODULE : t * .8, t * .8, along ? t * .8 : MODULE); - // A strut under the cantilever back to the tower's face. - const ox = along ? 0 : s * h, oz = along ? s * h : 0; - box(P.twFrame, (mx + x) / 2 + ox, yt - LEVEL * .45, (mz + z) / 2 + oz, t * .6, BRACE * .9, t * .6, - along ? 0 : (d.j > d.from[1] ? -1 : 1) * TILT, 0, along ? (d.i > d.from[0] ? 1 : -1) * TILT : 0); + // Overhanging decks get a strut back to whatever holds them. + if (!below) for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + if (!is(side(di, dj), 'frame', 'deck')) continue; + const along = dj !== 0; + if (along) box(P.bkFrame, 0, -H * .5, dj * H * .5, .03, BRACE * .5, .03, dj * QUARTER, 0, 0); + else box(P.bkFrame, di * H * .5, -H * .5, 0, .03, BRACE * .5, .03, 0, 0, -di * QUARTER); + break; } - box(P.twFrame, x + (along ? (x - px) / 2 : 0) * .98, yt + LEVEL * .25, z + (along ? 0 : (z - pz) / 2) * .98, t, LEVEL * .5, t); - } else if (d.kind === 'gear') { - const a = d.side * Math.PI / 2, gx = x + Math.cos(a) * (h + .05), gz = z + Math.sin(a) * (h + .05); - dummy.position.set(gx, yb + LEVEL / 2, gz); dummy.rotation.set(0, -a + Math.PI / 2, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); + } else if (b.kind === 'pod') { + // A small panelled dwelling with a brass-trimmed roof and a round window. + box(P.bkPanel, 0, BLOCK * .42, 0, BLOCK * .82, BLOCK * .78, BLOCK * .82); + box(P.bkBrass, 0, BLOCK * .84, 0, BLOCK * .9, .035, BLOCK * .9); + box(P.bkDeck, 0, .02, 0, BLOCK * .96, .04, BLOCK * .96); + const open = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => !side(di, dj)) || [0, 1]; + fittings.push({ b, x, y, z, face: open }); + } else if (b.kind === 'pipe') { + const n = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => is(side(di, dj), 'frame')) || [0, 0]; + const px = n[0] * H * .7, pz = n[1] * H * .7; + box(P.bkBrass, px, H, pz, .07, BLOCK, .07); + if (!is(below, 'pipe')) box(P.bkBrass, px, .04, pz, .11, .05, .11); + if (!is(above, 'pipe')) box(P.bkBrass, px, BLOCK - .03, pz, .11, .05, .11); + if (n[0] || n[1]) box(P.bkBrass, px / 2, H, pz / 2, n[0] ? H * .7 : .03, .03, n[1] ? H * .7 : .03); + } else if (b.kind === 'lamp') { + box(P.bkFrame, 0, .18, 0, .045, .36, .045); + box(P.bkBrass, 0, .38, 0, .12, .025, .12); + fittings.push({ b, x, y, z }); + } else if (b.kind === 'wheel') { + // An axle out from the frame beside it, braced at both ends. + const n = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; + box(P.bkBrass, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H * 1.1 : .045, .045, n[1] ? H * 1.1 : .045); + for (const e of [-1, 1]) box(P.bkFrame, n[0] * H * .9 + (n[1] ? e * H * .8 : 0), H * .5, n[1] * H * .9 + (n[0] ? e * H * .8 : 0), .04, H * 1.1, .04); + fittings.push({ b, x, y, z, face: n }); + wheels.push({ b, x, z }); + } else if (b.kind === 'gear') { + const n = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; + box(P.bkFrame, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H : .04, .04, n[1] ? H : .04); + fittings.push({ b, x, y, z, face: n }); + } + } + for (const p of parts) if (p.fixed) { + p.mesh.count = p.n; + p.mesh.instanceMatrix.needsUpdate = true; + } + } + // Moving and lit fittings: gears turning with the tide, windows and lamps that burn at night. + function drawFittings(glows) { + if (drawnWorld !== world || drawnVersion !== world.blockVersion) rebuildStructures(); + const night = clamp((.45 - daylight(world)) / .2, 0, 1); + for (const f of fittings) { + setBase(f.x, f.y, f.z, 0); + // Windows and lamps burn only when a charged tide wheel nearby powers them. + if (f.power === undefined || world.step % 30 === 0) f.power = wheels.some(w => w.b.charge > .03 && Math.hypot(w.x - f.x, w.z - f.z) < 6) ? 1 : 0; + const lit = night * f.power; + const shine = lit * (.6 + .4 * Math.sin(world.time * .7 + f.b.key)); + if (f.b.kind === 'pod') { + box(P.mcLamp, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83, .07, .07, .07, 0, 0, 0, + lampColor.set(0x2a2530).lerp(lampWarm, shine * .9)); + } else if (f.b.kind === 'lamp') { + const lampAt = toWorld(v3, 0, .45, 0).clone(); + put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.08)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + lit * .85)); + if (lit > .05 && glows < 22) { + glowMesh.setMatrixAt(glows, m4.compose(lampAt, camera.quaternion, s3.setScalar(.7 + lit * .4))); + glowMesh.setColorAt(glows++, glowColor.set(0xd8c4ec).multiplyScalar(lit * .6)); + } + } else if (f.b.kind === 'wheel') { + // A paddle wheel turning about the axle; its charge shows in a small lamp at the hub. + const yaw = f.face[0] ? 0 : Math.PI / 2; + dummy.position.set(-f.face[0] * .06, H, -f.face[1] * .06); dummy.rotation.set(0, yaw, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); hub.multiplyMatrices(base, dummy.matrix); - for (const [r, spin] of [[.17, m.spin], [.09, -m.spin * 1.9]]) { - dummy.position.set(r === .17 ? 0 : .2, r === .17 ? 0 : .12, 0); dummy.rotation.set(0, 0, spin); dummy.scale.set(r, r, r * 1.6); dummy.updateMatrix(); + dummy.position.set(0, 0, 0); dummy.rotation.set(f.b.turn, 0, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); + hub.multiply(dummy.matrix); + for (const off of [-.1, .1]) { + dummy.position.set(off, 0, 0); dummy.rotation.set(0, Math.PI / 2, 0); dummy.scale.set(.3, .3, .3); dummy.updateMatrix(); put(P.mcRim, m4.multiplyMatrices(hub, dummy.matrix)); - for (let k = 0; k < 4; k++) { - dummy.rotation.set(0, 0, spin + k * Math.PI / 4); dummy.scale.set(r * 2, .02, .02); dummy.updateMatrix(); - put(P.twBrass, m4.multiplyMatrices(hub, dummy.matrix)); - } } - } else if (d.kind === 'pipe') { - const cx = d.corner & 1 ? h + .06 : -h - .06, cz = d.corner & 2 ? h + .06 : -h - .06; - box(P.twBrass, x + cx, yb + LEVEL, z + cz, .05, LEVEL * 2, .05); - box(P.twBrass, x + cx, yb + .05, z + cz, .08, .04, .08); - } else if (d.kind === 'mast') { - box(P.twFrame, x, yb + .7, z, .08, 1.4, .08); - for (let k = 0; k < 3; k++) box(P.twBrass, x, yb + .35 + k * .35, z, .26 - k * .06, .03, .26 - k * .06); - const lampAt = toWorld(v3, x, yb + 1.5, z).clone(); - put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.13)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + m.lit * .85)); - if (m.lit > .05 && glows < 22) { - glowMesh.setMatrixAt(glows, m4.compose(lampAt, camera.quaternion, s3.setScalar(.8 + m.lit * .5))); - glowMesh.setColorAt(glows++, glowColor.set(0xd8c4ec).multiplyScalar(m.lit * .7)); - glowMesh.setMatrixAt(glows, m4.compose(v1.set(m.x, surfaceAt(world, m.x, m.z) + .03, m.z), q.setFromAxisAngle(v2.set(1, 0, 0), -Math.PI / 2), s3.setScalar(4 * m.lit))); - glowMesh.setColorAt(glows++, glowColor.set(0xb9a6d8).multiplyScalar(m.lit * .35)); + for (let k = 0; k < 8; k++) { + const a = k / 8 * Math.PI * 2; + dummy.position.set(0, Math.cos(a) * .25, Math.sin(a) * .25); dummy.rotation.set(-a, 0, 0); dummy.scale.set(.22, .02, .11); dummy.updateMatrix(); + put(P.mcPaddle, m4.multiplyMatrices(hub, dummy.matrix)); + } + put(P.mcLamp, m4.compose(toWorld(v3, 0, H, 0), q.identity(), s3.setScalar(.04)), lampColor.set(0x3a3540).lerp(lampWarm, f.b.charge)); + } else if (f.b.kind === 'gear') { + // A toothed wheel turning in the plane of the frame beside it. + const spin = world.gearTurn * (f.b.key % 2 ? 1 : -1), yaw = f.face[0] ? Math.PI / 2 : 0; + dummy.position.set(-f.face[0] * .04, H, -f.face[1] * .04); dummy.rotation.set(0, yaw, 0); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); + hub.multiplyMatrices(base, dummy.matrix); + dummy.position.set(0, 0, 0); dummy.rotation.set(0, 0, spin); dummy.scale.set(.19, .19, .3); dummy.updateMatrix(); + put(P.mcRim, m4.multiplyMatrices(hub, dummy.matrix)); + for (let k = 0; k < 8; k++) { + const a = spin + k * Math.PI / 4; + dummy.position.set(Math.cos(a) * .2, Math.sin(a) * .2, 0); dummy.rotation.set(0, 0, a); dummy.scale.set(.06, .04, .04); dummy.updateMatrix(); + put(P.bkGear, m4.multiplyMatrices(hub, dummy.matrix)); + } + for (let k = 0; k < 2; k++) { + dummy.position.set(0, 0, 0); dummy.rotation.set(0, 0, spin + k * Math.PI / 2); dummy.scale.set(.36, .025, .025); dummy.updateMatrix(); + put(P.bkGear, m4.multiplyMatrices(hub, dummy.matrix)); } } } @@ -1025,7 +1088,7 @@ async function initialize() { fill.intensity = 1.6; - for (const p of parts) p.n = 0; + for (const p of parts) if (!p.fixed) p.n = 0; for (const c of world.creatures) DRAW[c.sp](c); drawObjects(); drawMachines(); @@ -1035,6 +1098,7 @@ async function initialize() { if (focus) drawCrop(focus); drawMarker(); for (const p of parts) { + if (p.fixed) continue; p.mesh.count = p.n; p.mesh.instanceMatrix.needsUpdate = true; if (p.colored && p.mesh.instanceColor) p.mesh.instanceColor.needsUpdate = true; @@ -1118,7 +1182,7 @@ async function initialize() { const DEBUT = { pylon: 'It filters the water, and folds shut when the water is disturbed.', borer: 'It digs.', - artificer: 'It builds machines: sluice gates, tide wheels, and beacons.', + artificer: 'It builds structures one part at a time, and machines: sluice gates and beacons.', collector: 'It hoards brass.', mason: 'It fills hollows and builds walls.', breaker: 'It hunts.', @@ -1144,6 +1208,10 @@ async function initialize() { case 'assemble': return `${who} fitted a part to a ${MACHINES[e.detail].name}.`; case 'complete': return `${who} finished a ${MACHINES[e.detail].name}.`; case 'wreck': return `${who} wrecked a ${MACHINES[e.detail].name}.`; + case 'found': return `${who} set the first frame of a new structure.`; + case 'erect': return `${who} fitted ${e.detail} part${e.detail === 1 ? '' : 's'} to a structure.`; + case 'unbolt': return `${who} tore a ${e.detail} off a structure.`; + case 'collapse': return `Part of a structure gave way: ${e.detail} pieces fell.`; case 'dam': return `${who} raised the dam on pool ${e.detail}.`; case 'form': return `Pool ${e.detail} has formed.`; case 'inspect': return `${who} inspected ${e.detail}.`; @@ -1231,7 +1299,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', 'debut', 'form', 'complete', 'wreck', 'plan'].includes(e.type)); + const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form', 'complete', 'wreck', 'plan', 'found', 'collapse'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index ac2dd41..626f5e5 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -204,3 +204,13 @@ test('a runaway shelf stays bounded: no ground below the digging floor and all w assert.ok(Math.min(...world.h.slice(0, 128 * 60)) > -1.3); assert.ok(TIDE_PERIOD > 0); }); + +test('artificers compose structures part by part, and every part stands on something', () => { + const world = createWorld(3); + run(world, 1500); + assert.ok(world.blocks.size > 20, `only ${world.blocks.size} parts`); + const kinds = new Set([...world.blocks.values()].map(b => b.kind)); + for (const kind of ['frame', 'deck']) assert.ok(kinds.has(kind), kind); + for (const b of world.blocks.values()) assert.ok(b.s <= 3, `${b.kind} at strain ${b.s}`); + assert.ok(world.structures.some(t => t.top > TIDE_HIGH + 1), 'a structure rises above high water'); +}); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 702ce6e..19d2ff2 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,12 +1,12 @@ const SPECIES = [ { id: "scraper", code: "SC", name: "Scraper", note: "Grazes the film on wet floors. Hides in shells, hollows, and walled homes." }, - { id: "tab", code: "TB", name: "Tab", note: "Schools in open water and makes its home circling a tower. Feels for deeper water as the tide drains." }, - { id: "pylon", code: "PY", name: "Pylon", note: "Stands still, filters the water, and catches tabs. Buds settle at the foot of towers." }, + { id: "tab", code: "TB", name: "Tab", note: "Schools in open water and makes its home circling a structure. Feels for deeper water as the tide drains." }, + { 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." }, { id: "mason", code: "MS", name: "Mason", note: "Fills. Lifts spoil and stones and builds walled homes, filling in the hollow beneath first." }, { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers and breaks walls down to reach them." }, { id: "borer", code: "BO", name: "Borer", note: "Digs. Sinks a burrow, then tunnels outward toward other water and banks the spoil beside it." }, - { id: "artificer", code: "AR", name: "Artificer", note: "Builds machines from brass and salvage: lattice towers in deep water, sluice gates that hold pools at low tide, tide wheels turned by the current, and beacons they power at night." }, + { id: "artificer", code: "AR", name: "Artificer", note: "Builds machines from brass and salvage: structures in deep water, one small part at a time, plus sluice gates that hold pools at low tide, tide wheels turned by the current, and beacons they power at night." }, ]; const TOOLS = [ @@ -128,7 +128,7 @@ export function TidePoolContent() {
Inside the pool -

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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 raise towers in deep water, one module at a time to a design of their own: lattice frames, decks, cantilevered arms, turning gears, and a lamp mast that burns at night. Every tower is different, and the tabs make their homes circling them. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

+

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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 deep water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, decks that reach out over the water, pods to live in, gears the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. Breakers tear parts off at the footing, and whatever can no longer stand falls. The tabs make their homes circling them. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

    {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}
@@ -141,7 +141,7 @@ export function TidePoolContent() {
{/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From f3fc5b2214c42613ee9579220b0ad2ff81c40875 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 23 Sep 2026 20:59:18 -0700 Subject: [PATCH 11/28] Let creatures live in and around the structures: scrapers sleep in pods, collectors hoard on decks, and tabs dart into the frames; parts drop into place, fall visibly when a footing gives way, and land as scrap; footings and plates are solid to the water and pin the ground beneath them; breakers loosen parts that artificers mend, and now actually tear them off. --- public/tide-pool-world.js | 346 +++++++++++++++++++++++++++++------ public/tide-pool.js | 300 +++++++++++++++++++----------- scripts/tide-pool.test.mjs | 54 ++++++ src/components/tide-pool.tsx | 14 +- 4 files changed, 537 insertions(+), 177 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 5345f9f..7ab6143 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -55,7 +55,9 @@ export function sample(field, x, z) { } export const heightAt = (world, x, z) => sample(world.h, x, z); export const surfaceAt = (world, x, z) => sample(world.w, x, z); -export const depthAt = (world, x, z) => Math.max(0, surfaceAt(world, x, z) - heightAt(world, x, z)); +export const depthAt = (world, x, z) => Math.max(0, surfaceAt(world, x, z) - heightAt(world, x, z) - sample(world.wall, x, z)); +// Where a body stands: on the ground, or up in a structure. +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); @@ -137,7 +139,7 @@ function spill(world) { } return [top, key]; }; - const gate = world.gate; + const gate = world.gate, wall = world.wall; for (let j = ROWS - SEA_ROWS; j < ROWS; j++) for (let i = 0; i < COLS; i++) { const k = j * COLS + i; S[k] = h[k]; push(k, h[k]); } while (heap.length) { const [k, key] = pop(); @@ -146,9 +148,9 @@ function spill(world) { 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; - // A shut gate stands in the water's way like ground. + // A shut gate or a wall of plates stands in the water's way like ground. // Rounded to the same 32-bit precision it is stored in, or a gate's added height could 'improve' a cell forever. - const n = nj * COLS + ni, v = Math.fround(Math.max(key, h[n] + gate[n])); + const n = nj * COLS + ni, v = Math.fround(Math.max(key, h[n] + Math.max(gate[n], wall[n]))); if (v < S[n]) { S[n] = v; push(n, v); } } } @@ -157,13 +159,15 @@ function spill(world) { // 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 } = world; + const { h, S, w, wall } = world; const tide = waterLevel(world); 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; } - w[k] = v < h[k] ? h[k] : v; + // 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; } } @@ -185,7 +189,7 @@ function slump(world) { if (ni < 0 || nj < 0 || ni >= COLS || nj >= ROWS) continue; const limit = di && dj ? TALUS * Math.SQRT2 : TALUS; const n = nj * COLS + ni, diff = h[k] - h[n]; - if (Math.abs(diff) <= limit + 1e-4) continue; + if (Math.abs(diff) <= limit + 1e-4 || world.pin[k] || world.pin[n]) continue; const move = (Math.abs(diff) - limit) / 2 * Math.sign(diff); h[k] -= move; h[n] += move; if (world.built[k] > 0 && move > 0) { const b = Math.min(world.built[k], move); world.built[k] -= b; world.built[n] += b; } @@ -215,7 +219,7 @@ function reshape(world, x, z, amount, radius = 1, builtShare = 0) { let moved = 0; for (const [k, weight] of cells) { let delta = amount * weight / total; - if (delta < 0) delta = Math.max(delta, -1.1 - world.h[k]); + if (delta < 0) delta = world.pin[k] ? 0 : Math.max(delta, -1.1 - world.h[k]); world.h[k] += delta; moved += delta; if (delta > 0 && builtShare) world.built[k] += delta * builtShare; if (delta < 0 && world.built[k] > 0) world.built[k] = Math.max(0, world.built[k] + delta); @@ -229,8 +233,11 @@ function reshape(world, x, z, amount, radius = 1, builtShare = 0) { // Bodies of water are connected wet cells. Named ones persist through the tide by overlap with where they last were. const ROMAN = [['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], ['C', 100], ['XC', 90], ['L', 50], ['XL', 40], ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1]]; const roman = n => ROMAN.reduce((s, [r, v]) => { while (n >= v) { s += r; n -= v; } return s; }, ''); +// Where water rests: the ground, or the top of a wall of plates. +const BED = new Float32Array(N); function survey(world) { - const { h, w } = world; + const { w, wall } = world, h = BED; + for (let k = 0; k < N; k++) h[k] = world.h[k] + wall[k]; const seen = new Int32Array(N).fill(-1); const parts = []; for (let s = 0; s < N; s++) { @@ -336,8 +343,8 @@ 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), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), - machines: [], nextMachine: 0, blocks: new Map(), blockVersion: 0, structures: [], nextSupport: 0, gearTurn: 0, + film: new Float32Array(N), plankton: 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: [], names: [], bodies: [], poolCount: 0, seaCells: 0, terrainVersion: 0, disturbed: new Set(), spillDirty: true, serials: Object.fromEntries(SPECIES_ORDER.map(s => [s, 0])), @@ -391,7 +398,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, born: world.time, + 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 === 'collector' || sp === 'mason' || sp === 'borer' || sp === 'artificer') c.home = { x, z }; @@ -438,7 +445,7 @@ export function offerObject(world, kind, x, z) { const object = dropObject(world, kind, x, z); if (!object) return null; const wants = kind === 'brass' ? ['collector', 'artificer', 'mason', 'breaker'] : kind === 'shell' ? ['scraper'] : ['mason']; - const free = world.creatures.filter(c => wants.includes(c.sp) && c.carrying === null && !(c.sp === 'scraper' && c.shell !== null)); + const free = world.creatures.filter(c => wants.includes(c.sp) && c.carrying === null && c.state !== 'fall' && !(c.sp === 'scraper' && c.shell !== null)); free.sort((a, b) => wants.indexOf(a.sp) - wants.indexOf(b.sp) || distance(a, object) - distance(b, object)); const c = free[0]; if (c) { @@ -484,7 +491,8 @@ function idle(world, c) { c.task = null; c.state = 'idle'; c.path = []; c.timer = .3 + world.random() * .6; c.stuck = 0; } function assign(world, c, kind, object, extra = {}) { - const at = extra.at || (object ? { x: object.x, z: object.z } : { x: c.x, z: c.z }); + // Something kept up in a structure is reached by climbing to it. + const at = extra.at || (object ? { x: object.x, z: object.z, ...(object.perch != null ? { y: object.alt, key: object.perch } : {}) } : { x: c.x, z: c.z }); c.task = { kind, object: object ? object.id : null, prey: extra.prey ?? null, use: extra.use ?? null, at, amount: extra.amount ?? 0 }; if (object && object.place !== 'carried') object.claimed = c.id; c.path = [{ x: at.x, z: at.z }]; @@ -505,7 +513,9 @@ function enclosed(world, x, z, rise = .16) { } return walls >= 6; } -function sheltered(world, c) { return c.shell !== null || enclosed(world, c.x, c.z); } +function sheltered(world, c) { return c.shell !== null || enclosed(world, c.x, c.z) || aloft(world, c); } +// Up in a structure, out of reach of anything that walks. +const aloft = (world, c) => c.alt != null && c.alt > heightAt(world, c.x, c.z) + .3; function nearest(c, list, limit = Infinity) { let best = null, score = limit; @@ -560,15 +570,18 @@ function die(world, c, cause, by = null) { if (i < 0) return; world.creatures.splice(i, 1); for (const o of world.objects) if (o.claimed === c.id) o.claimed = null; + const lift = c.alt != null ? Math.max(0, c.alt - heightAt(world, c.x, c.z)) : 0; + const pod = c.pod != null ? world.blocks.get(c.pod) : null; + if (pod?.resident === c.id) pod.resident = null; const carried = objectById(world, c.carrying); - if (carried) { carried.place = 'loose'; carried.height = .2; } + if (carried) { carried.place = 'loose'; carried.height = .2 + lift; carried.alt = null; } const shell = objectById(world, c.shell); if (shell) { shell.place = 'loose'; shell.owner = null; shell.x = c.x; shell.z = c.z; } if (c.spoil > 0) reshape(world, c.x, c.z, c.spoil, .8); const S = SPECIES[c.sp]; if (cause !== 'caught') { - if (S.core) addObject(world, 'brass', c.x + .25, c.z, { height: .3 }); - addObject(world, S.core ? 'husk' : 'scrap', c.x, c.z, { height: .15 }); + if (S.core) addObject(world, 'brass', c.x + .25, c.z, { height: .3 + lift }); + addObject(world, S.core ? 'husk' : 'scrap', c.x, c.z, { height: .15 + lift }); } world.ended.push({ id: c.id, label: label(c), sp: c.sp, cause, by, time: world.time }); if (world.ended.length > 40) world.ended.shift(); @@ -607,8 +620,32 @@ function wander(world, c) { assign(world, c, 'wander', null, { at: onShelf(world, x, z) ? { x, z } : { x: c.x, z: c.z }, patience: 20 }); } +// A pod is a home: a scraper claims an empty one near where it grazes, climbs into it at night, and runs to it from breakers. +function podOf(world, c) { + const b = c.pod != null ? world.blocks.get(c.pod) : null; + if (b?.kind === 'pod' && b.resident === c.id) return b; + c.pod = null; + return null; +} +function claimPod(world, c) { + let best = null, score = 16; + for (const b of world.blocks.values()) { + if (b.kind !== 'pod' || world.time < b.born || (b.resident != null && creatureById(world, b.resident))) continue; + const d = distance(blockCenter(b), c); + if (d < score) { score = d; best = b; } + } + if (!best) return null; + best.resident = c.id; c.pod = best.key; + event(world, 'move-in', c); + return best; +} +const roostAt = b => ({ ...blockCenter(b), y: blockBottom(b) + .05, key: b.key }); function thinkScraper(world, c) { const threat = world.creatures.find(b => b.sp === 'breaker' && distance(b, c) < 2.6); + const pod = podOf(world, c) || (world.random() < .2 ? claimPod(world, c) : null); + if (threat && !sheltered(world, c) && pod && distance(blockCenter(pod), c) < 6) return assign(world, c, 'roost', null, { at: roostAt(pod), patience: 20 }); + // Nights are spent at home, once fed. + if (pod && daylight(world) < .4 && c.energy > .45) return assign(world, c, 'roost', null, { at: roostAt(pod), patience: 45 }); if (threat && !sheltered(world, c)) { // Look for a hollow or a walled home nearby; otherwise a loose shell; otherwise just back away. let refuge = null; @@ -640,8 +677,28 @@ function brassSource(world, c, allowSteal) { return hoarded ? { object: hoarded, steal: true } : null; } +// A dry deck no one else keeps a hoard on, near home: brass kept up there is out of the way of anything that will not climb. +function claimDeck(world, c) { + let best = null, score = 14; + const high = Math.ceil((TIDE_HIGH - BLOCK_Y0) / BLOCK); + for (const b of world.blocks.values()) { + if (b.kind !== 'deck' || b.k < high || world.time < b.born || world.blocks.has(blockKey(b.i, b.j, b.k + 1))) continue; + if (world.creatures.some(o => o !== c && o.home?.key === b.key)) continue; + const d = distance(blockCenter(b), c.home); + if (d < score) { score = d; best = b; } + } + return best; +} function thinkCollector(world, c) { - if (c.carrying !== null) return assign(world, c, 'deliver', null, { at: c.home, patience: 60 }); + // A home up on a deck that has gone is no home: back to the ground. + if (c.home?.key != null && !world.blocks.has(c.home.key)) c.home = { x: c.home.x, z: c.home.z }; + if (c.carrying !== null) { + if (c.home.key == null && world.random() < .5) { + const deck = claimDeck(world, c); + if (deck) { c.home = { ...blockCenter(deck), y: blockBottom(deck) + .04, key: deck.key }; event(world, 'nest', c); } + } + return assign(world, c, 'deliver', null, { at: c.home, patience: 60 }); + } if (c.energy < .6) { const food = nearest(c, looseOf(world, ['husk', 'scrap']), 30); if (food) return assign(world, c, 'eat', food); @@ -733,7 +790,24 @@ function thinkBreaker(world, c) { if (source) return assign(world, c, source.steal ? 'steal' : 'fetch', source.object, { use: 'breed' }); } if (c.energy < .7) { - const target = nearest(c, world.creatures.filter(s => s.sp === 'scraper' && s.shell === null), 28); + // Prey on the ground first; prey up in a pod only when starving, and then only a bout at a time. + const exposed = world.creatures.filter(s => s.sp === 'scraper' && s.shell === null); + const target = nearest(c, exposed.filter(s => !aloft(world, s)), 28); + const roosting = !target && c.energy < .45 && world.time > (c.siege ?? 0) && nearest(c, exposed, 20); + if (roosting) { + // Tear at the footing under its pod, so that the column comes down. + let best = null, score = Infinity; + for (const b of world.blocks.values()) { + const at = blockCenter(b); + if (blockBottom(b) > heightAt(world, at.x, at.z) + .6) continue; + const d = distance(at, roosting) + (b.i === Math.floor((roosting.x - X0) / CELL) && b.j === Math.floor((roosting.z - Z0) / CELL) ? -1 : 0); + if (d < score) { score = d; best = b; } + } + if (best && score < 3) { + c.siege = world.time + 30 + world.random() * 30; + assign(world, c, 'unbolt', null, { at: blockCenter(best), patience: 30, reach: .7 }); c.task.block = best.key; return; + } + } if (target) { // Prey inside a wall: break the wall down first. if (enclosed(world, target.x, target.z)) { @@ -755,7 +829,7 @@ function thinkBreaker(world, c) { } } // Idle breakers go at machines and set stone: a gate, a wheel, a wall, a dam, a structure's footing. - if (world.blocks.size && world.random() < .05) { + if (world.blocks.size && world.random() < .015) { let best = null, score = Infinity; for (const b of world.blocks.values()) { const at = blockCenter(b), d = distance(at, c); @@ -790,23 +864,26 @@ export const MACHINES = { }; const machineById = (world, id) => world.machines.find(m => m.id === id) || null; // Structures: artificers build out of small parts on a lattice of half-unit cells, one part at a time, choosing -// each by what is already around it. Frames bear load and must stand on ground or on other frames; decks span -// out from them and may overhang a few cells; pods, lamps, gears, and pipes fit onto what is there. No part is -// planned beyond the next one, so every structure grows its own shape, and one that loses its footing sheds what it can no longer hold. +// each by what is already around it. Frames bear load and must stand on ground or on other frames; plates are solid +// and hold water back like ground; decks span out from frames and may overhang a few cells; pods, lamps, gears, +// wheels, and pipes fit onto what is there. No part is planned beyond the next one, so every structure grows its own +// shape, and one that loses its footing sheds what it can no longer hold. export const BLOCK = CELL, BLOCK_Y0 = -2.5, BLOCK_LEVELS = 24, BLOCK_LIMIT = 900, SITES = 4, OVERHANG = 3; -export const BLOCK_KINDS = ['frame', 'deck', 'pod', 'gear', 'wheel', 'lamp', 'pipe']; +export const BLOCK_KINDS = ['frame', 'plate', 'deck', 'pod', 'gear', 'wheel', 'lamp', 'pipe']; +// Plates stop just under high water, so every high tide spills over a wall and refills what it holds. +const PLATE_TOP = Math.floor((TIDE_HIGH - .15 - BLOCK_Y0) / BLOCK) - 1; export const blockKey = (i, j, k) => (k * ROWS + j) * COLS + i; export const blockCenter = b => ({ x: X0 + (b.i + .5) * CELL, z: Z0 + (b.j + .5) * CELL }); export const blockBottom = b => BLOCK_Y0 + b.k * BLOCK; const groundLevel = (world, i, j) => Math.floor((world.h[j * COLS + i] - BLOCK_Y0) / BLOCK); const SIDES = [[1, 0], [-1, 0], [0, 1], [0, -1]]; -const MIX = { frame: .56, deck: .2, pod: .07, gear: .03, wheel: .04, lamp: .03, pipe: .07 }; -const bearing = b => !!b && (b.kind === 'frame' || b.kind === 'pipe'); +const MIX = { frame: .5, plate: .12, deck: .16, pod: .07, gear: .03, wheel: .04, lamp: .03, pipe: .05 }; +const bearing = b => !!b && (b.kind === 'frame' || b.kind === 'pipe' || b.kind === 'plate'); // How much strain a part of this kind at (i, j, k) would carry: 0 on the ground, one more per cell of overhang. Infinity if it cannot stand. function strain(world, i, j, k, kind) { const below = world.blocks.get(blockKey(i, j, k - 1)); const grounded = k <= groundLevel(world, i, j); - if (kind === 'frame' || kind === 'pipe') { + if (kind === 'frame' || kind === 'pipe' || kind === 'plate') { if (grounded) return 0; if (bearing(below)) return below.s; if (kind === 'frame' && below?.kind === 'deck') return below.s + 1; @@ -822,9 +899,10 @@ function strain(world, i, j, k, kind) { } return best; } -function addBlock(world, i, j, k, kind, c, s) { +function addBlock(world, i, j, k, kind, c, s, delay = 0) { const key = blockKey(i, j, k); - const b = { key, i, j, k, kind, s, born: world.time, by: c ? c.id : -1, charge: 0, turn: 0 }; + // Parts fitted together from one load go on one after another. + const b = { key, i, j, k, kind, s, born: world.time + delay, by: c ? c.id : -1, charge: 0, turn: 0, resident: null, wear: 0 }; world.blocks.set(key, b); world.blockVersion++; return b; @@ -832,12 +910,47 @@ function addBlock(world, i, j, k, kind, c, s) { export function removeBlock(world, b, shed) { world.blocks.delete(b.key); world.blockVersion++; - if (shed) { - const at = blockCenter(b); - addObject(world, 'scrap', at.x + (world.random() - .5) * .6, at.z + (world.random() - .5) * .6, { height: .6 }); - } + if (shed) drop(world, b, true, null); bearLoads(world); } +// A part that comes away falls: it tumbles off the structure, lands, and becomes scrap where it lands. +const GRAVITY = 6; +export function debrisAt(d, t) { + const s = clamp(t - d.born, 0, d.land - d.born); + return { x: d.x + d.vx * s, y: d.y + d.vy * s - GRAVITY / 2 * s * s, z: d.z + d.vz * s, turn: d.spin * s }; +} +function drop(world, b, scrap, from) { + if (world.debris.length >= 80) { world.debris.shift(); } + const at = blockCenter(b), y = blockBottom(b) + BLOCK / 2; + // Away from the middle of the structure, with a little whim. + const out = from ? Math.atan2(at.z - from.z, at.x - from.x) : world.random() * Math.PI * 2; + const a = out + (world.random() - .5) * 1.2, v = .3 + world.random() * .6; + const d = { kind: b.kind, i: b.i, j: b.j, k: b.k, x: at.x, y, z: at.z, vx: Math.cos(a) * v, vz: Math.sin(a) * v, vy: .4 + world.random() * .6, + spin: (world.random() - .5) * 6, born: world.time, land: world.time, scrap }; + // Solve for where it meets the ground or the water, twice over, since the ground under it changes as it drifts. + let t = .5; + for (let n = 0; n < 3; n++) { + const p = debrisAt({ ...d, land: world.time + 9 }, world.time + t); + // It lands on the ground, or ends its fall with a splash if it drops into water from above. + const ground = heightAt(world, p.x, p.z), surface = surfaceAt(world, p.x, p.z); + const floor = y > surface ? Math.max(ground, surface - .2) : ground; + const fall = y - floor; + t = (d.vy + Math.sqrt(Math.max(0, d.vy * d.vy + 2 * GRAVITY * fall))) / GRAVITY; + } + d.land = world.time + clamp(t, .2, 2.5); + world.debris.push(d); +} +function landDebris(world) { + const landed = world.debris.filter(d => d.land <= world.time); + if (!landed.length) return; + world.debris = world.debris.filter(d => d.land > world.time); + for (const d of landed) { + const p = debrisAt(d, d.land); + if (!onShelf(world, p.x, p.z)) continue; + if (d.scrap) addObject(world, 'scrap', p.x, p.z, { height: 0 }); + if (depthAt(world, p.x, p.z) > WET) ripple(world, p.x, p.z); + } +} // Recompute every part's strain from the ground up; whatever can no longer stand falls as scrap. function bearLoads(world) { const order = [...world.blocks.values()].sort((a, b) => a.k - b.k); @@ -854,8 +967,9 @@ function bearLoads(world) { if (!fallen.length) return 0; for (const b of fallen) world.blocks.delete(b.key); world.blockVersion++; - const at = blockCenter(fallen[0]); - for (let n = 0; n < Math.min(4, Math.ceil(fallen.length / 3)); n++) addObject(world, 'scrap', at.x + (world.random() - .5) * 2, at.z + (world.random() - .5) * 2, { height: .6 }); + // Everything that gave way falls, highest last; about one piece in three lands as usable scrap. + const middle = world.structures.reduce((best, t) => !best || distance(t, blockCenter(fallen[0])) < distance(best, blockCenter(fallen[0])) ? t : best, null); + fallen.forEach((b, n) => drop(world, b, n % 3 === 0 && n < 12, middle)); if (fallen.length >= 3) event(world, 'collapse', null, fallen.length); return fallen.length; } @@ -869,6 +983,11 @@ function chooseSite(world, c) { } if (world.structures.length >= SITES) return (c.site = null); const clear = (x, z) => world.structures.every(t => distance(t, { x, z }) > t.reach + 6) && world.machines.every(m => distance(m, { x, z }) > 2.5); + // Sometimes at the mouth of a pool, where the footing of a structure holds the water back like a dam. + if (world.random() < .45) { + const mouths = world.bodies.filter(b => b.outlet && distance(c, b.outlet) < 24 && clear(b.outlet.x, b.outlet.z)); + if (mouths.length) { const m = mouths[Math.floor(world.random() * mouths.length)]; return (c.site = { x: m.outlet.x, z: m.outlet.z, fresh: true }); } + } for (const b of [...world.bodies].sort((a, b) => distance(c, a) - distance(c, b))) { if (b.area < 6 || distance(c, b) > 24) continue; for (let k = 0; k < 20; k++) { @@ -885,7 +1004,7 @@ function chooseSite(world, c) { return (c.site = null); } // Choose and place the next part near where the artificer stands, by local rules alone. -function placeBlock(world, c) { +function placeBlock(world, c, delay = 0) { if (world.blocks.size >= BLOCK_LIMIT) return null; const ci = Math.floor((c.x - X0) / CELL), cj = Math.floor((c.z - Z0) / CELL); const near = []; @@ -897,11 +1016,11 @@ function placeBlock(world, c) { if (k < 0 || k >= BLOCK_LEVELS) return null; if (c.site) c.site.fresh = false; event(world, 'found', c); - return addBlock(world, ci, cj, k, 'frame', c, 0); + return addBlock(world, ci, cj, k, 'frame', c, 0, delay); } if (c.site) c.site.fresh = false; const at = (i, j, k) => world.blocks.get(blockKey(i, j, k)); - const tally = { frame: 0, deck: 0, pod: 0, gear: 0, wheel: 0, lamp: 0, pipe: 0 }; + const tally = { frame: 0, plate: 0, deck: 0, pod: 0, gear: 0, wheel: 0, lamp: 0, pipe: 0 }; let top = 0; for (const b of near) { tally[b.kind]++; top = Math.max(top, b.k); } const high = Math.ceil((TIDE_HIGH - BLOCK_Y0) / BLOCK), low = Math.floor((TIDE_LOW - BLOCK_Y0) / BLOCK); @@ -925,8 +1044,14 @@ function placeBlock(world, c) { const ground = groundLevel(world, i, j); if (k < ground) continue; const below = at(i, j, k - 1), above = at(i, j, k + 1); - let framesBeside = 0, decksBeside = 0; - for (const [a, e] of SIDES) { const n = at(i + a, j + e, k); if (n?.kind === 'frame') framesBeside++; else if (n?.kind === 'deck') decksBeside++; } + let framesBeside = 0, decksBeside = 0, platesBeside = 0; + for (const [a, e] of SIDES) { + const n = at(i + a, j + e, k); + if (n?.kind === 'frame') framesBeside++; else if (n?.kind === 'deck') decksBeside++; else if (n?.kind === 'plate') platesBeside++; + } + // Frames around this cell, at its level or one either side: what a wall here would close around. + let hug = 0; + for (let a = -1; a <= 1; a++) for (let e = -1; e <= 1; e++) for (let f = -1; f <= 1; f++) if ((a || e) && at(i + a, j + e, k + f)?.kind === 'frame') hug++; // The footprint of bearing parts one level down: structures taper as they climb. let under = 0; for (let a = -1; a <= 1; a++) for (let e = -1; e <= 1; e++) if (bearing(at(i + a, j + e, k - 1))) under++; @@ -937,7 +1062,13 @@ function placeBlock(world, c) { let v; if (kind === 'frame') { // Spread a little along the ground, rise where the level beneath is broad, and climb up out of the water. - v = 1 + (k === ground ? .4 - tally.frame * .02 : under * .25 + (under >= 3 ? .5 : 0) - Math.max(0, k - high - 6) * .15) + (k < high + 2 ? .7 : 0) + framesBeside * .1 - s * .8; + v = 1 + (k === ground ? .4 - tally.frame * .02 - platesBeside * 1.2 : under * .25 + (under >= 3 ? .5 : 0) - Math.max(0, k - high - 6) * .15) + (k < high + 2 ? .7 : 0) + framesBeside * .1 - s * .8; + } else if (kind === 'plate') { + // Plates make a solid footing at the waterline: they go in beside the frames, run on along a footing already + // begun, and stack up to just under high water. The water treats a footing like ground, so a structure + // on one stands clear of its pool at low tide, and one across a channel dams it. + v = k <= PLATE_TOP && hug && world.h[j * COLS + i] < TIDE_HIGH - .1 && framesBeside < 3 + ? .5 + Math.min(hug, 4) * .1 + platesBeside * .35 + (below?.kind === 'plate' ? .5 : 0) : -3; } else if (kind === 'deck') { // Decks reach out as balconies over the water, and seldom cap a column that could still rise. v = (dry ? .8 : -.6) + (!below ? .5 - s * .2 : bearing(below) ? -.5 : 0) + decksBeside * .15; @@ -963,7 +1094,7 @@ function placeBlock(world, c) { } } if (!best) return null; - return addBlock(world, best.i, best.j, best.k, best.kind, c, best.s); + return addBlock(world, best.i, best.j, best.k, best.kind, c, best.s, delay); } // Group parts into structures for the creatures that live around them, and check their footing as the ground shifts. function structuresStep(world, dt) { @@ -981,6 +1112,9 @@ function structuresStep(world, dt) { world.nextSupport = world.time + 4; if (world.blocks.size) bearLoads(world); } + if (world.debris.length) landDebris(world); + const walls = world.blockVersion + ':' + world.terrainVersion; + if (world.wallVersion !== walls) { world.wallVersion = walls; raiseWalls(world); } if (world.structuresVersion === world.blockVersion) return; world.structuresVersion = world.blockVersion; const cells = new Map(); @@ -1020,6 +1154,25 @@ export function poweredBy(world, p, reach) { } return best; } +// What stands on the ground is solid to the water: a frame's footing, or plates, and any plates stacked on them. +export const footing = (world, b) => (b.kind === 'frame' || b.kind === 'plate') && b.k <= groundLevel(world, b.i, b.j) && + !world.blocks.has(blockKey(b.i, b.j, b.k - 1)); +function raiseWalls(world) { + const { wall, h } = world, fresh = new Map(); + for (const b of world.blocks.values()) { + if (!footing(world, b)) continue; + let top = b.k; + while (world.blocks.get(blockKey(b.i, b.j, top + 1))?.kind === 'plate') top++; + const k = b.j * COLS + b.i; + fresh.set(k, Math.max(fresh.get(k) ?? 0, Math.fround(Math.max(0, BLOCK_Y0 + (top + 1) * BLOCK - h[k])))); + } + let changed = false; + for (const k of world.wallCells) { world.pin[k] = 0; if (!fresh.has(k) && wall[k]) { wall[k] = 0; changed = true; } } + // A footing holds the ground beneath it: nothing digs it out or slides it away. + for (const [k, v] of fresh) { world.pin[k] = 1; if (wall[k] !== v) { wall[k] = v; changed = true; } } + world.wallCells = [...fresh.keys()]; + if (changed) world.spillDirty = true; +} export const nearStructure = (world, p) => { let best = Infinity; for (const t of world.structures) if (t.n >= 6) best = Math.min(best, Math.max(0, distance(t, p) - t.reach)); @@ -1207,6 +1360,7 @@ function arrive(world, c) { switch (t.kind) { case 'graze': return work(c, 2.5 + world.random() * 2); case 'hide': return work(c, 4); + case 'roost': return work(c, 6 + world.random() * 4); case 'flee': case 'wander': return idle(world, c); case 'inspect': event(world, 'inspect', c, o?.kind); c.seen = [...(c.seen || []).slice(-10), t.object]; return work(c, 1.6); case 'eat': case 'wear': case 'fetch': case 'steal': @@ -1225,7 +1379,7 @@ function arrive(world, c) { case 'lift': return work(c, 1.8); case 'dig': return work(c, 30); case 'breed': return work(c, 3); - case 'dismantle': case 'wreck': return work(c, 2.4); + case 'dismantle': case 'wreck': case 'unbolt': return work(c, 2.4); case 'assemble': case 'erect': return work(c, 2.6); case 'hunt': case 'crack': case 'topple': { const prey = creatureById(world, t.prey); @@ -1273,7 +1427,7 @@ function finish(world, c) { if (t.kind === 'steal') event(world, 'steal', c, o.kind, label(creatureById(world, o.owner) || { sp: 'collector', serial: 0 })); else if (o.kind === 'brass') event(world, 'collect', c, 'brass'); else event(world, 'gather', c, o.kind); - o.place = 'carried'; o.owner = c.id; o.claimed = c.id; c.carrying = o.id; + o.place = 'carried'; o.owner = c.id; o.claimed = c.id; c.carrying = o.id; o.perch = null; c.task = null; c.state = 'idle'; c.timer = 0; return; } @@ -1283,7 +1437,11 @@ function finish(world, c) { if (held) { const k = hoardOf(world, c).length; held.place = 'hoard'; held.owner = c.id; held.claimed = null; held.height = 0; - held.x = c.home.x + (k % 3) * .3 - .3; held.z = c.home.z + Math.floor(k / 3) * .3; + if (c.home.key != null) { + // Kept up on the deck, packed into the cell. + held.x = c.home.x + (k % 3) * .13 - .13; held.z = c.home.z + (Math.floor(k / 3) % 3) * .13 - .13; + held.alt = c.home.y; held.perch = c.home.key; + } else { held.x = c.home.x + (k % 3) * .3 - .3; held.z = c.home.z + Math.floor(k / 3) * .3; held.alt = null; held.perch = null; } c.carrying = null; ripple(world, held.x, held.z); event(world, 'hoard', c, held.kind); @@ -1342,10 +1500,17 @@ function finish(world, c) { case 'erect': { const held = objectById(world, c.carrying); if (!held) break; - // Brass is worth four parts, anything else three. + // First it makes good anything worked loose nearby, then fits new parts: brass is worth four, anything else three. + const ci = Math.floor((c.x - X0) / CELL), cj = Math.floor((c.z - Z0) / CELL); + let mended = 0; + for (const b of world.blocks.values()) { + if (!b.wear || Math.abs(b.i - ci) > 5 || Math.abs(b.j - cj) > 5 || mended >= 2) continue; + b.wear = 0; mended++; + } + if (mended) { world.blockVersion++; event(world, 'repair', c, mended); } let placed = 0; - for (let n = 0; n < (held.kind === 'brass' ? 4 : 3); n++) if (placeBlock(world, c)) placed++; - if (placed) { + for (let n = mended; n < (held.kind === 'brass' ? 4 : 3); n++) if (placeBlock(world, c, placed * .45)) placed++; + if (placed || mended) { c.carrying = null; removeObject(world, held); c.gesture = .3; event(world, 'erect', c, placed); @@ -1356,8 +1521,11 @@ function finish(world, c) { const b = world.blocks.get(t.block); if (b) { c.strike = 1; - removeBlock(world, b, true); - event(world, 'unbolt', c, b.kind); + // Load-bearing parts take a few blows before they come away; until then they only work loose. + b.wear++; + world.blockVersion++; + if (!bearing(b) || b.wear >= 3) { removeBlock(world, b, true); event(world, 'unbolt', c, b.kind); } + else event(world, 'loosen', c, b.kind); } break; } @@ -1424,15 +1592,39 @@ function finish(world, c) { function canStep(world, c, x, z) { return onShelf(world, x, z) && heightAt(world, x, z) - heightAt(world, c.x, c.z) < CLIMB; } +// At the foot of a destination that is up in a structure, climb to it before arriving. +function reachGoal(world, c) { + const at = c.task?.at; + if (at?.y != null && Math.abs(footAt(world, c) - at.y) > .03) { c.state = 'climb'; return; } + arrive(world, c); +} +function climb(world, c, dt) { + const at = c.task?.at, ground = heightAt(world, c.x, c.z); + if (!at || at.y == null || (at.key != null && !world.blocks.has(at.key))) { + idle(world, c); + if (c.alt != null) { c.state = 'fall'; c.fallV = 0; } + return; + } + // Onto the column, then up the frames. + c.x += (at.x - c.x) * Math.min(1, dt * 4); c.z += (at.z - c.z) * Math.min(1, dt * 4); + const y = c.alt ?? ground; + const next = y + clamp(at.y - y, -dt * .9, dt * .7); + c.speed = .15; c.gesture = Math.sin(c.age * 9) * .2; + if (Math.abs(next - at.y) < .03) { + const up = at.y > ground + .05; + c.alt = up ? at.y : null; c.perch = up ? at.key ?? null : null; + arrive(world, c); + } else c.alt = next; +} function moveWalker(world, c, dt) { const S = SPECIES[c.sp]; const wp = c.path[0]; - if (!wp) { arrive(world, c); return; } + if (!wp) { reachGoal(world, c); return; } const d = distance(c, wp); const reach = c.path.length === 1 ? c.reach : .2; if (d <= reach) { c.path.shift(); - if (!c.path.length) arrive(world, c); + if (!c.path.length) reachGoal(world, c); return; } const urgent = ['flee', 'hide', 'hunt'].includes(c.task?.kind); @@ -1472,7 +1664,15 @@ function moveSwimmer(world, c, dt) { const dx = c.x - o.x, dz = c.z - o.z, d = Math.hypot(dx, dz); if (d < .9 && d > 1e-4) { ax += dx / d * (o.sp === 'pylon' ? .8 : 1.6); az += dz / d * (o.sp === 'pylon' ? .8 : 1.6); } } - for (const r of world.ripples) { + // Startled beside a structure, the school darts in among its frames and holds there a moment instead of scattering. + const refuge = c.id % 4 ? world.structures.find(t => t.n >= 8 && distance(t, c) < t.reach + 2.5) : null; + if (refuge && (world.ripples.some(r => world.time - r.born < .6 && distance(r, c) < 2.5) || + world.creatures.some(o => (o.sp === 'breaker' || o.sp === 'pylon' && o.strike > .5) && distance(o, c) < 1.6))) c.hiding = world.time + 2.5 + world.random(); + if (refuge && c.hiding > world.time) { + const dx = refuge.x - c.x, dz = refuge.z - c.z, d = Math.hypot(dx, dz) || 1, core = refuge.reach * .5; + const pull = d > core ? 3.5 : -.5; + ax += dx / d * pull + (world.random() - .5) * 1.5; az += dz / d * pull + (world.random() - .5) * 1.5; + } else for (const r of world.ripples) { 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; } } @@ -1620,6 +1820,9 @@ export function advanceWorld(world, elapsed) { environment(world, dt); for (const o of [...world.objects]) { o.age += dt; + if (o.perch != null && o.place !== 'carried' && !world.blocks.has(o.perch)) { + o.height = Math.max(o.height, o.alt - heightAt(world, o.x, o.z)); o.alt = null; o.perch = null; + } if (o.place !== 'carried' && o.place !== 'worn') o.height = Math.max(0, o.height - dt * 2.8); if (o.place === 'loose' && o.claimed === null) { if (o.kind === 'husk' && o.age > 110) { o.kind = 'scrap'; o.age = 0; } @@ -1633,7 +1836,8 @@ export function advanceWorld(world, elapsed) { c.age += dt; const depth = depthAt(world, c.x, c.z); const dry = depth < WET; - c.energy -= dt * S.burn * (1 + c.speed * .6) * (dry && S.kind === 'walker' ? .7 : 1) * (S.kind === 'sessile' ? .6 + c.size * .2 : 1); + c.energy -= dt * S.burn * (1 + c.speed * .6) * (dry && S.kind === 'walker' ? .7 : 1) * (S.kind === 'sessile' ? .6 + c.size * .2 : 1) * + (c.task?.kind === 'roost' && c.state === 'work' ? .45 : 1); if (S.kind !== 'walker') { c.dry = dry ? c.dry + dt : 0; if (dry) c.energy -= dt * (S.kind === 'swimmer' ? .05 : .012); @@ -1669,7 +1873,26 @@ export function advanceWorld(world, elapsed) { } c.gesture = Math.sin(c.phase * .5) * .2; } else { - if (c.state === 'idle' && c.timer <= 0) think(world, c); + // Up in a structure when what held it gives way: it falls. + if (c.alt != null && c.state !== 'fall' && c.perch != null && !world.blocks.has(c.perch)) { + idle(world, c); c.state = 'fall'; c.fallV = 0; c.perch = null; + } + if (c.state === 'fall') { + c.fallV += GRAVITY * dt; + c.alt -= c.fallV * dt; + const ground = heightAt(world, c.x, c.z); + if (c.alt <= ground) { + c.alt = null; c.perch = null; c.fallV = 0; c.energy -= .06; + if (depthAt(world, c.x, c.z) > WET) ripple(world, c.x, c.z); + event(world, 'fall', c); + idle(world, c); + } + } else if (c.state === 'climb') climb(world, c, dt); + else if (c.state === 'move' && c.alt != null && c.task?.at?.key !== c.perch) { + // Leaving for somewhere else: climb down first. + c.alt -= dt * .9; c.speed = .15; + if (c.alt <= heightAt(world, c.x, c.z) + .02) { c.alt = null; c.perch = null; } + } else if (c.state === 'idle' && c.timer <= 0) think(world, c); else if (c.state === 'notice') { const at = c.task?.at; if (at) { @@ -1708,6 +1931,7 @@ export function advanceWorld(world, elapsed) { for (const c of world.creatures) { const carried = objectById(world, c.carrying); if (carried) { + carried.alt = c.alt; carried.perch = null; const reach = c.sp === 'breaker' ? .55 : c.sp === 'mason' ? 0 : .42; carried.x = c.x + Math.sin(c.angle) * reach; carried.z = c.z + Math.cos(c.angle) * reach; @@ -1723,7 +1947,7 @@ export function advanceWorld(world, elapsed) { // Yield a little space rather than walking through another body. function separate(world, dt) { - const walkers = world.creatures.filter(c => SPECIES[c.sp].kind === 'walker'); + const walkers = world.creatures.filter(c => SPECIES[c.sp].kind === 'walker' && c.alt == null); for (let i = 0; i < walkers.length; i++) for (let j = i + 1; j < walkers.length; j++) { const a = walkers[i], b = walkers[j]; if (a.task?.prey === b.id || b.task?.prey === a.id) continue; @@ -1740,7 +1964,7 @@ function separate(world, dt) { } const TASK_TEXT = { - graze: 'grazing the film on the floor', hide: 'sheltering in a hollow', flee: 'backing away from a breaker', + graze: 'grazing the film on the floor', hide: 'sheltering in a hollow', roost: 'resting in its pod', flee: 'backing away from a breaker', wander: 'walking the shelf', inspect: 'inspecting a new arrival', eat: 'salvaging a husk', wear: 'moving into a shell', steal: 'lifting brass from another hoard', deliver: 'returning brass to its hoard', build: 'setting material into its wall', breed: 'building a new body', dismantle: 'breaking a wall down', crack: 'cracking a shell open', topple: 'toppling a pylon', @@ -1759,8 +1983,12 @@ export function goalText(world, c) { return c.strike > .2 ? 'closing on a catch' : c.open < .35 ? 'folded shut, waiting for still water' : c.open < .9 ? 'opening again' : 'filtering the water'; } const t = c.task; - if (!t) return c.carrying !== null ? 'deciding where to take its load' : 'pausing'; + if (c.state === 'fall') return 'falling'; + if (!t) return c.carrying !== null ? 'deciding where to take its load' : aloft(world, c) ? 'resting up in a structure' : 'pausing'; if (c.state === 'notice') return 'noticing something new in the water'; + if (c.state === 'fall') return 'falling'; + if (c.state === 'climb') return t.kind === 'roost' ? 'climbing up into its pod' : 'climbing a structure'; + if (c.state === 'move' && c.alt != null && t.at?.key !== c.perch) return 'climbing down'; 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'; diff --git a/public/tide-pool.js b/public/tide-pool.js index 2cdfa9a..966f684 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, -} from './tide-pool-world.js?v=21'; + heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, BLOCK, BLOCK_Y0, blockKey, footAt, debrisAt, footing, +} from './tide-pool-world.js?v=23'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -339,10 +339,11 @@ async function initialize() { const cellCount = COLS * ROWS, gridData = gridTexture.image.data; const toHalf = T.DataUtils.toHalfFloat; function uploadGrid() { - const { w, h, built, film } = world; + const { w, h, built, film, wall } = world; for (let k = 0; k < cellCount; k++) { const o = k * 4; - gridData[o] = toHalf(w[k]); gridData[o + 1] = toHalf(h[k]); gridData[o + 2] = toHalf(Math.min(1, built[k] * 3)); gridData[o + 3] = toHalf(film[k]); + // The water's floor is the ground, or the top of a wall of plates standing in it. + gridData[o] = toHalf(w[k]); gridData[o + 1] = toHalf(h[k] + wall[k]); gridData[o + 2] = toHalf(Math.min(1, built[k] * 3)); gridData[o + 3] = toHalf(film[k]); } gridTexture.needsUpdate = true; } @@ -363,9 +364,11 @@ async function initialize() { 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; + // 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; } data[k * 2] = toHalf(fx); data[k * 2 + 1] = toHalf(fz); } @@ -424,7 +427,8 @@ async function initialize() { bloomMesh.instanceMatrix.needsUpdate = true; bloomMesh.instanceColor.needsUpdate = true; } - const glowMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2), new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 24); + const GLOWS = 60; + const glowMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2), new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), GLOWS + 4); glowMesh.frustumCulled = false; glowMesh.renderOrder = 6; glowMesh.count = 0; glowMesh.setColorAt(0, new T.Color()); scene.add(glowMesh); @@ -468,6 +472,8 @@ async function initialize() { } const brass = lit(0xa88d5f, { metal: true }); const joints = lit(0xb89a66, { metal: true }); + const frameSteel = lit(0x4a4252, { metal: true, rough: .5 }), deckSteel = lit(0x6a6072, { metal: true, rough: .45 }); + const podPanel = lit(0x7c7288, { rough: .6 }), brassTrim = lit(0xb89a66, { metal: true }), plateSteel = lit(0x3e3747, { metal: true, rough: .55 }); const P = { // Scraper: a low ribbed slab on treads with a brass blade. scBody: part(unitBox, lit(0xb3aea6), 40), scCap: part(unitBox, lit(0x8e8a86), 40), scRib: part(unitBox, lit(0x3c3a40), 120), @@ -497,10 +503,12 @@ async function initialize() { mcPlate: part(unitBox, lit(0x5a5262, { metal: true, rough: .45 }), 20), mcRim: part(new T.TorusGeometry(1, .07, 6, 24), lit(0xa88d5f, { metal: true }), 80), mcPaddle: part(unitBox, lit(0x6b5f55), 200), // Structure parts: violet steel lattice, grated decks, panelled pods, and brass fittings. - bkFrame: part(unitBox, lit(0x4a4252, { metal: true, rough: .5 }), 9000, { fixed: true }), - bkDeck: part(unitBox, lit(0x6a6072, { metal: true, rough: .45 }), 4000, { fixed: true }), - bkPanel: part(unitBox, lit(0x7c7288, { rough: .6 }), 600, { fixed: true }), - bkBrass: part(unitBox, lit(0xb89a66, { metal: true }), 2500, { fixed: true }), + bkFrame: part(unitBox, frameSteel, 9000, { fixed: true }), bkDeck: part(unitBox, deckSteel, 4000, { fixed: true }), + bkPanel: part(unitBox, podPanel, 600, { fixed: true }), bkBrass: part(unitBox, brassTrim, 2500, { fixed: true }), + bkPlate: part(unitBox, plateSteel, 700, { fixed: true }), + // The same parts again for pieces in motion: dropping into place, or coming away and falling. + lvFrame: part(unitBox, frameSteel, 2000), lvDeck: part(unitBox, deckSteel, 600), lvPanel: part(unitBox, podPanel, 120), + lvBrass: part(unitBox, brassTrim, 500), lvPlate: part(unitBox, plateSteel, 120), bkGear: part(unitBox, lit(0xb89a66, { metal: true }), 400), mcLamp: part(new T.SphereGeometry(1, 12, 8), lamp, 400, { shadow: false, colored: true }), // Objects and masonry. @@ -561,7 +569,7 @@ async function initialize() { const DRAW = { scraper(c) { - const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .006 * c.speed; + const y = footAt(world, c) + Math.sin(c.phase * 2) * .006 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.scBody, 0, .1, 0, .32, .1, .46); box(P.scCap, 0, .18, -.03, .24, .07, .32, c.gesture * .2); @@ -606,7 +614,7 @@ async function initialize() { } }, collector(c) { - const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .01 * c.speed; + const y = footAt(world, c) + Math.sin(c.phase * 2) * .01 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.clBody, 0, .27, 0, .22, .13, .26, 0, Math.PI / 6); box(P.clFin, 0, .37, -.02, .02, .08, .28); @@ -624,7 +632,7 @@ async function initialize() { } }, mason(c) { - const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .008 * c.speed; + const y = footAt(world, c) + Math.sin(c.phase * 2) * .008 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.msBody, 0, .29, 0, .34, .2, .4); box(P.msTray, 0, .405, 0, .38, .03, .38); @@ -635,7 +643,7 @@ async function initialize() { } }, breaker(c) { - const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .012 * c.speed; + const y = footAt(world, c) + Math.sin(c.phase * 2) * .012 * c.speed; setBase(c.x, y, c.z, c.angle); box(P.brBody, 0, .35, 0, .44, .24, .6); box(P.brRidge, 0, .47, -.04, .2, .2, .48, 0, 0, Math.PI / 4); @@ -656,7 +664,7 @@ async function initialize() { }; DRAW.artificer = c => { - const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .01 * c.speed; + const y = footAt(world, c) + Math.sin(c.phase * 2) * .01 * c.speed; setBase(c.x, y, c.z, c.angle); for (const [sx, sz, o] of [[-1, -1, 0], [1, -1, Math.PI], [-1, 1, Math.PI], [1, 1, 0]]) { leg(c, sx * .1, .34, sz * .08, sx * .26, sz * .2, sx, o, { thick: .026, stride: .1, lift: .07, kneeUp: .14 }); @@ -667,7 +675,7 @@ async function initialize() { box(P.eye, 0, .89, .1, .12, .022, .012, 0, 0, 0, eye(c)); box(P.arSpool, 0, .6, -.15, .07, .16, .07, 0, 0, Math.PI / 2); // Long jointed arms; while assembling, the tool hand works in small quick strokes. - const working = c.state === 'work' && (c.task?.kind === 'assemble' || c.task?.kind === 'erect'); + const working = c.state === 'work' && (c.task?.kind === 'assemble' || c.task?.kind === 'erect') || c.state === 'climb'; for (const side of [-1, 1]) { const stroke = working ? Math.sin(c.age * 11 + side) * .06 : 0; toWorld(hip, side * .13, .74, .05); @@ -679,7 +687,7 @@ async function initialize() { } }; DRAW.borer = c => { - const y = heightAt(world, c.x, c.z) + Math.sin(c.phase * 2) * .006 * c.speed; + const y = footAt(world, c) + Math.sin(c.phase * 2) * .006 * c.speed; setBase(c.x, y, c.z, c.angle); for (const side of [-1, 1]) box(P.boTread, side * .16, .05, 0, .07, .09, .56); box(P.boBody, 0, .13, -.02, .28, .13, .5); @@ -725,7 +733,7 @@ async function initialize() { box(P.mcBrass, 0, 2.52, 0, .34, .03, .34); const lampAt = toWorld(v3, 0, 2.35, 0).clone(); put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.1)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + m.lit * .85)); - if (m.lit > .05 && glows < 22) { + if (m.lit > .05 && glows < GLOWS) { // A halo at the lamp, and a pool of light on the water below. glowMesh.setMatrixAt(glows, m4.compose(lampAt, camera.quaternion, s3.setScalar(.9 + m.lit * .5))); glowMesh.setColorAt(glows++, glowColor.set(0xd8c4ec).multiplyScalar(m.lit * .7)); @@ -742,91 +750,126 @@ async function initialize() { } // Structures are drawn part by part. Each part joins up with its neighbours: shared posts and beams are drawn // once, bracing crosses only outside faces, and rails run only along open deck edges. - const H = BLOCK / 2, BRACE = BLOCK * Math.SQRT2, QUARTER = Math.PI / 4; - let drawnWorld = null, drawnVersion = -1, fittings = [], wheels = []; + const H = BLOCK / 2, BRACE = BLOCK * Math.SQRT2, QUARTER = Math.PI / 4, DROP = .8, SPARK = .6; + const FIXED = { frame: P.bkFrame, deck: P.bkDeck, panel: P.bkPanel, brass: P.bkBrass, plate: P.bkPlate }; + const LIVE = { frame: P.lvFrame, deck: P.lvDeck, panel: P.lvPanel, brass: P.lvBrass, plate: P.lvPlate }; + const SIDES4 = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + let drawnWorld = null, drawnVersion = -1, settleAt = Infinity, fittings = [], wheels = [], arriving = []; + const drag = new Float32Array(cellCount).fill(1), swirl = new Float32Array(cellCount); + // One part, drawn about the current base. A piece that has come away is drawn whole, joined to nothing. + function drawPart(b, kit, alone, solid = false) { + const at = alone ? () => null : (i, j, k) => world.blocks.get(blockKey(i, j, k)); + const is = (n, ...kinds) => !!n && kinds.includes(n.kind); + const side = (di, dj, k = b.k) => at(b.i + di, b.j + dj, k); + const below = at(b.i, b.j, b.k - 1), above = at(b.i, b.j, b.k + 1); + if (b.kind === 'frame') { + const t = .055; + // Standing on the ground, the lowest frame is cast solid inside: a footing the water cannot pass. + if (solid) box(kit.plate, 0, H, 0, BLOCK * .88, BLOCK * .98, BLOCK * .88); + // A post at each corner, drawn by the first frame, in a fixed order, among the four that share it. + for (const sx of [-1, 1]) for (const sz of [-1, 1]) { + const owners = [[0, 0], [sx, 0], [0, sz], [sx, sz]].map(([a, e]) => [b.i + a, b.j + e]).sort((p, q) => p[0] - q[0] || p[1] - q[1]); + const first = alone ? [b.i, b.j] : owners.find(([i, j]) => is(at(i, j, b.k), 'frame')); + if (first[0] === b.i && first[1] === b.j) box(kit.frame, sx * H, H, sz * H, t, BLOCK, t); + } + for (const [di, dj] of SIDES4) { + const n = side(di, dj), open = !is(n, 'frame'), mine = open || di + dj > 0; + const along = dj !== 0; + // Top beams, and bottom beams where nothing bears from below. + if (mine) { + box(kit.frame, di * H, BLOCK, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); + if (!is(below, 'frame', 'pipe')) box(kit.frame, di * H, 0, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); + } + // A brace across each outside face, turning with the level so the lattice zigzags. + if (open) { + const flip = (b.i + b.j + b.k) % 2 ? 1 : -1; + if (along) box(kit.frame, 0, H, dj * H, t * .55, BRACE, t * .55, 0, 0, flip * QUARTER); + else box(kit.frame, di * H, H, 0, t * .55, BRACE, t * .55, flip * QUARTER, 0, 0); + } + } + } else if (b.kind === 'plate') { + // A solid bulkhead: dark plate with ribs on its outside faces and a brass cap on the top course. + box(kit.plate, 0, H, 0, BLOCK * .99, BLOCK * .99, BLOCK * .99); + for (const [di, dj] of SIDES4) { + if (is(side(di, dj), 'plate')) continue; + const along = dj !== 0; + for (const e of [-.5, .5]) box(kit.frame, along ? e * H : di * H * 1.02, H, along ? dj * H * 1.02 : e * H, .035, BLOCK * .96, .035); + } + if (!is(above, 'plate')) box(kit.brass, 0, BLOCK - .01, 0, BLOCK * 1.02, .03, BLOCK * 1.02); + } else if (b.kind === 'deck') { + // Grated planks across the floor of the cell. + for (const o of [-.33, 0, .33]) box(kit.deck, 0, .02, o * BLOCK, BLOCK * .98, .03, BLOCK * .27); + box(kit.deck, 0, -.02, 0, BLOCK * .98, .03, .04); + for (const [di, dj] of SIDES4) { + const n = side(di, dj); + if (is(n, 'deck', 'frame', 'pod')) continue; + // A rail along each open edge, on posts at its ends. + const along = dj !== 0; + box(kit.brass, di * H * .94, .2, dj * H * .94, along ? BLOCK : .02, .02, along ? .02 : BLOCK); + for (const e of [-1, 1]) box(kit.deck, along ? e * H * .94 : di * H * .94, .1, along ? dj * H * .94 : e * H * .94, .025, .2, .025); + } + // Overhanging decks get a strut back to whatever holds them. + if (!below) for (const [di, dj] of SIDES4) { + if (!is(side(di, dj), 'frame', 'deck')) continue; + const along = dj !== 0; + if (along) box(kit.frame, 0, -H * .5, dj * H * .5, .03, BRACE * .5, .03, dj * QUARTER, 0, 0); + else box(kit.frame, di * H * .5, -H * .5, 0, .03, BRACE * .5, .03, 0, 0, -di * QUARTER); + break; + } + } else if (b.kind === 'pod') { + // A small panelled dwelling with a brass-trimmed roof and a round window. + box(kit.panel, 0, BLOCK * .42, 0, BLOCK * .82, BLOCK * .78, BLOCK * .82); + box(kit.brass, 0, BLOCK * .84, 0, BLOCK * .9, .035, BLOCK * .9); + box(kit.deck, 0, .02, 0, BLOCK * .96, .04, BLOCK * .96); + return { face: SIDES4.find(([di, dj]) => !side(di, dj)) || [0, 1] }; + } else if (b.kind === 'pipe') { + const n = SIDES4.find(([di, dj]) => is(side(di, dj), 'frame')) || [0, 0]; + const px = n[0] * H * .7, pz = n[1] * H * .7; + box(kit.brass, px, H, pz, .07, BLOCK, .07); + if (!is(below, 'pipe')) box(kit.brass, px, .04, pz, .11, .05, .11); + if (!is(above, 'pipe')) box(kit.brass, px, BLOCK - .03, pz, .11, .05, .11); + if (n[0] || n[1]) box(kit.brass, px / 2, H, pz / 2, n[0] ? H * .7 : .03, .03, n[1] ? H * .7 : .03); + } else if (b.kind === 'lamp') { + box(kit.frame, 0, .18, 0, .045, .36, .045); + box(kit.brass, 0, .38, 0, .12, .025, .12); + return {}; + } else if (b.kind === 'wheel') { + // An axle out from the frame beside it, braced at both ends. + const n = SIDES4.find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; + box(kit.brass, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H * 1.1 : .045, .045, n[1] ? H * 1.1 : .045); + for (const e of [-1, 1]) box(kit.frame, n[0] * H * .9 + (n[1] ? e * H * .8 : 0), H * .5, n[1] * H * .9 + (n[0] ? e * H * .8 : 0), .04, H * 1.1, .04); + return { face: n }; + } else if (b.kind === 'gear') { + const n = SIDES4.find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; + box(kit.frame, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H : .04, .04, n[1] ? H : .04); + return { face: n }; + } + return null; + } + const partX = b => X0 + (b.i + .5) * CELL, partZ = b => Z0 + (b.j + .5) * CELL, partY = b => BLOCK_Y0 + b.k * BLOCK; + // Standing parts are drawn once and kept until something changes. Parts still dropping into place wait until they land. function rebuildStructures() { - drawnWorld = world; drawnVersion = world.blockVersion; + drawnWorld = world; drawnVersion = world.blockVersion; settleAt = Infinity; for (const p of parts) if (p.fixed) p.n = 0; - const at = (i, j, k) => world.blocks.get(blockKey(i, j, k)); - const is = (b, ...kinds) => !!b && kinds.includes(b.kind); - fittings = []; wheels = []; + fittings = []; wheels = []; arriving = []; + drag.fill(1); swirl.fill(0); for (const b of world.blocks.values()) { - const x = X0 + (b.i + .5) * CELL, z = Z0 + (b.j + .5) * CELL, y = BLOCK_Y0 + b.k * BLOCK; - setBase(x, y, z, 0); - const side = (di, dj, k = b.k) => at(b.i + di, b.j + dj, k); - const below = at(b.i, b.j, b.k - 1), above = at(b.i, b.j, b.k + 1); - if (b.kind === 'frame') { - const t = .055; - // A post at each corner, drawn by the first frame, in a fixed order, among the four that share it. - for (const sx of [-1, 1]) for (const sz of [-1, 1]) { - const owners = [[0, 0], [sx, 0], [0, sz], [sx, sz]].map(([a, e]) => [b.i + a, b.j + e]).sort((p, q) => p[0] - q[0] || p[1] - q[1]); - const first = owners.find(([i, j]) => is(at(i, j, b.k), 'frame')); - if (first[0] === b.i && first[1] === b.j) box(P.bkFrame, sx * H, H, sz * H, t, BLOCK, t); - } - for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const n = side(di, dj), open = !is(n, 'frame'), mine = open || di + dj > 0; - const along = dj !== 0; - // Top beams, and bottom beams where nothing bears from below. - if (mine) { - box(P.bkFrame, di * H, BLOCK, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); - if (!is(below, 'frame', 'pipe')) box(P.bkFrame, di * H, 0, dj * H, along ? BLOCK : t * .8, t * .8, along ? t * .8 : BLOCK); - } - // A brace across each outside face, turning with the level so the lattice zigzags. - if (open) { - const flip = (b.i + b.j + b.k) % 2 ? 1 : -1; - if (along) box(P.bkFrame, 0, H, dj * H, t * .55, BRACE, t * .55, 0, 0, flip * QUARTER); - else box(P.bkFrame, di * H, H, 0, t * .55, BRACE, t * .55, flip * QUARTER, 0, 0); - } - } - } else if (b.kind === 'deck') { - // Grated planks across the floor of the cell. - for (const o of [-.33, 0, .33]) box(P.bkDeck, 0, .02, o * BLOCK, BLOCK * .98, .03, BLOCK * .27); - box(P.bkDeck, 0, -.02, 0, BLOCK * .98, .03, .04); - for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const n = side(di, dj); - if (is(n, 'deck', 'frame', 'pod')) continue; - // A rail along each open edge, on posts at its ends. - const along = dj !== 0; - box(P.bkBrass, di * H * .94, .2, dj * H * .94, along ? BLOCK : .02, .02, along ? .02 : BLOCK); - for (const e of [-1, 1]) box(P.bkDeck, along ? e * H * .94 : di * H * .94, .1, along ? dj * H * .94 : e * H * .94, .025, .2, .025); - } - // Overhanging decks get a strut back to whatever holds them. - if (!below) for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - if (!is(side(di, dj), 'frame', 'deck')) continue; - const along = dj !== 0; - if (along) box(P.bkFrame, 0, -H * .5, dj * H * .5, .03, BRACE * .5, .03, dj * QUARTER, 0, 0); - else box(P.bkFrame, di * H * .5, -H * .5, 0, .03, BRACE * .5, .03, 0, 0, -di * QUARTER); - break; - } - } else if (b.kind === 'pod') { - // A small panelled dwelling with a brass-trimmed roof and a round window. - box(P.bkPanel, 0, BLOCK * .42, 0, BLOCK * .82, BLOCK * .78, BLOCK * .82); - box(P.bkBrass, 0, BLOCK * .84, 0, BLOCK * .9, .035, BLOCK * .9); - box(P.bkDeck, 0, .02, 0, BLOCK * .96, .04, BLOCK * .96); - const open = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => !side(di, dj)) || [0, 1]; - fittings.push({ b, x, y, z, face: open }); - } else if (b.kind === 'pipe') { - const n = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => is(side(di, dj), 'frame')) || [0, 0]; - const px = n[0] * H * .7, pz = n[1] * H * .7; - box(P.bkBrass, px, H, pz, .07, BLOCK, .07); - if (!is(below, 'pipe')) box(P.bkBrass, px, .04, pz, .11, .05, .11); - if (!is(above, 'pipe')) box(P.bkBrass, px, BLOCK - .03, pz, .11, .05, .11); - if (n[0] || n[1]) box(P.bkBrass, px / 2, H, pz / 2, n[0] ? H * .7 : .03, .03, n[1] ? H * .7 : .03); - } else if (b.kind === 'lamp') { - box(P.bkFrame, 0, .18, 0, .045, .36, .045); - box(P.bkBrass, 0, .38, 0, .12, .025, .12); - fittings.push({ b, x, y, z }); - } else if (b.kind === 'wheel') { - // An axle out from the frame beside it, braced at both ends. - const n = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; - box(P.bkBrass, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H * 1.1 : .045, .045, n[1] ? H * 1.1 : .045); - for (const e of [-1, 1]) box(P.bkFrame, n[0] * H * .9 + (n[1] ? e * H * .8 : 0), H * .5, n[1] * H * .9 + (n[0] ? e * H * .8 : 0), .04, H * 1.1, .04); - fittings.push({ b, x, y, z, face: n }); - wheels.push({ b, x, z }); - } else if (b.kind === 'gear') { - const n = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([di, dj]) => is(side(di, dj), 'frame')) || [1, 0]; - box(P.bkFrame, n[0] * H * .5, H, n[1] * H * .5, n[0] ? H : .04, .04, n[1] ? H : .04); - fittings.push({ b, x, y, z, face: n }); + if (world.time < b.born + DROP) { arriving.push(b); settleAt = Math.min(settleAt, b.born + DROP); continue; } + const x = partX(b), y = partY(b), z = partZ(b); + // A part worked loose by a breaker hangs a little askew until it is made good. + setBase(x, y - b.wear * .025, z, b.wear * .07); + const fitting = drawPart(b, FIXED, false, footing(world, b)); + if (fitting) fittings.push({ b, x, y, z, face: fitting.face }); + if (b.kind === 'wheel') wheels.push({ b, x, z }); + // The water slows among a structure's footings, stops at its plates, and eddies behind its wheels. + const k = b.j * COLS + b.i; + if (b.kind === 'plate') drag[k] = 0; + else if (b.kind === 'frame' && b.k * BLOCK + BLOCK_Y0 < world.w[k]) drag[k] *= .8; + if (b.kind === 'wheel') for (let dj = -3; dj <= 3; dj++) for (let di = -3; di <= 3; di++) { + const i = b.i + di, j = b.j + dj, d = Math.hypot(di, dj); + if (i < 0 || j < 0 || i >= COLS || j >= ROWS || d > 3) continue; + drag[j * COLS + i] *= .45 + .55 * d / 3; + swirl[j * COLS + i] += (1 - d / 3) * 1.1 * (b.key % 2 ? 1 : -1); } } for (const p of parts) if (p.fixed) { @@ -834,9 +877,37 @@ async function initialize() { p.mesh.instanceMatrix.needsUpdate = true; } } + // Parts going on drop into place from above and strike a small spark as they seat; parts coming away tumble and fall. + const tumble = new T.Euler(), pivot = new T.Matrix4().makeTranslation(0, -H, 0), sparkColor = new T.Color(0xf0d7a0); + function drawMoving(glows) { + for (const b of arriving) { + const age = world.time - b.born; + if (age < 0) continue; + const e = clamp(age / DROP, 0, 1), seat = 1 - (1 - e) ** 3; + setBase(partX(b), partY(b) + (1 - seat) * .9, partZ(b), (1 - seat) * .6, .75 + .25 * seat); + drawPart(b, LIVE, false); + } + for (const b of world.blocks.values()) { + const age = world.time - b.born - DROP * .8; + if (age < 0 || age > SPARK || glows >= GLOWS) continue; + const fade = 1 - age / SPARK; + glowMesh.setMatrixAt(glows, m4.compose(v1.set(partX(b), partY(b) + H, partZ(b)), camera.quaternion, s3.setScalar(.25 + age * .9))); + glowMesh.setColorAt(glows++, glowColor.copy(sparkColor).multiplyScalar(fade * fade * .9)); + } + for (const d of world.debris) { + if (world.time < d.born) continue; + const p = debrisAt(d, world.time); + tumble.set(p.turn, p.turn * .6, p.turn * .3); + m4.compose(v1.set(p.x, p.y, p.z), q.setFromEuler(tumble), s3.setScalar(1)); + base.multiplyMatrices(m4, pivot); + drawPart(d, LIVE, true); + } + return glows; + } // Moving and lit fittings: gears turning with the tide, windows and lamps that burn at night. function drawFittings(glows) { - if (drawnWorld !== world || drawnVersion !== world.blockVersion) rebuildStructures(); + if (drawnWorld !== world || drawnVersion !== world.blockVersion || world.time >= settleAt) rebuildStructures(); + glows = drawMoving(glows); const night = clamp((.45 - daylight(world)) / .2, 0, 1); for (const f of fittings) { setBase(f.x, f.y, f.z, 0); @@ -845,12 +916,14 @@ async function initialize() { const lit = night * f.power; const shine = lit * (.6 + .4 * Math.sin(world.time * .7 + f.b.key)); if (f.b.kind === 'pod') { + // Someone at home shows as a faint glow in the window, brighter when there is power. + const home = f.b.resident != null && creatureById(world, f.b.resident)?.perch === f.b.key ? .35 : 0; box(P.mcLamp, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83, .07, .07, .07, 0, 0, 0, - lampColor.set(0x2a2530).lerp(lampWarm, shine * .9)); + lampColor.set(0x2a2530).lerp(lampWarm, Math.max(shine * .9, home * (.5 + .5 * night)))); } else if (f.b.kind === 'lamp') { const lampAt = toWorld(v3, 0, .45, 0).clone(); put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.08)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + lit * .85)); - if (lit > .05 && glows < 22) { + if (lit > .05 && glows < GLOWS) { glowMesh.setMatrixAt(glows, m4.compose(lampAt, camera.quaternion, s3.setScalar(.7 + lit * .4))); glowMesh.setColorAt(glows++, glowColor.set(0xd8c4ec).multiplyScalar(lit * .6)); } @@ -895,7 +968,7 @@ async function initialize() { for (const o of world.objects) { const worn = o.place === 'worn'; const wearer = worn ? creatureById(world, o.owner) : null; - const y = (worn && wearer ? heightAt(world, wearer.x, wearer.z) + .2 : heightAt(world, o.x, o.z)) + (worn ? 0 : o.height); + const y = (worn && wearer ? footAt(world, wearer) + .2 : o.alt ?? heightAt(world, o.x, o.z)) + (worn ? 0 : o.height); setBase(o.x, y, o.z, o.rotation); if (o.kind === 'brass') { box(P.brass, 0, .03, 0, .07, .06, .07); box(P.brassBoss, 0, .07, 0, .04, .02, .04); } else if (o.kind === 'shell') worn ? box(P.shell, 0, 0, -.02, .2, .13, .26) : box(P.shell, 0, 0, 0, .14, .08, .12); @@ -913,8 +986,8 @@ async function initialize() { function drawContacts() { for (const c of world.creatures) { const S = SPECIES[c.sp]; - const floor = S.kind === 'swimmer' ? c.y : heightAt(world, c.x, c.z), surface = surfaceAt(world, c.x, c.z); - if (surface - heightAt(world, c.x, c.z) < .02) continue; + const floor = S.kind === 'swimmer' ? c.y : footAt(world, c), surface = surfaceAt(world, c.x, c.z); + if (surface - heightAt(world, c.x, c.z) < .02 || c.alt != null && c.alt > surface) continue; const top = c.sp === 'pylon' ? floor + (c.size + 1) * .15 * (.6 + .4 * (c.open ?? 1)) + .1 : floor + HEIGHT[c.sp]; const breaks = top > surface && floor < surface; if (S.kind === 'swimmer' && surface - c.y > .12) continue; @@ -934,7 +1007,7 @@ async function initialize() { function drawCrop(c) { // Registration marks around the followed machine, like a crop on a contact sheet. const r = (SPECIES[c.sp].radius || .2) + .18; - const y = (SPECIES[c.sp].kind === 'swimmer' ? c.y - .08 : heightAt(world, c.x, c.z)) + .02; + const y = (SPECIES[c.sp].kind === 'swimmer' ? c.y - .08 : footAt(world, c)) + .02; setBase(c.x, y, c.z, 0); const len = r * .45, t = .018; for (const sx of [-1, 1]) for (const sz of [-1, 1]) { @@ -1012,7 +1085,7 @@ async function initialize() { return hit; } function screenOf(c) { - const y = SPECIES[c.sp].kind === 'swimmer' ? c.y : heightAt(world, c.x, c.z) + .2; + const y = SPECIES[c.sp].kind === 'swimmer' ? c.y : footAt(world, c) + .2; v1.set(c.x, y, c.z).project(camera); const bounds = canvas.getBoundingClientRect(); return { x: (v1.x + 1) / 2 * bounds.width + bounds.left, y: (1 - v1.y) / 2 * bounds.height + bounds.top }; @@ -1212,6 +1285,11 @@ async function initialize() { case 'erect': return `${who} fitted ${e.detail} part${e.detail === 1 ? '' : 's'} to a structure.`; case 'unbolt': return `${who} tore a ${e.detail} off a structure.`; case 'collapse': return `Part of a structure gave way: ${e.detail} pieces fell.`; + case 'fall': return `${who} fell from a structure.`; + case 'move-in': return `${who} moved into a pod.`; + case 'nest': return `${who} moved its hoard up onto a deck.`; + case 'loosen': return `${who} worked a ${e.detail} loose.`; + case 'repair': return `${who} made good ${e.detail === 1 ? 'a loosened part' : e.detail + ' loosened parts'}.`; case 'dam': return `${who} raised the dam on pool ${e.detail}.`; case 'form': return `Pool ${e.detail} has formed.`; case 'inspect': return `${who} inspected ${e.detail}.`; @@ -1299,7 +1377,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', 'debut', 'form', 'complete', 'wreck', 'plan', 'found', 'collapse'].includes(e.type)); + const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form', 'complete', 'wreck', 'plan', 'found', 'collapse', 'fall'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index 626f5e5..fd463c6 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -3,8 +3,10 @@ import assert from 'node:assert/strict'; import { createWorld, advanceWorld, dropObject, offerObject, onShelf, heightAt, depthAt, surfaceAt, bodyAt, waterLevel, SPECIES, SPECIES_ORDER, LIMIT, TIDE_PERIOD, TIDE_LOW, TIDE_HIGH, START_POOL, BURROW, WET, describeWorld, goalText, + BLOCK, BLOCK_Y0, X0, Z0, CELL, blockKey, removeBlock, footAt, } from '../public/tide-pool-world.js'; +const cellX = i => X0 + (i + .5) * CELL, cellZ = j => Z0 + (j + .5) * CELL; function run(world, seconds) { for (let i = 0; i < seconds * 20; i++) advanceWorld(world, 1 / 20); } @@ -214,3 +216,55 @@ test('artificers compose structures part by part, and every part stands on somet for (const b of world.blocks.values()) assert.ok(b.s <= 3, `${b.kind} at strain ${b.s}`); assert.ok(world.structures.some(t => t.top > TIDE_HIGH + 1), 'a structure rises above high water'); }); + +// Stand a part directly, as an artificer would have. +function part(world, i, j, k, kind) { + const key = blockKey(i, j, k); + const b = { key, i, j, k, kind, s: 0, born: -10, by: -1, charge: 0, turn: 0, resident: null, wear: 0 }; + world.blocks.set(key, b); + world.blockVersion++; + return b; +} + +test('a scraper climbs into its pod, and falls when the column under it is knocked out; the falling parts land as scrap', () => { + const world = createWorld(); + run(world, 1); + const i = Math.floor((pool.x + 3 - X0) / CELL), j = Math.floor((pool.z - Z0) / CELL); + const ground = Math.floor((world.h[j * 128 + i] - BLOCK_Y0) / BLOCK); + const footing = part(world, i, j, ground, 'frame'); + for (let k = ground + 1; k < ground + 7; k++) part(world, i, j, k, 'frame'); + const pod = part(world, i, j, ground + 7, 'pod'); + const scraper = world.creatures.find(c => c.sp === 'scraper'); + pod.resident = scraper.id; scraper.pod = pod.key; + const at = { x: X0 + (i + .5) * CELL, z: Z0 + (j + .5) * CELL, y: BLOCK_Y0 + pod.k * BLOCK + .05, key: pod.key }; + Object.assign(scraper, { task: { kind: 'roost', object: null, prey: null, use: null, at, amount: 0 }, path: [{ x: at.x, z: at.z }], state: 'move', timer: 90, reach: .25 }); + for (let s = 0; s < 90 && scraper.perch !== pod.key; s++) run(world, 1); + assert.equal(scraper.perch, pod.key, 'it reached its pod'); + assert.ok(footAt(world, scraper) > heightAt(world, scraper.x, scraper.z) + 2, 'it is up in the structure'); + const scrap = world.objects.filter(o => o.kind === 'scrap').length; + removeBlock(world, footing, true); + assert.equal(world.blocks.size, 0, 'everything the footing held came down'); + assert.ok(world.debris.length > 0, 'the pieces are falling'); + run(world, 4); + assert.equal(scraper.alt, null, 'it landed'); + assert.ok(world.events.some(e => e.type === 'fall')); + assert.equal(world.debris.length, 0, 'every piece landed'); + assert.ok(world.objects.filter(o => o.kind === 'scrap').length > scrap, 'and some became scrap'); +}); + +test('plates standing in the water are solid to it, and hold the ground beneath them', () => { + const world = createWorld(); + run(world, 1); + const i = Math.floor((pool.x - X0) / CELL), j = Math.floor((pool.z - Z0) / CELL), k = j * 128 + i; + const ground = Math.floor((world.h[k] - BLOCK_Y0) / BLOCK); + part(world, i, j, ground, 'plate'); + part(world, i, j, ground + 1, 'plate'); + const top = BLOCK_Y0 + (ground + 2) * BLOCK; + run(world, 1); + assert.ok(Math.abs(world.h[k] + world.wall[k] - top) < 1e-4, 'the wall reaches the top plate'); + assert.ok(world.S[k] >= top - 1e-4, 'water must rise over it to pass'); + assert.ok(depthAt(world, cellX(i), cellZ(j)) < depthAt(world, cellX(i + 3), cellZ(j)), 'the water over it is shallower'); + const before = world.h[k]; + run(world, 1); + assert.equal(world.h[k], before, 'the ground under it stays put'); +}); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 19d2ff2..ab77ca4 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,12 +1,12 @@ const SPECIES = [ - { id: "scraper", code: "SC", name: "Scraper", note: "Grazes the film on wet floors. Hides in shells, hollows, and walled homes." }, - { id: "tab", code: "TB", name: "Tab", note: "Schools in open water and makes its home circling a structure. Feels for deeper water as the tide drains." }, + { 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 and makes its home circling a structure, darting in among its frames when startled. Feels for deeper water as the tide drains." }, { 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." }, + { 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." }, - { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers and breaks walls down to reach them." }, + { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers. Breaks walls down to reach them, and tears at a structure's footing to bring a pod down." }, { id: "borer", code: "BO", name: "Borer", note: "Digs. Sinks a burrow, then tunnels outward toward other water and banks the spoil beside it." }, - { id: "artificer", code: "AR", name: "Artificer", note: "Builds machines from brass and salvage: structures in deep water, one small part at a time, plus sluice gates that hold pools at low tide, tide wheels turned by the current, and beacons they power at night." }, + { id: "artificer", code: "AR", name: "Artificer", note: "Builds from brass and salvage: structures in the water, one small part at a time, and sluice gates and beacons. Mends parts that breakers work loose." }, ]; const TOOLS = [ @@ -128,7 +128,7 @@ export function TidePoolContent() {
Inside the pool -

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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 deep water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, decks that reach out over the water, pods to live in, gears the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. Breakers tear parts off at the footing, and whatever can no longer stand falls. The tabs make their homes circling them. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full; a tide wheel turns in the current and stores its charge; a beacon it powers burns at night, feeds the plankton around it, and draws the tabs. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

+

Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

    {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}
@@ -141,7 +141,7 @@ export function TidePoolContent() {
{/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From e4d1065226fae5f26860156c15177b965b030244 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 24 Sep 2026 10:48:16 -0700 Subject: [PATCH 12/28] Move the tide pool from Artifacts to Other at /tide-pool, redirecting the old /artifacts/tide-pool address. --- public/tide-pool-three-license.txt | 2 +- scripts/test-tide-pool-browser.mjs | 6 +++--- src/components/artifacts.tsx | 9 --------- src/components/other-index.tsx | 8 ++++++++ src/components/tide-pool.tsx | 2 +- src/index.tsx | 25 ++++++++++++++----------- src/tide-pool.test.tsx | 10 ++++++---- 7 files changed, 33 insertions(+), 29 deletions(-) diff --git a/public/tide-pool-three-license.txt b/public/tide-pool-three-license.txt index 2efd8d3..8d6c2a3 100644 --- a/public/tide-pool-three-license.txt +++ b/public/tide-pool-three-license.txt @@ -1,5 +1,5 @@ Three.js 0.180.0 — https://threejs.org/ -Used by /artifacts/tide-pool. Browser modules are served from the pinned npm package. +Used by /tide-pool. Browser modules are served from the pinned npm package. The MIT License diff --git a/scripts/test-tide-pool-browser.mjs b/scripts/test-tide-pool-browser.mjs index a742e6e..6aa8e15 100644 --- a/scripts/test-tide-pool-browser.mjs +++ b/scripts/test-tide-pool-browser.mjs @@ -19,7 +19,7 @@ try { const page = await context.newPage(); page.on('pageerror', e => errors.push(String(e))); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); - const response = await page.goto(`${base}/artifacts/tide-pool?immersive-review=2`, { waitUntil: 'networkidle' }); + const response = await page.goto(`${base}/tide-pool?immersive-review=2`, { waitUntil: 'networkidle' }); assert.equal(response.status(), 200); assert.equal(response.headers()['x-robots-tag'], undefined); await page.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled); @@ -102,7 +102,7 @@ try { await context.close(); const touch = await browser.newContext({ viewport: { width: 393, height: 852 }, isMobile: true, hasTouch: true, reducedMotion: 'reduce' }); const m = await touch.newPage(); - await m.goto(`${base}/artifacts/tide-pool`, { waitUntil: 'networkidle' }); + await m.goto(`${base}/tide-pool`, { waitUntil: 'networkidle' }); await m.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled); assert.equal(await m.locator('#tide-pause').textContent(), 'Play'); await m.locator('#tide-canvas').focus(); @@ -124,7 +124,7 @@ try { assert.ok(await m.locator('[data-tool="brass"]').isDisabled()); await touch.close(); const fallback = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 393, height: 852 } }); - const f = await fallback.newPage(); await f.goto(`${base}/artifacts/tide-pool`); + const f = await fallback.newPage(); await f.goto(`${base}/tide-pool`); assert.ok(await f.locator('.tide-still').isVisible()); await f.screenshot({ path: `${out}/no-javascript.png` }); await fallback.close(); diff --git a/src/components/artifacts.tsx b/src/components/artifacts.tsx index df0c641..e282f1f 100644 --- a/src/components/artifacts.tsx +++ b/src/components/artifacts.tsx @@ -1,13 +1,4 @@ const artifacts = [ - { - number: "005", - href: "/artifacts/tide-pool", - title: "Tide pool", - kind: "INTERACTIVE ART", - description: - "A tidal shelf that its machines dig, fill, and wall. Pools form on their own; leave something, then follow who comes for it.", - status: "PUBLIC", - }, { number: "004", href: "/artifacts/thoughtstore-and-jazz", diff --git a/src/components/other-index.tsx b/src/components/other-index.tsx index 4732522..b43285f 100644 --- a/src/components/other-index.tsx +++ b/src/components/other-index.tsx @@ -58,6 +58,14 @@ export const otherEntries = [ meta: "Typeface specimen", external: false, }, + { + index: "08", + href: "/tide-pool", + title: "Tide pool", + description: "A tidal shelf its machines dig, fill, and build on. Leave something, then follow who comes for it.", + meta: "Interactive art", + external: false, + }, ]; export async function OtherIndex() { diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index ab77ca4..581307a 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -21,7 +21,7 @@ export function TidePoolContent() { return (
- ← Artifacts + ← Other

Tide pool

One pool on a tidal shelf. Everything else is dug.

diff --git a/src/index.tsx b/src/index.tsx index 1f58a29..6cd8753 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -184,6 +184,18 @@ app.get("/other", async (c) => { }); }); +// Commissioned public artwork, listed under Other. Unlike the artifacts, it is indexable. +app.get("/tide-pool", async (c) => { + const stream = renderToReadableStream( + + + + ); + return c.body(stream, { + headers: { "Content-Type": "text/html; charset=UTF-8", "Transfer-Encoding": "chunked" }, + }); +}); + app.get("/code", async (c) => { const stream = renderToReadableStream( @@ -358,17 +370,8 @@ 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( - - - - ); - return c.body(stream, { - headers: { "Content-Type": "text/html; charset=UTF-8", "Transfer-Encoding": "chunked" }, - }); -}); +// The tide pool moved from Artifacts to Other; old links keep working. +app.get("/artifacts/tide-pool", (c) => c.redirect(`/tide-pool${new URL(c.req.url).search}`, 301)); app.get("/artifacts/tinker", async (c) => { const stream = renderToReadableStream( diff --git a/src/tide-pool.test.tsx b/src/tide-pool.test.tsx index 7bbc9cc..75ff61c 100644 --- a/src/tide-pool.test.tsx +++ b/src/tide-pool.test.tsx @@ -3,11 +3,12 @@ import assert from "node:assert/strict"; import { TidePoolContent } from "./components/tide-pool.tsx"; import { Shell } from "./components/shell.tsx"; import { Artifacts } from "./components/artifacts.tsx"; +import { otherEntries } from "./components/other-index.tsx"; test("public tide pool keeps the shell and progressive enhancement with an immersive surface", async () => { const html = (await ).toString(); assert.ok(html.includes('class="page-container"')); - assert.ok(html.includes('class="tide-exit"')); + assert.ok(html.includes('class="tide-exit" href="/other"')); assert.ok(html.includes('Tap the water to drop brass.')); assert.ok(!html.includes("page-container-wide")); assert.ok(!html.includes('name="robots"')); @@ -20,10 +21,11 @@ test("public tide pool keeps the shell and progressive enhancement with an immer assert.ok(html.includes("Nothing you do here is saved or sent.")); }); -test("artifact index includes the commissioned artwork without losing older entries", async () => { +test("the tide pool is listed under Other, not Artifacts, and the artifacts keep their entries", async () => { + assert.equal(otherEntries.filter((entry) => entry.href === "/tide-pool").length, 1); const html = (await ).toString(); - for (const slug of ["tide-pool", "thoughtstore-and-jazz", "tinker", "what-fits-in-a-lora", "lora-without-regret"]) { + assert.ok(!html.includes("tide-pool")); + for (const slug of ["thoughtstore-and-jazz", "tinker", "what-fits-in-a-lora", "lora-without-regret"]) { assert.ok(html.includes(`/artifacts/${slug}`)); } - assert.ok(html.includes("INTERACTIVE ART / PUBLIC")); }); -- 2.51.2 From f9fc728c5f87813de8c6c112d17a37de4242c429 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 24 Sep 2026 13:45:55 -0700 Subject: [PATCH 13/28] Quiet the tide pool and make its night: loose scrap, husks, and pebbles settle into the silt; the instruments fade when untouched and pool names show only when asked for; nights get cool moonlight, lamps that light their structures and lay trembling columns of light on the water, stars on still water, a dusk tint, and a faint violet haze toward the far edge. --- public/tide-pool-world.js | 17 ++++- public/tide-pool.css | 8 ++ public/tide-pool.js | 138 ++++++++++++++++++++++++++++++----- src/components/tide-pool.tsx | 2 +- src/index.tsx | 2 +- 5 files changed, 142 insertions(+), 25 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 7ab6143..0adfff5 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -406,6 +406,8 @@ function spawn(world, sp, x, z, extra = {}) { return c; } +// When each kind of loose object starts to settle into the silt, and when it is gone, in seconds. +const SILT = { scrap: [25, 140], husk: [50, 220], pebble: [60, 200] }; function addObject(world, kind, x, z, extra = {}) { if (!onShelf(world, x, z)) return null; if (world.objects.length >= LIMIT) { @@ -414,7 +416,7 @@ function addObject(world, kind, x, z, extra = {}) { if (!removable) return null; removeObject(world, removable); } - const object = { id: world.nextObject++, kind, x, z, height: extra.height ?? 1.8, age: 0, + const object = { id: world.nextObject++, kind, x, z, height: extra.height ?? 1.8, age: 0, sunk: 0, claimed: null, place: extra.place ?? 'loose', owner: extra.owner ?? null, rotation: world.random() * Math.PI * 2 }; world.objects.push(object); return object; @@ -1824,9 +1826,16 @@ export function advanceWorld(world, elapsed) { o.height = Math.max(o.height, o.alt - heightAt(world, o.x, o.z)); o.alt = null; o.perch = null; } if (o.place !== 'carried' && o.place !== 'worn') o.height = Math.max(0, o.height - dt * 2.8); - if (o.place === 'loose' && o.claimed === null) { - if (o.kind === 'husk' && o.age > 110) { o.kind = 'scrap'; o.age = 0; } - else if (o.kind === 'scrap' && o.age > 140) removeObject(world, o); + // Left alone, scrap, husks, and pebbles settle into the silt and are covered over; a pebble becomes part of the ground. + // Brass and shells stay in view. Anything picked up again is dug out clean. + if (o.place !== 'loose') o.sunk = 0; + else if (o.claimed === null && SILT[o.kind]) { + const [start, gone] = SILT[o.kind]; + o.sunk = clamp((o.age - start) / (gone - start), 0, 1); + if (o.sunk >= 1) { + if (o.kind === 'pebble') reshape(world, o.x, o.z, .02, .3); + removeObject(world, o); + } } } const light = daylight(world); diff --git a/public/tide-pool.css b/public/tide-pool.css index e1c99d1..d196923 100644 --- a/public/tide-pool.css +++ b/public/tide-pool.css @@ -122,6 +122,14 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di #tide-description:empty { display: none; } .tide-pool noscript { position: absolute; left: 16px; bottom: 240px; max-width: 300px; } +/* When nobody is touching it, the instruments fade and the shelf has the screen to itself. */ +.tide-heading, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-inspect, .tide-notes { transition: opacity .5s ease; } +.tide-pool[data-idle] :is(.tide-heading, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-status, .tide-inspect, .tide-notes) { + opacity: 0; pointer-events: none; transition-duration: 2.2s; } +@media (prefers-reduced-motion: reduce) { + .tide-heading, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-inspect, .tide-notes { transition: none; } +} + @media (max-width: 1100px) { .tide-ticker { display: none; } } diff --git a/public/tide-pool.js b/public/tide-pool.js index 966f684..e806bf6 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, -} from './tide-pool-world.js?v=23'; +} from './tide-pool-world.js?v=24'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -87,6 +87,13 @@ async function initialize() { 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 }; + // 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. + const LAMPS = mobile ? 8 : 16; + shared.uLamps = { value: Array.from({ length: LAMPS }, () => new T.Vector4(0, 0, 0, 0)) }; + shared.uLampColor = { value: new T.Color(0xf0dcf6) }; + shared.uNight = { value: 0 }; shared.uDusk = { value: 0 }; + shared.uHaze = { value: new T.Vector4(0, 1, 0, 0) }; shared.uHazeZoom = { value: 7 }; + shared.uHazeColor = { value: new T.Color(0x1a1524) }; // 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 = ` @@ -145,7 +152,7 @@ async function initialize() { cw = instanceMatrix * cw; #endif vWorldC = (modelMatrix * cw).xyz;`); - shader.fragmentShader = 'varying vec3 vWorldC;\nuniform float uWater, uTime, uLight;\n' + (shader.fragmentShader.includes('gridAt(') ? '' : GRID) + CAUSTIC + '\n' + + shader.fragmentShader = 'varying vec3 vWorldC;\nuniform float uWater, uTime, uLight, uHazeZoom;\nuniform vec4 uHaze;\nuniform vec3 uHazeColor;\n' + (shader.fragmentShader.includes('gridAt(') ? '' : GRID) + CAUSTIC + '\n' + shader.fragmentShader.replace('#include ', ` vec4 local = gridAt(vWorldC.xz); float column = local.r - local.g; @@ -158,6 +165,9 @@ async function initialize() { // 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(.82, .76, .92) * lap * .14 * uLight; + // Toward the far edge of the view the shelf recedes into a faint violet haze. + float ahead = dot(vWorldC.xz - uHaze.zw, uHaze.xy); + gl_FragColor.rgb = mix(gl_FragColor.rgb, uHazeColor, smoothstep(uHazeZoom * .25, uHazeZoom * 1.9, ahead) * .5); #include `); }; return material; @@ -271,7 +281,9 @@ async function initialize() { 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, uFlow: shared.uFlow, uOpen: { value: 0 }, uTide: shared.uWater }, + uGrid: shared.uGrid, uGridBox: shared.uGridBox, uFlow: shared.uFlow, uOpen: { value: 0 }, uTide: shared.uWater, + uLamps: shared.uLamps, uLampColor: shared.uLampColor, uNight: shared.uNight, uDusk: shared.uDusk, + uHaze: shared.uHaze, uHazeZoom: shared.uHazeZoom, uHazeColor: shared.uHazeColor }, vertexShader: `${GRID} uniform float uOpen, uTide, uTime; varying vec3 vW; varying float vDepth; void main() { @@ -286,6 +298,7 @@ async function initialize() { }`, fragmentShader: ` varying vec3 vW; varying float vDepth; uniform float uTime, uLight; uniform vec3 uSun, uView; + uniform vec4 uLamps[${LAMPS}]; uniform vec3 uLampColor, uHazeColor; uniform float uNight, uDusk, uHazeZoom; uniform vec4 uHaze; uniform sampler2D uFlow; uniform vec4 uGridBox; uniform float uOpen; ${WAVES} void main() { @@ -310,7 +323,39 @@ async function initialize() { vec3 h = normalize(uSun + uView); float nh = max(dot(n, h), 0.); 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; + col += mix(vec3(1., .95, 1.), vec3(.72, .78, 1.), uNight) * (pow(nh, 60.) * .07 + pow(nh, 900.) * 1.1 * patchy * detail) * uLight; + // Each lamp lays a trembling column of light across the water toward the eye, centred where its mirror image + // sits and broken into moving bars by the ripples, as harbour lights are. + vec3 lampTint = pow(uLampColor, vec3(1. / 2.2)); + vec2 toEye = normalize(uView.xz); + float sparkle = 0.; + for (int i = 0; i < ${LAMPS}; i++) { + vec4 L = uLamps[i]; + if (L.w <= 0.) continue; + float above = max(L.y - vW.y, .05); + vec2 dp = vW.xz - (L.xz + toEye * above * length(uView.xz) / uView.y); + float along = dot(dp, toEye), across = dot(dp, vec2(-toEye.y, toEye.x)); + float column = exp(-across * across / (.003 + above * .01)) * exp(-along * along / (above * above * .45)); + float bars = .25 + .75 * smoothstep(.35, .95, sin(along * 34. - uTime * 2.1 + dot(n.xz, vec2(24., 31.))) * .5 + .5); + vec3 toL = L.xyz - vW; + float glint = pow(max(dot(n, normalize(normalize(toL) + uView)), 0.), 400.) / (1. + dot(toL, toL) * .5); + float lampLight = L.w * (column * bars * .8 + glint * 2.5 * mix(.35, 1., detail)); + col += lampTint * lampLight; + sparkle += lampLight; + } + // On still water at night, stars. + float calm = (1. - smoothstep(.08, .26, length(g))) * uNight * smoothstep(.08, .3, d); + if (calm > .01) { + vec2 sp = vW.xz * 3.2 + n.xz * 1.2; + float hS = fract(sin(dot(floor(sp), vec2(12.9898, 78.233))) * 43758.5453); + vec2 at = fract(sp) - .5 - (vec2(fract(hS * 13.7), fract(hS * 7.31)) - .5) * .6; + float size = max(px * 3.2 * 2.1, .045); + float star = smoothstep(size, size * .2, length(at)) * step(.962, hS) * (hS > .993 ? 1.15 : .6) * (.7 + .3 * sin(uTime * 1.7 + hS * 90.)) * calm; + col += vec3(.86, .84, 1.) * star; + sparkle += star; + } + // At dusk and dawn the pools take the colour of the sky. + col = mix(col, col * vec3(1.1, .95, 1.04) + vec3(.05, .02, .04), uDusk * .6); // 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.); @@ -321,9 +366,12 @@ async function initialize() { 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; + float alpha = .24 + absorb * .58 + fres * 1.2 + foam + sparkle; // Colours above are chosen as they should look on screen; the pipeline expects linear light. - gl_FragColor = vec4(pow(max(col, 0.), vec3(2.2)), clamp(alpha, 0., .94) * smoothstep(.012, .04, d)); + vec3 linear = pow(max(col, 0.), vec3(2.2)); + // Toward the far edge of the view the water recedes into the same faint haze as the shelf. + float hz = smoothstep(uHazeZoom * .25, uHazeZoom * 1.9, dot(vW.xz - uHaze.zw, uHaze.xy)) * .5; + gl_FragColor = vec4(mix(linear, uHazeColor, hz), clamp(alpha + hz * .3, 0., .94) * smoothstep(.012, .04, d)); }`, }); const water = new T.Mesh(grid, waterMaterial); @@ -427,13 +475,30 @@ async function initialize() { bloomMesh.instanceMatrix.needsUpdate = true; bloomMesh.instanceColor.needsUpdate = true; } + // Lights at night. Each frame the brightest near the view lay their paths on the water, and the few brightest light what is around them. + const lampSpots = []; + const lampLights = Array.from({ length: mobile ? 2 : 4 }, () => { const l = new T.PointLight(0xe8d4f2, 0, 5, 1.6); scene.add(l); return l; }); + function emit(x, y, z, strength) { if (strength > .02) lampSpots.push({ x, y, z, s: strength }); } + function castLamps(night) { + const weight = l => l.s / (1 + ((l.x - view.x) ** 2 + (l.z - view.z) ** 2) / (view.zoom * view.zoom * 4)); + lampSpots.sort((a, b) => weight(b) - weight(a)); + shared.uLamps.value.forEach((v, i) => { const l = lampSpots[i]; if (l && night > .01) v.set(l.x, l.y, l.z, l.s * night); else v.set(0, 0, 0, 0); }); + lampLights.forEach((light, i) => { + const l = lampSpots[i]; + if (l && night > .02) { light.position.set(l.x, l.y + .12, l.z); light.intensity = l.s * night * 2.4; } else light.intensity = 0; + }); + lampSpots.length = 0; + } const GLOWS = 60; const glowMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2), new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), GLOWS + 4); glowMesh.frustumCulled = false; glowMesh.renderOrder = 6; glowMesh.count = 0; glowMesh.setColorAt(0, new T.Color()); scene.add(glowMesh); - // Pools are named as they form; each name floats as a hairline label over its water. + // Pools are named as they form. A name shows only when asked for: pointed at, tapped, or jumped to. const labels = new Map(); + let pointedPool = null, shownPool = null, shownUntil = 0; + const namedPool = name => name && name !== 'sea' && name !== 'puddle' ? name : null; + function showPool(name, seconds = 2.5) { shownPool = name; shownUntil = performance.now() + seconds * 1000; } function placeLabels() { const bounds = stage.getBoundingClientRect(); const live = new Set(); @@ -450,7 +515,8 @@ async function initialize() { const sx = (v1.x + 1) / 2 * bounds.width, sy = (1 - v1.y) / 2 * bounds.height; const show = view.zoom < 15 && sx > -40 && sx < bounds.width - 120 && sy > 150 && sy < bounds.height - (bounds.width < 720 ? 230 : 150); el.style.transform = `translate(${(sx - 20).toFixed(1)}px, ${sy.toFixed(1)}px)`; - el.style.opacity = show ? String(clamp((15 - view.zoom) / 3, 0, 1) * .8) : '0'; + const asked = b.name === pointedPool || b.name === shownPool && performance.now() < shownUntil; + el.style.opacity = show && asked ? String(clamp((15 - view.zoom) / 3, 0, 1) * .8) : '0'; } for (const [name, el] of labels) if (!live.has(name)) { el.remove(); labels.delete(name); } } @@ -732,6 +798,7 @@ async function initialize() { for (let k = 0; k < 4; k++) box(P.mcBrass, Math.cos(k * Math.PI / 2) * .13, 2.35, Math.sin(k * Math.PI / 2) * .13, .025, .32, .025); box(P.mcBrass, 0, 2.52, 0, .34, .03, .34); const lampAt = toWorld(v3, 0, 2.35, 0).clone(); + emit(lampAt.x, lampAt.y, lampAt.z, m.lit * 1.8); put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.1)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + m.lit * .85)); if (m.lit > .05 && glows < GLOWS) { // A halo at the lamp, and a pool of light on the water below. @@ -918,10 +985,13 @@ async function initialize() { if (f.b.kind === 'pod') { // Someone at home shows as a faint glow in the window, brighter when there is power. const home = f.b.resident != null && creatureById(world, f.b.resident)?.perch === f.b.key ? .35 : 0; + toWorld(v3, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83); + emit(v3.x, v3.y, v3.z, Math.max(f.power * .8, home * 1.2)); box(P.mcLamp, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83, .07, .07, .07, 0, 0, 0, lampColor.set(0x2a2530).lerp(lampWarm, Math.max(shine * .9, home * (.5 + .5 * night)))); } else if (f.b.kind === 'lamp') { const lampAt = toWorld(v3, 0, .45, 0).clone(); + emit(lampAt.x, lampAt.y, lampAt.z, f.power); put(P.mcLamp, m4.compose(lampAt, q.identity(), s3.setScalar(.08)), lampColor.set(0x3a3540).lerp(lampWarm, .15 + lit * .85)); if (lit > .05 && glows < GLOWS) { glowMesh.setMatrixAt(glows, m4.compose(lampAt, camera.quaternion, s3.setScalar(.7 + lit * .4))); @@ -964,12 +1034,15 @@ async function initialize() { } return glows; } + // How far each kind sinks before it is covered: settling into the silt, it sinks and tips a little as it goes. + const SINK = { scrap: .13, husk: .17, pebble: .11 }; function drawObjects() { for (const o of world.objects) { const worn = o.place === 'worn'; const wearer = worn ? creatureById(world, o.owner) : null; - const y = (worn && wearer ? footAt(world, wearer) + .2 : o.alt ?? heightAt(world, o.x, o.z)) + (worn ? 0 : o.height); - setBase(o.x, y, o.z, o.rotation); + const sunk = worn ? 0 : (o.sunk || 0) * (SINK[o.kind] || 0); + const y = (worn && wearer ? footAt(world, wearer) + .2 : o.alt ?? heightAt(world, o.x, o.z)) + (worn ? 0 : o.height) - sunk; + setBase(o.x, y, o.z, o.rotation + sunk * 2); if (o.kind === 'brass') { box(P.brass, 0, .03, 0, .07, .06, .07); box(P.brassBoss, 0, .07, 0, .04, .02, .04); } else if (o.kind === 'shell') worn ? box(P.shell, 0, 0, -.02, .2, .13, .26) : box(P.shell, 0, 0, 0, .14, .08, .12); else if (o.kind === 'pebble') box(P.pebble, 0, .04, 0, .1, .055, .08); @@ -1139,9 +1212,13 @@ async function initialize() { function render() { shared.uTime.value = world.time; const light = daylight(world); - // Day and night only tint the pool; it never goes dark. - const glow = .78 + .22 * light; - shared.uLight.value = glow; + // Night is its own time, not a dimmer day: cool moonlight, darker water, and the lamps take over. Dusk tints the pools. + const night = clamp((.52 - light) / .3, 0, 1), dusk = clamp(1 - Math.abs(light - .55) / .3, 0, 1); + shared.uLight.value = .55 + .45 * light; + shared.uNight.value = night; shared.uDusk.value = dusk; + // The far edge of the view, for the haze: straight away from the eye, across the ground. + shared.uHaze.value.set(-Math.sin(YAW), -Math.cos(YAW), view.x, view.z); + shared.uHazeZoom.value = view.zoom; shared.uWater.value = waterLevel(world); uploadGrid(); uploadFlow(1 / 30); @@ -1154,17 +1231,18 @@ async function initialize() { // The water's glint light sits on the glitter path: mirrored about a near-level surface, so ripples catch it. const level = v2.set(.12 + Math.sin(dayAngle) * .05, 1, -.1).normalize(), eye = shared.uView.value; shared.uSun.value.copy(level).multiplyScalar(2 * level.dot(eye)).sub(eye).normalize(); - sun.intensity = 4 + .8 * light; - sun.color.setRGB(.86 + .12 * light, .83 + .12 * light, .95); - hemi.intensity = .7 + .15 * light; - scene.environmentIntensity = .55 + .6 * light; - fill.intensity = 1.6; + sun.intensity = 4.6 - 2.2 * night; + sun.color.setRGB(.98 - .36 * night, .95 - .3 * night, .95 + .05 * night); + hemi.intensity = .85 - .4 * night; + scene.environmentIntensity = 1.15 - .75 * night; + fill.intensity = 1.6 - .9 * night; for (const p of parts) if (!p.fixed) p.n = 0; for (const c of world.creatures) DRAW[c.sp](c); drawObjects(); drawMachines(); + castLamps(night); const focus = creatureById(world, selected); drawContacts(); drawBlooms(); @@ -1195,6 +1273,9 @@ async function initialize() { const b = document.createElement('button'); b.type = 'button'; b.dataset.basin = name; b.textContent = name; b.setAttribute('aria-label', `Pool ${name}`); b.addEventListener('click', () => goToBasin(i)); + b.addEventListener('pointerenter', () => { pointedPool = name; wake(); }); + b.addEventListener('pointerleave', () => { pointedPool = null; wake(); }); + b.addEventListener('focus', () => { showPool(name, 3); wake(); }); return b; })); } @@ -1453,6 +1534,8 @@ async function initialize() { if (paused && o.height) o.height = 0; root.dataset.engaged = 'true'; const pool = placeName(x, z); + const here = namedPool(bodyAt(world, x, z)); + if (here) showPool(here); const drawn = o.id !== null && world.creatures.find(c => c.task?.object === o.id); if (tool === 'feed') { blooms.push({ x, z, born: world.time }); @@ -1506,6 +1589,7 @@ async function initialize() { const over = creatureAt(e.clientX, e.clientY); marker.visible = !!hit && tool !== 'look' && !over && onShelf(world, hit.x, hit.z); if (hit) cursor = hit; + pointedPool = hit ? namedPool(bodyAt(world, hit.x, hit.z)) : null; canvas.classList.toggle('is-dropping', marker.visible); canvas.style.cursor = over ? 'pointer' : ''; wake(); @@ -1549,7 +1633,7 @@ async function initialize() { } canvas.addEventListener('pointerup', e => endPointer(e, false)); canvas.addEventListener('pointercancel', e => endPointer(e, true)); - canvas.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !pointers.size) { marker.visible = false; wake(); } }); + canvas.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !pointers.size) { marker.visible = false; pointedPool = null; wake(); } }); canvas.addEventListener('wheel', e => { e.preventDefault(); const delta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; @@ -1606,6 +1690,7 @@ async function initialize() { goal.x = p.x; goal.z = p.z + .3; goal.zoom = Math.min(Math.max(goal.zoom, 4.5), 9); cursor = { x: p.x, z: p.z }; clampGoal(); + showPool(p.name, 3); const n = world.creatures.filter(c => bodyAt(world, c.x, c.z) === p.name).length; status.textContent = `Pool ${p.name}. ${Math.round(p.area)} m² of water, ${n} machine${n === 1 ? '' : 's'}.`; readout(); wake(); @@ -1696,6 +1781,21 @@ async function initialize() { root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]').forEach(b => { b.disabled = false; }); status.textContent = paused ? 'Paused for reduced motion. Press Play when you want to watch.' : 'Drag to look around. Tap a machine to follow it.'; updateButtons(); readout(); schedule(); + // When nobody is touching it, the instruments fade and the shelf has the screen to itself. Any touch brings them back. + let idleTimer = 0; + const busy = () => root.querySelector('.tide-notes[open], :is(.tide-heading, .tide-hud, .tide-inspect, .tide-controls, .tide-nav, .tide-utility):hover') || + document.activeElement !== canvas && root.contains(document.activeElement) && document.activeElement.matches(':focus-visible'); + function settleIdle() { + if (busy() || !root.dataset.ready) idleTimer = setTimeout(settleIdle, 2500); + else root.dataset.idle = 'true'; + } + function stir(first) { + delete root.dataset.idle; + clearTimeout(idleTimer); + idleTimer = setTimeout(settleIdle, first === true ? 9000 : 5000); + } + for (const type of ['pointermove', 'pointerdown', 'wheel', 'keydown', 'focusin']) root.addEventListener(type, stir, { passive: true }); + stir(true); // Test and debugging hook; read-only by convention. window.__tidePool = { scene, draw: () => { easeCamera(1); render(); }, advance: seconds => { for (let i = 0; i < seconds * 20; i++) advanceWorld(world, .05); readout(); }, get world() { return world; }, select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 581307a..2d4e259 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} +
); } diff --git a/src/index.tsx b/src/index.tsx index 6cd8753..029a698 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -187,7 +187,7 @@ app.get("/other", async (c) => { // Commissioned public artwork, listed under Other. Unlike the artifacts, it is indexable. app.get("/tide-pool", async (c) => { const stream = renderToReadableStream( - + ); -- 2.51.2 From 0975438ae6114e480ded8eef98c5bd2fa7b6b814 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 24 Sep 2026 15:20:02 -0700 Subject: [PATCH 14/28] Make the tide pool a miniature under glass: a focus band keeps the middle of the view sharp and softens the top and bottom; left alone, the camera drifts toward whatever is happening, using positions now recorded with each event; the water is clearer, so what lives under it stays visible at high tide. --- public/tide-pool-world.js | 11 ++-- public/tide-pool.js | 119 +++++++++++++++++++++++++++++------ src/components/tide-pool.tsx | 4 +- 3 files changed, 109 insertions(+), 25 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 0adfff5..69f1639 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -283,7 +283,7 @@ function survey(world) { 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); + event(world, 'form', null, best.name, null, { x: part.x, z: part.z }); } best.mask.fill(0); for (const k of part.cells) best.mask[k] = 1; @@ -482,8 +482,11 @@ function ripple(world, x, z) { world.ripples.push({ x, z, born: world.time }); if (world.ripples.length > 12) world.ripples.shift(); } -function event(world, type, c, detail, other) { - world.events.push({ type, time: world.time, who: c ? label(c) : null, sp: c?.sp ?? null, detail: detail ?? null, other: other ?? null }); +// Each event keeps where it happened: at the creature involved, or at the place given. +function event(world, type, c, detail, other, at) { + const where = at || c; + world.events.push({ type, time: world.time, who: c ? label(c) : null, sp: c?.sp ?? null, detail: detail ?? null, other: other ?? null, + x: where ? Math.round(where.x * 100) / 100 : null, z: where ? Math.round(where.z * 100) / 100 : null }); if (world.events.length > 80) world.events.shift(); } @@ -972,7 +975,7 @@ function bearLoads(world) { // Everything that gave way falls, highest last; about one piece in three lands as usable scrap. const middle = world.structures.reduce((best, t) => !best || distance(t, blockCenter(fallen[0])) < distance(best, blockCenter(fallen[0])) ? t : best, null); fallen.forEach((b, n) => drop(world, b, n % 3 === 0 && n < 12, middle)); - if (fallen.length >= 3) event(world, 'collapse', null, fallen.length); + if (fallen.length >= 3) event(world, 'collapse', null, fallen.length, null, blockCenter(fallen[0])); return fallen.length; } // Where an artificer builds: the structure it already works on, a neighbour's, or a new one in deep water. diff --git a/public/tide-pool.js b/public/tide-pool.js index e806bf6..4a4e05b 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, -} from './tide-pool-world.js?v=24'; +} from './tide-pool-world.js?v=25'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -30,19 +30,40 @@ async function initialize() { scene.background = new T.Color(0x0a0a0a); scene.fog = null; const camera = new T.OrthographicCamera(-6, 6, 6, -6, 0.1, 140); + // A miniature under glass: a band across the middle of the view stays sharp and everything softens toward the top and + // bottom, as in a photograph of a small world taken close up. One Gaussian blur, run across and then down. + const TILT = { + uniforms: { tDiffuse: { value: null }, uDir: { value: new T.Vector2(1, 0) }, uTexel: { value: new T.Vector2(1 / 1024, 1 / 1024) }, + uAmount: { value: 2 }, uFocus: { value: .5 }, uBand: { value: .13 }, uRamp: { value: .34 } }, + vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }', + fragmentShader: ` + uniform sampler2D tDiffuse; uniform vec2 uDir, uTexel; uniform float uAmount, uFocus, uBand, uRamp; varying vec2 vUv; + void main() { + float blur = smoothstep(uBand, uBand + uRamp, abs(vUv.y - uFocus)) * uAmount; + if (blur < .02) { gl_FragColor = texture2D(tDiffuse, vUv); return; } + vec2 step = uDir * uTexel * blur; + vec4 sum = texture2D(tDiffuse, vUv) * .2270270270; + sum += (texture2D(tDiffuse, vUv + step * 1.3846153846) + texture2D(tDiffuse, vUv - step * 1.3846153846)) * .3162162162; + sum += (texture2D(tDiffuse, vUv + step * 3.2307692308) + texture2D(tDiffuse, vUv - step * 3.2307692308)) * .0702702703; + gl_FragColor = sum; + }`, + }; // Bloom: lamps, eyes, and glints bleed softly past their edges. Without the add-ons, the scene renders plainly. - let composer = null; + let composer = null, tilt = null; try { const base = '/public/tide-pool-vendor/jsm/postprocessing/'; - const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }, { OutputPass }] = await Promise.all( - ['EffectComposer', 'RenderPass', 'UnrealBloomPass', 'OutputPass'].map(name => import(`${base}${name}.js`))); + const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }, { OutputPass }, { ShaderPass }] = await Promise.all( + ['EffectComposer', 'RenderPass', 'UnrealBloomPass', 'OutputPass', 'ShaderPass'].map(name => import(`${base}${name}.js`))); composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); composer.addPass(new UnrealBloomPass(new T.Vector2(512, 512), mobile ? .35 : .45, .55, .72)); + tilt = [new ShaderPass(TILT), new ShaderPass(TILT)]; + tilt[1].uniforms.uDir.value.set(0, 1); + tilt.forEach(pass => composer.addPass(pass)); composer.addPass(new OutputPass()); } catch (error) { console.warn('Bloom unavailable; rendering without it.', error); - composer = null; + composer = null; tilt = null; } // Light follows a slow day. Eyes and visors are unlit, so they read as signal lamps at night. @@ -158,10 +179,10 @@ async function initialize() { float column = local.r - local.g; 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, .62, .8), vec3(.28, .26, .42), clamp(depth * .9, 0., 1.)); + // Water absorbs warm light first: deeper floors go cooler and a little darker, but stay easy to see, as in a clear pool. + vec3 absorbed = gl_FragColor.rgb * mix(vec3(.84, .8, .96), vec3(.5, .47, .68), clamp(depth * .6, 0., 1.)); gl_FragColor.rgb = mix(gl_FragColor.rgb, absorbed, under); - gl_FragColor.rgb += vec3(.8, .72, .95) * caustic(vWorldC.xz, uTime * .5) * under * exp(-max(depth, 0.) * 1.8) * .15 * uLight; + gl_FragColor.rgb += vec3(.8, .72, .95) * caustic(vWorldC.xz, uTime * .5) * under * exp(-max(depth, 0.) * 1.8) * .1 * 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(.82, .76, .92) * lap * .14 * uLight; @@ -314,7 +335,7 @@ async function initialize() { 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)); // Depth: a clear lavender-grey in the shallows, deep violet where the water is deep. - float absorb = 1. - exp(-d * 2.4); + float absorb = 1. - exp(-d * 1.3); 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); @@ -366,7 +387,7 @@ async function initialize() { 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 + sparkle; + 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. vec3 linear = pow(max(col, 0.), vec3(2.2)); // Toward the far edge of the view the water recedes into the same faint haze as the shelf. @@ -1130,10 +1151,55 @@ async function initialize() { function cameraSettled() { return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002; } + // Watching: once the instruments have faded and nothing is being followed, the camera drifts on its own and eases toward + // whatever is happening: a collapse, a fall, a new pool, a structure begun or growing, a birth, the lights at dusk, the school. + const director = { on: false, shot: null, until: 0, phase: 0, wide: false }; + const WEIGHT = { collapse: 9, fall: 6, form: 5, found: 5, debut: 4, erect: 3.5, complete: 3, birth: 3, arrive: 2.5, repair: 2 }; + function candidates() { + const light = daylight(world), picks = []; + const add = (x, z, score, zoom) => { if (Number.isFinite(x + z) && onShelf(world, x, z)) picks.push({ x, z, score, zoom }); }; + for (const e of world.events) { + const age = world.time - e.time; + if (age > 45 || e.x == null || !WEIGHT[e.type]) continue; + add(e.x, e.z, WEIGHT[e.type] * (1 - age / 45), e.type === 'form' ? 6.5 : 3.6); + } + // Structures draw the eye, more so when their lights are on and most of all as they come on at dusk. + const dusk = clamp(1 - Math.abs(light - .5) / .25, 0, 1), dark = clamp((.52 - light) / .3, 0, 1); + for (const t of world.structures) { + let lights = 0; + if (dark > .1) for (const b of world.blocks.values()) { + if ((b.kind === 'lamp' || b.kind === 'pod') && Math.hypot(X0 + (b.i + .5) * CELL - t.x, Z0 + (b.j + .5) * CELL - t.z) < t.reach + 1) lights++; + } + add(t.x, t.z, 1 + t.n / 70 + lights * (.4 + dusk), clamp(3 + t.reach, 3.4, 6)); + } + const fish = world.creatures.filter(c => c.sp === 'tab'); + if (fish.length > 4) add(fish.reduce((a, c) => a + c.x, 0) / fish.length, fish.reduce((a, c) => a + c.z, 0) / fish.length, 1.4, 3.4); + return picks; + } + function direct(dt) { + const now = performance.now(), last = director.shot; + const urgent = last && world.events.some(e => (e.type === 'collapse' || e.type === 'fall') && world.time - e.time < 2 && e.x != null && + Math.hypot(e.x - last.x, e.z - last.z) > 1); + if (!last || now > director.until || urgent) { + const picks = candidates().map(p => ({ ...p, score: p.score + Math.random() * .8 - (last && Math.hypot(p.x - last.x, p.z - last.z) < 2 ? 1.5 : 0) })); + picks.sort((a, b) => b.score - a.score); + // Now and then a wide shot of the whole shelf between the close ones. + director.wide = !urgent && !director.wide && Math.random() < .3; + const middle = picks.length ? { x: picks.reduce((a, p) => a + p.x, 0) / picks.length, z: picks.reduce((a, p) => a + p.z, 0) / picks.length } : view; + director.shot = director.wide ? { x: middle.x, z: middle.z, zoom: 9.5 } : picks[0] || { x: view.x, z: view.z, zoom: 7 }; + director.until = now + (director.wide ? 14000 : 20000 + Math.random() * 12000); + } + director.phase += dt; + // Never quite still: a slow circle around the subject, and a slow breath in and out. + const shot = director.shot, t = director.phase; + goal.x = shot.x + Math.cos(t * .07) * .7; goal.z = shot.z + Math.sin(t * .053) * .45; + goal.zoom = shot.zoom * (1 + Math.sin(t * .041) * .06); + clampGoal(); + } function easeCamera(dt) { if (intro && performance.now() - intro.start > 1800 && !intro.released) { intro.released = true; Object.assign(goal, intro.to); } if (intro?.released && cameraSettled()) intro = null; - const k = reduced.matches ? 1 : 1 - Math.exp(-dt * (intro?.released ? 1.1 : 7)); + const k = reduced.matches ? 1 : 1 - Math.exp(-dt * (intro?.released ? 1.1 : director.on ? .32 : 7)); view.x += (goal.x - view.x) * k; view.z += (goal.z - view.z) * k; view.zoom += (goal.zoom - view.zoom) * k; placeCamera(); } @@ -1254,6 +1320,12 @@ async function initialize() { p.mesh.instanceMatrix.needsUpdate = true; if (p.colored && p.mesh.instanceColor) p.mesh.instanceColor.needsUpdate = true; } + if (tilt) { + let at = .5; + if (focus) { v1.set(focus.x, SPECIES[focus.sp].kind === 'swimmer' ? focus.y : footAt(world, focus), focus.z).project(camera); at = clamp((v1.y + 1) / 2, .2, .8); } + const amount = clamp(.55 + (view.zoom - 1.4) * .15, .55, 1) * 2.3 * renderer.getPixelRatio(); + tilt.forEach(pass => { pass.uniforms.uFocus.value = at; pass.uniforms.uAmount.value = amount; }); + } if (composer) composer.render(); else renderer.render(scene, camera); placeLabels(); dirty = false; @@ -1491,17 +1563,21 @@ async function initialize() { readoutClock -= dt; if (readoutClock <= 0) { readoutClock = .4; readout(); tick(); } } else previous = 0; + aim(dt); + easeCamera(dt); + render(); + if (!paused || !cameraSettled()) frame = requestAnimationFrame(loop); + else lastFrame = 0; + } + // Where the camera wants to be this frame: on the machine being followed, or wherever the wandering eye has settled. + function aim(dt) { const focus = following && creatureById(world, selected); if (focus) { // On narrow screens the specimen sheet covers the lower half, so frame the machine above it. const lift = stage.clientWidth < 720 ? view.zoom * .42 / Math.sin(PITCH) : 0; const upScreen = v2.setFromMatrixColumn(camera.matrixWorld, 1); upScreen.y = 0; upScreen.normalize(); goal.x = focus.x - upScreen.x * lift; goal.z = focus.z - upScreen.z * lift; clampGoal(); - } - easeCamera(dt); - render(); - if (!paused || !cameraSettled()) frame = requestAnimationFrame(loop); - else lastFrame = 0; + } else if (director.on && !paused) direct(dt); } function wake() { dirty = true; @@ -1517,6 +1593,7 @@ async function initialize() { renderer.setPixelRatio(Math.min(devicePixelRatio, mobile ? 1.5 : 2, 1800 / Math.max(1, width))); renderer.setSize(width, height, false); if (composer) { composer.setPixelRatio(renderer.getPixelRatio()); composer.setSize(width, height); } + if (tilt) { const ratio = renderer.getPixelRatio(); tilt.forEach(pass => pass.uniforms.uTexel.value.set(1 / (width * ratio), 1 / (height * ratio))); } if (width / height < .8 && goal.zoom === 7) goal.zoom = view.zoom = 10; placeCamera(); wake(); } @@ -1786,10 +1863,14 @@ async function initialize() { const busy = () => root.querySelector('.tide-notes[open], :is(.tide-heading, .tide-hud, .tide-inspect, .tide-controls, .tide-nav, .tide-utility):hover') || document.activeElement !== canvas && root.contains(document.activeElement) && document.activeElement.matches(':focus-visible'); function settleIdle() { - if (busy() || !root.dataset.ready) idleTimer = setTimeout(settleIdle, 2500); - else root.dataset.idle = 'true'; + if (busy() || !root.dataset.ready) { idleTimer = setTimeout(settleIdle, 2500); return; } + root.dataset.idle = 'true'; + // Left alone, and not following anything, the camera starts to wander the shelf on its own. + if (!following && !reduced.matches) { director.on = true; director.shot = null; wake(); } } function stir(first) { + // Any touch stops the wandering camera where it is. + if (director.on) { director.on = false; director.shot = null; Object.assign(goal, { x: view.x, z: view.z, zoom: view.zoom }); } delete root.dataset.idle; clearTimeout(idleTimer); idleTimer = setTimeout(settleIdle, first === true ? 9000 : 5000); @@ -1797,5 +1878,5 @@ async function initialize() { for (const type of ['pointermove', 'pointerdown', 'wheel', 'keydown', 'focusin']) root.addEventListener(type, stir, { passive: true }); stir(true); // Test and debugging hook; read-only by convention. - window.__tidePool = { scene, draw: () => { easeCamera(1); render(); }, advance: seconds => { for (let i = 0; i < seconds * 20; i++) advanceWorld(world, .05); readout(); }, get world() { return world; }, select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; + window.__tidePool = { scene, draw: () => { easeCamera(1); render(); }, watch: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { advanceWorld(world, 1 / 30); aim(1 / 30); easeCamera(1 / 30); } render(); }, director, advance: seconds => { for (let i = 0; i < seconds * 20; i++) advanceWorld(world, .05); readout(); }, get world() { return world; }, select: id => { const c = creatureById(world, id); if (c) select(c); }, view, goal }; } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 2d4e259..3b1eab7 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -133,7 +133,7 @@ export function TidePoolContent() { {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}

    Large machines need a brass core to cast a new body. When they stop, they leave a husk and give their core back. Brass, shells, and pebbles also wash in from the sea at high tide. Collectors hoard it, and others steal it.

    -

    Tap a machine to follow it. Choose a material, then tap water to drop it. Feed makes the film and plankton bloom where it lands. A purely synthetic, procedural artwork. Sound is optional and starts only when you turn it on. Nothing you do here is saved or sent.

    +

    Tap a machine to follow it. Choose a material, then tap water to drop it. Feed makes the film and plankton bloom where it lands. Leave it alone and the controls fade away and the view drifts on its own toward whatever is happening; any touch brings them back. A purely synthetic, procedural artwork. Sound is optional and starts only when you turn it on. Nothing you do here is saved or sent.

    Keyboard: focus the pool. Arrow keys move the marker. Shift and arrows pan. Plus and minus zoom. Enter or Space drops, or selects in Look mode. Brackets step between machines. Escape releases. Keys 1 to 9 jump to a pool. Reduced-motion settings start the world paused; you can choose to play.

    @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From 3f6da300c7e75d23600d5fec6d2da60252aeddbd Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 09:10:13 -0700 Subject: [PATCH 15/28] Take away two sources of noise: half-built machines nobody works on fall apart part by part and leave scrap to settle, and rings mark the surface only where a body breaks it. --- public/tide-pool-world.js | 12 +++++++++++- public/tide-pool.js | 11 ++++++----- src/components/tide-pool.tsx | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 69f1639..a4f1d6b 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -1225,7 +1225,7 @@ function thinkArtificer(world, c) { if (!project) { const next = plan(world, c); if (next) { - project = { id: world.nextMachine++, ...next, need: MACHINES[next.kind].need, have: 0, charge: 0, spin: 0, lit: 0, shut: 0, owner: c.id, angle: world.random() * Math.PI }; + project = { id: world.nextMachine++, ...next, need: MACHINES[next.kind].need, have: 0, charge: 0, spin: 0, lit: 0, shut: 0, owner: c.id, angle: world.random() * Math.PI, touched: world.time }; world.machines.push(project); c.project = project.id; event(world, 'plan', c, next.kind); @@ -1255,6 +1255,15 @@ function setGate(world, m, height) { // Finished machines work every step. function machinery(world, dt) { structuresStep(world, dt); + // A machine left half-built falls apart: once nobody has fitted a part for a while, a part comes loose, then another, + // until nothing is left standing. + for (const m of [...world.machines]) { + if (m.have >= m.need || world.time - (m.touched ?? world.time) < 90) continue; + m.touched = world.time - 70; + m.have -= 1; + if (m.have > 0) addObject(world, 'scrap', m.x + (world.random() - .5) * .6, m.z + (world.random() - .5) * .6, { height: .4 }); + else { if (m.kind === 'gate') setGate(world, m, 0); world.machines.splice(world.machines.indexOf(m), 1); } + } const rising = tideRising(world.time), rate = Math.abs(Math.cos(world.time / TIDE_PERIOD * Math.PI * 2 + TIDE_PHASE)); const night = daylight(world) < .45; for (const m of world.machines) { @@ -1495,6 +1504,7 @@ function finish(world, c) { const m = machineById(world, c.project); if (held && m && m.have < m.need) { m.have = Math.min(m.need, m.have + (held.kind === 'brass' ? 2 : 1)); + m.touched = world.time; c.carrying = null; removeObject(world, held); c.gesture = .3; diff --git a/public/tide-pool.js b/public/tide-pool.js index 4a4e05b..60789d8 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, -} from './tide-pool-world.js?v=25'; +} from './tide-pool-world.js?v=26'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -1083,17 +1083,18 @@ async function initialize() { const floor = S.kind === 'swimmer' ? c.y : footAt(world, c), surface = surfaceAt(world, c.x, c.z); if (surface - heightAt(world, c.x, c.z) < .02 || c.alt != null && c.alt > surface) continue; const top = c.sp === 'pylon' ? floor + (c.size + 1) * .15 * (.6 + .4 * (c.open ?? 1)) + .1 : floor + HEIGHT[c.sp]; + // A ring only where a body breaks the surface; whatever is wholly under water leaves the surface alone. const breaks = top > surface && floor < surface; - if (S.kind === 'swimmer' && surface - c.y > .12) continue; - const y = breaks ? surface + .004 : floor + .012; + if (!breaks) continue; + const y = surface + .004; const r = S.radius * (c.sp === 'tab' ? 1.6 : 1.25); - const strength = breaks ? .16 : .05; + const strength = .14; put(P.contact, m4.compose(v1.set(c.x, y, c.z), q.identity(), s3.set(r, 1, r)), ringColor.copy(ringBase).multiplyScalar(strength)); if (c.speed > .08) { const t = (c.phase * .12 + c.id * .37) % 1; const wide = r * (1 + t * 1.6); put(P.contact, m4.compose(v1.set(c.x, y, c.z), q.identity(), s3.set(wide, 1, wide)), - ringColor.copy(ringBase).multiplyScalar(strength * (1 - t) * Math.min(1, c.speed * 2))); + ringColor.copy(ringBase).multiplyScalar(strength * .8 * (1 - t) * Math.min(1, c.speed * 2))); } } } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 3b1eab7..feffe89 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From e680828377774bdbe5984dbb9d730aaaf694d6d3 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 09:25:28 -0700 Subject: [PATCH 16/28] Let the fish move as schools: they read a slow current shared across the shelf, all circle a structure the same way, align more strongly with less jitter, and a hungry school follows the plankton to fresh water instead of starving in place. --- public/tide-pool-world.js | 52 ++++++++++++++++++++++++++---------- public/tide-pool.js | 2 +- src/components/tide-pool.tsx | 2 +- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index a4f1d6b..32ba44a 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -1662,6 +1662,15 @@ function moveWalker(world, c, dt) { } // Swimming: a loose school that keeps to water, heads for deeper water as the tide drains, and scatters from ripples. +// A slow current shared by the whole shelf: the curl of a drifting field, so it swirls without gathering anywhere. +// Fish that feel it together turn together, which is what keeps a school moving as one shape. +export function drift(x, z, t) { + const e = .01; + const psi = (a, b) => Math.sin(a * .23 + t * .045) * Math.cos(b * .19 - t * .031) + .6 * Math.sin((a + b) * .13 - t * .027); + const vx = (psi(x, z + e) - psi(x, z - e)) / (2 * e), vz = -(psi(x + e, z) - psi(x - e, z)) / (2 * e); + const len = Math.hypot(vx, vz) || 1; + return { x: vx / len, z: vz / len }; +} function moveSwimmer(world, c, dt) { const S = SPECIES.tab; const depth = depthAt(world, c.x, c.z); @@ -1669,11 +1678,11 @@ function moveSwimmer(world, c, dt) { for (const o of world.creatures) { if (o === c || o.sp !== 'tab') continue; const dx = o.x - c.x, dz = o.z - c.z, d = Math.hypot(dx, dz); - if (d > 1.4 || d < 1e-4) continue; + if (d > 2 || d < 1e-4) continue; n++; cx += dx; cz += dz; ux += o.vx; uz += o.vz; if (d < .35) { ax -= dx / d * (.35 - d) * 6; az -= dz / d * (.35 - d) * 6; } } - if (n) { ax += cx / n * .6 + (ux / n - c.vx) * .9; az += cz / n * .6 + (uz / n - c.vz) * .9; } + if (n) { ax += cx / n * .5 + (ux / n - c.vx) * 1.4; az += cz / n * .5 + (uz / n - c.vz) * 1.4; } for (const o of world.creatures) { if (o.sp !== 'pylon' && o.sp !== 'breaker') continue; const dx = c.x - o.x, dz = c.z - o.z, d = Math.hypot(dx, dz); @@ -1701,8 +1710,24 @@ function moveSwimmer(world, c, dt) { } if (best > depth) { ax += bx * 2.5 * worry; az += bz * 2.5 * worry; } } + // A hungry fish follows the plankton, leaning toward richer water nearby; the school turns with it, so a school that + // has grazed its water out moves on to fresh water instead of starving where it is. + const hunger = clamp((.75 - c.energy) / .5, 0, 1); + if (hunger > .05) { + let best = world.plankton[cellAt(c.x, c.z)] + .02, bx = 0, bz = 0; + for (let k = 0; k < 6; k++) { + const a = k / 6 * Math.PI * 2 + c.phase * .1, x = c.x + Math.cos(a) * 1.6, z = c.z + Math.sin(a) * 1.6; + if (depthAt(world, x, z) < .12) continue; + const pl = world.plankton[cellAt(x, z)]; + if (pl > best) { best = pl; bx = Math.cos(a); bz = Math.sin(a); } + } + // Smoothed, so a fish turns toward food rather than twitching between samples. + c.fx = (c.fx || 0) + (bx - (c.fx || 0)) * Math.min(1, dt * .8); + c.fz = (c.fz || 0) + (bz - (c.fz || 0)) * Math.min(1, dt * .8); + ax += c.fx * 1.8 * hunger; az += c.fz * 1.8 * hunger; + } // Most of the school takes a tower as home once one stands: they find their way back to it from anywhere, - // then circle it in a slow orbit, drifting in and out through its frames. + // then circle it in a slow orbit, drifting in and out through its frames. Hunger loosens the hold. let home = null, homeD = Infinity; if (c.id % 4) for (const r of world.structures) { if (r.n < 8) continue; @@ -1711,10 +1736,12 @@ 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; - if (d > 7) { ax += dx / d * 1.3; az += dz / d * 1.3; } + const hold = 1 - hunger * .75; + if (d > 7) { ax += dx / d * 1.3 * hold; az += dz / d * 1.3 * hold; } else { - const pull = (d - ring) * .9, swirl = 1.1 * (c.id % 2 ? 1 : -1); - ax += dx / d * pull + (-dz / d) * swirl; az += dz / d * pull + (dx / d) * swirl; + // Every fish around one structure circles it the same way, so they go round as one. + const pull = (d - ring) * .9, swirl = 1.1 * (Math.floor(home.x * 3 + home.z * 7) % 2 ? 1 : -1); + ax += (dx / d * pull + (-dz / d) * swirl) * hold; az += (dz / d * pull + (dx / d) * swirl) * hold; } } // At night a lit beacon draws the school toward its light. @@ -1723,15 +1750,12 @@ function moveSwimmer(world, c, dt) { const dx = m.x - c.x, dz = m.z - c.z, d = Math.hypot(dx, dz); if (d < 9 && d > 1.2) { ax += dx / d * m.lit * 1.2; az += dz / d * m.lit * 1.2; } } - // At high water the school roams the flooded shelf along a slowly turning heading. - const high = tideOf(world.time); - if (high > .55 && depth > .15) { - c.heading += (world.random() - .5) * dt * 1.2; - const roam = 1.4 * (high - .55) * 2.2 * (home ? .3 : 1); - ax += Math.cos(c.heading) * roam; az += Math.sin(c.heading) * roam; - } + // The shared current always nudges a little, and at high water carries the school out across the flooded shelf. + const high = tideOf(world.time), flow = drift(c.x, c.z, world.time); + const roam = .35 + (high > .55 && depth > .15 ? 1.4 * (high - .55) * 2.2 * (home ? .3 : 1) : 0); + ax += flow.x * roam; az += flow.z * roam; const a = (world.random() - .5) * 2.2; - ax += Math.cos(c.phase * .3 + a) * .5; az += Math.sin(c.phase * .3 + a) * .5; + ax += Math.cos(c.phase * .3 + a) * .15; az += Math.sin(c.phase * .3 + a) * .15; c.vx += ax * dt; c.vz += az * dt; const sp = Math.hypot(c.vx, c.vz), max = S.speed * (depth < .1 ? .3 : 1), min = .25; if (sp > max) { c.vx *= max / sp; c.vz *= max / sp; } else if (sp < min && sp > 1e-5) { c.vx *= min / sp; c.vz *= min / sp; } diff --git a/public/tide-pool.js b/public/tide-pool.js index 60789d8..0c4745b 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, -} from './tide-pool-world.js?v=26'; +} from './tide-pool-world.js?v=28'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index feffe89..75d1f88 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From 985cbd4b777cf1a8009e2536e1d56cd4fbd77f24 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 09:30:45 -0700 Subject: [PATCH 17/28] Let the tide leave the rock wet: ground the water has just left stays dark and violet and dries slowly back to its usual tone, so higher ground, uncovered first, dries first. --- public/tide-pool-world.js | 6 ++++-- public/tide-pool.js | 14 ++++++++------ src/components/tide-pool.tsx | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index 32ba44a..bd3f02f 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -343,7 +343,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), - film: new Float32Array(N), plankton: new Float32Array(N), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), wall: new Float32Array(N), pin: new Uint8Array(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: [], names: [], bodies: [], poolCount: 0, seaCells: 0, terrainVersion: 0, disturbed: new Set(), spillDirty: true, @@ -1775,10 +1775,12 @@ function moveSwimmer(world, c, dt) { function environment(world, dt) { const light = daylight(world); - const { h, w, film, plankton } = world; + const { h, w, film, plankton, damp } = world; for (let k = 0; k < N; k++) { const d = w[k] - h[k], a = film[k]; film[k] = d > WET && d < 1.5 ? a + dt * .01 * light * (a + .04) * (1 - a) : a - dt * .002 * a; + // Ground the water has left stays damp for a while and dries slowly. + damp[k] = d > WET ? 1 : damp[k] * (1 - dt * .018); } // Plankton spreads through connected water and is renewed by the sea. if (world.step % 4 === 0) { diff --git a/public/tide-pool.js b/public/tide-pool.js index 0c4745b..c156471 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, -} from './tide-pool-world.js?v=28'; +} from './tide-pool-world.js?v=29'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -115,7 +115,7 @@ async function initialize() { shared.uNight = { value: 0 }; shared.uDusk = { value: 0 }; shared.uHaze = { value: new T.Vector4(0, 1, 0, 0) }; shared.uHazeZoom = { value: 7 }; shared.uHazeColor = { value: new T.Color(0x1a1524) }; - // The simulation grid as a texture: r = water surface, g = ground, b = set stone, a = film. + // The simulation grid as a texture: r = water surface, g = ground, b = set stone, a = how damp the ground still is. // Sampled with a cubic B-spline (four bilinear taps) so shorelines and hollows come out round, not grid-shaped. const GRID = ` uniform sampler2D uGrid; uniform vec4 uGridBox; @@ -285,7 +285,9 @@ 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(.03, .027, .037), vec3(.08, .073, .088), above) * grain; + vec3 rock = mix(vec3(.034, .03, .042), vec3(.08, .073, .088), 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); 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)); @@ -404,15 +406,15 @@ async function initialize() { openSea.renderOrder = 2; scene.add(openSea); - // Upload the simulation's grid: surface, ground, set stone, film. + // Upload the simulation's grid: surface, ground, set stone, dampness. const cellCount = COLS * ROWS, gridData = gridTexture.image.data; const toHalf = T.DataUtils.toHalfFloat; function uploadGrid() { - const { w, h, built, film, wall } = world; + const { w, h, built, damp, wall } = world; for (let k = 0; k < cellCount; k++) { const o = k * 4; // The water's floor is the ground, or the top of a wall of plates standing in it. - gridData[o] = toHalf(w[k]); gridData[o + 1] = toHalf(h[k] + wall[k]); gridData[o + 2] = toHalf(Math.min(1, built[k] * 3)); gridData[o + 3] = toHalf(film[k]); + gridData[o] = toHalf(w[k]); gridData[o + 1] = toHalf(h[k] + wall[k]); gridData[o + 2] = toHalf(Math.min(1, built[k] * 3)); gridData[o + 3] = toHalf(damp[k]); } gridTexture.needsUpdate = true; } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 75d1f88..138462d 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From b5c4710072c254477fd0e8f5e6dcf351f573b71b Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 09:44:07 -0700 Subject: [PATCH 18/28] Give artificers families: each carries a way of building, how tall, how many balconies, dwellings, and footings, that its children inherit with small changes; they now breed, seek brass when ready, and keep building where their parent built. --- public/tide-pool-world.js | 23 ++++++++++++++++++----- public/tide-pool.js | 2 +- src/components/tide-pool.tsx | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index bd3f02f..a57c71e 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -402,6 +402,9 @@ function spawn(world, sp, x, z, extra = {}) { }; 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' || 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. + if (sp === 'artificer') c.style = extra.style || { tall: .6 + world.random() * .8, reach: .6 + world.random() * .8, dwell: .6 + world.random() * .8, solid: .6 + world.random() * .8 }; world.creatures.push(c); return c; } @@ -529,7 +532,7 @@ function nearest(c, list, limit = Infinity) { } const looseOf = (world, kinds) => world.objects.filter(o => o.place === 'loose' && o.claimed === null && o.age > .6 && kinds.includes(o.kind)); // 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 REST = { scraper: 60, tab: 80, pylon: 90, collector: 90, mason: 90, breaker: 120, borer: 55, artificer: 50 }; const canBreed = (world, sp, c) => { const n = count(world, sp); if (n >= SPECIES[sp].max) return false; @@ -559,13 +562,16 @@ function breed(world, c) { if (ok && clear) at = { x, z }; } 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 }); + 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 }); 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; if (c.sp === 'tab') { child.vx = -c.vz; child.vz = c.vx; } if (c.sp === 'borer') child.home = settle(world, c) || child.home; if (c.sp === 'collector' || c.sp === 'mason') child.home = roomy(world, child, 4, 12, c.sp === 'mason') || child.home; + // A young artificer goes on building where its parent builds, so a family's structure takes on its way of building. + if (c.sp === 'artificer' && c.site) child.site = { x: c.site.x, z: c.site.z }; event(world, 'birth', c, null, label(child)); return child; } @@ -1026,6 +1032,8 @@ function placeBlock(world, c, delay = 0) { if (c.site) c.site.fresh = false; const at = (i, j, k) => world.blocks.get(blockKey(i, j, k)); const tally = { frame: 0, plate: 0, deck: 0, pod: 0, gear: 0, wheel: 0, lamp: 0, pipe: 0 }; + const style = c.style || { tall: 1, reach: 1, dwell: 1, solid: 1 }; + const want = { ...MIX, deck: MIX.deck * style.reach, pod: MIX.pod * style.dwell, plate: MIX.plate * style.solid }; let top = 0; for (const b of near) { tally[b.kind]++; top = Math.max(top, b.k); } const high = Math.ceil((TIDE_HIGH - BLOCK_Y0) / BLOCK), low = Math.floor((TIDE_LOW - BLOCK_Y0) / BLOCK); @@ -1067,7 +1075,7 @@ function placeBlock(world, c, delay = 0) { let v; if (kind === 'frame') { // Spread a little along the ground, rise where the level beneath is broad, and climb up out of the water. - v = 1 + (k === ground ? .4 - tally.frame * .02 - platesBeside * 1.2 : under * .25 + (under >= 3 ? .5 : 0) - Math.max(0, k - high - 6) * .15) + (k < high + 2 ? .7 : 0) + framesBeside * .1 - s * .8; + v = 1 + (k === ground ? (.4 - tally.frame * .02) * (2 - style.tall) - platesBeside * 1.2 : (under * .25 + (under >= 3 ? .5 : 0)) * style.tall - Math.max(0, k - high - 6 * style.tall) * .15) + (k < high + 2 ? .7 : 0) + framesBeside * .1 - s * .8; } else if (kind === 'plate') { // Plates make a solid footing at the waterline: they go in beside the frames, run on along a footing already // begun, and stack up to just under high water. The water treats a footing like ground, so a structure @@ -1076,7 +1084,7 @@ function placeBlock(world, c, delay = 0) { ? .5 + Math.min(hug, 4) * .1 + platesBeside * .35 + (below?.kind === 'plate' ? .5 : 0) : -3; } else if (kind === 'deck') { // Decks reach out as balconies over the water, and seldom cap a column that could still rise. - v = (dry ? .8 : -.6) + (!below ? .5 - s * .2 : bearing(below) ? -.5 : 0) + decksBeside * .15; + v = (dry ? .8 : -.6) + (!below ? (.5 - s * .2) * style.reach : bearing(below) ? -.5 : 0) + decksBeside * .15; } else if (kind === 'pod') { // A small dwelling, above the tide, sheltered beside frames. v = (dry ? .6 : -2) + framesBeside * .25 - tally.pod * .1 + (below?.kind === 'deck' ? .4 : 0) - SIDES.filter(([a, e]) => at(i + a, j + e, k)?.kind === 'pod').length * .35; @@ -1092,7 +1100,7 @@ function placeBlock(world, c, delay = 0) { v = framesBeside ? (below?.kind === 'pipe' ? .9 : .1) - tally.pipe * .12 : -3; } // A neighbourhood keeps a working mix: whatever is scarce nearby is wanted more. - v += (MIX[kind] - tally[kind] / near.length) * 4; + v += (want[kind] - tally[kind] / near.length) * 4; // The artificer favours what is within arm's reach, with a little whim. v += world.random() * .9 - Math.hypot(i - ci, j - cj) * .1; if (v > score) { score = v; best = { i, j, k, kind, s }; } @@ -1206,6 +1214,11 @@ function thinkArtificer(world, c) { if (c.hungry) return graze(world, c, .8); const held = objectById(world, c.carrying); if (held?.kind === 'brass' && c.energy > .7 && canBreed(world, 'artificer', c)) { work(c, 3); c.task = { kind: 'breed' }; return; } + // Well fed and rested, it goes looking for brass to cast a child from rather than building with it. + if (!held && c.energy > .75 && canBreed(world, 'artificer', c)) { + const source = brassSource(world, c, false); + if (source) return assign(world, c, 'fetch', source.object, { use: 'breed' }); + } // Most of the time an artificer adds parts to a structure; now and then it turns to a working machine. if (c.building === undefined || world.random() < .08) c.building = world.random() < .75; if (c.building) { diff --git a/public/tide-pool.js b/public/tide-pool.js index c156471..ee1ee46 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, -} from './tide-pool-world.js?v=29'; +} from './tide-pool-world.js?v=30'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 138462d..f615124 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From e42d85b3aa3d426461dbb762701f2a7a76d1c38b Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 09:50:08 -0700 Subject: [PATCH 19/28] Give the tide springs and neaps: its range swells and shrinks over twenty minutes, so some low waters lay the shelf bare, some high waters flood it deep, and at neaps the water barely moves. --- public/tide-pool-world.js | 6 +++++- public/tide-pool.js | 2 +- src/components/tide-pool.tsx | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index a57c71e..c0ee361 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -36,7 +36,11 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.z - b.z); 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)); +// Springs and neaps: the range of the tide swells and shrinks over twenty minutes, so some low waters lay the shelf bare +// and some high waters flood it deep, while at neaps the water barely moves. The shelf opens at springs. +export const SPRING_PERIOD = 1200; +export const tideRange = t => 1 + .32 * Math.cos(t / SPRING_PERIOD * Math.PI * 2); +export const waterLevel = world => (TIDE_LOW + TIDE_HIGH) / 2 + (tideOf(world.time) - .5) * (TIDE_HIGH - TIDE_LOW) * tideRange(world.time); export const daylight = world => { const s = Math.sin(world.time / DAY_PERIOD * Math.PI * 2 + .45); const t = clamp((s + .25) / .6, 0, 1); diff --git a/public/tide-pool.js b/public/tide-pool.js index ee1ee46..306c464 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, -} from './tide-pool-world.js?v=30'; +} from './tide-pool-world.js?v=31'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index f615124..94344b8 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From 53bb72c0afb4d3eed4387c6aa38ddcbb51018a65 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 09:53:38 -0700 Subject: [PATCH 20/28] Widen the sharp band of the miniature view on tall screens, so a phone keeps as much of the shelf in focus as a desktop. --- public/tide-pool.js | 4 +++- src/components/tide-pool.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/public/tide-pool.js b/public/tide-pool.js index 306c464..bdde2de 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1327,7 +1327,9 @@ async function initialize() { let at = .5; if (focus) { v1.set(focus.x, SPECIES[focus.sp].kind === 'swimmer' ? focus.y : footAt(world, focus), focus.z).project(camera); at = clamp((v1.y + 1) / 2, .2, .8); } const amount = clamp(.55 + (view.zoom - 1.4) * .15, .55, 1) * 2.3 * renderer.getPixelRatio(); - tilt.forEach(pass => { pass.uniforms.uFocus.value = at; pass.uniforms.uAmount.value = amount; }); + // On a tall screen the sharp band takes a larger share of the height, so a phone keeps as much of the shelf in focus. + const band = .13 * clamp(stage.clientHeight / Math.max(1, stage.clientWidth) * .75, 1, 1.8); + tilt.forEach(pass => { pass.uniforms.uFocus.value = at; pass.uniforms.uAmount.value = amount; pass.uniforms.uBand.value = band; }); } if (composer) composer.render(); else renderer.render(scene, camera); placeLabels(); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 94344b8..f634af7 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From 51b6175a73b2556022277a893b699718812c5fe5 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 10:01:52 -0700 Subject: [PATCH 21/28] Bring storms in off the sea every fifteen to twenty-five minutes: for about a minute the water rises and runs rough under an overcast sky, the waves batter what stands at the waterline, the fish hide among the frames, and when it blows over the sea leaves flotsam. --- public/tide-pool-world.js | 45 +++++++++++++++++++++++++++++++++--- public/tide-pool.js | 33 +++++++++++++++----------- scripts/tide-pool.test.mjs | 30 +++++++++++++++++++++--- src/components/tide-pool.tsx | 2 +- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index c0ee361..c265800 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -40,7 +40,15 @@ export const tideRising = t => Math.cos(t / TIDE_PERIOD * Math.PI * 2 + TIDE_PHA // and some high waters flood it deep, while at neaps the water barely moves. The shelf opens at springs. export const SPRING_PERIOD = 1200; export const tideRange = t => 1 + .32 * Math.cos(t / SPRING_PERIOD * Math.PI * 2); -export const waterLevel = world => (TIDE_LOW + TIDE_HIGH) / 2 + (tideOf(world.time) - .5) * (TIDE_HIGH - TIDE_LOW) * tideRange(world.time); +// Storms: now and then one comes in off the sea for about a minute. It rises, holds, and blows over. +export const STORM_LENGTH = 75; +export const stormAt = world => { + const s = world.storm, u = s ? (world.time - s.start) / STORM_LENGTH : -1; + return u > 0 && u < 1 ? Math.sin(Math.PI * u) ** 2 : 0; +}; +// A storm piles the sea up against the shelf. +export const waterLevel = world => (TIDE_LOW + TIDE_HIGH) / 2 + (tideOf(world.time) - .5) * (TIDE_HIGH - TIDE_LOW) * tideRange(world.time) + + stormAt(world) * .2; export const daylight = world => { const s = Math.sin(world.time / DAY_PERIOD * Math.PI * 2 + .45); const t = clamp((s + .25) / .6, 0, 1); @@ -353,10 +361,12 @@ export function createWorld(seed = 41) { 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, debuted: {}, + nextWash: 45, nextCensus: 0, nextSurvey: 0, nextSpill: 0, step: 0, debuted: {}, storm: null, }; shape(world); spill(world); + // The first storm comes some time after the builders have begun. + world.storm = { start: 700 + world.random() * 400, clock: 0, announced: false }; // Begin at mid tide with the pool full. const tide = waterLevel(world); for (let k = 0; k < N; k++) { @@ -1707,7 +1717,7 @@ function moveSwimmer(world, c, dt) { } // Startled beside a structure, the school darts in among its frames and holds there a moment instead of scattering. const refuge = c.id % 4 ? world.structures.find(t => t.n >= 8 && distance(t, c) < t.reach + 2.5) : null; - if (refuge && (world.ripples.some(r => world.time - r.born < .6 && distance(r, c) < 2.5) || + if (refuge && (stormAt(world) > .35 || world.ripples.some(r => world.time - r.born < .6 && distance(r, c) < 2.5) || world.creatures.some(o => (o.sp === 'breaker' || o.sp === 'pylon' && o.strike > .5) && distance(o, c) < 1.6))) c.hiding = world.time + 2.5 + world.random(); if (refuge && c.hiding > world.time) { const dx = refuge.x - c.x, dz = refuge.z - c.z, d = Math.hypot(dx, dz) || 1, core = refuge.reach * .5; @@ -1835,6 +1845,34 @@ function debuts(world) { } } +// While a storm is on, the waves work at whatever stands at the waterline: a part takes a blow every second or two, +// light parts break away after two, footings only after four, and anything battered hangs askew until an artificer mends it. +// When it blows over, the sea leaves a little flotsam behind. +function weather(world, dt) { + const s = world.storm; + if (world.time > s.start + STORM_LENGTH) { + event(world, 'calm', null); + for (let n = 0; n < 3; n++) { + const x = X0 + 4 + world.random() * (X1 - X0 - 8), z = 9 + world.random() * 3, r = world.random(); + if (depthAt(world, x, z) > .2) addObject(world, r < .4 ? 'brass' : r < .7 ? 'shell' : 'pebble', x, z, { height: .8 }); + } + world.storm = { start: world.time + 900 + world.random() * 600, clock: 0, announced: false }; + return; + } + const strength = stormAt(world); + if (strength <= 0) return; + if (!s.announced) { s.announced = true; event(world, 'storm', null); } + s.clock -= dt; + if (s.clock > 0 || !world.blocks.size) return; + s.clock = 1.4 / Math.max(.25, strength); + const level = waterLevel(world); + const surf = [...world.blocks.values()].filter(b => world.time > b.born && blockBottom(b) < level + .5 && blockBottom(b) + BLOCK > level - .35); + if (!surf.length) return; + const b = surf[Math.floor(world.random() * surf.length)]; + b.wear++; + world.blockVersion++; + if (b.wear >= (bearing(b) ? 4 : 2)) removeBlock(world, b, true); +} function sea(world) { debuts(world); if (tideOf(world.time) < .55) return; @@ -1874,6 +1912,7 @@ export function advanceWorld(world, elapsed) { if (world.disturbed.size) slump(world); if (world.spillDirty && world.time >= world.nextSpill) { spill(world); world.nextSpill = world.time + .5; } water(world, dt); + weather(world, dt); machinery(world, dt); environment(world, dt); for (const o of [...world.objects]) { diff --git a/public/tide-pool.js b/public/tide-pool.js index bdde2de..971c7a5 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, -} from './tide-pool-world.js?v=31'; + heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, BLOCK, BLOCK_Y0, blockKey, footAt, debrisAt, footing, stormAt, +} from './tide-pool-world.js?v=32'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -112,7 +112,7 @@ async function initialize() { const LAMPS = mobile ? 8 : 16; shared.uLamps = { value: Array.from({ length: LAMPS }, () => new T.Vector4(0, 0, 0, 0)) }; shared.uLampColor = { value: new T.Color(0xf0dcf6) }; - shared.uNight = { value: 0 }; shared.uDusk = { value: 0 }; + shared.uNight = { value: 0 }; shared.uDusk = { value: 0 }; shared.uStorm = { value: 0 }; shared.uHaze = { value: new T.Vector4(0, 1, 0, 0) }; shared.uHazeZoom = { value: 7 }; shared.uHazeColor = { value: new T.Color(0x1a1524) }; // The simulation grid as a texture: r = water surface, g = ground, b = set stone, a = how damp the ground still is. @@ -305,23 +305,23 @@ async function initialize() { 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, uFlow: shared.uFlow, uOpen: { value: 0 }, uTide: shared.uWater, - uLamps: shared.uLamps, uLampColor: shared.uLampColor, uNight: shared.uNight, uDusk: shared.uDusk, + uLamps: shared.uLamps, uLampColor: shared.uLampColor, uNight: shared.uNight, uDusk: shared.uDusk, uStorm: shared.uStorm, uHaze: shared.uHaze, uHazeZoom: shared.uHazeZoom, uHazeColor: shared.uHazeColor }, vertexShader: `${GRID} - uniform float uOpen, uTide, uTime; varying vec3 vW; varying float vDepth; + uniform float uOpen, uTide, uTime, uStorm; varying vec3 vW; varying float vDepth; 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 * clamp(vDepth, 0., 1.); + 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; 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; - uniform vec4 uLamps[${LAMPS}]; uniform vec3 uLampColor, uHazeColor; uniform float uNight, uDusk, uHazeZoom; uniform vec4 uHaze; + uniform vec4 uLamps[${LAMPS}]; uniform vec3 uLampColor, uHazeColor; uniform float uNight, uDusk, uHazeZoom, uStorm; uniform vec4 uHaze; uniform sampler2D uFlow; uniform vec4 uGridBox; uniform float uOpen; ${WAVES} void main() { @@ -334,7 +334,8 @@ async function initialize() { 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; + // 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); 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); @@ -387,7 +388,7 @@ async function initialize() { // 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; + float foam = (smoothstep(.06, .012, d) * edge * smoothstep(.35, .9, lapping) * (.5 + uStorm * .5) + streak * (1. + uStorm * 1.5)) * 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. @@ -1300,10 +1301,14 @@ async function initialize() { // The water's glint light sits on the glitter path: mirrored about a near-level surface, so ripples catch it. const level = v2.set(.12 + Math.sin(dayAngle) * .05, 1, -.1).normalize(), eye = shared.uView.value; shared.uSun.value.copy(level).multiplyScalar(2 * level.dot(eye)).sub(eye).normalize(); - sun.intensity = 4.6 - 2.2 * night; + // A storm is overcast: the sun and sky dim while it blows, and the water glints less. + const storm = stormAt(world); + shared.uStorm.value = storm; + shared.uLight.value *= 1 - .3 * storm; + sun.intensity = (4.6 - 2.2 * night) * (1 - .5 * storm); sun.color.setRGB(.98 - .36 * night, .95 - .3 * night, .95 + .05 * night); - hemi.intensity = .85 - .4 * night; - scene.environmentIntensity = 1.15 - .75 * night; + hemi.intensity = (.85 - .4 * night) * (1 - .25 * storm); + scene.environmentIntensity = (1.15 - .75 * night) * (1 - .35 * storm); fill.intensity = 1.6 - .9 * night; @@ -1444,6 +1449,8 @@ async function initialize() { case 'unbolt': return `${who} tore a ${e.detail} off a structure.`; case 'collapse': return `Part of a structure gave way: ${e.detail} pieces fell.`; case 'fall': return `${who} fell from a structure.`; + case 'storm': return 'A storm is coming in off the sea.'; + case 'calm': return 'The storm has passed.'; case 'move-in': return `${who} moved into a pod.`; case 'nest': return `${who} moved its hoard up onto a deck.`; case 'loosen': return `${who} worked a ${e.detail} loose.`; @@ -1535,7 +1542,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', 'debut', 'form', 'complete', 'wreck', 'plan', 'found', 'collapse', 'fall'].includes(e.type)); + const fresh = world.events.filter(e => e.time > tickerSeen && ['birth', 'end', 'steal', 'arrive', 'crack', 'wash', 'debut', 'form', 'complete', 'wreck', 'plan', 'found', 'collapse', 'fall', 'storm', 'calm'].includes(e.type)); if (!fresh.length) return; tickerSeen = world.events.at(-1).time; for (const e of fresh.slice(-3)) { diff --git a/scripts/tide-pool.test.mjs b/scripts/tide-pool.test.mjs index fd463c6..85cc3d1 100644 --- a/scripts/tide-pool.test.mjs +++ b/scripts/tide-pool.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createWorld, advanceWorld, dropObject, offerObject, onShelf, heightAt, depthAt, surfaceAt, bodyAt, waterLevel, SPECIES, SPECIES_ORDER, LIMIT, TIDE_PERIOD, TIDE_LOW, TIDE_HIGH, START_POOL, BURROW, WET, describeWorld, goalText, - BLOCK, BLOCK_Y0, X0, Z0, CELL, blockKey, removeBlock, footAt, + BLOCK, BLOCK_Y0, X0, Z0, CELL, blockKey, removeBlock, footAt, stormAt, STORM_LENGTH, } from '../public/tide-pool-world.js'; const cellX = i => X0 + (i + .5) * CELL, cellZ = j => Z0 + (j + .5) * CELL; @@ -42,7 +42,7 @@ test('borers dig burrows and tunnels, pools form on their own, and the ground vi const before = Float32Array.from(world.h); run(world, 62); const borer = world.creatures.find(c => c.sp === 'borer'); - const home = { ...borer.home }; + const home = { ...borer.home }, start = heightAt(world, home.x, home.z); const seen = new Set(); for (let minute = 0; minute < 12; minute++) { run(world, 60); @@ -51,7 +51,9 @@ test('borers dig burrows and tunnels, pools form on their own, and the ground vi let changed = 0; for (let k = 0; k < before.length; k++) if (Math.abs(world.h[k] - before[k]) > .15) changed++; assert.ok(changed > 200, `only ${changed} cells changed`); - assert.ok(heightAt(world, home.x, home.z) < BURROW + .3 || !world.creatures.includes(borer), 'a burrow was sunk'); + // Sunk to burrow depth, or dug well down from wherever it began, since some burrows start on high ground. + const now = heightAt(world, home.x, home.z); + assert.ok(now < BURROW + .3 || now < start - .6 || !world.creatures.includes(borer), 'a burrow was sunk'); assert.ok(seen.has('dump'), 'spoil was banked'); assert.ok(seen.has('form') || world.bodies.length > 1, 'a new pool formed'); }); @@ -268,3 +270,25 @@ test('plates standing in the water are solid to it, and hold the ground beneath run(world, 1); assert.equal(world.h[k], before, 'the ground under it stays put'); }); + +test('a storm raises the water, batters what stands at the waterline, and blows over leaving flotsam', () => { + const world = createWorld(); + run(world, 1); + // A column of frames with a deck at the waterline, in the first pool. + const i = Math.floor((pool.x - X0) / CELL), j = Math.floor((pool.z - Z0) / CELL); + const ground = Math.floor((world.h[j * 128 + i] - BLOCK_Y0) / BLOCK); + const level = Math.floor((waterLevel(world) - BLOCK_Y0) / BLOCK); + for (let k = ground; k <= level + 1; k++) part(world, i, j, k, 'frame'); + const standing = world.blocks.size, calm = waterLevel(world); + world.storm.start = world.time + .1; + run(world, STORM_LENGTH / 2); + assert.ok(stormAt(world) > .9, 'the storm is at its height'); + assert.ok(waterLevel(world) > calm + .12, 'the sea is piled up'); + assert.ok([...world.blocks.values()].some(b => b.wear > 0) || world.blocks.size < standing, 'the waves work at the waterline'); + const objects = world.objects.length; + run(world, STORM_LENGTH / 2 + 2); + assert.equal(stormAt(world), 0, 'it has blown over'); + assert.ok(world.events.some(e => e.type === 'storm') && world.events.some(e => e.type === 'calm')); + assert.ok(world.objects.length > objects, 'the sea leaves flotsam behind'); + assert.ok(world.storm.start > world.time + 800, 'the next storm is a long way off'); +}); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index f634af7..32a6d70 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From 1164c8bab76d26edfe4ab390824d4cd652a453c6 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 10:52:37 -0700 Subject: [PATCH 22/28] Let more of the structures' lights come on: tide wheels may hang beside one or two frames, lamps cap tall columns near the top of a structure, and both are a slightly larger share of what gets built. --- public/tide-pool-world.js | 8 +++++--- public/tide-pool.js | 2 +- src/components/tide-pool.tsx | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index c265800..3099cf2 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -902,7 +902,7 @@ export const blockCenter = b => ({ x: X0 + (b.i + .5) * CELL, z: Z0 + (b.j + .5) export const blockBottom = b => BLOCK_Y0 + b.k * BLOCK; const groundLevel = (world, i, j) => Math.floor((world.h[j * COLS + i] - BLOCK_Y0) / BLOCK); const SIDES = [[1, 0], [-1, 0], [0, 1], [0, -1]]; -const MIX = { frame: .5, plate: .12, deck: .16, pod: .07, gear: .03, wheel: .04, lamp: .03, pipe: .05 }; +const MIX = { frame: .47, plate: .12, deck: .16, pod: .07, gear: .03, wheel: .06, lamp: .05, pipe: .04 }; const bearing = b => !!b && (b.kind === 'frame' || b.kind === 'pipe' || b.kind === 'plate'); // How much strain a part of this kind at (i, j, k) would carry: 0 on the ground, one more per cell of overhang. Infinity if it cannot stand. function strain(world, i, j, k, kind) { @@ -1103,10 +1103,12 @@ function placeBlock(world, c, delay = 0) { // A small dwelling, above the tide, sheltered beside frames. v = (dry ? .6 : -2) + framesBeside * .25 - tally.pod * .1 + (below?.kind === 'deck' ? .4 : 0) - SIDES.filter(([a, e]) => at(i + a, j + e, k)?.kind === 'pod').length * .35; } else if (kind === 'lamp') { - v = k >= top && dry && !above && below?.kind !== 'lamp' ? .3 + (k - high) * .12 - tally.lamp * .7 : -3; + // A lamp caps a column near the top of the structure once it stands well clear of the water, where its light + // carries; the taller the column, the likelier. + v = k >= top - 1 && k - high >= 4 && !above && bearing(below) ? .7 + (k - high - 4) * .2 - tally.lamp * .6 : -3; } else if (kind === 'wheel') { // A tide wheel hangs off an outside frame, low enough for the current to turn it and charge it. - v = k >= low && k < high - 1 && framesBeside === 1 ? .6 - tally.wheel * .4 : -3; + v = k >= low && k < high - 1 && framesBeside >= 1 && framesBeside <= 2 ? .7 - tally.wheel * .35 : -3; } else if (kind === 'gear') { // Gears go where the tide runs through. v = k >= low && k < high && framesBeside ? .5 - tally.gear * .3 : -3; diff --git a/public/tide-pool.js b/public/tide-pool.js index 971c7a5..feea907 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, -} from './tide-pool-world.js?v=32'; +} from './tide-pool-world.js?v=33'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 32a6d70..92f4923 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From aad68b1638bdee3237b887348aa83fc5f36eb8de Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 11:00:52 -0700 Subject: [PATCH 23/28] Let the plankton glow at night where the water is stirred: anything moving through it leaves a trail of cool sparks, and anything dropped in sends out a glowing ring, brighter where the plankton is rich. --- public/tide-pool.js | 54 ++++++++++++++++++++++++++++++++++++ src/components/tide-pool.tsx | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/public/tide-pool.js b/public/tide-pool.js index feea907..7fc5ffb 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -113,6 +113,12 @@ async function initialize() { shared.uLamps = { value: Array.from({ length: LAMPS }, () => new T.Vector4(0, 0, 0, 0)) }; shared.uLampColor = { value: new T.Color(0xf0dcf6) }; shared.uNight = { value: 0 }; shared.uDusk = { value: 0 }; shared.uStorm = { value: 0 }; + // At night the plankton glows where the water is stirred: the plankton itself, and the bodies moving through the water. + const WAKES = mobile ? 12 : 24; + shared.uWakes = { value: Array.from({ length: WAKES }, () => new T.Vector4(0, 0, 0, 0)) }; + const planktonTexture = new T.DataTexture(new Uint16Array(COLS * ROWS), COLS, ROWS, T.RedFormat, T.HalfFloatType); + planktonTexture.magFilter = planktonTexture.minFilter = T.LinearFilter; planktonTexture.needsUpdate = true; + shared.uPlankton = { value: planktonTexture }; shared.uHaze = { value: new T.Vector4(0, 1, 0, 0) }; shared.uHazeZoom = { value: 7 }; shared.uHazeColor = { value: new T.Color(0x1a1524) }; // The simulation grid as a texture: r = water surface, g = ground, b = set stone, a = how damp the ground still is. @@ -306,6 +312,7 @@ async function initialize() { uniforms: { uTime: shared.uTime, uLight: shared.uLight, uRipples: shared.uRipples, uSun: shared.uSun, uView: shared.uView, uGrid: shared.uGrid, uGridBox: shared.uGridBox, uFlow: shared.uFlow, uOpen: { value: 0 }, uTide: shared.uWater, uLamps: shared.uLamps, uLampColor: shared.uLampColor, uNight: shared.uNight, uDusk: shared.uDusk, uStorm: shared.uStorm, + uWakes: shared.uWakes, uPlankton: shared.uPlankton, uHaze: shared.uHaze, uHazeZoom: shared.uHazeZoom, uHazeColor: shared.uHazeColor }, vertexShader: `${GRID} uniform float uOpen, uTide, uTime, uStorm; varying vec3 vW; varying float vDepth; @@ -322,6 +329,7 @@ async function initialize() { fragmentShader: ` varying vec3 vW; varying float vDepth; 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 sampler2D uFlow; uniform vec4 uGridBox; uniform float uOpen; ${WAVES} void main() { @@ -378,6 +386,31 @@ async function initialize() { col += vec3(.86, .84, 1.) * star; sparkle += star; } + // At night the plankton lights where the water is stirred: in a trail behind anything moving through it, and in + // the rings where something drops in. Brighter where the plankton is rich, dim where it has been grazed out. + if (uNight > .02) { + float stir = 0.; + for (int i = 0; i < ${WAKES}; i++) { + vec4 wk = uWakes[i]; + float sp = length(wk.zw); + if (sp < .02) continue; + vec2 dir = wk.zw / sp, dp = vW.xz - wk.xy; + float along = dot(dp, dir), across = dot(dp, vec2(-dir.y, dir.x)); + float trail = along < 0. ? exp(along / (.25 + sp * .6)) : exp(-along * along * 60.); + stir += trail * exp(-across * across / (.004 + max(-along, 0.) * .02)) * min(sp * 1.5, 1.); + } + for (int i = 0; i < 12; i++) { + float age = uTime - uRipples[i].z; + if (age <= 0. || age > 3.) continue; + float dd = length(vW.xz - uRipples[i].xy); + stir += exp(-pow((dd - age * .8) * 9., 2.)) * (1. - age / 3.) * 1.5; + } + float pl = texture2D(uPlankton, (vW.xz - uGridBox.xy) / uGridBox.zw).r; + float spark = fract(sin(dot(floor(vW.xz * 22.) + floor(uTime * 4.), vec2(12.9898, 78.233))) * 43758.5453); + float glow = min(stir, 2.) * (.25 + pl * 1.5) * uNight * (.3 + .7 * step(.7, spark)) * smoothstep(.02, .12, d); + col += vec3(.5, .6, 1.) * glow * .55; + sparkle += glow * .45; + } // At dusk and dawn the pools take the colour of the sky. col = mix(col, col * vec3(1.1, .95, 1.04) + vec3(.05, .02, .04), uDusk * .6); // Fast water carries thin streaks of foam in the direction it runs. @@ -419,6 +452,26 @@ async function initialize() { } gridTexture.needsUpdate = true; } + let planktonClock = 0; + function uploadPlankton(dt) { + planktonClock -= dt; + if (planktonClock > 0) return; + planktonClock = .25; + const data = planktonTexture.image.data, { plankton } = world; + for (let k = 0; k < cellCount; k++) data[k] = toHalf(plankton[k]); + planktonTexture.needsUpdate = true; + } + // Bodies moving through water, nearest the view first, for the glowing wakes. + function gatherWakes() { + const moving = []; + for (const c of world.creatures) { + if (c.speed < .05 || c.alt != null || depthAt(world, c.x, c.z) < .05) continue; + const vx = SPECIES[c.sp].kind === 'swimmer' ? c.vx : Math.sin(c.angle) * c.speed, vz = SPECIES[c.sp].kind === 'swimmer' ? c.vz : Math.cos(c.angle) * c.speed; + moving.push({ x: c.x, z: c.z, vx, vz, d: (c.x - view.x) ** 2 + (c.z - view.z) ** 2 }); + } + moving.sort((a, b) => a.d - b.d); + 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) { @@ -1292,6 +1345,7 @@ async function initialize() { shared.uWater.value = waterLevel(world); uploadGrid(); uploadFlow(1 / 30); + 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. const dayAngle = world.time / 420 * Math.PI * 2; diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 92f4923..556475a 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From 9fa9f540af90ebb4d0b7e0e24ee305d4a177950f Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 11:05:00 -0700 Subject: [PATCH 24/28] Halve the caustic light on the pool floors, so the netted light stays as a faint shimmer instead of a web over everything. --- public/tide-pool.js | 2 +- src/components/tide-pool.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/tide-pool.js b/public/tide-pool.js index 7fc5ffb..06beb28 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -188,7 +188,7 @@ async function initialize() { // Water absorbs warm light first: deeper floors go cooler and a little darker, but stay easy to see, as in a clear pool. vec3 absorbed = gl_FragColor.rgb * mix(vec3(.84, .8, .96), vec3(.5, .47, .68), clamp(depth * .6, 0., 1.)); gl_FragColor.rgb = mix(gl_FragColor.rgb, absorbed, under); - gl_FragColor.rgb += vec3(.8, .72, .95) * caustic(vWorldC.xz, uTime * .5) * under * exp(-max(depth, 0.) * 1.8) * .1 * uLight; + gl_FragColor.rgb += vec3(.8, .72, .95) * caustic(vWorldC.xz, uTime * .5) * under * exp(-max(depth, 0.) * 1.8) * .05 * 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(.82, .76, .92) * lap * .14 * uLight; diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 556475a..5878bcd 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From 5c3fe1f4a20b8564ca623cef5003a8a9e445bd6d Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 11:08:22 -0700 Subject: [PATCH 25/28] Let the wandering camera follow the biggest school as it swims, favour it at night when its wake glows, watch the largest structure through a storm, and not show the school twice running. --- public/tide-pool.js | 34 ++++++++++++++++++++++++++++++---- src/components/tide-pool.tsx | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/public/tide-pool.js b/public/tide-pool.js index 06beb28..4607721 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1214,7 +1214,12 @@ async function initialize() { const WEIGHT = { collapse: 9, fall: 6, form: 5, found: 5, debut: 4, erect: 3.5, complete: 3, birth: 3, arrive: 2.5, repair: 2 }; function candidates() { const light = daylight(world), picks = []; - const add = (x, z, score, zoom) => { if (Number.isFinite(x + z) && onShelf(world, x, z)) picks.push({ x, z, score, zoom }); }; + const add = (x, z, score, zoom) => { + if (!Number.isFinite(x + z) || !onShelf(world, x, z)) return null; + const pick = { x, z, score, zoom }; + picks.push(pick); + return pick; + }; for (const e of world.events) { const age = world.time - e.time; if (age > 45 || e.x == null || !WEIGHT[e.type]) continue; @@ -1229,16 +1234,32 @@ async function initialize() { } add(t.x, t.z, 1 + t.n / 70 + lights * (.4 + dusk), clamp(3 + t.reach, 3.4, 6)); } - const fish = world.creatures.filter(c => c.sp === 'tab'); - if (fish.length > 4) add(fish.reduce((a, c) => a + c.x, 0) / fish.length, fish.reduce((a, c) => a + c.z, 0) / fish.length, 1.4, 3.4); + // The school, where the most fish swim together; at night its glowing wake is the best thing on the shelf. + const school = schoolAt(); + const followed = school && school.n > 3 && add(school.x, school.z, 1 + school.n * .1 + dark * 3, 3.2); + if (followed) followed.school = true; + // In a storm, the largest structure taking the waves. + const storm = stormAt(world); + if (storm > .1 && world.structures.length) { const t = [...world.structures].sort((a, b) => b.n - a.n)[0]; add(t.x, t.z, 4 + storm * 4, 4.2); } return picks; } + function schoolAt() { + const fish = world.creatures.filter(c => c.sp === 'tab'); + let best = null; + for (const a of fish) { + const near = fish.filter(b => Math.hypot(a.x - b.x, a.z - b.z) < 2); + if (!best || near.length > best.length) best = near; + } + return best && { x: best.reduce((s, c) => s + c.x, 0) / best.length, z: best.reduce((s, c) => s + c.z, 0) / best.length, n: best.length }; + } function direct(dt) { const now = performance.now(), last = director.shot; const urgent = last && world.events.some(e => (e.type === 'collapse' || e.type === 'fall') && world.time - e.time < 2 && e.x != null && Math.hypot(e.x - last.x, e.z - last.z) > 1); if (!last || now > director.until || urgent) { - const picks = candidates().map(p => ({ ...p, score: p.score + Math.random() * .8 - (last && Math.hypot(p.x - last.x, p.z - last.z) < 2 ? 1.5 : 0) })); + // Something else next time: not the same place, and not the school twice running. + const picks = candidates().map(p => ({ ...p, score: p.score + Math.random() * .8 - (last && Math.hypot(p.x - last.x, p.z - last.z) < 2 ? 1.5 : 0) - + (last?.school && p.school ? 3 : 0) })); picks.sort((a, b) => b.score - a.score); // Now and then a wide shot of the whole shelf between the close ones. director.wide = !urgent && !director.wide && Math.random() < .3; @@ -1247,6 +1268,11 @@ async function initialize() { director.until = now + (director.wide ? 14000 : 20000 + Math.random() * 12000); } director.phase += dt; + // A school swims on, so a shot of one keeps it in view. + if (director.shot.school) { + const school = schoolAt(); + if (school) { director.shot.x += (school.x - director.shot.x) * Math.min(1, dt * .4); director.shot.z += (school.z - director.shot.z) * Math.min(1, dt * .4); } + } // Never quite still: a slow circle around the subject, and a slow breath in and out. const shot = director.shot, t = director.phase; goal.x = shot.x + Math.cos(t * .07) * .7; goal.z = shot.z + Math.sin(t * .053) * .45; diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 5878bcd..64350a5 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() { {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From abb235f9785fe71b2ffd5e8de8dcd9b995a08da7 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 11:09:40 -0700 Subject: [PATCH 26/28] Describe the tide's springs and neaps, drying rock, storms, glowing plankton, schooling, and artificer families in the tide pool's notes. --- src/components/tide-pool.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 64350a5..5a8ebe7 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,12 +1,12 @@ 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 and makes its home circling a structure, darting in among its frames when startled. 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 and feels for deeper water as the tide drains." }, { 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." }, { id: "breaker", code: "BR", name: "Breaker", note: "Hunts scrapers. Breaks walls down to reach them, and tears at a structure's footing to bring a pod down." }, { id: "borer", code: "BO", name: "Borer", note: "Digs. Sinks a burrow, then tunnels outward toward other water and banks the spoil beside it." }, - { id: "artificer", code: "AR", name: "Artificer", note: "Builds from brass and salvage: structures in the water, one small part at a time, and sluice gates and beacons. Mends parts that breakers work loose." }, + { id: "artificer", code: "AR", name: "Artificer", note: "Builds from brass and salvage: structures in the water, one small part at a time, and sluice gates and beacons. Each has its own way of building, which its children inherit. Mends parts that breakers and storms work loose." }, ]; const TOOLS = [ @@ -128,7 +128,7 @@ export function TidePoolContent() {
    Inside the pool -

    Everything here is one surface: a shelf of ground that the tide washes over every four minutes. 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. Day turns to night every seven minutes, and the film on each floor grows only in wet light.

    +

    Everything here is one surface: a shelf of ground that the tide washes over every four minutes. The tide's reach swells and shrinks over twenty minutes, so some low waters lay the shelf bare, and the rock stays dark where the water has just left it. 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.

      {SPECIES.map(s =>
    • {s.code} {s.name}. {s.note}
    • )}
    -- 2.51.2 From 53fbcad6e73a99586b0d53883d5ddd35b56eb90b Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 11:12:40 -0700 Subject: [PATCH 27/28] Brighten a pod's window when its scraper is home, so the lived-in pods light the night. --- public/tide-pool.js | 2 +- src/components/tide-pool.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/tide-pool.js b/public/tide-pool.js index 4607721..18a6f74 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1061,7 +1061,7 @@ async function initialize() { const shine = lit * (.6 + .4 * Math.sin(world.time * .7 + f.b.key)); if (f.b.kind === 'pod') { // Someone at home shows as a faint glow in the window, brighter when there is power. - const home = f.b.resident != null && creatureById(world, f.b.resident)?.perch === f.b.key ? .35 : 0; + const home = f.b.resident != null && creatureById(world, f.b.resident)?.perch === f.b.key ? .6 : 0; toWorld(v3, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83); emit(v3.x, v3.y, v3.z, Math.max(f.power * .8, home * 1.2)); box(P.mcLamp, f.face[0] * H * .83, BLOCK * .45, f.face[1] * H * .83, .07, .07, .07, 0, 0, 0, diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 5a8ebe7..7f02612 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -141,7 +141,7 @@ export function TidePoolContent() {
    {/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2 From fb92a93504b41645d5b51aa675e15b3a86916c4c Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 14:01:17 -0700 Subject: [PATCH 28/28] Add the log of the autonomous tide pool improvements: what changed in each step, why, how it was checked, and what remains uncertain. --- .claude/tide-pool-log.md | 168 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 .claude/tide-pool-log.md diff --git a/.claude/tide-pool-log.md b/.claude/tide-pool-log.md new file mode 100644 index 0000000..b4aa274 --- /dev/null +++ b/.claude/tide-pool-log.md @@ -0,0 +1,168 @@ +# Tide pool log + +Working toward: more beautiful and more fascinating to watch, without making it busier. +Each step is committed on its own and not pushed. Checks for every step: tests, a 30-minute headless +simulation on four seeds, and the browser by day and night, at high and low tide, zoomed in and out. + +Baseline (commit 0975438), 30 minutes on seeds 41, 7, 3, 12: +- parts 116 / 93 / 120 / 104; loose objects 13 / 7 / 7 / 3; every species present. +- machines finished: 1 of 3, 2 of 2, 1 of 3, 0 of 1. Half-built machines linger as bare posts. +- collapses: 0 in all four. Since footings take three blows and artificers mend them, structures rarely fall now. + +## 1. Less noise (3f6da30) +- Changed: a half-built machine that nobody has worked on for 90 seconds loses a part every 20 seconds until it is gone. + Each lost part drops as scrap, which settles into the silt like any other. Rings are drawn only where a body breaks + the water's surface, not on the floor under submerged creatures. +- Why: bare gate posts stood around for the whole run, and rings under every submerged scraper made the pools busy. +- Checks: tests pass. 30 minutes on four seeds: parts 146 / 135 / 93 / 107, loose objects 9 to 12, every species present, + fewer machines left unfinished (0 of 0, 0 of 1, 2 of 3, 1 of 3 finished; the unfinished ones were recent). + Browser: day and night, high and low tide, zoom 3 to 8. +- Not sure: at night the wide view (zoom 8) is very dark, close to black. Worth a look; the fix would be a little more moonlight. + +## 2. Fish move as schools (e680828) +- Changed: the fish feel a slow current shared across the whole shelf (a swirling field that never bunches up), + all fish around a structure circle it the same way (it used to depend on the individual fish), alignment is stronger + and random jitter is a third of what it was. A hungry fish leans toward richer plankton nearby, smoothed so it turns + rather than twitches, and hunger loosens its hold on its home structure. +- Why: schools kept splitting because half the fish circled each way and each roamed its own heading. Tighter schools + on their own then starved, grazing out the water they sat in, so hungry schools now move on to fresh water. +- Checks: tests pass. At 20 minutes the largest school holds 55 to 99% of the fish (was 25 to 46%), alignment 0.73 to + 0.91 (was 0.68 to 0.85). Fish from minute 5 to 30 average 11.8 to 15.5 (was 12.4 to 17), lowest 4 to 7 (was 5 to 10). + Every species present; parts 108 to 157. Browser: a school of ten swimming as one, day and night, high and low tide. +- Not sure: about one fish fewer on average, and on a couple of seeds pylons catch more, since a school sometimes + lingers beside pylons at the shore where the sea keeps the plankton fresh. + +## 3. The shelf dries after the tide (985cbd4) +- Changed: each cell remembers how damp it is. Under water it is fully damp; once uncovered it dries over about a minute + and a half. Damp rock below the high-water line is drawn darker and more violet, drying back to its usual tone. The + dampness travels to the rock shader in the texture channel that used to carry the film, which was never drawn. +- Why: a time mark that costs nothing in clutter. As the tide goes out the uncovered shelf is dark and wet and pales from + the top down, so the shape of the ground shows in the drying. +- Checks: tests pass; the simulation is unchanged (dampness draws no randomness), so the 30-minute numbers match step 2. + Browser: falling tide by day (compared with dampness switched off), low tide at night, rising tide at dusk. +- Not sure: it is deliberately subtle. First version lifted dry rock to a lighter tone, which read as grey; I backed that + out so dry rock stays about as dark as before and only damp rock changes. + +## 4. Artificers in families (b5c4710) +- Changed: artificers never bred before (they had no rest period, so breeding was never allowed), so every one was a + newcomer from the sea. Now they rest 50 seconds between children (scaled up with their numbers, as for every species), + go looking for brass when well fed and rested, and a child keeps building at its parent's structure. Each carries a + building style: how much it raises frames rather than spreading them, and how much it favours balconies, pods, and + plates. Children inherit it with small changes. The style shifts both the local rules and the mix of parts it keeps. +- Why: fascination over long runs. Families now reach the fourth generation in 30 minutes, and a family's structure + takes on its style: in one run a tall-building line raised a slender tower 6.5 high on 12 columns; in another a + balcony-loving line built one that is 23% decks. +- Checks: tests pass. Eight seeds, 30 minutes: parts 121 on average (was 114), tallest 5.0 (was 4.1), artificer births + 1.9 per run (was 0). Every species present. Lowest counts between minutes 10 and 30 match the old ones except scrapers + on one seed, which dipped to 1 (immigration refills them below 4). Browser: day and night, zoom 4.5 and 7.5. +- Not sure: more artificers means a little more building; parts stay well under the 900 cap. The style is invisible in + the interface; it only shows in what gets built. + +## 5. Springs and neaps (e42d85b) +- Changed: the tide's range swells and shrinks over 20 minutes, starting at springs. Highs run from 0.30 at springs to + 0.08 at neaps, lows from -0.67 to -0.44 (the old fixed tide ran from -0.55 to 0.20). +- Why: rhythm over long runs. Spring lows uncover far more of the shelf, and the drying from step 3 shows most there; + neap highs leave the boulders standing out of shallow water. Nothing new is drawn. +- Checks: tests pass unchanged. 30 minutes on four seeds: parts 129 / 143 / 117 / 101, every species present at the end. + Lowest counts between minutes 10 and 30 across eight seeds: scrapers and tabs mostly higher than before; pylons, + breakers, and masons now touch zero on some seeds for a moment (spring lows strand more pylons), and immigration + refills them. Browser: neap high by day, spring low at night, spring high by day close in. +- Not sure: the brief zeros for pylons and breakers. They recover every time in these runs, but a spring low is now a + real hardship for anything that cannot move. + +## 6. Phones (53bb72c) +- Checked: at phone size (375 by 812, touch) the layout is clean when the controls are up, and they fade as on desktop. + A night frame with a large structure takes about 4 ms here, but that is this Mac's GPU, not a phone's; I could not + measure a real phone. +- Changed: on a tall screen the focus band covered only a quarter of the height, so most of a phone's view was blurred. + The sharp band now grows with the screen's height-to-width ratio, up to 1.8 times; desktop screens are unchanged. +- Checks: tests pass; the simulation is untouched. Browser: phone size at spring high by day and spring low at night. +- Not sure: real phone performance. The heaviest parts on a phone are the bloom, the two blur passes, and up to eight + lamps in the water shader; each could be cut back for phones if it stutters. + +## 7. Storms (51b6175) +- Changed: a storm comes in every 15 to 25 minutes (the first at about 13) and lasts 75 seconds, rising and falling + smoothly. It piles the sea up by 0.2, roughens the water (bigger slopes, a heavier swell, more foam), and dims the sun + and sky. The waves strike a part at the waterline every second or two: light parts break away after two blows, + footings after four, and battered parts hang askew until an artificer mends them. Fish hide among the frames while it + blows. When it passes, the sea leaves three pieces of flotsam. The ticker says when one comes in and when it passes. +- Why: since breakers were reined in, nothing on the shelf ever broke. A storm is a rare, passing event that tests the + structures and gives the artificers mending to do, without adding anything to the calm scene. +- Checks: tests pass, with a new one for the storm. One older test (a burrow was sunk) depended on the exact path of + the world and missed by 0.02 once the storm's start took a random draw; it now also accepts a burrow dug 0.6 below + where it began. 30 minutes on four seeds: parts 91 / 115 / 98 / 90, every species present. Browser: the storm at its + height by day, and the same view a minute after it passed. +- Not sure: the first storm comes at nearly the same time in every world with a small seed number, because the first + random draw of a small seed barely varies; with the default world it is at about 13 minutes. The storm is fairly + gentle; it batters but rarely brings a whole structure down. + +## 8. More lights at night (1164c8b) +- Found: at night after 25 minutes, across six seeds there were 0 to 2 tide wheels, almost no lamps, and not one lit + lamp. Structure lights need a charged wheel within reach, and both wheels and lamps were rare under tight rules + (a wheel had to hang beside exactly one frame; a lamp only at the very top of the whole structure). +- Changed: a wheel may hang beside one or two frames; a lamp caps any column near the top once it stands at least four + levels above high water, and is likelier the taller the column; wheels and lamps are 6% and 5% of the mix (were 4% + and 3%). An earlier try let lamps cap short columns, which stopped towers growing; it now waits for tall ones. +- Checks: tests pass. Eight seeds, 30 minutes: 105 parts on average, tallest 3.9 (98.5 and 3.4 just before). At night + after 25 minutes, most worlds now have a charged wheel and 1 to 5 powered pods. Browser: day, and night in a world + where, by chance, nothing was lit. +- Not sure: nights still depend on chance. In the browser's own run of the default world, no wheel had been built by + minute 25, so nothing was lit. Next I'd like a light that doesn't depend on it (see step 10). +- Also noticed: storms (step 7) cost growth. Before storms, eight seeds averaged 121 parts and a tallest tower of 5.0; + with them, 98.5 and 3.4. Next step softens them. + +## 9. Storms kept as they are (no commit) +- Tried: storms that only batter frames and footings and break off light parts. Compared on sixteen seeds, 30 minutes: + no storms 108 parts and a tallest tower of 4.3 on average; storms as committed 105 and 3.8; the softer storms 104 and + 4.0. Storms cost about 3% of growth, and softening them hardly changes that, so I reverted it. +- Correction to step 8: the drop I noted there (121 parts and 5.0 before storms, 98.5 and 3.4 after) came from comparing + only eight seeds. The worlds vary more than I'd assumed; sixteen seeds is the least I now trust for comparisons. + +## 10. Glowing plankton at night (aad68b1) +- Changed: at night the water lights where it is stirred. Every body moving through water (fish and walkers, up to 24 + nearest the view, 12 on phones) leaves a short trail of blue-violet sparks behind it, and anything dropped in sends + out a glowing ring. The glow is brighter where the plankton is rich and dim where fish have grazed it out; the + plankton now travels to the water shader as its own small texture, four times a second, and only at night. +- Why: step 8 showed that structure lights at night depend on a chain of chance (a wheel, its charge, a lamp within + reach). This light needs nothing built: it comes from what moves, so the night shows where life is, and it answers + a visitor's drop with a ring of light. +- Checks: tests pass; the simulation is untouched. Browser: close in on a school and a collector at night (both trail + sparks), wide at night with a drop (a glowing ring), and by day with a drop (no glow). +- Not sure: the colour is a cool blue-violet, the one new hue in the scene. It sits with the violet palette in the + screenshots, but it's worth your eye. + +## 11. A calmer web of light (9fa9f54) +- Changed: the caustics, the netted light on floors under water, are half as strong. +- Why: in daylight at high tide a bright web of wavy lines covered all the water; it was the busiest thing on screen + and veiled what lives underneath. I switched parts off one at a time to find it: turning off the sun's broad + highlight on the waves changed nothing, turning off the caustics removed the web entirely (calm, but lifeless), and + half strength keeps a faint shimmer. +- Checks: tests pass; the simulation is untouched; the only line changed is the caustic strength. Browser: high tide by + day with the web at full, half, and none; low tide by day; night. +- Not sure: taste. If you liked the web, this is the one number to put back (0.05 back to 0.1). + +## 12. The wandering camera knows the new sights (5c3fe1f) +- Changed: the camera used to aim at the average position of all the fish, which could be empty water between two + schools; it now finds the biggest school and keeps it in view as it swims. At night that shot is strongly favoured, + since its glowing wake (step 10) is the best thing on the shelf, but never twice running. During a storm the + largest structure is favoured, to watch it take the waves. +- Checks: tests pass; the simulation is untouched. Browser, stepping the camera by hand: at night the shots went school, + wide, school, wide, school, structure; during a storm, structure, school, structure. One night shot, of a lit pod + window's reflection beside the school's glow, is the most beautiful frame I've seen so far. +- Not sure: pacing in real time is still unwatched (the pane was hidden throughout). + +## 13. Notes brought up to date (abb235f) +- Changed: the "Inside the pool" notes now mention the tide's slow swell and shrink and the rock drying behind it, + storms, and the glowing plankton; the Tab and Artificer entries mention schooling, following the plankton, inherited + building styles, and mending after storms. +- Checks: tests pass. Browser: the notes panel opened and read. +- Not sure: the main paragraph is now long. It lives in a panel you open on purpose, but it could be split. + +## 14. Lit windows where someone is home (53fbcad) +- Found: across eight seeds at night after 25 minutes, 1 to 3 pods per world have their scraper home, so an occupied + window is a reliable night light, more than lamps (step 8). But its glow was faint (0.35 of a lamp). +- Changed: an occupied window glows at 0.6, which also strengthens its light on the frames and its column of light on + the water. +- Checks: tests pass; the simulation is untouched. Browser: in the browser's own run no scraper happened to be home, so + I placed one in its pod by hand to see it: a warm window, lit frames, and a light column on the water beside the + school's glow.