Verse Wiki — 写给 UE6 时代的 Verse 互动手册
拓展 · EXTRA

<persistable>:让玩家数据跨局存活

排行榜、金币、通关进度——玩家下线再上线还都在,靠的就是 persistable 修饰符加 weak_map。这一页把「枯燥的 specifier」直接连到你最想做的功能:存档系统。

排行榜、金币、通关进度——玩家下线再上线还都在,靠的就是 persistable 徽章加 weak_map。这一页把「枯燥的尖括号徽章」直接连到你最想做的功能:存档(SaveGame)系统——每个玩家一份、退出重进还在。

一、持久化三件套

普通 Verse 变量的寿命只有一局游戏:回合结束、服务器关闭,一切归零。想让数据跨会话存活,官方给的方案由三个零件组成:

player_save.verse
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> 两枚徽章,里面两个常量 CoinsWins 都给了默认 0。下半段 PlayerSaves 是那本「谁存了什么」的花名册(weak_map),用玩家当索引、存档模板当内容;它放在模块最外层,引擎才会在玩家进出时自动读写它。

为什么只能常量字段?回想 struct 那一课的不可变哲学:持久化数据是要写进玩家档案的,不可变意味着每一份存档都是一个完整、自洽的快照——不会出现「改了一半」的中间状态。

为什么只能放常量、不能放 var?回想 struct 那一课的不可变哲学:存档是要写进玩家档案的,「不可变」意味着每一份存档都是一个完整、自洽的快照——不会出现「改了一半」的中间状态。

二、读与写:不可变更新

字段不可变,那怎么「加金币」?答案是函数式的老配方:构造一个带新值的新实例,整体替换旧的。读和写都走失败上下文——玩家可能还没有存档,weak_map 的读写都是可失败表达式:

字段不能改,那怎么「加金币」?答案是那套「不改旧的、直接换新的」老配方:造一份带新数值的新存档,整体替换掉旧的。读和写都走「可能走不通」的判断——玩家可能压根还没存档,所以从 weak_map 里读、往里写,都是「可能没过」的操作,得用 Branch 接住:

save_station_device.verse
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 ↗