目录
前言
本篇文章将从游戏框架中的事件管理、存档系统、对话系统、数据管理等方面,介绍如何分步实现一个美观实用的游戏框架,以进行游戏项目的长期开发与合作
一、Action事件管理器
在一款游戏中,想要有一个严密的框架,一定是缺少不了事件的产生的,在这里向大家介绍如何在unity中实现事件管理C#框架,并介绍每个事件在游戏中大概发挥的作用。
为了方便脚本中事件的直接启动,使用static静态类,并且使用static前缀,定义Action事件和启动Action事件的脚本
public static class EventHandler
{
//物体被切换的事件
public static event Action<int> ChangeItemEvent;
/// <summary>
/// 呼叫物体被切换时执行
/// </summary>
/// <param name="index"></param>
public static void Call_ChangeItemEvent(int index)
{
ChangeItemEvent?.Invoke(index);
}
}
在这里EventHandler作为事件管理器,为静态类,再次定义有参的事件ChangeItemEvent。由于事件只能由内部调用让事件执行,所以在这里设置含有对应参数的Call_ChangeItemEvent,只要调用该函数,就能达到事件执行的效果。那么之前订阅了事件的函数,将会一起随之执行。这就是广播的效果
1.代码示范
1.1 订阅事件
在脚本启动与禁用的时候,分别把事件订阅(注册)与事件取消订阅
private void OnEnable(){
{
EventHandler.ChangeItemEvent += OnChangeItemEvent;
}
private void OnDisable()
{
EventHandler.ChangeItemEvent -= OnChangeItemEvent;
}
1.2 事件订阅的具体函数
事件注册的函数实现(如果有参数则带上参数),该函数将会在事件执行(广播)的时候伴随执行
private void OnChangeItemEvent(int index)
{
if (index >= 0 && index < m_itemList.Count)
{
ItemDetails item = m_itemData.GetItemDetails(m_itemList[index]);
EventHandler.Call_UpdateUIEvent(item,index);
}
}
1.3 启动、引发事件
只需要在需要的地方调用该函数即可实现快速启动事件
EventHandler.Call_ChangeItemEvent(index);
以上只是代码实现,那如何结合在实际游戏制作中,以下是我的基本框架
2.框架示范
2.1 场景卸载与加载
2.2 对话加载
2.3 物品加载
2.4 游戏状态的改变
2.5 事件管理器总视图
2.6 总结
可以看到,事件管理器中的每一个事件,都存在“引发事件”的脚本和“订阅事件”的脚本。彼此之间存在联系,“引发事件”就是在确定事件启动的时机时刻,就是在特定的时刻进行广播 。而“订阅事件”的对象就是在接收广播,接收广播的内容,然后按照内容一起执行。
而设置管理器的好处就是可以直观的管理控制游戏中每个事件的执行,不然事件分散在各个脚本中会十分混乱,难以管理。并且利用事件的特性,可以高效正确的管理好游戏中的每种状态,也可以联系到存档相关的同样需要统一管理的系统
二、Json存档管理模块
1.使用插件
为了存储的方便与后期修改,这里使用Newtonsoft.Json这个插件,它的好处是可以将数据按字典存储并且读取的时候,可以将保存的数据再次转换为字典,在实际开发中,会更加直观且便利
using Newtonsoft.Json;
2.存储路径
一般选择Application.persistentDataPath+加上自定义的存储数据文件夹名,这样存储是在本地的C盘的对应游戏存储文件夹下,也便于后期查找排错与游戏的运行存储
3.存储思路
可以设置string字典,以脚本名称作为数据的slug,并且获取脚本上需要存储的数据。这样在读取的时候,就可以直接按照路径和转化过后的字典,按照脚本的名称一个个恢复数据(赋值)
4.代码实现
以上只是大致的思路,接下来是具体的代码实现
4.1 定义接口
为了将存档同步进行,设置接口规范好方法,之后只需要让脚本继承接口,实现方法即可(使用接口的原因是,接口会规范化每一个继承的成员,使得继承类一定要实现对应的方法,并且因为继承了接口,可以用接口类型去统一管理这些脚本,例如结合反射GetType,创建接口继承脚本列表管理)
public interface ISaveInterface
{
void SaveLoadRegister()
{
//注册存入存储对象的列表
SaveLoadManager.Instance.Register(this);
}
GameSaveData GenerateSaveData();//产生存储数据并且获取
void RestoreGameData(GameSaveData saveSaveData);//根据存储数据恢复数据
}
在接口中,分别设置了三个最基础的函数,分别是注册函数(存入存档管理器SaveLoadManager的列表中,之后统一执行),请注意这里的注册函数不是一个抽象函数,它已经实现了具体的方法,则不需要在子类中实现该方法。该方法的作用只是获取到具体的对象,然后按照接口中的方法实现,实现该方法而已。获取数据函数(产生存储数据,并且将本地需要存储的数据返回),恢复数据函数(按照存储好的数据,在游戏开始后选择该脚本对应的数据来恢复数据)。
4.2 定义存储类型
其中GameSaveData是一个存储类型类,在实际开发中,可以脱离这个存储类型,针对性地建立存储类型,当然要改变相应的逻辑,这里介绍的只是较为通用的方法。
public class GameSaveData
{
public int gameweek;//小游戏的数据
public string currentScene;//游戏所处场景
public Dictionary<SceneName, bool> miniGameStateDic;
public Dictionary<ItemName, bool> itemAvailableDict;
public Dictionary<string, bool> interactiveStateDict;
public List<ItemName> itemList;
}
4.3 继承接口
在实际需要存储的脚本中,首先需要继承存储 ISaveInterface的接口,这里以GameManager脚本举例
public class GameManager : MonoBehaviour,ISaveInterface
{
//保存游戏状态的字典
private Dictionary<SceneName, bool> m_miniGameStateDic = new Dictionary<SceneName, bool>();
private GameController m_currentGame;
4.4 存储脚本
继承好之后,首先需要注册该函数到存储数据管理器中,在这里创建变量,并且将继承了该接口的脚本对象,调用接口中已经实现的方法,进而完成保存需要存储的对象
private void Start()
{
//保存数据
ISaveInterface saveinter = this;
saveinter.SaveLoadRegister();
}
4.5 存储数据
接下来产生存储数据并且存储相应的数据。可以在方法中看见,先是实例化GameSaveData
的存储类型,然后分别将本脚本中的数据赋值到存储类型中的数据,然后返回该数据类型,
public GameSaveData GenerateSaveData()
{
GameSaveData saveData = new GameSaveData();
saveData.gameweek = m_gameweek;
saveData.miniGameStateDic = m_miniGameStateDic;
return saveData;
}
4.6 恢复数据
最后要实现的就是数据恢复函数。这里是接收传入的数据类型参数,然后将相应的数据赋值给本脚本中的数据即可完成恢复。
public void RestoreGameData(GameSaveData saveSaveData)
{
m_gameweek = saveSaveData.gameweek;
m_miniGameStateDic = saveSaveData.miniGameStateDic;
}
4.7 统一管理
那如何对这些需要存储的脚本,进行统一管理呢,在这里建立SaveLoadManager进行统一的管理
4.7.1 建立脚本列表
首先是要建立接口列表,去存储每一个注册的脚本
private List<ISaveInterface> m_saveinterList = new List<ISaveInterface>();
public void Register(ISaveInterface saveinter)
{
m_saveinterList.Add(saveinter);
}
这里就对应了之前提到的注册函数的具体实现,在接口的继承中,只要注册过,就会在该接口列表中存储
4.7.2 存储数据
通过存储列表,进行统一数据的存储
private Dictionary<string,GameSaveData> m_saveDataList = new Dictionary<string, GameSaveData>();
protected override void Awake()
{
base.Awake();
m_jsonFolder = Application.persistentDataPath + "/SAVE/";
}
public void Save()
{
m_saveDataList.Clear();
foreach (var saveinter in m_saveinterList)
{
m_saveDataList.Add(saveinter.GetType().Name,saveinter.GenerateSaveData());
}
var resultPath = m_jsonFolder + "data.txt";
var jsonData = JsonConvert.SerializeObject(m_saveDataList, Formatting.Indented);//将字典转为json格式数据
if (!File.Exists(resultPath))
{
Directory.CreateDirectory(m_jsonFolder);//创建该文件夹
}
System.IO.File.WriteAllText(resultPath, jsonData);//文件流写入
}
数据存储依托一个字典来实现,字典的key对应脚本名称(这里应用了反射GetType去获取该脚本的实际名称),其value对应脚本的存储数据。由于脚本需要实现单例,所以在继承了单例实现脚本之后,如果还要在子类中使用Awake的方法,就需要单例实现脚本中的Awake是virtual抽象方法,那么才可以在子类中进行重写修改。如此定义好存储路径后,则利用Newtonsoft.Json中的API
JsonConvert.SerializeObject
将对应字典类型转化为json存储的数据,然后按照路径,使用
System.IO.File.WriteAllText
文件流将json格式的数据写入对应路径下的文件中 ,如此就完成了统一的数据存储
4.7.3 读取数据
在数据读取中也是类似的思路,这里就快速介绍一下
public void Load()
{
var resultPath = m_jsonFolder + "data.txt";
if (!File.Exists(resultPath))
{
print("当前不存在存储的文件!");
return;
}
var stringData = System.IO.File.ReadAllText(resultPath);//读取数据
var jsonData = JsonConvert.DeserializeObject<Dictionary<String, GameSaveData>>(stringData);
foreach (var saveinter in m_saveinterList)
{
saveinter.RestoreGameData(jsonData[saveinter.GetType().Name]);//根据姓名去恢复保存的数据
}
}
使用了文件流读取
System.IO.File.ReadAllText
读取获得的数据,使用Newtonsoft.Json中的API转化为字典数据
JsonConvert.DeserializeObject
继承对象的列表去一一按照脚本的名称,调用对应的方法,恢复自己的数据
然后在结合事件管理器中的事件,在恰当的时机,调用管理器中的脚本,就可以实现一套完整美观的存档读档系统
三、Stack栈式实现对话管理模块
在对话系统中,一定是需要保存对话数据的,动态管理数据一般都是使用List列表实现。但是这样需要一个个遍历,还需要管理当前的对话次数来显示预期的对话。但如果用栈来存储对话数据,就不会这么麻烦,因为栈的弹出,可以直接将当前栈顶弹出去,也不需要用remove和count等函数,一个TryPop就可以同时做到获取当前对话数据,删除当前对话条,自动获得当前未使用的对话数据的功能。
1.代码实现
1.1 定义栈
private Stack<string> m_dialogueEmptyStack;//栈式结构存储与使用
1.2 存储数据入栈
因为栈的弹出是,先进后出的特点,所以顺序存储的对话数据dialogueEmpty.DailogueList就需要倒序存储进入栈中
private void FillDialogueStack()
{
m_dialogueEmptyStack = new Stack<string>();
for (int i = dialogueEmpty.DailogueList.Count - 1; i > -1; i--)
{
//先进后出,则倒序存入栈
m_dialogueEmptyStack.Push(dialogueEmpty.DailogueList[i]);
}
}
1.3 栈对话进行管理
private IEnumerator DialogueRountine(Stack<string> data)
{
m_isTalking = true;//协程正在对话中
if (data.TryPop(out string result))
{
EventHandler.Call_ShowDialogueEvent(result);
yield return null;
m_isTalking = false;
EventHandler.Call_GameStateChangeEvent(GameState.Pause);
}
else
{
//当无法再次弹出的时候,就重新存入数据
EventHandler.Call_ShowDialogueEvent(String.Empty);
FillDialogueStack();
m_isTalking = false;
EventHandler.Call_GameStateChangeEvent(GameState.GamePlay);
}
}
这里是设置了一个协程,以m_isTalking为开关,控制协程的进行。对栈使用TryPop就可以对当前栈顶元素result进行管理(这里是传入事件中,进行显示事件的启动)。如果无法再次弹出,那么就可以再次调用FillDialogueStack(),去存储对话数据,然后就可以再次弹出。如此就实现了动态对话数据的管理
注:在对话时候,也可以和示例代码一样,去启动对应的事件,从而改变游戏的状态,防止其他不必要的影响
四、游戏固定数据存储模块ScriptableObject
在这里固定数据指的是,游戏中一些物品的名称,对应的图片,这样固定好的,不需要专门存储的对应数据。(而且有关图片的数据在json中存储是不太方便的,当然可以使用Adressable进行字符串对应存储,但这已经不在本篇的介绍之内)在前面介绍了游戏数据的json存储。这里介绍unity内置的一种数据存储方案 -- ScriptableObject
在项目开发中,通常用其去存储物品的属性、图片、名称这样的数据。
1.建立物品类列表
格式是建立一个物品的类,然后在ScriptableObject脚本中建立类的列表
[CreateAssetMenu(fileName = "ItemDataList_SO",menuName = "Inventory/ItemDataList_SO")]
public class ItemDataList_SO : ScriptableObject
{
public List<ItemDetails> m_itemDetailsList;
}
[System.Serializable] //可序列化--可存储
public class ItemDetails
{
public ItemName itemName;
public Sprite itemSprite;
}
然后利用
[CreateAssetMenu(fileName = "...",menuName = ".../...")]
在外部建立出 ScriptableObject的类型数据,然后对应的脚本进行数据获取调用即可。
//获取数据存储管理器Scriptobject
[SerializeField] private ItemDataList_SO m_itemData;
2.存储数据快速查找
为了实现数据获取的方便,这里一般是在数据存储脚本内部,使用列表的Find方法,快速获取对应的数据
public class ItemDataList_SO : ScriptableObject
{
public List<ItemDetails> m_itemDetailsList;
/// <summary>
/// 根据物品名称获取详细信息
/// </summary>
/// <param name="itemName"></param>
/// <returns></returns>
public ItemDetails GetItemDetails(ItemName itemName)
{
return m_itemDetailsList.Find(i => i.itemName == itemName);//列表通过元素值相同快速寻找元素
}
}
五、特殊名称管理模块 Enum
实际开发中,会出现各种各样的名称管理,可以是场景名,也可以是物品名,这些名称如果每次都输入的话,会不直观,并且繁琐,那么可以使用enum创建枚举类型,只要定义好了之后,在脚本中声明,就可以直接在编辑器中进行选择,非常直观
public enum SceneName
{
H1, H2,H3,H4,H2A,Persistent
}
public enum ItemName
{
None,Key,Ticket
}
public enum GameState
{
GamePlay,Pause
}
六、受保护可改写方法的注意
通常在游戏的框架中,继承是经常发生的,有时候我们需要执行父类方法的同时,启动子类的对应方法,这时候就可以使用
protected virtual
去修饰父类中的方法,然后在继承该父类的子类中,使用
protected override
就可以重写覆盖父类中的同名函数。但有时候我们也需要父类中的一起执行,那就可以在子类中加上base.方法名即可
接下来是示例代码
protected virtual void OnClickAction()
{
//父类中
}
protected override void OnClickAction()
{
//在子类中
base.OnClickAction();//同时执行父类的方法
m_spriteRenderer.sprite = m_openSprite;
transform.GetChild(0).gameObject.SetActive(true);
}
七、UI互动性相关API
在完整的游戏中,是肯定需要与UI互动的存在,但是只是单纯的点击是满足不了需求的,有时候会需要鼠标靠近、点击、离开这三个操作执行不同的事件的,那么就需要当前UI元素,挂上继承了
IPointerClickHandler,IPointerEnterHandler,IPointerExitHandler
的脚本,去执行相应的操作
1.当鼠标点击该UI时
public void OnPointerClick(PointerEventData eventData)
{
}
2.当鼠标进入该UI的范围内时
public void OnPointerEnter(PointerEventData eventData)
{
}
3.当鼠标离开UI的范围时
public void OnPointerExit(PointerEventData eventData)
{
}
4.当鼠标和Collider2D互动时
有时候需要互动的也可以是图片SpriteRender,那么只需要在图片上挂载Collider2D组件,然后Update调用api --- Physics2D.OverlapPoint
private Collider2D ObjectAtMousePos()
{
return Physics2D.OverlapPoint(m_mouseWorldPos);//存在任何2D碰撞器。如果有,它将返回与鼠标位置重叠的第一个碰撞器的Collider2D组件。如果没有物体与鼠标位置重叠,它将返回null
}
即可返回当前与鼠标互动的Collider2D组件