存档系统
存档系统简介
将游戏数据持久化的一种方式,方便玩家读取进度而不必从新开始游戏。常用的持久化方式有三种,一是透过Unity自带的存档方式PlayerPrefs,二是通过二进制格式器将对象写入二进制文件,三是通过Json,将对象序列化成Json格式并将字符串写入文件。本章仅介绍框架中所使用到的二进制方法。
系统结构设计
设计SaveManager作为实际的存档读写和存档获取的入口。类似的,不继承MonoBehavior使用static。在框架的设计中,存档有两类,一类是用户型存档,存储这某个游戏用户的具体信息,如血量,武器,游戏进度等。一类是设置型存档,与任何用户存档无关,是通用的存储信息,比如屏幕分辨率,音量设置等。故可分两个文件夹进行不同类型的存档数据存储,由于用户不可对游戏存档结构进行更改,所以两个文件夹命名是确定的,故使用const的string即可。存档存放路径也由此确定(存档路径的根目录有计算机系统的持久化路径Application.persistentDataPath决定)。设计readonly的string在初始化时获取默认存储路径并和文件夹名字进行字符串拼接。
public static class SaveManager { //存档所在的文件夹命名 //设置所在的文件夹命名,常规情况下由存档管理器自行维护 private const string saveDirName = "saveData"; private const string settingDirName = "setting"; //存档文件夹路径,在初始化的时候获取 private static readonly string saveDirPath; private static readonly string settingDirPath; private static SaveManager() { //拼接字符串,存档即放置在该文件夹路径下 saveDirPath = Application.persistentDataPath + "/" + saveDirName; settingDirPath = Application.persistentDataPath + "/" + settingDirName; //确保路径存在 if(Directory.Exists(saveDirPath) == false) { Directory.CreateDirectory(saveDirPath); } if(Directory.Exists(settingDirPath) == false) { Directory.CreateDirectory(settingDirPath); } //初始化SaveManagerData(后续补充) InitSaveManagerData(); } }
注:为了防止游戏过程中删除文件夹导致无法获得路径的情况。初始化,游戏进行中都需要确保路径的存在,才对文件进行读写。
为了解决上述问题,设计GetSavePath以便复用:检查是否存在该存档路径,若无则创建。该框架选择使用二进制的方式保存数据,存档相关操作都离不开数据的保存和加载,故设计LoadFile和SaveFile作为工具函数供框架内部使用。(实际用户存档SaveItem和设置存档SaveSetting存数据对于工具函数底层写入文件是不做区分,其原因在于存的时候只需要传一个object转成二进制流放到指定位置,取得时候传泛型T和其他参数即可锁定位置且知道要将object强转成什么类型的存档数据)
//BinaryFormatter可以复用,在外面定义即可 private static BinaryFormatter binaryFormatter = new BinaryFormatter(); private static void SaveFlie(object saveObject, string path) { //打开文件模式OpenOrCreate:若有则打开若无则创建 FileStream f = new FileStream(path, FileMode.OpenOrCreate); //二进制的方式把对象写入文件,文件流用完需要关闭 binaryFormatter.Serialize(f, saveObject); f.Dispose(); } private static T LoadFile<T>(string path) where T : class { //为了防止文件流报错,需要文件路径进行判空 if(!File.Exits(path)) { return null; } FileStream file = new FileStream(path, FileMode.Open); //将内容解码成对象 T obj = (T)binaryFormatter.Deserialize(file); file.Dispose(); return obj; } private static string GetSavePath(int saveID, bool createDir = true) { //验证是否有某个存档 if (GetSaveItem(saveID) == null) throw new Exception("JK:saveID 存档不存在!"); string saveDir = saveDirPath + "/" + saveID; //确定文件夹是否存在 if(!Directory.Exists(saveDir)) { //若不存在根据参数createDir创建文件夹 if(createDir) { Directory.CreateDirectory(saveDir); } else { return null; } } return saveDir; }
支持多存档功能,由于存档内容保存在本地文件,List中的存档仅作为存档信息载体,并存在外部访问的可能性(类似GameMAnager或者UI)故在外部设计SaveItem存档类,包含ID和最后更新时间(参考游戏中存档列表)。需要一个容器对存档进行收纳管理,考虑到玩家一般存档数量为0-50之间(相对而言数量并不大),故使用List作为容器而不是Dictionary(性能消耗可忽略不计)。SaveManager还需要一个全局数据:用于递增当前存档ID,并持有所有的存档数据List。设计一个内部类SaveManagerData,并在构造函数中对SaveManagerData进行初始化。
//存档管理器的设置数据 [Serializable] private class SaveManagerData { //当前存档ID public int currID = 0; //收纳所有存档的列表 public List<SaveItem> saveItemList = new List<SaveItem>(); } //一个存档的数据 public class SaveItem() { //saveID只允许由存档管理器来决定,更新时间亦是如此 public int saveID { get; private set; } public DataTime lastSaveTime { get; private set; } public SaveItem(int saveID, DataTime lastSaveTime) { this.saveID = saveID; this.lastSaveTime = lastSaveTime; } //有时候只需要更新存档的更新时间 public void UpdateTime(DataTime lastSaveTime) { this.lastSaveTime = lastSaveTime; } }
同时持有缓存功能,避免频繁的从本地加载一个对象,消除性能隐患。由于存储结构为Application.persistentDataPath/存档ID/具体存储文件,设计Dictionary嵌套对存档进行存储。
// 存档中对象的缓存字典 // <存档ID,<文件名称,实际的对象>> private static Dictionary<int, Dictionary<string, object>> cacheDic = new Dictionary<int, Dictionary<string, object>>();
对象
将某个具体对象写入某个存档、从某个存档读取具体对象。设计SaveObject和LoadObject方法实现该功能,并提供多种重载提高灵活度。以具体对象写入某个存档为例,一个保存操作需要参数有object对象,通过GetSavePath根据存档ID获取存档路径string和自定义string文件名进行字符串拼接即可得到文件路径。故参数为(object saveObject, string saveFileName, int saveID),随后调用函数更新各种属性。LoadObject首先对缓存字典进行查找(避免频繁从本地加载导致的性能隐患),若无则从本地加载,随后调用函数更新各种属性。
public static void SaveObject(object saveObject, string saveFileName, int saveID) { //存档路径 string dirPath = GetSavePath(saveID, true); //具体对象文件路径 string savePath = dirPath + "/" + saveFileName; SaveFile(saveObject, savePath); //修改存档更新时间 //更新缓存列表 //更新SaveManagerData并写入磁盘 GetSaveItem(saveID).UpdateTime(DataTime.Now); SetCache(saveID, saveFileName, saveObject); UpdateSaveManagerData(); } public static T LoadObject<T>(string saveFileName, int saveID) where T : class { //查询缓存字典 T obj = GetCache<T>(saveID, saveFileName); if(obj == null) { //从本地加载 string dirPath = GetSavePath(saveID); if(dirPath == null) return null; string savePath = dirPath + "/" + saveFileName; obj = LoadFile<T>(string savePath); //更新缓存 SetCache(saveID, saveFileName, obj); } return obj; }
提供多种重载,其中存在逻辑复用和函数调用。
public static void SaveOjbect(object saveObject, int saveID = 0) { SaveOjbect(saveObject, saveObject.GetType().Name, saveID); } public static void SaveOjbect(object saveObject, SaveItem saveItem) { SaveOjbect(saveObject, saveObject.GetType().Name, saveItem); } public static T LoadObject<T>(int saveID = 0) where T : class { return LoadObject<T>(typeof(T).Name, saveID); } public static T LoadObject<T>(string saveFileName, SaveItem saveItem) where T : class { return LoadObject<T>(saveFileName, saveItem.saveID); } public static T LoadObject<T>(SaveItem saveItem) where T : class { return LoadObject<T>(typeof(T).Name, saveItem.saveID); }
缓存
缓存字典持有增、删、查三种功能。设计对应的SetCache,RemoveCache,GetCache方法,依据字典结构设计参数。其本质是将存档object存入字典置于内存中,删除缓存则通过id把整个存档字典删除即可。
//设置缓存 public static void SetCache(int saveID, string fileName, object saveObject) { //检查缓存中有无saveID这个键 if(cacheDic.ContainsKey(saveID)) { //检查存档中是否有fileName为命名的对象 if(cacheDir[saveID].ContainsKey(fileName)) { cacheDir[saveID][fileName] = saveObject; } else { cacheDir[saveID].Add(fileName, saveObject); } } else { cacheDir.Add(saveID, new Dictionary<string, object>(){ {fileName,saveObject} }); } } //获取缓存 public static T GetCache<T>(int saveID, string fileName) where T : class { if(cacheDic.ContainsKey(saveID)) { if(cacheDir[saveID].ContainsKey(fileName)) { return (T)cacheDir[saveID][fileName]; } else { return null; } } else { return null; } } //移除缓存 public static void RemoveCache(int saveID) { cacheDir.Remove(saveID); }
存档
在存档部分实质是对List<SaveItem>进行操作。主要有添加、删除、获取三个功能,通过saveID或传对象对存档进行查找,设计CreateSaveItem,DeleteSaveItem和GetSaveItem三种方法,并对其设计方法重载(saveID或传对象)。注:添加存档要依据SaveManagerData数据,删除实质是将本地文件删除,其还涉及缓存和SaveManagerData的更新!
//添加一个存档 public static SaveItem CreateSaveItem() { //根据全局数据.currID创建存档对象并放入List容器收纳 SaveItem saveItem = new SaveItem(saveManagerData.currID, DataTime.Now); saveManagerData.saveItemList.Add(saveItem); saveManagerData.currID += 1; //更新SaveManagerData并写入磁盘 UpdateSaveManagerData(); return saveItem; } //重载 public static void DeleteSaveItem(SaveItem saveItem) //删除一个存档 public static DeleteSaveItem(int saveID) { string dirPath = GetSavePath(saveID, false); if(dirPath != null) { //把这个存档下的文件递归删除 Directory.Delete(itemDir, true); } //移除缓存 RemoveCache(saveID); //更新全局数据 saveManagerData.saveItemList.Remove(GetSaveItem(saveID)); UpdateSaveManagerData(); } //获取一个存档 public static SaveItem GetSaveItem(int id) { for (int i = 0; i < saveManagerData.saveItemList.Count; i++) { if (saveManagerData.saveItemList[i].saveID == id) { return saveManagerData.saveItemList[i]; } } return null; }
存档设置
提供初始化方法InitSaveManagerData对存档管理器数据进行初始化和更新方法UpdateSaveManagerData对数据进行更新和写入磁盘。参考游戏中存档列表(可按创建时间顺序,最后更新时间顺序对存档进行排列)提供GetAllSaveItemByCreateTime和GetAllSaveItemByUpdateTime(依据实现IComparer类并重写Compare方法的OrderByUpdateTimeComparer类)。
public static void InitSaveManagerData() { //先读取,若无在创建,防止残留 saveManagerData = LoadFile<SaveManagerData>(saveDirPath + "/SaveManagerData"); if(saveManagerData == null) { saveManagerData = new SaveManagerData(); UpdateSaveManagerData(); } } public static void UpdateSaveManagerData() { SaveFile(saveManagerData, saveDirPath + "/SaveManagerData"); } //创建时间最新的在前 public static List<SaveItem> GetAllSaveItemByCreateTime() { //保持原List不变,创建新List对原List进行拷贝排序 //确定List长度,无需在途中拓展 List<SaveItem> saveItems = new List<SaveItem>(saveManagerData.saveItemList.Count); for (int i = 0; i < saveManagerData.saveItemList.Count; i++) { saveItems.Add(saveManagerData.saveItemList[saveManagerData.saveItemList.Count - (i+1)]); } return saveItems; } //最新更新的在前 public static List<SaveItem> GetAllSaveItemByUpdateTime() { List<SaveItem> saveItems = new List<SaveItem>(saveManagerData.saveItemList.Count); for (int i = 0; i < saveManagerData.saveItemList.Count; i++) { saveItems.Add(saveManagerData.saveItemList[i]); } OrderByUpdateTimeComparer orderBy = new OrderByUpdateTimeComparer(); saveItems.Sort(orderBy); return saveItems; } private class OrderByUpdateTimeComparer : IComparer<SaveItem> { //实现并重写Compare public int Compare(SaveItem x, SaveItem y) { if (x.lastSaveTime>y.lastSaveTime) { return -1; } else { return 1; } } }
附带获取所有存档的万能解决方案,继承如上所有功能对外提供自定义比较依据Function的万能GetAllSaveItem泛型方法。
/// 获取所有存档 /// 最新的在最后面 public static List<SaveItem> GetAllSaveItem() { return saveManagerData.saveItemList; } /// 获取所有存档 /// 万能解决方案 public static List<SaveItem> GetAllSaveItem<T>(Func<SaveItem, T> orderFunc, bool isDescending = false) { if (isDescending) { return saveManagerData.saveItemList.OrderByDescending(orderFunc).ToList(); } else { return saveManagerData.saveItemList.OrderBy(orderFunc).ToList(); } }
全局设置
全局数据部分持有加载和保存设置两种操作,设计LoadSetting泛型方法和SaveSetting即可。
/// <summary> /// 加载设置,全局生效,不关乎任何一个存档 /// </summary> public static T LoadSetting<T>(string fileName) where T : class { return LoadFile<T>(settingDirPath + "/" + fileName); } /// <summary> /// 加载重载 /// </summary> public static T LoadSetting<T>() where T : class { return LoadSetting<T>(typeof(T).Name); } /// <summary> /// 保存设置,全局生效,不关乎任何一个存档 /// </summary> public static void SaveSetting(object saveObject, string fileName) { SaveFile(saveObject, settingDirPath + "/" + fileName); } /// <summary> /// 保存重载 /// </summary> public static void SaveSetting(object saveObject) { SaveSetting(saveObject, saveObject.GetType().Name); }
个人学习用