An animated effort bar for Claude Code: Clawd casts Avada Kedavra at six ranks, one per effort level from low to ultracode, with Voldemort looming behind.

English · 中文
A Claude Code mod that puts an animated bar above the prompt. Clawd raises a wand and casts the Killing Curse at the strength of the session's effort level. There is one rank for each position of the effort picker, and every rank adds layers on top of the one below it. Voldemort watches from the smoke behind. At the top rank, Clawd becomes the Dark Lord.

| Rank | Effort | What this rank adds |
|---|---|---|
| I | low | Green sparks spit from the wand and the curse fizzles. Voldemort is little more than two red eyes. |
| II | medium | A thin, crooked bolt that reaches halfway. |
| III | high | The bolt reaches its target, with shockwave rings, green smoke and a flash. Voldemort's face lights up in the flashes. |
| IV | xhigh | Arcs coil around the bolt, Clawd's eyes turn green, and the screen shakes. |
| V | max | A thicker, pulsing bolt, forked lightning from the sky, and stronger flash and shake. |
| VI | ultracode | The jet holds for most of the cast, strobing lightning, a burning frame, and Clawd turned Dark Lord: pale green skin, a black robe and Voldemort's bone-white wand. |
In every rank the bolt gets thicker, more crooked and more forked, and the flash, shake and hold last longer.

It runs in the terminal too, drawn in the half-block pixels of Clawd's own logo:

max from ultracode. Neither of them shows up in the effort field that plugins can read.Raster./spell: list the ranks and the current state./spell 1…/spell 6, /spell IV, /spell max, /spell ultracode: pin a rank so you can look at it./spell auto: follow the effort again./spell off and /spell on: hide or show the bar./spellbook: open a pane with all six ranks playing side by side.You need a Claude Code build with function-hook mods (2.1.286 or newer), in the desktop app's Code tab or in the terminal.
From the marketplace:
/plugin marketplace add powerofjinbo/claude-code-spell-bar
/plugin install spell-bar@claude-code-spell-bar
/reload-plugins
From a clone, loaded into every session (edits to the clone take effect in new sessions):
git clone https://github.com/powerofjinbo/claude-code-spell-bar.git ~/Documents/GitHub/claude-code-spell-bar
Then add this to the env block of ~/.claude/settings.json. Paths are separated by : on macOS and Linux. The variable is only read from your user settings, never from a project's.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/Documents/GitHub/claude-code-spell-bar/plugins/spell-bar"
}
}
For one session only: claude --plugin-dir ./plugins/spell-bar
The plugin API has no effort getter and no event for an effort change, so the bar puts the answer together from two sources:
apply_flag_settings control request, which lands in the session's flag settings, and $.settings.read({ source: 'flag' }) can read those.low, medium, high and xhigh read as themselves.max reads as no level at all. It is recognised by a level disappearing right after the picker had set one.ultracode is its own boolean. The desktop sends it as { effortLevel: "xhigh", ultracode: true }.turn.step). A request carries the effort it was sent with. This fills in what the flags cannot tell, such as max or the effort a session was launched with.There is one limitation. A new session receives its starting effort as a launch flag, not through the flag settings. If the session's flags already hold ultracode: false when it starts and the first pick is max, nothing changes that the plugin can see, so the bar catches up at the next message.
plugins/spell-bar/
.claude-plugin/plugin.json manifest
hooks/hooks.json -> register.tsx
hooks/register.tsx hooks: band, pane, commands, effort tracking
hooks/spells.ts the six ranks, effort -> rank, picker reading
hooks/scene.ts the animated SVG banner (desktop)
hooks/pixels.ts the half-block pixel animation (terminal)
types/index.d.ts the $.state contract
tests/ `claude plugin test` suite
scripts/render-demo.mjs regenerates docs/demo.gif and docs/terminal.png
claude plugin validate plugins/spell-bar # what the engine will load and refuse
claude plugin test plugins/spell-bar # run the tests against the engine
claude --plugin-dir plugins/spell-bar # try it; saving a file hot-reloads it
node scripts/render-demo.mjs # redraw the demo images (Chrome, Python + Pillow, macOS fonts)
To have an editor type-check the hooks, run /plugin-types in a session. It writes the API declarations into .claude-plugin/types/ (git-ignored), which tsconfig.json points at.
This is an unofficial fan project. Harry Potter, Voldemort and the spell names belong to J.K. Rowling and Warner Bros.; Clawd belongs to Anthropic. The project is not affiliated with or endorsed by any of them. All artwork was drawn from scratch in SVG and pixel code for this project.
一个 Claude Code mod,在输入框上方放一条动画 bar:Clawd 举起魔杖施放阿瓦达索命,威力随当前会话的 effort 档位变化。effort 选择器的每个位置对应一档,每档都在下一档的基础上叠加新效果,伏地魔在背景的烟雾里注视着。到最高档 ultracode,Clawd 会变成伏地魔。
| 档 | effort | 新叠加的效果 |
|---|---|---|
| Ⅰ | low | 杖尖冒出绿色火星,咒语哑火;伏地魔只看得到一双红眼 |
| Ⅱ | medium | 一道细细的锯齿闪电,只射出一半 |
| Ⅲ | high | 闪电射满全程,加冲击环、绿色烟雾和全屏闪光,伏地魔的脸在闪光中显形 |
| Ⅳ | xhigh | 光束外缠绕电弧,Clawd 眼睛变绿,画面震动 |
| Ⅴ | max | 闪电更粗并脉动,天降分叉落雷,闪光和震动更强 |
| Ⅵ | ultracode | 光束持续轰击,多道落雷频闪,边框燃起绿光,Clawd 变身伏地魔:苍白泛绿的脸、黑袍、骨白魔杖 |
功能:
/spell:列出各档和当前状态;/spell 1~6 或 /spell max:固定显示某一档;/spell auto:恢复跟随 effort;/spell off / /spell on:隐藏或显示 bar;/spellbook:打开面板,6 档同时播放。安装:
~/.claude/settings.json 的 env 里设置 CLAUDE_CODE_PLUGIN_DIRS,指向 plugins/spell-bar。之后每个新会话都会自动加载。原理和局限:
max 会被设置格式丢掉,只能通过"刚才的档位消失了"推断出来。ultracode 是一个单独的布尔开关,可以直接读。ultracode: false,第一次切换又正好选 max,插件看不到变化,要等下一条消息才更新。免责声明:本项目是非官方的同人作品。哈利波特、伏地魔和咒语名称归 J.K. Rowling 与华纳兄弟所有,Clawd 归 Anthropic 所有,本项目与上述各方均无关联。所有图像都是用 SVG 和像素代码从零绘制的。采用 MIT 许可证。
hooks/register.tsx 364 lines1import { atom, read, update } from 'claude-code'
2import type { ElementConstructor, EngineInterface, Register, TextProps } from 'claude-code'
3
4import type { Cast } from '../types'
5import { frame, RASTER_ROWS } from './pixels.ts'
6import { scene, sceneBox } from './scene.ts'
7import { levelOf, pickerEffort, spellAt, SPELLS, tierNamed, tierOf } from './spells.ts'
8
9const cast = atom({ plugin: 'spell-bar', key: 'cast' } as const, null)
10const preview = atom({ plugin: 'spell-bar', key: 'preview' } as const, null)
11const isHidden = atom({ plugin: 'spell-bar', key: 'isHidden' } as const, false)
12
13const BOOK = 'spellbook'
14const FPS = 12
15// The desktop measures the band in cells of its code font, about 7.9 CSS pixels
16// each; a little under keeps the banner inside the band. An Svg given no size
17// draws in a 300x150 frame there, so the banner always says its size.
18const PIXELS_PER_COLUMN = 7.8
19// A blit is refused until the engine commits the tree a render hook returned
20// (a Raster drawn for the first time or at a new size), so one refusal does not
21// drop a mount; two seconds of nothing but refusals (a closed pane) does.
22const MISSES = FPS * 2
23
24type Mount = { requestId: string; key: string; tier: number; columns: number; rows: number; misses: number }
25
26function describe(seen: Cast | null): string {
27 if (seen === null) {
28 return 'effort not seen yet'
29 }
30
31 const level =
32 seen.effort === null ? 'no effort setting' : seen.effort === 'ultracode' ? 'ultracode' : `effort ${seen.effort}`
33
34 return `${level} · ${seen.model}`
35}
36
37async function settled<T>(work: Promise<T>): Promise<T | undefined> {
38 try {
39 return await work
40 } catch {
41 return undefined
42 }
43}
44
45// The flag settings, where the desktop's effort picker lands as it moves;
46// undefined when they cannot be read, which says nothing about the picker
47// (`{}` can be a max pick, a refused read is not).
48async function readFlag($: EngineInterface): Promise<Readonly<Record<string, unknown>> | undefined> {
49 return settled($.settings.read({ source: 'flag' }))
50}
51
52async function setEffort($: EngineInterface, effort: string) {
53 const seen = await read($, cast)
54
55 if (seen?.effort === effort) {
56 return
57 }
58
59 const model = await $.session.model()
60 await update($, cast, () => ({ effort, model, source: 'live' }))
61}
62
63async function status($: EngineInterface): Promise<string> {
64 const seen = await read($, cast)
65 const pinned = await read($, preview)
66 const tier = pinned ?? tierOf(seen?.effort ?? null)
67 const rows = SPELLS.map(
68 (spell, i) => `${i === tier ? '›' : ' '} ${spell.rank.padEnd(3)} ${spell.name} ${levelOf(spell)}`,
69 )
70
71 return [
72 "Avada Kedavra ranks, by the main loop's effort:",
73 ...rows,
74 pinned === null ? `Now: ${describe(seen)}.` : `Pinned to rank ${spellAt(pinned).rank}.`,
75 '/spell <1-6|I-VI|low…ultracode> pins a rank · /spell auto follows effort · /spell off hides · /spellbook shows all six',
76 ].join('\n')
77}
78
79function pips(Text: ElementConstructor<TextProps>, tier: number) {
80 return SPELLS.map((spell, i) => (
81 <Text color={i <= tier ? spell.hex : '#4a4a4a'}>{i <= tier ? '✦' : '✧'}</Text>
82 ))
83}
84
85export const register: Register = on => {
86 // The terminal's Rasters on screen, repainted by the clock between redraws.
87 const mounts = new Map<string, Mount>()
88 let ticks = 0
89
90 const remount = (requestId: string, next: Array<Omit<Mount, 'misses'>>) => {
91 for (const [id, mount] of mounts) {
92 if (mount.requestId === requestId) {
93 mounts.delete(id)
94 }
95 }
96
97 for (const mount of next) {
98 mounts.set(`${mount.requestId}/${mount.key}`, { ...mount, misses: 0 })
99 }
100 }
101
102 on('session.start', async ($, e, next) => {
103 await $.command.register({
104 name: 'spell',
105 description: 'Spell bar: list the ranks, pin one, follow the effort again, or hide it',
106 argumentHint: '[1-6 | I-VI | low…ultracode | auto | on | off]',
107 })
108 await $.command.register({
109 name: 'spellbook',
110 description: 'Open all six ranks of the curse in a pane',
111 })
112
113 // The picker moves the flag settings at once; a request says the effort
114 // only when it is sent. Poll the flags and act on a change. `wasSet` says
115 // the flags last showed a level the picker set, so that a level vanishing
116 // from them (max, which the schema drops) can be read as max. Every pick
117 // also carries the ultracode key, so `ultracode: false` first showing up
118 // with no level is a max pick too, the first pick of a session included.
119 // A refused read at start leaves no starting point; the first read that
120 // succeeds becomes it, so unchanged startup flags are never taken for a pick.
121 const flag = await readFlag($)
122 const first = flag === undefined ? undefined : pickerEffort(flag, false)
123 let lastFlag = flag === undefined ? undefined : JSON.stringify(flag)
124 let lastUltracode = flag?.['ultracode']
125 let wasSet = first !== undefined
126
127 if (first !== undefined) {
128 await setEffort($, first)
129 }
130
131 $.clock.every(1500, () => {
132 void readFlag($).then(async now => {
133 if (now === undefined) {
134 return
135 }
136
137 const json = JSON.stringify(now)
138
139 if (json === lastFlag) {
140 return
141 }
142
143 if (lastFlag === undefined) {
144 lastFlag = json
145 lastUltracode = now['ultracode']
146 const start = pickerEffort(now, false)
147 wasSet = start !== undefined
148
149 if (start !== undefined) {
150 await setEffort($, start)
151 }
152
153 return
154 }
155
156 lastFlag = json
157 const isPick = now['ultracode'] === false && lastUltracode !== false
158 lastUltracode = now['ultracode']
159 const effort = pickerEffort(now, wasSet || isPick)
160
161 if (effort !== undefined) {
162 wasSet = effort !== 'max'
163 await setEffort($, effort)
164 }
165 })
166 })
167
168 if ((await $.session.surfaces()).includes('terminal')) {
169 $.clock.every(Math.round(1000 / FPS), () => {
170 ticks += 1
171
172 for (const [id, mount] of mounts) {
173 const cells = frame(mount.tier, mount.columns, mount.rows, ticks / FPS)
174 void $.ui.blit({ requestId: mount.requestId, key: mount.key, cells }).then(result => {
175 if (mounts.get(id) !== mount) {
176 return
177 }
178
179 if (result.deny === undefined) {
180 mount.misses = 0
181 } else if (++mount.misses > MISSES) {
182 mounts.delete(id)
183 }
184 })
185 }
186 })
187 }
188
189 return next(e)
190 })
191
192 // Every model request of the main loop says which effort it asks for.
193 on('turn.step', async function* ($, e, next) {
194 if (e.agentId === undefined) {
195 // The request says what was sent: none for a model that takes no effort
196 // setting, and a level after the engine's own downgrades. The picker only
197 // tells ultracode apart, which goes out as a request at xhigh or max.
198 // A refused read keeps what is already shown.
199 const step = e.effort === undefined ? null : String(e.effort)
200 const seen = await read($, cast)
201 const isHigh = step === 'xhigh' || step === 'max'
202 const flags = isHigh ? await readFlag($) : {}
203 const isUltra = isHigh && (flags === undefined ? seen?.effort === 'ultracode' : pickerEffort(flags, false) === 'ultracode')
204 const effort = isUltra ? 'ultracode' : step
205
206 if (seen === null || seen.effort !== effort || seen.model !== e.model || seen.source !== 'turn') {
207 await update($, cast, () => ({ effort, model: e.model, source: 'turn' }))
208 }
209 }
210
211 return yield* next(e)
212 })
213
214 on('command.run', { command: 'spell' }, async ($, e) => {
215 const arg = e.args.trim().toLowerCase()
216
217 if (arg === '') {
218 return { text: await status($) }
219 }
220
221 if (arg === 'off' || arg === 'hide') {
222 await update($, isHidden, () => true)
223
224 return { text: 'Spell bar hidden; /spell on brings it back.' }
225 }
226
227 if (arg === 'on' || arg === 'show') {
228 await update($, isHidden, () => false)
229
230 return { text: 'Spell bar shown.' }
231 }
232
233 if (arg === 'auto') {
234 await update($, preview, () => null)
235 await update($, isHidden, () => false)
236
237 return { text: 'Spell bar follows the effort again.' }
238 }
239
240 const tier = tierNamed(arg)
241
242 if (tier === null) {
243 return { text: `No rank "${arg}". Try 1-6, I-VI, an effort level, auto, on or off.` }
244 }
245
246 await update($, preview, () => tier)
247 await update($, isHidden, () => false)
248
249 return { text: `Pinned rank ${spellAt(tier).rank}; /spell auto follows the effort again.` }
250 })
251
252 on('command.run', { command: 'spellbook' }, async $ => {
253 const opened = await $.ui.open({ id: BOOK, title: 'Spellbook' })
254
255 return { text: opened.isPlaced ? 'Spellbook open.' : `The spellbook waits for room: ${opened.reason}` }
256 })
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 if (e.props.hasSurvey || (await read($, isHidden))) {
260 remount(e.requestId, [])
261
262 return next(e)
263 }
264
265 const seen = await read($, cast)
266 const pinned = await read($, preview)
267 const tier = pinned ?? tierOf(seen?.effort ?? null)
268 const spell = spellAt(tier)
269 const detail = pinned === null ? describe(seen) : 'pinned · /spell auto follows effort'
270
271 if (e.surface === 'terminal') {
272 const { Box, Text, Raster } = $.ui.resolve(e)
273 const isWide = e.props.bodyColumns >= 72
274 const columns = isWide
275 ? Math.min(56, e.props.bodyColumns - 38)
276 : Math.max(16, Math.min(56, e.props.bodyColumns - 2))
277 remount(e.requestId, [{ requestId: e.requestId, key: 'spell', tier, columns, rows: RASTER_ROWS }])
278
279 return (
280 <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 2 : 0}>
281 <Raster key="spell" columns={columns} rows={RASTER_ROWS} cells={frame(tier, columns, RASTER_ROWS, ticks / FPS)} />
282 <Box flexDirection="column">
283 <Text color={spell.hex} bold>
284 {spell.name} {spell.rank}
285 </Text>
286 <Text dimColor wrap="truncate-end">
287 {detail}
288 </Text>
289 <Text>{pips(Text, tier)}</Text>
290 </Box>
291 </Box>
292 )
293 }
294
295 const { Box, Svg } = $.ui.resolve(e)
296 const room = e.props.bodyColumns * PIXELS_PER_COLUMN
297 const box = sceneBox(room)
298
299 return (
300 <Box>
301 <Svg
302 source={scene(tier, { width: room, detail })}
303 alt={`Clawd casts ${spell.name} ${spell.rank} (${detail})`}
304 width={box.width}
305 height={box.height}
306 isInteractive
307 />
308 </Box>
309 )
310 })
311
312 on('ui.render', { component: 'Pane', requestId: BOOK }, async ($, e) => {
313 const seen = await read($, cast)
314 const pinned = await read($, preview)
315 const current = pinned ?? tierOf(seen?.effort ?? null)
316 const caption = (tier: number) => `${levelOf(spellAt(tier))}${tier === current ? ' · now' : ''}`
317
318 if (e.surface === 'terminal') {
319 const { Box, Text, Raster } = $.ui.resolve(e)
320 const columns = Math.max(16, Math.min(60, e.props.bodyColumns - 2))
321 remount(
322 e.requestId,
323 SPELLS.map((_, tier) => ({ requestId: e.requestId, key: `tier-${tier}`, tier, columns, rows: RASTER_ROWS })),
324 )
325
326 return (
327 <Box flexDirection="column" gap={1}>
328 {SPELLS.map((spell, tier) => (
329 <Box flexDirection="column">
330 <Text color={spell.hex} bold={tier === current}>
331 {spell.name} {spell.rank} <Text dimColor>· {caption(tier)}</Text>
332 </Text>
333 <Raster
334 key={`tier-${tier}`}
335 columns={columns}
336 rows={RASTER_ROWS}
337 cells={frame(tier, columns, RASTER_ROWS, ticks / FPS)}
338 />
339 </Box>
340 ))}
341 </Box>
342 )
343 }
344
345 const { Box, Svg } = $.ui.resolve(e)
346 const room = e.props.bodyColumns * PIXELS_PER_COLUMN
347 const box = sceneBox(room)
348
349 return (
350 <Box flexDirection="column" gap={1}>
351 {SPELLS.map((spell, tier) => (
352 <Svg
353 source={scene(tier, { width: room, detail: caption(tier) })}
354 alt={`Clawd casts ${spell.name} ${spell.rank} (${caption(tier)})`}
355 width={box.width}
356 height={box.height}
357 isInteractive
358 />
359 ))}
360 </Box>
361 )
362 })
363}
364hooks/pixels.ts 301 lines1// The terminal's band: the same casts as half-block pixels, two to a cell,
2// with Clawd drawn in the logo's own quadrant glyphs.
3import { seeded, spellAt } from './spells.ts'
4
5export const RASTER_ROWS = 4
6
7type RGB = [number, number, number]
8
9const LOGO = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ ']
10const BODY: RGB = [217, 119, 87]
11const WOOD: RGB = [126, 80, 42]
12const BONE: RGB = [226, 219, 196]
13const WHITE: RGB = [255, 255, 255]
14
15// Wand pixels out from the hand, the last one the tip: at rest, raised, aimed.
16const POSES = {
17 rest: [[0, 0], [1, -1], [2, -1], [3, -2]],
18 up: [[0, -1], [0, -2], [1, -3], [1, -4]],
19 aim: [[0, 0], [1, 0], [2, -1], [3, -1]],
20} as const
21
22function rgb(hex: string): RGB {
23 const value = parseInt(hex.slice(1), 16)
24
25 return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
26}
27
28function mixed(a: RGB, b: RGB, k: number): RGB {
29 return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
30}
31
32function pack(c: RGB): number {
33 const byte = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
34
35 return (byte(c[0]) << 16) | (byte(c[1]) << 8) | byte(c[2])
36}
37
38// A cheap, stable noise for a position: the same pixel twinkles the same way.
39function hash(i: number): number {
40 const x = Math.sin(i * 12.9898) * 43758.5453
41
42 return x - Math.floor(x)
43}
44
45class Canvas {
46 width: number
47 height: number
48 data: Float32Array
49
50 constructor(width: number, height: number) {
51 this.width = width
52 this.height = height
53 this.data = new Float32Array(width * height * 3)
54 }
55
56 at(x: number, y: number): RGB {
57 const i = (y * this.width + x) * 3
58
59 return [this.data[i] ?? 0, this.data[i + 1] ?? 0, this.data[i + 2] ?? 0]
60 }
61
62 mix(x: number, y: number, c: RGB, k: number) {
63 const px = Math.round(x)
64 const py = Math.round(y)
65
66 if (px < 0 || py < 0 || px >= this.width || py >= this.height || k <= 0) {
67 return
68 }
69
70 const i = (py * this.width + px) * 3
71 const a = Math.min(1, k)
72
73 for (let j = 0; j < 3; j++) {
74 const now = this.data[i + j] ?? 0
75 this.data[i + j] = now + ((c[j] ?? 0) - now) * a
76 }
77 }
78
79 add(x: number, y: number, c: RGB, k: number) {
80 const px = Math.round(x)
81 const py = Math.round(y)
82
83 if (px < 0 || py < 0 || px >= this.width || py >= this.height || k <= 0) {
84 return
85 }
86
87 const i = (py * this.width + px) * 3
88
89 for (let j = 0; j < 3; j++) {
90 this.data[i + j] = (this.data[i + j] ?? 0) + (c[j] ?? 0) * k
91 }
92 }
93
94 glow(cx: number, cy: number, radius: number, c: RGB, strength: number) {
95 for (let y = Math.floor(cy - radius); y <= Math.ceil(cy + radius); y++) {
96 for (let x = Math.floor(cx - radius); x <= Math.ceil(cx + radius); x++) {
97 const d = Math.hypot(x - cx, y - cy)
98
99 if (d < radius) {
100 this.add(x, y, c, strength * (1 - d / radius) ** 2)
101 }
102 }
103 }
104 }
105
106 line(x0: number, y0: number, x1: number, y1: number, c: RGB, k: number) {
107 const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
108
109 for (let s = 0; s <= steps; s++) {
110 this.mix(x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, c, k)
111 }
112 }
113
114 wash(c: RGB, k: number) {
115 for (let y = 0; y < this.height; y++) {
116 for (let x = 0; x < this.width; x++) {
117 this.mix(x, y, c, k)
118 }
119 }
120 }
121}
122
123const within = (p: number, from: number, to: number) => p >= from && p < to
124const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
125
126type Scene = { c: Canvas; p: number; time: number; tip: { x: number; y: number }; hex: RGB; core: RGB; glow: RGB }
127
128// The curse at rank `level` (0 for I): every rank keeps the layers below it.
129function avada({ c, p, time, tip, hex, core }: Scene, level: number): number {
130 const green: RGB = [15, 122, 56]
131
132 // Voldemort in the sky: a pale head and red eyes, plainer at every rank.
133 const vx = Math.round(tip.x + (c.width - tip.x) * 0.5)
134 const presence = 0.2 + 0.1 * level
135 for (let y = 0; y < 4; y++) {
136 for (let x = 0; x < 5; x++) {
137 if ((y === 0 || y === 3) && (x === 0 || x === 4)) continue
138 c.mix(vx + x, y, [170, 190, 175], presence)
139 }
140 }
141 for (let y = 4; y < c.height; y++) {
142 for (let x = -2 - (y - 4); x < 7 + (y - 4); x++) {
143 c.mix(vx + x, y, [4, 8, 5], 0.5 + 0.06 * level)
144 }
145 }
146 c.mix(vx + 1, 1, [255, 42, 42], 0.5 + 0.1 * level)
147 c.mix(vx + 3, 1, [255, 42, 42], 0.5 + 0.1 * level)
148
149 // III and up: smoke drifting through the sky.
150 if (level >= 2) {
151 for (let y = 0; y < c.height; y++) {
152 for (let x = 0; x < c.width; x++) {
153 if (Math.sin(x * 0.35 + time * 0.8) * Math.cos(y * 0.9 - time * 0.6) > 0.75 - 0.05 * level) {
154 c.add(x, y, green, 0.12 + 0.04 * level)
155 }
156 }
157 }
158 }
159
160 // I: sparks spit from the tip.
161 if (within(p, 0.19, 0.32)) {
162 const k = (p - 0.19) / 0.13
163 for (let i = 0; i < 3 + level; i++) {
164 const a = hash(i + 17) * Math.PI * 2
165 c.add(tip.x + Math.cos(a) * (1 + 4 * k), tip.y + Math.sin(a) * (1 + 2 * k), core, 1 - k)
166 }
167 }
168
169 const hold = [0.3, 0.4, 0.46, 0.52, 0.6, 0.86][level] ?? 0.5
170 let light = 0
171
172 // II: the jet, half way; full length from III, thicker with every rank.
173 if (level >= 1 && within(p, 0.2, hold + 0.04)) {
174 const reach = clamp01((p - 0.2) / 0.025) * (level === 1 ? 0.45 : 1)
175 const fade = p < hold ? 1 : 1 - (p - hold) / 0.04
176 const end = tip.x + (c.width - 1 - tip.x) * reach
177 const pulse = level >= 4 && Math.floor(time * 18) % 2 === 0 ? 1 : 0
178
179 for (let x = tip.x; x <= end; x++) {
180 if (level >= 3) {
181 c.mix(x, tip.y - 1, hex, (0.35 + 0.1 * level + 0.15 * pulse) * fade)
182 c.mix(x, tip.y + 1, hex, (0.35 + 0.1 * level + 0.15 * pulse) * fade)
183 }
184 const rushing = level >= 4 && (((x - time * 40) % 7) + 7) % 7 < 2
185 c.mix(x, tip.y, rushing ? WHITE : level >= 2 ? core : hex, fade)
186 }
187
188 c.glow(end, tip.y, 2 + level, hex, fade)
189 light = (0.2 + 0.1 * level) * fade
190 }
191
192 // IV and up: lightning around the jet; V and up: strikes from the sky.
193 if (level >= 3 && within(p, 0.21, hold)) {
194 const rand = seeded(Math.floor(time * 12) + 31)
195 for (let bolt = 0; bolt < level - 2; bolt++) {
196 let y = tip.y
197 for (let x = tip.x + 2; x < c.width - 2; x++) {
198 y = Math.max(0, Math.min(c.height - 1, y + Math.round(rand() * 2 - 1)))
199 if (rand() < 0.6) c.add(x, y, hex, 0.5)
200 }
201 }
202 if (level >= 4 && rand() < (level === 5 ? 0.5 : 0.25)) {
203 let x = tip.x + 6 + Math.floor(rand() * (c.width - tip.x - 10))
204 for (let y = 0; y < c.height; y++) {
205 x += Math.round(rand() * 2 - 1)
206 c.mix(x, y, core, 0.9)
207 }
208 }
209 }
210
211 // The flash: brighter with every rank, a strobe at VI.
212 const flash = [0.05, 0.12, 0.26, 0.42, 0.6, 0.8][level] ?? 0.4
213 if (level === 5 && within(p, 0.21, hold)) {
214 c.wash(hex, Math.floor(time * 10) % 2 === 0 ? flash * 0.6 : 0.08)
215 } else if (within(p, 0.21, 0.26)) {
216 c.wash(hex, flash * (1 - (p - 0.21) / 0.05))
217 }
218
219 return light
220}
221
222function base64(bytes: Uint8Array): string {
223 const native = (bytes as unknown as { toBase64?: () => string }).toBase64
224
225 if (typeof native === 'function') {
226 return native.call(bytes)
227 }
228
229 let text = ''
230
231 for (let i = 0; i < bytes.length; i++) {
232 text += String.fromCharCode(bytes[i] ?? 0)
233 }
234
235 return btoa(text)
236}
237
238// One frame of `tier`'s cast at `time` seconds, packed as Raster cells.
239export function frame(tier: number, columns: number, rows: number, time: number): string {
240 const spell = spellAt(tier)
241 const height = rows * 2
242 const c = new Canvas(columns, height)
243 const p = (time % spell.seconds) / spell.seconds
244 const top = rows - 3
245 const hand = { x: 10, y: (top + 1) * 2 }
246 const sky0 = rgb(spell.sky[0])
247 const sky1 = rgb(spell.sky[1])
248
249 for (let y = 0; y < height; y++) {
250 const shade = mixed(sky0, sky1, y / Math.max(1, height - 1))
251 for (let x = 0; x < columns; x++) {
252 c.mix(x, y, shade, 1)
253 }
254 }
255
256 const aim = POSES.aim[POSES.aim.length - 1] ?? [0, 0]
257 const tip = { x: hand.x + aim[0], y: hand.y + aim[1] }
258 const hex = rgb(spell.hex)
259 const core = rgb(spell.core)
260 const glow = rgb(spell.glow)
261 const light = avada({ c, p, time, tip, hex, core, glow }, Math.max(0, Math.min(5, tier)))
262
263 // Rank VI is Clawd turned Dark Lord: pale green head, black robe, red eyes,
264 // and Voldemort's bone-white wand.
265 const isDark = tier >= 5
266 const pose = p < 0.1 ? POSES.rest : p < 0.19 ? POSES.up : p < 0.88 ? POSES.aim : POSES.rest
267 pose.forEach(([dx, dy], i) => {
268 c.mix(hand.x + dx, hand.y + dy, i === pose.length - 1 ? core : isDark ? BONE : WOOD, 1)
269 })
270 const end = pose[pose.length - 1] ?? [0, 0]
271 const charge = p < 0.19 ? 0.3 + p * 3.5 : p < 0.3 ? 1 : 0.35
272 c.glow(hand.x + end[0], hand.y + end[1], 3, hex, 0.6 * charge)
273
274 const body = pack(isDark ? mixed([200, 240, 210], hex, light * 0.3) : mixed(BODY, hex, light * 0.3))
275 const robe = pack([30, 51, 38])
276 const words = new Uint32Array(columns * rows * 3)
277
278 for (let r = 0; r < rows; r++) {
279 for (let x = 0; x < columns; x++) {
280 const up = c.at(x, 2 * r)
281 const down = c.at(x, 2 * r + 1)
282 const glyph = LOGO[r - top]?.[x - 1] ?? ' '
283 const i = (r * columns + x) * 3
284
285 if (glyph === ' ') {
286 words[i] = 0x2580
287 words[i + 1] = pack(up)
288 words[i + 2] = pack(down)
289 } else {
290 const row = r - top
291 const isEye = row === 0 && (x === 3 || x === 7)
292 words[i] = glyph.codePointAt(0) ?? 0x2588
293 words[i + 1] = isDark && row > 0 ? robe : body
294 words[i + 2] = isDark && isEye ? pack([255, 42, 42]) : pack(mixed(up, down, 0.5))
295 }
296 }
297 }
298
299 return base64(new Uint8Array(words.buffer))
300}
301hooks/scene.ts 616 lines1// The desktop's banner: an animated SVG of Clawd casting the Killing Curse at
2// one of six ranks. Each rank keeps every layer of the rank below and adds
3// its own, and everything that can grow (reach, width, flash, shake, sparks,
4// how long the jet holds) grows with the rank. It all moves by CSS and SMIL
5// inside the SVG, so nothing redraws while it plays.
6import { seeded, spellAt, SPELLS, type Spell } from './spells.ts'
7
8export const SCENE_HEIGHT = 128
9
10type Point = { x: number; y: number }
11
12type Stage = {
13 spell: Spell
14 // 0 for rank I up to 5 for rank VI.
15 level: number
16 W: number
17 // One cast, in seconds.
18 D: number
19 // The curse's room: from the wand tip to short of the title.
20 zx0: number
21 zx1: number
22 rand: () => number
23}
24
25type Layer = {
26 css: string
27 defs: string
28 back: string
29 front: string
30 flash: string
31 eyes: boolean
32 shake: boolean
33}
34
35const H = SCENE_HEIGHT
36
37// Clawd as the logo draws him (▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝): quadrant pixels,
38// twice as tall as wide.
39const U = 6
40const V = 12
41const X0 = 26
42const GROUND = 118
43const Y0 = GROUND - 5 * V
44const PIVOT: Point = { x: X0 + 16 * U - 2, y: Y0 + 2.5 * V }
45const WAND = 50
46const AIM = (-14 * Math.PI) / 180
47// Where the wand tip points while the curse leaves it.
48const TIP: Point = {
49 x: PIVOT.x + WAND * Math.cos(AIM),
50 y: PIVOT.y + WAND * Math.sin(AIM),
51}
52
53// Voldemort as the films show him, drawn about a head 58 tall: the bald egg of
54// a skull, a deep V of robe, the black cloth going to smoke at the edges.
55const HEAD =
56 'M0 -28C14 -28 21 -18 21 -4C21 8 15 18 9 25C6 28 3 30 0 30C-3 30 -6 28 -9 25C-15 18 -21 8 -21 -4C-21 -18 -14 -28 0 -28Z'
57const CHEST = 'M-7 22L-9 40L0 64L9 40L7 22Z'
58const ROBE = 'M-8 34C-18 38 -34 42 -48 52C-62 63 -70 90 -78 150L78 150C70 90 62 63 48 52C34 42 18 38 8 34L0 64Z'
59
60// Per rank, I to VI.
61const REACH = [0, 0.45, 1, 1, 1, 1]
62const BEAM = [0, 2.4, 5, 8, 11, 15]
63const HOLD = [0.3, 0.4, 0.46, 0.52, 0.6, 0.86]
64const FLASH = [0.05, 0.12, 0.26, 0.42, 0.6, 0.82]
65const SHAKE = [0, 0, 0, 1.2, 2.2, 3.4]
66
67const n = (value: number) => Math.round(value * 10) / 10
68
69const times = <T>(count: number, make: (index: number) => T): T[] =>
70 Array.from({ length: count }, (_, index) => make(index))
71
72const pct = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`
73
74const delay = (seconds: number) => `animation-delay:${seconds.toFixed(3)}s`
75
76// Shifts an animation so its 0% keyframe lands at fraction `q` of the cast.
77const at = (q: number, D: number) => delay(-(1 - q) * D)
78
79const pick = (list: readonly number[], level: number) => list[level] ?? list[list.length - 1] ?? 0
80
81const escape = (text: string) =>
82 text
83 .replace(/&/g, '&')
84 .replace(/</g, '<')
85 .replace(/>/g, '>')
86 .replace(/"/g, '"')
87
88const textRoom = (W: number) => Math.round(Math.max(210, Math.min(310, W * 0.36)))
89
90// The banner's width for the room it is given, in CSS pixels.
91export function fitWidth(pixels: number): number {
92 return Math.round(Math.max(420, Math.min(2400, pixels)))
93}
94
95// The banner's drawn box for `pixels` of room: the scene laid out at
96// fitWidth(pixels), scaled down (viewBox kept) when the room is narrower than
97// the scene's floor, so it never overflows its slot.
98export function sceneBox(pixels: number): { width: number; height: number } {
99 const W = fitWidth(pixels)
100 const k = Math.min(1, pixels / W)
101
102 return { width: Math.max(1, Math.round(W * k)), height: Math.max(1, Math.round(SCENE_HEIGHT * k)) }
103}
104
105function sparkle(x: number, y: number, r: number): string {
106 const c = `${n(x)} ${n(y)}`
107
108 return `M${n(x)} ${n(y - r)}Q${c} ${n(x + r)} ${n(y)}Q${c} ${n(x)} ${n(y + r)}Q${c} ${n(x - r)} ${n(y)}Q${c} ${n(x)} ${n(y - r)}Z`
109}
110
111// A crooked line from a to b, still at both ends: lightning.
112function jagged(a: Point, b: Point, segments: number, amplitude: number, rand: () => number): Point[] {
113 const dx = b.x - a.x
114 const dy = b.y - a.y
115 const length = Math.hypot(dx, dy) || 1
116
117 return times(segments + 1, i => {
118 const t = i / segments
119 const swing = (rand() - 0.5) * 2 * amplitude * Math.sin(Math.PI * t)
120
121 return { x: a.x + dx * t - (dy / length) * swing, y: a.y + dy * t + (dx / length) * swing }
122 })
123}
124
125const poly = (points: Point[]) => `M${points.map(p => `${n(p.x)} ${n(p.y)}`).join('L')}`
126
127// Keyframes that flicker between `high` and `low` from `from` to `to`, dark outside.
128function flicker(name: string, from: number, to: number, step: number, high: number, low: number): string {
129 const frames = [`0%,${pct(from - 0.01)}{opacity:0}`]
130 let isHigh = true
131
132 for (let p = from; p < to; p += step) {
133 frames.push(`${pct(p)}{opacity:${isHigh ? high : low}}`)
134 isHigh = !isHigh
135 }
136
137 frames.push(`${pct(to + 0.02)},100%{opacity:0}`)
138
139 return `@keyframes ${name}{${frames.join('')}}`
140}
141
142// A screen shake: still until `from`, a jitter until `to`, still again.
143function shake(from: number, to: number, step: number, amplitude: number, rand: () => number): string {
144 const frames = [`0%,${pct(from - 0.002)}{transform:translate(0,0)}`]
145
146 for (let p = from; p < to; p += step) {
147 const x = n((rand() - 0.5) * 2 * amplitude)
148 const y = n((rand() - 0.5) * 1.4 * amplitude)
149 frames.push(`${pct(p)}{transform:translate(${x}px,${y}px)}`)
150 }
151
152 frames.push(`${pct(to)},100%{transform:translate(0,0)}`)
153
154 return `.shake{animation:shake var(--cast) linear infinite}@keyframes shake{${frames.join('')}}`
155}
156
157function clawdRects(x: number, y: number, u: number, v: number): string {
158 // [column, row, width, height] in quadrant pixels: body, arms, four legs.
159 const runs = [
160 [2, 0, 12, 4],
161 [0, 2, 16, 1],
162 [3, 4, 1, 1],
163 [5, 4, 1, 1],
164 [10, 4, 1, 1],
165 [12, 4, 1, 1],
166 ] as const
167
168 return runs
169 .map(
170 ([c, r, w, h]) =>
171 `<rect x="${n(x + c * u)}" y="${n(y + r * v)}" width="${n(w * u)}" height="${n(h * v)}"/>`,
172 )
173 .join('')
174}
175
176function clawdEyes(x: number, y: number, u: number, v: number): string {
177 return [4, 11]
178 .map(c => `<rect x="${n(x + c * u)}" y="${n(y + v)}" width="${n(u)}" height="${n(v)}"/>`)
179 .join('')
180}
181
182function wand(spell: Spell, isBone: boolean): string {
183 const shaft = isBone
184 ? `<path d="M-6 -2.6C-10.5 -3.4 -13.5 -0.4 -12.2 3.2C-11.4 5.2 -8.8 5.6 -8.2 3.6" fill="none" stroke="url(#bone)" stroke-width="2.6" stroke-linecap="round"/>
185<polygon points="-7,-2.8 12,-2.3 ${WAND},-0.8 ${WAND},0.8 12,2.3 -7,2.8" fill="url(#bone)"/>
186<g fill="#ddd5bf" stroke="#8d846c" stroke-width=".5"><ellipse cx="2.5" rx="2.4" ry="3.2"/><ellipse cx="10" rx="2" ry="2.8"/><ellipse cx="22" rx="1.4" ry="1.9"/></g>`
187 : `<polygon points="-6,-2.7 12,-2.3 ${WAND},-0.9 ${WAND},0.9 12,2.3 -6,2.7" fill="url(#wood)"/>
188<rect x="4" y="-3" width="1.6" height="6" fill="#2a160a"/><rect x="8.5" y="-3.1" width="2.2" height="6.2" fill="#2a160a"/>`
189
190 return `<g transform="translate(${n(PIVOT.x)} ${n(PIVOT.y)})"><g class="wand">
191${shaft}
192<g transform="translate(${WAND} 0)"><circle r="9" fill="url(#tipglow)" class="tip"/><circle r="2.1" fill="${spell.core}"/></g>
193</g></g>`
194}
195
196// Rank VI: Clawd turned Dark Lord. Pale green skin, a black robe with a deep V
197// over the body and legs, red slit eyes, snake slits for a nose, a thin
198// downturned mouth, smoke coming off the shoulders and Voldemort's own wand.
199function darkClawd(st: Stage): string {
200 const { spell, rand } = st
201 const px = (c: number) => n(X0 + c * U)
202 const py = (r: number) => n(Y0 + r * V)
203 const mid = X0 + 8 * U
204 const hemTop = Y0 + 4 * V
205 const teeth = times(7, i => {
206 const x = X0 + 1.4 * U + (i / 6) * 13.2 * U
207
208 return `${n(x)},${n(GROUND + (i % 2 === 0 ? 1 : -5))}`
209 }).reverse()
210 const hem = `${px(1.6)},${n(hemTop - 1)} ${px(14.4)},${n(hemTop - 1)} ${teeth.join(' ')}`
211 const robe = `<rect x="${px(2)}" y="${py(2)}" width="${12 * U}" height="${2 * V}"/>
212<rect x="${px(0)}" y="${py(2)}" width="${2 * U}" height="${V}"/><rect x="${px(14)}" y="${py(2)}" width="${2 * U}" height="${V}"/>
213<polygon points="${hem}"/>`
214 const smoke = times(6, () => {
215 const side = rand() < 0.5 ? -1 : 1
216 const seconds = 2.2 + rand() * 2
217
218 return `<ellipse cx="${n(mid + side * (20 + rand() * 30))}" cy="${n(Y0 + 2 * V + rand() * 2 * V)}" rx="${n(5 + rand() * 6)}" ry="${n(4 + rand() * 4)}" class="vsm" style="--x:${n(side * (8 + rand() * 14))}px;animation-duration:${n(seconds)}s;${delay(-rand() * seconds)}"/>`
219 }).join('')
220
221 return `<g class="hop">
222<g class="lit"><ellipse cx="${mid}" cy="${n(Y0 + 2.2 * V)}" rx="70" ry="44" fill="${spell.hex}" fill-opacity=".35" filter="url(#b8)"/></g>
223<ellipse cx="${mid}" cy="${n(Y0 + 2.2 * V)}" rx="58" ry="38" fill="${spell.hex}" fill-opacity=".14" filter="url(#b8)" class="aura"/>
224<g filter="url(#b4)">${smoke}</g>
225<g shape-rendering="crispEdges">
226<g fill="url(#vskin)">${clawdRects(X0, Y0, U, V)}</g>
227<g fill="url(#vcloak)" stroke="${spell.hex}" stroke-opacity=".55" stroke-width="1">${robe}</g>
228<polygon points="${n(mid - 11)},${py(2)} ${n(mid)},${n(Y0 + 2.75 * V)} ${n(mid + 11)},${py(2)}" fill="url(#vskin)"/>
229<path d="M${n(mid - 11)} ${py(2)}L${n(mid)} ${n(Y0 + 2.75 * V)}L${n(mid + 11)} ${py(2)}" stroke="#2c4a36" stroke-width="1.2" fill="none"/>
230<rect x="${px(15)}" y="${py(2)}" width="${U}" height="${V}" fill="url(#vskin)"/>
231<g fill="#050806" class="blink">${clawdEyes(X0, Y0, U, V)}</g>
232</g>
233<g fill="#ff2a2a" filter="url(#g2)" class="veye">${[4, 11].map(c => `<rect x="${n(X0 + c * U + U * 0.33)}" y="${n(Y0 + V * 0.18)}" width="${n(U * 0.34)}" height="${n(V * 0.64)}" rx=".6"/>`).join('')}</g>
234<path d="M${n(mid - 3)} ${n(Y0 + V * 1.2)}l-1 4.2M${n(mid + 3)} ${n(Y0 + V * 1.2)}l1 4.2" stroke="#1d3a26" stroke-width="1.5" stroke-linecap="round"/>
235<path d="M${n(mid - 9)} ${n(Y0 + V * 1.88)}Q${mid} ${n(Y0 + V * 1.72)} ${n(mid + 9)} ${n(Y0 + V * 1.88)}" stroke="#1d3a26" stroke-width="1.3" fill="none" stroke-linecap="round"/>
236${wand(spell, true)}</g>`
237}
238
239function clawd(st: Stage, eyes: boolean): string {
240 if (st.level === 5) {
241 return darkClawd(st)
242 }
243
244 const body = clawdRects(X0, Y0, U, V)
245 const eyeRects = clawdEyes(X0, Y0, U, V)
246 const glowingEyes = eyes
247 ? `<g class="lit"><g fill="${st.spell.hex}" filter="url(#g3)">${eyeRects}</g></g>`
248 : ''
249
250 return `<g class="hop"><g shape-rendering="crispEdges">
251<g fill="#d97757">${body}</g>
252<g class="lit"><g fill="url(#rim)">${body}</g></g>
253<g fill="#1a1210" class="blink">${eyeRects}</g>
254${glowingEyes}
255</g>${wand(st.spell, false)}</g>`
256}
257
258function glowFilter(id: string, blur: number, W: number): string {
259 return `<filter id="${id}" filterUnits="userSpaceOnUse" x="${-W}" y="${-2 * H}" width="${3 * W}" height="${5 * H}"><feGaussianBlur stdDeviation="${blur}" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
260}
261
262function blurFilter(id: string, blur: number, W: number): string {
263 return `<filter id="${id}" filterUnits="userSpaceOnUse" x="${-W}" y="${-2 * H}" width="${3 * W}" height="${5 * H}"><feGaussianBlur stdDeviation="${blur}"/></filter>`
264}
265
266function commonDefs(st: Stage): string {
267 const { W, spell } = st
268
269 return [
270 `<clipPath id="card"><rect width="${W}" height="${H}" rx="12"/></clipPath>`,
271 `<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${spell.sky[0]}"/><stop offset="1" stop-color="${spell.sky[1]}"/></linearGradient>`,
272 `<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${spell.glow}" stop-opacity=".16"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>`,
273 `<radialGradient id="vig" cx=".4" cy=".5" r=".75"><stop offset=".55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".55"/></radialGradient>`,
274 `<linearGradient id="shade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset=".4" stop-color="#000" stop-opacity=".35"/><stop offset="1" stop-color="#000" stop-opacity=".5"/></linearGradient>`,
275 `<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8f5e34"/><stop offset=".5" stop-color="#5b361a"/><stop offset="1" stop-color="#2c180a"/></linearGradient>`,
276 `<radialGradient id="tipglow"><stop offset="0" stop-color="${spell.core}"/><stop offset=".35" stop-color="${spell.hex}" stop-opacity=".85"/><stop offset="1" stop-color="${spell.glow}" stop-opacity="0"/></radialGradient>`,
277 `<radialGradient id="rim" gradientUnits="userSpaceOnUse" cx="${n(TIP.x)}" cy="${n(TIP.y)}" r="130"><stop offset="0" stop-color="${spell.core}" stop-opacity=".95"/><stop offset=".4" stop-color="${spell.hex}" stop-opacity=".5"/><stop offset="1" stop-color="${spell.glow}" stop-opacity="0"/></radialGradient>`,
278 `<linearGradient id="ttl" x1="0" y1="0" x2="0" y2="1"><stop offset=".15" stop-color="${spell.core}"/><stop offset="1" stop-color="${spell.hex}"/></linearGradient>`,
279 `<linearGradient id="bone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3eedf"/><stop offset=".6" stop-color="#d2c8ad"/><stop offset="1" stop-color="#9c9278"/></linearGradient>`,
280 `<linearGradient id="vskin" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d4f5dc"/><stop offset="1" stop-color="#6fbf86"/></linearGradient>`,
281 `<linearGradient id="vcloak" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#24452f"/><stop offset=".5" stop-color="#11241a"/><stop offset="1" stop-color="#08120c"/></linearGradient>`,
282 `<radialGradient id="burst"><stop offset="0" stop-color="${spell.core}"/><stop offset=".3" stop-color="${spell.hex}" stop-opacity=".85"/><stop offset="1" stop-color="${spell.glow}" stop-opacity="0"/></radialGradient>`,
283 glowFilter('g2', 2, W),
284 glowFilter('g3', 3, W),
285 glowFilter('g4', 4, W),
286 blurFilter('b2', 2, W),
287 blurFilter('b4', 4, W),
288 blurFilter('b8', 8, W),
289 ].join('')
290}
291
292// The wand's swing, Clawd's hop and the tip's charge: raised by 16%, flicked
293// at 19%, aimed from 21.5% while the curse plays.
294function commonCss(st: Stage): string {
295 return `:root{--cast:${st.D}s}
296.fb{transform-box:fill-box;transform-origin:center}
297.hop{animation:hop var(--cast) ease-in-out infinite}
298@keyframes hop{0%,11%{transform:translateY(0)}16%{transform:translateY(2px)}20.5%{transform:translateY(-3px)}28%,100%{transform:translateY(0)}}
299.wand{animation:wand var(--cast) cubic-bezier(.3,.7,.3,1) infinite}
300@keyframes wand{0%{transform:rotate(-24deg)}9%{transform:rotate(-30deg)}16%{transform:rotate(-66deg)}19%{transform:rotate(-4deg)}21.5%{transform:rotate(-14deg)}50%{transform:rotate(-12deg)}75%{transform:rotate(-15deg)}88%{transform:rotate(-14deg)}100%{transform:rotate(-24deg)}}
301.tip{transform-box:fill-box;transform-origin:center;animation:tip var(--cast) ease-out infinite}
302@keyframes tip{0%{transform:scale(1);opacity:.55}14%{transform:scale(1.7);opacity:1}19%{transform:scale(2.6);opacity:1}27%{transform:scale(1.3);opacity:.85}100%{transform:scale(1);opacity:.55}}
303.lit{animation:lit var(--cast) linear infinite}
304.blink{transform-box:fill-box;transform-origin:center;animation:blink 4.3s linear infinite}
305@keyframes blink{0%,93%{transform:scaleY(1)}95%{transform:scaleY(.12)}97%,100%{transform:scaleY(1)}}
306.cur{transform-box:fill-box;transform-origin:center;animation:pip 1.6s ease-in-out infinite}
307@keyframes pip{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.25);opacity:1}}
308.ttl{font-family:Luminari,'Apple Chancery',Trattatello,Palatino,Georgia,serif}
309.sub{font-family:'SF Mono',Menlo,Monaco,monospace;font-size:10.5px;letter-spacing:1.4px;fill:rgba(255,255,255,.72)}
310@keyframes spin{to{transform:rotate(360deg)}}
311.bolts path{fill:none;stroke-linejoin:round;stroke-linecap:round}`
312}
313
314// Film lightning: a trunk that meanders every `spacing` pixels, with sharp
315// kinks between, and forks.
316function bolt(a: Point, b: Point, spacing: number, amplitude: number, rand: () => number): Point[] {
317 const length = Math.hypot(b.x - a.x, b.y - a.y)
318 const coarse = jagged(a, b, Math.max(3, Math.round(length / spacing)), amplitude, rand)
319 const fine: Point[] = [a]
320
321 for (let i = 1; i < coarse.length; i++) {
322 fine.push(...jagged(coarse[i - 1] ?? a, coarse[i] ?? b, 4, amplitude * 0.4, rand).slice(1))
323 }
324
325 return fine
326}
327
328function forksOf(trunk: Point[], count: number, reach: number, amplitude: number, rand: () => number): string {
329 const first = trunk[0] ?? { x: 0, y: 0 }
330 const last = trunk[trunk.length - 1] ?? first
331 const heading = Math.atan2(last.y - first.y, last.x - first.x)
332
333 return times(count, () => {
334 const from = trunk[1 + Math.floor(rand() * Math.max(1, trunk.length - 3))] ?? first
335 const turn = heading + (rand() < 0.5 ? -1 : 1) * (0.35 + rand() * 0.75)
336 const length = reach * (0.4 + rand() * 0.6)
337 const to = { x: from.x + Math.cos(turn) * length, y: from.y + Math.sin(turn) * length }
338
339 return poly(jagged(from, to, 3 + Math.floor(rand() * 3), amplitude, rand))
340 }).join('')
341}
342
343// The Dark Lord behind the curse, more of him at every rank: at I only his
344// eyes, at VI a face the flashes keep lighting green.
345function voldemort(st: Stage): { css: string; defs: string; back: string } {
346 const { rand, spell, zx0, zx1, level: L } = st
347 const x = zx0 + (zx1 - zx0) * 0.55
348 const place = `translate(${n(x)} 33) scale(.95)`
349 const presence = [0.16, 0.24, 0.34, 0.46, 0.58, 0.7][L] ?? 0.4
350 const glare = [0.45, 0.55, 0.7, 0.85, 1, 1][L] ?? 1
351 const reveal = [0, 0, 0.22, 0.32, 0.42, 0.55][L] ?? 0
352 const smoke = times(2 + 2 * L, () => {
353 const side = rand() < 0.5 ? -1 : 1
354 const seconds = 3 + rand() * 3
355
356 return `<ellipse cx="${n(side * (34 + rand() * 40))}" cy="${n(50 + rand() * 70)}" rx="${n(10 + rand() * 14)}" ry="${n(6 + rand() * 8)}" class="vsm" style="--x:${n(side * (14 + rand() * 26))}px;animation-duration:${n(seconds)}s;${delay(-rand() * seconds)}"/>`
357 }).join('')
358 const eye = (side: number) =>
359 `<ellipse cx="${side * 8.5}" cy="-2.6" rx="3.2" ry="1.1" transform="rotate(${-side * 14} ${side * 8.5} -2.6)"/>`
360 const socket = (side: number) =>
361 `<ellipse cx="${side * 8.5}" cy="-3" rx="7" ry="4.4" transform="rotate(${-side * 14} ${side * 8.5} -3)"/>`
362 const face = `<path d="${HEAD}" fill="url(#vface)"/>
363<path d="M-16 -9Q-8 -12 -2 -6M16 -9Q8 -12 2 -6" stroke="#000" stroke-opacity=".25" stroke-width="2" fill="none"/>
364<g fill="#121815" fill-opacity=".85">${socket(-1)}${socket(1)}</g>
365<g fill="#000" fill-opacity=".18"><ellipse cx="-13" cy="10" rx="5.5" ry="9"/><ellipse cx="13" cy="10" rx="5.5" ry="9"/></g>
366<path d="M-1.6 -1L-2.4 7.5M1.6 -1L2.4 7.5" stroke="#1b221e" stroke-opacity=".3" stroke-width=".8" fill="none"/>
367<path d="M-3.4 8C-4.6 10.2 -4.8 12 -3.8 13.6M3.4 8C4.6 10.2 4.8 12 3.8 13.6" stroke="#1b221e" stroke-width="1.5" fill="none" stroke-linecap="round"/>
368<path d="M-9 21Q0 18.6 9 21" stroke="#1b221e" stroke-width="1.4" fill="none" stroke-linecap="round"/>
369<path d="M-5 23.4Q0 22.4 5 23.4" stroke="#1b221e" stroke-opacity=".35" stroke-width=".9" fill="none"/>
370<ellipse cx="-6" cy="-19" rx="8" ry="4.5" fill="#fff" fill-opacity=".14"/>`
371
372 return {
373 css: `.vbob{animation:vbob 6s ease-in-out infinite alternate}
374@keyframes vbob{from{transform:translateY(-2px)}to{transform:translateY(3px)}}
375.vsm{fill:#000;fill-opacity:.55;animation:vsm 4s ease-out infinite}
376@keyframes vsm{0%{transform:translate(0,0) scale(.7);opacity:0}25%{opacity:1}100%{transform:translate(var(--x),-18px) scale(1.6);opacity:0}}
377.veye{animation:veye 3s ease-in-out infinite alternate}
378.aura{transform-box:fill-box;transform-origin:center;animation:aura 1.4s ease-in-out infinite alternate}
379@keyframes aura{from{transform:scale(.9);opacity:.6}to{transform:scale(1.12);opacity:1}}
380@keyframes veye{from{opacity:${n(glare * 0.6)}}to{opacity:${glare}}}`,
381 defs: `<linearGradient id="vface" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#d9e3da"/><stop offset=".55" stop-color="#aab6ac"/><stop offset="1" stop-color="#5c6a60"/></linearGradient>
382<linearGradient id="vrobe" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#0d1d13"/><stop offset=".4" stop-color="#050a07"/><stop offset="1" stop-color="#020403"/></linearGradient>
383<radialGradient id="vaura"><stop offset="0" stop-color="#000" stop-opacity=".55"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>`,
384 back: `<g transform="${place}"><g class="vbob">
385<ellipse cx="0" cy="30" rx="120" ry="80" fill="url(#vaura)" fill-opacity="${n(0.3 + 0.12 * L)}"/>
386<g opacity="${presence}">
387<path d="${CHEST}" fill="#8f9c92"/>
388<path d="${ROBE}" fill="url(#vrobe)"/>
389<path d="M-8 34L0 64L8 34M-30 48C-36 70 -40 100 -44 150M30 48C36 70 40 100 44 150" stroke="#1c2a22" stroke-width="1.3" fill="none"/>
390<path d="${ROBE}" fill="none" stroke="${spell.hex}" stroke-opacity="${n(0.12 + 0.05 * L)}" stroke-width="1.5" filter="url(#b2)"/>
391${face}
392</g>
393${reveal > 0 ? `<g class="lit"><path d="${HEAD}" fill="${spell.hex}" fill-opacity="${reveal}" filter="url(#b2)"/></g>` : ''}
394<g fill="#ff3b30" filter="url(#g2)" class="veye">${eye(-1)}${eye(1)}</g>
395<g filter="url(#b4)">${smoke}</g>
396</g></g>`,
397 }
398}
399
400// The curse, rank by rank. `L` is 0 for rank I; every `L >= k` block is a
401// layer that rank k+1 adds and every rank above keeps.
402function avada(st: Stage): Layer {
403 const { W, D, rand, spell, zx0, zx1, level: L } = st
404 const Z = zx1 - zx0
405 const T = { x: TIP.x + (zx1 - 8 - TIP.x) * pick(REACH, L), y: TIP.y - 2 }
406 const body = `M${n(TIP.x)} ${n(TIP.y)}Q${n((TIP.x + T.x) / 2)} ${n(TIP.y - 7)} ${n(T.x)} ${n(T.y)}`
407 const width = pick(BEAM, L)
408 const hold = pick(HOLD, L)
409 const flash = pick(FLASH, L)
410 const lord = voldemort(st)
411 const css: string[] = [lord.css]
412 const back: string[] = [lord.back]
413 const front: string[] = []
414 const flashes: string[] = []
415
416 // How bright the scene is lit: a pulse at the cast, held as long as the jet.
417 css.push(
418 L === 5
419 ? flicker('lit', 0.2, hold, 0.035, 1, 0.7)
420 : `@keyframes lit{0%,20%{opacity:0}22%{opacity:${n(0.4 + 0.12 * L)}}27%{opacity:${n(0.2 + 0.08 * L)}}${pct(hold)}{opacity:${n(0.15 + 0.06 * L)}}${pct(hold + 0.06)},100%{opacity:0}}`,
421 )
422
423 // I: green sparks spit from the tip, and the curse fizzles.
424 const wisps = times(2 + 2 * L, k => `<circle r="${n(2 + rand() * (2 + L * 0.6))}" class="wisp fb" style="${delay(-k * 0.29)}"/>`).join('')
425 const fizzle = times(
426 6 + 3 * L,
427 () =>
428 `<g transform="rotate(${n(-120 + rand() * 240)})"><line x1="3" y1="0" x2="${n(6 + rand() * 5)}" y2="0" class="fiz" style="--d:${n(8 + rand() * (10 + 4 * L))}px;${at(0.195 + rand() * 0.02, D)}"/></g>`,
429 ).join('')
430 css.push(`.wisp{animation:wisp ${n(2.6 - 0.2 * L)}s ease-out infinite}
431@keyframes wisp{0%{transform:translate(0,0) scale(.6);opacity:0}15%{opacity:.55}100%{transform:translate(8px,-${24 + 4 * L}px) scale(1.9);opacity:0}}
432.fiz{animation:fiz var(--cast) ease-out infinite}
433@keyframes fiz{0%{transform:translateX(0);opacity:1}10%,100%{transform:translateX(var(--d));opacity:0}}
434.fl{animation:fl var(--cast) linear infinite}`)
435 front.push(`<g transform="translate(${n(TIP.x)} ${n(TIP.y)})"><g fill="${spell.hex}" filter="url(#b2)">${wisps}</g><g stroke="${spell.core}" stroke-width="1.4" stroke-linecap="round">${fizzle}</g></g>`)
436
437 // II: the jet, a crooked bolt that never holds one shape; full length from
438 // III, and with every rank thicker, more crooked, more forked, faster.
439 if (L >= 1) {
440 const variants = L + 2
441 const period = n(0.36 - 0.03 * L)
442 const spacing = 60 - 6 * L
443 const amplitude = 3 + 2.4 * L
444 const trunk = width * 0.6
445 const reveal = pct(L === 1 ? 0.24 : 0.225)
446 css.push(`.awin{animation:awin var(--cast) linear infinite}
447@keyframes awin{0%,19%{opacity:0}20%{opacity:1}${pct(hold - 0.05)}{opacity:1}${pct(hold)},100%{opacity:0}}
448.after{animation:after var(--cast) linear infinite}
449@keyframes after{0%,22.5%{opacity:0}23%{opacity:1}${pct(hold - 0.05)}{opacity:1}${pct(hold)},100%{opacity:0}}
450.abeam{fill:none;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100 100;animation:abeam var(--cast) cubic-bezier(.2,.8,.3,1) infinite}
451@keyframes abeam{0%,19%{stroke-dashoffset:100}${reveal},100%{stroke-dashoffset:0}}
452.bv{animation:bv ${period}s steps(1) infinite}
453@keyframes bv{0%{opacity:1}${pct(1 / variants)},100%{opacity:0}}
454${L >= 4 ? `.abeam.throb{animation:abeam var(--cast) cubic-bezier(.2,.8,.3,1) infinite,throb .11s ease-in-out infinite alternate}@keyframes throb{from{stroke-width:${n(trunk)}}to{stroke-width:${n(trunk * 1.5)}}}` : ''}`)
455
456 const jets = times(variants, k => {
457 const points = bolt(TIP, T, spacing, amplitude, rand)
458 const d = poly(points)
459 const forks = forksOf(points, L === 1 ? 1 : 2 * L, 10 + 7 * L, 3 + L, rand)
460
461 return `<g class="bv" style="${delay((-k * period) / variants)}">
462<path d="${d}" pathLength="100" stroke="${spell.glow}" stroke-width="${n(trunk * 2.4)}" stroke-opacity=".55" filter="url(#b4)" class="abeam"/>
463<path d="${d}" pathLength="100" stroke="${spell.hex}" stroke-width="${n(trunk)}" class="abeam${L >= 4 ? ' throb' : ''}"/>
464<path d="${d}" pathLength="100" stroke="${spell.core}" stroke-width="${n(Math.max(1, trunk * 0.45))}" class="abeam"/>
465<g class="after bolts"><path d="${forks}" stroke="${spell.hex}" stroke-width="${n(Math.max(0.8, trunk * 0.35))}" filter="url(#g2)"/></g>
466</g>`
467 }).join('')
468 front.push(`<g class="awin">
469<path d="${body}" pathLength="100" stroke="${spell.glow}" stroke-width="${n(width * 2.6)}" stroke-opacity="${n(0.18 + 0.04 * L)}" filter="url(#b8)" class="abeam"/>
470${jets}
471</g>`)
472
473 const sparks = times(
474 4 * L,
475 () =>
476 `<g transform="rotate(${n(rand() * 360)})"><line x1="6" y1="0" x2="${n(10 + rand() * (4 + 2 * L))}" y2="0" class="spk" style="--d:${n(16 + rand() * (10 + 12 * L))}px;${at(0.225 + rand() * 0.04, D)}"/></g>`,
477 ).join('')
478 css.push(`.imp{animation:imp var(--cast) ease-out infinite}
479@keyframes imp{0%,22%{transform:scale(.2);opacity:0}23.5%{transform:scale(1.3);opacity:1}${pct(hold - 0.05)}{transform:scale(1);opacity:.8}${pct(hold + 0.04)},100%{transform:scale(1.6);opacity:0}}
480.spk{animation:spk var(--cast) ease-out infinite}
481@keyframes spk{0%{transform:translateX(0);opacity:1}18%,100%{transform:translateX(var(--d));opacity:0}}`)
482 front.push(`<g transform="translate(${n(T.x)} ${n(T.y)})"><circle r="${12 + 7 * L}" fill="url(#burst)" class="imp fb"/><g stroke="${spell.core}" stroke-width="1.8" stroke-linecap="round">${sparks}</g></g>`)
483 }
484
485 // III: smoke, shockwave rings, a flash over everything.
486 if (L >= 2) {
487 const smoke = times(
488 3 + L,
489 () =>
490 `<ellipse cx="${n((rand() - 0.5) * Z)}" cy="${n((rand() - 0.5) * 70)}" rx="${n(40 + rand() * 60)}" ry="${n(9 + rand() * 12)}" transform="rotate(${n(rand() * 180)})" fill="#0f7a38" fill-opacity="${n(0.1 + 0.04 * L)}" filter="url(#b8)"/>`,
491 ).join('')
492 const rings = times(L - 1, k => `<circle r="12" stroke="${spell.hex}" stroke-width="${n(1.6 + 0.4 * L)}" class="ring fb" style="${at(0.225 + k * 0.035, D)}"/>`).join('')
493 css.push(`.swirl{animation:spin ${60 - 6 * L}s linear infinite}
494.ring{fill:none;animation:ring var(--cast) ease-out infinite}
495@keyframes ring{0%{transform:scale(.2);opacity:1}22%,100%{transform:scale(${2 + L});opacity:0}}`)
496 back.push(`<g transform="translate(${n(zx0 + Z / 2)} 64)"><g class="swirl">${smoke}</g></g>`)
497 front.push(`<g transform="translate(${n(T.x)} ${n(T.y)})">${rings}</g>`)
498 }
499
500 css.push(
501 L === 5
502 ? flicker('fl', 0.21, hold, 0.045, flash, 0.08)
503 : `@keyframes fl{0%,20.5%{opacity:0}22%{opacity:${flash}}25%{opacity:${n(flash * 0.15)}}${L >= 3 ? `29%{opacity:${n(flash * 0.6)}}34%{opacity:.04}` : ''}40%,100%{opacity:0}}`,
504 )
505 flashes.push(`<rect width="${W}" height="${H}" fill="#2bff66" class="fl"/>`)
506
507 // IV: arcs that wrap the jet, green eyes, a shake. More arcs at V and VI.
508 if (L >= 3) {
509 const arcs = times(
510 L - 2,
511 k =>
512 `<path d="${poly(bolt(TIP, T, 40, 9 + 3 * L, rand))}" stroke="${k === 0 ? spell.core : spell.hex}" stroke-width="${n(0.9 + 0.2 * L)}" filter="url(#g2)" class="arc" style="${delay(-k * 0.09)}"/>`,
513 ).join('')
514 css.push(`.arc{animation:arc .27s steps(1) infinite}
515@keyframes arc{0%{opacity:1}45%,100%{opacity:0}}`)
516 front.push(`<g class="after bolts">${arcs}</g>`)
517 css.push(shake(0.205, L === 5 ? hold : 0.32, 0.012, pick(SHAKE, L), rand))
518 }
519
520 // V: forked lightning out of the sky, four strikes at VI.
521 if (L >= 4) {
522 const strikes = L === 5 ? 4 : 1
523 times(strikes, k => {
524 const x = zx0 + Z * (0.15 + rand() * 0.75)
525 const points = bolt({ x: x + (rand() - 0.5) * 60, y: -6 }, { x, y: GROUND - rand() * 30 }, 30, 9, rand)
526 const d = poly(points) + forksOf(points, 3, 26, 4, rand)
527 const q = 0.215 + k * ((hold - 0.27) / Math.max(1, strikes))
528 css.push(`@keyframes strike${k}{0%{opacity:1}1.2%{opacity:.15}2.4%{opacity:.9}4.5%,100%{opacity:0}}.strike${k}{animation:strike${k} var(--cast) linear infinite}`)
529 back.push(`<g class="strike${k} bolts" style="${at(q, D)}"><path d="${d}" stroke="${spell.hex}" stroke-width="5" stroke-opacity=".5" filter="url(#b4)"/><path d="${d}" stroke="${spell.core}" stroke-width="1.5"/></g>`)
530 })
531 }
532
533 // VI: it holds, and the whole bar burns at the edges.
534 if (L >= 5) {
535 flashes.push(`<g class="lit"><rect x="2" y="2" width="${W - 4}" height="${H - 4}" rx="11" fill="none" stroke="${spell.hex}" stroke-width="5" filter="url(#b4)"/></g>`)
536 back.push(`<g class="lit"><ellipse cx="${n(T.x)}" cy="${n(T.y)}" rx="${n(Z * 0.6)}" ry="70" fill="url(#burst)" fill-opacity=".35"/></g>`)
537 }
538
539 return {
540 css: css.join('\n'),
541 defs: lord.defs,
542 back: back.join('\n'),
543 front: front.join('\n'),
544 flash: flashes.join('\n'),
545 eyes: L >= 3,
546 shake: L >= 3,
547 }
548}
549
550function title(st: Stage, detail: string, layer: Layer): string {
551 const { W, spell, level } = st
552 const right = W - 22
553 const room = textRoom(W)
554 const name = `${spell.name} ${spell.rank}`
555 const size = n(Math.max(15, Math.min(34, (room - 8) / (name.length * 0.56))))
556 const gap = 17
557 const pips = SPELLS.map((other, i) => {
558 const x = right - 6 - (SPELLS.length - 1 - i) * gap
559
560 if (i > level) {
561 return `<path d="${sparkle(x, 99, 5.6)}" fill="#fff" fill-opacity=".05" stroke="#fff" stroke-opacity=".28" stroke-width=".8"/>`
562 }
563
564 return `<path d="${sparkle(x, 99, i === level ? 7.6 : 5.6)}" fill="${other.hex}" filter="url(#g2)"${i === level ? ' class="cur"' : ''}/>`
565 }).join('')
566 const words = `x="${right}" y="54" text-anchor="end" font-size="${size}"`
567
568 return `<g${layer.shake ? ' class="shake"' : ''}>
569<text ${words} class="ttl" fill="${spell.glow}" fill-opacity="${n(0.3 + 0.08 * level)}" filter="url(#b4)">${escape(name)}</text>
570<g class="lit"><text ${words} class="ttl" fill="${spell.core}" filter="url(#b8)">${escape(name)}</text></g>
571<text ${words} class="ttl" fill="url(#ttl)" stroke="#000" stroke-opacity=".55" stroke-width="3" paint-order="stroke">${escape(name)}</text>
572<text x="${right}" y="78" text-anchor="end" class="sub">${escape(detail)}</text>
573${pips}
574</g>`
575}
576
577// `width` is the room in CSS pixels; the banner is SCENE_HEIGHT tall, scaled
578// down only when the room is narrower than its 420 px floor.
579export function scene(tier: number, options: { width: number; detail: string }): string {
580 const spell = spellAt(tier)
581 const W = fitWidth(options.width)
582 const box = sceneBox(options.width)
583 const room = textRoom(W)
584 const st: Stage = {
585 spell,
586 level: SPELLS.indexOf(spell),
587 W,
588 D: spell.seconds,
589 zx0: TIP.x + 14,
590 zx1: W - room - 18,
591 rand: seeded(tier * 7919 + W),
592 }
593 const layer = avada(st)
594
595 return `<svg xmlns="http://www.w3.org/2000/svg" width="${box.width}" height="${box.height}" viewBox="0 0 ${W} ${H}">
596<defs>${commonDefs(st)}${layer.defs}<style>${commonCss(st)}
597${layer.css}</style></defs>
598<g clip-path="url(#card)">
599<rect width="${W}" height="${H}" fill="url(#sky)"/>
600<g${layer.shake ? ' class="shake"' : ''}>
601${layer.back}
602<rect y="${GROUND}" width="${W}" height="${H - GROUND}" fill="url(#ground)"/>
603<g class="lit"><ellipse cx="${n(TIP.x + 30)}" cy="${GROUND + 2}" rx="150" ry="10" fill="${spell.glow}" fill-opacity=".5" filter="url(#b4)"/></g>
604<ellipse cx="${X0 + 8 * U}" cy="${GROUND + 1}" rx="46" ry="3.5" fill="#000" fill-opacity=".6"/>
605${clawd(st, layer.eyes)}
606${layer.front}
607</g>
608${layer.flash}
609<rect width="${W}" height="${H}" fill="url(#vig)"/>
610<rect x="${W - room - 46}" width="${room + 46}" height="${H}" fill="url(#shade)"/>
611${title(st, options.detail, layer)}
612<rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="12" fill="none" stroke="${spell.hex}" stroke-opacity="${n(0.15 + 0.07 * st.level)}"/>
613</g>
614</svg>`
615}
616hooks/spells.ts 126 lines1// Six ranks of one curse, weakest first, each adding a layer to the one below:
2// one per position of the effort picker, ultracode the strongest. A model that
3// takes no effort setting, or one not seen yet, shows rank I.
4export type Spell = {
5 id: string
6 name: string
7 rank: string
8 effort: string
9 // Main colour, the glow around it, and the white-hot core.
10 hex: string
11 glow: string
12 core: string
13 // Sky gradient, top to bottom.
14 sky: [string, string]
15 // One cast, in seconds.
16 seconds: number
17}
18
19const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']
20const RANKS = ['I', 'II', 'III', 'IV', 'V', 'VI']
21const HEXES = ['#1f8a44', '#27a852', '#30c45f', '#3ddf6c', '#5cff86', '#9dffbb']
22const SKIES: Array<[string, string]> = [
23 ['#010302', '#050c07'],
24 ['#010402', '#061209'],
25 ['#010503', '#07170c'],
26 ['#010603', '#081d0f'],
27 ['#010804', '#0a2414'],
28 ['#020b05', '#0d2e19'],
29]
30const SECONDS = [3.2, 3.2, 3.4, 3.6, 3.8, 4.4]
31
32export const SPELLS: readonly Spell[] = EFFORTS.map((effort, i) => ({
33 id: effort,
34 name: 'Avada Kedavra',
35 rank: RANKS[i] ?? '',
36 effort,
37 hex: HEXES[i] ?? '#3ddf6c',
38 glow: '#00c853',
39 core: '#ecfff2',
40 sky: SKIES[i] ?? ['#000502', '#04190c'],
41 seconds: SECONDS[i] ?? 3.6,
42}))
43
44export function spellAt(tier: number): Spell {
45 return SPELLS[tier] ?? SPELLS[0]!
46}
47
48export function levelOf(spell: Spell): string {
49 return spell.effort === 'ultracode' ? 'ultracode' : `effort ${spell.effort}`
50}
51
52// A small seeded generator, so a scene comes out the same on every redraw.
53export function seeded(seed: number): () => number {
54 let state = seed >>> 0
55
56 return () => {
57 state = (state + 0x6d2b79f5) >>> 0
58 let t = state
59 t = Math.imul(t ^ (t >>> 15), t | 1)
60 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
61
62 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
63 }
64}
65
66// An integer thinking budget has no named level; these cut-offs are rough.
67const BUDGETS = [8000, 16000, 32000, 64000]
68
69export function tierOf(effort: string | null): number {
70 if (effort === null) {
71 return 0
72 }
73
74 const level = EFFORTS.indexOf(effort)
75
76 if (level >= 0) {
77 return level
78 }
79
80 const budget = Number(effort)
81
82 if (Number.isFinite(budget)) {
83 const below = BUDGETS.findIndex(cut => budget < cut)
84
85 return below < 0 ? 4 : below
86 }
87
88 return 0
89}
90
91// `/spell <arg>`: a number 1-6, a roman rank, or an effort level.
92export function tierNamed(arg: string): number | null {
93 const word = arg.trim().toLowerCase()
94 const index = Number(word)
95
96 if (Number.isInteger(index) && index >= 1 && index <= SPELLS.length) {
97 return index - 1
98 }
99
100 const found = SPELLS.findIndex(spell => spell.effort === word || spell.rank.toLowerCase() === word)
101
102 return found < 0 ? null : found
103}
104
105const NAMED = ['low', 'medium', 'high', 'xhigh']
106
107// The effort picker's position, from the flag settings the desktop applies it
108// as. The settings schema drops "max", so max reads as no level at all, which
109// only means max when the flags just moved off a level the picker set
110// (`wasSet`); at startup the desktop passes the effort as a flag of the
111// process instead. Ultracode is its own boolean, sent with effort xhigh.
112// Undefined when the flags cannot tell.
113export function pickerEffort(flag: Readonly<Record<string, unknown>>, wasSet: boolean): string | undefined {
114 const level = typeof flag['effortLevel'] === 'string' && NAMED.includes(flag['effortLevel']) ? flag['effortLevel'] : undefined
115
116 if (flag['ultracode'] === true && (level === undefined || level === 'xhigh')) {
117 return 'ultracode'
118 }
119
120 if (level !== undefined) {
121 return level
122 }
123
124 return wasSet ? 'max' : undefined
125}
126types/index.d.ts 19 lines1// What the main loop last asked for: `effort` is null for a model that takes none.
2export type Cast = {
3 effort: string | null
4 model: string
5 // `turn` from a model request, `live` from the picker as it moved.
6 source: 'turn' | 'live'
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'spell-bar': {
12 cast: Cast | null
13 // A tier the person pinned with /spell, or null to follow the effort.
14 preview: number | null
15 isHidden: boolean
16 }
17 }
18}
19