import * as React from 'react'; import { useState } from 'react'; import { Box, Text, Pane, Tab, Tabs, useInput, type Color } from '@anthropic/ink'; import { useSetAppState } from '../../state/AppState.js'; import { useKeybinding } from '../../keybindings/useKeybinding.js'; import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, type BuddyData, type Creature, type SpeciesId, } from '@claude-code-best/pokemon'; import { getSpeciesData, ensureSpeciesData } from '@claude-code-best/pokemon'; import { getNextEvolution } from '@claude-code-best/pokemon'; import { calculateStats, getCreatureName, getTotalEV, getActiveCreature, saveBuddyData, EGG_REQUIRED_DAYS, addToParty, swapPartySlots, removeFromParty, compactParty } from '@claude-code-best/pokemon'; import { getXpProgress } from '@claude-code-best/pokemon'; import { getGenderSymbol } 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'; const YELLOW: Color = 'ansi:yellow'; const GREEN: Color = 'ansi:green'; const BLUE: Color = 'ansi:blue'; const RED: Color = 'ansi:red'; const MAGENTA: Color = 'ansi:magenta'; const WHITE: Color = 'ansi:whiteBright'; const GRAY: Color = 'ansi:white'; const TYPE_COLORS: Record = { grass: 'ansi:green', poison: 'ansi:magenta', fire: 'ansi:red', flying: 'ansi:cyan', water: 'ansi:blue', electric: 'ansi:yellow', normal: 'ansi:white', }; interface BuddyPanelProps { buddyData: BuddyData; spriteLines?: string[]; onClose: LocalJSXCommandOnDone; } /** * Unified buddy panel with tabs — same pattern as Settings. * ESC closes, ←/→ switch tabs, Ctrl+C/D double-press exits. */ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) { const [selectedTab, setSelectedTab] = useState('Buddy'); const [data, setData] = useState(buddyData); const setAppState = useSetAppState(); useExitOnCtrlCDWithKeybindings(); // Trigger species data refresh from API (fire-and-forget) React.useEffect(() => { ensureSpeciesData(); }, []); const handleEscape = () => { onClose('buddy panel closed'); }; useKeybinding('confirm:no', handleEscape, { context: 'Settings', isActive: true, }); // Tab / Shift+Tab to switch between tabs const TAB_ORDER = ['Buddy', 'PC Box', 'Pokédex', 'Egg'] useInput((_input, key) => { if (key.tab) { setSelectedTab(prev => { const idx = TAB_ORDER.indexOf(prev) if (key.shift) { return TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]! } return TAB_ORDER[(idx + 1) % TAB_ORDER.length]! }) } }); const updateData = (updated: BuddyData) => { setData(updated); saveBuddyData(updated); setAppState(prev => ({ ...prev, companionCreatureChangedAt: Date.now() })); }; const tabs = [ , , onClose('buddy panel closed')} /> , , ]; return ( {tabs} ); } // ─── Party View (replaces BuddyTab) ───────────────────── function PartyView({ data, onUpdate, isActive, }: { data: BuddyData; onUpdate: (data: BuddyData) => void; spriteLines?: string[]; isActive: boolean; }) { 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; if (key.leftArrow) { setFocusedSlot(prev => (prev > 0 ? prev - 1 : 5)); setTick(t => t + 1); setStatusMsg(null); } else if (key.rightArrow) { setFocusedSlot(prev => (prev < 5 ? prev + 1 : 0)); setTick(t => t + 1); setStatusMsg(null); } else if (key.return) { if (focusedSlot === 0) { setStatusMsg('This is your active buddy!'); return; } const updated = swapPartySlots(data, 0, focusedSlot); onUpdate(updated); setStatusMsg('Swapped with active buddy!'); } else if (_input === 'x' || _input === 'X') { const creatureId = data.party[focusedSlot]; if (!creatureId) return; const updated = removeFromParty(data, focusedSlot); onUpdate(updated); setStatusMsg('Removed from party.'); } }); // Resolve creature for the focused slot (tick forces re-read) const _tick = tick; // reference tick to avoid unused warning const focusedCreatureId = data.party[focusedSlot]; const focusedCreature = focusedCreatureId ? 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)) : undefined; return ( {/* Party slots row */} {data.party.map((creatureId, i) => { const creature = creatureId ? data.creatures.find(c => c.id === creatureId) : null; const isActiveSlot = i === 0; const isFocused = i === focusedSlot; return ( {isActiveSlot && !isFocused && } {isFocused && } {creature ? ( {getCreatureName(creature).length > 8 ? getCreatureName(creature).slice(0, 7) + '…' : getCreatureName(creature)} ) : ( --- )} {creature ? `Lv.${creature.level}` : ' '} ); })} {/* Status message */} {statusMsg && ( {statusMsg} )} {/* Hint */} ←/→ navigate · Enter swap · X remove {/* Selected creature detail — key forces remount on slot change */} {focusedCreature ? ( ) : ( Empty slot — add from Pokédex tab )} ); } // ─── Creature Detail ───────────────────────────────────── function CreatureDetail({ creature, spriteLines, isActive, }: { creature: Creature; spriteLines?: string[]; isActive: boolean; }) { const species = getSpeciesData(creature.speciesId); const stats = calculateStats(creature); const xp = getXpProgress(creature); const genderSymbol = getGenderSymbol(creature.gender); const name = getCreatureName(creature); const totalEV = getTotalEV(creature); const nextEvo = getNextEvolution(creature.speciesId); const typeBadges = species.types .filter((t): t is string => Boolean(t)) .map((t, i) => ( {i > 0 && /} {t.toUpperCase()} )); const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED; const shinyBadge = creature.isShiny ? ★SHINY★ : null; const evoHint = nextEvo ? ( {' '} → {getSpeciesData(nextEvo.to).name} Lv. {nextEvo.minLevel} ) : null; return ( {name} #{String(species.dexNumber).padStart(3, '0')} {shinyBadge} Lv.{creature.level} {isActive && ★ Active} {species.name} {typeBadges} {genderSymbol && {genderSymbol}} {spriteLines && ( )} {species.flavorText && ( "{species.flavorText}" )} ─── Stats ─── ─── Base ─── {STAT_NAMES.map(stat => { const baseVal = species.baseStats[stat]; const baseFilled = Math.round((baseVal / 130) * 12); const ev = creature.ev[stat]; const evText = ev > 0 ? ({ev}) : null; return ( {evText} {STAT_LABELS[stat].padEnd(3)} {'█'.repeat(baseFilled)} {'░'.repeat(12 - baseFilled)} {String(baseVal).padStart(3)} ); })} XP {'█'.repeat(Math.round(xp.percentage / 10))} {'░'.repeat(10 - Math.round(xp.percentage / 10))} {' '} {xp.current}/{xp.needed} EV = 510 ? GREEN : GRAY}>{totalEV}/510 {'█'.repeat(Math.round((creature.friendship / 255) * 10))} {'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))} {creature.friendship}/255 {evoHint && ( Next: {evoHint} )} ); } // ─── Dex Tab ────────────────────────────────────────── const BAR_WIDTH = 30 const MAX_VISIBLE_DEX = 15 const GEN_RANGES = [ { label: 'Gen I', start: 1, end: 151 }, { label: 'Gen II', start: 152, end: 251 }, { label: 'Gen III', start: 252, end: 386 }, { label: 'Gen IV', start: 387, end: 493 }, { label: 'Gen V', start: 494, end: 649 }, { label: 'Gen VI', start: 650, end: 721 }, { label: 'Gen VII', start: 722, end: 809 }, { label: 'Gen VIII',start: 810, end: 905 }, { label: 'Gen IX', start: 906, end: 1025 }, ] function DexTab({ buddyData, isActive, onUpdate, onClose, }: { buddyData: BuddyData; isActive: boolean; onUpdate: (data: BuddyData) => void; onClose: () => void; }) { const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d])); const collected = buddyData.dex.length; const total = ALL_SPECIES_IDS.length; const percent = total > 0 ? collected / total : 0; const partySet = new Set(buddyData.party.filter((id): id is string => id !== null)); const [mode, setMode] = useState<'stats' | 'search' | 'detail'>('stats'); 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 .slice() .sort((a, b) => getSpeciesData(a.speciesId).dexNumber - getSpeciesData(b.speciesId).dexNumber); // Per-gen stats const genStats = GEN_RANGES.map(g => { const genSpecies = ALL_SPECIES_IDS.filter(id => { const n = getSpeciesData(id).dexNumber return n >= g.start && n <= g.end }) const collectedNums = new Set(buddyData.dex.map(e => getSpeciesData(e.speciesId).dexNumber)) const genCollected = genSpecies.filter(id => collectedNums.has(getSpeciesData(id).dexNumber)).length return { ...g, total: genSpecies.length, collected: genCollected } }) // Input handling for stats & detail modes useInput((_input, key) => { if (!isActive) return; if (mode === 'search') return; // SpeciesPicker handles its own input if (mode === 'stats') { if (_input.toLowerCase() === 's') { setMode('search'); return; } if (key.upArrow) { setDexCursor(prev => Math.max(0, prev - 1)); setStatusMsg(null); return; } if (key.downArrow) { setDexCursor(prev => Math.min(discovered.length - 1, prev + 1)); setStatusMsg(null); return; } if (key.return && discovered.length > 0) { const entry = discovered[dexCursor]; if (entry) { setFocusedId(entry.speciesId); setMode('detail'); setStatusMsg(null); } return; } if (key.escape) { onClose(); return; } return; } if (mode === 'detail') { if (_input.toLowerCase() === 's') { setMode('search'); return; } if (key.escape) { setMode('stats'); return; } if (key.return) { handleAddToParty(focusedId); return; } } }); const handleAddToParty = (speciesId: SpeciesId) => { const creature = buddyData.creatures.find(c => c.speciesId === speciesId); if (!creature) return; if (partySet.has(creature.id)) { setStatusMsg('Already in party!'); return; } const result = addToParty(buddyData, creature.id); if (result.added) { onUpdate(result.data); setStatusMsg(`Added ${getCreatureName(creature)} to party!`); } else { setStatusMsg('Party is full!'); } }; // ─── Search mode (SpeciesPicker) ─── if (mode === 'search') { return ( { setFocusedId(speciesId); setMode('detail'); setStatusMsg(null); }} onCancel={() => setMode('stats')} title="搜索精灵" /> ); } // ─── Detail mode ─── if (mode === 'detail') { const species = getSpeciesData(focusedId); const entry = dexMap.get(focusedId); const discovered_ = !!entry; const owned = buddyData.creatures.find(c => c.speciesId === focusedId); const inParty = owned ? partySet.has(owned.id) : false; const sprite = discovered_ ? (loadSprite(focusedId)?.lines ?? getFallbackSprite(focusedId)) : null; const maxBase = 130; return ( Pokédex — Detail {collected}/{total} {(percent * 100).toFixed(1)}% {/* Sprite (centered, full width) */} {discovered_ ? ( <> {sprite && ( {sprite.map((line, i) => {line})} )} #{String(species.dexNumber).padStart(3, '0')} {species.name} {species.types.filter((t): t is string => Boolean(t)).map((t, ti) => ( {ti > 0 && /} {t.toUpperCase()} ))} {getGenderInfoText(species.genderRate)} {/* Evolution chain */} {(() => { const chain = getChainFor(focusedId); if (chain.length <= 1) return null; return ( Evolution: {chain.map((sid, i) => { const next = getNextEvolution(sid); return ( {i > 0 && } {getSpeciesData(sid).name} {next && Lv.{next.minLevel}} ); })} ); })()} ) : ( {' ??? '} {' / \\'} {' | ? |'} {' \\_/'} #{String(species.dexNumber).padStart(3, '0')} ??? Undiscovered species... )} {discovered_ && species.flavorText && ( "{species.flavorText}" )} {/* Bottom: base stats */} {discovered_ && ( ─── Base Stats ─── {STAT_NAMES.map(stat => { const val = species.baseStats[stat]; const filled = Math.round((val / maxBase) * 12); return ( {STAT_LABELS[stat].padEnd(3)} {'█'.repeat(filled)}{'░'.repeat(12 - filled)} {String(val).padStart(3)} ); })} {'Total'.padEnd(3)} {'─'.repeat(12)} {Object.values(species.baseStats).reduce((a, b) => a + b, 0)} )} {/* Status */} {statusMsg ? ( {statusMsg} ) : owned ? ( inParty ? ★ In party : [Enter] 加入队伍 ) : ( Not owned )} [S] 搜索 · [Esc] 返回列表 ); } // ─── Stats mode (default) ─── // Visible window of discovered species const halfVis = Math.floor(MAX_VISIBLE_DEX / 2); let startIdx = dexCursor - halfVis; if (startIdx < 0) startIdx = 0; if (startIdx + MAX_VISIBLE_DEX > discovered.length) startIdx = Math.max(0, discovered.length - MAX_VISIBLE_DEX); const visibleDex = discovered.slice(startIdx, startIdx + MAX_VISIBLE_DEX); return ( {/* Header */} Pokédex {collected} /{total} {(percent * 100).toFixed(1)}% {/* Fixed-width progress bar */} {'█'.repeat(Math.round(percent * BAR_WIDTH))} {'░'.repeat(BAR_WIDTH - Math.round(percent * BAR_WIDTH))} {Math.floor(percent * 100)}% {/* Left: discovered species list */} {discovered.length > 0 ? ( <> {startIdx > 0 && ↑ more} {visibleDex.map((entry, vi) => { const actualIdx = startIdx + vi; const species = getSpeciesData(entry.speciesId); const inParty = buddyData.creatures.some(c => partySet.has(c.id) && c.speciesId === species.id); const isCursor = actualIdx === dexCursor; return ( {isCursor ? ' ▸' : ' '} #{String(species.dexNumber).padStart(3, '0')} {species.name} {inParty && } ); })} {startIdx + MAX_VISIBLE_DEX < discovered.length && ( ↓ {discovered.length - startIdx - MAX_VISIBLE_DEX} more )} ) : ( No species discovered yet )} {/* Divider */} {/* Right: gen stats */} ─── 分代统计 ─── {genStats.map(g => { const p = g.total > 0 ? g.collected / g.total : 0; const miniBar = '█'.repeat(Math.round(p * 10)) + '░'.repeat(10 - Math.round(p * 10)); return ( {g.label.padEnd(8)} = 1 ? GREEN : p > 0 ? YELLOW : GRAY}>{miniBar} {g.collected}/{g.total} ); })} {/* Footer */} Turns:{buddyData.stats.totalTurns} Days:{buddyData.stats.consecutiveDays} Eggs:{buddyData.stats.totalEggsObtained} Evos:{buddyData.stats.totalEvolutions} {buddyData.eggs.length > 0 && ( 🥚 {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} steps )} [↑↓] 浏览 · [Enter] 详情 · [S] 搜索 · [Esc] 关闭 ); } // ─── Egg Tab ────────────────────────────────────────── function EggTab({ buddyData }: { buddyData: BuddyData }) { const eggs = buddyData.eggs; if (eggs.length === 0) { // Include today in progress even if updateDailyStats hasn't run yet const today = new Date().toISOString().split('T')[0]; const lastDate = buddyData.stats.lastActiveDate; let effectiveDays = buddyData.stats.consecutiveDays; if (lastDate !== today) { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yesterdayStr = yesterday.toISOString().split('T')[0]; effectiveDays = lastDate === yesterdayStr ? effectiveDays + 1 : 1; } const progress = Math.min(effectiveDays, EGG_REQUIRED_DAYS); const filled = Math.round((progress / EGG_REQUIRED_DAYS) * 10); const empty = 10 - filled; const daysLeft = Math.max(0, EGG_REQUIRED_DAYS - effectiveDays); return ( Egg No egg currently. Keep coding! Egg progress = EGG_REQUIRED_DAYS ? GREEN : YELLOW}> {'█'.repeat(filled)} {'░'.repeat(empty)} {progress}/{EGG_REQUIRED_DAYS} days {daysLeft > 0 ? ( Next egg: {daysLeft} more day{daysLeft > 1 ? 's' : ''} ) : ( Ready! Keep coding to trigger an egg. )} ); } const egg = eggs[0]!; const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100); const filled = Math.round(percentage / 10); const empty = 10 - filled; return ( Egg Status . / \ | | \_/ Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps} {'█'.repeat(filled)} {'░'.repeat(empty)} {percentage}% Pet (+5) · Chat (+3) · Cmd (+1) Hatch: ~{egg.stepsRemaining} more interactions ─── Egg Stats ─── Total eggs: {buddyData.stats.totalEggsObtained} ); } // ─── PC Box Tab ────────────────────────────────────── const BOX_COLS = 6 const BOX_SIZE = 30 const PARTY_SLOTS = 6 type Panel = 'party' | 'box' function PcBoxTab({ data, onUpdate, isActive }: { data: BuddyData; onUpdate: (d: BuddyData) => void; isActive: boolean }) { const [boxIdx, setBoxIdx] = useState(0) const [panel, setPanel] = useState('box') const [partyCursor, setPartyCursor] = useState(0) // 0-5 const [boxCursor, setBoxCursor] = useState(0) // 0-29 const [held, setHeld] = useState<{ id: string; from: Panel; partySlot?: number; boxSlot?: number } | null>(null) const [statusMsg, setStatusMsg] = useState(null) const partySet = new Set(data.party.filter((id): id is string => id !== null)) const box = data.boxes[boxIdx]! const boxRows = Math.ceil(BOX_SIZE / BOX_COLS) // Currently selected creature const selectedCreature = panel === 'party' ? (data.party[partyCursor] ? data.creatures.find(c => c.id === data.party[partyCursor]) ?? null : null) : (box.slots[boxCursor] ? data.creatures.find(c => c.id === box.slots[boxCursor]) ?? null : null) useInput((_input, key) => { if (!isActive) return // Switch box with ,/. regardless of panel if (_input === ',' || _input === '<') { setBoxIdx(prev => (prev - 1 + data.boxes.length) % data.boxes.length) setStatusMsg(null) return } if (_input === '.' || _input === '>') { setBoxIdx(prev => (prev + 1) % data.boxes.length) setStatusMsg(null) return } if (panel === 'party') { // Party: up/down navigate, left/right does nothing if (key.upArrow) { setPartyCursor(prev => Math.max(0, prev - 1)); setStatusMsg(null); return } if (key.downArrow) { setPartyCursor(prev => Math.min(PARTY_SLOTS - 1, prev + 1)); setStatusMsg(null); return } if (key.rightArrow) { setPanel('box'); setStatusMsg(null); return } if (_input === ' ') { const slotId = data.party[partyCursor] if (!held && !slotId) return if (!held) { // Pick up from party setHeld({ id: slotId!, from: 'party', partySlot: partyCursor }) setStatusMsg(`Picked up ${getCreatureName(data.creatures.find(c => c.id === slotId!)!)}`) return } // Place / swap into party slot let updated = { ...data, party: [...data.party], boxes: data.boxes.map(b => ({ ...b, slots: [...b.slots] })) } if (slotId) { // Swap updated.party[partyCursor] = held.id if (held.from === 'party') { updated.party[held.partySlot!] = slotId } else { updated.boxes[boxIdx]!.slots[held.boxSlot!] = slotId } } else { // Empty party slot: place held updated.party[partyCursor] = held.id if (held.from === 'party') { updated.party[held.partySlot!] = null } else { updated.boxes[boxIdx]!.slots[held.boxSlot!] = null } } updated.party = compactParty(updated.party) onUpdate(updated) setHeld(null) setStatusMsg('Done!') return } // Cancel held if (key.escape && held) { setHeld(null); setStatusMsg('Cancelled'); return } return } // ─── Box panel ─── if (panel === 'box') { if (key.leftArrow) { if (boxCursor % BOX_COLS === 0) { setPanel('party') } else { setBoxCursor(prev => prev - 1) } setStatusMsg(null) return } if (key.rightArrow) { setBoxCursor(prev => (prev % BOX_COLS === BOX_COLS - 1 ? prev : prev + 1)) setStatusMsg(null) return } if (key.upArrow) { setBoxCursor(prev => (prev < BOX_COLS ? prev + BOX_SIZE - BOX_COLS : prev - BOX_COLS)) setStatusMsg(null) return } if (key.downArrow) { setBoxCursor(prev => (prev >= BOX_SIZE - BOX_COLS ? prev - BOX_SIZE + BOX_COLS : prev + BOX_COLS)) setStatusMsg(null) return } // Tab to party if (_input.toLowerCase() === 'p') { setPanel('party'); setStatusMsg(null); return } if (_input === ' ') { const slotId = box.slots[boxCursor] if (!held && !slotId) return if (!held) { // Pick up from box setHeld({ id: slotId!, from: 'box', boxSlot: boxCursor }) setStatusMsg(`Picked up ${getCreatureName(data.creatures.find(c => c.id === slotId!)!)}`) return } // Place / swap into box slot — direct slot manipulation let updated = { ...data, party: [...data.party], boxes: data.boxes.map(b => ({ ...b, slots: [...b.slots] })) } if (slotId) { // Swap updated.boxes[boxIdx]!.slots[boxCursor] = held.id if (held.from === 'box') { updated.boxes[boxIdx]!.slots[held.boxSlot!] = slotId } else { updated.party[held.partySlot!] = slotId } } else { // Empty box slot: place held if (held.from === 'party') { const partyCount = updated.party.filter(Boolean).length if (partyCount <= 1) { setStatusMsg('Cannot deposit last party member!') return } } updated.boxes[boxIdx]!.slots[boxCursor] = held.id if (held.from === 'box') { updated.boxes[boxIdx]!.slots[held.boxSlot!] = null } else { updated.party[held.partySlot!] = null } } updated.party = compactParty(updated.party) onUpdate(updated) setHeld(null) setStatusMsg('Done!') return } if (key.escape && held) { setHeld(null); setStatusMsg('Cancelled'); return } if (key.escape && !held) { setPanel('party'); return } } }) return ( {/* Header */} PC Box {held && ✦ Carrying: {getCreatureName(data.creatures.find(c => c.id === held.id)!)}} {box.name} ({boxIdx + 1}/{data.boxes.length}) {/* Left: Party */} Party {data.party.map((slotId, i) => { const c = slotId ? data.creatures.find(cr => cr.id === slotId) : null const isCursor = panel === 'party' && i === partyCursor return ( {isCursor ? '▸' : ' '} {c ? ( {getCreatureName(c).length > 8 ? getCreatureName(c).slice(0, 7) + '..' : getCreatureName(c)} {c.level} {c.isShiny && } ) : ( --- )} ) })} Total: {data.creatures.length} {/* Right: Box grid */} {Array.from({ length: boxRows }, (_, row) => ( {Array.from({ length: BOX_COLS }, (_, col) => { const slotIdx = row * BOX_COLS + col const slotId = box.slots[slotIdx] const c = slotId ? data.creatures.find(cr => cr.id === slotId) : null const isCursor = panel === 'box' && slotIdx === boxCursor return ( {c ? ( {isCursor ? '[' : ' '} {getCreatureName(c).length > 5 ? getCreatureName(c).slice(0, 4) + '.' : getCreatureName(c).padEnd(5)} {isCursor ? ']' : ' '} {String(c.level).padStart(2)} ) : ( {isCursor ? '[ --- ]' : ' ... '} )} ) })} ))} {/* Selected creature info */} {selectedCreature && ( ─── {getCreatureName(selectedCreature)} ─── Lv.{selectedCreature.level} {getSpeciesData(selectedCreature.speciesId).name} {getGenderSymbol(selectedCreature.gender)} {selectedCreature.isShiny && ★SHINY} {partySet.has(selectedCreature.id) && ★ Party} )} {statusMsg && ( {statusMsg} )} [Space] 拾取/放置 · [Esc] 返回/取消 · [,/.] 切箱 · [Tab] 切到标签栏 ) } // ─── Helpers ────────────────────────────────────────── function getStatColor(stat: string): Color { const colors: Record = { hp: 'ansi:green', attack: 'ansi:red', defense: 'ansi:yellow', spAtk: 'ansi:blue', spDef: 'ansi:magenta', speed: 'ansi:cyan', }; return colors[stat] ?? 'ansi:white'; } function getGenderInfoText(genderRate: number): string { if (genderRate === -1) return 'Genderless'; if (genderRate === 0) return '♂ 100%'; if (genderRate === 8) return '♀ 100%'; return `♀ ${(genderRate / 8) * 100}%`; } /** Build full evolution chain for a species by walking backwards then forwards */ function getChainFor(speciesId: SpeciesId): SpeciesId[] { const chain: SpeciesId[] = [] // Walk backwards to find the base form let current: SpeciesId | undefined = speciesId const visited = new Set() while (current) { if (visited.has(current)) break visited.add(current) chain.unshift(current) // Find pre-evolution const dex = getSpeciesData(current) // Check if any species evolves into current const preEvo = ALL_SPECIES_IDS.find(id => { const next = getNextEvolution(id) return next?.to === current }) if (preEvo) { current = preEvo } else { break } } // Walk forwards from each node to find branches const fullChain: SpeciesId[] = [...chain] for (const sid of [...chain]) { const next = getNextEvolution(sid) if (next && !fullChain.includes(next.to)) { fullChain.push(next.to) } } return fullChain }