Um Hollow de estimação no Claude Code: reage às ferramentas, abate os bugs com um Cero e dorme quando você some.

Um Hollow de estimação que mora no seu Claude Code. Ele reage ao que o Claude faz: cada build ou teste que falha vira um bug, e quando o mesmo comando passa ele abate o bug com um Cero. Também come os seus prompts e commits, caça enquanto o Claude trabalha e dorme quando você some.
<img src="../../docs/claude-code/terminal-cero.webp" alt="Gravação do Claude Code de verdade: o npm test falha e um bug aparece ao lado do Hollowzinho; o teste passa e o Cero sai da boca dele, atinge o bug, explode, e ele respira aliviado" width="100%">
Os prints deste README são do Claude Code de verdade (v2.1.289, num terminal de 120 colunas), com o plugin instalado: nada aqui é maquete. Só os do desktop são prévias, e dizem isso.
É o mesmo bichinho do <hollow-pet> e da extensão do VS Code, agora em pixel art de terminal. Não precisa de mais nada: não usa rede, não escreve arquivos e não chama modelo.
No Claude Code:
/plugin marketplace add juninmd/hueco-mundo-theme
/plugin install hollowzinho@hueco-mundo
Ou, do terminal: claude plugin marketplace add juninmd/hueco-mundo-theme && claude plugin install hollowzinho@hueco-mundo.
Para experimentar sem instalar, aponte para a pasta (num clone deste repositório):
claude --plugin-dir ./claude-code/hollowzinho
A faixa aparece acima do prompt assim que a sessão abre. Na instalação o Claude Code avisa que 6 opções ainda não foram definidas; pode ignorar, os padrões servem. Para mudar alguma: /plugin configure hollowzinho@hueco-mundo.
Precisa de um Claude Code com plugins de function hooks (módulos em hooks/hooks.json). Foi testado na 2.1.289.
| O que acontece | O que ele faz | |
|---|---|---|
um comando de build ou teste falha (npm test, cargo build, pytest, tsc, make…) | nasce um bug ao lado dele: "Opa… falhou: npm test." | |
| o mesmo comando falha de novo | o bug conta mais uma falha (×2, ×3…) e, na terceira, vira um chefe dourado | |
| o mesmo comando passa | atira o Cero e abate o bug (o chefe vale dois): "Sem bugs! Respirei." | |
| o Claude roda `npm test 2>&1 \ | tail` | o tail esconde o código de saída, então ele lê o final da saída: FAIL, 1 failed, error TS…, Traceback… |
| você manda um prompt | come um pouco do texto e acorda | |
| o Claude trabalha (turno em andamento) | modo caçador: a marca vermelha no rosto | |
| o turno acaba com bugs vivos | fica preocupado e diz quantos faltam | |
| você interrompe o turno | leva um susto | |
| commit, push, PR criado ou mergeado, branch nova | come, comemora, cria vínculo | |
| o Claude edita arquivos | come um pouco, de vez em quando comenta | |
| nada acontece por 5 minutos | dorme (e acorda no próximo prompt) |
O reiatsu (barra verde) cai devagar e o bichinho avisa quando está com fome; o vínculo (♥ vermelho) cresce com comida, carinho, commits e bugs abatidos (✦). Com 15 de vínculo ele vira Adjuchas e com 40, Vasto Lorde.
<img src="../../docs/claude-code/terminal-bug.png" alt="Claude Code: o npm test falhou e um bug pixelado anda até o Hollowzinho, que diz Opa… falhou: npm test." width="100%">
Um bug é um comando, não uma linha. npm test, npm test -- a.test.ts e npm test --watch=false são o mesmo bug. Só o que parece build, teste ou verificação conta: um grep sem resultado também sai com erro e não é problema. Ele não corrige nada: quem faz o teste passar é o Claude (ou você).
<img src="../../docs/claude-code/terminal-cero.png" alt="Claude Code: o Cero atinge o bug, que explode, e o Hollowzinho diz Sem bugs! Respirei." width="100%">
/hollowzinho/hollowzinho [pet | feed | cero | sleep | wake | hide | show | status | reset | pane]
| Subcomando | Faz |
|---|---|
pane | abre o painel do bicho (veja abaixo) |
pet | carinho |
feed | dá reiatsu (+26) |
cero | atira no bug mais perto. Custa 12 de reiatsu e só raspa: quem abate é o teste que passa |
sleep / wake | põe para dormir / acorda |
hide / show | esconde / mostra o bicho (e ele continua escondido nas próximas sessões). O [-] ao lado da faixa, que é do próprio Claude Code, só a recolhe |
status | uma linha com o estado |
reset | recomeça: vínculo e abatidos voltam a 0 e o reiatsu ao valor inicial |
Os nomes em inglês e em português valem (/hollowzinho dormir, /hollowzinho painel, /hollowzinho alimentar…).
/hollowzinho pane abre o painel: o corpo inteiro do bicho, os cinco botões (Carinho, Alimentar, Cero, Dormir, Esconder, também nas teclas 1 a 5) e a lista de bugs vivos. Em tela cheia com 110 colunas ou mais ele fica ao lado da conversa; nas outras, acima do prompt.
<img src="../../docs/claude-code/terminal-pane.png" alt="Claude Code com o painel do Hollowzinho ao lado da conversa: o corpo inteiro do bicho, o Cero a caminho do bug, os botões e a lista Bugs vivos" width="100%">
| Superfície | Faixa acima do prompt | Painel |
|---|---|---|
| Terminal | ✅ pixel art em células (▀▄█, 2 pixels por célula) | ✅ |
| Desktop | ✅ SVG animado | ✅ |
| Celular e VS Code | não existe lá | ✅ SVG |
Num terminal estreito a faixa se adapta: abaixo de 64 colunas a pista dos bugs some (ficam o bicho, o texto e um +N bugs) e abaixo de 40 vira uma linha de texto. Dá para trocar a faixa por só uma linha na barra de status (display: status) ou deixar tudo escondido e abrir só pelo /hollowzinho (display: off).
<img src="../../docs/claude-code/desktop-band.png" alt="Prévia da faixa no app desktop: o Hollowzinho em SVG ao lado de dois bugs, com a fala Opa… falhou: tsc." width="100%">
<img src="../../docs/claude-code/desktop-cero.png" alt="Prévia da faixa no app desktop: o Cero cruza a faixa e atinge o bug, com a fala Boom, um a menos." width="100%">
<img src="../../docs/claude-code/desktop-pane.png" alt="Prévia do painel no app desktop: o corpo inteiro do Hollowzinho em SVG, os cinco botões e a lista Bugs vivos com um bug comum e um chefe dourado" width="100%">
As imagens do desktop são prévias: o app não dá para ser capturado do jeito que o terminal foi. Elas são a árvore que o plugin entrega ao Claude Code (
npm run claude:preview), desenhada num HTML. Já os do terminal são do Claude Code de verdade, num tmux.
Todas são opcionais (/plugin configure hollowzinho@hueco-mundo):
| Opção | Valores | Padrão | O que muda |
|---|---|---|---|
display | band, status, off | band | a faixa acima do prompt, só uma linha na barra de status, ou nada |
language | auto, pt, en | auto | idioma dos balões; auto segue LC_ALL/LC_MESSAGES/LANG (pt* fala português, o resto, inglês) |
name | texto | Hollowzinho | o nome do bicho |
tint | cero, reishi, ouro | cero | a cor das marcas e do raio: vermelho, verde ou dourado |
chatter | off, low, normal | low | quanto ele fala sozinho: nada, de vez em quando, bastante |
sleepAfterMinutes | número ≥ 0 | 5 | minutos sem atividade até dormir; 0 nunca dorme |
|| true). Os bugs vivos mostram só o comando (npm test), nunca a saída.$.store, no armazenamento do plugin do Claude Code): reiatsu, vínculo, abatidos, se está escondido e a hora da última vez. Nenhum comando, nenhuma saída, nenhum prompt, nenhum arquivo seu.Ele só roda em sessões interativas: em claude -p fica desligado.
/hollowzinho cero só raspa.FAIL, failed ou parecidos, atrás de um pipe, passa despercebido.CLAUDE_CODE_TMUX_TRUECOLOR=1. A paleta do bicho já é de 256 cores, então nada muda.claude plugin test), mas a prévia é um HTML, não o app.npm run claude:check # os manifestos; com o `claude` no PATH, também validate --strict e test (é o que o npm test roda)
npm run claude:test # claude plugin test: a lógica do bicho e o plugin inteiro contra o motor
npm run claude:preview # refaz as prévias do desktop em docs/claude-code/ (precisa do Chromium do Playwright)
npx tsc -p claude-code/hollowzinho # tipos (o motor escreve .claude-plugin/types/ ao carregar o plugin)
.claude-plugin/plugin.json o manifesto: nome, versão e as seis opções
hooks/hooks.json um módulo só: register.tsx
hooks/register.tsx os eventos (sessão, prompt, turno, ferramenta, comando, desenho), o relógio e o que se guarda
hooks/pet.ts a lógica pura: bugs, Cero, humor, fome, sono, evolução
hooks/view.tsx a faixa e o painel (terminal em Raster, as outras superfícies em Svg)
hooks/sprite.ts a pixel art (o avatar 12×10, o corpo 16×16, o bug, o raio e a explosão) e o empacotamento em células
hooks/art.ts a mesma arte em SVG, para o desktop, o celular e o VS Code
hooks/text.ts as falas em português e em inglês
types/index.d.ts o que fica em `$.state`
tests/ pet.test.ts (lógica) e engine.test.tsx (o plugin contra o motor)
A lógica é toda em funções puras sobre um único valor (hollowzinho.pet); o desenho só lê esse valor, então escrever nele redesenha a faixa e o painel sozinho. O bichinho é o mesmo do pet da página e da extensão do VS Code: a arte em SVG é a mesma, com os mesmos estágios, rostos e cores.
hooks/register.tsx 252 lines1// Hollowzinho para o Claude Code: um Hollow de estimação que mora acima do prompt.
2//
3// - Cada comando de build ou teste que falha vira um bugzinho na cena; quando o mesmo comando passa, o pet atira um Cero e abate.
4// - Edições, commits, pushes e PRs alimentam o bicho; um turno em andamento o põe em modo caçador; parado, ele dorme.
5// - /hollowzinho abre o painel (ou carinho, comida, Cero, sono) e guarda o vínculo entre as sessões.
6//
7// Este arquivo só liga o Claude Code à lógica do pet.ts e ao desenho do view.tsx. Nada aqui altera o que o Claude faz:
8// as ferramentas rodam como sempre e o resultado volta intacto, mesmo que o pet tropece.
9
10import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
11
12import type { Hollow } from '../types'
13import { act, begin, nextDue, onPrompt, onTool, onTurnEnd, onTurnStart, restore, rulesOf, save, seed, statusLine, step, type Action, type Rules, type ToolInfo } from './pet'
14import type { Tint } from './sprite'
15import { WORDS } from './text'
16import { remoteBand, remotePane, statusText, terminalBand, terminalLine, terminalPane, type Look } from './view'
17
18type Dollar = EngineInterface
19
20const PET = { plugin: 'hollowzinho', key: 'pet' } as const
21const PANE = 'hollowzinho'
22const SAVED = 'pet'
23// Do que o Bash escreveu, só o final importa (o resumo do teste vem por último).
24const OUTPUT_TAIL = 4000
25
26const text = (v: unknown, d: string): string => (typeof v === 'string' && v.trim() !== '' ? v.trim() : d)
27const oneOf = <T extends string>(v: unknown, list: readonly T[], d: T): T => (list.includes(v as T) ? (v as T) : d)
28
29/** O que o `/hollowzinho` entende, em português e em inglês. */
30const WORDS_OF: Readonly<Record<string, Action | 'status' | 'pane' | 'help'>> = {
31 '': 'pane', pane: 'pane', painel: 'pane', open: 'pane', abrir: 'pane',
32 pet: 'pet', carinho: 'pet',
33 feed: 'feed', alimentar: 'feed', comer: 'feed',
34 cero: 'cero',
35 sleep: 'sleep', dormir: 'sleep',
36 wake: 'wake', acordar: 'wake',
37 hide: 'hide', esconder: 'hide',
38 show: 'show', mostrar: 'show',
39 reset: 'reset', recomecar: 'reset', 'recomeçar': 'reset',
40 status: 'status', estado: 'status',
41 help: 'help', ajuda: 'help', '?': 'help',
42}
43
44/* ───────────────────────── configuração e estado do módulo ─────────────────────────
45 * O motor só deixa passar o `$` a funções declaradas no topo do arquivo (assim `claude plugin validate` lista tudo o que o
46 * módulo chama). Por isso o que `register` recebe fica aqui em cima, em variáveis do módulo: elas valem por carga, e uma
47 * recarga a quente começa do zero. */
48
49type Config = { options: PluginOptions; name: string; tint: Tint; display: 'band' | 'status' | 'off' }
50
51let cfg: Config = { options: {}, name: 'Hollowzinho', tint: 'cero', display: 'band' }
52/** Sem pessoa à frente (`claude -p`, SDK) não há o que desenhar: o bicho dorme quieto. */
53let live = true
54let rulesCache: Promise<Rules> | undefined
55let timer: Timer | undefined
56let saver: Timer | undefined
57
58const lookOf = (r: Rules): Look => ({ name: cfg.name, lang: r.lang, tint: cfg.tint })
59
60async function rules($: Dollar): Promise<Rules> {
61 rulesCache ??= (async () => {
62 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
63 return rulesOf(cfg.options, env, Math.random)
64 })()
65 return rulesCache
66}
67
68/* ───────── estado: uma única fonte, lida pela tela e escrita só por aqui ───────── */
69
70async function persist($: Dollar): Promise<void> {
71 const { value } = await $.state.get(PET)
72 if (value) await $.store.set(SAVED, save(value, await $.clock.now()))
73}
74
75function saveSoon($: Dollar): void {
76 saver?.cancel()
77 saver = $.clock.after(1500, () => void persist($).catch(() => {}))
78}
79
80async function paintStatus($: Dollar, p: Hollow): Promise<void> {
81 if (cfg.display !== 'status') return
82 $.ui.status(p.hidden ? undefined : statusText(p, lookOf(await rules($))))
83}
84
85/** Aplica `fn` ao estado. Se `fn` devolve o mesmo objeto, nada é escrito e nada é redesenhado. */
86async function change($: Dollar, fn: (p: Hollow) => Hollow): Promise<{ state: Hollow; changed: boolean }> {
87 for (let tries = 0; tries < 8; tries++) {
88 const held = await $.state.get(PET)
89 const before = held.value ?? seed(0)
90 const after = fn(before)
91 if (after === before) return { state: before, changed: false }
92 const wrote = await $.state.set(PET, after, { ifVersion: held.version })
93 if (wrote.isSet) {
94 void paintStatus($, after).catch(() => {})
95 saveSoon($)
96 return { state: after, changed: true }
97 }
98 }
99 return { state: (await $.state.get(PET)).value ?? seed(0), changed: false }
100}
101
102/* ───────── o tempo: um único relógio que acorda quando há o que fazer ───────── */
103
104async function arm($: Dollar, justChanged = true): Promise<void> {
105 timer?.cancel()
106 const r = await rules($)
107 const now = await $.clock.now()
108 const p = (await $.state.get(PET)).value ?? seed(0)
109 const due = nextDue(p, r)
110 if (!Number.isFinite(due)) return
111 // Vencido e nada mudou na última volta: espera um pouco em vez de rodar sem parar.
112 const delay = due <= now ? (justChanged ? 40 : 1000) : Math.min(Math.max(due - now, 40), 60_000)
113 timer = $.clock.after(delay, () => void tick($))
114}
115
116async function tick($: Dollar): Promise<void> {
117 try {
118 const r = await rules($)
119 const now = await $.clock.now()
120 const { changed } = await change($, (p) => step(p, now, r))
121 await arm($, changed)
122 } catch (_) {
123 timer = $.clock.after(5000, () => void tick($))
124 }
125}
126
127async function run($: Dollar, fn: (p: Hollow, now: number, r: Rules) => Hollow): Promise<Hollow> {
128 const r = await rules($)
129 const now = await $.clock.now()
130 const { state } = await change($, (p) => fn(p, now, r))
131 await arm($)
132 return state
133}
134
135/* ───────────────────────── os ganchos ───────────────────────── */
136
137export const register: Register = (on, options) => {
138 cfg = {
139 options,
140 name: text(options.name, 'Hollowzinho').slice(0, 24),
141 tint: oneOf<Tint>(options.tint, ['cero', 'reishi', 'ouro'], 'cero'),
142 display: oneOf(options.display, ['band', 'status', 'off'] as const, 'band'),
143 }
144 live = true
145 rulesCache = undefined
146 timer = undefined
147 saver = undefined
148
149 /* ───────── a sessão ───────── */
150
151 on('session.start', async ($, e, next) => {
152 live = e.isInteractive
153 if (live) {
154 const r = await rules($)
155 const now = await $.clock.now()
156 const saved = restore(await $.store.get(SAVED), now)
157 await $.command.register({ name: 'hollowzinho', description: WORDS[r.lang].ui.help, argumentHint: '[pet|feed|cero|sleep|wake|hide|show|status|reset|pane]' })
158 await change($, (p) => begin(p, now, saved, r))
159 await arm($)
160 }
161 return next(e)
162 })
163
164 on('session.end', async ($, e, next) => {
165 timer?.cancel()
166 saver?.cancel()
167 if (live) await persist($).catch(() => {})
168 return next(e)
169 })
170
171 /* ───────── o que o Claude faz ───────── */
172
173 on('prompt.submit', async ($, e, next) => {
174 if (live) await run($, (p, now, r) => onPrompt(p, now, e.text, r)).catch(() => {})
175 return next(e)
176 })
177
178 on('turn.start', async ($, e, next) => {
179 if (live) await run($, (p, now, r) => onTurnStart(p, now, r)).catch(() => {})
180 return next(e)
181 })
182
183 on('turn.complete', async ($, e, next) => {
184 const out = await next(e)
185 if (live && e.agentId === undefined) await run($, (p, now, r) => onTurnEnd(p, now, e.reason, r)).catch(() => {})
186 return out
187 })
188
189 // Só observa: o resultado da ferramenta volta como veio, e qualquer tropeço aqui é engolido.
190 on('tool.call', async ($, e, next) => {
191 const ran = await next(e)
192 if (!live || ran.deny !== undefined) return ran
193 try {
194 const info: ToolInfo = { tool: String(e.tool), ok: ran.isError !== true }
195 if (e.tool === 'Bash') {
196 info.command = e.command
197 const bash = (ran.result ?? {}) as { stdout?: unknown; stderr?: unknown; gitOperation?: { commit?: unknown; push?: unknown; pr?: { action?: string } } }
198 if (ran.isError !== true) info.output = `${typeof bash.stdout === 'string' ? bash.stdout : ''}\n${typeof bash.stderr === 'string' ? bash.stderr : ''}`.slice(-OUTPUT_TAIL)
199 const git = bash.gitOperation
200 const newBranch = /\bgit\s+(?:checkout\s+-b|switch\s+-c)\b/.test(e.command)
201 info.git = { commit: !!git?.commit, push: !!git?.push, pr: git?.pr?.action === 'created' || git?.pr?.action === 'merged', branch: newBranch }
202 }
203 await run($, (p, now, r) => onTool(p, now, info, r))
204 } catch (_) {
205 // o bicho tropeçar não pode derrubar a ferramenta
206 }
207 return ran
208 })
209
210 /* ───────── /hollowzinho ───────── */
211
212 on('command.run', { command: 'hollowzinho' }, async ($, e) => {
213 const r = await rules($)
214 const w = WORDS[r.lang].ui
215 const arg = e.args.trim().toLowerCase()
216 const what = WORDS_OF[arg]
217 if (what === undefined) return { text: w.unknown(arg) }
218 if (what === 'help') return { text: w.help }
219 if (what === 'pane') {
220 const opened = await $.ui.open({ id: PANE, title: cfg.name })
221 return { text: opened.isPlaced ? w.paneOpened : w.paneNarrow }
222 }
223 if (what === 'status') return { text: statusLine((await $.state.get(PET)).value ?? seed(0), cfg.name, r.lang) }
224 const after = await run($, (p, now, rr) => act(p, now, what, rr))
225 return { text: `${cfg.name}: ${after.say !== '' ? after.say : statusLine(after, cfg.name, r.lang)}` }
226 })
227
228 /* ───────── o que aparece na tela ───────── */
229
230 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231 if (cfg.display !== 'band' || e.props.hasSurvey) return next(e)
232 const p = (await $.state.get(PET)).value
233 if (!p || p.hidden) return next(e)
234 const look = lookOf(await rules($))
235 if (e.surface === 'terminal') {
236 const t = $.ui.resolve(e)
237 const width = e.props.bodyColumns
238 return width < 40 ? terminalLine(t, p, look) : terminalBand(t, p, look, width)
239 }
240 if (e.surface === 'desktop') return remoteBand($.ui.resolve(e), p, look)
241 return next(e)
242 })
243
244 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245 const p = (await $.state.get(PET)).value ?? seed(0)
246 const look = lookOf(await rules($))
247 const handlers = { act: (action: Action) => void run($, (q, now, r) => act(q, now, action, r)).catch(() => {}) }
248 if (e.surface === 'terminal') return terminalPane($.ui.resolve(e), p, look, e.props.bodyColumns, handlers)
249 return remotePane($.ui.resolve(e), p, look, handlers)
250 })
251}
252hooks/pet.ts 419 lines1// A vida do Hollowzinho no Claude Code, sem nenhuma dependência do motor: funções puras que recebem o estado, o
2// instante e as regras e devolvem o estado novo (ou o mesmo objeto, quando nada mudou).
3// Quem liga isso ao Claude Code é o register.tsx; quem desenha é o view.tsx.
4
5import type { Hollow, HollowBug, HollowFace, HollowSaved } from '../types'
6import { WORDS, choose, pickLang, type Lang } from './text'
7
8export type Chatter = 'off' | 'low' | 'normal'
9
10export type Rules = {
11 lang: Lang
12 chatter: Chatter
13 /** Parado por este tempo, ele dorme. 0 nunca dorme. */
14 sleepAfterMs: number
15 /** Um número em [0, 1): trocar por um fixo deixa os testes previsíveis. */
16 roll: () => number
17}
18
19export const MAX_BUGS = 8
20/** Até quantos bugs a cena desenha (os outros só contam). */
21export const MAX_VISIBLE_BUGS = 6
22export const CERO_STEP_MS = 90
23export const CERO_FRAMES = 9
24/** O quadro em que o raio acerta o alvo. */
25export const CERO_HIT = 4
26export const CERO_COST = 12
27export const BOSS_FAILS = 3
28const SAY_MS = 3600
29const BLINK_MS = 170
30const DECAY_EVERY_MS = 30_000
31const CHEER_GAP_MS = 4_000
32
33/** Lê as opções do plugin (`userConfig`) sem confiar nos tipos que chegam. */
34export function rulesOf(options: Readonly<Record<string, unknown>>, env: string | undefined, roll: () => number = Math.random): Rules {
35 const chatter = options.chatter === 'off' || options.chatter === 'normal' ? options.chatter : 'low'
36 const minutes = typeof options.sleepAfterMinutes === 'number' && options.sleepAfterMinutes >= 0 ? options.sleepAfterMinutes : 5
37 return { lang: pickLang(options.language, env), chatter, sleepAfterMs: Math.round(minutes * 60_000), roll }
38}
39
40const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
41
42export const stageOf = (bond: number): 1 | 2 | 3 => (bond >= 40 ? 3 : bond >= 15 ? 2 : 1)
43export const stageName = (bond: number, lang: Lang): string => WORDS[lang].stages[stageOf(bond) - 1] ?? ''
44
45/** O bicho recém-nascido. Com `now` = 0 é o valor inicial do estado, antes de o motor dizer que horas são. */
46export function seed(now: number): Hollow {
47 return {
48 v: 1,
49 reiatsu: 80,
50 bond: 0,
51 hunted: 0,
52 mood: 'calm',
53 moodUntil: 0,
54 pose: '',
55 poseUntil: 0,
56 say: '',
57 sayUntil: 0,
58 bugs: [],
59 cero: null,
60 hidden: false,
61 working: false,
62 blink: false,
63 lastActive: now,
64 lastTick: now,
65 lastChat: now,
66 lastCheer: 0,
67 blinkAt: now + 4000,
68 edits: 0,
69 greeted: false,
70 }
71}
72
73/* ───────────────────────── guardar e restaurar ───────────────────────── */
74
75export function save(p: Hollow, now: number): HollowSaved {
76 return { v: 1, reiatsu: Math.round(p.reiatsu), bond: p.bond, hunted: p.hunted, hidden: p.hidden, seen: p.greeted, t: now }
77}
78
79/** Valida o que veio do disco: formato desconhecido vira `null`, e o reiatsu cai pelo tempo que o bicho ficou sozinho. */
80export function restore(raw: unknown, now: number): HollowSaved | null {
81 if (!raw || typeof raw !== 'object') return null
82 const r = raw as Record<string, unknown>
83 if (r.v !== 1) return null
84 const num = (v: unknown, d: number): number => (typeof v === 'number' && Number.isFinite(v) ? v : d)
85 const t = num(r.t, now)
86 const away = Math.max(0, (now - t) / 60_000)
87 return {
88 v: 1,
89 reiatsu: clamp(num(r.reiatsu, 80) - Math.min(30, away / 8), 5, 100),
90 bond: Math.max(0, Math.floor(num(r.bond, 0))),
91 hunted: Math.max(0, Math.floor(num(r.hunted, 0))),
92 hidden: r.hidden === true,
93 seen: r.seen === true,
94 t,
95 }
96}
97
98/* ───────────────────────── pequenos efeitos ───────────────────────── */
99
100const speak = (p: Hollow, now: number, text: string, ms = SAY_MS): Hollow => ({ ...p, say: text, sayUntil: now + ms })
101const feel = (p: Hollow, now: number, mood: Hollow['mood'], ms: number): Hollow => ({ ...p, mood, moodUntil: now + ms })
102const wakeUp = (p: Hollow, now: number): Hollow => ({ ...p, pose: p.pose === 'sleep' ? '' : p.pose, lastActive: now })
103const eat = (p: Hollow, amount: number): Hollow => ({ ...p, reiatsu: clamp(p.reiatsu + amount, 0, 100) })
104
105/** Soma vínculo e anuncia a evolução quando passa de estágio. */
106function grow(p: Hollow, now: number, by: number, rules: Rules): Hollow {
107 const before = stageOf(p.bond)
108 const q = { ...p, bond: p.bond + by }
109 const after = stageOf(q.bond)
110 if (after > before) return feel(speak(q, now, WORDS[rules.lang].levelup[after === 3 ? 3 : 2], 4200), now, 'happy', 3000)
111 return q
112}
113
114const pick = (list: readonly string[], rules: Rules): string => choose(list, rules.roll())
115
116/** Comemora sem repetir: no máximo uma vez a cada poucos segundos. */
117function cheer(p: Hollow, now: number, text: string, rules: Rules): Hollow {
118 if (now - p.lastCheer < CHEER_GAP_MS) return p
119 return { ...feel(speak(p, now, text), now, 'happy', 2200), lastCheer: now }
120}
121
122/* ───────────────────────── comandos de build e teste ───────────────────────── */
123
124// Só o que parece build, teste ou verificação vira bug: um `grep` sem resultado também sai com erro e não é problema.
125const BUILD_RE =
126 /\b(test|tests|jest|vitest|mocha|pytest|tox|rspec|phpunit|build|compile|tsc|webpack|rollup|esbuild|make|cmake|gradle|gradlew|mvn|cargo|go\s+(?:test|build|vet)|dotnet\s+(?:test|build)|lint|eslint|ruff|mypy|ctest|bazel|typecheck|type-check)\b/i
127const NOT_BUILD = new Set(['git', 'gh', 'echo', 'cat', 'grep', 'rg', 'ls', 'cd', 'sed', 'awk', 'head', 'tail', 'curl', 'wget', 'mkdir', 'rm', 'cp', 'mv', 'touch', 'which', 'find', 'printf', 'pwd', 'export', 'source', 'sleep', 'kill', 'tmux', 'ps', 'open', 'code', 'less', 'tree', 'diff'])
128const RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'npx', 'pnpx', 'bunx', 'cargo', 'go', 'dotnet', 'make', 'gradle', 'gradlew', 'mvn', 'poetry', 'uv', 'python', 'python3', 'node', 'deno', 'rake', 'bundle', 'composer', 'php', 'swift', 'flutter', 'dart', 'mix', 'sbt', 'tox', 'nox', 'ctest', 'bazel'])
129
130/** Tira o que está entre aspas, para que `git commit -m "add test"` não pareça um teste. */
131const unquote = (s: string): string => s.replace(/"(?:[^"\\]|\\.)*"|'[^']*'|`[^`]*`/g, '""')
132
133/** Primeiro pedaço do comando que parece build ou teste, sem prefixos de ambiente. `null` se nenhum parece. */
134export function buildPart(command: string): string | null {
135 const bare = unquote(command)
136 for (const raw of bare.split(/&&|\|\||;|\||\n/)) {
137 const seg = raw
138 .trim()
139 .replace(/^(?:cd\s+\S+\s*)+/, '')
140 .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '')
141 .replace(/^(?:time|nice|sudo|exec)\s+/, '')
142 .trim()
143 if (!seg) continue
144 const first = (seg.split(/\s+/)[0] ?? '').replace(/^.*\//, '')
145 if (NOT_BUILD.has(first)) continue
146 if (BUILD_RE.test(seg)) return seg
147 }
148 return null
149}
150
151/** A "família" do comando: `npm test -- a.test.ts` e `npm test` são o mesmo bug. */
152export function family(segment: string): string {
153 const words = segment.split(/\s+/).filter(Boolean)
154 const first = (words[0] ?? '').replace(/^.*\//, '')
155 const second = words[1] ?? ''
156 const third = words[2] ?? ''
157 if (!RUNNERS.has(first) || !/^[a-z][\w:.-]*$/i.test(second)) return first.slice(0, 40)
158 if (/^(?:npm|pnpm|yarn|bun)$/.test(first) && second === 'run' && /^[a-z][\w:.-]*$/i.test(third)) return `${first} run ${third}`.slice(0, 40)
159 return `${first} ${second}`.slice(0, 40)
160}
161
162/* ───────────────────────── o código de saída escondido ───────────────────────── */
163
164// O modelo costuma escrever `npm test 2>&1 | tail -30`: o `tail` sai com 0 e o teste que falhou passa como se nada tivesse
165// acontecido. Quando o comando esconde o código de saída (um pipe, um `|| true`), a saída é a única pista.
166const FAIL_MARKS: readonly RegExp[] = [
167 /^\s*(?:---\s+)?(?:FAIL|FAILED|FAILURES|ERROR)\b/m,
168 /^\s*not ok\b|^# fail [1-9]/m,
169 /\b[1-9]\d*\s+(?:failed|failing|failures?|errors?)\b/i,
170 /\bnpm ERR!|\berror\s+TS\d+\s*:|\berror\[E\d+\]\s*:|\berror:\s+(?:could not compile|aborting)/i,
171 /^Traceback \(most recent call last\)|\bAssertionError\b|\bpanicked at\b|\bBUILD (?:FAILURE|FAILED)\b|^make: \*\*\*/m,
172 /[✗✖]/,
173]
174
175/** O comando esconde o código de saída do que roda (um pipe, `|| true`)? */
176export function masksExit(command: string): boolean {
177 const bare = unquote(command)
178 if (/\|\|\s*(?:true|:|exit\s+0)\b/.test(bare)) return true
179 return bare.replace(/\|\|/g, ' ').includes('|')
180}
181
182/** A saída de um build ou teste diz que ele falhou? */
183export function readsFailed(output: string): boolean {
184 return FAIL_MARKS.some((re) => re.test(output))
185}
186
187/* ───────────────────────── o que o Claude Code conta ───────────────────────── */
188
189export type ToolInfo = {
190 tool: string
191 /** A ferramenta terminou sem erro. */
192 ok: boolean
193 /** O comando do Bash, quando é ele. */
194 command?: string
195 /** O final do que o Bash escreveu (stdout e stderr). Só serve quando o comando esconde o código de saída. */
196 output?: string
197 /** O que o Bash diz ter feito com o git (`gitOperation` do resultado), mais a criação de branch, que ele não anuncia. */
198 git?: { commit?: boolean; push?: boolean; pr?: boolean; branch?: boolean }
199}
200
201const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
202
203/** Um comando de build ou teste terminou. */
204function onBuild(p: Hollow, now: number, segment: string, ok: boolean, rules: Rules): Hollow {
205 const w = WORDS[rules.lang]
206 const id = family(segment)
207 const known = p.bugs.find((b) => b.id === id)
208 if (ok) {
209 if (known) {
210 const done = finishCero(p, now, rules)
211 const slot = done.bugs.findIndex((b) => b.id === id)
212 return { ...done, cero: { frame: 0, nextAt: now + CERO_STEP_MS, target: id, slot: Math.max(0, slot), kill: true }, mood: 'calm', moodUntil: 0 }
213 }
214 return cheer(eat(p, 0.4), now, pick(w.passed, rules), rules)
215 }
216 if (known) {
217 const fails = known.fails + 1
218 const bugs = p.bugs.map((b) => (b.id === id ? { ...b, fails, hit: false } : b))
219 const line = fails === BOSS_FAILS ? w.bugBoss(known.label) : w.bugAgain(known.label, fails)
220 return feel(speak({ ...p, bugs }, now, line, 4200), now, 'worried', 3200)
221 }
222 const bug: HollowBug = { id, label: id, fails: 1 }
223 const bugs = [...p.bugs, bug].slice(-MAX_BUGS)
224 return feel(speak({ ...p, bugs }, now, w.bugNew(id), 4200), now, 'worried', 3200)
225}
226
227/** Uma ferramenta terminou. */
228export function onTool(p: Hollow, now: number, info: ToolInfo, rules: Rules): Hollow {
229 const w = WORDS[rules.lang]
230 let q = wakeUp(p, now)
231 const part = info.tool === 'Bash' && info.command ? buildPart(info.command) : null
232 if (part !== null) q = onBuild(q, now, part, info.ok && !(masksExit(info.command ?? '') && readsFailed(info.output ?? '')), rules)
233 else if (!info.ok) {
234 q = feel(q, now, 'worried', 1800)
235 if (rules.chatter !== 'off' && now - q.lastChat > 8_000) q = { ...speak(q, now, pick(w.toolFail, rules), 2400), lastChat: now }
236 }
237 if (!info.ok) return q
238 if (info.git?.commit) q = grow(feel(speak(eat(q, 5), now, pick(w.commit, rules)), now, 'happy', 2200), now, 1, rules)
239 if (info.git?.push) q = grow(cheer({ ...q, lastCheer: 0 }, now, pick(w.push, rules), rules), now, 1, rules)
240 if (info.git?.pr) q = grow(cheer({ ...q, lastCheer: 0 }, now, pick(w.pr, rules), rules), now, 2, rules)
241 if (info.git?.branch) q = feel(speak(q, now, pick(w.branch, rules)), now, 'happy', 2200)
242 if (EDIT_TOOLS.has(info.tool)) {
243 q = { ...eat(q, 0.35), edits: q.edits + 1 }
244 if (rules.chatter === 'normal' && q.edits % 8 === 0 && now - q.lastChat > 25_000 && q.say === '') q = { ...speak(q, now, pick(w.edit, rules), 2600), lastChat: now }
245 } else if (part === null && !info.git) q = eat(q, 0.05)
246 return q
247}
248
249/** O usuário mandou um prompt: o bicho acorda e come um pouco do texto. */
250export function onPrompt(p: Hollow, now: number, text: string, rules: Rules): Hollow {
251 const was = p.pose === 'sleep'
252 let q = eat(wakeUp(p, now), Math.min(1.5, 0.3 + text.length / 160))
253 if (was) return feel(speak(q, now, pick(WORDS[rules.lang].back, rules)), now, 'happy', 1800)
254 if (rules.chatter === 'normal' && now - q.lastChat > 20_000 && q.say === '') q = { ...speak(q, now, pick(WORDS[rules.lang].prompt, rules), 2200), lastChat: now }
255 return q
256}
257
258export function onTurnStart(p: Hollow, now: number, rules: Rules): Hollow {
259 let q = { ...wakeUp(p, now), working: true }
260 if (rules.chatter === 'normal' && now - q.lastChat > 25_000 && q.say === '') q = { ...speak(q, now, pick(WORDS[rules.lang].hunt, rules), 2400), lastChat: now }
261 return q
262}
263
264export type TurnEnd = 'answer' | 'aborted' | 'refusal' | 'error'
265
266export function onTurnEnd(p: Hollow, now: number, reason: TurnEnd, rules: Rules): Hollow {
267 const w = WORDS[rules.lang]
268 const q = { ...p, working: false, lastActive: now }
269 if (reason === 'aborted') return { ...speak(q, now, pick(w.aborted, rules), 2400), pose: 'alert', poseUntil: now + 1400, edits: 0 }
270 if (reason !== 'answer') return feel(speak({ ...q, edits: 0 }, now, pick(w.failed, rules), 3200), now, 'sad', 3200)
271 const fed = eat(q, 1.5)
272 if (fed.bugs.length > 0) return rules.chatter === 'off' ? { ...fed, edits: 0 } : feel(speak({ ...fed, edits: 0 }, now, w.turnBugs(fed.bugs.length), 3600), now, 'worried', 2400)
273 if (fed.edits > 0 && rules.chatter !== 'off') return { ...cheer(fed, now, pick(w.turnDone, rules), rules), edits: 0 }
274 return { ...fed, edits: 0 }
275}
276
277/** Começa a sessão: aplica o que estava guardado e cumprimenta. */
278export function begin(p: Hollow, now: number, saved: HollowSaved | null, rules: Rules): Hollow {
279 const w = WORDS[rules.lang]
280 let q: Hollow = { ...seed(now), greeted: true }
281 let line = pick(w.hello, rules)
282 if (saved) {
283 q = { ...q, reiatsu: saved.reiatsu, bond: saved.bond, hunted: saved.hunted, hidden: saved.hidden }
284 line = now - saved.t > 10 * 60_000 ? pick(w.back, rules) : ''
285 }
286 q = { ...q, bugs: p.bugs }
287 if (!saved || rules.chatter !== 'off') q = line ? { ...speak(q, now, line, 4200), mood: 'happy', moodUntil: now + 2400 } : q
288 return q
289}
290
291/* ───────────────────────── ações do usuário ───────────────────────── */
292
293export type Action = 'pet' | 'feed' | 'cero' | 'sleep' | 'wake' | 'hide' | 'show' | 'reset'
294
295export function act(p: Hollow, now: number, action: Action, rules: Rules): Hollow {
296 const w = WORDS[rules.lang]
297 switch (action) {
298 case 'pet':
299 return grow(feel(speak(eat(wakeUp(p, now), 1), now, pick(w.pet, rules)), now, 'happy', 1800), now, 1, rules)
300 case 'feed': {
301 const awake = wakeUp(p, now)
302 if (awake.reiatsu >= 98) return feel(speak(awake, now, w.full), now, 'happy', 1500)
303 return grow(feel(speak(eat(awake, 26), now, pick(w.feed, rules)), now, 'happy', 1800), now, 2, rules)
304 }
305 case 'cero': {
306 const awake = wakeUp(p, now)
307 if (awake.reiatsu < CERO_COST) return feel(speak(awake, now, w.weak), now, 'sad', 1800)
308 const done = finishCero(awake, now, rules)
309 const target = done.bugs[0]?.id
310 return grow({ ...eat(done, -CERO_COST), cero: { frame: 0, nextAt: now + CERO_STEP_MS, target, slot: target === undefined ? undefined : 0, kill: false } }, now, 1, rules)
311 }
312 case 'sleep':
313 return { ...speak(p, now, w.sleep, 2200), pose: 'sleep', poseUntil: 0, mood: 'calm', moodUntil: 0 }
314 case 'wake':
315 return feel(speak(wakeUp(p, now), now, pick(w.wake, rules)), now, 'happy', 1500)
316 case 'hide':
317 return { ...p, hidden: true }
318 case 'show':
319 return feel(speak({ ...wakeUp(p, now), hidden: false }, now, w.shown), now, 'happy', 1500)
320 case 'reset':
321 return speak({ ...seed(now), greeted: true, hunted: 0, bugs: p.bugs }, now, w.reset)
322 }
323}
324
325/* ───────────────────────── o tempo passa ───────────────────────── */
326
327/** Termina na hora o Cero que está no ar (o abate e as contas valem como se ele tivesse chegado). */
328function finishCero(p: Hollow, now: number, rules: Rules): Hollow {
329 if (!p.cero) return p
330 let q = p
331 while (q.cero) q = advanceCero(q, now, rules)
332 return q
333}
334
335/** O Cero dá um passo; no quadro do acerto, abate (ou só raspa) o alvo. */
336function advanceCero(p: Hollow, now: number, rules: Rules): Hollow {
337 const cero = p.cero
338 if (!cero) return p
339 const w = WORDS[rules.lang]
340 const frame = cero.frame + 1
341 let q: Hollow = { ...p, cero: { ...cero, frame, nextAt: now + CERO_STEP_MS } }
342 if (frame === CERO_HIT && cero.target) {
343 const bug = q.bugs.find((b) => b.id === cero.target)
344 if (bug && cero.kill) {
345 const boss = bug.fails >= BOSS_FAILS
346 const left = q.bugs.filter((b) => b.id !== bug.id)
347 const line = left.length === 0 ? w.bugsClear : pick(boss ? w.bossKill : w.bugKill, rules)
348 q = grow(feel(speak({ ...eat(q, 4), bugs: left, hunted: q.hunted + (boss ? 2 : 1) }, now, line, 4200), now, 'happy', 2600), now, boss ? 2 : 1, rules)
349 } else if (bug) {
350 q = speak({ ...q, bugs: q.bugs.map((b) => (b.id === bug.id ? { ...b, hit: true } : b)) }, now, pick(w.bugHit, rules), 3800)
351 }
352 } else if (frame === CERO_HIT) {
353 q = speak(q, now, pick(w.cero, rules), 1600)
354 }
355 if (frame >= CERO_FRAMES - 1) q = { ...q, cero: null, bugs: q.bugs.some((b) => b.hit) ? q.bugs.map((b) => ({ ...b, hit: false })) : q.bugs }
356 return q
357}
358
359const chatGap = (rules: Rules): number => (rules.chatter === 'normal' ? 3.5 * 60_000 : 7 * 60_000)
360
361/** Faz o que já venceu: balão e humor que acabam, piscar, Cero, sono, fome e fala sozinho. */
362export function step(p: Hollow, now: number, rules: Rules): Hollow {
363 let q = p
364 if (q.cero && now >= q.cero.nextAt) q = advanceCero(q, now, rules)
365 if (q.say !== '' && now >= q.sayUntil) q = { ...q, say: '' }
366 if (q.mood !== 'calm' && now >= q.moodUntil) q = { ...q, mood: 'calm' }
367 if (q.pose === 'alert' && now >= q.poseUntil) q = { ...q, pose: '' }
368 const dozing = q.pose === 'sleep'
369 if (now >= q.blinkAt) {
370 const closing = !q.blink && !dozing && !q.cero
371 q = { ...q, blink: closing, blinkAt: now + (closing ? BLINK_MS : 3500 + Math.floor(rules.roll() * 3000)) }
372 }
373 if (now - q.lastTick >= DECAY_EVERY_MS) {
374 const minutes = (now - q.lastTick) / 60_000
375 q = { ...q, reiatsu: clamp(q.reiatsu - minutes * (dozing ? 0.05 : 0.25), 0, 100), lastTick: now }
376 }
377 if (!dozing && !q.working && !q.cero && rules.sleepAfterMs > 0 && now - q.lastActive >= rules.sleepAfterMs) {
378 q = { ...speak(q, now, WORDS[rules.lang].sleep, 2600), pose: 'sleep', poseUntil: 0, mood: 'calm', moodUntil: 0, blink: false }
379 } else if (!dozing && !q.working && !q.cero && !q.hidden && rules.chatter !== 'off' && q.say === '' && now - q.lastChat >= chatGap(rules) && now - q.lastActive >= 60_000) {
380 const w = WORDS[rules.lang]
381 const line = q.reiatsu < 30 ? pick(w.hungry, rules) : pick(w.idle, rules)
382 q = { ...speak(q, now, line, 4200), lastChat: now }
383 }
384 return q
385}
386
387/** Quando `step` volta a ter o que fazer (ms desde 1970). `Infinity` se nada está marcado. */
388export function nextDue(p: Hollow, rules: Rules): number {
389 if (p.cero) return p.cero.nextAt
390 const due: number[] = [p.lastTick + DECAY_EVERY_MS]
391 if (p.say !== '') due.push(p.sayUntil)
392 if (p.mood !== 'calm') due.push(p.moodUntil)
393 if (p.pose === 'alert') due.push(p.poseUntil)
394 if (p.pose !== 'sleep') due.push(p.blinkAt)
395 if (p.pose !== 'sleep' && !p.working && rules.sleepAfterMs > 0) due.push(p.lastActive + rules.sleepAfterMs)
396 if (p.pose !== 'sleep' && !p.working && !p.hidden && rules.chatter !== 'off') due.push(Math.max(p.lastChat + chatGap(rules), p.lastActive + 60_000))
397 return Math.min(...due)
398}
399
400/* ───────────────────────── o rosto e as contas para a tela ───────────────────────── */
401
402export function faceOf(p: Hollow): HollowFace {
403 if (p.cero) return 'fire'
404 if (p.pose === 'alert') return 'alert'
405 if (p.pose === 'sleep') return 'sleep'
406 if (p.mood === 'worried') return 'worried'
407 if (p.mood === 'sad') return 'sad'
408 if (p.mood === 'happy') return 'happy'
409 if (p.working) return 'scan'
410 return p.blink ? 'blink' : 'idle'
411}
412
413export const isBoss = (b: HollowBug): boolean => b.fails >= BOSS_FAILS
414
415/** A linha de estado para o `/hollowzinho status` e para o status do Claude Code. */
416export function statusLine(p: Hollow, name: string, lang: Lang): string {
417 return WORDS[lang].ui.status(name, stageName(p.bond, lang), Math.round(p.reiatsu), p.bond, p.bugs.length, p.hunted)
418}
419hooks/sprite.ts 358 lines1// A arte do terminal: o Hollowzinho em pixel art, desenhado em tempo de execução e empacotado em meias-células.
2// Cada célula do terminal mostra dois pixels, um sobre o outro (▀ com a cor da frente no de cima e a de fundo no de baixo),
3// então 12×10 pixels ocupam 12 colunas por 5 linhas. O fundo é sempre o do terminal, para servir a qualquer tema.
4// Sem imports: dá para rodar este arquivo direto no Node para espiar os quadros (veja scripts/claude-plugin-preview.mjs).
5
6import type { HollowFace } from '../types'
7
8export type Tint = 'cero' | 'reishi' | 'ouro'
9export type Grid = readonly string[]
10
11/** Sem cor: o terminal escolhe (0x01000000, o bit 24 sozinho). */
12export const DEFAULT = 0x01000000
13const CLEAR = -1
14
15// As cores são da paleta de 256 do xterm (os níveis 0, 95, 135, 175, 215 e 255 e a rampa de cinzas): o Claude Code leva
16// cada célula do Raster à cor mais próxima dessa paleta, e com estas o desenho sai igual em qualquer terminal.
17const BASE: Readonly<Record<string, number>> = {
18 W: 0xffffd7, // osso
19 w: 0xd7d7af, // sombra do osso
20 d: 0x87875f, // contorno
21 K: 0x080808, // preto das órbitas e da boca
22 B: 0x1c1c1c, // corpo
23 b: 0x3a3a3a, // braços
24 C: 0x5f5f87, // borda clara do corpo
25 G: 0xffaf00, // íris
26 O: 0xd78700, // íris, sombra
27 H: 0xffffff, // brilho
28 S: 0x87d7ff, // suor e lágrima
29 P: 0xff87af, // bochecha
30 Z: 0xd7d7ff, // o Z do sono
31 A: 0xffd75f, // bug dourado
32 Y: 0xffffaf, // bug dourado, brilho
33 E: 0xffafaf, // clarão do Cero
34 L: 0xffffff, // miolo do raio
35}
36
37const TINTS: Readonly<Record<Tint, { R: number; r: number; D: number }>> = {
38 cero: { R: 0xd7005f, r: 0xff5f5f, D: 0xaf0000 },
39 reishi: { R: 0x00af5f, r: 0x5fff87, D: 0x00875f },
40 ouro: { R: 0xaf8700, r: 0xffd75f, D: 0x875f00 },
41}
42
43export function palette(tint: Tint): Readonly<Record<string, number>> {
44 return { ...BASE, ...(TINTS[tint] ?? TINTS.cero) }
45}
46
47/* ───────────────────────── tela de pixels ───────────────────────── */
48
49export class Canvas {
50 readonly width: number
51 readonly height: number
52 readonly px: Int32Array
53
54 constructor(width: number, height: number) {
55 this.width = width
56 this.height = height
57 this.px = new Int32Array(width * height).fill(CLEAR)
58 }
59
60 set(x: number, y: number, color: number): void {
61 if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.px[y * this.width + x] = color
62 }
63
64 get(x: number, y: number): number {
65 return x >= 0 && y >= 0 && x < this.width && y < this.height ? (this.px[y * this.width + x] ?? CLEAR) : CLEAR
66 }
67
68 rect(x0: number, y0: number, x1: number, y1: number, color: number): void {
69 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) this.set(x, y, color)
70 }
71
72 /** Cola uma grade de letras na posição dada; `.` e espaço são transparentes. */
73 blit(grid: Grid, x: number, y: number, pal: Readonly<Record<string, number>>): void {
74 grid.forEach((row, dy) => {
75 for (let dx = 0; dx < row.length; dx++) {
76 const c = pal[row[dx] ?? '.']
77 if (c !== undefined) this.set(x + dx, y + dy, c)
78 }
79 })
80 }
81}
82
83/* ───────────────────────── montar grades por camadas ───────────────────────── */
84
85type Draft = string[][]
86const draft = (w: number, h: number): Draft => Array.from({ length: h }, () => Array<string>(w).fill('.'))
87const put = (g: Draft, x: number, y: number, c: string): void => {
88 const row = g[y]
89 if (row && x >= 0 && x < row.length) row[x] = c
90}
91const fill = (g: Draft, x0: number, y0: number, x1: number, y1: number, c: string): void => {
92 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) put(g, x, y, c)
93}
94const rows = (g: Draft): Grid => g.map((r) => r.join(''))
95
96/** Contorna uma silhueta: o que encosta no vazio vira `d`. */
97function outline(g: Draft): Draft {
98 const h = g.length
99 const w = g[0]?.length ?? 0
100 const solid = (x: number, y: number): boolean => (g[y]?.[x] ?? '.') !== '.'
101 const out = draft(w, h)
102 for (let y = 0; y < h; y++)
103 for (let x = 0; x < w; x++) {
104 if (!solid(x, y)) continue
105 const edge = !solid(x + 1, y) || !solid(x - 1, y) || !solid(x, y + 1) || !solid(x, y - 1)
106 put(out, x, y, edge ? 'd' : (g[y]?.[x] ?? 'W'))
107 }
108 return out
109}
110
111/* ───────────────────────── o rosto da faixa: 12×10 ───────────────────────── */
112
113export const AVATAR_W = 12
114export const AVATAR_H = 10
115
116function avatarBase(stage: 1 | 2 | 3): Draft {
117 const g = draft(AVATAR_W, AVATAR_H)
118 const head: ReadonlyArray<readonly [number, number, number]> = [
119 [1, 3, 8], [2, 1, 10], [3, 0, 11], [4, 0, 11], [5, 0, 11], [6, 0, 11], [7, 0, 11], [8, 1, 10], [9, 3, 8],
120 ]
121 for (const [y, a, b] of head) fill(g, a, y, b, y, 'W')
122 const o = outline(g)
123 put(o, 3, 0, 'd'); put(o, 4, 0, 'd'); put(o, 2, 1, 'd'); put(o, 3, 1, 'W'); put(o, 4, 1, 'd')
124 fill(o, 5, 1, 6, 4, 'R')
125 put(o, 1, 8, 'R'); put(o, 10, 8, 'R')
126 if (stage >= 2) { put(o, 8, 0, 'd'); put(o, 9, 0, 'd'); put(o, 9, 1, 'd'); put(o, 8, 1, 'W') }
127 if (stage >= 3) { put(o, 5, 0, 'd'); put(o, 6, 0, 'd'); put(o, 5, 1, 'R'); put(o, 6, 1, 'R') }
128 return o
129}
130
131const aEye = (g: Draft, x: number, top: number): void => {
132 fill(g, x, top, x + 2, top + 3, 'K'); put(g, x, top + 1, 'H'); put(g, x + 1, top + 1, 'G'); put(g, x + 1, top + 2, 'G'); put(g, x + 2, top + 2, 'O')
133}
134const aEyes = (g: Draft, top = 3): void => { aEye(g, 1, top); aEye(g, 8, top) }
135const aMouth = (g: Draft): void => { fill(g, 3, 8, 8, 8, 'K'); put(g, 4, 8, 'W'); put(g, 6, 8, 'W'); put(g, 8, 8, 'W') }
136const aClosed = (g: Draft): void => { fill(g, 1, 5, 3, 5, 'K'); fill(g, 8, 5, 10, 5, 'K') }
137
138const AVATAR_FACES: Readonly<Record<HollowFace, (g: Draft) => void>> = {
139 idle: (g) => { aEyes(g); aMouth(g) },
140 blink: (g) => { aClosed(g); aMouth(g) },
141 happy: (g) => {
142 for (const x of [1, 8]) { put(g, x + 1, 4, 'K'); put(g, x, 5, 'K'); put(g, x + 2, 5, 'K') }
143 put(g, 1, 6, 'P'); put(g, 10, 6, 'P'); aMouth(g)
144 },
145 worried: (g) => {
146 aEyes(g, 4); put(g, 1, 3, 'K'); put(g, 2, 2, 'K'); put(g, 10, 3, 'K'); put(g, 9, 2, 'K'); aMouth(g); put(g, 11, 1, 'S'); put(g, 11, 2, 'S')
147 },
148 sad: (g) => { aEyes(g, 4); fill(g, 1, 3, 3, 3, 'K'); fill(g, 8, 3, 10, 3, 'K'); aMouth(g); put(g, 0, 8, 'S'); put(g, 0, 9, 'S') },
149 sleep: (g) => {
150 aClosed(g); aMouth(g)
151 for (const [x, y] of [[9, 0], [10, 0], [11, 0], [10, 1], [9, 2], [10, 2], [11, 2]] as const) put(g, x, y, 'Z')
152 },
153 scan: (g) => {
154 aEyes(g)
155 for (let y = 2; y <= 8; y++)
156 for (let x = 7; x <= 11; x++) {
157 const edge = y === 2 || y === 8 || x === 7 || x === 11
158 const corner = (x === 7 || x === 11) && (y === 2 || y === 8)
159 if (edge && !corner) put(g, x, y, 'r')
160 }
161 aMouth(g)
162 },
163 alert: (g) => { aEyes(g, 2); aMouth(g); put(g, 11, 0, 'r'); put(g, 11, 1, 'r'); put(g, 11, 3, 'r') },
164 fire: (g) => { aEyes(g, 4); fill(g, 1, 3, 3, 3, 'K'); fill(g, 8, 3, 10, 3, 'K'); fill(g, 4, 7, 7, 8, 'K'); put(g, 5, 8, 'r'); put(g, 6, 8, 'r') },
165}
166
167export function avatar(face: HollowFace, stage: 1 | 2 | 3 = 1): Grid {
168 const g = avatarBase(stage)
169 AVATAR_FACES[face](g)
170 return rows(g)
171}
172
173/* ───────────────────────── a figura inteira: 16×16 ───────────────────────── */
174
175export const BODY_W = 16
176export const BODY_H = 16
177
178function bodyBase(stage: 1 | 2 | 3): Draft {
179 const g = draft(BODY_W, BODY_H)
180 const head: ReadonlyArray<readonly [number, number, number]> = [
181 [2, 5, 10], [3, 3, 12], [4, 2, 13], [5, 1, 14], [6, 1, 14], [7, 1, 14], [8, 1, 14], [9, 2, 13], [10, 3, 12], [11, 4, 11],
182 ]
183 for (const [y, a, b] of head) fill(g, a, y, b, y, 'W')
184 const o = outline(g)
185 for (let x = 5; x <= 10; x++) if (o[10]?.[x] === 'W') put(o, x, 10, 'w')
186 for (const [x, y, c] of [[4, 0, 'd'], [5, 0, 'd'], [3, 1, 'd'], [4, 1, 'W'], [5, 1, 'd'], [6, 1, 'd'], [3, 2, 'd'], [4, 2, 'W']] as const) put(o, x, y, c)
187 fill(o, 7, 3, 8, 6, 'R')
188 fill(o, 2, 9, 2, 10, 'R'); fill(o, 13, 9, 13, 10, 'R')
189 fill(o, 5, 12, 10, 15, 'B'); fill(o, 3, 12, 4, 14, 'b'); fill(o, 11, 12, 12, 14, 'b')
190 for (const [x, y] of [[3, 12], [12, 12], [5, 12], [10, 12]] as const) put(o, x, y, 'C')
191 for (const [x, y, c] of [[7, 13, 'R'], [8, 13, 'R'], [6, 14, 'R'], [9, 14, 'R'], [7, 14, 'K'], [8, 14, 'K'], [7, 15, 'R'], [8, 15, 'R'], [4, 15, 'W'], [11, 15, 'W']] as const) put(o, x, y, c)
192 if (stage >= 2) { put(o, 10, 1, 'd'); put(o, 11, 1, 'd'); put(o, 11, 2, 'd'); put(o, 10, 2, 'W') }
193 if (stage >= 3) { put(o, 7, 1, 'd'); put(o, 8, 1, 'd'); put(o, 7, 2, 'R'); put(o, 8, 2, 'R') }
194 return o
195}
196
197const fEye = (g: Draft, x: number, top: number): void => {
198 fill(g, x, top, x + 3, top + 3, 'K'); put(g, x + 1, top + 1, 'H'); put(g, x + 2, top + 1, 'G'); put(g, x + 1, top + 2, 'G'); put(g, x + 2, top + 2, 'O')
199}
200const fEyes = (g: Draft, top = 5): void => { fEye(g, 2, top); fEye(g, 10, top) }
201const fMouth = (g: Draft): void => { fill(g, 4, 10, 11, 10, 'K'); for (const x of [5, 7, 9]) put(g, x, 10, 'W'); put(g, 4, 9, 'd'); put(g, 11, 9, 'd') }
202const fClosed = (g: Draft): void => { fill(g, 2, 7, 5, 7, 'K'); fill(g, 10, 7, 13, 7, 'K') }
203
204const BODY_FACES: Readonly<Record<HollowFace, (g: Draft) => void>> = {
205 idle: (g) => { fEyes(g); fMouth(g) },
206 blink: (g) => { fClosed(g); fMouth(g) },
207 happy: (g) => {
208 for (const x of [2, 10]) { put(g, x + 1, 6, 'K'); put(g, x + 2, 6, 'K'); put(g, x, 7, 'K'); put(g, x + 3, 7, 'K') }
209 fill(g, 2, 8, 3, 8, 'P'); fill(g, 12, 8, 13, 8, 'P'); fMouth(g)
210 },
211 worried: (g) => {
212 fEyes(g, 6); put(g, 2, 5, 'K'); put(g, 3, 4, 'K'); put(g, 13, 5, 'K'); put(g, 12, 4, 'K'); fMouth(g); put(g, 6, 10, 'K'); put(g, 9, 10, 'K')
213 for (const y of [2, 3, 4]) put(g, 14, y, 'S')
214 put(g, 13, 3, 'S')
215 },
216 sad: (g) => { fEyes(g, 6); fill(g, 2, 5, 3, 5, 'K'); fill(g, 12, 5, 13, 5, 'K'); fMouth(g); put(g, 1, 9, 'S'); put(g, 1, 10, 'S') },
217 sleep: (g) => {
218 fClosed(g); fMouth(g)
219 for (const [x, y] of [[12, 0], [13, 0], [14, 0], [13, 1], [12, 2], [13, 2], [14, 2]] as const) put(g, x, y, 'Z')
220 },
221 scan: (g) => {
222 fEyes(g)
223 for (let y = 4; y <= 10; y++)
224 for (let x = 9; x <= 14; x++) {
225 const edge = y === 4 || y === 10 || x === 9 || x === 14
226 const corner = (x === 9 || x === 14) && (y === 4 || y === 10)
227 if (edge && !corner) put(g, x, y, 'r')
228 }
229 put(g, 15, 5, 'r'); fMouth(g)
230 },
231 alert: (g) => { fEyes(g, 4); fMouth(g); for (const y of [0, 1, 2, 4]) put(g, 15, y, 'r') },
232 fire: (g) => {
233 fEyes(g, 6); fill(g, 2, 5, 5, 5, 'K'); fill(g, 10, 5, 13, 5, 'K')
234 fill(g, 5, 9, 10, 10, 'K'); put(g, 7, 10, 'r'); put(g, 8, 10, 'r'); put(g, 6, 9, 'W'); put(g, 9, 9, 'W')
235 },
236}
237
238export function body(face: HollowFace, stage: 1 | 2 | 3 = 1): Grid {
239 const g = bodyBase(stage)
240 BODY_FACES[face](g)
241 return rows(g)
242}
243
244/* ───────────────────────── bugs, raio e explosão ───────────────────────── */
245
246export const BUG_W = 8
247export const BUG_H = 6
248
249/** O bug: um besouro vermelho; o chefe é dourado. `legs` alterna as perninhas para ele andar. */
250export function bug(boss: boolean, legs = false): Grid {
251 const g = draft(BUG_W, BUG_H)
252 const shell = boss ? 'A' : 'R'
253 const shine = boss ? 'Y' : 'r'
254 const dark = boss ? 'O' : 'K'
255 for (const [x, y] of [[1, 0], [6, 0], [2, 1], [5, 1]] as const) put(g, x, y, 'K')
256 fill(g, 2, 2, 5, 4, shell); put(g, 1, 3, shell); put(g, 6, 3, shell); put(g, 3, 2, shine); put(g, 4, 2, shine)
257 put(g, 3, 3, dark); put(g, 4, 3, dark); put(g, 2, 4, dark); put(g, 5, 4, dark)
258 for (const x of legs ? [0, 2, 5, 7] : [1, 3, 4, 6]) put(g, x, 5, 'K')
259 put(g, 0, 2, 'K'); put(g, 7, 2, 'K')
260 return rows(g)
261}
262
263/** O bug que levou um raspão pisca em branco. */
264export function bugHit(boss: boolean): Grid {
265 return bug(boss).map((r) => r.replace(/[RrAYO]/g, 'H'))
266}
267
268export const BOOM_SIZE = 7
269
270const BOOM: ReadonlyArray<Grid> = [
271 ['.......', '.......', '..HHH..', '..HHH..', '..HHH..', '.......', '.......'],
272 ['...H...', '.rHYHr.', '.HYAYH.', 'HYAHAYH', '.HYAYH.', '.rHYHr.', '...H...'],
273 ['..r.r..', '.r.E.r.', 'r.....r', '.E...E.', 'r.....r', '.r.E.r.', '..r.r..'],
274 ['.......', '..D.D..', '.......', '.D...D.', '.......', '..D.D..', '.......'],
275]
276
277export function boom(step: number): Grid {
278 return BOOM[Math.min(BOOM.length - 1, Math.max(0, step))] ?? BOOM[0]!
279}
280
281/**
282 * O raio, de `x0` a `x1` (inclusive) na altura `y`, que é o pixel de baixo do miolo. A espessura vai de 1 a 4:
283 * 1 é um fio vermelho, 2 o miolo claro, 3 miolo mais a borda viva (quatro pixels) e 4 acrescenta a borda escura (seis).
284 */
285export function beam(c: Canvas, pal: Readonly<Record<string, number>>, x0: number, x1: number, y: number, level: number): void {
286 if (level <= 0 || x1 < x0) return
287 const L = pal.L ?? 0xffffff
288 const r = pal.r ?? 0xff3b5c
289 const D = pal.D ?? 0xa11236
290 if (level === 1) return c.rect(x0, y, x1, y, r)
291 c.rect(x0, y - 1, x1, y, L)
292 if (level >= 3) { c.rect(x0, y - 2, x1, y - 2, r); c.rect(x0, y + 1, x1, y + 1, r) }
293 if (level >= 4) { c.rect(x0, y - 3, x1, y - 3, D); c.rect(x0, y + 2, x1, y + 2, D) }
294}
295
296/* ───────────────────────── células do terminal ───────────────────────── */
297
298const UPPER_HALF = 0x2580
299const LOWER_HALF = 0x2584
300const FULL = 0x2588
301
302/** Empacota a tela em meias-células: o formato do `Raster` (code point, frente e fundo, em u32 little-endian, base64). */
303export function toCells(c: Canvas): { columns: number; rows: number; cells: string } {
304 const columns = c.width
305 const rowsN = Math.ceil(c.height / 2)
306 const bytes = new Uint8Array(columns * rowsN * 12)
307 const view = new DataView(bytes.buffer)
308 let o = 0
309 for (let cy = 0; cy < rowsN; cy++)
310 for (let x = 0; x < columns; x++) {
311 const top = c.get(x, cy * 2)
312 const bottom = c.get(x, cy * 2 + 1)
313 let glyph = 0x20
314 let fg = DEFAULT
315 let bg = DEFAULT
316 if (top !== CLEAR && bottom !== CLEAR) {
317 if (top === bottom) { glyph = FULL; fg = top } else { glyph = UPPER_HALF; fg = top; bg = bottom }
318 } else if (top !== CLEAR) { glyph = UPPER_HALF; fg = top }
319 else if (bottom !== CLEAR) { glyph = LOWER_HALF; fg = bottom }
320 view.setUint32(o, glyph, true); view.setUint32(o + 4, fg, true); view.setUint32(o + 8, bg, true)
321 o += 12
322 }
323 return { columns, rows: rowsN, cells: base64(bytes) }
324}
325
326const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
327
328/** Base64 padrão, à mão: o ambiente dos plugins não tem `Buffer`. */
329export function base64(bytes: Uint8Array): string {
330 let out = ''
331 for (let i = 0; i < bytes.length; i += 3) {
332 const a = bytes[i] ?? 0
333 const b = bytes[i + 1] ?? 0
334 const d = bytes[i + 2] ?? 0
335 out += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]!
336 out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (d >> 6)]! : '='
337 out += i + 2 < bytes.length ? B64[d & 63]! : '='
338 }
339 return out
340}
341
342/** O contrário, para os testes e para a prévia: lê as células de volta como `[glifo, frente, fundo]`. */
343export function fromCells(cells: string): Array<[number, number, number]> {
344 const clean = cells.replace(/=+$/, '')
345 const bytes: number[] = []
346 let bits = 0
347 let acc = 0
348 for (const ch of clean) {
349 acc = (acc << 6) | B64.indexOf(ch)
350 bits += 6
351 if (bits >= 8) { bits -= 8; bytes.push((acc >> bits) & 255) }
352 }
353 const view = new DataView(new Uint8Array(bytes).buffer)
354 const out: Array<[number, number, number]> = []
355 for (let o = 0; o + 12 <= bytes.length; o += 12) out.push([view.getUint32(o, true), view.getUint32(o + 4, true), view.getUint32(o + 8, true)])
356 return out
357}
358hooks/text.ts 215 lines1// As falas do Hollowzinho no Claude Code, em português e em inglês.
2// A voz é a mesma do pet da página e da extensão do VS Code; aqui as falas falam de ferramentas, turnos e bugs.
3
4export type Lang = 'pt' | 'en'
5
6export type Words = {
7 stages: readonly [string, string, string]
8 hello: readonly string[]
9 back: readonly string[]
10 idle: readonly string[]
11 hungry: readonly string[]
12 prompt: readonly string[]
13 hunt: readonly string[]
14 bugNew: (label: string) => string
15 bugAgain: (label: string, fails: number) => string
16 bugBoss: (label: string) => string
17 bugKill: readonly string[]
18 bossKill: readonly string[]
19 bugsClear: string
20 bugHit: readonly string[]
21 passed: readonly string[]
22 edit: readonly string[]
23 commit: readonly string[]
24 push: readonly string[]
25 pr: readonly string[]
26 branch: readonly string[]
27 turnDone: readonly string[]
28 turnBugs: (n: number) => string
29 aborted: readonly string[]
30 failed: readonly string[]
31 toolFail: readonly string[]
32 sleep: string
33 wake: readonly string[]
34 cero: readonly string[]
35 weak: string
36 pet: readonly string[]
37 feed: readonly string[]
38 full: string
39 levelup: { 2: string; 3: string }
40 reset: string
41 hidden: string
42 shown: string
43 ui: {
44 pane: string
45 pet: string
46 feed: string
47 cero: string
48 sleep: string
49 wake: string
50 hide: string
51 reiatsu: string
52 bond: string
53 hunted: string
54 bugs: string
55 noBugs: string
56 times: (n: number) => string
57 paneHint: string
58 status: (name: string, stage: string, reiatsu: number, bond: number, bugs: number, hunted: number) => string
59 help: string
60 unknown: (arg: string) => string
61 paneOpened: string
62 paneNarrow: string
63 }
64}
65
66const pt: Words = {
67 stages: ['Hollow', 'Adjuchas', 'Vasto Lorde'],
68 hello: ['Cheguei! Tô de olho no seu código.', 'Oi! Posso ficar aqui em cima?'],
69 back: ['Voltou! Senti sua falta.', 'Bem-vindo de volta.', 'Hã? Tô acordado!'],
70 idle: [
71 'Tá quieto demais por aqui…',
72 'Sinto um reiatsu gostoso.',
73 'Cero carregado. É só falar.',
74 'Hueco Mundo é lar, mas aqui tá melhor.',
75 'Me dá um carinho? /hollowzinho pet',
76 'Shhh… tô vigiando os testes.',
77 'Já viu a lua hoje?',
78 ],
79 hungry: ['Meu buraco tá vazio…', 'Fome de reiatsu…', 'Alguém me alimenta? /hollowzinho feed'],
80 prompt: ['Hmm, deixa eu ver…', 'Anotado.', 'Bora!', 'Tô de olho.'],
81 hunt: ['Caçando…', 'Farejando bug…', 'Modo caçador.', 'Atento à saída dos comandos.'],
82 bugNew: (l) => `Opa… falhou: ${l}.`,
83 bugAgain: (l, n) => `${l} de novo (×${n}).`,
84 bugBoss: (l) => `${l} virou um chefe!`,
85 bugKill: ['Bug abatido!', 'Boom, um a menos.', 'Esse não volta.'],
86 bossKill: ['O chefe caiu!', 'Chefe abatido!', 'Mandou bem. Esse era osso duro.'],
87 bugsClear: 'Sem bugs! Respirei.',
88 bugHit: ['Acertei! Mas ele ainda respira.', 'Pegou de raspão. Conserta esse!', 'Ainda tá vivo. Bora corrigir?'],
89 passed: ['Passou!', 'Tudo verde.', 'Cero comemorativo!'],
90 edit: ['Nham, código fresco.', 'Delícia de diff.', 'Mais um arquivo no bucho.'],
91 commit: ['Nham, um commit!', 'Commit fresquinho.', 'Isso alimenta.'],
92 push: ['Enviado!', 'Lá vai ele!', 'No ar!'],
93 pr: ['PR aberto! Que orgulho.', 'Pull request no ar.'],
94 branch: ['Outra branch! Que passeio.', 'Território novo.'],
95 turnDone: ['Pronto!', 'Mandou bem!', 'Acabou? Já?'],
96 turnBugs: (n) => (n === 1 ? 'Ainda tem 1 bug vivo…' : `Ainda tem ${n} bugs vivos…`),
97 aborted: ['Ué, interrompeu?', 'Parei.', 'Tá bom, tá bom.'],
98 failed: ['Deu ruim lá fora.', 'Hmm, isso não parece bom.', 'Poxa…'],
99 toolFail: ['Opa…', 'Hmm.', 'Deu erro, mas segue.'],
100 sleep: 'Zzz…',
101 wake: ['Hã? Tô acordado!', 'Quem chamou?'],
102 cero: ['CERO!', 'Alvo na mira.', 'Boom.'],
103 weak: 'Sem reiatsu pra isso… me alimenta?',
104 pet: ['Hehe!', 'Mais um pouquinho!', 'Bem na máscara.', 'Isso, aí mesmo.', 'Gostei!'],
105 feed: ['Nham! Reiatsu fresquinho.', 'Delícia!', 'Meu buraco tá feliz.', 'Mais, por favor!'],
106 full: 'Tô cheio de reiatsu!',
107 levelup: { 2: 'Evoluí! Agora sou um Adjuchas!', 3: 'Vasto Lorde! Cuidado com o Cero.' },
108 reset: 'Voltei a ser um Hollow novinho.',
109 hidden: 'Escondido. /hollowzinho show chama de volta.',
110 shown: 'Voltei!',
111 ui: {
112 pane: 'Hollowzinho',
113 pet: 'Carinho',
114 feed: 'Alimentar',
115 cero: 'Cero',
116 sleep: 'Dormir',
117 wake: 'Acordar',
118 hide: 'Esconder',
119 reiatsu: 'reiatsu',
120 bond: 'vínculo',
121 hunted: 'abatidos',
122 bugs: 'Bugs vivos',
123 noBugs: 'Nenhum bug vivo. Respirando fundo.',
124 times: (n) => (n > 1 ? `×${n}` : ''),
125 paneHint: 'Um bug nasce quando um comando de build ou teste falha e morre quando o mesmo comando passa.',
126 status: (name, stage, r, b, bugs, h) => `${name} · ${stage} · reiatsu ${r} · vínculo ${b} · bugs vivos ${bugs} · abatidos ${h}`,
127 help: 'Uso: /hollowzinho [pet | feed | cero | sleep | wake | hide | show | status | reset | pane]',
128 unknown: (a) => `Não entendi "${a}". /hollowzinho help lista os comandos.`,
129 paneOpened: 'Painel do Hollowzinho aberto.',
130 paneNarrow: 'Hollowzinho: alargue o terminal para ver o painel.',
131 },
132}
133
134const en: Words = {
135 stages: ['Hollow', 'Adjuchas', 'Vasto Lorde'],
136 hello: ["I'm here! Watching your code.", 'Hi! Can I stay up here?'],
137 back: ['You came back! Missed you.', 'Welcome back.', "Huh? I'm awake!"],
138 idle: [
139 'Too quiet in here…',
140 'Nice reiatsu today.',
141 'Cero charged. Just say the word.',
142 'Hueco Mundo is home, but this is nicer.',
143 'Pet me? /hollowzinho pet',
144 'Shh… watching the tests.',
145 'Seen the moon today?',
146 ],
147 hungry: ['My hole is empty…', 'Hungry for reiatsu…', 'Anyone feeding me? /hollowzinho feed'],
148 prompt: ['Hmm, let me see…', 'Noted.', "Let's go!", 'Watching.'],
149 hunt: ['Hunting…', 'Sniffing for bugs…', 'Hunter mode.', 'Eyes on the command output.'],
150 bugNew: (l) => `Oops… failed: ${l}.`,
151 bugAgain: (l, n) => `${l} again (×${n}).`,
152 bugBoss: (l) => `${l} became a boss!`,
153 bugKill: ['Bug down!', 'Boom, one less.', "That one won't be back."],
154 bossKill: ['The boss fell!', 'Boss down!', 'Well done. That one was tough.'],
155 bugsClear: 'No bugs! Phew.',
156 bugHit: ["Got it! But it's still breathing.", 'Only grazed it. Fix that one!', 'Still alive. Shall we fix it?'],
157 passed: ['Passed!', 'All green.', 'Victory Cero!'],
158 edit: ['Yum, fresh code.', 'Tasty diff.', 'One more file in the belly.'],
159 commit: ['Yum, a commit!', 'Fresh commit.', 'That feeds me.'],
160 push: ['Sent!', 'Off it goes!', 'Live!'],
161 pr: ['PR opened! So proud.', 'Pull request is up.'],
162 branch: ['Another branch! What a walk.', 'New territory.'],
163 turnDone: ['Done!', 'Well done!', 'Already?'],
164 turnBugs: (n) => (n === 1 ? 'There is still 1 live bug…' : `There are still ${n} live bugs…`),
165 aborted: ['Huh, interrupted?', 'Stopped.', 'Okay, okay.'],
166 failed: ['That went wrong out there.', "Hmm, that doesn't look good.", 'Aw…'],
167 toolFail: ['Oops…', 'Hmm.', 'Error, but moving on.'],
168 sleep: 'Zzz…',
169 wake: ["Huh? I'm awake!", 'Who called?'],
170 cero: ['CERO!', 'Target locked.', 'Boom.'],
171 weak: 'Not enough reiatsu… feed me?',
172 pet: ['Hehe!', 'A little more!', 'Right on the mask.', 'Yes, there.', 'Like that!'],
173 feed: ['Yum! Fresh reiatsu.', 'Delicious!', 'My hole is happy.', 'More, please!'],
174 full: "I'm full of reiatsu!",
175 levelup: { 2: 'I evolved! I am an Adjuchas now!', 3: 'Vasto Lorde! Mind the Cero.' },
176 reset: "I'm a brand new Hollow again.",
177 hidden: 'Hidden. /hollowzinho show calls it back.',
178 shown: "I'm back!",
179 ui: {
180 pane: 'Hollowzinho',
181 pet: 'Pet',
182 feed: 'Feed',
183 cero: 'Cero',
184 sleep: 'Sleep',
185 wake: 'Wake',
186 hide: 'Hide',
187 reiatsu: 'reiatsu',
188 bond: 'bond',
189 hunted: 'hunted',
190 bugs: 'Live bugs',
191 noBugs: 'No live bugs. Breathing easy.',
192 times: (n) => (n > 1 ? `×${n}` : ''),
193 paneHint: 'A bug is born when a build or test command fails and dies when the same command passes.',
194 status: (name, stage, r, b, bugs, h) => `${name} · ${stage} · reiatsu ${r} · bond ${b} · live bugs ${bugs} · hunted ${h}`,
195 help: 'Usage: /hollowzinho [pet | feed | cero | sleep | wake | hide | show | status | reset | pane]',
196 unknown: (a) => `I did not get "${a}". /hollowzinho help lists the commands.`,
197 paneOpened: 'Hollowzinho pane opened.',
198 paneNarrow: 'Hollowzinho: widen the terminal to see the pane.',
199 },
200}
201
202export const WORDS: Readonly<Record<Lang, Words>> = { pt, en }
203
204/** `auto` segue o idioma do sistema (`LC_ALL`, `LC_MESSAGES` ou `LANG`): português para `pt*`, inglês no resto. */
205export function pickLang(setting: unknown, env: string | undefined): Lang {
206 if (setting === 'pt' || setting === 'en') return setting
207 return (env || '').toLowerCase().startsWith('pt') ? 'pt' : 'en'
208}
209
210/** Escolhe um item da lista com o sorteador dado (um número em [0, 1)). */
211export function choose<T>(list: readonly T[], roll: number): T {
212 const i = Math.min(list.length - 1, Math.floor(roll * list.length))
213 return list[Math.max(0, i)] as T
214}
215hooks/view.tsx 299 lines1// Atenção: `h` e `Fragment` são a fábrica do JSX, globais do ambiente: nada neste arquivo pode se chamar assim.
2// O que o Hollowzinho desenha: a faixa acima do prompt e o painel, no terminal (pixel art em `Raster`) e nas telas
3// remotas (SVG em `Svg`: desktop, celular e VS Code). Aqui só se monta a árvore: o estado vem pronto do pet.ts.
4
5import type { Elements, RenderElement } from 'claude-code'
6
7import type { Hollow, HollowBug, HollowFace } from '../types'
8import { LANE_H, LANE_SLOT, laneSvg, petSvg } from './art'
9import { CERO_FRAMES, CERO_HIT, MAX_VISIBLE_BUGS, faceOf, isBoss, stageName, stageOf, type Action } from './pet'
10import { AVATAR_H, AVATAR_W, BODY_H, BODY_W, BUG_H, BUG_W, Canvas, avatar, beam, body, boom, bug, bugHit, palette, toCells, type Tint } from './sprite'
11import { WORDS, type Lang } from './text'
12
13type Term = Elements['terminal']
14type Remote = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Svg'>
15
16export type Look = { name: string; lang: Lang; tint: Tint }
17export type Handlers = { act: (action: Action) => void }
18
19const ACCENT: Readonly<Record<Tint, string>> = { cero: '#e11d48', reishi: '#16a34a', ouro: '#d97706' }
20const GOLD = '#d97706'
21const OK = '#22c55e'
22const WARN = '#f59e0b'
23
24/** Um rosto de texto para a linha de status, para quem prefere o bicho bem pequeno. */
25const KAOMOJI: Readonly<Record<HollowFace, string>> = {
26 idle: '(•_•)',
27 blink: '(-_-)',
28 happy: '(^‿^)',
29 worried: '(°_°;)',
30 sad: '(;_;)',
31 sleep: '(-_-) zZ',
32 scan: '(⌐■_■)',
33 alert: '(°o°)!',
34 fire: '(>o<)━━',
35}
36
37export function statusText(p: Hollow, look: Look): string {
38 const w = WORDS[look.lang].ui
39 const bugs = p.bugs.length > 0 ? ` · ${p.bugs.length} bug${p.bugs.length > 1 ? 's' : ''}` : ''
40 return `${KAOMOJI[faceOf(p)]} ${look.name} · ♥ ${p.bond} · ${w.reiatsu} ${Math.round(p.reiatsu)}${bugs}`
41}
42
43/** A barra do reiatsu: `█` cheio e `░` vazio. */
44export function meter(value: number, cells = 8): string {
45 const on = Math.max(0, Math.min(cells, Math.round((value / 100) * cells)))
46 return '█'.repeat(on) + '░'.repeat(cells - on)
47}
48
49/* ───────────────────────── a faixa de bugs do terminal ─────────────────────────
50 * Como no VS Code, o bicho fica à direita e os bugs vêm do lado esquerdo, andando até ele: a vaga 0 é a mais perto do pet.
51 * A faixa encosta no pet, então o raio sai da boca dele e cruza a faixa até o alvo, sem nada no meio. */
52
53const SLOT = BUG_W + 2
54
55/** Quantos bugs cabem numa faixa de `cols` colunas. */
56function fits(cols: number): number {
57 return Math.max(0, Math.min(MAX_VISIBLE_BUGS, Math.floor(cols / SLOT)))
58}
59
60/** O que a coluna de texto precisa para a linha do reiatsu não quebrar: `██████░░ reiatsu 100 ♥ 99 ✦ 99`. */
61const TEXT_MIN = 30
62
63/**
64 * A largura da faixa de bugs: zero quando não há o que mostrar, nem raio no ar. Ela ocupa no máximo `share` de `width` e
65 * deixa `reserve` colunas para o resto (o bicho, a coluna de texto), de modo que o texto nunca é espremido por ela.
66 */
67export function laneColumns(p: Pick<Hollow, 'bugs' | 'cero'>, width: number, share = 0.45, reserve = 0): number {
68 const n = Math.min(p.bugs.length, MAX_VISIBLE_BUGS)
69 if (n === 0 && !p.cero) return 0
70 const want = Math.max(n * SLOT, p.cero ? 30 : 0)
71 const cap = Math.max(0, Math.min(Math.floor(width * share), width - reserve))
72 return Math.min(want, cap) >= SLOT ? Math.min(want, cap) : 0
73}
74
75type LaneGeometry = { px: number; bugY: number; beamY: number }
76
77/** O raio fica mais grosso no instante do acerto e afina até sumir. */
78const levelAt = (frame: number): number => (frame < CERO_HIT ? 3 : frame <= CERO_HIT + 1 ? 4 : frame === CERO_HIT + 2 ? 3 : frame === CERO_HIT + 3 ? 2 : 1)
79
80/** A faixa dos bugs: bichinhos andando até o pet, o raio do Cero a caminho do alvo e a explosão no acerto. */
81export function laneCells(p: Pick<Hollow, 'bugs' | 'cero'>, cols: number, tint: Tint, g: LaneGeometry): { columns: number; rows: number; cells: string } {
82 const pal = palette(tint)
83 const c = new Canvas(cols, g.px)
84 const shown = p.bugs.slice(0, fits(cols))
85 const cero = p.cero
86 const left = (i: number): number => cols - 2 - BUG_W - i * SLOT
87 const slot = cero ? (cero.slot ?? -1) : -1
88 shown.forEach((b, i) => {
89 if (cero?.kill && cero.target === b.id && cero.frame > CERO_HIT) return
90 const flash = !!b.hit || (cero?.target === b.id && cero.frame === CERO_HIT)
91 c.blit(flash ? bugHit(isBoss(b)) : bug(isBoss(b), ((cero?.frame ?? 0) + i) % 2 === 1), left(i), g.bugY, pal)
92 })
93 if (cero && cero.frame < CERO_FRAMES - 1) {
94 const aim = slot >= 0 ? left(slot) + BUG_W / 2 : 0
95 const progress = Math.min(1, (cero.frame + 1) / (CERO_HIT + 1))
96 const xa = Math.round(cols - 1 - (cols - 1 - aim) * progress)
97 beam(c, pal, xa, cols - 1, g.beamY, levelAt(cero.frame))
98 if (slot >= 0 && cero.frame >= CERO_HIT && cero.frame <= CERO_HIT + 3) c.blit(boom(cero.frame - CERO_HIT), aim - 3, g.beamY - 3, pal)
99 }
100 return toCells(c)
101}
102
103const BAND_LANE: LaneGeometry = { px: AVATAR_H, bugY: AVATAR_H - BUG_H, beamY: 6 }
104const PANE_LANE: LaneGeometry = { px: BODY_H, bugY: 7, beamY: 10 }
105
106function petCells(p: Hollow, tint: Tint, full: boolean): { columns: number; rows: number; cells: string } {
107 const pal = palette(tint)
108 const face = faceOf(p)
109 const stage = stageOf(p.bond)
110 const c = full ? new Canvas(BODY_W, BODY_H) : new Canvas(AVATAR_W, AVATAR_H)
111 c.blit(full ? body(face, stage) : avatar(face, stage), 0, 0, pal)
112 return toCells(c)
113}
114
115/* ───────────────────────── a faixa acima do prompt ───────────────────────── */
116
117type Plain = Pick<Term, 'Box' | 'Text'>
118
119const statsRow = (t: Plain, p: Hollow, look: Look): RenderElement => {
120 const { Box, Text } = t
121 const w = WORDS[look.lang].ui
122 const hungry = p.reiatsu < 30
123 return (
124 <Box flexDirection="row" gap={1}>
125 <Text color={hungry ? WARN : OK}>{meter(p.reiatsu)}</Text>
126 <Text dimColor>{`${w.reiatsu} ${Math.round(p.reiatsu)}`}</Text>
127 <Text color={ACCENT[look.tint]}>{`♥ ${p.bond}`}</Text>
128 <Text dimColor>{`✦ ${p.hunted}`}</Text>
129 </Box>
130 )
131}
132
133export function terminalBand(t: Term, p: Hollow, look: Look, width: number): RenderElement {
134 const { Box, Text, Raster } = t
135 const pet = petCells(p, look.tint, false)
136 const lane = laneColumns(p, width, 0.45, 2 + AVATAR_W + 2 + TEXT_MIN)
137 const laneOn = lane >= SLOT && width >= 64
138 const textW = Math.max(14, width - 2 - AVATAR_W - 1 - (laneOn ? lane + 1 : 0))
139 const lanePx = laneOn ? laneCells(p, lane, look.tint, BAND_LANE) : null
140 const w = WORDS[look.lang]
141 const extra = p.bugs.length - (laneOn ? fits(lane) : 0)
142 return (
143 <Box flexDirection="row" gap={1} paddingX={1}>
144 <Box flexDirection="column" width={textW} height={pet.rows} justifyContent="center">
145 {p.say !== '' ? (
146 <Box borderStyle="round" borderColor={ACCENT[look.tint]} paddingX={1} width={Math.min(textW, p.say.length + 4)}>
147 <Text wrap="truncate-end">{p.say}</Text>
148 </Box>
149 ) : (
150 <Box flexDirection="row" gap={1}>
151 <Text bold>{look.name}</Text>
152 <Text dimColor>{`· ${stageName(p.bond, look.lang)}`}</Text>
153 </Box>
154 )}
155 {statsRow(t, p, look)}
156 {extra > 0 ? <Text color={GOLD}>{`+${extra} bugs · ${w.ui.hunted} ${p.hunted}`}</Text> : null}
157 </Box>
158 {lanePx ? <Raster key="lane" columns={lanePx.columns} rows={lanePx.rows} cells={lanePx.cells} /> : null}
159 <Raster key="pet" columns={pet.columns} rows={pet.rows} cells={pet.cells} />
160 </Box>
161 )
162}
163
164/** Quando nem a faixa cabe: uma linha só. */
165export function terminalLine(t: Pick<Term, 'Box' | 'Text'>, p: Hollow, look: Look): RenderElement {
166 const { Box, Text } = t
167 return (
168 <Box paddingX={1}>
169 <Text wrap="truncate-end" dimColor>{p.say !== '' ? `${KAOMOJI[faceOf(p)]} ${p.say}` : statusText(p, look)}</Text>
170 </Box>
171 )
172}
173
174export function remoteBand(r: Remote, p: Hollow, look: Look): RenderElement {
175 const { Box, Text, Svg } = r
176 const face = faceOf(p)
177 const shown = p.bugs.slice(0, MAX_VISIBLE_BUGS)
178 const laneW = shown.length === 0 && !p.cero ? 0 : Math.max(shown.length * LANE_SLOT, p.cero ? 240 : 0)
179 return (
180 <Box flexDirection="row" alignItems="center" gap={2} paddingX={1}>
181 <Box flexDirection="column" flexGrow={1}>
182 <Text bold>{p.say !== '' ? p.say : `${look.name} · ${stageName(p.bond, look.lang)}`}</Text>
183 {statsRow(r, p, look)}
184 </Box>
185 {laneW > 0 ? <Svg source={laneSvg(p, shown, laneW)} alt={`${p.bugs.length} bugs`} width={Math.round(laneW / 2)} height={LANE_H / 2} isInteractive /> : null}
186 <Svg source={petSvg({ face, stage: stageOf(p.bond), tint: look.tint, crop: 'head' })} alt={`${look.name}: ${face}`} width={88} height={78} isInteractive />
187 </Box>
188 )
189}
190
191/* ───────────────────────── o painel ───────────────────────── */
192
193const BUTTONS: ReadonlyArray<readonly [Action, 'pet' | 'feed' | 'cero' | 'sleep' | 'hide', string]> = [
194 ['pet', 'pet', '1'],
195 ['feed', 'feed', '2'],
196 ['cero', 'cero', '3'],
197 ['sleep', 'sleep', '4'],
198 ['hide', 'hide', '5'],
199]
200
201function buttonLabel(look: Look, p: Hollow, key: 'pet' | 'feed' | 'cero' | 'sleep' | 'hide'): string {
202 const ui = WORDS[look.lang].ui
203 return key === 'sleep' && p.pose === 'sleep' ? ui.wake : ui[key]
204}
205
206function bugRows(t: Plain, p: Hollow, look: Look): RenderElement {
207 const { Box, Text } = t
208 const ui = WORDS[look.lang].ui
209 return (
210 <Box flexDirection="column">
211 <Text bold>{ui.bugs}</Text>
212 {p.bugs.length === 0 ? <Text dimColor>{ui.noBugs}</Text> : null}
213 {p.bugs.map((b: HollowBug) => (
214 <Box key={`bug:${b.id}`} flexDirection="row" gap={1}>
215 <Text color={isBoss(b) ? GOLD : ACCENT[look.tint]}>●</Text>
216 <Text>{b.label}</Text>
217 <Text dimColor>{ui.times(b.fails)}</Text>
218 </Box>
219 ))}
220 </Box>
221 )
222}
223
224/** A partir desta largura o texto cabe ao lado da faixa e do bicho; num painel mais estreito ele sobe para uma fileira própria. */
225const WIDE_PANE = 72
226
227export function terminalPane(t: Term, p: Hollow, look: Look, width: number, handlers: Handlers): RenderElement {
228 const { Box, Text, Raster, Button } = t
229 const w = WORDS[look.lang]
230 const pet = petCells(p, look.tint, true)
231 const stacked = width < WIDE_PANE
232 // O que sobra à esquerda do bicho. No painel estreito ele é todo da faixa, que encosta no bicho como na faixa de cima.
233 const room = Math.max(0, width - 2 - BODY_W - 1)
234 const lane = stacked ? laneColumns(p, room, 1) : laneColumns(p, width, 0.45, 2 + BODY_W + 2 + TEXT_MIN)
235 const laneOn = lane >= SLOT
236 const lanePx = laneOn ? laneCells(p, lane, look.tint, PANE_LANE) : null
237 const side = stacked ? {} : { width: Math.max(16, width - 2 - BODY_W - 1 - (laneOn ? lane + 1 : 0)), height: pet.rows }
238 const info = (
239 <Box flexDirection="column" {...side}>
240 <Box flexDirection="row" gap={1}>
241 <Text bold>{look.name}</Text>
242 <Text dimColor>{`· ${stageName(p.bond, look.lang)}`}</Text>
243 </Box>
244 <Text wrap="truncate-end">{p.say !== '' ? `“${p.say}”` : ' '}</Text>
245 {statsRow(t, p, look)}
246 <Text dimColor>{`${w.ui.bond} ${p.bond} · ${w.ui.hunted} ${p.hunted}`}</Text>
247 </Box>
248 )
249 return (
250 <Box flexDirection="column" gap={1} paddingX={1}>
251 {stacked ? info : null}
252 <Box flexDirection="row" gap={1} justifyContent={stacked ? 'flex-end' : 'flex-start'}>
253 {stacked ? null : info}
254 {lanePx ? <Raster key="lane" columns={lanePx.columns} rows={lanePx.rows} cells={lanePx.cells} /> : null}
255 <Raster key="pet" columns={pet.columns} rows={pet.rows} cells={pet.cells} />
256 </Box>
257 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
258 {BUTTONS.map(([action, key, hotkey]) => (
259 <Button key={action} label={buttonLabel(look, p, key)} hotkey={hotkey} onPress={() => handlers.act(action === 'sleep' && p.pose === 'sleep' ? 'wake' : action)} />
260 ))}
261 </Box>
262 {bugRows(t, p, look)}
263 <Text dimColor wrap="wrap">{w.ui.paneHint}</Text>
264 </Box>
265 )
266}
267
268export function remotePane(r: Remote, p: Hollow, look: Look, handlers: Handlers): RenderElement {
269 const { Box, Text, Button, Svg } = r
270 const w = WORDS[look.lang]
271 const face = faceOf(p)
272 const shown = p.bugs.slice(0, MAX_VISIBLE_BUGS)
273 const laneW = shown.length === 0 && !p.cero ? 0 : Math.max(shown.length * LANE_SLOT, p.cero ? 240 : 0)
274 return (
275 <Box flexDirection="column" gap={1} paddingX={1}>
276 <Box flexDirection="row" alignItems="center" gap={2}>
277 <Box flexDirection="column" flexGrow={1}>
278 <Box flexDirection="row" gap={1}>
279 <Text bold>{look.name}</Text>
280 <Text dimColor>{`· ${stageName(p.bond, look.lang)}`}</Text>
281 </Box>
282 <Text>{p.say !== '' ? `“${p.say}”` : ' '}</Text>
283 {statsRow(r, p, look)}
284 <Text dimColor>{`${w.ui.bond} ${p.bond} · ${w.ui.hunted} ${p.hunted}`}</Text>
285 </Box>
286 {laneW > 0 ? <Svg source={laneSvg(p, shown, laneW)} alt={`${p.bugs.length} bugs`} width={Math.round(laneW / 2)} height={LANE_H / 2} isInteractive /> : null}
287 <Svg source={petSvg({ face, stage: stageOf(p.bond), tint: look.tint, crop: 'full' })} alt={`${look.name}: ${face}`} width={150} height={150} isInteractive />
288 </Box>
289 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
290 {BUTTONS.map(([action, key, hotkey]) => (
291 <Button key={action} label={buttonLabel(look, p, key)} hotkey={hotkey} onPress={() => handlers.act(action === 'sleep' && p.pose === 'sleep' ? 'wake' : action)} />
292 ))}
293 </Box>
294 {bugRows(r, p, look)}
295 <Text dimColor>{w.ui.paneHint}</Text>
296 </Box>
297 )
298}
299hooks/art.ts 261 lines1// A arte vetorial para o desktop, o celular e o VS Code (o elemento `Svg`): a mesma figura do <hollow-pet> da página,
2// com as expressões escolhidas por atributos `data-*` na raiz do SVG, como o web component faz no shadow DOM.
3// O terminal não tem `Svg`: lá a arte é o pixel art de sprite.ts.
4// Se mudar a figura em pet/hollow-pet.js, mude aqui também (scripts/claude-plugin-test.mjs confere os trechos principais).
5
6import type { Hollow, HollowBug, HollowFace } from '../types'
7import { CERO_FRAMES, CERO_HIT, isBoss } from './pet'
8import type { Tint } from './sprite'
9
10const HEAD = 'M80 22C106 22 128 38 128 62C128 82 118 97 104 103C96 106.5 88 108 80 108C72 108 64 106.5 56 103C42 97 32 82 32 62C32 38 54 22 80 22Z'
11
12const f1 = (n: number): string => n.toFixed(1)
13
14/** Dentes ao longo de uma curva quadrática, cada um girado pela tangente. */
15function teethAlong(n: number, p0: readonly [number, number], p1: readonly [number, number], p2: readonly [number, number], w: number, h: number, from = 0.1, to = 0.9): string {
16 let out = ''
17 for (let i = 0; i < n; i++) {
18 const t = from + ((to - from) * i) / (n - 1)
19 const x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0]
20 const y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1]
21 const dx = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0])
22 const dy = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1])
23 const a = (Math.atan2(dy, dx) * 180) / Math.PI
24 const k = 1 - Math.abs(t - 0.5) * 0.9
25 const ww = w * (0.8 + 0.3 * k)
26 const hh = h * (0.8 + 0.3 * k)
27 out += `<rect x="${f1(x - ww / 2)}" y="${f1(y - 0.6)}" width="${f1(ww)}" height="${f1(hh)}" rx="1.5" transform="rotate(${f1(a)} ${f1(x)} ${f1(y)})"/>`
28 }
29 return out
30}
31
32/** Dentes de uma boca aberta (elipse), apontando para o centro. */
33function teethRing(cx: number, cy: number, rx: number, ry: number, a0: number, a1: number, n: number, w: number, h: number): string {
34 let out = ''
35 for (let i = 0; i < n; i++) {
36 const a = a0 + ((a1 - a0) * i) / (n - 1)
37 const r = (a * Math.PI) / 180
38 const x = cx + rx * Math.cos(r)
39 const y = cy + ry * Math.sin(r)
40 out += `<rect x="${f1(x - w / 2)}" y="${f1(y - 0.8)}" width="${w}" height="${h}" rx="1.4" transform="rotate(${f1(a - 90)} ${f1(x)} ${f1(y)})"/>`
41 }
42 return out
43}
44
45const MOUTH_CLOSED = `<g class="mouth mouth-closed"><path d="M52 86Q80 104 108 86Q80 116 52 86Z" fill="#0a0a0e"/><g fill="url(#g-bone)" stroke="#8d8573" stroke-width=".5">${teethAlong(9, [52, 86], [80, 104], [108, 86], 4.6, 6)}</g></g>`
46const MOUTH_OPEN = `<g class="mouth mouth-open"><ellipse cx="80" cy="94" rx="20" ry="11.5" fill="#0a0a0e"/><ellipse class="mouth-glow" cx="80" cy="95" rx="15" ry="8" fill="url(#g-mouth)"/><g fill="url(#g-bone)" stroke="#8d8573" stroke-width=".5">${teethRing(80, 94, 20, 11.5, 196, 344, 8, 4.6, 6.2)}</g><g fill="url(#g-bone)" stroke="#8d8573" stroke-width=".5">${teethRing(80, 94, 19, 10.5, 28, 152, 6, 3.8, 4.6)}</g></g>`
47
48const eyeSocket = (cx: number, rot: number): string => `<ellipse cx="${cx}" cy="66" rx="11.5" ry="14.5" transform="rotate(${rot} ${cx} 66)"/>`
49
50const TINT_VARS: Readonly<Record<Tint, string>> = {
51 cero: '--aura:#e11d48;--mark-hi:#ff3b5c;--mark-lo:#a11236',
52 reishi: '--aura:#4ade80;--mark-hi:#6ff0a0;--mark-lo:#1f9a52',
53 ouro: '--aura:#fbbf24;--mark-hi:#ffd45e;--mark-lo:#b9770e',
54}
55
56// O mesmo CSS do web component, só o que a cena usa. A raiz do SVG leva os atributos `data-*` que escolhem a expressão.
57const CSS = `
58.art{overflow:visible}
59.bob,.tail,.aura,.arm,.head,.eye,.horn,.legs{transform-box:view-box}
60.bob{transform-origin:80px 148px;animation:breathe 3.6s ease-in-out infinite}
61.aura{transform-origin:80px 100px;animation:aura 3.2s ease-in-out infinite}
62.tail{transform-origin:108px 138px;animation:wag 2.8s ease-in-out infinite}
63.arm-l{transform-origin:51px 112px}.arm-r{transform-origin:109px 112px}
64.eye{transform-origin:80px 66px;animation:blink 5.2s infinite}
65.eye-r{animation-delay:.02s}
66.hole-ring{animation:ring 2.6s ease-in-out infinite}
67.hole-glow{opacity:.65;animation:ring 2.6s ease-in-out infinite}
68.eye-alt,.mouth-open,.blush,.mouth-glow,.show-2,.show-3,.sweat{opacity:0}
69.visor{opacity:0}
70[data-stage="2"] .show-2,[data-stage="3"] .show-2,[data-stage="3"] .show-3{opacity:1}
71[data-stage="2"] .horn,[data-stage="3"] .horn{transform-origin:70px 29px;transform:scale(1.08)}
72[data-stage="3"] .horn{transform:scale(1.28)}
73[data-stage="2"] .horn-r,[data-stage="3"] .horn-r{transform-origin:100px 29px}
74[data-stage="3"] .horn-r{transform:scale(1.35)}
75[data-stage="3"] .crack,[data-stage="3"] .crack-2{stroke:#e11d48;filter:drop-shadow(0 0 1.2px #ff3b5c)}
76[data-stage="3"] .aura{animation-duration:2.2s;transform:scale(1.12)}
77[data-eyes="happy"] .eye{opacity:0}[data-eyes="happy"] .eye-happy{opacity:1}[data-eyes="happy"] .blush{opacity:.42}
78[data-eyes="sleep"] .eye{opacity:0}[data-eyes="sleep"] .eye-sleep{opacity:1}
79[data-mouth="open"] .mouth-closed{opacity:0}[data-mouth="open"] .mouth-open{opacity:1}
80[data-glow="1"] .mouth-glow{opacity:1}
81[data-glow="1"] .marks{filter:drop-shadow(0 0 2px var(--mark-hi,#ff3b5c)) drop-shadow(0 0 4px var(--mark-hi,#ff3b5c))}
82[data-glow="1"] .hole-ring{stroke:#fff0f3}
83[data-worried="1"] .sweat{opacity:1;animation:sweat 1.5s ease-in infinite}
84[data-worried="1"] .pupil{transform-box:fill-box;transform-origin:center;transform:scale(.7)}
85[data-worried="1"] .tail{animation-duration:.25s}
86[data-worried="1"] .aura{opacity:.6}
87[data-sleep="1"] .aura{opacity:.5}
88[data-sleep="1"] .bob{animation:breathe-slow 4.8s ease-in-out infinite}
89[data-sleep="1"] .tail{animation-duration:6s}
90[data-pose="scan"] .visor{opacity:1}
91[data-pose="scan"] .aura{animation-duration:1.7s}
92[data-pose="alert"] .pupil{transform-box:fill-box;transform-origin:center;transform:scale(.6)}
93[data-pose="alert"] .aura{opacity:1;transform:scale(1.15);animation:aura .35s ease-in-out infinite}
94[data-pose="alert"] .tail{animation:none;transform:rotate(16deg)}
95[data-pose="alert"] .arm-l{transform:rotate(30deg)}[data-pose="alert"] .arm-r{transform:rotate(-30deg)}
96[data-pose="alert"] .bob{animation:startle .6s cubic-bezier(.2,0,0,1) 1}
97[data-mood="happy"] .arm-l{transform:rotate(42deg)}[data-mood="happy"] .arm-r{transform:rotate(-42deg)}
98[data-mood="happy"] .tail{animation-duration:.5s}
99.scanline{animation:scan 1.6s linear infinite}
100.visor-led{animation:led .9s steps(2) infinite}
101.zz{font:700 20px ui-sans-serif,system-ui,sans-serif;fill:#cfd8ff;animation:zz 2.4s ease-in-out infinite}
102@keyframes scan{0%{transform:translateY(0)}100%{transform:translateY(38px)}}
103@keyframes led{0%{opacity:1}100%{opacity:.25}}
104@keyframes startle{0%{transform:translateY(0) scale(1,1)}16%{transform:translateY(-15px) scale(.93,1.1) rotate(-3deg)}38%{transform:translateY(2px) scale(1.07,.92)}58%{transform:translateY(-4px) scale(.98,1.03)}100%{transform:translateY(0) scale(1,1)}}
105@keyframes sweat{0%{transform:translateY(-3px);opacity:0}20%{opacity:1}80%{opacity:1}100%{transform:translateY(9px);opacity:0}}
106@keyframes breathe{0%,100%{transform:scale(1,1)}50%{transform:scale(1.014,1.03)}}
107@keyframes breathe-slow{0%,100%{transform:translateY(3px) scale(1.02,.96)}50%{transform:translateY(3px) scale(1.03,.99)}}
108@keyframes blink{0%,92%,100%{transform:scaleY(1)}95.5%{transform:scaleY(.07)}}
109@keyframes wag{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(9deg)}}
110@keyframes aura{0%,100%{opacity:.8}50%{opacity:1}}
111@keyframes ring{0%,100%{opacity:.75}50%{opacity:1}}
112@keyframes zz{0%,100%{transform:translateY(0);opacity:.4}50%{transform:translateY(-6px);opacity:1}}
113`
114
115export type PetArt = {
116 face: HollowFace
117 stage: 1 | 2 | 3
118 tint: Tint
119 /** Só a cabeça (a faixa) ou a figura inteira (o painel). */
120 crop: 'head' | 'full'
121}
122
123/** O Hollowzinho em SVG, na expressão pedida. */
124export function petSvg({ face, stage, tint, crop }: PetArt): string {
125 const eyes = face === 'happy' ? 'happy' : face === 'sleep' || face === 'blink' ? 'sleep' : 'open'
126 const attrs = [
127 `data-stage="${stage}"`,
128 `data-eyes="${eyes}"`,
129 `data-mouth="${face === 'fire' ? 'open' : 'closed'}"`,
130 `data-glow="${face === 'fire' ? 1 : 0}"`,
131 `data-worried="${face === 'worried' || face === 'sad' ? 1 : 0}"`,
132 `data-sleep="${face === 'sleep' ? 1 : 0}"`,
133 `data-pose="${face === 'scan' ? 'scan' : face === 'alert' ? 'alert' : ''}"`,
134 `data-mood="${face === 'happy' ? 'happy' : ''}"`,
135 ].join(' ')
136 const view = crop === 'head' ? '16 0 128 114' : '0 0 160 160'
137 const eyeL = eyeSocket(58, 9)
138 const eyeR = eyeSocket(102, -9)
139 const badge =
140 face === 'alert'
141 ? '<g><circle cx="136" cy="22" r="14" fill="#e11d48" stroke="#fff" stroke-width="1.5"/><rect x="134" y="13" width="4" height="12" rx="2" fill="#fff"/><circle cx="136" cy="30" r="2.4" fill="#fff"/></g>'
142 : face === 'sleep'
143 ? '<g><text class="zz" x="118" y="30">z</text><text class="zz" x="130" y="16" style="animation-delay:.8s">Z</text></g>'
144 : ''
145 return `<svg xmlns="http://www.w3.org/2000/svg" class="art" viewBox="${view}" ${attrs} style="${TINT_VARS[tint]};--gx:0;--gy:0">
146<style>${CSS}</style>
147<defs>
148<radialGradient id="g-bone" gradientUnits="userSpaceOnUse" cx="64" cy="42" r="86"><stop offset="0" stop-color="#fffdf8"/><stop offset=".5" stop-color="#f1ebde"/><stop offset="1" stop-color="#c8bfa9"/></radialGradient>
149<linearGradient id="g-chin" gradientUnits="userSpaceOnUse" x1="0" y1="72" x2="0" y2="110"><stop offset="0" stop-color="#8a8068" stop-opacity="0"/><stop offset="1" stop-color="#8a8068" stop-opacity=".5"/></linearGradient>
150<linearGradient id="g-body" gradientUnits="userSpaceOnUse" x1="0" y1="98" x2="0" y2="152"><stop offset="0" stop-color="#2d2d3a"/><stop offset=".55" stop-color="#15151c"/><stop offset="1" stop-color="#09090d"/></linearGradient>
151<radialGradient id="g-iris" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#fff2b0"/><stop offset=".55" stop-color="#fbbf24"/><stop offset="1" stop-color="#a8650a"/></radialGradient>
152<linearGradient id="g-mark" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--mark-hi)"/><stop offset="1" stop-color="var(--mark-lo)"/></linearGradient>
153<radialGradient id="g-aura" cx=".5" cy=".55" r=".5"><stop offset="0" stop-color="var(--aura)" stop-opacity=".45"/><stop offset=".6" stop-color="var(--aura)" stop-opacity=".13"/><stop offset="1" stop-color="var(--aura)" stop-opacity="0"/></radialGradient>
154<radialGradient id="g-mouth" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#fff0f3"/><stop offset=".45" stop-color="var(--mark-hi)"/><stop offset="1" stop-color="var(--mark-lo)" stop-opacity="0"/></radialGradient>
155<filter id="f-glow" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="2.2"/></filter>
156<filter id="f-soft" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="2.6"/></filter>
157<clipPath id="c-head"><path d="${HEAD}"/></clipPath>
158<clipPath id="c-eye-l">${eyeL}</clipPath>
159<clipPath id="c-eye-r">${eyeR}</clipPath>
160<clipPath id="c-visor"><ellipse cx="102" cy="66" rx="16" ry="18.5"/></clipPath>
161<mask id="m-body"><rect width="160" height="160" fill="#fff"/><circle cx="80" cy="127" r="10.5" fill="#000"/></mask>
162</defs>
163<ellipse class="aura" cx="80" cy="96" rx="76" ry="70" fill="url(#g-aura)"/>
164<ellipse cx="80" cy="153" rx="38" ry="6" fill="#000" opacity=".5" filter="url(#f-soft)"/>
165<g class="tail"><path d="M108 138C128 141 143 129 141 111C140 104 143 98 150 99" fill="none" stroke="#14141b" stroke-width="8.5" stroke-linecap="round"/><path d="M144 96L158 97L148 106Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".6" stroke-linejoin="round"/></g>
166<g class="legs"><ellipse cx="63" cy="148" rx="13" ry="6.5" fill="#101017"/><ellipse cx="97" cy="148" rx="13" ry="6.5" fill="#101017"/><g fill="#e6dfcd"><path d="M53 150l-2 4.5 5.5-2.4zM58 152l-.5 5 4.5-3zM91 152l.5 5 4.5-3zM98 152.5l1 4.5 4-3.4z"/></g></g>
167<g class="bob">
168<g class="arm arm-l"><path d="M51 112C41 114 34 121 33 131" fill="none" stroke="#17171f" stroke-width="10" stroke-linecap="round"/><circle cx="33" cy="132" r="6.6" fill="#17171f"/><path d="M27 135l-1.6 6.4 4.6-3.2zM32 138l0 6.8 3.6-4.2zM37 136.5l2.2 5.6 2.4-4.8z" fill="#e6dfcd"/></g>
169<g class="arm arm-r"><path d="M109 112C119 114 126 121 127 131" fill="none" stroke="#17171f" stroke-width="10" stroke-linecap="round"/><circle cx="127" cy="132" r="6.6" fill="#17171f"/><path d="M133 135l1.6 6.4-4.6-3.2zM128 138l0 6.8-3.6-4.2zM123 136.5l-2.2 5.6-2.4-4.8z" fill="#e6dfcd"/></g>
170<g mask="url(#m-body)"><path d="M47 102C40 124 50 149 80 149C110 149 120 124 113 102Z" fill="url(#g-body)"/></g>
171<circle class="hole-glow" cx="80" cy="127" r="10.5" fill="none" stroke="var(--aura)" stroke-width="3" filter="url(#f-glow)"/>
172<circle class="hole-ring" cx="80" cy="127" r="10.5" fill="none" stroke="var(--mark-hi)" stroke-width="1.5"/>
173<circle cx="80" cy="127" r="9.2" fill="none" stroke="#000" stroke-opacity=".55" stroke-width="2"/>
174<g class="head">
175<g class="show-2 horn-r"><path d="M100 30C102 21 106 15 111 9C101 12 92 19 90 29Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".7" stroke-linejoin="round"/></g>
176<g class="show-3 crest"><path d="M72 27C74 17 77.5 9 80 2C82.5 9 86 17 88 27Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".7" stroke-linejoin="round"/></g>
177<path class="horn" d="M60 30C57 19 57 10 52 2C66 6 77 15 77 29Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".8" stroke-linejoin="round"/>
178<g class="show-2 spikes"><path d="M33 78L21 84L31 91Z M127 78L139 84L129 91Z" fill="url(#g-bone)" stroke="#8d8573" stroke-width=".7" stroke-linejoin="round"/></g>
179<path d="${HEAD}" fill="url(#g-bone)" stroke="#8d8573" stroke-width="1"/>
180<g clip-path="url(#c-head)"><ellipse cx="62" cy="38" rx="30" ry="12" fill="#fff" opacity=".55" filter="url(#f-glow)"/><rect x="20" y="70" width="120" height="42" fill="url(#g-chin)"/><path class="crack" d="M96 22L90 36L99 44L92 58" fill="none" stroke="#2a2a33" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"/><path class="crack crack-2 show-2" d="M44 40L52 47L46 56" fill="none" stroke="#2a2a33" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/></g>
181<g class="marks" fill="url(#g-mark)" clip-path="url(#c-head)"><path d="M49.5 79C46 86 45.5 93 48 101C50.4 94 52.6 87 54.6 80Z"/><path d="M110.5 79C114 86 114.5 93 112 101C109.6 94 107.4 87 105.4 80Z"/><path d="M78.5 23L81.5 23L80.7 48L79.3 48Z"/><path d="M49 36L62 45L60.4 47.2L47.6 38.4Z M111 36L98 45L99.6 47.2L112.4 38.4Z"/></g>
182<path d="M77 81L83 81L80 87Z" fill="#1b1b22" opacity=".8" stroke="#1b1b22" stroke-width="1.6" stroke-linejoin="round"/>
183<ellipse class="blush" cx="44" cy="84" rx="6.5" ry="3.4" fill="#ff3b5c"/><ellipse class="blush" cx="116" cy="84" rx="6.5" ry="3.4" fill="#ff3b5c"/>
184<g class="sweat"><path d="M119 38C115 45 114.5 49.5 118 52C121.5 49.5 122 45 119 38Z" fill="#8ecdf5" stroke="#4d8fb8" stroke-width=".6"/><ellipse cx="117.2" cy="46.5" rx="1" ry="1.8" fill="#fff" opacity=".85"/></g>
185<g class="eye eye-l"><g fill="#07070a">${eyeL}</g><g clip-path="url(#c-eye-l)"><g class="gaze"><circle cx="58" cy="67" r="9.4" fill="url(#g-iris)"/><ellipse class="pupil" cx="58" cy="67" rx="3.7" ry="5.8" fill="#0a0a0e"/><circle cx="54.2" cy="62.4" r="2.7" fill="#fff"/><circle cx="62.2" cy="71.8" r="1.3" fill="#fff" opacity=".85"/></g></g></g>
186<g class="eye eye-r"><g fill="#07070a">${eyeR}</g><g clip-path="url(#c-eye-r)"><g class="gaze"><circle cx="102" cy="67" r="9.4" fill="url(#g-iris)"/><ellipse class="pupil" cx="102" cy="67" rx="3.7" ry="5.8" fill="#0a0a0e"/><circle cx="98.2" cy="62.4" r="2.7" fill="#fff"/><circle cx="106.2" cy="71.8" r="1.3" fill="#fff" opacity=".85"/></g></g></g>
187<g class="eye-alt eye-happy" fill="none" stroke="#0a0a0e" stroke-width="4.6" stroke-linecap="round"><path d="M47 71Q58 57 69 71"/><path d="M91 71Q102 57 113 71"/></g>
188<g class="eye-alt eye-sleep" fill="none" stroke="#0a0a0e" stroke-width="3.6" stroke-linecap="round"><path d="M47 65Q58 74 69 65"/><path d="M91 65Q102 74 113 65"/></g>
189<g class="visor"><path d="M34 53C48 44 112 44 126 53L125.5 59.5C112 51.5 48 51.5 34.5 59.5Z" fill="#17171f" stroke="#050506" stroke-opacity=".6" stroke-width=".8" stroke-linejoin="round"/><ellipse cx="102" cy="66" rx="17.5" ry="20" fill="#e11d48" fill-opacity=".2" stroke="#15151c" stroke-width="3.4"/><ellipse cx="102" cy="66" rx="17.5" ry="20" fill="none" stroke="#ff5a78" stroke-width="1.1" stroke-opacity=".95"/><g clip-path="url(#c-visor)"><rect class="scanline" x="84" y="47" width="36" height="2.6" fill="#ffb3c1" opacity=".9"/></g><path d="M91 57C94 52 100 50 106 51" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="1.6" stroke-linecap="round"/><circle class="visor-led" cx="122" cy="62" r="2" fill="#ff3b5c"/></g>
190${MOUTH_CLOSED}
191${MOUTH_OPEN}
192</g>
193</g>
194${badge}
195</svg>`
196}
197
198/* ───────────────────────── a faixa dos bugs e do raio ───────────────────────── */
199
200/** Largura de cada vaga de bug na faixa SVG, em unidades do viewBox. */
201export const LANE_SLOT = 54
202export const LANE_H = 96
203
204function bugSvg(x: number, boss: boolean, hit: boolean, delay: number): string {
205 const shell = hit ? '#ffffff' : boss ? '#fbbf24' : '#e11d48'
206 const shine = hit ? '#ffffff' : boss ? '#fff2b0' : '#ff3b5c'
207 const dark = boss ? '#a8650a' : '#0a0a0e'
208 const glow = boss ? '<circle cx="21" cy="60" r="24" fill="#fbbf24" opacity=".22"/>' : ''
209 return `<g transform="translate(${x} 0)">${glow}<g class="crawl" style="animation-delay:${delay}s">
210<path d="M12 40L6 32M30 40L36 32" stroke="#0a0a0e" stroke-width="2.4" stroke-linecap="round"/>
211<path d="M6 62L0 58M6 70L0 74M36 62L42 58M36 70L42 74M10 78L6 86M32 78L36 86" stroke="#0a0a0e" stroke-width="2.4" stroke-linecap="round"/>
212<ellipse cx="21" cy="62" rx="15" ry="17" fill="${shell}" stroke="#0a0a0e" stroke-width="2"/>
213<path d="M21 46V80" stroke="${dark}" stroke-width="2"/>
214<ellipse cx="16" cy="55" rx="4" ry="5" fill="${shine}" opacity=".85"/>
215<circle cx="27" cy="62" r="2.4" fill="${dark}"/><circle cx="15" cy="70" r="2.2" fill="${dark}"/><circle cx="27" cy="72" r="2" fill="${dark}"/>
216<circle cx="21" cy="44" r="7" fill="#0a0a0e"/><circle cx="18.5" cy="42.5" r="1.8" fill="#fff"/><circle cx="24" cy="42.5" r="1.8" fill="#fff"/>
217</g></g>`
218}
219
220function boomSvg(cx: number, cy: number, step: number): string {
221 if (step === 0) return `<circle cx="${cx}" cy="${cy}" r="9" fill="#fff"/>`
222 if (step === 1)
223 return `<g><circle cx="${cx}" cy="${cy}" r="22" fill="#ff3b5c" opacity=".55"/><circle cx="${cx}" cy="${cy}" r="15" fill="#ffd45e"/><circle cx="${cx}" cy="${cy}" r="8" fill="#fff"/><path d="M${cx} ${cy - 30}V${cy + 30}M${cx - 30} ${cy}H${cx + 30}" stroke="#fff" stroke-width="3" stroke-linecap="round"/></g>`
224 if (step === 2) return `<circle cx="${cx}" cy="${cy}" r="26" fill="none" stroke="#ff3b5c" stroke-width="3" stroke-dasharray="4 6"/>`
225 return `<circle cx="${cx}" cy="${cy}" r="30" fill="none" stroke="#a11236" stroke-width="2" stroke-dasharray="2 10" opacity=".7"/>`
226}
227
228/** Onde fica o bug da vaga `i`, contando da direita (o lado do pet): a vaga 0 é a mais perto dele. */
229const bugX = (width: number, i: number): number => width - (i + 1) * LANE_SLOT + 4
230
231/**
232 * A faixa dos bugs: os bichinhos andando até o pet (que fica à direita), o raio do Cero saindo da direita até o alvo
233 * e a explosão quando acerta.
234 */
235export function laneSvg(p: Pick<Hollow, 'bugs' | 'cero'>, visible: readonly HollowBug[], width: number): string {
236 const cero = p.cero
237 const slot = cero && cero.slot !== undefined ? cero.slot : -1
238 let beam = ''
239 let boom = ''
240 if (cero && cero.frame < CERO_FRAMES - 1) {
241 const aim = slot >= 0 ? bugX(width, slot) + 21 : 0
242 const grow = Math.min(1, (cero.frame + 1) / (CERO_HIT + 1))
243 const len = Math.max(8, (width - aim) * grow)
244 const thin = cero.frame <= CERO_HIT + 1 ? 1 : Math.max(0.15, 1 - (cero.frame - CERO_HIT - 1) / (CERO_FRAMES - CERO_HIT - 2))
245 const h = 14 * thin
246 const x = width - len
247 beam = `<g><rect x="${x}" y="${60 - h * 1.4}" width="${len}" height="${h * 2.8}" rx="${h}" fill="#ff3b5c" opacity=".35" filter="url(#bf)"/><rect x="${x}" y="${60 - h / 2}" width="${len}" height="${h}" rx="${h / 2}" fill="url(#bg)"/><rect x="${x}" y="${60 - h / 5}" width="${len}" height="${(h * 2) / 5}" rx="${h / 5}" fill="#fff0f3"/></g>`
248 if (slot >= 0 && cero.frame >= CERO_HIT && cero.frame <= CERO_HIT + 3) boom = boomSvg(aim, 58, cero.frame - CERO_HIT)
249 }
250 const bugs = visible
251 .map((b, i) => ({ b, i }))
252 .filter(({ b }) => !(cero?.kill && cero.target === b.id && cero.frame > CERO_HIT))
253 .map(({ b, i }) => bugSvg(bugX(width, i), isBoss(b), !!b.hit || (cero?.target === b.id && cero.frame === CERO_HIT), i * 0.35))
254 .join('')
255 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${LANE_H}">
256<style>@keyframes crawl{0%,100%{transform:translateX(0)}50%{transform:translateX(-3px)}}.crawl{animation:crawl 1.4s ease-in-out infinite}</style>
257<defs><linearGradient id="bg" x1="1" y1="0" x2="0" y2="0"><stop offset="0" stop-color="#a11236"/><stop offset=".5" stop-color="#ff3b5c"/><stop offset="1" stop-color="#ffb3c1"/></linearGradient><filter id="bf" x="-10%" y="-100%" width="120%" height="300%"><feGaussianBlur stdDeviation="4"/></filter></defs>
258${bugs}${beam}${boom}
259</svg>`
260}
261types/index.d.ts 89 lines1/** Como o bicho aparece: cada uma é uma expressão do rosto. */
2export type HollowFace = 'idle' | 'blink' | 'happy' | 'worried' | 'sad' | 'sleep' | 'scan' | 'alert' | 'fire'
3
4/** Humor passageiro; some sozinho quando `moodUntil` passa. */
5export type HollowMood = 'calm' | 'happy' | 'worried' | 'sad'
6
7/** Um comando de build ou teste que falhou e ainda não passou: vira um bichinho na cena. */
8export type HollowBug = {
9 /** Identifica a "família" do comando (`npm test`, `tsc`, `cargo build`). */
10 id: string
11 /** O que aparece na tela: a família do comando, curta. */
12 label: string
13 /** Quantas vezes falhou seguido. Com 3 ou mais ele vira um chefe dourado. */
14 fails: number
15 /** Levou um raspão de um Cero que não o matou: pisca por um instante. */
16 hit?: boolean
17}
18
19/** O Cero em voo: um quadro por passo da animação. */
20export type HollowCero = {
21 /** O passo atual, de 0 ao fim da animação. */
22 frame: number
23 /** Quando o próximo passo é devido (ms desde 1970). */
24 nextAt: number
25 /** O bug mirado (o id dele); sem alvo, o raio vai até a borda. */
26 target?: string
27 /** A vaga do alvo na faixa (0 é a mais perto do pet): fica guardada porque o bug morre no meio do voo. */
28 slot?: number
29 /** O Cero abate o alvo no instante do acerto (é o caso de um comando que passou). */
30 kill: boolean
31}
32
33/** Tudo o que a cena precisa para se desenhar; é o que o plugin guarda como estado da sessão. */
34export type Hollow = {
35 v: 1
36 /** 0 a 100. Cai devagar; teclas, ferramentas e commits alimentam. */
37 reiatsu: number
38 /** Cresce com carinho, comida, commits e bugs abatidos. Com 15 vira Adjuchas e com 40, Vasto Lorde. */
39 bond: number
40 /** Quantos bugs já foram abatidos, desde sempre. */
41 hunted: number
42 mood: HollowMood
43 moodUntil: number
44 /** `scan` não é guardado aqui: sai de `working`. `alert` dura pouco; `sleep` dura até a próxima atividade. */
45 pose: '' | 'alert' | 'sleep'
46 poseUntil: number
47 /** O que está no balão e até quando. */
48 say: string
49 sayUntil: number
50 bugs: HollowBug[]
51 cero: HollowCero | null
52 hidden: boolean
53 /** Um turno do Claude está rodando: o bicho entra no modo caçador. */
54 working: boolean
55 /** Os olhos fechados por um instante. */
56 blink: boolean
57 /** Quando foi o último sinal de vida (prompt, ferramenta, comando). */
58 lastActive: number
59 /** Até onde o tempo já foi contado (fome, sono, piscar). */
60 lastTick: number
61 /** Quando ele falou sozinho pela última vez e quando comemorou (para não ficar repetitivo). */
62 lastChat: number
63 lastCheer: number
64 /** Quando o próximo piscar acontece. */
65 blinkAt: number
66 /** O que a sessão já fez: usado para comemorar o fim do turno só quando houve trabalho. */
67 edits: number
68 /** Já cumprimentou nesta sessão. */
69 greeted: boolean
70}
71
72/** O que fica guardado entre sessões (`$.store`). */
73export type HollowSaved = {
74 v: 1
75 reiatsu: number
76 bond: number
77 hunted: number
78 hidden: boolean
79 seen: boolean
80 /** Quando foi guardado (ms desde 1970): o reiatsu cai pelo tempo que o bicho ficou sozinho. */
81 t: number
82}
83
84declare module 'claude-code' {
85 interface PluginState {
86 hollowzinho: { pet: Hollow }
87 }
88}
89