boardgame-core/docs/animation-sync.md

3.2 KiB
Raw Blame History

动画与状态更新同步

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

基本原理

核心思路:逻辑层将 produce 替换为 produceAsyncUI 层负责注册动画 interruption。

时序如下:

逻辑层:  produceAsync(fn1)         produceAsync(fn2)         produceAsync(fn3)
         ↓ (无 interruption,        ↓ 等待 anim1 完成         ↓ 等待 anim2 完成
          立即更新状态 1)

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 阻塞,等待动画完成后再更新状态
  4. 依此类推,形成链式等待

逻辑层:将 produce 替换为 produceAsync

// 之前
async function turn(game: BoopGame, turnPlayer: PlayerType) {
    game.produce(state => {
        game.scores[turnPlayer] ++;
    });
    // 这里不能触发动画等待
    game.produce(state => {
        game.currentPlayer = turnPlayer;
    });
};

// 之后:改为 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

import { effect } from '@preact/signals-core';

const host = createGameHost(module);

effect(() => {
    const state = host.context.value;
    // 每次 produceAsync 更新状态后,这里会被触发
    // 播放对应的动画
    const animation = playAnimationForState(state);

    // 为下一个 produceAsync 注册 interruption
    // 注意animation 必须是 Promise<void>,在动画完成时 resolve
    host.addInterruption(animation);
});

注意playAnimationForState 函数需要返回 Promise<void>,在动画播放完成并 resolve 后,下一个 produceAsync 才会继续执行状态更新。

中断 API

GameHost 直接暴露了以下方法,供 UI 层调用:

方法 说明
addInterruption(promise: Promise<void>) 注册中断,下一个 produceAsync 会等待它
clearInterruptions() 清除所有未完成的中断

MutableSignal 上还有 produceAsync,逻辑层使用。

注意事项

  • produce() 仍然保持同步,适合不需要动画的场景(如 setup 阶段)
  • produceAsync() 使用 Promise.allSettled 等待所有 interruption即使某个动画 reject 也不会阻止状态更新
  • 不要忘记 await produceAsync(),否则多个效应可能并发执行导致竞态
  • 第一个 produceAsync 总是立即执行(无前序 interruption从第二个开始等待动画