2026-04-04 13:20:34 +08:00
|
|
|
|
# 编写 GameModule
|
|
|
|
|
|
|
|
|
|
|
|
GameModule 是定义游戏逻辑的模块,包含状态定义和命令注册。
|
|
|
|
|
|
|
|
|
|
|
|
## GameModule 结构
|
|
|
|
|
|
|
|
|
|
|
|
一个 GameModule 必须导出两个东西:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
import { createGameCommandRegistry, createRegion } from 'boardgame-core';
|
|
|
|
|
|
|
|
|
|
|
|
export function createInitialState() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
board: createRegion('board', [
|
|
|
|
|
|
{ name: 'x', min: 0, max: 2 },
|
|
|
|
|
|
{ name: 'y', min: 0, max: 2 },
|
|
|
|
|
|
]),
|
|
|
|
|
|
parts: {} as Record<string, Part>,
|
|
|
|
|
|
currentPlayer: 'X' as PlayerType,
|
|
|
|
|
|
winner: null as WinnerType,
|
|
|
|
|
|
turn: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const registration = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
|
|
|
|
|
|
export const registry = registration.registry;
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
registration.add('setup', async function () { /* ... */ });
|
|
|
|
|
|
registration.add('play <player> <row:number> <col:number>', async function (cmd) { /* ... */ });
|
2026-04-04 13:20:34 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
也可用 `createGameModule` 辅助函数包装:
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export const gameModule = createGameModule({
|
|
|
|
|
|
registry: registration.registry,
|
|
|
|
|
|
createInitialState,
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 定义游戏状态
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
建议用 `ReturnType` 推导状态类型:
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
export type GameState = ReturnType<typeof createInitialState>;
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
状态通常包含 Region、parts(`Record<string, Part>`)以及游戏专属字段(当前玩家、分数等)。
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
|
|
|
|
|
## 注册命令
|
|
|
|
|
|
|
|
|
|
|
|
使用 `registration.add()` 注册命令。Schema 字符串定义了命令格式:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
registration.add('play <player> <row:number> <col:number>', async function (cmd) {
|
|
|
|
|
|
const [player, row, col] = cmd.params as [PlayerType, number, number];
|
|
|
|
|
|
|
|
|
|
|
|
// this.context 是 MutableSignal<GameState>
|
|
|
|
|
|
this.context.produce(state => {
|
|
|
|
|
|
state.parts[piece.id] = piece;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { winner: null };
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Schema 语法
|
|
|
|
|
|
|
|
|
|
|
|
| 语法 | 含义 |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| `name` | 命令名 |
|
|
|
|
|
|
| `<param>` | 必填参数(字符串) |
|
|
|
|
|
|
| `<param:number>` | 必填参数(自动转为数字) |
|
|
|
|
|
|
| `[--flag]` | 可选标志 |
|
|
|
|
|
|
| `[-x:number]` | 可选选项(带类型) |
|
|
|
|
|
|
|
|
|
|
|
|
### 命令处理器中的 this
|
|
|
|
|
|
|
|
|
|
|
|
命令处理器中的 `this` 是 `CommandRunnerContext<MutableSignal<TState>>`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
registration.add('myCommand <arg>', async function (cmd) {
|
2026-04-04 15:27:37 +08:00
|
|
|
|
const state = this.context.value; // 读取状态
|
|
|
|
|
|
this.context.produce(d => { d.currentPlayer = 'O'; }); // 修改状态
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
const result = await this.prompt('confirm <action>', validator, currentPlayer);
|
2026-04-04 13:20:34 +08:00
|
|
|
|
const subResult = await this.run<{ score: number }>(`score ${player}`);
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
详见 [API 参考](./api-reference.md)。
|
|
|
|
|
|
|
2026-04-04 13:20:34 +08:00
|
|
|
|
## 使用 prompt 等待玩家输入
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
`this.prompt()` 暂停命令执行,等待外部通过 `host.onInput()` 提交输入:
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
|
|
|
|
|
```ts
|
2026-04-04 15:27:37 +08:00
|
|
|
|
const playCmd = await this.prompt(
|
|
|
|
|
|
'play <player> <row:number> <col:number>',
|
|
|
|
|
|
(command) => {
|
|
|
|
|
|
const [player, row, col] = command.params as [PlayerType, number, number];
|
|
|
|
|
|
if (player !== turnPlayer) return `Invalid player: ${player}`;
|
|
|
|
|
|
if (row < 0 || row > 2 || col < 0 || col > 2) return `Invalid position`;
|
|
|
|
|
|
if (isCellOccupied(this.context, row, col)) return `Cell occupied`;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
},
|
|
|
|
|
|
this.context.value.currentPlayer
|
|
|
|
|
|
);
|
2026-04-04 13:20:34 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
验证函数返回 `null` 表示有效,返回 `string` 表示错误信息。验证通过后 `playCmd` 是已解析的命令对象。
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
## 使用 setup 驱动游戏循环
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
`setup` 作为入口点驱动游戏循环:
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
registration.add('setup', async function () {
|
|
|
|
|
|
const { context } = this;
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
const currentPlayer = context.value.currentPlayer;
|
|
|
|
|
|
const turnNumber = context.value.turn + 1;
|
2026-04-04 15:27:37 +08:00
|
|
|
|
const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`);
|
2026-04-04 13:20:34 +08:00
|
|
|
|
if (!turnOutput.success) throw new Error(turnOutput.error);
|
|
|
|
|
|
|
|
|
|
|
|
context.produce(state => {
|
|
|
|
|
|
state.winner = turnOutput.result.winner;
|
|
|
|
|
|
if (!state.winner) {
|
|
|
|
|
|
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
|
|
|
|
|
state.turn = turnNumber;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if (context.value.winner) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
return context.value;
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
## Part、Region 和 RNG
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
详见 [棋子、区域与 RNG](./parts-regions-rng.md)。
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
2026-04-04 15:27:37 +08:00
|
|
|
|
## 完整示例
|
2026-04-04 13:20:34 +08:00
|
|
|
|
|
|
|
|
|
|
参考 [`src/samples/tic-tac-toe.ts`](../src/samples/tic-tac-toe.ts),包含:
|
|
|
|
|
|
- 2D 棋盘区域
|
|
|
|
|
|
- 玩家轮流输入
|
|
|
|
|
|
- 胜负判定
|
|
|
|
|
|
- 完整的游戏循环
|