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

设备、@editable 与事件:把代码接进关卡

前两课你练熟了 suspends 与并发表达式会「等」的节点(带小钟表的 latent)和让几条线一起跑的并发玩法;这一课把目光放回关卡:用 @editable 在面板上留插槽,用 Subscribe 给设备事件接引线,再用自定义 event 让设备之间隔空喊话。学完它,你的代码就能真正「听懂」玩家的操作。

一、@editable:在遥控器上预留按钮位

想让代码控制关卡里的按钮、屏障,第一反应可能是「在代码里把它们找出来」。打住!Verse 的正确姿势恰恰相反:不在代码里找设备,而是留一个插槽,让设备自己插进来。这就是 @editable 的工作——给类的字段戴上这顶帽子给一个变量戴上这顶帽子(它其实就是蓝图里你熟的 Instance Editable 小眼睛),它就会出现在 UEFN 的细节面板(Details)里,变成一个可以把关卡设备拖进去的配置项。

打个比方:你在设计一只万能遥控器,@editable 就是在遥控器面板上预留的按钮位——出厂时是空的,等遥控器摆进客厅(设备拖进关卡),再决定这个位对应哪盏灯、哪台电视。换台电视?重新配对就行,遥控器本身一个字都不用改。

remote_device.verse
using { /Fortnite.com/Devices }

# 一只带插槽的「遥控器」设备
remote_device := class(creative_device):

    # 插槽一:关卡里的按钮。@editable 独占一行,写在字段上方
    @editable
    TriggerButton:button_device = button_device{}

    # 插槽二:普通数值也能暴露,策划改数字不用碰代码
    @editable
    Delay:float = 2.0

读图翻译:这段代码开了一台「遥控器」设备,里面挂了两个带 Instance Editable 小眼睛的变量——TriggerButton 是一个按钮设备引脚(摆进关卡后在 Details 面板里把某个按钮拖进来),Delay 是一个浮点数、默认 2.0(策划直接在面板里改)。@editable 那一行,就是给它下面那个变量勾上小眼睛。

写法三件套:@editable 独占一行 → 字段名和类型 → 末尾必须给默认值。设备引用用空原型占位,比如 = button_device{};漏掉默认值直接吃编译错误。可暴露的类型不止设备引用:intfloatlogicstring 及它们的数组都行;自定义类想上面板,还得给类加 <concrete> 说明符(要求每个成员都有默认值)。

写法三件套:@editable 独占一行 → 变量名和类型 → 末尾必须给默认值。设备类的变量用一个空原型占位,比如 = button_device{}(相当于先给引脚接一个空的默认值);漏掉默认值,Compile 直接报红。能挂小眼睛的不止设备引用:intfloatlogic(真/假那种勾选框)、string 及它们的数组都行;想把你自己写的一整个蓝图类摆上面板,还得给那个类加 <concrete> 标记(条件是它每个变量都有默认值)。

官方文档点明了 @editable 的最大价值:在面板里改值不需要重新编译 Verse 代码不需要重新 Compile 代码(左上角那个 Compile 按钮不用再点)。冷却时间从 2 秒调到 3 秒这种事,策划在编辑器里拖一下滑块就完成了,迭代速度完全不一样。这也是「绝不硬编码查找」的理由:关卡是随时会改的,按钮今天摆在城门口,明天可能被删掉重摆;硬编码就像把电视型号焊死在遥控器里,而插槽只要重新拖一下。

二、Subscribe:把引线接到事件上

插槽里有了按钮,接下来要回答:按钮被按下的那一刻,代码怎么知道?Verse 的答案是事件(event)。每台设备都会广播自己身上发生的大事,button_device 就有一个 InteractedWithEvent——「有人按了我!」。你要做的,是提前登记:「这个事件发生时,请调用我的这个函数」。这个登记动作,就是 Subscribe(订阅)(订阅)——就是蓝图里的 Bind Event:把设备的事件绑到你自己的函数上

回调函数的签名必须和事件的「载荷」严丝合缝:InteractedWithEvent 会把按按钮的那个 agent(玩家或 AI)递给你,所以回调必须写成 OnAlarm(Agent:agent):void。参数漏写、类型写成 player,编译器都会当场摇头。还有一个关键细节:Subscribe(OnAlarm) 括号里是函数的名字,没有括号、不带参数——你是把函数本身交给按钮保管,而不是现在就调用它。接线 ≠ 拉线

你绑上去的那个函数图,输入引脚必须跟事件递出来的东西对得上:InteractedWithEvent 会把「按按钮的那个人」当成一个 agent(玩家或 AI,类似蓝图事件里的 Instigator / 玩家引脚)递给你,所以你的 OnAlarm 必须留一个 agent 类型的输入引脚,写成 OnAlarm(Agent:agent):void。少留这个引脚、或把类型写成 player,Compile 都会当场报红。还有一个关键细节:Subscribe(OnAlarm) 括号里填的是函数的名字,后面不加括号、不填参数——你是把「这个函数图」本身交给按钮保管,让它以后需要时再运行,而不是现在就跑一遍。接线 ≠ 现在就拉一次

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

# 警报器:接线、拆线、干等,三种姿势一次看全
alarm_device := class(creative_device):

    @editable
    AlarmButton:button_device = button_device{}

    OnBegin<override>()<suspends>:void =
        # 接线:按钮被按下时,自动调用 OnAlarm
        Sub := AlarmButton.InteractedWithEvent.Subscribe(OnAlarm)
        # Subscribe 返回 cancelable,想拆线时调用 Sub.Cancel() 即退订

        # 另一条路:不接线,直接「等」——Await 是异步调用
        FirstAgent := AlarmButton.InteractedWithEvent.Await()
        Print("等到了第一次按下,OnBegin 继续往下走")

    OnAlarm(Agent:agent):void =
        Print("警报!有人按下了按钮!")

读图翻译:游戏一开始(OnBegin,即 Event BeginPlay),这台警报器做了两件事——第一,AlarmButton.InteractedWithEvent.Subscribe(OnAlarm) 把按钮的「被按下」事件 Bind 到 OnAlarm 函数图上(以后每次有人按,OnAlarm 就自动跑,打印「警报!」);第二,...Await() 是另一种玩法:不绑函数,而是让 OnBegin 这条线直接「停在这里等」,等到第一次有人按才继续往下走。下面三段,把这两条路的门道讲透。

这段代码浓缩了三个知识点。其一,Subscribe 的返回值是 cancelable,对它调用 Cancel() 就能退订;订阅多了可以存进 var Subs:[]cancelable,清场时统一拆线。其二,事件不止能订阅,还能:Await() 会把当前协程挂起,直到事件发生才带着载荷返回——它是异步调用,必须待在异步上下文里(OnBegin<suspends>,正好合法)。InteractedWithEvent 的类型叫 listenable(agent),可以理解成「既能 Subscribe 又能 Await」的组合接口。

这段代码浓缩了三个知识点。其一,Subscribe 会交回来一个「可取消凭证」(cancelable),对它调用 Cancel() 就能退订(解绑);绑得多了可以把这些凭证存进一个数组 var Subs:[]cancelable,清场时统一拆线。其二,事件不止能绑,还能直接等:Await() 会让当前这条线停在原地(挂起),直到事件响了、带着那份「捎带的东西」再往下走——它是个「带小钟表」的动作,只能待在能等待的函数里(OnBegin<suspends>,正好合法)。InteractedWithEvent 这类事件,既能 Bind(Subscribe)又能直接 Await

其三,也是最容易翻车的一点:Subscribe 的回调是立即函数,签名是官方定死的,不带 <suspends>。想在回调里睡 3 秒再行动?直接写 Sleep 必报效果错误。正确做法是让第 20 课的分身术出场——在回调里 spawn{ SomeAsyncFunc() }spawn 恰好被允许出现在非异步上下文,这正是它的主场。

其三,也是最容易翻车的一点:你 Bind 上去的函数图是「立即跑完」类型,它的样子官方定死,不带 <suspends>(里面不能放会等待的节点)。想在里面睡 3 秒再行动?直接放 Sleep 一定报错。正确做法是请第 20 课的「分身术」出场——在里面 spawn{ SomeAsyncFunc() } 岔出一条新线,让那条新线去睡去等。spawn 恰好允许待在这种「不能等待」的函数里,这正是它的主场。

三、完整示例:一台计数门铃

把零件拼起来:@editable 插槽 + var 记忆格子一个记数的变量 + Subscribe 接线 + 签名匹配的回调输入引脚对得上的函数图,就是一台会记账的门铃。点「运行下一步」,看它从布线到被按两次的完整链路。

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

# 计数门铃:每按一次,记一次账
doorbell_device := class(creative_device):

    @editable
    Bell:button_device = button_device{}

    var PressCount:int = 0

    OnBegin<override>()<suspends>:void =
        Bell.InteractedWithEvent.Subscribe(OnRing)
        Print("门铃就绪,等你来按。")

    OnRing(Agent:agent):void =
        set PressCount += 1
        Print("叮咚!第 {PressCount} 次按铃")
输出日志

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

留意 OnBegin 跑完之后发生的事:设备并没有「结束」,它守着接好的线,玩家什么时候按,OnRing 就什么时候被叫醒,按十次记十次账。事件驱动省掉了「每帧检查按钮状态」的轮询循环——这正是第 19 课 loop: + Sleep(0.0) 方案在很多场景下的更优替代那种在 Tick 里一圈圈查的轮询——很多场景下,它都比第 19 课那种「自绕回去、每圈 Delay 一下的循环」更省心

四、自定义 event(t):设备之间的对讲机

原生设备自带事件,那你自己写的两台 Verse 设备之间怎么通信?比如发令台按钮一按,另一头的闸门设备立刻放行。Verse 给了两条路:一是用 @editable 直接引用对方设备,调用它的 <public> 方法;二是更解耦的方案——自定义事件 event(t),一端 Signal(发信号),一端 Await(等信号),像一对对讲机。event(t)Signal / Await 来自 /Verse.org/Concurrency 模块。

原生设备自带事件,那你自己写的两台 Verse 设备之间怎么通信?比如发令台按钮一按,另一头的闸门设备立刻放行。Verse 给了两条路:一是用 @editable 把对方设备拖进来引用,调用它开放出来的(<public>)函数;二是更松耦合的方案——自定义事件 event(t),它其实就是蓝图里的事件分发器(Event Dispatcher):一端 Signal(相当于 Call 一下分发器),另一端 Await(相当于 Bind 之后等它响),像一对对讲机。event(t)Signal / Await 来自 /Verse.org/Concurrency(把它想成引用了并发相关的那个抽屉)。

声明自定义事件有一条铁律:「类型 = 原型」必须成对出现,即 RaceStartEvent<public>:event(agent) = event(agent){}。只写类型标注、不给右边的原型实例,编译器会报抽象类相关的错误。括号里的 agent 是事件的载荷类型,没东西可捎就写 event() = event(){}

新建一个自定义事件有一条铁律:「类型 = 原型」必须成对写全,即 RaceStartEvent<public>:event(agent) = event(agent){}。只写左半边(声明它是个 event)、不给右边那个 = event(agent){} 初始值,Compile 会报红(说这是个没法直接用的抽象东西)。括号里的 agent,是这个事件「捎带的东西」的类型(比如把触发它的那个玩家一起带过去);没东西可捎就写 event() = event(){}

starter_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# event(t) 的 Signal / Await 来自 /Verse.org/Concurrency
# TODO: 以 Verse API Reference 为准(不同版本模板的 using 路径可能有差异)
using { /Verse.org/Concurrency }

# 发令端:按钮一按,向全场广播「比赛开始」
starter_device := class(creative_device):

    @editable
    StartButton:button_device = button_device{}

    # 自定义事件:「类型 = 原型」成对出现,少一半都不行
    RaceStartEvent<public>:event(agent) = event(agent){}

    OnBegin<override>()<suspends>:void =
        StartButton.InteractedWithEvent.Subscribe(OnPressed)

    OnPressed(Agent:agent):void =
        # Signal:拉响事件,按挂起顺序叫醒所有 Await 中的任务
        RaceStartEvent.Signal(Agent)
        Print("发令枪响!")
gate_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

# 听令端:等发令枪响,再放行屏障
gate_device := class(creative_device):

    # @editable 也能引用你自己写的 Verse 设备!
    @editable
    Starter:starter_device = starter_device{}

    @editable
    Gate:barrier_device = barrier_device{}

    OnBegin<override>()<suspends>:void =
        # Await 是异步调用,OnBegin 带 suspends,正好合法
        Racer := Starter.RaceStartEvent.Await()
        Gate.Disable()
        Print("发令枪响,屏障放行!")

通信链路完整跑一遍:玩家按下发令台按钮 → OnPressed 被调用 → RaceStartEvent.Signal(Agent) 拉响事件 → 闸门设备里挂起已久的 Await() 带着 Agent 醒来 → Gate.Disable() 关掉屏障放行。Signal 会按挂起顺序依次唤醒所有正在 Await 的任务,同时同步调用通过 Subscribe 登记的回调——一枪响,全场都听得见。注意 gate_device 的插槽引用的是你自己写的 starter_device 类型:进编辑器后,把关卡里那台发令设备拖进面板,两台设备就正式配对了。

通信链路完整跑一遍:玩家按下发令台按钮 → OnPressed 函数图跑起来 → RaceStartEvent.Signal(Agent) 相当于 Call 一下事件分发器,把玩家一起带上 → 闸门设备那边一直 Await 等着的线(提前 Bind 好、停在那儿等响的那条)被叫醒,拿到 AgentGate.Disable() 关掉屏障放行。一次 Signal,会把所有正在 Await 等它的线按先后顺序全部叫醒,同时也会通知所有 Bind(Subscribe)过它的函数图——一枪响,全场都听得见。注意 gate_device 面板上那个插槽,引用的是你自己写的 starter_device 设备:进编辑器后,把关卡里那台发令设备拖进面板,两台设备就正式配对了。

轮到你了。下面这台「对讲机」被挖掉了两个关键动作:一个负责拉响事件,一个负责等待事件。把暗号补全:

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

# 对讲机:按钮一按,巡逻兵立刻收到暗号
relay_device := class(creative_device):

    @editable
    GoButton:button_device = button_device{}

    GoEvent:event() = event(){}

    OnBegin<override>()<suspends>:void =
        GoButton.InteractedWithEvent.Subscribe(OnGo)
        WaitForGo()

    OnGo(Agent:agent):void =
        # 拉响事件,叫醒所有等待中的任务
        GoEvent.____()

    WaitForGo()<suspends>:void =
        # 睡在这里,直到有人拉响 GoEvent
        GoEvent.____()
        Print("收到暗号,出发!")

五、常见坑:设备课的翻车现场

设备编程的坑有个共同特点:一半在代码外面。逐条对照,能省下大量排查时间。

六、关卡挑战

布线手艺练完了,来闯三道小关卡验收。答对拿星星 ★,答错可以一直重试,零惩罚。

编译全绿Compile 全绿,但一进游戏按下按钮就报「访问无效对象」的运行时错误。最可能的原因是?

想在按钮回调绑定的函数图里等 3 秒再开门,正确做法是?

声明新建自定义事件,哪个写法能通过编译Compile 通过?

拓展阅读

技巧 · EXTRA

自定义事件跨设备通信:论坛实战与 Dispatch 变体

声明报错怎么破、Verse 事件为什么进不了原生设备下拉框、三种设备间通信方式怎么选。

进入拓展 →

技巧 · EXTRA

用事件打断循环:Subscribe 回调与 loop 的正确协作

回调里 break 不了循环?标志位与 race + 事件两种成熟模式,把 19-21 课知识串成一个案例。

进入拓展 →

拓展 · EXTRA

playspace 与玩家生命周期:处理中途进出的玩家

GetPlayers 开局点名、PlayerAddedEvent 迎新,以及 agent / player / fort_character 三层模型。

进入拓展 →