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.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- 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
|
+
}
|