@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.
package/src/swarm.ts ADDED
@@ -0,0 +1,587 @@
1
+ /**
2
+ * A flock of thousands, once in a while.
3
+ *
4
+ * Kept free of the DOM the way the rest of the ornament is. It is not a shoal
5
+ * and it is not a second `shoal.ts`: the fish in that file are steered one at a
6
+ * time and each of them has an opinion, which is what makes a dozen of them
7
+ * read as animals. Do that to a thousand and two things happen, both bad. It
8
+ * costs a thousand times as much, and it looks wrong, because a bait ball is
9
+ * not a thousand fish deciding. It is one thing with a thousand fish in it.
10
+ *
11
+ * ## What the eye is actually reading
12
+ *
13
+ * Not the fish. At the size a fish in a flock is drawn, nobody can follow one,
14
+ * and anybody watching one of these is reading three things instead:
15
+ *
16
+ * * **The shape.** A ball, a ceiling, a ribbon. It is the shape that says how
17
+ * many there are, and it is the shape that turns.
18
+ * * **The grain.** Fish at slightly different distances, so the mass has a
19
+ * front and a back rather than being a stipple. This is most of why marks
20
+ * that could be counted read as thousands: the eye gives up counting the
21
+ * moment they stop lying on one plane.
22
+ * * **What happens when something goes through it.** A flock opens and closes
23
+ * again. That is the whole reason a flock is worth having in a scene that
24
+ * already has fish in it.
25
+ *
26
+ * So a fish here has no opinions at all. It has a place in the flock's own
27
+ * frame, it holds it, and everything it appears to decide is the flock turning
28
+ * or something pushing it aside. `HOMED` is what brings it back.
29
+ *
30
+ * ## How many marks the mass is made of
31
+ *
32
+ * As many as it takes to read as packed, which is more than it used to be. The
33
+ * count was a few hundred while the reason for it was the frame: a renderer
34
+ * draws one mark per fish, and a desktop background does not get to spend a
35
+ * frame on scenery. Timed in the wgpu renderer, three thousand specks cost
36
+ * about a millisecond of a twenty-three millisecond frame, so the frame was
37
+ * never the thing holding the number down, and a few hundred marks spread over
38
+ * a mass tens of metres across is not a flock. It is a sprinkle with a shape.
39
+ *
40
+ * So the count follows from the mass instead. Each shape says how tightly it
41
+ * packs and how big it is, and how many fish are in it falls out of the two,
42
+ * which is why a ceiling is thousands of marks and a ball is a fraction of
43
+ * that while being the denser of the two. The pool a renderer hands over is
44
+ * still a ceiling on all of it.
45
+ *
46
+ * ## Why a speck does not hold its drawing
47
+ *
48
+ * Everything else in this water that costs a renderer real money holds its last
49
+ * drawing until it has moved far enough to be worth another one, and the flock
50
+ * is the layer where that buys nothing. Nearly every speck travels past a
51
+ * quarter of a pixel between frames, and most of them past a whole one, and a
52
+ * whole pixel on a mark a few px long is a mark the eye can watch stepping. A
53
+ * flock is the one thing down here that never stops, which is the whole of what
54
+ * it is for.
55
+ *
56
+ * So what a speck wants is not fewer frames of itself. It is fewer of itself in
57
+ * the murk, where nobody could make one out anyway, and that is a different
58
+ * change from this one.
59
+ * `docs/decisions/0027-let-an-element-decide-when-its-drawing-is-stale.md` is
60
+ * the contract this is the exception to, and the exception is written here so
61
+ * that reading it costs less than measuring it again.
62
+ *
63
+ * ## Rarer than anything that swims and commoner than a boat
64
+ *
65
+ * It sits between the two clocks the ornament already keeps. `visitors.ts` is
66
+ * minutes and `passers.ts` is days, and this is a long stretch of minutes: often
67
+ * enough that somebody who keeps a desktop uncovered will meet one, seldom
68
+ * enough that it is the thing they mention.
69
+ */
70
+
71
+ import type { Biome } from "./biome.ts"
72
+ import { makeNoise2, makeRandom } from "./perlin.ts"
73
+ import type { Startle } from "./shoal.ts"
74
+
75
+ export type SwarmShape = "ball" | "ceiling" | "ribbon"
76
+
77
+ /** One fish of a flock, which is a mark rather than an animal. */
78
+ export interface Speck {
79
+ /** 0 is as far off as this water goes, 1 is up against the glass. */
80
+ depth: number
81
+ /** How long it is drawn, in px, with the distance already in it. */
82
+ size: number
83
+ /** Which way it is pointing, in radians. It is going that way. */
84
+ tilt: number
85
+ x: number
86
+ y: number
87
+ }
88
+
89
+ export interface Swarm {
90
+ resize(width: number, height: number, count?: number): void
91
+ /** What the flock is at the moment, for a renderer that wants to know. */
92
+ readonly shape: SwarmShape
93
+ readonly specks: readonly Speck[]
94
+ /**
95
+ * Advance by `seconds`. `startle` is whatever is going through the flock,
96
+ * which is the only thing here that is not the flock's own business.
97
+ */
98
+ step(seconds: number, startle?: Startle | null): void
99
+ /** How loud the flock is now, 0 to 1. Nothing at all while there is none. */
100
+ readonly weight: number
101
+ }
102
+
103
+ export interface SwarmOptions {
104
+ /** How many fish. A renderer's ceiling, not a count of anything real. */
105
+ count: number
106
+ /** Come round one after another with no rest. For previews and for tests. */
107
+ eager?: boolean
108
+ height: number
109
+ seed: number
110
+ shapes?: readonly SwarmShape[]
111
+ /**
112
+ * The water this is in, so that whatever lives down there can see it.
113
+ *
114
+ * A flock enters as one thing rather than as several hundred, which is how it
115
+ * is seen. What passes over a crab is a shadow the width of the whole ball;
116
+ * offered a fish at a time it would be hundreds of animals none of them
117
+ * bigger than the crab itself, and nothing on the floor would look up.
118
+ */
119
+ water?: Biome
120
+ width: number
121
+ }
122
+
123
+ /**
124
+ * How each shape is laid out, and where it sits in the column.
125
+ *
126
+ * `wide` and `tall` are the flock's own radii as shares of the box's width, and
127
+ * `thick` is the same measure into the screen, which is what gives the mass a
128
+ * front and a back. A ball is nearly as deep as it is wide because it is a
129
+ * ball; a ceiling is broad, shallow and deep, because what it is is the
130
+ * underside of a shoal spread out over you.
131
+ *
132
+ * The ceiling is the one worth the trouble. A ball is a shape in the water with
133
+ * you and a ceiling is a shape you are under, which is the difference between
134
+ * watching a flock and being in one. It seats high and reaches past both edges
135
+ * of the picture on purpose: a ceiling with ends is a cloud.
136
+ */
137
+ interface Layout {
138
+ /**
139
+ * How wide the mass really is, in metres.
140
+ *
141
+ * What every fish in it is drawn against, and the only place this scene says
142
+ * how big a flock is out loud. A ball is a room across, a ribbon is a street,
143
+ * and a ceiling is the far side of what anybody can see.
144
+ */
145
+ readonly across: number
146
+ /** How fast the whole mass turns over, in turns a second. */
147
+ readonly churn: number
148
+ /**
149
+ * How many fish stand over a square metre of the face this shape shows.
150
+ *
151
+ * A share of the pool was the wrong statement, because it made the mark
152
+ * count independent of `across`: widen a ceiling from a street to a horizon
153
+ * and the same marks were asked to cover it, so the mass thinned and nothing
154
+ * said so. This is the same fact `census.ts` keeps for the sea, told about
155
+ * one animal in one shape.
156
+ *
157
+ * It is a per-shape number rather than one number in metres, because how
158
+ * tightly fish pack is what separates these shapes. A ball is a knot they
159
+ * have tied against something hunting them and it is the densest thing in
160
+ * this water; a ceiling is the same animals with nothing after them, spread
161
+ * out over you, and it is loose. Packing a ceiling like a ball would be a
162
+ * ball the size of the sky.
163
+ */
164
+ readonly packed: number
165
+ /** Where in the column it sits, as shares of the height. */
166
+ readonly seatLeast: number
167
+ readonly seatSpan: number
168
+ /** How long it takes to cross, at least and up to that again. */
169
+ readonly takeLeast: number
170
+ readonly takeSpan: number
171
+ readonly tall: number
172
+ readonly thick: number
173
+ readonly wide: number
174
+ }
175
+
176
+ const LAYOUTS: Readonly<Record<SwarmShape, Layout>> = {
177
+ ball: {
178
+ across: 8,
179
+ churn: 0.05,
180
+ packed: 20,
181
+ seatLeast: 0.18,
182
+ seatSpan: 0.34,
183
+ takeLeast: 70,
184
+ takeSpan: 40,
185
+ tall: 0.11,
186
+ thick: 0.11,
187
+ wide: 0.12,
188
+ },
189
+ ceiling: {
190
+ across: 45,
191
+ churn: 0.015,
192
+ packed: 11,
193
+ seatLeast: 0.04,
194
+ seatSpan: 0.12,
195
+ takeLeast: 95,
196
+ takeSpan: 50,
197
+ tall: 0.1,
198
+ thick: 0.3,
199
+ wide: 0.75,
200
+ },
201
+ ribbon: {
202
+ across: 22,
203
+ churn: 0.03,
204
+ packed: 13,
205
+ seatLeast: 0.2,
206
+ seatSpan: 0.36,
207
+ takeLeast: 60,
208
+ takeSpan: 34,
209
+ tall: 0.05,
210
+ thick: 0.1,
211
+ wide: 0.36,
212
+ },
213
+ }
214
+
215
+ /** How far past each side the flock starts and finishes, as shares of width. */
216
+ const OFFING = 0.55
217
+
218
+ /** How much of a crossing is spent fading in, and the same again fading out. */
219
+ const FADE = 0.12
220
+
221
+ /**
222
+ * Watched seconds between one flock leaving and the next arriving.
223
+ *
224
+ * Longer than a visitor's rest by a good deal, and this is the number that
225
+ * decides whether the thing is an event or a feature of the water. Anything
226
+ * that comes round inside a sitting stops being thousands of fish and becomes
227
+ * the fish animation.
228
+ */
229
+ const REST_LEAST = 420
230
+
231
+ const REST_SPAN = 620
232
+
233
+ /** How much of that rest the water waits before the first flock of a sitting. */
234
+ const OPENING = 0.4
235
+
236
+ /** How far back the flock as a whole swims, and how much of that is its depth. */
237
+ const DEEP_LEAST = 0.3
238
+
239
+ const DEEP_SPAN = 0.5
240
+
241
+ const DEPTH_SIZE = 0.7
242
+
243
+ /**
244
+ * How long one fish is, in metres, and how much longer or shorter than that
245
+ * any one of them may be.
246
+ *
247
+ * An anchovy, which is what a bait ball is made of. It is the one animal in
248
+ * this ornament that is not sized by `sizes.ts`, and the reason is worth
249
+ * writing down: everything else is a single body somewhere in the water, and a
250
+ * flock is a body made of bodies. The root that keeps a shark from swamping a
251
+ * reef fish would put a fish the size of your hand in a ball the size of a bus,
252
+ * which is a ball of about nine fish.
253
+ *
254
+ * So the flock is the yardstick instead. A fish is drawn at its own length
255
+ * against the mass it is in, which is honest for the same reason a clownfish is
256
+ * measured against its anemone in `nemos.ts`: the two are at one distance, so
257
+ * nothing is being compressed between them.
258
+ */
259
+ const FISH_LONG = 0.11
260
+
261
+ const FISH_ODD = 0.22
262
+
263
+ /**
264
+ * How far a fish wanders inside the flock, and how fast.
265
+ *
266
+ * Small, and it has to be. A fish that wanders far is a fish somebody can
267
+ * follow, and a mass whose parts can be followed is not a mass. What this is
268
+ * for is that a flock holding perfectly still inside itself reads as a pattern
269
+ * printed on a moving card.
270
+ */
271
+ const FIDGET = 0.055
272
+
273
+ const FIDGET_RATE = 0.55
274
+
275
+ /** How many features of the fidget field fit across one flock. */
276
+ const FIDGET_CELLS = 2.6
277
+
278
+ /**
279
+ * How hard a flock is pushed by something coming through it, and how far the
280
+ * push reaches past what pushed it.
281
+ *
282
+ * A flock is the one thing in this water that answers a disturbance visibly,
283
+ * because it is the only thing with enough parts for the answer to have a
284
+ * shape: the hole a shark leaves is a picture of the shark.
285
+ *
286
+ * What it must not be is a rout. A real bait ball harried by something is torn
287
+ * to pieces, and it is a picture about being eaten; this water is not that. So
288
+ * the push is a dent that the mass closes over rather than a hole through it,
289
+ * which is what `HOMED` is doing on the other side of the same sum.
290
+ */
291
+ const SHOVE = 0.85
292
+
293
+ const SHOVE_REACH = 1
294
+
295
+ /** How fast a pushed fish comes back to its place, per second. */
296
+ const HOMED = 1.2
297
+
298
+ /** How much of a flock has to have arrived before the water can see it. */
299
+ const SEEN = 0.05
300
+
301
+ /** How far a mark may be turned by what it is doing, at most. */
302
+ const TILT_EASE = 3.5
303
+
304
+ const MIN_SPAN = 1
305
+
306
+ /**
307
+ * How many marks a shape is made of, off its own size and its own packing.
308
+ *
309
+ * The face it shows, in metres: `across` wide, and as tall as `tall` is against
310
+ * `wide`, since the two are shares of one number and their ratio is the shape's
311
+ * own. What is behind that face is not counted, which is why `packed` is a
312
+ * density on a face rather than through a volume: a ceiling is deeper than a
313
+ * ball and would come out the denser of the two, and it is the looser one.
314
+ */
315
+ function marks(layout: Layout): number {
316
+ return Math.round(layout.packed * layout.across * layout.across * (layout.tall / layout.wide))
317
+ }
318
+
319
+ /**
320
+ * The flock of a piece of water.
321
+ *
322
+ * `eager` throws the rest away and brings one round after another, for the
323
+ * preview harness and for tests. Nothing should pass it in earnest.
324
+ */
325
+ export function createSwarm(options: SwarmOptions): Swarm {
326
+ const random = makeRandom(options.seed ^ 0x5f0c)
327
+ const field = makeNoise2(options.seed ^ 0x1caa)
328
+ const shapes = options.shapes ?? (["ball", "ceiling", "ribbon"] as const)
329
+ const eager = options.eager ?? false
330
+
331
+ let width = Math.max(MIN_SPAN, options.width)
332
+ let height = Math.max(MIN_SPAN, options.height)
333
+ let count = Math.max(0, Math.round(options.count))
334
+
335
+ /**
336
+ * The pool, and the part of it this flock is using.
337
+ *
338
+ * `specks` holds the same objects rather than copies of them, so a renderer
339
+ * reading it and this file moving them are looking at one set of marks.
340
+ */
341
+ const pool: Speck[] = []
342
+
343
+ const specks: Speck[] = []
344
+
345
+ /** Where each one lives in the flock's own frame, and how it is pushed off it. */
346
+ const homes: {
347
+ /** How long this one is, before the distance is taken out of it. */
348
+ long: number
349
+ pushX: number
350
+ pushY: number
351
+ u: number
352
+ v: number
353
+ w: number
354
+ }[] = []
355
+
356
+ /** What is crossing: its shape, its course, and how far through it is. */
357
+ let shape: SwarmShape = "ball"
358
+ let along = 1
359
+ let deep = 1
360
+ let facing = 1
361
+ let seat = 0
362
+ let spin = 0
363
+ let take = 1
364
+ let weight = 0
365
+
366
+ /** Watched seconds still owed before the next flock arrives. */
367
+ let rest = (REST_LEAST + random() * REST_SPAN) * OPENING
368
+
369
+ /** The clock the fidget field is read against. */
370
+ let drift = 0
371
+
372
+ // The whole flock as one presence, measured off where its fish actually are
373
+ // rather than off where it was aimed. A ball that something has just gone
374
+ // through is wider than the ball that set off, and how wide it looks is the
375
+ // only thing anything reading this cares about.
376
+ options.water?.enter(() => {
377
+ const first = specks[0]
378
+ if (weight <= SEEN || !first) return []
379
+
380
+ let left = first.x
381
+ let right = left
382
+ let top = first.y
383
+ let bottom = top
384
+ let far = 0
385
+
386
+ for (const one of specks) {
387
+ if (one.x < left) left = one.x
388
+ if (one.x > right) right = one.x
389
+ if (one.y < top) top = one.y
390
+ if (one.y > bottom) bottom = one.y
391
+ far += one.depth
392
+ }
393
+
394
+ return [
395
+ {
396
+ depth: far / specks.length,
397
+ size: right - left,
398
+ x: (left + right) / 2,
399
+ y: (top + bottom) / 2,
400
+ },
401
+ ]
402
+ })
403
+
404
+ /**
405
+ * A place in the flock, drawn as a point of a solid rather than of a disc.
406
+ *
407
+ * The cube root is what makes it solid. Drawn straight off three rolls the
408
+ * points bunch in the middle and the mass has a hot core with a haze round
409
+ * it, which is a nebula. Taking the radius to the third root spreads them
410
+ * evenly through the volume, so the flock has a skin and the eye can find its
411
+ * edge, which is the thing it is reading the shape off.
412
+ */
413
+ function place(): { u: number; v: number; w: number } {
414
+ const u = random() * 2 - 1
415
+ const v = random() * 2 - 1
416
+ const w = random() * 2 - 1
417
+ const reach = Math.hypot(u, v, w) || 1
418
+ const full = random() ** (1 / 3)
419
+
420
+ return { u: (u / reach) * full, v: (v / reach) * full, w: (w / reach) * full }
421
+ }
422
+
423
+ function fill(): void {
424
+ pool.length = 0
425
+ specks.length = 0
426
+ homes.length = 0
427
+
428
+ for (let made = 0; made < count; made++) {
429
+ homes.push({
430
+ long: 1 - FISH_ODD + random() * FISH_ODD * 2,
431
+ pushX: 0,
432
+ pushY: 0,
433
+ ...place(),
434
+ })
435
+ pool.push({ depth: 1, size: 1, tilt: 0, x: 0, y: 0 })
436
+ }
437
+ }
438
+
439
+ function arrive(): void {
440
+ shape = shapes[Math.floor(random() * shapes.length)] ?? "ball"
441
+ const layout = LAYOUTS[shape]
442
+
443
+ along = 0
444
+ deep = DEEP_LEAST + random() * DEEP_SPAN
445
+ facing = random() < 0.5 ? -1 : 1
446
+ seat = height * (layout.seatLeast + random() * layout.seatSpan)
447
+ spin = random() * Math.PI * 2
448
+ take = layout.takeLeast + random() * layout.takeSpan
449
+ weight = 0
450
+
451
+ for (const home of homes) {
452
+ home.pushX = 0
453
+ home.pushY = 0
454
+ }
455
+
456
+ specks.length = 0
457
+ const using = Math.min(pool.length, marks(layout))
458
+ for (let at = 0; at < using; at++) {
459
+ const one = pool[at]
460
+ if (one) specks.push(one)
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Move every fish to where the flock has it now.
466
+ *
467
+ * One pass, and everything a fish is doing is in it: its place in the mass,
468
+ * the mass turning, its own small fidget, and whatever is shoving it. There
469
+ * is nothing here that reads another fish, which is the whole reason a flock
470
+ * of this size is affordable at all.
471
+ */
472
+ function carry(dt: number, middle: number, startle: Startle | null): void {
473
+ const layout = LAYOUTS[shape]
474
+ const wide = width * layout.wide
475
+ const tall = width * layout.tall
476
+ const thick = layout.thick
477
+ // What one fish's length comes to, drawn: the mass is so many metres
478
+ // across and so many pixels across, and a fish is its own length of that.
479
+ const mark = ((wide * 2) / layout.across) * FISH_LONG
480
+ const cos = Math.cos(spin)
481
+ const sin = Math.sin(spin)
482
+ const fall = HOMED * dt
483
+
484
+ for (let at = 0; at < specks.length; at++) {
485
+ const home = homes[at]
486
+ const one = specks[at]
487
+ if (!home || !one) continue
488
+
489
+ // The mass turns about its own middle, which is what a bait ball does and
490
+ // the one motion that reads at this size. It is the same turn for every
491
+ // fish in it, so the shape is preserved and the parts are not.
492
+ const u = home.u * cos - home.w * sin
493
+ const w = home.u * sin + home.w * cos
494
+
495
+ const wobbleX = field(home.u * FIDGET_CELLS + drift, home.v * FIDGET_CELLS)
496
+ const wobbleY = field(home.v * FIDGET_CELLS, home.u * FIDGET_CELLS - drift)
497
+
498
+ const wantX = middle + (u + wobbleX * FIDGET) * wide
499
+ const wantY = seat + (home.v + wobbleY * FIDGET) * tall
500
+
501
+ if (startle && startle.force > 0) {
502
+ const offX = wantX + home.pushX - startle.x
503
+ const offY = wantY + home.pushY - startle.y
504
+ const gap = Math.hypot(offX, offY)
505
+ const reach = startle.reach * SHOVE_REACH
506
+
507
+ if (gap < reach && gap > 0) {
508
+ const push = ((reach - gap) / reach) * startle.force * SHOVE * dt * reach
509
+ home.pushX += (offX / gap) * push
510
+ home.pushY += (offY / gap) * push
511
+ }
512
+ }
513
+
514
+ home.pushX -= home.pushX * Math.min(1, fall)
515
+ home.pushY -= home.pushY * Math.min(1, fall)
516
+
517
+ const nextX = wantX + home.pushX
518
+ const nextY = wantY + home.pushY
519
+
520
+ // Pointing along whatever it just did, eased, so a fish that is barely
521
+ // moving does not spin. A mark whose angle is read straight off a frame's
522
+ // worth of travel is a mark that flickers.
523
+ const runX = nextX - one.x
524
+ const runY = nextY - one.y
525
+ if (Math.hypot(runX, runY) > 0.01) {
526
+ const want = Math.atan2(runY, runX)
527
+ const turn = Math.atan2(Math.sin(want - one.tilt), Math.cos(want - one.tilt))
528
+ one.tilt += turn * Math.min(1, TILT_EASE * dt)
529
+ }
530
+
531
+ one.depth = Math.max(0.05, Math.min(1, deep + w * thick))
532
+ one.size = home.long * mark * (1 - DEPTH_SIZE + DEPTH_SIZE * one.depth)
533
+ one.x = nextX
534
+ one.y = nextY
535
+ }
536
+ }
537
+
538
+ fill()
539
+
540
+ return {
541
+ resize(nextWidth, nextHeight, nextCount) {
542
+ width = Math.max(MIN_SPAN, nextWidth)
543
+ height = Math.max(MIN_SPAN, nextHeight)
544
+ if (nextCount != null) count = Math.max(0, Math.round(nextCount))
545
+ // Whatever was crossing is dropped, the way a passer is. A flock that
546
+ // changed size halfway over would be a flock that had been counted.
547
+ along = 1
548
+ weight = 0
549
+ fill()
550
+ },
551
+
552
+ get shape() {
553
+ return shape
554
+ },
555
+
556
+ specks,
557
+
558
+ step(seconds, startle = null) {
559
+ const dt = Math.min(Math.max(seconds, 0), 0.1)
560
+ drift += FIDGET_RATE * dt
561
+
562
+ if (along >= 1) {
563
+ weight = 0
564
+ rest -= dt
565
+ if (eager || rest <= 0) {
566
+ arrive()
567
+ rest = REST_LEAST + random() * REST_SPAN
568
+ }
569
+ if (along >= 1) return
570
+ }
571
+
572
+ along += dt / Math.max(take, 0.001)
573
+ spin += LAYOUTS[shape].churn * Math.PI * 2 * dt
574
+ weight = Math.max(0, Math.min(1, along / FADE, (1 - along) / FADE))
575
+
576
+ const from = -OFFING * width
577
+ const to = width * (1 + OFFING)
578
+ const at = Math.min(1, along)
579
+
580
+ carry(dt, facing > 0 ? from + at * (to - from) : to - at * (to - from), startle)
581
+ },
582
+
583
+ get weight() {
584
+ return weight
585
+ },
586
+ }
587
+ }