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

specifiers 与 attributes:代码的徽章与便签

从 <override> 到 <suspends>,你已经收集了不少尖括号「徽章」。这一课把整套徽章系统一次讲清:访问、效果、类修饰符各管什么;@ 开头的「便签」又是给谁看的;顺便拆解那条人人都会撞上的 no_rollback 报错。

你在蓝图里连节点时,引擎其实一直在背后给每样东西贴「标签」。Verse 把这些标签摆到了明面上:一类用尖括号 <> 写,是「徽章」——蓝图左上角 Compile 按钮会逐条检查、不合格就报红的硬规矩;另一类以 @ 开头,是「便签」,不改逻辑,只是对 UEFN 编辑器喊话(你早就会的 Instance Editable 小眼睛就是其中一张)。这一课把两套标签讲清,顺便拆掉那条人人都会撞上的 no_rollback 报红。

一、两套系统:尖括号徽章与 @ 便签

回顾一下你写过的代码:OnBegin 后面跟着 <override> 和 <suspends>,可失败函数戴着 <decides>,想在编辑器里调数值的字段上方蹲着一行 @editable。这些「附加标记」其实来自两套完全不同的机制。

回顾一下你一路见过的写法:游戏开始事件 OnBegin(Verse 版的 Event BeginPlay)后面跟着 <override> 和 <suspends>,可能失败的函数戴着 <decides>,想在编辑器面板里调的数值上方蹲着一行 @editable。这些「挂在名字旁边的小标记」其实来自两套完全不同的机制。

specifier(修饰符)用尖括号写,紧跟在标识符或关键字之后,例如 Foo<public>()<suspends>:voidclass<final>。它描述的是语言层面的语义约束,由编译器强制执行:说不能继承就不能继承,说可失败就必须按可失败的规矩调用。把它想成焊死在装备上的附魔词条——进本之前,系统(编译器)逐条验装,一条不合格就不放行。

specifier(尖括号徽章)用尖括号 <> 写,紧跟在名字或关键字后面,例如 <public><final>。它管的是「这段逻辑允许怎么被用」的硬规矩,由 Compile 按钮(蓝图左上角那个 Compile,Verse 里对应快捷键 Ctrl+Shift+B)强制执行:说不能继承就不能继承,说可能失败就必须按可能失败的规矩调用。把它想成焊死在装备上的附魔词条——上场之前,系统逐条验装,一条不合格就亮红、不放行。

attribute(特性)以 @ 开头,独占一行,写在声明的上方,例如 @editable。它不改变代码本身的语义,而是告诉编辑器或运行环境「请特殊对待这个声明」——@editable 就是在对 UE6 编辑器喊话:把这个字段摆进细节面板。它更像挂在装备上的便签,是写给商店老板(编辑器)看的。

attribute(@ 便签)以 @ 开头,独占一行,写在某样东西的上方,例如 @editable。它不改逻辑本身,而是告诉UEFN 编辑器或运行环境「请特殊对待它」——@editable 干的就是把一个变量的 Instance Editable 小眼睛点亮,让它出现在 Details 细节面板里,策划不碰一行逻辑就能调。它更像挂在装备上的便签,是写给商店老板(编辑器)看的。

badge_demo_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

badge_demo_device := class(creative_device):

    # attribute:@ 开头,独占一行,写在声明上方
    @editable
    var Prize:int = 100

    # 访问 specifier:紧跟在标识符后面
    var Secret<private>:int = 0

    # 效果 specifiers:在参数表之后、返回类型之前
    IsLucky()<decides><transacts>:void =
        Prize > 50

    # override + suspends:两枚你早就认识的徽章
    OnBegin<override>()<suspends>:void =
        Print("徽章展览开幕")

读图翻译:这段 badge_demo_device 就是一张「徽章陈列柜」——从上到下摆了四样东西。@editable 那行是贴在 Prize 变量上方的便签(点亮它的 Instance Editable 小眼睛);Secret<private> 是一个只有自己能碰的私有变量;IsLucky()<decides><transacts> 是一个「可能失败、失败了还能整体撤销」的判断函数;最后 OnBegin 就是 Verse 版的 Event BeginPlay,游戏一开始自动跑,里面用 Print String 打了句「徽章展览开幕」。每样东西旁边挂的尖括号或 @,就是这一课的主角。

位置口诀记四条:类的徽章贴在 class 后(class<final>);函数的效果徽章贴在参数表之后、返回类型冒号之前(F()<decides><transacts>:int);访问级别贴在标识符后(X<private>:int);@ 便签永远独占一行、待在声明上方。写反位置——比如把 <public> 挪到行首、把 @editable 写成 <editable>——都是语法错误。

位置也有规矩,记四条:类的徽章贴在 class 后面(class<final>);函数的效果徽章贴在那对小括号后面、冒号前面(F()<decides><transacts>:int);管「谁能碰」的访问徽章贴在名字后面(X<private>:int);@ 便签永远自己独占一行、待在上方。贴错地方——比如把 <public> 挪到行首、或者把 @editable 写成尖括号版 <editable>——一按 Compile 就报红。

二、修饰符总览:一张表看全

Verse 的修饰符按「管什么」分三大类:管可见性的访问修饰符、管函数行为承诺的效果修饰符、管类形态的类修饰符。一张表看全:

Verse 的徽章按「管什么」分三大类:管「谁能看见、谁能碰」的访问徽章、管「这个函数会对世界做什么」的效果徽章、管「这张蓝图长什么样、能不能被继承」的类徽章。一张表看全:

类别 specifier 一句人话 示例
访问 <public> 完全公开,谁都能用 Score<public>:int
<internal> 同一模块内可用——不写就是它(默认值) Helper<internal>():void
<protected> 只有本类和它的子类能碰 var HP<protected>:int
<private> 只有本类自己能碰 var Seed<private>:int
效果 · 独占
(四选一)
<transacts> 做的事可以整体回滚(隐含读、写、分配) AddHit()<transacts>:int
<varies> 同样的输入,可能给出不同结果 多见于官方 API 签名
<computes> 纯计算,无副作用:同输入必同输出 多见于官方 API 签名
<converges> computes 再加一条:保证一定算得完 字段初始化等场合引擎要求它
效果 · 附加
(可叠加)
<decides> 可能失败,调用要用方括号 Foo[] IsReady()<decides><transacts>:void
<suspends> 异步函数,可跨多个模拟帧慢慢跑 OnBegin<override>()<suspends>:void
类 / 成员 <abstract> 不能实例化,留给子类补全 base := class<abstract>:
<final> 到此为止:类不能再被继承,成员不能再被重写 boss := class<final>:
<unique> 实例有唯一身份,可用 = 比较、可当 map 键 pass := class<unique>:
<concrete> 所有字段都有默认值,可用空原型 {} 实例化 @editable 自定义类需要它
<override> 我在重写父类的成员 Tick<override>():void

效果修饰符有一条重要的组队规则:transacts / varies / computes / converges 是独占效果,一个函数只能选一个;decides 和 suspends 是附加效果,可以叠加在独占效果上,比如 IsPuzzleSolved()<decides><transacts>:void。而什么效果都不写时,函数默认拿到的是 no_rollback——「做过的事覆水难收」。这个不起眼的默认值会在下一节掀起风浪。日常写 UEFN 代码,你主动写的基本只有 transacts、decides、suspends 三个;computes、converges、varies 更多是在阅读官方 API 签名时遇到,认得它们就够了。

效果徽章有一条重要的组队规则:transacts / varies / computes / converges 是「独占款」,一个函数只能戴一个;decides 和 suspends 是「附加款」,可以叠在独占款上面,比如 IsPuzzleSolved()<decides><transacts>:void。而什么效果都不写时,函数默认拿到的是 no_rollback——「做过的事覆水难收」。这个不起眼的默认值会在下一节掀起风浪。平时做 UEFN,你会主动写的基本只有 transacts、decides、suspends 三个;computes、converges、varies 你更多是在翻官方现成节点/函数的说明时碰到,认得它们就够了。

访问修饰符还有个隐藏玩法:它既可以贴在成员标识符上,也可以单独贴在 var 关键字上,把「读」和「写」的权限拆开:

访问徽章还有个隐藏玩法:它既能贴在变量名字上,也能单独贴在 var 这个关键字上,把「读」和「写」的权限拆成两半:

score_module.verse
# 谁都能读分数,但只有本模块能改它
var<internal> Score<public>:int = 0

最后敲一下黑板:什么访问修饰符都不写时,默认是 internal(同一模块内可见),不是 public。跨模块引用一个忘了标 <public> 的函数,收到的报错是 unknown identifier——代码没丢,只是被门禁拦在外面了。

最后敲一下黑板:什么访问徽章都不写时,默认是 internal(只在同一个模块/文件夹里可见),不是 public。想从别的模块引用一个忘了标 <public> 的东西,Compile 会甩来一句 unknown identifier(找不到这个名字)——东西没丢,只是被门禁拦在外面了。

三、decides 与失败上下文:方括号背后的契约

之前的课程说过:带 <decides> 的函数要用方括号调用。现在可以把背后的逻辑链讲完整了。

之前的课程说过:带 <decides> 的函数,调用时要用方括号 [] 而不是小括号。现在可以把背后的道理讲完整了。

<decides> 把函数变成一个可失败表达式——它执行到一半可能宣布「失败」。Verse 对失败的态度和多数语言不同:失败不是异常、不是崩溃,而是像掷骰没过判定一样的正常结果,但它必须被接住。谁来接?失败上下文——if 的条件、for 的过滤条件这些「允许失败发生」的地方。所以 decides 函数的调用必须出现在失败上下文里,方括号 Foo[] 就是「此处可能失败」的显式路标。

<decides> 把一个函数变成「可能走不通」的判断——它跑到一半可能当场宣布「没过」。Verse 对「没过」的态度和别处不一样:它不是报错、不是崩溃,而是像掷骰没过判定一样的正常结果,但它必须有地方接住。谁来接?就是那些「允许一条线走不通」的地方——最典型的是 Branch(if):Branch 问的不是 True/False,而是「这条线到底能不能走通」,走不通就转去走 else 那根引脚。所以 decides 函数只能在这种地方调用,方括号 Foo[] 就是明摆着的路标:「注意,这一步可能走不通」。

接住失败还不够,还得善后:函数失败时可能已经做了一半的事,改了一半的变量。Verse 的方案很硬核——失败上下文里的一切都是推测执行:先记账再动手,一旦失败,账本一撕,所有改动整体回滚,像什么都没发生过。这正是 decides 总和 transacts 成对出现的原因:transacts 承诺「我做的每件事都可以撤销」。

接住「没过」还不够,还得善后:一个判断走到一半,可能已经动了几个变量、做了半件事。Verse 的做法很硬核——这种地方里的一切都是推测执行:就像先在一张「影子图」上把线试连一遍,全走通了才把改动落到真图;只要中途走不通,影子图整个作废,所有改动一笔勾销,像没连过。这正是 decides 总拉着 transacts 一起出场的原因:transacts 就是那份「我干的每件事都能撤回」的承诺。

反过来,这也解释了那条著名的编译错误「This invocation calls a function that has the no_rollback effect, which is not allowed by its context」:没有效果标注的函数默认是 no_rollback,它做的事(比如 Print 打日志)没法撤销,编译器自然禁止它进失败上下文。常见解法也简单:把调用挪到 if 外面,先存进常量,再在条件里用常量。

反过来,这也解释了那条著名的报红「This invocation calls a function that has the no_rollback effect, which is not allowed by its context」:没戴效果徽章的函数默认是 no_rollback,它做的事(比如 Print String 打一行调试字)撤不回来,Compile 自然不准它进这种「可能要反悔」的地方。解法也简单:把这个调用挪到 Branch 外面先跑,把结果存进一个常量,再让 Branch 只拿常量做判断。

下面这台「金库门」把回滚过程演给你看:只有一把钥匙,却要开两扇门。点「运行下一步」,盯紧 Keys 的值。

vault_device.verse
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }

vault_device := class(creative_device):

    # 只有一把钥匙
    var Keys:int = 1

    # 先扣钥匙再验收:失败时 set 会被整体回滚
    TrySpendKey()<decides><transacts>:void =
        set Keys -= 1
        Keys >= 0

    OnBegin<override>()<suspends>:void =
        if (TrySpendKey[]):
            Print("第一扇门开了,剩余钥匙:{Keys}")
        if (TrySpendKey[]):
            Print("第二扇门开了")
        else:
            Print("开门失败,Keys 被回滚,还是 {Keys}")
输出日志

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

注意第 11 行被执行了两次:同一句 set Keys -= 1,第一次被提交,第二次被撤销——决定命运的是随后那行判定。这套「先斩后奏但可以反悔」的机制,本质是语言级的事务内存;想再深挖一层,文末拓展页《效果系统与事务回滚:no_rollback 全解》等着你。

注意那句 set Keys -= 1(第 11 行)被跑了两次:同一个 Set 节点,第一次被提交、第二次被撤销——决定命运的是紧跟其后的那句判定。这套「先斩后奏但可以反悔」的机制,本质就是把「事务」(一整笔操作要么全成、要么全撤)做进了语言里;想再深挖一层,文末拓展页《效果系统与事务回滚:no_rollback 全解》等着你。

四、@editable:最常用的便签

说完编译器的徽章,轮到编辑器的便签。@editable 是你最常用的 attribute:把它写在 creative_device 字段的上一行,这个字段就会出现在 UE6 编辑器的细节面板里——策划和关卡设计师不写一行代码,就能拖着滑条调你的数值。

说完 Compile 管的徽章,轮到编辑器的便签。@editable 是你最常用的便签:把它写在设备(creative_device)某个变量的上一行,这个变量就冒到 UE6 编辑器的细节面板里——这正是蓝图里那个 Instance Editable 小眼睛干的事,策划和关卡设计师不碰一行逻辑,就能拖着滑条调你的数值。

用它时记三件事:一,字段必须有默认值;二,类型要在支持清单里(基本类型、设备引用、agent、数组,以及标了 <concrete> 的自定义类);三,想要 ToolTip 悬浮提示、面板分组、数值范围滑条这些高级体验,@editable 家族还有带块级元数据的进阶写法——见文末拓展页。

用它时记三件事:一,这个变量必须有默认值;二,变量类型得在支持清单里(基本类型、设备引用、agent(就是那个「玩家引脚」)、数组,以及标了 <concrete> 的自定义类);三,想要鼠标悬停说明(ToolTip)、面板分组、带范围的滑条这些高级体验,@editable 家族还有更进阶的写法——见文末拓展页。

最后澄清一个高频误会:persistable 是 specifier,不是 attribute!让类可持久化要写 class<final><persistable>,写成 @persistable 编译器直接摇头。判断两套系统就一条准绳:要编译器强制执行的语义约束,是 <> 徽章;给编辑器 / 运行环境的处理指示,才是 @ 便签。

最后澄清一个高频误会:persistable 是尖括号徽章(specifier),不是 @ 便签!想让一个类能存档,要写成 class<final><persistable>,写成 @persistable 一按 Compile 直接摇头。分辨两套系统就一条准绳:要 Compile 强制执行的硬规矩,用 <> 徽章;只是给编辑器 / 运行环境的悄悄话,才用 @ 便签。

动手时间:下面这台设备被挖掉了三个关键词——一张便签、一枚访问徽章、一个 decides 的黄金搭档。把它们填回去,再点「检查答案」。

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

drill_device := class(creative_device):

    # 便签:把奖金字段挂到编辑器细节面板
    @____
    var Prize:int = 100

    # 只有本类和子类能碰的护盾
    var Shield<____>:int = 50

    # 可失败函数:decides 的黄金搭档是谁?
    BossAlive()<decides><____>:void =
        Shield > 0

五、常见坑

specifiers 相关的编译错误,大概是全 Verse 报错榜的前排常客。提前踩点,真遇到时不慌:

跟徽章有关的 Compile 报红,大概是全 Verse 报错榜的前排常客。提前踩点,真遇到时不慌:

六、关卡挑战

徽章展览闭馆前,来闯三道关卡验收战利品。答错零惩罚,可以一直重试。

一个函数什么访问 specifier 访问徽章都没写,它的默认访问级别是?

为什么 <decides> 函数必须用方括号、并且只能在失败上下文「可能走不通」的地方(比如 Branch)里调用?

想让一个类可以持久化(玩家数据跨局保存),下面哪个写法是对的?

拓展阅读

技巧 · EXTRA

@editable 进阶:ToolTip、Categories 与滑条

用块级元数据把 Verse 设备的细节面板做出接近原生设备的体验:分组、悬浮提示、数值范围滑条。

进入拓展 →

拔高 · EXTRA

效果系统与事务回滚:no_rollback 全解

把那条天书报错拆到原子级:推测执行、事务内存,以及为什么 Print 进不了失败上下文。

进入拓展 →

拓展 · EXTRA

<persistable>:让玩家数据跨局存活

排行榜、货币、通关进度——用 persistable + weak_map 搭出你的第一个存档系统。

进入拓展 →