做 Godot 项目做久了,你会发现“存档”是所有游戏绕不过去的一道坎。不管是玩家进度、角色属性、背包物品,还是音量分辨率,你总得找个地方把数据记下来。网上关于 Godot 存档的教程不算少,但多数只教一种写法,要么贴一段代码让你照抄,根本没说清什么时候该用 Config,什么时候该上 JSON,什么时候值得自己写二进制格式。这篇就是来补这个空的。
我会从需求分类讲起,把 Config、JSON、自定义三种方案做一次完整对比,再给每套方案写可以直接抄进项目的 GDScript 代码,最后把我踩过的坑、存档版本迁移、文件损坏恢复这些事一起说清楚。刚接触 Godot 4 的新手可以把这篇当作存档入门参考;已经会写基础逻辑但正纠结选型的开发者,也能在方案对比和避坑部分找到你要的东西。
1. 选型之前先做需求分类:你的存档属于哪一类
看过的求助帖越多,越觉得很多人不是不会写存档,而是没想过自己到底需要哪种存档。需求不确定,方案自然没法选。
1.1 先回答三个问题:存什么、多久存一次、谁要读
第一个问题是“存什么”。你只需要存几个设置项,比如分辨率、音量、语言,还是需要存一个包含玩家坐标、血量、物品栏、任务进度、NPC 状态的复杂结构?这两种需求对格式的要求完全不同。
第二个问题是“多久存一次”。是一进入游戏读一次、退出前写一次,还是每次玩家切场景、拾取物品、打开宝箱都要写入?写入频率越高,越要考虑文件体积和读写耗时,否则玩家会明显感觉到卡顿。
第三个问题是“谁要读”。这里说的“谁”包括机器和你自己。你的游戏代码要读,这是最基本的。但如果以后你想写一个存档查看器,或者玩家希望手动修改配置文件,那存档格式最好是可读的文本格式;如果存档只是给程序自己用,二进制格式反而更省空间。
把这三个问题的答案写在纸面上,选型其实已经完成一大半。
1.2 自带方案的优势与边界
Godot 提供的存档手段很直白:ConfigFile 适合做键值对配置,JSON 适合做结构化数据,FileAccess 配合自定义格式则能处理高性能和大体量场景。很多刚接触引擎的开发者容易犯一个误区,觉得自定义格式显得“专业”,一上来就用二进制,结果存取逻辑写了一堆,最后发现需求只是多存几个设置项,维护成本白白抬高了。
反过来也有另一种极端:所有数据不分青紅皂白全塞 JSON,哪怕只是一张地图上几千个敌人的位置,每次自动存档都要序列化整个字典,存档文件越来越大,加载越来越慢。这不是 JSON 的问题,而是需求分类没做好。
我自己习惯先画一张表,把项目里的数据项逐条列进去:字段名、类型、写入频率、是否需要玩家可读、是否敏感。表画完,三种方案各自对应哪些行,就已经一目了然了。
2. Config、JSON、自定义:三套方案的取舍逻辑
明确了需求,再来看三套方案背后的设计逻辑。理解它们为什么存在,你才不会用错。
2.1 ConfigFile:为“人类可读的键值配置”而生
ConfigFile 在 Godot 里的本质是一个 INI 风格的文本文件,由“节(section)”和“键(key)”组成。打开文件你就能直接看到:
[display] resolution="1920x1080" fullscreen=true [audio] master_volume=0.8这种格式的好处非常明显:玩家可以打开存档文件直接改配置,你调试的时候也能一眼看出数据有没有写对,不用经过任何转换。它的缺点也很明显:表达复杂嵌套结构非常别扭。
举例来说,如果你想存一个物品栏,里面每个物品有 id、数量、耐久度,用 ConfigFile 写出来的效果大概是[items]下一堆item_0_id、item_0_count、item_0_durability之类的键。读取的时候还要写循环去拼这些键名,既啰嗦又容易出错。
所以我的结论是:ConfigFile 最适合“全局设置类”数据,比如音量、画质、键位、语言。它不是给你存复杂进度用的。
2.2 JSON:表达复杂结构,代价是可读性下降
JSON 是文本格式里表达复杂结构最自然的选择。字典嵌套数组、数组嵌套字典,一套写下来结构非常清晰。Godot 4 对 JSON 的支持也做得不错,序列化和反序列化各一个方法就能完成。
JSON 比 ConfigFile 更灵活,但有两个需要注意的地方。第一个是文件体积会偏大,因为每个键名、每个括号都要占字节;第二个是解析后得到的都是基础类型,JSON 里没有 Vector2、Vector3、Color 这些 Godot 内部类型,你需要自己把坐标、颜色转成数组存进去,读取时再转回来。
这套“存取时手动转换”的功夫省不了,但比 ConfigFile 硬拼键名要容易维护得多。
2.3 自定义格式:用复杂度换体积和速度
当你存的数据不是给人看的,而是希望在尽量小的体积、尽量快的速度下被程序读回去,就该考虑自定义二进制格式了。用 FileAccess 把数字按固定字节数写进文件,读出来的时候按相同规则解析,整个过程没有任何字符串格式化的开销。
但这种方案的前期成本很高,你必须自己设计布局:前几个字节存什么,每个字段多大,怎么处理版本差异,文件损坏了怎么判断。而且一旦设计失误,后面改格式的代价远比 JSON 大。
2.4 快速选型对照表
| 判断维度 | ConfigFile | JSON | 自定义二进制 |
|---|---|---|---|
| 数据结构复杂度 | 低,适合键值 | 中高,适合嵌套 | 任意,但需要自设计 |
| 文件可读性 | 很好 | 较好 | 几乎不可读 |
| 读写性能 | 一般 | 一般 | 最好 |
| 文件体积 | 偏大 | 偏大 | 最小 |
| 调试便利性 | 很好 | 好 | 差,需要工具辅助 |
| 版本迁移成本 | 低 | 中 | 高 |
| 典型用途 | 设置项、轻量进度 | 玩家存档、任务状态 | 大数据量、频繁自动存档 |
看到这张表,你应该能理解为什么大多数中小型游戏项目最终都会选 JSON:它处在“表达能力和维护成本”的最佳平衡点。Config 管设置,JSON 管进度,自定义留给真正需要它的场景。
3. ConfigFile 实战:从设置到轻量进度的完整写法
3.1 保存设置项:基础写法与你容易忽略的检查
先来一套最常规的写法。假设你要保存音量、全屏开关和窗口分辨率:
const SETTINGS_PATH := "user://settings.cfg" func save_settings(resolution: Vector2i, volume: float, fullscreen: bool) -> Error: var cfg := ConfigFile.new() cfg.set_value("display", "resolution", resolution) cfg.set_value("audio", "master_volume", volume) cfg.set_value("audio", "fullscreen", fullscreen) return cfg.save(SETTINGS_PATH)这里的set_value有三个参数:节名、键名、值。节名可以用来区分不同模块,避免所有键堆在一起。cfg.save()返回一个 Error 枚举值,很多教程会忽略这个返回值,但项目上线后你很可能遇到磁盘满、权限不足等情况,不检查返回值,存档写失败了你都不知道。
读取的写法是对称的:
func load_settings() -> Dictionary: var cfg := ConfigFile.new() var err := cfg.load(SETTINGS_PATH) if err != OK: push_warning("配置文件不存在或读取失败,使用默认设置") return { "resolution": Vector2i(1280, 720), "volume": 0.8, "fullscreen": false } var result := {} result["resolution"] = cfg.get_value("display", "resolution", Vector2i(1280, 720)) result["volume"] = cfg.get_value("audio", "master_volume", 0.8) result["fullscreen"] = cfg.get_value("audio", "fullscreen", false) return resultget_value的第三个参数是默认值。当键不存在或者类型对不上时,直接返回默认值,这比先判断键是否存在再取值要省事得多。
3.2 我用 ConfigFile 踩过的类型坑
ConfigFile 表面上存的是 “Variant”,但写进文件时会经过文本化处理。整数、浮点数、字符串、布尔值这类基础类型是最稳妥的。复合类型就要小心了,我早期试过直接存 Vector2:
cfg.set_value("player", "position", Vector2(100, 200))当时确实把内容存进去了,读出来的结果也正常。但后来升级 Godot 小版本,发现同一份存档在旧版本写、新版本读,Vector2 被当成了一个包含两个元素的浮点数组,代码里直接当 Vector2 用就出错了。这种问题非常隐蔽,不仔细打印类型根本看不出来。
所以我的建议是:用 ConfigFile 时,复合类型一律拆成基础值去存。坐标就拆成pos_x、pos_y,颜色就拆成r、g、b。虽然写起来繁琐,但换来的是长期的稳定。
如果你存的是一个字典或者数组,ConfigFile 在 Godot 4 里其实可以保留结构,但读取时返回的可能是Dictionary或Array,而不是深层类型完全还原的副本。与其依赖这种不确定行为,不如遇到复杂结构直接用 JSON。
4. JSON 实操:复杂存档结构与版本迁移
4.1 一个可以直接抄的 JSON 保存模板
先给一套通用模板。这段代码会把你游戏里的存档数据统一序列化,写入user://save.json:
const SAVE_PATH := "user://save.json" const SAVE_VERSION := 1 func save_game(player_data: Dictionary, world_data: Dictionary, items: Array) -> Error: var payload := { "version": SAVE_VERSION, "player": player_data, "world": world_data, "items": items, "saved_at": Time.get_datetime_string_from_system() } var json_str := JSON.stringify(payload, "\t") var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: return FileAccess.get_open_error() file.store_string(json_str) file.close() return OK要点有三个。第一,JSON.stringify的第二个参数传"\t",这样生成的 JSON 文件每个层级都会缩进,用文本编辑器打开能直接阅读。如果存档体积很大、追求性能,可以去掉这个参数,文件会更紧凑。
第二,存档里一定要放version字段。随着开发进行,你的存档结构一定会变,版本号是以后做迁移逻辑的唯一依据。没有版本号的存档,升级到一半基本等于作废。
第三,Time.get_datetime_string_from_system()这类时间信息虽然不影响游戏逻辑,但调试时非常好用,玩家反馈存档丢失时,你能从文件末尾看到最后一次保存时间。
4.2 加载 JSON 与强类型恢复
读取端这样写:
func load_game() -> Dictionary: var file := FileAccess.open(SAVE_PATH, FileAccess.READ) if file == null: return {} var json := JSON.new() var err := json.parse(file.get_as_text()) file.close() if err != OK: push_error("存档解析失败:%s(第%d行)" % [json.get_error_message(), json.get_error_line()]) return {} if not json.data is Dictionary: push_error("存档根节点不是字典,拒绝加载") return {} return json.data这里必须强调一点:JSON.parse返回的是一个 Variant 数据,虽然你写的时候是 Dictionary,但一定要做类型检查。我见过不止一次因为存档文件被外界改坏,解析出的结果变成了字符串或数组,代码往下直接按字典取键名,立刻崩溃。
JSON 里没有 Godot 自带的向量类型,所以你的存档数据在放进字典前要手动转换。举个例子:
func save_position(pos: Vector3) -> Array: return [pos.x, pos.y, pos.z] func load_position(data: Array) -> Vector3: return Vector3(data[0], data[1], data[2])这类小函数建议封装在一个独立的存档工具类里,永远不要在业务逻辑里直接写裸的数组下标转换,否则后期维护你会想骂人。
4.3 版本迁移:升级存档结构的标准流程
开发过程中最常遇到的痛点是:你已经在游戏里加了新的武器系统,但玩家手里的存档还是旧结构,没有对应字段。最直接的报错方式就是读取时访问不存在的键。
标准做法是写一个迁移函数。假设旧版本是version=1,新版本version=2:
func migrate_data(data: Dictionary) -> Dictionary: var version: int = data.get("version", 0) if version < 2: # 旧版本没有武器列表,补一个默认空列表 if not data.has("weapons"): data["weapons"] = [] data["version"] = 2 return data读档流程变成:先解析原始数据,再调用迁移函数,最后才交给游戏逻辑使用。每次调整数据结构时,只需要在迁移函数里追加一段if version < N的分支,保证旧版本存档能逐级升上来。
我强烈建议迁移函数里的每个分支都写清楚注释,说明这次迁移是补了哪个字段、为什么补。项目做了半年以后,你不可能记得每个字段的来历。
4.4 JSON 文件放到编辑器里预览的小技巧
调试 JSON 存档,最好在 Godot 编辑器里直接打开真实路径。在任意脚本里运行:
print(ProjectSettings.globalize_path("user://"))这行代码会输出当前平台user://对应的真实目录。在 Windows 上通常是%APPDATA%\Godot\app_userdata\你的项目名,在 Linux 上是~/.local/share/godot/app_userdata/...。配合编辑器的文件系统面板,或者在系统文件管理器里打开这个目录,你可以随时检查存档文件的实际内容,排查问题能快很多。
5. 自定义二进制实操:体积、压缩与防篡改思路
5.1 什么时候值得自己写二进制格式
如果游戏里有一张很大的地图,需要记录成千上万个可破坏物、敌人刷新点,每次自动存档都序列化成一个巨大的 JSON 数组,文件轻松涨到十几甚至几十 MB,加载时还要把整段大文本解析成对象,那种卡顿你在开发机上可能感受不明显,玩家机器上就会很明显。
这种场景才值得上自定义二进制格式。你按固定字节数写数字,每个敌人只占几百字节,寺节点和括号全部消失,文件体积能压到 JSON 的十分之一。同时二进制读写速度极快,没有字符串解析过程,适合频繁自动存档。
代价也摆在明面:文件不可读,出问题难排查;格式一旦定下,升级要格外小心,因为你不再有“缺个键就补默认值”的容错,读错一个字节,后面的所有字段都会错位。
5.2 带版本字段的二进制存档模板
先给一个最简单的模板:
const SAVE_PATH := "user://save.bin" const SAVE_VERSION := 1 func save_binary(best_score: int, player_name: String, position: Vector2) -> Error: var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: return FileAccess.get_open_error() file.store_32(SAVE_VERSION) file.store_32(best_score) file.store_pascal_string(player_name) file.store_float(position.x) file.store_float(position.y) file.close() return OK func load_binary() -> Dictionary: if not FileAccess.file_exists(SAVE_PATH): return {} var file := FileAccess.open(SAVE_PATH, FileAccess.READ) if file == null: return {} var version: int = file.get_32() if version != SAVE_VERSION: push_error("存档版本 %d 与当前版本 %d 不一致" % [version, SAVE_VERSION]) file.close() return {} var result := { "best_score": file.get_32(), "player_name": file.get_pascal_string(), "position": Vector2(file.get_float(), file.get_float()) } file.close() return resultstore_pascal_string很有意思,它会先写一个长度字节,再写字符串内容。读取时用对应的get_pascal_string,引擎自动知道要读多长的字符串,省得你自己处理长度分配。
这是最简单的手工布局。如果你有大量同构数据,比如怪物列表,可以在一个固定区域里循环写入:
for monster in monsters: file.store_16(monster.type_id) file.store_float(monster.position.x) file.store_float(monster.position.y) file.store_8(monster.hp)每个怪物固定 8 个字节,加载时你甚至可以根据文件大小直接算出有多少个怪物,不需要额外存数量。这种紧凑度是 JSON 完全做不到的。
5.3 使用 store_var 的便利与陷阱
Godot 的 FileAccess 还提供了一个更省事的接口:store_var和get_var。你可以直接存一个字典进去,引擎自动处理类型信息:
file.store_var(player_data_dict)读取:
var data: Variant = file.get_var()听着很完美,对吧?但有两个重要警告。
第一,存入的字典里的Vector3、Color等类型可以直接保留,比 JSON 方便得多。但如果你的字典里包含某个对象实例,比如不小心把某个 Node 引用存进去了,那么读取时引擎需要重新构造一个对象,这往往不是你想要的,还可能因为脚本未加载而报错。所以我的建议是:存储内容只放纯数据,绝不放节点引用或资源对象。如果你的数据结构里包含这些,用full_objects=false的默认参数也救不了你。
第二,store_var的格式是引擎内部的序列化格式,不保证跨大版本兼容。你年初用一个 Godot 4.2 版本存的文件,到了年中升级到 Godot 4.4,有可能读不出来或格式有微妙变化。所以即使使用store_var,也一定要在文件开头单独写一个可读明文标记和版本号,确认版本后才继续读取。
5.4 压缩与防篡改的真实经验
二进制方案经常伴随着压缩需求。Godot 4 自带了ZIPReader和ZIPWriter,可以把多个存档文件打包压缩,或者对单个大文件做压缩后写入。
不过我的建议是,如果你只是想把一个大 JSON 压小一点,先别急着设计一套自有压缩格式。先用 JSON 自带的紧凑模式序列化,再包一层压缩,很多时候体积就已经降下来了。只有当你连序列化到字符串的时间都嫌长,才需要考虑直接写二进制。
防篡改是一个经常被误会的点。你可以在存档末尾写一个校验和,比如对前面所有字节做一次循环冗余校验,加载时重新算一遍。这能防止文件被传输出错、或者玩家手动改文件后加载报错。但它是防“误改”和“损坏”,不是防“作弊”。真正想做反作弊,必须在服务器端校验关键数值,本地任何加密手段都只能提高修改门槛,做不到绝对安全。如果你只是做一个单机游戏,我的建议是不要在加密上花太多时间,玩家改存档是社区生态的一部分,有时候反而能延长游戏寿命。
6. 常见存档问题的排查与恢复技巧
无论你选哪种格式,总会遇到同样的毛病。这一节挑几个我实际踩过的坑说说,希望能让你少走弯路。
6.1 user:// 路径到底在哪里
很多新手第一次打开线上日志,看到报错里的user://会很蒙。这不是项目目录里的某个文件夹,而是引擎根据操作系统自动分配的“用户数据目录”。它在每个平台上的位置都不同,Windows 上通常在%APPDATA%,macOS 在~/Library/Application Support/Godot,Linux 则在~/.local/share/godot。
你在项目设置里选择的游戏名称,会作为子目录出现在其中。把这个路径当作一个抽象的、跨平台的可写区域就好。永远不要硬编码绝对路径,否则换个机器存档就会丢失。
6.2 读档时最容易崩的一句话
没有做解析结果检查就强制类型断言,是最常见的崩溃点。JSON 解析出来的json.data可能是Dictionary,也可能是Array、String、float,甚至null。遇到被玩家手动改坏的存档文件、或编辑器里残留的旧模板文件,解析结果很容易不符合你的预期。
所以加载函数的开头,一定要对根节点的 type 做防御性判断,然后对每个关键子字段都使用data.get("key", 默认值)的形式,而不是直接data["key"]。这样即使缺失字段也不会直接崩溃,顶多回落到默认值。
6.3 文件写一半断电导致存档损坏
游戏崩溃或断电时,存档文件可能只写入了一半。下次启动读档,解析就会报错。
我现在的做法是:先写一个临时文件,确认写成功后再覆盖正式文件。
const TMP_PATH := SAVE_PATH + ".tmp" func save_game_safely(payload: Dictionary) -> Error: var json_str := JSON.stringify(payload, "\t") var file := FileAccess.open(TMP_PATH, FileAccess.WRITE) if file == null: return FileAccess.get_open_error() file.store_string(json_str) file.close() # 先移除旧文件,再重命名临时文件 if FileAccess.file_exists(SAVE_PATH): DirAccess.remove_absolute(SAVE_PATH) var err := DirAccess.rename_absolute(TMP_PATH, SAVE_PATH) return err这样一来,“写半个文件”只可能发生在临时文件上,正式存档要么是完整的旧版本,要么是全新的完整版本,不会出现中间状态。这个思路在 PC、主机平台都适用,成本又很低,强烈建议加入到你的存档管理里。
6.4 关闭游戏时保存,但节点已经没了
我踩过的一个经典坑是在NOTIFICATION_WM_CLOSE_REQUEST里访问场景中的节点属性,结果某些节点已经被释放,一取属性就报错。
如果你要做一个“退出前自动保存”的功能,尽量把保存逻辑放在单例(Autoload)节点里,并且在一进入退出流程时立刻保存,不要依赖具体场景节点的存在。更保险的做法是把“自动保存”分散到关键节点变化时,比如玩家拾取物品、完成任务、进入新区域时就写存档,而不是只把希望寄托在退出时的最后一次写入。
7. 选型到落地:我的固定做法与额外建议
最后再分享一点我的个人习惯。现在接到新项目,我第一反应已经不再是追求某种酷炫格式,而是先列需求。纯设置项直接 Config,复杂进度和玩家状态统一 JSON,只有数据量大到影响性能才上自定义二进制。版本字段永远是第一个写的数据,迁移逻辑永远和读档逻辑绑在一起,这让我后面改结构时不用再对着旧存档猜半天。
存档本质上是一个数据接口,你要保护的并不是某个格式本身,而是当需求变化后,你的玩家数据还能不能平滑地升级到新版本。把版本管理和容错做好,比纠结用哪种格式重要得多。希望这篇能帮你少踩几个我踩过的坑。