技巧 · EXTRA
@editable 进阶:ToolTip、Categories 与滑条
用块级元数据把 Verse 设备的细节面板做出接近原生设备的体验:分组、悬浮提示、数值范围滑条。
进入拓展 →从 <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>:void、class<final>。它描述的是语言层面的语义约束,由编译器强制执行:说不能继承就不能继承,说可失败就必须按可失败的规矩调用。把它想成焊死在装备上的附魔词条——进本之前,系统(编译器)逐条验装,一条不合格就不放行。
specifier(尖括号徽章)用尖括号 <> 写,紧跟在名字或关键字后面,例如 <public>、<final>。它管的是「这段逻辑允许怎么被用」的硬规矩,由 Compile 按钮(蓝图左上角那个 Compile,Verse 里对应快捷键 Ctrl+Shift+B)强制执行:说不能继承就不能继承,说可能失败就必须按可能失败的规矩调用。把它想成焊死在装备上的附魔词条——上场之前,系统逐条验装,一条不合格就亮红、不放行。
attribute(特性)以 @ 开头,独占一行,写在声明的上方,例如 @editable。它不改变代码本身的语义,而是告诉编辑器或运行环境「请特殊对待这个声明」——@editable 就是在对 UE6 编辑器喊话:把这个字段摆进细节面板。它更像挂在装备上的便签,是写给商店老板(编辑器)看的。
attribute(@ 便签)以 @ 开头,独占一行,写在某样东西的上方,例如 @editable。它不改逻辑本身,而是告诉UEFN 编辑器或运行环境「请特殊对待它」——@editable 干的就是把一个变量的 Instance Editable 小眼睛点亮,让它出现在 Details 细节面板里,策划不碰一行逻辑就能调。它更像挂在装备上的便签,是写给商店老板(编辑器)看的。
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 这个关键字上,把「读」和「写」的权限拆成两半:
# 谁都能读分数,但只有本模块能改它
var<internal> Score<public>:int = 0
最后敲一下黑板:什么访问修饰符都不写时,默认是 internal(同一模块内可见),不是 public。跨模块引用一个忘了标 <public> 的函数,收到的报错是 unknown identifier——代码没丢,只是被门禁拦在外面了。
最后敲一下黑板:什么访问徽章都不写时,默认是 internal(只在同一个模块/文件夹里可见),不是 public。想从别的模块引用一个忘了标 <public> 的东西,Compile 会甩来一句 unknown identifier(找不到这个名字)——东西没丢,只是被门禁拦在外面了。
之前的课程说过:带 <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 的值。
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 是你最常用的 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 的黄金搭档。把它们填回去,再点「检查答案」。
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 报错榜的前排常客。提前踩点,真遇到时不慌:
OnBegin<override>()<suspends>:void,少 <override> 报「成员已在父类中定义」,少 <suspends> 报签名不匹配。OnBegin<override>()<suspends>:void,少 <override> 报「成员已在父类中定义」,少 <suspends> 报签名对不上。OnBegin 就是 Verse 版的 Event BeginPlay,重写它等于蓝图里 Override 掉它。[] 调用,普通函数得用小括号 (),反过来都会报红。括号形状必须和「这一步会不会走不通」对上。spawn{ Fn() } 启动。spawn{ Fn() } 岔出一条新线去跑它。class<final><persistable>,而且持久化类只能含常量字段。class<final><persistable>,而且能存档的类只能放常量、不能放 var。徽章展览闭馆前,来闯三道关卡验收战利品。答错零惩罚,可以一直重试。
一个函数什么访问 specifier 访问徽章都没写,它的默认访问级别是?
为什么 <decides> 函数必须用方括号、并且只能在失败上下文「可能走不通」的地方(比如 Branch)里调用?
想让一个类可以持久化(玩家数据跨局保存),下面哪个写法是对的?