[Unity] 状态机事件流程框架 (二) 设计游戏状态的保存框架,存档功能 ScriptableObject、EasySave

发布于:2023-01-20 ⋅ 阅读:(11) ⋅ 点赞:(0) ⋅ 评论:(0)

前文 : ​​​​​​​[Unity] 状态机事件流程框架 (一)

        本期来设计一个游戏状态的怎么在游戏中表示和存储。保存游戏状态的目的一是方便根据玩家当前的游戏进度实行各种各样的逻辑分支,二是在存档时能记录实时的游戏数据,方便读档回到存档位置。

效果展示

        实现的效果图如下(图为作者参与过项目展示,图一的例子为游戏流程-序章剧情中某一处需要触发摄像机引导的Trigger配置,图二为在框架中自定义游戏状态表示,并可以使用一个Trigger去访问它。其中编辑器窗体由Odin制作,不在本期讨论范围)

最后所有的状态都能被写入磁盘(EasySave实现)

 

实现方式

如何在游戏中存储状态:

        一般来说,我们会使用unity中ScriptableObject来表示游戏中一些数据,方便我们在游戏编辑器下的编辑,但使用ScriptableObject时需要搞清楚几个概念。首先分享一个在M_Studio中背包系统视频下的一条评论:

        总结就是:游戏中的数据分为持久化数据非持久化数据。比如一个物品可能由A、B、C三种状态,这里的物品状态列表就可以使用一个ScriptableObject进行存储(即非持久化数据,使用List可进行存储)。当游戏开始运行时,某一刻该物品的状态是B,此时我们要读取状态做判断或者存档操作时,我们不需要知道该物品是否有其他状态(A、C),只需要知道【物品状态->B】的关系就可以了。这个就是需要持久化数据,这种一一对应的关系比较适合用字典方式来实现它。

        因此,我们将游戏状态需要的数据分离成可持久化和非持久化,并需要将它们表示在不同的脚本位置。

        非持久化数据:状态名(String),拥有的状态列表(List<String>),应放在ScriptableObject中

        持久化数据:状态名,当前状态(Dictionary<string,string>),应放在MonoBehaviour脚本上。该脚本一般是拥有单例模式的管理类。

        我们先表示以下怎么使用状态的ScriptableObject表示。这里的ValueDropID和ValueDropValue方法主要提供给是在Trigger使用下拉菜单。

     public interface IStatusCheck<TKey,TValue>
    {
        List<TKey> SelectID();
        List<TValue> SelectValue(TKey ID);
    }
    public abstract class StatusData : ScriptableObject
    {
        public void OnEnable()
        {
            key = this.GetType().ToString() + "-" + name;
        }
        [Header("请保证key值唯一")]
        public string key;

        public abstract List<string> ValueDropID();
        public abstract List<string> ValueDropValue(string id);
    }

    //实现范式版本
    public class StatusData<TKey, TValue> : StatusData, IStatusCheck<TKey, TValue>
    {
        [Header("备注"),TextArea]
        public string content;

        [Space]
        public List<Data> datas;

        [Serializable]
        public class Data
        {
            public TKey ID;
            public List<TValue> Values;
        }

        public override List<string> ValueDropID()
        {
            List<string> retList = new List<string>();
            foreach (var id in datas)
            {
                retList.Add(id.ID.ToString());
            }
            return retList;
        }

        public override List<string> ValueDropValue(string id)
        {
            List<string> retList = new List<string>();

            var selectData = datas.Find(x => x.ID.ToString() == id.ToString());
            if (selectData != null)
            {
                foreach(var str in selectData.Values)
                    retList.Add(str.ToString());
            }
                

            return retList;
        }
    }

这里具体实现游戏状态,使用 StatusData<string, string>进行派生就好啦。

    [CreateAssetMenu(menuName = "新建状态/游戏状态")]
    public class GameStatusData : StatusData<string, string>
    {
        
    }

随后我们设置对应的Trigger,使用它的下拉方法ValueDropID()和ValueDropValue()

    [System.Serializable]
    public class Status
    {
        public StatusData config;

        [ValueDropdown(nameof(ValueDropID))]
        public string id;
        [ValueDropdown(nameof(ValueDropValue))]
        public string value;

        public List<string> ValueDropID()
        {
            if (config)
                return config.ValueDropID();
            return null;
        }
        public List<string> ValueDropValue()
        {
            if (config)
                return config.ValueDropValue(id);
            return null;
        }
        [LabelText("条件为真/假")]
        public bool isTrue = true;
    }

    [AddComponentMenu("Sugarzo触发器/游戏状态触发器")]
    public class StatusTrigger : BaseTrigger
    {
        public List<Status> status = new List<Status>();
        //还有很多其他设置先省略
    }

现在看看我们写好的效果:

         嗯嗯,看起来程序运行的十分顺利(?),我们已经正确能在Trigger看到写好的状态并选择他们。很明显,ScriptableObject的数据只存在编辑器中。我们现在的状态数据还没有被装进游戏中。现在应该添加一个MonoBehaviour的管理类脚本,去管理游戏中实时数据了。

         作为一个管理类,应该实现什么功能呢?首先应该是一个单例,随后要有设置数据/检查数据的方法,然后是保存数据/读取数据的方法,我们先把接口写出来:

public interface IStatusSave
    {
        void LoadData();
        void SaveData();
    }
    public interface IStatusCheck
    {
        bool IsStatus(string _data, string _id, string _value);
        void SetStatus(string _data, string _id, string _value);
    }

这里的方法IsStatus用了三个参数的版本。id/value自然是标记状态名和具体状态的。data主要是表示该状态位于哪一个ScriptableObject中的数据(这里用了前文中的StatusData.Key)

接着我们注册一个静态类存储一些静态方法,方便我们的Trigger调用:

public static class StatusManager
    {
        //存储IStatusCheck的实例
        public static Dictionary<Type,IStatusCheck> managerInstances = new Dictionary<Type, IStatusCheck>();

        public static bool IsStatus(StatusData data, string ID, string value)
        {
            if (managerInstances.ContainsKey(data.GetType()))
                return managerInstances[data.GetType()].IsStatus(data.key, ID, value);

            Debug.LogError("找不到关于 " + data.name + " 的管理类实例");
            return false;
        }
        public static void SetStatus(StatusData data, string ID, string value)
        {
            if (managerInstances.ContainsKey(data.GetType()))
            {
                Debug.Log("切换游戏状态 " + ID + " -> " + value);
                managerInstances[data.GetType()].SetStatus(data.key, ID, value);
                EventManager.EmitEvent(EventEnum.GameStatusChange.ToString());
            }
        }
    }

可以看到设置状态时发送了EventManager.EmitEvent(EventEnum.GameStatusChange.ToString());该事件需要由所有状态Trigger监听,意思时修改完状态时,通过发送信号所有Trigger都会检查当前状态是否满足条件,如果满足就执行Action。

我们回到StatusTrigger实现完其余功能:

    [AddComponentMenu("Sugarzo触发器/游戏状态触发器")]
    public class StatusTrigger : BaseTrigger
    {
        public List<Status> status = new List<Status>();

        public override void Execute()
        {
            if (IsState())
                base.Execute();
        }
        //会在Enable中运行
        public override void RegisterSaveTypeEvent()
        {
            base.RegisterSaveTypeEvent();
            
            if(status.Count > 0)
                EventManager.StartListening(EventEnum.GameStatusChange.ToString(), Execute);
        }
        //会在DisEnable中运行
        public override void DeleteSaveTypeEvent()
        {
            base.DeleteSaveTypeEvent();

            EventManager.StopListening(EventEnum.GameStatusChange.ToString(), Execute);
        }

        private bool IsState()
        {
            foreach (var statu in status)
            {
                if (StatusManager.IsStatus(statu.config,statu.id,statu.value) != statu.isTrue)
                    return false;
            }
            return true;
        }
    }

       对应的修改状态的Action:

public class StatusAction : BaseAction
    {
        [Header("设置游戏状态")]
        public List<Status> status = new List<Status>();

        public override void RunningLogic()
        {
            foreach(var sta in status)
            {
                StatusManager.SetStatus(sta.config, sta.id, sta.value);
            }

            RunOver();
        }

        [System.Serializable]
        public class Status
        {
            public StatusData config;

            [ValueDropdown(nameof(ValueDropID))]
            public string id;
            [ValueDropdown(nameof(ValueDropValue))]
            public string value;

            public List<string> ValueDropID()
            {
                if (config)
                    return config.ValueDropID();
                return null;
            }
            public List<string> ValueDropValue()
            {
                if (config)
                    return config.ValueDropValue(id);
                return null;
            }
        }
    }

         接着我们就可以写具体实现了接口IStatusSave和IStatusCheckStatusManager的管理类实例了。为了方便扩展这里使用了三个泛型参数。TData被StatusData约束,<TKey, TValue>对应的也是StatusData的数据类型。

        在管理类中,我们需要维护两个东西,一个是需要配置在游戏中的数据List<TData> configs,另一个则是实时数据存储的字典了:Dictionary<string, Dictionary<string, string>> configData,我们实时存档的数据都存储在字典中,设置检查状态,读档和存档的操作也是在操作这个类型。

public class StatusManager<TData,TKey, TValue> : MonoBehaviour,IStatusSave, IStatusCheck where TData : StatusData<TKey, TValue>
    {

        public virtual bool IsStatus(string _data, string _id, string _value)
        {
            if(configData.ContainsKey(_data))
                if (configData[_data].ContainsKey(_id))
                    return configData[_data][_id].Equals(_value);

            Debug.LogError("找不到关于 " + _id + " 的数据类型");
            return false;
        }

        public virtual void SetStatus(string _data, string _id, string _value)
        {
            if (configData.ContainsKey(_data))
                if (configData[_data].ContainsKey(_id))
                {
                    configData[_data][_id] = _value;
                }
                else
                    Debug.LogError("找不到关于 " + _id + " 的数据类型");
            else
                Debug.LogError("找不到关于 " + _id + " 的数据类型");

            return;
        }

        public List<TData> configs;
        protected Dictionary<string, Dictionary<string, string>> configData;

        protected virtual void Awake()
        {
            InitData();
            RegisterStatic(); 
            RegisterSave();
        }

        //初始化字典数据
        protected virtual void InitData()
        {
            configData = new Dictionary<string, Dictionary<string, string>>();
            foreach(var config in configs)
            {
                configData.Add(config.key, new Dictionary<string, string>());
                foreach(var cData in config.datas)
                {
                    configData[config.key].Add(cData.ID.ToString(), string.Empty);
                }
            }
        }
        //注册静态数据
        protected virtual void RegisterStatic()
        {
            StatusManager.managerInstances.Add(typeof(TData), this);
        }
        //注册存档事件监听数据
        protected virtual void RegisterSave()
        {
            EventManager.StartListening(EventEnum.GameSave.ToString(), SaveData);
            EventManager.StartListening(EventEnum.GameLoad.ToString(), LoadData);
        }


        public void LoadData()
        {
            GameSaveManager.LoadData(this.GetType().ToString(), out configData);
        }

        public void SaveData()
        {
            GameSaveManager.SaveData(this.GetType().ToString(), configData);
        }

        [Sirenix.OdinInspector.Button]
        public void DebugAllStatus()
        {
            foreach (var data in configData)
            {
                foreach (var cData in data.Value)
                {
                    Debug.Log(cData.Key + " " + cData.Value);
                }
            }
        }
    }

}

使用这个范式基类派生出我们真正需要的GameStatusManager实例:

public class GameStatusManager : StatusManager<GameStatusData,string,string>
    {

    }

        接着是存档框架,这里使用了ES3.Save和ES3.Load,通过SaveData<T>(string saveKey,T data)的函数签名,可以很方便的存储游戏数据。

    public class GameSaveManager : SingletonMono<GameSaveManager>
    {

        public string slotKey = "Save0";

        protected override void Awake()
        {
            base.Awake();
            GameSaveInstance = new GameSave();
        }

        public static GameSave GetGameSave()
        {
            return Instance.GameSaveInstance;
        }

        [Button,ButtonGroup]
        public static void SaveGameToSlot()
        {
            Debug.Log("存储卡槽存档 " + Instance.slotKey);

            EventManager.EmitEvent(EventEnum.GameSave.ToString());
            GetGameSave().slotKey = Instance.slotKey;

        }
        [Button, ButtonGroup]
        public static void LoadGameFromSlot()
        {
            Debug.Log("读取卡槽存档 " + Instance.slotKey);

            EventManager.EmitEvent(EventEnum.GameLoad.ToString());
        }

        public static void SaveData<T>(string saveKey,T data)
        {
            Debug.Log(saveKey + " 保存");
            ES3.Save(Instance.slotKey + "@" + saveKey,data);
        }
        public static void LoadData<T>(string saveKey,out T data)
        {
            Debug.Log(saveKey + " 读取");
            data = (T)ES3.Load(Instance.slotKey + "@" + saveKey);
        }
    }

        好了这里的框架就分析了。虽然感觉是有点乱(?)把这一部分的源码上传到了github,有兴趣的可以参考参考,框架内已内置Odin和EasySave3插件。有问题欢迎讨论GitHub - sugarzo/UnityFrame: 一些unity框架,目前只做到了Trigger/Action/状态表示系统

        后面可能还会有几篇文档,可能会讲讲unity的编辑器拓展,动态管理窗口配置啥的。(下次一定)