Verse Wiki — 写给蓝图作者的 Verse 手册
拓展 · EXTRA

把存档当生产系统设计:官方持久化最佳实践精读

存档不是普通变量,它是要陪玩家活很多年的生产数据。Epic 专门写了一页《Verse Persistence Best Practices》,本文把它拆成四个可执行的设计原则,外加一份上线前检查清单。

一、加载失败 = 拒之门外:先理解这条保护线

官方文档里最容易被略读、却最能改变你设计思路的一句话是:玩家加入之前,引擎会先加载他的持久化数据;如果加载失败,这位玩家会被禁止加入。乍看很粗暴——为什么不让他用默认值先玩着?官方的解释是:这是防止存档被覆盖的保护措施。想象一下,如果允许「加载失败就当新玩家」,那么一次网络抖动就可能让老玩家以空白存档进场,你的代码兢兢业业地把「金币 0」写回去——真存档就这样被合法地冲掉了。

对创作者的推论有两条。第一,你不需要(也不应该)为「存档半加载」状态写防御代码:只要玩家在场,他的数据就是完整加载过的,读不到某位玩家的记录只意味着「他是新玩家」,而不是「加载坏了」。第二,存档结构越稳健、越小,加载失败的概率就越低——这直接引出下一节的体积预算。

二、256 KB 的预算怎么花:控体积、降频率

每位玩家在单个持久化变量下的记录上限是 256 KB,超出则保存失败并抛出 Verse 运行时错误。256 KB 听着不小,但「把整局的事件日志塞进存档数组」这种写法可以在几十局内把它吃穿。官方建议是两手抓:

write_discipline.verse
# ✗ 反面教材:每帧把位置塞进存档数组
# loop:
#     Sleep(0.0)
#     if (set TravelLog[Player] = ...) {}    # 高频写入 + 体积无限增长

# ✓ 正确姿势:回合结束时,一次性写入"结论"
OnRoundEnd(Player:player, RoundScore:int):void =
    var Best:int = RoundScore
    if (Old := BestScore[Player], Old > RoundScore):
        set Best = Old
    if (set BestScore[Player] = Best) {}

读图翻译:下半段的 OnRoundEnd 就是「回合一结束才写一次」的正确姿势——先把本局分数 RoundScore 放进一个变量 Best;再用一个 Branch 查这位玩家的历史最好成绩,如果查到了、而且旧成绩比这局还高,就把 Best 换回旧的(保留更高分);最后用一个 Set 把 Best 写回存档表。整套逻辑只在回合结束时跑一次,不在每帧里连。

三、给未来留门:class 是唯一会长大的容器

基础课第五节讲过发布锁定:发布那一刻,存档表里「每格放什么类型」就参加了终身制的向后兼容检查。最佳实践页把这条规则升格成设计原则:凡是可能随版本演化的数据,从第一天起就用 class(蓝图类)来装,而不是光秃秃塞一个 int、struct 或 tuple。因为在所有可持久化类型里,只有 class 支持「发布后再追加带默认值的字段」——光存一个 int,想日后升级成「int + 时间戳」?没有通道,只能另开一张存档表,而每个项目的存档表数量有硬上限,这份预算比你想象中珍贵。

官方的 Speedway Race 模板就是这条原则的活例子:它用一个持久化的统计类通过 PlayerStatsMap 保存比赛成绩,后来官方给模板加新功能时,靠的正是「追加带默认值的字段」这条演化通道。理论在最佳实践页,实例在模板里,两边对照着读收获最大。

四、上线前检查清单

把整页最佳实践浓缩成四问,发布前逐条打钩:

某玩家进场时,他的持久化数据加载失败了。按官方设计,会发生什么?

来源

本文整理自 Epic 官方文档与官方博客: