board game state management
Go to file
hypercross ceaf4e8ded docs: update readme 2026-04-04 13:05:08 +08:00
src refactor: remove setup command from host creation 2026-04-04 12:48:51 +08:00
tests refactor: remove setup command from host creation 2026-04-04 12:48:51 +08:00
.gitignore Initial commit: boardgame-core with build fixes 2026-03-31 18:01:57 +08:00
AGENTS.md docs: update docs 2026-04-04 01:03:12 +08:00
README.md docs: update readme 2026-04-04 13:05:08 +08:00
package-lock.json chore: switch to npm link based dependencies 2026-04-02 21:07:43 +08:00
package.json chore: switch to npm link based dependencies 2026-04-02 21:07:43 +08:00
tsconfig.json refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00
tsup.config.ts refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00
vitest.config.ts refactor: add src alias to @/ 2026-04-02 16:03:44 +08:00

README.md

boardgame-core

基于 Preact Signals 的桌游状态管理库。

使用响应式状态、实体集合、空间区域和命令驱动的游戏循环来构建回合制桌游。

特性

  • 响应式状态管理:基于 @preact/signals-core 的细粒度响应
  • 类型安全:完整的 TypeScript 支持,启用严格模式和泛型上下文扩展
  • 区域系统:支持多轴定位、对齐和洗牌的空间管理
  • 命令系统CLI 风格的命令解析,带 schema 校验、类型转换和玩家输入提示
  • 游戏生命周期管理GameHost 类提供清晰的游戏设置/重置/销毁生命周期
  • 确定性 RNGMulberry32 种子伪随机数生成器,用于可复现的游戏状态

安装

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() 创建的空间区域
  • 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 棋盘区域
  • 玩家轮流输入
  • 胜负判定
  • 完整的游戏循环

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