diff --git a/public/tide-pool.js b/public/tide-pool.js index d423afd..a15a8ff 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1943,10 +1943,11 @@ async function initialize() { readoutClock -= dt; if (readoutClock <= 0) { readoutClock = .4; readout(); tick(); } } else previous = 0; + const driving = drive(dt); aim(dt); easeCamera(dt); render(); - if (!paused || !cameraSettled() || reveal) frame = requestAnimationFrame(loop); + if (!paused || driving || !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. @@ -2053,20 +2054,24 @@ async function initialize() { 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 }; } }); + // Where a resting mouse is, so the marker can keep up with it when the view moves under it. + let hovering = null; + function hover(point) { + hovering = { clientX: point.clientX, clientY: point.clientY }; + const hit = floorAt(point.clientX, point.clientY); + const over = creatureAt(point.clientX, point.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; + holdLantern(point); + canvas.classList.toggle('is-dropping', marker.visible); + canvas.style.cursor = over ? 'pointer' : ''; + wake(); + } canvas.addEventListener('pointermove', e => { 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 && onShelf(world, hit.x, hit.z); - if (hit) cursor = hit; - pointedPool = hit ? namedPool(bodyAt(world, hit.x, hit.z)) : null; - holdLantern(e); - canvas.classList.toggle('is-dropping', marker.visible); - canvas.style.cursor = over ? 'pointer' : ''; - wake(); - } + if (e.pointerType === 'mouse') hover(e); return; } const dx = e.clientX - known.x, dy = e.clientY - known.y; @@ -2108,7 +2113,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; pointedPool = null; dropLantern(); } }); + canvas.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !pointers.size) { hovering = null; marker.visible = false; pointedPool = null; dropLantern(); } }); canvas.addEventListener('wheel', e => { e.preventDefault(); const delta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; @@ -2157,6 +2162,40 @@ async function initialize() { marker.visible = true; wake(); }); + // W, A, S and D glide the view across the screen for as long as they are held, from anywhere on the page, the way a + // game's camera moves. Shift goes faster. Keys are read by where they sit on the keyboard, so other layouts work too. + const DRIVE = { KeyW: [0, 1], KeyA: [-1, 0], KeyS: [0, -1], KeyD: [1, 0] }; + const held = new Set(), pace = { x: 0, y: 0 }; + let hurry = false; + addEventListener('keydown', e => { + hurry = e.shiftKey; + // With a modifier down the keys belong to the browser; a Command shortcut can also swallow the key-up, so let go. + if (e.metaKey || e.ctrlKey || e.altKey) { held.clear(); return; } + if (!DRIVE[e.code] || !root.dataset.ready || !visible || e.target.closest?.('input, textarea, select, [contenteditable]')) return; + e.preventDefault(); + if (held.has(e.code)) return; + held.add(e.code); + intro = null; root.dataset.engaged = 'true'; + if (following) setFollow(false); + stir(); wake(); + }); + addEventListener('keyup', e => { hurry = e.shiftKey; held.delete(e.code); }); + addEventListener('blur', () => held.clear()); + // Eases toward the held direction's pace and away again on release, so the view starts and stops softly. Reports + // whether it moved, which keeps frames coming even while the world is paused. + function drive(dt) { + let x = 0, y = 0; + for (const code of held) { x += DRIVE[code][0]; y += DRIVE[code][1]; } + const length = Math.hypot(x, y) || 1, speed = hurry ? 1.5 : .6, k = 1 - Math.exp(-dt * (reduced.matches ? 60 : 10)); + pace.x += (x / length * speed - pace.x) * k; pace.y += (y / length * speed - pace.y) * k; + if (!held.size && Math.hypot(pace.x, pace.y) < .01) { pace.x = pace.y = 0; return false; } + // Pace is in screen heights a second; panning by the drag that would carry the ground the other way moves the view. + const height = canvas.getBoundingClientRect().height * dt; + panBy(-pace.x * height, pace.y * height); + if (hovering) hover(hovering); + return true; + } + function goToBasin(i) { intro = null; const p = [...world.bodies].sort((a, b) => b.area - a.area)[i]; @@ -2276,10 +2315,10 @@ async function initialize() { 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.'); + if (!lost) unveil(paused ? 'Paused for reduced motion. Press Play when you want to watch.' : `${mobile ? 'Drag' : 'Drag, or hold W A S D,'} 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') || + const busy = () => held.size || 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); return; } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 89a6520..5a6154d 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -135,14 +135,14 @@ export function TidePoolContent() {

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. After dark every machine's lamp is lit, so the life on the shelf shows as small moving lights, and a ball of light floats wherever you point, over rock or water, lighting what is around it; the fish come to it, and the plankton sparks behind it as you move it over the water. Leave it alone and the controls fade away and the view drifts on its own toward whatever is happening; any touch brings them back. Every visit grows its own shelf; its number is in the address, so you can send someone yours, and Begin again grows another. 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.

+

Keyboard: hold W, A, S, or D to glide the view, with Shift to go faster. Focus the pool for the rest. 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.

{/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); }