# boardgame-core 基于 [Preact Signals](https://preactjs.com/guide/v10/signals/) 的桌游状态管理库。 使用响应式状态、实体集合、空间区域和命令驱动的游戏循环来构建回合制桌游。 ## 特性 - **响应式状态管理**:基于 [@preact/signals-core](https://preactjs.com/guide/v10/signals/) 的细粒度响应 - **类型安全**:完整的 TypeScript 支持,启用严格模式和泛型上下文扩展 - **区域系统**:支持多轴定位、对齐和洗牌的空间管理 - **命令系统**:CLI 风格的命令解析,带 schema 校验、类型转换和玩家输入提示 - **游戏生命周期管理**:`GameHost` 类提供清晰的游戏设置/重置/销毁生命周期 - **确定性 RNG**:Mulberry32 种子伪随机数生成器,用于可复现的游戏状态 ## 安装 ```bash npm install boardgame-core ``` --- ## 使用 GameHost `GameHost` 是游戏运行的核心容器,负责管理游戏状态、命令执行和玩家交互的生命周期。 ### 创建 GameHost 通过 `createGameHost` 传入一个 GameModule 来创建: ```ts import { createGameHost } from 'boardgame-core'; import * as tictactoe from 'boardgame-core/samples/tic-tac-toe'; const host = createGameHost(tictactoe); ``` ### 响应式状态 GameHost 暴露的所有属性都是响应式 Signal,可以直接用于 UI 渲染或 `effect()`: ```ts 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()` 并传入初始化命令名来启动游戏: ```ts await host.setup('setup'); ``` 这会重置游戏状态、取消当前活动提示、运行指定的 setup 命令,并将状态设为 `'running'`。 ### 处理玩家输入 当命令通过 `this.prompt()` 等待玩家输入时,使用 `onInput()` 提交输入: ```ts // 提交玩家操作,返回错误信息或 null const error = host.onInput('play X 1 2'); if (error) { console.log('输入无效:', error); // 玩家可以重新输入 } else { // 输入已被接受,命令继续执行 } ``` ### 监听事件 ```ts // 监听游戏设置完成 host.on('setup', () => { console.log('Game initialized'); }); // 监听游戏销毁 host.on('dispose', () => { console.log('Game disposed'); }); // on() 返回取消订阅函数 const unsubscribe = host.on('setup', handler); unsubscribe(); // 取消监听 ``` ### 重新开始游戏 ```ts // 取消当前命令,重置状态,重新运行 setup 命令 await host.setup('setup'); ``` ### 销毁游戏 ```ts host.dispose(); ``` 销毁后会取消所有活动命令、清理事件监听器,并将状态设为 `'disposed'`。销毁后无法再次使用。 ### 完整示例 ```ts 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 必须导出两个东西: ```ts 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, currentPlayer: 'X' as PlayerType, winner: null as WinnerType, turn: 0, }; } // 2. 创建命令注册表并注册命令 const registration = createGameCommandRegistry>(); export const registry = registration.registry; // 注册命令 registration.add('setup', async function () { // ... 命令逻辑 }); registration.add('play ', async function (cmd) { // ... 命令逻辑 }); ``` 也可以使用 `createGameModule` 辅助函数: ```ts import { createGameModule, createGameCommandRegistry, createRegion } from 'boardgame-core'; export const gameModule = createGameModule({ registry: registration.registry, createInitialState, }); ``` ### 定义游戏状态 游戏状态是一个普通对象,通过 `createInitialState()` 工厂函数创建。建议使用 `ReturnType` 推导类型: ```ts export function createInitialState() { return { board: createRegion('board', [ { name: 'x', min: 0, max: 2 }, { name: 'y', min: 0, max: 2 }, ]), parts: {} as Record, currentPlayer: 'X' as PlayerType, winner: null as WinnerType, turn: 0, }; } export type GameState = ReturnType; ``` 状态通常包含: - **Region**:用 `createRegion()` 创建的空间区域 - **parts**:`Record` 游戏棋子集合 - 游戏特有的字段:当前玩家、分数、回合数等 ### 注册命令 使用 `registration.add()` 注册命令。Schema 字符串定义了命令格式: ```ts registration.add('play ', async function (cmd) { const [player, row, col] = cmd.params as [PlayerType, number, number]; // this.context 是 MutableSignal this.context.produce(state => { state.parts[piece.id] = piece; }); return { winner: null }; }); ``` #### Schema 语法 | 语法 | 含义 | |---|---| | `name` | 命令名 | | `` | 必填参数(字符串) | | `` | 必填参数(自动转为数字) | | `[--flag]` | 可选标志 | | `[-x:number]` | 可选选项(带类型) | #### 命令处理器中的 this 命令处理器中的 `this` 是 `CommandRunnerContext>`: ```ts registration.add('myCommand ', async function (cmd) { // 读取状态 const state = this.context.value; // 修改状态 this.context.produce(draft => { draft.currentPlayer = 'O'; }); // 提示玩家输入 const result = await this.prompt( 'confirm ', (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()` 提交输入: ```ts registration.add('turn ', async function (cmd) { const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; // 等待玩家输入 const playCmd = await this.prompt( 'play ', // 期望的输入格式 (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` 命令通常作为游戏的入口点,负责驱动整个游戏循环: ```ts 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 ```ts 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 池 ```ts 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(); // 剩余数量 ``` #### 区域操作 ```ts 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 ```ts import { createRNG } from 'boardgame-core'; const rng = createRNG(12345); // 种子 rng.nextInt(6); // 0-5 rng.next(); // [0, 1) ``` ### 完整示例:井字棋 参考 [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts),包含: - 2D 棋盘区域 - 玩家轮流输入 - 胜负判定 - 完整的游戏循环 --- ## API 参考 ### 核心 | 导出 | 说明 | |---|---| | `IGameContext` | 游戏上下文基础接口 | | `createGameContext(registry, initialState?)` | 创建游戏上下文实例 | | `createGameCommandRegistry()` | 创建命令注册表,返回带 `.add()` 的对象 | | `createGameModule(module)` | 辅助函数,标记一个对象为 GameModule | | `GameHost` | 游戏生命周期管理类 | | `createGameHost(module, options?)` | 从 GameModule 创建 GameHost | | `GameHostStatus` | 类型: `'created' \| 'running' \| 'disposed'` | ### 棋子 (Parts) | 导出 | 说明 | |---|---| | `Part` | 游戏棋子类型 | | `PartTemplate` | 创建棋子的模板类型 | | `PartPool` | 棋子池 | | `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 | --- ## 脚本 ```bash npm run build # 构建 ESM bundle + 类型声明到 dist/ npm run test # 以 watch 模式运行测试 npm run test:run # 运行测试一次 npm run typecheck # TypeScript 类型检查 ``` ## License MIT