@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.
package/src/puffers.ts ADDED
@@ -0,0 +1,510 @@
1
+ /**
2
+ * The one animal in this water that does not run.
3
+ *
4
+ * Every other response in this sea is a way of leaving. A fish darts, a crab
5
+ * freezes and then bolts, an octopus jets, a jellyfish furls and sinks, an
6
+ * anemone shuts, a colony of eels drops into the sand. All of it is the same
7
+ * sentence said in six ways, and a sea that only knows one sentence is a sea
8
+ * where nothing that happens means anything, because the answer is always the
9
+ * same answer.
10
+ *
11
+ * A pufferfish stays. It swallows water until it is a ball with spines out and
12
+ * then hangs there, and what it is saying is not "I am leaving" but "you cannot
13
+ * get this in your mouth". It is the only argument in the picture, and it is
14
+ * the whole reason to draw one.
15
+ *
16
+ * ## Why it is not a fish in `shoal.ts`
17
+ *
18
+ * Because it is not driven the way a fish is. A fish in that file is a spine
19
+ * with a wave running down it and a heading it eases onto, and what makes it
20
+ * look like a fish is that the body it is drawn as is the body that is pushing
21
+ * it. A pufferfish sculls with two small fins on the sides of a body that is
22
+ * held rigid. It is closer to a helicopter than to a fish, and every constant
23
+ * in `shoal.ts` is about the thing it is not.
24
+ *
25
+ * The other half is that a shoal steers on fright and this one does not. A fish
26
+ * that stopped fleeing would break every rule that file is built out of.
27
+ *
28
+ * ## What swelling costs
29
+ *
30
+ * Everything, which is why it is not free here either. Inflating is expensive
31
+ * and it takes a while to undo, so a real one does not do it lightly and does
32
+ * not stop doing it the moment the danger has passed: `SWELL` and `SETTLE` are
33
+ * far apart for the same reason `eels.ts` has them far apart, and it is the same
34
+ * fact about animals. Half deflated with nothing about is what one looks like
35
+ * for a minute after something went by, and it is the state that makes the event
36
+ * before it legible.
37
+ *
38
+ * ## What a renderer gets
39
+ *
40
+ * `body` is a closed outline in px and `spines` are lines standing off it, both
41
+ * recut only when the animal has changed enough to be worth it. The outline goes
42
+ * from an oval to very nearly a circle, the tail shrinks against it as it goes,
43
+ * and the spines are nothing at rest: a pufferfish at rest is a slightly
44
+ * lumpy fish, and that is what makes the other state read.
45
+ */
46
+
47
+ import { abreast, type Presence } from "./biome.ts"
48
+ import { makeRandom } from "./perlin.ts"
49
+ import type { Point } from "./seabed.ts"
50
+ import { BED_OFF, drawnAt } from "./sizes.ts"
51
+
52
+ export interface Puffer {
53
+ /** The outline, closed, in px. */
54
+ body: Point[]
55
+ /**
56
+ * How many times it has redrawn itself.
57
+ *
58
+ * The only thing a renderer has to watch. A pufferfish spends most of a
59
+ * sitting at rest and drifting slowly, so it holds a drawing for a long time.
60
+ */
61
+ cut: number
62
+ /** 0 is as far off as this water goes, 1 is up against the glass. */
63
+ readonly depth: number
64
+ /** Where the eye sits, in px. */
65
+ eye: Point
66
+ /** Which way it is pointed, once and for life: 1 rightward, -1 left. */
67
+ readonly facing: number
68
+ /** How wide it is across at rest, in px. */
69
+ readonly size: number
70
+ /** The spines, each a triangle standing off the body. Empty at rest. */
71
+ spines: Point[][]
72
+ /** How swollen it is, 0 a fish and 1 a ball with spines out. */
73
+ swell: number
74
+ x: number
75
+ y: number
76
+ }
77
+
78
+ export interface Puffers {
79
+ readonly puffers: readonly Puffer[]
80
+ resize(width: number, height: number, floor: (x: number, depth?: number) => number): void
81
+ step(seconds: number): void
82
+ }
83
+
84
+ export interface PuffersOptions {
85
+ /**
86
+ * What else is in the water, asked for once a step.
87
+ *
88
+ * Left out, nothing is, and a pufferfish with nothing to argue with is a
89
+ * slightly lumpy fish sculling about, which is what one is.
90
+ */
91
+ about?: () => readonly Presence[]
92
+ count?: number
93
+ /** The ground at x, at a given distance; `seabed.floorAt`. */
94
+ floor: (x: number, depth?: number) => number
95
+ height: number
96
+ seed: number
97
+ /** How far it may move from its last drawing before it recuts, in px. */
98
+ tolerance?: number
99
+ width: number
100
+ }
101
+
102
+ /**
103
+ * How long one is, in metres.
104
+ *
105
+ * A reef pufferfish, which is the ordinary one and is a hand's length. The
106
+ * giants are a different animal and a different picture.
107
+ */
108
+ const LENGTH = 0.3
109
+
110
+ /** How much bigger or smaller than that one of them is. */
111
+ const ODD = 0.22
112
+
113
+ /** How much of its drawn size is its distance. The floor's own share. */
114
+ const DEPTH_SIZE = 0.55
115
+
116
+ /**
117
+ * How far back they go, and how far off the sand they hang.
118
+ *
119
+ * Near the bottom without being on it. A pufferfish works the floor and the
120
+ * lower reef, and one drawn out in the open water is a balloon somebody let go
121
+ * of. The height off the sand is in its own body widths, so a small one keeps
122
+ * closer to the ground than a large one, which is what they do.
123
+ */
124
+ const DEPTH_FAR = 0.3
125
+
126
+ const DEPTH_NEAR = 0.95
127
+
128
+ const OVER_LEAST = 0.6
129
+
130
+ const OVER_SPAN = 3.4
131
+
132
+ /** How fast it sculls, in its own widths a second, and how far off that one
133
+ * of them is. Slow. Two fins the size of a thumbnail on a body like a fist. */
134
+ const SCULL = 0.42
135
+
136
+ const DAWDLE = 0.45
137
+
138
+ /** How long it holds a heading, in seconds. It has nowhere to be. */
139
+ const HOLD_LEAST = 4
140
+
141
+ const HOLD_SPAN = 11
142
+
143
+ /** How far past the side it goes before it comes back on the other. */
144
+ const MARGIN = 1.5
145
+
146
+ /**
147
+ * How much bigger than a pufferfish a thing has to be before it is minded, and
148
+ * how far off it is minded from as a share of its own size.
149
+ *
150
+ * `walkers.ts`, `nemos.ts` and `eels.ts` weigh the water by these. The rule is
151
+ * shared and the answer is not, which is the point of having four animals read
152
+ * one register.
153
+ */
154
+ const MINDS = 1.7
155
+
156
+ const NOTICE = 2.6
157
+
158
+ /** How much of an alarm something with no menace about it can still cause. */
159
+ const BULK = 0.25
160
+
161
+ /** How little a thing has to worry it before it is not worth the water. */
162
+ const MINDS_AT = 0.16
163
+
164
+ /**
165
+ * How fast it swells and how fast it settles, in shares of full a second.
166
+ *
167
+ * Far apart, and the far apart is the animal. Swallowing enough water to double
168
+ * is a second's work and getting rid of it again is not, so what a pufferfish
169
+ * looks like for a while after something went past is a pufferfish that has not
170
+ * finished being frightened.
171
+ */
172
+ const SWELL = 1.6
173
+
174
+ const SETTLE = 0.1
175
+
176
+ /**
177
+ * How much wider it gets, and how much rounder.
178
+ *
179
+ * The width is the smaller half of it. What says inflated is that the animal
180
+ * has stopped being a fish shape: a deflated one is a long oval and a swollen
181
+ * one is a circle, and the eye reads the roundness before it reads the size.
182
+ */
183
+ const GROWTH = 0.55
184
+
185
+ /** How long a body is against its width, at rest and full. */
186
+ const LONG_REST = 1.5
187
+
188
+ const LONG_FULL = 1.06
189
+
190
+ /** How finely the outline is cut. Enough that a circle has no corners in it. */
191
+ const RIM = 30
192
+
193
+ /**
194
+ * The tail, as a share of the body's own width: how long at rest, how much of
195
+ * that survives being swollen, and how far it fans.
196
+ *
197
+ * Nearly none of it survives. A tail on a sphere is a flap, and the flap is
198
+ * what makes the sphere read as an animal rather than as a mine.
199
+ */
200
+ const TAIL = 0.26
201
+
202
+ const TAIL_KEPT = 0.34
203
+
204
+ const TAIL_FAN = 0.5
205
+
206
+ /** How much of the rim the tail joins across, either side of the back. */
207
+ const TAIL_ROOT = 0.05
208
+
209
+ /** How fast the tail works, and how far it sweeps, in radians. */
210
+ const BEAT_SLOWEST = 0.5
211
+
212
+ const BEAT_FASTEST = 0.9
213
+
214
+ const SWEEP = 0.42
215
+
216
+ /**
217
+ * How many spines it carries, how long one is against the body, and how much
218
+ * of the swell has to happen before any of them show.
219
+ *
220
+ * They lie flat until the skin is stretched, which is a fact about the animal
221
+ * and is also the thing that makes the drawing work: spines out on a resting
222
+ * fish would be a hedgehog.
223
+ */
224
+ const SPINES = 16
225
+
226
+ const SPINE_LONG = 0.19
227
+
228
+ const SPINE_FROM = 0.25
229
+
230
+ /** How much of the gap between two spines the base of one takes. */
231
+ const SPINE_SEAT = 0.3
232
+
233
+ /** Where the eye sits along the body and how far off the middle, and how big
234
+ * it is. A pufferfish is mostly eye, which is why it reads as a face. */
235
+ const EYE_ALONG = 0.62
236
+
237
+ const EYE_UP = 0.22
238
+
239
+ /** How far it drifts before it is worth redrawing, in px, by default. */
240
+ const TOLERANCE = 0.3
241
+
242
+ const MIN_SPAN = 1
243
+
244
+ /** What it was when it was last drawn. */
245
+ interface Drawn {
246
+ swell: number
247
+ wag: number
248
+ x: number
249
+ y: number
250
+ }
251
+
252
+ /** Everything about one that is not in the drawing. */
253
+ interface Habit {
254
+ /** How fast the tail works. */
255
+ readonly beat: number
256
+ /** Where in that beat it is. */
257
+ clock: number
258
+ drawn: Drawn | null
259
+ /** Radians. Where it is going, which is nowhere in particular. */
260
+ heading: number
261
+ /** Seconds left on the heading it is holding. */
262
+ hold: number
263
+ /** How high off the sand it hangs, in its own widths. */
264
+ readonly over: number
265
+ /** How fast it sculls, as a share of the ordinary. */
266
+ readonly pace: number
267
+ }
268
+
269
+ export function createPuffers(options: PuffersOptions): Puffers {
270
+ const random = makeRandom(options.seed ^ 0x71c3)
271
+
272
+ let width = Math.max(MIN_SPAN, options.width)
273
+ let height = Math.max(MIN_SPAN, options.height)
274
+ let floor = options.floor
275
+
276
+ const tolerance = Math.max(0, options.tolerance ?? TOLERANCE)
277
+
278
+ const puffers: Puffer[] = []
279
+ const habits: Habit[] = []
280
+
281
+ function fill(): void {
282
+ puffers.length = 0
283
+ habits.length = 0
284
+
285
+ const many = Math.max(0, Math.floor(options.count ?? 0))
286
+ for (let made = 0; made < many; made++) {
287
+ const depth = DEPTH_FAR + random() * (DEPTH_NEAR - DEPTH_FAR)
288
+ const across = drawnAt(LENGTH, BED_OFF) * width
289
+ const size = across * (1 - DEPTH_SIZE + DEPTH_SIZE * depth) * (1 + (random() - 0.5) * 2 * ODD)
290
+ const x = random() * width
291
+
292
+ puffers.push({
293
+ body: [],
294
+ cut: 0,
295
+ depth,
296
+ eye: { x, y: 0 },
297
+ facing: random() < 0.5 ? -1 : 1,
298
+ size,
299
+ spines: [],
300
+ swell: 0,
301
+ x,
302
+ y: 0,
303
+ })
304
+ habits.push({
305
+ beat: BEAT_SLOWEST + random() * (BEAT_FASTEST - BEAT_SLOWEST),
306
+ clock: random() * Math.PI * 2,
307
+ drawn: null,
308
+ heading: 0,
309
+ hold: random() * HOLD_SPAN,
310
+ over: OVER_LEAST + random() * OVER_SPAN,
311
+ pace: 1 + (random() - 0.5) * 2 * DAWDLE,
312
+ })
313
+ }
314
+
315
+ for (let at = 0; at < puffers.length; at++) sit(at)
316
+ }
317
+
318
+ /** Put it where it hangs over the sand it is above. */
319
+ function sit(at: number): void {
320
+ const one = puffers[at]
321
+ const habit = habits[at]
322
+ if (!one || !habit) return
323
+
324
+ one.y = Math.min(height, floor(one.x, one.depth) - one.size * habit.over)
325
+ }
326
+
327
+ /**
328
+ * How much the water worries it, right now.
329
+ *
330
+ * The same rule the rest of the sea reads, and a different answer. What comes
331
+ * out of this is not where to go: it is how much water to swallow.
332
+ */
333
+ function minded(one: Puffer, water: readonly Presence[]): number {
334
+ let worst = 0
335
+
336
+ for (const thing of water) {
337
+ if (thing.size < one.size * MINDS) continue
338
+
339
+ const reach = thing.size * NOTICE
340
+ const away = Math.hypot(one.x - thing.x, one.y - thing.y)
341
+ if (away >= reach) continue
342
+
343
+ const near = (1 - away / reach) * abreast(one.depth, thing.depth)
344
+ worst = Math.max(worst, near * (BULK + (thing.menace ?? 0) * (1 - BULK)))
345
+ }
346
+
347
+ return worst < MINDS_AT ? 0 : worst
348
+ }
349
+
350
+ /**
351
+ * The outline, from an oval at rest to a circle full.
352
+ *
353
+ * One shape rather than two blended, because two drawings crossfaded is what
354
+ * a sprite sheet does and it shows: the halfway frame of a crossfade is two
355
+ * animals at once. What changes here is how long the body is against how wide
356
+ * it is, and every point on the outline is on the one animal the whole way
357
+ * through.
358
+ */
359
+ function cut(at: number): void {
360
+ const one = puffers[at]
361
+ const habit = habits[at]
362
+ if (!one || !habit) return
363
+
364
+ const wide = (one.size / 2) * (1 + one.swell * GROWTH)
365
+ const long = wide * (LONG_REST + (LONG_FULL - LONG_REST) * one.swell)
366
+
367
+ const body: Point[] = []
368
+ for (let step = 0; step < RIM; step++) {
369
+ const turn = (step / RIM) * Math.PI * 2
370
+ body.push({
371
+ x: one.x + Math.cos(turn) * long * one.facing,
372
+ y: one.y + Math.sin(turn) * wide,
373
+ })
374
+ }
375
+
376
+ // The tail, hung off the back and swept by its own clock. It is drawn as
377
+ // part of the outline rather than beside it, so a swollen fish has one
378
+ // silhouette instead of a ball with a leaf behind it.
379
+ const wag = Math.sin(habit.clock) * SWEEP
380
+ const stub = one.size * TAIL * (TAIL_KEPT + (1 - TAIL_KEPT) * (1 - one.swell))
381
+ const from = { x: one.x - long * one.facing, y: one.y }
382
+ const tip = {
383
+ x: from.x - Math.cos(wag) * stub * one.facing,
384
+ y: from.y + Math.sin(wag) * stub,
385
+ }
386
+ const fan = stub * TAIL_FAN
387
+
388
+ // Spliced at the back of the rim rather than at the seam, and across a few
389
+ // points of it rather than between two. The seam is where the outline
390
+ // wraps, which is the front of the animal, and a tail let in there is a
391
+ // wedge driven through the middle of the fish; let in between two adjacent
392
+ // points it is a wedge hung off one, which is a leaf on a string. What a
393
+ // tail actually joins is a stretch of the back.
394
+ const back = Math.round(RIM / 2)
395
+ const root = Math.max(1, Math.round(RIM * TAIL_ROOT))
396
+ one.body = [
397
+ ...body.slice(0, back - root),
398
+ { x: tip.x, y: tip.y + fan },
399
+ { x: tip.x, y: tip.y - fan },
400
+ ...body.slice(back + root),
401
+ ]
402
+
403
+ one.eye = {
404
+ x: one.x + long * EYE_ALONG * one.facing,
405
+ y: one.y - wide * EYE_UP,
406
+ }
407
+
408
+ // Triangles rather than lines, because a spine has a point on it and a
409
+ // stroke with a round cap has a thumb. Each one sits on a chord of the rim
410
+ // and leaves from the middle of it, so the base is the width the body can
411
+ // actually spare and the taper is the rim's own curvature.
412
+ one.spines = []
413
+ if (one.swell > SPINE_FROM) {
414
+ const out = ((one.swell - SPINE_FROM) / (1 - SPINE_FROM)) * one.size * SPINE_LONG
415
+ const seat = (Math.PI * 2 * SPINE_SEAT) / SPINES
416
+ for (let step = 0; step < SPINES; step++) {
417
+ const turn = (step / SPINES) * Math.PI * 2
418
+ const on = (off: number) => ({
419
+ x: one.x + Math.cos(turn + off) * long * one.facing,
420
+ y: one.y + Math.sin(turn + off) * wide,
421
+ })
422
+ one.spines.push([
423
+ on(-seat),
424
+ {
425
+ x: one.x + Math.cos(turn) * (long + out) * one.facing,
426
+ y: one.y + Math.sin(turn) * (wide + out),
427
+ },
428
+ on(seat),
429
+ ])
430
+ }
431
+ }
432
+
433
+ habit.drawn = { swell: one.swell, wag, x: one.x, y: one.y }
434
+ one.cut++
435
+ }
436
+
437
+ /** How far the drawing has drifted from what is on screen. */
438
+ function stir(at: number): number {
439
+ const one = puffers[at]
440
+ const habit = habits[at]
441
+ if (!one || !habit || !habit.drawn) return Number.POSITIVE_INFINITY
442
+
443
+ const was = habit.drawn
444
+ const wag = Math.sin(habit.clock) * SWEEP
445
+ return (
446
+ Math.hypot(one.x - was.x, one.y - was.y) +
447
+ Math.abs(one.swell - was.swell) * one.size +
448
+ Math.abs(wag - was.wag) * one.size * TAIL
449
+ )
450
+ }
451
+
452
+ fill()
453
+
454
+ return {
455
+ puffers,
456
+
457
+ resize(next: number, tall: number, ground: (x: number, depth?: number) => number): void {
458
+ width = Math.max(MIN_SPAN, next)
459
+ height = Math.max(MIN_SPAN, tall)
460
+ floor = ground
461
+ fill()
462
+ },
463
+
464
+ step(seconds: number): void {
465
+ const water = options.about?.() ?? []
466
+
467
+ for (let at = 0; at < puffers.length; at++) {
468
+ const one = puffers[at]
469
+ const habit = habits[at]
470
+ if (!one || !habit) continue
471
+
472
+ const worries = minded(one, water)
473
+
474
+ // The whole animal, in one line. It swells towards what the water
475
+ // deserves and settles back at a fraction of the rate, and it never
476
+ // steers on either: where it was going is where it is still going.
477
+ const rate = (worries > one.swell ? SWELL : SETTLE) * seconds
478
+ one.swell += Math.max(-rate, Math.min(rate, worries - one.swell))
479
+
480
+ habit.clock += seconds * habit.beat * Math.PI * 2
481
+
482
+ habit.hold -= seconds
483
+ if (habit.hold <= 0) {
484
+ habit.hold = HOLD_LEAST + random() * HOLD_SPAN
485
+ // A shallow pitch either side of level and never a turn, the reason
486
+ // `shoal.ts` gives: a drawn animal faces one way, and one that
487
+ // mirrored would turn inside out between two frames.
488
+ habit.heading = (random() - 0.5) * 0.6
489
+ }
490
+
491
+ // Slower the more swollen it is, because a ball is a worse shape to
492
+ // push through water than a fish is, and a pufferfish that kept its
493
+ // speed while doubling would be a pufferfish nothing had happened to.
494
+ const pace = SCULL * habit.pace * one.size * (1 - one.swell * 0.7)
495
+ one.x += Math.cos(habit.heading) * pace * seconds * one.facing
496
+ one.y += Math.sin(habit.heading) * pace * seconds
497
+
498
+ const edge = one.size * MARGIN
499
+ if (one.x < -edge) one.x += width + edge * 2
500
+ if (one.x > width + edge) one.x -= width + edge * 2
501
+
502
+ const over = floor(one.x, one.depth) - one.size * habit.over
503
+ one.y += (over - one.y) * Math.min(1, seconds)
504
+
505
+ if (stir(at) < tolerance) continue
506
+ cut(at)
507
+ }
508
+ },
509
+ }
510
+ }