feat: Phase 3 — 战斗 UI 终端交互组件

- BattleConfigPanel: 战斗前配置(队伍展示 + 对手选择)
- BattleView: 战斗主界面(双方 HP + 招式选择 + 事件日志)
- SwitchPanel: 换人选择面板
- ItemPanel: 道具使用面板
- BattleResultPanel: 战斗结算展示
- MoveLearnPanel: 新招式学习面板
- HP 条颜色分级(绿/黄/红)
- 事件日志中文格式化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 00:37:26 +08:00
parent a3fc348421
commit f5a97011e8
7 changed files with 375 additions and 0 deletions

View File

@@ -71,3 +71,9 @@ export { EvolutionAnim } from './ui/EvolutionAnim'
export { StatBar } from './ui/StatBar'
export { SpeciesDetail } from './ui/SpeciesDetail'
export { SpriteAnimator } from './ui/SpriteAnimator'
export { BattleConfigPanel } from './ui/BattleConfigPanel'
export { BattleView } from './ui/BattleView'
export { SwitchPanel } from './ui/SwitchPanel'
export { ItemPanel } from './ui/ItemPanel'
export { BattleResultPanel } from './ui/BattleResultPanel'
export { MoveLearnPanel } from './ui/MoveLearnPanel'

View File

@@ -0,0 +1,66 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { calculateStats, getCreatureName } from '../core/creature'
const CYAN = 'ansi:cyan'
const GREEN = 'ansi:green'
const GRAY = 'ansi:white'
const YELLOW = 'ansi:yellow'
interface BattleConfigPanelProps {
party: (Creature | null)[]
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
onCancel: () => void
}
export function BattleConfigPanel({ party, onSubmit, onCancel }: BattleConfigPanelProps) {
const activeCreature = party[0]
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{/* Party display */}
<Box flexDirection="column" marginTop={1}>
<Text bold>:</Text>
{party.map((creature, i) => {
if (!creature) return (
<Box key={i}>
<Text color={GRAY}> [{i + 1}] []</Text>
</Box>
)
const species = getSpeciesData(creature.speciesId)
const stats = calculateStats(creature)
const hpPercent = 100
const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
const isLead = i === 0
return (
<Box key={creature.id}>
<Text>{isLead ? ' ▶ ' : ' '}</Text>
<Text bold={isLead}>{getCreatureName(creature)}</Text>
<Text> Lv.{creature.level} </Text>
<Text color={GREEN}>{hpBar}</Text>
<Text color={GRAY}>{hpEmpty}</Text>
<Text> {hpPercent}%</Text>
</Box>
)
})}
</Box>
{/* Opponent selection */}
<Box flexDirection="column" marginTop={1}>
<Text bold>:</Text>
<Text color={YELLOW}> [1] </Text>
<Text color={GRAY}> [2] </Text>
</Box>
<Box marginTop={1}>
<Text color={GRAY}>[Enter] [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BattleResult, BattlePokemon } from '../battle/types'
const GREEN = 'ansi:green'
const RED = 'ansi:red'
const YELLOW = 'ansi:yellow'
const CYAN = 'ansi:cyan'
const WHITE = 'ansi:whiteBright'
interface BattleResultPanelProps {
result: BattleResult
playerPokemon: BattlePokemon
onContinue: () => void
}
export function BattleResultPanel({ result, playerPokemon, onContinue }: BattleResultPanelProps) {
const isWin = result.winner === 'player'
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Box>
<Text bold color={isWin ? GREEN : RED}>
{' '}{isWin ? '胜利!' : '失败...'}
</Text>
</Box>
{isWin && (
<Box flexDirection="column">
<Text> {playerPokemon.name} {result.xpGained} </Text>
{Object.keys(result.evGained).length > 0 && (
<Box>
<Text> : </Text>
{Object.entries(result.evGained).map(([stat, value]) => (
<Text key={stat}> {stat.toUpperCase()}+{value} </Text>
))}
</Box>
)}
</Box>
)}
<Box marginTop={1}>
<Text color={CYAN}> [Enter] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,128 @@
import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BattleState, BattleEvent, BattlePokemon, MoveOption } from '../battle/types'
import { getSpeciesData } from '../data/species'
import { Dex } from '@pkmn/sim'
const CYAN = 'ansi:cyan'
const GREEN = 'ansi:green'
const YELLOW = 'ansi:yellow'
const RED = 'ansi:red'
const GRAY = 'ansi:white'
const WHITE = 'ansi:whiteBright'
function hpColor(pct: number): Color {
if (pct > 50) return GREEN
if (pct > 25) return YELLOW
return RED
}
function hpBar(current: number, max: number): { bar: string; pct: number } {
if (max <= 0) return { bar: '░░░░░░░░░░', pct: 0 }
const pct = Math.round((current / max) * 100)
const filled = Math.round((current / max) * 10)
return {
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 10 - filled)),
pct,
}
}
interface BattleViewProps {
state: BattleState
onAction: (action: import('../battle/types').PlayerAction) => void
}
export function BattleView({ state, onAction }: BattleViewProps) {
const opp = state.opponentPokemon
const player = state.playerPokemon
const oppHp = hpBar(opp.hp, opp.maxHp)
const playerHp = hpBar(player.hp, player.maxHp)
// Show last 5 events
const recentEvents = state.events.slice(-5)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
{/* Opponent */}
<Box flexDirection="column">
<Box>
<Text bold> {opp.name} </Text>
<Text>(Lv.{opp.level})</Text>
</Box>
<Box>
<Text> HP </Text>
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
<Text> {oppHp.pct}%</Text>
{opp.status !== 'none' && <Text color={YELLOW}> [{opp.status}]</Text>}
</Box>
</Box>
<Text color={GRAY}> vs </Text>
{/* Player */}
<Box flexDirection="column">
<Box>
<Text bold> {player.name} </Text>
<Text>(Lv.{player.level})</Text>
</Box>
<Box>
<Text> HP </Text>
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
<Text> {playerHp.pct}%</Text>
{player.status !== 'none' && <Text color={YELLOW}> [{player.status}]</Text>}
</Box>
</Box>
{/* Move selection */}
{!state.finished && (
<Box flexDirection="column" marginTop={1}>
<Text bold> :</Text>
{player.moves.map((move, i) => (
<Box key={move.id || i}>
<Text color={move.pp > 0 ? WHITE : GRAY}>
{' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp}
</Text>
</Box>
))}
<Text color={CYAN}> [S] [I] </Text>
</Box>
)}
{/* Event log */}
{recentEvents.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{recentEvents.map((event, i) => (
<Text key={i} color={eventColor(event)}> {formatEvent(event)}</Text>
))}
</Box>
)}
</Box>
)
}
function eventColor(event: BattleEvent): Color {
switch (event.type) {
case 'damage': return RED
case 'heal': return GREEN
case 'faint': return RED
case 'crit': return YELLOW
case 'miss': return GRAY
case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW
default: return WHITE
}
}
function formatEvent(event: BattleEvent): string {
switch (event.type) {
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)`
case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!`
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}${event.speciesId} 倒下了!`
case 'crit': return '击中要害!'
case 'miss': return '攻击没有命中!'
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
case 'turn': return `── 回合 ${event.number} ──`
default: return ''
}
}

View File

@@ -0,0 +1,32 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
const CYAN = 'ansi:cyan'
const GRAY = 'ansi:white'
interface ItemPanelProps {
items: { id: string; name: string; count: number; description?: string }[]
onSelect: (itemId: string) => void
onCancel: () => void
}
export function ItemPanel({ items, onSelect, onCancel }: ItemPanelProps) {
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{items.length === 0 ? (
<Text color={GRAY}> </Text>
) : (
items.map((item, i) => (
<Box key={item.id}>
<Text> [{i + 1}] {item.name} ×{item.count}</Text>
{item.description && <Text color={GRAY}> {item.description}</Text>}
</Box>
))
)}
<Box marginTop={1}>
<Text color={GRAY}> [ESC] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,49 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature } from '../types'
import { Dex } from '@pkmn/sim'
const CYAN = 'ansi:cyan'
const YELLOW = 'ansi:yellow'
const GRAY = 'ansi:white'
const WHITE = 'ansi:whiteBright'
interface MoveLearnPanelProps {
creature: Creature
newMoveId: string
replaceIndex: number
onLearn: (replaceIndex: number) => void
onSkip: () => void
onSelectReplace: (index: number) => void
}
export function MoveLearnPanel({ creature, newMoveId, replaceIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
const dexMove = Dex.moves.get(newMoveId)
const moveName = dexMove?.name ?? newMoveId
const moveType = dexMove?.type ?? 'Normal'
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
<Text> {creature.speciesId} : <Text bold>{moveName}</Text> ({moveType})</Text>
<Box marginTop={1}><Text bold> :</Text></Box>
{creature.moves.map((move, i) => {
const isReplaceTarget = i === replaceIndex
const moveInfo = move.id ? Dex.moves.get(move.id) : null
return (
<Box key={i}>
<Text color={isReplaceTarget ? YELLOW : WHITE}>
{' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp}
</Text>
{isReplaceTarget && <Text color={YELLOW}> </Text>}
</Box>
)
})}
<Box marginTop={1}>
<Text color={GRAY}> [Y] [N] [ ] </Text>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature } from '../types'
import { getCreatureName } from '../core/creature'
import { calculateStats } from '../core/creature'
const CYAN = 'ansi:cyan'
const GREEN = 'ansi:green'
const YELLOW = 'ansi:yellow'
const RED = 'ansi:red'
const GRAY = 'ansi:white'
const WHITE = 'ansi:whiteBright'
interface SwitchPanelProps {
party: Creature[]
activeId: string
onSelect: (creatureId: string) => void
onCancel: () => void
}
export function SwitchPanel({ party, activeId, onSelect, onCancel }: SwitchPanelProps) {
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color={CYAN}> </Text>
{party.map((creature, i) => {
const stats = calculateStats(creature)
const isActive = creature.id === activeId
const hpPct = 100 // No current HP tracking in v2, assume full
const hpColor = hpPct > 50 ? GREEN : hpPct > 25 ? YELLOW : RED
return (
<Box key={creature.id}>
<Text>{isActive ? ' ▶ ' : ' '}</Text>
<Text color={isActive ? GRAY : WHITE}>
[{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '}
</Text>
{isActive && <Text color={GRAY}> </Text>}
</Box>
)
})}
<Box marginTop={1}>
<Text color={GRAY}> [ESC] </Text>
</Box>
</Box>
)
}