diff --git a/README.md b/README.md index 62d4f7d..c677389 100644 --- a/README.md +++ b/README.md @@ -1,311 +1,533 @@ # 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/) -- **Type Safe**: Full TypeScript support with strict mode and generic context extension -- **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 -- **Game Lifecycle Management**: `GameHost` class provides clean setup/reset/dispose lifecycle for game sessions -- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states +- **响应式状态管理**:基于 [@preact/signals-core](https://preactjs.com/guide/v10/signals/) 的细粒度响应 +- **类型安全**:完整的 TypeScript 支持,启用严格模式和泛型上下文扩展 +- **区域系统**:支持多轴定位、对齐和洗牌的空间管理 +- **命令系统**:CLI 风格的命令解析,带 schema 校验、类型转换和玩家输入提示 +- **游戏生命周期管理**:`GameHost` 类提供清晰的游戏设置/重置/销毁生命周期 +- **确定性 RNG**:Mulberry32 种子伪随机数生成器,用于可复现的游戏状态 -## Installation +## 安装 ```bash 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 -import { - createGameCommandRegistry, - Region, - createRegion, -} from 'boardgame-core'; +import { createGameHost } from 'boardgame-core'; +import * as tictactoe from 'boardgame-core/samples/tic-tac-toe'; -// 1. Define your game-specific state -type MyGameState = { - board: Region; - parts: Record; - score: { white: number; black: number }; - currentPlayer: 'white' | 'black'; - winner: 'white' | 'black' | 'draw' | null; -}; +const host = createGameHost(tictactoe); +``` -// 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 { board: createRegion('board', [ - { name: 'x', min: 0, max: 5 }, - { name: 'y', min: 0, max: 5 }, + { name: 'x', min: 0, max: 2 }, + { name: 'y', min: 0, max: 2 }, ]), - parts: {}, - score: { white: 0, black: 0 }, - currentPlayer: 'white', - winner: null, + parts: {} as Record, + currentPlayer: 'X' as PlayerType, + winner: null as WinnerType, + turn: 0, }; } -// 3. Create registry and register commands -const registration = createGameCommandRegistry(); +// 2. 创建命令注册表并注册命令 +const registration = createGameCommandRegistry>(); export const registry = registration.registry; -registration.add('place ', async function(cmd) { - const [row, col] = cmd.params as [number, number]; - const player = this.context.value.currentPlayer; +// 注册命令 +registration.add('setup', async function () { + // ... 命令逻辑 +}); - // Mutate state via produce() +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.score[player] += 1; + 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 }; }); ``` -### Running a Game +### 使用 prompt 等待玩家输入 + +`this.prompt()` 是处理玩家输入的核心方法。它会暂停命令执行,等待外部通过 `host.onInput()` 提交输入: ```ts -import { createGameContext } from 'boardgame-core'; -import { registry, createInitialState } from './my-game'; +registration.add('turn ', async function (cmd) { + const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; -const game = createGameContext(registry, createInitialState); + // 等待玩家输入 + const playCmd = await this.prompt( + 'play ', // 期望的输入格式 + (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 (result.success) { - console.log(result.result); -} + // 验证逻辑 + 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`; + } -// Access reactive game state -console.log(game.state.value.score.white); + 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) }; +}); ``` -### 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 -import { createGameContext } from 'boardgame-core'; -import { registry, createInitialState } from './my-game'; +registration.add('setup', async function () { + 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(); -console.log(promptEvent.schema.name); // e.g. 'play' + // 更新状态 + context.produce(state => { + 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) { - console.log('Invalid move:', error); - // tryCommit can be called again with corrected input -} else { - // Input accepted, command continues - const result = await runPromise; -} + return context.value; +}); ``` -If the player needs to cancel instead of committing: +### 使用 Part 和 Region + +#### 创建和放置 Part ```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`: - -```ts -import { createGameHost } from 'boardgame-core'; -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 +// 创建棋子 +const piece = createPart<{ owner: string }>( + { regionId: 'board', position: [1, 1], owner: 'white' }, + 'piece-1' ); -// Reactive state — use in effects or computed values -console.log(host.state.value.currentPlayer); -console.log(host.status.value); // 'created' | 'running' | 'disposed' - -// Check if a prompt is active and what schema it expects -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(); +// 放入状态 +state.produce(draft => { + draft.parts[piece.id] = piece; + draft.board.childIds.push(piece.id); + draft.board.partMap['1,1'] = piece.id; +}); ``` -The `GameHost` provides: -- **`state`**: `ReadonlySignal` — reactive game state -- **`status`**: `ReadonlySignal<'created' | 'running' | 'disposed'>` — lifecycle status -- **`activePromptSchema`**: `ReadonlySignal` — 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 +#### 创建 Part 池 ```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 -shuffle(handRegion, parts, rng); - -// Move a part from one region to another -moveToRegion(part, sourceRegion, targetRegion, [0, 0]); +const piece = pool.draw(); // 取出一个 +pool.return(piece); // 放回 +pool.remaining(); // 剩余数量 ``` -## Command Parsing +#### 区域操作 ```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'); -// { name: 'move', params: ['card1', 'hand'], flags: { force: true }, options: { x: '10' } } +// 检查格子是否被占用 +if (isCellOccupied(state.parts, 'board', [1, 1])) { ... } -// Define and validate against a schema -const schema = parseCommandSchema('move [--force] [-x: number]'); -const result = validateCommand(cmd, schema); -// { valid: true } +// 对齐排列(紧凑排列) +applyAlign(handRegion, state.parts); + +// 打乱位置 +shuffle(deckRegion, state.parts, rng); + +// 移动到其他区域 +moveToRegion(piece, sourceRegion, targetRegion, [0, 0]); ``` -## Random Number Generation +### 使用 RNG ```ts import { createRNG } from 'boardgame-core'; -const rng = createRNG(12345); -rng.nextInt(6); // 0-5 -rng.next(); // [0, 1) -rng.next(100); // [0, 100) -rng.setSeed(999); // reseed +const rng = createRNG(12345); // 种子 +rng.nextInt(6); // 0-5 +rng.next(); // [0, 1) ``` -## 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) | -| `createGameContext(registry, initialState?)` | Create a game context instance. `initialState` can be an object or factory function | -| `createGameCommandRegistry()` | Create a command registry with fluent `.add()` API | -| `GameHost` | Game lifecycle manager class with setup/reset/dispose | -| `createGameHost(module, setupCommand, options?)` | Create a GameHost instance from a game module | -| `GameHostStatus` | Type: `'created' \| 'running' \| 'disposed'` | -| `GameHostOptions` | Options: `{ autoStart?: boolean }` | +| `IGameContext` | 游戏上下文基础接口 | +| `createGameContext(registry, initialState?)` | 创建游戏上下文实例 | +| `createGameCommandRegistry()` | 创建命令注册表,返回带 `.add()` 的对象 | +| `createGameModule(module)` | 辅助函数,标记一个对象为 GameModule | +| `GameHost` | 游戏生命周期管理类 | +| `createGameHost(module, options?)` | 从 GameModule 创建 GameHost | +| `GameHostStatus` | 类型: `'created' \| 'running' \| 'disposed'` | -### Parts +### 棋子 (Parts) -| Export | Description | +| 导出 | 说明 | |---|---| -| `Part` | Type representing a game piece with sides, position, and region. `TMeta` for game-specific fields | -| `PartTemplate` | Template type for creating parts (excludes `id`, requires metadata) | -| `PartPool` | Pool of parts with `draw()`, `return()`, and `remaining()` methods. `parts` field is `Record` | -| `createPart(template, id)` | Create a single part from a template | -| `createParts(template, count, idPrefix)` | Create multiple identical parts with auto-generated IDs | -| `createPartPool(template, count, idPrefix)` | Create a pool of parts for lazy loading | -| `mergePartPools(...pools)` | Merge multiple part pools into one | -| `findPartById(parts, id)` | Find a part by ID in a Record | -| `isCellOccupied(parts, regionId, position)` | Check if a cell is occupied | -| `getPartAtPosition(parts, regionId, position)` | Get the part at a specific position | -| `flip(part)` | Cycle to the next side | -| `flipTo(part, side)` | Set to a specific side | -| `roll(part, rng)` | Randomize side using RNG | +| `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 +### 区域 (Regions) -| Export | Description | +| 导出 | 说明 | |---|---| -| `Region` | Type for spatial grouping of parts with axis-based positioning | -| `RegionAxis` | Axis definition with min/max/align | -| `createRegion(id, axes)` | Create a new region | -| `applyAlign(region, parts)` | Compact parts according to axis alignment | -| `shuffle(region, parts, rng)` | Randomize part positions | -| `moveToRegion(part, sourceRegion?, targetRegion, position?)` | Move a part to another region. `sourceRegion` is optional for first placement | -| `moveToRegionAll(parts, sourceRegion?, targetRegion, positions?)` | Move multiple parts to another region. `parts` is `Record`. `sourceRegion` is optional for first placement | -| `removeFromRegion(part, region)` | Remove a part from its 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)` | 从区域移除棋子 | -### Commands +### 命令 (Commands) -| Export | Description | +| 导出 | 说明 | |---|---| -| `parseCommand(input)` | Parse a command string into a `Command` object | -| `parseCommandSchema(schema)` | Parse a schema string into a `CommandSchema` | -| `validateCommand(cmd, schema)` | Validate a command against a schema | -| `parseCommandWithSchema(cmd, schema)` | Parse and validate in one step | -| `applyCommandSchema(cmd, schema)` | Apply schema validation and return validated command | -| `createCommandRegistry()` | Create a new command registry | -| `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) | +| `parseCommand(input)` | 解析命令字符串 | +| `parseCommandSchema(schema)` | 解析 Schema 字符串 | +| `validateCommand(cmd, schema)` | 验证命令 | +| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 | +| `PromptEvent` | 玩家输入提示事件 | +| `createRNG(seed?)` | 创建种子 RNG | -### Utilities +--- -| Export | Description | -|---|---| -| `createRNG(seed?)` | Create a seeded RNG instance | -| `Mulberry32RNG` | Mulberry32 PRNG class | - -## Scripts +## 脚本 ```bash -npm run build # Build ESM bundle + declarations to dist/ -npm run test # Run tests in watch mode -npm run test:run # Run tests once -npm run typecheck # Type check with TypeScript +npm run build # 构建 ESM bundle + 类型声明到 dist/ +npm run test # 以 watch 模式运行测试 +npm run test:run # 运行测试一次 +npm run typecheck # TypeScript 类型检查 ``` ## License