
做游戏做到后半程才补存档功能的开发者通常会在一小时内同时遇到三个问题用 FileAccess 写出来的文件人看不懂存档结构里加了两个字段后老存档全部作废玩家用记事本改两个数字测试存档就崩了。这些问题都不是“写文件”本身难而是很多人把文件读写、数据序列化、存档加密混成了一件事。Godot 4 里保存/加载系统的正确拆法应该是FileAccess 负责读写文件JSON 负责把游戏状态变成人和机器都能读的文本加密负责让玩家不能顺手改存档。三者组合起来就是 2025 年独立游戏和小团队最实用的存档方案。本文会从零实现一套带版本号、带异常处理、可选 AES 加密的存档系统并在最后给出生产环境建议。读完之后你能直接回答这几件事存档文件应该放 user:// 还是 res://为什么 Vector2 不能直接塞进 JSONENCRYPTED 和 ENCRYPTED_EXT 有什么区别存档写一半崩溃怎么办玩家改坏存档怎么优雅降级。1. 这篇文章真正要解决的问题先给结论Godot 4 的存档功能本身不难难的是把“数据结构设计”和“异常情况处理”考虑周全。具体来说常见的坑有三个。第一个坑是路径。新手习惯把存档写到 res:// 下编辑器里跑得好好的一导出游戏存档就写不进去。原因很简单res:// 在导出后是只读的。第二个坑是类型。JSON 只能表达 Dictionary、Array、字符串、数字、布尔和 null。而游戏状态里到处是 Vector2、Vector3、Color、Transform3D。直接把这些变量塞进 JSON.stringify序列化结果不是你想的那样加载回来更是对不上。第三个坑是兼容性。游戏迭代版本会改存档结构如果不写版本号老存档在更新后轻则丢字段重则解析失败。这个问题在正式项目里基本必现越早设计越省事。这篇文章的读者画像也很清晰在用 Godot 4 做 2D/3D 游戏的开发者尤其是第一次做存档系统的开发者以及从 Godot 3 迁移过来的开发者。Godot 3 里 File 类的那套写法在 Godot 4 里已经不能用需要重新理解 FileAccess 的语义。下面所有代码都基于 GDScript 和 Godot 4.x版本差异会用文字单独标出。2. Godot 4 保存/加载的核心概念2.1 user:// 与 res:// 路径的区别Godot 4 的项目文件路径有两类前缀理解它们的区别是存档系统的第一课res:// 指向项目资源目录。运行导出版时这个目录是只读的所以只能放代码、场景、纹理这些随包分发的资源。user:// 指向当前系统用户的持久数据目录。每个玩家的机器上都不一样Windows 下通常在 AppData 相关目录macOS 下在 Library/Application SupportLinux 下在 ~/.local/share。游戏存档、设置、日志都放这里。判断方法很简单凡是运行时需要“写”的数据一律走 user://。存档属于典型的需要写的数据所以本文所有保存路径都以 user:// 开头。2.2 FileAccess 与读写模式Godot 4 用 FileAccess 类替代了 Godot 3 的 File 类。打开文件用 FileAccess.open(path, mode)返回一个 FileAccess 对象如果返回 null说明打开失败用 FileAccess.get_open_error() 拿到具体错误码。常用的 mode 有READ只读。WRITE写入覆盖已有内容或创建新文件。READ_WRITE读写内容不清空一般配合 seek() 使用。此外还有两个标志位它们不是单独的模式而是和 READ/WRITE 按位组合使用ENCRYPTEDAES 加密模式适合体积不大的存档文件。ENCRYPTED_EXTGodot 4.2 引入的扩展加密模式适合更大体积的文件如 PCK 和大型存档。首次接触这些标志位时最容易犯的错误是“只写 ENCRYPTED不带 WRITE”。完整写法是 FileAccess.WRITE | FileAccess.ENCRYPTED。2.3 JSON 序列化它到底能存什么JSON 本质上是文本格式好处是调试时能直接看到内容坏处是只能表达通用数据类型。Godot 4 提供的 JSON 工具主要有三个函数JSON.stringify(data, indent)把 Variant 转成 JSON 文本indent 传 \t 后生成可读的缩进格式。JSON.parse_string(json_text)把 JSON 文本解析成 Variant。解析失败返回 null。JSON.parse(json_text)把解析结果放进 JSON 对象可以拿到更详细的错误行号和错误信息。一个必须记住的边界Vector2、Vector3、Color、Rect2、Transform2D/3D 这些引擎类型不会被 JSON.stringify 正确保留。它们要么转成 Array要么转成基础类型之后才能存。第 4 节会专门处理这个问题。2.4 ENCRYPTED 与 ENCRYPTED_EXT 的定位很多开发者一听到“加密存档”就想到反作弊、防破解。实际上在 Godot 里FileAccess.ENCRYPTED 的定位更朴素让玩家不能直接用记事本改存档以及让存档文件在传输或备份时不容易被篡改。它不是 DRM也挡不住真正的逆向工程师。两种模式的差异如下模式引入版本密钥来源文件体积适合场景普通 JSONGodot 4.0无任意学习、调试、可编辑存档ENCRYPTEDGodot 4.0config/encryption/key适合较小存档常规游戏进度存档ENCRYPTED_EXTGodot 4.2独立扩展密钥可更大大存档、资源包、PCK从这个表能看出一件事如果你只是存玩家进度ENCRYPTED 就够用如果你的存档里带着地图数据、截图、日志等大内容才需要更重的 ENCRYPTED_EXT。3. 环境准备与项目配置本文示例基于 Godot 4.2 及以上版本。如果你仍在使用 4.0 或 4.1大部分 JSON 与 FileAccess 代码可用但 ENCRYPTED_EXT 不支持需要跳过相关小节。准备步骤从 Godot 官网下载稳定版本 Godot 4.2并创建标准空项目。场景结构保持简单根节点 Node2D挂一个 PlayerCharacterBody2D和一个 SaveManager本文提供的脚本。键盘触发可以直接在脚本里监听 F5/F9不依赖输入映射方便快速测试。如果后面要用加密需要先在项目配置文件里写好密钥。直接编辑项目根目录的 project.godot; 文件路径project.godot节选 [config] encryption/key0123456789abcdef0123456789abcdef这里 key 是 32 个字符的字符串对应 AES-256 的 32 字节密钥。加完配置后用下面代码验证密钥是否被读到# 临时脚本打印密钥长度 var key: String ProjectSettings.get_setting(config/encryption/key, ) print(当前加密密钥长度为: , key.length())如果输出 32说明配置生效。长度不对或者为空FileAccess 打开加密文件时会失败。4. 数据结构设计先想清楚再动手4.1 存档的本质最小可恢复游戏状态一个存档文件应该保存的是“玩家重新进游戏时能够准确回到当前局面”所需的最小信息而不是把所有 Node、所有变量的值都塞进去。典型的存档内容分三类玩家状态位置、血量、等级、装备、技能点。世界状态已解锁区域、任务标记、NPC 对话状态、时间。物品与进度背包、仓库、货币、成就。在设计数据结构时优先使用字母数字和小驼峰命名避免中文键名带来的编码边界问题。这纯粹是工程习惯不是 Godot 的限制。4.2 版本号与元数据存档文件里至少要有一个 version 字段。它的作用是当游戏版本更新、存档结构变化时程序可以根据 version 决定走老版本迁移逻辑或者提示“此存档已过期”。推荐的顶层结构{ version: 1, saved_at: 2025-01-01T12:30:00, player: { ... }, world: { ... }, inventory: [ ... ] }saved_at 用 Time.get_datetime_string_from_system() 生成可读时间方便玩家槽位界面显示“上次存档时间”。4.3 Vector2 等类型的序列化处理这是最容易踩坑的一步。Vector2 直接放进 Dictionary 后 JSON.stringify 不会报错但结果可能是空对象或奇怪的结构而且不同 Godot 版本表现还不一致。稳妥的做法是自定义转换函数# 文件路径scripts/player_serializer.gd class_name PlayerSerializer static func to_dict(player: CharacterBody2D) - Dictionary: return { position: [player.global_position.x, player.global_position.y], hp: player.hp, level: player.level, inventory: player.inventory, } static func apply_dict(player: CharacterBody2D, data: Dictionary) - void: if data.has(position): var pos: Array data[position] if pos.size() 2: player.global_position Vector2(pos[0], pos[1]) player.hp data.get(hp, player.hp) player.level data.get(level, player.level) if data.has(inventory): player.inventory data[inventory]这样做的核心思路是引擎内部类型在序列化边界处提前转成 Array/String/数字加载时再转回引擎类型。所有转换代码集中在一个文件里以后数据结构变了只改这里。5. 核心实现JSON 保存与加载5.1 SaveManager 基础版新建 scripts/save_manager.gd挂到场景任意节点例如名为 SaveManager 的 Node上# 文件路径scripts/save_manager.gd extends Node ## 存档路径user:// 是运行时唯一可靠的写入目录 const SAVE_PATH : user://savegame.json ## 存档格式版本结构变更时递增 const SAVE_VERSION : 1 func save_game(data: Dictionary) - bool: # 深拷贝避免调用方后续修改 data 影响已写入的存档 var save_data: Dictionary data.duplicate(true) save_data[version] SAVE_VERSION save_data[saved_at] Time.get_datetime_string_from_system() var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file null: push_error(打开存档失败错误码: %d % FileAccess.get_open_error()) return false # 第二个参数 \t 让 JSON 带上缩进方便调试 file.store_string(JSON.stringify(save_data, \t)) file.close() return true func load_game() - Dictionary: if not FileAccess.file_exists(SAVE_PATH): return {} var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: push_error(读取存档失败错误码: %d % FileAccess.get_open_error()) return {} var json_text : file.get_as_text() file.close() var parsed JSON.parse_string(json_text) if parsed is Dictionary: return parsed push_error(存档内容不是合法的 JSON 对象) return {}代码里有三个容易忽视的细节。第一save_game 内部先做 duplicate(true)把调用方的 Dictionary 深拷贝一份。否则外部对象在保存后继续修改可能会影响已经写入的内容。第二FileAccess.open 返回 null 时必须用 get_open_error() 拿错误码排查不能直接继续用 file。第三load_game 失败时返回空字典而不是直接崩溃。调用方根据空字典判断“没有存档”或“存档有问题”这是存档系统最基本的兜底逻辑。5.2 在游戏场景中调用再写一个主场景脚本用 F5 保存、F9 加载作为示例# 文件路径scripts/main.gd extends Node2D onready var player: CharacterBody2D $Player onready var save_manager: Node $SaveManager func _input(event: InputEvent) - void: if event is InputEventKey and event.pressed: if event.keycode KEY_F5: save_current_state() elif event.keycode KEY_F9: load_saved_state() func save_current_state() - void: var state : { player: { position: [player.global_position.x, player.global_position.y], hp: player.hp, level: player.level, }, inventory: player.inventory, } if save_manager.save_game(state): print(保存成功) else: push_error(保存失败请查看日志中的错误码) func load_saved_state() - void: var state : save_manager.load_game() if state.is_empty(): print(没有可用存档) return var player_state: Dictionary state.get(player, {}) if player_state.has(position): var pos: Array player_state[position] player.global_position Vector2(pos[0], pos[1]) player.hp player_state.get(hp, player.hp) player.level player_state.get(level, player.level) if state.has(inventory): player.inventory state[inventory]注意 state.get(player, {}) 这种写法。老存档可能没有 player 字段get 加默认值可以把类型错误挡在门外。位置数据从 Array 转回 Vector2 时也要先判断大小防止玩家手工改坏存档导致越界。5.3 更严格的 JSON 解析JSON.parse_string 返回 null 时只知道解析失败不知道具体错在哪一行。线上环境需要快速定位被改坏的存档时可以用带行号信息的版本# 文件路径scripts/save_manager.gd追加 func load_game_detailed() - Dictionary: var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: return {} var json_text : file.get_as_text() file.close() var json : JSON.new() var err : json.parse(json_text) if err ! OK: push_error(JSON 解析失败第 %d 行: %s % [ json.get_error_line(), json.get_error_message() ]) return {} if json.data is Dictionary: return json.data push_error(JSON 根节点不是对象) return {}6. 进阶实现存档加密6.1 加密密钥配置加密存档和非加密存档的代码几乎一样差别有两个一是 project.godot 里要配置密钥二是 FileAccess.open 的模式要加上 ENCRYPTED。密钥配置在第 3 节已经给出。这里再强调一次ENCRYPTED 模式要求密钥为 32 字节长度不对时 FileAccess 不会报语法错误但运行时打开文件会失败。建议在 SaveManager 的 _ready 里做一次启动自检# 文件路径scripts/save_manager_encrypted.gd节选 func _ready() - void: var key: String ProjectSettings.get_setting(config/encryption/key, ) if key.length() ! 32: push_warning(加密密钥长度不是 32加密存档可能会读写失败当前长度: %d % key.length())6.2 使用 ENCRYPTED 模式读写下面的脚本是第 5 节 SaveManager 的加密版# 文件路径scripts/save_manager_encrypted.gd extends Node const SAVE_PATH : user://savegame.dat const SAVE_VERSION : 1 func save_game(data: Dictionary) - bool: var save_data: Dictionary data.duplicate(true) save_data[version] SAVE_VERSION save_data[saved_at] Time.get_datetime_string_from_system() # 写入文件WRITE | ENCRYPTED var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE | FileAccess.ENCRYPTED) if file null: push_error(打开加密存档失败错误码: %d % FileAccess.get_open_error()) return false file.store_string(JSON.stringify(save_data, \t)) file.close() return true func load_game() - Dictionary: if not FileAccess.file_exists(SAVE_PATH): return {} # 读取文件READ | ENCRYPTED与写入时保持一致 var file : FileAccess.open(SAVE_PATH, FileAccess.READ | FileAccess.ENCRYPTED) if file null: push_error(读取加密存档失败错误码: %d % FileAccess.get_open_error()) return {} var json_text : file.get_as_text() file.close() var parsed JSON.parse_string(json_text) return parsed if parsed is Dictionary else {}与普通版相比只有两行发生了变化打开时的 mode 多加了 ENCRYPTED文件后缀从 .json 换成了 .dat。加密模式下即使文件内容仍然是 JSON 文本落盘后也是密文。这里有个容易忽略的点读和写的 mode 必须一致。写是 WRITE | ENCRYPTED读是 READ | ENCRYPTED。如果读时漏掉 ENCRYPTEDFileAccess 会把它当成普通文件拿到的是密文字节JSON 解析会失败。6.3 ENCRYPTED_EXT 与大数据存档当存档内容较大时普通 ENCRYPTED 模式可能不够用因为它的设计目标是小体积文件。Godot 4.2 引入的 ENCRYPTED_EXT 会为每个文件派生独立的实际密钥写入和读取速度更慢但可以承载更大体积的文件。适合存包含大段地图数据、多张缩略图的存档以及导出时的 PCK 包。# 文件路径scripts/save_manager_ext.gdGodot 4.2 extends Node const SAVE_PATH : user://savegame_ext.dat func save_game(data: Dictionary) - bool: var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE | FileAccess.ENCRYPTED_EXT) if file null: push_error(打开 EXT 加密存档失败: %d % FileAccess.get_open_error()) return false file.store_string(JSON.stringify(data, \t)) file.close() return true func load_game() - Dictionary: var file : FileAccess.open(SAVE_PATH, FileAccess.READ | FileAccess.ENCRYPTED_EXT) if file null: return {} var json_text : file.get_as_text() file.close() var parsed JSON.parse_string(json_text) return parsed if parsed is Dictionary else {}关于 ENCRYPTED_EXT 的密钥配置不同 Godot 小版本的设置项写法略有差异。建议动手前先在官方文档里确认当前版本的“extended encryption key”配置项。如果你的普通存档在 4 KiB 范围内直接使用 ENCRYPTED 是最省心的选择。6.4 加密的边界防篡改不等于防破解这一点必须说清楚。Godot 的存档加密是为了阻止普通玩家用记事本改存档而不是为了阻止一个懂逆向的人。原因很简单密钥就写在你的游戏程序里解密逻辑也在程序里攻击者拿到二进制后总有机会提取出来。因此如果你的游戏有排行榜、竞技场这类强对抗玩法不要指望本地加密解决作弊问题。稳妥的做法是在服务器端做校验上传成绩时附带关键参数的签名或哈希服务端确认来源可信。本地加密只能让你的游戏看起来“不那么容易改”这个定位本身就是它的价值。7. 运行结果与效果验证7.1 最小测试流程启动项目后让 Player 移动到某个位置按 F5 保存。控制台应输出“保存成功”。移动 Player按 F9 加载。观察 Player 位置、血量、等级是否回到保存时的状态。如果使用普通 JSON 存档可以在系统文件浏览器中找到 user:// 对应的真实路径打开 savegame.json看到类似下面的内容{ version: 1, saved_at: 2025-01-01T12:30:00, player: { position: [64.0, 128.0], hp: 80, level: 3 } }user:// 在 Windows 上编辑器运行时的真实路径一般是 C:/Users/用户名/AppData/Roaming/Godot/app_userdata/项目名/。也可以在脚本里直接打印print(ProjectSettings.globalize_path(user://))7.2 判断是否真的加密如果使用的是加密版脚本用文本编辑器直接打开 savegame.dat看到的应该是乱码或二进制数据而不是上面的明文 JSON。这说明 ENCRYPTED 模式生效了。想进一步验证密钥是否正确可以故意把 project.godot 里的 key 改成另一个 32 字符字符串再运行加载。此时 FileAccess.open 读出的内容会解不出来日志里出现 JSON 解析失败或打开失败这就是密钥不匹配的表现。7.3 失败时先看哪里如果你的存档读写出现问题第一排查入口不是代码而是这个顺序看 FileAccess.get_open_error() 返回的错误码。看 ProjectSettings 里密钥是否配置、长度是否为 32。看路径前缀是不是 user://。看读和写的 ENCRYPTED 标志是否一致。按这个顺序找绝大多数问题都能在第 2 步或第 3 步被定位。8. 常见问题与排查思路问题现象可能原因排查方式解决方案FileAccess.open 返回 null保存目录不存在或权限不足打印 FileAccess.get_open_error()确认 user:// 路径先创建目录再写文件或换合法的 user:// 子目录加密存档打开失败密钥未配置或长度不是 32 字节用 ProjectSettings.get_setting 打印密钥长度在 project.godot 配置 32 字节密钥加载结果不是 Dictionary存档被玩家改坏或 JSON 语法错误用 JSON.parse 获取 error_line / error_message返回空字典做降级把坏档改名为 .corrupt 备份Vector2 / Vector3 数据不对JSON 不能直接序列化引擎类型打印存档内容检查类型序列化前手动转 Array加载时转回引擎类型中文显示乱码用不支持 UTF-8 的编辑器手工修改存档确认编辑器编码不要手工编辑存档查看时使用支持 UTF-8 的工具导出后保存失败错误地写到了 res://检查路径前缀运行时写入一律改 user://老版本存档加载后缺字段存档结构变化但没做版本迁移检查存档 version 字段增加迁移函数按 version 逐步升级ENCRYPTED_EXT 编译报错Godot 版本低于 4.2查看编辑器版本升级到 4.2或改用 ENCRYPTED存档内容过大、读写很慢JSON 序列化大结构 普通加密模式限制检查文件大小精简存档数据或改用 ENCRYPTED_EXT / 二进制 store_var9. 最佳实践与工程建议9.1 存档版本兼容强烈建议从第一个版本开始就写入 version 字段。以后新增字段时只动迁移函数不动主存档流程。一个简单约定是version 递增时旧的加载流程仍然保留并提供一个 convert_save(data, from_version) 函数把老存档升级到新结构。不要在没有迁移函数的情况下直接删掉旧字段那会让所有老玩家的存档在更新后变成空档。9.2 抗损坏写入临时文件 原子替换游戏存档最怕“写一半崩溃”。如果正在写入时程序退出存档文件可能会处于截断状态。缓解方案是先写临时文件flush 并 close 后再把旧存档替换掉。# 文件路径scripts/save_manager_atomic.gd节选 const SAVE_PATH : user://savegame.json const TMP_PATH : user://savegame.json.tmp func save_game_atomic(data: Dictionary) - bool: var file : FileAccess.open(TMP_PATH, FileAccess.WRITE) if file null: return false file.store_string(JSON.stringify(data, \t)) file.flush() file.close() # 把旧存档改为 .bak保留一份回滚素材 if FileAccess.file_exists(SAVE_PATH): DirAccess.rename_absolute( ProjectSettings.globalize_path(SAVE_PATH), ProjectSettings.globalize_path(SAVE_PATH .bak) ) var err : DirAccess.rename_absolute( ProjectSettings.globalize_path(TMP_PATH), ProjectSettings.globalize_path(SAVE_PATH) ) if err ! OK: push_error(存档替换失败错误码: %d % err) return false return true注意不同平台的 rename 行为有差异Windows 上目标文件已存在时 rename 可能失败所以上面先做了 .bak 备份。这个方案在生产项目里要按目标平台实测后再决定是否使用。9.3 存档槽位与备份给玩家提供 3 个存档槽位是成本最低但体验提升明显的功能。实现时只需要把路径参数化# 文件路径scripts/save_slots.gd节选 const SAVE_DIR : user://saves const SLOT_COUNT : 3 func ensure_save_dir() - void: if not DirAccess.dir_exists_absolute(SAVE_DIR): DirAccess.make_dir_recursive_absolute(SAVE_DIR) func slot_path(slot_index: int) - String: return SAVE_DIR /slot_%d.json % slot_index func save_to_slot(slot_index: int, data: Dictionary) - bool: ensure_save_dir() if slot_index 0 or slot_index SLOT_COUNT: return false var file : FileAccess.open(slot_path(slot_index), FileAccess.WRITE) if file null: return false file.store_string(JSON.stringify(data, \t)) file.close() return true同时建议在槽位 UI 上展示 saved_at 字段玩家能直观看到每个槽位的最后存档时间。9.4 调试辅助开发期建议在 Debug 菜单或控制台里提供“查看存档内容”“删除存档”“生成测试存档”三个操作。测试存档能帮你快速复现“玩家在第 10 关、背包 20 格、金币 99999”的边界状态不用靠手动操作游戏一步步打到那个阶段。9.5 性能与保存频率JSON.stringify 对 Dictionary 全量序列化数据量大时会有可见耗时。不要在 _process 里保存合理的做法是关卡切换时保存、自动存档加冷却时间、手动存档由玩家触发。把“系统设置”和“游戏进度”分开存这样玩家改音量时不需要重写整个存档文件。9.6 安全边界与合法操作提醒如果你将来要把存档同步到服务器或者实现排行榜、玩家对战请记住本地存档加密只是防篡改的第一道门槛不能替代服务端校验。生成密钥、修改存档结构这类操作一定要在测试环境里先验证再上生产。任何删除存档、覆盖存档的操作都建议先备份到 .bak 再执行。10. 总结与后续学习方向现在回到开头的三个问题答案已经很明确。路径问题运行时写入一律用 user://res:// 导出后只读。类型问题Vector2 等引擎类型在序列化边界处显式转成 Array加载时再转回。兼容问题存档顶层从第一天就带 version 字段以后任何结构变化都有迁移入口。如果你自己动手把第 5 节的 SaveManager、第 6 节的加密版、第 9 节的槽位版本都跑通你的游戏就已经具备一套可以上线的存档基础。加密不是必须的但只要你打算做多周目、成就、排行榜中的任何一个它就值得加上。往后继续深入有三个方向值得看第一ConfigFile 类适合存键值对密集的系统设置写法比 JSON 更接近 INI第二FileAccess.store_var / get_var 二进制序列化能直接保留 Vector2、Transform3D 等类型体积更小但不可读第三把存档抽成 Autoload 单例配合信号监听游戏状态变化实现“任何节点在任何时刻都能安全地访问存档服务”。存档系统的本质是“状态快照 可靠写入 优雅恢复”。把这三件事拆开做你的游戏在遇到玩家改存档、跨平台路径差异、版本更新迁移时就不会手忙脚乱。建议先把本文最小示例跑通再复制到正式项目里按自己的玩法扩展。如果某个错误码卡住了优先看 FileAccess.get_open_error() 的输出它比任何猜测都准确。