nordic-skating / commit
feat: an endless lake, skated on autopilot, with steering
index.html | 1 + package-lock.json | 7 +++ package.json | 1 + src/input.ts | 44 ++++++++++++++++ src/main.ts | 74 +++++++++++++++++++++------ src/render/ice.ts | 117 +++++++++++++++++++++++++++++++++++++++++++ src/render/shores.ts | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/skater.ts | 67 +++++++++++++++++++++++++ src/style.css | 20 ++++++++ src/world/lake.ts | 54 ++++++++++++++++++++ test/lake.test.ts | 42 ++++++++++++++++ test/skater.test.ts | 66 ++++++++++++++++++++++++ 12 files changed, 615 insertions(+), 16 deletions(-) diff --git a/index.html b/index.html index 378fe13..6804e0e 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ </head> <body> <canvas id="scene"></canvas> +<p id="hint">Drag left or right to steer — or just watch</p> <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/package-lock.json b/package-lock.json index 5ad412c..436de3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "nordic-skating", "version": "0.1.0", "dependencies": { + "simplex-noise": "^4.0.3", "three": "^0.180.0" }, "devDependencies": { @@ -1274,6 +1275,12 @@ "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, + "node_modules/simplex-noise": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz", + "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", diff --git a/package.json b/package.json index 2079033..c0644e4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test:watch": "vitest" }, "dependencies": { + "simplex-noise": "^4.0.3", "three": "^0.180.0" }, "devDependencies": { diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 0000000..fca27bb --- /dev/null +++ b/src/input.ts @@ -0,0 +1,44 @@ +/** How far a finger has to be dragged sideways for full steering, CSS pixels. */ +const FULL_DRAG = 70 + +/** + * Steering, from whatever is to hand: a finger or mouse dragged sideways from where it + * went down, or the arrow keys or A and D. + */ +export class Input { + private pointer: number | null = null + private startX = 0 + private drag = 0 + private readonly keys = new Set<string>() + + constructor(target: HTMLElement) { + target.addEventListener('pointerdown', (e) => { + if (this.pointer !== null) return + this.pointer = e.pointerId + this.startX = e.clientX + this.drag = 0 + target.setPointerCapture(e.pointerId) + }) + target.addEventListener('pointermove', (e) => { + if (e.pointerId === this.pointer) this.drag = (e.clientX - this.startX) / FULL_DRAG + }) + const release = (e: PointerEvent): void => { + if (e.pointerId !== this.pointer) return + this.pointer = null + this.drag = 0 + } + target.addEventListener('pointerup', release) + target.addEventListener('pointercancel', release) + window.addEventListener('keydown', (e) => this.keys.add(e.code)) + window.addEventListener('keyup', (e) => this.keys.delete(e.code)) + // A key held while the window loses focus never sends its keyup. + window.addEventListener('blur', () => this.keys.clear()) + } + + /** -1 (hard left) to 1 (hard right); 0 when nothing is being pressed. */ + 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)) + } +} diff --git a/src/main.ts b/src/main.ts index cbff3e9..8f21a8e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,46 +1,88 @@ import './style.css' import * as THREE from 'three' +import { Input } from './input.js' +import { Ice } from './render/ice.js' +import { Shores } from './render/shores.js' +import { createSkater, skaterX, stepSkater } from './skater.js' +import { createLake } from './world/lake.js' const SKY = 0x9db4c8 -// Black ice: clear enough that the dark water under it is what you see. -const ICE = 0x0b1a24 // Eye height of a skater leaning into a stride, in metres. const EYE = 1.5 +const SEED = 1 const canvas = document.querySelector<HTMLCanvasElement>('#scene')! +const hint = document.querySelector<HTMLElement>('#hint')! 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 far edge of the ice fades into the sky, so the lake has no visible end. +// The lake and its shores fade into the sky, so neither has a visible end. scene.fog = new THREE.Fog(SKY, 30, 600) +// 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.position.set(0, EYE, 0) -camera.lookAt(0, EYE * 0.6, -30) - -const ice = new THREE.Mesh( - new THREE.PlaneGeometry(3000, 3000), - new THREE.MeshStandardMaterial({ color: ICE, roughness: 0.12 }), -) -ice.rotation.x = -Math.PI / 2 -scene.add(ice) - -scene.add(new THREE.HemisphereLight(SKY, ICE, 1.5)) -// A low winter sun ahead and to the left, which is what puts a sheen on the ice. +camera.rotation.order = 'YXZ' + +const lake = createLake(SEED) +const ice = new Ice(renderer) +const shores = new Shores(lake, SEED) +scene.add(ice.mesh, shores.group) + +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) +/** How far below level the skater looks, radians. */ +let pitch = 0 + function resize(): void { const { clientWidth: w, clientHeight: h } = canvas renderer.setSize(w, h, false) camera.aspect = w / h + // The field of view is the vertical one. A phone held upright needs a wide one, or the + // view across is so narrow that both shores fall outside it. + camera.fov = w < h ? 90 : 65 + // And held upright there is far more height than the lake needs: look further down, at + // the ice coming, rather than fill the top half of the screen with sky. + pitch = w < h ? 0.3 : 0.12 camera.updateProjectionMatrix() } window.addEventListener('resize', resize) resize() -renderer.setAnimationLoop(() => renderer.render(scene, camera)) +// `?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(Number(new URLSearchParams(location.search).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. +setTimeout(() => hint.classList.add('gone'), 8000) + +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) + const steer = input.steer() + if (steer !== 0) hint.classList.add('gone') + stepSkater(skater, lake, steer, dt) + + // 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, lake) + 0.2 * sway + camera.position.y = EYE - 0.04 * Math.abs(sway) + // 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. + const along = Math.atan(lake.slope(skater.s)) + const look = along + 0.5 * (skater.heading - along) + camera.rotation.set(-pitch, -look, -0.015 * sway - 0.012 * skater.lateral) + + ice.update(x, skater.s) + shores.update(x, skater.s) + renderer.render(scene, camera) +}) diff --git a/src/render/ice.ts b/src/render/ice.ts new file mode 100644 index 0000000..7df525a --- /dev/null +++ b/src/render/ice.ts @@ -0,0 +1,117 @@ +import * as THREE from 'three' +import { mulberry32 } from '../world/lake.js' + +// Black ice: clear enough that the dark water under it is what you see. +const ICE = '#0b1a24' +/** Side of the square of ice the texture covers before it repeats, metres. */ +const TILE = 32 +const PIXELS = 2048 +/** The marks are sized in metres; this is how many pixels a metre is. */ +const PER_METRE = PIXELS / TILE +/** Side of the sheet of ice that is drawn, metres: well past where the fog ends. */ +const SIZE = 3200 + +/** Draw `paint` nine times, shifted by a tile each way, so that it wraps at every edge. */ +function wrapped(g: CanvasRenderingContext2D, paint: () => void): void { + for (const dx of [-PIXELS, 0, PIXELS]) { + for (const dy of [-PIXELS, 0, PIXELS]) { + g.save() + g.translate(dx, dy) + paint() + g.restore() + } + } +} + +/** + * The marks that show the ice is moving under you: without them a sheet of black ice is + * one flat colour and the skater appears to stand still. Frost in soft patches, the white + * hairline cracks that black ice is laced with, and bubbles frozen into it. + */ +function paintIce(): HTMLCanvasElement { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = PIXELS + const g = canvas.getContext('2d')! + const random = mulberry32(2018) + g.fillStyle = ICE + g.fillRect(0, 0, PIXELS, PIXELS) + + for (let i = 0; i < 45; i++) { + const x = random() * PIXELS + const y = random() * PIXELS + const r = (1.2 + random() * 5.5) * PER_METRE + const alpha = 0.04 + random() * 0.1 + wrapped(g, () => { + const frost = g.createRadialGradient(x, y, 0, x, y, r) + frost.addColorStop(0, `rgba(150, 180, 200, ${alpha})`) + frost.addColorStop(1, 'rgba(150, 180, 200, 0)') + g.fillStyle = frost + g.fillRect(x - r, y - r, 2 * r, 2 * r) + }) + } + + g.lineCap = 'round' + g.lineJoin = 'round' + for (let i = 0; i < 30; i++) { + // A crack runs roughly straight, kinking a little at each grain of the ice. + const points: [number, number][] = [[random() * PIXELS, random() * PIXELS]] + let direction = random() * Math.PI * 2 + const kinks = 5 + Math.floor(random() * 10) + for (let k = 0; k < kinks; k++) { + direction += (random() - 0.5) * 0.9 + const step = (1 + random() * 2.2) * PER_METRE + const [x, y] = points[points.length - 1]! + points.push([x + Math.cos(direction) * step, y + Math.sin(direction) * step]) + } + g.strokeStyle = `rgba(205, 228, 240, ${0.3 + random() * 0.5})` + g.lineWidth = 1.2 + random() * 1.6 + wrapped(g, () => { + g.beginPath() + points.forEach(([x, y], n) => (n === 0 ? g.moveTo(x, y) : g.lineTo(x, y))) + g.stroke() + }) + } + + g.fillStyle = 'rgba(200, 225, 240, 0.22)' + for (let i = 0; i < 900; i++) { + g.beginPath() + g.arc(random() * PIXELS, random() * PIXELS, 0.8 + random() * 2, 0, Math.PI * 2) + g.fill() + } + return canvas +} + +/** + * The ice is one sheet that stays under the skater while its texture slides the other + * way. Moving the picture rather than the sheet is what lets the lake go on for ever: + * nothing drawn is ever far from the origin, so nothing loses precision however far the + * skater has gone. + */ +export class Ice { + readonly mesh: THREE.Mesh + private readonly texture: THREE.CanvasTexture + + constructor(renderer: THREE.WebGLRenderer) { + this.texture = new THREE.CanvasTexture(paintIce()) + this.texture.colorSpace = THREE.SRGBColorSpace + this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping + this.texture.repeat.set(SIZE / TILE, SIZE / TILE) + // The ice is seen at a grazing angle all the way to the horizon; without this the + // cracks smear into grey a few metres ahead. + this.texture.anisotropy = renderer.capabilities.getMaxAnisotropy() + 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 }), + ) + this.mesh.rotation.x = -Math.PI / 2 + } + + /** 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) + } +} diff --git a/src/render/shores.ts b/src/render/shores.ts new file mode 100644 index 0000000..3817a98 --- /dev/null +++ b/src/render/shores.ts @@ -0,0 +1,138 @@ +import * as THREE from 'three' +import { type Lake, mulberry32 } from '../world/lake.js' + +/** The shores are made a stretch at a time, this many metres of lake each. */ +const CHUNK = 100 +/** Metres between rows of the land's mesh along the lake. */ +const STEP = 5 +const ROWS = CHUNK / STEP +/** How far inland each column of the mesh is, metres: fine at the shore, coarse far off. */ +const INLAND = [0, 2, 6, 14, 30, 60, 120, 220, 400] +/** Stretches kept behind the skater and made ahead, metres. The fog ends at 600. */ +const BEHIND = 100 +const AHEAD = 700 +const TREES_PER_SIDE = 60 + +const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 }) +const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true }) +// A spruce is a cone standing on its base. +const cone = new THREE.ConeGeometry(1.7, 8, 6).translate(0, 4, 0) + +interface Chunk { + /** Where the chunk's own origin is: across the lake, and along it. */ + x: number + s: number + group: THREE.Group +} + +/** + * One side's land for one stretch, as a mesh whose vertices are measured from the + * stretch's own origin (`x0`, `s0`) so that they stay small numbers. + */ +function land(lake: Lake, side: -1 | 1, x0: number, s0: number): THREE.BufferGeometry { + const cols = INLAND.length + // One row more at each end than is drawn. The normals are worked out with them and + // they are then left out, so the shading runs unbroken from one stretch into the next. + const positions = new Float32Array((ROWS + 3) * cols * 3) + let p = 0 + for (let row = -1; row <= ROWS + 1; row++) { + const s = s0 + row * STEP + const shore = lake.centre(s) + side * lake.halfWidth(s) + for (const d of INLAND) { + positions[p++] = shore + side * d - x0 + positions[p++] = lake.bankHeight(side, s, d) + positions[p++] = -(s - s0) + } + } + const quads = (from: number, to: number): number[] => { + const index: number[] = [] + for (let row = from; row < to; row++) { + for (let col = 0; col < cols - 1; col++) { + const a = row * cols + col + const b = a + 1 + const c = a + cols + const d = c + 1 + // Wound so that both sides' land faces up. + if (side === 1) index.push(a, b, c, b, d, c) + else index.push(a, c, b, b, c, d) + } + } + return index + } + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setIndex(quads(0, ROWS + 2)) + geometry.computeVertexNormals() + geometry.setIndex(quads(1, ROWS + 1)) + return geometry +} + +function trees(lake: Lake, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh { + const random = mulberry32(seed ^ Math.imul(index, 0x9e3779b1)) + const mesh = new THREE.InstancedMesh(cone, spruce, TREES_PER_SIDE * 2) + const at = new THREE.Object3D() + let n = 0 + for (const side of [-1, 1] as const) { + for (let i = 0; i < TREES_PER_SIDE; i++) { + const s = s0 + random() * CHUNK + // Thick along the shore and thinning out up the hill. + const d = 5 + random() ** 2 * 170 + const size = 0.7 + random() * 0.9 + at.position.set( + lake.centre(s) + side * (lake.halfWidth(s) + d) - x0, + lake.bankHeight(side, s, d) - 0.3, + -(s - s0), + ) + at.rotation.y = random() * Math.PI + at.scale.set(size, size * (0.8 + random() * 0.5), size) + at.updateMatrix() + mesh.setMatrixAt(n++, at.matrix) + } + } + return mesh +} + +/** + * The land on both sides of the lake, made ahead of the skater and thrown away behind. + * Each stretch is placed relative to the skater every frame, so the skater is always at + * the origin of what is drawn and the lake can run on without losing precision. + */ +export class Shores { + readonly group = new THREE.Group() + private readonly chunks = new Map<number, Chunk>() + + constructor(private readonly lake: Lake, private readonly seed: number) {} + + /** Make, drop and place the stretches for a skater at `x` across and `s` along. */ + update(x: number, s: number): void { + const first = Math.floor((s - BEHIND) / CHUNK) + 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.chunks.delete(index) + } + for (let index = first; index <= last; index++) { + if (!this.chunks.has(index)) this.chunks.set(index, this.make(index)) + } + for (const chunk of this.chunks.values()) { + chunk.group.position.set(chunk.x - x, 0, -(chunk.s - s)) + } + } + + private make(index: number): Chunk { + const s = index * CHUNK + const x = this.lake.centre(s) + const group = new THREE.Group() + group.add(new THREE.Mesh(land(this.lake, -1, x, s), snow)) + group.add(new THREE.Mesh(land(this.lake, 1, x, s), snow)) + group.add(trees(this.lake, this.seed, index, x, s)) + this.group.add(group) + return { x, s, group } + } +} diff --git a/src/skater.ts b/src/skater.ts new file mode 100644 index 0000000..91e1b84 --- /dev/null +++ b/src/skater.ts @@ -0,0 +1,67 @@ +import type { Lake } from './world/lake.js' + +/** Cruising speed, 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 +/** The autopilot never lets the skater closer to a shore than this, metres. */ +export const SHORE_MARGIN = 2 +/** Seconds without steering before the autopilot starts easing back to the middle. */ +export const IDLE_BEFORE_RETURN = 1.5 +/** Time constant of that easing, seconds. */ +export const RETURN_TIME = 3 +/** Strides per second: one push of one leg. */ +export const STRIDE_HZ = 0.75 + +export interface Skater { + /** Distance skated along the lake, metres. */ + s: number + /** Sideways distance from the middle of the 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. */ + heading: number + /** Seconds since the skater was last steered. */ + idle: number + /** Strides taken, with the fraction being how far through the current one. */ + stride: number +} + +export function createSkater(s = 0): Skater { + return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0 } +} + +/** Where the skater is across the lake, in the same metres as `Lake.centre`. */ +export function skaterX(k: Skater, lake: Lake): number { + return lake.centre(k.s) + k.offset +} + +/** + * 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 the 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. + */ +export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): void { + const before = k.offset + if (Math.abs(steer) > 0.05) { + k.idle = 0 + k.offset += Math.max(-1, Math.min(1, steer)) * LATERAL_SPEED * dt + } else { + k.idle += dt + if (k.idle > IDLE_BEFORE_RETURN) k.offset *= Math.exp(-dt / RETURN_TIME) + } + const limit = lake.halfWidth(k.s) - SHORE_MARGIN + k.offset = Math.max(-limit, Math.min(limit, 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 = lake.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 += STRIDE_HZ * dt +} diff --git a/src/style.css b/src/style.css index cf4f36e..8eab72a 100644 --- a/src/style.css +++ b/src/style.css @@ -14,3 +14,23 @@ body { width: 100vw; height: 100dvh; } + +#hint { + position: fixed; + left: 0; + right: 0; + bottom: calc(2rem + env(safe-area-inset-bottom)); + margin: 0; + padding: 0 1rem; + text-align: center; + font: 500 1rem/1.4 system-ui, sans-serif; + color: #fff; + text-shadow: 0 1px 6px rgb(0 0 0 / 70%); + /* The canvas under it takes the drag. */ + pointer-events: none; + transition: opacity 1.5s; +} + +#hint.gone { + opacity: 0; +} diff --git a/src/world/lake.ts b/src/world/lake.ts new file mode 100644 index 0000000..1dcf51f --- /dev/null +++ b/src/world/lake.ts @@ -0,0 +1,54 @@ +import { createNoise2D } from 'simplex-noise' + +/** A small seeded generator: the same seed is the same lake, on every device. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * The lake is a channel that runs on for ever. Everything about it is a function of `s`, + * the distance skated along it in metres, so any stretch can be made on demand and + * nothing has to be remembered: where its middle is, how wide it is, how the land rises + * from its shores. + */ +export interface Lake { + /** Sideways position of the middle of the channel, metres. */ + centre(s: number): number + /** Sideways metres per metre forward that the middle moves: the channel's bend. */ + slope(s: number): number + /** From the middle to either shore, metres. */ + halfWidth(s: number): number + /** Height of the land `d` metres inland of the left (-1) or right (+1) shore. */ + bankHeight(side: -1 | 1, s: number, d: number): number +} + +/** The narrowest and widest the channel gets, shore to middle, in metres. */ +export const MIN_HALF_WIDTH = 12 +export const MAX_HALF_WIDTH = 44 + +export function createLake(seed: number): Lake { + const noise = createNoise2D(mulberry32(seed)) + // A long wander with a shorter wiggle on it. The amplitudes over the wavelengths keep + // the bend gentle enough to skate without ever braking. + const centre = (s: number): number => 45 * noise(s / 600, 0) + 6 * noise(s / 180, 10) + const mid = (MIN_HALF_WIDTH + MAX_HALF_WIDTH) / 2 + const swing = (MAX_HALF_WIDTH - MIN_HALF_WIDTH) / 2 + return { + centre, + slope: (s) => centre(s + 0.5) - centre(s - 0.5), + halfWidth: (s) => mid + swing * (0.75 * noise(s / 300, 50) + 0.25 * noise(s / 90, 80)), + bankHeight: (side, s, d) => { + // A bank that climbs quickly out of the ice and then a slow rise into hills, taller + // in some places than others. Zero at the shore, so the land meets the ice exactly. + const rise = 8 * (1 - Math.exp(-d / 25)) + 0.06 * d + return rise * (0.55 + 0.45 * noise(s / 140, side * 37 + d / 90)) + }, + } +} diff --git a/test/lake.test.ts b/test/lake.test.ts new file mode 100644 index 0000000..36ca5c6 --- /dev/null +++ b/test/lake.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { MAX_HALF_WIDTH, MIN_HALF_WIDTH, createLake } from '../src/world/lake.js' + +describe('lake', () => { + it('is the same lake for the same seed, and another for another', () => { + const a = createLake(7) + const b = createLake(7) + const c = createLake(8) + for (const s of [0, 123.4, 5_000, 1e7]) { + expect(a.centre(s)).toBe(b.centre(s)) + expect(a.halfWidth(s)).toBe(b.halfWidth(s)) + } + expect(a.centre(123.4)).not.toBe(c.centre(123.4)) + }) + + it('stays within its narrowest and widest, however far it runs', () => { + const lake = createLake(1) + for (let s = 0; s < 200_000; s += 7) { + const w = lake.halfWidth(s) + expect(w).toBeGreaterThanOrEqual(MIN_HALF_WIDTH) + expect(w).toBeLessThanOrEqual(MAX_HALF_WIDTH) + } + }) + + it('never bends too sharply to skate at full speed', () => { + const lake = createLake(1) + let steepest = 0 + for (let s = 0; s < 200_000; s += 3) steepest = Math.max(steepest, Math.abs(lake.slope(s))) + // 0.7 sideways per metre forward is a 35 degree turn off straight ahead. + expect(steepest).toBeLessThan(0.7) + }) + + it('has land that meets the ice at the shore and rises inland', () => { + const lake = createLake(1) + for (const side of [-1, 1] as const) { + for (let s = 0; s < 5_000; s += 50) { + expect(lake.bankHeight(side, s, 0)).toBe(0) + expect(lake.bankHeight(side, s, 60)).toBeGreaterThan(0) + } + } + }) +}) diff --git a/test/skater.test.ts b/test/skater.test.ts new file mode 100644 index 0000000..49d21a7 --- /dev/null +++ b/test/skater.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + IDLE_BEFORE_RETURN, LATERAL_SPEED, SHORE_MARGIN, SPEED, + createSkater, skaterX, stepSkater, +} from '../src/skater.js' +import { createLake } from '../src/world/lake.js' + +const DT = 1 / 60 +const lake = createLake(1) + +function run(k: ReturnType<typeof createSkater>, steer: number, seconds: number): void { + const steps = Math.round(seconds / DT) + for (let i = 0; i < steps; i++) stepSkater(k, lake, steer, DT) +} + +describe('skater', () => { + it('left alone, follows the middle of the channel for as long as you like', () => { + const k = createSkater() + for (let t = 0; t < 3600; t += DT) { + const before = k.s + stepSkater(k, lake, 0, DT) + expect(k.s).toBeGreaterThan(before) + expect(k.offset).toBe(0) + } + expect(skaterX(k, lake)).toBe(lake.centre(k.s)) + // 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('steers across the channel at the steering speed', () => { + const k = createSkater() + 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) + }) + + it('cannot be steered into a shore', () => { + for (const steer of [-1, 1]) { + const k = createSkater() + for (let t = 0; t < 120; t += DT) { + stepSkater(k, lake, steer, DT) + expect(Math.abs(k.offset)).toBeLessThanOrEqual(lake.halfWidth(k.s) - SHORE_MARGIN + 0.2) + } + expect(Math.sign(k.offset)).toBe(steer) + } + }) + + it('holds its line for a moment after steering, then eases back to the middle', () => { + const k = createSkater() + 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) + }) + + it('skates the same far down the lake as at its start', () => { + const k = createSkater(1e7) + run(k, 0, 10) + expect(k.s - 1e7).toBeGreaterThan(SPEED * 10 * 0.9) + }) +})