diff --git a/skills/call/SKILL.md b/skills/call/SKILL.md index 580cf02e..63a00f60 100644 --- a/skills/call/SKILL.md +++ b/skills/call/SKILL.md @@ -27,12 +27,18 @@ The command outputs JSON: `{"ev": N, "call_count": N, "evGained": true/false}` If `evGained` is true, append a subtle note after the reaction: > *(사이가 조금 가까워진 것 같다... EV +1)* -## Step 3: Show sprite +## Step 3: Show animated sprite with emotion bubble + +Run with the Pokémon's ID and the EV value returned from Step 2: ```bash -"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh" "${CLAUDE_PLUGIN_ROOT}/src/cli/tokenmon.ts" pokedex <포켓몬_ID> +"${CLAUDE_PLUGIN_ROOT}/bin/tsx-resolve.sh" "${CLAUDE_PLUGIN_ROOT}/src/cli/tokenmon.ts" sprite <포켓몬_ID> ``` +This renders the Pokémon's braille sprite with: +- A speech bubble showing its emotional state (? / ... / :) / <3 / <3!) based on EV +- A brief bounce animation (sprite hops up and down when called) + ## Step 4: React based on EV Format: **[닉네임 또는 이름]**[은/는] [반응] diff --git a/src/cli/tokenmon.ts b/src/cli/tokenmon.ts index 8e6b2eda..95cd45cd 100644 --- a/src/cli/tokenmon.ts +++ b/src/cli/tokenmon.ts @@ -18,12 +18,14 @@ import { getEventsDB, getRegionsDB, getPokedexRewardsDB } from '../core/pokemon- import { getTypeMasterProgress } from '../core/pokedex-rewards.js'; import { t, initLocale, getLocale } from '../i18n/index.js'; import { withLock, withLockRetry } from '../core/lock.js'; -import { getActiveGeneration, setActiveGenerationCache, clearActiveGenerationCache, PLUGIN_ROOT, GLOBAL_CONFIG_PATH, DATA_DIR } from '../core/paths.js'; +import { getActiveGeneration, setActiveGenerationCache, clearActiveGenerationCache, PLUGIN_ROOT, GLOBAL_CONFIG_PATH, DATA_DIR, SPRITES_BRAILLE_DIR, SPRITES_TERMINAL_DIR } from '../core/paths.js'; import { execSync } from 'node:child_process'; import { detectRenderer } from '../core/detect-renderer.js'; -import { isShinyKey, toBaseId, toShinyKey } from '../core/shiny-utils.js'; +import { isShinyKey, toBaseId } from '../core/shiny-utils.js'; +import { shiftAnsiHue } from '../sprites/shiny.js'; import { readWeatherCache, WEATHER_LABELS, refreshWeatherIfStale, type WeatherCondition } from '../core/weather.js'; import type { ExpGroup, EvolutionContext } from '../core/types.js'; +import { getEmotionInner } from '../core/emotion.js'; // ANSI color helpers const BOLD = '\x1b[1m'; @@ -222,15 +224,7 @@ function cmdStarter(choiceArg?: string): void { if (!freshState.pokemon[chosen]) { const starterLevel = 5; const expGroup: ExpGroup = pData?.exp_group ?? 'medium_fast'; - freshState.pokemon[chosen] = { - id: pData?.id ?? 0, - xp: levelToXp(starterLevel, expGroup), - level: starterLevel, - friendship: 0, - ev: 0, - met: 'starter', - met_detail: { region: freshConfig.current_region, met_level: starterLevel, met_date: new Date().toISOString().split('T')[0] }, - }; + freshState.pokemon[chosen] = { id: pData?.id ?? 0, xp: levelToXp(starterLevel, expGroup), level: starterLevel, friendship: 0, ev: 0 }; } if (!freshState.unlocked.includes(chosen)) { freshState.unlocked.push(chosen); @@ -570,16 +564,6 @@ function cmdPokedex(): void { if (state.pokemon[pokemonName]) { const ps = state.pokemon[pokemonName]; console.log(` ${t('cli.pokedex.detail_current_level', { level: ps.level, xp: formatNumber(ps.xp) })}`); - if (ps.met) { - const metText = formatMetInfo(ps.met, ps.met_detail); - if (metText) { - console.log(''); - console.log(` ${t('cli.pokedex.trainer_memo')}`); - for (const line of metText.split('\n')) { - console.log(` ${line}`); - } - } - } } return; } @@ -754,6 +738,7 @@ function cmdCall(nameOrId: string): void { p.call_count = 0; evGained = p.ev > prevEv; } + s.last_called = { pokemon: id, ev: p.ev ?? 0, ts: Date.now() }; writeState(s); return { ev: p.ev, call_count: p.call_count, evGained }; }); @@ -762,6 +747,97 @@ function cmdCall(nameOrId: string): void { console.log(JSON.stringify(result.value)); } +// ── call-sprite: animated sprite with emotion bubble ────────────────────────── + +const SPRITE_H = 10; +const SPRITE_W = 20; + +function loadSpriteForCall(pokemonId: number, isShiny: boolean = false): string[] { + const brailleFile = join(SPRITES_BRAILLE_DIR, `${pokemonId}.txt`); + const termFile = join(SPRITES_TERMINAL_DIR, `${pokemonId}.txt`); + const file = existsSync(brailleFile) ? brailleFile : existsSync(termFile) ? termFile : null; + if (!file) return Array(SPRITE_H).fill(''); + const raw = readFileSync(file, 'utf-8').split('\n'); + if (raw.length > 0 && raw[raw.length - 1] === '') raw.pop(); + const lines = raw.map((l: string) => l.replace(/ /g, '\u2800')); + const result = isShiny ? lines.map(l => shiftAnsiHue(l)) : lines; + while (result.length < SPRITE_H) result.push(''); + return result.slice(0, SPRITE_H); +} + +function emotionBubble(ev: number): string[] { + // Each bubble line is 8 visible cols: ╭──────╮ + // Middle: │ + space + 5-char content + │ = 8 + const inner = getEmotionInner(ev); + return [ + `╭──────╮`, + `│ ${inner}│`, + `╰───╮──╯`, + ` │ `, + ]; +} + +function spriteFrameLines(lines: string[], bubble: string[], offset: number): string[] { + const result: string[] = []; + for (let i = 0; i < SPRITE_H; i++) { + const src = i + offset; + const spriteLine = (src >= 0 && src < lines.length) ? lines[src] : ''; + const vLen = spriteLine.replace(/\x1b\[[^m]*m/g, '').length; + const padded = spriteLine + (vLen < SPRITE_W ? '\u2800'.repeat(SPRITE_W - vLen) : ''); + const bubbleLine = (src >= 0 && src < bubble.length) ? bubble[src] : ''; + // CLI output converts \u2800 → ASCII space (unlike status-line.ts which keeps \u2800 + // for CJK terminal width consistency). Here we control the column via fixed SPRITE_W, + // so ASCII spaces are fine and avoid non-printable characters in terminal output. + result.push(padded.replace(/\u2800/g, ' ') + (bubbleLine ? ' ' + bubbleLine : '')); + } + return result; +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function printFrame(frameLines: string[]): void { + for (const line of frameLines) process.stdout.write(line + '\n'); +} + +function eraseFrame(n: number): void { + for (let i = 0; i < n; i++) process.stdout.write('\x1b[1A\x1b[2K'); +} + +async function cmdSprite(nameOrId: string, ev: number): Promise { + const state = readState(); + const pokemonDB = getPokemonDB(); + const resolvedId = resolveNameToId(nameOrId, state) ?? nameOrId; + const baseId = toBaseId(resolvedId); + const pData = pokemonDB.pokemon[baseId]; + if (!pData) return; + + const spriteLines = loadSpriteForCall(pData.id, isShinyKey(resolvedId)); + const bubble = emotionBubble(ev); + + const normalFrame = spriteFrameLines(spriteLines, bubble, 0); + + // Animate only on real TTY — not when captured by Claude Code or piped + if (process.stdout.isTTY) { + const bouncedFrame = spriteFrameLines(spriteLines, bubble, 1); + printFrame(normalFrame); + sleepSync(100); + eraseFrame(SPRITE_H); + printFrame(bouncedFrame); + sleepSync(140); + eraseFrame(SPRITE_H); + printFrame(normalFrame); + sleepSync(90); + eraseFrame(SPRITE_H); + printFrame(bouncedFrame); + sleepSync(120); + eraseFrame(SPRITE_H); + } + + printFrame(normalFrame); +} + function cmdNickname(nameOrId: string, nickname?: string): void { // Resolve ID and mutate inside the same lock to prevent nickname race const result = withLockRetry(() => { @@ -845,6 +921,7 @@ function doReset(): void { legendary_pool: [], legendary_pending: [], titles: [], completed_chains: [], star_dismissed: false, + last_called: null, }; writeState(defaultState); }); @@ -909,13 +986,7 @@ function cmdCheat(subcmd: string, arg1?: string, arg2?: string): void { case 'unlock': { const pData = pokemonDB.pokemon[arg1!]; if (!state.unlocked.includes(arg1!)) state.unlocked.push(arg1!); - if (!state.pokemon[arg1!]) { - state.pokemon[arg1!] = { - id: pData.id, xp: 0, level: 1, friendship: 0, ev: 0, - met: 'unknown', - met_detail: { met_level: 1, met_date: new Date().toISOString().split('T')[0] }, - }; - } + if (!state.pokemon[arg1!]) state.pokemon[arg1!] = { id: pData.id, xp: 0, level: 1, friendship: 0, ev: 0 }; if (!state.pokedex[arg1!]) state.pokedex[arg1!] = { seen: true, caught: true, first_seen: new Date().toISOString().split('T')[0] }; else { state.pokedex[arg1!].seen = true; state.pokedex[arg1!].caught = true; } logCheat(`unlock ${arg1}`); @@ -998,11 +1069,7 @@ function cmdEvolve(pokemonArg?: string, targetArg?: string): void { items: state.items ?? {}, }; const branches = getEligibleBranches(pokemonArg, ctx); - // UX-only: hide branches whose evolved form is already in unlocked (safety guards are in checkEvolution/applyBranchEvolution) - const eligible = branches.filter(b => { - const evolvedKey = isShinyKey(pokemonArg) ? toShinyKey(b.name) : b.name; - return b.conditionMet && !state.unlocked.includes(evolvedKey); - }); + const eligible = branches.filter(b => b.conditionMet); if (eligible.length === 0) { warn(t('cli.evolve.no_eligible', { pokemon: getPokemonName(pokemonArg) })); @@ -1331,11 +1398,7 @@ function cmdLegendary(action?: string): void { const pokemonDB = getPokemonDB(); const pData = pokemonDB.pokemon[chosen]; if (pData && !s.pokemon[chosen]) { - s.pokemon[chosen] = { - id: pData.id, xp: 0, level: 50, friendship: 0, ev: 0, - met: 'fateful_encounter', - met_detail: { met_level: 50, met_date: new Date().toISOString().split('T')[0], from: pending.group }, - }; + s.pokemon[chosen] = { id: pData.id, xp: 0, level: 50, friendship: 0, ev: 0 }; } if (!s.pokedex[chosen]) { s.pokedex[chosen] = { seen: true, caught: true, first_seen: new Date().toISOString().split('T')[0] }; @@ -1720,8 +1783,6 @@ function cmdSetup(args: string[]): void { level: starterLevel, friendship: 0, ev: 0, - met: 'starter', - met_detail: { region: freshConfig.current_region, met_level: starterLevel, met_date: new Date().toISOString().split('T')[0] }, }; } if (!freshState.unlocked.includes(starterKey)) { @@ -1914,7 +1975,6 @@ function cmdHelp(): void { console.log(t('cli.help.cmd_uninstall')); console.log(t('cli.help.cmd_uninstall_keep')); console.log(t('cli.help.cmd_reset')); - console.log(t('cli.help.cmd_friendly_battle')); console.log(t('cli.help.cmd_cheat')); console.log(t('cli.help.cmd_help')); console.log(''); @@ -2023,17 +2083,15 @@ switch (command) { case 'call': cmdCall(args[1] ?? ''); break; + case 'sprite': + await cmdSprite(args[1] ?? '', Math.max(0, parseInt(args[2] ?? '0', 10) || 0)); + break; case 'nickname': cmdNickname(args[1] ?? '', args.slice(2).join(' ') || undefined); break; case 'reset': cmdReset(args.includes('--confirm')); break; - case 'friendly-battle': { - const { runFriendlyBattleCli } = await import('./friendly-battle.js'); - await runFriendlyBattleCli(args.slice(1)); - break; - } case 'cheat': cmdCheat(args[1], args[2], args[3]); break; diff --git a/src/core/emotion.ts b/src/core/emotion.ts new file mode 100644 index 00000000..7c6074d8 --- /dev/null +++ b/src/core/emotion.ts @@ -0,0 +1,11 @@ +/** + * EV-based emotion string for call speech bubbles. + * Shared between status-line.ts (hook) and tokenmon.ts (CLI sprite command). + */ +export function getEmotionInner(ev: number): string { + if (ev <= 0) return ' ? '; + if (ev <= 50) return '... '; + if (ev <= 120) return ':) '; + if (ev <= 200) return '<3 '; + return '<3! '; +} diff --git a/src/core/evolution.ts b/src/core/evolution.ts index 9cef0e33..ee38a98f 100644 --- a/src/core/evolution.ts +++ b/src/core/evolution.ts @@ -226,8 +226,9 @@ function checkCondition(condition: string, context: EvolutionContext): boolean { return context.newLevel > context.oldLevel; } if (condition === 'special') { - // Generic special: trigger on level up as fallback - return context.newLevel > context.oldLevel; + // Special evolutions (e.g. walk-based) require high friendship instead of + // triggering on any level-up, which caused 3-stage chains to skip stage 1. + return context.friendship >= 200; } return false; } diff --git a/src/core/types.ts b/src/core/types.ts index 601acf24..d044bf50 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -257,6 +257,7 @@ export interface State { gym_badges?: string[]; rare_weight_multiplier?: number; last_codex_xp?: number | null; + last_called?: { pokemon: string; ev: number; ts: number } | null; } export interface Config { diff --git a/src/status-line.ts b/src/status-line.ts index 97916ccf..4ad9722d 100644 --- a/src/status-line.ts +++ b/src/status-line.ts @@ -14,6 +14,7 @@ import { readWeatherCache, WEATHER_LABELS, type WeatherCondition } from './core/ import { ppBar } from './core/pp.js'; import type { ExpGroup, StdinData } from './core/types.js'; import { determineTier, SPRITE_WIDTH, SPRITE_COL_WIDTH } from './core/layout.js'; +import { getEmotionInner } from './core/emotion.js'; interface SignatureMove { move: string; @@ -517,6 +518,16 @@ function main(): void { }); } + // === Call bubble: appears next to sprite for 15s after tkm call === + const lastCalled = state.last_called; + const CALL_BUBBLE_TTL = 15_000; + const callBubbleActive = !!(lastCalled && Date.now() - lastCalled.ts < CALL_BUBBLE_TTL); + const callBubbleLines: string[] = []; + if (callBubbleActive) { + const inner = getEmotionInner(lastCalled!.ev); + callBubbleLines.push('╭──────╮', `│ ${inner}│`, '╰───╮──╯', ' │ '); + } + // === Sprite rendering (responsive 4-tier layout) === // Use printWidth for tier selection so sprites fit in the same budget as text const tier = determineTier(printWidth, pokeData.length, spriteMode); @@ -562,24 +573,41 @@ function main(): void { if (!isBlankLine(s[r])) { firstRow = Math.min(firstRow, r); lastRow = Math.max(lastRow, r); } } } + // Collect padded bubble lines for this group + let paddedBubbleLines: string[] = []; + let calledGroupIdx = -1; + if (callBubbleActive) { + calledGroupIdx = Array.from({ length: group.length }, (_, i) => gi + i) + .findIndex(pi => pokeData[pi]?.speciesId === lastCalled?.pokemon); + if (calledGroupIdx >= 0) { + const BUBBLE_WIDTH = 8; + const bubbleLeftPad = calledGroupIdx * SPRITE_COL_WIDTH + Math.floor((SPRITE_WIDTH - BUBBLE_WIDTH) / 2); + paddedBubbleLines = callBubbleLines.map(bl => ' '.repeat(bubbleLeftPad) + bl); + } + } + + // Collect sprite rows + // Keep \u2800 (braille blank) in output instead of converting to ASCII space. + // In some CJK terminals, non-zero braille (sprite art) and \u2800 are both + // rendered at the same width while ASCII space is narrower — mixing them + // causes per-row width variance proportional to sprite opacity, which is + // invisible without weather but blatant once particles land on random cells. + // Keeping every transparent position as \u2800 guarantees uniform row width + // regardless of the terminal's actual braille glyph width. + const renderedSpriteRows: string[] = []; for (let row = firstRow; row <= lastRow; row++) { let rowStr = group.map(s => { const line = s[row] ?? ''; const visibleLen = line.replace(/\x1b\[[^m]*m/g, '').length; return visibleLen < SPRITE_WIDTH ? line + '\u2800'.repeat(SPRITE_WIDTH - visibleLen) : line; }).join('\u2800'); - if (weatherCondition) { - rowStr = scatterWeatherParticles(rowStr, weatherCondition); - } - // Keep \u2800 (braille blank) in output instead of converting to ASCII space. - // In some CJK terminals, non-zero braille (sprite art) and \u2800 are both - // rendered at the same width while ASCII space is narrower — mixing them - // causes per-row width variance proportional to sprite opacity, which is - // invisible without weather but blatant once particles land on random cells. - // Keeping every transparent position as \u2800 guarantees uniform row width - // regardless of the terminal's actual braille glyph width. - console.log(rowStr); + if (weatherCondition) rowStr = scatterWeatherParticles(rowStr, weatherCondition); + renderedSpriteRows.push(rowStr); } + + // Print bubble + sprite + for (const bl of paddedBubbleLines) console.log(bl); + for (const sl of renderedSpriteRows) console.log(sl); } }