fuzzyworld

nordic-skating / mistral-medium-35-littlejs / files / src / main.ts

Branch mistral-medium-35-littlejs · default branch · all branches

main.ts

26.1 KB · history · blame

1import './style.css'
2import skateIcon from '../assets/skate-shoes.png'
3import soundOffIcon from '../assets/sound-off.png'
4import soundOnIcon from '../assets/sound-on.png'
5import tiltOffIcon from '../assets/tilt-off.png'
6import tiltOnIcon from '../assets/tilt-on.png'
7import * as THREE from 'three'
8import { IceSound } from './audio/iceSound.js'
9import { Input } from './input.js'
10import { Drifts } from './render/drifts.js'
11import { Ice } from './render/ice.js'
12import { Marks } from './render/marks.js'
13import { Shores } from './render/shores.js'
14import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js'
15import { Snow } from './render/snow.js'
16import { WEATHER, WEATHERS, type Weather, anyLake, loadSettings, saveSettings, timeOf, weatherOf, windOf } from './settings.js'
17import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js'
18import { PACES, PACE_NAMES, type Pace, UNDER_SECONDS, createSkater, pushSeconds, sincePush, stepSkater } from './skater.js'
19import { createLake } from './world/lake.js'
20
21// Eye height of a skater leaning into a stride, in metres.
22const 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.
31const params = new URLSearchParams(location.search)
32const numbered = (from: unknown): number => Math.floor(Number(from)) || 0
33const arrival = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
34const returning = arrival?.type === 'back_forward'
35let seed = numbered(params.get('seed')) || (returning && numbered(history.state?.seed)) || anyLake()
36history.replaceState({ seed }, '')
37const settings = loadSettings()
38
39const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
40const hint = document.querySelector<HTMLElement>('#hint')!
41const water = document.querySelector<HTMLElement>('#water')!
42const soundButton = document.querySelector<HTMLButtonElement>('#sound')!
43const links = document.querySelector<HTMLElement>('#links')!
44const aboutButton = document.querySelector<HTMLButtonElement>('#about')!
45const about = document.querySelector<HTMLDialogElement>('#about-dialog')!
46const settingsButton = document.querySelector<HTMLButtonElement>('#settings')!
47const randomButton = document.querySelector<HTMLButtonElement>('#randomize')!
48const veil = document.querySelector<HTMLElement>('#veil')!
49const settingsBox = document.querySelector<HTMLDialogElement>('#settings-dialog')!
50/** Whether the lake is waiting behind a box that is open over it. */
51const waiting = (): boolean => about.open || settingsBox.open
52const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
53// Phones report a ratio of 3 or more, and the fill cost grows with its square.
54renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
55
56const scene = new THREE.Scene()
57// The lake and its shores fade into the sky at its horizon, so neither has a visible end.
58const haze = new THREE.Fog(0, 30, 600)
59scene.fog = haze
60scene.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).
64const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500)
65camera.rotation.order = 'YXZ'
66
67// The lake reads this as it goes, so the settings box can change it under a skater.
68const drifts = new Drifts()
69const lakeOptions = { impossibleIce: settings.impossibleIce, snow: (x: number, s: number) => drifts.cover(x, s) }
70let lake = createLake(seed, lakeOptions)
71const ice = new Ice(renderer, lake)
72const shores = new Shores(lake, seed)
73const sky = new Sky()
74const snow = new Snow()
75const marks = new Marks()
76// The light of the whole sky, and the sun or moon, which gives the banks and trees a lit side.
77const skyLight = new THREE.HemisphereLight()
78const sun = new THREE.DirectionalLight()
79scene.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. */
82function 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}
106const asked = params.get('time') as TimeName | null
107const 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. */
109function 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.
114const askedPace = params.get('pace') as Pace | null
115if (askedPace && PACE_NAMES.includes(askedPace)) settings.pace = askedPace
116/** And the weather, likewise: `?weather=snow` asks for one whatever the settings say. */
117function weatherNow(): Weather {
118 if (askedWeather && WEATHERS.includes(askedWeather)) return askedWeather
119 return settings.weather === 'surprise' ? weatherOf(seed) : settings.weather
120}
121showWorld()
122
123/** How far below level the skater looks, radians. */
124let pitch = 0
125/** The field of view at a cruising pace, degrees; it widens with speed. */
126let baseFov = 65
127
128function 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}
142window.addEventListener('resize', resize)
143resize()
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.
147let skater = createSkater(lake, Number(params.get('s')) || 0)
148const input = new Input(canvas)
149const clock = new THREE.Clock()
150// Whoever is just watching has no use for the hint after a first read of it.
151setTimeout(() => 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.
156let audio: AudioContext | null = null
157let sound: IceSound | null = null
158let muted = false
159try {
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. */
165const 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 */
172function 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}
178const soundIcon = soundButton.appendChild(icon(soundOffIcon))
179
180function 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}
189function 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}
201function 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}
212showSound()
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.
215const 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.
219for (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.
236const impossible = settingsBox.querySelector<HTMLInputElement>('#impossible')!
237const tiltButton = document.querySelector<HTMLButtonElement>('#tilt-button')!
238const paceButton = document.querySelector<HTMLButtonElement>('#pace-button')!
239/** A pace's name as it is shown, and its speed as people say speeds. */
240const paceName = (pace: Pace): string => pace[0]!.toUpperCase() + pace.slice(1)
241const 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. */
243function 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}
253let paceSaid = 0
254/** One pace faster or slower; `round` goes from the fastest back to the slowest. */
255function 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.
261paceButton.append(icon(skateIcon))
262const paceLabel = paceButton.appendChild(document.createElement('span'))
263paceButton.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.
266tiltButton.hidden = !Input.canTilt
267const tiltIcon = tiltButton.appendChild(icon(tiltOffIcon))
268const tiltLabel = tiltButton.appendChild(document.createElement('span'))
269// A touch of the button is the touch an iPhone wants before it will give its leave.
270tiltButton.addEventListener('click', () => void setTilt(!settings.tilt))
271/** Whether the page was put into full screen in order to hold the screen's rotation. */
272let 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 */
283async 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. */
308async 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.
324if (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}
331const impossibleOnly = document.querySelectorAll<HTMLElement>('.impossible-only')
332function 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}
348showSettings()
349settingsBox.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})
362function showLake(): void {
363 settingsBox.querySelector('#lake-number')!.textContent = String(seed)
364 settingsBox.querySelector<HTMLAnchorElement>('#lake-link')!.href = `?seed=${seed}`
365}
366showLake()
367
368/** Leave this lake for lake number `to`, and start at the top of it. */
369function 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 */
390function 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}
399randomButton.addEventListener('click', randomize)
400settingsBox.querySelector('#new-lake')!.addEventListener('click', randomize)
401// Back and Forward: to the lake that was at that place in the history.
402window.addEventListener('popstate', (e) => {
403 enterLake(numbered(e.state?.seed) || numbered(new URLSearchParams(location.search).get('seed')) || anyLake())
404})
405soundButton.addEventListener('click', () => setMuted(audio !== null && !muted))
406window.addEventListener('pointerdown', (e) => {
407 if (!aside(e)) startSound()
408})
409window.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.
417document.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.
423function keepAwake(): void {
424 if (!document.hidden) void navigator.wakeLock?.request('screen').catch(() => {})
425}
426keepAwake()
427document.addEventListener('visibilitychange', keepAwake)
428window.addEventListener('pointerdown', keepAwake, { once: true })
429marks.clear(skater.x, skater.s)
430skater.pace = settings.pace
431/** Seconds until the ice next cracks somewhere off across the lake. */
432let untilCrack = 3
433
434renderer.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})