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

array 数组:把战利品排成一队

欢迎进入「容器」章。一个变量只装得下一件东西,array 能装一整排:这一课学 array{} 字面量与 []t 类型、会「失败」的下标访问、.Length 报数、+ 拼接和 Slice 切片——把一排数据管得明明白白。

欢迎进入「容器」章。一个变量只装得下一件东西,array(数组)能装一整排——就是蓝图变量面板里、把变量类型旁那个九宫格点开变成「数组」的同一件事。这一课学:怎么预填一排数据、怎么按编号取里面的东西(取不到也不会崩)、怎么数它有几件、怎么把两排接起来、再怎么切出中间一段——把一排数据管得明明白白。

一、array{} 字面量与 []t 类型:开一排格子

前面几课的变量都是「单格背包」:一个 var Health:int 只能装一个数。可游戏里的数据经常成群结队地来——一套武器配置、一串击杀记录、一队排位分。给每个都单开变量?Weapon1Weapon2Weapon3……写到第十个你就会想掀桌。Verse 的答案是 array(数组):一次开一整排格子,格子按 0、1、2…… 编号,用一个名字统一管理。

前面几课的变量都是「单格背包」:变量面板里新建一个 Health,它只装得下一个数。可游戏里的数据经常成群结队地来——一套武器配置、一串击杀记录、一队排位分。给每个都单开一个变量?Weapon1、Weapon2、Weapon3……建到第十个你就会想掀桌。Verse 的答案是 array(数组):就是你在蓝图变量面板里点那个九宫格、把变量变成「数组」的同一件事——一次开一整排格子,格子按 0、1、2…… 编号,用一个名字统一管理。

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

loadout_device := class(creative_device):

    # []string 读作「一排 string」;array{...} 是数组字面量
    Weapons:[]string = array{"镐子", "喷子", "狙击枪"}

    # 空数组也用 array{},以后再往里装
    var Kills:[]int = array{}

两处新语法,拆开看:类型写作 []t——方括号在前、元素类型在后,[]string 就是「一排字符串」;字面量写作 array{元素, 元素, …}——关键字 array花括号。注意,是花括号!写成 [1, 2, 3]array(1, 2, 3) 都过不了编译,这是从别的语言转来的同学第一天必踩的坑。

读一下上面这段:它在蓝图变量面板里建了两个数组变量——一个叫 Weapons、装 string,开局预填了三件武器名;一个叫 Kills、装 int,先留空,以后再往里加。写法上记两点:类型写成 []string,方括号在前、装什么在后,读作「一排 string」;想预填内容就用 array{元素, 元素, …}——关键字 array 加一对花括号。花括号别写岔成别的括号,写错了点 Compile(Verse 里是 Ctrl+Shift+B)会亮红报错。

还有一条铁律:一排格子里的东西必须同款[]int 里全是整数,[]string 里全是字符串;想写 array{1, "a"} 混装?编译器直接拒收。真需要把不同类型的值捆成一组,那是 tuple 的活,第 14 课见。

还有一条铁律:一排格子里的东西必须同款[]int 里全是整数,[]string 里全是文字;想写 array{1, "a"} 把数字和文字混装?点 Compile 直接给你亮红、拒绝通过。真需要把不同类型的值捆成一组,那是 tuple(元组)的活,第 14 课见。

二、下标访问:一次可能落空的抽取

格子开好了,怎么取东西?写 Items[0]——下标从 0 数起,0 号是第一格。但 Verse 在这里做了一个非常有个性的设计:下标访问是一个可能失败的表达式。想想就合理:万一数组只有 3 件东西,你却伸手去掏 99 号格呢?别的语言要么当场崩溃(C++ 未定义行为)、要么抛异常(Python 的 IndexError);Verse 说——这就是一次「失败」,和 if (Health > 0) 判断不成立是同一种东西,交给失败上下文接住就好。

格子开好了,怎么取东西?写 Items[0] 就是「取 0 号格」——编号从 0 数起,0 号是第一格(蓝图数组也是这个规矩)。但 Verse 在这里有个很有个性的设计:取一格这个动作本身可能「取不到」。想想就合理:万一数组只有 3 件东西,你却伸手去掏 99 号格呢?Verse 既不崩、也不硬塞个默认值给你,而是把「取不到」当成一条走不通的线——跟 Branch 节点走不通就走 else 引脚是同一回事。所以在 Verse 里取一格,得像连了个 Branch:取到了走一条线,取不到走另一条线。

所以下标访问必须写在失败上下文里,最常见的就是 if:

所以在 Verse 里,取一格永远要接上「取不到怎么办」的那条线,最常见的接法就是 Branch(也就是代码里的 if):

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

chest_device := class(creative_device):

    var Items:[]string = array{"药水", "盾牌", "钥匙"}

    OnBegin<override>()<suspends>:void =
        # 下标访问放进 if:拿到了走 then,越界走 else
        if (First := Items[0]):
            Print("第一格:{First}")
        if (Nothing := Items[99]):
            Print("永远到不了这里")
        else:
            Print("99 号格不存在,拿取失败——但游戏没崩")
        # .Length 报数永远安全,不需要失败上下文
        Print("宝箱共 {Items.Length} 件")
        # 最后一格的惯用写法:下标是 Length - 1
        if (Last := Items[Items.Length - 1]):
            Print("最后一格:{Last}")
        # 改格子里的东西,同样要套失败上下文
        if (set Items[0] = "大药水"):
            Print("0 号格换成了大药水")

逐条敲黑板:

▢ 越界 = 失败,不是崩溃。Items[99] 那行不会炸服务器,它只是让整个 if 条件失败,程序从 else 继续走。没有 null,没有异常,没有未定义行为。

▢ 掏空格 = 走不通,不是崩溃。Items[99] 那行不会炸服务器,它只是让这个 Branch 走不通,于是顺着「取不到」那条线(else 引脚)继续跑。不会蹦出蓝图里那种 Accessed None 报错,也不会半路崩溃。

▢ 裸写下标是编译错误。在普通语句里直接写 Elem := Items[0],编译器会甩给你这一课最经典的报错:This invocation calls a function that has the 'decides' effect, which is not allowed by its context. 翻译成人话:「这个操作可能失败(带 decides 效果),但你没给它准备失败时的退路。」把它套进 if 就好。

▢ 不接「取不到」那条线,一点 Compile 就亮红。如果你没接 Branch、直接想把 Elem := Items[0] 的结果拿去用,Ctrl+Shift+B 一编译,Verse 会甩给你这一课最经典的一条红字,大意是:「这个动作可能取不到,可你没给它准备取不到时该走哪条线。」把它接进一个 Branch(if)就好。

▢ .Length 永不失败。问「有几件」总是安全的,所以 Items.Length 可以随处写。它也是拿「最后一件」的钥匙:最后一格的下标永远是 Length - 1,而 Items[Items.Length] 永远失败——差一错误的重灾区。

▢ 数数(.Length)永远不会取不到。问「这排有几件」总是安全的,所以 Items.Length 随处能写(蓝图里数组那个 Length 节点也从不报错)。它还是拿「最后一件」的钥匙:因为编号从 0 起,最后一格的编号永远是 Length - 1;而去掏 Items[Items.Length] 必然取不到——正好多数了一格,是新手最常翻车的地方。

▢ 写入也可能失败。set Items[0] = "大药水" 同样带 decides 效果:0 号格存在就改,不存在就失败。特别注意,往 Items[Items.Length] 写并不会把数组撑大一格——它只会失败。想追加?往下看。

▢ 往格子里换东西,也可能「换不进去」。set Items[0] = "大药水" 就是给 0 号格接一个 Set 节点、写进新值;但这一步同样得走 Branch——0 号格存在才换得成,不存在就走不通。特别注意:往 Items[Items.Length]不会把数组撑大一格,它只会走不通。想往队尾加东西?往下看。

三、拼接 + 与遍历回顾:队伍会换,不会改

先立一个世界观:Verse 的数组是不可变的值。它没有 .Add().Push().Sort() 这类「原地修改」的方法——所谓「改数组」,语义上都是造一个新数组,再整个替换 var 里的旧值。连 set Items[0] = X 也不例外:它编译后相当于「按旧数组抄一份、换掉 0 号格、整体赋回去」。

先立一个世界观,而且这条和蓝图不太一样,要留神:Verse 的数组是「值」,不能原地改。蓝图里你熟的 Add / Remove / Insert 这种「就地往数组里加一件、删一件」的节点,Verse 全都没有。这里所谓「改数组」,其实都是造一排新的、再整排替换掉变量里那排旧的(相当于对整个数组变量接一个 Set 节点)。连 set Items[0] = "大药水" 也一样:底层是「照旧的抄一份、换掉 0 号格、整排赋回去」。

所以「往队伍里加人」用的是拼接:运算符 + 把两个数组首尾相接,产出一个新数组;追加一件东西的惯用写法是 set Items += array{新元素}。遍历则是老朋友 for:for (Item : Items) 逐个拿元素,for (Index -> Item : Items) 连下标一起拿。点「运行下一步」,把这几件武器一次看全:

所以「往队伍里加人」靠的是拼接:+ 把两排数组首尾接成新的一排;往队尾加一件东西的惯用写法是 set Items += array{新元素}(读作「把它接到尾巴、再整排赋回去」)。逐个处理每件东西,就是蓝图里的 ForEach Loop:for (Item : Items) 相当于把数组接进 ForEach,Loop Body 里每圈拿到一件;for (Index -> Item : Items) 则连 Array Index 一起拿到手。点「运行下一步」,把这几件武器一次看全:

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

loot_bag_device := class(creative_device):

    var Loot:[]string = array{"药水", "盾牌"}

    OnBegin<override>()<suspends>:void =
        set Loot += array{"金币"}
        Print("背包共 {Loot.Length} 件")
        for (Index -> Item : Loot):
            Print("{Index} 号格:{Item}")
        if (Ghost := Loot[3]):
            Print("到不了这里")
        else:
            Print("3 号格是空气,拿取失败")
输出日志

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

再送一个进阶彩蛋:for 在 Verse 里是表达式,它自己就能产出一个新数组。Doubled := for (N : Numbers) { N * 2 } 把每个元素翻倍装进新数组;还能带过滤条件——Big := for (N : Numbers, N > 2) { N } 只保留大于 2 的元素。一行完成「加工 + 筛选」,以后写工具函数会经常用到。

再送一个进阶彩蛋:Verse 的 ForEach(for)不只是「跑一圈」,它每圈的结果还会自动收集成一排新数组。Doubled := for (N : Numbers) { N * 2 } 就是「把每个数翻倍、收成新的一排」;还能顺手筛选——Big := for (N : Numbers, N > 2) { N } 只留下大于 2 的。一句话同时干完「逐个加工」和「按条件筛」,以后自己搭工具节点会经常用到。

四、切片 Slice:从队伍里切一段下来

有时你要的不是一件,而是一段:排行榜前三名、去掉排头后的剩余队伍。Verse 标准库给数组配了 Slice:Items.Slice[StartIndex, StopIndex] 返回从 StartIndexStopIndex - 1 的元素——含头不含尾。官方文档写得很清楚:仅当 0 <= StartIndex <= StopIndex <= 数组长度 时成功,否则失败。

有时你要的不是一件,而是一段:排行榜前三名、去掉排头后的剩余队伍。Verse 给数组内置了一个 Slice(切片)工具:Items.Slice[StartIndex, StopIndex] 会切出从 StartIndexStopIndex 前一格的元素——含头不含尾(点名的那一格不算在内)。规矩很简单:只有当 0 <= StartIndex <= StopIndex <= 数组件数时切得成,否则走不通。

注意调用写法:方括号Slice 和下标访问一样带 decides 效果(可能失败),而 Verse 的规矩是——可失败的函数用方括号调用,写 Items.Slice(0, 2) 圆括号直接编译不过。方括号本身就是一句提醒:「这次调用可能落空,请放进失败上下文。」

注意调用写法用的是方括号Slice 和取一格一样可能「切不到」,所以在 Verse 里,凡是「可能做不成」的工具都用方括号来调;写成 Items.Slice(0, 2) 那样的圆括号,一点 Compile 就亮红过不去。方括号本身就是一句提醒:「这次可能落空,记得接上 Branch。」

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

slice_device := class(creative_device):

    Queue:[]string = array{"一号", "二号", "三号", "四号"}

    OnBegin<override>()<suspends>:void =
        # 切出下标 0、1 两件(不含 2):含头不含尾
        if (FrontTwo := Queue.Slice[0, 2]):
            Print("前排 {FrontTwo.Length} 人")
        # 去掉排头,保留剩下的所有人
        if (Rest := Queue.Slice[1, Queue.Length]):
            Print("排头走后还剩 {Rest.Length} 人")
        # 越界的切法直接失败,而不是崩溃
        if (Bad := Queue.Slice[2, 99]):
            Print("到不了这里")
        else:
            Print("切不动:99 超出了数组长度")

两个高频套路记下来:Slice[1, Items.Length] 是「去掉第一件、保留其余」,Slice[0, Items.Length - 1] 是「去掉最后一件、保留其余」。配合上一节的 += 追加,你已经凑齐了用不可变数组实现栈和队列的全部零件——文末的拓展阅读里有完整实战。

两个高频套路记下来:Slice[1, Items.Length] 是「去掉第一件、保留其余」,Slice[0, Items.Length - 1] 是「去掉最后一件、保留其余」。配合上一节的 += 拼接,你已经凑齐了「不能原地改的数组」也能搭出栈和队列的全部零件——文末的拓展阅读里有完整实战。

五、常见坑合集:排雷手册

array 这一课的坑,九成来自「把别的语言的习惯带进来」。对照下表自查一遍:

array 这一课的坑,九成来自「把蓝图或别处的老习惯直接搬过来」——尤其是以为数组能像蓝图那样原地 Add / Remove。对照下表自查一遍:

踩坑写法 后果 正确姿势
Elem := Items[0] 裸写在普通语句里 编译错误:'decides' effect... not allowed by its context 套进失败上下文:if (Elem := Items[0]):
Elem := Items[0] 没接 Branch 就想拿去用 Compile 亮红:大意「这动作可能取不到,你没准备退路」 接进 Branch:if (Elem := Items[0]):
Items[Items.Length] 永远失败——差一错误 最后一格是 Items[Items.Length - 1]
Items[Items.Length] 永远取不到——正好多数了一格 最后一格是 Items[Items.Length - 1]
set Items[5] = X 当普通语句写 同样的 decides 编译错误 if (set Items[5] = X):
set Items[5] = X 没接 Branch 同样 Compile 亮红(这一步可能做不成) if (set Items[5] = X):
Items[Items.Length] 赋值想扩容(JS 习惯) 不会追加,只会失败 追加用拼接:set Items += array{X}
Items[Items.Length] 写、想像蓝图 Add 那样撑大一格 不会加进去,只会走不通 加东西用拼接:set Items += array{X}
Items.Slice(0, 2) 圆括号调用 编译不过 可失败函数用方括号:Items.Slice[0, 2]
Items.Slice(0, 2) 用圆括号调 Compile 亮红 可能落空的工具用方括号:Items.Slice[0, 2]
字面量写 [1, 2, 3]array[1, 2, 3] 编译不过 关键字 + 花括号:array{1, 2, 3}
预填内容写成 [1, 2, 3]array[1, 2, 3] Compile 亮红 关键字 + 花括号:array{1, 2, 3}
到处找 .Add / .Push / 原地 .Sort 不存在这些方法 数组是不可变值,一切「修改」都产出新数组
到处找蓝图那种 Add / Remove / 原地 Sort 节点 Verse 数组没有这些 数组是「值」、不能原地改,一切「修改」都产出新的一排
for 遍历边 set,期待影响本轮循环 循环拿到的是开跑那一刻的数组值,毫无反应 循环里的追加/修改,要等下一次遍历才可见
一边 ForEach 遍历一边 set 改它,指望这一圈就变 ForEach 拿到的是开跑那一刻那排,改了毫无反应 循环里的拼接/修改,要等下一次 ForEach 才看得到

光看不练假把式。下面这台「训练靶机」挖掉了三个本课关键词:字面量关键字、报数属性、追加运算符。填回去,点「检查答案」:

光看不练假把式。下面这台「训练靶机」挖掉了三个本课关键词:预填数组的关键字、数数用的属性、往队尾拼东西的运算符。填回去,点「检查答案」:

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

drill_device := class(creative_device):

    # 数组字面量的关键字是什么?
    var Scores:[]int = ____{90, 80, 70}

    OnBegin<override>()<suspends>:void =
        # 最后一格的下标 = 总数 - 1
        if (Last := Scores[Scores.____ - 1]):
            Print("垫底分数:{Last}")
        # 追加一个 60:拼接运算符
        set Scores ____ array{60}
        Print("现在共 {Scores.Length} 个分数")

六、关卡挑战

三道小关验收本课战利品。答错零惩罚,可以一直重试。

在 OnBegin 里直接写一行 Item := Items[0](不套 if),会发生什么?在 Event BeginPlay 里直接写 Item := Items[0]、不接 Branch 就想拿去用,会发生什么?

Items 里有 3 件东西,执行 if (X := Items[3]): A else: B——接下来会怎样?Items 里有 3 件东西,你连了个 Branch 去取 3 号格(if (X := Items[3])),取到走 A、取不到走 B——接下来会怎样?

var Numbers:[]int = array{1, 2},想在尾部追加一个 3,哪种写法对?一个装 int 的数组 var Numbers:[]int = array{1, 2},想在队尾加一个 3,哪种写法对?

拓展阅读

技巧 · EXTRA

给数组补上 Map / Filter / Reduce

Verse 原生数组没有高阶函数,社区开源模块教你扩展方法与「函数当参数」的写法。

Verse 数组没有 Map / Filter / Reduce 这类现成工具,社区开源模块教你自己补上,还能把「一个函数」当积木传来传去。

进入拓展 →

拔高 · EXTRA

为什么越界不抛异常

失败上下文的推测执行与事务回滚:失败时,连已经执行的 set 都会被撤销。

「取不到」背后的门道:Verse 会先在「影子图」上试连一遍,只要中途走不通,连已经改好的值都会被整个撤销、像没发生过。

进入拓展 →

拓展 · EXTRA

用不可变数组实现栈和队列

Length、下标失败、Slice、+= 四件套的实战组合:没有 push/pop 也能玩转经典数据结构。

Length、取一格会落空、Slice、+= 四件套的实战组合:没有 push/pop 也能搭出栈和队列这两种经典结构。

进入拓展 →