Skip to content

Commit e9405e4

Browse files
feat: 战斗引擎全面升级 — 捕获/逃跑/多对手/AI/道具/状态
- 新增 capture.ts:Gen 9 捕获率计算,支持精灵球/状态/时间修正 - 实现逃跑概率公式 (Gen 9) 和失败累计机制 - createBattle 支持多对手 OpponentEntry[],AI 换人考虑属性克制 - AI 选招改为优先克制招式,避免蓄力招式和被抵抗招 - 野生招式从 Dex.data.Learnsets 按等级获取,替换硬编码映射 - 实现 Potion/SuperPotion/FullRestore 等回复药效果 - 野生对手随机持有道具(5%树果/专属、3%属性增强道具) - 新增 VolatileStatus 类型,BattlePokemon 添加 volatileStatus - needsSwitch 检测改为更健壮的 p1Fainted + hasAliveBench 逻辑 - 解决 #3 物品使用、#4 逃跑、#5 多精灵对战、#6 AI、#7 野生招式、 #10 捕获系统、#11 volatile状态、#12 天气/地形、#19 野生道具 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent be64db7 commit e9405e4

6 files changed

Lines changed: 568 additions & 27 deletions

File tree

packages/pokemon/src/battle/ai.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,73 @@
1+
import { Dex } from '@pkmn/sim'
12
import type { BattlePokemon } from './types'
23

34
/**
4-
* Simple AI: pick a random usable move.
5+
* AI move selection: prefers super-effective moves, avoids resisted moves,
6+
* falls back to random among usable moves.
57
*/
6-
export function chooseAIMove(pokemon: BattlePokemon): number {
8+
export function chooseAIMove(pokemon: BattlePokemon, opponentTypes?: string[]): number {
79
const usable = pokemon.moves
810
.map((m, i) => ({ move: m, index: i }))
911
.filter(({ move }) => move.pp > 0 && !move.disabled)
1012

1113
if (usable.length === 0) return 0 // Struggle
12-
return usable[Math.floor(Math.random() * usable.length)]!.index
14+
15+
// If no opponent type info, pick randomly
16+
if (!opponentTypes || opponentTypes.length === 0) {
17+
return usable[Math.floor(Math.random() * usable.length)]!.index
18+
}
19+
20+
// Classify moves by effectiveness against opponent
21+
const superEffective: number[] = []
22+
const neutral: number[] = []
23+
const resisted: number[] = []
24+
const statusMoves: number[] = [] // Lowest priority
25+
26+
for (const { move, index } of usable) {
27+
const dexMove = Dex.moves.get(move.id)
28+
if (!dexMove?.type) {
29+
neutral.push(index)
30+
continue
31+
}
32+
33+
const moveType = dexMove.type // Keep original case for Dex.getEffectiveness
34+
// Status moves and charge moves are lowest priority
35+
if (dexMove.category === 'Status' || dexMove.flags?.charge) {
36+
statusMoves.push(index)
37+
continue
38+
}
39+
40+
// Check effectiveness against all opponent types using Dex.getEffectiveness
41+
let totalEffectiveness = 0
42+
for (const rawOppType of opponentTypes) {
43+
// Dex.getEffectiveness expects capitalized type names
44+
const oppType = rawOppType.charAt(0).toUpperCase() + rawOppType.slice(1)
45+
totalEffectiveness += Dex.getEffectiveness(moveType, oppType)
46+
}
47+
48+
if (totalEffectiveness > 0) {
49+
superEffective.push(index)
50+
} else if (totalEffectiveness < 0) {
51+
resisted.push(index)
52+
} else {
53+
neutral.push(index)
54+
}
55+
}
56+
57+
// Priority: super-effective (70%) > neutral > super-effective (30%) > resisted > status
58+
const rand = Math.random()
59+
if (superEffective.length > 0 && rand < 0.7) {
60+
return superEffective[Math.floor(Math.random() * superEffective.length)]!
61+
}
62+
if (neutral.length > 0) {
63+
return neutral[Math.floor(Math.random() * neutral.length)]!
64+
}
65+
if (superEffective.length > 0) {
66+
return superEffective[Math.floor(Math.random() * superEffective.length)]!
67+
}
68+
if (resisted.length > 0) {
69+
return resisted[Math.floor(Math.random() * resisted.length)]!
70+
}
71+
// Only status moves available
72+
return statusMoves[Math.floor(Math.random() * statusMoves.length)]!
1373
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { Dex } from '@pkmn/sim'
2+
import type { SpeciesId } from '../types'
3+
import { getCaptureRate } from '../dex/pokedex-data'
4+
5+
/**
6+
* Gen 9 capture rate calculation.
7+
* Returns { captured: boolean, shakes: 0-3 }
8+
*
9+
* Formula:
10+
* a = (3 * maxHP - 2 * currentHP) * catchRate * ballModifier / (3 * maxHP)
11+
* b = 65536 / (255 / a) ^ (1/4) (shake probability)
12+
* For each of 4 shakes: if random(0,65535) < b → pass, else → break out
13+
*/
14+
15+
/** Pokeball catch rate modifiers */
16+
const BALL_MODIFIERS: Record<string, number> = {
17+
pokeball: 1,
18+
greatball: 1.5,
19+
ultraball: 2,
20+
masterball: 255, // always catches
21+
netball: 3.5, // bug/water bonus (applied below)
22+
diveball: 3.5, // underwater/surfing
23+
nestball: 1, // scales with level (applied below)
24+
repeatball: 3.5, // if already caught
25+
timerball: 1, // scales with turns (applied below)
26+
duskball: 3.5, // night/cave
27+
quickball: 5, // first turn
28+
luxuryball: 1,
29+
premierball: 1,
30+
cherishball: 1,
31+
healball: 1,
32+
friendball: 1,
33+
levelball: 1,
34+
lureball: 1,
35+
moonball: 1,
36+
loveball: 1,
37+
heavyball: 1,
38+
fastball: 1,
39+
sportball: 1,
40+
parkball: 255,
41+
beastball: 5, // Ultra Beasts
42+
}
43+
44+
/** Status condition catch rate multiplier */
45+
const STATUS_MODIFIERS: Record<string, number> = {
46+
none: 1,
47+
poison: 1.5,
48+
bad_poison: 1.5,
49+
burn: 1.5,
50+
paralysis: 1.5,
51+
freeze: 2,
52+
sleep: 2.5,
53+
}
54+
55+
export interface CaptureResult {
56+
captured: boolean
57+
shakes: number // 0-3 (3 means captured)
58+
critical: boolean // critical capture (Gen 5+)
59+
}
60+
61+
/**
62+
* Calculate capture attempt.
63+
* @param speciesId Opponent species
64+
* @param currentHp Opponent current HP
65+
* @param maxHp Opponent max HP
66+
* @param ballId Pokeball item ID
67+
* @param status Opponent status condition
68+
* @param turn Current battle turn number
69+
* @param isFirstTurn Whether it's the first turn of battle
70+
* @param isNight Whether it's nighttime (for Dusk Ball)
71+
* @param alreadyCaught Whether this species has been caught before (for Repeat Ball)
72+
* @param opponentLevel Opponent's level (for Nest Ball)
73+
*/
74+
export function attemptCapture(
75+
speciesId: SpeciesId,
76+
currentHp: number,
77+
maxHp: number,
78+
ballId: string,
79+
status: string = 'none',
80+
turn: number = 1,
81+
isFirstTurn: boolean = false,
82+
isNight: boolean = false,
83+
alreadyCaught: boolean = false,
84+
opponentLevel: number = 50,
85+
): CaptureResult {
86+
const catchRate = getCaptureRate(speciesId)
87+
88+
// Master Ball always catches
89+
if (ballId === 'masterball' || catchRate === 255) {
90+
return { captured: true, shakes: 3, critical: false }
91+
}
92+
93+
// Calculate ball modifier with conditional bonuses
94+
let ballModifier = BALL_MODIFIERS[ballId.toLowerCase()] ?? 1
95+
96+
// Quick Ball: 5x on first turn, 1x otherwise
97+
if (ballId === 'quickball') {
98+
ballModifier = isFirstTurn ? 5 : 1
99+
}
100+
101+
// Timer Ball: up to 4x after 10 turns
102+
if (ballId === 'timerball') {
103+
ballModifier = Math.min(4, 1 + (turn - 1) * 3 / 10)
104+
}
105+
106+
// Nest Ball: better for lower level wild Pokémon
107+
if (ballId === 'nestball') {
108+
ballModifier = Math.max(1, (40 - opponentLevel) / 10)
109+
}
110+
111+
// Dusk Ball: 3.5x at night or in caves
112+
if (ballId === 'duskball') {
113+
ballModifier = isNight ? 3.5 : 1
114+
}
115+
116+
// Repeat Ball: 3.5x if already caught
117+
if (ballId === 'repeatball') {
118+
ballModifier = alreadyCaught ? 3.5 : 1
119+
}
120+
121+
// Net Ball: 3.5x for Bug or Water types
122+
if (ballId === 'netball') {
123+
const species = Dex.species.get(speciesId)
124+
if (species?.types?.some((t: string) => t.toLowerCase() === 'bug' || t.toLowerCase() === 'water')) {
125+
ballModifier = 3.5
126+
}
127+
}
128+
129+
// Status modifier
130+
const statusMod = STATUS_MODIFIERS[status] ?? 1
131+
132+
// Catch rate formula (Gen 9)
133+
const hpFactor = (3 * maxHp - 2 * currentHp) / (3 * maxHp)
134+
const catchValue = hpFactor * catchRate * ballModifier * statusMod
135+
const a = Math.min(255, Math.floor(catchValue))
136+
137+
// Shake probability
138+
const b = Math.floor(65536 / Math.pow(255 / Math.max(1, a), 0.25))
139+
140+
// Perform 3 shake checks (4th check is automatic if all 3 pass)
141+
let shakes = 0
142+
let captured = true
143+
for (let i = 0; i < 3; i++) {
144+
const roll = Math.floor(Math.random() * 65536)
145+
if (roll < b) {
146+
shakes++
147+
} else {
148+
captured = false
149+
break
150+
}
151+
}
152+
153+
// Critical capture check (Gen 5+, rare)
154+
const dexCount = 0 // Could track Pokedex completion rate
155+
const criticalChance = Math.min(255, Math.floor(catchValue * dexCount / 256))
156+
const critical = criticalChance > 0 && Math.floor(Math.random() * 256) < criticalChance
157+
158+
if (critical) {
159+
// Critical capture only needs 1 shake
160+
const roll = Math.floor(Math.random() * 65536)
161+
captured = roll < b
162+
return { captured, shakes: captured ? 1 : 0, critical: true }
163+
}
164+
165+
return { captured, shakes, critical: false }
166+
}

0 commit comments

Comments
 (0)