# 1. 创建一个基类用于存储数据

GameData.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable] // 标记该类为可序列化,这样它可以被保存到文件或传输数据。
public class GameData
{
public int souls; // 玩家拥有的灵魂数量,用于游戏中的资源或货币系统。
public string saveName; // 存档的名称,玩家可以自定义以便区分不同的存档。
public string saveTime; // 存档时间,以 "yyyyMMdd_HHmmss" 格式保存。
public bool encryptData; // 标志是否需要加密存档数据,增强数据安全性。
public SerializableDictionary<string, int> inventory; // 玩家背包,存储物品的名称及对应数量。
public SerializableDictionary<string, int> equipment; // 玩家装备,存储装备的名称及对应等级或强化值。
public SerializableDictionary<string, bool> EXPTree; // 玩家技能树,存储技能名称及是否已解锁的状态。

// 构造函数,在创建 GameData 对象时初始化默认值。
public GameData()
{
this.souls = 0; // 初始化灵魂数量为 0。
inventory = new SerializableDictionary<string, int>(); // 初始化背包为空。
equipment = new SerializableDictionary<string, int>(); // 初始化装备为空。
EXPTree = new SerializableDictionary<string, bool>(); // 初始化技能树为空。
this.encryptData = true; // 默认启用数据加密。
this.saveTime = System.DateTime.Now.ToString("yyyyMMdd_HHmmss"); // 初始化存档时间为当前时间。
this.saveName = ""; // 默认存档名称为空字符串。
Debug.Log("GameData Created!"); // 输出调试信息,确认 GameData 已创建。
}
}


由于字典类型无法序列化,所以创建一个类用于序列化字典类型数据
SerializableDictionary.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 一个可序列化的字典类,继承自 Dictionary<TKey, TValue>,并实现了 ISerializationCallbackReceiver 接口。
// 使得原本无法序列化的 Dictionary 可以在 Unity 的 Inspector 面板中显示并保存。
[System.Serializable]
public class SerializableDictionary<TKey, TValue> : Dictionary<TKey, TValue>, ISerializationCallbackReceiver
{
// 用于序列化的键列表
[SerializeField] private List<TKey> keys = new List<TKey>();
// 用于序列化的值列表
[SerializeField] private List<TValue> values = new List<TValue>();

// 在反序列化(加载数据)之后调用,负责将序列化的数据(键和值列表)还原成字典。
public void OnAfterDeserialize()
{
// 清空字典,以准备重新填充数据。
this.Clear();
// 检查键值列表的数量是否一致,不一致说明数据损坏。
if (keys.Count != values.Count)
{
Debug.LogError("keys and values count not equal"); // 输出错误信息
}
// 遍历键和值列表,将键值对重新添加到字典中。
for (int i = 0; i < keys.Count; i++)
{
// Debug.Log(keys[i]); // 可选的调试信息,用于检查每个键的内容。
this.Add(keys[i], values[i]);
}
}

// 在序列化(保存数据)之前调用,负责将字典中的数据转换成两个列表(键和值)。
public void OnBeforeSerialize()
{
// 清空键和值的列表,以确保没有旧数据。
keys.Clear();
values.Clear();
// 遍历字典中的每个键值对,将键和值分别添加到对应的列表中。
foreach (KeyValuePair<TKey, TValue> pair in this)
{
keys.Add(pair.Key); // 添加键
values.Add(pair.Value); // 添加值
}
}
}

# 2. 创建一个管理类

我们需要集成一个管理类用于管理所有需要存档的数据

row
1
2
3
4
5
6
7
8
9
10
11
private void Awake()
    {
        if (instance != null)
            Destroy(gameObject); // 如果已存在一个 SaveManager 实例,销毁当前对象。
        else
        {
            instance = this; // 设置实例。
            DontDestroyOnLoad(gameObject); // 确保对象在场景切换时不会被销毁。
        }
        SceneManager.sceneLoaded += OnSceneLoaded; // 订阅场景加载事件。
    }

由于我们在开始界面进入存档时若销毁此管理类,将会导致 gameData 丢失,所以需要将此类注册为不被销毁的类用于管理存档。
row
1
2
3
4
5
6
// Start:初始化存档管理器。
void Start()
{
saveManagers = GetSaveManagers(); // 获取所有实现 ISaveManager 接口的对象。
saveFolder = Application.persistentDataPath + "/Saves"; // 设置存档文件夹路径。
}

将所有具有 save 和 load 方法的类统一管理

row
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 加载游戏数据
public void LoadGame(GameData _gameData)
{
isSaveRequired = true; // 标记需要保存数据。
if (!Directory.Exists(saveFolder))
{
Directory.CreateDirectory(saveFolder); // 如果文件夹不存在,则创建。
}

// 如果存档名称为空,则初始化默认存档。
if (_gameData == null || string.IsNullOrEmpty(_gameData.saveName))
{
this.gameData = new GameData();
gameData.saveName = "Save_" + System.DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".json"; // 自动生成存档名称。
gameData.encryptData = encryptData;
}

// 初始化文件处理器并加载存档数据。
fileDataHandler = new FileDataHandler(Application.persistentDataPath + "/Saves", gameData.saveName, gameData.encryptData);
gameData = fileDataHandler.Load(gameData);

// 调用所有 ISaveManager 对象的加载方法。
foreach (ISaveManager saveManager in saveManagers)
{
saveManager.LoadData(gameData);
}
}

Load 的具体实现在另一个 FileDataHandler 中实现,这边后面再说
初始化存档并将数据注入各个具体存档实现的地方

row
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 保存游戏数据
public void SaveGame()
{
if (!isSaveRequired)
{
return; // 如果不需要保存,则直接返回。
}

// 调用所有 ISaveManager 对象的保存方法。
foreach (ISaveManager saveManager in saveManagers)
{
saveManager.SaveData(ref gameData);
}

// 更新存档时间并保存到文件。
gameData.saveTime = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
fileDataHandler.Save(gameData);
}

// 在应用退出时保存游戏数据。
private void OnApplicationQuit()
{
SaveGame();
}

然后同理,在游戏退出时将所有存档实现类的数据都通过方法类中的 save 注入 JSON 文档,并存储在本地。

row
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 获取所有实现 ISaveManager 接口的对象。
private List<ISaveManager> GetSaveManagers()
{
IEnumerable<ISaveManager> saveManagers = FindObjectsOfType<MonoBehaviour>().OfType<ISaveManager>();
return new List<ISaveManager>(saveManagers);
}

// 获取所有存档的列表。
public List<GameData> GetSaveList()
{
List<GameData> saveList = new List<GameData>();
if (string.IsNullOrEmpty(saveFolder))
{
saveFolder = Application.persistentDataPath + "/Saves"; // 设置存档文件夹路径。
}

// 遍历存档文件夹中的所有 .json 文件。
string[] saveFiles = Directory.GetFiles(saveFolder, "*.json");
foreach (string filePath in saveFiles)
{
string fileName = Path.GetFileName(filePath);
FileDataHandler _fileDataHandler = new FileDataHandler(saveFolder, fileName, gameData.encryptData);
saveList.Add(_fileDataHandler.Load()); // 加载存档文件。
}

return saveList;
}

由于在开始界面需要获取本地的所有存档,所以用此方法来返回所有的存档列表并供以操作。

row
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 加载数据的简化方法,适用于 UI 按钮等触发。
public void UseOnToggle()
{
saveManagers = GetSaveManagers(); // 获取所有 ISaveManager 对象。
saveFolder = Application.persistentDataPath + "/Saves";
LoadGame(gameData); // 加载游戏数据。
}

// 场景加载完成后调用。
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (scene.name == "StartMenu")
{
return; // 如果是主菜单场景,不进行存档加载。
}
UseOnToggle(); // 在其他场景加载完成后自动加载存档数据。
}

最后添加提供外部载入数据的方法,在开始界面点击存档后加载存档。

# 3.load 和 save 方法类实现

row
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using System.IO;

// 用于存档文件的读写和加解密的类。
public class FileDataHandler
{
private string dataDirPath; // 数据存储的文件夹路径。
private string dataFileName; // 数据存储的文件名。
private bool encryptData = false; // 是否启用数据加密。
private string codeWord = "1234"; // 加密解密的密钥。

// 构造函数,用于初始化文件路径、文件名和加密设置。
public FileDataHandler(string _dataDirPath, string _dataFileName, bool _encryptData = false, string _codeWord = "1234")
{
// 如果未指定文件夹路径,使用默认路径。
if (string.IsNullOrEmpty(_dataDirPath))
{
dataDirPath = Application.persistentDataPath + "/Saves"; // 默认存储在持久化数据路径下的 "Saves" 文件夹。
}
else
{
dataDirPath = _dataDirPath; // 使用指定路径。
}
dataFileName = _dataFileName; // 设置文件名。
encryptData = _encryptData; // 设置是否加密。
codeWord = _codeWord; // 设置加密密钥。
}

// 保存游戏数据到文件
public void Save(GameData _data)
{
string fullPath = Path.Combine(dataDirPath, dataFileName); // 构建完整文件路径。
try
{
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); // 确保文件夹存在,若不存在则创建。
string dataToStore = JsonUtility.ToJson(_data); // 将 GameData 对象序列化为 JSON 字符串。
Debug.Log("Save data: " + dataToStore);

if (encryptData)
{
dataToStore = EncryDecrypt(dataToStore); // 如果启用加密,则加密数据。
}

// 使用文件流和流写入器写入文件。
using (FileStream stream = new FileStream(fullPath, FileMode.Create))
{
using (StreamWriter writer = new StreamWriter(stream))
{
writer.Write(dataToStore); // 写入数据到文件。
}
}
}
catch (Exception e)
{
Debug.LogError("Error in saving data path: " + fullPath + " Error: " + e.Message); // 捕获并输出保存过程中的错误信息。
}
}

// 加载游戏数据
public GameData Load(GameData _gameData = null)
{
string fullPath = Path.Combine(dataDirPath, dataFileName); // 构建完整文件路径。
try
{
string dataToLoad = string.Empty; // 用于存储从文件中读取的数据。

if (File.Exists(fullPath))
{
// 使用文件流和流读取器读取文件内容。
using (FileStream stream = new FileStream(fullPath, FileMode.Open))
{
using (StreamReader reader = new StreamReader(stream))
{
dataToLoad = reader.ReadToEnd(); // 读取文件内容。
}
}

if (encryptData)
{
dataToLoad = EncryDecrypt(dataToLoad); // 如果启用加密,则解密数据。
}

return JsonUtility.FromJson<GameData>(dataToLoad); // 将 JSON 字符串反序列化为 GameData 对象并返回。
}
}
catch (Exception e)
{
Debug.LogError("Error in loading data path: " + fullPath + " Error: " + e.Message); // 捕获并输出加载过程中的错误信息。
}

// 如果文件不存在或加载失败,返回默认的 GameData。
if (_gameData == null)
{
_gameData = new GameData();
}
return _gameData;
}

// 删除存档文件
public void Delete()
{
string fullPath = Path.Combine(dataDirPath, dataFileName); // 构建完整文件路径。
if (File.Exists(fullPath))
{
File.Delete(fullPath); // 删除文件。
}
}

// 加密/解密数据的私有方法(使用简单的异或运算)。
private string EncryDecrypt(string _data)
{
string result = string.Empty; // 存储加密/解密后的结果。
for (int i = 0; i < _data.Length; i++)
{
// 使用密钥对数据进行异或操作,加密和解密使用相同的逻辑。
result += (char)(_data[i] ^ codeWord[i % codeWord.Length]);
}
return result;
}
}

# 4. 通过接口类管理需要存档的数据

ISaveManager.cs
1
2
3
4
5
public interface ISaveManager
{
    void LoadData(GameData _data);
    void SaveData(ref GameData _data);
}

在每个需要存储数据的模块都进行了实现,这边以 Inventory 进行举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 从存档数据中加载玩家的物品和装备
public void LoadData(GameData _data)
{
// 遍历存档中的背包物品
foreach (KeyValuePair<string, int> pair in _data.inventory)
{
// 遍历物品数据库,寻找与存档匹配的物品
foreach (ItemData item in GetItemDataBase())
{
if (item != null && item.itemID == pair.Key) // 检查物品 ID 是否匹配
{
InventoryItem itemToLoad = new InventoryItem(item); // 创建对应的 InventoryItem
itemToLoad.stackSize = pair.Value; // 设置物品堆叠数量
loadItems.Add(itemToLoad); // 将物品添加到加载的物品列表
}
}
}

// 遍历存档中的装备
foreach (KeyValuePair<string, int> pair in _data.equipment)
{
// 遍历物品数据库,寻找与存档匹配的装备
foreach (ItemData item in GetItemDataBase())
{
if (item != null && item.itemID == pair.Key) // 检查装备 ID 是否匹配
{
InventoryItem equipmentToLoad = new InventoryItem(item); // 创建对应的 InventoryItem
equipmentToLoad.stackSize = pair.Value; // 设置装备堆叠数量(通常为 1)
equipItems.Add(equipmentToLoad); // 将装备添加到加载的装备列表
}
}
}
}

// 将玩家的物品和装备保存到存档数据中
public void SaveData(ref GameData _data)
{
// 清空存档中的背包和装备,以准备写入新的数据
_data.inventory.Clear();
_data.equipment.Clear();

// 遍历装备字典,将已装备的物品保存到存档
foreach (KeyValuePair<ItemData_Equipment, InventoryItem> item in equipmentDictionary)
{
_data.equipment.Add(item.Key.itemID, item.Value.stackSize); // 保存装备的 ID 和堆叠数量
}

// 遍历所有物品,将背包中的物品保存到存档
foreach (KeyValuePair<ItemData, InventoryItem> item in GetAllItems())
{
_data.inventory.Add(item.Key.itemID, item.Value.stackSize); // 保存物品的 ID 和堆叠数量
}
}

由于我舍弃了 Inventory 的使用,所以我需要创建一个方法获取所有的物品,用以存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public Dictionary<ItemData, InventoryItem> GetAllItems()
{
Dictionary<ItemData, InventoryItem> allItems = new Dictionary<ItemData, InventoryItem>();

foreach (var category in categories)
{
// 根据分类类型处理
switch (category.Key)
{
case ItemType.Equipment:
AddEquipmentItemsToDictionary(category.Value as EquipmentCategory, allItems);
break;
default:
AddCategoryItemsToDictionary(category.Value as InventoryCategory, allItems);
break;
}
}

return allItems;
}

// 提取装备分类的逻辑
private void AddEquipmentItemsToDictionary(EquipmentCategory equipmentCategory, Dictionary<ItemData, InventoryItem> allItems)
{
if (equipmentCategory == null) return;

// 遍历装备类型和对应的物品列表
foreach (var item in equipmentCategory.GetTypeToItems())
{
foreach (InventoryItem inventoryItem in item.Value)
{
if (!allItems.ContainsKey(inventoryItem.data)) // 避免重复添加
{
allItems.Add(inventoryItem.data, inventoryItem);
}
}
}
}

// 提取一般分类的逻辑
private void AddCategoryItemsToDictionary(InventoryCategory inventoryCategory, Dictionary<ItemData, InventoryItem> allItems)
{
if (inventoryCategory == null) return;

// 遍历分类中的物品列表
foreach (InventoryItem item in inventoryCategory.GetItems())
{
if (!allItems.ContainsKey(item.data)) // 避免重复添加
{
allItems.Add(item.data, item);
}
}
}


两个辅助类用于分类管理多种类型的物品,最终返回所有物品及状态给存档

# 5. 加载存档与新的开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void Start()
{
LoadSaveList(); // 加载存档列表并动态生成按钮
}

// 加载存档列表并动态生成按钮
void LoadSaveList()
{
// 获取存档列表
List<GameData> saveList = SaveManager.instance.GetSaveList();

// 遍历存档列表,为每个存档生成一个按钮
foreach (GameData save in saveList)
{
GameObject button = Instantiate(saveButtonPrefab, saveListContainer); // 实例化存档按钮
// 设置按钮上的文本为存档名称和存档时间
button.GetComponentInChildren<TextMeshProUGUI>().text = save.saveName + " - " + save.saveTime;

// 为按钮添加点击事件
button.GetComponent<Button>().onClick.AddListener(() =>
{
LoadSave(save); // 点击按钮后加载对应的存档
});
}
}

获取存档列表并通过预制件加到 UI 当中去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 加载指定的存档
void LoadSave(GameData _gameData)
{
Time.timeScale = 1f; // 恢复时间缩放,确保游戏运行正常
StartCoroutine(LoadSaveCoroutine(_gameData, 1.5f)); // 开启协程加载存档
}

// 协程:加载存档时显示黑屏并延迟切换场景
private IEnumerator LoadSaveCoroutine(GameData _gameData, float delay)
{
DarkScreen.SetActive(true); // 激活黑屏效果
yield return new WaitForSeconds(delay); // 延迟指定的时间

// 切换到游戏场景并加载存档数据
SaveManager.instance.gameData = _gameData; // 设置当前存档数据
SceneManager.LoadScene("GameScene"); // 加载游戏场景
}

然后通过场景切换与画布过场动画进行跳转