diff --git a/plans/phase-0.md b/plans/phase-0.md new file mode 100644 index 000000000..fa3ce9fce --- /dev/null +++ b/plans/phase-0.md @@ -0,0 +1,189 @@ +# Phase 0: 清除重复 — 委托 @pkmn + +## 目标 + +删除所有与 @pkmn 重复的硬编码数据和手写公式,统一委托给 @pkmn 生态。**零新功能,纯重构。** + +## 设计原则 + +先清后建。先消除重复代码,再在干净基础上添加新功能。此 Phase 不引入任何新类型或新功能。 + +## 改动 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `types.ts` | 删除 `NATURES` 常量(27 行) | `Dex.natures` 已有完整数据 | +| `data/nature.ts` | 重写 | `ALL_NATURE_NAMES` → 遍历 `Dex.natures`;`getNatureEffect()` → 查询 `Dex.natures.get()` | +| `data/evolution.ts` | 重写 | `EVOLUTION_CHAINS` → `Dex.species.get(id).evos`/`.evoLevel` 动态查询 | +| `core/creature.ts` | 重写 `calculateStats()` | 手写公式 → `gen.stats.calc()`(Nature ±10% 自动处理) | +| `data/species.ts` | 简化 `buildEvolutionChain()` | 已部分使用 Dex.evos,统一用新 `getNextEvolution()` | +| `data/pkmn.ts` | 统一 `gens` 单例 | 导出 `gen` 和 `TO_DEX_STAT` 映射,消除多处重复创建 `Generations` | + +## 使用 @pkmn 包 + +- `@pkmn/sim`(Dex.natures, Dex.species) +- `@pkmn/data`(stats.calc, Generations) + +## 保留不变 + +- `types.ts` 中的 `NatureName`/`NatureStat`/`NatureEffect` 类型定义(Creature 类型约束需要) +- `data/species.ts` 的 `SUPPLEMENT`(growthRate/captureRate/flavorText 不在 Dex 中) +- `data/names.ts`(i18n 多语言名称) +- `data/xpTable.ts`、`data/evMapping.ts`(完全自定义) +- `core/` 其他文件、`ui/`、`sprites/` + +## 详细实现 + +### 1. types.ts — 删除 NATURES + +删除 `types.ts` 中 `NATURES` 常量(约 27 行手写数据)。保留 `NatureName`、`NatureStat`、`NatureEffect` 类型。 + +### 2. data/nature.ts — 委托 Dex.natures + +```typescript +import { Dex } from '@pkmn/sim' +import type { NatureName, NatureEffect, NatureStat } from '../types' + +export function getAllNatureNames(): NatureName[] { + const names: NatureName[] = [] + for (const nature of Dex.natures) { + if (nature.exists) names.push(nature.id as NatureName) + } + return names +} + +export function randomNature(): NatureName { + const names = getAllNatureNames() + return names[Math.floor(Math.random() * names.length)]! +} + +export function getNatureEffect(nature: NatureName): NatureEffect { + const n = Dex.natures.get(nature) + if (!n?.exists) return { plus: null, minus: null } + return { + plus: (n.plus as NatureStat | undefined) ?? null, + minus: (n.minus as NatureStat | undefined) ?? null, + } +} +``` + +### 3. data/evolution.ts — 委托 Dex.species + +```typescript +import { Dex } from '@pkmn/sim' +import type { SpeciesId } from '../types' + +export interface EvolutionChainStep { + from: SpeciesId + to: SpeciesId + trigger: 'level_up' | 'item' | 'trade' | 'friendship' + minLevel?: number +} + +/** 查找物种的下一个进化(从 Dex 动态获取) */ +export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined { + const dex = Dex.species.get(speciesId) + if (!dex?.evos?.length) return undefined + + const target = dex.evos[0]!.toLowerCase() + const targetDex = Dex.species.get(target) + if (!targetDex?.exists) return undefined + + const trigger = dex.evoType === 'trade' ? 'trade' + : dex.evoType === 'useItem' ? 'item' + : dex.evoType === 'levelFriendship' ? 'friendship' + : 'level_up' + + return { + from: speciesId, + to: target as SpeciesId, + trigger, + minLevel: targetDex.evoLevel, + } +} +``` + +### 4. data/pkmn.ts — 统一 gens 单例 + 导出 stat 映射 + +```typescript +import { Dex } from '@pkmn/sim' +import { Generations } from '@pkmn/data' +import type { StatName } from '../types' + +// 统一单例(全包唯一入口) +const gens = new Generations(Dex as unknown as import('@pkmn/data').Dex) +export const gen = gens.get(9) + +// Stat key 映射:@pkmn 缩写 → 我们的 StatName +export const FROM_DEX_STAT: Record = { + hp: 'hp', atk: 'attack', def: 'defense', + spa: 'spAtk', spd: 'spDef', spe: 'speed', +} + +// Stat key 映射:我们的 StatName → @pkmn 缩写 +export const TO_DEX_STAT: Record = { + hp: 'hp', attack: 'atk', defense: 'def', + spAtk: 'spa', spDef: 'spd', speed: 'spe', +} + +// ...保留现有 getSpecies, getMove, getAbility, getType, mapBaseStats, mapGenderRatio, getPrimaryAbility ... +``` + +### 5. core/creature.ts — calculateStats 委托 stats.calc + +```typescript +import { gen, TO_DEX_STAT } from '../data/pkmn' +import { STAT_NAMES } from '../types' +import type { Creature, StatsResult } from '../types' + +export function calculateStats(creature: Creature): StatsResult { + const species = gen.species.get(creature.speciesId) + if (!species) throw new Error(`Species ${creature.speciesId} not found`) + + const nature = creature.nature ? gen.natures.get(creature.nature) : undefined + const result = {} as StatsResult + + for (const stat of STAT_NAMES) { + const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe' + result[stat] = gen.stats.calc( + dexKey, + species.baseStats[dexKey], + creature.iv[stat], + creature.ev[stat], + creature.level, + nature ?? undefined, + ) + } + + return result +} +``` + +**注意**:`gen.stats.calc()` 内部已处理 Nature ±10% 修正。Phase -1 计划中的手写 Nature 修正代码不再需要。 + +### 6. data/species.ts — 简化 buildEvolutionChain + +现有的 `buildEvolutionChain()` 已使用 `dex.evos`,只需确保它与新的 `getNextEvolution()` 一致。可简化为: + +```typescript +function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] { + const evo = getNextEvolution(speciesId) + if (!evo) return undefined + return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }] +} +``` + +## 验证 + +1. `bun run typecheck` 零错误 +2. `bun test packages/pokemon/` 全部通过 +3. `bun run dev` → `/buddy` 所有现有功能正常(pet、dex、egg、switch) +4. 性格效果正确:Adamant Charmander Lv50 ATK 应比 Hardy 高 ~10%,SPA 低 ~10% +5. 进化判定正确:Charmander Lv16 → Charmeleon,Squirtle Lv16 → Wartortle +6. stat 计算结果与旧实现数值一致 + +## 代码量 + +- 删除:~55 行(NATURES 27 行 + EVOLUTION_CHAINS 12 行 + calculateStats 手写公式 18 行 - 2 行) +- 新增:~40 行(委托代码) +- 净变化:-15 行 diff --git a/plans/phase-1.md b/plans/phase-1.md new file mode 100644 index 000000000..cc2ed03ef --- /dev/null +++ b/plans/phase-1.md @@ -0,0 +1,215 @@ +# Phase 1: 数据模型升级 — Creature v2 + 存储迁移 + +## 前置 + +Phase 0 完成(@pkmn 数据源已就位,Natures/Evolution/Stats 已委托) + +## 目标 + +扩展 Creature 类型(Nature/Moves/Ability/HeldItem),新增 PCBox/Bag,BuddyData v2 存储迁移。 + +## 新类型定义 + +### types.ts 新增 + +```typescript +// 招式槽位 +export type MoveSlot = { id: string; pp: number; maxPp: number } +export const EMPTY_MOVE: MoveSlot = { id: '', pp: 0, maxPp: 0 } + +// 道具 ID(Showdown 格式字符串) +export type ItemId = string + +// PC 箱(固定 30 槽) +export type PCBox = { + name: string + slots: (string | null)[] // 固定长度 30,存 creature ID +} + +// 背包条目 +export type BagEntry = { id: ItemId; count: number } +export type Bag = { items: BagEntry[] } +``` + +### Creature 扩展 + +```typescript +export type Creature = { + // ─── 标识 ─── + id: string // UUID + speciesId: SpeciesId + nickname?: string + + // ─── 基础属性 ─── + gender: Gender + level: number + xp: number // 当前等级进度 XP + totalXp: number // 累计总 XP + nature: NatureName // NEW: 性格 + isShiny: boolean + + // ─── 能力值 ─── + ev: Record // 努力值 + iv: Record // 个体值 (0-31) + + // ─── 战斗 ─── + moves: [MoveSlot, MoveSlot, MoveSlot, MoveSlot] // NEW: 4 招式槽位 + ability: string // NEW: Showdown 特性 ID + heldItem: ItemId | null // NEW: 携带道具 + + // ─── 关系 ─── + friendship: number // 亲密度 (0-255) + hatchedAt: number // 获得时间戳 + pokeball: string // NEW: 捕获球 +} +``` + +### BuddyData v2 + +```typescript +export type BuddyData = { + version: 2 + party: (string | null)[] // 固定 6 槽,party[0] = 活跃精灵 + boxes: PCBox[] // NEW: PC 箱(默认 8 箱 × 30 槽) + creatures: Creature[] // 主表 + eggs: Egg[] + dex: DexEntry[] + bag: Bag // NEW: 玩家背包 + stats: { + totalTurns: number + consecutiveDays: number + lastActiveDate: string + totalEggsObtained: number + totalEvolutions: number + battlesWon: number // NEW + battlesLost: number // NEW + } +} +``` + +## 设计决策 + +- Party 和 Box 只存 `id`,不嵌套对象 → 单一数据源,移动只改引用 +- Creature 主表用数组 → JSON 友好 +- 8 箱 × 30 槽 = 240 + 6 party = 246 上限 → 足够 MVP +- `activeCreatureId` 彻底删除 → Party slot 0 即活跃精灵 +- 战后自动恢复:HP/PP 满值,Creature 不存 currentHp +- 异常状态/能力阶级仅战斗内(Phase 2 BattleState),不持久化 + +## 新建文件 + +### data/learnsets.ts + +```typescript +import { Dex } from '@pkmn/sim' +import type { SpeciesId, MoveSlot } from '../types' +import { EMPTY_MOVE } from '../types' + +const GEN = 9 + +/** 获取物种在指定等级的默认招式(最后 4 个 level-up 招式) */ +export function getDefaultMoveset(speciesId: SpeciesId, level: number): [MoveSlot, MoveSlot, MoveSlot, MoveSlot] { + const learnset = Dex.learnsets.get(speciesId) + if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE] + + const levelUpMoves: { id: string; level: number }[] = [] + for (const [moveId, sources] of Object.entries(learnset.learnset)) { + for (const src of sources) { + if (src.startsWith(`${GEN}L`)) { + levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) }) + break + } + } + } + + levelUpMoves.sort((a, b) => a.level - b.level) + const available = levelUpMoves.filter(m => m.level <= level).slice(-4) + + const slots: MoveSlot[] = available.map(m => { + const dexMove = Dex.moves.get(m.id) + return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 } + }) + + while (slots.length < 4) slots.push(EMPTY_MOVE) + return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot] +} + +/** 获取物种的默认特性(第一个非隐藏特性) */ +export function getDefaultAbility(speciesId: SpeciesId): string { + const species = Dex.species.get(speciesId) + return species?.abilities?.['0']?.toLowerCase() ?? '' +} + +/** 获取物种在指定等级新可学的招式列表(用于升级检测) */ +export function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): { id: string; name: string }[] { + const learnset = Dex.learnsets.get(speciesId) + if (!learnset?.learnset) return [] + + const result: { id: string; name: string }[] = [] + for (const [moveId, sources] of Object.entries(learnset.learnset)) { + for (const src of sources) { + if (src.startsWith(`${GEN}L`)) { + const moveLevel = parseInt(src.slice(2)) + if (moveLevel > oldLevel && moveLevel <= newLevel) { + const dexMove = Dex.moves.get(moveId) + result.push({ id: moveId, name: dexMove?.name ?? moveId }) + } + break + } + } + } + return result +} +``` + +## 改动 + +| 文件 | 操作 | +|------|------| +| `types.ts` | 新增 MoveSlot/PCBox/Bag 类型;Creature 扩展字段;BuddyData v2;删除 `activeCreatureId` | +| `core/creature.ts` | `generateCreature()` 填充 nature(randomNature)、moves(getDefaultMoveset)、ability(getDefaultAbility) | +| `core/storage.ts` | BuddyData v2 默认值;v1→v2 迁移;PCBox 操作;Bag 操作 | +| `core/egg.ts` | `hatchEgg()` 返回的 creature 自动放入 party 空位或 PC 箱 | +| `index.ts` | 新增所有导出 | + +### storage.ts 新增函数 + +```typescript +// PC 箱操作 +depositToBox(data, creatureId): { data, deposited } +withdrawFromBox(data, creatureId): { data, withdrawn } +moveInBox(data, fromBox, fromSlot, toBox, toSlot): BuddyData +renameBox(data, boxIndex, name): BuddyData +findCreatureLocation(data, creatureId): { area, slot } | null +releaseCreature(data, creatureId): BuddyData +getTotalCreatureCount(data): number +getAllCreatureIds(data): string[] + +// 背包操作 +addItemToBag(data, itemId, count?): BuddyData +removeItemFromBag(data, itemId, count?): { data, removed } +getItemCount(data, itemId): number +``` + +### v1 → v2 迁移 + +- 保留 `party` 数组 +- 新增默认 8 箱空 `boxes` +- 不在 party 的精灵放入 Box 1 前几个槽位 +- 每个 creature 补全新字段:`nature`(随机)、`moves`(getDefaultMoveset)、`ability`(getDefaultAbility)、`heldItem: null`、`pokeball: 'pokeball'` +- 新增 `bag: { items: [] }` +- `stats` 补全 `battlesWon: 0`, `battlesLost: 0` +- 删除 `activeCreatureId` + +## 验证 + +1. `bun run typecheck` + `bun test packages/pokemon/` +2. 新 creature 带有随机 nature + 正确的 4 招(含 PP)+ 默认 ability +3. 旧 v1 数据自动迁移为 v2:boxes 生成、空 moves/ability 被回填 +4. PC 箱操作:存入/取出/移动/释放均正确 +5. 背包操作:添加/消耗/查询均正确 +6. `bun run dev` → `/buddy` 现有功能正常(pet、dex、egg、switch) + +## 代码量 + +新增 ~200 行(类型 + learnsets + PCBox/Bag 操作 + 迁移) diff --git a/plans/phase-2.md b/plans/phase-2.md new file mode 100644 index 000000000..a315a0205 --- /dev/null +++ b/plans/phase-2.md @@ -0,0 +1,188 @@ +# Phase 2: 战斗引擎 — @pkmn 薄适配层 + +## 前置 + +Phase 1 完成(Creature 含 moves/ability/nature/heldItem,PCBox/Bag 就位) + +## 目标 + +安装 `@pkmn/protocol`、`@pkmn/client`、`@pkmn/view`,构建战斗引擎作为 @pkmn 的薄适配层。纯逻辑层,不涉及 UI。 + +## 安装依赖 + +```bash +cd packages/pokemon && bun add @pkmn/protocol @pkmn/client @pkmn/view +``` + +## 战斗设计原则 + +- **战后自动恢复**:每场战斗结束后 HP/PP 自动恢复满值,Creature 不存 currentHp +- **不可逃跑**:野生战斗必须打到一方倒下 +- **状态不持久化**:异常状态、能力阶级仅存在于 BattleState +- **支持换人**:战斗中可切换 party 其他精灵(消耗回合) +- **支持道具**:战斗中使用背包道具(消耗回合) +- **特性/物品自动处理**:@pkmn/sim 内置 + +## 核心架构 + +``` +@pkmn/sim Battle ──(log)──→ @pkmn/protocol ──(Handler)──→ BattleEvent[] + ──(protocol)──→ @pkmn/client Battle ──(投影)──→ BattleState +``` + +## 新建文件 + +``` +packages/pokemon/src/battle/ +├── types.ts # BattleState, BattleEvent, BattleResult, PlayerAction, BattlePokemon +├── adapter.ts # Creature → PokemonSet 转换(stat key + gender 映射) +├── engine.ts # createBattle(), executeTurn() — 薄封装 @pkmn/sim Battle +├── handler.ts # @pkmn/protocol Handler → BattleEvent 转换 +├── ai.ts # AI 决策(随机合法招式) +├── settlement.ts # 战后结算(XP/EV/升级/进化/招式学习/背包扣减) +└── index.ts # 导出 +``` + +## 详细实现 + +### 1. battle/types.ts — 战斗类型 + +```typescript +export type StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'none' + +export type BattlePokemon = { + id: string // creature ID + speciesId: string + name: string + level: number + hp: number // 战斗中当前 HP + maxHp: number + types: string[] + moves: MoveOption[] + ability: string + heldItem: string | null + status: StatusCondition + statStages: Record // -6 到 +6 +} + +export type MoveOption = { + id: string; name: string; type: string + pp: number; maxPp: number; disabled: boolean +} + +export type PlayerAction = + | { type: 'move'; moveIndex: number } + | { type: 'switch'; creatureId: string } + | { type: 'item'; itemId: string } + +export type BattleEvent = + | { type: 'move'; side: 'player' | 'opponent'; move: string; user: string } + | { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number } + | { type: 'heal'; side: 'player' | 'opponent'; amount: number; percentage: number } + | { type: 'faint'; side: 'player' | 'opponent'; speciesId: string } + | { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string } + | { type: 'effectiveness'; multiplier: number } + | { type: 'crit' } + | { type: 'miss' } + | { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition } + | { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number } + | { type: 'ability'; side: 'player' | 'opponent'; ability: string } + | { type: 'item'; side: 'player' | 'opponent'; item: string } + | { type: 'fail'; reason: string } + | { type: 'turn'; number: number } + +export type BattleResult = { + winner: 'player' | 'opponent' + turns: number + xpGained: number + evGained: Record + participantIds: string[] +} + +export type BattleState = { + _sim: any // @pkmn/sim Battle 实例(内部) + _client: any // @pkmn/client Battle 实例(内部) + playerPokemon: BattlePokemon // 从 _client.p1.active[0] 投影 + opponentPokemon: BattlePokemon // 从 _client.p2.active[0] 投影 + playerParty: BattlePokemon[] // 从 _client.p1.team 投影 + turn: number + events: BattleEvent[] + finished: boolean + result?: BattleResult + usableItems: { id: string; name: string; count: number }[] +} +``` + +### 2. battle/adapter.ts — 格式转换 + +- `creatureToSet(creature)` → `PokemonSet`(stat key 映射 attack→atk, spAtk→spa 等) +- `wildPokemonToSet(speciesId, level)` → 野生对手 `PokemonSet` +- 复用 `TO_DEX_STAT` 映射(来自 `data/pkmn.ts`) + +### 3. battle/engine.ts — 核心引擎 + +- `createBattle(partyCreatures, opponentSpeciesId, opponentLevel, bagItems?)` → BattleState + - 创建 `@pkmn/sim.Battle` 实例(`gen9customgame` 格式) + - 创建 `@pkmn/client.Battle` 实例追踪状态 +- `executeTurn(state, action)` → BattleState + - 构建 choice 字符串(`move 1` / `switch 2`) + - AI 选招(随机合法招式) + - `battle.makeChoices(p1, p2)` + - 新 log → `@pkmn/protocol` Handler → BattleEvent[] + - 从 `_client` 投影 BattleState + +### 4. battle/handler.ts — 协议处理 + +- 实现 `Protocol.Handler` 接口 +- 每个 `|move|`、`|-damage|`、`|faint|` 等回调产出 `BattleEvent` +- 替代手写 switch-case 解析器 + +### 5. battle/ai.ts — AI 决策 + +初期实现:从对手可用招式中随机选一个(PP > 0)。后续可增加属性克制优先。 + +### 6. battle/settlement.ts — 战后结算 + +```typescript +settleBattle(data, battleState) → { + data: BuddyData + learnableMoves: { creatureId, moveId, moveName }[] + pendingEvolutions: { creatureId, from, to }[] +} +applyMoveLearn(data, creatureId, newMoveId, replaceIndex) → BuddyData +applyEvolution(data, creatureId) → BuddyData +``` + +结算流程:XP → EV → 升级 → 新招式检测 → 进化检测 → 背包扣减 → 统计更新。 + +## 使用 @pkmn 包 + +- `@pkmn/sim`(Battle 类、Dex.teams.pack) +- `@pkmn/protocol`(Protocol.Handler、Protocol.parse) +- `@pkmn/client`(Battle 状态追踪) +- `@pkmn/sets`(PokemonSet 类型) +- `@pkmn/view`(LogFormatter,可选) +- `@pkmn/data`(stats.calc,Phase 0 已集成) + +## 测试 + +**新文件**: `packages/pokemon/src/__tests__/battle.test.ts` + +- 创建战斗:BattleState 初始化正确 +- 选招回合:HP 更新 + 事件生成 +- 异常状态:状态附加/恢复 +- 换人:正确切换 + 对手出招 +- 道具:HP 恢复 + 背包扣减 +- 特性/物品:事件生成 +- 击倒 → 结束 → XP/EV 奖励 +- 战后不写入 Creature 持久化数据 + +## 验证 + +1. `bun test packages/pokemon/src/__tests__/battle.test.ts` 通过 +2. 可在 Node REPL 中完成完整战斗流程 +3. `bun run typecheck` 零错误 + +## 代码量 + +新增 ~300 行(适配层),避免 ~500 行手写引擎 diff --git a/plans/phase-3.md b/plans/phase-3.md new file mode 100644 index 000000000..63ed780a8 --- /dev/null +++ b/plans/phase-3.md @@ -0,0 +1,182 @@ +# Phase 3: 战斗 UI — 终端交互 + +## 前置 + +Phase 2 完成(引擎 API 就绪:createBattle, executeTurn, settleBattle, applyMoveLearn, applyEvolution) + +## 目标 + +基于 Phase 2 引擎 API,构建终端战斗交互界面。纯 UI 层,无引擎逻辑。 + +## 引擎 API 依赖 + +``` +createBattle(party, opponent, level, bagItems) → BattleState +executeTurn(state, action) → BattleState +settleBattle(data, state) → { data, learnableMoves, pendingEvolutions } +applyMoveLearn(data, creatureId, moveId, replaceIndex) → BuddyData +applyEvolution(data, creatureId) → BuddyData +``` + +## 新建文件 + +``` +packages/pokemon/src/ui/ +├── BattleConfigPanel.tsx # 战斗前配置 +├── BattleView.tsx # 战斗主界面 +├── SwitchPanel.tsx # 换人选择 +├── ItemPanel.tsx # 道具使用 +├── BattleResultPanel.tsx # 战斗结算 +├── MoveLearnPanel.tsx # 招式学习 +src/commands/buddy/ +└── BattleFlow.tsx # 状态机编排 +``` + +## 详细设计 + +### 1. BattleConfigPanel — 战斗配置 + +``` +┌── 战斗配置 ──────────────────────────────────┐ +│ 队伍: │ +│ ▶ Charizard Lv.36 🔥🦅 HP ██████ 100% │ +│ Venusaur Lv.28 🌿☢ HP ██████ 100% │ +│ [空] [空] [空] │ +│ │ +│ 对手: │ +│ [1] 随机遇战(等级自动匹配) │ +│ [2] 指定对手: _________ │ +│ │ +│ [Enter] 开始战斗 [ESC] 取消 │ +└──────────────────────────────────────────────┘ +``` + +Props: `{ party, onSubmit, onCancel }` + +交互:[1] 随机遇战 → ±5 级匹配;[2] 模糊搜索物种名;Enter 确认;ESC 取消。 + +### 2. BattleView — 战斗主界面 + +``` +┌────────────────────────────────────────────┐ +│ 野生 Blastoise (Lv.32) 💧 │ +│ HP ████░░░░ 78% [烧伤] │ +│ (对手精灵 ASCII) │ +│ ── vs ── │ +│ (我方精灵 ASCII) │ +│ Charizard (Lv.36) ♂ 🔥🦅 │ +│ HP ████████ 95% [特性: 猛火] │ +│ │ +│ > 选择行动: │ +│ [1] Flamethrower PP 15/15 │ +│ [2] Air Slash PP 15/15 │ +│ [3] Dragon Rage PP 10/10 │ +│ [4] Slash PP 20/20 │ +│ [S] 换人 [I] 道具 │ +│ │ +│ 效果拔群! Blastoise 受到 23 点伤害! │ +└────────────────────────────────────────────┘ +``` + +Props: `{ state: BattleState, onAction: (action) => void }` + +视觉:HP 条 >50% 绿/25-50% 黄/<25% 红;状态标签彩色;PP=0 灰显;最近 8 条事件日志。 + +### 3. SwitchPanel — 换人选择 + +``` +┌── 换人 ──────────────────────────┐ +│ [1] Venusaur (Lv.28) HP 100% │ +│ [2] Pikachu (Lv.25) HP 72% │ +│ [3] Wartortle (Lv.22) HP 45% ⚠ │ +│ [ESC] 取消 │ +└──────────────────────────────────┘ +``` + +Props: `{ party, activeId, onSelect, onCancel }` + +HP=0 灰显不可选;当前场上精灵标注 ▶ 不可选;ESC 取消。 + +### 4. ItemPanel — 道具使用 + +``` +┌── 道具 ──────────────────────────┐ +│ [1] 伤药 ×3 恢复 20 HP │ +│ [2] 好伤药 ×1 恢复 50 HP │ +│ [ESC] 取消 │ +└──────────────────────────────────┘ +``` + +Props: `{ items, onSelect, onCancel }` + +### 5. BattleResultPanel — 战斗结算 + +``` +┌── 战斗结束!胜利! ──────────────────────┐ +│ Charizard 获得了 340 经验值! │ +│ ████████████░░ 75% → 82% │ +│ ⬆ 升到了 Lv.37! │ +│ 努力值获得: ATK +2 DEF +1 │ +│ [Enter] 继续 │ +└─────────────────────────────────────────┘ +``` + +### 6. MoveLearnPanel — 招式学习 + +``` +┌── 新招式! ──────────────────────────────────┐ +│ Charizard 升到了 Lv.37! │ +│ 可以学习: Flamethrower 🔥 │ +│ 当前招式: │ +│ [1] Ember PP 35/35 │ +│ [2] Air Slash PP 15/15 ← 替换目标 │ +│ [3] Dragon Rage PP 10/10 │ +│ [4] Slash PP 20/20 │ +│ [Y] 学习 [N] 跳过 [← →] 切换替换目标 │ +└──────────────────────────────────────────────┘ +``` + +### 7. BattleFlow — 状态机编排 + +``` +config → battle ⇌ switch/item (子状态) → result → learnMoves → evolution → 完成 +``` + +- `config`:渲染 BattleConfigPanel → `createBattle()` +- `battle`:渲染 BattleView + - 选招 → `executeTurn({ type: 'move' })` + - S → SwitchPanel → `executeTurn({ type: 'switch' })` + - I → ItemPanel → `executeTurn({ type: 'item' })` + - `finished` → `settleBattle()` → `result` +- `result`:BattleResultPanel → learnMoves / evolution +- `learnMoves`:循环 MoveLearnPanel → `applyMoveLearn()` +- `evolution`:循环 EvolutionAnim → `applyEvolution()` +- 完成 → `saveBuddyData()` + +### 8. 命令集成 + +`/buddy battle` → 打开 BattleConfigPanel +`/buddy battle pikachu` → 快捷指定对手 + +`src/commands/buddy/index.ts` 更新 argumentHint:`'[pet|dex|egg|battle|switch|rename |on|off]'` + +## 使用 @pkmn 包 + +- `@pkmn/view`(LogFormatter,可选用于战斗日志格式化) + +## 验证 + +1. `bun run typecheck` 零错误 +2. `/buddy battle` 完整流程: + - 配置 → 选对手 → 开始战斗 + - 选招 → HP 变化 + 事件日志 + - S → 换人 → 对手出招 + - I → 选道具 → HP 恢复 + - 胜利 → 结算 → 升级提示 + - 新招式 → 确认学习/跳过 + - 进化 → 动画 +3. 战后数据持久化正确(XP/EV/招式/进化),HP/PP 下次战斗满值 + +## 代码量 + +新增 ~400 行(6 个 UI 组件 + 状态机 + 命令集成)