docs: update readme

This commit is contained in:
hypercross 2026-04-04 13:05:08 +08:00
parent 22d40fdc50
commit ceaf4e8ded
1 changed files with 438 additions and 216 deletions

654
README.md
View File

@ -1,311 +1,533 @@
# boardgame-core # boardgame-core
A state management library for board games using [Preact Signals](https://preactjs.com/guide/v10/signals/). 基于 [Preact Signals](https://preactjs.com/guide/v10/signals/) 的桌游状态管理库。
Build turn-based board games with reactive state, entity collections, spatial regions, and a command-driven game loop. 使用响应式状态、实体集合、空间区域和命令驱动的游戏循环来构建回合制桌游。
## Features ## 特性
- **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/) - **响应式状态管理**:基于 [@preact/signals-core](https://preactjs.com/guide/v10/signals/) 的细粒度响应
- **Type Safe**: Full TypeScript support with strict mode and generic context extension - **类型安全**:完整的 TypeScript 支持,启用严格模式和泛型上下文扩展
- **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling - **区域系统**:支持多轴定位、对齐和洗牌的空间管理
- **Command System**: CLI-style command parsing with schema validation, type coercion, and prompt support - **命令系统**CLI 风格的命令解析,带 schema 校验、类型转换和玩家输入提示
- **Game Lifecycle Management**: `GameHost` class provides clean setup/reset/dispose lifecycle for game sessions - **游戏生命周期管理**`GameHost` 类提供清晰的游戏设置/重置/销毁生命周期
- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states - **确定性 RNG**Mulberry32 种子伪随机数生成器,用于可复现的游戏状态
## Installation ## 安装
```bash ```bash
npm install boardgame-core npm install boardgame-core
``` ```
## Writing a Game ---
### Defining a Game ## 使用 GameHost
Each game defines a command registry and exports a `createInitialState` function: `GameHost` 是游戏运行的核心容器,负责管理游戏状态、命令执行和玩家交互的生命周期。
### 创建 GameHost
通过 `createGameHost` 传入一个 GameModule 来创建:
```ts ```ts
import { import { createGameHost } from 'boardgame-core';
createGameCommandRegistry, import * as tictactoe from 'boardgame-core/samples/tic-tac-toe';
Region,
createRegion,
} from 'boardgame-core';
// 1. Define your game-specific state const host = createGameHost(tictactoe);
type MyGameState = { ```
board: Region;
parts: Record<string, { id: string; regionId: string; position: number[] }>;
score: { white: number; black: number };
currentPlayer: 'white' | 'black';
winner: 'white' | 'black' | 'draw' | null;
};
// 2. Create initial state factory ### 响应式状态
export function createInitialState(): MyGameState {
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 { return {
board: createRegion('board', [ board: createRegion('board', [
{ name: 'x', min: 0, max: 5 }, { name: 'x', min: 0, max: 2 },
{ name: 'y', min: 0, max: 5 }, { name: 'y', min: 0, max: 2 },
]), ]),
parts: {}, parts: {} as Record<string, Part>,
score: { white: 0, black: 0 }, currentPlayer: 'X' as PlayerType,
currentPlayer: 'white', winner: null as WinnerType,
winner: null, turn: 0,
}; };
} }
// 3. Create registry and register commands // 2. 创建命令注册表并注册命令
const registration = createGameCommandRegistry<MyGameState>(); const registration = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
export const registry = registration.registry; export const registry = registration.registry;
registration.add('place <row:number> <col:number>', async function(cmd) { // 注册命令
const [row, col] = cmd.params as [number, number]; registration.add('setup', async function () {
const player = this.context.value.currentPlayer; // ... 命令逻辑
});
// Mutate state via produce() registration.add('play <player> <row:number> <col:number>', 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<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 字符串定义了命令格式:
```ts
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 => { this.context.produce(state => {
state.score[player] += 1; state.parts[piece.id] = piece;
}); });
return { winner: null };
});
```
#### Schema 语法
| 语法 | 含义 |
|---|---|
| `name` | 命令名 |
| `<param>` | 必填参数(字符串) |
| `<param:number>` | 必填参数(自动转为数字) |
| `[--flag]` | 可选标志 |
| `[-x:number]` | 可选选项(带类型) |
#### 命令处理器中的 this
命令处理器中的 `this``CommandRunnerContext<MutableSignal<TState>>`
```ts
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 }; return { success: true };
}); });
``` ```
### Running a Game ### 使用 prompt 等待玩家输入
`this.prompt()` 是处理玩家输入的核心方法。它会暂停命令执行,等待外部通过 `host.onInput()` 提交输入:
```ts ```ts
import { createGameContext } from 'boardgame-core'; registration.add('turn <player> <turn:number>', async function (cmd) {
import { registry, createInitialState } from './my-game'; const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
const game = createGameContext(registry, createInitialState); // 等待玩家输入
const playCmd = await this.prompt(
'play <player> <row:number> <col:number>', // 期望的输入格式
(command) => {
const [player, row, col] = command.params as [PlayerType, number, number];
// Run commands through the context // 验证逻辑
const result = await game.commands.run('place 2 3'); if (player !== turnPlayer) {
if (result.success) { return `Invalid player: ${player}`;
console.log(result.result); }
} 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`;
}
// Access reactive game state return null; // 验证通过
console.log(game.state.value.score.white); },
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) };
});
``` ```
### Handling Player Input 验证函数返回 `null` 表示输入有效,返回 `string` 表示错误信息。
Commands can prompt for player input using `this.prompt()`. Use `promptQueue.pop()` to wait for prompt events: ### 使用 setup 命令驱动游戏循环
`setup` 命令通常作为游戏的入口点,负责驱动整个游戏循环:
```ts ```ts
import { createGameContext } from 'boardgame-core'; registration.add('setup', async function () {
import { registry, createInitialState } from './my-game'; const { context } = this;
const game = createGameContext(registry, createInitialState); while (true) {
const currentPlayer = context.value.currentPlayer;
const turnNumber = context.value.turn + 1;
// Start a command that will prompt for input // 运行回合命令
const runPromise = game.commands.run('turn X 1'); const turnOutput = await this.run<{ winner: WinnerType }>(
`turn ${currentPlayer} ${turnNumber}`
);
if (!turnOutput.success) throw new Error(turnOutput.error);
// Wait for the prompt event // 更新状态
const promptEvent = await game.commands.promptQueue.pop(); context.produce(state => {
console.log(promptEvent.schema.name); // e.g. 'play' state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.turn = turnNumber;
}
});
// Validate and submit player input // 游戏结束条件
const error = promptEvent.tryCommit('play X 1 2'); if (context.value.winner) break;
}
if (error) { return context.value;
console.log('Invalid move:', error); });
// tryCommit can be called again with corrected input
} else {
// Input accepted, command continues
const result = await runPromise;
}
``` ```
If the player needs to cancel instead of committing: ### 使用 Part 和 Region
#### 创建和放置 Part
```ts ```ts
promptEvent.cancel('player quit'); import { createPart, createRegion, moveToRegion } from 'boardgame-core';
```
### Managing Game Lifecycle with GameHost // 创建区域
const board = createRegion('board', [
{ name: 'row', min: 0, max: 2 },
{ name: 'col', min: 0, max: 2 },
]);
For a cleaner game lifecycle (setup, reset, dispose), use `GameHost`: // 创建棋子
const piece = createPart<{ owner: string }>(
```ts { regionId: 'board', position: [1, 1], owner: 'white' },
import { createGameHost } from 'boardgame-core'; 'piece-1'
import { registry, createInitialState } from './my-game';
// Create a game host with a setup command
const host = createGameHost(
{ registry, createInitialState },
'setup' // command to run when setting up/resetting the game
); );
// Reactive state — use in effects or computed values // 放入状态
console.log(host.state.value.currentPlayer); state.produce(draft => {
console.log(host.status.value); // 'created' | 'running' | 'disposed' draft.parts[piece.id] = piece;
draft.board.childIds.push(piece.id);
// Check if a prompt is active and what schema it expects draft.board.partMap['1,1'] = piece.id;
const schema = host.activePromptSchema.value; });
if (schema) {
console.log('Waiting for:', schema.name, schema.params);
}
// Submit player input to the active prompt
const error = host.onInput('play X 1 2');
if (error) {
console.log('Invalid move:', error);
}
// Reset the game (cancels active prompt, resets state, runs setup)
await host.setup('setup');
// Dispose when done (cleans up listeners, cancels active prompts)
host.dispose();
``` ```
The `GameHost` provides: #### 创建 Part 池
- **`state`**: `ReadonlySignal<TState>` — reactive game state
- **`status`**: `ReadonlySignal<'created' | 'running' | 'disposed'>` — lifecycle status
- **`activePromptSchema`**: `ReadonlySignal<CommandSchema | null>` — reactive current prompt schema
- **`onInput(input)`**: Submit input to active prompt, returns error or null
- **`setup(command)`**: Reset and reinitialize the game
- **`dispose()`**: Clean up all resources
- **`on(event, listener)`**: Listen to `'setup'` or `'dispose'` events
## Sample Games
### Tic-Tac-Toe
The simplest example. Shows the basic command loop, 2D board regions, and win detection.
See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts).
### Boop
A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules.
See [`src/samples/boop/index.ts`](src/samples/boop/index.ts).
## Region System
```ts ```ts
import { applyAlign, shuffle, moveToRegion } from 'boardgame-core'; import { createPartPool } from 'boardgame-core';
// Compact cards in a hand towards the start // 从池中抽取
applyAlign(handRegion, parts); const pool = createPartPool<{ type: string }>(
{ regionId: 'supply', type: 'kitten' },
10,
'kitten'
);
// Shuffle positions of all parts in a region const piece = pool.draw(); // 取出一个
shuffle(handRegion, parts, rng); pool.return(piece); // 放回
pool.remaining(); // 剩余数量
// Move a part from one region to another
moveToRegion(part, sourceRegion, targetRegion, [0, 0]);
``` ```
## Command Parsing #### 区域操作
```ts ```ts
import { parseCommand, parseCommandSchema, validateCommand } from 'boardgame-core'; import { applyAlign, shuffle, moveToRegion, isCellOccupied } from 'boardgame-core';
// Parse a command string // 检查格子是否被占用
const cmd = parseCommand('move card1 hand --force -x 10'); if (isCellOccupied(state.parts, 'board', [1, 1])) { ... }
// { name: 'move', params: ['card1', 'hand'], flags: { force: true }, options: { x: '10' } }
// Define and validate against a schema // 对齐排列(紧凑排列)
const schema = parseCommandSchema('move <from> <to> [--force] [-x: number]'); applyAlign(handRegion, state.parts);
const result = validateCommand(cmd, schema);
// { valid: true } // 打乱位置
shuffle(deckRegion, state.parts, rng);
// 移动到其他区域
moveToRegion(piece, sourceRegion, targetRegion, [0, 0]);
``` ```
## Random Number Generation ### 使用 RNG
```ts ```ts
import { createRNG } from 'boardgame-core'; import { createRNG } from 'boardgame-core';
const rng = createRNG(12345); const rng = createRNG(12345); // 种子
rng.nextInt(6); // 0-5 rng.nextInt(6); // 0-5
rng.next(); // [0, 1) rng.next(); // [0, 1)
rng.next(100); // [0, 100)
rng.setSeed(999); // reseed
``` ```
## API Reference ### 完整示例:井字棋
### Core 参考 [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts),包含:
- 2D 棋盘区域
- 玩家轮流输入
- 胜负判定
- 完整的游戏循环
| Export | Description | ---
## API 参考
### 核心
| 导出 | 说明 |
|---|---| |---|---|
| `IGameContext` | Base interface for the game context (state, commands) | | `IGameContext` | 游戏上下文基础接口 |
| `createGameContext(registry, initialState?)` | Create a game context instance. `initialState` can be an object or factory function | | `createGameContext(registry, initialState?)` | 创建游戏上下文实例 |
| `createGameCommandRegistry<TState>()` | Create a command registry with fluent `.add()` API | | `createGameCommandRegistry<TState>()` | 创建命令注册表,返回带 `.add()` 的对象 |
| `GameHost<TState>` | Game lifecycle manager class with setup/reset/dispose | | `createGameModule(module)` | 辅助函数,标记一个对象为 GameModule |
| `createGameHost(module, setupCommand, options?)` | Create a GameHost instance from a game module | | `GameHost<TState>` | 游戏生命周期管理类 |
| `GameHostStatus` | Type: `'created' \| 'running' \| 'disposed'` | | `createGameHost(module, options?)` | 从 GameModule 创建 GameHost |
| `GameHostOptions` | Options: `{ autoStart?: boolean }` | | `GameHostStatus` | 类型: `'created' \| 'running' \| 'disposed'` |
### Parts ### 棋子 (Parts)
| Export | Description | | 导出 | 说明 |
|---|---| |---|---|
| `Part<TMeta>` | Type representing a game piece with sides, position, and region. `TMeta` for game-specific fields | | `Part<TMeta>` | 游戏棋子类型 |
| `PartTemplate<TMeta>` | Template type for creating parts (excludes `id`, requires metadata) | | `PartTemplate<TMeta>` | 创建棋子的模板类型 |
| `PartPool<TMeta>` | Pool of parts with `draw()`, `return()`, and `remaining()` methods. `parts` field is `Record<string, Part>` | | `PartPool<TMeta>` | 棋子池 |
| `createPart(template, id)` | Create a single part from a template | | `createPart(template, id)` | 创建单个棋子 |
| `createParts(template, count, idPrefix)` | Create multiple identical parts with auto-generated IDs | | `createParts(template, count, idPrefix)` | 批量创建相同棋子 |
| `createPartPool(template, count, idPrefix)` | Create a pool of parts for lazy loading | | `createPartPool(template, count, idPrefix)` | 创建棋子池 |
| `mergePartPools(...pools)` | Merge multiple part pools into one | | `mergePartPools(...pools)` | 合并多个棋子池 |
| `findPartById(parts, id)` | Find a part by ID in a Record | | `findPartById(parts, id)` | 按 ID 查找棋子 |
| `isCellOccupied(parts, regionId, position)` | Check if a cell is occupied | | `isCellOccupied(parts, regionId, position)` | 检查格子是否被占用 |
| `getPartAtPosition(parts, regionId, position)` | Get the part at a specific position | | `flip(part)` / `flipTo(part, side)` / `roll(part, rng)` | 翻面/随机面 |
| `flip(part)` | Cycle to the next side |
| `flipTo(part, side)` | Set to a specific side |
| `roll(part, rng)` | Randomize side using RNG |
### Regions ### 区域 (Regions)
| Export | Description | | 导出 | 说明 |
|---|---| |---|---|
| `Region` | Type for spatial grouping of parts with axis-based positioning | | `Region` / `RegionAxis` | 区域类型 |
| `RegionAxis` | Axis definition with min/max/align | | `createRegion(id, axes)` | 创建区域 |
| `createRegion(id, axes)` | Create a new region | | `applyAlign(region, parts)` | 紧凑排列 |
| `applyAlign(region, parts)` | Compact parts according to axis alignment | | `shuffle(region, parts, rng)` | 打乱位置 |
| `shuffle(region, parts, rng)` | Randomize part positions | | `moveToRegion(part, sourceRegion?, targetRegion, position?)` | 移动棋子到其他区域 |
| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | Move a part to another region. `sourceRegion` is optional for first placement | | `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | 批量移动 |
| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | Move multiple parts to another region. `parts` is `Record<string, Part>`. `sourceRegion` is optional for first placement | | `removeFromRegion(part, region)` | 从区域移除棋子 |
| `removeFromRegion(part, region)` | Remove a part from its region |
### Commands ### 命令 (Commands)
| Export | Description | | 导出 | 说明 |
|---|---| |---|---|
| `parseCommand(input)` | Parse a command string into a `Command` object | | `parseCommand(input)` | 解析命令字符串 |
| `parseCommandSchema(schema)` | Parse a schema string into a `CommandSchema` | | `parseCommandSchema(schema)` | 解析 Schema 字符串 |
| `validateCommand(cmd, schema)` | Validate a command against a schema | | `validateCommand(cmd, schema)` | 验证命令 |
| `parseCommandWithSchema(cmd, schema)` | Parse and validate in one step | | `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
| `applyCommandSchema(cmd, schema)` | Apply schema validation and return validated command | | `PromptEvent` | 玩家输入提示事件 |
| `createCommandRegistry<TContext>()` | Create a new command registry | | `createRNG(seed?)` | 创建种子 RNG |
| `registerCommand(registry, runner)` | Register a command runner |
| `unregisterCommand(registry, name)` | Remove a command from the registry |
| `hasCommand(registry, name)` | Check if a command exists |
| `getCommand(registry, name)` | Get a command runner by name |
| `runCommand(registry, context, input)` | Parse and run a command string |
| `runCommandParsed(registry, context, command)` | Run a pre-parsed command |
| `createCommandRunnerContext(registry, context)` | Create a command runner context |
| `PromptEvent` | Event dispatched when a command prompts for input |
| `CommandRunnerEvents` | Event types: `prompt` (when prompt starts), `promptEnd` (when prompt completes) |
### Utilities ---
| Export | Description | ## 脚本
|---|---|
| `createRNG(seed?)` | Create a seeded RNG instance |
| `Mulberry32RNG` | Mulberry32 PRNG class |
## Scripts
```bash ```bash
npm run build # Build ESM bundle + declarations to dist/ npm run build # 构建 ESM bundle + 类型声明到 dist/
npm run test # Run tests in watch mode npm run test # 以 watch 模式运行测试
npm run test:run # Run tests once npm run test:run # 运行测试一次
npm run typecheck # Type check with TypeScript npm run typecheck # TypeScript 类型检查
``` ```
## License ## License