mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
{' '}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user