nordic-skating / commit
feat(ui): a sound button that says which way it is set, and links to the README and source
README.md | 11 +++++-- docs/screenshots/autopilot.png | Bin 187810 -> 211085 bytes docs/screenshots/far.png | Bin 227583 -> 224452 bytes docs/screenshots/landscape.png | Bin 319389 -> 245957 bytes docs/screenshots/steering.png | Bin 314805 -> 253068 bytes docs/screenshots/thin-ice.png | Bin 289903 -> 267775 bytes index.html | 7 ++++- src/main.ts | 64 +++++++++++++++++++++++++++++++++++------ src/style.css | 44 ++++++++++++++++++++++++++++ 9 files changed, 114 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 926e862..a45d681 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,11 @@ and at its thinnest gives way. ## Controls -Tap or press a key once to turn the sound on: a browser lets a page make sound only -after it has been touched. On an iPhone the ring/silent switch also silences it. +The button at the top right turns the sound on and off, as does the M key, and says +which it is. Sound cannot start by itself: a browser lets a page make sound only after +it has been touched, so until then the button reads "Tap for sound", and the first tap +or key anywhere starts it. Turning it off is remembered for next time. On an iPhone the +ring/silent switch also silences it. Do nothing and the skater follows the middle of the lake, round every bend and down one side of every fork, for as long as you leave it. @@ -40,6 +43,10 @@ one side of every fork, for as long as you leave it. |---|---|---| | Steer left or right | Drag sideways from wherever you touch | ← → or A D | | Choose a way at a fork | Steer towards it before the island begins | the same | +| Sound on or off | The button at the top right | M | + +The links at the top left, About and Source, open this README and the repository in a +new tab, so the skate is still going when you come back. Steering moves the skater across the lake, never into a shore. Let go and the skater holds that line for a second and a half, then eases back to the middle. diff --git a/docs/screenshots/autopilot.png b/docs/screenshots/autopilot.png index e33572f..b02260e 100644 Binary files a/docs/screenshots/autopilot.png and b/docs/screenshots/autopilot.png differ diff --git a/docs/screenshots/far.png b/docs/screenshots/far.png index 45ea391..b54ee97 100644 Binary files a/docs/screenshots/far.png and b/docs/screenshots/far.png differ diff --git a/docs/screenshots/landscape.png b/docs/screenshots/landscape.png index 403eb55..77988ec 100644 Binary files a/docs/screenshots/landscape.png and b/docs/screenshots/landscape.png differ diff --git a/docs/screenshots/steering.png b/docs/screenshots/steering.png index 002dcf3..c296417 100644 Binary files a/docs/screenshots/steering.png and b/docs/screenshots/steering.png differ diff --git a/docs/screenshots/thin-ice.png b/docs/screenshots/thin-ice.png index 9ebf776..a0b7b66 100644 Binary files a/docs/screenshots/thin-ice.png and b/docs/screenshots/thin-ice.png differ diff --git a/index.html b/index.html index aa4a089..5e282fe 100644 --- a/index.html +++ b/index.html @@ -8,8 +8,13 @@ </head> <body> <canvas id="scene"></canvas> +<nav id="links"> + <a href="https://gitlab.com/PavelSafronov/nordic-skating/-/blob/main/README.md" target="_blank" rel="noopener">About</a> + <a href="https://gitlab.com/PavelSafronov/nordic-skating" target="_blank" rel="noopener">Source</a> +</nav> +<button id="sound" type="button"></button> <div id="water"><p>The ice gave way</p></div> -<p id="hint">Tap for sound · drag left or right to steer — or just watch</p> +<p id="hint">Drag left or right to steer — or just watch</p> <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/src/main.ts b/src/main.ts index 0c3a96c..57596a6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,8 @@ const SEED = 1 const canvas = document.querySelector<HTMLCanvasElement>('#scene')! const hint = document.querySelector<HTMLElement>('#hint')! const water = document.querySelector<HTMLElement>('#water')! +const soundButton = document.querySelector<HTMLButtonElement>('#sound')! +const links = document.querySelector<HTMLElement>('#links')! const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) // Phones report a ratio of 3 or more, and the fill cost grows with its square. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) @@ -68,19 +70,62 @@ const clock = new THREE.Clock() setTimeout(() => hint.classList.add('gone'), 8000) // A browser lets a page make sound only once it has been touched, so the sound starts -// at the first tap or key rather than with the page. +// at the first tap or key rather than with the page — unless it has been muted, which +// is remembered from one visit to the next. let audio: AudioContext | null = null let sound: IceSound | null = null +let muted = false +try { + muted = localStorage.getItem('muted') === '1' +} catch { + // No storage, in a private window for one: the choice lasts for this visit only. +} +/** Whether the ice can be heard at this moment. */ +const sounding = (): boolean => audio?.state === 'running' + +function showSound(): void { + const on = audio !== null && !muted + soundButton.textContent = on ? '🔊 Sound on' : muted ? '🔇 Sound off' : '🔇 Tap for sound' + soundButton.setAttribute('aria-pressed', String(on)) + soundButton.classList.toggle('on', on) +} function startSound(): void { - if (audio) return void audio.resume() - audio = new AudioContext() - sound = new IceSound(audio) + if (muted) return + if (audio) void audio.resume() + else { + audio = new AudioContext() + sound = new IceSound(audio) + // The button reads "on" from the first touch; this is for when the browser disagrees. + audio.addEventListener('statechange', showSound) + } + showSound() } -window.addEventListener('pointerdown', startSound) -window.addEventListener('keydown', startSound) +function setMuted(to: boolean): void { + muted = to + try { + localStorage.setItem('muted', to ? '1' : '0') + } catch { + // As above. + } + if (muted) void audio?.suspend() + else startSound() + showSound() +} +showSound() +// A touch of the button must not also start the sound, since it has just been asked to +// do the opposite; nor a touch of a link, which is someone on their way out. +const aside = (e: Event): boolean => e.target === soundButton || links.contains(e.target as Node) +soundButton.addEventListener('click', () => setMuted(audio !== null && !muted)) +window.addEventListener('pointerdown', (e) => { + if (!aside(e)) startSound() +}) +window.addEventListener('keydown', (e) => { + if (e.code === 'KeyM') setMuted(audio !== null && !muted) + else if (!aside(e)) startSound() +}) // Frames stop while the page is hidden, but sound would not: the hum would drone on. document.addEventListener('visibilitychange', () => { - if (audio) void (document.hidden ? audio.suspend() : audio.resume()) + if (audio && !muted) void (document.hidden ? audio.suspend() : audio.resume()) }) /** Seconds until the ice next cracks somewhere off across the lake. */ let untilCrack = 3 @@ -95,11 +140,12 @@ renderer.setAnimationLoop(() => { stepSkater(skater, lake, steer, dt) const h = lake.thickness(skaterX(skater), skater.s, skater.channel) const fell = skater.under > 0 && !wasUnder - if (fell) sound?.fall(h) + // Nothing is asked of the sound while it is off: it would all play at once on its return. + if (fell && sounding()) sound?.fall(h) // In the water for all but the last second, which is the climb back out. water.classList.toggle('under', skater.under > 1) - if (sound && skater.under === 0) { + if (sound && sounding() && skater.under === 0) { // One strike of a skate at the start of each stride, no two quite alike. if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, 0.75 + 0.5 * Math.random()) // The push is over half a second into a stride; the rest of it is glide. diff --git a/src/style.css b/src/style.css index 6901933..5aff98c 100644 --- a/src/style.css +++ b/src/style.css @@ -54,3 +54,47 @@ body { opacity: 1; transition: opacity 0.25s 0.15s; } + +/* Big, in a corner, and saying in words which way it is set. */ +#sound { + position: fixed; + top: calc(0.75rem + env(safe-area-inset-top)); + right: calc(0.75rem + env(safe-area-inset-right)); + min-height: 3.25rem; + padding: 0 1.1rem; + border: 2px solid #fff; + border-radius: 2rem; + background: rgb(8 24 34 / 85%); + color: #fff; + font: 600 1.05rem/1 system-ui, sans-serif; + /* A tap on it is a tap, not the start of a drag or a double-tap zoom. */ + touch-action: manipulation; + cursor: pointer; +} + +#sound.on { + background: #fff; + color: #06202c; +} + +/* The way to the README and the source: there, but smaller than the sound button. */ +#links { + position: fixed; + top: calc(0.75rem + env(safe-area-inset-top)); + left: calc(0.75rem + env(safe-area-inset-left)); + display: flex; + gap: 0.5rem; +} + +#links a { + display: grid; + place-items: center; + min-height: 2.75rem; + padding: 0 0.9rem; + border-radius: 2rem; + background: rgb(8 24 34 / 70%); + color: #fff; + font: 500 0.95rem/1 system-ui, sans-serif; + text-decoration: none; + touch-action: manipulation; +}