From 771afaa0535d7fda2b67c7ebf5ba8770ae0bdbf9 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 3 Apr 2026 16:02:05 +0800 Subject: [PATCH] docs: add usage docs --- AGENTS.md | 46 ++- docs/boardgame-core-guide.md | 646 +++++++++++++++++++++++++++++++++++ 2 files changed, 687 insertions(+), 5 deletions(-) create mode 100644 docs/boardgame-core-guide.md diff --git a/AGENTS.md b/AGENTS.md index 0ac16b0..0a08c7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,40 @@ A Phaser 3 framework for building web board games, built on top of `boardgame-co - **`packages/framework`** (`boardgame-phaser`) — Reusable library: reactive scenes, signal→Phaser bindings, input/command bridge, Preact UI components - **`packages/sample-game`** — Demo Tic-Tac-Toe game using the framework +## boardgame-core Usage + +For detailed boardgame-core API documentation and examples, see **[boardgame-core Guide](docs/boardgame-core-guide.md)**. + +Key concepts: +- **MutableSignal** — Reactive state container with `.value` and `.produce()` +- **Command System** — CLI-style parsing with schema validation and prompt support +- **Region System** — Spatial management with `createRegion()`, `applyAlign()`, `shuffle()`, `moveToRegion()` +- **Part System** — Game pieces with `createPart()`, `createPartPool()`, `flip()`, `roll()` +- **RNG** — Deterministic PRNG via `createRNG(seed)` for reproducible game states + +### Quick Example + +```ts +import { createGameCommandRegistry, createRegion, MutableSignal } from 'boardgame-core'; + +type GameState = { + board: Region; + parts: Part<{ player: 'X' | 'O' }>[]; + currentPlayer: 'X' | 'O'; +}; + +const registration = createGameCommandRegistry(); +export const registry = registration.registry; + +registration.add('place ', async function(cmd) { + const [row, col] = cmd.params as [number, number]; + this.context.produce(state => { + state.parts.push({ id: `p-${row}-${col}`, regionId: 'board', position: [row, col], player: state.currentPlayer }); + }); + return { success: true }; +}); +``` + ## Commands ### Root level @@ -25,18 +59,20 @@ pnpm --filter boardgame-phaser typecheck # tsc --noEmit ### Sample game (`packages/sample-game`) ```bash -pnpm --filter sample-game dev # Vite dev server +pnpm --filter sample-game dev # Vite dev server with HMR pnpm --filter sample-game build # tsc && vite build pnpm --filter sample-game preview # vite preview +pnpm --filter sample-game typecheck # tsc --noEmit (add to scripts first) ``` +**Note**: Sample game uses Tailwind CSS v4 with `@tailwindcss/vite` plugin and `@preact/preset-vite` for JSX transformation. + ### Dependency setup ```bash -# boardgame-core is a local dependency at ../boardgame-core -# After changes to boardgame-core, rebuild it and copy dist: +# boardgame-core is a local dependency via symlink (link:../../../boardgame-core) +# After changes to boardgame-core, simply rebuild it: cd ../boardgame-core && pnpm build -# Then copy dist into the pnpm symlink (pnpm file: links don't include dist/): -cp -r dist/* ../boardgame-phaser/node_modules/.pnpm/boardgame-core@file+..+boardgame-core_*/node_modules/boardgame-core/dist/ +# The symlink automatically resolves to the updated dist/ ``` ### Testing diff --git a/docs/boardgame-core-guide.md b/docs/boardgame-core-guide.md new file mode 100644 index 0000000..bfd81e7 --- /dev/null +++ b/docs/boardgame-core-guide.md @@ -0,0 +1,646 @@ +# boardgame-core 使用指南 + +## 概述 + +`boardgame-core` 是一个基于 Preact Signals 的桌游状态管理库,提供响应式状态、实体集合、空间区域系统和命令驱动的游戏循环。 + +## 核心概念 + +### 1. MutableSignal — 响应式状态容器 + +`MutableSignal` 扩展了 Preact Signal,增加了通过 Mutative 进行不可变状态更新的能力。 + +```ts +import { mutableSignal } from 'boardgame-core'; + +// 创建响应式状态 +const state = mutableSignal({ score: 0, players: [] }); + +// 读取状态 +console.log(state.value.score); + +// 更新状态 — 使用 produce 进行不可变更新 +state.produce(draft => { + draft.score += 10; + draft.players.push('Alice'); +}); +``` + +**关键 API:** +- `.value` — 访问当前状态值 +- `.produce(fn)` — 通过 Mutative 更新状态(类似 Immer 的 `produce`) + +### 3. Region System — 空间区域管理 + +`Region` 用于管理游戏部件的空间位置和分组。 + +```ts +import { createRegion, applyAlign, shuffle, moveToRegion } from 'boardgame-core'; + +// 创建区域 +const board = createRegion('board', [ + { name: 'x', min: 0, max: 7 }, + { name: 'y', min: 0, max: 7 }, +]); + +const hand = createRegion('hand', [ + { name: 'x', min: 0, max: 10, align: 'start' }, +]); + +// 区域属性: +// - id: 区域标识 +// - axes: 坐标轴定义(name, min, max, align) +// - childIds: 包含的部件 ID 列表 +// - partMap: 位置 → ID 映射 +``` + +**区域操作:** + +```ts +// 对齐/紧凑排列(根据 axis.align 自动调整位置) +applyAlign(hand, parts); + +// 随机打乱位置(需要 RNG) +shuffle(board, parts, rng); + +// 移动部件到目标区域 +moveToRegion(part, sourceRegion, targetRegion, [2, 3]); + +// 批量移动 +moveToRegionAll(parts, sourceRegion, targetRegion, positions); + +// 从区域移除部件 +removeFromRegion(part, region); +``` + +### 4. Part System — 游戏部件系统 + +`Part` 表示游戏中的一个部件(棋子、卡牌等)。 + +```ts +import { createPart, createParts, createPartPool, flip, flipTo, roll } from 'boardgame-core'; + +// 创建单个部件 +const piece = createPart( + { regionId: 'board', position: [0, 0], player: 'X' }, + 'piece-1' +); + +// 批量创建(生成 piece-pawn-0, piece-pawn-1, ...) +const pawns = createParts( + { regionId: 'supply', position: [0, 0] }, + 8, + 'piece-pawn' +); + +// 部件池(用于抽牌堆等) +const deck = createPartPool( + { regionId: 'deck', sides: 4 }, // template + 52, // count + 'card' // id prefix +); + +// 抽牌 +const card = deck.draw(); + +// 返回部件到池中 +deck.return(card); + +// 剩余数量 +console.log(deck.remaining()); + +// 合并多个牌池 +const merged = mergePartPools(deck1, deck2); +``` + +**部件属性:** +- `id` — 唯一标识 +- `sides` — 面数(如骰子 6 面,卡牌 2 面) +- `side` — 当前朝向(0-based 索引) +- `regionId` — 所属区域 +- `position` — 位置坐标(数组,长度 = axes 数量) +- `alignments` — 对齐方式列表 +- `alignment` — 当前对齐方式 +- `...TMeta` — 自定义元数据 + +**部件操作:** + +```ts +// 翻面(循环到下一面) +flip(card); + +// 翻到指定面 +flipTo(card, 1); + +// 随机面(使用 RNG) +roll(dice, rng); +``` + +**部件查询:** + +```ts +import { findPartById, isCellOccupied, getPartAtPosition } from 'boardgame-core'; + +// 按 ID 查找 +const piece = findPartById(parts, 'piece-1'); + +// 检查位置是否被占用 +if (isCellOccupied(parts, 'board', [2, 3])) { + // 位置已被占用 +} + +// 获取指定位置的部件 +const part = getPartAtPosition(parts, 'board', [2, 3]); +``` + +### 5. Command System — 命令系统 + +CLI 风格的命令解析、验证和执行系统。 + +#### 命令字符串格式 + +``` +commandName [--option value] [-s short] [--flag] +``` + +**示例:** +``` +place 2 3:number --force -x 10 +move card1 hand --type=kitten +turn X 1 +``` + +#### 定义命令注册表 + +```ts +import { createGameCommandRegistry, MutableSignal } from 'boardgame-core'; + +type GameState = { + board: Region; + parts: Part[]; + currentPlayer: 'X' | 'O'; + winner: string | null; +}; + +// 创建注册表 +const registration = createGameCommandRegistry(); +export const registry = registration.registry; + +// 注册命令(链式 API) +registration.add('place ', async function(cmd) { + const [row, col] = cmd.params as [number, number]; + const player = this.context.value.currentPlayer; + + // 更新状态 + this.context.produce(state => { + state.parts.push({ id: `piece-${row}-${col}`, regionId: 'board', position: [row, col], player }); + }); + + return { success: true, row, col }; +}); + +// 继续注册更多命令 +registration.add('turn ', async function(cmd) { + // ... +}); +``` + +#### 命令处理器的 `this` 上下文 + +命令处理函数中,`this` 是 `CommandRunnerContext`,提供: + +```ts +registration.add('my-command ', async function(cmd) { + // this.context — 游戏上下文(MutableSignal) + const state = this.context.value; + + // this.context.produce — 更新状态 + this.context.produce(draft => { + draft.score += 1; + }); + + // this.run — 运行子命令 + const result = await this.run(`place 2 3`); + if (result.success) { + console.log(result.result.row); + } + + // this.prompt — 等待玩家输入 + const playCmd = await this.prompt( + 'play ', + (command) => { + // 验证函数:返回 null 表示有效,返回 string 表示错误消息 + const [player, row, col] = command.params; + if (player !== state.currentPlayer) { + return `Invalid player: ${player}`; + } + return null; + } + ); + + return { success: true }; +}); +``` + +#### 运行命令 + +```ts +import { createGameContext } from 'boardgame-core'; + +const game = createGameContext(registry, createInitialState); + +// 运行命令(返回 Promise) +const result = await game.commands.run('place 2 3'); + +if (result.success) { + console.log('Command succeeded:', result.result); +} else { + console.error('Command failed:', result.error); +} +``` + +#### Prompt 系统 — 等待玩家输入 + +某些命令需要等待玩家输入(如选择落子位置)。使用 `this.prompt()` 和 `promptQueue`: + +```ts +// 方法 1:内部使用(命令处理器中) +registration.add('turn ', async function(cmd) { + const player = cmd.params[0] as string; + + // 等待玩家输入,带验证 + const playCmd = await this.prompt( + 'play ', + (command) => { + const [row, col] = command.params; + if (isCellOccupied(this.context.value.parts, 'board', [row, col])) { + return `Cell (${row}, ${col}) is occupied`; + } + return null; + } + ); + + // 继续处理... + const [row, col] = playCmd.params; + placePiece(this.context, row, col, player); +}); + +// 方法 2:外部使用(UI/网络层) +const game = createGameContext(registry, initialState); + +// 启动需要玩家输入的命令 +const runPromise = game.commands.run('turn X'); + +// 等待 prompt 事件 +const promptEvent = await game.commands.promptQueue.pop(); +console.log('Expected input:', promptEvent.schema); + +// 提交玩家输入 +const error = promptEvent.tryCommit('play 2 3'); +if (error) { + console.log('Invalid move:', error); + // 可以再次尝试 + const error2 = promptEvent.tryCommit('play 1 1'); +} else { + // 输入已接受,命令继续执行 + const result = await runPromise; +} + +// 或者取消 +promptEvent.cancel('player quit'); +``` + +### 6. Random Number Generation — 确定性随机数 + +使用 Mulberry32 算法提供可重现的随机数生成。 + +```ts +import { createRNG } from 'boardgame-core'; + +// 创建 RNG(可选种子) +const rng = createRNG(12345); + +// 获取 [0, 1) 随机数 +const r1 = rng.next(); + +// 获取 [0, max) 随机数 +const r2 = rng.next(100); + +// 获取 [0, max) 随机整数 +const dice = rng.nextInt(6); // 0-5 + +// 重新设置种子 +rng.setSeed(999); + +// 获取当前种子 +const seed = rng.getSeed(); +``` + +## 完整示例:井字棋 + +```ts +import { + createGameCommandRegistry, + createRegion, + createPart, + MutableSignal, + isCellOccupied as isCellOccupiedUtil, +} from 'boardgame-core'; + +const BOARD_SIZE = 3; + +type PlayerType = 'X' | 'O'; +type TicTacToePart = Part<{ player: PlayerType }>; + +export function createInitialState() { + return { + board: createRegion('board', [ + { name: 'x', min: 0, max: BOARD_SIZE - 1 }, + { name: 'y', min: 0, max: BOARD_SIZE - 1 }, + ]), + parts: [] as TicTacToePart[], + currentPlayer: 'X' as PlayerType, + winner: null as PlayerType | 'draw' | null, + turn: 0, + }; +} + +export type TicTacToeState = ReturnType; + +const registration = createGameCommandRegistry(); +export const registry = registration.registry; + +// 游戏主循环 +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: PlayerType | 'draw' | null}>( + `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; +}); + +// 单个回合 +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 >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) { + return `Invalid position: (${row}, ${col})`; + } + if (isCellOccupied(this.context, row, col)) { + return `Cell (${row}, ${col}) is occupied`; + } + return null; + } + ); + + const [player, row, col] = playCmd.params as [PlayerType, number, number]; + + // 放置棋子 + const piece = createPart( + { regionId: 'board', position: [row, col], player }, + `piece-${player}-${turnNumber}` + ); + this.context.produce(state => { + state.parts.push(piece); + state.board.childIds.push(piece.id); + state.board.partMap[`${row},${col}`] = piece.id; + }); + + // 检查胜负 + const winner = checkWinner(this.context); + if (winner) return { winner }; + if (turnNumber >= BOARD_SIZE * BOARD_SIZE) return { winner: 'draw' }; + + return { winner: null }; +}); + +function isCellOccupied(host: MutableSignal, row: number, col: number): boolean { + return isCellOccupiedUtil(host.value.parts, 'board', [row, col]); +} + +function checkWinner(host: MutableSignal): PlayerType | 'draw' | null { + // 实现胜负判断... + return null; +} +``` + +## 从模块创建游戏上下文 + +```ts +import { createGameContextFromModule } from 'boardgame-core'; +import * as ticTacToe from './tic-tac-toe'; + +const game = createGameContextFromModule(ticTacToe); +// 等同于: +// const game = createGameContext(ticTacToe.registry, ticTacToe.createInitialState()); +``` + +## API 参考 + +### 核心 + +| 导出 | 说明 | +|---|---| +| `IGameContext` | 游戏上下文接口(包含 state 和 commands) | +| `createGameContext(registry, initialState?)` | 创建游戏上下文实例 | +| `createGameContextFromModule(module)` | 从模块(registry + createInitialState)创建游戏上下文 | +| `createGameCommandRegistry()` | 创建命令注册表(带 `.add()` 链式 API) | + +### MutableSignal + +| 导出 | 说明 | +|---|---| +| `MutableSignal` | 响应式信号类型,扩展 Preact Signal | +| `mutableSignal(initial?)` | 创建 MutableSignal | + +### Part + +| 导出 | 说明 | +|---|---| +| `Part` | 部件类型 | +| `PartTemplate` | 部件模板(创建时排除 id) | +| `PartPool` | 部件池(draw/return/remaining) | +| `createPart(template, id)` | 创建单个部件 | +| `createParts(template, count, idPrefix)` | 批量创建部件 | +| `createPartPool(template, count, idPrefix)` | 创建部件池 | +| `mergePartPools(...pools)` | 合并部件池 | +| `findPartById(parts, id)` | 按 ID 查找 | +| `isCellOccupied(parts, regionId, position)` | 检查位置占用 | +| `getPartAtPosition(parts, regionId, position)` | 获取位置上的部件 | +| `flip(part)` | 翻面 | +| `flipTo(part, side)` | 翻到指定面 | +| `roll(part, rng)` | 随机面 | + +### 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)` | 移除部件 | + +### Command + +| 导出 | 说明 | +|---|---| +| `Command` | 命令对象(name, params, options, flags) | +| `CommandSchema` | 命令模式定义 | +| `CommandResult` | 命令结果(成功/失败联合类型) | +| `parseCommand(input)` | 解析命令字符串 | +| `parseCommandSchema(schema)` | 解析模式字符串 | +| `validateCommand(cmd, schema)` | 验证命令 | +| `parseCommandWithSchema(cmd, schema)` | 解析并验证 | +| `createCommandRegistry()` | 创建命令注册表 | +| `registerCommand(registry, runner)` | 注册命令 | +| `hasCommand(registry, name)` | 检查命令是否存在 | +| `runCommand(registry, context, input)` | 解析并运行命令 | +| `PromptEvent` | Prompt 事件(tryCommit/cancel) | +| `CommandRunnerContext` | 命令运行器上下文 | + +### Utilities + +| 导出 | 说明 | +|---|---| +| `createRNG(seed?)` | 创建随机数生成器 | +| `Mulberry32RNG` | Mulberry32 PRNG 类 | +| `AsyncQueue` | 异步队列(用于 promptQueue) | + +## 最佳实践 + +### 状态更新 + +- **总是使用 `produce()`** 更新状态,不要直接修改 `.value` +- `produce()` 内部是 draft 模式,可以直接修改属性 + +```ts +// ✅ 正确 +state.produce(draft => { + draft.score += 1; + draft.parts.push(newPart); +}); + +// ❌ 错误 — 会破坏响应式 +state.value.score = 10; +``` + +### 命令设计 + +- **命令应该是纯操作**:只修改状态,不处理 UI +- **使用 schema 验证**:在命令定义中声明参数类型 +- **使用 prompt 处理玩家输入**:不要假设输入顺序 + +```ts +// ✅ 好的命令设计 +registration.add('move ', async function(cmd) { + // 只负责移动逻辑 +}); + +// ❌ 避免在命令中处理 UI +registration.add('show-alert', async function(cmd) { + alert('Hello'); // 不要这样做 +}); +``` + +### 错误处理 + +- **命令返回 `CommandResult`**:使用 `{ success, result/error }` 模式 +- **使用 try/catch 包装外部调用**:捕获错误并返回失败结果 + +```ts +registration.add('risky-op', async function(cmd) { + try { + // 可能失败的操作 + const data = await fetchSomething(); + return { success: true, data }; + } catch (e) { + const error = e as Error; + return { success: false, error: error.message }; + } +}); +``` + +### Prompt 验证器 + +- **验证器返回 `null` 表示有效** +- **验证器返回 `string` 表示错误消息** + +```ts +const cmd = await this.prompt( + 'play ', + (command) => { + const [row] = command.params; + if (row < 0 || row > 2) { + return `Row must be 0-2, got ${row}`; + } + return null; // 验证通过 + } +); +``` + +### 确定性游戏 + +- **使用固定种子的 RNG**:保证游戏可重现 +- **所有随机操作都通过 RNG**:不要用 `Math.random()` + +```ts +const rng = createRNG(12345); // 固定种子 +shuffle(deck, parts, rng); // 使用 RNG 打乱 +roll(dice, rng); // 使用 RNG 掷骰子 +``` + +## 与 boardgame-phaser 集成 + +在 boardgame-phaser 中使用 boardgame-core: + +```ts +import { createGameContextFromModule } from 'boardgame-core'; +import { ReactiveScene } from 'boardgame-phaser'; +import * as ticTacToe from './tic-tac-toe'; + +class TicTacToeScene extends ReactiveScene { + protected onStateReady() { + // 初始化 Phaser 对象 + } + + protected setupBindings() { + // 绑定信号到 Phaser 对象 + bindSignal(this, () => this.gameContext.state.value.winner, (winner) => { + if (winner) this.showWinMessage(winner); + }); + } +} + +// 创建场景 +const gameContext = createGameContextFromModule(ticTacToe); +const scene = new TicTacToeScene(gameContext); +```