手把手教程 · 从零到能写业务

动作序列 · 使用说明

这份文档只回答一个问题:我该怎么用它?

读完你能做到:3 分钟跑起第一条序列 · 看到需求就知道怎么写 · 知道哪 6 个坑会让人白干半天

〇它是干什么的

先花 30 秒建立直觉,再动手写。

只学 4 个东西就能干活(真的)
RevSequencePlayer.Play(                                // ① 播放(Unity 里连"每帧驱动"都不用管)
    RevSequence.Create("开宝箱")                         // ② 开一张清单(名字会出现在日志里)
        .Do("播音效", ctx => Sound.Play("Box_Open"))      // ③ 一步一步写:Do = 立刻做一件事
        .Wait(1f)                                     //    Wait = 停一会儿
        .WaitUntil("等玩家点击", ctx => 点了, 15f));   //    WaitUntil = 等条件成立(带超时保护)

上面是"一行到底"的写法,适合临时的一次性演出。要被反复触发,就先 .Build() 存成静态字段再 Play (第 1 章一句话讲清:写一次、用一辈子)。
就这 4 个 —— 剩下的(并行、事件、自定义步骤、服务注入……)都是用到再看的增值项。

人话 动作序列 = 一张"先做什么、再做什么"的清单,写成代码,交给框架按顺序执行。

清单里如果有一项是"等玩家点一下",框架就停在那儿等,玩家点了再往下走。这个停等的过程中,游戏其它部分照常运行(不会卡住)。

生活里它就长这样

① 烧水 3 分钟→ ② 放面饼→ ③ 等 2 分钟→ ④ 放调料,开吃

游戏里的"开宝箱"是同一件事:

① 播放出现音效→ ② 等玩家点一下→ ③ 播放开箱音效→ ④ 通知门:可以开了

它解决什么问题(如果你现在是这样写代码的)

// 没有动作序列时,"先做 A、等条件、再做 B"通常长这样:
private bool _clicked;          // ← 为了"等玩家点" 得先加个字段
private float _timer;           // ← 为了"停 1 秒" 再加个字段
private int _step;              // ← 为了记住"走到第几步" 还得加个字段

void Update() {
    switch (_step) {
        case 0: PlaySound("Box_Appear"); _step = 1; break;
        case 1: if (_clicked) { PlaySound("Box_Open"); _step = 2; } break;
        // …再加需求就要再塞分支,而且"取消/回收"没地方放
    }
}

用动作序列写,上面那一坨变成:

RevSequence.Create("开宝箱")
    .Do("出现音效", ctx => PlaySound("Box_Appear"))
    .WaitUntil("等玩家点击", ctx => _clicked)
    .Do("开箱音效", ctx => PlaySound("Box_Open"))
    .Do("通知门",   ctx => ctx.Events.Publish(new ChestOpened()))
    .Build();
人话 多了三个字段、三个分支,全都不需要了:"走到第几步""等了多久""点了没"这三件事由框架替你记,你只写"要做哪些事"。

什么时候该用它 / 什么时候不该用

你的需求结论
"先 A 再 B""B 要等条件成立才继续"✅ 用它
"演出播完再进入下一步""资源加载完再继续"✅ 用它
"这个流程要能被中途取消,取消后还得把现场收拾干净"✅ 用它
"两件事要同时做(音乐 + 动画)"✅ 用它
"状态之间互相跳转,比如眩晕 → 逃跑 → 追击"❌ 用状态机,不要硬塞进序列
"每帧都要根据战场情况做决策(AI)"❌ 用状态机
一句话记住 序列管"一条路走到底",状态机管"我现在是什么状态"。实战里最常见的搭配是:状态机管形态(Boss 阶段),序列管演出(登场动画);演出播完再让状态机切状态。

13 分钟跑起来

复制这一整段到任意脚本里,挂到场景任意物体上,运行就能看到日志。

using UnityEngine;
using Revolution;        // ← 框架的命名空间(忘了它会报"找不到 RevSequence")

public sealed class MyFirstSequence : MonoBehaviour
{
    private void Start()
    {
        // ① 写清单(只写一次,写完存起来 —— 后面第 6 章坑 5 会讲为什么)
        RevSequenceDefinition flow = RevSequence.Create("我的第一个序列")
            .Do("打个招呼", ctx => Debug.Log("你好,动作序列!"))
            .Wait(1f)                                     // 停 1 秒(这 1 秒里游戏照常运行)
            .Do("再说一句", ctx => Debug.Log("1 秒过去了"))
            .Build();

        // ② 让它开始跑(不用你每帧喂时间,框架会自动创建驱动者;传 gameObject 后它被销毁时序列自动取消)
        flow.Play(gameObject);
    }
}

这几行分别是什么意思

这一行意思
RevSequence.Create("名字")拿一张空清单开始写。名字会出现在报错与排查信息里,所以起个看得懂的名字("开宝箱""登录流程")
.Do("名字", ctx => 做事())加一步"马上做"的事。第一步的名字是"打个招呼",将来排查问题时你会看到它
.Wait(1f)加一步"停 1 秒"。注意:这 1 秒不会卡住游戏,只是这一条流程暂停在这里,其它系统照常跑
.Build()清单定稿。定稿之后不能再改(所以要用变量/静态字段把它存起来复用)
flow.Play(gameObject)开始跑(等价于 RevSequencePlayer.Default.Play(flow, gameObject))。第一次播放会自动创建一个隐藏物体来每帧推进序列,所以你在 Unity 里什么都不用配;立刻返回一个"句柄"(钥匙),想取消时用它 —— 见第五章
gameObject(触发者)步骤里用 ctx.Source / ctx.SourceGameObject() 拿到它;它被 Destroy 时序列自动取消(不会再去碰已销毁的物体)

把清单存起来(推荐从第一条序列就这么写)

清单是"不可变的"(定稿后不能改),所以它天生适合放进静态字段,写一次、用一辈子:

private static readonly RevSequenceDefinition Flow =      // ← 静态字段:整个游戏只构建一次
    RevSequence.Create("我的第一个序列")
        .Do("打个招呼", ctx => Debug.Log("你好"))
        .Wait(1f)
        .Build();

// 需要时随时播放(想播几次播几次,一份清单可以被多个玩家/多个对象同时使用)
Flow.Play(gameObject);
人话 这就像"菜谱"和"做菜"的关系:菜谱写一次贴在墙上(静态字段),谁都能照着做(Play)。两个厨师同时照着同一张菜谱做菜,各做各的、不会串味 —— 这是它比"把状态写在脚本字段里"强的地方。

谁负责"每帧推进"(三种方式,挑一个)

方式怎么用什么时候选它
① 全局默认(最常用)flow.Play(gameObject);什么都不想配 —— Unity 里九成场景够用。暂停界面里的演出:RevSequencePlayer.UseUnscaledTime = true
② 场景级组件把 RevSequencePlayer 挂到场景物体上,勾选"使用自己的引擎"这条流程属于某个玩法场景,希望它随场景卸载一起收掉
③ 自己 new + 自己喂时间var r = new RevSequenceRunner(); r.Play(flow); r.Tick(dt);单元测试、需要暂停/快进/慢放、或者你有自己的时间源
⚠ 选了 ③ 就必须自己每帧调 Tick 忘了调 Tick 的症状是:Play 了但什么都没发生(因为没人推进它)。这是新手第一个坑,第六章会再提醒一次。

2"我要做 X" 对照表

写业务时 90% 的情况都在下面这张表里 —— 找到你要的,照抄即可。

我想要这样写说明
立刻做一件事.Do("播个音效", ctx => Sound.Play("Box_Open"))不等待,执行完就下一步。名字可以省:.Do(ctx => …)(日志里显示"文件名:行号")
停一会儿.Wait(0.5f) / .WaitFrames(6)按秒 / 按帧。等待期间游戏不卡
等玩家点了再继续.WaitUntil("等玩家点击", ctx => 点了, timeoutSeconds: 15f)每帧问一次"好了吗";15 秒还没好就放行并在 Console 告警(写明哪条序列、哪一步),不会永远卡死;要自己处理超时就传 onTimeout: ctx => …
等资源 / 数据到位.WaitUntil("等加载完", ctx => Res.IsDone)同上,条件是"加载完了"
等一个异步任务.WaitTask(ctx => 那个异步任务, "等服务器回包")任务由 ctx => 工厂在运行时创建 → 清单可缓存、并发互不干扰;任务失败会打日志,cancelOnFailure: true 则取消整条序列
等异步任务并拿到结果.WaitTask(ctx => LoadAsync(path), (ctx, prefab) => 用它(prefab))RevTask<T> 的结果交给第二个参数
移动 / 缩放 / 淡入淡出(Unity).MoveTo("飞向玩家", ctx => 玩家位置, 0.35f) · .ScaleTo("弹出", Vector3.one, 0.2f, RevEase.OutBack) · .FadeTo("淡出", 0f, 0.3f)默认操作触发者(source);别的物体用 target: 参数指定。目标点每帧重新取,追得上移动的玩家
任意"随时间变化"的表现.Tween("数字滚动", 1f, (ctx, t) => label.text = ((int)(t * 100)).ToString())t 从 0 到 1(可选缓动 RevEase),最后一帧必然是 1。需要起始值用 Tween("…", 秒, begin: ctx => 起点, update: (ctx, 起点, t) => …) —— 不用再为此写自定义步骤
根据条件走不同流程.If("低画质?", ctx => 条件, then: b => b.Do(…), otherwise: b => b.Do(…).Wait(1f))进入这一步时判断一次;分支里可以有等待
打一条调试日志.Log("走到开门这一步了")进 RevLog(tag = ActionSequence)
开始一个动作 + 每帧问它结束没.DoBlocking("等移动完", ctx => 移动.Start(), ctx => 移动.Done)适合"我做一次启动,然后盯着它结束"
两件事同时做.Parallel("音乐和动画一起", p => p.Do(...).Do(...))组内全部完成,这一步才算结束
重复几遍.Repeat("闪三次", 3, r => r.Do("亮", ...).WaitFrames(6))闪光、脉冲、轮播都用它
跑完这条,接着跑另一条.Sequence("先开箱", ChestOpen)把常用演出做成积木,拼装复用
通知别的序列 / 别的系统.Publish(new ChestOpened(uid))事件是强类型的,改名编译器会报错(不会悄悄断链)
跑完和取消都要做的收尾.Finally(f => f.Do("恢复镜头", ctx => Cam.Restore()))最常用的收尾写法:只写一遍,跑完 / 取消 / 出错都执行。第五章专门讲
只在取消时才做的收尾.OnCancel(f => f.Do("回收", ctx => Pool.Recycle(go)))例如"正常跑完物件已经交给玩家了,只有中途取消才要回收"
跑完/被取消时通知我.OnCompleted(run => ...) / .OnCancelled(run => ...)接埋点、刷 UI、记日志
每走一步都记一下(埋点).Do("埋点", ctx => YourLog.Step())框架不内建埋点:在步骤里自己打点即可
取消它 / 问它还在不在handle.Stop() / handle.IsValidPlay 的返回值就是句柄
在步骤里拿业务系统ctx.Require<ISoundService>()(必须有)/ ctx.Get<ISoundService>()(可以没有)框架不认识你的系统,全靠这样注入(见第七章)。Require 取不到会直接报错并告诉你怎么注册,比 Get()?.X() 静默跳过好查得多
在步骤里知道"谁触发的"ctx.Source / ctx.SourceAs<T>() / ctx.SourceGameObject()例如"是哪个玩家开的这个箱子"
只用这张表就能写完整游戏流程 上面 16 行覆盖了"顺序 + 等待 + 并行 + 重复 + 事件 + 取消 + 埋点"全套能力 —— 不需要先学任何理论。

3五个最常用的写法(逐个讲透)

每个卡片都是"什么时候用 → 怎么写 → 要注意什么"。

动图整条序列是怎么走完的(一眼看懂"等待"和"立刻"的区别)
Do 播音效立刻
›
Do 开提示立刻
›
Wait 1s等 1 秒
›
WaitUntil 等点击等玩家
›
Do 发奖励立刻

① 前两步是"立刻"的:同一帧内连着就做完了(不会一步占一帧)。

② 第 3 步是"等 1 秒":进度条走完 1 秒才继续 —— 这期间游戏照常跑,别的序列也在跑。

③ 第 4 步是"等玩家点击":条件成立才放行;最后一步又立刻做完,整条流程结束。

看到"蓝色"= 正在执行;看到"黄色进度条"= 卡在等待中。黄色就是"序列挂起"的样子 —— 它没死,只是在等。
① .Do("名字", ctx => 做事()) —— 立刻做一件事
什么时候用:播音效、开界面、发奖励、改状态 …… 所有"做完就往下走"的事。
.Do("播放音效", ctx => ctx.Get<ISoundService>()?.PostEvent("Play_Box_Open"))
.Do("打开奖励面板", ctx => UIManager.Open("RewardPanel"))
.Do("加金币", ctx => ctx.Get<IPlayerService>()?.AddGold(100))
✅ 名字要认真写 —— 排查问题时它就是你唯一的路标("卡在 播放音效 这一步")。
❌ 不要在 Do 里等东西(那会卡住主线程):要等就用 WaitUntil / WaitTask。
② .WaitUntil("名字", ctx => 条件, 超时秒数) —— 等一个条件成立
什么时候用:等玩家点击、等动画播完、等资源加载完、等某个标志位变 true —— "等外部状态"全靠它。
// 等玩家点击(15 秒没点就放弃,并打一条告警)
.WaitUntil("等玩家点击", ctx => ctx.Get<IInputService>()?.ConsumeClick() == true, timeoutSeconds: 15f)

// 等相机推近到位(用业务服务的状态)
.WaitUntil("等镜头就位", ctx => ctx.Get<ICameraService>()?.IsArrived == true)
✅ 一定要写超时(最后一个参数):万一条件永远不成立,序列会永远卡在那里 —— 原体系最典型的事故就是"某个动作永远不返回,序列停在半路"。
✅ 条件里读的必须是"能变的东西"(服务状态、字段)——别写 ctx => true 这种永远成立的假条件(除非你就是想占一帧)。
③ .DoBlocking("名字", 启动动作, 判断结束) —— 我启动一件事,然后盯它结束
什么时候用:播放一个时长不定的动画 / 让角色走过去 / 等一个"有开始也有结束"的动作。
.DoBlocking("等角色走过去",
    execute:     ctx => ctx.Get<IMoveService>()?.MoveTo(target),
    isCompleted: ctx => ctx.Get<IMoveService>()?.IsArrived == true)
✅ 第一个参数只会执行一次("启动"),第二个参数每帧问一次("到了吗")。
⚠️ 判断条件里别用"你在构造函数里存的字段" —— 两个玩家同时触发会互相踩(第六章坑 3 有对错对比)。
④ .Parallel("名字", p => ...) —— 两件事同时做
什么时候用:音效 + 动画同时开始;几个特效一起播;界面打开的同时拉数据。
.Parallel("消失音效与飞行动画同时进行", p => p
    .Do("消失音效", ctx => ctx.Get<ISoundService>()?.PostEvent("Play_Disappear"))
    .Step(new MyFlyStep(duration: 0.35f)))      // 自定义步骤,见本节第 ⑥ 个卡片
// ↑ 两条都完成后,才继续后面的步骤
✅ 顺序执行是默认行为;只有你明确写 Parallel 才是并行。
✅ 还有 .Repeat("名字", 次数, r => ...)(重复)与 .Sequence("名字", 别的清单)(嵌套复用),用法同理。
⑤ .Finally(f => f.Do(...)) —— 收拾现场(跑完、取消都执行)
什么时候用:这条流程里"改了东西 / 产生了东西"(推近的镜头、隐藏的 HUD、生成的特效、注册的监听)—— 只要有,就得写。
.Finally(f => f
    .Do("恢复镜头", ctx => ctx.Require<ICameraService>().Restore())
    .Do("回收特效", ctx => ctx.Require<IEffectService>().RecycleAll(ctx.Source)))
✅ 正常跑完、玩家中途退出、切场景、掉线、被别的流程顶替、步骤报错 —— 都会走到这里,只写一遍。
✅ 只有"中途打断才需要做"的事,改用 .OnCancel(f => …)(取消时它排在 Finally 前面执行)。
✅ 里面只能写"立刻做完"的步骤(不能等)—— 写了 Wait 之类的,Build() 会直接报错。
✅ 判断标准很简单:这条序列"占用了什么",就在 Finally 里还回去。
⑥ 需要记住"这次运行自己"的中间值时(进阶,先知道有这回事)
什么时候用:要记住"本次飞行的起点时间""本次随到的随机数"这类只有这一次运行才有的数据。
public sealed class MyFlyStep : RevStepBase
{
    private sealed class State { public float Duration; }

    public override string Name => "抛物线飞向玩家";

    public override void Execute(RevSequenceRun run, RevSequenceContext ctx)
        => GetOrCreateState<State>(run).Duration = 0.35f;     // ← 状态存这里,按"本次运行"隔离

    public override bool IsCompleted(RevSequenceRun run, RevSequenceContext ctx)
        => run.Elapsed >= GetState<State>(run).Duration;
}
写法就是这三段:状态放内部类 → Execute 里准备 → IsCompleted 里判断。不用它也行(DoBlocking 够用),但涉及"每个玩家各自一份状态"时就得用它。

4谁来启动它:三种触发方式

清单写好了,接下来要回答"什么时候开始跑"。

方式一:手动启动(最直接,够用一半场景)

// 点按钮、流程里走到某一步 —— 直接 Play 就行
ChestOpen.Play(gameObject);
//             ↑ source = "是谁触发的"(第 4.4 节会讲它为什么重要;它被销毁时序列自动取消)
人话 "谁触发的"这个概念有两个用处:① 判断重复触发时"是不是同一个玩家";② 步骤里能让你知道"这次是给谁播的"(ctx.Source)。

方式二:玩家走进范围就播(区域触发)

框架不内置"范围检测"(每个项目检测方式不同:Unity 物理 / 自研网格 / 服务端下发),但把它接到序列上只要 8 行:

// ① 让触发物实现"触发源契约":有人进来,就喊一声
public sealed class ChestZone : MonoBehaviour, RevITriggerSource
{
    public string Name => "宝箱区域";
    public event Action<RevTriggerSignal> Triggered;
    public void Dispose() => Triggered = null;          // 断开所有订阅

    // Unity 物理回调:谁走进来,就把"谁"喊出去
    private void OnTriggerEnter(Collider other)
        => Triggered?.Invoke(new RevTriggerSignal(other.gameObject, Name));
}

// ② 绑一下:这个区域触发 → 播这条序列(写在 Awake 里)
var zone = gameObject.AddComponent<ChestZone>();
_binding = RevSequencePlayer.Default.Bind(zone, GameSequences.ChestOpen);

// ③ 不需要时解绑(必须做,否则对象没了、回调还在被调用)
_binding.Dispose();
人话 框架只认"有人喊了一声"这件事 —— 怎么发现"有人进来"完全由你决定: Unity 物理回调、你自己的距离检测、甚至服务端推来的一句"玩家 X 进入宝箱区",都能这么接。

触发源只有这一种写法(实现 RevITriggerSource)—— 框架刻意不提供"触发源基类",就是为了不让你在"继承还是实现"之间选:MonoBehaviour 受 C# 单基类限制只能走接口,两条路并存只会踩坑。

方式三:别的事件发生时启动(一行搞定)

// 场景:宝箱开了 → 门就该开(门这条序列被"宝箱开启"事件触发)
RevSequencePlayer.Default.BindEvents<ChestOpenedEvent>(
    RevSequencePlayer.Default.Events,      // 事件总线
    SlidingDoorOpen,                       // 要启动的序列
    e => e.Opener);                        // 从事件里取"是谁开的"

// 而"发事件"是这样发的(通常写在宝箱序列的最后一步):
.Do("广播宝箱已开", ctx => ctx.Events.Publish(new ChestOpenedEvent(ctx.Source, uid)))
事件是强类型的,这一点很重要 原体系用字符串事件名("chest_opened")互相通知,改个名字就悄悄断链、跑起来才发现。 这里 ChestOpenedEvent 是一个类型,改名编译器立刻报错;事件里能带什么字段也由你定义。

三种方式怎么选

场景选哪个
UI 按钮 / 流程中的某一步 / 测试时手动跑手动 Play
玩家走近、进入某个区域、碰到东西区域触发源(+ 自己的检测)
别的序列/系统做了某件事之后要接力事件触发(BindEvents)
服务端下指令、定时器到点手动 Play(在你收到指令的地方调)

4.4 防止"连点两次跑两遍":并发策略

同一个玩家在极短时间内触发同一条序列(连点、快速进出区域),你希望怎样?框架给你三选一,写清单的时候声明:

RevSequence.Create("开宝箱", RevSequenceConcurrency.RejectPerSource)   // ← 第三个参数(不写就是 Free)
    .Do(...)
    .Build();
策略同一个玩家再触发一次时典型场景
Free(默认)再跑一条,两条并存每个怪各播各的受击表现
ReplacePerSource掐掉旧的,跑新的连点采集 / 同一个 NPC 换目标,旧演出让位
RejectPerSource忽略这次(旧的继续跑完)宝箱只能开一次、演出期间防连点
⚠ 用了后两种策略,Play 时必须传 source 因为"是不是同一个"靠 source 判断。不传(Play(清单))的话,所有人会被当成"同一个空触发者",策略就变成了"全场只允许一条" —— 通常不是你想要的。 这种情况框架会在开发期提醒你一次(日志里会写清怎么改)。

5中途取消:怎么停、怎么收拾

这是最容易被忽略、也最容易出事的一章。好消息是它只有两个 API。

5.1 什么时候会被取消(大多数时候不是你主动取消)

5.2 两个 API

// ① 存下句柄(Play 的返回值),想取消时用它
RevSequenceHandle handle = LoadingFlow.Play(player);

if (玩家点了取消) handle.Stop();                          // 取消这一条(走它的收尾)
if (切场景)        RevSequencePlayer.Default.StopAll();    // 全部取消(每条都走各自的收尾)

// ② 想知道它还在不在跑
if (handle.IsValid) Debug.Log("还在跑");
人话 句柄就是"这场演出的票根":拿着它才能叫停、才能问"还在演吗"。演出结束后票根自动作废(IsValid 变 false),所以旧票根永远不会误停别的新演出。
取消是"下一个步骤边界"生效,不是立刻中断 这是故意的:正在执行的步骤不会被打断(避免"做一半被砍"的脏状态),序列会在下一步之前停下来,然后走收尾。

5.3 收尾:.Finally(...) 与 .OnCancel(...)

写法什么时候执行用来做什么
.Finally(f => …)跑完、被取消、出错都执行(最常用)恢复镜头 / HUD、反注册监听 —— "进来时改了什么,出去时改回来"
.OnCancel(f => …)只在被取消 / 出错时执行(排在 Finally 前面)正常跑完不需要、只有中途打断才要做的事,例如回收"飞到一半"的物件
.Do("推近镜头", ctx => ctx.Require<ICameraService>().PushIn())
.WaitUntil("等对白结束", ctx => …, 15f)
.OnCancel(f => f
    .Do("回收生成的特效", ctx => ctx.Require<IEffectService>().RecycleAll(ctx.Source)))
.Finally(f => f
    .Do("恢复镜头与 HUD", ctx => ctx.Require<ICameraService>().Restore()))
收尾里只能放立即步骤 Do、SetActive、Log,以及由它们组成的 Parallel / If:收尾只同步执行一次、不会等待。 放了 Wait / WaitUntil / Tween / 嵌套序列,Build() 时会直接报错告诉你是哪一步。 收尾里某一步抛异常只会跳过那一步(打日志),其余收尾照常执行。
动图两条路都通向"收尾"—— 一条是跑完,一条是被取消
✅ 正常跑完
→ 播音效✓ 收尾(Finally)→ 完成回调(OnCompleted)
Finally 执行;OnCancel 不执行(没有被打断)
⚠ 跑到一半被取消(退出 / 切场景 / 顶替)
→ 播音效✗ 中途停住✓ 收尾(OnCancel → Finally)
不写收尾的话:特效留在场上、镜头还推着、事件还被监听
判断标准一句话:这条序列"占用了什么",就在 Finally 里还回去。 生成的物件、加载的资源、推近的镜头、注册的监听 —— 四样东西,对应四行收尾。
❌ 常见错误:只写回收,不写取消
.Do("生成特效", ctx => spawned = Effect.Spawn("Fx_Open"))
.Do("回收特效", ctx => Effect.Recycle(spawned))
// 玩家中途退出 → 回收那一步永远走不到 → 特效留在场上
✅ 正确:把"还回去"放进 Finally(只写一遍,两条路都会走到)
.Do("生成特效", ctx => ctx.Require<IEffectService>().Spawn("Fx_Open", ctx.Source))
.Wait(1f)
.Finally(f => f.Do("回收特效", ctx => ctx.Require<IEffectService>().RecycleAll(ctx.Source)))

5.4 三种结束方式(对照表)

结束方式怎么发生的.OnCancel.Finally回调
正常跑完所有步骤都放行了不执行执行OnCompleted
被取消handle.Stop() / StopAll() / 触发者被销毁 / 被顶替执行执行OnCancelled
步骤抛异常某个步骤的代码报错执行执行OnCancelled(Console 里有一条带序列名和步骤的异常日志)

handle.Stop(runFinally: false) / StopAll(runFinally: false) 会显式跳过两种收尾。

6新手最容易白干的 6 个坑

每个坑都是"症状 → 原因 → 怎么改"。前两个坑占了新手问题的一大半。

坑 1:Play 了,但什么都没发生
症状:日志里连"启动"都没有,或者日志有但画面没动。

原因(两种):

  • 没人"喂时间"——你用自己 new 的引擎(方式 ③),但忘了每帧调 Tick;
  • 被并发策略忽略了——你把清单声明成 RejectPerSource,而同一个 source 已经有一条在跑。
❌ 忘了 Tick
var r = new RevSequenceRunner();
r.Play(Flow);   // 就没了 → 永远不动
✅ 用 Default,或自己 Tick
Flow.Play(gameObject);
// 或
r.Play(Flow);  r.Tick(Time.deltaTime);
被策略忽略时会返回"空句柄":if (!handle.IsAssigned) Debug.Log("被策略忽略了")。
坑 2:等待时间不对(感觉快了一倍 / 动画加速了)
症状:明明写 Wait(2f),一秒就过去了;或者所有表现都加速。

原因:场景里有两个在推进同一个引擎的驱动组件 —— 序列每帧被推进两次,等待时间自然减半。

✅ 框架会自己发现并处理:第二个驱动组件会被自动关掉,并在 Console 打一条告警说明原因。看到这条告警就删掉多余的组件(或者它改成"使用自己的引擎")。
坑 3:两个玩家同时触发,表现互相串了
症状:玩家甲采集灵果,结果玩家乙那边的动画乱了;或者"第一次跑对,第二次跑用了上次的数据"。

原因:在写清单的 lambda 里用了"存在外面的变量"来记跨帧状态 —— 这份变量是全天下共享的。

❌ 用了外面的字段
private float _start;   // 全天下共用一个

.DoBlocking("飞",
    ctx => _start = ctx.Elapsed,
    ctx => ctx.Elapsed - _start > 0.35f)
// 甲触发把 _start 改了 → 乙的进度被算错
✅ 状态放"服务"或"自定义步骤"
// (a) 状态放业务服务里(推荐)
.DoBlocking("飞",
    ctx => fly.Begin(uid),
    ctx => fly.IsDone(uid))

// (b) 或写成 RevStepBase 用状态槽
//     见第 3 章第 ⑥ 个卡片
坑 4:清单只写一次,但又用 Play(builder) 播(同源策略会静默失效)
症状:明明写了 RejectPerSource(只允许一次),连点两下却跑了两遍;或者没感觉到"防连点"生效。

原因:Play(builder) 每次都会 重新构建一份新清单,而"同源策略"判定的是"同一份清单实例" —— 每次都是新的,自然永远不命中。

❌ 每次 Play 都新建清单
_runner.Play(RevSequence.Create("开宝箱",
        RevSequenceConcurrency.RejectPerSource)      // 声明了"只能一次"
    .Do(...).Build(), gameObject);
// 每次都是新实例 → 策略永远不命中
✅ 构建一次,缓存复用
private static readonly RevSequenceDefinition ChestOpen =
    RevSequence.Create("开宝箱", RevSequenceConcurrency.RejectPerSource)
        .Do(...).Build();                             // 只构建一次

_runner.Play(ChestOpen, gameObject);                  // 反复播同一份 → 策略生效
✅ 开发期框架会就这一点告警一次(日志里会写清怎么改)。
✅ 反过来:只播一次的临时演出(一句提示、一次反馈)用 Play(builder) 完全没问题。
坑 5:改了清单,跑起来还是老样子
症状:改了步骤,运行结果没变;或者报错"已经 Build 过了"。

原因:清单是不可变的(Build() 只能调一次)。你那句 .Do(...) 加在了已经 Build 之后,框架会直接报错拦你,而不是"悄悄不生效"。

✅ 正确姿势:清单定义在一个 static readonly 字段里,改代码 → 重新编译 → 新清单自然生效。这也正是它比"Unity 里拖组件"好维护的地方(改动可 diff、可 Code Review)。
坑 6:取消之后,东西没收拾干净
症状:玩家中途退出后,特效还在场上、镜头还推着、界面还开着;或者下次进来事件被响应两次。

原因:把"回收"写在了正常流程的末尾,而流程被中途取消时根本走不到那里。

✅ 判断标准:这条序列"占用了什么"(生成的物件 / 加载的资源 / 推近的镜头 / 注册的监听),就在 .OnCancel(...) 里还回去 —— 见第 5 章第 5.3 节的动图。

7完整实战:宝箱开启全流程

把前面学的东西拼起来。这段代码包含:业务服务注入 · 清单 · 区域触发 · 事件接力 · 取消收尾,一共约 70 行。

7.1 先定义"业务服务"接口(框架不认识你的系统)

using UnityEngine;
using Revolution;

// 这些接口由你(业务)定义,实现也是你写 —— 框架完全不知道它们的存在
public interface ISoundService  { void Play(string eventName, object emitter = null); }
public interface IInputService  { bool ConsumeClick(); }
public interface IUIService     { void Open(string panel); void Close(string panel); }
public interface ISceneService  { void SetSlidingDoor(bool open); }
为什么要这样 这样写的好处是:清单只调接口,不直接调你的管理器。于是同一份清单可以换实现跑 —— 真实现(跑游戏)、假实现(单元测试,甚至不用开 Unity)、录播实现(复现线上问题)。

7.2 定义"事件":宝箱开完要通知门

// 用 readonly struct:零分配,而且带什么字段由你决定
public readonly struct ChestOpenedEvent
{
    public readonly object Opener;      // 谁开的(会传给门那条序列)
    public readonly string ChestUid;
    public ChestOpenedEvent(object opener, string uid) { Opener = opener; ChestUid = uid; }
}

7.3 写两条清单(加载期构建一次)

public static class GameSequences
{
    private const string ChestUid = "chest_1601";

    // ① 开宝箱:出现音效 → 等点击 → 开箱音效 → 弹面板 → 通知门
    public static readonly RevSequenceDefinition ChestOpen =
        RevSequence.Create("次元\\宝箱开启", RevSequenceConcurrency.RejectPerSource)   // 只能开一次
            .Do("出现音效",     ctx => Sound(ctx)?.Play("Play_Box_Appear", ctx.Source))
            .WaitUntil("等玩家点击",
                       ctx => ctx.Get<IInputService>()?.ConsumeClick() == true,
                       timeoutSeconds: 15f)                                  // 15 秒没点就放弃并告警
            .Do("开箱音效",     ctx => Sound(ctx)?.Play("Play_Box_Open"))
            .Do("弹出奖励面板", ctx => ctx.Get<IUIService>()?.Open("RewardPanel"))
            .Do("通知门:可以开了", ctx => ctx.Events.Publish(new ChestOpenedEvent(ctx.Source, ChestUid)))
            .OnCancel(f => f.Do("收起面板", ctx => ctx.Get<IUIService>()?.Close("RewardPanel")))  // 取消也要收
            .Build();

    // ② 开门:被上面那条的事件触发(顺序里没写它,是事件接力的)
    public static readonly RevSequenceDefinition SlidingDoorOpen =
        RevSequence.Create("次元\\宝箱门开启", RevSequenceConcurrency.ReplacePerSource)
            .Do("滑门音效", ctx => Sound(ctx)?.Play("Play_Door_Slide"))
            .Do("滑门打开", ctx => ctx.Get<ISceneService>()?.SetSlidingDoor(true))
            .Build();

    private static ISoundService Sound(RevSequenceContext ctx) => ctx.Get<ISoundService>();
}

7.4 接上触发:玩家走进范围 → 开宝箱 → 门自动开

// 区域触发源:谁走进来,喊一声(换成你自己的距离检测 / 服务端下发也一样)
public sealed class ChestZone : MonoBehaviour, RevITriggerSource
{
    public string Name => "宝箱区域";
    public event Action<RevTriggerSignal> Triggered;
    public void Dispose() => Triggered = null;

    private void OnTriggerEnter(Collider other)
        => Triggered?.Invoke(new RevTriggerSignal(other.gameObject, Name));
}

public sealed class ChestDemo : MonoBehaviour
{
    private RevSequenceRunner _runner;
    private IDisposable _zoneBinding, _doorBinding;

    private void Awake()
    {
        _runner = RevSequencePlayer.Default;

        // ① 注入业务服务(★ 泛型参数要写接口类型,不然步骤里 ctx.Get<IXxx>() 取不到)
        _runner.Services
            .Add<ISoundService>(new MySoundService())
            .Add<IInputService>(new MyInputService())
            .Add<IUIService>(new MyUIService())
            .Add<ISceneService>(new MySceneService());

        // ② 区域触发 → 开宝箱清单(AddComponent 出来的就是上面那个实现接口的触发源)
        var zone = gameObject.AddComponent<ChestZone>();
        _zoneBinding = _runner.Bind(zone, GameSequences.ChestOpen);

        // ③ 宝箱开完 → 门自动开(事件接力,一行)
        _doorBinding = _runner.BindEvents<ChestOpenedEvent>(
            _runner.Events, GameSequences.SlidingDoorOpen, e => e.Opener);
    }

    private void OnDestroy()
    {
        // ★ 注册什么就反注册什么(否则对象没了、回调还在)
        _zoneBinding?.Dispose();
        _doorBinding?.Dispose();
    }
}

7.5 这段代码用到了前面哪几章

代码里的东西出自
.Do / .WaitUntil / .OnCancel第 3 章五个常用写法
RejectPerSource(只能开一次)第 4 章 4.4 防连点
触发源 + Bind(走进去就播)第 4 章方式二
Publish + BindEvents(门自动开)第 4 章方式三
服务注入 + ctx.Get<T>()第 7 章 7.1(也是坑 4 的正解)
timeoutSeconds(防永久卡死)第 3 章第 ② 个卡片
把清单放 static readonly第 1 章"把清单存起来"
想看更多实战? 仓库里有一份可直接跑的 Demo(5 个参考实现真实场景:宝箱开门 / 灵果采集 / 农场礼盒 / NPC 特写 / 火箭演出): Assets\Revolution.Demo\RevActionSequence.Demo\。把它挂在场景任意物体上,按 1 ~ 9 就能逐个体验。

8怎么确认它真的在跑 / 出问题怎么查

好消息:不用你自己加日志,框架已经打好了。

8.1 先看 Console:出问题框架会主动告诉你

框架的日志走 RevLog(tag = ActionSequence,要静音就 RevLog.MuteTag("ActionSequence")),每条都写明哪条序列、第几步、步骤名:

Console 里看到意思
「宝箱开启」第 2/4 步「等玩家点击」抛异常 → 已按取消处理这一步的代码报错了;整条序列按取消收尾(OnCancel / Finally 照常执行),其它序列不受影响
「宝箱开启」第 2/4 步「等玩家点击」等了 15s 条件仍未成立 → 超时放行等待超时了,流程继续往下走
…等待的异步任务失败了WaitTask 等的任务抛了异常
…收尾步骤「xxx」抛异常(已跳过,继续执行其余收尾)收尾里某一步报错
…的并发策略是 RejectPerSource,但 Play 时没传触发者忘了传 source,策略变成了"同时只能有一条"(每条序列只提醒一次)
没有注册服务 IXxx:请先 runner.Services.Add<IXxx>(实现)用了 ctx.Require<IXxx>(),但服务没注册

另外两个观察口子:

// 回调:想统计什么就在这里记(订阅者抛异常会被记日志,不影响收尾)
runner.RunFinished += run => Debug.Log($"[序列] {run} 结束({run.Status},{run.Elapsed:F2}s)");

// 句柄与引擎:还在不在跑 / 现在有几条在跑
if (handle.IsValid) …
Debug.Log(RevSequencePlayer.Default.ActiveCount);

8.2 三种典型问题,先看哪里

现象先看什么通常是
完全没反应Play 的返回值 / Console被并发策略拦了(4.4,Play 返回空句柄);自己 new 的引擎没 Tick(坑 1);忘了 .Build() 或传 null 会直接抛异常
停在半路不动Console 里的超时 / 异常日志那一步的等待条件没满足(没配超时就会一直等);或触发者被销毁、序列已被自动取消
表现乱了 / 两个玩家互相影响这条序列的触发者是谁坑 3(在清单里用了外面的变量)或 source 传错(4.4)

9一页速查卡

打印出来贴在显示器旁边,写序列时不用回来翻文档。

写清单(Builder)

开一张清单RevSequence.Create("名字", 策略)
立刻做一件事.Do("名字", ctx => …) / .Do(ctx => …)
停一会儿.Wait(1f) / .WaitFrames(6)
等条件成立(建议带超时).WaitUntil("名字", ctx => 条件, 15f)
随时间变化.Tween("名字", 秒, (ctx, t) => …);Unity:.MoveTo / .ScaleTo / .FadeTo / .SetActive
二选一.If("名字", ctx => 条件, then: b => …, otherwise: b => …)
等异步任务.WaitTask(ctx => 任务);要结果:.WaitTask(ctx => 任务, (ctx, 结果) => …)
启动一个动作 + 盯它结束.DoBlocking("名字", 启动, 判断结束)
两件事同时做.Parallel("名字", p => p.Do(…).Do(…))
重复几遍.Repeat("名字", 3, r => …)
嵌套另一条清单.Sequence("名字", 别的清单)
发事件(通知别人).Publish(new 我的事件(…)) / .Publish(ctx => new 我的事件(ctx.Source))
收尾(跑完 / 取消都执行).Finally(f => f.Do("恢复", …))
只在取消时收尾.OnCancel(f => f.Do("回收", …))
跑完 / 被取消 回调.OnCompleted / .OnCancelled
打调试日志.Log("…")
定稿(只能一次,结果存静态字段).Build()
自定义步骤(上面都满足不了时).Step(new MyStep())(每处 new 一个)

跑起来 / 停掉

最省事地播放(Unity)清单.Play(gameObject)(物体销毁时自动取消)
想 await 它跑完await 清单.PlayAsync(gameObject)
自己 new + 自己喂时间new RevSequenceRunner(); r.Tick(dt)
取消一条 / 全部handle.Stop() / RevSequencePlayer.Default.StopAll()
它还活着吗 / 现在几条在跑handle.IsValid / runner.ActiveCount
暂停时也要跑RevSequencePlayer.UseUnscaledTime = true

触发 / 依赖注入

区域等触发源 → 清单runner.Bind(触发源, 清单)
事件 → 清单(一行)runner.BindEvents<事件>(bus, 清单, e => e.Owner)
注册业务服务RevSequencePlayer.Default.Services.Add<IXxxService>(实现)(写成具体类型也能按接口取到)
步骤里取服务ctx.Require<IXxxService>()(必须有)/ ctx.Get<IXxxService>()(可以没有)
步骤里取触发者ctx.Source / ctx.SourceAs<T>() / ctx.SourceGameObject()
步骤里的时间ctx.Elapsed / ctx.DeltaTime(别读 Time.time)

三条永远不要忘

① 清单只构建一次(static readonly)—— 否则每次触发都付构建成本,而且"_同源顶替/拒绝"策略会失效。

② 用了后两种并发策略就要传 source —— 不传的话"同一个触发者"判断不出来,策略会变成"全场唯一"。

③ 这条序列占用了什么,就在 .Finally(只在取消时才需要的放 .OnCancel)里还回去 —— 生成的特效、加载的资源、推近的镜头、注册的监听。

下一步读什么
  • 只想用起来:就是本文件(第 0~9 章够了);
  • 想搞懂"为什么这么设计""和参考实现差在哪""它适合哪些业务":看同目录的《动作序列架构解析》;
  • 想直接跑起来看效果:Assets\Revolution.Demo\RevActionSequence.Demo\(挂上入口脚本,按 1~9 体验五个参考实现真实场景)。