tower / files / src / world / frost.ts
frost.ts
| 1 | /** |
| 2 | * The far cap's frost, and the order Tower clears it in. See design.md §1.9.6, D-090, D-091. |
| 3 | * |
| 4 | * The far cap is single-glazed, and cold glass frosts on its inner face. Tower clears one |
| 5 | * pane per turn of the world, in an order it shuffles afresh every loop. The order is drawn |
| 6 | * from the world's seed and the loop's number, so which panes are clear at any moment is a |
| 7 | * pure function of the clock — the same in both views, and in the book — with no history to |
| 8 | * keep. |
| 9 | * |
| 10 | * Panes are the triangles of the cap's lattice (D-078): side `PANE_KM`, a vertex on the |
| 11 | * axis, and one family of mullions parallel to x. A pane is named by its lattice cell (a, b) |
| 12 | * and which half of it it is: with basis (s, 0) and (s/2, h), every point is a·e1 + b·e2, |
| 13 | * and the cell splits along a + b = whole into a lower and an upper triangle. |
| 14 | */ |
| 15 | import { RADIUS_M, SPIN_PERIOD_S } from '../canon/constants.js' |
| 16 | import { hash3, mulberry32 } from '../terrain/noise.js' |
| 17 | |
| 18 | const R_KM = RADIUS_M / 1000 |
| 19 | /** A pane's side, km: six to the cap's radius, as the mullions are drawn (cap-pattern.ts). */ |
| 20 | export const PANE_KM = R_KM / 6 |
| 21 | const H_KM = (PANE_KM * Math.sqrt(3)) / 2 |
| 22 | |
| 23 | export interface Pane { |
| 24 | a: number |
| 25 | b: number |
| 26 | /** 0 for the lower triangle of the cell, 1 for the upper. */ |
| 27 | up: 0 | 1 |
| 28 | } |
| 29 | |
| 30 | /** The pane a point on the cap is in, km from the axis. */ |
| 31 | export function paneAt(x: number, y: number): Pane { |
| 32 | const B = y / H_KM |
| 33 | const A = x / PANE_KM - B / 2 |
| 34 | const a = Math.floor(A), b = Math.floor(B) |
| 35 | return { a, b, up: A - a + (B - b) >= 1 ? 1 : 0 } |
| 36 | } |
| 37 | |
| 38 | /** A pane's three corners, km. */ |
| 39 | export function paneCorners(p: Pane): [number, number][] { |
| 40 | const at = (a: number, b: number): [number, number] => [a * PANE_KM + (b * PANE_KM) / 2, b * H_KM] |
| 41 | return p.up |
| 42 | ? [at(p.a + 1, p.b), at(p.a + 1, p.b + 1), at(p.a, p.b + 1)] |
| 43 | : [at(p.a, p.b), at(p.a + 1, p.b), at(p.a, p.b + 1)] |
| 44 | } |
| 45 | |
| 46 | /** Nearest distance from the axis to a segment. */ |
| 47 | function segmentDistance([x0, y0]: [number, number], [x1, y1]: [number, number]): number { |
| 48 | const dx = x1 - x0, dy = y1 - y0 |
| 49 | const t = Math.max(0, Math.min(1, -(x0 * dx + y0 * dy) / (dx * dx + dy * dy))) |
| 50 | return Math.hypot(x0 + dx * t, y0 + dy * t) |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Every pane with glass in it: each triangle the cap's disc reaches into, whole or cut by |
| 55 | * the rim. In a fixed order, so a pane's index means the same pane everywhere. |
| 56 | */ |
| 57 | export function farCapPanes(): Pane[] { |
| 58 | const out: Pane[] = [] |
| 59 | const reach = Math.ceil(R_KM / H_KM) + 2 |
| 60 | for (let b = -reach; b <= reach; b++) { |
| 61 | for (let a = -2 * reach; a <= 2 * reach; a++) { |
| 62 | for (const up of [0, 1] as const) { |
| 63 | const c = paneCorners({ a, b, up }) |
| 64 | // A triangle meets the disc where its nearest point to the axis is inside it. The |
| 65 | // axis itself is a corner of six of them, so the edges are enough. |
| 66 | const near = Math.min(segmentDistance(c[0]!, c[1]!), segmentDistance(c[1]!, c[2]!), |
| 67 | segmentDistance(c[2]!, c[0]!)) |
| 68 | if (near < R_KM - 1e-9) out.push({ a, b, up }) |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | return out |
| 73 | } |
| 74 | |
| 75 | /** The panes, built once. */ |
| 76 | let panesCached: Pane[] | null = null |
| 77 | export function panes(): Pane[] { |
| 78 | return (panesCached ??= farCapPanes()) |
| 79 | } |
| 80 | |
| 81 | /** One pane is cleared per turn of the world (D-090): 7 min 29 s. */ |
| 82 | export const TURN_S = SPIN_PERIOD_S |
| 83 | |
| 84 | /** A loop clears every pane once: 282 turns, 35.1 hours. */ |
| 85 | export const loopS = (): number => panes().length * TURN_S |
| 86 | |
| 87 | /** |
| 88 | * How fast frost closes a cleared pane: its cover is 1 - e^(-age / FROST_TAU_S). Ten hours |
| 89 | * leaves about one pane in ten nearly clear at any moment, which is §1.9.6's guess; the frost |
| 90 | * rate is rough to a factor of ten, and this is the knob that answers for it. |
| 91 | */ |
| 92 | export const FROST_TAU_S = 10 * 3600 |
| 93 | |
| 94 | /** The order of loop `k`: pane indices in the order they are cleared, shuffled from the seed. */ |
| 95 | export function loopOrder(seed: number, k: number): number[] { |
| 96 | const n = panes().length |
| 97 | const order = Array.from({ length: n }, (_, i) => i) |
| 98 | const r = mulberry32(hash3(seed, k, 0xf405)) |
| 99 | for (let i = n - 1; i > 0; i--) { |
| 100 | const j = Math.floor(r() * (i + 1)) |
| 101 | const t = order[i]! |
| 102 | order[i] = order[j]! |
| 103 | order[j] = t |
| 104 | } |
| 105 | return order |
| 106 | } |
| 107 | |
| 108 | /** Where each pane falls in loop `k`'s order: the inverse of `loopOrder`. */ |
| 109 | function slots(seed: number, k: number): number[] { |
| 110 | const order = loopOrder(seed, k) |
| 111 | const slot = new Array<number>(order.length) |
| 112 | order.forEach((pane, s) => { slot[pane] = s }) |
| 113 | return slot |
| 114 | } |
| 115 | |
| 116 | const cover = (ageS: number): number => 1 - Math.exp(-Math.max(0, ageS) / FROST_TAU_S) |
| 117 | |
| 118 | /** The two loops' slots last asked for: a frame at a time, the loop rarely changes. */ |
| 119 | let memo: { seed: number; k: number; now: number[]; before: number[] } | null = null |
| 120 | |
| 121 | /** |
| 122 | * The frost on every pane at `tS` seconds of world time, 0 (clear) to 1 (white), into |
| 123 | * `out`, in `panes()` order. The pane being cleared this turn thaws across it. |
| 124 | */ |
| 125 | export function frostAt(seed: number, tS: number, out: Float32Array): Float32Array { |
| 126 | const L = loopS() |
| 127 | const k = Math.floor(tS / L) |
| 128 | if (!memo || memo.seed !== seed || memo.k !== k) { |
| 129 | memo = { seed, k, now: slots(seed, k), before: slots(seed, k - 1) } |
| 130 | } |
| 131 | const start = k * L |
| 132 | const s = Math.floor((tS - start) / TURN_S) |
| 133 | const progress = (tS - start) / TURN_S - s |
| 134 | const n = panes().length |
| 135 | for (let i = 0; i < n; i++) { |
| 136 | const mine = memo.now[i]! |
| 137 | // When this pane last came clear: earlier in this loop, or in the loop before. |
| 138 | const clearedAt = mine < s ? start + (mine + 1) * TURN_S : start - L + (memo.before[i]! + 1) * TURN_S |
| 139 | if (mine === s) { |
| 140 | // Being cleared now: the frost it had built by the start of its turn, thawing. |
| 141 | out[i] = cover(start + s * TURN_S - clearedAt) * (1 - progress) |
| 142 | } else { |
| 143 | out[i] = cover(tS - clearedAt) |
| 144 | } |
| 145 | } |
| 146 | return out |
| 147 | } |