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

事件与绑定:从 Event Dispatcher 到 Subscribe

蓝图作者做解耦靠 Event Dispatcher:一个东西喊一声,别的东西自己决定听不听。Verse 里同一件事叫 Subscribe。这一课把「广播 / 订阅」这套模型从节点图搬进代码,顺带讲清怎么引用另一个对象、怎么退订,以及为什么事件几乎总比 Tick 划算。

一、广播与订阅:这套模型你早就会

想想你上一次在蓝图里用 Event Dispatcher 的场景。门被打开了,门自己不知道谁关心这件事——它只管 Call 一下自己的 OnOpened 分发器,喊一声「我开了」。谁关心,谁自己去 Bind:计分板 Bind 一下,音效管理器 Bind 一下,成就系统 Bind 一下。门从头到尾没听说过它们中的任何一个。

这就是广播 / 订阅模型,也是蓝图里最重要的解耦工具。它值钱的地方不在「能触发别的逻辑」——直接持有一个引用去调函数也能触发;它值钱在方向:发布者不认识订阅者,所以你能随时增删一个监听者,而不用回去改门。

Verse 里,同一套模型用三个词表达:

另外 Verse 还多给了一件蓝图没有的东西:Await()——不注册处理函数,而是让当前这条执行线停在这里等这个事件响。蓝图里没有对应节点,因为蓝图的函数图不能中途停下(第 23 课讲过);Verse 有 <suspends>,于是「等一个事件」可以像等一个 Delay 那样直接写在执行线上。这一课后面会两条路都走一遍。

但在订阅之前,还有一件更基础的事要解决:你的代码怎么才能拿到那扇门?

二、引用另一个对象:Details 面板那根拖线,在 Verse 里叫 @editable

蓝图里的做法你闭着眼都能做:在类里加一个变量,类型选 Actor 引用(或某个具体的 Actor 类),勾上 Instance Editable 那只小眼睛;摆进关卡后,在 Details 面板里点那个下拉框,从关卡里挑一个 Actor 拖进去。代码不认识具体是哪扇门,门是配置进来的

Verse 一模一样,只是那只小眼睛现在写成一行属性:@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

逐行对照蓝图:@editable 那一行 = 勾上 Instance Editable;TriggerButton:button_device = 一个「按钮类型的 Actor 引用变量」;= button_device{} = 默认值。Delay:float = 2.0 就是变量面板里一个默认 2.0 的 Float,策划直接在 Details 面板改。

三条规则要记牢。第一,@editable 独占一行,写在字段上方。第二,必须给默认值——引用类的字段用一个空原型占位(= button_device{}),漏了直接 Compile 报红;蓝图里对象引用默认是 None,Verse 不允许这种「空着」的状态,这是 Verse 从根上消灭 Accessed None 的一环(第 18 课讲过 option)。第三,能挂 @editable 的不止对象引用:intfloatlogicstring 以及它们的数组都行;想把你自己写的类整个摆上面板,那个类还得加 <concrete> 标记(条件是它每个字段都有默认值)。

和蓝图完全一致的一点:改面板上的值不用重新 Compile。冷却时间从 2 秒调到 3 秒,策划拖一下就完事。也正因如此,「在代码里按名字硬找关卡里的对象」在两边都是坏习惯——关卡随时会改,而插槽只要重新拖一次。

三、订阅事件:Bind Event 的代码形态

插槽里有了按钮,回答开头那个问题:按钮被按下的那一刻,代码怎么知道?蓝图里你会在事件图上放一个 Bind Event to On Interacted,把红色的事件引脚连到一个 Custom Event 上。Verse 里就是一次函数调用:

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("警报!有人按下了按钮!")

订阅写在哪?和蓝图一样:写在「开场只跑一次」的地方。蓝图里是 Event BeginPlay,Verse 里是 OnBegin。写在别处不是不行,但要小心——这也是本课最常见的坑,见下面第三条。

处理函数的签名必须匹配。蓝图里 Bind Event 时,如果签名对不上,连线根本连不上去;Verse 里对不上就是 Compile 报红。InteractedWithEvent 会把「按按钮的那个人」当成一个 agent 递过来(相当于蓝图事件上的 Instigator 引脚),所以处理函数必须写成 OnAlarm(Agent:agent):void——少一个参数不行,把类型写成 player 也不行。还有一个细节值得敲黑板:Subscribe(OnAlarm) 括号里填的是函数名字,后面不加括号、不填参数。你是把这张函数图交给按钮保管,让它以后需要时再运行,不是现在就跑一遍——接线 ≠ 现在就拉一次

退订。Subscribe 会交回一个 cancelable(可取消凭证),对它调 Cancel() 就等于蓝图的 Unbind Event。蓝图里你还有一个 Unbind All Events 可以一键清场,Verse 没有对应物——凭证得你自己收好,绑得多了就存进一个数组 var Subs:[]cancelable,清场时挨个 Cancel()。什么时候必须退订、什么时候可以不管,拓展页 x1 有一整页。

处理函数里不能等。你订阅上去的那个函数是「立即跑完」类型,签名由官方定死,不带 <suspends>——里面直接放 Sleep 一定报错。这和蓝图那条「Delay 不能放进自定义 Function」的限制是同一种味道。解法是请第 24 课的分身术出场:在里面 spawn{ SomeAsyncFunc() } 岔出一条新线,让那条线去睡去等。spawn 恰好是唯一允许待在「不能等的执行线」上的并发表达式,这正是它的主场。

把零件拼起来看一次完整链路:插槽 + 一个计数变量 + 订阅 + 签名对得上的处理函数,就是一台会记账的门铃。点「运行下一步」。

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 就什么时候被叫醒,按十次记十次账——和蓝图里 BeginPlay 跑完、Bind 好的事件继续生效,是完全一样的心智模型。

四、自己发一个事件:Call Dispatcher 的代码形态

原生设备自带事件;那你自己写的两个 Verse 类之间怎么喊话?这正是蓝图里 Event Dispatcher 的主场:在类里 Add Event Dispatcher,起个名字,配好输入参数,然后在需要的地方 Call 一下。

Verse 里对应的是 event(t)。声明有一条铁律:「类型 = 原型」必须成对写全,即 RaceStartEvent<public>:event(agent) = event(agent){}。只写左半边,Compile 会抱怨这是个抽象的、没法直接用的东西。括号里的 agent 就是这个分发器的输入参数类型(蓝图里你在 Event Dispatcher 面板上配的那些 Inputs);没参数就写 event() = event(){}<public> 是为了让别的类也访问得到——通信的另一端通常在另一个类里。

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

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

    @editable
    StartButton:button_device = button_device{}

    # 相当于蓝图里 Add Event Dispatcher,输入参数是一个 agent
    RaceStartEvent<public>:event(agent) = event(agent){}

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

    OnPressed(Agent:agent):void =
        # Signal 就是 Call Dispatcher:喊一声,把 Agent 一起带上
        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 这张图跑起来(蓝图里那个 Custom Event 被点亮) → RaceStartEvent.Signal(Agent) 就是 Call Dispatcher,把玩家一起带上 → 另一端那条 Await 着的执行线被叫醒,拿到 AgentGate.Disable() 放行。注意 gate_device 面板上那个插槽引用的是你自己写的 starter_device——进编辑器把发令台拖进去,两者就配对了,和蓝图里拖一个 Actor 引用是同一个动作。

有一处语义要留个心眼:同一个事件同时被多条 Await 等着时,唤醒顺序与「叫醒一个还是叫醒全部」在不同版本的文档里表述并不一致。需要「一次广播、多个监听者都收到」这种确定行为时,用 Subscribe 注册多个处理函数,而不是让多条线各自 Await 同一个事件——这也更贴近你在蓝图里的直觉:Event Dispatcher 的多个 Bind 都会被通知。

轮到你了。下面这台「对讲机」被挖掉了两个关键动作:一个负责(Call Dispatcher),一个负责。把暗号补全:

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 =
        # 喊一声(相当于 Call Dispatcher)
        GoEvent.____()

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

五、为什么事件比 Tick 好

同一个需求——「玩家按下按钮就开门」——有两种写法。轮询版是这样的:

polling_vs_event.verse
# ✗ 轮询(示意):相当于把逻辑塞进 Event Tick,每帧问一次「按了吗?」
#   ButtonIsPressed 是你自己维护的一个 logic 字段,这里只为对比结构
PollForPress()<suspends>:void =
    loop:
        if (ButtonIsPressed?):
            OpenDoor()
        Sleep(0.0)

# ✓ 事件驱动:登记一次,剩下的交给引擎
OnBegin<override>()<suspends>:void =
    Bell.InteractedWithEvent.Subscribe(OnRing)

轮询版每秒要跑约 30 次(第 23 课讲过服务器的模拟帧节奏),而这 30 次里通常 30 次都在做同一件事:确认「没按」。事件版一整局可能只跑两次——玩家真按了两次。差距不是几个百分点,是几个数量级;而且它还是随设备数量线性增长的:20 台门,轮询就是 20 条每帧空转的执行线。

更麻烦的是延迟和正确性。轮询最多晚一帧才发现状态变了,而且只能看到「现在是什么状态」,看不到「刚刚发生了什么」——玩家在同一帧内按下又松开,轮询直接漏掉,事件则一次不落。这就是为什么 Unreal 的性能优化文章几十年如一日地重复同一句话:能不用 Tick 就别用 Tick。这条建议在 Verse 里一字不改地成立,只是 Tick 换了个名字,叫「循环体里的 Sleep(0.0)」。

给蓝图作者的明确工程建议,三句话:

六、常见坑:布线现场

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

蓝图对照

这一课几乎每个概念在蓝图里都有一个同名的东西。差异那一列才是重点。

蓝图里的做法 Verse 里的写法 差异
在类里 Add Event Dispatcher,配好 Inputs MyEvent<public>:event(agent) = event(agent){} Verse 必须「类型 = 原型」成对写全,少右半边就报抽象错误;括号里的类型就是分发器的 Inputs
Bind Event to XXX 节点,红色引脚连到 Custom Event Button.InteractedWithEvent.Subscribe(OnPressed) 蓝图靠连线校验签名(对不上连不上);Verse 靠编译器校验(对不上 Compile 报红)。传的是函数名,不带括号
Unbind Event / Unbind All Events Sub := ...Subscribe(...),之后 Sub.Cancel() Verse 没有「一键全退」:凭证得你自己收着,通常存进 var Subs:[]cancelable
Call Dispatcher 节点 MyEvent.Signal(Agent) 语义一致。但 Verse 的事件还能被 Await——这是蓝图没有的第三种用法
Actor 引用变量(Object Reference 类型的变量) @editable 字段,如 Button:button_device = button_device{} 蓝图的对象引用默认是 None;Verse 强制给默认值,不允许「空着」这个状态
在 Details 面板里从关卡拖一个 Actor 进那个变量槽 同一个 Details 面板,拖进 @editable 完全一致——这一步 Verse 没有代码替代品,也不该有:配置和逻辑分开才是对的

差异的来源是两件事。其一,蓝图把「签名匹配」交给连线这个动作去保证,连不上就是错;Verse 没有连线,只好把这份保证移交给编译器,于是你会看到更多、更早的红字。其二,Verse 有 <suspends>,所以事件除了「注册一个回调」之外还多了一种消费方式:让一条执行线停在事件上等。蓝图的 Bind 没有这种形态,因为蓝图的执行线停不下来。

还有一处蓝图更方便,得承认:蓝图的 Event Dispatcher 面板上,「谁 Bind 了我」是可以点开看的;Verse 里没有这样的可视化——想知道一个事件有多少订阅者,只能靠搜代码。这是文本代码换来编译期安全所付出的代价之一。

关卡挑战

布线手艺练完了,三道关卡验收。答错零惩罚,可以一直重试。

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

想在 Subscribe 上去的那个处理函数里等 3 秒再开门,正确做法是?

要在 Verse 里写一个相当于 Event Dispatcher 的东西,哪个写法能 Compile 通过?

拓展阅读

技巧 · EXTRA

订阅的生命周期:什么时候订、什么时候退订

订阅写在哪一行、cancelable 凭证怎么收着、重复订阅为什么会让一次按下触发 N 次。

进入拓展 →

拓展 · EXTRA

@editable 引用:把关卡里的对象接进代码

从 Actor 引用变量到 @editable 字段:默认值、数组插槽、引用你自己写的类,以及重命名会踩的雷。

进入拓展 →

拔高 · EXTRA

事件驱动 vs 轮询:两种架构的代价对比

把同一个需求两种写法摆在一起算账:每秒开销、响应延迟、漏事件的风险,以及轮询仍然合理的少数场景。

进入拓展 →