`,是最核心的布局容器。
+
+```tsx
+import { Box, Text } from '@anthropic/ink'
+
+// 基础布局
+
+
+ 左侧
+ 右侧
+
+
+
+// 主题色边框
+
+ 带主题色边框的盒子
+
+
+// 键盘交互
+
{
+ if (e.key === 'escape') e.preventDefault()
+ }}
+>
+ 可聚焦的盒子
+
+```
+
+**Props**:
+
+| 属性 | 类型 | 说明 |
+|------|------|------|
+| `ref` | `Ref
` | DOM 元素引用 |
+| `tabIndex` | `number` | Tab 键序(≥0 参与循环,-1 仅程序聚焦) |
+| `autoFocus` | `boolean` | 挂载时自动聚焦 |
+| `onClick` | `(e: ClickEvent) => void` | 鼠标点击(仅 AlternateScreen 内生效) |
+| `onFocus` / `onBlur` | `(e: FocusEvent) => void` | 焦点事件 |
+| `onFocusCapture` / `onBlurCapture` | 同上 | 捕获阶段焦点事件 |
+| `onKeyDown` / `onKeyDownCapture` | `(e: KeyboardEvent) => void` | 键盘事件 |
+| `onMouseEnter` / `onMouseLeave` | `() => void` | 鼠标进出(仅 AlternateScreen) |
+| 所有 `Styles` 属性 | 见 [布局系统](#5-布局系统-styles) | |
+
+**ThemedBox 额外的颜色属性**:
+
+| 属性 | 类型 | 说明 |
+|------|------|------|
+| `borderColor` | `keyof Theme \| Color` | 边框颜色(接受主题 key) |
+| `borderTopColor` 等 | 同上 | 单边边框颜色 |
+| `backgroundColor` | `keyof Theme \| Color` | 背景颜色(接受主题 key) |
+
+---
+
+### Text / ThemedText
+
+文本渲染组件。
+
+```tsx
+import { Text } from '@anthropic/ink'
+
+// 主题色文本
+Claude
+错误信息
+次要信息(使用 inactive 色)
+
+// 原始色值
+橙色
+也是橙色
+256色橙色
+
+// 文本截断
+很长的文本会被截断...
+
+// bold 和 dim 互斥(终端限制)
+加粗文本
+暗淡文本
+// ❌ 不允许
+```
+
+**Props**:
+
+| 属性 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `color` | `keyof Theme \| Color` | — | 文字颜色 |
+| `backgroundColor` | `keyof Theme` | — | 背景色(仅接受主题 key) |
+| `bold` | `boolean` | — | 加粗(与 dim 互斥) |
+| `dim` | `boolean` | — | 暗淡(与 bold 互斥) |
+| `dimColor` | `boolean` | — | 使用主题 inactive 色着色(不与 bold 互斥) |
+| `italic` | `boolean` | — | 斜体 |
+| `underline` | `boolean` | — | 下划线 |
+| `strikethrough` | `boolean` | — | 删除线 |
+| `inverse` | `boolean` | — | 反转前景/背景 |
+| `wrap` | 见下方 | `'wrap'` | 文本换行/截断模式 |
+
+**wrap 模式**:
+
+| 值 | 说明 |
+|----|------|
+| `'wrap'` | 自动换行(默认) |
+| `'wrap-trim'` | 换行并去除行尾空格 |
+| `'end'` | 不换行,末尾省略 |
+| `'middle'` | 不换行,中间省略 |
+| `'truncate-end'` | 末尾截断 |
+| `'truncate'` | 同 truncate-end |
+| `'truncate-middle'` | 中间截断 |
+| `'truncate-start'` | 开头截断 |
+
+**`TextHoverColorContext`**: 为子树中未设置 `color` 的 Text 提供级联颜色。
+
+```tsx
+import { Text, TextHoverColorContext } from '@anthropic/ink'
+
+
+ 这段文字会继承 suggestion 色
+ 这段保持 error 色
+
+```
+
+---
+
+### ScrollBox
+
+支持虚拟滚动的容器,用于显示超出视口的内容。
+
+```tsx
+import { ScrollBox, Box, Text } from '@anthropic/ink'
+import { useRef } from 'react'
+
+function LogViewer({ lines }: { lines: string[] }) {
+ const scrollRef = useRef(null)
+
+ // 编程式滚动
+ useKeybinding('app:scrollUp', () => scrollRef.current?.scrollBy(-5))
+ useKeybinding('app:scrollDown', () => scrollRef.current?.scrollBy(5))
+
+ return (
+
+ {lines.map((line, i) => (
+ {line}
+ ))}
+
+ )
+}
+```
+
+**ScrollBoxHandle 方法**:
+
+| 方法 | 返回 | 说明 |
+|------|------|------|
+| `scrollTo(y)` | `void` | 滚动到指定行 |
+| `scrollBy(dy)` | `void` | 相对滚动 |
+| `scrollToElement(el, offset?)` | `void` | 滚动到指定元素 |
+| `scrollToBottom()` | `void` | 滚到底部 |
+| `getScrollTop()` | `number` | 当前滚动位置 |
+| `getScrollHeight()` | `number` | 内容总高度 |
+| `getViewportHeight()` | `number` | 视口高度 |
+| `isSticky()` | `boolean` | 是否粘在底部 |
+| `setClampBounds(min, max)` | `void` | 限制滚动范围 |
+| `subscribe(listener)` | `() => void` | 订阅滚动变化 |
+
+**关键 Props**:
+- `stickyScroll` — 自动跟踪最新内容(适合日志/聊天)
+- 继承所有 `Styles` 属性(需设置 `flexGrow={1}` 或固定 `height`)
+
+---
+
+### Button
+
+可交互按钮,支持键盘(Enter/Space)和鼠标点击。
+
+```tsx
+import { Button, Text } from '@anthropic/ink'
+
+function ConfirmButton({ onConfirm }: { onConfirm: () => void }) {
+ return (
+
+ )
+}
+```
+
+**Props**:
+
+| 属性 | 类型 | 说明 |
+|------|------|------|
+| `onAction` | `() => void` | 按下 Enter/Space/点击时触发 |
+| `tabIndex` | `number` | Tab 序号 |
+| `autoFocus` | `boolean` | 自动聚焦 |
+| `children` | `(state: ButtonState) => ReactNode \| ReactNode` | render prop 或静态内容 |
+
+**ButtonState**: `{ focused: boolean, hovered: boolean, active: boolean }`
+
+---
+
+### 其他基础组件
+
+#### `Newline`
+
+```tsx
+ // 插入一个空行
+ // 插入三个空行
+```
+
+#### `Spacer`
+
+```tsx
+
+ 左
+ // 占据剩余空间
+ 右
+
+```
+
+#### `Link` — OSC 8 超链接
+
+```tsx
+点击打开
+```
+
+#### `NoSelect` — 禁止文本选择
+
+```tsx
+
+ 这段文字无法被鼠标选中复制
+
+```
+
+#### `RawAnsi` — 预渲染 ANSI 内容
+
+```tsx
+
+```
+
+#### `AlternateScreen` — 全屏 TUI 模式
+
+```tsx
+
+ {/* 启用鼠标追踪、文本选择等高级功能 */}
+
+
+```
+
+---
+
+### 设计系统组件
+
+这些是 theme 层提供的高级 UI 组件。
+
+#### `Dialog` — 对话框
+
+```tsx
+import { Dialog, Box, Text } from '@anthropic/ink'
+
+
+```
+
+**Props**: `title`, `subtitle?`, `color?` (Theme key), `onCancel`, `hideInputGuide?`, `hideBorder?`
+
+#### `Tabs` / `Tab` — 标签页
+
+```tsx
+import { Tabs, Tab, Box, Text } from '@anthropic/ink'
+
+
+
+ 通用设置内容
+
+
+ 高级设置内容
+
+
+```
+
+**Tabs Props**: `title?`, `color?`, `defaultTab?`, `selectedTab?`, `onTabChange?`, `banner?`, `contentHeight?`, `navFromContent?`
+
+#### `Pane` — 带边框的容器
+
+```tsx
+
+ 内容
+
+```
+
+#### `Divider` — 分隔线
+
+```tsx
+
+
+```
+
+#### `ProgressBar` — 进度条
+
+```tsx
+
+```
+
+#### `StatusIcon` — 状态图标
+
+```tsx
+ // ✓
+ // ✗
+
+
+```
+
+#### `FuzzyPicker` — 模糊搜索选择器
+
+```tsx
+ handleSelect(item)}
+ onCancel={() => {}}
+/>
+```
+
+#### `SearchBox` — 搜索框
+
+```tsx
+
+```
+
+#### `ListItem` — 列表项
+
+```tsx
+
+ {item.label}
+
+```
+
+#### `Spinner` — 加载动画
+
+```tsx
+
+```
+
+#### `LoadingState` — 加载状态
+
+```tsx
+
+```
+
+#### `Byline` — 提示信息行
+
+```tsx
+
+ 按 Tab 切换
+ 按 Enter 确认
+
+```
+
+#### `KeyboardShortcutHint` / `ConfigurableShortcutHint` — 快捷键提示
+
+```tsx
+
+```
+
+---
+
+## 5. 布局系统 (Styles)
+
+所有布局属性都通过 `Box` 的 props 传入,底层使用 Yoga 引擎计算 flexbox 布局。
+
+### Flexbox
+
+```tsx
+
+```
+
+### 尺寸
+
+```tsx
+
+```
+
+### 间距
+
+```tsx
+
+```
+
+**重要**: spacing 值必须是整数,否则会触发警告。
+
+### 定位
+
+```tsx
+
+ 绝对定位在右上角
+
+
+
+ 相对偏移
+
+```
+
+### 边框
+
+```tsx
+
+```
+
+**borderStyle 可选值**: `round`, `single`, `double`, `bold`, `dashed`, 以及 `cli-boxes` 支持的所有样式。
+
+### 溢出与滚动
+
+```tsx
+
+```
+
+### 其他
+
+```tsx
+
+```
+
+---
+
+## 6. Hooks
+
+### `useApp()` — 应用控制
+
+```tsx
+const { exit } = useApp()
+// 调用 exit() 卸载应用
+```
+
+### `useInput(handler, options?)` — 键盘输入
+
+```tsx
+useInput((input, key, event) => {
+ if (input === 'q') exit()
+ if (key.ctrl && input === 'c') handleInterrupt()
+ if (key.upArrow) moveUp()
+ if (key.return) submit()
+}, { isActive: isFocused }) // isActive 控制是否启用
+```
+
+**Key 对象**:
+
+```ts
+type Key = {
+ upArrow: boolean
+ downArrow: boolean
+ leftArrow: boolean
+ rightArrow: boolean
+ return: boolean
+ escape: boolean
+ ctrl: boolean
+ shift: boolean
+ meta: boolean // Alt/Option
+ super_: boolean // Cmd/Win (仅 kitty 协议)
+ tab: boolean
+ backspace: boolean
+ delete: boolean
+ pageDown: boolean
+ pageUp: boolean
+ home: boolean
+ end: boolean
+}
+```
+
+**重要**: `useInput` 注册顺序影响事件传播。先注册的 handler 可以调用 `event.stopImmediatePropagation()` 阻止后续 handler。
+
+### `useStdin()` — 访问输入流
+
+```tsx
+const { stdin, setRawMode, isRawModeSupported, internal_eventEmitter } = useStdin()
+```
+
+### `useTerminalSize()` — 终端尺寸
+
+```tsx
+const { columns, rows } = useTerminalSize()
+```
+
+### `useTheme()` — 主题
+
+```tsx
+const [themeName, setTheme] = useTheme()
+// themeName: 'dark' | 'light' | ...
+// setTheme('light') 或 setTheme('auto')
+```
+
+### `useThemeSetting()` — 原始主题设置
+
+```tsx
+const setting = useThemeSetting()
+// 可能返回 'auto'(而 useTheme 返回解析后的实际值)
+```
+
+### `useKeybinding(action, handler, options?)` — 单个快捷键
+
+```tsx
+useKeybinding('chat:submit', () => {
+ handleSubmit()
+}, { context: 'Chat', isActive: !isDisabled })
+```
+
+### `useKeybindings(handlers, options?)` — 多个快捷键
+
+```tsx
+useKeybindings({
+ 'chat:submit': () => handleSubmit(),
+ 'chat:cancel': () => handleCancel(),
+ 'chat:undo': () => handleUndo(),
+}, { context: 'Chat' })
+```
+
+### `useRegisterKeybindingContext(name)` — 注册快捷键上下文
+
+```tsx
+// 组件挂载时激活此上下文,使对应上下文的快捷键优先于 Global
+useRegisterKeybindingContext('ThemePicker')
+```
+
+### `useTerminalFocus()` — 终端窗口焦点
+
+```tsx
+const isFocused = useTerminalFocus()
+// 需要 DECSET 1004 支持
+```
+
+### `useTerminalTitle(title)` — 设置终端标题
+
+```tsx
+useTerminalTitle('Claude Code')
+```
+
+### `useSearchHighlight()` — 搜索高亮
+
+```tsx
+const { setQuery, scanElement, setPositions } = useSearchHighlight()
+```
+
+### `useSelection()` / `useHasSelection()` — 文本选择
+
+```tsx
+const { hasSelection, selectedText, copy, clear } = useSelection()
+// 仅 AlternateScreen 内有效
+```
+
+### `useTerminalViewport()` — 视口可见性
+
+```tsx
+const [ref, entry] = useTerminalViewport()
+// entry.isVisible: 元素是否在视口内
+```
+
+### `useDeclaredCursor()` — IME 光标定位
+
+```tsx
+const declaredRef = useDeclaredCursor()
+// 用于 CJK 输入法的原生光标定位
+```
+
+### `useTerminalNotification()` — 终端通知
+
+```tsx
+const notify = useTerminalNotification()
+notify({ title: '完成', body: '任务已完成' })
+```
+
+### `useExitOnCtrlCD()` — 双击退出
+
+```tsx
+const exitState = useExitOnCtrlCD()
+// exitState.pending: boolean — 第一次按下后等待确认
+// exitState.keyName: 'Ctrl+C' | 'Ctrl+D'
+```
+
+### `useAnimationFrame(callback)` — 动画帧
+
+```tsx
+useAnimationFrame((deltaTime) => {
+ // 每帧调用
+ setFrame(f => f + deltaTime)
+})
+```
+
+### `useInterval(callback, delay)` — 定时器
+
+```tsx
+useInterval(() => {
+ setCount(c => c + 1)
+}, 1000)
+```
+
+### `useTimeout(callback, delay)` — 延迟执行
+
+```tsx
+useTimeout(() => {
+ setLoading(false)
+}, 3000)
+```
+
+### `useMinDisplayTime(ms, onComplete)` — 最小显示时间
+
+```tsx
+useMinDisplayTime(1000, () => {
+ // 确保内容至少显示 1 秒
+ goToNextStep()
+})
+```
+
+### `useDoublePress()` — 双击检测
+
+```tsx
+const doublePress = useDoublePress()
+doublePress('q', () => {
+ // 快速按两次 q 触发
+ exit()
+})
+```
+
+### `useTabStatus(kind, title?)` — iTerm2 标签状态
+
+```tsx
+useTabStatus({ kind: 'success', title: 'Build Done' })
+// kind: 'success' | 'error' | 'running'
+```
+
+---
+
+## 7. 快捷键系统
+
+### 架构
+
+```
+用户按键 → useInput → EventEmitter → ChordInterceptor (全局拦截)
+ ↓
+ KeybindingResolver (上下文匹配)
+ ↓
+ Handler 注册表 → 组件 Handler
+```
+
+### 定义格式
+
+快捷键配置在 `~/.claude/keybindings.json`:
+
+```json
+{
+ "bindings": [
+ {
+ "context": "Global",
+ "bindings": {
+ "ctrl+t": "app:toggleTodos",
+ "ctrl+o": "app:toggleTranscript"
+ }
+ },
+ {
+ "context": "Chat",
+ "bindings": {
+ "enter": "chat:submit",
+ "escape": "chat:cancel",
+ "ctrl+x ctrl+k": "chat:killAgents"
+ }
+ }
+ ]
+}
+```
+
+### 按键语法
+
+**单键**:
+- `ctrl+k`, `shift+tab`, `alt+v`, `cmd+c`
+- `escape`, `enter`, `return`, `space`, `tab`, `backspace`, `delete`
+- `up`, `down`, `left`, `right`(也支持 `↑` `↓` `←` `→`)
+- `pageup`, `pagedown`, `home`, `end`
+
+**修饰符别名**: `ctrl`/`control`, `alt`/`opt`/`option`, `cmd`/`command`/`super`/`win`
+
+**Chord(组合序列)**: `ctrl+x ctrl+k` — 先按 `ctrl+x`,再按 `ctrl+k`
+
+### 上下文系统
+
+| 上下文 | 说明 |
+|--------|------|
+| `Global` | 全局生效 |
+| `Chat` | 聊天输入聚焦时 |
+| `Autocomplete` | 自动补全菜单显示时 |
+| `Confirmation` | 确认对话框 |
+| `Settings` | 设置菜单 |
+| `Transcript` | 查看转录 |
+| `ThemePicker` | 主题选择器 |
+| `Select` | 选择组件 |
+
+**优先级**: 注册的活动上下文 > 组件上下文 > `Global`
+
+### Action 类型
+
+- **内置 Action**: `app:toggleTodos`, `chat:submit` 等
+- **命令 Action**: `command:help`, `command:commit` — 执行 slash 命令
+- **解绑**: 设为 `null` — 取消默认绑定
+
+### 注册 Handler
+
+```tsx
+function ChatInput() {
+ // 注册上下文(覆盖 Global 中相同的按键)
+ useRegisterKeybindingContext('Chat')
+
+ // 注册多个 handler
+ useKeybindings({
+ 'chat:submit': () => handleSubmit(),
+ 'chat:cancel': () => handleCancel(),
+ }, { context: 'Chat' })
+}
+```
+
+### 显示快捷键
+
+```tsx
+import { useShortcutDisplay } from '@/keybindings/useShortcutDisplay'
+
+const shortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o')
+// 返回用户自定义的按键或 fallback
+```
+
+---
+
+## 8. 主题系统
+
+### 可用主题
+
+| 名称 | 说明 |
+|------|------|
+| `dark` | 默认暗色 |
+| `light` | 亮色 |
+| `dark-daltonized` | 色盲友好暗色 |
+| `light-daltonized` | 色盲友好亮色 |
+| `dark-ansi` | 16色 ANSI 暗色 |
+| `light-ansi` | 16色 ANSI 亮色 |
+| `auto` | 跟随终端主题 |
+
+### 常用 Theme 色值 key
+
+| Key | 用途 |
+|-----|------|
+| `text` | 默认文字色 |
+| `background` | 背景色 |
+| `claude` | Claude 品牌色 |
+| `claudeShimmer` | Claude 动画色 |
+| `permission` | 权限相关 |
+| `suggestion` | 建议/提示 |
+| `success` | 成功 |
+| `error` | 错误 |
+| `warning` | 警告 |
+| `inactive` | 非活跃/暗淡 |
+| `bashBorder` | Bash 边框 |
+| `diffAdded` / `diffRemoved` | Diff 增/删 |
+
+### 使用主题色
+
+```tsx
+// 组件上直接使用主题 key
+Claude
+
+
+// 混合使用:主题 key + 原始色值
+主题色
+原始色
+
+// color() 函数用于非组件场景
+import { color } from '@anthropic/ink'
+const [theme] = useTheme()
+const text = color('error', theme)('出错了')
+```
+
+### ThemeProvider
+
+```tsx
+
+
+
+```
+
+**Hooks**:
+- `useTheme()` — `[ThemeName, (setting: ThemeSetting) => void]`
+- `useThemeSetting()` — 原始设置(含 `'auto'`)
+- `usePreviewTheme()` — `{ setPreviewTheme, savePreview, cancelPreview }`
+
+---
+
+## 9. 事件系统
+
+### 事件类型
+
+| 事件类 | 说明 |
+|--------|------|
+| `InputEvent` | 键盘输入 |
+| `ClickEvent` | 鼠标点击 |
+| `KeyboardEvent` | 键盘事件(DOM 风格) |
+| `FocusEvent` | 焦点变化 |
+| `TerminalFocusEvent` | 终端窗口焦点 |
+| `PasteEvent` | 粘贴 |
+
+### 事件传播
+
+事件支持冒泡和 `stopImmediatePropagation()`:
+
+```tsx
+// Box 事件冒泡
+ {
+ // 从最深层 Box 冒泡上来
+ e.stopImmediatePropagation() // 阻止继续冒泡
+}}>
+```
+
+### EventEmitter
+
+```tsx
+const emitter = new EventEmitter()
+emitter.on('input', handler)
+emitter.emit('input', event)
+```
+
+**注册顺序**很重要:先注册的 handler 先收到事件,可以调用 `stopImmediatePropagation()` 阻止后续 handler。
+
+---
+
+## 10. 工具函数
+
+### `stringWidth(text)` — 计算文本显示宽度
+
+```tsx
+import { stringWidth } from '@anthropic/ink'
+
+stringWidth('你好') // 4(中文字符占 2 列)
+stringWidth('abc') // 3
+stringWidth('\x1b[31mred\x1b[0m') // 3(忽略 ANSI 转义)
+```
+
+### `measureElement(domElement)` — 测量元素尺寸
+
+```tsx
+import { measureElement } from '@anthropic/ink'
+
+const { width, height } = measureElement(ref.current)
+```
+
+### `color(themeKey, theme)` — 创建主题色着色函数
+
+```tsx
+import { color } from '@anthropic/ink'
+const [theme] = useTheme()
+
+const red = color('error', theme)('错误文本')
+const green = color('success', theme)(figures.tick)
+```
+
+### `colorize(text, color, backgroundColor?)` — 原始色着色
+
+```tsx
+import { colorize } from '@anthropic/ink'
+
+colorize('Hello', 'rgb(255,0,0)')
+colorize('Hello', '#ff0000')
+colorize('Hello', 'ansi256(196)')
+```
+
+### `setClipboard(text)` — 复制到剪贴板
+
+```tsx
+import { setClipboard } from '@anthropic/ink'
+setClipboard('复制的文本') // 通过 OSC 52
+```
+
+### `Ansi` 组件 — 渲染 ANSI 字符串
+
+```tsx
+import { Ansi } from '@anthropic/ink'
+
+{ansiColoredText}
+```
+
+### `wrapText(text, width, options?)` — 文本换行
+
+```tsx
+import wrapText from '@anthropic/ink'
+const wrapped = wrapText('很长的文本...', 80)
+```
+
+---
+
+## 11. 与官方 Ink 的关键差异
+
+| 特性 | 官方 Ink | @anthropic/ink |
+|------|----------|----------------|
+| React 版本 | 18 | **19** |
+| 渲染方式 | 单缓冲 | **双缓冲 + diff 优化** |
+| 布局引擎 | Yoga 基础 | **Yoga + 定位 + overflow + 虚拟滚动** |
+| 全屏模式 | 无 | **AlternateScreen + 鼠标追踪** |
+| 文本选择 | 无 | **鼠标选择 + OSC 52 剪贴板** |
+| 主题系统 | 无 | **多主题 + 自动检测 + 色盲友好** |
+| 快捷键 | `useInput` only | **Chord + 上下文 + 热重载** |
+| 设计系统 | 无 | **Dialog/Tabs/Pane/ProgressBar 等** |
+| 事件系统 | 简单 | **DOM 风格冒泡 + capture 阶段** |
+| CJK 输入 | 无支持 | **IME 光标定位** |
+| 性能优化 | 无 | **Blit 优化 + damage tracking + pool** |
+| 组件层级 | 单层 | **双层 (Base + Themed)** |
+| 边框系统 | 基础 | **per-side 控制 + dimColor + borderText** |
+| 文本换行 | `wrap` | **wrap/wrap-trim/end/middle/truncate-\*** |
+
+---
+
+## 12. 最佳实践
+
+### 1. 始终使用 Themed 组件
+
+```tsx
+// ✅ 推荐 — 使用主题色
+import { Box, Text } from '@anthropic/ink'
+主题色
+
+// ❌ 避免 — 只有在明确不需要主题时使用
+import { BaseBox, BaseText } from '@anthropic/ink'
+原始色
+```
+
+### 2. 用 `useKeybindings` 代替直接 `useInput`
+
+```tsx
+// ✅ 推荐 — 声明式,支持上下文切换
+useKeybindings({
+ 'chat:submit': handleSubmit,
+ 'chat:cancel': handleCancel,
+}, { context: 'Chat' })
+
+// ❌ 避免 — 手动解析按键
+useInput((input, key) => {
+ if (key.return) handleSubmit()
+ if (key.escape) handleCancel()
+})
+```
+
+### 3. spacing 值必须是整数
+
+```tsx
+// ✅
+
+
+// ❌ 触发运行时警告
+
+```
+
+### 4. `bold` 和 `dim` 互斥
+
+```tsx
+// ✅ 二选一
+加粗
+暗淡
+
+// ❌ TypeScript 编译错误
+...
+```
+
+### 5. 需要暗淡但不互斥时用 `dimColor`
+
+```tsx
+// ✅ dimColor 与 bold 兼容(使用 inactive 色,不是 ANSI dim)
+加粗但暗淡
+```
+
+### 6. 鼠标事件只在 AlternateScreen 内生效
+
+```tsx
+// onClick、onMouseEnter、onMouseLeave 需要在 AlternateScreen 内
+
+ 可点击
+
+```
+
+### 7. ScrollBox 需要明确高度
+
+```tsx
+// ✅ 设置 flexGrow 或固定 height
+
+ {content}
+
+
+// ❌ 没有高度约束,ScrollBox 不知道视口大小
+
+ {content}
+
+```
+
+### 8. 渲染入口模式
+
+```tsx
+// 推荐:wrappedRender(异步,用于独立界面)
+import { wrappedRender as render } from '@anthropic/ink'
+const { unmount } = await render()
+
+// 复用根:createRoot
+import { createRoot } from '@anthropic/ink'
+const root = createRoot()
+root.render()
+// 后续切换
+root.render()
+```
diff --git a/packages/pokemon/src/__tests__/battle-helper.ts b/packages/pokemon/src/__tests__/battle-helper.ts
new file mode 100644
index 000000000..5a7262e07
--- /dev/null
+++ b/packages/pokemon/src/__tests__/battle-helper.ts
@@ -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>
+ iv?: Partial>
+}
+
+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): BattleScenario
+ /** Set opponent (wild Pokémon) */
+ opponent(species: SpeciesId, level: number): BattleScenario
+ /** Create the battle and return runner */
+ start(): Promise
+}
+
+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
+ /** Keep using move 0 until battle ends or max turns reached */
+ runUntilEnd(maxTurns?: number): Promise
+ /** Execute forced switch after faint */
+ doSwitch(partyIndex: number): Promise
+ /** 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): 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+): 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) {
+ test(name, fn)
+}
diff --git a/packages/pokemon/src/__tests__/battle-scenarios.test.ts b/packages/pokemon/src/__tests__/battle-scenarios.test.ts
new file mode 100644
index 000000000..14dc9f9da
--- /dev/null
+++ b/packages/pokemon/src/__tests__/battle-scenarios.test.ts
@@ -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')
+ })
+})
diff --git a/packages/pokemon/src/__tests__/battle.test.ts b/packages/pokemon/src/__tests__/battle.test.ts
index ab9744187..1c1237417 100644
--- a/packages/pokemon/src/__tests__/battle.test.ts
+++ b/packages/pokemon/src/__tests__/battle.test.ts
@@ -52,46 +52,46 @@ function makeTestBuddyData(creatures: Creature[] = [makeTestCreature()]): BuddyD
}
describe('createBattle', () => {
- test('creates battle with valid initial state', () => {
+ test('creates battle with valid initial state', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
+ const init = await createBattle([creature], 'squirtle', 50)
expect(init.state).toBeDefined()
expect(init.state.playerPokemon).toBeDefined()
expect(init.state.opponentPokemon).toBeDefined()
expect(init.state.finished).toBe(false)
})
- test('player pokemon has correct species', () => {
+ test('player pokemon has correct species', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'bulbasaur', 30)
+ const init = await createBattle([creature], 'bulbasaur', 30)
expect(init.state.playerPokemon.speciesId).toBe('charmander')
expect(init.state.opponentPokemon.speciesId).toBe('bulbasaur')
})
- test('player pokemon has moves', () => {
+ test('player pokemon has moves', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
+ const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.playerPokemon.moves.length).toBeGreaterThan(0)
})
})
describe('executeTurn', () => {
- test('move action generates events', () => {
+ test('move action generates events', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
+ const init = await createBattle([creature], 'squirtle', 50)
const initialEventCount = init.state.events.length
- const newState = executeTurn(init, { type: 'move', moveIndex: 0 })
+ const newState = await executeTurn(init, { type: 'move', moveIndex: 0 })
expect(newState.events.length).toBeGreaterThanOrEqual(initialEventCount)
})
- test('battle eventually ends within 50 turns', () => {
+ test('battle eventually ends within 50 turns', async () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 0, speed: 252 } })
- const init = createBattle([creature], 'squirtle', 5)
+ const init = await createBattle([creature], 'squirtle', 5)
let state = init.state
for (let i = 0; i < 50 && !state.finished; i++) {
- state = executeTurn(init, { type: 'move', moveIndex: 0 })
+ state = await executeTurn(init, { type: 'move', moveIndex: 0 })
}
expect(state.finished).toBe(true)
@@ -221,9 +221,9 @@ describe('applyEvolution', () => {
})
describe('chooseAIMove', () => {
- test('returns a valid move index', () => {
+ test('returns a valid move index', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
+ const init = await createBattle([creature], 'squirtle', 50)
const aiPokemon = init.state.opponentPokemon
const idx = chooseAIMove(aiPokemon)
expect(idx).toBeGreaterThanOrEqual(0)
@@ -304,50 +304,50 @@ describe('settleBattle - advanced', () => {
})
describe('createBattle - extended', () => {
- test('battle state has turn initialized', () => {
+ test('battle state has turn initialized', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
+ const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.turn).toBeGreaterThanOrEqual(1)
})
- test('player pokemon has correct level', () => {
+ test('player pokemon has correct level', async () => {
const creature = makeTestCreature({ level: 25 })
- const init = createBattle([creature], 'bulbasaur', 10)
+ const init = await createBattle([creature], 'bulbasaur', 10)
expect(init.state.playerPokemon.level).toBe(25)
})
- test('opponent pokemon has correct level', () => {
+ test('opponent pokemon has correct level', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 15)
+ const init = await createBattle([creature], 'squirtle', 15)
expect(init.state.opponentPokemon.level).toBe(15)
})
- test('battle state has player party', () => {
+ test('battle state has player party', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
+ const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.playerParty.length).toBeGreaterThan(0)
})
- test('battle state has usable items (empty bag)', () => {
+ test('battle state has usable items (empty bag)', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
+ const init = await createBattle([creature], 'squirtle', 50)
expect(init.state.usableItems).toEqual([])
})
})
describe('executeTurn - extended', () => {
- test('item action defaults to move 1', () => {
+ test('item action defaults to move 1', async () => {
const creature = makeTestCreature()
- const init = createBattle([creature], 'squirtle', 50)
- const state = executeTurn(init, { type: 'item', itemId: 'potion' })
+ const init = await createBattle([creature], 'squirtle', 50)
+ const state = await executeTurn(init, { type: 'item', itemId: 'potion' })
expect(state).toBeDefined()
expect(state.events.length).toBeGreaterThan(0)
})
- test('battle produces damage or heal events', () => {
+ test('battle produces damage or heal events', async () => {
const creature = makeTestCreature({ level: 100, ev: { hp: 252, attack: 252, defense: 0, spAtk: 0, spDef: 4, speed: 252 } })
- const init = createBattle([creature], 'squirtle', 5)
- const state = executeTurn(init, { type: 'move', moveIndex: 0 })
+ const init = await createBattle([creature], 'squirtle', 5)
+ const state = await executeTurn(init, { type: 'move', moveIndex: 0 })
const hasDamageOrHeal = state.events.some(e => e.type === 'damage' || e.type === 'heal')
expect(hasDamageOrHeal).toBe(true)
})
diff --git a/packages/pokemon/src/battle/engine.ts b/packages/pokemon/src/battle/engine.ts
index 2db5cd8c7..bc20d45a5 100644
--- a/packages/pokemon/src/battle/engine.ts
+++ b/packages/pokemon/src/battle/engine.ts
@@ -1,11 +1,25 @@
-import { Battle, Teams, toID } from '@pkmn/sim'
-import { Dex } from '@pkmn/sim'
+import { BattleStreams, Teams, Dex, toID } from '@pkmn/sim'
+import { Protocol } from '@pkmn/protocol'
import type { Creature, SpeciesId } from '../types'
import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
import { STAT_NAMES } from '../types'
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types'
import { chooseAIMove } from './ai'
+// ─── Types ───
+
+export type BattleInit = {
+ streams: {
+ omniscient: { write(data: string): void; read(): Promise }
+ spectator: { read(): Promise }
+ p1: { write(data: string): void; read(): Promise }
+ p2: { write(data: string): void; read(): Promise }
+ }
+ /** Underlying stream — access .battle for Battle object */
+ stream: BattleStreams.BattleStream
+ state: BattleState
+}
+
// ─── Adapter: Creature → Showdown Set ───
function creatureToSetString(creature: Creature): string {
@@ -43,18 +57,13 @@ function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
const species = Dex.species.get(speciesId)
if (!species) throw new Error(`Species ${speciesId} not found`)
const ability = species.abilities['0'] ?? ''
- // Get first 4 level-up moves (from species data)
const moves = getSpeciesMoves(speciesId, level)
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
}
function getSpeciesMoves(speciesId: string, _level: number): string[] {
- // In @pkmn/sim, Dex.species doesn't expose learnsets directly.
- // Use common moves that exist in the sim's data for basic battles.
- // The actual move pool is resolved by the Battle engine during construction.
const species = Dex.species.get(speciesId)
if (!species) return ['Tackle']
- // Use type-appropriate basic moves as fallback
const type = species.types[0]?.toLowerCase() ?? 'normal'
const basicMoves: Record = {
normal: ['Tackle', 'Scratch'],
@@ -79,7 +88,7 @@ function getSpeciesMoves(speciesId: string, _level: number): string[] {
return basicMoves[type] ?? ['Tackle', 'Scratch']
}
-// ─── State Projection ───
+// ─── State Projection (from Battle object) ───
function projectPokemon(pkm: any): BattlePokemon {
if (!pkm) throw new Error('No active pokemon')
@@ -88,7 +97,7 @@ function projectPokemon(pkm: any): BattlePokemon {
const maxHp = pkm.maxhp ?? 1
return {
- id: pkm.name, // sim doesn't store our UUID, use name as temp id
+ id: pkm.name,
speciesId: toID(species.name) as SpeciesId,
name: species.name,
level: pkm.level,
@@ -136,184 +145,9 @@ function projectBoosts(boosts: Record | undefined): Record
- s?.startsWith('p1a') ? 'player' : 'opponent'
-
- for (const line of log) {
- const parts = line.split('|')
- const side = parseSide(parts[2])
-
- if (line.startsWith('|move|')) {
- events.push({ type: 'move', side, move: parts[3], user: parts[2] })
- } else if (line.startsWith('|-damage|')) {
- const [cur, max] = parseHpString(parts[3])
- events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) })
- } else if (line.startsWith('|-heal|')) {
- const [cur, max] = parseHpString(parts[3])
- events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) })
- } else if (line.startsWith('|faint|')) {
- events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
- } else if (line.startsWith('|switch|')) {
- const speciesPart = parts[3]?.split(',')[0]?.split(': ')
- events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' })
- } else if (line.startsWith('|-supereffective|')) {
- events.push({ type: 'effectiveness', multiplier: 2 })
- } else if (line.startsWith('|-resisted|')) {
- events.push({ type: 'effectiveness', multiplier: 0.5 })
- } else if (line.startsWith('|-crit|')) {
- events.push({ type: 'crit' })
- } else if (line.startsWith('|-miss|')) {
- events.push({ type: 'miss', side })
- } else if (line.startsWith('|-status|')) {
- events.push({ type: 'status', side, status: mapStatus(parts[3]) })
- } else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
- const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4])
- events.push({ type: 'statChange', side, stat: parts[3], stages })
- } else if (line.startsWith('|-ability|')) {
- events.push({ type: 'ability', side, ability: parts[3] })
- } else if (line.startsWith('|turn|')) {
- events.push({ type: 'turn', number: parseInt(parts[2]) })
- }
- }
- return events
-}
-
-function parseHpString(hpStr: string): [number, number] {
- if (!hpStr) return [0, 1]
- // Remove status suffix like "[1]"
- const clean = hpStr.replace(/\[.*\]/, '')
- const parts = clean.split('/')
- if (parts.length !== 2) return [0, 1]
- return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1]
-}
-
-// ─── Engine ───
-
-export type BattleInit = {
- battle: any // @pkmn/sim Battle instance
- state: BattleState
-}
-
-export function createBattle(
- partyCreatures: Creature[],
- opponentSpeciesId: SpeciesId,
- opponentLevel: number,
- _bagItems?: { id: string; count: number }[],
-): BattleInit {
- const p1Sets = partyCreatures.map(c => creatureToSetString(c))
- const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
-
- const p1Team = Teams.import(p1Sets.join('\n\n'))
- const p2Team = Teams.import(p2Set)
-
- // Create battle
- const battle = new Battle({
- formatid: 'gen9customgame' as any,
- p1: { name: 'Player', team: p1Team },
- p2: { name: 'Opponent', team: p2Team },
- })
-
- // Handle team preview → auto-select leads
- battle.makeChoices('team 1', 'team 1')
-
- // Project initial state
- const state = projectState(battle, _bagItems)
- return { battle, state }
-}
-
-export function executeTurn(
- battleInit: BattleInit,
- action: PlayerAction,
-): BattleState {
- const { battle } = battleInit
- const prevLogLen = battle.log.length
-
- // Build player choice string
- let p1Choice: string
- switch (action.type) {
- case 'move':
- p1Choice = `move ${action.moveIndex + 1}`
- break
- case 'switch': {
- const p1Pokemon: any[] = battle.p1.pokemon
- const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId)
- p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1'
- break
- }
- case 'item':
- p1Choice = 'move 1' // Items handled via settlement
- break
- default:
- p1Choice = 'move 1'
- }
-
- // AI choice — pick a legal move for the active opponent Pokémon
- let p2Choice: string
- const p2Active = battle.p2.active[0]
- if (p2Active?.fainted) {
- // AI needs to switch to next non-fainted Pokémon
- const p2Pokemon: any[] = battle.p2.pokemon
- const nextAlive = p2Pokemon.findIndex((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
- p2Choice = nextAlive >= 0 ? `switch ${nextAlive + 1}` : 'pass'
- } else {
- const aiPokemon = projectPokemon(battle.p2.active[0])
- const aiMoveIndex = chooseAIMove(aiPokemon)
- p2Choice = `move ${aiMoveIndex + 1}`
- }
-
- // Handle player forced switch (fainted active Pokémon)
- const p1Active = battle.p1.active[0]
- if (p1Active?.fainted || p1Active?.hp === 0) {
- const p1Pokemon: any[] = battle.p1.pokemon
- const nextAlive = p1Pokemon.findIndex((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
- if (nextAlive >= 0) {
- p1Choice = `switch ${nextAlive + 1}`
- } else {
- p1Choice = 'pass'
- }
- }
-
- // Execute — use try/catch for safety
- try {
- battle.makeChoices(p1Choice, p2Choice)
- } catch {
- // If choices fail (e.g. mid-turn faint), try pass
- try { battle.makeChoices('pass', 'pass') } catch { /* battle likely ended */ }
- }
-
- // Parse new log entries
- const newLog = battle.log.slice(prevLogLen)
- const newEvents = parseLogToEvents(newLog)
-
- // Project new state
- const state = projectState(battle, battleInit.state.usableItems)
- state.events = [...battleInit.state.events, ...newEvents]
-
- // Check for battle end
- if (battle.ended) {
- state.finished = true
- const winner = battle.winner === 'Player' ? 'player' : 'opponent'
- state.result = {
- winner,
- turns: state.turn,
- xpGained: 0, // calculated in settlement
- evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
- participantIds: [],
- }
- }
-
- battleInit.state = state
- return state
-}
-
function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState {
const p1 = battle.p1
const p2 = battle.p2
-
return {
playerPokemon: projectPokemon(p1.active[0]),
opponentPokemon: projectPokemon(p2.active[0]),
@@ -325,3 +159,298 @@ function projectState(battle: any, bagItems?: { id: string; count: number }[]):
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
}
}
+
+// ─── Protocol Event Parsing (from spectator chunks) ───
+
+function parseChunkToEvents(chunk: string, prevHp?: { player: { hp: number; maxHp: number }; opponent: { hp: number; maxHp: number } }): BattleEvent[] {
+ const events: BattleEvent[] = []
+ // Track HP through the chunk to compute damage/heal amounts
+ const hp = prevHp ? { player: { ...prevHp.player }, opponent: { ...prevHp.opponent } } : { player: { hp: 0, maxHp: 1 }, opponent: { hp: 0, maxHp: 1 } }
+
+ for (const line of chunk.split('\n')) {
+ if (!line.startsWith('|')) continue
+ // Skip non-battle lines
+ if (line.startsWith('|t:|') || line === '|' || line.startsWith('|gametype|') || line.startsWith('|player|') ||
+ line.startsWith('|gen|') || line.startsWith('|tier|') || line.startsWith('|clearpoke|') ||
+ line.startsWith('|poke|') || line.startsWith('|teampreview|') || line.startsWith('|teamsize|') ||
+ line.startsWith('|start|') || line.startsWith('|done|') || line.startsWith('|upkeep|')) continue
+
+ const parts = line.split('|')
+ const cmd = parts[1]
+ if (!cmd) continue
+ const side = parts[2]?.startsWith('p1a') ? 'player' as const : 'opponent' as const
+
+ switch (cmd) {
+ case 'move':
+ events.push({ type: 'move', side, move: parts[3] ?? '', user: parts[2] ?? '' })
+ break
+ case '-damage': {
+ const newHp = parseHpValue(parts[3])
+ const prev = hp[side].hp
+ const maxHp = hp[side].maxHp || 1
+ if (newHp !== null) {
+ const amount = Math.max(0, prev - newHp)
+ const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
+ hp[side].hp = newHp
+ hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
+ events.push({ type: 'damage', side, amount, percentage })
+ } else {
+ events.push({ type: 'damage', side, amount: 0, percentage: 0 })
+ }
+ break
+ }
+ case '-heal': {
+ const newHp = parseHpValue(parts[3])
+ const prev = hp[side].hp
+ const maxHp = hp[side].maxHp || 1
+ if (newHp !== null) {
+ const amount = Math.max(0, newHp - prev)
+ const percentage = maxHp > 0 ? Math.round((amount / maxHp) * 100) : 0
+ hp[side].hp = newHp
+ hp[side].maxHp = Math.max(hp[side].maxHp, parseMaxHp(parts[3]) ?? maxHp)
+ events.push({ type: 'heal', side, amount, percentage })
+ } else {
+ events.push({ type: 'heal', side, amount: 0, percentage: 0 })
+ }
+ break
+ }
+ case 'faint':
+ events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
+ break
+ case 'switch': {
+ const name = parts[3]?.split(',')[0] ?? ''
+ // Parse HP from switch: "Squirtle, L5, 100/100"
+ const hpStr = parts[3] ?? ''
+ const hpMatch = hpStr.match(/(\d+)\/(\d+)/)
+ if (hpMatch) {
+ hp[side].hp = parseInt(hpMatch[1], 10)
+ hp[side].maxHp = parseInt(hpMatch[2], 10)
+ }
+ events.push({ type: 'switch', side, speciesId: toID(name), name })
+ break
+ }
+ case '-supereffective':
+ events.push({ type: 'effectiveness', multiplier: 2 })
+ break
+ case '-resisted':
+ events.push({ type: 'effectiveness', multiplier: 0.5 })
+ break
+ case '-crit':
+ events.push({ type: 'crit' })
+ break
+ case '-miss':
+ events.push({ type: 'miss', side })
+ break
+ case '-status':
+ events.push({ type: 'status', side, status: mapStatus(parts[3]) })
+ break
+ case '-boost':
+ case '-unboost': {
+ const stages = cmd === '-boost' ? Number(parts[4]) : -Number(parts[4])
+ events.push({ type: 'statChange', side, stat: parts[3] ?? '', stages })
+ break
+ }
+ case '-ability':
+ events.push({ type: 'ability', side, ability: parts[3] ?? '' })
+ break
+ case 'turn':
+ events.push({ type: 'turn', number: Number(parts[2]) })
+ break
+ }
+ }
+ return events
+}
+
+/** Parse current HP from protocol HP string like "80/100" or "80/100brn" */
+function parseHpValue(hpStr?: string): number | null {
+ if (!hpStr) return null
+ const match = hpStr.match(/^(\d+)/)
+ return match ? parseInt(match[1], 10) : null
+}
+
+/** Parse max HP from protocol HP string like "80/100" or "80/100brn" */
+function parseMaxHp(hpStr?: string): number | null {
+ if (!hpStr) return null
+ const match = hpStr.match(/\/(\d+)/)
+ return match ? parseInt(match[1], 10) : null
+}
+
+// ─── Engine API ───
+
+export async function createBattle(
+ partyCreatures: Creature[],
+ opponentSpeciesId: SpeciesId,
+ opponentLevel: number,
+ _bagItems?: { id: string; count: number }[],
+): Promise {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/packages/pokemon/src/battle/index.ts b/packages/pokemon/src/battle/index.ts
index 807204d59..03910b28a 100644
--- a/packages/pokemon/src/battle/index.ts
+++ b/packages/pokemon/src/battle/index.ts
@@ -1,4 +1,4 @@
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types'
-export { createBattle, executeTurn, type BattleInit } from './engine'
+export { createBattle, executeTurn, executeSwitch, type BattleInit } from './engine'
export { settleBattle, applyMoveLearn, applyEvolution } from './settlement'
export { chooseAIMove } from './ai'
diff --git a/packages/pokemon/src/battle/types.ts b/packages/pokemon/src/battle/types.ts
index 83ae08055..8e2c7da74 100644
--- a/packages/pokemon/src/battle/types.ts
+++ b/packages/pokemon/src/battle/types.ts
@@ -28,7 +28,7 @@ export type MoveOption = {
export type PlayerAction =
| { type: 'move'; moveIndex: number }
- | { type: 'switch'; creatureId: string }
+ | { type: 'switch'; partyIndex: number }
| { type: 'item'; itemId: string }
export type BattleEvent =
@@ -65,4 +65,5 @@ export type BattleState = {
finished: boolean
result?: BattleResult
usableItems: { id: string; name: string; count: number }[]
+ needsSwitch?: boolean // player's active Pokémon fainted, must switch
}
diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts
index 5f1593665..b87aee7fb 100644
--- a/packages/pokemon/src/index.ts
+++ b/packages/pokemon/src/index.ts
@@ -38,7 +38,7 @@ export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
// Battle
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
-export { createBattle, executeTurn, type BattleInit } from './battle/engine'
+export { createBattle, executeTurn, executeSwitch, type BattleInit } from './battle/engine'
export { settleBattle, applyMoveLearn, applyEvolution } from './battle/settlement'
export { chooseAIMove } from './battle/ai'
@@ -60,7 +60,7 @@ export {
export { loadSprite, fetchAndCacheSprite, getSpeciesDisplay } from './core/spriteCache'
// Sprites
-export { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
+export { renderAnimatedSprite, shrinkSprite, getIdleAnimMode, getPetOverlay } from './sprites/renderer'
export { getFallbackSprite } from './sprites/fallback'
// UI Components
@@ -71,8 +71,14 @@ export { EvolutionAnim } from './ui/EvolutionAnim'
export { StatBar } from './ui/StatBar'
export { SpeciesDetail } from './ui/SpeciesDetail'
export { SpriteAnimator } from './ui/SpriteAnimator'
+export { BattleSprite } from './ui/BattleSprite'
+export { BattleField } from './ui/BattleField'
export { BattleConfigPanel } from './ui/BattleConfigPanel'
-export { BattleView } from './ui/BattleView'
+export { BattleScene } from './ui/BattleScene'
+export type { MenuPhase } from './ui/BattleScene'
+export { HpCard } from './ui/HpCard'
+export { BattleMenu } from './ui/BattleMenu'
+export { BattleLogPanel } from './ui/BattleLogPanel'
export { SwitchPanel } from './ui/SwitchPanel'
export { ItemPanel } from './ui/ItemPanel'
export { BattleResultPanel } from './ui/BattleResultPanel'
diff --git a/packages/pokemon/src/sprites/renderer.ts b/packages/pokemon/src/sprites/renderer.ts
index 7957f24da..a9e51204f 100644
--- a/packages/pokemon/src/sprites/renderer.ts
+++ b/packages/pokemon/src/sprites/renderer.ts
@@ -13,7 +13,7 @@ import type { AnimMode } from '../types'
//
// After transform, render each row back: reset → style → char → reset
-interface Pixel {
+export interface Pixel {
char: string
/** Full ANSI state needed to render this pixel */
style: string
@@ -21,6 +21,7 @@ interface Pixel {
const EMPTY_PIXEL: Pixel = { char: ' ', style: '' }
const EMPTY_ROW: Pixel[] = []
+export { EMPTY_PIXEL, EMPTY_ROW }
// ─── Parse / Render ───────────────────────────────────
@@ -67,11 +68,11 @@ function renderRow(pixels: Pixel[]): string {
return out
}
-function parseSprite(lines: string[]): Pixel[][] {
+export function parseSprite(lines: string[]): Pixel[][] {
return lines.map(parseLine)
}
-function renderSprite(grid: Pixel[][]): string[] {
+export function renderSprite(grid: Pixel[][]): string[] {
return grid.map(renderRow)
}
@@ -173,6 +174,14 @@ export function getIdleAnimMode(tick: number): AnimMode {
// Public API
// ═══════════════════════════════════════════════════════
+/**
+ * Flip sprite lines horizontally (mirror + swap directional chars).
+ * For player Pokemon facing right towards the opponent.
+ */
+export function flipSpriteLines(lines: string[]): string[] {
+ return renderSprite(reverseH(parseSprite(lines), true))
+}
+
/**
* Apply animation transform to sprite lines.
* Internally: parse ANSI → Pixel grid → transform → render back.
@@ -226,6 +235,114 @@ export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMo
return renderSprite(result)
}
+// ═══════════════════════════════════════════════════════
+// Sprite Shrink (nearest-neighbor / block sampling)
+// ═══════════════════════════════════════════════════════
+
+function pixelWeight(char: string): number {
+ if (char === ' ') return 0
+ if ('█▓'.includes(char)) return 4
+ if ('▒■▀▄'.includes(char)) return 3
+ if ('░▌▐/\\()<>'.includes(char)) return 2
+ return 1
+}
+
+function pickDominantPixel(
+ grid: Pixel[][],
+ x0: number,
+ x1: number,
+ y0: number,
+ y1: number,
+): Pixel {
+ let best: Pixel = EMPTY_PIXEL
+ let bestScore = -1
+ const cx = (x0 + x1 - 1) / 2
+ const cy = (y0 + y1 - 1) / 2
+
+ for (let y = y0; y < y1; y++) {
+ for (let x = x0; x < x1; x++) {
+ const pixel = grid[y]?.[x] ?? EMPTY_PIXEL
+ const weight = pixelWeight(pixel.char)
+ if (weight === 0) continue
+
+ const dist = Math.abs(x - cx) + Math.abs(y - cy)
+ const score = weight * 10 - dist
+ if (score > bestScore) {
+ best = pixel
+ bestScore = score
+ }
+ }
+ }
+
+ return bestScore >= 0 ? best : EMPTY_PIXEL
+}
+
+function resampleGrid(grid: Pixel[][], targetWidth: number, targetHeight: number): Pixel[][] {
+ const srcHeight = grid.length
+ const srcWidth = Math.max(0, ...grid.map(row => row.length))
+
+ return Array.from({ length: targetHeight }, (_, y) => {
+ const y0 = Math.floor((y * srcHeight) / targetHeight)
+ const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / targetHeight))
+
+ return Array.from({ length: targetWidth }, (_, x) => {
+ const x0 = Math.floor((x * srcWidth) / targetWidth)
+ const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * srcWidth) / targetWidth))
+ return pickDominantPixel(grid, x0, x1, y0, y1)
+ })
+ })
+}
+
+function isEmptyRow(row: Pixel[]): boolean {
+ return row.length === 0 || row.every(pixel => pixel.char === ' ')
+}
+
+function trimEmptyMargin(grid: Pixel[][]): Pixel[][] {
+ if (grid.length === 0) return grid
+
+ let top = 0
+ let bottom = grid.length - 1
+ while (top <= bottom && isEmptyRow(grid[top] ?? [])) top++
+ while (bottom >= top && isEmptyRow(grid[bottom] ?? [])) bottom--
+
+ if (top > bottom) return []
+
+ const sliced = grid.slice(top, bottom + 1)
+ const width = Math.max(0, ...sliced.map(row => row.length))
+
+ let left = 0
+ let right = width - 1
+ const isEmptyCol = (x: number) => sliced.every(row => (row[x]?.char ?? ' ') === ' ')
+
+ while (left <= right && isEmptyCol(left)) left++
+ while (right >= left && isEmptyCol(right)) right--
+
+ return sliced.map(row => row.slice(left, right + 1))
+}
+
+export function shrinkSprite(
+ lines: string[],
+ opts: { scale?: number; maxWidth?: number; maxHeight?: number },
+): string[] {
+ const grid = trimEmptyMargin(parseSprite(lines))
+ const srcHeight = grid.length
+ const srcWidth = Math.max(0, ...grid.map(row => row.length))
+
+ if (srcWidth === 0 || srcHeight === 0) return lines
+
+ const baseScale = Math.min(opts.scale ?? 0.75, 1)
+ const widthScale = opts.maxWidth ? opts.maxWidth / srcWidth : 1
+ const heightScale = opts.maxHeight ? opts.maxHeight / srcHeight : 1
+ const finalScale = Math.min(baseScale, widthScale, heightScale, 1)
+
+ if (finalScale >= 1) return lines
+
+ const targetWidth = Math.max(1, Math.floor(srcWidth * finalScale))
+ const targetHeight = Math.max(1, Math.floor(srcHeight * finalScale))
+
+ return renderSprite(resampleGrid(grid, targetWidth, targetHeight))
+}
+
// ─── Heart overlay (kept for SpriteAnimator convenience) ──
const PET_HEARTS = [
diff --git a/packages/pokemon/src/ui/BattleConfigPanel.tsx b/packages/pokemon/src/ui/BattleConfigPanel.tsx
index 276d2a169..25dd02e6e 100644
--- a/packages/pokemon/src/ui/BattleConfigPanel.tsx
+++ b/packages/pokemon/src/ui/BattleConfigPanel.tsx
@@ -1,65 +1,72 @@
-import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature, SpeciesId } from '../types'
-import { ALL_SPECIES_IDS } from '../types'
-import { getSpeciesData } from '../dex/species'
-import { calculateStats, getCreatureName } from '../core/creature'
-
-const CYAN = 'ansi:cyan'
-const GREEN = 'ansi:green'
-const GRAY = 'ansi:white'
-const YELLOW = 'ansi:yellow'
+import { getCreatureName } from '../core/creature'
interface BattleConfigPanelProps {
party: (Creature | null)[]
+ cursorIndex: number
onSubmit: (opponentSpeciesId: SpeciesId, opponentLevel: number) => void
onCancel: () => void
}
-export function BattleConfigPanel({ party, onSubmit, onCancel }: BattleConfigPanelProps) {
- const activeCreature = party[0]
+const OPTIONS = [
+ { label: '随机遇战(等级自动匹配)', color: 'warning' as const },
+ { label: '指定对手', color: 'inactive' as const },
+]
+export function BattleConfigPanel({ party, cursorIndex }: BattleConfigPanelProps) {
return (
-
- 战斗配置
-
+
{/* Party display */}
-
- 队伍:
- {party.map((creature, i) => {
- if (!creature) return (
-
- [{i + 1}] [空]
-
- )
- const species = getSpeciesData(creature.speciesId)
- const stats = calculateStats(creature)
- const hpPercent = 100
- const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
- const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
- const isLead = i === 0
- return (
-
- {isLead ? ' ▶ ' : ' '}
- {getCreatureName(creature)}
- Lv.{creature.level}
- {hpBar}
- {hpEmpty}
- {hpPercent}%
-
- )
- })}
-
+ 队伍
+ {party.map((creature, i) => {
+ if (!creature) return (
+
+ [空]
+
+ )
+ const hpPercent = 100
+ const hpBar = '█'.repeat(Math.floor(hpPercent / 10))
+ const hpEmpty = '░'.repeat(10 - Math.floor(hpPercent / 10))
+ const isLead = i === 0
+ return (
+
+
+ {isLead ? ' ▸ ' : ' '}
+
+ {getCreatureName(creature)}
+ Lv.{creature.level}
+ {hpBar}
+ {hpEmpty}
+ {hpPercent}%
+
+ )
+ })}
- {/* Opponent selection */}
+ {/* Options */}
- 对手:
- [1] 随机遇战(等级自动匹配)
- [2] 指定对手(输入物种名)
+ 选择对手
+ {OPTIONS.map((opt, i) => (
+
+
+ {i === cursorIndex ? ' ▶ ' : ' '}
+
+
+ {opt.label}
+
+
+ ))}
- [Enter] 开始战斗 [ESC] 取消
+ [↑↓] 选择 · [Enter] 确认 · [ESC] 取消
)
diff --git a/packages/pokemon/src/ui/BattleField.tsx b/packages/pokemon/src/ui/BattleField.tsx
new file mode 100644
index 000000000..23fd13e0c
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleField.tsx
@@ -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 (
+
+ {rendered.map((line, i) => (
+ {line || ' '}
+ ))}
+
+ )
+}
+
+/** 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
+ }
+ }
+ }
+}
diff --git a/packages/pokemon/src/ui/BattleFlow.tsx b/packages/pokemon/src/ui/BattleFlow.tsx
index 81cceae42..902ee5468 100644
--- a/packages/pokemon/src/ui/BattleFlow.tsx
+++ b/packages/pokemon/src/ui/BattleFlow.tsx
@@ -4,30 +4,39 @@ import type { BuddyData, Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../dex/species'
import { saveBuddyData } from '../core/storage'
-import { createBattle, executeTurn, type BattleInit } from '../battle/engine'
+import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
import { BattleConfigPanel } from './BattleConfigPanel'
-import { BattleView } from './BattleView'
+import { BattleScene, type MenuPhase } from './BattleScene'
import { SwitchPanel } from './SwitchPanel'
import { ItemPanel } from './ItemPanel'
import { BattleResultPanel } from './BattleResultPanel'
import { MoveLearnPanel } from './MoveLearnPanel'
-import { chooseAIMove } from '../battle/ai'
import type { BattleState, PlayerAction } from '../battle/types'
type Phase =
| 'config'
| 'configSelect'
| 'battle'
- | 'switch'
- | 'item'
| 'result'
| 'learnMoves'
| 'evolution'
| 'done'
export interface BattleFlowHandle {
- handleInput: (input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => void
+ handleInput: (input: string, key: {
+ escape?: boolean
+ return?: boolean
+ upArrow?: boolean
+ downArrow?: boolean
+ leftArrow?: boolean
+ rightArrow?: boolean
+ tab?: boolean
+ backspace?: boolean
+ ctrl?: boolean
+ shift?: boolean
+ meta?: boolean
+ }) => void
}
interface BattleFlowProps {
@@ -37,6 +46,8 @@ interface BattleFlowProps {
inputRef?: React.MutableRefObject
}
+const VISIBLE_SPECIES = 7
+
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
const [phase, setPhase] = useState('config')
const [buddyData, setBuddyData] = useState(initialData)
@@ -48,6 +59,12 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
const [replaceIndex, setReplaceIndex] = useState(0)
const [speciesIndex, setSpeciesIndex] = useState(0)
+ const [configCursor, setConfigCursor] = useState(0)
+
+ // ─── Battle UI state ───
+ const [menuPhase, setMenuPhase] = useState('main')
+ const [cursorIndex, setCursorIndex] = useState(0)
+ const [animEnabled, setAnimEnabled] = useState(true)
// ─── Helpers ───
@@ -65,6 +82,28 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
.filter((c): c is Creature => c !== undefined)
}
+ /** Build battleHp map from battleState.playerParty */
+ function getBattleHpMap(): Record {
+ if (!battleState) return {}
+ const map: Record = {}
+ for (const p of battleState.playerParty) {
+ map[p.id] = { hp: p.hp, maxHp: p.maxHp }
+ }
+ return map
+ }
+
+ /** Get max cursor index for current sub-phase */
+ function getMaxCursor(): number {
+ if (!battleState) return 0
+ switch (menuPhase) {
+ case 'main': return 3
+ case 'fight': return battleState.playerPokemon.moves.length - 1
+ case 'bag': return battleState.usableItems.length - 1
+ case 'pokemon': return getPartyCreatures().length - 1
+ default: return 0
+ }
+ }
+
// ─── Actions ───
const handleRandomBattle = useCallback(() => {
@@ -74,8 +113,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
handleStartBattle(randomSpecies, opponentLevel)
}, [buddyData])
- // Config phase: start battle
- const handleStartBattle = useCallback((speciesId: SpeciesId, level: number) => {
+ const handleStartBattle = useCallback(async (speciesId: SpeciesId, level: number) => {
setOpponentSpeciesId(speciesId)
setOpponentLevel(level)
@@ -87,17 +125,27 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
if (creatures.length === 0) return
const bagItems = buddyData.bag.items
- const init = createBattle(creatures, speciesId, level, bagItems)
+ const init = await createBattle(creatures, speciesId, level, bagItems)
setBattleInit(init)
setBattleState(init.state)
+ setMenuPhase('main')
+ setCursorIndex(0)
setPhase('battle')
}, [buddyData])
- // Battle phase: handle action
const handleAction = useCallback(async (action: PlayerAction) => {
if (!battleInit) return
- const state = executeTurn(battleInit, action)
+ const state = await executeTurn(battleInit, action)
setBattleState(state)
+ setMenuPhase('main')
+ setCursorIndex(0)
+
+ // Pokémon fainted — show switch panel overlay
+ if (state.needsSwitch && !state.finished) {
+ setMenuPhase('pokemon')
+ setCursorIndex(0)
+ return
+ }
if (state.finished && state.result) {
const participants = buddyData.party.filter((id): id is string => id !== null)
@@ -112,7 +160,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
}
}, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
- // Result phase: continue to move learning
const handleResultContinue = useCallback(() => {
if (pendingMoves.length > 0) {
setPhase('learnMoves')
@@ -125,7 +172,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
- // Move learning
const handleMoveLearn = useCallback((idx: number) => {
if (pendingMoves.length === 0) return
const move = pendingMoves[0]!
@@ -158,7 +204,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
}
}, [pendingMoves, pendingEvos, buddyData, onClose])
- // Evolution
const handleEvolutionConfirm = useCallback(() => {
if (pendingEvos.length === 0) return
const evo = pendingEvos[0]!
@@ -173,18 +218,63 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
}
}, [pendingEvos, buddyData, onClose])
- // ─── Input handler (called externally via inputRef) ───
+ // Forced switch after faint
+ const handleForcedSwitch = useCallback(async (partyIndex: number) => {
+ if (!battleInit) return
+ const state = await executeSwitch(battleInit, partyIndex)
+ setBattleState(state)
+ setMenuPhase('main')
+ setCursorIndex(0)
- const handleInput = useCallback((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
+ if (state.finished && state.result) {
+ const participants = buddyData.party.filter((id): id is string => id !== null)
+ const result = { ...state.result, participantIds: participants }
+ const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
+ setBuddyData(settled.data)
+ setPendingMoves(settled.learnableMoves)
+ setPendingEvos(settled.pendingEvolutions)
+ setBattleState({ ...state, result })
+ setPhase('result')
+ }
+ }, [battleInit, buddyData, opponentSpeciesId, opponentLevel])
+
+ // ─── Main menu cursor navigation (2x2 grid) ───
+
+ const moveMainCursor = useCallback((direction: 'up' | 'down' | 'left' | 'right') => {
+ setCursorIndex(prev => {
+ // Grid: 0=TL, 1=TR, 2=BL, 3=BR
+ switch (direction) {
+ case 'up': return prev >= 2 ? prev - 2 : prev + 2
+ case 'down': return prev < 2 ? prev + 2 : prev - 2
+ case 'left': return prev % 2 === 1 ? prev - 1 : prev + 1
+ case 'right': return prev % 2 === 0 ? prev + 1 : prev - 1
+ default: return prev
+ }
+ })
+ }, [])
+
+ // ─── Input handler ───
+
+ const handleInput = useCallback((input: string, key: {
+ escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean
+ leftArrow?: boolean; rightArrow?: boolean
+ }) => {
if (!isActive) return
if (phase === 'config') {
if (key.escape) {
onClose()
- } else if (key.return || input === '1') {
- handleRandomBattle()
- } else if (input === '2') {
- setPhase('configSelect')
+ } else if (key.upArrow) {
+ setConfigCursor(prev => (prev - 1 + 2) % 2)
+ } else if (key.downArrow) {
+ setConfigCursor(prev => (prev + 1) % 2)
+ } else if (key.return) {
+ if (configCursor === 0) {
+ handleRandomBattle()
+ } else {
+ setSpeciesIndex(ALL_SPECIES_IDS.indexOf(opponentSpeciesId))
+ setPhase('configSelect')
+ }
}
return
}
@@ -207,47 +297,126 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
}
if (phase === 'battle') {
- if (key.escape) return
- if (input >= '1' && input <= '4') {
- const idx = parseInt(input) - 1
- if (battleState && idx < battleState.playerPokemon.moves.length) {
- handleAction({ type: 'move', moveIndex: idx })
- }
- } else if (input.toLowerCase() === 's') {
- setPhase('switch')
- } else if (input.toLowerCase() === 'i') {
- setPhase('item')
- }
- return
- }
+ if (!battleState) return
- if (phase === 'switch') {
- if (key.escape) {
- setPhase('battle')
- } else if (input >= '1' && input <= '6') {
- const idx = parseInt(input) - 1
- const partyCreatures = getPartyCreatures()
- if (battleState && partyCreatures[idx] && partyCreatures[idx]!.id !== battleState.playerPokemon.id) {
- handleAction({ type: 'switch', creatureId: partyCreatures[idx]!.id })
- setPhase('battle')
- }
+ // F key toggles animation
+ if (input.toLowerCase() === 'f') {
+ setAnimEnabled(prev => !prev)
+ return
}
- return
- }
- if (phase === 'item') {
- if (key.escape) {
- setPhase('battle')
- } else if (input >= '1' && input <= '9') {
- if (battleState) {
- const idx = parseInt(input) - 1
- const items = battleState.usableItems
- if (items[idx]) {
- handleAction({ type: 'item', itemId: items[idx]!.id })
- setPhase('battle')
+ // ─── Main menu ───
+ if (menuPhase === 'main') {
+ if (key.escape) return
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
+ moveMainCursor(key.upArrow ? 'up' : key.downArrow ? 'down' : key.leftArrow ? 'left' : 'right')
+ return
+ }
+ if (key.return) {
+ switch (cursorIndex) {
+ case 0: // 战斗 → move selection
+ setMenuPhase('fight')
+ setCursorIndex(0)
+ return
+ case 1: // 背包
+ setMenuPhase('bag')
+ setCursorIndex(0)
+ return
+ case 2: // 宝可梦
+ setMenuPhase('pokemon')
+ setCursorIndex(0)
+ return
+ case 3: // 逃跑 — show message
+ return
}
}
+ return
}
+
+ // ─── Fight (move selection) ───
+ if (menuPhase === 'fight') {
+ if (key.escape) {
+ setMenuPhase('main')
+ setCursorIndex(0)
+ return
+ }
+ if (key.upArrow) {
+ setCursorIndex(prev => Math.max(0, prev - 1))
+ return
+ }
+ if (key.downArrow) {
+ setCursorIndex(prev => Math.min(battleState.playerPokemon.moves.length - 1, prev + 1))
+ return
+ }
+ if (key.return) {
+ const move = battleState.playerPokemon.moves[cursorIndex]
+ if (move && move.pp > 0 && !move.disabled) {
+ handleAction({ type: 'move', moveIndex: cursorIndex })
+ }
+ return
+ }
+ return
+ }
+
+ // ─── Bag (item selection) ───
+ if (menuPhase === 'bag') {
+ if (key.escape) {
+ setMenuPhase('main')
+ setCursorIndex(1) // return to 背包
+ return
+ }
+ if (key.upArrow) {
+ setCursorIndex(prev => Math.max(0, prev - 1))
+ return
+ }
+ if (key.downArrow) {
+ setCursorIndex(prev => Math.min(battleState.usableItems.length - 1, prev + 1))
+ return
+ }
+ if (key.return) {
+ const item = battleState.usableItems[cursorIndex]
+ if (item) {
+ handleAction({ type: 'item', itemId: item.id })
+ }
+ return
+ }
+ return
+ }
+
+ // ─── Pokemon (switch selection) ───
+ if (menuPhase === 'pokemon') {
+ const isForced = battleState.needsSwitch
+ if (key.escape && !isForced) {
+ setMenuPhase('main')
+ setCursorIndex(2) // return to 宝可梦
+ return
+ }
+ if (key.upArrow) {
+ setCursorIndex(prev => Math.max(0, prev - 1))
+ return
+ }
+ if (key.downArrow) {
+ const maxIdx = getPartyCreatures().length - 1
+ setCursorIndex(prev => Math.min(maxIdx, prev + 1))
+ return
+ }
+ if (key.return) {
+ const party = getPartyCreatures()
+ const creature = party[cursorIndex]
+ const battleParty = battleState.playerParty
+ const battleCreature = battleParty[cursorIndex]
+ if (creature && battleCreature && battleCreature.hp > 0) {
+ if (isForced) {
+ handleForcedSwitch(cursorIndex)
+ } else {
+ handleAction({ type: 'switch', partyIndex: cursorIndex })
+ }
+ }
+ return
+ }
+ return
+ }
+
return
}
@@ -259,10 +428,12 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
if (phase === 'learnMoves') {
if (input.toLowerCase() === 's') {
handleMoveSkip()
- } else if (input >= '1' && input <= '4') {
- const idx = parseInt(input) - 1
- setReplaceIndex(idx)
- handleMoveLearn(idx)
+ } else if (key.upArrow) {
+ setReplaceIndex(prev => Math.max(0, prev - 1))
+ } else if (key.downArrow) {
+ setReplaceIndex(prev => Math.min(3, prev + 1))
+ } else if (key.return) {
+ handleMoveLearn(replaceIndex)
}
return
}
@@ -271,86 +442,80 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
if (key.return) handleEvolutionConfirm()
return
}
- }, [isActive, phase, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm])
+ }, [isActive, phase, menuPhase, cursorIndex, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
// Expose handleInput via ref
useEffect(() => {
if (inputRef) inputRef.current = { handleInput }
}, [handleInput, inputRef])
- // Render by phase
+ // ─── Build overlay content for sub-panels ───
+
+ function buildOverlay(): React.ReactNode | undefined {
+ if (!battleState) return undefined
+
+ if (menuPhase === 'bag') {
+ return (
+ {}}
+ onCancel={() => { setMenuPhase('main'); setCursorIndex(1) }}
+ />
+ )
+ }
+
+ if (menuPhase === 'pokemon') {
+ return (
+ {}}
+ onCancel={() => { setMenuPhase('main'); setCursorIndex(2) }}
+ />
+ )
+ }
+
+ return undefined
+ }
+
+ // ─── Render by phase ───
+
switch (phase) {
case 'config':
return (
)
- case 'configSelect': {
- const selectedIdx = ALL_SPECIES_IDS.indexOf(opponentSpeciesId)
- const startIdx = Math.max(0, Math.min(selectedIdx, ALL_SPECIES_IDS.length - 5))
- const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + 5)
- return (
-
- 选择对手
- {visibleSpecies.map((sid) => {
- const s = getSpeciesData(sid)
- const isSelected = sid === opponentSpeciesId
- return (
-
-
- {isSelected ? ' ▶ ' : ' '}
- #{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
-
- {isSelected && Lv.{getActiveCreatureLevel()}}
-
- )
- })}
-
- [↑↓] 选择 [Enter] 确认 [ESC] 返回
-
-
- )
- }
+ case 'configSelect':
+ return renderSpeciesSelect()
case 'battle': {
if (!battleState) return null
return (
-
- )
- }
-
- case 'switch': {
- if (!battleState) return null
- return (
- {
- handleAction({ type: 'switch', creatureId })
- setPhase('battle')
+ menuPhase={menuPhase}
+ cursorIndex={cursorIndex}
+ animEnabled={animEnabled}
+ overlay={buildOverlay()}
+ onMoveCursor={(dir) => {
+ if (menuPhase === 'main') moveMainCursor(dir)
+ else if (dir === 'up') setCursorIndex(prev => Math.max(0, prev - 1))
+ else if (dir === 'down') setCursorIndex(prev => Math.min(getMaxCursor(), prev + 1))
}}
- onCancel={() => setPhase('battle')}
- />
- )
- }
-
- case 'item': {
- if (!battleState) return null
- return (
- {
- handleAction({ type: 'item', itemId })
- setPhase('battle')
- }}
- onCancel={() => setPhase('battle')}
+ onSelect={() => {}}
+ onBack={() => { setMenuPhase('main'); setCursorIndex(0) }}
+ onToggleAnim={() => setAnimEnabled(prev => !prev)}
/>
)
}
@@ -360,7 +525,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
return (
)
@@ -375,7 +539,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
- 进化!
- {evo.from} 正在进化为 {evo.to}!
- [Enter] 继续
+
+ {evo.from} 正在进化为 {evo.to}!
+
+ [Enter] 继续
+
)
}
@@ -401,4 +573,65 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
default:
return null
}
+
+ // ─── Species select sub-render ───
+
+ function renderSpeciesSelect() {
+ const total = ALL_SPECIES_IDS.length
+ // Scroll window centered on selection
+ const halfVisible = Math.floor(VISIBLE_SPECIES / 2)
+ let startIdx = speciesIndex - halfVisible
+ if (startIdx < 0) startIdx = 0
+ if (startIdx + VISIBLE_SPECIES > total) startIdx = Math.max(0, total - VISIBLE_SPECIES)
+ const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + VISIBLE_SPECIES)
+
+ return (
+
+ {/* Scroll indicator */}
+ {total > VISIBLE_SPECIES && (
+
+ {startIdx > 0 ? ' ↑ 更多 ' : ''}
+
+ )}
+
+ {visibleSpecies.map((sid) => {
+ const s = getSpeciesData(sid)
+ const isSelected = sid === opponentSpeciesId
+ return (
+
+ {isSelected ? (
+ ▸
+ ) : (
+
+ )}
+
+ #{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
+
+ {isSelected && (
+ Lv.{getActiveCreatureLevel()}
+ )}
+
+ )
+ })}
+
+ {/* Scroll indicator */}
+ {total > VISIBLE_SPECIES && (
+
+ {startIdx + VISIBLE_SPECIES < total ? ' ↓ 更多 ' : ''}
+
+ )}
+
+
+ [↑↓] 选择 · [Enter] 确认 · [ESC] 返回
+
+
+ )
+ }
}
diff --git a/packages/pokemon/src/ui/BattleLogPanel.tsx b/packages/pokemon/src/ui/BattleLogPanel.tsx
new file mode 100644
index 000000000..414055a46
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleLogPanel.tsx
@@ -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 (
+
+
+ {visible.map((event, i) => (
+
+ {' '}{formatEvent(event)}
+
+ ))}
+ {visible.length === 0 && (
+ 等待战斗开始...
+ )}
+
+
+ [F] {animEnabled ? '关闭动画' : '开启动画'}
+
+
+ )
+}
diff --git a/packages/pokemon/src/ui/BattleMenu.tsx b/packages/pokemon/src/ui/BattleMenu.tsx
new file mode 100644
index 000000000..b09d54154
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleMenu.tsx
@@ -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
+ }
+
+ return
+}
+
+function MainMenu({ cursorIndex }: { cursorIndex: number }) {
+ return (
+
+ {/* Row 0: 战斗 + 背包 */}
+
+
+
+
+ {/* Row 1: 宝可梦 + 逃跑 */}
+
+
+
+
+
+ )
+}
+
+function MenuItem({ label, selected, disabled }: { label: string; selected: boolean; disabled?: boolean }) {
+ if (selected && disabled) {
+ return (
+
+
+ {' ▶ '}{label} (不可用)
+
+
+ )
+ }
+
+ if (selected) {
+ return (
+
+
+ {' ▶ '}{label}
+
+
+ )
+ }
+
+ if (disabled) {
+ return (
+
+
+ {' '}{label}
+
+
+ )
+ }
+
+ return (
+
+
+ {' '}{label}
+
+
+ )
+}
+
+function MoveMenu({ moves, cursorIndex }: { moves: MoveOption[]; cursorIndex: number }) {
+ return (
+
+ {moves.map((move, i) => (
+
+ ))}
+
+ )
+}
+
+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 (
+
+
+ {' ▶ '}{move.name.padEnd(14)}{ppText}
+
+
+ )
+ }
+
+ return (
+
+
+ {' '}{move.name.padEnd(14)}{ppText}
+
+ {move.disabled && 禁用}
+
+ )
+}
diff --git a/packages/pokemon/src/ui/BattleResultPanel.tsx b/packages/pokemon/src/ui/BattleResultPanel.tsx
index 5529d4041..5681dbbd7 100644
--- a/packages/pokemon/src/ui/BattleResultPanel.tsx
+++ b/packages/pokemon/src/ui/BattleResultPanel.tsx
@@ -1,47 +1,30 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
-import type { BattleResult, BattlePokemon } from '../battle/types'
-
-const GREEN = 'ansi:green'
-const RED = 'ansi:red'
-const YELLOW = 'ansi:yellow'
-const CYAN = 'ansi:cyan'
-const WHITE = 'ansi:whiteBright'
+import type { BattleResult } from '../battle/types'
interface BattleResultPanelProps {
result: BattleResult
- playerPokemon: BattlePokemon
onContinue: () => void
}
-export function BattleResultPanel({ result, playerPokemon, onContinue }: BattleResultPanelProps) {
+export function BattleResultPanel({ result, onContinue }: BattleResultPanelProps) {
const isWin = result.winner === 'player'
return (
-
-
-
- {' '}战斗结束!{isWin ? '胜利!' : '失败...'}
-
-
-
- {isWin && (
-
- {playerPokemon.name} 获得了 {result.xpGained} 经验值!
-
- {Object.keys(result.evGained).length > 0 && (
-
- 努力值获得:
- {Object.entries(result.evGained).map(([stat, value]) => (
- {stat.toUpperCase()}+{value}
- ))}
-
- )}
-
- )}
+
+
+ {isWin ? '战斗胜利!' : '战斗失败...'}
+
- [Enter] 继续
+ [Enter] 继续
)
diff --git a/packages/pokemon/src/ui/BattleScene.tsx b/packages/pokemon/src/ui/BattleScene.tsx
new file mode 100644
index 000000000..c33f9cba8
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleScene.tsx
@@ -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 (
+
+ {/* Left: Battle Log (40%) */}
+
+
+ {/* Right: Battle Field (60%) */}
+
+ {overlay ? (
+ overlay
+ ) : (
+ <>
+ {/* Opponent: HP card left, sprite right */}
+
+
+
+
+
+ {/* Player: sprite left, HP card right — no spacer, visually close */}
+
+
+
+
+
+ {/* Menu */}
+ {!state.finished && (
+
+ )}
+
+ {state.finished && (
+
+ 战斗结束
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/packages/pokemon/src/ui/BattleSprite.tsx b/packages/pokemon/src/ui/BattleSprite.tsx
new file mode 100644
index 000000000..acbeae187
--- /dev/null
+++ b/packages/pokemon/src/ui/BattleSprite.tsx
@@ -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 (
+
+ {rendered.map((line, i) => (
+ {line || ' '}
+ ))}
+
+ )
+}
+
+/** 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
+}
diff --git a/packages/pokemon/src/ui/BattleView.tsx b/packages/pokemon/src/ui/BattleView.tsx
index 79554557d..6b519e140 100644
--- a/packages/pokemon/src/ui/BattleView.tsx
+++ b/packages/pokemon/src/ui/BattleView.tsx
@@ -1,18 +1,11 @@
import React from 'react'
-import { Box, Text, type Color } from '@anthropic/ink'
+import { Box, Text } from '@anthropic/ink'
import type { BattleState, BattleEvent } from '../battle/types'
-const CYAN = 'ansi:cyan'
-const GREEN = 'ansi:green'
-const YELLOW = 'ansi:yellow'
-const RED = 'ansi:red'
-const GRAY = 'ansi:white'
-const WHITE = 'ansi:whiteBright'
-
-function hpColor(pct: number): Color {
- if (pct > 50) return GREEN
- if (pct > 25) return YELLOW
- return RED
+function hpColor(pct: number): 'success' | 'warning' | 'error' {
+ if (pct > 50) return 'success'
+ if (pct > 25) return 'warning'
+ return 'error'
}
function hpBar(current: number, max: number): { bar: string; pct: number } {
@@ -36,53 +29,64 @@ export function BattleView({ state, onAction }: BattleViewProps) {
const oppHp = hpBar(opp.hp, opp.maxHp)
const playerHp = hpBar(player.hp, player.maxHp)
- // Show last 5 events
- const recentEvents = state.events.slice(-5)
+ const recentEvents = state.events.slice(-10)
return (
-
+
{/* Opponent */}
- 野生 {opp.name}
- (Lv.{opp.level})
+ 野生的
+ {opp.name}
+ Lv.{opp.level}
- HP
+ HP
{oppHp.bar}
- {oppHp.pct}%
- {opp.status !== 'none' && [{opp.status}]}
+ {opp.hp}/{opp.maxHp}
+ {opp.status !== 'none' && [{opp.status}]}
- ── vs ──
+ ─── vs ───
{/* Player */}
- {player.name}
- (Lv.{player.level})
+
+ {player.name}
+ Lv.{player.level}
- HP
+ HP
{playerHp.bar}
- {playerHp.pct}%
- {player.status !== 'none' && [{player.status}]}
+ {player.hp}/{player.maxHp}
+ {player.status !== 'none' && [{player.status}]}
{/* Move selection */}
{!state.finished && (
- 选择行动:
+ 选择行动
{player.moves.map((move, i) => (
- 0 ? WHITE : GRAY}>
- {' '}[{i + 1}] {move.name || '---'} PP {move.pp}/{move.maxPp}
+ 0 ? 'text' : 'inactive'}>
+ {' '}[{i + 1}] {move.name || '---'}
+ PP {move.pp}/{move.maxPp}
+ {move.disabled && (禁用)}
))}
- [S] 换人 [I] 道具
+ [S] 换人
+ [I] 道具
)}
@@ -90,7 +94,7 @@ export function BattleView({ state, onAction }: BattleViewProps) {
{recentEvents.length > 0 && (
{recentEvents.map((event, i) => (
- {formatEvent(event)}
+ {formatEvent(event)}
))}
)}
@@ -98,23 +102,23 @@ export function BattleView({ state, onAction }: BattleViewProps) {
)
}
-function eventColor(event: BattleEvent): Color {
+function eventColor(event: BattleEvent): 'error' | 'success' | 'warning' | 'claude' | 'inactive' | 'text' {
switch (event.type) {
- case 'damage': return RED
- case 'heal': return GREEN
- case 'faint': return RED
- case 'crit': return YELLOW
- case 'miss': return GRAY
- case 'effectiveness': return event.multiplier > 1 ? GREEN : YELLOW
- default: return WHITE
+ case 'damage': return 'error'
+ case 'heal': return 'success'
+ case 'faint': return 'error'
+ case 'crit': return 'warning'
+ case 'miss': return 'inactive'
+ case 'effectiveness': return event.multiplier > 1 ? 'success' : 'warning'
+ default: return 'inactive'
}
}
function formatEvent(event: BattleEvent): string {
switch (event.type) {
case 'move': return `${event.side === 'player' ? '我方' : '对手'}使用了 ${event.move}!`
- case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害! (${event.percentage}%)`
- case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP!`
+ case 'damage': return `${event.side === 'player' ? '我方' : '对手'}受到了 ${event.amount} 点伤害 (${event.percentage}%)`
+ case 'heal': return `${event.side === 'player' ? '我方' : '对手'}恢复了 ${event.amount} HP`
case 'faint': return `${event.side === 'player' ? '我方' : '对手'}的 ${event.speciesId} 倒下了!`
case 'crit': return '击中要害!'
case 'miss': return '攻击没有命中!'
diff --git a/packages/pokemon/src/ui/HpCard.tsx b/packages/pokemon/src/ui/HpCard.tsx
new file mode 100644
index 000000000..f135f74fb
--- /dev/null
+++ b/packages/pokemon/src/ui/HpCard.tsx
@@ -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 = (
+
+ {isOpponent && }
+ {prefix}{name}
+ Lv.{level}
+ {statusInfo && (
+ {statusInfo.text}
+ )}
+
+ )
+
+ const hpLine = (
+
+ HP
+ {bar}
+ {hp}/{maxHp}
+
+ )
+
+ return (
+
+ {nameLine}
+ {hpLine}
+
+ )
+}
diff --git a/packages/pokemon/src/ui/ItemPanel.tsx b/packages/pokemon/src/ui/ItemPanel.tsx
index 9d5c9748f..7ec8550cf 100644
--- a/packages/pokemon/src/ui/ItemPanel.tsx
+++ b/packages/pokemon/src/ui/ItemPanel.tsx
@@ -1,31 +1,83 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
-const CYAN = 'ansi:cyan'
-const GRAY = 'ansi:white'
-
interface ItemPanelProps {
items: { id: string; name: string; count: number; description?: string }[]
+ cursorIndex: number
+ categoryIndex: number
+ phase: 'category' | 'items'
onSelect: (itemId: string) => void
onCancel: () => void
}
-export function ItemPanel({ items, onSelect, onCancel }: ItemPanelProps) {
+/** Item categories */
+const CATEGORIES = [
+ { id: 'healing', label: '回复药', filter: (id: string) => id.includes('potion') || id.includes('berry') || id.includes('heal') },
+ { id: 'ball', label: '精灵球', filter: (id: string) => id.includes('ball') },
+ { id: 'battle', label: '战斗道具', filter: (id: string) => id.includes('x-') || id.includes('dire') || id.includes('guard') },
+]
+
+export function ItemPanel({ items, cursorIndex, categoryIndex, phase, onSelect, onCancel }: ItemPanelProps) {
+ if (phase === 'category') {
+ return (
+
+
+ {CATEGORIES.map((cat, i) => (
+
+ {categoryIndex === i ? (
+ ▶ {cat.label}
+ ) : (
+ {cat.label}
+ )}
+
+ ))}
+
+
+ [ESC] 返回
+
+
+ )
+ }
+
+ // 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 (
-
- 道具
- {items.length === 0 ? (
- 没有可用道具
- ) : (
- items.map((item, i) => (
-
- [{i + 1}] {item.name} ×{item.count}
- {item.description && {item.description}}
-
- ))
- )}
+
+
+ {displayItems.length === 0 ? (
+ 没有可用道具
+ ) : (
+ displayItems.map((item, i) => (
+
+ {cursorIndex === i ? (
+ ▶ {item.name}
+ ) : (
+ {item.name}
+ )}
+ ×{item.count}
+
+ ))
+ )}
+
- [ESC] 取消
+ [ESC] 返回
)
diff --git a/packages/pokemon/src/ui/MoveLearnPanel.tsx b/packages/pokemon/src/ui/MoveLearnPanel.tsx
index 33b768645..c8b9b09f9 100644
--- a/packages/pokemon/src/ui/MoveLearnPanel.tsx
+++ b/packages/pokemon/src/ui/MoveLearnPanel.tsx
@@ -3,46 +3,56 @@ import { Box, Text } from '@anthropic/ink'
import type { Creature } from '../types'
import { Dex } from '@pkmn/sim'
-const CYAN = 'ansi:cyan'
-const YELLOW = 'ansi:yellow'
-const GRAY = 'ansi:white'
-const WHITE = 'ansi:whiteBright'
-
interface MoveLearnPanelProps {
creature: Creature
newMoveId: string
- replaceIndex: number
+ cursorIndex: number
onLearn: (replaceIndex: number) => void
onSkip: () => void
onSelectReplace: (index: number) => void
}
-export function MoveLearnPanel({ creature, newMoveId, replaceIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
+export function MoveLearnPanel({ creature, newMoveId, cursorIndex, onLearn, onSkip, onSelectReplace }: MoveLearnPanelProps) {
const dexMove = Dex.moves.get(newMoveId)
const moveName = dexMove?.name ?? newMoveId
const moveType = dexMove?.type ?? 'Normal'
return (
-
- 新招式!
- {creature.speciesId} 可以学习: {moveName} ({moveType})
+
+ {creature.speciesId} 可以学习: {moveName} ({moveType})
- 当前招式:
- {creature.moves.map((move, i) => {
- const isReplaceTarget = i === replaceIndex
- const moveInfo = move.id ? Dex.moves.get(move.id) : null
- return (
-
-
- {' '}[{i + 1}] {moveInfo?.name ?? move.id ?? '---'} PP {move.pp}/{move.maxPp}
-
- {isReplaceTarget && ← 替换目标}
-
- )
- })}
+
+ 当前招式:
+ {creature.moves.map((move, i) => {
+ const isSelected = i === cursorIndex
+ const moveInfo = move.id ? Dex.moves.get(move.id) : null
+ return (
+
+ {isSelected ? (
+
+ {' ▶ '}{moveInfo?.name ?? move.id ?? '---'}
+
+ ) : (
+
+ {' '}{moveInfo?.name ?? move.id ?? '---'}
+
+ )}
+ PP {move.pp}/{move.maxPp}
+ {isSelected && {'<-- 替换'}}
+
+ )
+ })}
+
- [1-4] 替换对应招式 [S] 跳过
+ [↑↓] 选择 · [Enter] 替换 · [S] 跳过
)
diff --git a/packages/pokemon/src/ui/SpriteAnimator.tsx b/packages/pokemon/src/ui/SpriteAnimator.tsx
index 8b3386dd1..f0848efd2 100644
--- a/packages/pokemon/src/ui/SpriteAnimator.tsx
+++ b/packages/pokemon/src/ui/SpriteAnimator.tsx
@@ -1,7 +1,7 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useState, useMemo } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { AnimMode } from '../types'
-import { renderAnimatedSprite, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
+import { renderAnimatedSprite, flipSpriteLines, getIdleAnimMode, getPetOverlay } from '../sprites/renderer'
/** Vertical padding — bounce shifts within this space */
const V_PAD = 4
@@ -19,6 +19,8 @@ interface SpriteAnimatorProps {
centered?: boolean
/** Show pet hearts overlay */
petting?: boolean
+ /** Flip horizontally (for player Pokemon facing opponent) */
+ flip?: boolean
}
/**
@@ -35,6 +37,7 @@ export function SpriteAnimator({
mode,
centered = true,
petting,
+ flip,
}: SpriteAnimatorProps) {
const [tick, setTick] = useState(0)
@@ -43,8 +46,14 @@ export function SpriteAnimator({
return () => clearInterval(timer)
}, [tickMs])
+ // Flip sprite if needed (cached)
+ const sourceLines = useMemo(
+ () => flip ? flipSpriteLines(lines) : lines,
+ [lines, flip],
+ )
+
// Add vertical padding — bounce shifts within this space
- const padded = [...Array(V_PAD).fill(''), ...lines, ...Array(V_PAD).fill('')]
+ const padded = [...Array(V_PAD).fill(''), ...sourceLines, ...Array(V_PAD).fill('')]
// Apply animation (renderer parses to pixels, transforms, renders back)
const currentMode = mode ?? getIdleAnimMode(tick)
diff --git a/packages/pokemon/src/ui/SwitchPanel.tsx b/packages/pokemon/src/ui/SwitchPanel.tsx
index 38f6269e3..0c32adcb4 100644
--- a/packages/pokemon/src/ui/SwitchPanel.tsx
+++ b/packages/pokemon/src/ui/SwitchPanel.tsx
@@ -3,35 +3,77 @@ import { Box, Text } from '@anthropic/ink'
import type { Creature } from '../types'
import { getCreatureName } from '../core/creature'
-const CYAN = 'ansi:cyan'
-const GRAY = 'ansi:white'
-const WHITE = 'ansi:whiteBright'
-
interface SwitchPanelProps {
party: Creature[]
activeId: string
- onSelect: (creatureId: string) => void
+ cursorIndex: number
+ /** HP values from battle state (keyed by creature id) */
+ battleHp?: Record
+ onSelect: (creatureId: string, partyIndex: number) => void
onCancel: () => void
}
-export function SwitchPanel({ party, activeId, onSelect, onCancel }: SwitchPanelProps) {
+function hpBarSmall(current: number, max: number): string {
+ if (max <= 0) return '░░░░░░'
+ const filled = Math.round((current / max) * 6)
+ return '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, 6 - filled))
+}
+
+function hpColorStr(pct: number): string {
+ if (pct > 50) return 'success'
+ if (pct > 25) return 'warning'
+ return 'error'
+}
+
+export function SwitchPanel({ party, activeId, cursorIndex, battleHp, onCancel }: SwitchPanelProps) {
return (
-
- 换人
- {party.map((creature, i) => {
- const isActive = creature.id === activeId
- return (
-
- {isActive ? ' ▶ ' : ' '}
-
- [{i + 1}] {getCreatureName(creature)} (Lv.{creature.level}){' '}
-
- {isActive && 当前场上}
-
- )
- })}
+
+
+ {party.map((creature, i) => {
+ const isActive = creature.id === activeId
+ const hpData = battleHp?.[creature.id]
+ const hp = hpData?.hp ?? 0
+ const maxHp = hpData?.maxHp ?? 1
+ const hpPct = maxHp > 0 ? Math.round((hp / maxHp) * 100) : 0
+ const isFainted = hpData ? hp <= 0 : false
+
+ return (
+
+ {cursorIndex === i ? (
+
+ {' ▸ '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
+
+ ) : isActive ? (
+
+ {' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (场上) '}
+
+ ) : isFainted ? (
+
+ {' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' (倒下) '}
+
+ ) : (
+
+ {' '}{getCreatureName(creature)}{' Lv.'}{creature.level}{' '}
+
+ )}
+
+ {hpData && (
+
+ {hpBarSmall(hp, maxHp)} {hp}/{maxHp}
+
+ )}
+
+ )
+ })}
+
- [ESC] 取消
+ [ESC] 返回
)
diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx
index e660a76b3..a79833cfe 100644
--- a/src/buddy/CompanionSprite.tsx
+++ b/src/buddy/CompanionSprite.tsx
@@ -146,7 +146,8 @@ export function companionReservedColumns(terminalColumns: number, speaking: bool
const name = getCreatureName(creature);
const nameWidth = stringWidth(name);
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
- return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
+ // Without sprite art, only need name row width + padding + optional bubble
+ return nameWidth + NAME_ROW_PAD + SPRITE_PADDING_X + bubble;
}
/**
@@ -275,11 +276,6 @@ export function CompanionSprite(): React.ReactNode {
const spriteColumn = (
- {displayLines.map((line, i) => (
-
- {line}
-
- ))}
{focused ? ` ${name} ` : name}
diff --git a/src/commands/pokemon-battle/pokemon-battle.ts b/src/commands/pokemon-battle/pokemon-battle.ts
index d4cddbadc..9a91d4ce3 100644
--- a/src/commands/pokemon-battle/pokemon-battle.ts
+++ b/src/commands/pokemon-battle/pokemon-battle.ts
@@ -1,8 +1,7 @@
-import React, { useState, useRef } from 'react'
-import { useInput } from '@anthropic/ink'
+import React, { useRef } from 'react'
+import { useInput, useRegisterKeybindingContext } from '@anthropic/ink'
import {
loadBuddyData,
- saveBuddyData,
getActiveCreature,
BattleFlow,
type BuddyData,
@@ -49,20 +48,18 @@ function BattlePanel({
buddyData: BuddyData
onClose: () => void
}) {
- const [battleKey, setBattleKey] = useState(0)
const inputRef = useRef(null)
- useInput((input, key) => {
+ // Register keybinding context so our shortcuts take priority over Global
+ useRegisterKeybindingContext('Battle')
+
+ useInput((input, key, event) => {
+ // Consume ALL keyboard events to prevent PromptInput from intercepting
+ event.stopImmediatePropagation()
inputRef.current?.handleInput(input, key)
})
- const handleClose = async () => {
- const updated = await loadBuddyData()
- setBattleKey(k => k + 1)
- }
-
return React.createElement(BattleFlow, {
- key: battleKey,
buddyData,
onClose,
isActive: true,