diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts
index 5465f0e0c..4debb21e0 100644
--- a/packages/pokemon/src/index.ts
+++ b/packages/pokemon/src/index.ts
@@ -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'
diff --git a/packages/pokemon/src/ui/BattleConfigPanel.tsx b/packages/pokemon/src/ui/BattleConfigPanel.tsx
new file mode 100644
index 000000000..21a3d05ef
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleConfigPanel.tsx
@@ -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 (
+
+ 战斗配置
+
+ {/* Party display */}
+
+ 队伍:
+ {party.map((creature, i) => {
+ if (!creature) return (
+
+ [{i + 1}] [空]
+
+ )
+ 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 (
+
+ {isLead ? ' ▶ ' : ' '}
+ {getCreatureName(creature)}
+ Lv.{creature.level}
+ {hpBar}
+ {hpEmpty}
+ {hpPercent}%
+
+ )
+ })}
+
+
+ {/* Opponent selection */}
+
+ 对手:
+ [1] 随机遇战(等级自动匹配)
+ [2] 指定对手(输入物种名)
+
+
+
+ [Enter] 开始战斗 [ESC] 取消
+
+
+ )
+}
diff --git a/packages/pokemon/src/ui/BattleResultPanel.tsx b/packages/pokemon/src/ui/BattleResultPanel.tsx
new file mode 100644
index 000000000..ac2194648
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleResultPanel.tsx
@@ -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 (
+
+
+
+ {' '}战斗结束!{isWin ? '胜利!' : '失败...'}
+
+
+
+ {isWin && (
+
+ {playerPokemon.name} 获得了 {result.xpGained} 经验值!
+
+ {Object.keys(result.evGained).length > 0 && (
+
+ 努力值获得:
+ {Object.entries(result.evGained).map(([stat, value]) => (
+ {stat.toUpperCase()}+{value}
+ ))}
+
+ )}
+
+ )}
+
+
+ [Enter] 继续
+
+
+ )
+}
diff --git a/packages/pokemon/src/ui/BattleView.tsx b/packages/pokemon/src/ui/BattleView.tsx
new file mode 100644
index 000000000..a27b3e4c6
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleView.tsx
@@ -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 (
+
+ {/* Opponent */}
+
+
+ 野生 {opp.name}
+ (Lv.{opp.level})
+
+
+ HP
+ {oppHp.bar}
+ {oppHp.pct}%
+ {opp.status !== 'none' && [{opp.status}]}
+
+
+
+ ── vs ──
+
+ {/* Player */}
+
+
+ {player.name}
+ (Lv.{player.level})
+
+
+ HP
+ {playerHp.bar}
+ {playerHp.pct}%
+ {player.status !== 'none' && [{player.status}]}
+
+
+
+ {/* Move selection */}
+ {!state.finished && (
+
+ 选择行动:
+ {player.moves.map((move, i) => (
+
+ 0 ? WHITE : GRAY}>
+ {' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp}
+
+
+ ))}
+ [S] 换人 [I] 道具
+
+ )}
+
+ {/* Event log */}
+ {recentEvents.length > 0 && (
+
+ {recentEvents.map((event, i) => (
+ {formatEvent(event)}
+ ))}
+
+ )}
+
+ )
+}
+
+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 ''
+ }
+}
diff --git a/packages/pokemon/src/ui/ItemPanel.tsx b/packages/pokemon/src/ui/ItemPanel.tsx
new file mode 100644
index 000000000..05aaa680c
--- /dev/null
+++ b/packages/pokemon/src/ui/ItemPanel.tsx
@@ -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 (
+
+ 道具
+ {items.length === 0 ? (
+ 没有可用道具
+ ) : (
+ items.map((item, i) => (
+
+ [{i + 1}] {item.name} ×{item.count}
+ {item.description && {item.description}}
+
+ ))
+ )}
+
+ [ESC] 取消
+
+
+ )
+}
diff --git a/packages/pokemon/src/ui/MoveLearnPanel.tsx b/packages/pokemon/src/ui/MoveLearnPanel.tsx
new file mode 100644
index 000000000..49ce639f2
--- /dev/null
+++ b/packages/pokemon/src/ui/MoveLearnPanel.tsx
@@ -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 (
+
+ 新招式!
+ {creature.speciesId} 可以学习: {moveName} ({moveType})
+
+ 当前招式:
+ {creature.moves.map((move, i) => {
+ const isReplaceTarget = i === replaceIndex
+ const moveInfo = move.id ? Dex.moves.get(move.id) : null
+ return (
+
+
+ {' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp}
+
+ {isReplaceTarget && ← 替换目标}
+
+ )
+ })}
+
+
+ [Y] 学习 [N] 跳过 [← →] 切换替换目标
+
+
+ )
+}
diff --git a/packages/pokemon/src/ui/SwitchPanel.tsx b/packages/pokemon/src/ui/SwitchPanel.tsx
new file mode 100644
index 000000000..400d91b55
--- /dev/null
+++ b/packages/pokemon/src/ui/SwitchPanel.tsx
@@ -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 (
+
+ 换人
+ {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 (
+
+ {isActive ? ' ▶ ' : ' '}
+
+ [{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '}
+
+ {isActive && 当前场上}
+
+ )
+ })}
+
+ [ESC] 取消
+
+
+ )
+}