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. */} + ); }