mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 00:35:51 +00:00
feat: 扩展精灵动画系统并新增 SpriteAnimator 组件
- 新增动画模式: breathe, bounce, walkLeft, walkRight, flip - 新增 SpriteAnimator 组件,内置 tick 循环和居中渲染 - BuddyPanel 使用 SpriteAnimator 替代手动渲染 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,3 +46,4 @@ export { EggView } from './ui/EggView'
|
|||||||
export { EvolutionAnim } from './ui/EvolutionAnim'
|
export { EvolutionAnim } from './ui/EvolutionAnim'
|
||||||
export { StatBar } from './ui/StatBar'
|
export { StatBar } from './ui/StatBar'
|
||||||
export { SpeciesDetail } from './ui/SpeciesDetail'
|
export { SpeciesDetail } from './ui/SpeciesDetail'
|
||||||
|
export { SpriteAnimator } from './ui/SpriteAnimator'
|
||||||
|
|||||||
@@ -1,5 +1,107 @@
|
|||||||
import type { AnimMode } from '../types'
|
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 */
|
/** Heart particle frames for pet animation */
|
||||||
const PET_HEARTS = [
|
const PET_HEARTS = [
|
||||||
[' ♥ ', ' '],
|
[' ♥ ', ' '],
|
||||||
@@ -9,68 +111,20 @@ const PET_HEARTS = [
|
|||||||
[' ♥ ', ' ♥ ♥ '],
|
[' ♥ ', ' ♥ ♥ '],
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/** Add heart particle frames above the sprite */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function addPetParticles(lines: string[], tick: number): string[] {
|
function addPetParticles(lines: string[], tick: number): string[] {
|
||||||
const hearts = PET_HEARTS[tick % PET_HEARTS.length]
|
const hearts = PET_HEARTS[tick % PET_HEARTS.length]
|
||||||
return [...hearts, ...lines]
|
return [...hearts, ...lines]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the animation mode for a given tick in the idle sequence.
|
* Reverse a line's visible characters while preserving leading ANSI codes.
|
||||||
* IDLE_SEQUENCE replicates the original buddy design pattern.
|
* Handles simple cases: ANSI at start + visible text.
|
||||||
*/
|
*/
|
||||||
const IDLE_SEQUENCE: AnimMode[] = [
|
function reverseLine(line: string): string {
|
||||||
'idle', 'idle', 'idle', 'idle',
|
// eslint-disable-next-line no-control-regex
|
||||||
'fidget', 'idle', 'idle', 'idle',
|
const ansiMatch = line.match(/^(\x1b\[[0-9;]*m)+/)
|
||||||
'blink', 'idle', 'idle', 'idle', 'idle',
|
const stripped = line.replace(/\x1b\[[0-9;]*m/g, '')
|
||||||
]
|
const reversed = stripped.split('').reverse().join('')
|
||||||
|
return ansiMatch ? ansiMatch[0] + reversed : reversed
|
||||||
export function getIdleAnimMode(tick: number): AnimMode {
|
|
||||||
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,4 +140,14 @@ export type SpriteCache = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Animation mode
|
// Animation mode
|
||||||
export type AnimMode = 'idle' | 'fidget' | 'blink' | 'excited' | 'pet'
|
export type AnimMode =
|
||||||
|
| 'idle'
|
||||||
|
| 'breathe'
|
||||||
|
| 'blink'
|
||||||
|
| 'fidget'
|
||||||
|
| 'bounce'
|
||||||
|
| 'walkLeft'
|
||||||
|
| 'walkRight'
|
||||||
|
| 'flip'
|
||||||
|
| 'excited'
|
||||||
|
| 'pet'
|
||||||
|
|||||||
73
packages/pokemon/src/ui/SpriteAnimator.tsx
Normal file
73
packages/pokemon/src/ui/SpriteAnimator.tsx
Normal file
@@ -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
|
||||||
|
* <SpriteAnimator lines={spriteLines} color="ansi:blue" />
|
||||||
|
*
|
||||||
|
* // Forced excited mode
|
||||||
|
* <SpriteAnimator lines={spriteLines} mode="excited" />
|
||||||
|
*
|
||||||
|
* // With heart overlay
|
||||||
|
* <SpriteAnimator lines={spriteLines} overlay={heartLines} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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 = (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{displayLines.map((line, i) => (
|
||||||
|
<Text key={i} color={color}>{line}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!centered) return spriteBlock
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="center" width="100%">
|
||||||
|
{spriteBlock}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { calculateStats, getCreatureName, getTotalEV, getActiveCreature } from '
|
|||||||
import { getXpProgress } from '@claude-code-best/pokemon';
|
import { getXpProgress } from '@claude-code-best/pokemon';
|
||||||
import { getEVSummary } from '@claude-code-best/pokemon';
|
import { getEVSummary } from '@claude-code-best/pokemon';
|
||||||
import { getGenderSymbol } 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';
|
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||||
|
|
||||||
const CYAN: Color = 'ansi:cyan';
|
const CYAN: Color = 'ansi:cyan';
|
||||||
@@ -151,10 +151,12 @@ function BuddyTab({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{spriteLines && (
|
{spriteLines && (
|
||||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
<Box marginY={0}>
|
||||||
{spriteLines.map((line, i) => (
|
<SpriteAnimator
|
||||||
<Text key={i}>{line}</Text>
|
lines={spriteLines}
|
||||||
))}
|
color={creature.isShiny ? YELLOW : CYAN}
|
||||||
|
tickMs={500}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user