拔高 · EXTRA
把存档当生产系统设计
官方《Verse Persistence Best Practices》精读:加载失败保护、256 KB 预算、上线前检查清单。
进入拓展 →第 26 课给了你世界的骨架,第 27 课给了你往里装能力的手艺。第八章的最后一步是时间:让数据活过这一局。说白了就是给每位玩家配一份 SaveGame——数据存进一张「以玩家为键」的特殊表(放在所有类外面的「模块」层),玩家下线再上线、下周再来,金币和进度都还在。学完你就能做出真正的跨对局访问计数器。💾
把第八章走到这里的进度盘一下:第 26 课我们搞清了世界是怎么组成的——entity 是容器,component 是能力;第 27 课我们亲手写了一个 component,让一块平台自己动了起来。骨架有了、能力有了,还差最后一样东西:记忆。
做个思想实验:你的世界里有一套金币系统,玩家肝了一下午攒下 999 枚。第二天他兴冲冲上线——金币归零。为什么?因为你在变量面板里新建的那个「金币」变量,只活在这一局的内存里:会话一结束,变量连同它的值一起被清掉。前面所有课里我们建过的变量——包括上一课那个组件的 @editable 字段——都是这种「金鱼记忆」,就像蓝图里没接任何 SaveGame、每次开局都从默认值重来。
持久化(persistence)解决的就是这件事:把数据交给引擎,挂到玩家的账号上。这对持久化世界与多人在线体验是刚需——一场比赛结束、一次会话断开,世界不该失忆。官方的设计是「每玩家、每模块」——每位玩家在你这套脚本(模块,可理解成「这个项目的 Verse 工程」)下,拥有自己独立的一份存档。玩家下次进入之前,引擎会先把这份存档加载好;要是加载失败,玩家甚至会被暂时禁止加入——这不是 bug,是官方刻意的保护机制:宁可让玩家晚点进来,也不能让一份空数据把旧存档覆盖掉。
最妙的是,在 Verse 里开启持久化几乎不用学新东西:没有「保存到存档」的节点、也不用读写文件,你只需要把一张特殊的存档表(weak_map 变量,后面细讲)放对位置。位置本身,就是开关。这种「声明式」的味道,是本课和蓝图 SaveGame 最大的分野,我们会在「蓝图对照」一节里正式算这笔账。
(本课的一切今天都跑在 UEFN 上——它仍是唯一能真正运行 Verse 的地方。但请把下面所有内容理解成「Verse 的持久化模型」,而不是「UEFN 的某个功能」:等这套语言随 UE6 铺开,规则不会因为换了个编辑器就变。)
持久化数据的建法只有一条规矩:在所有蓝图类的外面(这一层叫「模块作用域」)建一个变量,类型选 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 (Coins := PlayerCoins[Player]) 包起来——这就像一个 Branch,查得到才走「打印金币」那根线,查不到就默默走另一头;写也一样,if (set PlayerCoins[Player] = 100) {} 里的 set 就是个 Set 节点(把新值 100 连进这位玩家的格子),它同样可能失败,所以照旧裹在 Branch 里;成功之后没别的事做,就挂一对空花括号 {} 收尾。
另外记住这张存档表的「三不能」:数不了有多少格(没有 Length)、不能用 ForEach 逐格遍历、而且它对玩家的引用是「弱」的(玩家一走,那一格随时会被回收)。换句话说,「把所有存过档的玩家拉出来遍历一遍」根本做不到——你只能对当前在场的玩家一个个查,比如用上一课的 GetPlayspace().GetPlayers() 拿到在场玩家数组,再用 ForEach 挨个去表里查。要做全服排行榜这类需求,得另想办法(见课末拓展阅读)。
不是什么值都能塞进 weak_map 存档。官方给出的「可持久化类型」清单如下:
| 类别 | 类型 | 条件 |
|---|---|---|
| 基础类型 | logic、int、float、string 等 |
直接可用 |
| 组合类型 | enum、option、array、map、tuple |
里面装的元素/键值也全部可持久化 |
| 自定义类型 | 带 <persistable> 说明符的类 |
必须 class<final><persistable>,只能有常量字段 |
顺带把名字掰清楚:<persistable> 是一枚尖括号说明符——就是写在 class 名字后面尖括号里的一个开关,和 <final>(不许别人拿它做子蓝图)、<override> 是一伙的;别把它和 @editable(变量上那个 Instance Editable 小眼睛)搞混了,那种是写在字段头上、以 @ 开头的属性。持久化类有两条硬规矩:必须挂 <final>(不许有子蓝图),而且字段只能是常量——一个可改的(var)字段都不许有,字段本身的类型也得是可持久化的。
字段是焊死的常量,那还怎么「改」数据?官方的标准姿势是换而不是改:先读出玩家现在那份存档实例 → 用它的旧值拼一个全新的实例(想改的那个字段填上新值)→ 再用一个 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(也就是 Event BeginPlay)那一刻已经在场的玩家。真实的多人在线体验还有中途加入的玩家——第 25 课提过的 PlayerAddedEvent 就是为这个准备的:把它像 Bind Event 一样绑到你的处理逻辑上,有新玩家进来时,在回调里对他调用同一个 WelcomePlayer 就行,思路完全一样。
持久化的坑分两类:写代码时的坑,和发布之后才炸的坑。先看写代码时的:
Score := PlayerScore[Player] 直接裸连、不套 Branch,Compile 就会亮红拦下:可能失败的操作必须放在失败上下文(Branch 那样的地方)里。别忘了写存档的 set(Set 节点)也一样要包在 if 里。player,可按钮事件递给你的那个「玩家引脚」是 agent 类型(类似蓝图的 Instigator)——得先用 if (P := player[Agent]): 把它转成 player,再拿去查存档。GetPlayspace().GetPlayers() 拿到的在场玩家数组用 ForEach。再看发布后的:发布那一刻,这张存档表里「每格放什么类型」就被永久锁定了。之后每次发布,引擎都会做一次「向后兼容检查」,新旧对不上就直接发布失败(跟 Compile 报错一样拦住你)。这就是为什么第三节建议「会演化的数据用 class 装」——在所有可持久化类型里,只有 class(蓝图类)留了一条日后还能加东西的通道:
| 发布后想做的事 | 行不行 | 对策 |
|---|---|---|
| 把值类型 int 改成 float,或改动 struct / tuple 结构 | ✗ 兼容检查失败 | 规划期就改用 class 承载 |
| 给 persistable class 加新字段 | ✓ 唯一的演化通道 | 新字段必须带默认值,旧存档加载时自动取默认值 |
| 删掉不再用的 persistable class | ✗ 发布失败 | 旧定义留在代码里退役,新系统另开一个 weak_map |
| 删除玩家的存档数据 | ✗ 无法真正删除 | 只能重置:写回默认值 |
还有两条要在计划期就记在小本本上:每个项目的持久化变量(也就是这些建在类外面、以玩家为键的存档表)数量有上限,目前为 4 个——这个上限历史上调整过(从 2 提升到 4),动手前以当时的官方文档为准。另外,在 UEFN 编辑器的测试会话里验证「跨会话持久化」,常会得出「不生效」的错误结论——真实的跨会话行为,要以发布后(私有版本即可)的表现为准;编辑器会话里的表现随版本有差异,别拿它下结论。
存档这件事,蓝图里你早就做过——只是做法完全是另一种形状。这张表把两边逐项对上,差异那一列才是重点。
| 蓝图里的做法 | Verse 里的写法 | 差异 |
|---|---|---|
| 新建一个 SaveGame 对象蓝图,在里面声明要存的变量 | class<final><persistable> 的持久化类 |
Verse 的持久化类只能有常量字段;SaveGame 对象的变量可以随便改 |
CreateSaveGameObject 造一个存档实例 |
player_profile{ Level := 1, Coins := 0 } 构造一个新实例 |
两边都是「造一份数据」,Verse 这边是普通的类构造表达式 |
SaveGameToSlot(槽位名 + 用户索引) |
if (set PlayerCoins[Player] = 100) {} |
这是最大的分野:Verse 没有「保存」这个动作,写进那张表就等于存了 |
LoadGameFromSlot + Cast 回你的存档类 |
if (Coins := PlayerCoins[Player]): |
不用 Cast、也不会拿到 None:读不到就是「这条线走不通」,由失败上下文接住 |
DoesSaveGameExist 判断是不是新玩家 |
同一句 if 的 else 分支 |
Verse 把「存在吗」和「取出来」合并成了一次判定 |
| 槽位名 / 用户索引自己管理 | 键就是 player,由引擎按账号分 |
没有槽位这个概念,也就没有「槽位名拼错」这一类 bug |
| GameInstance 里的变量:跨关卡活着,进程一关就没 | 模块作用域的 weak_map(player, t) |
GameInstance 是「跨关卡」,持久化变量是「跨会话、跨天、跨版本」——不是一个量级 |
| 改 SaveGame 类的结构:自己写版本号和迁移代码 | 发布时的向后兼容检查自动拦截 | Verse 把「你可能改坏老存档」从运行时问题变成了发布期问题 |
把这张表读完,你会发现真正的差异只有一句话:Verse 的持久化是声明式的。蓝图里,存档是一串你必须亲手接的节点——建对象、填字段、SaveGameToSlot、下次进来 LoadGameFromSlot、Cast、判空。任何一环忘了接,数据就悄悄丢了。Verse 这边,你只需要把数据的形状写对:一张以 player 为键的表、放在类的外面、装的是可持久化的类型。形状对了,存档就是自动的;没有「保存」按钮,也没有「忘了保存」这种 bug。
代价当然也有,而且很实在:声明式意味着你放弃了对时机的控制。什么时候真正落盘、频率多高,都由引擎决定,你插不上手。同时它换来了一批新约束——常量字段、发布即锁定类型、存档表数量有上限、不能遍历。第五节那一串坑,本质上全是「把控制权交出去」的账单。
另外提醒一句别对错号:蓝图里最接近「跨对局数据」的东西是 SaveGame 对象,而不是 GameInstance 变量。GameInstance 只保证数据在切关卡时不丢,进程一关就归零;真正要活过今天的,只有写进磁盘或后端的那份。Verse 的持久化变量对应的是后者。
存档系统验收时间。三道关卡,答错零惩罚,可以一直重试。
想让 VisitCount 这个 weak_map(player, int) 真正跨局保存,它应该声明在哪里?
player_profile 是 class<final><persistable>,想给某玩家的 Coins 加 10,正确姿势是?
项目已经发布。下面哪个存档结构改动能通过向后兼容检查?