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