diff --git a/public/tide-pool.css b/public/tide-pool.css index d196923..8c3b28e 100644 --- a/public/tide-pool.css +++ b/public/tide-pool.css @@ -4,7 +4,8 @@ body:has(.tide-pool) { margin: 0; background: #0a0a0a; } body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { display: none; } .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; } + --site-text: #e5e5e5; --site-border: #48444b; --site-link: #d4b9df; font-size: 14px; + --ring: clamp(104px, 15vmin, 148px); --title-size: clamp(2.4rem, 5vw, 5.2rem); --mark-scale: .7; } .tide-pool a { color: #d3cbd6; text-underline-offset: .3em; text-decoration-color: #a58aad; } .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; } @@ -13,13 +14,12 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .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 h1, .tide-mark { margin: .3rem 0 .4rem; font: 400 var(--title-size)/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: none; cursor: grab; } +.tide-stage canvas { display: block; width: 100%; height: 100%; 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; } @@ -27,6 +27,39 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .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; } +/* The veil: the site's ring mark over the dark while the pool loads, filling like a pool, with the title beneath it. When the + pool is ready the view opens out of the ring's middle and the title glides up to its place. */ +.tide-veil { position: absolute; inset: 0; z-index: 1; background: #0a0a0a; pointer-events: none; transition: opacity .6s ease; } +.tide-pool:not([data-veiled]) .tide-veil { opacity: 0; visibility: hidden; transition: opacity .9s ease, visibility 0s .9s; } +/* Opening through the ring, the scene draws the dark and the ring itself, so the veil keeps only the water to fade. */ +.tide-pool .tide-veil[data-iris] { background: none; opacity: 1; visibility: visible; transition: none; } +.tide-pool .tide-veil[data-iris="done"] { visibility: hidden; } +.tide-veil-ring { position: absolute; left: 50%; top: 50%; width: var(--ring); height: var(--ring); margin: calc(var(--ring) / -2) 0 0 calc(var(--ring) / -2); animation: tide-veil-in 1.2s ease both; } +.tide-veil-ring > svg { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke: #e5e5e5; } +.tide-veil:is([data-iris="open"], [data-iris="done"]) .tide-veil-ring > svg { visibility: hidden; } +.tide-veil-water { position: absolute; inset: 18.5%; border-radius: 50%; overflow: hidden; transition: opacity .5s ease; } +.tide-veil[data-iris] .tide-veil-water { opacity: 0; } +.tide-pool:is(:not([data-veiled]), [data-veiled="failed"]) :is(.tide-veil-bob, .tide-veil-swell) { animation-play-state: paused; } +.tide-veil-level { position: absolute; inset: 0; transform: translateY(calc((1 - var(--level, .2)) * 100%)); transition: transform 1.8s cubic-bezier(.4, 0, .2, 1); } +.tide-veil-bob { position: absolute; left: 0; top: -10%; width: 200%; height: 120%; animation: tide-bob 4.6s ease-in-out infinite alternate; } +.tide-veil-swell { width: 100%; height: 100%; animation: tide-swell 7s linear infinite; } +.tide-veil-swell svg { display: block; width: 100%; height: 100%; } +.tide-veil-sea { fill: #7b62c9; fill-opacity: .24; } +.tide-veil-crest { fill: none; stroke: #cdbcf0; stroke-width: 1.25px; vector-effect: non-scaling-stroke; } +.tide-veil-note { position: absolute; left: 16px; right: 16px; top: calc(50% + var(--ring) / 2 + 2.6rem + var(--title-size) * var(--mark-scale)); margin: 0; + text-align: center; font-size: .64rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); transition: opacity .4s ease; animation: tide-veil-in 1.2s .3s ease both; } +.tide-veil[data-iris] .tide-veil-note { opacity: 0; } +.tide-mark { position: absolute; z-index: 2; left: 50%; top: calc(50% + var(--ring) / 2 + 1.5rem); margin: 0; white-space: nowrap; pointer-events: none; + transform: translateX(-50%) scale(var(--mark-scale)); transform-origin: 50% 0; animation: tide-veil-in 1.2s .15s ease both; } +.tide-pool[data-titled] .tide-mark { visibility: hidden; } +.tide-pool:not([data-titled]) .tide-heading h1 { opacity: 0; } +/* Under the veil the instruments wait; they come in as the view opens. */ +.tide-pool[data-veiled] :is(.tide-heading p, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-status, .tide-inspect) { opacity: 0; pointer-events: none; } +.tide-pool[data-arriving] :is(.tide-heading p, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker) { transition: opacity 1s ease 1.8s; } +@keyframes tide-veil-in { from { opacity: 0; } } +@keyframes tide-bob { from { transform: translateY(-3%); } to { transform: translateY(3%); } } +@keyframes tide-swell { to { transform: translateX(-50%); } } + /* 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; } .tide-clock { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; border-top: 1px solid var(--line); } @@ -120,7 +153,6 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .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: 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; } @@ -128,6 +160,9 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di 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; } + .tide-veil-bob, .tide-veil-swell { animation: none; } + .tide-veil-level { transition: none; } + .tide-pool[data-arriving] :is(.tide-heading p, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker) { transition: none; } } @media (max-width: 1100px) { @@ -141,7 +176,8 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di } @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-pool { --title-size: 2.3rem; --mark-scale: 1; } + .tide-heading h1 { 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; } @@ -173,8 +209,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di #tide-reset { display: none; } } @media (max-height: 620px) and (min-width: 721px) { - .tide-pool { min-height: 420px; } - .tide-heading h1 { font-size: 2.2rem; } + .tide-pool { min-height: 420px; --title-size: 2.2rem; --mark-scale: .9; } .tide-heading p { display: none; } .tide-hud { top: 60px; } .tide-inspect { bottom: 90px; } diff --git a/public/tide-pool.js b/public/tide-pool.js index b57e4c1..a113fe2 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -5,21 +5,33 @@ import { const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); +// The veil over the pool while it loads: the water in the ring rises as each part arrives. +const veil = document.querySelector('#tide-veil'), veilNote = document.querySelector('#tide-veil-note'); +const rise = level => veil?.style.setProperty('--level', String(level)); if (root) initialize().catch(error => { console.error(error); - status.textContent = 'The still edition. Interactive 3D is unavailable in this browser.'; + root.dataset.veiled = 'failed'; rise(.5); + veilNote.textContent = 'This browser cannot show the pool'; + status.textContent = 'The pool needs WebGL, which this browser cannot provide.'; }); 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. + rise(.4); + // Local, pinned modules. A failed import or unavailable WebGL leaves the veil up, with a note. const T = await import('/public/tide-pool-vendor/three.module.js'); + const post = await Promise.all(['EffectComposer', 'RenderPass', 'UnrealBloomPass', 'OutputPass', 'ShaderPass'] + .map(name => import(`/public/tide-pool-vendor/jsm/postprocessing/${name}.js`))) + .catch(error => { console.warn('Bloom unavailable; rendering without it.', error); return null; }); + rise(.6); 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' }); + // Drawn through the post-processing chain, the scene never touches the canvas's own buffer except as one flat + // full-screen pass, so multisampling it would only cost memory, which phones run short of. + const renderer = new T.WebGLRenderer({ canvas, antialias: !post, alpha: false, powerPreference: mobile ? 'low-power' : 'default' }); renderer.shadowMap.enabled = true; renderer.shadowMap.type = T.PCFSoftShadowMap; renderer.outputColorSpace = T.SRGBColorSpace; @@ -48,12 +60,29 @@ async function initialize() { gl_FragColor = sum; }`, }; + // The veil's ring, drawn over the first frames so the view can open out of it: the pool shows through the ring's + // middle, then the opening widens past the edges of the screen and the ring runs out ahead of it like a ripple, its + // lines keeping their width as it grows, and fades. In pixels of the finished image, after tone mapping, so the dark + // and the ring match the page's exactly. + const IRIS = { + uniforms: { tDiffuse: { value: null }, uIris: { value: new T.Vector4() }, uBands: { value: new T.Vector4() } }, + vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }', + fragmentShader: ` + uniform sampler2D tDiffuse; uniform vec4 uIris, uBands; varying vec2 vUv; + float edge(float d, float r) { return smoothstep(r - .75, r + .75, d); } + void main() { + // uIris: the centre, the opening's radius, and how soft its edge has grown. uBands: the ring's heavy line, the + // gap, and the fine line outside it, and how strongly it still shows. + float d = distance(gl_FragCoord.xy, uIris.xy), r = uIris.z, a = r + uBands.x, b = a + uBands.y; + vec3 col = mix(texture2D(tDiffuse, vUv).rgb, vec3(.0392), smoothstep(r - uIris.w, r + uIris.w, d)); + float ring = edge(d, r) - edge(d, a) + edge(d, b) - edge(d, b + uBands.z); + gl_FragColor = vec4(mix(col, vec3(.898), ring * uBands.w), 1.); + }`, + }; // Bloom: lamps, eyes, and glints bleed softly past their edges. Without the add-ons, the scene renders plainly. - let composer = null, tilt = null; - try { - const base = '/public/tide-pool-vendor/jsm/postprocessing/'; - const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }, { OutputPass }, { ShaderPass }] = await Promise.all( - ['EffectComposer', 'RenderPass', 'UnrealBloomPass', 'OutputPass', 'ShaderPass'].map(name => import(`${base}${name}.js`))); + let composer = null, tilt = null, iris = null; + if (post) try { + const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }, { OutputPass }, { ShaderPass }] = post; composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); composer.addPass(new UnrealBloomPass(new T.Vector2(512, 512), mobile ? .35 : .45, .55, .72)); @@ -61,33 +90,38 @@ async function initialize() { tilt[1].uniforms.uDir.value.set(0, 1); tilt.forEach(pass => composer.addPass(pass)); composer.addPass(new OutputPass()); + iris = new ShaderPass(IRIS); + iris.enabled = false; + composer.addPass(iris); } catch (error) { console.warn('Bloom unavailable; rendering without it.', error); - composer = null; tilt = null; + composer = null; tilt = null; iris = 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 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.); + }`, + }))); + function makeEnvironment() { const pmrem = new T.PMREMGenerator(renderer); - scene.environment = pmrem.fromScene(sky, .04).texture; + const map = pmrem.fromScene(sky, .04).texture; pmrem.dispose(); + return map; } + scene.environment = makeEnvironment(); const hemi = new T.HemisphereLight(0xd8ccdf, 0x141118, 1.4); scene.add(hemi); const sun = new T.DirectionalLight(0xf4efe8, 3.8); @@ -1321,7 +1355,7 @@ async function initialize() { clampGoal(); } function easeCamera(dt) { - if (intro && performance.now() - intro.start > 1800 && !intro.released) { intro.released = true; Object.assign(goal, intro.to); } + if (intro && performance.now() - intro.start > intro.hold && !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 : 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; @@ -1366,6 +1400,70 @@ async function initialize() { return best; } + // Lifting the veil. Shaders compile in the background where the browser allows it, and the title's face loads, first. + const controls = () => root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]'); + const prepare = () => Promise.race([ + Promise.all([Promise.resolve().then(() => renderer.compileAsync(scene, camera)), document.fonts?.ready]).catch(() => {}), + new Promise(done => setTimeout(done, 6000)), + ]); + // The view opens out of the ring: the water in it drains away to show the pool through the ring's middle, then the + // opening widens past the edges of the screen as the ring runs out ahead of it like a ripple, and the title rises to + // its place. + const ring = $('.tide-veil-ring'), HOLD = .6, OPEN = 1.6; + let reveal = null; + function unveil(message) { + const motion = !!iris && !reduced.matches && visible && !document.hidden; + reveal = { start: performance.now(), motion }; + if (motion) iris.enabled = true; + frameTime = 1 / 60; qualityClock = 4; + canvas.hidden = false; + render(); + if (motion) veil.dataset.iris = 'hole'; else reveal = null; + delete root.dataset.veiled; + root.dataset.ready = 'true'; root.dataset.arriving = 'true'; + setTimeout(() => { delete root.dataset.arriving; }, 3000); + if (intro) intro.start = performance.now(); + glideTitle(motion ? HOLD * 1000 : 0); + controls().forEach(b => { b.disabled = false; }); + status.textContent = message; + updateButtons(); readout(); schedule(); + } + function openIris() { + const t = (performance.now() - reveal.start) / 1000; + if (t >= HOLD + OPEN) { endReveal(); return; } + if (t >= HOLD && veil.dataset.iris !== 'open') veil.dataset.iris = 'open'; + const { width, height } = stage.getBoundingClientRect(), ratio = renderer.getPixelRatio(); + const from = ring.getBoundingClientRect().width / 2 * .66, to = Math.hypot(width, height) / 2 + 2; + const u = clamp((t - HOLD) / OPEN, 0, 1), e = u < .5 ? 4 * u ** 3 : 1 - (2 - 2 * u) ** 3 / 2, r = from + (to - from) * e; + // The ring's proportions: its middle is .66 of its radius, the heavy line runs to .814, the fine one from .96 to 1. + const R = from / .66 * ratio, thin = 1 - .5 * e; + iris.uniforms.uIris.value.set(width / 2 * ratio, height / 2 * ratio, r * ratio, (.75 + (r - from) * .08) * ratio); + iris.uniforms.uBands.value.set(.154 * R * thin, .146 * R * (1 + 2 * e), .04 * R * thin, 1 - clamp((e - .05) / .6, 0, 1)); + } + function endReveal() { + reveal = null; + if (iris) iris.enabled = false; + if (veil.dataset.iris) veil.dataset.iris = 'done'; + wake(); + } + // The title under the ring glides up to its place at the top as the view opens. + function glideTitle(delay) { + const mark = $('.tide-mark'), title = $('.tide-heading h1'); + if (root.dataset.titled) return; + if (!mark?.animate || reduced.matches) { root.dataset.titled = 'true'; return; } + const text = element => { const range = document.createRange(); range.selectNodeContents(element); return range.getBoundingClientRect(); }; + const from = text(mark), to = text(title); + mark.style.transform = 'none'; mark.style.transformOrigin = '0 0'; + const box = mark.getBoundingClientRect(), plain = text(mark); + const place = rect => { + const k = rect.width / plain.width; + return `translate(${rect.left - box.left - (plain.left - box.left) * k}px, ${rect.top - box.top - (plain.top - box.top) * k}px) scale(${k})`; + }; + const done = () => { root.dataset.titled = 'true'; }; + mark.animate([{ transform: place(from) }, { transform: place(to) }], { duration: 1500, delay, easing: 'cubic-bezier(.65, 0, .35, 1)', fill: 'both' }) + .finished.then(done, done); + } + 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; @@ -1461,6 +1559,7 @@ async function initialize() { 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 (reveal?.motion) openIris(); if (composer) composer.render(); else renderer.render(scene, camera); placeLabels(); dirty = false; @@ -1704,7 +1803,7 @@ async function initialize() { aim(dt); easeCamera(dt); render(); - if (!paused || !cameraSettled()) frame = requestAnimationFrame(loop); + if (!paused || !cameraSettled() || reveal) 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. @@ -1985,12 +2084,32 @@ async function initialize() { document.addEventListener('visibilitychange', schedule); new IntersectionObserver(entries => { visible = entries[0].isIntersecting; schedule(); }, { threshold: .05 }).observe(stage); new ResizeObserver(resize).observe(stage); + // If the browser takes the 3D view away, the veil comes back until it is returned, and then the view opens out of the + // ring again, where it was. + let restoring = 0; 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-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.'; + if (reveal) endReveal(); + delete root.dataset.ready; delete veil.dataset.iris; + root.dataset.veiled = 'lost'; rise(.5); + veilNote.textContent = 'Bringing the pool back'; + controls().forEach(b => { b.disabled = true; }); + status.textContent = 'The 3D view was interrupted. Bringing it back.'; + clearTimeout(restoring); + restoring = setTimeout(() => { + veilNote.textContent = 'The pool went dark. Reload the page to return to it'; + status.textContent = 'The 3D view was interrupted. Reload the page to return to the pool.'; + }, 8000); + }); + canvas.addEventListener('webglcontextrestored', async () => { + clearTimeout(restoring); + veilNote.textContent = 'Bringing the pool back'; + scene.environment = makeEnvironment(); + resize(); + await prepare(); + if (renderer.getContext().isContextLost()) return; + lost = false; + unveil('The pool is back.'); }); window.addEventListener('pagehide', () => { visible = false; schedule(); }); window.addEventListener('pageshow', () => { visible = true; schedule(); }); @@ -2005,16 +2124,16 @@ async function initialize() { return n > a.n ? { c, n } : a; }, { c: null, n: 0 }).c; if (busiest) { - intro = { start: performance.now(), to: { ...goal } }; + intro = { start: Infinity, hold: 1000, 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-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(); + rise(.85); + await prepare(); + // Give the ring a moment to settle in before it opens, so a quick load doesn't flash it. + await new Promise(done => setTimeout(done, Math.max(0, 1300 - performance.now()))); + if (!lost) unveil(paused ? 'Paused for reduced motion. Press Play when you want to watch.' : 'Drag to look around. Tap a machine to follow it.'); // 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') || diff --git a/scripts/test-tide-pool-browser.mjs b/scripts/test-tide-pool-browser.mjs index 6aa8e15..6d08941 100644 --- a/scripts/test-tide-pool-browser.mjs +++ b/scripts/test-tide-pool-browser.mjs @@ -19,10 +19,12 @@ 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}/tide-pool?immersive-review=2`, { waitUntil: 'networkidle' }); + const response = await page.goto(`${base}/tide-pool?immersive-review=2&shelf=41`, { waitUntil: 'networkidle' }); assert.equal(response.status(), 200); assert.equal(response.headers()['x-robots-tag'], undefined); await page.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled); + // The view opens out of the veil's ring, and the title settles in its place. + await page.waitForFunction(() => document.querySelector('[data-tide-pool]').dataset.titled && !['hole', 'open'].includes(document.querySelector('#tide-veil').dataset.iris)); 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 }); @@ -44,14 +46,15 @@ try { assert.match(await page.locator('#tide-instructions').textContent(), new RegExp(kind)); 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/); + assert.match(await page.locator('#tide-status').textContent(), /dropped in pool|blooms in pool/); } assert.equal(await page.locator('[data-tide-pool]').getAttribute('data-engaged'), 'true'); await page.screenshot({ path: `${out}/offering-response.png` }); // Follow a machine from the census, then release it. - await page.locator('[data-species="breaker"]').click(); + await page.locator('.tide-census summary').click(); + await page.locator('[data-species="scraper"]').click(); assert.ok(await page.locator('#tide-inspect').isVisible()); - assert.match(await page.locator('#tide-inspect-title').textContent(), /^BR-\d{3}$/); + assert.match(await page.locator('#tide-inspect-title').textContent(), /^SC-\d{3}$/); await page.screenshot({ path: `${out}/following.png` }); await page.locator('#tide-release').click(); assert.ok(await page.locator('#tide-inspect').isHidden()); @@ -67,6 +70,9 @@ try { await page.locator('#tide-pause').click(); await page.waitForFunction(() => window.__audio[0]?.state === 'suspended'); await page.mouse.move(2, 2); + // Paused, frames stop once the camera has come to rest. + await page.waitForFunction(() => { const { goal, view } = window.__tidePool; return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002; }); + await page.waitForTimeout(200); const still = await page.locator('#tide-canvas').screenshot(); await page.waitForTimeout(400); assert.ok(still.equals(await page.locator('#tide-canvas').screenshot())); @@ -102,7 +108,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}/tide-pool`, { waitUntil: 'networkidle' }); + await m.goto(`${base}/tide-pool?shelf=41`, { 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(); @@ -118,16 +124,24 @@ try { await m.locator('#tide-describe').tap(); assert.match(await m.locator('#tide-description').textContent(), /The shelf holds/); await m.locator('.tide-notes summary').tap(); - await m.evaluate(() => document.querySelector('#tide-canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()); + // Losing the 3D context brings the veil back; when the browser returns it, the pool comes back where it was. + await m.evaluate(() => { window.__lose = document.querySelector('#tide-canvas').getContext('webgl2').getExtension('WEBGL_lose_context'); window.__lose.loseContext(); }); await m.waitForFunction(() => document.querySelector('#tide-status').textContent.includes('interrupted')); - assert.ok(await m.locator('.tide-still').isVisible()); + assert.equal(await m.locator('[data-tide-pool]').getAttribute('data-veiled'), 'lost'); + assert.ok(await m.locator('.tide-veil-ring').isVisible()); assert.ok(await m.locator('[data-tool="brass"]').isDisabled()); + await m.evaluate(() => window.__lose.restoreContext()); + await m.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled); + assert.equal(await m.locator('[data-tide-pool]').getAttribute('data-veiled'), null); + assert.match(await m.locator('#tide-status').textContent(), /back/); await touch.close(); const fallback = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 393, height: 852 } }); const f = await fallback.newPage(); await f.goto(`${base}/tide-pool`); - assert.ok(await f.locator('.tide-still').isVisible()); + assert.ok(await f.locator('.tide-veil-ring').isVisible()); + assert.ok(await f.locator('noscript .tide-veil-note').isVisible()); + assert.equal(await f.locator('noscript .tide-veil-note').textContent(), 'The pool needs JavaScript to fill'); 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', 'follow and release', 'zoom and pool navigation', '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 and return', 'no-JS veil'] }, null, 2)); console.log(JSON.stringify({ passed: true, base, out, sizes })); } finally { await browser.close(); } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 19ad753..c5063aa 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -9,6 +9,9 @@ const SPECIES = [ { 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." }, ]; +// Two swells across twice the ring's width, so sliding it along by half repeats seamlessly. +const WAVE = "M0 10C16.5 6 33.5 6 50 10S83.5 14 100 10S133.5 6 150 10S183.5 14 200 10"; + const TOOLS = [ { id: "look", label: "Look" }, { id: "brass", label: "Brass" }, @@ -19,38 +22,36 @@ const TOOLS = [ export function TidePoolContent() { return ( -
+
← Other

Tide pool

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

+
- - A dark tidal shelf with a pool in it, and small mechanical creatures digging and building on its floor. - - - - {[{ 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 => ( - - - - - ))} - - + {/* While the pool loads, and if the view is ever lost: the ring fills like a pool, then the view opens out of it. */} +
+ + +