Flecs ECS 游戏开发技术专题全层级18 / 31 已发布

持久化与存档系统:Flecs World 的序列化实战

· 0 分钟阅读
持久化与存档系统:Flecs World 的序列化实战(Persistence & Save Systems: Serialization That Survives Three Versions) — Flecs ECS 技术专题

持久化与存档系统:Flecs World 的序列化实战(Persistence & Save Systems: Serialization That Survives Three Versions)

为什么存档是 ECS 项目里最后被重视、最先让玩家删游戏的那块地基

凌晨两点,一个玩家在 Steam 讨论区发了个压缩包,标题是《读档之后我的城堡变成了一堆散件》。我下下来解压,打开那个 JSON——那是我第一次用文本编辑器直接看自己的游戏世界。四千多个实体,父子关系写得整整齐齐,问题出在我自己身上:城堡的墙用父子关系指向地基,但存档时我把实体的运行期序号原样写了进去。重开游戏,序号全变了,四千面墙一起失去了爹。那一刻我真想给自己一巴掌,因为这事我在教程里读到过,只是当时觉得“我创建实体的顺序是固定的,不会出问题”。

存档在 ECS 项目里有个很尴尬的位置。它不像渲染那么有画面感,不像 AI 那么显得聪明,它就是“把东西写出去再读回来”的体力活。但它有个别的系统都没有的属性:它是你唯一一个把整个游戏世界的解释权交给玩家的系统。玩家打不开你的游戏,他会退款;玩家打不开他玩了三十小时的存档,他会写三千字差评,还会截图,还会在评论里@你。丢档是玩家唯一不原谅的错误,别的一律可以修。

更麻烦的是它的 bug 有个很讨厌的性质:测试里不炸。你自测的时候从新档开始,玩十分钟存一次读一次,一切正常。因为它只在“玩家玩到第八小时、中途删过几个实体、又跨了两个游戏版本”的时候才炸。存档 bug 是一种定时引信特别长的地雷。

ECS 让这件事既变简单又变危险。简单的是——所有状态都躺在同一个 World 里,你至少知道该去哪儿翻;Flecs 还自带把整个 World 导出成 JSON 的能力,原型期一天就能让存档跑通。危险的是——World 是个大澡堂子,什么都往里扔,你把渲染句柄、物理引擎的刚体指针、粒子发射器和玩家的传家宝一起冻进了文件里,它们不分贵贱,一视同仁。OOP 时代那些 private 字段,其实是在替你干“哪些该存”这件脏活;到了 ECS,这个活儿回到你手上。

先说清楚边界:这篇讲的是落盘。第 15 篇讲网络同步时聊过 World 快照,那套东西的验收标准是“快、小、随时可以丢弃重来”,跟存档几乎是反着来的。存档要稳、要能被人打开看、要能在三四个版本之后还认得出来自己。同一个序列化底层,两套完全不同的 KPI。别把回滚的代码直接拿来当存档,你会后悔。

第一层:存什么——把 World 切成「记忆」和「当下」

存档的第一性问题从来不是“用什么格式”,而是“哪些数据属于这个世界,哪些只属于这次运行”。这个问题不解决,格式选得再漂亮也是白搭。

我习惯把 World 里的东西分成四堆:

真状态——玩家亲手改变过、且不能重算出来的东西。建筑的坐标、角色的血量、背包里那把你刷了六小时才掉的剑、任务进度、已经打开过的宝箱。派生状态——能从真状态算出来的东西。寻路网格、碰撞体的包围盒、模型的渲染句柄、AI 当前走到行为树的哪个节点。瞬态——只在这一次运行里有意义。输入缓存、粒子、音频播放器、这一帧的临时实体。调试用——高亮圈、调试名、统计计数。

玩家看不见你这套分类,但他能感知到三个后果:存档多大(他硬盘小、云盘有配额)、读档多快(他一天读十次档)、东西有没有丢(一次就够他给你差评)。这三件事全由第一层决定。

这里有个制度问题值得单独说。Flecs 里组件没有天然的“要不要存”的属性,所有组件在序列化面前平等。我给的建议是:默认不存,只有被显式标记为“持久”的组件才进存档,然后在持续集成里跑一条检查,把所有“既没标持久、也没标瞬态”的组件列出来报警。

为什么我推荐“默认不存”而不是“默认全存、手动排除”?因为我第一个项目就是这么翻车的。你以为瞬态组件只有三五个,实际上你有二十多个:物理刚体、渲染资源、输入状态、音频通道、粒子发射器、调试高亮、加载进度……每加一个新系统都得记得给它挂“不存”的标记,忘一次,存档里就坐着几百个指向野指针的句柄。读档时最好的结果是崩溃,最坏的结果是不崩——但物理不工作了,玩家能穿墙。这种“看起来能玩但处处不对劲”的 bug,比崩溃难查十倍。默认不存,忘了标的后果只是“这个组件没被保存”,那是能一眼看出来的小错。

一个极端案例:Tiny Glade 只存“意图”

Tiny Glade 这座造城堡的游戏,存档只有几 KB。它存的不是城堡的几何数据,而是你做的那些编辑操作和参数——墙在哪儿起、往哪个方向弯、窗户用哪一档样式。读档的时候,程序用这些参数把整座城堡重新生成一遍。

这套方案的成立有个硬前提:世界必须完全可推导。同样的参数加同样的随机种子,必须长出同一座城堡,一个砖缝都不能差。它换来的收益极其诱人:存档小到可以塞进邮件、读档快到像切场景、而且天然对“撤销重做”友好。代价也很硬:你的生成器一旦改动,旧存档重建出来的东西就变了形,玩家一读档发现自己的城堡塌了一个角。所以它必须把生成逻辑的版本号一起冻进存档,甚至要为老版本保留旧的生成分支。这是第一层机制的一个极端解——如果世界可推导,你根本不需要存状态,只需要存意图。但它不是普适答案,它是一道选择题的一个端点,代价转移到了别处。

反面端点:Factorio 全存,而且理直气壮

Factorio 走的是另一头:全量、确定性、二进制,存档随着工厂规模线性膨胀。社区里“我的存档 300MB”是日常话题,玩家互相分享存档像分享一本相册。

它凭什么敢全量?因为它的世界不可推导——玩家的布局是完全自由的,没有任何算法能把那张意大利面传送带还原出来。而且它有一套确定性模拟,全量存档加上确定性重放,是它做多人同步和 bug 复现的基础。你看,同一个问题,两个游戏给出完全相反的答案,原因不是技术口味不同,而是世界本身的“可推导程度”不同。

给你一个粗糙但好用的判断式:能靠参数+种子重建出来的内容占比超过八成,走“存意图”;低于两成,老老实实存状态;中间地带就混合——主干存意图,玩家的自定义装饰存状态。Tiny Glade 如果哪天允许你手绘一面涂鸦墙,那面墙就必须被当状态存下来,因为它是不可推导的。

顺手说一句:这个判断式也能反过来当设计工具用。你想让存档小,那就把更多东西设计成可推导的;你想让玩家的创作有分量,那就得接受存档变大。存档体积其实是玩法自由度的一张账单。

第二层:怎么存——World 级 JSON 导出之后,三个必须你自己擦的屁股

Flecs 提供了把整个 World 导出成 JSON 的能力:实体、组件值、关系、一部分类型信息,一把写成树状结构。开箱能用的好处实在太实在了——你不用给几百个组件手写读写,加个新组件也不用改存档代码。而“加组件就要改存档代码、改了还总忘”正是存档系统最典型的慢性病。

但它替你解决的是“怎么把值写出去”,没替你解决“写出去之后还能不能对得上”。有三个结构性问题,Flecs 不会替你想,你不想它就会在半年后炸。

问题一:实体序号是运行期的,不是永久的

Flecs 里的实体标识是 World 内部的序号,重启、删除、提前创建都会让它变。而你的世界里到处都是引用:墙指向地基、装备指向持有者、任务指向目标 NPC、子弹指向发射者。如果你把这些序号原样写进 JSON,读档时要么靠“创建顺序刚好一致”这种脆得离谱的运气,要么就得映射。

正确做法是几乎所有存档系统的标准姿势:导出时给每个被存档的实体分配一个稳定标识(可以是 GUID,也可以是“类型名+序号”这种人类可读的字符串),同时输出一张“稳定标识 → 世界内实体”的映射表;导入时分两遍走,第一遍把所有实体建出来占位,第二遍拿着映射表把所有引用翻译成新的世界内实体。

为什么要啰嗦这一条?因为原型期它不会出问题。你的实体创建顺序是固定的,序号恰好对得上,于是你得出“不需要映射表”的结论。等玩家在游戏里删了几个实体、你又加了个系统提前创建了一堆管理器实体之后,它才开始坏,而且坏得毫无规律。我第一次见这个 bug 时盯着代码看了两小时,一直以为是自己算错了坐标。

问题二:浮点精度是白送出去的体积

一个位置写成十位小数,等于把“厘米级就够了”的精度膨胀成“纳米级”。玩家看不出 0.0000001 的差别,但文件里每个坐标要多躺好几个字节。把位置类字段量化到两位小数,视觉上基本无损,体积能砍掉三成左右。

例外情况要提前想清楚:如果你的游戏是太空题材或者超大地图,精度需求是分层的——近处要厘米,远处要公里。这时候别搞全局精度,按“所在区域”分档量化,或者干脆用相对坐标。这是另一篇文章的活儿,但你在设计组件的时候得给它留位置,别等到地图扩到十公里才发现坐标全是绝对位置的浮点数。

问题三:JSON 很啰嗦,而“人能打开看”这件事要单独定价

可读的收益是真实的:出问题时你能用文本编辑器打开瞄一眼,能做版本间的差异对比,最妙的是社区能给你写存档编辑器——这对 mod 生态是无价的,很多游戏的寿命是被存档编辑器延长的。代价也真实:体积是同数据的二进制的三到十倍,写盘和解析都吃处理器时间。

我的建议很朴素:开发期用 JSON,发布期 gzip 一下还是 JSON。压缩通常能把文本压到原体积的十分之一上下,而你永远保留了“打开看一眼”的能力。除非你的存档已经到几十 MB 并且写盘时间敏感,否则别急着上二进制。二进制存档的第一个月你会觉得自己很专业,第三个月你会开始写一个“把二进制读成可读格式”的调试工具,然后意识到自己绕了一圈回来。

案例深挖:Flecs Explorer 就是一次 World 导出的实战

Flecs 自带的 Explorer(第 18 篇会细讲它的完整功能)本身就是一个活生生的“World 转 JSON 再转前端”的工程案例。它通过 HTTP 把运行中 World 的状态丢给浏览器,你在网页上能看到实体列表、组件值、关系图,甚至能一边跑游戏一边看数据变化。

这件事对我们有两个直接启发。第一,World 级导出在性能上足够支撑“每秒刷新好几次”的调试场景,说明它的开销在一个可以接受的区间里,你不用一上来就担心它太慢。第二,也是最容易忽略的——Explorer 必须做过滤和分页。它不想把几千个粒子实体全部传到浏览器上去,所以它按组件类型、按数量做了筛选。放到存档里,这就是同一个道理:真实项目里,导出前的过滤是必需能力,你要能说清楚“这次存档只导出哪些组件、哪些实体不参与”。一次导出整个 World 听起来很爽,直到你的 World 里躺着两万个只活一秒的特效实体。

对照案例:Bevy 的场景序列化走的是“注册制”

Bevy 要存场景,所有组件类型必须在启动时登记反射信息,导出时按登记信息逐字段写。这和 Flecs 的路线差别很有意思:注册制换来的是类型安全和自动的版本感知——它知道“这个类型现在是第二版”,代价是“忘了注册”变成一类新 bug,而且这类 bug 在开发期往往不报错,只是那个组件悄悄没被存下来,你玩到一半才发现装饰物全没了。

两条路线没有高下,只有取舍:注册制把风险放在“漏登记”,自动制把风险放在“脏数据”。Flecs 的路线更省事,但你必须自己补上“分类制度”这块,也就是第一层讲的那件事。

第三层:变了怎么办——存档兼容的四道防线

这是存档系统真正的分水岭。第一版存档写得再漂亮,只要你更新了游戏,它就要面对“旧档进新版本”这件事。而这件事没有一次性的解法,只有一套长期纪律。

先把会遇到的变更类型摊开看看,你才知道该防什么:

变更类型典型例子真正的难点
组件加字段武器新增“耐久度”缺的值给什么默认值
组件删字段砍掉“饥饿度”重做生存系统旧数据留不留,玩家回退版本会怎样
组件拆分或改名“生命”拆成“血量”加“护盾”需要映射规则,不是改个名就行
关系替换自定义的“归属”关系换成标准的父子关系结构变了,迁移成本最高
实体类型合并三种宝箱合成一种加标签需要查表,且要考虑玩家已开的箱子
全局规则变化背包从 20 格扩到 30 格(或反过来)溢出的物品去哪儿,最容易丢东西

策略 A:宽容读取——缺什么就落默认值

成本最低,几乎不写代码。缺字段用默认值,多出来的字段忽略。适用于“加字段”这种最高频的变更,也是你能长期依赖的兜底方式。

但这里有一个我付过学费的细节:默认值要选“关闭新机制”的那个值,不是“最合理”的那个值。我给老存档的武器补“耐久 = 100”,想着满耐久多合理啊。结果玩家那把用了三十小时的传说剑,突然开始掉耐久了,他觉得我偷了他的东西。正确的补法是“耐久 = 无限”,让老存档的武器彻底免疫这个新机制,玩家想要新玩法会自己去做新武器。加新机制的时候,让老存档“置身事外”永远是安全的。

策略 B:版本化迁移链——存档头里写版本号

存档里记一个格式版本,加载时按顺序升级:第三版的档先升到第四版,再升到第五版。每一步单独可测,逻辑清楚。

要立两条铁规矩。第一条:每一步迁移必须可重复执行,因为写盘中途崩溃后重试会重跑它,跑两遍把数据改坏就等于没做容灾。第二条:每一步只做一件事,别在同一步里既删字段又改结构,否则出问题时你没法回滚到中间状态排查。这两条听着像废话,但我见过的迁移事故,八成是违反了其中之一。

策略 C:双写兼容期——大改版才动用的重武器

新版本同时能读新旧两种格式,写的时候只写新格式,撑一个版本周期后砍掉旧读取逻辑。适用于战斗系统重做、从关卡制改成开放世界这种伤筋动骨的改动。成本是你要维护两套读取逻辑,收益是玩家不用重开档。

案例深挖:Minecraft 的 DataFixerUpper——把“存档是承诺”写进工程

Mojang 干的事情值得所有做长线游戏的人抄作业:每个存档里带一个数据版本号,游戏内置一条“从任意老版本升到当前版本”的修复链。玩家拿 2013 年的世界进 2024 年的版本,中间十几步迁移自动跑完,村庄还在,箱子里的东西还在。

这套东西的工程量很吓人——它是一个通用的、带类型检查的数据变换框架,不只是“读存档时补个默认值”。它换来的东西也很吓人:一个十几年前买游戏的玩家,今天还能打开当年的世界。这是“存档是一种承诺”这句话最好的注脚。

对我们的启发不是“你也去写一个修复框架”——独立团队写不起,写出来也没人维护。启发是:版本号从第一天就要写进存档,每次发布递增,成本是零,收益是“未来你还保留着做迁移的资格”。反过来,如果你等到第五个版本才想起来加版本号,前面那堆老档你已经分不清谁是谁了,剩下的选项只有“洗档”——而洗档对玩家来说就是“我的世界没了”,跟删号没区别。

案例深挖:RimWorld 的存档宽容度与它的代价

RimWorld 的存档是一坨人类可读的结构化文本,读取时的态度极其宽容:认识的字段就读,不认识的忽略,缺的就落默认值。这套设计最大的收益是 mod 生态——某个 mod 给你的人加了“心情”字段,你把 mod 卸了,老档照样能开,那些数据静静躺在文件里没人读,也不会崩。

代价同样明显:它太宽容了,错误也被一起吃进去。存档结构出问题时它不报错,只是某些东西悄悄变成默认值,玩家玩到一半发现整个殖民地的人都不记得自己会做饭了。这是个很好的提醒——宽容读取必须配一份“异常清单”:把“读到了当前版本不认识的组件或字段”“有实体缺了必填字段”这类事件记下来,加载完成时汇总进日志,开发期打屏幕,发布期写文件。别让存档系统当老好人,老好人最后都变成事故责任人。

那些“不知道该拿它怎么办”的孤儿数据

旧存档里有些组件在当前版本已经不存在了。Flecs 读到这类数据不会崩——数据就是数据,只是没有系统去处理它。这就是“孤儿数据”。要不要在加载后立刻清掉?

我的观点很明确:不要立刻清。留着有两个好处:玩家回退到旧版本还有救;你自己做回滚测试时也需要它。清理应该放在“确认这个版本稳定之后的下一次全量保存”里做,而且要留一条日志,写清楚这次清掉了什么、清掉多少条。玩家的存档里少了一个叫“饥饿度”的东西,他迟早会来问你,你得答得上来。

第四层:存多快、存多稳——增量、原子写与 SQLite 云存档

前三层决定“存得对”,这一层决定“存得住、存得快”。很多项目死在这一层,不是因为技术难,是因为它一直没出问题,直到玩家的世界大到一定程度,写盘开始卡,卡到玩家在自动存档那一秒放下了手柄去倒了杯水。

全量还是增量

全量存档的问题在于它随世界线性增长。你每三分钟存一次,玩家建了五千个物件,每次都在写好几 MB。硬盘不心疼,玩家的帧率和耐心心疼。

增量存档的核心是脏标记:实体被改过就打个标,只写脏的那部分。Minecraft 的区块文件是这个思路的教科书——只保存被修改过的区块,并且把一批区块打包成一个区域文件,避免生成几万个碎文件把文件系统拖死。

但 ECS 里做增量有个容易被忽略的成本:脏标记本身要花钱。你给每个组件都挂“改过了”的标记,等于给每次写入多一次判定,而组件的写入在游戏里是每秒几十万次的热路径。更扎心的是,位置、旋转这类数据每帧都在变,脏标记等于永远脏,那还不如老实全量。

实用的折中:只对低频、大块的数据做脏标记——建筑、地形、任务进度、玩家改过的箱子;高频的数据(位置、朝向、动画状态)在“离开区域”或者“存档点”时全量落一次。再给一个判断阈值:当脏实体占比超过三到四成,增量的簿记成本就超过收益了,直接切全量更快。所以一个健康的增量实现里,一定有一条“降级到全量”的分支。没有这条分支的增量系统,跑久了会越存越慢,最后比全量还慢。

崩溃安全:假设玩家会在你写盘的任意一毫秒里按 Alt+F4

这一条最容易被跳过去,也最容易让玩家直接删游戏。你必须假设:写存档的过程中,进程可能被杀、可能断电、玩家可能强行退出。

  • 永远不要直接覆盖原文件。写到一个临时文件,写完做一次完整校验——能不能解析、实体数量对不对——校验通过再原子地替换掉正式文件。
  • 至少留两份:当前档加上一份好档。玩家遇到坏档的时候,那个“上一份”就是他的救命稻草,也是你的免责声明。只留一份等于没做容灾,这一点我态度很硬。
  • 自动存档要绑关键时刻,不能纯靠计时。打完 Boss、进入新区域、退出游戏前——这些时点玩家能理解“卡一下”。随机的每三十秒卡半秒只会让他骂人,而他的骂声不会指向代码,会指向你的游戏。
  • 给“存不上”做界面。磁盘满、目录没权限、杀毒软件锁住了文件,在玩家机器上都是常态,不是意外。你得有一个能告诉他“这次没存上,请清理空间”的提示,加一个重试按钮。静默失败是最糟糕的选择。

案例深挖:Factorio 的存档卡顿与异步保存

Factorio 的自动存档卡顿是社区里讨论了很多年的话题。它的存档是全量二进制,工厂一大,保存就要几百毫秒甚至更久,玩家在扩建到后期时会明显感觉到每隔一会儿的顿一下。开发组的应对是异步保存加压缩优化,把卡顿压到玩家能忍的范围里。

这个案例的价值在于它证明了:全量存档在“世界巨大”之后一定会撞墙,你早晚要做“保存不阻塞主循环”这件事。而这件事的正确姿势是——保存线程拿到的必须是某一帧的稳定快照,不能是活蹦乱跳、主线程还在改的 World。Flecs 里这意味着你要么在存档前做一次冻结(暂停写入,或者利用延迟命令机制把写入排队),要么复制出一份只读副本。这块跟第 10 篇多线程的边界规则是同一套东西,别在这里另发明一套,两套规则打架是灾难的开端。

SQLite:当你要做云存档和多设备

为什么在存档系统里专门提 SQLite?因为独立游戏一旦要做“云存档加多设备加自动备份”,裸文件立刻露馅。你需要事务(写到一半崩了不留下半个档)、需要部分更新(只同步变化的那几行)、需要查询(哪个槽位最新、哪些实体属于这个区域),这些需求裸文件要一样一样自己造,而 SQLite 一次全给你,而且它就是一个文件,能塞进 Steam 云,也能塞进你自己的对象存储。

落地时的表结构有两个方案。方案一:一个实体一行,所有组件值打包成一块。写起来简单,但任何一个组件变化都要重写整行,增量能力基本为零。方案二:一个组件一行,按组件类型分表,行里带实体标识和格式版本。增量能力强——只更新变化的那几行——但表会很多,迁移时要动很多张表,脑力消耗大。

我的建议是混着来:实体骨架(稳定标识、名字、父子关系)单独一张表,组件按变更频率分组。低频的(建筑、装备、任务)一类一张表,高频的(位置、状态)合并成一张宽表按区域块存。这不是数据库理论,这是“你一年后还得改它”的现实主义。另外别忘了两件小事:开启预写日志模式能让写盘更抗崩;每几百个实体提交一次事务,比一条一提交快十倍以上。

云同步冲突:只有三条路,选一条并写进界面

时间戳赢。最后写入者覆盖。实现最简单,也最容易丢进度——玩家在台式机上肝了三小时,笔记本一上线把台式机的进度盖了。我至今没见过被这个机制伤害过的玩家还能心平气和。

分槽位不覆盖。冲突就生成一份“冲突副本”。安全,但会给玩家留下“我有两个存档,不知道哪个是对的”的困惑。要做就得配一个能看懂的对比:游玩时长、最后位置、关键进度,用人话写。

服务端权威加版本号。每次上传带一个递增版本,服务端拒绝对旧版本的覆盖,客户端提示“你有更新的进度”。最正确,也最复杂。而且你会发现它要求存档本身带版本——你看,又绕回第三层了。

小团队的倾向我替你说了:分槽位,永不静默覆盖。丢档是玩家唯一不原谅的错误,别的都能修。

参数化设计工具:一张组件分类表,一张可调参数表

下面两张表,第一张是制度,不是配置——它规定你的团队怎么给组件分类;第二张是旋钮,是可以调的数值,我把建议区间和对应的坑一起写上了。

表一:组件分类表(贴在你项目文档第一页)

分类判定标准处理方式典型组件
持久玩家能感知到、且不能重算出来的改变存,带稳定标识位置、血量、背包、任务进度、已开启的宝箱
可推导能从持久数据算出来不存,读档后重建寻路网格、碰撞体、模型句柄、包围盒
瞬态只在本次运行有意义不存输入缓存、粒子、音频播放器、加载进度
调试只有开发期需要发布版剔除高亮圈、调试名称、性能计数

配套一条纪律:持续集成里跑一个检查,把所有“未表态”的组件列出来报警。未表态组件数这个指标,比你想的有用——它在项目膨胀期是最早亮起来的红灯。

表二:可调参数与建议区间

参数作用建议区间踩过的坑
自动存档间隔存档频率120–300 秒,或绑关键事件低于 60 秒会打断节奏,玩家会关掉这个功能
单次写盘预算主线程阻塞上限PC ≤ 8ms,移动端 ≤ 4ms超了必须异步化,别再抠别的地方
单档体积上限云盘配额与耐心压缩后 ≤ 5MB超了就该分块或改增量,别硬扛
浮点量化位数控制体积位置 2 位小数,角度 1 位太空或超大地图要做分层精度
版本号粒度触发迁移的时机主版本变才迁移补丁版本也迁移,迁移代码会累死你
保留历史份数容灾2–3 份只留 1 份约等于没做容灾
脏块占比阈值增量还是全量超过 30%–40% 切全量没有降级分支,跑久了比全量还慢
数据库事务批量写吞吐每 100–500 个实体一次提交一条一提交,慢十倍不止
迁移链最大跨度兼容成本≤ 8–10 步,再远考虑洗档链子太长没人测,等于没有
加载异常上报上限异常清单规模同类合并,最多记 50 条不合并会刷爆日志,合并过头会漏线索

这些数字不是圣旨,是我在几个项目里调出来的手感值。你照抄之前先想一件事:你的存档是“玩家玩一小时存一次”,还是“沙盒建造类每五分钟就存一次”?前者可以奢侈,后者每一毫秒都得抠。

两条路:一天能跑通的最小存档,和能撑到上线的存档架构

初级路径:一天跑通,撑得住一千个实体

你要是刚开工,别被上面那些内容吓到。最小可用存档就六步,一天能干完:

一,列一张“必须存”的组件清单,先只存这几样,别贪心。
二,把 World 导出成 JSON,文件头写三个字段:格式版本、游戏版本、保存时间。
三,给每个实体生成稳定标识,同时导出一张父子关系表。
四,读档走两遍:先建实体占位,再翻译所有引用。
五,写临时文件,校验,原子替换,保留上一份。
六,先别做增量,先别碰 SQLite。

这六步做完,你的游戏就能存档了,代码量小到你能一眼看完。什么时候该升级?当你发现存档时间超过一帧预算、或者文件超过几 MB、或者你要做云存档了。到那时候再回来读第三第四层,你会发现每一节都对着一个你已经踩上的坑。

进阶路径:准备上线的存档架构

如果你已经在给发行版做准备了,清单大概是这样的:

  • 组件分类制度落地,加上持续集成里的“未表态组件”报警
  • 分块存档加脏标记,并且有“脏占比过高就切全量”的降级分支
  • 异步写盘,写盘线程拿的是冻结快照,不是活的 World
  • 迁移注册表,每步可重复执行,配一批“考古档”在每次发布前跑一遍
  • 数据库分表、事务批量、预写日志
  • 云同步走分槽位,冲突生成副本,配人能看懂的进度对比
  • 存档的可观测性:体积、耗时、脏块比例、迁移次数、清理掉的孤儿数据条数,全部进日志

最后一条经常被当成锦上添花,其实是雪中送炭。玩家反馈“我的档坏了”的时候,你能拿到的那份日志,决定你是两小时定位还是两天猜谜。第 18 篇讲诊断工具链时会再展开,这里先记住一句话:存档系统的可观测性,是给未来的自己留的证据。

常见坑:这八个地方,我踩过至少五个

坑一:把运行期的实体序号写进存档。这是最经典的死法,也是我开头那个故事的起因。只要你的实体会被删除、会被提前创建,序号就一定会漂。稳定标识加两遍走的映射,不贵,别省。

坑二:把资源句柄当组件存进去。刚体指针、纹理句柄、音频通道,这些是“这次运行里的地址”,换个进程就是一堆垃圾数字。它们属于瞬态,第一层就要清出去。

坑三:第一版没写版本号。成本一行,收益是你未来还能做迁移。没写,你就只剩洗档一条路,而洗档在玩家眼里等于删号。

坑四:拿“文件修改时间”判断云存档谁新。跨设备的时间戳不可信,时区、系统时间被改、同步工具的写入行为,都会让它撒谎。存档内容里自带的版本号或时间才是真相。

坑五:迁移脚本不能重复执行。写盘崩了重试,迁移跑两遍,数据翻倍。测试时一定要故意在迁移中途杀进程,看它能不能自愈。

坑六:删了组件,既不清理也不记录。孤儿数据会一直陪着你,占体积、混淆排查。清理可以晚点做,但要记账。

坑七:自动存档只靠计时器。纯计时的自动存档,永远会在玩家最不想被打断的那一刻触发。绑事件,或者至少在战斗、过场、对话里暂停计时。

坑八:假设写盘一定成功。磁盘满、权限不足、杀毒软件锁文件,在玩家机器上是日常。写失败要有界面、要有重试、要有“上一份好档”兜底。

剩下一个不算坑,算争议:存档要不要防玩家改?我的态度是单机别防,把它当成 mod 生态的入口,很多游戏的第二春是被存档编辑器点着的。联机当然要防,但那是服务端权威的事,跟本地存档不是一个战场——你在本地存档上做的任何防改,最终都只是给正人君子添麻烦。

FAQ

整个 World 导 JSON 到底多慢,多大算大?

对独立游戏常见的规模——几千个持久实体——同步导出通常在几毫秒到几十毫秒的区间,原型期完全够用。真正变慢的不是实体数,是“你导出了不该导出的东西”:几万个一秒生命周期特效实体、每帧都在变的位置数据、只为调试存在的组件。先做过滤,再看性能。等你过滤完还嫌慢,再考虑分块和异步。

用 JSON 是不是就得放弃二进制性能?

不是二选一。压缩后的 JSON 在体积上能追到二进制的一半以内,而解析开销在你做了分块和异步之后通常不是瓶颈。真正需要二进制的是那些存档动辄几十上百 MB、写盘时间被玩家反复投诉的项目。先上 JSON 拿到可读性和 mod 友好度,等体积真的成为问题时再换,那时候你已经有版本号和迁移链,换格式本身只是一次迁移。

派生状态真的一点都不存吗?重算一次要两秒怎么办?

可以存,但要把它当“缓存”而不是“真相”。意思是它必须带一个校验标记——比如生成它时的世界版本号——读档后先校验,对得上就用,对不上就重算。这样它坏了只是慢一次,不会让你的世界出错。分不清“缓存”和“真相”,是存档系统最贵的一类错误。

玩家拿旧档进新版本,组件没了会崩吗?

Flecs 不会因为存档里多出来一些当前版本不认识的组件就崩,数据就是数据,它静静躺着。真正会崩的是你的系统——系统默认“这个实体一定有某个组件”,直接去取,取到空,然后炸。所以防御点应该在系统的入口(这个实体是否具备了我要的全部组件),而不是在加载器里做一堆特判。加载器只负责把数据搬进来,并且把“我不认识这些”记进异常清单。

存档冲突到底怎么定,能不能自动合并?

能自动合并的前提是数据可交换——比如“玩家收集了哪些图鉴条目”这种集合型数据,合并很安全。但“角色在哪儿、任务做到哪一步”这类顺序相关的状态,自动合并必然产生一个玩家没经历过的世界,那是更糟的体验。所以我建议:默认分槽不合并,把选择权交回玩家,并且给他足够的信息去选。自动合并听起来聪明,实际上是把你的困惑转嫁给了玩家。

最后留个问题给你,也是我每次立项都会问自己的:你现在这个存档,能活过三个大版本吗?如果答案是不确定,那今天就去把版本号加进文件头——这是你唯一还来得及做、而且几乎不花成本的事。等到玩家把三千字差评贴出来的时候,你做的所有补救都只是在回信,不是在修复。

文章标签
Flecs ECSEntity Component SystemC++ ECSArchetype ECSSparse Set ECSEnTTBevy ECSUnity DOTSECS 框架对比Flecs RelationshipsFlecs QueryFlecs Pipeline
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:Flecs ECS 游戏开发技术专题