拔高 · EXTRA
为什么给 map 赋值也「可能失败」?
围观 Epic 官方论坛的真实争论,看懂效果系统「全家统一标 decides」的设计取舍。
围观 Epic 官方论坛的真实争论,看懂为什么连「写一行」都被要求接进 Branch 的设计取舍。
进入拓展 →array 用编号找东西,map 用「钥匙」找:报上玩家名,分数立刻到手。这一课学 [k]v 类型与 map{} 字面量、按键查询、set 增改与键值对遍历,最后教你在 map 和 array 之间怎么选。这一课把蓝图里的 Map 变量搬进 Verse:怎么声明这张表、怎么按键查询取值、怎么写入增改、怎么用 ForEach 逐行点名,最后教你在 Map 和 Array 之间怎么选。
上一课的 array 是一排编号储物柜:找东西得报编号——「3 号柜里是什么?」但游戏里更多的问题长这样:「泡泡多少分?」「阿奇有几把钥匙?」你手里握着的不是编号,而是名字。硬用 array,就得从头到尾把柜子翻一遍。这活儿该交给 map(映射):一张登记表,左列写键(key),右列写值(value),报上键,值立刻到手。
类型的写法是 [k]v——方括号里写键的类型,后面跟值的类型。对比记忆:array 写 []int,方括号空着,因为下标天生是 int;map 写 [string]int,方括号里填了 string,等于声明「本表用字符串当钥匙」。字面量则用 map{},键值之间用 => 配对:
想要一张 Map 表,在蓝图里你会把变量面板那个「容器类型」下拉框从单值改成 Map,再分别给键(Key)和值(Value)挑好类型。Verse 没有下拉框,全靠一行文字声明:方括号里那个类型是键,紧跟其后的是值。所以 [string]int 读作「键是文字、值是整数的一张 Map」,对应蓝图里 Key 选 String、Value 选 Integer;[]int 方括号空着,是因为 Array 的「键」永远是从 0 开始的位置编号,不用你填。下面代码里的 map{} 则是给这张表填好初始几行,=> 就是「这个键配这个值」的箭头,相当于在变量默认值面板里点 + 号加一对 Key/Value:
using { /Fortnite.com/Devices }
registry_device := class(creative_device):
# [string]int:键是玩家名,值是分数
var Scores:[string]int = map{"泡泡" => 10, "阿奇" => 25}
# 空登记表:一行都还没登记
var Kills:[string]int = map{}
三个细节值得敲黑板:第一,配对符号是 =>(等号加大于号),不是冒号也不是箭头 ->,写错编译器直接翻脸;第二,空表写 map{};第三,同一个键在字面量里出现两次,后写的赢——一张表里,一个键永远只占一行。
三个细节值得敲黑板:第一,配对用的箭头是 =>(等号加大于号),别写成冒号或 ->,写错了点 Compile(蓝图左上角那个按钮,Verse 里是 Ctrl+Shift+B)一样亮红;第二,想要一张空表就写 map{},一行都不填;第三,同一个键在初始内容里配了两次,后写的赢——一张表里一个键永远只占一行,和蓝图 Map 不许重复 Key 是一个道理。
还有一道「资格审查」:键的类型必须是 comparable(可比较)的子类型。int、float、string、char、枚举都天生合格;普通 class 不行,除非它带 <unique> 标记。而 player、agent 恰好就是 unique 类,所以 [player]int 完全合法——官方的 Team Elimination 教程就是用它记录每个玩家的淘汰数的。
查询的语法跟 array 下标一模一样:Scores["泡泡"]。也正因为一模一样,它继承了同一条铁律:方括号查询带 <decides> 效果,是可失败表达式,只能出现在失败上下文里——还记得掷骰子的比喻吗?if 就是那张赌桌。
在蓝图里,从 Map 取值是 Find 节点:它一次给你两样东西——值,还有一个「找到了吗?」的布尔。Verse 把这两样合成一个动作:Scores["泡泡"] 就是那次 Find,但它不单独给你布尔,而是「查不到就整个动作走不通」。所以你得把它接进一个 Branch(也就是 if)——这里的 Branch 问的不是 True/False,而是「这次查询走通了没」:走通了走主线,查不到就走 else 那根引脚。还记得掷骰子的比喻吗?if 就是那张赌桌。
为什么会失败?因为可能查无此人。别的语言在这里也许塞给你一个 null,也许当场抛异常;Verse 的答案统一得多:整个表达式失败,程序稳稳地走进 else 分支。没有空指针,没有崩溃,失败被类型系统关在笼子里。
为什么会走不通?因为可能查无此人。换个地方,查不到也许会甩给你一个空值、甚至当场报错崩游戏;Verse 的做法干净得多:整次查询走不通,执行线稳稳拐进 else 引脚。不会拿到一个「空的」值,也不会崩——「查不到」这件事被牢牢关在 Branch 的 else 那一侧,漏不出来。
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 里用。
想给缺席的键一个默认值?最直白的就是上面的 if/else;另一条路是把查询包进 option{Scores[Key]},让失败变成一只「空盒子」——那是下一课 option 的主场,先按下不表。
想给查不到的键一个默认值?最直白的就是上面那套 Branch:走通用查到的值,走 else 就填个默认值。另一条路是把查询裹进 option{Scores[Key]},让「查不到」变成一只明明白白的「空盒子」而不是直接走不通——那是下一课 option 的主场,先按下不表。
往表里写东西,语法是 set Scores[Key] = Value,一条语句身兼两职:键不存在,就新增一行;键已存在,就覆盖旧值。不用先问「在不在」,直接写就是。
往表里写东西,在蓝图里就是 Map 的 Add 节点:给它一个 Key、一个 Value。Verse 写成 set Scores[Key] = Value,一个动作身兼两职——键不存在就新增一行,键已存在就覆盖旧值,跟 Add 节点「有就更新、没有就加」的脾气一模一样。不用先问「在不在」,直接写就是。
但这里藏着全 Verse 最让新人瞪眼的设计:这条赋值也是可失败表达式。哪怕逻辑上「要么新增要么覆盖」看起来永远成功,它照样带 <decides> 效果,必须包进失败上下文:if (set Scores["阿奇"] = 0):。不需要 else 分支时,社区惯用单行形式 if (set Scores["阿奇"] = 0) {} 收尾。裸写?编译器当场打回。
但这里藏着全 Verse 最让蓝图老手瞪眼的一点:这个「写一行」的动作居然也可能走不通,所以它也得接进一个 Branch。蓝图的 Add 节点从不会失败,你连上就完事;Verse 偏要你写成 if (set Scores["阿奇"] = 0):,把这次写入接进 Branch。不需要 else 那一侧时,社区习惯用单行 if (set Scores["阿奇"] = 0) {} 收尾——那对 {} 就是「else 什么也不做」的空引脚。想省掉 Branch 直接裸写?点 Compile 当场亮红。
为什么连纯赋值都「可能失败」?一句话剧透:因为 set Scores[K] += 1 这样的复合赋值必须先读旧值再写新值,键不存在时「读」那一步是实打实会失败的;为了让整个「索引赋值家族」规则统一,连纯 = 也一起标了可失败。这背后有一场真实的官方论坛争论,拔高拓展页里详聊。
为什么连「纯写入」都可能走不通?一句话剧透:因为像 set Scores[K] += 1 这种「在原值上加」的写法,得先把旧值读出来、加完再写回去;键不存在时,「读」那一步就实打实查无此人了。为了让「往表里某个键写值」的所有写法都守同一条规矩,Verse 干脆把最普通的「直接写」也一并要求接 Branch。这背后有一场真实的官方论坛争论,拔高拓展页里详聊。
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 一律包 if;用 += 累加之前,确保这个键已经初始化过,或者老老实实处理 else 分支。现在动手时间到——下面这张道具登记表被挖掉了三个关键位:字面量关键字、配对箭头、修改口令。把它们填回去,点「检查答案」。
由此得出两条实践守则:往 map 里写东西一律接一个 Branch;用「在原值上加」之前,先确保这个键登记过初值,否则就老实处理 else 那一侧。现在动手时间到——下面这张道具登记表被挖掉了三处:开表的关键字、配对的箭头、写入的口令。把它们填回去,点「检查答案」。
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("药水入库")
整张表点一遍名,用 for (Name -> Score : Scores):——每轮循环取出一对键值,箭头左边接键,右边接值。注意这个 -> 和字面量里的 => 长得像但分工不同:=> 负责「写入」配对,-> 负责「读出」配对。
整张表点一遍名,在蓝图里就是对着 Map 拖一个 ForEach Loop:每转一圈,Loop Body 就递给你一对 Key 和 Value。Verse 写成 for (Name -> Score : Scores):,箭头左边接住键、右边接住值。注意这个读出用的 -> 和前面写入用的 => 长得像但活儿不同:=> 是填表时「这个键配这个值」,-> 是遍历时「把这对键值拆给我」。
顺序有讲究:Verse 的 map 按写入顺序遍历——谁先登记,谁先被点名,不按键的大小排序。这跟 C++ 的 std::map(按键排序)不一样,倒是跟你直觉里那张纸质登记表完全一致。想知道表里有几行,用 Scores.Length,和 array 同款。至于「删掉一行」——搜遍文档你也找不到 Remove,因为 map 跟 array 一样是不可变值;删除的正解是「重建一张没有那个键的新表」,技巧拓展页手把手教。
顺序有讲究:Verse 的 map 按写入顺序点名——谁先登记谁先被叫到,不会自动按键排序,跟你直觉里那张纸质登记表完全一致。想知道表里有几行,用 Scores.Length,和 Array 的长度同款。这里有个和蓝图不一样、要留神的点:蓝图的 Map 有个 Remove 节点,Verse 的 map 却没有——因为它跟 array 一样是「不能原地改的整体」。想删一行,只能「重建一张没有那个键的新表」再整体换上,技巧拓展页手把手教。
下面把本课的招式串成一台完整的计分设备。点「运行下一步」,亲眼看登记、累加、点名怎么一行行发生。
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}")
点「运行下一步」,看代码怎么一行行执行。
一句话原则:数据天生有顺序、按位置取,用 array;数据按身份查、每个键对应一份,用 map。拿不准时问自己:「我找东西时,手里握着的是编号,还是名字?」
| 问题 | array | map |
|---|---|---|
| 用什么找东西 | int 下标(从 0 开始的位置编号) | 任意 comparable 键(名字、ID、player)任意「能当钥匙」的键(名字、ID、玩家) |
| 类型 / 字面量变量类型 / 初始内容 | []int / array{1, 2, 3}Array 容器,元素是整数;初始三个:1、2、3 |
[string]int / map{"a" => 1}Map 容器,键是文字、值是整数;初始一行:a 配 1 |
| 新增元素 | set A += array{X}(尾部拼接)Add 节点往尾部追加一个元素 X |
if (set M[K] = V)(任意键直接写)Add 节点写任意键 K 的值 V(Verse 里要接进 Branch) |
| 遍历逐个点名 | for (X : A) 或 for (I -> X : A)对 Array 拖 ForEach Loop:只要元素,或连位置编号一起要 |
for (K -> V : M)对 Map 拖 ForEach Loop:每圈给你一对键 K 和值 V |
| 典型用途 | 出怪顺序、巡逻路径点、前十名榜单 | 玩家分数、道具库存、ID 到配置的对照 |
再把本课的高频雷区排成一排,巡场一遍:
| 坑 | 现场 | 正确姿势 |
|---|---|---|
裸写 set M[K] = V不接 Branch 直接写一行 set M[K] = V |
编译错误:'decides' effect ... not allowed by its contextCompile 亮红:说这个动作可能走不通,不许裸放 | 包进失败上下文:if (set M[K] = V) {}接进 Branch:if (set M[K] = V) {},空 {} 就是 else 什么都不做 |
| 期望缺键返回 null / 默认值以为查不到会自动给个空值 / 默认值 | 查询直接失败,没有 null 这回事查询直接走不通(走 else),没有「空值」这回事 | if/else 给默认值,或 option{M[K]}用 Branch 的 else 给默认值,或裹进 option{M[K]} |
对没初始化的键 +=对没登记过初值的键做「在原值上加」 |
if 静悄悄走 else,分数永远加不上去Branch 悄悄走了 else,分数永远加不上去 | 先登记初值,再累加;或处理 else 分支先登记初值再累加;或老实处理 else 那一侧 |
字面量写成 map{"a": 1} 或 "a" -> 1配对箭头写错:写成冒号 map{"a": 1} 或 "a" -> 1 |
编译错误Compile 亮红 | 配对符号只有一个:=> |
| 拿普通 class 当键拿普通蓝图类当键 | 编译错误:键必须是 comparable 的子类型Compile 亮红:键必须是「能比较、能当钥匙」的类型 | 换 string / int / 枚举;player 因带 <unique> 天生可用换成文字 / 整数 / 枚举;玩家(player)天生就能当键 |
寻找 M.Remove(K)到处找 Remove 节点删键 |
查无此函数Verse 的 map 根本没有 Remove | 重建法:新表过滤重灌(见技巧拓展页) |
登记表画好了,来闯三道小关卡验收。答错零惩罚,可以一直重试。
在 OnBegin 里直接裸写一行 set Scores["泡泡"] = 10(没有包 if),会发生什么?游戏一开场(Event BeginPlay)里,不接 Branch 直接写一行 set Scores["泡泡"] = 10,会发生什么?
下面哪个才是合法的 map 字面量?给 map 填初始几行,下面哪种写法才对?
Kills 表里从没登记过「新人」,现在执行 if (set Kills["新人"] += 1): 会发生什么?Kills 表里从没登记过「新人」,现在对这个没登记的键做一次「在原值上加 1」的写入(接在 Branch 里),会发生什么?
拔高 · EXTRA
围观 Epic 官方论坛的真实争论,看懂效果系统「全家统一标 decides」的设计取舍。
围观 Epic 官方论坛的真实争论,看懂为什么连「写一行」都被要求接进 Branch 的设计取舍。
进入拓展 →技巧 · EXTRA
不可变容器怎么做「删除」?普通 map 用过滤重建,weak_map 用整条重置。
不能原地改的表怎么做「删除」?普通 map 靠过滤重建,weak_map 靠整条重置。
进入拓展 →拓展 · EXTRA
map 的近亲 weak_map 是 UE6 跨会话存档的载体:排行榜、玩家进度、货币系统都从这里起步。
进入拓展 →