mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 一大坨优化
This commit is contained in:
@@ -293,7 +293,7 @@ bun run typecheck # equivalent to bun run typecheck
|
||||
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。**开发任何 TUI 组件前,必须先查阅 `docs/ink-guide.md`**,该文档涵盖了双层组件设计(Base vs Themed)、布局系统、主题色、快捷键、所有 Hooks 和设计系统组件的用法。日常使用 `Box`/`Text`(Themed 版),用 `useKeybindings` 代替直接 `useInput`。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -270,7 +270,6 @@
|
||||
"dependencies": {
|
||||
"@pkmn/client": "^0.7.2",
|
||||
"@pkmn/protocol": "^0.7.2",
|
||||
"@pkmn/view": "^0.7.2",
|
||||
},
|
||||
},
|
||||
"packages/remote-control-server": {
|
||||
@@ -990,8 +989,6 @@
|
||||
|
||||
"@pkmn/types": ["@pkmn/types@4.0.0", "", {}, "sha512-gR2s/pxJYEegek1TtsYCQupNR3d5hMlcJFsiD+2LyfKr4tc+gETTql47tWLX5mFSbPcbXh7f4+7txlMIDoZx/g=="],
|
||||
|
||||
"@pkmn/view": ["@pkmn/view@0.7.2", "", { "dependencies": { "@pkmn/protocol": "^0.7.2", "@pkmn/types": "^4.0.0" }, "bin": { "format-battle": "format-battle" } }, "sha512-SBaBIAuyJ/iGfYQxfzQ6jXv64Qz1/pIo5gjCXT9AfsErkHT27VwIhEEd2vUlD0bdfx802sPNvzHvLYJWMtss1w=="],
|
||||
|
||||
"@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="],
|
||||
|
||||
"@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "https://registry.npmmirror.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="],
|
||||
|
||||
1218
docs/ink-guide.md
Normal file
1218
docs/ink-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
337
packages/pokemon/src/__tests__/battle-helper.ts
Normal file
337
packages/pokemon/src/__tests__/battle-helper.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Battle Test Framework
|
||||
*
|
||||
* Fluent API for testing Pokémon battle scenarios:
|
||||
*
|
||||
* const s = await battleScenario()
|
||||
* .party('charmander', 50, ['flamethrower'])
|
||||
* .party('bulbasaur', 30, ['vinewhip'])
|
||||
* .opponent('squirtle', 50)
|
||||
* .start()
|
||||
*
|
||||
* const state = await s.useMove(0).runTurn()
|
||||
* s.expect(state).hasDamage('opponent')
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { createBattle, executeTurn, executeSwitch } from '../battle/engine'
|
||||
import type { BattleState } from '../battle/types'
|
||||
import type { BattleInit } from '../battle/engine'
|
||||
import type { BattleEvent } from '../battle/types'
|
||||
import type { Creature, SpeciesId, StatName } from '../types'
|
||||
|
||||
// ─── Creature Builder ───
|
||||
|
||||
interface CreatureSpec {
|
||||
id: string
|
||||
speciesId: SpeciesId
|
||||
level: number
|
||||
moves: string[]
|
||||
ability?: string
|
||||
nature?: string
|
||||
ev?: Partial<Record<StatName, number>>
|
||||
iv?: Partial<Record<StatName, number>>
|
||||
}
|
||||
|
||||
function buildCreature(spec: CreatureSpec, index: number): Creature {
|
||||
return {
|
||||
id: spec.id ?? `test-${index}`,
|
||||
speciesId: spec.speciesId,
|
||||
gender: 'male',
|
||||
level: spec.level,
|
||||
xp: 0,
|
||||
totalXp: 0,
|
||||
nature: (spec.nature ?? 'adamant') as Creature['nature'],
|
||||
ev: {
|
||||
hp: spec.ev?.hp ?? 0,
|
||||
attack: spec.ev?.attack ?? 0,
|
||||
defense: spec.ev?.defense ?? 0,
|
||||
spAtk: spec.ev?.spAtk ?? 0,
|
||||
spDef: spec.ev?.spDef ?? 0,
|
||||
speed: spec.ev?.speed ?? 0,
|
||||
},
|
||||
iv: {
|
||||
hp: spec.iv?.hp ?? 31,
|
||||
attack: spec.iv?.attack ?? 31,
|
||||
defense: spec.iv?.defense ?? 31,
|
||||
spAtk: spec.iv?.spAtk ?? 31,
|
||||
spDef: spec.iv?.spDef ?? 31,
|
||||
speed: spec.iv?.speed ?? 31,
|
||||
},
|
||||
moves: [
|
||||
...spec.moves.map(m => ({ id: m, pp: 15, maxPp: 15 })),
|
||||
...Array(Math.max(0, 4 - spec.moves.length)).fill({ id: '', pp: 0, maxPp: 0 }),
|
||||
] as [import('../types').MoveSlot, import('../types').MoveSlot, import('../types').MoveSlot, import('../types').MoveSlot],
|
||||
ability: spec.ability ?? 'blaze',
|
||||
heldItem: null,
|
||||
friendship: 70,
|
||||
isShiny: false,
|
||||
hatchedAt: Date.now(),
|
||||
pokeball: 'pokeball',
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scenario Builder ───
|
||||
|
||||
export interface BattleScenario {
|
||||
/** Add a party member (first = lead) */
|
||||
party(species: SpeciesId, level: number, moves: string[], opts?: Partial<CreatureSpec>): BattleScenario
|
||||
/** Set opponent (wild Pokémon) */
|
||||
opponent(species: SpeciesId, level: number): BattleScenario
|
||||
/** Create the battle and return runner */
|
||||
start(): Promise<BattleRunner>
|
||||
}
|
||||
|
||||
export interface BattleRunner {
|
||||
/** Queue a move action (0-indexed) */
|
||||
useMove(index: number): BattleRunner
|
||||
/** Queue a switch action (party slot index, 0-indexed) */
|
||||
switchTo(partyIndex: number): BattleRunner
|
||||
/** Execute one turn with queued action, return state */
|
||||
runTurn(): Promise<BattleState>
|
||||
/** Keep using move 0 until battle ends or max turns reached */
|
||||
runUntilEnd(maxTurns?: number): Promise<BattleState>
|
||||
/** Execute forced switch after faint */
|
||||
doSwitch(partyIndex: number): Promise<BattleState>
|
||||
/** Get current battle state (re-projected from Battle object) */
|
||||
readonly state: BattleState
|
||||
/** Assertion helpers */
|
||||
expect(state: BattleState): BattleAssertions
|
||||
}
|
||||
|
||||
export interface BattleAssertions {
|
||||
/** Battle has not ended */
|
||||
ongoing(): BattleAssertions
|
||||
/** Battle has ended */
|
||||
finished(): BattleAssertions
|
||||
/** Player won */
|
||||
playerWon(): BattleAssertions
|
||||
/** Opponent won */
|
||||
opponentWon(): BattleAssertions
|
||||
/** Player's active HP is full */
|
||||
playerHpFull(): BattleAssertions
|
||||
/** Player's active HP is below threshold (absolute) */
|
||||
playerHpBelow(hp: number): BattleAssertions
|
||||
/** Player's active HP percentage is below threshold */
|
||||
playerHpPctBelow(pct: number): BattleAssertions
|
||||
/** Opponent's active HP is full */
|
||||
opponentHpFull(): BattleAssertions
|
||||
/** Opponent's active HP is below threshold */
|
||||
opponentHpBelow(hp: number): BattleAssertions
|
||||
/** Player needs to switch (active fainted, bench alive) */
|
||||
needsSwitch(): BattleAssertions
|
||||
/** Player's active Pokémon has fainted */
|
||||
playerFainted(): BattleAssertions
|
||||
/** Opponent's active Pokémon has fainted */
|
||||
opponentFainted(): BattleAssertions
|
||||
/** Player's active species matches */
|
||||
playerSpecies(species: SpeciesId): BattleAssertions
|
||||
/** Opponent's active species matches */
|
||||
opponentSpecies(species: SpeciesId): BattleAssertions
|
||||
/** Events contain at least one of given type (optionally for given side) */
|
||||
hasEvent(type: BattleEvent['type'], side?: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain damage for given side */
|
||||
hasDamage(side: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain a move event for given side */
|
||||
hasMove(side: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain a faint event for given side */
|
||||
hasFaint(side: 'player' | 'opponent'): BattleAssertions
|
||||
/** Events contain super-effective hit */
|
||||
hasSuperEffective(): BattleAssertions
|
||||
/** Events contain resisted hit */
|
||||
hasResisted(): BattleAssertions
|
||||
/** Events contain critical hit */
|
||||
hasCrit(): BattleAssertions
|
||||
/** Turn number matches */
|
||||
turnIs(n: number): BattleAssertions
|
||||
/** Player party has N alive (hp > 0) Pokémon */
|
||||
aliveInParty(n: number): BattleAssertions
|
||||
/** Generic assertion */
|
||||
satisfies(fn: (state: BattleState) => boolean, msg?: string): BattleAssertions
|
||||
}
|
||||
|
||||
// ─── Implementation ───
|
||||
|
||||
class BattleScenarioImpl implements BattleScenario {
|
||||
private _party: CreatureSpec[] = []
|
||||
private _opponentSpecies: SpeciesId = 'pikachu'
|
||||
private _opponentLevel = 5
|
||||
|
||||
party(species: SpeciesId, level: number, moves: string[], opts?: Partial<CreatureSpec>): BattleScenario {
|
||||
this._party.push({
|
||||
id: opts?.id ?? `p${this._party.length + 1}`,
|
||||
speciesId: species,
|
||||
level,
|
||||
moves,
|
||||
...opts,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
opponent(species: SpeciesId, level: number): BattleScenario {
|
||||
this._opponentSpecies = species
|
||||
this._opponentLevel = level
|
||||
return this
|
||||
}
|
||||
|
||||
async start(): Promise<BattleRunner> {
|
||||
if (this._party.length === 0) {
|
||||
this._party.push({ id: 'p1', speciesId: 'charmander', level: 50, moves: ['tackle'] })
|
||||
}
|
||||
const creatures = this._party.map((s, i) => buildCreature(s, i))
|
||||
const init = await createBattle(creatures, this._opponentSpecies, this._opponentLevel)
|
||||
return new BattleRunnerImpl(init)
|
||||
}
|
||||
}
|
||||
|
||||
class BattleRunnerImpl implements BattleRunner {
|
||||
private _init: BattleInit
|
||||
private _pendingAction: { type: 'move'; index: number } | { type: 'switch'; partyIndex: number } | null = null
|
||||
|
||||
constructor(init: BattleInit) {
|
||||
this._init = init
|
||||
}
|
||||
|
||||
get state(): BattleState {
|
||||
return this._init.state
|
||||
}
|
||||
|
||||
useMove(index: number): BattleRunner {
|
||||
this._pendingAction = { type: 'move', index }
|
||||
return this
|
||||
}
|
||||
|
||||
switchTo(partyIndex: number): BattleRunner {
|
||||
this._pendingAction = { type: 'switch', partyIndex }
|
||||
return this
|
||||
}
|
||||
|
||||
async runTurn(): Promise<BattleState> {
|
||||
const action = this._pendingAction
|
||||
this._pendingAction = null
|
||||
|
||||
if (!action) {
|
||||
// Default: use move 0
|
||||
return executeTurn(this._init, { type: 'move', moveIndex: 0 })
|
||||
}
|
||||
|
||||
if (action.type === 'move') {
|
||||
return executeTurn(this._init, { type: 'move', moveIndex: action.index })
|
||||
} else {
|
||||
return executeTurn(this._init, { type: 'switch', partyIndex: action.partyIndex })
|
||||
}
|
||||
}
|
||||
|
||||
async runUntilEnd(maxTurns = 100): Promise<BattleState> {
|
||||
let state = this._init.state
|
||||
for (let i = 0; i < maxTurns && !state.finished; i++) {
|
||||
if (state.needsSwitch) {
|
||||
// Auto-switch to first alive bench
|
||||
const alive = state.playerParty.findIndex((p: any, idx: any) => idx > 0 && p.hp > 0)
|
||||
if (alive >= 0) {
|
||||
state = await executeSwitch(this._init, alive)
|
||||
} else break
|
||||
}
|
||||
state = await executeTurn(this._init, { type: 'move', moveIndex: 0 })
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
async doSwitch(partyIndex: number): Promise<BattleState> {
|
||||
return executeSwitch(this._init, partyIndex)
|
||||
}
|
||||
|
||||
expect(state: BattleState): BattleAssertions {
|
||||
return new BattleAssertionsImpl(state)
|
||||
}
|
||||
}
|
||||
|
||||
class BattleAssertionsImpl implements BattleAssertions {
|
||||
constructor(private s: BattleState) {}
|
||||
|
||||
ongoing() { expect(this.s.finished).toBe(false); return this }
|
||||
finished() { expect(this.s.finished).toBe(true); return this }
|
||||
playerWon() { expect(this.s.result?.winner).toBe('player'); return this }
|
||||
opponentWon() { expect(this.s.result?.winner).toBe('opponent'); return this }
|
||||
|
||||
playerHpFull() { expect(this.s.playerPokemon.hp).toBe(this.s.playerPokemon.maxHp); return this }
|
||||
playerHpBelow(hp: number) { expect(this.s.playerPokemon.hp).toBeLessThan(hp); return this }
|
||||
playerHpPctBelow(pct: number) {
|
||||
const actual = this.s.playerPokemon.maxHp > 0 ? (this.s.playerPokemon.hp / this.s.playerPokemon.maxHp) * 100 : 0
|
||||
expect(actual).toBeLessThan(pct)
|
||||
return this
|
||||
}
|
||||
opponentHpFull() { expect(this.s.opponentPokemon.hp).toBe(this.s.opponentPokemon.maxHp); return this }
|
||||
opponentHpBelow(hp: number) { expect(this.s.opponentPokemon.hp).toBeLessThan(hp); return this }
|
||||
|
||||
needsSwitch() { expect(this.s.needsSwitch).toBe(true); return this }
|
||||
playerFainted() { expect(this.s.playerPokemon.hp).toBe(0); return this }
|
||||
opponentFainted() { expect(this.s.opponentPokemon.hp).toBe(0); return this }
|
||||
|
||||
playerSpecies(sp: SpeciesId) { expect(this.s.playerPokemon.speciesId).toBe(sp); return this }
|
||||
opponentSpecies(sp: SpeciesId) { expect(this.s.opponentPokemon.speciesId).toBe(sp); return this }
|
||||
|
||||
hasEvent(type: BattleEvent['type'], side?: 'player' | 'opponent') {
|
||||
const has = this.s.events.some(e =>
|
||||
e.type === type && (side === undefined || ('side' in e && e.side === side))
|
||||
)
|
||||
expect(has).toBe(true)
|
||||
return this
|
||||
}
|
||||
hasDamage(side: 'player' | 'opponent') { return this.hasEvent('damage', side) }
|
||||
hasMove(side: 'player' | 'opponent') { return this.hasEvent('move', side) }
|
||||
hasFaint(side: 'player' | 'opponent') { return this.hasEvent('faint', side) }
|
||||
hasSuperEffective() { return this.hasEvent('effectiveness') }
|
||||
|
||||
hasResisted() {
|
||||
const has = this.s.events.some(e => e.type === 'effectiveness' && 'multiplier' in e && e.multiplier < 1)
|
||||
expect(has).toBe(true)
|
||||
return this
|
||||
}
|
||||
hasCrit() { return this.hasEvent('crit') }
|
||||
|
||||
turnIs(n: number) { expect(this.s.turn).toBe(n); return this }
|
||||
aliveInParty(n: number) {
|
||||
const alive = this.s.playerParty.filter(p => p.hp > 0).length
|
||||
expect(alive).toBe(n)
|
||||
return this
|
||||
}
|
||||
|
||||
satisfies(fn: (state: BattleState) => boolean, msg?: string) {
|
||||
expect(fn(this.s), msg).toBe(true)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
/** Create a new battle scenario */
|
||||
export function battleScenario(): BattleScenario {
|
||||
return new BattleScenarioImpl()
|
||||
}
|
||||
|
||||
/** Quick creature builder for raw Creature objects */
|
||||
export function makeCreature(
|
||||
species: SpeciesId,
|
||||
level: number,
|
||||
moves: string[] = ['tackle'],
|
||||
opts?: Partial<CreatureSpec>,
|
||||
): Creature {
|
||||
return buildCreature({
|
||||
id: opts?.id ?? 'test-1',
|
||||
speciesId: species,
|
||||
level,
|
||||
moves,
|
||||
...opts,
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** Shorthand for describe/test wrapper */
|
||||
export function battleSuite(name: string, fn: (b: typeof battleScenario) => void) {
|
||||
describe(name, () => fn(battleScenario))
|
||||
}
|
||||
|
||||
/** Shorthand for a single battle test */
|
||||
export function battleTest(name: string, fn: () => Promise<void>) {
|
||||
test(name, fn)
|
||||
}
|
||||
281
packages/pokemon/src/__tests__/battle-scenarios.test.ts
Normal file
281
packages/pokemon/src/__tests__/battle-scenarios.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { battleScenario, battleTest, makeCreature } from './battle-helper'
|
||||
import type { BattleState } from '../battle/types'
|
||||
|
||||
// ─── 基础战斗创建 ───
|
||||
|
||||
describe('Battle Scenario: 创建', () => {
|
||||
battleTest('单精灵对战正常初始化', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower', 'airslash'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
s.expect(s.state)
|
||||
.ongoing()
|
||||
.playerSpecies('charmander')
|
||||
.opponentSpecies('squirtle')
|
||||
.playerHpFull()
|
||||
.opponentHpFull()
|
||||
})
|
||||
|
||||
battleTest('多精灵队伍正确初始化', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.party('bulbasaur', 30, ['vinewhip'])
|
||||
.party('pikachu', 25, ['thundershock'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
s.expect(s.state)
|
||||
.ongoing()
|
||||
.playerSpecies('charmander')
|
||||
.satisfies(s => s.playerParty.length === 3, 'party should have 3 members')
|
||||
.aliveInParty(3)
|
||||
})
|
||||
|
||||
battleTest('初始回合数为 1', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('pikachu', 50, ['thundershock'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
s.expect(s.state).turnIs(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 单回合战斗事件 ───
|
||||
|
||||
describe('Battle Scenario: 单回合事件', () => {
|
||||
battleTest('使用招式后产生伤害事件', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasDamage('opponent')
|
||||
})
|
||||
|
||||
battleTest('双方均使用招式', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state)
|
||||
.hasMove('player')
|
||||
.hasMove('opponent')
|
||||
})
|
||||
|
||||
battleTest('等级碾压一击击杀', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().opponentFainted()
|
||||
})
|
||||
|
||||
battleTest('回合数递增', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).turnIs(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 属性克制 ───
|
||||
|
||||
describe('Battle Scenario: 属性克制', () => {
|
||||
battleTest('火系招式对草系效果绝佳', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('bulbasaur', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasSuperEffective().hasDamage('opponent')
|
||||
})
|
||||
|
||||
battleTest('水系招式对火系效果绝佳', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('squirtle', 50, ['watergun'])
|
||||
.opponent('charmander', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasSuperEffective().hasDamage('opponent')
|
||||
})
|
||||
|
||||
battleTest('水系招式对水系效果不佳', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('squirtle', 50, ['watergun'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasResisted().hasDamage('opponent')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 强制换人 ───
|
||||
|
||||
describe('Battle Scenario: 强制换人', () => {
|
||||
battleTest('精灵倒下触发强制换人', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('bulbasaur', 50, ['vinewhip'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).needsSwitch().playerFainted().aliveInParty(1)
|
||||
})
|
||||
|
||||
battleTest('换人后新精灵上场', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('bulbasaur', 50, ['vinewhip'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const afterTurn = await s.useMove(0).runTurn()
|
||||
s.expect(afterTurn).needsSwitch()
|
||||
|
||||
const afterSwitch = await s.doSwitch(1)
|
||||
s.expect(afterSwitch).playerSpecies('bulbasaur').ongoing()
|
||||
})
|
||||
|
||||
battleTest('换人后继续战斗', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('pikachu', 100, ['thundershock'], { ev: { attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
// Charmander gets OHKO'd by L100 Squirtle
|
||||
await s.useMove(0).runTurn()
|
||||
// Switch to Pikachu
|
||||
await s.doSwitch(1)
|
||||
// Pikachu fights Squirtle
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).hasMove('player').playerSpecies('pikachu')
|
||||
})
|
||||
|
||||
battleTest('最后一只倒下不触发强制换人', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state)
|
||||
.finished()
|
||||
.opponentWon()
|
||||
.satisfies(s => !s.needsSwitch, 'no switch needed when all fainted')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 战术换人 ───
|
||||
|
||||
describe('Battle Scenario: 战术换人', () => {
|
||||
battleTest('战术换人在同回合执行', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.party('squirtle', 50, ['watergun'])
|
||||
.opponent('bulbasaur', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.switchTo(1).runTurn()
|
||||
s.expect(state).playerSpecies('squirtle').ongoing()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 战斗结束 ───
|
||||
|
||||
describe('Battle Scenario: 战斗结束', () => {
|
||||
battleTest('玩家胜利', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('bulbasaur', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().playerWon()
|
||||
})
|
||||
|
||||
battleTest('玩家失败', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().opponentWon()
|
||||
})
|
||||
|
||||
battleTest('runUntilEnd 自动完成战斗', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
const state = await s.runUntilEnd()
|
||||
s.expect(state).finished()
|
||||
})
|
||||
|
||||
battleTest('长战斗在 maxTurns 内结束', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 50, ['flamethrower'])
|
||||
.opponent('squirtle', 50)
|
||||
.start()
|
||||
|
||||
const state = await s.runUntilEnd(100)
|
||||
s.expect(state).finished()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 多精灵队伍战斗流程 ───
|
||||
|
||||
describe('Battle Scenario: 多精灵队伍', () => {
|
||||
battleTest('2v1 战斗:需要两次击杀', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 100, ['flamethrower'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.party('bulbasaur', 100, ['vinewhip'], { ev: { hp: 252, attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 5)
|
||||
.start()
|
||||
|
||||
// First pokemon OHKOs opponent
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state).finished().playerWon()
|
||||
})
|
||||
|
||||
battleTest('连续换人后战斗继续', async () => {
|
||||
const s = await battleScenario()
|
||||
.party('charmander', 5, ['ember'])
|
||||
.party('bulbasaur', 5, ['vinewhip'])
|
||||
.party('pikachu', 100, ['thundershock'], { ev: { attack: 252, speed: 252 } })
|
||||
.opponent('squirtle', 100)
|
||||
.start()
|
||||
|
||||
// Charmander faints to L100 Squirtle
|
||||
await s.useMove(0).runTurn()
|
||||
// Switch to Bulbasaur (index 1)
|
||||
await s.doSwitch(1)
|
||||
// Bulbasaur faints too
|
||||
await s.useMove(0).runTurn()
|
||||
// Switch to Pikachu (index 2)
|
||||
await s.doSwitch(2)
|
||||
// Pikachu finishes
|
||||
const state = await s.useMove(0).runTurn()
|
||||
s.expect(state)
|
||||
.playerSpecies('pikachu')
|
||||
.hasMove('player')
|
||||
})
|
||||
})
|
||||
@@ -52,46 +52,46 @@ function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyD
|
||||
}
|
||||
|
||||
describe('createBattle', () => {
|
||||
test('creates battle with valid initial state', () => {
|
||||
test('creates battle with valid initial state', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state).toBeDefined()
|
||||
expect(init.state.playerPokemon).toBeDefined()
|
||||
expect(init.state.opponentPokemon).toBeDefined()
|
||||
expect(init.state.finished).toBe(false)
|
||||
})
|
||||
|
||||
test('player pokemon has correct species', () => {
|
||||
test('player pokemon has correct species', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'bulbasaur', 30)
|
||||
const init = await createBattle([creature], 'bulbasaur', 30)
|
||||
expect(init.state.playerPokemon.speciesId).toBe('charmander')
|
||||
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
|
||||
})
|
||||
|
||||
test('player pokemon has moves', () => {
|
||||
test('player pokemon has moves', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeTurn', () => {
|
||||
test('move action generates events', () => {
|
||||
test('move action generates events', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
const initialEventCount = init.state.events.length
|
||||
|
||||
const newState = executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
const newState = await executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
|
||||
})
|
||||
|
||||
test('battle eventually ends within 50 turns', () => {
|
||||
test('battle eventually ends within 50 turns', async () => {
|
||||
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } })
|
||||
const init = createBattle([creature], 'squirtle', 5)
|
||||
const init = await createBattle([creature], 'squirtle', 5)
|
||||
|
||||
let state = init.state
|
||||
for (let i = 0; i < 50 && !state.finished; i++) {
|
||||
state = executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
state = await executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
}
|
||||
|
||||
expect(state.finished).toBe(true)
|
||||
@@ -221,9 +221,9 @@ describe('applyEvolution', () => {
|
||||
})
|
||||
|
||||
describe('chooseAIMove', () => {
|
||||
test('returns a valid move index', () => {
|
||||
test('returns a valid move index', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
const aiPokemon = init.state.opponentPokemon
|
||||
const idx = chooseAIMove(aiPokemon)
|
||||
expect(idx).toBeGreaterThanOrEqual(0)
|
||||
@@ -304,50 +304,50 @@ describe('settleBattle - advanced', () => {
|
||||
})
|
||||
|
||||
describe('createBattle - extended', () => {
|
||||
test('battle state has turn initialized', () => {
|
||||
test('battle state has turn initialized', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.turn).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('player pokemon has correct level', () => {
|
||||
test('player pokemon has correct level', async () => {
|
||||
const creature = makeTestCreature({ level: 25 })
|
||||
const init = createBattle([creature], 'bulbasaur', 10)
|
||||
const init = await createBattle([creature], 'bulbasaur', 10)
|
||||
expect(init.state.playerPokemon.level).toBe(25)
|
||||
})
|
||||
|
||||
test('opponent pokemon has correct level', () => {
|
||||
test('opponent pokemon has correct level', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 15)
|
||||
const init = await createBattle([creature], 'squirtle', 15)
|
||||
expect(init.state.opponentPokemon.level).toBe(15)
|
||||
})
|
||||
|
||||
test('battle state has player party', () => {
|
||||
test('battle state has player party', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.playerParty.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('battle state has usable items (empty bag)', () => {
|
||||
test('battle state has usable items (empty bag)', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
expect(init.state.usableItems).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeTurn - extended', () => {
|
||||
test('item action defaults to move 1', () => {
|
||||
test('item action defaults to move 1', async () => {
|
||||
const creature = makeTestCreature()
|
||||
const init = createBattle([creature], 'squirtle', 50)
|
||||
const state = executeTurn(init, { type: 'item', itemId: 'potion' })
|
||||
const init = await createBattle([creature], 'squirtle', 50)
|
||||
const state = await executeTurn(init, { type: 'item', itemId: 'potion' })
|
||||
expect(state).toBeDefined()
|
||||
expect(state.events.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('battle produces damage or heal events', () => {
|
||||
test('battle produces damage or heal events', async () => {
|
||||
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } })
|
||||
const init = createBattle([creature], 'squirtle', 5)
|
||||
const state = executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
const init = await createBattle([creature], 'squirtle', 5)
|
||||
const state = await executeTurn(init, { type: 'move', moveIndex: 0 })
|
||||
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
|
||||
expect(hasDamageOrHeal).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { Battle, Teams, toID } from '@pkmn/sim'
|
||||
import { Dex } from '@pkmn/sim'
|
||||
import { BattleStreams, Teams, Dex, toID } from '@pkmn/sim'
|
||||
import { Protocol } from '@pkmn/protocol'
|
||||
import type { Creature, SpeciesId } from '../types'
|
||||
import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
|
||||
import { STAT_NAMES } from '../types'
|
||||
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types'
|
||||
import { chooseAIMove } from './ai'
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export type BattleInit = {
|
||||
streams: {
|
||||
omniscient: { write(data: string): void; read(): Promise<string | null | undefined> }
|
||||
spectator: { read(): Promise<string | null | undefined> }
|
||||
p1: { write(data: string): void; read(): Promise<string | null | undefined> }
|
||||
p2: { write(data: string): void; read(): Promise<string | null | undefined> }
|
||||
}
|
||||
/** Underlying stream — access .battle for Battle object */
|
||||
stream: BattleStreams.BattleStream
|
||||
state: BattleState
|
||||
}
|
||||
|
||||
// ─── Adapter: Creature → Showdown Set ───
|
||||
|
||||
function creatureToSetString(creature: Creature): string {
|
||||
@@ -43,18 +57,13 @@ function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
|
||||
const species = Dex.species.get(speciesId)
|
||||
if (!species) throw new Error(`Species ${speciesId} not found`)
|
||||
const ability = species.abilities['0'] ?? ''
|
||||
// Get first 4 level-up moves (from species data)
|
||||
const moves = getSpeciesMoves(speciesId, level)
|
||||
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
|
||||
}
|
||||
|
||||
function getSpeciesMoves(speciesId: string, _level: number): string[] {
|
||||
// In @pkmn/sim, Dex.species doesn't expose learnsets directly.
|
||||
// Use common moves that exist in the sim's data for basic battles.
|
||||
// The actual move pool is resolved by the Battle engine during construction.
|
||||
const species = Dex.species.get(speciesId)
|
||||
if (!species) return ['Tackle']
|
||||
// Use type-appropriate basic moves as fallback
|
||||
const type = species.types[0]?.toLowerCase() ?? 'normal'
|
||||
const basicMoves: Record<string, string[]> = {
|
||||
normal: ['Tackle', 'Scratch'],
|
||||
@@ -79,7 +88,7 @@ function getSpeciesMoves(speciesId: string, _level: number): string[] {
|
||||
return basicMoves[type] ?? ['Tackle', 'Scratch']
|
||||
}
|
||||
|
||||
// ─── State Projection ───
|
||||
// ─── State Projection (from Battle object) ───
|
||||
|
||||
function projectPokemon(pkm: any): BattlePokemon {
|
||||
if (!pkm) throw new Error('No active pokemon')
|
||||
@@ -88,7 +97,7 @@ function projectPokemon(pkm: any): BattlePokemon {
|
||||
const maxHp = pkm.maxhp ?? 1
|
||||
|
||||
return {
|
||||
id: pkm.name, // sim doesn't store our UUID, use name as temp id
|
||||
id: pkm.name,
|
||||
speciesId: toID(species.name) as SpeciesId,
|
||||
name: species.name,
|
||||
level: pkm.level,
|
||||
@@ -136,184 +145,9 @@ function projectBoosts(boosts: Record<string, number> | undefined): Record<strin
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Log Parsing ───
|
||||
|
||||
function parseLogToEvents(log: string[]): BattleEvent[] {
|
||||
const events: BattleEvent[] = []
|
||||
const parseSide = (s: string | undefined): 'player' | 'opponent' =>
|
||||
s?.startsWith('p1a') ? 'player' : 'opponent'
|
||||
|
||||
for (const line of log) {
|
||||
const parts = line.split('|')
|
||||
const side = parseSide(parts[2])
|
||||
|
||||
if (line.startsWith('|move|')) {
|
||||
events.push({ type: 'move', side, move: parts[3], user: parts[2] })
|
||||
} else if (line.startsWith('|-damage|')) {
|
||||
const [cur, max] = parseHpString(parts[3])
|
||||
events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) })
|
||||
} else if (line.startsWith('|-heal|')) {
|
||||
const [cur, max] = parseHpString(parts[3])
|
||||
events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) })
|
||||
} else if (line.startsWith('|faint|')) {
|
||||
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
|
||||
} else if (line.startsWith('|switch|')) {
|
||||
const speciesPart = parts[3]?.split(',')[0]?.split(': ')
|
||||
events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' })
|
||||
} else if (line.startsWith('|-supereffective|')) {
|
||||
events.push({ type: 'effectiveness', multiplier: 2 })
|
||||
} else if (line.startsWith('|-resisted|')) {
|
||||
events.push({ type: 'effectiveness', multiplier: 0.5 })
|
||||
} else if (line.startsWith('|-crit|')) {
|
||||
events.push({ type: 'crit' })
|
||||
} else if (line.startsWith('|-miss|')) {
|
||||
events.push({ type: 'miss', side })
|
||||
} else if (line.startsWith('|-status|')) {
|
||||
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
|
||||
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
|
||||
const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4])
|
||||
events.push({ type: 'statChange', side, stat: parts[3], stages })
|
||||
} else if (line.startsWith('|-ability|')) {
|
||||
events.push({ type: 'ability', side, ability: parts[3] })
|
||||
} else if (line.startsWith('|turn|')) {
|
||||
events.push({ type: 'turn', number: parseInt(parts[2]) })
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function parseHpString(hpStr: string): [number, number] {
|
||||
if (!hpStr) return [0, 1]
|
||||
// Remove status suffix like "[1]"
|
||||
const clean = hpStr.replace(/\[.*\]/, '')
|
||||
const parts = clean.split('/')
|
||||
if (parts.length !== 2) return [0, 1]
|
||||
return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1]
|
||||
}
|
||||
|
||||
// ─── Engine ───
|
||||
|
||||
export type BattleInit = {
|
||||
battle: any // @pkmn/sim Battle instance
|
||||
state: BattleState
|
||||
}
|
||||
|
||||
export function createBattle(
|
||||
partyCreatures: Creature[],
|
||||
opponentSpeciesId: SpeciesId,
|
||||
opponentLevel: number,
|
||||
_bagItems?: { id: string; count: number }[],
|
||||
): BattleInit {
|
||||
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
|
||||
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
|
||||
|
||||
const p1Team = Teams.import(p1Sets.join('\n\n'))
|
||||
const p2Team = Teams.import(p2Set)
|
||||
|
||||
// Create battle
|
||||
const battle = new Battle({
|
||||
formatid: 'gen9customgame' as any,
|
||||
p1: { name: 'Player', team: p1Team },
|
||||
p2: { name: 'Opponent', team: p2Team },
|
||||
})
|
||||
|
||||
// Handle team preview → auto-select leads
|
||||
battle.makeChoices('team 1', 'team 1')
|
||||
|
||||
// Project initial state
|
||||
const state = projectState(battle, _bagItems)
|
||||
return { battle, state }
|
||||
}
|
||||
|
||||
export function executeTurn(
|
||||
battleInit: BattleInit,
|
||||
action: PlayerAction,
|
||||
): BattleState {
|
||||
const { battle } = battleInit
|
||||
const prevLogLen = battle.log.length
|
||||
|
||||
// Build player choice string
|
||||
let p1Choice: string
|
||||
switch (action.type) {
|
||||
case 'move':
|
||||
p1Choice = `move ${action.moveIndex + 1}`
|
||||
break
|
||||
case 'switch': {
|
||||
const p1Pokemon: any[] = battle.p1.pokemon
|
||||
const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId)
|
||||
p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1'
|
||||
break
|
||||
}
|
||||
case 'item':
|
||||
p1Choice = 'move 1' // Items handled via settlement
|
||||
break
|
||||
default:
|
||||
p1Choice = 'move 1'
|
||||
}
|
||||
|
||||
// AI choice — pick a legal move for the active opponent Pokémon
|
||||
let p2Choice: string
|
||||
const p2Active = battle.p2.active[0]
|
||||
if (p2Active?.fainted) {
|
||||
// AI needs to switch to next non-fainted Pokémon
|
||||
const p2Pokemon: any[] = battle.p2.pokemon
|
||||
const nextAlive = p2Pokemon.findIndex((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
||||
p2Choice = nextAlive >= 0 ? `switch ${nextAlive + 1}` : 'pass'
|
||||
} else {
|
||||
const aiPokemon = projectPokemon(battle.p2.active[0])
|
||||
const aiMoveIndex = chooseAIMove(aiPokemon)
|
||||
p2Choice = `move ${aiMoveIndex + 1}`
|
||||
}
|
||||
|
||||
// Handle player forced switch (fainted active Pokémon)
|
||||
const p1Active = battle.p1.active[0]
|
||||
if (p1Active?.fainted || p1Active?.hp === 0) {
|
||||
const p1Pokemon: any[] = battle.p1.pokemon
|
||||
const nextAlive = p1Pokemon.findIndex((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
||||
if (nextAlive >= 0) {
|
||||
p1Choice = `switch ${nextAlive + 1}`
|
||||
} else {
|
||||
p1Choice = 'pass'
|
||||
}
|
||||
}
|
||||
|
||||
// Execute — use try/catch for safety
|
||||
try {
|
||||
battle.makeChoices(p1Choice, p2Choice)
|
||||
} catch {
|
||||
// If choices fail (e.g. mid-turn faint), try pass
|
||||
try { battle.makeChoices('pass', 'pass') } catch { /* battle likely ended */ }
|
||||
}
|
||||
|
||||
// Parse new log entries
|
||||
const newLog = battle.log.slice(prevLogLen)
|
||||
const newEvents = parseLogToEvents(newLog)
|
||||
|
||||
// Project new state
|
||||
const state = projectState(battle, battleInit.state.usableItems)
|
||||
state.events = [...battleInit.state.events, ...newEvents]
|
||||
|
||||
// Check for battle end
|
||||
if (battle.ended) {
|
||||
state.finished = true
|
||||
const winner = battle.winner === 'Player' ? 'player' : 'opponent'
|
||||
state.result = {
|
||||
winner,
|
||||
turns: state.turn,
|
||||
xpGained: 0, // calculated in settlement
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
battleInit.state = state
|
||||
return state
|
||||
}
|
||||
|
||||
function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState {
|
||||
const p1 = battle.p1
|
||||
const p2 = battle.p2
|
||||
|
||||
return {
|
||||
playerPokemon: projectPokemon(p1.active[0]),
|
||||
opponentPokemon: projectPokemon(p2.active[0]),
|
||||
@@ -325,3 +159,298 @@ function projectState(battle: any, bagItems?: { id: string; count: number }[]):
|
||||
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Protocol Event Parsing (from spectator chunks) ───
|
||||
|
||||
function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxHp: number }; opponent: { hp: number; maxHp: number } }): BattleEvent[] {
|
||||
const events: BattleEvent[] = []
|
||||
// Track HP through the chunk to compute damage/heal amounts
|
||||
const hp = prevHp ? { player: { ...prevHp.player }, opponent: { ...prevHp.opponent } } : { player: { hp: 0, maxHp: 1 }, opponent: { hp: 0, maxHp: 1 } }
|
||||
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line.startsWith('|')) continue
|
||||
// Skip non-battle lines
|
||||
if (line.startsWith('|t:|') || line === '|' || line.startsWith('|gametype|') || line.startsWith('|player|') ||
|
||||
line.startsWith('|gen|') || line.startsWith('|tier|') || line.startsWith('|clearpoke|') ||
|
||||
line.startsWith('|poke|') || line.startsWith('|teampreview|') || line.startsWith('|teamsize|') ||
|
||||
line.startsWith('|start|') || line.startsWith('|done|') || line.startsWith('|upkeep|')) continue
|
||||
|
||||
const parts = line.split('|')
|
||||
const cmd = parts[1]
|
||||
if (!cmd) continue
|
||||
const side = parts[2]?.startsWith('p1a') ? 'player' as const : 'opponent' as const
|
||||
|
||||
switch (cmd) {
|
||||
case 'move':
|
||||
events.push({ type: 'move', side, move: parts[3] ?? '', user: parts[2] ?? '' })
|
||||
break
|
||||
case '-damage': {
|
||||
const newHp = parseHpValue(parts[3])
|
||||
const prev = hp[side].hp
|
||||
const maxHp = hp[side].maxHp || 1
|
||||
if (newHp !== null) {
|
||||
const amount = Math.max(0, prev - newHp)
|
||||
const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
|
||||
hp[side].hp = newHp
|
||||
hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
|
||||
events.push({ type: 'damage', side, amount, percentage })
|
||||
} else {
|
||||
events.push({ type: 'damage', side, amount: 0, percentage: 0 })
|
||||
}
|
||||
break
|
||||
}
|
||||
case '-heal': {
|
||||
const newHp = parseHpValue(parts[3])
|
||||
const prev = hp[side].hp
|
||||
const maxHp = hp[side].maxHp || 1
|
||||
if (newHp !== null) {
|
||||
const amount = Math.max(0, newHp - prev)
|
||||
const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
|
||||
hp[side].hp = newHp
|
||||
hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
|
||||
events.push({ type: 'heal', side, amount, percentage })
|
||||
} else {
|
||||
events.push({ type: 'heal', side, amount: 0, percentage: 0 })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'faint':
|
||||
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
|
||||
break
|
||||
case 'switch': {
|
||||
const name = parts[3]?.split(',')[0] ?? ''
|
||||
// Parse HP from switch: "Squirtle, L5, 100/100"
|
||||
const hpStr = parts[3] ?? ''
|
||||
const hpMatch = hpStr.match(/(\d+)\/(\d+)/)
|
||||
if (hpMatch) {
|
||||
hp[side].hp = parseInt(hpMatch[1], 10)
|
||||
hp[side].maxHp = parseInt(hpMatch[2], 10)
|
||||
}
|
||||
events.push({ type: 'switch', side, speciesId: toID(name), name })
|
||||
break
|
||||
}
|
||||
case '-supereffective':
|
||||
events.push({ type: 'effectiveness', multiplier: 2 })
|
||||
break
|
||||
case '-resisted':
|
||||
events.push({ type: 'effectiveness', multiplier: 0.5 })
|
||||
break
|
||||
case '-crit':
|
||||
events.push({ type: 'crit' })
|
||||
break
|
||||
case '-miss':
|
||||
events.push({ type: 'miss', side })
|
||||
break
|
||||
case '-status':
|
||||
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
|
||||
break
|
||||
case '-boost':
|
||||
case '-unboost': {
|
||||
const stages = cmd === '-boost' ? Number(parts[4]) : -Number(parts[4])
|
||||
events.push({ type: 'statChange', side, stat: parts[3] ?? '', stages })
|
||||
break
|
||||
}
|
||||
case '-ability':
|
||||
events.push({ type: 'ability', side, ability: parts[3] ?? '' })
|
||||
break
|
||||
case 'turn':
|
||||
events.push({ type: 'turn', number: Number(parts[2]) })
|
||||
break
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Parse current HP from protocol HP string like "80/100" or "80/100brn" */
|
||||
function parseHpValue(hpStr?: string): number | null {
|
||||
if (!hpStr) return null
|
||||
const match = hpStr.match(/^(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
/** Parse max HP from protocol HP string like "80/100" or "80/100brn" */
|
||||
function parseMaxHp(hpStr?: string): number | null {
|
||||
if (!hpStr) return null
|
||||
const match = hpStr.match(/\/(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
// ─── Engine API ───
|
||||
|
||||
export async function createBattle(
|
||||
partyCreatures: Creature[],
|
||||
opponentSpeciesId: SpeciesId,
|
||||
opponentLevel: number,
|
||||
_bagItems?: { id: string; count: number }[],
|
||||
): Promise<BattleInit> {
|
||||
const stream = new BattleStreams.BattleStream()
|
||||
const streams = BattleStreams.getPlayerStreams(stream)
|
||||
|
||||
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
|
||||
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
|
||||
const p1Team = Teams.import(p1Sets.join('\n\n'))
|
||||
const p2Team = Teams.import(p2Set)
|
||||
|
||||
const spec = { formatid: 'gen9customgame' }
|
||||
const p1spec = { name: 'Player', team: Teams.pack(p1Team) }
|
||||
const p2spec = { name: 'Opponent', team: Teams.pack(p2Team) }
|
||||
|
||||
// Initialize battle
|
||||
streams.omniscient.write(
|
||||
`>start ${JSON.stringify(spec)}\n` +
|
||||
`>player p1 ${JSON.stringify(p1spec)}\n` +
|
||||
`>player p2 ${JSON.stringify(p2spec)}`
|
||||
)
|
||||
|
||||
// Drain team preview from omniscient and spectator streams
|
||||
await streams.omniscient.read()
|
||||
await streams.spectator.read()
|
||||
|
||||
// Accept team preview — lead with first Pokémon
|
||||
streams.omniscient.write(`>p1 team 1\n>p2 team 1`)
|
||||
|
||||
// Read battle start from spectator (clean, no |split|)
|
||||
const startChunk = (await streams.spectator.read()) ?? ''
|
||||
|
||||
// Parse initial events (switches + turn)
|
||||
const initialEvents = parseChunkToEvents(startChunk)
|
||||
|
||||
// Use Battle object for rich state projection
|
||||
const battle = stream.battle!
|
||||
const state = projectState(battle, _bagItems)
|
||||
state.events = initialEvents
|
||||
|
||||
return { streams, stream, state }
|
||||
}
|
||||
|
||||
export async function executeTurn(
|
||||
battleInit: BattleInit,
|
||||
action: PlayerAction,
|
||||
): Promise<BattleState> {
|
||||
const { streams, stream } = battleInit
|
||||
const prevState = battleInit.state
|
||||
const battle = stream.battle!
|
||||
|
||||
// Build p1 choice
|
||||
let p1Choice: string
|
||||
switch (action.type) {
|
||||
case 'move':
|
||||
p1Choice = `move ${action.moveIndex + 1}`
|
||||
break
|
||||
case 'switch': {
|
||||
// Use partyIndex directly (1-indexed for showdown protocol)
|
||||
const idx = action.partyIndex
|
||||
const p1Pokemon: any[] = battle.p1.pokemon
|
||||
p1Choice = idx >= 0 && idx < p1Pokemon.length ? `switch ${idx + 1}` : 'move 1'
|
||||
break
|
||||
}
|
||||
case 'item':
|
||||
p1Choice = 'move 1'
|
||||
break
|
||||
default:
|
||||
p1Choice = 'move 1'
|
||||
}
|
||||
|
||||
// AI choice
|
||||
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon)
|
||||
const p2Choice = `move ${aiMoveIndex + 1}`
|
||||
|
||||
// Submit choices via stream
|
||||
streams.omniscient.write(`>p1 ${p1Choice}\n>p2 ${p2Choice}`)
|
||||
|
||||
// Read turn result from spectator (no |split| issues)
|
||||
const turnChunk = (await streams.spectator.read()) ?? ''
|
||||
const newEvents = parseChunkToEvents(turnChunk, {
|
||||
player: { hp: prevState.playerPokemon.hp, maxHp: prevState.playerPokemon.maxHp },
|
||||
opponent: { hp: prevState.opponentPokemon.hp, maxHp: prevState.opponentPokemon.maxHp },
|
||||
})
|
||||
|
||||
// Project rich state from Battle object
|
||||
const state = projectState(battle, prevState.usableItems)
|
||||
state.events = [...prevState.events, ...newEvents]
|
||||
|
||||
// Forced switch detection via Battle object
|
||||
const p1Active = battle.p1.active[0]
|
||||
const hasAliveBench = battle.p1.pokemon.some((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
||||
if (p1Active?.fainted && hasAliveBench && !battle.ended) {
|
||||
state.needsSwitch = true
|
||||
}
|
||||
|
||||
// Battle end detection
|
||||
if (battle.ended) {
|
||||
state.finished = true
|
||||
const winner = battle.winner === 'Player' ? 'player' as const : 'opponent' as const
|
||||
state.result = {
|
||||
winner,
|
||||
turns: state.turn,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
battleInit.state = state
|
||||
return state
|
||||
}
|
||||
|
||||
export async function executeSwitch(
|
||||
battleInit: BattleInit,
|
||||
partyIndex: number,
|
||||
): Promise<BattleState> {
|
||||
const { streams, stream } = battleInit
|
||||
const prevState = battleInit.state
|
||||
const battle = stream.battle!
|
||||
|
||||
// Validate slot index
|
||||
const p1Pokemon: any[] = battle.p1.pokemon
|
||||
if (partyIndex < 0 || partyIndex >= p1Pokemon.length) return prevState
|
||||
|
||||
// Build p2 command: switch if fainted, otherwise use AI move
|
||||
let p2Cmd = ''
|
||||
const p2Active = battle.p2.active[0]
|
||||
if (p2Active?.fainted || p2Active?.hp === 0) {
|
||||
const p2Pkm: any[] = battle.p2.pokemon
|
||||
const nextAlive = p2Pkm.findIndex((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
||||
p2Cmd = nextAlive >= 0 ? `\n>p2 switch ${nextAlive + 1}` : '\n>p2 pass'
|
||||
} else {
|
||||
// p2's active is alive — submit AI move choice
|
||||
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon)
|
||||
p2Cmd = `\n>p2 move ${aiMoveIndex + 1}`
|
||||
}
|
||||
|
||||
// Submit switch (1-indexed for showdown protocol)
|
||||
streams.omniscient.write(`>p1 switch ${partyIndex + 1}${p2Cmd}`)
|
||||
|
||||
// Read result
|
||||
const switchChunk = (await streams.spectator.read()) ?? ''
|
||||
const newEvents = parseChunkToEvents(switchChunk, {
|
||||
player: { hp: prevState.playerPokemon.hp, maxHp: prevState.playerPokemon.maxHp },
|
||||
opponent: { hp: prevState.opponentPokemon.hp, maxHp: prevState.opponentPokemon.maxHp },
|
||||
})
|
||||
|
||||
// Project state
|
||||
const state = projectState(battle, prevState.usableItems)
|
||||
state.events = [...prevState.events, ...newEvents]
|
||||
|
||||
// Forced switch detection via Battle object
|
||||
const p1Active = battle.p1.active[0]
|
||||
const hasAliveBench = battle.p1.pokemon.some((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
||||
if (p1Active?.fainted && hasAliveBench && !battle.ended) {
|
||||
state.needsSwitch = true
|
||||
}
|
||||
|
||||
if (battle.ended) {
|
||||
state.finished = true
|
||||
const winner = battle.winner === 'Player' ? 'player' as const : 'opponent' as const
|
||||
state.result = {
|
||||
winner,
|
||||
turns: state.turn,
|
||||
xpGained: 0,
|
||||
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
|
||||
participantIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
battleInit.state = state
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types'
|
||||
export { createBattle, executeTurn, type BattleInit } from './engine'
|
||||
export { createBattle, executeTurn, executeSwitch, type BattleInit } from './engine'
|
||||
export { settleBattle, applyMoveLearn, applyEvolution } from './settlement'
|
||||
export { chooseAIMove } from './ai'
|
||||
|
||||
@@ -28,7 +28,7 @@ export type MoveOption = {
|
||||
|
||||
export type PlayerAction =
|
||||
| { type: 'move'; moveIndex: number }
|
||||
| { type: 'switch'; creatureId: string }
|
||||
| { type: 'switch'; partyIndex: number }
|
||||
| { type: 'item'; itemId: string }
|
||||
|
||||
export type BattleEvent =
|
||||
@@ -65,4 +65,5 @@ export type BattleState = {
|
||||
finished: boolean
|
||||
result?: BattleResult
|
||||
usableItems: { id: string; name: string; count: number }[]
|
||||
needsSwitch?: boolean // player's active Pokémon fainted, must switch
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
|
||||
|
||||
// Battle
|
||||
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
|
||||
export { createBattle, executeTurn, type BattleInit } from './battle/engine'
|
||||
export { createBattle, executeTurn, executeSwitch, type BattleInit } from './battle/engine'
|
||||
export { settleBattle, applyMoveLearn, applyEvolution } from './battle/settlement'
|
||||
export { chooseAIMove } from './battle/ai'
|
||||
|
||||
@@ -60,7 +60,7 @@ export {
|
||||
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
|
||||
|
||||
// Sprites
|
||||
export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
|
||||
export { renderAnimatedSprite, shrinkSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
|
||||
export { getFallbackSprite } from './sprites/fallback'
|
||||
|
||||
// UI Components
|
||||
@@ -71,8 +71,14 @@ export { EvolutionAnim } from './ui/EvolutionAnim'
|
||||
export { StatBar } from './ui/StatBar'
|
||||
export { SpeciesDetail } from './ui/SpeciesDetail'
|
||||
export { SpriteAnimator } from './ui/SpriteAnimator'
|
||||
export { BattleSprite } from './ui/BattleSprite'
|
||||
export { BattleField } from './ui/BattleField'
|
||||
export { BattleConfigPanel } from './ui/BattleConfigPanel'
|
||||
export { BattleView } from './ui/BattleView'
|
||||
export { BattleScene } from './ui/BattleScene'
|
||||
export type { MenuPhase } from './ui/BattleScene'
|
||||
export { HpCard } from './ui/HpCard'
|
||||
export { BattleMenu } from './ui/BattleMenu'
|
||||
export { BattleLogPanel } from './ui/BattleLogPanel'
|
||||
export { SwitchPanel } from './ui/SwitchPanel'
|
||||
export { ItemPanel } from './ui/ItemPanel'
|
||||
export { BattleResultPanel } from './ui/BattleResultPanel'
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { AnimMode } from '../types'
|
||||
//
|
||||
// After transform, render each row back: reset → style → char → reset
|
||||
|
||||
interface Pixel {
|
||||
export interface Pixel {
|
||||
char: string
|
||||
/** Full ANSI state needed to render this pixel */
|
||||
style: string
|
||||
@@ -21,6 +21,7 @@ interface Pixel {
|
||||
|
||||
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
|
||||
const EMPTY_ROW: Pixel[] = []
|
||||
export { EMPTY_PIXEL, EMPTY_ROW }
|
||||
|
||||
// ─── Parse / Render ───────────────────────────────────
|
||||
|
||||
@@ -67,11 +68,11 @@ function renderRow(pixels: Pixel[]): string {
|
||||
return out
|
||||
}
|
||||
|
||||
function parseSprite(lines: string[]): Pixel[][] {
|
||||
export function parseSprite(lines: string[]): Pixel[][] {
|
||||
return lines.map(parseLine)
|
||||
}
|
||||
|
||||
function renderSprite(grid: Pixel[][]): string[] {
|
||||
export function renderSprite(grid: Pixel[][]): string[] {
|
||||
return grid.map(renderRow)
|
||||
}
|
||||
|
||||
@@ -173,6 +174,14 @@ export function getIdleAnimMode(tick: number): AnimMode {
|
||||
// Public API
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Flip sprite lines horizontally (mirror + swap directional chars).
|
||||
* For player Pokemon facing right towards the opponent.
|
||||
*/
|
||||
export function flipSpriteLines(lines: string[]): string[] {
|
||||
return renderSprite(reverseH(parseSprite(lines), true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply animation transform to sprite lines.
|
||||
* Internally: parse ANSI → Pixel grid → transform → render back.
|
||||
@@ -226,6 +235,114 @@ export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMo
|
||||
return renderSprite(result)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Sprite Shrink (nearest-neighbor / block sampling)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
function pixelWeight(char: string): number {
|
||||
if (char === ' ') return 0
|
||||
if ('█▓'.includes(char)) return 4
|
||||
if ('▒■▀▄'.includes(char)) return 3
|
||||
if ('░▌▐/\\()<>'.includes(char)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function pickDominantPixel(
|
||||
grid: Pixel[][],
|
||||
x0: number,
|
||||
x1: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): Pixel {
|
||||
let best: Pixel = EMPTY_PIXEL
|
||||
let bestScore = -1
|
||||
const cx = (x0 + x1 - 1) / 2
|
||||
const cy = (y0 + y1 - 1) / 2
|
||||
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const pixel = grid[y]?.[x] ?? EMPTY_PIXEL
|
||||
const weight = pixelWeight(pixel.char)
|
||||
if (weight === 0) continue
|
||||
|
||||
const dist = Math.abs(x - cx) + Math.abs(y - cy)
|
||||
const score = weight * 10 - dist
|
||||
if (score > bestScore) {
|
||||
best = pixel
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore >= 0 ? best : EMPTY_PIXEL
|
||||
}
|
||||
|
||||
function resampleGrid(grid: Pixel[][], targetWidth: number, targetHeight: number): Pixel[][] {
|
||||
const srcHeight = grid.length
|
||||
const srcWidth = Math.max(0, ...grid.map(row => row.length))
|
||||
|
||||
return Array.from({ length: targetHeight }, (_, y) => {
|
||||
const y0 = Math.floor((y * srcHeight) / targetHeight)
|
||||
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / targetHeight))
|
||||
|
||||
return Array.from({ length: targetWidth }, (_, x) => {
|
||||
const x0 = Math.floor((x * srcWidth) / targetWidth)
|
||||
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * srcWidth) / targetWidth))
|
||||
return pickDominantPixel(grid, x0, x1, y0, y1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isEmptyRow(row: Pixel[]): boolean {
|
||||
return row.length === 0 || row.every(pixel => pixel.char === ' ')
|
||||
}
|
||||
|
||||
function trimEmptyMargin(grid: Pixel[][]): Pixel[][] {
|
||||
if (grid.length === 0) return grid
|
||||
|
||||
let top = 0
|
||||
let bottom = grid.length - 1
|
||||
while (top <= bottom && isEmptyRow(grid[top] ?? [])) top++
|
||||
while (bottom >= top && isEmptyRow(grid[bottom] ?? [])) bottom--
|
||||
|
||||
if (top > bottom) return []
|
||||
|
||||
const sliced = grid.slice(top, bottom + 1)
|
||||
const width = Math.max(0, ...sliced.map(row => row.length))
|
||||
|
||||
let left = 0
|
||||
let right = width - 1
|
||||
const isEmptyCol = (x: number) => sliced.every(row => (row[x]?.char ?? ' ') === ' ')
|
||||
|
||||
while (left <= right && isEmptyCol(left)) left++
|
||||
while (right >= left && isEmptyCol(right)) right--
|
||||
|
||||
return sliced.map(row => row.slice(left, right + 1))
|
||||
}
|
||||
|
||||
export function shrinkSprite(
|
||||
lines: string[],
|
||||
opts: { scale?: number; maxWidth?: number; maxHeight?: number },
|
||||
): string[] {
|
||||
const grid = trimEmptyMargin(parseSprite(lines))
|
||||
const srcHeight = grid.length
|
||||
const srcWidth = Math.max(0, ...grid.map(row => row.length))
|
||||
|
||||
if (srcWidth === 0 || srcHeight === 0) return lines
|
||||
|
||||
const baseScale = Math.min(opts.scale ?? 0.75, 1)
|
||||
const widthScale = opts.maxWidth ? opts.maxWidth / srcWidth : 1
|
||||
const heightScale = opts.maxHeight ? opts.maxHeight / srcHeight : 1
|
||||
const finalScale = Math.min(baseScale, widthScale, heightScale, 1)
|
||||
|
||||
if (finalScale >= 1) return lines
|
||||
|
||||
const targetWidth = Math.max(1, Math.floor(srcWidth * finalScale))
|
||||
const targetHeight = Math.max(1, Math.floor(srcHeight * finalScale))
|
||||
|
||||
return renderSprite(resampleGrid(grid, targetWidth, targetHeight))
|
||||
}
|
||||
|
||||
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
|
||||
|
||||
const PET_HEARTS = [
|
||||
|
||||
@@ -1,65 +1,72 @@
|
||||
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 '../dex/species'
|
||||
import { calculateStats, getCreatureName } from '../core/creature'
|
||||
|
||||
const CYAN = 'ansi:cyan'
|
||||
const GREEN = 'ansi:green'
|
||||
const GRAY = 'ansi:white'
|
||||
const YELLOW = 'ansi:yellow'
|
||||
import { getCreatureName } from '../core/creature'
|
||||
|
||||
interface BattleConfigPanelProps {
|
||||
party: (Creature | null)[]
|
||||
cursorIndex: number
|
||||
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BattleConfigPanel({ party, onSubmit, onCancel }: BattleConfigPanelProps) {
|
||||
const activeCreature = party[0]
|
||||
const OPTIONS = [
|
||||
{ label: '随机遇战(等级自动匹配)', color: 'warning' as const },
|
||||
{ label: '指定对手', color: 'inactive' as const },
|
||||
]
|
||||
|
||||
export function BattleConfigPanel({ party, cursorIndex }: BattleConfigPanelProps) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Text bold color={CYAN}> 战斗配置 </Text>
|
||||
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="claude"
|
||||
borderText={{ content: ' 战斗配置 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{/* 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>
|
||||
<Text bold color="claude">队伍</Text>
|
||||
{party.map((creature, i) => {
|
||||
if (!creature) return (
|
||||
<Box key={i}>
|
||||
<Text dimColor> [空]</Text>
|
||||
</Box>
|
||||
)
|
||||
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 color={isLead ? 'claude' : 'inactive'}>
|
||||
{isLead ? ' ▸ ' : ' '}
|
||||
</Text>
|
||||
<Text bold={isLead}>{getCreatureName(creature)}</Text>
|
||||
<Text> Lv.{creature.level} </Text>
|
||||
<Text color="success">{hpBar}</Text>
|
||||
<Text color="inactive">{hpEmpty}</Text>
|
||||
<Text> {hpPercent}%</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Opponent selection */}
|
||||
{/* Options */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>对手:</Text>
|
||||
<Text color={YELLOW}> [1] 随机遇战(等级自动匹配)</Text>
|
||||
<Text color={GRAY}> [2] 指定对手(输入物种名)</Text>
|
||||
<Text bold color="claude">选择对手</Text>
|
||||
{OPTIONS.map((opt, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={i === cursorIndex ? 'success' : 'inactive'}>
|
||||
{i === cursorIndex ? ' ▶ ' : ' '}
|
||||
</Text>
|
||||
<Text bold={i === cursorIndex} color={i === cursorIndex ? opt.color : 'inactive'}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={GRAY}>[Enter] 开始战斗 [ESC] 取消</Text>
|
||||
<Text dimColor>[↑↓] 选择 · [Enter] 确认 · [ESC] 取消</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
98
packages/pokemon/src/ui/BattleField.tsx
Normal file
98
packages/pokemon/src/ui/BattleField.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { parseSprite, renderSprite, flipSpriteLines, EMPTY_PIXEL, EMPTY_ROW } from '../sprites/renderer'
|
||||
import type { Pixel } from '../sprites/renderer'
|
||||
|
||||
/**
|
||||
* Combined battle field — composites both sprites into one canvas.
|
||||
* Opponent (top-right) and player (bottom-left) share overlapping rows,
|
||||
* like the classic GBA Pokemon battle layout.
|
||||
*
|
||||
* Bounce: fast 0-1-2-1px vertical, staggered between the two.
|
||||
*/
|
||||
|
||||
const BOUNCE = [0, 1, 2, 1]
|
||||
/** How many rows the player sprite overlaps into opponent's area */
|
||||
const OVERLAP = 3
|
||||
|
||||
interface BattleFieldProps {
|
||||
opponentLines: string[]
|
||||
playerLines: string[]
|
||||
animEnabled?: boolean
|
||||
}
|
||||
|
||||
export function BattleField({ opponentLines, playerLines, animEnabled = true }: BattleFieldProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!animEnabled) return
|
||||
const timer = setInterval(() => setTick(t => t + 1), 120)
|
||||
return () => clearInterval(timer)
|
||||
}, [animEnabled])
|
||||
|
||||
// Parse & flip (cached)
|
||||
const oppGrid = useMemo(() => parseSprite(opponentLines), [opponentLines])
|
||||
const playerGrid = useMemo(() => parseSprite(flipSpriteLines(playerLines)), [playerLines])
|
||||
|
||||
// Composited canvas
|
||||
const canvas = useMemo(() => {
|
||||
const oppH = oppGrid.length
|
||||
const playerH = playerGrid.length
|
||||
const totalH = oppH + playerH - OVERLAP
|
||||
const canvasW = Math.max(
|
||||
widthOf(oppGrid),
|
||||
widthOf(playerGrid),
|
||||
)
|
||||
|
||||
// Build empty canvas
|
||||
const rows: Pixel[][] = Array.from({ length: totalH }, () =>
|
||||
Array.from({ length: canvasW }, () => EMPTY_PIXEL),
|
||||
)
|
||||
|
||||
// Bounce offsets
|
||||
const oppOffset = animEnabled ? BOUNCE[tick % BOUNCE.length]! : 0
|
||||
const playerOffset = animEnabled ? BOUNCE[(tick + 2) % BOUNCE.length]! : 0
|
||||
|
||||
// Blit opponent (top-right, shifted up by bounce)
|
||||
const oppY = -oppOffset // negative = shift up
|
||||
blit(rows, oppGrid, oppY, canvasW - widthOf(oppGrid))
|
||||
|
||||
// Blit player (bottom-left, shifted up by bounce)
|
||||
const playerStartRow = oppH - OVERLAP
|
||||
const playerY = playerStartRow - playerOffset
|
||||
blit(rows, playerGrid, playerY, 0)
|
||||
|
||||
return rows
|
||||
}, [oppGrid, playerGrid, animEnabled, tick])
|
||||
|
||||
const rendered = renderSprite(canvas)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rendered.map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Get width of a pixel grid */
|
||||
function widthOf(grid: Pixel[][]): number {
|
||||
return Math.max(0, ...grid.map(row => row.length))
|
||||
}
|
||||
|
||||
/** Blit source grid onto target at (startRow, startCol). Non-empty pixels overwrite. */
|
||||
function blit(target: Pixel[][], source: Pixel[][], startRow: number, startCol: number) {
|
||||
for (let sy = 0; sy < source.length; sy++) {
|
||||
const ty = startRow + sy
|
||||
if (ty < 0 || ty >= target.length) continue
|
||||
for (let sx = 0; sx < source[sy].length; sx++) {
|
||||
const tx = startCol + sx
|
||||
if (tx < 0 || tx >= target[ty].length) continue
|
||||
const pixel = source[sy][sx]
|
||||
if (pixel.char !== ' ') {
|
||||
target[ty][tx] = pixel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,30 +4,39 @@ import type { BuddyData, Creature, SpeciesId } from '../types'
|
||||
import { ALL_SPECIES_IDS } from '../types'
|
||||
import { getSpeciesData } from '../dex/species'
|
||||
import { saveBuddyData } from '../core/storage'
|
||||
import { createBattle, executeTurn, type BattleInit } from '../battle/engine'
|
||||
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
|
||||
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
|
||||
import { BattleConfigPanel } from './BattleConfigPanel'
|
||||
import { BattleView } from './BattleView'
|
||||
import { BattleScene, type MenuPhase } from './BattleScene'
|
||||
import { SwitchPanel } from './SwitchPanel'
|
||||
import { ItemPanel } from './ItemPanel'
|
||||
import { BattleResultPanel } from './BattleResultPanel'
|
||||
import { MoveLearnPanel } from './MoveLearnPanel'
|
||||
import { chooseAIMove } from '../battle/ai'
|
||||
import type { BattleState, PlayerAction } from '../battle/types'
|
||||
|
||||
type Phase =
|
||||
| 'config'
|
||||
| 'configSelect'
|
||||
| 'battle'
|
||||
| 'switch'
|
||||
| 'item'
|
||||
| 'result'
|
||||
| 'learnMoves'
|
||||
| 'evolution'
|
||||
| 'done'
|
||||
|
||||
export interface BattleFlowHandle {
|
||||
handleInput: (input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => void
|
||||
handleInput: (input: string, key: {
|
||||
escape?: boolean
|
||||
return?: boolean
|
||||
upArrow?: boolean
|
||||
downArrow?: boolean
|
||||
leftArrow?: boolean
|
||||
rightArrow?: boolean
|
||||
tab?: boolean
|
||||
backspace?: boolean
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
meta?: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface BattleFlowProps {
|
||||
@@ -37,6 +46,8 @@ interface BattleFlowProps {
|
||||
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
|
||||
}
|
||||
|
||||
const VISIBLE_SPECIES = 7
|
||||
|
||||
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
|
||||
const [phase, setPhase] = useState<Phase>('config')
|
||||
const [buddyData, setBuddyData] = useState(initialData)
|
||||
@@ -48,6 +59,12 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
|
||||
const [replaceIndex, setReplaceIndex] = useState(0)
|
||||
const [speciesIndex, setSpeciesIndex] = useState(0)
|
||||
const [configCursor, setConfigCursor] = useState(0)
|
||||
|
||||
// ─── Battle UI state ───
|
||||
const [menuPhase, setMenuPhase] = useState<MenuPhase>('main')
|
||||
const [cursorIndex, setCursorIndex] = useState(0)
|
||||
const [animEnabled, setAnimEnabled] = useState(true)
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
@@ -65,6 +82,28 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
.filter((c): c is Creature => c !== undefined)
|
||||
}
|
||||
|
||||
/** Build battleHp map from battleState.playerParty */
|
||||
function getBattleHpMap(): Record<string, { hp: number; maxHp: number }> {
|
||||
if (!battleState) return {}
|
||||
const map: Record<string, { hp: number; maxHp: number }> = {}
|
||||
for (const p of battleState.playerParty) {
|
||||
map[p.id] = { hp: p.hp, maxHp: p.maxHp }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Get max cursor index for current sub-phase */
|
||||
function getMaxCursor(): number {
|
||||
if (!battleState) return 0
|
||||
switch (menuPhase) {
|
||||
case 'main': return 3
|
||||
case 'fight': return battleState.playerPokemon.moves.length - 1
|
||||
case 'bag': return battleState.usableItems.length - 1
|
||||
case 'pokemon': return getPartyCreatures().length - 1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ───
|
||||
|
||||
const handleRandomBattle = useCallback(() => {
|
||||
@@ -74,8 +113,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
handleStartBattle(randomSpecies, opponentLevel)
|
||||
}, [buddyData])
|
||||
|
||||
// Config phase: start battle
|
||||
const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => {
|
||||
const handleStartBattle = useCallback(async (speciesId: SpeciesId, level: number) => {
|
||||
setOpponentSpeciesId(speciesId)
|
||||
setOpponentLevel(level)
|
||||
|
||||
@@ -87,17 +125,27 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
if (creatures.length === 0) return
|
||||
|
||||
const bagItems = buddyData.bag.items
|
||||
const init = createBattle(creatures, speciesId, level, bagItems)
|
||||
const init = await createBattle(creatures, speciesId, level, bagItems)
|
||||
setBattleInit(init)
|
||||
setBattleState(init.state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
setPhase('battle')
|
||||
}, [buddyData])
|
||||
|
||||
// Battle phase: handle action
|
||||
const handleAction = useCallback(async (action: PlayerAction) => {
|
||||
if (!battleInit) return
|
||||
const state = executeTurn(battleInit, action)
|
||||
const state = await executeTurn(battleInit, action)
|
||||
setBattleState(state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
|
||||
// Pokémon fainted — show switch panel overlay
|
||||
if (state.needsSwitch && !state.finished) {
|
||||
setMenuPhase('pokemon')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.finished && state.result) {
|
||||
const participants = buddyData.party.filter((id): id is string => id !== null)
|
||||
@@ -112,7 +160,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
}
|
||||
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
||||
|
||||
// Result phase: continue to move learning
|
||||
const handleResultContinue = useCallback(() => {
|
||||
if (pendingMoves.length > 0) {
|
||||
setPhase('learnMoves')
|
||||
@@ -125,7 +172,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
// Move learning
|
||||
const handleMoveLearn = useCallback((idx: number) => {
|
||||
if (pendingMoves.length === 0) return
|
||||
const move = pendingMoves[0]!
|
||||
@@ -158,7 +204,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
}
|
||||
}, [pendingMoves, pendingEvos, buddyData, onClose])
|
||||
|
||||
// Evolution
|
||||
const handleEvolutionConfirm = useCallback(() => {
|
||||
if (pendingEvos.length === 0) return
|
||||
const evo = pendingEvos[0]!
|
||||
@@ -173,18 +218,63 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
}
|
||||
}, [pendingEvos, buddyData, onClose])
|
||||
|
||||
// ─── Input handler (called externally via inputRef) ───
|
||||
// Forced switch after faint
|
||||
const handleForcedSwitch = useCallback(async (partyIndex: number) => {
|
||||
if (!battleInit) return
|
||||
const state = await executeSwitch(battleInit, partyIndex)
|
||||
setBattleState(state)
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
|
||||
const handleInput = useCallback((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
|
||||
if (state.finished && state.result) {
|
||||
const participants = buddyData.party.filter((id): id is string => id !== null)
|
||||
const result = { ...state.result, participantIds: participants }
|
||||
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
|
||||
setBuddyData(settled.data)
|
||||
setPendingMoves(settled.learnableMoves)
|
||||
setPendingEvos(settled.pendingEvolutions)
|
||||
setBattleState({ ...state, result })
|
||||
setPhase('result')
|
||||
}
|
||||
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
|
||||
|
||||
// ─── Main menu cursor navigation (2x2 grid) ───
|
||||
|
||||
const moveMainCursor = useCallback((direction: 'up' | 'down' | 'left' | 'right') => {
|
||||
setCursorIndex(prev => {
|
||||
// Grid: 0=TL, 1=TR, 2=BL, 3=BR
|
||||
switch (direction) {
|
||||
case 'up': return prev >= 2 ? prev - 2 : prev + 2
|
||||
case 'down': return prev < 2 ? prev + 2 : prev - 2
|
||||
case 'left': return prev % 2 === 1 ? prev - 1 : prev + 1
|
||||
case 'right': return prev % 2 === 0 ? prev + 1 : prev - 1
|
||||
default: return prev
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ─── Input handler ───
|
||||
|
||||
const handleInput = useCallback((input: string, key: {
|
||||
escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean
|
||||
leftArrow?: boolean; rightArrow?: boolean
|
||||
}) => {
|
||||
if (!isActive) return
|
||||
|
||||
if (phase === 'config') {
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
} else if (key.return || input === '1') {
|
||||
handleRandomBattle()
|
||||
} else if (input === '2') {
|
||||
setPhase('configSelect')
|
||||
} else if (key.upArrow) {
|
||||
setConfigCursor(prev => (prev - 1 + 2) % 2)
|
||||
} else if (key.downArrow) {
|
||||
setConfigCursor(prev => (prev + 1) % 2)
|
||||
} else if (key.return) {
|
||||
if (configCursor === 0) {
|
||||
handleRandomBattle()
|
||||
} else {
|
||||
setSpeciesIndex(ALL_SPECIES_IDS.indexOf(opponentSpeciesId))
|
||||
setPhase('configSelect')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -207,47 +297,126 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
}
|
||||
|
||||
if (phase === 'battle') {
|
||||
if (key.escape) return
|
||||
if (input >= '1' && input <= '4') {
|
||||
const idx = parseInt(input) - 1
|
||||
if (battleState && idx < battleState.playerPokemon.moves.length) {
|
||||
handleAction({ type: 'move', moveIndex: idx })
|
||||
}
|
||||
} else if (input.toLowerCase() === 's') {
|
||||
setPhase('switch')
|
||||
} else if (input.toLowerCase() === 'i') {
|
||||
setPhase('item')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!battleState) return
|
||||
|
||||
if (phase === 'switch') {
|
||||
if (key.escape) {
|
||||
setPhase('battle')
|
||||
} else if (input >= '1' && input <= '6') {
|
||||
const idx = parseInt(input) - 1
|
||||
const partyCreatures = getPartyCreatures()
|
||||
if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
|
||||
handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
|
||||
setPhase('battle')
|
||||
}
|
||||
// F key toggles animation
|
||||
if (input.toLowerCase() === 'f') {
|
||||
setAnimEnabled(prev => !prev)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'item') {
|
||||
if (key.escape) {
|
||||
setPhase('battle')
|
||||
} else if (input >= '1' && input <= '9') {
|
||||
if (battleState) {
|
||||
const idx = parseInt(input) - 1
|
||||
const items = battleState.usableItems
|
||||
if (items[idx]) {
|
||||
handleAction({ type: 'item', itemId: items[idx]!.id })
|
||||
setPhase('battle')
|
||||
// ─── Main menu ───
|
||||
if (menuPhase === 'main') {
|
||||
if (key.escape) return
|
||||
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
|
||||
moveMainCursor(key.upArrow ? 'up' : key.downArrow ? 'down' : key.leftArrow ? 'left' : 'right')
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
switch (cursorIndex) {
|
||||
case 0: // 战斗 → move selection
|
||||
setMenuPhase('fight')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 1: // 背包
|
||||
setMenuPhase('bag')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 2: // 宝可梦
|
||||
setMenuPhase('pokemon')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
case 3: // 逃跑 — show message
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Fight (move selection) ───
|
||||
if (menuPhase === 'fight') {
|
||||
if (key.escape) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(0)
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setCursorIndex(prev => Math.min(battleState.playerPokemon.moves.length - 1, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const move = battleState.playerPokemon.moves[cursorIndex]
|
||||
if (move && move.pp > 0 && !move.disabled) {
|
||||
handleAction({ type: 'move', moveIndex: cursorIndex })
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Bag (item selection) ───
|
||||
if (menuPhase === 'bag') {
|
||||
if (key.escape) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(1) // return to 背包
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setCursorIndex(prev => Math.min(battleState.usableItems.length - 1, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const item = battleState.usableItems[cursorIndex]
|
||||
if (item) {
|
||||
handleAction({ type: 'item', itemId: item.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ─── Pokemon (switch selection) ───
|
||||
if (menuPhase === 'pokemon') {
|
||||
const isForced = battleState.needsSwitch
|
||||
if (key.escape && !isForced) {
|
||||
setMenuPhase('main')
|
||||
setCursorIndex(2) // return to 宝可梦
|
||||
return
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
const maxIdx = getPartyCreatures().length - 1
|
||||
setCursorIndex(prev => Math.min(maxIdx, prev + 1))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const party = getPartyCreatures()
|
||||
const creature = party[cursorIndex]
|
||||
const battleParty = battleState.playerParty
|
||||
const battleCreature = battleParty[cursorIndex]
|
||||
if (creature && battleCreature && battleCreature.hp > 0) {
|
||||
if (isForced) {
|
||||
handleForcedSwitch(cursorIndex)
|
||||
} else {
|
||||
handleAction({ type: 'switch', partyIndex: cursorIndex })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -259,10 +428,12 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
if (phase === 'learnMoves') {
|
||||
if (input.toLowerCase() === 's') {
|
||||
handleMoveSkip()
|
||||
} else if (input >= '1' && input <= '4') {
|
||||
const idx = parseInt(input) - 1
|
||||
setReplaceIndex(idx)
|
||||
handleMoveLearn(idx)
|
||||
} else if (key.upArrow) {
|
||||
setReplaceIndex(prev => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow) {
|
||||
setReplaceIndex(prev => Math.min(3, prev + 1))
|
||||
} else if (key.return) {
|
||||
handleMoveLearn(replaceIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -271,86 +442,80 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
if (key.return) handleEvolutionConfirm()
|
||||
return
|
||||
}
|
||||
}, [isActive, phase, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm])
|
||||
}, [isActive, phase, menuPhase, cursorIndex, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
|
||||
|
||||
// Expose handleInput via ref
|
||||
useEffect(() => {
|
||||
if (inputRef) inputRef.current = { handleInput }
|
||||
}, [handleInput, inputRef])
|
||||
|
||||
// Render by phase
|
||||
// ─── Build overlay content for sub-panels ───
|
||||
|
||||
function buildOverlay(): React.ReactNode | undefined {
|
||||
if (!battleState) return undefined
|
||||
|
||||
if (menuPhase === 'bag') {
|
||||
return (
|
||||
<ItemPanel
|
||||
items={battleState.usableItems}
|
||||
cursorIndex={cursorIndex}
|
||||
categoryIndex={0}
|
||||
phase="items"
|
||||
onSelect={() => {}}
|
||||
onCancel={() => { setMenuPhase('main'); setCursorIndex(1) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (menuPhase === 'pokemon') {
|
||||
return (
|
||||
<SwitchPanel
|
||||
party={getPartyCreatures()}
|
||||
activeId={battleState.playerPokemon.id}
|
||||
cursorIndex={cursorIndex}
|
||||
battleHp={getBattleHpMap()}
|
||||
onSelect={() => {}}
|
||||
onCancel={() => { setMenuPhase('main'); setCursorIndex(2) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ─── Render by phase ───
|
||||
|
||||
switch (phase) {
|
||||
case 'config':
|
||||
return (
|
||||
<BattleConfigPanel
|
||||
party={getPartyCreatures()}
|
||||
cursorIndex={configCursor}
|
||||
onSubmit={handleStartBattle}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'configSelect': {
|
||||
const selectedIdx = ALL_SPECIES_IDS.indexOf(opponentSpeciesId)
|
||||
const startIdx = Math.max(0, Math.min(selectedIdx, ALL_SPECIES_IDS.length - 5))
|
||||
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + 5)
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Text bold color="ansi:cyan"> 选择对手 </Text>
|
||||
{visibleSpecies.map((sid) => {
|
||||
const s = getSpeciesData(sid)
|
||||
const isSelected = sid === opponentSpeciesId
|
||||
return (
|
||||
<Box key={sid}>
|
||||
<Text color={isSelected ? 'ansi:yellow' : 'ansi:white'}>
|
||||
{isSelected ? ' ▶ ' : ' '}
|
||||
#{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
|
||||
</Text>
|
||||
{isSelected && <Text color="ansi:cyan"> Lv.{getActiveCreatureLevel()}</Text>}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<Box marginTop={1}>
|
||||
<Text color="ansi:white"> [↑↓] 选择 [Enter] 确认 [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
case 'configSelect':
|
||||
return renderSpeciesSelect()
|
||||
|
||||
case 'battle': {
|
||||
if (!battleState) return null
|
||||
return (
|
||||
<BattleView
|
||||
<BattleScene
|
||||
state={battleState}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
if (!battleState) return null
|
||||
return (
|
||||
<SwitchPanel
|
||||
party={getPartyCreatures()}
|
||||
activeId={battleState.playerPokemon.id}
|
||||
onSelect={(creatureId) => {
|
||||
handleAction({ type: 'switch', creatureId })
|
||||
setPhase('battle')
|
||||
menuPhase={menuPhase}
|
||||
cursorIndex={cursorIndex}
|
||||
animEnabled={animEnabled}
|
||||
overlay={buildOverlay()}
|
||||
onMoveCursor={(dir) => {
|
||||
if (menuPhase === 'main') moveMainCursor(dir)
|
||||
else if (dir === 'up') setCursorIndex(prev => Math.max(0, prev - 1))
|
||||
else if (dir === 'down') setCursorIndex(prev => Math.min(getMaxCursor(), prev + 1))
|
||||
}}
|
||||
onCancel={() => setPhase('battle')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'item': {
|
||||
if (!battleState) return null
|
||||
return (
|
||||
<ItemPanel
|
||||
items={battleState.usableItems}
|
||||
onSelect={(itemId) => {
|
||||
handleAction({ type: 'item', itemId })
|
||||
setPhase('battle')
|
||||
}}
|
||||
onCancel={() => setPhase('battle')}
|
||||
onSelect={() => {}}
|
||||
onBack={() => { setMenuPhase('main'); setCursorIndex(0) }}
|
||||
onToggleAnim={() => setAnimEnabled(prev => !prev)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -360,7 +525,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
return (
|
||||
<BattleResultPanel
|
||||
result={battleState.result}
|
||||
playerPokemon={battleState.playerPokemon}
|
||||
onContinue={handleResultContinue}
|
||||
/>
|
||||
)
|
||||
@@ -375,7 +539,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
<MoveLearnPanel
|
||||
creature={creature}
|
||||
newMoveId={move.moveId}
|
||||
replaceIndex={replaceIndex}
|
||||
cursorIndex={replaceIndex}
|
||||
onLearn={handleMoveLearn}
|
||||
onSkip={handleMoveSkip}
|
||||
onSelectReplace={setReplaceIndex}
|
||||
@@ -387,10 +551,18 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
if (pendingEvos.length === 0) return null
|
||||
const evo = pendingEvos[0]!
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Text bold color="ansi:yellow"> 进化!</Text>
|
||||
<Text> {evo.from} 正在进化为 {evo.to}!</Text>
|
||||
<Text color="ansi:white"> [Enter] 继续</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="warning"
|
||||
borderText={{ content: ' 进化 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold color="warning">{evo.from} 正在进化为 {evo.to}!</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="claude">[Enter] 继续</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -401,4 +573,65 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Species select sub-render ───
|
||||
|
||||
function renderSpeciesSelect() {
|
||||
const total = ALL_SPECIES_IDS.length
|
||||
// Scroll window centered on selection
|
||||
const halfVisible = Math.floor(VISIBLE_SPECIES / 2)
|
||||
let startIdx = speciesIndex - halfVisible
|
||||
if (startIdx < 0) startIdx = 0
|
||||
if (startIdx + VISIBLE_SPECIES > total) startIdx = Math.max(0, total - VISIBLE_SPECIES)
|
||||
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + VISIBLE_SPECIES)
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 选择对手 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{/* Scroll indicator */}
|
||||
{total > VISIBLE_SPECIES && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>{startIdx > 0 ? ' ↑ 更多 ' : ''}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visibleSpecies.map((sid) => {
|
||||
const s = getSpeciesData(sid)
|
||||
const isSelected = sid === opponentSpeciesId
|
||||
return (
|
||||
<Box key={sid}>
|
||||
{isSelected ? (
|
||||
<Text color="success" bold> ▸ </Text>
|
||||
) : (
|
||||
<Text dimColor> </Text>
|
||||
)}
|
||||
<Text color={isSelected ? 'claude' : 'inactive'} bold={isSelected}>
|
||||
#{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<Text dimColor> Lv.{getActiveCreatureLevel()}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Scroll indicator */}
|
||||
{total > VISIBLE_SPECIES && (
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor>{startIdx + VISIBLE_SPECIES < total ? ' ↓ 更多 ' : ''}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] 选择 · [Enter] 确认 · [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
81
packages/pokemon/src/ui/BattleLogPanel.tsx
Normal file
81
packages/pokemon/src/ui/BattleLogPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BattleEvent } from '../battle/types'
|
||||
|
||||
/** Max lines to display in the log panel */
|
||||
const MAX_VISIBLE = 20
|
||||
|
||||
function eventColor(event: BattleEvent): string {
|
||||
switch (event.type) {
|
||||
case 'damage': return 'error'
|
||||
case 'heal': return 'success'
|
||||
case 'faint': return 'error'
|
||||
case 'crit': return 'warning'
|
||||
case 'miss': return 'inactive'
|
||||
case 'effectiveness': return event.multiplier > 1 ? 'success' : 'warning'
|
||||
case 'move': return 'claude'
|
||||
case 'status': return 'warning'
|
||||
case 'switch': return 'claude'
|
||||
case 'turn': return 'inactive'
|
||||
default: return 'inactive'
|
||||
}
|
||||
}
|
||||
|
||||
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 'switch': return `${event.side === 'player' ? '我方' : '对手'}换上了 ${event.name}!`
|
||||
case 'turn': return `── 回合 ${event.number} ──`
|
||||
case 'statChange': {
|
||||
const sign = event.stages > 0 ? '↑' : '↓'
|
||||
return `${event.side === 'player' ? '我方' : '对手'}的 ${event.stat} ${sign}${Math.abs(event.stages)}`
|
||||
}
|
||||
case 'ability': return `${event.side === 'player' ? '我方' : '对手'}的特性 ${event.ability} 发动了!`
|
||||
case 'item': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.item}!`
|
||||
case 'fail': return `失败了: ${event.reason}`
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
interface BattleLogPanelProps {
|
||||
events: BattleEvent[]
|
||||
animEnabled: boolean
|
||||
onToggleAnim: () => void
|
||||
}
|
||||
|
||||
export function BattleLogPanel({ events, animEnabled, onToggleAnim }: BattleLogPanelProps) {
|
||||
const visible = events.slice(-MAX_VISIBLE)
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 战斗日志 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
width="40%"
|
||||
>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{visible.map((event, i) => (
|
||||
<Text key={i} color={eventColor(event) as any} dimColor={event.type === 'turn'}>
|
||||
{' '}{formatEvent(event)}
|
||||
</Text>
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<Text dimColor> 等待战斗开始...</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> [F] {animEnabled ? '关闭动画' : '开启动画'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
122
packages/pokemon/src/ui/BattleMenu.tsx
Normal file
122
packages/pokemon/src/ui/BattleMenu.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { MoveOption } from '../battle/types'
|
||||
|
||||
export interface BattleMenuProps {
|
||||
phase: 'main' | 'fight'
|
||||
moves: MoveOption[]
|
||||
cursorIndex: number
|
||||
onMoveCursor: (direction: 'up' | 'down' | 'left' | 'right') => void
|
||||
onSelect: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function BattleMenu({ phase, moves, cursorIndex }: BattleMenuProps) {
|
||||
if (phase === 'fight') {
|
||||
return <MoveMenu moves={moves} cursorIndex={cursorIndex} />
|
||||
}
|
||||
|
||||
return <MainMenu cursorIndex={cursorIndex} />
|
||||
}
|
||||
|
||||
function MainMenu({ cursorIndex }: { cursorIndex: number }) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
paddingX={1}
|
||||
>
|
||||
{/* Row 0: 战斗 + 背包 */}
|
||||
<Box>
|
||||
<MenuItem label="战斗" selected={cursorIndex === 0} />
|
||||
<MenuItem label="背包" selected={cursorIndex === 1} />
|
||||
</Box>
|
||||
{/* Row 1: 宝可梦 + 逃跑 */}
|
||||
<Box>
|
||||
<MenuItem label="宝可梦" selected={cursorIndex === 2} />
|
||||
<MenuItem label="逃跑" selected={cursorIndex === 3} disabled />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ label, selected, disabled }: { label: string; selected: boolean; disabled?: boolean }) {
|
||||
if (selected && disabled) {
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text color="warning" bold>
|
||||
{' ▶ '}{label} (不可用)
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text color="success" bold>
|
||||
{' ▶ '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text dimColor>
|
||||
{' '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={16}>
|
||||
<Text>
|
||||
{' '}{label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MoveMenu({ moves, cursorIndex }: { moves: MoveOption[]; cursorIndex: number }) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 选择招式 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{moves.map((move, i) => (
|
||||
<MoveItem key={move.id || i} move={move} selected={cursorIndex === i} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MoveItem({ move, selected }: { move: MoveOption; selected: boolean }) {
|
||||
const ppText = `PP ${move.pp}/${move.maxPp}`
|
||||
const noPP = move.pp <= 0 || move.disabled
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<Box width={32}>
|
||||
<Text color="success" bold>
|
||||
{' ▶ '}{move.name.padEnd(14)}{ppText}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={32}>
|
||||
<Text color={noPP ? ('inactive' as any) : undefined} dimColor={noPP}>
|
||||
{' '}{move.name.padEnd(14)}{ppText}
|
||||
</Text>
|
||||
{move.disabled && <Text color="error"> 禁用</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +1,30 @@
|
||||
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'
|
||||
import type { BattleResult } from '../battle/types'
|
||||
|
||||
interface BattleResultPanelProps {
|
||||
result: BattleResult
|
||||
playerPokemon: BattlePokemon
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
export function BattleResultPanel({ result, playerPokemon, onContinue }: BattleResultPanelProps) {
|
||||
export function BattleResultPanel({ result, 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
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={isWin ? 'success' : 'error'}
|
||||
borderText={{ content: isWin ? ' 胜利 ' : ' 战败 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold color={isWin ? 'success' : 'error'}>
|
||||
{isWin ? '战斗胜利!' : '战斗失败...'}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={CYAN}> [Enter] 继续</Text>
|
||||
<Text color="claude">[Enter] 继续</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
133
packages/pokemon/src/ui/BattleScene.tsx
Normal file
133
packages/pokemon/src/ui/BattleScene.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BattleState } from '../battle/types'
|
||||
import type { SpeciesId } from '../types'
|
||||
import { loadSprite } from '../core/spriteCache'
|
||||
import { getFallbackSprite } from '../sprites/fallback'
|
||||
import { HpCard } from './HpCard'
|
||||
import { BattleMenu } from './BattleMenu'
|
||||
import { BattleLogPanel } from './BattleLogPanel'
|
||||
import { BattleSprite } from './BattleSprite'
|
||||
import type { StatusCondition } from '../battle/types'
|
||||
|
||||
export type MenuPhase = 'main' | 'fight' | 'bag' | 'pokemon'
|
||||
|
||||
/** Get sprite lines: try cache → fallback */
|
||||
function getSpriteLines(speciesId: SpeciesId): string[] {
|
||||
const cached = loadSprite(speciesId)
|
||||
if (cached) return cached.lines
|
||||
return getFallbackSprite(speciesId)
|
||||
}
|
||||
|
||||
interface BattleSceneProps {
|
||||
state: BattleState
|
||||
menuPhase: MenuPhase
|
||||
cursorIndex: number
|
||||
animEnabled: boolean
|
||||
/** Override content for right panel (bag/pokemon overlay) */
|
||||
overlay?: React.ReactNode
|
||||
onMoveCursor: (direction: 'up' | 'down' | 'left' | 'right') => void
|
||||
onSelect: () => void
|
||||
onBack: () => void
|
||||
onToggleAnim: () => void
|
||||
}
|
||||
|
||||
export function BattleScene({
|
||||
state,
|
||||
menuPhase,
|
||||
cursorIndex,
|
||||
animEnabled,
|
||||
overlay,
|
||||
onMoveCursor,
|
||||
onSelect,
|
||||
onBack,
|
||||
onToggleAnim,
|
||||
}: BattleSceneProps) {
|
||||
const opp = state.opponentPokemon
|
||||
const player = state.playerPokemon
|
||||
|
||||
// Load sprite lines (memoized by speciesId)
|
||||
const oppSpriteLines = useMemo(() => getSpriteLines(opp.speciesId as SpeciesId), [opp.speciesId])
|
||||
const playerSpriteLines = useMemo(() => getSpriteLines(player.speciesId as SpeciesId), [player.speciesId])
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width="100%">
|
||||
{/* Left: Battle Log (40%) */}
|
||||
<BattleLogPanel
|
||||
events={state.events}
|
||||
animEnabled={animEnabled}
|
||||
onToggleAnim={onToggleAnim}
|
||||
/>
|
||||
|
||||
{/* Right: Battle Field (60%) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
width="60%"
|
||||
>
|
||||
{overlay ? (
|
||||
overlay
|
||||
) : (
|
||||
<>
|
||||
{/* Opponent: HP card left, sprite right */}
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<HpCard
|
||||
name={opp.name}
|
||||
level={opp.level}
|
||||
hp={opp.hp}
|
||||
maxHp={opp.maxHp}
|
||||
status={opp.status as StatusCondition}
|
||||
align="left"
|
||||
isOpponent
|
||||
/>
|
||||
<BattleSprite
|
||||
lines={oppSpriteLines}
|
||||
animEnabled={animEnabled}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Player: sprite left, HP card right — no spacer, visually close */}
|
||||
<Box flexDirection="row" justifyContent="space-between" alignItems="flex-end">
|
||||
<BattleSprite
|
||||
lines={playerSpriteLines}
|
||||
flip
|
||||
phaseOffset={2}
|
||||
animEnabled={animEnabled}
|
||||
/>
|
||||
<HpCard
|
||||
name={player.name}
|
||||
level={player.level}
|
||||
hp={player.hp}
|
||||
maxHp={player.maxHp}
|
||||
status={player.status as StatusCondition}
|
||||
align="right"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Menu */}
|
||||
{!state.finished && (
|
||||
<BattleMenu
|
||||
phase={menuPhase as 'main' | 'fight'}
|
||||
moves={player.moves}
|
||||
cursorIndex={cursorIndex}
|
||||
onMoveCursor={onMoveCursor}
|
||||
onSelect={onSelect}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.finished && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> 战斗结束</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
68
packages/pokemon/src/ui/BattleSprite.tsx
Normal file
68
packages/pokemon/src/ui/BattleSprite.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { parseSprite, renderSprite, flipSpriteLines, EMPTY_ROW } from '../sprites/renderer'
|
||||
import type { Pixel } from '../sprites/renderer'
|
||||
|
||||
/**
|
||||
* Simple battle sprite with fast 1-2px vertical bounce.
|
||||
* Padded so bounce never clips the sprite.
|
||||
*/
|
||||
|
||||
// Bounce pattern: 0 → 1 → 2 → 1 → 0 → ...
|
||||
const BOUNCE = [0, 1, 2, 1]
|
||||
/** Vertical padding above & below — bounce shifts within this space */
|
||||
const V_PAD = 3
|
||||
|
||||
interface BattleSpriteProps {
|
||||
/** ANSI sprite lines */
|
||||
lines: string[]
|
||||
/** Flip horizontally (player side) */
|
||||
flip?: boolean
|
||||
/** Enable animation (false = static) */
|
||||
animEnabled?: boolean
|
||||
/** Phase offset to stagger bounce between sprites */
|
||||
phaseOffset?: number
|
||||
}
|
||||
|
||||
export function BattleSprite({ lines, flip, animEnabled = true, phaseOffset = 0 }: BattleSpriteProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!animEnabled) return
|
||||
const timer = setInterval(() => setTick(t => t + 1), 120)
|
||||
return () => clearInterval(timer)
|
||||
}, [animEnabled])
|
||||
|
||||
// Flip once (cached)
|
||||
const source = useMemo(() => flip ? flipSpriteLines(lines) : lines, [lines, flip])
|
||||
|
||||
// Parse to pixel grid once (cached), then pad
|
||||
const padded = useMemo(() => {
|
||||
const grid = parseSprite(source)
|
||||
const top = Array.from({ length: V_PAD }, () => EMPTY_ROW)
|
||||
const bottom = Array.from({ length: V_PAD }, () => EMPTY_ROW)
|
||||
return [...top, ...grid, ...bottom]
|
||||
}, [source])
|
||||
|
||||
// Apply bounce offset with phase shift — shift up within padded space
|
||||
const offset = animEnabled ? BOUNCE[(tick + phaseOffset) % BOUNCE.length]! : 0
|
||||
const shifted = shiftGridUp(padded, offset)
|
||||
const rendered = renderSprite(shifted)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rendered.map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Shift Pixel grid up by n rows, pad empty rows at bottom */
|
||||
function shiftGridUp(grid: Pixel[][], n: number): Pixel[][] {
|
||||
if (n <= 0) return grid
|
||||
const height = grid.length
|
||||
const shifted = grid.slice(n)
|
||||
while (shifted.length < height) shifted.push(EMPTY_ROW)
|
||||
return shifted
|
||||
}
|
||||
@@ -1,18 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { BattleState, BattleEvent } from '../battle/types'
|
||||
|
||||
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 hpColor(pct: number): 'success' | 'warning' | 'error' {
|
||||
if (pct > 50) return 'success'
|
||||
if (pct > 25) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function hpBar(current: number, max: number): { bar: string; pct: number } {
|
||||
@@ -36,53 +29,64 @@ export function BattleView({ state, onAction }: BattleViewProps) {
|
||||
const oppHp = hpBar(opp.hp, opp.maxHp)
|
||||
const playerHp = hpBar(player.hp, player.maxHp)
|
||||
|
||||
// Show last 5 events
|
||||
const recentEvents = state.events.slice(-5)
|
||||
const recentEvents = state.events.slice(-10)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="claude"
|
||||
borderText={{ content: ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{/* Opponent */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold> 野生 {opp.name} </Text>
|
||||
<Text>(Lv.{opp.level})</Text>
|
||||
<Text bold> 野生的 </Text>
|
||||
<Text bold color="error">{opp.name}</Text>
|
||||
<Text dimColor> Lv.{opp.level}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text> HP </Text>
|
||||
<Text dimColor> HP </Text>
|
||||
<Text color={hpColor(oppHp.pct)}>{oppHp.bar}</Text>
|
||||
<Text> {oppHp.pct}%</Text>
|
||||
{opp.status !== 'none' && <Text color={YELLOW}> [{opp.status}]</Text>}
|
||||
<Text> {opp.hp}/{opp.maxHp}</Text>
|
||||
{opp.status !== 'none' && <Text color="warning"> [{opp.status}]</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text color={GRAY}> ── vs ──</Text>
|
||||
<Text color="inactive"> ─── vs ───</Text>
|
||||
|
||||
{/* Player */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold> {player.name} </Text>
|
||||
<Text>(Lv.{player.level})</Text>
|
||||
<Text bold> </Text>
|
||||
<Text bold color="claude">{player.name}</Text>
|
||||
<Text dimColor> Lv.{player.level}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text> HP </Text>
|
||||
<Text dimColor> HP </Text>
|
||||
<Text color={hpColor(playerHp.pct)}>{playerHp.bar}</Text>
|
||||
<Text> {playerHp.pct}%</Text>
|
||||
{player.status !== 'none' && <Text color={YELLOW}> [{player.status}]</Text>}
|
||||
<Text> {player.hp}/{player.maxHp}</Text>
|
||||
{player.status !== 'none' && <Text color="warning"> [{player.status}]</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Move selection */}
|
||||
{!state.finished && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold> 选择行动:</Text>
|
||||
<Text bold color="claude">选择行动</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 color={move.pp > 0 ? 'text' : 'inactive'}>
|
||||
{' '}[{i + 1}] {move.name || '---'}
|
||||
</Text>
|
||||
<Text dimColor> PP {move.pp}/{move.maxPp}</Text>
|
||||
{move.disabled && <Text color="error"> (禁用)</Text>}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={CYAN}> [S] 换人 [I] 道具</Text>
|
||||
<Text color="claude"> [S] 换人</Text>
|
||||
<Text color="claude"> [I] 道具</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -90,7 +94,7 @@ export function BattleView({ state, onAction }: BattleViewProps) {
|
||||
{recentEvents.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{recentEvents.map((event, i) => (
|
||||
<Text key={i} color={eventColor(event)}> {formatEvent(event)}</Text>
|
||||
<Text key={i} color={eventColor(event)} dimColor> {formatEvent(event)}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
@@ -98,23 +102,23 @@ export function BattleView({ state, onAction }: BattleViewProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function eventColor(event: BattleEvent): Color {
|
||||
function eventColor(event: BattleEvent): 'error' | 'success' | 'warning' | 'claude' | 'inactive' | 'text' {
|
||||
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
|
||||
case 'damage': return 'error'
|
||||
case 'heal': return 'success'
|
||||
case 'faint': return 'error'
|
||||
case 'crit': return 'warning'
|
||||
case 'miss': return 'inactive'
|
||||
case 'effectiveness': return event.multiplier > 1 ? 'success' : 'warning'
|
||||
default: return 'inactive'
|
||||
}
|
||||
}
|
||||
|
||||
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 '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 '攻击没有命中!'
|
||||
|
||||
85
packages/pokemon/src/ui/HpCard.tsx
Normal file
85
packages/pokemon/src/ui/HpCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { StatusCondition } from '../battle/types'
|
||||
|
||||
/** HP bar width in characters (GBA style) */
|
||||
const HP_BAR_WIDTH = 12
|
||||
|
||||
function hpColor(pct: number): string {
|
||||
if (pct > 50) return 'success'
|
||||
if (pct > 25) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function hpBar(current: number, max: number): { bar: string; pct: number } {
|
||||
if (max <= 0) return { bar: '░'.repeat(HP_BAR_WIDTH), pct: 0 }
|
||||
const pct = Math.round((current / max) * 100)
|
||||
const filled = Math.round((current / max) * HP_BAR_WIDTH)
|
||||
return {
|
||||
bar: '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, HP_BAR_WIDTH - filled)),
|
||||
pct,
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: StatusCondition): { text: string; color: string } | null {
|
||||
switch (status) {
|
||||
case 'poison':
|
||||
case 'bad_poison':
|
||||
return { text: 'PSN', color: 'warning' }
|
||||
case 'burn':
|
||||
return { text: 'BRN', color: 'error' }
|
||||
case 'paralysis':
|
||||
return { text: 'PAR', color: 'warning' }
|
||||
case 'freeze':
|
||||
return { text: 'FRZ', color: 'claude' }
|
||||
case 'sleep':
|
||||
return { text: 'SLP', color: 'inactive' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface HpCardProps {
|
||||
name: string
|
||||
level: number
|
||||
hp: number
|
||||
maxHp: number
|
||||
status?: StatusCondition
|
||||
/** Left = opponent (top-left), Right = player (bottom-right) */
|
||||
align: 'left' | 'right'
|
||||
/** Show as opponent (wild pokemon prefix) */
|
||||
isOpponent?: boolean
|
||||
}
|
||||
|
||||
export function HpCard({ name, level, hp, maxHp, status, align, isOpponent }: HpCardProps) {
|
||||
const { bar, pct } = hpBar(hp, maxHp)
|
||||
const statusInfo = status && status !== 'none' ? statusLabel(status) : null
|
||||
|
||||
const prefix = isOpponent ? '野生的 ' : ''
|
||||
|
||||
const nameLine = (
|
||||
<Box justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
|
||||
{isOpponent && <Text bold> </Text>}
|
||||
<Text bold>{prefix}{name}</Text>
|
||||
<Text dimColor> Lv.{level}</Text>
|
||||
{statusInfo && (
|
||||
<Text color={statusInfo.color as any}> {statusInfo.text}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const hpLine = (
|
||||
<Box justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
|
||||
<Text dimColor> HP </Text>
|
||||
<Text color={hpColor(pct) as any}>{bar}</Text>
|
||||
<Text> {hp}/{maxHp}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{nameLine}
|
||||
{hpLine}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,83 @@
|
||||
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 }[]
|
||||
cursorIndex: number
|
||||
categoryIndex: number
|
||||
phase: 'category' | 'items'
|
||||
onSelect: (itemId: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ItemPanel({ items, onSelect, onCancel }: ItemPanelProps) {
|
||||
/** Item categories */
|
||||
const CATEGORIES = [
|
||||
{ id: 'healing', label: '回复药', filter: (id: string) => id.includes('potion') || id.includes('berry') || id.includes('heal') },
|
||||
{ id: 'ball', label: '精灵球', filter: (id: string) => id.includes('ball') },
|
||||
{ id: 'battle', label: '战斗道具', filter: (id: string) => id.includes('x-') || id.includes('dire') || id.includes('guard') },
|
||||
]
|
||||
|
||||
export function ItemPanel({ items, cursorIndex, categoryIndex, phase, onSelect, onCancel }: ItemPanelProps) {
|
||||
if (phase === 'category') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 背包 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{CATEGORIES.map((cat, i) => (
|
||||
<Box key={cat.id}>
|
||||
{categoryIndex === i ? (
|
||||
<Text color="success" bold> ▶ {cat.label} </Text>
|
||||
) : (
|
||||
<Text> {cat.label} </Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor> [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Phase: items — show items in selected category
|
||||
const cat = CATEGORIES[categoryIndex]
|
||||
const filtered = cat
|
||||
? items.filter(item => cat.filter(item.id))
|
||||
: items
|
||||
const displayItems = filtered.length > 0 ? filtered : items
|
||||
|
||||
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 flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ` ${cat?.label ?? '道具'} `, position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{displayItems.length === 0 ? (
|
||||
<Text dimColor> 没有可用道具</Text>
|
||||
) : (
|
||||
displayItems.map((item, i) => (
|
||||
<Box key={item.id}>
|
||||
{cursorIndex === i ? (
|
||||
<Text color="success" bold> ▶ {item.name}</Text>
|
||||
) : (
|
||||
<Text> {item.name}</Text>
|
||||
)}
|
||||
<Text dimColor> ×{item.count}</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={GRAY}> [ESC] 取消</Text>
|
||||
<Text dimColor> [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -3,46 +3,56 @@ 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
|
||||
cursorIndex: number
|
||||
onLearn: (replaceIndex: number) => void
|
||||
onSkip: () => void
|
||||
onSelectReplace: (index: number) => void
|
||||
}
|
||||
|
||||
export function MoveLearnPanel({ creature, newMoveId, replaceIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
|
||||
export function MoveLearnPanel({ creature, newMoveId, cursorIndex, 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
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 新招式 ', position: 'top', align: 'center' }}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text>{creature.speciesId} 可以学习: <Text bold color="claude">{moveName}</Text> <Text dimColor>({moveType})</Text></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 flexDirection="column" marginTop={1}>
|
||||
<Text bold>当前招式:</Text>
|
||||
{creature.moves.map((move, i) => {
|
||||
const isSelected = i === cursorIndex
|
||||
const moveInfo = move.id ? Dex.moves.get(move.id) : null
|
||||
return (
|
||||
<Box key={i}>
|
||||
{isSelected ? (
|
||||
<Text color="success" bold>
|
||||
{' ▶ '}{moveInfo?.name ?? move.id ?? '---'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}{moveInfo?.name ?? move.id ?? '---'}
|
||||
</Text>
|
||||
)}
|
||||
<Text dimColor> PP {move.pp}/{move.maxPp}</Text>
|
||||
{isSelected && <Text color="warning"> {'<-- 替换'}</Text>}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={GRAY}> [1-4] 替换对应招式 [S] 跳过</Text>
|
||||
<Text dimColor>[↑↓] 选择 · [Enter] 替换 · [S] 跳过</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { Box, Text, type Color } from '@anthropic/ink'
|
||||
import type { AnimMode } from '../types'
|
||||
import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
|
||||
import { renderAnimatedSprite, flipSpriteLines, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
|
||||
|
||||
/** Vertical padding — bounce shifts within this space */
|
||||
const V_PAD = 4
|
||||
@@ -19,6 +19,8 @@ interface SpriteAnimatorProps {
|
||||
centered?: boolean
|
||||
/** Show pet hearts overlay */
|
||||
petting?: boolean
|
||||
/** Flip horizontally (for player Pokemon facing opponent) */
|
||||
flip?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,7 @@ export function SpriteAnimator({
|
||||
mode,
|
||||
centered = true,
|
||||
petting,
|
||||
flip,
|
||||
}: SpriteAnimatorProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
@@ -43,8 +46,14 @@ export function SpriteAnimator({
|
||||
return () => clearInterval(timer)
|
||||
}, [tickMs])
|
||||
|
||||
// Flip sprite if needed (cached)
|
||||
const sourceLines = useMemo(
|
||||
() => flip ? flipSpriteLines(lines) : lines,
|
||||
[lines, flip],
|
||||
)
|
||||
|
||||
// Add vertical padding — bounce shifts within this space
|
||||
const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')]
|
||||
const padded = [...Array(V_PAD).fill(''), ...sourceLines, ...Array(V_PAD).fill('')]
|
||||
|
||||
// Apply animation (renderer parses to pixels, transforms, renders back)
|
||||
const currentMode = mode ?? getIdleAnimMode(tick)
|
||||
|
||||
@@ -3,35 +3,77 @@ import { Box, Text } from '@anthropic/ink'
|
||||
import type { Creature } from '../types'
|
||||
import { getCreatureName } from '../core/creature'
|
||||
|
||||
const CYAN = 'ansi:cyan'
|
||||
const GRAY = 'ansi:white'
|
||||
const WHITE = 'ansi:whiteBright'
|
||||
|
||||
interface SwitchPanelProps {
|
||||
party: Creature[]
|
||||
activeId: string
|
||||
onSelect: (creatureId: string) => void
|
||||
cursorIndex: number
|
||||
/** HP values from battle state (keyed by creature id) */
|
||||
battleHp?: Record<string, { hp: number; maxHp: number }>
|
||||
onSelect: (creatureId: string, partyIndex: number) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function SwitchPanel({ party, activeId, onSelect, onCancel }: SwitchPanelProps) {
|
||||
function hpBarSmall(current: number, max: number): string {
|
||||
if (max <= 0) return '░░░░░░'
|
||||
const filled = Math.round((current / max) * 6)
|
||||
return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 6 - filled))
|
||||
}
|
||||
|
||||
function hpColorStr(pct: number): string {
|
||||
if (pct > 50) return 'success'
|
||||
if (pct > 25) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export function SwitchPanel({ party, activeId, cursorIndex, battleHp, onCancel }: SwitchPanelProps) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Text bold color={CYAN}> 换人 </Text>
|
||||
{party.map((creature, i) => {
|
||||
const isActive = creature.id === activeId
|
||||
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 flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="success"
|
||||
borderText={{ content: ' 换人 ', position: 'top', align: 'start' }}
|
||||
paddingX={1}
|
||||
>
|
||||
{party.map((creature, i) => {
|
||||
const isActive = creature.id === activeId
|
||||
const hpData = battleHp?.[creature.id]
|
||||
const hp = hpData?.hp ?? 0
|
||||
const maxHp = hpData?.maxHp ?? 1
|
||||
const hpPct = maxHp > 0 ? Math.round((hp / maxHp) * 100) : 0
|
||||
const isFainted = hpData ? hp <= 0 : false
|
||||
|
||||
return (
|
||||
<Box key={creature.id}>
|
||||
{cursorIndex === i ? (
|
||||
<Text color="success" bold>
|
||||
{' ▸ '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
|
||||
</Text>
|
||||
) : isActive ? (
|
||||
<Text dimColor>
|
||||
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (场上) '}
|
||||
</Text>
|
||||
) : isFainted ? (
|
||||
<Text dimColor>
|
||||
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (倒下) '}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hpData && (
|
||||
<Text color={hpColorStr(hpPct) as any}>
|
||||
{hpBarSmall(hp, maxHp)} {hp}/{maxHp}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={GRAY}> [ESC] 取消</Text>
|
||||
<Text dimColor> [ESC] 返回</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -146,7 +146,8 @@ export function companionReservedColumns(terminalColumns: number, speaking: bool
|
||||
const name = getCreatureName(creature);
|
||||
const nameWidth = stringWidth(name);
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
|
||||
// Without sprite art, only need name row width + padding + optional bubble
|
||||
return nameWidth + NAME_ROW_PAD + SPRITE_PADDING_X + bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,11 +276,6 @@ export function CompanionSprite(): React.ReactNode {
|
||||
|
||||
const spriteColumn = (
|
||||
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||
{displayLines.map((line, i) => (
|
||||
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
||||
{focused ? ` ${name} ` : name}
|
||||
</Text>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { useInput } from '@anthropic/ink'
|
||||
import React, { useRef } from 'react'
|
||||
import { useInput, useRegisterKeybindingContext } from '@anthropic/ink'
|
||||
import {
|
||||
loadBuddyData,
|
||||
saveBuddyData,
|
||||
getActiveCreature,
|
||||
BattleFlow,
|
||||
type BuddyData,
|
||||
@@ -49,20 +48,18 @@ function BattlePanel({
|
||||
buddyData: BuddyData
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [battleKey, setBattleKey] = useState(0)
|
||||
const inputRef = useRef<BattleFlowHandle | null>(null)
|
||||
|
||||
useInput((input, key) => {
|
||||
// Register keybinding context so our shortcuts take priority over Global
|
||||
useRegisterKeybindingContext('Battle')
|
||||
|
||||
useInput((input, key, event) => {
|
||||
// Consume ALL keyboard events to prevent PromptInput from intercepting
|
||||
event.stopImmediatePropagation()
|
||||
inputRef.current?.handleInput(input, key)
|
||||
})
|
||||
|
||||
const handleClose = async () => {
|
||||
const updated = await loadBuddyData()
|
||||
setBattleKey(k => k + 1)
|
||||
}
|
||||
|
||||
return React.createElement(BattleFlow, {
|
||||
key: battleKey,
|
||||
buddyData,
|
||||
onClose,
|
||||
isActive: true,
|
||||
|
||||
Reference in New Issue
Block a user