@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,1041 @@
1
+ /**
2
+ * Squid in the water, octopuses on the floor.
3
+ *
4
+ * Kept free of the DOM the way the rest of the ornament is, and kept apart from
5
+ * `shoal.ts` because a cephalopod is not a fish with different steering. A fish
6
+ * is a body that beats continuously and travels continuously; these two are
7
+ * neither, and the whole reason for having them in the scene is that they break
8
+ * the rhythm everything else keeps.
9
+ *
10
+ * ## The squid, and why it is worth the trouble
11
+ *
12
+ * A shoal, however well steered, settles into one tempo. Every animal in it is
13
+ * always swimming, so the water has a background rate and after a minute the
14
+ * eye has it. A squid does not have a rate. It hangs, does nothing, then goes,
15
+ * hard, and hangs again.
16
+ *
17
+ * That is one cycle with two very unequal halves:
18
+ *
19
+ * * **Filling.** Most of the cycle. The mantle swells, the animal barely
20
+ * moves, and what movement there is is the water carrying it. The arms
21
+ * spread because nothing is holding them.
22
+ * * **The jet.** A fraction of it. The mantle snaps thin, the arms are pulled
23
+ * into a point, and the animal covers more ground than it did in the whole
24
+ * of the fill.
25
+ *
26
+ * The mantle drives the speed rather than the other way round, which is the
27
+ * same rule `shoal.ts` uses for a tail and for the same reason: an animal whose
28
+ * drawing and whose travel come off one number cannot fall out of step with
29
+ * itself.
30
+ *
31
+ * It never turns round, for the reason `Fish.facing` gives. A squid mirrored
32
+ * mid-glide would be worse than a fish mirrored, because there is no beat to
33
+ * hide the frame it happens on.
34
+ *
35
+ * ## The octopus
36
+ *
37
+ * Not in the water at all. It is on the bottom, which is the point: everything
38
+ * else in this scene is suspended, and an animal in contact with the ground is
39
+ * what tells you the ground is solid.
40
+ *
41
+ * It works along the floor rather than swimming over it, so its height comes
42
+ * from the seabed under it and never from a decision of its own. The arms are
43
+ * the whole animal, each on its own phase and none of them the same length.
44
+ *
45
+ * ## What it does with the day, and where the numbers came from
46
+ *
47
+ * It had two behaviours to begin with, a crawl and a jet, and it alternated
48
+ * them for as long as anybody watched. That is not an animal. It is a duty
49
+ * cycle, and the eye has the whole of it inside a few seconds.
50
+ *
51
+ * So the repertoire is taken off field work rather than invented. Sultan et
52
+ * al., "Deception in the dark, an ethogram of Callistoctopus furvus", Journal
53
+ * of Molluscan Studies 91(3), 2025, scored wild octopuses frame by frame and
54
+ * reported what share of watched time went to each behaviour. `DOINGS` is that
55
+ * table, one row a behaviour, and two of its findings are the whole reason for
56
+ * this rewrite:
57
+ *
58
+ * * **Most of an octopus is not travel.** Between sitting still and working
59
+ * its arms into crevices without moving the body, close to two fifths of
60
+ * the day goes to being somewhere rather than going somewhere. The old one
61
+ * had no way of not crawling except to jet.
62
+ * * **Jetting is a small part of it, and it is an answer.** It is the second
63
+ * line of defence, after not being seen has failed, so most of it is
64
+ * provoked. `UNPROVOKED` splits the measured share: that much is the
65
+ * animal's own idea and the rest waits for a `Startle`, which is what a
66
+ * hull going over hands down.
67
+ *
68
+ * The pick weights come off `DOINGS` rather than being tuned by hand, because a
69
+ * bout is a length of time and the long behaviours would otherwise swallow the
70
+ * day. Adding another behaviour is a row here and a posture in `POSTURES`.
71
+ *
72
+ * The speeds come off the same shelf. Huffard, "Locomotion by Abdopus
73
+ * aculeatus", Journal of Experimental Biology 209, 2006, timed the modes: a
74
+ * crawl at 0.62 body lengths a second, a walk on the back arms at rather more
75
+ * than twice that, a jet at under three times it and five at the very top. The
76
+ * old jet was nearly nine times the crawl, which is why it read as a firework
77
+ * rather than an escape. What is kept off that paper is the ratios; this water
78
+ * runs slower than the sea on purpose, and everything in it does.
79
+ */
80
+
81
+ import { makeRandom } from "./perlin.ts"
82
+ import type { Point } from "./seabed.ts"
83
+ import { drawnTilt, felt, type Startle } from "./shoal.ts"
84
+ import { BED_OFF, drawnAt, SWIMMING_OFF } from "./sizes.ts"
85
+
86
+ export interface Squid {
87
+ /** 0 is as far off as this water goes, 1 is up against the glass. */
88
+ readonly depth: number
89
+ /** Which way it points, once and for life: 1 rightward, -1 left. */
90
+ readonly facing: number
91
+ /** Radians, and always inside a shallow cone off level. */
92
+ heading: number
93
+ /** Nose to arm tip in px, depth already taken out of it. */
94
+ readonly size: number
95
+ /** px/s, which is whatever the mantle is doing. */
96
+ speed: number
97
+ /**
98
+ * How hard the mantle is squeezed, 0 slack and 1 shut.
99
+ *
100
+ * The one number the drawing and the travel are both read off.
101
+ */
102
+ squeeze: number
103
+ /**
104
+ * How far to rotate the drawing, in radians, after mirroring it by `facing`.
105
+ *
106
+ * Not the heading; see `Fish.tilt`, which this is the same fact as and for
107
+ * the same reason. A squid mirrors exactly as a fish does, and every
108
+ * renderer got the angle backwards for a leftward one, so it jetted up the
109
+ * screen while pointing down it.
110
+ */
111
+ tilt: number
112
+ x: number
113
+ y: number
114
+ }
115
+
116
+ /** One of the behaviours `DOINGS` scores, and the whole of what an octopus does. */
117
+ export type OctopusDoing =
118
+ | "bury"
119
+ | "crawl"
120
+ | "handle"
121
+ | "jet"
122
+ | "pounce"
123
+ | "probe"
124
+ | "rest"
125
+ | "stilt"
126
+
127
+ export interface Octopus {
128
+ /**
129
+ * How full the mantle is, 0 empty and 1 swelled.
130
+ *
131
+ * Ventilation, which never stops. It is the only thing a stopped octopus has
132
+ * to say it is alive: at this size an animal holding still with nothing
133
+ * moving on it is a stone, which is a compliment to the camouflage and a
134
+ * problem for the picture.
135
+ */
136
+ breath: number
137
+ /** How far through its own gait it is, which is what moves the arms. */
138
+ crawl: number
139
+ readonly depth: number
140
+ /** What it is doing. `DOINGS` says how likely that was and how long it runs. */
141
+ doing: OctopusDoing
142
+ /**
143
+ * Which way it points: 1 rightward, -1 left.
144
+ *
145
+ * It turns, which a squid never does, and the reason the two differ is that
146
+ * an octopus has moments of standing still to turn in. See `take`.
147
+ */
148
+ facing: number
149
+ /**
150
+ * How hard it is being driven right now, 0 at rest and 1 mid-jet.
151
+ *
152
+ * The arms are read off this rather than off the speed, because the two are
153
+ * not the same thing: an arm streams because the water is going past it, and
154
+ * the water is going past it hardest at the moment of the push.
155
+ */
156
+ haul: number
157
+ /** How far off the floor it is, in its own head widths. Below it when buried. */
158
+ lift: number
159
+ /** How far the arms are thrown out, 0 gathered under it and 1 stretched. */
160
+ reach: number
161
+ /** Head width in px, depth already taken out of it. */
162
+ readonly size: number
163
+ x: number
164
+ /** Where it is. The floor decides this while it is on the floor. */
165
+ y: number
166
+ }
167
+
168
+ export interface Cephalopods {
169
+ readonly octopuses: readonly Octopus[]
170
+ resize(width: number, height: number, floor: (x: number) => number): void
171
+ readonly squids: readonly Squid[]
172
+ /**
173
+ * Advance by `seconds`. `startle` is whatever the water is being told to do
174
+ * this tick, which the octopuses answer and the squid do not: a squid is
175
+ * already somewhere else, and an animal sitting on the ground is the one with
176
+ * a reason to care that something went over.
177
+ */
178
+ step(seconds: number, startle?: Startle | null): void
179
+ }
180
+
181
+ export interface CephalopodOptions {
182
+ floor: (x: number) => number
183
+ height: number
184
+ octopuses?: number
185
+ seed: number
186
+ squids?: number
187
+ width: number
188
+ }
189
+
190
+ /** How long one jet cycle lasts, slowest to fastest, in seconds. */
191
+ const CYCLE_SLOWEST = 3.4
192
+
193
+ const CYCLE_FASTEST = 6.8
194
+
195
+ /**
196
+ * What share of the cycle is the jet itself.
197
+ *
198
+ * Small, and it has to be. Give the push a third of the cycle and the animal
199
+ * reads as swimming badly rather than as jetting; give it a tenth and the
200
+ * stillness before it becomes the thing you notice, which is correct.
201
+ */
202
+ const JET_SHARE = 0.16
203
+
204
+ /** How fast a squid travels at the peak of a jet, in its own lengths a second. */
205
+ const JET_PEAK = 4.2
206
+
207
+ /** What it keeps while filling, as a share of that. Nearly nothing. */
208
+ const GLIDE = 0.07
209
+
210
+ /** How fast it sheds speed once the jet is over, per second. */
211
+ const DRAG = 2.1
212
+
213
+ /**
214
+ * The ends of a squid's length, in metres, mantle and arms together.
215
+ *
216
+ * A reef squid rather than anything from deep water, and `sizes.ts` puts it at
217
+ * the same distance the rest of the water swims at, so a squid crossing in
218
+ * front of a shoal is the size a squid is beside those fish.
219
+ */
220
+ const SQUID_SHORTEST = 0.16
221
+
222
+ const SQUID_LONGEST = 0.38
223
+
224
+ /** How steeply a squid may travel, either side of level. */
225
+ const SQUID_PITCH = 0.42
226
+
227
+ /** How far its line wanders while it fills, and how fast it takes a new one. */
228
+ const WANDER = 0.5
229
+
230
+ const RETHINK_LEAST = 2.5
231
+
232
+ const RETHINK_SPAN = 5
233
+
234
+ /** How far down the water a squid is ever born. They belong to the open water
235
+ * above the bed, and one that starts in the weeds looks like a mistake. */
236
+ const ROOF = 0.72
237
+
238
+ /** How far past the sides one goes before it comes back on the other. */
239
+ const MARGIN = 1.1
240
+
241
+ /**
242
+ * How near the top or the bed a squid gets before it starts turning away, in
243
+ * its own lengths, and how hard it leans on the line it was holding.
244
+ *
245
+ * It used to have no opinion about either. The step ended by clamping it back
246
+ * inside the water, so an animal that had drifted up spent the next several
247
+ * seconds sliding along the ceiling still nosed at it, which reads exactly like
248
+ * an animal swimming in one direction and facing another. The clamp is still
249
+ * there underneath, because a jet is fast enough to cross this band inside one
250
+ * frame, but nothing should ever reach it.
251
+ *
252
+ * The pull is stronger than `WANDER` on purpose. A squid that argued with the
253
+ * surface would be a squid that lost.
254
+ */
255
+ const SHY_BAND = 2.4
256
+
257
+ const SHY_PULL = 1.4
258
+
259
+ /**
260
+ * The ends of an octopus's head across, in metres.
261
+ *
262
+ * The head and not the animal, which is the number this file draws from and is
263
+ * about a fifth of what the arms reach. `sizes.ts` turns it into a share of the
264
+ * box at the distance the floor lies at, so an octopus at the mouth of a den
265
+ * and a crab going past it are the sizes they are against each other.
266
+ */
267
+ const OCTOPUS_SMALLEST = 0.09
268
+
269
+ const OCTOPUS_LARGEST = 0.17
270
+
271
+ /**
272
+ * The repertoire, and what share of a life each behaviour takes.
273
+ *
274
+ * `share` is measurement: the percentage of watched time the ethogram in the
275
+ * module note scored for each, with its burying and its going to ground added
276
+ * together because at this size they are the same picture. The rest of the
277
+ * table is judgement, and the note says which is which so nobody tunes the
278
+ * wrong column. `least` and `span` are how long one bout runs here, in
279
+ * seconds, which no ethogram reports.
280
+ */
281
+ const DOINGS: Readonly<Record<OctopusDoing, { least: number; share: number; span: number }>> = {
282
+ /** Into the sand and out of sight, which is its first answer to anything. */
283
+ bury: { least: 7, share: 9.65, span: 13 },
284
+ crawl: { least: 2.5, share: 41.29, span: 4.5 },
285
+ /** Hunched over whatever it caught, arms knotted underneath it. */
286
+ handle: { least: 3, share: 0.85, span: 4 },
287
+ jet: { least: 1.2, share: 4.79, span: 0.8 },
288
+ /** The parachute: arms opened into a bell and dropped over a stone. */
289
+ pounce: { least: 0.9, share: 0.65, span: 0.6 },
290
+ /** Body still, arms into the crevices without it. The one it lives on. */
291
+ probe: { least: 3, share: 21.82, span: 6 },
292
+ rest: { least: 5, share: 17.41, span: 11 },
293
+ /** Walking on the back pair, with the rest coiled up over it. */
294
+ stilt: { least: 1.5, share: 0.8, span: 2.5 },
295
+ }
296
+
297
+ /**
298
+ * What share of jetting is the animal's own idea.
299
+ *
300
+ * The ethogram counts every jet it saw, and a good deal of what a wild octopus
301
+ * jets away from is the person holding the camera. Here the thing that turns up
302
+ * is a `Startle`, so the measured share is split in two: this much happens
303
+ * unasked and the rest waits to be provoked. Left whole, every octopus in the
304
+ * scene would be bolting from nothing several times a minute, which is what the
305
+ * old one did and what this is for.
306
+ */
307
+ const UNPROVOKED = 0.2
308
+
309
+ /**
310
+ * How near and how hard a startle has to be felt before it lets go.
311
+ *
312
+ * Under this the answer is to stop being visible rather than to leave, and that
313
+ * is the right way round. An octopus's first defence is that you did not see
314
+ * it. The jet is what is left when that has failed.
315
+ */
316
+ const BOLT = 0.45
317
+
318
+ /** The behaviours it is standing still through, and so can turn round in. */
319
+ const STILL: ReadonlySet<OctopusDoing> = new Set<OctopusDoing>(["bury", "handle", "probe", "rest"])
320
+
321
+ /** How likely it is to have turned round by the end of one of those. */
322
+ const TURN_ODDS = 0.35
323
+
324
+ /**
325
+ * How far it gets in one pull, in head widths a second at the peak of it.
326
+ *
327
+ * Higher than the old steady pace it replaces, and it has to be: half the
328
+ * cycle is now a gather where it does not move at all, so the same ground has
329
+ * to be covered in half the time or the animal is slower than it was as well
330
+ * as jerkier.
331
+ */
332
+ const CRAWL_REACH = 0.62
333
+
334
+ /** How far it comes up off the floor as it pulls, in its own head widths. */
335
+ const CRAWL_RISE = 0.06
336
+
337
+ /** How fast its arms work through their gait, in cycles a second. */
338
+ const GAIT = 0.55
339
+
340
+ /**
341
+ * How fast it walks on the back pair, in head widths a second.
342
+ *
343
+ * Steady rather than pulsed, which is the difference between the two gaits: a
344
+ * crawl is a haul and a rest and a haul, and a walk on two arms is a walk. The
345
+ * timing work put it at rather more than twice a crawl and that is what this is.
346
+ */
347
+ const STILT_PACE = 0.48
348
+
349
+ /** How far up it stands to do it, in its own head widths. */
350
+ const STILT_RISE = 0.42
351
+
352
+ /** How deep it sinks when it buries, in its own head widths. */
353
+ const BURY_DEPTH = 0.62
354
+
355
+ /**
356
+ * How fast a jet goes at the peak of it, in head widths a second.
357
+ *
358
+ * It was nearly nine times the crawl and it read as a firework going off. The
359
+ * timing work puts a jet at under three times a crawl on the mean and five at
360
+ * the very top of the range, so this is the top of that and no further.
361
+ */
362
+ const JET_RUSH = 1.6
363
+
364
+ /** How far off the floor a jet takes it, in its own head widths. */
365
+ const JET_HEIGHT = 2.2
366
+
367
+ /** How fast it ventilates when it is idle, in cycles a second. */
368
+ const BREATH_CALM = 0.42
369
+
370
+ /** And how much more of that a hard haul adds, because working costs air. */
371
+ const BREATH_WORKED = 1.6
372
+
373
+ /** How far the arms stream behind, standing still and at full drive. */
374
+ const TRAIL_HELD = 0.22
375
+
376
+ const TRAIL_DRIVEN = 1.15
377
+
378
+ /** How far back these stand. Squid keep out of the very front of the glass. */
379
+ const DEPTH_FAR = 0.15
380
+
381
+ const DEPTH_NEAR = 0.85
382
+
383
+ /** How much of a body's drawn size, and with it its speed, is its depth. */
384
+ const DEPTH_SIZE = 0.6
385
+
386
+ const MIN_SPAN = 1
387
+
388
+ interface Beat {
389
+ /** The line it is holding, and how long before it takes another. */
390
+ aim: number
391
+ /** Seconds into the current cycle. */
392
+ at: number
393
+ hold: number
394
+ /** How long this cycle runs. */
395
+ span: number
396
+ }
397
+
398
+ /** One octopus's turn at one behaviour, and its breathing, which outlasts it. */
399
+ interface Bout {
400
+ /** Seconds into it. */
401
+ at: number
402
+ /** How far through a ventilation it is, in turns. */
403
+ puff: number
404
+ /** How long this one runs. */
405
+ span: number
406
+ }
407
+
408
+ export function createCephalopods(options: CephalopodOptions): Cephalopods {
409
+ const random = makeRandom(options.seed ^ 0x0c70)
410
+
411
+ let width = Math.max(MIN_SPAN, options.width)
412
+ let height = Math.max(MIN_SPAN, options.height)
413
+ let floor = options.floor
414
+
415
+ const squids: Squid[] = []
416
+ const octopuses: Octopus[] = []
417
+ const beats: Beat[] = []
418
+ const bouts: Bout[] = []
419
+
420
+ function bornSquid(): Squid {
421
+ const depth = DEPTH_FAR + random() * (DEPTH_NEAR - DEPTH_FAR)
422
+ const facing = random() < 0.5 ? -1 : 1
423
+ const long = SQUID_SHORTEST + random() * (SQUID_LONGEST - SQUID_SHORTEST)
424
+ const length = drawnAt(long, SWIMMING_OFF) * width
425
+ const aim = (random() - 0.5) * WANDER
426
+ const x = random() * width
427
+ const size = length * (1 - DEPTH_SIZE + DEPTH_SIZE * depth)
428
+
429
+ // Inside the water it is allowed, and not merely inside the box. One born
430
+ // over the surface was shoved back under it on its first frame, which is a
431
+ // squid travelling a body length sideways in a thirtieth of a second.
432
+ const top = size * MARGIN
433
+ const bed = Math.max(top, Math.min(floor(x) - size * 0.4, height * ROOF))
434
+
435
+ beats.push({
436
+ aim,
437
+ at: random() * CYCLE_SLOWEST,
438
+ hold: RETHINK_LEAST + random() * RETHINK_SPAN,
439
+ span: CYCLE_SLOWEST + random() * (CYCLE_FASTEST - CYCLE_SLOWEST),
440
+ })
441
+
442
+ return {
443
+ depth,
444
+ facing,
445
+ heading: point(facing, aim),
446
+ size,
447
+ speed: 0,
448
+ squeeze: 0,
449
+ tilt: drawnTilt(point(facing, aim), facing),
450
+ x,
451
+ y: top + random() * (bed - top),
452
+ }
453
+ }
454
+
455
+ /** How long one bout of this lasts, drawn out of `DOINGS`. */
456
+ function span(doing: OctopusDoing): number {
457
+ const row = DOINGS[doing]
458
+ return row.least + random() * row.span
459
+ }
460
+
461
+ /**
462
+ * Put it on to something else.
463
+ *
464
+ * It turns while it is standing still and never while it is going, which is
465
+ * the same rule `Squid.facing` states in its harder form. Flipping a moving
466
+ * animal swaps which way its arms stream between two frames, and what that
467
+ * reads as is the drawing being replaced rather than the animal turning
468
+ * round. Standing still there is nothing streaming to swap.
469
+ */
470
+ function take(one: Octopus, bout: Bout, doing: OctopusDoing): void {
471
+ if (STILL.has(one.doing) && random() < TURN_ODDS) one.facing = -one.facing
472
+ one.doing = doing
473
+ bout.at = 0
474
+ bout.span = span(doing)
475
+ }
476
+
477
+ function bornOctopus(): Octopus {
478
+ const depth = DEPTH_FAR + random() * (DEPTH_NEAR - DEPTH_FAR)
479
+ const doing = pick(random)
480
+ const across = OCTOPUS_SMALLEST + random() * (OCTOPUS_LARGEST - OCTOPUS_SMALLEST)
481
+ const head = drawnAt(across, BED_OFF) * width
482
+ const run = span(doing)
483
+ const x = random() * width
484
+
485
+ // Part way through it already. A scene that opened with every octopus on
486
+ // the first frame of the same fresh bout would spend its first minute in
487
+ // lockstep, which is the one minute anybody is certain to watch.
488
+ bouts.push({ at: random() * run, puff: random(), span: run })
489
+
490
+ return {
491
+ breath: 0,
492
+ crawl: random() * Math.PI * 2,
493
+ depth,
494
+ doing,
495
+ facing: random() < 0.5 ? -1 : 1,
496
+ haul: 0,
497
+ lift: 0,
498
+ reach: 1,
499
+ size: head * (1 - DEPTH_SIZE + DEPTH_SIZE * depth),
500
+ x,
501
+ y: floor(x),
502
+ }
503
+ }
504
+
505
+ function stock(): void {
506
+ squids.length = 0
507
+ octopuses.length = 0
508
+ beats.length = 0
509
+ bouts.length = 0
510
+
511
+ for (let made = 0; made < Math.max(0, options.squids ?? 0); made++) squids.push(bornSquid())
512
+ for (let made = 0; made < Math.max(0, options.octopuses ?? 0); made++) {
513
+ octopuses.push(bornOctopus())
514
+ }
515
+ }
516
+
517
+ stock()
518
+
519
+ return {
520
+ octopuses,
521
+
522
+ resize(nextWidth, nextHeight, nextFloor) {
523
+ const scaleX = Math.max(MIN_SPAN, nextWidth) / width
524
+ const scaleY = Math.max(MIN_SPAN, nextHeight) / height
525
+ width = Math.max(MIN_SPAN, nextWidth)
526
+ height = Math.max(MIN_SPAN, nextHeight)
527
+ floor = nextFloor
528
+
529
+ for (const one of squids) {
530
+ one.x *= scaleX
531
+ one.y *= scaleY
532
+ }
533
+ for (const one of octopuses) {
534
+ one.x *= scaleX
535
+ one.y = floor(one.x) - one.lift * one.size
536
+ }
537
+ },
538
+ squids,
539
+
540
+ step(seconds, startle) {
541
+ const dt = Math.min(Math.max(seconds, 0), 0.1)
542
+
543
+ for (let at = 0; at < squids.length; at++) {
544
+ const one = squids[at]
545
+ const beat = beats[at]
546
+ if (!one || !beat) continue
547
+
548
+ beat.at += dt
549
+ if (beat.at >= beat.span) {
550
+ beat.at -= beat.span
551
+ beat.span = CYCLE_SLOWEST + random() * (CYCLE_FASTEST - CYCLE_SLOWEST)
552
+ }
553
+
554
+ // The mantle first, and everything else read off it.
555
+ const through = beat.at / beat.span
556
+ one.squeeze = through < JET_SHARE ? Math.sin((through / JET_SHARE) * Math.PI) : 0
557
+
558
+ const push = one.squeeze * JET_PEAK * one.size
559
+ const glide = GLIDE * JET_PEAK * one.size
560
+ one.speed =
561
+ push > one.speed ? push : glide + (one.speed - glide) * Math.max(0, 1 - DRAG * dt)
562
+
563
+ beat.hold -= dt
564
+ if (beat.hold <= 0) {
565
+ beat.aim = (random() - 0.5) * WANDER
566
+ beat.hold = RETHINK_LEAST + random() * RETHINK_SPAN
567
+ }
568
+
569
+ // Eased rather than set. `beat.aim` is a fresh number every few
570
+ // seconds, and a body that took it whole snapped onto the new line
571
+ // between two frames: the squid appeared to twitch round rather than
572
+ // turn, which is the one thing a drifting animal must not do.
573
+ const edge = one.size * MARGIN
574
+ const bed = floor(one.x) - one.size * 0.4
575
+
576
+ one.heading = ease(
577
+ one.heading,
578
+ point(one.facing, beat.aim + shy(one.y, edge, bed, one.size)),
579
+ TURN * dt,
580
+ )
581
+ one.tilt = drawnTilt(one.heading, one.facing)
582
+ one.x += Math.cos(one.heading) * one.speed * dt
583
+ one.y += Math.sin(one.heading) * one.speed * dt
584
+
585
+ if (one.x > width + edge) one.x = -edge
586
+ if (one.x < -edge) one.x = width + edge
587
+ one.y = Math.min(Math.max(one.y, edge), bed)
588
+ }
589
+
590
+ for (let at = 0; at < octopuses.length; at++) {
591
+ const one = octopuses[at]
592
+ const bout = bouts[at]
593
+ if (!one || !bout) continue
594
+
595
+ // Breathing outlasts the bout and is the one thing here that never
596
+ // stops, so it is stepped before anything gets a say in it.
597
+ bout.puff = (bout.puff + (BREATH_CALM + one.haul * BREATH_WORKED) * dt) % 1
598
+ one.breath = (1 - Math.cos(bout.puff * Math.PI * 2)) / 2
599
+
600
+ // Something went over. Hard and near enough and it leaves; anything
601
+ // less and it goes into the sand, which is the answer it prefers.
602
+ const felt = pressure(startle, one)
603
+ if (felt > BOLT) {
604
+ if (one.doing !== "jet") take(one, bout, "jet")
605
+ } else if (felt > 0 && one.doing !== "jet" && one.doing !== "bury") {
606
+ take(one, bout, "bury")
607
+ }
608
+
609
+ bout.at += dt
610
+ if (bout.at >= bout.span) take(one, bout, pick(random))
611
+
612
+ const through = Math.min(1, bout.at / bout.span)
613
+ advance(one, through, dt)
614
+
615
+ const edge = one.size * MARGIN
616
+ if (one.x > width + edge) one.x = -edge
617
+ if (one.x < -edge) one.x = width + edge
618
+ one.y = floor(one.x) - one.lift * one.size
619
+ }
620
+ },
621
+ }
622
+ }
623
+
624
+ /**
625
+ * The ladder the next behaviour is drawn off, each rung a running total.
626
+ *
627
+ * Built once, because `DOINGS` never moves.
628
+ */
629
+ const ODDS = weigh()
630
+
631
+ /**
632
+ * How likely each behaviour is to be chosen, given how long it then lasts.
633
+ *
634
+ * A bout is a length of time, so drawing straight off `share` would hand the
635
+ * long behaviours far more of the day than the field work gave them: burying is
636
+ * a tenth of a life in bouts of a dozen seconds, and a pounce is under a
637
+ * hundredth in bouts of one. Dividing the share by the mean bout undoes that,
638
+ * and what comes out at the far end is time spent, which is what was measured.
639
+ */
640
+ function weigh(): { doing: OctopusDoing; upto: number }[] {
641
+ const rungs: { doing: OctopusDoing; upto: number }[] = []
642
+ let total = 0
643
+
644
+ for (const doing of Object.keys(DOINGS).sort() as OctopusDoing[]) {
645
+ const row = DOINGS[doing]
646
+ const share = doing === "jet" ? row.share * UNPROVOKED : row.share
647
+ total += share / (row.least + row.span / 2)
648
+ rungs.push({ doing, upto: total })
649
+ }
650
+
651
+ for (const rung of rungs) rung.upto /= total
652
+ return rungs
653
+ }
654
+
655
+ /** What it does next, which is nothing to do with what it was doing. */
656
+ function pick(random: () => number): OctopusDoing {
657
+ const roll = random()
658
+ for (const rung of ODDS) if (roll <= rung.upto) return rung.doing
659
+ return "crawl"
660
+ }
661
+
662
+ /**
663
+ * How hard a startle is felt where this one is standing, 0 to 1.
664
+ *
665
+ * Linear out to `reach` and nothing past it, and weighed by how much of the
666
+ * startle reaches this distance at all; see `felt`. A fish and an octopus
667
+ * disagree about what to do with a startle and agree about how much of it there
668
+ * is, which is why both of those come off the same two lines in `shoal.ts`.
669
+ */
670
+ function pressure(startle: Startle | null | undefined, one: Octopus): number {
671
+ if (!startle || startle.force <= 0 || startle.reach <= 0) return 0
672
+
673
+ const gap = Math.hypot(one.x - startle.x, one.y - startle.y)
674
+ if (gap >= startle.reach) return 0
675
+
676
+ return startle.force * (1 - gap / startle.reach) * felt(one.depth, startle)
677
+ }
678
+
679
+ /**
680
+ * One octopus, moved on by whatever it happens to be doing.
681
+ *
682
+ * Every arm of this sets `haul`, `lift` and `reach`, because the drawing reads
683
+ * all three every frame and a behaviour that left one of them alone would be
684
+ * wearing the last one's posture. Only the three that travel touch `x`.
685
+ */
686
+ function advance(one: Octopus, through: number, dt: number): void {
687
+ switch (one.doing) {
688
+ case "bury": {
689
+ // Down, a while under, and back up. Most of the bout is spent buried, so
690
+ // the going down and the coming up are quick at either end of it.
691
+ const under = Math.min(1, Math.min(through, 1 - through) * 6)
692
+ one.haul = 0
693
+ one.lift = -BURY_DEPTH * under
694
+ one.reach = 1 - under * 0.55
695
+ break
696
+ }
697
+
698
+ case "crawl": {
699
+ one.crawl += GAIT * dt * Math.PI * 2
700
+
701
+ // A step rather than a slide. The old gait never let the pace reach
702
+ // zero, and an animal that is always moving a little is an animal being
703
+ // dragged: what says walking is the stop. So the pull is the positive
704
+ // half of the cycle and the other half is a gather, where it stands
705
+ // still and brings its arms up under it.
706
+ one.haul = Math.max(0, Math.sin(one.crawl)) ** 1.4
707
+ one.lift = one.haul * CRAWL_RISE
708
+ one.reach = 1
709
+ one.x += one.facing * CRAWL_REACH * one.haul * one.size * dt
710
+ break
711
+ }
712
+
713
+ case "handle": {
714
+ // Hunched over something, arms knotted under the head, and the only
715
+ // movement is the worrying at it.
716
+ one.crawl += GAIT * 0.6 * dt * Math.PI * 2
717
+ one.haul = 0
718
+ one.lift = 0
719
+ one.reach = 0.5 + Math.sin(one.crawl) * 0.08
720
+ break
721
+ }
722
+
723
+ case "jet": {
724
+ // Off the ground and travelling, arms streamed. The arc is the whole of
725
+ // it: an octopus that jetted along the floor would be an octopus on
726
+ // rails, and the reason to jet at all is to leave.
727
+ one.haul = Math.sin(through * Math.PI) ** 0.6
728
+ one.lift = Math.sin(through * Math.PI) * JET_HEIGHT
729
+ one.reach = 1
730
+ one.x += one.facing * JET_RUSH * one.size * one.haul * dt
731
+ break
732
+ }
733
+
734
+ case "pounce": {
735
+ // Open, then shut. Arms thrown wide into a bell, lifted, and dropped
736
+ // over a stone, and whatever was under the stone is now inside. The
737
+ // closing is faster than the opening because it is a strike.
738
+ one.crawl += GAIT * dt * Math.PI * 2
739
+ one.haul = 0
740
+ one.lift = Math.sin(Math.min(1, through * 1.6) * Math.PI) * 0.3
741
+ one.reach =
742
+ through < 0.55
743
+ ? 0.5 + Math.min(1, through * 2.2) * 0.6
744
+ : Math.max(0.25, 1.1 - (through - 0.55) * 6)
745
+ break
746
+ }
747
+
748
+ case "probe": {
749
+ // Still, with the arms going where the body does not. This is the one it
750
+ // spends most of its standing life on, so the sweep is slow enough to
751
+ // watch: an arm in a crevice takes seconds over it.
752
+ one.crawl += GAIT * 0.45 * dt * Math.PI * 2
753
+ one.haul = 0
754
+ one.lift = 0
755
+ one.reach = 0.72 + Math.sin(one.crawl * 0.6) * 0.28
756
+ break
757
+ }
758
+
759
+ case "rest": {
760
+ one.crawl += GAIT * 0.2 * dt * Math.PI * 2
761
+ one.haul = 0
762
+ one.lift = 0
763
+ one.reach = 0.45 + Math.sin(one.crawl) * 0.05
764
+ break
765
+ }
766
+
767
+ case "stilt": {
768
+ // Up on the back pair with the rest coiled over it, and the pace steady:
769
+ // two arms alternating is a walk, and a walk does not stall the way the
770
+ // crawl does. It stands up at the start and sits back down at the end,
771
+ // because appearing at full height is a cut rather than a movement.
772
+ one.crawl += GAIT * 1.7 * dt * Math.PI * 2
773
+ const up = Math.min(1, Math.min(through, 1 - through) * 5)
774
+ one.haul = 0.25
775
+ one.lift = STILT_RISE * up
776
+ one.reach = 1
777
+ one.x += one.facing * STILT_PACE * one.size * up * dt
778
+ break
779
+ }
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Which way the water is telling it to go, if it is telling it anything.
785
+ *
786
+ * Positive is down, the way `point` reads an aim. Nothing at all through the
787
+ * middle of the water, which is the point: this is a wall it notices when it is
788
+ * near one, not a force pulling it to the centre of the picture.
789
+ */
790
+ function shy(y: number, top: number, bed: number, size: number): number {
791
+ // Never more than a share of the water, or in a shallow scene the two bands
792
+ // overlap and the animal is told to go up and down at once. What it did then
793
+ // was hold still against whichever of the two was winning.
794
+ const band = Math.max(1, Math.min(size * SHY_BAND, (bed - top) * 0.4))
795
+ const under = Math.max(0, top + band - y)
796
+ const over = Math.max(0, y - (bed - band))
797
+
798
+ return ((under - over) / band) * SHY_PULL
799
+ }
800
+
801
+ /** How fast a squid comes round onto a new line, in radians a second. */
802
+ const TURN = 0.9
803
+
804
+ /** A step of the way from one number to another, and never past it. */
805
+ function ease(from: number, to: number, step: number): number {
806
+ const gap = to - from
807
+ return Math.abs(gap) <= step ? to : from + Math.sign(gap) * step
808
+ }
809
+
810
+ /** The nearest heading a body facing this way is allowed to hold. */
811
+ function point(facing: number, want: number): number {
812
+ const asked = Math.atan2(Math.sin(want), facing * Math.cos(want))
813
+ const rel = SQUID_PITCH * Math.tanh(asked / SQUID_PITCH)
814
+ return facing > 0 ? rel : Math.PI - rel
815
+ }
816
+
817
+ /**
818
+ * A squid, drawn at whatever its mantle is doing.
819
+ *
820
+ * Built nose-first along positive x with the arms trailing behind the origin,
821
+ * in a body a unit long, so a renderer scales it by `Squid.size` and mirrors it
822
+ * by `Squid.facing` exactly as it does a fish.
823
+ *
824
+ * The mantle is a leaf: wide where the fins are, drawn to a point at the nose,
825
+ * and narrowing to the collar where the arms leave. Squeezing it takes width
826
+ * out and puts length in, because that is where the water went.
827
+ */
828
+ export function squidBody(squeeze: number): string {
829
+ const fat = 0.19 - squeeze * 0.07
830
+ const long = 0.62 + squeeze * 0.1
831
+ const fin = 0.2 - squeeze * 0.05
832
+
833
+ return [
834
+ `M${long.toFixed(3)} 0`,
835
+ `Q${(long * 0.55).toFixed(3)} ${(-fat).toFixed(3)} 0 ${(-fat * 0.72).toFixed(3)}`,
836
+ `L0 ${(fat * 0.72).toFixed(3)}`,
837
+ `Q${(long * 0.55).toFixed(3)} ${fat.toFixed(3)} ${long.toFixed(3)} 0`,
838
+ "Z",
839
+ // The fins, one either side, back where the mantle is widest.
840
+ `M${(long * 0.52).toFixed(3)} ${(-fat * 0.86).toFixed(3)}`,
841
+ `Q${(long * 0.86).toFixed(3)} ${(-fat - fin).toFixed(3)} ${(long * 0.97).toFixed(3)} ${(-fat * 0.2).toFixed(3)}`,
842
+ "Z",
843
+ `M${(long * 0.52).toFixed(3)} ${(fat * 0.86).toFixed(3)}`,
844
+ `Q${(long * 0.86).toFixed(3)} ${(fat + fin).toFixed(3)} ${(long * 0.97).toFixed(3)} ${(fat * 0.2).toFixed(3)}`,
845
+ "Z",
846
+ ].join(" ")
847
+ }
848
+
849
+ /**
850
+ * The arms, as polylines leaving the collar.
851
+ *
852
+ * Gathered into a point during a jet and spread while filling, which is not a
853
+ * flourish: it is the only part of the animal that shows the water it is
854
+ * pushing against.
855
+ */
856
+ export function squidArms(squeeze: number, count = 8): Point[][] {
857
+ const arms: Point[][] = []
858
+ // Gathered at full squeeze, never shut. Arms folded into a single line make
859
+ // the animal a dart, and a dart has no arms to notice: what is wanted is a
860
+ // handful of them held tight, which still reads as a handful.
861
+ const spread = (1 - squeeze) * 0.44 + 0.2
862
+
863
+ for (let made = 0; made < count; made++) {
864
+ const side = made / (count - 1) - 0.5
865
+ const lean = side * spread
866
+ const reach = 0.34 + Math.abs(side) * 0.16
867
+ const points: Point[] = []
868
+
869
+ for (let step = 0; step <= 5; step++) {
870
+ const t = step / 5
871
+ points.push({
872
+ x: -t * reach,
873
+ y: lean * t * (0.5 + t) + Math.sin(t * 2.6 + side * 5) * 0.03 * (1 - squeeze) * t,
874
+ })
875
+ }
876
+ arms.push(points)
877
+ }
878
+
879
+ return arms
880
+ }
881
+
882
+ /**
883
+ * An octopus's mantle, in a head a unit wide sitting on the origin.
884
+ *
885
+ * A bag with a waist rather than a dome. The dome is what it was first and the
886
+ * dome is a mushroom: what tells an octopus from a jellyfish above the arms is
887
+ * that the mantle narrows where it meets them, so there is a head and then a
888
+ * gather, and the arms leave the gather.
889
+ *
890
+ * `breath` is the ventilation, 0 empty and 1 full, and it takes width in and
891
+ * lets a little height out, because that is where the water went. It is a small
892
+ * enough movement that nobody will name it, which is correct: what it is for is
893
+ * that a stopped octopus is still doing something. Most of this animal's day is
894
+ * now spent not travelling, and without a breath under it that day is a sprite
895
+ * that has been left on the ground.
896
+ */
897
+ export function octopusHead(breath: number): string {
898
+ const full = Math.min(Math.max(breath, 0), 1)
899
+ const wide = 1 + full * 0.08
900
+ const tall = 1 - full * 0.05
901
+ const w = (at: number): string => (at * wide).toFixed(3)
902
+ const h = (at: number): string => (at * tall).toFixed(3)
903
+
904
+ return [
905
+ `M${w(-0.3)} ${h(0.14)}`,
906
+ `Q${w(-0.5)} ${h(-0.06)} ${w(-0.44)} ${h(-0.4)}`,
907
+ `Q${w(-0.36)} ${h(-0.78)} 0 ${h(-0.8)}`,
908
+ `Q${w(0.36)} ${h(-0.78)} ${w(0.44)} ${h(-0.4)}`,
909
+ `Q${w(0.5)} ${h(-0.06)} ${w(0.3)} ${h(0.14)}`,
910
+ "Z",
911
+ ].join(" ")
912
+ }
913
+
914
+ /** The mantle with the breath out of it, for anything wanting a still one. */
915
+ export const OCTOPUS_HEAD = octopusHead(0)
916
+
917
+ /** How one behaviour holds its arms. Alongside `DOINGS`, a row a behaviour. */
918
+ interface Posture {
919
+ /** How far the tips curl back up and in towards the body. */
920
+ curl: number
921
+ /** How far an arm falls towards the floor along its own length. */
922
+ drop: number
923
+ /** How wide the arms are spread across the ground. */
924
+ fan: number
925
+ /**
926
+ * How much longer the leading arms are than the trailing ones.
927
+ *
928
+ * The sign is the whole of what tells a probe from a walk on two arms.
929
+ * Positive reaches forward and tucks the back: the body stays where it is and
930
+ * the front arms go into the crevice. Negative stands on the back pair and
931
+ * coils the front over the head, which is the bipedal gait. `curl` follows
932
+ * it, so whichever end is short is also the end that is curled up.
933
+ */
934
+ lead: number
935
+ /** Arm length, in head widths. */
936
+ reach: number
937
+ /** How much the gait moves them at all, 0 held still. */
938
+ work: number
939
+ }
940
+
941
+ /**
942
+ * What each behaviour looks like, which is almost entirely a matter of arms.
943
+ *
944
+ * The head barely changes, so if these eight are not distinguishable from one
945
+ * another here then the animal has one behaviour however many `DOINGS` lists.
946
+ */
947
+ const POSTURES: Readonly<Record<OctopusDoing, Posture>> = {
948
+ bury: { curl: 0.05, drop: 0.08, fan: 3.4, lead: 0, reach: 0.75, work: 0.12 },
949
+ crawl: { curl: 0, drop: 0.34, fan: 2.5, lead: 0.25, reach: 1, work: 1 },
950
+ handle: { curl: 0.32, drop: 0.26, fan: 1.3, lead: 0, reach: 0.66, work: 0.4 },
951
+ jet: { curl: 0, drop: 0.06, fan: 0.7, lead: 0, reach: 1.1, work: 0.15 },
952
+ pounce: { curl: 0, drop: 0.1, fan: 3.2, lead: 0, reach: 1, work: 0.25 },
953
+ probe: { curl: 0.22, drop: 0.3, fan: 2.6, lead: 1, reach: 1, work: 0.7 },
954
+ rest: { curl: 0.3, drop: 0.3, fan: 1.9, lead: 0, reach: 0.68, work: 0.25 },
955
+ stilt: { curl: 0.62, drop: 0.55, fan: 1.5, lead: -1.6, reach: 0.95, work: 0.9 },
956
+ }
957
+
958
+ /** As much of an octopus as its arms are drawn off. An `Octopus` is one. */
959
+ export interface OctopusPose {
960
+ crawl: number
961
+ doing: OctopusDoing
962
+ facing: number
963
+ haul: number
964
+ reach: number
965
+ }
966
+
967
+ /**
968
+ * An octopus's arms, in a body a unit wide, rooted at the origin.
969
+ *
970
+ * Each arm has its own phase off the shared gait, so the animal is always part
971
+ * way through reaching with some of them and gathering with the rest. Arms all
972
+ * doing the same thing at once are a windscreen wiper with too many blades.
973
+ *
974
+ * It takes the animal rather than a number now, because there is no longer one
975
+ * number to take: which arm is long, how far it falls and whether the gait is
976
+ * running at all are all read off what it is doing.
977
+ */
978
+ export function octopusArms(pose: OctopusPose, count = 8): Point[][] {
979
+ const arms: Point[][] = []
980
+ const drive = Math.min(Math.max(pose.haul, 0), 1)
981
+ const held = POSTURES[pose.doing] ?? POSTURES.crawl
982
+ const open = Math.min(Math.max(pose.reach, 0), 1.2)
983
+
984
+ for (let made = 0; made < count; made++) {
985
+ // Fanned across most of a half-turn rather than dropped straight down. An
986
+ // octopus is mostly arm and it is mostly sideways: a bundle of them hanging
987
+ // under a dome is a jellyfish, and it takes only spreading them out along
988
+ // the ground to be the other animal entirely.
989
+ const side = made / (count - 1) - 0.5
990
+
991
+ // Two groups out of phase rather than one each, and the split runs front to
992
+ // back. Every arm on its own phase is that many things happening at once
993
+ // and it reads as a spin; a leading half reaching while a trailing half
994
+ // pushes reads as a gait, which is what it is.
995
+ const phase = pose.crawl + (side > 0 ? 0 : Math.PI) + made * 0.12
996
+ const work = Math.sin(phase) * held.work
997
+
998
+ // Which end of the animal this arm is on: 1 leading, -1 trailing.
999
+ const ahead = Math.sign(side * pose.facing) || 1
1000
+ const grown = held.reach * open * (1 + ahead * held.lead * 0.35)
1001
+ const reach = grown * (0.9 + Math.abs(side) * 0.5) * (0.88 + work * 0.12)
1002
+ const curl = held.curl * (1 - ahead * held.lead * 0.8)
1003
+
1004
+ // Which way the water is going past it. An arm behind a moving animal is
1005
+ // pulled straight out behind it, and that is the single clearest statement
1006
+ // a shape this size can make about which way it is going: without it the
1007
+ // thing is symmetrical, and a symmetrical animal sliding sideways is being
1008
+ // dragged rather than swimming.
1009
+ const stream = -pose.facing * (TRAIL_HELD + drive * TRAIL_DRIVEN)
1010
+
1011
+ // Gathered as it goes. Arms spread wide are arms holding ground; arms
1012
+ // brought in are arms in the water, so the fan closes as the push builds.
1013
+ const fan = held.fan * (1 - drive * 0.62) * open
1014
+
1015
+ const points: Point[] = []
1016
+
1017
+ for (let step = 0; step <= 6; step++) {
1018
+ const t = step / 6
1019
+ // Out first and down after, so an arm lies along the floor for most of
1020
+ // its length instead of dangling. `t * t` on the drop is what does that.
1021
+ //
1022
+ // About a body and a bit either side, which is as far as this can go. An
1023
+ // octopus is mostly arm, but an animal whose arms are three times its own
1024
+ // width reads as a spider, and one whose arms are longer than the shot is
1025
+ // wide reads as a mistake.
1026
+ const along = side * fan * reach * t
1027
+ const wave = Math.sin(phase + t * 2.2) * 0.14 * t * (1 - t) * 4 * held.work
1028
+ // A gathered arm comes in and up, and only so far up. The mantle sits
1029
+ // above the origin and it is solid, so an arm lifted much past the line
1030
+ // it is rooted on is an arm drawn behind the head, which at this weight
1031
+ // is an arm that is not there at all. Gathered still has to be visible.
1032
+ points.push({
1033
+ x: along + stream * t * reach - ahead * curl * t * t * 0.28,
1034
+ y: (t * t * held.drop + t * 0.1) * (1 - drive * 0.7) - wave * 0.22 - curl * t * t * 0.5,
1035
+ })
1036
+ }
1037
+ arms.push(points)
1038
+ }
1039
+
1040
+ return arms
1041
+ }