@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,132 @@
1
+ /**
2
+ * The light in the water, and what is falling through it.
3
+ *
4
+ * Shafts from the surface, marine snow, and the bubbles off a vent. The
5
+ * numbers are `Seascape.qml`'s, because the two renderers are meant to be the
6
+ * same sea.
7
+ */
8
+ import type { Drift } from "../drift.ts"
9
+ import { type Rays, SPREAD } from "../rays.ts"
10
+ import { OWN, type Pen, type Point, TONE } from "./pen.ts"
11
+
12
+ /** How dark each of these may be, as a share of full ink. */
13
+ const RAY_INK = 0.07
14
+ const SNOW_INK = 0.15
15
+ const BUBBLE_INK = 0.3
16
+
17
+ /**
18
+ * How much light there is to make shafts out of, at the hour it is.
19
+ *
20
+ * Not none at night. A moon over calm water throws the same shafts a sun does,
21
+ * and a scene whose light went out altogether at dusk would be a scene that
22
+ * turned into a black rectangle for half of every day.
23
+ */
24
+ const MOON_RAYS = 0.28
25
+
26
+ /**
27
+ * How many copies each shaft is drawn as, and how much shorter each one inside
28
+ * it is.
29
+ *
30
+ * The plies are drawn one over another, so at any height they are the same fade
31
+ * repeated, and copies hung to the same depth step down at exactly the same
32
+ * heights: the eye is offered one edge as strong as all of them. Staggering
33
+ * them gives each step its own height, and the core of the shaft runs out above
34
+ * the glow around it, which is what a shaft does.
35
+ */
36
+ const PLIES = 4
37
+ const TAPER = 0.13
38
+
39
+ /** How many bubbles are ever drawn at once. */
40
+ const BUBBLES = 96
41
+
42
+ /** Where these sit against everything else. */
43
+ const LANE = { ray: -1 }
44
+
45
+ export function paintRays(pen: Pen, light: Rays, daylight: number): void {
46
+ const overcast = MOON_RAYS + (1 - MOON_RAYS) * daylight
47
+
48
+ for (const ray of light.rays) {
49
+ const slope = Math.tan(ray.tilt)
50
+ const mouth = ray.span / 2
51
+ const hem = (ray.span * SPREAD) / 2
52
+ const lit = (RAY_INK / PLIES) * overcast * ray.glow
53
+ if (lit <= 0) continue
54
+
55
+ for (let ply = 0; ply < PLIES; ply++) {
56
+ const inset = 1 - ply / PLIES
57
+ const drop = ray.reach * (1 - ply * TAPER)
58
+ const lean = drop * slope
59
+ const lip = mouth * inset
60
+ const flare = (mouth + (hem - mouth) * (ray.reach > 0 ? drop / ray.reach : 0)) * inset
61
+
62
+ pen.fill(
63
+ [
64
+ [
65
+ { x: ray.x - lip, y: 0 },
66
+ { x: ray.x + lip, y: 0 },
67
+ { x: ray.x + flare + lean, y: drop },
68
+ { x: ray.x - flare + lean, y: drop },
69
+ ],
70
+ ],
71
+ {
72
+ alpha: lit,
73
+ fade: [0, drop],
74
+ lane: LANE.ray,
75
+ spent: true,
76
+ tone: TONE.ink,
77
+ },
78
+ )
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Marine snow, sorted into the fish by depth rather than laid over them, so a
85
+ * near mote passes in front of a far animal and the water gets a thickness the
86
+ * fish alone cannot give it.
87
+ */
88
+ export function paintSnow(pen: Pen, drift: Drift): void {
89
+ for (const mote of drift.motes) {
90
+ pen.fill([disc(mote.x, mote.y, mote.r)], {
91
+ lane: mote.depth,
92
+ shade: mote.y,
93
+ weight: SNOW_INK * mote.depth,
94
+ })
95
+ }
96
+
97
+ // Rings rather than discs. A filled circle at this size is a dot, and a dot
98
+ // going up is a dot going up; an outline with the water showing through it is
99
+ // a bubble.
100
+ let drawn = 0
101
+ for (const bubble of drift.bubbles) {
102
+ if (drawn++ >= BUBBLES) break
103
+
104
+ pen.line([ring(bubble.x, bubble.y, bubble.r)], {
105
+ alpha: BUBBLE_INK * bubble.depth,
106
+ lane: bubble.depth,
107
+ shade: OWN,
108
+ tone: TONE.ink,
109
+ width: 1,
110
+ })
111
+ }
112
+ }
113
+
114
+ /** How many sides a small round thing is worth cutting into. */
115
+ const SIDES = 16
116
+
117
+ export function disc(cx: number, cy: number, r: number): Point[] {
118
+ const points: Point[] = []
119
+ for (let i = 0; i < SIDES; i++) {
120
+ const turn = (i / SIDES) * Math.PI * 2
121
+ points.push({ x: cx + r * Math.cos(turn), y: cy + r * Math.sin(turn) })
122
+ }
123
+ return points
124
+ }
125
+
126
+ /** The same, as a line that comes back to where it started. */
127
+ export function ring(cx: number, cy: number, r: number): Point[] {
128
+ const points = disc(cx, cy, r)
129
+ const first = points[0]
130
+ if (first) points.push(first)
131
+ return points
132
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The one thing a renderer is told, and the whole of it.
3
+ *
4
+ * `scene.ts` says where the ground is and how the plants are bending, which is
5
+ * a bed that stands still and is cut once. Everything else in this water moves:
6
+ * a fish is somewhere new, a mote has fallen, a shaft has breathed, and the
7
+ * moon is a different shape tonight than it was last night. All of that is
8
+ * written here, as drawings.
9
+ *
10
+ * A drawing is small on purpose. A shape, a colour to mix it from, how heavy it
11
+ * is, how it gives out, and how far back it stands. There is no fish in this
12
+ * file and no moon either: what a thing is stays in the ornament, and this says
13
+ * only what it looks like.
14
+ */
15
+
16
+ /** How a drawing is drawn. */
17
+ export const FORM = { fill: 0, light: 2, stroke: 1, wash: 3 } as const
18
+
19
+ /**
20
+ * Which colour it is mixed from.
21
+ *
22
+ * `water` is a weight towards the ink at the height it is drawn at, which is
23
+ * anything in the water lit from where the reader is standing; `shadow` is the
24
+ * same weight towards the surface colour, which is near rock with the light
25
+ * behind it. `wall` is the one thing here you can see through, and what you see
26
+ * through it is whatever the surface under the water is showing. The rest are
27
+ * the lights, and they are the only colours in this scene that are not the
28
+ * theme's.
29
+ */
30
+ export const TONE = {
31
+ dusk: 4,
32
+ ink: 6,
33
+ moon: 3,
34
+ shadow: 1,
35
+ sun: 2,
36
+ surface: 5,
37
+ wall: 7,
38
+ water: 0,
39
+ } as const
40
+
41
+ /**
42
+ * A light that gives out the way a shaft of sunlight in water does.
43
+ *
44
+ * `Ornament.fade`, which no single exponent draws: it holds most of its
45
+ * strength for the first half of its length and then goes quickly. Added to a
46
+ * tone rather than named as one, since it says how a thing fades rather than
47
+ * what colour it is.
48
+ */
49
+ export const SPENT = 0x200
50
+
51
+ /** Read the water at the height the fragment itself is at. */
52
+ export const OWN = -1
53
+
54
+ export interface Mark {
55
+ /** How much of it there is where it starts, before it fades. */
56
+ alpha?: number
57
+ /** Where a fade down the box starts and how far it runs. No span, no fade. */
58
+ fade?: readonly [number, number]
59
+ /** How fast it gives out, whether down the box or out from a middle. */
60
+ fall?: number
61
+ lane: number
62
+ /** The height the water under it is read at, or `OWN`. */
63
+ shade?: number
64
+ /**
65
+ * How far out of focus it is, in pixels of blur.
66
+ *
67
+ * Rock only, and it is the lens rather than the water: what is a hand from
68
+ * the glass is further outside the field than what is a long way behind the
69
+ * subject. Anything with this set is drawn on its own and softened before it
70
+ * is laid in, so drawings meant to be softened together, a mass of rock and
71
+ * what is growing on it, have to be written one after another.
72
+ */
73
+ soft?: number
74
+ /** Whether it ends on the light's own curve rather than on an exponent. */
75
+ spent?: boolean
76
+ /** How much of its height a round light keeps. Under 1 is a streak. */
77
+ thin?: number
78
+ tone?: number
79
+ weight?: number
80
+ /** How wide a line is, or how far a round light reaches. */
81
+ width?: number
82
+ }
83
+
84
+ export type Point = { x: number; y: number }
85
+
86
+ /**
87
+ * Where a unit drawing is stood, since most of what swims is written about the
88
+ * origin and put in the water afterwards.
89
+ *
90
+ * `facing` is a mirror rather than a turn, and it is applied before the turn:
91
+ * the ornament works out the whole angle for an animal that is swimming left,
92
+ * so turning a mirrored drawing again is how every renderer here has got a
93
+ * leftward fish upside down at least once.
94
+ */
95
+ export interface Spot {
96
+ facing?: number
97
+ scale?: number
98
+ tilt?: number
99
+ x: number
100
+ y: number
101
+ }
102
+
103
+ /**
104
+ * Drawings into the block of numbers the renderer reads.
105
+ *
106
+ * The count goes in first and is written again at the end, because how many
107
+ * there are is not known until the last one is drawn and the renderer has to
108
+ * read it before any of them.
109
+ */
110
+ /**
111
+ * How much room one drawing is assumed to want.
112
+ *
113
+ * A ridge is a few hundred points and a disc is seventy-two, so this is over
114
+ * the top of almost everything and the block is sized to hold far more than a
115
+ * frame anyway. It is a floor under the check rather than a budget: what it
116
+ * buys is that a block which does run out ends on a whole drawing rather than
117
+ * halfway through one, so a reader walking it never falls off the end.
118
+ */
119
+ const ELBOW = 4096
120
+
121
+ export class Pen {
122
+ private drawn = 0
123
+ private held = 0
124
+ private full = false
125
+
126
+ private readonly floats: Float32Array
127
+ private at: number
128
+
129
+ constructor(floats: Float32Array, at: number) {
130
+ this.floats = floats
131
+ this.at = at
132
+ this.held = at
133
+ this.put(0)
134
+ }
135
+
136
+ /** How many numbers were written, once every drawing is in. */
137
+ close(): number {
138
+ this.floats[this.held] = this.drawn
139
+ return this.at
140
+ }
141
+
142
+ fill(parts: readonly (readonly Point[])[], mark: Mark, spot?: Spot): void {
143
+ if (this.head(FORM.fill, mark)) this.parts(parts, spot)
144
+ }
145
+
146
+ line(parts: readonly (readonly Point[])[], mark: Mark, spot?: Spot): void {
147
+ if (this.head(FORM.stroke, mark)) this.parts(parts, spot)
148
+ }
149
+
150
+ /** A round light, which is a middle and a reach rather than a shape. */
151
+ light(x: number, y: number, mark: Mark): void {
152
+ if (!this.head(FORM.light, mark)) return
153
+ this.put(1)
154
+ this.put(1)
155
+ this.put(x)
156
+ this.put(y)
157
+ }
158
+
159
+ /** A wash over the whole box, which is what a night is. */
160
+ wash(mark: Mark): void {
161
+ if (this.head(FORM.wash, mark)) this.put(0)
162
+ }
163
+
164
+ /** Whether there was room for the drawing that was about to be made. */
165
+ private head(form: number, mark: Mark): boolean {
166
+ if (this.full) return false
167
+ if (this.at + ELBOW > this.floats.length) {
168
+ this.full = true
169
+ return false
170
+ }
171
+ this.drawn++
172
+ this.put(form)
173
+ this.put((mark.tone ?? TONE.water) | (mark.spent ? SPENT : 0))
174
+ this.put(mark.weight ?? 0)
175
+ this.put(mark.shade ?? OWN)
176
+ this.put(mark.alpha ?? 1)
177
+ this.put(mark.lane)
178
+ this.put(mark.width ?? 0)
179
+ this.put(mark.fall ?? 1)
180
+ this.put(mark.fade ? mark.fade[0] : 0)
181
+ this.put(mark.fade ? mark.fade[1] : 0)
182
+ this.put(mark.thin ?? 1)
183
+ this.put(mark.soft ?? 0)
184
+ return true
185
+ }
186
+
187
+ /**
188
+ * Every piece of one drawing, stood where it is seen.
189
+ *
190
+ * Several pieces rather than one, because an animal is not one outline: a
191
+ * fish is a body, a tail swung off its joint and a fin rooted inside the
192
+ * back, and they are meant to overlap. Handed over as one drawing they are
193
+ * filled as one silhouette; handed over as three they would each be painted
194
+ * over the last, and every overlap would show as a seam.
195
+ */
196
+ private parts(parts: readonly (readonly Point[])[], spot?: Spot): void {
197
+ this.put(parts.length)
198
+
199
+ const scale = spot?.scale ?? 1
200
+ const mirror = spot?.facing ?? 1
201
+ const turn = spot?.tilt ?? 0
202
+ const cos = Math.cos(turn)
203
+ const sin = Math.sin(turn)
204
+
205
+ for (const points of parts) {
206
+ this.put(points.length)
207
+ for (const point of points) {
208
+ if (!spot) {
209
+ this.put(point.x)
210
+ this.put(point.y)
211
+ continue
212
+ }
213
+ const x = point.x * scale * mirror
214
+ const y = point.y * scale
215
+ this.put(spot.x + x * cos - y * sin)
216
+ this.put(spot.y + x * sin + y * cos)
217
+ }
218
+ }
219
+ }
220
+
221
+ private put(value: number): void {
222
+ if (this.at < this.floats.length) this.floats[this.at] = value
223
+ this.at++
224
+ }
225
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * What the water sits on and what stands out of it: stones, near rock, the
3
+ * reef's heads, an island, and whatever is lying on the bottom.
4
+ *
5
+ * Everything here recedes the way the ground recedes rather than the way an
6
+ * animal does. The rock in front of the picture is the exception and is painted
7
+ * away from the ink instead: it is between the light and the reader, so what
8
+ * there is to see of it is that none of the light came back.
9
+ */
10
+ import { type Crags, SPRIGS } from "../crags.ts"
11
+ import type { Reef } from "../reef.ts"
12
+ import {
13
+ BLOCK_CARD,
14
+ BLOCK_LINES,
15
+ CHEST_BANDS,
16
+ CHEST_BODY,
17
+ CHEST_LOCK,
18
+ LAPTOP_BASE,
19
+ LAPTOP_LINES,
20
+ LAPTOP_SCREEN,
21
+ type Relics,
22
+ SMOKER,
23
+ WRECK,
24
+ WRECK_SPAR,
25
+ } from "../relics.ts"
26
+ import type { Seabed } from "../seabed.ts"
27
+ import { disc } from "./light.ts"
28
+ import { OWN, type Pen, type Point, TONE } from "./pen.ts"
29
+ import type { Box } from "./sky.ts"
30
+ import { trace } from "./trace.ts"
31
+
32
+ /** How dark each of these may be at the front of the picture. */
33
+ const CRAG_INK = 0.94
34
+ const FLORA_INK = 0.33
35
+ const HAZE_INK = 0.04
36
+ const ISLE_INK = 0.88
37
+ const PLUME_INK = 0.2
38
+ const RELIC_INK = 0.24
39
+ const STONE_INK = 0.25
40
+
41
+ /** How much of a weight is punched back out for a strap, a lock or a line. */
42
+ const CUT_SHADE = 0.34
43
+
44
+ /** What is left of a puff's own body over the last of its life. */
45
+ const PLUME_LET_GO = 0.25
46
+
47
+ /** How far forward a mass has to stand before it is rock you are among rather
48
+ * than rock you are looking at, and what grows on it. */
49
+ const CRAG_NEAR = 0.8
50
+
51
+ /**
52
+ * How far the lens gives up on the near rock, in pixels, at the front of the
53
+ * water, and each mass takes its own share of it by how far forward it stands.
54
+ *
55
+ * `Seascape.qml` says the same in a blur of 0.24 out of a `blurMax` of 48.
56
+ */
57
+ const CRAG_BLUR = 0.24 * 48
58
+ const PERCH_SCALE = 1.15
59
+ const PERCH_SHADE = 0.72
60
+
61
+ /** How thick a blade is against the strand it grows off. */
62
+ const BLADE_GIRTH = 0.8
63
+
64
+ /** How many relics and how much smoke are ever drawn at once. */
65
+ const MOST = { plume: 76, relics: 4 }
66
+
67
+ /** Where these sit against everything else. */
68
+ const LANE = { isle: -1.15, plume: -1.6 }
69
+
70
+ export function far(full: number, depth: number): number {
71
+ return HAZE_INK + (full - HAZE_INK) * Math.min(1, Math.max(0, depth))
72
+ }
73
+
74
+ /**
75
+ * A face of near rock, closed off past the edge it hangs from.
76
+ *
77
+ * The ground closes downwards because the ground is what the box is standing
78
+ * on. A wall is not: it comes in from the side and the mass of it is off the
79
+ * picture, so closing it under the box would fill the water in front of it as
80
+ * well. The roof is the same statement turned on its end.
81
+ */
82
+ function faced(points: readonly Point[], edge: string, box: Box): Point[] {
83
+ const out = points.map((p) => ({ x: p.x, y: p.y }))
84
+ if (!out.length) return out
85
+
86
+ const first = out[0]
87
+ const last = out.at(-1)
88
+ if (!first || !last) return out
89
+
90
+ const past = 60
91
+
92
+ if (edge === "top") {
93
+ out.push({ x: last.x, y: -past })
94
+ out.push({ x: first.x, y: -past })
95
+ return out
96
+ }
97
+
98
+ const side = edge === "left" ? -past : box.width + past
99
+ out.push({ x: side, y: last.y })
100
+ out.push({ x: side, y: first.y })
101
+ return out
102
+ }
103
+
104
+ /**
105
+ * A ridge with the ground under it closed off, below everything it is made of.
106
+ *
107
+ * Below the ridge rather than below the box, and the difference is one that
108
+ * only shows up in a renderer that cares whether an outline crosses itself. The
109
+ * near sand sits a hundredth of the box off the bottom and rolls a good deal
110
+ * further than that, so parts of it are under the box already: a line drawn
111
+ * across at the box's own floor would pass through the ridge rather than under
112
+ * it, and the shape would be a bowtie in as many places as the sand dips.
113
+ *
114
+ * Nobody sees the difference. Everything below the bottom edge is off the
115
+ * picture either way, and what it buys is an outline that is what it looks
116
+ * like.
117
+ */
118
+ export function floored(points: readonly Point[], box: Box): Point[] {
119
+ const out = points.map((p) => ({ x: p.x, y: p.y }))
120
+ const first = out[0]
121
+ const last = out.at(-1)
122
+ if (!first || !last) return out
123
+
124
+ let under = box.height
125
+ for (const point of out) under = Math.max(under, point.y)
126
+ under += 2
127
+
128
+ out.push({ x: last.x, y: under })
129
+ out.push({ x: first.x, y: under })
130
+ return out
131
+ }
132
+
133
+ /**
134
+ * Stones, lying where they fell.
135
+ *
136
+ * Rounded rather than drawn: at this weight a stone is a mass with a lean on
137
+ * it, and an outline would make it a pebble somebody had illustrated. A
138
+ * round-capped line is the same shape as a rectangle with a radius of half its
139
+ * height, which is what the other renderer draws one as.
140
+ */
141
+ export function paintStones(pen: Pen, seabed: Seabed): void {
142
+ for (const stone of seabed.stones) {
143
+ const run = Math.max(0, stone.span - stone.rise) / 2
144
+
145
+ pen.line(
146
+ [
147
+ [
148
+ { x: -run, y: 0 },
149
+ { x: run, y: 0 },
150
+ ],
151
+ ],
152
+ {
153
+ lane: -2 + stone.depth * 0.3,
154
+ shade: stone.y,
155
+ weight: far(STONE_INK, stone.depth),
156
+ width: stone.rise,
157
+ },
158
+ { tilt: stone.lean, x: stone.x, y: stone.y - stone.rise * 0.12 },
159
+ )
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The reef's heads: what took a place on the mound.
165
+ *
166
+ * A head is drawn the way a plant is, and which of the two kinds of drawing it
167
+ * carries is its own business: a stencil hands over twigs, and an animal hands
168
+ * over a stalk with whatever it has grown on the end of it.
169
+ */
170
+ export function paintHeads(pen: Pen, reef: Reef): void {
171
+ for (const head of reef.heads) {
172
+ const weight = far(FLORA_INK, head.depth)
173
+ const mark = { lane: -3 + head.depth, shade: head.y, weight }
174
+
175
+ if (head.blades.length > 0) {
176
+ pen.line([head.points], { ...mark, width: head.girth })
177
+ pen.line(head.blades, { ...mark, width: head.girth * BLADE_GIRTH })
178
+ }
179
+
180
+ if (head.twigs.length === 0) continue
181
+
182
+ // A stencil: the same drawing every frame, leaned where it grew and bent by
183
+ // the swell it is standing in. Coral does not move under its own steam, and
184
+ // with everything else on this rock moving it is what the movement is
185
+ // measured against.
186
+ const spot = { scale: head.scale, tilt: head.lean + head.bend, x: head.x, y: head.y }
187
+ for (const twig of head.twigs) {
188
+ pen.line(trace(twig.d), { ...mark, width: twig.width * head.scale }, spot)
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * The rock you are among, and what is living on it.
195
+ *
196
+ * Last of the ground and over everything including the boats: on the days this
197
+ * water has a roof, the roof is between the picture and the surface, and a hull
198
+ * drawn through it would be a hull inside the rock.
199
+ */
200
+ export function paintCrags(pen: Pen, crags: Crags, box: Box): void {
201
+ for (const crag of crags.rocks) {
202
+ const weight = CRAG_INK * crag.depth
203
+ const near = crag.depth >= CRAG_NEAR
204
+ const adrift = near ? (crag.depth - CRAG_NEAR) / Math.max(0.01, 1 - CRAG_NEAR) : 0
205
+ const lane = near ? crag.depth + 0.5 : crag.depth
206
+ const soft = CRAG_BLUR * adrift
207
+
208
+ pen.fill([faced(crag.outline, crag.edge, box)], {
209
+ lane,
210
+ shade: OWN,
211
+ soft,
212
+ tone: TONE.shadow,
213
+ weight,
214
+ })
215
+
216
+ // What is growing on it, one colony at a time. Drawn a shade off the rock
217
+ // rather than in the rock's own weight: a silhouette on a silhouette is a
218
+ // silhouette, and the whole reason these are here is that a bare wall is a
219
+ // shape somebody cut out of paper.
220
+ for (const perch of crag.perches) {
221
+ const unit = perch.size * PERCH_SCALE * crag.depth
222
+ // The drawing grows up out of nothing; the perch says which way is out of
223
+ // the rock, and a quarter turn is the difference between them.
224
+ const spot = { scale: unit, tilt: perch.lean + Math.PI / 2, x: perch.x, y: perch.y }
225
+
226
+ for (const twig of SPRIGS[perch.kind]) {
227
+ pen.line(
228
+ trace(twig.d),
229
+ {
230
+ lane,
231
+ shade: perch.y,
232
+ soft,
233
+ tone: TONE.shadow,
234
+ weight: weight * PERCH_SHADE,
235
+ width: twig.width * unit,
236
+ },
237
+ spot,
238
+ )
239
+ }
240
+ }
241
+ }
242
+
243
+ // An island, where the ground climbed the whole way and came out. It goes
244
+ // dark rather than bright for the reason near rock does: a shore lit from
245
+ // behind is a silhouette, and everybody has seen that one.
246
+ if (crags.isle) {
247
+ pen.fill([floored(crags.isle.outline, box)], {
248
+ lane: LANE.isle,
249
+ shade: OWN,
250
+ tone: TONE.shadow,
251
+ weight: ISLE_INK,
252
+ })
253
+ }
254
+ }
255
+
256
+ /**
257
+ * What is lying on the bottom, and what is coming out of it.
258
+ *
259
+ * Behind the sand rather than in front of it: a wreck lies half buried, and the
260
+ * sand is what buries it.
261
+ */
262
+ export function paintRelics(pen: Pen, wreckage: Relics): void {
263
+ let drawn = 0
264
+ for (const one of wreckage.relics) {
265
+ if (drawn++ >= MOST.relics) break
266
+
267
+ const weight = RELIC_INK * one.depth
268
+ const spot = { scale: one.scale, tilt: one.lean, x: one.x, y: one.y }
269
+ const mark = { lane: -3 + one.depth, shade: one.y, weight }
270
+ const cut = { ...mark, weight: weight * CUT_SHADE }
271
+
272
+ const body =
273
+ one.kind === "wreck"
274
+ ? WRECK
275
+ : one.kind === "smoker"
276
+ ? SMOKER
277
+ : one.kind === "chest"
278
+ ? CHEST_BODY
279
+ : BLOCK_CARD
280
+ pen.fill(trace(body), mark, spot)
281
+
282
+ // The wreck's spar, which is what stops leaning verticals reading
283
+ // as two dead trees.
284
+ if (one.kind === "wreck") {
285
+ pen.line([WRECK_SPAR], { ...mark, width: 0.03 * one.scale }, spot)
286
+ }
287
+
288
+ // The code block's three lines, cut out of the card in the surface rather
289
+ // than drawn in ink, the way a fish's eye is. A snippet is a card with
290
+ // light coming through it, not a card with marks on it.
291
+ if (one.kind === "block") {
292
+ for (const line of BLOCK_LINES) pen.fill(trace(line), cut, spot)
293
+ }
294
+
295
+ // The chest, and the laptop still open on its lid. The straps and the lock
296
+ // are cut out in the surface; the screen is the one thing in the whole sea
297
+ // drawn at full ink.
298
+ if (one.kind === "chest") {
299
+ for (const band of CHEST_BANDS) pen.fill(trace(band), cut, spot)
300
+ pen.fill(trace(CHEST_LOCK), cut, spot)
301
+ pen.fill(trace(LAPTOP_BASE), mark, spot)
302
+ pen.fill(trace(LAPTOP_SCREEN), { ...mark, tone: TONE.ink, weight: 1 }, spot)
303
+ for (const row of LAPTOP_LINES) {
304
+ pen.fill(trace(row), { ...mark, tone: TONE.surface, weight: 1 }, spot)
305
+ }
306
+ }
307
+ }
308
+
309
+ // What the smoker is pouring: the only thing on the bottom that moves under
310
+ // its own steam rather than the water's, which is what makes a vent read as
311
+ // hot. Opaque for most of its life, like everything else that is a thing
312
+ // rather than light, and letting go of the last of the alpha as well, because
313
+ // by then the puff is the colour of what is behind it.
314
+ let puffed = 0
315
+ for (const puff of wreckage.plume) {
316
+ if (puffed++ >= MOST.plume) break
317
+
318
+ pen.fill([disc(puff.x, puff.y, puff.r)], {
319
+ alpha: Math.min(1, (1 - puff.age) / PLUME_LET_GO),
320
+ lane: LANE.plume,
321
+ shade: puff.y,
322
+ weight: PLUME_INK * Math.sqrt(Math.max(0, 1 - puff.age)),
323
+ })
324
+ }
325
+ }