From 58f03b3fdc992c0df11fc21adc9af0bb8ee9fb91 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 23:55:44 -0700 Subject: [PATCH] Let the tide pool's view turn, and gather its controls: Q and E, a right-drag, or a two-finger twist turn it and a compass turns it back; R and F zoom; Z and X choose what to drop and Space drops it where you point; every key works from anywhere on the page; a double tap zooms in; buttons step between machines; a map of the whole shelf shows where you are and takes you where you tap; and a settings sheet lists the keys and turns the idle wandering, edge scrolling, and the map on or off. --- public/tide-pool.css | 54 +++++- public/tide-pool.js | 338 ++++++++++++++++++++++++++++------- src/components/tide-pool.tsx | 57 +++++- src/index.tsx | 2 +- src/tide-pool.test.tsx | 7 +- 5 files changed, 386 insertions(+), 72 deletions(-) diff --git a/public/tide-pool.css b/public/tide-pool.css index 844cd20..260120b 100644 --- a/public/tide-pool.css +++ b/public/tide-pool.css @@ -54,7 +54,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .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-veiled] :is(.tide-heading p, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-status, .tide-inspect, .tide-settings) { 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%); } } @@ -66,6 +66,9 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .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; } +/* The map: the whole shelf, set under the clock like another row of the table. */ +.tide-map { display: block; width: 100%; aspect-ratio: 128 / 88; border-top: 1px solid var(--line); cursor: crosshair; touch-action: none; } +.tide-pool[data-map="off"] .tide-map { display: none; } .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; } @@ -132,13 +135,47 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .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, .tide-zoom, .tide-machines { display: flex; align-items: center; } +.tide-machines { padding-right: .3rem; border-right: 1px solid #2a272d; } +.tide-machines span { font-size: .58rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: #5f5963; } +.tide-machines button { min-width: 30px; padding: 0 .3rem; font-family: var(--site-font-display); font-size: 1.1rem; } +.tide-compass { display: grid; place-items: center; width: 44px; padding: 0; } +.tide-compass svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1; transition: none; } +.tide-compass path { fill: var(--accent); stroke: none; } +.tide-compass .tide-compass-tail { fill: currentColor; opacity: .5; } .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-utility button:is([aria-pressed="true"], [aria-expanded="true"]) { color: #e5e0e8; } + +/* Settings: a small sheet over the playback corner, with the keys beneath the choices. */ +.tide-settings { position: absolute; z-index: 4; left: clamp(16px, 3.4vw, 56px); bottom: calc(max(22px, env(safe-area-inset-bottom)) + 54px); width: 300px; max-height: calc(100svh - 150px); overflow-y: auto; + box-sizing: border-box; padding: .9rem 1rem 1rem; background: #0c0b0d; border: 1px solid var(--line); } +.tide-settings[hidden] { display: none; } +.tide-settings header { position: relative; padding-right: 2rem; margin-bottom: .5rem; } +#tide-settings-close { position: absolute; top: -.9rem; right: -1rem; width: 44px; height: 44px; padding: 0; color: var(--muted); background: none; border: 0; font-size: 1.15rem; line-height: 1; } +#tide-settings-close:hover { color: #e5e0e8; } +.tide-option { display: grid; grid-template-columns: auto 1fr; gap: .7rem; align-items: start; padding: .55rem 0; border-top: 1px solid #1f1d21; cursor: pointer; font-size: .74rem; line-height: 1.45; color: #9a949e; } +.tide-option b { display: block; font-weight: 600; font-size: .8rem; color: #e5e0e8; } +.tide-option input { width: 16px; height: 16px; margin: .15rem 0 0; accent-color: var(--accent); cursor: pointer; } +.tide-option input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.tide-keys { margin-top: .5rem; padding-top: .7rem; border-top: 1px solid var(--line); } +.tide-keys dl { margin: .5rem 0 0; } +.tide-keys div { display: grid; grid-template-columns: 4.4rem 1fr; gap: .6rem; padding: .28rem 0; font-size: .72rem; line-height: 1.4; } +.tide-keys dt { color: #e5e0e8; font-variant-numeric: tabular-nums; white-space: nowrap; } +.tide-keys dd { margin: 0; color: #9a949e; } +.tide-keys p { margin: .6rem 0 0; font-size: .72rem; line-height: 1.45; color: #9a949e; } +/* Wide screens set the keys beside the choices, so the sheet stays low. */ +@media (min-width: 900px) and (pointer: fine) { + .tide-settings { display: grid; grid-template-columns: 250px 1fr; column-gap: 1.4rem; width: 620px; } + .tide-settings header { grid-column: 1 / -1; } + .tide-option { grid-column: 1; } + .tide-keys { grid-column: 2; grid-row: 2 / span 4; margin-top: 0; padding-top: .55rem; border-top: 1px solid #1f1d21; } +} +/* A touch screen has no keys or resting mouse to set up. */ +@media (pointer: coarse) { .tide-keys, .tide-option:has([data-option="edge"]) { display: 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:not([data-news]) { opacity: 0; } @@ -160,8 +197,8 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di #tide-description:empty { display: none; } /* 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) { +.tide-heading, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-inspect, .tide-notes, .tide-settings { 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, .tide-settings) { 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; } @@ -173,9 +210,13 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di @media (max-width: 1100px) { .tide-ticker { display: none; } } +@media (max-height: 860px) and (min-width: 721px) { + .tide-pool:has(.tide-inspect:not([hidden])) .tide-map { 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-settings { bottom: calc(max(22px, env(safe-area-inset-bottom)) + 112px); } .tide-hint { margin-bottom: 3.9rem; } .tide-status { bottom: 136px; } } @@ -189,7 +230,8 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di .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-census, .tide-map, .tide-option:has([data-option="map"]) { display: none; } + .tide-settings { left: 12px; right: 12px; width: auto; bottom: calc(max(12px, env(safe-area-inset-bottom)) + 104px); } .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; } diff --git a/public/tide-pool.js b/public/tide-pool.js index a15a8ff..577ede4 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1386,9 +1386,10 @@ async function initialize() { let cursor = { x: START_POOL.x, z: START_POOL.z }; const marker = { visible: false }; - // Camera: an orthographic, slightly turned view with pan and zoom. + // Camera: an orthographic, slightly turned view with pan, zoom, and a turn about the middle of the view. YAW is the turn + // it opens at, looking in across the shelf with the sea behind. const YAW = -.3, PITCH = .74; - const view = { x: START_POOL.x, z: START_POOL.z + .6, zoom: 7 }, goal = { ...view }; + const view = { x: START_POOL.x, z: START_POOL.z + .6, zoom: 7, yaw: YAW }, goal = { ...view }; let intro = null; const ZMIN = 1.4, ZMAX = 20; const reduced = matchMedia('(prefers-reduced-motion: reduce)'); @@ -1397,7 +1398,7 @@ async function initialize() { 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)); + const dir = v2.set(Math.sin(view.yaw) * Math.cos(PITCH), Math.sin(PITCH), Math.cos(view.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(); @@ -1412,7 +1413,7 @@ async function initialize() { goal.z = clamp(goal.z, Z0 + 3, Z1 - 1); } function cameraSettled() { - return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002; + return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002 && Math.abs(goal.yaw - view.yaw) < .001; } // 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. @@ -1489,7 +1490,7 @@ async function initialize() { 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; + view.x += (goal.x - view.x) * k; view.z += (goal.z - view.z) * k; view.zoom += (goal.zoom - view.zoom) * k; view.yaw += (goal.yaw - view.yaw) * k; placeCamera(); } @@ -1636,7 +1637,7 @@ async function initialize() { 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.uHaze.value.set(-Math.sin(view.yaw), -Math.cos(view.yaw), view.x, view.z); shared.uHazeZoom.value = view.zoom; shared.uWater.value = waterLevel(world); shared.uSwellLift.value = swellHeight(world); @@ -1702,6 +1703,7 @@ async function initialize() { if (reveal?.motion) openIris(); if (composer) composer.render(); else renderer.render(scene, camera); placeLabels(); + pointCompass(); drawMap(); dirty = false; } @@ -1745,6 +1747,7 @@ async function initialize() { b.setAttribute('aria-current', String(focus?.sp === b.dataset.species)); }); drawChart(focus?.sp); + paintTerrain(); inspect(); } function drawChart(highlight) { @@ -1990,7 +1993,7 @@ async function initialize() { 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(); + placeCamera(); sizeMap(); wake(); } const names = { brass: 'Brass', shell: 'Shell', pebble: 'Pebble', feed: 'Feed' }; @@ -2044,16 +2047,32 @@ async function initialize() { } wake(); } + // Turns the view about its middle; a positive turn swings the view to the left, so the scene turns clockwise. + function turnBy(angle) { + goal.yaw += angle; view.yaw = goal.yaw; + placeCamera(); wake(); + } + const wrapAngle = a => a - Math.round(a / (Math.PI * 2)) * Math.PI * 2; + // A double tap or double click goes in closer on that spot, or on the machine being followed. + function zoomInto(clientX, clientY) { + intro = null; root.dataset.engaged = 'true'; + const hit = !following && floorAt(clientX, clientY); + if (hit) { goal.x = hit.x; goal.z = hit.z; } + goal.zoom = goal.zoom * .5; clampGoal(); wake(); + } + let lastTap = null; canvas.addEventListener('pointerdown', e => { try { canvas.setPointerCapture?.(e.pointerId); } catch { /* a pointer the page cannot capture still works without it */ } pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (e.pointerType !== 'mouse') holdLantern(e); - if (pointers.size === 1) gesture = { x: e.clientX, y: e.clientY, moved: false }; + // The right button, or the left with Option or Alt held, turns the view instead of panning it. + if (pointers.size === 1) gesture = { x: e.clientX, y: e.clientY, moved: false, turn: e.pointerType === 'mouse' && (e.button === 2 || e.altKey) }; 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 }; + 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 }, angle: Math.atan2(b.y - a.y, b.x - a.x), twist: 0, moved: true }; } }); + canvas.addEventListener('contextmenu', e => e.preventDefault()); // 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) { @@ -2076,19 +2095,30 @@ async function initialize() { } const dx = e.clientX - known.x, dy = e.clientY - known.y; known.x = e.clientX; known.y = e.clientY; + if (e.pointerType === 'mouse') hovering = { clientX: e.clientX, clientY: e.clientY }; if (e.pointerType !== 'mouse' && pointers.size === 1) holdLantern(e); 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'); intro = null; root.dataset.engaged = 'true'; - if (following) setFollow(false); + // Turning keeps a followed machine in the middle; panning lets go of it. + if (following && !gesture.turn) setFollow(false); + } + if (gesture.moved) { + // Half a turn across the width of the view, the ground near the eye following the hand. + if (gesture.turn) turnBy(-dx * Math.PI / Math.max(1, canvas.clientWidth)); + else { panBy(dx, dy); wake(); } } - 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); + // Twisting two fingers turns the view with them, once the twist is plainly meant, so a pinch alone stays level. + const angle = Math.atan2(b.y - a.y, b.x - a.x), twist = wrapAngle(angle - gesture.angle); + gesture.twist += twist; gesture.angle = angle; + if (gesture.turning) turnBy(twist); + else if (Math.abs(gesture.twist) > .2) { gesture.turning = true; turnBy(gesture.twist); } gesture.pinch = d; gesture.mid = mid; } }); @@ -2097,10 +2127,16 @@ async function initialize() { pointers.delete(e.pointerId); if (e.pointerType !== 'mouse' && !pointers.size) dropLantern(); canvas.classList.remove('is-dragging'); - if (pointers.size) { gesture = null; return; } - const tap = gesture && !gesture.moved && !cancelled; + if (pointers.size) { gesture = null; lastTap = null; return; } + const tap = gesture && !gesture.moved && !gesture.turn && !cancelled; gesture = null; - if (!tap) return; + if (!tap) { lastTap = null; return; } + // The second tap of a double tap zooms in rather than tapping again. + const now = performance.now(); + if (lastTap && now - lastTap.time < 320 && Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < 24) { + lastTap = null; zoomInto(e.clientX, e.clientY); return; + } + lastTap = { time: now, x: e.clientX, y: e.clientY }; const hitCreature = creatureAt(e.clientX, e.clientY); if (hitCreature) { select(hitCreature); return; } const hit = floorAt(e.clientX, e.clientY); @@ -2130,28 +2166,41 @@ async function initialize() { : list[(i + direction + list.length) % list.length]; select(next); } - canvas.addEventListener('keydown', e => { - const handled = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' ', '+', '=', '-', '_', '[', ']', 'Escape', '1', '2', '3', '4', '5', '6', '7', '8', '9']; - 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 ('123456789'.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); + // Where Space or Enter acts: under a resting mouse, else at the keyboard's marker while it is in view, else in the + // middle of the view. + function onScreen(p) { + v1.set(p.x, heightAt(world, p.x, p.z), p.z).project(camera); + return Math.abs(v1.x) < .95 && Math.abs(v1.y) < .95; + } + function aimPoint() { + if (hovering) return floorAt(hovering.clientX, hovering.clientY); + if (marker.visible && onScreen(cursor)) return cursor; + const b = canvas.getBoundingClientRect(); + return floorAt(b.left + b.width / 2, b.top + b.height / 2); + } + function act() { + const at = aimPoint(); + if (!at) return; + cursor = { x: at.x, z: at.z }; + if (tool === 'look') { + const near = world.creatures.reduce((a, c) => !a || Math.hypot(c.x - at.x, c.z - at.z) < Math.hypot(a.x - at.x, a.z - at.z) ? c : a, null); + if (near && Math.hypot(near.x - at.x, near.z - at.z) < 1.5) select(near); + else status.textContent = 'No machine there. Brackets step between machines.'; + } else { drop(at.x, at.z); marker.visible = true; } + wake(); + } + // Z and X step through what a tap does, in the order of the buttons. + function cycleTool(direction) { + const i = toolButtons.findIndex(b => b.dataset.tool === tool); + toolButtons[(i + direction + toolButtons.length) % toolButtons.length].click(); + } + // Screen-relative arrows move the marker; with Shift they pan the view. + function nudge(key, shift) { + const axis = new T.Vector3().setFromMatrixColumn(camera.matrixWorld, key === 'ArrowLeft' || key === 'ArrowRight' ? 0 : 1); axis.y = 0; axis.normalize(); - const direction = e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 1; - const step = e.shiftKey ? view.zoom * .25 : .25; - if (e.shiftKey) { + const direction = key === 'ArrowLeft' || key === 'ArrowDown' ? -1 : 1; + const step = shift ? view.zoom * .25 : .25; + if (shift) { if (following) setFollow(false); goal.x += axis.x * step * direction; goal.z += axis.z * step * direction; clampGoal(); } else { @@ -2160,41 +2209,91 @@ async function initialize() { 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(); - }); + } - // 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; + // Keys work from anywhere on the page but a text field, and leave the browser's own shortcuts alone. Held keys move the + // view the way a game's camera moves: W, A, S and D glide it across the screen, Q and E turn it, R and F zoom, and + // Shift goes faster. They are read by where they sit on the keyboard, so other layouts work too. + const DRIVE = { KeyW: [0, 1, 0, 0], KeyA: [-1, 0, 0, 0], KeyS: [0, -1, 0, 0], KeyD: [1, 0, 0, 0], KeyQ: [0, 0, 1, 0], KeyE: [0, 0, -1, 0], KeyR: [0, 0, 0, -1], KeyF: [0, 0, 0, 1] }; + const held = new Set(), pace = { x: 0, y: 0, turn: 0, zoom: 0 }; + let hurry = false, edging = false, edgeSince = 0; + const typing = el => el?.closest?.('input:not([type="checkbox"]), textarea, select, [contenteditable]'); + const pressable = el => el !== canvas && el?.closest?.('button, a, summary, input, label'); 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(); + if (!root.dataset.ready || !visible || typing(e.target)) return; + if (DRIVE[e.code]) { + e.preventDefault(); + if (held.has(e.code)) return; + held.add(e.code); + intro = null; root.dataset.engaged = 'true'; + // Gliding lets go of a followed machine; turning and zooming keep it in the middle. + if (following && (DRIVE[e.code][0] || DRIVE[e.code][1])) setFollow(false); + stir(); wake(); + } else if (command(e)) { e.preventDefault(); stir(); } }); 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 command(e) { + const { key } = e; + if (key === '?') { toggleSettings(); return true; } + if (key === 'Escape') { + if (!settingsPanel.hidden) { toggleSettings(false); return true; } + if (selected !== null) { release(); status.textContent = 'Released.'; return true; } + return false; + } + if (key === ' ' || key === 'Enter') { if (pressable(e.target)) return false; act(); return true; } + if (e.code === 'KeyZ' || e.code === 'KeyX') { cycleTool(e.code === 'KeyX' ? 1 : -1); return true; } + if (key === '[' || key === ']') { stepSelection(key === ']' ? 1 : -1); return true; } + if (key.length === 1 && '+=-_'.includes(key)) { zoomAt(key === '+' || key === '=' ? .8 : 1.25); return true; } + if (/^[1-9]$/.test(key)) { goToBasin(+key - 1); return true; } + // Arrows still scroll the notes when they are being read. + if (key.startsWith('Arrow') && !e.target.closest?.('.tide-notes, .tide-settings')) { nudge(key, e.shiftKey); return true; } + return false; + } + // Eases toward the held keys' pace and away again on release, so the view starts and stops softly. Reports whether it + // is moving, 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; } + let x = 0, y = 0, turn = 0, zoom = 0; + for (const code of held) { const [a, b, c, d] = DRIVE[code]; x += a; y += b; turn += c; zoom += d; } + const length = Math.hypot(x, y) || 1, fast = hurry ? 2.5 : 1; + x = x / length * .6 * fast; y = y / length * .6 * fast; + // With edge scrolling on, a mouse resting near the edge of the view carries it that way, faster the closer it is. + // It waits a moment first, so a mouse passing over the edge on its way somewhere doesn't move anything. + const edge = options.edge && hovering && !pointers.size && document.hasFocus() && edgePull(); + edging = !!edge; + if (!edge) edgeSince = 0; + else if (!edgeSince) edgeSince = performance.now(); + const pulling = edge && performance.now() - edgeSince > 250; + if (pulling) { + x += edge.x * .9; y += edge.y * .9; + intro = null; + if (following) setFollow(false); + } + const k = 1 - Math.exp(-dt * (reduced.matches ? 60 : 10)); + pace.x += (x - pace.x) * k; pace.y += (y - pace.y) * k; + pace.turn += (clamp(turn, -1, 1) * 1.4 * fast - pace.turn) * k; pace.zoom += (clamp(zoom, -1, 1) * 1.1 * fast - pace.zoom) * k; + if (!held.size && !pulling && Math.abs(pace.x) + Math.abs(pace.y) + Math.abs(pace.turn) + Math.abs(pace.zoom) < .01) { + pace.x = pace.y = pace.turn = pace.zoom = 0; + // Waiting at the edge still needs frames to notice when the wait is over. + return edging; + } // 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 (pace.x || pace.y) panBy(-pace.x * height, pace.y * height); + if (pace.turn) turnBy(pace.turn * dt); + if (pace.zoom) { goal.zoom = clamp(goal.zoom * Math.exp(pace.zoom * dt), ZMIN, ZMAX); view.zoom = goal.zoom; placeCamera(); } if (hovering) hover(hovering); return true; } + function edgePull() { + const b = canvas.getBoundingClientRect(), EDGE = 36, { clientX: x, clientY: y } = hovering; + const near = d => Math.max(0, 1 - d / EDGE); + const pull = { x: near(b.right - x) - near(x - b.left), y: near(y - b.top) - near(b.bottom - y) }; + return pull.x || pull.y ? pull : null; + } function goToBasin(i) { intro = null; @@ -2233,6 +2332,124 @@ async function initialize() { marker.visible = tool !== 'look' && document.activeElement === canvas; wake(); })); + // Settings stay in this browser, and only these few choices. + const OPTIONS = 'tide-pool-options'; + const options = { wander: true, edge: false, map: true }; + try { Object.assign(options, JSON.parse(localStorage.getItem(OPTIONS) || '{}')); } catch { /* the defaults do */ } + const settingsPanel = $('#tide-settings'), settingsButton = $('#tide-settings-toggle'); + function toggleSettings(open = settingsPanel.hidden) { + settingsPanel.hidden = !open; + settingsButton.setAttribute('aria-expanded', String(open)); + root.dataset.engaged = 'true'; + } + settingsButton.addEventListener('click', () => toggleSettings()); + $('#tide-settings-close').addEventListener('click', () => { toggleSettings(false); settingsButton.focus(); }); + function applyOptions() { + root.dataset.map = options.map ? 'on' : 'off'; + // Turning the wandering off stops it where it is. + if (!options.wander && director.on) { director.on = false; director.shot = null; Object.assign(goal, { x: view.x, z: view.z, zoom: view.zoom }); } + sizeMap(); wake(); + } + root.querySelectorAll('[data-option]').forEach(box => { + box.checked = !!options[box.dataset.option]; + box.addEventListener('change', () => { + options[box.dataset.option] = box.checked; + try { localStorage.setItem(OPTIONS, JSON.stringify(options)); } catch { /* kept for this visit only */ } + applyOptions(); + }); + }); + + // The compass shows which way the view is turned, and turns it back to where it began. + const compass = $('#tide-compass'), needle = compass.querySelector('svg'); + let shownYaw = null; + function pointCompass() { + if (Math.abs(view.yaw - (shownYaw ?? Infinity)) < .002) return; + shownYaw = view.yaw; + needle.style.transform = `rotate(${view.yaw}rad)`; + } + compass.addEventListener('click', () => { + intro = null; + goal.yaw = view.yaw + wrapAngle(YAW - view.yaw); + status.textContent = 'The view turns back to face in across the shelf.'; + wake(); + }); + $('#tide-machine-prev').addEventListener('click', () => stepSelection(-1)); + $('#tide-machine-next').addEventListener('click', () => stepSelection(1)); + + // The map: the whole shelf from above, the sea along its foot, with the water, the structures, the machines, and the + // part of the shelf in view. Tap or drag on it to go there. + const mapCanvas = $('#tide-map'), mapContext = mapCanvas.getContext('2d'); + const terrain = document.createElement('canvas'); terrain.width = COLS; terrain.height = ROWS; + const terrainContext = terrain.getContext('2d'), terrainImage = terrainContext.createImageData(COLS, ROWS); + let mapSize = null, mapClock = 0; + function sizeMap() { + const width = options.map ? mapCanvas.clientWidth : 0; + mapSize = width ? { width, height: width * ROWS / COLS, ratio: Math.min(devicePixelRatio, 2) } : null; + if (!mapSize) return; + mapCanvas.width = Math.round(mapSize.width * mapSize.ratio); mapCanvas.height = Math.round(mapSize.height * mapSize.ratio); + paintTerrain(); drawMap(true); + } + function paintTerrain() { + if (!mapSize) return; + const { w, h, wall } = world, data = terrainImage.data; + for (let k = 0; k < COLS * ROWS; k++) { + const ground = h[k] + wall[k], depth = w[k] - ground, o = k * 4; + if (depth > .02) { + // Water: violet, deeper the deeper it lies. + const d = clamp(depth / .9, 0, 1); + data[o] = 46 + 34 * d; data[o + 1] = 38 + 26 * d; data[o + 2] = 74 + 76 * d; + } else { + // Rock: darker low down, lighter where it stands up. + const r = clamp((ground + 1.2) / 2.6, 0, 1); + data[o] = data[o + 1] = 18 + 44 * r; data[o + 2] = 20 + 46 * r; + } + data[o + 3] = 255; + } + terrainContext.putImageData(terrainImage, 0, 0); + } + function drawMap(now) { + if (!mapSize) return; + const t = performance.now(); + if (!now && t - mapClock < 90) return; + mapClock = t; + const { width, height, ratio } = mapSize, g = mapContext; + g.setTransform(ratio, 0, 0, ratio, 0, 0); + g.imageSmoothingEnabled = true; + g.drawImage(terrain, 0, 0, width, height); + const sx = width / (X1 - X0), sz = height / (Z1 - Z0); + g.fillStyle = '#d6b980'; + for (const s of world.structures) g.fillRect((s.x - X0) * sx - 1.5, (s.z - Z0) * sz - 1.5, 3, 3); + for (const c of world.creatures) { + const on = c.id === selected; + g.fillStyle = on ? '#d4b9df' : '#e5e0e8'; + g.beginPath(); g.arc((c.x - X0) * sx, (c.z - Z0) * sz, on ? 2.6 : 1.1, 0, Math.PI * 2); g.fill(); + } + // The part in view: the corners of the screen laid on the ground, its far edge marked so its turn reads. + const b = canvas.getBoundingClientRect(); + const corners = [[b.left, b.top], [b.right, b.top], [b.right, b.bottom], [b.left, b.bottom]].map(([x, y]) => groundAt(x, y)); + if (corners.every(Boolean)) { + const at = corners.map(p => [(p.x - X0) * sx, (p.z - Z0) * sz]); + g.beginPath(); at.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); g.closePath(); + g.fillStyle = '#ffffff12'; g.fill(); + g.lineWidth = 1; g.strokeStyle = '#e5e5e5aa'; g.stroke(); + g.beginPath(); g.moveTo(...at[0]); g.lineTo(...at[1]); + g.lineWidth = 2; g.strokeStyle = '#d4b9df'; g.stroke(); + } + } + function mapTo(e) { + const b = mapCanvas.getBoundingClientRect(); + intro = null; root.dataset.engaged = 'true'; + if (following) setFollow(false); + goal.x = X0 + (e.clientX - b.left) / b.width * (X1 - X0); + goal.z = Z0 + (e.clientY - b.top) / b.height * (Z1 - Z0); + clampGoal(); wake(); + } + mapCanvas.addEventListener('pointerdown', e => { + try { mapCanvas.setPointerCapture?.(e.pointerId); } catch { /* the tap alone still goes there */ } + mapTo(e); + }); + mapCanvas.addEventListener('pointermove', e => { if (mapCanvas.hasPointerCapture?.(e.pointerId)) mapTo(e); }); + pauseButton.addEventListener('click', () => { paused = !paused; updateButtons(); schedule(); status.textContent = paused ? 'The pool is resting. You can still pan, zoom, and follow.' : 'The pool is awake.'; @@ -2297,6 +2514,7 @@ async function initialize() { window.addEventListener('pageshow', () => { visible = true; schedule(); }); if (paused) world.objects.forEach(o => { o.height = 0; }); + applyOptions(); resize(); if (!reduced.matches) { // Open close on the busiest spot in the first pool, then draw back to show the shelf around it. @@ -2315,16 +2533,16 @@ 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.' : `${mobile ? 'Drag' : 'Drag, or hold W A S D,'} 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 to look around, and twist two fingers to turn.' : 'Drag, or hold W A S D, to look around. Press ? for keys.'} 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 = () => held.size || root.querySelector('.tide-notes[open], :is(.tide-heading, .tide-hud, .tide-inspect, .tide-controls, .tide-nav, .tide-utility):hover') || + const busy = () => held.size || edging || root.querySelector('.tide-notes[open], .tide-settings:not([hidden]), :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; } 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(); } + if (!following && !reduced.matches && options.wander) { director.on = true; director.shot = null; wake(); } } function stir(first) { // Any touch stops the wandering camera where it is. @@ -2336,5 +2554,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(); }, 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; }, lantern, probe: (x, y) => floorAt(x, y), 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(); }, steer: seconds => { for (let t = 0; t < seconds; t += 1 / 30) { drive(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; }, lantern, probe: (x, y) => floorAt(x, y), 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 5a6154d..3d4794c 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -12,6 +12,20 @@ const SPECIES = [ // 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"; +// The keys, as the settings panel lists them. +const KEYS = [ + ["W A S D", "Glide the view. Hold Shift to go faster."], + ["Q E", "Turn the view."], + ["R F", "Zoom in and out. Plus and minus work too."], + ["Z X", "Choose what a tap drops."], + ["Space", "Drop it where you point, or follow the machine there in Look mode. Enter works too."], + ["Arrows", "Move the marker. With Shift, pan."], + ["[ ]", "Step between machines."], + ["1–9", "Go to a pool."], + ["Esc", "Let go of the machine you follow."], + ["?", "Open or close this panel."], +]; + const TOOLS = [ { id: "look", label: "Look" }, { id: "brass", label: "Brass" }, @@ -33,7 +47,7 @@ export function TidePoolContent() {
{/* While the pool loads, and if the view is ever lost: the ring fills like a pool, then the view opens out of it. */}
@@ -61,6 +75,7 @@ export function TidePoolContent() {
Tide
Mid
Pool
III
+
+
+ + + +
{/* Filled in by the page with the pools that exist right now. */}
+
@@ -124,7 +147,33 @@ export function TidePoolContent() { +
+