图灵杯是我们学院一年内比较重大的活动
于是图灵杯的游戏程序设计也是至关重要的
面对像图灵杯这样较大的项目 需要一个比较好的项目设计模式
上网搜索 发现事件驱动模式(或者说观察者模式亦或是发布/订阅模式)是比较适合Unity3D的
于是写下这么一篇学习心得 以供自己后面和后来人参考
如有纰漏 望耐心指教
原本以为事件驱动模式是非常简单的 但是却发现自己的理解非常浅显
我总结的事件驱动模式的核心思想大概就是
人为地指定一些事件 当事件发生时 所有监听这个事件的类自动调用对这个事件的处理函数
其实通过一句话是非常难解释清楚 利用我们这次的图灵杯来举个例子吧
本次的图灵杯中 存在这么一个游戏机制 玩家在地上放下一个炸弹 炸弹爆炸时 在炸弹爆炸范围内的方块被炸掉 玩家受到一定伤害
在这个游戏机制中 存在一个非常明显的事件:炸弹爆炸
也存在非常明显的对这个事件的处理:在炸弹爆炸范围内 方块被炸掉 玩家受到一定伤害
而事件驱动模式 就是需要让下图的这样的一个流程图成为贯穿整个项目的模式
在一般的没有经过设计的面向对象编程中 类之间的交互可能是直接交互的(如下图)
标没有采用事件驱动模式的调用关系
这样就会造成类之间的耦合非常大 当需要修改一个类的函数的时候 可能会涉及到其他类对这个函数调用的代码
进一步造成后期维护和再开发十分困难和危险
而通过事件驱动模式或者观察者模式 我们可以发现 脚本或者类之间的交互全部都是间接交互的
他们通过中间一个叫做事件管理器的全局单例脚本进行间接地交互 这解除了类之间的直接耦合(解耦)
并且发送事件的函数和处理事件的函数在事先就已经约定好并且作了十分周全的设计(例如通过继承接口约定函数原型)
这在后期的开发和维护中 只需要修改本脚本对应的极个别函数 就可以做到扩展或者维护
同时在多人开发中 不需要约定过多的接口 只需要约定好发送和处理事件的函数原型即可 极大方便了多人开发
密密麻麻的字自己都要看晕了 那么应该怎么通过代码实现呢?请往下看
为了防止我个人的理解偏差对大家造成影响 下面贴出我参考的清华大学出版社出版的《Unity脚本设计》中关于EventManager类的3个核心概念的介绍
- EventListener:监听器是指对象需要被告知所发生的事件,甚至是自身实践。在实际操作过程中,几乎每一个对象均可针对至少一个事件定义为监听器。例如,某一地方角色需要了解其他角色的生命值和弹药量。此时,该对象应至少针对两个独立事件定义为一个监听器。因此,无论何时对象需要被告知所发生的事件时,该对象均可定义为一个监听器。
- EventPoster:与监听器相比,当对象检测到所发生的事件后,应发送与其相关的消息,并告知全部监听器。在示例-代码4-2中(未贴出),敌方角色类通过相关属性检测Ammo和Health事件,并于随后在必要时调用内部事件。对此,对象需要在全局层次上生成相关事件。
- EventManager:最后,还需定义一个单例持久对象EventManager,并提供全局访问行为。该对象可将监听器连接至消息传递者。另外,该对象接收传递者发送的时间消息,并与随后将其以事件形式传递至相应的监听器中。
其实 有很多方法可以实现观察者模式
可以利用C#的委托来实现 Unity也提供了UnityEvent供开发者使用
而在这里我采用另外一种方法——接口实现
原因是因为这是我直接针对这个项目设计的接口和管理器
代码方面更加透明 并且针对我们的项目高度定制化 可以提高后期开发的效率
事件处理系统中的基本实体是监听器——可以向其通知特定的事件 并且他会对对应的事件作出对应的响应(可以监听多个事件)
并且 如果可能 任意对象和类都可以被定义为监听器 并且向他通知特定的事件 而通知事件是事件管理器所做的工作 这个我们稍后再讨论
在图灵杯中 我参考了其他书籍 定义了一个接口 并且全部监听器对象均继承自该接口
这就相当于我事先约定好了相应事件的函数(是所有的事件 不是特定的或者个别的事件) 以便多人开发和维护
接口定义如下
//TListener.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface TListener
{
///
/// 事件处理接口
///
/// 事件类型
/// 发送事件的游戏组件
/// 可选参数 可传递游戏中的各种对象
/// 可选参数 Dictionary类型 可传递游戏数值
bool OnEvent(EVENT_TYPE Event_Type, Component Sender, Object param = null, Dictionary value = null);
///
/// 获取当前游戏对象的引用
///
/// 当前类游戏对象的引用(只读)
Object getGameObject();
}
这样 如果我们希望这个类成为一个监听器的话 只要继承这个接口 然后在这个类里面实现对应的OnEvent函数 通过Event_Type传参就可以实现对特定事件的处理了
监听器这边处理完毕了 接下来就要处理整个流程图中最关键的东西——事件处理器
上面定义完了事件监听器 现在要定义最重要的事件处理器了
事件处理器完成这么几个重要的功能:
可以看出 事件处理器是事件驱动模式的关键
因为所有脚本都要与其进行交互 所以在Unity中 事件处理器是单例的
单例脚本可以通过绑定空的GameObject 并在初始化函数内(Awake()或Start())来判断事例是否唯一来实现
下面我们通过代码实现事件处理器
应该有的解释我已经详尽地写在注释里了 麻烦大家耐心阅读
using System.Collections;
using System.Collections.Generic;//访问附加的Mono类 同时还包括Dictionary类
using UnityEngine;
public class EventManager : MonoBehaviour
{
// 事件管理器的实例(在这里主要是实现单例访问)
private static EventManager instance = null;
private float ClearTiming; //监听清理器计时器
/*
* 事件-监听器链表 键值对
* 用来存储并管理所有的监听器
*/
private Dictionary> Listeners = new Dictionary>();
// 事件管理器的只读接口
public static EventManager Instance
{
get { return instance; }
set { }
}
// 在程序运行时调用 实现单例访问
private void Awake()
{
ClearTiming = 0;
//如果不存在事件管理器 则创建
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
//如果存在 则销毁自身
else
DestroyImmediate(this);
}
private void FixedUpdate()
{
ClearTiming += Time.deltaTime;
//事件管理器每5秒执行一次无效监听器清理
if(ClearTiming >= 5)
{
ClearTiming = 0;
RemoveRedundancies();
}
}
///
/// 注册监听器 在监听器类的Start函数调用
///
/// 监听的事件
/// 事件的监听器
public void AddListener(EVENT_TYPE Event_Type, TListener Listener)
{
List ListenList = null;
//如果当前事件的监听器不为空 则直接添加监听器至链表
if (Listeners.TryGetValue(Event_Type, out ListenList))
{
ListenList.Add(Listener);
}
//如果当前事件的监听器为空 则新建链表 添加键值对
else
{
ListenList = new List();
ListenList.Add(Listener);
Listeners.Add(Event_Type, ListenList);
}
}
///
/// 事件传递函数 ===传递事件的关键函数===
///
/// 将要被处理的事件
/// 发送事件的组件
/// 可选参数 指定响应本事件的对象
/// 可选参数 可以传递参数
public bool PostNotification(EVENT_TYPE Event_Type, Component Sender, Object param = null, Dictionary value = null)
{
List ListenList = null;
//如果事件对应的监听器为空 直接返回
if (!Listeners.TryGetValue(Event_Type, out ListenList))
{
return false;
}
//遍历事件的所有监听器的事件处理函数
for (int i = 0; i < ListenList.Count; i++)
{
//如果param不为空 则寻找特定的对象
if (param)
{
if(!ListenList[i].Equals(null) && param == ListenList[i].getGameObject())
{
//寻找到特定对象后执行操作并返回操作是否成功
return ListenList[i].OnEvent(Event_Type, Sender, param, value);
}
}
//如果没有特定对象(广播事件)则遍历事件所有的监听器
else if (!ListenList[i].Equals(null))
{
ListenList[i].OnEvent(Event_Type, Sender, param, value);//调用函数 传参
}
}
return true;
}
//注销事件
public void RemoveEvent(EVENT_TYPE Event_Type)
{
Listeners.Remove(Event_Type);
}
//删除无效的监听器
private void RemoveRedundancies()
{
Dictionary> TmpListeners = new Dictionary>();
//遍历所有事件
foreach (KeyValuePair> Item in Listeners)
{
//遍历事件的所有监听器
for (int i = Item.Value.Count - 1; i >= 0; i--)
{
//如果监听器的引用为null 则删除监听器
if (Item.Value[i].Equals(null))
Item.Value.RemoveAt(i);
}
//重构监听器链表
if (Item.Value.Count > 0)
TmpListeners.Add(Item.Key, Item.Value);
}
Listeners = TmpListeners;
}
}
上面基础的两大组件已经设计完毕 那么如何在程序中进行实际调用呢
其实到这里 框架的轮廓已经很清晰了 有经验的大佬应该已经可以开始写了
下面贴出我个人在图灵杯项目中本框架的实现方法
首先是注册监听器
方块类的监听器注册
//BoxManager.cs
public class BoxManager : MonoBehaviour, TListener {
...
...
private void Start()
{
...
...
//注册监听器 监听炸弹爆炸事件
EventManager.Instance.AddListener(EVENT_TYPE.BOMB_EXPLODE, this);
}
}
玩家类的监听器注册
//PlayerHealth.cs
public class PlayerHealth : MonoBehaviour,TListener {
...
...
private void Start()
{
...
...
//注册监听器 监听炸弹爆炸事件
EventManager.Instance.AddListener(EVENT_TYPE.BOMB_EXPLODE, this);
}
...
...
}
题外话 可跳过
我在注册炸弹的另一个监听器BOMB_SET_INFO发现 注册监听器的时机也十分重要
先贴出代码
//BombManager.cs
private void Awake()
{
/*
* 由于创建炸弹实例之后
* PlayerBomb马上会发送BOMB_SET_INFO事件
* 故监听器的注册提前到Awake阶段
*/
EventManager.Instance.AddListener(EVENT_TYPE.BOMB_SET_INFO, this); //注册监听器 监听设置信息事件
}
在这里 炸弹实例的监听器注册是在Awake函数内而不是一般的Start函数内
这是因为在创建完炸弹之后 玩家类还要对炸弹进行一些个性化的设置
所以玩家类在创建完炸弹实例之后马上会发送炸弹信息设置事件
对Awake()和Start()的本质区别 参考Unity3D脚本中的Awake()和Start()的本质区别的说法 总结下来大概是
- Awake():Awake is called when the script instance is being loaded.
- Start():Start is called on the frame when a script is enabled just before any of the Update methods is called the first time.
Awake()是在脚本对象实例化时被调用的,而Start()是在对象的第一帧时被调用的,而且是在Update()之前。
这样的话,在使用上,有几点值得注意:
- 脚本的一些成员,如果想在创建之后的代码中立即使用,则必须写在Awake()里面;
- 当关卡加载时,脚本的Awake的次序是不能控制的;至于在关卡加载时,对象实例化和Awake()的调用关系,得看源码才知道了。
所以 由于BOMB_SET_INFO事件会紧随炸弹实例创建完成到来 所以放在Awake()内注册监听器才能被EventManager所调用
否则在事件到来时 监听器并没有在EventManager内注册 从而事件就没有办法到达BombManager内
其他具体代码省略 当炸弹爆炸时发送事件
private void Explode()
{
//获取爆炸范围内的所有在Attackable层的物体(包括可炸方块和玩家)
Collider[] colliders = Physics.OverlapSphere(transform.position, BombRadius,LayerMask.GetMask("Attackable"));
foreach(Collider hit in colliders)
{
//向在Attackable层内的 在爆炸范围内的每个实例发送事件
//此处函数内的hit传参指定了要相应本事件的实例(发送特定消息)
EventManager.Instance.PostNotification(EVENT_TYPE.BOMB_EXPLODE, this, hit, new object[] { BombOwner });
}
Destroy(gameObject, 0.5f);//延迟销毁 播放动画
}
在EventManager的PostNotification内 将会搜索对应的事件监听器列表 并且将事件转发给列表内所有的监听器
假设现在事件BOMB_EXPLODE已经发生并且已经传递至监听器 那么监听器应该做出什么相应呢?
查阅PostNotification可以发现 监听器内的OnEvent函数 也就是我们之前事先用接口约定的函数 会被调用
这里就是我们应该定制的地方
方块类的OnEvent函数
public void OnEvent(EVENT_TYPE Event_Type, Component Sender, Object param = null, object[] value = null)
{
switch (Event_Type) //可以接收多种事件
{
case (EVENT_TYPE.BOMB_EXPLODE): //指定事件发生时
if (param == gameObject) //当指定对象等于自身时
InExplode(); //执行特定的函数
break;
default: break;
}
}
玩家类的OnEvent亦同 就不贴出了
总的来说 只要静下心来 会发现事件驱动模式是很简单的
但是一定要静下心来细读代码
才能懂得这个模式真正精髓的地方
除此之外
类似于OnEvent的参数 和EventManager内的函数均可以高度定制化
以满足特定程序的需求
最后希望和大家共同讨论和学习 如有纰漏 望请耐心指教
------------------2018年8月6日 17:57:43更新------------------
通过在EventManager的FixedUpdate()中定期调用RemoveRedundancies()可以及时清理已经被销毁的监听器
这样可以有效防止空指针的存在
//EventManager.cs
public class EventManager : MonoBehaviour
{
...
...
private float ClearTiming; //监听清理器计时器
...
...
private void FixedUpdate()
{
ClearTiming += Time.deltaTime;
//事件管理器每5秒执行一次无效监听器清理
if(ClearTiming >= 5)
{
ClearTiming = 0;
RemoveRedundancies();
}
}
...
...
}
------------------2018年8月7日 18:09:38更新------------------
针对我们的项目 我对EventManager和TListener Interface进行了一些优化 代码也已经更新
首先修改OnEvent函数为:bool OnEvent(EVENT_TYPE Event_Type, Component Sender, Object param = null, Dictionary
其中最大的修改就是讲最后一个可选参数从object[] value = null修改成Dictionary
之前采用object[]函数传值时 无法统一传参在数组中的位置
造成了开发上的困难
Dictionary是一个类似于C++的std::map Java的Map的一个键值对 通过键(Key)来获取指向数值(Value)的引用
这样的话 只要事先规定好传参的关键字 就可以直接通过关键字获取传值 不用考虑所需要的值在数组中的位置
但是这样会带来一个问题
在调用PostNotification之前 必须用new新建一个Dictionary
然后使用Dictionary.Add()进行添加键值对
所以所需要的内存空间可能会增加 并且调用速度会有所下降
所以在使用完Dictionary之后要及时调用Dictionary.Clear()进行清理
参考资料:
《Unity脚本设计》 作者:[美]Alan Thorn 著 刘君 译 清华大学出版社 ISBN:9787302453987
https://blog.csdn.net/jlsgkid/article/details/72728349
https://blog.csdn.net/ylbs110/article/details/53953880
https://blog.csdn.net/w1095471150/article/details/53130190
https://www.cnblogs.com/jeason1997/p/4818777.html
http://tieba.baidu.com/p/4432168857
2018年8月9日 21:59:06
陈思聪