资讯详情

Godot 4存档系统完整指南:基于JSON与AES加密的保存/加载方案

📅 2026/10/13 17:36:55 | 华诺云谱 👁 阅读
Godot 4存档系统完整指南:基于JSON与AES加密的保存/加载方案
开发过 Godot 游戏的读者应该都有过这种体验辛辛苦苦做的游戏玩法、关卡、UI 都调好了结果一关游戏进度全部归零。玩家每次打开都要从头开始哪怕只是一个简单的平台跳跃小游戏这种体验也足够劝退。保存/加载系统看似是游戏开发的“最后一公里”但真要做扎实涉及的问题比想象中多数据模型怎么设计、文件存在哪里、读档时怎么处理损坏数据、要不要加密、加密后会不会影响加载性能。如果只看网上零散的教程很容易学到一套只能跑通最短路径的写法——单机跑没问题一旦遇到版本升级、存档文件被玩家手动修改、多平台发布就会暴露出各种隐患。这篇文章针对 Godot 4 写一套完整的保存/加载方案核心是 JSON 加 AES 加密。会覆盖从数据模型设计、目录规划、序列化读写到加密混淆、损坏恢复的完整流程并附上可直接复制的代码和排查清单。读完你不仅能给游戏加上可靠的存档系统还能理解这套方案在设计层面的取舍。1. 为什么保存系统值得认真设计很多独立开发者在做存档功能时第一反应是“把关键变量存下来就行”。于是最常见的一种写法出现了把几个数值拼成字符串用FileAccess.store_string()写进user://save.dat读的时候再按分隔符拆开。这种方案在开发初期完全够用代码量小逻辑直观几行就能搞定。但问题会在项目推进过程中慢慢暴露一是数据结构会膨胀。一开始只需要存金币数和当前关卡后来要存玩家位置、背包物品、已解锁技能、任务进度、设置项。字符串拼接开始变得难以维护顺序一换或者漏了一个字段旧存档直接作废。二是读写逻辑严重耦合。保存和加载的代码散落在各个脚本中今天在角色脚本里存一个位置明天又在背包系统里存一个列表过两周再想加一个“暂停存档”功能几乎无从下手。三是对存档文件的错误没有防护。玩家可能因为意外断电、设备崩溃获得一个写到一半的文件加载时直接解析失败游戏崩溃存档也彻底损坏。JSON 加密的方案本质上是把这三类问题集中化、结构化地解决。JSON 提供了一套完整的数据结构表达方式。嵌套对象、数组、字符串、数字、布尔值都能自然地映射到玩家的游戏状态不需要自己发明序列化格式。加密解决的是存档被篡改和复制的问题——JSON 明文存档用记事本就能改玩家把金币改成 999999再把文件传回存档目录游戏的内购和数值体系就形同虚设。这套方案的核心思路是游戏逻辑不关心存档怎么存储只依赖一个统一的存档管理器存档管理器只负责数据的序列化、加密、文件读写和错误恢复不掺入任何游戏规则。2. JSON 与 AES 加密的基础概念先同步一下基础概念后面代码才不会看得一头雾水。2.1 JSON 在 Godot 中的角色JSONJavaScript Object Notation是一种轻量级数据交换格式本身只是一个字符串规范。Godot 4 提供了JSON.stringify()和JSON.parse()两个核心方法JSON.stringify(data)把一个 Godot 原生数据对象如Dictionary、Array、int、String转换成 JSON 字符串。JSON.parse(json_string)把 JSON 字符串解析回 Godot 数据变量返回值是一个JSON对象里面带有data属性和error信息。JSON 在保存系统中的价值不单是“能把字典转成字符串”更关键的是它天然适合表达游戏存档这种嵌套结构。比如一个角色的存档数据可以表达为{ player: { name: Hero, level: 3, health: 80 }, inventory: [ { id: sword, count: 1 }, { id: potion, count: 5 } ], settings: { volume: 0.8 } }这种结构在 Godot 中可以直接从一个Dictionary转换而来不需要额外引入数据库或序列化库。2.2 为什么要加密而不只是编码Base64 编码并不等于加密。Base64 只是一种编码规则它把二进制数据或文本转换成 ASCII 字符集在网络上传输时很方便但没有任何保密能力——因为解码规则是公开的而且在 Godot 中只要一行代码就能反向还原。对存档来说Base64 能解决的问题不是防修改而是把可能包含特殊字符的二进制数据变成便于阅读和存储的文本。真正的加密需要用到密钥。加密之后不知道密钥的人无法还原明文内容自然也就无法直接通过编辑存档文件来改数据。Godot 4 的AESContext类提供了 AESAdvanced Encryption Standard对称加密能力。对称加密的意思是加密和解密使用同一个密钥。适用于存档场景因为存档的读写都发生在本地不需要像网络通信那样分发公钥。一个容易误解的地方是AESContext 不是直接把字符串喂进去就加密输出它是块加密算法需要处理数据长度、填充、以及数据块大小对齐。后面写代码时会看到一个长度为几十字节的 JSON 文本在处理时往往需要按 16 字节的倍数补齐。2.3 为什么选择 AES而不是只依赖引擎自带能力Godot 4 的FileAccess类提供了文件读写接口其中静态方法FileAccess.get_file_as_bytes()可以一次读取整个文件内容。但FileAccess本身并不提供直接基于密钥的加密接口过去 Godot 3 中的FileAccess.open_encrypted()在新版本中已经不能作为跨版本依赖的方案。所以在 Godot 4 中做加密存档典型做法是自己使用AESContext加Base64组合完成。这意味着整套加密流程需要手动管理四步压缩数据打包、初始化密钥、执行加密转换、输出 Base64 文本。每一步都不复杂但顺序和参数必须一致之前没有接触过 AES 的读者可以从下文的最小实现中逐步理解。3. 环境准备与项目结构规划在开始写代码之前先把基础框架搭好。本次示例基于 Godot 4.x 版本使用的是 GDScript操作步骤适用于 Windows、macOS 和 Linux 桌面平台。3.1 创建测试项目打开 Godot 4新建一个空项目项目名称随意比如SaveDemo。创建完成后在文件系统面板中依次创建以下目录结构res:// ├── scenes/ │ └── main.tscn ├── scripts/ │ ├── player_data.gd │ ├── save_manager.gd │ └── main.gd └── autoload/ └── save_manager.gd这里把save_manager.gd同时放在scripts/和autoload/中是因为在 Godot 中注册为自动加载Autoload单例时源脚本文件需要在项目设置中指向具体路径。实际项目中你只要关心脚本所在路径即可不必真的复制两份文件——下面代码中我们只创建一个scripts/save_manager.gd然后把它注册为 Autoload。3.2 注册全局单例保存管理器作为全局单例是为了让任何脚本都可以直接调用SaveManager.save_game()而不需要传递节点引用。具体操作打开菜单项目 - 项目设置。选择“自动加载Autoload”标签页。在路径输入框中填写res://scripts/save_manager.gd。名称填写SaveManager点击“添加”。完成之后任何脚本中都可以直接访问SaveManager这个全局实例。3.3 关于用户目录 user://Godot 中user://是专用用户数据目录上面提到的FileAccess.open()如果传入user://save.dat会写进系统分配给当前项目的用户数据目录。这个目录对每个平台有不同的实际位置但对开发者而言不需要关心绝对路径是什么只要明确一点user://下的数据不应该放在游戏安装目录中也不会在游戏更新时被覆盖。这个特性对存档至关重要。如果直接把存档写到res://下发布游戏后安装目录通常是只读的玩家根本没有写入权限。所以所有存档读写全部走user://。4. 数据模型设计先决定存档中要存什么。为了让示例有代表性我们设计一个包含玩家状态、背包列表、游戏设置三部分的存档结构。4.1 玩家数据脚本创建scripts/player_data.gd# 文件路径scripts/player_data.gd class_name PlayerData extends RefCounted var player_name: String Hero var level: int 1 var health: int 100 var max_health: int 100 var gold: int 0 var position: Vector2 Vector2.ZERO var inventory: Array[Dictionary] [] var unlocked_levels: Array[String] [] var settings: Dictionary { volume: 1.0, quality: high } func to_dictionary() - Dictionary: return { player_name: player_name, level: level, health: health, max_health: max_health, gold: gold, position_x: position.x, position_y: position.y, inventory: inventory, unlocked_levels: unlocked_levels, settings: settings, } static func from_dictionary(data: Dictionary) - PlayerData: var pd : PlayerData.new() pd.player_name data.get(player_name, Hero) pd.level data.get(level, 1) pd.health data.get(health, 100) pd.max_health data.get(max_health, 100) pd.gold data.get(gold, 0) pd.position Vector2( data.get(position_x, 0.0), data.get(position_y, 0.0) ) pd.inventory data.get(inventory, []) pd.unlocked_levels data.get(unlocked_levels, []) pd.settings data.get(settings, { volume: 1.0, quality: high }) return pd这段代码的关键点是to_dictionary()和静态方法from_dictionary()成对出现一个负责把数据导出成 JSON 友好的字典结构一个负责从字典结构恢复对象。其中最值得注意的细节是Vector2不能直接被 JSON 序列化因此拆成position_x和position_y两个浮点数保存读取时再重新组合。不这么做会怎样当你尝试JSON.stringify()一个包含Vector2的字典时Godot 不会报错但序列化结果中Vector2会被转成空对象或无法正确还原的形态加载时位置数据就丢了。这种错误在写代码时最容易忽略因为编译和启动都不会报错只有运行到读档阶段才会发现角色出现在(0,0)处。4.2 为什么不用直接将整个节点属性序列化另一个常见思路是把整个玩家节点store到文件里。Godot 的ResourceSaver和PackedScene确实支持节点序列化但存档场景中使用这种方式有一个重大缺陷当游戏后续版本修改了玩家节点的属性、删除了某个子节点、或重构了技能系统旧存档的节点结构无法正确匹配新版本反序列化轻则警告重则失败。手写数据 DTO 虽然多写几行代码但换来的是对存档结构的完全控制。存档格式本质上是一个版本化接口应该由开发者明确管理而不是交给引擎自动推断。这就是PlayerData作为独立类存在的原因。5. 核心存档管理器实现现在进入正题。SaveManager作为单例负责统一处理所有存档相关逻辑作为一个管理器类由它提供如下能力保存收集数据、转 JSON、压缩/加密、写文件。加载读文件、解密、解析 JSON、构造PlayerData。附加能力存档备份、存档删除、错误上报。元数据存档版本号、时间戳。先看完整代码再逐段解释。# 文件路径scripts/save_manager.gd extends Node const SAVE_PATH : user://savegame.sav const SAVE_VERSION : 1 const BACKUP_PATH : user://savegame.bak const AES_KEY : 0123456789abcdef0123456789abcdef # 32 字节 256 位仅示例实际项目不应硬编码 const AES_IV : abcdef0123456789 # 16 字节 128 位 var current_data: PlayerData null func _ready() - void: current_data PlayerData.new() func save_game() - bool: if current_data null: current_data PlayerData.new() var data: Dictionary current_data.to_dictionary() data[save_version] SAVE_VERSION data[timestamp] Time.get_unix_time_from_system() var json_string: String JSON.stringify(data, \t) var encrypted: PackedByteArray _encrypt_data(json_string.to_utf8_buffer()) var base64_string: String encrypted.get_string_from_utf8() # 先写备份再写主存档降低写文件一半时崩溃的风险 if FileAccess.file_exists(SAVE_PATH): var backup_success : _copy_file(SAVE_PATH, BACKUP_PATH) if not backup_success: push_warning(存档备份失败) var file : FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file null: push_error(无法打开存档文件进行写入: SAVE_PATH) return false file.store_string(base64_string) file.close() return true func load_game() - bool: if not FileAccess.file_exists(SAVE_PATH): return false var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: return false var base64_string: String file.get_as_text() file.close() var encrypted: PackedByteArray base64_string.to_utf8_buffer() var decrypted: PackedByteArray _decrypt_data(encrypted) if decrypted.size() 0: # 尝试用备份恢复 if FileAccess.file_exists(BACKUP_PATH): return _load_from_backup() return false var json: JSON JSON.new() var error : json.parse(decrypted.get_string_from_utf8()) if error ! OK: push_error(存档 JSON 解析失败: , json.get_error_message()) return false var parsed: Variant json.data if not parsed is Dictionary: push_error(存档格式错误: 根节点不是对象) return false var version: Variant parsed.get(save_version, 0) if int(version) ! SAVE_VERSION: push_warning(存档版本不匹配当前版本: %d存档版本: %s % [SAVE_VERSION, str(version)]) # 这里可以做版本迁移示例中只提示 current_data PlayerData.from_dictionary(parsed) return true func _load_from_backup() - bool: push_warning(尝试从备份文件恢复存档) if not FileAccess.file_exists(BACKUP_PATH): return false var file : FileAccess.open(BACKUP_PATH, FileAccess.READ) if file null: return false var base64_string: String file.get_as_text() file.close() var encrypted: PackedByteArray base64_string.to_utf8_buffer() var decrypted: PackedByteArray _decrypt_data(encrypted) if decrypted.size() 0: return false var json: JSON JSON.new() var error : json.parse(decrypted.get_string_from_utf8()) if error ! OK: return false var parsed: Variant json.data if not parsed is Dictionary: return false current_data PlayerData.from_dictionary(parsed) return true func delete_save() - bool: if FileAccess.file_exists(SAVE_PATH): var err : DirAccess.remove_absolute(ProjectSettings.globalize_path(SAVE_PATH)) if err ! OK: push_error(删除存档失败: , err) return false if FileAccess.file_exists(BACKUP_PATH): DirAccess.remove_absolute(ProjectSettings.globalize_path(BACKUP_PATH)) current_data PlayerData.new() return true func _copy_file(from_path: String, to_path: String) - bool: var file : FileAccess.open(from_path, FileAccess.READ) if file null: return false var content : file.get_as_text() file.close() var out : FileAccess.open(to_path, FileAccess.WRITE) if out null: return false out.store_string(content) out.close() return true func _encrypt_data(data: PackedByteArray) - PackedByteArray: # AES-256-CBC 加密返回 Base64 字符串的字节形式 var ctx : AESContext.new() ctx.start(AESContext.MODE_CBC_ENCRYPT, AES_KEY.to_utf8_buffer(), AES_IV.to_utf8_buffer()) # 加密数据需要 16 字节对齐尾部补零 var padded_size : int(ceil(data.size() / 16.0)) * 16 var padded : data.duplicate() padded.resize(padded_size) var encrypted : ctx.update(padded) ctx.finish() return Marshalls.raw_to_base64(encrypted).to_utf8_buffer() func _decrypt_data(data: PackedByteArray) - PackedByteArray: var raw : Marshalls.base64_to_raw(data.get_string_from_utf8()) if raw.size() 0: return PackedByteArray() var ctx : AESContext.new() ctx.start(AESContext.MODE_CBC_DECRYPT, AES_KEY.to_utf8_buffer(), AES_IV.to_utf8_buffer()) var decrypted : ctx.update(raw) ctx.finish() # 移除尾部补零 var last_non_zero : decrypted.size() - 1 while last_non_zero 0 and decrypted[last_non_zero] 0: last_non_zero - 1 decrypted.resize(last_non_zero 1) return decrypted func get_save_timestamp() - int: if not FileAccess.file_exists(SAVE_PATH): return 0 var file : FileAccess.open(SAVE_PATH, FileAccess.READ) if file null: return 0 var base64_string : file.get_as_text() file.close() var raw : Marshalls.base64_to_raw(base64_string) if raw.size() 0: return 0 var ctx : AESContext.new() ctx.start(AESContext.MODE_CBC_DECRYPT, AES_KEY.to_utf8_buffer(), AES_IV.to_utf8_buffer()) var decrypted : ctx.update(raw) ctx.finish() var json : JSON.new() if json.parse(decrypted.get_string_from_utf8()) ! OK: return 0 if not json.data is Dictionary: return 0 return int(json.data.get(timestamp, 0))这段代码虽然长但核心流程只有三块加密、解密、文件读写。5.1 加密函数的实现逻辑_encrypt_data()做了四件事用AESContext.new()创建加密上下文。调用start()传入加密模式、密钥、初始化向量IV。对待加密数据做填充使其长度成为 16 的倍数。执行update()获取密文再使用Marshalls.raw_to_base64()转成可打印文本。padded_size的计算是加密最容易出错的点。AES 按 16 字节块处理明文长度如果不是 16 的倍数AESContext.update()会报错或产生不可预期的结果。这里用ceil()计算向上取整再用resize()补零逻辑比较直观。5.2 解密时处理填充_decrypt_data()中有一个细节解密后的数据尾部多出了补零字节如果不移除JSON.parse()会因为末尾含有\0而解析失败。代码中的 while 循环会把尾部所有的0字节删掉恢复到原始 JSON 文本长度。这里有一个需要提醒的地方这种“尾部补零 去除补零”的填充方式只适用于原始数据尾部本来就不是 0 字节的场景。JSON 文本以}结尾字节值不可能为 0所以可以安全区分。但如果你扩展这套代码去加密任意二进制数据比如 PNG 缩略图就不能假设尾部 0 是填充而要使用 PKCS7 等标准填充方案。在游戏存档场景中先转 JSON 再加密尾部补零方案完全够用。5.3 为什么返回的是 PackedByteArray 而不是字符串_encrypt_data()的返回值是PackedByteArray但内部实际是把 Base64 字符串转成了字节数组。这样设计是为了统一 write 和 read 路径外层调用file.store_string()时直接传入由to_utf8_buffer()得到的内容读取时用get_as_text()获得字符串两种方式对称。你也可以直接返回String但使用PackedByteArray便于后续扩展二进制文件的写入不需要改动接口。6. 在游戏主场景中接入保存与加载有了底层管理器接下来要写一个可运行的主场景来验证整套流程。6.1 主场景脚本创建scripts/main.gd并挂载到scenes/main.tscn的根节点上# 文件路径scripts/main.gd extends Node onready var status_label: Label %StatusLabel onready var gold_spin: SpinBox %GoldSpin onready var health_spin: SpinBox %HealthSpin func _on_save_pressed() - void: SaveManager.current_data.gold int(gold_spin.value) SaveManager.current_data.health int(health_spin.value) var ok : SaveManager.save_game() if ok: status_label.text 保存成功时间戳: %d % SaveManager.get_save_timestamp() else: status_label.text 保存失败请查看控制台 func _on_load_pressed() - void: var ok : SaveManager.load_game() if ok: gold_spin.value SaveManager.current_data.gold health_spin.value SaveManager.current_data.health status_label.text 加载成功金币%d生命%d % [SaveManager.current_data.gold, SaveManager.current_data.health] else: status_label.text 加载失败场景中放置一个Label用于显示状态两个SpinBox用于修改金币和生命值两个Button分别连接保存和加载信号。示例中的%StatusLabel使用了 Godot 4 的场景唯一名称Unique Name语法节点名需要在检查器中开启“作为唯一名称”。6.2 验证方式运行场景修改金币和生命值点击保存按钮。然后再次修改数值点击加载按钮观察两个控件是否恢复到保存时的数值。再检查一下user://savegame.sav文件的实际内容你会发现它不是明文 JSON而是一串 Base64 字符这证明加密链路生效了。如果你想彻底验证加密是否可靠可以尝试手动把这个.sav文件里的任意一个字符改掉再运行加载。预期结果是加载失败并看到错误日志因为解密后 JSON 解析不会通过。这就是加密方案对比明文方案的核心差异明文存档的修改几乎必定生效而加密存档在密钥安全的前提下手工修改基本不可行。7. 关于加密的安全边界说清楚这套加密方案能做到什么不能做到什么。7.1 AES 可以防篡改但不能防内存修改AES 加密让玩家无法直接编辑存档文件这是它最有价值的地方。但它解决不了另一个问题玩家在运行时通过内存修改工具改变游戏变量然后触发一次正常保存把修改后的数据写入存档。比如玩家先用修改器把内存中的金币改成 999999然后点击“保存”存档管理器收集到的数据就是已经被篡改的数据再加密也保存的是非法数据。AES 能阻止修改文件但无法鉴别数据来源是否可信。这不是加密方案的问题而是本地单机游戏的天然边界。如果你的游戏依赖数值真实性需要配合服务端校验这就超出本地存档的范畴了。7.2 密钥硬编码的问题示例中的 AES 密钥直接写死在脚本里原因是为了让教程代码最短。真实项目中这种做法非常危险——任何拿到游戏包的人都能通过反编译 GDScript 或分析导出的资源找到密钥。一个更稳妥的妥协是把密钥拆散到多个脚本或资源中定期更换再配合代码混淆工具增加逆向成本。但必须诚实地说在 Godot 本地存档中没有任何方案能做到绝对防逆向。加密的本质是提高门槛而不是制造无法逾越的障碍。对于独立游戏开发者AES 加密足以阻止 99% 的普通玩家手动修改存档对硬核逆向者任何本地方案都防不住。7.3 使用加密前要确认合规这套技术用于保护游戏存档数据合理合法。但如果你打算用相同方案处理用户隐私数据、密码、账号信息就必须额外考虑合规要求。存档中如果包含用户敏感信息建议使用系统级安全存储或平台提供的密钥链服务而不是自己在游戏代码里维护密钥。另外涉及任何形式的网络传输时AES 之外还需要考虑 TLS 等传输层安全不能把 AES 密钥直接放在客户端。8. 常见问题与排查思路在实际写这套系统时有几个问题基本每个人都会遇到。整理成表格方便对照定位。问题现象可能原因排查方式解决方案保存时FileAccess.open()返回 null路径错误或磁盘无写入权限打印SAVE_PATH实际路径检查目录是否存在使用DirAccess.make_dir_recursive_absolute()创建目录确认使用user://路径加载时 JSON 解析失败加密解密流程不对称或填充去除逻辑错误打印解密后的字符串仅在调试阶段检查密钥、IV 是否一致确认填充长度计算正确加密时报错数据大小不匹配待加密数据长度不是 16 的倍数打印data.size()和padded_size补零逻辑是否正确ceil()结果必须转 int存档能够保存但重新打开游戏后无法加载保存时用了user://加载时用了res://检查两个路径写的是否一致统一定义SAVE_PATH常量避免手写路径解密后字符串尾部出现乱码或\0填充补零没有被正确移除打印decrypted字节长度和末尾字节值使用上述 while 循环移除尾部 0或改用 PKCS7 填充修改一个字符后存档反而能加载且数值异常加密方案对 CBC 模式误用或密文长度没有对齐检查AESContext的MODE_CBC_DECRYPT参数正确配置模式和 IV确保加密解密使用同一套参数备份文件永远无法恢复备份复制时机不对主文件损坏时备份也是坏的检查备份是否先于主文件写入先复制备份、后写主文件备份文件也应定期轮换排查的时候第一件事永远是先确认加密解密是否对称。把加密后的数据在解密函数原样传回如果不恢复原始 JSON后面的文件读写都不用查了。第二个高发点是路径不一致开发时在user://和res://之间反复切换最容易漏改。9. 最佳实践与工程建议代码跑通只是第一步。下面这些实践建议能把这套存档系统从“能用”推向“好用”。9.1 存档版本号与迁移机制这次的代码中已经在 JSON 根节点写入了save_version字段加载时也做了版本比对。但真正工程化时版本不匹配不能只是 push_warning而是要设计迁移路线。推荐的做法是维护一个版本升级函数链# 假设旧版本存档结构里没有 settings 字段 # 迁移函数签名输入旧版本 Dictionary输出下一版本 Dictionary static func _migrate_1_to_2(data: Dictionary) - Dictionary: data[settings] { volume: 1.0, quality: high } return data加载时根据存档里的save_version依次调用迁移函数直到版本号对齐。这套机制投入不大但对玩家体验的价值非常高——游戏更新后旧存档继续可用是留存的基本要求。9.2 存档损坏时的用户体验不要只在控制台输出错误。对玩家而言存档损坏是重大事故你的游戏需要给出明确的反馈界面而不是默默弹出一个错误框然后崩溃。常见设计模式主存档损坏时自动尝试加载.bak备份。备份也损坏时进入“新游戏”流程但用弹窗告知玩家“检测到存档异常已启动新游戏”。如果存档彻底无法恢复至少让玩家保持在一个可操作的状态不要卡死。9.3 自动保存与延迟写入动作游戏或开放世界游戏需要自动保存但不要每秒钟写一次文件频繁写入会损耗闪存寿命也容易导致存档碎片化。推荐策略在关键节点手动触发保存过关、进入新区域、拾取重要道具。自动保存加计时器例如每 60 秒检查一次如果数据有变化才写入。保存时使用异步思路Godot 4.4 后支持Thread轻松处理后台写入但也可以先把数据序列化后用call_deferred()延迟到空闲帧写入。9.4 调试模式下的明文输出开发阶段为了快速排错可以给SaveManager增加一个debug_plaintext布尔变量为 true 时直接写入 JSON 文件不加密。发布前务必关闭。这样既能享受加密保护的收益又不影响日常开发效率。const DEBUG_PLAINTEXT : false # 在 save_game() 中 if DEBUG_PLAINTEXT: file.store_string(json_string) else: file.store_string(base64_string)9.5 JSON 中不要存放超大数组存档数据结构如果包含了大量运行时临时数据比如成千上万个坐标点JSON 序列化后的文件会变得很大加载时间也会变长。请遵循单一职责原则长期进度数据存到存档文件短生命周期的临时数据不要放进去。如果确实需要存储大型稀疏数据考虑用独立文件保存并放入user://data/子目录避免所有读写都在一个主存档文件上。10. 扩展方向与更复杂的存储需求JSON AES 的组合能够覆盖大多数单机游戏的存档需求但它不是终极答案。如果你的项目开始出现以下信号就该思考下一步技术选型了存档文件超过 5MB加载耗时明显。需要存储复杂的关系型数据比如多角色、多任务、多图鉴。需要实时同步到云端。这些场景下SQLite 可能比 JSON 更合适。Godot 4 中没有内置 SQLite 模块但可以通过 GDExtension 或第三方插件接入社区常用的方案是godot-sqlite。它把结构化数据存入数据库文件支持事务、索引和增量更新由于代码经过可靠扩展在存档复杂度和查询性能上优于纯 JSON。另一个方向是整合版本控制与自动构建流程。当你的游戏同时面向 Windows、macOS、Linux、Android 和 Web 发布时存档路径在不同平台差异很大建议为每次打包生成一个路径映射配置避免测试平台之间的存档不连通。最后如果你的目标是做出一个像样的商业游戏建议在开发早期就为存档系统写好单元测试。Godot 4 内置的GUT测试框架可以模拟保存 - 修改 - 加载 - 断言数据一致的循环保证每次改完游戏逻辑后存档链路不会意外破坏。相比等到发布后让玩家替你发现存档 bug早做测试的成本低得多。这套 JSON AES 的保存/加载方案代码量不大如果你按照本文步骤操作的话从零到跑通预计只需要一小时。但它把最容易踩坑的几处都覆盖了Vector2 的序列化、AES 块对齐、填充移除、备份恢复、版本兼容。你在实际项目中实现时可以直接复制前文的SaveManager代码替换PlayerData和具体字段就能接入大多数游戏。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑