@codincod/ornament 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Light cycles driven by a Perlin field.
3
+ *
4
+ * The simulation behind the Trace world page's background, kept free of the DOM
5
+ * so the drawing half stays a drawing half and this stays readable on its own.
6
+ *
7
+ * The idea in one line: sample Perlin noise for a direction, then throw away
8
+ * everything about that direction except which of the four cardinals it is
9
+ * closest to. A smooth field read through a four-way quantiser stops looking
10
+ * like the usual particle-flow wallpaper and starts looking like light cycles,
11
+ * because the riders can only ever turn ninety degrees, and because a field
12
+ * that varies smoothly makes neighbouring riders agree for a while and then
13
+ * peel apart. That is the whole ornament: Perlin's noise drawn with Trace's
14
+ * geometry.
15
+ *
16
+ * Two of the rules are borrowed from the real world in `codin-cod-worlds/trace`, not
17
+ * invented here, because the ornament should not contradict the game it sits
18
+ * in front of. A trail holds a bounded number of cells and the oldest expires
19
+ * (`DESIGN.md`, "the trail is finite"), and a rider may not reverse into its
20
+ * own neck. What is *not* borrowed is rider-on-rider collision: these riders
21
+ * pass through each other, because a background that empties itself is worse
22
+ * scenery than one that does not.
23
+ *
24
+ * They do collide with a wall the reader draws. Dragging the pointer lays a
25
+ * line across the field and a rider that reaches it dies there and respawns
26
+ * somewhere else, which is the one thing on this canvas anybody can actually
27
+ * do. Riders do not steer around it: a wall you have to watch them dodge is a
28
+ * wall that does nothing, and the whole appeal is that the line you drew is the
29
+ * thing that stopped them. The wall expires on its own (`WALL_LIFE`) so the
30
+ * field always comes back, and drawing suppresses the pointer's usual shove,
31
+ * since riders fleeing the cursor would never reach the line under it.
32
+ *
33
+ * The edge is a wall, and that is load-bearing rather than cosmetic. The first
34
+ * version wrapped riders around to the far side, which is cheaper and looks
35
+ * fine on paper. In practice a rider that left through an edge arrived in a
36
+ * part of the field that was also pointing outward, so it left again on the
37
+ * very next tick, and since a wrapped trail has to be cut (or it draws one
38
+ * straight line across the whole canvas) those riders spent their lives as a
39
+ * single flickering dot in a corner. Turning them along the wall instead keeps
40
+ * every trail whole and costs nothing.
41
+ */
42
+
43
+ import { makeNoise2, makeRandom, type Noise2 } from "./perlin"
44
+
45
+ export interface Cell {
46
+ readonly x: number
47
+ readonly y: number
48
+ }
49
+
50
+ export interface Rider {
51
+ cell: Cell
52
+ direction: Cell
53
+ /** Which `rider-N` colour this one wears, so it matches the real board. */
54
+ readonly slot: number
55
+ /** Oldest cell first, current cell last. */
56
+ readonly trail: Cell[]
57
+ }
58
+
59
+ /** A drawn wall cell, or the mark a rider left dying on one. */
60
+ export interface Fading {
61
+ readonly cell: Cell
62
+ /** 1 when new, falling to 0 as it expires. What the painter fades on. */
63
+ readonly life: number
64
+ }
65
+
66
+ export interface Field {
67
+ /**
68
+ * Lay wall along the segment between two cells, or on one cell if `from` is
69
+ * null. Takes a segment rather than a cell because a pointer moving quickly
70
+ * reports positions cells apart, and a wall with holes in it is a wall
71
+ * riders slip through.
72
+ */
73
+ draw(from: Cell | null, to: Cell): void
74
+ /**
75
+ * Re-fit to a resized grid, keeping the riders that still fit on it. When
76
+ * `riders` is given and differs, the field grows or thins to that count, so a
77
+ * phone turned landscape or a window dragged wide is not left with a
78
+ * desktop's crowd on a postcard or a postcard's handful on a desktop.
79
+ */
80
+ resize(cols: number, rows: number, riders?: number): void
81
+ readonly riders: readonly Rider[]
82
+ /** Where riders have just died, for painting. */
83
+ readonly sparks: readonly Fading[]
84
+ /** Advance one tick. `pointer` is in cell coordinates, or null if absent. */
85
+ step(pointer: Cell | null): void
86
+ /** The wall, for painting. */
87
+ readonly walls: readonly Fading[]
88
+ }
89
+
90
+ export interface FieldOptions {
91
+ cols: number
92
+ riders: number
93
+ rows: number
94
+ seed: number
95
+ /** Cells a trail keeps before the oldest expires. */
96
+ trail: number
97
+ }
98
+
99
+ /** A floor so a sliver of a window never empties the field completely. */
100
+ const MIN_FIELD_RIDERS = 1
101
+
102
+ const CARDINALS: readonly Cell[] = [
103
+ { x: 1, y: 0 },
104
+ { x: 0, y: 1 },
105
+ { x: -1, y: 0 },
106
+ { x: 0, y: -1 },
107
+ ]
108
+
109
+ /**
110
+ * How far apart two cells have to be before the field gives them unrelated
111
+ * directions. Smaller means longer straights and lazier sweeps; larger turns
112
+ * the whole thing into confetti. This is the number to reach for first if the
113
+ * ornament ever needs retuning.
114
+ */
115
+ const FIELD_SCALE = 0.055
116
+
117
+ /**
118
+ * How far apart the two samples that make up a flow vector are taken.
119
+ *
120
+ * The field's direction is built from two noise reads rather than by turning
121
+ * one read into an angle, and this is what keeps the second read from simply
122
+ * echoing the first. Any large non-integer offset does; it stays under the
123
+ * noise's 256-unit repeat so the two samples land in genuinely different parts
124
+ * of the same field.
125
+ */
126
+ const FIELD_OFFSET = 137.5
127
+
128
+ /** How far the field slides per tick, so riders cannot settle into a loop. */
129
+ const DRIFT = 0.004
130
+
131
+ /**
132
+ * How much better than carrying straight on another direction has to look
133
+ * before a rider takes it.
134
+ *
135
+ * Without a margin the rider re-decides from scratch every tick and takes
136
+ * whatever is fractionally ahead. The field is smooth, but "which cardinal is
137
+ * closest" is not: it flips every time the flow crosses 45°, and because
138
+ * turning changes which axis the rider is sampling along, the flip feeds back
139
+ * on itself. Riders came out shimmering down diagonal staircases, turning
140
+ * every 1.7 cells.
141
+ *
142
+ * Deliberately above 1, which is the largest dot product a unit flow vector
143
+ * can offer. That makes the rule "drive on until the field actively disagrees"
144
+ * rather than "take the best option each tick". The pointer can still overrule
145
+ * it, because `POINTER_PUSH` is larger again.
146
+ *
147
+ * 1.1 was not enough, and the margin turns out to have a cliff in it. A rider
148
+ * only reverses its opinion when the flow has swung round behind it, and in
149
+ * that regime the straight-on score has already been dragged near zero by its
150
+ * own negative dot product, so the margin is no longer protecting anything and
151
+ * the choice goes back to being decided by which side of 45° the flow fell.
152
+ * Riders turned every 4.4 cells rather than the 8 this comment used to claim,
153
+ * and a 22-cell trail folded into a knot: measured over 3000 ticks, 41% of
154
+ * trails crossed themselves and 28% fitted inside six cells square, which is
155
+ * what reads as a rider stuck in a box.
156
+ *
157
+ * At 1.4 both go to roughly zero (1% and 0.1%) and the run length lands at 15
158
+ * cells, one or two corners in a trail. The cliff is sharp on both sides: 1.3
159
+ * still knots 7% of trails, and by 1.5 the field cannot turn a rider at all
160
+ * and every one of them ends up orbiting the frame.
161
+ */
162
+ const TURN_MARGIN = 1.4
163
+
164
+ /** Cells from the pointer at which its influence has fallen to nothing. */
165
+ const POINTER_REACH = 9
166
+
167
+ /**
168
+ * How far from a wall the wall starts pushing back, and how hard.
169
+ *
170
+ * Turning riders along the edge rather than wrapping them keeps their trails
171
+ * whole, but it has a second-order cost: the turn leaves a rider travelling
172
+ * parallel to the wall one cell from it, `TURN_MARGIN` then commits it to that
173
+ * heading, and the flow that drove it into the wall is still pointing outward
174
+ * where it now stands. So it runs the whole length of the frame. Riders spent
175
+ * a third of their time on literal edge cells, against the 7% you would get by
176
+ * scattering them at random, and the ornament grew a bright border.
177
+ *
178
+ * A soft shove inward, shaped exactly like the pointer's and summed into the
179
+ * same vector, fixes it without a special case in the steering: the wall is
180
+ * just another thing the flow has an opinion about. Below the field's own unit
181
+ * strength, so it biases rather than dictates, and it takes edge dwelling to
182
+ * 13%. Deliberately below `POINTER_PUSH`, so a pointer can still hold a rider
183
+ * against a wall if someone wants to.
184
+ *
185
+ * The reach is clamped to a third of each axis so that on a narrow phone the
186
+ * two opposing walls do not squeeze every rider into a single lane; where they
187
+ * do overlap they cancel, which leaves the middle of the grid alone.
188
+ */
189
+ const WALL_REACH = 8
190
+
191
+ const WALL_PUSH = 0.9
192
+
193
+ /**
194
+ * Ticks a drawn wall cell survives. About eight seconds at the ornament's tick,
195
+ * which is long enough to catch riders that were nowhere near when the line was
196
+ * drawn, and short enough that a reader who scribbled over the whole viewport
197
+ * gets their field back without having to reload the page.
198
+ */
199
+ const WALL_LIFE = 60
200
+
201
+ /** Ticks the mark at a kill survives. Long enough to be read as a death. */
202
+ const SPARK_LIFE = 9
203
+
204
+ /**
205
+ * The soft field that keeps riders off each other, and how far it reaches.
206
+ *
207
+ * One smooth field steering everything is the whole idea, and the cost of the
208
+ * idea is that riders who find each other agree from then on: they sample
209
+ * almost the same noise, so they make the same decision, and nothing pushed
210
+ * them apart because they pass through each other. Committing riders to longer
211
+ * runs made it worse, since two that align then stay aligned. Measured, riders
212
+ * sat 3.3 cells from their nearest neighbour where random scattering gives 7.5,
213
+ * and 62% of ticks a rider was within two cells of another on the same heading,
214
+ * which is two riders drawn on top of each other reading as one.
215
+ *
216
+ * So riders repel each other, summed into the same vector as the walls and the
217
+ * pointer rather than as a special case. The point is a force field, not a grid:
218
+ * a `smoothstep` falloff that barely registers at arm's length and firms up only
219
+ * as two riders close, so they glide apart instead of snapping to a spacing. Held
220
+ * gently on purpose. A hard push held the pair-up near zero, which looked like
221
+ * riders obeying a lattice; this leaves it near 4%, so they still drift together
222
+ * now and then and part again, which is what a field does and a rule does not.
223
+ *
224
+ * Weaker than `WALL_PUSH`, which is weaker than `POINTER_PUSH`: a rider yields
225
+ * to the edge of the screen, and both yield to the reader.
226
+ */
227
+ const SWARM_REACH = 6
228
+
229
+ const SWARM_PUSH = 0.5
230
+
231
+ /**
232
+ * How hard the pointer shoves, relative to the field's unit-length vector.
233
+ * Above the field's own strength on purpose: at the pointer's centre the shove
234
+ * has to be able to overrule the field outright, or riders drive through it
235
+ * and the interaction reads as a coincidence rather than a response.
236
+ */
237
+ const POINTER_PUSH = 2.6
238
+
239
+ export function createField(options: FieldOptions): Field {
240
+ const noise = makeNoise2(options.seed)
241
+ const random = makeRandom(options.seed ^ 0x5f3d)
242
+
243
+ let cols = Math.max(1, options.cols)
244
+ let rows = Math.max(1, options.rows)
245
+ let drift = 0
246
+
247
+ const riders: Rider[] = Array.from({ length: options.riders }, (_, index) =>
248
+ spawnRider(index, random, cols, rows),
249
+ )
250
+
251
+ const walls = new Map<string, number>()
252
+ const sparks = new Map<string, number>()
253
+
254
+ const fade = (entries: Map<string, number>, span: number): Fading[] =>
255
+ Array.from(entries, ([at, left]) => ({ cell: unkey(at), life: left / span }))
256
+
257
+ return {
258
+ draw(from, to) {
259
+ const target = clamp(round(to), cols, rows)
260
+ const start = from ? clamp(round(from), cols, rows) : target
261
+ const span = Math.max(Math.abs(target.x - start.x), Math.abs(target.y - start.y))
262
+
263
+ for (let i = 0; i <= span; i++) {
264
+ const at = span === 0 ? target : lerp(start, target, i / span)
265
+ walls.set(key(at), WALL_LIFE)
266
+ }
267
+ },
268
+
269
+ resize(nextCols, nextRows, nextRiders) {
270
+ cols = Math.max(1, nextCols)
271
+ rows = Math.max(1, nextRows)
272
+
273
+ if (nextRiders != null) {
274
+ while (riders.length > nextRiders && riders.length > MIN_FIELD_RIDERS) riders.pop()
275
+ while (riders.length < nextRiders) {
276
+ riders.push(spawnRider(riders.length, random, cols, rows))
277
+ }
278
+ }
279
+
280
+ for (const rider of riders) {
281
+ if (rider.cell.x < cols && rider.cell.y < rows) continue
282
+ rider.cell = clamp(rider.cell, cols, rows)
283
+ rider.trail.length = 0
284
+ rider.trail.push(rider.cell)
285
+ }
286
+ },
287
+ riders,
288
+
289
+ get sparks() {
290
+ return fade(sparks, SPARK_LIFE)
291
+ },
292
+
293
+ step(pointer) {
294
+ drift += DRIFT
295
+ expire(walls)
296
+ expire(sparks)
297
+
298
+ for (const rider of riders) {
299
+ rider.direction = steer(noise, rider, riders, drift, pointer, cols, rows)
300
+ const next = {
301
+ x: rider.cell.x + rider.direction.x,
302
+ y: rider.cell.y + rider.direction.y,
303
+ }
304
+
305
+ if (walls.has(key(next))) {
306
+ sparks.set(key(next), SPARK_LIFE)
307
+ respawn(rider, random, cols, rows, walls)
308
+ continue
309
+ }
310
+
311
+ rider.cell = next
312
+ rider.trail.push(rider.cell)
313
+ if (rider.trail.length > options.trail) rider.trail.shift()
314
+ }
315
+ },
316
+
317
+ get walls() {
318
+ return fade(walls, WALL_LIFE)
319
+ },
320
+ }
321
+ }
322
+
323
+ /**
324
+ * The field's direction at the rider, as one of the four cardinals.
325
+ *
326
+ * Three things narrow the choice, in order. Only cells on the grid are
327
+ * candidates, so the edge is a wall a rider turns along rather than something
328
+ * it drives through, and the wall leans on the flow as it gets close so that
329
+ * turning along it is not the same as sticking to it. Reversing is rejected
330
+ * the way the real world rejects it,
331
+ * unless a rider has driven into a corner and reversing is the only move left,
332
+ * because the alternative is a rider frozen against a wall forever. And
333
+ * carrying straight on gets a bonus, which is the hysteresis that makes a
334
+ * light cycle commit to a run instead of shimmering along a diagonal.
335
+ */
336
+ function steer(
337
+ noise: Noise2,
338
+ rider: Rider,
339
+ riders: readonly Rider[],
340
+ drift: number,
341
+ pointer: Cell | null,
342
+ cols: number,
343
+ rows: number,
344
+ ): Cell {
345
+ const nx = rider.cell.x * FIELD_SCALE + drift * 0.3
346
+ const ny = rider.cell.y * FIELD_SCALE + drift
347
+
348
+ let flowX = noise(nx, ny)
349
+ let flowY = noise(nx + FIELD_OFFSET, ny + FIELD_OFFSET)
350
+ const length = Math.hypot(flowX, flowY) || 1
351
+ flowX /= length
352
+ flowY /= length
353
+
354
+ const wallX = Math.min(WALL_REACH, Math.floor(cols / 3))
355
+ const wallY = Math.min(WALL_REACH, Math.floor(rows / 3))
356
+ flowX += wall(rider.cell.x, wallX) - wall(cols - 1 - rider.cell.x, wallX)
357
+ flowY += wall(rider.cell.y, wallY) - wall(rows - 1 - rider.cell.y, wallY)
358
+
359
+ for (const other of riders) {
360
+ if (other === rider) continue
361
+ const awayX = rider.cell.x - other.cell.x
362
+ const awayY = rider.cell.y - other.cell.y
363
+ const distance = Math.hypot(awayX, awayY)
364
+ if (distance >= SWARM_REACH) continue
365
+
366
+ const t = 1 - distance / SWARM_REACH
367
+ const strength = t * t * (3 - 2 * t) * SWARM_PUSH
368
+ const spread = Math.max(distance, 0.5)
369
+ flowX += (awayX / spread) * strength
370
+ flowY += (awayY / spread) * strength
371
+ }
372
+
373
+ if (pointer) {
374
+ const awayX = rider.cell.x - pointer.x
375
+ const awayY = rider.cell.y - pointer.y
376
+ const distance = Math.hypot(awayX, awayY)
377
+
378
+ if (distance < POINTER_REACH) {
379
+ const strength = ((POINTER_REACH - distance) / POINTER_REACH) * POINTER_PUSH
380
+ const spread = Math.max(distance, 0.5)
381
+ flowX += (awayX / spread) * strength
382
+ flowY += (awayY / spread) * strength
383
+ }
384
+ }
385
+
386
+ const onGrid = CARDINALS.filter((candidate) => {
387
+ const x = rider.cell.x + candidate.x
388
+ const y = rider.cell.y + candidate.y
389
+ return x >= 0 && x < cols && y >= 0 && y < rows
390
+ })
391
+
392
+ const forward = onGrid.filter(
393
+ (candidate) => !(candidate.x === -rider.direction.x && candidate.y === -rider.direction.y),
394
+ )
395
+ const pool = forward.length > 0 ? forward : onGrid
396
+
397
+ let best: Cell | null = null
398
+ let bestDot = -Infinity
399
+
400
+ for (const candidate of pool) {
401
+ const straight = candidate.x === rider.direction.x && candidate.y === rider.direction.y
402
+ const dot = candidate.x * flowX + candidate.y * flowY + (straight ? TURN_MARGIN : 0)
403
+ if (dot > bestDot) {
404
+ bestDot = dot
405
+ best = candidate
406
+ }
407
+ }
408
+
409
+ return best ?? rider.direction
410
+ }
411
+
412
+ /**
413
+ * Riders are placed from the seeded PRNG, so their starting positions are as
414
+ * fixed as the field they drive through and the page opens the same way every
415
+ * time. Not from a second noise field, which was the first attempt: noise
416
+ * values cluster around zero, and twelve riders placed that way all start in a
417
+ * knot in the middle of the screen with most of it left empty.
418
+ */
419
+ function spawnRider(index: number, random: () => number, cols: number, rows: number): Rider {
420
+ const cell = { x: Math.floor(random() * cols), y: Math.floor(random() * rows) }
421
+
422
+ return {
423
+ cell,
424
+ direction: CARDINALS[index % CARDINALS.length]!,
425
+ slot: index % 10,
426
+ trail: [cell],
427
+ }
428
+ }
429
+
430
+ const key = (cell: Cell): string => `${cell.x},${cell.y}`
431
+
432
+ function unkey(at: string): Cell {
433
+ const [x, y] = at.split(",")
434
+ return { x: Number(x), y: Number(y) }
435
+ }
436
+
437
+ const round = (cell: Cell): Cell => ({ x: Math.round(cell.x), y: Math.round(cell.y) })
438
+
439
+ const lerp = (from: Cell, to: Cell, t: number): Cell => ({
440
+ x: Math.round(from.x + (to.x - from.x) * t),
441
+ y: Math.round(from.y + (to.y - from.y) * t),
442
+ })
443
+
444
+ function expire(entries: Map<string, number>): void {
445
+ for (const [at, left] of entries) {
446
+ if (left <= 1) entries.delete(at)
447
+ else entries.set(at, left - 1)
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Put a killed rider back somewhere else, trail and all.
453
+ *
454
+ * It keeps its slot, so the colours on screen stay the same set rather than
455
+ * churning every time somebody draws a line. The retries are to avoid dropping
456
+ * it straight back onto the wall that just killed it; if a reader has covered
457
+ * the whole viewport there may be nowhere clean left, in which case it spawns
458
+ * anyway and dies again next tick, which is a fair thing to happen to someone
459
+ * who scribbled over the whole viewport.
460
+ */
461
+ function respawn(
462
+ rider: Rider,
463
+ random: () => number,
464
+ cols: number,
465
+ rows: number,
466
+ walls: Map<string, number>,
467
+ ): void {
468
+ let cell = { x: 0, y: 0 }
469
+ for (let attempt = 0; attempt < 8; attempt++) {
470
+ cell = { x: Math.floor(random() * cols), y: Math.floor(random() * rows) }
471
+ if (!walls.has(key(cell))) break
472
+ }
473
+
474
+ rider.cell = cell
475
+ rider.direction = CARDINALS[Math.floor(random() * CARDINALS.length)] ?? CARDINALS[0]!
476
+ rider.trail.length = 0
477
+ rider.trail.push(cell)
478
+ }
479
+
480
+ /** One wall's inward shove on a rider `distance` cells away from it. */
481
+ function wall(distance: number, reach: number): number {
482
+ if (reach <= 0 || distance >= reach) return 0
483
+ return ((reach - distance) / reach) * WALL_PUSH
484
+ }
485
+
486
+ function clamp(cell: Cell, cols: number, rows: number): Cell {
487
+ return {
488
+ x: Math.min(Math.max(cell.x, 0), cols - 1),
489
+ y: Math.min(Math.max(cell.y, 0), rows - 1),
490
+ }
491
+ }