Barra lateral do Claude Code com modelo, contexto, custo, agentes, tarefas e um palco pixel art com personagens estilo SNES

Uma barra lateral para o Claude Code que mostra o que importa na sessão (modelo, contexto, custo, agentes e tarefas) e, embaixo, um palco em pixel art com personagens estilo SNES que conversam com você enquanto trabalha.
![]()
~/.claude/pixel-sidebar.json./pomodoro abre um cronômetro de foco no painel; no fim o personagem comemora e você ganha XP.No Claude Code, digite:
/plugin install pixel-sidebar --marketplace karnagge/pixel-sidebar
Responda y para adicionar o marketplace e escolha o escopo (usuário é o padrão).
O painel só fica encaixado ao lado da conversa no layout de tela cheia, com o terminal tendo 110 colunas ou mais. Para ativar, adicione ao seu ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_NO_FLICKER": "1" }
}
Fora da tela cheia, o painel abre inline.
| Comando | O que faz | |
|---|---|---|
/sidebar | Abre a barra lateral, caso ela tenha sido fechada | |
/especial | O personagem atual solta o especial agora | |
/especial goku | Chama um personagem específico (o nome pode ser parcial) | |
/especial goku forte | Especial carregado | |
| `/pomodoro [min\ | parar]` | Cronômetro de foco (padrão 25 min) |
/encontro | Chama um encontro (ou /encontro ryu, por exemplo) |
Opções em /config (ou pluginConfigs no settings.json): notificacoes (liga/desliga os avisos nativos), carregado_min (minutos pro especial carregado, padrão 5), custo_alerta_usd (padrão 10) e pomodoro_min (padrão 25).
claude --plugin-dir . a partir da pasta do repositório. Os testes rodam com claude plugin test ..Este é um projeto de fã, sem fins comerciais. Os personagens são releituras em pixel art feitas à mão e pertencem aos seus respectivos donos (Nintendo, SEGA, Square Enix, FromSoftware, Bandai Namco, Capcom, Microsoft/Mojang, The Pokémon Company, Game Freak, Toei/Shueisha, Toby Fox, Warner Bros./NetherRealm, Konami, Sony Interactive Entertainment, Taito, Innersloth, Extremely OK Games e outros). O projeto não tem afiliação com nenhuma dessas empresas nem é endossado por elas. Se você é titular de algum desses direitos e quer que algo seja removido, abra uma issue e o conteúdo será retirado.
O código está sob licença MIT. A licença não se aplica aos personagens nem às marcas citadas acima.
hooks/register.tsx 469 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Banner, Last, Snap, Todo } from '../types'
5import { EXTRAS, mainColor, SPRITES, type Grid } from './sprites'
6import { BAND_MS, bandGrid } from './band'
7import { bubble, escapeAt, partner, scene, SPRITE_W, type Force, type FxKind, type Scene } from './stage'
8import { cheer, encLine, escape, say } from './talk'
9import { cells, merge, paint, stamp, STAGE_ROWS, type Weather } from './world'
10
11const PANE = 'sidebar'
12const snap = atom({ plugin: 'pixel-sidebar', key: 'snap' } as const, {
13 model: '', pct: 0, tokens: 0, window: 0, usd: 0, agents: [], startedAt: 0, ponytail: '',
14} as Snap)
15const tick = atom({ plugin: 'pixel-sidebar', key: 'tick' } as const, 0)
16const last = atom({ plugin: 'pixel-sidebar', key: 'last' } as const, { tool: '', file: '' } as Last)
17const todos = atom({ plugin: 'pixel-sidebar', key: 'todos' } as const, [] as Todo[])
18const weather = atom({ plugin: 'pixel-sidebar', key: 'weather' } as const, { temp: null, desc: '', at: 0 } as Weather)
19const banner = atom({ plugin: 'pixel-sidebar', key: 'banner' } as const, { kind: '', at: -99 } as Banner)
20const force = atom({ plugin: 'pixel-sidebar', key: 'force' } as const, { at: -999, idx: 0 } as Force)
21const gen = atom({ plugin: 'pixel-sidebar', key: 'gen' } as const, '')
22const NAMES = SPRITES.map(s => s.name)
23const lines = new Map<string, string>() // fala fixa por janela (turno:tipo)
24let room = 34 // largura do palco, lida no último render
25let coins = 0
26let band: { kind: FxKind; start: number; power?: number } | null = null // especial atravessando a tela
27const began = new Map<string, number>() // quando cada tarefa entrou em andamento (especial carregada)
28let chargeMs = 5 * 60_000
29let notifs = true
30let alertUsd = 10
31let home = ''
32// progresso salvo em ~/.claude/pixel-sidebar.json
33const stats = { xp: 0, tasks: 0, deaths: 0, bosses: 0, focus: 0, got: [] as string[] }
34const ACH: [string, string, () => boolean][] = [
35 ['Primeira vitória', '1ª tarefa concluída', () => stats.tasks >= 1],
36 ['Produtivo', '10 tarefas concluídas', () => stats.tasks >= 10],
37 ['Lenda', '100 tarefas concluídas', () => stats.tasks >= 100],
38 ['Caçador de bugs', 'derrotou um chefão', () => stats.bosses >= 1],
39 ['Fênix', 'morreu 3 vezes e voltou', () => stats.deaths >= 3],
40 ['Foco total', 'completou um pomodoro', () => stats.focus >= 1],
41]
42const level = (xp: number) => Math.floor(Math.sqrt(xp / 25)) + 1
43const BOSSES = ['Bug Gigante', 'Null Pointer', 'Segfault', 'Merge Conflict', 'Stack Overflow']
44let boss = { streak: 0, name: '' } // erros seguidos no Bash; 3+ = chefão na tela
45let pomo: { end: number; min: number } | null = null
46const warned = new Set<string>()
47let costStep = -1
48const byName = (name: string) => [...SPRITES, ...EXTRAS].find(s => s.name === name)
49const flipped = (g: Grid, flip: boolean) => (flip ? g.map(row => [...row].reverse()) : g)
50
51const bar = (pct: number, w = 18) => {
52 const n = Math.round((Math.min(100, pct) / 100) * w)
53 return '█'.repeat(n) + '░'.repeat(w - n)
54}
55const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
56const mins = (ms: number) => `${Math.floor(ms / 60000)}min`
57const SEED = Math.floor(Math.random() * 1000) // ponytail: novo a cada load, ordem dos personagens varia por sessão
58
59const save = ($: Engine) => $.fs.write(`${home}/.claude/pixel-sidebar.json`, JSON.stringify(stats)).catch(() => {})
60
61/** Dá XP, soma o contador e avisa nível novo e conquistas. */
62async function gain($: Engine, xp: number, field?: 'tasks' | 'deaths' | 'bosses' | 'focus') {
63 const before = level(stats.xp)
64 stats.xp += xp
65 if (field) stats[field]++
66 if (level(stats.xp) > before) $.ui.toast(`LEVEL UP! Nível ${level(stats.xp)}`, { timeoutMs: 5000 })
67 for (const [name, why, ok] of ACH) {
68 if (stats.got.includes(name) || !ok()) continue
69 stats.got.push(name)
70 $.ui.toast(`Conquista: ${name} (${why})`, { timeoutMs: 6000 })
71 }
72 await save($)
73}
74
75const ping = ($: Engine, text: string) => { if (notifs) void $.ui.notify(text).catch(() => {}) }
76
77/** O personagem do palco reage: fala a linha e solta a especial (no máximo uma a cada ~15s). */
78async function react($: Engine, why: 'done' | 'ask' | 'pause' | 'abort', detail: string, power?: number, custom?: string) {
79 try { await reactNow($, why, detail, power, custom) } catch { /* enfeite: nunca atrapalha o trabalho */ }
80}
81
82async function reactNow($: Engine, why: 'done' | 'ask' | 'pause' | 'abort', detail: string, power?: number, custom?: string) {
83 const n = await read($, tick)
84 const f = await read($, force)
85 if (n - f.at < 22) return
86 const idx = scene(n, SEED, NAMES, room, f).idx
87 const line = custom ?? cheer(why, NAMES[idx]!, detail, n)
88 lines.set(`f${n}:shout`, power ? `CARREGADO! ${line}` : line)
89 await update($, force, () => ({ at: n, idx, power }))
90}
91
92export const register: Register = (on, options) => {
93 chargeMs = Number(options?.carregado_min ?? 5) * 60_000
94 notifs = options?.notificacoes !== false
95 alertUsd = Number(options?.custo_alerta_usd ?? 10)
96 on('session.start', async ($, e, next) => {
97 await $.command.register({ name: 'sidebar', description: 'Abre a barra lateral' })
98 await $.command.register({
99 name: 'especial',
100 description: 'Faz o personagem da barra lateral soltar a especial agora',
101 argumentHint: '[nome do personagem] [forte]',
102 })
103 await $.command.register({
104 name: 'encontro',
105 description: 'Chama dois personagens pro palco ao mesmo tempo',
106 argumentHint: '[mario|sonic|ryu|guile|scorpion|sub-zero|pac-man]',
107 })
108 await $.command.register({
109 name: 'pomodoro',
110 description: 'Contagem de foco com o personagem (padrão 25 min; "parar" cancela)',
111 argumentHint: '[minutos|parar]',
112 })
113 home = (await $.env.get('HOME')) ?? ''
114 Object.assign(stats, JSON.parse(await $.fs.read(`${home}/.claude/pixel-sidebar.json`).catch(() => '{}')))
115 const r = await $.ui.open({ id: PANE, title: 'Sessão', columns: 34 })
116 if (!r.isPlaced) $.ui.toast('pixel-sidebar: terminal estreito, rode /sidebar ou alargue a janela', { timeoutMs: 8000 })
117 // hot reload roda session.start de novo: cada geração cancela os timers da anterior
118 const me = String(Math.random())
119 await update($, gen, () => me)
120 const own = (ms: number, fn: () => Promise<unknown> | unknown) => {
121 const tm = $.clock.every(ms, async () => {
122 if ((await read($, gen)) !== me) return tm.cancel()
123 await fn()
124 })
125 }
126 // ponytail: polling de 2s, trocar por eventos se pesar
127 own(2000, async () => {
128 const [model, u, agents, home] = await Promise.all([
129 $.session.model(), $.session.usage(), $.agent.list(), $.env.get('HOME'),
130 ])
131 // ponytail: o plugin grava o modo ativo nesse arquivo; ausente = desligado
132 const flag = await $.fs.read(`${home}/.claude/.ponytail-active`).catch(() => '')
133 await update($, snap, () => ({
134 model,
135 pct: u.context.percent ?? 0,
136 tokens: u.context.tokens ?? 0,
137 window: u.context.window,
138 usd: u.cost?.usd ?? 0,
139 startedAt: u.startedAt,
140 ponytail: (flag.split('\n')[0] ?? '').trim() || (flag ? 'full' : ''),
141 agents: agents.map(a => ({ id: a.id, type: a.type, description: a.description, status: a.status })),
142 }))
143 // contexto e custo: o personagem avisa ao cruzar 60%/85% e a cada alertUsd gastos
144 const pct = u.context.percent ?? 0
145 if (pct < 50) warned.clear()
146 else if (pct >= 85 && !warned.has('85')) {
147 warned.add('85'); warned.add('60')
148 ping($, 'Contexto em 85%. Hora de /compact.')
149 await react($, 'ask', '', undefined, 'Contexto em 85%! Faz um /compact antes que eu esqueça tudo.')
150 } else if (pct >= 60 && !warned.has('60')) {
151 warned.add('60')
152 await react($, 'pause', '', undefined, 'Contexto passou de 60%. Dá pra pensar num /compact.')
153 }
154 const usd = u.cost?.usd ?? 0
155 const step = Math.floor(usd / alertUsd)
156 if (costStep < 0) costStep = step
157 else if (step > costStep) {
158 costStep = step
159 ping($, `Sessão já custou US$ ${usd.toFixed(2)}.`)
160 await react($, 'pause', '', undefined, `Já são US$ ${usd.toFixed(2)} nessa sessão. A carteira sentiu.`)
161 }
162 })
163 // clima pelo IP, de hora em hora (wttr.in); falhou, o palco segue sem clima
164 const fetchWeather = async () => {
165 if (Date.now() - (await read($, weather)).at < 3600_000) return
166 const r = await $.http.fetch('https://wttr.in/?format=%t|%C&lang=pt').catch(() => null)
167 const [t, d] = r?.ok ? r.text.trim().split('|') : []
168 if (!d) return
169 const temp = Number.parseInt((t ?? '').replace('+', ''), 10)
170 await update($, weather, () => ({ temp: Number.isNaN(temp) ? null : temp, desc: d.trim(), at: Date.now() }))
171 }
172 void fetchWeather()
173 own(3600_000, fetchWeather)
174 // faixa da tela inteira: ~12 quadros/s só enquanto ela existe
175 own(80, () => {
176 if (!band) return
177 if (Date.now() - band.start > BAND_MS[band.kind]) band = null
178 $.ui.invalidate('ui.render')
179 })
180 // ponytail: redesenha o painel a cada 700ms por causa do palco; subir o intervalo se pesar
181 own(700, async () => {
182 const n = await update($, tick, x => x + 1)
183 if (pomo && Date.now() >= pomo.end) {
184 const { min } = pomo
185 pomo = null
186 ping($, `Pomodoro de ${min} min acabou. Levanta e bebe água.`)
187 $.ui.toast('Pomodoro acabou! Hora da pausa.', { timeoutMs: 6000 })
188 await react($, 'pause', '', undefined, 'Pomodoro acabou! Levanta, alonga e bebe água.')
189 await gain($, 30, 'focus')
190 }
191 const f = await read($, force)
192 const sc = scene(n, SEED, NAMES, room, f)
193 const forced = n > f.at && n - f.at < 200 && sc.seed === 0 && f.loud === true
194 // quarta parede: quando a magia chega à borda do painel, atravessa a tela (faixa acima
195 // do prompt) e solta um toast. Metade das especiais naturais; a do /especial sempre.
196 const cx = Math.floor((room - SPRITE_W) / 2)
197 const reacting = n > f.at && n - f.at < 200 && sc.seed === 0
198 if (sc.fx && sc.fx.t === escapeAt(sc.fx.kind, cx, room) && (reacting || sc.seed % 2 === 0)) {
199 band = { kind: sc.fx.kind, start: Date.now(), power: sc.fx.power }
200 if (sc.fx.kind === 'coin' || sc.fx.kind === 'fairy') coins++
201 const msg = escape(sc.fx.kind, NAMES[sc.idx]!, coins)
202 // reações a eventos não soltam toast (seriam a cada turno), a não ser carregadas; o /especial e as naturais sim
203 if (msg && (forced || !reacting || sc.fx.power)) $.ui.toast(sc.fx.power ? `Especial carregado! ${msg}` : msg, { timeoutMs: 5000 })
204 }
205 })
206 return next(e)
207 })
208
209 on('command.run', { command: 'sidebar' }, async $ => {
210 const r = await $.ui.open({ id: PANE, title: 'Sessão', columns: 34 })
211 return { text: r.isPlaced ? 'Barra lateral aberta.' : 'Sem espaço: alargue o terminal (110+ colunas) e rode /sidebar de novo.' }
212 })
213
214 on('command.run', { command: 'especial' }, async ($, e) => {
215 const n = await read($, tick)
216 const words = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
217 const strong = words.some(w => /^(forte|carregad)/.test(w))
218 const want = words.filter(w => !/^(forte|carregad)/.test(w)).join(' ')
219 const cur = scene(n, SEED, NAMES, room, await read($, force)).idx
220 const idx = want ? NAMES.findIndex(x => x.toLowerCase().includes(want)) : cur
221 if (idx < 0) return { text: `Não conheço "${want}". Personagens: ${NAMES.join(', ')}.` }
222 await update($, force, () => ({ at: n, idx, loud: true, power: strong ? 2 : undefined }))
223 return { text: `${NAMES[idx]} vai soltar a especial${strong ? ' carregada' : ''}!` }
224 })
225
226 on('command.run', { command: 'pomodoro' }, async (_$, e) => {
227 const a = e.args.trim().toLowerCase()
228 if (/^(parar|stop|cancel)/.test(a)) { pomo = null; return { text: 'Pomodoro cancelado.' } }
229 const min = Number.parseInt(a, 10) || Number(options?.pomodoro_min ?? 25)
230 pomo = { end: Date.now() + min * 60_000, min }
231 return { text: `Pomodoro de ${min} min começou. Foco!` }
232 })
233
234 on('command.run', { command: 'encontro' }, async ($, e) => {
235 const n = await read($, tick)
236 const want = e.args.trim().toLowerCase()
237 const pairs = NAMES.filter(x => partner(x))
238 const idx = want
239 ? NAMES.findIndex(x => partner(x) && x.toLowerCase().includes(want))
240 : NAMES.indexOf(pairs[n % pairs.length]!)
241 if (idx < 0) return { text: `Encontros: ${pairs.join(', ')}.` }
242 await update($, force, () => ({ at: n, idx, enc: true }))
243 return { text: `${NAMES[idx]} e ${partner(NAMES[idx]!)!.guest} vão se encontrar no palco!` }
244 })
245
246 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
247 const list: Todo[] = e.todos.map(x => ({ content: x.content, status: x.status }))
248 const before = (await read($, todos)).filter(x => x.status === 'completed').length
249 await update($, todos, () => list)
250 for (const x of list) if (x.status === 'in_progress' && !began.has(x.content)) began.set(x.content, Date.now())
251 if (list.filter(x => x.status === 'completed').length > before) {
252 const n = await read($, tick)
253 await update($, banner, () => ({ kind: 'done', at: n }))
254 const fresh = list.filter(x => x.status === 'completed').at(-1)
255 // tarefa longa (5+ min em andamento) = especial carregada
256 const since = fresh ? began.get(fresh.content) : undefined
257 const power = since && Date.now() - since >= chargeMs ? 2 : undefined
258 await gain($, power ? 50 : 10, 'tasks')
259 await react($, 'done', fresh?.content ?? 'tarefa', power)
260 }
261 if (began.size > 100) began.clear()
262 return next(e)
263 })
264
265 on('tool.call', async ($, e, next) => {
266 const file = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : ''
267 await update($, last, () => ({ tool: e.tool, file }))
268 const res = await next(e)
269 if (e.tool === 'Bash' && 'isError' in res && res.isError) {
270 // ponytail: qualquer exit != 0 conta (até grep sem match); no máximo 1 a cada ~4min
271 const n = await read($, tick)
272 const ev = await read($, banner)
273 if (ev.kind !== 'died' || n - ev.at > 340) {
274 await update($, banner, () => ({ kind: 'died', at: n }))
275 await gain($, 0, 'deaths')
276 }
277 if (++boss.streak === 3) {
278 boss.name = BOSSES[Math.floor(Math.random() * BOSSES.length)]!
279 $.ui.toast(`CHEFÃO: ${boss.name} apareceu! Corrija o erro pra derrotá-lo.`, { timeoutMs: 6000 })
280 }
281 } else if (e.tool === 'Bash' && 'isError' in res) {
282 if (boss.streak >= 3) {
283 const nome = boss.name
284 await gain($, 40, 'bosses')
285 await react($, 'done', nome, undefined, `${nome} derrotado! +40 XP.`)
286 }
287 boss.streak = 0
288 }
289 return res
290 })
291
292 on('tool.check', async ($, e, next) => {
293 const v = await next(e)
294 if (v.decision === 'ask') {
295 ping($, `Claude pede autorização: ${e.tool}`)
296 await react($, 'ask', e.tool)
297 }
298 return v
299 })
300
301 on('turn.complete', async ($, e, next) => {
302 if (!('agentId' in e && e.agentId)) {
303 await react($, e.isAborted ? 'abort' : 'pause', '')
304 // avisa fora do terminal só quando o turno foi longo (dá tempo de ter saído da frente da tela)
305 if (!e.isAborted && e.durationMs >= 60_000) ping($, `Claude terminou (${Math.round(e.durationMs / 1000)} s).`)
306 }
307 return next(e)
308 })
309
310 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
311 const b = band
312 if (!b || e.props.hasSurvey) return next(e)
313 const ms = Date.now() - b.start
314 if (ms > BAND_MS[b.kind]) return next(e)
315 const { Box, Text } = $.ui.resolve(e)
316 const g = bandGrid(b.kind, ms, e.props.bodyColumns, b.power)
317 const lines = g.filter((_, y) => y % 2 === 0).map((_, r) => {
318 const y = r * 2
319 const segs: { ch: string; fg?: string; bg?: string; text: string }[] = []
320 g[y]!.forEach((top, x) => {
321 const bot = g[y + 1]![x]!
322 const cell = !top && !bot ? { ch: ' ' } : !bot ? { ch: '▀', fg: top } : !top ? { ch: '▄', fg: bot } : { ch: '▀', fg: top, bg: bot }
323 const last = segs[segs.length - 1]
324 if (last && last.ch === cell.ch && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.ch
325 else segs.push({ ...cell, text: cell.ch })
326 })
327 return <Text wrap="truncate-end">{segs.map(s => s.fg ? <Text color={s.fg} backgroundColor={s.bg}>{s.text}</Text> : <Text>{s.text}</Text>)}</Text>
328 })
329 return <Box flexDirection="column">{lines}</Box>
330 })
331
332 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
333 const { Box, Text } = $.ui.resolve(e)
334 const s = await read($, snap)
335 const t = await read($, todos)
336 const n = await read($, tick)
337 const w = Math.max(10, (e.props.bodyColumns ?? 30) - 4)
338 const live = s.agents.filter(a => a.status === 'running' || a.status === 'waiting')
339 const done = t.filter(x => x.status === 'completed').length
340 const tone = s.pct >= 85 ? 'error' : s.pct >= 60 ? 'warning' : 'success'
341 room = e.props.bodyColumns ?? 30
342 const sc: Scene = scene(n, SEED, NAMES, room, await read($, force))
343 const sp = SPRITES[sc.idx]!
344 const frame = Math.floor(n / 2) % 2
345 const px = flipped(((sc.alt && sp.alt) || sp.frames)[frame]!, sc.flip)
346 const gsp = sc.guest && byName(sc.guest.name)
347 const guestPx = gsp && sc.guest ? flipped(((sc.guest.alt && gsp.alt) || gsp.frames)[frame]!, sc.guest.flip) : undefined
348 const hatSp = sc.hat ? byName(sc.hat) : undefined
349 const now = new Date()
350 const wx = await read($, weather)
351 // estimativa das linhas dos cartões; sem espaço, some o cartão de agentes vazio pro palco caber
352 const cardRows = 5 + 4 + 3 + 3 + 3 + (pomo ? 4 : 0) + (boss.streak >= 3 ? 4 : 0) + Math.max(1, live.length) + 4 + Math.min(t.length, 6)
353 const roomy = (e.props.scroll?.bodyRows ?? 99) >= cardRows + STAGE_ROWS
354 const canvas = cells(paint(n, room, sc, px, now, wx, { guest: guestPx, hat: hatSp && mainColor(hatSp) }))
355 if (sc.talk) {
356 // no encontro, o balão aponta pra quem fala (anfitrião ou convidado)
357 const who = sc.talk.who === 'guest' && sc.guest ? sc.guest : { name: sp.name, x: sc.x, hop: sc.hop }
358 let text = lines.get(sc.talk.key)
359 if (!text) {
360 const lt = await read($, last)
361 text = sc.talk.kind === 'enc'
362 ? encLine(who.name, sc.talk.line ?? 0, sc.seed + n)
363 : say(sc.talk.kind, sp.name, sc.fx?.kind ?? null, sc.seed, {
364 s, t, last: lt, mins: (Date.now() - s.startedAt) / 60000, now, weather: wx,
365 })
366 if (sc.talk.kind === 'shout' && sc.fx?.power && !text.startsWith('CARREGADO')) text = `CARREGADO! ${text}`
367 if (lines.size > 30) lines.clear()
368 lines.set(sc.talk.key, text)
369 }
370 const width = Math.min(room, Math.max(18, Math.min(text.length, 26) + 4))
371 const head = Math.max(1, Math.min(room - 2, who.x + SPRITE_W / 2))
372 const left = Math.min(Math.max(0, head - Math.floor(width / 2)), room - width)
373 const bb = bubble(who.name, text, sc.talk.since, width, head - left, n % 2 === 0)
374 const headRow = Math.floor((STAGE_ROWS * 2 - 3 - 19 - who.hop) / 2) // linha do topo da cabeça no palco
375 const top = headRow - (bb.body.length + 3)
376 stamp(canvas, top, left, bb.top, 'line')
377 stamp(canvas, top, left + 2, ` ${who.name} `, '#e8875f', { bold: true })
378 bb.body.forEach((l, i) => {
379 stamp(canvas, top + 1 + i, left, l, 'line')
380 stamp(canvas, top + 1 + i, left + 2, l.slice(2, -2), 'ink', { bold: true })
381 })
382 stamp(canvas, top + 1 + bb.body.length, left, bb.bottom, 'line')
383 stamp(canvas, top + 2 + bb.body.length, left, bb.stem, 'line', { skipSpaces: true })
384 }
385 const ev = await read($, banner)
386 if (n - ev.at < 7) {
387 const msg = ev.kind === 'died' ? ' VOCÊ MORREU ' : ' TAREFA CONCLUÍDA '
388 stamp(canvas, 3, Math.max(0, Math.floor((room - msg.length) / 2)), msg, ev.kind === 'died' ? '#e04a3a' : '#f0c040', { bold: true })
389 }
390 const stage = merge(canvas).map(line => (
391 <Text wrap="truncate-end">{line.map(seg => <Text color={seg.fg} backgroundColor={seg.bg} bold={seg.bold}>{seg.text}</Text>)}</Text>
392 ))
393 const card = (title: string, right: string | undefined, body: any) => (
394 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
395 <Box justifyContent="space-between">
396 <Text bold color="claude">{title}</Text>
397 {right && <Text color="subtle">{right}</Text>}
398 </Box>
399 {body}
400 </Box>
401 )
402 return (
403 <Box flexDirection="column" minHeight={e.props.scroll?.bodyRows} justifyContent="space-between">
404 <Box flexDirection="column">
405 {card('MODELO', mins(Date.now() - s.startedAt), (
406 <Box flexDirection="column">
407 <Text bold>{s.model || '…'}</Text>
408 {s.ponytail && (
409 <Text color={s.ponytail === 'ultra' ? 'warning' : 'success'}>
410 [PONYTAIL{s.ponytail === 'full' ? '' : `:${s.ponytail.toUpperCase()}`}]
411 </Text>
412 )}
413 </Box>
414 ))}
415 {card('CONTEXTO', `${Math.round(s.pct)}%`, (
416 <Box flexDirection="column">
417 <Text color={tone}>{bar(s.pct, w)}</Text>
418 <Text color="subtle">{k(s.tokens)} de {k(s.window)} tokens</Text>
419 </Box>
420 ))}
421 {card('CUSTO', undefined, (
422 <Text bold color="success">US$ {s.usd.toFixed(2)}</Text>
423 ))}
424 {card('NÍVEL', `${stats.xp} XP`, (
425 <Box flexDirection="column">
426 <Text color="suggestion">Nv {level(stats.xp)} <Text color="subtle">{stats.got.length}/{ACH.length} conquistas</Text></Text>
427 <Text color="suggestion">{bar(((stats.xp - 25 * (level(stats.xp) - 1) ** 2) / (25 * (2 * level(stats.xp) - 1))) * 100, w)}</Text>
428 </Box>
429 ))}
430 {pomo && card('FOCO', `${pomo.min} min`, (
431 <Box flexDirection="column">
432 <Text bold color="warning">{Math.floor(Math.max(0, pomo.end - Date.now()) / 60000)}:{String(Math.floor((Math.max(0, pomo.end - Date.now()) % 60000) / 1000)).padStart(2, '0')}</Text>
433 <Text color="warning">{bar((1 - (pomo.end - Date.now()) / (pomo.min * 60000)) * 100, w)}</Text>
434 </Box>
435 ))}
436 {boss.streak >= 3 && card('CHEFÃO', boss.name, (
437 <Text color="error">{'♥'.repeat(Math.min(boss.streak, 10))} <Text color="subtle">{boss.streak} erros seguidos</Text></Text>
438 ))}
439 {(live.length > 0 || roomy) && card('AGENTES', String(live.length), (
440 <Box flexDirection="column">
441 {live.length === 0 && <Text color="inactive">nenhum ativo</Text>}
442 {live.map(a => (
443 <Text color="warning" wrap="truncate">● {a.type} <Text color="subtle">{a.description}</Text></Text>
444 ))}
445 </Box>
446 ))}
447 {card('TAREFAS', t.length ? `${done}/${t.length}` : undefined, (
448 <Box flexDirection="column">
449 {t.length === 0 && <Text color="inactive">sem checklist</Text>}
450 {t.length > 0 && <Text color="success">{bar((done / t.length) * 100, w)}</Text>}
451 {t.length > 5 && <Text color="inactive">+{t.length - 5} mais</Text>}
452 {t.slice(t.length > 5 ? Math.max(0, Math.min(t.findIndex(x => x.status !== 'completed'), t.length - 5)) : 0).slice(0, 5).map(x => (
453 <Text
454 wrap="truncate"
455 color={x.status === 'in_progress' ? 'suggestion' : x.status === 'completed' ? 'inactive' : undefined}
456 strikethrough={x.status === 'completed'}
457 >
458 {x.status === 'completed' ? '✔' : x.status === 'in_progress' ? '▶' : '○'} {x.content}
459 </Text>
460 ))}
461 </Box>
462 ))}
463 </Box>
464 <Box flexDirection="column">{stage}</Box>
465 </Box>
466 )
467 })
468}
469hooks/sprites.ts 454 lines1// Pixel art estilo SNES: desenho 14x18 + contorno automático = 16x20.
2// Maiúscula = material com volume (luz no alto à esquerda: brilho, base, sombra);
3// minúscula = detalhe chapado (olho, boca, fivela). '.' vazio.
4export type Grid = string[][] // cor hex por pixel, '' = vazio
5// alt: variação do mesmo personagem (cabelo azul da Madeline, Arthur de cueca, fantasma assustado)
6type Art = {
7 name: string; palette: Record<string, string>; a: string[]; b: Record<number, string>; outline?: string
8 alt?: { palette?: Record<string, string>; rows?: Record<number, string> }
9}
10
11const mix = (hex: string, to: number, t: number) =>
12 '#' + [1, 3, 5].map(i => {
13 const c = parseInt(hex.slice(i, i + 2), 16)
14 return Math.round(c + (to - c) * t).toString(16).padStart(2, '0')
15 }).join('')
16
17function bake(art: Art, rows: string[]): Grid {
18 const at = (x: number, y: number) => rows[y]?.[x] ?? '.'
19 const W = 14 + 2
20 const H = rows.length + 2
21 const ink = art.outline ?? '#1d1a2b'
22 const grid: Grid = []
23 for (let y = -1; y < H - 1; y++) {
24 const line: string[] = []
25 for (let x = -1; x < W - 1; x++) {
26 const k = at(x, y)
27 if (k === '.') {
28 const edge = [at(x - 1, y), at(x + 1, y), at(x, y - 1), at(x, y + 1)].some(n => n !== '.')
29 line.push(edge ? ink : '')
30 continue
31 }
32 const base = art.palette[k]!
33 if (k === k.toLowerCase()) { line.push(base); continue }
34 const lit = at(x, y - 1) !== k || at(x - 1, y) !== k
35 const shade = at(x, y + 1) !== k || at(x + 1, y) !== k
36 line.push(lit ? mix(base, 255, 0.3) : shade ? mix(base, 0, 0.35) : base)
37 }
38 grid.push(line)
39 }
40 return grid
41}
42
43const ARTS: Art[] = [
44 { // / Mario
45 name: 'Mario',
46 palette: {
47 R: '#e0302a', N: '#7a4a24', S: '#f6c79c', U: '#2f5fd8', W: '#f4f4f4',
48 k: '#1b1b1b', m: '#3b2412', y: '#f6d33a',
49 },
50 a: [
51 '....RRRRR.....', '...RRRRRRRRR..', '...NNNSSkS....', '..NSNSSSkSSS..',
52 '..NSNNSSSSmSSS', '..NNSSSSmmmm..', '....SSSSSSS...', '...RRURRRR....',
53 '..RRRURRURRR..', '.RRRRUUUURRRR.', '.WWRUyUUyURWW.', '.WWWUUUUUUWWW.',
54 '.WWUUUUUUUUWW.', '...UUUUUUUU...', '...UUUU.UUUU..', '...UUU...UUU..',
55 '..NNNN...NNNN.', '.NNNNN...NNNNN'],
56 b: { 14: '....UUUUUUU...', 15: '....UUU.UUU...', 16: '...NNNN.NNNN..', 17: '..NNNNN.NNNNN.' },
57 },
58 { // / Link
59 name: 'Link',
60 palette: {
61 G: '#3da64a', Y: '#e9c64a', S: '#f6c79c', N: '#7a4a24', T: '#e6dcc0', B: '#2f6fd8',
62 k: '#1b1b1b', m: '#c47a5a', y: '#f6d33a',
63 },
64 a: [
65 '......GG......', '.....GGGG.....', '....GGGGGG....', '...GGGGGGGG...',
66 '..GYYYYYYYYG..', 'S.YYSSSSSSYY.S', 'SSYSSkSSkSSYSS', '.SYSSkSSkSSYS.',
67 '..YSSSSSSSSY..', '....SSmmSS....', 'BB.GGGGGGGG...', 'BBSGGGGGGGGS..',
68 'BBSGNNNyNNGS..', 'BB.GGGGGGGG...', '.B.GGGGGGGG...', '....TT..TT....',
69 '...NNN..NNN...', '..NNNN..NNNN..'],
70 b: { 15: '.....TTTT.....', 16: '....NNNNNN....', 17: '...NNNN.NNNN..' },
71 },
72 { // / Sonic
73 name: 'Sonic',
74 palette: {
75 B: '#2a6ee6', S: '#f2c49a', W: '#f8f8f8', R: '#e0302a',
76 k: '#111111', g: '#27a04a', n: '#111111', w: '#c9ced8',
77 },
78 a: [
79 '.....BBB......', '...BBBBBBB....', '..BBBBBBBBB...', 'BBBBBBBBWWW...',
80 '.BBBBBBWWWWW..', 'BBBBBBBWWgkW..', '.BBBBBBWWgkW..', 'BBBBBBSSSSSSn.',
81 '.BBBBBBSSSSS..', 'BBBBBBBBSS....', '..BBBBBBB.....', '...BBSSSBB....',
82 '..BBSSSSSBWW..', '..BBBSSSBBWW..', '...BBBBBBB....', '...BBB..BBB...',
83 '..RRRRw.RRRRw.', '.RRRRRR.RRRRRR'],
84 b: { 15: '....BBBBBB....', 16: '...RRRRwRRRRw.', 17: '..RRRRRRRRRRRR' },
85 },
86 { // / Crono (Chrono Trigger)
87 name: 'Crono',
88 palette: {
89 R: '#d8352a', W: '#f0f0f0', S: '#f6c79c', B: '#2f5fd8', N: '#6b4420', P: '#d8d2c0',
90 k: '#1b1b1b', m: '#c47a5a', y: '#f6d33a',
91 },
92 a: [
93 '.R..R.RR..R...', 'RRR.RRRRR.RRR.', '.RRRRRRRRRRRRR', 'RRRRRRRRRRRRR.',
94 '.RRRRRRRRRRRR.', '..WWWWWWWWWW..', '.RSSSSSSSSSSR.', '.RSSkSSSSkSSR.',
95 '..SSSSSSSSSS..', '...SSSmmSSS...', '..BBBBBBBBBB..', '.SBBBBBBBBBBS.',
96 '.SBBBBBBBBBBS.', '..NNNNyNNNNN..', '..PPPPPPPPPP..', '..PPPP..PPPP..',
97 '..NNNN..NNNN..', '.NNNNN..NNNNN.'],
98 b: { 15: '...PPPPPPPP...', 16: '...NNNN.NNNN..', 17: '..NNNNN.NNNNN.' },
99 },
100 { // / Tarnished (Elden Ring)
101 name: 'Tarnished',
102 palette: {
103 Y: '#f0c040', Z: '#8a6a1c', D: '#7a808e', C: '#4a3c34',
104 v: '#141414', o: '#ffb347', y: '#f0c040',
105 },
106 a: [
107 '....YYYYYY....', '...Y......Y...', '....YYDDYY....', '.....DDDD.....',
108 '....DDDDDD....', '...DDvvvvDD...', '...DvovvovD...', '...DDvvvvDD...',
109 '..CDDDDDDDDC..', '.CCDDDDDDDDCC.', 'CCCDDDyDDDDCCC', 'CCCDDDDDDDDCCC',
110 'CCCCDDDDDDCCCC', '.CCCCDDDDCCCC.', '.CCCCDDDDCCCC.', '..CCDD..DDCC..',
111 '....DD..DD....', '...DDD..DDD...'],
112 b: {
113 0: '....ZZZZZZ....', 1: '...Z......Z...', 2: '....ZZDDZZ....',
114 15: '..CCCDDDDCCC..', 16: '.....DDDD.....', 17: '....DDD.DDD...',
115 },
116 },
117 { // / Pac-Man
118 name: 'Pac-Man',
119 palette: { Y: '#f6d33a', k: '#1b1b1b', p: '#ffe9c4' },
120 a: [
121 '..............', '..............', '..............', '..............',
122 '.....YYYY.....', '...YYYYYYYY...', '..YYYYYYkYYY..', '.YYYYYYYYYY...',
123 '.YYYYYYYY.....', 'YYYYYYY.......', 'YYYYYY.....p..', 'YYYYYYY.......',
124 '.YYYYYYYY.....', '.YYYYYYYYYY...', '..YYYYYYYYYY..', '...YYYYYYYY...',
125 '.....YYYY.....', '..............'],
126 b: {
127 7: '.YYYYYYYYYYYY.', 8: '.YYYYYYYYYYYY.', 9: 'YYYYYYYYYYYYYY', 10: 'YYYYYYYYYYYYYY',
128 11: 'YYYYYYYYYYYYYY', 12: '.YYYYYYYYYYYY.', 13: '.YYYYYYYYYYYY.',
129 },
130 },
131 { // / Kirby
132 name: 'Kirby',
133 palette: { P: '#f59ac0', R: '#d8304a', k: '#1b1b1b', b: '#2f5fd8', c: '#ff6f9a', m: '#a3243c' },
134 a: [
135 '..............', '..............', '..............', '..............',
136 '.....PPPP.....', '...PPPPPPPP...', '..PPPPPPPPPP..', '.PPPPPkPkPPPP.',
137 '.PPPPPkPkPPPP.', 'PPPPPPbPbPPPPP', 'PPPPcPPPPPcPPP', 'PPPPPPPmPPPPPP',
138 '.PPPPPPPPPPPP.', '..PPPPPPPPPP..', '...PPPPPPPP...', '..RRRR..RRRR..',
139 '.RRRRR..RRRRR.', '..............'],
140 b: { 7: '.PPPPPPPPPPPP.', 8: '.PPPPPkPkPPPP.', 9: 'PPPPPPPPPPPPPP', 15: '...RRRRRRRR...', 16: '..RRRR..RRRR..' },
141 },
142 { // / Mega Man
143 name: 'Mega Man',
144 palette: { B: '#2a5ad0', C: '#4fb8f0', S: '#f6c79c', W: '#f8f8f8', k: '#1b1b1b', m: '#c47a5a' },
145 a: [
146 '.....BBBB.....', '....BBCCBB....', '...BBBCCBBB...', '..BBBBBBBBBB..',
147 '..BBSSSSSSBB..', '..BSWkSSkWSB..', '..BSSSSSSSSB..', '...SSSmmSSS...',
148 '..CCBBBBBBCC..', '.CCCBBBBBBCCC.', 'BBCCBBBBBBCCBB', 'BB.CBBBBBBC.BB',
149 '...BBBBBBBB...', '...CCCCCCCC...', '...CCC..CCC...', '..BBBB..BBBB..',
150 '.BBBBB..BBBBB.', 'BBBBBB..BBBBBB'],
151 b: { 14: '....CCCCCC....', 15: '...BBBB.BBBB..', 16: '..BBBBB.BBBBB.', 17: '.BBBBBB.BBBBBB' },
152 },
153 { // / Samus
154 name: 'Samus',
155 palette: { R: '#c8302a', O: '#f08a2a', Y: '#f2c84a', G: '#3fd070', A: '#a0a8b8' },
156 a: [
157 '.....RRRR.....', '....RRRRRR....', '...RRGGGGRR...', '...RGGGGGGR...',
158 '...RRGGGGRR...', '..YYRRRRRRYY..', '.YYYYRRRRYYYY.', '.YYYYOOOOYYYY.',
159 '..OOOOOOOOOAA.', '..OORRRRROAAAA', '..OOOYYYOOAAAA', '...OOOOOO.AA..',
160 '...RRRRRR.....', '...OOO.OOO....', '...OOO..OOO...', '...YYY..YYY...',
161 '..RRRR..RRRR..', '.RRRRR..RRRRR.'],
162 b: { 13: '....OOOOOO....', 14: '....OOO.OOO...', 15: '....YYY.YYY...', 16: '...RRRR.RRRR..', 17: '..RRRRR.RRRRR.' },
163 },
164 { // / Pikachu
165 name: 'Pikachu',
166 palette: { Y: '#f6d33a', k: '#1b1b1b', c: '#e0302a', m: '#7a2a1a' },
167 a: [
168 '.k..........k.', '.kk........kk.', '..YY......YY..', '..YYY....YYY..',
169 '...YYYYYYYY...', '..YYYYYYYYYY..', '..YYkYYYYkYY..', '.YcYYYmmYYYcY.',
170 '..YYYYYYYYYY..', '...YYYYYYYY..Y', '..YYYYYYYYY.YY', '..YYYYYYYYYYY.',
171 '..YYYYYYYYYY..', '...YYYYYYYY...', '...YYYYYYYY...', '...YY....YY...',
172 '..YYY....YYY..', '..............'],
173 b: { 6: '..YYYYYYYYYY..', 9: '...YYYYYYYY.Y.', 10: '..YYYYYYYYYYY.' },
174 },
175 { // / Master Chief
176 name: 'Master Chief',
177 palette: { G: '#5f7f3c', V: '#e0a030', D: '#3a3e46' },
178 a: [
179 '....GGGGGG....', '...GGGGGGGG...', '..GGVVVVVVGG..', '..GVVVVVVVVG..',
180 '..GGVVVVVVGG..', '..GGGGGGGGGG..', '.GGGDDDDDDGGG.', 'GGGGGGGGGGGGGG',
181 'GGGGGDDDDGGGGG', 'GG.GGGGGGGG.GG', 'DD.GGGGGGGG.DD', 'DD.DDDDDDDD.DD',
182 '...GGGGGGGG...', '...GGG..GGG...', '...DDD..DDD...', '...GGG..GGG...',
183 '..GGGG..GGGG..', '.GGGGG..GGGGG.'],
184 b: { 13: '....GGGGGG....', 14: '....DDD.DDD...', 15: '....GGG.GGG...', 16: '...GGGG.GGGG..', 17: '..GGGGG.GGGGG.' },
185 },
186 { // Goku
187 name: 'Goku',
188 palette: { H: '#3c3c5a', S: '#f6c79c', O: '#f08a24', U: '#2f5fd8', k: '#1b1b1b', m: '#c47a5a' },
189 a: [
190 '..H..HHH..H...', '.HHH.HHHH.HH..', 'HHHHHHHHHHHHH.', '.HHHHHHHHHHHHH',
191 'HHHHSHHHSHHHH.', '.HHSSSSSSSSH..', '..HSkSSSkSSH..', '...SSSSSSSS...',
192 '....SSmSSS....', '..OOOUUUUOOO..', '.SOOOOUUOOOOS.', '.SUOOOOOOOOUS.',
193 '..UUUUUUUUUU..', '..OOOOOOOOOO..', '..OOOO..OOOO..', '..OOOO..OOOO..',
194 '..UUUU..UUUU..', '.UUUUU..UUUUU.'],
195 b: { 14: '...OOOOOOOO...', 15: '...OOOO.OOOO..', 16: '...UUUU.UUUU..', 17: '..UUUUU.UUUUU.' },
196 },
197 { // Ryu
198 name: 'Ryu',
199 palette: { H: '#4a2c1a', R: '#d8302a', S: '#f6c79c', W: '#eeeeee', b: '#1b1b1b', k: '#1b1b1b', m: '#c47a5a' },
200 a: [
201 '....HHHHH.....', '...HHHHHHH....', '..RRRRRRRRRRRR', '..HSSSSSSSHR.R',
202 '..HSkSSSkSH...', '...SSSSSSS....', '....SSmSS.....', '...WWWSSWWW...',
203 '..WWWWSSWWWW..', '.SWWWWWWWWWWS.', '.SWWWWWWWWWWS.', '..bbbbbbbbbb..',
204 '..WWWWWWWWWW..', '..WWWW..WWWW..', '..WWWW..WWWW..', '..WWW....WWW..',
205 '..SSS....SSS..', '.SSSS....SSSS.'],
206 b: { 13: '...WWWWWWWW...', 14: '...WWWW.WWWW..', 15: '...WWW..WWW...', 16: '...SSS..SSS...', 17: '..SSSS..SSSS..' },
207 },
208 { // Black Mage
209 name: 'Black Mage',
210 palette: { Y: '#d9b45a', N: '#7a4a24', B: '#2f4fb0', T: '#8a5a2c', v: '#141414', y: '#f6e05a' },
211 a: [
212 '......YY......', '.....YYYY.....', '....YYYYY.....', '...YYYYYYY....',
213 '..YYYYYYYYY...', 'YYNNNNNNNNNNYY', '.YYYYYYYYYYYY.', '...vvvvvvvv.Y.',
214 '...vyvvvvyv.T.', '...vvvvvvvv.T.', '..BBBBBBBBBBT.', '.BBBBBBBBBBBT.',
215 '.BBBBBBBBBBBT.', '..BBBBBBBBBBT.', '..BBBBBBBBBBT.', '..BBBBBBBBBBT.',
216 '...NNN..NNN.T.', '..NNNN..NNNNT.'],
217 b: { 16: '....NNNNNN..T.', 17: '...NNNN.NNNNT.' },
218 },
219 { // Sans (Undertale)
220 name: 'Sans',
221 palette: { W: '#eeeeee', B: '#3a6ad0', F: '#d8dce4', K: '#2a2a34', P: '#f08ab0', k: '#111111', w: '#ffffff', c: '#5fd8ff' },
222 a: [
223 '...WWWWWWWW...', '..WWWWWWWWWW..', '.WWWWWWWWWWWW.', '.WWkkWWWWkkWW.',
224 '.WWkwWWWWkwWW.', '.WWWWWkkWWWWW.', '..WkkkkkkkkW..', '..WWkWkWkWWW..',
225 '...WWWWWWWW...', '.FFBBBBBBBBFF.', 'BBBBBBBBBBBBBB', 'BBBBBBkkBBBBBB',
226 'WBBBBBkkBBBBBW', '.BBBBBkkBBBBB.', '..KKKKKKKKKK..', '..KKKK..KKKK..',
227 '...WW....WW...', '..PPPP..PPPP..'],
228 b: { 4: '.WWkkWWWWkcWW.', 12: '.BBBBBkkBBBBB.', 13: 'WBBBBBkkBBBBBW' },
229 },
230 ninja('Scorpion', '#e8c020', '#26262c', '#ffffff'),
231 ninja('Sub-Zero', '#3a7ae0', '#1e2440', '#e8f6ff'),
232 { // MissingNo. (Pokémon)
233 name: 'MissingNo.',
234 palette: { a: '#9a8ab0', b: '#4a4060', c: '#7ad0c0', w: '#e8e8f0', k: '#1b1b1b' },
235 a: [
236 '.......wbkaaa.', '.......wabaak.', '.......kabaka.', '.......abakab.', '.......abckba.',
237 '.......cbabwa.', '.aabkkwkkwcbb.', '.backwkcaakbw.', '.bkkaawwwkkaa.', '.ckaackckwakw.',
238 '.bakabcbbkkka.', '.bkkcbkckwkbb.', '.abbbbakbccab.', '.kwwbakkkkkak.', '.kababkbawaaa.',
239 '.bawaabkbcwwk.', '.aakkkkcabawc.', '.kbabwbacacwb.'],
240 b: {
241 0: '.......bwkbba.', 2: '.......kkkkkb.', 4: '.......kkkaca.', 6: '.bakbbaakbbkk.', 8: '.wcabwbacwbca.',
242 10: '.caakwbkbbkkk.', 12: '.ababwkacwwwa.', 14: '.bkkbkaakbkak.', 16: '.awkabbwkbacc.',
243 },
244 },
245 { // Navi (Zelda)
246 name: 'Navi',
247 palette: { L: '#9fdcff', w: '#ffffff', v: '#d8f0ff' },
248 outline: '#3a6a9a',
249 a: [
250 '..............', '..............', '..............', '..............', '..............',
251 '..vv......vv..', '.vvvv....vvvv.', '.vvvvLLLLvvvv.', '..vvLLwwLLvv..', '...LLwwwwLL...',
252 '...LLwwwwLL...', '..vvLLwwLLvv..', '.vvv.LLLL.vvv.', '..v........v..', '..............',
253 '..............', '..............', '..............'],
254 b: {
255 4: '..............', 5: '..............', 6: '...v......v...', 7: '..vvvLLLLvvv..', 8: '...vLLwwLLv...',
256 9: '...LLwwwwLL...', 10: '...LLwwwwLL...', 11: '..vvLLwwLLvv..', 12: '.vvvvLLLLvvvv.', 13: '..vv......vv..',
257 },
258 },
259 { // Cachorro do Duck Hunt
260 name: 'Duck Hunt',
261 palette: { N: '#b07a3a', T: '#ecc890', E: '#6a3a1a', W: '#f4f4f4', k: '#1b1b1b', m: '#d8505a' },
262 a: [
263 '..............', '....NNNNN.....', '...NNNNNNN....', '..ENNNNkNNN...', '.EENNNNNNTTTk.',
264 '.EENNNNNTTTTT.', '.EEENNNNTmmmT.', '..EE.NNNNTTT..', '..EE.NNNNN....', '....WWNNNNN...',
265 '...WWWNNNNNN..', '..NWWWNNNNNN..', '..NWWWNNNNNNN.', '...WWNNNNNNN..', '...NNN..NNN...',
266 '...NNN..NNN...', '..TTTT..TTTT..', '.TTTTT..TTTTT.'],
267 b: { 4: '.EENNNNNNTTTk.', 5: '.EENNNNNTmmmT.', 6: '.EEENNNNTmmmT.', 11: '..NWWWNNNNNNT.', 12: '..NWWWNNNNNNT.' },
268 },
269 { // Creeper (Minecraft)
270 name: 'Creeper',
271 palette: { G: '#5ab84a', g: '#3a8a32', l: '#8ad87a', k: '#141414' },
272 a: [
273 '..GGGGGGGGGG..', '..GlGGgGGlGG..', '..GkkGGGGkkG..', '..GkkGGgGkkG..', '..GGgGkkGGGG..',
274 '..GGGkkkkgGG..', '..GlGkkkkGGG..', '..GGGkGGkGlG..', '....GGgGGG....', '....GlGGGG....',
275 '....GGGGgG....', '....GgGGGG....', '....GGGlGG....', '....GGGGGG....', '..GGGG..GGGG..',
276 '..GgGG..GGlG..', '..GGGG..GgGG..', '..GGGG..GGGG..'],
277 b: { 14: '...GGGGGGGG...', 15: '...GgGG.GGlG..', 16: '..GGGG...GgGG.', 17: '..GGGG...GGGG.' },
278 },
279 { // Simon Belmont (Castlevania)
280 name: 'Simon',
281 palette: { H: '#c87a3a', S: '#f6c79c', A: '#7a5a3a', D: '#4a3a2a', M: '#b8b8c0', k: '#1b1b1b', m: '#c47a5a', y: '#f6d33a' },
282 a: [
283 '...HHHHHH.....', '..HHHHHHHH....', '.HHHHHHHHHH...', '.HHSSSSSSHH...', '.HHSkSSkSSH...',
284 '..HSSSSSSS....', '...SSmmSS.....', '..AAASSAAA....', '.AAAAAAAAAA...', 'SAAAAAAAAAAS..',
285 'S.AAAyyAAA.SM.', '..AAAAAAAA..M.', '..AAAA.AAAA.M.', '..SSS...SSS.MM', '..SSS...SSS...',
286 '..DDD...DDD...', '.DDDD..DDDD...', '.DDDDD.DDDDD..'],
287 b: { 13: '...SSS.SSS..MM', 14: '...SSS.SSS....', 15: '...DDD.DDD....', 16: '..DDDD.DDDD...', 17: '..DDDDDDDDDD..' },
288 },
289 { // Bomberman
290 name: 'Bomberman',
291 palette: { W: '#f4f4f4', p: '#ff6fa0', S: '#f6c79c', k: '#1b1b1b', B: '#3a5ad8', P: '#e05a9a', y: '#f6d33a' },
292 a: [
293 '......pp......', '......pp......', '.......W......', '....WWWWWW....', '...WWWWWWWW...',
294 '..WWSSSSSSWW..', '..WSSkSSkSSW..', '..WSSkSSkSSW..', '..WWSSSSSSWW..', '...WWWWWWWW...',
295 '..WBBBBBBBBW..', '.WWBBByyBBBWW.', '.WWBBBBBBBBWW.', '...BBBBBBBB...', '...BBB..BBB...',
296 '...BBB..BBB...', '..PPPP..PPPP..', '.PPPPP..PPPPP.'],
297 b: { 0: '.....pp.......', 1: '.....pp.......', 14: '....BBBBBB....', 15: '....BBB.BBB...', 16: '...PPPP.PPPP..', 17: '..PPPPP.PPPPP.' },
298 },
299 { // Donkey Kong
300 name: 'Donkey Kong',
301 palette: { N: '#7a4a24', T: '#e8b47a', R: '#d8302a', k: '#1b1b1b', m: '#7a2a1a', y: '#f6d33a' },
302 a: [
303 '....NNNNNN....', '...NNNNNNNN...', '..NNNNNNNNNN..', '..NTTkTTkTTN..', '..NTTTTTTTTN..',
304 '.NNTTTkkTTTNN.', '.NNTTTTTTTTNN.', 'NNNNTTmmTTNNNN', 'NNNNNRRRRNNNNN', 'NNNNTRyyRTNNNN',
305 'TTNNTTRRTTNNTT', 'TTNNTTRRTTNNTT', 'TT.NNTTTTNN.TT', '...NNNNNNNN...', '..NNNN..NNNN..',
306 '..NNN....NNN..', '.TTTT....TTTT.', '.TTTTT..TTTTT.'],
307 b: { 10: '.TNNTTRRTTNNT.', 11: 'TTNNTTRRTTNNTT', 12: 'TTTNNTTTTNNTTT', 15: '...NNN..NNN...', 16: '..TTTT..TTTT..', 17: '.TTTTT..TTTTT.' },
308 },
309 { // Yoshi
310 name: 'Yoshi',
311 palette: { G: '#4ac040', W: '#f4f4f4', R: '#e0302a', O: '#f08a24', k: '#1b1b1b', w: '#ffffff', r: '#e0302a' },
312 a: [
313 '.......GGG....', '....r.GGGGGG..', '...r.GwwGGGGG.', '..r..GwkGGGGGG', '.....GGGGGGGGG',
314 '....GGGGGWWWW.', '...RRGGGWWW...', '..RRRRGGWW....', '.GRRRRGGWWG...', 'GGGRRGGWWWGG..',
315 'GG.GGGGWWWG...', '...GGGGWWW....', '...GGGGGGG....', '...GGG.GGG....', '...GGG.GGG....',
316 '..OOOO.OOOO...', '.OOOOO.OOOOO..', '..............'],
317 b: { 13: '....GGGGGG....', 14: '....GGG.GGG...', 15: '...OOOO.OOOO..', 16: '..OOOOO.OOOOO.' },
318 },
319 { // Guile (Street Fighter)
320 name: 'Guile',
321 palette: { H: '#f0d050', S: '#f6c79c', G: '#5a7a3a', C: '#7a8a4a', D: '#3a3a3a', k: '#1b1b1b', m: '#c47a5a' },
322 a: [
323 '..HHHHHHHHH...', '..HHHHHHHHH...', '..HHHHHHHHH...', '..HSSSSSSSH...', '..SSkSSSkSS...',
324 '..SSSSSSSSS...', '...SSmmmSS....', '.SSSSSSSSSSS..', 'SSSGGGGGGGSSS.', 'SS.GGGGGGG.SS.',
325 'SS.GGGGGGG.SS.', '...DDDDDDD....', '...CCCCCCC....', '...CCC.CCC....', '...CCC.CCC....',
326 '...CCC.CCC....', '..DDDD.DDDD...', '.DDDDD.DDDDD..'],
327 b: { 9: 'SS.GGGGGGG.SS.', 10: '.S.GGGGGGG.S..', 13: '..CCC...CCC...', 14: '..CCC...CCC...', 15: '..CCC...CCC...', 16: '.DDDD...DDDD..', 17: 'DDDDD...DDDDD.' },
328 },
329 { // Cloud (Final Fantasy VII)
330 name: 'Cloud',
331 palette: { H: '#f0d060', S: '#f6c79c', P: '#5a4a90', D: '#3a3050', A: '#a8b0bc', N: '#7a4a24', k: '#1b1b1b', m: '#c47a5a' },
332 a: [
333 '..H.H.H.....A.', '.HHHHHHH...AA.', 'HHHHHHHHH..AA.', '.HHSSSSHH..AA.', 'HHSkSSkSH..AA.',
334 '..SSSSSS...AA.', '...SSmS....AA.', '..PPPPPPP..AA.', '.PPPPPPPPP.AA.', 'SPPPPPPPPPSAA.',
335 'S.PPNNNPP.SNN.', '..DDDDDDD..NN.', '..DDDDDDD.....', '..DDD.DDD.....', '..DDD.DDD.....',
336 '..DDD.DDD.....', '.NNNN.NNNN....', '.NNNNN.NNNNN..'],
337 b: { 0: '.H.H.H......A.', 13: '..DDD..DDD....', 14: '..DDD..DDD....', 15: '..DDD..DDD....', 16: '.NNNN..NNNN...', 17: 'NNNNN..NNNNN..' },
338 },
339 { // Kratos (God of War)
340 name: 'Kratos',
341 palette: { S: '#e4dcd2', R: '#c8302a', D: '#3a2a20', L: '#8a5a3a', A: '#b0b0b8', k: '#1b1b1b' },
342 a: [
343 '...SSSSSS.....', '..SSSRSSSS....', '..SSSRSSSS....', '..SkSRSkSS....', '..SSSRSSSS....',
344 '..SSDDDDSS....', '...SDDDDS.....', '..SSRSSSSSS...', '.SSSRSSSSSSS..', 'SSSSRSSSSSSSS.',
345 'SS.SRRSSSSS.S.', 'AA.LLLLLLLL.AA', 'AA.LLLLLLLL.AA', '...SSS..SSS...', '...SSS..SSS...',
346 '...SSS..SSS...', '..DDDD..DDDD..', '.DDDDD..DDDDD.'],
347 b: { 10: 'SS.SRRSSSSS.SS', 11: 'AA.LLLLLLLL.AA', 12: '.A.LLLLLLLL.A.', 16: '..DDDD...DDDD.', 17: '.DDDDD...DDDDD' },
348 },
349 { // Madeline (Celeste)
350 name: 'Madeline',
351 palette: { R: '#d8303a', S: '#f6c79c', B: '#5a4ab0', N: '#7a4a24', D: '#4a3a2a', k: '#1b1b1b', m: '#c47a5a' },
352 a: [
353 '..............', '..............', '...RRRRRR.....', '..RRRRRRRR....', '.RRRSSSSSRR...',
354 'RRRSkSSkSR....', 'RRRSSSSSSR....', 'RRR.SSmSS.....', '.RR.BBBBBB....', '...BBBBBBBB...',
355 '..SBBBBBBBBS..', '..SBBBBBBBBS..', '....BBBBBB....', '....NNNNNN....', '....NN..NN....',
356 '....NN..NN....', '...DDD..DDD...', '..DDDD..DDDD..'],
357 b: { 5: '.RRSkSSkSR....', 6: 'RRRSSSSSSR....', 7: '.RR.SSmSS.....', 8: '..R.BBBBBB....', 14: '...NN...NN....', 15: '...NN...NN....', 16: '..DDD...DDD...', 17: '.DDDD...DDDD..' },
358 alt: { palette: { R: '#4ab0f0' } },
359 },
360 { // Space Invader
361 name: 'Invader',
362 palette: { g: '#5adc5a' },
363 a: [
364 '..............', '..............', '..............', '..............', '..............',
365 '...g.....g....', '....g...g.....', '...ggggggg....', '..gg.ggg.gg...', '.ggggggggggg..',
366 '.g.ggggggg.g..', '.g.g.....g.g..', '....gg.gg.....', '..............', '..............',
367 '..............', '..............', '..............'],
368 b: {
369 5: '...g.....g....', 6: '.g..g...g..g..', 7: '.g.ggggggg.g..', 8: '.ggg.ggg.ggg..', 9: '.ggggggggggg..',
370 10: '..ggggggggg...', 11: '...g.....g....', 12: '..g.......g...',
371 },
372 },
373 { // Arthur (Ghosts'n Goblins)
374 name: 'Arthur',
375 palette: { A: '#b8c0cc', R: '#e0302a', D: '#5a6070', S: '#f6c79c', H: '#7a4a24', k: '#1b1b1b', m: '#c47a5a', w: '#f4f4f4', r: '#e0302a' },
376 a: [
377 '.....RRR......', '....RRRRR.....', '...AAAAAAA....', '..AAAAAAAAA...', '..AAkkkkkAA...',
378 '..AAAAAAAAA...', '...AAAAAAA....', '..DAAAAAAAD...', '.AAAAAAAAAAA..', 'AAAAAAAAAAAAA.',
379 'AA.AAAAAAA.AA.', 'SS.DDDDDDD.SS.', '...AAAAAAA....', '...AAA.AAA....', '...AAA.AAA....',
380 '...DDD.DDD....', '..AAAA.AAAA...', '.AAAAA.AAAAA..'],
381 b: { 13: '..AAA...AAA...', 14: '..AAA...AAA...', 15: '..DDD...DDD...', 16: '.AAAA...AAAA..', 17: 'AAAAA...AAAAA.' },
382 alt: {
383 rows: {
384 0: '..............', 1: '..............', 2: '...HHHHHHH....', 3: '..HHHHHHHHH...', 4: '..HSkSSSkSH...',
385 5: '..SSSSSSSSS...', 6: '...SSmmmSS....', 7: '....SSSSS.....', 8: '.SSSSSSSSSSS..', 9: 'SSSSSSSSSSSSS.',
386 10: 'SS.SSSSSSS.SS.', 11: '.S.wwrwwrww.S.', 12: '...wwwrwwww...', 13: '...SSS.SSS....', 14: '...SSS.SSS....',
387 15: '...SSS.SSS....', 16: '..SSSS.SSSS...', 17: '.SSSSS.SSSSS..',
388 },
389 },
390 },
391 { // Tripulante (Among Us)
392 name: 'Tripulante',
393 palette: { R: '#d8303a', V: '#9ad8f0', v: '#ffffff' },
394 a: [
395 '..............', '..............', '..............', '.....RRRRR....', '....RRRRRRR...',
396 '...RRRRVVVVV..', '...RRRVvVVVVV.', '.RRRRRVVVVVVV.', '.RRRRRRVVVVV..', '.RRRRRRRRRRR..',
397 '.RRRRRRRRRRR..', '.RRRRRRRRRRR..', '.RRRRRRRRRRR..', '...RRRRRRRR...', '...RRRR.RRRR..',
398 '...RRR...RRR..', '...RRR...RRR..', '..............'],
399 b: { 14: '...RRRRRRRR...', 15: '..RRR....RRR..', 16: '..RRR....RRR..' },
400 },
401]
402
403// Fantasma do Pac-Man: só aparece no encontro, não entra no rodízio.
404const EXTRA_ARTS: Art[] = [
405 {
406 name: 'Blinky',
407 palette: { R: '#e0302a', w: '#ffffff', b: '#2a4ad8' },
408 a: [
409 '..............', '..............', '..............', '..............', '..............',
410 '..............', '.....RRRR.....', '...RRRRRRRR...', '..RRRRRRRRRR..', '..RRwwRRRwwR..',
411 '.RRRwbbRRwbbR.', '.RRRwbbRRwbbR.', '.RRRRwwRRRwwR.', '.RRRRRRRRRRRR.', '.RRRRRRRRRRRR.',
412 '.RRRRRRRRRRRR.', '.RR.RRR.RRR.R.', '.R...R...R....'],
413 b: { 16: '.R.RRR.RRR.RR.', 17: '....R...R...R.' },
414 alt: { palette: { R: '#2a3ad8', b: '#2a3ad8', w: '#f4c0a0' } },
415 },
416]
417
418/** Os dois ninjas do Mortal Kombat: mesmo corpo, outra cor. */
419function ninja(name: string, cloth: string, dark: string, eye: string): Art {
420 return {
421 name,
422 palette: { Y: cloth, D: dark, w: eye, r: '#c8a040' },
423 a: [
424 '....YYYYYY....', '...YYYYYYYY...', '..YYYYYYYYYY..', '..YYwwYYwwYY..', '..DDDDDDDDDD..',
425 '..YDDDDDDDDY..', '...YYYYYYYY...', '.YYYYDDDDYYYY.', 'YYYYYYDDYYYYYY', 'YYDYYYDDYYYDYY',
426 'DD.YYYDDYYYrDD', 'DD.DDDDDDDD.DD', '...YYYYYYYY...', '...YYY..YYY...', '...YYY..YYY...',
427 '...DDD..DDD...', '..DDDD..DDDD..', '.DDDDD..DDDDD.'],
428 b: { 13: '..YYY...YYY...', 14: '..YYY...YYY...', 15: '..DDD...DDD...', 16: '.DDDD...DDDD..', 17: 'DDDDD...DDDDD.' },
429 }
430}
431
432export type Sprite = { name: string; frames: [Grid, Grid]; alt?: [Grid, Grid] }
433
434function sprite(art: Art): Sprite {
435 const fb = art.a.map((r, i) => art.b[i] ?? r)
436 const out: Sprite = { name: art.name, frames: [bake(art, art.a), bake(art, fb)] }
437 if (art.alt) {
438 const v = { ...art, palette: { ...art.palette, ...art.alt.palette } }
439 const rows = art.alt.rows ?? {}
440 out.alt = [bake(v, art.a.map((r, i) => rows[i] ?? r)), bake(v, fb.map((r, i) => rows[i] ?? r))]
441 }
442 return out
443}
444
445export const SPRITES: Sprite[] = ARTS.map(sprite)
446export const EXTRAS: Sprite[] = EXTRA_ARTS.map(sprite)
447
448/** Cor que mais aparece no sprite (chapéu do Kirby quando copia alguém). */
449export function mainColor(s: Sprite): string {
450 const count = new Map<string, number>()
451 for (const row of s.frames[0]) for (const c of row) if (c && c !== '#1d1a2b') count.set(c, (count.get(c) ?? 0) + 1)
452 return [...count].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '#ffffff'
453}
454hooks/band.ts 305 lines1// A especial que "vaza" do painel: faixa acima do prompt, na largura da conversa,
2// 2 linhas (4 px) de altura, da direita (onde fica o painel) para a esquerda. Fundo transparente.
3import type { Grid } from './sprites'
4import type { FxKind } from './stage'
5import { hash } from './stage'
6import { halo } from './world'
7
8export const BAND_MS: Record<FxKind, number> = {
9 beam: 2200, shot: 1300, missile: 1500, hadouken: 1600, bolt: 1300, dash: 1000,
10 chomp: 2600, coin: 1800, ring: 1400, wind: 1800, grenade: 1900, roll: 1800,
11 gaster: 2200, harpoon: 2000, ice: 2200, glitch: 1400, fairy: 2000, duck: 2200, boom: 1700, whip: 1500,
12 bomb: 1900, barrel: 2000, egg: 1800, sonicboom: 1400, omni: 1600, chains: 1800, airdash: 1100,
13 invaders: 2400, lance: 1500, vent: 2000,
14}
15
16/** Faixa da especial; carregada (power) ganha 2 px de brilho em volta e fica com 3 linhas. */
17export function bandGrid(kind: FxKind, ms: number, W: number, power?: number): Grid {
18 const g = draw(kind, ms, W)
19 if (!power) return g
20 const big: Grid = [Array<string>(W).fill(''), ...g, Array<string>(W).fill('')]
21 halo(big)
22 return big
23}
24
25function draw(kind: FxKind, ms: number, W: number): Grid {
26 const H = 4
27 const g: Grid = Array.from({ length: H }, () => Array<string>(W).fill(''))
28 const put = (x: number, y: number, c: string) => {
29 x = Math.round(x)
30 if (x >= 0 && x < W && y >= 0 && y < H) g[y]![x] = c
31 }
32 const p = Math.min(1, ms / BAND_MS[kind]) // progresso 0..1
33 const lead = W - p * (W + 12) // frente do efeito, vindo da direita
34 switch (kind) {
35 case 'beam': { // feixe cresce da direita até a borda esquerda, segura e afina
36 const front = Math.max(-2, W - Math.min(1, p / 0.45) * (W + 2))
37 const thin = p > 0.85
38 for (let x = Math.ceil(front); x < W; x++) {
39 if (!thin) { put(x, 0, '#5fd8ff'); put(x, 3, '#5fd8ff') }
40 put(x, 1, '#ffffff'); put(x, 2, thin ? '#5fd8ff' : '#ffffff')
41 }
42 if (p < 0.45) for (let y = 0; y < 4; y++) for (let d = -1; d <= 1; d++) put(front + d, y, d === 0 ? '#ffffff' : '#bff0ff')
43 if (p >= 0.45 && p < 0.8) for (let i = 0; i < 6; i++) put(hash(i + Math.floor(ms / 90)) % 4, hash(i * 3) % 4, '#ffe066')
44 break
45 }
46 case 'shot': case 'hadouken': case 'missile': {
47 const x = lead
48 if (kind === 'shot') {
49 for (let y = 1; y < 3; y++) for (let d = 0; d < 3; d++) put(x + d, y, d === 0 ? '#fff6a0' : '#ffd23a')
50 put(x + 4, 1, '#ffe98a'); put(x + 6, 2, '#ffe98a')
51 } else if (kind === 'hadouken') {
52 const wob = Math.floor(ms / 80) % 2
53 for (let y = 0; y < 4; y++) for (let d = 0; d < 4; d++) put(x + d, y, (y === 1 || y === 2) && d < 3 ? '#e6f6ff' : '#5ab0ff')
54 put(x + 5 + wob, 1, '#5ab0ff'); put(x + 7, 2 - wob, '#3a80d0'); put(x + 9, 1, '#3a80d0')
55 } else {
56 put(x, 1, '#ff4a3a'); put(x, 2, '#ff4a3a')
57 for (let d = 1; d < 6; d++) { put(x + d, 1, '#f08a24'); put(x + d, 2, '#c86a14') }
58 for (let i = 0; i < 6; i++) put(x + 8 + i * 3, (i + Math.floor(ms / 100)) % 4, ['#c0c0c0', '#9a9a9a', '#7a7a7a'][i % 3]!)
59 }
60 break
61 }
62 case 'dash': { // Sonic em bola, com rastro
63 const x = lead
64 for (let y = 0; y < 4; y++) for (let d = 0; d < 4; d++) {
65 if ((y === 0 || y === 3) && (d === 0 || d === 3)) continue
66 put(x + d, y, d < 2 && y < 2 ? '#5a9cff' : '#2a6ee6')
67 }
68 for (const y of [0, 1, 2, 3]) for (let d = 5; d < 5 + 10 + y * 3; d += 2) put(x + d, y, d < 9 ? '#cfe6ff' : '#7fb6ff')
69 break
70 }
71 case 'chomp': { // Pac-Man atravessa comendo os pontinhos
72 const x = lead
73 for (let d = 2; d < W; d += 4) if (d < x) { put(d, 1, '#ffe9c4'); put(d + 1, 1, '#ffe9c4') }
74 const open = Math.floor(ms / 120) % 2 === 0
75 const pac = open ? ['YYY.', '..YY', '..YY', 'YYY.'] : ['.YY.', 'YYYY', 'YYYY', '.YY.']
76 pac.forEach((row, y) => [...row].forEach((ch, d) => { if (ch === 'Y') put(x + d, y, '#f6d33a') }))
77 break
78 }
79 case 'coin': { // moedas pulando pela tela
80 for (let i = 0; i < 5; i++) {
81 const x = lead + i * 7
82 const y = Math.round(Math.abs(Math.sin(ms / 140 + i)) * 2)
83 const w = (Math.floor(ms / 100) + i) % 2 ? 1 : 2
84 for (let d = 0; d < w; d++) { put(x + d, y, '#fff6a0'); put(x + d, y + 1, '#f6d33a') }
85 }
86 break
87 }
88 case 'ring': { // raio da espada do Link (vida cheia!)
89 const x = lead
90 for (let d = 0; d < 7; d++) { put(x + d, 1, d === 0 ? '#ffffff' : '#dff2ff'); put(x + d, 2, '#9fd6ff') }
91 put(x + 7, 0, '#e8c84a'); put(x + 7, 3, '#e8c84a'); put(x + 7, 1, '#7a4a24'); put(x + 7, 2, '#7a4a24')
92 put(x + 8, 1, '#7a4a24'); put(x + 8, 2, '#7a4a24')
93 if (Math.floor(ms / 70) % 2) for (let i = 0; i < 3; i++) put(x + 10 + i * 4, i % 4, '#ffffff')
94 break
95 }
96 case 'wind': { // Kirby suga a tela: correntes indo pro painel
97 for (let i = 0; i < 9; i++) {
98 const x = (hash(i * 11) % W) + p * W * 1.4
99 const y = hash(i * 5) % 4
100 for (let d = 0; d < 4; d++) put((x + d) % W, y, d === 3 ? '#ffffff' : '#cfe9ff')
101 }
102 break
103 }
104 case 'grenade': { // granada voa e explode no meio da tela
105 if (p < 0.5) {
106 const x = W - (p / 0.5) * W * 0.6
107 put(x, Math.round(3 - Math.sin((p / 0.5) * Math.PI) * 3), '#4a6a3a')
108 } else {
109 const ex = W * 0.4
110 const r = Math.min(8, (p - 0.5) * 30)
111 for (let y = 0; y < 4; y++) for (let d = -r; d <= r; d++) {
112 if (Math.abs(d) + Math.abs(y - 1.5) * 2 > r) continue
113 const k = Math.abs(d) / Math.max(1, r)
114 put(ex + d, y, p > 0.8 ? (k < 0.5 ? '#9a9a9a' : '#6a6a6a') : k < 0.4 ? '#fff6a0' : k < 0.7 ? '#ffd23a' : '#ff7a2a')
115 }
116 }
117 break
118 }
119 case 'bolt': { // relâmpago horizontal piscando
120 if (Math.floor(ms / 110) % 3 === 2) break
121 let y = 1
122 for (let x = W - 1; x >= 0; x--) {
123 if (x % 3 === 0) y = Math.max(0, Math.min(3, y + (hash(x + Math.floor(ms / 220)) % 3) - 1))
124 put(x, y, '#fff27a')
125 if (hash(x) % 4 === 0) put(x, Math.min(3, y + 1), '#ffd23a')
126 }
127 break
128 }
129 case 'gaster': { // caveira na beirada direita e o feixe atravessando
130 const front = Math.max(-2, W - 8 - Math.min(1, p / 0.4) * W)
131 const SK = ['.WW.', 'WkkW', 'WWWW', 'W.W.']
132 SK.forEach((row, y) => [...row].forEach((ch, d) => { if (ch !== '.') put(W - 5 + d, y, ch === 'W' ? '#f2f2f2' : '#5fd8ff') }))
133 if (p < 0.9) for (let x = Math.ceil(front); x < W - 5; x++) { put(x, 1, '#ffffff'); put(x, 2, p > 0.75 ? '#bfe8ff' : '#ffffff'); if (p <= 0.75) { put(x, 0, '#bfe8ff'); put(x, 3, '#bfe8ff') } }
134 break
135 }
136 case 'harpoon': { // vai até a borda esquerda e volta puxando um pedaço
137 const tip = p < 0.5 ? W - (p / 0.5) * (W + 2) : -2 + ((p - 0.5) / 0.5) * (W + 2)
138 for (let x = Math.ceil(tip) + 1; x < W; x++) put(x, 1, x % 2 ? '#c8a040' : '#8a6a2a')
139 put(tip, 0, '#e0e0e8'); put(tip, 1, '#ffffff'); put(tip, 2, '#e0e0e8'); put(tip - 1, 1, '#c9ced8')
140 if (p >= 0.5) for (let d = 1; d <= 6; d++) { put(tip - 1 - d, 2, '#c9ced8'); put(tip - 1 - d, 3, '#8a8f9a') }
141 break
142 }
143 case 'ice': { // bola de gelo, a faixa congela e racha
144 if (p < 0.45) {
145 const x = W - (p / 0.45) * (W + 6)
146 for (let y = 1; y < 3; y++) for (let d = 0; d < 3; d++) put(x + d, y, d === 0 ? '#ffffff' : '#5ab8f0')
147 for (let i = 1; i < 6; i++) put(x + 3 + i * 2, (i % 2) + 1, '#bfe8ff')
148 } else {
149 const crack = p > 0.75
150 for (let x = 0; x < W; x++) for (let y = 0; y < 4; y++) {
151 const h = hash(x * 4 + y)
152 if (crack && h % 4 === 0) continue
153 const fall = crack ? Math.floor((p - 0.75) * 20 * ((h % 3) + 1)) : 0
154 if (y === 3 || h % 5 === 0) put(x, y + fall, h % 7 === 0 ? '#ffffff' : '#9fd6ff')
155 }
156 }
157 break
158 }
159 case 'glitch': { // blocos corrompidos que vão se consertando
160 const tick = Math.floor(ms / 80)
161 for (let i = 0; i < W / 3; i++) {
162 const h = hash(i * 7 + tick * 131)
163 if (h % 100 >= 45 * (1 - p)) continue
164 const col = ['#ff00ff', '#00ffff', '#ffff00', '#9a8ab0', '#e8e8f0'][h % 5]!
165 for (let d = 0; d < 3; d++) put(i * 3 + d, (h >> 6) % 4, col)
166 }
167 break
168 }
169 case 'fairy': { // Navi atravessa em zigue-zague deixando brilho
170 const at = (k: number) => [lead + k * 3, Math.round(1.5 + Math.sin((ms - k * 60) / 90) * 1.5)] as const
171 for (let k = 6; k >= 0; k--) {
172 const [x, y] = at(k)
173 put(x, y, k === 0 ? '#ffffff' : k < 3 ? '#bfe8ff' : '#7fc8ff')
174 }
175 put(lead, at(0)[1] + 1, '#bfe8ff')
176 break
177 }
178 case 'duck': { // patos cruzam, levam tiro e caem
179 for (let i = 0; i < 2; i++) {
180 const x = W - Math.min(p, 0.6) / 0.6 * W * 0.8 + i * 10
181 const y = p < 0.6 ? 1 - i % 2 : 1 + Math.floor((p - 0.6) * 25)
182 const flap = Math.floor(ms / 120 + i) % 2
183 put(x, y, '#2a8a3a'); put(x - 1, y, '#ffd23a'); put(x + 1, y + 1, '#8a5a2a'); put(x, y + 1, '#8a5a2a')
184 if (p < 0.6) put(x + 1, y + (flap ? 0 : 2), '#c8a070')
185 if (p >= 0.6 && p < 0.68) { put(x - 1, y - 1, '#ffffff'); put(x + 2, y - 1, '#ffffff') }
186 }
187 break
188 }
189 case 'boom': { // explosão do creeper na beirada do painel, blocos voando
190 const ex = W * 0.88
191 const r = Math.min(10, p * 36)
192 for (let y = 0; y < 4; y++) for (let d = -r; d <= r; d++) {
193 if (Math.abs(d) + Math.abs(y - 1.5) * 2 > r) continue
194 const k = Math.abs(d) / Math.max(1, r)
195 put(ex + d, y, p > 0.75 ? (k < 0.5 ? '#9a9a9a' : '#6a6a6a') : k < 0.4 ? '#ffffff' : k < 0.7 ? '#ffe066' : '#ff7a2a')
196 }
197 for (let i = 0; i < 8; i++) put(ex - p * W * (0.3 + (hash(i) % 7) / 10), (hash(i * 3) % 4), i % 2 ? '#5ab84a' : '#3a8a32')
198 break
199 }
200 case 'whip': case 'chains': { // estica até a borda, segura e recolhe
201 const ext = p < 0.4 ? p / 0.4 : p < 0.7 ? 1 : (1 - p) / 0.3
202 const front = W - ext * (W + 2)
203 const lines = kind === 'whip' ? [1] : [0, 3]
204 for (const y of lines) {
205 for (let x = Math.ceil(front); x < W; x++) put(x, y, x % 2 ? '#c9ced8' : '#8a8f9a')
206 if (kind === 'whip') { put(front, 0, '#ff9a3a'); put(front, 2, '#ff9a3a'); put(front - 1, 1, '#ffe066') }
207 else { put(front, y, '#ffe066'); put(front + 1, y, '#ff7a2a'); put(front + 2, y, '#c83a1a'); put(front + 1 + (Math.floor(ms / 90) % 3), y === 0 ? 1 : 2, '#ff9a3a') }
208 }
209 break
210 }
211 case 'bomb': { // chama em cruz atravessando a tela
212 const len = Math.min(W, (p / 0.4) * W)
213 const fade = p > 0.8
214 for (let x = W - 1; x >= W - len; x--) for (let y = 0; y < 4; y++) {
215 if (fade && hash(x * 4 + y + Math.floor(ms / 100)) % 3) continue
216 put(x, y, fade ? '#8a8a8a' : y === 1 || y === 2 ? '#ffffff' : x < W - len + 3 ? '#ff7a2a' : '#ffe066')
217 }
218 break
219 }
220 case 'barrel': { // barris rolando
221 for (let i = 0; i < 3; i++) {
222 const x = lead + i * 11
223 for (let y = 0; y < 4; y++) for (let d = 0; d < 4; d++) {
224 if ((y === 0 || y === 3) && (d === 0 || d === 3)) continue
225 put(x + d, y, (d + Math.floor(ms / 90)) % 3 === 0 ? '#5a3a1a' : '#a06a30')
226 }
227 }
228 break
229 }
230 case 'egg': { // ovo quicando
231 const x = lead
232 const y = Math.round(Math.abs(Math.sin(ms / 160)) * 1) - 1
233 for (let dy = 0; dy < 4; dy++) for (let d = 0; d < 3; d++) {
234 if ((dy === 0 || dy === 3) && d !== 1) continue
235 put(x + d, y + dy, (d + dy + Math.floor(ms / 120)) % 3 === 0 ? '#4ac040' : '#f8f8f8')
236 }
237 break
238 }
239 case 'sonicboom': { // dois crescentes girando
240 for (let i = 0; i < 2; i++) {
241 const x = lead + i * 9
242 const wob = Math.floor(ms / 70) % 2
243 for (let y = 0; y < 4; y++) {
244 const bend = Math.round(((y - 1.5) ** 2) / 1.2)
245 put(x + bend, y, y === 1 || y === 2 ? '#ffffff' : '#ffe066')
246 put(x + bend + 1 + wob, y, '#fff6a0')
247 }
248 }
249 break
250 }
251 case 'omni': { // cortes diagonais pipocando pela tela
252 for (let i = 0; i < 9; i++) {
253 const born = i / 11
254 if (p < born || p > born + 0.18) continue
255 const x0 = hash(i * 19 + 3) % Math.max(1, W - 4)
256 const dir = i % 2 ? 1 : -1
257 for (let y = 0; y < 4; y++) put(x0 + (dir > 0 ? y : 3 - y), y, p < born + 0.08 ? '#ffffff' : '#9fd6ff')
258 }
259 break
260 }
261 case 'airdash': { // rastro rosa e azul
262 const x = lead
263 for (let d = 0; d < 16; d++) for (const y of [1, 2]) put(x + d, y - (d > 8 ? 1 : 0), d < 3 ? '#ffffff' : d < 9 ? '#ff8ab8' : '#8ad8ff')
264 for (let i = 0; i < 4; i++) put(x + 4 + hash(i + Math.floor(ms / 100)) % 14, i % 2 ? 0 : 3, '#ffd6ea')
265 break
266 }
267 case 'invaders': { // fileira marchando pra esquerda; um explode
268 const step = Math.floor(ms / 250)
269 const INV = ['.g.g.', 'ggggg', 'g.g.g']
270 for (let i = 0; i < 6; i++) {
271 const x = W - 4 - step * 3 + i * 8 - (W * 0.2)
272 if (i === 2 && p > 0.55) { if (p < 0.7) for (let k = 0; k < 5; k++) put(x + hash(k + step) % 6, hash(k * 3) % 4, '#ffffff'); continue }
273 INV.forEach((row, y) => [...row].forEach((ch, d) => { if (ch === 'g') put(x + d, y + (step % 2), '#5adc5a') }))
274 }
275 break
276 }
277 case 'lance': { // lanças voando
278 for (let i = 0; i < 3; i++) {
279 const x = lead + i * 14
280 for (let d = 0; d < 7; d++) put(x + d, 1, '#c9ced8')
281 put(x - 1, 1, '#ffffff'); put(x, 0, '#e8e8f0'); put(x, 2, '#e8e8f0')
282 }
283 break
284 }
285 case 'vent': { // sai de uma ventilação, corre e entra na outra
286 for (const vx of [0, W - 6]) for (let d = 0; d < 6; d++) put(vx + d, 3, d % 2 ? '#2a2a33' : '#7a7f8a')
287 if (p > 0.08 && p < 0.92) {
288 const x = W - 6 - ((p - 0.08) / 0.84) * (W - 9)
289 const y = Math.floor(ms / 120) % 2
290 for (let d = 0; d < 3; d++) for (let dy = 0; dy < 3; dy++) put(x + d, y + dy, d === 0 && dy === 0 ? '#9ad8f0' : '#d8303a')
291 }
292 break
293 }
294 case 'roll': { // partículas douradas da Graça
295 for (let i = 0; i < 12; i++) {
296 const x = lead + hash(i * 7) % 30
297 const y = (hash(i) + Math.floor(ms / 200)) % 4
298 if ((i + Math.floor(ms / 150)) % 3) put(x, y, i % 2 ? '#f0c040' : '#fff0a0')
299 }
300 break
301 }
302 }
303 return g
304}
305hooks/stage.ts 365 lines1// Diretor de cena: cada personagem fica TURN ticks (700ms) no palco, começando no centro.
2// Roteiro do turno: entra falando o bordão; depois faz a especial (1 em 3), passeia ou dá pulinhos;
3// no meio comenta algo; termina de volta ao centro.
4export const TURN = 128 // ~90s
5export const SPRITE_W = 16
6const HELLO = [2, 16] as const
7const ACT_AT = 24
8const CHAT = [76, 94] as const
9
10export type FxKind =
11 | 'coin' | 'ring' | 'dash' | 'bolt' | 'roll' | 'chomp' | 'wind'
12 | 'shot' | 'missile' | 'hadouken' | 'grenade' | 'beam'
13 | 'gaster' | 'harpoon' | 'ice' | 'glitch' | 'fairy' | 'duck' | 'boom' | 'whip' | 'bomb'
14 | 'barrel' | 'egg' | 'sonicboom' | 'omni' | 'chains' | 'airdash' | 'invaders' | 'lance' | 'vent'
15
16export const SPECIAL: Record<string, FxKind> = {
17 Mario: 'coin', Link: 'ring', Sonic: 'dash', Crono: 'bolt', Tarnished: 'roll',
18 'Pac-Man': 'chomp', Kirby: 'wind', 'Mega Man': 'shot', Samus: 'missile',
19 Pikachu: 'bolt', 'Master Chief': 'grenade', Goku: 'beam', Ryu: 'hadouken', 'Black Mage': 'bolt',
20 Sans: 'gaster', Scorpion: 'harpoon', 'Sub-Zero': 'ice', 'MissingNo.': 'glitch', Navi: 'fairy',
21 'Duck Hunt': 'duck', Creeper: 'boom', Simon: 'whip', Bomberman: 'bomb', 'Donkey Kong': 'barrel',
22 Yoshi: 'egg', Guile: 'sonicboom', Cloud: 'omni', Kratos: 'chains', Madeline: 'airdash',
23 Invader: 'invaders', Arthur: 'lance', Tripulante: 'vent',
24}
25
26// Encontros: dois personagens no palco ao mesmo tempo.
27export type MeetKind = 'race' | 'duel' | 'chase'
28const PAIRS: [string, string, MeetKind][] = [
29 ['Mario', 'Sonic', 'race'], ['Ryu', 'Guile', 'duel'], ['Scorpion', 'Sub-Zero', 'duel'], ['Pac-Man', 'Blinky', 'chase'],
30]
31export function partner(name: string): { guest: string; kind: MeetKind } | null {
32 for (const [a, b, kind] of PAIRS) {
33 if (a === name) return { guest: b, kind }
34 if (b === name) return { guest: a, kind }
35 }
36 return null
37}
38
39export const hash = (n: number) => {
40 let h = (n ^ 0x9e3779b9) >>> 0
41 h = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0
42 h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0
43 return (h ^ (h >>> 16)) >>> 0
44}
45
46// enc: fala do encontro; who diz quem fala (o balão aponta pra ele), line 0 abre e 1 responde
47export type Talk = { kind: 'hello' | 'chat' | 'shout' | 'enc'; since: number; key: string; who?: 'guest'; line?: 0 | 1 }
48export type Pose = { name: string; x: number; flip: boolean; hop: number; alt?: boolean }
49export type Scene = {
50 turn: number
51 idx: number
52 seed: number
53 x: number
54 flip: boolean
55 hop: number
56 fx: { kind: FxKind; t: number; power?: number } | null
57 talk: Talk | null
58 alt?: boolean // usa a variação do sprite (Madeline de cabelo azul, Arthur sem armadura)
59 hat?: string // Kirby copiou alguém: chapéu na cor desse personagem
60 guest?: Pose // encontro
61 clash?: number // faísca do duelo nessa coluna
62}
63
64const HOPS = [0, 2, 4, 5, 5, 4, 2, 0]
65
66/** Quanto dura a especial e onde o personagem está em cada tick dela. */
67export function fxMotion(kind: FxKind, t: number, cx: number, W: number) {
68 const right = W - SPRITE_W
69 switch (kind) {
70 case 'dash': { // sai correndo pela direita e volta pela esquerda
71 const out = Math.ceil((W - cx) / 3)
72 const back = Math.ceil((cx + SPRITE_W) / 3)
73 const x = t < out ? cx + 3 * t : Math.min(cx, -SPRITE_W + 3 * (t - out))
74 return { dur: out + back + 1, x, hop: 0, flip: false }
75 }
76 case 'roll': {
77 const x = t < 5 ? cx - 2 * t : t < 8 ? cx - 10 : Math.min(cx, cx - 10 + 2 * (t - 8))
78 return { dur: 14, x: Math.max(0, x), hop: t < 5 ? (t % 2) : 0, flip: t < 5 }
79 }
80 case 'chomp': { // anda pra direita comendo as pastilhas e volta
81 const go = Math.max(0, Math.min(12, right - cx))
82 const x = t < go ? cx + t : Math.max(cx, cx + go - (t - go))
83 return { dur: 2 * go + 1, x, hop: 0, flip: t >= go }
84 }
85 case 'coin': return { dur: 10, x: cx, hop: HOPS[t] ?? 0, flip: false }
86 case 'shot': case 'missile': case 'hadouken':
87 return { dur: Math.ceil((W - cx) / 3) + 4, x: cx, hop: 0, flip: false }
88 case 'beam': return { dur: 16, x: cx, hop: 0, flip: false }
89 case 'grenade': return { dur: 14, x: cx, hop: 0, flip: false }
90 case 'wind': return { dur: 14, x: cx, hop: 0, flip: false }
91 case 'ring': return { dur: 12, x: cx, hop: t % 4 === 1 ? 1 : 0, flip: false }
92 case 'bolt': return { dur: 10, x: cx, hop: 0, flip: false }
93 case 'ice': case 'sonicboom': case 'barrel':
94 return { dur: Math.ceil((W - cx) / 3) + 4, x: cx, hop: 0, flip: false }
95 case 'lance': return { dur: Math.ceil((W - cx) / 4) + 8, x: cx, hop: 0, flip: false }
96 case 'harpoon': { // ida e volta da corda
97 const out = Math.ceil((cx + 3) / 4)
98 return { dur: 2 * out + 3, x: cx, hop: 0, flip: false }
99 }
100 case 'glitch': return { dur: 10, x: t < 8 ? cx + [0, 1, -1][t % 3]! : cx, hop: 0, flip: t % 4 === 3 }
101 case 'duck': return { dur: 16, x: cx, hop: [0, 2, 3, 2][t - 12] ?? 0, flip: false }
102 case 'bomb': { // planta a bomba, recua e volta depois da explosão
103 const back = t < 3 ? 0 : t < 7 ? t - 2 : t < 11 ? 4 : Math.max(0, 4 - 2 * (t - 10))
104 return { dur: 14, x: Math.max(0, cx - back), hop: 0, flip: t >= 3 && t < 7 }
105 }
106 case 'omni': { // Cloud teleporta cortando
107 const j = t < 10 ? (hash(t * 7 + 1) % 7) - 3 : 0
108 return { dur: 14, x: Math.max(0, Math.min(right, cx + j)), hop: t < 10 ? hash(t) % 3 : 0, flip: t < 10 && t % 2 === 1 }
109 }
110 case 'airdash': { // dash na diagonal, pra cima e pra frente
111 const d = [0, 0, 2, 4, 6, 8, 7, 6, 4, 3, 1, 0][t] ?? 0
112 return { dur: 12, x: cx + Math.min(d, cx), hop: d, flip: false }
113 }
114 case 'vent': { // entra numa ventilação e sai na outra
115 const x = t < 4 ? cx + t : t < 10 ? -999 : Math.min(cx, cx - 6 + (t - 10))
116 return { dur: 17, x, hop: 0, flip: false }
117 }
118 case 'gaster': case 'chains': case 'egg': case 'boom': case 'invaders': case 'fairy':
119 return { dur: kind === 'chains' ? 12 : kind === 'fairy' || kind === 'invaders' ? 16 : 14, x: cx, hop: 0, flip: false }
120 case 'whip': return { dur: 10, x: cx, hop: 0, flip: false }
121 }
122}
123
124// especial forçada a partir do tick `at` (loud: veio do /especial; power: carregada; enc: encontro em vez de especial)
125export type Force = { at: number; idx: number; loud?: boolean; power?: number; enc?: boolean }
126
127/** Especiais acontecem virados para a conversa (esquerda): o movimento é espelhado em torno do centro. */
128export function fxMove(kind: FxKind, t: number, cx: number, W: number) {
129 const m = fxMotion(kind, t, cx, W)
130 return { ...m, x: 2 * cx - m.x, flip: !m.flip }
131}
132
133/** Tick da especial em que ela chega à borda esquerda do painel e "vaza" para a tela. */
134export function escapeAt(kind: FxKind, cx: number, W: number): number {
135 switch (kind) {
136 case 'beam': return 5
137 case 'shot': case 'missile': case 'hadouken': return Math.ceil(cx / 3)
138 case 'dash': return Math.ceil((cx + SPRITE_W) / 3)
139 case 'chomp': return Math.max(1, Math.min(12, W - SPRITE_W - cx))
140 case 'grenade': return 5
141 case 'ring': return 4
142 case 'coin': return 3
143 case 'roll': return 3
144 case 'wind': case 'bolt': case 'glitch': return 2
145 case 'ice': case 'sonicboom': case 'barrel': return Math.ceil(cx / 3)
146 case 'lance': return Math.ceil(cx / 4)
147 case 'harpoon': return Math.ceil((cx + 1) / 4)
148 case 'gaster': case 'fairy': return 6
149 case 'duck': return 5
150 case 'boom': case 'vent': return 8
151 case 'whip': case 'chains': case 'airdash': case 'invaders': return 4
152 case 'bomb': case 'egg': return 7
153 case 'omni': return 3
154 }
155}
156
157/** Sequência da especial: o Kirby primeiro inspira e depois solta a especial de quem engoliu. */
158function moves(name: string, copy: string): { kinds: FxKind[]; hat?: string } {
159 const own = SPECIAL[name] ?? 'coin'
160 if (name !== 'Kirby') return { kinds: [own] }
161 const got = SPECIAL[copy]
162 return got && got !== 'wind' ? { kinds: ['wind', got], hat: copy } : { kinds: ['wind'] }
163}
164
165/** Onde a especial está no tick `a` (null: acabou). */
166function act(kinds: FxKind[], a: number, cx: number, W: number) {
167 let t = a
168 for (const [i, kind] of kinds.entries()) {
169 const m = fxMove(kind, t, cx, W)
170 if (t < m.dur) return { kind, t, m, part: i }
171 t -= m.dur
172 }
173 return null
174}
175
176const lerp = (a: number, b: number, k: number) => Math.round(a + (b - a) * Math.max(0, Math.min(1, k)))
177const wrapX = (p: number, W: number) => ((p + SPRITE_W) % (W + SPRITE_W) + W + SPRITE_W) % (W + SPRITE_W) - SPRITE_W
178
179/** Coreografia do encontro no tick `a`; null quando acabou. */
180export function meet(kind: MeetKind, a: number, cx: number, W: number) {
181 const right = Math.max(0, W - SPRITE_W)
182 let host = { x: cx, flip: false, hop: 0, alt: false }
183 let guest = { x: W + 2, flip: true, hop: 0, alt: false }
184 let clash: number | undefined
185 let talk: { who?: 'guest'; line: 0 | 1; since: number } | undefined
186 if (kind === 'chase') {
187 // fantasma persegue o Pac-Man pra fora; ele volta com a pastilha de poder e o fantasma foge azul
188 if (a < 28) {
189 host = { x: cx - 2 * a, flip: true, hop: 0, alt: false }
190 guest = { x: W - 2 * a + 6, flip: true, hop: 0, alt: false }
191 } else {
192 const b = a - 28
193 host = { x: Math.min(cx, -SPRITE_W - 16 + 2 * b), flip: false, hop: 0, alt: false }
194 guest = { x: -SPRITE_W + 2 * b, flip: false, hop: 0, alt: true }
195 if (host.x === cx && guest.x > W) return null
196 }
197 if (a >= 1 && a < 9) talk = { line: 0, since: a - 1 }
198 if (a >= 34 && a < 42) talk = { line: 1, since: a - 34 }
199 return { host, guest, clash, talk }
200 }
201 // entrada: anfitrião vai pra esquerda, convidado entra pela direita, os dois se encaram
202 if (a < 6) {
203 host = { x: lerp(cx, 0, (a + 1) / 6), flip: true, hop: 0, alt: false }
204 guest = { x: lerp(W + 2, right, (a + 1) / 6), flip: true, hop: 0, alt: false }
205 } else {
206 host = { x: 0, flip: false, hop: 0, alt: false }
207 guest = { x: right, flip: true, hop: 0, alt: false }
208 }
209 if (a >= 6 && a < 14) talk = { line: 0, since: a - 6 }
210 if (a >= 14 && a < 22) talk = { who: 'guest', line: 1, since: a - 14 }
211 const b = a - 22
212 if (kind === 'race' && b >= 0) {
213 // os dois correm pra direita, dão a volta na tela; o convidado é mais rápido
214 const lap = W + SPRITE_W
215 const gp = Math.min(right + lap, right + 4 * b)
216 const hp = Math.min(lap, 2 * b)
217 guest = { x: wrapX(gp, W), flip: gp === right + lap, hop: gp === right + lap && b % 2 ? 2 : 0, alt: false }
218 host = { x: wrapX(hp, W), flip: false, hop: 0, alt: false }
219 if (hp === lap) { // terminou: convidado sai, anfitrião volta ao centro
220 const c = b - lap / 2
221 guest = { x: right + 2 * c, flip: false, hop: 0, alt: false }
222 host = { x: Math.min(cx, c), flip: false, hop: 0, alt: false }
223 if (host.x === cx && guest.x > W) return null
224 }
225 }
226 if (kind === 'duel' && b >= 0) {
227 if (b < 24) { // trocam golpes: cada um avança por vez, faísca no meio
228 const k = Math.floor(b / 4)
229 const lunge = b % 4 === 1 || b % 4 === 2 ? 3 : 0
230 if (k % 2 === 0) host = { ...host, x: lunge, hop: b % 4 === 1 ? 2 : 0 }
231 else guest = { ...guest, x: right - lunge, hop: b % 4 === 1 ? 2 : 0 }
232 if (b % 4 === 2) clash = Math.floor((host.x + SPRITE_W + guest.x) / 2)
233 } else {
234 const c = b - 24
235 guest = { x: right + 2 * c, flip: false, hop: 0, alt: false }
236 host = { x: Math.min(cx, c), flip: false, hop: 0, alt: false }
237 if (host.x === cx && guest.x > W) return null
238 }
239 }
240 return { host, guest, clash, talk }
241}
242
243export function scene(n: number, seed0: number, names: string[], W: number, force?: Force): Scene {
244 const turn = Math.floor(n / TURN)
245 const t = n % TURN
246 const seed = hash(turn * 31 + seed0)
247 // 5 e o número de personagens são coprimos: percorre todos sem repetir em sequência
248 const at = (k: number) => ((k * 5 + seed0) % names.length + names.length) % names.length
249 const idx = at(turn)
250 const right = Math.max(0, W - SPRITE_W)
251 const cx = Math.floor(right / 2)
252 let x = cx
253 let flip = false
254 let hop = 0
255 let fx: Scene['fx'] = null
256 let talk: Talk | null = null
257 const encounter = (who: number, a: number, key: string, seedOut: number): Scene | null => {
258 const p = partner(names[who]!)
259 const m = p && meet(p.kind, a, cx, W)
260 if (!p || !m) return null
261 const tk: Talk | null = m.talk ? { kind: 'enc', since: m.talk.since, key: `${key}:enc${m.talk.line}`, who: m.talk.who, line: m.talk.line } : null
262 return {
263 turn, idx: who, seed: seedOut, x: m.host.x, flip: m.host.flip, hop: m.host.hop, fx: null, talk: tk, alt: m.host.alt,
264 guest: { name: p.guest, ...m.guest }, clash: m.clash,
265 }
266 }
267 if (force && n > force.at && n - force.at < 200) {
268 const a = n - force.at - 1
269 if (force.enc) {
270 const e = encounter(force.idx, a, `f${force.at}`, 0)
271 if (e) return e
272 } else {
273 // Kirby forçado engole quem estava no palco (ou o anterior, se era ele mesmo)
274 const ft = Math.floor(force.at / TURN) // fixo pelo tick do pedido: não troca no meio se virar o turno
275 const src = at(ft) === force.idx ? at(ft - 1) : at(ft)
276 const mv = moves(names[force.idx]!, names[src]!)
277 const r = act(mv.kinds, a, cx, W)
278 if (r) {
279 return {
280 turn, idx: force.idx, seed: 0, x: r.m.x, flip: r.m.flip, hop: r.m.hop, fx: { kind: r.kind, t: r.t, power: force.power },
281 talk: a < 10 ? { kind: 'shout', since: a, key: `f${force.at}:shout` } : null,
282 alt: altOn(r.kind, r.t, r.m.dur), hat: r.part > 0 ? mv.hat : undefined,
283 }
284 }
285 }
286 }
287 const plan = seed % 4 // 0 especial, 1 passeio, 2 pulinhos, 3 encontro (quem não tem par dá pulinhos)
288 const a = t - ACT_AT
289 let alt = false
290 let hat: string | undefined
291 if (plan === 3 && a >= 0) {
292 const e = encounter(idx, a, `${turn}`, seed)
293 if (e) return e
294 }
295 if (plan === 0 && a >= 0) {
296 const mv = moves(names[idx]!, names[at(turn - 1)]!)
297 const r = act(mv.kinds, a, cx, W)
298 if (r) {
299 fx = { kind: r.kind, t: r.t }
300 ;({ x, hop, flip } = r.m)
301 alt = altOn(r.kind, r.t, r.m.dur)
302 if (r.part > 0) hat = mv.hat
303 if (a < 10) talk = { kind: 'shout', since: a, key: `${turn}:shout` }
304 }
305 } else if (plan === 1 && a >= 0 && right >= 6) {
306 // centro → esquerda → direita → centro
307 if (a < cx) { x = cx - a - 1; flip = true }
308 else if (a < cx + right) { x = a - cx + 1 }
309 else if (a < 2 * right) { x = right - (a - cx - right) - 1; flip = true }
310 x = Math.max(0, Math.min(right, x))
311 } else if ((plan === 2 || plan === 3) && a >= 0 && a < 24) {
312 hop = HOPS[a % 8] ?? 0
313 }
314 if (!talk && t >= HELLO[0] && t < HELLO[1]) talk = { kind: 'hello', since: t - HELLO[0], key: `${turn}:hello` }
315 if (!talk && t >= CHAT[0] && t < CHAT[1]) talk = { kind: 'chat', since: t - CHAT[0], key: `${turn}:chat` }
316 return { turn, idx, seed, x, flip, hop, fx, talk, alt, hat }
317}
318
319/** Madeline fica de cabelo azul depois do dash; Arthur perde a armadura no fim da lança. */
320function altOn(kind: FxKind, t: number, dur: number) {
321 return (kind === 'airdash' && t >= 3 && t < dur - 1) || (kind === 'lance' && t >= dur - 6)
322}
323
324function wrap(text: string, width: number): string[] {
325 const out: string[] = []
326 let line = ''
327 for (const word of text.split(' ')) {
328 const w = word.length > width ? word.slice(0, width - 1) + '…' : word
329 if (!line) line = w
330 else if (line.length + 1 + w.length <= width) line += ' ' + w
331 else { out.push(line); line = w }
332 }
333 if (line) out.push(line)
334 return out.slice(0, 3)
335}
336
337/**
338 * Caixa de diálogo estilo RPG: nome na borda, texto surgindo letra a letra,
339 * ▼ piscando quando termina e rabinho apontando para a cabeça (col `tail`).
340 */
341export function bubble(name: string, text: string, since: number, width: number, tail: number, blink: boolean) {
342 const inner = width - 4
343 const lines = wrap(text, inner)
344 let budget = Math.max(0, since * 6) // 6 letras por tick
345 const total = lines.join('').length
346 const shown = lines.map(l => {
347 const part = l.slice(0, budget)
348 budget = Math.max(0, budget - l.length)
349 return part
350 })
351 const done = shown.join('').length >= total
352 const label = `─ ${name} `
353 const top = '╭' + label + '─'.repeat(Math.max(0, width - 2 - label.length)) + '╮'
354 const body = shown.map((l, i) => {
355 const last = i === shown.length - 1
356 const pad = inner - l.length
357 const mark = last && done && blink ? '▼' : ' '
358 return '│ ' + l + ' '.repeat(Math.max(0, pad - 1)) + (pad > 0 ? mark : '') + ' │'
359 })
360 const t = Math.min(Math.max(tail, 2), width - 4)
361 const bottom = '╰' + '─'.repeat(t - 1) + '╮ ╭' + '─'.repeat(width - t - 4) + '╯'
362 const stem = ' '.repeat(t + 1) + '╲'
363 return { top, body, bottom, stem }
364}
365hooks/talk.ts 309 lines1import type { Last, Snap, Todo } from '../types'
2import { SPECIAL, type FxKind } from './stage'
3import { isRain, type Weather } from './world'
4
5// Bordão de entrada.
6const HELLO: Record<string, string[]> = {
7 Mario: ["It's-a me, Mario!", 'Let’s-a go!', 'Wahoo! Bora codar!', 'Mamma mia, quanto código!'],
8 Link: ['Hyaaa!', 'É perigoso ir sozinho! Leve este café.', '(Link abre um baú) ♪ da-da-da-DAAA!', 'Ouça! Hey, listen!'],
9 Sonic: ['Rápido demais pra você!', 'Gotta go fast!', 'Bora acelerar esse build?', 'Ei! Cheguei antes do deploy.'],
10 Crono: ['…', '(Crono acena em silêncio)', '(Crono concorda com a cabeça)', '(Crono saca a espada)'],
11 Tarnished: ['Levante-se, Maculado.', 'Graça encontrada. Descanse um pouco.', 'Tente rolar.', 'Ó, código à frente.'],
12 'Pac-Man': ['Waka waka!', 'Comendo bugs um por um.', 'Pronto pra próxima fase.', 'Waka? Waka!'],
13 Kirby: ['Poyo!', 'Poyo poyo!', 'Hi! (acena)', 'Poyo? (tá com fome)'],
14 'Mega Man': ['Arma nova adquirida!', 'Pronto pra missão!', 'Carregando o Mega Buster…', 'Dr. Wily não terá chance.'],
15 Samus: ['Varredura completa.', 'Missão registrada.', 'Nenhum Metroid neste repositório.', 'Energia: 99.'],
16 Pikachu: ['Pika pika!', 'Pikachu!', 'Pi-ka? (sei lá o que esse regex faz)', 'Chaaa!'],
17 'Master Chief': ['Termine a luta.', 'Eu preciso de uma arma.', 'Pode deixar comigo.', 'Spartans nunca morrem.'],
18 Goku: ['Oi, eu sou o Goku!', 'Tô ficando com fome…', 'Esse código tem um poder enorme!', 'Vamos treinar!'],
19 Ryu: ['A resposta está no coração da batalha.', 'Oss.', 'Treino é a resposta.', 'Mais um round.'],
20 'Black Mage': ['…', '(chapéu ajeitado)', 'Mana cheia.', 'Fogo, gelo ou raio?'],
21 Sans: ['e aí, humano.', 'heh. tá trabalhando duro, hein?', 'hoje tá um dia lindo lá fora…', 'quer ouvir uma piada? toc toc.'],
22 Scorpion: ['Vem cá!', 'O inferno espera seus bugs.', 'Kombat de código!', 'Fatality nos warnings.'],
23 'Sub-Zero': ['O código está congelado.', 'Sangue frio, deploy frio.', 'Calma glacial.', 'Gelo no bug.'],
24 'MissingNo.': ['?????', 'MissingNo. apareceu!', 'ERR0R: n0me n4o enc0ntrad0', '▓▒░ olá ░▒▓'],
25 Navi: ['Hey, listen!', 'Hey! Hey! Olha!', 'Listen! Tenho uma dica!', 'Watch out!'],
26 'Duck Hunt': ['(rabo abanando)', 'Au au!', '(fareja o diff)', '(ri baixinho)'],
27 Creeper: ['ssssss…', '…sss?', '(chega perto devagar)', 'Que código bonito. Seria uma pena se…'],
28 Simon: ['Que ninguém zombe do chicote.', 'A noite chegou. Hora de caçar bugs.', 'Mais um castelo de código.', 'Ninguém escapa do Belmont.'],
29 Bomberman: ['Bomba no bug!', 'Pronto pra explodir esse teste!', 'Hey!', 'Mais uma fase, mais bombas.'],
30 'Donkey Kong': ['Ooh ooh!', '(bate no peito)', 'Banana pro código bom!', 'DK! Donkey Kong!'],
31 Yoshi: ['Yoshi!', 'Yoshi yoshi!', '(língua de fora)', 'Mlem! Comi um bug.'],
32 Guile: ['Bom combate!', 'Pronto pra missão.', 'Postura de defesa, como sempre.', 'Ninguém passa.'],
33 Cloud: ['…Não me interessa.', 'Ex-SOLDADO, codando agora.', 'Vamos acabar logo com isso.', 'Hm.'],
34 Kratos: ['BOY!', 'Não temos tempo.', 'Concentre-se.', 'O código é uma batalha.'],
35 Madeline: ['Bora subir essa montanha.', 'Respira. Você consegue.', 'Um passo de cada vez.', 'Só mais esse trecho…'],
36 Invader: ['BIP BIP BIP', 'Invasão iniciada.', '(marcha pro lado)', 'Leve-me ao seu repositório.'],
37 Arthur: ['Pela princesa!', 'Armadura polida, lança afiada.', 'Nenhum fantasma me assusta.', 'Pronto pra batalha!'],
38 Tripulante: ['Tô só fazendo minhas tarefas.', 'Alguém viu o fio da elétrica?', '(anda em silêncio)', 'Reunião de emergência?'],
39}
40
41// Grito da especial.
42const SHOUT: Record<FxKind, string[]> = {
43 coin: ['Moeda!', 'Wahoo! +1 moeda'],
44 ring: ['Hyaaa! (giro de espada)', 'Giro de espada!'],
45 dash: ['Muito lento!', 'Lá vou eu!'],
46 bolt: ['Raio!'],
47 roll: ['(rola pra trás)', 'Esquiva!'],
48 chomp: ['Nhac nhac nhac!', 'Waka waka waka!'],
49 wind: ['Poyo! (inspirando…)', 'Hmmmmmff!'],
50 shot: ['Pew pew!', 'Mega Buster!'],
51 missile: ['Míssil lançado.', 'Alvo travado.'],
52 hadouken: ['Hadouken!', 'HADOUKEN!'],
53 grenade: ['Granada!', 'Fogo na área!'],
54 beam: ['KAME… HAME… HAAA!', 'KAMEHAMEHAAA!'],
55 gaster: ['heh. tá tendo um dia ruim?', 'Gaster Blaster!'],
56 harpoon: ['GET OVER HERE!', 'VEM CÁ!'],
57 ice: ['Congela!', 'Gelo!'],
58 glitch: ['▓▒░ERRO░▒▓', '??????????'],
59 fairy: ['Hey, listen!', 'Hey! Olha aqui!'],
60 duck: ['(late pros patos)', 'Au! Patos!'],
61 boom: ['ssssss…', 'SSSSSS… BOOM'],
62 whip: ['Chicotada!', 'Vampire Killer!'],
63 bomb: ['Bomba!', 'Sai da frente!'],
64 barrel: ['Barril!', 'Ooh ooh! Barril!'],
65 egg: ['Yoshi!', 'Mlem… ovo!'],
66 sonicboom: ['SONIC BOOM!', 'Sonic Boom!'],
67 omni: ['Omnislash!', 'Limite: Omnislash!'],
68 chains: ['Lâminas do Caos!', 'BOY! Pra trás!'],
69 airdash: ['Dash!', 'Hup!'],
70 invaders: ['BIP BIP BIP BIP', 'Invasão!'],
71 lance: ['Lança!', 'Pela princesa!'],
72 vent: ['(entra na ventilação)', 'Não vi nada. Não tô aqui.'],
73}
74
75const SHOUT_BY: Record<string, string[]> = {
76 Pikachu: ['Pikaaa… CHUUU!', 'Pika… CHUUU!'],
77 'Black Mage': ['Thundaga!', 'Raio de nível 3!'],
78 Crono: ['(Crono ergue a espada) Lightning!'],
79 Kirby: ['Poyo! (inspirando…)', 'Hmmmmmff… poyo!'],
80}
81
82// Comentário no estilo de cada um.
83const QUIRK: Record<string, string[]> = {
84 Mario: ['A princesa está em outro repositório.', 'Cano novo: pipeline!'],
85 Link: ['Achei uma rupia no meio do diff.', 'Este código pede uma chave pequena.'],
86 Sonic: ['Teste lento? Paraleliza!', 'Odeio água e build lento.'],
87 Crono: ['(Crono olha para o diff, pensativo)', '(Crono sorri para o teste verde)'],
88 Tarnished: ['Cuidado: merge à frente.', 'Tente refatorar, mas com cautela.', 'Ah, um bug. Tente dois-mãos.'],
89 'Pac-Man': ['Fantasma? É só um warning.', 'Fui comer uns imports não usados.'],
90 Kirby: ['Engoli o bug. Agora tenho o poder dele.', 'Poyo! (copiou: refatorar)'],
91 'Mega Man': ['Habilidade copiada: Ctrl+Z!', 'Boss derrotado. Próxima fase!'],
92 Samus: ['Escaneando dependências…', 'Ponto de save à vista: git commit.'],
93 Pikachu: ['Pika! (tradução: roda os testes)', 'Pi-ka-chu! (tradução: faz commit)'],
94 'Master Chief': ['Achei esse bug. Vou terminar a luta.', 'Cortana diria pra ler os logs.'],
95 Goku: ['Seu poder de código está acima de 8000!', 'Bora mais um treino de refatoração?'],
96 Ryu: ['O verdadeiro oponente é você mesmo… e o linter.', 'Disciplina: um commit por vez.'],
97 'Black Mage': ['Magia de nível 3: git bisect.', 'Sem mana pra sexta-feira.'],
98 Sans: ['sabe o que é pior que um bug? dois. heh.', 'eu revisaria esse PR… mas tô com preguiça.', 'você tá cheio de determinação.'],
99 Scorpion: ['Esse bug vai ver o inferno.', 'Fatality no lint: zero warnings.'],
100 'Sub-Zero': ['Freeze no código antes do release.', 'Teste flaky? Congela e investiga.'],
101 'MissingNo.': ['Seu item 6 foi multiplicado por 128.', 'Isso não é um bug. Ou é?', 'Ч̴ ̷ч̶ ̵ч (desculpa, glitchei)'],
102 Navi: ['Dica: /compact quando o contexto encher.', 'Dica: @arquivo cita um arquivo no prompt.', 'Dica: Esc interrompe o Claude.', 'Dica: ! na frente roda um comando no shell.'],
103 'Duck Hunt': ['(ri do seu último erro)', '(farejou um bug ali)'],
104 Creeper: ['Commit antes que eu chegue perto.', 'Esse build tá… explosivo.'],
105 Simon: ['Que código amaldiçoado é esse?', 'Tem vampiro nesse legado.'],
106 Bomberman: ['Explodi os imports não usados.', 'Cuidado com a reação em cadeia nos testes.'],
107 'Donkey Kong': ['Barril de café, por favor.', 'Essa função tá mais pesada que barril.'],
108 Yoshi: ['Engoli o bug e botei um ovo.', 'Yoshi carrega o time nas costas.'],
109 Guile: ['Vá pra casa e seja um pai de família!', 'Defesa primeiro: escreve o teste.'],
110 Cloud: ['Esse código não é meu problema. …Tá, é sim.', 'Limite carregado: refatorar.'],
111 Kratos: ['Esse bug vai pagar.', 'Não olhe para trás. Olhe para o diff.'],
112 Madeline: ['Parte de mim quer desistir. A outra quer o teste verde.', 'Morangos são opcionais. Commits também. Faz mesmo assim.'],
113 Invader: ['Seu código foi invadido por boas práticas.', 'Descendo uma linha a cada bug.'],
114 Arthur: ['Se eu tomar um hit, perco a armadura. Cuidado aí.', 'Dois hits e acabou. Que nem prod sexta à noite.'],
115 Tripulante: ['Esse commit tá sus.', 'Eu vi o bug entrando na ventilação.'],
116}
117
118// Encontros: [quem abre, quem responde], pela fala de cada personagem.
119const ENC: Record<string, [string[], string[]]> = {
120 Mario: [['Corrida até o deploy?', 'Aposta uma moeda?'], ['Mamma mia, de novo não!', 'Wahoo! Valendo!']],
121 Sonic: [['Bora correr? Perdedor revisa o PR!', 'Te dou 3 segundos de vantagem.'], ['Você nunca vai me alcançar!', 'Gotta go fast!']],
122 Ryu: [['Lute comigo, Guile.', 'Mostre sua força.'], ['O caminho do guerreiro não tem fim.', 'Oss.']],
123 Guile: [['Pronto pra apanhar, Ryu?', 'Vamos ver quem é mais forte.'], ['Vá pra casa e seja um pai de família!', 'Bom combate!']],
124 Scorpion: [['Sub-Zero! Hoje você não escapa.', 'O fogo vence o gelo.'], ['Vingança!', 'GET OVER HERE!']],
125 'Sub-Zero': [['Scorpion. Sabia que viria.', 'Seu fogo não me assusta.'], ['Gelo apaga fogo.', 'Calma glacial.']],
126 'Pac-Man': [['Fantasma! Corre!', 'Waka?! Socorro!'], ['Pastilha de poder! Agora é minha vez!', 'Waka waka! Volta aqui!']],
127}
128
129export function encLine(name: string, line: 0 | 1, seed: number): string {
130 return pick(ENC[name]?.[line] ?? ['…'], seed)
131}
132
133const MOTIVA = [
134 'Commit pequeno é commit feliz.', 'Um passo de cada vez. Tá indo bem!',
135 'Código simples hoje, menos plantão amanhã.', 'Erro é só o compilador te ajudando.',
136 'Respira. O bug tem medo de quem tem calma.', 'Bebeu água hoje?',
137 'Teste verde é a melhor sensação do mundo.', 'Você já resolveu coisa pior que isso.',
138 'Feito é melhor que perfeito.', 'Alonga as costas, campeão.',
139]
140
141const PIADA = [
142 'Funciona na minha máquina. Vamos mandar a máquina?', 'Não é bug, é feature não documentada.',
143 'Existem 10 tipos de pessoas: as que entendem binário e as que não.',
144 'Cache e nomear coisas: os dois problemas difíceis. E off-by-one.',
145 'O código mais rápido é o que não roda.', 'git push --force: coragem ou desespero?',
146 'Debugar é ser o detetive de um crime que você mesmo cometeu.',
147 '99 bugs no código… corrige um… 127 bugs no código.',
148 'Regex resolve um problema. Agora você tem dois.',
149 'Documentação é como piada: se precisa explicar, não é boa.',
150]
151
152const base = (p: string) => p.split('/').pop() ?? p
153const days = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / 86400000)
154
155function quando(now: Date): string[] {
156 const out: string[] = []
157 const h = now.getHours()
158 const dow = now.getDay()
159 if (h < 5) out.push('Madrugada codando? Respeito. Mas dorme, hein.')
160 else if (h < 12) out.push(h < 9 ? 'Bom dia! Café já tomado?' : 'Bom dia! Manhã produtiva, hein.')
161 else if (h < 14) out.push('Já almoçou? Código com fome sai torto.')
162 else if (h < 18) out.push(h >= 17 ? 'Quase hora de ir embora…' : 'Boa tarde! Hora do cafezinho?')
163 else out.push('Boa noite! Hora extra ou paixão?')
164 if (dow === 1) out.push('Segunda-feira. Força, guerreiro.')
165 if (dow === 5) out.push(h >= 14 ? 'Sextou! Nada de deploy em produção, hein.' : 'Sexta-feira! Segura o deploy até segunda.')
166 if (dow === 0 || dow === 6) out.push('Trabalhando no fim de semana? Hmm…')
167 // datas especiais
168 const y = now.getFullYear()
169 const d0 = new Date(y, now.getMonth(), now.getDate())
170 if (days(new Date(y, 0, 1), d0) === 255) out.push('Dia do Programador! 256º dia do ano, claro.')
171 if (dow === 5 && now.getDate() === 13) out.push('Sexta-feira 13: nada de rm -rf hoje.')
172 const kids = days(d0, new Date(y, 9, 12))
173 if (kids === 0) out.push('Dia das Crianças! Hoje vale jogar um pouquinho.')
174 else if (kids > 0 && kids <= 5) out.push(`Dia das Crianças em ${kids} dia${kids > 1 ? 's' : ''}!`)
175 if (now.getMonth() === 9 && now.getDate() === 31) out.push('Halloween: cuidado com os bugs fantasmas.')
176 if (now.getMonth() === 11 && now.getDate() === 25) out.push('Feliz Natal! Deploy hoje só se for presente.')
177 if (now.getMonth() === 0 && now.getDate() === 1) out.push('Feliz Ano Novo! Novo ano, mesmos bugs.')
178 return out
179}
180
181function clima(w: Weather): string[] {
182 if (!w.desc) return []
183 const out: string[] = []
184 const t = w.temp
185 const d = w.desc.toLowerCase()
186 if (isRain(w)) out.push('Tá chovendo lá fora. Dia perfeito pra codar.')
187 else out.push(t !== null ? `Lá fora: ${t}°C e ${d}.` : `Lá fora: ${d}.`)
188 if (t !== null && t >= 30) out.push(`${t}°C! Liga o ar-condicionado e o dark mode.`)
189 if (t !== null && t <= 15) out.push('Friozinho, hein? Café quente e código quente.')
190 return out
191}
192
193function trabalho(s: Snap, t: Todo[], last: Last, mins: number): string[] {
194 const out: string[] = []
195 const pct = Math.round(s.pct)
196 if (pct >= 80) out.push(`Contexto em ${pct}%. Pensa num /compact?`)
197 else if (pct > 0 && pct < 15) out.push(`Contexto fresquinho, ${pct}%. Manda ver!`)
198 const doing = t.find(x => x.status === 'in_progress')
199 const left = t.filter(x => x.status !== 'completed').length
200 if (doing) out.push(`Foco: ${doing.content}`)
201 if (t.length && !left) out.push('Checklist zerado. Mandou bem!')
202 else if (left > 1) out.push(`Faltam ${left} tarefas. Bora!`)
203 const live = s.agents.filter(a => a.status === 'running' || a.status === 'waiting').length
204 if (live) out.push(`${live} agente${live > 1 ? 's' : ''} trabalhando pra você. Chefe de equipe!`)
205 if (s.usd >= 1) out.push(`Já foram US$ ${s.usd.toFixed(2)}. Valendo cada centavo?`)
206 if (mins >= 120) out.push(`${Math.floor(mins / 60)}h de sessão. Que tal levantar um pouco?`)
207 if (s.ponytail) out.push('Ponytail ligado: menos código, menos bug.')
208 if (last.tool === 'Bash') out.push('Terminal pegando fogo!')
209 if ((last.tool === 'Edit' || last.tool === 'Write') && last.file) out.push(`Mexendo em ${base(last.file)}. Capricha!`)
210 if (last.tool === 'Read' || last.tool === 'Grep') out.push('Lendo o código com calma. Gosto disso.')
211 if (last.tool === 'Agent') out.push('Delegou, é? Estilo gerente.')
212 return out
213}
214
215const pick = <T>(xs: T[], seed: number) => xs[Math.abs(seed) % xs.length]!
216
217export type World = { s: Snap; t: Todo[]; last: Last; mins: number; now: Date; weather: Weather }
218
219/** Escolhe a fala. Chamar uma vez por janela de fala e guardar: as entradas mudam a cada tool call. */
220export function say(kind: 'hello' | 'chat' | 'shout', name: string, fx: FxKind | null, seed: number, w: World): string {
221 if (kind === 'hello') return pick(HELLO[name] ?? ['Olá!'], seed)
222 if (kind === 'shout') return pick((fx === SPECIAL[name] && SHOUT_BY[name]) || SHOUT[fx ?? 'coin'], seed)
223 if (w.s.pct >= 80) return trabalho(w.s, w.t, w.last, w.mins)[0]! // alerta de contexto tem prioridade
224 const work = trabalho(w.s, w.t, w.last, w.mins)
225 const pools = [work, quando(w.now), clima(w.weather), QUIRK[name] ?? [], PIADA, MOTIVA].filter(p => p.length)
226 return pick(pick(pools, seed), seed >> 3)
227}
228
229// Dicas da Navi (atalhos reais do Claude Code e deste mod).
230const TIPS = [
231 'Contexto enchendo? /compact resume a conversa.', 'Esc interrompe o Claude no meio do caminho.',
232 'Esc duas vezes volta pra uma mensagem anterior.', 'Shift+Tab alterna os modos de permissão.',
233 '@arquivo coloca um arquivo no contexto.', '! no começo roda um comando de shell aqui mesmo.',
234 '/especial nome forte solta a especial carregada.', '/encontro chama dois personagens pro palco.',
235 'Ctrl+O mostra os detalhes das ferramentas.', '/model troca o modelo da sessão.',
236]
237
238/** Quebra da quarta parede: o que escapa do painel vira toast. */
239export function escape(kind: FxKind, name: string, coins: number): string | null {
240 switch (kind) {
241 case 'coin': return `+1 moeda pro Mario (total: ${coins})`
242 case 'dash': return 'Sonic deu uma volta inteira no seu terminal.'
243 case 'beam': return 'O Kamehameha atravessou a barra lateral. Seu código está bem?'
244 case 'shot': return 'O tiro do Mega Man saiu do painel. Foi mal pelo transcript!'
245 case 'missile': return 'Míssil da Samus perdido em algum lugar do terminal.'
246 case 'hadouken': return 'Hadouken! Se o prompt tremer, foi o Ryu.'
247 case 'bolt': return `${name} conjurou um raio. Lembra de salvar os arquivos!`
248 case 'chomp': return 'Pac-Man comeu 3 pontos do seu terminal. Waka.'
249 case 'gaster': return 'heh. o Gaster Blaster passou pela sua conversa. tá tudo bem, humano.'
250 case 'harpoon': return 'Scorpion puxou um pedaço do seu terminal. GET OVER HERE!'
251 case 'ice': return 'Sub-Zero congelou a tela por um segundo. Já descongelou.'
252 case 'glitch': return 'MissingNo. corrompeu o terminal… brincadeira, seus arquivos estão bem.'
253 case 'fairy': return `Hey, listen! ${TIPS[coins % TIPS.length]}`
254 case 'duck': return 'O cachorro do Duck Hunt está rindo do seu último erro.'
255 case 'boom': return 'Um Creeper explodiu perto do seu prompt. Ainda bem que tem git.'
256 case 'whip': return 'O chicote do Simon estalou até o fim da tela.'
257 case 'bomb': return 'Bomberman explodiu uma fileira inteira do terminal.'
258 case 'barrel': return 'Donkey Kong jogou barris na sua conversa. Pula!'
259 case 'egg': return 'Yoshi jogou um ovo no seu terminal.'
260 case 'sonicboom': return 'SONIC BOOM! O Guile mandou lembranças.'
261 case 'omni': return 'Omnislash! Cloud cortou a tela em pedacinhos.'
262 case 'chains': return 'As Lâminas do Caos passaram raspando no prompt. BOY!'
263 case 'airdash': return 'Madeline deu um dash pela tela. Cabelo azul = dash gasto.'
264 case 'invaders': return 'Space Invaders no seu terminal! Um já foi abatido.'
265 case 'lance': return 'Arthur jogou a lança… e perdeu a armadura de novo. Não olha.'
266 case 'vent': return 'Tem alguém na ventilação do seu terminal… sus.'
267 default: return null
268 }
269}
270
271// Reações a eventos do trabalho: tarefa concluída, pedido de autorização, pausa no fim do turno.
272const CHEER: Record<'done' | 'ask' | 'pause' | 'abort', string[]> = {
273 done: ['Tarefa concluída: {d}!', 'Missão cumprida: {d}', 'Mais uma! {d}', 'Check! {d}'],
274 ask: ['Ei! Preciso da sua autorização aí embaixo.', 'Posso rodar {d}? Dá uma olhada no prompt!', 'Autorização pendente: {d}.'],
275 pause: ['Sua vez! Tô esperando.', 'Pronto! Próximo comando?', 'Terminei. O que achou?', 'Pausa pro café? Tô aqui.'],
276 abort: ['Opa, interrompido. Tudo bem?', 'Parei! Mudou de ideia?'],
277}
278const CHEER_BY: Record<string, Partial<Record<keyof typeof CHEER, string[]>>> = {
279 Mario: { done: ['Wahoo! Fase concluída: {d}'], pause: ['Let’s-a go? Sua vez!'] },
280 Link: { done: ['♪ Da-da-da-DAAA! {d}'], ask: ['Hey! Listen! Autorização pendente!'] },
281 Tarnished: { done: ['INIMIGO ABATIDO: {d}'], pause: ['Descanse na Graça. Sua vez.'] },
282 Samus: { done: ['Missão completa: {d}'], ask: ['Aguardando ordens: {d}.'] },
283 Goku: { done: ['Treino concluído! {d}'], pause: ['Ufa! Bora mais um round?'] },
284 'Mega Man': { done: ['Boss derrotado: {d}!'] },
285 Pikachu: { ask: ['Pika?! (aprova aí: {d})'], done: ['Pika pika! ({d})'] },
286 Crono: { pause: ['(Crono olha pra você, esperando)'], ask: ['(Crono aponta para o prompt)'] },
287 Kirby: { done: ['Poyo! {d}'], pause: ['Poyo? (sua vez)'] },
288 'Pac-Man': { done: ['Waka! Comi essa: {d}'] },
289 'Master Chief': { done: ['Objetivo concluído: {d}'], ask: ['Aguardando autorização, senhor.'] },
290 Ryu: { done: ['Vitória! {d}'] },
291 Sonic: { pause: ['Tô parado? Isso é tortura. Sua vez!'] },
292 'Black Mage': { ask: ['(segura o feitiço) Autoriza: {d}?'] },
293 Sans: { done: ['heh. feito: {d}'], pause: ['sua vez, humano.'], ask: ['psst. aprova aí: {d}'] },
294 Scorpion: { done: ['FATALITY! {d}'] },
295 'Sub-Zero': { done: ['Congelado e concluído: {d}'] },
296 Navi: { ask: ['Hey! Listen! Autoriza: {d}?'], pause: ['Hey! Sua vez!'] },
297 Creeper: { done: ['ssss… {d} (não explodi)'] },
298 Kratos: { done: ['Está feito. {d}'], pause: ['BOY! Sua vez.'] },
299 Madeline: { done: ['Cume alcançado: {d}'] },
300 Tripulante: { done: ['Tarefa concluída: {d} (sério, não sou o impostor)'] },
301 Arthur: { ask: ['Aguardando ordens, majestade: {d}'] },
302}
303
304export function cheer(why: keyof typeof CHEER, name: string, detail: string, seed: number): string {
305 const pool = [...(CHEER_BY[name]?.[why] ?? []), ...CHEER[why]]
306 const line = pick(pool, seed).replace('{d}', detail)
307 return line.length > 70 ? line.slice(0, 69) + '…' : line
308}
309hooks/world.ts 572 lines1// O palco em pixels: céu pela hora real, sol/lua em arco, nuvens e chuva pelo clima,
2// árvore, grama, passarinho de vez em quando, o personagem e as magias por cima.
3import type { Grid } from './sprites'
4import { hash, SPRITE_W, type Scene } from './stage'
5
6export const STAGE_ROWS = 19 // linhas de texto (38 px): personagem + espaço do balão
7const GROUND = 3
8
9import type { Weather } from '../types'
10export type { Weather }
11
12const mix = (hex: string, to: string, t: number) =>
13 '#' + [1, 3, 5].map(i => {
14 const a = parseInt(hex.slice(i, i + 2), 16)
15 const b = parseInt(to.slice(i, i + 2), 16)
16 return Math.round(a + (b - a) * t).toString(16).padStart(2, '0')
17 }).join('')
18
19function mute(hex: string) {
20 const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number]
21 const l = 0.299 * r + 0.587 * g + 0.114 * b
22 const gray = '#' + [r, g, b].map(c => Math.round(c + (l - c) * 1).toString(16).padStart(2, '0')).join('')
23 return mix(gray, '#1a1a1a', 0.58)
24}
25
26export const isRain = (w: Weather) => /chuv|garoa|tempest|trovo|aguaceiro/i.test(w.desc)
27export const isCloudy = (w: Weather) => /nubl|encobert|neblin|nevoeiro/i.test(w.desc)
28
29function sky(hour: number): [string, string, number] {
30 // [topo, horizonte, quanto escurecer a paisagem]
31 if (hour < 5 || hour >= 20) return ['#070b1f', '#1b2550', 0.55]
32 if (hour < 7) return ['#2c3e7a', '#f2a46b', 0.25]
33 if (hour < 17) return ['#4a90d9', '#b4e0f7', 0]
34 return ['#3b3a78', '#f08a5d', 0.25]
35}
36
37const TREE = [
38 '...LLGGG...', '..LGGGgGG..', '.LGGGGGGgG.', 'LGGgGGGGGGg', 'LGGGGGgGGGg',
39 'GGGGGGGGGgg', '.GgGGGGGgg.', '..gGGGGgg..', '....TTt....', '....TTt....',
40 '....TTt....', '...TTTtt...',
41]
42const TREE_PAL: Record<string, string> = { L: '#6fc25a', G: '#3f8f3a', g: '#2a6428', T: '#7a5230', t: '#5a3b20' }
43const CLOUD = ['..WWWW...', '.WWWWWWW.', 'WWWWWWWWW']
44const SUN = ['.YY.', 'YYYY', 'YYYY', '.YY.']
45const BIRD = [['K.K', '.K.'], ['.K.', 'K.K']]
46
47export function paint(
48 n: number, W: number, sc: Scene, sprite: Grid, now: Date, weather: Weather,
49 extra: { guest?: Grid; hat?: string } = {},
50): Grid {
51 const H = STAGE_ROWS * 2
52 const gy = H - GROUND // primeira linha de grama
53 const g: Grid = Array.from({ length: H }, () => Array<string>(W).fill(''))
54 const put = (x: number, y: number, c: string) => {
55 if (x >= 0 && x < W && y >= 0 && y < H) g[y]![x] = c
56 }
57 const hour = now.getHours() + now.getMinutes() / 60
58 const [top, bottom, night] = sky(hour)
59 const wet = isRain(weather)
60 const gray = wet || isCloudy(weather) ? 0.45 : 0
61 const land = (c: string) => mix(c, '#000000', night)
62
63 // céu em degradê
64 for (let y = 0; y < gy; y++) {
65 const c = mix(mix(top, bottom, y / gy), '#8a94a6', gray)
66 for (let x = 0; x < W; x++) g[y]![x] = c
67 }
68 // estrelas
69 if (night > 0.5 && !wet) {
70 for (let i = 0; i < 14; i++) {
71 const h = hash(i * 97 + 3)
72 if ((n + i) % 6 !== 0) put(h % W, (h >> 8) % (gy - 8), (n + i) % 3 ? '#e8ecff' : '#8f9bd0')
73 }
74 }
75 // sol de 6h às 18h, lua de 18h às 6h, em arco
76 const isDay = hour >= 6 && hour < 18
77 const arc = isDay ? (hour - 6) / 12 : ((hour - 18 + 24) % 24) / 12
78 const ox = Math.round(1 + arc * (W - 6))
79 const oy = Math.round(2 + (1 - Math.sin(Math.PI * arc)) * (gy - 18))
80 SUN.forEach((row, y) => [...row].forEach((ch, x) => {
81 if (ch === '.') return
82 const c = isDay ? (x + y < 3 ? '#fff6c0' : '#ffd84a') : (x === 2 && y === 1 ? '#b9c0d0' : '#eef1f8')
83 put(ox + x, oy + y, gray && isDay ? mix(c, '#d0d4dc', 0.5) : c)
84 }))
85 // nuvens
86 const clouds = wet ? 4 : gray ? 3 : 2
87 for (let i = 0; i < clouds; i++) {
88 const cx = ((hash(i * 7) % (W + 12)) + Math.floor(n / (5 + i))) % (W + 12) - 9
89 const cy = 2 + (i % 3) * 4 + (hash(i) % 2)
90 const c = wet ? '#9aa1ad' : night > 0.5 ? '#2c3558' : gray ? '#d5d9e0' : '#ffffff'
91 CLOUD.forEach((row, y) => [...row].forEach((ch, x) => {
92 if (ch !== '.') put(cx + x, cy + y, y === 2 ? mix(c, '#000000', 0.12) : c)
93 }))
94 }
95 // passarinho cruza o céu de vez em quando (não voa na chuva)
96 const fly = n % 150
97 if (!wet && fly < W + 4) {
98 const frame = BIRD[Math.floor(n / 1) % 2]!
99 const by = 9 + (fly % 6 < 3 ? 0 : 1)
100 frame.forEach((row, y) => [...row].forEach((ch, x) => {
101 if (ch !== '.') put(W - fly + x, by + y, night > 0.5 ? '#9aa3b5' : '#2b2b33')
102 }))
103 }
104 // árvore à esquerda
105 if (W >= 26) {
106 TREE.forEach((row, y) => [...row].forEach((ch, x) => {
107 if (ch !== '.') put(1 + x, gy - TREE.length + y, land(TREE_PAL[ch]!))
108 }))
109 }
110 // grama com tufos e flores, terra embaixo
111 for (let x = 0; x < W; x++) {
112 const h = hash(x * 13 + 1)
113 put(x, gy, land(h % 5 === 0 ? '#5cc062' : '#4caf50'))
114 if (h % 4 === 0) put(x, gy - 1, land('#3d9140'))
115 if (h % 17 === 0) put(x, gy - 1, land(h % 2 ? '#ff7aa8' : '#ffe066'))
116 for (let y = gy + 1; y < H; y++) put(x, y, land(hash(x * 31 + y) % 7 === 0 ? '#5e3d22' : '#7a5230'))
117 }
118
119 // cenário em tons de cinza (como o tema); só personagem e magias têm cor
120 for (const line of g) for (let x = 0; x < W; x++) if (line[x]) line[x] = mute(line[x]!)
121
122 // personagem (e o convidado do encontro)
123 const sy = gy - sprite.length + 1 - sc.hop
124 sprite.forEach((row, y) => row.forEach((c, x) => { if (c) put(sc.x + x, sy + y, c) }))
125 if (extra.guest && sc.guest) {
126 const gy0 = gy - extra.guest.length + 1 - sc.guest.hop
127 extra.guest.forEach((row, y) => row.forEach((c, x) => { if (c) put(sc.guest!.x + x, gy0 + y, c) }))
128 }
129 if (extra.hat) { // boné do Kirby copiando: aba + copa na cor de quem ele engoliu
130 const dark = mix(extra.hat, '#000000', 0.35)
131 for (let i = 4; i <= 11; i++) put(sc.x + i, sy + 3, i === 4 || i === 11 ? dark : extra.hat)
132 for (let i = 3; i <= 12; i++) put(sc.x + i, sy + 4, dark)
133 for (let i = 6; i <= 9; i++) put(sc.x + i, sy + 2, extra.hat)
134 }
135 if (sc.clash !== undefined) { // faísca do duelo
136 const cy = gy - 10
137 for (const [dx, dy, c] of [[0, 0, '#ffffff'], [1, 0, '#ffe066'], [-1, 0, '#ffe066'], [0, 1, '#ffe066'], [0, -1, '#ffe066'], [2, -2, '#ff9a3a'], [-2, 2, '#ff9a3a'], [2, 2, '#ff9a3a'], [-2, -2, '#ff9a3a']] as const) {
138 put(sc.clash + dx, cy + dy, c)
139 }
140 }
141
142 if (sc.fx) {
143 // a especial é desenhada olhando pra direita e espelhada em torno do centro (vira pra conversa);
144 // vai numa camada à parte pra especial carregada ganhar um brilho em volta
145 const axis = 2 * Math.floor((W - SPRITE_W) / 2) + SPRITE_W - 1
146 const layer: Grid = Array.from({ length: H }, () => Array<string>(W).fill(''))
147 const putM = (x: number, y: number, c: string) => {
148 const X = axis - Math.round(x)
149 if (X >= 0 && X < W && y >= 0 && y < H) layer[Math.round(y)]![X] = c
150 }
151 const isBody = (x: number, y: number) => !!sprite[y - sy]?.[axis - x - sc.x]
152 fx(sc.fx.kind, sc.fx.t, { put: putM, g, W: axis + 1, gy, x: axis - (sc.x + SPRITE_W - 1), sy, n, isBody })
153 if (sc.fx.power) halo(layer)
154 for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (layer[y]![x]) g[y]![x] = layer[y]![x]!
155 }
156
157 // chuva por cima de tudo
158 if (wet) {
159 for (let i = 0; i < 22; i++) {
160 const h = hash(i * 53 + 9)
161 const x = (h % (W + 6)) - Math.floor(n / 1) % 3
162 const y = ((h >> 8) + n * 3) % gy
163 put(x, y, mute('#a8c8ff'))
164 put(x, y + 1, mute('#7fa7e8'))
165 }
166 }
167 return g
168}
169
170type Ctx = {
171 put: (x: number, y: number, c: string) => void; g: Grid; W: number; gy: number; x: number; sy: number; n: number
172 isBody: (x: number, y: number) => boolean
173}
174
175/** Brilho em volta de tudo que já está na camada (especial carregada). */
176export function halo(layer: Grid) {
177 const H = layer.length
178 const W = layer[0]?.length ?? 0
179 const glow: [number, number, string][] = []
180 for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
181 if (layer[y]![x]) continue
182 const near = [layer[y - 1]?.[x], layer[y + 1]?.[x], layer[y]![x - 1], layer[y]![x + 1]].find(Boolean)
183 if (near) glow.push([x, y, mix(near, '#ffffff', 0.55)])
184 }
185 for (const [x, y, c] of glow) layer[y]![x] = c
186}
187
188function fx(kind: string, t: number, c: Ctx) {
189 const { put, W, gy, x, sy } = c
190 const hx = x + 13 // mão, olhando pra direita
191 const hy = sy + 11
192 const ball = (cx: number, cy: number, r: number, inner: string, outer: string) => {
193 for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
194 const d = dx * dx + dy * dy
195 if (d <= r * r) put(cx + dx, cy + dy, d <= (r - 1) * (r - 1) ? inner : outer)
196 }
197 }
198 switch (kind) {
199 case 'shot': case 'missile': case 'hadouken': {
200 const px = hx + 2 + 3 * t
201 if (kind === 'shot') { ball(px, hy, 1, '#fff6a0', '#ffd23a'); put(px - 2, hy, '#ffe98a') }
202 if (kind === 'missile') {
203 for (let i = 0; i < 4; i++) put(px - i, hy, i === 0 ? '#ff4a3a' : '#f08a24')
204 put(px - 5, hy, '#b0b0b0'); put(px - 7, hy - 1, '#8a8a8a'); put(px - 9, hy, '#6a6a6a')
205 }
206 if (kind === 'hadouken') {
207 ball(px, hy, 2, '#e6f6ff', '#5ab0ff')
208 put(px - 3, hy - 1, '#5ab0ff'); put(px - 4, hy + 1, '#3a80d0'); put(px - 5, hy, '#3a80d0')
209 }
210 break
211 }
212 case 'beam': { // carrega e solta o feixe até a borda
213 if (t < 5) { ball(hx + 1, hy, 1 + (t % 2), '#ffffff', '#5fd8ff'); break }
214 const thick = t < 13 ? 1 : 0
215 for (let bx = hx + 1; bx < W; bx++) {
216 for (let dy = -thick; dy <= thick; dy++) put(bx, hy + dy, dy === 0 ? '#ffffff' : '#5fd8ff')
217 }
218 ball(hx + 1, hy, 2, '#ffffff', '#5fd8ff')
219 break
220 }
221 case 'bolt': { // céu pisca, raio cai na frente do personagem
222 const bx = x + SPRITE_W + 6 < W ? x + SPRITE_W + 4 : Math.max(1, x - 5)
223 if (t < 2) {
224 for (let y = 0; y < gy; y++) for (let i = 0; i < W; i++) {
225 const cur = c.g[y]![i]
226 if (cur) c.g[y]![i] = mix(cur, '#ffffff', 0.45)
227 }
228 }
229 if (t >= 1 && t < 6) {
230 let lx = bx
231 for (let y = 0; y < gy; y++) {
232 if (y % 3 === 0) lx += (hash(y * 7 + t) % 3) - 1
233 put(lx, y, '#fff27a'); put(lx + 1, y, '#ffd23a')
234 }
235 }
236 if (t >= 5 && t < 9) for (let i = 0; i < 6; i++) put(bx - 3 + (hash(i + t) % 8), gy - 1 - (hash(i * 3) % 3), '#ffe066')
237 break
238 }
239 case 'coin': {
240 if (t < 8) {
241 const cx = x + 7
242 const cy = sy - 2 - t
243 const w = t % 2 ? 1 : 2
244 for (let dy = 0; dy < 3; dy++) for (let dx = 0; dx < w; dx++) put(cx + dx, cy + dy, dx === 0 && dy === 0 ? '#fff6a0' : '#f6d33a')
245 }
246 break
247 }
248 case 'ring': { // giro de espada: arco de luz ao redor
249 const cx = x + 8
250 const cy = sy + 11
251 for (let k = 0; k < 4; k++) {
252 const a = ((t * 2 + k) % 12) / 12 * Math.PI * 2
253 put(Math.round(cx + Math.cos(a) * 10), Math.round(cy + Math.sin(a) * 6), ['#ffffff', '#dff2ff', '#9fd6ff', '#5fb0f0'][k]!)
254 }
255 break
256 }
257 case 'dash': { // rastro de velocidade
258 for (const dy of [5, 10, 14]) for (let i = 1; i <= 5; i++) put(x - i, sy + dy, i < 3 ? '#cfe6ff' : '#7fb6ff')
259 break
260 }
261 case 'roll': {
262 if (t < 8) for (let i = 0; i < 3; i++) put(x + 12 + i * 2 + (t % 2), gy - 1 - (i % 2), '#c9b79a')
263 break
264 }
265 case 'chomp': { // pastilhas fixas no chão; somem quando a boca passa
266 const start = Math.floor((W - SPRITE_W) / 2)
267 for (let i = 0; i < 3; i++) {
268 const px = start + 16 + 3 * i
269 if (x + 13 < px) { put(px, sy + 10, '#ffe9c4'); put(px + 1, sy + 10, '#ffe9c4') }
270 }
271 break
272 }
273 case 'wind': { // Kirby inspirando: correntes de ar vindo na direção da boca
274 for (let i = 0; i < 5; i++) {
275 const dist = (W - hx) - ((t * 3 + i * 7) % Math.max(1, W - hx))
276 const y = sy + 5 + i * 2 + ((t + i) % 2)
277 for (let k = 0; k < 3; k++) put(hx + dist + k, y, k === 0 ? '#ffffff' : '#cfe9ff')
278 }
279 break
280 }
281 case 'grenade': {
282 if (t < 8) {
283 put(hx + 2 * t, Math.round(hy - 5 * t + 0.7 * t * t), '#3a5a2a')
284 } else {
285 const ex = hx + 15
286 const ey = gy - 2
287 const r = (t - 8) * 2 + 1
288 ball(ex, ey, Math.min(r, 5), t < 11 ? '#ffe066' : '#9a9a9a', t < 11 ? '#ff7a2a' : '#6a6a6a')
289 }
290 break
291 }
292 case 'gaster': { // caveira aparece no alto e solta o feixe
293 const sx = x + 12 // no alto, à frente da cabeça
294 const sk = Math.max(0, sy - 7)
295 const jaw = t >= 4 ? 1 : 0
296 const SKULL = ['.WWWWW.', 'WWWWWWW', 'WkkWkkW', 'WkcWkcW', 'WWWkWWW']
297 if (t >= 1) SKULL.forEach((row, y) => [...row].forEach((ch, d) => {
298 if (ch !== '.') put(sx + d, sk + y, ch === 'W' ? '#f2f2f2' : ch === 'c' ? (t % 2 ? '#5fd8ff' : '#ffe066') : '#111111')
299 }))
300 if (t >= 1) for (const d of [0, 2, 4, 6]) put(sx + d, sk + 5 + jaw, '#f2f2f2')
301 if (t >= 4 && t < 13) {
302 const th = t < 11 ? 1 : 0
303 for (let bx = sx + 7; bx < W; bx++) for (let dy = -th; dy <= th; dy++) put(bx, sk + 4 + dy, dy === 0 ? '#ffffff' : '#bfe8ff')
304 }
305 break
306 }
307 case 'harpoon': { // arpão vai até a borda e volta puxando um pedaço da tela
308 const start = Math.floor((W - SPRITE_W) / 2)
309 const out = Math.ceil((start + 3) / 4)
310 const reach = t < out ? 4 * (t + 1) : Math.max(0, 4 * (2 * out - t))
311 const tip = Math.min(W, hx + 1 + reach)
312 for (let i = hx + 1; i < tip; i++) put(i, hy - 1, i % 2 ? '#c8a040' : '#8a6a2a')
313 put(tip, hy - 2, '#e0e0e8'); put(tip, hy - 1, '#ffffff'); put(tip, hy, '#e0e0e8'); put(tip + 1, hy - 1, '#c9ced8')
314 if (t >= out && reach > 0) for (let i = 0; i < 4; i++) for (let j = 0; j < 2; j++) put(tip + 2 + i, hy - 2 + j, j ? '#8a8f9a' : '#c9ced8')
315 break
316 }
317 case 'ice': {
318 const px = hx + 2 + 3 * t
319 ball(px, hy, 1, '#ffffff', '#5ab8f0')
320 for (let i = 1; i <= 5; i++) put(px - 2 * i, hy + (i % 2) - 1 + (t % 2), i < 3 ? '#bfe8ff' : '#7fc0f0')
321 break
322 }
323 case 'sonicboom': {
324 const px = hx + 2 + 3 * t
325 for (let dy = -3; dy <= 3; dy++) {
326 const bend = Math.floor((dy * dy) / 3)
327 put(px - bend, hy + dy, dy === 0 ? '#ffffff' : '#ffe066')
328 put(px - bend - 1, hy + dy, '#fff6a0')
329 if (Math.abs(dy) < 3 && t % 2) put(px - bend - 3, hy + dy, '#ffd23a')
330 }
331 break
332 }
333 case 'barrel': { // barril rolando e quicando no chão
334 const bx = hx + 2 + 3 * t
335 const by = gy - 4 - (t % 3 === 0 ? 1 : 0)
336 for (let dy = 0; dy < 4; dy++) for (let d = 0; d < 4; d++) {
337 if ((dy === 0 || dy === 3) && (d === 0 || d === 3)) continue
338 put(bx + d, by + dy, (d + t) % 3 === 0 ? '#5a3a1a' : '#a06a30')
339 }
340 break
341 }
342 case 'lance': { // lança atravessa; no fim o Arthur leva um golpe e a armadura voa
343 const px = hx + 2 + 4 * t
344 if (px < W) {
345 for (let i = 0; i < 6; i++) put(px - i, hy, '#c9ced8')
346 put(px + 1, hy, '#ffffff'); put(px, hy - 1, '#e8e8f0'); put(px, hy + 1, '#e8e8f0')
347 }
348 const start = Math.floor((W - SPRITE_W) / 2)
349 const hit = Math.ceil((W - start) / 4) + 2
350 if (t >= hit && t < hit + 4) {
351 const k = t - hit
352 for (let i = 0; i < 6; i++) put(x + 2 + i * 2 + (i % 2 ? k : -k), sy + 4 + i * 2 - 3 * k + k * k, i % 2 ? '#b8c0cc' : '#8a92a0')
353 }
354 break
355 }
356 case 'glitch': { // MissingNo. bagunça o palco por um instante
357 if (t < 8) {
358 for (let r = 0; r < 4; r++) {
359 const y = hash(r * 13 + t) % gy
360 const sh = (hash(r + t * 3) % 7) - 3
361 const row = c.g[y]!
362 const copy = [...row]
363 for (let i = 0; i < row.length; i++) row[i] = copy[(i - sh + row.length) % row.length]!
364 }
365 for (let i = 0; i < 8; i++) {
366 const h = hash(i * 31 + t * 7)
367 const col = ['#ff00ff', '#00ffff', '#ffff00', '#1b1b1b', '#9a8ab0'][h % 5]!
368 for (let d = 0; d < 3; d++) put((h >> 4) % W + d, (h >> 12) % gy, col)
369 }
370 }
371 break
372 }
373 case 'fairy': { // "!" e depois a Navi dá uma volta brilhando e sai voando
374 if (t < 4) { for (let y = 0; y < 3; y++) put(x + 7, sy + 1 + y, '#ffe066'); put(x + 7, sy + 5, '#ffe066') }
375 const pos = (k: number) => k < 10
376 ? [x + 8 + Math.cos(k * 0.8) * 10, sy + 9 + Math.sin(k * 0.8) * 5]
377 : [x + 8 + (k - 9) * 6, sy + 9 - (k - 9)]
378 for (let i = 0; i < 4; i++) {
379 if (t - i < 0) continue
380 const [px, py] = pos(t - i)
381 put(px!, Math.round(py!), ['#ffffff', '#bfe8ff', '#7fc8ff', '#4a90d0'][i]!)
382 }
383 break
384 }
385 case 'duck': { // patos sobem da grama, tiro, caem; o cachorro ri
386 if (t === 6) for (let y = 0; y < gy; y++) for (let i = 0; i < c.g[y]!.length; i++) {
387 const cur = c.g[y]![i]
388 if (cur) c.g[y]![i] = mix(cur, '#ffffff', 0.5)
389 }
390 for (let i = 0; i < 2; i++) {
391 const dx = hx + 4 + i * 7
392 const dy = t < 6 ? gy - 2 - 3 * t - i * 2 : gy - 20 - i * 2 + 4 * (t - 6)
393 if (t >= 12 || dy >= gy) continue
394 const flap = t < 6 && (t + i) % 2
395 put(dx, dy, '#2a8a3a'); put(dx + 1, dy, '#ffd23a')
396 put(dx - 1, dy + 1, '#8a5a2a'); put(dx, dy + 1, '#8a5a2a')
397 put(dx - 1, dy + (flap ? 0 : 2), '#c8a070')
398 }
399 break
400 }
401 case 'boom': { // creeper pisca... e explode
402 if (t < 6 && t % 2 === 0) for (let y = sy; y < sy + 20; y++) for (let i = x; i < x + SPRITE_W; i++) if (c.isBody(i, y)) put(i, y, '#f4f4f4')
403 if (t >= 6) {
404 const r = Math.min(2 + (t - 6) * 2, 9)
405 const smoke = t >= 11
406 ball(x + 8, sy + 12, r, smoke ? '#9a9a9a' : t < 8 ? '#ffffff' : '#ffe066', smoke ? '#6a6a6a' : '#ff7a2a')
407 for (let i = 0; i < 6; i++) put(x + 8 + Math.round(Math.cos(i) * (r + 3)), sy + 12 + Math.round(Math.sin(i) * (r + 2)), i % 2 ? '#5ab84a' : '#7a7a7a')
408 }
409 break
410 }
411 case 'whip': { // chicote estala reto até a borda
412 if (t < 2) { for (let i = 0; i < 4; i++) put(hx - 1 - i, hy - 1 - i, '#b8b8c0'); break }
413 const len = t < 8 ? Math.min(W - hx, (t - 1) * 12) : Math.max(0, (W - hx) - (t - 7) * 14)
414 for (let i = 1; i <= len; i++) put(hx + i, hy - 1, i % 2 ? '#c9ced8' : '#8a8f9a')
415 if (len >= W - hx - 1) { put(W - 1, hy - 2, '#ff9a3a'); put(W - 1, hy, '#ff9a3a'); put(W - 2, hy - 1, '#ffe066') }
416 break
417 }
418 case 'bomb': { // bomba no chão, pavio, explosão em cruz
419 const start = Math.floor((W - SPRITE_W) / 2)
420 const bx = start + 17
421 const by = gy - 3
422 if (t < 6) {
423 ball(bx, by, 2, '#2a2a33', '#14141a'); put(bx - 1, by - 1, '#6a6a7a')
424 put(bx + 1, by - 3, '#c8a040'); put(bx + 2, by - 4, t % 2 ? '#ffe066' : '#ff6a3a')
425 } else {
426 const len = Math.min((t - 5) * 6, W)
427 const fade = t >= 11
428 for (let d = -len; d <= len; d++) for (let dy = -1; dy <= 1; dy++) {
429 if (fade && (hash(d * 7 + dy + t) % 3)) continue
430 put(bx + d, by + dy, fade ? '#8a8a8a' : dy === 0 ? '#ffffff' : Math.abs(d) > len - 3 ? '#ff7a2a' : '#ffe066')
431 }
432 for (let up = 1; up <= Math.min(len, 10); up++) for (let dx = -1; dx <= 1; dx++) {
433 if (fade && (hash(up * 5 + dx + t) % 3)) continue
434 put(bx + dx, by - up, fade ? '#8a8a8a' : dx === 0 ? '#ffffff' : '#ffd23a')
435 }
436 }
437 break
438 }
439 case 'egg': { // língua, depois o ovo em arco
440 if (t < 4) {
441 const len = [3, 7, 7, 3][t]!
442 for (let i = 1; i <= len; i++) put(x + 13 + i, sy + 5, '#e0302a')
443 put(x + 14 + len, sy + 5, '#ff8aa8')
444 } else {
445 const k = t - 4
446 const ex = hx + 2 + 4 * k
447 const ey = sy + 6 - Math.round(Math.sin((k / 9) * Math.PI) * 7)
448 for (let dy = 0; dy < 4; dy++) for (let d = 0; d < 3; d++) {
449 if ((dy === 0 || dy === 3) && d !== 1) continue
450 put(ex + d, ey + dy, (d + dy + k) % 3 === 0 ? '#4ac040' : '#f8f8f8')
451 }
452 }
453 break
454 }
455 case 'omni': { // Omnislash: cortes por toda parte, e o X final
456 if (t < 10) {
457 for (const k of [t, t - 1]) {
458 if (k < 0) continue
459 const h = hash(k * 17 + 5)
460 const ox = x - 4 + (h % Math.max(1, W - x))
461 const oy = sy + 2 + ((h >> 8) % 12)
462 const dir = k % 2 ? 1 : -1
463 for (let i = 0; i < 8; i++) put(ox + i, oy + dir * Math.floor(i / 1.5), k === t ? (i > 5 ? '#ffffff' : '#9fd6ff') : '#5a8ab8')
464 }
465 } else {
466 const cxx = hx + 6
467 const cyy = hy - 3
468 for (let i = -6; i <= 6; i++) { put(cxx + i, cyy + Math.round(i * 0.7), '#fff6a0'); put(cxx + i, cyy - Math.round(i * 0.7), '#fff6a0') }
469 }
470 break
471 }
472 case 'chains': { // Lâminas do Caos: giram em volta e depois disparam
473 const tips: [number, number][] = []
474 if (t < 5) {
475 const a = t * 1.3
476 tips.push([hx + Math.cos(a) * 9, hy - 2 + Math.sin(a) * 6], [hx + Math.cos(a + Math.PI) * 9, hy - 2 + Math.sin(a + Math.PI) * 6])
477 } else {
478 const reach = t < 9 ? (t - 4) * 8 : Math.max(2, (13 - t) * 8)
479 tips.push([Math.min(W - 1, hx + reach), hy - 3], [Math.min(W - 1, hx + reach - 3), hy + 1])
480 }
481 for (const [tx, ty] of tips) {
482 const steps = Math.max(1, Math.round(Math.hypot(tx - hx, ty - hy) / 2))
483 for (let i = 1; i < steps; i++) put(hx + ((tx - hx) * i) / steps, Math.round(hy + ((ty - hy) * i) / steps), '#8a8f9a')
484 put(tx, Math.round(ty), '#ffe066'); put(tx - 1, Math.round(ty), '#ff7a2a'); put(tx - 2, Math.round(ty), '#c83a1a')
485 put(tx, Math.round(ty) - 1 - (t % 2), '#ff9a3a')
486 }
487 break
488 }
489 case 'airdash': { // rastro rosa/azul e poeira na saída
490 if (t < 3) for (let i = 0; i < 4; i++) put(x + 2 + i * 3, gy - 1 - (i % 2), '#c9b79a')
491 if (t >= 2 && t < 8) for (let i = 1; i <= 6; i++) for (const dy of [4, 9, 14]) put(x - i, sy + dy + Math.floor(i / 2), i < 3 ? '#8ad8ff' : '#ff8ab8')
492 break
493 }
494 case 'invaders': { // fileira de invasores marchando; o Invader acerta um
495 const INV = ['.g.g.', 'ggggg', 'g.g.g']
496 const step = Math.floor(t / 2) % 2 ? 2 : 0
497 const baseY = 2 + Math.floor(t / 4) * 2
498 for (let i = 0; i < 3; i++) {
499 const ix = hx - 6 + i * 8 + step
500 if (i === 1 && t >= 10) {
501 if (t < 13) for (let k = 0; k < 5; k++) put(ix + (hash(k + t) % 6), baseY + (hash(k * 3) % 4), '#ffffff')
502 continue
503 }
504 INV.forEach((row, y) => [...row].forEach((ch, d) => { if (ch === 'g') put(ix + d, baseY + y, '#5adc5a') }))
505 }
506 if (t >= 6 && t < 10) for (let y = sy + 4; y > baseY + 3; y--) put(hx - 6 + 8 + step + 2 + ((y + t) % 2), y, '#ffffff')
507 break
508 }
509 case 'vent': { // duas ventilações no chão: entra numa e sai na outra
510 const start = Math.floor((W - SPRITE_W) / 2)
511 for (const vx of [start + 18, start - 8]) for (let d = 0; d < 6; d++) {
512 put(vx + d, gy, d % 2 ? '#2a2a33' : '#7a7f8a'); put(vx + d, gy - 1, '#4a4f5a')
513 }
514 if (t >= 3 && t < 5) for (let i = 0; i < 4; i++) put(start + 18 + (hash(i + t) % 6), gy - 2 - (hash(i) % 3), '#c9ced8')
515 if (t >= 9 && t < 11) for (let i = 0; i < 4; i++) put(start - 8 + (hash(i + t) % 6), gy - 2 - (hash(i) % 3), '#c9ced8')
516 break
517 }
518 }
519}
520
521export type Cell = { ch: string; fg: string; bg: string; bold?: boolean }
522
523/** Grade de pixels → células de texto: ▀ com cor de frente (pixel de cima) e fundo (de baixo). */
524export function cells(g: Grid): Cell[][] {
525 const out: Cell[][] = []
526 for (let y = 0; y < g.length; y += 2) {
527 out.push(g[y]!.map((top, x) => {
528 const fg = top || '#000000'
529 return { ch: '▀', fg, bg: g[y + 1]?.[x] || fg }
530 }))
531 }
532 return out
533}
534
535const lum = (hex: string) =>
536 0.299 * parseInt(hex.slice(1, 3), 16) + 0.587 * parseInt(hex.slice(3, 5), 16) + 0.114 * parseInt(hex.slice(5, 7), 16)
537
538/**
539 * Carimba texto no palco: a letra herda o fundo do cenário atrás dela (média dos dois pixels)
540 * e a cor se ajusta ao contraste. `tone`: 'ink' texto, 'line' borda, ou uma cor fixa.
541 */
542export function stamp(c: Cell[][], row: number, col: number, text: string, tone: string, opts: { bold?: boolean; skipSpaces?: boolean } = {}) {
543 const line = c[row]
544 if (!line) return
545 ;[...text].forEach((ch, i) => {
546 const cell = line[col + i]
547 if (!cell || (opts.skipSpaces && ch === ' ')) return
548 const bg = cell.ch === '▀' ? mix(cell.fg, cell.bg, 0.5) : cell.bg
549 const light = lum(bg) > 125
550 const fg = tone === 'ink' ? (light ? '#15171c' : '#f4f4f4')
551 : tone === 'line' ? (light ? '#3a3f4b' : '#c9ced8')
552 : tone
553 line[col + i] = { ch, fg, bg, bold: opts.bold }
554 })
555}
556
557/** Junta células vizinhas iguais em um só trecho de texto. */
558export function merge(c: Cell[][]) {
559 return c.map(line => {
560 const out: { fg: string; bg: string; bold?: boolean; text: string }[] = []
561 for (const cell of line) {
562 const last = out[out.length - 1]
563 if (last && last.fg === cell.fg && last.bg === cell.bg && last.bold === cell.bold) last.text += cell.ch
564 else out.push({ fg: cell.fg, bg: cell.bg, bold: cell.bold, text: cell.ch })
565 }
566 return out
567 })
568}
569
570/** Atalho: pixels → trechos prontos (sem carimbos). */
571export const rows = (g: Grid) => merge(cells(g))
572types/index.d.ts 22 lines1export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
2export type Agent = { id: string; type: string; description: string; status: string }
3export type Weather = { temp: number | null; desc: string; at: number }
4export type Banner = { kind: '' | 'died' | 'done'; at: number }
5export type Last = { tool: string; file: string }
6export type Snap = {
7 model: string
8 pct: number
9 tokens: number
10 window: number
11 usd: number
12 agents: Agent[]
13 startedAt: number
14 ponytail: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'pixel-sidebar': { snap: Snap; todos: Todo[]; tick: number; last: Last; weather: Weather; banner: Banner; gen: string; force: { at: number; idx: number; loud?: boolean; power?: number; enc?: boolean } }
20 }
21}
22