mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 同步 pkmn Dex 全部 1025 只精灵,新增 SpeciesPicker 搜索选择器
- SpeciesId 从 10 项联合类型改为 string,动态从 @pkmn/sim Dex 加载 1025 只精灵 - getSpecies() 改用 Dex.species.get() 直接查找(gen wrapper 仅覆盖 733/1025) - SUPPLEMENT/DEX_TO_SPECIES 动态生成,未收录 species 使用默认值兜底 - names/fallback 改为 partial records,缺失时回退到 Dex 英文名/通用 sprite - 新增 SpeciesPicker 组件(基于 FuzzyPicker),支持中英文/编号搜索选择精灵 - BattleFlow configSelect 阶段替换为 SpeciesPicker,删除旧的上下翻页逻辑 - evolution 移除 ALL_SPECIES_IDS 限制,所有 Dex 物种均可进化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,9 +71,11 @@ describe('checkEvolution', () => {
|
||||
expect(checkEvolution(creature)).toBeNull()
|
||||
})
|
||||
|
||||
test('pikachu cannot evolve in MVP', () => {
|
||||
test('pikachu does not evolve by level-up (needs item)', () => {
|
||||
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
|
||||
expect(checkEvolution(creature)).toBeNull()
|
||||
// Pikachu evolves via Thunder Stone, not level-up
|
||||
const result = checkEvolution(creature)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
|
||||
@@ -118,7 +120,7 @@ describe('canEvolveFurther', () => {
|
||||
expect(canEvolveFurther('blastoise')).toBe(false)
|
||||
})
|
||||
|
||||
test('pikachu cannot evolve in MVP', () => {
|
||||
expect(canEvolveFurther('pikachu')).toBe(false)
|
||||
test('pikachu can evolve into raichu', () => {
|
||||
expect(canEvolveFurther('pikachu')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,16 +10,21 @@ describe('getFallbackSprite', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('returns pikachu fallback for unknown species', () => {
|
||||
const sprite = getFallbackSprite('unknown' as any)
|
||||
expect(sprite).toEqual(getFallbackSprite('pikachu'))
|
||||
test('returns generic fallback for unknown species', () => {
|
||||
const sprite = getFallbackSprite('unknowndefinitelynotarealspecies')
|
||||
expect(sprite.length).toBe(5)
|
||||
expect(sprite[0]).toContain('.---')
|
||||
})
|
||||
|
||||
test('returns curated sprite for pikachu', () => {
|
||||
const sprite = getFallbackSprite('pikachu')
|
||||
expect(sprite[0]).toContain('/\\')
|
||||
})
|
||||
|
||||
test('each line has consistent width', () => {
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
const sprite = getFallbackSprite(id)
|
||||
const widths = sprite.map(line => line.length)
|
||||
// All lines should be roughly the same width
|
||||
const maxWidth = Math.max(...widths)
|
||||
const minWidth = Math.min(...widths)
|
||||
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/names'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
|
||||
// Original 10 curated species
|
||||
const CURATED = [
|
||||
'bulbasaur', 'ivysaur', 'venusaur',
|
||||
'charmander', 'charmeleon', 'charizard',
|
||||
'squirtle', 'wartortle', 'blastoise',
|
||||
'pikachu',
|
||||
]
|
||||
|
||||
describe('SPECIES_NAMES', () => {
|
||||
test('has name for every species', () => {
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
test('has name for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(SPECIES_NAMES[id]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
@@ -15,8 +22,8 @@ describe('SPECIES_NAMES', () => {
|
||||
})
|
||||
|
||||
describe('SPECIES_I18N', () => {
|
||||
test('has i18n for every species', () => {
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
test('has i18n for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(SPECIES_I18N[id]).toBeTruthy()
|
||||
expect(SPECIES_I18N[id]!.en).toBeTruthy()
|
||||
}
|
||||
@@ -29,14 +36,14 @@ describe('SPECIES_I18N', () => {
|
||||
})
|
||||
|
||||
describe('SPECIES_PERSONALITY', () => {
|
||||
test('has personality for every species', () => {
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
test('has personality for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test('personality is non-empty string', () => {
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
test('personality is non-empty string for curated species', () => {
|
||||
for (const id of CURATED) {
|
||||
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
|
||||
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
|
||||
|
||||
|
||||
export interface EvolutionChainStep {
|
||||
from: SpeciesId
|
||||
@@ -18,7 +15,6 @@ export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | und
|
||||
|
||||
// Take the first evolution target (most species have single evo path)
|
||||
const target = dex.evos[0]!.toLowerCase()
|
||||
if (!ALL_SPECIES_IDS.includes(target as SpeciesId)) return undefined
|
||||
|
||||
const targetDex = Dex.species.get(target)
|
||||
if (!targetDex?.exists) return undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SpeciesId } from '../types'
|
||||
|
||||
/** Default names for each species (English) */
|
||||
export const SPECIES_NAMES: Record<SpeciesId, string> = {
|
||||
/** Curated English names (Dex provides default names for all species) */
|
||||
export const SPECIES_NAMES: Partial<Record<string, string>> = {
|
||||
bulbasaur: 'Bulbasaur',
|
||||
ivysaur: 'Ivysaur',
|
||||
venusaur: 'Venusaur',
|
||||
@@ -14,8 +14,8 @@ export const SPECIES_NAMES: Record<SpeciesId, string> = {
|
||||
pikachu: 'Pikachu',
|
||||
}
|
||||
|
||||
/** Multilingual names */
|
||||
export const SPECIES_I18N: Record<SpeciesId, Record<string, string>> = {
|
||||
/** Curated multilingual names (falls back to English from Dex) */
|
||||
export const SPECIES_I18N: Partial<Record<string, Record<string, string>>> = {
|
||||
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
|
||||
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
|
||||
venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' },
|
||||
@@ -28,8 +28,8 @@ export const SPECIES_I18N: Record<SpeciesId, Record<string, string>> = {
|
||||
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
|
||||
}
|
||||
|
||||
/** Personality descriptions for each species */
|
||||
export const SPECIES_PERSONALITY: Record<SpeciesId, string> = {
|
||||
/** Curated personality descriptions (falls back to empty string) */
|
||||
export const SPECIES_PERSONALITY: Partial<Record<string, string>> = {
|
||||
bulbasaur: 'Calm and collected, a reliable partner',
|
||||
ivysaur: 'Steady growth, patient and resilient',
|
||||
venusaur: 'Majestic and powerful, a natural leader',
|
||||
|
||||
@@ -18,9 +18,9 @@ export const TO_DEX_STAT: Record<StatName, string> = {
|
||||
spAtk: 'spa', spDef: 'spd', speed: 'spe',
|
||||
}
|
||||
|
||||
/** Query species from Dex */
|
||||
/** Query species from Dex (uses Dex directly for full coverage) */
|
||||
export function getSpecies(id: string) {
|
||||
return gen.species.get(id)
|
||||
return Dex.species.get(id)
|
||||
}
|
||||
|
||||
/** Map Dex baseStats to our StatName format */
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
|
||||
import { getNextEvolution } from './evolution'
|
||||
import { SPECIES_I18N, SPECIES_PERSONALITY } from './names'
|
||||
|
||||
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
|
||||
// ─── Dynamic species list from @pkmn/sim Dex ───
|
||||
|
||||
const SUPPLEMENT: Record<SpeciesId, {
|
||||
const _rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
|
||||
const _ids: string[] = []
|
||||
for (const [id, s] of Object.entries(_rawSpecies)) {
|
||||
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
|
||||
_ids.push(id)
|
||||
}
|
||||
}
|
||||
_ids.sort((a, b) => (_rawSpecies[a]?.num ?? 9999) - (_rawSpecies[b]?.num ?? 9999))
|
||||
|
||||
/** All base species IDs from @pkmn/sim Dex (sorted by dex number) */
|
||||
export const ALL_SPECIES_IDS: SpeciesId[] = _ids
|
||||
|
||||
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
|
||||
// Only curated entries for species with known data; defaults used for others.
|
||||
|
||||
interface SupplementEntry {
|
||||
growthRate: GrowthRate
|
||||
captureRate: number
|
||||
baseHappiness: number
|
||||
flavorText: string
|
||||
}> = {
|
||||
}
|
||||
|
||||
const DEFAULT_SUPPLEMENT: SupplementEntry = {
|
||||
growthRate: 'medium-slow',
|
||||
captureRate: 45,
|
||||
baseHappiness: 70,
|
||||
flavorText: '',
|
||||
}
|
||||
|
||||
const SUPPLEMENT: Partial<Record<string, SupplementEntry>> = {
|
||||
bulbasaur: {
|
||||
growthRate: 'medium-slow',
|
||||
captureRate: 45,
|
||||
@@ -86,12 +110,11 @@ function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'
|
||||
|
||||
function buildSpeciesData(id: SpeciesId): SpeciesData {
|
||||
const dex = getSpecies(id)
|
||||
const sup = SUPPLEMENT[id]
|
||||
const sup = SUPPLEMENT[id] ?? DEFAULT_SUPPLEMENT
|
||||
const i18n = SPECIES_I18N[id]
|
||||
const personality = SPECIES_PERSONALITY[id]
|
||||
|
||||
if (!dex) {
|
||||
// Fallback if Dex somehow doesn't have the species (shouldn't happen for MVP)
|
||||
throw new Error(`Species ${id} not found in @pkmn/sim Dex`)
|
||||
}
|
||||
|
||||
@@ -175,17 +198,13 @@ export async function refreshAllSpeciesData(): Promise<void> {
|
||||
speciesCache.clear()
|
||||
}
|
||||
|
||||
// ─── Dex number mapping ───
|
||||
// ─── Dex number mapping (dynamic) ───
|
||||
|
||||
export const DEX_TO_SPECIES: Record<number, SpeciesId> = {
|
||||
1: 'bulbasaur',
|
||||
2: 'ivysaur',
|
||||
3: 'venusaur',
|
||||
4: 'charmander',
|
||||
5: 'charmeleon',
|
||||
6: 'charizard',
|
||||
7: 'squirtle',
|
||||
8: 'wartortle',
|
||||
9: 'blastoise',
|
||||
25: 'pikachu',
|
||||
}
|
||||
export const DEX_TO_SPECIES: Record<number, SpeciesId> = (() => {
|
||||
const map: Record<number, SpeciesId> = {}
|
||||
for (const id of ALL_SPECIES_IDS) {
|
||||
const s = _rawSpecies[id]
|
||||
if (s) map[s.num] = id
|
||||
}
|
||||
return map
|
||||
})()
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { SpeciesId } from '../types'
|
||||
|
||||
/**
|
||||
* Fallback ASCII art for when sprites can't be fetched.
|
||||
* Simple 5-line representations of each species.
|
||||
* Curated sprites for original 10 species; generic fallback for all others.
|
||||
*/
|
||||
const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
|
||||
const FALLBACK_SPRITES: Partial<Record<string, string[]>> = {
|
||||
bulbasaur: [
|
||||
' _,,--.,,_ ',
|
||||
' ,\' `, ',
|
||||
@@ -77,9 +77,18 @@ const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
|
||||
],
|
||||
}
|
||||
|
||||
/** Generic fallback sprite for species without curated ASCII art */
|
||||
const GENERIC_SPRITE: string[] = [
|
||||
' .---. ',
|
||||
' / o o \\ ',
|
||||
' | --- | ',
|
||||
' \\ / ',
|
||||
' `---\' ',
|
||||
]
|
||||
|
||||
/**
|
||||
* Get fallback ASCII sprite lines for a species.
|
||||
*/
|
||||
export function getFallbackSprite(speciesId: SpeciesId): string[] {
|
||||
return FALLBACK_SPRITES[speciesId] ?? FALLBACK_SPRITES.pikachu
|
||||
return FALLBACK_SPRITES[speciesId] ?? GENERIC_SPRITE
|
||||
}
|
||||
|
||||
@@ -10,31 +10,11 @@ export const STAT_LABELS: Record<StatName, string> = {
|
||||
speed: 'SPE',
|
||||
}
|
||||
|
||||
// Species IDs (MVP 10 species)
|
||||
export type SpeciesId =
|
||||
| 'bulbasaur'
|
||||
| 'ivysaur'
|
||||
| 'venusaur'
|
||||
| 'charmander'
|
||||
| 'charmeleon'
|
||||
| 'charizard'
|
||||
| 'squirtle'
|
||||
| 'wartortle'
|
||||
| 'blastoise'
|
||||
| 'pikachu'
|
||||
// Species IDs — dynamically populated from @pkmn/sim Dex (1025 species)
|
||||
export type SpeciesId = string
|
||||
|
||||
export const ALL_SPECIES_IDS: SpeciesId[] = [
|
||||
'bulbasaur',
|
||||
'ivysaur',
|
||||
'venusaur',
|
||||
'charmander',
|
||||
'charmeleon',
|
||||
'charizard',
|
||||
'squirtle',
|
||||
'wartortle',
|
||||
'blastoise',
|
||||
'pikachu',
|
||||
]
|
||||
// Re-exported from dex/species.ts (computed from Dex.data at module load)
|
||||
export { ALL_SPECIES_IDS } from './dex/species'
|
||||
|
||||
// Nature (delegated to @pkmn/sim Dex.natures)
|
||||
export type NatureName = string
|
||||
|
||||
@@ -2,12 +2,12 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { saveBuddyData } from '../core/storage'
|
||||
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
|
||||
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
||||
import { BattleConfigPanel } from './BattleConfigPanel'
|
||||
import { BattleScene, type MenuPhase } from './BattleScene'
|
||||
import { SpeciesPicker } from './SpeciesPicker'
|
||||
import { SwitchPanel } from './SwitchPanel'
|
||||
import { ItemPanel } from './ItemPanel'
|
||||
import { BattleResultPanel } from './BattleResultPanel'
|
||||
@@ -46,8 +46,6 @@ interface BattleFlowProps {
|
||||
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
|
||||
}
|
||||
|
||||
const VISIBLE_SPECIES = 7
|
||||
|
||||
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
|
||||
const [phase, setPhase] = useState<Phase>('config')
|
||||
const [buddyData, setBuddyData] = useState(initialData)
|
||||
@@ -58,7 +56,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
|
||||
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
||||
const [replaceIndex, setReplaceIndex] = useState(0)
|
||||
const [speciesIndex, setSpeciesIndex] = useState(0)
|
||||
const [configCursor, setConfigCursor] = useState(0)
|
||||
|
||||
// ─── Battle UI state ───
|
||||
@@ -272,7 +269,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
if (configCursor === 0) {
|
||||
handleRandomBattle()
|
||||
} else {
|
||||
setSpeciesIndex(ALL_SPECIES_IDS.indexOf(opponentSpeciesId))
|
||||
setPhase('configSelect')
|
||||
}
|
||||
}
|
||||
@@ -280,19 +276,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
}
|
||||
|
||||
if (phase === 'configSelect') {
|
||||
if (key.escape) {
|
||||
setPhase('config')
|
||||
} else if (key.upArrow) {
|
||||
const idx = speciesIndex > 0 ? speciesIndex - 1 : ALL_SPECIES_IDS.length - 1
|
||||
setSpeciesIndex(idx)
|
||||
setOpponentSpeciesId(ALL_SPECIES_IDS[idx]!)
|
||||
} else if (key.downArrow) {
|
||||
const idx = speciesIndex < ALL_SPECIES_IDS.length - 1 ? speciesIndex + 1 : 0
|
||||
setSpeciesIndex(idx)
|
||||
setOpponentSpeciesId(ALL_SPECIES_IDS[idx]!)
|
||||
} else if (key.return) {
|
||||
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
|
||||
}
|
||||
// SpeciesPicker handles its own input via FuzzyPicker/useInput
|
||||
return
|
||||
}
|
||||
|
||||
@@ -442,7 +426,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
if (key.return) handleEvolutionConfirm()
|
||||
return
|
||||
}
|
||||
}, [isActive, phase, menuPhase, cursorIndex, configCursor, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
|
||||
}, [isActive, phase, menuPhase, cursorIndex, configCursor, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
|
||||
|
||||
// Expose handleInput via ref
|
||||
useEffect(() => {
|
||||
@@ -497,7 +481,12 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
)
|
||||
|
||||
case 'configSelect':
|
||||
return renderSpeciesSelect()
|
||||
return (
|
||||
<SpeciesPicker
|
||||
onSelect={(speciesId) => handleStartBattle(speciesId, getActiveCreatureLevel())}
|
||||
onCancel={() => setPhase('config')}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'battle': {
|
||||
if (!battleState) return null
|
||||
@@ -573,65 +562,4 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Species select sub-render ───
|
||||
|
||||
function renderSpeciesSelect() {
|
||||
const total = ALL_SPECIES_IDS.length
|
||||
// Scroll window centered on selection
|
||||
const halfVisible = Math.floor(VISIBLE_SPECIES / 2)
|
||||
let startIdx = speciesIndex - halfVisible
|
||||
if (startIdx < 0) startIdx = 0
|
||||
if (startIdx + VISIBLE_SPECIES > total) startIdx = Math.max(0, total - VISIBLE_SPECIES)
|
||||
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + VISIBLE_SPECIES)
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 选择对手 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{/* Scroll indicator */}
|
||||
{total > VISIBLE_SPECIES && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>{startIdx > 0 ? ' ↑ 更多 ' : ''}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visibleSpecies.map((sid) => {
|
||||
const s = getSpeciesData(sid)
|
||||
const isSelected = sid === opponentSpeciesId
|
||||
return (
|
||||
<Box key={sid}>
|
||||
{isSelected ? (
|
||||
<Text color="success" bold> ▸ </Text>
|
||||
) : (
|
||||
<Text dimColor> </Text>
|
||||
)}
|
||||
<Text color={isSelected ? 'claude' : 'inactive'} bold={isSelected}>
|
||||
#{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<Text dimColor> Lv.{getActiveCreatureLevel()}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Scroll indicator */}
|
||||
{total > VISIBLE_SPECIES && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>{startIdx + VISIBLE_SPECIES < total ? ' ↓ 更多 ' : ''}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] 选择 · [Enter] 确认 · [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
79
packages/pokemon/src/ui/SpeciesPicker.tsx
Normal file
79
packages/pokemon/src/ui/SpeciesPicker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { Box, Text, FuzzyPicker } from '@anthropic/ink'
|
||||
import type { SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
|
||||
/** Pre-computed species entry for picker */
|
||||
type SpeciesEntry = {
|
||||
id: SpeciesId
|
||||
name: string
|
||||
displayName: string // zh name or English name
|
||||
dexNumber: number
|
||||
types: string[]
|
||||
}
|
||||
|
||||
// Build all entries once (species data is cached internally by getSpeciesData)
|
||||
const ALL_ENTRIES: SpeciesEntry[] = ALL_SPECIES_IDS.map(id => {
|
||||
const data = getSpeciesData(id)
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
displayName: (data.names as Record<string, string>).zh ?? data.name,
|
||||
dexNumber: data.dexNumber,
|
||||
types: data.types as string[],
|
||||
}
|
||||
})
|
||||
|
||||
/** Searchable species picker using FuzzyPicker */
|
||||
export function SpeciesPicker({
|
||||
onSelect,
|
||||
onCancel,
|
||||
title = '选择精灵',
|
||||
}: {
|
||||
onSelect: (speciesId: SpeciesId) => void
|
||||
onCancel: () => void
|
||||
title?: string
|
||||
}) {
|
||||
const [filtered, setFiltered] = useState<SpeciesEntry[]>(ALL_ENTRIES.slice(0, 50))
|
||||
|
||||
const handleQueryChange = useCallback((q: string) => {
|
||||
if (!q.trim()) {
|
||||
setFiltered(ALL_ENTRIES.slice(0, 50))
|
||||
return
|
||||
}
|
||||
const lower = q.toLowerCase()
|
||||
const matched = ALL_ENTRIES.filter(e =>
|
||||
e.id.includes(lower) ||
|
||||
e.name.toLowerCase().includes(lower) ||
|
||||
e.displayName.includes(q) ||
|
||||
String(e.dexNumber).includes(q)
|
||||
)
|
||||
setFiltered(matched.slice(0, 100))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<FuzzyPicker<SpeciesEntry>
|
||||
title={title}
|
||||
placeholder="输入名称或编号搜索…"
|
||||
items={filtered}
|
||||
getKey={item => item.id}
|
||||
renderItem={(item, focused) => (
|
||||
<Box>
|
||||
<Text color={focused ? 'claude' : undefined} bold={focused}>
|
||||
#{String(item.dexNumber).padStart(3, '0')} {item.displayName}
|
||||
</Text>
|
||||
{item.displayName !== item.name && (
|
||||
<Text dimColor> {item.name}</Text>
|
||||
)}
|
||||
<Text color="inactive"> {item.types.join('/')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
onQueryChange={handleQueryChange}
|
||||
onSelect={item => onSelect(item.id)}
|
||||
onCancel={onCancel}
|
||||
emptyMessage={q => `没有找到 "${q}" 相关的精灵`}
|
||||
matchLabel={filtered.length < ALL_ENTRIES.length ? `${filtered.length} 个结果` : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user