nordic-skating / commit
feat: snow drifts that slow and muffle, and steering by a game controller
index.html | 2 + src/audio/iceSound.ts | 19 ++++++-- src/input.ts | 45 +++++++++++++++++- src/main.ts | 24 ++++++++-- src/render/drifts.ts | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/skater.ts | 7 ++- src/world/lake.ts | 5 ++ test/input.test.ts | 15 +++++- test/skater.test.ts | 11 +++++ 9 files changed, 241 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index 6c77cd4..214c149 100644 --- a/index.html +++ b/index.html @@ -40,6 +40,8 @@ <dd>Tufts of reeds standing in the ice. The skater goes round them unasked; you can weave between them, or circle one.</dd> <dt>Sound on or off</dt> <dd>The button at the top right, or M.</dd> + <dt>With a game controller</dt> + <dd>Either stick or the direction buttons steer. Y or triangle goes to another lake; X or square turns the sound on and off.</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> diff --git a/src/audio/iceSound.ts b/src/audio/iceSound.ts index 16d916e..fe80978 100644 --- a/src/audio/iceSound.ts +++ b/src/audio/iceSound.ts @@ -44,6 +44,7 @@ export class IceSound { private readonly hum: BiquadFilterNode private readonly humGain: GainNode private readonly scrapeGain: GainNode + private readonly snowGain: GainNode private readonly tickGain: GainNode private readonly splashGain: GainNode private readonly gust: BiquadFilterNode @@ -110,6 +111,14 @@ export class IceSound { this.splashGain.gain.value = 0 this.noise.connect(splash).connect(this.splashGain).connect(this.out) + // A blade in snow: no ring and no hiss, only a soft, low shush. + const shush = ctx.createBiquadFilter() + shush.type = 'lowpass' + shush.frequency.value = 1100 + this.snowGain = ctx.createGain() + this.snowGain.gain.value = 0 + this.noise.connect(shush).connect(this.snowGain).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' @@ -130,10 +139,14 @@ export class IceSound { * The blade gliding over ice `h` metres thick. `push` is 1 at the moment of a strike * and falls to 0 through the glide that follows it. */ - glide(h: number, push: number, when = this.ctx.currentTime): void { + glide(h: number, push: number, when = this.ctx.currentTime, snow = 0): void { + // Snow lying on the ice takes the blade off it: `snow` is how much, 0 to 1. The ice + // under a drift does not sing, and the blade shushes through it. + const bare = 1 - snow this.hum.frequency.setTargetAtTime(singingFrequency(h), when, 0.05) - this.humGain.gain.setTargetAtTime(0.15 + 0.5 * push, when, 0.03) - this.scrapeGain.gain.setTargetAtTime(0.012 + 0.03 * push, when, 0.03) + this.humGain.gain.setTargetAtTime((0.15 + 0.5 * push) * bare, when, 0.03) + this.scrapeGain.gain.setTargetAtTime((0.012 + 0.03 * push) * bare, when, 0.03) + this.snowGain.gain.setTargetAtTime((0.05 + 0.1 * push) * snow, when, 0.05) } /** diff --git a/src/input.ts b/src/input.ts index b135b10..285de7f 100644 --- a/src/input.ts +++ b/src/input.ts @@ -24,18 +24,31 @@ export function tiltSteer(beta: number, gamma: number, turned: number): number { return past <= 0 ? 0 : Math.sign(roll) * Math.min(1, past / (TILT_FULL - TILT_REST)) } +/** How far a controller's stick is from its middle before it counts: a stick at rest is never quite at it. */ +const PAD_REST = 0.15 + +/** Steering from a controller's stick, pushed `lean` of the way over, -1 to 1: nothing near its middle, and easing in from there. */ +export function padSteer(lean: number): number { + const past = Math.abs(lean) - PAD_REST + return past <= 0 ? 0 : Math.sign(lean) * Math.min(1, past / (1 - PAD_REST)) +} + /** What iPhones add to the event: steering by tilt has to be asked for, and at a touch. */ type Asking = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<'granted' | 'denied'> } /** * Steering, from whatever is to hand: a finger or mouse dragged sideways from where it - * went down, the arrow keys or A and D, or, if it has been turned on, the tilt of a phone. + * went down, the arrow keys or A and D, a game controller's stick or direction buttons, + * or, if it has been turned on, the tilt of a phone. */ export class Input { private pointer: number | null = null private startX = 0 private drag = 0 private tilt = 0 + private pad = 0 + /** The controller buttons that were down the last time they were read. */ + private padDown = new Set<number>() private readonly keys = new Set<string>() private readonly tilted = (e: DeviceOrientationEvent): void => { if (e.beta === null || e.gamma === null) return @@ -101,6 +114,34 @@ export class Input { steer(): number { const held = (...codes: string[]): number => (codes.some((c) => this.keys.has(c)) ? 1 : 0) const keys = held('ArrowRight', 'KeyD') - held('ArrowLeft', 'KeyA') - return Math.max(-1, Math.min(1, keys + this.drag + this.tilt)) + return Math.max(-1, Math.min(1, keys + this.drag + this.tilt + this.pad)) + } + + /** + * Read the game controllers, which have to be asked: they send nothing of their own + * accord. Called once a frame. Answers which of their buttons have just been pressed, + * by the numbers of the standard layout: 2 is X or square, 3 is Y or triangle. + */ + pollPads(): number[] { + const pressed: number[] = [] + const down = new Set<number>() + this.pad = 0 + for (const pad of navigator.getGamepads?.() ?? []) { + if (!pad) continue + // Either stick, or the direction buttons, whichever is pushed furthest. + const left = pad.buttons[14]?.pressed ? -1 : 0 + const right = pad.buttons[15]?.pressed ? 1 : 0 + for (const lean of [pad.axes[0] ?? 0, pad.axes[2] ?? 0, left + right]) { + const steer = padSteer(lean) + if (Math.abs(steer) > Math.abs(this.pad)) this.pad = steer + } + pad.buttons.forEach((button, n) => { + if (!button.pressed) return + down.add(n) + if (!this.padDown.has(n)) pressed.push(n) + }) + } + this.padDown = down + return pressed } } diff --git a/src/main.ts b/src/main.ts index 90c62bd..c154452 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import './style.css' import * as THREE from 'three' import { IceSound } from './audio/iceSound.js' import { Input } from './input.js' +import { Drifts } from './render/drifts.js' import { Ice } from './render/ice.js' import { Marks } from './render/marks.js' import { Shores } from './render/shores.js' @@ -59,7 +60,8 @@ const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500) camera.rotation.order = 'YXZ' // The lake reads this as it goes, so the settings box can change it under a skater. -const lakeOptions = { impossibleIce: settings.impossibleIce } +const drifts = new Drifts() +const lakeOptions = { impossibleIce: settings.impossibleIce, snow: (x: number, s: number) => drifts.cover(x, s) } let lake = createLake(seed, lakeOptions) const ice = new Ice(renderer, lake) const shores = new Shores(lake, seed) @@ -69,7 +71,7 @@ const marks = new Marks() // 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, marks.mesh, shores.group, snow.points, skyLight, sun) +scene.add(sky.mesh, ice.mesh, drifts.mesh, marks.mesh, shores.group, snow.points, skyLight, sun) /** Put the lake under the sky, in the light and in the weather it is to have. */ function showWorld(): void { @@ -82,6 +84,8 @@ function showWorld(): void { 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 that falls lies, in drifts, laid by this lake's wind. + drifts.show(seed, weather.snow, windOf(seed)) // 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) @@ -366,6 +370,14 @@ 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. + // A game controller: its stick steers (input.ts), Y or triangle goes to another lake, + // and X or square turns the sound on and off. + for (const button of input.pollPads()) { + if (button === 3 && !waiting()) randomize() + else if (button === 2) setMuted(audio !== null && !muted) + // Any button is a touch, as far as a browser's leave to make sound goes. + else startSound() + } const passed = Math.min(clock.getDelta(), 0.1) const dt = waiting() ? 0 : passed sky.update(clock.elapsedTime) @@ -405,9 +417,12 @@ renderer.setAnimationLoop(() => { 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()) + // Snow under the blade: the strike is muffled by as much as there is of it, and the + // glide shushes instead of singing. + const lying = lake.snow(skater.x, skater.s) + if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, (0.75 + 0.5 * Math.random()) * (1 - 0.9 * lying)) // The push is over half a second into a stride; the rest of it is glide. - sound.glide(h, Math.exp(-sincePush(skater) / 0.5)) + sound.glide(h, Math.exp(-sincePush(skater) / 0.5), undefined, lying) untilCrack -= dt * (1 + 14 * strain ** 2) if (untilCrack <= 0) { // On sound ice: mostly near, now and then from far down the lake. @@ -445,6 +460,7 @@ renderer.setAnimationLoop(() => { const facing = Math.cos(look - along) ice.update(skater.x, skater.s, facing) + drifts.update(skater.x, skater.s) shores.update(skater.x, skater.s, skater.channel, facing) renderer.render(scene, camera) }) diff --git a/src/render/drifts.ts b/src/render/drifts.ts new file mode 100644 index 0000000..b59a7af --- /dev/null +++ b/src/render/drifts.ts @@ -0,0 +1,124 @@ +import * as THREE from 'three' +import { mulberry32 } from '../world/lake.js' + +/** Side of the square of lake the picture of the drifts covers before it repeats, metres, and its side in pixels. */ +const TILE = 256 +const PIXELS = 512 +/** Side of the sheet they are drawn on, metres: past where the haze ends. */ +const SIZE = 1600 + +/** + * Snow lying on the ice in drifts: long streaks laid down by the wind, all running the + * way it blows. Where one lies the ice is covered, and a skater on it is slowed and + * hears snow under the blade, not ice (skater.ts, audio/iceSound.ts). + * + * The drifts are one picture, repeated across the lake and turned to the wind. The same + * picture is what is asked where a drift lies, so what is seen and what is felt agree. + */ +export class Drifts { + readonly mesh: THREE.Mesh + private readonly canvas = document.createElement('canvas') + private readonly texture: THREE.CanvasTexture + private readonly corners: THREE.BufferAttribute + /** How much snow lies at each pixel of the picture, 0 to 255; none of it when there are no drifts. */ + private lying: Uint8ClampedArray | null = null + /** The way the wind blows, as how far across the lake and how far along it for each metre. */ + private windX = 0 + private windS = 1 + + constructor() { + this.canvas.width = this.canvas.height = PIXELS + this.texture = new THREE.CanvasTexture(this.canvas) + this.texture.colorSpace = THREE.SRGBColorSpace + this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping + // The picture's first row is where its coordinates begin, as it is when it is asked about. + this.texture.flipY = false + const geometry = new THREE.PlaneGeometry(SIZE, SIZE) + this.corners = geometry.getAttribute('uv') as THREE.BufferAttribute + this.mesh = new THREE.Mesh( + geometry, + // Lit like the snow on the banks: white by day, blue by night. + new THREE.MeshLambertMaterial({ map: this.texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }), + ) + this.mesh.rotation.x = -Math.PI / 2 + this.mesh.visible = false + } + + /** + * Lay the drifts of lake number `seed`: `amount` of them, from 0 for none to 1 for + * those of a blizzard, by a wind blowing `windFrom` radians round from down the lake. + */ + show(seed: number, amount: number, windFrom: number): void { + this.windX = Math.sin(windFrom) + this.windS = Math.cos(windFrom) + this.mesh.visible = amount > 0 + if (amount === 0) { + this.lying = null + return + } + const g = this.canvas.getContext('2d', { willReadFrequently: true })! + const random = mulberry32(seed ^ 0x9e3779b9) + g.clearRect(0, 0, PIXELS, PIXELS) + const perMetre = PIXELS / TILE + for (let n = 0; n < 20 + 90 * amount; n++) { + // A streak: tens of metres long down the wind, a few across, thick in its middle + // and thinning to nothing at its edges. The wind is along the picture. + const x = random() * PIXELS + const y = random() * PIXELS + const long = (25 + 80 * random()) * perMetre + const wide = (2 + 6 * random() * (0.5 + amount)) * perMetre + for (const dx of [-PIXELS, 0, PIXELS]) { + for (const dy of [-PIXELS, 0, PIXELS]) { + g.save() + g.translate(x + dx, y + dy) + g.scale(long, wide) + const soft = g.createRadialGradient(0, 0, 0, 0, 0, 1) + soft.addColorStop(0, 'rgba(236, 241, 246, 0.95)') + soft.addColorStop(0.55, 'rgba(236, 241, 246, 0.8)') + soft.addColorStop(1, 'rgba(236, 241, 246, 0)') + g.fillStyle = soft + g.fillRect(-1, -1, 2, 2) + g.restore() + } + } + } + const picture = g.getImageData(0, 0, PIXELS, PIXELS).data + this.lying = new Uint8ClampedArray(PIXELS * PIXELS) + for (let i = 0; i < this.lying.length; i++) this.lying[i] = picture[i * 4 + 3]! + this.texture.needsUpdate = true + } + + /** Where a place on the lake is in the picture, in tiles: down the wind, and across it. */ + private place(x: number, s: number): [number, number] { + return [(x * this.windX + s * this.windS) / TILE, (x * this.windS - s * this.windX) / TILE] + } + + /** How much snow lies at `x` across the lake and `s` along it: 0 for bare ice to 1 for a drift's middle. */ + cover(x: number, s: number): number { + if (!this.lying) return 0 + const [u, v] = this.place(x, s) + const col = Math.floor((u - Math.floor(u)) * PIXELS) + const row = Math.floor((v - Math.floor(v)) * PIXELS) + return this.lying[row * PIXELS + col]! / 255 + } + + /** Put the right drifts under a skater who is at `x` across and `s` along. */ + update(x: number, s: number): void { + if (!this.mesh.visible) return + // The sheet stays under the skater, and each of its corners is told which part of + // the picture it is over: worked out here, where the numbers are exact, and with the + // whole tiles taken off so that they are small. + const [u0, v0] = this.place(x, s) + const half = SIZE / 2 + // The corners as the sheet has them: across, and up the sheet, which is along the lake. + const at = [[-half, half], [half, half], [-half, -half], [half, -half]] as const + for (const [i, [dx, ds]] of at.entries()) { + this.corners.setXY( + i, + u0 - Math.floor(u0) + (dx * this.windX + ds * this.windS) / TILE, + v0 - Math.floor(v0) + (dx * this.windS - ds * this.windX) / TILE, + ) + } + this.corners.needsUpdate = true + } +} diff --git a/src/skater.ts b/src/skater.ts index 379bc4b..3d6c333 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -23,6 +23,8 @@ export const SPEED = 7 */ export const PUSH_SECONDS = 0.5 const DRAG = 0.42 +/** How much harder than ice a skate is dragged at in the middle of a snow drift, less one. */ +const SNOW_DRAG = 3 /** 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 @@ -356,7 +358,10 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul k.heading += k.turn * dt const pushing = sincePush(k) < PUSH_SECONDS - k.speed += ((pushing ? PUSH : 0) - DRAG * (k.speed / SPEED) ** 2) * dt + // Snow on the ice drags at the blade: in the middle of a drift, four times as hard as + // ice does, which brings a skater down to about half their speed. + const drag = DRAG * (1 + SNOW_DRAG * lake.snow(k.x, k.s)) + 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 was = alongside(k) diff --git a/src/world/lake.ts b/src/world/lake.ts index 543b807..e501a5d 100644 --- a/src/world/lake.ts +++ b/src/world/lake.ts @@ -93,6 +93,8 @@ export interface Lake { bankHeight(side: -1 | 1, s: number, d: number): number /** Thickness of the ice, metres, at `x` across and `s` along. */ thickness(x: number, s: number): number + /** How much snow lies on the ice at `x` across and `s` along: 0 for bare ice to 1 for the middle of a drift. */ + snow(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. @@ -189,6 +191,8 @@ export function channelAt(lake: Lake, s: number): Channel { export interface LakeOptions { /** Whether the ice may be thinner than any ice could be. */ impossibleIce: boolean + /** Where snow lies on the ice, if any does: that is the weather's to say, not the lake's. */ + snow?: (x: number, s: number) => number } export function createLake(seed: number, options: LakeOptions = { impossibleIce: false }): Lake { @@ -309,6 +313,7 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce: if (!options.impossibleIce) return BREAKING_THICKNESS return BREAKING_THICKNESS - (BREAKING_THICKNESS - patch) * IMPOSSIBLE_PLUNGE }, + snow: (x, s) => options.snow?.(x, s) ?? 0, carve: (s) => noise(s / 110, 900), } } diff --git a/test/input.test.ts b/test/input.test.ts index 80ddb40..aaa9139 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { tiltSteer } from '../src/input.js' +import { padSteer, tiltSteer } from '../src/input.js' describe('steering by tilt', () => { it('is nothing for a phone held level, or nearly', () => { @@ -29,3 +29,16 @@ describe('steering by tilt', () => { expect(tiltSteer(0, 16, 180)).toBeCloseTo(-0.5, 5) }) }) + +describe('steering by a controller', () => { + it('is nothing for a stick at rest, which is never quite at its middle', () => { + for (const lean of [0, 0.05, -0.12, 0.15]) expect(padSteer(lean)).toBe(0) + }) + + it('eases in from there to full steering with the stick right over', () => { + expect(padSteer(0.575)).toBeCloseTo(0.5, 5) + expect(padSteer(-0.575)).toBeCloseTo(-0.5, 5) + expect(padSteer(1)).toBe(1) + expect(padSteer(-1)).toBe(-1) + }) +}) diff --git a/test/skater.test.ts b/test/skater.test.ts index 36a1303..b3aa9be 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -299,6 +299,17 @@ describe('skater', () => { expect(k.speed).toBeLessThan(pushed - 0.3) }) + it('is slowed by snow lying on the ice, and picks up again off it', () => { + const snowy: Lake = { ...lake, snow: () => 1 } + const k = createSkater(lake, 3300) + run(k, 0, 40, STRAIGHT, snowy) + // About half speed in the middle of a drift, and still moving. + expect(k.speed).toBeLessThan(SPEED * 0.65) + expect(k.speed).toBeGreaterThan(SPEED * 0.35) + run(k, 0, 40) + expect(k.speed).toBeGreaterThan(SPEED * 0.85) + }) + it('skates the same far down the lake as at its start', () => { const k = createSkater(lake, 1e6) run(k, 0, 10)