Verse Wiki — 写给 UE6 时代的 Verse 互动手册
第三章 · 第 11 课

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

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

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

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

那怎么停?官方给了两个出口:break 跳出当前这个 loop;return 更狠,连所在的函数一起退出(还能带返回值)。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 自己不数圈,所以我们请出老朋友 var:每圈 set Lap += 1,数到 3 就 break 出圈。loop 自己不数圈,所以我们在变量面板里新建一个整数变量 Lap。把上面那段想成节点图:循环线每转一圈,先接一个 Set 节点给 Lap 加 1(set Lap += 1),再用 Print String 打一行「第 N 圈」;接着一个 Branch 判断 Lap >= 3 能不能走通,走通就连去 Break 节点跳出循环线;跳出后,循环外那句 Print String 打出「冲线」。顺带解决一个高频疑问:Verse 没有 while,没有 do-while,也没有 continue。其他语言的 while (条件),在 Verse 里就是「loop + 条件反过来 break」:顺带解决一个高频疑问:如果你在别处见过 whiledo-whilecontinue 这类循环写法,Verse 一个都没有。想要「条件成立才继续转」的循环,套路是:loop 无条件转,循环体开头用一个 Branch 检查条件,一旦走不通就 Break 出圈——正是下面这段:

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

记忆口诀:for 管「遍历」,loop 管「重复」。前者圈数由集合决定,天生会停;记忆口诀: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) 睡一秒,醒来接着干。这就是「心跳模式」:干一点活,Sleep(1.0) 睡一秒,醒来接着干——蓝图里就是在循环体尾巴上挂一个时长 1 秒的 Delay 节点,时间到了再从循环线开头继续。Sleep 来自 /Verse.org/Simulation 模块,签名是 Sleep(Time:float)<suspends>:void——注意那个 <suspends>:Sleep 是会「挂起」的异步函数,所以调用它的函数也必须标 <suspends>,好在 OnBegin 天生就带。Sleep 这颗 Delay 属于会「等一会儿」的节点——蓝图里那种带小钟表图标的 latent 节点。Verse 用 <suspends> 这个标记来说明「这个函数体里有带小钟表的节点」;凡是里面挂了这种节点的函数,调用它的执行线也必须能等,所以也得跟着标 <suspends>。好在这一课的入口 OnBegin(相当于 Event BeginPlay)天生就带,不用你操心。还有个特殊用法:Sleep(0.0) 不是「不睡」,而是「睡到下一帧」——loop + Sleep(0.0) 就是每帧跑一次的循环。

比「每秒问一遍」更优雅的是「有事叫我」:用事件的 Await() 让循环干脆睡到事件发生才醒——比「每秒问一遍」更优雅的是「有事叫我」:蓝图里你早就会绑事件(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 还会把触发的 agent 交出来,想接住就写 Agent := Bell.InteractedWithEvent.Await())。比起用 Sleep(0.0) 每帧去问「按了吗?按了吗?」,loop + Await 省性能得多,是处理重复事件的标准姿势——跟蓝图里「绑一次事件、之后每次响都自动跑」是同一个道理。(Await() 醒来时还会把触发它的那个玩家引脚交出来,类似事件节点上的 Instigator 输出;想接住就写 Agent := Bell.InteractedWithEvent.Await()。)这套「异步 + 心跳」的组合背后是 Verse 的并发系统——spawn、race 这些分身术,第 20 课会专门拆解。这套「等事件 + 心跳」的组合背后是 Verse 的并发系统——像 spawn(从当前线里岔出一条新线)、race(几条线赛跑、第一个到终点的算数)这些「分身术」,第 20 课会专门拆解。

现在动手把一台心跳设备补完整:第一个空是「数到 3 就出圈」的关键字,第二个空是「每拍之间歇一秒」的函数。现在动手把一台心跳设备补完整:第一个空是「数到 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。麻烦在于「后半件事」常常有好几个出口要照顾——正常走完要做,中途 Break 跳出要做,提前把整个函数结束掉(return)也要做,每个出口都得把收尾的那几个节点重连一遍,漏一个就是 bug。

defer(延迟执行)就是为此而生:控制流执行到 defer 这一行时,并不立刻运行它的代码块,而是把它「登记」下来;等控制流离开当前作用域时——不管是自然走完、break 还是 return——登记过的 defer 块才执行。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 必须先被「执行到」才算登记——写在 if 分支里的 defer,只有控制流真走进那个分支才会登记;如果函数在到达 defer 之前就退出了,它压根不会运行。defer 必须先被「执行到」才算登记——放在 Branch 某条出线上的 defer,只有执行线真的走进那条分支才会登记;要是执行线在还没走到 defer 之前就离开了函数,它压根不会跑。第二,只要登记过,提前退出(break / return)也会触发它——多出口统一收尾正是它的价值。只要登记过,提前从任何出口离开(Break 跳出、或提前结束整个函数)都会触发它——多个出口共用一套收尾,正是它的价值。第三,它是块级作用域:defer 跟随「当前代码块」,离开那个块就执行,而不是像某些语言的 finally / 函数级 defer 那样等到函数结束。它是块级的:defer 只跟着「它所在的这一段图」,执行线一离开那段就补跑,而不是非要等到整个函数结束才跑。

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

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

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

四、常见坑:一行代码让全岛瘫痪

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

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

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

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

五、关卡挑战 ★

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

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

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

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

拓展阅读

拓展 · EXTRA

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

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

进入拓展 →

拓展 · EXTRA

ErrRuntime_InfiniteLoop 深挖

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

进入拓展 →

技巧 · EXTRA

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

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

进入拓展 →