@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/seabed.ts ADDED
@@ -0,0 +1,525 @@
1
+ /**
2
+ * The bottom: what the water sits on, and what is standing further out.
3
+ *
4
+ * The ground layer of a seascape, kept free of the DOM the way the rest of the
5
+ * ornament is. Nothing here moves, so there is no `step`. A floor that drifted
6
+ * would be an earthquake.
7
+ *
8
+ * ## Why a seascape needs one
9
+ *
10
+ * Fish, snow and light give water a volume but no bottom, and water with no
11
+ * bottom has no scale in it: a picture of animals at unknown distances. Put a
12
+ * floor in and everything above it acquires a height. It is also the thing
13
+ * every other layer needs to exist at all, since kelp has to be rooted, a vent
14
+ * has to come out of something, and a wreck has to be lying on it.
15
+ *
16
+ * ## The floor
17
+ *
18
+ * A profile sampled across the box, not a straight line and not a jagged one.
19
+ * Two octaves of the same Perlin field the fish read: a long swell that puts
20
+ * the whole bed high at one end, and a shorter one for the lumps. Anything more
21
+ * and it reads as a mountain range seen from a helicopter, which is a landscape
22
+ * rather than a seabed.
23
+ *
24
+ * `floorAt` is the interface the rest of the scene actually uses, and it is
25
+ * exact rather than a lookup into `ridge`: a stone or a kelp plant sitting a
26
+ * pixel above the sand looks like it is hovering, and that is the sort of
27
+ * mistake that is invisible in a still and obvious in motion.
28
+ *
29
+ * ## The floor has a second axis, and it is distance
30
+ *
31
+ * `floorAt(x, depth)` is the ground under something standing that far back. The
32
+ * whole bed used to be one line, which meant every rooted thing in the water
33
+ * stood on the same line: a hundred plants of graded sizes all growing out of
34
+ * one edge, which reads as a stage rather than as a place. Depth is a picture's
35
+ * first subject and a single ridge cannot carry it.
36
+ *
37
+ * So the ground climbs the box as it goes back, its swells grow broad, and it
38
+ * reads a different lane of the same field at each distance, which is what
39
+ * keeps the far ground a different shape rather than the near one lifted. Where
40
+ * a far hill and a near one overlap is where the eye reads the gap between
41
+ * them, and the dip between two hills is a valley for free.
42
+ *
43
+ * `ranges` are that ground cut at a handful of distances, which is what a
44
+ * renderer draws. They are ground rather than scenery: things root to them, and
45
+ * a plant at a given depth is standing on `floorAt` at that same depth.
46
+ *
47
+ * ## What is standing further out
48
+ *
49
+ * Cliffs, in the murk behind all of it. They are not terrain the scene uses;
50
+ * nothing roots to them and nothing collides with them. They exist because a
51
+ * silhouette at the back of the water is the cheapest possible statement that
52
+ * the water continues past the edge of the picture, and without one the scene
53
+ * is a tank.
54
+ *
55
+ * They stand on the ground at their own distance, which is the furthest ground
56
+ * there is, and they rise well above it: a cliff that stopped at the same
57
+ * height as what it stands on would read as more ground.
58
+ */
59
+
60
+ import { makeNoise2, makeRandom } from "./perlin.ts"
61
+
62
+ export interface Point {
63
+ readonly x: number
64
+ readonly y: number
65
+ }
66
+
67
+ export interface Stone {
68
+ /** How far back it lies, which is its weight and most of its size. */
69
+ readonly depth: number
70
+ /** How far it is turned, in radians. Stones are not upright. */
71
+ readonly lean: number
72
+ /** How wide and how tall, in px. */
73
+ readonly rise: number
74
+ readonly span: number
75
+ readonly x: number
76
+ readonly y: number
77
+ }
78
+
79
+ export interface Cliff {
80
+ /** How far back it stands, which is all its weight. */
81
+ readonly depth: number
82
+ /** Its skyline, left to right, running past both edges of the box. */
83
+ readonly ridge: readonly Point[]
84
+ }
85
+
86
+ /** The ground at one distance, from the furthest to just behind the sand. */
87
+ export interface Range {
88
+ readonly depth: number
89
+ /** Its outline, left to right along the top. */
90
+ readonly ridge: readonly Point[]
91
+ }
92
+
93
+ export interface Seabed {
94
+ readonly cliffs: readonly Cliff[]
95
+ /**
96
+ * The y of the ground at x, for anything that has to sit on it.
97
+ *
98
+ * `depth` is how far forward the thing standing there is, 1 at the front of
99
+ * the picture and towards 0 in the murk. It defaults to the front, so a
100
+ * caller with nothing to say about distance gets the sand it always got.
101
+ */
102
+ floorAt(x: number, depth?: number): number
103
+ readonly ranges: readonly Range[]
104
+ resize(width: number, height: number): void
105
+ /** The sand's own outline, left to right. */
106
+ readonly ridge: readonly Point[]
107
+ readonly stones: readonly Stone[]
108
+ }
109
+
110
+ export interface SeabedOptions {
111
+ cliffs?: number
112
+ height: number
113
+ ranges?: number
114
+ seed: number
115
+ stones?: number
116
+ width: number
117
+ }
118
+
119
+ /**
120
+ * How much of the box is left under the nearest ground there is, and how far
121
+ * that ground rolls.
122
+ *
123
+ * Almost none, and that is the whole of it. It used to be a tenth, which read
124
+ * as a shelf: the ground at the front of the picture stopped a tenth of the way
125
+ * up the box and the bottom of the screen was the face of it, seen from above,
126
+ * with nothing on it. Nothing could be on it either, since the nearest a rooted
127
+ * thing goes is this line, so every plant, stone and crab in the scene stood
128
+ * along one edge with bare sand below them.
129
+ *
130
+ * Bringing it to the floor of the box is what gives the front of the picture
131
+ * somewhere to be. The near end of every distance in the scene now lands at the
132
+ * bottom edge, so the bed spreads down to it and the nearest plants are cut off
133
+ * by it, which is what being close to something looks like. The ground is no
134
+ * less shallow for it: what closes the bottom of the scene is the stack of
135
+ * bands behind this one, each shut under the box, and they are where they were.
136
+ */
137
+ const FLOOR_SEAT = 0.01
138
+
139
+ const FLOOR_ROLL = 0.085
140
+
141
+ /**
142
+ * The same two, for the furthest ground the water has.
143
+ *
144
+ * It sits a third of the way up the box, which is as far back as a seascape can
145
+ * go before the ground is level with the fish and the picture has no sky in it.
146
+ * It rolls harder than the sand does because what is up there is hills: the
147
+ * near bed is a floor with lumps on it, and the far one is country.
148
+ */
149
+ const HORIZON_SEAT = 0.36
150
+
151
+ const HORIZON_ROLL = 0.12
152
+
153
+ /** How many features of the long swell and the short one fit across the box. */
154
+ const SWELL_CELLS = 1.3
155
+
156
+ const LUMP_CELLS = 5.5
157
+
158
+ /**
159
+ * The same two at the horizon, where everything is broader.
160
+ *
161
+ * Distance takes the detail out of ground long before it takes the shape, which
162
+ * is why a far hill is a silhouette and a near one is a slope with rubble on
163
+ * it. Fewer features across the same box is the whole of that.
164
+ */
165
+ const HORIZON_SWELL_CELLS = 0.8
166
+
167
+ const HORIZON_LUMP_CELLS = 2.1
168
+
169
+ /**
170
+ * How fast the ground climbs the box as it goes back.
171
+ *
172
+ * Distance on a flat plane is not linear on the screen and never has been: the
173
+ * ground a step in front of your foot takes a third of the picture and the
174
+ * ground a mile off takes a hair, and everything between the two is squeezed
175
+ * towards the horizon. Held to a straight line, bands of ground come out evenly
176
+ * spaced, and evenly spaced lines across a picture are a contour map.
177
+ *
178
+ * An exponent rather than a proper projection, because there is no camera here
179
+ * and nothing to be right about: what it has to do is bunch at the back and
180
+ * open out at the front, which is the whole of what the eye is reading.
181
+ */
182
+ const DEPTH_FALL = 1.6
183
+
184
+ /**
185
+ * How far the field is walked as the ground goes back.
186
+ *
187
+ * Distance is the second axis of one Perlin field, so this is how much of it a
188
+ * screen's worth of distance crosses. Too little and the far hills are the near
189
+ * lumps drawn higher, which is the one thing that gives a flat picture away;
190
+ * this is enough that no two distances share a shape.
191
+ */
192
+ const DISTANCE_LANES = 3.1
193
+
194
+ /** How much of the roll belongs to the lumps rather than the swell. */
195
+ const LUMP_SHARE = 0.34
196
+
197
+ /** How finely the sand's outline is cut. */
198
+ const RIDGE_STEPS = 96
199
+
200
+ /** How far past each side the outline runs, so no edge shows a seam. */
201
+ const OVERHANG = 40
202
+
203
+ /** The ends of a stone's width in px, and how tall one is against that. */
204
+ const STONE_SMALLEST = 9
205
+
206
+ const STONE_LARGEST = 38
207
+
208
+ const STONE_SQUAT = 0.62
209
+
210
+ /** How far a stone may be turned, in radians. */
211
+ const STONE_LEAN = 0.5
212
+
213
+ /**
214
+ * How far back stones lie, and how much of a stone's drawn size is that.
215
+ *
216
+ * They go back further than a plant does, because scree is what a slope is made
217
+ * of and a slope with rubble only along its foot is a slope somebody swept.
218
+ * Nothing like the whole of the size, though: a stone at a tenth of its size is
219
+ * a grain of sand, and a beach of those is a texture rather than a distance.
220
+ */
221
+ const STONE_FAR = 0.3
222
+
223
+ const STONE_NEAR = 1
224
+
225
+ const STONE_SHRINK = 0.55
226
+
227
+ /**
228
+ * How far back the ground is cut, from the furthest band to the nearest.
229
+ *
230
+ * The near end stops well short of the sand, because two ridges a hand apart
231
+ * read as one ridge that has been drawn twice. What is wanted is a clear gap
232
+ * between what you are standing on and what is behind it.
233
+ */
234
+ const RANGE_FAR = 0.08
235
+
236
+ const RANGE_NEAR = 0.75
237
+
238
+ /**
239
+ * How high a cliff stands above the ground it is on, as a share of the box, and
240
+ * how wide.
241
+ *
242
+ * Wide, because a cliff is being read at a distance where the only information
243
+ * is its outline. A small one is a rock, and a rock at the back of the water is
244
+ * a stone somebody has drawn badly. Not as tall as it once was: it used to
245
+ * stand on the sand at the front of the picture and had a whole box to climb,
246
+ * and it stands on the horizon now, where the water above it is all there is
247
+ * left.
248
+ *
249
+ * Both come off one roll of the dice, so a summit that is high is broad and one
250
+ * that is low is narrow. Rolled apart, the tall end of one meets the narrow end
251
+ * of the other every so often, and what that draws is a tooth: sides at sixty
252
+ * degrees coming to a point, which no mountain has and nothing standing in
253
+ * water keeps.
254
+ */
255
+ const CLIFF_RISE_LEAST = 0.06
256
+
257
+ const CLIFF_RISE_SPAN = 0.16
258
+
259
+ const CLIFF_SPAN_LEAST = 0.12
260
+
261
+ const CLIFF_SPAN_SPAN = 0.26
262
+
263
+ /**
264
+ * How finely a range's skyline is cut.
265
+ *
266
+ * Finer than it was. It used to be one hill a few hundred px wide and fourteen
267
+ * steps was plenty for that; it is the whole width of the box and then some
268
+ * now, and fourteen steps across a wall would be a summit with three sides.
269
+ *
270
+ * Finer again, because the skyline carries what grows on it, and a fringe cut
271
+ * at thirteen px a step is a row of thumbs rather than a scruff. Points are the
272
+ * cheap half of the bill down here: a range is one shape however many of them
273
+ * its outline holds.
274
+ */
275
+ const CLIFF_STEPS = 280
276
+
277
+ /**
278
+ * How far the growth on a skyline stands off it, as a share of the box, how
279
+ * many stretches of it there are across the width, and how much of a range has
280
+ * nothing on it at all.
281
+ *
282
+ * Bare rock at this distance is a shape somebody forgot to finish, which is the
283
+ * argument `crags.ts` makes about the near wall and is no less true of the far
284
+ * one. Nothing up there can be told apart: a hill at the back of the water is
285
+ * an outline, so what a plant on it amounts to is that the outline stops being
286
+ * smooth. Colonies rather than an even fur, for the reason a near face is
287
+ * crowded in places and bare in others, and the same reason a summit under a
288
+ * lawn is a hill somebody has drawn hair on.
289
+ */
290
+ const FRINGE_RISE = 0.038
291
+
292
+ const FRINGE_PATCHES = 3.4
293
+
294
+ const FRINGE_BARE = 0.12
295
+
296
+ /** How far back the ranges stand. Nearly the colour of the water, and no nearer. */
297
+ const CLIFF_DEEP = 0.05
298
+
299
+ const CLIFF_NEAR = 0.16
300
+
301
+ /**
302
+ * How many ranges the summits are shared out between.
303
+ *
304
+ * Each is a skyline of its own at its own distance, so what the eye gets is one
305
+ * range standing behind another rather than a row of hills at one remove. Three,
306
+ * because two is a pair and four at these weights is two the reader can find and
307
+ * two they cannot.
308
+ */
309
+ const CLIFF_RANGES = 3
310
+
311
+ /** How far apart two stands of growth on a skyline are, in px. */
312
+ const STRAND_SPAN = 26
313
+
314
+ const MIN_SPAN = 1
315
+
316
+ /** Where between two numbers a distance falls, 0 at the back and 1 at the front. */
317
+ const mix = (far: number, near: number, at: number): number => far + (near - far) * at
318
+
319
+ export function createSeabed(options: SeabedOptions): Seabed {
320
+ const colonies = makeNoise2(options.seed ^ 0xc010)
321
+ const sprigs = makeNoise2(options.seed ^ 0x5197)
322
+ const swell = makeNoise2(options.seed ^ 0x5eab)
323
+ const lumps = makeNoise2(options.seed ^ 0x10c5)
324
+ const random = makeRandom(options.seed ^ 0x2d0e)
325
+
326
+ let width = Math.max(MIN_SPAN, options.width)
327
+ let height = Math.max(MIN_SPAN, options.height)
328
+
329
+ /**
330
+ * The ground at x, as far back as `depth` says.
331
+ *
332
+ * Everything a distance changes about ground is here and nowhere else, which
333
+ * is what lets a plant, a stone and a whole cut band all agree about where
334
+ * the bottom is at their own distance. It climbs, it broadens, it flattens
335
+ * its detail, and it reads its own lane of the field so that it is a
336
+ * different piece of country rather than the same one drawn higher.
337
+ */
338
+ function floorAt(x: number, depth = 1): number {
339
+ const near = Math.max(0, Math.min(1, depth))
340
+ const lane = (1 - near) * DISTANCE_LANES
341
+ const up = near ** DEPTH_FALL
342
+
343
+ const seat = height * (1 - mix(HORIZON_SEAT, FLOOR_SEAT, up))
344
+ const roll = height * mix(HORIZON_ROLL, FLOOR_ROLL, up)
345
+ const long = swell((x / width) * mix(HORIZON_SWELL_CELLS, SWELL_CELLS, near), lane)
346
+ const short = lumps((x / width) * mix(HORIZON_LUMP_CELLS, LUMP_CELLS, near), lane + 4.7)
347
+
348
+ return seat - (long * (1 - LUMP_SHARE) + short * LUMP_SHARE) * roll
349
+ }
350
+
351
+ function cutRidge(depth: number): Point[] {
352
+ const points: Point[] = []
353
+ for (let at = 0; at <= RIDGE_STEPS; at++) {
354
+ const x = -OVERHANG + (at / RIDGE_STEPS) * (width + OVERHANG * 2)
355
+ points.push({ x, y: floorAt(x, depth) })
356
+ }
357
+ return points
358
+ }
359
+
360
+ /**
361
+ * The ground cut at each distance, furthest first.
362
+ *
363
+ * Spread evenly rather than rolled, because bands are read as a stack: two
364
+ * that landed at the same distance would be one band with a seam in it, and
365
+ * the whole of what they are for is the gap between them.
366
+ */
367
+ function cutRanges(count: number): Range[] {
368
+ const bands: Range[] = []
369
+ for (let at = 0; at < count; at++) {
370
+ const depth =
371
+ count < 2 ? RANGE_FAR : RANGE_FAR + (at / (count - 1)) * (RANGE_NEAR - RANGE_FAR)
372
+ bands.push({ depth, ridge: cutRidge(depth) })
373
+ }
374
+ return bands
375
+ }
376
+
377
+ /**
378
+ * How far something growing at x stands off the skyline there.
379
+ *
380
+ * Every other point of the outline and none of the ones between, which is
381
+ * what makes this read as growth rather than as more rock: a strand is a
382
+ * spike with a gap beside it, and a smooth field at any frequency is a lump
383
+ * however small it is drawn. What varies is how tall each one is and whether
384
+ * there is anything there at all, and both are read off the water rather than
385
+ * rolled, so a range's fringe is a fact about that range and comes back the
386
+ * same on a resize.
387
+ */
388
+ function fringe(x: number, depth: number, at: number): number {
389
+ if (at % 2 === 0) return 0
390
+
391
+ const patch = colonies((x / width) * FRINGE_PATCHES, depth * 7.3)
392
+ if (patch < FRINGE_BARE) return 0
393
+
394
+ const thick = (patch - FRINGE_BARE) / (1 - FRINGE_BARE)
395
+ const stand = Math.abs(sprigs(x / STRAND_SPAN, depth * 11.7))
396
+
397
+ return height * FRINGE_RISE * thick * (0.4 + stand * 0.6)
398
+ }
399
+
400
+ /**
401
+ * One cliff, as a lump of its own.
402
+ *
403
+ * A summit is a half-cosine rather than noise, so it has a peak and two
404
+ * shoulders instead of a row of equal bumps, and the noise is what stops the
405
+ * two shoulders matching. That much is the only thing between one of these and
406
+ * a hill drawn by a child.
407
+ *
408
+ * What is returned is a whole skyline and not one hill. Each used to be its
409
+ * own little polygon dropped on the bed, and a shape that stops somewhere in
410
+ * the middle of the picture has ends, which is a problem no amount of colour
411
+ * fixes: shut under the box, the way a band of ground is, it grew two vertical
412
+ * walls running the height of the picture and a mountain came to a straight
413
+ * edge and fell off it. Shut along its own foot instead, the ends taper to a
414
+ * knife and lie on the ground in front like a crack in it.
415
+ *
416
+ * A range has no ends. This runs past both edges of the box exactly as the
417
+ * ground does, its summits are the highest of however many domes are standing
418
+ * at that x, and what closes it is off the side of the picture where nobody
419
+ * is looking. Between the summits it is simply the ground at its own distance,
420
+ * which is what the land between two mountains is.
421
+ */
422
+ function raise(depth: number, peaks: number): Cliff {
423
+ const domes = Array.from({ length: Math.max(1, peaks) }, () => {
424
+ const bulk = random()
425
+
426
+ return {
427
+ middle: random() * width,
428
+ rise: height * (CLIFF_RISE_LEAST + bulk * CLIFF_RISE_SPAN),
429
+ rough: makeNoise2((random() * 0xffff) | 0),
430
+ span: width * (CLIFF_SPAN_LEAST + bulk * CLIFF_SPAN_SPAN),
431
+ }
432
+ })
433
+
434
+ const ridge: Point[] = []
435
+ for (let at = 0; at <= CLIFF_STEPS; at++) {
436
+ const x = -OVERHANG + (at / CLIFF_STEPS) * (width + OVERHANG * 2)
437
+
438
+ let lift = 0
439
+ for (const dome of domes) {
440
+ const t = (x - (dome.middle - dome.span / 2)) / dome.span
441
+ if (t <= 0 || t >= 1) continue
442
+ const swell = (1 - Math.cos(t * Math.PI * 2)) / 2
443
+ lift = Math.max(lift, dome.rise * swell * (1 + dome.rough(t * 3.1, 0) * 0.34))
444
+ }
445
+
446
+ ridge.push({ x, y: floorAt(x, depth) - lift - fringe(x, depth, at) })
447
+ }
448
+
449
+ return { depth, ridge }
450
+ }
451
+
452
+ function settle(): Stone {
453
+ const x = random() * width
454
+ const depth = STONE_FAR + random() * (STONE_NEAR - STONE_FAR)
455
+ const shrink = 1 - STONE_SHRINK + STONE_SHRINK * depth
456
+ const span = (STONE_SMALLEST + random() ** 1.5 * (STONE_LARGEST - STONE_SMALLEST)) * shrink
457
+
458
+ return {
459
+ depth,
460
+ lean: (random() - 0.5) * STONE_LEAN * 2,
461
+ rise: span * STONE_SQUAT * (0.7 + random() * 0.6),
462
+ span,
463
+ x,
464
+ y: floorAt(x, depth),
465
+ }
466
+ }
467
+
468
+ let ridge = cutRidge(1)
469
+ let ranges = cutRanges(Math.max(0, options.ranges ?? 0))
470
+ /**
471
+ * The ranges, furthest first, with the summits shared out between them.
472
+ *
473
+ * `cliffs` is a count of summits rather than of ranges, which is what a caller
474
+ * counting against the width of its box is actually asking about: a wider
475
+ * screen wants more peaks along the same skylines, not more skylines.
476
+ */
477
+ function skylines(): Cliff[] {
478
+ const summits = Math.max(0, options.cliffs ?? 0)
479
+ if (summits === 0) return []
480
+
481
+ return Array.from({ length: CLIFF_RANGES }, (_, at) => {
482
+ const step = CLIFF_RANGES > 1 ? at / (CLIFF_RANGES - 1) : 0
483
+ const share =
484
+ Math.round((summits * (at + 1)) / CLIFF_RANGES) - Math.round((summits * at) / CLIFF_RANGES)
485
+
486
+ return raise(CLIFF_DEEP + step * (CLIFF_NEAR - CLIFF_DEEP), share)
487
+ })
488
+ }
489
+
490
+ let cliffs = skylines()
491
+ let stones = Array.from({ length: Math.max(0, options.stones ?? 0) }, settle)
492
+
493
+ return {
494
+ get cliffs() {
495
+ return cliffs
496
+ },
497
+ floorAt,
498
+ get ranges() {
499
+ return ranges
500
+ },
501
+
502
+ /**
503
+ * A resized box is rebuilt rather than scaled.
504
+ *
505
+ * Everything here is a pure function of the seed and the box, so rebuilding
506
+ * gives the same bed the same box would have had from the start. Scaling
507
+ * would stretch the sand's own grain, and a seabed that got wider when a
508
+ * window did would be the one thing on screen admitting it is a drawing.
509
+ */
510
+ resize(nextWidth, nextHeight) {
511
+ width = Math.max(MIN_SPAN, nextWidth)
512
+ height = Math.max(MIN_SPAN, nextHeight)
513
+ ridge = cutRidge(1)
514
+ ranges = cutRanges(ranges.length)
515
+ cliffs = skylines()
516
+ stones = stones.map(settle)
517
+ },
518
+ get ridge() {
519
+ return ridge
520
+ },
521
+ get stones() {
522
+ return stones
523
+ },
524
+ }
525
+ }