@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/passers.ts ADDED
@@ -0,0 +1,730 @@
1
+ /**
2
+ * What goes past overhead, what is looking for you, and what is passing
3
+ * through.
4
+ *
5
+ * A boat crossing the surface, sonar, and a submarine. All three are rare on a
6
+ * scale nothing else in the ornament is: each happens at most once in a day,
7
+ * and the submarine only on about one day in seven. Kept free of the DOM the
8
+ * way the rest is.
9
+ *
10
+ * ## Why a thing that almost never happens is worth the code
11
+ *
12
+ * Because the alternative is a loop, and a loop is what turns a background into
13
+ * wallpaper. Everything else here is on a cycle short enough to see the whole
14
+ * of: the fish come round, the kelp sways, the bubbles burst and rest, and
15
+ * after a minute you have watched the entire vocabulary. A scene that still has
16
+ * something in it you have not seen is a scene you have not finished.
17
+ *
18
+ * It also gives the water a surface and a beyond. A hull passing over the top
19
+ * of the picture says the water is under something; a ping says somebody is out
20
+ * there; a submarine says they came looking. None of that can be said by
21
+ * anything swimming.
22
+ *
23
+ * ## Rare in the day, not rare in the minute
24
+ *
25
+ * The first version counted seconds: a boat every eight minutes or so of the
26
+ * scene's own running time. That is the wrong clock for the surface this ended
27
+ * up on. A desktop advances its water only while the wallpaper is uncovered, so
28
+ * the eight minutes are eight minutes of glimpses, and a boat that is rare per
29
+ * minute watched turns out to be a boat you see most times you clear a
30
+ * workspace. Rarity anybody can feel is rarity in the day.
31
+ *
32
+ * So each kind keeps an appointment instead. The day's plan is drawn from the
33
+ * seed and the date, which makes it the same plan on every screen of a machine
34
+ * and a different one tomorrow: whether this kind happens at all today, and the
35
+ * moment it is due. Once the clock is past that moment the passer is owed, and
36
+ * it crosses the next time there is somebody to see it, after a stretch of
37
+ * watched time so that it never arrives on the instant a window closes. Then it
38
+ * is done until tomorrow.
39
+ *
40
+ * Which means a passer waits rather than being missed. A machine that was off
41
+ * at four in the morning gets its boat when it wakes up, and a machine nobody
42
+ * looks at all day gets one crossing when they finally do, not the nine it
43
+ * would have been owed. The hole in it is honest and stated here: nothing is
44
+ * written to disk, so restarting the shell forgets what has already crossed and
45
+ * a day with a restart in it can hold a second boat.
46
+ *
47
+ * ## All three are one shape
48
+ *
49
+ * A passer is a thing that is not here, then is here, then is not. They differ
50
+ * in how likely they are, how long they take, where in the column they sit, and
51
+ * what a renderer makes of them, so they share `Passer` and everything else
52
+ * lives in a table. Adding another kind means another row.
53
+ *
54
+ * ## A passer has to leave a mark on the water
55
+ *
56
+ * The first boat did not. It was a silhouette sliding across the ceiling with
57
+ * two straight lines trailing off the stern, and the two lines were supposed to
58
+ * be a wake. They read as a beam: a hard pair of rays opening into the picture
59
+ * off the back of a hull is a searchlight, or the sonar the ping is meant to
60
+ * be, and the eye is quite sure it is being pointed at. Nothing else about the
61
+ * boat said the water had noticed it at all.
62
+ *
63
+ * So a hull now leaves `wake`, which is churn: puffs of white water let go at
64
+ * the stern that swell, open into the V a hull actually leaves, thin, and go.
65
+ * They belong to the water rather than to the boat, which is the whole point of
66
+ * them. They stay where they were made while the hull goes on, and they are
67
+ * still dying out after it has left the picture, so what somebody sees is a
68
+ * boat that pushed the water aside rather than a boat drawn on top of it.
69
+ *
70
+ * And a passer says what it is doing to the fish, in `startle`. A hull
71
+ * overhead and a ping going through are both things a shoal reacts to, and
72
+ * `shoal.ts` takes one straight: the fish under the boat dive and scatter, and
73
+ * a ping empties the water in front of it. That is the difference between a
74
+ * scene with a boat in it and a scene a boat is happening to.
75
+ */
76
+
77
+ import { makeRandom, stir } from "./perlin.ts"
78
+ import { drawnAt } from "./sizes.ts"
79
+
80
+ export type PassKind = "boat" | "sonar" | "submarine"
81
+
82
+ export interface Passer {
83
+ /** How far through its crossing, 0 to 1. */
84
+ along: number
85
+ /** Which way it is going: 1 rightward, -1 left. Meaningless for sonar. */
86
+ readonly facing: number
87
+ readonly kind: PassKind
88
+ /** Its size in px: hull length for a boat, the ring's reach for sonar. */
89
+ readonly scale: number
90
+ /** How loud it is now, 0 to 1, faded in and out at the ends. */
91
+ weight: number
92
+ /** Where it is, in the box's own units. */
93
+ x: number
94
+ y: number
95
+ }
96
+
97
+ /**
98
+ * One puff of churned water, left behind by whatever made it.
99
+ *
100
+ * It has no course of its own. Where it was made is where it stays, and all it
101
+ * does from there is swell, drift off the line it was made on, and thin out.
102
+ * The two arms of a wake are one of these let go alternately to either side.
103
+ */
104
+ export interface Froth {
105
+ /** How far through its life it is, 0 new and 1 gone. */
106
+ age: number
107
+ r: number
108
+ /**
109
+ * The size of the thing that made it, in px.
110
+ *
111
+ * Kept so the puff can go on swelling and opening at its own rate after its
112
+ * boat has left the picture. A renderer has no use for it.
113
+ */
114
+ readonly scale: number
115
+ /**
116
+ * Which way it drifts off the line it was made on, and how fast.
117
+ *
118
+ * The sign is the arm of the wake it belongs to. The size of it is what
119
+ * keeps the two arms from being two rows of dots: every puff opens at its
120
+ * own rate, so the churn spreads into a band that is thickest at the arms
121
+ * and has water in it everywhere, which is what a wake is.
122
+ */
123
+ readonly side: number
124
+ x: number
125
+ y: number
126
+ }
127
+
128
+ /**
129
+ * What the water is being told to do right now, for anything living in it.
130
+ *
131
+ * A push out from a point, felt to `reach` and no further. `shoal.ts` takes it
132
+ * as a thing to get away from, which is why the numbers are in px and a share
133
+ * rather than in anything to do with boats: a fish has no opinion about what
134
+ * kind of passer it is under.
135
+ */
136
+ export interface Startle {
137
+ /** How hard it is felt at the middle of it, 0 to 1. */
138
+ force: number
139
+ /** How far out it is felt at all, in px. */
140
+ reach: number
141
+ x: number
142
+ y: number
143
+ }
144
+
145
+ export interface Passers {
146
+ readonly passing: readonly Passer[]
147
+ resize(width: number, height: number): void
148
+ /** Where the water is being disturbed now, or null while it is still. */
149
+ readonly startle: Startle | null
150
+ /**
151
+ * `seconds` is watched time, which is what a crossing is made of, and `now`
152
+ * is the wall clock, which is what the day's plan is read against. They are
153
+ * two different clocks on a desktop and telling them apart is the whole of
154
+ * the timing here: a covered wallpaper stops the first and not the second.
155
+ */
156
+ step(seconds: number, now?: Date): void
157
+ /** The churn left behind, oldest first. */
158
+ readonly wake: readonly Froth[]
159
+ }
160
+
161
+ export interface PassersOptions {
162
+ /** Ignore the day's plan and keep crossing. See `createPassers`. */
163
+ eager?: boolean
164
+ height: number
165
+ kinds?: readonly PassKind[]
166
+ seed: number
167
+ width: number
168
+ }
169
+
170
+ /**
171
+ * How far up and out the two hulls are, in metres.
172
+ *
173
+ * A boat is not small, it is far, and writing it down that way is what keeps
174
+ * the next one from being wrong: it is a shape crossing the ceiling rather than
175
+ * a thing you are swimming beside, and the height of the water column is most
176
+ * of why. Drawn as though it were alongside, it filled the top of the frame and
177
+ * the eye went to it and stayed.
178
+ *
179
+ * The submarine is out in the blue instead, which is the only place a hull that
180
+ * long can be and still be a hull rather than a wall. Both go through
181
+ * `sizes.ts` with the animals, so the boat comes out over a turtle and under a
182
+ * shark, and the rare thing is the largest silhouette this water ever draws.
183
+ */
184
+ const BOAT_OFF = 30
185
+
186
+ const SUB_OFF = 150
187
+
188
+ /** How much of a crossing is spent fading in, and the same again fading out. */
189
+ const FADE = 0.16
190
+
191
+ /** How far down the box a boat's waterline sits, as a share of the height. */
192
+ const WATERLINE = 0.035
193
+
194
+ /** How far past each side a boat starts and finishes, as shares of the width. */
195
+ const OFFING = 0.2
196
+
197
+ /** How far in from a side a ping comes from: never the middle. */
198
+ const PING_EDGE = 0.18
199
+
200
+ interface Habit {
201
+ /**
202
+ * How many days in ten it happens at all.
203
+ *
204
+ * The one number that decides whether something is scenery or a find. A boat
205
+ * and a ping are every day, so the water has a beyond to it as a matter of
206
+ * course; the submarine is not, because a thing that turns up daily is a
207
+ * thing you stop looking for.
208
+ */
209
+ readonly chance: number
210
+ /** Where it sits in the column, as shares of the height. */
211
+ readonly deepLeast: number
212
+ readonly deepSpan: number
213
+ /**
214
+ * How big it is drawn, as a share of the box's width.
215
+ *
216
+ * A hull says it in metres of hull at metres of distance and lets `sizes.ts`
217
+ * do the sum. Sonar has no length to state: what it is measuring out is how
218
+ * far the ring reaches, which is a share of the box and nothing else.
219
+ */
220
+ readonly sizeLeast: number
221
+ readonly sizeSpan: number
222
+ /** How long a crossing takes, at least and up to that again. */
223
+ readonly takeLeast: number
224
+ readonly takeSpan: number
225
+ }
226
+
227
+ /**
228
+ * How likely each is on a given day, how deep it goes, and how long it takes.
229
+ *
230
+ * The boat is the largest thing the scene ever draws and it is a hard
231
+ * silhouette across the top of the picture, so it gets one crossing a day and
232
+ * no more. Sonar is cheaper to look at and does not travel at all, but it is
233
+ * the loudest thing here in the sense that matters: something out there knows
234
+ * you are down here. Once is plenty.
235
+ *
236
+ * The submarine is the rare one, and it is rare in the only way that lasts. It
237
+ * could have been made rare per minute, and then it would be a thing you see
238
+ * every few sittings and stop seeing after a fortnight; on about one day in
239
+ * seven it is a thing somebody mentions.
240
+ */
241
+ const HABITS: Readonly<Record<PassKind, Habit>> = {
242
+ boat: {
243
+ chance: 1,
244
+ deepLeast: WATERLINE,
245
+ deepSpan: 0,
246
+ sizeLeast: drawnAt(6, BOAT_OFF),
247
+ sizeSpan: drawnAt(11, BOAT_OFF) - drawnAt(6, BOAT_OFF),
248
+ takeLeast: 26,
249
+ takeSpan: 18,
250
+ },
251
+ sonar: {
252
+ chance: 1,
253
+ deepLeast: 0.12,
254
+ deepSpan: 0.43,
255
+ sizeLeast: 0.4,
256
+ sizeSpan: 0.3,
257
+ takeLeast: 4.5,
258
+ takeSpan: 2.5,
259
+ },
260
+ submarine: {
261
+ chance: 0.15,
262
+ deepLeast: 0.17,
263
+ deepSpan: 0.19,
264
+ sizeLeast: drawnAt(55, SUB_OFF),
265
+ sizeSpan: drawnAt(90, SUB_OFF) - drawnAt(55, SUB_OFF),
266
+ takeLeast: 38,
267
+ takeSpan: 22,
268
+ },
269
+ }
270
+
271
+ interface Churn {
272
+ /** How much water it moves, as a share of what a hull at full pelt moves. */
273
+ readonly force: number
274
+ /** Where the churn leaves it, along and across, in units of `scale`. */
275
+ readonly x: number
276
+ readonly y: number
277
+ }
278
+
279
+ /**
280
+ * What each does to the water it is in.
281
+ *
282
+ * The boat is at the surface with its screws in the top of the water, so it
283
+ * moves the most and it moves it where everything can see. A submarine at depth
284
+ * moves about half as much: it is smaller, it is further off, and a wake that
285
+ * came off it as hard as the boat's would make it the loud thing in the picture
286
+ * when the point of it is that it went by almost unnoticed.
287
+ *
288
+ * Sonar moves none. It is a sound, and the only mark it leaves is on the fish.
289
+ */
290
+ const CHURN: Readonly<Record<PassKind, Churn>> = {
291
+ boat: { force: 1, x: -1, y: 0.19 },
292
+ sonar: { force: 0, x: 0, y: 0 },
293
+ submarine: { force: 0.5, x: -1.12, y: 0 },
294
+ }
295
+
296
+ /**
297
+ * Seconds between one puff of churn and the next, while something is crossing.
298
+ *
299
+ * Close enough together that the puffs overlap at the stern, which is the
300
+ * difference between foam and beads. Foam is not a collection of things: it is
301
+ * a patch of water that has gone white, and the only way a renderer drawing
302
+ * discs gets a patch is to hand it more discs than there is room for.
303
+ */
304
+ const FROTH_GAP = 0.04
305
+
306
+ /** How long a puff of churn lasts, in seconds. */
307
+ const FROTH_LIFE = 4.4
308
+
309
+ /**
310
+ * The most churn the water holds at once, which is the two above and a margin.
311
+ *
312
+ * A ceiling rather than a count, and it exists because a renderer that keeps a
313
+ * pool of shapes needs to know how deep to make the pool. Running out at the
314
+ * young end would leave a gap of clear water between the stern and its own
315
+ * wake, which is a boat that has stopped churning while still under power.
316
+ */
317
+ export const FROTH_MOST = 112
318
+
319
+ /** How big a puff leaves, and how fast it swells, in units of `scale`. */
320
+ const FROTH_SEED = 0.07
321
+
322
+ const FROTH_SWELL = 0.04
323
+
324
+ /**
325
+ * How fast the two arms open, in units of `scale` a second.
326
+ *
327
+ * The V of a wake is a fixed angle in the real world, so what opens it here is
328
+ * time rather than distance: a puff a second old sits about this far off the
329
+ * line the hull ran down, whatever the hull was doing. It slows as it ages,
330
+ * because the water it is pushing into is water at rest.
331
+ */
332
+ const FROTH_OPEN = 0.2
333
+
334
+ /**
335
+ * How far a hull is felt through the water, in units of its own `scale`, and
336
+ * how hard a ping is felt against a hull.
337
+ *
338
+ * A hull is felt close in and a ping is felt everywhere it has got to, which is
339
+ * the honest difference between a thing displacing water and a sound going
340
+ * through it. The ping is the softer of the two on purpose: a shoal that bolted
341
+ * from a noise would be a shoal that had been hurt, and this one has only been
342
+ * asked a question.
343
+ */
344
+ const HULL_FELT = 2.6
345
+
346
+ const PING_FELT = 0.55
347
+
348
+ /** Seconds in a day, which is the unit the whole plan is written in. */
349
+ const DAY = 24 * 60 * 60
350
+
351
+ /**
352
+ * How long an owed passer waits for watched time before it crosses, at least
353
+ * and up to that again.
354
+ *
355
+ * Not zero, and this is the difference between a scene and a trick. A boat that
356
+ * arrived the instant the last window closed would be a boat that had been
357
+ * waiting for you, and everything else in this water is indifferent to whether
358
+ * anybody is there. A minute or two of ordinary sea first, and it is weather.
359
+ */
360
+ const WAIT_LEAST = 35
361
+
362
+ const WAIT_SPAN = 130
363
+
364
+ /** Watched seconds between the end of one crossing and the start of the next. */
365
+ const APART = 90
366
+
367
+ const MIN_SPAN = 1
368
+
369
+ /**
370
+ * The passers of a piece of water.
371
+ *
372
+ * `eager` throws the day's plan away and crosses one after another, and it
373
+ * exists for previews and for tests. Nothing should pass it in earnest: a boat
374
+ * you get for turning the screen on is a boat on a schedule, and the whole
375
+ * claim of this file is that there is no schedule.
376
+ */
377
+ export function createPassers(options: PassersOptions): Passers {
378
+ const random = makeRandom(options.seed ^ 0x30b7)
379
+ const kinds = options.kinds ?? (["boat", "sonar", "submarine"] as const)
380
+ const eager = options.eager ?? false
381
+
382
+ let width = Math.max(MIN_SPAN, options.width)
383
+ let height = Math.max(MIN_SPAN, options.height)
384
+
385
+ const passing: Passer[] = []
386
+ const takes = new Map<PassKind, number>()
387
+
388
+ /** The churn in the water, and which arm of the wake the next puff is on. */
389
+ const wake: Froth[] = []
390
+ let since = 0
391
+ let arm = 1
392
+
393
+ /** What the water is being told to do this tick; see `Startle`. */
394
+ let felt: Startle | null = null
395
+
396
+ /** The day the plan below was drawn for, as `daySeed` counts a day. */
397
+ let planned = 0
398
+
399
+ /** When each kind is due today, in seconds into the day, or null for not today. */
400
+ const due = new Map<PassKind, number | null>()
401
+
402
+ /** How much watched time each still owes before it crosses; see `WAIT_LEAST`. */
403
+ const waits = new Map<PassKind, number>()
404
+
405
+ /** What has already crossed today, and how long since the last one did. */
406
+ const gone = new Set<PassKind>()
407
+ let apart = APART
408
+
409
+ /**
410
+ * Draw a day's plan.
411
+ *
412
+ * A pure function of the seed and the date, on its own generator, so every
413
+ * screen of a machine agrees about what today holds without any of them
414
+ * saying so, and tomorrow is a different day rather than the next few numbers
415
+ * of this one. Both rolls are spent whatever the first one says, because a
416
+ * plan whose shape depended on its own outcome would put the kinds in a
417
+ * queue: a day without a submarine would hand the boat the submarine's hour.
418
+ */
419
+ function plan(day: number): void {
420
+ planned = day
421
+ gone.clear()
422
+
423
+ const dice = makeRandom(stir(options.seed ^ day ^ 0x51ed) | 0)
424
+
425
+ for (const kind of kinds) {
426
+ const happens = dice() < HABITS[kind].chance
427
+ const at = Math.floor(dice() * DAY)
428
+ due.set(kind, happens ? at : null)
429
+ waits.set(kind, WAIT_LEAST + dice() * WAIT_SPAN)
430
+ }
431
+ }
432
+
433
+ function launch(kind: PassKind): void {
434
+ const habit = HABITS[kind]
435
+ const facing = random() < 0.5 ? -1 : 1
436
+ const scale = width * (habit.sizeLeast + random() * habit.sizeSpan)
437
+ const deep = habit.deepLeast + random() * habit.deepSpan
438
+
439
+ takes.set(kind, habit.takeLeast + random() * habit.takeSpan)
440
+ gone.add(kind)
441
+
442
+ // Owing a puff from the first tick, so a hull is churning by the time the
443
+ // frame has it rather than a tenth of a second later.
444
+ since = FROTH_GAP
445
+
446
+ // A ping never comes from the middle of the water, because a ring centred
447
+ // in the picture is a target reticle rather than something out there. It
448
+ // comes from a side, high up, which is where anything looking for you is.
449
+ // The two that travel start off the picture entirely; see `OFFING`.
450
+ const side = kind === "sonar" ? (facing > 0 ? 1 - PING_EDGE : PING_EDGE) : 0
451
+
452
+ passing.push({
453
+ along: 0,
454
+ facing,
455
+ kind,
456
+ scale,
457
+ weight: 0,
458
+ x: width * side,
459
+ y: height * deep,
460
+ })
461
+ }
462
+
463
+ /**
464
+ * Which kind, if any, is owed a crossing right now.
465
+ *
466
+ * One at a time and a gap between them, because two of these at once is a
467
+ * busy sea and this is a scene whose whole argument is that almost nothing
468
+ * happens in it. Everything owed keeps standing until it gets its turn.
469
+ */
470
+ function owed(dt: number, clock: number): PassKind | null {
471
+ if (passing.length > 0 || apart > 0) return null
472
+
473
+ // The one that has been waiting longest, rather than the first in the list.
474
+ // Order in a list is not a reason to go first, and the kind that would
475
+ // always lose that argument is the submarine: it is owed on one day in
476
+ // seven, and on those days it would queue behind the two that are owed
477
+ // every day.
478
+ let next: PassKind | null = null
479
+ let oldest = Number.POSITIVE_INFINITY
480
+
481
+ for (const kind of kinds) {
482
+ if (gone.has(kind)) continue
483
+
484
+ const at = due.get(kind)
485
+ if (at == null || clock < at || at >= oldest) continue
486
+
487
+ oldest = at
488
+ next = kind
489
+ }
490
+
491
+ if (!next) return null
492
+
493
+ const left = (waits.get(next) ?? 0) - dt
494
+ waits.set(next, left)
495
+ return left <= 0 ? next : null
496
+ }
497
+
498
+ /**
499
+ * Let go of a puff of churn at the stern, if one is due.
500
+ *
501
+ * Alternate arms rather than one either side at every beat. Two puffs born at
502
+ * the same instant on the same line are a mirror, and a mirrored wake is a
503
+ * drawing of a wake; taking turns puts every puff on its own clock and the
504
+ * two arms come out of step the way water is. How far off its arm each one
505
+ * goes is its own business; see `Froth.side`.
506
+ */
507
+ function shed(one: Passer, churn: Churn, dt: number): void {
508
+ since += dt
509
+ if (since < FROTH_GAP) return
510
+
511
+ since = 0
512
+ arm = -arm
513
+
514
+ if (wake.length >= FROTH_MOST) wake.shift()
515
+
516
+ wake.push({
517
+ age: 0,
518
+ r: one.scale * churn.force * FROTH_SEED * (0.6 + random() * 0.8),
519
+ scale: one.scale * churn.force,
520
+ side: arm * (0.3 + random() * 0.7),
521
+ x: one.x + one.facing * one.scale * churn.x + (random() - 0.5) * one.scale * 0.09,
522
+ y: one.y + one.scale * churn.y + (random() - 0.5) * one.scale * 0.05,
523
+ })
524
+ }
525
+
526
+ /**
527
+ * How hard a ping is being felt, from the front of it.
528
+ *
529
+ * The first ring is the one out in front, so it is the one the fish meet.
530
+ * The rings behind it are a sound arriving twice more and the water has
531
+ * already been told what it needed to know.
532
+ */
533
+ function heard(one: Passer): Startle | null {
534
+ const front = ringAt(one.along, PING_RINGS[0] ?? 0)
535
+ if (!front) return null
536
+
537
+ return {
538
+ force: PING_FELT * front.weight * one.weight,
539
+ reach: front.reach * one.scale,
540
+ x: one.x,
541
+ y: one.y,
542
+ }
543
+ }
544
+
545
+ /** Carry the churn on: swell, open away from the line, thin, and go. */
546
+ function settle(dt: number): void {
547
+ for (let at = wake.length - 1; at >= 0; at--) {
548
+ const puff = wake[at]
549
+ if (!puff) continue
550
+
551
+ puff.age += dt / FROTH_LIFE
552
+ if (puff.age >= 1) {
553
+ wake.splice(at, 1)
554
+ continue
555
+ }
556
+
557
+ puff.r += puff.scale * FROTH_SWELL * dt
558
+ puff.y += puff.side * puff.scale * FROTH_OPEN * (1 - puff.age) * dt
559
+ }
560
+ }
561
+
562
+ return {
563
+ passing,
564
+
565
+ resize(nextWidth, nextHeight) {
566
+ width = Math.max(MIN_SPAN, nextWidth)
567
+ height = Math.max(MIN_SPAN, nextHeight)
568
+ // Whatever was crossing is dropped rather than rescaled. A boat that
569
+ // changed length halfway over would be worse than no boat, and a window
570
+ // being dragged is the one moment nobody is watching the water anyway.
571
+ // It stays spent for the day: half a boat is the day's boat.
572
+ passing.length = 0
573
+ wake.length = 0
574
+ felt = null
575
+ },
576
+
577
+ get startle() {
578
+ return felt
579
+ },
580
+
581
+ step(seconds, now = new Date()) {
582
+ const dt = Math.min(Math.max(seconds, 0), 0.1)
583
+ const day = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate()
584
+ if (day !== planned) plan(day)
585
+
586
+ apart = Math.max(0, apart - dt)
587
+
588
+ if (eager) {
589
+ if (passing.length === 0) {
590
+ gone.clear()
591
+ launch(kinds[Math.floor(random() * kinds.length)] ?? "boat")
592
+ }
593
+ } else {
594
+ const next = owed(dt, now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds())
595
+ if (next) launch(next)
596
+ }
597
+
598
+ for (let at = passing.length - 1; at >= 0; at--) {
599
+ const one = passing[at]
600
+ if (!one) continue
601
+
602
+ one.along += dt / Math.max(takes.get(one.kind) ?? 1, 0.001)
603
+ if (one.along >= 1) {
604
+ passing.splice(at, 1)
605
+ apart = APART
606
+ continue
607
+ }
608
+
609
+ // In at one end and out at the other, so nothing ever appears. A hull
610
+ // that popped into being at the edge of the picture would say the
611
+ // picture has an edge, which is the one thing the water must not admit.
612
+ one.weight = Math.min(1, one.along / FADE, (1 - one.along) / FADE)
613
+
614
+ if (one.kind === "sonar") continue
615
+ const from = -OFFING * width
616
+ const to = width * (1 + OFFING)
617
+ one.x = one.facing > 0 ? from + one.along * (to - from) : to - one.along * (to - from)
618
+ }
619
+
620
+ // What the water is doing about it, after everything has been moved: the
621
+ // churn comes off a stern that is already where this tick left it, and a
622
+ // fish is answering the boat it can see rather than the one from last
623
+ // frame. There is only ever one passer, so the last word is the only one.
624
+ felt = null
625
+
626
+ for (const one of passing) {
627
+ const churn = CHURN[one.kind]
628
+ if (churn.force <= 0) {
629
+ felt = heard(one)
630
+ continue
631
+ }
632
+
633
+ shed(one, churn, dt)
634
+ felt = {
635
+ force: churn.force * one.weight,
636
+ reach: one.scale * HULL_FELT,
637
+ x: one.x,
638
+ y: one.y,
639
+ }
640
+ }
641
+
642
+ settle(dt)
643
+ },
644
+ wake,
645
+ }
646
+ }
647
+
648
+ /**
649
+ * A boat, in a hull two units long, sitting on its own waterline at the origin.
650
+ *
651
+ * Seen from under it, which is the only view this water has. That means no
652
+ * superstructure and no detail above the line: what there is to draw is a hull
653
+ * from below, and a hull from below is a long shallow curve with a bow at one
654
+ * end. The `+x` end is the bow.
655
+ *
656
+ * Two propellers rather than one, because one on the centreline reads as a
657
+ * rudder. They say the thing is under power, and the wake coming off them says
658
+ * the water knows it; like everything else under a unit here they are a path
659
+ * rather than a shape a renderer would round to whole pixels before scaling it
660
+ * up.
661
+ */
662
+ export const HULL = "M-1 0 L1 0 L0.82 0.2 Q0.1 0.34 -0.72 0.28 Q-0.94 0.24 -1 0 Z"
663
+
664
+ export const SCREWS =
665
+ "M-0.78 0.15 A0.07 0.07 0 1 1 -0.78 0.29 A0.07 0.07 0 1 1 -0.78 0.15 Z " +
666
+ "M-0.62 0.19 A0.07 0.07 0 1 1 -0.62 0.33 A0.07 0.07 0 1 1 -0.62 0.19 Z"
667
+
668
+ /**
669
+ * A submarine, two units long, on its own centreline at the origin.
670
+ *
671
+ * Seen from the side, because unlike the boat it is down here in the water with
672
+ * everything else, and a thing at your own depth is a thing you see the flank
673
+ * of. The `+x` end is the bow.
674
+ *
675
+ * One path, in six pieces that overlap: hull, sail, the periscope out of the
676
+ * top of it, the fairwater planes across it, and a fin above and below the
677
+ * stern. It has to be filled with a winding rule rather than an odd-even one,
678
+ * which is the whole reason this note exists: under odd-even the sail would cut
679
+ * a hole out of the hull where it sits on it, and the drawing would come apart
680
+ * exactly where it is meant to join. Every subpath is wound the same way so a
681
+ * winding fill sees one silhouette.
682
+ *
683
+ * The periscope is four hundredths of a unit wide and nobody will ever
684
+ * knowingly see it. It is there because the difference between a shape that
685
+ * reads as a submarine and a shape that reads as a cigar is entirely in what
686
+ * sticks out of it.
687
+ */
688
+ export const SUBMARINE =
689
+ "M-0.92 -0.15 L0.4 -0.17 Q0.86 -0.16 1 0 Q0.86 0.16 0.4 0.17 L-0.92 0.15 " +
690
+ "Q-1 0.1 -1 0 Q-1 -0.1 -0.92 -0.15 Z " +
691
+ "M0.02 -0.16 L0.08 -0.44 L0.3 -0.44 L0.34 -0.16 Z " +
692
+ "M0.16 -0.44 L0.16 -0.62 L0.2 -0.62 L0.2 -0.44 Z " +
693
+ "M-0.02 -0.38 L0.42 -0.38 L0.42 -0.33 L-0.02 -0.33 Z " +
694
+ "M-0.74 -0.14 L-0.96 -0.4 L-0.84 -0.4 L-0.62 -0.14 Z " +
695
+ "M-0.62 0.14 L-0.84 0.4 L-0.96 0.4 L-0.74 0.14 Z"
696
+
697
+ /**
698
+ * Its screw, as two blades on a shaft astern.
699
+ *
700
+ * Apart from the hull for the same reason the boat's is: it is the one part
701
+ * that says the thing is under power rather than sinking, and a renderer may
702
+ * want to weight it differently or leave it out at a size where it would be a
703
+ * smudge.
704
+ */
705
+ export const SUB_SCREW =
706
+ "M-1 -0.02 L-1.14 -0.14 L-1.18 -0.06 L-1.04 0 L-1.18 0.06 L-1.14 0.14 L-1 0.02 Z"
707
+
708
+ /**
709
+ * How many rings a ping puts out, and where in its life each one starts.
710
+ *
711
+ * Three, staggered, which is what a ping sounds like and therefore what it
712
+ * should look like. One ring is a bubble.
713
+ */
714
+ export const PING_RINGS: readonly number[] = [0, 0.16, 0.32]
715
+
716
+ /**
717
+ * How far out a ring has got and how loud it still is, at `along` through the
718
+ * whole ping.
719
+ *
720
+ * The reach eases out and the weight falls off with it, which is a wave losing
721
+ * itself in the water rather than a circle being scaled. Returns null before
722
+ * its ring has started or after it has gone, so a renderer can skip it without
723
+ * knowing the arithmetic.
724
+ */
725
+ export function ringAt(along: number, offset: number): { reach: number; weight: number } | null {
726
+ const t = (along - offset) / (1 - offset)
727
+ if (t <= 0 || t >= 1) return null
728
+
729
+ return { reach: 1 - (1 - t) ** 2, weight: (1 - t) ** 1.6 }
730
+ }