diff --git a/docs/animation-sync.md b/docs/animation-sync.md index 3c491af..2f496a7 100644 --- a/docs/animation-sync.md +++ b/docs/animation-sync.md @@ -1,6 +1,6 @@ # 动画与状态更新同步 -命令执行时,效应函数通过 `produce()` 立即更新状态,UI 层只能看到最终结果, +命令执行时,效应函数如果通过 `produce()` 立即更新状态,UI 层只能看到最终结果, 无法在中间插入动画。为了解决这个问题,`MutableSignal` 提供了动画中断机制。 ## 基本原理 @@ -14,33 +14,40 @@ ↓ (无 interruption, ↓ 等待 anim1 完成 ↓ 等待 anim2 完成 立即更新状态 1) -UI层: effect 检测到状态 1 变化 addInterruption(anim1) addInterruption(anim2) +UI层: effect 检测到状态 1 变化 effect 检测到状态 2 变化 effect 检测到状态 3 变化 播放动画 1 播放动画 2 播放动画 3 + addInterruption(anim1) addInterruption(anim2) addInterruption(anim3) ``` 1. 第一个 `produceAsync` 没有 interruption 可等,立即更新状态 2. UI 层通过 `effect` 检测到状态变化,播放动画并调用 `addInterruption` -3. 第二个 `produceAsync` 被前一步的 interruption 阻塞,等待动画完成后再更新 +3. 第二个 `produceAsync` 被前一步注册的 interruption 阻塞,等待动画完成后再更新状态 4. 依此类推,形成链式等待 ## 逻辑层:将 `produce` 替换为 `produceAsync` ```ts // 之前 -registration.add('turn ', async function(cmd) { - const playCmd = await this.prompt('play ', validator, currentPlayer); - - placePiece(this.context, row, col, pieceType); // 内部调用 produce - applyBoops(this.context, row, col, pieceType); // 内部调用 produce -}); +async function turn(game: BoopGame, turnPlayer: PlayerType) { + game.produce(state => { + game.scores[turnPlayer] ++; + }); + // 这里不能触发动画等待 + game.produce(state => { + game.currentPlayer = turnPlayer; + }); +}; // 之后:改为 produceAsync -registration.add('turn ', async function(cmd) { - const playCmd = await this.prompt('play ', validator, currentPlayer); - - await placePieceAsync(this.context, row, col, pieceType); // 内部改用 produceAsync - await applyBoopsAsync(this.context, row, col, pieceType); // 内部改用 produceAsync -}); +async function turn(game: BoopGame, turnPlayer: PlayerType) { + await game.produceAsync(state => { + game.scores[turnPlayer] ++; + }); + // 这里会等待interruption结束再继续 + await game.produceAsync(state => { + game.currentPlayer = turnPlayer; + }); +}; ``` ## UI 层:监听状态变化并注册 interruption @@ -51,28 +58,18 @@ import { effect } from '@preact/signals-core'; const host = createGameHost(module); effect(() => { - const state = host.state.value; + const state = host.context.value; // 每次 produceAsync 更新状态后,这里会被触发 // 播放对应的动画 const animation = playAnimationForState(state); // 为下一个 produceAsync 注册 interruption + // 注意:animation 必须是 Promise,在动画完成时 resolve host.addInterruption(animation); }); ``` -## 辅助函数示例 - -```ts -// 将 produce 包装为 produceAsync 的辅助函数 -async function placePieceAsync(context: MutableSignal, row: number, col: number) { - await context.produceAsync(state => { - state.parts[piece.id] = piece; - board.childIds.push(piece.id); - board.partMap[`${row},${col}`] = piece.id; - }); -} -``` +> **注意**:`playAnimationForState` 函数需要返回 `Promise`,在动画播放完成并 resolve 后,下一个 `produceAsync` 才会继续执行状态更新。 ## 中断 API @@ -89,6 +86,5 @@ async function placePieceAsync(context: MutableSignal, row: number, c - `produce()` 仍然保持同步,适合不需要动画的场景(如 setup 阶段) - `produceAsync()` 使用 `Promise.allSettled` 等待所有 interruption,即使某个动画 reject 也不会阻止状态更新 -- `clearInterruptions()` 会丢弃所有未完成的中断,建议在回合/阶段结束时调用 - 不要忘记 `await` `produceAsync()`,否则多个效应可能并发执行导致竞态 - 第一个 `produceAsync` 总是立即执行(无前序 interruption),从第二个开始等待动画 diff --git a/docs/api-reference.md b/docs/api-reference.md index c60a312..8a9e668 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -23,7 +23,7 @@ | `PartPool` | 棋子池类型 | | `createPart(template, id)` | 创建单个棋子 | | `createParts(template, count, idPrefix)` | 批量创建相同棋子 | -| `createPartsFromTable(template, table, idField?)` | 从表格数据创建棋子 | +| `createPartsFromTable(items, getId, getCount?)` | 从表格数据创建棋子 | | `createPartPool(template, count, idPrefix)` | 创建棋子池 | | `mergePartPools(...pools)` | 合并多个棋子池 | | `findPartById(parts, id)` | 按 ID 查找棋子 | @@ -60,19 +60,32 @@ | 导出 | 说明 | |---|---| | `CommandRunner` | 命令运行器类型 | -| `CommandRunnerHandler` | 命令处理器 | -| `CommandRunnerContext` / `CommandRunnerContextExport` | 命令运行器上下文 | -| `CommandRegistry` | 命令注册表类型 | +| `CommandRunnerHandler` | 命令处理器类型 | +| `CommandRunnerContext` | 命令运行器上下文类型 | +| `CommandRunnerContextExport` | 导出的命令运行器上下文(含内部方法) | +| `CommandRegistry` | 命令注册表类型 | | `PromptEvent` / `CommandRunnerEvents` | 提示事件类型 | | `PromptValidator` | 提示验证器类型 | | `createCommandRegistry()` | 创建命令注册表 | -| `registerCommand(registry, name, handler)` | 注册命令 | +| `registerCommand(registry, runner)` | 注册命令运行器 | | `unregisterCommand(registry, name)` | 注销命令 | | `hasCommand(registry, name)` | 检查命令是否存在 | | `getCommand(registry, name)` | 获取命令 | -| `runCommand(ctx, input)` | 运行命令 | -| `runCommandParsed(ctx, cmd)` | 运行已解析命令 | -| `createCommandRunnerContext(registry, ctx)` | 创建命令运行器上下文 | +| `runCommand(registry, context, input)` | 运行命令 | +| `runCommandParsed(registry, context, command)` | 运行已解析命令 | +| `createCommandRunnerContext(registry, context)` | 创建命令运行器上下文 | + +### Game Command Registry + +游戏专用命令注册表(通过 `createGameCommandRegistry` 创建,类型为 `CommandRegistry>`): + +| 方法 | 说明 | +|---|---| +| `registry.register(schema, handler)` | 注册命令并返回可调用命令对象 | + +`registry.register` 接受命令 Schema(字符串或 `CommandSchema` 对象)和处理器函数,返回一个可调用函数。处理器函数签名为 `(ctx, ...args) => Promise`。 + +在 GameModule 中使用 `game.prompt()` 等待玩家输入,验证函数中 `throw` 字符串会触发重新提示,返回非 null 值表示验证通过。子命令可以通过 `await subCommand(game, ...args)` 方式调用。 ## MutableSignal @@ -95,6 +108,7 @@ ### GameHost 中断代理 `GameHost` 直接代理 `addInterruption` 和 `clearInterruptions`,供 UI 层使用。 +`IGameContext` 提供 `produce()`、`produceAsync()` 和 `addInterruption()` 方法。 详见 [动画与状态更新同步](./animation-sync.md)。 ## 工具 diff --git a/docs/game-host.md b/docs/game-host.md index 6aeb859..7abd20b 100644 --- a/docs/game-host.md +++ b/docs/game-host.md @@ -22,8 +22,8 @@ import { effect } from '@preact/signals-core'; // 游戏状态 effect(() => { - console.log(host.state.value.currentPlayer); - console.log(host.state.value.winner); + console.log(host.context.value.currentPlayer); + console.log(host.context.value.winner); }); // 生命周期状态: 'created' | 'running' | 'disposed' @@ -53,7 +53,7 @@ effect(() => { await host.setup('setup'); ``` -这会重置游戏状态、取消当前活动提示、运行指定的 setup 命令,并将状态设为 `'running'`。 +这会重置游戏状态、取消当前活动提示、在后台启动指定的 setup 命令(不等待完成),并将状态设为 `'running'`。 ## 处理玩家输入 @@ -115,7 +115,7 @@ const host = createGameHost(tictactoe); // 监听状态变化 effect(() => { - const state = host.state.value; + const state = host.context.value; console.log(`${state.currentPlayer}'s turn (turn ${state.turn + 1})`); if (state.winner) { console.log('Winner:', state.winner); @@ -126,6 +126,8 @@ effect(() => { await host.setup('setup'); // 游戏循环:等待提示 → 提交输入 +// 注意:setup() 会立即返回,但 prompt 可能需要一些时间才能激活 +// 实际应用中应该等待 activePromptSchema 变为非 null while (host.status.value === 'running' && host.activePromptSchema.value) { const schema = host.activePromptSchema.value!; console.log('Waiting for input:', schema.name); diff --git a/docs/game-module.md b/docs/game-module.md index 579836e..7e0371c 100644 --- a/docs/game-module.md +++ b/docs/game-module.md @@ -22,18 +22,17 @@ export function createInitialState() { }; } -const registration = createGameCommandRegistry>(); -export const registry = registration.registry; +export const registry = createGameCommandRegistry>(); -registration.add('setup', async function () { /* ... */ }); -registration.add('play ', async function (cmd) { /* ... */ }); +registry.register('setup', async function (game) { /* ... */ }); +registry.register('play ', async function (game, cmd) { /* ... */ }); ``` 也可用 `createGameModule` 辅助函数包装: ```ts export const gameModule = createGameModule({ - registry: registration.registry, + registry, createInitialState, }); ``` @@ -50,18 +49,16 @@ export type GameState = ReturnType; ## 注册命令 -使用 `registration.add()` 注册命令。Schema 字符串定义了命令格式: +使用 `registry.register()` 注册命令。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; +registry.register('play ', async function (game, player, row, col) { + // game 是 IGameContext,可访问和修改状态 + game.produce(state => { + // state.parts[...].position = [row, col]; }); - return { winner: null }; + return { success: true }; }); ``` @@ -75,66 +72,87 @@ registration.add('play ', async function (cmd) | `[--flag]` | 可选标志 | | `[-x:number]` | 可选选项(带类型) | -### 命令处理器中的 this +### 命令处理器函数签名 -命令处理器中的 `this` 是 `CommandRunnerContext>`: +命令处理器接收 `game`(`IGameContext`)作为第一个参数,后续参数来自命令解析: ```ts -registration.add('myCommand ', async function (cmd) { - const state = this.context.value; // 读取状态 - this.context.produce(d => { d.currentPlayer = 'O'; }); // 修改状态 +registry.register('myCommand ', async function (game, arg) { + const state = game.value; // 读取状态 + game.produce(d => { d.currentPlayer = 'O'; }); // 同步修改状态 + await game.produceAsync(d => { /* ... */ }); // 异步修改(等待动画) - const result = await this.prompt('confirm ', validator, currentPlayer); - const subResult = await this.run<{ score: number }>(`score ${player}`); + const result = await game.prompt('confirm ', validator, currentPlayer); + const subResult = await subCommand(game, player); // 调用子命令 return { success: true }; }); ``` +`registry.register()` 返回一个可调用函数,可在其他命令中直接调用: + +```ts +const subCommand = registry.register('sub ', async function (game, player) { + return { score: 10 }; +}); + +// 在另一个命令中使用 +registry.register('main', async function (game) { + const result = await subCommand(game, 'X'); + // result = { success: true, result: { score: 10 } } +}); +``` + 详见 [API 参考](./api-reference.md)。 ## 使用 prompt 等待玩家输入 -`this.prompt()` 暂停命令执行,等待外部通过 `host.onInput()` 提交输入: +`game.prompt()` 暂停命令执行,等待外部通过 `host.onInput()` 提交输入: ```ts -const playCmd = await this.prompt( +const playCmd = await game.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`; - if (isCellOccupied(this.context, row, col)) return `Cell occupied`; - return null; + if (player !== turnPlayer) throw `Invalid player: ${player}`; + if (row < 0 || row > 2 || col < 0 || col > 2) throw `Invalid position`; + if (isCellOccupied(game, row, col)) throw `Cell occupied`; + return { player, row, col }; // 验证通过,返回所需数据 }, - this.context.value.currentPlayer + game.value.currentPlayer ); + +// playCmd = { player, row, col } ``` -验证函数返回 `null` 表示有效,返回 `string` 表示错误信息。验证通过后 `playCmd` 是已解析的命令对象。 +验证函数中 `throw` 字符串会触发重新提示,返回非 null 值表示验证通过并通过该值 resolve Promise。 ## 使用 setup 驱动游戏循环 -`setup` 作为入口点驱动游戏循环: +`setup` 作为入口点驱动游戏循环,通过调用其他命令函数实现: ```ts -registration.add('setup', async function () { - const { context } = this; +// 注册 turn 命令并获取可调用函数 +const turnCommand = registry.register('turn ', async function (game, player) { + // ... 执行回合逻辑 + return { winner: null as WinnerType }; +}); + +// 注册 setup 命令 +registry.register('setup', async function (game) { while (true) { - const currentPlayer = context.value.currentPlayer; - const turnNumber = context.value.turn + 1; - const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`); + const currentPlayer = game.value.currentPlayer; + const turnOutput = await turnCommand(game, currentPlayer); if (!turnOutput.success) throw new Error(turnOutput.error); - context.produce(state => { + game.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; + if (game.value.winner) break; } - return context.value; + return game.value; }); ``` @@ -144,8 +162,6 @@ registration.add('setup', async function () { ## 完整示例 -参考 [`src/samples/tic-tac-toe.ts`](../src/samples/tic-tac-toe.ts),包含: -- 2D 棋盘区域 -- 玩家轮流输入 -- 胜负判定 -- 完整的游戏循环 +参考以下示例: +- [`src/samples/tic-tac-toe.ts`](../src/samples/tic-tac-toe.ts) - 井字棋:2D 棋盘、玩家轮流输入、胜负判定 +- [`src/samples/boop/`](../src/samples/boop/) - Boop 游戏:六边形棋盘、推动机制、小猫升级 diff --git a/docs/parts-regions-rng.md b/docs/parts-regions-rng.md index 1203eeb..dc8ede3 100644 --- a/docs/parts-regions-rng.md +++ b/docs/parts-regions-rng.md @@ -3,7 +3,7 @@ ## 创建和放置 Part ```ts -import { createPart, createRegion } from 'boardgame-core'; +import { createPart, createRegion, moveToRegion } from 'boardgame-core'; const board = createRegion('board', [ { name: 'row', min: 0, max: 2 }, @@ -11,15 +11,24 @@ const board = createRegion('board', [ ]); const piece = createPart<{ owner: string }>( - { regionId: 'board', position: [1, 1], owner: 'white' }, + { regionId: '', position: [], 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; + // 推荐使用 moveToRegion 自动维护 childIds 和 partMap + moveToRegion(piece, null, draft.board, [1, 1]); }); + +// 或者手动操作(不推荐,容易出错): +// state.produce(draft => { +// draft.parts[piece.id] = piece; +// draft.board.childIds.push(piece.id); +// draft.board.partMap['1,1'] = piece.id; +// piece.regionId = 'board'; +// piece.position = [1, 1]; +// }); ``` ## Part 池 @@ -40,15 +49,23 @@ pool.remaining(); // 剩余数量 ```ts const parts = createPartsFromTable( - { regionId: 'board', owner: 'white' }, [ - { id: 'p1', position: [0, 0] }, - { id: 'p2', position: [1, 1] }, + { id: 'p1', regionId: 'board', position: [0, 0], owner: 'white' }, + { id: 'p2', regionId: 'board', position: [1, 1], owner: 'black' }, ], - 'id' + (item, index) => item.id, // 返回 ID 的函数 + // 可选:每个 item 创建几个,默认 1 + 1 ); + +// parts = { +// 'p1': { id: 'p1', regionId: 'board', position: [0, 0], owner: 'white' }, +// 'p2': { id: 'p2', regionId: 'board', position: [1, 1], owner: 'black' }, +// } ``` +`createPartsFromTable` 接受对象数组,每个对象的所有字段都会被展开到 Part 中。 + ## 查询棋子 ```ts @@ -69,7 +86,11 @@ import { applyAlign, shuffle, moveToRegion } from 'boardgame-core'; applyAlign(handRegion, state.parts); // 紧凑排列 shuffle(deckRegion, state.parts, rng); // 打乱 + +// 移动棋子:sourceRegion 为 null 表示棋子当前不在区域中 moveToRegion(piece, sourceRegion, targetRegion, [0, 0]); +moveToRegion(piece, null, boardRegion, [0, 0]); // 从外部放入区域 +moveToRegion(piece, boardRegion, null); // 从区域中移除(返回外部) ``` ## 翻面与掷骰