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

结构对照:从节点图到缩进块

上一课解决「东西叫什么」,这一课解决「东西怎么摆」。蓝图图表是一张摊在二维平面上的图:你靠眼睛顺着线走。Verse 是一段从上往下读的文本:顺序和缩进替你把线画好了。这一课把图表里的每一种线、每一种框,逐个翻译成文本结构。

一、图表里其实只有四样东西

打开任何一张蓝图图表,不管它有多乱,视野里能看见的元素就四类:白色执行线彩色数据线节点本身注释框。把这四样各自翻译一遍,你就掌握了整套结构转换。

白色执行线 → 顺序 + 缩进。这是最反直觉的一条:在 Verse 里,执行线不存在对应物。没有关键字、没有符号、没有箭头。原因很简单——顺序本身就是执行线。上一行跑完跑下一行,这就是那根白线。蓝图之所以需要画线,是因为节点在平面上可以摆在任何位置,不画线你就不知道谁先谁后;文本天然只有一个方向,线就没必要了。你在图上花在「理线」上的那份力气,在 Verse 里直接省掉。

彩色数据线 → 表达式嵌套,或者一个变量名。把 A 节点的输出引脚拉一根线接到 B 节点的输入引脚,写成文本就是两种形态:要么把 A 直接塞进 B 的括号里——B(A()),一层括号就是一根线;要么先给这个中间结果起个名字——X := A(),再写 B(X)。前者短,后者好读,两种都对,选哪种全看这根线有多长、要不要复用。

节点 → 函数调用。图上那个带引脚的方块,在文本里就是 名字(参数)。Print String 变成 Print("Hi"),Get Player Count 变成 GetPlayerCount()。引脚的名字变成参数的位置或名字,仅此而已。

注释框 → # 注释。蓝图的注释框(选中一堆节点按 C)能把一片区域框起来、涂个颜色、起个标题。Verse 的 # 只能注释掉一行,做不到「框住一片」——但它也不太需要:Verse 里「这几行属于同一块」这件事,已经由缩进本身表达清楚了,注释框的框选功能被缩进接管了,只剩下「起个标题」的功能留给 #

四条合起来,一张三节点的小图就长这样:

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

score_board_device := class(creative_device):

    # 注释框 → # 注释:给下面这一小段起个标题
    Score:int = 40

    OnBegin<override>()<suspends>:void =
        # 数据线 → 表达式嵌套:一层括号就是一根线
        Print("总分:{Score + 10}")

        # 也可以先给中间结果起个名字,再用它
        Total := Score + 10
        Print("总分:{Total}")

读图翻译:Score 是变量面板里的一个整数;OnBegin 是 Event BeginPlay 那个红色事件节点;它下面缩进的四行,是顺着白线依次排开的四个节点。Score + 10 是一个 Add 节点,它的输出用一根数据线接到 Print String 的输入——写成文本,那根线就是把 Score + 10 写进大括号里。下面两行是同一根线的另一种画法:先拉出来落成 Total,再接进去。

二、一个节点,多个出口

上面说「执行线就是顺序」,马上会遇到一个问题:有些节点不止一个输出执行引脚。Branch 有 True 和 False,Sequence 有 Then 0 / Then 1 / Then 2,ForEach 有 Loop Body 和 Completed。一条直上直下的文本,怎么表达「兵分两路」?

答案就是缩进块:一个出口 = 一个缩进块。节点在图上朝右分出几条线,在文本里就朝里缩进几块。

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

turret_device := class(creative_device):

    var Ammo:int = 1

    OnBegin<override>()<suspends>:void =
        # Branch 的 True 引脚 → 冒号后缩进进去的这一块
        if (Ammo > 0):
            set Ammo -= 1
            Print("开火")
        # Branch 的 False 引脚 → else 后缩进进去的这一块
        else:
            Print("空仓")

        # Sequence 的 Then 0 / Then 1 / Then 2 → 同一层缩进的三行
        Print("一")
        Print("二")
        Print("三")
蓝图节点 输出执行引脚 Verse 里的写法
Branch True / False if (…): 下面的块 / else: 下面的块
Sequence Then 0 / Then 1 / Then 2 同一层缩进的第 1 / 2 / 3 行(Sequence 没有对应物)
ForEach / For Loop Loop Body / Completed for (…): 下面的块 / 退回缩进后的下一行
Switch on Int / String 每个 case 一个引脚 if 一层层接下去,或后面章节的 case

表里最值得盯着看的是 Sequence 那一行:它在 Verse 里压根不存在。你之所以在蓝图里需要 Sequence 节点,是因为一个执行引脚只能接一根线,想连着做三件事就得请一个专门的节点来分线。文本里「连着做三件事」就是写三行,不需要任何节点。同理消失的还有 Reroute 节点(拖线整理用的中转点)——它在 Verse 里的等价物是给中间结果起个名字,而那件事你是为了好读才做的,不是为了让线不打架。

另一件事:蓝图里 True 和 False 那两条线画在图上一上一下,Verse 里 if 块和 else 块也是一上一下地写。方向感完全一样,只是从二维压成了一维。你原本用眼睛顺线走的那套本能,在这里依然管用。

三、代码块的规矩:冒号开块,缩进定归属

把规则完整说一遍,只有三条:

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

vault_device := class(creative_device):

    OnBegin<override>()<suspends>:void =
        Print("开始")
        for (Round := 1..2):
            Print("第 {Round} 轮开始")
            if (Round > 1):
                Print("这是最后一轮")
            Print("第 {Round} 轮结束")
        Print("全部结束")

数一数缩进,这段有四层:缩进 0 是模块最外层(using 和类名那一行);缩进 4 是类的身体;缩进 8 是函数的身体;缩进 12 是 for 的身体;缩进 16 是 if 的身体。这在蓝图里就是三层嵌套的连线:类图 → Event BeginPlay 的执行线 → ForLoop 的 Loop Body 引脚 → Branch 的 True 引脚。区别只在于蓝图把这三层摊在同一张平面上、靠线连着,你得顺着线走才知道自己在第几层;Verse 把它们摞成阶梯,缩进几格就是第几层,一眼可数。

再看两处「退回缩进」的地方。Print("第 {Round} 轮结束") 退回到缩进 12,意思是它跳出了 if——不管刚才那个 Branch 走的是 True 还是 False,这一行都会跑,它接的是 Branch 两条线汇合之后的那根线。最后一行 Print("全部结束") 退回到缩进 8,意思是它跳出了 for——它接的正是 ForLoop 的 Completed 引脚。在蓝图里,「两条分支汇合」和「循环跑完」需要你把线拉回来重新接上;在 Verse 里,你只是少按了四下空格。

四、逐行运行:在图上跳到哪了

光看缩进还不够,要看它跑起来怎么跳。下面这台门禁设备有 2 把钥匙、要开 3 轮门,循环里套着分支——正好把上面两节的东西全用上。点「运行下一步」,每一步的说明都会告诉你:如果这是一张蓝图图表,此刻高亮的是哪个节点、走的是哪根线。

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

gate_device := class(creative_device):

    var Keys:int = 2

    OnBegin<override>()<suspends>:void =
        for (Round := 1..3):
            if (Keys > 0):
                set Keys -= 1
                Print("第 {Round} 轮:开门,剩余 {Keys}")
            else:
                Print("第 {Round} 轮:没钥匙了")
输出日志

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

回头看整段:代码只有 14 行,可执行的部分只占 6 行,却完整表达了「一个循环套一个双出口分支」。同一件事画成蓝图,你需要 Event BeginPlay、ForLoop、Branch、Set、两个 Print String 六个节点,外加至少七根线,而且为了不让线交叉,你多半还得挪半天位置。这不是说蓝图差——图有图的好处(下一课会讲哪些好处是 Verse 给不了的)——但「结构」这一项,文本确实赢得干净。

五、求值顺序:蓝图按需拉取,Verse 从里到外

结构翻译完了,还剩一个隐形的差异:值是什么时候算出来的。这一条不写在图上,也不写在代码里,但它能让蓝图作者结结实实踩一脚。

蓝图这边是「按需拉取」(pull)。数据线上的节点不挂在白线上,它们不会主动跑;只有当下游某个节点需要那个值的时候,才顺着数据线往回拉一次、算一次。所以同一个 Pure 节点,输出引脚拉出去三根线,就会被算三次——图上你只看见一个方块,心里以为只算一次。

Verse 这边是「从左到右、从里到外」(push)。执行到某一行时,这一行里的表达式就地求值:最里层的括号先算,算完把值交给外面一层;同一层的多个参数从左往右依次算。没有延迟,没有缓存,写几遍就算几遍。

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

shop_device := class(creative_device):

    var Coins:int = 100

    # 一个「算价格」的小函数,顺手打一行日志暴露自己被调用了
    # (函数怎么写,第 19 课细讲,这里只数它跑了几次)
    Price():int =
        Print("—— 算了一次价格")
        30

    OnBegin<override>()<suspends>:void =
        # 写了两遍 Price(),就调用两次,日志会打两行
        if (Coins > Price()):
            Print("买得起,剩 {Coins - Price()}")

        # 只想算一次?先给它起个名字
        P := Price()
        if (Coins > P):
            Print("买得起,剩 {Coins - P}")

上半段的日志会出现两次「算了一次价格」,下半段只出现一次。Coins - Price() 这一句的求值顺序也值得念一遍:先算最里面的 Price() 得到 30,再算减法得到 70,最后才把 70 交给字符串插值——从里到外,和你在蓝图里从最左边的节点顺着数据线往右传是同一个方向。

坑在哪。蓝图养出来的直觉是「Pure 节点是免费的,画一个随便拉」——因为它长得像一个值,不像一次调用。搬到 Verse 之后,这个直觉会让你在一个函数里反复写 GetSomething(),而那个函数背后可能每次都在遍历一整个数组。反过来说,这个坑在蓝图里其实一直存在(三根线就是三次求值),只是图把它藏起来了;Verse 把它摊在字面上——你写了两遍,它就在那儿明摆着。看得见的坑不算坑。规矩很简单:同一个值要用两次以上,就先给它起个名字。

六、动手:把结构填回去

下面这段挖掉了三个开块的关键字。注释里写了它在图表上对应哪个引脚,照着填:

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

arena_gate_device := class(creative_device):

    var Lives:int = 2

    OnBegin<override>()<suspends>:void =
        # 跑三轮:下面缩进的这一块是 ForLoop 的 Loop Body 引脚
        ____ (Round := 1..3):
            # 一个 Branch:冒号后缩进进去的是 True 那条线
            ____ (Lives > 0):
                set Lives -= 1
                Print("第 {Round} 轮:进场")
            # False 那条线:和上面那个块并排,缩进相同
            ____:
                Print("第 {Round} 轮:没命了")

蓝图对照

这一课没有新语法要背,只有一张对照表要记熟。左边是你在图上看得见的东西,右边是它在文本里的形状,差异那一列才是重点。

蓝图图表里的元素 Verse 里的文本结构 差异
白色执行线(Exec) 书写顺序 + 缩进(没有任何符号) 接不错、断不了、也不会出现「忘了连线的孤岛节点」;代价是不能画成任意形状
彩色数据线(数据引脚之间的连线) 表达式嵌套 B(A()),或先落成名字 X := A() 蓝图的数据线能跨半张图拉过去;Verse 的名字只在自己那个缩进块里有效
节点(函数调用节点) 函数调用 Print("Hi") 蓝图节点的「纯不纯」写在节点长相上;Verse 写在函数签名的说明符里,编译器会查(见拓展 x2)
注释框(选中节点按 C) # 行注释 # 框不住一片区域——但「这几行是一块」已经由缩进说清楚了
Branch 的 True / False 引脚 if (…): 块与 else: Verse 的 if 条件不止能填布尔,还能填「可能走不通的表达式」(第 12 课)
Sequence 节点 同一层缩进的连续几行 没有对应物:文本天然能连着写几件事,不需要专门的分线节点
Reroute 节点(理线用的中转点) 中间变量 X := … 蓝图起中转是为了线不打架,Verse 起名字是为了人好读、值只算一次
ForEach 的 Loop Body / Completed for (…): 块 / 退回缩进后的下一行 「循环跑完接着干什么」在蓝图里要把线拉回来,在 Verse 里只是少缩进一层
数据线的「按需拉取」求值 执行到那一行时,从左到右、从里到外求值 两边都是「用几次算几次」,但蓝图把重复求值藏在图里,Verse 摊在字面上

这些差异有一个共同的来源:蓝图是二维的,Verse 是一维的。二维给了你自由摆放的空间,代价是「谁先谁后」「谁属于谁」这两件事必须靠画线额外说明;一维本来就只有一个方向和一个深度,顺序和归属自动就有了答案,代价是你没法把两块逻辑并排放在一起对着看。

所以迁移时真正要改的不是手,是眼睛:以前你读图是「找到红色事件节点,顺着白线走」,以后你读代码是「找到函数名,从上往下读,遇到缩进就知道进了一层」。这个切换比记语法快得多——大多数人半天就能过去。

关卡挑战

三道小关卡,验收一下这张对照表。答错可以一直重试,零惩罚。

蓝图里那根白色执行线,在 Verse 里对应什么?

Sequence 节点的 Then 0 / Then 1 / Then 2,在 Verse 里怎么写?

一段代码里,条件写了 Price(),下面那行的插值里又写了一次 Price()。Price 被调用了几次?

拓展阅读

拔高 · EXTRA

执行线 vs 表达式:为什么 Verse 里没有「白线」

蓝图用白线和彩线把「做一件事」和「算一个值」分成两类。Verse 把这条界线拆了:一切都是表达式,连 if 和代码块都有值。这对你意味着什么?

进入拓展 →

拓展 · EXTRA

纯节点(Pure)与 Verse 的效果说明符

Pure 节点没有执行引脚、每用一次重算一次。Verse 把「纯不纯」写进了函数签名:computes、transacts、decides、suspends 各管什么。

进入拓展 →

技巧 · EXTRA

缩进式与大括号式:同一段 Verse 的两种排版

同一段代码,冒号加缩进能写,大括号加分号也能写。什么时候用哪种,单行 if (X) {} 的惯用法,以及团队里怎么定规矩。

进入拓展 →