diff --git a/packages/pokemon/src/__tests__/renderer.test.ts b/packages/pokemon/src/__tests__/renderer.test.ts new file mode 100644 index 000000000..06f540c22 --- /dev/null +++ b/packages/pokemon/src/__tests__/renderer.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' +import { renderAnimatedSprite } from '../sprites/renderer' + +describe('renderAnimatedSprite', () => { + test('flip preserves sprite width alignment across rows', () => { + const lines = [ + ' AB', + ' C', + ] + + const flipped = renderAnimatedSprite(lines, 0, 'flip') + + expect(flipped).toEqual([ + '\x1b[0mBA \x1b[0m', + '\x1b[0m C \x1b[0m', + ]) + }) +}) diff --git a/packages/pokemon/src/core/egg.ts b/packages/pokemon/src/core/egg.ts index 48ba8f28e..56570eecf 100644 --- a/packages/pokemon/src/core/egg.ts +++ b/packages/pokemon/src/core/egg.ts @@ -4,13 +4,16 @@ import { ALL_SPECIES_IDS } from '../types' import { SPECIES_DATA } from '../data/species' import { generateCreature } from './creature' +/** Days of consecutive coding needed to be eligible for an egg */ +export const EGG_REQUIRED_DAYS = 3 + /** * Check if the player is eligible to receive an egg. - * Conditions: consecutiveDays >= 7 AND totalTurns % 50 === 0 AND eggs.length < 1 + * Conditions: consecutiveDays >= EGG_REQUIRED_DAYS AND totalTurns % 50 === 0 AND eggs.length < 1 */ export function checkEggEligibility(buddyData: BuddyData): boolean { if (buddyData.eggs.length >= 1) return false - if (buddyData.stats.consecutiveDays < 7) return false + if (buddyData.stats.consecutiveDays < EGG_REQUIRED_DAYS) return false if (buddyData.stats.totalTurns % 50 !== 0) return false return true } diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts index d1c4f17c8..024eb82b0 100644 --- a/packages/pokemon/src/index.ts +++ b/packages/pokemon/src/index.ts @@ -31,12 +31,12 @@ export { determineGender, getGenderSymbol } from './core/gender' export { awardXP, getXpProgress } from './core/experience' export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort' export { checkEvolution, evolve, canEvolveFurther } from './core/evolution' -export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg } from './core/egg' +export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, hatchEgg, EGG_REQUIRED_DAYS } from './core/egg' export { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns } from './core/storage' export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache' // Sprites -export { renderAnimatedSprite, getIdleAnimMode } from './sprites/renderer' +export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer' export { getFallbackSprite } from './sprites/fallback' // UI Components diff --git a/packages/pokemon/src/sprites/index.ts b/packages/pokemon/src/sprites/index.ts index 209db884d..fd25b8377 100644 --- a/packages/pokemon/src/sprites/index.ts +++ b/packages/pokemon/src/sprites/index.ts @@ -1,4 +1,4 @@ -export { renderAnimatedSprite, getIdleAnimMode } from './renderer' +export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './renderer' export type { AnimMode } from '../types' export { getFallbackSprite } from './fallback' export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache' diff --git a/packages/pokemon/src/sprites/renderer.ts b/packages/pokemon/src/sprites/renderer.ts index 83eaf6059..4794e7a7a 100644 --- a/packages/pokemon/src/sprites/renderer.ts +++ b/packages/pokemon/src/sprites/renderer.ts @@ -1,108 +1,233 @@ import type { AnimMode } from '../types' -// ─── Idle Sequence ──────────────────────────────────── -// Natural-looking idle: mostly still with occasional breathe/blink/bounce/fidget +// ═══════════════════════════════════════════════════════ +// Pixel Grid Model — ANSI-safe animation foundation +// ═══════════════════════════════════════════════════════ +// +// Every sprite line is parsed into a Pixel[] row: +// Pixel = { char: '▄', style: '\x1b[33m' } +// +// style = full accumulated ANSI state at that position, +// so any transform (shift, reverse, slice) just moves Pixels +// around without ever touching raw ANSI strings. +// +// After transform, render each row back: reset → style → char → reset + +interface Pixel { + char: string + /** Full ANSI state needed to render this pixel */ + style: string +} + +const EMPTY_PIXEL: Pixel = { char: ' ', style: '' } +const EMPTY_ROW: Pixel[] = [] + +// ─── Parse / Render ─────────────────────────────────── + +/** Parse a raw ANSI string line into a Pixel row */ +function parseLine(line: string): Pixel[] { + const pixels: Pixel[] = [] + let style = '' + let i = 0 + while (i < line.length) { + if (line[i] === '\x1b') { + // Collect full ANSI escape sequence: \x1b[ ... m + const start = i + i++ // skip \x1b + if (i < line.length && line[i] === '[') { + i++ // skip [ + while (i < line.length && line[i] !== 'm') i++ + if (i < line.length) i++ // skip m + } + style += line.slice(start, i) + } else { + // Visible character (handle multi-byte Unicode) + const cp = line.codePointAt(i)! + const ch = String.fromCodePoint(cp) + pixels.push({ char: ch, style }) + i += ch.length + } + } + return pixels +} + +/** Render a Pixel row back to an ANSI string */ +function renderRow(pixels: Pixel[]): string { + if (pixels.length === 0) return '' + let out = '' + let lastStyle: string | null = null + for (const p of pixels) { + if (p.style !== lastStyle) { + out += '\x1b[0m' + p.style // reset then apply + lastStyle = p.style + } + out += p.char + } + out += '\x1b[0m' // final reset + return out +} + +function parseSprite(lines: string[]): Pixel[][] { + return lines.map(parseLine) +} + +function renderSprite(grid: Pixel[][]): string[] { + return grid.map(renderRow) +} + +// ─── Grid Transforms ────────────────────────────────── +// All transforms operate on Pixel[][], never touch raw strings. + +/** Horizontal shift — positive = right, negative = left */ +function shiftH(grid: Pixel[][], n: number): Pixel[][] { + if (n > 0) return grid.map(row => [...Array(n).fill(EMPTY_PIXEL), ...row]) + if (n < 0) return grid.map(row => row.slice(Math.abs(n))) + return grid +} + +/** Vertical shift up — removes rows from top, pads empty at bottom */ +function shiftUp(grid: Pixel[][], n: number): Pixel[][] { + if (n <= 0) return grid + const height = grid.length + const shifted = grid.slice(n) + while (shifted.length < height) shifted.push(EMPTY_ROW) + return shifted +} + +/** Mirror map — characters that change when flipped horizontally */ +const MIRROR: Record = { + '/': '\\', '\\': '/', + '(': ')', ')': '(', + '<': '>', '>': '<', + '{': '}', '}': '{', + '[': ']', ']': '[', + '╱': '╲', '╲': '╱', + '▌': '▐', '▐': '▌', + '▎': '▏', '▏': '▎', + '◀': '▶', '▶': '◀', + '◄': '►', '►': '◄', + '→': '←', '←': '→', + '↗': '↙', '↙': '↗', + '↘': '↖', '↖': '↘', + '`': "'", "'": '`', + ',': '´', '´': ',', +} + +/** + * Horizontal mirror — reverse each row. + * When mirrorChars=true, also swap directional characters (correct mirror). + * When mirrorChars=false, only reverse positions (more visible "flip" effect). + */ +function reverseH(grid: Pixel[][], mirrorChars = true): Pixel[][] { + const width = Math.max(0, ...grid.map(row => row.length)) + return grid.map(row => + [...row, ...Array(width - row.length).fill(EMPTY_PIXEL)] + .reverse() + .map(p => ({ + ...p, + char: mirrorChars ? (MIRROR[p.char] ?? p.char) : p.char, + })), + ) +} + +/** Replace eye-like characters with dash */ +function blinkEyes(grid: Pixel[][]): Pixel[][] { + return grid.map(row => + row.map(p => + /[·✦×◉@°oO]/.test(p.char) ? { ...p, char: '—' } : p, + ), + ) +} + +// ═══════════════════════════════════════════════════════ +// Idle Sequence +// ═══════════════════════════════════════════════════════ + const IDLE_SEQUENCE: AnimMode[] = [ - 'idle', 'idle', 'idle', - 'breathe', 'idle', 'idle', + 'breathe', 'breathe', + 'idle', 'blink', - 'idle', 'idle', 'idle', + 'idle', 'bounce', - 'idle', 'idle', 'idle', - 'fidget', + 'idle', + 'fidget', 'fidget', + 'idle', + 'breathe', 'breathe', + 'idle', + 'flip', 'flip', 'flip', 'idle', 'idle', + 'bounce', + 'idle', + 'blink', + 'idle', + 'excited', 'excited', + 'idle', ] export function getIdleAnimMode(tick: number): AnimMode { return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length] } -// ─── Main Render ────────────────────────────────────── +// ═══════════════════════════════════════════════════════ +// Public API +// ═══════════════════════════════════════════════════════ +/** + * Apply animation transform to sprite lines. + * Internally: parse ANSI → Pixel grid → transform → render back. + */ export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] { + const grid = parseSprite(lines) + + let result: Pixel[][] = grid + switch (mode) { case 'idle': - return lines + break case 'breathe': - return breathe(lines, tick) + // Right sway → center + result = shiftH(result, tick % 4 < 2 ? 3 : 0) + break case 'blink': - return blinkEyes(lines) + result = blinkEyes(result) + break case 'fidget': - return shiftLines(lines, tick % 2 === 0 ? 0 : 1) - case 'bounce': - return bounce(lines, tick) + // Big right sway → center + result = shiftH(result, tick % 2 === 0 ? 4 : 0) + break + case 'bounce': { + const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0] + const h = PATTERN[tick % PATTERN.length] + result = shiftUp(result, h) + break + } case 'walkLeft': - return walkLeft(lines, tick) + // Step right → center (mimics bounce-back from left step) + result = shiftH(result, tick % 4 === 0 ? 0 : 3) + break case 'walkRight': - return walkRight(lines, tick) + // Step right → further right → center + result = shiftH(result, (tick % 4) * 2) + break case 'flip': - return flipHorizontal(lines) + // Pure position reversal — do NOT mirror chars so / \ ( ) + // visibly swap, making the flip obvious. + result = reverseH(result, false) + break case 'excited': - return shiftLines(lines, tick % 2 === 0 ? -1 : 1) + // Jitter right ↔ further right (never crop) + result = shiftH(result, tick % 2 === 0 ? 1 : 4) + break case 'pet': - return addPetParticles(lines, tick) - default: - return lines + break // overlay handled by SpriteAnimator } + + return renderSprite(result) } -// ─── Animation Transforms ───────────────────────────── +// ─── Heart overlay (kept for SpriteAnimator convenience) ── -/** Subtle horizontal oscillation — shift right 1px on even ticks */ -function breathe(lines: string[], tick: number): string[] { - return tick % 4 < 2 ? shiftLines(lines, 1) : lines -} - -/** Parabolic bounce — sprite hops up and back down */ -function bounce(lines: string[], tick: number): string[] { - const PATTERN = [0, 1, 2, 2, 1, 0, 0, 0] - const h = PATTERN[tick % PATTERN.length] - if (h === 0 || lines.length === 0) return lines - return [ - ...Array(h).fill(''), - ...lines.slice(0, lines.length - h), - ] -} - -/** Walk left — shift left a few steps then reset */ -function walkLeft(lines: string[], tick: number): string[] { - const phase = tick % 8 - if (phase >= 4) return lines - return shiftLines(lines, -(phase + 1)) -} - -/** Walk right — shift right a few steps then reset */ -function walkRight(lines: string[], tick: number): string[] { - const phase = tick % 8 - if (phase >= 4) return lines - return shiftLines(lines, phase + 1) -} - -/** Flip sprite horizontally — reverse each line's characters */ -function flipHorizontal(lines: string[]): string[] { - return lines.map(reverseLine) -} - -// ─── Helpers ────────────────────────────────────────── - -/** Shift all lines left (negative) or right (positive) by offset columns */ -function shiftLines(lines: string[], offset: number): string[] { - if (offset === 0) return lines - if (offset > 0) { - const pad = ' '.repeat(offset) - return lines.map(line => pad + line) - } - const abs = Math.abs(offset) - return lines.map(line => line.slice(abs)) -} - -/** Replace eye characters with blink indicator */ -function blinkEyes(lines: string[]): string[] { - return lines.map(line => line.replace(/[·✦×◉@°oO]/g, '—')) -} - -/** Heart particle frames for pet animation */ const PET_HEARTS = [ [' ♥ ', ' '], [' ♥ ♥ ', ' ♥ '], @@ -111,20 +236,6 @@ const PET_HEARTS = [ [' ♥ ', ' ♥ ♥ '], ] -/** Add heart particle frames above the sprite */ -function addPetParticles(lines: string[], tick: number): string[] { - const hearts = PET_HEARTS[tick % PET_HEARTS.length] - return [...hearts, ...lines] -} - -/** - * Reverse a line's visible characters while preserving leading ANSI codes. - * Handles simple cases: ANSI at start + visible text. - */ -function reverseLine(line: string): string { - // eslint-disable-next-line no-control-regex - const ansiMatch = line.match(/^(\x1b\[[0-9;]*m)+/) - const stripped = line.replace(/\x1b\[[0-9;]*m/g, '') - const reversed = stripped.split('').reverse().join('') - return ansiMatch ? ansiMatch[0] + reversed : reversed +export function getPetOverlay(tick: number): string[] { + return PET_HEARTS[tick % PET_HEARTS.length] } diff --git a/packages/pokemon/src/ui/SpriteAnimator.tsx b/packages/pokemon/src/ui/SpriteAnimator.tsx index cb7203c79..8f82bd844 100644 --- a/packages/pokemon/src/ui/SpriteAnimator.tsx +++ b/packages/pokemon/src/ui/SpriteAnimator.tsx @@ -1,48 +1,40 @@ import React, { useEffect, useState } from 'react' -import { Box, Text, type Color, stringWidth } from '@anthropic/ink' +import { Box, Text, type Color } from '@anthropic/ink' import type { AnimMode } from '../types' -import { renderAnimatedSprite, getIdleAnimMode } from '../sprites/renderer' +import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer' + +/** Vertical padding — bounce shifts within this space */ +const V_PAD = 4 interface SpriteAnimatorProps { - /** Base sprite lines (before animation transforms) */ + /** Base sprite lines (ANSI is preserved) */ lines: string[] /** Text color for the sprite */ color?: Color - /** Tick interval in milliseconds (default 500) */ + /** Tick interval in ms (default 250) */ tickMs?: number - /** Single animation mode. Omit for idle sequence auto-play */ + /** Single mode; omit for idle auto-play */ mode?: AnimMode - /** Whether to center the sprite horizontally (default true) */ + /** Center horizontally (default true) */ centered?: boolean - /** Extra content to render above the sprite (e.g. hearts) */ - overlay?: string[] | null + /** Show pet hearts overlay */ + petting?: boolean } /** * Animated sprite renderer with built-in tick loop. * - * Renders base sprite lines with animation transforms applied per-tick. - * Uses the idle sequence by default; pass `mode` to force a single animation. - * - * @example - * ```tsx - * // Idle animation, auto-centered - * - * - * // Forced excited mode - * - * - * // With heart overlay - * - * ``` + * - Keeps ANSI intact (parse → pixel grid → transform → render) + * - Pads vertically so bounce never shifts layout + * - Grid transforms guarantee fixed output height */ export function SpriteAnimator({ lines, color, - tickMs = 500, + tickMs = 100, mode, centered = true, - overlay, + petting, }: SpriteAnimatorProps) { const [tick, setTick] = useState(0) @@ -51,14 +43,21 @@ export function SpriteAnimator({ return () => clearInterval(timer) }, [tickMs]) + // Add vertical padding — bounce shifts within this space + const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')] + + // Apply animation (renderer parses to pixels, transforms, renders back) const currentMode = mode ?? getIdleAnimMode(tick) - const animated = renderAnimatedSprite(lines, tick, currentMode) + const animated = renderAnimatedSprite(padded, tick, currentMode) + + // Pet hearts overlay + const overlay = petting ? getPetOverlay(tick) : null const displayLines = overlay ? [...overlay, ...animated] : animated const spriteBlock = ( {displayLines.map((line, i) => ( - {line} + {line || ' '} ))} ) diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx index f760aeabc..07426c623 100644 --- a/src/commands/buddy/BuddyPanel.tsx +++ b/src/commands/buddy/BuddyPanel.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Box, Text, Pane, Tab, Tabs, type Color } from '@anthropic/ink'; import { useKeybinding } from '../../keybindings/useKeybinding.js'; import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Select } from '../../components/CustomSelect/select.js'; import { STAT_NAMES, STAT_LABELS, @@ -12,13 +13,13 @@ import { type SpeciesId, } from '@claude-code-best/pokemon'; import { SPECIES_DATA } from '@claude-code-best/pokemon'; -import { SPECIES_PERSONALITY } from '@claude-code-best/pokemon'; + import { getNextEvolution } from '@claude-code-best/pokemon'; -import { calculateStats, getCreatureName, getTotalEV, getActiveCreature } from '@claude-code-best/pokemon'; +import { calculateStats, getCreatureName, getTotalEV, getActiveCreature, saveBuddyData, EGG_REQUIRED_DAYS } from '@claude-code-best/pokemon'; import { getXpProgress } from '@claude-code-best/pokemon'; -import { getEVSummary } from '@claude-code-best/pokemon'; + import { getGenderSymbol } from '@claude-code-best/pokemon'; -import { StatBar, SpriteAnimator, getFallbackSprite } from '@claude-code-best/pokemon'; +import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } from '@claude-code-best/pokemon'; import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; const CYAN: Color = 'ansi:cyan'; @@ -52,6 +53,7 @@ interface BuddyPanelProps { */ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) { const [selectedTab, setSelectedTab] = useState('Buddy'); + const [data, setData] = useState(buddyData); useExitOnCtrlCDWithKeybindings(); @@ -64,21 +66,32 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) isActive: true, }); - const creature = getActiveCreature(buddyData); + const creature = getActiveCreature(data); + + const handleSwitchCreature = (creatureId: string) => { + const updated = { ...data, activeCreatureId: creatureId }; + setData(updated); + saveBuddyData(updated); + }; const tabs = [ {creature ? ( - + ) : ( No buddy yet. Keep coding! )} , - + onClose('buddy panel closed')} + /> , - + , ]; @@ -107,7 +120,6 @@ function BuddyTab({ const xp = getXpProgress(creature); const genderSymbol = getGenderSymbol(creature.gender); const name = getCreatureName(creature); - const evSummary = getEVSummary(creature); const totalEV = getTotalEV(creature); const nextEvo = getNextEvolution(creature.speciesId); @@ -131,16 +143,14 @@ function BuddyTab({ ) : null; return ( - - - - - {name} - - #{String(species.dexNumber).padStart(3, '0')} - {shinyBadge} - - Lv.{creature.level} + + + + {name} + + #{String(species.dexNumber).padStart(3, '0')} + {shinyBadge} + Lv.{creature.level} @@ -160,17 +170,45 @@ function BuddyTab({ )} - - - "{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}" - - + {species.flavorText && ( + + + "{species.flavorText}" + + + )} - ─── Stats ─── - {STAT_NAMES.map(stat => ( - - ))} + + + ─── Stats ─── + + + ─── Base ─── + + + {STAT_NAMES.map(stat => { + const baseVal = species.baseStats[stat]; + const baseFilled = Math.round((baseVal / 130) * 12); + const ev = creature.ev[stat]; + const evText = ev > 0 ? ({ev}) : null; + return ( + + + + {evText} + + + {STAT_LABELS[stat].padEnd(3)} + + {'█'.repeat(baseFilled)} + {'░'.repeat(12 - baseFilled)} + + {String(baseVal).padStart(3)} + + + ); + })} @@ -185,11 +223,10 @@ function BuddyTab({ - + EV - = 510 ? GREEN : GRAY}>{evSummary} - ({totalEV}/510) + = 510 ? GREEN : GRAY}>{totalEV}/510 @@ -213,104 +250,234 @@ function BuddyTab({ // ─── Dex Tab ────────────────────────────────────────── -function DexTab({ buddyData }: { buddyData: BuddyData }) { +function DexTab({ + buddyData, + isActive, + onSwitchCreature, + onClose, +}: { + buddyData: BuddyData; + isActive: boolean; + onSwitchCreature: (creatureId: string) => void; + onClose: () => void; +}) { const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d])); const collected = buddyData.dex.length; const total = ALL_SPECIES_IDS.length; - const chains = groupByChain(); + const flatSpecies = groupByChain().flat(); + + const [focusedId, setFocusedId] = useState(flatSpecies[0]); + + // Build options for the Select component + const options = flatSpecies.map(speciesId => { + const species = SPECIES_DATA[speciesId]; + const entry = dexMap.get(speciesId); + const discovered = !!entry; + const isActiveCreature = buddyData.activeCreatureId + ? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId) + : false; + + return { + label: ( + + #{String(species.dexNumber).padStart(3, '0')} + + {discovered ? (species.names.zh ?? species.name) : '???'} + + {isActiveCreature && } + + ), + value: speciesId, + disabled: false, + }; + }); + + // Right panel data + const focusedSpecies = SPECIES_DATA[focusedId]; + const focusedEntry = dexMap.get(focusedId); + const focusedDiscovered = !!focusedEntry; + const focusedOwned = buddyData.creatures.find(c => c.speciesId === focusedId); + const focusedIsActive = buddyData.activeCreatureId + ? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === focusedId) + : false; + + const spriteLines = focusedDiscovered + ? (loadSprite(focusedId)?.lines ?? getFallbackSprite(focusedId)) + : null; + + const maxBase = 130; return ( + {/* Header */} - - Pokédex - + Pokédex - - {collected} - + {collected} /{total} + + {'█'.repeat(collected)} + {'░'.repeat(total - collected)} + {Math.floor((collected / total) * 100)}% - - {'█'.repeat(collected)} - {'░'.repeat(total - collected)} - {Math.floor((collected / total) * 100)}% - + {/* Two-column: Select list | detail */} + + {/* ── Left: Select list ── */} + +