技巧 · EXTRA
自定义事件跨设备通信:论坛实战与 Dispatch 变体
声明报错怎么破、Verse 事件为什么进不了原生设备下拉框、三种设备间通信方式怎么选。
进入拓展 →前两课你练熟了 suspends 与并发表达式会「等」的节点(带小钟表的 latent)和让几条线一起跑的并发玩法;这一课把目光放回关卡:用 @editable 在面板上留插槽,用 Subscribe 给设备事件接引线,再用自定义 event 让设备之间隔空喊话。学完它,你的代码就能真正「听懂」玩家的操作。
想让代码控制关卡里的按钮、屏障,第一反应可能是「在代码里把它们找出来」。打住!Verse 的正确姿势恰恰相反:不在代码里找设备,而是留一个插槽,让设备自己插进来。这就是 @editable 的工作——给类的字段戴上这顶帽子给一个变量戴上这顶帽子(它其实就是蓝图里你熟的 Instance Editable 小眼睛),它就会出现在 UEFN 的细节面板(Details)里,变成一个可以把关卡设备拖进去的配置项。
打个比方:你在设计一只万能遥控器,@editable 就是在遥控器面板上预留的按钮位——出厂时是空的,等遥控器摆进客厅(设备拖进关卡),再决定这个位对应哪盏灯、哪台电视。换台电视?重新配对就行,遥控器本身一个字都不用改。
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{};漏掉默认值直接吃编译错误。可暴露的类型不止设备引用:int、float、logic、string 及它们的数组都行;自定义类想上面板,还得给类加 <concrete> 说明符(要求每个成员都有默认值)。
写法三件套:@editable 独占一行 → 变量名和类型 → 末尾必须给默认值。设备类的变量用一个空原型占位,比如 = button_device{}(相当于先给引脚接一个空的默认值);漏掉默认值,Compile 直接报红。能挂小眼睛的不止设备引用:int、float、logic(真/假那种勾选框)、string 及它们的数组都行;想把你自己写的一整个蓝图类摆上面板,还得给那个类加 <concrete> 标记(条件是它每个变量都有默认值)。
官方文档点明了 @editable 的最大价值:在面板里改值不需要重新编译 Verse 代码不需要重新 Compile 代码(左上角那个 Compile 按钮不用再点)。冷却时间从 2 秒调到 3 秒这种事,策划在编辑器里拖一下滑块就完成了,迭代速度完全不一样。这也是「绝不硬编码查找」的理由:关卡是随时会改的,按钮今天摆在城门口,明天可能被删掉重摆;硬编码就像把电视型号焊死在遥控器里,而插槽只要重新拖一下。
插槽里有了按钮,接下来要回答:按钮被按下的那一刻,代码怎么知道?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) 括号里填的是函数的名字,后面不加括号、不填参数——你是把「这个函数图」本身交给按钮保管,让它以后需要时再运行,而不是现在就跑一遍。接线 ≠ 现在就拉一次。
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 接线 + 签名匹配的回调输入引脚对得上的函数图,就是一台会记账的门铃。点「运行下一步」,看它从布线到被按两次的完整链路。
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 一下的循环」更省心。
原生设备自带事件,那你自己写的两台 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(){}。
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("发令枪响!")
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 好、停在那儿等响的那条)被叫醒,拿到 Agent → Gate.Disable() 关掉屏障放行。一次 Signal,会把所有正在 Await 等它的线按先后顺序全部叫醒,同时也会通知所有 Bind(Subscribe)过它的函数图——一枪响,全场都听得见。注意 gate_device 面板上那个插槽,引用的是你自己写的 starter_device 设备:进编辑器后,把关卡里那台发令设备拖进面板,两台设备就正式配对了。
轮到你了。下面这台「对讲机」被挖掉了两个关键动作:一个负责拉响事件,一个负责等待事件。把暗号补全:
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("收到暗号,出发!")
设备编程的坑有个共同特点:一半在代码外面。逐条对照,能省下大量排查时间。
@editable 插槽忘了在细节面板里绑定实际设备。编译全绿Compile 全绿,一进游戏就报「访问无效对象」的运行时错误。记住:@editable 只负责开槽,连线要进编辑器手动拖。= button_device{} 这样的空原型初始化。= button_device{} 这样的空原型给个初始值。agent 参数、或把类型写成 player,都过不了编译。签名以官方 API 为准,绝不凭感觉猜。agent 引脚、或把类型写成 player,都过不了 Compile。引脚长相以官方 API 为准,绝不凭感觉猜。<suspends> 效果,直接报错。正确姿势:回调里 spawn 一个异步辅助函数。spawn 岔出一条新线,让那条线去等。cancelable,先 Cancel() 再重订。Cancel() 再重绑。@editable 字段变量。另外,重命名 @editable 字段变量可能导致面板上已配置的引用丢失,需要重新拖线。布线手艺练完了,来闯三道小关卡验收。答对拿星星 ★,答错可以一直重试,零惩罚。
编译全绿Compile 全绿,但一进游戏按下按钮就报「访问无效对象」的运行时错误。最可能的原因是?
想在按钮回调绑定的函数图里等 3 秒再开门,正确做法是?
声明新建自定义事件,哪个写法能通过编译Compile 通过?
技巧 · EXTRA
声明报错怎么破、Verse 事件为什么进不了原生设备下拉框、三种设备间通信方式怎么选。
进入拓展 →技巧 · EXTRA
回调里 break 不了循环?标志位与 race + 事件两种成熟模式,把 19-21 课知识串成一个案例。
进入拓展 →拓展 · EXTRA
GetPlayers 开局点名、PlayerAddedEvent 迎新,以及 agent / player / fort_character 三层模型。
进入拓展 →