Verse Wiki — 写给蓝图作者的 Verse 手册
第四章 · 第 15 课

loop、break 与 defer:永动机与善后师

ForEach Loop 只能跑「已知圈数」,这一课解锁真正的永动机:loop 无条件重复、break 紧急出口、Sleep 心跳节拍,再请一位专管善后的 defer。学完你就能写出跑一整局游戏的循环,还知道怎么不把设备卡死。

一、loop 与 break:没有条件的循环

上一课的 ForEach Loop 节点像绕操场跑固定圈数:Array 里有多少个元素,Loop Body 就逐个跑多少次,跑完自动停。但游戏里更多的是「不知道要跑多久」的活儿——巡逻的守卫、循环播放的机关、一局接一局的回合。这类活交给 loop:蓝图里最接近的画面,是把一根执行线从节点尾巴绕回它自己的开头,无条件一圈接一圈地转,没有任何条件判断,也不问「还要不要继续」。

那怎么停?官方给了两个出口:break 跳出当前这个 loop;return 更狠,直接把整个蓝图函数一起结束掉(还能顺手把值送到函数的 Return 输出引脚)。注意 break 只跳出最内层的那个 loop——嵌套 loop 里,内层喊 break,外层照转不误。

lap_counter_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

lap_counter_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        var Lap:int = 0
        loop:
            set Lap += 1
            Print("第 {Lap} 圈")
            if (Lap >= 3):
                break
        Print("跑完 {Lap} 圈,冲线!")

loop 自己不数圈,所以我们在变量面板里新建一个整数变量 Lap。把上面那段想成节点图:循环线每转一圈,先接一个 Set 节点给 Lap 加 1(set Lap += 1),再用 Print String 打一行「第 N 圈」;接着用一个 Branch 判断 Lap >= 3 是否成立,成立就从 True 引脚连去 Break 节点跳出循环线;跳出后,循环外那句 Print String 打出「冲线」。顺带解决一个高频疑问:如果你在别处见过 whiledo-whilecontinue 这类循环写法,Verse 一个都没有。想要「条件成立才继续转」的循环,套路是:loop 无条件转,循环体开头用一个 Branch 检查条件,条件一旦不成立就从 False 引脚 Break 出圈——正是下面这段:

while_in_verse.verse
# 其他语言:while (Energy > 0) { ... }
# Verse 的等价写法:
var Energy:int = 3
loop:
    if (Energy > 0):
        Print("还有 {Energy} 格体力")
        set Energy -= 1
    else:
        break

记忆口诀:ForEach Loop 管「逐个遍历」,loop 管「无条件重复」。前者跑几圈由 Array 的长度决定,天生会停;后者天生不停,停不停全看你安排的出口。这也埋下一个安全问题——出口忘了装怎么办?第四节的「常见坑」专门聊这个。

二、心跳模式:loop + Sleep,设备的节拍器

「数到 3 就 break」只是热身。设备里 loop 真正的主场,是跑一整局游戏都不停的循环——每秒检查一次状态、每 5 秒刷一波补给、每帧更新一次位置。这种循环故意不写 break,靠 Sleep 来「喘气」:

heartbeat_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

heartbeat_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        loop:
            Print("滴——巡逻一圈")
            Sleep(1.0)

这就是「心跳模式」:干一点活,Sleep(1.0) 睡一秒,醒来接着干——蓝图里就是在循环体尾巴上挂一个时长 1 秒的 Delay 节点,时间到了再从循环线开头继续。Sleep 相当于蓝图里的 Delay——那种带小钟表图标、会「等一会儿」的 latent 节点。Verse 用 <suspends> 这个标记来说明「这个函数体里有带小钟表的节点」;凡是里面挂了这种节点的函数,调用它的执行线也必须能等,所以也得跟着标 <suspends>。好在这一课的入口 OnBegin(相当于 Event BeginPlay)天生就带,不用你操心。还有个特殊用法:Sleep(0.0) 不是「不睡」,而是「睡到下一帧」——loop + Sleep(0.0) 就是每帧跑一次的循环。

比「每秒问一遍」更优雅的是「有事叫我」:蓝图里你早就会绑事件(Bind Event)——这里的 Await() 就是让循环在这一步睡死,直到那个事件响一声才醒过来往下走——

door_bell_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

door_bell_device := class(creative_device):

    @editable
    Bell:button_device = button_device{}

    OnBegin<override>()<suspends>:void =
        loop:
            Bell.InteractedWithEvent.Await()
            Print("有人按了门铃!")

每圈循环在 Await() 处睡着,按钮被按下才醒来干活,然后转回圈头继续睡。比起用 Sleep(0.0) 每帧去问「按了吗?按了吗?」,loop + Await 省性能得多,是处理重复事件的标准姿势——跟蓝图里「绑一次事件、之后每次响都自动跑」是同一个道理。(Await() 醒来时还会把触发它的那个玩家引脚交出来,类似事件节点上的 Instigator 输出;想接住就写 Agent := Bell.InteractedWithEvent.Await()。)这套「等事件 + 心跳」的组合背后是 Verse 的并发系统——像 spawn(从当前线里岔出一条新线)、race(几条线赛跑、第一个到终点的算数)这些「分身术」,第 24 课会专门拆解。

现在动手把一台心跳设备补完整:第一个空是「数到 3 就跳出循环线」的那个词(想想 Break 节点),第二个空是「每拍之间歇一秒」的那个 Delay(想想 Sleep)。

three_beats_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

three_beats_device := class(creative_device):

    var Beats:int = 0

    OnBegin<override>()<suspends>:void =
        loop:
            set Beats += 1
            Print("心跳 {Beats}")
            if (Beats >= 3):
                ____
            ____(1.0)

三、defer:离开房间前,记得锁门

循环会了,再认识一位「善后师」。游戏里很多操作是成对的:开门就要关门、显示 UI 就要隐藏、亮灯就要熄灯。麻烦在于「后半件事」常常有好几个出口要照顾——正常走完要做,中途 Break 跳出要做,提前把整个函数结束掉(return)也要做,每个出口都得把收尾的那几个节点重连一遍,漏一个就是 bug。

defer(延迟执行)就是为此而生:当执行线走到 defer 这个节点时,它并不马上跑自己那一小段,而是先把这段「登记」到一张待办清单上;等执行线要离开它所在的这段图时——不管是自然跑完、Break 跳出、还是提前把函数结束(return)——登记过的这段才补跑。相当于进门时在门把手上挂个牌子:「出去前记得锁门」,之后无论从哪个出口离开,牌子都会提醒你。点「运行下一步」,注意第 8 行登记、最后才轮到第 9 行执行的顺序:

vault_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

vault_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        Print("进入金库")
        defer:
            Print("离开时:锁门")
        Print("拿走宝石")
        Print("走到金库门口")
输出日志

点「运行下一步」,看代码怎么一行行执行。

三条规则记牢:第一,defer 必须先被「执行到」才算登记——放在 Branch 某条出线上的 defer,只有执行线真的走进那条分支才会登记;要是执行线在还没走到 defer 之前就离开了函数,它压根不会跑。第二,只要登记过,提前从任何出口离开(Break 跳出、或提前结束整个函数)都会触发它——多个出口共用一套收尾,正是它的价值。第三,它是块级的:defer 只跟着「它所在的这一段图」,执行线一离开那段就补跑,而不是非要等到整个函数结束才跑。

defer 和 loop 还能配合:在 loop 体内登记的 defer,每圈离开循环体时都会执行一次——

patrol_cleanup.verse
loop:
    defer:
        Print("这一圈的收尾")
    Print("巡逻干活")
    break    # break 出圈前,已登记的 defer 照样执行

限制也有:defer 那一小段里不能写 return 结束函数,也不能写一个跳到它外层循环的 Break(但你在这段里自己开一个 loop、自己 Break 出来是允许的)。另外两条实践提醒:不要在 defer 里放会「等一会儿」的 latent 节点(带小钟表那种);也别把 defer 摆在一段图的最后一步——都到最后一步才登记,等于白登记。多个 defer 之间的执行先后,官方文档没有明说,别让清理逻辑依赖它们的顺序。

四、常见坑:一行代码让整个关卡瘫痪

本课的坑比前几课都「贵」,因为循环写错时,蓝图左上角的 Compile 不会亮红——它不是编译期就能拦下的错,而是游戏跑起来才炸的运行时事故。头号事故长这样:

frozen_device.verse — 反面教材
# ✗ 千万别这么写
OnBegin<override>()<suspends>:void =
    var Count:int = 0
    loop:
        set Count += 1
        # 既没有 break,也没有 Sleep / Await——
        # 这一帧永远跑不完

一个既不 Break 跳出、也没有任何「喘气点」(Sleep 那种 Delay、或 Await 等事件)的 loop,会试图在一帧之内转无穷圈。Verse 的运行时装了死循环检测,直接掐断并抛出 ErrRuntime_InfiniteLoop 运行时错误。更疼的是官方明说的后果:运行时错误发生后,该设备之后的所有 Verse 执行都会停止——症状就是按钮突然失灵、机关集体罢工,像整个关卡被冻住了。想象一下事故现场:玩家按门铃,门铃毫无反应;你翻日志,发现祸根是另一段循环忘了装出口。心跳模式之所以安全,全靠每圈的 Sleep 把工作摊到多帧去。想深挖这个错误的判定机制(以及「明明没写死循环也中招」的离奇案例),见本课拓展页。

其余几个坑,按踩中率排序:

蓝图对照

这一课有一半内容能在蓝图里找到对应节点,另一半——defer——蓝图压根没有。两种情况都值得看一眼。

蓝图里的做法 Verse 里的写法 差异
While Loop 节点,条件引脚接一个 Boolean loop: 无条件转,循环体开头用 if 检查条件,不成立就 break Verse 没有 whiledo-whilecontinue;条件判断从循环头挪进了循环体,出口由你自己安排
Break 节点 break 行为一致:只跳出最内层那一层循环。区别是 Verse 的 break 只对 loop 有效,在 for 里写会直接报错
Do N 节点(前 N 次放行,之后关闭) 自己维护一个 var Lap:int,set Lap += 1 后判 if (Lap >= 3): break 蓝图给了现成节点还自带状态;Verse 不提供,计数变量得你自己建、自己加——多写三行,但状态摆在明处,不会「忘了 Reset」
Do Once 节点(只放行第一次) 自己维护一个 var HasRun:logic = false,if (not HasRun?): 里干活再 set HasRun = true 同上:蓝图把「只跑一次」封装成一个节点,Verse 要你把那个隐藏的标志变量显式写出来
Delay 节点(带小钟表图标的 latent 节点) Sleep(1.0) 能力相同,但 Verse 要求调用它的函数标上 <suspends>;Sleep(0.0) 表示「睡到下一帧」,相当于把活挪到下一个 Tick
Bind Event to …,绑一次之后每次触发都自动跑 loop: 里写 Bell.InteractedWithEvent.Await() 蓝图是「注册一个回调」,Verse 是「循环停在这一行等」——写法从事件图表的另一处跳转,变成了主流程里的一行,读起来是顺序的
成对收尾(开门/关门、显示/隐藏)要在每个出口各连一遍 defer: 登记一次,离开这段代码块时必定执行 蓝图没有对应物:正常跑完、Break 跳出、提前 return,三个出口都会触发同一段收尾,漏接一条线的经典 bug 从此消失

前几行的差异有个共同规律:蓝图把常见套路封装成节点,Verse 把它们还原成你自己写的几行代码。Do N、Do Once 这类节点里都藏着一个你看不见的状态变量,好处是顺手,坏处是它跨调用记着上一次的结果,排查起来要靠猜。Verse 逼你把那个变量写出来,代码长了几行,但「它现在是几」永远看得见。

defer 则是纯粹的净增量——它解决的是节点图里最容易漏的那类 bug:一个函数有三个出口,收尾节点只连了两个。蓝图作者对这种 bug 的记忆通常很深刻,而 defer 把「记得收尾」从人的纪律变成了语言的保证。唯一要留神的是本课「常见坑」里那条:它必须先被执行到才算登记。

关卡挑战 ★

循环与善后的知识都装备上了,三道关卡验收。答错零惩罚,可以一直重试。

一个 loop 里既没有 break,也没有 Sleep / Await,会发生什么?

关于 defer,下面哪个说法是对的?

想在 ForEach Loop 遍历中途提前退出整个循环,正确的做法是?

拓展阅读

拓展 · EXTRA

defer 与异步取消:一个跨年未修的坑

race 取消函数时,已登记的 defer 可能不执行——著名 bug 报告拆解与自保守则。

进入拓展 →

拓展 · EXTRA

ErrRuntime_InfiniteLoop 深挖

运行时如何判定「死循环」、为什么没写死循环也会中招,以及去哪里查线上错误。

进入拓展 →

技巧 · EXTRA

官方范例拆解:loop + Sleep 的两种形态

全局节拍器与周期性机关——两份官方教程里的游戏循环实战。

进入拓展 →