mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 16:55: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 { SPECIES_DATA } from '../data/species'
|
||||||
import { generateCreature } from './creature'
|
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.
|
* 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 {
|
export function checkEggEligibility(buddyData: BuddyData): boolean {
|
||||||
if (buddyData.eggs.length >= 1) return false
|
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
|
if (buddyData.stats.totalTurns % 50 !== 0) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ export { determineGender, getGenderSymbol } from './core/gender'
|
|||||||
export { awardXP, getXpProgress } from './core/experience'
|
export { awardXP, getXpProgress } from './core/experience'
|
||||||
export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort'
|
export { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from './core/effort'
|
||||||
export { checkEvolution, evolve, canEvolveFurther } from './core/evolution'
|
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 { loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy, updateDailyStats, incrementTurns } from './core/storage'
|
||||||
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
|
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
|
||||||
|
|
||||||
// Sprites
|
// Sprites
|
||||||
export { renderAnimatedSprite, getIdleAnimMode } from './sprites/renderer'
|
export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
|
||||||
export { getFallbackSprite } from './sprites/fallback'
|
export { getFallbackSprite } from './sprites/fallback'
|
||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { renderAnimatedSprite, getIdleAnimMode } from './renderer'
|
export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './renderer'
|
||||||
export type { AnimMode } from '../types'
|
export type { AnimMode } from '../types'
|
||||||
export { getFallbackSprite } from './fallback'
|
export { getFallbackSprite } from './fallback'
|
||||||
export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
|
export { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
|
||||||
|
|||||||
@@ -1,108 +1,233 @@
|
|||||||
import type { AnimMode } from '../types'
|
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[] = [
|
const IDLE_SEQUENCE: AnimMode[] = [
|
||||||
'idle', 'idle', 'idle',
|
|
||||||
'breathe',
|
|
||||||
'idle', 'idle',
|
'idle', 'idle',
|
||||||
|
'breathe', 'breathe',
|
||||||
|
'idle',
|
||||||
'blink',
|
'blink',
|
||||||
'idle', 'idle', 'idle',
|
'idle',
|
||||||
'bounce',
|
'bounce',
|
||||||
'idle', 'idle', 'idle',
|
'idle',
|
||||||
'fidget',
|
'fidget', 'fidget',
|
||||||
|
'idle',
|
||||||
|
'breathe', 'breathe',
|
||||||
|
'idle',
|
||||||
|
'flip', 'flip', 'flip',
|
||||||
'idle', 'idle',
|
'idle', 'idle',
|
||||||
|
'bounce',
|
||||||
|
'idle',
|
||||||
|
'blink',
|
||||||
|
'idle',
|
||||||
|
'excited', 'excited',
|
||||||
|
'idle',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getIdleAnimMode(tick: number): AnimMode {
|
export function getIdleAnimMode(tick: number): AnimMode {
|
||||||
return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
|
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[] {
|
export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
|
||||||
|
const grid = parseSprite(lines)
|
||||||
|
|
||||||
|
let result: Pixel[][] = grid
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
return lines
|
break
|
||||||
case 'breathe':
|
case 'breathe':
|
||||||
return breathe(lines, tick)
|
// Right sway → center
|
||||||
|
result = shiftH(result, tick % 4 < 2 ? 3 : 0)
|
||||||
|
break
|
||||||
case 'blink':
|
case 'blink':
|
||||||
return blinkEyes(lines)
|
result = blinkEyes(result)
|
||||||
|
break
|
||||||
case 'fidget':
|
case 'fidget':
|
||||||
return shiftLines(lines, tick % 2 === 0 ? 0 : 1)
|
// Big right sway → center
|
||||||
case 'bounce':
|
result = shiftH(result, tick % 2 === 0 ? 4 : 0)
|
||||||
return bounce(lines, tick)
|
break
|
||||||
case 'walkLeft':
|
case 'bounce': {
|
||||||
return walkLeft(lines, tick)
|
const PATTERN = [0, 2, 3, 4, 4, 3, 2, 0, 0]
|
||||||
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]
|
const h = PATTERN[tick % PATTERN.length]
|
||||||
if (h === 0 || lines.length === 0) return lines
|
result = shiftUp(result, h)
|
||||||
return [
|
break
|
||||||
...Array(h).fill(''),
|
}
|
||||||
...lines.slice(0, lines.length - h),
|
case 'walkLeft':
|
||||||
]
|
// Step right → center (mimics bounce-back from left step)
|
||||||
|
result = shiftH(result, tick % 4 === 0 ? 0 : 3)
|
||||||
|
break
|
||||||
|
case 'walkRight':
|
||||||
|
// Step right → further right → center
|
||||||
|
result = shiftH(result, (tick % 4) * 2)
|
||||||
|
break
|
||||||
|
case 'flip':
|
||||||
|
// Pure position reversal — do NOT mirror chars so / \ ( )
|
||||||
|
// visibly swap, making the flip obvious.
|
||||||
|
result = reverseH(result, false)
|
||||||
|
break
|
||||||
|
case 'excited':
|
||||||
|
// Jitter right ↔ further right (never crop)
|
||||||
|
result = shiftH(result, tick % 2 === 0 ? 1 : 4)
|
||||||
|
break
|
||||||
|
case 'pet':
|
||||||
|
break // overlay handled by SpriteAnimator
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Walk left — shift left a few steps then reset */
|
return renderSprite(result)
|
||||||
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 */
|
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
|
||||||
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 = [
|
const PET_HEARTS = [
|
||||||
[' ♥ ', ' '],
|
[' ♥ ', ' '],
|
||||||
[' ♥ ♥ ', ' ♥ '],
|
[' ♥ ♥ ', ' ♥ '],
|
||||||
@@ -111,20 +236,6 @@ const PET_HEARTS = [
|
|||||||
[' ♥ ', ' ♥ ♥ '],
|
[' ♥ ', ' ♥ ♥ '],
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Add heart particle frames above the sprite */
|
export function getPetOverlay(tick: number): string[] {
|
||||||
function addPetParticles(lines: string[], tick: number): string[] {
|
return PET_HEARTS[tick % PET_HEARTS.length]
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,40 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
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 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 {
|
interface SpriteAnimatorProps {
|
||||||
/** Base sprite lines (before animation transforms) */
|
/** Base sprite lines (ANSI is preserved) */
|
||||||
lines: string[]
|
lines: string[]
|
||||||
/** Text color for the sprite */
|
/** Text color for the sprite */
|
||||||
color?: Color
|
color?: Color
|
||||||
/** Tick interval in milliseconds (default 500) */
|
/** Tick interval in ms (default 250) */
|
||||||
tickMs?: number
|
tickMs?: number
|
||||||
/** Single animation mode. Omit for idle sequence auto-play */
|
/** Single mode; omit for idle auto-play */
|
||||||
mode?: AnimMode
|
mode?: AnimMode
|
||||||
/** Whether to center the sprite horizontally (default true) */
|
/** Center horizontally (default true) */
|
||||||
centered?: boolean
|
centered?: boolean
|
||||||
/** Extra content to render above the sprite (e.g. hearts) */
|
/** Show pet hearts overlay */
|
||||||
overlay?: string[] | null
|
petting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Animated sprite renderer with built-in tick loop.
|
* Animated sprite renderer with built-in tick loop.
|
||||||
*
|
*
|
||||||
* Renders base sprite lines with animation transforms applied per-tick.
|
* - Keeps ANSI intact (parse → pixel grid → transform → render)
|
||||||
* Uses the idle sequence by default; pass `mode` to force a single animation.
|
* - Pads vertically so bounce never shifts layout
|
||||||
*
|
* - Grid transforms guarantee fixed output height
|
||||||
* @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({
|
export function SpriteAnimator({
|
||||||
lines,
|
lines,
|
||||||
color,
|
color,
|
||||||
tickMs = 500,
|
tickMs = 100,
|
||||||
mode,
|
mode,
|
||||||
centered = true,
|
centered = true,
|
||||||
overlay,
|
petting,
|
||||||
}: SpriteAnimatorProps) {
|
}: SpriteAnimatorProps) {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
|
|
||||||
@@ -51,14 +43,21 @@ export function SpriteAnimator({
|
|||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [tickMs])
|
}, [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 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 displayLines = overlay ? [...overlay, ...animated] : animated
|
||||||
|
|
||||||
const spriteBlock = (
|
const spriteBlock = (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{displayLines.map((line, i) => (
|
{displayLines.map((line, i) => (
|
||||||
<Text key={i} color={color}>{line}</Text>
|
<Text key={i} color={color}>{line || ' '}</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import { Box, Text, Pane, Tab, Tabs, type Color } from '@anthropic/ink';
|
import { Box, Text, Pane, Tab, Tabs, type Color } from '@anthropic/ink';
|
||||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||||
|
import { Select } from '../../components/CustomSelect/select.js';
|
||||||
import {
|
import {
|
||||||
STAT_NAMES,
|
STAT_NAMES,
|
||||||
STAT_LABELS,
|
STAT_LABELS,
|
||||||
@@ -12,13 +13,13 @@ import {
|
|||||||
type SpeciesId,
|
type SpeciesId,
|
||||||
} from '@claude-code-best/pokemon';
|
} from '@claude-code-best/pokemon';
|
||||||
import { SPECIES_DATA } 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 { 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 { getXpProgress } 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, 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';
|
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||||
|
|
||||||
const CYAN: Color = 'ansi:cyan';
|
const CYAN: Color = 'ansi:cyan';
|
||||||
@@ -52,6 +53,7 @@ interface BuddyPanelProps {
|
|||||||
*/
|
*/
|
||||||
export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) {
|
export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) {
|
||||||
const [selectedTab, setSelectedTab] = useState('Buddy');
|
const [selectedTab, setSelectedTab] = useState('Buddy');
|
||||||
|
const [data, setData] = useState(buddyData);
|
||||||
|
|
||||||
useExitOnCtrlCDWithKeybindings();
|
useExitOnCtrlCDWithKeybindings();
|
||||||
|
|
||||||
@@ -64,21 +66,32 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps)
|
|||||||
isActive: true,
|
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 = [
|
const tabs = [
|
||||||
<Tab key="buddy" title="Buddy">
|
<Tab key="buddy" title="Buddy">
|
||||||
{creature ? (
|
{creature ? (
|
||||||
<BuddyTab creature={creature} buddyData={buddyData} spriteLines={spriteLines} />
|
<BuddyTab creature={creature} buddyData={data} spriteLines={spriteLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text color={GRAY}>No buddy yet. Keep coding!</Text>
|
<Text color={GRAY}>No buddy yet. Keep coding!</Text>
|
||||||
)}
|
)}
|
||||||
</Tab>,
|
</Tab>,
|
||||||
<Tab key="dex" title="Pokédex">
|
<Tab key="dex" title="Pokédex">
|
||||||
<DexTab buddyData={buddyData} />
|
<DexTab
|
||||||
|
buddyData={data}
|
||||||
|
isActive={selectedTab === 'Pokédex'}
|
||||||
|
onSwitchCreature={handleSwitchCreature}
|
||||||
|
onClose={() => onClose('buddy panel closed')}
|
||||||
|
/>
|
||||||
</Tab>,
|
</Tab>,
|
||||||
<Tab key="egg" title="Egg">
|
<Tab key="egg" title="Egg">
|
||||||
<EggTab buddyData={buddyData} />
|
<EggTab buddyData={data} />
|
||||||
</Tab>,
|
</Tab>,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -107,7 +120,6 @@ function BuddyTab({
|
|||||||
const xp = getXpProgress(creature);
|
const xp = getXpProgress(creature);
|
||||||
const genderSymbol = getGenderSymbol(creature.gender);
|
const genderSymbol = getGenderSymbol(creature.gender);
|
||||||
const name = getCreatureName(creature);
|
const name = getCreatureName(creature);
|
||||||
const evSummary = getEVSummary(creature);
|
|
||||||
const totalEV = getTotalEV(creature);
|
const totalEV = getTotalEV(creature);
|
||||||
const nextEvo = getNextEvolution(creature.speciesId);
|
const nextEvo = getNextEvolution(creature.speciesId);
|
||||||
|
|
||||||
@@ -131,15 +143,13 @@ function BuddyTab({
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column" alignItems="center">
|
||||||
<Box justifyContent="space-between">
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text bold color={CYAN}>
|
<Text bold color={CYAN}>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
||||||
{shinyBadge}
|
{shinyBadge}
|
||||||
</Box>
|
|
||||||
<Text bold> Lv.{creature.level}</Text>
|
<Text bold> Lv.{creature.level}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -160,17 +170,45 @@ function BuddyTab({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{species.flavorText && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY} italic>
|
<Text color={GRAY} italic>
|
||||||
"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"
|
"{species.flavorText}"
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box flexDirection="column" marginTop={0}>
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Box>
|
||||||
|
<Box width={28}>
|
||||||
<Text color={GRAY}>─── Stats ───</Text>
|
<Text color={GRAY}>─── Stats ───</Text>
|
||||||
{STAT_NAMES.map(stat => (
|
</Box>
|
||||||
<StatBar key={stat} label={STAT_LABELS[stat]} value={stats[stat]} maxValue={255} color={getStatColor(stat)} />
|
<Box>
|
||||||
))}
|
<Text color={GRAY}>─── Base ───</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{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 ? <Text color={GREEN}>({ev})</Text> : null;
|
||||||
|
return (
|
||||||
|
<Box key={stat}>
|
||||||
|
<Box width={28}>
|
||||||
|
<StatBar label={STAT_LABELS[stat]} value={stats[stat]} maxValue={255} color={getStatColor(stat)} />
|
||||||
|
{evText}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
|
||||||
|
<Text color={getStatColor(stat)}>
|
||||||
|
{'█'.repeat(baseFilled)}
|
||||||
|
{'░'.repeat(12 - baseFilled)}
|
||||||
|
</Text>
|
||||||
|
<Text> {String(baseVal).padStart(3)}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={0}>
|
<Box marginTop={0}>
|
||||||
@@ -185,11 +223,10 @@ function BuddyTab({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column" alignItems="center">
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>EV </Text>
|
<Text color={GRAY}>EV </Text>
|
||||||
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
<Text color={totalEV >= 510 ? GREEN : GRAY}>{totalEV}/510</Text>
|
||||||
<Text color={GRAY}> ({totalEV}/510)</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>♥ </Text>
|
<Text color={GRAY}>♥ </Text>
|
||||||
@@ -213,104 +250,234 @@ function BuddyTab({
|
|||||||
|
|
||||||
// ─── Dex Tab ──────────────────────────────────────────
|
// ─── 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 dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d]));
|
||||||
const collected = buddyData.dex.length;
|
const collected = buddyData.dex.length;
|
||||||
const total = ALL_SPECIES_IDS.length;
|
const total = ALL_SPECIES_IDS.length;
|
||||||
const chains = groupByChain();
|
const flatSpecies = groupByChain().flat();
|
||||||
|
|
||||||
return (
|
const [focusedId, setFocusedId] = useState<SpeciesId>(flatSpecies[0]);
|
||||||
<Box flexDirection="column">
|
|
||||||
<Box justifyContent="space-between">
|
|
||||||
<Text bold color={CYAN}>
|
|
||||||
Pokédex
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
<Text bold color={collected === total ? GREEN : WHITE}>
|
|
||||||
{collected}
|
|
||||||
</Text>
|
|
||||||
<Text color={GRAY}>/{total}</Text>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
// Build options for the Select component
|
||||||
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
const options = flatSpecies.map(speciesId => {
|
||||||
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
|
||||||
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{chains.map((chain, ci) => (
|
|
||||||
<Box key={ci} flexDirection="column">
|
|
||||||
{chain.map((speciesId, si) => {
|
|
||||||
const species = SPECIES_DATA[speciesId];
|
const species = SPECIES_DATA[speciesId];
|
||||||
const entry = dexMap.get(speciesId);
|
const entry = dexMap.get(speciesId);
|
||||||
const discovered = !!entry;
|
const discovered = !!entry;
|
||||||
const isActive = buddyData.activeCreatureId
|
const isActiveCreature = buddyData.activeCreatureId
|
||||||
? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId)
|
? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId)
|
||||||
: false;
|
: false;
|
||||||
const nextEvo = getNextEvolution(speciesId);
|
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<Box key={speciesId}>
|
label: (
|
||||||
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
|
<Text>
|
||||||
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
|
||||||
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
||||||
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
|
<Text color={discovered ? WHITE : GRAY} bold={isActiveCreature}>
|
||||||
{discovered ? (species.names.zh ?? species.name) : '???'}
|
{discovered ? (species.names.zh ?? species.name) : '???'}
|
||||||
</Text>
|
</Text>
|
||||||
{discovered && (
|
{isActiveCreature && <Text color={YELLOW}> ★</Text>}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Header */}
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Text bold color={CYAN}>Pokédex</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{' '}
|
<Text bold color={collected === total ? GREEN : WHITE}>{collected}</Text>
|
||||||
{species.types
|
<Text color={GRAY}>/{total}</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
||||||
|
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
||||||
|
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Two-column: Select list | detail */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
{/* ── Left: Select list ── */}
|
||||||
|
<Box width={20}>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
onFocus={(value: SpeciesId) => setFocusedId(value)}
|
||||||
|
onChange={(value: SpeciesId) => {
|
||||||
|
const creature = buddyData.creatures.find(c => c.speciesId === value);
|
||||||
|
if (creature && creature.id !== buddyData.activeCreatureId) {
|
||||||
|
onSwitchCreature(creature.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={onClose}
|
||||||
|
visibleOptionCount={flatSpecies.length}
|
||||||
|
hideIndexes
|
||||||
|
layout="compact"
|
||||||
|
isDisabled={!isActive}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Divider ── */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{Array.from({ length: flatSpecies.length }, (_, i) => (
|
||||||
|
<Text key={i} color={GRAY}>│</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Right: detail panel ── */}
|
||||||
|
<Box flexDirection="column" flexGrow={1} marginLeft={1}>
|
||||||
|
{focusedDiscovered ? (
|
||||||
|
<>
|
||||||
|
{/* Sprite */}
|
||||||
|
{spriteLines && (
|
||||||
|
<Box flexDirection="column" alignItems="center">
|
||||||
|
{spriteLines.map((line, i) => (
|
||||||
|
<Text key={i} color={CYAN}>{line}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name header */}
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Text bold color={CYAN}>#{String(focusedSpecies.dexNumber).padStart(3, '0')} </Text>
|
||||||
|
<Text bold color={WHITE}>{focusedSpecies.names.zh ?? focusedSpecies.name}</Text>
|
||||||
|
<Text color={GRAY}> {focusedSpecies.name}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Types + Gender */}
|
||||||
|
<Box justifyContent="center">
|
||||||
|
{focusedSpecies.types
|
||||||
.filter((t): t is string => Boolean(t))
|
.filter((t): t is string => Boolean(t))
|
||||||
.map((t, ti) => (
|
.map((t, ti) => (
|
||||||
<React.Fragment key={t}>
|
<React.Fragment key={t}>
|
||||||
{ti > 0 && <Text color={GRAY}>/</Text>}
|
{ti > 0 && <Text color={GRAY}>/</Text>}
|
||||||
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.slice(0, 3).toUpperCase()}</Text>
|
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.toUpperCase()}</Text>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
<Text color={GRAY}> {getGenderInfoText(focusedSpecies.genderRate)}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Base Stats */}
|
||||||
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={GRAY}>─── Base Stats ───</Text>
|
||||||
|
{STAT_NAMES.map(stat => {
|
||||||
|
const val = focusedSpecies.baseStats[stat];
|
||||||
|
const filled = Math.round((val / maxBase) * 12);
|
||||||
|
return (
|
||||||
|
<Box key={stat}>
|
||||||
|
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
|
||||||
|
<Text color={getStatColor(stat)}>
|
||||||
|
{'█'.repeat(filled)}
|
||||||
|
{'░'.repeat(12 - filled)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Text> {String(val).padStart(3)}</Text>
|
||||||
{discovered && entry ? (
|
|
||||||
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
|
||||||
) : (
|
|
||||||
<Text color={GRAY}> ───</Text>
|
|
||||||
)}
|
|
||||||
{nextEvo && (
|
|
||||||
<Text color={GRAY}>
|
|
||||||
{' '}
|
|
||||||
→<Text color={CYAN}>Lv.{nextEvo.minLevel}</Text>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Box marginTop={0} flexDirection="column">
|
|
||||||
<Text color={GRAY}>─── Stats ───</Text>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={GRAY}>Turns: </Text>
|
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
|
||||||
<Text>{buddyData.stats.totalTurns}</Text>
|
<Text color={GRAY}>{'─'.repeat(12)}</Text>
|
||||||
<Text color={GRAY}> Days: </Text>
|
<Text bold> {Object.values(focusedSpecies.baseStats).reduce((a, b) => a + b, 0)}</Text>
|
||||||
<Text>{buddyData.stats.consecutiveDays}</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text color={GRAY}>Eggs: </Text>
|
|
||||||
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
|
||||||
<Text color={GRAY}> Evolutions: </Text>
|
|
||||||
<Text>{buddyData.stats.totalEvolutions}</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{buddyData.eggs.length > 0 && (
|
{/* Evolution chain */}
|
||||||
<Box marginTop={0}>
|
{(() => {
|
||||||
<Text color={YELLOW}>🥚 Egg: </Text>
|
const evoChain = getChainFor(focusedId);
|
||||||
<Text>
|
if (evoChain.length <= 1) return null;
|
||||||
{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}
|
return (
|
||||||
|
<Box flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={GRAY}>─── Evolution ───</Text>
|
||||||
|
<Box>
|
||||||
|
{evoChain.map((sid, i) => {
|
||||||
|
const next = getNextEvolution(sid);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={sid}>
|
||||||
|
{i > 0 && <Text color={GRAY}> → </Text>}
|
||||||
|
<Text color={sid === focusedId ? CYAN : GRAY} bold={sid === focusedId}>
|
||||||
|
{SPECIES_DATA[sid].names.zh ?? SPECIES_DATA[sid].name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={GRAY}> steps</Text>
|
{next && <Text color={GRAY}> Lv.{next.minLevel}</Text>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Flavor text */}
|
||||||
|
{focusedSpecies.flavorText && (
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={GRAY} italic>"{focusedSpecies.flavorText}"</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Box marginTop={0}>
|
||||||
|
{focusedOwned ? (
|
||||||
|
focusedIsActive ? (
|
||||||
|
<Text color={GREEN}>★ Current buddy</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={CYAN}>Enter → switch to this buddy</Text>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Text color={GRAY}>Not owned</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box flexDirection="column" alignItems="center" marginTop={2}>
|
||||||
|
<Text color={GRAY}>{' ??? '}</Text>
|
||||||
|
<Text color={GRAY}>{' / \\'}</Text>
|
||||||
|
<Text color={GRAY}>{' | ? |'}</Text>
|
||||||
|
<Text color={GRAY}>{' \\_/'}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginTop={1}>
|
||||||
|
<Text bold color={GRAY}>#{String(focusedSpecies.dexNumber).padStart(3, '0')} ???</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Text color={GRAY} italic>Undiscovered species...</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={GRAY}>Turns:{buddyData.stats.totalTurns} Days:{buddyData.stats.consecutiveDays} Eggs:{buddyData.stats.totalEggsObtained} Evos:{buddyData.stats.totalEvolutions}</Text>
|
||||||
|
</Box>
|
||||||
|
{buddyData.eggs.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text color={YELLOW}>🥚 {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} steps</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -323,14 +490,39 @@ function EggTab({ buddyData }: { buddyData: BuddyData }) {
|
|||||||
const eggs = buddyData.eggs;
|
const eggs = buddyData.eggs;
|
||||||
|
|
||||||
if (eggs.length === 0) {
|
if (eggs.length === 0) {
|
||||||
|
// Include today in progress even if updateDailyStats hasn't run yet
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const lastDate = buddyData.stats.lastActiveDate;
|
||||||
|
let effectiveDays = buddyData.stats.consecutiveDays;
|
||||||
|
if (lastDate !== today) {
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||||
|
effectiveDays = lastDate === yesterdayStr ? effectiveDays + 1 : 1;
|
||||||
|
}
|
||||||
|
const progress = Math.min(effectiveDays, EGG_REQUIRED_DAYS);
|
||||||
|
const filled = Math.round((progress / EGG_REQUIRED_DAYS) * 10);
|
||||||
|
const empty = 10 - filled;
|
||||||
|
const daysLeft = Math.max(0, EGG_REQUIRED_DAYS - effectiveDays);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text bold color={CYAN}>
|
<Text bold color={CYAN}>
|
||||||
Egg
|
Egg
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={GRAY}>No egg currently. Keep coding!</Text>
|
<Text color={GRAY}>No egg currently. Keep coding!</Text>
|
||||||
{buddyData.stats.consecutiveDays < 7 && (
|
<Box marginTop={0}>
|
||||||
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
<Text color={GRAY}>Egg progress </Text>
|
||||||
|
<Text color={progress >= EGG_REQUIRED_DAYS ? GREEN : YELLOW}>
|
||||||
|
{'█'.repeat(filled)}
|
||||||
|
{'░'.repeat(empty)}
|
||||||
|
</Text>
|
||||||
|
<Text> {progress}/{EGG_REQUIRED_DAYS} days</Text>
|
||||||
|
</Box>
|
||||||
|
{daysLeft > 0 ? (
|
||||||
|
<Text color={GRAY}>Next egg: {daysLeft} more day{daysLeft > 1 ? 's' : ''}</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={GREEN}>Ready! Keep coding to trigger an egg.</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -403,3 +595,44 @@ function groupByChain(): SpeciesId[][] {
|
|||||||
['pikachu'],
|
['pikachu'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGenderInfoText(genderRate: number): string {
|
||||||
|
if (genderRate === -1) return 'Genderless';
|
||||||
|
if (genderRate === 0) return '♂ 100%';
|
||||||
|
if (genderRate === 8) return '♀ 100%';
|
||||||
|
return `♀ ${(genderRate / 8) * 100}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get full evolution chain containing this species */
|
||||||
|
function getChainFor(speciesId: SpeciesId): SpeciesId[] {
|
||||||
|
const chainHeads: SpeciesId[] = ['bulbasaur', 'charmander', 'squirtle', 'pikachu'];
|
||||||
|
let head: SpeciesId = speciesId;
|
||||||
|
for (const starter of chainHeads) {
|
||||||
|
if (isInChain(speciesId, starter)) {
|
||||||
|
head = starter;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chain: SpeciesId[] = [head];
|
||||||
|
let current: SpeciesId | undefined = head;
|
||||||
|
while (current) {
|
||||||
|
const next = getNextEvolution(current);
|
||||||
|
if (next) {
|
||||||
|
chain.push(next.to);
|
||||||
|
current = next.to;
|
||||||
|
} else {
|
||||||
|
current = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInChain(target: SpeciesId, head: SpeciesId): boolean {
|
||||||
|
let current: SpeciesId | undefined = head;
|
||||||
|
while (current) {
|
||||||
|
if (current === target) return true;
|
||||||
|
const next = getNextEvolution(current);
|
||||||
|
current = next ? next.to : undefined;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3468,8 +3468,8 @@ export function REPL({
|
|||||||
// 1. Collect tool names from this turn's messages
|
// 1. Collect tool names from this turn's messages
|
||||||
const _toolNames: string[] = [];
|
const _toolNames: string[] = [];
|
||||||
for (const _msg of messagesRef.current) {
|
for (const _msg of messagesRef.current) {
|
||||||
if (_msg.role === 'assistant' && Array.isArray(_msg.content)) {
|
if (_msg.type === 'assistant' && Array.isArray((_msg as any).message?.content)) {
|
||||||
for (const _block of _msg.content) {
|
for (const _block of (_msg as any).message.content) {
|
||||||
if (_block.type === 'tool_use') _toolNames.push(_block.name);
|
if (_block.type === 'tool_use') _toolNames.push(_block.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user