feat: 一大坨优化

This commit is contained in:
claude-code-best
2026-04-22 13:33:02 +08:00
parent 8bf645364f
commit 72cfb83de3
27 changed files with 3554 additions and 548 deletions

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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)
}

View 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')
})
})

View File

@@ -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)
})

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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 = [

View File

@@ -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>
)

View 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
}
}
}
}

View File

@@ -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>
)
}
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)

View 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>
)
}

View 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
}

View File

@@ -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 '攻击没有命中!'

View 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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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)

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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,