boardgame-core/docs/animation-sync.md

95 lines
3.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 动画与状态更新同步
命令执行时,效应函数通过 `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.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;
});
}
```
## 中断 API
`GameHost` 直接暴露了以下方法,供 UI 层调用:
| 方法 | 说明 |
|---|---|
| `addInterruption(promise: Promise<void>)` | 注册中断,下一个 `produceAsync` 会等待它 |
| `clearInterruptions()` | 清除所有未完成的中断 |
`MutableSignal` 上还有 `produceAsync`,逻辑层使用。
## 注意事项
- `produce()` 仍然保持同步,适合不需要动画的场景(如 setup 阶段)
- `produceAsync()` 使用 `Promise.allSettled` 等待所有 interruption即使某个动画 reject 也不会阻止状态更新
- `clearInterruptions()` 会丢弃所有未完成的中断,建议在回合/阶段结束时调用
- 不要忘记 `await` `produceAsync()`,否则多个效应可能并发执行导致竞态
- 第一个 `produceAsync` 总是立即执行(无前序 interruption从第二个开始等待动画