@codincod/ornament 0.0.0-stage → 0.1.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,611 @@
1
+ /**
2
+ * Everything alive, and everything going over the top of it.
3
+ *
4
+ * The shoal, the clownfish in their anemones, the bait ball, the big animals
5
+ * that cross once in a while, the cephalopods, what walks on the bottom, and
6
+ * the boats. All of it is the ornament's own simulation; what is here is what
7
+ * `Seascape.qml` decides, which is how heavy each of them is drawn and how far
8
+ * back it stands.
9
+ */
10
+ import type { Cephalopods } from "../cephalopods.ts"
11
+ import { octopusArms, octopusHead, squidArms, squidBody } from "../cephalopods.ts"
12
+ import type { Eels } from "../eels.ts"
13
+ import { frameAt, SPAN } from "../fish_shape.ts"
14
+ import {
15
+ armsOf,
16
+ bell,
17
+ combRows,
18
+ HAIR_GIRTH,
19
+ hairOf,
20
+ ARM_GIRTH as JELLY_ARM,
21
+ type Jellies,
22
+ moonMarks,
23
+ sparks,
24
+ washOf,
25
+ } from "../jellies.ts"
26
+ import type { Nemos } from "../nemos.ts"
27
+ import { HULL, type Passers, PING_RINGS, ringAt, SCREWS, SUB_SCREW, SUBMARINE } from "../passers.ts"
28
+ import type { Puffers } from "../puffers.ts"
29
+ import { type Shoal, SPECIES } from "../shoal.ts"
30
+ import type { Swarm } from "../swarm.ts"
31
+ import { BODIES, VEIL_INK, VEILS, type Visitors } from "../visitors.ts"
32
+ import type { Walkers } from "../walkers.ts"
33
+ import { disc, ring } from "./light.ts"
34
+ import { OWN, type Pen, TONE } from "./pen.ts"
35
+ import { trace } from "./trace.ts"
36
+
37
+ /** How dark an animal in the open water may be, and how much of that weight it
38
+ * keeps at the back of it. */
39
+ const OPEN_WATER = 0.22
40
+ const DEPTH_INK = 0.65
41
+
42
+ /**
43
+ * How much further back a veil stands than the animal wearing it.
44
+ *
45
+ * Enough for the depth buffer to settle it and not enough to be a distance. The
46
+ * solid pass takes its turn by how far back a thing stands rather than by the
47
+ * order it arrived in, so two drawings at one depth is two drawings fighting
48
+ * over the same pixels.
49
+ */
50
+ const BEHIND = 0.002
51
+
52
+ /** The rest of the ceilings, each one thing's share of full ink. */
53
+ const CRAWLER_INK = 0.42
54
+ const CRAWLER_LIFT = 0.8
55
+ const CRAWLER_RISEN = 0.62
56
+ const EEL_INK = 0.46
57
+ const PUFFER_INK = 0.44
58
+ const FROTH_INK = 0.3
59
+ const FROTH_LET_GO = 0.45
60
+ const HAZE_INK = 0.04
61
+ const HULL_INK = 0.16
62
+ const JELLY_INK = 0.17
63
+
64
+ /**
65
+ * How much of a jellyfish's own light there is, and how much of it survives a
66
+ * sunlit afternoon.
67
+ *
68
+ * Almost none. A flash is measured against the water around it, so a display
69
+ * that reads across a whole screen at midnight is nothing at all at noon, and
70
+ * the animal has not changed: the water it is being seen against has. What is
71
+ * kept in daylight is a hint, because a jellyfish that went perfectly dark at
72
+ * dawn would be an animal switching off on a schedule.
73
+ */
74
+ const JELLY_LIGHT = 0.62
75
+ const JELLY_DAY = 0.12
76
+
77
+ /**
78
+ * And how much of the light a comb jelly does not make.
79
+ *
80
+ * The rows are a diffraction grating, so what runs along them is the water's
81
+ * own light taken apart, and the rule runs the other way: it is at its best in
82
+ * the sun and there is nothing to take apart at midnight. Two lights on two
83
+ * animals swimming past each other, one needing the day and one needing the
84
+ * dark, which is the sort of thing this water is for.
85
+ */
86
+ const JELLY_SHEEN = 0.5
87
+ const JELLY_NIGHT = 0.15
88
+ const PING_INK = 0.62
89
+ const SPECK_INK = 0.16
90
+ const VISITOR_INK = 0.42
91
+
92
+ /** How much of a weight is punched back out again for an eye or a cut line. */
93
+ const CUT_SHADE = 0.34
94
+
95
+ /** What the bodies are drawn as lines at, in their own units. */
96
+ const ARM_GIRTH = 0.075
97
+ const TENTACLE_GIRTH = 0.035
98
+
99
+ /**
100
+ * How thick an eel is drawn, against how tall it stands.
101
+ *
102
+ * Thin, and the roundness of the cap does the rest. What a colony reads as at
103
+ * this distance is a texture with a grain to it, and the grain is the gaps: a
104
+ * girth that closed them would draw a patch of grass.
105
+ */
106
+ const EEL_GIRTH = 0.12
107
+
108
+ /** How big an eye is against the body it is in. */
109
+ const EYE_SIZE = 0.11
110
+
111
+ /** How many of each are ever drawn at once. */
112
+ const MOST = { froth: 220, passers: 4, specks: 3000, visitors: 6 }
113
+
114
+ /** Where the boats sit, which is over everything in the water. */
115
+ const LANE = { froth: 1.15, passer: 1.2 }
116
+
117
+ /**
118
+ * What a weight is worth at a distance, for anything in the open water.
119
+ *
120
+ * It keeps a share of its weight whatever the distance, because an animal that
121
+ * faded into the murk as far as the ground does is an animal the eye loses in
122
+ * the middle of a stroke.
123
+ */
124
+ function afloat(full: number, depth: number): number {
125
+ return full * (1 - DEPTH_INK + DEPTH_INK * depth)
126
+ }
127
+
128
+ /** And what it is worth for anything standing on the bottom, which does fade
129
+ * the whole way: the murk it is seen through is the ground's. */
130
+ function far(full: number, depth: number): number {
131
+ return HAZE_INK + (full - HAZE_INK) * Math.min(1, Math.max(0, depth))
132
+ }
133
+
134
+ export function paintShoal(pen: Pen, shoal: Shoal): void {
135
+ for (const fish of shoal.fish) {
136
+ const weight = afloat(OPEN_WATER, fish.depth)
137
+ if (weight <= 0.002) continue
138
+
139
+ const frame = frameAt(fish.tail)
140
+ const spot = {
141
+ facing: fish.facing,
142
+ scale: fish.size / SPAN,
143
+ tilt: fish.tilt,
144
+ x: fish.x,
145
+ y: fish.y,
146
+ }
147
+
148
+ // The billed kinds are a whole drawing of their own rather than this one
149
+ // with a bill laid over it. Two fills would put a seam across the
150
+ // swordfish's face wherever the bill's buried root crossed the head.
151
+ pen.fill(
152
+ trace(SPECIES[fish.kind].bill > 0 ? frame.billed : frame.d),
153
+ {
154
+ lane: fish.depth,
155
+ shade: fish.y,
156
+ weight,
157
+ },
158
+ spot,
159
+ )
160
+
161
+ // The eye is punched out in the surface behind it rather than drawn in an
162
+ // ink of its own, which is why a fish reads as a cutout in the water
163
+ // instead of a sticker on it.
164
+ pen.fill(
165
+ [disc(frame.eye.x, frame.eye.y, frame.eye.r)],
166
+ {
167
+ lane: fish.depth,
168
+ shade: fish.y,
169
+ weight: weight * CUT_SHADE,
170
+ },
171
+ spot,
172
+ )
173
+ }
174
+ }
175
+
176
+ /**
177
+ * The clownfish, each in the anemone it lives in.
178
+ *
179
+ * `face` is the drawn width and its sign is the mirror, so the fish squeezes to
180
+ * nothing and opens out the other way, which is what a fish that has stopped
181
+ * actually does. A fish all the way into the tentacles is not drawn at all
182
+ * rather than drawn faintly: it has not become hard to see, it is behind
183
+ * something.
184
+ */
185
+ export function paintNemos(pen: Pen, nemos: Nemos): void {
186
+ for (const nemo of nemos.nemos) {
187
+ const weight = afloat(OPEN_WATER, nemo.host.depth) * (1 - nemo.cover)
188
+ if (weight <= 0.002) continue
189
+
190
+ pen.fill(
191
+ trace(frameAt(nemo.beat).d),
192
+ {
193
+ lane: -3 + nemo.host.depth,
194
+ shade: nemo.y,
195
+ weight,
196
+ },
197
+ {
198
+ facing: nemo.face,
199
+ scale: nemo.length / SPAN,
200
+ tilt: nemo.tilt,
201
+ x: nemo.x,
202
+ y: nemo.y,
203
+ },
204
+ )
205
+ }
206
+ }
207
+
208
+ /**
209
+ * A flock of thousands, which is a few hundred specks and a shape round them.
210
+ *
211
+ * Marks rather than fish. At the size one of these is on the screen a fish
212
+ * drawing is four pixels of nothing, and what makes a bait ball read is that it
213
+ * is a crowd with a body: hundreds of identical small marks, packed, all
214
+ * leaning the same way and all leaning a little differently.
215
+ */
216
+ export function paintSwarm(pen: Pen, flock: Swarm): void {
217
+ let drawn = 0
218
+ for (const speck of flock.specks) {
219
+ if (drawn++ >= MOST.specks) break
220
+
221
+ const weight = afloat(SPECK_INK, speck.depth) * flock.weight
222
+ if (weight <= 0.002) continue
223
+
224
+ const girth = Math.max(0.7, speck.size * 0.42)
225
+ pen.line(
226
+ [
227
+ [
228
+ { x: -speck.size / 2, y: 0 },
229
+ { x: speck.size / 2, y: 0 },
230
+ ],
231
+ ],
232
+ {
233
+ lane: speck.depth,
234
+ shade: speck.y,
235
+ weight,
236
+ width: girth,
237
+ },
238
+ { tilt: speck.tilt, x: speck.x, y: speck.y },
239
+ )
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The big animals: a turtle, a pod of dolphins, a manta, a shark going
245
+ * somewhere, and once in a very long while a thing that is none of those.
246
+ * Heavier than a fish, because what is being read across the whole picture is
247
+ * the shape, and a shape drawn at the shoal's weight is a smudge.
248
+ *
249
+ * One of them is not all body, and `VEILS` in the ornament says which and why.
250
+ * What it hands over is laid in first and a hair behind, so the animal's own
251
+ * outline takes the depth where the two overlap and only what is streaming
252
+ * clear of it is drawn sheer. Both or neither: a visitor drawn without the veil
253
+ * it declares is missing a part of itself rather than drawn more simply.
254
+ */
255
+ export function paintVisitors(pen: Pen, visitors: Visitors): void {
256
+ let drawn = 0
257
+ for (const guest of visitors.crossing) {
258
+ if (drawn++ >= MOST.visitors) break
259
+
260
+ const weight = afloat(VISITOR_INK * guest.weight, guest.depth)
261
+ if (weight <= 0.002) continue
262
+
263
+ const spot = {
264
+ facing: guest.facing,
265
+ scale: guest.size,
266
+ tilt: guest.tilt,
267
+ x: guest.x,
268
+ y: guest.y,
269
+ }
270
+
271
+ const veil = VEILS[guest.kind]?.(guest.stroke)
272
+ if (veil) {
273
+ pen.fill(
274
+ trace(veil),
275
+ {
276
+ lane: guest.depth - BEHIND,
277
+ shade: guest.y,
278
+ weight: weight * VEIL_INK,
279
+ },
280
+ spot,
281
+ )
282
+ }
283
+
284
+ pen.fill(
285
+ trace(BODIES[guest.kind](guest.stroke)),
286
+ {
287
+ lane: guest.depth,
288
+ shade: guest.y,
289
+ weight,
290
+ },
291
+ spot,
292
+ )
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Squid and octopus.
298
+ *
299
+ * A squid jets and drifts, which is the opposite rhythm to a tail. An octopus
300
+ * is on the bottom rather than in the water, except for the once in a long
301
+ * while when one lets go, so it recedes the way the bottom does and it runs
302
+ * between two weights by how far off the floor it is.
303
+ */
304
+ export function paintInklings(pen: Pen, inklings: Cephalopods): void {
305
+ for (const squid of inklings.squids) {
306
+ const weight = afloat(OPEN_WATER, squid.depth)
307
+ if (weight <= 0.002) continue
308
+
309
+ const spot = {
310
+ facing: squid.facing,
311
+ scale: squid.size,
312
+ tilt: squid.tilt,
313
+ x: squid.x,
314
+ y: squid.y,
315
+ }
316
+ const mark = { lane: squid.depth, shade: squid.y, weight }
317
+
318
+ pen.fill(trace(squidBody(squid.squeeze)), mark, spot)
319
+ pen.line(squidArms(squid.squeeze), { ...mark, width: TENTACLE_GIRTH * squid.size }, spot)
320
+ }
321
+
322
+ for (const octopus of inklings.octopuses) {
323
+ const aloft = Math.max(-1, Math.min(1, octopus.lift / CRAWLER_LIFT))
324
+ const bedded = far(CRAWLER_INK, octopus.depth)
325
+ const risen = far(CRAWLER_RISEN, octopus.depth)
326
+
327
+ // Under the sand is under the haze. A buried one is not a faint animal, it
328
+ // is a bump in the ground with an animal somewhere inside it.
329
+ const weight = aloft < 0 ? bedded * (1 + aloft * 0.7) : bedded + (risen - bedded) * aloft
330
+ if (weight <= 0.002) continue
331
+
332
+ const spot = { scale: octopus.size, x: octopus.x, y: octopus.y }
333
+ const mark = { lane: octopus.depth, shade: octopus.y, weight }
334
+
335
+ pen.fill(trace(octopusHead(octopus.breath)), mark, spot)
336
+ pen.line(octopusArms(octopus), { ...mark, width: ARM_GIRTH * octopus.size }, spot)
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The jellyfish, all seven, and one of them is a float and two are not
342
+ * jellyfish at all.
343
+ *
344
+ * Three of the eight make their own light, and none of it is on until
345
+ * something touches them. It is drawn in the moon's colour, since this palette
346
+ * has no blue in it and nothing else in the water glows at all, and it is
347
+ * weighed against the hour: a flash is measured against the water around it,
348
+ * so these are a night thing. The comb jelly's rows are the exception that
349
+ * proves the rule, and they run the other way; see the note on `JELLY_SHEEN`.
350
+ *
351
+ * Lighter than anything else in the water on purpose. A bell is a bag of water
352
+ * inside water: it stops a little of the light and passes the rest, and drawn
353
+ * at a fish's weight it comes out as a cut-out mushroom. What hangs under one
354
+ * is lighter again, at the share `visitors.ts` settled on for a mermaid's
355
+ * hair, and for the reason it gives there: a fine mass scatters light where a
356
+ * body stops it.
357
+ *
358
+ * The order is the animal's own. Whatever hangs goes in first and a hair
359
+ * further back, so the bell takes the depth where the two overlap and only
360
+ * what is streaming clear of it is drawn sheer. Then the marks, punched out of
361
+ * the bell the way a fish's eye is punched out of its head, and last the two
362
+ * kinds that carry a light of their own.
363
+ */
364
+ export function paintJellies(pen: Pen, jellies: Jellies, daylight: number): void {
365
+ const hour = Math.min(1, Math.max(0, daylight))
366
+ // What a made light is worth at this hour, and what a borrowed one is.
367
+ const dark = JELLY_DAY + (1 - JELLY_DAY) * (1 - hour)
368
+ const sun = JELLY_NIGHT + (1 - JELLY_NIGHT) * hour
369
+
370
+ for (const one of jellies.jellies) {
371
+ const weight = afloat(JELLY_INK, one.depth)
372
+ if (weight <= 0.002) continue
373
+
374
+ const spot = {
375
+ facing: one.facing,
376
+ scale: one.size,
377
+ tilt: one.tilt,
378
+ x: one.x,
379
+ y: one.y,
380
+ }
381
+ const mark = { lane: one.depth, shade: one.y, weight }
382
+ const behind = { ...mark, lane: one.depth - BEHIND }
383
+
384
+ const hair = hairOf(one)
385
+ if (hair.length > 0) {
386
+ pen.line(
387
+ hair,
388
+ {
389
+ ...behind,
390
+ weight: weight * VEIL_INK,
391
+ width: Math.max(0.6, HAIR_GIRTH[one.kind] * one.size),
392
+ },
393
+ spot,
394
+ )
395
+ }
396
+
397
+ const arms = armsOf(one)
398
+ if (arms.length > 0) {
399
+ pen.line(arms, { ...behind, width: JELLY_ARM[one.kind] * one.size }, spot)
400
+ }
401
+
402
+ pen.fill(trace(bell(one.kind, one.squeeze)), mark, spot)
403
+
404
+ // The four horseshoes, which are the reason a moon jelly is the jellyfish
405
+ // everybody can name. Punched back out of the bell rather than drawn over
406
+ // it, so they read as the animal being see-through.
407
+ if (one.kind === "moon") {
408
+ pen.line(
409
+ moonMarks(one.squeeze),
410
+ {
411
+ ...mark,
412
+ weight: weight * CUT_SHADE,
413
+ width: Math.max(0.8, 0.03 * one.size),
414
+ },
415
+ spot,
416
+ )
417
+ }
418
+
419
+ // The comb rows carry both kinds of light and they carry them on one set
420
+ // of lines: the wave the grating takes out of the water, which wants the
421
+ // sun, and the animal's own flash along the same eight canals, which wants
422
+ // the dark. One drawing, two sources, and the reader can tell them apart
423
+ // because only one of them moves.
424
+ for (const row of combRows(one)) {
425
+ const alpha = JELLY_SHEEN * row.glow * sun + JELLY_LIGHT * one.glow * dark
426
+ if (alpha <= 0.01) continue
427
+
428
+ pen.line(
429
+ [row.points],
430
+ {
431
+ alpha,
432
+ lane: one.depth,
433
+ shade: OWN,
434
+ tone: TONE.moon,
435
+ width: Math.max(0.8, 0.05 * one.size),
436
+ },
437
+ spot,
438
+ )
439
+ }
440
+
441
+ // A whole animal alight, bell, arms and tentacles at once, which is what
442
+ // most of the sea's bioluminescence looks like and is why the reach here
443
+ // goes well past the margin.
444
+ const wash = washOf(one)
445
+ if (wash) {
446
+ pen.light(one.x, one.y, {
447
+ alpha: JELLY_LIGHT * wash.glow * dark,
448
+ fall: 2.2,
449
+ lane: one.depth,
450
+ shade: OWN,
451
+ tone: TONE.moon,
452
+ width: wash.reach,
453
+ })
454
+ }
455
+
456
+ for (const flare of sparks(one)) {
457
+ // A light is a middle and a reach, so it cannot be handed a spot and has
458
+ // to be stood in the water here. Mirrored and then turned, in that
459
+ // order, which is the order `Spot` says and the order everything in this
460
+ // scene has got wrong at least once.
461
+ const across = flare.x * one.size * one.facing
462
+ const down = flare.y * one.size
463
+ const cos = Math.cos(one.tilt)
464
+ const sin = Math.sin(one.tilt)
465
+
466
+ pen.light(one.x + across * cos - down * sin, one.y + across * sin + down * cos, {
467
+ alpha: JELLY_LIGHT * flare.glow * dark,
468
+ fall: 1.6,
469
+ lane: one.depth,
470
+ shade: OWN,
471
+ tone: TONE.moon,
472
+ width: one.size * 0.34,
473
+ })
474
+ }
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Crabs and starfish, which is everything down there that has legs and never
480
+ * uses them to leave.
481
+ *
482
+ * The legs first and the body over them, so a claw folded across the shell
483
+ * reads as a claw in front of a shell rather than a crack in it.
484
+ */
485
+ /**
486
+ * The colony, standing out of the sand.
487
+ *
488
+ * A line each, and nothing else. The head is the round cap on the far end of
489
+ * it, which costs nothing and is what a garden eel actually looks like from any
490
+ * distance a reader is at: a stalk with a knot on the end.
491
+ *
492
+ * One drawing an animal rather than one for the bed, because the whole of the
493
+ * thing is that they are not doing the same thing at the same moment. A colony
494
+ * drawn as one shape could not have a wave cross it.
495
+ */
496
+ export function paintEels(pen: Pen, eels: Eels): void {
497
+ for (const one of eels.eels) {
498
+ if (one.body.length < 2) continue
499
+
500
+ const weight = far(EEL_INK, one.depth)
501
+ if (weight <= 0.002) continue
502
+
503
+ pen.line([one.body], {
504
+ lane: one.depth,
505
+ shade: one.y,
506
+ weight,
507
+ width: Math.max(1, EEL_GIRTH * one.size * one.girth),
508
+ })
509
+ }
510
+ }
511
+
512
+ /**
513
+ * The one that stays.
514
+ *
515
+ * Drawn in the order it would be seen: the spines stand off the body, so they
516
+ * go under it and the body covers their roots. The eye is punched back out of
517
+ * the body at a lighter weight, which is the same trick every other face in
518
+ * this water is drawn with.
519
+ */
520
+ export function paintPuffers(pen: Pen, puffers: Puffers): void {
521
+ for (const one of puffers.puffers) {
522
+ if (one.body.length < 3) continue
523
+
524
+ const weight = far(PUFFER_INK, one.depth)
525
+ if (weight <= 0.002) continue
526
+
527
+ const mark = { lane: one.depth, shade: one.y, weight }
528
+
529
+ if (one.spines.length > 0) pen.fill(one.spines, mark)
530
+ pen.fill([one.body], mark)
531
+ pen.fill([disc(one.eye.x, one.eye.y, one.size * EYE_SIZE)], {
532
+ ...mark,
533
+ weight: weight * CUT_SHADE,
534
+ })
535
+ }
536
+ }
537
+
538
+ export function paintWalkers(pen: Pen, walkers: Walkers): void {
539
+ for (const one of walkers.walkers) {
540
+ const weight = far(CRAWLER_INK, one.depth)
541
+ if (weight <= 0.002) continue
542
+
543
+ const spot = { facing: one.facing, scale: one.size, x: one.x, y: one.y }
544
+ const mark = { lane: one.depth, shade: one.y, weight }
545
+
546
+ pen.line(one.legs, { ...mark, width: ARM_GIRTH * one.size }, spot)
547
+ pen.fill(trace(one.body), mark, spot)
548
+ }
549
+ }
550
+
551
+ /**
552
+ * What goes past overhead, and what is looking for you.
553
+ *
554
+ * Minutes apart rather than seconds, and most sittings will hold neither: a
555
+ * scene you can watch the whole vocabulary of in a minute is wallpaper.
556
+ */
557
+ export function paintPassers(pen: Pen, passers: Passers): void {
558
+ let drawn = 0
559
+ for (const one of passers.passing) {
560
+ if (drawn++ >= MOST.passers) break
561
+
562
+ const weight = HULL_INK * one.weight
563
+ const spot = { facing: one.facing, scale: one.scale, x: one.x, y: one.y }
564
+ const mark = { lane: LANE.passer, shade: one.y, weight }
565
+
566
+ if (one.kind === "boat") {
567
+ pen.fill(trace(HULL), mark, spot)
568
+ pen.fill(trace(SCREWS), mark, spot)
569
+ }
570
+
571
+ if (one.kind === "submarine") {
572
+ pen.fill(trace(SUBMARINE), mark, spot)
573
+ pen.fill(trace(SUB_SCREW), mark, spot)
574
+ }
575
+
576
+ // Sonar: three rings, staggered, going out and losing themselves in the
577
+ // water. One ring would be a bubble.
578
+ if (one.kind === "sonar") {
579
+ for (const stagger of PING_RINGS) {
580
+ const at = ringAt(one.along, stagger)
581
+ if (!at) continue
582
+
583
+ pen.line([ring(one.x, one.y, at.reach * one.scale)], {
584
+ alpha: PING_INK * at.weight * one.weight,
585
+ lane: LANE.passer,
586
+ shade: OWN,
587
+ tone: TONE.ink,
588
+ // Thick where it leaves and a hairline by the time it is spent. A
589
+ // front carries what it was sent with, spread over a circle that
590
+ // keeps growing.
591
+ width: Math.max(1, Math.round(4 * at.weight)),
592
+ })
593
+ }
594
+ }
595
+ }
596
+
597
+ // The churn a hull leaves: white water, filled rather than outlined, so
598
+ // overlapping puffs merge into one patch instead of piling their alphas into
599
+ // a bright knot wherever three of them meet.
600
+ let churned = 0
601
+ for (const puff of passers.wake) {
602
+ if (churned++ >= MOST.froth) break
603
+
604
+ pen.fill([disc(puff.x, puff.y, puff.r)], {
605
+ alpha: Math.min(1, (1 - puff.age) / FROTH_LET_GO),
606
+ lane: LANE.froth,
607
+ shade: puff.y,
608
+ weight: FROTH_INK * (1 - puff.age),
609
+ })
610
+ }
611
+ }