From 02783e4f5d9abf4776291a6e5413286cfdd8c17a Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 22 Apr 2026 17:21:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PC=20Box=20=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20+=20=E5=85=A8=E8=8B=B1=E6=96=87=E5=90=8D=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20+=20=E9=98=9F=E4=BC=8D=E8=A1=A5=E4=BD=8D=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .../pokemon/src/__tests__/storage.test.ts | 16 +- packages/pokemon/src/core/storage.ts | 17 +- packages/pokemon/src/dex/species.ts | 5 +- packages/pokemon/src/index.ts | 3 +- packages/pokemon/src/ui/CompanionCard.tsx | 4 +- packages/pokemon/src/ui/PokedexView.tsx | 2 +- packages/pokemon/src/ui/SpeciesDetail.tsx | 4 +- packages/pokemon/src/ui/SpeciesPicker.tsx | 2 +- src/commands/buddy/BuddyPanel.tsx | 644 ++++++++++++++++-- 9 files changed, 623 insertions(+), 74 deletions(-) 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 +}