Verse Wiki — 写给蓝图作者的 Verse 手册
参考手册 · REFERENCE

Verse 语法速查

一页扫完 Verse 的全部招式:声明、运算符、流程、容器、函数、类型、说明符、并发、设备样板、Scene Graph 组件骨架与持久化。每个速查块一段最小代码、一张「蓝图里是什么」对照表、一个详解课号。写代码卡壳时回来扫一眼,顺着课号跳回去补课。

怎么用这一页

这一页是「写代码时开着的那一屏」。每一节的结构固定成三层:先给一段能编译的最小代码,再给一句 ▢ 读一下这段 的白话讲解,最后是一张 蓝图里是什么 的对照表——你已经会做那件事,只是不知道它在这边怎么写。

想查一个具体名词而不是语法块,去蓝图 ↔ Verse 术语对照表;想弄明白某条规则为什么长这样,顺着每节末尾的课号跳回课程地图

一、声明:变量、常量与类型标注

variables.verse
# 常量:定义即终身,不可再改;:= 省略类型让编译器推断
MaxHealth:int = 100
Nickname := "泡泡"

# 变量:var 声明,带类型标注;修改必须喊 set
var Health:int = 100
set Health = 80
set Health += 15

▢ 读一下这段:上半截造了两个「定死不改」的常量(相当于建变量时不给它接 Set 节点);下半截 var Health 是个可改的变量,后面每次改都得用 set(就是接一个 Set 节点)。记住三个符号::= 只管第一次定义、set … = 才是改值(接 Set)、光一个 = 是「判断相等」的检定,而且根本没有 ==。详见 第 8 课

types.verse
Count:int = 42                        # 整数
Speed:float = 3.5                     # 浮点,字面量必须带小数点
Title:string = "UE6"                  # 字符串,插值写 "HP: {Count}"
var Ready:logic = false               # 布尔:true / false
Names:[]string = array{"泡泡", "阿奇"}   # 数组
Table:[string]int = map{"钥匙" => 1}    # 映射
var MaybeN:?int = false               # option,false 即空
Pair:tuple(int, float) = (1, 2.0)     # 元组

▢ 读一下这段:每一行都是「名字:类型 = 值」,给一个变量定好它装什么(整数、小数、文字、Boolean、数组、映射、可空的 option 盒子、元组),对应蓝图里建变量时先选好变量类型。两个坑:int 和 float 不会自动互转——整数变小数要乘 1.0,小数变整数得用「可能失败」的 Floor[X](向下取整);写小数一定带小数点。数字详见 第 9 课,字符串详见 第 10 课,logic 详见 第 11 课

这一节的 Verse蓝图里是什么一句话差异
Name:type = 值变量面板里新建一个变量并填 Default Value没有面板,声明写在哪一级缩进就属于哪个作用域
写出变量名Get 节点读一个值不需要任何节点
set X = …Set 节点漏了 set,单独的 = 会被当成判等
什么都不写(默认不可变)蓝图没有「不可变变量」默认值反了:Verse 要变才申请 var
int / float / logic / stringInteger / Float / Boolean / StringBoolean 改名叫 logic,且不能直接当 if 条件
?int(option)可能为空的对象引用「可能没有」被写进类型里,编译期就逼你处理

二、运算符

operators.verse
if (A = B, B <> C):            # 判等是 =,不等是 <>;没有 ==
    Print("逗号就是 and")
if (X > 0 and Y > 0):          # 逻辑词 and / or / not,没有 && || !
    Print("比较全家都是可失败表达式")
if (Ready?):                   # 后缀 ?:logic 为 true 才成功
    Print("裸写 if (Ready) 编译不过")

▢ 读一下这段:三段 if 都像 Branch,但注意它们问的不是「Boolean 是不是 true」,而是「括号里这件事走不走得通」。所以判等用一个 =、判不等用 <>(没有 ==),逻辑连接用 and / or / not(没有 && || !),逗号也当 and 用;想拿 Boolean 当条件得补个 ?。一句话:这些比较不吐 true/false,而是以「走通 / 走不通」参与流程,所以只能待在「允许失败的地方」。详见 第 13 课

符号 作用 蓝图里是什么 备注
:= 定义并初始化 新建变量并填默认值 只用于首次定义,可省类型
set X = … 给 var 赋值 Set 节点 复合赋值 += -= *= /=(int 的 /= 不行)
= <> < <= > >= 比较,可失败 Equal / Not Equal / Less / Greater 节点 只能出现在失败上下文;没有 ==
+ - * / 算术 Add / Subtract / Multiply / Divide 节点 int / int 可失败且得 rational(分数类型):if (Half := Floor(X / 2)):
Mod[A, B] 求余 Modulo 节点 没有 %;方括号 = 可失败调用
and or not 组合成功/失败 AND / OR / NOT 节点 没有 && || !;not 内的效果永远回滚,也不能拿来翻转一个 logic 变量
X? 查询 把 Boolean 接进 Branch 的 Condition / Is Valid logic 为 true / option 非空才成功
X[…] 可失败调用与取值 Cast 的那根 Cast Failed 引脚 取下标、查表、调 <decides> 函数、类型转换都用它

▢ 这张表按蓝图直觉认一遍::= 第一次建变量并赋初值;set X = … 是 Set 节点改值;那排比较符号都只在「允许失败的地方」用;算术里整数除整数会「可能失败」且得到分数,要用 Floor 收回整数;求余没有 %、写成 Mod[A, B];逻辑连接只有 and / or / not 三个词;末尾的 X? 把 Boolean 或盒子翻译成「走不走得通」。

三、控制流:if / for / loop / defer / case

if.verse
# 条件区是失败上下文:绑定链任何一步失败,整体走 else
if (Player := player[Agent], Fort := Player.GetFortCharacter[]):
    Print("拿到角色,继续干活")
else:
    Print("链上某一步失败了")

Max := if (A > B) then A else B    # if 是表达式,可直接取值

▢ 读一下这段:上面这个 if 像一串接龙的 Cast——先把 Agent 转成 player、再从它身上取到角色实体,任何一环走不通就整体走 else。下面 if (A > B) then A else B 则是把 if 当「二选一」用(相当于 Select 节点),直接算出较大值。关键脾气:「允许失败的地方」是可回滚的——条件里用 Set 改过的值,一旦失败就整体撤销、像没改过,所以条件括号里也不许放 Print String 这种收不回的动作。详见 第 12 课

for.verse
for (Item : Items):                # 遍历数组元素
    Print("{Item}")
for (Index -> Elem : Items):       # 带下标,从 0 起
    Print("{Index} 号:{Elem}")
for (N := 1..5, N > 2):            # 范围含两端;过滤失败只跳过本轮
    Print("{N}")
Squares := for (X := 1..5) { X * X }   # for 是表达式,收集成新数组

▢ 读一下这段:四种 ForEach 写法——逐个取元素、连序号一起取、按数字范围跑(还能顺手加个过滤条件,不满足就跳过这一轮)、以及把每轮算出的值收集成一个新数组。注意 Verse 的 ForEach 里没有 break / continue:想「跳过某轮」就写成过滤条件,想「边跑边收集」直接拿整个 for 的结果当数组用。详见 第 14 课

loop.verse
var Round:int = 0
OnBegin<override>()<suspends>:void =
    loop:
        set Round += 1
        if (Round > 3):
            break              # break 只属于 loop,for 里不能用
        Sleep(1.0)             # 每轮至少一个挂起点,否则判死循环

▢ 读一下这段:loop 是一根自己绕回去、无限重复的执行线(相当于 While Loop),每轮把回合数 +1,超过 3 就 break 跳出,末尾放一个 Sleep(1.0)(Delay)让它歇一秒再跑下一轮。两个铁律:break 只归 loop 用、ForEach 里不能用;而且同步 loop 里要是既不 break 又不放个 Delay,就会触发死循环错误 ErrRuntime_InfiniteLoop,整台设备的 Verse 全罢工。详见 第 15 课

defer.verse
Countdown()<suspends>:void =
    Print("倒计时开始")
    defer:
        Print("离开作用域时才执行,提前退出也会跑")
    Sleep(3.0)
    Print("三、二、一")

▢ 读一下这段:defer 里的那行是「离开这个函数之前,最后一定补跑」的收尾线——所以哪怕它写在中间,真正执行也排在末尾。关键:必须先让执行流「经过」defer 把它登记上才算数;要是它藏在一条根本没走到的岔线里,就不会补跑。详见 第 15 课

case.verse
game_phase := enum{Lobby, Battle, Podium}

PhaseLabel(Phase:game_phase):string =
    case (Phase):
        game_phase.Lobby => "大厅"
        game_phase.Battle => "战斗"
        game_phase.Podium => "颁奖台"

▢ 读一下这段:case 就是蓝图里的 Switch on Enum——按枚举当前是哪一项,分头走对应的线,这里把三种游戏阶段各翻译成一句中文交出去。好处:只要把枚举的每一项都写全了,就不用再留一个「其它」兜底分支;漏写了哪一项,一按 Compile 编译器会点名。要按整数或字符串分支也用 case,但那种情况没法穷尽,必须留一条 _ => 兜底。详见 第 11 课

这一节的 Verse蓝图里是什么一句话差异
if (…): … else: …Branch收的不是 Boolean,是「这一步走不走得通」;Cast 失败、越界、查不到键全走同一个 else
if (…) then A else BSelect 节点if 本身是表达式,直接吐值
顺序缩进的几行Sequence 节点没有对应节点,缩进本身就是顺序
for (X : Arr) / for (I -> X : Arr)ForEach Loop(带不带 Index)整个 for 跑完会攒出一个新数组
for (I := 0..9)For Loop和 ForEach 是同一个关键字,范围含两端
for (X : Arr, X > 0)循环体里套一个 Branch这是 Verse 版的 continue;for 里没有 break
loop: + breakWhile Loop没有 while,条件搬进循环体;同步 loop 不歇会判死循环
defer:没有对应节点登记一次,从哪个出口离开都会补跑
case (X):Switch on Enum / Switch on Int枚举必须覆盖全,否则要一条 _ =>

四、容器:array / map / option / tuple

array.verse
var Numbers:[]int = array{1, 2, 3}
if (First := Numbers[0]):            # 下标可失败,必须住进 if
    Print("第一个是 {First}")
if (set Numbers[0] = 9) {}           # 改元素同样可失败
set Numbers += array{4}              # 追加:拼接出新数组
Print("共 {Numbers.Length} 个")      # .Length 不会失败

▢ 读一下这段:建一个数组;按序号取第 0 个,但这一步「可能走不通」,所以包进 if;连改某个元素、往后追加,也都是这个套路(追加其实是拼出一个新数组);.Length 取总数才是稳的、不会失败。要点:Verse 数组是「只读值」,没有 .Add / .Push 这种就地改;序号越界不会崩,只是这一步失败、走 else。详见 第 16 课

map.verse
var Scores:[string]int = map{"泡泡" => 10}
if (Score := Scores["泡泡"]):        # 查询可失败:查无此键走 else
    Print("查到 {Score} 分")
if (set Scores["阿奇"] = 0) {}       # 连写入都要包 if
for (Name -> Score : Scores):       # 按写入顺序遍历
    Print("{Name}: {Score}")

▢ 读一下这段:建一张「名字 → 分数」的登记表(Map);拿名字查分数「可能查不到」,所以包进 if;连写入一个新键都要包 if;最后用 ForEach 按写入顺序把每对键值逐个读出来。注意两个箭头别搞混:建表配对用 =>,遍历读出用 ->;没有 Remove,想删只能「过滤出想留的、重建一张」。详见 第 17 课

option.verse
var MaybeScore:?int = false          # false 就是「空盒子」
set MaybeScore = option{42}          # 装入值用 option{},不是 option()
if (Score := MaybeScore?):           # 后缀 ? 解包,空则失败
    Print("盒子里是 {Score}")
MaybeFirst := option{Numbers[0]}     # 把可失败表达式装罐成 option

▢ 读一下这段:?int 是个「可能装了整数、也可能是空」的盒子,false 就是空盒;装东西用 option{42};想取出来在后面加 ?,空盒就走不通、走 else。最后一行把一个「可能失败」的取值直接塞进 option{…}——因为 option{…} 本身就是个「允许失败的地方」:里面那步失败了不报错,只是给你一个空盒。它正是「走不通」和「没有值」之间的官方桥梁。详见 第 18 课

tuple.verse
Point:tuple(int, float) = (1, 2.0)   # 混合类型定长打包
X := Point(0)                        # 圆括号访问,编译期检查,不会失败

GetXY():tuple(float, float) = (3.0, 4.0)   # 多返回值 = 返回 tuple
Result := GetXY()
Print("x = {Result(0)}, y = {Result(1)}")

▢ 读一下这段:把「一个整数 + 一个小数」捆成一小包,用圆括号装、也用圆括号 Point(0) 取第几个——因为包里几样、每样啥类型都是编译时定死的,取值不会失败。下面的 GetXY() 演示了元组最大的用处:让一个函数一次交出好几个返回值。记住:元组取值用圆括号 T(0)、数组取值用方括号 A[0],而且元组的序号必须写死,不能拿变量当序号。详见 第 18 课

这一节的 Verse蓝图里是什么一句话差异
[]int / array{…}Array不可变值,没有 Add;取下标可失败,越界不崩
[string]int / map{…}Map(TMap)查和写都可失败;没有 Remove,删要重建
(没有)Set(TSet)Verse 不提供集合,惯用替代是 [t]logic
?t / option{…}可能为空的对象引用 + Is Valid「空」写进类型里,Accessed None 崩溃不存在
tuple(int, float)函数的多个输出引脚取第几个用圆括号,序号必须是写死的常数

五、函数定义

functions.verse
Add(A:int, B:int):int = A + B        # 隐式返回:最后一个表达式即结果

Award(?Points:int = 1):void =        # ? 前缀 = 命名可选参数
    Print("加 {Points} 分")
# 调用:Award() 用默认值;Award(?Points := 10) 按名传参

Twice(F(X:int):int, N:int):int = F(F(N))   # 函数也能当参数传

▢ 读一下这段:Add 是个最普通的蓝图函数,最后算出的值自动就是返回值(不用专门放 Return 节点)。Award 演示带默认值的可选参数(名字前加 ?),调用时可以不传、也可以按名字传。Twice 更进一步:函数还能当参数塞给另一个函数。两个坑:传进来的参数是「只读」的,想改先拷进一个局部 var;写一个函数名 Foo 不加括号,拿到的是「这个函数本身」而不是它的运行结果。详见 第 19 课

decides_fn.verse
# <decides> 必须与 <transacts> 成对出现:失败要能回滚
IsBig(X:int)<transacts><decides>:void =
    X > 100

if (IsBig[200]):                     # 可失败函数用方括号调用
    Print("圆括号调普通函数,方括号调可失败函数")

▢ 读一下这段:给函数挂上 <decides> 就把它变成「可能走不通」的蓝图函数,里面 X > 100 这一步没过、整个函数就算失败;这种函数必须同时挂 <transacts>(可回滚),而且调用时改用方括号 IsBig[200](圆括号调普通函数、方括号调可失败函数)。一个坑:这种函数体内不许写 return;想「找到就带着值退出」,惯用法是先备一个空盒子 var Ret:?t = false 收集,最后一行用 Ret? 把它拆出来。详见 第 19 课第 22 课

这一节的 Verse蓝图里是什么一句话差异
Foo(A:int):int = …Function(蓝图函数)最后一个表达式自动就是返回值,不用 Return 节点
?Points:int = 1输入引脚的默认值只有加了 ? 的命名参数才能有默认值
函数当参数传蓝图基本做不到函数在 Verse 里是一等值
<decides> + Foo[]没有对应物,最接近的是 Cast 的双出口「可能失败」是函数签名的一部分,编译器强制
<computes> / <converges>Pure 复选框Verse 会检查你申报得对不对
模块层的 <public> 函数Blueprint Function Library不用专门建「库」资产,文件夹自动是模块
(没有)Macro(蓝图宏)写普通函数就够了
(没有)参数的 By Ref 勾选参数一律只读,想改先拷进局部 var

六、类型定义:class / struct / interface / enum

class.verse
enemy := class:
    Name:string                      # 无默认值 → 实例化时必填
    var Health:int = 100

    TakeDamage(Amount:int):void =
        set Health -= Amount         # 方法里用 Self 指自己

Grunt := enemy{Name := "小兵"}       # 原型语法实例化,没有 new

▢ 读一下这段:这就是定义一个蓝图类——列出字段(没给默认值的,造实例时必须填)、写方法(方法里用 Self 指「我自己」),最后 enemy{Name := "小兵"} 造一个实例(没有 new,直接花括号填字段)。继承写成 child := class(base):(相当于 Child Blueprint Class),重写父类成员必须挂 <override>(对应那个 Override 下拉),调父类原版写 (super:)Method();类是「引用」语义(传的是同一个实例,不是拷贝)。详见 第 20 课

struct.verse
point := struct:
    X:float = 0.0
    Y:float = 0.0

P := point{X := 3.0, Y := 4.0}       # 值语义:赋值、传参都是拷贝
Q := point{X := P.X, Y := 9.9}       # 「修改」= 构造一个新实例

▢ 读一下这段:struct 就是右键新建的那个 Structure 结构体资产,一小包纯数据。它是「值」语义:Q := point{X := P.X, …} 这种「改一下」其实是照着旧的造一个全新实例,赋值、传参都是整包拷贝、互不影响。规矩:结构体里不许放会变的 var 字段、也不许写方法——想给它加行为,用「扩展方法」(S:point).Norm():float = … 从外面挂上去。详见 第 21 课

interface.verse
rideable := interface:
    Mount():void                     # 只有签名,没有实现

bicycle := class(rideable):
    Mount<override>():void =         # 实现每个接口方法都要 <override>
        Print("上车,出发")

▢ 读一下这段:interface 就是蓝图接口(BPI)——只列函数签名、不写实现;下面这个类答应了这个接口,就得把 Mount 补齐,而且实现接口方法也要挂 <override>。规矩:一个类只能认一个父类,但能同时答应好几个接口 class(base, iface1, iface2);想把父类引用「向下转」成具体子类型,用 if (C := child_type[Base]):——这一步「可能走不通」,就是蓝图里的 Cast。详见 第 21 课

enum.verse
card_suit := enum{Clubs, Diamonds, Hearts, Spades}

MySuit := card_suit.Hearts           # 成员通过类型名访问
if (MySuit = card_suit.Hearts):      # 只支持 = 与 <> 判等
    Print("是红桃")                  # 想转文字?自己写 case 映射

▢ 读一下这段:enum 就是右键新建的 Enumeration 枚举资产,列几个起好名的选项;用「类型名.成员名」取某一项,判断是不是某一项用 =(只支持 =<>)。要点:枚举成员背后没有藏整数,不能转成数字或文字、也不能比大小;想变成显示文字得自己写一段 case 映射。定义要放在类的外面(模块层)。详见 第 11 课

这一节的 Verse蓝图里是什么一句话差异
classBlueprint Class 资产不是资产,是文本文件里的一段声明;引用语义、单继承
class(base)Child Blueprint Class重写成员必须挂 <override>
my_class{Field := 3}Spawn + Expose on Spawn没有 new;没默认值的字段是必填项
structStructure 资产更「纯」:不能有 var 字段、不能写方法
interfaceBlueprint Interface(BPI)没有「Message 调用 vs 直接调用」的分裂
enumEnumeration 资产背后不藏整数,不能转数字或文字
child_type[Base]Cast To …方括号 = 可能失败,失败走 else

七、说明符与属性速查表

尖括号 <…> 里的叫说明符(specifier),一按 Compile 编译器就照着它检查你;@ 开头的叫属性(attribute),写在声明的上一行,主要影响 UEFN 编辑器里的表现(比如 @editable 让字段冒到 Details 面板)。下面这张表挑你用得上的记。

修饰符 一句话 蓝图里是什么 写在哪
<public> <internal> <protected> <private> 访问权限;默认是 internal(模块内可见),不是 public 变量/函数的 Private / Protected / Public 下拉 标识符后;加在 var 上可读写分权:var<protected> Ammo<public>:int
<override> 重写父类成员,必加 函数的 Override 下拉 成员名后
<abstract> 不能实例化,可含无实现方法 Abstract 类勾选 class<abstract>
<final> 类不许继承 / 成员不许重写 把函数标成不可 Override class 或成员
<concrete> 全字段有默认值,可 my_class{} 空原型实例化;@editable 自定义类必备 「这个类能直接摆进面板」 class<concrete>
<unique> 实例有身份,可用 = 比较、可作 map 键(player 就是这么来的) 对象引用的「是不是同一个」 class<unique>
<transacts> 动作可回滚;失败上下文里只欢迎它 蓝图没有:Set 泼出去就收不回 效果:参数表后、返回类型前
<decides> 可失败,方括号调用;必须搭 <transacts> 最接近的是 Cast 的双出口 效果
<suspends> 异步,可跨帧挂起;只能在异步上下文直接调用 Latent 节点(带小钟表那种) 效果
<computes> <varies> <converges> 三档「无副作用」承诺,只能挑一个;什么都不标 = no_rollback(不可回滚) Pure 复选框 效果
<persistable> 可进存档;类须 <final> 且只能有常量字段 SaveGame Object 的字段 class / struct 后
<localizes> 把插值字符串包装成 message(UI 文本要的类型) Text(可本地化文本) 函数 / 常量
<final_super> Scene Graph 要求:自定义组件必须直接派生自 componentBeta,以官方文档为准。 蓝图没有对应概念 class<final_super>(component)
@editable 把字段暴露到细节面板,改值不用重编译 Instance Editable 的小眼睛 属性:字段声明的上一行

▢ 这张表全是一按 Compile 可能亮红的关键词。按蓝图直觉挑几条:<override> = 重写父类成员;<concrete> = 所有字段都有默认值、能空手 {} 造实例,自定义 @editable 类必备;<unique> = 实例带身份证、能当 map 键;<transacts> = 动作可回滚,只有它受「允许失败的地方」欢迎;<suspends> = 会跨帧等待;@editable = 就是 Instance Editable 小眼睛。「效果」这一类其实就是把「这个函数会干什么」写进签名——报错提到 no_rollback / decides / suspends 时,先回来看这张表。详见 第 22 课

八、并发五表达式

表达式 语义 其余任务的命运 蓝图里是什么
sync: 全部完成才继续,结果打包成 tuple —— 没有:蓝图要自己数「都回来了没有」
race: 第一名完成即结束,值取冠军 在下个挂起点被取消 没有:「超时」在蓝图里要靠 Tick 或 Timeline 手拼
rush: 第一名完成即返回 继续跑,随外层作用域收尾 没有
branch: 先启动,主线继续 随所在作用域退出被撤销 最接近「另起一条执行线」,但有人管清场
spawn{ Fn() } 放出独立任务,返回 task 不被取消,自己跑完 「另起一条执行线」,且没人管它什么时候结束

这五个都是「同时跑好几条线」的玩法,按蓝图直觉认:sync = 等所有线到齐再往下;race = 谁先到听谁的,其余的线当场剪断(超时就用它);rush = 也取最先到的,但落后的线不剪断;branch = 岔出去跑、主线继续,等这段结束时自动清场;spawn = 硬岔出一条没人管生命周期的线,是唯一能在普通同步代码里用的。前四个只能在「会等待的上下文」里用。

race_timeout.verse
GateOnce()<suspends>:void =
    race:
        block:
            Button.InteractedWithEvent.Await()   # Button 是 @editable 引用
            Print("及时按下!")
        block:
            Sleep(10.0)
            Print("超时,自动继续")

▢ 读一下这段:race 让两条线赛跑——一条等按钮按下(Await() 挂起等事件,Button 是面板上连好的 @editable 引用),另一条 Sleep(10.0) 当闹钟计时;谁先到就走谁,输的那条当场被剪断。这就是「超时」的标准配方:事件和闹钟赛跑。每条臂里有好几行时用 block: 打包成一条线,各臂最后给的结果类型要能兼容。详见 第 24 课

spawn.verse
OnPressed(Agent:agent):void =        # Subscribe 回调不是异步函数
    spawn{ CloseAfterDelay() }       # spawn 体只能是单个异步调用

CloseAfterDelay()<suspends>:void =
    Sleep(5.0)
    Print("五秒到,收尾")

▢ 读一下这段:按钮的 Subscribe 回调 OnPressed 本身不是「会等待」的函数,想在里面做需要等待的事,就 spawn{ CloseAfterDelay() } 岔一条新线出去(花括号里只能放单个异步调用),自己立刻返回;那条新线先 Sleep(5.0)(Delay 五秒)再收尾。提醒:能用结构化的四个(sync/race/rush/branch)就别裸用 spawn;Sleep(0.0) 是「等一帧再继续」的标准写法。会等待与时间流详见 第 23 课,五表达式详见 第 24 课

九、设备样板:@editable + Subscribe(今天在 UEFN)

今天 Verse 唯一能真正跑起来的地方是 UEFN,那里「能放进关卡的东西」叫 creative_device。下面这台设备是所有 UEFN Verse 代码的最小骨架。

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

my_device := class(creative_device):
    @editable
    Button:button_device = button_device{}    # 面板里连上真设备

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

    OnPressed(Agent:agent):void =
        Print("有玩家按下了按钮")

▢ 读一下这段:这是一台最小设备的骨架——@editableButton(面板上那个小眼睛,进关卡后连上真按钮);OnBegin 就是 Event BeginPlay,一开场就用 Subscribe(Bind Event)把按钮的「被交互」事件绑到自己的 OnPressed 上;按钮一按,OnPressed 就跑、打印一行字。要生效:设备得从内容浏览器拖进关卡、并在 Details 面板把 @editable 连上实物;回调的参数要和事件递来的数据对上(按钮给的是 agent);想在回调里做要等待的事就 spawn。第一台设备详见 第 6 课,事件与 @editable 详见 第 25 课,完整毕业项目详见 第 29 课

这一节的 Verse蓝图里是什么一句话差异
class(creative_device)Blueprint Actor不是资产,是一段声明;拖进关卡才会跑
@editable 字段Details 面板上的一栏面板是代码的镜子,没标就没有那一栏
OnBegin<override>()<suspends>Event BeginPlay自带 <suspends>,能挂一整个游戏循环
.Subscribe(F)Bind Event回一个 cancelable;只绑一次,重复绑会叠加
Print("…")Print String要先 using Diagnostics 模块

十、Scene Graph:entity 与 component(UE6 的方向)

状态,先说清楚:Scene Graph 是 UE6 的新玩法框架,从零基于 Verse 构建,用 entity(实体)装 component(组件)。它目前在 UEFN 中以 Beta 提供,Epic 官方文档明确写着「谨慎用于正式发布」,部分工具还要在项目设置的 Beta Access 里开启。与此同时:Actor 与 Blueprint 在 UE6 Early Access(目标 2027 年底)及早期版本中完整支持,弃用要等 Scene Graph 足够成熟,时间未定;Epic 已承诺在弃用前提供转换工具,但尚未发布

下面这段骨架取自 Epic 官方文档的写法。Beta 阶段签名会变,写代码时以官方文档为准(本节末尾附来源链接)。

my_component.verse
using { /Verse.org }
using { /Verse.org/Native }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/SceneGraph }

# 自定义组件必须挂 <final_super>,并直接派生自 component
my_component := class<final_super>(component):

    @editable
    WaitSeconds:float = 3.0          # 和设备一样,冒到 Details 面板

    # 开始模拟时跑一次(同步),相当于组件版的 Event BeginPlay
    OnBeginSimulation<override>():void =
        (super:)OnBeginSimulation()

    # 长命的异步逻辑写这里:能等时间、等事件、挂循环
    OnSimulate<override>()<suspends>:void =
        Sleep(WaitSeconds)

    # 结束模拟时收尾
    OnEndSimulation<override>():void =
        (super:)OnEndSimulation()

▢ 读一下这段:这是一个自定义组件的最小骨架。<final_super> 是 Epic 的硬性要求——组件必须直接派生自 component,少了它这个类就加不到 entity 上(在它之上再派生子类则不需要重复写)。@editable 的用法和设备一模一样,这一点从设备迁到组件不用重新学。三个生命周期函数分别对应「开始模拟 / 长命异步逻辑 / 结束模拟」,官方示例里都是先调一次 (super:) 的同名实现再写自己的。

component_entity.verse
# 组件天生知道自己挂在哪个 entity 上:直接写 Entity
if (Mesh := Entity.GetComponent[mesh_component]):
    # 方括号 = 可能走不通:这个 entity 未必装了网格组件
    Print("拿到了同一个 entity 上的网格组件")

Parent := Entity.GetParent()         # 往上找
Kids   := Entity.GetEntities()       # 往下找子实体
Comps  := Entity.GetComponents()     # 这个 entity 上的组件

▢ 读一下这段:在 Scene Graph 里,「拿到别的东西」不再是蓝图里的 Get All Actors of Class,而是沿着 entity 的层级找。组件里的 Entity 是一个现成的数据成员,指向「我挂在的那个实体」;GetComponent[…] 用方括号,因为那个组件未必在。层级本身就是数据结构——这是 Scene Graph 和 World Outliner 最大的区别。

Scene Graph 里的写法 蓝图里是什么 说明 状态
entity Actor 几乎空的容器,靠装组件获得功能;继承 entity 的类官方称为 prefab Beta
class<final_super>(component) Actor Component 行为和数据唯一的落脚点 Beta
OnAddedToScene 组件被添加到世界 官方列出的生命周期之一;完整签名以官方文档为准 Beta
OnBeginSimulation<override>():void Event BeginPlay(组件版) 同步的一次性初始化 Beta
OnSimulate<override>()<suspends>:void 没有对应物 组件的长命异步逻辑,能挂循环、等事件 Beta
OnEndSimulation<override>():void Event EndPlay(组件版) 收尾 Beta
OnRemovingFromScene 组件即将被移除 官方列出的生命周期之一;完整签名以官方文档为准 Beta
Entity GetOwner() 组件的数据成员,指向它挂在的实体 Beta
TickEvents.PrePhysics / .PostPhysics Event Tick 把回调挂上去拿到每帧时机;签名以官方文档为准 Beta
Entity.GetComponent[t] Get Component by Class 方括号 = 可能走不通 Beta
Entity.AddComponents(…) / RemoveFromEntity() Add Component / Destroy Component 同一类组件在一个 entity 上只能有一个 Beta
GetParent() / AddEntities() / RemoveFromParent() Attach To / Detach 层级即数据结构,不只是摆放关系 Beta
AddTag() / ContainsTag() / RemoveTag() Actor Tag entity 自带一套标签接口 Beta
SendUp() / SendDown() / OnReceive() 沿层级广播一个通知 把场景事件顺着 entity 树送出去,沿途组件接 Beta
IsInScene() / IsSimulating() Is Valid 之类的状态查询 官方描述为「成功即代表是」,即可失败调用 Beta

本节所有名称与骨架取自 Epic 官方文档。entity 的 API 参考页把它标在 /Verse.org/SceneGraph 模块下,而官方教程页示例的 using 清单是上面那四行——两者以官方文档为准OnAddedToSceneOnRemovingFromScene 的完整签名官方页面未给出,这里只列概念占位,不要照抄猜测的签名。Scene Graph 的完整世界观见 第 26 课,写一个组件见 第 27 课

来源

十一、持久化模式

persist_coins.verse
# 模块作用域(类的外面)——位置本身就是持久化开关
var PlayerCoins:weak_map(player, int) = map{}

AddCoins(Player:player, Amount:int):void =
    if (Old := PlayerCoins[Player], set PlayerCoins[Player] = Old + Amount):
        Print("金币到账,跨局也不丢")

▢ 读一下这段:把存金币的 weak_map 放在「类的外面」(模块层)——这个位置本身就是存档开关,数据会每个玩家一份、跨局自动保留。AddCoins 里,读旧值和写新值都是「可能走不通」的,所以整句包进一个 if(读到旧值、当场把新值 set 回去,一步到位)。切记:玩家第一次进图,得先 if (set PlayerCoins[Player] = 0) {} 把初值铺好,才谈得上累加。详见 第 28 课

persist_class.verse
player_profile := class<final><persistable>:
    Level:int = 1
    Coins:int = 0

var Profiles:weak_map(player, player_profile) = map{}
# 更新 = 读旧实例 → 用旧值构造新实例 → 整体 set 回去

▢ 读一下这段:把要存的一堆数据(等级、金币)打包成一个 <persistable> 的类,再用一张以玩家为键的 weak_map 存这些实例;更新的套路是「读出旧实例 → 拿旧值造个新实例 → 整体 set 回去」。记住 weak_map 三不能:问不出总数、不能遍历、键是弱引用;键必须是 player,拿到的是 agent 就先 player[Agent] 转一下(Cast to Player)。以后还会加字段的数据用 class 装最稳(发布后只有 class 能加带默认值的新字段)。详见 第 28 课

这一节的 Verse蓝图里是什么一句话差异
模块层的 weak_map(player, t)SaveGame / Save Game Object「放在类的外面」这个位置本身就是存档开关
class<final><persistable>SaveGame Object 的结构只能放不可变字段,且类必须 <final>
player[Agent]Cast to Player方括号 = 可能走不通,要包进 if
(没有)Save Game to Slot / Load Game from Slot没有显式存读盘调用,读写 weak_map 就是存读