boardgame-core/docs/game-module.md

7.6 KiB
Raw Blame History

编写 GameModule

GameModule 是定义游戏逻辑的模块,包含状态定义和命令注册。

GameModule 结构

一个 GameModule 必须导出两个东西:

import { createGameCommandRegistry, createRegion } from 'boardgame-core';

// 1. 定义游戏状态
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,
    };
}

// 2. 创建命令注册表并注册命令
const registration = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
export const registry = registration.registry;

// 注册命令
registration.add('setup', async function () {
    // ... 命令逻辑
});

registration.add('play <player> <row:number> <col:number>', async function (cmd) {
    // ... 命令逻辑
});

也可以使用 createGameModule 辅助函数:

import { createGameModule, createGameCommandRegistry, createRegion } from 'boardgame-core';

export const gameModule = createGameModule({
    registry: registration.registry,
    createInitialState,
});

定义游戏状态

游戏状态是一个普通对象,通过 createInitialState() 工厂函数创建。建议使用 ReturnType 推导类型:

export function createInitialState() {
    return {
        board: createRegion('board', [
            { name: 'x', min: 0, max: 2 },
            { name: 'y', min: 0, max: 2 },
        ]),
        parts: {} as Record<string, TicTacToePart>,
        currentPlayer: 'X' as PlayerType,
        winner: null as WinnerType,
        turn: 0,
    };
}
export type GameState = ReturnType<typeof createInitialState>;

状态通常包含:

  • Region:用 createRegion() 创建的空间区域
  • partsRecord<string, Part> 游戏棋子集合
  • 游戏特有的字段:当前玩家、分数、回合数等

注册命令

使用 registration.add() 注册命令。Schema 字符串定义了命令格式:

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

命令处理器中的 thisCommandRunnerContext<MutableSignal<TState>>

registration.add('myCommand <arg>', async function (cmd) {
    // 读取状态
    const state = this.context.value;

    // 修改状态
    this.context.produce(draft => {
        draft.currentPlayer = 'O';
    });

    // 提示玩家输入
    const result = await this.prompt(
        'confirm <action>',
        (command) => {
            // 验证函数:返回 null 表示有效,返回 string 表示错误信息
            return null;
        },
        this.context.value.currentPlayer  // currentPlayer 参数可选
    );

    // 调用子命令
    const subResult = await this.run<{ score: number }>(`score ${player}`);
    if (subResult.success) {
        console.log(subResult.result.score);
    }

    // 返回命令结果
    return { success: true };
});

使用 prompt 等待玩家输入

this.prompt() 是处理玩家输入的核心方法。它会暂停命令执行,等待外部通过 host.onInput() 提交输入:

registration.add('turn <player> <turn:number>', async function (cmd) {
    const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];

    // 等待玩家输入
    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: (${row}, ${col})`;
            }
            if (isCellOccupied(this.context, row, col)) {
                return `Cell (${row}, ${col}) is already occupied`;
            }

            return null; // 验证通过
        },
        this.context.value.currentPlayer  // 可选:标记当前等待的玩家
    );

    // 验证通过后playCmd 是已解析的命令对象
    const [player, row, col] = playCmd.params as [PlayerType, number, number];

    // 执行放置
    placePiece(this.context, row, col, player);

    return { winner: checkWinner(this.context) };
});

验证函数返回 null 表示输入有效,返回 string 表示错误信息。

使用 setup 命令驱动游戏循环

setup 命令通常作为游戏的入口点,负责驱动整个游戏循环:

registration.add('setup', async function () {
    const { context } = this;

    while (true) {
        const currentPlayer = context.value.currentPlayer;
        const turnNumber = context.value.turn + 1;

        // 运行回合命令
        const turnOutput = await this.run<{ winner: WinnerType }>(
            `turn ${currentPlayer} ${turnNumber}`
        );
        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;
});

使用 Part 和 Region

创建和放置 Part

import { createPart, createRegion, moveToRegion } from 'boardgame-core';

// 创建区域
const board = createRegion('board', [
    { name: 'row', min: 0, max: 2 },
    { name: 'col', min: 0, max: 2 },
]);

// 创建棋子
const piece = createPart<{ owner: string }>(
    { regionId: 'board', position: [1, 1], owner: 'white' },
    'piece-1'
);

// 放入状态
state.produce(draft => {
    draft.parts[piece.id] = piece;
    draft.board.childIds.push(piece.id);
    draft.board.partMap['1,1'] = piece.id;
});

创建 Part 池

import { createPartPool } from 'boardgame-core';

// 从池中抽取
const pool = createPartPool<{ type: string }>(
    { regionId: 'supply', type: 'kitten' },
    10,
    'kitten'
);

const piece = pool.draw();       // 取出一个
pool.return(piece);              // 放回
pool.remaining();                // 剩余数量

区域操作

import { applyAlign, shuffle, moveToRegion, isCellOccupied } from 'boardgame-core';

// 检查格子是否被占用
if (isCellOccupied(state.parts, 'board', [1, 1])) { ... }

// 对齐排列(紧凑排列)
applyAlign(handRegion, state.parts);

// 打乱位置
shuffle(deckRegion, state.parts, rng);

// 移动到其他区域
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);

使用 RNG

import { createRNG } from 'boardgame-core';

const rng = createRNG(12345);  // 种子
rng.nextInt(6);                // 0-5
rng.next();                    // [0, 1)

完整示例:井字棋

参考 src/samples/tic-tac-toe.ts,包含:

  • 2D 棋盘区域
  • 玩家轮流输入
  • 胜负判定
  • 完整的游戏循环