
如果你正在做 Godot 4 游戏存档功能迟早要碰。网上不少教程只讲“用 File 写文本”一旦涉及多场景、多槽位、加密和版本兼容代码就需要重写。这次我们直接把“JSON 保存、加载、加密”三件事串成一个完整方案放到 Godot 4.x 里跑通再讲清楚每一步为什么这么写。这个方案不依赖任何插件全部用 Godot 内置 API 完成。适合已经能运行 Godot 4 编辑器、写过基础 GDScript 的开发者。读完你能拿到可直接复制的 SaveManager 单例掌握普通 JSON 存档和加密存档两种写法并学会处理存档读取失败、密码不匹配、旧版本存档迁移等常见问题。加密部分重点解决“玩家直接改存档文件”这类问题不是复杂的安全系统。1. 核心能力速览能力项说明项目类型Godot 4 本地存档系统教程目标引擎Godot 4.x开发语言GDScript存储格式JSON 文本可选 FileAccess 加密写入核心功能保存进度、读取存档、加密存档、多槽位、自动保存外部依赖无使用 Godot 内置 API最低门槛能运行 Godot 4 的电脑2D 项目一般不需要独立显卡存档位置user:// 目录跨平台由引擎管理接口形态Autoload 全局单例游戏内直接调用适用场景单机游戏存档、关卡进度、游戏设置、本地数据备份这套实现的优点是把存档抽象成“一个字典 一个槽位号”。游戏里任何数据只要转成 Dictionary就能统一走保存和加载流程。后续加自动存档、多存档槽、版本迁移都不需要大改核心代码。2. 适用场景与使用边界先说适合做什么。JSON 存档适合保存小型结构化数据例如玩家坐标、血量、等级、金币背包物品列表解锁的关卡和任务进度图形设置、音量设置、语言设置竞速类游戏的关卡成绩。它不适合保存超大二进制内容。比如把整张图片、音频或大体积资源塞进 JSON会明显拖慢写入和读取也不利于维护。这类内容应单独用文件或二进制格式存储存档里只保存资源路径。加密的边界也要说清楚。使用加密后玩家无法直接用文本编辑器打开存档改数值这对“排行榜成绩”“关卡解锁条件”这类轻量防作弊是够用的。但客户端加密不是绝对安全内存修改、外部拦截仍然存在。如果你的游戏有联网排名、交易或竞技玩法必须在服务器端做数值校验客户端加密挡不住专业修改。另外凡是涉及玩家隐私数据的本地保存都建议先做最小化处理不存不必要的敏感信息。加密不是合规的万能药该提示授权的场景仍然要提示授权。3. 环境准备与项目前置条件这一步只需要做三件事装 Godot 4、建项目、注册全局单例。3.1 获取 Godot 4 编辑器从 Godot 官网下载 4.x 稳定版。本教程代码不依赖某个小版本的特殊 API4.0 以上都能运行。推荐使用当前 4.2 或 4.3 之后的版本编辑器功能更完整。3.2 创建项目打开 Godot 项目管理器新建项目渲染器选择 Forward 或 Mobile 都可以本教程不涉及渲染特性。项目建议设置一个明确的名称因为 user:// 路径在系统里的实际目录会包含项目名后续找存档文件时要靠它定位。3.3 规划项目结构建议提前建好脚本目录res:// ├── autoload/ │ └── save_manager.gd ├── scenes/ │ └── main.tscn └── project.godot脚本统一放 autoload 目录场景只负责调用。存档管理器设计成单例由项目启动时自动加载。3.4 注册 Autoload打开“项目设置 - 全局”页签在 Autoload 列表里添加路径: res://autoload/save_manager.gd 节点名: SaveManager注册后任何场景脚本里都可以直接写SaveManager.save_game(...)不需要再手动 get_node。3.5 明确 user:// 与 res:// 的区别这是一个非常容易踩的坑。res:// 指向项目资源目录导出后是只读的user:// 才是引擎为当前用户分配的写入目录。存档必须写 user://不要尝试在 res:// 里创建文件。Godot 会自动根据操作系统映射 user:// 的真实位置例如 Windows 通常在%APPDATA%\Godot\app_userdata\项目名下具体以各平台实际路径为准。4. 基础 JSON 存档实现先把不加密的版本写出来。这个版本的好处是文件可以直接查看和手工修改调试阶段非常直观。4.1 SaveManager 基础代码新建autoload/save_manager.gd内容如下extends Node const SAVE_DIR : user://saves const SLOT_COUNT : 3 ## 保存游戏数据到指定槽位 func save_game(data: Dictionary, slot: int 0) - bool: if not _ensure_save_dir(): push_error(无法创建存档目录) return false var path : _get_slot_path(slot) var file : FileAccess.open(path, FileAccess.WRITE) if file null: push_error(无法写入存档: %s错误码 %d % [path, FileAccess.get_open_error()]) return false var json_text : JSON.stringify(data, \t) file.store_string(json_text) file.close() return true ## 从指定槽位读取游戏数据失败返回空字典 func load_game(slot: int 0) - Dictionary: var path : _get_slot_path(slot) if not FileAccess.file_exists(path): return {} var file : FileAccess.open(path, FileAccess.READ) if file null: push_error(无法读取存档: %s % path) 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 解析失败: %s % json.get_error_message()) return {} if typeof(json.data) ! TYPE_DICTIONARY: push_error(存档根节点不是字典) return {} return json.data ## 判断某个槽位是否存在存档 func has_save(slot: int 0) - bool: return FileAccess.file_exists(_get_slot_path(slot)) ## 删除某个槽位的存档 func delete_save(slot: int 0) - bool: var path : _get_slot_path(slot) if not FileAccess.file_exists(path): return false return DirAccess.remove_absolute(path) OK func _get_slot_path(slot: int) - String: return %s/slot_%d.json % [SAVE_DIR, slot] func _ensure_save_dir() - bool: var dir : DirAccess.open(user://) if dir null: return false if dir.dir_exists(saves): return true var err : dir.make_dir(saves) return err OK这段代码做了三件基本功写前创建目录面值文件不存在时返回空字典JSON 解析失败时给出错误信息而不是直接崩溃。生产项目里不要省略这些异常分支存档文件被玩家删掉或改坏是常态。4.2 游戏内调用示例假设玩家角色有一个入口场景坐标是 Vector2血量是数值。保存代码可以这样写func save_player_progress(): var data : { version: 1, player: { name: player.name, hp: player.hp, level: player.level, position: [player.global_position.x, player.global_position.y], }, inventory: inventory.get_save_data(), timestamp: Time.get_unix_time_from_system(), } SaveManager.save_game(data, 1)加载代码func load_player_progress(): var data : SaveManager.load_game(1) if data.is_empty(): return player.name data[player][name] player.hp data[player][hp] player.level data[player][level] var pos_arr: Array data[player][position] player.global_position Vector2(pos_arr[0], pos_arr[1]) inventory.apply_save_data(data[inventory])这里有一个关键点JSON 不支持直接序列化 Vector2、Color、NodePath 等引擎类型。所以保存时要把 Vector2 转成数组加载时再转回来。如果直接塞进字典确实能写入但读出来的是字符串不是 Vector2后续使用会报类型错误。4.3 JSON 文件长什么样保存后在 user://saves/slot_1.json 能看到类似内容{ version: 1, player: { name: Ada, hp: 76, level: 12, position: [120.5, 48.3] }, inventory: { items: [sword, potion] }, timestamp: 1760000000 }明文 JSON 的好处是调试期一目了然坏处是玩家也能直接改。如果项目里存在“成绩上传”或“排行奖励”接下来就必须进入加密版本。5. 加密存档实现Godot 4 的 FileAccess 原生支持加密文件不需要自己拼 XOR 去做假加密。这里用到两个核心方法open_encrypted(path, mode, key)key 必须是长度为 32 字节的 PackedByteArrayopen_encrypted_pass(path, mode, pass)直接传密码字符串由引擎内部从密码派生密钥。对大多数独立游戏用open_encrypted_pass更省事不需要自己在代码里处理字节数组。下面直接给出完整实现。5.1 加密存档代码在原来的save_manager.gd里增加一组加密读写方法const SAVE_DIR_ENCRYPTED : user://saves const ENCRYPT_PASS : replace-with-your-own-game-password ## 加密保存 func save_game_encrypted(data: Dictionary, slot: int 0) - bool: if not _ensure_save_dir(): push_error(无法创建存档目录) return false var path : _get_slot_encrypted_path(slot) var file : FileAccess.open_encrypted_pass(path, FileAccess.WRITE, ENCRYPT_PASS) if file null: push_error(无法打开加密存档文件: %s错误码 %d % [path, FileAccess.get_open_error()]) return false var json_text : JSON.stringify(data, \t) file.store_string(json_text) file.close() return true ## 加密读取 func load_game_encrypted(slot: int 0) - Dictionary: var path : _get_slot_encrypted_path(slot) if not FileAccess.file_exists(path): return {} var file : FileAccess.open_encrypted_pass(path, FileAccess.READ, ENCRYPT_PASS) if file null: push_error(无法打开加密存档密码可能不匹配: %s % path) 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 解析失败: %s % json.get_error_message()) return {} if typeof(json.data) ! TYPE_DICTIONARY: push_error(加密存档根节点不是字典) return {} return json.data func _get_slot_encrypted_path(slot: int) - String: return %s/slot_%d.dat % [SAVE_DIR_ENCRYPTED, slot]如果你更倾向于固定密钥写法可以这样替换 open 部分const ENCRYPT_KEY : 0123456789abcdef0123456789abcdef var file : FileAccess.open_encrypted( path, FileAccess.WRITE, ENCRYPT_KEY.to_utf8_buffer() )注意ENCRYPT_KEY必须精确 32 字节多了少了都会导致 open 失败。用字符串密码版本就没有这个长度限制更符合日常使用习惯。5.2 加密后文件无法直接查看保存后打开user://saves/slot_0.dat看到的是一堆乱码不再有player、hp之类的可读内容。这就达到“防止玩家用文本编辑器直接改存档”的目的了。能改文件名的后缀没有实际意义。slot_0.dat只是命名习惯引擎加密与否取决于 FileAccess 的打开方式不取决于扩展名。你把它命名成.json也照样是加密内容。5.3 密码管理建议不要把硬编码密码直接写进 GDScript 后发布这样密码会被反编译拿到。对这个教程的存档功能来说更稳妥的做法分两层本地存档加密密码可以用项目内固定值主要防普通玩家改文本需要更强保护时改为存档内容签名校验或服务端校验。如果你的游戏后续要支持云存档上传加密后的文件依然不能直接视为安全数据真正的可靠边界在服务器端。这个思路要提前想清楚不要等存档冲突、作弊数据涌进来再改架构。6. 功能测试与效果验证代码写完一定要按下面几个维度跑一遍验证。这里给出一套通用测试流程不需要额外插件。6.1 测试用例清单测试项操作预期结果基础保存调用 save_game传一个包含 int、float、String、Array 的字典返回 trueuser://saves 下生成 JSON 文件基础加载调用 load_game返回相同数据结构类型保持一致文件不存在调用 load_game(99)返回空字典不崩溃损坏文件手动在 JSON 文件里删除一个逗号再加载打印 JSON 解析失败返回空字典加密写入调用 save_game_encrypted生成 .dat 文件文本编辑器打开为乱码加密读取调用 load_game_encrypted能正确还原数据密码不匹配临时改 ENCRYPT_PASS 再读取旧档file 为 null返回空字典删除存档调用 delete_save文件消失has_save 返回 false6.2 一键验证脚本可以在主场景_ready里临时加一段测试逻辑跑完看输出func _ready() - void: var test_data : { version: 1, player: { name: Bob, hp: 88, position: [10.0, 20.0], }, items: [key, map], } var ok : SaveManager.save_game(test_data, 0) print(save result: , ok) var loaded : SaveManager.load_game(0) print(loaded: , loaded) print(player name: , loaded[player][name])如果输出里能看到完整字典并且字段和保存前一致说明基础链路通了。再把save_game换成save_game_encrypted重复同样的验证。6.3 手动破坏存档文件这一步必须做。找到 user://saves 目录打开 JSON 文件删掉中间一个字符再运行加载。正确表现是程序不崩溃打印 JSON 解析失败返回空字典。如果直接崩溃说明你的代码还在用get_as_text()后直接做字典操作没有走 JSON 解析分支需要回头补异常处理。加密文件的手动破坏测试也值得做用文本编辑器随意改几个字节再加载。预期是文件打开失败或解析失败而不是出现一个被篡改后的“假数据”。7. 接口封装与批量存档管理到这里SaveManager 已经是一个可用的本地存档 API。接下来扩展成生产可用形态。7.1 对外接口一览函数作用返回save_game(data, slot)保存明文存档boolload_game(slot)读取明文存档Dictionarysave_game_encrypted(data, slot)保存加密存档boolload_game_encrypted(slot)读取加密存档Dictionaryhas_save(slot)检查槽位是否存在存档booldelete_save(slot)删除存档boolget_all_slots()列出所有有存档的槽位Arrayget_all_slots可以这样实现func get_all_slots() - Array[int]: var slots: Array[int] [] for slot in range(SLOT_COUNT): if has_save(slot): slots.append(slot) return slots游戏界面里的“继续游戏”按钮就可以根据这个返回值判断显示哪些存档槽。7.2 自动保存接口自动存档不需要每帧执行。比较合理的触发时机是切换场景、进入检查点、完成关卡、退出游戏前。可以封装一个定时自动保存## 在 Main 场景里启动一个软自动保存定时器 func _ready() - void: var auto_save_timer : Timer.new() auto_save_timer.wait_time 120.0 auto_save_timer.autostart true auto_save_timer.timeout.connect(_on_auto_save_timeout) add_child(auto_save_timer) func _on_auto_save_timeout() - void: var data : _collect_all_save_data() SaveManager.save_game(data, 0) print(auto save done)自动保存的注意点不要在玩家战斗中途强制保存容易保存到异常状态。最好是先判断当前场景允许保存再写盘。批量存档任务如果需要处理多个槽位循环调用即可但每个槽位之间留出一点间隔避免瞬间大量写盘影响游戏帧率。7.3 批量备份存档给存档做备份是便宜又有效的保护措施。玩家遇到坏档时可以手动换备份。核心思路是把当前所有槽位文件复制到带时间戳的备份目录func backup_all_saves() - void: var backup_dir : user://backups/%d % Time.get_unix_time_from_system() var err : DirAccess.make_dir_recursive_absolute(backup_dir) if err ! OK: push_error(创建备份目录失败: %s % error_string(err)) return for slot in get_all_slots(): var src : _get_slot_path(slot) var dst : %s/slot_%d.json % [backup_dir, slot] DirAccess.copy_absolute(src, dst) print(backup finished at , backup_dir)注意DirAccess.copy_absolute在 Godot 4 中会返回错误码生产代码里要检查返回值。备份目录也不能无限堆积要做数量上限控制比如只保留最近 5 次备份。8. 资源占用与性能观察一句话结论JSON 存档的读写开销可以忽略不计真正的性能问题来自“保存频率”和“一次性写入的数据量”。8.1 用时间戳测量开销在真实设备上验证一下不要靠感觉判断。用 Time 类记录耗时func measure_save_time() - void: var data : { player: {...}, inventory: {...}, } var start : Time.get_ticks_msec() var ok : SaveManager.save_game_encrypted(data, 0) var cost : Time.get_ticks_msec() - start print(save ok, ok, cost, cost, ms)一份包含几百个字段的存档加密写入通常只需要几毫秒甚至在多数时候小于 1 毫秒。如果你发现保存一次要几十毫秒甚至上百毫秒多半是数据里带了不该带的内容比如大量文本、大数组、甚至把节点引用塞进了字典。8.2 控制写盘频率每帧调用save_game是最典型的性能问题。哪怕单次只花 1 毫秒在 60 FPS 下也会明显消耗帧时间。正确做法用 Timer 做间隔自动保存在检查点、过关等关键节点手动保存玩家手动保存时加一个短防抖防止连点按钮触发多次写盘。如果数据量真的很大可以把大字段拆到单独文件存档文件里只存路径和摘要。这是大型游戏常用思路小型项目不需要一上来就这么做。8.3 观察存档文件大小在调试时打印文件大小可以快速发现异常数据func print_save_file_size(slot: int) - void: var path : _get_slot_path(slot) if not FileAccess.file_exists(path): return var file : FileAccess.open(path, FileAccess.READ) if file ! null: var size : file.get_length() file.close() print(save size: , size, bytes)如果某次更新后存档文件体积暴涨优先检查是不是有贴图路径数组、日志列表或不断追加的聊天记录进了字典。JSON 不会自动去重大量重复数据会直接体现在文件体积里。9. 常见问题与排查方法下面这些问题是实际使用中最容易遇到的按现象、原因、排查、解决四列整理。问题现象可能原因排查方式解决方案FileAccess.open 返回 null目录不存在、路径错误、写入权限问题打印 FileAccess.get_open_error()先调用_ensure_save_dir()再写文件加载返回空字典JSON 解析失败把 json_text 打印出来看修复被损坏文件或删除坏档重新生成加载数据字段缺失存档版本升级后字段名变了打印 loaded 的 keys用默认值合并写版本迁移逻辑加密读取失败密码不一致、密钥长度不对检查 ENCRYPT_PASS 是否统一统一密码密钥版本用固定 32 字节找不到 user:// 路径不知道系统真实目录在代码里打印 ProjectSettings.globalize_path(user://)按打印出的路径定位存档文件导出后无法写入将文件写到了 res://检查路径前缀统一改成 user://存档文件能直接看到明文还在调用明文版 save_game检查调用的是哪个函数切换到 save_game_encrypted清理旧槽位加密文件复制到别的机器后读取失败项目名不同导致 user:// 路径不同对比两台机器的加密密钥和项目名保持密码和项目名一致或规划可迁移存档路径自动保存导致卡顿保存时机太频繁查看每次保存耗时增加间隔、改为检查点保存补充两个容易忽略的细节。第一FileAccess.open_encrypted_pass读取失败时错误可能来自“文件损坏”而不是“密码错误”排查时先换一个已知正常的存档文件测试。第二存档数据里的版本号必须在使用前校验。优先判断版本再决定走哪个迁移逻辑不然加载旧存档时用新代码直接访问字段很容易得到空值和崩溃。10. 最佳实践与使用建议核心实践建议如下前面零散提到过这里集中整理。10.1 存档结构与版本控制无论游戏多小存档根节点里都要放一个 version 字段。后续每次改存档结构就把版本号加一。加载时根据版本走不同分支老存档通过默认值补字段或触发一次性迁移脚本。这个习惯能从源头避免“更新一次游戏老存档全废”的问题。10.2 统一走字典收口保存时把数据集中成一个_collect_save_data()函数加载时统一走_apply_save_data(data)。不要在一个脚本里顺手调用一次SaveManager.save_game在另一个脚本里又偷偷写一个文件。存档出入口不统一后续排查问题会非常痛苦。10.3 优先小成本验证第一次接入时不要急着把所有系统都接进来。先保存一个玩家坐标和一个字符串字段跑通读写链路再逐步扩展。这个最小闭环能帮你快速区分“代码 bug”和“设计问题”。10.4 加密密码的工程管理密码硬编码能跑通教程但上线前要思考分发方式。独立游戏可接受的做法是把密码放在一个独立模块里方便统一替换需要更高安全性时配合签名校验、服务端校验而不是单独依赖本地加密。此外备份、云存档、跨设备同步都要在加密时会话设计好否则换了密码旧存档全部失效。10.5 隐私与合规边界本地存档如果包含玩家个人信息比如昵称、设备标识、位置数据至少要做到最小化存储。不要为了让存档更“完整”而收集无关数据。如果游戏面向特定地区还需要按对应规范处理隐私授权这里仅提醒边界具体合规要求需要咨询专业人士。11. 总结与下一步这套在 Godot 4 中实现 JSON 加密存档的流程核心就三句话所有数据先转成字典字典用 JSON.stringify 写入需要加密时把 FileAccess.open 换成 FileAccess.open_encrypted_pass。把这三个动作封装到 SaveManager 单例后游戏里任何系统都能轻松接入存档。初学者建议先跑通不加密版本手动打开 JSON 文件看一眼结构再切换到加密版本。最容易踩的坑有三个路径写到了 res://、Vector2 没有拆成数组、密码不匹配导致读档失败。这三个坑都避开了存档系统就稳了一大半。下一步可以在这个基础上扩展多存档槽界面、自动保存检查点、跨平台云存档、服务器端存档校验与防作弊。每一步都是在 SaveManager 外层做增量不需要推翻重写。建议收藏这篇等写游戏做到存档功能时照着代码和排查表直接开工。