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

<persistable> 与跨对局数据:让世界记住每一位玩家

第 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):位置就是开关

持久化数据的建法只有一条规矩:在所有蓝图类的外面(这一层叫「模块作用域」)建一个变量,类型选 weak_map(player, t)——把它想成一张以玩家为键的存档表:每位玩家对应表里一格,格子里放你要存的东西(类型 t,得是「可持久化」的,下一节讲哪些合格)。不用勾任何「保存」选项、也不用连额外节点,只要这张表建在类的外面,引擎就会自动替你保存:

coin_storage.verse
# ▢ 声明:必须在模块作用域(类的外面!)
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 挨个去表里查。要做全服排行榜这类需求,得另想办法(见课末拓展阅读)。

三、什么能进存档:可持久化类型与 <persistable> 类

不是什么值都能塞进 weak_map 存档。官方给出的「可持久化类型」清单如下:

类别 类型 条件
基础类型 logicintfloatstring 直接可用
组合类型 enumoptionarraymaptuple 里面装的元素/键值也全部可持久化
自定义类型 <persistable> 说明符的类 必须 class<final><persistable>,只能有常量字段

顺带把名字掰清楚:<persistable> 是一枚尖括号说明符——就是写在 class 名字后面尖括号里的一个开关,和 <final>(不许别人拿它做子蓝图)、<override> 是一伙的;别把它和 @editable(变量上那个 Instance Editable 小眼睛)搞混了,那种是写在字段头上、以 @ 开头的属性。持久化类有两条硬规矩:必须挂 <final>(不许有子蓝图),而且字段只能是常量——一个可改的(var)字段都不许有,字段本身的类型也得是可持久化的。

字段是焊死的常量,那还怎么「改」数据?官方的标准姿势是换而不是改:先读出玩家现在那份存档实例 → 用它的旧值拼一个全新的实例(想改的那个字段填上新值)→ 再用一个 Set 节点把新实例整个写回存档表。下面这段代码挖掉了两个关键词,填回去感受一下完整套路:

player_profile.verse
# 会随版本演化的数据,用 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 的老玩家进场,看数据怎么被读出、更新、写回。

visit_counter_device.verse
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 就行,思路完全一样。

五、常见坑与版本演进:发布那一刻,类型就锁死了

持久化的坑分两类:写代码时的坑,和发布之后才炸的坑。先看写代码时的:

再看发布后的:发布那一刻,这张存档表里「每格放什么类型」就被永久锁定了。之后每次发布,引擎都会做一次「向后兼容检查」,新旧对不上就直接发布失败(跟 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,正确姿势是?

项目已经发布。下面哪个存档结构改动能通过向后兼容检查?

拓展阅读

拔高 · EXTRA

把存档当生产系统设计

官方《Verse Persistence Best Practices》精读:加载失败保护、256 KB 预算、上线前检查清单。

进入拓展 →

技巧 · EXTRA

发布后删不掉的 persistable class

版本演化踩坑实录:为什么旧持久化类删不得,以及并行开新 weak_map 的退役方案。

进入拓展 →

拓展 · EXTRA

端到端实战:持久化统计系统

官方 Persistent Player Statistics 教程 + 社区综合指南,把本课知识落地成可发布的功能。

进入拓展 →