拔高 · EXTRA
Verse 并发全景:race、sync、rush、branch 与取消语义
官方为什么劝你「能不用 spawn 就不用」?四种结构化并发表达式一张桌子摆齐,顺便说清取消的边界。
官方为什么劝你「能不岔线(spawn)就别岔」?四种有结构、看得见头尾的并发写法一张桌子摆齐,顺便说清取消的边界。
进入拓展 →23 课的旅程在这里收束。毕业项目升级到 2.0:@editable 三插槽、logic 状态、spawn 分身,再用 loop + Await + race 实现「超时自动关门、再按提前关门」。从需求到上线,完整走一遍创作者的工作流。
23 课的旅程在这里收束。毕业项目升级到 2.0:三个 Instance Editable 插槽、一个开/关状态、一条常驻后台线,再用「自绕循环 + 在线里等事件 + 几条线赛跑」实现「超时自动关门、再按提前关门」。从需求到上线,完整走一遍创作者的工作流。
还记得课程前半程那扇「按一下、开 5 秒、自动关」的门吗?那是当时的你能造出的最好机关。现在你的背包里多了并发三兄弟、事件的两种听法、map 和 option、甚至 <persistable>——是时候把毕业作品升级到 2.0 了。新需求一共四条:
还记得课程前半程那扇「按一下、开 5 秒、自动关」的门吗?那是当时的你能造出的最好机关。现在你的背包里多了并发的几种玩法、事件的两种听法(Bind 回调 / 在线里直接等)、随时可查的键值表和「可能没有」的可选值、甚至每人一份的存档——是时候把毕业作品升级到 2.0 了。新需求一共四条:
第三条是真正的难点:门开着的时候,「倒计时走完」和「玩家又按了一次」两件事在赛跑,谁先发生听谁的,而且输家必须被干净地掐掉,不能留下幽灵倒计时。这正是 race「赛跑」(race)的主场。先清点一下这扇门要用到的技能石:
| 技能石 | 在这扇门里的用途 |
|---|---|
creative_device 与 OnBegin设备蓝图 + Event BeginPlay | door_controller 本体与上电入口 |
var / set / logic变量面板的变量 + Set 节点(存开/关) | IsDoorOpen 状态记录 |
@editableInstance Editable 小眼睛 | 三个插槽:按钮、屏障、可调秒数 |
| 事件的 Await 听法在执行线里直接等事件 | 在主循环里等按钮,不用回调 |
<suspends> / spawn / Sleep带小钟表的节点 + 岔出常驻线 + Delay | 常驻分身与倒计时 |
loop / race自绕循环 + 几条线赛跑 | 无限主循环与「倒计时 vs 再按」的赛跑 |
<persistable>每人一份的 SaveGame | 这次轮空;想做「累计开门次数排行」时它就登场 |
要哪些设备?关卡里两台现成设备:Button Device(按钮)负责接收交互,Barrier Device(屏障)就是门本体。再加上我们手写的第三台 door_controller——它不干体力活,只当指挥官。
要哪些插槽?三个 @editable:OpenButton 和 Door 指向两台真实设备,AutoCloseDelay:float = 5.0 是自动关门秒数。三个开了 Instance Editable 小眼睛的变量:OpenButton 和 Door 是两个设备引用,分别指向关卡里真实的按钮和屏障;AutoCloseDelay 是个小数(float)变量,默认 5.0,就是自动关门秒数。把秒数做成插槽是 2.0 的新招——数值策划在 Details 面板拖一下就能改,不用碰一行代码,也不用重新编译。
要什么状态?一个 var IsDoorOpen:logic = false。一个布尔变量 IsDoorOpen(在变量面板新建,类型 logic 就是蓝图里的 Boolean),默认 false。有趣的是,升级版里它不再是防连按的门卫——防重复触发这件事,由主循环的结构天然保证(循环没回到「等按钮」那一步,新的开门流程就不可能开始)。留着它,是给日志、给其他系统(比如拓展页里的计分器)一个随时可查的「门现在开着吗」。
流程长什么样?旧版和新版的对比,一张表看清:
| 1.0(回调风格) | 2.0(主循环风格) | |
|---|---|---|
| 听按钮 | Subscribe + 回调函数 | 在 <suspends> 循环里 Await() |
| 防连按 | IsOpen 门卫 + if 检定 | loop 结构天然防重入 |
| 定时关门 | spawn 分身 + Sleep | race:倒计时 vs 再按,先到先赢 |
| 提前关门 | 做不到 | race 的第二条赛道,免费送的 |
| 1.0(Bind 回调风格) | 2.0(常驻循环风格) | |
|---|---|---|
| 听按钮 | Bind 事件到自定义事件 + 回调 | 在一条常驻线里直接等事件响 |
| 防连按 | 布尔门卫 + Branch 检查 | 循环结构天生防重入 |
| 定时关门 | 岔一条线 + Delay | 两条线赛跑:Delay vs 再按,先到先算 |
| 提前关门 | 做不到 | 赛跑的第二条线,顺带就有了 |
官方在并发文档里的建议是:能用结构化并发(sync / race / rush / branch)就不用非结构化的 spawn。2.0 把「等 5 秒然后关门」从 spawn 分身重构成了 race 赛跑,只保留一处 spawn——在 OnBegin 里把常驻主循环 DoorLoop() 派出去。这是 spawn 最正当的用法:启动一个与设备同寿命的长期流程。整条流程串起来:loop 等按钮 → 开门 → race(倒计时 vs 再按)→ 关门 → 回到 loop 顶部。照图施工!
官方的建议是:能用「有结构、看得见头尾」的并发(sync / race / rush / branch,都是几条执行线一起跑、由结构框住起止的写法)就别用「岔出去就不管」的 spawn。2.0 把「等 5 秒然后关门」从「岔一条线倒计时」重构成了「两条线赛跑」,只在一处岔线——Event BeginPlay 里把常驻主循环 DoorLoop 岔出去跑。这是 spawn 最正当的用法:启动一条和设备同寿命的长期后台线。整条流程串起来:自绕循环等按钮 → 开门 → 两条线赛跑(倒计时 vs 再按)→ 关门 → 绕回循环顶部。照图施工!
下面是完整的毕业作品 2.0,42 行。右边的运行面板才是本课的主菜:点「运行下一步」,亲眼看这扇门从上电、派分身、待命,到开门、赛跑、再关上的完整一生。
读图翻译:整段代码 = Event BeginPlay 里先 Print String 报个到,再岔出一条常驻后台线跑 DoorLoop;DoorLoop 是一个自绕的无限循环,每圈只做四件事——等按钮事件响 → 开门(Set 状态为 true + 给 Barrier 连 Disable 节点 + Print)→ 两条线赛跑(一条 Delay 倒计时、一条再等一次按钮,谁先到听谁的,输的那条被自动剪断)→ 关门(Set 状态为 false + 给 Barrier 连 Enable 节点 + Print)→ 绕回顶部继续等。下面这台步进器会把这套连线一步步演给你看。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# 毕业实战 2.0:loop 主循环 + race 限时赛跑,超时自动关门
door_controller := class(creative_device):
# 三个 @editable:两台设备 + 一个可调秒数,全在 Details 面板配置
@editable
OpenButton:button_device = button_device{}
@editable
Door:barrier_device = barrier_device{}
@editable
AutoCloseDelay:float = 5.0
# 门的状态:false = 关着,true = 开着
var IsDoorOpen:logic = false
OnBegin<override>()<suspends>:void =
Print("门禁系统上线,主循环出发。")
spawn{ DoorLoop() }
DoorLoop()<suspends>:void =
loop:
OpenButton.InteractedWithEvent.Await()
OpenDoor()
race:
Sleep(AutoCloseDelay)
OpenButton.InteractedWithEvent.Await()
CloseDoor()
OpenDoor():void =
set IsDoorOpen = true
Door.Disable()
Print("大门开启,倒计时开始;再按一次可提前关门。")
CloseDoor():void =
set IsDoorOpen = false
Door.Enable()
Print("大门已关闭,机关待命。")
点「运行下一步」,看这扇门的一生:上电、派分身、待命、开门、赛跑、关门。
四个函数各司其职:OnBegin 是点火器,报到之后把常驻主循环 spawn 出去就退场;DoorLoop 是这扇门的灵魂,带着 <suspends> 牌子,一辈子只做四件事的循环;OpenDoor / CloseDoor 是一对普通帮手函数,把「改状态 + 拨屏障 + 喊日志」打包,顺便消灭了 Enable/Disable 极性搞反的隐患——代码里从此只出现「开门」「关门」,不出现「启用」「禁用」。
四个函数各司其职:Event BeginPlay 是点火器,报到之后把常驻主循环 DoorLoop 岔出去跑就退场;DoorLoop 是这扇门的灵魂,挂着小钟表(能等待的 latent 函数),一辈子只做四步的循环;OpenDoor / CloseDoor 是一对普通蓝图帮手函数,把「Set 状态 + 拨屏障 + Print 喊话」打包,顺便消灭了 Enable/Disable 极性搞反的隐患——图里从此只出现「开门」「关门」,不出现「启用」「禁用」。
两个容易忽略的细节。其一:race 至少需要两条异步赛道,只放一条是编译错误;如果你想回到 1.0 的「开门期间按钮无效」,直接删掉 race 和赛道 B,只留 Sleep(AutoCloseDelay) 一行就好。其二:同一个事件别混用两种听法——如果你既 Subscribe 了 InteractedWithEvent,又在 race 里 Await 它,一次按钮会同时惊动两边,开关门逻辑直接翻倍触发。一个事件,一种听法,提前定好。
两个容易忽略的细节。其一:赛跑(race)至少要两条能等待的赛道,只放一条会 Compile 报错;如果你想回到 1.0 的「开门期间按钮无效」,直接删掉赛跑和赛道 B,只留 Delay(时长 AutoCloseDelay)一格就好。其二:同一个事件别混用两种听法——如果你既 Bind 了 InteractedWithEvent 的回调,又在赛跑里等它响,一次按钮会同时惊动两边,开关门逻辑直接翻倍触发。一个事件,一种听法,提前定好。
代码只是灵魂,还得给它一副身体。UEFN 是 UE6 面向 Fortnite 的创作环境,下面以它为例——这套流程在 UE6 的各个创作环境里都是一致的:
在 Content Browser 里找到 Devices,把一台 Button Device 和一台 Barrier Device 拖进关卡:按钮放门边,屏障正好堵住门洞。摆放位置本身就是关卡设计。
打开 Verse Explorer,新建 Verse 文件,命名 door_controller,粘贴上面的代码。按 Ctrl+Shift+B(Build Verse Code)编译,看到成功提示再继续。
编译成功后 Content Browser 里会出现 door_controller,把它拖进关卡。选中它,在 Details 面板把 OpenButton 指向按钮、Door 指向屏障,顺手把 AutoCloseDelay 调成你想要的秒数。
点 Launch Session 进游戏。按一下按钮:屏障消失,等满秒数自动恢复;再开一次门,立刻补按一下:门应声提前关上。两条路径都验过,才算测试完成。
之后每次改动代码,都要重新 Build 并推送更改到会话——这一步忘掉的话,场景里跑的还是旧版本,下一节的排雷手册里它榜上有名。
下面六个坑,覆盖了这类「事件 + 并发 + 设备」项目九成以上的翻车现场。提前在脑子里踩一遍,正式上场就稳了:
Sleep 和 Await 只能在 suspends 上下文里调用。Subscribe 的回调是普通函数,直接往里塞 Sleep 必吃编译错误——要么 spawn 出一个 suspends 函数,要么像本课一样整体改用 Await 风格。
Delay(Sleep)和「等事件」(Await)这类会停下来等的节点,只能放在挂了小钟表(suspends / latent)的函数里。Bind 事件的回调是普通函数,直接往里塞 Delay 必定 Compile 报错——要么岔出(spawn)一个带小钟表的函数,要么像本课一样整体改成「在线里等事件」的风格。
spawn{ Sleep(5.0); Door.Enable() } 不编译:spawn 花括号里只允许恰好一个异步函数调用。想跑一串动作,就把它们包进一个命名的 suspends 函数,再 spawn 那个函数。
spawn{ Sleep(5.0); Door.Enable() } 不通过:岔线块(spawn 花括号)里只允许恰好一个能等待的函数调用。想跑一串动作,就先把它们包进一个命名的、挂小钟表的函数,再岔出那个函数。
if (IsDoorOpen): 不编译——if 里要的是可失败表达式,logic 变量得写 if (IsDoorOpen?):,true 过、false 挡。同理,改值漏写 set 也是当场拒收。
if (IsDoorOpen): 不通过——Verse 的 if 要的是一条「能不能走通」的检定(失败上下文,类似 Branch,但问的是通不通),logic(也就是 Boolean)变量得写成 if (IsDoorOpen?):,true 放行、false 走不通。同理,改值漏了 Set(漏写 set)也当场被拒。
loop 体内一个挂起点都没有?它永远不把控制权还给游戏,运行时按「死循环 / 超出计算限制」处理,当场报错终止。铁律:每圈至少一次 Await 或 Sleep。
循环体里一个能停下来等待的节点都没有?它永远不把控制权还给游戏,运行时按「死循环 / 超出计算量」处理,当场报错终止。铁律:每圈至少停下来等一次——一个 Delay 或一次「等事件」。
最阴险的一个:编译全绿、进游戏按了没反应——多半是 Details 面板里插槽还空着,默认的 button_device{} 指着空气。另外,重命名 @editable 属性会清空编辑器里已有的绑定,必须重新指一次。
最阴险的一个:Compile 全绿、进游戏按了没反应——多半是 Details 面板里那个变量还空着,默认的 button_device{} 指着空气。另外,给开了小眼睛的变量改名,会清空编辑器里已连好的引用,必须重新指一次。
改了代码没按 Ctrl+Shift+B、没推送更改?会话里跑的还是旧版本,新加的 @editable 字段也不会出现在面板里。「代码明明改了怎么没反应」十有八九是它。
改了内容没按 Ctrl+Shift+B(Build / Compile)、没推送更改?会话里跑的还是旧版本,新加的那个开了小眼睛的字段也不会出现在面板里。「明明改了怎么没反应」十有八九是它。
毕业考最后一关。先热手补全核心零件,再答三道题。答对拿星星 ★,答错可以一直重试——就像这扇门,永远给你留着下一次机会。
# 补全三个空:派分身的关键字、等待资格的牌子、限时赛跑的块
OnBegin<override>()<suspends>:void =
____{ DoorLoop() }
DoorLoop()<____>:void =
loop:
OpenButton.InteractedWithEvent.Await()
OpenDoor()
____:
Sleep(AutoCloseDelay)
OpenButton.InteractedWithEvent.Await()
CloseDoor()
门开着时,race 正在跑。门开着时,两条线正在赛跑。如果倒计时还没走完,玩家又按了一次按钮,会发生什么?
spawn{ Sleep(3.0); Door.Enable() } 为什么不编译?为什么 Compile 不通过?
DoorLoop 的 loop 每一圈为什么必须至少挂起一次(Await 或 Sleep)?DoorLoop 的循环每一圈为什么必须至少停下来等一次(等事件 Await 或 Delay)?
回头看看:第 1 课你还在对着 Print("Hello, Verse!") 傻乐,现在你造出了一扇有状态、会赛跑、能自己关上的门。事件驱动、失败即条件、结构化并发、状态与持久化——这些词放进任何一门语言的简历里都是硬通货,而你是在做游戏的路上顺手集齐的。
毕业不是终点,是解锁自由改造模式。下面三个拓展页就是三条支线任务:把并发四兄弟一次看全、做一扇「所有开关就位才开」的联动门、让开门的勇士当场得分。或者干脆翻开官方 Verse API Reference,挑一台你从没用过的设备,给它写一台指挥官。你的地图,你说了算。
拔高 · EXTRA
官方为什么劝你「能不用 spawn 就不用」?四种结构化并发表达式一张桌子摆齐,顺便说清取消的边界。
官方为什么劝你「能不岔线(spawn)就别岔」?四种有结构、看得见头尾的并发写法一张桌子摆齐,顺便说清取消的边界。
进入拓展 →技巧 · EXTRA
论坛真实案例:「所有开关都扳开才开门」怎么做?把状态存进设备,代码只做汇总判断。
进入拓展 →拓展 · EXTRA
谁按下按钮谁得分:事件的 agent 载荷一路贯通,三个已核实签名的计分 API 直接上手。
谁按下按钮谁得分:事件带着「是谁按的」那个玩家引脚一路贯通,三个已核实的计分接口直接上手。
进入拓展 →