mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +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()
|
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 })
|
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)', () => {
|
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
|
||||||
@@ -118,7 +120,7 @@ describe('canEvolveFurther', () => {
|
|||||||
expect(canEvolveFurther('blastoise')).toBe(false)
|
expect(canEvolveFurther('blastoise')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pikachu cannot evolve in MVP', () => {
|
test('pikachu can evolve into raichu', () => {
|
||||||
expect(canEvolveFurther('pikachu')).toBe(false)
|
expect(canEvolveFurther('pikachu')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,16 +10,21 @@ describe('getFallbackSprite', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns pikachu fallback for unknown species', () => {
|
test('returns generic fallback for unknown species', () => {
|
||||||
const sprite = getFallbackSprite('unknown' as any)
|
const sprite = getFallbackSprite('unknowndefinitelynotarealspecies')
|
||||||
expect(sprite).toEqual(getFallbackSprite('pikachu'))
|
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', () => {
|
test('each line has consistent width', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
const sprite = getFallbackSprite(id)
|
const sprite = getFallbackSprite(id)
|
||||||
const widths = sprite.map(line => line.length)
|
const widths = sprite.map(line => line.length)
|
||||||
// All lines should be roughly the same width
|
|
||||||
const maxWidth = Math.max(...widths)
|
const maxWidth = Math.max(...widths)
|
||||||
const minWidth = Math.min(...widths)
|
const minWidth = Math.min(...widths)
|
||||||
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
|
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { describe, test, expect } from 'bun:test'
|
import { describe, test, expect } from 'bun:test'
|
||||||
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/names'
|
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', () => {
|
describe('SPECIES_NAMES', () => {
|
||||||
test('has name for every species', () => {
|
test('has name for curated species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of CURATED) {
|
||||||
expect(SPECIES_NAMES[id]).toBeTruthy()
|
expect(SPECIES_NAMES[id]).toBeTruthy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -15,8 +22,8 @@ describe('SPECIES_NAMES', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('SPECIES_I18N', () => {
|
describe('SPECIES_I18N', () => {
|
||||||
test('has i18n for every species', () => {
|
test('has i18n for curated species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of CURATED) {
|
||||||
expect(SPECIES_I18N[id]).toBeTruthy()
|
expect(SPECIES_I18N[id]).toBeTruthy()
|
||||||
expect(SPECIES_I18N[id]!.en).toBeTruthy()
|
expect(SPECIES_I18N[id]!.en).toBeTruthy()
|
||||||
}
|
}
|
||||||
@@ -29,14 +36,14 @@ describe('SPECIES_I18N', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('SPECIES_PERSONALITY', () => {
|
describe('SPECIES_PERSONALITY', () => {
|
||||||
test('has personality for every species', () => {
|
test('has personality for curated species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of CURATED) {
|
||||||
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
|
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('personality is non-empty string', () => {
|
test('personality is non-empty string for curated species', () => {
|
||||||
for (const id of ALL_SPECIES_IDS) {
|
for (const id of CURATED) {
|
||||||
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
|
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
|
||||||
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
|
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Dex } from '@pkmn/sim'
|
import { Dex } from '@pkmn/sim'
|
||||||
import type { SpeciesId } from '../types'
|
import type { SpeciesId } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface EvolutionChainStep {
|
export interface EvolutionChainStep {
|
||||||
from: SpeciesId
|
from: SpeciesId
|
||||||
@@ -18,7 +15,6 @@ export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | und
|
|||||||
|
|
||||||
// Take the first evolution target (most species have single evo path)
|
// Take the first evolution target (most species have single evo path)
|
||||||
const target = dex.evos[0]!.toLowerCase()
|
const target = dex.evos[0]!.toLowerCase()
|
||||||
if (!ALL_SPECIES_IDS.includes(target as SpeciesId)) return undefined
|
|
||||||
|
|
||||||
const targetDex = Dex.species.get(target)
|
const targetDex = Dex.species.get(target)
|
||||||
if (!targetDex?.exists) return undefined
|
if (!targetDex?.exists) return undefined
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { SpeciesId } from '../types'
|
import type { SpeciesId } from '../types'
|
||||||
|
|
||||||
/** Default names for each species (English) */
|
/** Curated English names (Dex provides default names for all species) */
|
||||||
export const SPECIES_NAMES: Record<SpeciesId, string> = {
|
export const SPECIES_NAMES: Partial<Record<string, string>> = {
|
||||||
bulbasaur: 'Bulbasaur',
|
bulbasaur: 'Bulbasaur',
|
||||||
ivysaur: 'Ivysaur',
|
ivysaur: 'Ivysaur',
|
||||||
venusaur: 'Venusaur',
|
venusaur: 'Venusaur',
|
||||||
@@ -14,8 +14,8 @@ export const SPECIES_NAMES: Record<SpeciesId, string> = {
|
|||||||
pikachu: 'Pikachu',
|
pikachu: 'Pikachu',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Multilingual names */
|
/** Curated multilingual names (falls back to English from Dex) */
|
||||||
export const SPECIES_I18N: Record<SpeciesId, Record<string, string>> = {
|
export const SPECIES_I18N: Partial<Record<string, Record<string, string>>> = {
|
||||||
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
|
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
|
||||||
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
|
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
|
||||||
venusaur: { en: 'Venusaur', 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: '皮卡丘' },
|
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Personality descriptions for each species */
|
/** Curated personality descriptions (falls back to empty string) */
|
||||||
export const SPECIES_PERSONALITY: Record<SpeciesId, string> = {
|
export const SPECIES_PERSONALITY: Partial<Record<string, string>> = {
|
||||||
bulbasaur: 'Calm and collected, a reliable partner',
|
bulbasaur: 'Calm and collected, a reliable partner',
|
||||||
ivysaur: 'Steady growth, patient and resilient',
|
ivysaur: 'Steady growth, patient and resilient',
|
||||||
venusaur: 'Majestic and powerful, a natural leader',
|
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',
|
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) {
|
export function getSpecies(id: string) {
|
||||||
return gen.species.get(id)
|
return Dex.species.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map Dex baseStats to our StatName format */
|
/** Map Dex baseStats to our StatName format */
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
|
import { Dex } from '@pkmn/sim'
|
||||||
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
|
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
|
||||||
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
|
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
|
||||||
import { getNextEvolution } from './evolution'
|
import { getNextEvolution } from './evolution'
|
||||||
import { SPECIES_I18N, SPECIES_PERSONALITY } from './names'
|
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
|
growthRate: GrowthRate
|
||||||
captureRate: number
|
captureRate: number
|
||||||
baseHappiness: number
|
baseHappiness: number
|
||||||
flavorText: string
|
flavorText: string
|
||||||
}> = {
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SUPPLEMENT: SupplementEntry = {
|
||||||
|
growthRate: 'medium-slow',
|
||||||
|
captureRate: 45,
|
||||||
|
baseHappiness: 70,
|
||||||
|
flavorText: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPLEMENT: Partial<Record<string, SupplementEntry>> = {
|
||||||
bulbasaur: {
|
bulbasaur: {
|
||||||
growthRate: 'medium-slow',
|
growthRate: 'medium-slow',
|
||||||
captureRate: 45,
|
captureRate: 45,
|
||||||
@@ -86,12 +110,11 @@ function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'
|
|||||||
|
|
||||||
function buildSpeciesData(id: SpeciesId): SpeciesData {
|
function buildSpeciesData(id: SpeciesId): SpeciesData {
|
||||||
const dex = getSpecies(id)
|
const dex = getSpecies(id)
|
||||||
const sup = SUPPLEMENT[id]
|
const sup = SUPPLEMENT[id] ?? DEFAULT_SUPPLEMENT
|
||||||
const i18n = SPECIES_I18N[id]
|
const i18n = SPECIES_I18N[id]
|
||||||
const personality = SPECIES_PERSONALITY[id]
|
const personality = SPECIES_PERSONALITY[id]
|
||||||
|
|
||||||
if (!dex) {
|
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`)
|
throw new Error(`Species ${id} not found in @pkmn/sim Dex`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,17 +198,13 @@ export async function refreshAllSpeciesData(): Promise<void> {
|
|||||||
speciesCache.clear()
|
speciesCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dex number mapping ───
|
// ─── Dex number mapping (dynamic) ───
|
||||||
|
|
||||||
export const DEX_TO_SPECIES: Record<number, SpeciesId> = {
|
export const DEX_TO_SPECIES: Record<number, SpeciesId> = (() => {
|
||||||
1: 'bulbasaur',
|
const map: Record<number, SpeciesId> = {}
|
||||||
2: 'ivysaur',
|
for (const id of ALL_SPECIES_IDS) {
|
||||||
3: 'venusaur',
|
const s = _rawSpecies[id]
|
||||||
4: 'charmander',
|
if (s) map[s.num] = id
|
||||||
5: 'charmeleon',
|
}
|
||||||
6: 'charizard',
|
return map
|
||||||
7: 'squirtle',
|
})()
|
||||||
8: 'wartortle',
|
|
||||||
9: 'blastoise',
|
|
||||||
25: 'pikachu',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { SpeciesId } from '../types'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback ASCII art for when sprites can't be fetched.
|
* 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: [
|
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.
|
* Get fallback ASCII sprite lines for a species.
|
||||||
*/
|
*/
|
||||||
export function getFallbackSprite(speciesId: SpeciesId): string[] {
|
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',
|
speed: 'SPE',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Species IDs (MVP 10 species)
|
// Species IDs — dynamically populated from @pkmn/sim Dex (1025 species)
|
||||||
export type SpeciesId =
|
export type SpeciesId = string
|
||||||
| 'bulbasaur'
|
|
||||||
| 'ivysaur'
|
|
||||||
| 'venusaur'
|
|
||||||
| 'charmander'
|
|
||||||
| 'charmeleon'
|
|
||||||
| 'charizard'
|
|
||||||
| 'squirtle'
|
|
||||||
| 'wartortle'
|
|
||||||
| 'blastoise'
|
|
||||||
| 'pikachu'
|
|
||||||
|
|
||||||
export const ALL_SPECIES_IDS: SpeciesId[] = [
|
// Re-exported from dex/species.ts (computed from Dex.data at module load)
|
||||||
'bulbasaur',
|
export { ALL_SPECIES_IDS } from './dex/species'
|
||||||
'ivysaur',
|
|
||||||
'venusaur',
|
|
||||||
'charmander',
|
|
||||||
'charmeleon',
|
|
||||||
'charizard',
|
|
||||||
'squirtle',
|
|
||||||
'wartortle',
|
|
||||||
'blastoise',
|
|
||||||
'pikachu',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Nature (delegated to @pkmn/sim Dex.natures)
|
// Nature (delegated to @pkmn/sim Dex.natures)
|
||||||
export type NatureName = string
|
export type NatureName = string
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'
|
|||||||
import { Box, Text } from '@anthropic/ink'
|
import { Box, Text } from '@anthropic/ink'
|
||||||
import type { BuddyData, Creature, SpeciesId } from '../types'
|
import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||||
import { ALL_SPECIES_IDS } from '../types'
|
import { ALL_SPECIES_IDS } from '../types'
|
||||||
import { getSpeciesData } from '../dex/species'
|
|
||||||
import { saveBuddyData } from '../core/storage'
|
import { saveBuddyData } from '../core/storage'
|
||||||
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
|
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
|
||||||
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
||||||
import { BattleConfigPanel } from './BattleConfigPanel'
|
import { BattleConfigPanel } from './BattleConfigPanel'
|
||||||
import { BattleScene, type MenuPhase } from './BattleScene'
|
import { BattleScene, type MenuPhase } from './BattleScene'
|
||||||
|
import { SpeciesPicker } from './SpeciesPicker'
|
||||||
import { SwitchPanel } from './SwitchPanel'
|
import { SwitchPanel } from './SwitchPanel'
|
||||||
import { ItemPanel } from './ItemPanel'
|
import { ItemPanel } from './ItemPanel'
|
||||||
import { BattleResultPanel } from './BattleResultPanel'
|
import { BattleResultPanel } from './BattleResultPanel'
|
||||||
@@ -46,8 +46,6 @@ interface BattleFlowProps {
|
|||||||
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
|
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const VISIBLE_SPECIES = 7
|
|
||||||
|
|
||||||
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
|
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
|
||||||
const [phase, setPhase] = useState<Phase>('config')
|
const [phase, setPhase] = useState<Phase>('config')
|
||||||
const [buddyData, setBuddyData] = useState(initialData)
|
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 [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
|
||||||
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
||||||
const [replaceIndex, setReplaceIndex] = useState(0)
|
const [replaceIndex, setReplaceIndex] = useState(0)
|
||||||
const [speciesIndex, setSpeciesIndex] = useState(0)
|
|
||||||
const [configCursor, setConfigCursor] = useState(0)
|
const [configCursor, setConfigCursor] = useState(0)
|
||||||
|
|
||||||
// ─── Battle UI state ───
|
// ─── Battle UI state ───
|
||||||
@@ -272,7 +269,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
|||||||
if (configCursor === 0) {
|
if (configCursor === 0) {
|
||||||
handleRandomBattle()
|
handleRandomBattle()
|
||||||
} else {
|
} else {
|
||||||
setSpeciesIndex(ALL_SPECIES_IDS.indexOf(opponentSpeciesId))
|
|
||||||
setPhase('configSelect')
|
setPhase('configSelect')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,19 +276,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (phase === 'configSelect') {
|
if (phase === 'configSelect') {
|
||||||
if (key.escape) {
|
// SpeciesPicker handles its own input via FuzzyPicker/useInput
|
||||||
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)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +426,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
|||||||
if (key.return) handleEvolutionConfirm()
|
if (key.return) handleEvolutionConfirm()
|
||||||
return
|
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
|
// Expose handleInput via ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -497,7 +481,12 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
|||||||
)
|
)
|
||||||
|
|
||||||
case 'configSelect':
|
case 'configSelect':
|
||||||
return renderSpeciesSelect()
|
return (
|
||||||
|
<SpeciesPicker
|
||||||
|
onSelect={(speciesId) => handleStartBattle(speciesId, getActiveCreatureLevel())}
|
||||||
|
onCancel={() => setPhase('config')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
case 'battle': {
|
case 'battle': {
|
||||||
if (!battleState) return null
|
if (!battleState) return null
|
||||||
@@ -573,65 +562,4 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
|||||||
default:
|
default:
|
||||||
return null
|
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