nordic-skating / local-littlejs / files / src / main.ts
Branch local-littlejs · default branch · all branches
main.ts
| 1 | import './style.css' |
| 2 | import skateIcon from '../assets/skate-shoes.png' |
| 3 | import soundOffIcon from '../assets/sound-off.png' |
| 4 | import soundOnIcon from '../assets/sound-on.png' |
| 5 | import tiltOffIcon from '../assets/tilt-off.png' |
| 6 | import tiltOnIcon from '../assets/tilt-on.png' |
| 7 | import * as THREE from 'three' |
| 8 | import { IceSound } from './audio/iceSound.js' |
| 9 | import { Input } from './input.js' |
| 10 | import { Drifts } from './render/drifts.js' |
| 11 | import { Ice } from './render/ice.js' |
| 12 | import { Marks } from './render/marks.js' |
| 13 | import { Shores } from './render/shores.js' |
| 14 | import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js' |
| 15 | import { Snow } from './render/snow.js' |
| 16 | import { WEATHER, WEATHERS, type Weather, anyLake, loadSettings, saveSettings, timeOf, weatherOf, windOf } from './settings.js' |
| 17 | import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js' |
| 18 | import { PACES, PACE_NAMES, type Pace, UNDER_SECONDS, createSkater, pushSeconds, sincePush, stepSkater } from './skater.js' |
| 19 | import { createLake } from './world/lake.js' |
| 20 | |
| 21 | // Eye height of a skater leaning into a stride, in metres. |
| 22 | const EYE = 1.5 |
| 23 | |
| 24 | // Every visit is to a different lake, one of a million, under a sky of its own: `?seed=` |
| 25 | // asks for a particular one, which is how a lake is linked to, and how it is photographed. |
| 26 | // `?time=` asks for a time of day whatever the lake's own is. |
| 27 | // |
| 28 | // Which lake a page is showing is kept with its place in the browser's history, not in |
| 29 | // its address. So Back returns to the lake that was there, and a reload, which is |
| 30 | // someone asking for something new, gets a new one. |
| 31 | const params = new URLSearchParams(location.search) |
| 32 | const numbered = (from: unknown): number => Math.floor(Number(from)) || 0 |
| 33 | const arrival = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined |
| 34 | const returning = arrival?.type === 'back_forward' |
| 35 | let seed = numbered(params.get('seed')) || (returning && numbered(history.state?.seed)) || anyLake() |
| 36 | history.replaceState({ seed }, '') |
| 37 | const settings = loadSettings() |
| 38 | |
| 39 | const canvas = document.querySelector<HTMLCanvasElement>('#scene')! |
| 40 | const hint = document.querySelector<HTMLElement>('#hint')! |
| 41 | const water = document.querySelector<HTMLElement>('#water')! |
| 42 | const soundButton = document.querySelector<HTMLButtonElement>('#sound')! |
| 43 | const links = document.querySelector<HTMLElement>('#links')! |
| 44 | const aboutButton = document.querySelector<HTMLButtonElement>('#about')! |
| 45 | const about = document.querySelector<HTMLDialogElement>('#about-dialog')! |
| 46 | const settingsButton = document.querySelector<HTMLButtonElement>('#settings')! |
| 47 | const randomButton = document.querySelector<HTMLButtonElement>('#randomize')! |
| 48 | const veil = document.querySelector<HTMLElement>('#veil')! |
| 49 | const settingsBox = document.querySelector<HTMLDialogElement>('#settings-dialog')! |
| 50 | /** Whether the lake is waiting behind a box that is open over it. */ |
| 51 | const waiting = (): boolean => about.open || settingsBox.open |
| 52 | const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) |
| 53 | // Phones report a ratio of 3 or more, and the fill cost grows with its square. |
| 54 | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) |
| 55 | |
| 56 | const scene = new THREE.Scene() |
| 57 | // The lake and its shores fade into the sky at its horizon, so neither has a visible end. |
| 58 | const haze = new THREE.Fog(0, 30, 600) |
| 59 | scene.fog = haze |
| 60 | scene.background = haze.color |
| 61 | |
| 62 | // The skater is always at the origin of what is drawn, looking down -z; the ice and the |
| 63 | // shores are moved past instead (render/ice.ts, render/shores.ts). |
| 64 | const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500) |
| 65 | camera.rotation.order = 'YXZ' |
| 66 | |
| 67 | // The lake reads this as it goes, so the settings box can change it under a skater. |
| 68 | const drifts = new Drifts() |
| 69 | const lakeOptions = { impossibleIce: settings.impossibleIce, snow: (x: number, s: number) => drifts.cover(x, s) } |
| 70 | let lake = createLake(seed, lakeOptions) |
| 71 | const ice = new Ice(renderer, lake) |
| 72 | const shores = new Shores(lake, seed) |
| 73 | const sky = new Sky() |
| 74 | const snow = new Snow() |
| 75 | const marks = new Marks() |
| 76 | // The light of the whole sky, and the sun or moon, which gives the banks and trees a lit side. |
| 77 | const skyLight = new THREE.HemisphereLight() |
| 78 | const sun = new THREE.DirectionalLight() |
| 79 | scene.add(sky.mesh, ice.mesh, drifts.mesh, marks.mesh, shores.group, snow.points, skyLight, sun) |
| 80 | |
| 81 | /** Put the lake under the sky, in the light and in the weather it is to have. */ |
| 82 | function showWorld(): void { |
| 83 | const look = LOOKS[timeNow()] |
| 84 | const weather = WEATHER[weatherNow()] |
| 85 | // Cloud takes the colour out of a horizon: a sunset seen through snow is grey with a |
| 86 | // memory of orange in it. |
| 87 | const horizon = { h: 0, s: 0, l: 0 } |
| 88 | haze.color.set(look.horizon).getHSL(horizon) |
| 89 | haze.color.setHSL(horizon.h, horizon.s * (1 - 0.75 * weather.overcast), horizon.l) |
| 90 | sky.show(look, seed, weather.overcast, haze.color) |
| 91 | snow.show(weather.snow, look.snow) |
| 92 | // Snow that falls lies, in drifts, laid by this lake's wind. |
| 93 | drifts.show(seed, weather.snow, windOf(seed)) |
| 94 | // Snow in the air is haze as well: the further shore goes first, and in a blizzard both. |
| 95 | haze.far = weather.sight |
| 96 | skyLight.color.set(look.skyLight) |
| 97 | skyLight.groundColor.set(look.groundLight) |
| 98 | skyLight.intensity = look.skyStrength |
| 99 | sun.color.set(look.sun) |
| 100 | sun.intensity = look.sunStrength |
| 101 | sun.position.set(...look.sunFrom) |
| 102 | ice.light = look.iceLight |
| 103 | // The marks on the ice are seen by the same light, though never quite lost in the dark. |
| 104 | marks.light = 0.25 + 0.75 * look.iceLight |
| 105 | } |
| 106 | const asked = params.get('time') as TimeName | null |
| 107 | const askedWeather = params.get('weather') as Weather | null |
| 108 | /** The time it is on the lake: what the address asks for, or the settings, or the lake's own. */ |
| 109 | function timeNow(): TimeName { |
| 110 | return asked && TIME_NAMES.includes(asked) ? asked : settings.time === 'surprise' ? timeOf(seed) : settings.time |
| 111 | } |
| 112 | // `?pace=olympic` asks for a pace whatever the settings say: taken up for this visit, |
| 113 | // and kept only if the skater goes on to change it. |
| 114 | const askedPace = params.get('pace') as Pace | null |
| 115 | if (askedPace && PACE_NAMES.includes(askedPace)) settings.pace = askedPace |
| 116 | /** And the weather, likewise: `?weather=snow` asks for one whatever the settings say. */ |
| 117 | function weatherNow(): Weather { |
| 118 | if (askedWeather && WEATHERS.includes(askedWeather)) return askedWeather |
| 119 | return settings.weather === 'surprise' ? weatherOf(seed) : settings.weather |
| 120 | } |
| 121 | showWorld() |
| 122 | |
| 123 | /** How far below level the skater looks, radians. */ |
| 124 | let pitch = 0 |
| 125 | /** The field of view at a cruising pace, degrees; it widens with speed. */ |
| 126 | let baseFov = 65 |
| 127 | |
| 128 | function resize(): void { |
| 129 | const { clientWidth: w, clientHeight: h } = canvas |
| 130 | renderer.setSize(w, h, false) |
| 131 | camera.aspect = w / h |
| 132 | // The field of view is the vertical one. A phone held upright needs a wide one, or the |
| 133 | // view across is so narrow that both shores fall outside it. |
| 134 | baseFov = w < h ? 90 : 65 |
| 135 | camera.fov = baseFov |
| 136 | // And held upright there is far more height than the lake needs: look further down, at |
| 137 | // the ice coming, rather than fill the top half of the screen with sky. |
| 138 | pitch = w < h ? 0.3 : 0.12 |
| 139 | camera.updateProjectionMatrix() |
| 140 | snow.resize(renderer.domElement.height) |
| 141 | } |
| 142 | window.addEventListener('resize', resize) |
| 143 | resize() |
| 144 | |
| 145 | // `?s=2500` starts that many metres down the lake: for screenshots, and for checking |
| 146 | // that nothing degrades a long way from the start. |
| 147 | let skater = createSkater(lake, Number(params.get('s')) || 0) |
| 148 | const input = new Input(canvas) |
| 149 | const clock = new THREE.Clock() |
| 150 | // Whoever is just watching has no use for the hint after a first read of it. |
| 151 | setTimeout(() => hint.classList.add('gone'), 8000) |
| 152 | |
| 153 | // A browser lets a page make sound only once it has been touched, so the sound starts |
| 154 | // at the first tap or key rather than with the page — unless it has been muted, which |
| 155 | // is remembered from one visit to the next. |
| 156 | let audio: AudioContext | null = null |
| 157 | let sound: IceSound | null = null |
| 158 | let muted = false |
| 159 | try { |
| 160 | muted = localStorage.getItem('muted') === '1' |
| 161 | } catch { |
| 162 | // No storage, in a private window for one: the choice lasts for this visit only. |
| 163 | } |
| 164 | /** Whether the ice can be heard at this moment. */ |
| 165 | const sounding = (): boolean => audio?.state === 'running' |
| 166 | |
| 167 | /** |
| 168 | * A picture for a button, from line art that is black on nothing: only its shape is |
| 169 | * used, filled with whatever colour the button's words are, so that it is white on a |
| 170 | * dark button and dark on a white one (style.css). |
| 171 | */ |
| 172 | function icon(picture: string): HTMLSpanElement { |
| 173 | const span = document.createElement('span') |
| 174 | span.className = 'icon' |
| 175 | span.style.setProperty('--icon', `url(${picture})`) |
| 176 | return span |
| 177 | } |
| 178 | const soundIcon = soundButton.appendChild(icon(soundOffIcon)) |
| 179 | |
| 180 | function showSound(): void { |
| 181 | const on = audio !== null && !muted |
| 182 | // The picture says which way it is set; the words are for whoever cannot see it. |
| 183 | soundIcon.style.setProperty('--icon', `url(${on ? soundOnIcon : soundOffIcon})`) |
| 184 | soundButton.setAttribute('aria-label', on ? 'Sound on' : muted ? 'Sound off' : 'Tap for sound') |
| 185 | soundButton.title = soundButton.getAttribute('aria-label')! |
| 186 | soundButton.setAttribute('aria-pressed', String(on)) |
| 187 | soundButton.classList.toggle('on', on) |
| 188 | } |
| 189 | function startSound(): void { |
| 190 | // Nor while the lake is waiting behind a box: it starts again with the skating. |
| 191 | if (muted || waiting()) return |
| 192 | if (audio) void audio.resume() |
| 193 | else { |
| 194 | audio = new AudioContext() |
| 195 | sound = new IceSound(audio) |
| 196 | // The button reads "on" from the first touch; this is for when the browser disagrees. |
| 197 | audio.addEventListener('statechange', showSound) |
| 198 | } |
| 199 | showSound() |
| 200 | } |
| 201 | function setMuted(to: boolean): void { |
| 202 | muted = to |
| 203 | try { |
| 204 | localStorage.setItem('muted', to ? '1' : '0') |
| 205 | } catch { |
| 206 | // As above. |
| 207 | } |
| 208 | if (muted) void audio?.suspend() |
| 209 | else startSound() |
| 210 | showSound() |
| 211 | } |
| 212 | showSound() |
| 213 | // A touch of the button must not also start the sound, since it has just been asked to |
| 214 | // do the opposite; nor a touch of a link, which is someone on their way out. |
| 215 | const aside = (e: Event): boolean => soundButton.contains(e.target as Node) || links.contains(e.target as Node) |
| 216 | |
| 217 | // A box over the lake stops it while the box is open: the skater, and the sound with |
| 218 | // them. The sky goes on, and the settings can be seen taking effect behind their box. |
| 219 | for (const [button, box] of [[aboutButton, about], [settingsButton, settingsBox]] as const) { |
| 220 | button.addEventListener('click', () => { |
| 221 | box.showModal() |
| 222 | // Opened at its top, whatever it was scrolled to when it was last closed. |
| 223 | box.scrollTop = 0 |
| 224 | void audio?.suspend() |
| 225 | }) |
| 226 | box.addEventListener('close', () => { |
| 227 | if (!muted) void audio?.resume() |
| 228 | }) |
| 229 | // A touch outside the box, on the lake behind it, closes it too. |
| 230 | box.addEventListener('click', (e) => { |
| 231 | if (e.target === box) box.close() |
| 232 | }) |
| 233 | } |
| 234 | |
| 235 | // The settings box: each thing in it takes effect as it is touched, and is remembered. |
| 236 | const impossible = settingsBox.querySelector<HTMLInputElement>('#impossible')! |
| 237 | const tiltButton = document.querySelector<HTMLButtonElement>('#tilt-button')! |
| 238 | const paceButton = document.querySelector<HTMLButtonElement>('#pace-button')! |
| 239 | /** A pace's name as it is shown, and its speed as people say speeds. */ |
| 240 | const paceName = (pace: Pace): string => pace[0]!.toUpperCase() + pace.slice(1) |
| 241 | const paceSpeed = (pace: Pace): string => `${Math.round(PACES[pace].speed * 3.6)} km/h` |
| 242 | /** Keep a pace: the skater works up to it, or eases down, over the next few strides. */ |
| 243 | function setPace(pace: Pace): void { |
| 244 | settings.pace = skater.pace = pace |
| 245 | saveSettings(settings) |
| 246 | showSettings() |
| 247 | // Said where the hint is, for long enough to read. |
| 248 | hint.textContent = `${paceName(pace)} · ${paceSpeed(pace)}` |
| 249 | hint.classList.remove('gone') |
| 250 | clearTimeout(paceSaid) |
| 251 | paceSaid = window.setTimeout(() => hint.classList.add('gone'), 2500) |
| 252 | } |
| 253 | let paceSaid = 0 |
| 254 | /** One pace faster or slower; `round` goes from the fastest back to the slowest. */ |
| 255 | function stepPace(by: number, round = false): void { |
| 256 | const at = PACE_NAMES.indexOf(settings.pace) + by |
| 257 | const to = round ? (at + PACE_NAMES.length) % PACE_NAMES.length : Math.max(0, Math.min(PACE_NAMES.length - 1, at)) |
| 258 | if (PACE_NAMES[to] !== settings.pace) setPace(PACE_NAMES[to]!) |
| 259 | } |
| 260 | // The skate is the app's own icon, as on the browser's tab. |
| 261 | paceButton.append(icon(skateIcon)) |
| 262 | const paceLabel = paceButton.appendChild(document.createElement('span')) |
| 263 | paceButton.addEventListener('click', () => stepPace(1, true)) |
| 264 | // Steering by tilt is offered only on what can be tilted, by a button of its own at the |
| 265 | // foot of the screen. |
| 266 | tiltButton.hidden = !Input.canTilt |
| 267 | const tiltIcon = tiltButton.appendChild(icon(tiltOffIcon)) |
| 268 | const tiltLabel = tiltButton.appendChild(document.createElement('span')) |
| 269 | // A touch of the button is the touch an iPhone wants before it will give its leave. |
| 270 | tiltButton.addEventListener('click', () => void setTilt(!settings.tilt)) |
| 271 | /** Whether the page was put into full screen in order to hold the screen's rotation. */ |
| 272 | let wentFullScreen = false |
| 273 | /** |
| 274 | * Stop the screen turning with the phone while tilt steers, and let it turn again after. |
| 275 | * Leaning a phone over to steer is exactly what makes its screen flip to the other |
| 276 | * orientation, and the lake with it. |
| 277 | * |
| 278 | * A browser holds the rotation only for a page that fills the screen: an installed app |
| 279 | * already does, and in a browser tab the page is put into full screen to get it, and |
| 280 | * taken out again when tilt is turned off. Where the browser will do neither, as on an |
| 281 | * iPhone, nothing happens, and the phone's own rotation lock is the way. |
| 282 | */ |
| 283 | async function holdRotation(hold: boolean): Promise<void> { |
| 284 | const turning = screen.orientation as (ScreenOrientation & { lock?: (to: string) => Promise<void> }) | undefined |
| 285 | try { |
| 286 | if (!hold) { |
| 287 | turning?.unlock?.() |
| 288 | if (wentFullScreen && document.fullscreenElement) await document.exitFullscreen() |
| 289 | wentFullScreen = false |
| 290 | return |
| 291 | } |
| 292 | if (!turning?.lock) return |
| 293 | try { |
| 294 | await turning.lock(turning.type) |
| 295 | } catch { |
| 296 | if (!document.fullscreenElement) { |
| 297 | await document.documentElement.requestFullscreen() |
| 298 | wentFullScreen = true |
| 299 | } |
| 300 | await turning.lock(turning.type) |
| 301 | } |
| 302 | } catch { |
| 303 | // Not allowed here, or not without a touch: the screen turns as it always did. |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /** Turn steering by tilt on or off, and say so if the browser will not have it. */ |
| 308 | async function setTilt(on: boolean): Promise<void> { |
| 309 | const given = on && (await input.tiltOn()) |
| 310 | if (!given) input.tiltOff() |
| 311 | settings.tilt = given |
| 312 | saveSettings(settings) |
| 313 | showSettings() |
| 314 | // Refused: said where the hint is, for long enough to read, with where to allow it. |
| 315 | if (on && !given) { |
| 316 | hint.textContent = 'The browser would not allow tilt. On an iPhone: Settings, Safari, Motion & Orientation Access.' |
| 317 | hint.classList.remove('gone') |
| 318 | setTimeout(() => hint.classList.add('gone'), 10_000) |
| 319 | } |
| 320 | void holdRotation(given) |
| 321 | } |
| 322 | // Remembered from last time. An iPhone gives its leave afresh each visit and only at a |
| 323 | // touch, so there it is asked for at the first one. |
| 324 | if (settings.tilt && Input.canTilt) { |
| 325 | // (Unless that touch is of the Tilt button, which is someone turning it off.) |
| 326 | if (Input.tiltNeedsTouch) { |
| 327 | window.addEventListener('pointerdown', (e) => void (!tiltButton.contains(e.target as Node) && setTilt(true)), { once: true }) |
| 328 | } |
| 329 | else void setTilt(true) |
| 330 | } |
| 331 | const impossibleOnly = document.querySelectorAll<HTMLElement>('.impossible-only') |
| 332 | function showSettings(): void { |
| 333 | for (const radio of settingsBox.querySelectorAll<HTMLInputElement>('input[type=radio]')) { |
| 334 | radio.checked = radio.value === settings[radio.name as 'time' | 'weather' | 'pace'] |
| 335 | } |
| 336 | impossible.checked = settings.impossibleIce |
| 337 | paceLabel.textContent = paceName(settings.pace) |
| 338 | paceButton.setAttribute('aria-label', `Pace: ${paceName(settings.pace)}, ${paceSpeed(settings.pace)}. Press for the next.`) |
| 339 | // A phone in a hand when tilting it steers, and a phone by itself when it does not. |
| 340 | tiltIcon.style.setProperty('--icon', `url(${settings.tilt ? tiltOnIcon : tiltOffIcon})`) |
| 341 | tiltLabel.textContent = settings.tilt ? 'Tilt on' : 'Tilt off' |
| 342 | tiltButton.setAttribute('aria-pressed', String(settings.tilt)) |
| 343 | tiltButton.classList.toggle('on', settings.tilt) |
| 344 | hint.textContent = settings.tilt ? 'Tilt to steer — or just watch' : 'Drag left or right to steer — or just watch' |
| 345 | // What the About box says of impossible ice is there only when the ice is. |
| 346 | for (const part of impossibleOnly) part.hidden = !settings.impossibleIce |
| 347 | } |
| 348 | showSettings() |
| 349 | settingsBox.addEventListener('change', (e) => { |
| 350 | const changed = e.target as HTMLInputElement |
| 351 | if (changed.name === 'pace') return setPace(changed.value as Pace) |
| 352 | if (changed.name === 'time' || changed.name === 'weather') { |
| 353 | ;(settings[changed.name] as string) = changed.value |
| 354 | showWorld() |
| 355 | } else if (changed === impossible) { |
| 356 | settings.impossibleIce = lakeOptions.impossibleIce = impossible.checked |
| 357 | ice.redraw() |
| 358 | } |
| 359 | saveSettings(settings) |
| 360 | showSettings() |
| 361 | }) |
| 362 | function showLake(): void { |
| 363 | settingsBox.querySelector('#lake-number')!.textContent = String(seed) |
| 364 | settingsBox.querySelector<HTMLAnchorElement>('#lake-link')!.href = `?seed=${seed}` |
| 365 | } |
| 366 | showLake() |
| 367 | |
| 368 | /** Leave this lake for lake number `to`, and start at the top of it. */ |
| 369 | function enterLake(to: number): void { |
| 370 | seed = to |
| 371 | lake = createLake(seed, lakeOptions) |
| 372 | ice.show(lake) |
| 373 | shores.show(lake, seed) |
| 374 | skater = createSkater(lake) |
| 375 | skater.pace = settings.pace |
| 376 | marks.clear(skater.x, skater.s) |
| 377 | showWorld() |
| 378 | showLake() |
| 379 | // One lake does not turn into another: the haze closes in and clears on the new one. |
| 380 | veil.style.background = haze.color.getStyle() |
| 381 | veil.classList.remove('clearing') |
| 382 | void veil.offsetWidth |
| 383 | veil.classList.add('clearing') |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Go to a lake picked at random, leaving this one in the browser's history to come back |
| 388 | * to. The address loses any `?seed=`, since it no longer says which lake this is. |
| 389 | */ |
| 390 | function randomize(): void { |
| 391 | const address = new URL(location.href) |
| 392 | address.searchParams.delete('seed') |
| 393 | address.searchParams.delete('s') |
| 394 | let to = anyLake() |
| 395 | while (to === seed) to = anyLake() |
| 396 | history.pushState({ seed: to }, '', address) |
| 397 | enterLake(to) |
| 398 | } |
| 399 | randomButton.addEventListener('click', randomize) |
| 400 | settingsBox.querySelector('#new-lake')!.addEventListener('click', randomize) |
| 401 | // Back and Forward: to the lake that was at that place in the history. |
| 402 | window.addEventListener('popstate', (e) => { |
| 403 | enterLake(numbered(e.state?.seed) || numbered(new URLSearchParams(location.search).get('seed')) || anyLake()) |
| 404 | }) |
| 405 | soundButton.addEventListener('click', () => setMuted(audio !== null && !muted)) |
| 406 | window.addEventListener('pointerdown', (e) => { |
| 407 | if (!aside(e)) startSound() |
| 408 | }) |
| 409 | window.addEventListener('keydown', (e) => { |
| 410 | if (e.code === 'KeyM') setMuted(audio !== null && !muted) |
| 411 | else if (e.code === 'KeyR' && !waiting() && !e.metaKey && !e.ctrlKey) randomize() |
| 412 | else if ((e.code === 'ArrowUp' || e.code === 'KeyW') && !waiting()) stepPace(1) |
| 413 | else if ((e.code === 'ArrowDown' || e.code === 'KeyS') && !waiting() && !e.metaKey && !e.ctrlKey) stepPace(-1) |
| 414 | else if (!aside(e)) startSound() |
| 415 | }) |
| 416 | // Frames stop while the page is hidden, but sound would not: the hum would drone on. |
| 417 | document.addEventListener('visibilitychange', () => { |
| 418 | if (audio && !muted && !waiting()) void (document.hidden ? audio.suspend() : audio.resume()) |
| 419 | }) |
| 420 | // Nobody touches the screen of a lake that skates itself, so a phone would dim and |
| 421 | // sleep. The lock is the browser's to give, is dropped whenever the page is hidden, and |
| 422 | // some browsers give it only after a touch: so it is asked for at each of those moments. |
| 423 | function keepAwake(): void { |
| 424 | if (!document.hidden) void navigator.wakeLock?.request('screen').catch(() => {}) |
| 425 | } |
| 426 | keepAwake() |
| 427 | document.addEventListener('visibilitychange', keepAwake) |
| 428 | window.addEventListener('pointerdown', keepAwake, { once: true }) |
| 429 | marks.clear(skater.x, skater.s) |
| 430 | skater.pace = settings.pace |
| 431 | /** Seconds until the ice next cracks somewhere off across the lake. */ |
| 432 | let untilCrack = 3 |
| 433 | |
| 434 | renderer.setAnimationLoop(() => { |
| 435 | // A tab left in the background comes back with one enormous step; skate it as a short |
| 436 | // one. And none at all while a box is open over the lake: it waits behind it. |
| 437 | // A game controller: its stick steers (input.ts), Y or triangle goes to another lake, |
| 438 | // and X or square turns the sound on and off. |
| 439 | for (const button of input.pollPads()) { |
| 440 | if (button === 3 && !waiting()) randomize() |
| 441 | // Up on the direction buttons or the right shoulder: faster. Down, or the left: slower. |
| 442 | else if (button === 12 || button === 5) stepPace(1) |
| 443 | else if (button === 13 || button === 4) stepPace(-1) |
| 444 | else if (button === 2) setMuted(audio !== null && !muted) |
| 445 | // Any button is a touch, as far as a browser's leave to make sound goes. |
| 446 | else startSound() |
| 447 | } |
| 448 | const passed = Math.min(clock.getDelta(), 0.1) |
| 449 | const dt = waiting() ? 0 : passed |
| 450 | sky.update(clock.elapsedTime) |
| 451 | // The wind: between its lull and its gust, never the same for long, from where the |
| 452 | // lake has it coming from. |
| 453 | const { wind: [lull, gusting] } = WEATHER[weatherNow()] |
| 454 | const t = clock.elapsedTime |
| 455 | const blowing = lull + (gusting - lull) * (0.5 + 0.5 * Math.sin(t * 0.23) * Math.sin(t * 0.071 + 1)) |
| 456 | const from = windOf(seed) |
| 457 | const steer = input.steer() |
| 458 | if (steer !== 0) hint.classList.add('gone') |
| 459 | const strides = Math.floor(skater.stride) |
| 460 | const wasUnder = skater.under > 0 |
| 461 | stepSkater(skater, lake, steer, dt) |
| 462 | const h = lake.thickness(skater.x, skater.s) |
| 463 | // What the skates leave on the ice: their line, and a scratch at each push, off the |
| 464 | // left skate and the right in turn. |
| 465 | marks.follow(skater.x, skater.s, dt) |
| 466 | if (dt > 0) marks.track(skater.x, skater.s) |
| 467 | const pushed = Math.floor(skater.stride) !== strides |
| 468 | if (pushed) marks.push(skater.x, skater.s, skater.heading, Math.floor(skater.stride) % 2 ? 1 : -1) |
| 469 | // Ice near its limit cracks under the skater: the thinner, the more often and the |
| 470 | // nearer, until at the breaking thickness it is cracking every second or so. Thinner |
| 471 | // than that it is not real ice, and does not crack at all (world/lake.ts). |
| 472 | const strain = h < BREAKING_THICKNESS ? 0 : Math.max(0, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS)) |
| 473 | // A push on such ice starts cracks from under the skate, which is seen whether or not |
| 474 | // there is sound to hear it by. |
| 475 | if (pushed && strain > 0.2) { |
| 476 | for (let n = 0; n < 1 + 3 * strain; n++) marks.crack(skater.x, skater.s, 1 + 5 * strain * Math.random()) |
| 477 | } |
| 478 | const fell = skater.under > 0 && !wasUnder |
| 479 | // Nothing is asked of the sound while it is off: it would all play at once on its return. |
| 480 | if (fell && sounding()) sound?.fall(h) |
| 481 | // In the water for all but the last second, which is the climb back out. |
| 482 | water.classList.toggle('under', skater.under > 1) |
| 483 | |
| 484 | // The wind in the skater's ears is the weather's and their own: at a racing pace the |
| 485 | // air rushes past on the stillest day. |
| 486 | const rush = 0.5 * Math.min(1.2, skater.speed / PACES.olympic.speed) ** 2.5 |
| 487 | if (sound && sounding()) sound.wind(Math.max(blowing / WEATHER.blizzard.wind[1], rush)) |
| 488 | if (sound && sounding() && skater.under === 0) { |
| 489 | // One strike of a skate at the start of each stride, no two quite alike. |
| 490 | // Snow under the blade: the strike is muffled by as much as there is of it, and the |
| 491 | // glide shushes instead of singing. |
| 492 | const lying = lake.snow(skater.x, skater.s) |
| 493 | if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, (0.75 + 0.5 * Math.random()) * (1 - 0.9 * lying)) |
| 494 | // The push is over half a second into a stride; the rest of it is glide. |
| 495 | sound.glide(h, Math.exp(-sincePush(skater) / 0.5), undefined, lying) |
| 496 | untilCrack -= dt * (1 + 14 * strain ** 2) |
| 497 | if (untilCrack <= 0) { |
| 498 | // On sound ice: mostly near, now and then from far down the lake. |
| 499 | const distance = strain > 0 ? 2 + 30 * (1 - strain) * Math.random() : 40 + 560 * Math.random() ** 2 |
| 500 | const pan = Math.random() * 2 - 1 |
| 501 | sound.crack(h, distance, undefined, pan) |
| 502 | // A crack near enough to see is drawn where it was heard from: that far off, and |
| 503 | // to the side of the skater it sounded on. |
| 504 | if (distance < 40) { |
| 505 | const towards = skater.heading + pan * 1.4 |
| 506 | marks.crack(skater.x + distance * Math.sin(towards), skater.s + distance * Math.cos(towards), 3 + 9 * Math.random()) |
| 507 | } |
| 508 | untilCrack = 3 + 12 * Math.random() |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | // A skater's head is the stillest part of them: it does not swing from leg to leg. |
| 513 | // It rises a little with each push, and leans into a turn as the body under it does, |
| 514 | // by as much as the turn presses sideways. |
| 515 | const pushFor = pushSeconds(skater.pace) |
| 516 | const push = skater.under === 0 && sincePush(skater) < pushFor ? Math.sin((Math.PI * sincePush(skater)) / pushFor) : 0 |
| 517 | // A racer is down in a crouch and an ambler stands tall; the eyes move between the |
| 518 | // two as the speed does. And the faster the skater, the wider the view, which is most |
| 519 | // of what makes speed look like speed. |
| 520 | const hurry = (skater.speed - PACES.cruise.speed) / (PACES.olympic.speed - PACES.cruise.speed) |
| 521 | const crouch = hurry > 0 ? PACES.olympic.crouch * hurry : PACES.meander.crouch * Math.min(1, -hurry * 2.3) |
| 522 | const fov = baseFov + 12 * Math.max(-0.4, Math.min(1.1, hurry)) |
| 523 | if (Math.abs(camera.fov - fov) > 0.05) { |
| 524 | camera.fov = fov |
| 525 | camera.updateProjectionMatrix() |
| 526 | } |
| 527 | // Going through the ice drops the view to the water in the first third of a second. |
| 528 | const sunk = skater.under > 0 ? Math.min(1, (UNDER_SECONDS - skater.under) / 0.3) : 0 |
| 529 | camera.position.y = EYE - crouch + 0.03 * push - 1.3 * sunk |
| 530 | // The eyes lead where the lake goes, and turn most of the way into the skater's own |
| 531 | // direction: a skater cutting towards a shore still watches the lake ahead. |
| 532 | const along = Math.atan(skater.channel.slope(skater.s)) |
| 533 | // (By the sine of the angle between them, which does the same for a skater who has |
| 534 | // turned right round and is, for the moment, heading back up it.) |
| 535 | const look = skater.heading - 0.25 * Math.sin(skater.heading - along) |
| 536 | camera.rotation.set(-pitch, -look, -0.02 * skater.turn * skater.speed) |
| 537 | |
| 538 | // The snow falls on while the lake waits behind a box; the skater does not move through it. |
| 539 | const moving = dt > 0 ? skater.speed : 0 |
| 540 | snow.update(passed, blowing * Math.sin(from), blowing * Math.cos(from), moving * Math.sin(skater.heading), moving * Math.cos(skater.heading)) |
| 541 | |
| 542 | const facing = Math.cos(look - along) |
| 543 | ice.update(skater.x, skater.s, facing) |
| 544 | drifts.update(skater.x, skater.s) |
| 545 | shores.update(skater.x, skater.s, skater.channel, facing) |
| 546 | renderer.render(scene, camera) |
| 547 | }) |