11. 存档系统设计
2026/4/14大约 11 分钟
存档系统设计
玩了一下午的游戏,突然断电——如果你没有存档系统,玩家之前所有的心血就全白费了。存档系统就是把游戏的"当前状态"拍一张快照,保存到硬盘上。下次打开游戏时,读出这张快照,游戏就能从上次停下的地方继续。
本章你将学到
- 存档系统设计原则:什么需要保存、什么不需要
- JSON 序列化存档(人类可读)
- 二进制存档(更小更快)
- 存档数据结构设计
- 多存档槽位管理
- 自动存档机制
- 存档加密与防篡改
- 云存档实现思路
存档系统设计原则
什么需要保存
不是游戏中的所有东西都需要存档。想象你在拍照——你只需要记录画面中重要的东西,不需要记录每一粒灰尘。
原则总结
- 只保存必要数据:能重新计算的就不存
- 版本号必须带上:游戏更新后存档格式可能变化,版本号帮你做兼容
- 不要保存 Godot 节点引用:节点路径会变化,用路径字符串或 ID 代替
- 先序列化再保存:把游戏数据转成 JSON 或二进制,再写入文件
存档数据结构设计
一个好的存档结构应该清晰、可扩展。以下是推荐的层级结构:
JSON 序列化存档
JSON 存档的优点是人类可读——你可以用记事本打开查看和调试。缺点是文件较大、读写较慢。
C#
using Godot;
using System.Text.Json;
using System.Collections.Generic;
public class SaveData
{
public string Version { get; set; } = "1.0";
public string SaveTime { get; set; }
public PlayerData Player { get; set; }
public WorldData World { get; set; }
public List<QuestData> Quests { get; set; } = new();
}
public class PlayerData
{
public float PosX { get; set; }
public float PosY { get; set; }
public float PosZ { get; set; }
public float RotY { get; set; }
public float Health { get; set; }
public int Level { get; set; }
public Dictionary<string, int> Inventory { get; set; } = new();
}
public class WorldData
{
public string CurrentScene { get; set; }
public Dictionary<string, bool> ObjectStates { get; set; } = new();
public List<string> DiscoveredAreas { get; set; } = new();
}
public class QuestData
{
public string QuestId { get; set; }
public string State { get; set; } // "active", "completed", "failed"
public int Progress { get; set; }
}
public partial class SaveManager : Node
{
private const string SAVE_DIR = "user://saves/";
private const string FILE_EXTENSION = ".json";
public override void _Ready()
{
// 确保存档目录存在
DirAccess.MakeDirRecursiveAbsolute(SAVE_DIR);
}
/// <summary>
/// 保存游戏到指定槽位
/// </summary>
public void SaveGame(int slot)
{
var saveData = new SaveData
{
SaveTime = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
Player = CollectPlayerData(),
World = CollectWorldData(),
Quests = CollectQuestData()
};
string json = JsonSerializer.Serialize(saveData, new JsonSerializerOptions
{
WriteIndented = true
});
string path = SAVE_DIR + $"save_{slot}{FILE_EXTENSION}";
var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
if (file != null)
{
file.StoreString(json);
file.Close();
GD.Print($"存档已保存到: {path}");
}
else
{
GD.PrintErr($"保存失败: {FileAccess.GetOpenError()}");
}
}
/// <summary>
/// 从指定槽位加载游戏
/// </summary>
public SaveData LoadGame(int slot)
{
string path = SAVE_DIR + $"save_{slot}{FILE_EXTENSION}";
if (!FileAccess.FileExists(path))
{
GD.PrintErr($"存档不存在: {path}");
return null;
}
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
string json = file.GetAsText();
file.Close();
var saveData = JsonSerializer.Deserialize<SaveData>(json);
GD.Print($"存档已加载: 版本 {saveData.Version}, 时间 {saveData.SaveTime}");
return saveData;
}
/// <summary>
/// 获取所有存档槽位信息(用于显示存档列表)
/// </summary>
public Godot.Collections.Array<Godot.Collections.Dictionary> GetSaveSlots()
{
var slots = new Godot.Collections.Array<Godot.Collections.Dictionary>();
var dir = DirAccess.Open(SAVE_DIR);
if (dir == null) return slots;
dir.ListDirBegin();
string fileName = dir.GetNext();
while (fileName != "")
{
if (fileName.EndsWith(FILE_EXTENSION))
{
var file = FileAccess.Open(SAVE_DIR + fileName, FileAccess.ModeFlags.Read);
string json = file.GetAsText();
file.Close();
var data = JsonSerializer.Deserialize<SaveData>(json);
var info = new Godot.Collections.Dictionary
{
{ "file_name", fileName },
{ "save_time", data.SaveTime },
{ "level", data.Player.Level },
{ "scene", data.World.CurrentScene }
};
slots.Add(info);
}
fileName = dir.GetNext();
}
return slots;
}
private PlayerData CollectPlayerData()
{
var player = GetTree().GetFirstNodeInGroup("player") as CharacterBody3D;
if (player == null) return new PlayerData();
return new PlayerData
{
PosX = player.GlobalPosition.X,
PosY = player.GlobalPosition.Y,
PosZ = player.GlobalPosition.Z,
RotY = player.Rotation.Y,
Health = (float)player.Get("health"),
Level = (int)player.Get("level"),
// 背包数据从 Inventory 组件收集
Inventory = new Dictionary<string, int>
{
{ "sword_iron", 1 },
{ "potion_health", 5 }
}
};
}
private WorldData CollectWorldData()
{
return new WorldData
{
CurrentScene = GetTree().CurrentScene.SceneFilePath,
ObjectStates = new Dictionary<string, bool>
{
{ "chest_room1", true },
{ "door_boss", false }
}
};
}
private List<QuestData> CollectQuestData()
{
return new List<QuestData>
{
new QuestData { QuestId = "main_01", State = "completed", Progress = 100 },
new QuestData { QuestId = "side_herbs", State = "active", Progress = 3 }
};
}
}GDScript
extends Node
const SAVE_DIR: String = "user://saves/"
const FILE_EXTENSION: String = ".json"
func _ready():
# 确保存档目录存在
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
## 保存游戏到指定槽位
func save_game(slot: int) -> bool:
var save_data = {
"version": "1.0",
"save_time": Time.get_datetime_string_from_system(),
"player": _collect_player_data(),
"world": _collect_world_data(),
"quests": _collect_quest_data()
}
var json_string = JSON.stringify(save_data, "\t")
var path = SAVE_DIR + "save_%d%s" % [slot, FILE_EXTENSION]
var file = FileAccess.open(path, FileAccess.WRITE)
if file:
file.store_string(json_string)
file.close()
print("存档已保存到: ", path)
return true
else:
push_error("保存失败: ", FileAccess.get_open_error())
return false
## 从指定槽位加载游戏
func load_game(slot: int) -> Dictionary:
var path = SAVE_DIR + "save_%d%s" % [slot, FILE_EXTENSION]
if not FileAccess.file_exists(path):
push_error("存档不存在: ", path)
return {}
var file = FileAccess.open(path, FileAccess.READ)
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
var error = json.parse(json_string)
if error != OK:
push_error("解析存档失败: ", json.error_string)
return {}
var save_data = json.data
print("存档已加载: 版本 %s, 时间 %s" % [save_data.version, save_data.save_time])
return save_data
## 获取所有存档槽位信息(用于显示存档列表)
func get_save_slots() -> Array:
var slots = []
var dir = DirAccess.open(SAVE_DIR)
if dir == null:
return slots
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if file_name.ends_with(FILE_EXTENSION):
var file = FileAccess.open(SAVE_DIR + file_name, FileAccess.READ)
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
json.parse(json_string)
var data = json.data
slots.append({
"file_name": file_name,
"save_time": data.save_time,
"level": data.player.level,
"scene": data.world.current_scene
})
file_name = dir.get_next()
return slots
## 删除存档
func delete_save(slot: int) -> bool:
var path = SAVE_DIR + "save_%d%s" % [slot, FILE_EXTENSION]
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
print("存档已删除: ", path)
return true
return false
func _collect_player_data() -> Dictionary:
var player = get_tree().get_first_node_in_group("player")
if player == null:
return {}
return {
"pos_x": player.global_position.x,
"pos_y": player.global_position.y,
"pos_z": player.global_position.z,
"rot_y": player.rotation.y,
"health": player.health,
"level": player.level,
"inventory": {
"sword_iron": 1,
"potion_health": 5
}
}
func _collect_world_data() -> Dictionary:
return {
"current_scene": get_tree().current_scene.scene_file_path,
"object_states": {
"chest_room1": true,
"door_boss": false
}
}
func _collect_quest_data() -> Array:
return [
{ "quest_id": "main_01", "state": "completed", "progress": 100 },
{ "quest_id": "side_herbs", "state": "active", "progress": 3 }
]二进制存档
二进制存档的优点是文件更小、读写更快、不易被玩家直接修改。适合正式发布的游戏。
C#
using Godot;
using System.Collections.Generic;
public partial class BinarySaveManager : Node
{
private const string SAVE_DIR = "user://saves/";
public void SaveBinary(int slot, Dictionary<string, Variant> data)
{
string path = SAVE_DIR + $"save_{slot}.dat";
var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
if (file == null)
{
GD.PrintErr("无法创建存档文件");
return;
}
// 写入版本号
file.StoreString("SAVE_V1");
// 写入时间戳
file.Store64((ulong)Time.GetTicksMsec());
// 使用 Godot 内置的二进制序列化
// 把 Dictionary 转为字节数组
var packed = new Godot.Collections.Dictionary(data);
var bytes = VarToBytes(packed);
file.Store64((ulong)bytes.Length); // 先存数据长度
file.StoreBuffer(bytes);
file.Close();
GD.Print($"二进制存档已保存: {path}");
}
public Godot.Collections.Dictionary LoadBinary(int slot)
{
string path = SAVE_DIR + $"save_{slot}.dat";
if (!FileAccess.FileExists(path)) return null;
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
string version = file.GetLine();
ulong timestamp = file.Get64();
ulong dataLength = file.Get64();
byte[] bytes = file.GetBuffer(dataLength);
file.Close();
var data = (Godot.Collections.Dictionary)BytesToVar(bytes);
GD.Print($"二进制存档已加载, 版本: {version}");
return data;
}
// 辅助方法
private byte[] VarToBytes(Godot.Collections.Dictionary dict)
{
var streams = new Godot.Collections.Array { dict };
return Godot.Json.Stringify(dict).ToUtf8Buffer();
}
private Variant BytesToVar(byte[] bytes)
{
string json = bytes.GetStringFromUtf8();
var parsed = Godot.Json.ParseString(json);
return parsed;
}
}GDScript
extends Node
const SAVE_DIR: String = "user://saves/"
func save_binary(slot: int, data: Dictionary) -> bool:
var path = SAVE_DIR + "save_%d.dat" % slot
var file = FileAccess.open(path, FileAccess.WRITE)
if file == null:
push_error("无法创建存档文件")
return false
# 写入版本号
file.store_line("SAVE_V1")
# 写入时间戳
file.store_64(Time.get_ticks_msec())
# 使用 JSON 序列化后转二进制
var json_string = JSON.stringify(data)
var bytes = json_string.to_utf8_buffer()
file.store_64(bytes.size()) # 先存数据长度
file.store_buffer(bytes) # 再存数据
file.close()
print("二进制存档已保存: ", path)
return true
func load_binary(slot: int) -> Dictionary:
var path = SAVE_DIR + "save_%d.dat" % slot
if not FileAccess.file_exists(path):
return {}
var file = FileAccess.open(path, FileAccess.READ)
var version = file.get_line()
var timestamp = file.get_64()
var data_length = file.get_64()
var bytes = file.get_buffer(data_length)
file.close()
var json_string = bytes.get_string_from_utf8()
var json = JSON.new()
json.parse(json_string)
print("二进制存档已加载, 版本: ", version)
return json.data多存档槽位管理
让玩家可以在多个存档之间切换,就像游戏机上的多个存档位。
C#
using Godot;
using System.Linq;
public partial class SlotManager : Control
{
private const int MAX_SLOTS = 10;
private SaveManager _saveManager;
public override void _Ready()
{
_saveManager = GetNode<SaveManager>("/root/SaveManager");
RefreshSlotList();
}
/// <summary>
/// 刷新存档列表 UI
/// </summary>
public void RefreshSlotList()
{
var slots = _saveManager.GetSaveSlots();
var container = GetNode<VBoxContainer>("SlotContainer");
// 清空现有显示
foreach (var child in container.GetChildren())
child.QueueFree();
// 显示已存在的存档
foreach (var slotInfo in slots)
{
var button = new Button();
button.Text = $"存档: {slotInfo["save_time"]} - 等级 {slotInfo["level"]}";
string fileName = (string)slotInfo["file_name"];
button.Pressed += () => OnSlotSelected(fileName);
container.AddChild(button);
}
// 显示空槽位
int usedSlots = slots.Count;
for (int i = usedSlots; i < MAX_SLOTS; i++)
{
var button = new Button();
button.Text = $"空槽位 {i + 1}";
int slotIndex = i + 1;
button.Pressed += () => OnNewSlot(slotIndex);
container.AddChild(button);
}
}
private void OnSlotSelected(string fileName)
{
GD.Print($"选择了存档: {fileName}");
// 加载存档...
}
private void OnNewSlot(int slot)
{
_saveManager.SaveGame(slot);
RefreshSlotList();
}
}GDScript
extends Control
const MAX_SLOTS: int = 10
@onready var save_manager: Node = %SaveManager
@onready var slot_container: VBoxContainer = $SlotContainer
func _ready():
refresh_slot_list()
## 刷新存档列表 UI
func refresh_slot_list():
# 清空现有显示
for child in slot_container.get_children():
child.queue_free()
var slots = save_manager.get_save_slots()
# 显示已存在的存档
for slot_info in slots:
var button = Button.new()
button.text = "存档: %s - 等级 %d" % [slot_info.save_time, slot_info.level]
var file_name: String = slot_info.file_name
button.pressed.connect(_on_slot_selected.bind(file_name))
slot_container.add_child(button)
# 显示空槽位
var used_slots = slots.size()
for i in range(used_slots, MAX_SLOTS):
var button = Button.new()
button.text = "空槽位 %d" % (i + 1)
var slot_index = i + 1
button.pressed.connect(_on_new_slot.bind(slot_index))
slot_container.add_child(button)
func _on_slot_selected(file_name: String):
print("选择了存档: ", file_name)
# 加载存档...
func _on_new_slot(slot: int):
save_manager.save_game(slot)
refresh_slot_list()自动存档机制
自动存档防止玩家忘记手动保存而丢失进度。通常在以下时机触发自动存档:
- 进入新场景时
- 完成重要任务时
- 每隔 N 分钟定时保存
- 玩家打开菜单时
C#
using Godot;
public partial class AutoSave : Node
{
[Export] public float AutoSaveInterval { get; set; } = 300.0f; // 5分钟
[Export] public int AutoSaveSlot { get; set; } = 0; // 槽位0为自动存档
private double _timer;
private SaveManager _saveManager;
public override void _Ready()
{
_saveManager = GetNode<SaveManager>("/root/SaveManager");
// 监听场景切换
GetTree().TreeChanged += OnSceneChanged;
}
public override void _Process(double delta)
{
_timer += delta;
if (_timer >= AutoSaveInterval)
{
_timer = 0;
PerformAutoSave();
}
}
private void PerformAutoSave()
{
GD.Print("正在自动存档...");
_saveManager.SaveGame(AutoSaveSlot);
GD.Print("自动存档完成");
}
private void OnSceneChanged()
{
// 场景切换时自动存档
PerformAutoSave();
}
}GDScript
extends Node
@export var auto_save_interval: float = 300.0 # 5分钟
@export var auto_save_slot: int = 0 # 槽位0为自动存档
var timer: float = 0.0
var save_manager: Node
func _ready():
save_manager = get_node("/root/SaveManager")
func _process(delta):
timer += delta
if timer >= auto_save_interval:
timer = 0.0
_perform_auto_save()
func _perform_auto_save():
print("正在自动存档...")
save_manager.save_game(auto_save_slot)
print("自动存档完成")
## 场景切换时调用(由外部触发)
func on_scene_changed():
_perform_auto_save()存档加密与防篡改
如果你想防止玩家修改存档(比如改金币数量),可以对存档数据进行加密和签名。
C#
using Godot;
using System.Text.Json;
public partial class SecureSaveManager : Node
{
// 使用 FileAccess 加密存储(Godot 内置)
private const string ENCRYPTION_KEY = "YourSecretKey123!";
public void SaveEncrypted(int slot, object data)
{
string path = $"user://saves/secure_{slot}.enc";
string json = JsonSerializer.Serialize(data);
var file = FileAccess.OpenEncryptedWithPass(
path, FileAccess.ModeFlags.Write, ENCRYPTION_KEY);
if (file != null)
{
file.StoreString(json);
file.Close();
// 额外写入校验文件(哈希值)
string hash = ComputeHash(json);
var hashFile = FileAccess.Open(
$"user://saves/secure_{slot}.hash", FileAccess.ModeFlags.Write);
hashFile?.StoreString(hash);
hashFile?.Close();
GD.Print("加密存档已保存");
}
}
public string LoadEncrypted(int slot)
{
string path = $"user://saves/secure_{slot}.enc";
var file = FileAccess.OpenEncryptedWithPass(
path, FileAccess.ModeFlags.Read, ENCRYPTION_KEY);
if (file != null)
{
string json = file.GetAsText();
file.Close();
// 验证哈希
string hashPath = $"user://saves/secure_{slot}.hash";
if (FileAccess.FileExists(hashPath))
{
var hashFile = FileAccess.Open(hashPath, FileAccess.ModeFlags.Read);
string savedHash = hashFile.GetAsText();
hashFile.Close();
string currentHash = ComputeHash(json);
if (savedHash != currentHash)
{
GD.PrintErr("存档校验失败!存档可能被篡改。");
return null;
}
}
GD.Print("加密存档已加载并验证通过");
return json;
}
return null;
}
private string ComputeHash(string input)
{
// 简单的哈希计算(生产环境建议用 SHA256)
var bytes = input.ToUtf8Buffer();
ulong hash = 5381;
foreach (byte b in bytes)
{
hash = ((hash << 5) + hash) + b;
}
return hash.ToString();
}
}GDScript
extends Node
const ENCRYPTION_KEY: String = "YourSecretKey123!"
## 加密保存
func save_encrypted(slot: int, data: Dictionary) -> bool:
var path = "user://saves/secure_%d.enc" % slot
var json_string = JSON.stringify(data)
var file = FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, ENCRYPTION_KEY)
if file:
file.store_string(json_string)
file.close()
# 额外写入校验文件(哈希值)
var hash_value = _compute_hash(json_string)
var hash_file = FileAccess.open(
"user://saves/secure_%d.hash" % slot, FileAccess.WRITE)
if hash_file:
hash_file.store_string(hash_value)
hash_file.close()
print("加密存档已保存")
return true
return false
## 加密加载
func load_encrypted(slot: int) -> Dictionary:
var path = "user://saves/secure_%d.enc" % slot
var file = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, ENCRYPTION_KEY)
if file:
var json_string = file.get_as_text()
file.close()
# 验证哈希
var hash_path = "user://saves/secure_%d.hash" % slot
if FileAccess.file_exists(hash_path):
var hash_file = FileAccess.open(hash_path, FileAccess.READ)
var saved_hash = hash_file.get_as_text()
hash_file.close()
var current_hash = _compute_hash(json_string)
if saved_hash != current_hash:
push_error("存档校验失败!存档可能被篡改。")
return {}
print("加密存档已加载并验证通过")
var json = JSON.new()
json.parse(json_string)
return json.data
return {}
## 简单哈希计算
func _compute_hash(input: String) -> String:
var hash_value: int = 5381
for ch in input:
hash_value = ((hash_value << 5) + hash_value) + ch.ord()
return str(hash_value)防篡改的局限性
客户端加密只能防"君子"不能防"小人"。真正的安全需要服务端验证。如果你的游戏有在线功能,关键数据(金币、装备)应该以服务端为准。本地存档的加密主要是防止普通玩家用记事本改数字。
云存档思路
云存档就是把存档文件上传到远程服务器。基本流程:
实现要点:
- 用户身份认证(登录系统)
- 存档上传/下载的 HTTP 接口
- 冲突解决策略(本地 vs 云端,谁的更新?以时间戳为准)
- 离线支持(先存本地,联网后同步)
本章小结
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| JSON 存档 | 人类可读,调试方便 | 文件较大,易被修改 | 开发阶段、小型游戏 |
| 二进制存档 | 文件小,读写快 | 不可直接阅读 | 正式发布 |
| 加密存档 | 防篡改 | 增加复杂度 | 竞技游戏、在线游戏 |
| 云存档 | 跨设备同步 | 需要服务器 | 在线游戏 |
相关章节
- 基础篇 - 文件结构:文件读写基础
- 网络同步:云存档的网络通信
- Mod 系统:Mod 与存档的兼容性
