拓展 · EXTRA
defer 与异步取消:一个跨年未修的坑
race 取消函数时,已登记的 defer 可能不执行——著名 bug 报告拆解与自保守则。
进入拓展 →forForEach Loop 只能跑「已知圈数」,这一课解锁真正的永动机:loop 无条件重复、break 紧急出口、Sleep 心跳节拍,再请一位专管善后的 defer。学完你就能写出跑一整局游戏的循环,还知道怎么不把设备卡死。
上一课的 for 像绕操场跑固定圈数:集合有多少元素,就跑多少圈,跑完自动停。ForEach Loop 节点像绕操场跑固定圈数:Array 里有多少个元素,Loop Body 就逐个跑多少次,跑完自动停。但游戏里更多的是「不知道要跑多久」的活儿——巡逻的守卫、循环播放的机关、一局接一局的回合。这类活交给 loop:它无条件重复自己的代码块,没有循环条件,不问「还要不要继续」,只管一圈接一圈地转。蓝图里最接近的画面,是把一根执行线从节点尾巴绕回它自己的开头,无条件一圈接一圈地转,没有任何条件判断,也不问「还要不要继续」。
那怎么停?官方给了两个出口:break 跳出当前这个 loop;return 更狠,连所在的函数一起退出(还能带返回值)。return 更狠,直接把整个蓝图函数一起结束掉(还能顺手把值送到函数的 Return 输出引脚)。注意 break 只跳出最内层的那个 loop——嵌套 loop 里,内层喊 break,外层照转不误。
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」:顺带解决一个高频疑问:如果你在别处见过 while、do-while、continue 这类循环写法,Verse 一个都没有。想要「条件成立才继续转」的循环,套路是:loop 无条件转,循环体开头用一个 Branch 检查条件,一旦走不通就 Break 出圈——正是下面这段:
# 其他语言: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 的长度决定,天生会停;后者天生不停,停不停全看你安排的出口。这也埋下一个安全问题——出口忘了装怎么办?第四节的「常见坑」专门聊这个。
「数到 3 就 break」只是热身。设备里 loop 真正的主场,是跑一整局游戏都不停的循环——每秒检查一次状态、每 5 秒刷一波补给、每帧更新一次位置。这种循环故意不写 break,靠 Sleep 来「喘气」:
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() 就是让循环在这一步睡死,直到那个事件响一声才醒过来往下走——
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)。
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)
循环会了,再认识一位「善后师」。游戏里很多操作是成对的:开门就要关门、显示 UI 就要隐藏、亮灯就要熄灯。麻烦在于「后半件事」常常有好几个出口要照顾——正常走完要做,中途 break 要做,提前 return 也要做,每个出口抄一遍收尾代码,漏一个就是 bug。麻烦在于「后半件事」常常有好几个出口要照顾——正常走完要做,中途 Break 跳出要做,提前把整个函数结束掉(return)也要做,每个出口都得把收尾的那几个节点重连一遍,漏一个就是 bug。
defer(延迟执行)就是为此而生:控制流执行到 defer 这一行时,并不立刻运行它的代码块,而是把它「登记」下来;等控制流离开当前作用域时——不管是自然走完、break 还是 return——登记过的 defer 块才执行。defer(延迟执行)就是为此而生:当执行线走到 defer 这个节点时,它并不马上跑自己那一小段,而是先把这段「登记」到一张待办清单上;等执行线要离开它所在的这段图时——不管是自然跑完、Break 跳出、还是提前把函数结束(return)——登记过的这段才补跑。相当于进门时在门把手上挂个牌子:「出去前记得锁门」,之后无论从哪个出口离开,牌子都会提醒你。点「运行下一步」,注意第 8 行登记、最后才轮到第 9 行执行的顺序:
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,每圈离开循环体时都会执行一次——
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 不会亮红——它不是编译期就能拦下的错,而是游戏跑起来才炸的运行时事故。头号事故长这样:
# ✗ 千万别这么写
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 把工作摊到多帧去。想深挖这个错误的判定机制(以及「明明没写死循环也中招」的离奇案例),见本课拓展页。
其余几个坑,按踩中率排序:
<suspends> 函数里写 loop + Sleep —— 编译错误:Sleep 需要挂起能力。游戏循环要放进 OnBegin<suspends>,或者用 spawn 开分身(第 20 课见)。<suspends> 的函数里写 loop + Sleep —— Compile 会亮红:Sleep 那种带小钟表的 Delay 需要「能等」的能力,而这个函数没声明自己能等。把游戏循环放进入口 OnBegin<suspends>(它天生能等),或者用 spawn 岔出一条新线去跑(第 20 课见)。循环与善后的知识都装备上了,三道关卡验收。答错零惩罚,可以一直重试。
一个 loop 里既没有 break,也没有 Sleep / Await,会发生什么?
关于 defer,下面哪个说法是对的?
想在 forForEach Loop 遍历中途提前退出整个循环,正确的做法是?