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,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many pixels a terminal cell can carry, and with what glyphs.
|
|
3
|
+
*
|
|
4
|
+
* A cell is roughly twice as tall as it is wide, so the mapping from pixels to
|
|
5
|
+
* glyphs decides both the resolution and the shape of the pixels:
|
|
6
|
+
*
|
|
7
|
+
* | packing | pixels/cell | pixel shape | glyphs |
|
|
8
|
+
* |------------|-------------|-------------|---------------------------------|
|
|
9
|
+
* | `half` | 1 x 2 | square | `▀ ▄ █` |
|
|
10
|
+
* | `quadrant` | 2 x 2 | 1:2 tall | `▘ ▝ ▖ ▗ ▚ ▞ ▛ ▜ ▙ ▟` |
|
|
11
|
+
* | `braille` | 2 x 4 | square | `U+2800..U+28FF` |
|
|
12
|
+
*
|
|
13
|
+
* `half` is what the dashboard draws with: it is the only packing whose pixels
|
|
14
|
+
* are square *and* whose glyphs are solid, which is what pixel art of a whale
|
|
15
|
+
* needs. `braille` carries four times as many pixels with square pixels too,
|
|
16
|
+
* but every pixel is a dot with gaps around it, so solid areas come out
|
|
17
|
+
* stippled — good for a plot, poor for a picture. `quadrant` doubles the
|
|
18
|
+
* horizontal count at the cost of pixels that are twice as tall as they are
|
|
19
|
+
* wide, which stretches anything drawn for square pixels.
|
|
20
|
+
*
|
|
21
|
+
* None of this is a free win, which is why the dashboard stays on `half` and
|
|
22
|
+
* this module exists to let you *look* at the alternatives on your own font.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-live-working/packing
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Half blocks: one wide, two tall, and every glyph is solid. */
|
|
28
|
+
const HALF = {
|
|
29
|
+
name: 'half',
|
|
30
|
+
cols: 1,
|
|
31
|
+
rows: 2,
|
|
32
|
+
square: true,
|
|
33
|
+
solid: true,
|
|
34
|
+
glyphs: [' ', '▀', '▄', '█'],
|
|
35
|
+
label: 'half blocks — 1x2, square pixels, solid glyphs (what the dashboard uses)'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Quadrant blocks, in row-major bit order: top-left 1, top-right 2,
|
|
40
|
+
* bottom-left 4, bottom-right 8. Combinations that coincide with a half or
|
|
41
|
+
* full block use that glyph instead, which is both cleaner and better
|
|
42
|
+
* supported.
|
|
43
|
+
*/
|
|
44
|
+
const QUADRANT = {
|
|
45
|
+
name: 'quadrant',
|
|
46
|
+
cols: 2,
|
|
47
|
+
rows: 2,
|
|
48
|
+
square: false,
|
|
49
|
+
solid: true,
|
|
50
|
+
glyphs: [
|
|
51
|
+
' ', '▘', '▝', '▀',
|
|
52
|
+
'▖', '▌', '▞', '▛',
|
|
53
|
+
'▗', '▚', '▐', '▜',
|
|
54
|
+
'▄', '▙', '▟', '█'
|
|
55
|
+
],
|
|
56
|
+
label: 'quadrant blocks — 2x2, pixels 1:2 tall, solid glyphs'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Braille patterns. The eight dots are numbered down each column, so the bit
|
|
61
|
+
* for a dot is not its position in reading order:
|
|
62
|
+
*
|
|
63
|
+
* ```
|
|
64
|
+
* 1 4
|
|
65
|
+
* 2 5
|
|
66
|
+
* 3 6
|
|
67
|
+
* 7 8
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
const BRAILLE_BITS = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80]
|
|
71
|
+
const BRAILLE = {
|
|
72
|
+
name: 'braille',
|
|
73
|
+
cols: 2,
|
|
74
|
+
rows: 4,
|
|
75
|
+
square: true,
|
|
76
|
+
solid: false,
|
|
77
|
+
glyphs: Array.from({ length: 256 }, (_, mask) => String.fromCodePoint(0x2800 + mask)),
|
|
78
|
+
bits: BRAILLE_BITS,
|
|
79
|
+
label: 'braille — 2x4, square pixels, but every pixel is a dot with gaps'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every packing, in increasing order of resolution. */
|
|
83
|
+
export const PACKINGS = { half: HALF, quadrant: QUADRANT, braille: BRAILLE }
|
|
84
|
+
|
|
85
|
+
/** Packing names. */
|
|
86
|
+
export const PACKING_NAMES = Object.keys(PACKINGS)
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} [name]
|
|
90
|
+
* @returns {typeof HALF}
|
|
91
|
+
*/
|
|
92
|
+
export function packingFor(name) {
|
|
93
|
+
return PACKINGS[name] ?? HALF
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The glyph for a set of filled pixels within one cell.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} packing
|
|
100
|
+
* @param {boolean[]} filled row-major, `packing.cols * packing.rows` entries
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function glyphFor(packing, filled) {
|
|
104
|
+
if (packing.bits === undefined) {
|
|
105
|
+
let mask = 0
|
|
106
|
+
for (let index = 0; index < filled.length; index += 1) {
|
|
107
|
+
if (filled[index]) mask |= 1 << index
|
|
108
|
+
}
|
|
109
|
+
return packing.glyphs[mask] ?? ' '
|
|
110
|
+
}
|
|
111
|
+
let mask = 0
|
|
112
|
+
for (let index = 0; index < filled.length; index += 1) {
|
|
113
|
+
if (filled[index]) mask |= packing.bits[index]
|
|
114
|
+
}
|
|
115
|
+
return packing.glyphs[mask] ?? ' '
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A sample sheet for one packing, so you can see what your font does with it.
|
|
120
|
+
*
|
|
121
|
+
* A font that lacks these glyphs does not fail loudly: it substitutes a box, a
|
|
122
|
+
* blank, or another character, and the picture quietly falls apart. Printing
|
|
123
|
+
* this is the only reliable way to find out.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} packing
|
|
126
|
+
* @returns {{ label: string, rows: string[] }}
|
|
127
|
+
*/
|
|
128
|
+
export function probe(packing) {
|
|
129
|
+
const rows = []
|
|
130
|
+
// Every glyph the packing uses, in a grid.
|
|
131
|
+
for (let start = 0; start < packing.glyphs.length; start += 16) {
|
|
132
|
+
rows.push(packing.glyphs.slice(start, start + 16).map((glyph) => `${glyph}${glyph}`).join(''))
|
|
133
|
+
}
|
|
134
|
+
// A disc rasterised at the packing's own pixel resolution, so its edge falls
|
|
135
|
+
// on partial cells and every glyph gets used. Filling whole cells instead
|
|
136
|
+
// would draw an identical blocky disc for all three packings and tell you
|
|
137
|
+
// nothing.
|
|
138
|
+
const cells = 17
|
|
139
|
+
const across = cells * packing.cols
|
|
140
|
+
const down = cells * packing.rows
|
|
141
|
+
const radius = (Math.min(across, down) - 1) / 2
|
|
142
|
+
const cx = (across - 1) / 2
|
|
143
|
+
const cy = (down - 1) / 2
|
|
144
|
+
const filledAt = (px, py) => (px - cx) ** 2 + (py - cy) ** 2 <= radius * radius
|
|
145
|
+
for (let cellY = 0; cellY < cells; cellY += 1) {
|
|
146
|
+
const line = []
|
|
147
|
+
for (let cellX = 0; cellX < cells; cellX += 1) {
|
|
148
|
+
const filled = []
|
|
149
|
+
for (let row = 0; row < packing.rows; row += 1) {
|
|
150
|
+
for (let col = 0; col < packing.cols; col += 1) {
|
|
151
|
+
filled.push(filledAt(cellX * packing.cols + col, cellY * packing.rows + row))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
line.push(glyphFor(packing, filled))
|
|
155
|
+
}
|
|
156
|
+
rows.push(line.join(''))
|
|
157
|
+
}
|
|
158
|
+
return { label: packing.label, rows }
|
|
159
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session chooser, as a pure reducer.
|
|
3
|
+
*
|
|
4
|
+
* With several sessions running, guessing which one the user meant is a guess
|
|
5
|
+
* they cannot see the basis of, so `dsh-live-working` asks. The key handling
|
|
6
|
+
* lives here rather than inline in the render loop so it can be tested without
|
|
7
|
+
* a terminal: the parts that have broken in this command have all been in the
|
|
8
|
+
* wiring between a key press and a state change.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-live-working/picker
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{ index: number, count: number, boundId: string | null }} PickerState
|
|
15
|
+
* @typedef {{ index: number, intent: 'none' | 'bind' | 'cancel', sessionId: string | null }} PickerStep
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Positive modulo. */
|
|
19
|
+
function mod(value, base) {
|
|
20
|
+
if (base <= 0) return 0
|
|
21
|
+
return ((value % base) + base) % base
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A picker positioned on the currently bound session.
|
|
26
|
+
*
|
|
27
|
+
* @param {Array<{ id: string }>} sessions
|
|
28
|
+
* @param {string | null} [boundId]
|
|
29
|
+
* @returns {PickerState}
|
|
30
|
+
*/
|
|
31
|
+
export function createPickerState(sessions, boundId = null) {
|
|
32
|
+
const list = Array.isArray(sessions) ? sessions : []
|
|
33
|
+
const found = list.findIndex((session) => session?.id === boundId)
|
|
34
|
+
return { index: found >= 0 ? found : 0, count: list.length, boundId: boundId ?? null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Apply one key.
|
|
39
|
+
*
|
|
40
|
+
* @param {PickerState} state
|
|
41
|
+
* @param {string} key
|
|
42
|
+
* @param {Array<{ id: string }>} [sessions]
|
|
43
|
+
* @returns {PickerStep}
|
|
44
|
+
*/
|
|
45
|
+
export function applyPickerKey(state, key, sessions = []) {
|
|
46
|
+
const count = state.count > 0 ? state.count : (Array.isArray(sessions) ? sessions.length : 0)
|
|
47
|
+
const index = mod(state.index, Math.max(1, count))
|
|
48
|
+
const stay = (next = index) => ({ index: next, intent: 'none', sessionId: null })
|
|
49
|
+
|
|
50
|
+
switch (key) {
|
|
51
|
+
case 'up':
|
|
52
|
+
case 'k':
|
|
53
|
+
return stay(mod(index - 1, Math.max(1, count)))
|
|
54
|
+
case 'down':
|
|
55
|
+
case 'j':
|
|
56
|
+
return stay(mod(index + 1, Math.max(1, count)))
|
|
57
|
+
case 'home':
|
|
58
|
+
return stay(0)
|
|
59
|
+
case 'end':
|
|
60
|
+
return stay(Math.max(0, count - 1))
|
|
61
|
+
case 'enter':
|
|
62
|
+
case '\r':
|
|
63
|
+
case '\n': {
|
|
64
|
+
const chosen = Array.isArray(sessions) ? sessions[index] : undefined
|
|
65
|
+
const sessionId = chosen?.id ?? null
|
|
66
|
+
return { index, intent: sessionId === null ? 'cancel' : 'bind', sessionId }
|
|
67
|
+
}
|
|
68
|
+
case 'escape':
|
|
69
|
+
case 's':
|
|
70
|
+
case 'q':
|
|
71
|
+
return { index, intent: 'cancel', sessionId: null }
|
|
72
|
+
default:
|
|
73
|
+
return stay()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The props on the desk: a keyboard, a stack of books and a red telephone.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is composed from a few primitives rather than stored as pixel
|
|
5
|
+
* grids, so each prop can move, open or light up between frames. All
|
|
6
|
+
* coordinates are pixels in the scene grid; the renderer packs two pixel rows
|
|
7
|
+
* into each terminal cell. Sizes are passed in, so the same prop works at any
|
|
8
|
+
* scale.
|
|
9
|
+
*
|
|
10
|
+
* Classes: `.` transparent, `d` desk wood, `D` desk shadow, `k` keyboard deck,
|
|
11
|
+
* `K` keycap, `a` lit key, `r` telephone red, `R` telephone shadow, `p` page,
|
|
12
|
+
* `q` page in shadow, `Q` deep shadow, `g`/`y`/`c` book covers, `o` dark line,
|
|
13
|
+
* `n` bubble border, `N` bubble fill, `e` window edge, `E` window fill,
|
|
14
|
+
* `t` text.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-live-working/props
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A blank pixel canvas. */
|
|
20
|
+
export function canvas(width, height) {
|
|
21
|
+
return Array.from({ length: Math.max(1, height) }, () => new Array(Math.max(1, width)).fill('.'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fill an inclusive rectangle. */
|
|
25
|
+
export function rect(grid, x0, y0, x1, y1, cls) {
|
|
26
|
+
const height = grid.length
|
|
27
|
+
const width = grid[0].length
|
|
28
|
+
for (let y = Math.max(0, y0); y <= Math.min(height - 1, y1); y += 1) {
|
|
29
|
+
for (let x = Math.max(0, x0); x <= Math.min(width - 1, x1); x += 1) grid[y][x] = cls
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Draw a rectangle outline. */
|
|
34
|
+
export function frame(grid, x0, y0, x1, y1, cls) {
|
|
35
|
+
rect(grid, x0, y0, x1, y0, cls)
|
|
36
|
+
rect(grid, x0, y1, x1, y1, cls)
|
|
37
|
+
rect(grid, x0, y0, x0, y1, cls)
|
|
38
|
+
rect(grid, x1, y0, x1, y1, cls)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Draw a ring of whole cells, for a dial. */
|
|
42
|
+
export function ring(grid, cx, cy, radius, cls) {
|
|
43
|
+
const r = Math.max(1, Math.round(radius))
|
|
44
|
+
const inner = (r - 1) * (r - 1)
|
|
45
|
+
for (let dy = -r; dy <= r; dy += 1) {
|
|
46
|
+
for (let dx = -r; dx <= r; dx += 1) {
|
|
47
|
+
const d = dx * dx + dy * dy
|
|
48
|
+
if (d > r * r || d < inner) continue
|
|
49
|
+
const px = cx + dx
|
|
50
|
+
const py = cy + dy
|
|
51
|
+
if (py < 0 || py >= grid.length || px < 0 || px >= grid[0].length) continue
|
|
52
|
+
grid[py][px] = cls
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Copy a grid onto a canvas at an offset, optionally only onto empty pixels. */
|
|
58
|
+
export function blit(target, source, dx, dy, only) {
|
|
59
|
+
const height = target.length
|
|
60
|
+
const width = target[0].length
|
|
61
|
+
for (let y = 0; y < source.length; y += 1) {
|
|
62
|
+
for (let x = 0; x < source[y].length; x += 1) {
|
|
63
|
+
const ch = source[y][x]
|
|
64
|
+
if (ch === '.') continue
|
|
65
|
+
const tx = x + dx
|
|
66
|
+
const ty = y + dy
|
|
67
|
+
if (tx < 0 || tx >= width || ty < 0 || ty >= height) continue
|
|
68
|
+
if (only !== undefined && !only(target[ty][tx])) continue
|
|
69
|
+
target[ty][tx] = ch
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The desk: a top surface, a darker front edge, and two legs. */
|
|
75
|
+
export function drawDesk(grid, top, left, right, thickness) {
|
|
76
|
+
const height = grid.length
|
|
77
|
+
const slab = Math.max(2, thickness)
|
|
78
|
+
rect(grid, left, top, right, top + slab - 1, 'd')
|
|
79
|
+
rect(grid, left, top + slab, right, top + slab + 2, 'D')
|
|
80
|
+
const legTop = top + slab + 3
|
|
81
|
+
if (legTop < height) {
|
|
82
|
+
const legWidth = Math.max(2, Math.round((right - left) * 0.04))
|
|
83
|
+
rect(grid, left + 3, legTop, left + 3 + legWidth, height - 1, 'D')
|
|
84
|
+
rect(grid, right - 3 - legWidth, legTop, right - 3, height - 1, 'D')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A keyboard lying flat on the desk, seen from the front and slightly above.
|
|
90
|
+
*
|
|
91
|
+
* Drawn as a **trapezoid**: the far edge is narrower than the near edge, and
|
|
92
|
+
* the rows of keycaps shrink towards the back. A plain rectangle reads as a
|
|
93
|
+
* picture of a keyboard pasted onto the desk rather than an object on it.
|
|
94
|
+
*
|
|
95
|
+
* `phase` sweeps which keys are lit; a negative phase leaves the deck alone,
|
|
96
|
+
* for the states where the orca has its hands off it.
|
|
97
|
+
*/
|
|
98
|
+
export function drawKeyboard(grid, x, y, width, height, phase) {
|
|
99
|
+
// A small dark bar. An earlier version drew a perspective deck with rows of
|
|
100
|
+
// keycaps, which at this size was a grey mass that read as neither a
|
|
101
|
+
// keyboard nor anything else; a plain slab on the desk is clearer.
|
|
102
|
+
const h = Math.max(2, height)
|
|
103
|
+
rect(grid, x, y, x + width - 1, y + h - 1, 'k')
|
|
104
|
+
rect(grid, x, y, x + width - 1, y, 'K')
|
|
105
|
+
rect(grid, x, y + h - 1, x + width - 1, y + h - 1, 'D')
|
|
106
|
+
// A single travelling highlight, so a keyboard that is being used still
|
|
107
|
+
// looks alive without drawing keys.
|
|
108
|
+
void phase
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A pectoral flipper, drawn over the orca's shoulder.
|
|
113
|
+
*
|
|
114
|
+
* The sampled sprite is a side view with no separate limb, so a flipper that
|
|
115
|
+
* moves has to be drawn: the reference artwork cannot animate something it does
|
|
116
|
+
* not depict.
|
|
117
|
+
*
|
|
118
|
+
* @param {number} size scale in pixels
|
|
119
|
+
* @param {boolean} down whether the tip is on the keys
|
|
120
|
+
*/
|
|
121
|
+
export function drawFlipper(grid, x, y, size, down, cls = 'f') {
|
|
122
|
+
const s = Math.max(1, size)
|
|
123
|
+
// Deliberately stubby. A longer reach reads as a spindly arm rather than a
|
|
124
|
+
// pectoral fin.
|
|
125
|
+
drawLimb(grid, x, y, s, 2 * s, (down ? 2 : 1) * s, cls)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A thick limb drawn along a direction, from `(x, y)` towards `(x + dx, y + dy)`.
|
|
130
|
+
*
|
|
131
|
+
* @param {number} size thickness scale in pixels
|
|
132
|
+
*/
|
|
133
|
+
export function drawLimb(grid, x, y, size, dx, dy, cls = 'f') {
|
|
134
|
+
const s = Math.max(1, size)
|
|
135
|
+
const steps = Math.max(1, Math.round(Math.hypot(dx, dy)))
|
|
136
|
+
const thickness = Math.max(2, Math.round(s * 1.3))
|
|
137
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
138
|
+
const cx = x + Math.round((dx * step) / steps)
|
|
139
|
+
const cy = y + Math.round((dy * step) / steps)
|
|
140
|
+
rect(grid, cx, cy, cx + thickness - 1, cy + thickness - 1, cls)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The little window that shows what the orca is typing.
|
|
146
|
+
*
|
|
147
|
+
* A frame and a dark interior; the text is written by the caller, because
|
|
148
|
+
* glyphs are not pixels.
|
|
149
|
+
*
|
|
150
|
+
* @returns {{ x: number, y: number, width: number, height: number }} the inner
|
|
151
|
+
* area in pixels where text may be placed
|
|
152
|
+
*/
|
|
153
|
+
export function drawWindow(grid, x, y, width, height) {
|
|
154
|
+
rect(grid, x, y, x + width - 1, y + height - 1, 'e')
|
|
155
|
+
rect(grid, x + 1, y + 1, x + width - 2, y + height - 2, 'E')
|
|
156
|
+
// Title bar, so it reads as a window rather than a hole.
|
|
157
|
+
rect(grid, x + 1, y + 1, x + width - 2, y + 1, 'e')
|
|
158
|
+
return { x: x + 2, y: y + 2, width: Math.max(1, width - 4), height: Math.max(1, height - 3) }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* An open book: two pages either side of a spine, with shading, and a page
|
|
163
|
+
* part-way through turning.
|
|
164
|
+
*
|
|
165
|
+
* The shading is the point. Real pages are all the same white, but a book is
|
|
166
|
+
* not lit evenly: the gutter between the pages falls into shadow, the leaf
|
|
167
|
+
* away from the light is darker than the one facing it, and a page caught
|
|
168
|
+
* mid-turn is edge-on and darker still. Without that the spread reads as a
|
|
169
|
+
* blank rectangle.
|
|
170
|
+
*/
|
|
171
|
+
export function drawOpenBook(grid, x, y, width, height, phase) {
|
|
172
|
+
const bottom = y + height - 1
|
|
173
|
+
const top = y + 1
|
|
174
|
+
const pageBottom = bottom - 1
|
|
175
|
+
const spine = x + Math.floor(width / 2)
|
|
176
|
+
|
|
177
|
+
rect(grid, x, y, x + width - 1, bottom, 'o')
|
|
178
|
+
// Left page catches the light; the right page is turned away from it.
|
|
179
|
+
rect(grid, x + 1, top, spine - 1, pageBottom, 'p')
|
|
180
|
+
rect(grid, spine + 1, top, x + width - 2, pageBottom, 'q')
|
|
181
|
+
// Gutter shadow either side of the spine.
|
|
182
|
+
rect(grid, spine - 2, top, spine - 1, pageBottom, 'q')
|
|
183
|
+
rect(grid, spine + 1, top, spine + 2, pageBottom, 'Q')
|
|
184
|
+
rect(grid, spine, top, spine, pageBottom, 'o')
|
|
185
|
+
// The block of pages under the spread.
|
|
186
|
+
rect(grid, x + 1, bottom, x + width - 2, bottom, 'Q')
|
|
187
|
+
|
|
188
|
+
const half = Math.max(2, Math.floor((width - 3) / 2))
|
|
189
|
+
const steps = 6
|
|
190
|
+
const step = ((Math.trunc(phase) % steps) + steps) % steps
|
|
191
|
+
const turning = step < steps / 2 ? 'q' : 'p'
|
|
192
|
+
if (step < steps / 2) {
|
|
193
|
+
// Lifting off the right-hand side and folding towards the spine: the leaf
|
|
194
|
+
// is edge-on, so it is the darkest thing on the spread.
|
|
195
|
+
const fold = Math.round((half * step * 2) / steps)
|
|
196
|
+
const edge = spine + half - fold
|
|
197
|
+
rect(grid, edge, top, spine + half, pageBottom, 'q')
|
|
198
|
+
rect(grid, edge, top, spine + half, top, 'o')
|
|
199
|
+
rect(grid, edge, top, edge, pageBottom, 'Q')
|
|
200
|
+
} else {
|
|
201
|
+
// Landing on the left, widening away from the spine, picking the light up
|
|
202
|
+
// as it flattens.
|
|
203
|
+
const grow = Math.round((half * (step - steps / 2) * 2) / steps)
|
|
204
|
+
rect(grid, spine - grow, top, spine, pageBottom, turning)
|
|
205
|
+
rect(grid, spine - grow, top, spine, top, 'o')
|
|
206
|
+
rect(grid, spine - grow, top, spine - grow, pageBottom, 'Q')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A stack of closed books. `open` puts an open, page-turning book on top. */
|
|
211
|
+
export function drawBooks(grid, x, y, width, height, open, phase) {
|
|
212
|
+
const covers = ['g', 'y', 'c']
|
|
213
|
+
const thickness = Math.max(2, Math.floor((height - 1) / 3))
|
|
214
|
+
let cursor = y + height - 1
|
|
215
|
+
for (let index = 0; index < 3; index += 1) {
|
|
216
|
+
const bottom = cursor
|
|
217
|
+
const top = Math.max(y, cursor - thickness + 1)
|
|
218
|
+
rect(grid, x, top, x + width - 1, bottom, covers[index])
|
|
219
|
+
rect(grid, x, top, x + width - 1, top, 'o')
|
|
220
|
+
// Page block on the fore edge, with the bottom book in shadow.
|
|
221
|
+
rect(grid, x + width - 2, top + 1, x + width - 1, bottom, index === 0 ? 'Q' : 'p')
|
|
222
|
+
cursor = top - 1
|
|
223
|
+
}
|
|
224
|
+
if (open) drawOpenBook(grid, x, y - Math.max(5, height - 4), width, Math.max(4, thickness + 2), phase)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A handset: a bar with a bulb at each end, drawn at an angle.
|
|
229
|
+
*
|
|
230
|
+
* The angle is what makes the pickup readable. Snapping the handset from lying
|
|
231
|
+
* flat to standing at the ear is a teleport; rotating it there is an action.
|
|
232
|
+
*
|
|
233
|
+
* @param {number} angle radians, 0 being horizontal with the earpiece right
|
|
234
|
+
*/
|
|
235
|
+
export function drawHandset(grid, cx, cy, length, thickness, angle) {
|
|
236
|
+
const t = Math.max(1, Math.round(thickness))
|
|
237
|
+
const steps = Math.max(2, Math.round(length))
|
|
238
|
+
const dx = Math.cos(angle)
|
|
239
|
+
const dy = Math.sin(angle)
|
|
240
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
241
|
+
const along = (index / steps - 0.5) * length
|
|
242
|
+
const px = Math.round(cx + dx * along - (t - 1) / 2)
|
|
243
|
+
const py = Math.round(cy + dy * along - (t - 1) / 2)
|
|
244
|
+
rect(grid, px, py, px + t - 1, py + t - 1, 'r')
|
|
245
|
+
}
|
|
246
|
+
// Earpiece and mouthpiece: bulbs at either end.
|
|
247
|
+
const bulb = Math.max(t, Math.round(t * 1.6))
|
|
248
|
+
for (const direction of [-1, 1]) {
|
|
249
|
+
const ex = Math.round(cx + dx * direction * (length / 2))
|
|
250
|
+
const ey = Math.round(cy + dy * direction * (length / 2))
|
|
251
|
+
rect(grid, ex - Math.floor(bulb / 2), ey - Math.floor(bulb / 2), ex + Math.floor(bulb / 2), ey + Math.floor(bulb / 2), 'r')
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A red telephone on its cradle, with the handset anywhere between resting and
|
|
257
|
+
* held to the ear.
|
|
258
|
+
*
|
|
259
|
+
* `at.lift` runs 0 (lying on the cradle) to 1 (at the ear); everything between
|
|
260
|
+
* is the handset on its way up or down. The cord follows whichever end is the
|
|
261
|
+
* mouthpiece at that moment.
|
|
262
|
+
*
|
|
263
|
+
* @param {{ x: number, y: number, scale: number, lift?: number, shake?: number } | null} at
|
|
264
|
+
*/
|
|
265
|
+
export function drawPhone(grid, x, y, width, height, at) {
|
|
266
|
+
const s = Math.max(1, Math.round(at?.scale ?? 1))
|
|
267
|
+
const raw = Number.isFinite(at?.lift) ? at.lift : (at === null || at === undefined ? 0 : 1)
|
|
268
|
+
const lift = Math.max(0, Math.min(1, raw))
|
|
269
|
+
// Smoothstep: the handset eases off the cradle and eases onto the ear.
|
|
270
|
+
const eased = lift * lift * (3 - 2 * lift)
|
|
271
|
+
const shake = Number.isFinite(at?.shake) ? Math.round(at.shake) : 0
|
|
272
|
+
|
|
273
|
+
// The base: a body with a lit top surface, so it has a front and a top rather
|
|
274
|
+
// than being a red rectangle.
|
|
275
|
+
const baseTop = y + Math.round(height * 0.45)
|
|
276
|
+
const baseH = Math.max(3, y + height - baseTop)
|
|
277
|
+
const capH = Math.max(1, Math.round(baseH * 0.28))
|
|
278
|
+
rect(grid, x, baseTop, x + width - 1, y + height - 1, 'R')
|
|
279
|
+
rect(grid, x, baseTop, x + width - 1, baseTop + capH - 1, 'r')
|
|
280
|
+
rect(grid, x + 1, baseTop + capH, x + width - 2, y + height - 2, 'r')
|
|
281
|
+
rect(grid, x, y + height - 1, x + width - 1, y + height - 1, 'R')
|
|
282
|
+
|
|
283
|
+
// The dial, on the front face.
|
|
284
|
+
const dialR = Math.max(1, Math.round(Math.min(width, baseH) * 0.24))
|
|
285
|
+
ring(grid, x + Math.max(2, Math.round(width * 0.3)), baseTop + capH + Math.max(1, Math.round((baseH - capH) * 0.45)), dialR, 'o')
|
|
286
|
+
|
|
287
|
+
// The cradle: two nubs the handset rests between.
|
|
288
|
+
const cradleY = Math.max(0, baseTop - Math.max(2, Math.round(baseH * 0.45)))
|
|
289
|
+
rect(grid, x + 1, cradleY, x + 1, baseTop - 1, 'o')
|
|
290
|
+
rect(grid, x + width - 2, cradleY, x + width - 2, baseTop - 1, 'o')
|
|
291
|
+
|
|
292
|
+
// Where the handset rests, and where it ends up.
|
|
293
|
+
const restCx = x + (width - 1) / 2
|
|
294
|
+
const restCy = cradleY - 1.5 * s
|
|
295
|
+
const restLength = width - 2
|
|
296
|
+
const run = 6 * s
|
|
297
|
+
const drop = 13 * s
|
|
298
|
+
const earX = at?.x ?? restCx
|
|
299
|
+
const earY = at?.y ?? restCy
|
|
300
|
+
const heldCx = earX - run / 2 + shake
|
|
301
|
+
const heldCy = earY + drop / 2
|
|
302
|
+
const heldLength = Math.hypot(run, drop)
|
|
303
|
+
|
|
304
|
+
const cx = restCx + (heldCx - restCx) * eased
|
|
305
|
+
const cy = restCy + (heldCy - restCy) * eased
|
|
306
|
+
const angle = (-Math.atan2(drop, run)) * eased
|
|
307
|
+
const length = restLength + (heldLength - restLength) * eased
|
|
308
|
+
|
|
309
|
+
drawHandset(grid, cx, cy, length, Math.max(1, Math.round(2.4 * s)), angle)
|
|
310
|
+
|
|
311
|
+
// Cord, from the mouthpiece end down to the base. It slackens as the handset
|
|
312
|
+
// rises, which is most of what sells the movement.
|
|
313
|
+
const dirX = Math.cos(angle)
|
|
314
|
+
const dirY = Math.sin(angle)
|
|
315
|
+
const fromX = Math.round(cx - dirX * (length / 2))
|
|
316
|
+
const fromY = Math.round(cy - dirY * (length / 2))
|
|
317
|
+
const span = Math.max(1, 10 * s)
|
|
318
|
+
for (let step = 0; step <= span; step += 1) {
|
|
319
|
+
const ratio = step / span
|
|
320
|
+
const sag = Math.round(Math.sin(Math.PI * ratio) * (3 * s + Math.round(eased * 3 * s)))
|
|
321
|
+
const px = Math.round(fromX + (x + width - 2 - fromX) * ratio)
|
|
322
|
+
const py = fromY + Math.round((baseTop - fromY) * ratio) + sag
|
|
323
|
+
if (py >= 0 && py < grid.length && px >= 0 && px < grid[0].length) grid[py][px] = 'R'
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { cx, cy, angle, length }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* A speech bubble with a tail, sized to the text it will hold.
|
|
331
|
+
*
|
|
332
|
+
* @returns {{ x: number, y: number, width: number, height: number }} the inner
|
|
333
|
+
* area, in pixels, where text may be placed
|
|
334
|
+
*/
|
|
335
|
+
export function drawBubble(grid, x, y, innerWidth, innerHeight) {
|
|
336
|
+
const x1 = Math.min(grid[0].length - 1, x + innerWidth + 3)
|
|
337
|
+
const y1 = Math.min(grid.length - 1, y + innerHeight + 1)
|
|
338
|
+
rect(grid, x + 1, y, x1 - 1, y, 'n')
|
|
339
|
+
rect(grid, x + 1, y1, x1 - 1, y1, 'n')
|
|
340
|
+
rect(grid, x, y + 1, x, y1 - 1, 'n')
|
|
341
|
+
rect(grid, x1, y + 1, x1, y1 - 1, 'n')
|
|
342
|
+
rect(grid, x + 1, y + 1, x1 - 1, y1 - 1, 'N')
|
|
343
|
+
const tailX = x + 4
|
|
344
|
+
for (let step = 0; step < 3; step += 1) {
|
|
345
|
+
const row = y1 + 1 + step
|
|
346
|
+
if (row >= grid.length) break
|
|
347
|
+
grid[row][Math.max(0, tailX - step)] = 'n'
|
|
348
|
+
grid[row][Math.max(0, tailX - step + 1)] = 'N'
|
|
349
|
+
}
|
|
350
|
+
// Text must stop one cell short of the right border, or the last glyph
|
|
351
|
+
// overwrites it.
|
|
352
|
+
return { x: x + 2, y: y + 1, width: Math.max(1, x1 - x - 4), height: innerHeight }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A cartoon `z`, rising, for the sleeping pose. */
|
|
356
|
+
export function drawSleepZ(grid, x, y, size) {
|
|
357
|
+
rect(grid, x, y, x + size, y, 'z')
|
|
358
|
+
rect(grid, x, y + size, x + size, y + size, 'z')
|
|
359
|
+
for (let step = 0; step <= size; step += 1) {
|
|
360
|
+
const cx = x + size - step
|
|
361
|
+
const cy = y + step
|
|
362
|
+
if (cy < grid.length && cx >= 0 && cx < grid[0].length) grid[cy][cx] = 'z'
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Ringing arcs: two short dashes either side of the telephone, growing and
|
|
368
|
+
* fading, which is what makes a ringing telephone read as ringing rather than
|
|
369
|
+
* as a telephone that happens to be shaking.
|
|
370
|
+
*
|
|
371
|
+
* @param {{ x: number, y: number, size: number, frame: number }} at
|
|
372
|
+
*/
|
|
373
|
+
export function drawRingArcs(grid, x, y, size, frame) {
|
|
374
|
+
const s = Math.max(1, size)
|
|
375
|
+
const phase = ((Math.trunc(frame) % 8) + 8) % 8
|
|
376
|
+
const reach = 2 + Math.floor(phase / 2)
|
|
377
|
+
for (const direction of [-1, 1]) {
|
|
378
|
+
for (let index = 0; index < 3; index += 1) {
|
|
379
|
+
const cx = x + direction * (reach + index) * 2 * s
|
|
380
|
+
const cy = y - index * s
|
|
381
|
+
if (cy < 0 || cy >= grid.length || cx < 0 || cx >= grid[0].length) continue
|
|
382
|
+
rect(grid, cx, cy, cx, cy + s, 'a')
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|