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

map 映射:每个玩家一行的分数登记表

array 用编号找东西,map 用「钥匙」找:报上玩家名,分数立刻到手。这一课把蓝图里的 Map 变量搬进 Verse:怎么声明这张表、怎么按键查询取值、怎么写入增改、怎么用 ForEach 逐行点名,最后教你在 Map 和 Array 之间怎么选。

一、从编号柜到登记表:[k]v 与 map{} 字面量

上一课的 array 是一排编号储物柜:找东西得报编号——「3 号柜里是什么?」但游戏里更多的问题长这样:「泡泡多少分?」「阿奇有几把钥匙?」你手里握着的不是编号,而是名字。硬用 array,就得从头到尾把柜子翻一遍。这活儿该交给 map(映射):一张登记表,左列写键(key),右列写值(value),报上键,值立刻到手。

想要一张 Map 表,在蓝图里你会把变量面板那个「容器类型」下拉框从单值改成 Map,再分别给键(Key)和值(Value)挑好类型。Verse 没有下拉框,全靠一行文字声明:方括号里那个类型是,紧跟其后的是。所以 [string]int 读作「键是文字、值是整数的一张 Map」,对应蓝图里 Key 选 String、Value 选 Integer;[]int 方括号空着,是因为 Array 的「键」永远是从 0 开始的位置编号,不用你填。下面代码里的 map{} 则是给这张表填好初始几行,=> 就是「这个键配这个值」的箭头,相当于在变量默认值面板里点 + 号加一对 Key/Value:

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

registry_device := class(creative_device):

    # [string]int:键是玩家名,值是分数
    var Scores:[string]int = map{"泡泡" => 10, "阿奇" => 25}

    # 空登记表:一行都还没登记
    var Kills:[string]int = map{}

三个细节值得敲黑板:第一,配对用的箭头是 =>(等号加大于号),别写成冒号或 ->,写错了点 Compile(蓝图左上角那个按钮,Verse 里是 Ctrl+Shift+B)一样亮红;第二,想要一张空表就写 map{},一行都不填;第三,同一个键在初始内容里配了两次,后写的赢——一张表里一个键永远只占一行,和蓝图 Map 不许重复 Key 是一个道理。

还有一道「资格审查」:键的类型必须是 comparable(可比较)的类型——说白了,得能拿来当「钥匙」互相比对。int、float、string、char、枚举都天生合格;普通 class 不行,除非它带 <unique> 标记(每个实例天生独一无二,能拿身份互相比对)。而 player、agent 恰好就是这种 unique 类,所以 [player]int 完全合法——官方的 Team Elimination 教程就是用它记录每个玩家的淘汰数的。

二、按键查询:查无此人就「失败」

在蓝图里,从 Map 取值是 Find 节点:它一次给你两样东西——值,还有一个「找到了吗?」的布尔。Verse 把这两样合成一个动作:Scores["泡泡"] 就是那次 Find,但它不单独给你布尔,而是「查不到就整个动作走不通」。所以你得把它接进一个 Branch(也就是 if)——这里的 Branch 问的不是 True/False,而是「这次查询走通了没」:走通了走主线,查不到就走 else 那根引脚。还记得掷骰子的比喻吗?if 就是那张赌桌。

为什么会走不通?因为可能查无此人。换个地方,查不到也许会甩给你一个空值、甚至当场报错崩游戏;Verse 的做法干净得多:整次查询走不通,执行线稳稳拐进 else 引脚。不会拿到一个「空的」值,也不会崩——「查不到」这件事被牢牢关在 Branch 的 else 那一侧,漏不出来。

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

lookup_device := class(creative_device):

    var Scores:[string]int = map{"泡泡" => 10}

    OnBegin<override>()<suspends>:void =
        if (Score := Scores["泡泡"]):
            Print("泡泡的分数:{Score}")
        if (Score := Scores["神秘人"]):
            Print("这行不会执行")
        else:
            Print("查无此人:神秘人没登记过")

读图翻译:这段的骨架是一个 Event BeginPlay(OnBegin,游戏一开场自动响)。里面接了两个 Branch:第一个 Find「泡泡」,查到了就走 Print String 打出分数;第二个 Find「神秘人」,表里没这号人、走不通,于是拐进 else 的 Print String,打出「查无此人」。Score := 那半句,是把 Find 出来的值临时存进一个格子,好在 Print 里用。

想给查不到的键一个默认值?最直白的就是上面那套 Branch:走通用查到的值,走 else 就填个默认值。另一条路是把查询裹进 option{Scores[Key]},让「查不到」变成一只明明白白的「空盒子」而不是直接走不通——那是下一课 option 的主场,先按下不表。

三、增改一体:连「写一行」都要接进 Branch

往表里写东西,在蓝图里就是 Map 的 Add 节点:给它一个 Key、一个 Value。Verse 写成 set Scores[Key] = Value,一个动作身兼两职——键不存在就新增一行,键已存在就覆盖旧值,跟 Add 节点「有就更新、没有就加」的脾气一模一样。不用先问「在不在」,直接写就是。

但这里藏着全 Verse 最让蓝图老手瞪眼的一点:这个「写一行」的动作居然也可能走不通,所以它也得接进一个 Branch。蓝图的 Add 节点从不会失败,你连上就完事;Verse 偏要你写成 if (set Scores["阿奇"] = 0):,把这次写入接进 Branch。不需要 else 那一侧时,社区习惯用单行 if (set Scores["阿奇"] = 0) {} 收尾——那对 {} 就是「else 什么也不做」的空引脚。想省掉 Branch 直接裸写?点 Compile 当场亮红。

为什么连「纯写入」都可能走不通?一句话剧透:因为像 set Scores[K] += 1 这种「在原值上加」的写法,得先把旧值读出来、加完再写回去;键不存在时,「读」那一步就实打实查无此人了。为了让「往表里某个键写值」的所有写法都守同一条规矩,Verse 干脆把最普通的「直接写」也一并要求接 Branch。这背后有一场真实的官方论坛争论,拔高拓展页里详聊。

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

score_write_device := class(creative_device):

    var Scores:[string]int = map{}

    OnBegin<override>()<suspends>:void =
        # 新增:表里还没有「泡泡」,这行添加一行记录
        if (set Scores["泡泡"] = 10):
            Print("登记成功")

        # 覆盖:键已存在,10 被换成 99
        if (set Scores["泡泡"] = 99) {}

        # 复合赋值:先读出 99,再写回 100
        if (set Scores["泡泡"] += 1) {}

        # 危险示范:「路人甲」没登记过,「读」直接失败
        if (set Scores["路人甲"] += 1):
            Print("这行不会执行")
        else:
            Print("路人甲不存在,+= 失败")

读图翻译:四个 Branch 顺次排开。第一个把「泡泡 => 10」写进空表(新增),走通,Print String 打「登记成功」;第二个把泡泡改成 99(覆盖),用空 {} 收尾不打日志;第三个 += 1 先读出 99 再写回 100;第四个对没登记过的「路人甲」做 += 1,「读」那步查无此人,直接走 else 打出失败提示。

由此得出两条实践守则:往 map 里写东西一律接一个 Branch;用「在原值上加」之前,先确保这个键登记过初值,否则就老实处理 else 那一侧。现在动手时间到——下面这张道具登记表被挖掉了三处:开表的关键字、配对的箭头、写入的口令。把它们填回去,点「检查答案」。

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

inventory_device := class(creative_device):

    # 道具登记表:道具名对应数量
    var Items:[string]int = ____{"钥匙" ____ 1}

    OnBegin<override>()<suspends>:void =
        if (____ Items["药水"] = 3):
            Print("药水入库")

四、遍历键值对:一场按登记顺序的点名

整张表点一遍名,在蓝图里就是对着 Map 拖一个 ForEach Loop:每转一圈,Loop Body 就递给你一对 Key 和 Value。Verse 写成 for (Name -> Score : Scores):,箭头左边接住键、右边接住值。注意这个读出用的 -> 和前面写入用的 => 长得像但活儿不同:=> 是填表时「这个键配这个值」,-> 是遍历时「把这对键值拆给我」。

顺序有讲究:Verse 的 map 按写入顺序点名——谁先登记谁先被叫到,不会自动按键排序,跟你直觉里那张纸质登记表完全一致。想知道表里有几行,用 Scores.Length,和 Array 的长度同款。这里有个和蓝图不一样、要留神的点:蓝图的 Map 有个 Remove 节点,Verse 的 map 却没有——因为它跟 array 一样是「不能原地改的整体」。想删一行,只能「重建一张没有那个键的新表」再整体换上,技巧拓展页手把手教。

下面把本课的招式串成一台完整的计分设备。点「运行下一步」,亲眼看登记、累加、点名怎么一行行发生。

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

score_board_device := class(creative_device):

    # 登记表:玩家名 -> 分数
    var Scores:[string]int = map{"泡泡" => 10}

    OnBegin<override>()<suspends>:void =
        if (set Scores["阿奇"] = 0):
            Print("阿奇登记完成")
        if (set Scores["阿奇"] += 5):
            Print("阿奇加 5 分")
        for (Name -> Score : Scores):
            Print("{Name}: {Score}")
输出日志

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

五、map 还是 array?选型指南与常见坑

一句话原则:数据天生有顺序、按位置取,用 array;数据按身份查、每个键对应一份,用 map。拿不准时问自己:「我找东西时,手里握着的是编号,还是名字?」

问题 array map
用什么找东西 int 下标(从 0 开始的位置编号) 任意「能当钥匙」的键(名字、ID、玩家)
变量类型 / 初始内容 Array 容器,元素是整数;初始三个:1、2、3 Map 容器,键是文字、值是整数;初始一行:a 配 1
新增元素 Add 节点往尾部追加一个元素 X Add 节点写任意键 K 的值 V(Verse 里要接进 Branch)
逐个点名 对 Array 拖 ForEach Loop:只要元素,或连位置编号一起要 对 Map 拖 ForEach Loop:每圈给你一对键 K 和值 V
典型用途 出怪顺序、巡逻路径点、前十名榜单 玩家分数、道具库存、ID 到配置的对照

再把本课的高频雷区排成一排,巡场一遍:

现场 正确姿势
不接 Branch 直接写一行 set M[K] = V Compile 亮红:说这个动作可能走不通,不许裸放 接进 Branch:if (set M[K] = V) {},空 {} 就是 else 什么都不做
以为查不到会自动给个空值 / 默认值 查询直接走不通(走 else),没有「空值」这回事 用 Branch 的 else 给默认值,或裹进 option{M[K]}
对没登记过初值的键做「在原值上加」 Branch 悄悄走了 else,分数永远加不上去 先登记初值再累加;或老实处理 else 那一侧
配对箭头写错:写成冒号 map{"a": 1}"a" -> 1 Compile 亮红 配对符号只有一个:=>
拿普通蓝图类当键 Compile 亮红:键必须是「能比较、能当钥匙」的类型 换成文字 / 整数 / 枚举;玩家(player)天生就能当键
到处找 Remove 节点删键 Verse 的 map 根本没有 Remove 重建法:新表过滤重灌(见技巧拓展页)

蓝图对照

蓝图的 Map 和 Verse 的 map 装的是同一种东西,脾气却差得远。差异那一列是本节的正题。

蓝图里的做法 Verse 里的写法 差异
变量面板把容器类型下拉框切成 Map,再分别给 Key 和 Value 挑类型 var Scores:[string]int = map{"泡泡" => 10} Verse 用一行文字表达:方括号里是键、紧跟其后是值。键的类型必须「能当钥匙」(comparable);普通类当不了键,除非带 <unique>——player 和 agent 恰好就是这种类
Find 节点:一次给你两个引脚,Value 和「找到了吗」的布尔 if (V := M[K]):else: 蓝图把「值」和「找没找到」拆成两个引脚,你可以只接 Value、不看布尔——于是拿到的是那个类型的默认值。Verse 把两件事焊成一个动作:查不到就整条线走不通,你根本拿不到一个假值,也不许绕开这个判断
Contains 节点:只问「在不在」,返回布尔 if (M[K]):(不接住值,只看走不走得通) Verse 没有独立的 Contains——查询本身就是存在性判断,顺手还把值给你了
Add 节点:有就更新、没有就加,从不失败,连出去就完事 if (set M[K] = V) {} Verse 里连「写一行」都带着「可能走不通」的戳,必须接进 Branch;空 {} 就是「else 什么也不做」。原因是 set M[K] += 1 这种写法要先读旧值,键不存在时读那一步就查无此人,于是整个家族守同一条规矩
Remove 节点 / Clear 节点:就地抠掉一行 没有对应节点:过滤重建一张新表再整体换上 和 array 同源——Verse 的 map 是不能原地改的整体,任何「修改」本质上都是造一张新表
对 Map 拖 ForEach Loop,每圈给你一对 Key / Value for (K -> V : M):;条数用 M.Length 形态一致,但顺序的可靠性不同:蓝图的 TMap 是哈希表,官方不保证迭代顺序、增删之后顺序还可能变,所以你不该依赖它;Verse 的 map 按写入顺序点名,这个顺序是可以放心依赖的

头两条差异是同一件事的两面:蓝图把「查到了吗」交给你自觉去接,Verse 把它焊死在控制流里。蓝图的 Find 允许你只拉 Value 那根线走人,代价是键不存在时你悄悄拿到了一个默认值——分数变成 0、引用变成 None,错误一路飘到很远的地方才现形。Verse 干脆不给「默认值」这个选项:查不到就是这条线走不通,你要么处理 else,要么编译不过。

后两条差异则来自「map 是值不是容器」:既然不能原地改,就没有 Remove 的立足之地,也就没有「删着删着哈希桶出现空洞」这回事——顺序因此可以稳定地保持成写入顺序。蓝图那边的顺序不确定性,恰恰是原地增删的哈希表结构留下的副作用。

六、关卡挑战

登记表画好了,来闯三道小关卡验收。答错零惩罚,可以一直重试。

游戏一开场(Event BeginPlay)里,不接 Branch 直接写一行 set Scores["泡泡"] = 10,会发生什么?

给 map 填初始几行,下面哪种写法才对?

Kills 表里从没登记过「新人」,现在对这个没登记的键做一次「在原值上加 1」的写入(接在 Branch 里),会发生什么?

拓展阅读

拔高 · EXTRA

为什么给 map 赋值也「可能失败」?

围观 Epic 官方论坛的真实争论,看懂为什么连「写一行」都被要求接进 Branch 的设计取舍。

进入拓展 →

技巧 · EXTRA

map 没有删除函数:重建法与 weak_map 重置法

不能原地改的表怎么做「删除」?普通 map 靠过滤重建,weak_map 靠整条重置。

进入拓展 →

拓展 · EXTRA

weak_map 与跨局持久化数据

map 的近亲 weak_map 是 Verse 跨会话存档的载体:排行榜、玩家进度、货币系统都从这里起步。

进入拓展 →