diff --git a/packages/pokemon/src/__tests__/storage.test.ts b/packages/pokemon/src/__tests__/storage.test.ts
index e4371c6be..237411649 100644
--- a/packages/pokemon/src/__tests__/storage.test.ts
+++ b/packages/pokemon/src/__tests__/storage.test.ts
@@ -112,10 +112,12 @@ describe('addToParty', () => {
})
describe('removeFromParty', () => {
- test('removes creature at index', () => {
- const data = makeData(2)
- const updated = removeFromParty(data, 1)
- expect(updated.party[1]).toBeNull()
+ test('removes creature and compacts party', () => {
+ const data = makeData(3)
+ const updated = removeFromParty(data, 0)
+ expect(updated.party[0]).toBe('creature-1')
+ expect(updated.party[1]).toBe('creature-2')
+ expect(updated.party[2]).toBeNull()
})
test('does nothing for out-of-bounds index', () => {
@@ -123,6 +125,12 @@ describe('removeFromParty', () => {
const updated = removeFromParty(data, 10)
expect(updated.party).toEqual(data.party)
})
+
+ test('cannot remove last party member', () => {
+ const data = makeData(1)
+ const updated = removeFromParty(data, 0)
+ expect(updated.party[0]).toBe('creature-0')
+ })
})
describe('swapPartySlots', () => {
diff --git a/packages/pokemon/src/core/storage.ts b/packages/pokemon/src/core/storage.ts
index 74cf1efef..c6b4ad222 100644
--- a/packages/pokemon/src/core/storage.ts
+++ b/packages/pokemon/src/core/storage.ts
@@ -266,19 +266,28 @@ export function incrementTurns(data: BuddyData): BuddyData {
// ─── Party operations ───
+/** Compact party: move all non-null to front, pad with nulls to length 6 */
+export function compactParty(party: (string | null)[]): (string | null)[] {
+ const filled = party.filter((id): id is string => id !== null)
+ return [...filled, ...Array(6).fill(null)].slice(0, 6)
+}
+
export function addToParty(data: BuddyData, creatureId: string): { data: BuddyData; added: boolean } {
const party = [...data.party]
const emptyIdx = party.findIndex(p => p === null)
if (emptyIdx === -1) return { data, added: false }
party[emptyIdx] = creatureId
- return { data: { ...data, party }, added: true }
+ return { data: { ...data, party: compactParty(party) }, added: true }
}
export function removeFromParty(data: BuddyData, slotIndex: number): BuddyData {
if (slotIndex < 0 || slotIndex >= 6) return data
const party = [...data.party]
+ // Don't remove if it would leave party empty
+ const count = party.filter(Boolean).length
+ if (count <= 1) return data
party[slotIndex] = null
- return { ...data, party }
+ return { ...data, party: compactParty(party) }
}
export function swapPartySlots(data: BuddyData, indexA: number, indexB: number): BuddyData {
@@ -287,7 +296,7 @@ export function swapPartySlots(data: BuddyData, indexA: number, indexB: number):
const b = party[indexB]
party[indexA] = b
party[indexB] = a
- return { ...data, party }
+ return { ...data, party: compactParty(party) }
}
export function setActivePartyMember(data: BuddyData, creatureId: string): BuddyData {
@@ -300,7 +309,7 @@ export function setActivePartyMember(data: BuddyData, creatureId: string): Buddy
} else {
party[0] = creatureId
}
- return { ...data, party }
+ return { ...data, party: compactParty(party) }
}
// ─── PC Box operations ───
diff --git a/packages/pokemon/src/dex/species.ts b/packages/pokemon/src/dex/species.ts
index 61cc21f64..c908b8b98 100644
--- a/packages/pokemon/src/dex/species.ts
+++ b/packages/pokemon/src/dex/species.ts
@@ -2,7 +2,7 @@ import { Dex } from '@pkmn/sim'
import type { SpeciesData, SpeciesId, GrowthRate } from '../types'
import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn'
import { getNextEvolution } from './evolution'
-import { SPECIES_I18N, SPECIES_PERSONALITY } from './names'
+import { SPECIES_PERSONALITY } from './names'
// ─── Dynamic species list from @pkmn/sim Dex ───
@@ -111,7 +111,6 @@ function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'
function buildSpeciesData(id: SpeciesId): SpeciesData {
const dex = getSpecies(id)
const sup = SUPPLEMENT[id] ?? DEFAULT_SUPPLEMENT
- const i18n = SPECIES_I18N[id]
const personality = SPECIES_PERSONALITY[id]
if (!dex) {
@@ -121,7 +120,7 @@ function buildSpeciesData(id: SpeciesId): SpeciesData {
return {
id,
name: dex.name,
- names: i18n ?? { en: dex.name },
+ names: { en: dex.name },
dexNumber: dex.num,
genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined),
baseStats: mapBaseStats(dex.baseStats),
diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts
index b87aee7fb..a384e4029 100644
--- a/packages/pokemon/src/index.ts
+++ b/packages/pokemon/src/index.ts
@@ -52,7 +52,7 @@ export { checkEggEligibility, generateEgg, advanceEggSteps, isEggReadyToHatch, h
export {
loadBuddyData, saveBuddyData, getDefaultBuddyData, migrateFromLegacy,
updateDailyStats, incrementTurns,
- addToParty, removeFromParty, swapPartySlots, setActivePartyMember,
+ addToParty, removeFromParty, swapPartySlots, setActivePartyMember, compactParty,
depositToBox, withdrawFromBox, moveInBox, renameBox,
findCreatureLocation, releaseCreature, getTotalCreatureCount, getAllCreatureIds,
addItemToBag, removeItemFromBag, getItemCount,
@@ -62,6 +62,7 @@ export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/sprit
// Sprites
export { renderAnimatedSprite, shrinkSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
export { getFallbackSprite } from './sprites/fallback'
+export { SpeciesPicker } from './ui/SpeciesPicker'
// UI Components
export { CompanionCard } from './ui/CompanionCard'
diff --git a/packages/pokemon/src/ui/CompanionCard.tsx b/packages/pokemon/src/ui/CompanionCard.tsx
index 57069eaf3..7abf0e15c 100644
--- a/packages/pokemon/src/ui/CompanionCard.tsx
+++ b/packages/pokemon/src/ui/CompanionCard.tsx
@@ -67,7 +67,7 @@ export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCar
// Evolution hint
const evoHint = nextEvo ? (
- → {getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name} Lv.{nextEvo.minLevel}
+ → {getSpeciesData(nextEvo.to).name} Lv.{nextEvo.minLevel}
) : null
return (
@@ -84,7 +84,7 @@ export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCar
{/* Species + type + gender */}
- {species.names.zh ?? species.name}
+ {species.name}
{typeBadges}
{genderSymbol && {genderSymbol}}
diff --git a/packages/pokemon/src/ui/PokedexView.tsx b/packages/pokemon/src/ui/PokedexView.tsx
index aca628c48..799b7c4e2 100644
--- a/packages/pokemon/src/ui/PokedexView.tsx
+++ b/packages/pokemon/src/ui/PokedexView.tsx
@@ -112,7 +112,7 @@ export function PokedexView({ buddyData }: PokedexViewProps) {
{isActive ? ▶ : ' '}
#{String(species.dexNumber).padStart(3, '0')}
- {(species.names as Record).zh ?? species.name}
+ {species.name}
{' '}
diff --git a/packages/pokemon/src/ui/SpeciesDetail.tsx b/packages/pokemon/src/ui/SpeciesDetail.tsx
index 8c1162904..ff225f050 100644
--- a/packages/pokemon/src/ui/SpeciesDetail.tsx
+++ b/packages/pokemon/src/ui/SpeciesDetail.tsx
@@ -59,7 +59,7 @@ export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDe
{/* Header */}
- #{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}
+ #{String(species.dexNumber).padStart(3, '0')} {species.name}
{caughtLevel && Best: Lv.{caughtLevel}}
@@ -163,7 +163,7 @@ function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
{i > 0 && → }
- {getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
+ {getSpeciesData(sid).name}
{i < chain.length - 1 && getNextEvolution(sid) && (
Lv.{getNextEvolution(sid)!.minLevel}
diff --git a/packages/pokemon/src/ui/SpeciesPicker.tsx b/packages/pokemon/src/ui/SpeciesPicker.tsx
index ae2a3c989..b2b7a4f4c 100644
--- a/packages/pokemon/src/ui/SpeciesPicker.tsx
+++ b/packages/pokemon/src/ui/SpeciesPicker.tsx
@@ -19,7 +19,7 @@ const ALL_ENTRIES: SpeciesEntry[] = ALL_SPECIES_IDS.map(id => {
return {
id,
name: data.name,
- displayName: (data.names as Record).zh ?? data.name,
+ displayName: data.name,
dexNumber: data.dexNumber,
types: data.types as string[],
}
diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx
index 952335dcf..ade1be720 100644
--- a/src/commands/buddy/BuddyPanel.tsx
+++ b/src/commands/buddy/BuddyPanel.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
import { useState } from 'react';
-import { Box, Text, Pane, Tab, Tabs, useInput, type Color } from '@anthropic/ink';
+import { Box, Text, Pane, Tab, Tabs, useInput, useTabHeaderFocus, type Color } from '@anthropic/ink';
import { useSetAppState } from '../../state/AppState.js';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
@@ -15,11 +15,11 @@ import {
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 } 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 } from '@claude-code-best/pokemon';
+import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite, SpeciesPicker } from '@claude-code-best/pokemon';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
const CYAN: Color = 'ansi:cyan';
@@ -82,6 +82,9 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps)
,
+
+
+ ,
{' '}
- → {getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name} Lv.
+ → {getSpeciesData(nextEvo.to).name} Lv.
{nextEvo.minLevel}
) : null;
@@ -268,7 +271,7 @@ function CreatureDetail({
- {species.names.zh ?? species.name}
+ {species.name}
{typeBadges}
{genderSymbol && {genderSymbol}}
@@ -365,6 +368,7 @@ function CreatureDetail({
// ─── Dex Tab ──────────────────────────────────────────
const BAR_WIDTH = 30
+const MAX_VISIBLE_DEX = 15
const GEN_RANGES = [
{ label: 'Gen I', start: 1, end: 151 },
@@ -380,8 +384,8 @@ const GEN_RANGES = [
function DexTab({
buddyData,
- isActive: _isActive,
- onUpdate: _onUpdate,
+ isActive,
+ onUpdate,
onClose,
}: {
buddyData: BuddyData;
@@ -389,11 +393,22 @@ function DexTab({
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);
+
+ // 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 => {
@@ -405,16 +420,222 @@ function DexTab({
return { ...g, total: genSpecies.length, collected: genCollected }
})
- // Discover party species detail for display
- const discovered = buddyData.dex
- .sort((a, b) => getSpeciesData(a.speciesId).dexNumber - getSpeciesData(b.speciesId).dexNumber)
- .slice(0, 15)
+ // Input handling for stats & detail modes
+ useInput((_input, key) => {
+ if (!isActive) return;
+ if (mode === 'search') return; // SpeciesPicker handles its own input
- void onClose; // used by parent
+ 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 with percentage */}
+ {/* Header */}
Pokédex
@@ -431,62 +652,69 @@ function DexTab({
{Math.floor(percent * 100)}%
- {/* Per-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}
-
- );
- })}
-
+
+ {/* 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
+ )}
+
- {/* Discovered species */}
- {discovered.length > 0 && (
-
- ─── 已发现 ({buddyData.dex.length}) ───
- {discovered.map(entry => {
- const species = getSpeciesData(entry.speciesId);
- const inParty = buddyData.creatures.some(c => partySet.has(c.id) && c.speciesId === species.id);
+ {/* 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 (
-
- #{String(species.dexNumber).padStart(3, '0')}
-
- {(species.names as Record).zh ?? species.name}
-
- {inParty && ★}
- Lv.{entry.bestLevel}
- {entry.caughtCount > 1 && x{entry.caughtCount}}
+
+ {g.label.padEnd(8)}
+ = 1 ? GREEN : p > 0 ? YELLOW : GRAY}>{miniBar}
+ {g.collected}/{g.total}
);
})}
- {buddyData.dex.length > 15 && (
- …还有 {buddyData.dex.length - 15} 只
- )}
- )}
-
- {discovered.length === 0 && (
-
- 还没有发现任何精灵,开始冒险吧!
-
- )}
+
{/* 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
-
+ 🥚 {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} steps
)}
+
+ [↑↓] 浏览 · [Enter] 详情 · [S] 搜索 · [Esc] 关闭
+
);
}
@@ -580,6 +808,276 @@ function EggTab({ buddyData }: { buddyData: BuddyData }) {
);
}
+// ─── 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 { headerFocused, blurHeader, focusHeader } = useTabHeaderFocus()
+ 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)
+
+ // Blur header on mount so left/right goes to box grid, not tab switching
+ React.useEffect(() => { blurHeader() }, [blurHeader])
+
+ 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
+ // When header is focused, only handle Tab to give focus back to content
+ if (headerFocused) {
+ if (key.tab) { blurHeader() }
+ return // let Tabs handle left/right
+ }
+
+ // 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 {
@@ -600,3 +1098,37 @@ function getGenderInfoText(genderRate: number): string {
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
+}