From 1217c453c4bbcb3860fbfcf6662cc05938701a9e Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 22 Apr 2026 15:15:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=20pkmn=20Dex=20?= =?UTF-8?q?=E5=85=A8=E9=83=A8=201025=20=E5=8F=AA=E7=B2=BE=E7=81=B5?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=20SpeciesPicker=20=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../pokemon/src/__tests__/evolution.test.ts | 10 ++- .../pokemon/src/__tests__/fallback.test.ts | 13 ++- packages/pokemon/src/__tests__/names.test.ts | 25 ++++-- packages/pokemon/src/dex/evolution.ts | 4 - packages/pokemon/src/dex/names.ts | 12 +-- packages/pokemon/src/dex/pkmn.ts | 4 +- packages/pokemon/src/dex/species.ts | 57 ++++++++---- packages/pokemon/src/sprites/fallback.ts | 15 +++- packages/pokemon/src/types.ts | 28 +----- packages/pokemon/src/ui/BattleFlow.tsx | 90 ++----------------- packages/pokemon/src/ui/SpeciesPicker.tsx | 79 ++++++++++++++++ 11 files changed, 181 insertions(+), 156 deletions(-) create mode 100644 packages/pokemon/src/ui/SpeciesPicker.tsx diff --git a/packages/pokemon/src/__tests__/evolution.test.ts b/packages/pokemon/src/__tests__/evolution.test.ts index 07a68514a..05c6e10da 100644 --- a/packages/pokemon/src/__tests__/evolution.test.ts +++ b/packages/pokemon/src/__tests__/evolution.test.ts @@ -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) }) }) diff --git a/packages/pokemon/src/__tests__/fallback.test.ts b/packages/pokemon/src/__tests__/fallback.test.ts index 48372340b..65b208884 100644 --- a/packages/pokemon/src/__tests__/fallback.test.ts +++ b/packages/pokemon/src/__tests__/fallback.test.ts @@ -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) diff --git a/packages/pokemon/src/__tests__/names.test.ts b/packages/pokemon/src/__tests__/names.test.ts index 20916cdca..cd367257d 100644 --- a/packages/pokemon/src/__tests__/names.test.ts +++ b/packages/pokemon/src/__tests__/names.test.ts @@ -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) } diff --git a/packages/pokemon/src/dex/evolution.ts b/packages/pokemon/src/dex/evolution.ts index 5b276c4a0..b4f6ad0ef 100644 --- a/packages/pokemon/src/dex/evolution.ts +++ b/packages/pokemon/src/dex/evolution.ts @@ -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 diff --git a/packages/pokemon/src/dex/names.ts b/packages/pokemon/src/dex/names.ts index d88e5ab1b..8e571702c 100644 --- a/packages/pokemon/src/dex/names.ts +++ b/packages/pokemon/src/dex/names.ts @@ -1,7 +1,7 @@ import type { SpeciesId } from '../types' -/** Default names for each species (English) */ -export const SPECIES_NAMES: Record = { +/** Curated English names (Dex provides default names for all species) */ +export const SPECIES_NAMES: Partial> = { bulbasaur: 'Bulbasaur', ivysaur: 'Ivysaur', venusaur: 'Venusaur', @@ -14,8 +14,8 @@ export const SPECIES_NAMES: Record = { pikachu: 'Pikachu', } -/** Multilingual names */ -export const SPECIES_I18N: Record> = { +/** Curated multilingual names (falls back to English from Dex) */ +export const SPECIES_I18N: Partial>> = { bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' }, ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' }, venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' }, @@ -28,8 +28,8 @@ export const SPECIES_I18N: Record> = { pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' }, } -/** Personality descriptions for each species */ -export const SPECIES_PERSONALITY: Record = { +/** Curated personality descriptions (falls back to empty string) */ +export const SPECIES_PERSONALITY: Partial> = { bulbasaur: 'Calm and collected, a reliable partner', ivysaur: 'Steady growth, patient and resilient', venusaur: 'Majestic and powerful, a natural leader', diff --git a/packages/pokemon/src/dex/pkmn.ts b/packages/pokemon/src/dex/pkmn.ts index aab2da6ee..2de59a9f2 100644 --- a/packages/pokemon/src/dex/pkmn.ts +++ b/packages/pokemon/src/dex/pkmn.ts @@ -18,9 +18,9 @@ export const TO_DEX_STAT: Record = { 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 */ diff --git a/packages/pokemon/src/dex/species.ts b/packages/pokemon/src/dex/species.ts index 1bfd02342..61cc21f64 100644 --- a/packages/pokemon/src/dex/species.ts +++ b/packages/pokemon/src/dex/species.ts @@ -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 +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> = { 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 { speciesCache.clear() } -// ─── Dex number mapping ─── +// ─── Dex number mapping (dynamic) ─── -export const DEX_TO_SPECIES: Record = { - 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 = (() => { + const map: Record = {} + for (const id of ALL_SPECIES_IDS) { + const s = _rawSpecies[id] + if (s) map[s.num] = id + } + return map +})() diff --git a/packages/pokemon/src/sprites/fallback.ts b/packages/pokemon/src/sprites/fallback.ts index 1688431b8..0af30f948 100644 --- a/packages/pokemon/src/sprites/fallback.ts +++ b/packages/pokemon/src/sprites/fallback.ts @@ -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 = { +const FALLBACK_SPRITES: Partial> = { bulbasaur: [ ' _,,--.,,_ ', ' ,\' `, ', @@ -77,9 +77,18 @@ const FALLBACK_SPRITES: Record = { ], } +/** 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 } diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts index d33b3f66b..684245e44 100644 --- a/packages/pokemon/src/types.ts +++ b/packages/pokemon/src/types.ts @@ -10,31 +10,11 @@ export const STAT_LABELS: Record = { 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 diff --git a/packages/pokemon/src/ui/BattleFlow.tsx b/packages/pokemon/src/ui/BattleFlow.tsx index 95a75816a..32c5c96b4 100644 --- a/packages/pokemon/src/ui/BattleFlow.tsx +++ b/packages/pokemon/src/ui/BattleFlow.tsx @@ -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 } -const VISIBLE_SPECIES = 7 - export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) { const [phase, setPhase] = useState('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 ( + 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 ( - - {/* Scroll indicator */} - {total > VISIBLE_SPECIES && ( - - {startIdx > 0 ? ' ↑ 更多 ' : ''} - - )} - - {visibleSpecies.map((sid) => { - const s = getSpeciesData(sid) - const isSelected = sid === opponentSpeciesId - return ( - - {isSelected ? ( - - ) : ( - - )} - - #{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name} - - {isSelected && ( - Lv.{getActiveCreatureLevel()} - )} - - ) - })} - - {/* Scroll indicator */} - {total > VISIBLE_SPECIES && ( - - {startIdx + VISIBLE_SPECIES < total ? ' ↓ 更多 ' : ''} - - )} - - - [↑↓] 选择 · [Enter] 确认 · [ESC] 返回 - - - ) - } } diff --git a/packages/pokemon/src/ui/SpeciesPicker.tsx b/packages/pokemon/src/ui/SpeciesPicker.tsx new file mode 100644 index 000000000..ae2a3c989 --- /dev/null +++ b/packages/pokemon/src/ui/SpeciesPicker.tsx @@ -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).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(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 ( + + title={title} + placeholder="输入名称或编号搜索…" + items={filtered} + getKey={item => item.id} + renderItem={(item, focused) => ( + + + #{String(item.dexNumber).padStart(3, '0')} {item.displayName} + + {item.displayName !== item.name && ( + {item.name} + )} + {item.types.join('/')} + + )} + onQueryChange={handleQueryChange} + onSelect={item => onSelect(item.id)} + onCancel={onCancel} + emptyMessage={q => `没有找到 "${q}" 相关的精灵`} + matchLabel={filtered.length < ALL_ENTRIES.length ? `${filtered.length} 个结果` : undefined} + /> + ) +}