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[],
}