nordic-skating / commit
feat(lake): forks round islands, narrow passes, and stretches of open sea
README.md | 60 ++++++++++----- docs/screenshots/fork.png | Bin 0 -> 321595 bytes docs/screenshots/pass.png | Bin 0 -> 241573 bytes docs/screenshots/sea.png | Bin 0 -> 209619 bytes src/main.ts | 12 +-- src/render/ice.ts | 30 +++++--- src/render/shores.ts | 150 +++++++++++++++++++++++++++----------- src/skater.ts | 90 +++++++++++++++++++---- src/world/lake.ts | 182 +++++++++++++++++++++++++++++++++++++++------- test/lake.test.ts | 148 ++++++++++++++++++++++++++++--------- test/skater.test.ts | 95 ++++++++++++++++-------- tools/screenshots.mjs | 12 ++- 12 files changed, 589 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index 9874422..926e862 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,17 @@ in which Mårten Ajne skates a small lake outside Stockholm. ## Status -**Step 4 of 6: thin ice.** The lake runs on for ever between snowy, wooded shores, the -skater skates it, and the ice sings under every stride at a pitch set by how thick it is -there. Thin ice is black, sings high, cracks under you, and at its thinnest gives way. +**Step 5 of 6: forks, passes and open sea.** The lake runs on for ever between snowy, +wooded shores, forking every few kilometres, pinching to narrow passes and opening out +until no shore can be seen. The skater skates it, and the ice sings under every stride at +a pitch set by how thick it is there. Thin ice is black, sings high, cracks under you, +and at its thinnest gives way. 1. ✅ Scaffold: build, tests, deploy to GitLab Pages. 2. ✅ An endless lake, skated on autopilot, with steering. 3. ✅ Measure the reference video, then synthesize the ice sound from the ice's thickness. 4. ✅ Ice zones that fall out of the physics, and falling through. -5. Forks in the lake. +5. ✅ Forks in the lake, narrow passes, and stretches of open sea. 6. Game controller, then multiplayer. ## Controls @@ -31,16 +33,21 @@ there. Thin ice is black, sings high, cracks under you, and at its thinnest give Tap or press a key once to turn the sound on: a browser lets a page make sound only after it has been touched. On an iPhone the ring/silent switch also silences it. -Do nothing and the skater follows the middle of the lake, round every bend, for as -long as you leave it. +Do nothing and the skater follows the middle of the lake, round every bend and down +one side of every fork, for as long as you leave it. | To | Touch or mouse | Keyboard | |---|---|---| | Steer left or right | Drag sideways from wherever you touch | ← → or A D | +| Choose a way at a fork | Steer towards it before the island begins | the same | Steering moves the skater across the lake, never into a shore. Let go and the skater holds that line for a second and a half, then eases back to the middle. +At a fork the lake parts round an island. Until the island begins, a little over 200 m +after the fork, you are in whichever of the two channels you are nearer the middle of, +so steering across takes the other one. After that there is land between. + ## The ice How thick the ice is decides everything about it: how it looks, the note it sings, and @@ -55,24 +62,43 @@ formula for what a sheet of ice will carry (`src/ice/strength.ts`), worked out f | Sings | 3.4 to 4.8 cm | dark grey | 630 to 900 Hz | Sound ice near its limit. The ice of the film is here, at 4.1 cm | | Safe | over 4.8 cm | milky grey | below 630 Hz | Nothing. The thicker, the lower and plainer the note | -Of the ice a skater can reach, about 3% gives way, 21% cracks, 41% sings and 36% is -safe. The middle of the lake is never thinner than 4.1 cm, so the autopilot never falls -through: where a thin patch crosses the middle, a strip of sound ice bridges it. +Of the ice a skater can reach, about 2% gives way, 18% cracks, 45% sings and 35% is +safe. The middle of every channel is never thinner than 4.1 cm, so the autopilot never +falls through: where a thin patch crosses the middle, a strip of sound ice bridges it. -<img src="docs/screenshots/thin-ice.png" alt="Milky grey ice down the lake, with a black patch of thin ice to the right of the middle" width="640" /> +<img src="docs/screenshots/thin-ice.png" alt="Milky grey ice down the lake, with a black patch of thin ice to the left of the middle" width="640" /> ## How the lake works -- **The lake is a function, not a map** (`src/world/lake.ts`). Where its middle is, how - wide it is and how the land rises from its shores are all computed from the distance - skated and a seed. Any stretch can be made on demand, the same on every device, and - nothing is stored. -- **The skater is an offset from the middle of the lake** (`src/skater.ts`), not a +- **The lake is a function, not a map** (`src/world/lake.ts`). Where a channel's middle + is, how wide it is and how the land rises from its shores are all computed from the + distance skated and a seed. Any stretch can be made on demand, the same on every + device, and nothing is stored. +- **It forks.** A channel runs from the fork that made it to the fork that ends it: the + first fork is 700 m from the start, and each one after comes 1.8 to 4.2 km after the + last. A fork's two channels start as the one channel and draw 180 m apart over 600 m, + which is always more than both are wide, so there is always an island. Each has a + width and forks of its own from there on. Both are drawn for 1.2 km, and then only the + one taken: the other is out of sight behind the island by then. +- **It changes character over kilometres.** About 70% of the way it is ordinary water, + 12 to 44 m from the middle to a shore. About 10% of the way it pinches to a pass 5 m + to a side. And now and then it opens until both shores are beyond the haze, 700 m off + or more: a stretch of sea. Near a fork it is always ordinary, so that a fork reads as + one. + +<img src="docs/screenshots/fork.png" alt="The lake parting round a wooded island ahead" width="640" /> + +<img src="docs/screenshots/sea.png" alt="Ice to a hazy horizon with no shore in sight" width="640" /> + +- **The skater is an offset from the middle of a channel** (`src/skater.ts`), not a position. An offset nobody touches follows every bend, which is all the autopilot is. + Where the lake narrows faster than a skater could steer clear, the room they have + starts closing 200 m early, so a shore never sweeps them sideways. Out at sea they may + go no further than 150 m from the middle. - **Thickness is a function too**, and is painted on the ice from a small map of the 768 m around the skater that is redrawn every 32 m (`src/render/ice.ts`). -- **The skater never leaves the origin of what is drawn.** The shores are built 100 m at - a time ahead of the skater, thrown away behind, and placed relative to the skater +- **The skater never leaves the origin of what is drawn.** The shores and islands are + built 100 m at a time ahead of the skater, thrown away behind, and placed relative to the skater every frame (`src/render/shores.ts`); the ice is one sheet whose texture slides the other way (`src/render/ice.ts`). So nothing drawn is ever a large number, and the picture is as sharp 10,000 km down the lake as at the start. diff --git a/docs/screenshots/fork.png b/docs/screenshots/fork.png new file mode 100644 index 0000000..4441a43 Binary files /dev/null and b/docs/screenshots/fork.png differ diff --git a/docs/screenshots/pass.png b/docs/screenshots/pass.png new file mode 100644 index 0000000..5388e6b Binary files /dev/null and b/docs/screenshots/pass.png differ diff --git a/docs/screenshots/sea.png b/docs/screenshots/sea.png new file mode 100644 index 0000000..1fa2677 Binary files /dev/null and b/docs/screenshots/sea.png differ diff --git a/src/main.ts b/src/main.ts index 0ebe9f0..0c3a96c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,7 +61,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(Number(new URLSearchParams(location.search).get('s')) || 0) +const skater = createSkater(lake, 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. @@ -93,7 +93,7 @@ renderer.setAnimationLoop(() => { const strides = Math.floor(skater.stride) const wasUnder = skater.under > 0 stepSkater(skater, lake, steer, dt) - const h = lake.thickness(skaterX(skater, lake), skater.s) + const h = lake.thickness(skaterX(skater), skater.s, skater.channel) const fell = skater.under > 0 && !wasUnder if (fell) sound?.fall(h) // In the water for all but the last second, which is the climb back out. @@ -120,17 +120,17 @@ 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, lake) + 0.2 * sway + const x = skaterX(skater) + 0.2 * sway // 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. - const along = Math.atan(lake.slope(skater.s)) + 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) - ice.update(x, skater.s) - shores.update(x, skater.s) + ice.update(x, skater.s, skater.channel) + shores.update(x, skater.s, skater.channel) renderer.render(scene, camera) }) diff --git a/src/render/ice.ts b/src/render/ice.ts index 6df8e4a..03ef2f1 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 Lake, MAX_THICKNESS, mulberry32 } from '../world/lake.js' +import { type Channel, type Lake, MAX_THICKNESS, mulberry32 } from '../world/lake.js' // Black ice: clear enough that the dark water under it is what you see. const ICE = '#0b1a24' @@ -15,11 +15,13 @@ const SIZE = 3200 /** * How thick the ice is, is painted on it from a small map of the lake around the skater: * this many metres across and along, of which `MAP_BEHIND` are behind the skater, at one - * value every `MAP_STEP` metres. It is redrawn each time the skater has gone `MAP_REDRAW`. + * value every `MAP_STEP_ACROSS` metres across and `MAP_STEP` along. It is redrawn each + * time the skater has gone `MAP_REDRAW` metres along, or twice that across. */ -const MAP_ACROSS = 512 +const MAP_ACROSS = 1536 const MAP_ALONG = 768 const MAP_BEHIND = 96 +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. */ @@ -121,8 +123,8 @@ export class Ice { // clear; thicker ice is milkier. So the map of thickness is laid on as light added to // the dark sheet, none where the ice will not hold and more the thicker it gets. this.map = new THREE.DataTexture( - new Uint8Array((MAP_ACROSS / MAP_STEP) * (MAP_ALONG / MAP_STEP) * 4), - MAP_ACROSS / MAP_STEP, + new Uint8Array((MAP_ACROSS / MAP_STEP_ACROSS) * (MAP_ALONG / MAP_STEP) * 4), + MAP_ACROSS / MAP_STEP_ACROSS, MAP_ALONG / MAP_STEP, ) this.map.colorSpace = THREE.SRGBColorSpace @@ -136,14 +138,17 @@ export class Ice { 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 { + /** + * 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 { // 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)) this.drawMap(s) + if (!(s - MAP_BEHIND - this.mapS < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s, channel) // 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, @@ -151,17 +156,18 @@ export class Ice { ) } - private drawMap(s: number): void { + private drawMap(x: number, s: number, channel: Channel): 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(this.lake.centre(s + MAP_ALONG / 3) / MAP_STEP) * MAP_STEP + this.mapX = Math.round(x / MAP_STEP_ACROSS) * MAP_STEP_ACROSS const data = this.map.image.data as Uint8Array let p = 0 for (let row = 0; row < MAP_ALONG / MAP_STEP; row++) { - for (let col = 0; col < MAP_ACROSS / MAP_STEP; col++) { + for (let col = 0; col < MAP_ACROSS / MAP_STEP_ACROSS; col++) { const h = this.lake.thickness( - this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP, + 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) diff --git a/src/render/shores.ts b/src/render/shores.ts index 3817a98..234838d 100644 --- a/src/render/shores.ts +++ b/src/render/shores.ts @@ -1,17 +1,20 @@ import * as THREE from 'three' -import { type Lake, mulberry32 } from '../world/lake.js' +import { type Channel, type Lake, channelsAt, 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. */ +/** How far inland each column of a bank's mesh is, metres: fine at the shore, coarse far off. */ const INLAND = [0, 2, 6, 14, 30, 60, 120, 220, 400] +/** How far across an island each column of its mesh is, as a fraction of its width. */ +const ACROSS = [0, 0.04, 0.12, 0.25, 0.5, 0.75, 0.88, 0.96, 1] /** 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 TREES_PER_BANK = 60 +const TREES_PER_ISLAND = 50 const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 }) const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true }) @@ -25,36 +28,61 @@ interface Chunk { group: THREE.Group } +/** The water's edges at `s`: the outer shores, and the island between two channels. */ +interface Shoreline { + left: number + right: number + /** The island's two shores; the same number where there is no island. */ + islandLeft: number + islandRight: number +} + +function shoreline(channel: Channel, s: number): Shoreline { + const channels = channelsAt(channel, s) + const first = channels[0]! + const last = channels[channels.length - 1]! + const left = first.centre(s) - first.halfWidth(s) + const right = last.centre(s) + last.halfWidth(s) + const islandLeft = first.centre(s) + first.halfWidth(s) + const islandRight = last.centre(s) - last.halfWidth(s) + // One channel, or two that have not yet drawn apart: no island, and its two shores + // are put at one point so that the mesh made for it closes up to nothing. + if (islandRight <= islandLeft) { + const middle = (islandLeft + islandRight) / 2 + return { left, right, islandLeft: middle, islandRight: middle } + } + return { left, right, islandLeft, islandRight } +} + /** - * 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. + * A strip of land for one stretch, `columns` wide, as a mesh whose vertices are measured + * from the stretch's own origin (`x0`, `s0`) so that they stay small numbers. `place` + * says where column `col` is at `s`, across and up. */ -function land(lake: Lake, side: -1 | 1, x0: number, s0: number): THREE.BufferGeometry { - const cols = INLAND.length +function land(columns: number, x0: number, s0: number, place: (s: number, col: number) => [number, number]): THREE.BufferGeometry { // 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) + const positions = new Float32Array((ROWS + 3) * columns * 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) + for (let col = 0; col < columns; col++) { + const [x, y] = place(s, col) + positions[p++] = x - x0 + positions[p++] = y 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 + for (let col = 0; col < columns - 1; col++) { + const a = row * columns + col const b = a + 1 - const c = a + cols + const c = a + columns 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) + // Columns run left to right; wound so that the land faces up. + index.push(a, b, c, b, d, c) } } return index @@ -67,35 +95,43 @@ function land(lake: Lake, side: -1 | 1, x0: number, s0: number): THREE.BufferGeo return geometry } -function trees(lake: Lake, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh { +function trees(lake: Lake, channel: Channel, 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 mesh = new THREE.InstancedMesh(cone, spruce, TREES_PER_BANK * 2 + TREES_PER_ISLAND) const at = new THREE.Object3D() let n = 0 + const plant = (x: number, s: number, d: number, size: number): void => { + at.position.set(x - x0, lake.bankHeight(1, 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) + } for (const side of [-1, 1] as const) { - for (let i = 0; i < TREES_PER_SIDE; i++) { + for (let i = 0; i < TREES_PER_BANK; i++) { const s = s0 + random() * CHUNK + const shore = shoreline(channel, s) // 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) + plant((side < 0 ? shore.left : shore.right) + side * d, s, d, 0.7 + random() * 0.9) } } + for (let i = 0; i < TREES_PER_ISLAND; i++) { + const s = s0 + random() * CHUNK + const { islandLeft, islandRight } = shoreline(channel, s) + const width = islandRight - islandLeft + const x = islandLeft + 2 + random() * (width - 4) + // No island here, or one too narrow to stand a tree on: a tree of no size. + plant(x, s, Math.min(x - islandLeft, islandRight - x), width > 8 ? 0.7 + random() * 0.9 : 0) + } 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. + * The land of the lake: both banks, and the island wherever it has forked. It is made + * ahead of the skater and thrown away behind, and 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() @@ -103,8 +139,16 @@ export class Shores { 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 { + /** + * Make, drop and place the stretches for a skater at `x` across and `s` along, who is + * in `channel`. + * + * A stretch is made once, with whichever channel the skater was in at the time, and + * that is safe: a stretch near a fork has both of the fork's channels in it whichever + * of them the skater takes, for `FORK_ZONE` metres, and the skater has taken one long + * before stretches further off than that are made. + */ + update(x: number, s: number, channel: Channel): void { const first = Math.floor((s - BEHIND) / CHUNK) const last = Math.floor((s + AHEAD) / CHUNK) for (const [index, chunk] of this.chunks) { @@ -118,21 +162,39 @@ export class Shores { this.chunks.delete(index) } for (let index = first; index <= last; index++) { - if (!this.chunks.has(index)) this.chunks.set(index, this.make(index)) + if (!this.chunks.has(index)) this.chunks.set(index, this.make(index, channel)) } 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) + private make(index: number, channel: Channel): Chunk { + const { lake } = this + const s0 = index * CHUNK + const x0 = channel.centre(s0) 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)) + // The left bank's columns run from far inland to the shore, the right bank's from + // the shore inland, and the island's from its left shore to its right. + const inland = INLAND.length - 1 + group.add(new THREE.Mesh(land(INLAND.length, x0, s0, (s, col) => { + const d = INLAND[inland - col]! + return [shoreline(channel, s).left - d, lake.bankHeight(-1, s, d)] + }), snow)) + group.add(new THREE.Mesh(land(INLAND.length, x0, s0, (s, col) => { + const d = INLAND[col]! + return [shoreline(channel, s).right + d, lake.bankHeight(1, s, d)] + }), snow)) + group.add(new THREE.Mesh(land(ACROSS.length, x0, s0, (s, col) => { + const { islandLeft, islandRight } = shoreline(channel, s) + const width = islandRight - islandLeft + const t = ACROSS[col]! + // An island rises from both its shores, so its height is that of land as far + // inland as its nearer shore. + return [islandLeft + t * width, lake.bankHeight(1, s, Math.min(t, 1 - t) * width)] + }), snow)) + group.add(trees(lake, channel, this.seed, index, x0, s0)) this.group.add(group) - return { x, s, group } + return { x: x0, s: s0, group } } } diff --git a/src/skater.ts b/src/skater.ts index b0bd933..5e18769 100644 --- a/src/skater.ts +++ b/src/skater.ts @@ -1,5 +1,5 @@ import { BREAKING_THICKNESS } from './ice/strength.js' -import { type Lake, mulberry32 } from './world/lake.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. */ export const SPEED = 7 @@ -7,6 +7,25 @@ export const SPEED = 7 export const LATERAL_SPEED = 4 /** The autopilot never lets the skater closer to a shore than this, metres. */ export const SHORE_MARGIN = 2 +/** + * Nor further from the middle of the channel than this, metres, which only matters out + * at sea, where the shores are far out of sight. + */ +export const MAX_REACH = 150 + +/** + * 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. + */ +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. */ export const IDLE_BEFORE_RETURN = 1.5 /** Time constant of that easing, seconds. */ @@ -27,13 +46,15 @@ export function strideSeconds(index: number): number { return MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)() } -/** Seconds between going through the ice and skating on from the middle of the lake. */ +/** Seconds between going through the ice and skating on from the middle of the channel. */ export const UNDER_SECONDS = 4 export interface Skater { /** Distance skated along the lake, metres. */ s: number - /** Sideways distance from the middle of the channel, metres; positive is right. */ + /** 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 @@ -47,25 +68,40 @@ export interface Skater { under: number } -export function createSkater(s = 0): Skater { - return { s, offset: 0, lateral: 0, heading: 0, idle: Infinity, stride: 0, under: 0 } +/** 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 } +} + +/** 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 } -/** 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 +/** + * The other channel of the fork the skater has just come through, for as long as there + * is open ice between the two: until the island begins. + */ +function alongside(k: Skater): Channel | undefined { + const fork = k.channel.birth + if (!fork || k.s < fork.s || k.s >= fork.tip) return undefined + return k.channel === fork.left ? fork.right : fork.left } /** * 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 + * 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. * + * 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 lake, - * where the ice is always sound (world/lake.ts). + * 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 { if (k.under > 0) { @@ -73,7 +109,7 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): vo k.lateral = 0 if (k.under === 0) { k.offset = 0 - k.heading = Math.atan(lake.slope(k.s)) + k.heading = Math.atan(k.channel.slope(k.s)) k.idle = Infinity } return @@ -86,16 +122,38 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): vo 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)) + // 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) + } + 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 = lake.slope(k.s) + k.lateral / SPEED + 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)) - if (lake.thickness(skaterX(k, lake), k.s) < BREAKING_THICKNESS) k.under = UNDER_SECONDS + + // 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) + } + 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 } diff --git a/src/world/lake.ts b/src/world/lake.ts index 3bdf7b9..cc11b8f 100644 --- a/src/world/lake.ts +++ b/src/world/lake.ts @@ -12,28 +12,94 @@ export function mulberry32(seed: number): () => number { } } +/** A number from 0 to 1 that is always the same for the same two whole numbers. */ +function hash(a: number, b: number): number { + return mulberry32(a ^ Math.imul(b + 1, 0x9e3779b1))() +} + +const smooth = (t: number): number => { + const c = Math.max(0, Math.min(1, t)) + return c * c * (3 - 2 * c) +} +const mix = (a: number, b: number, t: number): number => a + (b - a) * t + /** - * 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. + * The lake is water that runs on for ever, and forks. A channel is one way through it: + * from the fork that made it to the fork that ends it. Everything about a channel is a + * function of `s`, the distance skated in metres, so any stretch can be made on demand + * and nothing has to be remembered. + * + * A channel answers for any `s`, not only its own stretch. Before the fork that made it, + * it is the channel it forked from; so the two channels of a fork are one where they part + * and draw away from each other after it. */ -export interface Lake { +export interface Channel { /** 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. */ + /** The fork that made this channel; the lake's first channel has none. */ + readonly birth?: Fork + /** The fork this channel ends in. */ + readonly end: Fork +} + +/** Where one channel becomes two. */ +export interface Fork { + /** Where the channels part, and, further on, where the island between them begins. */ + readonly s: number + readonly tip: number + readonly left: Channel + readonly right: Channel + /** The one a skater who does nothing is taken down. */ + readonly preferred: Channel +} + +export interface Lake { + /** The channel the lake starts in. */ + 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 (as `centre` is) and `s` along. */ - thickness(x: number, s: 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 } -/** The narrowest and widest the channel gets, shore to middle, in metres. */ -export const MIN_HALF_WIDTH = 12 -export const MAX_HALF_WIDTH = 44 +/** + * The narrowest and widest a channel gets, shore to middle, in metres. Most of the lake + * is `ORDINARY` wide. Now and then it pinches to a pass `MIN_HALF_WIDTH` wide, and now + * and then it opens until neither shore can be seen through the haze (which ends at + * 600 m): a stretch of sea. + */ +export const MIN_HALF_WIDTH = 5 +export const ORDINARY_HALF_WIDTH = [12, 44] as const +const OPEN_TIMES = 19 +export const MAX_HALF_WIDTH = ORDINARY_HALF_WIDTH[1] * OPEN_TIMES + +/** + * How far each channel of a fork ends up from where the one channel would have run, and + * the distance over which it gets there, metres. Twice `FORK_APART` is always more than + * two ordinary channels are wide, so there is always an island between. + */ +const FORK_APART = 90 +const FORK_RAMP = 600 +/** + * Around a fork the lake keeps to its ordinary width, from this far before it, metres: + * a fork in a pass or out at sea would not read as one. + */ +const CALM_BEFORE = 250 +const CALM_FADE = 250 +/** + * Metres after a fork for which both its channels are part of the lake that is drawn, + * and so for which they keep to their ordinary width too. + */ +export const FORK_ZONE = 1200 +const CALM_AFTER = FORK_ZONE +/** The first fork, and then how soon and how late after one fork the next comes, metres. */ +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. @@ -41,7 +107,7 @@ export const MAX_HALF_WIDTH = 44 export const MIN_THICKNESS = 0.014 export const MAX_THICKNESS = 0.07 /** - * The ice down the middle of the lake is never thinner than this, metres, which is what + * 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. */ @@ -50,32 +116,98 @@ export const MIDDLE_THICKNESS = 0.041 const MIDDLE_HOLDS = 3 const MIDDLE_FADES = 8 +/** 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[] { + const { birth, end } = channel + if (birth && s >= birth.s && s < birth.s + FORK_ZONE) return [birth.left, birth.right] + if (s >= end.s) return [end.left, end.right] + return [channel] +} + +/** The channel a skater who has done nothing since the start is in at `s`. */ +export function channelAt(lake: Lake, s: number): Channel { + let channel = lake.start + while (channel.end.s <= s) channel = channel.end.preferred + return channel +} + 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 + // 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. + const wander = (s: number): number => 45 * noise(s / 600, 0) + 6 * noise(s / 180, 10) + + /** + * One way through the lake. `lane` is a number of its own, which is what makes its + * width and its forks unlike any other channel's; `shift` is how far to one side of + * the lake's wander it runs. + */ + function channel(lane: number, shift: number, birth?: Fork, parent?: Channel, parentShift = 0): Channel { + // Somewhere of its own in the noise. + const key = (lane % 9973) * 3.1 + const born = birth?.s ?? -Infinity + const ends = birth ? born + mix(NEXT_FORK[0], NEXT_FORK[1], hash(lane, 0)) : FIRST_FORK + /** 0 where the channel leaves the one it forked from, 1 once it has drawn clear. */ + const away = (s: number): number => (birth ? smooth((s - born) / FORK_RAMP) : 1) + /** 1 near either of the channel's forks, where it keeps to its ordinary width. */ + const calm = (s: number): number => + Math.max(...[born, ends].map((f) => Math.min(smooth((s - (f - CALM_BEFORE - CALM_FADE)) / CALM_FADE), + smooth((f + CALM_AFTER + CALM_FADE - s) / CALM_FADE)))) + + const ownWidth = (s: number): number => { + const [lo, hi] = ORDINARY_HALF_WIDTH + const ordinary = (lo + hi) / 2 + ((hi - lo) / 2) * (0.75 * noise(s / 300, key + 50) + 0.25 * noise(s / 90, key + 80)) + // The lake's character changes over kilometres: a pass, ordinary water, open sea. + const character = noise(s / 2500, key + 7) * (1 - calm(s)) + const pass = smooth((-0.22 - character) / 0.25) + const open = smooth((character - 0.03) / 0.27) + return mix(ordinary, MIN_HALF_WIDTH, pass) * (1 + (OPEN_TIMES - 1) * open * open) + } + + const centre = (s: number): number => wander(s) + mix(parentShift, shift, away(s)) + let end: Fork | undefined + const self: Channel = { + centre, + slope: (s) => centre(s + 0.5) - centre(s - 0.5), + halfWidth: (s) => (parent && s < born + FORK_RAMP ? mix(parent.halfWidth(s), ownWidth(s), away(s)) : ownWidth(s)), + birth, + get end() { + return (end ??= fork(self, lane, shift, ends)) + }, + } + return self + } + + function fork(trunk: Channel, lane: number, shift: number, s: number): Fork { + const made = {} as { -readonly [K in keyof Fork]: Fork[K] } + made.s = s + made.left = channel(Math.floor(hash(lane, 1) * 2 ** 32), shift - FORK_APART, made, trunk, shift) + made.right = channel(Math.floor(hash(lane, 2) * 2 ** 32), shift + FORK_APART, made, trunk, shift) + made.preferred = hash(lane, 3) < 0.5 ? made.left : made.right + // The island begins where the two channels' facing shores first come apart. + made.tip = s + while (made.left.centre(made.tip) + made.left.halfWidth(made.tip) + >= made.right.centre(made.tip) - made.right.halfWidth(made.tip)) made.tip += 2 + return made + } + 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)), + start: channel(seed >>> 0, 0), 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)) }, - thickness: (x, s) => { + thickness: (x, s, channel) => { // 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 the lake, a strip of sound ice bridges it. - const off = Math.abs(x - centre(s)) - const bridge = 1 - Math.min(1, Math.max(0, (off - MIDDLE_HOLDS) / (MIDDLE_FADES - MIDDLE_HOLDS))) + // 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 patch + (MIDDLE_THICKNESS - patch) * bridge * bridge * (3 - 2 * bridge) + return mix(patch, MIDDLE_THICKNESS, bridge) }, } } diff --git a/test/lake.test.ts b/test/lake.test.ts index b76dc44..33472d1 100644 --- a/test/lake.test.ts +++ b/test/lake.test.ts @@ -1,40 +1,68 @@ import { describe, expect, it } from 'vitest' import { BREAKING_THICKNESS, CRACKING_THICKNESS, SAFE_THICKNESS } from '../src/ice/strength.js' import { - MAX_HALF_WIDTH, MAX_THICKNESS, MIDDLE_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, createLake, + type Channel, FORK_ZONE, MAX_HALF_WIDTH, MAX_THICKNESS, MIDDLE_THICKNESS, MIN_HALF_WIDTH, MIN_THICKNESS, + ORDINARY_HALF_WIDTH, channelAt, channelsAt, createLake, } from '../src/world/lake.js' +const lake = createLake(1) + +/** Go down the lake as a skater who does nothing would, calling `see` every `step` metres. */ +function follow(to: number, step: number, see: (channel: Channel, s: number) => void): void { + let channel = lake.start + for (let s = 0; s < to; s += step) { + while (channel.end.s <= s) channel = channel.end.preferred + see(channel, s) + } +} + +/** The first `count` forks that skater comes to. */ +function forks(count: number): Channel['end'][] { + const found = [] + for (let channel = lake.start; found.length < count; channel = channel.end.preferred) found.push(channel.end) + return found +} + 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) + const again = createLake(1) + const other = createLake(2) 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(channelAt(again, s).centre(s)).toBe(channelAt(lake, s).centre(s)) + expect(channelAt(again, s).halfWidth(s)).toBe(channelAt(lake, s).halfWidth(s)) } - expect(a.centre(123.4)).not.toBe(c.centre(123.4)) + expect(other.start.centre(123.4)).not.toBe(lake.start.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) + it('is mostly ordinary water, with narrow passes and stretches of open sea', () => { + let pass = 0 + let ordinary = 0 + let sea = 0 + let all = 0 + follow(400_000, 10, (channel, s) => { + const w = channel.halfWidth(s) + expect(w).toBeGreaterThanOrEqual(MIN_HALF_WIDTH - 1e-9) expect(w).toBeLessThanOrEqual(MAX_HALF_WIDTH) - } + // A pass: you could throw a snowball across it. Sea: neither shore within the haze. + if (w < 8) pass++ + else if (w > 600) sea++ + else if (w <= ORDINARY_HALF_WIDTH[1]) ordinary++ + all++ + }) + console.info(`pass ${((100 * pass) / all).toFixed(1)}%, ordinary ${((100 * ordinary) / all).toFixed(1)}%, sea ${((100 * sea) / all).toFixed(1)}%`) + expect(ordinary / all).toBeGreaterThan(0.5) + expect(pass / all).toBeGreaterThan(0.03) + expect(sea / all).toBeGreaterThan(0.03) }) - it('never bends too sharply to skate at full speed', () => { - const lake = createLake(1) + it('never bends too sharply to skate at full speed, forks included', () => { 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) + follow(300_000, 3, (channel, s) => (steepest = Math.max(steepest, Math.abs(channel.slope(s))))) + // 0.85 sideways per metre forward is a 40 degree turn off straight ahead. + expect(steepest).toBeLessThan(0.85) }) 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) @@ -43,45 +71,93 @@ describe('lake', () => { } }) + it('forks into two channels that start as one and draw apart round an island', () => { + for (const fork of forks(40)) { + const { s, tip, left, right } = fork + // 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) + // The island begins soon after, and from then on there is always land between. + expect(tip - s).toBeGreaterThan(50) + expect(tip - s).toBeLessThan(400) + for (let at = tip + 20; at < s + FORK_ZONE; at += 10) { + const island = right.centre(at) - right.halfWidth(at) - (left.centre(at) + left.halfWidth(at)) + expect(island).toBeGreaterThan(at < tip + 100 ? 0 : 20) + } + // Both are ordinary water for as long as both are drawn: a fork reads as a fork. + for (let at = s - 300; at < s + FORK_ZONE; at += 25) { + for (const channel of [left, right]) expect(channel.halfWidth(at)).toBeLessThanOrEqual(ORDINARY_HALF_WIDTH[1] + 1e-9) + } + } + }) + + it('comes to a fork soon, and then every few kilometres', () => { + const [first, ...rest] = forks(40).map((fork) => fork.s) + expect(first).toBeLessThan(1000) + let before = first! + for (const s of rest) { + expect(s - before).toBeGreaterThanOrEqual(1800) + expect(s - before).toBeLessThanOrEqual(4200) + before = s + } + }) + + 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]) + 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]) + // 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)) + } + }) + it('has ice between its thinnest and thickest everywhere', () => { - const lake = createLake(1) - for (let s = 0; s < 50_000; s += 13) { - for (const x of [-80, -20, 0, 35, 90]) { - const h = lake.thickness(x, s) + 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) expect(h).toBeGreaterThanOrEqual(MIN_THICKNESS) expect(h).toBeLessThanOrEqual(MAX_THICKNESS) } - } + }) }) - it('always has sound ice down its middle', () => { - const lake = createLake(1) - for (let s = 0; s < 200_000; s += 5) { - for (const off of [-2.9, 0, 2.9]) expect(lake.thickness(lake.centre(s) + off, s)).toBeGreaterThanOrEqual(MIDDLE_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 ice within reach of a skater, most of it sound', () => { - const lake = createLake(1) const zones = { breaking: 0, cracking: 0, singing: 0, safe: 0 } let all = 0 - for (let s = 0; s < 100_000; s += 10) { - const reach = lake.halfWidth(s) - 2 - for (let off = -reach; off <= reach; off += 2) { - const h = lake.thickness(lake.centre(s) + off, s) + 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++ } - } + }) + 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) - console.info(Object.entries(zones).map(([k, n]) => `${k} ${((100 * n) / all).toFixed(1)}%`).join(', ')) }) }) diff --git a/test/skater.test.ts b/test/skater.test.ts index 1d76e7d..8acb874 100644 --- a/test/skater.test.ts +++ b/test/skater.test.ts @@ -1,36 +1,46 @@ import { describe, expect, it } from 'vitest' +import { BREAKING_THICKNESS } from '../src/ice/strength.js' import { - IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, - createSkater, skaterX, stepSkater, strideSeconds, + IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_REACH, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED, UNDER_SECONDS, + type Skater, createSkater, skaterX, stepSkater, strideSeconds, } from '../src/skater.js' -import { BREAKING_THICKNESS } from '../src/ice/strength.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 } -function run(k: ReturnType<typeof createSkater>, steer: number, seconds: number): void { +function run(k: Skater, steer: number, seconds: number, on = sound): void { const steps = Math.round(seconds / DT) - for (let i = 0; i < steps; i++) stepSkater(k, lake, steer, DT) + for (let i = 0; i < steps; i++) stepSkater(k, on, steer, DT) } describe('skater', () => { - it('left alone, follows the middle of the channel for as long as you like', () => { - const k = createSkater() + it('left alone, follows the middle of the lake for as long as you like, through every fork', () => { + const k = createSkater(lake) + let forks = 0 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(k.offset).toBe(0) + expect(Math.abs(k.offset)).toBeLessThan(1e-3) + expect(k.under).toBe(0) + if (k.channel !== channel) { + // Doing nothing takes the way the fork prefers. + expect(k.channel).toBe(channel.end.preferred) + forks++ + } } - expect(skaterX(k, lake)).toBe(lake.centre(k.s)) + 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('steers across the channel at the steering speed', () => { - const k = createSkater() + const k = createSkater(lake) run(k, 1, 1) expect(k.offset).toBeCloseTo(LATERAL_SPEED, 1) expect(k.heading).toBeGreaterThan(0) @@ -38,19 +48,52 @@ describe('skater', () => { expect(k.offset).toBeCloseTo(-LATERAL_SPEED, 1) }) - it('cannot be steered into a shore', () => { + it('cannot be steered into a shore, however wide or narrow the lake', () => { 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) + 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) + } + expect(Math.abs(skaterX(k) - x)).toBeLessThan(0.3) + x = skaterX(k) } - expect(Math.sign(k.offset)).toBe(steer) } }) + it('takes the left of a fork when steered left and the right when steered right', () => { + 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. + run(k, 0, 60) + expect(k.channel).toBe(wanted) + expect(Math.abs(k.offset)).toBeLessThan(0.01) + } + }) + + it('can change its mind at a fork until the island begins, and not after', () => { + const fork = lake.start.end + const other = fork.preferred === fork.left ? fork.right : fork.left + 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.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) + run(k, -towards, 30) + expect(k.channel).toBe(other) + }) + it('holds its line for a moment after steering, then eases back to the middle', () => { - const k = createSkater() + const k = createSkater(lake) run(k, 1, 1) const steered = k.offset run(k, 0, IDLE_BEFORE_RETURN - 0.1) @@ -69,29 +112,21 @@ describe('skater', () => { // Varied: they fill the range rather than sitting at one length. expect(Math.max(...lengths) - Math.min(...lengths)).toBeGreaterThan(0.8 * (MAX_STRIDE - MIN_STRIDE)) // And the skater takes them at that pace: the first three strides, to the frame. - const k = createSkater() + const k = createSkater(lake) run(k, 0, lengths[0]! + lengths[1]! + lengths[2]! + 2 * DT) expect(Math.floor(k.stride)).toBe(3) }) it('skates the same far down the lake as at its start', () => { - const k = createSkater(1e7) + const k = createSkater(lake, 1e6) run(k, 0, 10) - expect(k.s - 1e7).toBeGreaterThan(SPEED * 10 * 0.9) - }) - - it('left alone, never goes through the ice', () => { - const k = createSkater() - for (let t = 0; t < 3600; t += DT) { - stepSkater(k, lake, 0, DT) - expect(k.under).toBe(0) - } + 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) => (x - lake.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } - const k = createSkater() + const thin: Lake = { ...lake, thickness: (x, s, channel) => (x - channel.centre(s) > 5 ? BREAKING_THICKNESS / 2 : 0.05) } + const k = createSkater(lake) while (k.under === 0) stepSkater(k, thin, 1, DT) expect(k.offset).toBeGreaterThan(5) const where = k.s diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 5e15bde..776e4fb 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -22,13 +22,17 @@ const SHOTS = [ await page.waitForTimeout(1500) }, }, - // Ice too thin to hold lies to the right of the middle here: black, where sound ice is milky. - { name: 'thin-ice', query: '?s=3035', viewport: { width: 844, height: 390 }, act: async () => {} }, + // The first fork: the island between its two channels is a little way ahead. + { name: 'fork', query: '?s=790', viewport: { width: 844, height: 390 }, act: async () => {} }, + { name: 'pass', query: '?s=6400', act: async () => {} }, + { name: 'sea', query: '?s=3400', viewport: { width: 844, height: 390 }, act: async () => {} }, + // Ice too thin to hold lies to the left of the middle here: black, where sound ice is milky. + { name: 'thin-ice', query: '?s=2795', viewport: { width: 844, height: 390 }, act: async () => {} }, { name: 'fell', - query: '?s=3055', + query: '?s=2815', act: async (page) => { - await page.keyboard.down('ArrowRight') + await page.keyboard.down('ArrowLeft') await page.waitForFunction(() => document.querySelector('#water').classList.contains('under'), null, { timeout: 15_000 }) await page.waitForTimeout(600) },