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

8.6 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
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. 创建测试

测试文件位于 tests/samples/ 目录下,命名格式为 <game-name>.test.ts

测试要求

覆盖范围:

  • 每种游戏结束条件至少一条测试
  • 每种玩家行动至少一条测试
  • 边界条件和异常情况至少一条测试
  • 胜利和失败场景各至少一条测试

测试结构:

import { describe, it, expect } from 'vitest';
import { createGameContext } from '@/core/game';
import { registry, createInitialState } from './my-game';

describe('My Game', () => {
    // 测试辅助函数
    function createTestContext() {
        return createGameContext(registry, createInitialState());
    }

    // 测试工具函数
    describe('Utils', () => {
        it('should calculate correct values', () => {
            // 测试纯函数逻辑
        });
    });

    // 测试命令
    describe('Commands', () => {
        it('should perform action correctly', async () => {
            const game = createTestContext();
            // 设置初始状态
            // 执行命令
            // 验证状态变化
        });

        it('should fail on invalid input', async () => {
            const game = createTestContext();
            // 测试错误输入
            const result = await game.run('invalid-command');
            expect(result.success).toBe(false);
        });
    });

    // 测试完整游戏流程
    describe('Game Flow', () => {
        it('should complete a full game cycle', async () => {
            // 模拟完整游戏流程
        });
    });
});

测试规范:

  • 使用 createGameContext(registry, initialState) 创建测试上下文
  • 使用 game.run('command args') 执行命令
  • 验证 game.value 的状态变化,而非命令返回值
  • 不要使用 console.log 或其他调试输出
  • 使用 describe 分组相关测试
  • 测试命名使用 should... 格式描述预期行为
  • 异步测试使用 async/await

命令测试示例:

it('should deal damage to enemy', async () => {
    const game = createTestContext();
    setupTestGame(game); // 自定义测试设置

    const enemyHpBefore = game.value.currentEnemy!.hp;
    await game.run('play player1 card_1');

    // 验证状态变化
    expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore);
    expect(game.value.playerHands.player1).not.toContain('card_1');
});

it('should fail if card not in hand', async () => {
    const game = createTestContext();
    setupTestGame(game);

    const result = await game.run('play player1 invalid_card');
    expect(result.success).toBe(false);
    if (!result.success) {
        expect(result.error).toContain('不存在');
    }
});

运行测试:

# 运行所有测试
npm run test:run

# 运行特定测试文件
npx vitest run tests/samples/my-game.test.ts

# 运行特定测试用例
npx vitest run -t "should deal damage" tests/samples/my-game.test.ts

完整示例

参考 src/samples/boop/ 获取完整的boop游戏实现。 参考 src/samples/regicide/ 获取卡牌游戏的完整示例。

相关资源