docs: update docs

This commit is contained in:
hypercross 2026-04-04 15:27:37 +08:00
parent a0dd5c94f5
commit 60a6bcad77
5 changed files with 202 additions and 171 deletions

91
docs/animation-sync.md Normal file
View File

@ -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 <player>', async function(cmd) {
const playCmd = await this.prompt('play <player> <row:number> <col:number>', validator, currentPlayer);
placePiece(this.context, row, col, pieceType); // 内部调用 produce
applyBoops(this.context, row, col, pieceType); // 内部调用 produce
});
// 之后:改为 produceAsync
registration.add('turn <player>', async function(cmd) {
const playCmd = await this.prompt('play <player> <row:number> <col:number>', 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<GameState>, 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<void>)` | 添加中断 Promise`produceAsync` 会等待它完成 |
| `clearInterruptions()` | 清除所有未完成的中断 |
| `produceAsync(fn: (draft: T) => void): Promise<void>` | 等待所有 interruption 完成后更新状态 |
## 注意事项
- `produce()` 仍然保持同步,适合不需要动画的场景(如 setup 阶段)
- `produceAsync()` 使用 `Promise.allSettled` 等待所有 interruption即使某个动画 reject 也不会阻止状态更新
- `clearInterruptions()` 会丢弃所有未完成的中断,建议在回合/阶段结束时调用
- 不要忘记 `await` `produceAsync()`,否则多个效应可能并发执行导致竞态
- 第一个 `produceAsync` 总是立即执行(无前序 interruption从第二个开始等待动画

View File

@ -49,3 +49,21 @@
| `Command` / `CommandSchema` / `CommandResult` | 命令相关类型 |
| `PromptEvent` | 玩家输入提示事件 |
| `createRNG(seed?)` | 创建种子 RNG |
## MutableSignal
| 导出 | 说明 |
|---|---|
| `MutableSignal<T>` | 支持突变和动画中断的响应式信号 |
| `mutableSignal(initial?, options?)` | 创建 MutableSignal |
| `EntityCollection<T>` / `createEntityCollection()` | 实体集合辅助函数 |
### MutableSignal 成员
| 成员 | 说明 |
|---|---|
| `value: T` | 获取当前值(继承自 Signal |
| `produce(fn: (draft: T) => void): void` | 同步不可变更新 |
| `addInterruption(promise: Promise<void>): void` | 添加中断 Promise`produceAsync` 会等待它完成 |
| `clearInterruptions(): void` | 清除所有未完成的中断 |
| `produceAsync(fn: (draft: T) => void): Promise<void>` | 等待所有 interruption 完成后更新状态 |

View File

@ -145,3 +145,7 @@ while (host.status.value === 'running' && host.activePromptSchema.value) {
// 或彻底销毁
// host.dispose();
```
## 动画同步
如需在状态更新之间播放动画,参考 [动画与状态更新同步](./animation-sync.md)。

View File

@ -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<ReturnType<typeof createInitialState>>();
export const registry = registration.registry;
// 注册命令
registration.add('setup', async function () {
// ... 命令逻辑
});
registration.add('play <player> <row:number> <col:number>', async function (cmd) {
// ... 命令逻辑
});
registration.add('setup', async function () { /* ... */ });
registration.add('play <player> <row:number> <col:number>', 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<string, TicTacToePart>,
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export type GameState = ReturnType<typeof createInitialState>;
```
状态通常包含:
- **Region**:用 `createRegion()` 创建的空间区域
- **parts**`Record<string, Part>` 游戏棋子集合
- 游戏特有的字段:当前玩家、分数、回合数等
状态通常包含 Region、parts`Record<string, Part>`)以及游戏专属字段(当前玩家、分数等)。
## 注册命令
@ -106,96 +81,50 @@ registration.add('play <player> <row:number> <col:number>', async function (cmd)
```ts
registration.add('myCommand <arg>', 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 <action>',
(command) => {
// 验证函数:返回 null 表示有效,返回 string 表示错误信息
return null;
},
this.context.value.currentPlayer // currentPlayer 参数可选
);
// 调用子命令
const result = await this.prompt('confirm <action>', 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 <player> <turn:number>', async function (cmd) {
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
// 等待玩家输入
const playCmd = await this.prompt(
'play <player> <row:number> <col:number>', // 期望的输入格式
'play <player> <row:number> <col:number>',
(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; // 验证通过
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 // 可选:标记当前等待的玩家
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) };
});
```
验证函数返回 `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 棋盘区域

60
docs/parts-regions-rng.md Normal file
View File

@ -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)。