〇它是干什么的
先花 30 秒建立直觉,再动手写。
RevSequencePlayer.Play( // ① 播放(Unity 里连"每帧驱动"都不用管) RevSequence.Create("开宝箱") // ② 开一张清单(名字会出现在日志里) .Do("播音效", ctx => Sound.Play("Box_Open")) // ③ 一步一步写:Do = 立刻做一件事 .Wait(1f) // Wait = 停一会儿 .WaitUntil("等玩家点击", ctx => 点了, 15f)); // WaitUntil = 等条件成立(带超时保护)
上面是"一行到底"的写法,适合临时的一次性演出。要被反复触发,就先 .Build() 存成静态字段再 Play
(第 1 章一句话讲清:写一次、用一辈子)。
就这 4 个 —— 剩下的(并行、事件、自定义步骤、服务注入……)都是用到再看的增值项。
清单里如果有一项是"等玩家点一下",框架就停在那儿等,玩家点了再往下走。这个停等的过程中,游戏其它部分照常运行(不会卡住)。
生活里它就长这样
游戏里的"开宝箱"是同一件事:
它解决什么问题(如果你现在是这样写代码的)
// 没有动作序列时,"先做 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)" | ❌ 用状态机 |
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);
谁负责"每帧推进"(三种方式,挑一个)
| 方式 | 怎么用 | 什么时候选它 |
|---|---|---|
| ① 全局默认(最常用) | flow.Play(gameObject); | 什么都不想配 —— Unity 里九成场景够用。暂停界面里的演出:RevSequencePlayer.UseUnscaledTime = true |
| ② 场景级组件 | 把 RevSequencePlayer 挂到场景物体上,勾选"使用自己的引擎" | 这条流程属于某个玩法场景,希望它随场景卸载一起收掉 |
| ③ 自己 new + 自己喂时间 | var r = new RevSequenceRunner(); r.Play(flow); r.Tick(dt); | 单元测试、需要暂停/快进/慢放、或者你有自己的时间源 |
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.IsValid | Play 的返回值就是句柄 |
| 在步骤里拿业务系统 | ctx.Require<ISoundService>()(必须有)/ ctx.Get<ISoundService>()(可以没有) | 框架不认识你的系统,全靠这样注入(见第七章)。Require 取不到会直接报错并告诉你怎么注册,比 Get()?.X() 静默跳过好查得多 |
| 在步骤里知道"谁触发的" | ctx.Source / ctx.SourceAs<T>() / ctx.SourceGameObject() | 例如"是哪个玩家开的这个箱子" |
3五个最常用的写法(逐个讲透)
每个卡片都是"什么时候用 → 怎么写 → 要注意什么"。
① 前两步是"立刻"的:同一帧内连着就做完了(不会一步占一帧)。
② 第 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 => 条件, 超时秒数) —— 等一个条件成立// 等玩家点击(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(...)) —— 收拾现场(跑完、取消都执行).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; }
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();
触发源只有这一种写法(实现 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 | 忽略这次(旧的继续跑完) | 宝箱只能开一次、演出期间防连点 |
source 判断。不传(Play(清单))的话,所有人会被当成"同一个空触发者",策略就变成了"全场只允许一条" —— 通常不是你想要的。
这种情况框架会在开发期提醒你一次(日志里会写清怎么改)。
5中途取消:怎么停、怎么收拾
这是最容易被忽略、也最容易出事的一章。好消息是它只有两个 API。
5.1 什么时候会被取消(大多数时候不是你主动取消)
- 玩家点了关闭 / 中途退出;
- 切场景、断线重连 —— 触发者(Play 时传的
source)被 Destroy,框架会自动取消它的序列; - 被"同源顶替"策略挤掉(第 4.4 节);
- 某一步的代码抛了异常(框架打日志后按取消处理);
- 你自己调了
Stop(比如玩家点了"取消加载")—— 在步骤里 Stop 自己也行,后面的步骤不会再执行。
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 执行;OnCancel 不执行(没有被打断).Do("生成特效", ctx => spawned = Effect.Spawn("Fx_Open")) .Do("回收特效", ctx => Effect.Recycle(spawned)) // 玩家中途退出 → 回收那一步永远走不到 → 特效留在场上
.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 个坑
每个坑都是"症状 → 原因 → 怎么改"。前两个坑占了新手问题的一大半。
原因(两种):
- 没人"喂时间"——你用自己 new 的引擎(方式 ③),但忘了每帧调
Tick; - 被并发策略忽略了——你把清单声明成
RejectPerSource,而同一个source已经有一条在跑。
var r = new RevSequenceRunner(); r.Play(Flow); // 就没了 → 永远不动
Flow.Play(gameObject);
// 或
r.Play(Flow); r.Tick(Time.deltaTime);if (!handle.IsAssigned) Debug.Log("被策略忽略了")。Wait(2f),一秒就过去了;或者所有表现都加速。原因:场景里有两个在推进同一个引擎的驱动组件 —— 序列每帧被推进两次,等待时间自然减半。
原因:在写清单的 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 章第 ⑥ 个卡片
Play(builder) 播(同源策略会静默失效)RejectPerSource(只允许一次),连点两下却跑了两遍;或者没感觉到"防连点"生效。原因:Play(builder) 每次都会 重新构建一份新清单,而"同源策略"判定的是"同一份清单实例" —— 每次都是新的,自然永远不命中。
_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) 完全没问题。原因:清单是不可变的(Build() 只能调一次)。你那句 .Do(...) 加在了已经 Build 之后,框架会直接报错拦你,而不是"悄悄不生效"。
static readonly 字段里,改代码 → 重新编译 → 新清单自然生效。这也正是它比"Unity 里拖组件"好维护的地方(改动可 diff、可 Code Review)。原因:把"回收"写在了正常流程的末尾,而流程被中途取消时根本走不到那里。
.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); }
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 章"把清单存起来" |
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);
- 要「每步流水」:
.Log("走到开门这一步了");步骤名就是你的路标(省略名字时日志里显示"文件名:行号")。
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 一个)跑起来 / 停掉
清单.Play(gameObject)(物体销毁时自动取消)await 清单.PlayAsync(gameObject)new RevSequenceRunner(); r.Tick(dt)handle.Stop() / RevSequencePlayer.Default.StopAll()handle.IsValid / runner.ActiveCountRevSequencePlayer.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体验五个参考实现真实场景)。