SLOPSHOPPER

pixel-quest

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

newspinnerguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-18fhva29/claude-code-mods/pixel-quest
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-quest
› fix the failing auth test and add an audit log call ╭─────────────────────────╮ │ pixel-quest │ ⏺ Read(src/auth.ts) │ ⬆ LEVEL UP! Lv 2 Novice │ ⎿ Read 6 lines ╰─────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /toons ⎿ pixel-quest: Pixel quest shown. ✻ Thinking… ▄▀▀▀▀▄ ▄▄▄ ▀▀▀▀▀▀▀▀ ▄▀▀▀▀▀▄ Lv 2 Novice ▰▱▱▱▱▱▱▱ 24/141 XP +4 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
✻ Thinking… ▄▀▀▀▀▄ ▄▄▄ ▀▀▀▀▀▀▀▀ ▄▀▀▀▀▀▄ Lv 2 Novice ▰▱▱▱▱▱▱▱ 24/141 XP +4
README

claude-code-mods

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.

Install

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.

Mods

ModWhat it doesCommand
next-liteSuggests 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-stackRoutes 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-toolsDraws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line./lines
blast-radiusHolds 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-gateHolds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers.
replay-theaterSteps through the file edits Claude made in the last turn, one diff at a time./replay
usage-weatherA forecast band above the prompt: context fill, sparkline, rate limits, model and effort.
prompt-gitAppends the git branch and working-tree counts to the hint line under the prompt.
pixel-questReplaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls./toons
brief-modeAppends 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-neonDraws 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.

Development

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.

Source 5 files
hooks/register.tsx 179 lines
1import { 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}
179
hooks/progress.ts 122 lines
1import 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}
122
hooks/raster.ts 52 lines
1import { 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}
52
hooks/scenes.ts 413 lines
1export 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}
413
types/index.d.ts 33 lines
1export 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