泛型存档系统
一、PlayerPrefs
作为存档相关的,最先想到的是PlayerPrefs
private void OnRoomClear(){ var character = FindObjectOfType<CharacterController>(); PlayerPrefs.SetFloat("Character::Position::x",character.transform.position.x); PlayerPrefs.SetFloat("Character::Position::y",character.transform.position.y); PlayerPrefs.SetFloat("Character::Health",character.Health); PlayerPrefs.SetFloat("Character::Magic",character.Magic); PlayerPrefs.SetFloat("Character:: Level", character.Level); PlayerPrefs.SetFloat("Character::Exp",character.Exp);}private void Start(){ var character = FindObjectOfType<CharacterController>(); character.transform.position = new Vector3( PlayerPrefs.GetFloat("Character::Position::x"), PlayerPrefs.GetFloat("Character::Position::y")); character.Health = PlayerPrefs.GetFloat("Character::Health"); character.Magic = PlayerPrefs.GetFloat("Character::Magic"); character.Level_= PlayerPrefs.GetFloat("Character::Level"); character.Exp = PlayerPrefs.GetFloat("Character::Exp");}但是,这些数据是全局唯一的,显然无法实现多存档的需求
那么很自然的是,在属性前面加一个头,用来指定存档名
private void OnRoomClear(){ var character = FindObjectOfType<CharacterController>(); //在每个数值的key前面加一个存档名,用来区分不同存档 PlayerPrefs.SetFloat(_currentFileName + "::Character::Position::x",character.transform.position.x); PlayerPrefs.SetFloat(_currentFileName + "::Character::Position::y",character.transform.position.y); PlayerPrefs.SetFloat(_currentFileName + "::Character::Health",character.Health); PlayerPrefs.SetFloat(_currentFileName + "::Character::Magic",character.Magic); PlayerPrefs.SetFloat(_currentFileName + "::Character:: Level", character.Level); PlayerPrefs.SetFloat(_currentFileName + "::Character::Exp",character.Exp);}
//选择存档名称private void ChooseFile(string fileName){ _currentFileName = fileName; LoadFile();}
//加载存档private void Start(){ var character = FindObjectOfType<CharacterController>(); character.transform.position = new Vector3( PlayerPrefs.GetFloat(_currentFileName + "::Character::Position::x"), PlayerPrefs.GetFloat(_currentFileName + "::Character::Position::y")); character.Health = PlayerPrefs.GetFloat(_currentFileName + "::Character::Health"); character.Magic = PlayerPrefs.GetFloat(_currentFileName + "::Character::Magic"); character.Level_= PlayerPrefs.GetFloat(_currentFileName + "::Character::Level"); character.Exp = PlayerPrefs.GetFloat(_currentFileName + "::Character::Exp");}那么,所有的属性都挂载到了对应的存档下面。
但是,现在所有的存档名都是隐藏在数据库中的,需要一个方法来获得所有存档名。
因为PlayerPref只提供了string的持久化接口,所以可以通过json将存档名的List序列化之后,以string的形式保存下来,获取时,再将string反序列化为List.
private IReadOnlyList<string> FileNames(){ return JsonConvert.DeserializeObject<List<string>>(PlayerPrefs.GetString("Filenames","[]"));}
private void CreatFile(string fileName){ var list = JosnConvert.DeserializeObiect<List<string>>(PlayerPrefs.GetString("Filenames","[]")); list.Add(fileName); PlayerPrefs.SetString("FileNames",JsonConvet.SerializeObject(list));}如此便解决了多存档问题,似乎看起来很好(?)
接下来就是复杂数据存储
假设我们有一个简单的背包物体
[Serializable]public class BackpackItem{ public string ItemId; public string ItemName; public string Desc; public Sprite Icon; public int Count;}最直接的存储想法是
private void save(){ PlayerPrefs.SetInt(_currentFileName + "::BackpackItem::Count", _backpackItems?.Count ?? 0); for (var i = 0; i <_backpackItems.Count; i++) { var backpackItem = _backpackItems[i]; PlayerPrefs.SetString($"{_currentFileName}::BackpackItem[{i}]::ItemId",backpackItem.ItemId); PlayerPrefs.SetString($"{_currentFileName}: : BackpackItem[{i}]: :ItemName") backpackItem.ItemName); PlayerPrefs.SetString($"{_currentFileName}: :BackpackItem[{i}]: :Desc",backpackItem.Desc); PlayerPrefs.SetInt($"{_currentFileName}::BackpackItem[{i}]::Count", backpackItem.Count); }}
private void Load(){ var count = PlayerPrefs.GetInt(_currentFileName + "::BackpackItem::Count",0); for (var i = 0; i く count; i++) { var backpackItem = new BackpackItem { ItemId = PlayerPrefs.GetString($"{_currentFileName}::BackpackItem[{i}]::ItemId", string.Empty), ItemName = PlayerPrefs.GetString($"{_currentFileName}::BackpackItem[{i}]::ItemName", string.Empty), Desc= PlayerPrefs.GetString($"{_currentFileName}::BackpackItem[{i}]::Desc", string.Empty), Count= PlayerPrefs.GetInt($"{_currentFileName}::BackpackItem[{i}]::Count",0), }; _backpackItems.Add(backpackItem); }}呃。。。好像。。。看上去。。。没问题?
似乎太丑陋了,可以通过json的方式将它序列化成一个类
因为json无法将sprite序列化,需要标记成JsonIgnore,先对原来的进行一些处理
[Serializable]public class BackpackItem{ public string ItemId; public string ItemName; public string Desc; [JsonIgnore] public Sprite Icon; public int Count;}然后对代码进行修改
private void save(){ PlayerPrefs.SetInt(_currentFileName + "::BackpackItem::Count", _backpackItems?.Count ?? 0); for (var i = 0; i <_backpackItems.Count; i++) { var backpackItem = _backpackItems[i]; PlayerPrefs.SetString($"{_currentFileName}::BackpackItem[{i}]",JsonConvert.SerializeObject(backpackItem)); }}
private void Load(){ var count = PlayerPrefs.GetInt(_currentFileName + "::BackpackItem::Count",0); for (var i = 0; i く count; i++) { var backpackItem = JsonConvert.DeserializeObject<BackpackItem>(PlayerPrefs.GetString($"{_currentFileName}::BackpackItem[{i}]","{}")); _backpackItems.Add(backpackItem); }}既然如此,不如一步到位
private void save(){ PlayerPrefs.SetString(_currentFileName + "::BackpackItems" , JsonConvert.SerializeObject(_backpackItems));}
private void Load(){ _backpackItems = JsonConvert.DeserializeObject<List<BackpackItem>>( PlayerPrefs.GetString(_currentFileName + "::BackpackItems" , "[]"));}那么再泛用一点
private void Save<T>(string key, T value){ PlayerPrefs.SetString($"{_currentFileName}:: {key}" , JsonConvert.SerializeObject(value));}
private T Load<T>(string key){ return JsonConvert.DeserializeObject<T>(PlayerPrefs.GetString($"{_currentFileName}::{key}", string.Empty));}这两个函数只关心存取的内容和当前的存档,其实存档系统只要负责把需要的数据给出来就行了,那么,使用一个唯一的key即可标记这个数据
二、基于json序列化的泛型存档系统
那么现阶段聚焦于存档系统的核心:多存档,IO,泛型加载
需求分析
1、每通关一个房间,自动保存进度(实际上这不是由存档系统主动发起的,而是被其他系统调用的),需要保存的东西可能为
玩家状态(血量、法力、经验、等级等)
持有装备、加持等
我们认为这些是和业务强相关的,由Model层同意管理
现假设Model层封装了一个PlayerModel来存放相关数据
2、游戏提供多存档位,每次开启新游戏都占用一个存档位
存档位显示游戏进度,创建时间等信息
所以用于储存存档信息的数据结构应当包含除了存档名之外的可自定义的数据
现假设存档信息数据结构如下
public interface IFileInfoPayload{
}
public sealed class FileInfo<T> where T : IFileInfoPayload{ public string FileName; public DateTime CreateTime; public DateTime LastModifyTime; public T Payload;}
//在Payload中加入任意想加入的信息public sealed class TestFileInfoPayload : IFileInfoPayload{ public string CharacterName; public string CharacterType; public string CurrentScene; public float GameProgress;}其中payload用于承载业务相关自定义的信息
本质上是开辟了一个新的空间用来存储存档信息。
游戏读取存档之前,先读取这个空间中的存档信息。
然后根据存档信息来做一些UI展示等内容。
3、提供设置信息的保存方式。设置信息必须独立于存档内信息,整个游戏是唯一的。
音量音效,光效,屏幕振动,语言这些用户偏好性设置
本质上是新加了一份存档文件,但是这个存档文件没有存档信息,也不受存档系统管理
实现思路
先实现一个Storage模块,用于进行实际的Io操作。
为了尽量减少IO操作的次数,需要实现一次性读入数据。
数据保留在缓冲区内,读写时只操作缓冲区。
提供写入方法,实际需要写入文件的时候将缓冲区内数据一次性写入文件。
按照这个思路IStorage接口定义如下
public interface IStorage{ ///<summary>/// 指定存档路径///</summary>///<param name="path"></param>void AssignSavePath(string path = "fileInfo");
///<summary>/// 从磁盘中读取(需要先调用AssignSavePath指定存档路径,默认为Unity持久化路径下的fileInfo文件)/// 数据存入缓冲区///</summary>void ReadFromDisk();
///<summary>/// 从文件中读取///</summary>///<param name="json"></param>void ReadFromText(string json);
///<summary>/// 将缓冲区内信息写入磁盘///</summary>void WriteToDisk();
///<summary>/// 将缓冲区内数据取出///</summary>///<param name="key"></param>///<param name="defaultValue"></param>///<param name="newValue"></param>///<typeparam name="T"></typeparam>///<returns></returns>T Load<T>(string key, T defaultValue, out bool newValue);
///<summary>/// 将对应键的数据写入缓冲区///</summary>///<param name="key"></param>///<param name="saveData"></param>///<typeparam name="T"></typeparam>void Save<T>(string key, T saveData);
///<summary>/// 清除缓冲区///</summary>void Clear();}其中,Load和Save是存档系统的核心函数。Load同时多提供一个out bool用于标记缓冲区中是否存在这个值。
public sealed class JsonStorage : IStorage{ //默认数据路径private string _savePath = Path.Combine(Application.persistentDataPath, "fileInfo.json");
//缓冲区,以键值对的形式存储数据,string就是对应数据的key,object以JObject的形式存在private Dictionary<string, object> _saveData = new();
public void ReadFromDisk() { if (File.Exists(_savePath)) { var json = File.ReadAllText(_savePath); //从指定路径下读取数据_saveData = JsonConvert.DeserializeObject<Dictionary<string, object>>(json); } else { _saveData.Clear(); } }
//从文件中读取public void ReadFromText(string json) { _saveData = JsonConvert.DeserializeObject<Dictionary<string, object>>(json); }
//缓冲区数据写入磁盘public void WriteToDisk() { var json = JsonConvert.SerializeObject(_saveData); File.WriteAllText(_savePath, json); }
//覆盖原缓冲区内数据public void Save<T>(string key, T saveData) { if (saveData == null) { Debug.LogWarning("Saving null data"); return; }
if (!_saveData.TryAdd(key, saveData)) { _saveData[key] = saveData; } }
//从缓冲区读取数据public T Load<T>(string key, T defaultValue, out bool newValue) { newValue = false; //缓冲区中没有数据,返回默认值,newValue设置为trueif (!_saveData.TryGetValue(key, out var saveValue)) { newValue = true; return defaultValue; } //特殊处理,JsonConvert.DeserializeObject<T>对bool和string直接转换会报错if (typeof(T) == typeof(bool) || typeof(T) == typeof(string)) { return (T)saveValue; } //特殊处理,List类数据为”[]"时,直接转化也会报错if (typeof(IList).IsAssignableFrom(typeof(T))) { if (saveValue.ToString() == "[]") { newValue = true; return defaultValue; }
if (saveValue is not JArray && saveValue is IList) { return (T)saveValue; } }
//反序列化return JsonConvert.DeserializeObject<T>(saveValue.ToString()); }
//指定存储路径public void AssignSavePath(string path = "fileInfo") { _savePath = Path.Combine(Application.persistentDataPath, path + ".json"); }
public void Clear() { _saveData.Clear(); }
}再实现FileSystem。存档系统是高于Storage一级的模块,通过操作Storage实现功能
public interface IFileSystem{ ///<summary>/// 提供所有存档名///</summary>IReadOnlyList<string> FileNames { get;}
///<summary>/// 根据给定FileInfoPayload返回对应的FileInfo/// FileInfoPayload,用来存储业务相关的自定义数据///</summary>///<typeparam name="T"></typeparam>///<returns></returns>IReadOnlyList<FileInfo<T>> GetFileInfos<T>() where T : IFileInfoPayload;
///<summary>/// 注册一个值///</summary>///<typeparam name="T"></typeparam>///<returns></returns>void Register<T>(SavableProperty<T> savable, string name);
///<summary>/// 注销一个值///</summary>///<param name="savable"></param>///<typeparam name="T"></typeparam>void Unregister<T>(SavableProperty<T> savable);
///<summary>/// 创建存档///</summary>///<param name="fileName"></param>void CreateFile(string fileName);
///<summary>/// 创建存档,同时创建一个Payload存储自定义信息///</summary>///<param name="fileName"></param>///<param name="payload"></param>///<typeparam name="T"></typeparam>void CreateFile<T>(string fileName, T payload) where T: IFileInfoPayload;
///<summary>/// 加载存档,返回存档对应的Payload///</summary>///<param name="fileName"></param>///<param name="defaultValue"></param>///<typeparam name="T"></typeparam>///<returns></returns>T LoadFile<T>(string fileName = "file1",T defaultValue = default) where T : IFileInfoPayload;
///<summary>/// 加载存档///</summary>///<param name="fileName"></param>void LoadFile(string fileName = "file1");
///<summary>/// 保存存档,同时写入Payload///</summary>///<param name="payload"></param>///<typeparam name="T"></typeparam>void SaveFile<T>(T payload) where T : IFileInfoPayload;
///<summary>/// 保存存档///</summary>void SaveFile(); void DeleteFile(string fileName); void DeleteAll();}SavableProperty<T>
public sealed class SavableProperty<T> : ISavable{ public T Value; Action ISavable.OnLoad { get; set; } = () => { }; Action ISavable.OnSave { get; set; } = () => { };}public interface ISavable{ Action OnLoad { get; set; } Action OnSave { get; set; }}解释一下为什么要这么设计。
我们希望存档系统最终能自动化的帮我们管理数值,而不是每次使用数据都要手动Load,每次保存都要手动Save。
那么就需要初始化的时候,将所有值注册到存档系统,并提供对应的键。
那么当调用读取存档方法的时候,存档系统会通过给定的键,自动帮我们把所有数据都加载完毕。
那么如何告诉存档系统,加载出来的数据放到哪里呢?
可以通过一个类包装起来。
用户持有SavableProperty<T>这个类,初始化时指定数据对应的key,并将其注册到存档系统
当用户调用读取存档方法时,存档系统会根据每一个key,从Storage的缓存区中读取数据,并为被注册的SavableProperty<T>的Value字段赋值。
用户端通过SavableProperty<T>.Value就能访问到他想要获得的数据
现在给出FileSystem的实现
public sealed class Filesystem : IFileSystem{ public IReadOnlyList<string> FileNames => _fileNames; private List<string> _fileNames { get; set; } = new();
private bool _inited; private bool _loaded;
private IStorage _storage; private readonly List<ISavable> _savableProperties = new(); private readonly Dictionary<ISavable, object> _defaultValues = new(); private string _currentFile = string.Empty;
public void DeleteFile(string fileName) { MakeSureInited(); _storage.AssignSavePath(fileName); _storage.Clear(); _storage.WriteToDisk(); _fileNames.Remove(fileName); Debug.Log("删除成功"); }
public void DeleteAll() { MakeSureInited(); foreach (var fileName in _fileNames) { DeleteFile(fileName); } }
private void MakeSureInited() { if (string.IsNullOrEmpty(_currentFile)) { if (_fileNames == null) { OnInit(); }
if (_fileNames!.Count == 0) { _fileNames.Add("file1"); }
_currentFile = _fileNames[0]; _storage.Save("FileNames", _fileNames); _storage.WriteToDisk(); } }
//具体实现委托给IStorageprivate T Load<T>(string key, T defaultValue, out bool isNew) { MakeSureInited(); return _storage.Load(key, defaultValue, out isNew); }
private T LoadFileInner<T>(T defaultValue) where T : IFileInfoPayload { MakeSureInited(); var fileInfo = SaveFileInfo(_currentFile, defaultValue); LoadFileInner(); return fileInfo.Payload; }
private void LoadFileInner() { MakeSureInited(); SaveFileName(); //指定路径_storage.AssignSavePath(_currentFile); //加载数据_storage.ReadFromDisk(); //为每个已注册值赋值foreach (var savable in _savableProperties) { savable.OnLoad?.Invoke(); }
_loaded = true; }
public T LoadFile<T>(string fileName = "file1", T defaultValue = default) where T : IFileInfoPayload { _currentFile = fileName; return LoadFileInner(defaultValue); }
//必须在所有值都注册完毕后,调用LoadFile,否则无法填充相应的值public void LoadFile(string fileName = "file1") { _currentFile = fileName; LoadFileInner(); }
///<summary>/// 用于将存档写入文件///</summary>private void SaveFileName() { MakeSureInited();
//指定路径_storage.AssignSavePath();
//读取磁盘_storage.ReadFromDisk();
//写入缓存区_storage.Save("FileNames", _fileNames);
//写入磁盘_storage.WriteToDisk(); }
//用于保存Payloadprivate FileInfo<T> SaveFileInfo<T>(string fileName, T payload) where T : IFileInfoPayload { MakeSureInited();
//指定路径_storage.AssignSavePath();
//读取磁盘_storage.ReadFromDisk();
var infos = _storage.Load("FileInfos" + typeof(T).Name, new List<FileInfo<T>>(), out _); var targetInfo = infos.Find(file => file.FileName == fileName); if (targetInfo != null) { targetInfo.LastModifyTime = DateTime.Now; targetInfo.Payload = payload; } else { targetInfo = new FileInfo<T> { FileName = fileName, CreateTime = DateTime.Now, LastModifyTime = DateTime.Now, Payload = payload }; infos.Add(targetInfo); }
//写入缓存区_storage.Save("FileInfos" + typeof(T).Name, infos); //写入硬盘_storage.WriteToDisk(); return targetInfo; } //具体实现委托给IStorageprivate void Save<T>(string key, T value) { MakeSureInited(); _storage.Save(key, value); }
public void SaveFile<T>(T payload) where T : IFileInfoPayload { if (!_loaded) { Debug.LogWarning("需要先加载一个存档"); return; } SaveFileInfo(_currentFile, payload); SaveFile(); }
public void SaveFile() { if (!_loaded) { Debug.LogWarning("需要先加载一个存档"); return; } _storage.AssignSavePath(_currentFile); _storage.Clear(); //依次调用onSave方法,把注册数据写入缓存区foreach (var savable in _savableProperties) { savable.OnSave?.Invoke(); } //将缓存区中的数据写入文件_storage.WriteToDisk(); //将存档名写入文件SaveFileName(); }
private void OnInit() { if (!_inited) { _storage = new JsonStorage(); _storage.AssignSavePath(); _storage.ReadFromDisk(); _fileNames = _storage.Load("FileNames", new List<string>(), out _); _inited = true; } }
//可以自定义存档名或者默认存档名public void CreateFile(string fileName) { MakeSureInited();
if (_fileNames.Contains(fileName)) { Debug.LogError($"已存在存档{fileName}"); return; }
_fileNames.Add(fileName); SaveFileName(); }
public IReadOnlyList<FileInfo<T>> GetFileInfos<T>() where T : IFileInfoPayload { MakeSureInited(); _storage.AssignSavePath(); _storage.ReadFromDisk(); return _storage.Load("FileInfos" + typeof(T).Name, new List<FileInfo<T>>(), out _); }
public void Register<T>(SavableProperty<T> savable, string name) { if (!_savableProperties.Contains(savable)) { _savableProperties.Add(savable); _defaultValues.Add(savable, savable.Value); var saveItem = (ISavable)savable; saveItem.OnLoad += () => { var value = Load(name, (T)_defaultValues[savable], out var isNew); if (!isNew) savable.Value = value; else savable.Value = (T)_defaultValues[savable]; }; saveItem.OnSave += () => Save(name, savable.Value); } else Debug.Log("重复注册值"); }
public void Unregister<T>(SavableProperty<T> savable) { if (_savableProperties.Contains(savable)) { _savableProperties.Remove(savable); var saveItem = (ISavable)savable; saveItem.OnLoad = null; saveItem.OnSave = null; } else Debug.Log("重复注销值"); }
public void CreateFile<T>(string fileName, T payload) where T : IFileInfoPayload { MakeSureInited(); if (_fileNames.Contains(fileName)) return; _fileNames.Add(fileName); SaveFileInfo(fileName, payload); SaveFileName(); }}这样基于Json的一个泛型存档系统就做好了。
这个系统,可以替换IStorage的底层实现,XML,Bindary都是可以的,完全不会影响到上层系统的调用。
并且你可以在读写方法时加入加密环节,或者采用异步读写的方式。
但还没有结束,还有设置信息的存档
我认为设置信息的读写是非常灵活的一个需求,往往会出现框架未初始化完毕就需要进行设置信息读写的需要。但是设置信息又是小规模的读写,不像游戏数据那样需要花费比较大的代价。
同时,当存档系统管理一个存档数据的时候,缓冲区内的数据是不能直接清理的,如果这个功能交给存档系统实现的话,需要再创建一个IStorage用于设置信息。
考虑到这些,我认为将这部分功能封装为一个静态类是比较合理的。
这个静态类实现为
public static class StorageUtil{ private static JsonStorage _jsonStorage; private static IStorage Storage => _jsonStorage ??= new JsonStorage();
public static T LoadByJson<T>(string key, T defaultValue, out bool newValue) { Storage.AssignSavePath("setting"); Storage.ReadFromDisk(); return Storage.Load(key, defaultValue,out newValue ); }
public static void SaveByJson<T>(string key, T value) { Storage.AssignSavePath("setting"); Storage.ReadFromDisk(); Storage.Save(key, value); Storage.WriteToDisk(); }}