陷阱
陷阱,最常見到的情節就是在探險類的電影及遊戲,探險家踩到地板的凸起的石頭,接著後面就滾下來了一個更大的石頭,要壓爆探險家!
設計
陷阱我認為它是一個觸發器 + 目標物件(如滾下來的石頭)所組合而成的東西。
目標物件可能是一個落石、夾子、機關…等很多奇怪的東西,目標物件將提供函式給觸發器呼叫控制。
觸發器將是本篇的重點,我們會設計一個通用的可編輯的觸發器,來控制一個或多個目標物件。在觸發器裡挑選要呼叫目標物件的那一支函式,類似 uGUI 的 UI Button ,如圖:
UI Button 它是用按 Button 來觸發事件,然後再執行所選擇的函式。
我們就做一個類似的功能,用 Trigger 來觸發事件,然後再對目標物件使用 SendMessage() 來呼叫所選擇的函式。
接下來我們將會需要用到以下的東西:
- 取出 class 裡函式
- System.Reflection
- GetType
- GetMethods
- GetParameters
- ParameterInfo
- 用來畫我們自訂腳本編輯介面
- UnityEditor
- GUILayout
- EditorGUILayout
- GenericMenu
- GUIContent
想知道他們更多、更詳細的內容請去參考文件或 Google 哦。
取出目標物件可呼叫的函式
因為我們是使用 SendMessage ,它只能送訊息給 MonoBehaviour 的 class,所以一開始我們先取出 GameObject 身上所有的 MonoBehaviour 的 class。
//GameObject go MonoBehaviour[] cpns = go.GetComponents<MonoBehaviour>();
然後用 GetMethods() 設定 Flag 取出特定種類的函式 。MSDN 說明
for (int i = 0; i < cpns.Length; i++) { Component cpn = cpns[i]; System.Type tp = cpn.GetType(); MethodInfo[] methodInfos = tp.GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.DeclaredOnly ); }
透過 GetParameters() 取出函式內的參數。
for (int j = 0; j < methodInfos.Length; j++) { MethodInfo mf = methodInfos[j]; ParameterInfo[] pms = mf.GetParameters(); for (int k = 0; k < pms.Length; k++) { ParameterInfo pm = pms[k]; } }
以上的做法可以取出我們想要的特定種類函式,但可呼叫的函式太多了且有的函式參數太多了,我們在做編輯介面會寫很多 code,所以為了簡化我們還是給一些限制好了。
我們限制如下:
- 函式最多只能有一個參數
- pms.Length <= 1
- 且參數的種類只能是
- float
- int
- string
- bool
這樣子我們的程式好寫多了,編輯時也不用在一堆函式裡大海撈針。
bool IsSupParmType(ParameterInfo pm) { if (pm.ParameterType == typeof(float)) { return true; } else if (pm.ParameterType == typeof(int)) { return true; } else if (pm.ParameterType == typeof(string)) { return true; } else if (pm.ParameterType == typeof(bool)) { return true; } return false; }
所以我們的程式就變成
for (int j = 0; j < methodInfos.Length; j++) { MethodInfo mf = methodInfos[j]; ParameterInfo[] pms = mf.GetParameters(); //sup 0 ~ 1 parm if (pms.Length == 0) { } else if (pms.Length == 1) { ParameterInfo pm = pms[0]; bool issup = IsSupParmType(pm); if (issup) { } }
編輯可呼叫函式的資料內容
能列出可呼叫的函式後,我們就要考慮如何去編輯,這些可編輯的設定都應被記錄下來供遊戲執行時使用。
這裡列出編輯時我們要記住的資料有:
- 陷阱被觸發時的目標物件 GameObject
- 呼叫 GameObject 裡的什麼函式
- 函式參數的種類
- 參數的值
- 進入時或離開時觸發呼叫函式
我們寫一個名為 FuncCallData 的 class 程式如下:
[Serializable] public class FuncCallData { public enum TriggleType { ON_ENTER, ON_EXIT } public GameObject targetObject; public TriggleType triggerType = TriggleType.ON_ENTER; public string funcName; public string parmType; public int parmInt; public float parmFloat; public string parmString; public bool parmBool; }
[Serializable] 的屬性可在編輯模式切換至 Play 模式時,讓它的資料不被清空。
觸發器腳本
再來就是本篇的主角登場了,我們寫個叫做 EventTriggerCaller 腳本,這個腳本只要記住二個東西就好:
- 用來存放函式呼叫資料的清單: funcdatas
- 那些 Tag 名稱的物件會觸發這個陷阱:tagNames
public class EventTriggerCaller : MonoBehaviour { public List<FuncCallData> funcdatas = new List<FuncCallData>(); public string[] tagNames; }
然後加入 HasTagName() 函式 判斷 Tag 名稱( 我相信用 for 會比用 System.Array.IndexOf 來得快,不信大家去測看看)與 Trigger 相關函式。
bool HasTagName(string tagname) { for (int i = 0; i < tagNames.Length; i++) { if (tagNames[i] == tagname) { return true; } } return false; } void OnTriggerEnter(Collider other) { if (HasTagName(other.tag)) { for (int i = 0; i < funcdatas.Count; i++) { FuncCallData fd = funcdatas[i]; if (fd.triggerType == FuncCallData.TriggleType.ON_ENTER) { if (fd.targetObject != null && fd.funcName != "") { if (fd.parmType == "") { fd.targetObject.SendMessage(fd.funcName); } else { fd.targetObject.SendMessage(fd.funcName, fd.GetParm()); } } } } } } void OnTriggerExit(Collider other) { if (HasTagName(other.tag)) { for (int i = 0; i < funcdatas.Count; i++) { FuncCallData fd = funcdatas[i]; if (fd.triggerType == FuncCallData.TriggleType.ON_EXIT) { if (fd.targetObject != null && fd.funcName != "") { if (fd.parmType == "") { fd.targetObject.SendMessage(fd.funcName); } else { fd.targetObject.SendMessage(fd.funcName, fd.GetParm()); } } } } } }
如上面程式所示,當OnTriggerEnter、OnTriggerExit 時,我們就判斷觸發的物件 Tag 是不是與我們設定可觸發陷阱的 Tag 名稱一樣。
然後再判斷要呼叫的函式是要在 Enter 或 Exit 時呼叫。
最後就是用 SendMessage 去呼叫的物件函式。
使用這個腳本的 GameObject 身上要有 Collider 並設定為 Trigger 哦!
陷阱觸發器腳本的編輯介面
有了一堆資料後,還要有一個合適的介面來編輯它,這裡建立一個名為 EventTriggerCallerEditor 的腳本使用 [CustomEditor] 的屬性。
要建立一個名為 Editor 的資料夾,並把這個腳本放進去才會有作用哦。
覆寫 OnInspectorGUI() 的函式,接下來我們將在這個函式裡把編輯介面寫出來。
using UnityEditor; [CustomEditor(typeof(EventTriggerCaller))] public class EventTriggerCallerEditor : Editor { override public void OnInspectorGUI() { } }
我們先畫出成員變數 tagNames
利用 serializedObject.FindProperty 取出該成員變數,並用 Unity 原本提供的功能 EditorGUILayout.PropertyField() 來畫它。其它程式碼是用來檢查這個成員變數有沒有被修改過,有則把修改過的值設定進去。
SerializedProperty tps = serializedObject.FindProperty ("tagNames"); EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(tps, true); if(EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); }
再來加兩個按鈕來增減 List 清單的內容。
- “+" 鈕:增加一筆資料在最後。
- “-" 鈕:刪除最後一筆資料。
EventTriggerCaller etc = (EventTriggerCaller)target; GUILayout.BeginHorizontal(); if (GUILayout.Button("+")) { FuncCallData dd = new FuncCallData(); etc.funcdatas.Add(dd); } if (GUILayout.Button("-")) { if (etc.funcdatas.Count > 0) { etc.funcdatas.RemoveAt(etc.funcdatas.Count - 1); } } GUILayout.EndHorizontal();
再來就是畫出 List 的內容。
歷遍 List 裡所有的資料。
EventTriggerCaller etc = (EventTriggerCaller)target; for (int i = 0; i < etc.funcdatas.Count; i++) { GUILayout.BeginHorizontal(); FuncCallData fd = etc.funcdatas[i]; }
我們規畫一下 List 裡資料要顯示那些欄位:
- 可設定 GameObject 的欄位。
- 可設定是在 Enter 或 Exit 時呼叫觸發的欄位。
- 顯示呼叫那支函式的欄位。
- 顯示設定參數的欄位。
- 選擇函式的按鈕。
- 顯示可呼叫的函式選單。
設定 GameObject 的欄位:
fd.targetObject = (GameObject)EditorGUILayout.ObjectField(fd.targetObject, typeof(GameObject), true);
可設定是在 Enter 或 Exit 時呼叫觸發的欄位:
fd.triggerType = (FuncCallData.TriggleType)EditorGUILayout.EnumPopup(fd.triggerType);
顯示被呼叫函式名稱的欄位:
GUILayout.Label(fd.funcName);
顯示設定參數的欄位,依參數的種類來決定使用那種 Field:
void ShowInputParmField(FuncCallData fd) { if (fd.parmType == typeof(float).ToString()) { fd.parmFloat = EditorGUILayout.FloatField(fd.parmFloat); } else if (fd.parmType == typeof(int).ToString()) { fd.parmInt = EditorGUILayout.IntField(fd.parmInt); } else if (fd.parmType == typeof(string).ToString()) { fd.parmString = EditorGUILayout.TextField(fd.parmString); } else if (fd.parmType == typeof(bool).ToString()) { fd.parmBool = EditorGUILayout.Toggle(fd.parmBool); } }
選擇函式的選單按鈕:
if (GUILayout.Button("func")) { ShowFuncMenu(etc.funcdatas[i].targetObject, fd); }
顯示可呼叫的函式選單:
void ShowFuncMenu(GameObject go, FuncCallData fd) { MonoBehaviour[] cpns = go.GetComponents<MonoBehaviour>(); GenericMenu gmenu = new GenericMenu(); for (int i = 0; i < cpns.Length; i++) { Component cpn = cpns[i]; CreateGUIContents(fd, gmenu, cpn); } gmenu.ShowAsContext(); } void CreateGUIContents(FuncCallData fd, GenericMenu gmenu, object classobj) { System.Type tp = classobj.GetType(); MethodInfo[] methodInfos = tp.GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.DeclaredOnly ); for (int j = 0; j < methodInfos.Length; j++) { MethodInfo mf = methodInfos[j]; ParameterInfo[] pms = mf.GetParameters(); //sup 0 ~ 1 parm if (pms.Length == 0) { GUIContent gcnt = new GUIContent(classobj.GetType().Name + "/" + mf.Name + "()"); MenuData md = new MenuData(); md.funcData = fd; md.functionName = mf.Name; gmenu.AddItem(gcnt, false, MenuFunc, md); } else if (pms.Length == 1) { ParameterInfo pm = pms[0]; bool issup = IsSupParmType(pm); if (issup) { GUIContent gcnt = new GUIContent(classobj.GetType().Name + "/" + mf.Name + "(" + pm.ParameterType + ")"); MenuData md = new MenuData(); md.funcData = fd; md.functionName = mf.Name; md.parmType = pm.ParameterType.ToString(); gmenu.AddItem(gcnt, false, MenuFunc, md); } } } }
在上面的 Code 裡,我們使用 GenericMenu 建立選單,把可呼叫的函式取出,用 GUIContent 建立選項。
我們在加入選項(GUIContent)時,可設定選項被點擊中的回呼函式及設定回呼函式參數。
我們要傳入的參數應有:
- 是那一個 FuncCallData 在做選擇選函式
- 該選項的函式名稱
- 該選項的參數種類
但回呼函式參數只能傳入一個,所以我們建立一個叫 MenuData 的 class 來把該記錄參數包起來。
class MenuData { public FuncCallData funcData; public string functionName; public string parmType; }
再建立一支回呼函式 MenuFunc,它要做的事就是把函式參數 object 轉型回 MenuData,然後把名稱及參數種類設定至 funcData 裡。
void MenuFunc(object data) { MenuData md = (MenuData)data; md.funcData.funcName = md.functionName; md.funcData.parmType = md.parmType; }
最後,加入選項前先設定 MenuData 的內容,然後加入選項時設定我們的回呼函式及回呼函式參數。
GUIContent gcnt = new GUIContent(cpn.GetType().Name + "/" + mf.Name + "(" + pm.ParameterType + ")"); MenuData md = new MenuData(); md.funcData = fd; md.functionName = mf.Name; md.parmType = pm.ParameterType.ToString(); gmenu.AddItem(gcnt, false, MenuFunc, md);
設定 GUIContent 內容時,可利用 "/" 做選項的分層,把同元件的函式都整理在一起。
呼!寫了一堆 Code 終於把它完成了!相信這個辛苦會是值得的!
目標物件
目標物件可是很多很天馬行空的東西,它的設計是各式各樣的,但是有一個共通的關鍵就是要提供一些能給別人呼叫的函式,利用這些函式來控制它。接下來會用實例來說明。
實例
我們建立三個 GameObject
- trigger:陷阱,加入我們剛才寫的 EventTriggerCaller 腳本。
- player:玩家,身上的 Tag 設定為 “Player" 。
- rock:落石,目標物件。
我們先在落石身上隨便寫一個腳本,裡面有一些函式可以供 trigger 呼叫:
public void nofun() { Debug.Log("nofun"); } public void Strfun(string str) { Debug.Log("Strfun:" + str); } public void intfun(int va) { Debug.Log("intfun:" + va); } public void boolfun(bool va) { Debug.Log("boolfun:" + va); }
當然,在遊戲中落石應會有播動作、放音效的函式供我們呼叫。
再來設定 trigger,設定成只有 Tag 名稱是 Player 時才會觸發。
最後當 player 進入及離開 trigger 時,rock 就會分別執行 StrFun() 及 intfun() 二支函式。
如何?有沒有很有趣、方便好用?
有了這個觸發器腳本,我們就能控制很多東西,比如動作、音樂音效、角色資訊…等,只要把想被控制的東西寫成函式,再透過 EventTriggerCaller 去呼叫它們,就能做出很多效果組合。
雖然它不可能能應用在各種情況,但是這種設計己經算是通用了,當然大家也可以依自己的需求去改造它哦!
完整程式碼
//https://arclee0117.wordpress.com/ //arclee0117@gmail.com using UnityEngine; using System.Collections; using System.Collections.Generic; using System; [Serializable] public class FuncCallData { public enum TriggleType { ON_ENTER, ON_EXIT } public GameObject targetObject; public TriggleType triggerType = TriggleType.ON_ENTER; public string funcName; public string parmType; public int parmInt; public float parmFloat; public string parmString; public bool parmBool; public void RestFuncData() { funcName = ""; parmType = ""; // triggerType = TriggleType.ON_ENTER; // parmInt = 0; // parmFloat = 0; // parmString = ""; // parmBool = false; } public object GetParm() { if (parmType == typeof(int).ToString()) { return parmInt; } else if (parmType == typeof(float).ToString()) { return parmFloat; } else if (parmType == typeof(string).ToString()) { return parmString; } else if (parmType == typeof(bool).ToString()) { return parmBool; } return null; } } public class EventTriggerCaller : MonoBehaviour { public List<FuncCallData> funcdatas = new List<FuncCallData>(); public string[] tagNames; // Use this for initialization void Start () { } // Update is called once per frame void Update () { } bool HasTagName(string tagname) { for (int i = 0; i < tagNames.Length; i++) { if (tagNames[i] == tagname) { return true; } } return false; } void OnTriggerEnter(Collider other) { if (HasTagName(other.tag)) { for (int i = 0; i < funcdatas.Count; i++) { FuncCallData fd = funcdatas[i]; if (fd.triggerType == FuncCallData.TriggleType.ON_ENTER) { if (fd.targetObject != null && fd.funcName != "") { if (fd.parmType == "") { fd.targetObject.SendMessage(fd.funcName); } else { fd.targetObject.SendMessage(fd.funcName, fd.GetParm()); } } } } } } void OnTriggerExit(Collider other) { if (HasTagName(other.tag)) { for (int i = 0; i < funcdatas.Count; i++) { FuncCallData fd = funcdatas[i]; if (fd.triggerType == FuncCallData.TriggleType.ON_EXIT) { if (fd.targetObject != null && fd.funcName != "") { if (fd.parmType == "") { fd.targetObject.SendMessage(fd.funcName); } else { fd.targetObject.SendMessage(fd.funcName, fd.GetParm()); } } } } } } }
//https://arclee0117.wordpress.com/ //arclee0117@gmail.com using UnityEngine; using System.Collections; using UnityEditor; using System; using System.Reflection; [CustomEditor(typeof(EventTriggerCaller))] public class EventTriggerCallerEditor : Editor { class MenuData { public FuncCallData funcData; public string functionName; public string parmType; } UnityEngine.Object go; // Use this for initialization void Start () { } // Update is called once per frame void Update () { } bool IsSupParmType(ParameterInfo pm) { if (pm.ParameterType == typeof(float)) { return true; } else if (pm.ParameterType == typeof(int)) { return true; } else if (pm.ParameterType == typeof(string)) { return true; } else if (pm.ParameterType == typeof(bool)) { return true; } return false; } void MenuFunc(object data) { MenuData md = (MenuData)data; md.funcData.funcName = md.functionName; md.funcData.parmType = md.parmType; } void CreateGUIContents(FuncCallData fd, GenericMenu gmenu, object classobj) { System.Type tp = classobj.GetType(); MethodInfo[] methodInfos = tp.GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.DeclaredOnly ); for (int j = 0; j < methodInfos.Length; j++) { MethodInfo mf = methodInfos[j]; ParameterInfo[] pms = mf.GetParameters(); //sup 0 ~ 1 parm if (pms.Length == 0) { GUIContent gcnt = new GUIContent(classobj.GetType().Name + "/" + mf.Name + "()"); MenuData md = new MenuData(); md.funcData = fd; md.functionName = mf.Name; gmenu.AddItem(gcnt, false, MenuFunc, md); } else if (pms.Length == 1) { ParameterInfo pm = pms[0]; bool issup = IsSupParmType(pm); if (issup) { GUIContent gcnt = new GUIContent(classobj.GetType().Name + "/" + mf.Name + "(" + pm.ParameterType + ")"); MenuData md = new MenuData(); md.funcData = fd; md.functionName = mf.Name; md.parmType = pm.ParameterType.ToString(); gmenu.AddItem(gcnt, false, MenuFunc, md); } } } } void ShowFuncMenu(GameObject go, FuncCallData fd) { MonoBehaviour[] cpns = go.GetComponents<MonoBehaviour>(); GenericMenu gmenu = new GenericMenu(); for (int i = 0; i < cpns.Length; i++) { Component cpn = cpns[i]; CreateGUIContents(fd, gmenu, cpn); } gmenu.ShowAsContext(); } void ShowInputParmField(FuncCallData fd) { if (fd.parmType == typeof(float).ToString()) { fd.parmFloat = EditorGUILayout.FloatField(fd.parmFloat); } else if (fd.parmType == typeof(int).ToString()) { fd.parmInt = EditorGUILayout.IntField(fd.parmInt); } else if (fd.parmType == typeof(string).ToString()) { fd.parmString = EditorGUILayout.TextField(fd.parmString); } else if (fd.parmType == typeof(bool).ToString()) { fd.parmBool = EditorGUILayout.Toggle(fd.parmBool); } } void DrawFuctionCallData() { EventTriggerCaller etc = (EventTriggerCaller)target; for (int i = 0; i < etc.funcdatas.Count; i++) { GUILayout.BeginHorizontal(); FuncCallData fd = etc.funcdatas[i]; GameObject lasttarget = fd.targetObject; fd.targetObject = (GameObject)EditorGUILayout.ObjectField(fd.targetObject, typeof(GameObject), true); if (fd.targetObject != null) { if (lasttarget != fd.targetObject) { fd.RestFuncData(); } fd.triggerType = (FuncCallData.TriggleType)EditorGUILayout.EnumPopup(fd.triggerType); GUILayout.Label(fd.funcName); ShowInputParmField(fd); if (GUILayout.Button("func")) { ShowFuncMenu(etc.funcdatas[i].targetObject, fd); } } GUILayout.EndHorizontal(); } } override public void OnInspectorGUI() { EventTriggerCaller etc = (EventTriggerCaller)target; SerializedProperty tps = serializedObject.FindProperty ("tagNames"); EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(tps, true); if(EditorGUI.EndChangeCheck()) { serializedObject.ApplyModifiedProperties(); } DrawFuctionCallData(); GUILayout.BeginHorizontal(); if (GUILayout.Button("+")) { FuncCallData dd = new FuncCallData(); etc.funcdatas.Add(dd); } if (GUILayout.Button("-")) { if (etc.funcdatas.Count > 0) { etc.funcdatas.RemoveAt(etc.funcdatas.Count - 1); } } GUILayout.EndHorizontal(); } }
//https://arclee0117.wordpress.com/ //arclee0117@gmail.com using UnityEngine; using System.Collections; public class eventTest : MonoBehaviour { // Use this for initialization void Start () { } // Update is called once per frame void Update () { } public void nofun() { Debug.Log("nofun"); } public void Strfun(string str) { Debug.Log("Strfun:" + str); } public void intfun(int va) { Debug.Log("intfun:" + va); } public void boolfun(bool va) { Debug.Log("boolfun:" + va); } }
參考
- GameObject.SendMessage http://docs.unity3d.com/ScriptReference/GameObject.SendMessage.html
- BindingFlags 列舉類型 https://msdn.microsoft.com/zh-tw/library/system.reflection.bindingflags%28v=vs.110%29.aspx
- Custom Editors http://docs.unity3d.com/Manual/editor-CustomEditors.html