diff --git a/docs/animation-sync.md b/docs/animation-sync.md new file mode 100644 index 0000000..8c0d624 --- /dev/null +++ b/docs/animation-sync.md @@ -0,0 +1,91 @@ +# 动画与状态更新同步 + +命令执行时,效应函数通过 `produce()` 立即更新状态,UI 层只能看到最终结果, +无法在中间插入动画。为了解决这个问题,`MutableSignal` 提供了动画中断机制。 + +## 基本原理 + +核心思路:**逻辑层将 `produce` 替换为 `produceAsync`,UI 层负责注册动画 interruption。** + +时序如下: + +``` +逻辑层: produceAsync(fn1) produceAsync(fn2) produceAsync(fn3) + ↓ (无 interruption, ↓ 等待 anim1 完成 ↓ 等待 anim2 完成 + 立即更新状态 1) + +UI层: effect 检测到状态 1 变化 addInterruption(anim1) addInterruption(anim2) + 播放动画 1 播放动画 2 播放动画 3 +``` + +1. 第一个 `produceAsync` 没有 interruption 可等,立即更新状态 +2. UI 层通过 `effect` 检测到状态变化,播放动画并调用 `addInterruption` +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 +}); + +// 之后:改为 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 +}); +``` + +## UI 层:监听状态变化并注册 interruption + +```ts +import { effect } from '@preact/signals-core'; + +const host = createGameHost(module); + +effect(() => { + const state = host.state.value; + // 每次 produceAsync 更新状态后,这里会被触发 + // 播放对应的动画 + const animation = playAnimationForState(state); + + // 为下一个 produceAsync 注册 interruption + host.state.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; + }); +} +``` + +## MutableSignal 中断 API + +| 方法 | 说明 | +|---|---| +| `addInterruption(promise: Promise)` | 添加中断 Promise,`produceAsync` 会等待它完成 | +| `clearInterruptions()` | 清除所有未完成的中断 | +| `produceAsync(fn: (draft: T) => void): Promise` | 等待所有 interruption 完成后更新状态 | + +## 注意事项 + +- `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 2908b38..9886caf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -49,3 +49,21 @@ | `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 | | `PromptEvent` | 玩家输入提示事件 | | `createRNG(seed?)` | 创建种子 RNG | + +## MutableSignal + +| 导出 | 说明 | +|---|---| +| `MutableSignal` | 支持突变和动画中断的响应式信号 | +| `mutableSignal(initial?, options?)` | 创建 MutableSignal | +| `EntityCollection` / `createEntityCollection()` | 实体集合辅助函数 | + +### MutableSignal 成员 + +| 成员 | 说明 | +|---|---| +| `value: T` | 获取当前值(继承自 Signal) | +| `produce(fn: (draft: T) => void): void` | 同步不可变更新 | +| `addInterruption(promise: Promise): void` | 添加中断 Promise,`produceAsync` 会等待它完成 | +| `clearInterruptions(): void` | 清除所有未完成的中断 | +| `produceAsync(fn: (draft: T) => void): Promise` | 等待所有 interruption 完成后更新状态 | diff --git a/docs/game-host.md b/docs/game-host.md index d7cbacc..c1748fa 100644 --- a/docs/game-host.md +++ b/docs/game-host.md @@ -145,3 +145,7 @@ while (host.status.value === 'running' && host.activePromptSchema.value) { // 或彻底销毁 // host.dispose(); ``` + +## 动画同步 + +如需在状态更新之间播放动画,参考 [动画与状态更新同步](./animation-sync.md)。 diff --git a/docs/game-module.md b/docs/game-module.md index 2469984..579836e 100644 --- a/docs/game-module.md +++ b/docs/game-module.md @@ -9,7 +9,6 @@ GameModule 是定义游戏逻辑的模块,包含状态定义和命令注册。 ```ts import { createGameCommandRegistry, createRegion } from 'boardgame-core'; -// 1. 定义游戏状态 export function createInitialState() { return { board: createRegion('board', [ @@ -23,25 +22,16 @@ export function createInitialState() { }; } -// 2. 创建命令注册表并注册命令 const registration = createGameCommandRegistry>(); export const registry = registration.registry; -// 注册命令 -registration.add('setup', async function () { - // ... 命令逻辑 -}); - -registration.add('play ', async function (cmd) { - // ... 命令逻辑 -}); +registration.add('setup', async function () { /* ... */ }); +registration.add('play ', async function (cmd) { /* ... */ }); ``` -也可以使用 `createGameModule` 辅助函数: +也可用 `createGameModule` 辅助函数包装: ```ts -import { createGameModule, createGameCommandRegistry, createRegion } from 'boardgame-core'; - export const gameModule = createGameModule({ registry: registration.registry, createInitialState, @@ -50,28 +40,13 @@ export const gameModule = createGameModule({ ## 定义游戏状态 -游戏状态是一个普通对象,通过 `createInitialState()` 工厂函数创建。建议使用 `ReturnType` 推导类型: +建议用 `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` 游戏棋子集合 -- 游戏特有的字段:当前玩家、分数、回合数等 +状态通常包含 Region、parts(`Record`)以及游戏专属字段(当前玩家、分数等)。 ## 注册命令 @@ -106,96 +81,50 @@ registration.add('play ', async function (cmd) ```ts registration.add('myCommand ', async function (cmd) { - // 读取状态 - const state = this.context.value; + const state = this.context.value; // 读取状态 + this.context.produce(d => { d.currentPlayer = 'O'; }); // 修改状态 - // 修改状态 - this.context.produce(draft => { - draft.currentPlayer = 'O'; - }); - - // 提示玩家输入 - const result = await this.prompt( - 'confirm ', - (command) => { - // 验证函数:返回 null 表示有效,返回 string 表示错误信息 - return null; - }, - this.context.value.currentPlayer // currentPlayer 参数可选 - ); - - // 调用子命令 + const result = await this.prompt('confirm ', validator, currentPlayer); const subResult = await this.run<{ score: number }>(`score ${player}`); - if (subResult.success) { - console.log(subResult.result.score); - } - - // 返回命令结果 return { success: true }; }); ``` +详见 [API 参考](./api-reference.md)。 + ## 使用 prompt 等待玩家输入 -`this.prompt()` 是处理玩家输入的核心方法。它会暂停命令执行,等待外部通过 `host.onInput()` 提交输入: +`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) }; -}); +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`; + if (isCellOccupied(this.context, row, col)) return `Cell occupied`; + return null; + }, + this.context.value.currentPlayer +); ``` -验证函数返回 `null` 表示输入有效,返回 `string` 表示错误信息。 +验证函数返回 `null` 表示有效,返回 `string` 表示错误信息。验证通过后 `playCmd` 是已解析的命令对象。 -## 使用 setup 命令驱动游戏循环 +## 使用 setup 驱动游戏循环 -`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}` - ); + 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) { @@ -203,88 +132,17 @@ registration.add('setup', async function () { state.turn = turnNumber; } }); - - // 游戏结束条件 if (context.value.winner) break; } - return context.value; }); ``` -## 使用 Part 和 Region +## Part、Region 和 RNG -### 创建和放置 Part +详见 [棋子、区域与 RNG](./parts-regions-rng.md)。 -```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 棋盘区域 diff --git a/docs/parts-regions-rng.md b/docs/parts-regions-rng.md new file mode 100644 index 0000000..6d21594 --- /dev/null +++ b/docs/parts-regions-rng.md @@ -0,0 +1,60 @@ +# 棋子、区域与 RNG + +## 创建和放置 Part + +```ts +import { createPart, createRegion } 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 +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'; + +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) +``` + +完整 API 列表详见 [API 参考](./api-reference.md)。