docs: some fixes

This commit is contained in:
hypercross 2026-04-04 22:42:23 +08:00
parent bd2988902a
commit f5830ea637
5 changed files with 141 additions and 92 deletions

View File

@ -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 <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
});
async function turn(game: BoopGame, turnPlayer: PlayerType) {
game.produce(state => {
game.scores[turnPlayer] ++;
});
// 这里不能触发动画等待
game.produce(state => {
game.currentPlayer = turnPlayer;
});
};
// 之后:改为 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
});
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<void>,在动画完成时 resolve
host.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;
});
}
```
> **注意**`playAnimationForState` 函数需要返回 `Promise<void>`,在动画播放完成并 resolve 后,下一个 `produceAsync` 才会继续执行状态更新。
## 中断 API
@ -89,6 +86,5 @@ async function placePieceAsync(context: MutableSignal<GameState>, row: number, c
- `produce()` 仍然保持同步,适合不需要动画的场景(如 setup 阶段)
- `produceAsync()` 使用 `Promise.allSettled` 等待所有 interruption即使某个动画 reject 也不会阻止状态更新
- `clearInterruptions()` 会丢弃所有未完成的中断,建议在回合/阶段结束时调用
- 不要忘记 `await` `produceAsync()`,否则多个效应可能并发执行导致竞态
- 第一个 `produceAsync` 总是立即执行(无前序 interruption从第二个开始等待动画

View File

@ -23,7 +23,7 @@
| `PartPool<TMeta>` | 棋子池类型 |
| `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<TContext, TResult>` | 命令运行器类型 |
| `CommandRunnerHandler` | 命令处理器 |
| `CommandRunnerContext` / `CommandRunnerContextExport` | 命令运行器上下文 |
| `CommandRegistry` | 命令注册表类型 |
| `CommandRunnerHandler<TContext, TResult>` | 命令处理器类型 |
| `CommandRunnerContext<TContext>` | 命令运行器上下文类型 |
| `CommandRunnerContextExport<TContext>` | 导出的命令运行器上下文(含内部方法) |
| `CommandRegistry<TContext>` | 命令注册表类型 |
| `PromptEvent` / `CommandRunnerEvents` | 提示事件类型 |
| `PromptValidator<T>` | 提示验证器类型 |
| `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<IGameContext<TState>>`
| 方法 | 说明 |
|---|---|
| `registry.register(schema, handler)` | 注册命令并返回可调用命令对象 |
`registry.register` 接受命令 Schema字符串或 `CommandSchema` 对象)和处理器函数,返回一个可调用函数。处理器函数签名为 `(ctx, ...args) => Promise<TResult>`
在 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)。
## 工具

View File

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

View File

@ -22,18 +22,17 @@ export function createInitialState() {
};
}
const registration = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
export const registry = registration.registry;
export const registry = createGameCommandRegistry<ReturnType<typeof createInitialState>>();
registration.add('setup', async function () { /* ... */ });
registration.add('play <player> <row:number> <col:number>', async function (cmd) { /* ... */ });
registry.register('setup', async function (game) { /* ... */ });
registry.register('play <player> <row:number> <col:number>', async function (game, cmd) { /* ... */ });
```
也可用 `createGameModule` 辅助函数包装:
```ts
export const gameModule = createGameModule({
registry: registration.registry,
registry,
createInitialState,
});
```
@ -50,18 +49,16 @@ export type GameState = ReturnType<typeof createInitialState>;
## 注册命令
使用 `registration.add()` 注册命令。Schema 字符串定义了命令格式:
使用 `registry.register()` 注册命令。Schema 字符串定义了命令格式:
```ts
registration.add('play <player> <row:number> <col:number>', async function (cmd) {
const [player, row, col] = cmd.params as [PlayerType, number, number];
// this.context 是 MutableSignal<GameState>
this.context.produce(state => {
state.parts[piece.id] = piece;
registry.register('play <player> <row:number> <col:number>', async function (game, player, row, col) {
// game 是 IGameContext<TState>,可访问和修改状态
game.produce(state => {
// state.parts[...].position = [row, col];
});
return { winner: null };
return { success: true };
});
```
@ -75,66 +72,87 @@ registration.add('play <player> <row:number> <col:number>', async function (cmd)
| `[--flag]` | 可选标志 |
| `[-x:number]` | 可选选项(带类型) |
### 命令处理器中的 this
### 命令处理器函数签名
命令处理器中的 `this``CommandRunnerContext<MutableSignal<TState>>`
命令处理器接收 `game``IGameContext<TState>`)作为第一个参数,后续参数来自命令解析
```ts
registration.add('myCommand <arg>', async function (cmd) {
const state = this.context.value; // 读取状态
this.context.produce(d => { d.currentPlayer = 'O'; }); // 修改状态
registry.register('myCommand <arg>', async function (game, arg) {
const state = game.value; // 读取状态
game.produce(d => { d.currentPlayer = 'O'; }); // 同步修改状态
await game.produceAsync(d => { /* ... */ }); // 异步修改(等待动画)
const result = await this.prompt('confirm <action>', validator, currentPlayer);
const subResult = await this.run<{ score: number }>(`score ${player}`);
const result = await game.prompt('confirm <action>', validator, currentPlayer);
const subResult = await subCommand(game, player); // 调用子命令
return { success: true };
});
```
`registry.register()` 返回一个可调用函数,可在其他命令中直接调用:
```ts
const subCommand = registry.register('sub <player>', 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 <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`;
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 <player>', 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 游戏:六边形棋盘、推动机制、小猫升级

View File

@ -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); // 从区域中移除(返回外部)
```
## 翻面与掷骰