diff --git a/packages/pokemon/src/battle/engine.ts b/packages/pokemon/src/battle/engine.ts index dc9e9e4f4..8d7a17edb 100644 --- a/packages/pokemon/src/battle/engine.ts +++ b/packages/pokemon/src/battle/engine.ts @@ -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 = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' } const formatStatLine = (vals: Record) => STAT_NAMES.map(s => `${vals[s]} ${DEX_DISPLAY[TO_DEX_STAT[s]]}`).join(' / ') diff --git a/packages/pokemon/src/core/creature.ts b/packages/pokemon/src/core/creature.ts index b09d29ca9..0296fdac9 100644 --- a/packages/pokemon/src/core/creature.ts +++ b/packages/pokemon/src/core/creature.ts @@ -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) diff --git a/packages/pokemon/src/dex/learnsets.ts b/packages/pokemon/src/dex/learnsets.ts index b64d6008c..d64c5f2c8 100644 --- a/packages/pokemon/src/dex/learnsets.ts +++ b/packages/pokemon/src/dex/learnsets.ts @@ -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 | 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): { id: string; level: number }[] { + // Collect level-up moves, preferring highest-gen data per move + const moveMap = new Map() + 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 } + }) } diff --git a/packages/pokemon/src/ui/BattleScene.tsx b/packages/pokemon/src/ui/BattleScene.tsx index 8e7cdb29c..33ccbd1d9 100644 --- a/packages/pokemon/src/ui/BattleScene.tsx +++ b/packages/pokemon/src/ui/BattleScene.tsx @@ -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 ( @@ -78,8 +83,8 @@ export function BattleScene({ overlay ) : ( <> - {/* Opponent: HP card left, sprite right */} - + {/* Opponent info */} + - - {/* Player: overlaps opponent area by pulling up */} - - + {/* + 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 */} + + + + + + + + + + {/* Player info */} + { + 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() diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index a79833cfe..278f312ca 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -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, diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx index e0038430a..0530a0d00 100644 --- a/src/commands/buddy/BuddyPanel.tsx +++ b/src/commands/buddy/BuddyPanel.tsx @@ -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(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(buddyData.dex[0]?.speciesId ?? 'bulbasaur'); const [dexCursor, setDexCursor] = useState(0); const [statusMsg, setStatusMsg] = useState(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