# Snow CLI 使用文档——小游戏插件指南

## 概述

Snow CLI 内置了一个小游戏面板，你可以通过 `/games` 命令打开它。面板启动后会显示所有可用的游戏列表，选中并按 Enter 即可开始游戏。

Snow CLI 已经内置了贪吃蛇游戏作为参考实现。你也可以编写自己的游戏插件放到用户目录下，外部插件会自动被加载并显示在游戏列表中。如果外部插件的 `id` 与内置游戏相同，外部插件会覆盖内置游戏。

适合这些场景：

- 编写终端小游戏，丰富开发间隙的休闲体验
- 学习 Snow CLI 插件系统的游戏循环架构
- 用自己的实现替换内置贪吃蛇游戏

## 插件目录

Snow CLI 从以下目录加载外部游戏插件：

```bash
~/.snow/plugin/games/
```

支持的文件扩展名：

- `.js`
- `.mjs`（推荐，纯 ES Module 写法）
- `.cjs`

说明：

- 只支持从用户目录加载插件。
- Snow CLI 进入游戏面板后按文件名排序懒加载所有插件。
- 新增或修改插件文件后，重新打开游戏面板即可加载最新代码（无需重启 Snow CLI）。
- 内置游戏无需安装任何文件，始终可用。

## 支持的导出形式

一个插件模块可以使用以下任意一种导出形式（加载器会扫描所有形式）：

```js
export default { ... }
```

```js
export const game = { ... }
```

```js
export const games = [{ ... }, { ... }]
```

如果外部插件的 `id` 与内置游戏相同，外部插件会覆盖内置游戏。

## 插件结构

每个游戏插件都必须满足以下结构（使用 TypeScript 表达更直观，但插件文件本身是普通 JavaScript）：

```ts
interface GamePlugin<S = unknown> {
	/** 全局唯一 id，用于内部索引 */
	id: string;
	/** 显示名称 */
	name: string;
	/**
	 * 简短描述，显示在游戏列表中。
	 * 支持多语言：传入纯字符串则所有语言通用，
	 * 传入对象则按语言选择对应描述。
	 */
	description?: string | Partial<Record<'en' | 'zh' | 'zh-TW', string>>;
	/** 作者信息 */
	author?: string;
	/** 版本号 */
	version?: string;
	/** 是否启用，默认 true */
	enable?: boolean;

	/** 初始化游戏状态，在游戏开始时调用一次 */
	init(ctx: GameInitContext): S;
	/** 处理用户输入，返回更新后的状态 */
	handleInput(state: S, input: GameInput): S;
	/** 推进游戏逻辑（每个 tick 调用一次），返回 null 表示状态不变 */
	tick(state: S): S | null;
	/** 渲染当前状态为渲染行数组，支持纯字符串行和带样式段落数组行（行内多色） */
	render(state: S): GameRenderResult;
	/** 查询当前游戏状态 */
	getStatus(state: S): 'playing' | 'gameover' | 'won' | 'paused';
	/** 获取底部提示文本（操作说明等），可选 */
	getHint?(state: S): string;
	/** 获取分数文本，可选 */
	getScore?(state: S): string | number | null;

	/** Tick 间隔（毫秒），默认 200。静态值适用于固定节奏的游戏 */
	tickInterval?: number;
	/** 动态获取 tick 间隔（毫秒），优先级高于 tickInterval。用于随状态变速（如贪吃蛇随分数加速） */
	getTickInterval?(state: S): number;
	/** 引擎暂停时调用（可选），由引擎 p 键触发 */
	onPause?(state: S): void;
	/** 引擎恢复时调用（可选），由引擎 p 键触发 */
	onResume?(state: S): void;
}
```

### 引擎层按键与暂停机制

GameRunner 引擎层统一处理以下按键，插件**无需自行实现**：

| 按键      | 行为               | 说明                                   |
| --------- | ------------------ | -------------------------------------- |
| `ESC`     | 退出游戏，返回菜单 | 引擎拦截，不会传入 `handleInput()`     |
| `p` / `P` | 暂停/恢复          | 引擎级暂停，独立于插件的 `getStatus()` |

**暂停机制详解：**

- 按下 `p` 键时，GameRunner 进入引擎级暂停状态：停止 tick 循环、显示 `[PAUSED]` 状态标签、底部提示 "Paused. Press p to resume, ESC to exit."
- 引擎暂停状态是 GameRunner 内部管理的，优先于插件的 `getStatus()` 返回值——即使插件返回 `playing`，引擎暂停时仍显示 `paused`
- 暂停时不会调用 `handleInput()`，插件无需处理暂停逻辑
- 若插件提供了 `onPause(state)` / `onResume(state)` 回调，引擎会在暂停/恢复时调用它们，插件可借此暂停音效、保存状态等
- 终态（`gameover`/`won`）下 `p` 键不生效，不会触发暂停

**Tick 速率控制：**

- 默认 tick 间隔 200ms（`DEFAULT_TICK_INTERVAL_MS`）
- 插件可通过 `tickInterval` 设置静态间隔（如 `tickInterval: 150`）
- 插件可通过 `getTickInterval(state)` 动态返回间隔，优先级高于 `tickInterval`，适合随状态变速的游戏
- GameRunner 在每次 tick 后检查间隔是否变化，变化时自动重建定时器

**状态感知的 tick 循环：**

- GameRunner 在调用 `tick()` 前会检查 `getStatus()`，非 `playing` 状态时跳过 tick 调用
- 这意味着 `gameover`/`won`/`paused` 状态下不会产生无意义的 tick，降低 CPU 开销
- 插件仍可在 `tick()` 内部返回 `null` 表示状态不变（向后兼容）

### 渲染行类型

`render()` 的返回类型 `GameRenderResult` 是 `GameRenderLine[]`，每行支持两种形式：

```ts
/** 一段带样式的文本 */
interface GameRenderSegment {
	/** 文本内容 */
	text: string;
	/** 文本颜色，支持 ink 支持的颜色名（如 'red'、'green'、'cyan'、'yellow' 等） */
	color?: string;
	/** 是否加粗 */
	bold?: boolean;
	/** 是否暗淡显示 */
	dim?: boolean;
}

/** 一行终端输出：纯字符串或带样式的段落数组 */
type GameRenderLine = string | GameRenderSegment[];

/** 渲染结果 */
type GameRenderResult = GameRenderLine[];
```

**纯字符串行**（向后兼容）：整行无样式，GameRunner 在 gameover 时整体变灰。

```js
render(state) {
	return [
		'┌─────────────┐',
		'│   Hello!    │',
		'└─────────────┘',
	];
}
```

**段落数组行**（行内多色）：同一行的不同字符可使用不同的颜色/加粗/暗淡，适合需要区分阵营、地形等元素的游戏。

```js
render(state) {
	return [
		// 纯字符串行和段落数组行可混用
		'┌─────────────┐',
		// 行内多色：标签灰色、数值黄色加粗
		[
			{text: '│ Score: ', color: 'gray'},
			{text: '100', color: 'yellow', bold: true},
			{text: '         │'},
		],
		'└─────────────┘',
	];
}
```

两种形式可在同一个 `render()` 返回值中混用。GameRunner 会逐行渲染：纯字符串行用单个 `<Text>`（gameover 时变灰），段落数组行用多个内联 `<Text>` 按段独立着色。

### GameInitContext

`init(ctx)` 收到的上下文对象：

```ts
interface GameInitContext {
	/** 终端宽度，供游戏自行决定渲染宽度 */
	terminalWidth: number;
	/** 终端高度，供游戏自行决定渲染高度 */
	terminalHeight: number;
}
```

### GameInput

`handleInput(state, input)` 收到的输入对象：

```ts
interface GameInput {
	/** 原始字符输入 */
	input: string;
	/** 按键状态 */
	key: {
		upArrow: boolean;
		downArrow: boolean;
		leftArrow: boolean;
		rightArrow: boolean;
		return: boolean;
		escape: boolean;
		backspace: boolean;
		delete: boolean;
		ctrl: boolean;
		shift: boolean;
		meta: boolean;
	};
}
```

### description 多语言支持

`description` 字段支持两种写法：

**纯字符串**（所有语言通用）：

```js
description: 'A simple terminal game.';
```

**多语言对象**（按用户语言设置自动选择）：

```js
description: {
	en: 'A simple terminal game.',
	zh: '一个简单的终端小游戏。',
	'zh-TW': '一個簡單的終端小遊戲。',
}
```

当使用多语言对象时，Snow CLI 按以下优先级选择描述文本：

1. 当前用户语言对应的文本
2. 英语（`en`）对应的文本
3. 对象中第一个可用语言的文本

这意味着你不必为所有语言都提供翻译，只需提供 `en` 作为兜底即可。

## 游戏循环

GameRunner 组件负责驱动整个游戏循环：

```
游戏开始
├── 调用 init(ctx) 初始化状态
├── 进入 tick 循环（默认 200ms，可由 tickInterval/getTickInterval 自定义）
│   ├── 检查 getStatus(state) === 'playing'，否则跳过本次 tick
│   ├── 调用 tick(state) 推进逻辑
│   │   └── 返回 null → 状态不变
│   │   └── 返回新状态 → 更新 state，并检查是否需要变速
│   ├── 调用 render(state) 获取画面
│   ├── 调用 getStatus(state) 获取状态
│   ├── 调用 getHint(state) 获取提示（可选）
│   └── 调用 getScore(state) 获取分数（可选）
├── 用户按键
│   ├── ESC → 退出游戏（引擎拦截，不传入 handleInput）
│   ├── p/P → 切换引擎级暂停（停止 tick，不传入 handleInput）
│   │         └── 调用 onPause(state) / onResume(state) 通知插件（可选）
│   └── 其他按键 → 调用 handleInput(state, input)
└── 状态变为 gameover/won 时停止 tick
```

### 状态机

游戏状态通过 `getStatus()` 返回，影响画面渲染颜色。此外，GameRunner 引擎层还维护一个独立的暂停状态：

| 状态       | 颜色          | 含义       | 来源                                           |
| ---------- | ------------- | ---------- | ---------------------------------------------- |
| `playing`  | 青色 (cyan)   | 游戏进行中 | 插件 `getStatus()`                             |
| `gameover` | 红色 (red)    | 游戏结束   | 插件 `getStatus()`                             |
| `won`      | 绿色 (green)  | 游戏胜利   | 插件 `getStatus()`                             |
| `paused`   | 黄色 (yellow) | 游戏暂停   | 引擎层（`p` 键触发），优先于插件 `getStatus()` |

当引擎处于暂停状态时，显示的 `paused` 状态会覆盖插件 `getStatus()` 的返回值。插件无需在 `getStatus()` 中返回 `paused`——暂停完全由引擎 `p` 键管理。

## 示例：简易数字猜猜乐

下面是一个完整的示例插件，演示了游戏循环、输入处理、渲染和分数的基本用法：

```js
// ~/.snow/plugin/games/guess-number.mjs

// 游戏内部状态
function initState() {
	return {
		target: Math.floor(Math.random() * 100) + 1,
		guess: null,
		attempts: 0,
		message: '',
		finished: false,
	};
}

export default {
	id: 'example.guess-number',
	name: 'Guess Number',
	description: {
		en: 'Guess a number between 1 and 100.',
		zh: '猜一个 1 到 100 之间的数字。',
		'zh-TW': '猜一個 1 到 100 之間的數字。',
	},
	author: 'Snow CLI',
	version: '1.0.0',
	enable: true,

	init() {
		return initState();
	},

	handleInput(state, input) {
		if (state.finished) {
			if (input.key.return) {
				return initState();
			}
			return state;
		}

		const char = input.input;
		if (char >= '0' && char <= '9') {
			const digit = Number.parseInt(char, 10);
			const current = state.guess === null ? 0 : state.guess;
			const newGuess = current * 10 + digit;
			if (newGuess <= 100) {
				return {...state, guess: newGuess, message: ''};
			}
		}

		if (input.key.return && state.guess !== null) {
			const attempts = state.attempts + 1;
			if (state.guess === state.target) {
				return {
					...state,
					attempts,
					finished: true,
					message: `Correct! You got it in ${attempts} tries.`,
				};
			}
			const hint = state.guess < state.target ? 'Too low!' : 'Too high!';
			return {
				...state,
				attempts,
				guess: null,
				message: hint,
			};
		}

		return state;
	},

	tick(state) {
		return null; // 纯输入驱动，无需 tick 逻辑
	},

	render(state) {
		const lines = [];
		lines.push('┌─────────────────────────┐');
		lines.push('│   Guess Number (1-100)  │');
		lines.push('└─────────────────────────┘');
		lines.push('');
		lines.push(`  Attempts: ${state.attempts}`);
		lines.push(`  Current:  ${state.guess ?? '---'}`);
		if (state.message) {
			lines.push(`  ${state.message}`);
		}
		lines.push('');
		if (state.finished) {
			lines.push('  Press Enter to play again.');
		} else {
			lines.push('  Type digits, Enter to submit.');
		}
		return lines;
	},

	getStatus(state) {
		return state.finished ? 'gameover' : 'playing';
	},

	getHint(state) {
		if (state.finished) {
			return 'Press Enter to restart, ESC to exit.';
		}
		return 'Type 0-9 to enter a number. Enter to guess. ESC to exit.';
	},

	getScore(state) {
		return `Attempts: ${state.attempts}`;
	},
};
```

## 示例：覆盖内置贪吃蛇

如果你想用自己的实现替换内置贪吃蛇，只需将插件 `id` 设为 `builtin.snake`：

```js
// ~/.snow/plugin/games/my-snake.mjs

export default {
	id: 'builtin.snake', // 相同 id 会覆盖内置游戏
	name: 'My Snake',
	description: 'My custom snake implementation.',
	enable: true,

	init(ctx) {
		// 你的初始化逻辑
	},

	handleInput(state, input) {
		// 你的输入处理
	},

	tick(state) {
		// 你的游戏逻辑
	},

	render(state) {
		// 你的渲染逻辑
	},

	getStatus(state) {
		// 返回状态
	},
};
```

## 渲染说明

- `render()` 返回一个数组，每个元素对应终端一行。每行可以是纯字符串或 `GameRenderSegment[]`（带样式段落数组），两种形式可混用。
- 纯字符串行：GameRunner 用单个 `<Text>` 渲染，游戏结束时整体变灰。
- 段落数组行：GameRunner 用多个内联 `<Text>` 按段独立着色，支持每段不同的 `color`/`bold`/`dim`，适合区分阵营、地形等元素。
- 游戏画面区域上方显示游戏名称和分数/状态标签。
- 画面下方显示 `getHint()` 返回的提示文本。
- 游戏结束时纯字符串行会变灰，段落数组行保持插件指定的颜色（由插件自行控制是否变暗）。
- 游戏运行期间按 ESC 退出返回菜单，按 `p` 键暂停/恢复（引擎统一管理）。

## 编写自己的插件：检查清单

1. **`id` 要稳定且唯一**。用于内部索引和覆盖内置游戏，一旦发布请保持不变。
2. **`init()` 必须返回一个全新的状态对象**。不要缓存旧状态。
3. **`handleInput()` 和 `tick()` 返回新状态，不要原地修改**。遵循 React 不可变更新原则。
4. **`tick()` 返回 `null` 表示状态不变**。纯输入驱动的游戏可以始终返回 `null`。
5. **`render()` 返回渲染行数组**。每行可以是纯字符串或 `GameRenderSegment[]`，每行不要超出终端宽度，可以使用 `GameInitContext.terminalWidth` 做适配。
6. **`getStatus()` 必须正确反映游戏状态**。这影响画面颜色和 tick 是否继续（非 `playing` 状态会跳过 tick 调用）。注意：`paused` 状态由引擎 `p` 键统一管理，插件无需自行返回 `paused`。
7. **`getHint()` 和 `getScore()` 是可选的**。不提供时不会报错。引擎暂停时会显示固定的暂停提示，覆盖 `getHint()` 返回值。
8. **`tickInterval` / `getTickInterval()` 是可选的**。未提供时使用默认 200ms。需要变速的游戏（如贪吃蛇随分数加速）可使用 `getTickInterval(state)` 动态返回间隔。
9. **`onPause()` / `onResume()` 是可选的**。引擎在 `p` 键触发暂停/恢复时调用，插件可借此暂停音效、保存状态等。
10. **`description` 支持多语言**。推荐至少提供 `en` 作为兜底。
11. **插件作为 Node.js 模块运行**。你可以 `import` 任何 Node.js 内置模块，也可以使用 `process.env` 读取环境变量。
12. **`enable: false` 可临时停用插件**，无需删除文件。

## 故障排查

- **插件没有出现在游戏列表中。**

  - 确认插件文件在 `~/.snow/plugin/games/` 目录下。
  - 确认扩展名是 `.js` / `.mjs` / `.cjs`。
  - 检查插件是否设置了 `enable: false`。
  - 确认导出的是包含 `{id, name, init, handleInput, tick, render, getStatus}` 的对象——加载器校验失败时会输出 `did not export a valid GamePlugin` 警告。

- **游戏开始后画面空白。**

  - 检查 `render()` 是否返回了非空字符串数组。
  - 检查 `init()` 是否正确返回了初始状态。

- **按键没有反应。**

  - 检查 `handleInput()` 是否正确解析了 `input.input`（原始字符）和 `input.key`（按键状态）。
  - 注意 `handleInput()` 必须返回新状态对象，而不是修改旧状态。

- **游戏状态不正确。**

  - 检查 `getStatus()` 是否返回了正确的状态值。
  - `gameover` 或 `won` 状态会停止 tick 循环。

- **我想覆盖内置贪吃蛇但没有生效。**

  - 确认插件 `id` 完全一致：`builtin.snake`。
  - 确认插件没有被 `enable: false` 停用。

## 相关文件

- `source/utils/plugins/games/types.ts` — 类型定义
- `source/utils/plugins/games/loader.ts` — 插件加载器
- `source/utils/plugins/games/builtin/snake.ts` — 内置贪吃蛇参考实现
- `source/ui/pages/GamesScreen.tsx` — 游戏面板页面
- `source/ui/components/games/GameRunner.tsx` — 通用游戏运行器组件

## 相关文档

- [自定义 StatusLine 指南](./21.自定义StatusLine指南.md)——使用相同的插件加载理念
- [自定义请求头插件指南](./26.自定义请求头插件指南.md)——使用相同的插件加载理念
- [自定义搜索引擎指南](./23.自定义搜索引擎指南.md)——使用相同的插件加载理念
