fuzzyworld

nordic-skating / mistral-medium-35-littlejs / files / src / ice / cracks.ts

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

cracks.ts

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

c123191e 2026-10-10 Pavel Safronov1/**
2 * How ice cracks, as far as how it looks goes.
3 *
4 * A sheet of black ice is crossed by a few long cracks that run nearly straight for tens
5 * of metres. Later cracks start from the side of an earlier one, run off at a wide angle
6 * to it, and stop when they meet another: a crack cannot cross a crack, since the ice is
7 * already parted there. So the cracks make a network of long runs joined at T's, with
8 * shorter and finer ones filling in between, and never a tangle of lines crossing at
9 * random.
10 */
11
12export type Point = [number, number]
13
14export interface Crack {
15 /** Where it runs, in metres. On a sheet that repeats, it may run off one edge: it comes in at the other. */
16 points: Point[]
17 /** 0 for one of the long first cracks, 1 for one that runs between them, 2 for a hairline. */
18 rank: 0 | 1 | 2
19}
20
21/** Metres between the points of a crack. */
22const STEP = 0.25
23/** How much a crack of each rank wanders from straight at each step, radians. */
24const WANDER = [0.035, 0.07, 0.12] as const
25
26/** Where along the step from `a` to `b` it meets the stretch from `c` to `d`, as a fraction of the step; nowhere if it does not. */
27function meets(a: Point, b: Point, c: Point, d: Point): number | undefined {
28 const rx = b[0] - a[0]
29 const ry = b[1] - a[1]
30 const sx = d[0] - c[0]
31 const sy = d[1] - c[1]
32 const cross = rx * sy - ry * sx
33 if (Math.abs(cross) < 1e-12) return undefined
34 const t = ((c[0] - a[0]) * sy - (c[1] - a[1]) * sx) / cross
35 const u = ((c[0] - a[0]) * ry - (c[1] - a[1]) * rx) / cross
36 return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : undefined
37}
38
39/**
40 * The cracks of a square sheet of ice `size` metres on a side that repeats in both
41 * directions: `first` long ones, then `between` that run from one crack to another,
42 * then `fine` hairlines. `random` gives numbers from 0 to 1, and the same numbers give
43 * the same cracks.
44 */
45export function crackNetwork(random: () => number, size: number, first = 5, between = 26, fine = 44): Crack[] {
46 const cracks: Crack[] = []
47 // Every step of every crack so far, kept by the metre square its middle is in, so that
48 // a new step is tried only against the steps near it.
49 const cells = Math.ceil(size)
50 const near: [Point, Point][][] = Array.from({ length: cells * cells }, () => [])
51 const cell = (x: number, y: number): number =>
52 (((Math.floor(x) % cells) + cells) % cells) + ((((Math.floor(y) % cells) + cells) % cells) * cells)
53 const keep = (a: Point, b: Point): void => void near[cell((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)]!.push([a, b])
54
55 /** Where the step from `a` to `b` first meets a crack already there, as a fraction of it. */
56 const stopped = (a: Point, b: Point): number | undefined => {
57 let first: number | undefined
58 const mx = (a[0] + b[0]) / 2
59 const my = (a[1] + b[1]) / 2
60 for (let dx = -1; dx <= 1; dx++) {
61 for (let dy = -1; dy <= 1; dy++) {
62 for (const [c, d] of near[cell(mx + dx, my + dy)]!) {
63 // The sheet repeats: the stretch is tried where its nearest copy to this step lies.
64 const ox = Math.round((mx - (c[0] + d[0]) / 2) / size) * size
65 const oy = Math.round((my - (c[1] + d[1]) / 2) / size) * size
66 const t = meets(a, b, [c[0] + ox, c[1] + oy], [d[0] + ox, d[1] + oy])
67 // Not at the step's very start, which is where a crack leaves the one it began on.
68 if (t !== undefined && t > 1e-9 && (first === undefined || t < first)) first = t
69 }
70 }
71 }
72 return first
73 }
74
75 /** Run a crack from `from` in direction `way` for up to `far` metres, or until it meets another. */
76 const run = (from: Point, way: number, far: number, rank: 0 | 1 | 2): Point[] => {
77 const points: Point[] = [from]
78 for (let gone = 0; gone < far; gone += STEP) {
79 way += (random() - 0.5) * 2 * WANDER[rank]
80 // Now and then a sharper turn, where the ice gives along a different grain.
81 if (random() < 0.03) way += (random() - 0.5) * 0.5
82 const a = points[points.length - 1]!
83 const b: Point = [a[0] + Math.cos(way) * STEP, a[1] + Math.sin(way) * STEP]
84 const t = stopped(a, b)
85 if (t !== undefined) {
86 points.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
87 break
88 }
89 points.push(b)
90 }
91 return points
92 }
93
94 const add = (points: Point[], rank: 0 | 1 | 2): void => {
95 if (points.length < 3) return
96 for (let i = 1; i < points.length; i++) keep(points[i - 1]!, points[i]!)
97 cracks.push({ points, rank })
98 }
99
100 // The long first cracks: from somewhere, both ways, most of the way across the sheet.
101 for (let n = 0; n < first; n++) {
102 const from: Point = [random() * size, random() * size]
103 const way = random() * Math.PI * 2
104 const one = run(from, way, size * (0.5 + 0.4 * random()), 0)
105 const other = run(from, way + Math.PI, size * (0.5 + 0.4 * random()), 0)
106 add([...other.reverse(), ...one.slice(1)], 0)
107 }
108 // The rest start from a point on a crack already there and leave it at a wide angle.
109 const branch = (far: () => number, rank: 1 | 2): void => {
110 const parent = cracks[Math.floor(random() * cracks.length)]!
111 const at = 1 + Math.floor(random() * (parent.points.length - 2))
112 const [px, py] = parent.points[at]!
113 const [qx, qy] = parent.points[at - 1]!
114 const along = Math.atan2(py - qy, px - qx)
115 const off = (random() < 0.5 ? 1 : -1) * (Math.PI / 2 + (random() - 0.5) * 1.1)
116 add(run([px, py], along + off, far(), rank), rank)
117 }
118 for (let n = 0; n < between; n++) branch(() => 3 + 11 * random(), 1)
119 for (let n = 0; n < fine; n++) branch(() => 0.8 + 3.5 * random(), 2)
120 return cracks
121}
122
123/**
124 * A crack opening now, under a skater or near one: a star of a few cracks running out
125 * from one point, about `far` metres each, each forking once or twice. Every stretch of
126 * it comes with how far along the crack from that point it is, in metres, for whoever
127 * wants to show it running outwards.
128 */
129export function crackStar(random: () => number, far: number): { from: Point; to: Point; along: number }[] {
130 const stretches: { from: Point; to: Point; along: number }[] = []
131 const arm = (start: Point, way: number, length: number, gone: number, forks: number): void => {
132 let at = start
133 for (let run = 0; run < length; run += STEP) {
134 way += (random() - 0.5) * 0.16
135 if (random() < 0.05) way += (random() - 0.5) * 0.6
136 const to: Point = [at[0] + Math.cos(way) * STEP, at[1] + Math.sin(way) * STEP]
137 stretches.push({ from: at, to, along: gone + run })
138 at = to
139 // A fork: a shorter crack off to one side, from here.
140 if (forks > 0 && random() < 0.5 * STEP) {
141 forks--
142 arm(at, way + (random() < 0.5 ? 1 : -1) * (0.5 + 0.6 * random()), (length - run) * (0.3 + 0.4 * random()), gone + run, 0)
143 }
144 }
145 }
146 // Two arms nearly opposite, which is the crack itself, and often a third or fourth.
147 const way = random() * Math.PI * 2
148 const arms = 2 + Math.floor(random() * 2.6)
149 for (let n = 0; n < arms; n++) {
150 const turn = n < 2 ? n * Math.PI + (random() - 0.5) * 0.4 : random() * Math.PI * 2
151 arm([0, 0], way + turn, far * (n < 2 ? 0.6 + 0.4 * random() : 0.25 + 0.4 * random()), 0, 2)
152 }
153 return stretches
154}