From 722aa6c97a501812a02144ef77b650d464369705 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Tue, 21 Apr 2026 19:23:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E7=B2=BE=E7=81=B5?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E7=B3=BB=E7=BB=9F=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20SpriteAnimator=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增动画模式: breathe, bounce, walkLeft, walkRight, flip - 新增 SpriteAnimator 组件,内置 tick 循环和居中渲染 - BuddyPanel 使用 SpriteAnimator 替代手动渲染 Co-Authored-By: Claude Opus 4.6 --- packages/pokemon/src/index.ts | 1 + packages/pokemon/src/sprites/renderer.ts | 168 ++++++++++++++------- packages/pokemon/src/types.ts | 12 +- packages/pokemon/src/ui/SpriteAnimator.tsx | 73 +++++++++ src/commands/buddy/BuddyPanel.tsx | 12 +- 5 files changed, 203 insertions(+), 63 deletions(-) create mode 100644 packages/pokemon/src/ui/SpriteAnimator.tsx diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts index 94eedab8a..d1c4f17c8 100644 --- a/packages/pokemon/src/index.ts +++ b/packages/pokemon/src/index.ts @@ -46,3 +46,4 @@ export { EggView } from './ui/EggView' export { EvolutionAnim } from './ui/EvolutionAnim' export { StatBar } from './ui/StatBar' export { SpeciesDetail } from './ui/SpeciesDetail' +export { SpriteAnimator } from './ui/SpriteAnimator' diff --git a/packages/pokemon/src/sprites/renderer.ts b/packages/pokemon/src/sprites/renderer.ts index 1ebdee02c..83eaf6059 100644 --- a/packages/pokemon/src/sprites/renderer.ts +++ b/packages/pokemon/src/sprites/renderer.ts @@ -1,5 +1,107 @@ import type { AnimMode } from '../types' +// ─── Idle Sequence ──────────────────────────────────── +// Natural-looking idle: mostly still with occasional breathe/blink/bounce/fidget +const IDLE_SEQUENCE: AnimMode[] = [ + 'idle', 'idle', 'idle', + 'breathe', + 'idle', 'idle', + 'blink', + 'idle', 'idle', 'idle', + 'bounce', + 'idle', 'idle', 'idle', + 'fidget', + 'idle', 'idle', +] + +export function getIdleAnimMode(tick: number): AnimMode { + return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length] +} + +// ─── Main Render ────────────────────────────────────── + +export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] { + switch (mode) { + case 'idle': + return lines + case 'breathe': + return breathe(lines, tick) + case 'blink': + return blinkEyes(lines) + case 'fidget': + return shiftLines(lines, tick % 2 === 0 ? 0 : 1) + case 'bounce': + return bounce(lines, tick) + case 'walkLeft': + return walkLeft(lines, tick) + case 'walkRight': + return walkRight(lines, tick) + case 'flip': + return flipHorizontal(lines) + case 'excited': + return shiftLines(lines, tick % 2 === 0 ? -1 : 1) + case 'pet': + return addPetParticles(lines, tick) + default: + return lines + } +} + +// ─── Animation Transforms ───────────────────────────── + +/** 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 = [ [' ♥ ', ' '], @@ -9,68 +111,20 @@ const PET_HEARTS = [ [' ♥ ', ' ♥ ♥ '], ] -/** - * Render animated sprite by applying mode-specific transformations. - * All species share the same animation logic - only the base sprite differs. - */ -export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] { - switch (mode) { - case 'idle': - return lines - case 'fidget': - return shiftLines(lines, tick % 2 === 0 ? 0 : 1) - case 'blink': - return blinkEyes(lines) - case 'excited': - return shiftLines(lines, tick % 2 === 0 ? -1 : 1) - case 'pet': - return addPetParticles(lines, tick) - default: - return lines - } -} - -/** - * Shift all lines left or right by offset columns. - */ -function shiftLines(lines: string[], offset: number): string[] { - if (offset === 0) return lines - if (offset > 0) { - return lines.map((line) => ' '.repeat(offset) + line) - } - // Shift left: remove leading characters - const absOffset = Math.abs(offset) - return lines.map((line) => line.slice(absOffset)) -} - -/** - * Replace eye characters with blink indicator. - */ -function blinkEyes(lines: string[]): string[] { - // Eye characters that should be replaced for blink - return lines.map((line) => - line.replace(/[·✦×◉@°oO]/g, '—'), - ) -} - -/** - * Add heart particle frames above the sprite for pet animation. - */ +/** 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] } /** - * Get the animation mode for a given tick in the idle sequence. - * IDLE_SEQUENCE replicates the original buddy design pattern. + * Reverse a line's visible characters while preserving leading ANSI codes. + * Handles simple cases: ANSI at start + visible text. */ -const IDLE_SEQUENCE: AnimMode[] = [ - 'idle', 'idle', 'idle', 'idle', - 'fidget', 'idle', 'idle', 'idle', - 'blink', 'idle', 'idle', 'idle', 'idle', -] - -export function getIdleAnimMode(tick: number): AnimMode { - return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length] +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 } diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts index 9f6f96215..293d581e9 100644 --- a/packages/pokemon/src/types.ts +++ b/packages/pokemon/src/types.ts @@ -140,4 +140,14 @@ export type SpriteCache = { } // Animation mode -export type AnimMode = 'idle' | 'fidget' | 'blink' | 'excited' | 'pet' +export type AnimMode = + | 'idle' + | 'breathe' + | 'blink' + | 'fidget' + | 'bounce' + | 'walkLeft' + | 'walkRight' + | 'flip' + | 'excited' + | 'pet' diff --git a/packages/pokemon/src/ui/SpriteAnimator.tsx b/packages/pokemon/src/ui/SpriteAnimator.tsx new file mode 100644 index 000000000..cb7203c79 --- /dev/null +++ b/packages/pokemon/src/ui/SpriteAnimator.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from 'react' +import { Box, Text, type Color, stringWidth } from '@anthropic/ink' +import type { AnimMode } from '../types' +import { renderAnimatedSprite, getIdleAnimMode } from '../sprites/renderer' + +interface SpriteAnimatorProps { + /** Base sprite lines (before animation transforms) */ + lines: string[] + /** Text color for the sprite */ + color?: Color + /** Tick interval in milliseconds (default 500) */ + tickMs?: number + /** Single animation mode. Omit for idle sequence auto-play */ + mode?: AnimMode + /** Whether to center the sprite horizontally (default true) */ + centered?: boolean + /** Extra content to render above the sprite (e.g. hearts) */ + overlay?: string[] | null +} + +/** + * 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 + * + * ``` + */ +export function SpriteAnimator({ + lines, + color, + tickMs = 500, + mode, + centered = true, + overlay, +}: SpriteAnimatorProps) { + const [tick, setTick] = useState(0) + + useEffect(() => { + const timer = setInterval(() => setTick(t => t + 1), tickMs) + return () => clearInterval(timer) + }, [tickMs]) + + const currentMode = mode ?? getIdleAnimMode(tick) + const animated = renderAnimatedSprite(lines, tick, currentMode) + const displayLines = overlay ? [...overlay, ...animated] : animated + + const spriteBlock = ( + + {displayLines.map((line, i) => ( + {line} + ))} + + ) + + if (!centered) return spriteBlock + + return ( + + {spriteBlock} + + ) +} diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx index 12454c04b..f760aeabc 100644 --- a/src/commands/buddy/BuddyPanel.tsx +++ b/src/commands/buddy/BuddyPanel.tsx @@ -18,7 +18,7 @@ import { calculateStats, getCreatureName, getTotalEV, getActiveCreature } from ' import { getXpProgress } from '@claude-code-best/pokemon'; import { getEVSummary } from '@claude-code-best/pokemon'; import { getGenderSymbol } from '@claude-code-best/pokemon'; -import { StatBar } from '@claude-code-best/pokemon'; +import { StatBar, SpriteAnimator, getFallbackSprite } from '@claude-code-best/pokemon'; import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; const CYAN: Color = 'ansi:cyan'; @@ -151,10 +151,12 @@ function BuddyTab({ {spriteLines && ( - - {spriteLines.map((line, i) => ( - {line} - ))} + + )}