feat: 解决显示问题

This commit is contained in:
claude-code-best
2026-04-22 14:24:41 +08:00
parent 72cfb83de3
commit 77e8d15482
5 changed files with 157 additions and 17 deletions

View File

@@ -3,7 +3,7 @@ import { Protocol } from '@pkmn/protocol'
import type { Creature, SpeciesId } from '../types'
import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
import { STAT_NAMES } from '../types'
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types'
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition, WeatherKind, FieldCondition } from './types'
import { chooseAIMove } from './ai'
// ─── Types ───
@@ -145,9 +145,13 @@ function projectBoosts(boosts: Record<string, number> | undefined): Record<strin
return result
}
function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState {
function projectState(battle: any, bagItems?: { id: string; count: number }[], prevConditions?: { player: FieldCondition[]; opponent: FieldCondition[] }): BattleState {
const p1 = battle.p1
const p2 = battle.p2
// Extract weather from battle field
const weatherRaw = battle.field?.weather ?? ''
const weather = mapWeather(weatherRaw)
return {
playerPokemon: projectPokemon(p1.active[0]),
opponentPokemon: projectPokemon(p2.active[0]),
@@ -157,9 +161,39 @@ function projectState(battle: any, bagItems?: { id: string; count: number }[]):
events: [],
finished: battle.ended,
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
weather,
playerConditions: prevConditions?.player ?? projectSideConditions(p1),
opponentConditions: prevConditions?.opponent ?? projectSideConditions(p2),
}
}
function mapWeather(raw: string): WeatherKind | undefined {
if (!raw) return undefined
const w = raw.toLowerCase()
if (w.includes('sun') || w.includes('desolateland')) return 'sun'
if (w.includes('rain') || w.includes('primordialsea')) return 'rain'
if (w.includes('sandstorm')) return 'sandstorm'
if (w.includes('hail')) return 'hail'
if (w.includes('snow')) return 'snow'
if (w.includes('deltastream')) return 'deltastream'
return undefined
}
/** Extract field conditions from a side object */
function projectSideConditions(side: any): FieldCondition[] {
const conditions: FieldCondition[] = []
if (!side) return conditions
const sr = side.sideConditions?.stealthrock
if (sr) conditions.push({ id: 'Stealth Rock', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: 1 })
const spikes = side.sideConditions?.spikes
if (spikes) conditions.push({ id: 'Spikes', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: spikes.levels ?? 1 })
const tspikes = side.sideConditions?.toxicspikes
if (tspikes) conditions.push({ id: 'Toxic Spikes', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: tspikes.levels ?? 1 })
const webs = side.sideConditions?.stickyweb
if (webs) conditions.push({ id: 'Sticky Web', side: side === side.battle?.p1 ? 'player' as const : 'opponent' as const, level: 1 })
return conditions
}
// ─── Protocol Event Parsing (from spectator chunks) ───
function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxHp: number }; opponent: { hp: number; maxHp: number } }): BattleEvent[] {
@@ -169,11 +203,11 @@ function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxH
for (const line of chunk.split('\n')) {
if (!line.startsWith('|')) continue
// Skip non-battle lines
// Skip non-battle lines (but NOT |upkeep| anymore!)
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
line.startsWith('|start|') || line.startsWith('|done|')) continue
const parts = line.split('|')
const cmd = parts[1]
@@ -244,6 +278,10 @@ function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxH
case '-status':
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
break
case '-curestatus':
// Pokémon cured of status — represent as status 'none'
events.push({ type: 'status', side, status: 'none' })
break
case '-boost':
case '-unboost': {
const stages = cmd === '-boost' ? Number(parts[4]) : -Number(parts[4])
@@ -253,6 +291,58 @@ function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxH
case '-ability':
events.push({ type: 'ability', side, ability: parts[3] ?? '' })
break
case '-item':
events.push({ type: 'item', side, item: parts[3] ?? '' })
break
case 'fail':
events.push({ type: 'fail', side, reason: parts[3] ?? '' })
break
case '-fail':
events.push({ type: 'fail', side, reason: parts[3] ?? '' })
break
case '-weather': {
const weatherRaw = parts[2] ?? ''
if (weatherRaw === 'none' || weatherRaw === '') {
events.push({ type: 'weather', weather: 'none' })
} else {
const weather = mapWeather(weatherRaw)
events.push({ type: 'weather', weather: weather ?? 'none', source: parts[3] ?? undefined })
}
break
}
case '-fieldstart':
case '-fieldend': {
const fieldId = parts[2] ?? ''
const action = cmd === '-fieldstart' ? 'add' as const : 'remove' as const
// Terrains etc. — map to fieldCondition
events.push({ type: 'fieldCondition', side: 'player', id: fieldId, level: 1, action })
break
}
case '-sidestart': {
const conditionId = parts[3] ?? ''
const condSide = parts[2]?.startsWith('p1') ? 'player' as const : 'opponent' as const
const level = conditionId.match(/\d/) ? parseInt(conditionId.match(/\d/)![0], 10) : 1
const cleanId = conditionId.replace(/\d+$/, '').trim()
events.push({ type: 'fieldCondition', side: condSide, id: cleanId, level, action: 'add' })
break
}
case '-sideend': {
const conditionId = parts[3] ?? ''
const condSide = parts[2]?.startsWith('p1') ? 'player' as const : 'opponent' as const
events.push({ type: 'fieldCondition', side: condSide, id: conditionId, level: 0, action: 'remove' })
break
}
case '-activate': {
const effect = parts[3] ?? parts[2] ?? ''
events.push({ type: 'activate', side, effect })
break
}
case '-immune':
events.push({ type: 'immune', side })
break
case 'upkeep':
events.push({ type: 'upkeep' })
break
case 'turn':
events.push({ type: 'turn', number: Number(parts[2]) })
break
@@ -317,7 +407,7 @@ export async function createBattle(
// Use Battle object for rich state projection
const battle = stream.battle!
const state = projectState(battle, _bagItems)
const state = projectState(battle, _bagItems, { player: [], opponent: [] })
state.events = initialEvents
return { streams, stream, state }
@@ -365,8 +455,11 @@ export async function executeTurn(
opponent: { hp: prevState.opponentPokemon.hp, maxHp: prevState.opponentPokemon.maxHp },
})
// Project rich state from Battle object
const state = projectState(battle, prevState.usableItems)
// Project rich state from Battle object, preserving field conditions
const state = projectState(battle, prevState.usableItems, {
player: prevState.playerConditions,
opponent: prevState.opponentConditions,
})
state.events = [...prevState.events, ...newEvents]
// Forced switch detection via Battle object
@@ -429,7 +522,10 @@ export async function executeSwitch(
})
// Project state
const state = projectState(battle, prevState.usableItems)
const state = projectState(battle, prevState.usableItems, {
player: prevState.playerConditions,
opponent: prevState.opponentConditions,
})
state.events = [...prevState.events, ...newEvents]
// Forced switch detection via Battle object

View File

@@ -31,6 +31,15 @@ export type PlayerAction =
| { type: 'switch'; partyIndex: number }
| { type: 'item'; itemId: string }
export type WeatherKind = 'sun' | 'rain' | 'sandstorm' | 'hail' | 'snow' | 'desolateland' | 'primordialsea' | 'deltastream'
export type FieldCondition = {
/** e.g. 'Stealth Rock', 'Spikes', 'Toxic Spikes', 'Sticky Web' */
id: string
side: 'player' | 'opponent'
level: number // 1-3 for Spikes/Toxic Spikes, 1 for others
}
export type BattleEvent =
| { type: 'move'; side: 'player' | 'opponent'; move: string; user: string }
| { type: 'damage'; side: 'player' | 'opponent'; amount: number; percentage: number }
@@ -44,7 +53,12 @@ export type BattleEvent =
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }
| { type: 'item'; side: 'player' | 'opponent'; item: string }
| { type: 'fail'; reason: string }
| { type: 'fail'; side: 'player' | 'opponent'; reason: string }
| { type: 'weather'; weather: WeatherKind | 'none'; source?: string }
| { type: 'upkeep' }
| { type: 'fieldCondition'; side: 'player' | 'opponent'; id: string; level: number; action: 'add' | 'remove' }
| { type: 'activate'; side: 'player' | 'opponent'; effect: string }
| { type: 'immune'; side: 'player' | 'opponent' }
| { type: 'turn'; number: number }
export type BattleResult = {
@@ -66,4 +80,7 @@ export type BattleState = {
result?: BattleResult
usableItems: { id: string; name: string; count: number }[]
needsSwitch?: boolean // player's active Pokémon fainted, must switch
weather?: WeatherKind // current weather
playerConditions: FieldCondition[] // hazards on player's side
opponentConditions: FieldCondition[] // hazards on opponent's side
}

View File

@@ -442,7 +442,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
if (key.return) handleEvolutionConfirm()
return
}
}, [isActive, phase, menuPhase, cursorIndex, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
}, [isActive, phase, menuPhase, cursorIndex, configCursor, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
// Expose handleInput via ref
useEffect(() => {

View File

@@ -17,10 +17,23 @@ function eventColor(event: BattleEvent): string {
case 'status': return 'warning'
case 'switch': return 'claude'
case 'turn': return 'inactive'
case 'weather': return 'claude'
case 'fieldCondition': return 'warning'
case 'activate': return 'claude'
case 'immune': return 'inactive'
case 'upkeep': return 'inactive'
case 'ability': return 'claude'
case 'item': return 'warning'
case 'fail': return 'inactive'
default: return 'inactive'
}
}
const WEATHER_NAMES: Record<string, string> = {
sun: '大晴天', rain: '雨天', sandstorm: '沙暴', hail: '冰雹',
snow: '下雪', desolateland: '大日照', primordialsea: '大雨', deltastream: '强气流',
}
function formatEvent(event: BattleEvent): string {
switch (event.type) {
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
@@ -30,7 +43,7 @@ function formatEvent(event: BattleEvent): string {
case 'crit': return '击中要害!'
case 'miss': return '攻击没有命中!'
case 'effectiveness': return event.multiplier > 1 ? '效果拔群!' : '效果不佳...'
case 'status': return `${event.side === 'player' ? '我方' : '对手'}陷入了${event.status}状态!`
case 'status': return `${event.side === 'player' ? '我方' : '对手'}${event.status === 'none' ? '恢复了异常状态!' : `陷入了${event.status}状态!`}`
case 'switch': return `${event.side === 'player' ? '我方' : '对手'}换上了 ${event.name}!`
case 'turn': return `── 回合 ${event.number} ──`
case 'statChange': {
@@ -38,8 +51,17 @@ function formatEvent(event: BattleEvent): string {
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}`
case 'item': return `${event.side === 'player' ? '我方' : '对手'} ${event.item} 发动了!`
case 'fail': return `${event.side === 'player' ? '我方' : '对手'}的攻击失败了!`
case 'weather':
if (event.weather === 'none') return '天气恢复了正常'
return `${WEATHER_NAMES[event.weather] ?? event.weather} 开始了!`
case 'upkeep': return '── 回合结束处理 ──'
case 'fieldCondition':
if (event.action === 'add') return `${event.side === 'player' ? '我方' : '对手'}场地: ${event.id}!`
return `${event.side === 'player' ? '我方' : '对手'}场地的 ${event.id} 消失了`
case 'activate': return `${event.side === 'player' ? '我方' : '对手'}触发了 ${event.effect}`
case 'immune': return `${event.side === 'player' ? '我方' : '对手'}不受影响!`
default: return ''
}
}

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BattleState } from '../battle/types'
import type { BattleState, WeatherKind } from '../battle/types'
import type { SpeciesId } from '../types'
import { loadSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'
@@ -32,6 +32,11 @@ interface BattleSceneProps {
onToggleAnim: () => void
}
const WEATHER_LABELS: Record<WeatherKind, string> = {
sun: '☀ 大晴天', rain: '🌧 雨天', sandstorm: '🌪 沙暴', hail: '❄ 冰雹',
snow: '🌨 下雪', desolateland: '☀ 大日照', primordialsea: '🌧 大雨', deltastream: '🌀 强气流',
}
export function BattleScene({
state,
menuPhase,
@@ -64,7 +69,7 @@ export function BattleScene({
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
borderText={{ content: state.weather ? ` ${WEATHER_LABELS[state.weather]} · 回合 ${state.turn} ` : ` 回合 ${state.turn} `, position: 'top', align: 'center' }}
paddingX={1}
paddingY={0}
width="60%"
@@ -90,8 +95,8 @@ export function BattleScene({
/>
</Box>
{/* Player: sprite left, HP card right — no spacer, visually close */}
<Box flexDirection="row" justifyContent="space-between" alignItems="flex-end">
{/* Player: overlaps opponent area by pulling up */}
<Box flexDirection="row" justifyContent="space-between" alignItems="flex-end" marginTop={-10}>
<BattleSprite
lines={playerSpriteLines}
flip