6. 存档系统设计
2026/4/14大约 5 分钟
存档系统设计
存档系统是游戏体验的重要组成部分。一个好的存档系统应该可靠、快速、向前兼容,并且在必要时能保护玩家数据不被篡改。
6.1 存档系统架构
推荐使用分层架构,将存档逻辑与游戏逻辑解耦:
游戏逻辑层 → SaveManager(存档管理器) → 序列化层 → 存储层
(统一接口) (JSON/二进制) (本地/云端)6.2 JSON 存档实现
JSON 存档可读性好,适合开发阶段和不需要加密的游戏。
C#
using Godot;
using System.Collections.Generic;
// 存档数据结构(纯数据类,不依赖 Godot 节点)
public class SaveData
{
public int Version = 1; // 存档版本号(用于兼容性处理)
public string PlayerName = "";
public int Level = 1;
public int Gold = 0;
public float PlayTime = 0f; // 游戏时长(秒)
public List<string> Inventory = new();
public Dictionary<string, bool> Achievements = new();
public Vector2 LastPosition; // 最后位置
}
public partial class SaveManager : Node
{
private const string SAVE_DIR = "user://saves/";
private const string SAVE_FILE = "save_slot_{0}.json";
public static SaveManager Instance { get; private set; }
public override void _Ready()
{
Instance = this;
// 确保存档目录存在
DirAccess.MakeDirRecursiveAbsolute(SAVE_DIR);
}
// 保存游戏
public bool Save(SaveData data, int slot = 0)
{
string path = SAVE_DIR + string.Format(SAVE_FILE, slot);
var dict = new Godot.Collections.Dictionary
{
["version"] = data.Version,
["player_name"] = data.PlayerName,
["level"] = data.Level,
["gold"] = data.Gold,
["play_time"] = data.PlayTime,
["inventory"] = new Godot.Collections.Array(data.Inventory.ToArray()),
["last_position_x"] = data.LastPosition.X,
["last_position_y"] = data.LastPosition.Y,
};
string json = Json.Stringify(dict, "\t"); // 格式化输出,便于调试
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
if (file == null)
{
GD.PrintErr($"[Save] Failed to open file: {path}");
return false;
}
file.StoreString(json);
GD.Print($"[Save] Saved to slot {slot}");
return true;
}
// 读取存档
public SaveData Load(int slot = 0)
{
string path = SAVE_DIR + string.Format(SAVE_FILE, slot);
if (!FileAccess.FileExists(path))
{
GD.Print($"[Save] No save file at slot {slot}");
return null;
}
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
if (file == null) return null;
string json = file.GetAsText();
var parsed = Json.ParseString(json);
if (parsed.VariantType == Variant.Type.Nil)
{
GD.PrintErr("[Save] Failed to parse save file");
return null;
}
var dict = parsed.AsGodotDictionary();
return MigrateAndDeserialize(dict);
}
// 版本迁移:处理旧版存档格式
private SaveData MigrateAndDeserialize(Godot.Collections.Dictionary dict)
{
int version = dict.ContainsKey("version") ? dict["version"].AsInt32() : 0;
// 版本 0 → 1:添加 play_time 字段
if (version < 1 && !dict.ContainsKey("play_time"))
dict["play_time"] = 0f;
var data = new SaveData
{
Version = 1, // 迁移后统一为最新版本
PlayerName = dict["player_name"].AsString(),
Level = dict["level"].AsInt32(),
Gold = dict["gold"].AsInt32(),
PlayTime = dict["play_time"].AsSingle(),
LastPosition = new Vector2(
dict["last_position_x"].AsSingle(),
dict["last_position_y"].AsSingle()
),
};
if (dict.ContainsKey("inventory"))
{
foreach (var item in dict["inventory"].AsGodotArray())
data.Inventory.Add(item.AsString());
}
return data;
}
// 删除存档
public void DeleteSave(int slot = 0)
{
string path = SAVE_DIR + string.Format(SAVE_FILE, slot);
if (FileAccess.FileExists(path))
DirAccess.RemoveAbsolute(path);
}
// 检查存档是否存在
public bool HasSave(int slot = 0)
{
return FileAccess.FileExists(SAVE_DIR + string.Format(SAVE_FILE, slot));
}
}GDScript
extends Node
const SAVE_DIR := "user://saves/"
const SAVE_FILE := "save_slot_%d.json"
static var instance: Node
func _ready() -> void:
instance = self
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
func save(data: Dictionary, slot: int = 0) -> bool:
var path := SAVE_DIR + SAVE_FILE % slot
var json := JSON.stringify(data, "\t")
var file := FileAccess.open(path, FileAccess.WRITE)
if not file:
push_error("[Save] Failed to open file: %s" % path)
return false
file.store_string(json)
print("[Save] Saved to slot %d" % slot)
return true
func load_save(slot: int = 0) -> Dictionary:
var path := SAVE_DIR + SAVE_FILE % slot
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return {}
var json_text := file.get_as_text()
var data = JSON.parse_string(json_text)
if not data is Dictionary:
push_error("[Save] Failed to parse save file")
return {}
return _migrate(data)
func _migrate(data: Dictionary) -> Dictionary:
var version: int = data.get("version", 0)
if version < 1:
data.setdefault("play_time", 0.0)
data["version"] = 1
return data
func delete_save(slot: int = 0) -> void:
var path := SAVE_DIR + SAVE_FILE % slot
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
func has_save(slot: int = 0) -> bool:
return FileAccess.file_exists(SAVE_DIR + SAVE_FILE % slot)6.3 二进制存档(更小、更快)
对于大型存档数据,二进制格式比 JSON 小 3-5 倍,读写速度也更快。
C#
using Godot;
public partial class BinarySaveManager : Node
{
private const string SAVE_PATH = "user://save.bin";
public bool SaveBinary(SaveData data)
{
using var file = FileAccess.Open(SAVE_PATH, FileAccess.ModeFlags.Write);
if (file == null) return false;
// 写入魔数(用于验证文件格式)
file.Store32(0x53415645); // "SAVE" in ASCII
file.Store32((uint)data.Version);
file.StorePascalString(data.PlayerName); // 带长度前缀的字符串
file.Store32((uint)data.Level);
file.Store32((uint)data.Gold);
file.StoreFloat(data.PlayTime);
file.StoreFloat(data.LastPosition.X);
file.StoreFloat(data.LastPosition.Y);
// 写入背包
file.Store32((uint)data.Inventory.Count);
foreach (var item in data.Inventory)
file.StorePascalString(item);
return true;
}
public SaveData LoadBinary()
{
if (!FileAccess.FileExists(SAVE_PATH)) return null;
using var file = FileAccess.Open(SAVE_PATH, FileAccess.ModeFlags.Read);
if (file == null) return null;
// 验证魔数
uint magic = file.Get32();
if (magic != 0x53415645)
{
GD.PrintErr("[Save] Invalid binary save file");
return null;
}
var data = new SaveData
{
Version = (int)file.Get32(),
PlayerName = file.GetPascalString(),
Level = (int)file.Get32(),
Gold = (int)file.Get32(),
PlayTime = file.GetFloat(),
LastPosition = new Vector2(file.GetFloat(), file.GetFloat()),
};
int inventoryCount = (int)file.Get32();
for (int i = 0; i < inventoryCount; i++)
data.Inventory.Add(file.GetPascalString());
return data;
}
}GDScript
extends Node
const SAVE_PATH := "user://save.bin"
func save_binary(data: Dictionary) -> bool:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if not file:
return false
file.store_32(0x53415645) # 魔数 "SAVE"
file.store_32(data.get("version", 1))
file.store_pascal_string(data.get("player_name", ""))
file.store_32(data.get("level", 1))
file.store_32(data.get("gold", 0))
file.store_float(data.get("play_time", 0.0))
file.store_float(data.get("last_position_x", 0.0))
file.store_float(data.get("last_position_y", 0.0))
var inventory: Array = data.get("inventory", [])
file.store_32(inventory.size())
for item in inventory:
file.store_pascal_string(item)
return true
func load_binary() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if not file:
return {}
var magic := file.get_32()
if magic != 0x53415645:
push_error("[Save] Invalid binary save file")
return {}
var data := {
"version": file.get_32(),
"player_name": file.get_pascal_string(),
"level": file.get_32(),
"gold": file.get_32(),
"play_time": file.get_float(),
"last_position_x": file.get_float(),
"last_position_y": file.get_float(),
}
var count := file.get_32()
var inventory := []
for i in count:
inventory.append(file.get_pascal_string())
data["inventory"] = inventory
return data6.4 存档加密
防止玩家修改存档(防作弊)。
C#
using Godot;
public partial class EncryptedSaveManager : Node
{
// 注意:密钥应该从服务器获取或混淆存储,不要硬编码
private const string ENCRYPTION_KEY = "your-secret-key-32-chars-minimum!";
private const string SAVE_PATH = "user://save_enc.bin";
public bool SaveEncrypted(string jsonData)
{
// Godot 内置 AES-256-CBC 加密
using var file = FileAccess.OpenEncryptedWithPass(
SAVE_PATH, FileAccess.ModeFlags.Write, ENCRYPTION_KEY);
if (file == null) return false;
file.StoreString(jsonData);
return true;
}
public string LoadEncrypted()
{
if (!FileAccess.FileExists(SAVE_PATH)) return null;
using var file = FileAccess.OpenEncryptedWithPass(
SAVE_PATH, FileAccess.ModeFlags.Read, ENCRYPTION_KEY);
if (file == null) return null;
return file.GetAsText();
}
}GDScript
extends Node
const ENCRYPTION_KEY := "your-secret-key-32-chars-minimum!"
const SAVE_PATH := "user://save_enc.bin"
func save_encrypted(json_data: String) -> bool:
var file := FileAccess.open_encrypted_with_pass(
SAVE_PATH, FileAccess.WRITE, ENCRYPTION_KEY)
if not file:
return false
file.store_string(json_data)
return true
func load_encrypted() -> String:
if not FileAccess.file_exists(SAVE_PATH):
return ""
var file := FileAccess.open_encrypted_with_pass(
SAVE_PATH, FileAccess.READ, ENCRYPTION_KEY)
if not file:
return ""
return file.get_as_text()6.5 自动存档与存档槽管理
C#
using Godot;
public partial class AutoSaveSystem : Node
{
[Export] private float _autoSaveInterval = 300f; // 每5分钟自动存档
private float _timer = 0f;
public override void _Process(double delta)
{
_timer += (float)delta;
if (_timer >= _autoSaveInterval)
{
_timer = 0f;
AutoSave();
}
}
private void AutoSave()
{
// 自动存档到专用槽(不占用玩家手动存档槽)
var data = CollectGameData();
SaveManager.Instance.Save(data, slot: 99); // 槽99为自动存档
GD.Print("[AutoSave] Auto saved");
}
private SaveData CollectGameData()
{
// 从各个游戏系统收集数据
return new SaveData
{
PlayerName = "Player",
Level = 5,
Gold = 1000,
PlayTime = _timer,
};
}
}GDScript
extends Node
@export var auto_save_interval: float = 300.0
var _timer := 0.0
func _process(delta: float) -> void:
_timer += delta
if _timer >= auto_save_interval:
_timer = 0.0
_auto_save()
func _auto_save() -> void:
var data := _collect_game_data()
SaveManager.instance.save(data, 99) # 槽99为自动存档
print("[AutoSave] Auto saved")
func _collect_game_data() -> Dictionary:
return {
"version": 1,
"player_name": "Player",
"level": 5,
"gold": 1000,
"play_time": _timer,
}6.6 存档系统最佳实践
| 实践 | 说明 |
|---|---|
| 版本号 | 每次修改存档格式时递增版本号 |
| 迁移函数 | 为每个版本升级编写迁移逻辑 |
| 备份 | 保存前先备份旧存档(save.bak) |
| 原子写入 | 先写临时文件,成功后再替换正式文件 |
| 校验和 | 存储数据的哈希值,检测文件损坏 |
存档路径
user:// 路径在不同平台指向不同位置:Windows 是 %APPDATA%\Godot\app_userdata\,Android 是应用私有目录。不要使用 res:// 存储存档,发布后该目录是只读的。
