feat: 又一大波改动

This commit is contained in:
claude-code-best
2026-04-23 11:20:24 +08:00
parent 1a910ed639
commit ecf2dbde44
7 changed files with 147 additions and 56 deletions

View File

@@ -29,10 +29,15 @@ function creatureToSetString(creature: Creature): string {
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
const moves = creature.moves
let moves = creature.moves
.filter(m => m.id)
.map(m => Dex.moves.get(m.id)?.name ?? m.id)
// Fallback: if no valid moves, use type-based defaults
if (moves.length === 0) {
moves = getSpeciesMoves(creature.speciesId, creature.level)
}
const DEX_DISPLAY: Record<string, string> = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }
const formatStatLine = (vals: Record<string, number>) =>
STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ')

View File

@@ -4,7 +4,7 @@ import { STAT_NAMES } from '../types'
import { getSpeciesData } from '../dex/species'
import { determineGender } from './gender'
import { levelFromXp } from '../dex/xpTable'
import { gen, TO_DEX_STAT } from '../dex/pkmn'
import { gen, TO_DEX_STAT, getSpecies } from '../dex/pkmn'
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
import { randomNature } from '../dex/nature'
@@ -49,7 +49,7 @@ export async function generateCreature(speciesId: SpeciesId, seed?: number): Pro
* Handles base stats, IV, EV, level, and nature correction internally.
*/
export function calculateStats(creature: Creature): StatsResult {
const species = gen.species.get(creature.speciesId)
const species = getSpecies(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
// Get nature if creature has one (Phase 1 adds nature field)

View File

@@ -4,22 +4,41 @@ import { EMPTY_MOVE } from '../types'
const GEN = 9
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
/** Get raw learnset data from Dex.data (synchronous, always available) */
function getLearnsetData(speciesId: SpeciesId): Record<string, string[]> | null {
const entry = Dex.data.Learnsets[speciesId]
return entry?.learnset ?? null
}
const levelUpMoves: { id: string; level: number }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) })
break
/**
* Get level-up moves for a species.
* Prefers the current gen (9L), falls back to the latest available gen.
*/
function getLevelUpMoves(learnset: Record<string, string[]>): { id: string; level: number }[] {
// Collect level-up moves, preferring highest-gen data per move
const moveMap = new Map<string, { id: string; level: number; gen: number }>()
for (const [moveId, sources] of Object.entries(learnset)) {
for (const src of sources) {
const match = src.match(/^(\d+)L(\d+)$/)
if (match) {
const gen = parseInt(match[1]!)
const level = parseInt(match[2]!)
const existing = moveMap.get(moveId)
if (!existing || gen > existing.gen) {
moveMap.set(moveId, { id: moveId, level, gen })
}
}
}
}
return Array.from(moveMap.values()).sort((a, b) => a.level - b.level)
}
levelUpMoves.sort((a, b) => a.level - b.level)
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
const learnset = getLearnsetData(speciesId)
if (!learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
const levelUpMoves = getLevelUpMoves(learnset)
const available = levelUpMoves.filter(m => m.level <= level).slice(-4)
const slots: MoveSlot[] = available.map(m => {
@@ -39,21 +58,14 @@ export function getDefaultAbility(speciesId: SpeciesId): string {
/** Get newly learnable moves when leveling up */
export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return []
const learnset = getLearnsetData(speciesId)
if (!learnset) return []
const result: { id: string; name: string }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
result.push({ id: moveId, name: dexMove?.name ?? moveId })
}
break
}
}
}
return result
const levelUpMoves = getLevelUpMoves(learnset)
return levelUpMoves
.filter(m => m.level > oldLevel && m.level <= newLevel)
.map(m => {
const dexMove = Dex.moves.get(m.id)
return { id: m.id, name: dexMove?.name ?? m.id }
})
}

View File

@@ -1,8 +1,8 @@
import React, { useMemo } from 'react'
import React, { useState, useEffect } from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BattleState, WeatherKind } from '../battle/types'
import type { SpeciesId } from '../types'
import { loadSprite } from '../core/spriteCache'
import { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'
import { HpCard } from './HpCard'
import { BattleMenu } from './BattleMenu'
@@ -12,11 +12,16 @@ import type { StatusCondition } from '../battle/types'
export type MenuPhase = 'main' | 'fight' | 'bag' | 'pokemon'
/** Get sprite lines: try cache → fallback */
function getSpriteLines(speciesId: SpeciesId): string[] {
/** Hook: get sprite lines with async fetch fallback */
function useSpriteLines(speciesId: SpeciesId): string[] {
const [tick, setTick] = useState(0)
useEffect(() => {
if (loadSprite(speciesId)) return
fetchAndCacheSprite(speciesId).then(s => { if (s) setTick(t => t + 1) })
}, [speciesId])
void tick
const cached = loadSprite(speciesId)
if (cached) return cached.lines
return getFallbackSprite(speciesId)
return cached?.lines ?? getFallbackSprite(speciesId)
}
interface BattleSceneProps {
@@ -51,9 +56,9 @@ export function BattleScene({
const opp = state.opponentPokemon
const player = state.playerPokemon
// Load sprite lines (memoized by speciesId)
const oppSpriteLines = useMemo(() => getSpriteLines(opp.speciesId as SpeciesId), [opp.speciesId])
const playerSpriteLines = useMemo(() => getSpriteLines(player.speciesId as SpeciesId), [player.speciesId])
// Load sprite lines (with async fetch for uncached species)
const oppSpriteLines = useSpriteLines(opp.speciesId as SpeciesId)
const playerSpriteLines = useSpriteLines(player.speciesId as SpeciesId)
return (
<Box flexDirection="row" width="100%">
@@ -78,8 +83,8 @@ export function BattleScene({
overlay
) : (
<>
{/* Opponent: HP card left, sprite right */}
<Box flexDirection="row" justifyContent="space-between">
{/* Opponent info */}
<Box flexDirection="row" justifyContent="flex-start">
<HpCard
name={opp.name}
level={opp.level}
@@ -89,20 +94,33 @@ export function BattleScene({
align="left"
isOpponent
/>
<BattleSprite
lines={oppSpriteLines}
animEnabled={animEnabled}
/>
</Box>
{/* Player: overlaps opponent area by pulling up */}
<Box flexDirection="row" justifyContent="space-between" alignItems="flex-end" marginTop={-10}>
<BattleSprite
lines={playerSpriteLines}
flip
phaseOffset={2}
animEnabled={animEnabled}
/>
{/*
Keep the overlapping sprites inside a fixed-height battlefield with absolute positioning.
Do NOT switch this back to negative margins or normal-flow overlap: Ink/Yoga reflow can leave
visual ghosting above the player sprite during animation when overlap affects outer layout.
*/}
{/* Overlapped battlefield: fixed-height container so overlap won't disturb outer layout */}
<Box height={18} marginTop={1} marginBottom={1} overflow="hidden">
<Box position="absolute" top={0} right={0}>
<BattleSprite
lines={oppSpriteLines}
animEnabled={animEnabled}
/>
</Box>
<Box position="absolute" bottom={0} left={0}>
<BattleSprite
lines={playerSpriteLines}
flip
phaseOffset={2}
animEnabled={animEnabled}
/>
</Box>
</Box>
{/* Player info */}
<Box flexDirection="row" justifyContent="flex-end">
<HpCard
name={player.name}
level={player.level}

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { getSpeciesData } from '../dex/species'
import { loadSprite } from '../core/spriteCache'
import { loadSprite, fetchAndCacheSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'
const YELLOW: Color = 'ansi:yellow'
@@ -22,8 +22,19 @@ interface EvolutionAnimProps {
*/
export function EvolutionAnim({ fromSpecies, toSpecies, onComplete }: EvolutionAnimProps) {
const [tick, setTick] = useState(0)
const [spriteTick, setSpriteTick] = useState(0)
const totalFrames = 8
// Prefetch sprites for both species
useEffect(() => {
for (const id of [fromSpecies, toSpecies]) {
if (!loadSprite(id)) {
fetchAndCacheSprite(id).then(s => { if (s) setSpriteTick(t => t + 1) })
}
}
}, [fromSpecies, toSpecies])
void spriteTick
useEffect(() => {
if (tick >= totalFrames) {
onComplete()

View File

@@ -13,6 +13,7 @@ import {
getCreatureName,
getXpProgress,
loadSprite,
fetchAndCacheSprite,
getFallbackSprite,
renderAnimatedSprite,
getIdleAnimMode,
@@ -165,10 +166,19 @@ export function CompanionSprite(): React.ReactNode {
const xpInfo = useAppState(s => s.companionXpInfo);
const focused = useAppState(s => s.footerSelection === 'companion');
// Subscribe to creature changes so we re-render immediately after switch
const _creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
const creatureChangedAt = useAppState(s => s.companionCreatureChangedAt);
const setAppState = useSetAppState();
const { columns } = useTerminalSize();
const [tick, setTick] = useState(0);
const [spriteTick, setSpriteTick] = useState(0);
// Prefetch sprite when creature changes
useEffect(() => {
const c = getPokemonCreature();
if (!c || loadSprite(c.speciesId)) return;
fetchAndCacheSprite(c.speciesId).then(s => { if (s) setSpriteTick(t => t + 1) });
}, [creatureChangedAt]);
void spriteTick;
const lastSpokeTick = useRef(0);
const [{ petStartTick, forPetAt }, setPetStart] = useState({
petStartTick: 0,

View File

@@ -19,7 +19,7 @@ import { calculateStats, getCreatureName, getTotalEV, getActiveCreature, saveBud
import { getXpProgress } from '@claude-code-best/pokemon';
import { getGenderSymbol } from '@claude-code-best/pokemon';
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite, SpeciesPicker } from '@claude-code-best/pokemon';
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite, fetchAndCacheSprite, SpeciesPicker } from '@claude-code-best/pokemon';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
const CYAN: Color = 'ansi:cyan';
@@ -136,6 +136,7 @@ function PartyView({
const [focusedSlot, setFocusedSlot] = useState(0);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [tick, setTick] = useState(0); // force re-render on navigation
const [spriteTick, setSpriteTick] = useState(0); // force re-render after sprite fetch
useInput((_input, key) => {
if (!isActive) return;
@@ -171,6 +172,31 @@ function PartyView({
? data.creatures.find(c => c.id === focusedCreatureId) ?? null
: null;
// Async-fetch sprite for focused creature if not cached
React.useEffect(() => {
if (!focusedCreature) return;
if (loadSprite(focusedCreature.speciesId)) return;
fetchAndCacheSprite(focusedCreature.speciesId).then((sprite) => {
if (sprite) setSpriteTick(t => t + 1);
});
}, [focusedCreature?.speciesId]);
// Also prefetch sprites for all party members on mount
React.useEffect(() => {
for (const id of data.party) {
if (!id) continue;
const c = data.creatures.find(cr => cr.id === id);
if (c && !loadSprite(c.speciesId)) {
fetchAndCacheSprite(c.speciesId).then((sprite) => {
if (sprite) setSpriteTick(t => t + 1);
});
}
}
}, []);
// Consume spriteTick to avoid unused warning
void spriteTick;
// Load sprite for focused creature (not just active)
const focusedSprite = focusedCreature
? (loadSprite(focusedCreature.speciesId)?.lines ?? getFallbackSprite(focusedCreature.speciesId))
@@ -417,6 +443,15 @@ function DexTab({
const [focusedId, setFocusedId] = useState<SpeciesId>(buddyData.dex[0]?.speciesId ?? 'bulbasaur');
const [dexCursor, setDexCursor] = useState(0);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [dexSpriteTick, setDexSpriteTick] = useState(0);
// Prefetch sprite for focused dex species
React.useEffect(() => {
if (!loadSprite(focusedId)) {
fetchAndCacheSprite(focusedId).then(s => { if (s) setDexSpriteTick(t => t + 1) });
}
}, [focusedId]);
void dexSpriteTick;
// Sorted discovered species
const discovered = buddyData.dex