boardgame-core/docs/animation-sync.md

3.5 KiB
Raw Blame History

动画与状态更新同步

命令执行时,效应函数通过 produce() 立即更新状态UI 层只能看到最终结果, 无法在中间插入动画。为了解决这个问题,MutableSignal 提供了动画中断机制。

基本原理

核心思路:逻辑层将 produce 替换为 produceAsyncUI 层负责注册动画 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

// 之前
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

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

辅助函数示例

// 将 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从第二个开始等待动画