拓展 · EXTRA
defer 与异步取消:一个跨年未修的坑
race 取消函数时,已登记的 defer 可能不执行——著名 bug 报告拆解与自保守则。
进入拓展 →ForEach Loop 只能跑「已知圈数」,这一课解锁真正的永动机:loop 无条件重复、break 紧急出口、Sleep 心跳节拍,再请一位专管善后的 defer。学完你就能写出跑一整局游戏的循环,还知道怎么不把设备卡死。
上一课的 ForEach Loop 节点像绕操场跑固定圈数:Array 里有多少个元素,Loop Body 就逐个跑多少次,跑完自动停。但游戏里更多的是「不知道要跑多久」的活儿——巡逻的守卫、循环播放的机关、一局接一局的回合。这类活交给 loop:蓝图里最接近的画面,是把一根执行线从节点尾巴绕回它自己的开头,无条件一圈接一圈地转,没有任何条件判断,也不问「还要不要继续」。
那怎么停?官方给了两个出口:break 跳出当前这个 loop;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 自己不数圈,所以我们在变量面板里新建一个整数变量 Lap。把上面那段想成节点图:循环线每转一圈,先接一个 Set 节点给 Lap 加 1(set Lap += 1),再用 Print String 打一行「第 N 圈」;接着用一个 Branch 判断 Lap >= 3 是否成立,成立就从 True 引脚连去 Break 节点跳出循环线;跳出后,循环外那句 Print String 打出「冲线」。顺带解决一个高频疑问:如果你在别处见过 while、do-while、continue 这类循环写法,Verse 一个都没有。想要「条件成立才继续转」的循环,套路是:loop 无条件转,循环体开头用一个 Branch 检查条件,条件一旦不成立就从 False 引脚 Break 出圈——正是下面这段:
# 其他语言:while (Energy > 0) { ... }
# Verse 的等价写法:
var Energy:int = 3
loop:
if (Energy > 0):
Print("还有 {Energy} 格体力")
set Energy -= 1
else:
break
记忆口诀: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) 睡一秒,醒来接着干——蓝图里就是在循环体尾巴上挂一个时长 1 秒的 Delay 节点,时间到了再从循环线开头继续。Sleep 相当于蓝图里的 Delay——那种带小钟表图标、会「等一会儿」的 latent 节点。Verse 用 <suspends> 这个标记来说明「这个函数体里有带小钟表的节点」;凡是里面挂了这种节点的函数,调用它的执行线也必须能等,所以也得跟着标 <suspends>。好在这一课的入口 OnBegin(相当于 Event BeginPlay)天生就带,不用你操心。还有个特殊用法:Sleep(0.0) 不是「不睡」,而是「睡到下一帧」——loop + Sleep(0.0) 就是每帧跑一次的循环。
比「每秒问一遍」更优雅的是「有事叫我」:蓝图里你早就会绑事件(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() 醒来时还会把触发它的那个玩家引脚交出来,类似事件节点上的 Instigator 输出;想接住就写 Agent := Bell.InteractedWithEvent.Await()。)这套「等事件 + 心跳」的组合背后是 Verse 的并发系统——像 spawn(从当前线里岔出一条新线)、race(几条线赛跑、第一个到终点的算数)这些「分身术」,第 24 课会专门拆解。
现在动手把一台心跳设备补完整:第一个空是「数到 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。
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 必须先被「执行到」才算登记——放在 Branch 某条出线上的 defer,只有执行线真的走进那条分支才会登记;要是执行线在还没走到 defer 之前就离开了函数,它压根不会跑。第二,只要登记过,提前从任何出口离开(Break 跳出、或提前结束整个函数)都会触发它——多个出口共用一套收尾,正是它的价值。第三,它是块级的:defer 只跟着「它所在的这一段图」,执行线一离开那段就补跑,而不是非要等到整个函数结束才跑。
defer 和 loop 还能配合:在 loop 体内登记的 defer,每圈离开循环体时都会执行一次——
loop:
defer:
Print("这一圈的收尾")
Print("巡逻干活")
break # break 出圈前,已登记的 defer 照样执行
限制也有:defer 那一小段里不能写 return 结束函数,也不能写一个跳到它外层循环的 Break(但你在这段里自己开一个 loop、自己 Break 出来是允许的)。另外两条实践提醒:不要在 defer 里放会「等一会儿」的 latent 节点(带小钟表那种);也别把 defer 摆在一段图的最后一步——都到最后一步才登记,等于白登记。多个 defer 之间的执行先后,官方文档没有明说,别让清理逻辑依赖它们的顺序。
本课的坑比前几课都「贵」,因为循环写错时,蓝图左上角的 Compile 不会亮红——它不是编译期就能拦下的错,而是游戏跑起来才炸的运行时事故。头号事故长这样:
# ✗ 千万别这么写
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 把工作摊到多帧去。想深挖这个错误的判定机制(以及「明明没写死循环也中招」的离奇案例),见本课拓展页。
其余几个坑,按踩中率排序:
<suspends> 的函数里写 loop + Sleep —— Compile 会亮红:Sleep 那种带小钟表的 Delay 需要「能等」的能力,而这个函数没声明自己能等。把游戏循环放进入口 OnBegin<suspends>(它天生能等),或者用 spawn 岔出一条新线去跑(第 24 课见)。这一课有一半内容能在蓝图里找到对应节点,另一半——defer——蓝图压根没有。两种情况都值得看一眼。
| 蓝图里的做法 | Verse 里的写法 | 差异 |
|---|---|---|
| While Loop 节点,条件引脚接一个 Boolean | loop: 无条件转,循环体开头用 if 检查条件,不成立就 break |
Verse 没有 while、do-while、continue;条件判断从循环头挪进了循环体,出口由你自己安排 |
| 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 遍历中途提前退出整个循环,正确的做法是?