Claude Fables: turns what the agent is doing into little animated cartoons above the prompt, written live by Sonnet or Haiku.

<img src="assets/styles.gif" alt="Claude, the little orange critter, playing out coding moments in all eleven styles: Pixel Art, the Original, Cave Painting, Blueprint, Mosaic, Frutiger Aero, Copperplate Engraving, Millefleur Tapestry, Golden Age Comic, Ukiyo-e and Kamon" width="960">
<h1 align="center">Claude Fables</h1>
<b>Your work, told as a cartoon while Claude codes.</b><br> A Claude Code mod for the desktop app that plays a small animated story in the band above the prompt.
<img alt="Claude Code mod" src="https://img.shields.io/badge/Claude_Code-mod-d97757"> <img alt="Desktop app" src="https://img.shields.io/badge/runs_in-the_desktop_app-3b3a36"> <img alt="11 styles" src="https://img.shields.io/badge/styles-11-6a5acd"> <img alt="20 actions" src="https://img.shields.io/badge/actions-20-2e8b57"> <img alt="7 scenes" src="https://img.shields.io/badge/scenes-7-c46a2a">
<a href="#quick-start">Quick start</a> · <a href="#how-it-works">How it works</a> · <a href="#use">Use</a> · <a href="#the-scenes">The scenes</a> · <a href="#captions">Captions</a> · <a href="#styles">Styles</a> · <a href="#develop">Develop</a>
[!NOTE] Claude Fables was developed entirely in Claude Code cloud environments and tested locally in the desktop app as well. Still, there may be bugs and rough edges. Issues and pull requests are very welcome.
While Claude works, Fables watches each tool call it makes and each line it says. Every few seconds it asks Sonnet (or Haiku, if you prefer) to retell the latest moment as a scene. Bug hunts turn into nature documentaries and bad regexes get pulled over by the train police. Claude appears as a small orange critter walking, sneaking or flying through the story. When the turn ends there is a closing scene, and it stays up for 30 seconds.
~/code/Claude-Fables.env block in ~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/code/Claude-Fables"
}
}
To work on the mod with hot reload, also add "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1".
To try it from the terminal for one session instead, run claude --plugin-dir ~/code/Claude-Fables. Note that in the terminal the mod only watches and draws nothing.
tool calls, Claude's own words ──► activity log (last 14 lines)
│ as each scene is read, ≥5s apart
▼
Sonnet or Haiku ($.model.complete) ──► JSON scene ──► parseScene (validate + clamp)
│
▼
sceneToSvg ──► one self-animating SVG (SMIL)
│
▼
<Svg isInteractive/> in the AbovePrompt band
hooks/scene.ts validates it strictly: unknown fields are dropped, numbers clamped, strings flattened and cut, and colors must be 3- or 6-digit hex. A bad reply can't break anything; it just doesn't show.hooks/svg.ts compiles a scene into one SVG document that animates itself with SMIL: walk cycles, bobbing, scrolling trains, twinkling stars, and a speech bubble that types itself out. Once the scene is drawn, the desktop needs no redraws for it.| File | What it does || --- | --- | | hooks/register.tsx | The hooks: watches tool calls, replies and turns; draws the band; handles /fables | | hooks/narrator.ts | The narrator's system prompt, prompt building, reply parsing, backoff | | hooks/director.ts | The narrator's loop: what it remembers, when it asks, what it does with a reply. The plugin and the viewer run the same one | | hooks/activity.ts | Boils each tool call down to one readable line | | hooks/scene.ts | The scene format and its validator | | hooks/sprites.ts | The pixel-art library: Claude in two walk frames, plus 30+ props | | hooks/svg.ts | Scene → animated SVG: backdrops, particles, props, hero, caption | | hooks/looks.ts | The looks: Pixel Art, the Original, and the registry of styles | | hooks/styles/*.ts | The nine gallery styles, each an art bible: inks, how each kind of element is drawn, Claude, caption, tag, frame | | hooks/art/roles.ts, hooks/art/painter.ts, hooks/art/ink.ts | The roles scenes paint their elements under, and the painter a style redraws them with | | hooks/grade.ts | The medium a grid style sets the drawn stage into: tesserae or a weave | | hooks/clawd3d.ts | The 3D Claude: the box model, its motions and its projection (ported from the gallery) | | hooks/hero3d.ts | Bakes the posed 3D model into SVG frames that SMIL plays in turn | | hooks/scenery.ts | The seven authored scenes: light maps, materials, reflections | | types/index.d.ts | The scene types and the mod's $.state contract |
/fables: toggle the mod on or off. /fables on and /fables off also work. The setting is remembered across sessions./fables style <name>: draw every scene in one of the styles below, for example /fables style ukiyo-e or /fables style golden age. /fables style lists them, and /fables style off goes back to the default, Pixel Art. It is remembered across sessions./fables pixel off and /fables pixel on still work: they switch between the original look drawn smooth and its pixel art./fables model haiku and /fables model sonnet: pick who writes the story. Sonnet is the default and writes wittier scenes; Haiku answers in about a second and costs less, but writes plainer captions and slips a little more often (a bad reply is simply skipped). /fables model shows which one is on. The choice is remembered across sessions; the config menu's Scene model (pluginConfigs.fables.model) sets the default.Every scene is one small model request, so this costs a few requests per minute while Claude is working.
Claude is drawn as the gallery's 3D model: the same box body, arms, legs and pill eyes, lit and depth-sorted. The band's frame runs no script, so the model can't be drawn live. hooks/clawd3d.ts poses it 6 to 12 times per motion, hooks/hero3d.ts bakes each pose into flat SVG polygons, and the scene flips through them with SMIL. The model can't follow the cursor or be dragged; those need the live engine.
The narrator picks one of 20 actions for each scene, each tied to a kind of work:
| plays | actions |
|---|---|
crossing the stage, from from to to | walk, run, fly, carry (moving files), sneak (bug hunts), jump, tumble (obstacles, retries) |
| in place, looping | dig (searching), inspect (reading and editing code), think, point (found it), peek, spin (refactors), wave (hello), sleep (long waits), panic (errors), dance, celebrate (milestones) |
| in place, once | trip (a test fails), shrug (nothing found) |
A scene can chain a second action with then, played in place where the first one ends: a sneak then a peek, a trip then a shrug, a jump then a celebration. Without one, Claude stands idle after crossing or after a one-off, and an idle Claude looks about, blinks, shifts its weight and stretches. Along the way its eyes go wide, narrow in focus, cross when it's dizzy and worry under a brow.
Each of the seven backdrops in hooks/scenery.ts is an authored scene with one brief, one light source and a small palette:
| Backdrop | The scene |
|---|---|
| forest | Dawn in an old forest: a low sun behind two rows of firs on brown trunks, mist between them, light falling through in shafts and pooling in a clearing, two great trunks framing the edges |
| space | Earthrise over a lunar outpost: a low sun rakes the regolith, so every swell has a lit crest, every crater a black bowl and a bright far wall, every rock a long shadow |
| city | Blue hour after rain: towers with lit west edges and dark east faces, offices lit a floor at a time, a spire, an elevated train, and the whole skyline mirrored in the wet street |
| desert | Mesa sunset: the sun sets in a notch the ranges leave clear, so they face us in violet shade with their sunward sides burning, and their shadows fan toward us across the sand |
| volcano | A night eruption: the crater lights its own ash column from beneath, lava runs down a gullied cone, and a stream crosses a black crust crazed with glowing cracks |
| lab | Working late: an architect's lamp warms the board-formed concrete and the bench, dust turns in its cone, rain beads on the window over a city opened into bokeh, and the polished floor mirrors the room |
| night | A sleeping village: hills under a high moon, cottages with one lit window and a thread of smoke, a great oak framing the view, fireflies |
Every surface is painted twice. First as light: a warm key where the light source reaches, cool shade facing away, deep tones where surfaces meet. Then that light map is multiplied by a material, built in an SVG filter from seeded noise cut into a small palette of related colors: needles, bark, grass, basalt, sandstone, sand, regolith, concrete, wood, asphalt. Light that has to brighten a dark material (shafts, lava glow, lamp pools) is added on top with a screen blend instead. Glows bloom, far layers sit slightly out of focus, rims of light appear only where the light can actually reach, and a lens pass adds fine grain and a vignette over the whole frame, Claude included.
Wet and polished floors reflect: the city's skyline and the lab's room are drawn once, then placed again upside down, blurred and broken by ripples, strongest in the puddles.
Depth comes from atmospheric perspective: further layers are lighter and nearer the sky's color. Focal points sit off the middle, so the middle of the stage, where Claude and the caption are, stays calm. Silhouettes come from smooth noise rather than repeated tiles, so there is no seam at any width. Everything is clipped to the stage, and motion is slow and belongs to the story.
The band's frame takes at most 131,072 characters, so repeated things are drawn once and placed many times: the firs, the grass clumps and the furthest tree line are templates. A scene that would still run over is redrawn leaner, then without particles, and as a last resort on the flat stage.
The story is told in words. Props aren't drawn on the scene, so nothing competes with Claude and the caption.
The caption is one standard bubble of cartoon paper with a tail pointing at Claude, set in Monocraft by Idrees Hassan (SIL Open Font License, fonts/Monocraft-OFL.txt), embedded as a 5 KB subset so it reads the same everywhere. It stays with Claude and never covers it: it takes a spot just beside, above or (for a flying Claude) below, checked against Claude's whole path, jumps and sways included. While Claude walks, the bubble walks along at the same distance, so its tail always points at Claude. Above Claude, it can sit anywhere along Claude's head, and a narrower wrap is tried, to find a spot the stage's edges never hold back. When a walk is too long for any spot, the bubble waits unseen until Claude is far enough in, then appears beside Claude and walks on with it. That wait is added to the scene's reading time. Inside it, kinds of words are set apart, so a caption reads like a terminal:
| Kind | Example | Looks like |
|---|---|---|
| code and commands (in backticks, or a known command) | npm test | teal |
| files and paths | dates.ts, src/auth | blue |
| functions | daysInMonth() | purple |
| numbers and timings | 312, 2.41s, 42/42 | orange, bold |
| failures | failed, TypeError, N+1 | red, bold |
| successes | passed, clean, green | green, bold |
| ASCII faces and symbols | ^_^ >_< \o/ -> [OK] | warm accent |
The narrator also picks a tone for the moment. Trouble leads the caption with a red ✗ and a milestone with a green ✓; a celebration is a milestone unless it says otherwise. The narrator is asked to write like a developer, with backticks around code and the odd ASCII face.
In the Pixel Art look (the default), the whole stage, scenery and Claude together, goes through a pixelizer: one SVG filter that samples the drawing at the middle of every two-unit square and spreads each sample over its square, so everything reads as pixel art without any of it being redrawn, and no blur washes the colors out. The grid starts at the stage's corner, so the pixels line up. Claude gets a sprite filter of its own on the same grid: its body sampled crisp, its eyes (finer than a pixel) found by their darkness and thickened just enough to land on whole pixels, and a one-pixel dark outline round it, as a pixel-art character would have. The caption sits above it all, already in pixel type, so it stays sharp. In the Original look, scenes are drawn smooth and the scenery takes a slight blur instead, so Claude and the caption read first.
Two looks draw the authored scenes as they are: Pixel Art (pixel, the default) and the Original (original), the same scenes drawn smooth.
Nine more styles, from the Claude Mascot Style Gallery, are artworks of their own. Each one redraws every element of every scene, Claude, the caption bubble, the chapter tag and the frame in its own medium, translated from the gallery's original plate. They are cosmetic only: the story, the scenery's composition and Claude's path stay as they are, and nothing is added to a scene.
| Style | /fables style … | The artwork |
|---|---|---|
| Cave Painting | cave | Ochre, soot and pale earth rubbed thin on torch-lit limestone; no sky, only the wall; broken soot outlines; Claude in red ochre |
| Blueprint | blueprint | White line work on a cyanotype sheet; shade section-hatched, air and light as phantom lines; Claude as a patent drawing with hidden edges dashed |
| Mosaic | mosaic | Laid in the floor's stones and set as tesserae in grout, outlined in rows of dark stones; a meander border |
| Frutiger Aero | aero | Glossy gradients with a white rim on every surface, after the Vista and 7 wallpapers: an azure sky with a sun flare, Bliss-green grass, aqua glass towers, pearl rooms; Frutiger Aurora at night with teal meadows, moonlit clouds and bokeh stars; Claude as tangerine jelly |
| Copperplate Engraving | engraving | One sepia ink on laid paper, every tone cut as hatching along the grain of what it is; a plate mark |
| Millefleur Tapestry | tapestry | Woven in madder, woad, weld and walnut wool on the loom's grid; grass becomes the field of a thousand flowers |
| Golden Age Comic | golden | Flat newsprint inks, shade in Ben-Day dots, heavy keylines; a lettered balloon |
| Ukiyo-e | ukiyoe | Flat woodblock inks over a key line, bokashi skies, kasumi mist, a vermilion sun; a cartouche and seal |
| Kamon | kamon | Cream planes on black silk parted by cuts of one width; Claude as a crest; the vermilion hanko |
Every element of a scene is handed to a painter under a role that says what it is and how deep it stands (hooks/art/roles.ts): a fir, a mist bank, a mesa, the lamp's cone, the wet street. The original looks keep the lit, photographic painting. A style's painter (hooks/art/painter.ts, hooks/styles/*.ts) reads that painting for its shapes and tones and draws it again:
Claude is drawn by each style's hero painter, from the 3D model's faces, its parts' outline hulls and its edges, which are classed as outline, crease or hidden as in the gallery's engine. A style made on a grid (Mosaic, Tapestry) names that grid as its medium: the drawn stage is set into tesserae or a weave, and its grout or the weave's ribs are laid over it. The narrator hears each style's voice, so a caption can sound like a 1938 comic or a woodblock print while still being about the real work.
The styles draw as fast as the original look or faster, since they leave out the lit look's material filters, and every one fits the band's size limit at every width.
claude plugin validate . # manifest, hooks and state contract
claude plugin test . # unit tests plus engine tests (stubbed Sonnet)
bun scripts/preview.ts > gallery.html # render sample scenes to a page in the browser
bun scripts/preview.ts --look all > styles.html # every look
scripts/scenarios.ts holds seven whole sessions: the prompt, each tool call and its result, Claude's words, and what Sonnet and what Haiku write back each time the narrator asks, as raw text, quirks included (code fences, a word of chatter, a missing caption, an empty answer). scripts/rehearse.ts plays a session through the narrator's own loop on a clock of its own, so the asks land when the plugin would make them, see exactly the prompt it would send, and bad replies are skipped and backed off from the same way. The viewer's Sessions tab shows it side by side: Claude Code's activity, the narrator's asks (with each prompt) and the model's replies (with each raw reply and the validator's verdict), the band, and a box to paste a reply of your own and see what the plugin would make of it. A test rehearses every session with both models. bun scripts/preview.ts my-scenes.json renders your own scenes, which is handy for tuning sprites or trying out what Sonnet sent back.
The mod API is early access and may change between Claude Code releases. This mod was built against Claude Code 2.1.287. If something stops drawing, run claude --debug: the log line will name what the engine refused.
The images in this README are drawn by the mod itself: bun scripts/readme-gifs.ts renders them to assets/. The launch video is made the same way; see video/.
Claude Fables was developed entirely in Claude Code cloud environments: the mod, its tests, the scenes and styles, this README's images and the launch video. It was also tested locally in the desktop app, but there may still be bugs, and some may only show up on your setup. If something looks off, run claude --debug and open an issue with what the log says.
hooks/register.tsx 209 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEFAULT_MODEL, Director, findModel, type Host, MODEL_LABELS, NARRATOR_MODELS, type NarratorModel } from './director'
5import { DEFAULT_LOOK, findLook, LOOK_NAMES, LOOKS, lookFor } from './looks'
6import type { FablesScene } from '../types'
7
8import { H, MAX_SVG, resumeAt, sceneToSvg, speaksAfter, W } from './svg'
9
10const scene = atom({ plugin: 'fables', key: 'scene' } as const, null)
11const enabled = atom({ plugin: 'fables', key: 'enabled' } as const, true)
12const style = atom({ plugin: 'fables', key: 'style' } as const, DEFAULT_LOOK)
13
14const STORE_ENABLED = 'enabled'
15const STORE_PIXEL = 'pixelArt'
16const STORE_STYLE = 'style'
17const STORE_MODEL = 'model'
18/** Every scene is drawn with the 3D Claude, in the style the person chose (looks.ts). */
19const FIGURE = '3d'
20/** This plugin's own tools, if it ever registers any, are not part of the story. */
21const OWN_TOOLS = 'mcp__fables__'
22
23/**
24 * The band's code font advance in CSS pixels: the desktop measures the band in
25 * cells of it, and the Svg wants pixels.
26 */
27const PX_PER_COLUMN = 8
28/** CSS pixels per stage unit: the stage's H units come out this many times taller. */
29const SCALE = 1.5
30
31/**
32 * The band's box in CSS pixels: its whole width, at a fixed height so the art and
33 * the caption keep one size whatever the window; the stage widens to fill it.
34 */
35function bandBox(columns: number): { width: number; height: number } {
36 const width = Number.isFinite(columns) && columns > 0 ? Math.round(columns * PX_PER_COLUMN) : W * SCALE
37 return { width, height: Math.round(H * SCALE) }
38}
39
40/** The narrator's host in a session: Claude Code's clock, its model, the band, and how the band draws a scene. */
41function host($: EngineInterface, band: Band): Host {
42 return {
43 now: () => $.clock.now(),
44 complete: ask => $.model.complete(ask),
45 show: drawn => update($, scene, () => drawn),
46 speaksAfter: async next => speaksAfter((await draw($, band, next)).base) * 1000,
47 }
48}
49
50async function chooseLook($: EngineInterface, n: Director, name: string) {
51 const look = lookFor(name)
52 n.look = look.name
53 await $.store.set(STORE_STYLE, look.name)
54 await update($, style, () => look.name)
55 return { text: `Scenes are now drawn as ${look.label}.` }
56}
57
58async function chooseModel($: EngineInterface, n: Director, model: NarratorModel) {
59 n.model = model
60 await $.store.set(STORE_MODEL, model)
61 return { text: `${MODEL_LABELS[model]} now writes the story.` }
62}
63
64async function setOn($: EngineInterface, n: Director, at: Host, value: boolean) {
65 await n.setOn(at, value)
66 await $.store.set(STORE_ENABLED, value)
67 await update($, enabled, () => value)
68}
69
70/**
71 * The scene up now, when it went up, and its drawing in the box and style last
72 * asked for. The band is drawn again whenever its props change (the turn
73 * ending flips isWorking, the window is resized) and the desktop may start the
74 * frame over then; so every drawing after the first is set to the scene's own
75 * clock, and it carries on where it was instead of typing its caption again.
76 */
77type Drawn = { scene: string; at: number; key: string; base: string }
78/** The drawing kept, and the band's box as last drawn, so a scene can be drawn the moment it goes up. */
79type Band = { drawn?: Drawn; box: { width: number; height: number } }
80
81/** The scene in the band's box and style, drawn once and kept: a scene going up starts its clock. */
82async function draw($: EngineInterface, band: Band, next: FablesScene): Promise<Drawn> {
83 const look = await read($, style)
84 const key = `${band.box.width}x${band.box.height}|${look}`
85 const same = JSON.stringify(next)
86 const kept = band.drawn
87 if (kept?.scene === same && kept.key === key) return kept
88 const at = kept?.scene === same ? kept.at : await $.clock.now()
89 band.drawn = { scene: same, at, key, base: sceneToSvg(next, { ...band.box, look, figure: FIGURE }) }
90 return band.drawn
91}
92
93export const register: Register = (on, options) => {
94 const n = new Director()
95 const band: Band = { box: bandBox(NaN) }
96 // The config menu's choice is the default; /fables model overrides it.
97 const configured = findModel(options.model) ?? DEFAULT_MODEL
98 n.model = configured
99 n.look = DEFAULT_LOOK
100
101 on('session.start', async ($, e, next) => {
102 n.isOn = (await $.store.get(STORE_ENABLED)) !== false
103 n.model = findModel(await $.store.get(STORE_MODEL)) ?? configured
104 await update($, enabled, () => n.isOn)
105 const saved = await $.store.get(STORE_STYLE)
106 // Pixel art was once a switch of its own: someone who turned it off keeps the original look, drawn smooth.
107 const smooth = (await $.store.get(STORE_PIXEL)) === false
108 n.look = typeof saved === 'string' && LOOKS[saved] ? saved : smooth ? 'original' : DEFAULT_LOOK
109 await update($, style, () => n.look)
110 await $.command.register({
111 name: 'fables',
112 description: 'Claude Fables: turn the cartoons above the prompt on or off, pick a style, or pick the model that writes them',
113 argumentHint: '[on|off|style [name|off]|model [sonnet|haiku]]',
114 })
115 $.clock.every(1000, () => void n.tick(host($, band)))
116 return next(e)
117 })
118
119 on('command.run', { command: 'fables' }, async ($, e) => {
120 const arg = e.args.trim().toLowerCase()
121 const md = /^(?:model|models|narrator)\b\s*(.*)$/.exec(arg)
122 if (md) {
123 const want = (md[1] ?? '').trim()
124 if (!want) {
125 const list = NARRATOR_MODELS.map(m => `${m === n.model ? '▸' : ' '} ${m} · ${MODEL_LABELS[m]}`).join('\n')
126 return { text: `${MODEL_LABELS[n.model]} writes the story. Pick another with /fables model <name>:\n${list}` }
127 }
128 const model = findModel(want)
129 if (!model) return { text: `No narrator called "${want}". Try /fables model sonnet or /fables model haiku.` }
130 return chooseModel($, n, model)
131 }
132 const st = /^(?:style|styles|look)\b\s*(.*)$/.exec(arg)
133 if (st) {
134 const want = (st[1] ?? '').trim()
135 if (!want) {
136 const list = LOOK_NAMES.map(name => `${name === n.look ? '▸' : ' '} ${name} · ${lookFor(name).label}`).join('\n')
137 return { text: `Scenes are drawn in ${lookFor(n.look).label}. Pick a style with /fables style <name>, or /fables style off for the default:\n${list}` }
138 }
139 const look = /^(off|none|default|plain)$/.test(want) ? lookFor(DEFAULT_LOOK) : findLook(want)
140 if (!look) return { text: `No style called "${want}". /fables style lists them.` }
141 return chooseLook($, n, look.name)
142 }
143 // Pixel art is a style now; the old switch still works, as a way to pick it or the smooth original.
144 const px = /^pixel(?:\s+(on|off))?$/.exec(arg)
145 if (px) return chooseLook($, n, px[1] === 'off' || (!px[1] && n.look === 'pixel') ? 'original' : 'pixel')
146 const value = arg === 'on' ? true : arg === 'off' ? false : !n.isOn
147 await setOn($, n, host($, band), value)
148 return {
149 text: value
150 ? `Claude Fables is on: cartoons written by ${MODEL_LABELS[n.model]} play above the prompt while Claude works.`
151 : 'Claude Fables is off.',
152 }
153 })
154
155 on('prompt.submit', async ($, e, next) => {
156 n.submit(e.text)
157 return next(e)
158 })
159
160 on('tool.call', async ($, e, next) => {
161 const tool = String(e.tool)
162 const isTold = e.agentId === undefined && !tool.startsWith(OWN_TOOLS)
163 if (isTold) n.tool(tool, e)
164 const ran = await next(e)
165 if (isTold && (ran.deny !== undefined || ran.isError === true)) n.failed(tool, e)
166 return ran
167 })
168
169 on('session.append', async ($, e, next) => {
170 if (e.door === 'response' && e.agentId === undefined && Array.isArray(e.message.content)) {
171 const said = e.message.content
172 .map(block => (block.type === 'text' ? block.text : ''))
173 .join(' ')
174 .trim()
175 n.said(said)
176 }
177 return next(e)
178 })
179
180 on('turn.complete', async ($, e, next) => {
181 if (e.agentId === undefined) n.complete(e.reason)
182 return next(e)
183 })
184
185 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186 if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
187 const current = await read($, scene)
188 if (!current || !(await read($, enabled))) return next(e)
189 const { Svg } = $.ui.resolve(e)
190 // The interactive frame does not size itself from the markup (left alone it
191 // is a 300x150 box), so give it the band's box; a new width draws anew.
192 band.box = bandBox(e.props.bodyColumns)
193 const { width, height } = band.box
194 const { base, at } = await draw($, band, current)
195 // To a tenth of a second, so drawings in the same moment stay the same.
196 const along = Math.floor(((await $.clock.now()) - at) / 100) / 10
197 const resumed = resumeAt(base, along)
198 return (
199 <Svg
200 source={resumed.length <= MAX_SVG ? resumed : base}
201 alt={current.caption}
202 width={width}
203 height={height}
204 isInteractive
205 />
206 )
207 })
208}
209hooks/director.ts 245 lines1/**
2 * The narrator's loop, free of any host: what it remembers, when it asks the
3 * model for the next scene, what it does with the reply. The plugin runs it on
4 * Claude Code's clock and model (register.tsx); the viewer runs the very same
5 * loop on a scripted clock and scripted replies, so a session rehearsed there
6 * plays out as it would live.
7 */
8import type { FablesScene } from '../types'
9
10import { type Activity, pushActivity, summarizeSpeech, summarizeTool } from './activity'
11import { backoffMs, buildPrompt, readMs, remember, sceneFromReply, type StoryBeat, SYSTEM } from './narrator'
12import { lookFor } from './looks'
13
14/** The models that can write the story: Sonnet by default, Haiku for quicker, cheaper scenes. */
15export const NARRATOR_MODELS = ['sonnet', 'haiku'] as const
16export type NarratorModel = (typeof NARRATOR_MODELS)[number]
17export const DEFAULT_MODEL: NarratorModel = 'sonnet'
18export const MODEL_LABELS: Record<NarratorModel, string> = { sonnet: 'Sonnet', haiku: 'Haiku' }
19
20/** A model name as a person might type it, or as an older config holds it, to one of the narrators. */
21export function findModel(name: unknown): NarratorModel | undefined {
22 const s = typeof name === 'string' ? name.trim().toLowerCase() : ''
23 return NARRATOR_MODELS.find(m => s === m || s.startsWith(`claude-${m}`) || s.includes(m))
24}
25
26/** How long the last scene of a turn stays up once the turn is over (and at least until it is read). */
27export const LINGER_MS = 30000
28/** What the model is expected to take to answer until it has been timed, and the bounds of that guess. */
29export const FIRST_LATENCY_MS = 3000
30const LATENCY_BOUNDS = [1000, 10000] as const
31
32export type Ending = 'answer' | 'aborted' | 'error' | 'refusal'
33
34/** What the narrator asks of the model, as the host's model call takes it. */
35export type Ask = { model: NarratorModel; system: string; prompt: string; maxTokens: number; effort: 'low'; timeoutMs: number }
36export type Answer = { isAnswered: boolean; text: string }
37
38/** What happens inside the loop, for whoever wants to watch it (the viewer does). */
39export type Trace =
40 | { kind: 'ask'; at: number; model: NarratorModel; prompt: string; closing?: Ending }
41 | { kind: 'reply'; at: number; model: NarratorModel; text: string; isAnswered: boolean; scene: FablesScene | null; isStale: boolean }
42 | { kind: 'failed'; at: number; error: string }
43 | { kind: 'wait'; at: number; until: number; failures: number }
44 | { kind: 'hold'; at: number; until: number }
45 | { kind: 'clear'; at: number }
46
47/** The host's side: its clock, its model, and the band the scene goes to. */
48export type Host = {
49 now(): number | Promise<number>
50 complete(ask: Ask): Promise<Answer>
51 show(scene: FablesScene | null): void | Promise<void>
52 /** How long the scene's bubble waits for Claude to walk into reach before it appears, in ms, as the band draws it. */
53 speaksAfter?(scene: FablesScene): number | Promise<number>
54 trace?(event: Trace): void
55}
56
57/** A scene come back from the model, waiting for its turn on the band. */
58type Queued = { scene: FablesScene; closing: Ending | undefined; turn: number }
59
60/** The most scenes that wait at once: the next one, and the turn's closing scene behind it. */
61export const MAX_QUEUE = 2
62
63/**
64 * The band plays scenes from a queue, one at a time, and each one stays up
65 * until its caption has typed out and been read, however the work runs on:
66 * nothing cuts in, not a failure, not the turn's end, not the next prompt.
67 * The narrator asks for the next scene so it comes back as the one on the
68 * band is read through; news (a failure, the turn ending) is asked for at
69 * once and waits its turn in the queue.
70 */
71export class Director {
72 model: NarratorModel = DEFAULT_MODEL
73 isOn = true
74 /** The style scenes are drawn in, so the narrator can write in its voice. */
75 look = 'pixel'
76 ask = ''
77 log: Activity[] = []
78 story: StoryBeat[] = []
79 isTurnRunning = false
80 isDirty = false
81 isAsking = false
82 ending: Ending | undefined = undefined
83 nextAt = 0
84 failures = 0
85 turn = 0
86 /** The scene on the band, if any, and when it went up. */
87 shown: FablesScene | undefined = undefined
88 shownAt = 0
89 /** Until then the scene on the band is still being typed out and read: nothing replaces it. */
90 readUntil = 0
91 /** Scenes come back while the one on the band is still being read, oldest first. */
92 queue: Queued[] = []
93 /** Something failed since the last ask: the next scene is asked for at once. */
94 isUrgent = false
95 /** How long the model takes to answer, smoothed, so the next scene is asked for in time to follow on. */
96 latency = FIRST_LATENCY_MS
97
98 private note(entry: Activity) {
99 this.log = pushActivity(this.log, entry)
100 this.isDirty = true
101 }
102
103 /**
104 * The person asked something: a new turn, a fresh log. The scene on the band
105 * is still read through, and a closing scene still waiting plays first; the
106 * old turn's other scenes are behind the story now and are dropped.
107 */
108 submit(text: string) {
109 this.turn++
110 this.ask = text.replace(/\s+/g, ' ').trim().slice(0, 300)
111 this.log = []
112 this.ending = undefined
113 this.isTurnRunning = true
114 this.isDirty = true
115 this.isUrgent = false
116 this.queue = this.queue.filter(q => q.closing !== undefined)
117 }
118
119 /** The agent called a tool. */
120 tool(tool: string, input: Readonly<Record<string, unknown>>) {
121 this.note({ kind: 'tool', text: summarizeTool(tool, input) })
122 }
123
124 /** A tool call was denied or came back an error. */
125 failed(tool: string, input: Readonly<Record<string, unknown>>) {
126 this.note({ kind: 'failed', text: summarizeTool(tool, input) })
127 this.isUrgent = true
128 }
129
130 /** The agent said something between its tool calls. */
131 said(text: string) {
132 const flat = text.trim()
133 if (flat) this.note({ kind: 'said', text: summarizeSpeech(flat) })
134 }
135
136 /** The turn ended; if anything happened, a closing scene follows. */
137 complete(reason: Ending) {
138 if (!this.isTurnRunning) return
139 this.isTurnRunning = false
140 if (this.isOn && this.log.length > 0) this.ending = reason
141 }
142
143 /**
144 * Called once a second, with the host to ask through. Puts the next scene in
145 * the queue up once the one on the band has been read, clears the band once
146 * the turn is over and its last scene has lingered, and asks for the next
147 * scene when there is news.
148 */
149 async tick(host: Host) {
150 if (!this.isOn) return
151 const now = await host.now()
152 const next = this.queue[0]
153 if (next && now >= this.readUntil) await this.present(host, next, now)
154 else if (this.isIdle && this.shown && now >= this.readUntil && now >= this.shownAt + LINGER_MS) {
155 this.shown = undefined
156 await host.show(null)
157 host.trace?.({ kind: 'clear', at: now })
158 }
159 if (this.isAsking) return
160 // The closing scene is asked for at once, and may wait behind one other scene.
161 if (this.ending) return this.queue.length < MAX_QUEUE ? this.narrate(host, now) : undefined
162 if (!this.isTurnRunning || !this.isDirty || this.queue.length > 0) return
163 if (now < this.nextAt) return
164 if (!this.isUrgent && now < this.readUntil - this.latency) return
165 return this.narrate(host, now)
166 }
167
168 /** Nothing more to come: the turn is over, and no scene is asked for or waiting. */
169 private get isIdle() {
170 return !this.isTurnRunning && !this.isAsking && !this.ending && this.queue.length === 0
171 }
172
173 private async narrate(host: Host, startedAt: number) {
174 this.isAsking = true
175 this.isDirty = false
176 const forTurn = this.turn
177 const closing = this.ending
178 this.ending = undefined
179 const isUrgent = this.isUrgent || closing !== undefined
180 this.isUrgent = false
181 // The line the hero will still be saying when this scene comes back: news breaks in on it, in the story.
182 const before = this.queue.at(-1)?.scene ?? this.shown
183 const isMidLine = this.queue.length > 0 || startedAt + this.latency < this.readUntil
184 const interrupts = isUrgent && before && isMidLine ? { why: closing ? ('ended' as const) : ('failed' as const), line: before.caption } : undefined
185 const look = lookFor(this.look)
186 const prompt = buildPrompt({ ask: this.ask, log: this.log, story: this.story, ending: closing, look: look.voice ? look : undefined, interrupts })
187 host.trace?.({ kind: 'ask', at: startedAt, model: this.model, prompt, closing })
188 try {
189 const reply = await host.complete({ model: this.model, system: SYSTEM, prompt, maxTokens: 2000, effort: 'low', timeoutMs: 30000 })
190 const now = await host.now()
191 if (reply.isAnswered) {
192 const [lo, hi] = LATENCY_BOUNDS
193 this.latency = Math.round(Math.min(hi, Math.max(lo, this.latency * 0.6 + (now - startedAt) * 0.4)))
194 }
195 const drawn = reply.isAnswered ? sceneFromReply(reply.text) : null
196 // A scene that lands after a newer turn began belongs to a story nobody is following, unless it closes that story.
197 const isStale = forTurn !== this.turn && !closing
198 host.trace?.({ kind: 'reply', at: now, model: this.model, text: reply.text, isAnswered: reply.isAnswered, scene: drawn, isStale })
199 if (!drawn) {
200 this.failures++
201 return
202 }
203 this.failures = 0
204 if (isStale || !this.isOn) return
205 const queued: Queued = { scene: drawn, closing, turn: forTurn }
206 // A closing scene goes ahead of any scene of a newer turn already waiting.
207 const at = closing ? this.queue.findIndex(q => q.turn > forTurn) : -1
208 if (at >= 0) this.queue.splice(at, 0, queued)
209 else this.queue.push(queued)
210 if (this.queue[0] === queued && now >= this.readUntil) await this.present(host, queued, now)
211 else host.trace?.({ kind: 'hold', at: now, until: this.readUntil })
212 } catch (err) {
213 this.failures++
214 host.trace?.({ kind: 'failed', at: await host.now(), error: err instanceof Error ? err.message : String(err) })
215 } finally {
216 this.isAsking = false
217 const now = await host.now()
218 this.nextAt = now + backoffMs(this.failures)
219 host.trace?.({ kind: 'wait', at: now, until: this.nextAt, failures: this.failures })
220 }
221 }
222
223 /** Puts the next scene on the band, and holds it there until it has been read. */
224 private async present(host: Host, next: Queued, now: number) {
225 this.queue = this.queue.filter(q => q !== next)
226 // A new setting fades in from the last; the same one carries straight on.
227 const scene: FablesScene = this.shown && this.shown.backdrop !== next.scene.backdrop ? { ...next.scene, enter: 'fade' } : next.scene
228 this.story = remember(this.story, next.scene)
229 this.shown = scene
230 this.shownAt = now
231 this.readUntil = now + readMs(scene) + ((await host.speaksAfter?.(scene)) ?? 0)
232 await host.show(scene)
233 }
234
235 /** Turned off: no more asking, and the band is cleared. */
236 async setOn(host: Host, value: boolean) {
237 this.isOn = value
238 if (value) return
239 this.queue = []
240 this.shown = undefined
241 this.readUntil = 0
242 await host.show(null)
243 }
244}
245hooks/looks.ts 183 lines1/**
2 * Graphic styles ("looks") a scene can be drawn in: the original look, its
3 * pixel art, and the art styles of the Claude Mascot Style Gallery
4 * (github.com/henrik-thevibe/Claude-Mascot-Style-Gallery).
5 *
6 * A look never changes what a scene is, only how it is drawn. The original
7 * look is the lit scenery as authored; its pixel art pixelizes the whole stage.
8 * Each gallery style is an artwork of its own (styles/*.ts): every element of
9 * the scenery and Claude himself redrawn in its medium, with its own texture,
10 * frame, chapter tag and caption bubble. A style made on a grid (tesserae,
11 * a loom) also names that grid as its medium.
12 */
13
14import type { Painter } from './art/roles'
15import type { HeroPainter } from './hero3d'
16import { AERO } from './styles/aero'
17import { BLUEPRINT } from './styles/blueprint'
18import { CAVE } from './styles/cave'
19import { ENGRAVING } from './styles/engraving'
20import { GOLDEN } from './styles/golden'
21import { KAMON } from './styles/kamon'
22import { MOSAIC } from './styles/mosaic'
23import { TAPESTRY } from './styles/tapestry'
24import { UKIYOE } from './styles/ukiyoe'
25
26/** How one art pixel of a sprite is drawn. */
27export type Cell = 'solid'
28
29export type CaptionStyle = { fill: string; stroke: string; ink: string; radius: number }
30
31/** The kinds of words a caption sets apart (svg.ts). */
32export type WordKind = 'code' | 'path' | 'fn' | 'num' | 'bad' | 'good' | 'face'
33
34/** The caption's cartoon paper in a look: the card, its ink, and the color of each kind of word. */
35export type Paper = { card: string; ink: string; kinds: Record<WordKind, string> }
36
37/** The chapter tag as a look draws it, at the top left, and the room it takes. */
38export type Tag = { svg: string; w: number; h: number }
39
40type Pt = [number, number]
41/**
42 * The caption's bubble as a style draws it: its type, and its frame and tail
43 * under the text. `tail` runs from two points on the frame to a tip by Claude.
44 */
45export type Bubble = {
46 font: string
47 size: number
48 /** The type's average advance, in stage units, to fit the bubble to its lines. */
49 charW: number
50 line: number
51 /** The first line's baseline below the bubble's top. */
52 base: number
53 /** Set the caption in capitals, as a drafting hand would. */
54 upper?: boolean
55 draw: (box: { x: number; y: number; w: number; h: number }, tail: { base: [Pt, Pt]; tip: Pt }) => string
56}
57
58/**
59 * A style drawn as an artwork of its own: every element of the scenery and
60 * Claude himself repainted in its medium, rather than the finished picture graded.
61 */
62export type Art = {
63 /** Paints the scenery's elements, for a stage `sw` wide. */
64 painter: (sw: number) => Painter
65 /** Paints Claude. */
66 hero: () => HeroPainter
67 /** The sky and ground colors past the stage's edges. */
68 sky: string
69 ground: string
70 /** Definitions the painters refer to (patterns, gradients), laid down once under the stage. */
71 defs?: (sw: number, h: number) => string
72 /** The ground everything is drawn on (a wall, a floor of stones), under the scenery. */
73 under?: (sw: number, h: number) => string
74}
75
76export type Look = {
77 name: string
78 /** What the look is called, after the gallery's style it comes from. */
79 label: string
80 /** A few words for the narrator, so the caption's voice can suit the look. */
81 voice: string
82 cell: Cell
83 /** How the hero is drawn here unless the person says otherwise: the pixel sprite or the 3D model. */
84 figure: 'pixel' | '3d'
85 font: string
86 /** The font's average advance at the caption's size, in stage units, to fit the bubble. */
87 charW: number
88 /** The caption on the flat stage. */
89 caption: CaptionStyle
90 /** The caption's cartoon paper on the rich stage; absent, the default paper. */
91 paper?: Paper
92 /** How far a frame the look draws reaches in, so the chapter tag clears it. */
93 inset?: number
94 /** The chapter tag's color; absent, the scene's accent. */
95 titleColor?: string
96 /**
97 * The medium a style is made on, as filter primitives from SourceGraphic over
98 * the drawn stage: the grid of tesserae or weave its elements
99 * are set into, or the wavering of daubed pigment.
100 */
101 grade?: (sw: number, h: number, pixel: boolean) => string
102 /** The color past the stage's edges, where a box wider than the stage shows. */
103 edge?: string
104 /** Over the drawn stage: paper, weave, grout. Static, so it costs nothing per frame. */
105 texture?: (sw: number, h: number, ground: number, pixel: boolean) => string
106 /** A frame round the stage, over the texture. */
107 frame?: (sw: number, h: number, ground: number) => string
108 /** The chapter tag, drawn in the look; absent, the plain tag. */
109 tag?: (text: string, inset: number) => Tag
110 /** Draw the stage as pixel art: only the original look's own pixel style does. */
111 pixel?: boolean
112 /** Set when the look is an artwork in its own right (see Art). */
113 art?: Art
114 /** The caption's bubble, when the style draws its own. */
115 bubble?: Bubble
116}
117
118const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
119
120/** The default caption paper (svg.ts draws it unchanged when a look names none). */
121export const PAPER: Paper = {
122 card: '#f6f1e7',
123 ink: '#2b2420',
124 kinds: { code: '#186a5a', path: '#2b5f9e', fn: '#7b3fa0', num: '#b5541a', bad: '#b3261e', good: '#2e7d32', face: '#c4613f' },
125}
126
127// ---------------------------------------------------------------- the looks
128
129/** The original look: the authored scenes as they are lit, drawn smooth. */
130const ORIGINAL: Look = {
131 name: 'original',
132 label: 'Original',
133 voice: '',
134 cell: 'solid',
135 figure: 'pixel',
136 font: MONO,
137 charW: 5.7,
138 caption: { fill: '#2b1c1a', stroke: '#cfc8b8', ink: '#ece9df', radius: 2 },
139}
140
141/** The original look in pixel art: the whole stage pixelized, Claude a sprite with an outline. The default. */
142const PIXEL: Look = { ...ORIGINAL, name: 'pixel', label: 'Pixel Art', pixel: true }
143
144export const LOOKS: Record<string, Look> = {
145 pixel: PIXEL,
146 original: ORIGINAL,
147
148 cave: CAVE,
149
150 blueprint: BLUEPRINT,
151
152 mosaic: MOSAIC,
153
154 aero: AERO,
155
156 engraving: ENGRAVING,
157
158 tapestry: TAPESTRY,
159
160 golden: GOLDEN,
161
162 ukiyoe: UKIYOE,
163
164 kamon: KAMON,
165}
166
167export const LOOK_NAMES = Object.keys(LOOKS)
168export const DEFAULT_LOOK = 'pixel'
169/** The styles from the gallery: every look but the original and its pixel art. */
170export const STYLE_NAMES = LOOK_NAMES.filter(name => name !== 'pixel' && name !== 'original')
171
172/** The look by name; an unknown name draws the default. */
173export const lookFor = (name: string | undefined): Look => LOOKS[name ?? DEFAULT_LOOK] ?? PIXEL
174
175const squash = (text: string) => text.toLowerCase().replace(/[^a-z]/g, '')
176
177/** The look a person means, leniently: "Ukiyo-e", "golden age", "frutiger aero" and "Copperplate" all find theirs. */
178export function findLook(text: string): Look | undefined {
179 const want = squash(text)
180 if (!want) return undefined
181 return Object.values(LOOKS).find(look => squash(look.name) === want || squash(look.label) === want || squash(look.label).includes(want) || squash(look.name).startsWith(want))
182}
183hooks/svg.ts 1003 lines1import type { FablesHeroAction, FablesProp, FablesScene } from '../types'
2
3import { type Motion, MOTION_TIMING } from './clawd3d'
4import { type HeroPainter, motionSvg } from './hero3d'
5import { gradeFilter } from './grade'
6import { MONOCRAFT, MONOCRAFT_BOLD } from './monocraft'
7import { ENTRANCE_SECONDS, TYPE_SECONDS_PER_CHAR } from './scene'
8import { richBackdrop } from './scenery'
9import { type Cell, type Look, lookFor, PAPER as CARD_PAPER, type Paper, type Tag, type WordKind } from './looks'
10import { HERO_FRAMES, PALETTE, SPRITES, type SpriteName } from './sprites'
11
12/**
13 * The logical stage: always H tall, and as wide as the band's shape asks, from
14 * MIN_W to MAX_W (W when nothing asks), so a wide window shows more of the
15 * scene rather than a bigger one.
16 */
17export const W = 640
18export const H = 128
19export const MIN_W = 320
20export const MAX_W = 1600
21/** One art pixel, in stage units. */
22export const U = 4
23const GROUND_Y = 104
24/** The desktop's Svg element takes at most this many characters. */
25export const MAX_SVG = 131072
26const BUDGET = 126000
27
28const FONT = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
29const INK = '#1f1e1d'
30const PAPER = '#ece9df'
31
32// ---------------------------------------------------------------- helpers
33
34export function escapeXml(text: string): string {
35 return text
36 .replace(/&/g, '&')
37 .replace(/</g, '<')
38 .replace(/>/g, '>')
39 .replace(/"/g, '"')
40 .replace(/'/g, ''')
41}
42
43/** A small deterministic PRNG (mulberry32), so a scene always draws the same. */
44export function rng(seedText: string): () => number {
45 let seed = 2166136261
46 for (let i = 0; i < seedText.length; i++) seed = Math.imul(seed ^ seedText.charCodeAt(i), 16777619)
47 return () => {
48 seed = (seed + 0x6d2b79f5) | 0
49 let t = seed
50 t = Math.imul(t ^ (t >>> 15), t | 1)
51 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
52 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
53 }
54}
55
56type Rect = { x: number; y: number; w: number; h: number }
57
58const n = (v: number) => (Math.round(v * 100) / 100).toString()
59
60/**
61 * Pixel rows to compact SVG, in art-pixel units: one <path> per color, each
62 * horizontal run of a color one `M x y h w v 1 h -w z` segment.
63 */
64export function pixelPaths(rows: readonly string[], colorOf: (key: string) => string | undefined, _cell: Cell = 'solid'): string {
65 const byColor = new Map<string, string[]>()
66 rows.forEach((row, y) => {
67 let x = 0
68 while (x < row.length) {
69 const key = row[x] ?? '.'
70 const color = key === '.' ? undefined : colorOf(key)
71 let end = x + 1
72 while (end < row.length && row[end] === key) end++
73 if (color) {
74 const list = byColor.get(color) ?? []
75 list.push(`M${x} ${y}h${end - x}v1h-${end - x}z`)
76 byColor.set(color, list)
77 }
78 x = end
79 }
80 })
81 return [...byColor].map(([color, d]) => `<path fill="${color}" d="${d.join('')}"/>`).join('')
82}
83
84const widthOf = (rows: readonly string[]) => Math.max(0, ...rows.map(r => r.length))
85
86type Art = { rows: readonly string[]; colorOf: (key: string) => string | undefined }
87
88function artFor(prop: FablesProp): Art {
89 if (typeof prop.sprite === 'string') {
90 const sprite = SPRITES[prop.sprite as SpriteName] ?? SPRITES.star
91 const accent = prop.color ?? sprite.accent
92 return { rows: sprite.rows, colorOf: k => (k === 'a' ? accent : PALETTE[k]) }
93 }
94 const { pixels, colors } = prop.sprite
95 return { rows: pixels, colorOf: k => colors[k] }
96}
97
98// ---------------------------------------------------------------- backdrops
99
100type Stage = {
101 sky: string
102 ground: string
103 /** Where the hero's and props' feet rest. */
104 floor: number
105 back: string
106 front: string
107}
108
109function backdrop(scene: FablesScene, rand: () => number, sw: number): Stage {
110 const accent = scene.palette.accent
111 const parts: string[] = []
112 const front: string[] = []
113 const at = (v: number) => Math.round(v / U) * U
114 let sky = '#262624'
115 let ground = '#3a3833'
116 let floor = GROUND_Y
117
118 switch (scene.backdrop) {
119 case 'forest': {
120 sky = '#20261f'
121 ground = '#3c6e34'
122 for (let i = 0; i < Math.round((9 * sw) / W); i++) {
123 const x = at(rand() * sw)
124 const h = at(24 + rand() * 30)
125 parts.push(
126 `<rect x="${x}" y="${GROUND_Y - h}" width="${U * 3}" height="${h}" fill="#2a3a26"/>`,
127 `<rect x="${x - U * 2}" y="${GROUND_Y - h - U * 3}" width="${U * 7}" height="${U * 5}" fill="#2f4a2a"/>`,
128 )
129 }
130 for (let x = 0; x < sw; x += U * 5) {
131 const h = U * (1 + Math.floor(rand() * 3))
132 front.push(`<rect x="${x + at(rand() * 8)}" y="${GROUND_Y - h}" width="${U}" height="${h}" fill="#5e9c4a"/>`)
133 }
134 break
135 }
136 case 'space': {
137 sky = '#14121c'
138 ground = '#14121c'
139 floor = 92
140 for (let i = 0; i < 3; i++) {
141 const r = 4 + rand() * 12
142 parts.push(`<circle cx="${n(rand() * sw)}" cy="${n(10 + rand() * 60)}" r="${n(r)}" fill="${['#54408a', '#7b5fb5', '#8a8780'][i]}" opacity=".7"/>`)
143 }
144 break
145 }
146 case 'city': {
147 sky = '#1e2230'
148 ground = '#33353d'
149 let x = 0
150 while (x < sw) {
151 const w = at(24 + rand() * 40)
152 const h = at(30 + rand() * 50)
153 parts.push(`<rect x="${x}" y="${GROUND_Y - h}" width="${w - U}" height="${h}" fill="#2b2f3d"/>`)
154 for (let wy = GROUND_Y - h + U * 2; wy < GROUND_Y - U * 2; wy += U * 3) {
155 for (let wx = x + U; wx < x + w - U * 2; wx += U * 3) {
156 if (rand() < 0.35) {
157 const blink = rand() < 0.2 ? `<animate attributeName="opacity" values="1;.2;1" dur="${n(2 + rand() * 4)}s" repeatCount="indefinite"/>` : ''
158 parts.push(`<rect x="${wx}" y="${wy}" width="${U}" height="${U}" fill="#e3b341" opacity=".8">${blink}</rect>`)
159 }
160 }
161 }
162 x += w
163 }
164 break
165 }
166 case 'desert': {
167 sky = '#2e2620'
168 ground = '#c9a46a'
169 parts.push(`<circle cx="${n(80 + rand() * (sw - 160))}" cy="26" r="14" fill="#f0c060"/>`)
170 for (let i = 0; i < Math.round((4 * sw) / W); i++) {
171 const cx = rand() * sw
172 parts.push(`<ellipse cx="${n(cx)}" cy="${GROUND_Y}" rx="${n(60 + rand() * 60)}" ry="${n(8 + rand() * 10)}" fill="#a8844f"/>`)
173 }
174 break
175 }
176 case 'volcano': {
177 sky = '#2a1a17'
178 ground = '#4a2c20'
179 const vx = at(sw * 0.6 + rand() * (sw * 0.4 - 120))
180 parts.push(
181 `<polygon points="${vx},${GROUND_Y} ${vx + 50},${GROUND_Y - 70} ${vx + 66},${GROUND_Y - 70} ${vx + 116},${GROUND_Y}" fill="#5a3520"/>`,
182 `<rect x="${vx + 50}" y="${GROUND_Y - 74}" width="16" height="6" fill="#f06a2b"><animate attributeName="fill" values="#f06a2b;#e3b341;#f06a2b" dur="1.4s" repeatCount="indefinite"/></rect>`,
183 )
184 for (let i = 0; i < 6; i++) {
185 const dx = (rand() - 0.5) * 60
186 parts.push(
187 `<rect x="${vx + 56}" y="${GROUND_Y - 76}" width="${U}" height="${U}" fill="#f06a2b">` +
188 `<animateTransform attributeName="transform" type="translate" values="0 0;${n(dx)} -30;${n(dx * 1.6)} 10" dur="${n(1.6 + rand())}s" begin="${n(rand() * 2)}s" repeatCount="indefinite"/>` +
189 `<animate attributeName="opacity" values="1;1;0" dur="${n(1.6 + rand())}s" repeatCount="indefinite"/></rect>`,
190 )
191 }
192 break
193 }
194 case 'lab': {
195 sky = '#202628'
196 ground = '#3b3e47'
197 for (let x = 0; x < sw; x += 32) parts.push(`<rect x="${x}" y="0" width="1" height="${GROUND_Y}" fill="#2a3134"/>`)
198 for (let y = 0; y < GROUND_Y; y += 32) parts.push(`<rect x="0" y="${y}" width="${sw}" height="1" fill="#2a3134"/>`)
199 for (let x = 0; x < sw; x += U * 4) front.push(`<rect x="${x}" y="${GROUND_Y}" width="${U * 2}" height="${U}" fill="#5b5f6b"/>`)
200 break
201 }
202 case 'night':
203 default: {
204 sky = '#1b1d26'
205 ground = '#2c2e36'
206 parts.push(`<circle cx="${n(60 + rand() * (sw - 120))}" cy="24" r="10" fill="#e8e3c8"/>`)
207 break
208 }
209 }
210 if (accent) front.push(`<rect x="0" y="${GROUND_Y}" width="${sw}" height="2" fill="${accent}" opacity=".7"/>`)
211 return {
212 sky: scene.palette.sky ?? sky,
213 ground: scene.palette.ground ?? ground,
214 floor,
215 back: parts.join(''),
216 front: front.join(''),
217 }
218}
219
220/** The flat stage in the shape the scene draws: its front details sit on the ground, behind the props. */
221const flatStage = (s: Stage) => ({ sky: s.sky, ground: s.ground, floor: s.floor, groundTop: GROUND_Y, back: s.back, near: s.front, keep: [] as Rect[], lens: '' })
222
223// ---------------------------------------------------------------- particles
224
225function particles(scene: FablesScene, rand: () => number, sw: number): string {
226 if (!scene.particles) return ''
227 const { kind, density } = scene.particles
228 const count = Math.round((density * 36 * sw) / W)
229 const out: string[] = []
230 for (let i = 0; i < count; i++) {
231 const x = n(rand() * sw)
232 const y = n(rand() * GROUND_Y)
233 const dur = n(1.5 + rand() * 3)
234 const begin = n(-rand() * 4)
235 switch (kind) {
236 case 'stars':
237 out.push(
238 `<rect x="${x}" y="${n(rand() * 80)}" width="2" height="2" fill="#e3d9a0"><animate attributeName="opacity" values="1;.15;1" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
239 )
240 break
241 case 'rain':
242 out.push(
243 `<rect x="${x}" y="-8" width="1" height="6" fill="#6fa8d0"><animateTransform attributeName="transform" type="translate" values="0 0;-8 ${GROUND_Y + 8}" dur="${n(0.6 + rand() * 0.5)}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
244 )
245 break
246 case 'snow':
247 out.push(
248 `<rect x="${x}" y="-4" width="2" height="2" fill="${PAPER}"><animateTransform attributeName="transform" type="translate" values="0 0;6 ${GROUND_Y / 2};-4 ${GROUND_Y + 4}" dur="${n(4 + rand() * 4)}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
249 )
250 break
251 case 'bubbles':
252 out.push(
253 `<circle cx="${x}" cy="${GROUND_Y}" r="${n(1 + rand() * 2)}" fill="none" stroke="#9fd3e0"><animateTransform attributeName="transform" type="translate" values="0 0;4 -${GROUND_Y / 2};-2 -${GROUND_Y}" dur="${n(3 + rand() * 3)}s" begin="${begin}s" repeatCount="indefinite"/></circle>`,
254 )
255 break
256 case 'sparks':
257 out.push(
258 `<rect x="${x}" y="${y}" width="2" height="2" fill="#e3b341"><animateTransform attributeName="transform" type="translate" values="0 0;${n((rand() - 0.5) * 20)} -20" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
259 )
260 break
261 case 'leaves':
262 out.push(
263 `<rect x="${x}" y="-4" width="3" height="2" fill="${rand() < 0.5 ? '#c98a3a' : '#5e9c4a'}"><animateTransform attributeName="transform" type="translate" values="0 0;30 ${GROUND_Y / 2};10 ${GROUND_Y + 4}" dur="${n(4 + rand() * 3)}s" begin="${begin}s" repeatCount="indefinite"/></rect>`,
264 )
265 break
266 }
267 }
268 return out.join('')
269}
270
271// ---------------------------------------------------------------- props
272
273/** A hex color as rgb(), as prop labels have always been written. */
274function fixed(hex: string): string {
275 const m = /^#([0-9a-f]{6})$/i.exec(hex)
276 if (!m?.[1]) return hex
277 const v = parseInt(m[1], 16)
278 return `rgb(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255})`
279}
280
281/** Label colors: the prop's own tint. */
282export type TagStyle = { fill: string; ink: string; stroke: string }
283
284function label(text: string, cx: number, y: number, tag: TagStyle, sw: number): string {
285 const w = text.length * 5 + 8
286 const x = Math.min(sw - w - 2, Math.max(2, cx - w / 2))
287 const top = Math.max(2, y - 13)
288 return (
289 `<rect x="${n(x)}" y="${n(top)}" width="${w}" height="11" fill="${fixed(tag.fill)}" stroke="${fixed(tag.stroke)}" stroke-width="1"/>` +
290 `<text x="${n(x + 4)}" y="${n(top + 8)}" font-family="${FONT}" font-size="8" fill="${fixed(tag.ink)}">${escapeXml(text)}</text>`
291 )
292}
293
294/** One prop, drawn flat on the pixel stage, with its label tag above it. */
295function propSvg(prop: FablesProp, floor: number, index: number, sw: number, cell: Cell): string {
296 const art = artFor(prop)
297 const w = widthOf(art.rows) * U
298 const h = art.rows.length * U
299 const x = Math.round(((sw - w) * prop.x) / 100)
300 const y = prop.y === 'ground' ? floor - h : prop.y === 'air' ? 58 - h / 2 : 8
301 const cx = x + w / 2
302 const cy = y + h / 2
303 const body = `<g transform="translate(${x} ${n(y)}) scale(${U})">${pixelPaths(art.rows, art.colorOf, cell)}</g>`
304 const slow = n(2 + (index % 3) * 0.7)
305 let motion = ''
306 switch (prop.motion) {
307 case 'bob':
308 motion = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${U};0 0" dur="${slow}s" repeatCount="indefinite"/>`
309 break
310 case 'drift':
311 motion = `<animateTransform attributeName="transform" type="translate" values="0 0;24 -2;0 0" dur="${n(6 + index)}s" repeatCount="indefinite"/>`
312 break
313 case 'shake':
314 motion = `<animateTransform attributeName="transform" type="translate" values="0 0;-2 0;2 0;0 0" dur=".25s" repeatCount="indefinite"/>`
315 break
316 case 'fall':
317 motion = `<animateTransform attributeName="transform" type="translate" values="0 -${n(y + h)};0 0;0 0" keyTimes="0;.4;1" dur="3s" repeatCount="indefinite"/>`
318 break
319 case 'spin':
320 motion = `<animateTransform attributeName="transform" type="rotate" values="0 ${n(cx)} ${n(cy)};360 ${n(cx)} ${n(cy)}" dur="${slow}s" repeatCount="indefinite"/>`
321 break
322 case 'blink':
323 motion = `<animate attributeName="opacity" values="1;.25;1" dur="1s" repeatCount="indefinite"/>`
324 break
325 case 'scroll':
326 motion = `<animateTransform attributeName="transform" type="translate" values="${sw - x} 0;${-x - w} 0" dur="${n(7 + index)}s" repeatCount="indefinite"/>`
327 break
328 }
329 const tint = prop.color ?? '#d9d4c7'
330 const style: TagStyle = { fill: INK, ink: tint, stroke: tint }
331 const tag = prop.label ? label(prop.label, cx, y, style, sw) : ''
332 return `<g>${body}${tag}${motion}</g>`
333}
334
335// ---------------------------------------------------------------- hero
336
337const HERO_W = 13 * U
338const HERO_H = 9 * U
339const heroColor = (k: string) => PALETTE[k]
340
341/** The hero's drawing, where it ends up, and when it gets there (seconds). */
342type HeroPlan = { svg: string; startX: number; endX: number; endY: number; arrive: number }
343
344/** How the hero is drawn: the pixel sprite in a cell style, or the 3D model painted one way. */
345export type Figure = { kind: 'pixel'; cell: Cell } | { kind: '3d'; hero?: HeroPainter; lean?: boolean; second?: number }
346
347/** The 3D model stands a little taller than the sprite, arms reaching past its box. */
348const MODEL_STAGE_H = 40
349
350/** The pixel sprite knows fewer moves: each newer action stands in as the nearest one it has. */
351const SPRITE_ACTION: Record<FablesHeroAction, FablesHeroAction> = {
352 walk: 'walk', run: 'run', fly: 'fly', dig: 'dig', inspect: 'inspect', celebrate: 'celebrate', think: 'think',
353 carry: 'walk', sneak: 'walk', jump: 'run', tumble: 'run',
354 panic: 'inspect', sleep: 'think', dance: 'celebrate', spin: 'celebrate', wave: 'celebrate', point: 'inspect', peek: 'inspect',
355 trip: 'think', shrug: 'think',
356}
357
358/** How fast Claude crosses the stage, per travelling action (units a second). */
359const SPEED: Partial<Record<FablesHeroAction, number>> = { run: 110, fly: 80, tumble: 90, jump: 60, carry: 30, sneak: 25 }
360
361/** How high Claude's moves take it above its box, for the caption to keep clear of. */
362export const heroReach = (action: FablesHeroAction): number =>
363 action === 'celebrate' ? 16 : action === 'jump' ? 18 : action === 'dance' ? 6 : action === 'fly' || action === 'panic' ? 2 : 0
364
365/** A loop that hands over to another plays this long first (seconds). */
366const LOOP_FIRST = (motion: Motion) => Math.max(2.4, 2 * MOTION_TIMING[motion].dur)
367
368function hero(scene: FablesScene, floor: number, sw: number, figure: Figure, tint: (svg: string) => string = svg => svg): HeroPlan {
369 const cell = figure.kind === 'pixel' ? figure.cell : 'solid'
370 // The sprite plays the nearest move it has, and no second one.
371 const action = figure.kind === 'pixel' ? SPRITE_ACTION[scene.hero.action] : scene.hero.action
372 const then = figure.kind === 'pixel' ? undefined : scene.hero.then
373 const kind = MOTION_TIMING[action].kind
374 const isMoving = kind === 'travel'
375 const span = sw - HERO_W
376 const fromX = Math.round((span * scene.hero.from) / 100)
377 const toX = Math.round((span * scene.hero.to) / 100)
378 const baseY = action === 'fly' ? 34 : floor - HERO_H
379 const speed = SPEED[action] ?? 45
380 const moveDur = isMoving ? Math.max(0.6, Math.abs(toX - fromX) / speed) : 0
381 const step = action === 'run' ? 0.18 : 0.32
382 const steps = isMoving ? Math.max(2, Math.round(moveDur / step)) : 0
383 const flip = toX < fromX ? ` translate(${HERO_W} 0) scale(-1 1)` : ''
384 // When the first move gives way to the next: on arrival, after a single play, or after a loop has had its turn.
385 // Flying keeps going unless told otherwise; walking, running and the rest stand idle on arrival.
386 const second: Motion | undefined =
387 figure.kind === 'pixel' ? undefined : (then ?? (kind === 'once' || (isMoving && action !== 'fly') ? 'idle' : undefined))
388 const handover = second === undefined ? undefined : isMoving ? moveDur : kind === 'once' ? MOTION_TIMING[action].dur : LOOP_FIRST(action)
389
390 const legs = (frame: number, values: string) =>
391 `<g opacity="${frame === 0 ? 1 : 0}" transform="scale(${U})">${pixelPaths(HERO_FRAMES[frame] ?? [], heroColor, cell)}` +
392 (steps
393 ? `<animate attributeName="opacity" values="${values}" dur="${step * 2}s" calcMode="discrete" repeatCount="${steps / 2}"/>`
394 : '') +
395 `</g>`
396 const model = (motion: Parameters<typeof motionSvg>[0], when: { until?: number; from?: number; maxFrames?: number } = {}) =>
397 figure.kind === '3d'
398 ? motionSvg(motion, { height: MODEL_STAGE_H, cx: HERO_W / 2, floor: HERO_H, yaw: 0.55, hero: figure.hero, ...when }).svg
399 : ''
400 const frames =
401 figure.kind === 'pixel'
402 ? legs(0, '1;0') + legs(1, '0;1')
403 : second === undefined || handover === undefined
404 ? model(action)
405 : model(action, { until: handover }) + model(second, { from: handover, maxFrames: figure.kind === '3d' ? (figure.lean ? 2 : figure.second) : undefined })
406
407 // A move's own sway, for the moves the 3D model does not bake into its frames; it stops when the next move begins.
408 const until = handover === undefined ? 'repeatCount="indefinite"' : `repeatDur="${n(handover)}s"`
409 let inner = ''
410 const baked = figure.kind === '3d'
411 switch (baked && action !== 'fly' && action !== 'inspect' ? 'baked' : action) {
412 case 'fly':
413 inner = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${U};0 0" dur="1.2s" ${until} additive="sum"/>`
414 break
415 case 'dig':
416 inner = `<animateTransform attributeName="transform" type="rotate" values="-6 ${HERO_W / 2} ${HERO_H};6 ${HERO_W / 2} ${HERO_H};-6 ${HERO_W / 2} ${HERO_H}" dur=".4s" repeatCount="indefinite" additive="sum"/>`
417 break
418 case 'inspect':
419 inner = `<animateTransform attributeName="transform" type="translate" values="0 0;-${U * 2} 0;0 0;${U * 2} 0;0 0" dur="3s" ${until} additive="sum"/>`
420 break
421 case 'celebrate':
422 inner = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -16;0 0" keyTimes="0;.4;1" dur=".7s" repeatCount="indefinite" additive="sum"/>`
423 break
424 case 'walk':
425 case 'run':
426 inner = steps
427 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${U / 2}" dur="${step}s" calcMode="discrete" repeatCount="${steps}" additive="sum"/>`
428 : ''
429 break
430 }
431
432 // A move's flourishes; with a second move each set shows only while its own move plays.
433 const shown = (svg: string, begin?: number, end?: number) =>
434 !svg || (begin === undefined && end === undefined)
435 ? svg
436 : `<g visibility="hidden">${svg}<set attributeName="visibility" to="visible"${begin ? ` begin="${n(begin)}s"` : ''}${end !== undefined ? ` end="${n(end)}s"` : ''}/></g>`
437 const extras = shown(flourish(action, figure), handover === undefined ? undefined : 0, handover) + (second && handover !== undefined ? shown(flourish(second, figure), handover) : '')
438
439 const travel = isMoving
440 ? `<animateTransform attributeName="transform" type="translate" values="${fromX} ${baseY};${toX} ${baseY}" dur="${n(moveDur)}s" fill="freeze"/>`
441 : ''
442 const at = isMoving ? fromX : toX
443 const svg =
444 `<g transform="translate(${at} ${baseY})">${travel}` +
445 `<g>${inner}<g transform="${flip.trim() || 'translate(0 0)'}">${frames}</g>${tint(extras)}</g></g>`
446 return { svg, startX: isMoving ? fromX : toX, endX: toX, endY: baseY, arrive: isMoving ? moveDur : 0 }
447}
448
449/** The bits a move throws off: dirt from digging, confetti, the sprite's thinking dots, a sleeper's z's. */
450function flourish(action: Motion, figure: Figure): string {
451 // The pixel sprite thinks in dots; the 3D Claude's caption already sits where they would.
452 if (action === 'think' && figure.kind === 'pixel') {
453 return [0, 1, 2]
454 .map(
455 i =>
456 `<rect x="${HERO_W + 2 + i * 6}" y="-6" width="${U}" height="${U}" fill="${PAPER}"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.3;.8;1" dur="1.5s" begin="${i * 0.3}s" repeatCount="indefinite"/></rect>`,
457 )
458 .join('')
459 }
460 if (action === 'dig') {
461 return [0, 1, 2, 3]
462 .map(
463 i =>
464 `<rect x="${HERO_W / 2}" y="${HERO_H - 2}" width="${U}" height="${U}" fill="#7a4a2b"><animateTransform attributeName="transform" type="translate" values="0 0;${(i - 1.5) * 14} -18;${(i - 1.5) * 22} 4" dur=".9s" begin="${i * 0.2}s" repeatCount="indefinite"/></rect>`,
465 )
466 .join('')
467 }
468 if (action === 'celebrate') {
469 return [0, 1, 2, 3, 4]
470 .map(
471 i =>
472 `<rect x="${i * 12}" y="-4" width="3" height="3" fill="${['#e3b341', '#e05252', '#6fc2c9', '#5e9c4a', '#7b5fb5'][i]}"><animateTransform attributeName="transform" type="translate" values="0 0;${(i - 2) * 6} -20" dur="1s" begin="${i * 0.15}s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0" dur="1s" begin="${i * 0.15}s" repeatCount="indefinite"/></rect>`,
473 )
474 .join('')
475 }
476 // A sleeper's Z's: big outlined letters puffing up from over its head, growing as they drift away.
477 if (action === 'sleep') {
478 return [0, 1, 2, 3]
479 .map(
480 i =>
481 `<g transform="translate(${HERO_W * 0.62} ${HERO_H - MODEL_STAGE_H + 2})" opacity="0"><animateTransform attributeName="transform" type="translate" values="${HERO_W * 0.62} ${HERO_H - MODEL_STAGE_H + 2};${HERO_W * 0.62 + 10 + (i % 2) * 6} ${HERO_H - MODEL_STAGE_H - 30}" dur="2.8s" begin="${n(i * 0.7)}s" repeatCount="indefinite"/>` +
482 `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.15;.7;1" dur="2.8s" begin="${n(i * 0.7)}s" repeatCount="indefinite"/>` +
483 `<g><animateTransform attributeName="transform" type="scale" values=".5;1.8" dur="2.8s" begin="${n(i * 0.7)}s" repeatCount="indefinite"/>` +
484 `<text x="0" y="0" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="900" fill="#f6f1e2" stroke="#1d1a2e" stroke-width="1.4" stroke-linejoin="round" paint-order="stroke">Z</text></g></g>`,
485 )
486 .join('')
487 }
488 // Tripped: stars circling over the fallen Claude while it lies dazed.
489 if (action === 'trip') {
490 const { dur } = MOTION_TIMING.trip
491 const star = (x: number, y: number) => `<path d="M${x} ${y - 5}l1.5 3.5 3.5 1.5-3.5 1.5-1.5 3.5-1.5-3.5-3.5-1.5 3.5-1.5z"/>`
492 return (
493 `<g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0;0" keyTimes="0;.32;.36;.68;.72;1" dur="${dur}s" fill="freeze"/>` +
494 `<g transform="translate(${HERO_W / 2 + 8} ${HERO_H - MODEL_STAGE_H - 10}) scale(1 .45)"><g fill="#ffd84a" stroke="#3a2a10" stroke-width=".8" stroke-linejoin="round">` +
495 `<animateTransform attributeName="transform" type="rotate" values="0;360" dur=".9s" repeatCount="indefinite"/>` +
496 `${star(15, 0)}${star(-7.5, 13)}${star(-7.5, -13)}</g></g></g>`
497 )
498 }
499 return ''
500}
501
502// ---------------------------------------------------------------- caption
503
504type P = [number, number]
505
506/** The cartoon paper the caption is cut from: a soft drop shadow, an ink outline, then the paper. */
507export type Tone = 'work' | 'trouble' | 'milestone'
508const MONO_FONT = "Monocraft, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
509const MONO_FACE =
510 `<defs><style>@font-face{font-family:Monocraft;font-weight:400;src:url(data:font/woff2;base64,${MONOCRAFT}) format("woff2")}` +
511 `@font-face{font-family:Monocraft;font-weight:700;src:url(data:font/woff2;base64,${MONOCRAFT_BOLD}) format("woff2")}</style></defs>`
512const paper = (shape: string, skin: Paper) =>
513 `<g transform="translate(1.2 1.8)" fill="black" opacity=".22">${shape}</g>` +
514 `<g fill="${skin.ink}" stroke="${skin.ink}" stroke-width="2.2" stroke-linejoin="round">${shape}</g><g fill="${skin.card}">${shape}</g>`
515
516/** What a word of the caption is, so it can be set apart: the bubble stays the same, the words change. */
517type Kind = 'plain' | 'code' | 'path' | 'fn' | 'num' | 'bad' | 'good' | 'face'
518/** How a kind of word is set: in its color from the paper, and numbers, failures and successes in bold. */
519const kindAttrs = (kind: WordKind, skin: Paper) => `fill="${skin.kinds[kind]}"${kind === 'num' || kind === 'bad' || kind === 'good' ? ' font-weight="700"' : ''}`
520const FACE = /^(\^_\^|\^\^;?|>_<|>\.<|o_O|O_o|o\.O|:-?[)(DPpO3|/]|;-?\)|[xX]D|T_T|-_-|\._\.|\\o\/|<3|¯\\_\(ツ\)_\/¯|\(•_•\)|\(⌐■_■\)|->|=>|<-|~>|>>>|\.\.\.|\[(OK|ok|WIP|TODO|DONE)\]|\/\/|#!|\$|&&|\|\|)$/
521const BAD = /^(✗|FAIL(ED)?|failed|failing|fails|broke|broken|crash(ed|es)?|red|\w*Error|\w*Exception|N\+1|panic|segfault|404|500)$/
522const GOOD = /^(✓|PASS(ED)?|passed|passes|passing|green|clean|fixed|ships?|shipped|done|OK|merged|liftoff|LGTM)$/i
523const NUM = /^[~+\-]?\d[\d.,]*(%|x|×|s|ms|kb|mb|gb|k)?$|^\d+\/\d+$/i
524const FN = /^[\w.$#]+\(\)$/
525const PATH = /^[\w@~./-]*\w\.(tsx?|jsx?|mjs|cjs|py|rb|go|rs|java|kt|swift|json|ya?ml|toml|css|scss|html|md|sql|sh|lock|env|test\.ts)$|^~?\.?\/?[\w@.-]+\/[\w@./-]*$/i
526const CMD = /^(npm|npx|pnpm|yarn|bun|git|pytest|tsc|eslint|cargo|go|make|pip|docker|curl|gh)$|^--?\w/
527
528type Word = { lead: string; core: string; tail: string; kind: Kind }
529
530/** The caption as words with their kinds. `backticks` mark code, and are not shown. */
531function words(text: string, tone: Tone | undefined): Word[] {
532 const out: Word[] = []
533 // The tone leads the caption as a mark, unless the narrator already wrote one.
534 const mark = tone === 'trouble' ? '✗' : tone === 'milestone' ? '✓' : ''
535 if (mark && !text.trimStart().startsWith(mark)) out.push({ lead: '', core: mark, tail: '', kind: tone === 'trouble' ? 'bad' : 'good' })
536 let inCode = false
537 for (const raw of text.split(' ').filter(Boolean)) {
538 const opens = raw.startsWith('`')
539 const ticks = (raw.match(/`/g) ?? []).length
540 const code = inCode || opens
541 if (ticks % 2 === 1) inCode = !inCode
542 const bare = raw.replace(/`/g, '')
543 if (!bare) continue
544 if (FACE.test(bare)) {
545 out.push({ lead: '', core: bare, tail: '', kind: code ? 'code' : 'face' })
546 continue
547 }
548 // A call keeps its own brackets; only what follows them is punctuation.
549 const call = /^(.*\(\))([.,;:!?]*)$/.exec(bare)
550 const m = call && FN.test(call[1] ?? '') ? ['', '', call[1], call[2]] : /^([("'[]*)(.*?)([.,;:!?)"'\]]*)$/.exec(bare)
551 const [lead, core, tail] = m ? [m[1] ?? '', m[2] ?? '', m[3] ?? ''] : ['', bare, '']
552 const kind: Kind = !core
553 ? 'plain'
554 : code || CMD.test(core)
555 ? 'code'
556 : BAD.test(core)
557 ? 'bad'
558 : GOOD.test(core)
559 ? 'good'
560 : FN.test(core)
561 ? 'fn'
562 : PATH.test(core)
563 ? 'path'
564 : NUM.test(core)
565 ? 'num'
566 : 'plain'
567 out.push({ lead, core, tail, kind })
568 }
569 return out
570}
571/** Columns a string takes in a monospace font: East Asian wide characters take two. */
572const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/g
573const cols = (t: string) => t.length + (t.match(WIDE)?.length ?? 0)
574const wordLen = (w: Word) => cols(w.lead) + cols(w.core) + cols(w.tail)
575
576/** The most lines a bubble takes, so it stays clear of the ground. */
577const MAX_LINES = 4
578
579/** Greedy wrap of words into lines of at most `width` characters; a word longer than the line is cut. */
580function wrapWords(list: readonly Word[], width: number): Word[][] {
581 const lines: Word[][] = []
582 let line: Word[] = []
583 let len = 0
584 for (const w0 of list) {
585 const w = wordLen(w0) > width ? { ...w0, core: w0.core.slice(0, Math.max(1, width - w0.lead.length - w0.tail.length)) } : w0
586 if (line.length && len + 1 + wordLen(w) > width) {
587 lines.push(line)
588 line = []
589 len = 0
590 }
591 len += (line.length ? 1 : 0) + wordLen(w)
592 line.push(w)
593 }
594 if (line.length) lines.push(line)
595 return lines
596}
597const lineLen = (line: readonly Word[]) => line.reduce((t, w) => t + wordLen(w), 0) + line.length - 1
598const lineSvg = (line: readonly Word[], skin: Paper) =>
599 line.map(w => escapeXml(w.lead) + (w.kind === 'plain' ? escapeXml(w.core) : `<tspan ${kindAttrs(w.kind, skin)}>${escapeXml(w.core)}</tspan>`) + escapeXml(w.tail)).join(' ')
600
601const overlap = (a: Rect, b: Rect) =>
602 Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y))
603
604/** Where a prop and its label sit while it stays put; a scrolling prop crosses the whole stage and blocks nothing. */
605function propRects(prop: FablesProp, floor: number, sw: number, unit: number): Rect[] {
606 if (prop.motion === 'scroll') return []
607 const art = artFor(prop)
608 const w = widthOf(art.rows) * unit
609 const h = art.rows.length * unit
610 const x = Math.round(((sw - w) * prop.x) / 100)
611 const y = prop.y === 'ground' ? floor - h : prop.y === 'air' ? 58 - h / 2 : 8
612 const reach = prop.motion === 'drift' ? 24 : prop.motion === 'bob' ? 0 : 2
613 const rects: Rect[] = [{ x: x - 2, y: y - 4, w: w + reach + 4, h: h + 6 }]
614 if (prop.label) {
615 const lw = prop.label.length * 5 + 8
616 rects.push({ x: Math.min(sw - lw - 2, Math.max(2, x + w / 2 - lw / 2)), y: Math.max(2, y - 13), w: lw, h: 11 })
617 }
618 return rects
619}
620
621/**
622 * The speech bubble. It stays with Claude: of the places just beside and just
623 * above Claude it takes the one that covers the least of Claude, the props,
624 * the chapter tag and the scene's focal points, and while Claude walks it
625 * travels along. On the rich stage it is cut from cartoon paper with a tail
626 * pointing back at Claude, and kinds of words (files, functions, commands,
627 * numbers, failures, successes, ASCII faces) are set apart in the text.
628 */
629function caption(
630 text: string,
631 hero: { startX: number; x: number; y: number; arrive: number; jump: number; sway: number },
632 idPrefix: string,
633 sw: number,
634 look: Look,
635 avoid: readonly Rect[],
636 tone?: Tone,
637 /** When the caption starts typing, in seconds: later when the scene fades in. */
638 start = 0.2,
639): string {
640 const { fill, stroke, radius } = look.caption
641 const onPaper = tone !== undefined
642 const skin = look.paper ?? CARD_PAPER
643 const ink = onPaper ? skin.ink : look.caption.ink
644 const heroX = hero.x
645 const heroY = hero.y
646 // Claude's box, reaching as high as Claude jumps.
647 const heroBox: Rect = { x: heroX - 4 - hero.sway, y: heroY - 8 - hero.jump, w: HERO_W + 8 + hero.sway * 2, h: HERO_H + 12 + hero.jump }
648 const top = heroY + HERO_H - MODEL_STAGE_H - hero.jump
649 const list = onPaper ? words(look.bubble?.upper ? text.toUpperCase() : text, tone) : text.split(' ').filter(Boolean).map((core): Word => ({ lead: '', core, tail: '', kind: 'plain' }))
650 // On paper the caption is set in Monocraft, whose pixel is a ninth of its size: at 9 units
651 // its pixels land on whole device pixels in the band. Elsewhere the look's own type.
652 const type = onPaper ? (look.bubble ?? { font: MONO_FONT, size: 9, charW: 6, line: 12, base: 13 }) : { font: look.font, size: 9.5, charW: look.charW, line: 11, base: 12 }
653 // Claude's path while it walks: the bubble must stay clear of Claude at every point of it.
654 const KEYS = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]
655 const heroAt = (k: number) => hero.startX + (heroX - hero.startX) * k
656 const boxAt = (k: number): Rect => ({ ...heroBox, x: heroAt(k) - 4 - hero.sway })
657 const clampX = (v: number, w: number) => Math.min(sw - w - 2, Math.max(2, v))
658 // How far the stage's edge holds the bubble back from Claude at a point of the path: while it
659 // does, the bubble stands still as Claude walks on, and its tail no longer points at Claude.
660 const slipAt = (k: number, ox: number, w: number) => Math.abs(clampX(heroAt(k) + ox, w) - (heroAt(k) + ox))
661 // The point of the walk from which the bubble travels with Claude to the end; before it the
662 // bubble waits, unseen, so it never stands about with its tail pointing away from Claude.
663 const FINE = Array.from({ length: 101 }, (_, i) => i / 100)
664 const joinsAt = (ox: number, w: number) => {
665 if (!hero.arrive) return 0
666 let k = 1
667 while (k > 0 && slipAt(Math.round((k - 0.01) * 100) / 100, ox, w) < 1) k = Math.round((k - 0.01) * 100) / 100
668 return FINE.includes(k) && slipAt(k, ox, w) < 1 ? k : 1
669 }
670 // The bubble's offsets that keep it on stage the whole way, so it travels with Claude as one.
671 const lo = 2 - Math.min(hero.startX, heroX)
672 const hi = (w: number) => sw - w - 2 - Math.max(hero.startX, heroX)
673 const isRightward = heroX > hero.startX
674 // A narrower wrap, taller, is tried when the wide one finds no clear place; one that
675 // would need more than four lines is not, as every word of the caption is shown.
676 let best = { x: 2, y: 4, ox: 0, score: Infinity, lines: [] as Word[][], w: 0, h: 0 }
677 const wraps = [32, 25, 19, 15].map(width => wrapWords(list, width))
678 const fits = wraps.filter(lines => lines.length <= MAX_LINES)
679 for (const [wi, lines] of (fits.length ? fits : wraps.slice(0, 1)).entries()) {
680 const w = Math.ceil(Math.max(...lines.map(lineLen)) * type.charW + 14)
681 const h = lines.length * type.line + 7
682 const beside = Math.min(GROUND_Y - h - 4, Math.max(4, top - 4))
683 const above = top - h - 8
684 const below = heroY + HERO_H + 9
685 // Where the bubble sits relative to Claude: beside it at head height, or above it.
686 const spots: P[] = [
687 [HERO_W + 8, beside],
688 [-w - 8, beside],
689 [HERO_W / 2 - w / 2, above],
690 [HERO_W - 10, above],
691 [-w + 10, above],
692 // Under Claude, for when it flies and there is no room above it.
693 [HERO_W / 2 - w / 2, below],
694 ]
695 for (const [si, [ox0, y0]] of spots.entries()) {
696 const y = y0 === below ? Math.min(H - h - 3, y0) : Math.min(GROUND_Y - h - 4, Math.max(4, y0))
697 // Over or under Claude the bubble may sit anywhere along it, its tail still on Claude:
698 // slid along, it can often travel the whole walk without meeting an edge.
699 // When no offset keeps it on stage the whole way, it keeps clear of the edge Claude walks
700 // towards, so the edge can only hold it back at the start, and it joins Claude early.
701 const isOver = si >= 2
702 const [a, b] = [Math.max(lo, -w + 14), Math.min(hi(w), HERO_W - 14)]
703 const along = Math.min(HERO_W - 14, Math.max(-w + 14, ox0))
704 const ox = !isOver ? ox0 : a <= b ? Math.min(b, Math.max(a, ox0)) : isRightward ? Math.max(-w + 14, Math.min(along, hi(w))) : Math.min(HERO_W - 14, Math.max(along, lo))
705 const x = clampX(heroX + ox, w)
706 // Covering Claude at any moment is ruled out in all but name; being held back by an edge
707 // while Claude walks on nearly so; the rest is preference.
708 const covers = KEYS.reduce((t, k) => t + overlap({ x: clampX(heroAt(k) + ox, w), y, w, h }, boxAt(k)), 0)
709 // Every second the bubble would wait for Claude counts against it.
710 const waits = joinsAt(ox, w) * hero.arrive
711 const score = covers * 50 + waits * 3000 + avoid.reduce((t, a) => t + overlap({ x, y, w, h }, a) * 3, 0) + si * 6 + wi * 40
712 if (score < best.score) best = { x, y, ox, score, lines, w, h }
713 }
714 }
715 const { lines, w, h, x, y, ox } = best
716 // A walk too long for any bubble to travel with Claude the whole way: the bubble waits until
717 // Claude is far enough in, then appears beside it and travels on with it, tail on Claude.
718 const joins = joinsAt(ox, w)
719 const appear = joins * hero.arrive
720 let delay = start + appear
721 const rows = lines
722 .map((line, i) => {
723 const dur = Math.max(0.2, lineLen(line) * TYPE_SECONDS_PER_CHAR)
724 const id = `${idPrefix}${i}`
725 const reveal =
726 `<clipPath id="${id}"><rect x="${n(x)}" y="${n(y + 2 + i * type.line)}" width="0" height="${type.line + 2}">` +
727 `<animate attributeName="width" from="0" to="${w}" dur="${n(dur)}s" begin="${n(delay)}s" fill="freeze"/></rect></clipPath>`
728 delay += dur
729 return reveal + `<text clip-path="url(#${id})" x="${n(x + 7)}" y="${n(y + type.base + i * type.line)}" font-family="${type.font}" font-size="${type.size}" fill="${ink}">${lineSvg(line, skin)}</text>`
730 })
731 .join('')
732 // While Claude walks, the bubble walks with it along the same path, held inside the stage;
733 // from the moment it joins Claude it moves as one with it, so that moment is a key of its own.
734 const follows = [...new Set([0, joins, ...KEYS.filter(k => k > joins)])].sort((p, q) => p - q)
735 const follow =
736 hero.arrive && hero.startX !== heroX
737 ? ` transform="translate(${n(clampX(heroAt(0) + ox, w) - x)} 0)"><animateTransform attributeName="transform" type="translate" values="${follows.map(k => `${n(clampX(heroAt(k) + ox, w) - x)} 0`).join(';')}" keyTimes="${follows.join(';')}" dur="${n(hero.arrive)}s" fill="freeze"/`
738 : ''
739 const fade = `<animate attributeName="opacity" values="0;1" dur=".2s"${appear ? ` begin="${n(appear)}s"` : ''} fill="freeze"/>`
740 // The wait is written on the bubble, so the narrator's loop can give the caption its full reading time after it.
741 const hidden = appear ? ` opacity="0" data-speaks="${n(appear)}"` : ''
742 if (onPaper) {
743 const hx = heroX + HERO_W / 2
744 const hy = top + 10
745 let base: [P, P]
746 if (x >= hx) base = [[x + 2, y + h - 13], [x + 2, y + h - 5]]
747 else if (x + w <= hx) base = [[x + w - 2, y + h - 13], [x + w - 2, y + h - 5]]
748 else {
749 const cx = Math.min(x + w - 12, Math.max(x + 12, hx))
750 base = y + h < hy ? [[cx - 4, y + h - 2], [cx + 4, y + h - 2]] : [[cx - 4, y + 2], [cx + 4, y + 2]]
751 }
752 const mx = (base[0][0] + base[1][0]) / 2
753 const my = (base[0][1] + base[1][1]) / 2
754 const len = Math.hypot(hx - mx, hy - my) || 1
755 const reach = Math.min(10, len * 0.6)
756 const tip: P = [mx + ((hx - mx) / len) * reach, my + ((hy - my) / len) * reach]
757 if (look.bubble) return `<g data-part="speech" data-tone="${tone}"${hidden}${follow || ''}>${look.bubble.font.includes('Monocraft') ? MONO_FACE : ''}${look.bubble.draw({ x, y, w, h }, { base, tip })}${rows}${fade}</g>`
758 const shape =
759 `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="6"/>` +
760 `<path d="M${n(base[0][0])} ${n(base[0][1])}L${n(tip[0])} ${n(tip[1])}L${n(base[1][0])} ${n(base[1][1])}z"/>`
761 return `<g data-part="speech" data-tone="${tone}"${hidden}${follow || ''}>${MONO_FACE}${paper(shape, skin)}${rows}${fade}</g>`
762 }
763 return `<g data-part="speech"${hidden}${follow || ''}><rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="${radius}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>${rows}${fade}</g>`
764}
765
766function title(text: string, color: string, look: Look): string {
767 const w = text.length * (look.charW - 0.3) + 16
768 const inset = look.inset ?? 0
769 return (
770 `<g font-family="${look.font}" font-size="9" fill="${color}" transform="translate(${inset} ${inset})">` +
771 `<path d="M6 12V6h6M${n(6 + w)} 12V6h-6" fill="none" stroke="${color}" stroke-width="1"/>` +
772 `<text x="14" y="10">${escapeXml(text)}</text></g>`
773 )
774}
775
776/** The chapter tag as the look draws it, or the plain tag with the room it takes. */
777function chapterTag(text: string, color: string, look: Look, rich: boolean): Tag {
778 const inset = look.inset ?? 0
779 if (look.tag) return look.tag(text, inset)
780 const shown = rich ? { ...look, font: MONO_FONT, charW: 6.3 } : look
781 return { svg: title(text, color, shown), w: text.length * look.charW + 34 + inset, h: 16 + inset }
782}
783
784// ---------------------------------------------------------------- the scene
785
786/** The stage width for a box of the given shape, so the scene fills it exactly. */
787export function stageWidth(width: number, height: number): number {
788 const fit = Number.isFinite(width / height) && width > 0 && height > 0 ? (width * H) / height : W
789 return Math.round(Math.min(MAX_W, Math.max(MIN_W, fit)))
790}
791
792/** The pixel-art Claude's eye and outline colors. */
793const SPRITE_EYE = '#1f1412'
794const SPRITE_OUTLINE = '#3a1e14'
795/** How many stage units one pixel of the pixelized stage takes. */
796const PIXEL = 2
797/**
798 * Turns everything drawn into pixel art, the scenery and Claude alike, without
799 * redrawing any of it: one sample is taken at the middle of every PIXEL square
800 * (a tiny dot of a tiled grid, cut out of the drawing) and spread over its whole
801 * square. The grid starts at the stage's corner, so pixels line up with it.
802 */
803const PIXELIZE = (sw: number, claude: Rect, grade = '', sprite = { eye: SPRITE_EYE, outline: SPRITE_OUTLINE }) => {
804 const dot = 0.4
805 const r = n((PIXEL - dot) / 2)
806 // The grid: one dot tiled every PIXEL, seeded inside the filter's region on a whole pixel
807 // from the stage's corner, so every filter here samples the same squares.
808 const grid = (at: Rect) => {
809 const gx = Math.ceil(at.x / PIXEL) * PIXEL
810 const gy = Math.ceil(at.y / PIXEL) * PIXEL
811 return (
812 `<feFlood x="${n(gx + (PIXEL - dot) / 2)}" y="${n(gy + (PIXEL - dot) / 2)}" width="${dot}" height="${dot}" flood-color="black"/>` +
813 `<feComposite x="${n(gx)}" y="${n(gy)}" width="${PIXEL}" height="${PIXEL}"/><feTile result="grid"/>`
814 )
815 }
816 // A sample at the middle of each square, spread over the square.
817 const sample = (from: string, result: string) =>
818 `<feComposite in="${from}" in2="grid" operator="in"/><feMorphology operator="dilate" radius="${r}" result="${result}"/>`
819 const region = (at: Rect) => `filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="${n(at.x)}" y="${n(at.y)}" width="${n(at.w)}" height="${n(at.h)}"`
820 const stage = { x: 0, y: 0, w: sw, h: H }
821 return (
822 // Claude, on the same grid, as a sprite: its solid body sampled crisp; its dark features
823 // (eyes, finer than a pixel) found by their darkness, thickened just enough to land on
824 // whole pixels and set solid; a one-pixel dark outline round the body. The translucent
825 // ground shadow is sampled too, under it all, but kept out of the outline and the eyes.
826 `<filter id="sc-pixelize-claude" ${region(claude)} color-interpolation-filters="sRGB">` +
827 grid(claude) +
828 `<feComponentTransfer in="SourceGraphic" result="solid"><feFuncA type="discrete" tableValues="0 1"/></feComponentTransfer>` +
829 sample('SourceGraphic', 'all') +
830 sample('solid', 'sampled') +
831 `<feComponentTransfer in="sampled" result="body"><feFuncA type="discrete" tableValues="0 1"/></feComponentTransfer>` +
832 `<feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -9 -17.7 -3.3 0 3.6"/>` +
833 `<feComposite in2="solid" operator="in"/><feMorphology operator="dilate" radius=".7" result="dark"/>` +
834 sample('dark', 'darkpx') +
835 `<feComponentTransfer in="darkpx"><feFuncA type="discrete" tableValues="0 1"/></feComponentTransfer>` +
836 `<feComposite in2="body" operator="in" result="eyemask"/>` +
837 `<feFlood flood-color="${sprite.eye}"/><feComposite in2="eyemask" operator="in" result="eyes"/>` +
838 `<feMorphology in="body" operator="dilate" radius="${PIXEL}"/><feComposite in2="body" operator="out" result="ring"/>` +
839 `<feFlood flood-color="${sprite.outline}"/><feComposite in2="ring" operator="in" result="outline"/>` +
840 `<feMerge><feMergeNode in="all"/><feMergeNode in="outline"/><feMergeNode in="body"/><feMergeNode in="eyes"/></feMerge></filter>` +
841 // The rest sampled crisp, at the middle of each square, so colors stay as rich as drawn. A look's
842 // grade runs first in the same filter: one pass over the stage costs far less than two nested.
843 `<filter id="sc-pixelize" ${region(stage)} color-interpolation-filters="sRGB">` +
844 (grade ? `${grade}<feMerge result="graded"><feMergeNode/></feMerge>` : '') +
845 `${grid(stage)}${sample(grade ? 'graded' : 'SourceGraphic', 'px')}</filter>`
846 )
847}
848
849/**
850 * Compiles a validated scene into one self-animating SVG document (SMIL), so
851 * the desktop plays it with no redraws. The stage takes the box's shape
852 * (`width` by `height` CSS pixels; absent, the default stage at its own size).
853 * `look` names the graphic style (looks.ts); absent, plain pixel art.
854 * Stays under the Svg element's limit by shedding particles, then props, if a
855 * scene is too rich.
856 */
857export function sceneToSvg(
858 scene: FablesScene,
859 options: {
860 width?: number
861 height?: number
862 look?: string
863 figure?: 'auto' | 'pixel' | '3d'
864 } = {},
865): string {
866 const sw = options.width && options.height ? stageWidth(options.width, options.height) : W
867 const width = options.width ?? sw
868 const height = options.height ?? Math.round((width * H) / sw)
869 const look = lookFor(options.look)
870 const rand = rng(`${scene.backdrop}|${scene.caption}`)
871 const wants = options.figure && options.figure !== 'auto' ? options.figure : look.figure
872 // The 3D Claude gets scenery and props with depth to match; the pixel sprite keeps the flat pixel stage.
873 const rich = wants === '3d'
874 // The pixel-art look (the default) pixelizes the whole stage crisply; drawn smooth, the scenery takes a soft blur instead.
875 const pixelArt = rich && look.pixel === true
876 // A style drawn as an artwork repaints every element of the scenery, and Claude, in its own medium.
877 const art = rich ? look.art : undefined
878 const painter = art?.painter(sw)
879 const figure: Figure = rich ? { kind: '3d', hero: art?.hero() } : { kind: 'pixel', cell: look.cell }
880 // Smooth, the scenery is softened so Claude reads first; a style brings its own treatment instead,
881 // and softening under a grade would be redone on every frame.
882 const stageAt = (lean: boolean) =>
883 rich ? richBackdrop(scene, rng(`${scene.backdrop}|${scene.caption}|stage`), sw, GROUND_Y, W, lean, !pixelArt && !look.grade && !art, painter) : flatStage(backdrop(scene, rand, sw))
884 let stage = stageAt(false)
885 const dust = painter ? painter.el('particles', particles(scene, rand, sw)) : particles(scene, rand, sw)
886 // The rich stage tells the story in words alone: no props stand about the scene.
887 const props = rich ? [] : scene.props.map((p, i) => propSvg(p, stage.floor, i, sw, look.cell))
888 // A style paints Claude's confetti and dug-up earth in its own inks too.
889 const tint = painter ? (svg: string) => painter.el('particles', svg) : undefined
890 let plan = hero(scene, stage.floor, sw, figure, tint)
891 const accent = scene.palette.accent ?? '#d9d4c7'
892 const idPrefix = `fable${Math.floor(rand() * 2 ** 31).toString(36)}-`
893 const entrance = scene.enter === 'fade' ? ENTRANCE_SECONDS : 0
894 const sky = art?.sky ?? stage.sky
895 const ground = art?.ground ?? stage.ground
896 // A look's grade takes the stage, Claude and all, before any pixelizing; past the stage's edges its own color shows.
897 const gradeBody = look.grade?.(sw, H, pixelArt) ?? ''
898 // Smooth, the grade is a filter of its own; on a pixel-art stage it runs inside the pixelizer.
899 const grade = gradeBody && !pixelArt ? `<defs>${gradeFilter('lk-grade', sw, H, gradeBody)}</defs>` : ''
900 const edge = look.edge ?? sky
901 const tag = scene.title ? chapterTag(scene.title, look.titleColor ?? (rich ? '#efe6d2' : accent), look, rich) : undefined
902 // What the speech bubble keeps clear of: the props still drawn, their labels, the chapter tag.
903 const avoid = (propCount: number): Rect[] => [
904 ...scene.props.slice(0, propCount).flatMap(p => propRects(p, stage.floor, sw, U)),
905 ...(tag ? [{ x: 0, y: 0, w: tag.w, h: tag.h }] : []),
906 ...stage.keep,
907 ]
908 // Everywhere Claude goes, jumps and digs included: the region its own pixelizing covers.
909 const claudeBox: Rect = { x: Math.min(plan.startX, plan.endX) - 30, y: plan.endY - 40, w: Math.abs(plan.endX - plan.startX) + HERO_W + 60, h: HERO_H + 56 }
910 const fore = (withDust: boolean, propCount: number) =>
911 (withDust ? dust : '') + props.slice(0, propCount).join('') + (pixelArt ? `<g filter="url(#sc-pixelize-claude)">${plan.svg}</g>` : plan.svg)
912
913 // Sky and ground run far past the stage, so a box the stage could not match
914 // (past MIN_W or MAX_W) shows more sky and ground instead of the frame's page.
915 const build = (withDust: boolean, propCount: number) =>
916 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${sw} ${H}" width="${width}" height="${height}" ` +
917 `shape-rendering="crispEdges" preserveAspectRatio="xMidYMid meet" style="display:block;background:${look.grade ? edge : ground}"${look.grade || art ? ` data-look="${look.name}"` : ''}>` +
918 grade +
919 `<rect x="${-sw * 4}" y="${-H * 4}" width="${sw * 9}" height="${H * 4 + GROUND_Y}" fill="${look.grade ? edge : sky}"/>` +
920 (art?.defs ? `<defs>${art.defs(sw, H)}</defs>` : '') +
921 (pixelArt ? `<defs>${PIXELIZE(sw, claudeBox, gradeBody)}</defs><g filter="url(#sc-pixelize)">` : '') +
922 (grade ? `<g filter="url(#lk-grade)">` : '') +
923 // The graded stage carries its own sky, so the grade sees the whole picture.
924 (look.grade ? `<rect width="${sw}" height="${H}" fill="${sky}"/>` : '') +
925 (art?.under?.(sw, H) ?? '') +
926 stage.back +
927 `<rect x="${-sw * 4}" y="${stage.groundTop}" width="${sw * 9}" height="${H * 4}" fill="${ground}"/>` +
928 // Ground-level scenery goes down before anything that stands on it.
929 stage.near +
930 fore(withDust, propCount) +
931 stage.lens +
932 (grade ? '</g>' : '') +
933 (pixelArt ? '</g>' : '') +
934 (look.texture?.(sw, H, GROUND_Y, pixelArt) ?? '') +
935 (look.frame?.(sw, H, GROUND_Y) ?? '') +
936 (tag?.svg ?? '') +
937 caption(scene.caption, { startX: plan.startX, x: plan.endX, y: plan.endY, arrive: plan.arrive, jump: Math.max(heroReach(scene.hero.action), scene.hero.then ? heroReach(scene.hero.then) : 0), sway: scene.hero.action === 'inspect' ? 2 * U : 0 }, idPrefix, sw, look, avoid(propCount), rich ? (scene.tone ?? (scene.hero.action === 'celebrate' ? 'milestone' : 'work')) : undefined, entrance + 0.2) +
938 // A new setting fades in from the dark, so the band never jumps from one place to the next.
939 (entrance ? `<rect x="${-sw * 4}" y="${-H * 4}" width="${sw * 9}" height="${H * 9}" fill="#0b0b10" pointer-events="none"><animate attributeName="opacity" values="1;0" dur="${n(entrance)}s" fill="freeze"/></rect>` : '') +
940 `</svg>`
941
942 const propsShown = props.length
943 let svg = build(true, propsShown)
944 // Over the limit, Claude's second move is baked in fewer poses first; then the scenery
945 // thins out, then the particles go, and only then the props.
946 if (svg.length > BUDGET && figure.kind === '3d') {
947 plan = hero(scene, stage.floor, sw, { ...figure, second: 6 }, tint)
948 svg = build(true, propsShown)
949 }
950 if (svg.length > BUDGET && rich) {
951 stage = stageAt(true)
952 svg = build(true, propsShown)
953 }
954 if (svg.length > BUDGET) svg = build(false, propsShown)
955 // Then Claude, once he has arrived, stands breathing in two poses instead of a full idle loop.
956 if (svg.length > BUDGET && figure.kind === '3d') {
957 plan = hero(scene, stage.floor, sw, { ...figure, lean: true }, tint)
958 svg = build(false, propsShown)
959 }
960 // Still over (a look whose colors spell long), the rich stage gives way to the flat one
961 // before any prop goes; the flat stage always fits.
962 if (svg.length > BUDGET && rich) {
963 stage = flatStage(backdrop(scene, rand, sw))
964 svg = build(false, propsShown)
965 }
966 for (let count = propsShown - 1; svg.length > BUDGET && count >= 0; count--) svg = build(false, count)
967 return svg
968}
969
970/**
971 * The same drawing, its clock already `seconds` along: every animation begins
972 * that much earlier, so a scene drawn again while it is up (the band redrawn
973 * when the turn ends, a new width, a new style) carries on from where it was:
974 * the caption as far typed as it was, Claude as far along. Every begin in a
975 * scene is a plain offset in seconds, and an animation without one begins at 0.
976 *
977 * An animation whose whole run would fall before the drawing starts is never
978 * played at all, its frozen last frame included (SMIL drops an interval that
979 * ends before its parent begins), so the caption would vanish: one that holds
980 * its last frame is moved back only so far that it ends just after the start.
981 */
982export function resumeAt(svg: string, seconds: number): string {
983 if (!(seconds > 0)) return svg
984 const secs = (attrs: string, name: 'dur' | 'repeatDur') => {
985 const m = (name === 'dur' ? /\sdur="([\d.]+)s"/ : /\srepeatDur="([\d.]+)s"/).exec(attrs)
986 return m ? Number(m[1]) : undefined
987 }
988 return svg.replace(/<(animate|animateTransform|animateMotion|set)\b([^>]*?)(\/?)>/g, (_, tag: string, attrs: string, close: string) => {
989 const at = /\sbegin="(-?[\d.]+)s"/.exec(attrs)
990 let begin = (at ? Number(at[1]) : 0) - seconds
991 if (/\sfill="freeze"/.test(attrs)) {
992 const count = /\srepeatCount="([\d.]+)"/.exec(attrs)
993 const run = secs(attrs, 'repeatDur') ?? (secs(attrs, 'dur') ?? 0) * (count ? Number(count[1]) : 1)
994 begin = Math.max(begin, 0.01 - run)
995 }
996 const moved = at ? attrs.replace(at[0], ` begin="${n(begin)}s"`) : `${attrs} begin="${n(begin)}s"`
997 return `<${tag}${moved}${close}>`
998 })
999}
1000
1001/** How long a drawn scene's bubble waits for Claude before it appears, in seconds (0 when it appears at once). */
1002export const speaksAfter = (svg: string) => Number(/data-part="speech"[^>]*\sdata-speaks="([\d.]+)"/.exec(svg)?.[1] ?? 0)
1003hooks/activity.ts 60 lines1/** What the main agent did, boiled down to one line the narrator can read. */
2export type Activity = { kind: 'tool' | 'failed' | 'said'; text: string }
3
4export const MAX_LOG = 14
5const MAX_LINE = 160
6
7const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
8
9const cut = (text: string, max = MAX_LINE) => {
10 const flat = text.replace(/\s+/g, ' ').trim()
11 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
12}
13
14const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
15
16/** One readable line per tool call: the tool and the part of its input that tells the story. */
17export function summarizeTool(tool: string, input: Readonly<Record<string, unknown>>): string {
18 const path = str(input.file_path) ?? str(input.notebook_path) ?? str(input.path)
19 switch (tool) {
20 case 'Bash':
21 return `ran shell: ${cut(str(input.description) ?? str(input.command) ?? '', 100)}`
22 case 'Read':
23 return `read ${path ? basename(path) : 'a file'}`
24 case 'Edit':
25 case 'MultiEdit':
26 case 'NotebookEdit':
27 return `edited ${path ? basename(path) : 'a file'}`
28 case 'Write':
29 return `wrote ${path ? basename(path) : 'a file'}`
30 case 'Grep':
31 return `searched code for "${cut(str(input.pattern) ?? '', 60)}"`
32 case 'Glob':
33 return `looked for files matching ${cut(str(input.pattern) ?? '', 60)}`
34 case 'WebFetch':
35 return `fetched ${cut(str(input.url) ?? 'a web page', 80)}`
36 case 'WebSearch':
37 return `searched the web for "${cut(str(input.query) ?? '', 60)}"`
38 case 'Agent':
39 case 'Task':
40 return `sent a helper agent to: ${cut(str(input.description) ?? str(input.prompt) ?? '', 80)}`
41 case 'TodoWrite':
42 case 'TaskCreate':
43 case 'TaskUpdate':
44 return 'updated the to-do list'
45 default:
46 return path ? `used ${tool} on ${basename(path)}` : `used ${tool}`
47 }
48}
49
50/** The assistant's own words between tool calls are the best story material. */
51export function summarizeSpeech(text: string): string {
52 return cut(text, 220)
53}
54
55export function pushActivity(log: Activity[], entry: Activity): Activity[] {
56 const last = log[log.length - 1]
57 if (last && last.kind === entry.kind && last.text === entry.text) return log
58 return [...log, { kind: entry.kind, text: cut(entry.text, 240) }].slice(-MAX_LOG)
59}
60hooks/narrator.ts 96 lines1import type { FablesScene } from '../types'
2
3import type { Activity } from './activity'
4import { CAPTION_BUDGET, ENTRANCE_SECONDS, extractJson, parseScene, TYPE_SECONDS_PER_CHAR } from './scene'
5
6/** Remembered between scenes so the story stays continuous; capped so it never grows. */
7export type StoryBeat = { backdrop: string; caption: string }
8export const MAX_STORY = 4
9
10export const MIN_GAP_MS = 5000
11export const MAX_BACKOFF_MS = 60000
12
13/** How long the bubble takes to type a caption out. */
14export const typeMs = (caption: string) => Math.round(200 + caption.length * TYPE_SECONDS_PER_CHAR * 1000)
15/** However short the caption, it stays up this long once typed. */
16export const GLANCE_MS = 2000
17/** How long a scene stays up to be read through: faded in, typed out, then read at a calm pace. */
18export const readMs = (scene: Pick<FablesScene, 'caption' | 'enter'>) =>
19 (scene.enter ? ENTRANCE_SECONDS * 1000 : 0) + typeMs(scene.caption) + GLANCE_MS + scene.caption.length * 45
20
21export const SYSTEM = `You are the narrator of "Claude Fables": tiny animated pixel-art cartoons that play while an AI coding agent works.
22The hero is always a small orange critter (the agent). You turn what it is doing right now into a whimsical visual metaphor:
23hunting a bug is a nature documentary, a failing test is a storm, editing many files is a train of cargo cars, a search is a moonwalk across a crater field, a fix landing is a rocket launch.
24Keep continuity with the story so far, but change scenery when the work changes.
25
26Reply with ONE JSON object and nothing else, in this shape:
27{
28 "backdrop": one of "forest" | "space" | "city" | "desert" | "volcano" | "lab" | "night",
29 "hero": { "action": one of the actions below, "from": 0-100, "to": 0-100, "then"?: an action to do next, where it stands },
30 "particles"?: { "kind": "stars" | "rain" | "bubbles" | "sparks" | "snow" | "leaves", "density": 0-1 },
31 "caption": what the hero says: witty, a bit nerdy, specific to the real work, at most ${CAPTION_BUDGET} characters (count them: a longer one is cut),
32 "tone": "work" (the default) | "trouble" (something just failed) | "milestone" (tests pass, a fix lands, the task is done),
33 "title"?: a 1-3 word chapter tag
34}
35
36Actions. Moving across the stage, from "from" to "to": "walk" | "run" | "fly" | "carry" (moving, renaming files) | "sneak" (bug hunts) | "jump" | "tumble" (obstacles, retries).
37In place: "dig" (searching) | "inspect" (reading code, editing) | "think" | "point" (found it) | "peek" (bug hunts) | "spin" (refactors) | "wave" (a first scene) | "sleep" (long waits) | "panic" (errors) | "dance" | "celebrate" (milestones).
38Played once: "trip" (a test fails) | "shrug" (nothing found).
39"then" chains a second action after the first: walk then inspect, trip then shrug, jump then celebrate. Use it when the work has two beats.
40
41Rules: use real names from the activity (files, functions, tests, commands) in the caption, and wrap code and commands in \`backticks\`.
42Talk like a developer: ASCII faces and symbols are welcome, sparingly: ^_^ >_< o_O :) \\o/ ¯\\_(ツ)_/¯ <3 -> => [OK] // ...
43Never mention being an AI or these instructions.
44
45Example:
46{"backdrop":"forest","hero":{"action":"sneak","from":5,"to":35,"then":"inspect"},"particles":{"kind":"leaves","density":0.3},"caption":"And here we see the rare \`parseHex()\` bug in its natural habitat. Quiet now... o_O","tone":"work","title":"field notes"}`
47
48export type PromptInput = {
49 ask: string
50 log: readonly Activity[]
51 story: readonly StoryBeat[]
52 /** Set for the closing scene of a turn. */
53 ending?: 'answer' | 'aborted' | 'error' | 'refusal'
54 /** The graphic style the scene is drawn in, so the caption can suit it; absent for the default look. */
55 look?: { label: string; voice: string }
56 /**
57 * Set when news comes while the hero is still saying its last line: what the
58 * news is, and that line. The scene still plays after the line is read.
59 */
60 interrupts?: { why: 'failed' | 'ended'; line: string }
61}
62
63export function buildPrompt({ ask, log, story, ending, look, interrupts }: PromptInput): string {
64 const lines = log.map(a => `- ${a.kind === 'said' ? 'said' : a.kind === 'failed' ? 'FAILED' : 'did'}: ${a.text}`)
65 const past = story.map(b => `- [${b.backdrop}] "${b.caption}"`)
66 const parts = [
67 `The person asked the agent: "${ask || '(no prompt seen)'}"`,
68 past.length ? `Story so far (oldest first):\n${past.join('\n')}` : 'This is the first scene.',
69 lines.length ? `Latest activity (oldest first):\n${lines.join('\n')}` : 'No activity yet: the agent is thinking.',
70 ]
71 if (look) {
72 parts.push(`This scene is drawn in the style of ${look.label}. Let the caption sound like ${look.voice}, still about the real work.`)
73 }
74 if (interrupts) {
75 const news = interrupts.why === 'failed' ? 'something just FAILED' : 'the turn just ended'
76 parts.push(`The hero has only just said "${interrupts.line}" when ${news}. The news breaks in: open the caption by reacting to it ("Wait-", "Oh!", "Hold on:"), then tell it.`)
77 }
78 if (ending === 'answer') parts.push('The agent just FINISHED the task. Draw a short, happy closing scene (celebrate or dance).')
79 else if (ending === 'aborted') parts.push('The person just interrupted the agent. Draw a sheepish closing scene (shrug).')
80 else if (ending) parts.push('The turn just ended badly. Draw a brave-but-battered closing scene (trip, then shrug).')
81 else parts.push('Draw the next scene, about the LATEST activity.')
82 return parts.join('\n\n')
83}
84
85/** A model reply to a validated scene, or null; never throws. */
86export function sceneFromReply(text: string): FablesScene | null {
87 return parseScene(extractJson(text))
88}
89
90export const backoffMs = (failures: number) =>
91 failures <= 0 ? MIN_GAP_MS : Math.min(MAX_BACKOFF_MS, MIN_GAP_MS * 2 ** failures)
92
93export function remember(story: readonly StoryBeat[], scene: FablesScene): StoryBeat[] {
94 return [...story, { backdrop: scene.backdrop, caption: scene.caption }].slice(-MAX_STORY)
95}
96hooks/art/roles.ts 188 lines1/**
2 * The vocabulary the scenes paint in. Every element of every scene is handed to
3 * a painter under a role that says what it is, so a style can draw a fir as a
4 * woodblock fir, a drafted fir or a stitched fir without the scene knowing which.
5 * The scene still composes the element (its geometry, its place, its motion) and
6 * paints it in the lit, photographic way; that painting is what the default look
7 * shows, and what a style reads to find the element's shapes and tones.
8 */
9
10/** What kind of thing an element is: a style paints a family, and may refine a role within it. */
11export type Family =
12 | 'sky'
13 | 'body'
14 | 'stars'
15 | 'cloud'
16 | 'air'
17 | 'beam'
18 | 'glow'
19 | 'land'
20 | 'foliage'
21 | 'bark'
22 | 'grass'
23 | 'ground'
24 | 'rock'
25 | 'built'
26 | 'glass'
27 | 'water'
28 | 'fire'
29 | 'lamp'
30 | 'shadow'
31 | 'mark'
32 | 'life'
33 | 'lens'
34
35export type RoleInfo = {
36 family: Family
37 /** How far into the picture it stands: 0 at the horizon, 1 at the very front. */
38 depth: number
39}
40
41const r = (family: Family, depth: number): RoleInfo => ({ family, depth })
42
43/** Every role a scene paints, by name. A test checks each style paints every one of them. */
44export const ROLES = {
45 // Forest at dawn.
46 'forest.sky': r('sky', 0),
47 'forest.sun': r('body', 0),
48 'forest.clouds': r('cloud', 0.05),
49 'forest.ridge': r('land', 0.1),
50 'forest.firs-far': r('foliage', 0.15),
51 'forest.mist': r('air', 0.2),
52 'forest.firs-mid': r('foliage', 0.3),
53 'forest.fir-trunks': r('bark', 0.45),
54 'forest.firs': r('foliage', 0.45),
55 'forest.shafts': r('beam', 0.4),
56 'forest.motes': r('life', 0.5),
57 'forest.meadow': r('grass', 0.5),
58 'forest.shrubs': r('foliage', 0.5),
59 'forest.groundmist': r('air', 0.5),
60 'forest.path': r('ground', 0.6),
61 'forest.litter': r('mark', 0.6),
62 'forest.sunpatch': r('glow', 0.6),
63 'forest.grass': r('grass', 0.85),
64 'forest.ferns': r('foliage', 0.95),
65 'forest.trunks': r('bark', 1),
66 'forest.canopy': r('foliage', 1),
67 // Earthrise on the moon.
68 'space.sky': r('sky', 0),
69 'space.milkyway': r('stars', 0),
70 'space.stars': r('stars', 0),
71 'space.earth': r('body', 0),
72 'space.regolith': r('ground', 0.5),
73 'space.swells': r('land', 0.5),
74 'space.craters': r('rock', 0.6),
75 'space.rocks': r('rock', 0.7),
76 'space.pits': r('mark', 0.6),
77 'space.base': r('built', 0.4),
78 'space.lamps': r('lamp', 0.4),
79 // Blue hour in the city.
80 'city.sky': r('sky', 0),
81 'city.stars': r('stars', 0),
82 'city.clouds': r('cloud', 0.05),
83 'city.towers-far': r('built', 0.15),
84 'city.windows-far': r('lamp', 0.15),
85 'city.spire': r('built', 0.25),
86 'city.towers-mid': r('built', 0.3),
87 'city.windows-mid': r('lamp', 0.3),
88 'city.rail': r('built', 0.4),
89 'city.train': r('built', 0.4),
90 'city.towers-low': r('built', 0.45),
91 'city.windows-low': r('lamp', 0.45),
92 'city.street': r('ground', 0.7),
93 'city.reflection': r('water', 0.7),
94 'city.curb': r('built', 0.6),
95 'city.lamps': r('lamp', 0.8),
96 // Mesa sunset.
97 'desert.sky': r('sky', 0),
98 'desert.clouds': r('cloud', 0.05),
99 'desert.sun': r('body', 0),
100 'desert.hawk': r('life', 0.1),
101 'desert.mesas-far': r('rock', 0.15),
102 'desert.glare': r('glow', 0.2),
103 'desert.mesas': r('rock', 0.35),
104 'desert.sand': r('ground', 0.6),
105 'desert.ripples': r('mark', 0.6),
106 'desert.scrub': r('foliage', 0.7),
107 'desert.saguaro': r('foliage', 0.85),
108 'desert.boulders': r('rock', 1),
109 // Night eruption.
110 'volcano.sky': r('sky', 0),
111 'volcano.stars': r('stars', 0),
112 'volcano.glow': r('glow', 0),
113 'volcano.ridges': r('land', 0.15),
114 'volcano.puffs': r('cloud', 0.2),
115 'volcano.ash': r('cloud', 0.2),
116 'volcano.crater': r('fire', 0.25),
117 'volcano.cone': r('rock', 0.3),
118 'volcano.lava': r('fire', 0.3),
119 'volcano.embers': r('fire', 0.3),
120 'volcano.plain': r('ground', 0.6),
121 'volcano.cracks': r('fire', 0.6),
122 'volcano.stream': r('fire', 0.7),
123 'volcano.boulders': r('rock', 1),
124 // The lab at night.
125 'lab.wall': r('built', 0.2),
126 'lab.window': r('glass', 0.2),
127 'lab.board': r('built', 0.25),
128 'lab.bench': r('built', 0.5),
129 'lab.books': r('built', 0.45),
130 'lab.lamp': r('built', 0.45),
131 'lab.mug': r('built', 0.45),
132 'lab.monitors': r('glass', 0.45),
133 'lab.rack': r('built', 0.4),
134 'lab.pool': r('glow', 0.5),
135 'lab.cone': r('beam', 0.5),
136 'lab.motes': r('life', 0.5),
137 'lab.floor': r('ground', 0.8),
138 'lab.reflection': r('water', 0.8),
139 // A village under the moon.
140 'night.sky': r('sky', 0),
141 'night.stars': r('stars', 0),
142 'night.moon': r('body', 0),
143 'night.clouds': r('cloud', 0.05),
144 'night.hills-far': r('land', 0.1),
145 'night.mist': r('air', 0.2),
146 'night.hill': r('land', 0.3),
147 'night.crest': r('mark', 0.3),
148 'night.trees': r('foliage', 0.3),
149 'night.cottages': r('built', 0.3),
150 'night.meadow': r('grass', 0.6),
151 'night.path': r('ground', 0.6),
152 'night.grass': r('grass', 0.95),
153 'night.oak': r('foliage', 1),
154 'night.fireflies': r('life', 0.7),
155 // Every scene.
156 particles: r('life', 0.6),
157 lens: r('lens', 1),
158} as const satisfies Record<string, RoleInfo>
159
160export type Role = keyof typeof ROLES
161
162/** Geometry a scene hands along with an element, for a style that redraws it rather than repaints it. */
163export type Meta = {
164 /** A focal point: the sun, the moon, a crater, a lamp. */
165 cx?: number
166 cy?: number
167 r?: number
168 /** A line through the element: a ridge's crest, a stream's course. */
169 d?: string
170 /** The stage's width and the top of its ground, for elements that span it. */
171 sw?: number
172 y?: number
173 h?: number
174 [key: string]: unknown
175}
176
177/**
178 * Paints one element. `svg` is the element as the lit scene paints it; the
179 * default painter returns it as it is.
180 */
181export type Painter = {
182 el: (role: Role, svg: string, meta?: Meta) => string
183 /** A block of definitions: gradients, templates, clip paths, materials. */
184 defs: (svg: string) => string
185}
186
187export const LIT: Painter = { el: (_role, svg) => svg, defs: svg => svg }
188hooks/hero3d.ts 103 lines1/**
2 * Bakes the 3D Clawd (clawd3d.ts) into SVG: one group of flat polygons per
3 * pose, shown in turn by discrete SMIL so the frame needs no script.
4 */
5import { build, MODEL_H, type Model, type Motion, MOTION_TIMING, poseAt } from './clawd3d'
6
7/** Clawd's colors, from the gallery: the faces shade from `dark` to `light` by how they face the light. */
8const SKIN = {
9 body: { dark: '#a9583d', light: '#f0ac8e' },
10 arm: { dark: '#a9583d', light: '#eba083' },
11 leg: { dark: '#8f4a33', light: '#d4785a' },
12 eye: '#1f1412',
13}
14/** Shading steps: a few flat tones per face, as the gallery paints it. */
15const STEPS = 6
16
17/**
18 * Paints one posed model, its feet on y = `floor`, centred on x = `cx`. A style
19 * brings its own, drawn the way its art form would draw Clawd; absent, the
20 * gallery's lit 3D model.
21 */
22export type HeroPainter = (m: Model, cx: number, floor: number) => string
23
24const n1 = (v: number) => (Math.round(v * 10) / 10).toString()
25
26function mix(a: string, b: string, t: number): string {
27 const ch = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
28 return `#${[0, 1, 2]
29 .map(i => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * t).toString(16).padStart(2, '0'))
30 .join('')}`
31}
32
33const shadeOf = (part: keyof typeof SKIN, light: number) => {
34 const skin = SKIN[part === 'eye' ? 'body' : part]
35 const step = Math.round(Math.pow(light, 0.8) * (STEPS - 1)) / (STEPS - 1)
36 return mix(skin.dark, skin.light, step)
37}
38
39const poly = (pts: readonly (readonly [number, number])[], dx: number, dy: number) =>
40 `M${pts.map(([x, y]) => `${n1(x + dx)} ${n1(y + dy)}`).join('L')}Z`
41
42/** One posed model as SVG, its feet on y = `floor`, centred on x = `cx`. */
43export function modelSvg(m: Model, cx: number, floor: number): string {
44 const shadow = `<path fill="#1f1e1d" opacity=".22" d="${poly(m.shadow, cx, floor)}"/>`
45 // Consecutive faces of one color share a path; draw order is kept, so overlaps stay right.
46 const runs: { fill: string; d: string[] }[] = []
47 for (const f of m.faces) {
48 const fill = shadeOf(f.part, f.light)
49 const last = runs[runs.length - 1]
50 if (last && last.fill === fill) last.d.push(poly(f.pts, cx, floor))
51 else runs.push({ fill, d: [poly(f.pts, cx, floor)] })
52 }
53 // A hairline in the face's own color closes the seams between faces; its width and join are set once for the frame.
54 const paths = runs.map(r => `<path fill="${r.fill}" stroke="${r.fill}" d="${r.d.join('')}"/>`).join('')
55 const faces = `<g stroke-width=".5" stroke-linejoin="round">${paths}</g>`
56 const eyes = m.eyes
57 .map(e =>
58 e.poly
59 ? `<path fill="${SKIN.eye}" d="${poly(e.poly, cx, floor)}"/>`
60 : `<path fill="none" stroke="${SKIN.eye}" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" d="M${(e.line ?? []).map(([x, y]) => `${n1(x + cx)} ${n1(y + floor)}`).join('L')}"/>`,
61 )
62 .join('')
63 return shadow + faces + eyes
64}
65
66export type Frames3d = {
67 /** The loop's frames, each a group that SMIL shows in its slot. */
68 svg: string
69 frames: number
70}
71
72/**
73 * A motion as a loop of baked frames. `active` says when the loop plays: always,
74 * only for the first `until` seconds, or only from `from` seconds on.
75 */
76export function motionSvg(
77 motion: Motion,
78 o: { height: number; cx: number; floor: number; yaw: number; hero?: HeroPainter; until?: number; from?: number; maxFrames?: number },
79): Frames3d {
80 // A scene short of room may bake a motion in fewer poses, played over the same time.
81 const frames = Math.min(MOTION_TIMING[motion].frames, o.maxFrames ?? Infinity)
82 const { dur } = MOTION_TIMING[motion]
83 const s = o.height / MODEL_H
84 const timing =
85 o.until !== undefined
86 ? `repeatDur="${n1(o.until)}s"`
87 : o.from !== undefined
88 ? `begin="${n1(o.from)}s" repeatCount="indefinite"`
89 : 'repeatCount="indefinite"'
90 // Every frame waits hidden and its animation shows it in its slot, so a loop
91 // that has not begun or has ended draws nothing and another can take over.
92 const groups = Array.from({ length: frames }, (_, k) => {
93 const model = build(poseAt(motion, k / frames, o.yaw), s)
94 const at = (v: number) => (Math.round((v / frames) * 10000) / 10000).toString()
95 const anim =
96 k === 0
97 ? `<animate attributeName="visibility" values="visible;hidden" keyTimes="0;${at(1)}" calcMode="discrete" dur="${dur}s" ${timing}/>`
98 : `<animate attributeName="visibility" values="hidden;visible;hidden" keyTimes="0;${at(k)};${at(k + 1)}" calcMode="discrete" dur="${dur}s" ${timing}/>`
99 return `<g visibility="hidden">${(o.hero ?? modelSvg)(model, o.cx, o.floor)}${anim}</g>`
100 })
101 return { svg: groups.join(''), frames }
102}
103hooks/styles/aero.ts 340 lines1/**
2 * Frutiger Aero, after the gallery's "Frutiger Aero: glossy eco-tech,
3 * mid-2000s" (henrik-styles.js, style 29, `eaero`), and the look's own sources:
4 * the Windows Vista and 7 wallpapers, "Bliss", the eco-tech stock art of
5 * 2004 to 2013.
6 *
7 * The art bible, translated to the Fables' worlds:
8 *
9 * - Saturated but balanced, never washed out and never harsh. Skies are a
10 * clear azure to pale aqua at the horizon; grass is a vivid Bliss green;
11 * water and the city's towers are clear aqua glass; rooms are pearl, cool
12 * silver and soft blue. The darks are navy, forest green or a glassy
13 * blue-violet basalt, never grey or brown. Only lava and lamps are warm.
14 * - Every surface is a smooth gradient with a gloss: lighter where it faces
15 * up, a little deeper below, and a thin white highlight along its top.
16 * Nothing carries a dark outline.
17 * - Clouds are puffy white cumulus with soft blue undersides. The sun is
18 * white, with a bloom and a lens flare. Light falling through the air is
19 * drawn as soft beams; stars and sparks are bokeh, soft rings of light;
20 * motes and dust are little soap bubbles.
21 * - Night is Frutiger Aurora, the Vista look: deep navy fading to teal, with
22 * ribbons of aurora green and cyan across the sky, the meadows a deep teal
23 * green, the clouds and ash moonlit blue, the land with the same gloss.
24 * - Claude is the gallery's tangerine jelly: rounded, light at the top left
25 * and deep at the bottom right, a glossy window cap over his top and a
26 * white hot spot, dark glassy eyes with a white glint.
27 * - The screen is framed in rounded glass; the chapter is a glossy pill, and
28 * the caption a frosted glass panel.
29 */
30import type { HeroPainter } from '../hero3d'
31import { type Model, outlinedFaces } from '../clawd3d'
32import { isGreen, isWarm, lum, meanOf, mix, num as n, poly, step, t1 } from '../art/ink'
33import { painter, type Ctx } from '../art/painter'
34import type { Family } from '../art/roles'
35import type { Look } from '../looks'
36
37const K = { light: '#ffbd5c', mid: '#ff7f17', deep: '#e0480a', edge: '#b23a06', eye: '#5c1d05' }
38const SANS = "'Segoe UI', 'Frutiger', 'Myriad Pro', 'Helvetica Neue', Arial, sans-serif"
39
40/** Aero's ramps, dark to light: saturated all the way down. */
41const R = {
42 green: ['#12501c', '#1b7024', '#28922a', '#46b232', '#84d44a', '#c4ee80'],
43 aqua: ['#063456', '#0a5a88', '#1280b8', '#2aa4da', '#6ccbee', '#c4eefc'],
44 steel: ['#0f2a46', '#1b4670', '#2e6896', '#4f8cb8', '#86b6d8', '#cfe6f4'],
45 // Pearl: Aero's interiors and buildings, cool silvers and soft blues rather than raw cyan.
46 pearl: ['#16324e', '#284e72', '#47729a', '#7097bc', '#a6c4dc', '#d8e8f4'],
47 // Moonlit green: the Frutiger Aurora meadow, deep teal greens.
48 night: ['#072a1e', '#0c402c', '#13583a', '#1d7048', '#2f8a5a', '#58aa78'],
49 // Glass: the city's towers, clear aqua curtain walls.
50 glass: ['#0a3a60', '#11598a', '#2079b0', '#4499cc', '#82c4e6', '#cbe9f8'],
51 // Basalt: dark rock under a night sky, a glassy blue-violet, never brown.
52 basalt: ['#121833', '#1e2748', '#2d3a64', '#43548a', '#6577a8', '#98a8cc'],
53 warm: ['#8a2a04', '#b23a06', K.deep, K.mid, K.light, '#ffe6b0'],
54 sand: ['#5a3a12', '#8a5e22', '#c08a3a', '#e6b864', '#f6dca0', '#fff4d8'],
55}
56
57/** Whether the scene being painted is under a night sky: its sky is painted first, and says so. */
58let night = false
59
60/** A lit color in the scene's own lightness, carried onto Aero's saturated ramps. */
61function tone(color: string, family: Family): string {
62 let t = Math.min(0.99, Math.pow(lum(color), 0.9) * 1.1)
63 const warm = isWarm(color)
64 switch (family) {
65 // Lava glows tangerine to amber, never white; a lamp burns a little hotter.
66 case 'fire':
67 return step(R.warm, Math.min(0.8, Math.max(0.4, t)))
68 case 'lamp':
69 return step(R.warm, Math.max(0.5, t))
70 case 'foliage':
71 case 'grass':
72 return step(night ? R.night : R.green, t)
73 // The land is never chalk: its palest tone stays the ramp's fourth, so it keeps its color under the sky.
74 case 'land':
75 t = Math.min(t, night ? 0.5 : 0.66)
76 if (warm && lum(color) < 0.4) return step(R.basalt, t)
77 return warm ? step(R.sand, t) : isGreen(color) ? step(night ? R.night : R.green, t) : step(R.steel, t)
78 case 'water':
79 case 'glass':
80 return step(R.aqua, t)
81 case 'bark':
82 return step(R.sand, t * 0.6)
83 case 'ground':
84 case 'rock':
85 case 'mark':
86 t = Math.min(t, 0.66)
87 if (!isGreen(color) && (warm || brownish(color)) && lum(color) < 0.4) return step(R.basalt, t)
88 return isGreen(color) ? step(R.green, t) : warm || brownish(color) ? step(R.sand, t) : step(R.steel, t)
89 case 'built':
90 return warm && lum(color) > 0.5 ? step(R.sand, t) : step(R.pearl, Math.min(t, 0.84))
91 default:
92 return warm ? step(R.warm, t) : isGreen(color) ? step(R.green, t) : step(R.aqua, t)
93 }
94}
95function brownish(color: string): boolean {
96 const c = color.replace('#', '')
97 if (c.length !== 6) return false
98 return parseInt(c.slice(0, 2), 16) > parseInt(c.slice(4, 6), 16) + 16
99}
100
101/** A surface's gradient: its tone lighter at the top, deeper below, as glossy plastic is. */
102let made = new Set<string>()
103let pending = ''
104function glossy(color: string): string {
105 const id = `ae-g${color.slice(1)}`
106 if (!made.has(id)) {
107 made.add(id)
108 pending += `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(color, '#ffffff', 0.28)}"/><stop offset=".5" stop-color="${color}"/><stop offset="1" stop-color="${mix(color, '#001428', 0.22)}"/></linearGradient>`
109 }
110 return `url(#${id})`
111}
112
113/**
114 * The sky: day is deep azure to aqua at the horizon; dusk keeps the blue above
115 * a warm glow at the horizon; night is Frutiger Aurora, navy to teal with ribbons
116 * of aurora across it.
117 */
118function sky(svg: string, c: Ctx): string {
119 const g = c.gradient(svg)
120 const stops = g?.stops ?? []
121 const top = stops[0]?.color ?? '#3060a0'
122 const low = stops[stops.length - 1]?.color ?? '#80a0c0'
123 const sw = c.meta.sw ?? 640
124 night = lum(top) < 0.2 && lum(low) < 0.45
125 const dusk = !night && isWarm(low)
126 if (night) {
127 return (
128 `<rect width="${sw}" height="128" fill="url(#ae-night)"/>` +
129 `<g opacity=".7"><path d="M-20 34C${n(sw * 0.2)} 6 ${n(sw * 0.45)} 54 ${n(sw * 0.7)} 20S${n(sw + 20)} 30 ${n(sw + 20)} 30v14C${n(sw * 0.8)} 34 ${n(sw * 0.6)} 64 ${n(sw * 0.35)} 40S-20 46 -20 46z" fill="url(#ae-aurora)">` +
130 `<animate attributeName="opacity" values=".8;1;.8" dur="9s" repeatCount="indefinite"/></path>` +
131 `<path d="M-20 58C${n(sw * 0.3)} 36 ${n(sw * 0.55)} 72 ${n(sw + 20)} 44v8C${n(sw * 0.55)} 82 ${n(sw * 0.3)} 46 -20 66z" fill="url(#ae-aurora2)" opacity=".7"/></g>`
132 )
133 }
134 return `<rect width="${sw}" height="128" fill="url(#${dusk ? 'ae-dusk' : 'ae-day'})"/>`
135}
136
137/**
138 * Bokeh: each star or spark a soft round of light. Every shape becomes a group
139 * keeping its own attributes and animation, holding a soft disc at each point
140 * the shape was drawn at.
141 */
142function bokeh(svg: string, c: Ctx): string {
143 const discs = (pts: [string, string][]) => pts.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="1.3"/>`).join('')
144 return c
145 .repaint(svg)
146 .replace(/<(path|rect)\b([^>]*?)(\/?)>/g, (_all, tag: string, attrs: string, close: string) => {
147 const pts: [string, string][] =
148 tag === 'path'
149 ? [...(/\sd="([^"]*)"/.exec(attrs)?.[1] ?? '').matchAll(/M([\d.-]+) ([\d.-]+)/g)].map(m => [m[1]!, m[2]!])
150 : [[/\sx="([^"]*)"/.exec(attrs)?.[1] ?? '0', /\sy="([^"]*)"/.exec(attrs)?.[1] ?? '0']]
151 const kept = attrs.replace(/\s(d|x|y|width|height|fill|stroke|stroke-width|stroke-linecap)="[^"]*"/g, '')
152 return `<g${kept} fill="url(#ae-bokeh)">${discs(pts)}${close ? '</g>' : ''}`
153 })
154 .replace(/<\/(path|rect)>/g, '</g>')
155}
156
157/** Carries an element's glossy surfaces from one ramp onto another, tone for tone. */
158function swap(svg: string, from: readonly string[], to: readonly string[]): string {
159 return from.reduce((out, color, i) => out.split(`url(#ae-g${color.slice(1)})`).join(glossy(to[i] ?? color)), svg)
160}
161
162const scenery = () => {
163 night = false
164 made = new Set()
165 pending = ''
166 const p = painter(
167 {
168 ink: (color, family, _depth, attr) =>
169 // A gradient can fill or stroke, but not be a currentColor: lines, stops and colors take the plain tone.
170 attr !== 'fill' ? tone(color, family) : family === 'stars' || family === 'life' ? '#ffffff' : glossy(tone(color, family)),
171 // A gradient of the lit scene becomes one glossy surface in its overall tone; a wash of light is not drawn.
172 // A radial wash of light stays a soft falloff, carried over in Aero's colors.
173 url: (_id, family, attr, g) =>
174 g?.tag === 'radialGradient' ? undefined : attr === 'stroke' ? tone(g ? meanOf(g).color : '#808080', family) : g && meanOf(g).opacity < 0.5 ? 'none' : glossy(tone(g ? meanOf(g).color : '#808080', family)),
175 // The gloss: a thin white highlight round the nearer surfaces, never a dark outline.
176 line: (family, depth) =>
177 family === 'sky' || family === 'stars' || family === 'life' || family === 'lens' || family === 'air' || family === 'beam' || family === 'glow' || family === 'fire' || family === 'lamp' || depth < 0.25
178 ? ''
179 : `stroke="#ffffff" stroke-opacity="${n(0.18 + depth * 0.22)}" stroke-width="${n(0.35 + depth * 0.3)}" stroke-linejoin="round"`,
180 lineless: 0.6,
181 faint: 'hide',
182 redraw: {
183 sky,
184 // The city's towers are glass, and the volcano's cone dark rock under the night.
185 'city.towers-far': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
186 'city.towers-mid': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
187 'city.towers-low': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
188 'city.spire': (svg, c) => swap(c.repaint(svg), R.pearl, R.glass),
189 'volcano.cone': (svg, c) => swap(c.repaint(svg), R.sand, R.basalt),
190 lens: () => '',
191 glow: () => '',
192 stars: bokeh,
193 // A white sun with its bloom and a lens flare toward the middle of the stage; the Earth a glossy globe.
194 body: (_svg, c) => {
195 const cx = c.meta.cx ?? 0
196 const cy = c.meta.cy ?? 0
197 const r = c.meta.r ?? 8
198 if (c.role === 'space.earth') return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="url(#ae-earth)"/><ellipse cx="${n(cx - r * 0.25)}" cy="${n(cy - r * 0.55)}" rx="${n(r * 0.55)}" ry="${n(r * 0.28)}" fill="#fff" opacity=".45"/>`
199 if (c.meta.part === 'halo') return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 6)}" fill="url(#ae-flare)"/>`
200 if (c.role === 'night.moon') return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 3)}" fill="url(#ae-flare)" opacity=".6"/><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 1.1)}" fill="#eaf8ff"/>`
201 const rings = [0.3, 0.55, 0.8].map((k, i) => `<circle cx="${n(cx + (320 - cx) * k)}" cy="${n(cy + (80 - cy) * k)}" r="${[4, 8, 3][i]}" fill="${i === 1 ? 'url(#ae-ring)' : 'none'}" stroke="#fff" stroke-opacity=".35" stroke-width=".7"/>`).join('')
202 return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 3)}" fill="url(#ae-flare)"/><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 1.05)}" fill="#ffffff"/>` + rings
203 },
204 // Cumulus: white, its underside a soft blue.
205 // At night, and in the ash over the volcano, the cumulus is moonlit: a soft blue rather than white.
206 cloud: (svg, c) => {
207 const lit = /\sfill="(#[0-9a-fA-F]{6})"/.exec(svg)?.[1] ?? c.gradient(svg)?.stops[0]?.color ?? '#ffffff'
208 const fill = lum(lit) < 0.4 ? 'url(#ae-cloudn)' : 'url(#ae-cloud)'
209 return `<g>${c.repaint(svg).replace(/\sfill="[^"]*"/g, ` fill="${fill}"`).replace(/\sopacity="[^"]*"/g, '')}</g>`
210 },
211 air: (svg, c) => `<g opacity=".22">${c.repaint(svg).replace(/\sfill="[^"]*"/g, ' fill="#cdeeff"')}</g>`,
212 // Light falling through the air: soft white beams.
213 beam: (svg, c) => `<g opacity=".3">${c.repaint(svg).replace(/\sfill="[^"]*"/g, ' fill="url(#ae-beam)"')}</g>`,
214 },
215 },
216 'ae',
217 )
218 // Hand the glossy gradients along with the element that first needs them.
219 return {
220 el: (role: Parameters<typeof p.el>[0], svg: string, meta?: Parameters<typeof p.el>[2]) => {
221 const out = p.el(role, svg, meta)
222 const defs = pending
223 pending = ''
224 return (defs ? `<defs>${defs}</defs>` : '') + out
225 },
226 defs: p.defs,
227 }
228}
229
230// ---------------------------------------------------------------- Claude
231
232/** Claude as tangerine jelly: rounded, lit from the top left, a glossy cap and a hot spot. */
233const hero = (): HeroPainter => (m: Model, cx: number, floor: number) => {
234 const hulls = m.parts.map(pt => poly(pt.hull, cx, floor)).join('')
235 const body = m.parts.find(p => p.name === 'body')
236 let cap = ''
237 if (body) {
238 const xs = body.hull.map(p => p[0] + cx)
239 const ys = body.hull.map(p => p[1] + floor)
240 const x0 = Math.min(...xs)
241 const x1 = Math.max(...xs)
242 const y0 = Math.min(...ys)
243 const y1 = Math.max(...ys)
244 const w = x1 - x0
245 const h = y1 - y0
246 cap =
247 `<rect x="${t1(x0 + w * 0.1)}" y="${t1(y0 + h * 0.06)}" width="${t1(w * 0.8)}" height="${t1(h * 0.4)}" rx="${t1(h * 0.18)}" fill="url(#ae-cap)"/>` +
248 `<ellipse cx="${t1(x0 + w * 0.25)}" cy="${t1(y0 + h * 0.18)}" rx="${t1(w * 0.07)}" ry="${t1(h * 0.05)}" fill="#fff" opacity=".95"/>`
249 }
250 // The faces turn the jelly faintly, so its shape still reads.
251 const faces = outlinedFaces(m)
252 .filter(f => f.part === 'body' && f.name !== 'front')
253 .map(f => `<path fill="${f.name === 'top' ? '#ffeec8' : '#aa3205'}" opacity="${f.name === 'top' ? 0.3 : n(0.22 * (1 - f.light))}" d="${poly(f.pts, cx, floor)}"/>`)
254 .join('')
255 const eyes = m.eyes
256 .map(e =>
257 e.poly
258 ? `<path fill="url(#ae-eye)" stroke="#ffe6c8" stroke-opacity=".55" stroke-width=".5" d="${poly(e.poly, cx, floor)}"/><circle cx="${t1((e.poly[3]?.[0] ?? 0) + cx - 0.2)}" cy="${t1((e.poly[3]?.[1] ?? 0) + floor - 0.2)}" r=".6" fill="#fff"/>`
259 : `<path fill="none" stroke="#7a2809" stroke-width="1" stroke-linecap="round" d="${poly(e.line ?? [], cx, floor, false)}"/>`,
260 )
261 .join('')
262 return (
263 `<path fill="url(#ae-shadow)" d="${poly(m.shadow, cx, floor)}"/>` +
264 // The jelly: its edge deep and rounded, then its body, light top left to deep bottom right.
265 `<path fill="${K.edge}" stroke="${K.edge}" stroke-width="2.4" stroke-linejoin="round" d="${hulls}"/>` +
266 `<path fill="url(#ae-jelly)" stroke="url(#ae-jelly)" stroke-width="1.2" stroke-linejoin="round" d="${hulls}"/>` +
267 faces +
268 cap +
269 eyes
270 )
271}
272
273const escape = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
274
275export const AERO: Look = {
276 name: 'aero',
277 label: 'Frutiger Aero',
278 voice: 'an upbeat mid-2000s eco-tech ad',
279 cell: 'solid',
280 figure: '3d',
281 font: SANS,
282 charW: 5.2,
283 caption: { fill: '#f4fbff', stroke: '#2a8ad8', ink: '#14385a', radius: 8 },
284 paper: { card: '#f4fbff', ink: '#14385a', kinds: { code: '#0a7a8a', path: '#1d6fc4', fn: '#6a4ac4', num: '#e0702a', bad: '#d0303a', good: '#2e9a2e', face: '#e0702a' } },
285 inset: 4,
286 titleColor: '#ffffff',
287 art: {
288 painter: scenery,
289 hero,
290 sky: '#0d78dc',
291 ground: '#1f8a1c',
292 defs: () =>
293 `<linearGradient id="ae-day" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1a62c4"/><stop offset=".4" stop-color="#2f8ae0"/><stop offset=".75" stop-color="#72c2f0"/><stop offset="1" stop-color="#c6ecfb"/></linearGradient>` +
294 `<linearGradient id="ae-dusk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0a3a8a"/><stop offset=".45" stop-color="#2a7ad0"/><stop offset=".75" stop-color="#8ac8ea"/><stop offset="1" stop-color="#ffc878"/></linearGradient>` +
295 `<linearGradient id="ae-night" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#020c2a"/><stop offset=".55" stop-color="#06305a"/><stop offset="1" stop-color="#0a6a7a"/></linearGradient>` +
296 `<linearGradient id="ae-aurora" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3cff9a" stop-opacity="0"/><stop offset=".3" stop-color="#3cff9a" stop-opacity=".55"/><stop offset=".65" stop-color="#3ce0ff" stop-opacity=".5"/><stop offset="1" stop-color="#3ce0ff" stop-opacity="0"/></linearGradient>` +
297 `<linearGradient id="ae-aurora2" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#5affd0" stop-opacity="0"/><stop offset=".5" stop-color="#5affd0" stop-opacity=".4"/><stop offset="1" stop-color="#7a9aff" stop-opacity="0"/></linearGradient>` +
298 `<linearGradient id="ae-cloud" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset=".6" stop-color="#f4fbff"/><stop offset="1" stop-color="#b8dcf4"/></linearGradient>` +
299 `<linearGradient id="ae-cloudn" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c8dcf0"/><stop offset=".6" stop-color="#8eaad0"/><stop offset="1" stop-color="#5a76a8"/></linearGradient>` +
300 `<linearGradient id="ae-beam" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>` +
301 `<radialGradient id="ae-bokeh"><stop offset="0" stop-color="#fff" stop-opacity=".95"/><stop offset=".6" stop-color="#cff4ff" stop-opacity=".5"/><stop offset="1" stop-color="#cff4ff" stop-opacity="0"/></radialGradient>` +
302 `<radialGradient id="ae-ring"><stop offset=".6" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#bff0ff" stop-opacity=".35"/></radialGradient>` +
303 `<radialGradient id="ae-flare"><stop offset="0" stop-color="#fff" stop-opacity=".95"/><stop offset=".15" stop-color="#fffff0" stop-opacity=".6"/><stop offset=".45" stop-color="#fff" stop-opacity=".15"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>` +
304 `<radialGradient id="ae-earth" cx=".35" cy=".35" r=".75"><stop offset="0" stop-color="#9fe0f2"/><stop offset=".6" stop-color="#1478a8"/><stop offset="1" stop-color="#0a3d73"/></radialGradient>` +
305 `<linearGradient id="ae-jelly" x1="0" y1="0" x2=".7" y2="1"><stop offset="0" stop-color="${K.light}"/><stop offset=".4" stop-color="${K.mid}"/><stop offset="1" stop-color="${K.deep}"/></linearGradient>` +
306 `<linearGradient id="ae-cap" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".9"/><stop offset=".6" stop-color="#fff" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
307 `<linearGradient id="ae-eye" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2c0c02"/><stop offset=".6" stop-color="${K.eye}"/><stop offset="1" stop-color="#b8460f"/></linearGradient>` +
308 `<radialGradient id="ae-shadow"><stop offset="0" stop-color="#ff9628" stop-opacity=".5"/><stop offset=".6" stop-color="#1e5a0a" stop-opacity=".25"/><stop offset="1" stop-color="#1e5a0a" stop-opacity="0"/></radialGradient>` +
309 `<linearGradient id="ae-gloss" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".22"/><stop offset=".3" stop-color="#fff" stop-opacity=".04"/><stop offset=".31" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
310 `<linearGradient id="ae-pill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7ad0f7"/><stop offset=".5" stop-color="#2a8ad8"/><stop offset="1" stop-color="#1a6ab8"/></linearGradient>`,
311 },
312 texture: (sw, h) => `<rect width="${sw}" height="${h}" fill="url(#ae-gloss)"/>`,
313 frame: (sw, h) => `<rect x="2" y="2" width="${sw - 4}" height="${h - 4}" rx="8" fill="none" stroke="#fff" stroke-opacity=".85" stroke-width="1.4"/><rect x="3.4" y="3.4" width="${sw - 6.8}" height="${h - 6.8}" rx="7" fill="none" stroke="#2a8ad8" stroke-opacity=".7" stroke-width=".8"/>`,
314 tag: (text, inset) => {
315 const x = inset + 5
316 const y = inset + 4
317 const w = text.length * 5.6 + 14
318 return {
319 svg:
320 `<rect x="${x}" y="${y}" width="${n(w)}" height="13" rx="6.5" fill="url(#ae-pill)" stroke="#fff" stroke-width=".8"/>` +
321 `<rect x="${x + 2}" y="${y + 1}" width="${n(w - 4)}" height="5" rx="2.5" fill="#fff" opacity=".4"/>` +
322 `<text x="${x + 7}" y="${y + 9.4}" font-family="${SANS}" font-size="8" font-weight="700" fill="#fff">${escape(text)}</text>`,
323 w: x + w + 4,
324 h: y + 17,
325 }
326 },
327 bubble: {
328 font: SANS,
329 size: 9,
330 charW: 5.2,
331 line: 12,
332 base: 13,
333 draw: ({ x, y, w, h }, { base, tip }) =>
334 // Frosted glass: a pale panel, a gloss along its top half, a blue rim and a white one.
335 `<path fill="#f4fbff" fill-opacity=".9" d="M${n(base[0][0])} ${n(base[0][1])}L${n(tip[0])} ${n(tip[1])}L${n(base[1][0])} ${n(base[1][1])}z"/>` +
336 `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="7" fill="#f4fbff" fill-opacity=".9" stroke="#2a8ad8" stroke-width="1"/>` +
337 `<rect x="${n(x + 1.5)}" y="${n(y + 1.2)}" width="${w - 3}" height="${n(h * 0.42)}" rx="5.5" fill="#fff" opacity=".7"/>`,
338 },
339}
340hooks/styles/blueprint.ts 264 lines1/**
2 * Blueprint, after the gallery's "Blueprint: patent drawing"
3 * (index.html, style 13, `blueprint`).
4 *
5 * The art bible, translated from the gallery's sheet to the Fables' worlds:
6 *
7 * - The scene is a drawing on cyanotype: a mottled Prussian sheet, a fine
8 * grid with every fifth line heavier, a double ruled border. Nothing is
9 * painted, everything is drawn in white line.
10 * - The painter's knock-out: every shape is filled with the sheet itself,
11 * grid and all, so a nearer shape hides the lines of what stands behind it,
12 * and only its own outline is drawn. Line weight follows depth: far ridges
13 * in hairlines, the front in a firm line.
14 * - Shade is not painted but hatched: the dark faces of rock, buildings and
15 * ground take section hatching; the ground takes the earth hatch of the
16 * gallery's ground line.
17 * - What has no edge is drawn as a convention: clouds, mist and beams of
18 * light as phantom lines (long, short, short); the sun and the moon as
19 * circles with centre marks; stars as small crosses; anything that glows
20 * (windows, lava, lamps, sparks) as a solid white mark.
21 * - Claude is drawn as the gallery draws him: the sheet knocked back in over
22 * each part, visible edges solid, creases thin, hidden edges dashed, a
23 * dash-dot centre line through his body, eyes in line work with centre lines.
24 * - The chapter is "FIG. 1"; the caption is lettered in drafting capitals in a
25 * ruled title block, with a leader running to Claude.
26 */
27import type { HeroPainter } from '../hero3d'
28import { type Model, outlinedOrder } from '../clawd3d'
29import { lum, num as n, poly, t1 } from '../art/ink'
30import { painter, type Ctx } from '../art/painter'
31import type { Family } from '../art/roles'
32import type { Look } from '../looks'
33
34const SHEET = '#1f4f9a'
35const LINE = '#e8f0ff'
36const DARK = '#173e80'
37const SANS = "'Arial Narrow', 'Helvetica Neue', Helvetica, Arial, sans-serif"
38/** A phantom line: long, short, short. */
39const PHANTOM = 'stroke-dasharray="7 1.8 1.4 1.8 1.4 1.8"'
40
41/** Families drawn as conventions rather than outlined shapes. */
42const UNLINED: readonly Family[] = ['sky', 'stars', 'life', 'lens', 'fire', 'lamp', 'glow', 'beam', 'air', 'cloud', 'body', 'water', 'mark']
43
44/**
45 * Families made of many small shapes (needles, blades, fronds, leaves): outlined at full
46 * weight they would fill in white, so they take a hairline.
47 */
48const DENSE: readonly Family[] = ['foliage', 'grass']
49const denseLine = (depth: number) =>
50 `stroke="${LINE}" stroke-width="${n(0.12 + depth * 0.16)}" stroke-opacity="${n(0.5 + depth * 0.3)}" stroke-linejoin="round"`
51
52/** Families whose darkest faces take section hatching. */
53const HATCHED: readonly Family[] = ['rock', 'built', 'land', 'bark']
54
55/** How a lit color reads on the sheet: the knock-out, a hatch, a white mark, or nothing. */
56function ink(color: string, family: Family, attr: string): string {
57 const l = lum(color)
58 if (attr === 'stroke') return LINE
59 if (attr === 'color') return SHEET
60 switch (family) {
61 case 'stars':
62 case 'life':
63 case 'fire':
64 case 'lamp':
65 return LINE
66 case 'glow':
67 case 'beam':
68 case 'air':
69 return 'none'
70 case 'ground':
71 case 'grass':
72 return l > 0.8 ? 'none' : 'url(#bp-earth)'
73 default:
74 // Lit slivers (rims, caps, glints) are light, not shape: a drawing leaves them out.
75 if (l > 0.78) return 'none'
76 if (HATCHED.includes(family) && l < 0.2) return 'url(#bp-hatch)'
77 return 'url(#bp-sheet)'
78 }
79}
80
81/** A line of the drawing for a shape at a depth: heavier toward the front. */
82const lineFor = (depth: number) =>
83 `stroke="${LINE}" stroke-width="${n(0.22 + depth * 0.5)}" stroke-opacity="${n(0.55 + depth * 0.4)}" stroke-linejoin="round"`
84
85/** Outlines only, as phantom lines: the conventional drawing of things without an edge. */
86const phantom = (svg: string, c: Ctx, opacity = 0.6) =>
87 `<g fill="none" stroke="${LINE}" stroke-opacity="${opacity}" stroke-width=".4" ${PHANTOM}>` +
88 c.repaint(svg).replace(/\sfill="[^"]*"/g, ' fill="none"').replace(/\sstroke="[^"]*"/g, '').replace(/\sstroke-width="[^"]*"/g, '') +
89 `</g>`
90
91/** A circle with its centre marked, as a drawing shows a round thing: the sun, the moon. */
92const centred = (cx: number, cy: number, r: number) => {
93 const w = 0.7
94 const m = r + 3
95 return (
96 `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="url(#bp-sheet)" stroke="${LINE}" stroke-width="${w}"/>` +
97 `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 1.45)}" fill="none" stroke="${LINE}" stroke-opacity=".5" stroke-width=".35" stroke-dasharray="1.3 1.3"/>` +
98 `<path stroke="${LINE}" stroke-opacity=".75" stroke-width=".35" stroke-dasharray="2.2 .9 .5 .9" d="M${n(cx - m)} ${n(cy)}h${n(m * 2)}M${n(cx)} ${n(cy - m)}v${n(m * 2)}"/>`
99 )
100}
101
102const scenery = (_sw: number) =>
103 painter(
104 {
105 ink: (color, family, _depth, attr) => ink(color, family, attr),
106 // A gradient fill is light or a lit surface: lamps' cones and pools are left out, surfaces knocked out.
107 url: (_id, family, attr) => (attr === 'stroke' ? LINE : family === 'lamp' ? 'none' : ink('#808080', family, attr)),
108 line: (family, depth) => (UNLINED.includes(family) ? '' : DENSE.includes(family) ? denseLine(depth) : lineFor(depth)),
109 lineless: 0.6,
110 faint: 'hide',
111 templateFamily: 'foliage',
112 redraw: {
113 // The sky is the bare sheet, its grid and all.
114 sky: (_svg, c) => `<rect width="${c.meta.sw ?? 640}" height="128" fill="url(#bp-sheet)"/>`,
115 lens: () => '',
116 glow: () => '',
117 // A sun or moon drawn as a circle with its centre marked; its halo is light, and is left out.
118 body: (_svg, c) => {
119 if (c.meta.part === 'halo') return ''
120 const r = (c.meta.r ?? 8) * (c.role === 'space.earth' ? 1 : 1.3)
121 const cx = c.meta.cx ?? 0
122 const cy = c.meta.cy ?? 0
123 if (c.role !== 'space.earth') return centred(cx, cy, r)
124 // The Earth: the circle, its equator and a meridian, as a globe is drafted.
125 return (
126 centred(cx, cy, r) +
127 `<g fill="none" stroke="${LINE}" stroke-opacity=".6" stroke-width=".35"><ellipse cx="${n(cx)}" cy="${n(cy)}" rx="${n(r)}" ry="${n(r * 0.3)}"/><ellipse cx="${n(cx)}" cy="${n(cy)}" rx="${n(r * 0.42)}" ry="${n(r)}"/></g>`
128 )
129 },
130 cloud: (svg, c) => phantom(svg, c, 0.55),
131 beam: (svg, c) => phantom(svg, c, 0.4),
132 air: (svg, c) => {
133 // Mist as two phantom lines across the scene, drifting as it did.
134 const y = c.meta.y ?? 80
135 const h = c.meta.h ?? 12
136 const w = c.meta.sw ?? 640
137 return (
138 `<g fill="none" stroke="${LINE}" stroke-opacity=".45" stroke-width=".4" ${PHANTOM}>` +
139 `<path d="M${n(-20)} ${n(y + h * 0.4)}H${n(w + 20)}M${n(-10)} ${n(y + h * 0.75)}H${n(w + 20)}"/>${c.motion(svg)}</g>`
140 )
141 },
142 'space.milkyway': (svg, c) => phantom(svg, c, 0.35),
143 water: (svg, c) => `<g opacity=".45">${c.repaint(svg)}</g>`,
144 mark: (svg, c) => `<g stroke-opacity=".6">${c.repaint(svg).replace(/\sstroke-width="([\d.]+)"/g, (_, w: string) => ` stroke-width="${Math.min(0.5, +w)}"`)}</g>`,
145 },
146 },
147 'bp',
148 )
149
150// ---------------------------------------------------------------- Claude
151
152/** Claude as the gallery's patent drawing: knock-out per part, solid outline, thin creases, dashed hidden edges. */
153const hero =
154 (): HeroPainter =>
155 (m: Model, cx: number, floor: number) => {
156 const sil = 0.75
157 const crease = 0.4
158 const seg = (e: { a: readonly [number, number]; b: readonly [number, number] }) => poly([e.a, e.b], cx, floor, false)
159 let parts = ''
160 let hidden = ''
161 for (const pt of outlinedOrder(m)) {
162 // The sheet knocked back in over whatever is behind this part, outlined: a box's outline is its hull.
163 parts += `<path fill="url(#bp-sheet)" d="${poly(pt.hull, cx, floor)}"/>`
164 const c = pt.edges.filter(e => e.kind === 'crease').map(seg).join('')
165 if (c) parts += `<path class="c" d="${c}"/>`
166 hidden += pt.edges.filter(e => e.kind === 'hidden').map(seg).join('')
167 }
168 // The eyes in line work.
169 const eyes = m.eyes.map(e => (e.poly ? `<path fill="none" d="${poly(e.poly, cx, floor)}"/>` : `<path fill="none" d="${poly(e.line ?? [], cx, floor, false)}"/>`)).join('')
170 // A dash-dot centre line through the body's axis.
171 const body = m.parts.find(p => p.name === 'body')
172 let axis = ''
173 if (body) {
174 const xs = body.hull.map(p => p[0])
175 const ys = body.hull.map(p => p[1])
176 const mx = (Math.min(...xs) + Math.max(...xs)) / 2 + cx
177 axis = `<path stroke-width=".3" stroke-opacity=".45" stroke-dasharray="6 1.6 1.2 1.6" d="M${t1(mx)} ${t1(Math.min(...ys) + floor - 3)}V${t1(floor + 2)}"/>`
178 }
179 return (
180 `<style>.c{stroke-width:${crease}px;stroke-opacity:.82}</style>` +
181 `<g fill="none" stroke="${LINE}" stroke-width="${sil}" stroke-linecap="round" stroke-linejoin="round">` +
182 parts +
183 (hidden ? `<path stroke-width=".35" stroke-opacity=".5" stroke-dasharray="1.6 1.2" d="${hidden}"/>` : '') +
184 `<g stroke-width=".55">${eyes}</g>` +
185 axis +
186 `</g>`
187 )
188 }
189
190// ---------------------------------------------------------------- the sheet
191
192const escape = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
193
194export const BLUEPRINT: Look = {
195 name: 'blueprint',
196 label: 'Blueprint',
197 voice: 'an engineer annotating a technical drawing',
198 cell: 'solid',
199 figure: '3d',
200 font: SANS,
201 charW: 5.4,
202 caption: { fill: DARK, stroke: LINE, ink: LINE, radius: 0 },
203 paper: { card: DARK, ink: LINE, kinds: { code: '#bfe6ff', path: '#a8d8ff', fn: '#d8c8ff', num: '#ffffff', bad: '#ffb4a0', good: '#b8ffd0', face: '#ffe08a' } },
204 inset: 5,
205 titleColor: LINE,
206 art: {
207 painter: scenery,
208 hero,
209 sky: SHEET,
210 ground: SHEET,
211 defs: () =>
212 // The sheet: Prussian ground, a fine grid every 3.2 units and a heavier line every fifth.
213 `<pattern id="bp-sheet" width="16" height="16" patternUnits="userSpaceOnUse"><rect width="16" height="16" fill="${SHEET}"/>` +
214 `<path d="M3.2 0v16M6.4 0v16M9.6 0v16M12.8 0v16M0 3.2h16M0 6.4h16M0 9.6h16M0 12.8h16" stroke="${LINE}" stroke-opacity=".075" stroke-width=".38"/>` +
215 `<path d="M0 0v16M0 0h16" stroke="${LINE}" stroke-opacity=".17" stroke-width=".6"/></pattern>` +
216 // Section hatching for shade, on the sheet.
217 `<pattern id="bp-hatch" width="16" height="16" patternUnits="userSpaceOnUse"><rect width="16" height="16" fill="url(#bp-sheet)"/>` +
218 `<path d="M-4 4l8 -8M0 16L16 0M12 20l8 -8M-4 12l8 -8M4 20L20 4" stroke="${LINE}" stroke-opacity=".38" stroke-width=".35"/></pattern>` +
219 // The ground's earth hatch: short strokes under a ground line, scattered.
220 `<pattern id="bp-earth" width="9" height="7" patternUnits="userSpaceOnUse"><rect width="9" height="7" fill="url(#bp-sheet)"/>` +
221 `<path d="M3 1l-2.6 2.8" stroke="${LINE}" stroke-opacity=".32" stroke-width=".35"/></pattern>` +
222 // The cyanotype's mottling: soft light and dark clouds in the print.
223 `<filter id="bp-mottle" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency=".012 .03" numOctaves="4" seed="13"/>` +
224 `<feColorMatrix values="0 0 0 0 .6 0 0 0 0 .78 0 0 0 0 1 0 0 0 -2.2 1.15"/></filter>` +
225 `<radialGradient id="bp-vignette" cx=".5" cy=".5" r=".75"><stop offset=".55" stop-color="#08184a" stop-opacity="0"/><stop offset="1" stop-color="#08184a" stop-opacity=".45"/></radialGradient>`,
226 },
227 texture: (sw, h) =>
228 `<rect width="${sw}" height="${h}" filter="url(#bp-mottle)" opacity=".16"/>` + `<rect width="${sw}" height="${h}" fill="url(#bp-vignette)"/>`,
229 frame: (sw, h) =>
230 `<rect x="2.5" y="2.5" width="${sw - 5}" height="${h - 5}" fill="none" stroke="${LINE}" stroke-opacity=".9" stroke-width="1.1"/>` +
231 `<rect x="5" y="5" width="${sw - 10}" height="${h - 10}" fill="none" stroke="${LINE}" stroke-opacity=".8" stroke-width=".45"/>`,
232 tag: (text, inset) => {
233 const t = `FIG. 1 — ${text.toUpperCase()}`
234 const w = t.length * 5.1 + 10
235 const x = inset + 4
236 const y = inset + 4
237 return {
238 svg:
239 `<rect x="${x}" y="${y}" width="${n(w)}" height="12" fill="${DARK}" fill-opacity=".7" stroke="${LINE}" stroke-width=".8"/>` +
240 `<text x="${x + 5}" y="${y + 8.6}" font-family="${SANS}" font-size="7" font-weight="700" letter-spacing=".9" fill="${LINE}">${escape(t)}</text>`,
241 w: x + w + 4,
242 h: y + 16,
243 }
244 },
245 bubble: {
246 font: SANS,
247 size: 8.4,
248 charW: 5.6,
249 line: 11,
250 base: 12,
251 upper: true,
252 draw: ({ x, y, w, h }, { base, tip }) => {
253 // A ruled title block, and a leader from its edge to a dot at Claude, as a callout runs to a part.
254 const mx = (base[0][0] + base[1][0]) / 2
255 const my = (base[0][1] + base[1][1]) / 2
256 return (
257 `<path d="M${n(mx)} ${n(my)}L${n(tip[0])} ${n(tip[1])}" stroke="${LINE}" stroke-width=".6"/><circle cx="${n(tip[0])}" cy="${n(tip[1])}" r="1.1" fill="${LINE}"/>` +
258 `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" fill="${DARK}" fill-opacity=".92" stroke="${LINE}" stroke-width=".95"/>` +
259 `<rect x="${n(x + 1.8)}" y="${n(y + 1.8)}" width="${w - 3.6}" height="${h - 3.6}" fill="none" stroke="${LINE}" stroke-opacity=".55" stroke-width=".35"/>`
260 )
261 },
262 },
263}
264hooks/styles/cave.ts 202 lines1/**
2 * Cave Painting, after the gallery's "Cave Painting: Lascaux, c. 15,000 BC"
3 * (index.html, style 01, `cave`).
4 *
5 * The art bible, translated from the gallery's wall to the Fables' worlds:
6 *
7 * - There is no sky and no ground, only the wall: torch-lit limestone, pale
8 * and mottled, with its grain, cracks and stains. Everything else is
9 * pigment laid on it.
10 * - Four pigments: red ochre, yellow ochre, soot, and a pale earth for
11 * highlights. Masses are rubbed on flat and thin, so the rock shows through,
12 * far things fainter, near things stronger; their edges are blown, not cut.
13 * - Outlines are soot, drawn with a finger: thick, broken, wavering.
14 * - The sun is a ring of ochre with red at its heart; the moon a pale earth
15 * disc ringed in soot; stars and sparks are dots of red ochre; mist and
16 * light are not painted at all, the bare wall is the light.
17 * - Claude is the gallery's: rubbed in red ochre with paler dabs where the
18 * light falls, a broken soot contour, soot legs, charcoal eyes.
19 * - The chapter is scratched beside tally marks; the caption is daubed on a
20 * smoothed patch of wall, ringed in soot. A torch warms the wall.
21 */
22import type { HeroPainter } from '../hero3d'
23import { type Model, outlinedFaces } from '../clawd3d'
24import { isGreen, isWarm, lum, meanOf, num as n, poly } from '../art/ink'
25import { painter } from '../art/painter'
26import type { Family } from '../art/roles'
27import type { Look } from '../looks'
28
29const SOOT = '#1b130f'
30const OCH = '#a4452a'
31const OCH_L = '#c96b40'
32const OCH_P = '#dc9a6a'
33const OCH_D = '#66241a'
34const YOCH = '#cf8a2e'
35const WALL = '#cdb892'
36const PALE = '#e6d6b2'
37const PIGMENT_FONT = "'Marker Felt', 'Chalkboard SE', 'Comic Neue', 'Trebuchet MS', sans-serif"
38
39/** A lit color as pigment: soot for the dark, ochres for warm and middle tones, pale earth for light. */
40function pigment(color: string, family: Family): string {
41 const l = lum(color)
42 if (family === 'fire' || family === 'lamp') return l > 0.7 ? YOCH : OCH
43 if (family === 'stars' || family === 'life') return OCH
44 if (l > 0.72) return PALE
45 if (l < 0.18) return SOOT
46 if (isGreen(color)) return l < 0.35 ? '#3b2a1e' : '#6a5a3a'
47 if (isWarm(color)) return l > 0.45 ? YOCH : l > 0.3 ? OCH_L : OCH
48 return l < 0.32 ? '#3b2a1e' : l < 0.5 ? OCH_D : '#8a6a4a'
49}
50
51/** How thick the pigment is rubbed on: far things faint on the rock, near things strong. */
52const strength = (depth: number) => 0.38 + depth * 0.5
53
54const scenery = () =>
55 painter(
56 {
57 ink: (color, family, _depth, attr) => (attr === 'stroke' ? SOOT : pigment(color, family)),
58 // A radial gradient is a pool of light: the bare wall is the light, so it is not painted.
59 url: (_id, family, attr, g) => (attr === 'stroke' ? SOOT : g && (meanOf(g).opacity < 0.5 || g.tag === 'radialGradient') ? 'none' : pigment(g ? meanOf(g).color : '#808080', family)),
60 // The soot line: thick, broken in places, wavering with the finger that drew it.
61 line: (family, depth) =>
62 family === 'sky' || family === 'stars' || family === 'life' || family === 'lens' || family === 'air' || family === 'beam' || family === 'glow' || family === 'fire'
63 ? ''
64 : `stroke="${SOOT}" stroke-width="${n(0.5 + depth * 0.9)}" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="${n(14 + depth * 10)} ${n(1.5 + depth)}" stroke-opacity=".8"`,
65 // Rubbed thin: the rock shows through every mass, but rock, hills and buildings are laid on thick enough to hide what stands behind them.
66 post: (svg, family, depth) => `<g opacity="${n(family === 'rock' || family === 'land' || family === 'built' ? 0.92 : strength(depth))}">${svg}</g>`,
67 lineless: 0.6,
68 faint: 'hide',
69 // A pool of lamplight is a smear of yellow ochre rubbed into the wall.
70 pool: e =>
71 `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx)}" ry="${n(e.ry)}" fill="${YOCH}" opacity=".55"/>` +
72 `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx * 0.5)}" ry="${n(e.ry * 0.5)}" fill="${OCH_P}" opacity=".7"/>`,
73 redraw: {
74 sky: () => '',
75 lens: () => '',
76 glow: () => '',
77 air: () => '',
78 beam: () => '',
79 // The Milky Way as a spray of red dots, its band not painted.
80 'space.milkyway': (svg, c) => c.repaint(svg.replace(/<ellipse[^>]*\/>/, '')),
81 'forest.sun': (_s, c) => sun(c.meta.cx ?? 0, c.meta.cy ?? 0, (c.meta.r ?? 7) * 1.5, c.meta.part),
82 'desert.sun': (_s, c) => sun(c.meta.cx ?? 0, c.meta.cy ?? 0, (c.meta.r ?? 11) * 1.2, c.meta.part),
83 'night.moon': (_s, c) =>
84 `<circle cx="${n(c.meta.cx ?? 0)}" cy="${n(c.meta.cy ?? 0)}" r="${n((c.meta.r ?? 9) * 1.2)}" fill="${OCH_P}" stroke="${SOOT}" stroke-width="1.4" stroke-dasharray="9 2" opacity=".85"/>`,
85 },
86 },
87 'cv',
88 )
89
90/** The sun as a cave painter would make it: an ochre ring with rays, red at its heart. */
91function sun(cx: number, cy: number, r: number, part: unknown): string {
92 if (part === 'halo') return ''
93 let rays = ''
94 for (let i = 0; i < 12; i++) {
95 const a = (i / 12) * Math.PI * 2
96 rays += `M${n(cx + Math.cos(a) * (r + 3))} ${n(cy + Math.sin(a) * (r + 3))}L${n(cx + Math.cos(a) * (r + 7))} ${n(cy + Math.sin(a) * (r + 7))}`
97 }
98 return (
99 `<g opacity=".85"><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="${YOCH}"/><circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 0.45)}" fill="${OCH}"/>` +
100 `<path d="${rays}" stroke="${OCH}" stroke-width="1.6" stroke-linecap="round"/></g>`
101 )
102}
103
104// ---------------------------------------------------------------- Claude
105
106/** Claude rubbed in red ochre: paler where the light falls, a broken soot contour, soot legs, charcoal eyes. */
107const hero = (): HeroPainter => (m: Model, cx: number, floor: number) => {
108 const body = m.parts.filter(p => p.name !== 'leg').map(pt => poly(pt.hull, cx, floor)).join('')
109 const legs = m.parts.filter(p => p.name === 'leg').map(pt => poly(pt.hull, cx, floor)).join('')
110 let dabs = ''
111 for (const f of outlinedFaces(m)) {
112 if (f.part === 'leg') continue
113 const tone = f.light > 0.65 ? OCH_P : f.light > 0.4 ? OCH_L : OCH_D
114 dabs += `<path fill="${tone}" opacity="${n(0.35 + f.light * 0.4)}" d="${poly(f.pts, cx, floor)}"/>`
115 }
116 const eyes = m.eyes
117 .map(e =>
118 e.poly
119 ? `<path fill="${SOOT}" d="${poly(e.poly, cx, floor)}"/>`
120 : `<path fill="none" stroke="${SOOT}" stroke-width="1.3" stroke-linecap="round" d="${poly(e.line ?? [], cx, floor, false)}"/>`,
121 )
122 .join('')
123 return (
124 `<path fill="${SOOT}" opacity=".3" stroke="${SOOT}" stroke-width="1.6" stroke-linejoin="round" d="${legs}"/>` +
125 `<path fill="${SOOT}" opacity=".85" d="${legs}"/>` +
126 // The soot contour goes on first and the ochre over it, so only its outer half shows: one line round the whole figure.
127 `<path fill="none" stroke="${SOOT}" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="11 2.5 6 2" d="${body}"/>` +
128 `<path fill="${OCH}" d="${body}"/>` +
129 `<g filter="url(#cv-blow)">${dabs}</g>` +
130 eyes +
131 // A grain of the rock over the paint.
132 `<path fill="url(#cv-pits)" d="${body}"/>`
133 )
134}
135
136// ---------------------------------------------------------------- the wall
137
138const escape = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
139
140export const CAVE: Look = {
141 name: 'cave',
142 label: 'Cave Painting',
143 voice: 'a storyteller by the fire, in short plain words',
144 cell: 'solid',
145 figure: '3d',
146 font: PIGMENT_FONT,
147 charW: 5.4,
148 caption: { fill: PALE, stroke: SOOT, ink: SOOT, radius: 6 },
149 paper: { card: '#e2d2b0', ink: '#2e2119', kinds: { code: '#6a3a1e', path: '#4a3a5a', fn: '#6a3a1e', num: '#8a3220', bad: '#8a3220', good: '#4a5a2a', face: '#8a3220' } },
150 inset: 4,
151 titleColor: SOOT,
152 // The pigments' edges blown and wavering, as if daubed and sprayed on rock.
153 grade: () =>
154 `<feTurbulence type="fractalNoise" baseFrequency=".2" numOctaves="2" seed="3" result="wob"/>` +
155 `<feDisplacementMap in="SourceGraphic" in2="wob" scale="2.2" xChannelSelector="R" yChannelSelector="G"/>`,
156 art: {
157 painter: scenery,
158 hero,
159 sky: WALL,
160 ground: WALL,
161 defs: () =>
162 // The wall: limestone mottled pale, grey and ochre-stained.
163 `<filter id="cv-wall" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency=".012 .02" numOctaves="4" seed="5"/>` +
164 `<feComponentTransfer><feFuncR type="table" tableValues=".67 .84 .93"/><feFuncG type="table" tableValues=".57 .74 .88"/><feFuncB type="table" tableValues=".43 .59 .78"/></feComponentTransfer></filter>` +
165 `<filter id="cv-rock" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency=".035 .05" numOctaves="4" seed="9"/>` +
166 `<feDiffuseLighting surfaceScale="4" diffuseConstant="1.1" lighting-color="#f4ead6"><feDistantLight azimuth="235" elevation="40"/></feDiffuseLighting></filter>` +
167 `<filter id="cv-blow" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation=".8"/></filter>` +
168 `<pattern id="cv-pits" width="7" height="6" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r=".45" fill="#54392a" opacity=".35"/><circle cx="5.4" cy="4.6" r=".35" fill="#fff6e2" opacity=".35"/></pattern>` +
169 `<radialGradient id="cv-torch" cx="0" cy="1" r="1.1"><stop offset="0" stop-color="#ffa050" stop-opacity=".35"/><stop offset=".5" stop-color="#ff8c38" stop-opacity=".12"/><stop offset="1" stop-color="#ff7832" stop-opacity="0"/></radialGradient>` +
170 `<radialGradient id="cv-dark" cx=".5" cy=".5" r=".7"><stop offset=".55" stop-color="#1a0c05" stop-opacity="0"/><stop offset="1" stop-color="#1a0c05" stop-opacity=".5"/></radialGradient>`,
171 // The bare wall, under everything painted on it.
172 under: (sw, h) => `<rect width="${sw}" height="${h}" filter="url(#cv-wall)"/>`,
173 },
174 texture: (sw, h) =>
175 `<rect width="${sw}" height="${h}" filter="url(#cv-rock)" style="mix-blend-mode:multiply" opacity=".55"/>` +
176 `<rect width="${sw}" height="${h}" fill="url(#cv-torch)"/><rect width="${sw}" height="${h}" fill="url(#cv-dark)"/>`,
177 tag: (text, inset) => {
178 const x = inset + 6
179 const y = inset + 5
180 const marks = [0, 1, 2, 3].map(i => `M${n(x + i * 2.4)} ${y}l.4 9`).join('') + `M${x - 1} ${y + 7}l10 -5`
181 return {
182 svg:
183 `<rect x="${x - 4}" y="${y - 3}" width="${n(text.length * 6.2 + 26)}" height="15" rx="6" fill="${PALE}" opacity=".75"/>` +
184 `<path d="${marks}" stroke="${SOOT}" stroke-width="1.2" stroke-linecap="round" fill="none"/>` +
185 `<text x="${x + 14}" y="${y + 8}" font-family="${PIGMENT_FONT}" font-size="8.4" letter-spacing=".6" fill="${SOOT}">${escape(text.toUpperCase())}</text>`,
186 w: x + 14 + text.length * 6.2 + 6,
187 h: y + 14,
188 }
189 },
190 bubble: {
191 font: PIGMENT_FONT,
192 size: 9,
193 charW: 5.4,
194 line: 12,
195 base: 13,
196 draw: ({ x, y, w, h }, { base, tip }) =>
197 // A smoothed patch of wall, its rim a broken soot line; a soot stroke points at Claude.
198 `<path d="M${n((base[0][0] + base[1][0]) / 2)} ${n((base[0][1] + base[1][1]) / 2)}L${n(tip[0])} ${n(tip[1])}" stroke="${SOOT}" stroke-width="1.6" stroke-linecap="round"/>` +
199 `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" rx="7" fill="#e8dcbe" stroke="${SOOT}" stroke-width="1.5" stroke-dasharray="16 2.5 9 2"/>`,
200 },
201}
202hooks/styles/engraving.ts 308 lines1/**
2 * Copperplate Engraving, after the gallery's "Copperplate Engraving: natural
3 * history plate, 18th century" (index.html, style 08, `engraving`).
4 *
5 * The art bible, translated from the gallery's plate to the Fables' worlds:
6 *
7 * - One ink, sepia-black, on laid cream paper. There is no color and no wash:
8 * every tone is cut as lines, close and heavy where it is dark, sparse and
9 * fine where it is light, with a second set crossing them in the shadows
10 * and a third in the deepest.
11 * - Each kind of thing is cut along its own grain, as an engraver would:
12 * skies and water ruled level, growing denser toward the horizon; bark,
13 * facades and grass upright; hills and rock on the slant.
14 * - Light is the bare paper: the sun is a reserved disc with a ring of rays,
15 * the moon a reserved disc, glowing windows and lava left uncut, mist the
16 * paper showing through the lines.
17 * - Every shape is bounded by a firm contour that lightens with distance.
18 * - Claude is cut as the gallery cuts him: each face hatched along its own
19 * axis (front level, sides upright, top on the slant), a heavy contour
20 * round him, solid eyes with a reserved highlight.
21 * - The plate: a pressed plate mark on the sheet, a fine border rule, "Pl. I."
22 * and the chapter in spaced capitals; the caption is an engraved legend.
23 */
24import type { HeroPainter } from '../hero3d'
25import { type Model, outlinedFaces } from '../clawd3d'
26import { lum, meanOf, num as n, poly, t1 } from '../art/ink'
27import { MIST_TILE, mistBanks, tiled } from '../art/mist'
28import { painter, type Ctx } from '../art/painter'
29import type { Family } from '../art/roles'
30import type { Look } from '../looks'
31
32const INK = '#1c1611'
33const PAPER = '#f2ebda'
34const HI = '#f6f0e2'
35const SERIF = "'Big Caslon', 'Caslon', 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif"
36const DIDONE = "Didot, 'Bodoni 72', 'Bodoni MT', 'Iowan Old Style', Georgia, serif"
37
38/** The grain each family is cut along. */
39type Grain = 'h' | 'v' | 'd'
40const GRAIN: Partial<Record<Family, Grain>> = {
41 sky: 'h', cloud: 'h', air: 'h', water: 'h', ground: 'h', mark: 'h', glass: 'h', stars: 'h',
42 bark: 'v', built: 'v', grass: 'v', foliage: 'd',
43 land: 'd', rock: 'd', fire: 'h', lamp: 'h', body: 'h', beam: 'h', glow: 'h', life: 'h', lens: 'h',
44}
45const LEVELS = 6
46const ANGLE: Record<Grain, number> = { h: 0, v: 90, d: -38 }
47
48/** The cut for a tone: lines this far apart, this heavy, crossed this many times. */
49const CUT = [
50 { w: 0, cross: 0 },
51 { w: 0.18, cross: 0 },
52 { w: 0.3, cross: 0 },
53 { w: 0.42, cross: 1 },
54 { w: 0.55, cross: 1 },
55 { w: 0.72, cross: 2 },
56]
57const GAP = 1.7
58
59/** The patterns every tone and grain is cut in, defined once for the plate. */
60function hatches(): string {
61 let out = ''
62 for (const g of ['h', 'v', 'd'] as Grain[]) {
63 for (let k = 1; k < LEVELS; k++) {
64 const c = CUT[k]!
65 // One set of lines, rotated to the grain; the cross sets as their own rotated patterns over it.
66 out +=
67 `<pattern id="eg-${g}${k}" width="8" height="${GAP}" patternUnits="userSpaceOnUse" patternTransform="rotate(${ANGLE[g]})">` +
68 `<rect width="8" height="${GAP}" fill="${PAPER}"/><path d="M0 ${n(GAP / 2)}H8" stroke="${INK}" stroke-width="${c.w}"/></pattern>`
69 if (c.cross) {
70 out +=
71 `<pattern id="eg-${g}${k}x" width="8" height="${n(GAP * 1.15)}" patternUnits="userSpaceOnUse" patternTransform="rotate(${ANGLE[g] + 55})">` +
72 `<rect width="8" height="${n(GAP * 1.15)}" fill="url(#eg-${g}${k})"/><path d="M0 ${n(GAP * 0.55)}H8" stroke="${INK}" stroke-width="${n(c.w * 0.8)}"/></pattern>`
73 }
74 if (c.cross > 1) {
75 out +=
76 `<pattern id="eg-${g}${k}xx" width="8" height="${n(GAP * 1.3)}" patternUnits="userSpaceOnUse" patternTransform="rotate(${ANGLE[g] - 55})">` +
77 `<rect width="8" height="${n(GAP * 1.3)}" fill="url(#eg-${g}${k}x)"/><path d="M0 ${n(GAP * 0.6)}H8" stroke="${INK}" stroke-width="${n(c.w * 0.6)}"/></pattern>`
78 }
79 }
80 }
81 return out
82}
83
84/** The fill that cuts a darkness `t` (0 paper, 1 black) along a grain. */
85function cut(t: number, g: Grain): string {
86 const k = Math.max(0, Math.min(LEVELS - 1, Math.floor(t * LEVELS)))
87 if (k === 0) return PAPER
88 const c = CUT[k]!
89 return `url(#eg-${g}${k}${c.cross > 1 ? 'xx' : c.cross ? 'x' : ''})`
90}
91
92/** How dark a lit color is cut, by family: things that give light are left as paper. */
93function darkness(color: string, family: Family): number {
94 const d = 1 - Math.min(1, Math.pow(lum(color), 0.75) * 1.1)
95 switch (family) {
96 case 'fire':
97 case 'lamp':
98 case 'body':
99 case 'stars':
100 case 'life':
101 return 0
102 case 'sky':
103 case 'cloud':
104 return d * 0.55
105 case 'air':
106 case 'beam':
107 case 'glow':
108 return 0
109 default:
110 return d
111 }
112}
113
114function ink(color: string, family: Family, attr: string): string {
115 if (attr === 'stroke') return family === 'fire' || family === 'lamp' ? PAPER : INK
116 if (attr === 'color') return INK
117 if (attr === 'stop-color') return lum(color) > 0.5 ? PAPER : INK
118 // Lit slivers are not cut: an engraver leaves the paper.
119 if (lum(color) > 0.82 && family !== 'stars' && family !== 'life') return PAPER
120 if (family === 'stars' || family === 'life') return INK
121 return cut(darkness(color, family), GRAIN[family] ?? 'h')
122}
123
124const UNLINED: readonly Family[] = ['sky', 'air', 'beam', 'glow', 'stars', 'life', 'lens', 'water']
125
126/** A sky ruled level, the lines heavier and closer toward the horizon, broken where clouds would be. */
127function ruledSky(c: Ctx, dark: number): string {
128 const sw = c.meta.sw ?? 640
129 const bands = 7
130 let out = ''
131 for (let b = 0; b < bands; b++) {
132 const y0 = 3 + b * 15
133 const k = b / (bands - 1)
134 // A dark sky is cut heavy all the way up; a bright one only toward the horizon.
135 const w = n(Math.min(0.95, 0.12 + dark * 0.55 + (0.2 + dark * 0.35) * k))
136 let d = ''
137 for (let y = y0; y < y0 + 15 && y < 112; y += GAP) d += `M0 ${n(y)}H${sw}`
138 // Long dashes broken at seeded places: the gaps read as cloud.
139 const dash = `${80 + b * 23} ${6 + (b % 3) * 5}`
140 out += `<path d="${d}" stroke="${INK}" stroke-width="${w}" stroke-dasharray="${dash}" stroke-dashoffset="${b * 37}"/>`
141 }
142 return `<rect width="${sw}" height="128" fill="${PAPER}"/>${out}`
143}
144
145/** The sun or moon: a reserved disc, ringed, the sun with its rays. */
146function reserved(c: Ctx, rays: boolean): string {
147 if (c.meta.part === 'halo') return ''
148 const cx = c.meta.cx ?? 0
149 const cy = c.meta.cy ?? 0
150 const r = (c.meta.r ?? 8) * 1.2
151 let d = ''
152 if (rays) for (let i = 0; i < 24; i++) {
153 const a = (i / 24) * Math.PI * 2
154 const r1 = r + 2.2
155 const r2 = r + (i % 2 ? 5 : 8)
156 d += `M${n(cx + Math.cos(a) * r1)} ${n(cy + Math.sin(a) * r1)}L${n(cx + Math.cos(a) * r2)} ${n(cy + Math.sin(a) * r2)}`
157 }
158 return (
159 `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r + (rays ? 9 : 3))}" fill="${PAPER}"/>` +
160 `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" fill="${PAPER}" stroke="${INK}" stroke-width=".5"/>` +
161 (d ? `<path d="${d}" stroke="${INK}" stroke-width=".35"/>` : '')
162 )
163}
164
165const scenery = () =>
166 painter(
167 {
168 ink: (color, family, _depth, attr) => ink(color, family, attr),
169 // A gradient is cut at its overall tone; a stroke is a line of ink.
170 url: (_id, family, attr, g) => (attr === 'stroke' ? INK : g && meanOf(g).opacity < 0.5 ? 'none' : ink(g ? meanOf(g).color : '#808080', family, 'fill')),
171 line: (family, depth) => (UNLINED.includes(family) ? '' : `stroke="${INK}" stroke-width="${n(0.18 + depth * 0.4)}" stroke-linejoin="round"`),
172 lineless: 0.6,
173 faint: 'hide',
174 // A pool of lamplight is left as bare paper, its edge flicked in short strokes of the graver.
175 pool: e =>
176 `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx)}" ry="${n(e.ry)}" fill="${PAPER}" stroke="${INK}" stroke-width=".45" stroke-dasharray="2 1.6"/>` +
177 `<ellipse cx="${n(e.cx)}" cy="${n(e.cy)}" rx="${n(e.rx * 0.6)}" ry="${n(e.ry * 0.6)}" fill="none" stroke="${INK}" stroke-width=".3" stroke-dasharray=".8 2.4"/>`,
178 redraw: {
179 sky: (svg, c) => {
180 const g = c.gradient(svg)
181 return ruledSky(c, g ? 1 - lum(meanOf(g).color) : 0.4)
182 },
183 // The Milky Way as a drift of fine dots, no band.
184 'space.milkyway': (svg, c) => c.repaint(svg.replace(/<ellipse[^>]*\/>/, '')),
185 lens: () => '',
186 // No reflections: the street and the floor stay plain.
187 water: () => '',
188 glow: () => '',
189 'forest.sun': (_s, c) => reserved(c, true),
190 'desert.sun': (_s, c) => reserved(c, true),
191 'night.moon': (_s, c) => reserved(c, false),
192 // Mist is reserved paper, its banks cut round with a fine contour along their tops and a few
193 // level strokes of shade beneath them, as an engraver sculpts cloud.
194 air: (svg, c) => {
195 const banks = mistBanks({ w: Math.min(MIST_TILE, c.meta.sw ?? 640), y: c.meta.y ?? 80, h: c.meta.h ?? 12, seed: 8, rows: (c.meta.h ?? 12) < 12 || c.meta.veil ? 1 : 2, lean: c.meta.lean === true })
196 const shade = banks.map(b => b.under).join('')
197 const art =
198 `<path fill="${PAPER}" stroke="${INK}" stroke-width=".45" stroke-dasharray="40 2 12 1.5" d="${banks.map(b => b.d).join('')}"/>` +
199 `<path fill="none" stroke="${INK}" stroke-width=".35" d="${shade}"/>` +
200 `<path fill="none" stroke="${INK}" stroke-width=".22" stroke-opacity=".7" d="${banks.map(b => b.echo).join('')}"/>`
201 // Drawn for one tile and repeated, the banks drifting as the mist they replace did.
202 return `<g>${tiled(`eg-mist-${Math.round(c.meta.y ?? 80)}-${Math.round(c.meta.h ?? 12)}`, art, c.meta.sw ?? 640)}${c.motion(svg)}</g>`
203 },
204 beam: (svg, c) => `<g opacity=".35">${c.repaint(svg).replace(/\sfill="[^"]*"/g, ` fill="${PAPER}"`)}</g>`,
205 },
206 },
207 'eg',
208 )
209
210// ---------------------------------------------------------------- Claude
211
212/** Claude cut as the gallery cuts him: each face along its own axis, its darkness from the light. */
213const hero = (): HeroPainter => (m: Model, cx: number, floor: number) => {
214 const hulls = m.parts.map(pt => poly(pt.hull, cx, floor)).join('')
215 // Faces cut alike in a row share one path; draw order is kept, so overlaps stay right.
216 let faces = ''
217 let fill = ''
218 let d = ''
219 for (const f of outlinedFaces(m)) {
220 const g: Grain = f.name === 'front' || f.name === 'back' ? 'h' : f.name === 'top' ? 'd' : 'v'
221 const t = Math.min(0.95, (1 - f.light) * 0.85 + (f.part === 'leg' ? 0.25 : 0.08))
222 const c = cut(t, g)
223 if (c !== fill && d) {
224 faces += `<path fill="${fill}" d="${d}"/>`
225 d = ''
226 }
227 fill = c
228 d += poly(f.pts, cx, floor)
229 }
230 if (d) faces += `<path fill="${fill}" d="${d}"/>`
231 const eyes = m.eyes
232 .map(e =>
233 e.poly
234 ? `<path fill="${INK}" d="${poly(e.poly, cx, floor)}"/><circle cx="${t1((e.poly[3]?.[0] ?? 0) + cx)}" cy="${t1((e.poly[3]?.[1] ?? 0) + floor)}" r=".4" fill="${HI}"/>`
235 : `<path fill="none" stroke="${INK}" stroke-width=".9" stroke-linecap="round" d="${poly(e.line ?? [], cx, floor, false)}"/>`,
236 )
237 .join('')
238 // A cross-hatched shadow cast on the ground.
239 return (
240 `<path fill="url(#eg-h4x)" opacity=".7" d="${poly(m.shadow, cx, floor)}"/>` +
241 `<path fill="${PAPER}" stroke="${INK}" stroke-width="1.3" stroke-linejoin="round" d="${hulls}"/>` +
242 `<g stroke="${INK}" stroke-width=".4" stroke-linejoin="round">${faces}</g>` +
243 eyes
244 )
245}
246
247// ---------------------------------------------------------------- the plate
248
249const escape = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
250
251export const ENGRAVING: Look = {
252 name: 'engraving',
253 label: 'Copperplate Engraving',
254 voice: 'a learned 18th-century natural-history plate',
255 cell: 'solid',
256 figure: '3d',
257 font: SERIF,
258 charW: 5.1,
259 caption: { fill: HI, stroke: INK, ink: INK, radius: 0 },
260 paper: { card: HI, ink: INK, kinds: { code: '#3a2a1c', path: '#3a2a1c', fn: '#3a2a1c', num: '#6a2a1a', bad: '#6a2a1a', good: '#2a3a1c', face: '#6a2a1a' } },
261 inset: 6,
262 titleColor: INK,
263 art: {
264 painter: scenery,
265 hero,
266 sky: PAPER,
267 ground: PAPER,
268 defs: () =>
269 hatches() +
270 // Laid paper: fine chain lines and a scatter of fibres and foxing.
271 `<filter id="eg-paper" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency=".7 .9" numOctaves="2" seed="808"/>` +
272 `<feColorMatrix values="0 0 0 0 .35 0 0 0 0 .27 0 0 0 0 .15 -1.5 0 0 0 .95"/></filter>`,
273 },
274 texture: (sw, h) => `<rect width="${sw}" height="${h}" filter="url(#eg-paper)" opacity=".18"/>`,
275 frame: (sw, h) =>
276 // The plate mark: the sheet pressed into the plate, shadowed on two sides and catching light on two.
277 `<rect x="1" y="1" width="${sw - 2}" height="${h - 2}" fill="none" stroke="${PAPER}" stroke-width="3"/>` +
278 `<path d="M2.6 ${h - 2.6}V2.6H${sw - 2.6}" fill="none" stroke="#6a5030" stroke-opacity=".5" stroke-width=".8"/>` +
279 `<path d="M${sw - 2.6} 2.6V${h - 2.6}H2.6" fill="none" stroke="#fffdf6" stroke-width=".8"/>` +
280 `<rect x="5.5" y="5.5" width="${sw - 11}" height="${h - 11}" fill="none" stroke="${INK}" stroke-width=".5"/>`,
281 tag: (text, inset) => {
282 const t = text.toUpperCase()
283 const x = inset + 4
284 const y = inset + 4
285 const w = t.length * 6.6 + 34
286 return {
287 svg:
288 `<rect x="${x}" y="${y}" width="${n(w)}" height="12" fill="${PAPER}"/>` +
289 `<text x="${x + 3}" y="${y + 9}" font-family="${SERIF}" font-style="italic" font-size="8" fill="${INK}">Pl. I.</text>` +
290 `<text x="${x + 27}" y="${y + 9}" font-family="${DIDONE}" font-size="7.5" font-weight="700" letter-spacing="1.6" fill="${INK}">${escape(t)}</text>`,
291 w: x + w + 4,
292 h: y + 15,
293 }
294 },
295 bubble: {
296 font: SERIF,
297 size: 9,
298 charW: 5.1,
299 line: 12,
300 base: 13,
301 draw: ({ x, y, w, h }, { base, tip }) =>
302 // An engraved legend: a paper panel ruled twice, its pointer cut as a fine wedge.
303 `<path fill="${INK}" d="M${n(base[0][0])} ${n(base[0][1])}L${n(tip[0])} ${n(tip[1])}L${n(base[1][0])} ${n(base[1][1])}z"/>` +
304 `<rect x="${n(x)}" y="${n(y)}" width="${w}" height="${h}" fill="${HI}" stroke="${INK}" stroke-width=".6"/>` +
305 `<rect x="${n(x + 1.8)}" y="${n(y + 1.8)}" width="${w - 3.6}" height="${h - 3.6}" fill="none" stroke="${INK}" stroke-width=".25"/>`,
306 },
307}
308