dsh-live-trace 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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,641 @@
1
+ /**
2
+ * What is happening outside the window.
3
+ *
4
+ * The world keeps its own clock: **one in-game minute passes per real second**,
5
+ * so a full day takes twenty-four real minutes and there is always something
6
+ * moving up there. Time and weather are both pure functions of the clock, so
7
+ * two viewers watching the same session see the same sky.
8
+ *
9
+ * @module dsh-live-working/sky
10
+ */
11
+
12
+ /** Minutes in an in-game day. */
13
+ export const MINUTES_PER_DAY = 1440
14
+
15
+ /** In-game minutes per real second. */
16
+ export const MINUTES_PER_SECOND = 1
17
+
18
+ /** Weather the sky can be in. */
19
+ export const WEATHER = ['clear', 'cloudy', 'rain', 'storm', 'fog', 'snow']
20
+
21
+ /** In-game minutes each spell of weather lasts. */
22
+ export const WEATHER_MINUTES = 180
23
+
24
+ /**
25
+ * The in-game clock, in minutes since midnight.
26
+ *
27
+ * Derived from the wall clock rather than from process start, so the world does
28
+ * not reset every time the command does.
29
+ *
30
+ * @param {number} [realMs]
31
+ * @returns {number} 0 .. 1439
32
+ */
33
+ export function gameMinutesAt(realMs = Date.now()) {
34
+ const seconds = Math.floor((Number.isFinite(realMs) ? realMs : Date.now()) / 1000) * MINUTES_PER_SECOND
35
+ return ((seconds % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
36
+ }
37
+
38
+ /** `06:05` for the corner of the window. */
39
+ export function clockLabel(minutes) {
40
+ const total = Math.floor(minutes)
41
+ const hours = Math.floor(total / 60) % 24
42
+ const mins = total % 60
43
+ return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`
44
+ }
45
+
46
+ /** Sky keyframes through the day: in-game minute, then colour. */
47
+ const SKY_KEYS = [
48
+ [0, [10, 12, 30]],
49
+ [270, [18, 22, 52]],
50
+ [330, [58, 52, 96]],
51
+ [390, [168, 108, 108]],
52
+ [435, [240, 150, 92]],
53
+ [510, [124, 178, 238]],
54
+ [660, [104, 182, 246]],
55
+ [840, [112, 186, 244]],
56
+ [990, [140, 176, 226]],
57
+ [1050, [250, 152, 88]],
58
+ [1125, [196, 92, 84]],
59
+ [1200, [72, 58, 108]],
60
+ [1290, [24, 26, 58]],
61
+ [1440, [10, 12, 30]]
62
+ ]
63
+
64
+ /** Interpolate the two keyframes either side of `minutes`. */
65
+ export function skyRgbAt(minutes) {
66
+ const at = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
67
+ for (let index = 1; index < SKY_KEYS.length; index += 1) {
68
+ const [endMinute, endRgb] = SKY_KEYS[index]
69
+ if (at > endMinute) continue
70
+ const [startMinute, startRgb] = SKY_KEYS[index - 1]
71
+ const span = endMinute - startMinute
72
+ const t = span <= 0 ? 0 : (at - startMinute) / span
73
+ return [
74
+ Math.round(startRgb[0] + (endRgb[0] - startRgb[0]) * t),
75
+ Math.round(startRgb[1] + (endRgb[1] - startRgb[1]) * t),
76
+ Math.round(startRgb[2] + (endRgb[2] - startRgb[2]) * t)
77
+ ]
78
+ }
79
+ return SKY_KEYS[SKY_KEYS.length - 1][1]
80
+ }
81
+
82
+ /**
83
+ * The nearest xterm-256 colour for an RGB triple.
84
+ *
85
+ * The 6x6x6 cube at 16..231 is coarse but it is the right shape for a sky: it
86
+ * has the blues, and it is a dozen lines rather than a lookup table.
87
+ */
88
+ export function xterm256(r, g, b) {
89
+ const step = (value) => Math.max(0, Math.min(5, Math.round((value / 255) * 5)))
90
+ return 16 + 36 * step(r) + 6 * step(g) + step(b)
91
+ }
92
+
93
+ /**
94
+ * How much light the weather takes out of the sky.
95
+ *
96
+ * Rain looks wrong under a clear blue sky: overcast is the whole point of it.
97
+ */
98
+ const WEATHER_DIM = { clear: 1, cloudy: 0.82, rain: 0.62, storm: 0.45, fog: 0.72, snow: 0.78 }
99
+
100
+ /**
101
+ * The sky colour, dimmed by whatever the weather is doing.
102
+ *
103
+ * @param {number} minutes
104
+ * @param {{ kind: string, intensity: number } | null} [weather]
105
+ */
106
+ export function skyRgbFor(minutes, weather = null) {
107
+ const [r, g, b] = skyRgbAt(minutes)
108
+ if (weather === null || weather === undefined) return [r, g, b]
109
+ const floor = WEATHER_DIM[weather.kind] ?? 1
110
+ const factor = 1 - (1 - floor) * Math.max(0, Math.min(1, weather.intensity))
111
+ return [Math.round(r * factor), Math.round(g * factor), Math.round(b * factor)]
112
+ }
113
+
114
+ /** The sky colour as a raw SGR parameter string. */
115
+ export function skySgrAt(minutes, weather = null) {
116
+ const [r, g, b] = skyRgbFor(minutes, weather)
117
+ return `38;5;${xterm256(r, g, b)}`
118
+ }
119
+
120
+ /** A 4x4 ordered-dither matrix. Two colours and this makes a gradient. */
121
+ export const BAYER = [
122
+ [0, 8, 2, 10],
123
+ [12, 4, 14, 6],
124
+ [3, 11, 1, 9],
125
+ [15, 7, 13, 5]
126
+ ]
127
+
128
+ /** Is the dither cell at (x, y) dark or light, for a blend of 0..1? */
129
+ export function ditherAt(x, y, blend) {
130
+ return (BAYER[((y % 4) + 4) % 4][((x % 4) + 4) % 4] + 0.5) / 16 < blend
131
+ }
132
+
133
+ /**
134
+ * Every colour the window paints with, derived from one sky colour.
135
+ *
136
+ * The sky is never one flat colour: it is darker overhead and lighter towards
137
+ * the horizon, and at dawn and dusk the horizon goes warm while the top stays
138
+ * cold. Two colours and an ordered dither are enough to draw that — a terminal
139
+ * has no alpha, but it does have a grid.
140
+ *
141
+ * @param {number} minutes
142
+ * @param {{ kind: string, intensity: number } | null} [weather]
143
+ */
144
+ export function skyColors(minutes, weather = null, sgr = (rgb) => `38;5;${xterm256(...rgb)}`) {
145
+ const base = skyRgbFor(minutes, weather)
146
+ const scale = (factor) => base.map((channel) => Math.max(0, Math.min(255, Math.round(channel * factor))))
147
+
148
+ // The room is lit by the window, so its walls follow the sky. A fixed mid
149
+ // grey made midnight look like noon indoors: a great slab of light grey next
150
+ // to a black sky.
151
+ const brightness = skyBrightness(base)
152
+ const light = Math.max(0.16, Math.min(1, brightness / 520))
153
+ const mix = (a, b, t) => a.map((channel, index) => Math.round(channel + (b[index] - channel) * t))
154
+ const dim = (rgb, factor) => rgb.map((channel) => Math.max(0, Math.min(255, Math.round(channel * factor))))
155
+ // Fog is a haze: the sky's own colour lifted towards white, not a grey slab.
156
+ // A fixed light grey read as a solid band, which is what made it shout.
157
+ const fog = mix(base, [235, 238, 242], 0.34)
158
+ const wall = mix([42, 45, 58], [181, 176, 166], light)
159
+ const wallShade = dim(wall, 0.74)
160
+ const skirting = dim(wall, 0.5)
161
+ const floor = dim(wall, 0.26)
162
+ const low = scale(1.2)
163
+ const phase = phaseAt(minutes)
164
+ if (phase === 'dawn' || phase === 'dusk') {
165
+ // The horizon burns while the top of the sky stays cold.
166
+ low[0] = Math.min(255, low[0] + 45)
167
+ low[2] = Math.max(0, low[2] - 25)
168
+ }
169
+ return {
170
+ top: base,
171
+ low,
172
+ landscape: scale(0.34),
173
+ glow: scale(1.5),
174
+ wall,
175
+ fog,
176
+ sgr: {
177
+ fog: sgr(fog),
178
+ sky: sgr(base),
179
+ skyLow: sgr(low),
180
+ landscape: sgr(scale(0.34)),
181
+ glow: sgr(scale(1.5)),
182
+ wall: sgr(wall),
183
+ wallShade: sgr(wallShade),
184
+ skirting: sgr(skirting),
185
+ floor: sgr(floor)
186
+ }
187
+ }
188
+ }
189
+
190
+ /** How bright the sky is, 0 (black) to 765 (white). */
191
+ export function skyBrightness(rgb) {
192
+ return rgb[0] + rgb[1] + rgb[2]
193
+ }
194
+
195
+ /** Where the day is: `night`, `dawn`, `day` or `dusk`. */
196
+ export function phaseAt(minutes) {
197
+ const at = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
198
+ if (at < 300 || at >= 1260) return 'night'
199
+ if (at < 480) return 'dawn'
200
+ if (at < 1020) return 'day'
201
+ if (at < 1200) return 'dusk'
202
+ return 'night'
203
+ }
204
+
205
+ /**
206
+ * The sun or the moon, as a position across the window.
207
+ *
208
+ * The sun is up from 06:00 to 18:00 and the moon takes the other half, so there
209
+ * is always exactly one body in the sky — and it always rises on the left and
210
+ * sets on the right.
211
+ *
212
+ * @returns {{ body: 'sun' | 'moon', x: number, y: number }}
213
+ */
214
+ export function celestialAt(minutes) {
215
+ const at = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
216
+ const day = at >= 360 && at < 1080
217
+ const t = day ? (at - 360) / 720 : (((at - 1080 + MINUTES_PER_DAY) % MINUTES_PER_DAY) / 720)
218
+ return {
219
+ body: day ? 'sun' : 'moon',
220
+ x: t,
221
+ // A shallow arc: high at the middle of its shift, on the sill at the ends.
222
+ y: Math.sin(Math.PI * t)
223
+ }
224
+ }
225
+
226
+ /** A stable pseudo-random number in [0, 1) from an integer. */
227
+ export function hash(value) {
228
+ let x = Math.imul(Math.trunc(value) ^ 0x9e3779b9, 0x85ebca6b)
229
+ x ^= x >>> 13
230
+ x = Math.imul(x, 0xc2b2ae35)
231
+ x ^= x >>> 16
232
+ return (x >>> 0) / 4294967296
233
+ }
234
+
235
+ /**
236
+ * The weather right now.
237
+ *
238
+ * Each spell lasts `WEATHER_MINUTES` and is picked from the spell's number, so
239
+ * the sky changes on its own without a random number generator and without
240
+ * disagreeing between two viewers. It fades in and out at the edges so weather
241
+ * does not snap on.
242
+ *
243
+ * @returns {{ kind: string, intensity: number, spell: number }}
244
+ */
245
+ export function weatherAt(minutes) {
246
+ const at = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
247
+ const spell = Math.floor(at / WEATHER_MINUTES)
248
+ const into = at - spell * WEATHER_MINUTES
249
+ const index = Math.floor(hash(spell * 7919) * WEATHER.length) % WEATHER.length
250
+ let kind = WEATHER[index]
251
+ const phase = phaseAt(at)
252
+ // Snow is the same weather as rain, one temperature away.
253
+ if (kind === 'snow' && phase !== 'night') kind = 'rain'
254
+ if (kind === 'rain' && phase === 'night') kind = 'snow'
255
+
256
+ // Fade in over the first, and out over the last, eighth of the spell.
257
+ const edge = WEATHER_MINUTES / 8
258
+ const intensity = Math.max(0, Math.min(1, Math.min(into, WEATHER_MINUTES - into) / edge))
259
+ return { kind, intensity, spell }
260
+ }
261
+
262
+ /**
263
+ * Everything the window needs, from one reading of the clock.
264
+ *
265
+ * @param {number} [realMs]
266
+ */
267
+ export function skyState(realMs = Date.now()) {
268
+ const minutes = gameMinutesAt(realMs)
269
+ const weather = weatherAt(minutes)
270
+ const celestial = celestialAt(minutes)
271
+ const rgb = skyRgbFor(minutes, weather)
272
+ return {
273
+ minutes,
274
+ clock: clockLabel(minutes),
275
+ phase: phaseAt(minutes),
276
+ weather,
277
+ celestial,
278
+ rgb,
279
+ colors: skyColors(minutes, weather),
280
+ sgr: `38;5;${xterm256(...rgb)}`
281
+ }
282
+ }
283
+
284
+ /* ------------------------------------------------------------------ *
285
+ * Painting it
286
+ * ------------------------------------------------------------------ */
287
+
288
+ import { canvas, rect } from './props.js'
289
+
290
+ /** Star positions are stable for a given night, not re-rolled every frame. */
291
+ function starAt(index, width, height) {
292
+ return {
293
+ x: Math.floor(hash(index * 3 + 11) * width),
294
+ y: Math.floor(hash(index * 7 + 29) * Math.max(1, height - 2))
295
+ }
296
+ }
297
+
298
+ /** A disc, as an ellipse of whole cells. */
299
+ function disc(grid, cx, cy, radius, cls) {
300
+ const r = Math.max(1, Math.round(radius))
301
+ for (let dy = -r; dy <= r; dy += 1) {
302
+ for (let dx = -r; dx <= r; dx += 1) {
303
+ if (dx * dx + dy * dy > r * r) continue
304
+ const px = cx + dx
305
+ const py = cy + dy
306
+ if (py < 0 || py >= grid.length || px < 0 || px >= grid[0].length) continue
307
+ grid[py][px] = cls
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * The wall the window is set into.
314
+ *
315
+ * Without it the scene floats on whatever the terminal's background happens to
316
+ * be, and anything drawn outside the window — the sleeping `z`s, most of all —
317
+ * has nothing to be read against.
318
+ *
319
+ * @param {{ width: number, height: number }} size in pixels
320
+ * @param {number} deskTop the y of the desk, below which the floor starts
321
+ */
322
+ export function drawWall(grid, size, deskTop) {
323
+ const width = grid[0].length
324
+ const wallBottom = Math.max(1, Math.min(grid.length - 1, deskTop + 2))
325
+ rect(grid, 0, 0, width - 1, wallBottom, 'W')
326
+ // Faint vertical paper stripes, so a large flat area is not dead flat.
327
+ for (let x = 3; x < width; x += 9) {
328
+ for (let y = 0; y <= wallBottom; y += 1) grid[y][x] = 'V'
329
+ }
330
+ // Skirting board along the bottom, where the wall meets the floor.
331
+ const skirtingTop = Math.max(0, deskTop - 3)
332
+ rect(grid, 0, skirtingTop, width - 1, deskTop, 'V')
333
+ // Floor, so the desk stands on something.
334
+ if (deskTop + 3 < grid.length) rect(grid, 0, deskTop + 3, width - 1, grid.length - 1, 'Y')
335
+ }
336
+
337
+ /**
338
+ * The view through the window.
339
+ *
340
+ * Drawn back to front: sky, stars, the sun or moon, clouds, then whatever is
341
+ * falling out of them. The frame and its mullions go on last so they cut across
342
+ * everything, which is what makes it read as a window rather than a picture.
343
+ *
344
+ * @param {{ x: number, y: number, width: number, height: number }} area in pixels
345
+ * @param {ReturnType<typeof skyState>} sky
346
+ * @param {number} frame
347
+ */
348
+ /**
349
+ * A filled ellipse, clipped to `maxY` so a cloud can have a flat bottom.
350
+ *
351
+ * `disc` draws a circle; a cumulus cloud is three overlapping ellipses and a
352
+ * flat underside, which is what makes it read as a cloud rather than a bar.
353
+ */
354
+ function ellipse(view, cx, cy, rx, ry, cls, maxY = Infinity) {
355
+ const ax = Math.max(0.5, rx)
356
+ const ay = Math.max(0.5, ry)
357
+ const x0 = Math.floor(cx - ax)
358
+ const x1 = Math.ceil(cx + ax)
359
+ const y0 = Math.floor(cy - ay)
360
+ const y1 = Math.ceil(cy + ay)
361
+ for (let py = y0; py <= y1; py += 1) {
362
+ if (py < 0 || py >= view.length || py > maxY) continue
363
+ for (let px = x0; px <= x1; px += 1) {
364
+ if (px < 0 || px >= view[0].length) continue
365
+ const nx = (px - cx) / ax
366
+ const ny = (py - cy) / ay
367
+ if (nx * nx + ny * ny > 1) continue
368
+ view[py][px] = cls
369
+ }
370
+ }
371
+ }
372
+
373
+ /** A cumulus: a wide base, two bumps, and a flat bottom. */
374
+ function cloudBlob(view, cx, cy, width, height) {
375
+ const base = Math.round(cy + height * 0.45)
376
+ ellipse(view, cx, base - height * 0.2, width * 0.5, height * 0.36, 'L', base)
377
+ ellipse(view, cx - width * 0.22, base - height * 0.42, width * 0.28, height * 0.3, 'L', base)
378
+ ellipse(view, cx + width * 0.24, base - height * 0.34, width * 0.24, height * 0.26, 'L', base)
379
+ }
380
+
381
+ /** The far hills, as a fraction of the pane's height at a given column. */
382
+ function ridgeAt(column, width) {
383
+ const x = width <= 1 ? 0 : column / (width - 1)
384
+ return (
385
+ 0.7 +
386
+ 0.13 * Math.sin(x * Math.PI * 2.3 + 0.7) +
387
+ 0.06 * Math.sin(x * Math.PI * 5.1 + 2.1) +
388
+ 0.03 * Math.sin(x * Math.PI * 11.4 + 4.2)
389
+ )
390
+ }
391
+
392
+ /* ------------------------------------------------------------------ *
393
+ * Painting the sky, on whatever canvas is handed over
394
+ * ------------------------------------------------------------------ */
395
+
396
+ /** Gradient sky, stars, and the sun or moon with its halo. */
397
+ export function paintSkyBase(view, sky, frame) {
398
+ const innerW = view[0].length
399
+ const innerH = view.length
400
+
401
+ // The sky: darker overhead, lighter towards the horizon, with the two colours
402
+ // dithered together so the change is gradual rather than banded.
403
+ for (let row = 0; row < innerH; row += 1) {
404
+ const blend = innerH <= 1 ? 0 : row / (innerH - 1)
405
+ for (let column = 0; column < innerW; column += 1) {
406
+ view[row][column] = ditherAt(column, row, blend) ? 'H' : 'S'
407
+ }
408
+ }
409
+
410
+ // Only when the sky is actually dark. Keying off the phase put white dots in
411
+ // a bright orange dawn.
412
+ const dark = skyBrightness(sky.rgb ?? skyRgbFor(sky.minutes, sky.weather)) < 200
413
+ if (dark) {
414
+ const count = Math.floor((innerW * innerH) / 70)
415
+ for (let index = 0; index < count; index += 1) {
416
+ const star = starAt(index, innerW, innerH)
417
+ // A slow twinkle, so the sky is not a static texture.
418
+ if (hash(index * 13 + Math.floor(frame / 24)) < 0.3) continue
419
+ // Two brightnesses: a sky of identical dots looks like a texture.
420
+ view[star.y][star.x] = hash(index * 5) < 0.35 ? 'i' : 'I'
421
+ }
422
+ }
423
+
424
+ // Sun or moon, on its arc across the panes — unless the weather is thick
425
+ // enough to hide it, which is what makes heavy weather read as heavy.
426
+ const obscured = { clear: 0, cloudy: 0.2, rain: 0.45, storm: 0.9, fog: 0.8, snow: 0.5 }[sky.weather.kind] ?? 0
427
+ if (obscured * sky.weather.intensity >= 0.5) return
428
+
429
+ const body = sky.celestial
430
+ const bx = Math.round(body.x * (innerW - 3)) + 1
431
+ const by = Math.round((1 - body.y) * (innerH - 7)) + 2
432
+ const radius = Math.max(2, Math.round(innerW / 20))
433
+
434
+ // A halo, dithered so it fades out rather than stopping dead.
435
+ const halo = radius * 2.4
436
+ // Integer bounds: a fractional loop index reaches the dither matrix as a
437
+ // fractional subscript, which is `undefined`.
438
+ const from = Math.floor(halo)
439
+ for (let py = by - from; py <= by + from; py += 1) {
440
+ for (let px = bx - from; px <= bx + from; px += 1) {
441
+ if (py < 0 || py >= innerH || px < 0 || px >= innerW) continue
442
+ const distance = Math.hypot(px - bx, py - by)
443
+ if (distance <= radius || distance > from) continue
444
+ const strength = 1 - (distance - radius) / (from - radius)
445
+ if (ditherAt(px, py, strength * 0.75)) view[py][px] = 'O'
446
+ }
447
+ }
448
+ // The moon gets the sun's radius: at a third smaller it was a speck.
449
+ disc(view, bx, by, radius, body.body === 'sun' ? 'U' : 'M')
450
+ }
451
+
452
+ /** Cumulus, drifting. */
453
+ export function paintClouds(view, sky, frame) {
454
+ const innerW = view[0].length
455
+ const innerH = view.length
456
+ const coverage = { clear: 0, cloudy: 3, rain: 4, storm: 6, fog: 2, snow: 4 }[sky.weather.kind] ?? 0
457
+ const drift = Math.floor(frame / 6)
458
+ for (let index = 0; index < coverage; index += 1) {
459
+ const seed = sky.weather.spell * 31 + index
460
+ const span = 6 + Math.floor(hash(seed) * Math.max(3, innerW / 5))
461
+ const startX = ((Math.floor(hash(seed * 3) * innerW) + drift + index * 9) % (innerW + span * 2)) - span
462
+ const tall = 3 + Math.floor(hash(seed * 7) * 3)
463
+ const cy = 1 + Math.floor(hash(seed * 5) * Math.max(1, innerH / 3))
464
+ cloudBlob(view, startX + span / 2, cy, span, tall)
465
+ }
466
+ }
467
+
468
+ /** Fog and precipitation. */
469
+ export function paintWeather(view, sky, frame) {
470
+ const innerW = view[0].length
471
+ const innerH = view.length
472
+
473
+ // Fog: a haze gathering towards the bottom of the view. It used to be a dense
474
+ // band over the bottom third, which read as a grey stripe rather than
475
+ // weather. Now it covers a quarter of the pane, is at most half-filled, and
476
+ // thins out as it rises, so it fades into the sky.
477
+ if (sky.weather.kind === 'fog' && sky.weather.intensity > 0.2) {
478
+ const rows = Math.max(1, Math.round((innerH / 4) * sky.weather.intensity))
479
+ const heaviest = 0.46 * sky.weather.intensity
480
+ for (let index = 0; index < rows; index += 1) {
481
+ const row = innerH - 1 - index
482
+ const strength = heaviest * (1 - (index / rows) * 0.75)
483
+ for (let column = 0; column < innerW; column += 1) {
484
+ if (!ditherAt(column, row, strength)) continue
485
+ view[row][column] = 'G'
486
+ }
487
+ }
488
+ }
489
+
490
+ const kind = sky.weather.kind
491
+ if (!(kind === 'rain' || kind === 'storm' || kind === 'snow') || sky.weather.intensity <= 0.05) return
492
+ const heavy = kind === 'storm' ? 2 : 1
493
+ // 0.04 of the pane was a downpour of speckle; rain reads better sparse.
494
+ const drops = Math.round(innerW * innerH * 0.012 * sky.weather.intensity * heavy)
495
+ for (let index = 0; index < drops; index += 1) {
496
+ const column = Math.floor(hash(index * 17 + 3) * innerW)
497
+ const speed = kind === 'snow' ? 1 : 3
498
+ const travel = (hash(index * 19 + 5) * (innerH + 8) + Math.floor(frame / speed)) % (innerH + 8)
499
+ const sway = kind === 'snow' ? Math.round(Math.sin((frame + index * 9) / 9) * 1.5) : 0
500
+ // Floored: an array index has to be an integer, and `view[48.7]` is
501
+ // `undefined` — which crashed the viewer on the first storm.
502
+ const px = Math.floor(column + sway)
503
+ const py = Math.floor(travel - 4)
504
+ if (py < 0 || py >= innerH || px < 0 || px >= innerW) continue
505
+ if (kind === 'snow') {
506
+ view[py][px] = 'Z'
507
+ } else {
508
+ // A streak rather than a dot: single pixels at this size read as noise.
509
+ const length = heavy === 2 ? 3 : 2
510
+ for (let step = 0; step < length; step += 1) {
511
+ const row = py + step
512
+ if (row < 0 || row >= innerH) break
513
+ view[row][px] = 'X'
514
+ }
515
+ }
516
+ }
517
+ }
518
+
519
+ /** How far a ridge line deviates from flat, at a given column, in rows. */
520
+ function ridgeHeight(column, width, amplitude) {
521
+ return Math.round((ridgeAt(column, width) - 0.7) * amplitude)
522
+ }
523
+
524
+ /** A conifer: a triangle of branches on a short trunk. */
525
+ function conifer(view, x, baseY, height) {
526
+ const top = baseY - height
527
+ for (let row = 0; row <= height; row += 1) {
528
+ const y = top + row
529
+ if (y < 0 || y >= view.length) continue
530
+ const half = Math.max(0, Math.round((row / Math.max(1, height)) * (height * 0.36)))
531
+ for (let dx = -half; dx <= half; dx += 1) {
532
+ const px = x + dx
533
+ if (px < 0 || px >= view[0].length) continue
534
+ view[y][px] = 'T'
535
+ }
536
+ }
537
+ for (let row = 1; row <= Math.max(1, Math.round(height * 0.12)); row += 1) {
538
+ const y = baseY + row
539
+ if (y < 0 || y >= view.length) continue
540
+ if (x >= 0 && x < view[0].length) view[y][x] = 'T'
541
+ }
542
+ }
543
+
544
+ /**
545
+ * The whole outdoors: sky from the top of the scene down to the ground, no wall
546
+ * and no window.
547
+ *
548
+ * A room is a frame around a view; this is the view itself, so the same weather
549
+ * covers the whole backdrop instead of being confined to a pane.
550
+ */
551
+ export function drawNature(grid, area, sky, frame, options = {}) {
552
+ const { x, y, width, height } = area
553
+ if (width < 8 || height < 10) return
554
+
555
+ // Composed on its own canvas and blitted, for the same reason the window is:
556
+ // nothing may spill outside the area it was given.
557
+ const view = canvas(width, height)
558
+ paintSkyBase(view, sky, frame)
559
+
560
+ // Where the ground begins. The desk sits just above this, so the horizon
561
+ // lands behind the desk rather than across it.
562
+ const groundTop = options.horizon ?? Math.round(height * 0.8)
563
+
564
+ // Two ridges for depth: a far one, then the near ground on top of it. Both
565
+ // run to the bottom of the scene, so the desk stands on ground.
566
+ for (let column = 0; column < width; column += 1) {
567
+ const farTop = groundTop - Math.round(height * 0.1) - ridgeHeight(column, width, height * 0.18)
568
+ for (let row = Math.max(0, farTop); row < height; row += 1) view[row][column] = 'B'
569
+ }
570
+ for (let column = 0; column < width; column += 1) {
571
+ const nearTop = groundTop + ridgeHeight(column + 37, width, height * 0.05)
572
+ for (let row = Math.max(0, nearTop); row < height; row += 1) view[row][column] = 'j'
573
+ }
574
+
575
+ const trees = options.trees ?? 9
576
+ for (let index = 0; index < trees; index += 1) {
577
+ const seed = index * 977
578
+ const tx = Math.round(hash(seed) * (width - 1))
579
+ const th = Math.max(4, Math.round(height * (0.1 + hash(seed * 3) * 0.12)))
580
+ conifer(view, tx, groundTop + ridgeHeight(tx + 37, width, height * 0.05), th)
581
+ }
582
+
583
+ paintClouds(view, sky, frame)
584
+ paintWeather(view, sky, frame)
585
+
586
+ for (let vy = 0; vy < height; vy += 1) {
587
+ for (let vx = 0; vx < width; vx += 1) {
588
+ const cell = view[vy][vx]
589
+ if (cell === '.') continue
590
+ grid[y + vy][x + vx] = cell
591
+ }
592
+ }
593
+ }
594
+
595
+ /* ------------------------------------------------------------------ *
596
+ * The window, which is the same sky seen through an opening
597
+ * ------------------------------------------------------------------ */
598
+
599
+ export function drawWindowView(grid, area, sky, frame) {
600
+ const { x, y, width, height } = area
601
+ if (width < 6 || height < 6) return
602
+
603
+ const innerX = x + 1
604
+ const innerY = y + 1
605
+ const innerW = width - 2
606
+ const innerH = height - 2
607
+
608
+ rect(grid, x, y, x + width - 1, y + height - 1, 'S')
609
+ if (innerW < 4 || innerH < 4) return
610
+
611
+ // Composed on its own canvas and blitted in afterwards, so a cloud cannot
612
+ // drift out of the opening and wander across the wall.
613
+ const view = canvas(innerW, innerH)
614
+ paintSkyBase(view, sky, frame)
615
+ // The hills the sun sets behind.
616
+ for (let column = 0; column < innerW; column += 1) {
617
+ const top = Math.round(ridgeAt(column, innerW) * (innerH - 1))
618
+ for (let row = Math.max(0, top); row < innerH; row += 1) view[row][column] = 'B'
619
+ }
620
+ paintClouds(view, sky, frame)
621
+ paintWeather(view, sky, frame)
622
+
623
+ // Copy the view in, whole cells only.
624
+ for (let vy = 0; vy < innerH; vy += 1) {
625
+ for (let vx = 0; vx < innerW; vx += 1) {
626
+ const cell = view[vy][vx]
627
+ if (cell === '.') continue
628
+ grid[innerY + vy][innerX + vx] = cell
629
+ }
630
+ }
631
+
632
+ // Frame and mullions, over the top of all of it.
633
+ const mx = innerX + Math.floor(innerW / 2)
634
+ const my = innerY + Math.floor(innerH / 2)
635
+ rect(grid, mx, innerY, mx, innerY + innerH - 1, 'F')
636
+ rect(grid, innerX, my, innerX + innerW - 1, my, 'F')
637
+ rect(grid, x, y, x + width - 1, y, 'F')
638
+ rect(grid, x, y + height - 1, x + width - 1, y + height - 1, 'F')
639
+ rect(grid, x, y, x, y + height - 1, 'F')
640
+ rect(grid, x + width - 1, y, x + width - 1, y + height - 1, 'F')
641
+ }