fix: Battle 快捷键通过 inputRef 转发至主 Ink 上下文

BattleFlow 不再直接调用 useInput(外部包的 Ink 上下文可能不同),
改为暴露 handleInput 方法,由 BuddyPanel 中的 BattleTab 通过
useInput + ref 转发键盘事件,确保在正确的 StdinContext 中工作。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 08:44:33 +08:00
parent d8e33935db
commit bd70971632
3 changed files with 125 additions and 112 deletions

View File

@@ -78,3 +78,4 @@ export { ItemPanel } from './ui/ItemPanel'
export { BattleResultPanel } from './ui/BattleResultPanel'
export { MoveLearnPanel } from './ui/MoveLearnPanel'
export { BattleFlow } from './ui/BattleFlow'
export type { BattleFlowHandle } from './ui/BattleFlow'

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react'
import { Box, Text, useInput } from '@anthropic/ink'
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../dex/species'
@@ -26,13 +26,18 @@ type Phase =
| 'evolution'
| 'done'
export interface BattleFlowHandle {
handleInput: (input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => void
}
interface BattleFlowProps {
buddyData: BuddyData
onClose: () => void
isActive?: boolean
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
}
export function BattleFlow({ buddyData: initialData, onClose, isActive = true }: BattleFlowProps) {
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
const [phase, setPhase] = useState<Phase>('config')
const [buddyData, setBuddyData] = useState(initialData)
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
@@ -44,112 +49,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true }:
const [replaceIndex, setReplaceIndex] = useState(0)
const [speciesIndex, setSpeciesIndex] = useState(0)
// ─── Input handling ───
useInput((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
// Config phase: Enter = random battle, ESC = cancel
if (!isActive) return
if (phase === 'config') {
if (key.escape) {
onClose()
} else if (key.return || input === '1') {
handleRandomBattle()
} else if (input === '2') {
setPhase('configSelect')
}
return
}
// Config select: pick species by number
if (phase === 'configSelect') {
if (key.escape) {
setPhase('config')
} else if (key.return) {
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
}
return
}
// Battle phase: 1-4 = move, S = switch, I = item, ESC = cancel
if (phase === 'battle') {
if (key.escape) {
// Can't flee from wild battle - do nothing
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
}
// Switch phase: 1-6 = select, ESC = cancel
if (phase === 'switch') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '6') {
const idx = parseInt(input) - 1
const partyCreatures = getPartyCreatures()
if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
setPhase('battle')
}
}
return
}
// Item phase: 1-9 = select item, ESC = cancel
if (phase === 'item') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '9') {
if (battleState) {
const idx = parseInt(input) - 1
const items = battleState.usableItems
if (items[idx]) {
handleAction({ type: 'item', itemId: items[idx]!.id })
setPhase('battle')
}
}
}
return
}
// Result phase: Enter = continue
if (phase === 'result') {
if (key.return) {
handleResultContinue()
}
return
}
// Move learn phase: 1-4 = replace, S = skip
if (phase === 'learnMoves') {
if (input.toLowerCase() === 's') {
handleMoveSkip()
} else if (input >= '1' && input <= '4') {
const idx = parseInt(input) - 1
setReplaceIndex(idx)
handleMoveLearn(idx)
}
return
}
// Evolution phase: Enter = confirm
if (phase === 'evolution') {
if (key.return) {
handleEvolutionConfirm()
}
return
}
})
// ─── Helpers ───
function getActiveCreatureLevel(): number {
@@ -274,6 +173,111 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true }:
}
}, [pendingEvos, buddyData, onClose])
// ─── Input handler (called externally via inputRef) ───
const handleInput = useCallback((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
if (!isActive) return
if (phase === 'config') {
if (key.escape) {
onClose()
} else if (key.return || input === '1') {
handleRandomBattle()
} else if (input === '2') {
setPhase('configSelect')
}
return
}
if (phase === 'configSelect') {
if (key.escape) {
setPhase('config')
} else if (key.upArrow) {
const idx = speciesIndex > 0 ? speciesIndex - 1 : ALL_SPECIES_IDS.length - 1
setSpeciesIndex(idx)
setOpponentSpeciesId(ALL_SPECIES_IDS[idx]!)
} else if (key.downArrow) {
const idx = speciesIndex < ALL_SPECIES_IDS.length - 1 ? speciesIndex + 1 : 0
setSpeciesIndex(idx)
setOpponentSpeciesId(ALL_SPECIES_IDS[idx]!)
} else if (key.return) {
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
}
return
}
if (phase === 'battle') {
if (key.escape) return
if (input >= '1' && input <= '4') {
const idx = parseInt(input) - 1
if (battleState && idx < battleState.playerPokemon.moves.length) {
handleAction({ type: 'move', moveIndex: idx })
}
} else if (input.toLowerCase() === 's') {
setPhase('switch')
} else if (input.toLowerCase() === 'i') {
setPhase('item')
}
return
}
if (phase === 'switch') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '6') {
const idx = parseInt(input) - 1
const partyCreatures = getPartyCreatures()
if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
setPhase('battle')
}
}
return
}
if (phase === 'item') {
if (key.escape) {
setPhase('battle')
} else if (input >= '1' && input <= '9') {
if (battleState) {
const idx = parseInt(input) - 1
const items = battleState.usableItems
if (items[idx]) {
handleAction({ type: 'item', itemId: items[idx]!.id })
setPhase('battle')
}
}
}
return
}
if (phase === 'result') {
if (key.return) handleResultContinue()
return
}
if (phase === 'learnMoves') {
if (input.toLowerCase() === 's') {
handleMoveSkip()
} else if (input >= '1' && input <= '4') {
const idx = parseInt(input) - 1
setReplaceIndex(idx)
handleMoveLearn(idx)
}
return
}
if (phase === 'evolution') {
if (key.return) handleEvolutionConfirm()
return
}
}, [isActive, phase, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm])
// Expose handleInput via ref
useEffect(() => {
if (inputRef) inputRef.current = { handleInput }
}, [handleInput, inputRef])
// Render by phase
switch (phase) {
case 'config':
@@ -286,14 +290,13 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true }:
)
case 'configSelect': {
const species = getSpeciesData(opponentSpeciesId)
const selectedIdx = ALL_SPECIES_IDS.indexOf(opponentSpeciesId)
const startIdx = Math.max(0, Math.min(selectedIdx, ALL_SPECIES_IDS.length - 5))
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + 5)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color="ansi:cyan"> </Text>
{visibleSpecies.map((sid, i) => {
{visibleSpecies.map((sid) => {
const s = getSpeciesData(sid)
const isSelected = sid === opponentSpeciesId
return (

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState } from 'react';
import { useState, useRef } from 'react';
import { Box, Text, Pane, Tab, Tabs, useInput, type Color } from '@anthropic/ink';
import { useSetAppState } from '../../state/AppState.js';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
@@ -22,6 +22,7 @@ import { getXpProgress } from '@claude-code-best/pokemon';
import { getGenderSymbol } from '@claude-code-best/pokemon';
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } from '@claude-code-best/pokemon';
import { BattleFlow, loadBuddyData } from '@claude-code-best/pokemon';
import type { BattleFlowHandle } from '@claude-code-best/pokemon';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
const CYAN: Color = 'ansi:cyan';
@@ -633,6 +634,13 @@ function BattleTab({
onUpdate: (data: BuddyData) => void;
}) {
const [battleKey, setBattleKey] = useState(0);
const inputRef = useRef<BattleFlowHandle | null>(null);
// Handle input here (in main app's Ink context) and forward to BattleFlow via ref
useInput((input, key) => {
if (!isActive) return;
inputRef.current?.handleInput(input, key);
});
const handleClose = async () => {
const updated = await loadBuddyData();
@@ -646,6 +654,7 @@ function BattleTab({
buddyData={buddyData}
onClose={handleClose}
isActive={isActive}
inputRef={inputRef}
/>
);
}