14 KiB
boardgame-core
基于 Preact Signals 的桌游状态管理库。
使用响应式状态、实体集合、空间区域和命令驱动的游戏循环来构建回合制桌游。
特性
- 响应式状态管理:基于 @preact/signals-core 的细粒度响应
- 类型安全:完整的 TypeScript 支持,启用严格模式和泛型上下文扩展
- 区域系统:支持多轴定位、对齐和洗牌的空间管理
- 命令系统:CLI 风格的命令解析,带 schema 校验、类型转换和玩家输入提示
- 游戏生命周期管理:
GameHost类提供清晰的游戏设置/重置/销毁生命周期 - 确定性 RNG:Mulberry32 种子伪随机数生成器,用于可复现的游戏状态
安装
npm install boardgame-core
使用 GameHost
GameHost 是游戏运行的核心容器,负责管理游戏状态、命令执行和玩家交互的生命周期。
创建 GameHost
通过 createGameHost 传入一个 GameModule 来创建:
import { createGameHost } from 'boardgame-core';
import * as tictactoe from 'boardgame-core/samples/tic-tac-toe';
const host = createGameHost(tictactoe);
响应式状态
GameHost 暴露的所有属性都是响应式 Signal,可以直接用于 UI 渲染或 effect():
import { effect } from '@preact/signals-core';
// 游戏状态
effect(() => {
console.log(host.state.value.currentPlayer);
console.log(host.state.value.winner);
});
// 生命周期状态: 'created' | 'running' | 'disposed'
effect(() => {
console.log('Status:', host.status.value);
});
// 当前等待的玩家输入 schema
effect(() => {
const schema = host.activePromptSchema.value;
if (schema) {
console.log('Waiting for:', schema.name, schema.params);
}
});
// 当前等待的玩家
effect(() => {
console.log('Current player prompt:', host.activePromptPlayer.value);
});
启动游戏
调用 setup() 并传入初始化命令名来启动游戏:
await host.setup('setup');
这会重置游戏状态、取消当前活动提示、运行指定的 setup 命令,并将状态设为 'running'。
处理玩家输入
当命令通过 this.prompt() 等待玩家输入时,使用 onInput() 提交输入:
// 提交玩家操作,返回错误信息或 null
const error = host.onInput('play X 1 2');
if (error) {
console.log('输入无效:', error);
// 玩家可以重新输入
} else {
// 输入已被接受,命令继续执行
}
监听事件
// 监听游戏设置完成
host.on('setup', () => {
console.log('Game initialized');
});
// 监听游戏销毁
host.on('dispose', () => {
console.log('Game disposed');
});
// on() 返回取消订阅函数
const unsubscribe = host.on('setup', handler);
unsubscribe(); // 取消监听
重新开始游戏
// 取消当前命令,重置状态,重新运行 setup 命令
await host.setup('setup');
销毁游戏
host.dispose();
销毁后会取消所有活动命令、清理事件监听器,并将状态设为 'disposed'。销毁后无法再次使用。
完整示例
import { effect } from '@preact/signals-core';
import { createGameHost } from 'boardgame-core';
import * as tictactoe from 'boardgame-core/samples/tic-tac-toe';
const host = createGameHost(tictactoe);
// 监听状态变化
effect(() => {
const state = host.state.value;
console.log(`${state.currentPlayer}'s turn (turn ${state.turn + 1})`);
if (state.winner) {
console.log('Winner:', state.winner);
}
});
// 启动游戏
await host.setup('setup');
// 游戏循环:等待提示 → 提交输入
while (host.status.value === 'running' && host.activePromptSchema.value) {
const schema = host.activePromptSchema.value!;
console.log('Waiting for input:', schema.name);
// 这里可以从 UI/网络等获取输入
const input = await getPlayerInput();
const error = host.onInput(input);
if (error) {
console.log('Invalid:', error);
}
}
// 游戏结束后可以重新开始
// await host.setup('setup');
// 或彻底销毁
// host.dispose();
编写 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()创建的空间区域 - parts:
Record<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
命令处理器中的 this 是 CommandRunnerContext<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 棋盘区域
- 玩家轮流输入
- 胜负判定
- 完整的游戏循环
API 参考
核心
| 导出 | 说明 |
|---|---|
IGameContext |
游戏上下文基础接口 |
createGameContext(registry, initialState?) |
创建游戏上下文实例 |
createGameCommandRegistry<TState>() |
创建命令注册表,返回带 .add() 的对象 |
createGameModule(module) |
辅助函数,标记一个对象为 GameModule |
GameHost<TState> |
游戏生命周期管理类 |
createGameHost(module, options?) |
从 GameModule 创建 GameHost |
GameHostStatus |
类型: 'created' | 'running' | 'disposed' |
棋子 (Parts)
| 导出 | 说明 |
|---|---|
Part<TMeta> |
游戏棋子类型 |
PartTemplate<TMeta> |
创建棋子的模板类型 |
PartPool<TMeta> |
棋子池 |
createPart(template, id) |
创建单个棋子 |
createParts(template, count, idPrefix) |
批量创建相同棋子 |
createPartPool(template, count, idPrefix) |
创建棋子池 |
mergePartPools(...pools) |
合并多个棋子池 |
findPartById(parts, id) |
按 ID 查找棋子 |
isCellOccupied(parts, regionId, position) |
检查格子是否被占用 |
flip(part) / flipTo(part, side) / roll(part, rng) |
翻面/随机面 |
区域 (Regions)
| 导出 | 说明 |
|---|---|
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) |
从区域移除棋子 |
命令 (Commands)
| 导出 | 说明 |
|---|---|
parseCommand(input) |
解析命令字符串 |
parseCommandSchema(schema) |
解析 Schema 字符串 |
validateCommand(cmd, schema) |
验证命令 |
Command / CommandSchema / CommandResult |
命令相关类型 |
PromptEvent |
玩家输入提示事件 |
createRNG(seed?) |
创建种子 RNG |
脚本
npm run build # 构建 ESM bundle + 类型声明到 dist/
npm run test # 以 watch 模式运行测试
npm run test:run # 运行测试一次
npm run typecheck # TypeScript 类型检查
License
MIT