boardgame-phaser/docs/boardgame-core-guide.md

17 KiB
Raw Blame History

boardgame-core 使用指南

概述

boardgame-core 是一个基于 Preact Signals 的桌游状态管理库,提供响应式状态、实体集合、空间区域系统和命令驱动的游戏循环。

核心概念

1. MutableSignal — 响应式状态容器

MutableSignal<T> 扩展了 Preact Signal增加了通过 Mutative 进行不可变状态更新的能力。

import { mutableSignal } from 'boardgame-core';

// 创建响应式状态
const state = mutableSignal({ score: 0, players: [] });

// 读取状态
console.log(state.value.score);

// 更新状态 — 使用 produce 进行不可变更新
state.produce(draft => {
    draft.score += 10;
    draft.players.push('Alice');
});

关键 API

  • .value — 访问当前状态值
  • .produce(fn) — 通过 Mutative 更新状态(类似 Immer 的 produce

3. Region System — 空间区域管理

Region 用于管理游戏部件的空间位置和分组。

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

// 创建区域
const board = createRegion('board', [
    { name: 'x', min: 0, max: 7 },
    { name: 'y', min: 0, max: 7 },
]);

const hand = createRegion('hand', [
    { name: 'x', min: 0, max: 10, align: 'start' },
]);

// 区域属性:
// - id: 区域标识
// - axes: 坐标轴定义name, min, max, align
// - childIds: 包含的部件 ID 列表
// - partMap: 位置 → ID 映射

区域操作:

// 对齐/紧凑排列(根据 axis.align 自动调整位置)
applyAlign(hand, parts);

// 随机打乱位置(需要 RNG
shuffle(board, parts, rng);

// 移动部件到目标区域
moveToRegion(part, sourceRegion, targetRegion, [2, 3]);

// 批量移动
moveToRegionAll(parts, sourceRegion, targetRegion, positions);

// 从区域移除部件
removeFromRegion(part, region);

4. Part System — 游戏部件系统

Part<TMeta> 表示游戏中的一个部件(棋子、卡牌等)。

import { createPart, createParts, createPartPool, flip, flipTo, roll } from 'boardgame-core';

// 创建单个部件
const piece = createPart(
    { regionId: 'board', position: [0, 0], player: 'X' },
    'piece-1'
);

// 批量创建(生成 piece-pawn-0, piece-pawn-1, ...
const pawns = createParts(
    { regionId: 'supply', position: [0, 0] },
    8,
    'piece-pawn'
);

// 部件池(用于抽牌堆等)
const deck = createPartPool(
    { regionId: 'deck', sides: 4 },  // template
    52,                               // count
    'card'                            // id prefix
);

// 抽牌
const card = deck.draw();

// 返回部件到池中
deck.return(card);

// 剩余数量
console.log(deck.remaining());

// 合并多个牌池
const merged = mergePartPools(deck1, deck2);

部件属性:

  • id — 唯一标识
  • sides — 面数(如骰子 6 面,卡牌 2 面)
  • side — 当前朝向0-based 索引)
  • regionId — 所属区域
  • position — 位置坐标(数组,长度 = axes 数量)
  • alignments — 对齐方式列表
  • alignment — 当前对齐方式
  • ...TMeta — 自定义元数据

部件操作:

// 翻面(循环到下一面)
flip(card);

// 翻到指定面
flipTo(card, 1);

// 随机面(使用 RNG
roll(dice, rng);

部件查询:

import { findPartById, isCellOccupied, getPartAtPosition } from 'boardgame-core';

// 按 ID 查找
const piece = findPartById(parts, 'piece-1');

// 检查位置是否被占用
if (isCellOccupied(parts, 'board', [2, 3])) {
    // 位置已被占用
}

// 获取指定位置的部件
const part = getPartAtPosition(parts, 'board', [2, 3]);

5. Command System — 命令系统

CLI 风格的命令解析、验证和执行系统。

命令字符串格式

commandName <param1> <param2:type> [--option value] [-s short] [--flag]

示例:

place 2 3:number --force -x 10
move card1 hand --type=kitten
turn X 1

定义命令注册表

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

type GameState = {
    board: Region;
    parts: Part[];
    currentPlayer: 'X' | 'O';
    winner: string | null;
};

// 创建注册表
const registration = createGameCommandRegistry<GameState>();
export const registry = registration.registry;

// 注册命令(链式 API
registration.add('place <row:number> <col:number>', async function(cmd) {
    const [row, col] = cmd.params as [number, number];
    const player = this.context.value.currentPlayer;

    // 更新状态
    this.context.produce(state => {
        state.parts.push({ id: `piece-${row}-${col}`, regionId: 'board', position: [row, col], player });
    });

    return { success: true, row, col };
});

// 继续注册更多命令
registration.add('turn <player> <turn:number>', async function(cmd) {
    // ...
});

命令处理器的 this 上下文

命令处理函数中,thisCommandRunnerContext,提供:

registration.add('my-command <arg>', async function(cmd) {
    // this.context — 游戏上下文MutableSignal<GameState>
    const state = this.context.value;
    
    // this.context.produce — 更新状态
    this.context.produce(draft => {
        draft.score += 1;
    });
    
    // this.run — 运行子命令
    const result = await this.run<PlaceResult>(`place 2 3`);
    if (result.success) {
        console.log(result.result.row);
    }
    
    // this.prompt — 等待玩家输入
    const playCmd = await this.prompt(
        'play <player> <row:number> <col:number>',
        (command) => {
            // 验证函数:返回 null 表示有效,返回 string 表示错误消息
            const [player, row, col] = command.params;
            if (player !== state.currentPlayer) {
                return `Invalid player: ${player}`;
            }
            return null;
        }
    );
    
    return { success: true };
});

运行命令

import { createGameContext } from 'boardgame-core';

const game = createGameContext(registry, createInitialState);

// 运行命令(返回 Promise<CommandResult>
const result = await game.commands.run('place 2 3');

if (result.success) {
    console.log('Command succeeded:', result.result);
} else {
    console.error('Command failed:', result.error);
}

Prompt 系统 — 等待玩家输入

某些命令需要等待玩家输入(如选择落子位置)。使用 this.prompt()promptQueue

// 方法 1内部使用命令处理器中
registration.add('turn <player>', async function(cmd) {
    const player = cmd.params[0] as string;
    
    // 等待玩家输入,带验证
    const playCmd = await this.prompt(
        'play <row:number> <col:number>',
        (command) => {
            const [row, col] = command.params;
            if (isCellOccupied(this.context.value.parts, 'board', [row, col])) {
                return `Cell (${row}, ${col}) is occupied`;
            }
            return null;
        }
    );
    
    // 继续处理...
    const [row, col] = playCmd.params;
    placePiece(this.context, row, col, player);
});

// 方法 2外部使用UI/网络层)
const game = createGameContext(registry, initialState);

// 启动需要玩家输入的命令
const runPromise = game.commands.run('turn X');

// 等待 prompt 事件
const promptEvent = await game.commands.promptQueue.pop();
console.log('Expected input:', promptEvent.schema);

// 提交玩家输入
const error = promptEvent.tryCommit('play 2 3');
if (error) {
    console.log('Invalid move:', error);
    // 可以再次尝试
    const error2 = promptEvent.tryCommit('play 1 1');
} else {
    // 输入已接受,命令继续执行
    const result = await runPromise;
}

// 或者取消
promptEvent.cancel('player quit');

6. Random Number Generation — 确定性随机数

使用 Mulberry32 算法提供可重现的随机数生成。

import { createRNG } from 'boardgame-core';

// 创建 RNG可选种子
const rng = createRNG(12345);

// 获取 [0, 1) 随机数
const r1 = rng.next();

// 获取 [0, max) 随机数
const r2 = rng.next(100);

// 获取 [0, max) 随机整数
const dice = rng.nextInt(6);  // 0-5

// 重新设置种子
rng.setSeed(999);

// 获取当前种子
const seed = rng.getSeed();

完整示例:井字棋

import {
    createGameCommandRegistry,
    createRegion,
    createPart,
    MutableSignal,
    isCellOccupied as isCellOccupiedUtil,
} from 'boardgame-core';

const BOARD_SIZE = 3;

type PlayerType = 'X' | 'O';
type TicTacToePart = Part<{ player: PlayerType }>;

export function createInitialState() {
    return {
        board: createRegion('board', [
            { name: 'x', min: 0, max: BOARD_SIZE - 1 },
            { name: 'y', min: 0, max: BOARD_SIZE - 1 },
        ]),
        parts: [] as TicTacToePart[],
        currentPlayer: 'X' as PlayerType,
        winner: null as PlayerType | 'draw' | null,
        turn: 0,
    };
}

export type TicTacToeState = ReturnType<typeof createInitialState>;

const registration = createGameCommandRegistry<TicTacToeState>();
export const registry = registration.registry;

// 游戏主循环
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: PlayerType | 'draw' | null}>(
            `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;
});

// 单个回合
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 >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
                return `Invalid position: (${row}, ${col})`;
            }
            if (isCellOccupied(this.context, row, col)) {
                return `Cell (${row}, ${col}) is occupied`;
            }
            return null;
        }
    );
    
    const [player, row, col] = playCmd.params as [PlayerType, number, number];
    
    // 放置棋子
    const piece = createPart(
        { regionId: 'board', position: [row, col], player },
        `piece-${player}-${turnNumber}`
    );
    this.context.produce(state => {
        state.parts.push(piece);
        state.board.childIds.push(piece.id);
        state.board.partMap[`${row},${col}`] = piece.id;
    });
    
    // 检查胜负
    const winner = checkWinner(this.context);
    if (winner) return { winner };
    if (turnNumber >= BOARD_SIZE * BOARD_SIZE) return { winner: 'draw' };
    
    return { winner: null };
});

function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
    return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
}

function checkWinner(host: MutableSignal<TicTacToeState>): PlayerType | 'draw' | null {
    // 实现胜负判断...
    return null;
}

从模块创建游戏上下文

import { createGameContextFromModule } from 'boardgame-core';
import * as ticTacToe from './tic-tac-toe';

const game = createGameContextFromModule(ticTacToe);
// 等同于:
// const game = createGameContext(ticTacToe.registry, ticTacToe.createInitialState());

API 参考

核心

导出 说明
IGameContext<TState> 游戏上下文接口(包含 state 和 commands
createGameContext(registry, initialState?) 创建游戏上下文实例
createGameContextFromModule(module) 从模块registry + createInitialState创建游戏上下文
createGameCommandRegistry<TState>() 创建命令注册表(带 .add() 链式 API

MutableSignal

导出 说明
MutableSignal<T> 响应式信号类型,扩展 Preact Signal
mutableSignal(initial?) 创建 MutableSignal

Part

导出 说明
Part<TMeta> 部件类型
PartTemplate<TMeta> 部件模板(创建时排除 id
PartPool<TMeta> 部件池draw/return/remaining
createPart(template, id) 创建单个部件
createParts(template, count, idPrefix) 批量创建部件
createPartPool(template, count, idPrefix) 创建部件池
mergePartPools(...pools) 合并部件池
findPartById(parts, id) 按 ID 查找
isCellOccupied(parts, regionId, position) 检查位置占用
getPartAtPosition(parts, regionId, position) 获取位置上的部件
flip(part) 翻面
flipTo(part, side) 翻到指定面
roll(part, rng) 随机面

Region

导出 说明
Region 区域类型
RegionAxis 坐标轴定义
createRegion(id, axes) 创建区域
applyAlign(region, parts) 对齐/紧凑排列
shuffle(region, parts, rng) 随机打乱位置
moveToRegion(part, sourceRegion?, targetRegion, position?) 移动部件
moveToRegionAll(parts, sourceRegion?, targetRegion, positions?) 批量移动
removeFromRegion(part, region) 移除部件

Command

导出 说明
Command 命令对象name, params, options, flags
CommandSchema 命令模式定义
CommandResult<T> 命令结果(成功/失败联合类型)
parseCommand(input) 解析命令字符串
parseCommandSchema(schema) 解析模式字符串
validateCommand(cmd, schema) 验证命令
parseCommandWithSchema(cmd, schema) 解析并验证
createCommandRegistry<TContext>() 创建命令注册表
registerCommand(registry, runner) 注册命令
hasCommand(registry, name) 检查命令是否存在
runCommand(registry, context, input) 解析并运行命令
PromptEvent Prompt 事件tryCommit/cancel
CommandRunnerContext<TContext> 命令运行器上下文

Utilities

导出 说明
createRNG(seed?) 创建随机数生成器
Mulberry32RNG Mulberry32 PRNG 类
AsyncQueue<T> 异步队列(用于 promptQueue

最佳实践

状态更新

  • 总是使用 produce() 更新状态,不要直接修改 .value
  • produce() 内部是 draft 模式,可以直接修改属性
// ✅ 正确
state.produce(draft => {
    draft.score += 1;
    draft.parts.push(newPart);
});

// ❌ 错误 — 会破坏响应式
state.value.score = 10;

命令设计

  • 命令应该是纯操作:只修改状态,不处理 UI
  • 使用 schema 验证:在命令定义中声明参数类型
  • 使用 prompt 处理玩家输入:不要假设输入顺序
// ✅ 好的命令设计
registration.add('move <from> <to>', async function(cmd) {
    // 只负责移动逻辑
});

// ❌ 避免在命令中处理 UI
registration.add('show-alert', async function(cmd) {
    alert('Hello');  // 不要这样做
});

错误处理

  • 命令返回 CommandResult:使用 { success, result/error } 模式
  • 使用 try/catch 包装外部调用:捕获错误并返回失败结果
registration.add('risky-op', async function(cmd) {
    try {
        // 可能失败的操作
        const data = await fetchSomething();
        return { success: true, data };
    } catch (e) {
        const error = e as Error;
        return { success: false, error: error.message };
    }
});

Prompt 验证器

  • 验证器返回 null 表示有效
  • 验证器返回 string 表示错误消息
const cmd = await this.prompt(
    'play <row:number>',
    (command) => {
        const [row] = command.params;
        if (row < 0 || row > 2) {
            return `Row must be 0-2, got ${row}`;
        }
        return null;  // 验证通过
    }
);

确定性游戏

  • 使用固定种子的 RNG:保证游戏可重现
  • 所有随机操作都通过 RNG:不要用 Math.random()
const rng = createRNG(12345);  // 固定种子
shuffle(deck, parts, rng);      // 使用 RNG 打乱
roll(dice, rng);                // 使用 RNG 掷骰子

与 boardgame-phaser 集成

在 boardgame-phaser 中使用 boardgame-core

import { createGameContextFromModule } from 'boardgame-core';
import { ReactiveScene } from 'boardgame-phaser';
import * as ticTacToe from './tic-tac-toe';

class TicTacToeScene extends ReactiveScene<typeof ticTacToe.createInitialState> {
    protected onStateReady() {
        // 初始化 Phaser 对象
    }
    
    protected setupBindings() {
        // 绑定信号到 Phaser 对象
        bindSignal(this, () => this.gameContext.state.value.winner, (winner) => {
            if (winner) this.showWinMessage(winner);
        });
    }
}

// 创建场景
const gameContext = createGameContextFromModule(ticTacToe);
const scene = new TicTacToeScene(gameContext);