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