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

suspends 与时间流:给函数一张时间通行证

蓝图里让时间流动,靠的是 Delay 和那些带小钟表的 latent 节点;Verse 把这件事写进了函数签名——<suspends> 就是函数的时间通行证。这一课用 Sleep 换掉 Delay,并讲清「Delay 在函数里用不了」这条蓝图限制,在 Verse 里变成了什么规则。

一、suspends:这个函数会跨时间

在蓝图里,想让一件事「等三秒再发生」,你会伸手去拿 Delay 节点。到目前为止本站教你写的每一行 Verse,对应的都是「瞬发」节点——Set 赋值、Branch 判断、ForEach 遍历——在当前一帧之内从头跑到尾,中间绝不停顿。那 Delay 呢?它在 Verse 里叫 Sleep;而蓝图用一个小钟表图标暗示「这个节点会等」,Verse 则把这件事直接写进函数签名:效果说明符 suspends

上一课我们认识了说明符家族,suspends 属于其中的「效果说明符」:你可以把它想成给蓝图函数贴的一张标签,向 Compile 郑重声明——这个函数会跨越时间。带 suspends 标签的函数就是异步函数(async function):它可以在中途某个节点上暂停(挂起),把控制权先交还给引擎,过了若干模拟帧再从原地醒来、沿着执行线继续往下——这跟蓝图里那种带小钟表图标的 latent 节点(比如 Delay)是同一种「会等一会儿」的味道。就像一张时间通行证,持证的函数才有资格穿越帧与帧之间的关卡。

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

launch_device := class(creative_device):

    # suspends 写在参数表之后、返回类型之前
    Countdown()<suspends>:void =
        Print("准备……")
        Sleep(1.0)
        Print("出发!")

    OnBegin<override>()<suspends>:void =
        # 异步上下文里直接调用异步函数:
        # OnBegin 会停在这一行,等 Countdown 完整跑完
        Countdown()
        Print("倒计时结束后才轮到我")

读图翻译:这段有两个蓝图函数。Countdown 里连了三个节点——Print String「准备……」→ 一个时长 1 秒的 Delay(就是 Sleep(1.0))→ Print String「出发!」。OnBegin(相当于 Event BeginPlay)里,先把执行线接到 Countdown 上;因为 Countdown 里挂了会等的 Delay,主线会一直等它整根跑完,才轮到最后那句 Print String「倒计时结束后才轮到我」。

规则一条:异步函数只能在异步上下文中调用——要么把它接在另一个带 suspends 标签的函数里,要么接在并发节点(下一课的 spawn / race / sync)内部。上面的 OnBegin 天生就带 suspends,所以能直接把执行线接到 Countdown() 上;而且这种「直接接上」会让 OnBegin 停在那个节点等着,等 Countdown 从头到尾跑完才往下走,相当于一次不用写的等待。注意:停下的只是 OnBegin 这一条执行线,游戏世界照常运转。

顺带揭开一个陈年谜底:从第 6 课起你复制粘贴到今天的 OnBegin<override>()<suspends>:void,现在终于能逐词读懂了——<override> 就是蓝图里那个 Override 下拉菜单的意思——你在重写父类给好的入口;<suspends> 表示这个入口本身就会跨时间(带小钟表)。Epic 把 OnBegin(也就是 Event BeginPlay)设计成异步函数,就是默认你会在游戏开场做「等待、倒数、分阶段」这类跨时间的事。

二、Sleep:最小的时间积木

有了通行证,还得有时间积木。Verse 里最小、最常用的一块叫 Sleep,它就是蓝图里那个 Delay 节点——填一个秒数,执行线走到这儿就等这么久。它住在 /Verse.org/Simulation 这个抽屉里,用之前得先写 using { /Verse.org/Simulation } 把抽屉拉开(类比启用一个插件)。Sleep 自己带 suspends,所以「谁用了 Sleep,谁就得持有时间通行证」;而且这张证会顺着「A 调 B、B 调 C」的链子一层层往上要——只要链条上有人用了会等的节点,这一串函数就都得能等。

Sleep 有三种玩法,官方文档写得明明白白:

写法 实际效果
Sleep(1.0) 当前这条执行线挂起,约 1 秒后在最近的模拟帧醒来
Sleep(0.0) 不是「不睡」——挂起到下一个模拟帧才恢复,「让出一帧」的标准写法
Sleep(无穷大) 永远沉睡,只有所在任务被取消(例如输掉下一课的 race)才会结束

最容易被误解的是 Sleep(0.0):它的意思是「这一帧我做完了,下一帧再叫我」。官方文档专门建议:需要每帧做事的循环,就在循环体里放一个 Sleep(0.0) 让出控制权,避免独占处理器——这是第四节倒计时和以后所有逐帧逻辑的地基。

再纠正一个直觉错误:Sleep 不是游戏的暂停键。它只是让当前这一条执行线钻进睡袋——营地外的世界照常运转:其他任务在跑、玩家在动、物理在算。你睡你的,游戏忙游戏的。

三、模拟帧:时间的最小刻度

Sleep 睡多久才醒,取决于一个底层概念:模拟帧。官方术语表里 simulation update、tick、frame、update 四个词说的是同一件事——引擎推进世界的一次「心跳」。Fortnite 服务器大约每秒心跳 30 次,所以 Verse 能感知的时间最小刻度约为 1/30 秒。这意味着 Sleep(0.01) 并不会在 10 毫秒后醒来——它最早也要等到下一帧。想做高精度计时?Sleep 干不了这活,死心吧。

以「会不会跨帧」为界,Verse 把能连进图里的一切分成两大阵营:

阵营 特点 成员举例
即时的一类(immediate) 保证在当前模拟帧内完成 Set 赋值、算术、Branch、ForEach、Print String
异步的一类(async) 可能耗费一个以上模拟帧 Sleep(Delay)、事件的 Await、spawn / race / sync 等并发节点

这个分类带来两条硬规则:其一,在「能不能走通」的判断里(比如 Branch 的条件、会掷骰失败的那种函数体)不允许放会等的节点——一条走不通的线要求「像什么都没发生过」地回滚,而已经流逝的时间没法倒流;其二,<suspends><decides> 不能同时标在一个函数上,理由同源。想看清 suspends 在整张效果矩阵里的坐标,文末拓展页安排上了。

四、实战:发射倒计时

把 suspends、Sleep、loop 三块积木拼起来,就是游戏里最经典的时间结构——倒计时。下面这台发射台每秒报一次数,数到 0 就发射。点「运行下一步」,重点盯住 Sleep(1.0) 那个 Delay 节点:每次执行到它,真实时间就流逝一秒。

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

countdown_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        Print("倒计时开始!")
        var Remaining:int = 3
        loop:
            Print("剩余 {Remaining} 秒")
            Sleep(1.0)
            set Remaining -= 1
            if (Remaining <= 0):
                break
        Print("发射!")
输出日志

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

「loop + Sleep + break」是 Verse 时间循环的万能模板:loop 负责重复,Sleep 负责让时间流动,break 负责收尾。记住一条铁律:循环体里至少要有一个「会等的节点」(Sleep 那种 Delay,或等事件的 Await)。少了它会发生什么?下一节的事故现场见。

五、常见坑:时间旅行者的事故现场

坑一,也是全 Verse 里最常让 Compile 亮红的错误之一:在没标 suspends 的函数里用了 Sleep(那个会等的 Delay)。

broken_helper.verse
# ✗ 编译错误:Helper 没有 suspends,却调用了 Sleep
Helper():void =
    Sleep(2.0)
    Print("等两秒再说话")

编译器会甩给你这句原文:This invocation calls a function that has the 'suspends' effect, which is not allowed by its context.——「这次调用的函数带有 suspends 效果,而当前上下文不允许」。修法有二:给这个函数补上 <suspends> 标签(然后顺着「谁调用了谁」的链子往上,看上游是不是也得补);或者你根本不想等它跑完,就用下一课的 spawn 岔出一条新线让它自己跑。还有个高发变种:手写 OnBegin 时漏掉 <suspends><override>,结果后面所有会等的节点集体爆红,新手往往从爆红的那个节点开始查——其实要先回头检查入口那一行的标签!

fixed_helper.verse
# ✓ 修好了:补上时间通行证
Helper()<suspends>:void =
    Sleep(2.0)
    Print("等两秒再说话")

坑二:在 suspends 函数里写了 loop:,循环体里却没有任何 Sleep / Await。这条执行线在同一帧里无限空转,永远不把控制权还给引擎——轻则服务器冻结,重则触发运行时的无限循环错误。规矩:每帧循环至少放一个 Sleep(0.0)

坑三:把 Sleep 当高精度秒表。Sleep(0.01) 实际要等到下一个模拟帧(约 1/30 秒)才醒。坑四:以为 Sleep 暂停整个游戏——它只挂起当前这条执行线,别的任务照常跑。坑五(预告):把按钮事件 Bind 过去的那个回调是普通(不能等的)函数,里面不能直接放 Sleep,得用 spawn 岔出一条能等的新线来跑——第 24、25 课展开细讲。

纸上谈兵到此为止,来修一台坏掉的巡逻机。它缺了两样东西:函数的时间通行证,和循环里的「让帧」积木。填好后点「检查答案」。

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

patrol_device := class(creative_device):

    # 修理任务 1:给函数补上「时间通行证」效果说明符
    Patrol()<____>:void =
        loop:
            Print("巡逻中……")
            # 修理任务 2:每帧让出控制权(填函数名)
            ____(0.0)

    OnBegin<override>()<suspends>:void =
        Patrol()

蓝图对照

「让时间流动」这件事,你在蓝图里早就天天在做,只是零件散落在不同节点上。下面把它们和 Verse 的写法一一对上——差异那一列才是重点。

蓝图里的做法 Verse 里的写法 差异
Delay 节点,Duration 填秒数 Sleep(1.0) 语义相同。但 Sleep 是普通函数调用,能塞进循环体、能写在分支里,不必为它单独腾一个节点位
带小钟表图标的 latent 节点(Delay、Move Component To、Play Anim…) <suspends> 的函数 蓝图靠图标提示「这个会等」;Verse 写进函数签名,由编译器强制核对——谁能等、谁不能等,连调用链一起管
Delay 只能放在事件图里,自定义 Function 里放不了 任何函数只要标上 <suspends> 就能等 Verse 没有「事件图能等、函数不能等」的二分;代价是这个标签会沿着「谁调用了谁」的链子一路往上传染
Event Tick,引擎每帧替你调一次 loop: 循环体里放一个 Sleep(0.0) Verse 没有 Tick 事件,逐帧要自己写。而且能不写就别写——第 25 课会讲,事件驱动几乎总比每帧轮询划算
Timeline 轨道,拖一条曲线做插值 没有直接对应物:自己写「loop + Sleep + 按已流逝时间算插值」 Verse 这边缺一个可视化曲线编辑器;换来的是插值逻辑就是普通代码,能复用、能 diff、能进版本控制
Set Timer by Event,记住句柄,回头 Clear Timer 把等待写成一条任务线,用第 24 课的 race / branch 管它的生死 蓝图的定时器句柄要你自己记着清理;Verse 的结构化并发让任务随所在作用域一起收场

差异的根子只有一条:蓝图把「会等」藏在节点的外观里,Verse 把它提到了类型层面。所以在蓝图里「忘了这个节点是 latent」通常只是行为不如预期,在 Verse 里则是 Compile 当场亮红——第五节那句 'suspends' effect ... not allowed by its context,就是这套检查在说话。

还有一处反方向的差异值得记住:蓝图的 Delay 节点在计时期间被再次触发时会忽略新的调用(编辑器还会提示 Delay 已在进行中);Verse 的 Sleep 没有这种隐藏行为——每条执行线各睡各的,谁也不吞谁。想要「只许有一份在跑」,得靠下一课的并发表达式自己表达出来。

六、关卡挑战

时间通行证已到手,闯三道关验收。答错零惩罚,可以一直重试。

在一个没有 <suspends> 的普通函数里放了一个 Sleep(2.0)(Delay),会发生什么?

Sleep(0.0) 的真实含义是?

Boss 战脚本里某条执行线走到了 Sleep(5.0)(Delay),这 5 秒内游戏的其他部分会怎样?

拓展阅读

拔高 · EXTRA

时间流的底层模型

Unreal Fest 2023 官方演讲精读:并发节点就是时间维度上的 Branch / ForEach。

进入拓展 →

技巧 · EXTRA

Sleep 的真实计时

社区实测:30 tick 封顶、逐帧循环与「免 DeltaTime」动画。

进入拓展 →

拓展 · EXTRA

Verse 效果系统全景

suspends 在效果矩阵中的坐标,以及它为何与 decides 互斥。

进入拓展 →