拔高 · EXTRA
把存档当生产系统设计
官方《Verse Persistence Best Practices》精读:加载失败保护、256 KB 预算、上线前检查清单。
进入拓展 →这一课把你的岛从「金鱼记忆」升级成「长期存档」:数据只要写进模块级的 weak_map(player, t),玩家下线再上线、下周再来,金币和进度都还在。学完你就能做出真正的跨局访问计数器。💾
这一课把你的岛从「金鱼记忆」升级成「长期存档」:说白了就是给每位玩家配一份 SaveGame——数据存进一张「以玩家为键」的特殊表(放在所有蓝图类外面的「模块」层),玩家下线再上线、下周再来,金币和进度都还在。学完你就能做出真正的跨局访问计数器。💾
先做个思想实验:你的岛上有一套金币系统,玩家肝了一下午攒下 999 枚。第二天他兴冲冲上线——金币归零。为什么?因为你用来记金币的 var 活在内存里,生命周期只有一局:会话结束,内存释放,一切清空。前面二十一课里我们写过的所有变量,都是这种「金鱼记忆」。
先做个思想实验:你的岛上有一套金币系统,玩家肝了一下午攒下 999 枚。第二天他兴冲冲上线——金币归零。为什么?因为你在变量面板里新建的那个「金币」变量,只活在这一局的内存里:会话一结束,变量连同它的值一起被清掉。前面二十一课里我们建过的所有变量,都是这种「金鱼记忆」——就像蓝图里没勾任何存档、每次开局都从默认值重来。
持久化(persistence)解决的就是这件事:把数据交给引擎,挂到玩家的账号上。官方的设计是「每玩家、每模块」——每位玩家在你这个岛的每个模块每套脚本(模块,可理解成「这个岛的 Verse 工程」)下,拥有自己独立的一份存档。玩家下次进岛之前,引擎会先把这份存档加载好;要是加载失败,玩家甚至会被暂时禁止加入这个岛——这不是 bug,是官方刻意的保护机制:宁可让玩家晚点进来,也不能让一份空数据把旧存档覆盖掉。
最妙的是,在 Verse 里开启持久化几乎不用学新 API:没有「存档函数」、没有读写文件,你只需要把一个 weak_map 变量放对位置。位置本身,就是开关。
最妙的是,在 Verse 里开启持久化几乎不用学新东西:没有「保存到存档」的节点、也不用读写文件,你只需要把一张特殊的存档表(weak_map 变量,后面细讲)放对位置。位置本身,就是开关。
持久化数据的声明公式只有一条:模块作用域(也就是类的外面)的 var,类型为 weak_map(player, t)——键必须是 player,值类型 t 必须「可持久化」(下一节讲哪些类型合格)。不需要任何额外标注,写在类外面,引擎就会自动替你保存:
持久化数据的建法只有一条规矩:在所有蓝图类的外面(这一层叫「模块作用域」)建一个变量,类型选 weak_map(player, t)——把它想成一张以玩家为键的存档表:每位玩家对应表里一格,格子里放你要存的东西(类型 t,得是「可持久化」的,下一节讲哪些合格)。不用勾任何「保存」选项、也不用连额外节点,只要这张表建在类的外面,引擎就自动天天替你存:
# ▢ 声明:必须在模块作用域(类的外面!)
var PlayerCoins:weak_map(player, int) = map{}
# ▢ 读:下标访问可能失败,住在失败上下文里
if (Coins := PlayerCoins[Player]):
Print("你有 {Coins} 枚金币")
# ▢ 写:set 同样可能失败,也要包进 if
if (set PlayerCoins[Player] = 100) {}
读和写都是失败上下文操作,这延续了你在失败上下文那几课练出的直觉:玩家可能不在图中、存档里可能还没有这位玩家的记录,所以「查不到」是一种正常的失败,不是异常。写入用 if (set PlayerCoins[Player] = 100) {} 这个略显奇怪的姿势——set 也可能失败,所以同样要住在 if 里;成功后没有别的事要做,就挂一对空花括号。
读和写这张存档表,都是可能失败的操作——就是你在失败上下文那几课熟悉的那种「这条线不一定走得通」:玩家可能已经离场、表里可能压根还没有他这一格,所以「查不到」是很正常的一种走不通,不是报错。所以上面代码里,读要用 if (Coins := PlayerCoins[Player]) 包起来——这就像一个 Branch,查得到才走「打印金币」那根线,查不到就默默走另一头;写也一样,if (set PlayerCoins[Player] = 100) {} 里的 set 就是个 Set 节点(把新值 100 连进这位玩家的格子),它同样可能失败所以照旧裹在 Branch 里;成功之后没别的事做,就挂一对空花括号 {} 收尾。
另外要记住 weak_map 的「三不能」:没有 Length 成员、不能遍历元素、键是弱引用。也就是说,「遍历所有有存档的玩家」这件事根本做不到——你只能对在场玩家逐个查询,比如用上一课学过的 GetPlayspace().GetPlayers() 拿到当前玩家列表再挨个查。要做全岛排行榜这类需求,得另想办法(见课末拓展阅读)。
另外记住这张存档表的「三不能」:数不了有多少格(没有 Length)、不能用 ForEach 逐格遍历、而且它对玩家的引用是「弱」的(玩家一走,那一格随时会被回收)。换句话说,「把所有存过档的玩家拉出来遍历一遍」根本做不到——你只能对当前在场的玩家一个个查,比如用上一课的 GetPlayspace().GetPlayers() 拿到在场玩家数组,再用 ForEach 挨个去表里查。要做全岛排行榜这类需求,得另想办法(见课末拓展阅读)。
不是什么值都能塞进 weak_map 存档。官方给出的「可持久化类型」清单如下:
| 类别 | 类型 | 条件 |
|---|---|---|
| 基础类型 | logic、int、float、string 等 |
直接可用 |
| 组合类型 | enum、option、array、map、tuple |
里面装的元素/键值也全部可持久化 |
| 自定义类型 | 带 <persistable> 说明符的类 |
必须 class<final><persistable>,只能有常量字段 |
顺带把名字掰清楚:它是一枚尖括号说明符,和 <final>、<override> 是一家,写在 class 后面;别和写在字段上一行、以 @ 开头的 @editable 属性搞混了。持久化类有两条硬规矩:必须 <final>(不许有子类),并且只能有常量字段——一个 var 字段都不行,字段类型自身也得可持久化。
顺带把名字掰清楚:<persistable> 是一枚尖括号说明符——就是写在 class 名字后面尖括号里的一个开关,和 <final>(不许别人拿它做子蓝图)、<override> 是一伙的;别把它和 @editable(变量上那个 Instance Editable 小眼睛)搞混了,那种是写在字段头上、以 @ 开头的属性。持久化类有两条硬规矩:必须挂 <final>(不许有子蓝图),而且字段只能是常量——一个可改的(var)字段都不许有,字段本身的类型也得是可持久化的。
字段不可变,那怎么「改」数据?官方的标准姿势是换而不是改:读出旧实例 → 用旧值构造一个新实例(要改的字段给新值)→ 把新实例 set 回 weak_map。下面这段代码挖掉了两个关键词,填回去感受一下完整的模式:
字段是焊死的常量,那还怎么「改」数据?官方的标准姿势是换而不是改:先读出玩家现在那份存档实例 → 用它的旧值拼一个全新的实例(想改的那个字段填上新值)→ 再用一个 Set 节点把新实例整个写回存档表。下面这段代码挖掉了两个关键词,填回去感受一下完整套路:
# 会随版本演化的数据,用 class 装(原因见第五节)
player_profile := class<final><____>:
Level:int = 1
Coins:int = 0
var Profiles:____(player, player_profile) = map{}
# 字段不可变:更新 = 读旧的,造个新的,整个换掉
AddCoins(Player:player, Amount:int):void =
if (Old := Profiles[Player]):
NewProfile := player_profile{ Level := Old.Level, Coins := Old.Coins + Amount }
if (set Profiles[Player] = NewProfile) {}
为什么这么麻烦也要用常量字段?因为存档是要跨版本、跨会话活很多年的数据,不可变意味着每次写入都是一个完整、自洽的快照,不会出现「改了一半」的存档。
把前三节拼起来,做一个最小但完整的持久化功能:统计每位玩家来过你的岛几次。老玩家进来,计数 +1;新玩家进来,记为 1。点「运行下一步」,假设一位上局存档为 2 的老玩家进岛,看数据怎么被读出、更新、写回。
using { /Fortnite.com/Devices }
using { /UnrealEngine.com/Temporary/Diagnostics }
# 模块作用域:持久化的访问计数,每位玩家各存一份
var VisitCount:weak_map(player, int) = map{}
visit_counter_device := class(creative_device):
OnBegin<override>()<suspends>:void =
for (Player : GetPlayspace().GetPlayers()):
WelcomePlayer(Player)
WelcomePlayer(Player:player):void =
var NewCount:int = 1
if (Old := VisitCount[Player]):
set NewCount = Old + 1
if (set VisitCount[Player] = NewCount) {}
Print("这位玩家第 {NewCount} 次进岛")
点「运行下一步」,看代码怎么一行行执行。
注意 WelcomePlayer 只处理了 OnBegin 时已在场的玩家。真实岛屿还有中途加入的情况——上一课提过的 PlayerAddedEvent 就是为此准备的,订阅它、在回调里对新玩家调用同一个 WelcomePlayer 即可,思路完全一样。
注意 WelcomePlayer 只招待了 OnBegin(也就是 Event BeginPlay)那一刻已经在场的玩家。真实岛屿还有中途加入的玩家——上一课提过的 PlayerAddedEvent 就是为这个准备的:把它像 Bind Event 一样绑到你的处理逻辑上,有新玩家进来时,在回调里对他调用同一个 WelcomePlayer 就行,思路完全一样。
持久化的坑分两类:写代码时的坑,和发布之后才炸的坑。先看写代码时的:
Score := PlayerScore[Player] 裸写在普通语句里,编译器会拦下:可能失败的表达式必须出现在失败上下文中。别忘了 set 也一样要包 if。Score := PlayerScore[Player] 直接裸连、不套 Branch,Compile 就会亮红拦下:可能失败的操作必须放在失败上下文(Branch 那样的地方)里。别忘了写存档的 set(Set 节点)也一样要包在 if 里。player,而按钮事件递给你的是 agent——先 if (P := player[Agent]): 转型再查存档。player,可按钮事件递给你的那个「玩家引脚」是 agent 类型(类似蓝图的 Instigator)——得先用 if (P := player[Agent]): 把它转成 player,再拿去查存档。GetPlayspace().GetPlayers() 循环。GetPlayspace().GetPlayers() 拿到的在场玩家数组用 ForEach。再看发布后的:发布那一刻,weak_map 的值类型就被锁定了。之后每次发布,引擎都会做向后兼容类型检查,不兼容直接发布失败。这就是为什么第三节建议「会演化的数据用 class 装」——在整个类型系统里,只有 class 拥有一条演化通道:
再看发布后的:发布那一刻,这张存档表里「每格放什么类型」就被永久锁定了。之后每次发布,引擎都会做一次「向后兼容检查」,新旧对不上就直接发布失败(跟 Compile 报错一样拦住你)。这就是为什么第三节建议「会演化的数据用 class 装」——在所有可持久化类型里,只有 class(蓝图类)留了一条日后还能加东西的通道:
| 发布后想做的事 | 行不行 | 对策 |
|---|---|---|
| 把值类型 int 改成 float,或改动 struct / tuple 结构 | ✗ 兼容检查失败 | 规划期就改用 class 承载 |
| 给 persistable class 加新字段 | ✓ 唯一的演化通道 | 新字段必须带默认值,旧存档加载时自动取默认值 |
| 删掉不再用的 persistable class | ✗ 发布失败 | 旧定义留在代码里退役,新系统另开一个 weak_map |
| 删除玩家的存档数据 | ✗ 无法真正删除 | 只能重置:写回默认值 |
还有两条要在计划期就记在小本本上:每个岛的持久化变量(以 player 为键的模块级 weak_map)(也就是这些建在类外面、以玩家为键的存档表)数量有上限,目前为 4 个——这个上限历史上调整过(从 2 提升到 4),动手前以当时的官方文档为准。另外,在 UEFN 编辑器的测试会话里验证「跨会话持久化」,常会得出「不生效」的错误结论——真实的跨会话行为要以发布后(私有版本即可)的表现为准,编辑器会话内的确切行为随版本有差异,以官方文档为准。
存档系统验收时间。三道关卡,答错零惩罚,可以一直重试。
想让 VisitCount 这个 weak_map(player, int) 真正跨局保存,它应该声明在哪里?
player_profile 是 class<final><persistable>,想给某玩家的 Coins 加 10,正确姿势是?
岛屿已经发布。下面哪个存档结构改动能通过向后兼容检查?