nordic-skating / commit
feat: weather: falling snow, blizzards, and a wind that can be heard
index.html | 10 +++++- src/audio/iceSound.ts | 18 ++++++++++ src/main.ts | 64 ++++++++++++++++++++++++--------- src/render/snow.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/settings.ts | 34 +++++++++++++++++- test/settings.test.ts | 30 ++++++++++++++-- 6 files changed, 232 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 3db032b..ffface0 100644 --- a/index.html +++ b/index.html @@ -43,7 +43,7 @@ <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> + <dd>Settings, at the top left: day, dusk, night under the Milky Way, or the northern lights; clear, snowing, or a blizzard. Left alone, every lake has a time and a weather of its own.</dd> </dl> <p>Every visit is to a different lake, one of a million.</p> @@ -81,6 +81,14 @@ <label><input type="radio" name="time" value="aurora" /> Aurora <small>the northern lights</small></label> </fieldset> + <fieldset> + <legend>Weather</legend> + <label><input type="radio" name="weather" value="surprise" /> Surprise me <small>each lake has its own</small></label> + <label><input type="radio" name="weather" value="clear" /> Clear</label> + <label><input type="radio" name="weather" value="snow" /> Snow <small>falling quietly</small></label> + <label><input type="radio" name="weather" value="blizzard" /> Blizzard <small>driven by a high wind</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> diff --git a/src/audio/iceSound.ts b/src/audio/iceSound.ts index 2c93280..16d916e 100644 --- a/src/audio/iceSound.ts +++ b/src/audio/iceSound.ts @@ -26,6 +26,7 @@ interface Voice { * few hundredths of a second: the chirp of a strike right under you. * - **The hum and the scrape.** Between strikes the gliding blade keeps the note faintly * alive, and hisses on the surface. + * - **The wind.** When there is one: low, loose noise, louder and higher in its gusts. * - **The cracks.** Now and then the ice cracks some way off, and that arrives as the * long falling "pew": high frequencies first, in the order the ice carries them. The * fall is a sine wave, the one pure tone here, and it is gone as soon as it lands on @@ -45,6 +46,8 @@ export class IceSound { private readonly scrapeGain: GainNode private readonly tickGain: GainNode private readonly splashGain: GainNode + private readonly gust: BiquadFilterNode + private readonly windGain: GainNode constructor(private readonly ctx: BaseAudioContext, random: () => number = Math.random) { // A limiter, in effect: several rings and a crack can land together. @@ -106,6 +109,21 @@ export class IceSound { this.splashGain = ctx.createGain() this.splashGain.gain.value = 0 this.noise.connect(splash).connect(this.splashGain).connect(this.out) + + // The wind: the low end of the same noise, loose and wide, rising in pitch as it blows harder. + this.gust = ctx.createBiquadFilter() + this.gust.type = 'bandpass' + this.gust.frequency.value = 300 + this.gust.Q.value = 0.6 + this.windGain = ctx.createGain() + this.windGain.gain.value = 0 + this.noise.connect(this.gust).connect(this.windGain).connect(this.out) + } + + /** The wind over the lake, from 0 for still air to 1 for the worst gust of a blizzard. */ + wind(strength: number, when = this.ctx.currentTime): void { + this.gust.frequency.setTargetAtTime(280 + 520 * strength, when, 0.4) + this.windGain.gain.setTargetAtTime(0.9 * strength ** 1.5, when, 0.4) } /** diff --git a/src/main.ts b/src/main.ts index b0600f8..3897f4a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,8 @@ 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 { Snow } from './render/snow.js' +import { WEATHER, WEATHERS, type Weather, anyLake, loadSettings, saveSettings, timeOf, weatherOf, windOf } from './settings.js' import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js' import { PUSH_SECONDS, UNDER_SECONDS, createSkater, sincePush, stepSkater } from './skater.js' import { createLake } from './world/lake.js' @@ -62,16 +63,25 @@ let lake = createLake(seed, lakeOptions) const ice = new Ice(renderer, lake) const shores = new Shores(lake, seed) const sky = new Sky() +const snow = new Snow() // 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(sky.mesh, ice.mesh, shores.group, snow.points, skyLight, 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) +/** Put the lake under the sky, in the light and in the weather it is to have. */ +function showWorld(): void { + const look = LOOKS[timeNow()] + const weather = WEATHER[weatherNow()] + // Cloud takes the colour out of a horizon: a sunset seen through snow is grey with a + // memory of orange in it. + const horizon = { h: 0, s: 0, l: 0 } + haze.color.set(look.horizon).getHSL(horizon) + haze.color.setHSL(horizon.h, horizon.s * (1 - 0.75 * weather.overcast), horizon.l) + sky.show(look, seed, weather.overcast, haze.color) + snow.show(weather.snow, look.snow) + // Snow in the air is haze as well: the further shore goes first, and in a blizzard both. + haze.far = weather.sight skyLight.color.set(look.skyLight) skyLight.groundColor.set(look.groundLight) skyLight.intensity = look.skyStrength @@ -81,10 +91,17 @@ function showTime(time: TimeName): void { ice.light = look.iceLight } const asked = params.get('time') as TimeName | null +const askedWeather = params.get('weather') as Weather | 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()) +function timeNow(): TimeName { + return asked && TIME_NAMES.includes(asked) ? asked : settings.time === 'surprise' ? timeOf(seed) : settings.time +} +/** And the weather, likewise: `?weather=snow` asks for one whatever the settings say. */ +function weatherNow(): Weather { + if (askedWeather && WEATHERS.includes(askedWeather)) return askedWeather + return settings.weather === 'surprise' ? weatherOf(seed) : settings.weather +} +showWorld() /** How far below level the skater looks, radians. */ let pitch = 0 @@ -100,6 +117,7 @@ function resize(): void { // the ice coming, rather than fill the top half of the screen with sky. pitch = w < h ? 0.3 : 0.12 camera.updateProjectionMatrix() + snow.resize(renderer.domElement.height) } window.addEventListener('resize', resize) resize() @@ -248,8 +266,8 @@ if (settings.tilt && Input.canTilt) { } 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 + for (const radio of settingsBox.querySelectorAll<HTMLInputElement>('input[type=radio]')) { + radio.checked = radio.value === settings[radio.name as 'time' | 'weather'] } impossible.checked = settings.impossibleIce tiltButton.textContent = settings.tilt ? '📱 Tilt on' : '📱 Tilt off' @@ -262,9 +280,9 @@ function showSettings(): void { 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()) + if (changed.name === 'time' || changed.name === 'weather') { + ;(settings[changed.name] as string) = changed.value + showWorld() } else if (changed === impossible) { settings.impossibleIce = lakeOptions.impossibleIce = impossible.checked ice.redraw() @@ -285,7 +303,7 @@ function enterLake(to: number): void { ice.show(lake) shores.show(lake, seed) skater = createSkater(lake) - showTime(timeNow()) + showWorld() showLake() // One lake does not turn into another: the haze closes in and clears on the new one. veil.style.background = haze.color.getStyle() @@ -341,8 +359,15 @@ let untilCrack = 3 renderer.setAnimationLoop(() => { // 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) + const passed = Math.min(clock.getDelta(), 0.1) + const dt = waiting() ? 0 : passed sky.update(clock.elapsedTime) + // The wind: between its lull and its gust, never the same for long, from where the + // lake has it coming from. + const { wind: [lull, gusting] } = WEATHER[weatherNow()] + const t = clock.elapsedTime + const blowing = lull + (gusting - lull) * (0.5 + 0.5 * Math.sin(t * 0.23) * Math.sin(t * 0.071 + 1)) + const from = windOf(seed) const steer = input.steer() if (steer !== 0) hint.classList.add('gone') const strides = Math.floor(skater.stride) @@ -355,6 +380,7 @@ renderer.setAnimationLoop(() => { // In the water for all but the last second, which is the climb back out. water.classList.toggle('under', skater.under > 1) + if (sound && sounding()) sound.wind(blowing / WEATHER.blizzard.wind[1]) if (sound && sounding() && skater.under === 0) { // One strike of a skate at the start of each stride, no two quite alike. if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, 0.75 + 0.5 * Math.random()) @@ -388,6 +414,10 @@ renderer.setAnimationLoop(() => { const look = skater.heading - 0.25 * Math.sin(skater.heading - along) camera.rotation.set(-pitch, -look, -0.02 * skater.turn * skater.speed) + // The snow falls on while the lake waits behind a box; the skater does not move through it. + const moving = dt > 0 ? skater.speed : 0 + snow.update(passed, blowing * Math.sin(from), blowing * Math.cos(from), moving * Math.sin(skater.heading), moving * Math.cos(skater.heading)) + const facing = Math.cos(look - along) ice.update(skater.x, skater.s, facing) shores.update(skater.x, skater.s, skater.channel, facing) diff --git a/src/render/snow.ts b/src/render/snow.ts new file mode 100644 index 0000000..54d0bb7 --- /dev/null +++ b/src/render/snow.ts @@ -0,0 +1,97 @@ +import * as THREE from 'three' +import { mulberry32 } from '../world/lake.js' + +/** The box of air round the skater that has snow in it: metres across, up, and along. */ +const BOX = [44, 22, 44] as const +/** Snowflakes in a blizzard; lighter snow shows a share of them. */ +const FLAKES = 6000 +/** How fast a snowflake falls, m/s. */ +const FALL = 1.5 + +const vertex = /* glsl */ ` + attribute float aOrder; + uniform vec3 uBox; + uniform vec3 uDrift; + uniform float uAmount; + uniform float uPixels; + varying float vShow; + void main() { + // Every flake is somewhere in the box, and the box goes round: what leaves one side + // of it comes in at the other, so the air is always full without a flake being made. + vec3 at = fract(position + uDrift) * uBox - vec3(uBox.x * 0.5, 3.0, uBox.z * 0.5); + vec4 seen = modelViewMatrix * vec4(at, 1.0); + gl_Position = projectionMatrix * seen; + float far = length(seen.xyz); + gl_PointSize = uPixels * (0.05 + 0.06 * fract(aOrder * 7.3)) / far; + // Lighter snow is fewer flakes; and they fade out towards the edge of the box. + vShow = step(aOrder, uAmount) * smoothstep(21.0, 14.0, far) * smoothstep(0.3, 1.5, far); + } +` + +const fragment = /* glsl */ ` + uniform vec3 uColour; + varying float vShow; + void main() { + float soft = smoothstep(0.5, 0.15, length(gl_PointCoord - 0.5)); + if (vShow * soft < 0.01) discard; + gl_FragColor = vec4(uColour, vShow * soft * 0.9); + #include <colorspace_fragment> + } +` + +/** + * Falling snow: a box of flakes that stays round the skater, through which the flakes + * fall, are blown by the wind, and stream past as the skater moves. + */ +export class Snow { + readonly points: THREE.Points + private readonly uniforms = { + uBox: { value: new THREE.Vector3(...BOX) }, + uDrift: { value: new THREE.Vector3() }, + uAmount: { value: 0 }, + uPixels: { value: 1 }, + uColour: { value: new THREE.Color() }, + } + + constructor() { + const random = mulberry32(1947) + const places = new Float32Array(FLAKES * 3) + const order = new Float32Array(FLAKES) + for (let i = 0; i < FLAKES; i++) { + for (let c = 0; c < 3; c++) places[i * 3 + c] = random() + order[i] = random() + } + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(places, 3)) + geometry.setAttribute('aOrder', new THREE.BufferAttribute(order, 1)) + this.points = new THREE.Points(geometry, new THREE.ShaderMaterial({ + uniforms: this.uniforms, vertexShader: vertex, fragmentShader: fragment, + transparent: true, depthWrite: false, + })) + this.points.frustumCulled = false + } + + /** How much snow: 0 for none, 1 for a blizzard; and its colour in this light. */ + show(amount: number, colour: number): void { + this.uniforms.uAmount.value = amount + this.uniforms.uColour.value.set(colour) + this.points.visible = amount > 0 + } + + /** `height` is the height of the picture in pixels, which sets how big a flake is drawn. */ + resize(height: number): void { + this.uniforms.uPixels.value = height + } + + /** + * Move the snow on by `dt` seconds: falling, blown by a wind of `windX` m/s across the + * lake and `windS` along it, and left behind by a skater moving at `moveX` and `moveS`. + */ + update(dt: number, windX: number, windS: number, moveX: number, moveS: number): void { + const drift = this.uniforms.uDrift.value + // What is drawn looks down -z, so along the lake is the other way round. + drift.x = (drift.x + ((windX - moveX) * dt) / BOX[0]) % 1 + drift.y = (drift.y - (FALL * dt) / BOX[1]) % 1 + drift.z = (drift.z - ((windS - moveS) * dt) / BOX[2]) % 1 + } +} diff --git a/src/settings.ts b/src/settings.ts index 71bc76f..696d371 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,17 +1,34 @@ import { TIME_NAMES, type TimeName } from './render/sky.js' import { mulberry32 } from './world/lake.js' +/** The weathers a lake can have. */ +export const WEATHERS = ['clear', 'snow', 'blizzard'] as const +export type Weather = (typeof WEATHERS)[number] + +/** + * What a weather is: how much snow is falling, from 0 to 1; how far the eye can see + * through it, metres; how much of the sky is cloud; and how hard the wind blows, m/s, + * at its lulls and in its gusts. + */ +export const WEATHER: Record<Weather, { snow: number; sight: number; overcast: number; wind: readonly [number, number] }> = { + clear: { snow: 0, sight: 600, overcast: 0, wind: [0, 0] }, + snow: { snow: 0.35, sight: 420, overcast: 0.7, wind: [0.5, 3] }, + blizzard: { snow: 1, sight: 200, overcast: 0.95, wind: [7, 16] }, +} + /** 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' + /** The weather, or `surprise` for whichever the lake comes with. */ + weather: Weather | 'surprise' /** Whether the lake has ice thinner than any ice could be (world/lake.ts). */ impossibleIce: boolean /** Whether tilting the phone steers (input.ts). */ tilt: boolean } -const DEFAULTS: Settings = { time: 'surprise', impossibleIce: false, tilt: false } +const DEFAULTS: Settings = { time: 'surprise', weather: 'surprise', impossibleIce: false, tilt: false } const KEY = 'settings' export function loadSettings(): Settings { @@ -19,6 +36,7 @@ export function loadSettings(): Settings { 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, + weather: kept.weather === 'surprise' || WEATHERS.includes(kept.weather as Weather) ? kept.weather! : DEFAULTS.weather, impossibleIce: kept.impossibleIce === true, tilt: kept.tilt === true, } @@ -50,3 +68,17 @@ 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' } + +/** + * The weather lake number `seed` comes with. Most days are clear; snow falls on a + * quarter of them, and a blizzard blows on one in ten. + */ +export function weatherOf(seed: number): Weather { + const r = mulberry32(seed ^ 0x3c6ef372)() + return r < 0.64 ? 'clear' : r < 0.9 ? 'snow' : 'blizzard' +} + +/** Which way the wind blows over lake number `seed`, radians: 0 is down the lake, a quarter turn is across it to the right. */ +export function windOf(seed: number): number { + return mulberry32(seed ^ 0x1b873593)() * Math.PI * 2 +} diff --git a/test/settings.test.ts b/test/settings.test.ts index 9c3bf16..55bbeca 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' import { TIME_NAMES } from '../src/render/sky.js' -import { anyLake, loadSettings, timeOf } from '../src/settings.js' +import { WEATHER, WEATHERS, anyLake, loadSettings, timeOf, weatherOf, windOf } from '../src/settings.js' describe('settings', () => { it('starts with a surprise for the time of day, no impossible ice, and steering by touch', () => { // There is no storage here, which is also what a private window is like. - expect(loadSettings()).toEqual({ time: 'surprise', impossibleIce: false, tilt: false }) + expect(loadSettings()).toEqual({ time: 'surprise', weather: 'surprise', impossibleIce: false, tilt: false }) }) it('picks a lake from a million', () => { @@ -27,4 +27,30 @@ describe('settings', () => { expect(count.day).toBe(Math.max(...Object.values(count))) expect(count.aurora).toBe(Math.min(...Object.values(count))) }) + + it('gives each lake a weather of its own: mostly clear, sometimes snow, now and then a blizzard', () => { + const count = Object.fromEntries(WEATHERS.map((name) => [name, 0])) + for (let lake = 1; lake <= 20_000; lake++) { + expect(weatherOf(lake)).toBe(weatherOf(lake)) + count[weatherOf(lake)]!++ + expect(windOf(lake)).toBeGreaterThanOrEqual(0) + expect(windOf(lake)).toBeLessThan(2 * Math.PI) + } + expect(count.clear! / 20_000).toBeGreaterThan(0.55) + expect(count.snow! / 20_000).toBeGreaterThan(0.2) + expect(count.blizzard! / 20_000).toBeGreaterThan(0.06) + expect(count.blizzard).toBeLessThan(count.snow!) + }) + + it('has weather that gets worse in every way together', () => { + const [clear, falling, blizzard] = WEATHERS.map((name) => WEATHER[name]) + expect(clear!.snow).toBe(0) + expect(clear!.wind[1]).toBe(0) + for (const [milder, worse] of [[clear!, falling!], [falling!, blizzard!]] as const) { + expect(worse.snow).toBeGreaterThan(milder.snow) + expect(worse.sight).toBeLessThan(milder.sight) + expect(worse.overcast).toBeGreaterThan(milder.overcast) + expect(worse.wind[1]).toBeGreaterThan(milder.wind[1]) + } + }) })