mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -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'
|
||||
|
||||
66
packages/pokemon/src/ui/BattleConfigPanel.tsx
Normal file
66
packages/pokemon/src/ui/BattleConfigPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
packages/pokemon/src/ui/BattleResultPanel.tsx
Normal file
48
packages/pokemon/src/ui/BattleResultPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
packages/pokemon/src/ui/BattleView.tsx
Normal file
128
packages/pokemon/src/ui/BattleView.tsx
Normal 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 ''
|
||||
}
|
||||
}
|
||||
32
packages/pokemon/src/ui/ItemPanel.tsx
Normal file
32
packages/pokemon/src/ui/ItemPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
packages/pokemon/src/ui/MoveLearnPanel.tsx
Normal file
49
packages/pokemon/src/ui/MoveLearnPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
packages/pokemon/src/ui/SwitchPanel.tsx
Normal file
46
packages/pokemon/src/ui/SwitchPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user