2026-04-10 12:47:00 +08:00
|
|
|
|
---
|
|
|
|
|
|
name: Create Game Module
|
|
|
|
|
|
description: Create a runnable logic module for a board game with 'boardgame-core', '@preact/signals' and 'mutative'
|
|
|
|
|
|
---
|
|
|
|
|
|
# 如何编写游戏模组
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
## 必须导出的接口
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
import { IGameContext, createGameCommandRegistry } from '@/index';
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
export type GameState = { /* 你的状态类型 */ };
|
2026-04-10 12:47:00 +08:00
|
|
|
|
export type Game = IGameContext<GameState>;
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
export function createInitialState(): GameState { /* ... */ }
|
|
|
|
|
|
export async function start(game: Game) { /* 游戏主循环 */ }
|
2026-04-10 12:47:00 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
或导出为 `GameModule` 对象:
|
2026-04-10 12:47:00 +08:00
|
|
|
|
```typescript
|
2026-04-10 13:52:32 +08:00
|
|
|
|
export const gameModule: GameModule<GameState> = { registry, createInitialState, start };
|
2026-04-10 12:47:00 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 流程
|
|
|
|
|
|
|
|
|
|
|
|
### 0. 确认规则
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
在 `rule.md` 中描述:主题、配件、布置形式、游戏流程、玩家行动、胜利条件。
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
### 1. 创建类型 (`types.ts`)
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
- 使用字符串枚举表示游戏概念(如 `PlayerType = 'X' | 'O'`)
|
|
|
|
|
|
- 使用 `Part<TMeta>` 表示配件(棋子、卡牌等)
|
|
|
|
|
|
- 使用 `Region` 表示区域(棋盘、牌堆等)
|
|
|
|
|
|
- 状态必须可序列化(不支持函数、`Map`、`Set`)
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
import { Part, Region } from '@/index';
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
export type Piece = Part<{ owner: 'X' | 'O' }>;
|
2026-04-10 12:47:00 +08:00
|
|
|
|
export type GameState = {
|
|
|
|
|
|
board: Region;
|
|
|
|
|
|
pieces: Record<string, Piece>;
|
2026-04-10 13:52:32 +08:00
|
|
|
|
currentPlayer: 'X' | 'O';
|
2026-04-10 12:47:00 +08:00
|
|
|
|
turn: number;
|
2026-04-10 13:52:32 +08:00
|
|
|
|
winner: 'X' | 'O' | null;
|
2026-04-10 12:47:00 +08:00
|
|
|
|
};
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
### 2. 创建配件
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
少量配件直接在代码创建:
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
const pieces = createParts({ owner: 'X' }, (i) => `piece-${i}`, 5);
|
|
|
|
|
|
```
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
大量配件使用 CSV:
|
2026-04-10 12:47:00 +08:00
|
|
|
|
```csv
|
|
|
|
|
|
# parts.csv
|
|
|
|
|
|
type,player,count
|
|
|
|
|
|
string,string,int
|
|
|
|
|
|
kitten,white,8
|
2026-04-10 13:45:10 +08:00
|
|
|
|
```
|
2026-04-10 12:47:00 +08:00
|
|
|
|
```typescript
|
|
|
|
|
|
import parts from "./parts.csv";
|
2026-04-10 13:52:32 +08:00
|
|
|
|
const pieces = createPartsFromTable(parts, (item, i) => `${item.type}-${i}`, (item) => item.count);
|
2026-04-10 12:47:00 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3. 创建 Prompts
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
export const prompts = {
|
2026-04-10 13:52:32 +08:00
|
|
|
|
play: createPromptDef<['X' | 'O', number, number]>(
|
2026-04-10 12:47:00 +08:00
|
|
|
|
'play <player> <row:number> <col:number>',
|
|
|
|
|
|
'选择下子位置'
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
Schema 语法:`<param>` 必需,`[param]` 可选,`[param:type]` 带验证。详见 [API 参考](./references/api.md)。
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
### 4. 创建游戏流程
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
export async function start(game: Game) {
|
2026-04-10 13:52:32 +08:00
|
|
|
|
while (!game.value.winner) {
|
|
|
|
|
|
const { row, col } = await game.prompt(
|
|
|
|
|
|
prompts.play,
|
|
|
|
|
|
(player, row, col) => {
|
|
|
|
|
|
if (player !== game.value.currentPlayer) throw '无效玩家';
|
|
|
|
|
|
if (!isValidMove(row, col)) throw '无效位置';
|
|
|
|
|
|
if (isCellOccupied(game, row, col)) throw '位置已被占用';
|
|
|
|
|
|
return { row, col };
|
|
|
|
|
|
},
|
|
|
|
|
|
game.value.currentPlayer
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
game.produce((state) => {
|
|
|
|
|
|
state.pieces[`p-${row}-${col}`] = { /* ... */ };
|
2026-04-10 12:47:00 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
const winner = checkWinner(game);
|
|
|
|
|
|
if (winner) {
|
|
|
|
|
|
game.produce((state) => { state.winner = winner; });
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
game.produce((state) => {
|
|
|
|
|
|
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
|
|
|
|
|
state.turn++;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-10 12:47:00 +08:00
|
|
|
|
return game.value;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**注意事项:**
|
2026-04-10 13:52:32 +08:00
|
|
|
|
- `game.produce(fn)` 同步更新,`game.produceAsync(fn)` 异步更新(等待动画)
|
|
|
|
|
|
- 验证器抛出字符串表示失败,返回值表示成功
|
|
|
|
|
|
- 玩家取消时 `game.prompt()` 抛出异常
|
|
|
|
|
|
- 循环必须有明确退出条件
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
### 5. 创建测试
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
位于 `tests/samples/<game-name>.test.ts`:
|
2026-04-10 13:45:10 +08:00
|
|
|
|
|
2026-04-10 12:47:00 +08:00
|
|
|
|
```typescript
|
2026-04-10 13:45:10 +08:00
|
|
|
|
import { describe, it, expect } from 'vitest';
|
2026-04-10 12:47:00 +08:00
|
|
|
|
import { createGameContext } from '@/core/game';
|
2026-04-10 13:45:10 +08:00
|
|
|
|
import { registry, createInitialState } from './my-game';
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
|
|
|
|
|
describe('My Game', () => {
|
2026-04-10 13:45:10 +08:00
|
|
|
|
function createTestContext() {
|
|
|
|
|
|
return createGameContext(registry, createInitialState());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
it('should perform action correctly', async () => {
|
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
|
await game.run('play X 0 0');
|
|
|
|
|
|
expect(game.value.pieces['p-0-0']).toBeDefined();
|
2026-04-10 13:45:10 +08:00
|
|
|
|
});
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
it('should fail on invalid input', async () => {
|
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
|
const result = await game.run('invalid');
|
|
|
|
|
|
expect(result.success).toBe(false);
|
2026-04-10 13:45:10 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
it('should complete a full game cycle', async () => {
|
|
|
|
|
|
// 模拟完整游戏流程,验证胜利条件
|
2026-04-10 12:47:00 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
**要求:**
|
|
|
|
|
|
- 每种游戏结束条件、玩家行动、边界情况各至少一条测试
|
|
|
|
|
|
- 使用 `createGameContext(registry, initialState)` 创建上下文
|
|
|
|
|
|
- 使用 `game.run('command')` 执行命令,验证 `game.value` 状态
|
|
|
|
|
|
- 测试命名使用 `should...` 格式,异步测试用 `async/await`
|
2026-04-10 13:45:10 +08:00
|
|
|
|
|
|
|
|
|
|
**运行测试:**
|
|
|
|
|
|
```bash
|
2026-04-10 13:52:32 +08:00
|
|
|
|
npm run test:run # 所有测试
|
|
|
|
|
|
npx vitest run tests/samples/my-game.test.ts # 特定文件
|
|
|
|
|
|
npx vitest run -t "should perform" # 特定用例
|
2026-04-10 13:45:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-10 12:47:00 +08:00
|
|
|
|
## 完整示例
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
参考 `src/samples/boop/` 和 `src/samples/regicide/`。
|
2026-04-10 12:47:00 +08:00
|
|
|
|
|
|
|
|
|
|
## 相关资源
|
|
|
|
|
|
|
2026-04-10 13:52:32 +08:00
|
|
|
|
- [API 参考](./references/api.md) - 完整 API 文档
|
|
|
|
|
|
- [AGENTS.md](../../AGENTS.md) - 项目代码规范
|