nordic-skating / commit
feat: a skater that turns and glides, a safe lake, skies, settings, and Randomize
docs/screenshots/fell.png | Bin 13898 -> 0 bytes index.html | 73 +++++++++++++- src/main.ts | 208 +++++++++++++++++++++++++++++++------- src/render/ice.ts | 45 ++++++--- src/render/shores.ts | 26 +++-- src/render/sky.ts | 180 +++++++++++++++++++++++++++++++++ src/settings.ts | 49 +++++++++ src/skater.ts | 252 +++++++++++++++++++++++++++++++--------------- src/style.css | 148 ++++++++++++++++++++++++++- src/world/lake.ts | 54 ++++++---- test/lake.test.ts | 102 ++++++++++++------- test/settings.test.ts | 30 ++++++ test/skater.test.ts | 149 ++++++++++++++++++--------- test/strength.test.ts | 8 +- 14 files changed, 1080 insertions(+), 244 deletions(-) diff --git a/docs/screenshots/fell.png b/docs/screenshots/fell.png deleted file mode 100644 index 8287d36..0000000 Binary files a/docs/screenshots/fell.png and /dev/null differ diff --git a/index.html b/index.html index 5e282fe..1e9155c 100644 --- a/index.html +++ b/index.html @@ -9,11 +9,78 @@ <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> + <button id="about" type="button">About</button> + <button id="settings" type="button">Settings</button> + <button id="randomize" type="button">Randomize</button> </nav> <button id="sound" type="button"></button> -<div id="water"><p>The ice gave way</p></div> +<dialog id="about-dialog" aria-labelledby="about-title"> + <h1 id="about-title" tabindex="-1" autofocus>Nordic Skating</h1> + <p>An endless skate over thin black ice. The ice sings under every stride, and the thinner it is, the higher it sings. Leave it alone and it skates itself, for as long as you like.</p> + + <h2>Controls</h2> + <dl> + <dt>Steer</dt> + <dd>Drag left or right from wherever you touch, or use ← → or A D. Let go and the skater straightens up, then drifts back to its own line.</dd> + <dt>Choose a way at a fork</dt> + <dd>Steer towards it before the island begins. Do nothing and the skater picks one.</dd> + <dt>Sound on or off</dt> + <dd>The button at the top right, or M.</dd> + <dt>Another lake</dt> + <dd>Randomize, at the top left, or R. The browser’s Back button returns to the lake you left.</dd> + <dt>Time of day, and more</dt> + <dd>Settings, at the top left: day, dusk, night under the Milky Way, or the northern lights. Left alone, every lake has a time of its own.</dd> + </dl> + <p>Every visit is to a different lake, one of a million.</p> + + <h2>The ice</h2> + <dl> + <dt>Milky grey</dt> + <dd>Thick ice. A low, plain note.</dd> + <dt>Dark grey</dt> + <dd>Thinner ice, near its limit. This is the ice that sings.</dd> + <dt>Nearly black</dt> + <dd>Thin ice. A high note, and it cracks around you.</dd> + <dt class="impossible-only">Black with a green glow</dt> + <dd class="impossible-only">Impossibly thin ice. A very high note, and no cracks at all.</dd> + </dl> + <p>Nobody falls through. How thick the ice is decides only how it sounds.</p> + + <h2>The sound</h2> + <p>None of it is a recording. It is synthesized from the physics of thin ice: the note is the one frequency at which a sheet of that thickness gives its sound to the air, and the falling “pew” is a distant crack, its high notes arriving first.</p> + <p>Inspired by National Geographic’s film <a href="https://www.youtube.com/watch?v=v3O9vNi-dkA" target="_blank" rel="noopener">Hear the Otherworldly Sounds of Skating on Thin Ice</a>.</p> + <p><a href="https://gitlab.com/PavelSafronov/nordic-skating/-/blob/main/README.md" target="_blank" rel="noopener">How it works, and credits</a> · <a href="https://gitlab.com/PavelSafronov/nordic-skating" target="_blank" rel="noopener">Source</a></p> + + <form method="dialog"><button id="about-close" class="done">Skate on</button></form> +</dialog> +<dialog id="settings-dialog" aria-labelledby="settings-title"> + <h1 id="settings-title" tabindex="-1" autofocus>Settings</h1> + + <fieldset> + <legend>Time of day</legend> + <label><input type="radio" name="time" value="surprise" /> Surprise me <small>each lake has its own</small></label> + <label><input type="radio" name="time" value="day" /> Day</label> + <label><input type="radio" name="time" value="dusk" /> Dusk</label> + <label><input type="radio" name="time" value="night" /> Night <small>under the Milky Way</small></label> + <label><input type="radio" name="time" value="aurora" /> Aurora <small>the northern lights</small></label> + </fieldset> + + <fieldset> + <legend>Ice</legend> + <label><input type="checkbox" id="impossible" /> Impossible ice <small>patches thinner than any ice could be: a very high note, a green glow, no cracks</small></label> + </fieldset> + + <fieldset> + <legend>Lake</legend> + <p>This is lake number <a id="lake-link"><span id="lake-number"></span></a> of a million. Every visit is to another; the link comes back to this one.</p> + <button id="new-lake" type="button">Another lake</button> + </fieldset> + + <form method="dialog"><button class="done">Done</button></form> +</dialog> +<!-- Hidden until the stylesheet has arrived to hide it properly: without this its words show for a moment as the page loads. --> +<div id="water" hidden><p>The ice gave way</p></div> +<div id="veil"></div> <p id="hint">Drag left or right to steer — or just watch</p> <script type="module" src="/src/main.ts"></script> </body> diff --git a/src/main.ts b/src/main.ts index 57596a6..5f927cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,44 +4,87 @@ import { IceSound } from './audio/iceSound.js' import { Input } from './input.js' import { Ice } from './render/ice.js' import { Shores } from './render/shores.js' +import { LOOKS, Sky, TIME_NAMES, type TimeName } from './render/sky.js' +import { anyLake, loadSettings, saveSettings, timeOf } from './settings.js' import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js' -import { UNDER_SECONDS, createSkater, skaterX, stepSkater, strideSeconds } from './skater.js' +import { PUSH_SECONDS, UNDER_SECONDS, createSkater, sincePush, stepSkater } from './skater.js' import { createLake } from './world/lake.js' -const SKY = 0x9db4c8 // Eye height of a skater leaning into a stride, in metres. const EYE = 1.5 -const SEED = 1 + +// Every visit is to a different lake, one of a million, under a sky of its own: `?seed=` +// asks for a particular one, which is how a lake is linked to, and how it is photographed. +// `?time=` asks for a time of day whatever the lake's own is. +// +// Which lake a page is showing is kept with its place in the browser's history, not in +// its address. So Back returns to the lake that was there, and a reload, which is +// someone asking for something new, gets a new one. +const params = new URLSearchParams(location.search) +const numbered = (from: unknown): number => Math.floor(Number(from)) || 0 +const arrival = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined +const returning = arrival?.type === 'back_forward' +let seed = numbered(params.get('seed')) || (returning && numbered(history.state?.seed)) || anyLake() +history.replaceState({ seed }, '') +const settings = loadSettings() 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 aboutButton = document.querySelector<HTMLButtonElement>('#about')! +const about = document.querySelector<HTMLDialogElement>('#about-dialog')! +const settingsButton = document.querySelector<HTMLButtonElement>('#settings')! +const randomButton = document.querySelector<HTMLButtonElement>('#randomize')! +const veil = document.querySelector<HTMLElement>('#veil')! +const settingsBox = document.querySelector<HTMLDialogElement>('#settings-dialog')! +/** Whether the lake is waiting behind a box that is open over it. */ +const waiting = (): boolean => about.open || settingsBox.open 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)) const scene = new THREE.Scene() -scene.background = new THREE.Color(SKY) -// The lake and its shores fade into the sky, so neither has a visible end. -scene.fog = new THREE.Fog(SKY, 30, 600) +// The lake and its shores fade into the sky at its horizon, so neither has a visible end. +const haze = new THREE.Fog(0, 30, 600) +scene.fog = haze +scene.background = haze.color // The skater is always at the origin of what is drawn, looking down -z; the ice and the // shores are moved past instead (render/ice.ts, render/shores.ts). const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500) camera.rotation.order = 'YXZ' -const lake = createLake(SEED) +// The lake reads this as it goes, so the settings box can change it under a skater. +const lakeOptions = { impossibleIce: settings.impossibleIce } +let lake = createLake(seed, lakeOptions) const ice = new Ice(renderer, lake) -const shores = new Shores(lake, SEED) -scene.add(ice.mesh, shores.group) +const shores = new Shores(lake, seed) +const sky = new Sky() +// The light of the whole sky, and the sun or moon, which gives the banks and trees a lit side. +const skyLight = new THREE.HemisphereLight() +const sun = new THREE.DirectionalLight() +scene.add(sky.mesh, ice.mesh, shores.group, skyLight, sun) -scene.add(new THREE.HemisphereLight(SKY, 0x0b1a24, 1.5)) -// A low winter sun ahead and to the left, which is what gives the banks and trees a lit side. -const sun = new THREE.DirectionalLight(0xfff1d6, 3) -sun.position.set(-40, 12, -100) -scene.add(sun) +/** Put the lake under the sky and in the light of a time of day. */ +function showTime(time: TimeName): void { + const look = LOOKS[time] + sky.show(look) + haze.color.set(look.horizon) + skyLight.color.set(look.skyLight) + skyLight.groundColor.set(look.groundLight) + skyLight.intensity = look.skyStrength + sun.color.set(look.sun) + sun.intensity = look.sunStrength + sun.position.set(...look.sunFrom) + ice.light = look.iceLight +} +const asked = params.get('time') as TimeName | null +/** The time it is on the lake: what the address asks for, or the settings, or the lake's own. */ +const timeNow = (): TimeName => + asked && TIME_NAMES.includes(asked) ? asked : settings.time === 'surprise' ? timeOf(seed) : settings.time +showTime(timeNow()) /** How far below level the skater looks, radians. */ let pitch = 0 @@ -63,7 +106,7 @@ resize() // `?s=2500` starts that many metres down the lake: for screenshots, and for checking // that nothing degrades a long way from the start. -const skater = createSkater(lake, Number(new URLSearchParams(location.search).get('s')) || 0) +let skater = createSkater(lake, Number(params.get('s')) || 0) const input = new Input(canvas) const clock = new THREE.Clock() // Whoever is just watching has no use for the hint after a first read of it. @@ -90,7 +133,8 @@ function showSound(): void { soundButton.classList.toggle('on', on) } function startSound(): void { - if (muted) return + // Nor while the lake is waiting behind a box: it starts again with the skating. + if (muted || waiting()) return if (audio) void audio.resume() else { audio = new AudioContext() @@ -115,30 +159,126 @@ 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) + +// A box over the lake stops it while the box is open: the skater, and the sound with +// them. The sky goes on, and the settings can be seen taking effect behind their box. +for (const [button, box] of [[aboutButton, about], [settingsButton, settingsBox]] as const) { + button.addEventListener('click', () => { + box.showModal() + // Opened at its top, whatever it was scrolled to when it was last closed. + box.scrollTop = 0 + void audio?.suspend() + }) + box.addEventListener('close', () => { + if (!muted) void audio?.resume() + }) + // A touch outside the box, on the lake behind it, closes it too. + box.addEventListener('click', (e) => { + if (e.target === box) box.close() + }) +} + +// The settings box: each thing in it takes effect as it is touched, and is remembered. +const impossible = settingsBox.querySelector<HTMLInputElement>('#impossible')! +const impossibleOnly = document.querySelectorAll<HTMLElement>('.impossible-only') +function showSettings(): void { + for (const radio of settingsBox.querySelectorAll<HTMLInputElement>('input[name=time]')) { + radio.checked = radio.value === settings.time + } + impossible.checked = settings.impossibleIce + // What the About box says of impossible ice is there only when the ice is. + for (const part of impossibleOnly) part.hidden = !settings.impossibleIce +} +showSettings() +settingsBox.addEventListener('change', (e) => { + const changed = e.target as HTMLInputElement + if (changed.name === 'time') { + settings.time = changed.value as typeof settings.time + showTime(timeNow()) + } else if (changed === impossible) { + settings.impossibleIce = lakeOptions.impossibleIce = impossible.checked + ice.redraw() + } + saveSettings(settings) + showSettings() +}) +function showLake(): void { + settingsBox.querySelector('#lake-number')!.textContent = String(seed) + settingsBox.querySelector<HTMLAnchorElement>('#lake-link')!.href = `?seed=${seed}` +} +showLake() + +/** Leave this lake for lake number `to`, and start at the top of it. */ +function enterLake(to: number): void { + seed = to + lake = createLake(seed, lakeOptions) + ice.show(lake) + shores.show(lake, seed) + skater = createSkater(lake) + showTime(timeNow()) + showLake() + // One lake does not turn into another: the haze closes in and clears on the new one. + veil.style.background = haze.color.getStyle() + veil.classList.remove('clearing') + void veil.offsetWidth + veil.classList.add('clearing') +} + +/** + * Go to a lake picked at random, leaving this one in the browser's history to come back + * to. The address loses any `?seed=`, since it no longer says which lake this is. + */ +function randomize(): void { + const address = new URL(location.href) + address.searchParams.delete('seed') + address.searchParams.delete('s') + let to = anyLake() + while (to === seed) to = anyLake() + history.pushState({ seed: to }, '', address) + enterLake(to) +} +randomButton.addEventListener('click', randomize) +settingsBox.querySelector('#new-lake')!.addEventListener('click', randomize) +// Back and Forward: to the lake that was at that place in the history. +window.addEventListener('popstate', (e) => { + enterLake(numbered(e.state?.seed) || numbered(new URLSearchParams(location.search).get('seed')) || anyLake()) +}) 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 (e.code === 'KeyR' && !waiting() && !e.metaKey && !e.ctrlKey) randomize() 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 && !muted) void (document.hidden ? audio.suspend() : audio.resume()) + if (audio && !muted && !waiting()) void (document.hidden ? audio.suspend() : audio.resume()) }) +// Nobody touches the screen of a lake that skates itself, so a phone would dim and +// sleep. The lock is the browser's to give, is dropped whenever the page is hidden, and +// some browsers give it only after a touch: so it is asked for at each of those moments. +function keepAwake(): void { + if (!document.hidden) void navigator.wakeLock?.request('screen').catch(() => {}) +} +keepAwake() +document.addEventListener('visibilitychange', keepAwake) +window.addEventListener('pointerdown', keepAwake, { once: true }) /** Seconds until the ice next cracks somewhere off across the lake. */ let untilCrack = 3 renderer.setAnimationLoop(() => { - // A tab left in the background comes back with one enormous step; skate it as a short one. - const dt = Math.min(clock.getDelta(), 0.1) + // A tab left in the background comes back with one enormous step; skate it as a short + // one. And none at all while a box is open over the lake: it waits behind it. + const dt = waiting() ? clock.getDelta() * 0 : Math.min(clock.getDelta(), 0.1) + sky.update(clock.elapsedTime) const steer = input.steer() if (steer !== 0) hint.classList.add('gone') const strides = Math.floor(skater.stride) const wasUnder = skater.under > 0 stepSkater(skater, lake, steer, dt) - const h = lake.thickness(skaterX(skater), skater.s, skater.channel) + const h = lake.thickness(skater.x, skater.s) const fell = skater.under > 0 && !wasUnder // 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) @@ -149,11 +289,11 @@ renderer.setAnimationLoop(() => { // 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. - const sincePush = (skater.stride % 1) * strideSeconds(Math.floor(skater.stride)) - sound.glide(h, Math.exp(-sincePush / 0.5)) + sound.glide(h, Math.exp(-sincePush(skater) / 0.5)) // Ice near its limit cracks under the skater: the thinner, the more often and the // nearer, until at the breaking thickness it is cracking several times a second. - const strain = Math.max(0, Math.min(1, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS))) + // Thinner than that it is not real ice, and does not crack at all (world/lake.ts). + const strain = h < BREAKING_THICKNESS ? 0 : Math.max(0, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS)) untilCrack -= dt * (1 + 40 * strain ** 2) if (untilCrack <= 0) { // On sound ice: mostly near, now and then from far down the lake. @@ -163,20 +303,20 @@ renderer.setAnimationLoop(() => { } } - // Each stride pushes off one foot and then the other, so the body sways across the - // line of travel once every two strides, leans into the sway, and dips at each push. - const sway = Math.sin(skater.stride * Math.PI) - const x = skaterX(skater) + 0.2 * sway + // A skater's head is the stillest part of them: it does not swing from leg to leg. + // It rises a little with each push, and leans into a turn as the body under it does, + // by as much as the turn presses sideways. + const push = skater.under === 0 && sincePush(skater) < PUSH_SECONDS ? Math.sin((Math.PI * sincePush(skater)) / PUSH_SECONDS) : 0 // Going through the ice drops the view to the water in the first third of a second. const sunk = skater.under > 0 ? Math.min(1, (UNDER_SECONDS - skater.under) / 0.3) : 0 - camera.position.y = EYE - 0.04 * Math.abs(sway) - 1.3 * sunk - // The eyes follow the channel, and turn only half way into a steer across it: a skater - // cutting towards a shore still watches where the lake goes. + camera.position.y = EYE + 0.03 * push - 1.3 * sunk + // The eyes lead where the lake goes, and turn most of the way into the skater's own + // direction: a skater cutting towards a shore still watches the lake ahead. const along = Math.atan(skater.channel.slope(skater.s)) - const look = along + 0.5 * (skater.heading - along) - camera.rotation.set(-pitch, -look, -0.015 * sway - 0.012 * skater.lateral) + const look = along + 0.75 * (skater.heading - along) + camera.rotation.set(-pitch, -look, -0.02 * skater.turn * skater.speed) - ice.update(x, skater.s, skater.channel) - shores.update(x, skater.s, skater.channel) + ice.update(skater.x, skater.s) + shores.update(skater.x, skater.s, skater.channel) renderer.render(scene, camera) }) diff --git a/src/render/ice.ts b/src/render/ice.ts index 03ef2f1..99e1a01 100644 --- a/src/render/ice.ts +++ b/src/render/ice.ts @@ -1,6 +1,6 @@ import * as THREE from 'three' import { BREAKING_THICKNESS } from '../ice/strength.js' -import { type Channel, type Lake, MAX_THICKNESS, mulberry32 } from '../world/lake.js' +import { type Lake, MAX_THICKNESS, MIN_THICKNESS, mulberry32 } from '../world/lake.js' // Black ice: clear enough that the dark water under it is what you see. const ICE = '#0b1a24' @@ -25,7 +25,13 @@ const MAP_STEP_ACROSS = 12 const MAP_STEP = 4 const MAP_REDRAW = 32 /** Thick ice is milky with frost and trapped air; this is the colour that adds. */ -const MILK = 0x7d93a6 +const MILK = [0x7d, 0x93, 0xa6] as const +/** + * Ice too thin to be real (world/lake.ts) is lit from within, faintly, in a colour no ice + * is: the thinner, the more. It is the one thing on the lake that is not physics, and it + * should look it. + */ +const GLOW = [0x1c, 0x8e, 0x8a] as const /** Draw `paint` nine times, shifted by a tile each way, so that it wraps at every edge. */ function wrapped(g: CanvasRenderingContext2D, paint: () => void): void { @@ -111,7 +117,7 @@ export class Ice { private mapX = 0 private mapS = NaN - constructor(renderer: THREE.WebGLRenderer, private readonly lake: Lake) { + constructor(renderer: THREE.WebGLRenderer, private lake: Lake) { this.texture = new THREE.CanvasTexture(paintIce()) this.texture.colorSpace = THREE.SRGBColorSpace this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping @@ -133,22 +139,35 @@ export class Ice { this.mesh = new THREE.Mesh( new THREE.PlaneGeometry(SIZE, SIZE), // Matt on purpose: a glossy sheet mirrors the sun as one glaring streak down the lake. - new THREE.MeshLambertMaterial({ map: this.texture, emissive: MILK, emissiveMap: this.map }), + new THREE.MeshLambertMaterial({ map: this.texture, emissive: 0xffffff, emissiveMap: this.map }), ) this.mesh.rotation.x = -Math.PI / 2 } - /** - * Put the right patch of ice under a skater who is at `x` across and `s` along, in - * `channel`. - */ - update(x: number, s: number, channel: Channel): void { + /** Paint the thickness of the ice afresh: something about the lake has been changed. */ + redraw(): void { + this.mapS = NaN + } + + /** Be the ice of another lake. */ + show(lake: Lake): void { + this.lake = lake + this.redraw() + } + + /** How strongly the milkiness of thick ice shows: 1 by day, less by the light of a night. */ + set light(strength: number) { + ;(this.mesh.material as THREE.MeshLambertMaterial).emissiveIntensity = strength + } + + /** Put the right patch of ice under a skater who is at `x` across and `s` along. */ + update(x: number, s: number): void { // Only the fraction of a tile matters, and taking it here, in doubles, is what keeps // the picture sharp at any distance. // (The sheet is a whole, even number of tiles across, so its middle is on a tile edge.) this.texture.offset.set((x / TILE) % 1, (s / TILE) % 1) - if (!(s - MAP_BEHIND - this.mapS < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s, channel) + if (!(s - MAP_BEHIND - this.mapS < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s) // The map stays where it was drawn while the sheet moves on with the skater. this.map.offset.set( (x - this.mapX + MAP_ACROSS / 2 - SIZE / 2) / MAP_ACROSS, @@ -156,7 +175,7 @@ export class Ice { ) } - private drawMap(x: number, s: number, channel: Channel): void { + private drawMap(x: number, s: number): void { // On whole steps, so that a redraw lands every value exactly where it was before. this.mapS = Math.floor((s - MAP_BEHIND) / MAP_STEP) * MAP_STEP this.mapX = Math.round(x / MAP_STEP_ACROSS) * MAP_STEP_ACROSS @@ -167,10 +186,10 @@ export class Ice { const h = this.lake.thickness( this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP_ACROSS, this.mapS + (row + 0.5) * MAP_STEP, - channel, ) const milk = Math.max(0, (h - BREAKING_THICKNESS) / (MAX_THICKNESS - BREAKING_THICKNESS)) - data[p++] = data[p++] = data[p++] = Math.round(255 * milk) + const glow = Math.max(0, (BREAKING_THICKNESS - h) / (BREAKING_THICKNESS - MIN_THICKNESS)) + for (let c = 0; c < 3; c++) data[p++] = Math.round(MILK[c]! * milk + GLOW[c]! * glow) data[p++] = 255 } } diff --git a/src/render/shores.ts b/src/render/shores.ts index 234838d..e8b1b37 100644 --- a/src/render/shores.ts +++ b/src/render/shores.ts @@ -137,7 +137,24 @@ export class Shores { readonly group = new THREE.Group() private readonly chunks = new Map<number, Chunk>() - constructor(private readonly lake: Lake, private readonly seed: number) {} + constructor(private lake: Lake, private seed: number) {} + + /** Be the land of another lake: what was made of the last one is thrown away. */ + show(lake: Lake, seed: number): void { + this.lake = lake + this.seed = seed + for (const chunk of this.chunks.values()) this.drop(chunk) + this.chunks.clear() + } + + private drop(chunk: Chunk): void { + this.group.remove(chunk.group) + for (const child of chunk.group.children) { + // The land is this stretch's own; the trees share one cone and one material. + if (child instanceof THREE.InstancedMesh) child.dispose() + else (child as THREE.Mesh).geometry.dispose() + } + } /** * Make, drop and place the stretches for a skater at `x` across and `s` along, who is @@ -153,12 +170,7 @@ export class Shores { const last = Math.floor((s + AHEAD) / CHUNK) for (const [index, chunk] of this.chunks) { if (index >= first && index <= last) continue - this.group.remove(chunk.group) - for (const child of chunk.group.children) { - // The land is this stretch's own; the trees share one cone and one material. - if (child instanceof THREE.InstancedMesh) child.dispose() - else (child as THREE.Mesh).geometry.dispose() - } + this.drop(chunk) this.chunks.delete(index) } for (let index = first; index <= last; index++) { diff --git a/src/render/sky.ts b/src/render/sky.ts new file mode 100644 index 0000000..cf62348 --- /dev/null +++ b/src/render/sky.ts @@ -0,0 +1,180 @@ +import * as THREE from 'three' + +/** The times the lake can be skated at. */ +export const TIME_NAMES = ['day', 'dusk', 'night', 'aurora'] as const +export type TimeName = (typeof TIME_NAMES)[number] + +/** Everything about how a time of day looks: its sky, and the light it puts on the lake. */ +export interface Look { + /** The sky overhead and at the horizon. The haze over the lake is the horizon's colour. */ + top: number + horizon: number + /** How much of each there is in the sky, 0 to 1. */ + stars: number + milkyWay: number + aurora: number + /** The light of the whole sky: its colour from above and from the ice, and its strength. */ + skyLight: number + groundLight: number + skyStrength: number + /** The sun, or by night the moon: its colour, strength and where it stands. */ + sun: number + sunStrength: number + sunFrom: readonly [number, number, number] + /** How milky thick ice shows, against 1 by day: it is seen by the light that is on it. */ + iceLight: number +} + +export const LOOKS: Record<TimeName, Look> = { + // A flat, bright winter noon under thin cloud. + day: { + top: 0x6f90b4, horizon: 0x9db4c8, stars: 0, milkyWay: 0, aurora: 0, + skyLight: 0x9db4c8, groundLight: 0x0b1a24, skyStrength: 1.5, + sun: 0xfff1d6, sunStrength: 3, sunFrom: [-40, 12, -100], iceLight: 1, + }, + // The sun just down ahead: an orange horizon under a deepening blue, and the first stars. + dusk: { + top: 0x27365c, horizon: 0xd99a7c, stars: 0.3, milkyWay: 0, aurora: 0, + skyLight: 0x8a86ad, groundLight: 0x0b1420, skyStrength: 0.95, + sun: 0xff9a5c, sunStrength: 1.7, sunFrom: [-20, 3, -100], iceLight: 0.55, + }, + // A clear, moonless night, far from any town: the Milky Way from horizon to horizon. + night: { + top: 0x02050c, horizon: 0x0d1828, stars: 1, milkyWay: 1, aurora: 0, + skyLight: 0x40537a, groundLight: 0x04070b, skyStrength: 0.6, + sun: 0xa9c1ff, sunStrength: 0.35, sunFrom: [60, 40, -60], iceLight: 0.14, + }, + // The same night with the northern lights over the lake ahead, and their green on the snow. + aurora: { + top: 0x02060c, horizon: 0x0c1c24, stars: 1, milkyWay: 0.35, aurora: 1, + skyLight: 0x40706a, groundLight: 0x04080a, skyStrength: 0.65, + sun: 0x8fe6c0, sunStrength: 0.3, sunFrom: [0, 30, -100], iceLight: 0.17, + }, +} + +const vertex = /* glsl */ ` + varying vec3 vDirection; + void main() { + vDirection = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +` + +const fragment = /* glsl */ ` + varying vec3 vDirection; + uniform vec3 uTop; + uniform vec3 uHorizon; + uniform float uStars; + uniform float uMilkyWay; + uniform float uAurora; + uniform float uTime; + + float hash(vec3 p) { + p = fract(p * 0.3183099 + 0.1); + p *= 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); + } + float noise(vec3 x) { + vec3 i = floor(x); + vec3 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(mix(hash(i), hash(i + vec3(1, 0, 0)), f.x), mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x), mix(hash(i + vec3(0, 1, 1)), hash(i + vec3(1, 1, 1)), f.x), f.y), + f.z); + } + float fbm(vec3 p) { + float sum = 0.0; + float gain = 0.5; + for (int i = 0; i < 4; i++) { + sum += gain * noise(p); + p *= 2.03; + gain *= 0.5; + } + return sum; + } + + // Stars: the sky is cut into cells, and a cell in every so many has one star in it, + // somewhere off its middle, of a brightness of its own. + float stars(vec3 d, float cells, float share, float size) { + vec3 p = d * cells; + vec3 cell = floor(p); + float h = hash(cell); + vec3 off = vec3(hash(cell + 7.0), hash(cell + 13.0), hash(cell + 29.0)) - 0.5; + float twinkle = 0.8 + 0.2 * sin(uTime * (1.5 + 3.0 * h) + h * 40.0); + return step(1.0 - share, h) * smoothstep(size, 0.0, length(fract(p) - 0.5 - off * 0.6)) * (0.3 + 0.7 * fract(h * 91.7)) * twinkle; + } + + void main() { + vec3 d = normalize(vDirection); + float up = d.y; + vec3 colour = mix(uHorizon, uTop, pow(clamp(up, 0.0, 1.0), 0.45)); + // Nothing shows through the haze that lies along the horizon. + float clear = smoothstep(0.02, 0.3, up); + + // The Milky Way: a band right round the sky, tilted, clouded, and thick with stars. + float band = exp(-pow(dot(d, normalize(vec3(0.75, 0.35, 0.56))), 2.0) / 0.022); + float cloud = fbm(d * 9.0) * (0.6 + 0.8 * fbm(d * 2.5 + 11.0)); + colour += uMilkyWay * clear * band * cloud * cloud * 1.1 * vec3(0.32, 0.36, 0.5); + float share = 0.09 + 0.3 * band * uMilkyWay; + colour += uStars * clear * vec3(0.95, 0.97, 1.0) + * (stars(d, 60.0, share, 0.11) + 0.6 * stars(d, 110.0, share * 1.3, 0.14)); + + // The aurora: curtains hung from a ceiling far overhead, rippling slowly, each made + // of upright rays, green low down and violet where they reach higher. + if (uAurora > 0.0 && up > 0.02) { + vec2 p = d.xz / (up + 0.15) * 1.2; + float warp = fbm(vec3(p * 0.5, uTime * 0.03)); + float curtain = pow(1.0 - abs(sin(p.y * 1.1 + p.x * 0.25 + warp * 5.0)), 7.0); + float rays = 0.45 + 0.55 * noise(vec3(p.x * 7.0 + warp * 3.0, p.y * 0.7, uTime * 0.25)); + float where = smoothstep(0.03, 0.16, up) * smoothstep(0.85, 0.3, up); + vec3 hue = mix(vec3(0.15, 1.0, 0.45), vec3(0.55, 0.3, 1.0), smoothstep(0.25, 0.75, up)); + colour += uAurora * where * (curtain * rays * 1.1 * hue + 0.025 * vec3(0.1, 0.8, 0.4)); + } + + gl_FragColor = vec4(colour, 1.0); + #include <colorspace_fragment> + } +` + +/** + * The sky: a great dome round the skater, painted by a shader. It is drawn first and + * never hides anything, and the haze does not touch it, since its horizon is already the + * colour of the haze. + */ +export class Sky { + readonly mesh: THREE.Mesh + private readonly uniforms = { + uTop: { value: new THREE.Color() }, + uHorizon: { value: new THREE.Color() }, + uStars: { value: 0 }, + uMilkyWay: { value: 0 }, + uAurora: { value: 0 }, + uTime: { value: 0 }, + } + + constructor() { + this.mesh = new THREE.Mesh( + new THREE.SphereGeometry(1000, 32, 16), + new THREE.ShaderMaterial({ + uniforms: this.uniforms, vertexShader: vertex, fragmentShader: fragment, + side: THREE.BackSide, depthWrite: false, fog: false, + }), + ) + this.mesh.renderOrder = -1 + this.mesh.frustumCulled = false + } + + show(look: Look): void { + this.uniforms.uTop.value.set(look.top) + this.uniforms.uHorizon.value.set(look.horizon) + this.uniforms.uStars.value = look.stars + this.uniforms.uMilkyWay.value = look.milkyWay + this.uniforms.uAurora.value = look.aurora + } + + /** `seconds` is the time by the clock, not by the skater: the sky moves while the lake waits. */ + update(seconds: number): void { + this.uniforms.uTime.value = seconds + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..79084d6 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,49 @@ +import { TIME_NAMES, type TimeName } from './render/sky.js' +import { mulberry32 } from './world/lake.js' + +/** What the skater has chosen in the settings box, kept from one visit to the next. */ +export interface Settings { + /** A time of day, or `surprise` for whichever the lake comes with. */ + time: TimeName | 'surprise' + /** Whether the lake has ice thinner than any ice could be (world/lake.ts). */ + impossibleIce: boolean +} + +const DEFAULTS: Settings = { time: 'surprise', impossibleIce: false } +const KEY = 'settings' + +export function loadSettings(): Settings { + try { + const kept = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Partial<Settings> + return { + time: kept.time === 'surprise' || TIME_NAMES.includes(kept.time as TimeName) ? kept.time! : DEFAULTS.time, + impossibleIce: kept.impossibleIce === true, + } + } catch { + // No storage, or nonsense in it: the settings last for this visit only. + return { ...DEFAULTS } + } +} + +export function saveSettings(settings: Settings): void { + try { + localStorage.setItem(KEY, JSON.stringify(settings)) + } catch { + // As above. + } +} + +/** A lake to skate that nobody has asked for by number: one of a million. */ +export function anyLake(): number { + return 1 + Math.floor(Math.random() * 999_999) +} + +/** + * The time of day lake number `seed` comes with. Part of the lake, so that a link to a + * lake is a link to the same water under the same sky. Day is the likeliest, and the + * aurora, as it should be, the least. + */ +export function timeOf(seed: number): TimeName { + const r = mulberry32(seed ^ 0x5bd1e995)() + return r < 0.35 ? 'day' : r < 0.55 ? 'dusk' : r < 0.82 ? 'night' : 'aurora' +} diff --git a/src/skater.ts b/src/skater.ts index 5e18769..76cee11 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -1,10 +1,45 @@ import { BREAKING_THICKNESS } from './ice/strength.js' import { type Channel, type Lake, channelAt, mulberry32 } from './world/lake.js' -/** Cruising speed, m/s: about 25 km/h, an easy pace on good ice. */ +/** + * Whether ice too thin to hold a skater gives way under one. Not for now: the lake is + * somewhere to skate, and how thick the ice is only decides how it sounds. + * + * Before this is turned back on, the autopilot needs a line it can be sure of. It carves + * across the channel now, and nothing keeps thin ice off its path. + */ +export const RULES = { falling: false } + +/** The speed the skater settles around, m/s: about 25 km/h, an easy pace on good ice. */ export const SPEED = 7 -/** How fast full steering carries the skater across the channel, m/s. */ -export const LATERAL_SPEED = 4 +/** + * A stride is a push and then a glide. The push lasts `PUSH_SECONDS` and speeds the + * skater up by `PUSH` m/s²; all the while the ice and the air slow them, by `DRAG` m/s² + * at `SPEED` and as the square of the speed otherwise. The two are balanced so that a + * stride of average length ends at the speed it began with: long glides run slower, + * short ones faster, and the speed breathes with the stride. + */ +export const PUSH_SECONDS = 0.5 +const DRAG = 0.42 +/** The shortest and longest a stride lasts, seconds, and so the most it speeds and slows. */ +export const MIN_STRIDE = 1.4 +export const MAX_STRIDE = 3.2 +const PUSH = (DRAG * (MIN_STRIDE + MAX_STRIDE)) / 2 / PUSH_SECONDS + +/** + * The sharpest the skater turns, radians a second. At `SPEED` that is a circle 16 m in + * radius and a third of a g sideways: a firm carve on a long blade, not a hockey stop. + */ +export const MAX_TURN = 0.45 +/** Seconds for the turn to follow the steering: the weight of a body leaning over. */ +const TURN_LAG = 0.3 +/** + * The furthest off the line of the channel the skater may point, radians: 50 degrees. + * Nothing in the lake needs this, which is why it is a rule of its own: lift it and the + * skater can turn right round, as the rest of this model already allows. + */ +export const MAX_HEADING = 0.87 + /** The autopilot never lets the skater closer to a shore than this, metres. */ export const SHORE_MARGIN = 2 /** @@ -12,34 +47,25 @@ export const SHORE_MARGIN = 2 * at sea, where the shores are far out of sight. */ export const MAX_REACH = 150 - +/** Within this of the edge of their room, metres, the skater is turned to run along it. */ +const EDGE = 8 +/** How fast a skater who has got past that edge is brought back to it, m/s. */ +const EDGE_PULL = 6 /** - * How far from the middle of `channel` a skater at `s` may be, metres. It looks ahead: - * where the lake narrows faster than a skater can be steered clear, a skater at the - * shore would be swept sideways by it at speed, so the room starts closing early, and - * by no more than half a metre for each metre forward. + * At a fork the skater changes channel once they are this much nearer the middle of the + * other, metres: a little past half way, so that being a hair to one side as the two + * part does not decide it. */ -function reach(channel: Channel, s: number): number { - let room = MAX_REACH - for (let ahead = 0; ahead <= 200; ahead += 20) { - room = Math.min(room, channel.halfWidth(s + ahead) - SHORE_MARGIN + 0.5 * ahead) - } - return room -} -/** Seconds without steering before the autopilot starts easing back to the middle. */ +const CHANGE_BY = 3 + +/** Seconds without steering before the autopilot starts easing back to its own line. */ export const IDLE_BEFORE_RETURN = 1.5 /** Time constant of that easing, seconds. */ export const RETURN_TIME = 3 -/** - * The shortest and longest a stride lasts, seconds: one push of one leg, one strike of a - * skate on the ice, and then the glide on it until the other leg pushes. - * - * The strikes in the film come every 0.66 s (tools/analyse-video.mjs), but skated at - * that rate the view never stops rocking from leg to leg. These are long, unhurried - * glides instead, and no two the same length. - */ -export const MIN_STRIDE = 1.4 -export const MAX_STRIDE = 3.2 +/** How far ahead of itself the autopilot aims, metres: about two and a half seconds. */ +const LOOK_AHEAD = 18 +/** The most the autopilot's line strays from the middle of the channel, metres. */ +const CARVE = 6 /** How long stride number `index` lasts, seconds: the same every time it is asked. */ export function strideSeconds(index: number): number { @@ -50,16 +76,22 @@ export function strideSeconds(index: number): number { export const UNDER_SECONDS = 4 export interface Skater { - /** Distance skated along the lake, metres. */ + /** Where the skater is: metres across the lake, and metres along it. */ + x: number s: number /** The channel of the lake the skater is in. */ channel: Channel - /** Sideways distance from the middle of that channel, metres; positive is right. */ - offset: number - /** Sideways speed across the channel, m/s. */ - lateral: number - /** Direction of travel relative to straight ahead, radians; positive is right. */ + /** Direction of travel, radians: 0 is straight down the lake, positive is to the right. */ heading: number + /** How fast that direction is changing, radians a second. */ + turn: number + /** m/s along the direction of travel. */ + speed: number + /** + * Where across the channel the autopilot is to keep the skater, metres from its middle: + * where they were last steered to, easing back to 0 once they are left alone. + */ + hold: number /** Seconds since the skater was last steered. */ idle: number /** Strides taken, with the fraction being how far through the current one. */ @@ -70,12 +102,16 @@ export interface Skater { /** A skater `s` metres down the lake, having let the autopilot choose at every fork. */ export function createSkater(lake: Lake, s = 0): Skater { - return { s, channel: channelAt(lake, s), offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0, under: 0 } + const channel = channelAt(lake, s) + return { + x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED, + hold: 0, idle: Infinity, stride: 0, under: 0, + } } -/** Where the skater is across the lake, in the same metres as `Channel.centre`. */ -export function skaterX(k: Skater): number { - return k.channel.centre(k.s) + k.offset +/** Seconds since the push that began the skater's current stride. */ +export function sincePush(k: Skater): number { + return (k.stride % 1) * strideSeconds(Math.floor(k.stride)) } /** @@ -88,72 +124,130 @@ function alongside(k: Skater): Channel | undefined { return k.channel === fork.left ? fork.right : fork.left } +/** + * How far from the middle of `channel` a skater at `s` may be, metres. It looks ahead: + * where the lake narrows faster than a skater can be steered clear, the room starts + * closing early, by no more than half a metre for each metre forward. + */ +function reach(channel: Channel, s: number): number { + let room = MAX_REACH + for (let ahead = 0; ahead <= 200; ahead += 20) { + room = Math.min(room, channel.halfWidth(s + ahead) - SHORE_MARGIN + 0.5 * ahead) + } + return room +} + +/** The skater's room at `s`, as the furthest left and right they may be across the lake. */ +function room(k: Skater, s: number): [number, number] { + const middle = k.channel.centre(s) + const own = reach(k.channel, s) + let left = middle - own + let right = middle + own + // While the other channel of a fork is still alongside, its width is theirs as well. + const other = alongside(k) + if (other) { + const far = reach(other, s) + left = Math.min(left, other.centre(s) - far) + right = Math.max(right, other.centre(s) + far) + } + return [left, right] +} + +const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v)) + /** * Move the skater on by `dt` seconds. `steer` is -1 (hard left) to 1 (hard right). * - * The skater is held as an offset from the middle of a channel rather than as a - * position, which is what makes doing nothing an autopilot: an untouched offset follows - * every bend. Steering moves the offset, and left alone for a while it eases back to 0. + * The skater has a place, a direction and a speed, and steering is turning: the stick + * sets how fast the direction changes, the body takes a moment to follow, and the path + * that results is an arc. Nothing moves the skater sideways. + * + * Left alone, the autopilot does the steering, by the same means and within the same + * limits. It aims at a point `LOOK_AHEAD` metres on, on a line that wanders gently + * across the channel (`Lake.carve`), which makes long S-curves of the way down the lake. + * After being steered it first keeps to where it was put, then eases back to that line. + * + * Either way the skater is kept on the ice. Near the edge of their room they are turned + * to run along it, and at the edge itself they are held there. * * At a fork the skater is put in the channel the fork prefers, and until the island * between the two begins they are in whichever channel's middle they are nearer. So * doing nothing takes the preferred way, and steering across takes the other. - * - * Ice too thin to hold a skater (ice/strength.ts) gives way. The skater is then in the - * water where they fell for `UNDER_SECONDS`, and skates on from the middle of the - * channel, where the ice is always sound (world/lake.ts). */ -export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): void { +export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rules = RULES): void { if (k.under > 0) { k.under = Math.max(0, k.under - dt) - k.lateral = 0 + k.turn = 0 if (k.under === 0) { - k.offset = 0 + k.x = k.channel.centre(k.s) k.heading = Math.atan(k.channel.slope(k.s)) + k.hold = 0 k.idle = Infinity } return } - const before = k.offset + const { channel } = k + const along = Math.atan(channel.slope(k.s)) + const [left, right] = room(k, k.s) + + // What turn is wanted: the steering's, or the autopilot's. + let wanted: number if (Math.abs(steer) > 0.05) { k.idle = 0 - k.offset += Math.max(-1, Math.min(1, steer)) * LATERAL_SPEED * dt + wanted = clamp(steer, -1, 1) * MAX_TURN + // Where this is taking the skater is where the autopilot will keep them afterwards. + k.hold = k.x + Math.sin(k.heading - along) * k.speed * 1 - channel.centre(k.s) } else { k.idle += dt - if (k.idle > IDLE_BEFORE_RETURN) k.offset *= Math.exp(-dt / RETURN_TIME) - } - // The skater has the width of their own channel and, while it is still alongside, of - // the other channel of the fork as well. - const room = reach(k.channel, k.s) - let left = -room - let right = room - const other = alongside(k) - if (other) { - const across = other.centre(k.s) - k.channel.centre(k.s) - const otherReach = reach(other, k.s) - left = Math.min(left, across - otherReach) - right = Math.max(right, across + otherReach) + const settled = clamp((k.idle - IDLE_BEFORE_RETURN) / 5, 0, 1) + if (k.idle > IDLE_BEFORE_RETURN) k.hold *= Math.exp(-dt / RETURN_TIME) + const ahead = k.s + LOOK_AHEAD + // No carving through a fork, from a little before it until the island has begun: the + // way taken there is the fork's to prefer or the skater's to choose, not the line's. + const open = k.s < channel.end.s - 150 && !(channel.birth && k.s < channel.birth.tip + 50) + const carve = open ? settled * Math.min(CARVE, 0.4 * reach(channel, ahead)) * lake.carve(ahead) : 0 + const [farLeft, farRight] = room(k, ahead) + // Aimed clear of the edges, or down the middle where there is not the room for that. + const clear = Math.min(EDGE, (farRight - farLeft) / 2) + const aim = clamp(channel.centre(ahead) + k.hold + carve, farLeft + clear, farRight - clear) + wanted = clamp((Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN) } - k.offset = Math.max(left, Math.min(right, k.offset)) - k.lateral = dt > 0 ? (k.offset - before) / dt : 0 - - // Sideways metres per metre forward. The speed is along the path actually skated, so - // a bend or a hard steer covers a little less of the lake's length. - const slope = k.channel.slope(k.s) + k.lateral / SPEED - k.s += (SPEED * dt) / Math.hypot(1, slope) - // The view turns into a change of direction over a moment rather than snapping to it. - k.heading += (Math.atan(slope) - k.heading) * (1 - Math.exp(-dt / 0.25)) - k.stride += dt / strideSeconds(Math.floor(k.stride)) + // Near the edge of the ice the skater may point less and less towards it, down to + // not at all at the edge itself, and is turned back if they point further than that. + const out = k.heading - along + const mayRight = clamp((right - k.x) / EDGE, 0, 1) * MAX_HEADING + const mayLeft = clamp((k.x - left) / EDGE, 0, 1) * MAX_HEADING + if (out > mayRight) wanted = Math.min(wanted, clamp((mayRight - out) / 0.2, -2 * MAX_TURN, 0)) + if (out < -mayLeft) wanted = Math.max(wanted, clamp((-mayLeft - out) / 0.2, 0, 2 * MAX_TURN)) + + k.turn += (wanted - k.turn) * (1 - Math.exp(-dt / TURN_LAG)) + k.heading = clamp(k.heading + k.turn * dt, along - MAX_HEADING, along + MAX_HEADING) + + const pushing = sincePush(k) < PUSH_SECONDS + k.speed += ((pushing ? PUSH : 0) - DRAG * (k.speed / SPEED) ** 2) * dt // Which channel the skater is in can change; where they are on the ice does not. - const x = skaterX(k) - const into = (channel: Channel): void => { - k.channel = channel - k.offset = x - channel.centre(k.s) + const was = alongside(k) + k.s += k.speed * Math.cos(k.heading) * dt + k.x += k.speed * Math.sin(k.heading) * dt + k.stride += dt / strideSeconds(Math.floor(k.stride)) + if (k.s >= k.channel.end.s) k.channel = k.channel.end.preferred + // At the island's tip the matter is settled by which side of it the skater is on. + const beside = alongside(k) ?? was + const nearerBy = beside ? Math.abs(k.x - k.channel.centre(k.s)) - Math.abs(k.x - beside.centre(k.s)) : 0 + if (beside && nearerBy > (alongside(k) ? CHANGE_BY : 0)) k.channel = beside + + // Kept on the ice, whatever else. A skater past the edge of their room is brought back + // to it and turned along it, firmly but not at a jump: there are two metres between + // that edge and the shore to do it in. + const [edgeLeft, edgeRight] = room(k, k.s) + if (k.x < edgeLeft || k.x > edgeRight) { + const over = k.x < edgeLeft + k.x += clamp(clamp(k.x, edgeLeft, edgeRight) - k.x, -EDGE_PULL * dt, EDGE_PULL * dt) + const now = Math.atan(k.channel.slope(k.s)) + const most = 2 * MAX_TURN * dt + k.heading += over ? clamp(now - k.heading, 0, most) : clamp(now - k.heading, -most, 0) } - if (k.s >= k.channel.end.s) into(k.channel.end.preferred) - const beside = alongside(k) - if (beside && Math.abs(x - beside.centre(k.s)) < Math.abs(k.offset)) into(beside) - if (lake.thickness(x, k.s, k.channel) < BREAKING_THICKNESS) k.under = UNDER_SECONDS + if (rules.falling && lake.thickness(k.x, k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS } diff --git a/src/style.css b/src/style.css index 5aff98c..63b1560 100644 --- a/src/style.css +++ b/src/style.css @@ -37,9 +37,10 @@ body { /* What the skater sees of the lake from under it. */ #water { + /* It is `hidden` in the page until this stylesheet arrives; from here on, this shows it. */ + display: grid; position: fixed; inset: 0; - display: grid; place-items: center; background: #06202c; color: #cfe6f2; @@ -82,19 +83,162 @@ body { position: fixed; top: calc(0.75rem + env(safe-area-inset-top)); left: calc(0.75rem + env(safe-area-inset-left)); + /* Beside the sound button where there is the width for it, and wrapping under + themselves, not it, where there is not. */ + max-width: calc(100vw - 14rem); display: flex; + flex-wrap: wrap; gap: 0.5rem; } -#links a { +/* Between one lake and the next: the haze, closing in at once and clearing slowly. */ +#veil { + position: fixed; + inset: 0; + opacity: 0; + pointer-events: none; +} + +#veil.clearing { + animation: clearing 0.9s ease-out; +} + +@keyframes clearing { + from { + opacity: 1; + } +} + +#links a, +#links button { display: grid; place-items: center; min-height: 2.75rem; padding: 0 0.9rem; + border: 0; 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; + cursor: pointer; +} + +/* The boxes that open over the lake, which waits behind them. */ +dialog { + box-sizing: border-box; + width: min(34rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + padding: 1.5rem; + border: 0; + border-radius: 1rem; + background: #f4f7fa; + color: #0b1a24; + font: 400 1rem/1.5 system-ui, sans-serif; + overflow-y: auto; + /* A long read on a small screen scrolls inside the box, which the page forbids itself. */ + touch-action: pan-y; +} + +dialog::backdrop { + background: rgb(6 32 44 / 60%); +} + +/* The settings sit at the foot of the screen under a clear view of the lake and the sky, + where what they change can be seen changing. */ +#settings-dialog { + margin-bottom: 1rem; + max-height: 62dvh; +} + +#settings-dialog::backdrop { + background: none; +} + +dialog h1 { + margin: 0 0 0.5rem; + font-size: 1.5rem; + /* It takes the focus when the box opens, so that the box opens at its top. */ + outline: none; +} + +dialog h2 { + margin: 1.25rem 0 0.25rem; + font-size: 1.1rem; +} + +dialog p, +dialog dl { + margin: 0 0 0.6rem; +} + +dialog dt { + font-weight: 600; +} + +dialog dd { + margin: 0 0 0.4rem; +} + +dialog a { + color: #0b5a7a; +} + +dialog fieldset { + margin: 0 0 0.9rem; + padding: 0.4rem 0.9rem 0.6rem; + border: 1px solid #c3ced8; + border-radius: 0.6rem; +} + +dialog legend { + padding: 0 0.3rem; + font-weight: 600; +} + +/* A whole line to touch for each choice, not a small circle. */ +dialog label { + display: block; + padding: 0.45rem 0; + cursor: pointer; +} + +dialog label input { + width: 1.15rem; + height: 1.15rem; + margin: 0 0.5rem 0 0; + vertical-align: -0.15rem; +} + +dialog small { + color: #4d6170; +} + +dialog button { + min-height: 3rem; + padding: 0 1.2rem; + border: 2px solid #0b1a24; + border-radius: 2rem; + background: none; + color: #0b1a24; + font: 600 1rem/1 system-ui, sans-serif; + cursor: pointer; +} + +dialog button.done { + display: block; + width: 100%; + margin-top: 1rem; + background: #0b1a24; + color: #fff; +} + +/* On a phone held upright: two of the three links to a row beside the sound button. */ +@media (max-width: 30rem) { + #links a, + #links button { + padding: 0 0.7rem; + font-size: 0.9rem; + } } diff --git a/src/world/lake.ts b/src/world/lake.ts index cc11b8f..0d90431 100644 --- a/src/world/lake.ts +++ b/src/world/lake.ts @@ -1,4 +1,5 @@ import { createNoise2D } from 'simplex-noise' +import { BREAKING_THICKNESS } from '../ice/strength.js' /** A small seeded generator: the same seed is the same lake, on every device. */ export function mulberry32(seed: number): () => number { @@ -62,8 +63,13 @@ export interface Lake { readonly start: Channel /** Height of the land `d` metres from the nearest shore, at `s` along. */ bankHeight(side: -1 | 1, s: number, d: number): number - /** Thickness of the ice, metres, at `x` across and `s` along, for a skater in `channel`. */ - thickness(x: number, s: number, channel: Channel): number + /** Thickness of the ice, metres, at `x` across and `s` along. */ + thickness(x: number, s: number): number + /** + * Which way an unhurried skater drifts at `s`, from -1 (as far left as they go) to 1: + * the long, lazy line the autopilot carves down the lake instead of a straight one. + */ + carve(s: number): number } /** @@ -101,20 +107,20 @@ const FIRST_FORK = 700 const NEXT_FORK = [1800, 4200] as const /** - * The thinnest and thickest the ice gets, metres: from ice that will not hold a skater - * (ice/strength.ts) to ice half as thick again as it needs to be. + * The thinnest and thickest the ice gets, metres. The thick end is half as thick again as + * a skater needs. The thin end is impossible, and is there only when asked for + * (`LakeOptions`): ice under 2.2 cm would not hold anyone (ice/strength.ts), and this + * goes down to 8 mm, which sings at 3.8 kHz. It can be, here: nobody falls through this + * lake (skater.ts), so the ice is free to be as thin as sounds good. Otherwise the + * thinnest ice is that 2.2 cm. */ -export const MIN_THICKNESS = 0.014 +export const MIN_THICKNESS = 0.008 export const MAX_THICKNESS = 0.07 /** - * The ice down the middle of a channel is never thinner than this, metres, which is what - * lets the autopilot skate for ever: it is the 4.1 cm of the film this is modelled on - * (tools/analyse-video.mjs), sound ice that sings. + * How much faster than ordinary ice the impossible ice thins towards the middle of a + * patch of it: such patches stay as rare and as small as thin ice was, and get thinner. */ -export const MIDDLE_THICKNESS = 0.041 -/** How far either side of the middle that holds, metres, and where it has faded out. */ -const MIDDLE_HOLDS = 3 -const MIDDLE_FADES = 8 +const IMPOSSIBLE_PLUNGE = (BREAKING_THICKNESS - MIN_THICKNESS) / (BREAKING_THICKNESS - 0.014) /** The channels the lake has at `s`, left to right, for a skater who is in `channel`. */ export function channelsAt(channel: Channel, s: number): Channel[] { @@ -131,7 +137,13 @@ export function channelAt(lake: Lake, s: number): Channel { return channel } -export function createLake(seed: number): Lake { +/** What may be different about a lake of the same number. It is read as the lake is used, so it may be changed. */ +export interface LakeOptions { + /** Whether the ice may be thinner than any ice could be. */ + impossibleIce: boolean +} + +export function createLake(seed: number, options: LakeOptions = { impossibleIce: false }): Lake { const noise = createNoise2D(mulberry32(seed)) // A long wander with a shorter wiggle on it, which every channel shares. The amplitudes // over the wavelengths keep the bend gentle enough to skate without ever braking. @@ -199,15 +211,15 @@ export function createLake(seed: number): Lake { const rise = 8 * (1 - Math.exp(-d / 25)) + 0.06 * d return rise * (0.55 + 0.45 * noise(s / 140, side * 37 + d / 90)) }, - thickness: (x, s, channel) => { + thickness: (x, s) => { // Ice thickens and thins in broad patches, longer along the lake than across it. - const patch = (MIN_THICKNESS + MAX_THICKNESS) / 2 + ((MAX_THICKNESS - MIN_THICKNESS) / 2) * noise(x / 60 + 500, s / 180) - if (patch >= MIDDLE_THICKNESS) return patch - // Where a thin patch crosses the middle of a channel, a strip of sound ice bridges it. - const off = Math.min(...channelsAt(channel, s).map((c) => Math.abs(x - c.centre(s)))) - const bridge = smooth(1 - (off - MIDDLE_HOLDS) / (MIDDLE_FADES - MIDDLE_HOLDS)) - if (bridge === 1) return MIDDLE_THICKNESS - return mix(patch, MIDDLE_THICKNESS, bridge) + const patch = 0.042 + 0.028 * noise(x / 60 + 500, s / 180) + if (patch >= BREAKING_THICKNESS) return patch + // The thinnest real ice is ice on the point of giving way, and its patches are + // that all the way across. Impossible ice goes on thinning to the middle of them. + if (!options.impossibleIce) return BREAKING_THICKNESS + return BREAKING_THICKNESS - (BREAKING_THICKNESS - patch) * IMPOSSIBLE_PLUNGE }, + carve: (s) => noise(s / 110, 900), } } diff --git a/test/lake.test.ts b/test/lake.test.ts index 33472d1..28d4c88 100644 --- a/test/lake.test.ts +++ b/test/lake.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js' import { - type Channel, FORK_ZONE, MAX_HALF_WIDTH, MAX_THICKNESS, MIDDLE_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, + type Channel, FORK_ZONE, MAX_HALF_WIDTH, MAX_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, ORDINARY_HALF_WIDTH, channelAt, channelsAt, createLake, } from '../src/world/lake.js' @@ -77,8 +77,8 @@ describe('lake', () => { // At the fork both are still the channel they came from. expect(left.centre(s)).toBe(right.centre(s)) expect(left.halfWidth(s)).toBe(right.halfWidth(s)) - expect(left.birth).toBe(fork) - expect([left, right]).toContain(fork.preferred) + expect(left.birth === fork && right.birth === fork).toBe(true) + expect(fork.preferred === left || fork.preferred === right).toBe(true) // The island begins soon after, and from then on there is always land between. expect(tip - s).toBeGreaterThan(50) expect(tip - s).toBeLessThan(400) @@ -106,12 +106,16 @@ describe('lake', () => { it('draws both channels of a fork, and then only the one taken', () => { const fork = lake.start.end - expect(channelsAt(lake.start, fork.s - 1)).toEqual([lake.start]) - expect(channelsAt(lake.start, fork.s + 1)).toEqual([fork.left, fork.right]) + // Channels are compared with `===`, never handed to `expect`: a channel leads to its + // fork and the fork to more channels without end. + const are = (found: Channel[], ...wanted: Channel[]): boolean => + found.length === wanted.length && found.every((channel, i) => channel === wanted[i]) + expect(are(channelsAt(lake.start, fork.s - 1), lake.start)).toBe(true) + expect(are(channelsAt(lake.start, fork.s + 1), fork.left, fork.right)).toBe(true) for (const taken of [fork.left, fork.right]) { - expect(channelsAt(taken, fork.s - 1)).toEqual([taken]) - expect(channelsAt(taken, fork.s + FORK_ZONE - 1)).toEqual([fork.left, fork.right]) - expect(channelsAt(taken, fork.s + FORK_ZONE + 1)).toEqual([taken]) + expect(are(channelsAt(taken, fork.s - 1), taken)).toBe(true) + expect(are(channelsAt(taken, fork.s + FORK_ZONE - 1), fork.left, fork.right)).toBe(true) + expect(are(channelsAt(taken, fork.s + FORK_ZONE + 1), taken)).toBe(true) // Before the fork, a channel is the one it forked from. expect(taken.centre(fork.s - 200)).toBe(lake.start.centre(fork.s - 200)) expect(taken.halfWidth(fork.s - 200)).toBe(lake.start.halfWidth(fork.s - 200)) @@ -121,43 +125,73 @@ describe('lake', () => { it('has ice between its thinnest and thickest everywhere', () => { follow(50_000, 13, (channel, s) => { for (const off of [-80, -20, 0, 35, 90]) { - const h = lake.thickness(channel.centre(s) + off, s, channel) + const h = lake.thickness(channel.centre(s) + off, s) expect(h).toBeGreaterThanOrEqual(MIN_THICKNESS) expect(h).toBeLessThanOrEqual(MAX_THICKNESS) } }) }) - it('always has sound ice down the middle of every channel', () => { - follow(200_000, 5, (channel, s) => { - for (const each of channelsAt(channel, s)) { - for (const off of [-2.9, 0, 2.9]) { - expect(lake.thickness(each.centre(s) + off, s, channel)).toBeGreaterThanOrEqual(MIDDLE_THICKNESS) + it('has every kind of real ice within reach of a skater, and impossible ice only when asked', () => { + for (const impossibleIce of [false, true]) { + const asked = createLake(1, { impossibleIce }) + const zones = { impossible: 0, cracking: 0, singing: 0, safe: 0 } + let all = 0 + let thinnest = Infinity + follow(100_000, 10, (channel, s) => { + const reach = channel.halfWidth(s) - 2 + for (let i = -10; i <= 10; i++) { + const h = asked.thickness(channel.centre(s) + (reach * i) / 10, s) + thinnest = Math.min(thinnest, h) + if (h < BREAKING_THICKNESS) zones.impossible++ + else if (h < CRACKING_THICKNESS) zones.cracking++ + else if (h < SAFE_THICKNESS) zones.singing++ + else zones.safe++ + all++ } + }) + console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', '), `thinnest ${(thinnest * 1000).toFixed(1)} mm`) + if (impossibleIce) { + // Thinner than could hold anyone: there to be found, rare, and far thinner. + expect(zones.impossible / all).toBeGreaterThan(0.005) + expect(zones.impossible / all).toBeLessThan(0.08) + expect(thinnest).toBeLessThan(0.012) + } else { + // The thinnest real ice is ice on the point of giving way, and no thinner. + expect(zones.impossible).toBe(0) + expect(thinnest).toBe(BREAKING_THICKNESS) } - }) + expect(zones.cracking / all).toBeGreaterThan(0.1) + expect(zones.singing / all).toBeGreaterThan(0.2) + expect(zones.safe / all).toBeGreaterThan(0.2) + } }) - it('has every kind of ice within reach of a skater, most of it sound', () => { - const zones = { breaking: 0, cracking: 0, singing: 0, safe: 0 } - let all = 0 + it('can have impossible ice turned on and off under a skater', () => { + const options = { impossibleIce: false } + const changing = createLake(1, options) + let found: [number, number] | undefined follow(100_000, 10, (channel, s) => { - const reach = channel.halfWidth(s) - 2 - for (let i = -10; i <= 10; i++) { - const h = lake.thickness(channel.centre(s) + (reach * i) / 10, s, channel) - if (h < BREAKING_THICKNESS) zones.breaking++ - else if (h < CRACKING_THICKNESS) zones.cracking++ - else if (h < SAFE_THICKNESS) zones.singing++ - else zones.safe++ - all++ - } + if (!found && changing.thickness(channel.centre(s) + 10, s) === BREAKING_THICKNESS) found = [channel.centre(s) + 10, s] }) - console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', ')) - // Ice that gives way is there to be found, and rare enough to be avoided. - expect(zones.breaking / all).toBeGreaterThan(0.005) - expect(zones.breaking / all).toBeLessThan(0.08) - expect(zones.cracking / all).toBeGreaterThan(0.1) - expect(zones.singing / all).toBeGreaterThan(0.2) - expect(zones.safe / all).toBeGreaterThan(0.2) + options.impossibleIce = true + expect(changing.thickness(...found!)).toBeLessThan(BREAKING_THICKNESS) + options.impossibleIce = false + expect(changing.thickness(...found!)).toBe(BREAKING_THICKNESS) + }) + + it('gives the autopilot a line that wanders slowly from side to side', () => { + let leftmost = 0 + let rightmost = 0 + for (let s = 0; s < 50_000; s += 5) { + const c = lake.carve(s) + expect(Math.abs(c)).toBeLessThanOrEqual(1) + // Slowly: never more than a thirtieth of its whole swing in a metre. + expect(Math.abs(lake.carve(s + 1) - c)).toBeLessThan(0.06) + leftmost = Math.min(leftmost, c) + rightmost = Math.max(rightmost, c) + } + expect(leftmost).toBeLessThan(-0.7) + expect(rightmost).toBeGreaterThan(0.7) }) }) diff --git a/test/settings.test.ts b/test/settings.test.ts new file mode 100644 index 0000000..55b8041 --- /dev/null +++ b/test/settings.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { TIME_NAMES } from '../src/render/sky.js' +import { anyLake, loadSettings, timeOf } from '../src/settings.js' + +describe('settings', () => { + it('starts with a surprise for the time of day and no impossible ice', () => { + // There is no storage here, which is also what a private window is like. + expect(loadSettings()).toEqual({ time: 'surprise', impossibleIce: false }) + }) + + it('picks a lake from a million', () => { + for (let i = 0; i < 1000; i++) { + const lake = anyLake() + expect(Number.isInteger(lake)).toBe(true) + expect(lake).toBeGreaterThanOrEqual(1) + expect(lake).toBeLessThanOrEqual(999_999) + } + }) + + it('gives each lake a time of day of its own: every time somewhere, day the likeliest', () => { + const count = Object.fromEntries(TIME_NAMES.map((name) => [name, 0])) + for (let lake = 1; lake <= 20_000; lake++) { + expect(timeOf(lake)).toBe(timeOf(lake)) + count[timeOf(lake)]!++ + } + for (const name of TIME_NAMES) expect(count[name]! / 20_000).toBeGreaterThan(0.12) + expect(count.day).toBe(Math.max(...Object.values(count))) + expect(count.aurora).toBe(Math.min(...Object.values(count))) + }) +}) diff --git a/test/skater.test.ts b/test/skater.test.ts index 8acb874..8a7cbef 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -1,65 +1,104 @@ import { describe, expect, it } from 'vitest' import { BREAKING_THICKNESS } from '../src/ice/strength.js' import { - IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_REACH, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, - type Skater, createSkater, skaterX, stepSkater, strideSeconds, + IDLE_BEFORE_RETURN, MAX_HEADING, MAX_REACH, MAX_STRIDE, MAX_TURN, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, + type Skater, createSkater, stepSkater, strideSeconds, } from '../src/skater.js' import { type Lake, createLake } from '../src/world/lake.js' const DT = 1 / 60 const lake = createLake(1) -/** The same lake with ice that holds everywhere, for trying what is not about thin ice. */ -const sound: Lake = { ...lake, thickness: () => 0.05 } +/** The same lake with an autopilot that keeps to the middle, for trying what is not about its line. */ +const straight: Lake = { ...lake, carve: () => 0 } -function run(k: Skater, steer: number, seconds: number, on = sound): void { +function run(k: Skater, steer: number, seconds: number, on = straight): void { const steps = Math.round(seconds / DT) for (let i = 0; i < steps; i++) stepSkater(k, on, steer, DT) } +/** How far the skater is from the middle of their channel, metres; positive is right. */ +const off = (k: Skater): number => k.x - k.channel.centre(k.s) +/** How far off the line of their channel they point, radians. */ +const out = (k: Skater): number => k.heading - Math.atan(k.channel.slope(k.s)) +/** Whether there is nothing but the skater's own channel to either side of them. */ +const alone = (k: Skater): boolean => !k.channel.birth || k.s > k.channel.birth.tip +// Channels are compared with `===`, never handed to `expect`: a channel leads to its fork +// and the fork to more channels without end, and a failure that tried to print one would +// follow them until the stack ran out. describe('skater', () => { - it('left alone, follows the middle of the lake for as long as you like, through every fork', () => { + it('left alone, skates the lake for as long as you like, through every fork', () => { const k = createSkater(lake) let forks = 0 + let slowest = Infinity + let fastest = 0 + let widest = 0 + const start = k.s for (let t = 0; t < 3600; t += DT) { const before = k.s const channel = k.channel stepSkater(k, lake, 0, DT) expect(k.s).toBeGreaterThan(before) - expect(Math.abs(k.offset)).toBeLessThan(1e-3) expect(k.under).toBe(0) + // On the ice, always: never on the shore side of the two metres kept clear of it. + if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(k.channel.halfWidth(k.s) - 0.5) if (k.channel !== channel) { // Doing nothing takes the way the fork prefers. - expect(k.channel).toBe(channel.end.preferred) + expect(k.channel === channel.end.preferred).toBe(true) forks++ } + slowest = Math.min(slowest, k.speed) + fastest = Math.max(fastest, k.speed) + widest = Math.max(widest, Math.abs(off(k))) } expect(forks).toBeGreaterThan(5) - // An hour at cruising speed, less a little for the bends. - expect(k.s).toBeGreaterThan(SPEED * 3600 * 0.9) - expect(k.s).toBeLessThanOrEqual(SPEED * 3600 + 1) + // It carves: well off the middle at times, where there is the room. + expect(widest).toBeGreaterThan(4) + // The speed breathes with the stride, around its cruising speed and never far from it. + expect((k.s - start) / 3600).toBeGreaterThan(SPEED * 0.9) + expect((k.s - start) / 3600).toBeLessThan(SPEED * 1.1) + expect(slowest).toBeGreaterThan(SPEED * 0.8) + expect(fastest).toBeLessThan(SPEED * 1.2) + expect(fastest - slowest).toBeGreaterThan(1) }) - it('steers across the channel at the steering speed', () => { + it('moves smoothly: no jumps, no jerks, and never turned further than it may be', () => { + for (const steer of [0, 1, -1]) { + const k = createSkater(lake) + for (let t = 0; t < 900; t += DT) { + const { x, heading, speed } = k + // Steered hard one way, with a rest every so often. + stepSkater(k, lake, Math.floor(t / 7) % 3 === 0 ? 0 : steer, DT) + expect(Math.abs(k.x - x)).toBeLessThan(0.25) + expect(Math.abs(k.heading - heading)).toBeLessThan(0.05) + expect(Math.abs(k.speed - speed)).toBeLessThan(0.05) + expect(Math.abs(k.turn)).toBeLessThanOrEqual(2 * MAX_TURN) + expect(Math.abs(out(k))).toBeLessThanOrEqual(MAX_HEADING + 0.02) + } + } + }) + + it('steers by turning, with a moment to lean into it', () => { const k = createSkater(lake) + run(k, 1, 0.1) + // A tenth of a second in, the turn has barely begun. + expect(k.turn).toBeGreaterThan(0) + expect(k.turn).toBeLessThan(MAX_TURN / 2) run(k, 1, 1) - expect(k.offset).toBeCloseTo(LATERAL_SPEED, 1) - expect(k.heading).toBeGreaterThan(0) - run(k, -1, 2) - expect(k.offset).toBeCloseTo(-LATERAL_SPEED, 1) + expect(k.turn).toBeCloseTo(MAX_TURN, 1) + expect(out(k)).toBeGreaterThan(0.2) + expect(off(k)).toBeGreaterThan(0.5) + run(k, -1, 3) + expect(out(k)).toBeLessThan(0) }) it('cannot be steered into a shore, however wide or narrow the lake', () => { for (const steer of [-1, 1]) { const k = createSkater(lake) - let x = skaterX(k) for (let t = 0; t < 1800; t += DT) { - stepSkater(k, sound, steer, DT) - // Never past a shore; held hard against one; and never moved at a jump. - if (!k.channel.birth || k.s > k.channel.birth.tip) { - expect(Math.abs(k.offset)).toBeLessThanOrEqual(Math.min(MAX_REACH, k.channel.halfWidth(k.s) - SHORE_MARGIN) + 0.5) + stepSkater(k, straight, steer, DT) + if (alone(k)) { + expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.5) } - expect(Math.abs(skaterX(k) - x)).toBeLessThan(0.3) - x = skaterX(k) } } }) @@ -68,12 +107,12 @@ describe('skater', () => { const fork = lake.start.end for (const [steer, wanted] of [[-1, fork.left], [1, fork.right]] as const) { const k = createSkater(lake, fork.s - 30) - while (k.s < fork.tip + 50) stepSkater(k, sound, steer, DT) - expect(k.channel).toBe(wanted) - // And from there on left alone, stays in it, in its middle. + while (k.s < fork.tip + 50) stepSkater(k, straight, steer, DT) + expect(k.channel === wanted).toBe(true) + // And from there on left alone, stays in it, and comes back to its middle. run(k, 0, 60) - expect(k.channel).toBe(wanted) - expect(Math.abs(k.offset)).toBeLessThan(0.01) + expect(k.channel === wanted).toBe(true) + expect(Math.abs(off(k))).toBeLessThan(0.5) } }) @@ -83,23 +122,24 @@ describe('skater', () => { const towards = other === fork.right ? 1 : -1 const k = createSkater(lake, fork.s - 1) run(k, 0, 1) - expect(k.channel).toBe(fork.preferred) - while (k.channel !== other) stepSkater(k, sound, towards, DT) + expect(k.channel === fork.preferred).toBe(true) + while (k.channel !== other) stepSkater(k, straight, towards, DT) expect(k.s).toBeLessThan(fork.tip) // Past the island's tip, steering back runs along its shore instead. - while (k.s < fork.tip + 5) stepSkater(k, sound, towards, DT) + while (k.s < fork.tip + 5) stepSkater(k, straight, towards, DT) run(k, -towards, 30) - expect(k.channel).toBe(other) + expect(k.channel === other).toBe(true) }) - it('holds its line for a moment after steering, then eases back to the middle', () => { + it('keeps to where it was steered for a moment, then eases back to the middle', () => { const k = createSkater(lake) - run(k, 1, 1) - const steered = k.offset - run(k, 0, IDLE_BEFORE_RETURN - 0.1) - expect(k.offset).toBeCloseTo(steered, 5) - run(k, 0, 30) - expect(Math.abs(k.offset)).toBeLessThan(0.01) + run(k, 1, 1.5) + run(k, 0, IDLE_BEFORE_RETURN) + // Straightened up, and still well out to the side it was steered to. + expect(off(k)).toBeGreaterThan(2) + run(k, 0, 40) + expect(Math.abs(off(k))).toBeLessThan(0.3) + expect(Math.abs(out(k))).toBeLessThan(0.02) }) it('takes long strides of varied length, the same ones every time', () => { @@ -117,25 +157,40 @@ describe('skater', () => { expect(Math.floor(k.stride)).toBe(3) }) + it('speeds up on the push and slows through the glide', () => { + const k = createSkater(lake) + const before = k.speed + run(k, 0, 0.5) + expect(k.speed).toBeGreaterThan(before + 0.5) + const pushed = k.speed + run(k, 0, strideSeconds(0) - 0.5 - 2 * DT) + expect(k.speed).toBeLessThan(pushed - 0.3) + }) + it('skates the same far down the lake as at its start', () => { const k = createSkater(lake, 1e6) run(k, 0, 10) expect(k.s - 1e6).toBeGreaterThan(SPEED * 10 * 0.9) }) - it('goes through ice too thin to hold, waits in the water, and skates on from the middle', () => { - // A lake that is sound down the middle and gives way anywhere 5 m to the right of it. - const thin: Lake = { ...lake, thickness: (x, s, channel) => (x - channel.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } + it('does not go through the ice, however thin, unless the rules say it can', () => { + // A lake that would give way anywhere 5 m to the right of the middle. + const thin: Lake = { ...straight, thickness: (x, s) => (x - lake.start.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } + const safe = createSkater(lake) + run(safe, 1, 20, thin) + expect(off(safe)).toBeGreaterThan(5) + expect(safe.under).toBe(0) + const k = createSkater(lake) - while (k.under === 0) stepSkater(k, thin, 1, DT) - expect(k.offset).toBeGreaterThan(5) + while (k.under === 0) stepSkater(k, thin, 1, DT, { falling: true }) + expect(off(k)).toBeGreaterThan(5) const where = k.s - for (let i = 0; i < Math.round(UNDER_SECONDS / DT) - 2; i++) stepSkater(k, thin, 1, DT) + for (let i = 0; i < Math.round(UNDER_SECONDS / DT) - 2; i++) stepSkater(k, thin, 1, DT, { falling: true }) expect(k.under).toBeGreaterThan(0) expect(k.s).toBe(where) - for (let i = 0; i < 4; i++) stepSkater(k, thin, 0, DT) + for (let i = 0; i < 4; i++) stepSkater(k, thin, 0, DT, { falling: true }) expect(k.under).toBe(0) - expect(k.offset).toBe(0) + expect(off(k)).toBeCloseTo(0, 1) expect(k.s).toBeGreaterThan(where) }) }) diff --git a/test/strength.test.ts b/test/strength.test.ts index d6b740f..4c27a53 100644 --- a/test/strength.test.ts +++ b/test/strength.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { singingFrequency } from '../src/ice/acoustics.js' import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS, bearingThickness } from '../src/ice/strength.js' -import { MAX_THICKNESS, MIDDLE_THICKNESS, MIN_THICKNESS } from '../src/world/lake.js' +import { MAX_THICKNESS, MIN_THICKNESS } from '../src/world/lake.js' describe('ice strength', () => { it('needs twice the ice for four times the load', () => { @@ -14,9 +14,9 @@ describe('ice strength', () => { expect(SAFE_THICKNESS * 100).toBeCloseTo(4.8, 1) }) - it('puts the film\'s ice, and the middle of the lake, in the zone that sings', () => { - expect(MIDDLE_THICKNESS).toBeGreaterThan(CRACKING_THICKNESS) - expect(MIDDLE_THICKNESS).toBeLessThan(SAFE_THICKNESS) + it('puts the film\'s ice in the zone that sings', () => { + expect(0.041).toBeGreaterThan(CRACKING_THICKNESS) + expect(0.041).toBeLessThan(SAFE_THICKNESS) }) it('has every zone somewhere on the lake, and a pitch that tells them apart', () => {