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] 取消 + + + ) +}