nordic-skating / mistral-medium-35-littlejs / files / src / ice / cracks.ts
Branch mistral-medium-35-littlejs · default branch · all branches
cracks.ts
c123191e 2026-10-10 Pavel Safronov | 1 | /** |
| 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 | ||
| 12 | export type Point = [number, number] | |
| 13 | ||
| 14 | export 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. */ | |
| 22 | const STEP = 0.25 | |
| 23 | /** How much a crack of each rank wanders from straight at each step, radians. */ | |
| 24 | const 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. */ | |
| 27 | function 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 | */ | |
| 45 | export 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 | */ | |
| 129 | export 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 | } |