A chibi pixel witch who wanders your consoles, follows you between them, and sometimes picks fights

Mods for Claude Code, built on its function-hook plugin API: they draw inside the terminal and react to what Claude is doing.
Installing with an AI assistant: tell Claude Code "Install the pixel-witch mod from https://github.com/Kyubey64/claude-code-mods". The steps it follows are in INSTALL.md.
A chibi pixel-art witch who lives in your terminal.
! when one fails, a happy hop when a reply lands, and a nap when things go quiet.classic (round chibi), starlight (huge floppy hat, glowing star, cloud of lavender hair), moonbroom (rides a broom side-saddle with a cat), bluebell (super-deformed, giant eyes, gem staff, cat on her shoulder), goldie (tall and slender, long golden hair, upright broom).Commands: /witch, /witch skin <name>, /witch summon, /witch fight.
A panel docked on the right while the prompt stays at the bottom:
On narrow or non-fullscreen terminals it falls back to a one-line status bar. Commands: /hud, /usage-ping (ping now), /usage-ping off / on (automatic pings in every console).
Each mod is a plugin folder (.claude-plugin/plugin.json, hooks/, types/). Check one with:
claude plugin validate mods/pixel-witch
claude plugin test mods/pixel-witch
The witch styles are drawn in tools/witch_styles.py, which renders a preview sheet and regenerates mods/pixel-witch/hooks/styles.ts.
hooks/register.tsx 503 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import { ALIVE_MS, FIGHT, SKINS, asWorld, chaosLevel, fightPhase, leaderOf, stepWorld } from './coven'
5import type { Ask, Console, World } from './coven'
6import { HAND_W, REGION_H, REGION_W } from './sprites'
7import type { Chaos, Mood, Skin, WitchPose } from '../types'
8
9const isHidden = atom({ plugin: 'pixel-witch', key: 'isHidden' } as const, false)
10const pose = atom({ plugin: 'pixel-witch', key: 'pose' } as const, {
11 x: 4,
12 up: 0,
13 facing: 1,
14 mood: 'wander',
15 isWalking: false,
16 skin: 'classic',
17 isShown: false,
18} as WitchPose)
19// The transcript row she is drawn from, `<component>:<requestId>`.
20const anchor = atom({ plugin: 'pixel-witch', key: 'anchor' } as const, null as string | null)
21const chaos = atom({ plugin: 'pixel-witch', key: 'chaos' } as const, { level: 0, seed: 1 } as Chaos)
22
23const TICK_MS = 200
24const SYNC_MS = 1000
25const SLEEP_AFTER_TICKS = Math.round(90_000 / TICK_MS)
26// Rows the fullscreen layout keeps under the transcript (prompt, band, footer).
27const BOTTOM_SLOT = 8
28
29type OnScreen = { first: number; last: number; of: number }
30type Site = { onScreen: OnScreen; seq: number }
31
32// What she knows of the screen, from the drawings she has seen.
33const sites = new Map<string, Site>()
34const seqOf = new Map<string, number>()
35let nextSeq = 0
36let isFullscreen = false
37let columns = 80
38let rows = 24
39
40// This console's place in the coven.
41const coven = {
42 dir: '',
43 me: '',
44 activeAt: 0,
45 asks: [] as Ask[],
46 world: undefined as World | undefined,
47 isWorking: false,
48}
49
50function noteSite(key: string, onScreen: OnScreen | null | undefined) {
51 if (!seqOf.has(key)) seqOf.set(key, nextSeq++)
52 if (onScreen) sites.set(key, { onScreen, seq: seqOf.get(key) ?? 0 })
53 else if (onScreen === null) sites.delete(key)
54}
55
56// The lowest row on screen: one cut off by the viewport's bottom edge, else
57// the newest.
58function pickAnchor(): string | null {
59 if (!isFullscreen) return null
60 let best: [string, number] | undefined
61 for (const [key, row] of sites) {
62 const isCut = row.onScreen.last < row.onScreen.of - 1
63 const score = isCut ? Number.MAX_SAFE_INTEGER : row.seq
64 if (best === undefined || score > best[1]) best = [key, score]
65 }
66
67 return best?.[0] ?? null
68}
69
70const viewRows = () => (isFullscreen ? Math.max(REGION_H, rows - BOTTOM_SLOT) : REGION_H)
71
72// Her own region's top row from the top of the view, and the hand's height.
73function handRows(at: WitchPose): number {
74 const witchTop = viewRows() - at.up - REGION_H
75 return Math.min(40, Math.max(5, witchTop + 3))
76}
77
78// Everything she draws over one console: the wreckage of an attack, the
79// hand, and her; `floor` is the site row her feet stand on.
80function overlay($: EngineInterface, e: RenderInput, at: WitchPose, wreck: Chaos, floor: number) {
81 if (e.surface !== 'terminal') return []
82 const { Box, Client } = $.ui.resolve(e)
83 const top = floor - at.up - REGION_H
84 const parts: RenderElement[] = []
85 if (wreck.level > 0) {
86 const height = viewRows()
87 parts.push(
88 <Box position="absolute" top={floor - height} left={0}>
89 <Client
90 key="chaos"
91 module="./chaos-client.tsx"
92 width={columns}
93 height={height}
94 props={{ columns, rows: height, level: wreck.level, seed: wreck.seed }}
95 />
96 </Box>,
97 )
98 }
99 if (at.isShown && at.hand) {
100 const tall = handRows(at)
101 parts.push(
102 <Box position="absolute" top={top - tall + 3 - at.hand.drop} left={at.x + 6}>
103 <Client
104 key="hand"
105 module="./hand-client.tsx"
106 width={HAND_W}
107 height={tall}
108 props={{ rows: tall, isClosed: at.hand.isClosed }}
109 />
110 </Box>,
111 )
112 }
113 if (at.isShown) {
114 parts.push(
115 <Box position="absolute" top={top} left={at.x}>
116 <Client key="witch" module="./witch-client.tsx" width={REGION_W} height={REGION_H} props={at} />
117 </Box>,
118 )
119 }
120
121 return parts
122}
123
124async function site($: EngineInterface, e: RenderInput, pass: () => Promise<RenderElement>) {
125 const key = `${e.component}:${e.requestId}`
126 const props = e.props as { onScreen?: OnScreen | null }
127 noteSite(key, props.onScreen)
128 if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
129 if (e.viewport) rows = e.viewport.rows
130
131 if ((await read($, anchor)) !== key || (await read($, isHidden))) return pass()
132 const at = sites.get(key)
133 const tree = await pass()
134 if (at === undefined || e.surface !== 'terminal') return tree
135 const parts = overlay($, e, await read($, pose), await read($, chaos), at.onScreen.last + 1)
136 if (parts.length === 0) return tree
137 const { Box } = $.ui.resolve(e)
138
139 return (
140 <Box flexDirection="column">
141 {tree}
142 {parts}
143 </Box>
144 )
145}
146
147function parse(text: unknown): unknown {
148 try {
149 return JSON.parse(String(text))
150 } catch {
151 return undefined
152 }
153}
154
155// One heartbeat: tell the coven this console lives (and what it asked for),
156// read the others, and if this console leads, move the world on a tick.
157async function sync($: EngineInterface, skin: Skin | 'random'): Promise<World> {
158 const now = await $.clock.now()
159 if (coven.dir === '') {
160 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
161 coven.dir = `${home.replace(/\\/g, '/')}/.claude/pixel-witch-coven`
162 coven.me = await $.session.id()
163 }
164 const mine: Console = { id: coven.me, seen: now, active: coven.activeAt, asks: coven.asks }
165 await $.fs.write(`${coven.dir}/consoles/${coven.me}.json`, JSON.stringify(mine))
166
167 const consoles: Console[] = []
168 for (const entry of await $.fs.list(`${coven.dir}/consoles`)) {
169 if (!entry.name.endsWith('.json') || now - entry.mtimeMs > ALIVE_MS) continue
170 const heard = parse(await $.fs.read(`${coven.dir}/consoles/${entry.name}`))
171 if (heard && typeof (heard as Console).id === 'string') consoles.push(heard as Console)
172 }
173
174 const worldPath = `${coven.dir}/world.json`
175 const stored = (await $.fs.exists(worldPath)) ? parse(await $.fs.read(worldPath)) : undefined
176 let world = stored === undefined ? (coven.world ?? asWorld(undefined)) : asWorld(stored)
177 if (leaderOf(consoles, now) === coven.me) {
178 const next = stepWorld(world, consoles, now, Math.random, skin)
179 if (JSON.stringify(next) !== JSON.stringify(world)) await $.fs.write(worldPath, JSON.stringify(next))
180 world = next
181 }
182 coven.world = world
183 coven.asks = coven.asks.filter(ask => ask.at > (world.handled[coven.me] ?? 0))
184
185 return world
186}
187
188async function markActive($: EngineInterface) {
189 coven.activeAt = await $.clock.now()
190}
191
192function isSkin(name: string): name is Skin {
193 return (SKINS as string[]).includes(name)
194}
195
196export const register: Register = (on, options) => {
197 const skinOption = String(options.skin ?? 'classic')
198 const configSkin: Skin | 'random' = isSkin(skinOption) ? skinOption : 'random'
199
200 let tick = 0
201 let x = 4
202 let up = 0
203 let facing: 1 | -1 = 1
204 let target = { x: 4, up: 0 }
205 let pause = 0
206 let isHeld = false
207 let activeTools = 0
208 let lastActivity = 0
209 let flash: { mood: Mood; until: number } | undefined
210 // Whether a witch is in this console, and which one.
211 let presence: 'gone' | 'arriving' | 'here' | 'leaving' = 'gone'
212 let witchId = -1
213 let skin: Skin = configSkin === 'random' ? 'classic' : configSkin
214 let isSolo = false
215 let now = 0
216
217 const touch = () => {
218 lastActivity = tick
219 }
220 const startFlash = (mood: Mood, ms: number) => {
221 flash = { mood, until: tick + Math.round(ms / TICK_MS) }
222 touch()
223 }
224 const xMax = () => Math.max(0, columns - REGION_W)
225 const upMax = () => (isFullscreen ? Math.max(0, rows - BOTTOM_SLOT - REGION_H) : 0)
226 const clamp = (n: number, max: number) => Math.min(Math.max(0, Math.round(n)), max)
227
228 function arrive(id: number, as: Skin) {
229 witchId = id
230 skin = as
231 presence = 'arriving'
232 target = { x: Math.floor(Math.random() * (xMax() + 1)), up: 0 }
233 if (isFullscreen) {
234 x = target.x
235 up = upMax() + REGION_H
236 } else {
237 x = -REGION_W
238 up = 0
239 }
240 }
241
242 // What the coven's world means for this console.
243 function apply(world: World) {
244 const mine = world.witches.find(w => w.home === coven.me)
245 const fight = world.fight
246 if (mine) {
247 const isHauledOff = fight?.attacker === mine.id && ['gone', 'over'].includes(fightPhase(fight, now))
248 if (!isHauledOff && (presence === 'gone' || presence === 'leaving' || mine.id !== witchId)) {
249 arrive(mine.id, mine.skin)
250 }
251 skin = mine.skin
252 } else if (presence === 'here' || presence === 'arriving') {
253 presence = 'leaving'
254 }
255 }
256
257 function fightRole(): { phase: ReturnType<typeof fightPhase>; role: 'attacker' | 'victim'; t: number } | undefined {
258 const fight = coven.world?.fight
259 if (!fight || isSolo) return undefined
260 const role = fight.attacker === witchId ? 'attacker' : fight.victim === witchId ? 'victim' : undefined
261 if (role === undefined) return undefined
262
263 return { phase: fightPhase(fight, now), role, t: now - fight.start }
264 }
265
266 function mood(): Mood {
267 if (isHeld) return 'grabbed'
268 const fight = fightRole()
269 if (fight?.role === 'attacker') {
270 if (fight.phase === 'cast') return 'attack'
271 if (fight.phase === 'chaos') return 'cheer'
272 if (fight.phase === 'hand') return 'oops'
273 return 'grabbed'
274 }
275 if (fight?.role === 'victim' && ['cast', 'chaos', 'hand'].includes(fight.phase)) return 'scared'
276 if (flash && tick < flash.until) return flash.mood
277 if (activeTools > 0) return 'cast'
278 if (coven.isWorking) return 'think'
279 if (tick - lastActivity > SLEEP_AFTER_TICKS) return 'sleep'
280 return 'wander'
281 }
282
283 function hidden(): WitchPose {
284 return { x, up, facing, mood: 'wander', isWalking: false, skin, isShown: false }
285 }
286
287 function step(): WitchPose {
288 tick += 1
289 if (presence === 'gone') return hidden()
290
291 if (presence === 'arriving') {
292 if (isFullscreen && up > target.up) {
293 up = Math.max(target.up, up - 2)
294 } else if (!isFullscreen && x < target.x) {
295 facing = 1
296 x = Math.min(target.x, x + 3)
297 } else {
298 presence = 'here'
299 startFlash('cheer', 1200)
300 }
301 return { x, up, facing, mood: 'fall', isWalking: !isFullscreen, skin, isShown: true }
302 }
303
304 if (presence === 'leaving') {
305 if (isFullscreen) up += 2
306 else x += 3
307 if ((isFullscreen && up > upMax() + REGION_H + 2) || (!isFullscreen && x > columns)) presence = 'gone'
308 return { x, up, facing: 1, mood: 'jump', isWalking: false, skin, isShown: presence !== 'gone' }
309 }
310
311 const fight = fightRole()
312 if (fight?.role === 'attacker' && (fight.phase === 'hand' || fight.phase === 'yank')) {
313 const reach = (fight.t - FIGHT.handStart) / (FIGHT.grabAt - FIGHT.handStart)
314 const at: WitchPose = { x, up, facing, mood: mood(), isWalking: false, skin, isShown: true }
315 if (fight.phase === 'hand') {
316 return { ...at, hand: { drop: Math.round((1 - Math.min(1, reach)) * handRows(at)), isClosed: false } }
317 }
318 up += 1
319 return { ...at, up, hand: { drop: 0, isClosed: true } }
320 }
321 if (fight?.role === 'attacker' && fight.phase !== 'cast' && fight.phase !== 'chaos') {
322 presence = 'gone'
323 return hidden()
324 }
325
326 x = clamp(x, xMax())
327 up = clamp(up, upMax())
328 const now = mood()
329 const isAtTarget = x === target.x && up === target.up
330 if (now !== 'wander' || isHeld) {
331 return { x, up, facing, mood: now, isWalking: false, skin, isShown: true }
332 }
333 if (pause > 0) {
334 pause -= 1
335 } else if (isAtTarget) {
336 pause = 10 + Math.floor(Math.random() * 30)
337 // Back to the floor half the time, up the screen the rest.
338 const goUp = Math.random() < 0.5 ? 0 : Math.floor(Math.random() * (upMax() + 1))
339 target = { x: Math.floor(Math.random() * (xMax() + 1)), up: goUp }
340 } else {
341 if (target.x !== x) {
342 facing = target.x > x ? 1 : -1
343 x += facing
344 }
345 if (target.up !== up && tick % 2 === 0) up += target.up > up ? 1 : -1
346 }
347
348 return { x, up, facing, mood: now, isWalking: pause === 0 && !isAtTarget, skin, isShown: true }
349 }
350
351 on('session.start', async ($, e, next) => {
352 await $.command.register({
353 name: 'witch',
354 description: 'The pixel witch: hide, show, skin <name>, summon, fight',
355 })
356 now = await $.clock.now()
357 coven.activeAt = now
358
359 $.clock.every(TICK_MS, () => {
360 const stepped = step()
361 void update($, pose, old => (JSON.stringify(old) === JSON.stringify(stepped) ? old : stepped))
362 const pick = pickAnchor()
363 void update($, anchor, old => (old === pick ? old : pick))
364 })
365
366 $.clock.every(SYNC_MS, () => {
367 void (async () => {
368 now = await $.clock.now()
369 try {
370 apply(await sync($, configSkin))
371 isSolo = false
372 } catch {
373 // No shared folder: she lives in this console alone.
374 isSolo = true
375 if (presence === 'gone') arrive(0, skin)
376 }
377 const fight = coven.world?.fight
378 const victim = fight && coven.world?.witches.find(w => w.id === fight.victim)
379 const level = fight && victim?.home === coven.me && !isSolo ? chaosLevel(fight, now) : 0
380 const rounded = Math.round(level * 20) / 20
381 const seed = fight?.start ?? 1
382 await update($, chaos, old => (old.level === rounded && old.seed === seed ? old : { level: rounded, seed }))
383 })()
384 })
385 $.ui.invalidate('ui.render')
386
387 return next(e)
388 })
389
390 on('command.run', { command: 'witch' }, async ($, e) => {
391 const [verb = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
392 const at = await $.clock.now()
393 await markActive($)
394 if (verb === 'skin') {
395 if (!isSkin(arg)) return { text: `Skins: ${SKINS.join(', ')}.` }
396 coven.asks.push({ kind: 'skin', skin: arg, at })
397 skin = arg
398 return { text: `The witch changes into ${arg}.` }
399 }
400 if (verb === 'summon' || verb === 'fight') {
401 coven.asks.push({ kind: verb, at })
402 return {
403 text:
404 verb === 'summon'
405 ? 'A second witch is called to another console (needs two consoles open).'
406 : 'The witches are spoiling for a fight (needs two consoles open).',
407 }
408 }
409 const wasHidden = await read($, isHidden)
410 const hide = verb === 'hide' ? true : verb === 'show' ? false : !wasHidden
411 await update($, isHidden, () => hide)
412
413 return { text: hide ? 'The witch is hidden in this console.' : 'The witch is back.' }
414 })
415
416 on('ui.message', async ($, e, next) => {
417 if (e.element !== 'witch') return next(e)
418 await markActive($)
419 const data = e.data as { type?: string; x?: number; up?: number }
420 if (data.type === 'hide') {
421 isHeld = false
422 await update($, isHidden, () => true)
423 $.ui.toast('The witch hides in this console. /witch show brings her back.')
424 return {}
425 }
426 if (typeof data.x === 'number') x = clamp(data.x, xMax())
427 if (typeof data.up === 'number') up = clamp(data.up, upMax())
428 if (data.type === 'grab' || data.type === 'drag') {
429 isHeld = true
430 touch()
431 } else if (data.type === 'drop') {
432 isHeld = false
433 target = { x, up }
434 pause = 15
435 startFlash('huff', 2500)
436 }
437 const mood: Mood = isHeld ? 'grabbed' : 'huff'
438 await update($, pose, old => ({ ...old, x, up, mood, isWalking: false }))
439
440 return {}
441 })
442
443 // Typing in a console makes it the active one: the witch follows.
444 on('prompt.edit', async ($, e, next) => {
445 await markActive($)
446
447 return next(e)
448 })
449
450 on('prompt.submit', async ($, e, next) => {
451 await markActive($)
452 coven.isWorking = true
453 touch()
454
455 return next(e)
456 })
457
458 on('tool.call', async ($, e, next) => {
459 activeTools += 1
460 touch()
461 try {
462 const ran = await next(e)
463 if (ran.deny !== undefined || ran.isError) startFlash('oops', 1500)
464 return ran
465 } finally {
466 activeTools = Math.max(0, activeTools - 1)
467 }
468 })
469
470 on('turn.complete', ($, e, next) => {
471 coven.isWorking = false
472 startFlash('cheer', 1800)
473
474 return next(e)
475 })
476
477 // Fullscreen: she rides on the lowest transcript row on screen and paints
478 // over the whole viewport from there.
479 on('ui.render', { component: 'UserMessage' }, ($, e, next) => site($, e, () => next(e)))
480 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => site($, e, () => next(e)))
481 on('ui.render', { component: 'ToolUse' }, ($, e, next) => site($, e, () => next(e)))
482 on('ui.render', { component: 'ToolResult' }, ($, e, next) => site($, e, () => next(e)))
483 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => site($, e, () => next(e)))
484 on('ui.render', { component: 'CommandOutput' }, ($, e, next) => site($, e, () => next(e)))
485 on('ui.render', { component: 'TurnDuration' }, ($, e, next) => site($, e, () => next(e)))
486 on('ui.render', { component: 'InfoNotice' }, ($, e, next) => site($, e, () => next(e)))
487
488 // Main screen: nothing paints outside its own row, so she gets a strip
489 // above the prompt instead.
490 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
491 if (e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
492 columns = e.props.bodyColumns
493 const isQuiet =
494 isFullscreen || e.props.hasSurvey || e.props.maxRows < REGION_H || (await read($, isHidden))
495 if (isQuiet || e.surface !== 'terminal') return next(e)
496
497 const parts = overlay($, e, await read($, pose), await read($, chaos), REGION_H)
498 const { Box } = $.ui.resolve(e)
499
500 return <Box height={REGION_H}>{parts}</Box>
501 })
502}
503hooks/coven.ts 213 lines1// The coven: every console running the witch shares one world through files
2// in a folder all of them read. Each console writes its own heartbeat; the
3// living console with the smallest id leads and alone writes the world:
4// which witch lives in which console, and any fight under way.
5
6import type { Skin } from '../types'
7
8export const SKINS: Skin[] = ['classic', 'starlight', 'moonbroom', 'bluebell', 'goldie']
9
10export type Ask = { kind: 'summon' | 'fight' | 'skin'; skin?: Skin; at: number }
11
12export type Console = { id: string; seen: number; active: number; asks?: Ask[] }
13
14export type Witch = { id: number; skin: Skin; home: string; since: number; leaveAt?: number }
15
16export type Fight = { attacker: number; victim: number; start: number }
17
18// witches[0] follows the person from console to console; a second one, when
19// there is one, keeps to another console.
20export type World = {
21 witches: Witch[]
22 fight?: Fight
23 nextId: number
24 handled: Record<string, number>
25}
26
27export const ALIVE_MS = 6000
28// Per leader tick (one a second): a second witch drops by about every seven
29// minutes while two consoles are open, and two witches fall out about once in
30// ten minutes together.
31export const SUMMON_CHANCE = 1 / 420
32export const FIGHT_CHANCE = 1 / 600
33export const VISIT_MS: [number, number] = [5 * 60_000, 15 * 60_000]
34
35// A fight, in milliseconds from its start.
36export const FIGHT = {
37 castEnd: 3000, // the attacker winds up a spell in her console
38 chaosFull: 8000, // the victim's console is wrecked by here
39 handStart: 12_000, // a hand reaches down for the attacker
40 grabAt: 14_500, // and grabs her: the wreckage clears
41 removeAt: 18_000, // she is hauled off-screen
42 newAt: 20_000, // a new witch drops into her console
43}
44
45export type FightPhase = 'cast' | 'chaos' | 'hand' | 'yank' | 'gone' | 'over'
46
47export function fightPhase(fight: Fight, now: number): FightPhase {
48 const t = now - fight.start
49 if (t < FIGHT.castEnd) return 'cast'
50 if (t < FIGHT.handStart) return 'chaos'
51 if (t < FIGHT.grabAt) return 'hand'
52 if (t < FIGHT.removeAt) return 'yank'
53 if (t < FIGHT.newAt) return 'gone'
54 return 'over'
55}
56
57// How wrecked the victim's console is, 0 to 1.
58export function chaosLevel(fight: Fight, now: number): number {
59 const t = now - fight.start
60 if (t < FIGHT.castEnd) return 0
61 if (t < FIGHT.chaosFull) return (t - FIGHT.castEnd) / (FIGHT.chaosFull - FIGHT.castEnd)
62 if (t < FIGHT.grabAt) return 1
63 if (t < FIGHT.grabAt + 1000) return 1 - (t - FIGHT.grabAt) / 1000
64
65 return 0
66}
67
68export function emptyWorld(): World {
69 return { witches: [], nextId: 1, handled: {} }
70}
71
72export function asWorld(value: unknown): World {
73 const v = value as Partial<World> | undefined
74 if (!v || !Array.isArray(v.witches) || typeof v.nextId !== 'number') return emptyWorld()
75 return { witches: v.witches, fight: v.fight, nextId: v.nextId, handled: v.handled ?? {} }
76}
77
78export function alive(consoles: Console[], now: number): Console[] {
79 return consoles.filter(c => now - c.seen < ALIVE_MS)
80}
81
82export function leaderOf(consoles: Console[], now: number): string | undefined {
83 return alive(consoles, now)
84 .map(c => c.id)
85 .sort()[0]
86}
87
88function pick<T>(list: T[], rand: () => number): T | undefined {
89 return list[Math.floor(rand() * list.length)]
90}
91
92function otherSkin(not: Skin | undefined, rand: () => number): Skin {
93 return pick(
94 SKINS.filter(s => s !== not),
95 rand,
96 ) ?? 'classic'
97}
98
99// One leader tick: the world as it stands after the consoles' heartbeats,
100// their asks, the clock and the dice.
101export function stepWorld(
102 before: World,
103 consoles: Console[],
104 now: number,
105 rand: () => number,
106 skin: Skin | 'random',
107): World {
108 const living = alive(consoles, now)
109 if (living.length === 0) return before
110 const world: World = {
111 ...before,
112 witches: before.witches.map(w => ({ ...w })),
113 handled: { ...before.handled },
114 }
115 const isAlive = (id: string) => living.some(c => c.id === id)
116 const active = [...living].sort((a, b) => b.active - a.active)[0]
117 if (active === undefined) return before
118 const spawn = (home: string, skinOf: Skin, leaveAt?: number): Witch => ({
119 id: world.nextId++,
120 skin: skinOf,
121 home,
122 since: now,
123 ...(leaveAt === undefined ? {} : { leaveAt }),
124 })
125 const firstSkin = skin === 'random' ? otherSkin(undefined, rand) : skin
126 const visitEnd = () => now + VISIT_MS[0] + rand() * (VISIT_MS[1] - VISIT_MS[0])
127 const startFight = () => {
128 const [a, b] = world.witches
129 if (!a || !b || world.fight) return
130 const attackerFirst = rand() < 0.5
131 world.fight = { attacker: (attackerFirst ? a : b).id, victim: (attackerFirst ? b : a).id, start: now }
132 }
133 const summon = () => {
134 const follower = world.witches[0]
135 if (!follower || world.witches.length > 1) return
136 const home = pick(
137 living.filter(c => c.id !== follower.home),
138 rand,
139 )
140 if (home) world.witches.push(spawn(home.id, otherSkin(follower.skin, rand), visitEnd()))
141 }
142
143 // What the consoles asked for since the last tick.
144 for (const c of living) {
145 for (const ask of c.asks ?? []) {
146 if (ask.at <= (world.handled[c.id] ?? 0)) continue
147 world.handled[c.id] = ask.at
148 if (ask.kind === 'skin' && ask.skin && world.witches[0]) world.witches[0].skin = ask.skin
149 if (ask.kind === 'summon') summon()
150 if (ask.kind === 'fight') {
151 if (world.witches.length < 2) summon()
152 startFight()
153 }
154 }
155 }
156
157 if (world.witches.length === 0) world.witches.push(spawn(active.id, firstSkin))
158
159 if (world.fight) {
160 const fight = world.fight
161 if (fightPhase(fight, now) === 'over') {
162 // The attacker is gone; a new witch takes her place in her console.
163 const at = world.witches.findIndex(w => w.id === fight.attacker)
164 const old = world.witches[at]
165 if (old) {
166 const home = isAlive(old.home) ? old.home : active.id
167 const fresh = spawn(home, otherSkin(old.skin, rand), at === 0 ? undefined : visitEnd())
168 world.witches[at] = fresh
169 }
170 world.fight = undefined
171 }
172
173 return world
174 }
175
176 // A witch whose console closed: the follower goes to the active one, a
177 // visitor to some other console or away.
178 const follower = world.witches[0]
179 if (follower && !isAlive(follower.home)) {
180 follower.home = active.id
181 follower.since = now
182 }
183 const visitor = world.witches[1]
184 if (visitor) {
185 const elsewhere = living.filter(c => c.id !== world.witches[0]?.home)
186 const isOver = visitor.leaveAt !== undefined && now >= visitor.leaveAt
187 if (isOver || elsewhere.length === 0) {
188 world.witches.splice(1, 1)
189 } else if (!isAlive(visitor.home) || visitor.home === world.witches[0]?.home) {
190 visitor.home = (pick(elsewhere, rand) ?? elsewhere[0])?.id ?? visitor.home
191 visitor.since = now
192 }
193 }
194
195 // The follower goes where the person is; if the visitor lives there, they
196 // trade consoles.
197 const lead = world.witches[0]
198 if (lead && lead.home !== active.id) {
199 const guest = world.witches[1]
200 if (guest && guest.home === active.id) {
201 guest.home = lead.home
202 guest.since = now
203 }
204 lead.home = active.id
205 lead.since = now
206 }
207
208 if (world.witches.length === 1 && living.length >= 2 && rand() < SUMMON_CHANCE) summon()
209 else if (world.witches.length === 2 && rand() < FIGHT_CHANCE) startFight()
210
211 return world
212}
213hooks/sprites.ts 241 lines1// The witch's pixels. Two pixels stack in each terminal cell (upper and lower
2// half blocks); the sprite sits centered in a REGION_W x REGION_H cell region
3// with room around her for sparkles, bubbles and grumbles. Each skin is its
4// own drawing (styles.ts), not a recolor.
5
6import { STYLES } from './styles'
7import type { Eyes, LegFrame } from './styles'
8import type { Skin } from '../types'
9
10export type { Eyes } from './styles'
11
12export const REGION_W = 24
13export const REGION_H = 13
14const HEIGHT_PX = REGION_H * 2
15// Glyph places are written for a 16-wide sprite at column 2 of a 20 x 12
16// region; they move with the sprite's place in the bigger region.
17const GLYPH_ROW_SHIFT = REGION_H - 12
18
19export type Wand = 'hold' | 'raise' | 'none'
20export type Legs = LegFrame | 'sit'
21
22export type Glyph = { col: number; row: number; text: string; color: string }
23
24export type Scene = {
25 skin: Skin
26 facing: 1 | -1
27 legs: Legs
28 eyes: Eyes
29 wand: Wand
30 lift: number
31 glyphs: Glyph[]
32}
33
34export type Cell = { ch: string; fg: string; bg?: string }
35
36export function spriteRows(scene: Pick<Scene, 'legs' | 'eyes' | 'wand'> & { skin?: Skin }): string[] {
37 const art = STYLES[scene.skin ?? 'classic']
38 const legs = scene.legs === 'sit' ? [] : art.legs[scene.legs]
39 const rows = [...art.body, ...legs].map(row => row.split(''))
40 const wand = scene.wand === 'raise' ? art.raise : scene.wand === 'none' ? art.none : []
41 for (const [x, y, ch] of [...art.eyes[scene.eyes], ...wand]) {
42 const row = rows[y]
43 if (row !== undefined && x >= 0 && x < row.length) row[x] = ch
44 }
45
46 return rows.map(row => row.join(''))
47}
48
49export function spriteLeft(skin: Skin): number {
50 return Math.floor((REGION_W - STYLES[skin].width) / 2)
51}
52
53// The region's cells, row by row; undefined where nothing is drawn.
54export function drawScene(scene: Scene): (Cell | undefined)[][] {
55 const art = STYLES[scene.skin]
56 const width = art.width
57 const left = spriteLeft(scene.skin)
58 const pixels: (string | undefined)[] = new Array(REGION_W * HEIGHT_PX)
59 const rows = spriteRows(scene)
60 const top = HEIGHT_PX - rows.length - scene.lift
61 rows.forEach((line, y) => {
62 for (let c = 0; c < width; c++) {
63 const color = art.colors[line[c] ?? '.']
64 if (color === undefined) continue
65 const px = left + (scene.facing === 1 ? c : width - 1 - c)
66 const py = top + y
67 if (py < 0 || py >= HEIGHT_PX) continue
68 pixels[py * REGION_W + px] = color
69 }
70 })
71
72 const cells = toCells(pixels, REGION_W, REGION_H)
73 const colShift = left - 2 + Math.max(0, width - 16)
74 for (const glyph of scene.glyphs) {
75 const col = glyph.col + (glyph.col >= 10 ? colShift : left - 2)
76 const start = scene.facing === 1 ? col : REGION_W - col - glyph.text.length
77 const line = cells[glyph.row + GLYPH_ROW_SHIFT]
78 if (line === undefined) continue
79 ;[...glyph.text].forEach((ch, i) => {
80 const at = start + i
81 if (at >= 0 && at < REGION_W) line[at] = { ch, fg: glyph.color }
82 })
83 }
84
85 return cells
86}
87
88// Pixels (width x rows*2, row-major) to half-block cells.
89function toCells(pixels: (string | undefined)[], width: number, rows: number): (Cell | undefined)[][] {
90 const cells: (Cell | undefined)[][] = []
91 for (let row = 0; row < rows; row++) {
92 const line: (Cell | undefined)[] = []
93 for (let col = 0; col < width; col++) {
94 const upper = pixels[row * 2 * width + col]
95 const lower = pixels[(row * 2 + 1) * width + col]
96 if (upper === undefined && lower === undefined) line.push(undefined)
97 else if (lower === undefined) line.push({ ch: '▀', fg: upper ?? '' })
98 else if (upper === undefined) line.push({ ch: '▄', fg: lower })
99 else if (upper === lower) line.push({ ch: '█', fg: upper })
100 else line.push({ ch: '▀', fg: upper, bg: lower })
101 }
102 cells.push(line)
103 }
104
105 return cells
106}
107
108// The hand that comes for a witch who started a fight: a long sleeve from
109// the top of its region down to a glove, open while it reaches, a fist once
110// it has her.
111export const HAND_W = 12
112const HAND_COLORS: Record<string, string> = {
113 K: '#2b1b3d', W: '#fbf7ff', g: '#c9c0dd', C: '#ffc94d', s: '#6b3fa0', l: '#8a5cd6',
114}
115const SLEEVE = '..KsslssK...'
116const GLOVE_OPEN = [
117 '.KCCCCCCCK..',
118 '.KWWWWWWWK..',
119 'KWWWWWWWWWK.',
120 'KWgWWgWWgWK.',
121 'KWWKWWKWWKWK',
122 'KWWKWWKWWKWK',
123 '.KK.KK.KK.K.',
124]
125const GLOVE_FIST = [
126 '.KCCCCCCCK..',
127 '.KWWWWWWWK..',
128 'KWWWWWWWWWK.',
129 'KWgWWgWWgWK.',
130 'KWWWWWWWWWWK',
131 '.KWgWgWgWWK.',
132 '..KKKKKKKK..',
133]
134
135export function drawHand(rows: number, isClosed: boolean): (Cell | undefined)[][] {
136 const glove = isClosed ? GLOVE_FIST : GLOVE_OPEN
137 const height = rows * 2
138 const sleeve = Array.from({ length: Math.max(0, height - glove.length) }, () => SLEEVE)
139 const lines = [...sleeve, ...glove].slice(-height)
140 const pixels: (string | undefined)[] = new Array(HAND_W * height)
141 lines.forEach((line, y) => {
142 for (let x = 0; x < HAND_W; x++) {
143 const color = HAND_COLORS[line[x] ?? '.']
144 if (color !== undefined) pixels[y * HAND_W + x] = color
145 }
146 })
147
148 return toCells(pixels, HAND_W, rows)
149}
150
151// The wreckage an attack leaves on a console: torn rows of glitch glyphs
152// that thicken with `level` and shift every frame.
153const GLITCH = [...'█▓▒░#%&@$*!?<>/\\|=+~^']
154const GLITCH_FG = ['#ff3b6b', '#ff7ad9', '#7af6ff', '#b388ff', '#ffe14d', '#ffffff']
155const GLITCH_BG = ['#1a0010', '#12001f', '#000000', '#2b0030', '#001a1f']
156
157function hash(a: number, b: number, c: number, d: number): number {
158 let h = (a * 374761393 + b * 668265263 + c * 2147483647 + d * 1274126177) | 0
159 h = Math.imul(h ^ (h >>> 13), 1274126177)
160 h ^= h >>> 16
161
162 return (h >>> 0) / 4294967296
163}
164
165// At most this many streaks are drawn: each is one run of Text, and a
166// surface module's tree must stay under 100,000 characters serialized.
167export const CHAOS_MAX_STREAKS = 360
168
169export function drawChaos(
170 width: number,
171 rows: number,
172 level: number,
173 seed: number,
174 frame: number,
175): (Cell | undefined)[][] {
176 const cells: (Cell | undefined)[][] = Array.from({ length: rows }, () => new Array(width).fill(undefined))
177 if (level <= 0) return cells
178 const beat = Math.floor(frame / 2)
179 const pickFrom = (list: string[], roll: number) => list[Math.floor(roll * list.length)] ?? '#000000'
180 // Streaks per row grow with the level; the budget is shared across rows.
181 const perRow = Math.max(1, Math.min(Math.floor(CHAOS_MAX_STREAKS / Math.max(1, rows)), Math.ceil(level * 6)))
182 for (let row = 0; row < rows; row++) {
183 const line = cells[row] ?? []
184 // Some rows tear whole once the attack is in full swing: one streak.
185 const isTorn = hash(seed, row, beat, 7) < level * level * 0.3
186 const streaks = isTorn ? 1 : perRow
187 for (let i = 0; i < streaks; i++) {
188 if (!isTorn && hash(seed, row, i, beat + 1) > level * 0.9) continue
189 const length = isTorn ? width : 2 + Math.floor(hash(seed, row, i, beat + 2) * (4 + level * 18))
190 const start = isTorn ? 0 : Math.floor(hash(seed, row, i, beat + 3) * Math.max(1, width - length))
191 const fg = pickFrom(GLITCH_FG, hash(seed, row, i, beat + 5))
192 const bg = pickFrom(GLITCH_BG, hash(seed, row, i, beat + 9))
193 for (let col = start; col < Math.min(width, start + length); col++) {
194 line[col] = { ch: pickFrom(GLITCH, hash(seed, col, row, beat + 11)), fg, bg }
195 }
196 }
197 }
198
199 return cells
200}
201
202// One run per stretch of same-colored cells, for drawing as Text.
203export type Run = { row: number; col: number; text: string; fg: string; bg?: string }
204
205export function toRuns(cells: (Cell | undefined)[][]): Run[] {
206 const runs: Run[] = []
207 cells.forEach((line, row) => {
208 let run: Run | undefined
209 line.forEach((cell, col) => {
210 if (cell === undefined) {
211 run = undefined
212 return
213 }
214 if (run && run.fg === cell.fg && run.bg === cell.bg) {
215 run.text += cell.ch
216 return
217 }
218 run = { row, col, text: cell.ch, fg: cell.fg, bg: cell.bg }
219 runs.push(run)
220 })
221 })
222
223 return runs
224}
225
226// Runs up to a serialized-size budget: each drawn as a positioned Box around
227// a Text, so it costs its own JSON plus the wrapping elements.
228export const RUN_OVERHEAD = 220
229
230export function capRuns(runs: Run[], budget = 60_000): Run[] {
231 const kept: Run[] = []
232 let size = 0
233 for (const run of runs) {
234 size += JSON.stringify(run).length + RUN_OVERHEAD
235 if (size > budget) break
236 kept.push(run)
237 }
238
239 return kept
240}
241hooks/chaos-client.tsx 38 lines1import type { ClientModule } from 'claude-code'
2
3import { capRuns, drawChaos, toRuns } from './sprites'
4
5export type ChaosProps = { columns: number; rows: number; level: number; seed: number }
6
7type Local = { frame: number }
8
9// The wreckage over a console whose witch was attacked: it flickers on its
10// own clock; the plugin only says how bad it is.
11const Chaos: ClientModule<ChaosProps, Local> = (props, surface) => {
12 const { Box, Text } = surface.elements
13 const local = surface.state ?? { frame: 0 }
14 if (surface.state === undefined) {
15 surface.setState(local)
16 surface.every(120, () => surface.setState({ frame: (surface.state?.frame ?? 0) + 1 }))
17 }
18 const runs = capRuns(toRuns(drawChaos(props.columns, props.rows, props.level, props.seed, local.frame)))
19
20 return (
21 <Box width={props.columns} height={props.rows}>
22 {runs.map(run => (
23 <Box position="absolute" top={run.row} left={run.col}>
24 {run.bg === undefined ? (
25 <Text color={run.fg}>{run.text}</Text>
26 ) : (
27 <Text color={run.fg} backgroundColor={run.bg}>
28 {run.text}
29 </Text>
30 )}
31 </Box>
32 ))}
33 </Box>
34 )
35}
36
37export default Chaos
38hooks/hand-client.tsx 29 lines1import type { ClientModule } from 'claude-code'
2
3import { HAND_W, drawHand, toRuns } from './sprites'
4
5export type HandProps = { rows: number; isClosed: boolean }
6
7const Hand: ClientModule<HandProps> = (props, surface) => {
8 const { Box, Text } = surface.elements
9 const runs = toRuns(drawHand(props.rows, props.isClosed))
10
11 return (
12 <Box width={HAND_W} height={props.rows}>
13 {runs.map(run => (
14 <Box position="absolute" top={run.row} left={run.col}>
15 {run.bg === undefined ? (
16 <Text color={run.fg}>{run.text}</Text>
17 ) : (
18 <Text color={run.fg} backgroundColor={run.bg}>
19 {run.text}
20 </Text>
21 )}
22 </Box>
23 ))}
24 </Box>
25 )
26}
27
28export default Hand
29hooks/witch-client.tsx 177 lines1import type { ClientModule } from 'claude-code'
2
3import { REGION_H, REGION_W, drawScene, toRuns } from './sprites'
4import type { Glyph, Scene } from './sprites'
5import type { WitchPose as WitchProps } from '../types'
6
7type Local = { frame: number; grab?: { x: number; y: number } }
8
9const STAR = '#fff27a'
10const PINK = '#ff8fa3'
11const MINT = '#8ff0c8'
12const SKY = '#9fd0ff'
13const LILAC = '#c9b3ff'
14const RED = '#ff4d6d'
15const RAINBOW = [STAR, PINK, MINT, SKY, LILAC]
16const GRUMBLES = ['hmph!', 'hey!!', 'put me down!', 'rude...']
17
18export function sceneFor(props: WitchProps, frame: number, isHeld: boolean): Scene {
19 const facing = props.facing
20 const blink = frame % 32 < 2 ? 'blink' : 'open'
21 const beat = Math.floor(frame / 3)
22 const base: Scene = { skin: props.skin ?? 'classic', facing, legs: 'stand', eyes: blink, wand: 'hold', lift: 0, glyphs: [] }
23 const mood = isHeld ? 'grabbed' : props.mood
24
25 switch (mood) {
26 case 'wander': {
27 const legs = props.isWalking ? (beat % 2 === 0 ? 'walk1' : 'walk2') : 'stand'
28 const twinkle: Glyph[] = frame % 24 < 3 ? [{ col: 18, row: 5, text: '·', color: STAR }] : []
29 return { ...base, legs, lift: props.isWalking && beat % 2 === 1 ? 1 : 0, glyphs: twinkle }
30 }
31 case 'think': {
32 const bubble: Glyph[] = [
33 { col: 15, row: 3, text: '.', color: LILAC },
34 { col: 16, row: 2, text: 'o', color: LILAC },
35 { col: 17, row: 1, text: 'O', color: LILAC },
36 { col: 18, row: 0, text: '?', color: SKY },
37 ]
38 return { ...base, glyphs: bubble.slice(0, (beat % 5) + 1) }
39 }
40 case 'cast': {
41 const spots: [number, number][] = [[18, 3], [16, 2], [19, 5], [17, 1], [19, 2]]
42 const glyphs = spots.map(([col, row], i) => ({
43 col,
44 row,
45 text: (i + beat) % 2 === 0 ? '*' : '+',
46 color: RAINBOW[(i + beat) % RAINBOW.length] ?? STAR,
47 }))
48 return { ...base, wand: 'raise', glyphs: glyphs.slice(0, 2 + (beat % 4)) }
49 }
50 case 'cheer': {
51 const spots: [number, number][] = [[0, 2], [1, 5], [18, 1], [19, 4], [17, 0], [0, 7]]
52 const glyphs = spots.map(([col, row], i) => ({
53 col,
54 row: (row + beat) % 8,
55 text: i % 2 === 0 ? '*' : '+',
56 color: RAINBOW[(i + beat) % RAINBOW.length] ?? STAR,
57 }))
58 return { ...base, eyes: 'happy', wand: 'raise', lift: [0, 1, 2, 1][frame % 4] ?? 0, glyphs }
59 }
60 case 'oops': {
61 const glyphs: Glyph[] = [{ col: 3, row: 4, text: '•', color: SKY }]
62 if (beat % 2 === 0) glyphs.push({ col: 15, row: 0, text: '!', color: RED })
63 return { ...base, eyes: 'blink', glyphs }
64 }
65 case 'sleep': {
66 const zs: Glyph[] = [
67 { col: 14, row: 4, text: 'z', color: SKY },
68 { col: 15, row: 3, text: 'z', color: SKY },
69 { col: 16, row: 2, text: 'Z', color: LILAC },
70 ]
71 return { ...base, legs: 'sit', eyes: 'blink', wand: 'none', glyphs: zs.slice(0, (Math.floor(frame / 5) % 4)) }
72 }
73 case 'grabbed': {
74 const grumble = GRUMBLES[Math.floor(frame / 14) % GRUMBLES.length] ?? 'hmph!'
75 const glyphs: Glyph[] = [{ col: Math.min(14, REGION_W - grumble.length), row: 0, text: grumble, color: PINK }]
76 if (frame % 4 < 2) glyphs.push({ col: 15, row: 2, text: '╬', color: RED })
77 return {
78 ...base,
79 legs: frame % 2 === 0 ? 'kick1' : 'kick2',
80 eyes: 'angry',
81 wand: 'none',
82 lift: frame % 2,
83 glyphs,
84 }
85 }
86 case 'huff': {
87 const glyphs: Glyph[] = [{ col: 14, row: 0, text: 'hmph.', color: PINK }]
88 if (beat % 3 === 0) glyphs.push({ col: 15, row: 2, text: '╬', color: RED })
89 return { ...base, eyes: 'angry', glyphs }
90 }
91 // Leaping off to another console.
92 case 'jump': {
93 const trail: Glyph[] = [{ col: 3, row: 10, text: '˚', color: STAR }, { col: 15, row: 11, text: '·', color: SKY }]
94 return { ...base, legs: 'kick2', eyes: 'happy', wand: 'raise', lift: 2, glyphs: trail }
95 }
96 // Dropping in from above.
97 case 'fall': {
98 const whoosh: Glyph[] = [{ col: 5, row: 0, text: '!', color: STAR }]
99 return { ...base, legs: frame % 2 === 0 ? 'kick1' : 'kick2', eyes: 'open', wand: 'raise', glyphs: whoosh }
100 }
101 // Winding up a spell at the other witch's console.
102 case 'attack': {
103 const bolt = ['>', '>>', '>>>', '>>>>'][beat % 4] ?? '>'
104 const glyphs: Glyph[] = [
105 { col: 16, row: 4, text: bolt, color: RED },
106 { col: 15, row: 2, text: '╬', color: RED },
107 { col: 18, row: 6, text: '*', color: RAINBOW[beat % RAINBOW.length] ?? STAR },
108 ]
109 return { ...base, eyes: 'angry', wand: 'raise', lift: frame % 2, glyphs }
110 }
111 // Her console is being wrecked.
112 case 'scared': {
113 const glyphs: Glyph[] = [
114 { col: 3, row: 4, text: '•', color: SKY },
115 { col: 14, row: 0, text: frame % 6 < 3 ? 'eek!' : 'EEK!', color: PINK },
116 ]
117 return { ...base, eyes: 'blink', wand: 'none', lift: frame % 3 === 0 ? 1 : 0, glyphs }
118 }
119 }
120}
121
122const Witch: ClientModule<WitchProps, Local> = (props, surface) => {
123 const { Box, Text } = surface.elements
124 const local = surface.state ?? { frame: 0 }
125 if (surface.state === undefined) {
126 surface.setState(local)
127 surface.every(150, () => {
128 const now = surface.state ?? { frame: 0 }
129 surface.setState({ ...now, frame: now.frame + 1 })
130 })
131 }
132
133 surface.onPointer(event => {
134 const now = surface.state ?? { frame: 0 }
135 // A right click sends her away from this console.
136 if (event.type === 'down' && event.button === 'right') {
137 surface.post({ type: 'hide' })
138 return
139 }
140 if (event.type === 'down' && event.button === 'left') {
141 surface.setState({ ...now, grab: { x: event.x, y: event.y } })
142 surface.post({ type: 'grab' })
143 return
144 }
145 if (now.grab === undefined) return
146 const x = props.x + event.x - now.grab.x
147 const up = props.up - (event.y - now.grab.y)
148 if (event.type === 'move') {
149 surface.post({ type: 'drag', x, up })
150 } else if (event.type === 'up') {
151 surface.setState({ frame: now.frame })
152 surface.post({ type: 'drop', x, up })
153 }
154 })
155
156 const scene = sceneFor(props, local.frame, local.grab !== undefined)
157 const runs = toRuns(drawScene(scene))
158
159 return (
160 <Box width={REGION_W} height={REGION_H}>
161 {runs.map(run => (
162 <Box position="absolute" top={run.row} left={run.col}>
163 {run.bg === undefined ? (
164 <Text color={run.fg}>{run.text}</Text>
165 ) : (
166 <Text color={run.fg} backgroundColor={run.bg}>
167 {run.text}
168 </Text>
169 )}
170 </Box>
171 ))}
172 </Box>
173 )
174}
175
176export default Witch
177hooks/styles.ts 231 lines1// Generated from the witch style designs: one drawing per style, facing
2// right. Patches are [x, y, letter] over body + legs rows.
3
4import type { Skin } from '../types'
5
6export type Patch = [x: number, y: number, ch: string]
7export type LegFrame = 'stand' | 'walk1' | 'walk2' | 'kick1' | 'kick2'
8export type Eyes = 'open' | 'blink' | 'happy' | 'angry'
9
10export type StyleArt = {
11 width: number
12 colors: Record<string, string>
13 body: string[]
14 legs: Record<LegFrame, string[]>
15 eyes: Record<Eyes, Patch[]>
16 raise: Patch[]
17 none: Patch[]
18}
19
20export const STYLES: Record<Skin, StyleArt> = {
21 // Round chibi: pointy purple hat, pink hair, star wand
22 classic: {
23 width: 16,
24 colors: {"K": "#2b1b3d", "H": "#5b2f99", "h": "#8a5cd6", "d": "#3f1f73", "B": "#ffc94d", "b": "#d99a2b", "*": "#fff6a0", "A": "#ff9fcf", "a": "#e06aa8", "S": "#ffe0c7", "W": "#ffffff", "E": "#3a2a6a", "P": "#ff9bb8", "Q": "#ff4d6d", "M": "#c4566e", "R": "#4a2c80", "r": "#6f48b0", "X": "#ff6f9a", "T": "#fff4fa", "F": "#3b2440", "V": "#a0703a", "Y": "#ffe46b"},
25 body: [
26 '..........KK....',
27 '.........KhK....',
28 '........KHHK....',
29 '.......KHhHK....',
30 '......KHHhHHK...',
31 '.....KHHhHHHK...',
32 '....KdBBB*BBdK..',
33 'KKKdHHHHHHHHHdKK',
34 '.KaAAAAAAAAAAaK.',
35 '.KAAaSSSSSaAAAK.',
36 '.KAaSWESSSWESaK.',
37 '.KAaSEESSSEESaK.',
38 '.KAAPSSSMSSSPAK.',
39 '..KASSSSSSSSAK.Y',
40 '...KRRRXXRRRK.V.',
41 '..KRrRRRRRRRRSK.',
42 '.KRRrRRRRRRRRRK.',
43 '.KRRRRRRRRRRRRK.',
44 '..KBbBbBbBbBbK..',
45 ],
46 legs: {
47 stand: ["....KTK..KTK....", "....KFFK.KFFK..."],
48 walk1: ["...KTK....KTK...", "...KFFK...KFFK.."],
49 walk2: [".....KTKKTK.....", ".....KFFKFFK...."],
50 kick1: ["....KTK...KTK...", "...KFFK....KFFK."],
51 kick2: [".....KTK.KTK....", ".....KFFKKFFK..."],
52 },
53 eyes: {
54 open: [],
55 blink: [[5, 10, "S"], [6, 10, "S"], [5, 11, "K"], [6, 11, "K"], [10, 10, "S"], [11, 10, "S"], [10, 11, "K"], [11, 11, "K"]],
56 happy: [[5, 10, "K"], [6, 10, "K"], [5, 11, "S"], [6, 11, "S"], [4, 11, "K"], [7, 11, "K"], [10, 10, "K"], [11, 10, "K"], [10, 11, "S"], [11, 11, "S"], [9, 11, "K"], [12, 11, "K"]],
57 angry: [[5, 10, "S"], [6, 10, "S"], [5, 11, "S"], [6, 11, "S"], [5, 10, "K"], [6, 11, "K"], [5, 11, "E"], [10, 10, "S"], [11, 10, "S"], [10, 11, "S"], [11, 11, "S"], [11, 10, "K"], [10, 11, "K"], [11, 11, "E"], [7, 12, "K"], [8, 12, "K"], [9, 12, "K"], [4, 12, "Q"], [12, 12, "Q"]],
58 },
59 raise: [[15, 13, "."], [14, 14, "."], [13, 15, "R"], [15, 8, "Y"], [15, 9, "V"], [15, 10, "V"], [15, 11, "V"], [15, 12, "S"]],
60 none: [[15, 13, "."], [14, 14, "."], [13, 15, "R"]],
61 },
62 // Huge floppy hat with glowing star, cloud of lavender hair, cyan glow
63 starlight: {
64 width: 20,
65 colors: {"K": "#141529", "H": "#262a52", "h": "#3a4383", "d": "#1a1d3b", "C": "#33d6e0", "c": "#1f8f99", "*": "#f08cff", "+": "#ffd6ff", "A": "#c9a2f2", "a": "#9a6fd0", "L": "#e3ccff", "S": "#fbe3d6", "W": "#ffffff", "E": "#3b2f6b", "P": "#ffb3c8", "Q": "#ff4d6d", "M": "#b85a7a", "R": "#1e2f45", "r": "#2b4560", "T": "#262a52", "F": "#33d6e0", "V": "#3a4383", "Y": "#7ff6ff"},
66 body: [
67 '..........KKK.......',
68 '........KKHHHKK.....',
69 '.......KHHhHHHHKK...',
70 '......KHHHhHHHHHHK..',
71 '.....KHHHH+HhHKKHHK.',
72 '.....KHHH***HHK..KHK',
73 '....KHHH**+**HHK..K.',
74 '....KHHHH***HHHK....',
75 '...KdHHHHH*HHHHdK...',
76 '.KKcCCCCCCCCCCCCcKK.',
77 'KHHHHHHHHHHHHHHHHHHK',
78 '.KCCCCCCCCCCCCCCCCK.',
79 '.KLAAaAAAAAAAAaAALK.',
80 'KAAAaASSSSSSSSaAAAAK',
81 'KALAaSSWESSWESSaAAAK',
82 'KAAASSSEESSEESSaALAK',
83 'KAaAaSSPSSMSPSSAAaAK',
84 'KAALAKSSSSSSSSKAALAK',
85 '.KAAKRrRRCCRRrRKAAK.',
86 '.KaKRRRRRCCRRRRRKaK.',
87 '..KKRRrRRRRRRrRRKK..',
88 ],
89 legs: {
90 stand: [".....KTK..KTK.......", ".....KFK..KFK......."],
91 walk1: ["....KTK....KTK......", "....KFK....KFK......"],
92 walk2: ["......KTKKTK........", "......KFKKFK........"],
93 kick1: ["....KTK.....KTK.....", "...KFK.......KFK...."],
94 kick2: ["......KTK.KTK.......", "......KFK.KFK......."],
95 },
96 eyes: {
97 open: [],
98 blink: [[7, 14, "S"], [8, 14, "S"], [7, 15, "K"], [8, 15, "K"], [11, 14, "S"], [12, 14, "S"], [11, 15, "K"], [12, 15, "K"]],
99 happy: [[7, 14, "K"], [8, 14, "K"], [7, 15, "S"], [8, 15, "S"], [6, 15, "K"], [9, 15, "K"], [11, 14, "K"], [12, 14, "K"], [11, 15, "S"], [12, 15, "S"], [10, 15, "K"], [13, 15, "K"]],
100 angry: [[7, 14, "S"], [8, 14, "S"], [7, 15, "S"], [8, 15, "S"], [7, 14, "K"], [8, 15, "K"], [7, 15, "E"], [11, 14, "S"], [12, 14, "S"], [11, 15, "S"], [12, 15, "S"], [12, 14, "K"], [11, 15, "K"], [12, 15, "E"], [9, 16, "K"], [10, 16, "K"], [11, 16, "K"], [7, 16, "Q"], [12, 16, "Q"]],
101 },
102 raise: [[18, 9, "Y"], [19, 9, "K"], [18, 10, "V"], [18, 11, "V"]],
103 none: [],
104 },
105 // Side-saddle on a broom, streaming magenta hair, long coat, cat on the broom
106 moonbroom: {
107 width: 22,
108 colors: {"K": "#1d1828", "H": "#4f4766", "h": "#6f6690", "d": "#3a3350", "B": "#ff4fd8", "G": "#ffc94d", "A": "#e85ad8", "a": "#a8329e", "L": "#ff9df0", "S": "#f6d9cc", "W": "#ffffff", "E": "#5a2a6a", "P": "#ff9bb8", "Q": "#ff4d6d", "M": "#c4566e", "R": "#4a4652", "r": "#6a6674", "g": "#a9a4b5", "V": "#8a5a2e", "v": "#b07a3e", "Z": "#c9a46a", "z": "#8f7442", "c": "#100c14", "y": "#ffe14d", "F": "#2a2232"},
109 body: [
110 '............KK........',
111 '...........KhHK.......',
112 '..........KHHHK.......',
113 '.........KHhHHHK......',
114 '........KHHhHHHHK..K..',
115 '......KKBBBBBBBBBK..G.',
116 '...KKKHHHHHHHHHHHHKKG.',
117 '....KKddddddddddKK....',
118 '...KAAAAAAAAAAAK......',
119 '..KAaAAAAAAaAAAK......',
120 '.KAaAAAAKSSSSSSK......',
121 'KAaAAAAAKWESWESK......',
122 'KaAAaAAAKEESEESK......',
123 'KAaLAaAAKPSMSPSK......',
124 '.KaAAaAAAKSSSSK.......',
125 '..KAaAAaKRRSSKK.......',
126 '...KAaAKRRRRRRK.c..c..',
127 '....KKKRRgRRRSSKcycy..',
128 'zZzZzKVVVVVVVVVVvcccvK',
129 ],
130 legs: {
131 stand: [".zZzZ.KRRRRRRK.SSK....", ".......KRRrRRK..SSK...", ".......KKKKKK...KFFK.."],
132 walk1: ["zZzZz.KRRRRRRK.SSK....", ".......KRRrRRK..SSK...", ".......KKKKKK...KFFK.."],
133 walk2: [".zZzZ.KRRRRRRK.SSK....", ".......KRRrRRK.SSK....", ".......KKKKKK..KFFK..."],
134 kick1: [".zZzZ.KRRRRRRK.SS.K...", ".......KRRrRRK...SSK..", ".......KKKKKK.....KFFK"],
135 kick2: [".zZzZ.KRRRRRRKSSK.....", ".......KRRrRRKSSK.....", ".......KKKKKKKFFK....."],
136 },
137 eyes: {
138 open: [],
139 blink: [[9, 11, "S"], [10, 11, "S"], [9, 12, "K"], [10, 12, "K"], [12, 11, "S"], [13, 11, "S"], [12, 12, "K"], [13, 12, "K"]],
140 happy: [[9, 11, "K"], [10, 11, "K"], [9, 12, "S"], [10, 12, "S"], [8, 12, "K"], [11, 12, "K"], [12, 11, "K"], [13, 11, "K"], [12, 12, "S"], [13, 12, "S"], [11, 12, "K"], [14, 12, "K"]],
141 angry: [[9, 11, "S"], [10, 11, "S"], [9, 12, "S"], [10, 12, "S"], [9, 11, "K"], [10, 12, "K"], [9, 12, "E"], [12, 11, "S"], [13, 11, "S"], [12, 12, "S"], [13, 12, "S"], [13, 11, "K"], [12, 12, "K"], [13, 12, "E"], [10, 13, "K"], [11, 13, "K"], [12, 13, "K"], [9, 13, "Q"], [13, 13, "Q"]],
142 },
143 raise: [],
144 none: [],
145 },
146 // Super-deformed: giant head and eyes, buckled hat, cat on shoulder, gem staff
147 bluebell: {
148 width: 20,
149 colors: {"K": "#1a2340", "H": "#2f4f9a", "h": "#4a72c8", "d": "#203a78", "G": "#c8d0e0", "g": "#8a93a8", "A": "#5a3f94", "a": "#3d2a6e", "S": "#ffe4d2", "W": "#ffffff", "E": "#1e2440", "I": "#4a5a8a", "P": "#ffb0b8", "Q": "#ff4d6d", "M": "#c46a6e", "R": "#2f4f9a", "r": "#5a82d8", "X": "#c8d0e0", "F": "#5a3a26", "T": "#1e2440", "V": "#6b4226", "v": "#8f5c36", "Y": "#3ee86b", "y": "#b6ffcb", "c": "#151018", "o": "#ffd23f"},
150 body: [
151 '..........KKKK......',
152 '........KKhHHHK.....',
153 '.......KHHhHHHHK....',
154 '......KHHhHHHHHHK...',
155 '......KHHHHHHKKHHK..',
156 '.....KddGGGddK..KHK.',
157 '....KHHGddGHHHK..KK.',
158 '..KKHHHHHHHHHHHKK...',
159 '.KHHHHHHHHHHHHHHHK..',
160 'yKKAAAAAAAAAAAAKK...',
161 'YKAAAaAAAAaAAAAAK...',
162 'YKAaSSSSSSSSSaAAK...',
163 'VKASSKKSSSSKKSaAK...',
164 'vKASSEWSSSSEWSSAK...',
165 'VKASSEISSSSEISSAKc.c',
166 'vKAPSEESSMSEESPAKccc',
167 'VKKASSSSSSSSSSAKoco.',
168 'v.KKKRRXXXRRRKKccc..',
169 'V..KRRrRRRRRrRRKKc..',
170 'v..KRRrRRRRRrRRK....',
171 ],
172 legs: {
173 stand: ["V...KKKKKKKKKK......", "v....KFK..KFK......."],
174 walk1: ["V...KKKKKKKKKK......", "v...KFK....KFK......"],
175 walk2: ["V...KKKKKKKKKK......", "v......KFKFK........"],
176 kick1: ["....KKKKKKKKKK......", "...KFK......KFK....."],
177 kick2: ["....KKKKKKKKKK......", ".....KFK..KFK......."],
178 },
179 eyes: {
180 open: [],
181 blink: [[5, 12, "S"], [6, 12, "S"], [5, 13, "S"], [6, 13, "S"], [5, 14, "S"], [6, 14, "S"], [5, 15, "K"], [6, 15, "K"], [11, 12, "S"], [12, 12, "S"], [11, 13, "S"], [12, 13, "S"], [11, 14, "S"], [12, 14, "S"], [11, 15, "K"], [12, 15, "K"]],
182 happy: [[5, 12, "K"], [6, 12, "K"], [5, 13, "S"], [6, 13, "S"], [5, 14, "S"], [6, 14, "S"], [5, 15, "S"], [6, 15, "S"], [4, 13, "K"], [7, 13, "K"], [11, 12, "K"], [12, 12, "K"], [11, 13, "S"], [12, 13, "S"], [11, 14, "S"], [12, 14, "S"], [11, 15, "S"], [12, 15, "S"], [10, 13, "K"], [13, 13, "K"]],
183 angry: [[5, 12, "S"], [6, 12, "S"], [5, 13, "S"], [6, 13, "S"], [5, 14, "S"], [6, 14, "S"], [5, 15, "S"], [6, 15, "S"], [5, 12, "K"], [6, 13, "K"], [5, 15, "E"], [11, 12, "S"], [12, 12, "S"], [11, 13, "S"], [12, 13, "S"], [11, 14, "S"], [12, 14, "S"], [11, 15, "S"], [12, 15, "S"], [12, 12, "K"], [11, 13, "K"], [12, 15, "E"], [8, 15, "K"], [9, 15, "K"], [10, 15, "K"], [3, 15, "Q"], [14, 15, "Q"]],
184 },
185 raise: [[0, 9, "."], [0, 10, "."], [0, 11, "."], [0, 12, "."], [0, 13, "."], [0, 14, "."], [0, 15, "."], [0, 16, "."], [0, 17, "."], [0, 18, "."], [0, 19, "."], [1, 2, "y"], [1, 3, "Y"], [1, 4, "Y"], [1, 5, "V"], [1, 6, "v"], [1, 7, "V"], [1, 8, "v"], [2, 9, "S"]],
186 none: [[0, 9, "."], [0, 10, "."], [0, 11, "."], [0, 12, "."], [0, 13, "."], [0, 14, "."], [0, 15, "."], [0, 16, "."], [0, 17, "."], [0, 18, "."], [0, 19, "."]],
187 },
188 // Tall and slender, long golden hair, fitted dress, tall upright broom
189 goldie: {
190 width: 16,
191 colors: {"K": "#141826", "H": "#22405e", "h": "#356086", "d": "#172c44", "B": "#101420", "A": "#f2c46b", "a": "#c08a36", "L": "#ffe3a1", "S": "#f8dcc6", "s": "#e8b9a0", "W": "#ffffff", "E": "#3a6aa8", "P": "#f2a0a0", "Q": "#ff4d6d", "M": "#b8606a", "R": "#1f2442", "r": "#343c6c", "X": "#8a2a3a", "T": "#2a2438", "F": "#101018", "V": "#2a1c14", "Z": "#b07a3e", "z": "#7a5228"},
192 body: [
193 '.............zZz',
194 '.......KK....ZzZ',
195 '......KHHK...zZz',
196 '.....KHhHK...ZzZ',
197 '....KHHhHHK...V.',
198 '..KKKBBBBBKKK.V.',
199 'KKHHHHHHHHHHHKV.',
200 '...KAAAAAAAK..V.',
201 '...KALSSSAAK..V.',
202 '..KAASESESAaK.V.',
203 '..KAASSMSSAaK.V.',
204 '..KAAKSSSKAAK.V.',
205 '..KAaKRXRKAaK.V.',
206 '..KAAKRRRRKAKSV.',
207 '..KAaRrRRRRKSK..',
208 '...KAKRRRRRKK.V.',
209 '...KaKXXXXXK..V.',
210 '....KRRrRRRK..V.',
211 '...KRRrRRRRRK.V.',
212 '...KRRRRRRRRK.V.',
213 ],
214 legs: {
215 stand: [".....KsK.KsK..V.", ".....KTK.KTK..V.", ".....KTK.KTK..V.", "....KFFK.KFFK.V."],
216 walk1: ["....KsK...KsK.V.", "....KTK...KTK.V.", "...KTK.....KTKV.", "...KFFK....KFFK."],
217 walk2: [".....KsKKsK...V.", ".....KTKKTK...V.", "......KTTK....V.", ".....KFFFFK...V."],
218 kick1: ["....KsK...KsK...", "...KTK.....KTK..", "..KTK.......KTK.", "..KFFK......KFFK"],
219 kick2: [".....KsK.KsK....", ".....KTK..KTK...", "......KTK.KTK...", "......KFFKKFFK.."],
220 },
221 eyes: {
222 open: [],
223 blink: [[6, 9, "K"], [8, 9, "K"]],
224 happy: [[6, 9, "K"], [8, 9, "K"]],
225 angry: [[6, 9, "S"], [6, 9, "K"], [8, 9, "S"], [8, 9, "K"], [7, 10, "K"]],
226 },
227 raise: [],
228 none: [[13, 0, "."], [14, 0, "."], [15, 0, "."], [13, 1, "."], [14, 1, "."], [15, 1, "."], [13, 2, "."], [14, 2, "."], [15, 2, "."], [13, 3, "."], [14, 3, "."], [15, 3, "."], [14, 4, "."], [14, 5, "."], [14, 6, "."], [14, 7, "."], [14, 8, "."], [14, 9, "."], [14, 10, "."], [14, 11, "."], [14, 12, "."], [14, 13, "."], [14, 14, "."], [14, 15, "."], [14, 16, "."], [14, 17, "."], [14, 18, "."], [14, 19, "."], [14, 20, "."], [14, 21, "."], [14, 22, "."], [14, 23, "."], [13, 13, "K"]],
229 },
230}
231types/index.d.ts 40 lines1export type Mood =
2 | 'wander'
3 | 'think'
4 | 'cast'
5 | 'cheer'
6 | 'oops'
7 | 'sleep'
8 | 'grabbed'
9 | 'huff'
10 | 'jump'
11 | 'fall'
12 | 'attack'
13 | 'scared'
14
15export type Skin = 'classic' | 'starlight' | 'moonbroom' | 'bluebell' | 'goldie'
16
17// Where she is: `x` columns from the left, `up` rows from the transcript's
18// floor to her feet. `hand` is the hand that comes for her after a fight:
19// `drop` rows still to come down, `isClosed` once it has her.
20export type WitchPose = {
21 x: number
22 up: number
23 facing: 1 | -1
24 mood: Mood
25 isWalking: boolean
26 skin: Skin
27 isShown: boolean
28 hand?: { drop: number; isClosed: boolean }
29}
30
31// How wrecked this console's screen is after an attack (0 to 1), and the
32// seed its wreckage is drawn from.
33export type Chaos = { level: number; seed: number }
34
35declare module 'claude-code' {
36 interface PluginState {
37 'pixel-witch': { isHidden: boolean; pose: WitchPose; anchor: string | null; chaos: Chaos }
38 }
39}
40