Clawd in a pink tutu, dancing ballet on a little stage above the prompt while Claude works

A mod for Claude Code CLI that shows Clawd doing ballet when a model is working. In live mode each performance is dynamic & unique, Clawd does moves in response to what Claude is doing, responding to each tool call and action. There is also a programme of ballets it can perform instead.
Built on claude-toons by Anshu Chimala, using toons' renderer, scene language and pixel-art Clawd.

Live mode (/ballet live). Each tool call fires a quick move of its own, labelled at the top left: grep a twirl, a file read up on pointe, git a ribbon run in by a stagehand, a failed test an "oops". Between calls Clawd holds a pose for the moment: tendus while Claude thinks, a slow port de bras while it writes the reply, balances while tests run.

Subagents bring on a corps de ballet in white, as many as the strip fits. They follow Clawd's lead a beat behind, and each dancer also dances a move of its own for every tool call its agent makes.
In standard mode a piece plays each turn, the acts of a ballet in order, dissolving into the next if Claude works long enough.



| Piece | Acts | Year | Choreography |
|---|---|---|---|
| Gala | original | ||
| Swan Lake | I to IV | 1895 | Marius Petipa, Lev Ivanov |
| The Nutcracker | I and II | 1892 | Lev Ivanov |
| The Firebird | one act | 1910 | Michel Fokine |
| Mayerling | I to III | 1978 | Kenneth MacMillan |
| Chroma | one act | 2006 | Wayne McGregor |
| Infra | one act | 2008 | Wayne McGregor |
| Untitled, 2023 | one act | 2023 | Wayne McGregor |
| Don Quixote | III, the grand pas de deux | 1869 | Marius Petipa |
| Giselle | II | 1841 | Jean Coralli, Jules Perrot |
| La Fille mal gardée | the farmyard | 1960 | Frederick Ashton |
| Manon | I | 1974 | Kenneth MacMillan |
| Class | barre and centre | original |
/ballet shows or hides Clawd, even mid-task (on and off too)./ballet live and /ballet standard switch mode./ballet <piece> picks what plays next: swan, mayerling 3, swan lake act ii. Typos and starts of names are fine, and Tab completes./ballet programme opens the programme, a row a letter: n lists every piece to pick from, m switches mode, h shows or hides Clawd, and o, s and l change the settings. Escape closes it.The settings are also in /config, under "Ballet":
| Setting | What it does |
|---|---|
| Order | "in turn" follows the programme; "shuffled" picks the next ballet at random, its acts still in order. |
| Speech bubbles | Off, every piece is danced in silence. |
| Live label | Off, live mode shows no label naming what set off each move. |
Everything is remembered across sessions.
We both know you're not installing this manually, give the following information to your agent.
It's a mod, installed as a plugin. You need Claude Code v2.1.287 or later (claude --version). In Claude Code, run:
/plugin marketplace add Minithena/Claude-Code-Ballet
/plugin install clawd-ballet@clawd-ballet
Clawd dances from your next turn on. If you also use claude-toons, both draw while Claude works, so you'll likely want only one.
Or from a clone, for one session:
git clone https://github.com/Minithena/Claude-Code-Ballet
claude --plugin-dir Claude-Code-Ballet
What it can reach. A mod runs inside Claude Code with your permissions, so check one before you install it: claude plugin validate ./Claude-Code-Ballet on a clone lists every event it hooks and every call it makes. This one reads the name of each tool Claude calls (and a shell command's first word, a file's name, a web address's host) to pick a move; it keeps whether Clawd is shown, the mode and the next piece in Claude Code's plugin store on your machine. It makes no network requests and calls no model.
Nothing shows up? Run /plugin: a dim line under the tabs names the mods loaded, such as 1 mod active · clawd-ballet. The stage is drawn in a terminal, with 24-bit colour; the Desktop app and IDE panels load the mod but don't show the stage.
node --experimental-transform-types scripts/play.ts --piece 4 # a piece (0-18) in the terminal; Ctrl-C stops
node --experimental-transform-types scripts/play.ts --live # live mode, through a made-up session
The GIFs (needs Pillow):
node --experimental-transform-types scripts/frames.ts --live --story --seconds 26 --fps 15 | python3 scripts/gif.py docs/live.gif
node --experimental-transform-types scripts/frames.ts --live --agents --seconds 20 --fps 15 --cols 110 | python3 scripts/gif.py docs/subagents.gif
node --experimental-transform-types scripts/frames.ts --piece 4 --fps 15 | python3 scripts/gif.py docs/swan-lake.gif
node --experimental-transform-types scripts/frames.ts --piece 14 --fps 15 | python3 scripts/gif.py docs/don-quixote.gif
node --experimental-transform-types scripts/frames.ts --piece 13 --fps 15 | python3 scripts/gif.py docs/untitled-2023.gif
MIT (see LICENSE). hooks/script.ts, lang.ts, effects.ts, clawd.ts and render3d.ts are from claude-toons, MIT, Copyright (c) 2026 Anshu Chimala; see LICENSE-claude-toons.
An independent project, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 638 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { PIECES } from './ballet'
4import { solidify } from './cells'
5import { choose, completions, following, isProgramme, MODES, modeOf, PROGRAMME, summary, title } from './choose'
6import { actOf, LIVE_SCENE, mcpOf, wire, type Act, type Moment, type Move, type News } from './live'
7import { cleanScript, stage, type Script } from './script'
8import type { BalletLoad } from '../types'
9
10const COMMAND = 'ballet'
11// The Raster's key, which each blitted frame names.
12const KEY = 'ballet'
13const ROWS = 9
14// How long the band takes to rise to its full height when the spinner shows,
15// as in claude-toons.
16const GROW_MS = 700
17// This load's mark on the session (types/index.d.ts). A hot reload starts
18// the module afresh, and the old one's animation loop can outlive it: both
19// would paint the band, a frame each, two stages flickering in turn.
20const LOAD = { plugin: 'clawd-ballet', key: 'load' } as const
21
22// The pieces in turn (standard), or the live dancer, whose steps follow
23// what Claude does (hooks/live.ts).
24type Mode = 'standard' | 'live'
25
26// The settings in /config (plugin.json's userConfig), also in the
27// programme: the order the ballets play in, speech bubbles, and the live
28// dancer's label. Changing one reloads the plugin with it.
29type Settings = { isShuffled: boolean; isSpoken: boolean; isLabelled: boolean }
30const settingsOf = (o: Record<string, unknown>): Settings => ({ isShuffled: o.order === 'shuffled', isSpoken: o.speech !== 'off', isLabelled: o.label !== 'off' })
31// Code added after a scene's own, whose definitions replace its: a `say`
32// that says nothing, a label chip that isn't drawn.
33const QUIET = '\nfunction say() {}'
34const UNLABELLED = '\nfunction tag() {}'
35// The programme's pane, opened by `/ballet programme`; its list of pieces,
36// a letter each (a hotkey is one letter or digit) in columns this wide.
37const PANE = 'ballet-programme'
38const PICK_KEYS = 'abcdefghijklmnopqrstuvwxyz'
39const PICK_WIDTH = 24
40
41// What Claude is doing, for the live dancer, as the hooks below see it.
42type Watch = {
43 act: Act
44 // The shell command's own steps, if it has some.
45 // The last tool call's move and the label shown over Clawd for it.
46 move: Move | ''
47 label: string
48 news: News | ''
49 newsN: number
50 // Main-loop tool calls so far: each one is a cue for the dancer.
51 toolN: number
52 // Tools running on the main loop, and the Agent calls among them.
53 tools: number
54 agentCalls: number
55 // When each subagent last called a tool: a background agent's call
56 // returns at once, so its own tools show it still at work.
57 agentsSeen: Map<string, number>
58 // The MCP server partnering Clawd, until a few moments after the last of
59 // the MCP calls running (they can overlap) is done.
60 guest: string
61 guestUntil: number
62 mcpCalls: number
63 // The subagents' tool calls: how many, whose was the last (by the order
64 // the subagents started) and its move, for that dancer of the corps.
65 crewN: number
66 crewWho: number
67 crewMove: Move | ''
68}
69
70type Stage = {
71 // The mark this load wrote on the session, once session.start has run.
72 load?: BalletLoad
73 isShown: boolean
74 isAnimating: boolean
75 mode: Mode
76 settings: Settings
77 // The band being drawn on while Claude works (its request), when the turn's
78 // dancing began (the band grows in there), the strip's size and the height
79 // drawn so far.
80 spinner?: string
81 shownAt: number
82 cols: number
83 drawnRows: number
84 // The piece playing, a fresh copy of its scene and when it began, and the
85 // one dissolving out (toons' renderer crossfades them cell by cell).
86 piece: number
87 scene?: Script
88 sceneAt: number
89 previous?: Script
90 previousAt: number
91 // The piece the next turn starts with.
92 next: number
93 // The live dancer's scene, kept from turn to turn so Clawd dances on, and
94 // when it began; what it is told; and the time of the frame being drawn.
95 live?: Script
96 liveAt: number
97 watch: Watch
98 now: number
99 // What was showing when the band last went (the scene, its clock and the
100 // band's), so a blink of it (a subagent's message landing) picks up where
101 // it was rather than growing in again.
102 paused?: { scene: Script; sceneAt: number; shownAt: number; previous?: Script; previousAt: number; mode: Mode; at: number }
103 // The hint row shown while the box holds `/ballet ...`, and what Tab is
104 // stepping through: the matches and the one it last put in.
105 hint?: string
106 tab?: { list: string[]; at: number }
107 // The programme showing its list of pieces rather than its rows.
108 isPicking?: boolean
109}
110
111// A piece shown only a moment (a quick turn, or the instant the band counts
112// as working while /ballet runs) plays again next time instead of being
113// skipped.
114const SEEN_MS = 3000
115// How long the partner stays on after an MCP call, and how long a subagent
116// counts as at work after its last tool call.
117const GUEST_MS = 2500
118// How long the band may be gone mid-turn (a subagent's message landing
119// redraws the screen) and still pick up where it was.
120const BLINK_MS = 4000
121const AGENT_MS = 20_000
122
123// When a turn's dancing ends: the next turn picks up after the piece that
124// was playing, if it played long enough to be seen. The live dancer just
125// pauses.
126function finish($: EngineInterface, s: Stage, at: number) {
127 if (!s.scene) return
128 s.paused = { scene: s.scene, sceneAt: s.sceneAt, shownAt: s.shownAt, previous: s.previous, previousAt: s.previousAt, mode: s.mode, at }
129 if (s.mode === 'live') {
130 s.scene = undefined
131
132 return
133 }
134 s.next = at - s.sceneAt > SEEN_MS ? following(NAMES, s.piece, s.settings.isShuffled) : s.piece
135 s.scene = undefined
136 void $.store.set('next', s.next).catch(() => {})
137}
138
139const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
140
141const rowsAt = (s: Stage, at: number) => {
142 const grown = clamp01((at - s.shownAt) / GROW_MS)
143
144 return Math.max(1, Math.ceil(ROWS * (1 - Math.pow(1 - grown, 3))))
145}
146
147// Starts a piece: a fresh scene, so its clock and its speech start over.
148function begin(s: Stage, index: number, at: number) {
149 s.piece = ((index % PIECES.length) + PIECES.length) % PIECES.length
150 const scene = PIECES[s.piece]!.scene
151 s.scene = cleanScript(s.settings.isSpoken ? scene : { ...scene, code: scene.code + QUIET })
152 s.sceneAt = at
153}
154
155// What the live dancer is told at a moment.
156function momentOf(s: Stage): Moment {
157 const w = s.watch
158 let recent = 0
159 for (const [id, at] of w.agentsSeen) {
160 if (s.now - at < AGENT_MS) recent++
161 else w.agentsSeen.delete(id)
162 }
163
164 return { act: w.act, move: w.move, label: w.label, toolN: w.toolN, busy: w.tools > 0, news: w.news, newsN: w.newsN, corps: Math.min(4, Math.max(w.agentCalls, recent)), guest: s.now < w.guestUntil ? w.guest : '', crewN: w.crewN, crewWho: w.crewWho, crewMove: w.crewMove }
165}
166
167// Starts the live dancer's turn: the scene it danced last turn, or a new
168// one the first time (or if its code broke).
169function beginLive(s: Stage, at: number) {
170 if (!s.live || s.live.code?.error) {
171 const script = cleanScript(LIVE_SCENE)
172 if (!script) return
173 s.live = wire(script, () => momentOf(s), (s.settings.isSpoken ? '' : QUIET) + (s.settings.isLabelled ? '' : UNLABELLED))
174 s.liveAt = at
175 }
176 s.scene = s.live
177 s.sceneAt = s.liveAt
178}
179
180function frameAt(s: Stage, at: number, rows: number) {
181 s.now = at
182 // A piece plays through once, then dissolves into the next.
183 if (s.mode === 'standard' && s.scene && at - s.sceneAt > PIECES[s.piece]!.routine * 1000) {
184 s.previous = s.scene
185 s.previousAt = s.sceneAt
186 begin(s, following(NAMES, s.piece, s.settings.isShuffled), at)
187 }
188 if (s.mode === 'live' && s.scene?.code?.error) beginLive(s, at)
189 if (!s.scene) return undefined
190
191 return solidify(
192 stage({
193 cols: s.cols,
194 rows,
195 t: (at - s.shownAt) / 1000,
196 script: s.scene,
197 previous: s.previous,
198 since: at - s.sceneAt,
199 previousSince: at - s.previousAt,
200 reveal: clamp01((at - s.shownAt) / (GROW_MS * 1.6)),
201 }),
202 )
203}
204
205// Paints the dance at about 20 frames a second while the spinner shows, each
206// frame once the last one landed.
207function animate($: EngineInterface, s: Stage) {
208 if (s.isAnimating) return
209 s.isAnimating = true
210 void (async () => {
211 while (s.spinner && s.isShown && s.scene) {
212 if (await isSuperseded($, s)) break
213 const at = await $.clock.now()
214 // While it grows, each new height is a redraw; frames keep painting at
215 // the height drawn until it lands.
216 if (rowsAt(s, at) !== s.drawnRows) $.ui.invalidate('ui.render')
217 const cells = frameAt(s, at, s.drawnRows)
218 if (cells) await $.ui.blit({ requestId: s.spinner, key: KEY, cells, columns: s.cols, rows: s.drawnRows }).catch(() => {})
219 await $.clock.sleep(50)
220 }
221 s.isAnimating = false
222 })().catch(() => {
223 // The module unloaded mid-wait (a reload): the next load starts afresh.
224 s.isAnimating = false
225 })
226}
227
228// Whether a later load of the plugin has taken the session over: its mark
229// stands in place of this load's.
230async function isSuperseded($: EngineInterface, s: Stage) {
231 if (!s.load) return false
232 const { value } = await $.state.get(LOAD).catch(() => ({ value: undefined }))
233
234 return value !== undefined && value !== s.load
235}
236
237async function toggle($: EngineInterface, s: Stage, show: boolean) {
238 s.isShown = show
239 await $.store.set('isShown', show).catch(() => {})
240 $.ui.invalidate('ui.render')
241}
242
243// Switches mode; the next frame starts the new one's dancing.
244async function switchTo($: EngineInterface, s: Stage, mode: Mode) {
245 s.mode = mode
246 s.scene = undefined
247 s.paused = undefined
248 s.previous = undefined
249 await $.store.set('mode', mode).catch(() => {})
250 if (!s.isShown) await toggle($, s, true)
251 else $.ui.invalidate('ui.render')
252}
253
254// The programme's list of pieces: shown (n), then closed by a pick, which
255// sets what plays next without changing the mode, or by 0.
256async function picking($: EngineInterface, s: Stage, isPicking: boolean, index?: number) {
257 s.isPicking = isPicking
258 if (index !== undefined) {
259 s.next = index
260 await $.store.set('next', s.next).catch(() => {})
261 }
262 $.ui.invalidate('ui.render')
263}
264
265// The piece the next turn starts with, picked by name.
266async function pick($: EngineInterface, s: Stage, index: number) {
267 s.next = index
268 await $.store.set('next', s.next).catch(() => {})
269 await switchTo($, s, 'standard')
270}
271
272// Changes one of the settings as /config would; the engine then reloads
273// the plugin with it. The row is found by its label, which stays put while
274// its key carries the plugin's name (`<name>@inline` loaded from a folder).
275async function setOption($: EngineInterface, field: string, value: string) {
276 try {
277 const rows = await $.config.list()
278 const row = rows.find(r => r.key.endsWith(`.${field}`) && r.label.startsWith('Ballet:'))
279 if (!row) throw new Error('no such setting; restart Claude Code to load it')
280 const { deny } = await $.config.set({ key: row.key, value })
281 if (deny) throw new Error(deny)
282 } catch (e) {
283 $.ui.toast(`clawd-ballet: could not change ${field}: ${e instanceof Error ? e.message : String(e)}`)
284 }
285}
286
287// What the live dancer is told: the work, and news.
288function tell(w: Watch, act: Act) {
289 w.act = act
290}
291
292function announce(w: Watch, news: News) {
293 w.news = news
294 w.newsN++
295}
296
297// `/ballet <words>` picks a piece by its name, loosely (hooks/choose.ts): a
298// ballet alone starts at its first act, and its acts play on in order.
299const NAMES = PIECES.map(p => p.name)
300
301// The box holding `/ballet <words>`, the words in group 1.
302const DRAFT = /^\/ballet (.*)$/s
303
304// What the hint row above the prompt says for a draft of `/ballet <words>`.
305function hintFor(arg: string): string {
306 if (!arg.trim()) return `${summary(NAMES)}, live, standard, programme, on, off`
307 const fits = completions(NAMES, arg)
308
309 return fits.length ? `${fits.join(' · ')} (tab completes)` : 'no piece by that name'
310}
311
312// Redraws the hint row when the box's text changes what it says.
313function showHint($: EngineInterface, s: Stage, text: string) {
314 const typed = DRAFT.exec(text)
315 const hint = typed ? hintFor(typed[1]!) : text === '/ballet' ? hintFor('') : undefined
316 if (hint === s.hint) return
317 s.hint = hint
318 $.ui.invalidate('ui.render')
319}
320
321export const register: Register = (on, options) => {
322 const s: Stage = {
323 isShown: true,
324 isAnimating: false,
325 mode: 'standard',
326 settings: settingsOf(options as Record<string, unknown>),
327 shownAt: 0,
328 cols: 60,
329 drawnRows: 1,
330 piece: -1,
331 sceneAt: 0,
332 previousAt: 0,
333 next: 0,
334 liveAt: 0,
335 watch: { act: 'idle', move: '', label: '', news: '', newsN: 0, toolN: 0, tools: 0, agentCalls: 0, agentsSeen: new Map(), guest: '', guestUntil: 0, mcpCalls: 0, crewN: 0, crewWho: 0, crewMove: '' },
336 now: 0,
337 }
338
339 // session.start fires again for each reload: the new load marks the
340 // session, and the loop of the load before stops.
341 on('session.start', async ($, e, next_) => {
342 const load = `${await $.clock.now().catch(() => 0)}-${Math.random()}`
343 await $.state.set(LOAD, load).then(
344 () => (s.load = load),
345 () => {},
346 )
347 s.isShown = (await $.store.get('isShown')) !== false
348 const saved = await $.store.get('next').catch(() => undefined)
349 if (typeof saved === 'number') s.next = saved
350 if ((await $.store.get('mode').catch(() => undefined)) === 'live') s.mode = 'live'
351 await $.command.register({
352 name: COMMAND,
353 description: `Show or hide Clawd dancing ballet above the prompt, pick the piece (${summary(NAMES)}), dance live to what Claude does, or open the programme`,
354 argumentHint: '[on|off|live|standard|programme|<ballet> [act]]',
355 immediate: true,
356 })
357
358 return next_(e)
359 })
360
361 on('command.run', { command: COMMAND }, async ($, e) => {
362 showHint($, s, '')
363 s.tab = undefined
364 const arg = e.args.trim().toLowerCase()
365 if (!arg || arg === 'on' || arg === 'off') {
366 await toggle($, s, arg === 'on' ? true : arg === 'off' ? false : !s.isShown)
367
368 return { text: s.isShown ? `Ballet on: Clawd dances ${s.mode === 'live' ? 'live, to what Claude does' : 'the ballets in turn'} while Claude works.` : 'Ballet off. /ballet brings Clawd back.' }
369 }
370 // A piece by its name first; else words near the programme open it, and
371 // words near a mode switch to it, typos and all ("progamme", "standrd").
372 const picked = choose(NAMES, arg)
373 if (PROGRAMME.includes(arg) || ('error' in picked && isProgramme(arg))) {
374 s.isPicking = false
375 await $.ui.open({ id: PANE, title: 'Ballet programme', focus: true, closeOnEscape: true, rows: 12 })
376
377 return { text: 'The programme is open. Escape closes it.' }
378 }
379 const mode = MODES.find(m => m === arg) ?? (arg === 'repertoire' || 'error' in picked ? modeOf(arg) : undefined)
380 if (mode === 'live') {
381 await switchTo($, s, 'live')
382
383 return { text: 'Live: Clawd dances on while Claude works, its steps following what Claude does. /ballet standard for the ballets in turn.' }
384 }
385 if (mode === 'standard') {
386 const saved = await $.store.get('next').catch(() => undefined)
387 if (typeof saved === 'number') s.next = saved
388 await switchTo($, s, 'standard')
389
390 return { text: `Standard: the ballets in turn. Next up, ${title(PIECES[s.next]!.name)}.` }
391 }
392 // Words that name no piece say so, and leave the ballet as it was.
393 if ('error' in picked) return { text: picked.error }
394 await pick($, s, picked.index)
395
396 return { text: `Next up: ${title(PIECES[picked.index]!.name)}.` }
397 })
398
399 // Tab (or the right arrow at the end) completes `/ballet <words>` to the
400 // piece they start; Tab again steps to the next match. Every edit updates
401 // the hint row.
402 on('prompt.edit', async ($, e, next_) => {
403 const k = e.key
404 const isTab = !!k && !k.ctrl && !k.meta && !k.shift && (k.key === 'tab' || k.key === 'right')
405 const typed = DRAFT.exec(e.text)
406 if (isTab && typed && e.cursor === e.text.length) {
407 const arg = typed[1]!.trim().toLowerCase()
408 if (!s.tab || arg !== s.tab.list[s.tab.at]) {
409 const list = completions(NAMES, arg)
410 s.tab = { list, at: list.indexOf(arg) }
411 }
412 if (s.tab.list.length) {
413 s.tab.at = (s.tab.at + 1) % s.tab.list.length
414 const text = `/ballet ${s.tab.list[s.tab.at]}`
415 showHint($, s, text)
416
417 return { text, cursor: text.length }
418 }
419 }
420 const box = await next_(e)
421 showHint($, s, box.text)
422
423 return box
424 })
425
426 // A new task: the live dancer begins it with a preparation. These two
427 // hooks stand in front of the prompt and every tool call, so the
428 // dancer's part in them gives up quietly: it never stops the work.
429 on('prompt.submit', async ($, e, next_) => {
430 try {
431 showHint($, s, '')
432 s.tab = undefined
433 tell(s.watch, 'thinking')
434 announce(s.watch, 'task')
435 } catch {}
436
437 return next_(e)
438 })
439
440 // Every tool call on the main loop tells the live dancer what Claude is
441 // doing, and how it went; a subagent's calls show it still at work. The
442 // call itself goes on untouched.
443 on('tool.call', async ($, e, next_) => {
444 const w = s.watch
445 let at = s.now
446 let act: Act | undefined
447 let isAgent = false
448 let mcp: ReturnType<typeof mcpOf>
449 try {
450 at = await $.clock.now().catch(() => s.now)
451 if (e.agentId) {
452 w.agentsSeen.set(e.agentId, at)
453 w.crewWho = [...w.agentsSeen.keys()].indexOf(e.agentId)
454 w.crewMove = actOf(e.tool, e as unknown as Record<string, unknown>).move
455 w.crewN++
456 } else {
457 const cue = actOf(e.tool, e as unknown as Record<string, unknown>)
458 const thisMcp = mcpOf(e.tool)
459 tell(w, cue.act)
460 w.move = cue.move
461 w.label = cue.label
462 w.toolN++
463 w.tools++
464 if (cue.act === 'agents') w.agentCalls++
465 if (thisMcp) {
466 w.guest = thisMcp.server
467 w.guestUntil = Infinity
468 w.mcpCalls++
469 }
470 // Set once the counters are up, so the finally below undoes
471 // exactly what was done.
472 act = cue.act
473 isAgent = act === 'agents'
474 mcp = thisMcp
475 }
476 } catch {}
477 // A subagent's call, or one the dancer couldn't read: nothing to undo.
478 if (!act) return next_(e)
479 try {
480 const result = await next_(e)
481 if (('deny' in result && result.deny) || result.isError) announce(w, 'fail')
482 else if (act === 'testing' || act === 'building') announce(w, 'pass')
483
484 return result
485 } finally {
486 w.tools--
487 if (isAgent) w.agentCalls--
488 if (mcp && --w.mcpCalls === 0) w.guestUntil = (await $.clock.now().catch(() => at)) + GUEST_MS
489 }
490 })
491
492 // The spinner says when Claude turns to thinking or to writing its reply;
493 // the engine draws it as ever.
494 on('ui.render', { component: 'Spinner' }, ($, e, next_) => {
495 const w = s.watch
496 if (w.tools === 0 && e.props.mode === 'thinking' && w.act !== 'thinking') tell(w, 'thinking')
497 if (w.tools === 0 && e.props.mode === 'responding' && w.act !== 'writing') tell(w, 'writing')
498
499 return next_(e)
500 })
501
502 on('turn.complete', async ($, e, next_) => {
503 s.spinner = undefined
504 tell(s.watch, 'idle')
505 finish($, s, await $.clock.now())
506 // A turn that ended is over: the next starts afresh.
507 s.paused = undefined
508 $.ui.invalidate('ui.render')
509
510 return next_(e)
511 })
512
513 // The programme: what plays next, the mode, and the settings, a row
514 // each, changed in place by its letter (or Tab to it and Enter): a Select
515 // trapped the arrow keys, which wrapped round its list. n shows the
516 // pieces instead, in columns, a letter each.
517 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
518 if (e.surface === 'mobile') {
519 const { Text } = $.ui.resolve(e)
520
521 return <Text>The ballet dances in the terminal; open the programme there.</Text>
522 }
523 const { Box, Text, Button } = $.ui.resolve(e)
524 if (s.isPicking) {
525 const columns = Math.max(1, Math.min(3, Math.floor((e.props.bodyColumns - 2) / PICK_WIDTH)))
526 const perColumn = Math.ceil(PIECES.length / columns)
527
528 return (
529 <Box flexDirection="column" paddingX={1}>
530 <Text bold>What plays next</Text>
531 <Box flexDirection="row" marginTop={1}>
532 {Array.from({ length: columns }, (_, c) => (
533 <Box key={`column-${c}`} flexDirection="column" width={PICK_WIDTH}>
534 {PIECES.slice(c * perColumn, (c + 1) * perColumn).map((p, k) => {
535 const i = c * perColumn + k
536
537 return (
538 <Button key={`piece-${i}`} hotkey={PICK_KEYS[i]} plain dimColor={i !== s.next} onPress={() => void picking($, s, false, i)}>
539 {i === s.next ? `${title(p.name)} <` : title(p.name)}
540 </Button>
541 )
542 })}
543 </Box>
544 ))}
545 </Box>
546 <Box marginTop={1}>
547 <Button key="unpick" hotkey="0" plain dimColor onPress={() => void picking($, s, false)}>
548 back
549 </Button>
550 </Box>
551 </Box>
552 )
553 }
554 const row = (label: string, keyed: string, hotkey: string, value: string, onPress: () => void) => (
555 <Box flexDirection="row">
556 <Box width={16}>
557 <Text dimColor>{label}</Text>
558 </Box>
559 <Button key={keyed} hotkey={hotkey} plain onPress={onPress}>
560 {value}
561 </Button>
562 </Box>
563 )
564 const settings = s.settings
565
566 return (
567 <Box flexDirection="column" paddingX={1}>
568 {row('Next up', 'next', 'n', `${title(PIECES[s.next]!.name)} (all the pieces)`, () => void picking($, s, true))}
569 {row('Mode', 'mode', 'm', s.mode === 'live' ? 'live, dancing to what Claude does' : 'standard, the ballets in turn', () => void switchTo($, s, s.mode === 'live' ? 'standard' : 'live'))}
570 {row('Clawd', 'shown', 'h', s.isShown ? 'shown' : 'hidden', () => void toggle($, s, !s.isShown))}
571 {row('Order', 'order', 'o', settings.isShuffled ? 'shuffled, acts still in order' : 'in turn', () => void setOption($, 'order', settings.isShuffled ? 'in turn' : 'shuffled'))}
572 {row('Speech bubbles', 'speech', 's', settings.isSpoken ? 'on' : 'off', () => void setOption($, 'speech', settings.isSpoken ? 'off' : 'on'))}
573 {row('Live label', 'label', 'l', settings.isLabelled ? 'on' : 'off', () => void setOption($, 'label', settings.isLabelled ? 'off' : 'on'))}
574 <Box marginTop={1}>
575 <Text dimColor>Press a letter to change its row (or Tab to it and Enter); Escape closes. The settings are also in /config.</Text>
576 </Box>
577 </Box>
578 )
579 })
580
581 // The stage sits in the band directly above the prompt, flush against it,
582 // while Claude works; the spinner line above stays the engine's.
583 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next_) => {
584 if (e.surface !== 'terminal' || !s.isShown || !e.props.isWorking || e.props.hasSurvey) {
585 s.spinner = undefined
586 if (s.scene) finish($, s, await $.clock.now())
587 // Idle with `/ballet ...` in the box: what it can complete to.
588 if (s.hint && e.surface === 'terminal' && !e.props.hasSurvey) {
589 const { Text } = $.ui.resolve(e)
590
591 return (
592 <Text key="ballet-hint" dimColor wrap="truncate-end">
593 {`/ballet ${s.hint}`}
594 </Text>
595 )
596 }
597
598 return next_(e)
599 }
600 const { Raster } = $.ui.resolve(e)
601 const now = await $.clock.now()
602 // A new turn grows the band in and starts the next piece from its top,
603 // or the live dancer where it left off.
604 if (!s.scene) {
605 // The store has the last word: a reload starts this module afresh.
606 s.mode = (await $.store.get('mode').catch(() => undefined)) === 'live' ? 'live' : 'standard'
607 const paused = s.paused
608 s.paused = undefined
609 s.shownAt = now
610 s.previous = undefined
611 if (paused && paused.mode === s.mode && now - paused.at < BLINK_MS) {
612 // Back after a blink, mid-turn: as it was.
613 s.scene = paused.scene
614 s.sceneAt = paused.sceneAt
615 s.shownAt = paused.shownAt
616 s.previous = paused.previous
617 s.previousAt = paused.previousAt
618 s.now = now
619 } else if (s.mode === 'live') {
620 s.now = now
621 beginLive(s, now)
622 } else {
623 const saved = await $.store.get('next').catch(() => undefined)
624 if (typeof saved === 'number') s.next = saved
625 begin(s, s.next, now)
626 }
627 }
628 s.spinner = e.requestId
629 s.cols = Math.max(20, Math.min(220, e.props.bodyColumns))
630 s.drawnRows = Math.min(rowsAt(s, now), Math.max(1, e.props.maxRows))
631 const cells = frameAt(s, now, s.drawnRows)
632 if (!cells) return next_(e)
633 animate($, s)
634
635 return <Raster key={KEY} columns={s.cols} rows={s.drawnRows} cells={cells} />
636 })
637}
638hooks/ballet.ts 2505 lines1// The ballets: claude-toons scenes, each the dancer (hooks/dance.ts) and a
2// choreography of eased keyframes on a set of its own. toons' renderer
3// (hooks/script.ts) plays them as it plays its stock scenes: the band grows
4// in, speech bubbles find a clear spot and type out, and one piece
5// dissolves into the next. Speech is plain ASCII, as toons' bubbles are.
6
7import { DANCER } from './dance'
8
9// Shared by the pieces after the dancer: keyframes added in order, speech
10// by time (said from beside Clawd, clear of its arms and tutu), a blink now and then, a bourrée's quick steps, the boards, and
11// sparkles that keep off Clawd and the floor. Arms are named for each side
12// ([left, right]): down, out, high, or up (the sticker's raised stub).
13export const COMMON = String.raw`
14const BAS = ['down', 'down'], SECOND = ['out', 'out'], FIFTH = ['high', 'high'];
15// The ballet sticker's arms: the front one raised, the back one out.
16const RAISED = { right: ['out', 'up'], left: ['up', 'out'], front: ['out', 'up'], back: ['out', 'up'] };
17const KEYS = [];
18const key = (t, p) => KEYS.push([t, p]);
19const LINES = [];
20const line = (t0, t1, lines) => LINES.push([t0, t1, lines]);
21function saying(u, n) {
22 for (const l of LINES) if (u >= l[0] && u < l[1]) return pick(l[2], n);
23 return '';
24}
25// Speech beside Clawd: toons centres a bubble near the point it is given
26// (and may drift it a few columns toward the speaker, as it doesn't count
27// a two-cell arm as something to keep off), so the point is set out by half
28// the bubble's width beyond Clawd's widest reach (its tutu and raised arms,
29// 10 columns from its middle) and a margin for that drift, on the side with
30// more room.
31function speak(text, d) {
32 const off = 15 + Math.ceil((text.length + 4) / 2);
33 say(text, d.x + (d.x < w / 2 ? off : -off), d.row + 1);
34}
35function blinking(p, t) {
36 if (p.eyes === 'open' && fract(t * 0.31) < 0.04) p.eyes = 'closed';
37 return p;
38}
39// Tiny quick steps on pointe: the feet fluttering in and out.
40function bourree(p, t) {
41 p.legs = Math.floor(t * 12) % 2 === 0 ? 'stand' : 'plie';
42 return p;
43}
44function boards(floor, cx, edge, board, light) {
45 for (let x = 0; x < w; x++) {
46 const pool = clamp(1 - Math.abs(x - cx) / 14);
47 const seam = x % 9 === 0;
48 put(x, floor, '▀', mix(seam ? '#684226' : edge, light, pool * 0.45), mix(seam ? '#684226' : board, light, pool * 0.25));
49 }
50}
51function sparkles(t, d, floor, color) {
52 const tick = Math.floor(t * 10);
53 for (let i = 0; i < 7; i++) {
54 if (rand(i * 7 + tick * 13) < 0.45) continue;
55 const a = rand(i * 11 + tick * 17) * Math.PI * 2;
56 const col = Math.round(d.x + Math.cos(a) * (13 + rand(i + tick) * 4));
57 const row = Math.round(d.row + 2 + Math.sin(a) * 3);
58 if (row < 0 || row >= floor || (col > d.x - 13 && col < d.x + 13 && row >= d.row - 2)) continue;
59 put(col, row, '✦✧·*'[i % 4], color);
60 }
61}
62// Grand jetés: from a plié, up into a stride at the top of the arc, and
63// down into a plié, n times across, facing the way they go.
64function leaps(t0, x0, x1, n) {
65 const view = x1 > x0 ? 'right' : 'left';
66 for (let i = 0; i < n; i++) {
67 const t = t0 + i * 0.75;
68 const a = x0 + ((x1 - x0) * i) / n;
69 const b = x0 + ((x1 - x0) * (i + 1)) / n;
70 key(t, P(a, view, BAS, 'plie'));
71 key(t + 0.36, P((a + b) / 2, view, RAISED[view], 'jete', { air: 4 }));
72 key(t + 0.7, P(b, view, SECOND, 'plie'));
73 }
74}
75// Pirouettes: from a plié, n turns in passé with the arms high, and back
76// into a plié, facing front. A landing keeps the spin it ends on: blending
77// back to 0 would turn Clawd backwards while it still spins.
78function pirouettes(t0, length, x, n) {
79 key(t0, P(x, 'front', SECOND, 'plie'));
80 key(t0 + 0.2, P(x, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
81 key(t0 + length - 0.3, P(x, 'front', FIFTH, 'passe', { spinning: true, spin: 360 * n }));
82 key(t0 + length, P(x, 'front', SECOND, 'plie', { spin: 360 * n }));
83}
84// A tour en l'air: up from a plié, round n times in the air, and down.
85function tour(t0, x, n) {
86 key(t0, P(x, 'front', SECOND, 'plie'));
87 key(t0 + 0.15, P(x, 'front', FIFTH, 'stand', { spinning: true, spin: 0 }));
88 key(t0 + 0.4, P(x, 'front', FIFTH, 'stand', { spinning: true, spin: 180 * n, air: 4 }));
89 key(t0 + 0.65, P(x, 'front', FIFTH, 'stand', { spinning: true, spin: 360 * n }));
90 key(t0 + 0.8, P(x, 'front', SECOND, 'plie', { spin: 360 * n }));
91}
92// Balancés: a waltz, rocking side to side, down on each first beat; n steps
93// of a given length, facing the way each one goes.
94function balances(t0, x, n, arms, step, more) {
95 for (let i = 0; i < n; i++) {
96 const side = i % 2 ? -1 : 1;
97 const view = side > 0 ? 'right' : 'left';
98 key(t0 + i * step, P(x + side * 0.02, view, arms, 'plie', more));
99 key(t0 + (i + 0.5) * step, P(x + side * 0.03, view, arms, 'stand', more));
100 }
101}
102// Entrechats: straight up, n times, the legs beating in the air.
103function entrechats(t0, x, n) {
104 for (let i = 0; i < n; i++) {
105 key(t0 + i * 0.6, P(x, 'front', BAS, 'plie'));
106 key(t0 + i * 0.6 + 0.3, P(x, 'front', BAS, 'stand', { air: 4 }));
107 }
108 key(t0 + n * 0.6, P(x, 'front', SECOND, 'plie'));
109}
110function beating(p, t) {
111 if (p.air > 1.5 && !p.spinning && p.legs !== 'jete') p.legs = Math.floor(t * 10) % 2 ? 'stand' : 'plie';
112 return p;
113}
114// A révérence: the back leg pointed behind, the arms lowered, the eyes
115// closed; held, and up again.
116function reverence(t0, x, view, length) {
117 key(t0, P(x, view, SECOND, 'derriere'));
118 key(t0 + 0.4, P(x, view, BAS, 'derriere', { eyes: 'closed' }));
119 key(t0 + length, P(x, view, BAS, 'derriere', { eyes: 'closed' }));
120}
121`
122
123// The gala: a stage with velvet curtains, the boards lit where Clawd
124// dances, and roses at the curtain call.
125const GALA = String.raw`
126const ROUTINE = 20;
127key(0, P(-0.3, 'right', SECOND, 'stand'));
128key(2.2, P(0.48, 'right', SECOND, 'stand'));
129key(2.6, P(0.5, 'front', BAS, 'stand'));
130key(3.2, P(0.5, 'front', BAS, 'plie'));
131key(3.7, P(0.5, 'front', SECOND, 'stand'));
132key(4.2, P(0.5, 'front', SECOND, 'plie'));
133key(4.8, P(0.5, 'front', FIFTH, 'stand'));
134pirouettes(5.3, 2.4, 0.5, 3);
135key(8.2, P(0.5, 'front', FIFTH, 'stand', { eyes: 'happy' }));
136key(9.4, P(0.5, 'front', FIFTH, 'stand', { eyes: 'happy' }));
137leaps(9.7, 0.5, 0.95, 3);
138key(12.2, P(0.95, 'left', SECOND, 'stand'));
139key(12.5, P(0.95, 'left', RAISED.left, 'derriere'));
140key(13.7, P(0.95, 'left', RAISED.left, 'derriere'));
141leaps(13.9, 0.95, 0.3, 3);
142key(16.3, P(0.3, 'front', SECOND, 'stand'));
143key(16.9, P(0.3, 'right', RAISED.right, 'derriere'));
144reverence(17.4, 0.3, 'right', 1.5);
145key(19.2, P(0.3, 'left', SECOND, 'stand'));
146key(20, P(-0.3, 'left', SECOND, 'stand'));
147line(2.6, 3.8, ['and 5, 6, 7, 8!', 'and... begin!', 'from the top!']);
148line(16.8, 18.6, ['merci, merci!', 'thank you, thank you!', 'roses! for me?']);
149
150// The curtains: drawn across the stage at open 0, tied back in the wings
151// at open 1, the hems leading as they part and close.
152function curtains(t, floor, wing, open) {
153 if (wing <= 0 && open >= 1) return;
154 const tie = Math.floor(floor * 0.62);
155 const half = Math.ceil(w / 2);
156 for (const side of [-1, 1]) {
157 for (let y = 0; y < floor; y++) {
158 const k = y < tie ? y / tie : 1 - (y - tie) / (floor - tie);
159 const tied = Math.max(2, wing - Math.round((wing - 2) * k * 0.6));
160 const reach = Math.round(lerp(half, tied, open * (1 - 0.15 * (1 - open) * (floor - y) / floor)));
161 for (let i = 0; i < reach; i++) {
162 const x = side < 0 ? i : w - 1 - i;
163 const isFold = (i + (Math.sin(t * 1.3 + y * 0.3) > 0.85 ? 1 : 0)) % 3 === 1;
164 if (y === tie && open > 0.95) put(x, y, '▬', '#deb252', '#961c2c');
165 else put(x, y, '█', isFold ? '#68101e' : '#961c2c');
166 }
167 }
168 }
169}
170function rose(k, x0, x1, floor) {
171 const x = lerp(x0, x1, k);
172 const py = lerp(-4, floor * 2 - 4, k) - Math.sin(Math.PI * k) * 6;
173 pixels(Math.round(x), Math.round(py), '.r.\nrrr\n.g.\ngg.', { r: '#e0304a', g: '#4a9a50' });
174}
175
176function frame(t, dt) {
177 const u = mod(t, ROUTINE);
178 const n = Math.floor(t / ROUTINE);
179 const floor = h - 1;
180 const wing = w >= 50 ? 6 : w >= 32 ? 3 : 0;
181 const L = wing + 12;
182 const R = w - wing - 12;
183 let p = blinking(track(KEYS, u), t);
184 if (u < 2.2 || u > 19.2) p = bourree(p, t);
185 const cx = Math.round(L + (R - L) * p.x);
186 boards(floor, cx, '#b07c4e', '#8c5c36', '#ffe8b4');
187 const d = dancer(p, cx, floor * 2 - 1, {});
188 for (let i = 0; i < 3; i++) {
189 const k = (u - 16.9 - i * 0.45) / 0.9;
190 if (k >= 0 && u < 19.2) rose(clamp(k), w - wing - 3, cx + 11 + i * 4, floor);
191 }
192 // Up as Clawd comes on, down after the bow.
193 curtains(t, floor, wing, ease(clamp(u / 1.6)) * (1 - ease(clamp((u - 19.2) / 0.8))));
194 if ((u > 5.6 && u < 7.4) || p.air > 3.5) sparkles(t, d, floor, '#ffd68c');
195 const said = saying(u, n);
196 if (said) speak(said, d);
197}
198`
199
200// Swan Lake's acts share Odette, the prince, the wingbeat, the lake and
201// von Rothbart as an owl.
202const SWAN_COMMON = String.raw`
203// Odette's headpiece: white feathers swept over the head.
204const ODETTE = { tutu: '#e6ecfa', frill: '#ffffff', crown: '..ww..\n.wwww.', crownColors: { w: '#ffffff' } };
205// Prince Siegfried: white tights, boots, a coronet.
206const PRINCE = { bare: true, breeches: '#e8e4f0', shoes: '#1a1a20', crown: '.y.y.\n.yyy.', crownColors: { y: '#ffd84a' } };
207// A wingbeat: the arms up, out, down, out.
208const BEAT = [FIFTH, SECOND, BAS, SECOND];
209// The lake at night, the moon (unless hasMoon is false) and reeds.
210function lake(t, floor, hasMoon) {
211 for (let x = 0; x < w; x++) {
212 const shine = Math.sin(x * 0.7 + t * 2) > 0.6;
213 put(x, floor, '▀', shine ? '#7fa8e0' : '#2a4a80', '#142850');
214 }
215 if (hasMoon !== false) {
216 pixels(w - 14, 1, '.mmm.\nmmmmm\nmmmmm\n.mmm.', { m: '#f0ecc8' });
217 // The moon on the water, shimmering.
218 for (let x = w - 15; x < w - 8; x++) {
219 if (Math.sin(x * 1.9 + t * 3) > -0.2) put(x, floor, '▀', mix('#c8c4a0', '#f0ecc8', fract(t + x * 0.3)), '#142850');
220 }
221 }
222 for (let x = 1; x < w; x += 23) {
223 put(x, floor - 1, '|', '#3a6a4a');
224 put(x + 2, floor - 1, '/', '#3a6a4a');
225 put(x + 1, floor - 2, '|', '#4a7a5a');
226 }
227}
228// Von Rothbart as an owl at a column and pixel row (even: whole rows),
229// wings up and down.
230function owlAt(t, x, py) {
231 const flap = Math.floor(t * 5) % 2;
232 pixels(x, py, flap ? 'w....w\nww..ww\n.wyyw.\n..ww..' : '......\n.wyyw.\nwwwwww\nw.ww.w', { w: '#6a5080', y: '#ffd040' });
233}
234// Lightning: the whole strip flashing white and fading, from t0.
235function lightning(u, t0) {
236 const k = (u - t0) / 0.3;
237 if (k >= 0 && k < 1) fill(0, 0, w, h, ' ', mix('#e8e8ff', '#101420', k), mix('#e8e8ff', '#101420', k));
238}
239`
240
241// Swan Lake, act II: night on the lake, a moon, reeds, and Clawd as the swan queen
242// in a white tutu, the arms beating like wings; the cygnets join on a wide
243// strip.
244const SWANS = String.raw`
245const ROUTINE = 22;
246const LOOK = ODETTE;
247key(0, P(1.3, 'left', SECOND, 'stand'));
248key(3, P(0.55, 'left', SECOND, 'stand'));
249key(3.4, P(0.5, 'left', RAISED.left, 'derriere'));
250key(5.2, P(0.5, 'left', RAISED.left, 'derriere'));
251pirouettes(5.5, 2.2, 0.5, 2);
252key(8, P(0.5, 'front', SECOND, 'stand'));
253key(14.2, P(0.5, 'front', SECOND, 'stand'));
254key(14.8, P(0.5, 'front', SECOND, 'plie'));
255key(16.4, P(0.5, 'front', BAS, 'plie', { eyes: 'closed' }));
256key(18.6, P(0.5, 'front', BAS, 'plie', { eyes: 'closed' }));
257key(19.4, P(0.5, 'front', FIFTH, 'stand', { eyes: 'happy' }));
258key(19.8, P(0.5, 'left', SECOND, 'stand'));
259key(22, P(-0.3, 'left', SECOND, 'stand'));
260line(0.5, 2.4, ['by the lake, at midnight...', 'a swan glides in...', 'swan lake, act two.']);
261line(19.2, 21, ['...just kidding!', 'i feel better now.', 'encore!']);
262
263// Von Rothbart, the sorcerer as an owl, flying across the moon as the
264// swan dies.
265function owl(t, u) {
266 const k = (u - 14.4) / 4.4;
267 if (k < 0 || k > 1) return;
268 owlAt(t, Math.round(lerp(w + 4, -8, k)), Math.round(Math.sin(k * 7)) * 2);
269}
270
271function frame(t, dt) {
272 const u = mod(t, ROUTINE);
273 const n = Math.floor(t / ROUTINE);
274 const floor = h - 1;
275 lake(t, floor);
276 owl(t, u);
277 let p = blinking(track(KEYS, u), t);
278 // Wingbeats while gliding in and out, slower as the swan sinks.
279 if (u < 3 || u > 19.8) p.arms = BEAT[Math.floor(t * 5) % 4];
280 if (u > 14.2 && u < 16.4) p.arms = BEAT[Math.floor(t * 2.5) % 4];
281 if (u < 3 || u > 19.8) p = bourree(p, t);
282 // The little swans: arms linked, stepping side to side in unison and
283 // turning their heads together, where the strip has room for them.
284 if (u > 8.2 && u < 14.2 && w >= 72) {
285 const count = w >= 110 ? 4 : w >= 90 ? 3 : 2;
286 const k = u - 8.2;
287 const step = Math.floor(k * 3);
288 const view = step % 8 < 2 ? 'right' : step % 8 < 4 ? 'front' : step % 8 < 6 ? 'left' : 'front';
289 const x0 = w / 2 - (count - 1) * 9.5 + Math.sin(k * 1.5) * 4;
290 for (let i = 0; i < count; i++) {
291 const q = P(0, view, SECOND, step % 2 ? 'stand' : 'plie', { air: step % 4 === 3 ? 2 : 0 });
292 dancer(blinking(q, t + i * 0.7), Math.round(x0 + i * 19), floor * 2 - 1, LOOK);
293 }
294 const said = saying(u, n);
295 if (said) say(said, Math.round(w / 2), 0);
296 return;
297 }
298 const cx = Math.round(12 + (w - 24) * p.x);
299 const d = dancer(p, cx, floor * 2 - 1, LOOK);
300 if (u > 5.7 && u < 7.4) sparkles(t, d, floor, '#d8e4ff');
301 const said = saying(u, n);
302 if (said) speak(said, d);
303}
304`
305
306// The Nutcracker: snow falling on a winter night, the tree lit up, and
307// Clawd as the Sugar Plum Fairy, all turns and sparkle.
308const SUGAR = String.raw`
309const ROUTINE = 20;
310const LOOK = { tutu: '#d87ab8', frill: '#ffc8e8', crown: '.y.y.\nyyyyy', crownColors: { y: '#ffd84a' } };
311key(0, P(-0.3, 'right', SECOND, 'stand'));
312key(1.6, P(0.2, 'right', SECOND, 'stand'));
313key(1.8, P(0.2, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
314key(4.2, P(0.7, 'front', FIFTH, 'passe', { spinning: true, spin: 1440 }));
315key(4.5, P(0.7, 'front', SECOND, 'plie', { spin: 1440 }));
316key(4.8, P(0.7, 'front', FIFTH, 'stand', { air: 4 }));
317key(5.1, P(0.7, 'front', BAS, 'plie'));
318key(5.4, P(0.7, 'front', FIFTH, 'stand', { air: 4 }));
319key(5.7, P(0.7, 'front', BAS, 'plie'));
320key(6, P(0.7, 'front', SECOND, 'stand', { air: 4 }));
321key(6.4, P(0.7, 'front', BAS, 'plie'));
322key(7, P(0.6, 'front', FIFTH, 'stand'));
323key(7.8, P(0.6, 'right', RAISED.right, 'derriere'));
324key(8.8, P(0.6, 'right', RAISED.right, 'derriere'));
325key(9.2, P(0.6, 'left', RAISED.left, 'derriere'));
326key(10.6, P(0.6, 'left', RAISED.left, 'derriere'));
327leaps(10.8, 0.6, 0.2, 2);
328key(12.6, P(0.2, 'front', SECOND, 'plie'));
329key(12.8, P(0.2, 'front', SECOND, 'passe', { spinning: true, spin: 0 }));
330key(15.6, P(0.2, 'front', SECOND, 'passe', { spinning: true, spin: 2160 }));
331key(16, P(0.2, 'right', RAISED.right, 'derriere', { eyes: 'happy', spin: 2160 }));
332key(17.6, P(0.2, 'right', RAISED.right, 'derriere', { eyes: 'happy' }));
333key(18.2, P(0.2, 'front', BAS, 'plie', { eyes: 'closed' }));
334key(19.2, P(0.2, 'left', SECOND, 'stand'));
335key(20, P(-0.3, 'left', SECOND, 'stand'));
336line(0.4, 1.6, ['sugar plum, coming through!', 'tinkle tinkle!', 'a winter night...']);
337line(16.1, 17.8, ['the sugar plum fairy!', 'ta-da!', 'merry everything!']);
338
339const LIGHTS = ['#ff5a5a', '#ffd84a', '#5ad0ff', '#9aff6a'];
340function tree(t, floor) {
341 const x0 = w - 12;
342 for (let y = 0; y < floor - 1; y++) {
343 const half = Math.min(5, Math.floor(y * 0.8) + 1);
344 for (let x = -half; x <= half; x++) {
345 const isLight = (x * 7 + y * 3) % 5 === 0;
346 put(x0 + x, y, isLight ? '•' : '▲', isLight ? LIGHTS[mod(x + y + Math.floor(t * 3), 4)] : '#2e7a40');
347 }
348 }
349 put(x0, 0, '★', '#ffd84a');
350 put(x0, floor - 1, '█', '#6a4428');
351}
352
353function frame(t, dt) {
354 const u = mod(t, ROUTINE);
355 const n = Math.floor(t / ROUTINE);
356 const floor = h - 1;
357 const R = w - (w >= 60 ? 30 : 12);
358 if (w >= 60) tree(t, floor);
359 let p = blinking(track(KEYS, u), t);
360 if (u < 1.6 || u > 19.2) p = bourree(p, t);
361 // Fouettés: the arms whip out to the side and back in each turn.
362 if (u > 12.8 && u < 15.6) p.arms = mod(p.spin, 360) < 180 ? FIFTH : SECOND;
363 const cx = Math.round(12 + (R - 12) * p.x);
364 boards(floor, cx, '#9a7a9a', '#6a4a6a', '#ffe0f4');
365 const d = dancer(p, cx, floor * 2 - 1, LOOK);
366 if ((u > 1.9 && u < 4.2) || (u > 12.9 && u < 15.6) || p.air > 2.5) sparkles(t, d, floor, '#ffc8f0');
367 const said = saying(u, n);
368 if (said) speak(said, d);
369}
370`
371
372// Class: the studio, a barre along the wall, and Clawd working through
373// the exercises with a teacher counting.
374const CLASS = String.raw`
375const ROUTINE = 20;
376const B = (legs, arms, more) => P(0.3, 'right', arms || ['out', 'out'], legs, more);
377key(0, B('stand', BAS));
378key(0.8, B('plie', BAS));
379key(1.4, B('stand', SECOND));
380key(2, B('plie', SECOND));
381key(2.6, B('stand', FIFTH));
382key(3.4, B('derriere', RAISED.right));
383key(3.9, B('stand'));
384key(4.4, B('derriere', RAISED.right));
385key(4.9, B('stand'));
386key(5.4, B('passe', FIFTH));
387key(5.9, B('stand'));
388key(6.4, B('passe', FIFTH));
389key(6.9, B('stand'));
390key(7.4, B('passe', FIFTH));
391key(9.4, B('passe', FIFTH));
392key(9.8, P(0.6, 'front', BAS, 'stand'));
393pirouettes(10.4, 1.6, 0.6, 2);
394key(12.4, P(0.6, 'front', SECOND, 'stand', { air: 4 }));
395key(12.8, P(0.6, 'front', BAS, 'plie'));
396key(13.2, P(0.6, 'front', SECOND, 'stand', { air: 4 }));
397key(13.6, P(0.6, 'front', BAS, 'plie'));
398key(14, P(0.6, 'front', FIFTH, 'stand', { air: 4 }));
399key(14.4, P(0.6, 'front', BAS, 'plie'));
400key(15.2, P(0.6, 'front', FIFTH, 'stand', { eyes: 'happy' }));
401key(16.8, P(0.6, 'front', FIFTH, 'stand', { eyes: 'happy' }));
402key(17.4, P(0.6, 'right', RAISED.right, 'derriere'));
403key(18.2, P(0.6, 'front', BAS, 'plie', { eyes: 'closed' }));
404key(19.2, P(0.3, 'right', BAS, 'stand'));
405key(20, P(0.3, 'right', BAS, 'stand'));
406line(0.2, 2.2, ['and plie, 2, 3, 4...', 'at the barre, please.', 'class, begin!']);
407line(15, 16.6, ['lovely!', 'beautiful line!', 'very nice, clawd.']);
408line(17.4, 18.8, ['merci, madame.', 'thank you!', 'same time tomorrow?']);
409
410// The piano, where the teacher plays and counts, a metronome on it ticking
411// and notes rising; where the strip has room.
412function piano(t, floor) {
413 const x0 = w - 12;
414 for (let i = 0; i < 9; i++) {
415 put(x0 + i, floor - 3, '█', '#4a2a1a');
416 put(x0 + i, floor - 2, '▀', i % 3 === 1 ? '#2a2420' : '#f0ece0', '#4a2a1a');
417 }
418 put(x0, floor - 1, '│', '#4a2a1a');
419 put(x0 + 8, floor - 1, '│', '#4a2a1a');
420 put(x0 + 6, floor - 4, '╱│╲│'[Math.floor(t * 4) % 4], '#e0c080');
421 for (let i = 0; i < 3; i++) {
422 const k = fract(t * 0.4 + i / 3);
423 put(x0 + 2 + Math.round(Math.sin(k * 6 + i) * 2), Math.round((floor - 4) * (1 - k)), '♪♫♪'[i], mix('#be96d2', '#2a2a40', k));
424 }
425}
426function studio(floor) {
427 for (let y = 0; y < floor; y++) for (let x = 4; x < w - 4; x += 16) put(x, y, '│', '#3a3a50');
428 // In pixels, so Clawd in front of it covers it.
429 for (let x = 2; x < w - 2; x++) pixel(x, (floor - 4) * 2 + 1, '#b08860');
430 for (let x = 6; x < w - 4; x += 20) {
431 put(x, floor - 3, '│', '#806040');
432 put(x, floor - 2, '│', '#806040');
433 }
434 for (let x = 0; x < w; x++) put(x, floor, '▀', '#8a7a68', '#6a5c4c');
435}
436
437function frame(t, dt) {
438 const u = mod(t, ROUTINE);
439 const n = Math.floor(t / ROUTINE);
440 const floor = h - 1;
441 studio(floor);
442 const hasPiano = w >= 60;
443 if (hasPiano) piano(t, floor);
444 const p = blinking(track(KEYS, u), t);
445 const R = hasPiano ? w - 26 : w - 12;
446 const cx = Math.round(12 + (R - 12) * p.x);
447 const d = dancer(p, cx, floor * 2 - 1, { tutu: '#c8c0e8', frill: '#ece8fa' });
448 if (u > 10.5 && u < 11.8) sparkles(t, d, floor, '#e8e0ff');
449 const said = saying(u, n);
450 // The teacher counts from the piano; the last word is Clawd's.
451 if (said && hasPiano && u < 17.4) say(said, w - 8, floor - 4);
452 else if (said) speak(said, d);
453}
454`
455
456// The Firebird: the enchanted garden at night, the tree of golden apples,
457// and Clawd as the Firebird in red and gold with a plume, embers trailing
458// wherever it flies. It steals an apple, is caught, leaves a feather, dances
459// the infernal dance, sings the lullaby and flies off.
460const FIRE = String.raw`
461const ROUTINE = 22;
462const LOOK = { tutu: '#c8283a', frill: '#ffb84a', crown: 'r.y.r\nryyyr', crownColors: { r: '#ff5a2a', y: '#ffd040' } };
463const BEAT = [FIFTH, SECOND, BAS, SECOND];
464key(0, P(1.3, 'left', SECOND, 'stand'));
465leaps(0.2, 1.3, 0.6, 3);
466key(2.8, P(0.6, 'front', FIFTH, 'stand', { eyes: 'happy' }));
467key(3.4, P(0.6, 'left', FIFTH, 'stand'));
468key(5.4, P(0.14, 'left', FIFTH, 'stand'));
469key(5.7, P(0.14, 'left', RAISED.left, 'stand'));
470key(6.4, P(0.14, 'left', RAISED.left, 'stand', { eyes: 'happy' }));
471// Caught: twisting this way and that.
472for (let i = 0; i < 8; i++) {
473 const side = i % 2 ? 'left' : 'right';
474 key(6.6 + i * 0.3, P(0.16 + (i % 2) * 0.04, side, i % 2 ? FIFTH : SECOND, i % 2 ? 'stand' : 'plie'));
475}
476reverence(9.2, 0.3, 'right', 1.2);
477key(10.9, P(0.3, 'front', SECOND, 'stand'));
478// The infernal dance: turning all the way across.
479key(11.2, P(0.3, 'front', SECOND, 'plie'));
480key(11.4, P(0.3, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
481key(13.8, P(0.75, 'front', FIFTH, 'passe', { spinning: true, spin: 2160 }));
482key(14.1, P(0.75, 'front', SECOND, 'plie', { spin: 2160 }));
483tour(14.3, 0.75, 2);
484entrechats(15.2, 0.75, 2);
485// The lullaby: rocking slowly, eyes closed.
486balances(16.8, 0.6, 4, FIFTH, 0.7, { eyes: 'closed' });
487key(19.6, P(0.6, 'left', SECOND, 'stand'));
488leaps(19.8, 0.6, -0.4, 3);
489line(2.7, 3.6, ['the firebird!', 'ta-da!', 'hot hot hot!']);
490line(6.7, 8.2, ['eek! let me go!', 'who grabbed me?!', 'unhand me, ivan!']);
491line(9.4, 10.8, ['take this feather.', 'call me if you need me.', 'a gift, for you.']);
492
493// The tree of golden apples, at the left; the apple Clawd takes is gone
494// from then on.
495function garden(t, u, floor) {
496 if (w < 60) return;
497 const x0 = 7;
498 const half = [3, 5, 6, 5];
499 for (let y = 0; y < 4; y++) {
500 for (let x = -half[y]; x <= half[y]; x++) {
501 const leaf = mod(x + y, 3) === 0 ? '#1a3a24' : '#24502e';
502 const isApple = mod(x * 5 + y * 3, 7) === 0 && !(x === 5 && y === 1 && u > 5.9);
503 const glint = fract(t * 0.5 + x * 0.13 + y * 0.31) < 0.08;
504 if (isApple) put(x0 + x, y, '●', glint ? '#fff4b0' : '#ffc830', leaf);
505 else put(x0 + x, y, '█', leaf);
506 }
507 }
508 for (let y = 4; y < floor; y++) put(x0, y, '█', '#4a2e1c');
509}
510// Embers where Clawd has just been: a ring of where it was, drawn as
511// sparks fading from gold to red, off Clawd itself.
512const TRAIL = [];
513let slot = 0;
514function embers(t, d, floor) {
515 TRAIL[slot % 16] = [t, d.x, d.row];
516 slot++;
517 for (let i = 0; i < TRAIL.length; i++) {
518 const e = TRAIL[i];
519 const age = t - e[0];
520 if (age < 0.05 || age > 0.8 || Math.abs(e[1] - d.x) < 4) continue;
521 for (let j = 0; j < 2; j++) {
522 const col = Math.round(e[1] + (rand(e[0] * 97 + j) - 0.5) * 12);
523 const row = Math.round(e[2] + 1 + rand(e[0] * 89 + j + 1) * 3 - age * 2);
524 if (row < 0 || row >= floor || (col > d.x - 11 && col < d.x + 11 && row >= d.row - 1)) continue;
525 put(col, row, age < 0.3 ? '*' : '·', mix('#ffe070', '#c02818', age / 0.8));
526 }
527 }
528}
529// The feather Clawd leaves: drifting down from beside it, then lying on
530// the ground, glowing.
531function feather(t, u, x, floor) {
532 if (u < 9.8 || u > 21.5) return;
533 const k = clamp((u - 9.8) / 1.6);
534 const col = Math.round(x + 11 + Math.sin(k * 9) * 2);
535 if (k < 1) {
536 let py = Math.round(lerp(4, floor * 2 - 4, k));
537 if (mod(py, 2) === 1) py -= 1;
538 pixels(col, py, '.y\nyr\nrr\nr.', { y: '#ffd040', r: '#ff5a2a' });
539 } else {
540 const glow = 0.5 + 0.5 * Math.sin(t * 4);
541 pixels(col - 1, floor * 2 - 1, 'rryy', { r: mix('#c02818', '#ff7a3a', glow), y: mix('#e0a020', '#fff0a0', glow) });
542 }
543}
544
545function frame(t, dt) {
546 const u = mod(t, ROUTINE);
547 const n = Math.floor(t / ROUTINE);
548 const floor = h - 1;
549 let p = blinking(track(KEYS, u), t);
550 if (u > 3.4 && u < 5.4) {
551 p = bourree(p, t);
552 p.arms = BEAT[Math.floor(t * 6) % 4];
553 }
554 if (u > 11.4 && u < 13.8) p.arms = mod(p.spin, 360) < 180 ? FIFTH : SECOND;
555 p = beating(p, t);
556 const cx = Math.round(12 + (w - 24) * p.x);
557 for (let x = 0; x < w; x++) {
558 const pool = clamp(1 - Math.abs(x - cx) / 12);
559 put(x, floor, '▀', mix(x % 7 === 0 ? '#3a5a2a' : '#2a4422', '#ff9040', pool * 0.35), mix('#1a2c18', '#802a10', pool * 0.3));
560 }
561 garden(t, u, floor);
562 feather(t, u, 12 + (w - 24) * 0.3, floor);
563 const d = dancer(p, cx, floor * 2 - 1, LOOK);
564 if (u < 2.8 || u > 19.6 || (u > 11.4 && u < 16.6)) embers(t, d, floor);
565 if (u > 6.6 && u < 9) put(d.x + (Math.floor(t * 6) % 2 ? -7 : 7), Math.max(0, d.row - 2), '!', '#ffe070');
566 if ((u > 11.5 && u < 13.8) || p.air > 3.5) sparkles(t, d, floor, '#ffb040');
567 if (u > 16.8 && u < 19.4) sparkles(t * 0.3, d, floor, '#a06040');
568 const said = saying(u, n);
569 if (said) speak(said, d);
570}
571`
572
573// Shared by the pieces for more than one dancer (Mayerling, McGregor):
574// Rudolf, poses placed for partners, a second track, a turn in the air on
575// any track, a phrase of sharp poses, and the lights.
576const DUETS = String.raw`
577// Rudolf: Clawd without a tutu, in breeches and boots.
578const RUDOLF = { bare: true, breeches: '#c8a070', shoes: '#16120e' };
579// Poses placed from the stage's middle: x a fraction of the stage, dx in
580// columns, so partners meet exactly whatever the width.
581const M = (x, dx, view, arms, legs, more) => P(x, view, arms, legs, Object.assign({ dx: dx }, more || {}));
582const RK = [];
583const rkey = (t, p) => RK.push([t, p]);
584const CK = [];
585const ckey = (t, p) => CK.push([t, p]);
586// A tour en l'air on a track (key, rkey, ...): a partner's straight legs,
587// as breeches have no room for a stride.
588function tourOn(add, t0, x, dx, n) {
589 add(t0, M(x, dx, 'front', SECOND, 'plie'));
590 add(t0 + 0.15, M(x, dx, 'front', FIFTH, 'stand', { spinning: true, spin: 0 }));
591 add(t0 + 0.4, M(x, dx, 'front', FIFTH, 'stand', { spinning: true, spin: 180 * n, air: 4 }));
592 add(t0 + 0.65, M(x, dx, 'front', FIFTH, 'stand', { spinning: true, spin: 360 * n }));
593 add(t0 + 0.8, M(x, dx, 'front', SECOND, 'plie', { spin: 360 * n }));
594}
595// Pirouettes on a track, placed as M places them: n turns in passé from a
596// plié, landing on the spin they end on.
597function pirouettesOn(add, t0, length, x, dx, n) {
598 add(t0, M(x, dx, 'front', SECOND, 'plie'));
599 add(t0 + 0.2, M(x, dx, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
600 add(t0 + length - 0.3, M(x, dx, 'front', FIFTH, 'passe', { spinning: true, spin: 360 * n }));
601 add(t0 + length, M(x, dx, 'front', SECOND, 'plie', { spin: 360 * n }));
602}
603// Travelling steps on a track: n little jumps from x0 to x1, each from a
604// plié, a beat apart, facing the way they go: dancers run on in steps
605// rather than glide.
606function steps(add, t0, x0, x1, dx, n, arms, beat) {
607 const view = x1 > x0 ? 'right' : 'left';
608 for (let i = 0; i < n; i++) {
609 add(t0 + i * beat, M(x0 + (x1 - x0) * i / n, dx, view, arms, 'plie'));
610 add(t0 + (i + 0.5) * beat, M(x0 + (x1 - x0) * (i + 0.5) / n, dx, view, arms, 'stand', { air: 2 }));
611 }
612 add(t0 + n * beat, M(x1, dx, view, arms, 'plie'));
613}
614// A phrase of sharp poses, one every dt seconds, on a track: each step
615// [view, arms, legs, columns aside]. A dancer in breeches (isBare) has no
616// room for a leg behind or a stride, so those become a plié.
617function phrase(add, t0, dt, x, dx, steps, isBare) {
618 for (let i = 0; i < steps.length; i++) {
619 const st = steps[i];
620 const legs = isBare && (st[2] === 'derriere' || st[2] === 'jete') ? 'plie' : st[2];
621 add(t0 + i * dt, M(x, dx + (st[3] || 0), st[0], st[1], legs, st[4]));
622 }
623}
624// The lights closing in: dark outside an oval round (cx, middle) of a
625// radius in columns, a cell twice as tall as wide.
626function lights(cx, radius, floor) {
627 for (let y = 0; y <= floor; y++) {
628 const dy = (y - floor / 2) * 2;
629 const half = radius > Math.abs(dy) ? Math.sqrt(radius * radius - dy * dy) : 0;
630 const a = Math.round(cx - half);
631 const b = Math.round(cx + half);
632 if (half <= 0) fill(0, y, w, 1, ' ', '#050608', '#050608');
633 else {
634 if (a > 0) fill(0, y, a, 1, ' ', '#050608', '#050608');
635 if (b < w) fill(b, y, w - b, 1, ' ', '#050608', '#050608');
636 }
637 }
638}
639`
640
641// Mayerling, act I: the wedding ball at the Hofburg, Vienna, 1881. Rudolf
642// and Princess Stephanie (pale blue, a tiara) bow, waltz arm in arm, and
643// she twirls under his arm; he turns away from her, dances alone, all
644// jumps and turns, and leaves her standing.
645const BALL = String.raw`
646const ROUTINE = 22;
647const STEPH = { tutu: '#b8c8e8', frill: '#e8f0ff', crown: '.y.y.\nyyyyy', crownColors: { y: '#ffd84a' } };
648key(0, M(-0.3, 0, 'right', SECOND, 'stand'));
649key(2.2, M(0.5, -9, 'right', SECOND, 'stand'));
650rkey(0, M(1.3, 0, 'left', BAS, 'stand'));
651rkey(2.2, M(0.5, 9, 'left', BAS, 'stand'));
652// Bows: her curtsy, his bow.
653key(2.4, M(0.5, -9, 'right', SECOND, 'derriere'));
654key(2.8, M(0.5, -9, 'right', BAS, 'derriere', { eyes: 'closed' }));
655key(3.3, M(0.5, -9, 'right', BAS, 'derriere', { eyes: 'closed' }));
656rkey(2.4, M(0.5, 9, 'left', BAS, 'plie', { eyes: 'closed' }));
657rkey(3.3, M(0.5, 9, 'left', BAS, 'plie', { eyes: 'closed' }));
658// The waltz, arm in arm: down on each first beat, travelling.
659for (let i = 0; i <= 10; i++) {
660 const x = 0.5 + 0.2 * Math.sin(i * 0.6);
661 const legs = i % 2 ? 'stand' : 'plie';
662 key(3.6 + i * 0.45, M(x, -7, 'right', ['down', 'out'], legs));
663 rkey(3.6 + i * 0.45, M(x, 7, 'left', ['out', 'down'], legs));
664}
665// She twirls under his raised arm.
666key(8.4, M(0.5, -9, 'front', SECOND, 'plie'));
667key(8.6, M(0.5, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
668key(9.8, M(0.5, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 720 }));
669key(10.1, M(0.5, -9, 'right', ['down', 'out'], 'stand', { spin: 720 }));
670rkey(8.4, M(0.5, 7, 'left', ['high', 'down'], 'stand'));
671rkey(10, M(0.5, 7, 'left', ['high', 'down'], 'stand'));
672// He turns his back on her, and dances alone.
673rkey(10.6, M(0.5, 7, 'right', BAS, 'stand'));
674rkey(11.4, M(0.6, 0, 'right', SECOND, 'stand'));
675tourOn(rkey, 11.8, 0.6, 0, 2);
676rkey(12.9, M(0.68, 0, 'right', FIFTH, 'stand', { air: 4 }));
677rkey(13.3, M(0.76, 0, 'right', SECOND, 'plie'));
678rkey(13.7, M(0.84, 0, 'right', FIFTH, 'stand', { air: 4 }));
679rkey(14.1, M(0.9, 0, 'right', SECOND, 'plie'));
680tourOn(rkey, 14.4, 0.9, 0, 2);
681rkey(15.4, M(0.9, 0, 'left', BAS, 'stand', { eyes: 'closed' }));
682rkey(16.4, M(0.9, 0, 'right', BAS, 'stand'));
683rkey(18, M(1.4, 0, 'right', BAS, 'stand'));
684key(10.6, M(0.5, -9, 'right', ['down', 'out'], 'stand'));
685key(16.6, M(0.5, -9, 'right', ['down', 'out'], 'stand'));
686key(17.2, M(0.5, -9, 'front', BAS, 'stand'));
687key(18.4, M(0.5, -9, 'front', BAS, 'plie', { eyes: 'closed' }));
688key(20.4, M(0.5, -9, 'front', BAS, 'plie', { eyes: 'closed' }));
689key(21, M(0.5, -9, 'left', BAS, 'stand'));
690key(22, M(-0.2, 0, 'left', BAS, 'stand'));
691line(0.3, 2.2, ['vienna, 1881.', 'the royal wedding ball.', 'the hofburg, ablaze.']);
692line(10.6, 11.6, ['how dull.', 'excuse me.', 'i need air.']);
693line(17.6, 19.4, ['some wedding...', 'rudolf?', 'well then.']);
694
695// The ballroom: cream and gold pilasters on dark rose walls, chandeliers
696// twinkling, a parquet floor.
697function ballroom(t, floor) {
698 for (let x = 2; x < w; x += 20) {
699 fill(x, 0, 2, floor, ' ', '#3a2a22', '#3a2a22');
700 fill(x, 0, 1, floor, '│', '#b8944a', '#3a2a22');
701 }
702 for (let x = 12; x < w - 4; x += 20) {
703 put(x, 0, '┃', '#b8944a');
704 for (let i = -1; i <= 1; i++) put(x + i, 1, '▼', fract(t * 0.7 + x * 0.13 + i * 0.37) < 0.15 ? '#ffffff' : '#ffe0a0');
705 }
706}
707
708function frame(t, dt) {
709 const u = mod(t, ROUTINE);
710 const n = Math.floor(t / ROUTINE);
711 const floor = h - 1;
712 const ground = floor * 2 - 1;
713 const at = p => Math.round(12 + (w - 24) * p.x + (p.dx || 0));
714 let s = blinking(track(KEYS, u), t);
715 if (u < 2.2 || u > 21) s = bourree(s, t);
716 const r = blinking(track(RK, u), t);
717 const scx = at(s);
718 const rcx = at(r);
719 ballroom(t, floor);
720 boards(floor, (scx + rcx) / 2, '#a07840', '#7a5a30', '#ffe8b4');
721 dancer(r, rcx, ground, RUDOLF);
722 const d = dancer(s, scx, ground, STEPH);
723 const said = saying(u, n);
724 if (said && u < 2.2) say(said, Math.round(w / 2), 0);
725 else if (said && u > 10.5 && u < 15.4) speak(said, { x: rcx, row: d.row });
726 else if (said) speak(said, d);
727}
728`
729
730// Mayerling, act II: Mitzi Caspar's tavern. The Hungarian officers stamp
731// and clap; Mitzi (red and black) dances among them; they turn in the air
732// together; Rudolf comes in, and she dances with him, laughs off his talk
733// of dying, and ends held high in a tableau with the officers knelt
734// round her.
735const TAVERN = String.raw`
736const ROUTINE = 22;
737const MITZI = { tutu: '#b01830', frill: '#1a1012', shoes: '#f0c0c8' };
738const HUSSAR = { bare: true, breeches: '#2a3a6a', shoes: '#16120e' };
739const OK = [];
740const okey = (t, p) => OK.push([t, p]);
741// Mitzi: in, leaping between the officers, a pose while they turn, to
742// Rudolf, turns, and the tableau.
743key(0, M(-0.3, 0, 'right', SECOND, 'stand'));
744key(2.4, M(0.5, 0, 'right', SECOND, 'stand'));
745leaps(2.6, 0.5, 0.7, 2);
746leaps(4.1, 0.7, 0.3, 3);
747leaps(6.4, 0.3, 0.5, 1);
748key(7.4, M(0.5, 0, 'front', FIFTH, 'stand', { eyes: 'happy' }));
749key(7.8, M(0.5, 0, 'right', RAISED.right, 'derriere'));
750key(10.6, M(0.5, 0, 'right', RAISED.right, 'derriere'));
751key(11.6, M(0.5, -7, 'right', ['down', 'out'], 'derriere'));
752key(13, M(0.5, -7, 'right', ['down', 'out'], 'derriere'));
753key(13.4, M(0.5, -9, 'right', FIFTH, 'stand', { air: 4, eyes: 'happy' }));
754key(14.4, M(0.5, -9, 'right', FIFTH, 'stand', { air: 4, eyes: 'happy' }));
755key(14.8, M(0.5, -9, 'front', SECOND, 'plie'));
756key(15, M(0.5, -4, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
757key(18.4, M(0.5, -4, 'front', FIFTH, 'passe', { spinning: true, spin: 1800 }));
758key(18.8, M(0.5, -4, 'front', FIFTH, 'stand', { spin: 1800, eyes: 'happy' }));
759key(22, M(0.5, -4, 'front', FIFTH, 'stand', { eyes: 'happy' }));
760// Rudolf: in late, takes her hands, lifts her, then stands apart.
761rkey(0, M(1.4, 0, 'left', BAS, 'stand'));
762rkey(10.6, M(1.4, 0, 'left', BAS, 'stand'));
763rkey(11.6, M(0.5, 7, 'left', SECOND, 'plie'));
764rkey(13, M(0.5, 7, 'left', SECOND, 'plie'));
765rkey(13.4, M(0.5, 7, 'left', ['high', 'down'], 'stand'));
766rkey(14.4, M(0.5, 7, 'left', ['high', 'down'], 'stand'));
767rkey(15, M(0.5, 18, 'left', BAS, 'stand', { eyes: 'closed' }));
768rkey(22, M(0.5, 18, 'left', BAS, 'stand', { eyes: 'closed' }));
769// The officers: stamping, three tours en l'air together, stamping, and
770// down on one knee.
771okey(0, M(0, 0, 'front', SECOND, 'stand'));
772okey(7.6, M(0, 0, 'front', SECOND, 'stand'));
773tourOn(okey, 7.8, 0, 0, 1);
774tourOn(okey, 8.7, 0, 0, 1);
775tourOn(okey, 9.6, 0, 0, 2);
776okey(10.6, M(0, 0, 'front', SECOND, 'stand'));
777okey(18.6, M(0, 0, 'front', SECOND, 'stand'));
778okey(19, M(0, 0, 'front', FIFTH, 'kneel', { eyes: 'happy' }));
779okey(22, M(0, 0, 'front', FIFTH, 'kneel', { eyes: 'happy' }));
780const STAMPING = [[0, 7.6], [10.6, 18.6]];
781line(0.3, 2.4, ['the tavern of mitzi caspar.', 'the hungarian officers!', 'vienna by night.']);
782line(13.4, 14.6, ['die with me, mitzi.', 'a pact, mitzi?', 'together, forever...']);
783line(15.2, 16.8, ['die? i would rather dance!', 'ha! no thank you!', 'dance with me instead!']);
784
785// The tavern: dark beams and lanterns flickering.
786function tavern(t, floor) {
787 for (let x = 0; x < w; x += 11) fill(x, 0, 1, floor, '║', '#2a1a10', '#140c08');
788 for (let x = 6; x < w - 2; x += 22) {
789 put(x, 0, '│', '#4a3020');
790 put(x, 1, '◘', mix('#ffb040', '#ffe090', rand(Math.floor(t * 6) + x)));
791 }
792}
793
794function frame(t, dt) {
795 const u = mod(t, ROUTINE);
796 const n = Math.floor(t / ROUTINE);
797 const floor = h - 1;
798 const ground = floor * 2 - 1;
799 const R = w - 12;
800 const at = p => Math.round(12 + (R - 12) * p.x + (p.dx || 0));
801 let m = blinking(track(KEYS, u), t);
802 if (u < 2.4) m = bourree(m, t);
803 const r = blinking(track(RK, u), t);
804 let o = track(OK, u);
805 for (const s of STAMPING) {
806 if (u > s[0] && u < s[1]) {
807 o.legs = Math.floor(t * 4) % 2 ? 'plie' : 'stand';
808 o.arms = Math.floor(t * 4) % 2 ? FIFTH : SECOND;
809 }
810 }
811 tavern(t, floor);
812 boards(floor, at(m), '#6a4a2a', '#4a3018', '#ffc070');
813 // As many officers as the strip has room for, at the ends of the stage,
814 // clear of the middle where Mitzi and Rudolf dance.
815 const PLACES = w >= 150 ? [0, 0.17, 0.83, 1] : w >= 90 ? [0, 1] : [];
816 for (let i = 0; i < PLACES.length; i++) {
817 dancer(blinking(Object.assign({}, o), t + i), Math.round(12 + (R - 12) * PLACES[i]), ground, HUSSAR);
818 }
819 if (u > 10.6) dancer(r, at(r), ground, RUDOLF);
820 const d = dancer(m, at(m), ground, MITZI);
821 if (u > 15 && u < 18.4) sparkles(t, d, floor, '#ffb0c0');
822 const said = saying(u, n);
823 if (said && u < 2.4) say(said, Math.round(w / 2), 0);
824 else if (said && u > 13.4 && u < 14.6) speak(said, { x: at(r), row: d.row });
825 else if (said) speak(said, d);
826}
827`
828
829// Mayerling, act III: the hunting lodge, January 1889. Crown Prince Rudolf and Mary
830// Vetsera: she arrives, the pull (her arabesque, his lunge, hands joined),
831// her turns, her flight and return, and the enveloping kiss, Mary held
832// upside down above Rudolf on his knees (drawn as one picture: the person
833// chose it, an exception to legs pointing down). The lights close in on
834// them and go to black; then, like Swan Lake, a joke: a pop in the dark
835// (champagne), the lights up on both of them grinning, and a bow.
836const MAYER = String.raw`
837const ROUTINE = 24;
838const MARY = { tutu: '#e8dcc0', frill: '#fff4e0' };
839// Mary bourrées in to Rudolf, who waits brooding.
840key(0, M(-0.3, 0, 'right', SECOND, 'stand'));
841key(2.6, M(0.5, -14, 'right', SECOND, 'stand'));
842// The pull: her arabesque, her arm out to his.
843key(3, M(0.5, -7, 'right', ['down', 'out'], 'derriere'));
844key(5.6, M(0.5, -7, 'right', ['down', 'out'], 'derriere'));
845// Her turns beside him.
846key(5.9, M(0.5, -9, 'front', SECOND, 'plie'));
847key(6.1, M(0.5, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
848key(7.8, M(0.5, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 720 }));
849key(8.1, M(0.5, -9, 'front', SECOND, 'plie', { spin: 720 }));
850// Away from him in leaps, back on pointe, and a leap into his arms.
851key(8.3, M(0.5, -9, 'left', BAS, 'plie'));
852key(8.65, M(0.5, -22, 'left', RAISED.left, 'jete', { air: 4 }));
853key(9, M(0.5, -34, 'left', SECOND, 'plie'));
854key(9.4, M(0.5, -34, 'right', SECOND, 'stand'));
855key(11.2, M(0.5, -20, 'right', SECOND, 'stand'));
856key(11.5, M(0.5, -20, 'right', BAS, 'plie'));
857key(11.9, M(0.5, -8, 'right', RAISED.right, 'jete', { air: 4 }));
858key(12.2, M(0.5, 0, 'right', RAISED.right, 'jete', { air: 4 }));
859// After the dark, the joke: the lights up on both grinning, his bottle
860// and her glass raised, the cork pops, and a bow.
861key(17.9, M(0.5, -9, 'front', ['high', 'out'], 'stand', { eyes: 'happy' }));
862key(20.4, M(0.5, -9, 'front', ['high', 'out'], 'stand', { eyes: 'happy' }));
863key(20.6, M(0.5, -9, 'right', SECOND, 'derriere'));
864key(21, M(0.5, -9, 'right', BAS, 'derriere', { eyes: 'closed' }));
865key(22.4, M(0.5, -9, 'right', BAS, 'derriere', { eyes: 'closed' }));
866key(22.8, M(0.5, -9, 'front', FIFTH, 'stand', { eyes: 'happy' }));
867key(24, M(0.5, -9, 'front', FIFTH, 'stand', { eyes: 'happy' }));
868rkey(0, M(0.5, 7, 'front', BAS, 'plie', { eyes: 'closed' }));
869rkey(2.3, M(0.5, 7, 'front', BAS, 'plie', { eyes: 'closed' }));
870rkey(2.7, M(0.5, 7, 'left', SECOND, 'plie'));
871rkey(5.6, M(0.5, 7, 'left', SECOND, 'plie'));
872rkey(6, M(0.5, 7, 'left', ['out', 'high'], 'stand'));
873rkey(8.2, M(0.5, 7, 'left', ['out', 'high'], 'stand'));
874rkey(8.6, M(0.5, 7, 'left', SECOND, 'stand'));
875rkey(11, M(0.5, 7, 'left', SECOND, 'stand'));
876rkey(11.6, M(0.5, 7, 'left', FIFTH, 'kneel'));
877rkey(17.8, M(0.5, 7, 'left', FIFTH, 'kneel'));
878rkey(17.9, M(0.5, 7, 'front', ['out', 'high'], 'stand', { eyes: 'happy' }));
879rkey(20.4, M(0.5, 7, 'front', ['out', 'high'], 'stand', { eyes: 'happy' }));
880rkey(20.6, M(0.5, 7, 'left', BAS, 'plie', { eyes: 'closed' }));
881rkey(22.4, M(0.5, 7, 'left', BAS, 'plie', { eyes: 'closed' }));
882rkey(22.8, M(0.5, 7, 'front', FIFTH, 'stand', { eyes: 'happy' }));
883rkey(24, M(0.5, 7, 'front', FIFTH, 'stand', { eyes: 'happy' }));
884const KISS_AT = 12.2;
885line(0.3, 2.5, ['mayerling, 1889.', 'the hunting lodge...', 'a winter night.']);
886line(3.2, 5.5, ['never let me go.', 'rudolf...', 'hold on to me.']);
887line(19.4, 20.6, ['...just kidding!', 'champagne, anyone?', 'plot twist: we are fine!']);
888
889// The kiss: Rudolf kneeling, body on the floor, his near arm up round
890// her; Mary upside down above him, set four columns aside so the two
891// shapes step apart, her nightdress fallen down her sides, her near arm
892// round his neck, both with eyes closed, shaded on opposite sides. Her
893// feet slowly part and close (two pictures). Whole cells throughout;
894// Rudolf's body is columns 6 to 15.
895const KISS_BODY = [
896 'TFTFTFTFTFTFTF',
897 'FTFTFTFTFTFTFT',
898 '.FDDBBBBBBBBF',
899 '.TDDBBBBBBBBT',
900 '.FDDEEBBBEEBF',
901 '.TDDBBBBBBBBT',
902 'AADDBBBBBBBBAARRR',
903 'AADDBBBBBBBBAARRR',
904 '......DDBBBBBBBBRR',
905 '......DDBBBBBBBBRR',
906 '......DDBBBBBBBBRR',
907 '......DDEEBBBEEBRR',
908 '......DDBBBBBBBB',
909 '......DDBBBBBBBB',
910].join('\n');
911const KISS = [
912 '..S..S..S..S\n..L..L..L..L\n' + KISS_BODY,
913 '...S.S..S.S\n...L.L..L.L\n' + KISS_BODY,
914];
915function kiss(t, rcx, ground) {
916 const feet = Math.floor(t / 0.8) % 2;
917 pixels(rcx - 11, ground - 15, KISS[feet], { S: MARY.frill, L: ORANGE, T: MARY.tutu, F: MARY.frill, B: ORANGE, D: SHADED, E: EYES, A: ORANGE, R: ORANGE });
918}
919// Little hearts drifting up from the kiss.
920function hearts(t, u, x, floor) {
921 for (let i = 0; i < 3; i++) {
922 const k = fract((u - KISS_AT) * 0.45 + i / 3);
923 const row = Math.round((floor - 4) * (1 - k));
924 const col = Math.round(x + 10 + i * 3 + Math.sin(k * 6 + i) * 2);
925 if (u - KISS_AT > i * 0.7) put(col, row, '♥', mix('#ff8aa8', '#3a2028', k * k));
926 }
927}
928// The champagne: a bottle in Rudolf's raised hand, a glass in Mary's; at
929// POP the cork flies off in an arc to the floor and the foam sprays up
930// and falls.
931const POP_AT = 18.6;
932function champagne(t, u, dr, d, floor) {
933 pixels(dr.x + 6, dr.top - 6, '.y.
934.g.
935ggg
936ggg
937ggg
938ggg', { y: '#ffd84a', g: '#1e6a30' });
939 pixels(d.x - 9, d.top - 4, 'c.c
940cyc
941.c.
942ccc', { c: '#e8eef0', y: '#ffe070' });
943 const k = u - POP_AT;
944 if (k < 0) return;
945 const top = Math.max(0, Math.floor(dr.top / 2) - 3);
946 if (k < 0.25) put(dr.x + 7, top, '✶', '#ffffff');
947 if (k < 0.9) put(dr.x + 8 + Math.round(k * 24), Math.round(lerp(top, floor - 1, (k / 0.9) * (k / 0.9))), '●', '#d8b080');
948 // The foam: spilling out of the neck to both sides and falling.
949 for (let i = 0; i < 14 && k < 1.8; i++) {
950 const a = k - rand(i) * 0.5;
951 if (a < 0 || a > 1.2) continue;
952 const col = dr.x + 7 + Math.round((rand(i * 3) - 0.5) * 18 * a);
953 const row = top + Math.round(a * a * 7);
954 if (row < floor) put(col, row, a < 0.7 ? '●' : '•', mix('#fffbe8', '#ffe070', a / 1.2));
955 }
956}
957// The window: night outside, snow falling past it.
958function snowWindow(t, floor, x0) {
959 fill(x0, 0, 10, floor - 2, ' ', '#101c34', '#101c34');
960 for (let y = 0; y < floor - 2; y++) {
961 put(x0, y, '█', '#2a1a10');
962 put(x0 + 5, y, '│', '#2a1a10', '#101c34');
963 put(x0 + 9, y, '█', '#2a1a10');
964 }
965 for (let x = x0; x < x0 + 10; x++) put(x, floor - 2, '▀', '#3a2618', '#0a2420');
966 for (let i = 0; i < 8; i++) {
967 const x = x0 + 1 + Math.floor(rand(i * 13) * 8);
968 const y = Math.floor(mod(rand(i * 7) * (floor - 2) + t * (0.6 + rand(i) * 0.5), floor - 2));
969 if (x !== x0 + 5) put(x, y, '·', '#e8f0ff', '#101c34');
970 }
971}
972
973// The room: tall panels of green velvet between dark frames, lit where
974// the two dance, and a candelabra.
975function room(t, floor, cx) {
976 for (let x = 0; x < w; x++) {
977 const i = mod(x, 14);
978 const light = clamp(1 - Math.abs(x - cx) / 30) * 0.5;
979 const c = i < 2 ? '#0a0e0a' : mix(mix('#10261a', '#1e4222', 0.5 + 0.5 * Math.sin(i * 1.2 + Math.floor(x / 14))), '#3a6a30', light);
980 fill(x, 0, 1, floor, ' ', c, c);
981 put(x, floor, '▀', mix('#12403a', '#3a8070', light), '#0a2420');
982 }
983}
984function candelabra(t, floor, x0, flames) {
985 put(x0, floor - 1, '┴', '#a08040');
986 put(x0, floor - 2, '│', '#a08040');
987 put(x0 - 2, floor - 3, '└', '#a08040');
988 put(x0 - 1, floor - 3, '─', '#a08040');
989 put(x0, floor - 3, '┼', '#a08040');
990 put(x0 + 1, floor - 3, '─', '#a08040');
991 put(x0 + 2, floor - 3, '┘', '#a08040');
992 for (let i = 0; i < 3; i++) {
993 put(x0 - 2 + i * 2, floor - 4, '│', '#f0e8d0');
994 if (i < flames) put(x0 - 2 + i * 2, floor - 5, '▲', mix('#ffb030', '#fff0a0', rand(Math.floor(t * 8) + i * 7)));
995 }
996}
997
998function frame(t, dt) {
999 const u = mod(t, ROUTINE);
1000 const n = Math.floor(t / ROUTINE);
1001 const floor = h - 1;
1002 const ground = floor * 2 - 1;
1003 const hasCandles = w >= 60;
1004 const L = hasCandles ? 16 : 10;
1005 const R = w - 10;
1006 const at = p => Math.round(L + (R - L) * p.x + p.dx);
1007 let m = blinking(track(KEYS, u), t);
1008 if (u < 2.6 || (u > 9.4 && u < 11.2)) m = bourree(m, t);
1009 const r = blinking(track(RK, u), t);
1010 const rcx = at(r);
1011 const mcx = at(m);
1012 room(t, floor, u < KISS_AT ? (mcx + rcx) / 2 : rcx);
1013 if (hasCandles) candelabra(t, floor, 6, 3);
1014 if (w >= 90) snowWindow(t, floor, w - 22);
1015 let d;
1016 if (u < KISS_AT || u > 17.9) {
1017 // Rudolf leans back in the pull, Mary with him.
1018 const lean = u > 3 && u < 5.6 ? Math.round(Math.sin((u - 3) * 2.4)) : 0;
1019 const dr = dancer(r, rcx + lean, ground, RUDOLF);
1020 d = dancer(m, mcx + lean, ground, MARY);
1021 if (u > 17.9 && u < 20.4) champagne(t, u, dr, d, floor);
1022 } else {
1023 kiss(t, rcx, ground);
1024 if (u < 15.6) hearts(t, u, rcx, floor);
1025 d = { x: rcx, row: 0, top: 0 };
1026 }
1027 // The lights close in on the kiss and go to black; then they come up
1028 // on the two of them, fine, with champagne.
1029 if (u > 15 && u < 18.4) {
1030 const radius = u < 17.4 ? 40 * (1 - (u - 15) / 2.4) : u < 17.9 ? 0 : 60 * (u - 17.9) / 0.5;
1031 lights(rcx - 2, Math.max(0, radius), floor);
1032 if (u < 17.9) return;
1033 }
1034 if (u > POP_AT && u < POP_AT + 0.7) say('POP!', at(r) - 4, 0);
1035 if (u > 19.2 && u < 21) sparkles(t, d, floor, '#ffe080');
1036 const said = saying(u, n);
1037 if (said && u < 2.6) say(said, Math.round(w / 2), 0);
1038 else if (said) speak(said, d);
1039}
1040`
1041
1042// Wayne McGregor, a triple bill. His dancers' hyperextended lines are
1043// beyond Clawd's block of a body, so the style is in the staging: phrases
1044// of sharp poses snapping from one to the next, off-centre, sudden
1045// stillness, partners pulling each other off balance, and the designs.
1046
1047// Chroma (2006): John Pawson's white room with its opening at the back,
1048// pale costumes, Jack White's orchestrated White Stripes. She steps out
1049// of the opening and dances alone, all snaps and freezes; he arrives and
1050// pulls her off balance, turns her, lifts her; they dance the phrase in
1051// canon; they turn; they go back into the opening and the light goes.
1052const CHROMA = String.raw`
1053const ROUTINE = 22;
1054const HER = { tutu: '#e8c4a8', frill: '#f6dcc6' };
1055const HIM = { bare: true, breeches: '#e0bc9e', shoes: '#e0bc9e' };
1056const SHARP = [
1057 ['right', ['high', 'down'], 'stand'], ['right', ['out', 'up'], 'derriere'], ['front', ['down', 'high'], 'plie', -2], ['left', ['up', 'out'], 'stand', -2],
1058 ['back', FIFTH, 'passe'], ['front', ['high', 'out'], 'stand', 2], ['right', BAS, 'plie', 2], ['left', ['out', 'high'], 'derriere', 1],
1059];
1060key(0, M(0.5, 0, 'front', BAS, 'stand', { eyes: 'closed' }));
1061key(1.2, M(0.5, 0, 'front', BAS, 'stand'));
1062phrase(key, 1.6, 0.3, 0.5, 0, SHARP);
1063phrase(key, 4, 0.25, 0.42, 0, SHARP);
1064key(6.2, M(0.42, 0, 'right', ['out', 'up'], 'derriere'));
1065key(7.4, M(0.42, 0, 'right', ['out', 'up'], 'derriere'));
1066key(7.7, M(0.42, 0, 'right', ['down', 'out'], 'derriere'));
1067key(9.4, M(0.42, 0, 'right', ['down', 'out'], 'derriere'));
1068key(9.6, M(0.42, -2, 'front', SECOND, 'plie'));
1069key(9.8, M(0.42, -2, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
1070key(11, M(0.42, -2, 'front', FIFTH, 'passe', { spinning: true, spin: 1080 }));
1071key(11.3, M(0.42, 1, 'right', RAISED.right, 'jete', { air: 4, spin: 1080 }));
1072key(12, M(0.42, 1, 'right', RAISED.right, 'jete', { air: 4 }));
1073phrase(key, 12.2, 0.3, 0.3, 0, SHARP);
1074phrase(key, 14.6, 0.3, 0.3, 0, SHARP);
1075pirouettes(17.2, 1.4, 0.3, 2);
1076key(19.4, M(0.5, -5, 'right', BAS, 'stand'));
1077key(22, M(0.5, -5, 'front', BAS, 'stand', { eyes: 'closed' }));
1078rkey(0, M(1.4, 0, 'left', BAS, 'stand'));
1079rkey(6.8, M(1.4, 0, 'left', BAS, 'stand'));
1080rkey(7.7, M(0.42, 14, 'left', SECOND, 'plie'));
1081rkey(9.4, M(0.42, 14, 'left', SECOND, 'plie'));
1082rkey(9.6, M(0.42, 14, 'left', ['out', 'high'], 'stand'));
1083rkey(11.2, M(0.42, 14, 'left', FIFTH, 'plie'));
1084rkey(12.1, M(0.42, 14, 'left', FIFTH, 'plie'));
1085phrase(rkey, 12.5, 0.3, 0.7, 0, SHARP, true);
1086phrase(rkey, 14.9, 0.3, 0.7, 0, SHARP, true);
1087tourOn(rkey, 17.4, 0.7, 0, 2);
1088rkey(19.4, M(0.5, 5, 'left', BAS, 'stand'));
1089rkey(22, M(0.5, 5, 'front', BAS, 'stand', { eyes: 'closed' }));
1090line(0.3, 1.5, ['chroma.']);
1091
1092// The white room, its opening at the back stepped into the wall.
1093function whiteRoom(floor) {
1094 fill(0, 0, w, floor, ' ', '#e4e2dc', '#e4e2dc');
1095 const half = Math.min(14, Math.floor(w / 6));
1096 const x0 = Math.round(w / 2) - half;
1097 fill(x0, 1, half * 2, floor - 1, ' ', '#c9c6be', '#c9c6be');
1098 fill(x0 + 2, 2, half * 2 - 4, floor - 2, ' ', '#b4b0a6', '#b4b0a6');
1099 for (let x = 0; x < w; x++) put(x, floor, '▀', '#d4d0c8', '#bcb8b0');
1100}
1101
1102function frame(t, dt) {
1103 const u = mod(t, ROUTINE);
1104 const n = Math.floor(t / ROUTINE);
1105 const floor = h - 1;
1106 const ground = floor * 2 - 1;
1107 const at = p => Math.round(12 + (w - 24) * p.x + (p.dx || 0));
1108 const s = blinking(track(KEYS, u), t);
1109 const r = blinking(track(RK, u), t);
1110 whiteRoom(floor);
1111 dancer(r, at(r), ground, HIM);
1112 const d = dancer(s, at(s), ground, HER);
1113 if (u > 20.6) lights(Math.round(w / 2), Math.max(0, 40 * (1 - (u - 20.6) / 1.2)), floor);
1114 const said = saying(u, n);
1115 if (said && u < 1.5) say(said, Math.round(w / 2), 0);
1116 else if (said && u > 20.6) return;
1117 else if (said) speak(said, d);
1118}
1119`
1120
1121// Infra (2008): Julian Opie's LED screen across the top, little figures
1122// walking along it the whole time; below, Max Richter, dark, and boxes of
1123// light. Two couples dance in them; a cut; a crowd walks past one woman
1124// left alone, crying, and nobody stops; then one does, and they dance;
1125// the light goes, and the walkers walk on.
1126const INFRA = String.raw`
1127const ROUTINE = 24;
1128const HER = { tutu: '#3c3c48', frill: '#5c5c6c' };
1129const HIM = { bare: true, breeches: '#3c3c48', shoes: '#1a1a20' };
1130const CROWD = { bare: true, breeches: '#6a6a74', shoes: '#2a2a30' };
1131// The first couple; the second dances the same, mirrored, a beat behind.
1132key(0, M(0.25, -7, 'right', ['down', 'out'], 'derriere'));
1133key(2.4, M(0.25, -7, 'right', ['down', 'out'], 'derriere'));
1134key(2.6, M(0.25, -9, 'front', SECOND, 'plie'));
1135key(2.8, M(0.25, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
1136key(4.4, M(0.25, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 720 }));
1137key(4.7, M(0.25, -9, 'right', RAISED.right, 'derriere', { spin: 720 }));
1138key(5.4, M(0.25, -6, 'right', FIFTH, 'jete', { air: 4 }));
1139key(6.4, M(0.25, -6, 'right', FIFTH, 'jete', { air: 4 }));
1140key(6.8, M(0.25, -9, 'right', BAS, 'plie'));
1141key(7.6, M(0.25, -9, 'front', BAS, 'stand', { eyes: 'closed' }));
1142// Alone in the crowd, crying; then he comes back to her.
1143key(8, M(0.5, 0, 'front', BAS, 'stand', { eyes: 'closed' }));
1144key(15.4, M(0.5, 0, 'front', BAS, 'stand', { eyes: 'closed' }));
1145key(15.8, M(0.5, -7, 'right', ['down', 'out'], 'stand'));
1146key(17, M(0.5, -7, 'right', ['down', 'out'], 'derriere'));
1147key(17.3, M(0.5, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
1148key(18.8, M(0.5, -9, 'front', FIFTH, 'passe', { spinning: true, spin: 720 }));
1149key(19.1, M(0.5, -6, 'right', FIFTH, 'jete', { air: 4, spin: 720 }));
1150key(20, M(0.5, -6, 'right', FIFTH, 'jete', { air: 4 }));
1151key(20.4, M(0.5, -7, 'right', ['down', 'out'], 'stand', { eyes: 'closed' }));
1152key(24, M(0.5, -7, 'right', ['down', 'out'], 'stand', { eyes: 'closed' }));
1153rkey(0, M(0.25, 7, 'left', SECOND, 'plie'));
1154rkey(2.4, M(0.25, 7, 'left', SECOND, 'plie'));
1155rkey(2.6, M(0.25, 7, 'left', ['out', 'high'], 'stand'));
1156rkey(5.2, M(0.25, 7, 'left', ['out', 'high'], 'stand'));
1157rkey(5.4, M(0.25, 7, 'left', FIFTH, 'plie'));
1158rkey(6.6, M(0.25, 7, 'left', FIFTH, 'plie'));
1159rkey(7.2, M(0.25, 7, 'right', BAS, 'stand'));
1160rkey(8, M(1.4, 0, 'right', BAS, 'stand'));
1161rkey(14.2, M(1.4, 0, 'left', BAS, 'stand'));
1162rkey(15.4, M(0.5, 7, 'left', SECOND, 'stand'));
1163rkey(17, M(0.5, 7, 'left', SECOND, 'plie'));
1164rkey(17.3, M(0.5, 7, 'left', ['out', 'high'], 'stand'));
1165rkey(19, M(0.5, 7, 'left', FIFTH, 'plie'));
1166rkey(20.2, M(0.5, 7, 'left', FIFTH, 'plie'));
1167rkey(20.4, M(0.5, 7, 'left', ['out', 'down'], 'stand', { eyes: 'closed' }));
1168rkey(24, M(0.5, 7, 'left', ['out', 'down'], 'stand', { eyes: 'closed' }));
1169line(0.3, 2.4, ['infra.']);
1170
1171const WALKER = ['.w.\nwww\n.w.\nw.w', '.w.\nwww\n.w.\n.w.'];
1172const OPIE = ['#e8e8e8', '#8ac8ff', '#ff9ab8', '#ffe070', '#9aff9a'];
1173// The LED screen: figures walking both ways at their own pace.
1174function screen(t) {
1175 fill(0, 0, w, 2, ' ', '#101216', '#101216');
1176 for (let i = 0; i < Math.floor(w / 8); i++) {
1177 const dir = i % 2 ? 1 : -1;
1178 const speed = 3 + rand(i) * 4;
1179 const x = Math.round(mod(rand(i * 3) * w + dir * t * speed, w + 6) - 3);
1180 pixels(x, 0, WALKER[Math.floor(t * speed * 0.7 + i) % 2], { w: OPIE[i % 5] });
1181 }
1182}
1183// A box of light on the floor, and its glow.
1184function lightBox(x, floor) {
1185 fill(x - 11, 2, 22, floor - 2, ' ', '#16181e', '#16181e');
1186 for (let i = -11; i < 11; i++) put(x + i, floor, '▀', '#8a8c94', '#3a3c44');
1187}
1188const mirrored = p => Object.assign({}, p, { view: p.view === 'right' ? 'left' : p.view === 'left' ? 'right' : p.view, arms: [p.arms[1], p.arms[0]], x: 1 - p.x, dx: -p.dx });
1189
1190function frame(t, dt) {
1191 const u = mod(t, ROUTINE);
1192 const n = Math.floor(t / ROUTINE);
1193 const floor = h - 1;
1194 const ground = floor * 2 - 1;
1195 const at = p => Math.round(12 + (w - 24) * p.x + (p.dx || 0));
1196 for (let x = 0; x < w; x++) put(x, floor, '▀', '#24262c', '#121318');
1197 screen(t);
1198 const s = blinking(track(KEYS, u), t);
1199 const r = blinking(track(RK, u), t);
1200 let d;hooks/cells.ts 61 lines1// Repaints a frame's half-block cells so they hold up in terminals with line
2// spacing, where a block glyph does not fill its whole cell and a strip at
3// the top of each cell shows the cell's background instead.
4//
5// toons' renderer draws two pixels to a cell as an upper half block, the top
6// pixel in the foreground and the bottom one in the background, so there that
7// strip shows the bottom pixel's color: a thin line of the eyes or the tutu
8// across Clawd, and gaps between the rows of anything solid. Here a cell whose
9// two pixels match becomes a space filled with that color, which covers the
10// whole cell, and one whose pixels differ becomes a lower half block, the
11// bottom pixel in the foreground, so the strip shows the top pixel's color.
12// In a terminal that draws blocks exactly, both look as before.
13
14import { CLEAR, encode } from './effects'
15
16const UPPER = 0x2580
17const LOWER = 0x2584
18const FULL = 0x2588
19const SPACE = 0x20
20
21const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
22const VALUE = new Int16Array(128).fill(0)
23for (let i = 0; i < B64.length; i++) VALUE[B64.charCodeAt(i)] = i
24
25// A Raster's base64 cells back to their words. Hook modules have no atob,
26// so it is spelled out.
27export function decode(cells: string): Uint32Array {
28 const text = cells.replace(/=+$/, '')
29 const bytes = new Uint8Array(Math.floor((text.length * 3) / 4))
30 let out = 0
31 for (let i = 0; i < text.length; i += 4) {
32 const v = (VALUE[text.charCodeAt(i)]! << 18) | (VALUE[text.charCodeAt(i + 1)]! << 12) | ((VALUE[text.charCodeAt(i + 2)] ?? 0) << 6) | (VALUE[text.charCodeAt(i + 3)] ?? 0)
33 if (out < bytes.length) bytes[out++] = (v >> 16) & 255
34 if (out < bytes.length) bytes[out++] = (v >> 8) & 255
35 if (out < bytes.length) bytes[out++] = v & 255
36 }
37
38 return new Uint32Array(bytes.buffer, 0, Math.floor(bytes.length / 4))
39}
40
41// Words are three to a cell: glyph, foreground, background.
42export function solidify(cells: string): string {
43 const words = decode(cells)
44 for (let at = 0; at + 2 < words.length; at += 3) {
45 const ch = words[at]
46 const fg = words[at + 1]!
47 const bg = words[at + 2]!
48 if (fg === CLEAR) continue
49 if (ch === FULL || ((ch === UPPER || ch === LOWER) && fg === bg)) {
50 words[at] = SPACE
51 words[at + 2] = fg
52 } else if (ch === UPPER && bg !== CLEAR) {
53 words[at] = LOWER
54 words[at + 1] = bg
55 words[at + 2] = fg
56 }
57 }
58
59 return encode(words)
60}
61hooks/choose.ts 151 lines1// What `/ballet <words>` picks, and what Tab completes them to. Forgiving:
2// a typo or two ("mmayerling", "glaa"), a start ("may"), a word of the name
3// ("lake"), and an act by numeral or number ("mayerling 3", "swan lake act ii").
4
5const ROMAN = ['i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'viii', 'ix']
6
7// A piece's name as its ballet and its act: "swan lake ii" is Swan Lake's
8// second act; "untitled 2023" is a ballet of one act (act 0).
9export function split(name: string): { ballet: string; act: number } {
10 const words = name.split(' ')
11 const act = ROMAN.indexOf(words[words.length - 1]!) + 1
12
13 return act > 0 && words.length > 1 ? { ballet: words.slice(0, -1).join(' '), act } : { ballet: name, act: 0 }
14}
15
16const normal = (text: string) =>
17 text
18 .toLowerCase()
19 .replace(/[^a-z0-9 ]/g, ' ')
20 .replace(/\s+/g, ' ')
21 .trim()
22
23// "swan lake iii" as "Swan Lake III", for replies.
24export const title = (name: string) => name.replace(/\b[a-z]+\b/g, w => (ROMAN.includes(w) ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
25
26// Edits from a to b, two letters swapped counting as one ("glaa", "gala").
27function distance(a: string, b: string): number {
28 const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)))
29 for (let i = 1; i <= a.length; i++) {
30 for (let j = 1; j <= b.length; j++) {
31 d[i]![j] = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
32 if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i]![j] = Math.min(d[i]![j]!, d[i - 2]![j - 2]! + 1)
33 }
34 }
35
36 return d[a.length]![b.length]!
37}
38
39// How far the words typed are from a name, or from its start (a start with
40// a typo still counts); Infinity past the typos allowed for that length.
41function near(typed: string, name: string): number {
42 const d = Math.min(distance(typed, name), distance(typed, name.slice(0, typed.length)))
43 const allowed = typed.length <= 2 ? 0 : typed.length <= 5 ? 1 : 2
44
45 return d <= allowed ? d : Infinity
46}
47
48// How well the words typed name a ballet: its whole name first, then any one
49// of its words ("lake"), a shade worse so a whole-name match wins a tie.
50const score = (typed: string, ballet: string) => Math.min(near(typed, ballet), ...ballet.split(' ').map(w => near(typed, w) + 0.5))
51
52export type Choice = { index: number } | { error: string }
53
54// The piece `/ballet <arg>` names, out of `names` (the pieces in order).
55export function choose(names: readonly string[], arg: string): Choice {
56 const typed = normal(arg)
57 const exact = names.indexOf(typed)
58 if (exact >= 0) return { index: exact }
59 const words = typed.split(' ').filter(w => w && w !== 'act')
60 // A trailing act, as a number or (after the ballet's name) a numeral.
61 const last = words[words.length - 1] ?? ''
62 const act = /^[1-9]$/.test(last) ? Number(last) : words.length > 1 ? ROMAN.indexOf(last) + 1 : 0
63 const stem = (act > 0 ? words.slice(0, -1) : words).join(' ')
64 const pieces = names.map(split)
65 let best = ''
66 let bestAt = Infinity
67 for (const ballet of new Set(pieces.map(p => p.ballet))) {
68 const d = stem ? score(stem, ballet) : Infinity
69 if (d < bestAt) [best, bestAt] = [ballet, d]
70 }
71 if (bestAt === Infinity) return { error: `No piece called "${arg.trim()}". Try ${summary(names)}.` }
72 const acts = pieces.flatMap((p, index) => (p.ballet === best ? [{ ...p, index }] : []))
73 if (act === 0) return { index: acts[0]!.index }
74 const found = acts.find(p => p.act === act)
75 if (found) return { index: found.index }
76
77 return { error: acts.length > 1 ? `${title(best)} has acts ${acts.map(p => ROMAN[p.act - 1]!.toUpperCase()).join(', ')}.` : `${title(best)} is a single act.` }
78}
79
80// The modes: the live dancer, or the pieces in turn.
81export const MODES = ['live', 'standard'] as const
82
83// Words for the modes: their names, and standard's old one, still taken.
84const MODE_WORDS: [string, (typeof MODES)[number]][] = [['live', 'live'], ['standard', 'standard'], ['repertoire', 'standard']]
85
86// The mode the words name, typos and all ("standrd", "liev"), if any.
87export function modeOf(arg: string): (typeof MODES)[number] | undefined {
88 const typed = normal(arg)
89 let best: (typeof MODES)[number] | undefined
90 let bestAt = Infinity
91 for (const [word, mode] of MODE_WORDS) {
92 const d = typed ? near(typed, word) : Infinity
93 if (d < bestAt) [best, bestAt] = [mode, d]
94 }
95
96 return best
97}
98
99// Words that open the programme (the pane of pieces and settings).
100export const PROGRAMME = ['programme', 'program', 'settings']
101
102// Whether the words name the programme, typos and all ("progamme",
103// "setings"); a start counts only from four letters, so "p" is no word.
104export function isProgramme(arg: string): boolean {
105 const typed = normal(arg)
106
107 return typed.length >= 4 && PROGRAMME.some(word => near(typed, word) < Infinity)
108}
109
110// The piece after the one at `index`: in turn, the next in the programme;
111// shuffled, its ballet's next act still, else the first act of another
112// ballet at random (`random` in [0, 1)).
113export function following(names: readonly string[], index: number, isShuffled: boolean, random = Math.random): number {
114 const next = (index + 1) % names.length
115 if (!isShuffled) return next
116 const here = split(names[index] ?? '')
117 const after = split(names[next]!)
118 if (after.ballet === here.ballet && after.act > here.act) return next
119 const starts = names.flatMap((name, i) => {
120 const { ballet, act } = split(name)
121
122 return act <= 1 && ballet !== here.ballet ? [i] : []
123 })
124
125 return starts[Math.floor(random() * starts.length)] ?? next
126}
127
128// What Tab offers for the words typed so far: every name (the modes, the
129// programme and on/off too) that starts with them, else the one piece they
130// name loosely.
131export function completions(names: readonly string[], arg: string): string[] {
132 const typed = normal(arg)
133 const starting = [...names, ...MODES, 'programme', 'on', 'off'].filter(n => n.startsWith(typed))
134 if (starting.length) return starting
135 const picked = choose(names, arg)
136
137 return 'index' in picked ? [names[picked.index]!] : []
138}
139
140// The names with each ballet's acts folded together: "swan lake i-iv".
141export function summary(names: readonly string[]): string {
142 const groups: { ballet: string; acts: number[] }[] = []
143 for (const { ballet, act } of names.map(split)) {
144 const group = groups.find(g => g.ballet === ballet)
145 if (group) group.acts.push(act)
146 else groups.push({ ballet, acts: [act] })
147 }
148
149 return groups.map(g => (g.acts.length > 1 ? `${g.ballet} ${ROMAN[g.acts[0]! - 1]}-${ROMAN[g.acts[g.acts.length - 1]! - 1]}` : g.ballet)).join(', ')
150}
151hooks/live.ts 1013 lines1// The live dancer: one Clawd in the studio, its dancing made of what
2// Claude does, as claude-toons' scenes follow the work (toons'
3// hooks/library.ts). No model is asked. Between tool calls Clawd holds
4// still, focused, in a pose for the moment (thinking, a test running,
5// writing the reply); each tool call or shell command fires one quick move
6// of its own (grep a twirl, cat a pas de chat, an edit a beaten jump), with
7// a label over its head saying what set it off, and calls in quick
8// succession chain their moves together. News (a new task, a tool that
9// failed, tests that passed) cuts in at once. A stagehand brings git's
10// ribbon, which trails Clawd's moves for a while after; a messenger runs
11// the web's letters in; an MCP server sends a partner, and subagents a
12// corps that dances Clawd's moves in canon.
13//
14// The plugin passes the moment in each frame as the scene's globals
15// (`liveAct`, `liveMove`, ...: `globalsOf`); played without them, as in
16// the scripts, Clawd stands ready.
17
18import { bare, COMMON } from './ballet'
19import { DANCER } from './dance'
20import { parseProgram } from './lang'
21import type { Script } from './script'
22
23// What Claude is at, for the pose Clawd holds between moves.
24export const ACTS = ['idle', 'thinking', 'reading', 'searching', 'editing', 'running', 'testing', 'building', 'git', 'web', 'mcp', 'agents', 'writing'] as const
25export type Act = (typeof ACTS)[number]
26export const NEWS = ['task', 'fail', 'pass'] as const
27export type News = (typeof NEWS)[number]
28// The moves a tool call fires (`MOVES` in the scene, which check-frames
29// holds to this list).
30export const MOVES = ['read', 'cat', 'head', 'tail', 'wc', 'diff', 'grep', 'rg', 'find', 'ls', 'pwd', 'edit', 'write', 'rm', 'mv', 'cp', 'mkdir', 'chmod', 'tar', 'echo', 'sleep', 'kill', 'cd', 'sort', 'open', 'ssh', 'run', 'test', 'build', 'git', 'commit', 'web', 'mcp', 'agent', 'skill'] as const
31export type Move = (typeof MOVES)[number]
32
33// Shell commands by their first word: the move, and the kind of work.
34const COMMANDS: Record<string, [Move, Act]> = {
35 cat: ['cat', 'reading'],
36 head: ['head', 'reading'],
37 tail: ['tail', 'reading'],
38 wc: ['wc', 'reading'],
39 diff: ['diff', 'reading'],
40 less: ['read', 'reading'],
41 more: ['read', 'reading'],
42 bat: ['read', 'reading'],
43 sed: ['read', 'reading'],
44 awk: ['read', 'reading'],
45 jq: ['read', 'reading'],
46 grep: ['grep', 'searching'],
47 rg: ['rg', 'searching'],
48 ag: ['rg', 'searching'],
49 find: ['find', 'searching'],
50 fd: ['find', 'searching'],
51 locate: ['find', 'searching'],
52 which: ['find', 'searching'],
53 ls: ['ls', 'searching'],
54 tree: ['ls', 'searching'],
55 pwd: ['pwd', 'searching'],
56 rm: ['rm', 'editing'],
57 rmdir: ['rm', 'editing'],
58 mv: ['mv', 'editing'],
59 cp: ['cp', 'editing'],
60 mkdir: ['mkdir', 'editing'],
61 touch: ['mkdir', 'editing'],
62 chmod: ['chmod', 'editing'],
63 chown: ['chmod', 'editing'],
64 sudo: ['chmod', 'running'],
65 tar: ['tar', 'editing'],
66 zip: ['tar', 'editing'],
67 unzip: ['tar', 'editing'],
68 gzip: ['tar', 'editing'],
69 echo: ['echo', 'running'],
70 printf: ['echo', 'running'],
71 sleep: ['sleep', 'running'],
72 wait: ['sleep', 'running'],
73 kill: ['kill', 'running'],
74 pkill: ['kill', 'running'],
75 killall: ['kill', 'running'],
76 cd: ['cd', 'running'],
77 sort: ['sort', 'running'],
78 uniq: ['sort', 'running'],
79 open: ['open', 'running'],
80 curl: ['web', 'web'],
81 wget: ['web', 'web'],
82 ssh: ['ssh', 'web'],
83 scp: ['ssh', 'web'],
84 rsync: ['ssh', 'web'],
85}
86
87// Printable ASCII only, short enough for a label.
88const plain = (text: string, max: number) => {
89 const flat = text.replace(/\s+/g, ' ').replace(/[^\x20-\x7e]/g, '').trim()
90
91 return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat
92}
93const base = (path: unknown) => (typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : '')
94const host = (url: unknown) => (typeof url === 'string' ? (/^\w+:\/\/([^/?#]+)/.exec(url)?.[1] ?? url) : '')
95
96// MCP tools are `mcp__<server>__<tool>`; the server's name as words.
97export function mcpOf(tool: string): { server: string; name: string } | undefined {
98 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
99 if (!m) return undefined
100
101 return { server: plain(m[1]!.replace(/_/g, ' '), 30), name: plain(m[2]!.replace(/_/g, ' '), 30) }
102}
103
104// A shell command: the command it starts with, past a `cd ... &&` and any
105// `VAR=value`s (never shown: they can hold keys), if it has a move of its
106// own; git by its own words (a commit message or a file named for tests is
107// no test run); else tests or a build, read from anywhere in it, as toons
108// reads them. Labelled by its first word or two.
109function bashOf(command: string): { act: Act; move: Move; label: string } {
110 const words = command.trim()
111 const after = /^cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*(.*)$/s.exec(words)
112 const rest = (after ? after[1]! : words).replace(/^(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, '')
113 const first = (/^(\S+)/.exec(rest)?.[1] ?? '').replace(/^.*\//, '')
114 const second = /^\S+\s+([a-z][\w-]*)/.exec(rest)?.[1] ?? ''
115 const label = plain(/^(git|gh|npm|npx|pnpm|yarn|bun|cargo|go|make|docker|node|python3?)$/.test(first) && second ? `${first} ${second}` : first, 20)
116 const known = COMMANDS[first]
117 if (known) return { act: known[1], move: known[0], label }
118 if (first === 'git' || first === 'gh') return { act: 'git', move: /^(git (commit|push|merge|tag|rebase)|gh pr create)\b/.test(rest) ? 'commit' : 'git', label }
119 if (/\b(test|tests|jest|vitest|pytest|mocha|spec|rspec|check|go test|cargo test)\b/.test(command)) return { act: 'testing', move: 'test', label }
120 if (/\b(build|tsc|make|cargo (build|check)|compile|webpack|vite|bundle|install|npm ci|yarn|pnpm|pip|poetry|gradle|mvn|xcodebuild)\b/.test(command)) return { act: 'building', move: 'build', label }
121 if (/\b(git (commit|push|merge|tag|rebase)|gh pr create)\b/.test(command)) return { act: 'git', move: 'commit', label }
122 if (/\b(git|gh)\b/.test(command)) return { act: 'git', move: 'git', label }
123
124 return { act: 'running', move: 'run', label }
125}
126
127// What a tool call is: the kind of work, the move it fires, and its label.
128export function actOf(tool: string, input: Record<string, unknown>): { act: Act; move: Move; label: string } {
129 const is = (act: Act, move: Move, label: string) => ({ act, move, label: plain(label, 24) })
130 const file = base(input.file_path ?? input.notebook_path)
131 const mcp = mcpOf(tool)
132 if (mcp) return is('mcp', 'mcp', mcp.server)
133 if (tool === 'Read' || tool === 'NotebookRead') return is('reading', 'read', `read ${file}`)
134 if (tool === 'Grep') return is('searching', 'grep', 'grep')
135 if (tool === 'Glob') return is('searching', 'find', 'glob')
136 if (tool === 'LS') return is('searching', 'ls', 'ls')
137 if (tool === 'ToolSearch') return is('searching', 'grep', 'tool search')
138 if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') return is('editing', 'edit', `edit ${file}`)
139 if (tool === 'Write') return is('editing', 'write', `write ${file}`)
140 if (tool === 'WebFetch') return is('web', 'web', `fetch ${host(input.url)}`)
141 if (tool === 'WebSearch') return is('web', 'web', 'web search')
142 if (tool === 'Agent' || tool === 'Task' || tool === 'Workflow') return is('agents', 'agent', tool === 'Workflow' ? 'workflow' : 'agent')
143 if (tool === 'Skill') return is('thinking', 'skill', `skill ${String(input.skill ?? '')}`)
144 if (tool === 'Bash') return bashOf(typeof input.command === 'string' ? input.command : '')
145
146 return is('running', 'run', tool)
147}
148
149// What the scene is told each frame.
150export type Moment = {
151 act: Act
152 // The last tool call's move and label, and a count of the calls, so each
153 // one fires its move once (and the same move twice fires twice).
154 move: Move | ''
155 label: string
156 toolN: number
157 // Whether a tool is running now (Clawd holds the move's pose for it).
158 busy: boolean
159 // The latest news and a count that changes with each.
160 news: News | ''
161 newsN: number
162 // Subagents at work (the corps), and the MCP server partnering Clawd.
163 corps: number
164 guest: string
165 // A count of the subagents' tool calls, which of them made the last one
166 // (by the order they started), and its move: that dancer of the corps
167 // dances it.
168 crewN: number
169 crewWho: number
170 crewMove: Move | ''
171}
172
173export const globalsOf = (m: Moment) => ({
174 liveAct: m.act,
175 liveMove: m.move,
176 liveLabel: m.label,
177 liveToolN: m.toolN,
178 liveBusy: m.busy,
179 liveNews: m.news,
180 liveNewsN: m.newsN,
181 liveCorps: m.corps,
182 liveGuest: m.guest,
183 liveCrewN: m.crewN,
184 liveCrewWho: m.crewWho,
185 liveCrewMove: m.crewMove,
186})
187
188// The live scene's code, after the dancer and the shared steps. A queue of
189// moves (QUEUE, the last two cues kept, news first) plays out back to
190// back; when it is empty Clawd holds a pose for the moment (hold), which
191// the next cue cuts at once unless Clawd is turning or in the air. The
192// stagehand (SK) and the partner (PK) have tracks of their own, scheduled
193// with Clawd's move. The corps joins only once Clawd is in the middle, and
194// while it is on, Clawd's moves stay in place, so nobody lands on anybody.
195const LIVE = String.raw`
196function live() {
197 return {
198 act: typeof liveAct === 'undefined' ? 'idle' : liveAct,
199 move: typeof liveMove === 'undefined' ? '' : liveMove,
200 label: typeof liveLabel === 'undefined' ? '' : liveLabel,
201 toolN: typeof liveToolN === 'undefined' ? 0 : liveToolN,
202 busy: typeof liveBusy === 'undefined' ? false : liveBusy,
203 news: typeof liveNews === 'undefined' ? '' : liveNews,
204 newsN: typeof liveNewsN === 'undefined' ? 0 : liveNewsN,
205 corps: typeof liveCorps === 'undefined' ? 0 : liveCorps,
206 guest: typeof liveGuest === 'undefined' ? '' : liveGuest,
207 crewN: typeof liveCrewN === 'undefined' ? 0 : liveCrewN,
208 crewWho: typeof liveCrewWho === 'undefined' ? 0 : liveCrewWho,
209 crewMove: typeof liveCrewMove === 'undefined' ? '' : liveCrewMove,
210 };
211}
212// Clawd in the studio, in class colours, as in Class.
213const LOOK = { tutu: '#c8c0e8', frill: '#ece8fa' };
214const CORPS = { tutu: '#e6ecfa', frill: '#ffffff' };
215// The stagehand: in stagehand's black, a shade light enough to show
216// against the studio at night, with a cap.
217const STAGEHAND = { bare: true, breeches: '#4a4a5c', shoes: '#2a2a32', crown: '.kkkk.\nkkkkkk', crownColors: { k: '#6a6a82' } };
218// Each MCP server sends a partner in its own colour.
219const PARTNERS = ['#3a4a7a', '#7a3a4a', '#3a6a4a', '#6a5a2a', '#5a3a7a', '#2a6a6a'];
220const LETTER = { art: 'wwwwww\nwggggw\nwwwwww\nwgggww', colors: { w: '#f4f0e4', g: '#9a9080' } };
221const RIBBON = '#ff7aa0';
222const SK = [];
223const sadd = (t, p) => SK.push([t, p]);
224const PK = [];
225const padd = (t, p) => PK.push([t, p]);
226// Poses placed from a fraction of the stage plus dx columns, for partners.
227const M = (x, dx, view, arms, legs, more) => P(x, view, arms, legs, Object.assign({ dx: dx }, more || {}));
228// Windows of time: bourrée steps, sparkles, labels, and little signs (FX:
229// from, to, kind, and a side for some).
230const GLIDES = [];
231const GLOWS = [];
232const TAGS = [];
233const FX = [];
234const PROPS = [];
235const QUEUE = [];
236// Where the ribbon has been: the hand's last few cells.
237const TRAIL = [];
238const during = (list, t) => list.some(g => t >= g[0] && t < g[1]);
239const fxAt = t => { for (const f of FX) if (t >= f[0] && t < f[1]) return f; return null; };
240const fx = (t0, t1, kind, side) => FX.push([t0, t1, kind, side || 0]);
241const prop = (thing, keys) => PROPS.push({ thing: thing, keys: keys });
242const facing = (x0, x1) => x1 > x0 ? 'right' : 'left';
243const other = view => view === 'right' ? 'left' : 'right';
244let n = 0, here = 0.5, until = 1.4, holding = false, seen = -1, seenTool = -1, seenCrew = -1, ribbonUntil = -1;
245let partnerOn = false, partnerSide = 1, partnerLook = { bare: true, breeches: PARTNERS[0], shoes: '#16120e' };
246let L = 12, R = 60;
247const away = (x, d) => x < 0.5 ? clamp(x + d, 0.05, 0.95) : clamp(x - d, 0.05, 0.95);
248// On from the wing in a bourrée, to the middle.
249key(0, P(-0.25, 'right', SECOND, 'stand'));
250key(1.4, P(0.5, 'right', SECOND, 'stand'));
251GLIDES.push([0, 1.4]);
252
253// Poses on Clawd's track a beat apart: [view, arms, legs, more].
254function seq(t0, x, beat, list) {
255 for (let i = 0; i < list.length; i++) key(t0 + i * beat, P(x, list[i][0], list[i][1], list[i][2], list[i][3]));
256}
257// A walk (air 0) or a run (air 2) on a track, k steps a beat apart.
258function walk(add, t0, x0, x1, dx, k, beat, arms, air) {
259 const view = facing(x0, x1);
260 for (let i = 0; i < k; i++) {
261 add(t0 + i * beat, M(x0 + (x1 - x0) * i / k, dx, view, arms, 'plie'));
262 add(t0 + (i + 0.5) * beat, M(x0 + (x1 - x0) * (i + 0.5) / k, dx, view, arms, 'stand', { air: air }));
263 }
264 add(t0 + k * beat, M(x1, dx, view, arms, 'stand'));
265}
266// Big grand jetés, higher than the pieces' leaps.
267function jetes(t0, x0, x1, k) {
268 const view = facing(x0, x1);
269 for (let i = 0; i < k; i++) {
270 const t = t0 + i * 0.85, a = x0 + (x1 - x0) * i / k, b = x0 + (x1 - x0) * (i + 1) / k;
271 key(t, P(a, view, BAS, 'plie'));
272 key(t + 0.42, P((a + b) / 2, view, RAISED[view], 'jete', { air: 6 }));
273 key(t + 0.8, P(b, view, SECOND, 'plie'));
274 }
275 return t0 + k * 0.85;
276}
277// A quick turn on the spot: k turns in passé, landing on the spin.
278function twirl(t0, x, k, length, arms) {
279 key(t0, P(x, 'front', SECOND, 'plie'));
280 key(t0 + 0.12, P(x, 'front', arms || FIFTH, 'passe', { spinning: true, spin: 0 }));
281 key(t0 + length - 0.15, P(x, 'front', arms || FIFTH, 'passe', { spinning: true, spin: 360 * k }));
282 key(t0 + length, P(x, 'front', SECOND, 'plie', { spin: 360 * k }));
283}
284// A glissade: a low, gliding jump to x1.
285function glissade(t0, x, x1, arms) {
286 const view = x1 === x ? 'right' : facing(x, x1);
287 key(t0, P(x, view, SECOND, 'plie'));
288 key(t0 + 0.3, P((x + x1) / 2, view, arms || RAISED[view], 'jete', { air: 3 }));
289 key(t0 + 0.6, P(x1, view, SECOND, 'plie'));
290 key(t0 + 0.85, P(x1, 'front', SECOND, 'stand'));
291}
292
293// The moves a tool call fires, each about a second, ending on the floor.
294const MOVES = {
295 // Reading a file: up on pointe, the arms opening as a page turns.
296 read: (t0, x) => seq(t0, x, 0.25, [['front', BAS, 'plie'], ['front', FIFTH, 'stand', { air: 1 }], ['front', ['high', 'out'], 'stand', { air: 1 }], ['front', SECOND, 'stand']]),
297 // cat: a pas de chat, the cat's own step.
298 cat: (t0, x, still) => {
299 const x1 = still ? x : away(x, 0.06), view = still ? 'right' : facing(x, x1);
300 key(t0, P(x, view, SECOND, 'plie'));
301 key(t0 + 0.3, P((x + x1) / 2, view, FIFTH, 'passe', { air: 4, eyes: 'happy' }));
302 key(t0 + 0.6, P(x1, view, SECOND, 'plie'));
303 key(t0 + 0.85, P(x1, 'front', SECOND, 'stand', { eyes: 'happy' }));
304 },
305 // head: reaching up for the top.
306 head: (t0, x) => seq(t0, x, 0.25, [['front', BAS, 'plie'], ['front', FIFTH, 'stand', { air: 2 }], ['front', FIFTH, 'stand', { air: 2 }], ['front', SECOND, 'stand']]),
307 // tail: an arabesque flicked out behind, a look back over it.
308 tail: (t0, x) => {
309 const view = n % 2 ? 'left' : 'right';
310 seq(t0, x, 0.3, [['front', SECOND, 'plie'], [view, RAISED[view], 'derriere'], ['back', SECOND, 'stand'], ['front', SECOND, 'stand']]);
311 },
312 // wc: three quick sautés, counted beside its head.
313 wc: (t0, x) => {
314 for (let i = 0; i < 3; i++) {
315 key(t0 + i * 0.35, P(x, 'front', BAS, 'plie'));
316 key(t0 + i * 0.35 + 0.17, P(x, 'front', SECOND, 'stand', { air: 3 }));
317 }
318 key(t0 + 1.05, P(x, 'front', SECOND, 'plie'));
319 fx(t0, t0 + 1.05, 'count');
320 },
321 // diff: a glance at its reflection in the mirror, and back.
322 diff: (t0, x) => seq(t0, x, 0.3, [['back', ['out', 'up'], 'stand'], ['front', ['up', 'out'], 'stand'], ['back', ['up', 'out'], 'stand'], ['front', SECOND, 'stand']]),
323 // grep: a quick twirl.
324 grep: (t0, x) => twirl(t0, x, 1, 0.75),
325 // rg: a double, quicker still.
326 rg: (t0, x) => twirl(t0, x, 2, 0.8),
327 // find, glob: turning travelled across the studio, and there it is.
328 find: (t0, x, still) => {
329 const x1 = still ? x : away(x, 0.25);
330 key(t0, P(x, 'front', BAS, 'stand', { spinning: true, spin: 0 }));
331 key(t0 + 1, P(x1, 'front', BAS, 'stand', { spinning: true, spin: 1080 }));
332 key(t0 + 1.15, P(x1, 'front', SECOND, 'plie', { spin: 1080 }));
333 key(t0 + 1.4, P(x1, 'front', ['out', 'up'], 'stand', { eyes: 'happy' }));
334 fx(t0 + 1.15, t0 + 1.8, 'idea');
335 },
336 // ls: pointing one way and the other, taking stock.
337 ls: (t0, x) => seq(t0, x, 0.28, [['right', RAISED.right, 'stand'], ['left', RAISED.left, 'stand'], ['right', RAISED.right, 'stand'], ['front', SECOND, 'stand']]),
338 // pwd: a turn on the spot, looking all round.
339 pwd: (t0, x) => seq(t0, x, 0.25, [['right', SECOND, 'stand'], ['back', SECOND, 'stand'], ['left', SECOND, 'stand'], ['front', ['out', 'up'], 'stand']]),
340 // An edit: a beaten jump, the legs fluttering.
341 edit: (t0, x) => {
342 key(t0, P(x, 'front', BAS, 'plie'));
343 key(t0 + 0.28, P(x, 'front', SECOND, 'stand', { air: 4 }));
344 key(t0 + 0.56, P(x, 'front', BAS, 'plie'));
345 key(t0 + 0.8, P(x, 'front', SECOND, 'stand'));
346 },
347 // A new file written: a grand jeté.
348 write: (t0, x, still) => {
349 if (still) return MOVES.edit(t0, x);
350 const x1 = away(x, 0.22);
351 const t = jetes(t0, x, x1, 1);
352 key(t + 0.1, P(x1, 'front', SECOND, 'stand'));
353 },
354 // rm: a kick sending a paper ball into the wings, and the hands dusted.
355 rm: (t0, x) => {
356 const side = x < 0.5 ? -1 : 1, view = side < 0 ? 'right' : 'left';
357 fx(t0, t0 + 0.35, 'ball', side);
358 key(t0, P(x, view, SECOND, 'plie'));
359 key(t0 + 0.35, P(x, view, FIFTH, 'derriere', { air: 2 }));
360 key(t0 + 0.7, P(x, view, SECOND, 'stand'));
361 fx(t0 + 0.35, t0 + 1.3, 'toss', side);
362 key(t0 + 0.95, P(x, 'front', ['out', 'up'], 'stand', { eyes: 'happy' }));
363 key(t0 + 1.2, P(x, 'front', ['up', 'out'], 'stand', { eyes: 'happy' }));
364 },
365 // mv: a glissade across, the arms carrying something high.
366 mv: (t0, x, still) => glissade(t0, x, still ? x : away(x, 0.2), FIFTH),
367 // cp: a pose, and the same pose the other way.
368 cp: (t0, x) => seq(t0, x, 0.3, [['right', RAISED.right, 'derriere'], ['right', RAISED.right, 'derriere'], ['left', RAISED.left, 'derriere'], ['left', RAISED.left, 'derriere'], ['front', SECOND, 'stand']]),
369 // mkdir, touch: down to the floor and springing up, something new there.
370 mkdir: (t0, x) => {
371 seq(t0, x, 0.3, [['front', BAS, 'plie'], ['front', BAS, 'kneel'], ['front', FIFTH, 'stand', { air: 3, eyes: 'happy' }], ['front', SECOND, 'plie', { eyes: 'happy' }]]);
372 fx(t0 + 0.4, t0 + 1.6, 'spark', x < 0.5 ? 1 : -1);
373 },
374 // chmod, sudo: a quick curtsy to the teacher at the piano.
375 chmod: (t0, x) => {
376 key(t0, P(x, 'right', SECOND, 'stand'));
377 key(t0 + 0.3, P(x, 'right', BAS, 'derriere', { eyes: 'closed' }));
378 key(t0 + 0.9, P(x, 'right', BAS, 'derriere', { eyes: 'closed' }));
379 key(t0 + 1.2, P(x, 'front', SECOND, 'stand', { eyes: 'happy' }));
380 },
381 // tar, zip: squashed down small, and out again.
382 tar: (t0, x) => seq(t0, x, 0.3, [['front', BAS, 'plie', { eyes: 'closed' }], ['front', BAS, 'kneel', { eyes: 'closed' }], ['front', BAS, 'plie'], ['front', FIFTH, 'stand', { air: 3, eyes: 'happy' }], ['front', SECOND, 'plie']]),
383 // echo: singing out, the arms opening, notes rising.
384 echo: (t0, x) => {
385 seq(t0, x, 0.3, [['front', BAS, 'stand'], ['front', SECOND, 'stand', { eyes: 'happy' }], ['front', ['out', 'high'], 'stand', { eyes: 'happy' }], ['front', SECOND, 'stand', { eyes: 'happy' }]]);
386 fx(t0 + 0.2, t0 + 1.8, 'notes');
387 },
388 // sleep: sitting down (and staying down while it waits).
389 sleep: (t0, x) => seq(t0, x, 0.35, [['front', BAS, 'plie'], ['front', BAS, 'kneel', { eyes: 'closed' }]]),
390 // kill: the dying swan, quickly, and up again.
391 kill: (t0, x) => {
392 const view = n % 2 ? 'left' : 'right';
393 seq(t0, x, 0.22, [[view, FIFTH, 'plie'], [view, SECOND, 'plie'], [view, BAS, 'plie', { eyes: 'closed' }], [view, SECOND, 'kneel', { eyes: 'closed' }], [view, BAS, 'kneel', { eyes: 'closed' }], ['front', FIFTH, 'stand', { air: 3, eyes: 'happy' }], ['front', SECOND, 'plie']]);
394 },
395 // cd: a glissade somewhere new.
396 cd: (t0, x, still) => glissade(t0, x, still ? x : away(x, 0.25)),
397 // sort: two chassés, one way and back.
398 sort: (t0, x, still) => {
399 const d = still ? 0 : 0.05;
400 const a = clamp(x + d, 0.05, 0.95), b = clamp(x - d, 0.05, 0.95);
401 key(t0, P(x, 'right', SECOND, 'plie'));
402 key(t0 + 0.25, P(a, 'right', SECOND, 'stand', { air: 2 }));
403 key(t0 + 0.5, P(a, 'left', SECOND, 'plie'));
404 key(t0 + 0.75, P(b, 'left', SECOND, 'stand', { air: 2 }));
405 key(t0 + 1, P(x, 'front', BAS, 'stand'));
406 },
407 // open: a presentation, the arms opening wide.
408 open: (t0, x) => seq(t0, x, 0.3, [['front', BAS, 'plie'], ['front', ['down', 'out'], 'stand'], ['front', SECOND, 'stand', { eyes: 'happy' }], ['front', FIFTH, 'stand', { eyes: 'happy' }]]),
409 // ssh: leaping off into the wings, and back in from the other side.
410 ssh: (t0, x, still) => {
411 if (still) return MOVES.cd(t0, x, true);
412 const out = x < 0.5 ? -0.3 : 1.3, back = x < 0.5 ? 1.3 : -0.3;
413 const t = jetes(t0, x, out, 1);
414 key(t, P(back, facing(back, x), SECOND, 'plie'));
415 jetes(t + 0.2, back, x, 1);
416 },
417 // Any other command: a double pirouette.
418 run: (t0, x) => twirl(t0, x, 2, 1, SECOND),
419 // Tests: a preparation, up into arabesque (held while they run).
420 test: (t0, x) => {
421 const view = x < 0.5 ? 'right' : 'left';
422 key(t0, P(x, 'front', SECOND, 'plie'));
423 key(t0 + 0.4, P(x, view, RAISED[view], 'derriere'));
424 },
425 // A build: a tour en l'air, then up in passé (held while it builds).
426 build: (t0, x) => {
427 tour(t0, x, 1);
428 key(t0 + 1.1, P(x, 'front', FIFTH, 'passe', { spin: 360 }));
429 },
430 // git: the stagehand runs in with a ribbon on a wand, if Clawd has none,
431 // and Clawd twirls with it.
432 git: (t0, x) => {
433 let t = t0;
434 if (t0 > ribbonUntil) {
435 const side = x < 0.5 ? 1 : -1, dx = 16 * side, wing = side > 0 ? 1.2 : -0.2, sv = side > 0 ? 'right' : 'left';
436 walk(sadd, t0, wing, x, dx, 3, 0.16, ['out', 'up'], 2);
437 sadd(t0 + 0.6, M(x, dx, other(sv), BAS, 'plie'));
438 walk(sadd, t0 + 0.75, x, wing, dx, 3, 0.16, BAS, 2);
439 key(t0 + 0.15, P(x, sv, SECOND, 'stand'));
440 key(t0 + 0.55, P(x, sv, RAISED[sv], 'stand', { eyes: 'happy' }));
441 t = t0 + 0.7;
442 }
443 ribbonUntil = t + 12;
444 twirl(t, x, 2, 1, RAISED.right);
445 },
446 // A commit or a push: a révérence, sparkling.
447 commit: (t0, x) => {
448 const view = n % 2 ? 'left' : 'right';
449 key(t0, P(x, view, RAISED[view], 'derriere'));
450 reverence(t0 + 0.4, x, view, 0.8);
451 key(t0 + 1.5, P(x, 'front', SECOND, 'stand', { eyes: 'happy' }));
452 GLOWS.push([t0 + 0.8, t0 + 1.6]);
453 },
454 // The web: a messenger runs in with a letter, and Clawd jumps for it.
455 web: (t0, x) => {
456 const side = x < 0.5 ? 1 : -1, dx = 16 * side, wing = side > 0 ? 1.2 : -0.2, sv = side > 0 ? 'right' : 'left';
457 walk(sadd, t0, wing, x, dx, 3, 0.16, SECOND, 2);
458 sadd(t0 + 0.6, M(x, dx, other(sv), SECOND, 'plie'));
459 walk(sadd, t0 + 0.75, x, wing, dx, 3, 0.16, BAS, 2);
460 prop(LETTER, [[t0, 'sh'], [t0 + 0.55, 'cl'], [t0 + 0.85, 'up'], [t0 + 1.7, 'none']]);
461 key(t0 + 0.15, P(x, sv, SECOND, 'plie'));
462 key(t0 + 0.45, P(x, sv, FIFTH, 'stand', { air: 3 }));
463 key(t0 + 0.7, P(x, sv, SECOND, 'plie'));
464 key(t0 + 0.9, P(x, 'front', FIFTH, 'stand'));
465 key(t0 + 1.6, P(x, 'front', FIFTH, 'stand'));
466 },
467 // An MCP server: its partner walks on (once) and lifts Clawd; partnered
468 // already, a supported pirouette.
469 mcp: (t0, x, still, s) => {
470 let t = t0;
471 if (!partnerOn) {
472 partnerSide = x < 0.5 ? 1 : -1;
473 let hash = 0;
474 for (let i = 0; i < s.guest.length; i++) hash = (hash * 31 + s.guest.charCodeAt(i)) % 997;
475 partnerLook = { bare: true, breeches: PARTNERS[hash % PARTNERS.length], shoes: '#16120e' };
476 walk(padd, t0, partnerSide > 0 ? 1.3 : -0.3, x, 16 * partnerSide, 5, 0.22, SECOND, 2);
477 partnerOn = true;
478 t = t0 + 1.2;
479 key(t0 + 0.2, P(x, partnerSide > 0 ? 'right' : 'left', SECOND, 'stand'));
480 key(t, P(x, partnerSide > 0 ? 'right' : 'left', SECOND, 'stand', { eyes: 'happy' }));
481 }
482 const dx = 16 * partnerSide, pv = partnerSide > 0 ? 'left' : 'right';
483 const lifting = partnerSide > 0 ? ['high', 'down'] : ['down', 'high'];
484 if (t > t0) {
485 padd(t + 0.1, M(x, dx, pv, SECOND, 'plie'));
486 key(t + 0.1, P(x, 'front', SECOND, 'plie'));
487 key(t + 0.45, P(x, 'front', FIFTH, 'stand', { air: 6, eyes: 'happy' }));
488 padd(t + 0.45, M(x, dx, pv, lifting, 'stand'));
489 key(t + 1.3, P(x, 'front', FIFTH, 'stand', { air: 6, eyes: 'happy' }));
490 padd(t + 1.3, M(x, dx, pv, lifting, 'stand'));
491 key(t + 1.7, P(x, 'front', SECOND, 'plie', { eyes: 'happy' }));
492 padd(t + 1.7, M(x, dx, pv, SECOND, 'stand'));
493 } else {
494 padd(t0, M(x, dx, pv, SECOND, 'stand'));
495 twirl(t0, x, 2, 1);
496 padd(t0 + 1, M(x, dx, pv, SECOND, 'stand'));
497 }
498 },
499 // Subagents sent out: Clawd calls them on, the arms sweeping up.
500 agent: (t0, x) => seq(t0, x, 0.3, [['front', BAS, 'plie'], ['front', SECOND, 'stand'], ['front', FIFTH, 'stand', { eyes: 'happy' }], ['front', SECOND, 'stand']]),
501 // A skill: a quick balancé, right and left.
502 skill: (t0, x) => balances(t0, x, 2, SECOND, 0.5),
503};
504
505// News, first in the queue.
506const ANSWERS = {
507 // A new task: a preparation, glad to begin.
508 task: (t0, x) => seq(t0, x, 0.3, [['front', BAS, 'plie'], ['front', SECOND, 'stand'], ['front', FIFTH, 'stand', { eyes: 'happy' }], ['front', FIFTH, 'stand', { eyes: 'happy' }]]),
509 // A tool failed: a wobble off balance, and shaken off.
510 fail: (t0, x) => {
511 key(t0, P(x, 'front', SECOND, 'plie', { eyes: 'closed' }));
512 key(t0 + 0.3, P(x - 0.01, 'left', ['high', 'down'], 'plie'));
513 key(t0 + 0.6, P(x + 0.01, 'right', ['down', 'high'], 'plie'));
514 key(t0 + 0.9, P(x - 0.006, 'left', ['high', 'down'], 'plie'));
515 key(t0 + 1.2, P(x, 'front', BAS, 'stand', { eyes: 'closed' }));
516 key(t0 + 1.6, P(x, 'front', SECOND, 'stand'));
517 line(t0 + 0.3, t0 + 2, ['oops', 'encore!', 'from the top']);
518 },
519 // Tests or a build passed: a double tour, and sparkles.
520 pass: (t0, x) => {
521 tour(t0, x, 2);
522 key(t0 + 1.2, P(x, 'front', FIFTH, 'stand', { eyes: 'happy', spin: 720 }));
523 GLOWS.push([t0, t0 + 1.8]);
524 line(t0 + 0.8, t0 + 2, ['bravo!', 'ta-da!', 'brava!']);
525 },
526};
527
528// The pose Clawd holds between moves: for a tool still running, the
529// move's own; otherwise focused and still while thinking, a slow port de
530// bras while writing the reply, ready otherwise.
531function hold(t0, x, s) {
532 const view = x < 0.5 ? 'right' : 'left';
533 const o = other(view);
534 if (s.busy && s.move === 'test') {
535 // Tests: balances, each held a moment: arabesque, passé, the other side.
536 seq(t0 + 0.3, x, 0.9, [[view, RAISED[view], 'derriere'], ['front', FIFTH, 'passe'], [o, RAISED[o], 'derriere'], ['front', SECOND, 'stand']]);
537 } else if (s.busy && s.move === 'build') seq(t0 + 0.3, x, 0.7, [['front', FIFTH, 'passe'], ['front', FIFTH, 'stand', { air: 1 }], ['front', SECOND, 'plie'], ['front', FIFTH, 'passe']]);
538 else if (s.busy && s.move === 'sleep') {
539 key(t0 + 2, P(x, 'front', BAS, 'kneel', { eyes: 'closed' }));
540 fx(t0, t0 + 2, 'zzz');
541 } else if (s.act === 'agents' && s.corps > 0) {
542 // Leading the corps: a lively phrase in place for them to take up.
543 seq(t0 + 0.2, x, 0.4, [['front', SECOND, 'plie'], ['front', FIFTH, 'stand', { air: 3 }], ['front', SECOND, 'plie'], [view, RAISED[view], 'derriere'], ['front', BAS, 'plie'], ['front', SECOND, 'stand', { air: 3 }], ['front', BAS, 'plie'], [o, RAISED[o], 'derriere'], ['front', SECOND, 'stand']]);
544 }
545 else if (s.act === 'writing') seq(t0 + 0.3, x, 0.9, [['front', BAS, 'stand'], ['front', SECOND, 'stand'], ['front', FIFTH, 'stand', { eyes: 'happy' }], ['front', SECOND, 'stand']]);
546 else if (s.act === 'idle') key(t0 + 2, P(x, 'front', BAS, 'stand'));
547 else {
548 // Thinking: light tendus and élevés, unhurried, as at the barre.
549 const v = n % 2 ? 'left' : 'right';
550 seq(t0 + 0.3, x, 0.6, [['front', BAS, 'stand'], ['front', SECOND, 'stand', { air: 1 }], ['front', BAS, 'stand'], [v, ['down', 'out'], 'derriere'], [v, ['down', 'out'], 'stand'], [v, ['down', 'out'], 'derriere'], ['front', BAS, 'stand']]);
551 }
552}
553
554// The partner off again, a bow, while Clawd waves.
555function farewell(t0, x) {
556 const dx = 16 * partnerSide, wing = partnerSide > 0 ? 1.3 : -0.3, sv = partnerSide > 0 ? 'right' : 'left';
557 padd(t0, M(x, dx, other(sv), BAS, 'plie'));
558 walk(padd, t0 + 0.3, x, wing, dx, 5, 0.22, BAS, 0);
559 seq(t0, x, 0.3, [[sv, RAISED[sv], 'stand', { eyes: 'happy' }], [sv, SECOND, 'stand', { eyes: 'happy' }], [sv, RAISED[sv], 'stand', { eyes: 'happy' }], ['front', SECOND, 'stand']]);
560 partnerOn = false;
561}
562
563// The studio, as in Class: the mirror's frames, the barre, the floor, and
564// the piano with its metronome where there's room.
565function studio(floor) {
566 for (let y = 0; y < floor; y++) for (let x = 4; x < w - 4; x += 16) put(x, y, '│', '#3a3a50');
567 for (let x = 2; x < w - 2; x++) pixel(x, (floor - 4) * 2 + 1, '#b08860');
568 for (let x = 6; x < w - 4; x += 20) {
569 put(x, floor - 3, '│', '#806040');
570 put(x, floor - 2, '│', '#806040');
571 }
572 for (let x = 0; x < w; x++) put(x, floor, '▀', '#8a7a68', '#6a5c4c');
573}
574function piano(t, floor) {
575 const x0 = w - 12;
576 for (let i = 0; i < 9; i++) {
577 put(x0 + i, floor - 3, '█', '#4a2a1a');
578 put(x0 + i, floor - 2, '▀', i % 3 === 1 ? '#2a2420' : '#f0ece0', '#4a2a1a');
579 }
580 put(x0, floor - 1, '│', '#4a2a1a');
581 put(x0 + 8, floor - 1, '│', '#4a2a1a');
582 put(x0 + 6, floor - 4, '╱│╲│'[Math.floor(t * 4) % 4], '#e0c080');
583}
584
585// Where the corps stands: columns from the middle, 22 apart so tutus
586// don't merge (further out while a partner is on), as many as fit.
587function slots() {
588 const out = [];
589 const first = partnerOn ? 38 : 22;
590 for (let k = 0; k < 2; k++) for (const side of [1, -1]) {
591 const off = (first + 22 * k) * side;
592 if (Math.abs(off) + 11 <= (R - L) / 2 + 10) out.push(off);
593 }
594 return out;
595}
596const crew = [];
597// Each corps dancer's own moves, fired by its agent's tool calls: a turn
598// for a search, rising for a read, a jump for an edit, a hop otherwise.
599const CREW = [[], [], [], []];
600const CREW_KIND = { grep: 'turn', rg: 'turn', find: 'turn', ls: 'turn', pwd: 'turn', read: 'rise', cat: 'rise', head: 'rise', tail: 'rise', wc: 'rise', diff: 'rise', edit: 'jump', write: 'jump', rm: 'jump', mkdir: 'jump' };
601function crewMove(i, move, t) {
602 const track = CREW[i];
603 const add = (tt, p) => track.push([tt, p]);
604 track.length = 0;
605 const kind = CREW_KIND[move] || 'hop';
606 const v = i % 2 ? 'left' : 'right';
607 if (kind === 'turn') {
608 add(t, P(0, 'front', SECOND, 'plie'));
609 add(t + 0.1, P(0, 'front', FIFTH, 'passe', { spinning: true, spin: 0 }));
610 add(t + 0.6, P(0, 'front', FIFTH, 'passe', { spinning: true, spin: 360 }));
611 add(t + 0.75, P(0, 'front', SECOND, 'plie', { spin: 360 }));
612 } else if (kind === 'rise') {
613 add(t, P(0, 'front', BAS, 'plie'));
614 add(t + 0.25, P(0, 'front', FIFTH, 'stand', { air: 2 }));
615 add(t + 0.6, P(0, 'front', SECOND, 'stand'));
616 } else if (kind === 'jump') {
617 add(t, P(0, 'front', BAS, 'plie'));
618 add(t + 0.25, P(0, 'front', SECOND, 'stand', { air: 4 }));
619 add(t + 0.5, P(0, 'front', BAS, 'plie'));
620 add(t + 0.7, P(0, 'front', SECOND, 'stand'));
621 } else {
622 add(t, P(0, v, SECOND, 'plie'));
623 add(t + 0.25, P(0, v, FIFTH, 'passe', { air: 3 }));
624 add(t + 0.5, P(0, v, SECOND, 'plie'));
625 add(t + 0.7, P(0, 'front', SECOND, 'stand'));
626 }
627}
628// The corps: each bourrées on from its wing to its place, dances Clawd's
629// moves a beat behind, and bourrées off when its agent is done.
630function company(t, dt, s, ground, solo) {
631 const offs = slots();
632 const mid = (L + R) / 2;
633 const centred = Math.abs(solo.x - 0.5) < 0.08 && !solo.spinning;
634 for (let i = 0; i < 4; i++) {
635 // Just off stage, on the side of its place.
636 const off = (i % 2 ? -1 : 1) * ((R - L) / 2 + 24);
637 if (!crew[i]) crew[i] = { at: off, isIn: false };
638 const c = crew[i];
639 const isWanted = i < offs.length && i < s.corps && (c.isIn || centred);
640 const target = isWanted ? offs[i] : off;
641 const gap = target - c.at;
642 const moving = Math.abs(gap) > 0.5;
643 c.at += clamp(gap, -30 * dt, 30 * dt);
644 if (!moving) c.isIn = isWanted;
645 if (!isWanted && !moving) continue;
646 // Its own move while one runs, else Clawd's, a beat behind.
647 const own = CREW[i].length && CREW[i][CREW[i].length - 1][0] > t;
648 const q = moving ? bourree(P(0, gap > 0 ? 'right' : 'left', SECOND, 'stand'), t + i * 0.1) : beating(blinking(track(own ? CREW[i] : KEYS, own ? t : t - 0.2 * (i + 1)), t + i * 0.7), t);
649 dancer(q, Math.round(mid + c.at), ground, CORPS);
650 }
651}
652
653// Where a prop is: in the stagehand's hands, Clawd's, or held overhead.
654function holderAt(who, d, ds) {
655 if (who === 'cl') return [d.x - 3, d.top + 4];
656 if (who === 'up') return [d.x - 3, d.top - 6];
657 if (who === 'sh' && ds) return [ds.x - 3, ds.top + 4];
658 return null;
659}
660function props(t, d, ds) {
661 for (const pr of PROPS) {
662 let i = -1;
663 for (let j = 0; j < pr.keys.length; j++) if (pr.keys[j][0] <= t) i = j;
664 if (i < 0) continue;
665 const to = holderAt(pr.keys[i][1], d, ds);
666 if (!to) continue;
667 const from = i > 0 ? holderAt(pr.keys[i - 1][1], d, ds) : null;
668 const k = from ? ease(clamp((t - pr.keys[i][0]) / 0.3)) : 1;
669 const x = Math.round(from ? lerp(from[0], to[0], k) : to[0]);
670 const py = 2 * Math.round((from ? lerp(from[1], to[1], k) : to[1]) / 2);
671 pixels(x, py, pr.thing.art, pr.thing.colors);
672 }
673}
674// The hand that holds the wand: the raised one, else the right; as a cell.
675const REACH = { up: [3, -3], high: [7, 0], out: [7, 2], down: [6, 5] };
676function handOf(p, d) {
677 const lifted = a => a === 'up' || a === 'high';
678 const side = lifted(p.arms[1]) || !lifted(p.arms[0]) ? 1 : -1;
679 const reach = REACH[p.arms[side > 0 ? 1 : 0]] || REACH.out;
680 return [d.x + (side > 0 ? reach[0] : -reach[0] - 1), Math.floor((d.top + reach[1]) / 2)];
681}
682// The ribbon: a band half a cell thick from the wand (the person found a
683// whole cell too chunky and a box-drawing line too thin): circling Clawd
684// as it turns, streaming back along the hand's path as it moves,
685// fluttering out in an S, away from Clawd, when it's still. Lower half
686// blocks along, half-width blocks up and down and on a slant (on the side
687// of the cell before, so the band joins up and seems to twist): the lower
688// half is what every terminal draws solid (solidify draws everything so),
689// and a half-width block is whole-height.
690const STROKE = (dx, dy) => dy === 0 ? '▄' : dx === 0 ? '▐' : dx > 0 ? '▌' : '▐';
691// A path's corners joined up into a line of neighbouring cells.
692function densify(path, cap) {
693 const cells = [path[0]];
694 for (let i = 1; i < path.length && cells.length < cap; i++) {
695 const a = cells[cells.length - 1], b = path[i];
696 const steps = Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]));
697 for (let k = 1; k <= steps && cells.length < cap; k++) cells.push([Math.round(lerp(a[0], b[0], k / steps)), Math.round(lerp(a[1], b[1], k / steps))]);
698 }
699 return cells;
700}
701function ribbon(t, p, d) {
702 const hand = handOf(p, d);
703 TRAIL.push([hand[0], hand[1], t]);
704 while (TRAIL.length && TRAIL[0][2] < t - 0.6) TRAIL.splice(0, 1);
705 const path = [[hand[0], hand[1]]];
706 let isRound = false;
707 if (p.spinning) {
708 isRound = true;
709 const a = (p.spin || 0) * Math.PI / 180;
710 for (let j = 1; j < 12; j++) {
711 const b = a - j * 0.38;
712 path.push([d.x + Math.round(Math.sin(b) * 13), d.row + 2 - Math.round(Math.cos(b) * 2.4)]);
713 }
714 } else {
715 const first = TRAIL[0];
716 if (first && Math.abs(first[0] - hand[0]) + Math.abs(first[1] - hand[1]) > 2) for (let i = TRAIL.length - 2; i >= 0; i--) path.push([TRAIL[i][0], TRAIL[i][1]]);
717 // Out to the side of the hand, or away from a partner.
718 const side = partnerOn ? -partnerSide : hand[0] >= d.x ? 1 : -1;
719 const from = path[path.length - 1];
720 for (let j = 1; j <= 4; j++) path.push([from[0] + side * j * 3, hand[1] + Math.round(Math.sin(t * 3 - j * 1.1) * 0.9)]);
721 }
722 const cells = densify(path, isRound ? 30 : 20);
723 for (let i = 1; i < cells.length; i++) {
724 const a = cells[i - 1], b = cells[i];
725 if (b[1] < 0 || b[1] >= h - 1) continue;
726 put(b[0], b[1], STROKE(b[0] - a[0], b[1] - a[1]), mix(RIBBON, '#8a3a5a', i / cells.length));
727 }
728}
729// Where the dancer will stand this frame, as dancer() places it, for the
730// ribbon drawn behind everyone before the dancers are.
731function placeOf(p, cx, ground) {
732 let top = Math.round(ground - (p.legs === 'kneel' ? 5 : 9) - (p.air || 0));
733 if (mod(top, 2) === 1) top -= 1;
734 return { x: cx, row: Math.floor(top / 2), top: top };
735}
736// Little signs: thought bubbles rising while thinking, an idea, a count,
737// notes, z's, a paper ball kicked away, a spark, a spin's whirl.
738function signs(t, s, p, d, ground) {
739 const f = fxAt(t);
740 const sign = f ? f[2] : '';
741 const k = f ? clamp((t - f[0]) / (f[1] - f[0])) : 0;
742 const head = Math.max(0, d.row - 1);
743 if (holding && s.act === 'thinking') {
744 for (let i = 0; i < 3; i++) {
745 const a = fract(t * 0.45 + i / 3);
746 const row = Math.round(head - a * (head + 0.4));
747 if (row >= 0) put(d.x + 6 + Math.round(a * 4), row, a < 0.33 ? '·' : a < 0.66 ? 'o' : 'O', '#a8a0c8');
748 }
749 }
750 if (sign === 'idea') put(d.x, Math.max(0, d.row - 2), '!', '#ffe070');
751 if (sign === 'count') put(d.x + 8, head, String(Math.min(3, Math.floor(k * 3) + 1)), '#e8e0b0');
752 if (sign === 'notes') for (let i = 0; i < 3; i++) {
753 const a = fract(t * 0.8 + i / 3);
754 put(d.x + 5 + i * 3, Math.round(head - a * head), '♪♫♪'[i], mix('#be96d2', '#2a2a40', a));
755 }
756 if (sign === 'zzz') for (let i = 0; i < 3; i++) {
757 const a = fract(t * 0.5 + i / 3);
758 put(d.x + 6 + Math.round(a * 5), Math.round(d.row + 1 - a * (d.row + 1)), a < 0.5 ? 'z' : 'Z', '#a8b0d0');
759 }
760 if (sign === 'ball') pixels(d.x + f[3] * 12, ground - 1, 'ww', { w: '#e8e4d8' });
761 if (sign === 'toss') pixels(Math.round(d.x + f[3] * (12 + k * w)), 2 * Math.round((ground - 1 - Math.sin(Math.PI * k) * 10) / 2), 'ww\nww', { w: '#e8e4d8' });
762 if (sign === 'spark') put(d.x + f[3] * 13, Math.floor(ground / 2), '✦✧'[Math.floor(t * 6) % 2], '#ffe8a0');
763 // A whirl round a quick turn.
764 if (p.spinning) {
765 const c = Math.floor(t * 8) % 2 ? '#b0a090' : '#706458';
766 put(d.x - 11, d.row + 2, '(', c);
767 put(d.x + 10, d.row + 2, ')', c);
768 }
769}
770// What Claude is at while Clawd holds a pose, for the label.
771function stateOf(s) {
772 if (s.busy && s.move === 'test') return 'tests running';
773 if (s.busy && s.move === 'build') return 'building';
774 if (s.busy) return 'running';
775 if (s.act === 'writing') return 'writing the reply';
776 if (s.act === 'agents' && s.corps > 0) return 'agents at work';
777 if (s.act === 'idle') return '';
778 return 'thinking';
779}
780// The label, always in one place at the top left: a bright chip naming
781// what set off the move in hand (the newest), else a dim one saying what
782// Claude is at.
783function tag(t, s) {
784 let g = null;
785 for (const one of TAGS) if (t >= one[0] && t < one[1]) g = one;
786 const label = g ? g[2] : stateOf(s);
787 if (!label || w < 24) return;
788 const chip = ' ' + label.slice(0, w - 4) + ' ';
789 if (g) text(1, 0, chip, '#1e1e2a', '#f0d890');
790 else text(1, 0, chip, '#b8b0c8', '#34344a');
791}
792
793function frame(t, dt) {
794 const s = live();
795 const floor = h - 1;
796 const ground = floor * 2 - 1;
797 const hasPiano = w >= 60;
798 L = 12;
799 R = hasPiano ? w - 26 : w - 12;
800 // Each tool call queues its move; news goes first. Only the last two
801 // moves wait, so a burst of calls never leaves Clawd behind.
802 if (s.toolN !== seenTool) {
803 if (seenTool >= 0 && s.move) QUEUE.push({ move: s.move, label: s.label });
804 while (QUEUE.length > 2) QUEUE.splice(0, 1);
805 seenTool = s.toolN;
806 }
807 if (s.newsN !== seen) {
808 if (seen >= 0 && ANSWERS[s.news]) QUEUE.unshift({ news: s.news });
809 seen = s.newsN;
810 }
811 if (s.crewN !== seenCrew) {
812 if (seenCrew >= 0 && s.crewMove && s.crewWho >= 0 && s.crewWho < 4) crewMove(s.crewWho, s.crewMove, t);
813 seenCrew = s.crewN;
814 }
815 // A held pose gives way at once (and to news, whatever is dancing),
816 // unless Clawd is turning or in the air.
817 const visiting = (SK.length && SK[SK.length - 1][0] > t) || (PK.length && PK[PK.length - 1][0] > t);
818 const leaving = holding && partnerOn && !s.guest;
819 if (((QUEUE.length && (holding || QUEUE[0].news)) || leaving) && !visiting && t < until) {
820 const now = track(KEYS, t);
821 if (!now.spinning && now.air < 0.5 && now.x > 0 && now.x < 1) {
822 while (KEYS.length > 1 && KEYS[KEYS.length - 1][0] > t) KEYS.pop();
823 key(t, now);
824 for (const g of GLIDES) g[1] = Math.min(g[1], t);
825 for (const f of FX) if (f[2] === 'zzz') f[1] = Math.min(f[1], t);
826 until = t;
827 here = now.x;
828 }
829 }
830 const still = Math.min(slots().length, s.corps) > 0;
831 if (t >= until && !visiting) {
832 n++;
833 holding = false;
834 const next = QUEUE.length ? QUEUE[0] : null;
835 // Off in the wings (an ssh), Clawd leaps back on before anything else.
836 if (here < 0.03 || here > 0.97) jetes(t, here, clamp(here, 0.15, 0.85), 1);
837 else if (partnerOn && !s.guest) farewell(t, here);
838 else if (still && Math.abs(here - 0.5) > 0.04) jetes(t, here, 0.5, 1);
839 else if (next) {
840 QUEUE.splice(0, 1);
841 if (next.news) {
842 ANSWERS[next.news](t, here);
843 TAGS.push([t, t + 1.2, { task: 'new task', fail: 'failed', pass: 'passed' }[next.news]]);
844 } else {
845 (MOVES[next.move] || MOVES.run)(t, here, still, s);
846 TAGS.push([t, t + 1.2, next.label]);
847 }
848 } else {
849 holding = true;
850 hold(t, here, s);
851 }
852 const end = KEYS[KEYS.length - 1];
853 until = end[0];
854 here = end[1].x;
855 // A move's label shows for as long as the move.
856 const last = TAGS[TAGS.length - 1];
857 if (last && last[0] === t) last[1] = Math.max(until, t + 1.2);
858 }
859 // Only the last few seconds are kept: the corps' canon reads them.
860 for (const list of [KEYS, SK, PK]) while (list.length > 2 && list[1][0] < t - 4) list.splice(0, 1);
861 for (const list of [GLIDES, GLOWS, TAGS, LINES, FX]) while (list.length && list[0][1] < t - 4) list.splice(0, 1);
862 while (PROPS.length && PROPS[0].keys[PROPS[0].keys.length - 1][0] < t - 4) PROPS.splice(0, 1);
863 const at = p => Math.round(L + (R - L) * p.x + (p.dx || 0));
864 let p = beating(blinking(track(KEYS, t), t), t);
865 if (during(GLIDES, t)) p = bourree(p, t);
866 studio(floor);
867 if (hasPiano) piano(t, floor);
868 // The ribbon behind every dancer; the wand, in Clawd's hand, in front.
869 const hasRibbon = t < ribbonUntil;
870 if (hasRibbon) ribbon(t, p, placeOf(p, at(p), ground));
871 else TRAIL.length = 0;
872 company(t, dt, s, ground, p);
873 let ds;
874 if (SK.length) {
875 const q = blinking(track(SK, t), t + 0.5);
876 if (q.x > -0.2 && q.x < 1.2) ds = dancer(q, at(q), ground, STAGEHAND);
877 }
878 if (PK.length) {
879 const q = blinking(track(PK, t), t + 0.3);
880 if (q.x > -0.2 && q.x < 1.2) dancer(q, at(q), ground, partnerLook);
881 }
882 const d = dancer(p, at(p), ground, LOOK);
883 if (hasRibbon) {
884 const hand = handOf(p, d);
885 put(hand[0], Math.max(0, hand[1] - 1), '╿', '#e8d8b0');
886 }
887 props(t, d, ds);
888 signs(t, s, p, d, ground);
889 if (during(GLOWS, t) || p.air > 3.5) sparkles(t, d, floor, '#e8e0ff');
890 tag(t, s);
891 const said = saying(t, n);
892 if (said) speak(said, d);
893}
894`
895
896export const LIVE_CODE = bare(DANCER + COMMON + LIVE)
897
898export const LIVE_SCENE: Record<string, unknown> = {
899 concept: 'Clawd in the studio, each of its moves fired by something Claude does',
900 background: { effect: 'pulse', palette: ['#1e1e2a', '#24243a', '#2a2a40'], speed: 0.2, intensity: 0 },
901 actors: [],
902 particles: [],
903 code: LIVE_CODE,
904}
905
906// The live scene with the moment passed in. toons' loader reads only the
907// first 20,000 characters of a scene's code (a guard for scenes a model
908// wrote); the live scene is the plugin's own and longer, so it is parsed
909// whole here. Its program runs with the renderer's own globals (t, dt, w,
910// h) and the moment's added on every call, so the renderer stays toons'.
911// `extra` is code added after it: definitions there replace the scene's own
912// (the settings' `function tag() {}` and `function say() {}`).
913export function wire(script: Script, moment: () => Moment, extra = ''): Script {
914 if (!script.code) return script
915 const program = parseProgram(LIVE_CODE + extra)
916 script.code.error = undefined
917 script.code.program = {
918 start: (globals, budget, ms) => program.start({ ...globals, ...globalsOf(moment()) }, budget, ms),
919 call: (name, args, globals, budget, ms) => program.call(name, args, { ...globals, ...globalsOf(moment()) }, budget, ms),
920 }
921
922 return script
923}
924
925// The kind of work each move belongs to, for the scripts' made-up session.
926const ACT_OF: Partial<Record<Move, Act>> = { read: 'reading', cat: 'reading', head: 'reading', tail: 'reading', wc: 'reading', diff: 'reading', grep: 'searching', rg: 'searching', find: 'searching', ls: 'searching', pwd: 'searching', edit: 'editing', write: 'editing', rm: 'editing', mv: 'editing', cp: 'editing', mkdir: 'editing', chmod: 'editing', tar: 'editing', test: 'testing', build: 'building', git: 'git', commit: 'git', web: 'web', ssh: 'web', mcp: 'mcp', agent: 'agents' }
927
928// A made-up session for the scripts (play.ts --live, frames.ts --live,
929// check-frames): a tool call every 1.5 seconds, every move in turn, some
930// still running a while (tests, builds, sleep), a pause to think every so
931// often, news now and then, a guest and a corps coming and going.
932// A made-up session told as real tool calls, for the README's live GIF
933// (frames.ts --story), already under way when it starts: Odile turns 31
934// fouettés of her 32, and the fix overshoots to 33 before it lands. Each
935// call is read by actOf as live mode reads one, close enough to chain
936// (the person found it idle with pauses between), thinking only for a
937// story beat; git (and its ribbon) only comes in at the end, to commit. [seconds in, tool, input,
938// seconds it runs]
939export const STORY: [number, string, Record<string, unknown>, number][] = [
940 [0.6, 'Bash', { command: 'rg -n fouette ballets/' }, 0],
941 [1.5, 'Read', { file_path: '/repo/ballets/swan-lake/odile.ts' }, 0],
942 [2.4, 'Glob', { pattern: 'test/**/fouette*' }, 0],
943 [3.3, 'Read', { file_path: '/repo/test/fouettes.test.ts' }, 0],
944 [4.2, 'WebFetch', { url: 'https://en.wikipedia.org/wiki/Fouetté' }, 0],
945 [6.6, 'Edit', { file_path: '/repo/ballets/swan-lake/odile.ts' }, 0],
946 [7.5, 'Bash', { command: 'npm test' }, 1.8],
947 [10.8, 'Edit', { file_path: '/repo/ballets/swan-lake/odile.ts' }, 0],
948 [11.7, 'Bash', { command: 'npm test' }, 1.8],
949 [14.1, 'Write', { file_path: '/repo/test/odile-stops.test.ts' }, 0],
950 [15, 'Bash', { command: 'npm test' }, 1.6],
951 [17.2, 'Bash', { command: 'git status' }, 0],
952 [18.1, 'Bash', { command: 'git diff' }, 0],
953 [19, 'Bash', { command: 'git commit -am "Give Odile her 32nd fouette"' }, 0],
954 [19.9, 'Bash', { command: 'git push' }, 0],
955 [20.8, 'Bash', { command: 'open https://github.com/you/ballet/pull/32' }, 0],
956]
957// What came of it: the first test run failing, the second passing (the
958// task was set before the GIF starts).
959const STORY_NEWS: [number, News][] = [[9.3, 'fail'], [13.5, 'pass']]
960// When Claude turns to writing the reply.
961export const STORY_REPLY = 22.4
962
963export function storyAt(t: number): Moment {
964 let n = -1
965 for (let k = 0; k < STORY.length; k++) if (STORY[k]![0] <= t) n = k
966 const call = STORY[n]
967 const cue = call ? actOf(call[1], call[2]) : undefined
968 const isBusy = !!call && t < call[0] + call[3]
969 // A call's act holds a moment, or while it runs; then Claude thinks.
970 const isOn = !!call && t < call[0] + Math.max(1.2, call[3])
971 let news = -1
972 for (let k = 0; k < STORY_NEWS.length; k++) if (STORY_NEWS[k]![0] <= t) news = k
973
974 return {
975 act: t >= STORY_REPLY ? 'writing' : isOn && cue ? cue.act : 'thinking',
976 move: cue?.move ?? '',
977 label: cue?.label ?? '',
978 toolN: n + 1,
979 busy: isBusy,
980 news: news >= 0 ? STORY_NEWS[news]![1] : '',
981 newsN: news + 1,
982 corps: 0,
983 guest: '',
984 crewN: 0,
985 crewWho: 0,
986 crewMove: '',
987 }
988}
989
990export function demoAt(t: number): Moment {
991 const i = Math.floor(t / 1.5)
992 const pause = i % 9 === 8
993 const at = (j: number) => MOVES[(((j - Math.floor(j / 9)) % MOVES.length) + MOVES.length) % MOVES.length]!
994 const move = at(i)
995 const recent = (m: Move) => [0, 1, 2, 3].some(k => at(i - k) === m)
996 const news = i % 11 === 5 ? 'fail' : i % 11 === 10 ? 'pass' : i % 29 === 0 ? 'task' : ''
997
998 return {
999 act: pause ? 'thinking' : (ACT_OF[move] ?? 'running'),
1000 move: pause ? '' : move,
1001 label: pause ? '' : `demo ${move}`,
1002 toolN: pause ? i - 1 : i,
1003 busy: !pause && ['test', 'build', 'sleep'].includes(move) && t % 1.5 < 1.2,
1004 news,
1005 newsN: news ? i : 0,
1006 corps: recent('agent') ? 2 : 0,
1007 guest: at(i) === 'mcp' || at(i - 1) === 'mcp' ? 'demo server' : '',
1008 crewN: recent('agent') ? Math.floor(t / 0.7) : 0,
1009 crewWho: Math.floor(t / 0.7) % 2,
1010 crewMove: MOVES[Math.floor(t / 0.7) % MOVES.length]!,
1011 }
1012}
1013hooks/script.ts 1292 lines1// Scenes the buddy scripts: actors with frames of ASCII art, moved by small
2// math expressions of time, with speech, particle swarms and a faint backdrop.
3// The expressions run in an interpreter of their own that knows arithmetic and
4// a handful of functions, so a script can move things and nothing else.
5
6import {
7 CLEAR,
8 cleanBackground,
9 cleanText,
10 clamp,
11 dim,
12 encode,
13 hash,
14 mix,
15 paintBackground,
16 parseHex,
17 type Background,
18 type Rgb,
19} from './effects'
20import { ARMS, CLAWD_H, CLAWD_ORANGE, CLAWD_W, LOOK, clawdAnchors, clawdMesh, clawdPixels, clawdPoints, type Anchors, type Arm, type ClawdLook, type Eyes, type Pose } from './clawd'
21import { ScriptError, obj, parseProgram, type Program } from './lang'
22import {
23 DEFAULT_CAMERA,
24 DEFAULT_FOG,
25 DEFAULT_LIGHT,
26 MOST_TRIANGLES,
27 SX,
28 SY,
29 box,
30 claimPixel,
31 composite,
32 createFrame,
33 cylinder,
34 drawLine,
35 drawMesh,
36 drawPoint,
37 plane,
38 project,
39 resolve,
40 sphere,
41 type Camera,
42 type Fog,
43 type Frame,
44 type Light,
45 type Mesh,
46 type Transform,
47 type View,
48} from './render3d'
49
50type Env = { t: number; w: number; h: number; k: number; n: number }
51type Expr = (env: Env) => number
52
53const fract = (v: number) => v - Math.floor(v)
54const mod = (a: number, b: number) => (b === 0 ? 0 : ((a % b) + b) % b)
55
56const FUNCTIONS: Record<string, (...args: number[]) => number> = {
57 sin: Math.sin,
58 cos: Math.cos,
59 tan: Math.tan,
60 abs: Math.abs,
61 min: (...v) => Math.min(...v),
62 max: (...v) => Math.max(...v),
63 floor: Math.floor,
64 ceil: Math.ceil,
65 round: Math.round,
66 sqrt: v => Math.sqrt(Math.max(0, v)),
67 pow: Math.pow,
68 exp: Math.exp,
69 sign: Math.sign,
70 mod,
71 fract,
72 clamp: (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v)),
73 lerp: (a, b, k) => a + (b - a) * k,
74 // 0 up to 1 and back down over each unit of x.
75 tri: v => 1 - Math.abs(2 * fract(v) - 1),
76 // A steady pseudo-random number in [0, 1) for each x.
77 rand: v => hash(v, 17),
78 step: (edge, v) => (v >= edge ? 1 : 0),
79 // 1 while lo <= v < hi.
80 between: (v, lo, hi) => (v >= lo && v < hi ? 1 : 0),
81 // 0 below a, 1 above b, eased in between.
82 smoothstep: (a, b, v) => {
83 const k = Math.min(1, Math.max(0, b === a ? (v >= b ? 1 : 0) : (v - a) / (b - a)))
84
85 return k * k * (3 - 2 * k)
86 },
87 atan2: Math.atan2,
88 hypot: (...v) => Math.hypot(...v),
89 // Smooth wandering noise in [0, 1) along x.
90 noise: v => {
91 const i = Math.floor(v)
92 const k = fract(v)
93
94 return hash(i, 17) + (hash(i + 1, 17) - hash(i, 17)) * k * k * (3 - 2 * k)
95 },
96}
97
98const VARIABLES = new Set(['t', 'w', 'h', 'k', 'n'])
99
100// Compiles an expression to a function of the scene's variables; anything it
101// cannot read compiles to a constant 0 rather than failing the scene.
102export function compile(source: string): Expr {
103 const tokens = source.slice(0, 240).match(/\d+\.?\d*|\.\d+|[a-z_]+|[-+*/%^(),]/gi) ?? []
104 let at = 0
105 const peek = () => tokens[at]
106 const take = () => tokens[at++]
107 type Node = Expr
108
109 function primary(): Node {
110 const token = take()
111 if (token === undefined) throw new Error('end')
112 if (token === '(') {
113 const inner = sum()
114 if (take() !== ')') throw new Error(')')
115
116 return inner
117 }
118 if (token === '-') {
119 const inner = power()
120
121 return env => -inner(env)
122 }
123 if (token === '+') return power()
124 if (/^[\d.]/.test(token)) {
125 const value = Number(token)
126
127 return () => value
128 }
129 const name = token.toLowerCase()
130 if (name === 'pi') return () => Math.PI
131 if (VARIABLES.has(name)) return env => env[name as keyof Env]
132 const fn = FUNCTIONS[name]
133 if (!fn || take() !== '(') throw new Error(name)
134 const args: Node[] = []
135 if (peek() !== ')') {
136 args.push(sum())
137 while (peek() === ',') {
138 take()
139 args.push(sum())
140 }
141 }
142 if (take() !== ')') throw new Error(')')
143
144 return env => fn(...args.map(arg => arg(env)))
145 }
146 function power(): Node {
147 const base = primary()
148 if (peek() !== '^') return base
149 take()
150 const exponent = power()
151
152 return env => Math.pow(base(env), exponent(env))
153 }
154 function product(): Node {
155 let left = power()
156 while (peek() === '*' || peek() === '/' || peek() === '%') {
157 const op = take()
158 const right = power()
159 const l = left
160 left = op === '*' ? env => l(env) * right(env) : op === '/' ? env => l(env) / right(env) : env => mod(l(env), right(env))
161 }
162
163 return left
164 }
165 function sum(): Node {
166 let left = product()
167 while (peek() === '+' || peek() === '-') {
168 const op = take()
169 const right = product()
170 const l = left
171 left = op === '+' ? env => l(env) + right(env) : env => l(env) - right(env)
172 }
173
174 return left
175 }
176 try {
177 const expr = sum()
178 if (at !== tokens.length) return () => 0
179
180 return env => {
181 const v = expr(env)
182
183 return Number.isFinite(v) ? v : 0
184 }
185 } catch {
186 return () => 0
187 }
188}
189
190export type Actor = {
191 // A sprite of the script's own frames, or Clawd, whom the stage draws.
192 kind: 'sprite' | 'clawd'
193 frames: string[][]
194 fps: number
195 x: Expr
196 y: Expr
197 color: string
198 say: string
199 // When the speech shows: seconds into the scene.
200 sayAt: number
201 // Drawn only while this is above 0; always when absent.
202 show?: Expr
203 // Which frame shows (wrapped to the frame count); by fps when absent.
204 frame?: Expr
205}
206
207export type Swarm = { glyphs: string[]; count: number; x: Expr; y: Expr; color: string }
208
209// A scene's own code: a program with a frame(t, dt) function that draws on
210// the strip every frame, its state kept between frames.
211export type Code = {
212 program?: Program
213 isStarted: boolean
214 lastT: number
215 // Why the code stopped (or never parsed), for the narrator to hear.
216 error?: string
217 // Whether the narrator has heard about the error.
218 isReported?: boolean
219 // When each line the code has Clawd say first showed, for the typing.
220 said: Map<string, number>
221 // Where each line's bubble sat last, so it does not hop about.
222 bubblesAt: Map<string, { x: number; y: number }>
223 // The 3D settings, kept between frames once the code sets them.
224 cam?: Camera
225 light?: Light
226 fog?: Fog
227}
228
229export type Script = {
230 background: Background
231 actors: Actor[]
232 swarms: Swarm[]
233 code?: Code
234}
235
236// The fuel the code's setup and each frame may burn: statements, loop turns
237// and calls. A frame over budget stops the code, not the strip.
238const SETUP_FUEL = 1_000_000
239const FRAME_FUEL = 150_000
240// Fuel bounds the code's own steps; this bounds the drawing it asks for.
241const FRAME_MS = 80
242// The setup may build a world; it gets longer, once.
243const SETUP_MS = 500
244
245function cleanCode(raw: unknown): Code | undefined {
246 if (typeof raw !== 'string' || !raw.trim()) return undefined
247 const code: Code = { isStarted: false, lastT: 0, said: new Map(), bubblesAt: new Map() }
248 try {
249 code.program = parseProgram(raw.slice(0, 20_000))
250 } catch (e) {
251 code.error = e instanceof Error ? e.message : String(e)
252 }
253
254 return code
255}
256
257// For the quarter blocks U+2596 to U+259F, which halves (1 top, 2 bottom)
258// hold ink: ▖▗▘▙▚▛▜▝▞▟.
259const QUARTER_HALVES = [2, 2, 1, 3, 3, 3, 3, 1, 3, 3]
260
261// Characters a sprite may hold: what one terminal cell draws at one width.
262const DRAWABLE = /[\x21-\x7e¡-ÿ←-⇿─-◿⠀-⣿★☆♥♦♣♠♪♫☺☻✓✗✦✧]/
263
264const glyphsOf = (line: string) => [...line].map(c => (c === ' ' || DRAWABLE.test(c) ? c : ' '))
265
266const isColor = (c: unknown): c is string => typeof c === 'string' && /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim())
267
268export function cleanScript(raw: unknown): Script | undefined {
269 if (typeof raw !== 'object' || raw === null) return undefined
270 const s = raw as Record<string, unknown>
271 const background = cleanBackground(s.background)
272 if (!background) return undefined
273 const actors: Actor[] = (Array.isArray(s.actors) ? s.actors : []).slice(0, 20).flatMap(a => {
274 if (typeof a !== 'object' || a === null) return []
275 const o = a as Record<string, unknown>
276 const kind = o.kind === 'clawd' ? 'clawd' : 'sprite'
277 const frames = (Array.isArray(o.frames) ? o.frames : [])
278 .filter((f): f is string => typeof f === 'string')
279 .slice(0, 12)
280 .map(f => f.split('\n').slice(0, 9).map(line => glyphsOf(line.slice(0, 80)).join('')))
281 if (kind === 'sprite' && frames.length === 0) return []
282
283 return [
284 {
285 kind,
286 frames: kind === 'clawd' ? [CLAWD_BOX] : frames,
287 fps: clamp(typeof o.fps === 'number' ? o.fps : 4, 0, 20),
288 x: compile(typeof o.x === 'string' ? o.x : '0'),
289 y: compile(typeof o.y === 'string' ? o.y : '0'),
290 color: isColor(o.color) ? o.color : '#d97757',
291 say: cleanText(typeof o.say === 'string' ? o.say : '', 70),
292 sayAt: clamp(typeof o.sayAt === 'number' ? o.sayAt : 0.5, 0, 30),
293 show: typeof o.show === 'string' && o.show.trim() ? compile(o.show) : undefined,
294 frame: typeof o.frame === 'string' && o.frame.trim() ? compile(o.frame) : undefined,
295 },
296 ]
297 })
298 const swarms: Swarm[] = (Array.isArray(s.particles) ? s.particles : []).slice(0, 6).flatMap(p => {
299 if (typeof p !== 'object' || p === null) return []
300 const o = p as Record<string, unknown>
301 const glyphs = glyphsOf(typeof o.glyphs === 'string' ? o.glyphs : '*').filter(c => c !== ' ').slice(0, 16)
302
303 return [
304 {
305 glyphs: glyphs.length > 0 ? glyphs : ['*'],
306 count: Math.round(clamp(typeof o.count === 'number' ? o.count : 10, 0, 120)),
307 x: compile(typeof o.x === 'string' ? o.x : 'rand(k)*w'),
308 y: compile(typeof o.y === 'string' ? o.y : 'rand(k+7)*h'),
309 color: isColor(o.color) ? o.color : '#ffffff',
310 },
311 ]
312 })
313
314 return { background, actors, swarms, code: cleanCode(s.code) }
315}
316
317// For layout Clawd fills a 14 by 4 cell box (its 8 pixel rows).
318const CLAWD_BOX = Array.from({ length: CLAWD_H / 2 }, () => '█'.repeat(CLAWD_W))
319
320// What the scene's code draws through: set while its frame runs.
321type Canvas = {
322 cols: number
323 rows: number
324 code: Code
325 put: (col: number, row: number, char: string, color: Rgb, back?: Rgb) => void
326 clear: (col: number, row: number) => void
327 // Marks a fill as under way: its cells do not count as drawn on.
328 fill: (on: boolean) => void
329 // One pixel: a column and a pixel row (two to a cell).
330 pixel: (col: number, py: number, color: Rgb) => void
331 // Clawd with its top-left at a column and a pixel row; where things attach.
332 clawd: (x: number, py: number, look: ClawdLook) => Anchors
333 // One pixel in the 3D world: drawn where nothing nearer has been.
334 pixel3d: (col: number, py: number, color: Rgb, depth: number) => void
335 // The cells a Clawd drawn some other way covers, for its speech.
336 mark: (box: Box) => void
337 say: (text: string, x: number, y: number, color: Rgb) => void
338 // The frame's 3D world, made on first use; drawing in it is noted.
339 scene3d: (isDrawing?: boolean) => { frame: Frame; view: View; light: Light; fog: Fog }
340}
341let canvas: Canvas | undefined
342
343// A color from the code: 0xRRGGBB, "#rrggbb" or "#rgb". A string is parsed
344// once and remembered, since a fill names the same one for every cell.
345const COLORS = new Map<string, Rgb>()
346const colorOf = (c: unknown, fallback: Rgb = [255, 255, 255]): Rgb => {
347 if (typeof c === 'number' && Number.isFinite(c)) return [(c >> 16) & 255, (c >> 8) & 255, c & 255]
348 if (typeof c !== 'string') return fallback
349 let rgb = COLORS.get(c)
350 if (!rgb) {
351 if (!isColor(c)) return fallback
352 if (COLORS.size >= 512) COLORS.clear()
353 rgb = parseHex(c)
354 COLORS.set(c, rgb)
355 }
356
357 return [rgb[0], rgb[1], rgb[2]]
358}
359// Text from the code: a plain object (which has no toString in the sandbox)
360// or an array reads as it would in JavaScript rather than throwing.
361const strOf = (v: unknown): string =>
362 v === null || v === undefined ? '' : typeof v === 'object' ? (Array.isArray(v) ? v.slice(0, 400).map(strOf).join(',') : '[object Object]') : String(v)
363const packRgb = (r: number, g: number, b: number) =>
364 (clamp(Math.round(r), 0, 255) << 16) | (clamp(Math.round(g), 0, 255) << 8) | clamp(Math.round(b), 0, 255)
365const num = (v: unknown, fallback = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
366// One drawable character, or undefined.
367const charOf = (c: unknown) => {
368 const cp = strOf(c).codePointAt(0)
369 const first = cp === undefined ? undefined : String.fromCodePoint(cp)
370
371 return first === ' ' || (first && DRAWABLE.test(first)) ? first : undefined
372}
373
374// The functions the scene's code draws with.
375const API = {
376 put: (x: unknown, y: unknown, ch: unknown, color?: unknown, bg?: unknown) => {
377 const char = charOf(ch)
378 if (char) canvas?.put(Math.round(num(x)), Math.round(num(y)), char, colorOf(color), bg === undefined ? undefined : colorOf(bg))
379 },
380 text: (x: unknown, y: unknown, str: unknown, color?: unknown, bg?: unknown) => {
381 const col = Math.round(num(x))
382 const row = Math.round(num(y))
383 ;[...strOf(str)].slice(0, 400).forEach((c, i) => API.put(col + i, row, charOf(c) ?? ' ', color, bg))
384 },
385 // Multi-line art, solid inside its outline like a sprite actor.
386 sprite: (x: unknown, y: unknown, art: unknown, color?: unknown, bg?: unknown) => {
387 const col = Math.round(num(x))
388 const row = Math.round(num(y))
389 strOf(art).split('\n').slice(0, 40).forEach((line, dy) => {
390 const chars = [...line.slice(0, 300)]
391 const start = chars.findIndex(c => c !== ' ')
392 if (start < 0) return
393 const end = Math.min(chars.length, line.trimEnd().length)
394 for (let dx = start; dx < end; dx++) {
395 const c = charOf(chars[dx]) ?? ' '
396 if (c === ' ' && bg === undefined) canvas?.clear(col + dx, row + dy)
397 else API.put(col + dx, row + dy, c, color, bg)
398 }
399 })
400 },
401 fill: (x: unknown, y: unknown, w: unknown, h: unknown, ch?: unknown, color?: unknown, bg?: unknown) => {
402 const x0 = Math.round(num(x))
403 const y0 = Math.round(num(y))
404 canvas?.fill(true)
405 for (let row = y0; row < y0 + Math.min(60, Math.round(num(h))); row++) {
406 for (let col = x0; col < x0 + Math.min(400, Math.round(num(w))); col++) API.put(col, row, ch ?? ' ', color, bg)
407 }
408 canvas?.fill(false)
409 },
410 line: (x0: unknown, y0: unknown, x1: unknown, y1: unknown, ch?: unknown, color?: unknown) => {
411 const ax = num(x0)
412 const ay = num(y0)
413 const steps = Math.min(800, Math.ceil(Math.max(Math.abs(num(x1) - ax), Math.abs(num(y1) - ay))) || 0)
414 for (let i = 0; i <= steps; i++) {
415 const k = steps === 0 ? 0 : i / steps
416 API.put(ax + (num(x1) - ax) * k, ay + (num(y1) - ay) * k, ch ?? '*', color)
417 }
418 },
419 // A circle of radius r rows, twice as wide in columns so it looks round.
420 circle: (cx: unknown, cy: unknown, r: unknown, ch?: unknown, color?: unknown) => {
421 const radius = Math.min(100, Math.abs(num(r)))
422 const steps = Math.max(8, Math.ceil(radius * 12))
423 for (let i = 0; i < steps; i++) {
424 const a = (i / steps) * Math.PI * 2
425 API.put(num(cx) + Math.cos(a) * radius * 2, num(cy) + Math.sin(a) * radius, ch ?? 'o', color)
426 }
427 },
428 disc: (cx: unknown, cy: unknown, r: unknown, ch?: unknown, color?: unknown, bg?: unknown) => {
429 const radius = Math.min(60, Math.abs(num(r)))
430 for (let dy = -Math.ceil(radius); dy <= Math.ceil(radius); dy++) {
431 for (let dx = -Math.ceil(radius * 2); dx <= Math.ceil(radius * 2); dx++) {
432 if ((dx / 2) ** 2 + dy ** 2 <= radius ** 2) API.put(Math.round(num(cx)) + dx, Math.round(num(cy)) + dy, ch ?? '█', color, bg)
433 }
434 }
435 },
436 // Clawd at x, y (top-left; y in rows, halves allowed), with either the
437 // short form (facing, stride, blink, color) or an options object.
438 clawd: (x: unknown, y: unknown, facing?: unknown, stride?: unknown, blink?: unknown, color?: unknown) => {
439 const look = lookOf(facing, stride, blink, color)
440
441 return anchorsOut(canvas?.clawd(Math.round(num(x)), Math.round(num(y) * 2), look), Math.round(num(x)), Math.round(num(y) * 2))
442 },
443 pixel: (x: unknown, py: unknown, color?: unknown) => {
444 canvas?.pixel(Math.round(num(x)), Math.round(num(py)), colorOf(color))
445 },
446 // Pixel art: lines of characters, each mapped to a color by the palette
447 // ("." and " " are transparent; an unmapped character is white).
448 pixels: (x: unknown, py: unknown, art: unknown, palette?: unknown) => {
449 const col = Math.round(num(x))
450 const row = Math.round(num(py))
451 const map = typeof palette === 'object' && palette !== null ? (palette as Record<string, unknown>) : {}
452 strOf(art).split('\n').slice(0, 80).forEach((line, dy) => {
453 ;[...line.slice(0, 300)].forEach((c, dx) => {
454 if (c === ' ' || c === '.') return
455 canvas?.pixel(col + dx, row + dy, colorOf(map[c]))
456 })
457 })
458 },
459 // The 3D world: a camera (eye, target, the angle it sees across in degrees),
460 // one light (the direction toward it, and ambient 0 to 1), and fog that
461 // fades things out between two depths. Each keeps until set again.
462 camera: (ex: unknown, ey: unknown, ez: unknown, tx?: unknown, ty?: unknown, tz?: unknown, fov?: unknown) => {
463 if (canvas) canvas.code.cam = { eye: [num(ex), num(ey, 2.5), num(ez, 9)], target: [num(tx), num(ty, 1), num(tz)], fov: num(fov, 90) }
464 },
465 light: (dx: unknown, dy: unknown, dz: unknown, ambient?: unknown) => {
466 if (canvas) canvas.code.light = { dir: [num(dx, 0.4), num(dy, 1), num(dz, 0.6)], ambient: clamp(num(ambient, 0.35)) }
467 },
468 fog: (near: unknown, far: unknown, color?: unknown) => {
469 if (canvas) canvas.code.fog = { near: num(near, 8), far: Math.max(num(near, 8) + 0.01, num(far, 40)), color: colorOf(color, [0, 0, 0]) }
470 },
471 box: (w: unknown, h: unknown, d: unknown) => meshOut('box', [num(w, 1), num(h, 1), num(d, 1)], (a, b, c) => box(a, b, c)),
472 sphere: (r: unknown, segments?: unknown) => meshOut('sphere', [num(r, 1), num(segments, 8)], (a, b) => sphere(a, b)),
473 cylinder: (r: unknown, h: unknown, segments?: unknown) => meshOut('cylinder', [num(r, 1), num(h, 1), num(segments, 8)], (a, b, c) => cylinder(a, a, b, c)),
474 cone: (r: unknown, h: unknown, segments?: unknown) => meshOut('cone', [num(r, 1), num(h, 1), num(segments, 8)], (a, b, c) => cylinder(a, 0, b, c)),
475 plane: (w: unknown, d: unknown) => meshOut('plane', [num(w, 1), num(d, 1)], (a, b) => plane(a, b)),
476 // Draws a mesh placed by its options: x, y, z; rx, ry, rz (radians);
477 // scale (one number or [sx, sy, sz]); color; wire (edges only); unlit.
478 mesh3d: (mesh: unknown, options?: unknown) => {
479 if (!canvas) return
480 const m = meshIn(mesh)
481 if (!m) return
482 const o = (typeof options === 'object' && options !== null ? options : {}) as Record<string, unknown>
483 const sc = Array.isArray(o.scale) ? o.scale.map(v => num(v, 1)) : [num(o.scale, 1), num(o.scale, 1), num(o.scale, 1)]
484 const t: Transform = {
485 at: [num(o.x), num(o.y), num(o.z)],
486 rot: [num(o.rx), num(o.ry), num(o.rz)],
487 scale: [sc[0] ?? 1, sc[1] ?? 1, sc[2] ?? 1],
488 }
489 const { frame, view, light, fog } = canvas.scene3d()
490 drawMesh(frame, view, light, fog, m, t, { color: colorOf(o.color), wire: Boolean(o.wire), unlit: Boolean(o.unlit), ascii: Boolean(o.ascii) })
491 },
492 line3d: (x0: unknown, y0: unknown, z0: unknown, x1: unknown, y1: unknown, z1: unknown, color?: unknown) => {
493 if (!canvas) return
494 const { frame, view, fog } = canvas.scene3d()
495 drawLine(frame, view, fog, [num(x0), num(y0), num(z0)], [num(x1), num(y1), num(z1)], colorOf(color))
496 },
497 point3d: (x: unknown, y: unknown, z: unknown, color?: unknown) => {
498 if (!canvas) return
499 const { frame, view, fog } = canvas.scene3d()
500 drawPoint(frame, view, fog, [num(x), num(y), num(z)], colorOf(color))
501 },
502 // Where a world point lands: x and y in cells, px and py in pixels, its
503 // depth, and the pixels one world unit covers there; null behind the camera.
504 project: (x: unknown, y: unknown, z: unknown) => {
505 if (!canvas) return null
506 const { view } = canvas.scene3d(false)
507 const p = project(view, [num(x), num(y), num(z)])
508
509 return p
510 ? obj({ x: Math.round(p.x / SX), y: Math.round(p.y / SY), px: Math.round(p.x / SX), py: Math.round(p.y / (SY / 2)), depth: p.depth, scale: view.f / p.depth / SX })
511 : null
512 },
513 // Clawd standing at a world point (its feet there), facing the camera: a
514 // lit solid sized by distance (`size` is its height in world units, 0.9 by
515 // default), its eyes flat pixel art on its face. Takes the same options as
516 // clawd and returns the same anchors, or null when it is behind the camera.
517 clawd3d: (x: unknown, y: unknown, z: unknown, options?: unknown) => {
518 if (!canvas) return null
519 const { frame, view, light, fog } = canvas.scene3d()
520 const at: [number, number, number] = [num(x), num(y), num(z)]
521 const p = project(view, at)
522 if (!p) return null
523 const o = (typeof options === 'object' && options !== null ? options : {}) as Record<string, unknown>
524 const look = lookOf(o)
525 // Pixel rows per world unit at its depth; its height is capped so it is
526 // never taller than most of the strip: Clawd is small in a big world.
527 const perUnit = view.f / p.depth / (SY / 2)
528 const size = Math.min(num(o.size, 0.9), (canvas.rows * 2 * 0.7) / perUnit)
529 // World units per sprite pixel, and strip pixels per sprite pixel.
530 const u = size / CLAWD_H
531 const s = (size * perUnit) / CLAWD_H
532 // Its feet at the point, unless that puts them below the strip: a Clawd
533 // near the camera stands on the bottom edge rather than out of view.
534 const below = p.y / (SY / 2) - canvas.rows * 2
535 if (below > 0) at[1] += below / perUnit
536 // It faces the camera.
537 const ry = Math.atan2(view.eye[0] - at[0], view.eye[2] - at[2])
538 const sin = Math.sin(ry)
539 const cos = Math.cos(ry)
540 const place = (q: { x: number; y: number; z: number }) => {
541 const w = project(view, [at[0] + (q.x * cos + q.z * sin) * u, at[1] + q.y * u, at[2] + (q.z * cos - q.x * sin) * u])
542
543 return w ? { x: w.x / SX, py: w.y / (SY / 2), depth: w.depth } : undefined
544 }
545 drawMesh(frame, view, light, fog, clawdMesh(look), { at, rot: [0, ry, 0], scale: [u, u, u] }, { color: look.color, wire: false, unlit: false, ascii: false })
546 const points = clawdPoints(look)
547 const feet = place(points.feet)
548 if (!feet) return null
549 // The eyes: whole pixels at a size that steps with distance, so they
550 // never smear with perspective, and none on a Clawd too small for them.
551 // Both sit on one row, from the point between them: projected apart, a
552 // Clawd off the camera's axis under a high camera gets one eye lower
553 // than the other, which reads as lopsided rather than as a turned face.
554 const ew = Math.max(1, Math.round(s))
555 const [one, two] = points.eyes
556 const between = one && two ? place({ x: (one.x + two.x) / 2, y: (one.y + two.y) / 2, z: (one.z + two.z) / 2 }) : undefined
557 if (s * CLAWD_H >= 3 && between) {
558 for (const eye of points.eyes) {
559 const e = place(eye)
560 if (!e) continue
561 const wide = eye.kind === 'open' ? ew : 2 * ew
562 const col0 = Math.round(e.x - wide / 2)
563 const row0 = 2 * Math.round((between.py - ew) / 2)
564 const from = eye.kind === 'shut' ? row0 + ew : row0
565 for (let prow = from; prow < row0 + 2 * ew; prow++) for (let col = col0; col < col0 + wide; col++) canvas.pixel3d(col, prow, look.eyeColor, e.depth * 0.98)
566 }
567 }
568 const w = Math.round(CLAWD_W * s)
569 const h = Math.round(CLAWD_H * s)
570 const left = Math.round(feet.x - w / 2)
571 const top = Math.round(feet.py - h)
572 canvas.mark({ x0: left - 1, y0: Math.floor(top / 2), x1: left + w, y1: Math.floor((top + h - 1) / 2) })
573 const anchor = (q: { x: number; y: number; z: number }) => {
574 const e = place(q) ?? feet
575 const py = Math.round(e.py)
576
577 return obj({ x: Math.round(e.x), py, row: Math.floor(py / 2) })
578 }
579
580 return obj({
581 x: left,
582 py: top,
583 row: Math.floor(top / 2),
584 w,
585 h,
586 top: anchor(points.top),
587 left: anchor(points.left),
588 right: anchor(points.right),
589 feet: anchor(points.feet),
590 eyes: obj({ ...anchor(points.eyeSpan), w: Math.round(points.eyeSpan.w * s) }),
591 })
592 },
593 // A speech bubble pointing at x, y.
594 say: (text: unknown, x: unknown, y: unknown, color?: unknown) => {
595 const line = cleanText(strOf(text), 70)
596 if (line) canvas?.say(line, Math.round(num(x)), Math.round(num(y)), colorOf(color, [217, 119, 87]))
597 },
598 rgb: (r: unknown, g: unknown, b: unknown) => packRgb(num(r), num(g), num(b)),
599 // Hue in degrees, saturation and lightness 0 to 1.
600 hsl: (hue: unknown, sat: unknown, light: unknown) => {
601 const hh = (((num(hue) % 360) + 360) % 360) / 60
602 const ss = clamp(num(sat))
603 const ll = clamp(num(light))
604 const c = (1 - Math.abs(2 * ll - 1)) * ss
605 const x = c * (1 - Math.abs((hh % 2) - 1))
606 const [r, g, b] = hh < 1 ? [c, x, 0] : hh < 2 ? [x, c, 0] : hh < 3 ? [0, c, x] : hh < 4 ? [0, x, c] : hh < 5 ? [x, 0, c] : [c, 0, x]
607 const m = ll - c / 2
608
609 return packRgb((r + m) * 255, (g + m) * 255, (b + m) * 255)
610 },
611 mix: (a: unknown, b: unknown, k: unknown) => {
612 const [r, g, bb] = mix(colorOf(a), colorOf(b), clamp(num(k)))
613
614 return packRgb(r, g, bb)
615 },
616}
617
618// Clawd's look from a call's arguments: an options object, or the short
619// form's facing, stride, blink and color.
620function lookOf(facing: unknown, stride?: unknown, blink?: unknown, color?: unknown): ClawdLook {
621 if (typeof facing === 'object' && facing !== null) {
622 const o = facing as Record<string, unknown>
623 const poses: Pose[] = ['stand', 'walk', 'jump', 'sit']
624 const eyes: Eyes[] = ['open', 'closed', 'wide']
625 const strideIn = num(o.stride, -1)
626 const pose = poses.includes(o.pose as Pose) ? (o.pose as Pose) : strideIn >= 0 ? 'walk' : 'stand'
627 const lookIn = (typeof o.look === 'object' && o.look !== null ? o.look : {}) as Record<string, unknown>
628 // arms: one pose for both, or {left, right}.
629 const armOf = (v: unknown, fallback: Arm): Arm => (ARMS.includes(v as Arm) ? (v as Arm) : fallback)
630 const armsIn = o.arms
631 const arms =
632 typeof armsIn === 'object' && armsIn !== null
633 ? { left: armOf((armsIn as Record<string, unknown>).left, 'up'), right: armOf((armsIn as Record<string, unknown>).right, 'up') }
634 : { left: armOf(armsIn, 'up'), right: armOf(armsIn, 'up') }
635
636 return {
637 pose,
638 arms,
639 stride: Math.round(Math.max(0, strideIn)),
640 facing: Math.sign(num(o.facing)),
641 eyes: eyes.includes(o.eyes as Eyes) ? (o.eyes as Eyes) : 'open',
642 look: { x: num(lookIn.x, num(o.lookX)), y: num(lookIn.y, num(o.lookY)) },
643 isBlinking: Boolean(o.blink),
644 color: colorOf(o.color, CLAWD_ORANGE),
645 eyeColor: colorOf(o.eyeColor, LOOK.eyeColor),
646 scale: clamp(Math.round(num(o.scale, 1)), 1, 4),
647 }
648 }
649 const strideIn = Math.round(num(stride, -1))
650
651 return {
652 ...LOOK,
653 pose: strideIn >= 0 ? 'walk' : 'stand',
654 stride: Math.max(0, strideIn),
655 facing: Math.sign(num(facing)),
656 isBlinking: Boolean(blink),
657 color: colorOf(color, CLAWD_ORANGE),
658 }
659}
660
661// Anchors as the code reads them: absolute pixel positions.
662function anchorsOut(a: Anchors | undefined, left: number, top: number) {
663 if (!a) return null
664 const at = (p: { x: number; y: number }) => obj({ x: left + p.x, py: top + p.y, row: Math.floor((top + p.y) / 2) })
665
666 return obj({
667 x: left,
668 py: top,
669 row: Math.floor(top / 2),
670 w: a.w,
671 h: a.h,
672 top: at(a.top),
673 left: at(a.left),
674 right: at(a.right),
675 feet: at(a.feet),
676 eyes: obj({ ...at(a.eyes), w: a.eyes.w }),
677 })
678}
679
680// A primitive the code asked for, the same object for the same arguments:
681// code that builds sphere(1, 48) inside frame() (the natural way to write
682// it) would otherwise read and prepare a fresh mesh every frame. Its arrays
683// are frozen, so a change to one cannot leave its prepared normals stale.
684const PRIMITIVES = new Map<string, ReturnType<typeof obj>>()
685function meshOut(kind: string, args: number[], make: (...a: number[]) => Mesh) {
686 const key = `${kind}|${args.join(',')}`
687 let out = PRIMITIVES.get(key)
688 if (!out) {
689 if (PRIMITIVES.size >= 64) PRIMITIVES.clear()
690 const m = make(...args)
691 Object.freeze(m.verts)
692 Object.freeze(m.faces)
693 out = obj({ verts: m.verts, faces: m.faces })
694 meshesIn.set(out, { verts: m.verts, faces: m.faces, mesh: m })
695 PRIMITIVES.set(key, out)
696 }
697
698 return out
699}
700
701// A mesh as the code gives it: verts flat, faces flat triangles or lists of
702// indices (polygons, fanned into triangles). A mesh built once and drawn
703// every frame is read once, so its normals (kept per Mesh) are kept too.
704const meshesIn = new WeakMap<object, { verts: unknown; faces: unknown; mesh: Mesh }>()
705function meshIn(raw: unknown): Mesh | undefined {
706 if (typeof raw !== 'object' || raw === null) return undefined
707 const o = raw as Record<string, unknown>
708 if (!Array.isArray(o.verts) || !Array.isArray(o.faces)) return undefined
709 const known = meshesIn.get(raw)
710 if (known && known.verts === o.verts && known.faces === o.faces) return known.mesh
711 const mesh = readMesh(o)
712 meshesIn.set(raw, { verts: o.verts, faces: o.faces, mesh })
713
714 return mesh
715}
716
717function readMesh(o: Record<string, unknown>): Mesh {
718 if (!Array.isArray(o.verts) || !Array.isArray(o.faces)) return { verts: [], faces: [] }
719 const verts = o.verts.slice(0, 60_000).map(v => num(v))
720 const faces: number[] = []
721 const count = Math.floor(verts.length / 3)
722 const index = (v: unknown) => {
723 const i = Math.round(num(v, -1))
724
725 return i >= 0 && i < count ? i : -1
726 }
727 if (o.faces.every(f => Array.isArray(f))) {
728 // Polygons fan into triangles, up to the most one frame draws anyway.
729 for (const poly of o.faces.slice(0, 20_000) as unknown[][]) {
730 if (faces.length >= 3 * MOST_TRIANGLES) break
731 const ids = poly.slice(0, 3 * MOST_TRIANGLES).map(index)
732 if (ids.some(i => i < 0)) continue
733 for (let i = 1; i + 1 < ids.length && faces.length < 3 * MOST_TRIANGLES; i++) faces.push(ids[0]!, ids[i]!, ids[i + 1]!)
734 }
735 } else {
736 const ids = o.faces.slice(0, 60_000).map(index)
737 for (let i = 0; i + 2 < ids.length; i += 3) if (ids[i]! >= 0 && ids[i + 1]! >= 0 && ids[i + 2]! >= 0) faces.push(ids[i]!, ids[i + 1]!, ids[i + 2]!)
738 }
739
740 return { verts, faces }
741}
742
743type Bubble = { lines: string[]; x: number; y: number; wide: number; tall: number; base: Rgb; tail?: { col: number; row: number; char: string } }
744
745// A bubble's text as typed out so far, and its size.
746function typedBubble(text: string, age: number, cols: number) {
747 const all = wrap(text, Math.max(10, Math.min(30, cols - 6)))
748 let left = Math.floor((age * 1000) / BUBBLE_MS)
749 const lines = all.map(line => {
750 const part = line.slice(0, Math.max(0, left))
751 left -= line.length + 1
752
753 return part
754 })
755
756 return { lines, wide: Math.max(...all.map(line => line.length)) + 4, tall: all.length + 2 }
757}
758
759// Where a bubble goes: the clearest spot near its speaker, scored by how
760// many drawn cells it would cover and how far it strays from the speaker's
761// box; the last spot keeps its place unless a clearly better one opens, so
762// the bubble does not hop about. The tail notches the border on the side
763// that faces the speaker.
764function placeBubble(args: { speaker: Box; wide: number; tall: number; cols: number; rows: number; covered: Uint8Array; last?: { x: number; y: number } }) {
765 const { speaker, wide, tall, cols, rows, covered, last } = args
766 const bottom = rows - 1
767 const cx = (speaker.x0 + speaker.x1 + 1) / 2
768 const cost = (x: number, y: number) => {
769 let blocked = 0
770 for (let row = y; row < y + tall; row++) {
771 for (let col = x; col < x + wide; col++) if (covered[row * cols + col]) blocked += 1
772 }
773 const gap = y + tall <= speaker.y0 ? speaker.y0 - (y + tall) : y > speaker.y1 ? y - speaker.y1 - 1 : 0
774 const side = x + wide <= speaker.x0 ? speaker.x0 - (x + wide) : x > speaker.x1 ? x - speaker.x1 - 1 : 0
775
776 return blocked * 10 + Math.abs(x + wide / 2 - cx) * 0.08 + gap * 0.6 + side * 0.15
777 }
778 let best = { x: Math.max(0, Math.min(cols - wide, Math.round(cx - wide / 2))), y: Math.max(0, speaker.y0 - tall) }
779 let bestCost = Infinity
780 const lo = Math.max(0, Math.round(cx) - 50)
781 const hi = Math.min(cols - wide, Math.round(cx) + 50)
782 for (let y = 0; y + tall - 1 <= bottom; y++) {
783 // Every other column, and both bounds.
784 for (let x = lo; x <= hi + 1; x += 2) {
785 const xx = Math.min(x, hi)
786 const c = cost(xx, y)
787 if (c < bestCost) {
788 bestCost = c
789 best = { x: xx, y }
790 }
791 }
792 }
793 if (last && last.x >= 0 && last.x <= cols - wide && last.y >= 0 && last.y + tall - 1 <= bottom && cost(last.x, last.y) <= bestCost + 3) best = last
794 const { x, y } = best
795 const tailCol = Math.max(x + 2, Math.min(x + wide - 3, Math.round(cx)))
796 const tailRow = Math.max(y + 1, Math.min(y + tall - 2, Math.round((speaker.y0 + speaker.y1) / 2)))
797 const tail =
798 y + tall - 1 < speaker.y0 ? { col: tailCol, row: y + tall - 1, char: '┬' }
799 : y > speaker.y1 ? { col: tailCol, row: y, char: '┴' }
800 : x + wide - 1 < speaker.x0 ? { col: x + wide - 1, row: tailRow, char: '├' }
801 : x > speaker.x1 ? { col: x, row: tailRow, char: '┤' }
802 : undefined
803 // The bubble's cells count as drawn for the next bubble.
804 for (let row = y; row < y + tall; row++) for (let col = x; col < x + wide; col++) covered[row * cols + col] = 1
805
806 return { x, y, tail }
807}
808
809export type Stage = {
810 cols: number
811 rows: number
812 // The buddy's clock, in seconds: the backdrop's.
813 t: number
814 script: Script
815 previous?: Script
816 // Milliseconds since this script arrived (its actors' clock), and since
817 // the previous one did, so its code keeps its own time while it dissolves.
818 since: number
819 previousSince?: number
820 reveal: number
821}
822
823const BUBBLE_MS = 32
824// Where each speaker's bubble sat last frame.
825const lastBubble = new WeakMap<Actor, { x: number; y: number }>()
826const OLD_MS = 500
827
828// Words wrapped to lines no wider than `width`.
829function wrap(text: string, width: number) {
830 const lines: string[] = []
831 let line = ''
832 for (const word of text.split(' ')) {
833 const next = line ? `${line} ${word}` : word
834 if (next.length <= width) {
835 line = next
836 } else {
837 if (line) lines.push(line)
838 // A word too long for a line is broken across lines.
839 let rest = word
840 while (rest.length > width) {
841 lines.push(rest.slice(0, width))
842 rest = rest.slice(width)
843 }
844 line = rest
845 }
846 }
847 if (line) lines.push(line)
848
849 return lines
850}
851
852type Box = { x0: number; y0: number; x1: number; y1: number }
853type Placed = { actor: Actor; lines: string[]; x: number; y: number; width: number; alpha: number; look?: ClawdLook }
854
855// Draws one frame of a script: backdrop, swarms, actors, then speech on top,
856// with the backdrop cleared around everything that reads.
857export function stage(frame: Stage): string {
858 const { cols, rows, t, since, reveal } = frame
859 // A scene whose code broke with nothing else to show keeps the last scene
860 // on until the narrator's fixed one arrives.
861 const isBlank = (s: Script) => Boolean(s.code?.error) && s.actors.length === 0 && s.swarms.length === 0
862 const script = isBlank(frame.script) && frame.previous && !isBlank(frame.previous) ? frame.previous : frame.script
863 const previous = script === frame.script ? frame.previous : undefined
864 const words = new Uint32Array(cols * rows * 3)
865 paintBackground(words, {
866 cols,
867 rows,
868 t,
869 background: script.background,
870 previous: previous?.background,
871 fade: clamp(since / 1500),
872 reveal,
873 strength: 0.4,
874 })
875 // Rows above the band's risen front stay empty while it grows in.
876 const shown = (row: number) => row >= Math.floor(rows * (1 - clamp(reveal * 1.3)))
877 const clear = (box: Box) => {
878 for (let row = Math.max(0, box.y0); row <= Math.min(rows - 1, box.y1); row++) {
879 for (let col = Math.max(0, box.x0); col <= Math.min(cols - 1, box.x1); col++) {
880 if (gate && !gate(col, row)) continue
881 const at = (row * cols + col) * 3
882 words[at] = 0x20
883 words[at + 1] = CLEAR
884 words[at + 2] = CLEAR
885 }
886 }
887 }
888 const pack = (c: Rgb) => (Math.round(c[0]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[2])
889 // While scenes dissolve, which cells this pass may touch.
890 let gate: ((col: number, row: number) => boolean) | undefined
891 const put = (col: number, row: number, char: string, color: Rgb, back?: Rgb) => {
892 if (col < 0 || col >= cols || row < 0 || row >= rows || !shown(row) || (gate && !gate(col, row))) return
893 const at = (row * cols + col) * 3
894 words[at] = char.codePointAt(0) ?? 0x20
895 words[at + 1] = pack(color)
896 words[at + 2] = back ? pack(back) : CLEAR
897 }
898
899 // Where every actor stands this frame, the outgoing scene's flickering out.
900 const placed: Placed[] = []
901 const scenes: [Script, number, number][] =
902 previous && since < OLD_MS ? [[previous, since / 1000 + 30, 1 - since / OLD_MS], [script, since / 1000, 1]] : [[script, since / 1000, 1]]
903 for (const [play, sceneT, alpha] of scenes) {
904 play.actors.forEach((actor, i) => {
905 const env = { t: sceneT, w: cols, h: rows, k: i, n: play.actors.length }
906 if (actor.show && actor.show(env) <= 0) return
907 const index = actor.frame ? Math.floor(actor.frame(env)) : Math.floor(sceneT * actor.fps)
908 const lines = actor.frames[mod(index, actor.frames.length)] ?? []
909 const width = Math.max(1, ...lines.map(line => line.trimEnd().length))
910 const x = actor.x(env)
911 let look: ClawdLook | undefined
912 if (actor.kind === 'clawd') {
913 // Clawd steps as far as it has walked, one frame each 1.5 columns, so
914 // its feet never slide; standing, it blinks now and then.
915 const dx = x - actor.x({ ...env, t: sceneT - 0.12 })
916 const isWalking = Math.abs(dx) >= 0.2
917 look = {
918 ...LOOK,
919 pose: isWalking ? 'walk' : 'stand',
920 stride: isWalking ? mod(Math.floor(x / 1.5), 4) : 0,
921 facing: isWalking ? Math.sign(dx) : 0,
922 isBlinking: !isWalking && hash(Math.floor(sceneT * 0.5), i) > 0.6 && fract(sceneT * 0.5) < 0.06,
923 color: parseHex(actor.color),
924 }
925 }
926 placed.push({ actor, lines, x: Math.round(x), y: Math.round(actor.y(env)), width, alpha, look })
927 })
928 }
929
930 // Each actor is solid inside its outline: on every line, the cells from its
931 // first drawn character to its last hide whatever lies behind them.
932 const spans = (one: Placed) =>
933 one.lines.map(line => {
934 const start = line.search(/\S/)
935
936 return start < 0 ? undefined : { x0: one.x + start, x1: one.x + line.trimEnd().length - 1 }
937 })
938 const covered = new Uint8Array(cols * rows)
939 for (const one of placed) {
940 spans(one).forEach((span, dy) => {
941 const row = one.y + dy
942 if (!span || row < 0 || row >= rows) return
943 for (let col = Math.max(0, span.x0); col <= Math.min(cols - 1, span.x1); col++) covered[row * cols + col] = 1
944 })
945 }
946
947 // Speech: a bubble in the clearest spot near its speaker.
948 const sceneT = since / 1000
949 const bubbles: Bubble[] = []
950 const saidNow = new Set<string>()
951 for (const one of placed) {
952 const { actor } = one
953 if (one.alpha < 1 || !actor.say || sceneT < actor.sayAt) continue
954 const typed = Math.floor(((sceneT - actor.sayAt) * 1000) / BUBBLE_MS)
955 const all = wrap(actor.say, Math.max(10, Math.min(30, cols - 6)))
956 let left = typed
957 const lines = all.map(line => {
958 const part = line.slice(0, Math.max(0, left))
959 left -= line.length + 1
960
961 return part
962 })
963 // The text plus a border and a space of padding either side. A bubble
964 // the strip cannot hold whole (while the band grows in) waits.
965 const wide = Math.max(...all.map(line => line.length)) + 4
966 const tall = all.length + 2
967 if (tall > rows || wide > cols) continue
968 const bottom = rows - 1
969 const outline = spans(one)
970 const drawn = outline.flatMap((span, dy) => (span ? [{ ...span, row: one.y + dy }] : []))
971 // An actor with nothing drawn (a blank narrator) speaks from its box.
972 const speaker: Box =
973 drawn.length > 0
974 ? {
975 x0: Math.min(...drawn.map(d => d.x0)),
976 x1: Math.max(...drawn.map(d => d.x1)),
977 y0: Math.min(...drawn.map(d => d.row)),
978 y1: Math.max(...drawn.map(d => d.row)),
979 }
980 : { x0: one.x, x1: one.x + Math.max(0, ...one.lines.map(l => l.length - 1)), y0: one.y, y1: one.y + one.lines.length - 1 }
981 const { x, y, tail } = placeBubble({ speaker, wide, tall, cols, rows, covered, last: lastBubble.get(actor) })
982 lastBubble.set(actor, { x, y })
983 bubbles.push({ lines, x, y, wide, tall, base: parseHex(actor.color), tail })
984 }
985
986 // Room to read: the backdrop gives way around the actors.
987 for (const one of placed) {
988 spans(one).forEach((span, dy) => span && clear({ x0: span.x0 - 1, y0: one.y + dy, x1: span.x1 + 1, y1: one.y + dy }))
989 }
990
991 // Swarms first, behind everything.
992 for (const [play, playT, alpha] of scenes) {
993 const env: Env = { t: playT, w: cols, h: rows, k: 0, n: 0 }
994 for (const swarm of play.swarms) {
995 const color = parseHex(swarm.color)
996 for (let k = 0; k < swarm.count; k++) {
997 if (alpha < 1 && hash(k, Math.floor(since / 60)) > alpha) continue
998 const at = { ...env, k, n: swarm.count }
999 const col = Math.round(swarm.x(at))
1000 const row = Math.round(swarm.y(at))
1001 put(col, row, swarm.glyphs[k % swarm.glyphs.length] ?? '*', color)
1002 }
1003 }
1004 }
1005 // One pixel, two to a cell: the cell keeps whatever its other half holds
1006 // (a backdrop pixel, an earlier pixel) and shows the new one in its half.
1007 const plotPixel = (col: number, py: number, color: Rgb) => {
1008 const row = Math.floor(py / 2)
1009 if (col < 0 || col >= cols || row < 0 || row >= rows || !shown(row) || (gate && !gate(col, row))) return
1010 const at = (row * cols + col) * 3
1011 const ch = words[at]
1012 const fg = words[at + 1]!
1013 const bg = words[at + 2]!
1014 let top: number | undefined
1015 let bottom: number | undefined
1016 if (ch === 0x2580) {
1017 top = fg
1018 bottom = bg === CLEAR ? undefined : bg
1019 } else if (ch === 0x2584) {
1020 bottom = fg
1021 top = bg === CLEAR ? undefined : bg
1022 } else if (ch === 0x2588) {
1023 top = bottom = fg
1024 } else if (ch === 0x20 && bg !== CLEAR) {
1025 top = bottom = bg
1026 } else if (ch !== undefined && ch >= 0x2596 && ch <= 0x259f) {
1027 // A quarter-block edge from the 3D world: each half counts as the
1028 // glyph's color where the glyph has anything in it.
1029 const q = QUARTER_HALVES[ch - 0x2596] ?? 0
1030 if (q & 1) top = fg
1031 if (q & 2) bottom = fg
1032 }
1033 if (py % 2 === 0) top = pack(color)
1034 else bottom = pack(color)
1035 if (top !== undefined) {
1036 words[at] = 0x2580
1037 words[at + 1] = top
1038 words[at + 2] = bottom ?? CLEAR
1039 } else {
1040 words[at] = 0x2584
1041 words[at + 1] = bottom ?? CLEAR
1042 words[at + 2] = CLEAR
1043 }
1044 }
1045
1046 // The cells the code has drawn characters and sprites on this frame (not
1047 // fills), and the last Clawd it drew: what its speech bubbles stay off.
1048 const inked = new Uint8Array(cols * rows)
1049 let isFilling = false
1050 const ink = (col: number, row: number) => {
1051 if (!isFilling && col >= 0 && col < cols && row >= 0 && row < rows) inked[row * cols + col] = 1
1052 }
1053 let lastClawd: Box | undefined
1054 const mark = (box: Box) => {
1055 lastClawd = box
1056 for (let row = Math.max(0, box.y0); row <= Math.min(rows - 1, box.y1); row++) for (let col = Math.max(0, box.x0); col <= Math.min(cols - 1, box.x1); col++) ink(col, row)
1057 }
1058 // The cells speech bubbles have taken this frame.
1059 const bubbled = new Uint8Array(cols * rows)
1060
1061 // The frame's 3D samples, made when the code first draws in 3D, and drawn
1062 // as glyphs before any 2D drawing that follows, so that sits on top.
1063 let frame3d: Frame | undefined
1064 let isDirty3d = false
1065 const frameOf = () => (frame3d ??= Object.assign(createFrame(cols, rows), { tick: () => spend() }))
1066 const flush3d = () => {
1067 if (frame3d && isDirty3d) composite(frame3d, put)
1068 isDirty3d = false
1069 }
1070 // A frame that draws for too long (a fill the size of the world, thousands
1071 // of meshes) stops the scene the way a runaway loop does.
1072 const deadline = performance.now() + FRAME_MS
1073 let calls = 0
1074 const spend = () => {
1075 if ((calls += 1) % 256 === 0 && performance.now() > deadline) throw new ScriptError(`a frame took more than ${FRAME_MS}ms to draw`)
1076 }
1077
1078 // Clawd at a column and a pixel row, some of its pixels held back while it
1079 // fades in.
1080 const paintClawd = (x: number, py: number, look: ClawdLook, isHidden?: (px: { x: number; y: number }) => boolean) => {
1081 for (const px of clawdPixels(look)) {
1082 if (isHidden?.(px)) continue
1083 plotPixel(x + px.x, py + px.y, px.c)
1084 }
1085
1086 return clawdAnchors(look)
1087 }
1088
1089 // A scene's code draws over the backdrop and swarms, under the actors:
1090 // the outgoing scene's for a moment, dissolving cell by cell into the new
1091 // one's, then the new one's. Each cell flips once, at its own moment.
1092 const runCode = (play: Script, playT: number, isOutgoing: boolean) => {
1093 const code = play.code
1094 if (!code?.program || code.error) return
1095 frame3d = undefined
1096 isDirty3d = false
1097 const dt = code.isStarted ? clamp(playT - code.lastT, 0, 0.25) : 0
1098 code.lastT = playT
1099 const globals = { t: playT, dt, w: cols, h: rows }
1100 let scene3d: { frame: Frame; view: View; light: Light; fog: Fog } | undefined
1101 canvas = {
1102 cols,
1103 rows,
1104 code,
1105 put: (col, row, char, color, back) => {
1106 spend()
1107 flush3d()
1108 put(col, row, char, color, back)
1109 ink(col, row)
1110 },
1111 fill: (on: boolean) => {
1112 isFilling = on
1113 },
1114 clear: (col, row) => {
1115 spend()
1116 flush3d()
1117 clear({ x0: col, y0: row, x1: col, y1: row })
1118 },
1119 pixel: (col, py, color) => {
1120 spend()
1121 flush3d()
1122 plotPixel(col, py, color)
1123 ink(col, Math.floor(py / 2))
1124 },
1125 clawd: (x, py, look) => {
1126 spend()
1127 flush3d()
1128 const a = paintClawd(x, py, look)
1129 mark({ x0: x - 1, y0: Math.floor(py / 2), x1: x + a.w, y1: Math.floor((py + a.h - 1) / 2) })
1130
1131 return a
1132 },
1133 pixel3d: (col, py, color, depth) => {
1134 spend()
1135 flush3d()
1136 if (!claimPixel(frameOf(), col, py, depth)) return
1137 plotPixel(col, py, color)
1138 ink(col, Math.floor(py / 2))
1139 },
1140 mark,
1141 scene3d: (isDrawing = true) => {
1142 if (isDrawing) {
1143 spend()
1144 isDirty3d = true
1145 }
1146 if (!scene3d) {
1147 scene3d = {
1148 frame: frameOf(),
1149 view: resolve(code.cam ?? DEFAULT_CAMERA, cols * SX, rows * SY),
1150 light: code.light ?? DEFAULT_LIGHT,
1151 fog: code.fog ?? DEFAULT_FOG,
1152 }
1153 }
1154
1155 return scene3d
1156 },
1157 say: (text, x, y, color) => {
1158 spend()
1159 if (isOutgoing) return
1160 // The same line twice in a frame is one bubble.
1161 if (saidNow.has(text)) return
1162 saidNow.add(text)
1163 flush3d()
1164 // A new line types out; one that only changed from a line already
1165 // typed (a live count, a timer) shows whole rather than restarting.
1166 let first = code.said.get(text)
1167 if (first === undefined) {
1168 const isTyped = (line: string, at: number) => (playT - at) * 1000 >= line.length * BUBBLE_MS
1169 first = [...code.said].some(([line, at]) => isTyped(line, at)) ? -Infinity : playT
1170 if (code.said.size >= 32) code.said.clear()
1171 code.said.set(text, first)
1172 }
1173 const { lines, wide, tall } = typedBubble(text, playT - first, cols)
1174 if (wide > cols || tall > rows) return
1175 // The speaker is the Clawd last drawn when the point is on or beside
1176 // it; otherwise the point itself.
1177 const near = lastClawd && x >= lastClawd.x0 - 2 && x <= lastClawd.x1 + 2 && y >= lastClawd.y0 - 1 && y <= lastClawd.y1 + 1
1178 const speaker: Box = near && lastClawd ? lastClawd : { x0: x, y0: y, x1: x, y1: y }
1179 // The bubble keeps clear of what is drawn in runs (Clawd, labels,
1180 // props) and of earlier bubbles, not of a scattering of rain, snow
1181 // or sparks: a drawn cell counts when the cells on both sides of it,
1182 // or above and below it, are drawn too.
1183 const covered = new Uint8Array(cols * rows)
1184 const drawn = (col: number, row: number) => col >= 0 && col < cols && row >= 0 && row < rows && inked[row * cols + col] === 1
1185 for (let row = 0; row < rows; row++) {
1186 for (let col = 0; col < cols; col++) {
1187 const i = row * cols + col
1188 if (bubbled[i] || (inked[i] && ((drawn(col - 1, row) && drawn(col + 1, row)) || (drawn(col, row - 1) && drawn(col, row + 1))))) covered[i] = 1
1189 }
1190 }
1191 const placed3 = placeBubble({ speaker, wide, tall, cols, rows, covered, last: code.bubblesAt.get(text) })
1192 bubbled.set(covered)
1193 if (code.bubblesAt.size >= 32) code.bubblesAt.clear()
1194 code.bubblesAt.set(text, { x: placed3.x, y: placed3.y })
1195 bubbles.push({ lines, x: placed3.x, y: placed3.y, wide, tall, base: color, tail: placed3.tail })
1196 },
1197 }
1198 try {
1199 if (!code.isStarted) {
1200 code.isStarted = truehooks/dance.ts 133 lines1// The dancer: Clawd as on the official stickers, in claude-toons' scene
2// language, shared by every ballet scene (each scene's code starts with it).
3//
4// Clawd is toons' Clawd, pixel for pixel: the 12 by 6 block with its
5// corners cut, two arm stubs and four short legs. Like the stickers, it
6// never changes shape to turn: a three-quarter view shades its far side and
7// slides its eyes toward the way it faces, and from behind its eyes are
8// gone. The ballet is the sticker's: a pink tutu checked in two pinks over
9// the lower body, an arm raised as a stub from the top corner, the other
10// held out, and a leg pointed down behind. Arms are only ever the two
11// stubs, and legs always point down, so it is two arms and four legs in
12// every pose.
13//
14// The art is generated from pixel coordinates by scripts/art.py; each
15// layer is a picture on a 24-pixel canvas, Clawd's grid starting 5 in.
16
17export const DANCER = String.raw`
18const ORANGE = '#d97757', SHADED = '#b5603f', EYES = '#2a1610';
19const ease = k => k * k * (3 - 2 * k);
20const pick = (lines, n) => lines[n % lines.length];
21
22// art:begin
23const BODY = '.......BBBBBBBBBB\n.......BBBBBBBBBB\n.......BBBBBBBBBB\n.......BBBBBBBBBB\n.......BBBBBBBBBB\n.......BBBBBBBBBB';
24const SHADE = { right: '.......DD\n.......DD\n.......DD\n.......DD\n.......DD\n.......DD', left: '...............DD\n...............DD\n...............DD\n...............DD\n...............DD\n...............DD' };
25const EYE = {
26 front: { open: '\n\n.........E....E\n.........E....E', closed: '\n\n\n........EE....EE', happy: '\n\n.........E....E\n........E.E..E.E' },
27 quarter: { open: '\n\n..........E....E\n..........E....E', closed: '\n\n\n.........EE...EE', happy: '\n\n..........E...E\n.........E.E.E.E' },
28};
29const ARM = {
30 out: [2, '.....AA\n.....AA', '.................AA\n.................AA'],
31 up: [-2, '.......AA\n.......AA', '...............AA\n...............AA'],
32 down: [4, '......A\n......A', '.................A\n.................A'],
33 high: [0, '...AAA\n...AAA\n.....AA\n.....AA', '..................AAA\n..................AAA\n.................AA\n.................AA'],
34};
35const LEGS = {
36 stand: [0, '........L.L..L.L\n........S.S..S.S'],
37 plie: [0, '.......L..L..L..L\n.......S..S..S..S'],
38 passe: [0, '........L.L..L\n........S.S..S'],
39 derriere: [0, '......L...L..L.L\n......S...S..S.S'],
40 jete: [0, '......L.L......L.L\n......S.S......S.S'],
41};
42// art:end
43
44// A pose: where on the stage (0 to 1), the view (front, right, left, back),
45// the arms [left, right] and legs by name, and anything more (eyes, air in
46// pixels, spin in degrees while spinning, dx columns aside for partners).
47const P = (x, view, arms, legs, more) => Object.assign({ x, view, arms, legs, eyes: 'open', air: 0, spin: 0, spinning: false, swirl: 0, dx: 0 }, more || {});
48// Numbers blend eased; names switch halfway, like sprite frames.
49function blend(p, q, k) {
50 const out = {};
51 for (const key of Object.keys(p)) {
52 const a = p[key];
53 const b = q[key];
54 out[key] = typeof a === 'number' && typeof b === 'number' ? a + (b - a) * k : k < 0.5 ? a : b;
55 }
56 return out;
57}
58// Where [time, pose] keyframes are at u, eased between each pair.
59function track(keys, u) {
60 if (u <= keys[0][0]) return blend(keys[0][1], keys[0][1], 0);
61 for (let i = 1; i < keys.length; i++) {
62 if (u < keys[i][0]) return blend(keys[i - 1][1], keys[i][1], ease((u - keys[i - 1][0]) / (keys[i][0] - keys[i - 1][0])));
63 }
64 return blend(keys[keys.length - 1][1], keys[keys.length - 1][1], 0);
65}
66
67// Flips a picture left to right on the 24-pixel canvas, once per picture.
68const MIRRORED = {};
69function mirror(art) {
70 if (MIRRORED[art]) return MIRRORED[art];
71 const flipped = art.split('\n').map(row => {
72 let out = '';
73 for (let x = 23; x >= 0; x--) out += x < row.length ? row[x] : '.';
74 return out;
75 }).join('\n');
76 MIRRORED[art] = flipped;
77 return flipped;
78}
79
80// Draws Clawd in a pose with its middle at column cx and its feet on pixel
81// row ground (lifted by air); returns where it is, for speech.
82function dancer(p, cx, ground, look) {
83 // Spinning, the view follows the spin, and the tutu's checks go round.
84 if (p.spinning) {
85 const a = mod(p.spin, 360);
86 p.view = a < 45 || a >= 315 ? 'front' : a < 135 ? 'right' : a < 225 ? 'back' : 'left';
87 p.swirl = p.spin / 30;
88 }
89 const isLeft = p.view === 'left';
90 const x0 = Math.round(cx) - 12;
91 // On whole rows only: half a row off, every edge falls inside a cell,
92 // which terminals with line spacing draw with a seam.
93 // The body is rows 0 to 5, the tutu 6 and 7, the legs 8 and 9.
94 // Kneeling, the body sits on the floor, the legs folded out of sight.
95 const isKneeling = p.legs === 'kneel';
96 let top = Math.round(ground - (isKneeling ? 5 : 9) - (p.air || 0));
97 if (mod(top, 2) === 1) top -= 1;
98 // Pointe shoes in the tutu's light colour, or boots.
99 const colors = { B: ORANGE, D: SHADED, E: EYES, A: ORANGE, L: ORANGE, S: look.shoes || look.frill || '#fbd9e3' };
100 const leg = LEGS[p.legs] || LEGS.stand;
101 if (!isKneeling) pixels(x0, top + 8 + leg[0], isLeft ? mirror(leg[1]) : leg[1], colors);
102 pixels(x0, top, BODY, colors);
103 if (p.view === 'right') pixels(x0, top, SHADE.right, colors);
104 if (isLeft) pixels(x0, top, SHADE.left, colors);
105 if (p.view === 'front') pixels(x0, top, EYE.front[p.eyes] || EYE.front.open, colors);
106 if (p.view === 'right') pixels(x0, top, EYE.quarter[p.eyes] || EYE.quarter.open, colors);
107 if (isLeft) pixels(x0, top, mirror(EYE.quarter[p.eyes] || EYE.quarter.open), colors);
108 // The tutu, as on the sticker: two tiers checked in two pinks at the
109 // hips, under the whole body, the lower one wider; the checks turn with
110 // Clawd.
111 const tutu = look.tutu || '#f2a7bb';
112 const light = look.frill || '#fbd9e3';
113 const spin = Math.floor(p.swirl || 0);
114 // A partner (look.bare) has no tutu: breeches the width of the body, in a
115 // whole row of cells, joining it to the legs; none while kneeling.
116 if (look.bare) {
117 if (!isKneeling) for (let x = -5; x < 5; x++) for (let y = 6; y < 8; y++) pixel(Math.round(cx) + x, top + y, look.breeches || '#c8a070');
118 } else {
119 for (let x = -7; x < 7; x++) pixel(Math.round(cx) + x, top + 6, mod(x + spin, 2) === 0 ? light : tutu);
120 for (let x = -9; x < 9; x++) pixel(Math.round(cx) + x, top + 7, mod(x + spin, 2) === 1 ? light : tutu);
121 }
122 if (look.crown) pixels(Math.round(cx) - 3, top - 2, look.crown, look.crownColors);
123 // The arms: one stub each side.
124 const left = ARM[p.arms[0]] || ARM.out;
125 const right = ARM[p.arms[1]] || ARM.out;
126 // The far arm, like the far side, in shade.
127 const far = Object.assign({}, colors, { A: SHADED });
128 pixels(x0, top + left[0], left[1], p.view === 'right' ? far : colors);
129 pixels(x0, top + right[0], right[2], isLeft ? far : colors);
130 return { x: Math.round(cx), row: Math.floor(top / 2), top };
131}
132`
133hooks/effects.ts 267 lines1// Procedural effects for the spinner: each one a field from a pixel to a
2// value in [0, 1], mapped through the scene's palette and packed into a
3// Raster's cells (upper half blocks, two pixels a row).
4
5export const EFFECTS = [
6 'plasma', 'fire', 'starfield', 'rain', 'tunnel', 'lava', 'glitch', 'aurora', 'waves', 'pulse', 'fireworks',
7] as const
8export type Effect = (typeof EFFECTS)[number]
9
10// The ambient layer behind a scene: one effect, faint and see-through.
11export type Background = {
12 effect: Effect
13 palette: string[]
14 speed: number
15 intensity: number
16}
17
18type Field = (x: number, y: number, t: number, w: number, h: number) => number
19
20const fract = (v: number) => v - Math.floor(v)
21const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v))
22const hash = (x: number, y: number) => fract(Math.sin(x * 12.9898 + y * 78.233) * 43758.5453)
23
24function noise(x: number, y: number) {
25 const ix = Math.floor(x)
26 const iy = Math.floor(y)
27 const fx = x - ix
28 const fy = y - iy
29 const sx = fx * fx * (3 - 2 * fx)
30 const sy = fy * fy * (3 - 2 * fy)
31 const a = hash(ix, iy)
32 const b = hash(ix + 1, iy)
33 const c = hash(ix, iy + 1)
34 const d = hash(ix + 1, iy + 1)
35
36 return a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy
37}
38
39// Cells are about twice as tall as wide, so x distances count half.
40const polar = (x: number, y: number, w: number, h: number) => {
41 const dx = (x - w / 2) * 0.5
42 const dy = y - h / 2
43
44 return { r: Math.hypot(dx, dy), a: Math.atan2(dy, dx) }
45}
46
47const FIELDS: Record<Effect, Field> = {
48 plasma: (x, y, t) =>
49 (Math.sin(x * 0.08 + t) + Math.sin(y * 0.3 + t * 1.3) + Math.sin((x + y) * 0.05 + t * 0.7) +
50 Math.sin(Math.hypot(x * 0.5, y) * 0.2 - t)) / 8 + 0.5,
51 fire: (x, y, t, _w, h) => clamp(noise(x * 0.15, y * 0.45 + t * 3) * 1.5 * Math.pow(y / h, 1.3) + noise(x * 0.4, t * 5) * 0.15 * (y / h)),
52 starfield: (x, y, t, w, h) => {
53 const { r, a } = polar(x, y, w, h)
54 const ray = Math.floor((a + Math.PI) * 24)
55 const z = fract(hash(ray, 3) + t * 0.25)
56 const reach = Math.hypot(w * 0.25, h * 0.5)
57
58 return clamp(Math.exp(-Math.abs(r - z * reach) * 1.2) * z * 1.6)
59 },
60 rain: (x, y, t, _w, h) => {
61 const head = fract(hash(x, 2) + t * (0.3 + hash(x, 1) * 0.5)) * (h + 12)
62 const d = head - y
63
64 return d >= 0 && d < 10 ? 1 - d / 10 : hash(x, Math.floor(y + t * 4)) * 0.08
65 },
66 tunnel: (x, y, t, w, h) => {
67 const { r, a } = polar(x, y, w, h)
68
69 return 0.5 + 0.5 * Math.sin(30 / (r + 2) + t * 4) * Math.cos(a * 3 + t)
70 },
71 lava: (x, y, t, w, h) => {
72 let sum = 0
73 for (let i = 0; i < 6; i++) {
74 const bx = (0.5 + 0.45 * Math.sin(t * (0.3 + i * 0.11) + i * 2.1)) * w
75 const by = (0.5 + 0.45 * Math.cos(t * (0.4 + i * 0.07) + i * 1.3)) * h
76 sum += 6 / (Math.hypot((x - bx) * 0.5, y - by) + 0.5)
77 }
78
79 return clamp((sum - 2.2) / 2)
80 },
81 glitch: (x, y, t) => {
82 const frame = Math.floor(t * 9)
83 const shift = hash(Math.floor(y / 2), frame) > 0.75 ? Math.floor(hash(y, frame) * 24) : 0
84 const block = hash(Math.floor((x + shift) / 5), Math.floor(y / 3) + frame * 7)
85
86 return block > 0.6 ? block : 0.5 + 0.5 * Math.sin((x + shift) * 0.1 + t * 2) * 0.4
87 },
88 aurora: (x, y, t, _w, h) => {
89 const center = h * (0.45 + 0.25 * Math.sin(x * 0.04 + t * 0.6) + 0.12 * Math.sin(x * 0.11 - t * 1.4))
90 const band = (y - center) / (2.5 + 3 * noise(x * 0.05, t * 0.5))
91
92 return clamp(Math.exp(-band * band) * (0.6 + 0.4 * noise(x * 0.2, t * 2)))
93 },
94 waves: (x, y, t, _w, h) => {
95 const surface = h * (0.5 + 0.3 * Math.sin(x * 0.09 - t * 2) * Math.sin(x * 0.023 + t * 0.7))
96
97 return y > surface ? clamp(0.55 + 0.45 * Math.sin(x * 0.2 + y * 0.6 - t * 3)) : 0.1 * noise(x * 0.1, y * 0.3 + t)
98 },
99 pulse: (x, y, t, w, h) => {
100 const { r } = polar(x, y, w, h)
101 const ring = fract(t * 0.6) * Math.hypot(w * 0.25, h * 0.5)
102
103 return clamp(Math.exp(-Math.abs(r - ring) * 0.6) + 0.15 * Math.sin(r * 0.5 - t * 3))
104 },
105 fireworks: (x, y, t, w, h) => {
106 let v = 0
107 for (let k = 0; k < 4; k++) {
108 const cycle = Math.floor(t / 1.6 + k * 0.37)
109 const age = fract(t / 1.6 + k * 0.37)
110 const cx = hash(k, cycle) * w
111 const cy = (0.2 + hash(cycle, k) * 0.6) * h
112 const r = Math.hypot((x - cx) * 0.5, y - cy)
113 const sparkle = hash(Math.floor(Math.atan2(y - cy, x - cx) * 6), cycle) > 0.4 ? 1 : 0.3
114 v += Math.exp(-Math.abs(r - age * 14) * 1.3) * (1 - age) * sparkle
115 }
116
117 return clamp(v)
118 },
119}
120
121function parseHex(hex: string): [number, number, number] {
122 const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
123 if (!m || m[1] === undefined) return [128, 128, 128]
124 const s = m[1].length === 3 ? [...m[1]].map(c => c + c).join('') : m[1]
125
126 return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
127}
128
129type Rgb = [number, number, number]
130
131function shade(stops: Rgb[], v: number): Rgb {
132 const p = clamp(v) * (stops.length - 1)
133 const i = Math.min(stops.length - 2, Math.floor(p))
134 const k = p - i
135 const a = stops[i] ?? [0, 0, 0]
136 const b = stops[i + 1] ?? a
137
138 return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
139}
140
141const pack = ([r, g, b]: Rgb) => (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
142const mix = (a: Rgb, b: Rgb, k: number): Rgb => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
143const dim = (c: Rgb, k: number): Rgb => [c[0] * k, c[1] * k, c[2] * k]
144
145const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
146
147function base64(bytes: Uint8Array) {
148 let out = ''
149 let i = 0
150 for (; i + 2 < bytes.length; i += 3) {
151 const v = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
152 out += (B64[(v >> 18) & 63] ?? '') + (B64[(v >> 12) & 63] ?? '') + (B64[(v >> 6) & 63] ?? '') + (B64[v & 63] ?? '')
153 }
154 if (i < bytes.length) {
155 const v = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8)
156 out += (B64[(v >> 18) & 63] ?? '') + (B64[(v >> 12) & 63] ?? '')
157 out += i + 1 < bytes.length ? (B64[(v >> 6) & 63] ?? '') + '=' : '=='
158 }
159
160 return out
161}
162
163// The terminal's own color: what shows through wherever nothing is drawn.
164export const CLEAR = 0x01000000
165
166// A 4x4 ordered dither: a pixel of opacity a shows where a beats its cell, so
167// a fade reads as a thinning pattern rather than a color against a background
168// the oracle cannot know.
169const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(v => (v + 0.5) / 16)
170const bayer = (x: number, y: number) => BAYER[(y & 3) * 4 + (x & 3)] ?? 0.5
171
172export const smooth = (lo: number, hi: number, v: number) => {
173 const k = clamp((v - lo) / (hi - lo))
174
175 return k * k * (3 - 2 * k)
176}
177
178export type Paint = {
179 cols: number
180 rows: number
181 t: number
182 background: Background
183 // The background being faded out, and how far the fade has gone (0 to 1).
184 previous?: Background
185 fade: number
186 // How far the band has risen into view, 0 to 1, from the bottom up.
187 reveal: number
188 // How strongly the backdrop shows, 0 to 1: kept low behind a scene.
189 strength: number
190}
191
192// Paints the backdrop into `words` (a Raster's cells): half blocks where the
193// effect is dense enough, the terminal's own color everywhere else, thinning
194// toward the band's edges and its rising front.
195export function paintBackground(words: Uint32Array, paint: Paint) {
196 const { cols, rows, t, background, previous, fade, reveal, strength } = paint
197 const w = cols
198 const h = rows * 2
199 const stops = background.palette.map(parseHex)
200 const oldStops = previous ? previous.palette.map(parseHex) : stops
201 const pixel = (x: number, y: number): { c: Rgb; a: number } => {
202 const v = FIELDS[background.effect](x, y, t * background.speed, w, h)
203 let c = shade(stops, 0.15 + 0.85 * v)
204 let a = smooth(0.2, 0.75, v) * (0.55 + 0.45 * background.intensity)
205 if (previous && fade < 1) {
206 const pv = FIELDS[previous.effect](x, y, t * previous.speed, w, h)
207 c = mix(shade(oldStops, 0.15 + 0.85 * pv), c, fade)
208 a = a * fade + smooth(0.2, 0.75, pv) * (0.55 + 0.45 * previous.intensity) * (1 - fade)
209 }
210 const edge = smooth(0, w * 0.14, Math.min(x, w - 1 - x)) * smooth(0, 2.5, Math.min(y + 0.5, h - 0.5 - y))
211 const rise = (h - 1 - y) / Math.max(1, h - 1)
212 const front = reveal * 1.35 - 0.3 * noise(x * 0.18, t * 1.5)
213 a *= strength * edge * smooth(0, 0.25, front - rise)
214
215 return { c, a }
216 }
217 for (let row = 0; row < rows; row++) {
218 for (let col = 0; col < cols; col++) {
219 const at = (row * cols + col) * 3
220 const top = pixel(col, row * 2)
221 const bottom = pixel(col, row * 2 + 1)
222 const isTop = top.a > bayer(col, row * 2)
223 const isBottom = bottom.a > bayer(col, row * 2 + 1)
224 words[at] = isTop || !isBottom ? (isTop ? 0x2580 : 0x20) : 0x2584
225 words[at + 1] = isTop ? pack(top.c) : isBottom ? pack(bottom.c) : CLEAR
226 words[at + 2] = isTop && isBottom ? pack(bottom.c) : CLEAR
227 }
228 }
229}
230
231export { parseHex, shade, mix, dim, hash, clamp, type Rgb }
232
233export function encode(words: Uint32Array) {
234 return base64(new Uint8Array(words.buffer))
235}
236
237export function cleanBackground(raw: unknown): Background | undefined {
238 if (typeof raw !== 'object' || raw === null) return undefined
239 const s = raw as Record<string, unknown>
240 const effect = EFFECTS.find(name => name === s.effect)
241 if (!effect) return undefined
242 const palette = Array.isArray(s.palette)
243 ? s.palette.filter((c): c is string => typeof c === 'string' && /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim())).slice(0, 6)
244 : []
245 if (palette.length < 2) return undefined
246
247 return {
248 effect,
249 palette,
250 speed: clamp(typeof s.speed === 'number' ? s.speed : 1, 0.1, 4),
251 intensity: clamp(typeof s.intensity === 'number' ? s.intensity : 0.6),
252 }
253}
254
255// Text the Raster can draw: one printable, single-width character a cell.
256export function cleanText(text: string, most: number) {
257 return text
258 .replace(/[‘’]/g, "'")
259 .replace(/[“”]/g, '"')
260 .replace(/[–—]/g, '-')
261 .replace(/…/g, '...')
262 .replace(/[^\x20-\x7e]/g, '')
263 .replace(/\s+/g, ' ')
264 .trim()
265 .slice(0, most)
266}
267hooks/lang.ts 1698 lines1// A small JavaScript-like language for scenes the buddy writes as code. The
2// module's environment has no eval, and the code comes from a model reading
3// the developer's repo, so it runs here, in an interpreter of its own: it sees
4// only its own values and the functions handed to it, and every step spends
5// fuel, so a runaway loop stops the scene rather than the terminal.
6//
7// What it knows: let/const/var, functions and arrow functions (closures),
8// if/else, for, for-of, for-in, while, break/continue/return, numbers,
9// strings and template literals, arrays, plain objects, destructuring, the
10// usual operators, Math, and a whitelist of array and string methods.
11
12// A value of the language: a number, string, boolean, null, undefined, array,
13// plain object, or function.
14// eslint-disable-next-line @typescript-eslint/no-explicit-any
15type V = any
16
17export class ScriptError extends Error {}
18
19// ---------------------------------------------------------------- tokens
20
21type Tok =
22 | { k: 'num'; v: number; at: number }
23 | { k: 'str'; v: string; at: number }
24 | { k: 'tpl'; strs: string[]; exprs: string[]; at: number }
25 | { k: 'id' | 'op' | 'eof'; v: string; at: number }
26
27const OPS = [
28 '>>>=', '===', '!==', '**=', '>>>', '<<=', '>>=', '...', '=>', '==', '!=', '<=', '>=', '&&', '||', '??', '?.', '++', '--',
29 '+=', '-=', '*=', '/=', '%=', '**', '<<', '>>', '&=', '|=', '^=', '&&=', '||=', '??=',
30].sort((a, b) => b.length - a.length)
31const SINGLE = '{}()[];,.?:+-*/%<>=!&|^~'
32
33function lex(src: string): Tok[] {
34 const toks: Tok[] = []
35 let i = 0
36 const fail = (what: string) => {
37 throw new ScriptError(`${what} at line ${src.slice(0, i).split('\n').length}`)
38 }
39 while (i < src.length) {
40 const c = src[i] as string
41 if (/\s/.test(c)) {
42 i++
43 } else if (src.startsWith('//', i)) {
44 while (i < src.length && src[i] !== '\n') i++
45 } else if (src.startsWith('/*', i)) {
46 const end = src.indexOf('*/', i + 2)
47 i = end < 0 ? src.length : end + 2
48 } else if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(src[i + 1] ?? ''))) {
49 const m = /^(0x[0-9a-f_]+|0b[01_]+|(\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(e[+-]?\d+)?)/i.exec(src.slice(i))
50 const text = m?.[0] ?? c
51 toks.push({ k: 'num', v: Number(text.replace(/_/g, '')), at: i })
52 i += text.length
53 } else if (/[A-Za-z_$]/.test(c)) {
54 const m = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(src.slice(i))
55 const text = m?.[0] ?? c
56 toks.push({ k: 'id', v: text, at: i })
57 i += text.length
58 } else if (c === '"' || c === "'") {
59 const at = i
60 let v = ''
61 i++
62 while (i < src.length && src[i] !== c) {
63 if (src[i] === '\\') {
64 const [ch, next] = escapeAt(src, i + 1)
65 v += ch
66 i = next
67 } else {
68 v += src[i]
69 i++
70 }
71 }
72 if (i >= src.length) fail('unclosed string')
73 i++
74 toks.push({ k: 'str', v, at })
75 } else if (c === '`') {
76 const at = i
77 const strs: string[] = []
78 const exprs: string[] = []
79 let v = ''
80 i++
81 while (i < src.length && src[i] !== '`') {
82 if (src[i] === '\\') {
83 const [ch, next] = escapeAt(src, i + 1)
84 v += ch
85 i = next
86 } else if (src.startsWith('${', i)) {
87 strs.push(v)
88 v = ''
89 const start = i + 2
90 i = closeOf(src, start)
91 exprs.push(src.slice(start, i))
92 i++
93 } else {
94 v += src[i]
95 i++
96 }
97 }
98 if (i >= src.length) fail('unclosed template')
99 i++
100 strs.push(v)
101 toks.push({ k: 'tpl', strs, exprs, at })
102 } else {
103 // "?." before a digit is a ternary and a number: t > 0 ? .5 : 1
104 const op = OPS.find(o => src.startsWith(o, i) && !(o === '?.' && /\d/.test(src[i + 2] ?? ''))) ?? (SINGLE.includes(c) ? c : undefined)
105 if (!op) fail(`unexpected "${c}"`)
106 toks.push({ k: 'op', v: op as string, at: i })
107 i += (op as string).length
108 }
109 }
110 toks.push({ k: 'eof', v: '', at: src.length })
111
112 return toks
113}
114
115const SIMPLE_ESCAPES: Record<string, string> = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' }
116
117// The character an escape at i (just after its backslash) stands for, and
118// where the escape ends.
119function escapeAt(src: string, i: number): [string, number] {
120 const c = src[i] ?? ''
121 const simple = SIMPLE_ESCAPES[c]
122 if (simple !== undefined) return [simple, i + 1]
123 if (c === 'x' || c === 'u') {
124 const m = (c === 'x' ? /^[0-9a-f]{2}/i : /^([0-9a-f]{4}|\{[0-9a-f]{1,6}\})/i).exec(src.slice(i + 1, i + 9))
125 const code = m ? parseInt(m[0].replace(/[{}]/g, ''), 16) : NaN
126 if (m && code <= 0x10ffff) return [String.fromCodePoint(code), i + 1 + m[0].length]
127 }
128
129 return [c, i + 1]
130}
131
132// From just after a template's "${", the index of the "}" that closes it,
133// stepping over strings and nested templates so their braces do not count.
134function closeOf(src: string, i: number): number {
135 let depth = 1
136 while (i < src.length) {
137 const c = src[i]
138 if (c === '"' || c === "'" || c === '`') {
139 i = skipString(src, i)
140 continue
141 }
142 if (c === '{') depth++
143 else if (c === '}' && --depth === 0) return i
144 i++
145 }
146
147 return i
148}
149
150// The index just past the string literal that opens at i.
151function skipString(src: string, i: number): number {
152 const q = src[i]
153 i++
154 while (i < src.length && src[i] !== q) {
155 if (src[i] === '\\') i += 2
156 else if (q === '`' && src.startsWith('${', i)) i = closeOf(src, i + 2) + 1
157 else i++
158 }
159
160 return i + 1
161}
162
163// ---------------------------------------------------------------- syntax
164
165type Node = { t: string; [key: string]: V }
166
167const ASSIGN = new Set(['=', '+=', '-=', '*=', '/=', '%=', '**=', '<<=', '>>=', '>>>=', '&=', '|=', '^=', '&&=', '||=', '??='])
168const BINARY: Record<string, number> = {
169 '??': 1, '||': 2, '&&': 3, '|': 4, '^': 5, '&': 6,
170 '==': 7, '!=': 7, '===': 7, '!==': 7,
171 '<': 8, '>': 8, '<=': 8, '>=': 8, in: 8,
172 '<<': 9, '>>': 9, '>>>': 9,
173 '+': 10, '-': 10, '*': 11, '/': 11, '%': 11, '**': 12,
174}
175
176function parse(src: string): Node[] {
177 const toks = lex(src)
178 let p = 0
179 const peek = (o = 0) => toks[Math.min(p + o, toks.length - 1)] as Tok
180 const fail = (what: string): never => {
181 throw new ScriptError(`${what} at line ${src.slice(0, peek().at).split('\n').length}`)
182 }
183 const is = (v: string, o = 0) => {
184 const tok = peek(o)
185
186 return (tok.k === 'op' || tok.k === 'id') && tok.v === v
187 }
188 const eat = (v: string) => {
189 if (!is(v)) fail(`expected "${v}" but found "${'v' in peek() ? String((peek() as { v: unknown }).v) : peek().k}"`)
190 p++
191 }
192 const maybe = (v: string) => (is(v) ? (p++, true) : false)
193 const name = () => {
194 const tok = peek()
195 if (tok.k !== 'id') fail('expected a name')
196 p++
197
198 return (tok as { v: string }).v
199 }
200 const semi = () => {
201 maybe(';')
202 }
203
204 function program(): Node[] {
205 const body: Node[] = []
206 while (peek().k !== 'eof') body.push(statement())
207
208 return body
209 }
210
211 function block(): Node[] {
212 eat('{')
213 const body: Node[] = []
214 while (!is('}')) {
215 if (peek().k === 'eof') fail('missing "}"')
216 body.push(statement())
217 }
218 eat('}')
219
220 return body
221 }
222
223 function pattern(): Node {
224 let target: Node
225 if (maybe('[')) {
226 const items: (Node | null)[] = []
227 while (!is(']')) {
228 if (is(',')) items.push(null)
229 else items.push(restOr(pattern))
230 if (!is(']')) eat(',')
231 }
232 eat(']')
233 target = { t: 'arrPat', items }
234 } else if (maybe('{')) {
235 const props: [string, Node][] = []
236 while (!is('}')) {
237 if (maybe('...')) {
238 props.push(['...', { t: 'rest', target: pattern() }])
239 } else {
240 const key = name()
241 props.push([key, maybe(':') ? pattern() : withDefault({ t: 'id', name: key })])
242 }
243 if (!is('}')) eat(',')
244 }
245 eat('}')
246 target = { t: 'objPat', props }
247 } else {
248 target = { t: 'id', name: name() }
249 }
250
251 return withDefault(target)
252 }
253 function withDefault(target: Node): Node {
254 return maybe('=') ? { t: 'default', target, def: assign() } : target
255 }
256 // A "...rest" item in a list pattern, or an ordinary one.
257 function restOr(item: () => Node): Node {
258 return maybe('...') ? { t: 'rest', target: pattern() } : item()
259 }
260
261 // An array or object literal on the left of "=" is a pattern: [a, b] = [b, a].
262 function toPattern(e: Node): Node {
263 switch (e.t) {
264 case 'id':
265 case 'member':
266 return e
267 case 'spread':
268 return { t: 'rest', target: toPattern(e.e) }
269 case 'assign':
270 if (e.op === '=') return { t: 'default', target: toPattern(e.target), def: e.value }
271 break
272 case 'arr':
273 return { t: 'arrPat', items: (e.items as Node[]).map(toPattern) }
274 case 'obj':
275 return {
276 t: 'objPat',
277 props: (e.props as [Node | string, Node][]).map(([k, v]) => (typeof k === 'string' ? [k, k === '...' ? { t: 'rest', target: toPattern(v) } : toPattern(v)] : fail('cannot assign to that'))),
278 }
279 }
280
281 return fail('cannot assign to that')
282 }
283
284 function declaration(): Node {
285 const kind = name()
286 const decls: [Node, Node | undefined][] = []
287 do {
288 const target = pattern()
289 decls.push(target.t === 'default' ? [target.target, target.def] : [target, undefined])
290 } while (maybe(','))
291
292 return { t: 'decl', kind, decls }
293 }
294
295 function params(): Node[] {
296 eat('(')
297 const list: Node[] = []
298 while (!is(')')) {
299 list.push(restOr(pattern))
300 if (!is(')')) eat(',')
301 }
302 eat(')')
303
304 return list
305 }
306
307 function statement(): Node {
308 const tok = peek()
309 if (is('{')) return { t: 'block', body: block() }
310 if (is(';')) {
311 p++
312
313 return { t: 'empty' }
314 }
315 if (tok.k === 'id') {
316 switch (tok.v) {
317 case 'let':
318 case 'const':
319 case 'var': {
320 const d = declaration()
321 semi()
322
323 return d
324 }
325 case 'function': {
326 p++
327 const fnName = name()
328
329 return { t: 'fnDecl', name: fnName, fn: { t: 'fn', name: fnName, params: params(), body: block() } }
330 }
331 case 'if': {
332 p++
333 eat('(')
334 const test = expression()
335 eat(')')
336 const then = statement()
337
338 return { t: 'if', test, then, otherwise: maybe('else') ? statement() : undefined }
339 }
340 case 'while': {
341 p++
342 eat('(')
343 const test = expression()
344 eat(')')
345
346 return { t: 'while', test, body: statement() }
347 }
348 case 'do': {
349 p++
350 const body = statement()
351 eat('while')
352 eat('(')
353 const test = expression()
354 eat(')')
355 semi()
356
357 return { t: 'doWhile', test, body }
358 }
359 case 'for': {
360 p++
361 eat('(')
362 const isDecl = is('let') || is('const') || is('var')
363 if (isDecl && (is('of', 2) || is('in', 2) || peek(1).k === 'op')) {
364 // for (const x of xs), for (const [a, b] of xs), for (const k in o)
365 const save = p
366 const kind = name()
367 const target = pattern()
368 if (is('of') || is('in')) {
369 const each = name()
370 const iter = expression()
371 eat(')')
372
373 return { t: each === 'of' ? 'forOf' : 'forIn', kind, target, iter, body: statement() }
374 }
375 p = save
376 }
377 const init = is(';') ? undefined : isDecl ? declaration() : { t: 'expr', e: expression() }
378 eat(';')
379 const test = is(';') ? undefined : expression()
380 eat(';')
381 const update = is(')') ? undefined : expression()
382 eat(')')
383
384 return { t: 'for', init, test, update, body: statement() }
385 }
386 case 'switch': {
387 p++
388 eat('(')
389 const disc = expression()
390 eat(')')
391 eat('{')
392 const cases: { test?: Node; body: Node[] }[] = []
393 while (!is('}')) {
394 let test: Node | undefined
395 if (maybe('case')) test = expression()
396 else eat('default')
397 eat(':')
398 const body: Node[] = []
399 while (!is('case') && !is('default') && !is('}')) {
400 if (peek().k === 'eof') fail('missing "}"')
401 body.push(statement())
402 }
403 cases.push({ test, body })
404 }
405 eat('}')
406
407 return { t: 'switch', disc, cases }
408 }
409 case 'return': {
410 p++
411 const value = is(';') || is('}') ? undefined : expression()
412 semi()
413
414 return { t: 'return', value }
415 }
416 case 'break':
417 case 'continue':
418 p++
419 semi()
420
421 return { t: tok.v }
422 }
423 }
424 const e = expression()
425 semi()
426
427 return { t: 'expr', e }
428 }
429
430 function expression(): Node {
431 let e = assign()
432 while (maybe(',')) e = { t: 'seq', a: e, b: assign() }
433
434 return e
435 }
436
437 // An arrow function starts here: x =>, () =>, (a, b) =>, ([a, b]) =>.
438 function isArrow(): boolean {
439 if (peek().k === 'id' && is('=>', 1)) return true
440 if (!is('(')) return false
441 let depth = 0
442 for (let o = 0; ; o++) {
443 const tok = peek(o)
444 if (tok.k === 'eof') return false
445 if (tok.k === 'op' && ['(', '[', '{'].includes(tok.v)) depth++
446 if (tok.k === 'op' && [')', ']', '}'].includes(tok.v)) depth--
447 if (depth === 0) return is('=>', o + 1)
448 }
449 }
450
451 function assign(): Node {
452 if (isArrow()) {
453 const list = peek().k === 'id' ? [{ t: 'id', name: name() }] : params()
454 eat('=>')
455 if (is('{')) return { t: 'fn', arrow: true, params: list, body: block() }
456
457 return { t: 'fn', arrow: true, params: list, body: [{ t: 'return', value: assign() }] }
458 }
459 const left = conditional()
460 const tok = peek()
461 if (tok.k === 'op' && ASSIGN.has(tok.v)) {
462 const target = tok.v === '=' && (left.t === 'arr' || left.t === 'obj') ? toPattern(left) : left
463 if (!['id', 'member', 'arrPat', 'objPat'].includes(target.t)) fail('cannot assign to that')
464 p++
465
466 return { t: 'assign', op: tok.v, target, value: assign() }
467 }
468
469 return left
470 }
471
472 function conditional(): Node {
473 const test = binary(1)
474 if (!maybe('?')) return test
475 const a = assign()
476 eat(':')
477
478 return { t: 'cond', test, a, b: assign() }
479 }
480
481 function binary(min: number): Node {
482 let left = unary()
483 for (;;) {
484 const tok = peek()
485 const op = tok.k === 'op' || (tok.k === 'id' && tok.v === 'in') ? tok.v : undefined
486 const prec = op === undefined ? undefined : BINARY[op]
487 if (op === undefined || prec === undefined || prec < min) return left
488 p++
489 // ** binds to the right; the rest to the left.
490 const right = binary(op === '**' ? prec : prec + 1)
491 left = { t: ['&&', '||', '??'].includes(op) ? 'logic' : 'bin', op, l: left, r: right }
492 }
493 }
494
495 function unary(): Node {
496 const tok = peek()
497 if (tok.k === 'op' && ['-', '+', '!', '~'].includes(tok.v)) {
498 p++
499
500 return { t: 'unary', op: tok.v, arg: unary() }
501 }
502 if (tok.k === 'op' && (tok.v === '++' || tok.v === '--')) {
503 p++
504
505 return { t: 'update', op: tok.v, prefix: true, target: unary() }
506 }
507 if (tok.k === 'id' && tok.v === 'typeof') {
508 p++
509
510 return { t: 'typeof', arg: unary() }
511 }
512 const e = postfix()
513 const after = peek()
514 if (after.k === 'op' && (after.v === '++' || after.v === '--')) {
515 p++
516
517 return { t: 'update', op: after.v, prefix: false, target: e }
518 }
519
520 return e
521 }
522
523 function args(): Node[] {
524 eat('(')
525 const list: Node[] = []
526 while (!is(')')) {
527 list.push(maybe('...') ? { t: 'spread', e: assign() } : assign())
528 if (!is(')')) eat(',')
529 }
530 eat(')')
531
532 return list
533 }
534
535 function postfix(): Node {
536 let e: Node
537 if (maybe('new')) {
538 const callee = primary()
539 e = { t: 'call', callee, args: is('(') ? args() : [] }
540 } else {
541 e = primary()
542 }
543 // Past a "?.", the whole rest of the chain is optional: o?.a.b() is
544 // undefined when o is, as in JavaScript.
545 let optional = false
546 for (;;) {
547 if (maybe('?.')) {
548 optional = true
549 if (is('(')) {
550 e = { t: 'call', callee: e, args: args(), optional }
551 continue
552 }
553 if (!is('[')) {
554 e = { t: 'member', obj: e, prop: { t: 'lit', v: name() }, optional }
555 continue
556 }
557 }
558 if (maybe('.')) e = { t: 'member', obj: e, prop: { t: 'lit', v: name() }, optional }
559 else if (maybe('[')) {
560 const prop = expression()
561 eat(']')
562 e = { t: 'member', obj: e, prop, optional }
563 } else if (is('(')) e = { t: 'call', callee: e, args: args(), optional }
564 else return e
565 }
566 }
567
568 function primary(): Node {
569 const tok = peek()
570 p++
571 if (tok.k === 'num') return { t: 'lit', v: tok.v }
572 if (tok.k === 'str') return { t: 'lit', v: tok.v }
573 if (tok.k === 'tpl') return { t: 'tpl', strs: tok.strs, exprs: tok.exprs.map(source => parseExpression(source)) }
574 if (tok.k === 'id') {
575 if (tok.v === 'true') return { t: 'lit', v: true }
576 if (tok.v === 'false') return { t: 'lit', v: false }
577 if (tok.v === 'null') return { t: 'lit', v: null }
578 if (tok.v === 'undefined') return { t: 'lit', v: undefined }
579 if (tok.v === 'function') {
580 const fnName = peek().k === 'id' ? name() : undefined
581
582 // A function expression's name is its own to use inside; a
583 // declaration's is the enclosing scope's.
584 return { t: 'fn', name: fnName, self: fnName !== undefined, params: params(), body: block() }
585 }
586
587 return { t: 'id', name: tok.v }
588 }
589 if (tok.k === 'op' && tok.v === '(') {
590 const e = expression()
591 eat(')')
592
593 return e
594 }
595 if (tok.k === 'op' && tok.v === '[') {
596 const items: Node[] = []
597 while (!is(']')) {
598 items.push(maybe('...') ? { t: 'spread', e: assign() } : assign())
599 if (!is(']')) eat(',')
600 }
601 eat(']')
602
603 return { t: 'arr', items }
604 }
605 if (tok.k === 'op' && tok.v === '{') {
606 const props: [Node | string, Node][] = []
607 while (!is('}')) {
608 if (maybe('...')) {
609 props.push(['...', assign()])
610 } else {
611 const key = peek()
612 let k: Node | string
613 if (maybe('[')) {
614 k = expression()
615 eat(']')
616 } else {
617 p++
618 k = key.k === 'num' || key.k === 'str' || key.k === 'id' ? String((key as { v: unknown }).v) : fail('bad property name')
619 }
620 if (typeof k === 'string' && (is(',') || is('}'))) props.push([k, { t: 'id', name: k }])
621 else if (typeof k === 'string' && is('(')) props.push([k, { t: 'fn', name: k, params: params(), body: block() }])
622 else {
623 eat(':')
624 props.push([k, assign()])
625 }
626 }
627 if (!is('}')) eat(',')
628 }
629 eat('}')
630
631 return { t: 'obj', props }
632 }
633
634 return fail(`unexpected "${(tok as { v?: unknown }).v ?? (tok as { k: string }).k}"`)
635 }
636
637 function parseExpression(source: string): Node {
638 const [first] = parse(`(${source});`)
639
640 return (first as Node).e as Node
641 }
642
643 return program()
644}
645
646// ---------------------------------------------------------------- running
647
648// The fuel left for the current run; every statement, loop turn and call
649// spends one.
650let fuel = 0
651// When the run must be over by the clock: what a host method does on one
652// unit of fuel (copying a huge object, reading a long string as a number)
653// is caught here when fuel alone would not catch it.
654let deadline = Infinity
655const late = () => {
656 if (performance.now() > deadline) throw new ScriptError('the scene code took too long to run')
657}
658const burn = () => {
659 if (--fuel < 0) throw new ScriptError('the scene code took too long to run')
660 if ((fuel & 255) === 0) late()
661}
662// Work over n elements inside one host method spends fuel too, so a loop of
663// slices over a huge array cannot run for seconds on one unit each.
664const cost = (n: number) => {
665 fuel -= Math.max(0, Math.min(n, 2 ** 31 - 1)) >> 5
666 if (fuel < 0) throw new ScriptError('the scene code took too long to run')
667 if (n >= 1024) late()
668}
669// The keys of an object about to be copied or walked, paid for.
670const keysOf = (v: Record<string, V>) => sized(Object.keys(v))
671
672// The largest array the code may build, and the longest string.
673const MAX_LENGTH = 100_000
674
675class Scope {
676 vars = new Map<string, V>()
677 consts?: Set<string>
678 // A function's own scope, where its var declarations land.
679 constructor(readonly parent?: Scope, readonly isFunction = false) {}
680 declare(name: string, value: V, isConst = false) {
681 this.vars.set(name, value)
682 if (isConst) (this.consts ??= new Set()).add(name)
683 else this.consts?.delete(name)
684 }
685 lookup(name: string): V {
686 for (let s: Scope | undefined = this; s; s = s.parent) if (s.vars.has(name)) return s.vars.get(name)
687 throw new ScriptError(`${name} is not defined`)
688 }
689 assign(name: string, value: V) {
690 for (let s: Scope | undefined = this; s; s = s.parent) {
691 if (s.vars.has(name)) {
692 if (s.consts?.has(name)) throw new ScriptError(`${name} is a const`)
693 s.vars.set(name, value)
694
695 return
696 }
697 }
698 throw new ScriptError(`${name} is not defined`)
699 }
700}
701
702// A function the code made.
703class Fn {
704 constructor(
705 readonly params: Node[],
706 readonly body: Exec,
707 readonly scope: Scope,
708 readonly name?: string,
709 readonly self = false,
710 // An arrow function keeps the `this` of where it was written.
711 readonly isArrow = false,
712 ) {}
713}
714
715type Eval = (s: Scope) => V
716type Signal = undefined | 'break' | 'continue' | { value: V }
717type Exec = (s: Scope) => Signal
718
719export const obj = (entries: Record<string, V> = {}) => Object.assign(Object.create(null) as Record<string, V>, entries)
720const isObj = (v: V): v is Record<string, V> => typeof v === 'object' && v !== null && !Array.isArray(v) && Object.getPrototypeOf(v) === null
721const describe = (v: V) => (v === null ? 'null' : v === undefined ? 'undefined' : typeof v === 'string' ? `"${v.slice(0, 20)}"` : Array.isArray(v) ? 'an array' : typeof v)
722
723// What a value reads as in a string: objects have no prototype here, so the
724// host's String would throw on them.
725const str = (v: V, depth = 0): string => {
726 if (Array.isArray(v)) return depth > 8 ? '' : joinAll(v, ',', depth + 1)
727 if (isObj(v)) return '[object Object]'
728 if (v instanceof Fn || typeof v === 'function') return `function ${v.name ?? ''}() {}`
729
730 return String(v)
731}
732const joinAll = (a: V[], sep: string, depth = 0): string => {
733 cost(a.length)
734 let out = ''
735 for (let i = 0; i < a.length; i++) {
736 if (i > 0) out += sep
737 const v = a[i]
738 if (v !== null && v !== undefined) out += str(v, depth)
739 checkLength(out.length)
740 }
741
742 return out
743}
744
745// `self` is what `this` means inside: the holder of a method call.
746export function call(f: V, args: V[], self?: V): V {
747 burn()
748 if (f instanceof Fn) {
749 const scope = new Scope(f.scope, true)
750 if (f.self) scope.declare(f.name as string, f)
751 if (!f.isArrow) scope.declare('this', self)
752 bindList(f.params, args, scope, 'let')
753 scope.declare('arguments', args)
754 const signal = f.body(scope)
755
756 return typeof signal === 'object' ? signal.value : undefined
757 }
758 if (typeof f === 'function') return (f as (...a: V[]) => V)(...args)
759 throw new ScriptError(`${describe(f)} is not a function`)
760}
761
762const checkLength = (n: number) => {
763 if (n > MAX_LENGTH) throw new ScriptError('an array or string grew too big')
764
765 return n
766}
767
768// The length of the array a method over a would build or walk.
769const sized = (a: V[]) => (cost(a.length), a)
770
771const ARRAY: Record<string, (a: V[], ...args: V[]) => V> = {
772 push: (a, ...v) => (checkLength(a.length + v.length), a.push(...v)),
773 pop: a => a.pop(),
774 shift: a => sized(a).shift(),
775 unshift: (a, ...v) => (checkLength(a.length + v.length), sized(a).unshift(...v)),
776 slice: (a, s, e) => sized(a).slice(s, e),
777 splice: (a, s, n, ...v) => (s === undefined ? [] : (checkLength(a.length + v.length), sized(a).splice(s, n === undefined ? a.length : n, ...v))),
778 concat: (a, ...v) => {
779 let n = a.length
780 for (const x of v) n += Array.isArray(x) ? x.length : 1
781 cost(checkLength(n))
782
783 return a.concat(...v)
784 },
785 indexOf: (a, v, from) => sized(a).indexOf(v, from),
786 lastIndexOf: (a, v, from) => (from === undefined ? sized(a).lastIndexOf(v) : sized(a).lastIndexOf(v, from)),
787 includes: (a, v, from) => sized(a).includes(v, from),
788 join: (a, sep) => joinAll(a, sep === undefined ? ',' : str(sep)),
789 reverse: a => sized(a).reverse(),
790 fill: (a, v, s, e) => sized(a).fill(v, s, e),
791 at: (a, i) => a.at(i),
792 map: (a, f) => a.map((v, i) => call(f, [v, i, a])),
793 filter: (a, f) => a.filter((v, i) => call(f, [v, i, a])),
794 forEach: (a, f) => void a.forEach((v, i) => call(f, [v, i, a])),
795 some: (a, f) => a.some((v, i) => call(f, [v, i, a])),
796 every: (a, f) => a.every((v, i) => call(f, [v, i, a])),
797 find: (a, f) => a.find((v, i) => call(f, [v, i, a])),
798 findIndex: (a, f) => a.findIndex((v, i) => call(f, [v, i, a])),
799 reduce: (a, f, ...init) =>
800 init.length > 0 ? a.reduce((acc, v, i) => call(f, [acc, v, i, a]), init[0]) : a.reduce((acc, v, i) => call(f, [acc, v, i, a])),
801 sort: (a, f) => {
802 cost(a.length * Math.ceil(Math.log2(a.length + 1)))
803
804 const byString = (x: V, y: V) => {
805 const sx = str(x)
806 const sy = str(y)
807
808 return sx < sy ? -1 : sx > sy ? 1 : 0
809 }
810
811 return a.sort(f === undefined ? byString : (x, y) => Number(call(f, [x, y])) || 0)
812 },
813 flat: a => {
814 let n = 0
815 for (const x of a) n += Array.isArray(x) ? x.length : 1
816 cost(checkLength(n))
817
818 return a.flat()
819 },
820}
821
822const text = (s: string) => (cost(s.length), s)
823const padTo = (n: V) => (cost(checkLength(Number(n) || 0)), n)
824
825const STRING: Record<string, (s: string, ...args: V[]) => V> = {
826 slice: (s, a, b) => text(s).slice(a, b),
827 substring: (s, a, b) => text(s).substring(a, b),
828 charAt: (s, i) => s.charAt(i),
829 charCodeAt: (s, i) => s.charCodeAt(i),
830 codePointAt: (s, i) => s.codePointAt(i),
831 indexOf: (s, v, from) => text(s).indexOf(str(v), from),
832 lastIndexOf: (s, v, from) => text(s).lastIndexOf(str(v), from),
833 includes: (s, v, from) => text(s).includes(str(v), from),
834 startsWith: (s, v, from) => s.startsWith(str(v), from),
835 endsWith: (s, v, end) => s.endsWith(str(v), end),
836 split: (s, sep, limit) => text(s).split(sep === undefined ? (undefined as unknown as string) : str(sep), limit),
837 repeat: (s, n) => {
838 const times = Math.max(0, Math.floor(Number(n) || 0))
839 cost(checkLength(s.length * times))
840
841 return s.repeat(times)
842 },
843 padStart: (s, n, f) => s.padStart(padTo(n), f === undefined ? undefined : str(f)),
844 padEnd: (s, n, f) => s.padEnd(padTo(n), f === undefined ? undefined : str(f)),
845 toUpperCase: s => text(s).toUpperCase(),
846 toLowerCase: s => text(s).toLowerCase(),
847 trim: s => text(s).trim(),
848 replace: (s, a, b) => {
849 const out = text(s).replace(str(a), str(b))
850 checkLength(out.length)
851
852 return out
853 },
854 replaceAll: (s, a, b) => {
855 const parts = text(s).split(str(a))
856 const glue = str(b)
857 checkLength(s.length + (parts.length - 1) * glue.length)
858
859 return parts.join(glue)
860 },
861 at: (s, i) => s.at(i),
862}
863
864const own = <T>(table: Record<string, T>, key: V): T | undefined =>
865 Object.prototype.hasOwnProperty.call(table, str(key)) ? table[str(key)] : undefined
866
867// Names hung on a library function, as Array.from hangs on Array.
868const STATICS = new WeakMap<object, Record<string, V>>()
869
870function get(target: V, key: V): V {
871 if (target === null || target === undefined) throw new ScriptError(`cannot read "${str(key)}" of ${target}`)
872 if (Array.isArray(target)) {
873 if (typeof key === 'number') return target[key]
874 if (key === 'length') return target.length
875 const m = own(ARRAY, key)
876 if (m) return (...a: V[]) => m(target, ...a)
877 if (/^\d+$/.test(str(key))) return target[Number(key)]
878
879 return undefined
880 }
881 if (typeof target === 'string') {
882 if (typeof key === 'number' || /^\d+$/.test(str(key))) return target[Number(key)]
883 if (key === 'length') return target.length
884 const m = own(STRING, key)
885
886 return m ? (...a: V[]) => m(target, ...a) : undefined
887 }
888 if (typeof target === 'number') {
889 if (key === 'toFixed') return (d: V) => target.toFixed(Math.max(0, Math.min(20, Number(d) || 0)))
890
891 return undefined
892 }
893 if (isObj(target)) return own(target, key)
894 if (typeof target === 'function') {
895 const statics = STATICS.get(target)
896
897 return statics ? own(statics, key) : undefined
898 }
899
900 return undefined
901}
902
903function set(target: V, key: V, value: V): V {
904 if (Array.isArray(target)) {
905 if (key === 'length') {
906 target.length = checkLength(Math.max(0, Math.floor(Number(value) || 0)))
907
908 return value
909 }
910 const i = Number(key)
911 if (!Number.isInteger(i) || i < 0) throw new ScriptError(`bad array index ${str(key)}`)
912 checkLength(i + 1)
913 target[i] = value
914
915 return value
916 }
917 if (isObj(target)) {
918 if (Object.isFrozen(target)) throw new ScriptError(`cannot set "${str(key)}": built-in objects are read-only`)
919 target[str(key)] = value
920
921 return value
922 }
923 throw new ScriptError(`cannot set "${str(key)}" on ${describe(target)}`)
924}
925
926// An operand read as a number or compared loosely: a long string costs its
927// length to scan, and a plain object (which has no toString here) or an array
928// reads as the string it would in JavaScript, instead of throwing.
929const operand = (v: V): V => {
930 if (typeof v === 'string') {
931 cost(v.length)
932
933 return v
934 }
935
936 return isObj(v) || Array.isArray(v) ? str(v) : v
937}
938
939function binop(op: string, a: V, b: V): V {
940 if (op === 'in') return isObj(b) ? Object.prototype.hasOwnProperty.call(b, str(a)) : Array.isArray(b) ? Number(a) in b : false
941 if (op === '==' || op === '!=') {
942 // Two objects are the same only when they are one object.
943 const same = isPrimitive(a) || isPrimitive(b) ? operand(a) == operand(b) : a === b // eslint-disable-line eqeqeq
944
945 return op === '==' ? same : !same
946 }
947 if (op !== '+' && op !== '===' && op !== '!==') {
948 a = operand(a)
949 b = operand(b)
950 }
951 switch (op) {
952 case '+': {
953 if (typeof a === 'number' && typeof b === 'number') return a + b
954 const v = isPrimitive(a) && isPrimitive(b) ? a + b : str(a) + str(b)
955 if (typeof v === 'string') checkLength(v.length)
956
957 return v
958 }
959 case '-': return a - b
960 case '*': return a * b
961 case '/': return a / b
962 case '%': return a % b
963 case '**': return a ** b
964 case '===': return a === b
965 case '!==': return a !== b
966 case '<': return a < b
967 case '>': return a > b
968 case '<=': return a <= b
969 case '>=': return a >= b
970 case '&': return a & b
971 case '|': return a | b
972 case '^': return a ^ b
973 case '<<': return a << b
974 case '>>': return a >> b
975 case '>>>': return a >>> b
976 }
977 throw new ScriptError(`unknown operator ${op}`)
978}
979
980const isPrimitive = (v: V) => v === null || (typeof v !== 'object' && typeof v !== 'function')
981
982// Binds a pattern (a name, [a, b], {x, y}, with defaults and ...rest) to a
983// value: kind is let, const or var to declare, or "assign" to assign.
984function bind(pattern: Node, value: V, scope: Scope, kind: string) {
985 switch (pattern.t) {
986 case 'id': {
987 if (kind === 'assign') return scope.assign(pattern.name, value)
988 let target = scope
989 if (kind === 'var') while (!target.isFunction && target.parent) target = target.parent
990 target.declare(pattern.name, value, kind === 'const')
991
992 return
993 }
994 case 'member':
995 set(evalNode(pattern.obj)(scope), evalNode(pattern.prop)(scope), value)
996
997 return
998 case 'default':
999 bind(pattern.target, value === undefined ? evalNode(pattern.def)(scope) : value, scope, kind)
1000
1001 return
1002 case 'arrPat':
1003 bindList(pattern.items, value, scope, kind)
1004
1005 return
1006 case 'objPat': {
1007 const taken = new Set<string>()
1008 for (const [key, target] of pattern.props as [string, Node][]) {
1009 if (key !== '...') {
1010 taken.add(key)
1011 bind(target, get(value, key), scope, kind)
1012 continue
1013 }
1014 const rest = obj()
1015 if (isObj(value)) for (const k of keysOf(value)) if (!taken.has(k)) rest[k] = value[k]
1016 bind(target.target, rest, scope, kind)
1017 }
1018
1019 return
1020 }
1021 }
1022 throw new ScriptError('cannot assign to that')
1023}
1024
1025// Binds the items of a list pattern (or parameters) to a value's elements.
1026function bindList(items: (Node | null)[], value: V, scope: Scope, kind: string) {
1027 for (let i = 0; i < items.length; i++) {
1028 const item = items[i]
1029 if (!item) continue
1030 if (item.t === 'rest') {
1031 const list = Array.isArray(value) ? value : toList(value)
1032 cost(list.length)
1033 bind(item.target, list.slice(i), scope, kind)
1034
1035 return
1036 }
1037 bind(item, get(value, i), scope, kind)
1038 }
1039}
1040
1041// Calls visit on node and everything under it, stopping at functions unless
1042// told to go in.
1043function walk(node: V, visit: (n: Node) => void, intoFns: boolean) {
1044 if (Array.isArray(node)) {
1045 for (const n of node) walk(n, visit, intoFns)
1046
1047 return
1048 }
1049 if (node === null || typeof node !== 'object') return
1050 if (typeof node.t === 'string') {
1051 visit(node)
1052 if (node.t === 'fn' && !intoFns) return
1053 }
1054 for (const k in node) if (k !== 't') walk(node[k], visit, intoFns)
1055}
1056
1057// The names a pattern binds.
1058function patternNames(p: Node, out: string[]) {
1059 switch (p.t) {
1060 case 'id':
1061 out.push(p.name)
1062 break
1063 case 'default':
1064 case 'rest':
1065 patternNames(p.target, out)
1066 break
1067 case 'arrPat':
1068 for (const item of p.items as (Node | null)[]) if (item) patternNames(item, out)
1069 break
1070 case 'objPat':
1071 for (const [, target] of p.props as [string, Node][]) patternNames(target, out)
1072 }
1073}
1074
1075// The var names declared anywhere in a function body, which exist throughout it.
1076function varNames(body: Node[]): string[] {
1077 const out: string[] = []
1078 walk(body, n => {
1079 if (n.t === 'decl' && n.kind === 'var') for (const [target] of n.decls as [Node, Node | undefined][]) patternNames(target, out)
1080 if ((n.t === 'forOf' || n.t === 'forIn') && n.kind === 'var') patternNames(n.target, out)
1081 }, false)
1082
1083 return out
1084}
1085
1086const hasFn = (node: V): boolean => {
1087 let found = false
1088 walk(node, n => {
1089 if (n.t === 'fn') found = true
1090 }, true)
1091
1092 return found
1093}
1094
1095// Compiled closures, cached per node so a function body compiles once.
1096const compiled = new WeakMap<Node, Eval | Exec>()
1097function evalNode(node: Node): Eval {
1098 let f = compiled.get(node) as Eval | undefined
1099 if (!f) {
1100 f = compileExpr(node)
1101 compiled.set(node, f)
1102 }
1103
1104 return f
1105}
1106function execNode(node: Node): Exec {
1107 let f = compiled.get(node) as Exec | undefined
1108 if (!f) {
1109 f = compileStmt(node)
1110 compiled.set(node, f)
1111 }
1112
1113 return f
1114}
1115
1116function compileExpr(node: Node): Eval {
1117 switch (node.t) {
1118 case 'lit': {
1119 const v = node.v
1120
1121 return () => v
1122 }
1123 case 'tpl': {
1124 const strs = node.strs as string[]
1125 const exprs = (node.exprs as Node[]).map(evalNode)
1126
1127 return s => {
1128 let out = strs[0] ?? ''
1129 exprs.forEach((e, i) => {
1130 out += str(e(s)) + (strs[i + 1] ?? '')
1131 checkLength(out.length)
1132 })
1133
1134 return out
1135 }
1136 }
1137 case 'id': {
1138 const id = node.name as string
1139
1140 return s => s.lookup(id)
1141 }
1142 case 'arr': {
1143 const items = (node.items as Node[]).map(item => (item.t === 'spread' ? { spread: evalNode(item.e) } : { one: evalNode(item) }))
1144
1145 return s => {
1146 const out: V[] = []
1147 for (const item of items) {
1148 if (item.spread) out.push(...toList(item.spread(s)))
1149 else out.push(item.one(s))
1150 }
1151
1152 return out
1153 }
1154 }
1155 case 'obj': {
1156 const props = (node.props as [Node | string, Node][]).map(([k, v]) => ({ key: typeof k === 'string' ? k : evalNode(k), value: evalNode(v) }))
1157
1158 return s => {
1159 const out = obj()
1160 for (const { key, value } of props) {
1161 if (key === '...') {
1162 const v = value(s)
1163 if (isObj(v)) {
1164 keysOf(v)
1165 Object.assign(out, v)
1166 }
1167 }
1168 else out[typeof key === 'string' ? key : String(key(s))] = value(s)
1169 }
1170
1171 return out
1172 }
1173 }
1174 case 'fn': {
1175 const params = node.params as Node[]
1176 const body = compileBody(node.body as Node[], true)
1177 const name = node.name as string | undefined
1178 const self = node.self === true
1179
1180 return s => new Fn(params, body, s, name, self, node.arrow === true)
1181 }
1182 case 'seq': {
1183 const a = evalNode(node.a)
1184 const b = evalNode(node.b)
1185
1186 return s => (a(s), b(s))
1187 }
1188 case 'unary': {
1189 const arg = evalNode(node.arg)
1190 switch (node.op) {
1191 case '-': return s => -operand(arg(s))
1192 case '+': return s => +operand(arg(s))
1193 case '!': return s => !arg(s)
1194 default: return s => ~operand(arg(s))
1195 }
1196 }
1197 case 'typeof': {
1198 const arg = evalNode(node.arg)
1199 // typeof of a name never declared is "undefined"; any other error stands.
1200 const isName = (node.arg as Node).t === 'id'hooks/clawd.ts 264 lines1// Clawd, the mascot, in pixel art: a rounded orange block with two eyes and
2// four little feet, 14 pixels wide and 8 tall at scale 1, which is 4 rows of
3// cells (two pixels to a cell). Scenes draw it with a pose, a facing, eyes,
4// a color and a scale, and get back where its head, sides and feet are, so
5// they can hang a hat on it or put a prop in its hand.
6
7import type { Rgb } from './effects'
8import type { Mesh } from './render3d'
9
10export const CLAWD_W = 14
11export const CLAWD_H = 8
12
13// The body: B is the body, E an eye (its resting place, 1 wide and 2 tall).
14// The claws are drawn per pose beside it.
15const BODY = [
16 '..BBBBBBBBBB..',
17 '..BBBBBBBBBB..',
18 '.BBBEBBBBBEBB.',
19 '.BBBEBBBBBEBB.',
20 '.BBBBBBBBBBBB.',
21 '..BBBBBBBBBB..',
22]
23
24export type Arm = 'up' | 'out' | 'down'
25export const ARMS: Arm[] = ['up', 'out', 'down']
26
27// A claw's pixels for each pose, for the left side (x from the body's left
28// edge at 1, so 0 is just outside it); the right side mirrors them. Each
29// ends at its tip, where things are held.
30const CLAWS: Record<Arm, { pixels: [number, number][]; tip: [number, number] }> = {
31 up: { pixels: [[0, 1], [0, 2]], tip: [0, 0] },
32 out: { pixels: [[-1, 2], [0, 2]], tip: [-2, 2] },
33 down: { pixels: [[0, 3], [0, 4]], tip: [0, 5] },
34}
35const BODY_H = BODY.length
36// The feet: two pairs under the body, each foot a pixel wide and two tall.
37const FEET = [3, 5, 8, 10]
38const EYES = [4, 9]
39const EYE_ROW = 2
40
41export const CLAWD_ORANGE: Rgb = [217, 119, 87]
42const EYE_DARK: Rgb = [42, 22, 16]
43
44export type Pose = 'stand' | 'walk' | 'jump' | 'sit'
45export type Eyes = 'open' | 'closed' | 'wide'
46
47export type ClawdLook = {
48 pose: Pose
49 // Which walk frame (0 to 3) while walking: a step cycle, so a walker's
50 // feet match its travel when this comes from its x.
51 stride: number
52 // -1 left, 0 ahead, 1 right: where the eyes look, and which way the feet
53 // swing.
54 facing: number
55 eyes: Eyes
56 // The eyes shifted from where they rest, in pixels: a glance.
57 look: { x: number; y: number }
58 isBlinking: boolean
59 color: Rgb
60 eyeColor: Rgb
61 // Whole pixels per pixel: 1 is 14x8, 2 is 28x16.
62 scale: number
63 // Where each claw is held.
64 arms: { left: Arm; right: Arm }
65}
66
67export const LOOK: ClawdLook = {
68 pose: 'stand',
69 stride: 0,
70 facing: 0,
71 eyes: 'open',
72 look: { x: 0, y: 0 },
73 isBlinking: false,
74 color: CLAWD_ORANGE,
75 eyeColor: EYE_DARK,
76 scale: 1,
77 arms: { left: 'up', right: 'up' },
78}
79
80export type Pixel = { x: number; y: number; c: Rgb }
81
82// Where things attach, in pixels from the sprite's top-left: above the head,
83// at either side, at the feet, and at the eyes.
84export type Anchors = {
85 w: number
86 h: number
87 top: { x: number; y: number }
88 left: { x: number; y: number }
89 right: { x: number; y: number }
90 feet: { x: number; y: number }
91 eyes: { x: number; y: number; w: number }
92}
93
94// The sprite's pixels at scale 1, body first and eyes last so a body pixel
95// never covers an eye that glanced aside.
96function pixelsAtOne(look: ClawdLook): Pixel[] {
97 const body: Pixel[] = []
98 const eyes: Pixel[] = []
99 // Sitting, the body rests on the ground where the feet would be.
100 const drop = look.pose === 'sit' ? CLAWD_H - BODY_H : 0
101 BODY.forEach((row, y) => {
102 for (let x = 0; x < row.length; x++) if (row[x] !== '.') body.push({ x, y: y + drop, c: look.color })
103 })
104 for (const [px, py] of CLAWS[look.arms.left].pixels) body.push({ x: px, y: py + drop, c: look.color })
105 for (const [px, py] of CLAWS[look.arms.right].pixels) body.push({ x: CLAWD_W - 1 - px, y: py + drop, c: look.color })
106 const ex = Math.max(-1, Math.min(1, Math.round(look.look.x))) || look.facing
107 const ey = Math.max(-1, Math.min(1, Math.round(look.look.y)))
108 for (const x0 of EYES) {
109 const x = x0 + ex
110 const y = EYE_ROW + ey + drop
111 if (look.eyes === 'closed' || look.isBlinking) {
112 // A line where the eye's lower pixel was.
113 eyes.push({ x, y: y + 1, c: look.eyeColor }, { x: x + (x0 === EYES[0] ? -1 : 1), y: y + 1, c: look.eyeColor })
114 } else if (look.eyes === 'wide') {
115 for (let dx = -1; dx <= 0; dx++) for (let dy = 0; dy <= 1; dy++) eyes.push({ x: x + dx, y: y + dy, c: look.eyeColor })
116 } else {
117 eyes.push({ x, y, c: look.eyeColor }, { x, y: y + 1, c: look.eyeColor })
118 }
119 }
120 const feet: Pixel[] = []
121 if (look.pose !== 'sit') {
122 const top = BODY_H
123 const ground = CLAWD_H - 1
124 const dir = look.facing || 1
125 FEET.forEach((x0, i) => {
126 // The left pair and the right pair swing against each other.
127 const isFront = i < 2
128 let swing = 0
129 let isLifted = false
130 if (look.pose === 'walk') {
131 // A four-beat step: the pairs swing past each other, and the pair
132 // mid-swing is off the ground.
133 const beat = ((Math.round(look.stride) % 4) + 4) % 4
134 if (beat === 0) swing = isFront ? 1 : -1
135 else if (beat === 2) swing = isFront ? -1 : 1
136 else isLifted = beat === 1 ? !isFront : isFront
137 } else if (look.pose === 'jump') {
138 isLifted = true
139 }
140 const x = x0 + swing * dir
141 for (let y = top; y <= (isLifted ? ground - 1 : ground); y++) feet.push({ x, y, c: look.color })
142 })
143 }
144 // A claw held out reaches a pixel past the box on either side.
145 const inside = (p: Pixel) => p.x >= -1 && p.x <= CLAWD_W && p.y >= 0 && p.y < CLAWD_H
146
147 return [...body, ...feet, ...eyes].filter(inside)
148}
149
150// The sprite's pixels at its scale, each pixel of the small sprite drawn as
151// a block of scale by scale.
152export function clawdPixels(look: ClawdLook): Pixel[] {
153 const scale = Math.max(1, Math.min(4, Math.round(look.scale)))
154 const small = pixelsAtOne(look)
155 if (scale === 1) return small
156 const out: Pixel[] = []
157 for (const p of small) {
158 for (let dy = 0; dy < scale; dy++) for (let dx = 0; dx < scale; dx++) out.push({ x: p.x * scale + dx, y: p.y * scale + dy, c: p.c })
159 }
160
161 return out
162}
163
164export function clawdAnchors(look: ClawdLook): Anchors {
165 const s = Math.max(1, Math.min(4, Math.round(look.scale)))
166 const drop = look.pose === 'sit' ? CLAWD_H - BODY_H : 0
167 // The claws' tips, where things are held.
168 const leftTip = CLAWS[look.arms.left].tip
169 const rightTip = CLAWS[look.arms.right].tip
170
171 return {
172 w: CLAWD_W * s,
173 h: CLAWD_H * s,
174 top: { x: Math.floor((CLAWD_W / 2) * s), y: drop * s - 1 },
175 left: { x: leftTip[0] * s - (s - 1), y: (leftTip[1] + drop) * s },
176 right: { x: (CLAWD_W - 1 - rightTip[0]) * s + (s - 1), y: (rightTip[1] + drop) * s },
177 feet: { x: Math.floor((CLAWD_W / 2) * s), y: CLAWD_H * s },
178 eyes: { x: EYES[0]! * s, y: (EYE_ROW + drop) * s, w: (EYES[1]! - EYES[0]! + 1) * s },
179 }
180}
181
182// Clawd as a solid for 3D scenes, in the sprite's pixel units: x across its
183// middle, y up from its feet, z toward the viewer. The body is the sprite's
184// outline as a block as deep as it is tall, with feet at its corners and the
185// claws beside it. The eyes are not part of it: a scene draws them flat on
186// its face, where clawdPoints puts them.
187const meshes = new Map<string, Mesh>()
188const BODY_DEPTH = 3
189
190export function clawdMesh(look: ClawdLook): Mesh {
191 const drop = look.pose === 'sit' ? CLAWD_H - BODY_H : 0
192 const beat = look.pose === 'walk' ? ((Math.round(look.stride) % 4) + 4) % 4 : -1
193 const key = `${look.pose}|${beat}|${look.arms.left}|${look.arms.right}`
194 const done = meshes.get(key)
195 if (done) return done
196 const verts: number[] = []
197 const faces: number[] = []
198 const vert = (x: number, y: number, z: number) => {
199 verts.push(x, y, z)
200
201 return verts.length / 3 - 1
202 }
203 const poly = (ids: number[]) => {
204 for (let i = 1; i + 1 < ids.length; i++) faces.push(ids[0]!, ids[i]!, ids[i + 1]!)
205 }
206 const block = (x0: number, x1: number, y0: number, y1: number, z0: number, z1: number) => {
207 const c = [vert(x0, y0, z0), vert(x1, y0, z0), vert(x1, y1, z0), vert(x0, y1, z0), vert(x0, y0, z1), vert(x1, y0, z1), vert(x1, y1, z1), vert(x0, y1, z1)]
208 for (const q of [[0, 1, 2, 3], [5, 4, 7, 6], [4, 0, 3, 7], [1, 5, 6, 2], [3, 2, 6, 7], [4, 5, 1, 0]]) poly(q.map(i => c[i]!))
209 }
210 // The body: 12 wide, 10 at its top and bottom rows, as a chamfered prism.
211 const bottom = CLAWD_H - BODY_H - drop
212 const top = CLAWD_H - drop
213 const outline: [number, number][] = [[-5, bottom], [5, bottom], [6, bottom + 1], [6, top - 1], [5, top], [-5, top], [-6, top - 1], [-6, bottom + 1]]
214 const front = outline.map(([x, y]) => vert(x, y, BODY_DEPTH))
215 const back = outline.map(([x, y]) => vert(x, y, -BODY_DEPTH))
216 poly(front)
217 poly([...back].reverse())
218 for (let i = 0; i < outline.length; i++) {
219 const j = (i + 1) % outline.length
220 poly([front[i]!, back[i]!, back[j]!, front[j]!])
221 }
222 // Feet at the corners; walking lifts one diagonal pair, then the other.
223 if (look.pose !== 'sit') {
224 for (const [fx, fz, diagonal] of [[-2.5, 1.5, 0], [2.5, -1.5, 0], [-2.5, -1.5, 1], [2.5, 1.5, 1]] as const) {
225 const isLifted = look.pose === 'jump' || (beat === 1 && diagonal === 0) || (beat === 3 && diagonal === 1)
226 block(fx - 0.5, fx + 0.5, isLifted ? 1 : 0, bottom, fz - 0.5, fz + 0.5)
227 }
228 }
229 // The claws, each pixel a block, mirrored for the right side.
230 for (const [side, arm] of [[-1, look.arms.left], [1, look.arms.right]] as const) {
231 for (const [px, py] of CLAWS[arm].pixels) {
232 const col = side < 0 ? px : CLAWD_W - 1 - px
233 block(col - CLAWD_W / 2, col - CLAWD_W / 2 + 1, CLAWD_H - py - 1 - drop, CLAWD_H - py - drop, -0.5, 0.5)
234 }
235 }
236 const mesh = { verts, faces }
237 meshes.set(key, mesh)
238
239 return mesh
240}
241
242// Where things attach on the solid, in its pixel units (z toward the viewer):
243// above the head, the claw tips, under the feet, and the eyes on its face,
244// each eye's center with how it looks (open, wide, or shut as a line), and
245// the span the two eyes cover.
246export function clawdPoints(look: ClawdLook) {
247 const drop = look.pose === 'sit' ? CLAWD_H - BODY_H : 0
248 const ex = Math.max(-1, Math.min(1, Math.round(look.look.x))) || look.facing
249 const ey = Math.max(-1, Math.min(1, Math.round(look.look.y)))
250 const half = CLAWD_W / 2
251 const tipAt = (side: number, tip: [number, number]) => ({ x: (side < 0 ? tip[0] : CLAWD_W - 1 - tip[0]) + 0.5 - half + (side < 0 ? -0.5 : 0.5), y: CLAWD_H - tip[1] - 0.5 - drop, z: 0 })
252 const eyeY = CLAWD_H - EYE_ROW - ey - 1 - drop
253 const shut = look.eyes === 'closed' || look.isBlinking
254
255 return {
256 top: { x: 0, y: CLAWD_H - drop, z: 0 },
257 left: tipAt(-1, CLAWS[look.arms.left].tip),
258 right: tipAt(1, CLAWS[look.arms.right].tip),
259 feet: { x: 0, y: 0, z: 0 },
260 eyes: EYES.map(col => ({ x: col + ex + 0.5 - half, y: eyeY, z: BODY_DEPTH, kind: shut ? ('shut' as const) : look.eyes === 'wide' ? ('wide' as const) : ('open' as const) })),
261 eyeSpan: { x: EYES[0]! + ex - half, y: eyeY, z: BODY_DEPTH, w: EYES[1]! - EYES[0]! + 1 },
262 }
263}
264hooks/render3d.ts 765 lines1// A small 3D renderer for scene code. The world is sampled at a finer grid
2// than the cells (2 across and 4 down per cell, so the samples are square),
3// each sample is lit with a smoothly interpolated normal, and each cell is
4// then drawn from its samples: a cell a surface covers whole is a solid cell
5// in the lit color, a cell covered in part is the block glyph that matches
6// which quarters are covered (so edges come out anti-aliased), and a mesh
7// drawn `ascii` is instead the glyph whose density matches its brightness,
8// the classic terminal look, which suits curved things.
9
10import type { Rgb } from './effects'
11
12export type Vec3 = [number, number, number]
13
14// Triangles over shared vertices: verts is x, y, z per vertex; faces is
15// three vertex indices per triangle.
16export type Mesh = { verts: number[]; faces: number[] }
17
18// `fov` is the angle the strip shows across, in degrees (see NOMINAL_COLS).
19export type Camera = { eye: Vec3; target: Vec3; fov: number }
20export type Light = { dir: Vec3; ambient: number }
21export type Fog = { near: number; far: number; color: Rgb }
22
23export type Transform = {
24 at: Vec3
25 // Rotations in radians, applied about x, then y, then z.
26 rot: Vec3
27 scale: Vec3
28}
29
30export type Style = { color: Rgb; wire: boolean; unlit: boolean; ascii?: boolean }
31
32export const DEFAULT_CAMERA: Camera = { eye: [0, 2.5, 9], target: [0, 1, 0], fov: 90 }
33export const DEFAULT_LIGHT: Light = { dir: [0.4, 1, 0.6], ambient: 0.25 }
34export const DEFAULT_FOG: Fog = { near: 8, far: 40, color: [0, 0, 0] }
35
36// The strip is wide and only 9 rows tall, so the field of view is the
37// angle across it, measured over this many columns whatever the terminal's
38// width: things stay the same size on a wider terminal, which shows more.
39const NOMINAL_COLS = 120
40
41// Samples per cell, across and down.
42export const SX = 2
43export const SY = 4
44
45// Nothing nearer than this draws: the near plane, where geometry that
46// crosses behind the camera is cut.
47const NEAR = 0.05
48
49// The most triangles one frame draws, over every mesh.
50export const MOST_TRIANGLES = 20_000
51
52// A frame's samples: depth, the lit color, and what owns each (nothing, a
53// surface, or a sprite drawn in cells that the glyphs must leave alone).
54export type Frame = {
55 cols: number
56 rows: number
57 w: number
58 h: number
59 depth: Float32Array
60 color: Float32Array
61 owner: Uint8Array
62 // Cells with surface samples drawn since the last composite.
63 dirty: Uint8Array
64 triangles: number
65 // Called for every triangle or edge drawn, so the caller can keep time.
66 tick?: () => void
67}
68
69export const createFrame = (cols: number, rows: number): Frame => {
70 const w = cols * SX
71 const h = rows * SY
72
73 return {
74 cols,
75 rows,
76 w,
77 h,
78 depth: new Float32Array(w * h).fill(Infinity),
79 color: new Float32Array(w * h * 3),
80 owner: new Uint8Array(w * h),
81 dirty: new Uint8Array(cols * rows),
82 triangles: 0,
83 }
84}
85
86const SURFACE = 1
87const SPRITE = 2
88const ASCII = 3
89const isSurface = (owner: number) => owner === SURFACE || owner === ASCII
90
91const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
92const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
93const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
94const norm = (a: Vec3): Vec3 => {
95 const l = Math.hypot(a[0], a[1], a[2]) || 1
96
97 return [a[0] / l, a[1] / l, a[2] / l]
98}
99const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
100const smooth = (lo: number, hi: number, v: number) => {
101 const k = clamp01(hi === lo ? (v >= hi ? 1 : 0) : (v - lo) / (hi - lo))
102
103 return k * k * (3 - 2 * k)
104}
105
106// ---------------------------------------------------------------- camera
107
108export type View = { eye: Vec3; right: Vec3; up: Vec3; forward: Vec3; f: number; w: number; h: number }
109
110export function resolve(cam: Camera, w: number, h: number): View {
111 const forward = norm(sub(cam.target, cam.eye))
112 const worldUp: Vec3 = Math.abs(forward[1]) > 0.99 ? [0, 0, -1] : [0, 1, 0]
113 const right = norm(cross(forward, worldUp))
114 const up = cross(right, forward)
115 const fov = (Math.max(10, Math.min(170, cam.fov)) * Math.PI) / 180
116 // The focal length in samples: pixels over the nominal width, times the
117 // samples per column.
118 const f = ((NOMINAL_COLS / 2) * SX) / Math.tan(fov / 2)
119
120 return { eye: cam.eye, right, up, forward, f, w, h }
121}
122
123// A point in the camera's frame: across, up, and its depth ahead.
124type ViewPoint = { r: number; u: number; d: number }
125
126const toView = (view: View, p: Vec3): ViewPoint => {
127 const d = sub(p, view.eye)
128
129 return { r: dot(d, view.right), u: dot(d, view.up), d: dot(d, view.forward) }
130}
131
132// On the sample grid; `x` and `y` in samples.
133export type Projected = { x: number; y: number; depth: number }
134
135const toScreen = (view: View, v: ViewPoint): Projected => ({
136 x: (v.r * view.f) / v.d + view.w / 2,
137 y: view.h / 2 - (v.u * view.f) / v.d,
138 depth: v.d,
139})
140
141// A world point on the sample grid, or undefined behind the camera.
142export function project(view: View, p: Vec3): Projected | undefined {
143 const v = toView(view, p)
144
145 return v.d < NEAR ? undefined : toScreen(view, v)
146}
147
148// The part of a polygon in front of the near plane (Sutherland-Hodgman
149// against one plane), each vertex carrying its attributes along.
150type Vertex = { v: ViewPoint; p: Vec3; n: Vec3 }
151
152function clipNear(poly: Vertex[]): Vertex[] {
153 if (poly.every(x => x.v.d >= NEAR)) return poly
154 const out: Vertex[] = []
155 const mixv = (a: Vec3, b: Vec3, k: number): Vec3 => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
156 for (let i = 0; i < poly.length; i++) {
157 const a = poly[i]!
158 const b = poly[(i + 1) % poly.length]!
159 const aIn = a.v.d >= NEAR
160 const bIn = b.v.d >= NEAR
161 if (aIn) out.push(a)
162 if (aIn !== bIn) {
163 const k = (NEAR - a.v.d) / (b.v.d - a.v.d)
164 out.push({
165 v: { r: a.v.r + (b.v.r - a.v.r) * k, u: a.v.u + (b.v.u - a.v.u) * k, d: NEAR },
166 p: mixv(a.p, b.p, k),
167 n: mixv(a.n, b.n, k),
168 })
169 }
170 }
171
172 return out
173}
174
175// ---------------------------------------------------------------- meshes
176
177export function transformPoint(t: Transform, p: Vec3): Vec3 {
178 return placed(t, rotation(t.rot), p)
179}
180
181const placed = (t: Transform, m: Mat3, p: Vec3): Vec3 => {
182 const [x, y, z] = apply(m, [p[0] * t.scale[0], p[1] * t.scale[1], p[2] * t.scale[2]])
183
184 return [x + t.at[0], y + t.at[1], z + t.at[2]]
185}
186
187// A rotation as a row-major 3x3 matrix, built once per mesh rather than
188// six trig calls per point.
189type Mat3 = [number, number, number, number, number, number, number, number, number]
190
191// Rz * Ry * Rx, so x applies first.
192const rotation = (rot: Vec3): Mat3 => {
193 const [cx, sx, cy, sy, cz, sz] = [Math.cos(rot[0]), Math.sin(rot[0]), Math.cos(rot[1]), Math.sin(rot[1]), Math.cos(rot[2]), Math.sin(rot[2])]
194
195 return [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx, sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx, -sy, cy * sx, cy * cx]
196}
197
198const apply = (m: Mat3, p: Vec3): Vec3 => [
199 m[0] * p[0] + m[1] * p[1] + m[2] * p[2],
200 m[3] * p[0] + m[4] * p[1] + m[5] * p[2],
201 m[6] * p[0] + m[7] * p[1] + m[8] * p[2],
202]
203
204// A normal under a transform: the inverse of the scale (a squashed sphere's
205// normals lean out, not along the squash), then the rotation.
206const transformNormal = (t: Transform, m: Mat3, n: Vec3): Vec3 =>
207 apply(m, norm([n[0] / (t.scale[0] || 1), n[1] / (t.scale[1] || 1), n[2] / (t.scale[2] || 1)]))
208
209// A mesh with a normal for each corner of each face: faces meeting at a
210// gentle angle share smoothed normals (a sphere looks round), faces meeting
211// at a crease keep their own (a cube stays sharp). And its edges to draw as
212// wire, each once: two vertex indices per edge, without the seams inside
213// flat polygons (the diagonal of a triangulated quad).
214type Prepared = { faceNormals: Vec3[]; cornerNormals: Vec3[]; edges: number[] }
215const prepared = new WeakMap<Mesh, Prepared>()
216const CREASE = Math.cos((50 * Math.PI) / 180)
217const FLAT = 0.999
218// The most faces around one vertex that smoothing considers: a real mesh has
219// a handful; a mesh of thousands of faces on three vertices would otherwise
220// cost the square of its size to prepare.
221const MOST_AROUND = 24
222
223export function prepare(mesh: Mesh): Prepared {
224 const done = prepared.get(mesh)
225 if (done) return done
226 const { verts, faces } = mesh
227 const at = (i: number): Vec3 => [verts[i * 3] ?? 0, verts[i * 3 + 1] ?? 0, verts[i * 3 + 2] ?? 0]
228 const count = Math.floor(faces.length / 3)
229 const faceNormals: Vec3[] = []
230 const around = new Map<number, number[]>()
231 for (let f = 0; f < count; f++) {
232 const [a, b, c] = [faces[f * 3]!, faces[f * 3 + 1]!, faces[f * 3 + 2]!]
233 faceNormals.push(norm(cross(sub(at(b), at(a)), sub(at(c), at(a)))))
234 for (const i of [a, b, c]) {
235 const list = around.get(i) ?? []
236 if (list.length < MOST_AROUND) list.push(f)
237 around.set(i, list)
238 }
239 }
240 const cornerNormals: Vec3[] = []
241 for (let f = 0; f < count; f++) {
242 const mine = faceNormals[f]!
243 for (let k = 0; k < 3; k++) {
244 const i = faces[f * 3 + k]!
245 const sum: Vec3 = [0, 0, 0]
246 for (const g of around.get(i) ?? []) {
247 const other = faceNormals[g]!
248 if (dot(mine, other) >= CREASE) {
249 sum[0] += other[0]
250 sum[1] += other[1]
251 sum[2] += other[2]
252 }
253 }
254 cornerNormals.push(norm(sum))
255 }
256 }
257 // Edges keyed by their two indices as one number (the index count is
258 // bounded, so this is exact), each with the faces along it.
259 const span = Math.floor(verts.length / 3) + 1
260 const edgeFaces = new Map<number, number[]>()
261 for (let f = 0; f < count; f++) {
262 for (let k = 0; k < 3; k++) {
263 const a = faces[f * 3 + k]!
264 const b = faces[f * 3 + ((k + 1) % 3)]!
265 const key = Math.min(a, b) * span + Math.max(a, b)
266 const along = edgeFaces.get(key)
267 if (along) along.push(f)
268 else edgeFaces.set(key, [f])
269 }
270 }
271 const edges: number[] = []
272 for (const [key, along] of edgeFaces) {
273 if (along.length === 2 && dot(faceNormals[along[0]!]!, faceNormals[along[1]!]!) > FLAT) continue
274 edges.push(Math.floor(key / span), key % span)
275 }
276 const result = { faceNormals, cornerNormals, edges }
277 prepared.set(mesh, result)
278
279 return result
280}
281
282// ---------------------------------------------------------------- drawing
283
284const fogged = (c: Rgb, fog: Fog, depth: number): Rgb => {
285 const k = smooth(fog.near, fog.far, depth)
286
287 return [c[0] + (fog.color[0] - c[0]) * k, c[1] + (fog.color[1] - c[1]) * k, c[2] + (fog.color[2] - c[2]) * k]
288}
289
290function plot(frame: Frame, x: number, y: number, depth: number, c: Rgb, owner = SURFACE) {
291 const at = y * frame.w + x
292 frame.depth[at] = depth
293 frame.owner[at] = owner
294 frame.color[at * 3] = c[0]
295 frame.color[at * 3 + 1] = c[1]
296 frame.color[at * 3 + 2] = c[2]
297 frame.dirty[Math.floor(y / SY) * frame.cols + Math.floor(x / SX)] = 1
298}
299
300// Lights a point of a surface: ambient, diffuse by the normal against the
301// light, and a small highlight where the light reflects toward the eye.
302function lit(color: Rgb, n: Vec3, p: Vec3, view: View, l: Vec3, ambient: number): Rgb {
303 const toEye = norm(sub(view.eye, p))
304 // Two-sided: a face seen from behind shades as if it faced the eye.
305 const nn: Vec3 = dot(n, toEye) < 0 ? [-n[0], -n[1], -n[2]] : n
306 const diffuse = Math.max(0, dot(nn, l))
307 const half = norm([l[0] + toEye[0], l[1] + toEye[1], l[2] + toEye[2]])
308 const spec = Math.pow(Math.max(0, dot(nn, half)), 24) * 0.35
309 const k = ambient + (1 - ambient) * diffuse
310
311 return [Math.min(255, color[0] * k + 255 * spec), Math.min(255, color[1] * k + 255 * spec), Math.min(255, color[2] * k + 255 * spec)]
312}
313
314// Fills one triangle on the sample grid, interpolating depth (linear in
315// 1/depth), world position and normal, lighting each sample.
316function fillTriangle(frame: Frame, view: View, fog: Fog, l: Vec3, ambient: number, tri: [Vertex, Vertex, Vertex], style: Style) {
317 const [A, B, C] = tri
318 const a = toScreen(view, A.v)
319 const b = toScreen(view, B.v)
320 const c = toScreen(view, C.v)
321 const area = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
322 // A triangle too thin to show, or one with a corner at infinity (its
323 // weights would be NaN and own every sample).
324 if (!(Math.abs(area) >= 1e-9) || !Number.isFinite(a.x + a.y + b.x + b.y + c.x + c.y)) return
325 const x0 = Math.max(0, Math.floor(Math.min(a.x, b.x, c.x)))
326 const x1 = Math.min(frame.w - 1, Math.ceil(Math.max(a.x, b.x, c.x)))
327 const y0 = Math.max(0, Math.floor(Math.min(a.y, b.y, c.y)))
328 const y1 = Math.min(frame.h - 1, Math.ceil(Math.max(a.y, b.y, c.y)))
329 if (x1 < x0 || y1 < y0) return
330 const ia = 1 / a.depth
331 const ib = 1 / b.depth
332 const ic = 1 / c.depth
333 for (let y = y0; y <= y1; y++) {
334 const py = y + 0.5
335 for (let x = x0; x <= x1; x++) {
336 const px = x + 0.5
337 let wa = ((b.x - px) * (c.y - py) - (b.y - py) * (c.x - px)) / area
338 let wb = ((c.x - px) * (a.y - py) - (c.y - py) * (a.x - px)) / area
339 let wc = 1 - wa - wb
340 if (wa < -1e-6 || wb < -1e-6 || wc < -1e-6) continue
341 const depth = 1 / (wa * ia + wb * ib + wc * ic)
342 const at = y * frame.w + x
343 if (depth >= frame.depth[at]!) continue
344 // Perspective-correct weights for the attributes.
345 wa *= ia * depth
346 wb *= ib * depth
347 wc *= ic * depth
348 let color = style.color
349 if (!style.unlit) {
350 const n = norm([
351 wa * A.n[0] + wb * B.n[0] + wc * C.n[0],
352 wa * A.n[1] + wb * B.n[1] + wc * C.n[1],
353 wa * A.n[2] + wb * B.n[2] + wc * C.n[2],
354 ])
355 const p: Vec3 = [
356 wa * A.p[0] + wb * B.p[0] + wc * C.p[0],
357 wa * A.p[1] + wb * B.p[1] + wc * C.p[1],
358 wa * A.p[2] + wb * B.p[2] + wc * C.p[2],
359 ]
360 color = lit(color, n, p, view, l, ambient)
361 }
362 plot(frame, x, y, depth, fogged(color, fog, depth), style.ascii ? ASCII : SURFACE)
363 }
364 }
365}
366
367// A line between two world points, a cell's width thick, cut at the near
368// plane and depth tested along its length.
369export function drawLine(frame: Frame, view: View, fog: Fog, p0: Vec3, p1: Vec3, color: Rgb) {
370 const zero: Vec3 = [0, 0, 0]
371 const ends = clipNear([
372 { v: toView(view, p0), p: p0, n: zero },
373 { v: toView(view, p1), p: p1, n: zero },
374 ])
375 if (ends.length < 2) return
376 const a = toScreen(view, ends[0]!.v)
377 const b = toScreen(view, ends[1]!.v)
378 // Only the part on screen is stepped, a sample at a time, with the step
379 // still a share of the whole segment so depth follows it correctly: an
380 // end cut at the near plane can project miles off-screen, and spreading
381 // a fixed count of steps over that leaves gaps in the part that shows.
382 const span = clipSpan(a.x, a.y, b.x, b.y, frame.w, frame.h)
383 if (!span) return
384 const [k0, k1] = span
385 const steps = Math.min(frame.w + frame.h, Math.ceil(Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y)) * (k1 - k0)) || 1)
386 for (let i = 0; i <= steps; i++) {
387 const k = k0 + ((k1 - k0) * i) / steps
388 const x = Math.floor(a.x + (b.x - a.x) * k)
389 const y = Math.floor(a.y + (b.y - a.y) * k)
390 const depth = 1 / ((1 - k) / a.depth + k / b.depth)
391 // A little nearer than the surface it lies on, so edges show on faces.
392 dab(frame, x, y, depth - 0.03, fogged(color, fog, depth))
393 }
394}
395
396// The share of a segment inside the frame (Liang-Barsky), as the parameters
397// where it enters and leaves, or undefined when it misses. A sample of
398// margin keeps the dabs that straddle the frame's edge.
399function clipSpan(x0: number, y0: number, x1: number, y1: number, w: number, h: number): [number, number] | undefined {
400 let k0 = 0
401 let k1 = 1
402 for (const [p, dp, hi] of [[x0, x1 - x0, w], [y0, y1 - y0, h]] as const) {
403 if (dp === 0) {
404 if (p < -1 || p > hi) return undefined
405 continue
406 }
407 const ka = (-1 - p) / dp
408 const kb = (hi - p) / dp
409 k0 = Math.max(k0, Math.min(ka, kb))
410 k1 = Math.min(k1, Math.max(ka, kb))
411 }
412
413 return k0 <= k1 ? [k0, k1] : undefined
414}
415
416export function drawPoint(frame: Frame, view: View, fog: Fog, p: Vec3, color: Rgb) {
417 const a = project(view, p)
418 if (!a) return
419 dab(frame, Math.floor(a.x), Math.floor(a.y), a.depth, fogged(color, fog, a.depth))
420}
421
422// A 2 by 2 sample dab: what a line or a point needs to read as a glyph.
423function dab(frame: Frame, x: number, y: number, depth: number, color: Rgb) {
424 for (let dy = 0; dy < 2; dy++) {
425 for (let dx = 0; dx < 2; dx++) {
426 const sx = x + dx
427 const sy = y + dy
428 if (sx < 0 || sx >= frame.w || sy < 0 || sy >= frame.h) continue
429 const at = sy * frame.w + sx
430 if (depth >= frame.depth[at]!) continue
431 plot(frame, sx, sy, depth, color)
432 }
433 }
434}
435
436// Draws a mesh placed by a transform: filled and lit, or as wire edges.
437export function drawMesh(frame: Frame, view: View, light: Light, fog: Fog, mesh: Mesh, t: Transform, style: Style) {
438 const { verts, faces } = mesh
439 const { cornerNormals, edges } = prepare(mesh)
440 const m = rotation(t.rot)
441 const n = Math.floor(verts.length / 3)
442 const world: Vec3[] = []
443 const eyed: ViewPoint[] = []
444 for (let i = 0; i < n; i++) {
445 const p = placed(t, m, [verts[i * 3]!, verts[i * 3 + 1]!, verts[i * 3 + 2]!])
446 world.push(p)
447 eyed.push(toView(view, p))
448 }
449 if (style.wire) {
450 // Each edge once; an edge counts against the triangle budget.
451 for (let e = 0; e + 1 < edges.length && frame.triangles < MOST_TRIANGLES; e += 2) {
452 const a = world[edges[e]!]
453 const b = world[edges[e + 1]!]
454 if (!a || !b) continue
455 frame.triangles += 1
456 frame.tick?.()
457 drawLine(frame, view, fog, a, b, style.color)
458 }
459
460 return
461 }
462 const l = norm(light.dir)
463 const faceCount = Math.floor(faces.length / 3)
464 for (let f = 0; f < faceCount && frame.triangles < MOST_TRIANGLES; f++) {
465 const ids = [faces[f * 3]!, faces[f * 3 + 1]!, faces[f * 3 + 2]!]
466 const corners: Vertex[] = []
467 for (let k = 0; k < 3; k++) {
468 const v = eyed[ids[k]!]
469 const p = world[ids[k]!]
470 if (!v || !p) break
471 corners.push({ v, p, n: transformNormal(t, m, cornerNormals[f * 3 + k] ?? [0, 1, 0]) })
472 }
473 if (corners.length < 3) continue
474 if (corners.every(c => c.v.d < NEAR)) continue
475 frame.triangles += 1
476 frame.tick?.()
477 const poly = clipNear(corners)
478 for (let j = 1; j + 1 < poly.length; j++) fillTriangle(frame, view, fog, l, light.ambient, [poly[0]!, poly[j]!, poly[j + 1]!], style)
479 }
480}
481
482// Claims a sprite's pixel (a column and a pixel row, two to a cell) at a
483// depth: true when it is nearer than what is there, in which case the
484// glyphs leave that pixel to the sprite.
485export function claimPixel(frame: Frame, col: number, py: number, depth: number) {
486 const x0 = col * SX
487 const y0 = py * (SY / 2)
488 if (x0 < 0 || x0 >= frame.w || y0 < 0 || y0 >= frame.h) return false
489 let nearest = Infinity
490 for (let dy = 0; dy < SY / 2; dy++) for (let dx = 0; dx < SX; dx++) nearest = Math.min(nearest, frame.depth[(y0 + dy) * frame.w + x0 + dx]!)
491 if (depth >= nearest) return false
492 for (let dy = 0; dy < SY / 2; dy++) {
493 for (let dx = 0; dx < SX; dx++) {
494 const at = (y0 + dy) * frame.w + x0 + dx
495 frame.depth[at] = depth
496 frame.owner[at] = SPRITE
497 }
498 }
499
500 return true
501}
502
503// ---------------------------------------------------------------- glyphs
504
505// Glyphs by brightness, sparse to dense, for `ascii` meshes.
506const RAMP = [...' .,:;-=+*#%@']
507
508// The block glyph for each set of covered quarters: bit 1 top-left, 2
509// top-right, 4 bottom-left, 8 bottom-right.
510const QUARTERS = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█']
511
512// How far a sample's depth must jump from a neighbor's to count as an edge
513// (as a share of its depth), and how much the farther side darkens there:
514// a dark rim on the far side of every overlap, so shapes read as separate.
515const EDGE = 0.06
516const RIM = 0.45
517
518// Two groups closer than this (squared distance over the channels: about
519// 20 levels each) are one color: the cell stays solid rather than split.
520const ALIKE = 20 * 20 * 3
521
522// Draws the cells drawn on since the last composite. Each cell's four
523// quarters (two samples each) are split into two groups by color, and the
524// cell is the quarter-block glyph of that split with a color for each
525// group, so a cell can hold an edge, or a gradient, with two colors.
526export function composite(frame: Frame, put: (col: number, row: number, char: string, color: Rgb, back?: Rgb) => void) {
527 const per = SX * SY
528 const half = SY / 2
529 for (let row = 0; row < frame.rows; row++) {
530 for (let col = 0; col < frame.cols; col++) {
531 const cell = row * frame.cols + col
532 if (!frame.dirty[cell]) continue
533 frame.dirty[cell] = 0
534 // Per quarter: samples covered, their summed color; and the cell's.
535 const qCount = [0, 0, 0, 0]
536 const qSum: Rgb[] = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
537 let covered = 0
538 let sprite = 0
539 let ascii = 0
540 const sum: Rgb = [0, 0, 0]
541 for (let dy = 0; dy < SY; dy++) {
542 for (let dx = 0; dx < SX; dx++) {
543 const x = col * SX + dx
544 const y = row * SY + dy
545 const at = y * frame.w + x
546 const owner = frame.owner[at]!
547 if (owner === SPRITE) sprite += 1
548 if (!isSurface(owner)) continue
549 if (owner === ASCII) ascii += 1
550 const c = rimmed(frame, x, y, at)
551 const q = (dy < half ? 0 : 2) + (dx < SX / 2 ? 0 : 1)
552 qCount[q] = (qCount[q] ?? 0) + 1
553 qSum[q]![0] += c[0]
554 qSum[q]![1] += c[1]
555 qSum[q]![2] += c[2]
556 covered += 1
557 sum[0] += c[0]
558 sum[1] += c[1]
559 sum[2] += c[2]
560 }
561 }
562 // A cell a sprite holds is the sprite's, unless a surface drawn since
563 // covers more of it (something passing in front of Clawd).
564 if (covered === 0 || sprite >= covered) continue
565 const mean: Rgb = [sum[0] / covered, sum[1] / covered, sum[2] / covered]
566 const coverage = covered / per
567 if (ascii > 0) {
568 // The classic look: a glyph as dense as the brightness, thinned by
569 // coverage, in ink that keeps the hue, over a dark tint when solid.
570 // Mid-tones lean toward the denser glyphs (a gamma of 0.6).
571 const brightness = (0.299 * mean[0] + 0.587 * mean[1] + 0.114 * mean[2]) / 255
572 const level = Math.min(RAMP.length - 1, Math.round(Math.pow(brightness, 0.6) * coverage * (RAMP.length - 1)))
573 const back: Rgb | undefined = coverage >= 0.5 ? [mean[0] * 0.18, mean[1] * 0.18, mean[2] * 0.18] : undefined
574 put(col, row, RAMP[Math.max(1, level)] ?? '.', lift(mean), back)
575 continue
576 }
577 const qMean: (Rgb | undefined)[] = qCount.map((n, q) => (n > 0 ? [qSum[q]![0] / n, qSum[q]![1] / n, qSum[q]![2] / n] : undefined))
578 const onMask = qMean.reduce((m, c, q) => (c ? m | (1 << q) : m), 0)
579 // The split of the covered quarters into two color groups that keeps
580 // each group most alike; the uncovered quarters always sit in the
581 // second group, which draws as the background.
582 let best = onMask
583 let bestCost = Infinity
584 for (let mask = 1; mask <= onMask; mask++) {
585 if ((mask & onMask) !== mask) continue
586 const a = groupMean(qMean, mask)
587 const b = groupMean(qMean, onMask & ~mask)
588 let cost = 0
589 for (let q = 0; q < 4; q++) {
590 const c = qMean[q]
591 if (!c) continue
592 const g = mask & (1 << q) ? a : b
593 cost += g ? dist(c, g) : 0
594 }
595 if (cost < bestCost - 1e-9) {
596 bestCost = cost
597 best = mask
598 }
599 }
600 const fg = groupMean(qMean, best) ?? mean
601 const rest = onMask & ~best
602 const bg = groupMean(qMean, rest)
603 if (onMask === 15 && (best === 15 || (bg && dist(fg, bg) < ALIKE))) {
604 // Four quarters of much the same color: a solid cell.
605 put(col, row, ' ', mean, mean)
606 } else if (best === 15) {
607 // Four quarters of one color: a solid cell.
608 put(col, row, ' ', fg, fg)
609 } else if (bg && bitCount(rest) > bitCount(~onMask & 15)) {
610 // The second group covers more than the uncovered part: two colors.
611 put(col, row, QUARTERS[best] ?? '█', fg, bg)
612 } else {
613 put(col, row, QUARTERS[best] ?? '█', fg)
614 }
615 }
616 }
617}
618
619// A sample's color, darkened where its depth jumps away from a neighbor's
620// (the far side of an overlap). Depth is compared as 1/depth, which runs
621// straight across a flat surface on screen, so only a kink counts, not a
622// slope: a floor receding steadily toward the horizon gets no rim, a nearer
623// shape in front of it does. Along each axis the kink is how far this
624// sample sits beyond where its neighbors' trend puts it; at a surface's
625// border the trend comes from the two samples on the one side it has, so a
626// limb curving away still rims where a flat floor's far edge does not.
627function rimmed(frame: Frame, x: number, y: number, at: number): Rgb {
628 const c: Rgb = [frame.color[at * 3]!, frame.color[at * 3 + 1]!, frame.color[at * 3 + 2]!]
629 const i = 1 / frame.depth[at]!
630 // 1/depth of the surface sample k steps along an axis, if there is one.
631 const along = (dx: number, dy: number, k: number) => {
632 const sx = x + dx * k
633 const sy = y + dy * k
634 if (sx < 0 || sx >= frame.w || sy < 0 || sy >= frame.h) return undefined
635 const n = sy * frame.w + sx
636
637 return isSurface(frame.owner[n]!) ? 1 / frame.depth[n]! : undefined
638 }
639 const kink = (dx: number, dy: number) => {
640 const a = along(dx, dy, 1)
641 const b = along(dx, dy, -1)
642 if (a !== undefined && b !== undefined) return a + b - 2 * i
643 const side = a !== undefined ? 1 : b !== undefined ? -1 : 0
644 if (side === 0) return 0
645 const near = along(dx, dy, side)!
646 const beyond = along(dx, dy, 2 * side)
647 // Two samples wide there is no trend to go by: the jump alone decides.
648 if (beyond === undefined) return near - i > EDGE * near ? Infinity : 0
649
650 return 2 * near - beyond - i
651 }
652 const isRim = kink(1, 0) > EDGE * i || kink(0, 1) > EDGE * i
653
654 return isRim ? [c[0] * RIM, c[1] * RIM, c[2] * RIM] : c
655}
656
657const bitCount = (m: number) => ((m & 1) + ((m >> 1) & 1) + ((m >> 2) & 1) + ((m >> 3) & 1))
658
659const dist = (a: Rgb, b: Rgb) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
660
661function groupMean(q: (Rgb | undefined)[], mask: number): Rgb | undefined {
662 const sum: Rgb = [0, 0, 0]
663 let n = 0
664 for (let i = 0; i < 4; i++) {
665 const c = q[i]
666 if (!(mask & (1 << i)) || !c) continue
667 sum[0] += c[0]
668 sum[1] += c[1]
669 sum[2] += c[2]
670 n += 1
671 }
672
673 return n > 0 ? [sum[0] / n, sum[1] / n, sum[2] / n] : undefined
674}
675
676// Ink that stays visible: the color brightened toward its hue when dark.
677function lift(c: Rgb): Rgb {
678 const max = Math.max(c[0], c[1], c[2], 1)
679 const k = Math.max(1, 90 / max)
680
681 return [Math.min(255, c[0] * k), Math.min(255, c[1] * k), Math.min(255, c[2] * k)]
682}
683
684// ---------------------------------------------------------------- shapes
685
686const quad = (faces: number[], a: number, b: number, c: number, d: number) => faces.push(a, b, c, a, c, d)
687
688// A box centered on the origin.
689export function box(w: number, h: number, d: number): Mesh {
690 const x = w / 2
691 const y = h / 2
692 const z = d / 2
693 const verts = [-x, -y, -z, x, -y, -z, x, y, -z, -x, y, -z, -x, -y, z, x, -y, z, x, y, z, -x, y, z]
694 const faces: number[] = []
695 quad(faces, 0, 1, 2, 3)
696 quad(faces, 5, 4, 7, 6)
697 quad(faces, 4, 0, 3, 7)
698 quad(faces, 1, 5, 6, 2)
699 quad(faces, 3, 2, 6, 7)
700 quad(faces, 4, 5, 1, 0)
701
702 return { verts, faces }
703}
704
705// A sphere of latitude rings and longitude segments.
706export function sphere(r: number, segments = 12): Mesh {
707 const seg = Math.max(3, Math.min(48, Math.round(segments)))
708 const rings = Math.max(2, Math.round(seg / 2))
709 const verts: number[] = []
710 const faces: number[] = []
711 for (let i = 0; i <= rings; i++) {
712 const phi = (i / rings) * Math.PI
713 for (let j = 0; j < seg; j++) {
714 const theta = (j / seg) * Math.PI * 2
715 verts.push(r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta))
716 }
717 }
718 for (let i = 0; i < rings; i++) {
719 for (let j = 0; j < seg; j++) {
720 const a = i * seg + j
721 const b = i * seg + ((j + 1) % seg)
722 const c = (i + 1) * seg + ((j + 1) % seg)
723 const d = (i + 1) * seg + j
724 if (i > 0) faces.push(a, b, c)
725 if (i < rings - 1) faces.push(a, c, d)
726 }
727 }
728
729 return { verts, faces }
730}
731
732// A cylinder (or a cone when the top radius is 0) standing on the origin,
733// its base at y 0 and its top at y h.
734export function cylinder(rBottom: number, rTop: number, h: number, segments = 12): Mesh {
735 const seg = Math.max(3, Math.min(48, Math.round(segments)))
736 const verts: number[] = []
737 const faces: number[] = []
738 for (let j = 0; j < seg; j++) {
739 const theta = (j / seg) * Math.PI * 2
740 verts.push(rBottom * Math.cos(theta), 0, rBottom * Math.sin(theta))
741 verts.push(rTop * Math.cos(theta), h, rTop * Math.sin(theta))
742 }
743 // The centers of the two caps.
744 const cb = seg * 2
745 const ct = seg * 2 + 1
746 verts.push(0, 0, 0, 0, h, 0)
747 for (let j = 0; j < seg; j++) {
748 const a = j * 2
749 const b = ((j + 1) % seg) * 2
750 quad(faces, a, b, b + 1, a + 1)
751 faces.push(cb, b, a)
752 if (rTop > 0) faces.push(ct, a + 1, b + 1)
753 }
754
755 return { verts, faces }
756}
757
758// A flat rectangle on the ground (the x-z plane), centered on the origin.
759export function plane(w: number, d: number): Mesh {
760 const x = w / 2
761 const z = d / 2
762
763 return { verts: [-x, 0, -z, x, 0, -z, x, 0, z, -x, 0, z], faces: [0, 1, 2, 0, 2, 3] }
764}
765types/index.d.ts 12 lines1// What clawd-ballet keeps in the session's state (`$.state`).
2
3// Each load of the plugin's code marks the session as its own: a hot reload
4// writes a new mark, and the animation loop of the load before stops at it.
5export type BalletLoad = string
6
7declare module 'claude-code' {
8 interface PluginState {
9 'clawd-ballet': { load: BalletLoad }
10 }
11}
12