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

毕业实战:按钮开门

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 BeginPlaydoor_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:OpenButtonDoor 指向两台真实设备,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 分身 + Sleeprace:倒计时 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 再按)→ 关门 → 绕回循环顶部。照图施工!

三、door_controller.verse:一行行跑给你看

下面是完整的毕业作品 2.0,42 行。右边的运行面板才是本课的主菜:点「运行下一步」,亲眼看这扇门从上电、派分身、待命,到开门、赛跑、再关上的完整一生。

读图翻译:整段代码 = Event BeginPlay 里先 Print String 报个到,再岔出一条常驻后台线跑 DoorLoop;DoorLoop 是一个自绕的无限循环,每圈只做四件事——等按钮事件响 → 开门(Set 状态为 true + 给 Barrier 连 Disable 节点 + Print)→ 两条线赛跑(一条 Delay 倒计时、一条再等一次按钮,谁先到听谁的,输的那条被自动剪断)→ 关门(Set 状态为 false + 给 Barrier 连 Enable 节点 + Print)→ 绕回顶部继续等。下面这台步进器会把这套连线一步步演给你看。

door_controller.verse
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 文件并编译

打开 Verse Explorer,新建 Verse 文件,命名 door_controller,粘贴上面的代码。按 Ctrl+Shift+B(Build Verse Code)编译,看到成功提示再继续。

③ 拖入设备并绑定插槽

编译成功后 Content Browser 里会出现 door_controller,把它拖进关卡。选中它,在 Details 面板把 OpenButton 指向按钮、Door 指向屏障,顺手把 AutoCloseDelay 调成你想要的秒数。

④ 启动会话测试

Launch Session 进游戏。按一下按钮:屏障消失,等满秒数自动恢复;再开一次门,立刻补按一下:门应声提前关上。两条路径都验过,才算测试完成。

之后每次改动代码,都要重新 Build 并推送更改到会话——这一步忘掉的话,场景里跑的还是旧版本,下一节的排雷手册里它榜上有名。

五、编译坑清单:毕业考排雷手册

下面六个坑,覆盖了这类「事件 + 并发 + 设备」项目九成以上的翻车现场。提前在脑子里踩一遍,正式上场就稳了:

忘挂 <suspends>忘了给函数挂「能等待」的牌子

SleepAwait 只能在 suspends 上下文里调用。Subscribe 的回调是普通函数,直接往里塞 Sleep 必吃编译错误——要么 spawn 出一个 suspends 函数,要么像本课一样整体改用 Await 风格。

Delay(Sleep)和「等事件」(Await)这类会停下来等的节点,只能放在挂了小钟表(suspends / latent)的函数里。Bind 事件的回调是普通函数,直接往里塞 Delay 必定 Compile 报错——要么岔出(spawn)一个带小钟表的函数,要么像本课一样整体改成「在线里等事件」的风格。

spawn 体超载岔线块里塞太多

spawn{ Sleep(5.0); Door.Enable() } 不编译:spawn 花括号里只允许恰好一个异步函数调用。想跑一串动作,就把它们包进一个命名的 suspends 函数,再 spawn 那个函数。

spawn{ Sleep(5.0); Door.Enable() } 不通过:岔线块(spawn 花括号)里只允许恰好一个能等待的函数调用。想跑一串动作,就先把它们包进一个命名的、挂小钟表的函数,再岔出那个函数。

logic 的问号与 setBoolean 的问号与 Set

if (IsDoorOpen): 不编译——if 里要的是可失败表达式,logic 变量得写 if (IsDoorOpen?):,true 过、false 挡。同理,改值漏写 set 也是当场拒收。

if (IsDoorOpen): 不通过——Verse 的 if 要的是一条「能不能走通」的检定(失败上下文,类似 Branch,但问的是通不通),logic(也就是 Boolean)变量得写成 if (IsDoorOpen?):,true 放行、false 走不通。同理,改值漏了 Set(漏写 set)也当场被拒。

裸奔的 loop没有喘气的循环

loop 体内一个挂起点都没有?它永远不把控制权还给游戏,运行时按「死循环 / 超出计算限制」处理,当场报错终止。铁律:每圈至少一次 Await 或 Sleep

循环体里一个能停下来等待的节点都没有?它永远不把控制权还给游戏,运行时按「死循环 / 超出计算量」处理,当场报错终止。铁律:每圈至少停下来等一次——一个 Delay 或一次「等事件」

@editable 没绑 / 重命名失联Instance Editable 没绑 / 改名失联

最阴险的一个:编译全绿、进游戏按了没反应——多半是 Details 面板里插槽还空着,默认的 button_device{} 指着空气。另外,重命名 @editable 属性会清空编辑器里已有的绑定,必须重新指一次。

最阴险的一个:Compile 全绿、进游戏按了没反应——多半是 Details 面板里那个变量还空着,默认的 button_device{} 指着空气。另外,给开了小眼睛的变量改名,会清空编辑器里已连好的引用,必须重新指一次。

忘记重新 Build

改了代码没按 Ctrl+Shift+B、没推送更改?会话里跑的还是旧版本,新加的 @editable 字段也不会出现在面板里。「代码明明改了怎么没反应」十有八九是它。

改了内容没按 Ctrl+Shift+B(Build / Compile)、没推送更改?会话里跑的还是旧版本,新加的那个开了小眼睛的字段也不会出现在面板里。「明明改了怎么没反应」十有八九是它。

六、关卡挑战

毕业考最后一关。先热手补全核心零件,再答三道题。答对拿星星 ★,答错可以一直重试——就像这扇门,永远给你留着下一次机会。

door_controller.verse(节选)
# 补全三个空:派分身的关键字、等待资格的牌子、限时赛跑的块
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

Verse 并发全景:race、sync、rush、branch 与取消语义

官方为什么劝你「能不用 spawn 就不用」?四种结构化并发表达式一张桌子摆齐,顺便说清取消的边界。

官方为什么劝你「能不岔线(spawn)就别岔」?四种有结构、看得见头尾的并发写法一张桌子摆齐,顺便说清取消的边界。

进入拓展 →

技巧 · EXTRA

多按钮联动开门:用 switch_device 数组做状态检查

论坛真实案例:「所有开关都扳开才开门」怎么做?把状态存进设备,代码只做汇总判断。

进入拓展 →

拓展 · EXTRA

给开门加计分:score_manager_device 实战

谁按下按钮谁得分:事件的 agent 载荷一路贯通,三个已核实签名的计分 API 直接上手。

谁按下按钮谁得分:事件带着「是谁按的」那个玩家引脚一路贯通,三个已核实的计分接口直接上手。

进入拓展 →