boardgame-core/.qwen/skills/create-game-module/SKILL.md

6.5 KiB
Raw Blame History

name description
Create Game Module Create a runnable logic module for a board game with 'boardgame-core', '@preact/signals' and 'mutative'

如何编写游戏模组

要求

游戏模组需要导出以下接口:

import { IGameContext, createGameCommandRegistry } from '@/index';

// 定义类型
export type GameState = {
    //...
};
export type Game = IGameContext<GameState>;

// 创建 mutative 游戏初始状态
export function createInitialState(): GameState {
    //...
}

// 创建命令注册表(可选)
export const registry = createGameCommandRegistry<GameState>();

// 运行游戏
export async function start(game: Game) {
    // ...
}

或者导出为 GameModule 对象:

import { GameModule } from '@/index';

export const gameModule: GameModule<GameState> = {
    registry,
    createInitialState,
    start,
};

流程

0. 确认规则

规则应当存放在 rule.md

描述一个桌面游戏的以下要素:

  • 主题:游戏的世界观和背景
  • 配件:棋子、卡牌、骰子等物理组件
  • 游戏布置形式:棋盘、版图、卡牌放置区等
  • 游戏流程:回合结构、阶段划分
  • 玩家行动:每回合玩家可以做什么
  • 胜利条件与终局结算:如何判定胜负

1. 创建类型

创建 types.ts 并导出游戏所用的类型。

  • 为游戏概念创建字符串枚举类型(如 PlayerType = 'X' | 'O'
  • 使用 Part<TMeta> 为游戏配件创建对象类型
  • 使用 Region 为游戏区域创建容器类型
  • 设计游戏的全局状态类型

游戏使用 mutative 不可变类型驱动。状态类型必须是可序列化的(不支持函数、MapSet 等)。

import { Part, Region } from '@/index';

export type PlayerType = 'X' | 'O';

export type PieceMeta = {
    owner: PlayerType;
};

export type Piece = Part<PieceMeta>;

export type GameState = {
    board: Region;
    pieces: Record<string, Piece>;
    currentPlayer: PlayerType;
    turn: number;
    winner: PlayerType | null;
};

2. 创建游戏配件表

若某种游戏配件数量较大可使用csv文件进行配置否则在代码中inline创建。

csv文件遵循以下要求

  • #开头的内容会被当作注释忽略
  • 第二行为数据类型,会用于生成.d.ts文件
  • 可以有空行
# parts.csv
type,player,count
string,string,int
kitten,white,8
kitten,black,8
cat,white,8
cat,black,8
- ```

```typescript
import parts from "./parts.csv";
const pieces = createPartsFromTable(
    parts,
    (item, index) => `${item.player}-${item.type}-${index + 1}`,
    (item) => item.count
) as Record<string, BoopPart>;

3. 创建 Prompts

使用 prompt 来描述需要玩家进行的行动命令 schema。

  • prompt 包含一个 schema 和若干参数
  • 每个参数通常指定某个配件 ID、某个枚举字符串、或者某个数字
  • 参数类型必须是原始类型(stringnumber)或字符串枚举
import { createPromptDef } from '@/index';

export const prompts = {
    play: createPromptDef<[PlayerType, number, number]>(
        'play <player> <row:number> <col:number>',
        '选择下子位置'
    ),
};

Prompt schema 语法:

  • <param> - 必需参数
  • [param] - 可选参数
  • [param:type] - 带类型验证的参数(如 [count:number]

3. 创建游戏流程

游戏主循环负责协调游戏进程、等待玩家输入、更新状态。

export async function start(game: Game) {
    while (true) {
        // game.value 可获取当前的全局状态
        const currentPlayer = game.value.currentPlayer;
        const turnNumber = game.value.turn + 1;
        const turnOutput = await turn(game, currentPlayer, turnNumber);

        // 更新状态
        await game.produceAsync((state) => {
            state.winner = turnOutput.winner;
            if (!state.winner) {
                state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
                state.turn = turnNumber;
            }
        });

        // 检查游戏结束条件
        if (game.value.winner) break;
    }

    return game.value;
}

回合逻辑示例:

async function turn(game: Game, turnPlayer: PlayerType, turnNumber: number) {
    // 获取玩家输入
    const { player, row, col } = await game.prompt(
        prompts.play,
        (player, row, col) => {
            if (player !== turnPlayer) {
                throw `无效的玩家: ${player}。应为 ${turnPlayer}。`;
            } else if (!isValidMove(row, col)) {
                throw `无效位置: (${row}, ${col})。必须在 0 到 ${BOARD_SIZE - 1} 之间。`;
            } else if (isCellOccupied(game, row, col)) {
                throw `格子 (${row}, ${col}) 已被占用。`;
            } else {
                return { player, row, col };
            }
        },
        game.value.currentPlayer
    );

    // 执行放置逻辑
    placePiece(game, row, col, turnPlayer);

    // 返回回合结果
    return { winner: checkWinner(game) };
}

注意事项:

  • game.produce(fn) 用于同步更新状态
  • game.produceAsync(fn) 用于异步更新状态(会等待中断 Promise 完成,适用于播放动画)
  • 验证器函数中抛出字符串错误会返回给玩家,玩家可重新输入
  • 循环必须有明确的退出条件,避免无限循环
  • 玩家取消输入时,game.prompt() 会抛出异常,需要适当处理

4. 创建测试

基于 createGameContext 来测试游戏逻辑。

  • 为每种游戏结束条件准备至少一条测试
  • 为每种玩家行动准备至少一条测试
  • 使用 createTestContext()createTestRegion() 测试辅助函数
import { createGameContext } from '@/core/game';
import { createInitialState, registry, start } from './my-game';

describe('My Game', () => {
    it('should detect horizontal win for X', async () => {
        const ctx = createGameContext({
            initialState: createInitialState(),
            registry,
        });

        // 执行一系列操作
        await ctx.run('play X 0 0');
        await ctx.run('play O 1 0');
        await ctx.run('play X 0 1');
        await ctx.run('play O 1 1');
        await ctx.run('play X 0 2');

        expect(ctx.value.winner).toBe('X');
    });
});

完整示例

参考 src/samples/boop/ 获取完整的boop游戏实现。

相关资源