<persistable>:让玩家数据跨局存活
排行榜、金币、通关进度——玩家下线再上线还都在,靠的就是 persistable 修饰符加 weak_map。这一页把「枯燥的 specifier」直接连到你最想做的功能:存档系统。
排行榜、金币、通关进度——玩家下线再上线还都在,靠的就是 persistable 徽章加 weak_map。这一页把「枯燥的尖括号徽章」直接连到你最想做的功能:存档(SaveGame)系统——每个玩家一份、退出重进还在。
一、持久化三件套
普通 Verse 变量的寿命只有一局游戏:回合结束、服务器关闭,一切归零。想让数据跨会话存活,官方给的方案由三个零件组成:
- 一个持久化类:
class<final><persistable>——必须 <final>(不许再被继承),只能含常量字段(不能 var),且每个字段的类型本身也必须可持久化。struct 同样可以标 <persistable>。 - 一个能存档的类:
class<final><persistable>——必须 <final>(不许再被继承、不能再有子蓝图),只能放常量、不能放 var(可改的变量),而且里面每个字段的类型本身也得能存档。结构体(struct)同样可以戴 <persistable>。 - 一个模块级的 weak_map:
var Saves:weak_map(player, t) = map{},以 player 为键,持久化类为值。数据必须住在模块级 var 里,引擎才会替你存取。 - 一个模块级的 weak_map:
var Saves:weak_map(player, t) = map{},以 player(玩家)为键、能存档的类为值。这份数据必须住在模块最外层的 var 里,引擎才会替你自动存取——它就是 Verse 版的、每个玩家一份的 SaveGame。 - 一个牢记的常识:persistable 是 specifier 不是 attribute——写 @persistable 直接编译报错(第 18 课敲过的黑板)。
- 一个牢记的常识:persistable 是尖括号徽章、不是 @ 便签——写成 @persistable 一按 Compile 直接报红(第 18 课敲过的黑板)。
using { /Verse.org/Simulation }
# 存档类:必须 final + persistable,只能有常量字段
player_save := class<final><persistable>:
Coins:int = 0
Wins:int = 0
# 持久化数据必须住在模块级的 weak_map 里
var PlayerSaves:weak_map(player, player_save) = map{}
读图翻译:上半段 player_save 就是「一份存档长什么样」的模板——戴着 <final><persistable> 两枚徽章,里面两个常量 Coins、Wins 都给了默认 0。下半段 PlayerSaves 是那本「谁存了什么」的花名册(weak_map),用玩家当索引、存档模板当内容;它放在模块最外层,引擎才会在玩家进出时自动读写它。
为什么只能常量字段?回想 struct 那一课的不可变哲学:持久化数据是要写进玩家档案的,不可变意味着每一份存档都是一个完整、自洽的快照——不会出现「改了一半」的中间状态。
为什么只能放常量、不能放 var?回想 struct 那一课的不可变哲学:存档是要写进玩家档案的,「不可变」意味着每一份存档都是一个完整、自洽的快照——不会出现「改了一半」的中间状态。
二、读与写:不可变更新
字段不可变,那怎么「加金币」?答案是函数式的老配方:构造一个带新值的新实例,整体替换旧的。读和写都走失败上下文——玩家可能还没有存档,weak_map 的读写都是可失败表达式:
字段不能改,那怎么「加金币」?答案是那套「不改旧的、直接换新的」老配方:造一份带新数值的新存档,整体替换掉旧的。读和写都走「可能走不通」的判断——玩家可能压根还没存档,所以从 weak_map 里读、往里写,都是「可能没过」的操作,得用 Branch 接住:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
save_station_device := class(creative_device):
# 第一次见到玩家:发一份默认存档
InitSave(Player:player):void =
if (not PlayerSaves[Player]):
if (set PlayerSaves[Player] = player_save{}):
Print("新玩家存档已建立")
# 加金币 = 构造新快照,整体替换
GiveCoins(Player:player, Amount:int):void =
if (Old := PlayerSaves[Player]):
New := player_save{Coins := Old.Coins + Amount, Wins := Old.Wins}
if (set PlayerSaves[Player] = New):
Print("已保存,当前金币:{New.Coins}")
读图翻译:InitSave 是「第一次见到某个玩家就发一份空白存档」——先用 Branch 确认他名下还没档(not PlayerSaves[Player]),没有就塞一份默认的 player_save{} 进花名册。GiveCoins 是「加金币」——先取出他的旧存档,照着它造一份 Coins 加了料、Wins 原样的新存档,再整份写回花名册覆盖旧的。全程没有「就地改一个数」,只有「造新的、换旧的」。
两处细节值得停一秒:PlayerSaves[Player] 的读取要用 if 接住(玩家可能没档);set PlayerSaves[Player] = New 同样是可失败表达式,也得进失败上下文。存档的每一步,都踩在你已经学会的失败机制上——这就是 Verse 各知识点咬合的方式。
两处细节值得停一秒:PlayerSaves[Player] 这一读,要用 Branch(if)接住(玩家可能没档);set PlayerSaves[Player] = New 这一写(一个 Set 节点)同样可能没过,也得待在能接住的地方。存档的每一步,都踩在你已经学会的「可能走不通」机制上——这就是 Verse 各知识点咬合的方式。
三、weak_map 的「刻意残废」与版本演进
用惯了普通 map 的你,很快会发现 weak_map 少了一堆能力:不能迭代、没有 Length、不能 ConcatenateMaps——只能拿着 player 键一对一读写。这不是偷工减料,而是刻意设计:持久化数据按玩家分片存储,引擎只在对应玩家在场时加载那一份;不给你「遍历所有玩家档案」的能力,体验才能不随玩家总数膨胀而崩坏(想做全服排行榜,得走别的专用管线)。
用惯了普通 map 的你,很快会发现 weak_map 缩水了一堆能力:不能遍历(ForEach 不了)、拿不到 Length、不能拼接(ConcatenateMaps)——只能攥着某个 player 键一对一地读写。这不是偷工减料,而是刻意设计:存档数据按玩家分片存,引擎只在对应玩家在场时加载他那一份;不给你「把所有玩家的档案挨个翻一遍」的能力,体验才不会随玩家总数膨胀而崩坏(想做全服排行榜,得走别的专用管线)。
另一个真实工程问题:游戏更新了,存档结构要加字段怎么办?官方最佳实践给了两条路:给新字段提供默认值——引擎会把旧版本数据与新结构自动合并,旧玩家上线拿到「旧数据 + 新字段默认值」;更进一步,用 option 类型(?t)声明将来才启用的字段,让存档结构从第一天起就为演进留好接口。设计持久化结构时多想一个版本,上线后就少一次痛苦的数据迁移。
另一个真实工程问题:游戏更新了,存档要加一栏数据怎么办?官方最佳实践给了两条路:给新加的那栏一个默认值——引擎会把旧版本的存档和新结构自动对齐,老玩家上线拿到「旧数据 + 新栏默认值」;更进一步,用 option 类型(?t,表示「可能有、也可能没有」)声明将来才启用的栏位,让存档从第一天起就给以后的扩展留好位置。设计存档结构时多想一个版本,上线后就少一次痛苦的数据搬迁。
下面哪个持久化类能存档的类的写法能通过编译Compile?
为什么 weak_map 不让你迭代遍历(ForEach)、也不给 Length?
本文整理自 Epic 官方文档:Using Persistable Data in Verse ↗、Verse Persistence Best Practices ↗、Struct in Verse ↗。