如何高效地从0搭建一个游戏项目
游戏底层框架的自我实现
文章目录
- 游戏底层框架的自我实现
- 一.写在前面
- 二.单例模式
- 1.普通单例模式
- 2.U3D单例模式
- 三.缓存池
- 四.事件中心
- 五.公共Mono
- 六.场景加载
- 7.资源加载
- 异步加载结合缓存池改进
- 9.输入管理
- 10.音效管理
- 11.UI管理
- 11.1 UI基类---BasePanel
- 11.2 UI管理
一.写在前面
- 1.为什么要自己写一个底层框架?
众所周知,公共模块 是所有游戏案例实现起来都必须经历的模块,这些模块往往适用面很广,它们像是建造房屋时打下的地基一样。有了他们,今后的项目开发会十分的便捷高效。由此可见,底层框架的搭建是尤为必要的。举个例子,像是Java中的Mybatis框架省去了许多数据源的初始化操作,更像是MybatisPlus框架直接省去了基本的SQL语句书写,从而更加便捷操作数据库。 该篇笔记便是从底层开始搭建一个高效的开发框架,从而实现以后游戏的高效筑基。让我们开始吧!
- 2.底层框架需要实现哪些模块?
– 1.单例模式基础
– 2.缓存池
– 3.事件中心模块
– 4.公共Mono模块
– 5.场景切换模块
– 6.资源加载模块
– 7.输入控制模块
– 8.音效管理模块
– 9.UI模块
– 10.数据管理模块
二.单例模式
1.普通单例模式
作用:减少单例模式重复代码的书写
代码样例:
public class BaseManager<T> where T:new()
{private static T instance;public static T Instance(){if (instance == null)instance = new T();return instance;}
}
个人理解:单例模式即为面向对象 思维的 封装与便捷访问 的样例,并且采用静态变量使其形成一个管理类的单例,之后调用管理类时直接采用该单例即可,并也保证了成员变量的安全性(访问权限private),也防止了管理类为空的报错。
2.U3D单例模式
但是一定要注意,继承了MonoBehavior的脚本,不能直接通过new关键字进行对象的创建!只能通过 ①拖动到对象上 或者 ②再脚本上加上AddComponent(),如果以上两种方式,U3D将帮我们内部实例化它。
代码样例:
public class SingletonMono<T> : MonoBehaviour where T : MonoBehaviour
{private static T instance;public static T Instance(){return instance;}protected virtual void Awake(){instance = this as T;}
}
个人理解:在我看来,C#中的泛型更像是外部是尖括号里面类型的父类,既然SingletonMono继承了MonoBehaviour,那么T像是MonoBehaviour的孙子类型,故须在后对T进行类型的限制。此外,由于继承该类的子类会重写Awake,故将Awake函数写成受保护的虚函数(具有继承权限并且支持重写)。再其他子类中使用时,参考以下案例:
public class AudioManager : SingletonMono<AudioManager>
{protected override void Awake(){base.Awake();//其他内容}
}
补充:继承了MonoBehaviour的单例模式对象,需要我们自己保证唯一性,否则 单例模式的概念会被破坏掉!
改进:为了防止人工操作的失误,还可以修改代码使其自动创建结点去挂载脚本,详情请继续读下去:
public class SingletonAutoMono<T> : MonoBehaviour where T : MonoBehaviour
{private static T instance;public static T Instance(){if (instance == null){//自动创建对象与添加脚本GameObject obj = new GameObject();obj.name = typeof(T).ToString();instance = obj.AddComponent<T>();//此外,为了让该单例模式对象 过场景 不被移除//因为 单例模式对象 往往是存在整个程序生命周期中的DontDestroyOnLoad(obj);}return instance;}
}
三.缓存池
场景引入:当玩家体验一些不断创建新的对象结点的游戏,比如FPS(第一人称射击游戏)游戏中枪口不断发射子弹,系统会持续不断地实例化粒子效果与子弹模型的对象,而C#的内存机制又是 尽管你删除了结点,但该节点对象占用的内存空间不会被回收,只有当内存空间溢出时,此时会进行一次GC(垃圾回收),从而导致玩家进行游戏的过程中卡顿,加大硬件设备的压力,为了较好的处理该问题,我们引用 缓存池 。
使用Dictionary 和 List 建立一个缓存池:
public class PoolManager : BaseManager<PoolManager>
{//字典实现缓存池容器public Dictionary<string, List<GameObject>> poolDic = new Dictionary<string, List<GameObject>>();//从缓存池中拿物体public GameObject GetObj(string name){GameObject obj = null;//有对应的容器List,并且容器里有东西if (poolDic.ContainsKey(name) && poolDic[name].Count > 0){//此处亦可用队列实现obj = poolDic[name][0];poolDic[name].RemoveAt(0);}else{obj = GameObject.Instantiate(Resources.Load<GameObject>(name));//将对象的名字跟池子名字保持一致obj.name = name;}obj.SetActive(true);return obj;}//向缓存池中添加物体public void AddObj(string name,GameObject obj){obj.SetActive(false);//如果已经存在对应容器if (poolDic.ContainsKey(name)){poolDic[name].Add(obj);}else{poolDic.Add(name, new List<GameObject>() { obj });}}
}
思考优化:我们观察上述代码可发现,该代码缓存池拿出物体时会统一出现在Hierarchy窗口的根目录下,从而导致层级窗口混乱。我们可以优化上述代码,使其激活时到根目录下,未激活状态则存储在更细致的子目录下。从而增强缓存池的可读性和通用性。
//抽屉类
public class PoolData
{//抽屉中对象挂载的父节点public GameObject fatherNode;//对象的容器public List<GameObject> poolList;//构造函数public PoolData(GameObject obj,GameObject poolObj){//给List创建一个父对象,使其成为pool的子对象fatherNode = new GameObject(obj.name);fatherNode.transform.parent = poolObj.transform;poolList = new List<GameObject>() { obj };AddObj(obj);}public void AddObj(GameObject obj){//失活obj.SetActive(false);poolList.Add(obj);//设置父对象obj.transform.parent = fatherNode.transform;}public GameObject GetObj(){GameObject obj = null;//取出第一个obj = poolList[0];poolList.RemoveAt(0);//激活该物体obj.SetActive(true);//断开父子关系obj.transform.parent = null;return obj;}
}public class PoolManager : BaseManager<PoolManager>
{//字典实现缓存池容器public Dictionary<string, PoolData> poolDic = new Dictionary<string, PoolData>();//根节点对象private GameObject poolObj;//从缓存池中拿物体public GameObject GetObj(string name){GameObject obj = null;//有对应的容器List,并且容器里有东西if (poolDic.ContainsKey(name) && poolDic[name].poolList.Count > 0){//此处亦可用队列实现obj = poolDic[name].GetObj();}else{obj = GameObject.Instantiate(Resources.Load<GameObject>(name));//将对象的名字跟池子名字保持一致obj.name = name;}return obj;}//向缓存池中添加物体public void AddObj(string name,GameObject obj){if (poolObj == null){poolObj = new GameObject("Pool");}//如果已经存在对应容器if (poolDic.ContainsKey(name)){poolDic[name].AddObj(obj);}else{poolDic.Add(name, new PoolData(obj,poolObj));}}//用于切换场景时的清空缓存池public void Clear(){poolDic.Clear();poolObj = null;}
}
四.事件中心
知识点:Dictionary,委托,观察者设计模式
作用:降低程序耦合性,减小程序复杂度
为什么要使用事件中心模块?
在玩家或者怪物死亡后,会通过事件中心执行 玩家获得奖励,任务记录,成就系统…。从而通知全局对象执行什么样的操作。比如,玩家死亡时,怪物播放胜利动画。如果没有观察者模式,则会在很多类中添加许多非自己调用的逻辑。造成耦合度极高。
代码样例:
using UnityEngine.Events;//事件中心单例模式对象
public class EventManager : BaseManager<EventManager>
{//key = 事件的名字,value = 监听这个事件对应的委托函数private Dictionary<string , UnityAction<object>> events = new Dictionary<string,UnityAction<object>>();//添加事件监听public void AddEventListener(string name,UnityAction<object> action){//有无对应事件监听if (events.ContainsKey(name)){events[name] += action;}else{events.Add(name, action);}}//在游戏对象销毁时调用public void RemoveEventListener(string name,UnityAction<object> action){if (events.ContainsKey(name)){events[name] -= action;}}//清空事件中心,用于场景切换public void Clear(){events.Clear();}//事件触发public void EventTrigger(string name,object info){//有无对应事件监听if (events.ContainsKey(name)){events[name]?.Invoke(info);}}
}
实际应用样例:
//事件中心的实际应用
public class PlayerController : MonoBehaviour
{//在对象创建时,注册事件中心void Start(){EventManager.Instance().AddEventListener("MonsterDead", MonsterDeadDo);}public void MonsterDeadDo(object info){Debug.Log("玩家获得奖励" + (info as MonsterController).name);}//在对象被销毁时,移除事件中心void OnDestroy(){EventManager.Instance().RemoveEventListener("MonsterDead", MonsterDeadDo); }
}
个人理解:事件中心像一个大喇叭(驿站)一样,其他游戏对象需要监听什么信息可以自己去事件中心进行注册,告诉它我需要知道某件事情的发生。注册后,当事件发生之后,事件中心会一次查看哪些对象注册了通知服务,并以此通知各个游戏对象执行接下来的逻辑。原本重复写很多遍的代码,放到事件中心里只需要写一次即可。极大的降低了程序的耦合度!
五.公共Mono
作用:让没有继承Mono的类可以开启协程,可以Update帧更新,统一管理Update。
代码样例:
using UnityEngine;
using UnityEngine.Events;public class MonoController : MonoBehaviour
{public event UnityAction updateEvent;// Start is called before the first frame updatevoid Start(){DontDestroyOnLoad(this.gameObject);}void Update(){if (updateEvent != null){updateEvent();}}//添加帧更新事件的函数public void AddUpdateListener(UnityAction fun){updateEvent += fun;}public void RemoveUpdateListener(UnityAction fun){updateEvent -= fun;}
}
个人理解:以上代码继承了MonoBehaviour,故在其中能正常调用协程函数以及帧更新等周期函数。但我们需要使用一个公共管理类来让没有继承Mono的类也可以正常调用上述函数。我们便可以写以下代码实现该功能。
using UnityEngine;
using UnityEngine.Events;//给外部提供添加帧更新事件的方法
//给外部提供开启协程的方法
public class MonoManager : BaseManager<MonoManager>
{private MonoController controller;public MonoManager(){//保证了MonoController对象的唯一性GameObject obj = new GameObject("MonoController");controller = obj.AddComponent<MonoController>();}//添加帧更新事件的函数public void AddUpdateListener(UnityAction fun){controller.AddUpdateListener(fun);}public void RemoveUpdateListener(UnityAction fun){controller.RemoveUpdateListener(fun);}//开启协程public Coroutine StartCoroutine(IEnumerator routine){return controller.StartCoroutine(routine);}}
个人理解:该代码更像是给Controller类做了一个封装,但其并未继承Mono,测试类中直接采用该管理类的单例即可正常开启协程以及调用帧更新管理。成功利用接口实现了对普通类的扩展。
六.场景加载
作用:给外部提供切换场景的接口。
代码样例:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.SceneManagement;public class SceneMgr : BaseManager<SceneMgr>
{//同步加载(必须场景完全加载完成才能执行以下操作)public void LoadScene(string name,UnityAction fun){SceneManager.LoadScene(name);//加载完成后才会执行funfun();}//异步加载public void LoadSceneAsyn(string name,UnityAction fun){MonoManager.Instance().StartCoroutine(ReallyLoadSceneAsyn(name, fun));}//协程异步加载场景private IEnumerator ReallyLoadSceneAsyn(string name,UnityAction fun){AsyncOperation ao = SceneManager.LoadSceneAsync(name);while (!ao.isDone){//可以使用事件中心管理事件EventManager.Instance().EventTrigger("进度条更新", ao.progress);//在这里面可以做到更新进度条的操作yield return ao.progress;}//加载完成后,执行funfun();}
}
7.资源加载
知识点:资源异步加载,协程,委托和lambda表达式,泛型
作用:提供资源加载的公共接口
代码样例:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;public class ResourcesManager : BaseManager<ResourcesManager>
{//同步加载资源public T Load<T>(string name) where T : Object{T res = Resources.Load<T>(name);//如果对象是一个GameObject类型,将其实例化if (res is GameObject){return GameObject.Instantiate(res);}return res;}//异步加载资源public void LoadAsync<T>(string name, UnityAction<T> callback) where T : Object{//开启异步加载协程MonoManager.Instance().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);elsecallback(r.asset as T);}
}
使用该框架模块时,可以使用Lambda表达式时执行逻辑清晰:
if (Input.GetMouseButtonDown(0))
{ResourcesManager.Instance().LoadAsync<GameObject>("Test/Cube",(obj)=>{//做一些 资源加载进去以后想做的事情});
}
异步加载结合缓存池改进
缓存池更新代码:
//从缓存池中拿物体public void GetObj(string name,UnityAction<GameObject> callback){//有对应的容器List,并且容器里有东西if (poolDic.ContainsKey(name) && poolDic[name].poolList.Count > 0){//此处亦可用队列实现callback(poolDic[name].GetObj());}else{//obj = GameObject.Instantiate(Resources.Load(name)); //将对象的名字跟池子名字保持一致//obj.name = name;//通过异步加载创建对象ResourcesManager.Instance().LoadAsync<GameObject>(name, (o) =>{o.name = name;callback(o);});}}
测试异步加载代码:
void Update(){if (Input.GetMouseButtonDown(0)){Debug.Log("按下了鼠标左键");ResourcesManager.Instance().Load<GameObject>("Test/Cube");}if (Input.GetMouseButtonDown(1)){Debug.Log("按下了鼠标右键");PoolManager.Instance().GetObj("Test/Cube", (o) =>{o.transform.localScale = Vector3.one * 2;});}}
9.输入管理
作用:统一管理用户的输入。降低程序耦合度。
代码样例:
public class InputManager : BaseManager<InputManager>
{//是否开启该模块功能private bool isStart = false;public InputManager(){MonoManager.Instance().AddUpdateListener(InputUpdate);}//切换开启状态public void SwitchStates(bool isOpen){isStart = isOpen;}private void InputUpdate(){if (!isStart) return;CheckKeyCode(KeyCode.W);CheckKeyCode(KeyCode.A);CheckKeyCode(KeyCode.S);CheckKeyCode(KeyCode.D);}//检测按键函数private void CheckKeyCode(KeyCode keycode){if (Input.GetKeyDown(keycode)){//事件中心分发按下抬起EventManager.Instance().EventTrigger("某键按下", keycode);}else if (Input.GetKeyUp(keycode)){EventManager.Instance().EventTrigger("某键抬起", keycode);}}
}
测试样例:
public class Test1 : MonoBehaviour
{void Start(){InputManager.Instance().SwitchStates(true);EventManager.Instance().AddEventListener("某键按下", CheckInputDown);EventManager.Instance().AddEventListener("某键抬起", CheckInputUp);}private void CheckInputUp(object key){Debug.Log(key + "抬起");KeyCode code = (KeyCode)key;switch (code){case KeyCode.W:Debug.Log("停止前进");break;case KeyCode.S:Debug.Log("停止后退");break;case KeyCode.A:Debug.Log("停止左转");break;case KeyCode.D:Debug.Log("停止右转");break;}}private void CheckInputDown(object key){Debug.Log(key + "按下");KeyCode code = (KeyCode)key;switch (code){case KeyCode.W:Debug.Log("前进");break;case KeyCode.S:Debug.Log("后退");break;case KeyCode.A:Debug.Log("左转");break;case KeyCode.D:Debug.Log("右转");break;}}
}
10.音效管理
作用:统一管理音乐音效。
代码样例:
public class MusicManager : BaseManager<MusicManager>
{//背景音乐private AudioSource BGM = null;//BGM响度private float BGMVolume = 1;//音效模块private GameObject soundObj = null;private List<AudioSource> soundList = new List<AudioSource>();//音效响度private float SoundVolume = 1;//此处注意,由于单循环次音效无法判断自己是否播放完成,需要手动使用Update检测是否播放完成public MusicManager(){MonoManager.Instance().AddUpdateListener(Update);}private void Update(){for (int i = 0;i < soundList.Count;i++){if (!soundList[i].isPlaying){GameObject.Destroy(soundList[i]);soundList.Remove(soundList[i]);}}}//播放背景音乐public void PlayBGM(string name){if (BGM == null){GameObject obj = new GameObject("BGM");BGM = obj.AddComponent<AudioSource>();}//异步加载音乐资源ResourcesManager.Instance().LoadAsync<AudioClip>("Music/BGM/" + name, (clip) =>{BGM.clip = clip;BGM.volume = BGMVolume;BGM.loop = true; // 设置循环播放BGM.Play();});}//暂停背景音乐public void PauseBGM(string name){if (BGM == null) return;BGM.Pause();}//停止背景音乐public void StopBGM(string name){if (BGM == null) return;BGM.Stop();}//设置音量大小public void ChangeBGMVolume(float v){BGMVolume = v;if (BGM == null) return;BGM.volume = BGMVolume;}//播放音效public void PlaySound(string name,bool isLoop,UnityAction<AudioSource> callback = null){//参数引入一个callback回调,如果调用函数时需要拿到音效进行操作,可使用callbackif (soundObj == null){soundObj = new GameObject("Sound");}//当音效资源异步加载后 再添加一个音效ResourcesManager.Instance().LoadAsync<AudioClip>("Sound/" + name, (clip) =>{AudioSource source = soundObj.AddComponent<AudioSource>();source.loop = isLoop; // 设置是否循环source.clip = clip;source.volume = SoundVolume;source.Play();soundList.Add(source);//如果检测到回调函数不为空,则回调if (callback != null) callback(source);});}//停止音效public void StopSound(AudioSource source){if (soundList.Contains(source)){soundList.Remove(source);source.Stop();GameObject.Destroy(source);}}//改变所有音效的响度public void ChangeSoundVolume(float v){SoundVolume = v;foreach(AudioSource source in soundList){source.volume = v;}}}
测试样例:
public class TestMusic : MonoBehaviour
{AudioSource source;private void OnGUI(){if (GUI.Button(new Rect(0, 0, 100, 100), "播放音乐"))MusicManager.Instance().PlayBGM("皎洁的笑颜");if (GUI.Button(new Rect(0, 100, 100, 100), "暂停音乐"))MusicManager.Instance().PauseBGM("皎洁的笑颜");if (GUI.Button(new Rect(0, 200, 100, 100), "停止音乐"))MusicManager.Instance().StopBGM("皎洁的笑颜");if (GUI.Button(new Rect(100, 0, 100, 100), "播放音效"))MusicManager.Instance().PlaySound("枪声",false,(r)=> {source = r;});if (GUI.Button(new Rect(100, 100, 100, 100), "暂停音效"))MusicManager.Instance().StopSound(source);//if (GUI.Button(new Rect(100, 200, 100, 100), "停止音乐")) ;// MusicManager.Instance().StopSound("枪声");}
}
重难点总结:音效的管理需要逐帧判断其音效切片是否播放完成,如果播放完成,则自动删除音效组件。
11.UI管理
11.1 UI基类—BasePanel
作用:自动在场景中寻找对应的控件,使其无需手动拖拽赋值
代码样例:
using UnityEngine.EventSystems;
using UnityEngine.UI;//面板基类
//需要找到所有自己面板下的对象
//提供显示或隐藏的接口
public class BasePanel : MonoBehaviour
{//里式转换原则//使用字典存储UI组件private Dictionary<string, List<UIBehaviour>> controlDic = new Dictionary<string, List<UIBehaviour>>();void Awake(){FindChildrenControl<Button>();FindChildrenControl<Image>();FindChildrenControl<Text>();FindChildrenControl<Toggle>();FindChildrenControl<Slider>();}public virtual void ShowMe(){}public virtual void HideMe(){}//得到对应名字的对应控件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;}//找到子对象的对应控件private void FindChildrenControl<T>() where T : UIBehaviour{T[] controls = GetComponentsInChildren<T>();string objName;for (int i = 0;i < controls.Length; i++){objName = controls[i].gameObject.name;if (controlDic.ContainsKey(objName)){controlDic[objName].Add(controls[i]);}else{controlDic.Add(objName, new List<UIBehaviour>() { controls[i] });}}}
}
11.2 UI管理
public enum E_UI_Layer
{Bot,Mid,Top,System,
}///
/// UI管理器
/// 1.管理所有显示的面板
/// 2.提供给外部 显示和隐藏等等接口
///
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 = ResourcesManager.Instance().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 = ResourcesManager.Instance().Load<GameObject>("UI/EventSystem");GameObject.DontDestroyOnLoad(obj);}/// /// 通过层级枚举 得到对应层级的父对象/// /// /// 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;}/// /// 显示面板/// /// 面板脚本类型 /// 面板名/// 显示在哪一层/// 当面板预设体创建成功后 你想做的事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;}ResourcesManager.Instance().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>();// 处理面板创建完成后的逻辑if (callBack != null)callBack(panel);panel.ShowMe();//把面板存起来panelDic.Add(panelName, panel);});}/// /// 隐藏面板/// /// public void HidePanel(string panelName){if(panelDic.ContainsKey(panelName)){panelDic[panelName].HideMe();GameObject.Destroy(panelDic[panelName].gameObject);panelDic.Remove(panelName);}}/// /// 得到某一个已经显示的面板 方便外部使用/// public T GetPanel<T>(string name) where T:BasePanel{if (panelDic.ContainsKey(name))return panelDic[name] as T;return null;}/// /// 给控件添加自定义事件监听/// /// 控件对象/// 事件类型/// 事件的响应函数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);}}
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
