2. 存档系统
存档系统
你有没有过这样的经历:打游戏打到一半突然断电,再打开游戏发现进度全没了?那种感觉就像写了一篇三千字的作文,电脑突然蓝屏——崩溃到想砸键盘。
存档系统就是游戏里的"自动保存"和"手动保存"功能。它让玩家关掉游戏后,下次打开还能接着玩。本章教你从零搭建一个完整的存档系统。
存档需求分析——什么该存、什么不该存
在设计存档系统之前,先想清楚一个问题:游戏里哪些数据值得存下来?
打个比方:你搬家的时候,不会把垃圾也带走,只会打包有用的东西。存档也一样。
该存的数据
| 数据类型 | 举例 | 说明 |
|---|---|---|
| 玩家属性 | 等级、经验值、生命值、攻击力 | 玩家辛苦培养的核心数据 |
| 背包物品 | 装备、道具、材料 | 玩家收集的一切 |
| 任务进度 | 已完成的任务、当前任务步骤 | 剧情推进的记录 |
| 世界状态 | 已解锁的区域、NPC的好感度 | 开放世界游戏必备 |
| 位置信息 | 玩家在地图上的坐标 | 下次进来站在老地方 |
不该存的数据
| 数据类型 | 举例 | 说明 |
|---|---|---|
| 临时特效 | 粒子动画、屏幕震动 | 关掉游戏重新来就行 |
| 动态物体 | 散落的金币、临时生成的敌人 | 每次进入场景重新生成 |
| UI状态 | 菜单是否打开、滚动条位置 | 无关紧要 |
| 运行时缓存 | 路径计算结果、临时变量 | 重新计算即可 |
记住这个原则
存档只存"不可重现"的数据。 能从代码逻辑重新生成的,就不需要存。比如关卡里敌人的初始位置是场景文件定义的,不需要存进存档;但如果你打死了某个BOSS、这个BOSS不应该再出现,那"BOSS已死"这个状态就需要存。
JSON 存档方案——最常用的存档格式
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式。你可以把它理解成"用文字画一个表格"——人类能看懂,电脑也能读懂。
一个典型的 JSON 存档长这样:
{
"player": {
"name": "勇者小明",
"level": 15,
"hp": 80,
"maxHp": 100,
"position": { "x": 120.5, "y": 340.2 }
},
"inventory": [
{ "id": "sword_001", "count": 1 },
{ "id": "potion_hp", "count": 5 }
],
"quests": {
"mainQuestProgress": 3,
"completedQuests": [101, 102, 105]
},
"gameTime": 3600,
"saveTime": "2026-04-14T20:30:00"
}序列化:把游戏数据变成 JSON 文本
"序列化"就是把内存里的游戏对象,转换成可以写入文件的文本格式。就像把乐高城堡拆开,按照说明书一五一十地记录下来,以后可以照着重新拼。
using Godot;
using System;
using System.IO;
using System.Text.Json;
using System.Collections.Generic;
public partial class SaveManager : Node
{
// Godot 的 user:// 路径会根据操作系统自动选择合适的位置
// Windows: C:/Users/用户名/AppData/Roaming/游戏名/
// Android: /data/data/包名/files/
// iOS: .../Library/Application Support/
private const string SaveDir = "user://saves/";
// 定义存档数据结构
public class SaveData
{
public string PlayerName { get; set; } = "";
public int PlayerLevel { get; set; }
public float Hp { get; set; }
public float MaxHp { get; set; }
public float PosX { get; set; }
public float PosY { get; set; }
public List<InventoryItem> Inventory { get; set; } = new();
public int MainQuestProgress { get; set; }
public List<int> CompletedQuests { get; set; } = new();
public float GameTime { get; set; }
public string SaveTime { get; set; } = "";
}
public class InventoryItem
{
public string Id { get; set; } = "";
public int Count { get; set; }
}
// 保存游戏——把 SaveData 对象写入 JSON 文件
public void SaveGame(SaveData data, int slot = 1)
{
try
{
// 确保存档目录存在
string dirPath = ProjectSettings.GlobalizePath(SaveDir);
if (!DirAccess.DirExistsAbsolute(dirPath))
{
DirAccess.MakeDirRecursiveAbsolute(dirPath);
}
// 记录保存时间
data.SaveTime = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss");
// 序列化为 JSON 字符串(美化输出,方便调试)
string json = JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = true
});
// 写入文件
string filePath = SaveDir + $"save_{slot}.json";
File.WriteAllText(ProjectSettings.GlobalizePath(filePath), json);
GD.Print($"存档成功!槽位 {slot}");
}
catch (Exception e)
{
GD.PrintErr($"存档失败:{e.Message}");
}
}
}extends Node
const SaveDir := "user://saves/"
# 定义存档数据(用字典表示)
# GDScript 没有类,所以用嵌套字典来组织数据
var save_data: Dictionary = {
"player_name": "",
"player_level": 1,
"hp": 100.0,
"max_hp": 100.0,
"pos_x": 0.0,
"pos_y": 0.0,
"inventory": [],
"main_quest_progress": 0,
"completed_quests": [],
"game_time": 0.0,
"save_time": ""
}
# 保存游戏——把字典写入 JSON 文件
func save_game(slot: int = 1) -> void:
# 确保存档目录存在
var dir := DirAccess.open("user://")
if not dir.dir_exists("saves"):
dir.make_dir("saves")
# 记录保存时间
save_data["save_time"] = Time.get_datetime_string_from_system()
# 序列化为 JSON 字符串
var json_string := JSON.stringify(save_data, " ") # 缩进美化
# 写入文件
var file_path := SaveDir + "save_%d.json" % slot
var file := FileAccess.open(file_path, FileAccess.WRITE)
if file:
file.store_string(json_string)
file.close()
print("存档成功!槽位 %d" % slot)
else:
push_error("存档失败:%s" % FileAccess.get_open_error())反序列化:从 JSON 文本恢复游戏数据
"反序列化"就是序列化的逆过程——把文件里的文本,还原成内存里的游戏对象。就像照着说明书把乐高城堡重新拼起来。
using System.Text.Json;
public partial class SaveManager : Node
{
// 加载游戏——从 JSON 文件读取数据
public SaveData LoadGame(int slot = 1)
{
string filePath = SaveDir + $"save_{slot}.json";
string fullPath = ProjectSettings.GlobalizePath(filePath);
if (!File.Exists(fullPath))
{
GD.Print($"槽位 {slot} 没有存档");
return null;
}
try
{
string json = File.ReadAllText(fullPath);
SaveData data = JsonSerializer.Deserialize<SaveData>(json);
GD.Print($"读档成功!玩家 {data.PlayerName},等级 {data.PlayerLevel}");
return data;
}
catch (Exception e)
{
GD.PrintErr($"读档失败:{e.Message}");
return null;
}
}
// 获取所有存档的信息(用于存档选择界面)
public Dictionary<int, string> GetAllSaveInfos()
{
var infos = new Dictionary<int, string>();
for (int i = 1; i <= 3; i++)
{
string filePath = SaveDir + $"save_{i}.json";
string fullPath = ProjectSettings.GlobalizePath(filePath);
if (File.Exists(fullPath))
{
string json = File.ReadAllText(fullPath);
using (JsonDocument doc = JsonDocument.Parse(json))
{
string name = doc.RootElement.GetProperty("player_name").GetString();
int level = doc.RootElement.GetProperty("player_level").GetInt32();
string time = doc.RootElement.GetProperty("save_time").GetString();
infos[i] = $"{name} Lv.{level} | {time}";
}
}
else
{
infos[i] = "空槽位";
}
}
return infos;
}
}extends Node
# 加载游戏——从 JSON 文件读取数据
func load_game(slot: int = 1) -> Dictionary:
var file_path := SaveDir + "save_%d.json" % slot
if not FileAccess.file_exists(file_path):
print("槽位 %d 没有存档" % slot)
return {}
var file := FileAccess.open(file_path, FileAccess.READ)
if not file:
push_error("读档失败:%s" % FileAccess.get_open_error())
return {}
var json_string := file.get_as_text()
file.close()
var json := JSON.new()
var error := json.parse(json_string)
if error != OK:
push_error("存档数据损坏:%s" % json.get_error_message())
return {}
var data: Dictionary = json.data
print("读档成功!玩家 %s,等级 %d" % [data.get("player_name", ""), data.get("player_level", 1)])
return data
# 获取所有存档信息(用于存档选择界面)
func get_all_save_infos() -> Dictionary:
var infos := {}
for i in range(1, 4):
var file_path := SaveDir + "save_%d.json" % i
if FileAccess.file_exists(file_path):
var data: Dictionary = load_game(i)
if data.is_empty():
infos[i] = "存档已损坏"
else:
var text := "%s Lv.%d | %s" % [
data.get("player_name", "???"),
data.get("player_level", 1),
data.get("save_time", "???")
]
infos[i] = text
else:
infos[i] = "空槽位"
return infos存档安全——防止玩家作弊和存档损坏
你肯定见过有人用记事本打开游戏存档,把金币从 100 改成 9999999。如果你的游戏有排行榜或者内购,这种行为就会破坏游戏平衡。
简单的存档加密
加密的原理就像给信件装进信封再封上蜡印——别人打不开,只有你用钥匙才能看到内容。
using System.Security.Cryptography;
using System.Text;
public partial class SaveManager : Node
{
// 简单的 XOR 加密(不是工业级安全方案,但能挡住大部分小白)
// XOR 加密的特点:加密和解密用的是同一个操作
private const string EncryptionKey = "MyGameSecretKey2026";
private byte[] XorEncrypt(string plainText)
{
byte[] keyBytes = Encoding.UTF8.GetBytes(EncryptionKey);
byte[] dataBytes = Encoding.UTF8.GetBytes(plainText);
byte[] result = new byte[dataBytes.Length];
for (int i = 0; i < dataBytes.Length; i++)
{
result[i] = (byte)(dataBytes[i] ^ keyBytes[i % keyBytes.Length]);
}
return result;
}
private string XorDecrypt(byte[] encryptedBytes)
{
// XOR 解密和加密是完全一样的操作
byte[] keyBytes = Encoding.UTF8.GetBytes(EncryptionKey);
byte[] result = new byte[encryptedBytes.Length];
for (int i = 0; i < encryptedBytes.Length; i++)
{
result[i] = (byte)(encryptedBytes[i] ^ keyBytes[i % keyBytes.Length]);
}
return Encoding.UTF8.GetString(result);
}
// 保存加密后的存档
public void SaveGameEncrypted(SaveData data, int slot = 1)
{
string json = JsonSerializer.Serialize(data);
byte[] encrypted = XorEncrypt(json);
string filePath = SaveDir + $"save_{slot}.dat";
File.WriteAllBytes(ProjectSettings.GlobalizePath(filePath), encrypted);
}
// 加载加密存档
public SaveData LoadGameEncrypted(int slot = 1)
{
string filePath = SaveDir + $"save_{slot}.dat";
string fullPath = ProjectSettings.GlobalizePath(filePath);
if (!File.Exists(fullPath)) return null;
byte[] encrypted = File.ReadAllBytes(fullPath);
string json = XorDecrypt(encrypted);
return JsonSerializer.Deserialize<SaveData>(json);
}
}extends Node
const ENCRYPTION_KEY := "MyGameSecretKey2026"
# XOR 加密(加密和解密是同一个函数)
func _xor_cipher(data: PackedByteArray) -> PackedByteArray:
var key := ENCRYPTION_KEY.to_utf8_buffer()
var result := PackedByteArray()
for i in range(data.size()):
result.append(data[i] ^ key[i % key.size()])
return result
# 保存加密存档
func save_game_encrypted(data: Dictionary, slot: int = 1) -> void:
var json_string := JSON.stringify(data)
var plain_bytes := json_string.to_utf8_buffer()
var encrypted_bytes := _xor_cipher(plain_bytes)
var file_path := SaveDir + "save_%d.dat" % slot
var file := FileAccess.open(file_path, FileAccess.WRITE)
if file:
file.store_buffer(encrypted_bytes)
file.close()
# 加载加密存档
func load_game_encrypted(slot: int = 1) -> Dictionary:
var file_path := SaveDir + "save_%d.dat" % slot
if not FileAccess.file_exists(file_path):
return {}
var file := FileAccess.open(file_path, FileAccess.READ)
if not file:
return {}
var encrypted_bytes := file.get_buffer(file.get_length())
file.close()
var plain_bytes := _xor_cipher(encrypted_bytes)
var json_string := plain_bytes.get_string_from_utf8()
var json := JSON.new()
if json.parse(json_string) != OK:
return {}
return json.data存档校验——防止存档被篡改
除了加密,你还可以给存档加一个"数字签名"。就像包裹上的封条——如果有人拆开过,封条就破了,你一眼就能看出来。
最常见的做法是计算存档数据的 哈希值(Hash),把它和存档一起保存。下次读取时重新计算哈希值,如果和保存的不一致,说明存档被修改过了。
using System.Security.Cryptography;
using System.Text;
public partial class SaveManager : Node
{
// 计算数据的 SHA256 哈希值
// SHA256 就像给数据拍了一个"指纹"——哪怕只改了一个字符,指纹也完全不同
private string ComputeHash(string data)
{
byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(data));
return Convert.ToHexString(bytes).ToLower();
}
// 保存带校验的存档
public void SaveGameWithHash(SaveData data, int slot = 1)
{
string json = JsonSerializer.Serialize(data);
string hash = ComputeHash(json);
// 把哈希值拼在 JSON 前面,用 | 分隔
string finalData = hash + "|" + json;
string filePath = SaveDir + $"save_{slot}.json";
File.WriteAllText(ProjectSettings.GlobalizePath(filePath), finalData);
}
// 加载带校验的存档
public SaveData LoadGameWithHash(int slot = 1)
{
string filePath = SaveDir + $"save_{slot}.json";
string fullPath = ProjectSettings.GlobalizePath(filePath);
if (!File.Exists(fullPath)) return null;
string finalData = File.ReadAllText(fullPath);
int separatorIndex = finalData.IndexOf('|');
if (separatorIndex < 0)
{
GD.PrintErr("存档格式错误:缺少校验数据");
return null;
}
string savedHash = finalData.Substring(0, separatorIndex);
string json = finalData.Substring(separatorIndex + 1);
string currentHash = ComputeHash(json);
if (savedHash != currentHash)
{
GD.PrintErr("存档数据已被篡改!");
return null;
}
return JsonSerializer.Deserialize<SaveData>(json);
}
}extends Node
# 计算字符串的 SHA256 哈希值
func _compute_hash(data: String) -> String:
var ctx := HashingContext.new()
ctx.start(HashingContext.HASH_SHA256)
ctx.update(data.to_utf8_buffer())
var hash_bytes := ctx.finish()
return hash_bytes.hex_encode()
# 保存带校验的存档
func save_game_with_hash(data: Dictionary, slot: int = 1) -> void:
var json_string := JSON.stringify(data)
var hash := _compute_hash(json_string)
var final_data := hash + "|" + json_string
var file_path := SaveDir + "save_%d.json" % slot
var file := FileAccess.open(file_path, FileAccess.WRITE)
if file:
file.store_string(final_data)
file.close()
# 加载带校验的存档
func load_game_with_hash(slot: int = 1) -> Dictionary:
var file_path := SaveDir + "save_%d.json" % slot
if not FileAccess.file_exists(file_path):
return {}
var file := FileAccess.open(file_path, FileAccess.READ)
if not file:
return {}
var final_data := file.get_as_text()
file.close()
var separator := final_data.find("|")
if separator == -1:
push_error("存档格式错误:缺少校验数据")
return {}
var saved_hash := final_data.substr(0, separator)
var json_string := final_data.substr(separator + 1)
var current_hash := _compute_hash(json_string)
if saved_hash != current_hash:
push_error("存档数据已被篡改!")
return {}
var json := JSON.new()
if json.parse(json_string) != OK:
return {}
return json.data安全提示
XOR 加密和 SHA256 校验只能防住"小白玩家"。如果有人真的想破解,这些方案都挡不住。对于需要高安全性的游戏(比如有真钱交易),应该使用 AES 加密 + 服务器端存档。不过对于大多数独立游戏和单机游戏来说,上面的方案已经足够了。
自动存档——让玩家不用操心
现代游戏几乎都有自动存档功能。你在《塞尔达传说》里每次走进一个神庙,游戏就会自动保存——你完全不用手动操作。
定时存档
每隔一定时间自动保存一次。
using Godot;
public partial class AutoSaveManager : Node
{
[Export] public double SaveIntervalSeconds { get; set; } = 60.0;
[Export] public int AutoSaveSlot { get; set; } = 99; // 自动存档用特殊槽位
private double _timer;
public override void _Process(double delta)
{
_timer += delta;
if (_timer >= SaveIntervalSeconds)
{
_timer = 0;
PerformAutoSave();
}
}
private void PerformAutoSave()
{
// 收集当前游戏状态
var data = new SaveManager.SaveData
{
PlayerName = "自动存档",
PlayerLevel = GameManager.Instance.PlayerLevel,
Hp = GameManager.Instance.PlayerHp,
PosX = GameManager.Instance.Player.Position.X,
PosY = GameManager.Instance.Player.Position.Y
};
var saveManager = GetNode<SaveManager>("/root/SaveManager");
saveManager.SaveGame(data, AutoSaveSlot);
GD.Print("自动存档完成");
}
}extends Node
@export var save_interval_seconds: float = 60.0
@export var auto_save_slot: int = 99 # 自动存档用特殊槽位
var _timer: float = 0.0
func _process(delta: float) -> void:
_timer += delta
if _timer >= save_interval_seconds:
_timer = 0.0
_perform_auto_save()
func _perform_auto_save() -> void:
var data: Dictionary = {
"player_name": "自动存档",
"player_level": GameManager.player_level,
"hp": GameManager.player_hp,
"pos_x": GameManager.player.position.x,
"pos_y": GameManager.player.position.y
}
var save_manager := get_node("/root/SaveManager")
save_manager.save_game(data, auto_save_slot)
print("自动存档完成")场景切换存档
每次玩家切换场景时自动保存,这是最常用的自动存档触发时机。
using Godot;
public partial class SceneTransitionManager : Node
{
public override void _Ready()
{
// 监听场景树变化
GetTree().NodeAdded += OnNodeAdded;
}
private void OnNodeAdded(Node node)
{
// 当场景切换完成后,触发自动存档
if (node is SceneTreeTimer)
{
var timer = new Timer();
timer.WaitTime = 0.5; // 延迟一小段时间,确保新场景加载完毕
timer.OneShot = true;
timer.Timeout += () =>
{
var saveManager = GetNodeOrNull<SaveManager>("/root/SaveManager");
saveManager?.PerformAutoSave();
};
AddChild(timer);
timer.Start();
}
}
}extends Node
func _ready() -> void:
# 使用信号总线来监听场景切换
if EventBus.has_signal("scene_changed"):
EventBus.scene_changed.connect(_on_scene_changed)
func _on_scene_changed(new_scene: String) -> void:
# 延迟一小段时间,确保新场景加载完毕
await get_tree().create_timer(0.5).timeout
var save_manager := get_node_or_null("/root/SaveManager")
if save_manager:
save_manager.save_game(GameManager.get_save_data(), 99)
print("场景切换后自动存档完成")多存档槽位管理
大多数游戏支持多个存档槽位,让不同玩家可以各自保存进度,或者让同一个玩家保留多个存档点。
存档槽位管理器
using Godot;
using System.Collections.Generic;
public partial class SlotManager : Node
{
private const int MaxSlots = 3;
// 获取存档的缩略信息(用于显示在存档选择界面)
public SaveSlotInfo GetSlotInfo(int slot)
{
var saveManager = GetNode<SaveManager>("/root/SaveManager");
string filePath = SaveManager.SaveDir + $"save_{slot}.json";
string fullPath = ProjectSettings.GlobalizePath(filePath);
if (!File.Exists(fullPath))
{
return new SaveSlotInfo { IsEmpty = true, SlotNumber = slot };
}
try
{
string json = File.ReadAllText(fullPath);
using (var doc = System.Text.Json.JsonDocument.Parse(json))
{
var root = doc.RootElement;
return new SaveSlotInfo
{
IsEmpty = false,
SlotNumber = slot,
PlayerName = root.GetProperty("player_name").GetString(),
PlayerLevel = root.GetProperty("player_level").GetInt32(),
SaveTime = root.GetProperty("save_time").GetString(),
GameTime = root.GetProperty("game_time").GetDouble()
};
}
}
catch
{
return new SaveSlotInfo { IsEmpty = true, SlotNumber = slot, IsCorrupted = true };
}
}
// 删除指定槽位的存档
public void DeleteSlot(int slot)
{
string filePath = SaveManager.SaveDir + $"save_{slot}.json";
string fullPath = ProjectSettings.GlobalizePath(filePath);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
GD.Print($"槽位 {slot} 的存档已删除");
}
}
// 复制存档(从源槽位复制到目标槽位)
public void CopySlot(int fromSlot, int toSlot)
{
string fromPath = ProjectSettings.GlobalizePath(SaveManager.SaveDir + $"save_{fromSlot}.json");
string toPath = ProjectSettings.GlobalizePath(SaveManager.SaveDir + $"save_{toSlot}.json");
if (File.Exists(fromPath))
{
File.Copy(fromPath, toPath, true);
GD.Print($"存档已从槽位 {fromSlot} 复制到槽位 {toSlot}");
}
}
}
public class SaveSlotInfo
{
public bool IsEmpty { get; set; }
public bool IsCorrupted { get; set; }
public int SlotNumber { get; set; }
public string PlayerName { get; set; }
public int PlayerLevel { get; set; }
public string SaveTime { get; set; }
public double GameTime { get; set; }
}extends Node
const MAX_SLOTS := 3
# 获取存档缩略信息
func get_slot_info(slot: int) -> Dictionary:
var file_path := "user://saves/save_%d.json" % slot
if not FileAccess.file_exists(file_path):
return {
"is_empty": true,
"slot_number": slot,
"is_corrupted": false
}
var data: Dictionary = load_game(slot)
if data.is_empty():
return {
"is_empty": true,
"slot_number": slot,
"is_corrupted": true
}
return {
"is_empty": false,
"is_corrupted": false,
"slot_number": slot,
"player_name": data.get("player_name", "???"),
"player_level": data.get("player_level", 1),
"save_time": data.get("save_time", "???"),
"game_time": data.get("game_time", 0.0)
}
# 删除指定槽位的存档
func delete_slot(slot: int) -> void:
var file_path := "user://saves/save_%d.json" % slot
if FileAccess.file_exists(file_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(file_path))
print("槽位 %d 的存档已删除" % slot)
# 复制存档
func copy_slot(from_slot: int, to_slot: int) -> void:
var from_path := ProjectSettings.globalize_path("user://saves/save_%d.json" % from_slot)
var to_path := ProjectSettings.globalize_path("user://saves/save_%d.json" % to_slot)
if FileAccess.file_exists(from_path):
DirAccess.copy_absolute(from_path, to_path)
print("存档已从槽位 %d 复制到槽位 %d" % [from_slot, to_slot])完整的存档系统架构
把上面所有模块组合起来,一个完整的存档系统包含以下组件:
| 模块 | 职责 |
|---|---|
| SaveManager | 负责存档的读写、序列化/反序列化 |
| AutoSaveManager | 负责定时自动存档 |
| SlotManager | 负责多槽位管理 |
| SaveUI | 负责存档界面的显示(保存/加载/删除) |
最终建议
- 单机游戏:用 JSON + SHA256 校验就够了,不需要加密
- 有排行榜的游戏:用服务器端存档,本地只存缓存
- 大型游戏:考虑用 SQLite 数据库代替 JSON 文件,查询更灵活
- 任何游戏:都要做"存档损坏恢复"——至少保留一个备份文件
