SLOPSHOPPER

pixel-sidebar

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-10karnagge/pixel-sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-sidebar
│ ┃ Sessão ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ MODELO 29860725min │ ⏺ Read(src/auth.ts) │ ┃ │ … │ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ CONTEXTO 0% │ ⎿ 3 pass, 1 fail │ ┃ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ ┃ │ ░░░░░░░░░░░░ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 0 de 0 tokens │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ CUSTO │ › /sidebar │ ┃ │ US$ 0.00 │ ⎿ pixel-sidebar: Barra lateral aberta. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ NÍVEL 0 XP │ │ ┃ │ Nv 1 0/6 conquistas │ │ ┃ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ ┃ │ ░░░░░░░░░░░░ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ FOCO 25 min │ │ ┃ │ 24:59 │ │ ┃ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessão
╭──────────────────────────────────────────────────────────╮ │ MODELO 29860725min │ │ … │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ CONTEXTO 0% │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ 0 de 0 tokens │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ CUSTO │ │ US$ 0.00 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ NÍVEL 0 XP │ │ Nv 1 0/6 conquistas │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ FOCO 25 min │ │ 24:59 │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ TAREFAS │ │ sem checklist │ ╰──────────────────────────────────────────────────────────╯ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ VOCÊ MORREU ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
README

pixel-sidebar

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.

Personagens

O que tem

  • Painel: modelo atual, barra de uso do contexto, custo da sessão, agentes ativos e a checklist de tarefas (TodoWrite).
  • Palco: um cenário discreto em tons de cinza do tema. O céu muda com a hora do dia, o sol e a lua fazem seu arco e aparecem nuvens, chuva, uma árvore, grama e um passarinho de vez em quando. Só os personagens e os especiais são coloridos.
  • 32 personagens que se revezam a cada ~90 s: Mario, Link, Sonic, Crono, Tarnished, Pac-Man, Kirby, Mega Man, Samus, Pikachu, Master Chief, Goku, Ryu, Black Mage, Sans, Scorpion, Sub-Zero, MissingNo., Navi, o cachorro do Duck Hunt, Creeper, Simon Belmont, Bomberman, Donkey Kong, Yoshi, Guile, Cloud, Kratos, Madeline, um Space Invader, Arthur e um tripulante de Among Us.
  • Falas em balão de RPG, com o texto surgindo letra a letra. São piadas, frases de motivação, comentários sobre o que está sendo feito (arquivo editado, tarefas, custo), hora do dia, dia da semana, datas especiais e o clima.
  • Especiais que quebram a quarta parede: o golpe sai da barra lateral e atravessa a tela inteira numa faixa acima do prompt. Alguns exemplos: o Gaster Blaster do Sans, o arpão do Scorpion (que volta puxando um pedaço da tela), o Creeper explodindo, a bomba em cruz do Bomberman, o gelo do Sub-Zero que congela e racha, o MissingNo. corrompendo a tela, a Navi dando uma dica de verdade e o Arthur perdendo a armadura.
  • Encontros: às vezes dois personagens dividem o palco. Mario e Sonic apostam corrida, Ryu duela com Guile, Scorpion com Sub-Zero, e o Pac-Man foge do fantasma até achar a pastilha de poder.
  • Kirby copia: o Kirby inspira o personagem anterior e solta a especial dele, de chapéu.
  • Especial carregado: quando uma tarefa passa mais de 5 minutos em andamento, ao terminar o especial sai maior, com brilho em volta.
  • Nível e conquistas: tarefa concluída dá XP (50 se foi carregada), chefão derrotado e pomodoro também. Sobe de nível, ganha conquistas, e o progresso fica em ~/.claude/pixel-sidebar.json.
  • Chefão: três erros seguidos no Bash e um chefão (Bug Gigante, Segfault...) aparece no painel. O próximo comando que passar derrota o bicho.
  • Contexto e custo: o personagem avisa ao passar de 60% e 85% do contexto e a cada US$ 10 gastos na sessão.
  • Notificações nativas: avisa fora do terminal quando um turno de mais de 1 min termina, quando o Claude pede autorização e quando o contexto passa de 85%.
  • Pomodoro: /pomodoro abre um cronômetro de foco no painel; no fim o personagem comemora e você ganha XP.
  • Reações: quando uma tarefa é concluída, quando o Claude pede autorização ou quando o turno termina, o personagem comenta e solta o especial.

Instalação

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).

Requisito: modo tela cheia

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.

Comandos

ComandoO que faz
/sidebarAbre a barra lateral, caso ela tenha sido fechada
/especialO personagem atual solta o especial agora
/especial gokuChama um personagem específico (o nome pode ser parcial)
/especial goku forteEspecial carregado
`/pomodoro [min\parar]`Cronômetro de foco (padrão 25 min)
/encontroChama um encontro (ou /encontro ryu, por exemplo)

Configuração

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).

Observações

  • O clima vem de wttr.in, pela localização do seu IP, no máximo uma vez por hora. Sem internet, o palco funciona normalmente, só que sem as falas sobre o clima.
  • As falas estão em português brasileiro.
  • Para desenvolver, rode claude --plugin-dir . a partir da pasta do repositório. Os testes rodam com claude plugin test ..

Aviso sobre fan art

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.

Licença

O código está sob licença MIT. A licença não se aplica aos personagens nem às marcas citadas acima.

Source 7 files
hooks/register.tsx 469 lines
1import { 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}
469
hooks/sprites.ts 454 lines
1// 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}
454
hooks/band.ts 305 lines
1// 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}
305
hooks/stage.ts 365 lines
1// 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}
365
hooks/talk.ts 309 lines
1import 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}
309
hooks/world.ts 572 lines
1// 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))
572
types/index.d.ts 22 lines
1export 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