From 3c6fdf264d73fc2b01c457ab7080a4e3e5720fd4 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 22:12:06 -0700 Subject: [PATCH] Draw the film on the tide pool's rock, a dim grey-green skin that grows where the water lies in daylight, and let the scrapers graze it as they creep slowly along wandering lines, so they leave clean trails through it. --- public/tide-pool-world.js | 62 ++++++++++++++++++++++++++++++------ public/tide-pool.js | 39 ++++++++++++++++++++--- src/components/tide-pool.tsx | 2 +- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index f5bbefc..07e7f12 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -645,15 +645,20 @@ 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. + // A scraper looks for film under water, where it can graze, and weighs the rock just ahead of it too: it grazes on + // from where it is while the film there holds up. + const scraper = c.sp === 'scraper'; let best = null, score = -Infinity; - 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; + for (let k = scraper ? -1 : 0; k < 16; k++) { + const r = k < 8 ? 3 : k < 13 ? 9 : 18, ahead = k < 0; + const x = ahead ? c.x + Math.sin(c.angle) * .5 : c.x + (world.random() - .5) * r * 2; + const z = ahead ? c.z + Math.cos(c.angle) * .5 : c.z + (world.random() - .5) * r * 2; + const dry = scraper && depthAt(world, x, z) < WET; + if (!onShelf(world, x, z) || ahead && dry) continue; 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 }; } + const s = world.film[cellAt(x, z)] * (dry ? .3 : 1) - Math.hypot(x - c.x, z - c.z) * .025 - crowd * .12 + (ahead ? .2 : 0); + if (s > score) { score = s; best = ahead ? { x: c.x, z: c.z } : { x, z }; } } assign(world, c, 'graze', null, { at: best || { x: c.x, z: c.z }, patience: 14 }); c.task.efficiency = efficiency; @@ -1420,7 +1425,8 @@ 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); + // A scraper grazes as it creeps across its patch; anything else grazes where it stands. + case 'graze': if (c.sp === 'scraper') { t.creep = true; return work(c, 20 + world.random() * 15); } 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); @@ -1452,15 +1458,52 @@ function arrive(world, c) { idle(world, c); } +// Scrapers graze as they go, the way limpets and snails do: they creep over the rock along a slowly wandering line, +// rasping the film from under the blade, and lean toward richer film and away from rock already grazed, so each leaves +// a clean trail behind it. +const CREEP = .045, RASP = .45, GAIN = .45; +function creep(world, c, dt) { + // Feel ahead to either side for film on wet rock. + const feel = a => { + const x = c.x + Math.sin(c.angle + a) * .45, z = c.z + Math.cos(c.angle + a) * .45; + return onShelf(world, x, z) ? sample(world.film, x, z) * (depthAt(world, x, z) > WET ? 1 : .3) : 0; + }; + // The line wanders a little of its own accord and leans gently toward the richer side, so it curves without curling up. + c.bend = (c.bend ?? 0) * (1 - dt * .2) + (world.random() - .5) * .06 * Math.sqrt(dt); + c.angle += (c.bend + clamp(feel(.6) - feel(-.6), -.08, .08)) * dt; + const pace = CREEP * (c.shell !== null ? .7 : 1) * (c.energy < .1 ? .6 : 1); + c.speed = pace; + let moved = false; + for (const aside of [0, .5, -.5, 1, -1, 1.6, -1.6]) { + const a = c.angle + aside * c.side, x = c.x + Math.sin(a) * pace * dt, z = c.z + Math.cos(a) * pace * dt; + if (canStep(world, c, x, z)) { c.x = x; c.z = z; c.angle += aside * c.side * Math.min(1, dt * 2); moved = true; break; } + } + if (!moved) { c.side = -c.side; c.angle += c.side * dt * 2; } + // Rasp the film from under the blade, shared among the cells around it the way the ground is sampled between them. + const bx = c.x + Math.sin(c.angle) * .24, bz = c.z + Math.cos(c.angle) * .24; + const fx = clamp((bx - X0) / CELL - .5, 0, COLS - 1.001), fz = clamp((bz - Z0) / CELL - .5, 0, ROWS - 1.001); + const i = Math.floor(fx), j = Math.floor(fz), u = fx - i, v = fz - j, k = j * COLS + i; + let eaten = 0; + for (const [n, share] of [[k, (1 - u) * (1 - v)], [k + 1, u * (1 - v)], [k + COLS, (1 - u) * v], [k + COLS + 1, u * v]]) { + if (world.w[n] - world.h[n] <= WET) continue; + const take = world.film[n] * Math.min(1, RASP * share * dt); + world.film[n] -= take; eaten += take; + } + c.energy = Math.min(1, c.energy + eaten * GAIN); + // Where there is nothing left to graze, on bare rock or out of the water, it soon gives up and looks for somewhere better. + if (eaten < RASP * dt * .03) c.timer -= dt * 3; +} + // Continuous work, applied every step while working. function labour(world, c, dt) { const t = c.task; if (t.kind === 'graze') { + if (t.creep) return creep(world, c, dt); const k = cellAt(c.x, c.z); if (world.w[k] - world.h[k] > WET) { const take = Math.min(world.film[k], .06 * dt); world.film[k] -= take; - c.energy = Math.min(1, c.energy + take * (c.sp === 'scraper' ? 1.7 : 2.4) * (t.efficiency ?? 1)); + c.energy = Math.min(1, c.energy + take * 2.4 * (t.efficiency ?? 1)); } } else if (t.kind === 'dig') { if (heightAt(world, t.at.x, t.at.z) <= t.amount || c.spoil >= LOAD) { finish(world, c); return; } @@ -2056,7 +2099,7 @@ export function advanceWorld(world, elapsed) { if (c.task?.kind !== 'dig') 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 !== 'move' && !c.task?.creep) c.speed *= Math.exp(-8 * dt); if (c.state !== 'work') c.gesture *= Math.exp(-6 * dt); } c.phase += dt * (1.2 + c.speed * 9); @@ -2132,6 +2175,7 @@ export function goalText(world, c) { 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 === 'graze' && t.creep) return 'grazing a trail through the film'; 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'; diff --git a/public/tide-pool.js b/public/tide-pool.js index a70017e..b4ccef3 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -151,7 +151,8 @@ async function initialize() { // 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); + // The living things too small to see, a cell apiece: r = plankton in the water, g = the film grown on the floor. + const planktonTexture = new T.DataTexture(new Uint16Array(COLS * ROWS * 2), COLS, ROWS, T.RGFormat, 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 }; @@ -316,6 +317,24 @@ async function initialize() { Object.assign(shader.uniforms, shared); shader.vertexShader = ROCK_HEAD + shader.vertexShader.replace('#include ', ROCK_BODY); }; + // The film on the floor, from the plankton texture's second channel. A grazed trail is about one cell wide, so it is + // drawn with a sharper cubic than the grid's (Mitchell and Netravali's, in five taps) to keep it a line, not a smudge. + const FILM = ` + uniform sampler2D uPlankton; + float filmAt(vec2 p) { + vec2 size = vec2(${COLS}., ${ROWS}.); + vec2 uv = (p - uGridBox.xy) / uGridBox.zw * size - .5; + vec2 i = floor(uv), f = uv - i, e = 1. - f; + vec2 w0 = (1. / 3. - 3. * f + 5. * f * f - 7. / 3. * f * f * f) / 6., w3 = (1. / 3. - 3. * e + 5. * e * e - 7. / 3. * e * e * e) / 6.; + vec2 w12 = (7. * (f * f * f + e * e * e) - 12. * (f * f + e * e) + 32. / 3.) / 6.; + vec2 t0 = (i - .5) / size, t3 = (i + 2.5) / size; + vec2 t12 = (i + .5 + (7. * e * e * e - 12. * e * e + 16. / 3.) / 6. / w12) / size; + float sum = texture2D(uPlankton, t12).g * w12.x * w12.y + + (texture2D(uPlankton, vec2(t0.x, t12.y)).g * w0.x + texture2D(uPlankton, vec2(t3.x, t12.y)).g * w3.x) * w12.y + + (texture2D(uPlankton, vec2(t12.x, t0.y)).g * w0.y + texture2D(uPlankton, vec2(t12.x, t3.y)).g * w3.y) * w12.x; + return sum / (w12.x * w12.y + (w0.x + w3.x) * w12.y + (w0.y + w3.y) * w12.x); + } + `; 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;\nvarying float vOcclude;\n' + shader.fragmentShader.replace('#include ', `#include @@ -334,7 +353,15 @@ async function initialize() { 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) * vOcclude; + // Over it all, the living film: floors the water has covered in daylight grow a dim grey-green skin, faint where + // they lie bare and fuller under water, lifted with depth so it still shows through a pool. Where a scraper has + // grazed, the floor shows clean. + float depth = vCell.r - vCell.g, deep = clamp(depth, 0., 1.); + float film = smoothstep(0., .35, filmAt(vWorldC.xz)) * (1. - smoothstep(0., .03, vBeyond)); + diffuseColor.rgb = mix(diffuseColor.rgb, vec3(.066, .082, .078) * grain * (1. + deep) * vOcclude, + film * mix(mix(.05, .15, clamp(vCell.a, 0., 1.)), .35 + .35 * deep, smoothstep(0., .06, depth))); }`); + shader.fragmentShader = FILM + shader.fragmentShader; }); const ground = new T.Mesh(rockSheet(X0 - 44, Z0 - 44, X1 + 44, Z1 + 30, CELL), rockMaterial); ground.frustumCulled = false; ground.receiveShadow = true; ground.castShadow = true; @@ -494,8 +521,8 @@ async function initialize() { 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]); + const data = planktonTexture.image.data, { plankton, film } = world; + for (let k = 0; k < cellCount; k++) { data[k * 2] = toHalf(plankton[k]); data[k * 2 + 1] = toHalf(film[k]); } planktonTexture.needsUpdate = true; } // Bodies moving through water, nearest the view first, for the glowing wakes. @@ -803,7 +830,8 @@ async function initialize() { 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); + // Grazing as it creeps, the blade rasps from side to side. + box(P.scBlade, 0, .04, .26, .3, .03, .05, 0, c.gesture * (c.task?.creep ? 2.2 : .6)); box(P.eye, 0, .15, .232, .12, .024, .02, 0, 0, 0, eye(c)); }, tab(c) { @@ -1555,7 +1583,8 @@ async function initialize() { shared.uWater.value = waterLevel(world); uploadGrid(); uploadFlow(1 / 30); - if (night > .02) { uploadPlankton(1 / 30); gatherWakes(); } + uploadPlankton(1 / 30); + if (night > .02) 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 fbf096f..ad28dc5 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -1,5 +1,5 @@ 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: "scraper", code: "SC", name: "Scraper", note: "Creeps slowly over wet floors, grazing the film as it goes, and leaves clean trails through it. Hides in shells, hollows, and walled homes, and sleeps in a pod up in a structure." }, { id: "tab", code: "TB", name: "Tab", note: "Schools in open water, turning as one, and makes its home circling a structure, darting in among its frames when startled. Follows the plankton when hungry and feels for deeper water as the tide drains." }, { id: "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." }, -- 2.51.2