@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/relics.ts ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * What is lying on the bottom, and what is coming out of it.
3
+ *
4
+ * Set pieces: a wreck, a volcanic smoker, and the two things down there that
5
+ * are jokes rather than scenery. Kept free of the DOM the way the rest of the
6
+ * ornament is.
7
+ *
8
+ * ## Rarity is the feature
9
+ *
10
+ * Everything here is placed by a roll of the seeded dice, and most rolls come
11
+ * up empty. That is deliberate and it is the only thing making any of it worth
12
+ * drawing: a wreck in every sea is set dressing, and a wreck in one sea out of
13
+ * three is something you found. The same water always has the same relics,
14
+ * because the seed decides, so it is a place rather than a slot machine.
15
+ *
16
+ * A consequence worth stating: most people will never see the chest. That is
17
+ * the correct outcome. Ornament that everybody sees every time has to justify
18
+ * itself to everybody every time, and this does not have to justify itself at
19
+ * all.
20
+ *
21
+ * ## The smoker
22
+ *
23
+ * A chimney, and a plume coming off it. The plume is the only part of the
24
+ * seabed that moves under its own steam rather than the water's, which is what
25
+ * makes a vent read as hot: everything else down there leans when the current
26
+ * leans, and this goes straight up regardless and then gets bent.
27
+ *
28
+ * ## The joke
29
+ *
30
+ * `chest` is the site's own, from `Marks.chest/1`: a chest with a laptop open
31
+ * on its lid, screen still lit. The site's rule for it is kept here, because
32
+ * the rule is most of why it works. It is never placed beside anything that
33
+ * explains it, and it is drawn small and half behind whatever is in front of
34
+ * it, for somebody who looks.
35
+ *
36
+ * The screen is the one thing in the entire seascape drawn at full weight. Down
37
+ * in the dark, under a hundred faint animals, there is a lit rectangle the size
38
+ * of a fingernail. Nothing else is allowed to be that bright, which is what
39
+ * makes it findable at all.
40
+ */
41
+
42
+ import { makeNoise2, makeRandom } from "./perlin.ts"
43
+ import type { Point } from "./seabed.ts"
44
+
45
+ export type RelicKind = "block" | "chest" | "smoker" | "wreck"
46
+
47
+ export interface Relic {
48
+ /** 0 is as far off as this water goes, 1 is up against the glass. */
49
+ readonly depth: number
50
+ readonly kind: RelicKind
51
+ /** How far it has settled over, in radians. Nothing down there is level. */
52
+ readonly lean: number
53
+ /** px per unit of the drawing. */
54
+ readonly scale: number
55
+ readonly x: number
56
+ readonly y: number
57
+ }
58
+
59
+ export interface Puff {
60
+ /** How far through its life it is, 0 new and 1 gone. */
61
+ age: number
62
+ r: number
63
+ /**
64
+ * This one's own share of the climb.
65
+ *
66
+ * Every puff used to rise at exactly the rate every other puff rose at, and
67
+ * a column of identical circles let go at an even beat is a string of beads,
68
+ * not a plume. One number each, drawn once, and the stack breaks up.
69
+ */
70
+ readonly rise: number
71
+ x: number
72
+ y: number
73
+ }
74
+
75
+ export interface Relics {
76
+ readonly plume: readonly Puff[]
77
+ readonly relics: readonly Relic[]
78
+ resize(width: number, height: number, floor: (x: number, depth?: number) => number): void
79
+ step(seconds: number): void
80
+ }
81
+
82
+ export interface RelicsOptions {
83
+ /** The ground at x, at a given distance; `seabed.floorAt`. */
84
+ floor: (x: number, depth?: number) => number
85
+ height: number
86
+ seed: number
87
+ width: number
88
+ }
89
+
90
+ /**
91
+ * The chance each relic is in this water at all.
92
+ *
93
+ * The wreck is the likeliest because it is the one that pays off as scenery
94
+ * even when you are not looking for it. The chest is the rarest by a distance,
95
+ * for the reason in the module note.
96
+ */
97
+ const ODDS: Readonly<Record<RelicKind, number>> = {
98
+ block: 0.34,
99
+ chest: 0.1,
100
+ smoker: 0.5,
101
+ wreck: 0.4,
102
+ }
103
+
104
+ /**
105
+ * How big each is, in px per unit of its own drawing, before depth takes its
106
+ * share.
107
+ *
108
+ * Per unit and not px across, so a renderer never has to know how wide any of
109
+ * these shapes happens to be. The wreck and the chest are both two units wide
110
+ * and the smoker is one, and that is between each drawing and its own comment.
111
+ */
112
+ const SIZES: Readonly<Record<RelicKind, readonly [number, number]>> = {
113
+ block: [17, 26],
114
+ chest: [20, 29],
115
+ smoker: [38, 66],
116
+ wreck: [95, 170],
117
+ }
118
+
119
+ /** How far one may have settled over, in radians. */
120
+ const LEAN: Readonly<Record<RelicKind, number>> = {
121
+ block: 0.6,
122
+ chest: 0.12,
123
+ smoker: 0.05,
124
+ wreck: 0.16,
125
+ }
126
+
127
+ /**
128
+ * How far back a relic stands. Never up against the glass, never in the murk.
129
+ *
130
+ * The far end used to reach 0.35, which was harmless while every relic was
131
+ * planted on the near sand whatever it claimed its distance was. Standing them
132
+ * on the ground they are actually drawn at put them among the bands of hills,
133
+ * which run to about 0.75, and a wreck behind a rise is a wreck with its masts
134
+ * showing on a good day and nothing at all on most.
135
+ *
136
+ * Half buried is worth having and worth keeping rare: it is what a wreck seen
137
+ * across a slope really looks like. So the range starts in front of most of the
138
+ * ground rather than behind it, and the odd one that lands behind a crest is
139
+ * the odd one, not the usual case.
140
+ */
141
+ const DEPTH_FAR = 0.6
142
+
143
+ const DEPTH_NEAR = 0.9
144
+
145
+ /** How often a smoker lets go of a puff, in seconds. */
146
+ const PUFF_GAP = 0.13
147
+
148
+ /** How much faster or slower than the rest one puff may climb. */
149
+ const PUFF_SLOWEST = 0.72
150
+
151
+ const PUFF_FASTEST = 1.28
152
+
153
+ /** How long one lasts, and how fast it climbs at first, in px a second. */
154
+ const PUFF_LIFE = 8
155
+
156
+ const PUFF_RISE = 52
157
+
158
+ /**
159
+ * How much it slows as it spreads, and how fast it swells.
160
+ *
161
+ * The swell is held down harder than looks right on paper. A puff's area goes
162
+ * up with the square of its radius while its weight only comes down with its
163
+ * age, so one that doubles in size has spread the same ink over four times the
164
+ * water and vanished; the plume then reads as a few dots at the vent and
165
+ * nothing above them, which is what it did at nine.
166
+ */
167
+ const PUFF_DRAG = 0.62
168
+
169
+ const PUFF_SWELL = 4.5
170
+
171
+ /** How far the water carries a plume sideways, in px a second. */
172
+ const PUFF_SWAY = 16
173
+
174
+ /**
175
+ * How many features of the current fit across the box, and up it.
176
+ *
177
+ * Far more up than across, because that is the difference between a current
178
+ * and a wind. Water moves in layers: what is going one way at the floor is
179
+ * going another two metres above it, and a plume crossing those layers is
180
+ * pulled apart by them. Read at one feature per column height the whole plume
181
+ * gets one push and leans like smoke.
182
+ */
183
+ const FIELD_CELLS = 2.4
184
+
185
+ const FIELD_ROWS = 5.5
186
+
187
+ const MIN_SPAN = 1
188
+
189
+ /**
190
+ * How many draws to throw away before the first decision is taken on one.
191
+ *
192
+ * The generator is an xorshift, and an xorshift's first outputs still carry the
193
+ * shape of the seed it was given: neighbouring seeds start with neighbouring
194
+ * numbers. Nothing else in the ornament notices, because everything else asks
195
+ * for hundreds of draws and only the first few are poor. This asks for four,
196
+ * and they decide the whole contents of the sea, so a run of seeds came out
197
+ * with a wreck and a smoker in almost every one of them and never much else.
198
+ *
199
+ * Discarding a handful first is the standard answer and it is enough here.
200
+ */
201
+ const WARMUP = 12
202
+
203
+ export function createRelics(options: RelicsOptions): Relics {
204
+ const random = makeRandom(options.seed ^ 0x3c2f)
205
+ const shear = makeNoise2(options.seed ^ 0xa71c)
206
+
207
+ let width = Math.max(MIN_SPAN, options.width)
208
+ let height = Math.max(MIN_SPAN, options.height)
209
+ let floor = options.floor
210
+ let since = 0
211
+ let drift = 0
212
+
213
+ const relics: Relic[] = []
214
+ const plume: Puff[] = []
215
+
216
+ /**
217
+ * What is in this sea, from its own generator started afresh.
218
+ *
219
+ * Its own, and restarted every time, because what a sea contains has to be a
220
+ * pure function of its seed. Drawing from the shared stream meant a resize
221
+ * dealt a second hand: a window dragged wider, or a monitor coming back from
222
+ * sleep, and the wreck was gone and there was a chest instead. That is a
223
+ * wallpaper whose contents nobody can rely on having seen, and it is also why
224
+ * the same sea could not be looked at twice while any of this was being tuned.
225
+ *
226
+ * The warm-up is separate from that and needed for its own reason; see
227
+ * `WARMUP`.
228
+ */
229
+ function place(): void {
230
+ const roll = makeRandom(options.seed ^ 0x8e11)
231
+ for (let spun = 0; spun < WARMUP; spun++) roll()
232
+
233
+ relics.length = 0
234
+
235
+ for (const kind of ["wreck", "smoker", "block", "chest"] as const) {
236
+ if (roll() >= ODDS[kind]) continue
237
+
238
+ const [least, most] = SIZES[kind]
239
+ const depth = DEPTH_FAR + roll() * (DEPTH_NEAR - DEPTH_FAR)
240
+ // Kept out of the corners: a wreck half off the edge of the picture is a
241
+ // wreck somebody cropped.
242
+ const x = width * (0.12 + roll() * 0.76)
243
+
244
+ relics.push({
245
+ depth,
246
+ kind,
247
+ lean: (roll() - 0.5) * LEAN[kind] * 2,
248
+ scale: (least + roll() * (most - least)) * depth,
249
+ x,
250
+ // Its own distance, which is the whole of the point. The ground is a
251
+ // different line at every distance and this asked for the one at the
252
+ // front, so a wreck standing a long way back was planted on the sand
253
+ // that belongs against the glass: a shape drawn at its own distance and
254
+ // sitting somewhere else entirely.
255
+ y: floor(x, depth),
256
+ })
257
+ }
258
+ }
259
+
260
+ const smoker = (): Relic | undefined => relics.find((one) => one.kind === "smoker")
261
+
262
+ place()
263
+
264
+ return {
265
+ plume,
266
+ relics,
267
+
268
+ resize(nextWidth, nextHeight, nextFloor) {
269
+ width = Math.max(MIN_SPAN, nextWidth)
270
+ height = Math.max(MIN_SPAN, nextHeight)
271
+ floor = nextFloor
272
+ plume.length = 0
273
+ place()
274
+ },
275
+
276
+ step(seconds) {
277
+ const dt = Math.min(Math.max(seconds, 0), 0.1)
278
+ drift += dt * 0.05
279
+
280
+ const vent = smoker()
281
+ if (vent) {
282
+ since += dt
283
+ if (since >= PUFF_GAP) {
284
+ since = 0
285
+ plume.push({
286
+ age: 0,
287
+ r: vent.scale * (0.07 + random() * 0.06),
288
+ rise: PUFF_SLOWEST + random() * (PUFF_FASTEST - PUFF_SLOWEST),
289
+ x: vent.x + (random() - 0.5) * vent.scale * 0.3,
290
+ y: vent.y - vent.scale * SMOKER_LIP,
291
+ })
292
+ }
293
+ }
294
+
295
+ for (let at = plume.length - 1; at >= 0; at--) {
296
+ const puff = plume[at]
297
+ if (!puff) continue
298
+
299
+ puff.age += dt / PUFF_LIFE
300
+ if (puff.age >= 1 || puff.y + puff.r < 0) {
301
+ plume.splice(at, 1)
302
+ continue
303
+ }
304
+
305
+ // Quick off the chimney, and nearly stalled by the top of its life.
306
+ // Hot water in cold water rises until it is no warmer than what it has
307
+ // climbed into, and then it stops and lies there; a plume that kept its
308
+ // speed to the end would be a jet, and a jet is a thing under pressure.
309
+ const climb = PUFF_RISE * puff.rise * (1 - PUFF_DRAG * puff.age)
310
+ puff.y -= climb * dt
311
+
312
+ // Spreading fastest where it is slowest, which is what turns the top of
313
+ // a plume into a layer rather than a point.
314
+ puff.r += PUFF_SWELL * (0.55 + puff.age) * dt
315
+
316
+ // A current, not a wind.
317
+ //
318
+ // This used to multiply the push by the puff's own age, so every puff
319
+ // was shoved harder than the one below it and the column leaned off in
320
+ // one direction and kept leaning: smoke off a chimney on a breezy day.
321
+ // Water does not do that. A current is the same push on everything in
322
+ // it, it varies far more with depth than with time, and it changes its
323
+ // mind, so what a plume does in it is lean one way, straighten, and
324
+ // lean the other. Reading the field finely up the column is what puts
325
+ // the snake in it; taking the age out is what stops it being weather.
326
+ puff.x +=
327
+ shear((puff.x / width) * FIELD_CELLS + drift, (puff.y / height) * FIELD_ROWS + drift) *
328
+ PUFF_SWAY *
329
+ dt
330
+ }
331
+ },
332
+ }
333
+ }
334
+
335
+ /**
336
+ * A wreck, in a hull two units long and lying on the origin.
337
+ *
338
+ * A silhouette and nothing else. At the weight anything down here is drawn,
339
+ * detail is wasted: what carries a wreck is the one line nothing in nature
340
+ * makes, which is a long straight sheer with a broken end on it. So the deck is
341
+ * straight, the bow is intact, and the stern is gone.
342
+ *
343
+ * The masts are what stop it reading as a rock. They are the only part that
344
+ * leaves the ground, and leaning verticals over a long horizontal suggest
345
+ * a ship at any size and any distance.
346
+ */
347
+ export const WRECK =
348
+ "M-1 0 L-0.62 -0.2 L0.52 -0.24 Q0.86 -0.23 1 -0.06 L0.94 0.04 Q0.4 0.11 -0.3 0.09 Z " +
349
+ "M-0.62 -0.2 L-0.58 -0.34 L0.44 -0.37 L0.52 -0.24 Z " +
350
+ "M0.1 -0.36 L0.16 -0.92 L0.23 -0.36 Z " +
351
+ "M-0.3 -0.35 L-0.2 -0.76 L-0.16 -0.35 Z"
352
+
353
+ /** The spar across the standing mast, so the ship has rigging without any. */
354
+ export const WRECK_SPAR: readonly Point[] = [
355
+ { x: 0.02, y: -0.74 },
356
+ { x: 0.3, y: -0.66 },
357
+ ]
358
+
359
+ /**
360
+ * A smoker, in a chimney one unit wide sitting on the origin.
361
+ *
362
+ * Tall and thin, and that proportion is the whole drawing. A chimney as wide as
363
+ * it is high is a mountain, which is what this was first: the thing a vent is
364
+ * has to read at a glance and there are only two facts available at this size,
365
+ * that it is narrow and that it is far taller than anything else on the floor.
366
+ *
367
+ * Not symmetrical, and stepped rather than smooth, because it built itself out
368
+ * of what it was pouring out. The lip is offset from the base for the same
369
+ * reason: nothing that grew from a leak grew straight.
370
+ */
371
+ export const SMOKER =
372
+ "M-0.5 0 L-0.3 -0.7 L-0.22 -1.5 L-0.16 -2.1 L-0.2 -2.42 L-0.02 -2.5 " +
373
+ "L0.06 -2.16 L0.14 -2.34 L0.16 -1.9 L0.24 -1.2 L0.42 0 Z"
374
+
375
+ /**
376
+ * How far up the drawing the throat is, in the same units.
377
+ *
378
+ * Exported because the plume has to leave from it, and read off the drawing
379
+ * rather than guessed at. A plume that starts anywhere else comes out of the
380
+ * side of the chimney, and a chimney venting sideways is a hole in a rock.
381
+ */
382
+ export const SMOKER_LIP = 2.45
383
+
384
+ /**
385
+ * A sunken code block, in a card two units wide, centred on the origin.
386
+ *
387
+ * The site's `Marks.code_block/1`: a card and three lines of unequal length
388
+ * with the middle one longest, which is the shape of a snippet at any size.
389
+ *
390
+ * The lines are paths and not rectangles, which is not a style choice. A
391
+ * renderer draws these inside a scale of a hundred or more, and a rectangle a
392
+ * fifth of a unit tall is rounded to a whole pixel before that scale is applied
393
+ * and comes out twenty units tall. Everything here is under a unit, so
394
+ * everything here is a path.
395
+ */
396
+ export const BLOCK_CARD = "M-1 -0.73 L1 -0.73 L1 0.73 L-1 0.73 Z"
397
+
398
+ export const BLOCK_LINES: readonly string[] = [
399
+ "M-0.62 -0.45 L0.2 -0.45 L0.2 -0.29 L-0.62 -0.29 Z",
400
+ "M-0.62 -0.08 L0.74 -0.08 L0.74 0.08 L-0.62 0.08 Z",
401
+ "M-0.62 0.29 L-0.03 0.29 L-0.03 0.45 L-0.62 0.45 Z",
402
+ ]
403
+
404
+ /**
405
+ * The chest, in a body two units wide sitting on the origin.
406
+ *
407
+ * `body` is the whole silhouette, trunk and domed lid in one closed path; the
408
+ * seam, the hasp and the laptop are separate because each is drawn in a
409
+ * different weight, and the screen is drawn in a weight nothing else in the sea
410
+ * is allowed.
411
+ *
412
+ * The trunk and the lid are one subpath and not two, which took a render to
413
+ * find: they meet along a shared edge, and two subpaths meeting along an edge
414
+ * are two subpaths a renderer is entitled to cancel against each other. What
415
+ * came out was a box with nothing on top of it.
416
+ *
417
+ * Wider than it is tall, with a shallow dome, which is the shape everybody's
418
+ * idea of a chest has.
419
+ */
420
+ export const CHEST_BODY = "M-1 0 L-1 -0.62 Q-1 -1.12 0 -1.12 Q1 -1.12 1 -0.62 L1 0 Z"
421
+
422
+ /**
423
+ * The one line across it, at the seam between lid and trunk, and the hasp.
424
+ *
425
+ * One, and it took cutting three to see why. Every strap is subtracted from the
426
+ * silhouette in the colour of the water, so each one is a hole; the chest is
427
+ * fifty pixels wide in the scene, and three holes in fifty pixels is not a
428
+ * chest with straps on it, it is six small blocks in a row. What survives at
429
+ * that size is the seam, because the seam is the only line that says the thing
430
+ * opens.
431
+ */
432
+ export const CHEST_BANDS: readonly string[] = ["M-1 -0.66 L1 -0.66 L1 -0.58 L-1 -0.58 Z"]
433
+
434
+ export const CHEST_LOCK = "M-0.13 -0.72 L0.13 -0.72 L0.13 -0.44 L-0.13 -0.44 Z"
435
+
436
+ /**
437
+ * The laptop open on the lid: the screen, the base under it, and the three
438
+ * lines still on the screen.
439
+ *
440
+ * Leaning back the way an open one does, and small against the chest, because
441
+ * what makes this land is finding it rather than being shown it.
442
+ */
443
+ export const LAPTOP_SCREEN = "M-0.44 -1.94 L0.3 -2.04 L0.38 -1.2 L-0.36 -1.1 Z"
444
+
445
+ export const LAPTOP_BASE = "M-0.42 -1.14 L0.46 -1.24 L0.56 -1.1 L-0.34 -1 Z"
446
+
447
+ export const LAPTOP_LINES: readonly string[] = [
448
+ "M-0.3 -1.82 L0.02 -1.86 L0.02 -1.78 L-0.3 -1.74 Z",
449
+ "M-0.3 -1.66 L0.16 -1.72 L0.16 -1.64 L-0.3 -1.58 Z",
450
+ "M-0.3 -1.5 L-0.08 -1.53 L-0.08 -1.45 L-0.3 -1.42 Z",
451
+ ]