nordic-skating / local-littlejs / files / src / world / lake.ts
Branch local-littlejs · default branch · all branches
lake.ts
8b9467ca 2026-10-09 Pavel Safronov | 1 | import { createNoise2D } from 'simplex-noise' |
a8fd3f13 2026-10-10 Pavel Safronov | 2 | import { BREAKING_THICKNESS } from '../ice/strength.js' |
8b9467ca 2026-10-09 Pavel Safronov | 3 | |
| 4 | /** A small seeded generator: the same seed is the same lake, on every device. */ | |
| 5 | export function mulberry32(seed: number): () => number { | |
| 6 | let a = seed >>> 0 | |
| 7 | return () => { | |
| 8 | a = (a + 0x6d2b79f5) >>> 0 | |
| 9 | let t = a | |
| 10 | t = Math.imul(t ^ (t >>> 15), t | 1) | |
| 11 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61) | |
| 12 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296 | |
| 13 | } | |
| 14 | } | |
| 15 | ||
1cb1fefc 2026-10-09 Pavel Safronov | 16 | /** A number from 0 to 1 that is always the same for the same two whole numbers. */ |
| 17 | function hash(a: number, b: number): number { | |
| 18 | return mulberry32(a ^ Math.imul(b + 1, 0x9e3779b1))() | |
| 19 | } | |
| 20 | ||
| 21 | const smooth = (t: number): number => { | |
| 22 | const c = Math.max(0, Math.min(1, t)) | |
| 23 | return c * c * (3 - 2 * c) | |
| 24 | } | |
| 25 | const mix = (a: number, b: number, t: number): number => a + (b - a) * t | |
| 26 | ||
8b9467ca 2026-10-09 Pavel Safronov | 27 | /** |
1cb1fefc 2026-10-09 Pavel Safronov | 28 | * The lake is water that runs on for ever, and forks. A channel is one way through it: |
| 29 | * from the fork that made it to the fork that ends it. Everything about a channel is a | |
| 30 | * function of `s`, the distance skated in metres, so any stretch can be made on demand | |
| 31 | * and nothing has to be remembered. | |
| 32 | * | |
| 33 | * A channel answers for any `s`, not only its own stretch. Before the fork that made it, | |
| 34 | * it is the channel it forked from; so the two channels of a fork are one where they part | |
| 35 | * and draw away from each other after it. | |
8b9467ca 2026-10-09 Pavel Safronov | 36 | */ |
1cb1fefc 2026-10-09 Pavel Safronov | 37 | export interface Channel { |
8b9467ca 2026-10-09 Pavel Safronov | 38 | /** Sideways position of the middle of the channel, metres. */ |
| 39 | centre(s: number): number | |
| 40 | /** Sideways metres per metre forward that the middle moves: the channel's bend. */ | |
| 41 | slope(s: number): number | |
| 42 | /** From the middle to either shore, metres. */ | |
| 43 | halfWidth(s: number): number | |
26ace8f5 2026-10-10 Pavel Safronov | 44 | /** |
| 45 | * How far from the middle of the channel to either shore of the island standing in | |
| 46 | * it, metres; 0 where there is none. An island is a long, low bank of reeds with water | |
| 47 | * both sides: the channel parts round it and is one again beyond it. `halfWidth` has | |
| 48 | * the island in it, so the water either side is as wide as the channel would have been. | |
| 49 | */ | |
| 50 | island(s: number): number | |
| 51 | /** Which side of that island a skater who does nothing is taken: -1 left, 1 right. */ | |
| 52 | islandSide(s: number): -1 | 1 | |
4f816cf0 2026-10-10 Pavel Safronov | 53 | /** |
| 54 | * The islets standing in the ice between `from` and `to` metres along, in the order | |
| 55 | * they are come to. | |
| 56 | */ | |
| 57 | islets(from: number, to: number): Islet[] | |
1cb1fefc 2026-10-09 Pavel Safronov | 58 | /** The fork that made this channel; the lake's first channel has none. */ |
| 59 | readonly birth?: Fork | |
| 60 | /** The fork this channel ends in. */ | |
| 61 | readonly end: Fork | |
| 62 | } | |
| 63 | ||
4f816cf0 2026-10-10 Pavel Safronov | 64 | /** |
| 65 | * A tuft of reeds standing in the ice, on a hump of frozen ground: something to skate | |
| 66 | * round. In the film this is modelled on they stand in clusters, and the skater weaves | |
| 67 | * between them. | |
| 68 | */ | |
| 69 | export interface Islet { | |
| 70 | /** Where its middle is, across the lake and along it, and how far its edge is from that, metres. */ | |
| 71 | x: number | |
| 72 | s: number | |
| 73 | radius: number | |
| 74 | /** A number of its own, for whatever is drawn on it. */ | |
| 75 | seed: number | |
| 76 | } | |
| 77 | ||
1cb1fefc 2026-10-09 Pavel Safronov | 78 | /** Where one channel becomes two. */ |
| 79 | export interface Fork { | |
| 80 | /** Where the channels part, and, further on, where the island between them begins. */ | |
| 81 | readonly s: number | |
| 82 | readonly tip: number | |
| 83 | readonly left: Channel | |
| 84 | readonly right: Channel | |
| 85 | /** The one a skater who does nothing is taken down. */ | |
| 86 | readonly preferred: Channel | |
| 87 | } | |
| 88 | ||
| 89 | export interface Lake { | |
| 90 | /** The channel the lake starts in. */ | |
| 91 | readonly start: Channel | |
| 92 | /** Height of the land `d` metres from the nearest shore, at `s` along. */ | |
8b9467ca 2026-10-09 Pavel Safronov | 93 | bankHeight(side: -1 | 1, s: number, d: number): number |
a8fd3f13 2026-10-10 Pavel Safronov | 94 | /** Thickness of the ice, metres, at `x` across and `s` along. */ |
| 95 | thickness(x: number, s: number): number | |
8bb08e4a 2026-10-10 Pavel Safronov | 96 | /** 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. */ |
| 97 | snow(x: number, s: number): number | |
a8fd3f13 2026-10-10 Pavel Safronov | 98 | /** |
| 99 | * Which way an unhurried skater drifts at `s`, from -1 (as far left as they go) to 1: | |
| 100 | * the long, lazy line the autopilot carves down the lake instead of a straight one. | |
| 101 | */ | |
| 102 | carve(s: number): number | |
8b9467ca 2026-10-09 Pavel Safronov | 103 | } |
| 104 | ||
1cb1fefc 2026-10-09 Pavel Safronov | 105 | /** |
| 106 | * The narrowest and widest a channel gets, shore to middle, in metres. Most of the lake | |
| 107 | * is `ORDINARY` wide. Now and then it pinches to a pass `MIN_HALF_WIDTH` wide, and now | |
| 108 | * and then it opens until neither shore can be seen through the haze (which ends at | |
| 109 | * 600 m): a stretch of sea. | |
| 110 | */ | |
| 111 | export const MIN_HALF_WIDTH = 5 | |
| 112 | export const ORDINARY_HALF_WIDTH = [12, 44] as const | |
| 113 | const OPEN_TIMES = 19 | |
| 114 | export const MAX_HALF_WIDTH = ORDINARY_HALF_WIDTH[1] * OPEN_TIMES | |
| 115 | ||
| 116 | /** | |
| 117 | * How far each channel of a fork ends up from where the one channel would have run, and | |
| 118 | * the distance over which it gets there, metres. Twice `FORK_APART` is always more than | |
| 119 | * two ordinary channels are wide, so there is always an island between. | |
| 120 | */ | |
| 121 | const FORK_APART = 90 | |
| 122 | const FORK_RAMP = 600 | |
| 123 | /** | |
| 124 | * Around a fork the lake keeps to its ordinary width, from this far before it, metres: | |
| 125 | * a fork in a pass or out at sea would not read as one. | |
| 126 | */ | |
| 127 | const CALM_BEFORE = 250 | |
| 128 | const CALM_FADE = 250 | |
| 129 | /** | |
| 130 | * Metres after a fork for which both its channels are part of the lake that is drawn, | |
| 131 | * and so for which they keep to their ordinary width too. | |
| 132 | */ | |
| 133 | export const FORK_ZONE = 1200 | |
| 134 | const CALM_AFTER = FORK_ZONE | |
4f816cf0 2026-10-10 Pavel Safronov | 135 | /** |
| 136 | * Islets come in beds: stretches of the lake some hundreds of metres long that have | |
| 137 | * them, with long stretches between that have none. Within a bed there is room for one | |
| 138 | * every `ISLET_EVERY` metres, and most of those have one. An islet is `ISLET_RADIUS` | |
| 139 | * across its middle, keeps `ISLET_CLEAR` metres of ice between itself and a shore, and | |
| 140 | * is not found where the water is narrower than `ISLET_WATER` to a side, nor near a fork. | |
| 141 | */ | |
| 142 | const ISLET_EVERY = 50 | |
| 143 | const ISLET_RADIUS = [2.5, 7.5] as const | |
| 144 | const ISLET_CLEAR = 9 | |
| 145 | const ISLET_WATER = 16 | |
26ace8f5 2026-10-10 Pavel Safronov | 146 | /** |
| 147 | * Islands: there is room for one every `ISLAND_EVERY` metres, and most of those that | |
| 148 | * are not near a fork have one. It is `ISLAND_LENGTH` long and, at its middle, `ISLAND_HALF` from its own | |
| 149 | * middle to either shore, coming to a point at both ends. None near a fork, nor in water | |
| 150 | * as narrow as a pass. | |
| 151 | */ | |
| 152 | const ISLAND_EVERY = 900 | |
| 153 | const ISLAND_LENGTH = [180, 480] as const | |
| 154 | const ISLAND_HALF = [6, 16] as const | |
1cb1fefc 2026-10-09 Pavel Safronov | 155 | /** The first fork, and then how soon and how late after one fork the next comes, metres. */ |
| 156 | const FIRST_FORK = 700 | |
| 157 | const NEXT_FORK = [1800, 4200] as const | |
| 158 | ||
174b4a1a 2026-10-09 Pavel Safronov | 159 | /** |
a8fd3f13 2026-10-10 Pavel Safronov | 160 | * The thinnest and thickest the ice gets, metres. The thick end is half as thick again as |
| 161 | * a skater needs. The thin end is impossible, and is there only when asked for | |
| 162 | * (`LakeOptions`): ice under 2.2 cm would not hold anyone (ice/strength.ts), and this | |
| 163 | * goes down to 8 mm, which sings at 3.8 kHz. It can be, here: nobody falls through this | |
| 164 | * lake (skater.ts), so the ice is free to be as thin as sounds good. Otherwise the | |
| 165 | * thinnest ice is that 2.2 cm. | |
174b4a1a 2026-10-09 Pavel Safronov | 166 | */ |
a8fd3f13 2026-10-10 Pavel Safronov | 167 | export const MIN_THICKNESS = 0.008 |
6216d5cb 2026-10-09 Pavel Safronov | 168 | export const MAX_THICKNESS = 0.07 |
| 169 | /** | |
a8fd3f13 2026-10-10 Pavel Safronov | 170 | * How much faster than ordinary ice the impossible ice thins towards the middle of a |
| 171 | * patch of it: such patches stay as rare and as small as thin ice was, and get thinner. | |
6216d5cb 2026-10-09 Pavel Safronov | 172 | */ |
a8fd3f13 2026-10-10 Pavel Safronov | 173 | const IMPOSSIBLE_PLUNGE = (BREAKING_THICKNESS - MIN_THICKNESS) / (BREAKING_THICKNESS - 0.014) |
8b9467ca 2026-10-09 Pavel Safronov | 174 | |
1cb1fefc 2026-10-09 Pavel Safronov | 175 | /** The channels the lake has at `s`, left to right, for a skater who is in `channel`. */ |
| 176 | export function channelsAt(channel: Channel, s: number): Channel[] { | |
| 177 | const { birth, end } = channel | |
| 178 | if (birth && s >= birth.s && s < birth.s + FORK_ZONE) return [birth.left, birth.right] | |
| 179 | if (s >= end.s) return [end.left, end.right] | |
| 180 | return [channel] | |
| 181 | } | |
| 182 | ||
| 183 | /** The channel a skater who has done nothing since the start is in at `s`. */ | |
| 184 | export function channelAt(lake: Lake, s: number): Channel { | |
| 185 | let channel = lake.start | |
| 186 | while (channel.end.s <= s) channel = channel.end.preferred | |
| 187 | return channel | |
| 188 | } | |
| 189 | ||
a8fd3f13 2026-10-10 Pavel Safronov | 190 | /** What may be different about a lake of the same number. It is read as the lake is used, so it may be changed. */ |
| 191 | export interface LakeOptions { | |
| 192 | /** Whether the ice may be thinner than any ice could be. */ | |
| 193 | impossibleIce: boolean | |
8bb08e4a 2026-10-10 Pavel Safronov | 194 | /** Where snow lies on the ice, if any does: that is the weather's to say, not the lake's. */ |
| 195 | snow?: (x: number, s: number) => number | |
a8fd3f13 2026-10-10 Pavel Safronov | 196 | } |
| 197 | ||
| 198 | export function createLake(seed: number, options: LakeOptions = { impossibleIce: false }): Lake { | |
8b9467ca 2026-10-09 Pavel Safronov | 199 | const noise = createNoise2D(mulberry32(seed)) |
1cb1fefc 2026-10-09 Pavel Safronov | 200 | // A long wander with a shorter wiggle on it, which every channel shares. The amplitudes |
| 201 | // over the wavelengths keep the bend gentle enough to skate without ever braking. | |
| 202 | const wander = (s: number): number => 45 * noise(s / 600, 0) + 6 * noise(s / 180, 10) | |
| 203 | ||
| 204 | /** | |
| 205 | * One way through the lake. `lane` is a number of its own, which is what makes its | |
| 206 | * width and its forks unlike any other channel's; `shift` is how far to one side of | |
| 207 | * the lake's wander it runs. | |
| 208 | */ | |
| 209 | function channel(lane: number, shift: number, birth?: Fork, parent?: Channel, parentShift = 0): Channel { | |
| 210 | // Somewhere of its own in the noise. | |
| 211 | const key = (lane % 9973) * 3.1 | |
| 212 | const born = birth?.s ?? -Infinity | |
| 213 | const ends = birth ? born + mix(NEXT_FORK[0], NEXT_FORK[1], hash(lane, 0)) : FIRST_FORK | |
| 214 | /** 0 where the channel leaves the one it forked from, 1 once it has drawn clear. */ | |
| 215 | const away = (s: number): number => (birth ? smooth((s - born) / FORK_RAMP) : 1) | |
| 216 | /** 1 near either of the channel's forks, where it keeps to its ordinary width. */ | |
| 217 | const calm = (s: number): number => | |
| 218 | Math.max(...[born, ends].map((f) => Math.min(smooth((s - (f - CALM_BEFORE - CALM_FADE)) / CALM_FADE), | |
| 219 | smooth((f + CALM_AFTER + CALM_FADE - s) / CALM_FADE)))) | |
| 220 | ||
| 221 | const ownWidth = (s: number): number => { | |
| 222 | const [lo, hi] = ORDINARY_HALF_WIDTH | |
| 223 | const ordinary = (lo + hi) / 2 + ((hi - lo) / 2) * (0.75 * noise(s / 300, key + 50) + 0.25 * noise(s / 90, key + 80)) | |
| 224 | // The lake's character changes over kilometres: a pass, ordinary water, open sea. | |
| 225 | const character = noise(s / 2500, key + 7) * (1 - calm(s)) | |
| 226 | const pass = smooth((-0.22 - character) / 0.25) | |
| 227 | const open = smooth((character - 0.03) / 0.27) | |
| 228 | return mix(ordinary, MIN_HALF_WIDTH, pass) * (1 + (OPEN_TIMES - 1) * open * open) | |
| 229 | } | |
| 230 | ||
| 231 | const centre = (s: number): number => wander(s) + mix(parentShift, shift, away(s)) | |
26ace8f5 2026-10-10 Pavel Safronov | 232 | /** The island at `s`, if it is this channel's own to have one there: half its width. */ |
| 233 | const ownIsland = (s: number): number => { | |
| 234 | const index = Math.floor(s / ISLAND_EVERY) | |
| 235 | if (hash(lane, 8000 + index) > 0.9) return 0 | |
| 236 | // Somewhere in its place that leaves room for the longest island before the next place. | |
| 237 | const from = (index + 0.05 + 0.35 * hash(lane, 5000 + index)) * ISLAND_EVERY | |
| 238 | const length = mix(ISLAND_LENGTH[0], ISLAND_LENGTH[1], hash(lane, 6000 + index)) | |
| 239 | if (s <= from || s >= from + length) return 0 | |
| 240 | if (from < born + FORK_ZONE + 200 || from + length > ends - 400) return 0 | |
| 241 | // Not in a pass, nor anywhere the lake is on its way into or out of one. | |
| 242 | for (let part = 0; part <= 8; part++) if (ownWidth(from + (length * part) / 8) < 15) return 0 | |
| 243 | return mix(ISLAND_HALF[0], ISLAND_HALF[1], hash(lane, 7000 + index)) * Math.sin((Math.PI * (s - from)) / length) | |
| 244 | } | |
| 245 | const island = (s: number): number => (parent && s < born ? parent.island(s) : s < born + FORK_RAMP ? 0 : ownIsland(s)) | |
4f816cf0 2026-10-10 Pavel Safronov | 246 | /** The islet in the `index`th place there is room for one, if there is one in it. */ |
| 247 | const islet = (index: number): Islet | undefined => { | |
| 248 | const s = (index + 0.2 + 0.6 * hash(lane, 1000 + index)) * ISLET_EVERY | |
| 249 | // None while the other channel of a fork is still in sight, nor coming up to one. | |
| 250 | if (s < born + FORK_ZONE + 100 || s > ends - 300) return undefined | |
| 251 | if (noise(s / 900, key + 300) < 0.25 || hash(lane, 2000 + index) > 0.7) return undefined | |
26ace8f5 2026-10-10 Pavel Safronov | 252 | // Nor beside an island, nor just off either end of one. |
| 253 | if (ownIsland(s) > 0 || ownIsland(s - 40) > 0 || ownIsland(s + 40) > 0) return undefined | |
4f816cf0 2026-10-10 Pavel Safronov | 254 | const width = self.halfWidth(s) |
| 255 | if (width < ISLET_WATER) return undefined | |
| 256 | const radius = mix(ISLET_RADIUS[0], ISLET_RADIUS[1], hash(lane, 3000 + index) ** 2) | |
| 257 | // Anywhere across the water that leaves ice to skate on both sides of it; out at | |
| 258 | // sea, within sight of the middle. | |
| 259 | const span = Math.min(width, 110) - radius - ISLET_CLEAR | |
| 260 | return { x: centre(s) + (2 * hash(lane, 4000 + index) - 1) * span, s, radius, seed: (lane ^ Math.imul(index, 0x85ebca6b)) >>> 0 } | |
| 261 | } | |
1cb1fefc 2026-10-09 Pavel Safronov | 262 | let end: Fork | undefined |
| 263 | const self: Channel = { | |
| 264 | centre, | |
| 265 | slope: (s) => centre(s + 0.5) - centre(s - 0.5), | |
26ace8f5 2026-10-10 Pavel Safronov | 266 | halfWidth: (s) => (parent && s < born + FORK_RAMP ? mix(parent.halfWidth(s), ownWidth(s), away(s)) : ownWidth(s) + ownIsland(s)), |
| 267 | island, | |
| 268 | islandSide: (s) => (parent && s < born ? parent.islandSide(s) : hash(lane, 9000 + Math.floor(s / ISLAND_EVERY)) < 0.5 ? -1 : 1), | |
4f816cf0 2026-10-10 Pavel Safronov | 269 | islets: (from, to) => { |
| 270 | // Before the fork that made it, a channel is the one it forked from, islets and all. | |
| 271 | const found = parent && from < born ? parent.islets(from, Math.min(to, born)) : [] | |
| 272 | for (let index = Math.floor(Math.max(from, born) / ISLET_EVERY); index * ISLET_EVERY < to; index++) { | |
| 273 | const here = islet(index) | |
| 274 | if (here && here.s >= from && here.s < to) found.push(here) | |
| 275 | } | |
| 276 | return found | |
| 277 | }, | |
1cb1fefc 2026-10-09 Pavel Safronov | 278 | birth, |
| 279 | get end() { | |
| 280 | return (end ??= fork(self, lane, shift, ends)) | |
| 281 | }, | |
| 282 | } | |
| 283 | return self | |
| 284 | } | |
| 285 | ||
| 286 | function fork(trunk: Channel, lane: number, shift: number, s: number): Fork { | |
| 287 | const made = {} as { -readonly [K in keyof Fork]: Fork[K] } | |
| 288 | made.s = s | |
| 289 | made.left = channel(Math.floor(hash(lane, 1) * 2 ** 32), shift - FORK_APART, made, trunk, shift) | |
| 290 | made.right = channel(Math.floor(hash(lane, 2) * 2 ** 32), shift + FORK_APART, made, trunk, shift) | |
| 291 | made.preferred = hash(lane, 3) < 0.5 ? made.left : made.right | |
| 292 | // The island begins where the two channels' facing shores first come apart. | |
| 293 | made.tip = s | |
| 294 | while (made.left.centre(made.tip) + made.left.halfWidth(made.tip) | |
| 295 | >= made.right.centre(made.tip) - made.right.halfWidth(made.tip)) made.tip += 2 | |
| 296 | return made | |
| 297 | } | |
| 298 | ||
8b9467ca 2026-10-09 Pavel Safronov | 299 | return { |
1cb1fefc 2026-10-09 Pavel Safronov | 300 | start: channel(seed >>> 0, 0), |
8b9467ca 2026-10-09 Pavel Safronov | 301 | bankHeight: (side, s, d) => { |
| 302 | // A bank that climbs quickly out of the ice and then a slow rise into hills, taller | |
| 303 | // in some places than others. Zero at the shore, so the land meets the ice exactly. | |
| 304 | const rise = 8 * (1 - Math.exp(-d / 25)) + 0.06 * d | |
| 305 | return rise * (0.55 + 0.45 * noise(s / 140, side * 37 + d / 90)) | |
| 306 | }, | |
a8fd3f13 2026-10-10 Pavel Safronov | 307 | thickness: (x, s) => { |
6216d5cb 2026-10-09 Pavel Safronov | 308 | // Ice thickens and thins in broad patches, longer along the lake than across it. |
a8fd3f13 2026-10-10 Pavel Safronov | 309 | const patch = 0.042 + 0.028 * noise(x / 60 + 500, s / 180) |
| 310 | if (patch >= BREAKING_THICKNESS) return patch | |
| 311 | // The thinnest real ice is ice on the point of giving way, and its patches are | |
| 312 | // that all the way across. Impossible ice goes on thinning to the middle of them. | |
| 313 | if (!options.impossibleIce) return BREAKING_THICKNESS | |
| 314 | return BREAKING_THICKNESS - (BREAKING_THICKNESS - patch) * IMPOSSIBLE_PLUNGE | |
6216d5cb 2026-10-09 Pavel Safronov | 315 | }, |
8bb08e4a 2026-10-10 Pavel Safronov | 316 | snow: (x, s) => options.snow?.(x, s) ?? 0, |
a8fd3f13 2026-10-10 Pavel Safronov | 317 | carve: (s) => noise(s / 110, 900), |
8b9467ca 2026-10-09 Pavel Safronov | 318 | } |
| 319 | } |