feat: PC Box 管理系统 + 全英文名统一 + 队伍补位机制

- 新增 PC Box tab(左侧 party + 右侧 box 网格,支持 party↔box 拾取/放置/交换)
- 空格键抓取/放下,左键在 col=0 时切到 party 面板
- 使用 useTabHeaderFocus 避免左右键被 Tabs 组件拦截
- 所有 1025 只精灵统一使用 Dex 英文名,移除中英混搭
- compactParty 补位机制:不允许前置空位,队伍最少保留一只
- PC Box tab 移至第二位(Buddy → PC Box → Pokédex → Egg)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 17:21:24 +08:00
parent 9930a53e51
commit 02783e4f5d
9 changed files with 623 additions and 74 deletions

View File

@@ -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', () => {

View File

@@ -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 ───

View File

@@ -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),

View File

@@ -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'

View File

@@ -67,7 +67,7 @@ export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCar
// Evolution hint
const evoHint = nextEvo ? (
<Text color={GRAY}> <Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
<Text color={GRAY}> <Text color={CYAN}>{getSpeciesData(nextEvo.to).name}</Text> Lv.{nextEvo.minLevel}</Text>
) : null
return (
@@ -84,7 +84,7 @@ export function CompanionCard({ creature, buddyData, spriteLines }: CompanionCar
{/* Species + type + gender */}
<Box>
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
<Text color={GRAY}>{species.name}</Text>
<Text> </Text>
{typeBadges}
{genderSymbol && <Text> {genderSymbol}</Text>}

View File

@@ -112,7 +112,7 @@ export function PokedexView({ buddyData }: PokedexViewProps) {
<Text>{isActive ? <Text color={YELLOW}></Text> : ' '}</Text>
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text color={WHITE} bold={isActive}>
{(species.names as Record<string, string>).zh ?? species.name}
{species.name}
</Text>
<Text>
{' '}

View File

@@ -59,7 +59,7 @@ export function SpeciesDetail({ speciesId, caughtLevel, spriteLines }: SpeciesDe
{/* Header */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.names.zh ?? species.name}</Text>
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} {species.name}</Text>
</Box>
{caughtLevel && <Text color={GREEN}>Best: Lv.{caughtLevel}</Text>}
</Box>
@@ -163,7 +163,7 @@ function EvolutionChain({ speciesId }: { speciesId: SpeciesId }) {
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === speciesId ? CYAN : GRAY} bold={sid === speciesId}>
{getSpeciesData(sid).names.zh ?? getSpeciesData(sid).name}
{getSpeciesData(sid).name}
</Text>
{i < chain.length - 1 && getNextEvolution(sid) && (
<Text color={GRAY}> Lv.{getNextEvolution(sid)!.minLevel}</Text>

View File

@@ -19,7 +19,7 @@ const ALL_ENTRIES: SpeciesEntry[] = ALL_SPECIES_IDS.map(id => {
return {
id,
name: data.name,
displayName: (data.names as Record<string, string>).zh ?? data.name,
displayName: data.name,
dexNumber: data.dexNumber,
types: data.types as string[],
}

View File

@@ -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)
<Tab key="buddy" title="Buddy">
<PartyView data={data} onUpdate={updateData} isActive={selectedTab === 'Buddy'} />
</Tab>,
<Tab key="pc" title="PC Box">
<PcBoxTab data={data} onUpdate={updateData} isActive={selectedTab === 'PC Box'} />
</Tab>,
<Tab key="dex" title="Pokédex">
<DexTab
buddyData={data}
@@ -250,7 +253,7 @@ function CreatureDetail({
const evoHint = nextEvo ? (
<Text color={GRAY}>
{' '}
<Text color={CYAN}>{getSpeciesData(nextEvo.to).names.zh ?? getSpeciesData(nextEvo.to).name}</Text> Lv.
<Text color={CYAN}>{getSpeciesData(nextEvo.to).name}</Text> Lv.
{nextEvo.minLevel}
</Text>
) : null;
@@ -268,7 +271,7 @@ function CreatureDetail({
</Box>
<Box>
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
<Text color={GRAY}>{species.name}</Text>
<Text> </Text>
{typeBadges}
{genderSymbol && <Text> {genderSymbol}</Text>}
@@ -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<SpeciesId>(buddyData.dex[0]?.speciesId ?? 'bulbasaur');
const [dexCursor, setDexCursor] = useState(0);
const [statusMsg, setStatusMsg] = useState<string | null>(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 (
<SpeciesPicker
onSelect={(speciesId) => {
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 (
<Box flexDirection="column">
<Box justifyContent="space-between">
<Text bold color={CYAN}>Pokédex Detail</Text>
<Text color={GRAY}>{collected}/{total} {(percent * 100).toFixed(1)}%</Text>
</Box>
{/* Sprite (centered, full width) */}
{discovered_ ? (
<>
{sprite && (
<Box flexDirection="column" alignItems="center">
{sprite.map((line, i) => <Text key={i} color={CYAN}>{line}</Text>)}
</Box>
)}
<Box justifyContent="center">
<Text bold color={CYAN}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text bold color={WHITE}>{species.name}</Text>
</Box>
<Box justifyContent="center">
{species.types.filter((t): t is string => Boolean(t)).map((t, ti) => (
<React.Fragment key={t}>
{ti > 0 && <Text color={GRAY}>/</Text>}
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.toUpperCase()}</Text>
</React.Fragment>
))}
<Text color={GRAY}> {getGenderInfoText(species.genderRate)}</Text>
</Box>
{/* Evolution chain */}
{(() => {
const chain = getChainFor(focusedId);
if (chain.length <= 1) return null;
return (
<Box flexDirection="column">
<Text color={GRAY}>Evolution:</Text>
<Box>
{chain.map((sid, i) => {
const next = getNextEvolution(sid);
return (
<React.Fragment key={sid}>
{i > 0 && <Text color={GRAY}> </Text>}
<Text color={sid === focusedId ? CYAN : GRAY} bold={sid === focusedId}>
{getSpeciesData(sid).name}
</Text>
{next && <Text color={GRAY}> Lv.{next.minLevel}</Text>}
</React.Fragment>
);
})}
</Box>
</Box>
);
})()}
</>
) : (
<Box flexDirection="column" alignItems="center" marginTop={1}>
<Text color={GRAY}>{' ??? '}</Text>
<Text color={GRAY}>{' / \\'}</Text>
<Text color={GRAY}>{' | ? |'}</Text>
<Text color={GRAY}>{' \\_/'}</Text>
<Text bold color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} ???</Text>
<Text color={GRAY} italic>Undiscovered species...</Text>
</Box>
)}
{discovered_ && species.flavorText && (
<Box>
<Text color={GRAY} italic>"{species.flavorText}"</Text>
</Box>
)}
{/* Bottom: base stats */}
{discovered_ && (
<Box flexDirection="column">
<Text color={GRAY}> Base Stats </Text>
{STAT_NAMES.map(stat => {
const val = species.baseStats[stat];
const filled = Math.round((val / maxBase) * 12);
return (
<Box key={stat}>
<Text color={WHITE}>{STAT_LABELS[stat].padEnd(3)}</Text>
<Text color={getStatColor(stat)}>
{'█'.repeat(filled)}{'░'.repeat(12 - filled)}
</Text>
<Text> {String(val).padStart(3)}</Text>
</Box>
);
})}
<Box>
<Text color={WHITE}>{'Total'.padEnd(3)}</Text>
<Text color={GRAY}>{'─'.repeat(12)}</Text>
<Text bold> {Object.values(species.baseStats).reduce((a, b) => a + b, 0)}</Text>
</Box>
</Box>
)}
{/* Status */}
<Box marginTop={0}>
{statusMsg ? (
<Text color={GREEN} italic>{statusMsg}</Text>
) : owned ? (
inParty ? <Text color={GREEN}> In party</Text> : <Text color={CYAN}>[Enter] </Text>
) : (
<Text color={GRAY}>Not owned</Text>
)}
</Box>
<Box>
<Text color={GRAY}>[S] · [Esc] </Text>
</Box>
</Box>
);
}
// ─── 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 (
<Box flexDirection="column">
{/* Header with percentage */}
{/* Header */}
<Box justifyContent="space-between">
<Text bold color={CYAN}>Pokédex</Text>
<Text>
@@ -431,62 +652,69 @@ function DexTab({
<Text> {Math.floor(percent * 100)}%</Text>
</Box>
{/* Per-gen stats */}
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> </Text>
{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 (
<Box key={g.label}>
<Text color={GRAY}>{g.label.padEnd(8)}</Text>
<Text color={p >= 1 ? GREEN : p > 0 ? YELLOW : GRAY}>{miniBar}</Text>
<Text> <Text bold>{g.collected}</Text><Text color={GRAY}>/{g.total}</Text></Text>
</Box>
);
})}
</Box>
<Box flexDirection="row">
{/* Left: discovered species list */}
<Box flexDirection="column" width={24}>
{discovered.length > 0 ? (
<>
{startIdx > 0 && <Text color={GRAY}> more</Text>}
{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 (
<Box key={species.id}>
<Text color={isCursor ? GREEN : GRAY}>{isCursor ? ' ▸' : ' '}</Text>
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text color={isCursor ? WHITE : GRAY} bold={inParty}>
{species.name}
</Text>
{inParty && <Text color={YELLOW}></Text>}
</Box>
);
})}
{startIdx + MAX_VISIBLE_DEX < discovered.length && (
<Text color={GRAY}> {discovered.length - startIdx - MAX_VISIBLE_DEX} more</Text>
)}
</>
) : (
<Text dimColor> No species discovered yet</Text>
)}
</Box>
{/* Discovered species */}
{discovered.length > 0 && (
<Box flexDirection="column" marginTop={0}>
<Text color={GRAY}> ({buddyData.dex.length}) </Text>
{discovered.map(entry => {
const species = getSpeciesData(entry.speciesId);
const inParty = buddyData.creatures.some(c => partySet.has(c.id) && c.speciesId === species.id);
{/* Divider */}
<Box flexDirection="column">
<Text color={GRAY}></Text>
</Box>
{/* Right: gen stats */}
<Box flexDirection="column" flexGrow={1} marginLeft={1}>
<Text color={GRAY}> </Text>
{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 (
<Box key={species.id}>
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
<Text color={WHITE} bold={inParty}>
{(species.names as Record<string, string>).zh ?? species.name}
</Text>
{inParty && <Text color={YELLOW}> </Text>}
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
{entry.caughtCount > 1 && <Text color={GRAY}> x{entry.caughtCount}</Text>}
<Box key={g.label}>
<Text color={GRAY}>{g.label.padEnd(8)}</Text>
<Text color={p >= 1 ? GREEN : p > 0 ? YELLOW : GRAY}>{miniBar}</Text>
<Text> <Text bold>{g.collected}</Text><Text color={GRAY}>/{g.total}</Text></Text>
</Box>
);
})}
{buddyData.dex.length > 15 && (
<Text color={GRAY}> {buddyData.dex.length - 15} </Text>
)}
</Box>
)}
{discovered.length === 0 && (
<Box marginTop={0}>
<Text dimColor> </Text>
</Box>
)}
</Box>
{/* Footer */}
<Box marginTop={0}>
<Text color={GRAY}>Turns:{buddyData.stats.totalTurns} Days:{buddyData.stats.consecutiveDays} Eggs:{buddyData.stats.totalEggsObtained} Evos:{buddyData.stats.totalEvolutions}</Text>
</Box>
{buddyData.eggs.length > 0 && (
<Box>
<Text color={YELLOW}>🥚 {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} steps</Text>
</Box>
<Box><Text color={YELLOW}>🥚 {buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps} steps</Text></Box>
)}
<Box>
<Text color={CYAN}>[] · [Enter] · [S] · [Esc] </Text>
</Box>
</Box>
);
}
@@ -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<Panel>('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<string | null>(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 (
<Box flexDirection="column">
{/* Header */}
<Box justifyContent="space-between">
<Box>
<Text bold color={CYAN}>PC Box</Text>
{held && <Text color={YELLOW}> Carrying: {getCreatureName(data.creatures.find(c => c.id === held.id)!)}</Text>}
</Box>
<Text color={GRAY}>{box.name} ({boxIdx + 1}/{data.boxes.length})</Text>
</Box>
<Box flexDirection="row">
{/* Left: Party */}
<Box flexDirection="column" width={16} borderStyle="round" borderColor={panel === 'party' ? CYAN : GRAY} paddingX={1}>
<Text bold color={CYAN}>Party</Text>
{data.party.map((slotId, i) => {
const c = slotId ? data.creatures.find(cr => cr.id === slotId) : null
const isCursor = panel === 'party' && i === partyCursor
return (
<Box key={i}>
<Text color={isCursor ? CYAN : GRAY}>{isCursor ? '▸' : ' '}</Text>
{c ? (
<Text>
<Text color={i === 0 ? YELLOW : WHITE} bold={isCursor}>
{getCreatureName(c).length > 8 ? getCreatureName(c).slice(0, 7) + '..' : getCreatureName(c)}
</Text>
<Text color={GRAY}> {c.level}</Text>
{c.isShiny && <Text color={YELLOW}></Text>}
</Text>
) : (
<Text color={GRAY}>---</Text>
)}
</Box>
)
})}
<Text color={GRAY}>Total: {data.creatures.length}</Text>
</Box>
{/* Right: Box grid */}
<Box flexDirection="column" borderStyle="round" borderColor={panel === 'box' ? CYAN : GRAY} paddingX={1} flexGrow={1}>
{Array.from({ length: boxRows }, (_, row) => (
<Box key={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 (
<Box key={col} width={11}>
{c ? (
<Text color={isCursor ? CYAN : GRAY} bold={isCursor}>
{isCursor ? '[' : ' '}
{getCreatureName(c).length > 5 ? getCreatureName(c).slice(0, 4) + '.' : getCreatureName(c).padEnd(5)}
{isCursor ? ']' : ' '}
{String(c.level).padStart(2)}
</Text>
) : (
<Text color={isCursor ? CYAN : undefined}>{isCursor ? '[ --- ]' : ' ... '}</Text>
)}
</Box>
)
})}
</Box>
))}
</Box>
</Box>
{/* Selected creature info */}
{selectedCreature && (
<Box flexDirection="column">
<Text color={GRAY}> {getCreatureName(selectedCreature)} </Text>
<Box>
<Text color={CYAN}>Lv.{selectedCreature.level}</Text>
<Text color={GRAY}> {getSpeciesData(selectedCreature.speciesId).name}</Text>
<Text color={GRAY}> {getGenderSymbol(selectedCreature.gender)}</Text>
{selectedCreature.isShiny && <Text color={YELLOW}> SHINY</Text>}
{partySet.has(selectedCreature.id) && <Text color={GREEN}> Party</Text>}
</Box>
</Box>
)}
{statusMsg && (
<Box><Text color={GREEN} italic>{statusMsg}</Text></Box>
)}
<Box>
<Text color={GRAY}>[Space] / · [Esc] / · [,/.] · [Tab] </Text>
</Box>
</Box>
)
}
// ─── 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<string>()
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
}