导入

介绍

总体大纲

框架的必要性

提高项目的编程效率,维护性和迭代性。再更符合人类思维的同时,减少代码的复用。

一旦拥有了一套可行的框架,就可以在未来的开发中,针对不同的游戏类型,通过对框架不断的迭代,随后通过对框架的运用,在其基础上高效的搭建出一个游戏。

框架的意义

框架

基本模块

单例模式基类模块

用途

Unity我们经常需要用到单例模式,有时候是不继承mono的单例,有时候是继承mono的单例,而继承mono的单例又分为事先挂载到场景上的脚本和后面创建出来的单例脚本。
所以我们不妨为此设计出3个单例脚本供单例对象继承,节约大量的代码。

思维导图

代码

共有三种单例模块脚本可供不同场合的单例脚本进行继承使用:

BaseManager.cs(不继承Mono的单例脚本):

//1.C#中 泛型的知识
//2.设计模式中 单例模式的知识
public class BaseManager<T> where T:new()
{
    private static T instance;

    public static T GetInstance()
    {
        if (instance == null)
            instance = new T();
        return instance;
    }
}

SingletonMono.cs(提前挂载到场景上的Mono单例脚本):

//C#中 泛型知识点
//设计模式 单例模式的知识点
//继承了 MonoBehaviour 的 单例模式对象 需要我们自己保证它的唯一性
public class SingletonMono<T> : MonoBehaviour where T: MonoBehaviour
{
    private static T instance;

    public static T GetInstance()
    {
        //继承了Mono的脚本 不能够直接new
        //只能通过拖动到对象上 或者 通过 加脚本的api AddComponent去加脚本
        //U3D内部帮助我们实例化它
        return instance;
    }

    protected virtual void Awake()
    {
        instance = this as T;
    }
	
}

SingletonAutoMono.cs(能自动创建Gameobject的mono单例脚本):

//C#中 泛型知识点
//设计模式 单例模式的知识点
//继承这种自动创建的 单例模式基类 不需要我们手动去拖 或者 api去加了
//想用他 直接 GetInstance就行了
public class SingletonAutoMono<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T instance;

    public static T GetInstance()
    {
        if( instance == null )
        {
            GameObject obj = new GameObject();
            //设置对象的名字为脚本名
            obj.name = typeof(T).ToString();
            //让这个单例模式对象 过场景 不移除
            //因为 单例模式对象 往往 是存在整个程序生命周期中的
            DontDestroyOnLoad(obj);
            instance = obj.AddComponent<T>();
        }
        return instance;
    }

}

总结

1.由于Unity的Mono脚本可以在编辑器时期随意挂载多个,所以使用SingletonMono的时候要保证SingletonMono的唯一性,否则脚本在Awake的时候将随机选择一个脚本,也将失去单例模式的意义。

2.相比SingletonMono,SingletonAutoMono在很多时候是一个更好的单例模式方案,它可以在需要的时候才被创建出来,也从代码的层面上就保证了脚本的唯一性。
不过,SingletonMono在需要挂载到某些预制体初始化有关的单例对象的情况下,使用它会更加的方便。

所以,一切方案都必须结合需求来使用,不要本末倒置。

缓存池模块基础

用途

游戏中,我们经常会产生大量可以复用的资源——如子弹特效一类的物品。这些特效大量的进行创建和销毁的时候会大量消耗性能。
而缓冲池创建后,会把这些原本要被销毁的物品失活后集中起来,等到需要再次使用的时候再激活取出来再次使用,以此降低大量的性能消耗。

思维导图

代码

本模块分为

池子容器类PoolData(可比喻为抽屉):用来处理不同分组缓存对象所在的数组以及管理缓存对象激活和失活时父对象的更改,主要供PoolMgr进行使用。

/// <summary>
/// 抽屉数据  池子中的一列容器
/// </summary>
public class PoolData
{
    //抽屉中 对象挂载的父节点
    public GameObject fatherObj;
    //对象的容器
    public List<GameObject> poolList;

    public PoolData(GameObject obj, GameObject poolObj)
    {
        //给我们的抽屉 创建一个父对象 并且把他作为我们pool(衣柜)对象的子物体
        fatherObj = new GameObject(obj.name);
        fatherObj.transform.parent = poolObj.transform;
        poolList = new List<GameObject>() {};
        PushObj(obj);
    }

    /// <summary>
    /// 往抽屉里面 压都东西
    /// </summary>
    /// <param name="obj"></param>
    public void PushObj(GameObject obj)
    {
        //失活 让其隐藏
        obj.SetActive(false);
        //存起来
        poolList.Add(obj);
        //设置父对象
        obj.transform.parent = fatherObj.transform;
    }

    /// <summary>
    /// 从抽屉里面 取东西
    /// </summary>
    /// <returns></returns>
    public GameObject GetObj()
    {
        GameObject obj = null;
        //取出第一个
        obj = poolList[0];
        poolList.RemoveAt(0);
        //激活 让其显示
        obj.SetActive(true);
        //断开了父子关系
        obj.transform.parent = null;

        return obj;
    }
}

池子管理类PoolMgr(可比喻为衣柜):面向外部提供缓冲池方法。

/// <summary>
/// 缓存池模块
/// 1.Dictionary List
/// 2.GameObject 和 Resources 两个公共类中的 API 
/// </summary>
public class PoolMgr : BaseManager<PoolMgr>
{
    //缓存池容器 (衣柜)
    public Dictionary<string, PoolData> poolDic = new Dictionary<string, PoolData>();

    private GameObject poolObj;

   /// <summary>
   /// 往外拿东西
   /// </summary>
   /// <param name="name">缓存对象名字(本质上是Resources地址)</param>
   /// <param name="callBack">可以通过传入回调函数对提取的缓存对象进行处理</param>
    public void GetObj(string name, UnityAction<GameObject> callBack)
    {
        //有抽屉 并且抽屉里有东西
        if (poolDic.ContainsKey(name) && poolDic[name].poolList.Count > 0)
        {
            callBack(poolDic[name].GetObj());
        }
        else
        {
            //通过异步加载资源 创建对象给外部用
            ResMgr.GetInstance().LoadAsync<GameObject>(name, (o) =>
            {
                o.name = name;
                callBack(o);
            });

            //obj = GameObject.Instantiate(Resources.Load<GameObject>(name));
            //把对象名字改的和池子名字一样
            //obj.name = name;
        }
    }

  /// <summary>
  /// 换暂时不用的东西给我
  /// </summary>
  /// <param name="name"></param>
  /// <param name="obj"></param>
    public void PushObj(string name, GameObject obj)
    {
        if (poolObj == null)
            poolObj = new GameObject("Pool");

        //里面有抽屉
        if (poolDic.ContainsKey(name))
        {
            poolDic[name].PushObj(obj);
        }
        //里面没有抽屉
        else
        {
            poolDic.Add(name, new PoolData(obj, poolObj));
        }
    }


    /// <summary>
    /// 清空缓存池的方法 
    /// 主要用在 场景切换时
    /// </summary>
    public void Clear()
    {
        poolDic.Clear();
        poolObj = null;
    }
}

总结

1.最终效果:场上创建的物体不需要时不再直接被销毁,而是被失活暂存于缓存池pool中指定的一类物体名下
在Pool池子中,第一层子类用来标记这堆物体名字叫什么,第二层子类才是真正存储缓存对象的存在
当需要再次调用时,如果缓存池有相应的待用对象,就从池子里面取,没有才创建新的对象。

虽然这会增加一定的内存占用,但是可以减少卡顿的同时让我们更好的观察每一类物体对象的调用情况

图示:

2.当切换场景的时候,记得使用Clear方法对缓存池进行清空,以防止由于引用地址建立着联系而导致不必要的bug出现。
关于这点,你需要明白,切换场景的时候虽然会销毁场景上的物体,但是内存并不一定被清空,GC还没有进行,例如上文的代码中,切换场景后你原来的poolObj和pooldic里面的联系都还是存在的,你不清空依然进行调用那肯定会报错,所以你需要手动切断联系。

3.注意:本文在PoolMgr中的异步加载ResMgr类有关内容,是下文资源加载模块那一节的内容,请结合起来看。而被注释掉的代码是尚未采取异步加载优化前的代码。
优化后可以从外部对对象进行的二次处理,更加具有实用性。

事件中心模块

用途

用来实现Unity中的事件管理:
完成了一个事件后,其他与此有关的对象都会发生更改,传统的写法是完成后在其中一个脚本里面调用其他脚本,事件中心就可以把这些脚本创建的时候分别向事件中心挂载上函数,然后在发生事件后统一执行。

思维导图

代码

前置
一.使用接口来存储不同类型的委托"子类"
以适应带参数的事件和不带参数的事件:

public interface IEventInfo  
{  
  
}  
  
public class EventInfo<T> : IEventInfo  
{  
  public UnityAction<T> actions;  
  
  public EventInfo( UnityAction<T> action)  
 {  actions += action;  
 }}  
  
public class EventInfo : IEventInfo  
{  
  public UnityAction actions;  
  
  public EventInfo(UnityAction action)  
 {  actions += action;  
 }}  

二.使用枚举来装载不同的事件名,这样就可以在调用的时候出现提示方便使用。
而且也方便我们的汇总。需要新的事件名的时候往里面加即可。

/// <summary>  
/// 事件类型 只要新加一种事件类型 就在枚举中添加 这样比直接用string安全一些也可以避免重复事件  
/// </summary>  
public enum E_EventType  
{  
  //怪物死亡  
  Event_Monster_Dead,  
  //玩家死亡  
  Event_Player_Dead,  
  //场景加载进度  
  Event_LoadScene_Progress,  
  //输入有关事件  
  Event_Keycode_Input,  
  Event_Mouse_Input,  
  Event_MouseX_Input,  
  Event_MouseY_Input,  
  Event_Horizontal_Input,  
  Event_Vertical_Input  
}

事件中心EventCenter :


/// <summary>
/// 事件中心 单例模式对象
/// 1.Dictionary
/// 2.委托
/// 3.观察者设计模式
/// 4.泛型
/// </summary>
public class EventCenter : BaseManager<EventCenter>
{
    //key —— 事件的名字(比如:怪物死亡,玩家死亡,通关 等等)
    //value —— 对应的是 监听这个事件 对应的委托函数们
    private Dictionary<E_EventType, IEventInfo> eventDic = new Dictionary<E_EventType, IEventInfo>();

    /// <summary>
    /// 添加事件监听
    /// </summary>
    /// <param name="name">事件的名字</param>
    /// <param name="action">准备用来处理事件 的委托函数</param>
    public void AddEventListener<T>(E_EventType name, UnityAction<T> action)
    {
        //有没有对应的事件监听
        //有的情况
        if( eventDic.ContainsKey(name) )
        {
            (eventDic[name] as EventInfo<T>).actions += action;
        }
        //没有的情况
        else
        {
            eventDic.Add(name, new EventInfo<T>( action ));
        }
    }

    /// <summary>
    /// 监听不需要参数传递的事件
    /// </summary>
    /// <param name="name"></param>
    /// <param name="action"></param>
    public void AddEventListener(E_EventType name, UnityAction action)
    {
        //有没有对应的事件监听
        //有的情况
        if (eventDic.ContainsKey(name))
        {
            (eventDic[name] as EventInfo).actions += action;
        }
        //没有的情况
        else
        {
            eventDic.Add(name, new EventInfo(action));
        }
    }


    /// <summary>
    /// 移除对应的事件监听
    /// </summary>
    /// <param name="name">事件的名字</param>
    /// <param name="action">对应之前添加的委托函数</param>
    public void RemoveEventListener<T>(E_EventType name, UnityAction<T> action)
    {
        if (eventDic.ContainsKey(name))
            (eventDic[name] as EventInfo<T>).actions -= action;
    }

    /// <summary>
    /// 移除不需要参数的事件
    /// </summary>
    /// <param name="name"></param>
    /// <param name="action"></param>
    public void RemoveEventListener(E_EventType name, UnityAction action)
    {
        if (eventDic.ContainsKey(name))
            (eventDic[name] as EventInfo).actions -= action;
    }

    /// <summary>
    /// 事件触发
    /// </summary>
    /// <param name="name">哪一个名字的事件触发了</param>
    public void EventTrigger<T>(E_EventType name, T info)
    {
        //有没有对应的事件监听
        //有的情况
        if (eventDic.ContainsKey(name))
        {
            //eventDic[name]();
            if((eventDic[name] as EventInfo<T>).actions != null)
                (eventDic[name] as EventInfo<T>).actions.Invoke(info);
            //eventDic[name].Invoke(info);
        }
    }

    /// <summary>
    /// 事件触发(不需要参数的)
    /// </summary>
    /// <param name="name"></param>
    public void EventTrigger(E_EventType name)
    {
        //有没有对应的事件监听
        //有的情况
        if (eventDic.ContainsKey(name))
        {
            //eventDic[name]();
            if ((eventDic[name] as EventInfo).actions != null)
                (eventDic[name] as EventInfo).actions.Invoke();
            //eventDic[name].Invoke(info);
        }
    }

    /// <summary>
    /// 清空事件中心
    /// 主要用在 场景切换时
    /// </summary>
    public void Clear()
    {
        eventDic.Clear();
    }
}
       

总结

1.使用:可分为两步,以某个怪物死亡为例:

一.所有关系到此怪物死亡的对象,先在脚本里面设计好对应的函数(不要用匿名函数,会导致无法移除,而且不方便维护),然后使用EventCenter.GetInstant.AddEventListener()方法挂载上对应的脚本。

二.当怪物死亡的时候,调用EventCenter.GetInstant.EventTrigger()方法,即可触发所有挂载过这个脚本的对象的函数,实现而牵一发动全身。

如果在以前,我们会选择在怪物死亡的时候,写一大堆代码,首先去获得有关系的脚本,然后再对这些个脚本一个一个的进行改动。
而通过事件中心,我们用观察者模式实现了一种牵一发而动全身的效果,让维护和方便程度直接上了一个档次。


2.我们采取接口来装载委托,是为了应对不同参数的委托对象需求,如果有加不同参数数量的参数委托需求,在前置代码里面多写几个新的继承接口的类即可。当然,你也可以选择不加类,直接用object的装箱拆箱解决问题。


3.潜在的问题:由于我们彻底解耦了不同事件的联系,这就需要我们自己手动保证不同的联系对象传入的事件参数完全一致,否则触发的时候会报错,然而由于我们没有编译器提醒,所以这方面上需要多加注意。


4.框架可选的代码修改方向:
我们可以把事件字典的eventDic的Value设置为List< Object >,好处是不需要针对接口对多个参数实现多个方法,只需要拆箱封箱就可以应对所有情况,坏处就是性能损耗较大。


5.注意
和缓存池一样,切换场景的时候,不要忘记clear一遍以防止报错。

公共Mono模块

用途

Unity中,经常会碰到没有继承mono的类想要实现协程,延时函数或者update等事件函数的情况,而我们可以通过提供一个公共的Mono模块来允许所有的类通过传递给此模块来应对这种需求。

思维导图

代码

MonoControl (Mono模块执行类):
用来创建场景上唯一的Mono模块执行类,用来被传入需要执行的mono方法并进行执行。

using UnityEngine;
using System;
/// <summary>
/// Mono控制基类 主要用于给没有继承Mono的对象提供 
/// 开启协程
/// 延迟函数
/// 帧更新
/// 等等
/// </summary>
public class MonoControl : MonoBehaviour
{
    /// <summary>
    /// 帧更新事件 提供给没有继承mono对象能够帧更新的事件
    /// 也可以减少Update分布在不同Mono中的数量 统一在此处管理
    /// </summary>
    [HideInInspector]
    public event Action eventUpdate;

    void Awake()
    {
        //过场景不移除
        DontDestroyOnLoad(this.gameObject);
    }

    void Update()
    {
        if (eventUpdate != null)
            eventUpdate();
    }

}

GlobalMonoMgr.cs(mono模块管理类):
用来创建MonoControl 对象,并提供外部接口来获取外部方法传入MonoControl 对象。

using UnityEngine;
using System.Collections;
using System.ComponentModel;
using System;

/// <summary>
/// 公共Mono控制对象 管理器 用于统一处理延迟触发和协程 等
/// </summary>
public class GlobalMonoMgr : BaseManager<GlobalMonoMgr>
{
    //场景中唯一一个的MonoControl对象 
    private MonoControl _monoControl = null;
    public GlobalMonoMgr()
    {
        //为空 则新建一个空对象 该空对象 将存在于游戏程序的整个生命周期
        //造就一个至始至终都不会销毁的对象 并且是动态创建的
        if (_monoControl == null)
        {
            GameObject obj = new GameObject();
            obj.name = "MONO_MAIN";
            _monoControl = obj.AddComponent<MonoControl>();
        }
    }

    /// <summary>
    /// 获取Mono管理对象 可以再外部往上挂载需要一直存在的脚本
    /// </summary>
    public MonoControl componentControl
    {
        get
        {
            return _monoControl;
        }
    }

    /// <summary>
    /// 添加update帧更新事件
    /// </summary>
    /// <param name="function"></param>
    public void AddUpdateListener(Action function)
    {
        _monoControl.eventUpdate += function;
    }

    /// <summary>
    /// 移除update帧更新时间
    /// </summary>
    /// <param name="function"></param>
    public void RemoveUpdateListener(Action function)
    {
        _monoControl.eventUpdate -= function;
    }

    #region 以下为封装 协程 延迟相关的接口
    public Coroutine StartCoroutine(IEnumerator routine)
    {
        return _monoControl.StartCoroutine(routine);
    }

    public Coroutine StartCoroutine(string methodName)
    {
        return _monoControl.StartCoroutine(methodName);
    }

    public Coroutine StartCoroutine(string methodName, [DefaultValue("null")] object value)
    {
        return _monoControl.StartCoroutine(methodName, value);
    }

    public void StopAllCoroutines()
    {
        _monoControl.StopAllCoroutines();
    }

    public void StopCoroutine(string methodName)
    {
        _monoControl.StopCoroutine(methodName);
    }

    public void StopCoroutine(IEnumerator routine)
    {
        _monoControl.StopCoroutine(routine);
    }

    public void StopCoroutine(Coroutine routine)
    {
        _monoControl.StopCoroutine(routine);
    }

    public void CancelInvoke()
    {
        _monoControl.CancelInvoke();
    }

    public void CancelInvoke(string methodName)
    {
        _monoControl.CancelInvoke(methodName);
    }

    public void Invoke(string methodName, float time)
    {
        _monoControl.Invoke(methodName, time);
    }

    public void InvokeRepeating(string methodName, float time, float repeatRate)
    {
        _monoControl.InvokeRepeating(methodName, time, repeatRate);
    }

    public bool IsInvoking()
    {
        return _monoControl.IsInvoking();
    }

    public bool IsInvoking(string methodName)
    {
        return _monoControl.IsInvoking(methodName);
    }
    #endregion
}

总结

拥有了mono模块,我们就可以在任何类上实现mono类的所有功能了。
甚至,我们可以令所有的mono脚本共用mono模块的update,可以减少反射的次数,提高允许效率。

场景切换模块

用途

在实际游戏开发中,我们经常需要频繁的切换场景,并且在切换场景后运用数据文件对新场景进行加载。
为此,我们不妨开发一个场景切换模块,结合事件管理模块在里面实现异步加载,来实现统一化的场景加载。

思维导图

代码

BaseManager(场景切换管理器):

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

/// <summary>
/// 场景切换管理器
/// </summary>
public class SceneMgr : BaseManager<SceneMgr>
{
    /// <summary>
    /// 提供给外部加载场景的方法
    /// </summary>
    /// <param name="name">场景名</param>
    /// <param name="loadOverDo">加载完成后做啥</param>
    public void LoadScene(string name, Action loadOverDo)
    {
        //开启协程加载
        GlobalMonoMgr.Instance.StartCoroutine(LoadSceneAsync(name, loadOverDo));
    }

    /// <summary>
    /// 协程异步加载场景
    /// </summary>
    /// <param name="name"></param>
    /// <param name="loadOverDo"></param>
    /// <returns></returns>
 private  IEnumerator LoadSceneAsync(string name, Action loadOverDo)
    {
        //异步加载场景
        AsyncOperation asy = SceneManager.LoadSceneAsync(name);
        //循环更新进度
        while(asy.progress < 1)
        {
            //事件中心分发 进度数据
          EventCenter.GetInstance().EventTrigger(E_EventType.Event_LoadScene_Progress, ao.progress);
            yield return asy;
        }
        //加载完成后 再分发一次
        EventCenter.Instance.EventTrigger("更新进度条事件名", 1);
        //等一帧 主要为界面更新提供时机
        yield return 1;
        //加载完成后做啥
        loadOverDo();
    }
}

总结

1.在代码的场景切换异步加载里面,我们还顺便为进度进度条加载留了一个口子——只需要令进度条UI在事件管理器里面预先挂载进度条事件,就可以方便的实现进度条的UI变化。

资源加载模块

用途

在Unity中,我们经常需要频繁的调用Resources里面的资源,如果涉及到大量资源甚至会需要异步加载(写起来可比同步加载麻烦多了),为此,我们不妨涉及一个资源加载模块来统一进行加载的API调用。

思维导图

代码

ResMgr(资源加载管理类):

/// <summary>
/// 资源加载模块
/// 1.异步加载
/// 2.委托和 lambda表达式
/// 3.协程
/// 4.泛型
/// </summary>
public class ResMgr : BaseManager<ResMgr>
{
    //同步加载资源
    public T Load<T>(string name) where T:Object
    {
        T res = Resources.Load<T>(name);
        //如果对象是一个GameObject类型的 我把他实例化后 再返回出去 外部 直接使用即可
        if (res is GameObject)
            return GameObject.Instantiate(res);
        else//例如TextAsset AudioClip类型,不需要实例化
            return res;
    }


    //异步加载资源
    public void LoadAsync<T>(string name, UnityAction<T> callback) where T:Object
    {
        //开启异步加载的协程
        MonoMgr.GetInstance().StartCoroutine(ReallyLoadAsync(name, callback));
    }

    //真正的协同程序函数  用于 开启异步加载对应的资源
    private IEnumerator ReallyLoadAsync<T>(string name, UnityAction<T> callback) where T : Object
    {
        ResourceRequest r = Resources.LoadAsync<T>(name);
        yield return r;

        if (r.asset is GameObject)
            callback(GameObject.Instantiate(r.asset) as T);
        else
            callback(r.asset as T);
    }


}

总结

1.同步加载资源的时候,可以直接获得资源的返回值。
但是异步加载资源不行,协程的返回值只能是IEnumerator ,这导致我们无法直接获得资源对象本身,所以我们采取传入回调函数的方法来对生成的资源进行处理。
建议调用的时候配合lambda表达式使用。


2.可以优化的方向:
我们可以为该类加上字典来充当资源容器,用来记录每一次获取的资源,之后我们重复调用的时候就可以直接使用字典里面的内容,而不是再通过resources去加载。
当然,资源容器是有缺点的:

优点:避免重复加载,提升加载效率
缺点:内存占用,需要自己合理掌握释放时机

输入控制模块

用途

在游戏中,我们经常碰到需要控制玩家操作的情况——有时候是阻止玩家在过场的时候操作,有时候是涉及到玩家控制不同角色的问题。

初学者通常喜欢把游戏的控制脚本直接挂载到要控制的人物之上——这当然是一种直观方便的方法。但是,我们仔细想想,这其实有四个问题:
1.玩家的操作端实际上是唯一的。
2.总所周知,有的游戏允许玩家操控多种不同的人物,如果涉及到切换角色的时候呢,在新角色上重新挂脚本?是不是有点太浪费代码量了?
3.如果只是挂个控制有关的脚本还好(然而实际上控制脚本常常代码量巨大),如果涉及到改键呢?
4.大量的控制脚本分布在不同的对象身上,不方便进行统一管理。

综上所述,控制代码经常有极大的复用和解耦冗余。

因此,为了节约代码量提高复用,为了解耦玩家操作来更方便的实现改键。我们不妨设计一个全局性的输入控制模块。

思维导图

代码

前置:
用来当作参数传递按键信息的枚举

/// <summary>  
/// 键盘按键输入类型  
/// </summary>  
public enum E_KeyCode_Type  
{  
  Up_D,//上按下  
  Down_D,//下按下  
  Left_D,//左按下  
  Right_D,//右按下  
  
  Up_U,//上抬起  
  Down_U,//下抬起  
  Left_U,//左抬起  
  Right_U,//右抬起  
}  
  
/// <summary>  
/// 鼠标输入类型  
/// </summary>  
public enum E_Mouse_Type  
{  
  Left,//左键  
  Left_D,//左键按下  
  Left_U,//左键抬起  
  
  Right,//右键  
  Right_D,//右键按下  
  Right_U,//右键抬起  
  
  Mid,//中键  
  Mid_D,//中键按下  
  Mid_U,//中键抬起  
}

InputMgr(输入控制模块):

/// <summary>
/// 输入管理器 主要作用是 统一管理输入相关 通过事件中心向外发放
/// 好处:
/// 1.如有多处需要检测输入 不需要频繁些Input检测相关代码
/// 2.哪里用哪里监听事件即可
/// 3.可以统一管理输入检测的开启与关闭
/// </summary>
public class inputMgr : BaseManager<inputMgr>
{
    private bool isStart = false;
       
    /// <summary>
    /// 开启输入检测 
    /// </summary>
    public void Start()
    {
        //由于InputMgr没有继承自mono 所以需要通过公共Mono来进行帧检测
        if(!isStart)
            MonoMgr.GetInstance().AddUpdateListener(CheckInput);
        isStart = true;
    }

    /// <summary>
    /// 关闭输入检测
    /// </summary>
    public void Stop()
    {
        if(isStart)
            MonoMgr.GetInstance().RemoveUpdateListener(CheckInput);
        isStart = false;
    }

    /// <summary>
    /// 每帧检测输入
    /// </summary>
    public void CheckInput()
    {
        //W键和键盘上键输入
        if( Input.GetKeyDown(KeyCode.W) ||
            Input.GetKeyDown(KeyCode.UpArrow))
            //注意,这里所有的EventTrigger方法都调用的是带参数的泛型方法
            //但是C#编译器会自动帮我们识别,所以我们省略了
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Up_D);
        if (Input.GetKeyUp(KeyCode.W) ||
            Input.GetKeyUp(KeyCode.UpArrow))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Up_U);

        //S键和键盘下键输入
        if (Input.GetKeyDown(KeyCode.S) ||
            Input.GetKeyDown(KeyCode.DownArrow))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Down_D);
        if (Input.GetKeyUp(KeyCode.S) ||
           Input.GetKeyUp(KeyCode.DownArrow))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Down_U);

        //A键和键盘左键输入
        if (Input.GetKeyDown(KeyCode.A) ||
            Input.GetKeyDown(KeyCode.LeftArrow))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Left_D);
        if (Input.GetKeyUp(KeyCode.A) ||
            Input.GetKeyUp(KeyCode.LeftArrow))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Left_U);

        //D键和键盘右键输入
        if (Input.GetKeyDown(KeyCode.D) ||
            Input.GetKeyDown(KeyCode.RightArrow))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Right_D);
        if (Input.GetKeyUp(KeyCode.D) ||
            Input.GetKeyUp(KeyCode.RightArrow))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Keycode_Input, E_KeyCode_Type.Right_U);

        //鼠标左键输入
        if( Input.GetMouseButton(0) )
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Left);
        //鼠标左键按下
        if (Input.GetMouseButtonDown(0))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Left_D);
        //鼠标左键抬起
        if (Input.GetMouseButtonUp(0))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Left_U);

        //鼠标右键输入
        if (Input.GetMouseButton(1))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Right);
        //鼠标右键按下
        if (Input.GetMouseButtonDown(1))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Right_D);
        //鼠标右键抬起
        if (Input.GetMouseButtonUp(1))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Right_U);

        //鼠标中键输入
        if (Input.GetMouseButton(2))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Mid);
        //鼠标中键按下
        if (Input.GetMouseButtonDown(2))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Mid_D);
        //鼠标中键抬起
        if (Input.GetMouseButtonUp(2))
            EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Mouse_Input, E_Mouse_Type.Mid_U);


        //鼠标移动热键检测
        EventCenter.GetInstance(). EventTrigger(E_EventType.Event_MouseX_Input, Input.GetAxis("Mouse X"));
        EventCenter.GetInstance(). EventTrigger(E_EventType.Event_MouseY_Input, Input.GetAxis("Mouse Y"));

        //键盘移动热键检测
        EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Horizontal_Input, Input.GetAxis("Horizontal"));
        EventCenter.GetInstance(). EventTrigger(E_EventType.Event_Vertical_Input, Input.GetAxis("Vertical"));
    }
}

总结

1.模块构建的思路:
需要用到mono模块和事件模块。

一.设置按键信息枚举,用来当作参数供事件中心触发使用。

二.建立不继承mono的单例IputMgr,用来管理按键输入。

三.创建start和stop方法,用来控制是否允许玩家操作按键,代码表现为是否将CheckInput方法加入到mono的委托中由update执行或者进行移除。

四.创建CheckInput方法,这个方法就是玩家按键逻辑的控制中心。可以用来调控玩家允许操作哪些按键,按下按键后触发对应的事件中心逻辑。


2.用法:

在需要关心玩家输入的脚本上:

//允许开启控制
inputMgr.GetInstance().Start();  
//对应按键挂载
EventCenter.GetInstance().AddEventListener<E_KeyCode_Type>(E_EventType.Event_Keycode_Input, 
(keycode) => { Debug.Log("玩家按下了这个按键"+keycode);}
);  
}
										  );

keycode参数将对应的按键信息传递进来,方便识别到底是哪个按键。
这样,即可在玩家按下按键的时候触发对应方法。


3.上文的CheckInput方法里面所有的EventTrigger方法都调用的是带参数的泛型方法,但是强大的C#编译器会自动帮我们识别,所以我们省略了。


4.可以优化的方向:
实现改键系统:
1.把CheckInput里面if判断里面的确定性按键判断删除,只留下Unity为我们提供的系统按键(如Input.GetKeyDown(KeyCode.UpArrow))
由于Unity本来就给系统按键提供了切换按键的API,这样就可以解耦,通过修改系统按键,直接达到改键的目标。

2.自定义字典,key为你指定的游戏按键,value为玩家指定的实际游戏按键,然后自己设定按键字典的初始化和读取,把if判断该为查阅字典的key,即可得到玩家指定的按键。

音效管理模块

用途

Unity中,我们经常性的需要创建和使用大量的背景音乐和音效。
甚至音效有关的内容,我们还进程性的需要在播放完毕后手动摧毁组件以节约内存(Unity不会自动销毁掉)
为此,我们不妨设置一个音效管理模块。

思维导图

代码

MusicMgr(音效管理模块)

public class MusicMgr : BaseManager<MusicMgr>
{
    //唯一的背景音乐组件
    private AudioSource bkMusic = null;
    //音乐大小
    private float bkValue = 1;

    //音效依附对象
    private GameObject soundObj = null;
    //音效列表
    private List<AudioSource> soundList = new List<AudioSource>();
    //音效大小
    private float soundValue = 1;

    public MusicMgr()
    {
        MonoMgr.GetInstance().AddUpdateListener(Update);
    }

    private void Update()
    {
        for( int i = soundList.Count - 1; i >=0; --i )
        {
            if(!soundList[i].isPlaying)
            {
                GameObject.Destroy(soundList[i]);
                soundList.RemoveAt(i);
            }
        }
    }

    /// <summary>
    /// 播放背景音乐
    /// </summary>
    /// <param name="name"></param>
    public void PlayBkMusic(string name)
    {
        if(bkMusic == null)
        {
            GameObject obj = new GameObject();
            obj.name = "BkMusic";
            bkMusic = obj.AddComponent<AudioSource>();
        }
        //异步加载背景音乐 加载完成后 播放
        ResMgr.GetInstance().LoadAsync<AudioClip>("Music/BK/" + name, (clip) =>
        {
            bkMusic.clip = clip;
            bkMusic.loop = true;
            bkMusic.volume = bkValue;
            bkMusic.Play();
        });

    }

    /// <summary>
    /// 暂停背景音乐
    /// </summary>
    public void PauseBKMusic()
    {
        if (bkMusic == null)
            return;
        bkMusic.Pause();
    }

    /// <summary>
    /// 停止背景音乐
    /// </summary>
    public void StopBKMusic()
    {
        if (bkMusic == null)
            return;
        bkMusic.Stop();
    }

    /// <summary>
    /// 改变背景音乐 音量大小
    /// </summary>
    /// <param name="v"></param>
    public void ChangeBKValue(float v)
    {
        bkValue = v;
        if (bkMusic == null)
            return;
        bkMusic.volume = bkValue;
    }

    /// <summary>
    /// 播放音效
    /// </summary>
    public void PlaySound(string name, bool isLoop, UnityAction<AudioSource> callBack = null)
    {
        if(soundObj == null)
        {
            soundObj = new GameObject();
            soundObj.name = "Sound";
        }
        //当音效资源异步加载结束后 再添加一个音效
        ResMgr.GetInstance().LoadAsync<AudioClip>("Music/Sound/" + name, (clip) =>
        {
            AudioSource source = soundObj.AddComponent<AudioSource>();
            source.clip = clip;
            source.loop = isLoop;
            source.volume = soundValue;
            source.Play();
            soundList.Add(source);
            if(callBack != null)
                callBack(source);
        });
    }

    /// <summary>
    /// 改变音效声音大小
    /// </summary>
    /// <param name="value"></param>
    public void ChangeSoundValue( float value )
    {
        soundValue = value;
        for (int i = 0; i < soundList.Count; ++i)
            soundList[i].volume = value;
    }

    /// <summary>
    /// 停止音效
    /// </summary>
    public void StopSound(AudioSource source)
    {
        if( soundList.Contains(source) )
        {
            soundList.Remove(source);
            source.Stop();
            GameObject.Destroy(source);
        }
    }
}

总结

1.由于场景里经常有大量的音效,我们需要时刻监控音效完毕后进行摧毁以节省内存。为此,我们设置Update方法监控音效数组并交给Mono管理器进行实时监控。


2.可以优化的方向:
一.由于音效需要经常性的大量创建和销毁,我们不妨将其处理为缓冲池的方式进行读取和销毁。

二.在某些3D游戏中,声音是3D音,会随着接收声音的组件和发出声音的组件的距离发生动态变化。如果有这个需求,可以为创建音效的方法加上gameobject对象参数,用来选定挂载的组件而不是一个固定的挂载对象。并且可以专门写几个方法用来修改接收声音的组件的位置。

UI模块

导入

思维导图

总结

本大节将制作出一个基于UGUI的UI模块,用来方便我们对UGUI的使用。
需要强调的是,在我的UGUI文章中的实战总结里面(Unity之UGUI概述 - 张先生的小屋 (klned.com)),我已经创建过一个UI管理模块。

这两个模块的功能和思路有一部分相同也有很大一部分的不同。我会在下文里文指出,请结合我上文挂出的文章进行比较和学习

UI管理模块——UI基类

用途

用来供面板UI脚本继承的父类,里面已经写好了一些常用的方法和初始化代码吗,并为UI管理类的对接做好了准备。

代码

BasePanel (面板基类):

/// <summary>
/// 面板基类 
/// 帮助我门通过代码快速的找到所有的子控件
/// 方便我们在子类中处理逻辑 
/// 节约找控件的工作量
/// </summary>
public class BasePanel : MonoBehaviour
{
    //通过里式转换原则 来存储所有的控件
    //key为对象名,value为每一个对象上的所有组件的集合
    private Dictionary<string, List<UIBehaviour>> controlDic = new Dictionary<string, List<UIBehaviour>>();

	// Use this for initialization
	protected virtual void Awake () {
        FindChildrenControl<Button>();
        FindChildrenControl<Image>();
        FindChildrenControl<Text>();
        FindChildrenControl<Toggle>();
        FindChildrenControl<Slider>();
        FindChildrenControl<ScrollRect>();
        FindChildrenControl<InputField>();
    }
	
    /// <summary>
    /// 显示自己
    /// </summary>
    public virtual void ShowMe()
    {
        
    }

    /// <summary>
    /// 隐藏自己
    /// </summary>
    public virtual void HideMe()
    {

    }

    protected virtual void OnClick(string btnName)
    {

    }

    protected virtual void OnValueChanged(string toggleName, bool value)
    {

    }

    /// <summary>
    /// 得到对应名字的对应控件脚本
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="controlName"></param>
    /// <returns></returns>
    protected T GetControl<T>(string controlName) where T : UIBehaviour
    {
        if(controlDic.ContainsKey(controlName))
        {
            for( int i = 0; i <controlDic[controlName].Count; ++i )
            {
                if (controlDic[controlName][i] is T)
                    return controlDic[controlName][i] as T;
            }
        }

        return null;
    }

    /// <summary>
    /// 找到子对象的对应控件
    /// </summary>
    /// <typeparam name="T"></typeparam>
    private void FindChildrenControl<T>() where T:UIBehaviour
    {
        T[] controls = this.GetComponentsInChildren<T>();
        for (int i = 0; i < controls.Length; ++i)
        {
            string objName = controls[i].gameObject.name;
            if (controlDic.ContainsKey(objName))
                controlDic[objName].Add(controls[i]);
            else
                controlDic.Add(objName, new List<UIBehaviour>() { controls[i] });
            //如果是按钮控件
            if(controls[i] is Button)
            {
                //一种很妙的写法,运用lamdad表达式在无参函数里面实现有参函数效果
                //虽然所有的同类控件都被挂载了
                //但是可以在panel中通过对OnClick重写
                //用swithc敲定对谁起效。
                (controls[i] as Button).onClick.AddListener(()=>
                {
                    OnClick(objName);
                });
            }
            //如果是单选框或者多选框
            else if(controls[i] is Toggle)
            {
                (controls[i] as Toggle).onValueChanged.AddListener((value) =>
                {
                    OnValueChanged(objName, value);
                });
            }
        }
    }
}

总结

1.与UGUI课程的UI面板基类的对比:
一.本基类多出了自动查找子物体组件的功能
二.本基类并没有实现面板的淡入淡出,而UGUI课程的基类实现了。

2本基类采用字典的存储形式,在UI组件awake的时期就会自动获取到子物体上的UI组件,省去了手动拖动的繁琐。
字典的存储形式是key为组件所在的对象名,value为组件的数组(为了应对不同对象上有多个组件的情况)。

3.我们通过FindChildrenControl方法直接实现了初始化的时候添加了监听事件,其中运用了lambda表达式在无参函数里面实现了有参函数(伪),
原理为,虽然所有的同类控件都被挂载了同一种方法,但是可以在panel中通过对OnClick重写的时候,用swithc敲定对谁起效,具体请看下文UI模块使用例的那一节。

UI管理模块——UI管理器

用途

为外界提供控制UI面板的方法。

代码

前置:UI层级枚举

/// <summary>
/// UI层级
/// </summary>
public enum E_UI_Layer
{
    Bot,
    Mid,
    Top,
    System,
}

UIManager (UI管理器):

/// <summary>
/// UI管理器
/// 1.管理所有显示的面板
/// 2.提供给外部 显示和隐藏等等接口
/// </summary>
public class UIManager : BaseManager<UIManager>
{
    public Dictionary<string, BasePanel> panelDic = new Dictionary<string, BasePanel>();

    private Transform bot;
    private Transform mid;
    private Transform top;
    private Transform system;

    //记录我们UI的Canvas父对象 方便以后外部可能会使用它
    public RectTransform canvas;

    public UIManager()
    {
        //创建Canvas 让其过场景的时候 不被移除
        GameObject obj = ResMgr.GetInstance().Load<GameObject>("UI/Canvas");
        canvas = obj.transform as RectTransform;
        GameObject.DontDestroyOnLoad(obj);

        //找到各层
        bot = canvas.Find("Bot");
        mid = canvas.Find("Mid");
        top = canvas.Find("Top");
        system = canvas.Find("System");

        //创建EventSystem 让其过场景的时候 不被移除
        obj = ResMgr.GetInstance().Load<GameObject>("UI/EventSystem");
        GameObject.DontDestroyOnLoad(obj);
    }

    /// <summary>
    /// 通过层级枚举 得到对应层级的父对象
    /// </summary>
    /// <param name="layer"></param>
    /// <returns></returns>
    public Transform GetLayerFather(E_UI_Layer layer)
    {
        switch(layer)
        {
            case E_UI_Layer.Bot:
                return this.bot;
            case E_UI_Layer.Mid:
                return this.mid;
            case E_UI_Layer.Top:
                return this.top;
            case E_UI_Layer.System:
                return this.system;
        }
        return null;
    }

    /// <summary>
    /// 显示面板
    /// </summary>
    /// <typeparam name="T">面板脚本类型</typeparam>
    /// <param name="panelName">面板名</param>
    /// <param name="layer">显示在哪一层</param>
    /// <param name="callBack">当面板预设体创建成功后 你想做的事</param>
    public void ShowPanel<T>(string panelName, E_UI_Layer layer = E_UI_Layer.Mid, UnityAction<T> callBack = null) where T:BasePanel
    {
        if (panelDic.ContainsKey(panelName))
        {
            panelDic[panelName].ShowMe();
            // 处理面板创建完成后的逻辑
            if (callBack != null)
                callBack(panelDic[panelName] as T);
            //避免面板重复加载 如果存在该面板 即直接显示 调用回调函数后  直接return 不再处理后面的异步加载逻辑
            return;
        }

        ResMgr.GetInstance().LoadAsync<GameObject>("UI/" + panelName, (obj) =>
        {
            //把他作为 Canvas的子对象
            //并且 要设置它的相对位置
            //找到父对象 你到底显示在哪一层
            Transform father = bot;
            switch(layer)
            {
                case E_UI_Layer.Mid:
                    father = mid;
                    break;
                case E_UI_Layer.Top:
                    father = top;
                    break;
                case E_UI_Layer.System:
                    father = system;
                    break;
            }
            //设置父对象  设置相对位置和大小
            obj.transform.SetParent(father);

            obj.transform.localPosition = Vector3.zero;
            obj.transform.localScale = Vector3.one;

            (obj.transform as RectTransform).offsetMax = Vector2.zero;
            (obj.transform as RectTransform).offsetMin = Vector2.zero;

            //得到预设体身上的面板脚本
            T panel = obj.GetComponent<T>();
            // 处理面板创建完成后的逻辑
            //你除了可以在面板本身的awake里面修改面板,还可以通过这里进行修改
            //这里的修改会发生在面板的时间函数修改过后
            if (callBack != null)
                callBack(panel);

            panel.ShowMe();

            //把面板存起来
            panelDic.Add(panelName, panel);
        });
    }

    /// <summary>
    /// 隐藏面板
    /// </summary>
    /// <param name="panelName"></param>
    public void HidePanel(string panelName)
    {
        if(panelDic.ContainsKey(panelName))
        {
            panelDic[panelName].HideMe();
            GameObject.Destroy(panelDic[panelName].gameObject);
            panelDic.Remove(panelName);
        }
    }

    /// <summary>
    /// 得到某一个已经显示的面板 方便外部使用
    /// </summary>
    public T GetPanel<T>(string name) where T:BasePanel
    {
        if (panelDic.ContainsKey(name))
            return panelDic[name] as T;
        return null;
    }

    /// <summary>
    /// 创建一个公共的静态方法,给某个面板的控件添加自定义事件监听
    /// </summary>
    /// <param name="control">控件对象</param>
    /// <param name="type">事件类型</param>
    /// <param name="callBack">事件的响应函数</param>
    public static void AddCustomEventListener(UIBehaviour control, EventTriggerType type, UnityAction<BaseEventData> callBack)
    {
        EventTrigger trigger = control.GetComponent<EventTrigger>();
        if (trigger == null)
            trigger = control.gameObject.AddComponent<EventTrigger>();

        EventTrigger.Entry entry = new EventTrigger.Entry();
        entry.eventID = type;
        entry.callback.AddListener(callBack);

        trigger.triggers.Add(entry);
    }

}

总结

1.ShowPanel方法中给予了程序员两次对面板进行修改的机会,比如showpanel()里面你可以调用panel组件上重写后的showme,以及调用回调函数Callback两种方法修改。
前者是我们程序员在设计panel面板的时候就安排好的面板原生功能,后者是打算在打算对面板进行一些特殊处理的时候可以考虑使用的一种手段。

2.在Canvas对象下面设置了4个层级对象(即层级枚举里面的对象),这四个对象是用来方便UI设置自己前后的显示顺序的,比如有的UI永远位于所有UI之上,就放在System里面。

3.设置了静态函数AddCustomEventListener() ,用来为组件添加自定义事件(如拖拽)。原本需要写大量的代码,现在可以在Panel脚本里简便的直接调用。

UI实战例子

代码

LoginPanel 脚本:

public class LoginPanel : BasePanel {

    //public Button btnStart;
    //public Button btnQuit;

    protected override void Awake()
    {
        //一定不能少 因为需要执行父类的awake来初始化一些信息 比如找控件 加事件监听
        base.Awake();
        //在下面处理自己的一些初始化逻辑
    }

    // Use this for initialization
    void Start () {

        //GetControl<Button>("btnStart").onClick.AddListener(ClickStart);
        //GetControl<Button>("btnQuit").onClick.AddListener(ClickQuit);

        UIManager.AddCustomEventListener(GetControl<Button>("btnStart"), EventTriggerType.PointerEnter, (data)=>{
            Debug.Log("进入");
        });
        UIManager.AddCustomEventListener(GetControl<Button>("btnStart"), EventTriggerType.PointerExit, (data) => {
            Debug.Log("离开");
        });
    }

    private void Drag(BaseEventData data)
    {

    }

    private void PointerDown(BaseEventData data)
    {

    }

    // Update is called once per frame
    void Update () {
		
	}

    public override void ShowMe()
    {
        base.ShowMe();
        //显示面板时 想要执行的逻辑 这个函数 在UI管理器中 会自动帮我们调用
        //只要重写了它  就会执行里面的逻辑
    }

    protected override void OnClick(string btnName)
    {
        switch(btnName)
        {
            case "btnStart":
                Debug.Log("btnStart被点击");
                break;
            case "btnQuit":
                Debug.Log("btnQuit被点击");
                break;
        }
    }

    protected override void OnValueChanged(string toggleName, bool value)
    {
        //在这来根据名字判断 到底是那一个单选框或者多选框状态变化了 当前状态就是传入的value
    }


    public void InitInfo()
    {
        Debug.Log("初始化数据");
    }

    //点击开始按钮的处理
    public void ClickStart()
    {
    }

    //点击开始按钮的处理
    public void ClickQuit()
    {
        Debug.Log("S");
    }
}

总结

请结合整个UI模块来感受使用方式。