From 77e8d154822005be8f2e460a3534c987606544b5 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 22 Apr 2026 14:24:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=A3=E5=86=B3=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/pokemon/src/battle/engine.ts | 112 +++++++++++++++++++-- packages/pokemon/src/battle/types.ts | 19 +++- packages/pokemon/src/ui/BattleFlow.tsx | 2 +- packages/pokemon/src/ui/BattleLogPanel.tsx | 28 +++++- packages/pokemon/src/ui/BattleScene.tsx | 13 ++- 5 files changed, 157 insertions(+), 17 deletions(-) diff --git a/packages/pokemon/src/battle/engine.ts b/packages/pokemon/src/battle/engine.ts index bc20d45a5..dc9e9e4f4 100644 --- a/packages/pokemon/src/battle/engine.ts +++ b/packages/pokemon/src/battle/engine.ts @@ -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 | undefined): Record 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 diff --git a/packages/pokemon/src/battle/types.ts b/packages/pokemon/src/battle/types.ts index 8e2c7da74..8662960a7 100644 --- a/packages/pokemon/src/battle/types.ts +++ b/packages/pokemon/src/battle/types.ts @@ -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 } diff --git a/packages/pokemon/src/ui/BattleFlow.tsx b/packages/pokemon/src/ui/BattleFlow.tsx index 902ee5468..95a75816a 100644 --- a/packages/pokemon/src/ui/BattleFlow.tsx +++ b/packages/pokemon/src/ui/BattleFlow.tsx @@ -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(() => { diff --git a/packages/pokemon/src/ui/BattleLogPanel.tsx b/packages/pokemon/src/ui/BattleLogPanel.tsx index 414055a46..9ff069a0a 100644 --- a/packages/pokemon/src/ui/BattleLogPanel.tsx +++ b/packages/pokemon/src/ui/BattleLogPanel.tsx @@ -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 = { + 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 '' } } diff --git a/packages/pokemon/src/ui/BattleScene.tsx b/packages/pokemon/src/ui/BattleScene.tsx index c33f9cba8..8e7cdb29c 100644 --- a/packages/pokemon/src/ui/BattleScene.tsx +++ b/packages/pokemon/src/ui/BattleScene.tsx @@ -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 = { + 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({ /> - {/* Player: sprite left, HP card right — no spacer, visually close */} - + {/* Player: overlaps opponent area by pulling up */} +