mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 00:05:51 +00:00
feat: 一大堆优化
This commit is contained in:
18
packages/pokemon/src/__tests__/renderer.test.ts
Normal file
18
packages/pokemon/src/__tests__/renderer.test.ts
Normal file
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'/': '\\', '\\': '/',
|
||||
'(': ')', ')': '(',
|
||||
'<': '>', '>': '<',
|
||||
'{': '}', '}': '{',
|
||||
'[': ']', ']': '[',
|
||||
'╱': '╲', '╲': '╱',
|
||||
'▌': '▐', '▐': '▌',
|
||||
'▎': '▏', '▏': '▎',
|
||||
'◀': '▶', '▶': '◀',
|
||||
'◄': '►', '►': '◄',
|
||||
'→': '←', '←': '→',
|
||||
'↗': '↙', '↙': '↗',
|
||||
'↘': '↖', '↖': '↘',
|
||||
'`': "'", "'": '`',
|
||||
',': '´', '´': ',',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* <SpriteAnimator lines={spriteLines} color="ansi:blue" />
|
||||
*
|
||||
* // Forced excited mode
|
||||
* <SpriteAnimator lines={spriteLines} mode="excited" />
|
||||
*
|
||||
* // With heart overlay
|
||||
* <SpriteAnimator lines={spriteLines} overlay={heartLines} />
|
||||
* ```
|
||||
* - 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 = (
|
||||
<Box flexDirection="column">
|
||||
{displayLines.map((line, i) => (
|
||||
<Text key={i} color={color}>{line}</Text>
|
||||
<Text key={i} color={color}>{line || ' '}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user