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,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scene: the orca at its desk, animated for whatever it is doing.
|
|
3
|
+
*
|
|
4
|
+
* The scene is **sized to the terminal** rather than fixed. A frame is composed
|
|
5
|
+
* as a pixel grid and then packed two-pixels-per-cell into terminal cells, so
|
|
6
|
+
* the horizontal resolution available is the terminal width: a fixed 96-column
|
|
7
|
+
* scene would sit in the middle of a wide terminal looking small and losing
|
|
8
|
+
* detail that was there to be had. The sprites scale by whole numbers, because
|
|
9
|
+
* pixel art scaled fractionally looks like a rendering fault.
|
|
10
|
+
*
|
|
11
|
+
* Text — the telephone bubble — is written into the cell grid directly, since
|
|
12
|
+
* glyphs are not pixels.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-live-working/scene
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { drawNature, drawWall, drawWindowView, skyState } from './sky.js'
|
|
18
|
+
import { displayWidth, truncate } from '../width.js'
|
|
19
|
+
import { orcaWithTail, ORCA_H, ORCA_W, scaleGrid, setEyes, tailDroopFor, tailSwayFor } from './art.js'
|
|
20
|
+
import {
|
|
21
|
+
blit,
|
|
22
|
+
canvas,
|
|
23
|
+
drawBooks,
|
|
24
|
+
drawBubble,
|
|
25
|
+
drawDesk,
|
|
26
|
+
drawFlipper,
|
|
27
|
+
drawKeyboard,
|
|
28
|
+
drawLimb,
|
|
29
|
+
drawOpenBook,
|
|
30
|
+
drawPhone,
|
|
31
|
+
drawRingArcs,
|
|
32
|
+
drawSleepZ,
|
|
33
|
+
drawWindow,
|
|
34
|
+
rect
|
|
35
|
+
} from './props.js'
|
|
36
|
+
|
|
37
|
+
/** The scene size used when the caller does not say (tests, previews). */
|
|
38
|
+
export const SCENE_W = 112
|
|
39
|
+
export const SCENE_H = 64
|
|
40
|
+
|
|
41
|
+
/** Largest scene, in cells, and the smallest a readable one can be. */
|
|
42
|
+
const MIN_W = 40
|
|
43
|
+
const MAX_W = 200
|
|
44
|
+
const MIN_H = 24
|
|
45
|
+
|
|
46
|
+
/** Which palette key each pixel class uses. */
|
|
47
|
+
const CLASS_KEY = {
|
|
48
|
+
b: 'body',
|
|
49
|
+
f: 'flipper',
|
|
50
|
+
o: 'outline',
|
|
51
|
+
w: 'belly',
|
|
52
|
+
s: 'spout',
|
|
53
|
+
a: 'accent',
|
|
54
|
+
d: 'desk',
|
|
55
|
+
D: 'deskDark',
|
|
56
|
+
k: 'keyboard',
|
|
57
|
+
K: 'key',
|
|
58
|
+
r: 'phone',
|
|
59
|
+
R: 'phoneDark',
|
|
60
|
+
p: 'page',
|
|
61
|
+
g: 'bookGreen',
|
|
62
|
+
y: 'bookYellow',
|
|
63
|
+
c: 'bookCyan',
|
|
64
|
+
n: 'bubble',
|
|
65
|
+
N: 'bubbleFill',
|
|
66
|
+
q: 'pageShade',
|
|
67
|
+
Q: 'pageDark',
|
|
68
|
+
e: 'windowEdge',
|
|
69
|
+
E: 'windowFill',
|
|
70
|
+
F: 'windowFrame',
|
|
71
|
+
S: 'sky',
|
|
72
|
+
H: 'skyLow',
|
|
73
|
+
B: 'landscape',
|
|
74
|
+
O: 'glow',
|
|
75
|
+
i: 'starDim',
|
|
76
|
+
T: 'tree',
|
|
77
|
+
j: 'ground',
|
|
78
|
+
L: 'cloud',
|
|
79
|
+
U: 'sun',
|
|
80
|
+
M: 'moon',
|
|
81
|
+
I: 'star',
|
|
82
|
+
X: 'rain',
|
|
83
|
+
Z: 'snow',
|
|
84
|
+
G: 'fog',
|
|
85
|
+
W: 'wall',
|
|
86
|
+
V: 'wallShade',
|
|
87
|
+
Y: 'floor',
|
|
88
|
+
z: 'sleepZ',
|
|
89
|
+
t: 'text'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Priority when one cell holds two different pixels. Furniture is drawn over
|
|
94
|
+
* the orca deliberately, so it wins; outline wins over body so the rim reads.
|
|
95
|
+
*/
|
|
96
|
+
const CLASS_ORDER = [
|
|
97
|
+
't', 'n', 'N', 'z', 'e', 'E', 'F', 'a', 'r', 'R', 'o', 'K', 'k', 'd', 'D',
|
|
98
|
+
'X', 'Z', 'G', 'L', 'U', 'M', 'O', 'I', 'i', 'T', 'j', 'B', 'H', 'S', 'V', 'W', 'Y',
|
|
99
|
+
'g', 'y', 'c', 'p', 'q', 'Q', 'w', 's', 'f', 'b'
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
const GLYPHS = [' ', '▀', '▄', '█']
|
|
103
|
+
const EMPTY = '.'
|
|
104
|
+
|
|
105
|
+
/** Frame length of the blink, bobs and page turns. */
|
|
106
|
+
const CYCLE = 12
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* How big the scene should be, in terminal cells, for a given terminal.
|
|
110
|
+
*
|
|
111
|
+
* @param {number} cols
|
|
112
|
+
* @param {number} rows
|
|
113
|
+
* @returns {{ width: number, height: number }}
|
|
114
|
+
*/
|
|
115
|
+
export function sceneSizeFor(cols, rows) {
|
|
116
|
+
const safeCols = Number.isFinite(cols) ? cols : 80
|
|
117
|
+
const safeRows = Number.isFinite(rows) ? rows : 24
|
|
118
|
+
// Never wider than the terminal. There is no minimum floor here on purpose:
|
|
119
|
+
// a floor that ignored the terminal would push the scene, and the telephone
|
|
120
|
+
// bubble with it, off the screen. The caller refuses to draw a scene in a
|
|
121
|
+
// terminal too small to hold one.
|
|
122
|
+
const width = Math.min(MAX_W, Math.max(16, Math.floor(safeCols) - 2))
|
|
123
|
+
// The scene is a wide band: keep its proportions when the terminal is short.
|
|
124
|
+
const preferred = Math.round(width * 0.3)
|
|
125
|
+
// Two rows are left over for the status line. The minimum height is a
|
|
126
|
+
// preference, not a floor: on a short terminal a scene that insisted on 24
|
|
127
|
+
// rows would be sliced by the caller and the status line would vanish with
|
|
128
|
+
// it.
|
|
129
|
+
const available = Math.max(8, Math.floor(safeRows) - 2)
|
|
130
|
+
const height = Math.min(Math.max(preferred, Math.min(MIN_H, available)), available)
|
|
131
|
+
return { width, height }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What whole-number scale the orca should be drawn at.
|
|
136
|
+
*
|
|
137
|
+
* One is the native size; two is for a terminal with room to spare, where the
|
|
138
|
+
* creature would otherwise look lost in the middle of the desk.
|
|
139
|
+
*
|
|
140
|
+
* @param {{ width: number, height: number }} size scene size in cells
|
|
141
|
+
* @returns {number}
|
|
142
|
+
*/
|
|
143
|
+
export function orcaScaleFor(size) {
|
|
144
|
+
const pixelW = size.width
|
|
145
|
+
const pixelH = size.height * 2
|
|
146
|
+
const fit = Math.min(Math.floor((pixelW * 0.68) / ORCA_W), Math.floor((pixelH * 0.78) / ORCA_H))
|
|
147
|
+
return Math.max(1, Math.min(3, fit))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Positive modulo. */
|
|
151
|
+
function mod(value, base) {
|
|
152
|
+
const n = Math.trunc(Number(value))
|
|
153
|
+
if (!Number.isFinite(n)) return 0
|
|
154
|
+
return ((n % base) + base) % base
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Eyes for the current state and frame.
|
|
159
|
+
*
|
|
160
|
+
* Reading is punctuated by an occasional squint, and sleep closes them; the
|
|
161
|
+
* model's other states keep them open.
|
|
162
|
+
*/
|
|
163
|
+
/**
|
|
164
|
+
* Whether the orca is rubbing its eyes.
|
|
165
|
+
*
|
|
166
|
+
* Only at night, and only while it is working: the joke is that the work
|
|
167
|
+
* carries on. Every so often it shuts its eyes and lifts a flipper to its face
|
|
168
|
+
* for a moment, then carries on exactly where it was.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} kind
|
|
171
|
+
* @param {number} frame
|
|
172
|
+
* @param {{ phase?: string } | undefined} sky
|
|
173
|
+
*/
|
|
174
|
+
export function isRubbingEyes(kind, frame, sky) {
|
|
175
|
+
if (sky?.phase !== 'night') return false
|
|
176
|
+
const working = kind === 'typing' || kind === 'writing' || kind === 'reading' || kind === 'searching'
|
|
177
|
+
if (!working) return false
|
|
178
|
+
return mod(frame + 23, RUB_BETWEEN) < RUB_LENGTH
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Frames between eye rubs, and how long one lasts. */
|
|
182
|
+
export const RUB_BETWEEN = 72
|
|
183
|
+
export const RUB_LENGTH = 16
|
|
184
|
+
|
|
185
|
+
export function eyeMode(kind, frame) {
|
|
186
|
+
const tick = mod(frame, CYCLE)
|
|
187
|
+
switch (kind) {
|
|
188
|
+
case 'sleep':
|
|
189
|
+
return 'closed'
|
|
190
|
+
case 'reading':
|
|
191
|
+
// A long blink every cycle, so the squint reads as "reading" rather than
|
|
192
|
+
// as a fixed expression.
|
|
193
|
+
return tick >= 8 ? 'closed' : tick >= 6 ? 'squint' : 'open'
|
|
194
|
+
case 'thinking':
|
|
195
|
+
return tick % 4 === 3 ? 'squint' : 'open'
|
|
196
|
+
case 'calling':
|
|
197
|
+
return tick >= 6 ? 'happy' : 'open'
|
|
198
|
+
case 'searching':
|
|
199
|
+
return tick % 6 === 0 ? 'squint' : 'open'
|
|
200
|
+
default:
|
|
201
|
+
return 'open'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Where everything goes, as fractions of the scene, resolved to pixels. */
|
|
206
|
+
export function layoutFor(size) {
|
|
207
|
+
// Tolerate a missing or partial size: the layout is also used to place the
|
|
208
|
+
// bubble on its own.
|
|
209
|
+
const W = Number.isFinite(size?.width) ? size.width : SCENE_W
|
|
210
|
+
const H = (Number.isFinite(size?.height) ? size.height : SCENE_H / 2) * 2
|
|
211
|
+
const scale = orcaScaleFor(size)
|
|
212
|
+
const deskTop = Math.round(H * 0.8)
|
|
213
|
+
const orcaH = ORCA_H * scale
|
|
214
|
+
return {
|
|
215
|
+
scale,
|
|
216
|
+
desk: { top: deskTop, left: Math.max(2, Math.round(W * 0.02)), right: W - Math.max(2, Math.round(W * 0.02)), thickness: Math.max(2, Math.round(H * 0.05)) },
|
|
217
|
+
orca: {
|
|
218
|
+
// Clamped so a narrow scene cannot push the creature off the right edge.
|
|
219
|
+
x: Math.max(0, Math.min(Math.round(W * 0.17), W - ORCA_W * scale)),
|
|
220
|
+
// Seated *behind* the desk: the furniture overlaps the last pixels of the
|
|
221
|
+
// belly, which is what makes it look like it is sitting at the desk
|
|
222
|
+
// rather than hovering above it.
|
|
223
|
+
y: Math.max(2, deskTop - orcaH + Math.max(1, Math.round(H * 0.03))),
|
|
224
|
+
width: ORCA_W * scale,
|
|
225
|
+
height: orcaH
|
|
226
|
+
},
|
|
227
|
+
keyboard: { x: Math.round(W * 0.3), y: deskTop - Math.max(3, Math.round(H * 0.05)), width: Math.round(W * 0.28), height: Math.max(3, Math.round(H * 0.05)) },
|
|
228
|
+
books: { x: Math.round(W * 0.68), y: deskTop - Math.max(8, Math.round(H * 0.2)), width: Math.round(W * 0.16), height: Math.max(8, Math.round(H * 0.2)) },
|
|
229
|
+
phone: { x: Math.round(W * 0.015), y: deskTop - Math.max(9, Math.round(H * 0.19)), width: Math.max(8, Math.round(W * 0.115)), height: Math.max(9, Math.round(H * 0.19)) },
|
|
230
|
+
document: { x: Math.round(W * 0.32), y: deskTop - Math.max(8, Math.round(H * 0.2)), width: Math.round(W * 0.36), height: Math.max(8, Math.round(H * 0.2)) },
|
|
231
|
+
// A heads-up panel showing the text being typed. Upper right, because the
|
|
232
|
+
// creature owns the left half of the scene and a window over the desk
|
|
233
|
+
// covers it.
|
|
234
|
+
// The whole outdoors, when there is no room around it.
|
|
235
|
+
nature: {
|
|
236
|
+
x: 0,
|
|
237
|
+
y: 0,
|
|
238
|
+
width: W,
|
|
239
|
+
// The whole scene: the ground runs to the bottom, so the desk stands on it.
|
|
240
|
+
height: H
|
|
241
|
+
},
|
|
242
|
+
// The window on the back wall. Drawn first, so the orca and the desk stand
|
|
243
|
+
// in front of it.
|
|
244
|
+
sky: {
|
|
245
|
+
x: Math.round(W * 0.4),
|
|
246
|
+
y: Math.round(H * 0.05),
|
|
247
|
+
width: Math.round(W * 0.57),
|
|
248
|
+
height: Math.max(10, Math.round(H * 0.58))
|
|
249
|
+
},
|
|
250
|
+
window: {
|
|
251
|
+
// On the wall at the top left. Over the sky it sat across the arc the sun
|
|
252
|
+
// and moon travel, and covered whichever of them happened to be up.
|
|
253
|
+
x: Math.round(W * 0.02),
|
|
254
|
+
y: Math.max(2, Math.round(H * 0.03)),
|
|
255
|
+
width: Math.round(W * 0.3),
|
|
256
|
+
height: Math.max(6, Math.round(H * 0.13))
|
|
257
|
+
},
|
|
258
|
+
// The book the orca is holding: large enough that the turning leaf is
|
|
259
|
+
// legible, which a book the size of the desk stack never was.
|
|
260
|
+
openBook: {
|
|
261
|
+
// In front of the body rather than the head, so the creature still reads
|
|
262
|
+
// as an orca while it holds the book open.
|
|
263
|
+
x: Math.round(W * 0.42),
|
|
264
|
+
y: deskTop - Math.max(10, Math.round(H * 0.28)),
|
|
265
|
+
width: Math.round(W * 0.3),
|
|
266
|
+
height: Math.max(8, Math.round(H * 0.2))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The point beside the orca's head where a handset is held. */
|
|
272
|
+
function earFor(layout) {
|
|
273
|
+
// Against the face. The eye sits at (8, 21) in the 64x40 sprite and the
|
|
274
|
+
// snout is the left edge, so the handset runs from the cheek down past the
|
|
275
|
+
// chin, mostly clear of the body rather than across it.
|
|
276
|
+
return {
|
|
277
|
+
x: layout.orca.x + 7 * layout.scale,
|
|
278
|
+
y: layout.orca.y + 15 * layout.scale,
|
|
279
|
+
scale: layout.scale
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether the preview window has a frame in this state.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} kind
|
|
287
|
+
* @param {{ width: number, height: number }} size
|
|
288
|
+
*/
|
|
289
|
+
export function windowVisible(kind, size) {
|
|
290
|
+
if (kind === 'sleep') return false
|
|
291
|
+
const area = layoutFor(size).window
|
|
292
|
+
return area.width >= 14 && area.height >= 6
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Build the pixel grid for one frame.
|
|
297
|
+
*
|
|
298
|
+
* @param {{ kind: string, subagent?: object | null }} work
|
|
299
|
+
* @param {number} frame
|
|
300
|
+
* @param {{ size?: { width: number, height: number } }} [options]
|
|
301
|
+
* @returns {string[][]}
|
|
302
|
+
*/
|
|
303
|
+
export function sceneGrid(work, frame, options = {}) {
|
|
304
|
+
const kind = work?.kind ?? 'sleep'
|
|
305
|
+
const size = options.size ?? { width: SCENE_W, height: SCENE_H / 2 }
|
|
306
|
+
const layout = layoutFor(size)
|
|
307
|
+
const tick = mod(frame, CYCLE)
|
|
308
|
+
const grid = canvas(size.width, size.height * 2)
|
|
309
|
+
const sky = options.sky ?? skyState(options.clockMs)
|
|
310
|
+
|
|
311
|
+
// --- behind everything --------------------------------------------------
|
|
312
|
+
if (options.scene === 'nature') {
|
|
313
|
+
// No wall and no window: the backdrop *is* the outdoors, so the weather
|
|
314
|
+
// covers the whole scene instead of one pane.
|
|
315
|
+
drawNature(grid, layout.nature, sky, frame, {
|
|
316
|
+
horizon: layout.desk.top - 2,
|
|
317
|
+
trees: Math.max(3, Math.round(size.width / 16))
|
|
318
|
+
})
|
|
319
|
+
} else {
|
|
320
|
+
drawWall(grid, size, layout.desk.top)
|
|
321
|
+
drawWindowView(grid, layout.sky, sky, frame)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- the orca -----------------------------------------------------------
|
|
325
|
+
const rubbing = isRubbingEyes(kind, frame, sky)
|
|
326
|
+
const sleepFor = Number.isFinite(options.sleepFor) ? options.sleepFor : 0
|
|
327
|
+
// Two tails, drawn: the renderer picks one rather than transforming the art.
|
|
328
|
+
const pose = kind === 'sleep' && tailDroopFor(kind, sleepFor) >= 0.5 ? 'sleep' : 'up'
|
|
329
|
+
const orca = orcaWithTail({ pose, sway: tailSwayFor(kind, frame) })
|
|
330
|
+
// Rubbing closes the eyes whatever the pose was doing.
|
|
331
|
+
setEyes(orca, rubbing ? 'closed' : eyeMode(kind, frame))
|
|
332
|
+
// Sleep and thinking bob more slowly than typing; the phone call holds still.
|
|
333
|
+
const bob = kind === 'sleep' ? (tick >= 6 ? layout.scale : 0) : kind === 'calling' ? 0 : tick % 4 === 0 ? layout.scale : 0
|
|
334
|
+
blit(grid, scaleGrid(orca, layout.scale), layout.orca.x, layout.orca.y + bob)
|
|
335
|
+
|
|
336
|
+
// --- the desk -----------------------------------------------------------
|
|
337
|
+
drawDesk(grid, layout.desk.top, layout.desk.left, layout.desk.right, layout.desk.thickness)
|
|
338
|
+
|
|
339
|
+
// --- what is on the desk, by activity ----------------------------------
|
|
340
|
+
const keyboard = layout.keyboard
|
|
341
|
+
switch (kind) {
|
|
342
|
+
case 'searching':
|
|
343
|
+
// Leafing through a book: the stack stays on the desk and a large open
|
|
344
|
+
// book is held in front, its pages turning.
|
|
345
|
+
drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
|
|
346
|
+
drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
|
|
347
|
+
drawOpenBook(grid, layout.openBook.x, layout.openBook.y, layout.openBook.width, layout.openBook.height, frame)
|
|
348
|
+
break
|
|
349
|
+
case 'reading':
|
|
350
|
+
// The same open book, held still while reading.
|
|
351
|
+
drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
|
|
352
|
+
drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
|
|
353
|
+
drawOpenBook(grid, layout.openBook.x, layout.openBook.y, layout.openBook.width, layout.openBook.height, 0)
|
|
354
|
+
break
|
|
355
|
+
case 'writing':
|
|
356
|
+
drawDocument(grid, layout.document, frame)
|
|
357
|
+
break
|
|
358
|
+
case 'waiting':
|
|
359
|
+
drawTerminal(grid, keyboard, frame)
|
|
360
|
+
break
|
|
361
|
+
case 'calling':
|
|
362
|
+
// On the telephone: hands off the keyboard entirely.
|
|
363
|
+
drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
|
|
364
|
+
drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
|
|
365
|
+
break
|
|
366
|
+
case 'thinking':
|
|
367
|
+
// Hands off the keys while it thinks, with a trail of dots above.
|
|
368
|
+
drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
|
|
369
|
+
drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
|
|
370
|
+
drawThoughtDots(grid, layout, frame)
|
|
371
|
+
break
|
|
372
|
+
case 'sleep':
|
|
373
|
+
drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
|
|
374
|
+
drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
|
|
375
|
+
break
|
|
376
|
+
default:
|
|
377
|
+
drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, frame)
|
|
378
|
+
drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
|
|
379
|
+
break
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The window frame is drawn whenever the preview text may be shown. Text
|
|
383
|
+
// without a frame reads as a stray floating bubble — which is exactly how it
|
|
384
|
+
// was reported.
|
|
385
|
+
if (windowVisible(kind, size)) drawTypingWindow(grid, layout)
|
|
386
|
+
|
|
387
|
+
// --- the orca's flippers -------------------------------------------------
|
|
388
|
+
// Drawn after the desk furniture, so they rest *on* it rather than
|
|
389
|
+
// disappearing behind it. The sampled sprite is a side view with no separate
|
|
390
|
+
// limb, so any movement has to come from these.
|
|
391
|
+
const atKeyboard = kind === 'typing' || kind === 'writing' || kind === 'waiting'
|
|
392
|
+
const flipperX = layout.orca.x + Math.round(layout.orca.width * 0.38)
|
|
393
|
+
const flipperY = layout.orca.y + Math.round(layout.orca.height * 0.78)
|
|
394
|
+
const flipperSize = Math.max(2, layout.scale * 3)
|
|
395
|
+
const flipperGap = Math.round(layout.orca.width * 0.17)
|
|
396
|
+
// Whether the handset is up. Exactly one set of flippers is drawn at a time:
|
|
397
|
+
// the pair on the keyboard, or the one holding the telephone. Drawing both
|
|
398
|
+
// gave the orca three hands, which is how it was reported.
|
|
399
|
+
const holding = (Number.isFinite(options.phoneLift) ? options.phoneLift : (kind === 'calling' || kind === 'ringing' ? 1 : 0)) >= 0.2
|
|
400
|
+
if (kind !== 'sleep' && !holding && !(kind === 'calling' || kind === 'ringing')) {
|
|
401
|
+
for (const [offset, phase] of [[0, 0], [flipperGap, 1]]) {
|
|
402
|
+
const down = atKeyboard ? tick % 2 === phase : false
|
|
403
|
+
drawFlipper(grid, flipperX + offset, flipperY - (offset === 0 ? 0 : 4 * layout.scale), flipperSize, down)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A flipper up to the face for the night-time rub, drawn with the others so
|
|
408
|
+
// it shares their colour and their size.
|
|
409
|
+
if (rubbing) {
|
|
410
|
+
drawFlipper(grid, layout.orca.x + 9 * layout.scale, layout.orca.y + bob + 15 * layout.scale, flipperSize, false)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The telephone is always on the desk, and is lifted for a subagent.
|
|
414
|
+
const ringing = kind === 'ringing'
|
|
415
|
+
const onThePhone = kind === 'calling' || ringing
|
|
416
|
+
const ear = earFor(layout)
|
|
417
|
+
if (ringing) {
|
|
418
|
+
// Shaken, not held still: the telephone is ringing.
|
|
419
|
+
ear.shake = (tick % 4 < 2 ? 1 : -1) * Math.max(1, layout.scale)
|
|
420
|
+
}
|
|
421
|
+
// `phoneLift` is the pickup/hangup animation: 0 on the cradle, 1 at the ear,
|
|
422
|
+
// and whatever is in between while the handset is travelling. Callers that
|
|
423
|
+
// do not animate it get the settled pose.
|
|
424
|
+
const lift = Number.isFinite(options.phoneLift) ? options.phoneLift : onThePhone ? 1 : 0
|
|
425
|
+
ear.lift = lift
|
|
426
|
+
const handset = drawPhone(grid, layout.phone.x, layout.phone.y, layout.phone.width, layout.phone.height, ear)
|
|
427
|
+
if (ringing && lift > 0.5) {
|
|
428
|
+
drawRingArcs(grid, layout.phone.x + Math.round(layout.phone.width / 2), layout.phone.y, layout.scale, frame)
|
|
429
|
+
drawRingArcs(grid, ear.x, ear.y - 4 * layout.scale, layout.scale, frame)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// On the telephone the orca holds the handset: a flipper runs from the
|
|
433
|
+
// mouthpiece back to the body, drawn over it so the fin reads as gripping.
|
|
434
|
+
if (holding && handset !== undefined) {
|
|
435
|
+
// Anchored at the mouthpiece end of the handset as it is right now, so the
|
|
436
|
+
// fin follows it up and down instead of staying behind at the ear.
|
|
437
|
+
const dx = Math.cos(handset.angle)
|
|
438
|
+
const dy = Math.sin(handset.angle)
|
|
439
|
+
const mouthX = handset.cx - dx * (handset.length / 2)
|
|
440
|
+
const mouthY = handset.cy - dy * (handset.length / 2)
|
|
441
|
+
drawLimb(
|
|
442
|
+
grid,
|
|
443
|
+
Math.round(mouthX),
|
|
444
|
+
Math.round(mouthY + 2 * layout.scale),
|
|
445
|
+
Math.max(2, layout.scale * 2.5),
|
|
446
|
+
8 * layout.scale,
|
|
447
|
+
5 * layout.scale,
|
|
448
|
+
'f'
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- effects ------------------------------------------------------------
|
|
453
|
+
if (kind === 'sleep') {
|
|
454
|
+
const s = layout.scale
|
|
455
|
+
for (let index = 0; index < 3; index += 1) {
|
|
456
|
+
const rise = mod(tick + index * 4, CYCLE)
|
|
457
|
+
// Big enough to read from across the room: at two or three pixels a z
|
|
458
|
+
// was a speck.
|
|
459
|
+
const zsize = (4 + index * 2) * s
|
|
460
|
+
// Above the head, on the wall: drawn over the window they were swallowed
|
|
461
|
+
// by the sky.
|
|
462
|
+
const zx = layout.orca.x + (6 + index * 7) * s + Math.floor(rise / 4) * s
|
|
463
|
+
const zy = Math.max(2, layout.orca.y + 10 * s - index * 6 * s - rise * s)
|
|
464
|
+
if (zy + zsize < grid.length && zx + zsize < grid[0].length) drawSleepZ(grid, zx, zy, zsize)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return grid
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The window above the keyboard, if the scene is wide enough for one. */
|
|
472
|
+
function drawTypingWindow(grid, layout) {
|
|
473
|
+
const area = layout.window
|
|
474
|
+
if (area.width < 14 || area.height < 6) return
|
|
475
|
+
drawWindow(grid, area.x, area.y, area.width, area.height)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Where the typing window's text goes, in terminal-cell coordinates, and how
|
|
480
|
+
* much of it fits.
|
|
481
|
+
*
|
|
482
|
+
* @param {{ width: number, height: number }} size
|
|
483
|
+
*/
|
|
484
|
+
export function previewArea(size) {
|
|
485
|
+
const layout = layoutFor(size)
|
|
486
|
+
const area = layout.window
|
|
487
|
+
if (area.width < 14 || area.height < 6) return null
|
|
488
|
+
return {
|
|
489
|
+
row: Math.floor(area.y / 2) + 1,
|
|
490
|
+
col: area.x + 2,
|
|
491
|
+
width: Math.max(1, area.width - 4),
|
|
492
|
+
rows: Math.max(1, Math.min(3, Math.floor(area.height / 2) - 2))
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Wrap the tail of some text to a cell budget.
|
|
498
|
+
*
|
|
499
|
+
* The tail is what matters — it is what is being typed right now — so the
|
|
500
|
+
* beginning is dropped rather than the end.
|
|
501
|
+
*
|
|
502
|
+
* @param {string} text
|
|
503
|
+
* @param {number} width cells per line
|
|
504
|
+
* @param {number} rows how many lines fit
|
|
505
|
+
* @returns {string[]}
|
|
506
|
+
*/
|
|
507
|
+
export function wrapPreview(text, width, rows) {
|
|
508
|
+
const clean = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
509
|
+
if (clean.length === 0 || rows <= 0) return []
|
|
510
|
+
const characters = [...clean]
|
|
511
|
+
// A generous tail: enough that the wrapping below always fills `rows` lines.
|
|
512
|
+
const tail = characters.slice(-(width * rows + width))
|
|
513
|
+
const lines = []
|
|
514
|
+
let line = ''
|
|
515
|
+
let used = 0
|
|
516
|
+
for (const character of tail) {
|
|
517
|
+
const cellWidth = displayWidth(character)
|
|
518
|
+
if (used + cellWidth > width && line.length > 0) {
|
|
519
|
+
lines.push(line)
|
|
520
|
+
line = ''
|
|
521
|
+
used = 0
|
|
522
|
+
}
|
|
523
|
+
line += character
|
|
524
|
+
used += cellWidth
|
|
525
|
+
}
|
|
526
|
+
if (line.length > 0) lines.push(line)
|
|
527
|
+
return lines.slice(-rows)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** A small trail of thought dots above the orca's head. */
|
|
531
|
+
function drawThoughtDots(grid, layout, frame) {
|
|
532
|
+
const s = layout.scale
|
|
533
|
+
const dots = 1 + mod(Math.floor(frame / 2), 3)
|
|
534
|
+
for (let index = 0; index < dots; index += 1) {
|
|
535
|
+
const dx = layout.orca.x + layout.orca.width - 10 * s + index * 4 * s
|
|
536
|
+
const dy = Math.max(0, layout.orca.y - 4 * s)
|
|
537
|
+
rect(grid, dx, dy, dx + s - 1, dy + s - 1, 'o')
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The document being written: a sheet that gains a line as the pen moves. */
|
|
542
|
+
function drawDocument(grid, area, frame) {
|
|
543
|
+
const { x, y, width, height } = area
|
|
544
|
+
rect(grid, x, y, x + width - 1, y + height - 1, 'p')
|
|
545
|
+
rect(grid, x, y, x + width - 1, y, 'o')
|
|
546
|
+
rect(grid, x, y, x, y + height - 1, 'o')
|
|
547
|
+
rect(grid, x + width - 1, y, x + width - 1, y + height - 1, 'o')
|
|
548
|
+
const lines = 1 + mod(Math.floor(frame / 2), Math.max(1, height - 3))
|
|
549
|
+
for (let index = 0; index < lines; index += 1) {
|
|
550
|
+
const ly = y + 1 + index
|
|
551
|
+
if (ly > y + height - 2) break
|
|
552
|
+
rect(grid, x + 2, ly, x + width - 3, ly, 'K')
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** A terminal window with a blinking cursor, for a command that is running. */
|
|
557
|
+
function drawTerminal(grid, area, frame) {
|
|
558
|
+
const { x, y, width, height } = area
|
|
559
|
+
rect(grid, x, y, x + width - 1, y + height - 1, 'k')
|
|
560
|
+
rect(grid, x, y, x + width - 1, y, 'K')
|
|
561
|
+
const line = Math.max(2, height - 3)
|
|
562
|
+
rect(grid, x + 2, line, x + width - 3, line, 'o')
|
|
563
|
+
const span = Math.max(1, width - 6)
|
|
564
|
+
const cursorX = x + 2 + mod(Math.floor(frame / 2), span)
|
|
565
|
+
rect(grid, x + 2, line + 2, cursorX, line + 2, 'K')
|
|
566
|
+
if (mod(frame, 4) < 2) rect(grid, cursorX + 1, line + 2, cursorX + 1, line + 3, 'a')
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Pack a pixel grid into terminal cells, two pixel rows per cell.
|
|
571
|
+
*
|
|
572
|
+
* @param {string[][]} grid
|
|
573
|
+
* @param {Record<string, string>} palette
|
|
574
|
+
* @returns {{ text: string, sgr: string }[][]}
|
|
575
|
+
*/
|
|
576
|
+
export function cellsFromGrid(grid, palette) {
|
|
577
|
+
const width = grid[0].length
|
|
578
|
+
const cells = []
|
|
579
|
+
for (let y = 0; y + 1 < grid.length; y += 2) {
|
|
580
|
+
const row = []
|
|
581
|
+
for (let x = 0; x < width; x += 1) {
|
|
582
|
+
const top = grid[y][x]
|
|
583
|
+
const bottom = grid[y + 1][x]
|
|
584
|
+
const glyph = GLYPHS[(top === EMPTY ? 0 : 1) + (bottom === EMPTY ? 0 : 2)]
|
|
585
|
+
let cls = EMPTY
|
|
586
|
+
for (const candidate of CLASS_ORDER) {
|
|
587
|
+
if (top === candidate || bottom === candidate) {
|
|
588
|
+
cls = candidate
|
|
589
|
+
break
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const key = CLASS_KEY[cls]
|
|
593
|
+
const sgr = glyph === ' ' || key === undefined ? '' : (palette?.[key] ?? '')
|
|
594
|
+
row.push({ text: glyph, sgr })
|
|
595
|
+
}
|
|
596
|
+
cells.push(row)
|
|
597
|
+
}
|
|
598
|
+
return cells
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Render one cell row to an ANSI string, merging runs of equal style. */
|
|
602
|
+
export function renderCells(row) {
|
|
603
|
+
let out = ''
|
|
604
|
+
let current = null
|
|
605
|
+
let buffer = ''
|
|
606
|
+
const flush = () => {
|
|
607
|
+
if (buffer.length === 0) return
|
|
608
|
+
out += current === '' || current === null ? buffer : `\u001b[${current}m${buffer}\u001b[0m`
|
|
609
|
+
buffer = ''
|
|
610
|
+
}
|
|
611
|
+
for (const cell of row) {
|
|
612
|
+
const sgr = cell.sgr ?? ''
|
|
613
|
+
if (sgr !== current) {
|
|
614
|
+
flush()
|
|
615
|
+
current = sgr
|
|
616
|
+
}
|
|
617
|
+
buffer += cell.text
|
|
618
|
+
}
|
|
619
|
+
flush()
|
|
620
|
+
return out
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Where the bubble's text lines go, in terminal-cell coordinates.
|
|
625
|
+
*
|
|
626
|
+
* @param {number} lines
|
|
627
|
+
* @param {string[]} texts
|
|
628
|
+
* @param {{ width?: number, height?: number }} [size]
|
|
629
|
+
*/
|
|
630
|
+
export function bubbleLayout(lines, texts, size = {}, layout = null) {
|
|
631
|
+
const list = Array.isArray(texts) ? texts : []
|
|
632
|
+
const text = list.join('\n')
|
|
633
|
+
const geo = bubbleGeometry(list[0] ?? '', list.slice(1).join('\n'), size, layout)
|
|
634
|
+
const grid = canvas(
|
|
635
|
+
Number.isFinite(size?.width) ? size.width : SCENE_W,
|
|
636
|
+
(Number.isFinite(size?.height) ? size.height : SCENE_H / 2) * 2
|
|
637
|
+
)
|
|
638
|
+
drawBubble(grid, geo.x, geo.y, geo.innerWidth, Math.max(2, lines * 2))
|
|
639
|
+
return {
|
|
640
|
+
grid,
|
|
641
|
+
rows: list.map((line, index) => ({
|
|
642
|
+
row: geo.row + index,
|
|
643
|
+
col: geo.col,
|
|
644
|
+
text: truncate(line, geo.innerWidth, '…')
|
|
645
|
+
}))
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* How many rows of the *message* the bubble shows before it starts scrolling.
|
|
651
|
+
* The header sits above these and never scrolls: it is what says who is on the
|
|
652
|
+
* line, so losing it to a long instruction would leave the bubble anonymous.
|
|
653
|
+
*/
|
|
654
|
+
export const BUBBLE_ROWS = 3
|
|
655
|
+
|
|
656
|
+
/** Rows in the bubble in total: the pinned header plus the scrolling message. */
|
|
657
|
+
export const BUBBLE_TOTAL_ROWS = BUBBLE_ROWS + 1
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Where the bubble sits and how much text fits, for a given message.
|
|
661
|
+
*
|
|
662
|
+
* The box is sized from the whole message so it does not jump about while the
|
|
663
|
+
* text is still being spoken.
|
|
664
|
+
*/
|
|
665
|
+
export function bubbleGeometry(header, body, size, layout = null) {
|
|
666
|
+
const sceneW = Number.isFinite(size?.width) ? size.width : SCENE_W
|
|
667
|
+
const sceneH = Number.isFinite(size?.height) ? size.height : SCENE_H / 2
|
|
668
|
+
const geometry = layout ?? layoutFor({ width: sceneW, height: sceneH })
|
|
669
|
+
const widest = [String(header ?? ''), ...String(body ?? '').split('\n')]
|
|
670
|
+
.reduce((max, line) => Math.max(max, displayWidth(line)), 0)
|
|
671
|
+
// Anchored over the orca's head. A speech bubble pinned to the corner of the
|
|
672
|
+
// scene, with its tail pointing at nothing, reads as a stray box floating
|
|
673
|
+
// outside the interface.
|
|
674
|
+
let x = Math.max(1, Math.min(geometry.orca.x - 1, sceneW - 24))
|
|
675
|
+
const y = Math.max(0, Math.min(geometry.orca.y - 2, sceneH * 2 - BUBBLE_TOTAL_ROWS * 2 - 6))
|
|
676
|
+
// Where the bubble and the preview window share rows on a small scene, the
|
|
677
|
+
// bubble moves aside rather than being squeezed: a narrow bubble wraps the
|
|
678
|
+
// message into a column and hides what it is trying to say.
|
|
679
|
+
const hudTop = Math.floor(geometry.window.y / 2)
|
|
680
|
+
const hudBottom = Math.floor((geometry.window.y + geometry.window.height) / 2)
|
|
681
|
+
const bubbleTop = Math.floor(y / 2)
|
|
682
|
+
const sharesRows = bubbleTop <= hudBottom && bubbleTop + BUBBLE_TOTAL_ROWS >= hudTop
|
|
683
|
+
const hudRight = geometry.window.x + geometry.window.width
|
|
684
|
+
if (sharesRows && x < hudRight + 2) x = Math.min(hudRight + 2, Math.max(1, sceneW - 24))
|
|
685
|
+
const room = Math.max(16, Math.min(Math.round(sceneW * 0.55), sceneW - x - 5))
|
|
686
|
+
const innerWidth = Math.max(12, Math.min(room, widest + 2))
|
|
687
|
+
return {
|
|
688
|
+
x,
|
|
689
|
+
y,
|
|
690
|
+
innerWidth,
|
|
691
|
+
rows: BUBBLE_TOTAL_ROWS,
|
|
692
|
+
bodyRows: BUBBLE_ROWS,
|
|
693
|
+
col: x + 2,
|
|
694
|
+
row: Math.floor((y + 1) / 2)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Wrap plain text to a cell budget, honouring explicit newlines. */
|
|
699
|
+
export function wrapAll(text, width) {
|
|
700
|
+
const out = []
|
|
701
|
+
for (const paragraph of String(text ?? '').split('\n')) {
|
|
702
|
+
if (paragraph.length === 0) {
|
|
703
|
+
out.push('')
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
let line = ''
|
|
707
|
+
let used = 0
|
|
708
|
+
for (const character of paragraph) {
|
|
709
|
+
const cellWidth = displayWidth(character)
|
|
710
|
+
if (used + cellWidth > width && line.length > 0) {
|
|
711
|
+
out.push(line)
|
|
712
|
+
line = ''
|
|
713
|
+
used = 0
|
|
714
|
+
}
|
|
715
|
+
line += character
|
|
716
|
+
used += cellWidth
|
|
717
|
+
}
|
|
718
|
+
out.push(line)
|
|
719
|
+
}
|
|
720
|
+
return out
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The lines the bubble is showing on this frame.
|
|
725
|
+
*
|
|
726
|
+
* The message is revealed a character at a time — a telephone call is speech,
|
|
727
|
+
* and speech arrives in pieces — and once it is longer than the bubble the
|
|
728
|
+
* window scrolls to keep the newest words in view, then cycles so the start is
|
|
729
|
+
* not lost either.
|
|
730
|
+
*
|
|
731
|
+
* @returns {{ lines: string[], typing: boolean, total: number }}
|
|
732
|
+
*/
|
|
733
|
+
export function spokenLines(text, width, rows, frame, options = {}) {
|
|
734
|
+
const raw = String(text ?? '')
|
|
735
|
+
if (raw.length === 0) return { lines: [], typing: false, total: 0 }
|
|
736
|
+
const perFrame = Number.isFinite(options.perFrame) ? options.perFrame : 2.5
|
|
737
|
+
// At least one character, so a message is never invisible on its first frame.
|
|
738
|
+
const revealed = raw.slice(0, Math.min(raw.length, Math.max(1, Math.floor(frame * perFrame))))
|
|
739
|
+
const typing = revealed.length < raw.length
|
|
740
|
+
const wrapped = wrapAll(revealed, Math.max(1, width))
|
|
741
|
+
if (wrapped.length <= rows) return { lines: wrapped, typing, total: wrapped.length }
|
|
742
|
+
const maxStart = wrapped.length - rows
|
|
743
|
+
const start = typing ? maxStart : Math.floor(frame / 6) % (maxStart + 1)
|
|
744
|
+
return { lines: wrapped.slice(start, start + rows), typing, total: wrapped.length }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Compose one complete scene frame.
|
|
749
|
+
*
|
|
750
|
+
* @param {{ kind: string, subagent?: object | null }} work
|
|
751
|
+
* @param {number} frame
|
|
752
|
+
* @param {{ palette?: Record<string, string>, bubbleLines?: string[] | null, size?: { width: number, height: number } }} [options]
|
|
753
|
+
* @returns {{ cells: { text: string, sgr: string }[][], width: number, height: number }}
|
|
754
|
+
*/
|
|
755
|
+
export function sceneFrame(work, frame, options = {}) {
|
|
756
|
+
const size = options.size ?? { width: SCENE_W, height: SCENE_H / 2 }
|
|
757
|
+
const sky = options.sky ?? skyState(options.clockMs)
|
|
758
|
+
// The sky is the one colour that changes on its own, so it is folded into the
|
|
759
|
+
// palette rather than baked into the art. A palette that names no sky stays
|
|
760
|
+
// colourless, which is what `--no-color` relies on.
|
|
761
|
+
const palette = { ...(options.palette ?? {}) }
|
|
762
|
+
// The sky is the one group of colours that changes on its own, so they are
|
|
763
|
+
// folded into the palette rather than baked into the art.
|
|
764
|
+
for (const [key, value] of Object.entries(sky.colors.sgr)) {
|
|
765
|
+
if (key in palette) palette[key] = value
|
|
766
|
+
}
|
|
767
|
+
const grid = sceneGrid(work, frame, {
|
|
768
|
+
size,
|
|
769
|
+
phoneLift: options.phoneLift,
|
|
770
|
+
sky,
|
|
771
|
+
scene: options.scene,
|
|
772
|
+
sleepFor: options.sleepFor
|
|
773
|
+
})
|
|
774
|
+
const cells = cellsFromGrid(grid, palette)
|
|
775
|
+
|
|
776
|
+
const preview = options.previewText ?? null
|
|
777
|
+
if (
|
|
778
|
+
preview !== null &&
|
|
779
|
+
preview !== undefined &&
|
|
780
|
+
String(preview).length > 0 &&
|
|
781
|
+
windowVisible(work?.kind ?? 'sleep', size)
|
|
782
|
+
) {
|
|
783
|
+
const area = previewArea(size)
|
|
784
|
+
if (area !== null) {
|
|
785
|
+
const rows = wrapPreview(preview, area.width, area.rows)
|
|
786
|
+
rows.forEach((text, index) => {
|
|
787
|
+
const row = area.row + index
|
|
788
|
+
if (row < 0 || row >= cells.length) return
|
|
789
|
+
let x = area.col
|
|
790
|
+
for (const character of text) {
|
|
791
|
+
const cellWidth = displayWidth(character)
|
|
792
|
+
if (x >= 0 && x < cells[row].length) cells[row][x] = { text: character, sgr: palette.text ?? '' }
|
|
793
|
+
for (let extra = 1; extra < cellWidth; extra += 1) {
|
|
794
|
+
if (x + extra >= 0 && x + extra < cells[row].length) cells[row][x + extra] = { text: '', sgr: '' }
|
|
795
|
+
}
|
|
796
|
+
x += cellWidth
|
|
797
|
+
}
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const given = Array.isArray(options.bubbleLines) ? options.bubbleLines : null
|
|
803
|
+
const header = options.bubbleHeader ?? given?.[0] ?? (typeof options.bubbleText === 'string' ? options.bubbleText.split('\n')[0] : null)
|
|
804
|
+
const body = options.bubbleBody ?? (given !== null ? given.slice(1).join('\n') : (typeof options.bubbleText === 'string' ? options.bubbleText.split('\n').slice(1).join('\n') : ''))
|
|
805
|
+
if (header !== null && header !== undefined && String(header).length > 0) {
|
|
806
|
+
const geo = bubbleGeometry(header, body, size, layoutFor(size))
|
|
807
|
+
const boxGrid = canvas(size.width, size.height * 2)
|
|
808
|
+
drawBubble(boxGrid, geo.x, geo.y, geo.innerWidth, geo.rows * 2)
|
|
809
|
+
const boxCells = cellsFromGrid(boxGrid, palette)
|
|
810
|
+
for (let y = 0; y < boxCells.length && y < cells.length; y += 1) {
|
|
811
|
+
for (let x = 0; x < boxCells[y].length; x += 1) {
|
|
812
|
+
if (boxCells[y][x].text !== ' ') cells[y][x] = boxCells[y][x]
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const put = (row, line) => {
|
|
817
|
+
if (row < 0 || row >= cells.length) return
|
|
818
|
+
let x = geo.col
|
|
819
|
+
for (const character of line) {
|
|
820
|
+
const cellWidth = displayWidth(character)
|
|
821
|
+
if (x >= 0 && x < cells[row].length) cells[row][x] = { text: character, sgr: palette.text ?? '' }
|
|
822
|
+
for (let extra = 1; extra < cellWidth; extra += 1) {
|
|
823
|
+
if (x + extra >= 0 && x + extra < cells[row].length) cells[row][x + extra] = { text: '', sgr: '' }
|
|
824
|
+
}
|
|
825
|
+
x += cellWidth
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// The header identifies who is on the line, so it is pinned; only the
|
|
830
|
+
// message below it is spoken and scrolled.
|
|
831
|
+
put(geo.row, truncate(String(header), geo.innerWidth, '…'))
|
|
832
|
+
const spoken = spokenLines(body, geo.innerWidth, geo.bodyRows, options.speechFrame ?? frame, options.speech)
|
|
833
|
+
spoken.lines.forEach((line, index) => put(geo.row + 1 + index, line))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return { cells, width: size.width, height: size.height, sky }
|
|
837
|
+
}
|