Replaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls.

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.
Requires a Claude Code build with mod (function hooks) support.
1. Add the marketplace. The repository is private, so gh auth login (or any git credential that can read it) must work first.
claude plugin marketplace add fhva29/claude-code-mods
Or clone it and add the folder; Claude Code then reads the mods straight from it, so an edit plus /reload-plugins is enough, with no reinstall:
git clone https://github.com/fhva29/claude-code-mods ~/.claude/mods
claude plugin marketplace add ~/.claude/mods
2. Install the mods you want (user scope):
claude plugin install next-lite@heitor-mods
claude plugin install tidy-tools@heitor-mods
# ...or all of them:
for m in blast-radius commit-gate model-stack next-lite pixel-quest prompt-git \
replay-theater tidy-tools usage-weather brief-mode; do
claude plugin install "$m@heitor-mods"
done
3. Reload inside Claude Code with /reload-plugins.
Options (userConfig) are set in /plugin → pick the mod → configure, then /reload-plugins. They are stored in ~/.claude/settings.json under pluginConfigs.
| Mod | What it does | Command | |
|---|---|---|---|
next-lite | Suggests up to three next prompts above the input (press 1/2/3 to fill the prompt box, 0 to dismiss; the top one is also Tab ghost text). Sends a ~1–2k token digest of the turn to a small model instead of forking the whole session, so its cost does not grow with the conversation. Replacement for next-steps. Options: mode (auto/manual), model, minAnswerChars, suggestSkills. | /next | |
model-stack | Routes the model and effort of the main chat turn and of each subagent with TypeSafe's Jev, and shows the roster, decisions and agent graph in a pane. Needs TYPESAFE_API_KEY in the environment. | /stack | |
tidy-tools | Draws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line. | /lines | |
blast-radius | Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons. | ||
commit-gate | Holds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers. | ||
replay-theater | Steps through the file edits Claude made in the last turn, one diff at a time. | /replay | |
usage-weather | A forecast band above the prompt: context fill, sparkline, rate limits, model and effort. | ||
prompt-git | Appends the git branch and working-tree counts to the hint line under the prompt. | ||
pixel-quest | Replaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls. | /toons | |
brief-mode | Appends a short-answer style section to the system prompt: answer or next action first, numbered steps, lists capped at five, no preamble, recap or closers. Edit hooks/rules.ts to tune it. | ||
midnight-neon | Draws assistant replies with a dark neon palette: filled h1 banner, colored h2–h6, colored bullets, quote bars, highlighted bold, italic, inline code and links. Fences and tables keep the engine's renderer. Not installed by default; /neon flips it per session, `claude plugin enable\ | disable` flips it for good. | /neon |
_archive holds mods that are kept but not in use (branch-hud); they are not listed in the marketplace.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module (register.ts or register.tsx). Check one with:
claude plugin validate <mod>
claude plugin test <mod>
claude plugin test does not type-check; run tsc against the SDK declarations Claude Code writes to <mod>/.claude-plugin/types/ as well.
next-lite builds on next-steps (MIT, Thariq Shihipar): it reuses the text sanitizer and the reply parsing, and replaces the session fork with a small digest.
hooks/register.tsx 179 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { HeroSave, QuestScene } from '../types'
5import { EMPTY_HERO, heroSheet, levelOf, questReward, reward, titleOf, xpBar } from './progress'
6import type { Reward } from './progress'
7import { toCells } from './raster'
8import { PX_WIDTH, drawScene, sceneForTool } from './scenes'
9import type { SceneId } from './scenes'
10
11const FRAME_MS = 200
12const FLASH_MS = 1600
13const MAX_COLUMNS = 512
14const INDENT = 2
15const STORE_HERO = 'hero'
16const STORE_HIDDEN = 'isHidden'
17const STORE_WIDE = 'isWide'
18
19const now = atom({ plugin: 'pixel-quest', key: 'now' } as const, 0)
20const scene = atom({ plugin: 'pixel-quest', key: 'scene' } as const, {
21 active: null,
22 flash: null,
23 flashUntil: 0,
24 startedAt: 0,
25 tools: 0,
26 lastGain: 0,
27})
28const hero = atom({ plugin: 'pixel-quest', key: 'hero' } as const, null)
29const isHidden = atom({ plugin: 'pixel-quest', key: 'isHidden' } as const, true)
30const isWide = atom({ plugin: 'pixel-quest', key: 'isWide' } as const, true)
31
32async function loadHero($: EngineInterface): Promise<HeroSave> {
33 const held = await read($, hero)
34 if (held !== null) return held
35 const saved = ((await $.store.get(STORE_HERO)) as HeroSave | undefined) ?? EMPTY_HERO
36 const merged: HeroSave = { ...EMPTY_HERO, ...saved, stats: { ...EMPTY_HERO.stats, ...saved.stats } }
37 await update($, hero, () => merged)
38 return merged
39}
40
41async function applyReward($: EngineInterface, outcome: Reward): Promise<void> {
42 await update($, hero, () => outcome.hero)
43 await $.store.set(STORE_HERO, outcome.hero)
44 if (outcome.levelUp !== null) {
45 $.ui.toast(`⬆ LEVEL UP! Lv ${outcome.levelUp} ${titleOf(outcome.levelUp)}`)
46 }
47 for (const name of outcome.earned) {
48 $.ui.toast(`★ Achievement unlocked: ${name}`)
49 }
50}
51
52let timer: Timer | null = null
53
54async function startTicking($: EngineInterface): Promise<void> {
55 if (timer !== null || (await read($, isHidden))) return
56 timer = $.clock.every(FRAME_MS, () => {
57 void $.clock.now().then(ms => update($, now, () => ms))
58 })
59}
60
61function stopTicking(): void {
62 timer?.cancel()
63 timer = null
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 const result = await next(e)
69 await $.command.register({
70 name: 'toons',
71 description: 'Toggle the pixel quest spinner; "/toons wide" switches full width, "/toons stats" shows your hero',
72 })
73 const hidden = (await $.store.get(STORE_HIDDEN)) !== false
74 const wide = (await $.store.get(STORE_WIDE)) !== false
75 await update($, isHidden, () => hidden)
76 await update($, isWide, () => wide)
77 await loadHero($)
78 return result
79 })
80
81 on('command.run', { command: 'toons' }, async ($, e) => {
82 const arg = e.args.trim()
83 if (arg === 'stats') {
84 return { text: heroSheet(await loadHero($)) }
85 }
86 if (arg === 'wide') {
87 const wide = await update($, isWide, value => !value)
88 await $.store.set(STORE_WIDE, wide)
89 return { text: wide ? 'Pixel quest spans the terminal width.' : `Pixel quest is ${PX_WIDTH} columns wide.` }
90 }
91 const hidden = await update($, isHidden, value => !value)
92 await $.store.set(STORE_HIDDEN, hidden)
93 if (hidden) stopTicking()
94 return { text: hidden ? 'Pixel quest hidden.' : 'Pixel quest shown.' }
95 })
96
97 on('prompt.submit', async ($, e, next) => {
98 const startedAt = await $.clock.now()
99 await update($, now, () => startedAt)
100 await update($, scene, held => ({ ...held, active: null, flash: null, startedAt, tools: 0 }))
101 await startTicking($)
102 return next(e)
103 })
104
105 on('tool.call', async ($, e, next) => {
106 const id = sceneForTool(e.tool, e.tool === 'Bash' ? e.command : undefined)
107 await update($, scene, held => ({ ...held, active: id }))
108 await startTicking($)
109
110 const started = await $.clock.now()
111 const ran = await next(e)
112 const finished = await $.clock.now()
113 const isError = ran.deny === undefined && ran.isError === true
114 const outcome = reward(await loadHero($), id, isError, finished - started)
115 await applyReward($, outcome)
116
117 const flash: SceneId | null = isError ? 'defeat' : id === 'battle' ? 'victory' : null
118 await update($, scene, held => ({
119 ...held,
120 active: held.active === id ? null : held.active,
121 flash: flash ?? held.flash,
122 flashUntil: flash === null ? held.flashUntil : finished + FLASH_MS,
123 tools: held.tools + 1,
124 lastGain: outcome.gained,
125 }))
126 return ran
127 })
128
129 on('turn.complete', async ($, e, next) => {
130 const result = await next(e)
131 if (e.agentId === undefined) {
132 stopTicking()
133 const held = await read($, scene)
134 const duration = held.startedAt > 0 ? (await $.clock.now()) - held.startedAt : 0
135 await applyReward($, questReward(await loadHero($), held.tools, duration))
136 }
137 return result
138 })
139
140 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
141 if (e.surface !== 'terminal' || (await read($, isHidden))) {
142 return next(e)
143 }
144
145 const ms = await read($, now)
146 const held: QuestScene = await read($, scene)
147 const sheet = (await read($, hero)) ?? EMPTY_HERO
148 const isFlashing = held.flash !== null && ms < held.flashUntil
149 const id = ((isFlashing ? held.flash : held.active) ?? 'camp') as SceneId
150 const columns = (await read($, isWide))
151 ? Math.min(MAX_COLUMNS, (e.viewport?.columns ?? PX_WIDTH + INDENT) - INDENT)
152 : PX_WIDTH
153 const raster = toCells(drawScene(id, Math.floor(ms / FRAME_MS), columns))
154 const { level, into, needed } = levelOf(sheet.xp)
155 const line = await next(e)
156 const { Box, Raster, Text } = $.ui.resolve(e)
157
158 return (
159 <Box flexDirection="column">
160 {line}
161 <Box paddingLeft={INDENT}>
162 <Raster key="scene" {...raster} />
163 </Box>
164 <Text>
165 <Text dimColor>
166 {' '}Lv {level} {titleOf(level)}{' '}
167 </Text>
168 <Text color="yellow">{xpBar(into, needed)}</Text>
169 <Text dimColor>
170 {' '}
171 {into}/{needed} XP
172 </Text>
173 {held.lastGain > 0 && <Text color="green"> +{held.lastGain}</Text>}
174 </Text>
175 </Box>
176 )
177 })
178}
179hooks/progress.ts 122 lines1import type { HeroSave, HeroStats } from '../types'
2import type { SceneId } from './scenes'
3
4export const EMPTY_HERO: HeroSave = {
5 xp: 0,
6 stats: { battles: 0, forged: 0, explored: 0, summons: 0, treasures: 0, defeats: 0, turns: 0 },
7 unlocked: [],
8}
9
10const XP_BY_SCENE: Partial<Record<SceneId, number>> = {
11 battle: 15,
12 forge: 10,
13 summon: 20,
14 treasure: 8,
15 mine: 4,
16 voyage: 4,
17 dungeon: 2,
18}
19
20const STAT_BY_SCENE: Partial<Record<SceneId, keyof HeroStats>> = {
21 battle: 'battles',
22 forge: 'forged',
23 dungeon: 'explored',
24 summon: 'summons',
25 treasure: 'treasures',
26}
27
28const TITLES = ['Novice', 'Squire', 'Adventurer', 'Knight', 'Paladin', 'Champion', 'Archmage', 'Hero of Legend']
29
30type Achievement = { id: string; name: string; isEarned: (hero: HeroSave) => boolean }
31
32export const ACHIEVEMENTS: Achievement[] = [
33 { id: 'first-blood', name: 'First Blood: first test battle', isEarned: h => h.stats.battles >= 1 },
34 { id: 'slime-slayer', name: 'Slime Slayer: 100 test battles', isEarned: h => h.stats.battles >= 100 },
35 { id: 'blacksmith', name: 'Blacksmith: 100 files forged', isEarned: h => h.stats.forged >= 100 },
36 { id: 'cartographer', name: 'Cartographer: 500 rooms explored', isEarned: h => h.stats.explored >= 500 },
37 { id: 'summoner', name: 'Summoner: 10 allies summoned', isEarned: h => h.stats.summons >= 10 },
38 { id: 'phoenix-down', name: 'Phoenix Down: rose from 25 defeats', isEarned: h => h.stats.defeats >= 25 },
39 { id: 'veteran', name: 'Veteran: reached level 10', isEarned: h => levelOf(h.xp).level >= 10 },
40]
41
42/** XP needed to go from `level` to the next: 50, 141, 260, 400 ... 1581 at level 10. */
43export function xpToNext(level: number): number {
44 return Math.round(50 * level ** 1.5)
45}
46
47export function levelOf(xp: number): { level: number; into: number; needed: number } {
48 let level = 1
49 let floor = 0
50 while (xp >= floor + xpToNext(level)) {
51 floor += xpToNext(level)
52 level += 1
53 }
54 return { level, into: xp - floor, needed: xpToNext(level) }
55}
56
57export function titleOf(level: number): string {
58 return TITLES[Math.min(TITLES.length - 1, Math.floor((level - 1) / 3))] ?? 'Novice'
59}
60
61export function xpBar(into: number, needed: number, width = 8): string {
62 const filled = Math.min(width, Math.floor((into / needed) * width))
63 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
64}
65
66export type Reward = { hero: HeroSave; gained: number; levelUp: number | null; earned: string[] }
67
68/** Base XP by scene plus one XP per 5 s the tool ran, the time bonus capped at 4x the base. */
69export function toolXp(scene: SceneId, durationMs: number): number {
70 const base = XP_BY_SCENE[scene] ?? 1
71 const timeBonus = Math.min(base * 4, Math.floor(durationMs / 5000))
72 return base + timeBonus
73}
74
75/** A finished turn is a quest: 5 XP, 2 per tool used and 1 per 15 s, capped at 80. */
76export function questXp(tools: number, durationMs: number): number {
77 return Math.min(80, 5 + tools * 2 + Math.floor(durationMs / 15000))
78}
79
80function grant(hero: HeroSave, gained: number, stats: HeroStats): Reward {
81 const xp = hero.xp + gained
82 const before = levelOf(hero.xp).level
83 const after = levelOf(xp).level
84 const next: HeroSave = { xp, stats, unlocked: hero.unlocked }
85 const earned = ACHIEVEMENTS.filter(a => !hero.unlocked.includes(a.id) && a.isEarned(next))
86
87 return {
88 hero: { ...next, unlocked: [...hero.unlocked, ...earned.map(a => a.id)] },
89 gained,
90 levelUp: after > before ? after : null,
91 earned: earned.map(a => a.name),
92 }
93}
94
95/** Applies one finished tool call; a failure counts a defeat and gives 1 XP. */
96export function reward(hero: HeroSave, scene: SceneId, isError: boolean, durationMs = 0): Reward {
97 const stats = { ...hero.stats }
98 const stat = STAT_BY_SCENE[scene]
99 if (isError) {
100 stats.defeats += 1
101 } else if (stat !== undefined) {
102 stats[stat] += 1
103 }
104 return grant(hero, isError ? 1 : toolXp(scene, durationMs), stats)
105}
106
107export function questReward(hero: HeroSave, tools: number, durationMs: number): Reward {
108 return grant(hero, questXp(tools, durationMs), { ...hero.stats, turns: hero.stats.turns + 1 })
109}
110
111export function heroSheet(hero: HeroSave): string {
112 const { level, into, needed } = levelOf(hero.xp)
113 const s = hero.stats
114 const unlocked = ACHIEVEMENTS.filter(a => hero.unlocked.includes(a.id)).map(a => ` ★ ${a.name}`)
115 return [
116 `Lv ${level} ${titleOf(level)} ${xpBar(into, needed)} ${into}/${needed} XP (${hero.xp} total)`,
117 `Battles ${s.battles} · Forged ${s.forged} · Explored ${s.explored} · Summons ${s.summons} · Treasures ${s.treasures} · Defeats ${s.defeats} · Quests ${s.turns}`,
118 `Achievements ${unlocked.length}/${ACHIEVEMENTS.length}`,
119 ...unlocked,
120 ].join('\n')
121}
122hooks/raster.ts 52 lines1import { PALETTE } from './scenes'
2import type { Grid } from './scenes'
3
4const DEFAULT_COLOR = 0x01000000
5const UPPER_HALF = 0x2580
6const LOWER_HALF = 0x2584
7const SPACE = 0x20
8const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
9
10function colorOf(pixel: string | undefined): number | null {
11 return pixel === undefined ? null : (PALETTE[pixel] ?? null)
12}
13
14function toBase64(bytes: Uint8Array): string {
15 let out = ''
16 for (let i = 0; i < bytes.length; i += 3) {
17 const a = bytes[i] ?? 0
18 const b = bytes[i + 1] ?? 0
19 const c = bytes[i + 2] ?? 0
20 const chunk = (a << 16) | (b << 8) | c
21 out += BASE64[(chunk >> 18) & 63]
22 out += BASE64[(chunk >> 12) & 63]
23 out += i + 1 < bytes.length ? BASE64[(chunk >> 6) & 63] : '='
24 out += i + 2 < bytes.length ? BASE64[chunk & 63] : '='
25 }
26 return out
27}
28
29/** Packs a pixel grid into Raster cells: each cell is two stacked pixels drawn with half blocks. */
30export function toCells(grid: Grid): { columns: number; rows: number; cells: string } {
31 const columns = grid[0]?.length ?? 0
32 const rows = Math.ceil(grid.length / 2)
33 const words = new Uint32Array(columns * rows * 3)
34
35 for (let row = 0; row < rows; row += 1) {
36 for (let x = 0; x < columns; x += 1) {
37 const top = colorOf(grid[row * 2]?.[x])
38 const bottom = colorOf(grid[row * 2 + 1]?.[x])
39 const at = (row * columns + x) * 3
40 if (top === null && bottom === null) {
41 words.set([SPACE, DEFAULT_COLOR, DEFAULT_COLOR], at)
42 } else if (top === null) {
43 words.set([LOWER_HALF, bottom ?? DEFAULT_COLOR, DEFAULT_COLOR], at)
44 } else {
45 words.set([UPPER_HALF, top, bottom ?? DEFAULT_COLOR], at)
46 }
47 }
48 }
49
50 return { columns, rows, cells: toBase64(new Uint8Array(words.buffer)) }
51}
52hooks/scenes.ts 413 lines1export type SceneId =
2 | 'battle'
3 | 'forge'
4 | 'dungeon'
5 | 'summon'
6 | 'treasure'
7 | 'mine'
8 | 'voyage'
9 | 'camp'
10 | 'victory'
11 | 'defeat'
12
13export const SCENE_IDS: readonly SceneId[] = [
14 'battle',
15 'forge',
16 'dungeon',
17 'summon',
18 'treasure',
19 'mine',
20 'voyage',
21 'camp',
22 'victory',
23 'defeat',
24]
25
26/** Scene size in pixels; two pixels stack in one terminal cell. */
27export const PX_WIDTH = 48
28export const PX_HEIGHT = 10
29
30/** Sweetie-16 based palette; `.` is transparent. */
31export const PALETTE: Record<string, number> = {
32 K: 0x1a1c2c,
33 S: 0xffcd9e,
34 H: 0x8f563b,
35 n: 0x5a3a2a,
36 B: 0x3b5dc9,
37 b: 0x41a6f6,
38 U: 0x29366f,
39 W: 0xf4f4f4,
40 w: 0x94b0c2,
41 d: 0x566c86,
42 e: 0x333c57,
43 G: 0x38b764,
44 g: 0xa7f070,
45 D: 0x257179,
46 Y: 0xffcd75,
47 F: 0xffe478,
48 O: 0xef7d57,
49 R: 0xb13e53,
50 P: 0x5d275d,
51 p: 0xc64cc6,
52 C: 0x73eff7,
53}
54
55export type Grid = string[][]
56
57const HERO = [
58 '..KKKKK..',
59 '.KHHHHHK.',
60 '.KHSSSSK.',
61 '.KSKSKSK.',
62 '..KSSSK..',
63 '.KBBBBBK.',
64 'KSKBBBKSK',
65 '.KBYYYBK.',
66 '..KUKUK..',
67]
68const HERO_STEP = [...HERO.slice(0, 8), '.KU...UK.']
69const HERO_CHEER = [
70 'S.KKKKK.S',
71 'KKHHHHHKK',
72 '.KHSSSSK.',
73 '.KSKSKSK.',
74 '..KSSSK..',
75 '.KBBBBBK.',
76 '..KBBBK..',
77 '.KBYYYBK.',
78 '.KU...UK.',
79]
80const HERO_SIT = [
81 '..KKKKK..',
82 '.KHHHHHK.',
83 '.KHSSSSK.',
84 '.KSKSKSK.',
85 '..KSSSK..',
86 '.KBBBBBK.',
87 'KUUUUUUUK',
88]
89
90const SWORD_UP = ['.W.', '.W.', '.W.', '.W.', 'KYK', '.n.']
91const SWORD_SLASH = ['....w.', '.....w', 'nYWWWW', '.....w', '....w.']
92
93const SLIME = [
94 '...KKKK...',
95 '..KggGGK..',
96 '.KgGGGGGK.',
97 '.KGKGGKGK.',
98 'KGGGGGGGGK',
99 'KGGGGGGGGK',
100 '.KKKKKKKK.',
101]
102const SLIME_SQUASH = [
103 '..KKKKKK..',
104 '.KggGGGGK.',
105 'KGGKGGKGGK',
106 'KGGGGGGGGK',
107 'KGGGGGGGGK',
108 '.KKKKKKKK.',
109]
110
111const ANVIL = ['KKKKKKKKK.', 'KeddddddeK', '.KKeeeKK..', '..KeeeK...', '.KKKKKKK..']
112const HAMMER_UP = ['KdK', 'KdK', '.n.', '.n.']
113const HAMMER_DOWN = ['..KK', 'nnKd', '..KK']
114
115const FLAMES = [
116 ['..F..', '.FYF.', '.YOY.', 'FOROF', '.ROR.'],
117 ['.F...', '..FY.', '.YOY.', 'YORO.', '.ROR.'],
118 ['...F.', '.YF..', '.YOY.', '.OROY', '.ROR.'],
119]
120const LOGS = ['nKnKnKn', 'KnnnnnK']
121
122const CHEST_SHUT = [
123 '.KKKKKKKK.',
124 'KnHHHHHHnK',
125 'KHHHHHHHHK',
126 'KKKKYYKKKK',
127 'KnHHYYHHnK',
128 'KHHHHHHHHK',
129 'KKKKKKKKKK',
130]
131const CHEST_OPEN = [
132 '.KKKKKKKK.',
133 'KnHHHHHHnK',
134 'KKKKKKKKKK',
135 'KYFYFYFYYK',
136 'KnHHYYHHnK',
137 'KHHHHHHHHK',
138 'KKKKKKKKKK',
139]
140
141const ROCK = [
142 '....KKKK....',
143 '..KKdddwKK..',
144 '.KddwddddeK.',
145 '.KdeddYdddK.',
146 'KddddddeddeK',
147 'KdYddddddddK',
148 'KKKKKKKKKKKK',
149]
150const PICK_UP = ['.www.', 'w.n.w', '..n..', '..n..']
151const PICK_DOWN = ['...w', 'nnnw', '...w']
152
153const TOMB = [
154 '..KKKK..',
155 '.KwwwwK.',
156 'KwwddwwK',
157 'KwddddwK',
158 'KwwddwwK',
159 'KwwddwwK',
160 'KwwwwwwK',
161 'KwwwwwwK',
162 'KKKKKKKK',
163]
164const GHOST = ['..KKK..', '.KWWWK.', 'KWKWKWK', 'KWWWWWK', 'KWWWWWK', 'KW.W.WK', '.K...K.']
165
166const ORB = ['.KKK.', 'KCWCK', 'KWWWK', 'KCWCK', '.KKK.']
167const ORB_SMALL = ['.C.', 'CWC', '.C.']
168
169const BOAT = [
170 '.......K......',
171 '......KWK.....',
172 '.....KWWWK....',
173 '....KWWWWWK...',
174 '.......K......',
175 'KKKKKKKKKKKKKK',
176 'KnHHHHHHHHHHnK',
177 '.KnHHHHHHHHnK.',
178 '..KKKKKKKKKK..',
179]
180const GULL = ['W.W', '.W.']
181const MOON = ['.YY.', 'YFFY', 'YFFY', '.YY.']
182
183/** A scene's drawing surface: sprite coordinates are relative to the centered 48 px stage. */
184class Canvas {
185 readonly grid: Grid
186 readonly width: number
187 readonly ox: number
188
189 constructor(width: number) {
190 this.width = width
191 this.ox = Math.floor((width - PX_WIDTH) / 2)
192 this.grid = Array.from({ length: PX_HEIGHT }, () => Array.from({ length: width }, () => '.'))
193 }
194
195 /** Sets one pixel in absolute grid coordinates. */
196 set(x: number, y: number, pixel: string): void {
197 const line = this.grid[y]
198 if (line !== undefined && x >= 0 && x < this.width) line[x] = pixel
199 }
200
201 dot(x: number, y: number, pixel: string): void {
202 this.set(this.ox + x, y, pixel)
203 }
204
205 dots(points: readonly (readonly [number, number])[], pixel: string): void {
206 for (const [x, y] of points) this.dot(x, y, pixel)
207 }
208
209 paint(x: number, y: number, sprite: readonly string[], recolor?: string): void {
210 sprite.forEach((row, dy) => {
211 ;[...row].forEach((pixel, dx) => {
212 if (pixel !== '.') this.dot(x + dx, y + dy, recolor !== undefined && pixel !== 'K' ? recolor : pixel)
213 })
214 })
215 }
216
217 /** Paints a sprite so its last row rests just above the ground row. */
218 stand(x: number, sprite: readonly string[], lift = 0, recolor?: string): void {
219 this.paint(x, PX_HEIGHT - 1 - sprite.length - lift, sprite, recolor)
220 }
221
222 /** Fills the ground row across the whole width. */
223 ground(shift = 0, a = 'G', b = 'D'): void {
224 for (let x = 0; x < this.width; x += 1) this.set(x, PX_HEIGHT - 1, (x - this.ox + shift + 4000) % 4 === 0 ? b : a)
225 }
226
227 /** Repeats stage-relative decorations every 48 px so wide scenes stay populated. */
228 tiled(draw: (offset: number) => void): void {
229 const first = -Math.ceil(this.ox / PX_WIDTH) * PX_WIDTH
230 for (let offset = first; this.ox + offset < this.width; offset += PX_WIDTH) draw(offset)
231 }
232}
233
234const pick = <T,>(list: readonly T[], index: number): T => list[index % list.length] as T
235
236type Scene = { frames: number; draw: (c: Canvas, frame: number) => void }
237
238const SCENES: Record<SceneId, Scene> = {
239 battle: {
240 frames: 6,
241 draw: (c, frame) => {
242 c.ground()
243 const heroX = pick([3, 8, 13, 20, 20, 16], frame)
244 c.stand(heroX, frame % 2 === 0 ? HERO : HERO_STEP)
245 if (frame === 3 || frame === 4) {
246 c.paint(heroX + 7, 3, SWORD_SLASH)
247 } else {
248 c.paint(heroX + 7, 0, SWORD_UP)
249 }
250 const isHit = frame === 4
251 c.stand(31, frame % 2 === 0 ? SLIME : SLIME_SQUASH, 0, isHit ? 'W' : undefined)
252 if (isHit || frame === 5) c.dots([[29, 1], [42, 0], [44, 4], [28, 5]], 'Y')
253 },
254 },
255 forge: {
256 frames: 4,
257 draw: (c, frame) => {
258 c.ground(0, 'e', 'd')
259 c.stand(8, HERO)
260 c.stand(18, ANVIL)
261 c.paint(20, PX_HEIGHT - 1 - ANVIL.length - 1, ['OOOOF'])
262 if (frame % 2 === 1) {
263 c.paint(16, 2, HAMMER_DOWN)
264 c.dots(frame === 1 ? [[25, 1], [27, 3], [23, 0], [29, 2]] : [[26, 0], [28, 2], [24, 1], [30, 4]], frame === 1 ? 'F' : 'Y')
265 } else {
266 c.paint(15, 0, HAMMER_UP)
267 }
268 c.stand(34, pick(FLAMES, frame))
269 },
270 },
271 dungeon: {
272 frames: 6,
273 draw: (c, frame) => {
274 const shift = frame * 2
275 for (let y = 0; y < PX_HEIGHT - 1; y += 1) {
276 for (let x = 0; x < c.width; x += 1) {
277 const offset = Math.floor(y / 3) % 2 === 0 ? 0 : 3
278 const isMortar = y % 3 === 2 || (x + shift + offset) % 6 === 0
279 c.set(x, y, isMortar ? 'K' : 'e')
280 }
281 }
282 c.ground(shift, 'd', 'e')
283 for (let base = 6; base < c.width + 24; base += 24) {
284 const x = base - (shift % 24)
285 c.set(x, 2, 'K')
286 c.set(x, 3, 'n')
287 const flame = frame % 2 === 0 ? ['.F.', 'YOY'] : ['F..', 'OYY']
288 flame.forEach((row, dy) => [...row].forEach((p, dx) => p !== '.' && c.set(x - 1 + dx, dy, p)))
289 }
290 c.stand(19, frame % 2 === 0 ? HERO : HERO_STEP)
291 },
292 },
293 summon: {
294 frames: 4,
295 draw: (c, frame) => {
296 c.ground(0, 'P', 'P')
297 for (let x = 22; x < 40; x += 1) c.dot(x, PX_HEIGHT - 1, (x + frame) % 3 === 0 ? 'p' : 'C')
298 c.stand(6, HERO)
299 c.paint(13, 0, SWORD_UP, 'p')
300 if (frame === 0) {
301 c.dots([[27, 6], [33, 4], [30, 2]], 'C')
302 } else if (frame === 1) {
303 c.paint(29, 4, ORB_SMALL)
304 } else {
305 c.paint(28, 2 + (frame % 2), ORB)
306 if (frame === 3) c.dots([[26, 1], [34, 1], [25, 5], [35, 5]], 'F')
307 }
308 },
309 },
310 treasure: {
311 frames: 4,
312 draw: (c, frame) => {
313 c.ground()
314 c.stand(frame === 0 ? 6 : 14, frame % 2 === 0 ? HERO : HERO_STEP)
315 c.stand(26, frame < 2 ? CHEST_SHUT : CHEST_OPEN)
316 if (frame === 3) c.dots([[25, 0], [31, 0], [37, 1], [29, 1], [34, 0]], 'F')
317 },
318 },
319 mine: {
320 frames: 4,
321 draw: (c, frame) => {
322 c.ground(0, 'n', 'e')
323 c.stand(10, HERO)
324 c.stand(22, ROCK)
325 if (frame % 2 === 1) {
326 c.paint(17, 5, PICK_DOWN)
327 c.dots(frame === 1 ? [[22, 1], [20, 3]] : [[21, 0], [23, 2]], 'Y')
328 } else {
329 c.paint(15, 0, PICK_UP)
330 }
331 },
332 },
333 voyage: {
334 frames: 4,
335 draw: (c, frame) => {
336 c.paint(40, 0, ['.FF.', 'FYYF', '.FF.'])
337 c.tiled(offset => c.paint(offset + 6 + frame * 2, 1, GULL))
338 for (let y = 7; y < PX_HEIGHT; y += 1) {
339 for (let x = 0; x < c.width; x += 1) {
340 const crest = (x + frame * 2 + y * 3) % 8
341 const isEmpty = y === 7 && crest > 2
342 if (!isEmpty) c.set(x, y, crest === 0 ? 'C' : y === 9 ? 'U' : 'b')
343 }
344 }
345 const bob = frame % 2
346 c.paint(16, bob, BOAT)
347 c.paint(19, 3 + bob, ['KSK'])
348 },
349 },
350 camp: {
351 frames: 3,
352 draw: (c, frame) => {
353 c.ground(0, 'D', 'e')
354 const stars = [[3, 1], [12, 0], [20, 2], [28, 0], [34, 3], [46, 2]] as const
355 c.tiled(offset =>
356 stars.forEach(([x, y], i) => {
357 if ((i + frame) % 3 !== 0) c.dot(offset + x, y, 'W')
358 }),
359 )
360 c.paint(40, 0, MOON)
361 c.stand(10, HERO_SIT)
362 c.stand(22, pick(FLAMES, frame), 1)
363 c.stand(21, LOGS)
364 },
365 },
366 victory: {
367 frames: 2,
368 draw: (c, frame) => {
369 c.ground()
370 c.stand(18, HERO_CHEER, frame)
371 c.paint(26, -frame, SWORD_UP)
372 const sparkles = frame === 0 ? [[10, 2], [36, 1], [14, 6], [32, 5]] : [[12, 0], [38, 3], [8, 5], [34, 7]]
373 c.tiled(offset => c.dots(sparkles.map(([x, y]) => [offset + (x ?? 0), y ?? 0] as const), 'F'))
374 },
375 },
376 defeat: {
377 frames: 3,
378 draw: (c, frame) => {
379 c.ground(0, 'e', 'd')
380 c.stand(18, TOMB)
381 c.paint(28, 2 - frame, GHOST)
382 },
383 },
384}
385
386export function sceneFrames(id: SceneId): number {
387 return SCENES[id].frames
388}
389
390/** Draws a scene `width` pixels wide (at least the 48 px stage), the action centered. */
391export function drawScene(id: SceneId, tick: number, width = PX_WIDTH): Grid {
392 const canvas = new Canvas(Math.max(PX_WIDTH, Math.floor(width)))
393 const scene = SCENES[id]
394 scene.draw(canvas, tick % scene.frames)
395 return canvas.grid
396}
397
398const TEST_COMMAND = /\b(pytest|jest|vitest|ruff|basedpyright|eslint|tsc|npm (run )?(test|lint))\b/
399const GIT_COMMAND = /^\s*(rtk\s+)?git\b/
400
401export function sceneForTool(tool: string, command?: string): SceneId {
402 if (tool === 'Bash') {
403 if (command !== undefined && TEST_COMMAND.test(command)) return 'battle'
404 if (command !== undefined && GIT_COMMAND.test(command)) return 'treasure'
405 return 'mine'
406 }
407 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'forge'
408 if (tool === 'Read' || tool === 'Grep' || tool === 'Glob') return 'dungeon'
409 if (tool === 'Agent' || tool === 'Task') return 'summon'
410 if (tool === 'WebFetch' || tool === 'WebSearch' || tool.startsWith('mcp__')) return 'voyage'
411 return 'camp'
412}
413types/index.d.ts 33 lines1export type HeroStats = {
2 battles: number
3 forged: number
4 explored: number
5 summons: number
6 treasures: number
7 defeats: number
8 turns: number
9}
10
11export type HeroSave = { xp: number; stats: HeroStats; unlocked: string[] }
12
13export type QuestScene = {
14 active: string | null
15 flash: string | null
16 flashUntil: number
17 startedAt: number
18 tools: number
19 lastGain: number
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'pixel-quest': {
25 now: number
26 scene: QuestScene
27 hero: HeroSave | null
28 isHidden: boolean
29 isWide: boolean
30 }
31 }
32}
33