SLOPSHOPPER

clawd-ballet

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

newpanebandspinnerguardcommand
v0.3.0MITupdated 2026-10-06Minithena/Claude-Code-Ballet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-ballet
│ ┃ ballet-programme ✕ › fix the failing auth test and add an audit log call │ ┃ Next up n: Gala (all the pieces) │ ┃ Mode m: standard, the ballets in ⏺ Read(src/auth.ts) │ ┃ Clawd h: hidden ⎿ Read 6 lines │ ┃ Order o: in turn ⏺ Update(src/auth.ts) │ ┃ Speech bubbles s: on ⎿ Added 2 lines, removed 1 line │ ┃ Live label l: on ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Press a letter to change its row (or Tab │ ┃ to it and Enter); Escape closes. The ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ settings are also in /config. │ ✻ Worked for 42s · done 4:20 PM │ │ › /ballet │ ⎿ clawd-ballet: Ballet off. /ballet brings Clawd back. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ballet-programme
Next up n: Gala (all the pieces) Mode m: standard, the ballets in turn Clawd h: hidden Order o: in turn Speech bubbles s: on Live label l: on Press a letter to change its row (or Tab to it and Enter); Escape closes. The settings are also in /config.
README

Claude Code Ballet

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 mid-request in Claude Code: Claude fixes Odile's missing 32nd fouetté while Clawd dances a move for each tool call, labelled with what set it off

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: a corps in white joins Clawd, each dancer moving to its own agent's tool calls

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.

The programme

In standard mode a piece plays each turn, the acts of a ballet in order, dissolving into the next if Claude works long enough.

Swan Lake, act IV: forgiveness, the storm, and a lift into the sunrise

Don Quixote: Kitri and Basilio's wedding pas de deux, with fireworks over Barcelona

Wayne McGregor's Untitled, 2023: a trio on Carmen Herrera's white canvas cut with green

PieceActsYearChoreography
Galaoriginal
Swan LakeI to IV1895Marius Petipa, Lev Ivanov
The NutcrackerI and II1892Lev Ivanov
The Firebirdone act1910Michel Fokine
MayerlingI to III1978Kenneth MacMillan
Chromaone act2006Wayne McGregor
Infraone act2008Wayne McGregor
Untitled, 2023one act2023Wayne McGregor
Don QuixoteIII, the grand pas de deux1869Marius Petipa
GiselleII1841Jean Coralli, Jules Perrot
La Fille mal gardéethe farmyard1960Frederick Ashton
ManonI1974Kenneth MacMillan
Classbarre and centreoriginal

Using it

  • /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":

SettingWhat it does
Order"in turn" follows the programme; "shuffled" picks the next ballet at random, its acts still in order.
Speech bubblesOff, every piece is danced in silence.
Live labelOff, live mode shows no label naming what set off each move.

Everything is remembered across sessions.

Install

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.

Preview without Claude Code

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

Credits and license

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.

Source 12 files
hooks/register.tsx 638 lines
1import 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}
638
hooks/ballet.ts 2505 lines
1// 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 lines
1// 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}
61
hooks/choose.ts 151 lines
1// 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}
151
hooks/live.ts 1013 lines
1// 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}
1013
hooks/script.ts 1292 lines
1// 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 = true
hooks/dance.ts 133 lines
1// 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`
133
hooks/effects.ts 267 lines
1// 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}
267
hooks/lang.ts 1698 lines
1// 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 lines
1// 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}
264
hooks/render3d.ts 765 lines
1// 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}
765
types/index.d.ts 12 lines
1// 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