17 KiB
17 KiB
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 上下文
命令处理函数中,this 是 CommandRunnerContext,提供:
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);