fuzzyworld

nordic-skating / mistral-medium-35-littlejs / files / src / world / lake.ts

Branch mistral-medium-35-littlejs · default branch · all branches

lake.ts

Blame: the commit that last changed each line · file · history

8b9467ca 2026-10-09 Pavel Safronov1import { createNoise2D } from 'simplex-noise'
a8fd3f13 2026-10-10 Pavel Safronov2import { BREAKING_THICKNESS } from '../ice/strength.js'
8b9467ca 2026-10-09 Pavel Safronov3
4/** A small seeded generator: the same seed is the same lake, on every device. */
5export 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 Safronov16/** A number from 0 to 1 that is always the same for the same two whole numbers. */
17function hash(a: number, b: number): number {
18 return mulberry32(a ^ Math.imul(b + 1, 0x9e3779b1))()
19}
20
21const smooth = (t: number): number => {
22 const c = Math.max(0, Math.min(1, t))
23 return c * c * (3 - 2 * c)
24}
25const mix = (a: number, b: number, t: number): number => a + (b - a) * t
26
8b9467ca 2026-10-09 Pavel Safronov27/**
1cb1fefc 2026-10-09 Pavel Safronov28 * 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 Safronov36 */
1cb1fefc 2026-10-09 Pavel Safronov37export interface Channel {
8b9467ca 2026-10-09 Pavel Safronov38 /** 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 Safronov44 /**
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 Safronov53 /**
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 Safronov58 /** 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 Safronov64/**
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 */
69export 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 Safronov78/** Where one channel becomes two. */
79export 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
89export 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 Safronov93 bankHeight(side: -1 | 1, s: number, d: number): number
a8fd3f13 2026-10-10 Pavel Safronov94 /** Thickness of the ice, metres, at `x` across and `s` along. */
95 thickness(x: number, s: number): number
8bb08e4a 2026-10-10 Pavel Safronov96 /** 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 Safronov98 /**
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 Safronov103}
104
1cb1fefc 2026-10-09 Pavel Safronov105/**
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 */
111export const MIN_HALF_WIDTH = 5
112export const ORDINARY_HALF_WIDTH = [12, 44] as const
113const OPEN_TIMES = 19
114export 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 */
121const FORK_APART = 90
122const 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 */
127const CALM_BEFORE = 250
128const 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 */
133export const FORK_ZONE = 1200
134const CALM_AFTER = FORK_ZONE
4f816cf0 2026-10-10 Pavel Safronov135/**
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 */
142const ISLET_EVERY = 50
143const ISLET_RADIUS = [2.5, 7.5] as const
144const ISLET_CLEAR = 9
145const ISLET_WATER = 16
26ace8f5 2026-10-10 Pavel Safronov146/**
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 */
152const ISLAND_EVERY = 900
153const ISLAND_LENGTH = [180, 480] as const
154const ISLAND_HALF = [6, 16] as const
1cb1fefc 2026-10-09 Pavel Safronov155/** The first fork, and then how soon and how late after one fork the next comes, metres. */
156const FIRST_FORK = 700
157const NEXT_FORK = [1800, 4200] as const
158
174b4a1a 2026-10-09 Pavel Safronov159/**
a8fd3f13 2026-10-10 Pavel Safronov160 * 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 Safronov166 */
a8fd3f13 2026-10-10 Pavel Safronov167export const MIN_THICKNESS = 0.008
6216d5cb 2026-10-09 Pavel Safronov168export const MAX_THICKNESS = 0.07
169/**
a8fd3f13 2026-10-10 Pavel Safronov170 * 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 Safronov172 */
a8fd3f13 2026-10-10 Pavel Safronov173const IMPOSSIBLE_PLUNGE = (BREAKING_THICKNESS - MIN_THICKNESS) / (BREAKING_THICKNESS - 0.014)
8b9467ca 2026-10-09 Pavel Safronov174
1cb1fefc 2026-10-09 Pavel Safronov175/** The channels the lake has at `s`, left to right, for a skater who is in `channel`. */
176export 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`. */
184export 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 Safronov190/** What may be different about a lake of the same number. It is read as the lake is used, so it may be changed. */
191export interface LakeOptions {
192 /** Whether the ice may be thinner than any ice could be. */
193 impossibleIce: boolean
8bb08e4a 2026-10-10 Pavel Safronov194 /** 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 Safronov196}
197
198export function createLake(seed: number, options: LakeOptions = { impossibleIce: false }): Lake {
8b9467ca 2026-10-09 Pavel Safronov199 const noise = createNoise2D(mulberry32(seed))
1cb1fefc 2026-10-09 Pavel Safronov200 // 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 Safronov232 /** 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 Safronov246 /** 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 Safronov252 // 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 Safronov254 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 Safronov262 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 Safronov266 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 Safronov269 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 Safronov278 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 Safronov299 return {
1cb1fefc 2026-10-09 Pavel Safronov300 start: channel(seed >>> 0, 0),
8b9467ca 2026-10-09 Pavel Safronov301 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 Safronov307 thickness: (x, s) => {
6216d5cb 2026-10-09 Pavel Safronov308 // Ice thickens and thins in broad patches, longer along the lake than across it.
a8fd3f13 2026-10-10 Pavel Safronov309 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 Safronov315 },
8bb08e4a 2026-10-10 Pavel Safronov316 snow: (x, s) => options.snow?.(x, s) ?? 0,
a8fd3f13 2026-10-10 Pavel Safronov317 carve: (s) => noise(s / 110, 900),
8b9467ca 2026-10-09 Pavel Safronov318 }
319}