2015年10月25日 星期日

Unity:使用 UnityEngine.Events 讓程式更靈活、穩定

自從 Unity 4.6 發佈新的 GUI 系統之後,我們可以從所建立的 GUI Control 發現新的事件欄位,例如,建立一個 Button,可以從 Inspector 視窗裡面的 On Click 欄位指定當按鈕被點擊時要去執行那一個 GameObject 上的哪個 Component 的功能,使得按鈕事件可以更視覺化、更彈性的編輯,當然,其他的 GUI Control 也都有類似的欄位可以做這樣的設置,而這個欄位則是由 UnityEngine.Events 底下的 UnityEvent 型別所產生的,我們自己所製作的 Component 同樣也可以提供這樣的欄位,使可以視覺化編輯,以及讓程式更加彈性。

在此影片使用兩個範例來示範作法,透過畫面操作以及語音的說明,相信可以更加了解到 UnityEngine.Events 在產生 Inspector 欄位的使用方法:




首先,簡單來說明一下 Unity 寫作程式的基礎,就是當我們在 Project 視窗建立我們自己的 Script 之後,將這個 Script 附加到 GameObject 之上,就會成為該 GameObject 的 Component,通常,變數欄位(Field)如果是使用 public 修飾字來宣告,而其型別是可序列化的話,在 Inspector 視窗就會產生可以設定其值的欄位,這是因為 Unity 本身預設會對 public 欄位自動序列化的關係。

public 的變數欄位會出現在 Inspector 視窗,可供編輯。
可是,如果 Class 裡所宣告的欄位並沒有要提供給外部存取的話,使用 public 修飾字就有些不恰當,所以,當使用 private 或 protected 等其他修飾字來宣告時,如果,希望該欄位變數也可以在 Inspector 視窗出現可編輯的欄位,可以在它的上一行插入 SerializeField 的 Attribute(特性),那麼 Unity 就知道這個欄位是要序列化使可以在 Inspector 視窗編輯。

有 SerializeField 的 private 變數欄位也會出現在 Inspector 視窗。

如果,要製作像是 UGUI 的 Button 那樣的事件欄位,最基本的就是要在程式檔最上方加個 using UnityEngine.Events,然後只要是使用 UnityEvent 為型別宣告的欄位,都會有個像是 Button 那樣的事件欄位。
UnityEvent 型別的變數欄位,在 Inspector 視窗會出現事件欄位。
 這個 UnityEvent 欄位在編輯器上的編輯相當彈性,當目標 Component 裡面含有使用 public 宣告的屬性(Property)或方法(Method),並且傳入值的型別為 bool、int、float、string 其中一種的話,就能直接在這裡選擇並提供傳入的值,而且可以設置多個不同型別的目標,並在之後程式執行時直接呼叫執行,另外,如果 Method 並沒有宣告傳入參數的話,在此也可以選擇設置,如果是宣告兩個以上的參數,在這邊的選單就不會出現了。

雖然 UnityEvent 欄位相當方便和彈性,不過,它所傳入的值是在編輯器上面設置的,而且只能設置一個值,如果,我們的 Component 中的程式想要直接提供參數值給目標的話,光靠 UnityEvent 就沒辦法做到,幸好,UnityEngine.Events 除了 UnityEvent 之外,還另外提供了四個泛型類給我們擴充使用。

我們在影片中的兩個範例可以看到,其中的 PassEvents Script 專門用來撰寫擴充 UnityEvent 泛型類的新事件型別,相關資料可以參考官方文件的 UnityEngine.Events 相關資料,例如 UnityEvent<T0>。總共有 UnityEvent<T0>UnityEvent<T0,T1>UnityEvent<T0,T1,T2>UnityEvent<T0,T1,T2,T3> 這四個可供擴充,其實,他們所具備的功能都是一樣的,只是,能接受的參數數量不同而已,也就是說,我們可以透過繼承這幾個泛型類來宣告我們自己需求的參數型別的 UnityEvent,而且可容納的參數數量可以有一到四個。例如:

[System.Serializable]
public class PassString : UnityEvent<string>{}

[System.Serializable]
public class PassColor : UnityEvent<Color>{}

因為,所宣告的 class 希望用於宣告欄位變數時,可以顯示於 Inspector 視窗,除了使用 SerializeField 標示在欄位上面之外,該型別也必須是可序列化的才行,所以,還要在 class 上方也加上 System.Serializable。

到這裡,我們就能夠使用自己定義的 UnityEvent 來宣告事件欄位,並使它可以顯示於 Inspector 視窗。



1. Simple Computer


第一個範例裡,主要是製作一個簡單的計算器來示範 UnityEngine.Events 的用法以及所帶來的好處;製作任何東西之前,一定要先了解到這個東西的使用目的以及功能有哪些,所以,我們先用簡單的 UI 排出一個運算式的樣子,這個運算式提供了兩個輸入欄位、一個運算符號的文字、一個顯示計算結果的文字,以及四個運算功能按鈕。


在此,我們先定義計算器的基本功能:
  • 點擊運算功能按鈕,會依照計算種類去改變運算符號的文字。
  • 點擊運算功能按鈕之後,在 UI 顯示計算結果文字。
功能相當簡單,所以,我們直接在 Project 視窗建立一個 C# Script 就差不多能處理全部的功能,先將此 Script 命名為 MyComputer。

由於,最後會將計算結果轉換為字串傳給 UI 去顯示,在這裡會使用到 UnityEvent 去傳遞資料,只是一般的 UnityEvent 並無法直接在程式呼叫時就帶入參數,所以我們還需要使用前面提到的做法來宣告可以帶字串參數的 UnityEvent<T0>,因為,宣告這個衍生類只需要寫一行,而且,將來還可能會宣告好幾個這種帶不同參數的 UnityEvent<T0>,所以,我們還要在 Project 視窗建立一個名為 PassEvents 的 C# Script,專門用來寫這些 Class 用,如果分類好,這支 Script 還可以直接拿到其他專案中使用。

在 PassEvents Script 裡面,首先,由於是要製作 UnityEngine.Events 的那些泛型類的衍生類,所以,最開頭一定要先寫一行 using UnityEngine.Events,接下來,在這個示範裡只會用到傳遞字串,所以,先只宣告一個能傳遞字串的 Class 就好。

using UnityEngine.Events;

[System.Serializable]
public class PassString : UnityEvent<string>{}

別忘了 System.Serializable,否則,在 Inspector 視窗會看不到欄位。

而在 MyComputer Script 之中,我們這個簡單計算器主要就是對兩個計算值計算出結果而已,配合 UI 的使用,所接收到的值原本會是字串,但我們在程式中卻必須做為數值來計算,所以,先宣告兩個僅限內部存取的欄位來暫存傳入的數值,這個數值的來源,則是要由 UGUI 的 InputField 的 Edit End 事件傳過來的,當然,我們在 MyComputer Script 裡面,並不用管那麼多,只要提供兩個 Property 讓外部可以把字串傳入就行了,管他是誰要傳進來的,只要提供入口,並且把傳入的字串轉為數值暫存下來,這樣其他計算功能就能計算了。

因為 MyComputer Script 的工作主要工作就是接收計算資料並計算出結果傳遞出去,所以,我們需要宣告每個計算結果的事件,來表達事情的發生,至於,最後會把結果傳給誰,其實,並不需要 MyComputer 去管。

目前,我們定義 MyComputer Script 只做加、減、乘、除四個計算功能,於是,就直接將獲得的數值計算出結果,並將結果轉為字串後,透過 Invoke 來呼叫執行個別對應的事件,那麼計算器的基本功能就算完成了。

private float _value1;
private float _value2;

[SerializeField]
private PassString onAdd;
[SerializeField]
private PassString onSubtract;
[SerializeField]
private PassString onMultiply;
[SerializeField]
private PassString onDivide;

public string value1{
    set{
        float.TryParse(value , out this._value1);
    }
}

public string value2{
    set{
        float.TryParse(value , out this._value2);
    }
}

public void Add(){

    this.onAdd.Invoke((this._value1 + this._value2).ToString());
}

public void Subtract(){

    this.onSubtract.Invoke((this._value1 - this._value2).ToString());
}

public void Multiply(){

    this.onMultiply.Invoke((this._value1 * this._value2).ToString());
}

public void Divide(){

    if(this._value2 == 0) return;

    this.onDivide.Invoke((this._value1 / this._value2).ToString());
}

因此,獲得以上的程式碼,其中,除法 Divide() 的部分要特別注意,因為,除法裡的除數不得為零,不然,會發生錯誤,所以計算之前要先判斷一下,如果除數為零,就不要執行後面的計算。

寫好 MyComputer Script 的內容之後,在 Unity 中,先建立一個空物件並且賦予它 MyComputer 成為它的 Component,這時候,可以很明顯地從 Inspector 視窗看到分別為加、減、乘、除的事件欄位。
MyConputer 出現加、減、乘、除的事件欄位。
接下來就可以在 UI 以及 MyComputer 間設置彼此之間的關係,首先,需要讓 UI 輸入的值可以傳遞給 MyComputer,所以,要從兩個 InputField 中的 End Edit 事件分別設置將輸入的字串傳給 MyComputer 的 value1 以及 value2 兩個 Property。

Value1 Field 輸入的字串傳給 MyComputer 的 value1

Value2 Field 輸入的字串傳給 MyComputer 的 value2

在這裡可以發現這個 End Edit 其實也是跟我們所宣告的 PassString 一樣的東西,設置好之後,不需要在 Inspector 視窗上設置傳入什麼字串,因為,這個 End Edit 事件是當 InputField 中的文字輸入完畢後,按下 Enter 或者點擊 UI 文字欄位外的地方,使它不能被輸入文字時,而視為文字輸入完畢時被呼叫執行的,當他被呼叫執行時,就會將其輸入的文字透過這個事件而傳遞出去,所以,這裡設置完畢之後,只要 UI 上的文字欄位輸入完畢後,都會將所輸入的內容傳遞給 MyComputer,而 MyComputer 則如我們程式中所寫的一樣,將收到的字串轉為數值暫存下來。

接下來,就是個別選擇 UI 上分別代表加、減、乘、除功能的按鈕,並在它們的 On Click 事件欄位設置執行 MyComputer 裡面相對應的計算功能。

Button + 的 On Click 事件執行 MyComputer 的 Add()

Button - 的 On Click 事件執行 MyComputer 的 Subtract()

Button x 的 On Click 事件執行 MyComputer 的 Multiply()

Button / 的 On Click 事件執行 MyComputer 的 Divide()

而這個 On Click 事件,相信大家都非常熟悉,就是按鈕被點擊時觸發執行。正確來說,應該是點擊按鈕之後,並在同一個按鈕之內放開按鈕才會執行。

到這裡,UI 已可將輸入的資料提供給 MyComputer,並將需要執行的功能要求 MyComputer 去進行,然後,我們就要來為 MyComputer 設置它的加、減、乘、除事件,使得 UI 可以顯示出結果。

回想一下我們前面寫的計算器主要功能,第一步,要讓計算符號改變,所以,每個計算事件,都要設置事件發生時去改變 UI 上的計算符號文字,在這裡,雖然 MyComputer 的計算事件預設是會由程式裡面傳遞出結果,但 UnityEvent 欄位本身就能直接傳入靜態參數,所以,我們的程式碼裡面不用為那些計算功能撰寫有關改變運算符號的部分,只要直接在 Inspector 視窗上面設置就行了。

第二步,則是要讓 UI 上顯示出計算結果,由於 MyComputer 程式碼裡面直接將計算結果的字串傳給了個別對應的事件,所以,直接為每個事件設置他們的目標是 UI 上顯示結果的 Text 即可。

每個計算功能事件讓 UI 變更運算符號以及顯示計算結果
這樣子,每當 MyComputer 執行計算時,就會把字串傳遞給 UI 顯示出來。

計算結果的畫面

現在,計算器需求的功能齊全了,我們可能還想要做些更細微的調整,例如,在輸入的值未改變的情況下,已經執行過的計算的,就不想要再次計算,所以,要將該功能按鈕關閉。在 UGUI 的 Button 中,有一個 Interactable 欄位,是用來管理使用者與按鈕之間的互動開關,當它被關閉時,Button 就會失去作用而呈現較暗的顏色,所以,我們在這邊可以制訂一個功能是當計算結果出來之後,把所對應的功能按鈕上的 Interactable 關閉,而這個功能完全不用去修改程式碼,只要再次去為每個計算功能的事件欄位加入執行目標即可。

計算結果之後關閉按鈕。

計算過的按鈕都關閉了。

雖然,按鈕在計算之後可以被關閉了,可是,如果輸入數值的欄位內容被改變了,應該還要有可以重新計算的機會,所以,需要有個能讓按鈕被重新打開的功能,這時候,其實我們可以直接在兩個輸入資料的 InputField 的 End Edit 事件對每個按鈕執行開啟 Interactable 的動作,只是,如果計算器有很多個功能按鈕、以及有很多個輸入欄位的話,要一個一個慢慢設置,執行流程會太過分散了。

所以,我們可以想像成 MyComputer 除了負責計算之外,還提供一個狀態重置的功能,這個狀態重置的功能本身並不執行任何事情,只是呼叫執行狀態重置事件,那麼,設置在這個事件上的目標功能,只要狀態重置的功能被呼叫執行,它們都會被執行,所以,我們在 MyComputer Script 要再加上以下的程式碼:

[SerializeField]
private UnityEvent onResetStatus;

public void ResetStatus(){

    this.onResetStatus.Invoke();
}

而在 Unity 編輯器中,我們就可以讓 MyComputer 的狀態重置事件(On Reset Status)欄位,設置開啟四個功能按鈕的 Intractable。

狀態重置時,再次啟用按鈕。

如此,兩個 InputField 的 End Edit 事件則是指定執行 MyComputer 的狀態重置功能即可。

End Edit 事件指定執行 MyComputer 的 ResetStatus()。

雖然,影片中狀態重置事件是讓按鈕重新啟用,但到這邊也可以任意變更狀態重置事件所要執行的動作,例如,讓計算結果文字變成問號,那麼,當每次輸入欄位重新被輸入完畢之後,不但被停用的按鈕會重新啟用,也會使結果文字變成問號,等按下功能按鈕之後才顯示正確的結果。

既然有了狀態重置的功能,那麼,我們是不是可以只讓當前計算出結果的按鈕被停用,其它按鈕是啟用的狀態呢?這樣就不用一定要重新在輸入欄位輸入資料完畢才能啟用按鈕。在做這個功能之前,要先解釋一下,每個事件欄位可以指定多個執行目標,而這些執行目標其實是有執行順序的,它會在執行完第一個之後才會執行第二個,一個一個往下執行。

因此,我們要做這個功能就不需要去修改程式碼,而能直接變更各計算功能的事件內容以及順序,讓每次執行計算之後先執行狀態重置,然後才顯示計算結果、停用當前的計算按鈕、變更運算符號。

更改為先執行狀態重置之後,才去執行其他行為。
到這裡,簡單的計算器功能將告一段落,我們可以發現 MyComputer Script 的程式碼相當的獨立,只是提供傳入資料的入口讓傳入的資料可以暫存下來,以及提供計算功能給外部執行,並將執行結果丟到事件中,至於,誰會傳入資料、誰會執行計算功能、誰會回應事件的執行,通通不需要去管,因此,如果全部的事件欄位都未設置目標,當被要求執行計算時,它也能照常執行計算,而不會因為未設置目標而發生錯誤,而究竟事件目標應該是誰,基本上,MyComputer 也不需要知道,一切就在 Inspector 視窗上,依照實際需求去設置或變更即可。

狀態重置的部分也是一樣,MyComputer Script 只負責提供狀態重置功能並執行狀態重置事件,至於,誰要求狀態重置、狀態重置到底要做些什麼,都不用去管。

這樣,就可以讓程式撰寫變得相當簡潔,而實際使用上的彈性變化卻非常的大,另外,因為一些需求功能的變更不需要依賴修改程式碼達成,除了節省程式編譯的時間之外,也可避免因為修改程式碼的人為失誤而發生錯誤,最重要的是,因為 MyComputer Script 根本就不需要知道誰要執行它的功能,以及它的事件最後會去執行什麼目標功能,所以,也使程式間的耦合度降到最低,如果將這種做法的 Script 移到其它專案中使用,也不用特別顧慮會與其它什麼程式有關聯而發生許多缺漏的情形。

以下是 MyComputer.cs 的完整內容:

using UnityEngine;
using UnityEngine.Events;

public class MyComputer : MonoBehaviour {

    private float _value1;
    private float _value2;

    [SerializeField]
    private PassString onAdd;
    [SerializeField]
    private PassString onSubtract;
    [SerializeField]
    private PassString onMultiply;
    [SerializeField]
    private PassString onDivide;
    [SerializeField]
    private UnityEvent onResetStatus;

    public string value1{
        set{
            float.TryParse(value , out this._value1);
        }
    }

    public string value2{
        set{
            float.TryParse(value , out this._value2);
        }
    }

    public void Add(){

        this.onAdd.Invoke((this._value1 + this._value2).ToString());
    }

    public void Subtract(){

        this.onSubtract.Invoke((this._value1 - this._value2).ToString());
    }

    public void Multiply(){

        this.onMultiply.Invoke((this._value1 * this._value2).ToString());
    }

    public void Divide(){
        if(this._value2 == 0) return;
        this.onDivide.Invoke((this._value1 / this._value2).ToString());
    }

    public void ResetStatus(){

        this.onResetStatus.Invoke();
    }
}

2. Sphere Control


第二個範例裡,設計了五個球體個別使用相同的 Componet,但卻因為實際使用時設置的不同,直接反應出不同的行為,並且示範如何讓 UnityEvent 除了傳遞參數之外,也能帶回資料。

首先,在場景中建立好五個球體,這只是 Unity 預設的原生物件,預設上,Unity 會個別為它們賦予 Sphere Collider,以及預設的 Material,Material 的 Shader 則是 Unity 5 的 Standand Shader,這部分,我們都不需要做任何更改。

原生物件球體的 Componet。


在此,定義了幾個球體的功能需求:
  • 球體可以被點擊觸發。
  • 球體可以彈跳起來。
  • 球體可以變色。
依據這幾個需求,先在 Project 視窗分別建立它們個別的 C# Script,並個別命名為 SphereTouch、SphereJump、SphereDiscolor。

先來寫 SphereTouch 的程式碼,SphereTouch 只提供讓使用者透過滑鼠點擊球體時的事件反應而已,所以,SphereTouch 會有一個 UnityEvent 事件欄位,以及實作 Unity 內建的 OnMouseDown,只要 GameObject 本身有 Collider 的 Componet,當滑鼠在 GameObject 上按下按鍵時,就可以觸發 OnMouseDown 執行其內容,而其內容裡,只是直接呼叫 UnityEvent 事件欄位執行而已,至於點擊之後要反應什麼行為,那是別人的事。

using UnityEngine;
using UnityEngine.Events;

public class SphereTouch : MonoBehaviour {

    [SerializeField]
    private UnityEvent onTouch;

    public void DoTouch(){

        this.onTouch.Invoke();
    }

    void OnMouseDown(){

        this.DoTouch();
    }
}

看到這個程式碼可能覺得奇怪,為什麼不在 OnMouseDown 裡面直接 Invoke 呢?其實,這只是要讓 SphereTouch 多提供一個外部功能,讓其他物件也可以傳達點擊訊息進來,例如,A 物件被滑鼠點擊,然後它的 On Touch 事件欄位設置去執行 B、C、D 物件的 DoTouch 功能,那麼就可以一個物件被點擊,四個物件同時做出反應。

再來是 SphereJump 的程式碼,讓球體跳動的行為可以有很多做法,例如使用 Unity 的動畫系統去做,或者給予物體一個往上的推力,再讓它因為重力而落下,不過,這邊為了簡化操作步驟,直接用程式碼讓它靠移動位置來達到跳動的效果,而這個跳動的動作,說穿了就是從原位置移動到一個指定高度的位置,再移動回來原來的位置,至於,要跳多高、移動速度多快,預先並不確定,所以,首先需要宣告兩個可以在 Inspector 視窗設置的數值欄位,讓我們可以在編輯器調整目標高度及跳動速度。

[SerializeField]
private float hight = 1;
[SerializeField]
private float speed = 5;

另外,在跳動的行為進行中,如果馬上又被要求跳動,就會跳到一半又往上跳,這樣的行為是有問題的,所以必須要設置一個狀態紀錄,當動作進行中,不接受再次的跳動要求,等到動作完畢之後才能再次接受並執行要求的行為。

private enum Status{

    None,
    Moving
}

private Status _status = Status.None;

因為我們這裡要做的跳動行為其實是兩個移動行為的組合,所以要先寫個可以提供物體本身從起點移動到終點的功能,兩點間的移動,直接透過 Unity 內建的 Vector3.Lerp 就可以達成。

private IEnumerator Move(Vector3 source , Vector3 target){

    float t = 0;

    while(t < 1){

        transform.position = Vector3.Lerp(source , target , t);
        t += Time.deltaTime * this.speed;

        yield return null;
    }

    transform.position = target;
}

Vector3.Lerp 的 t 是介於 0 到 1 的值,我們可以把它當作是起點到終點的進度位置來看待,0 是起點,1 是終點。因為實際執行時每次刷新畫面的時間都不一樣,所以,我們不能讓 t 隨著時間的推移增加固定的值,而應該要為我們預計增加的值(即是速度)乘上 Time.deltaTime,於是,我們在這裡透過 yield return null 讓 while 迴圈每經過一個 frame 才執行一次,當 t 超過 1 的時候,表示已經到達終點,即可結束迴圈。

要完成移動還有最後的步驟,就是 Vector3.Lerp 最後一次執行時,不可能剛剛好 t = 1,所以,最後還要校正一下位置到正確的終點位置,如此,移動行為才算是真正完成。

在這裡要特別注意的是,這個 Method 所回傳的是 IEnumerator,代表它是做為 Coroutine 來使用,所以才可以在其內部使用 yield 來控制一些流程和時間,而要呼叫這個 Method 執行則需要使用 StartCoroutine 來執行。

接下來要做跳的動作,就是跳動開始時,變更狀態為移動中,然後,取得起點和終點的位置,先執行起點移動到終點,執行完之後,再執行終點移動到起點的行為,等待動作完成之後,跳動就結束了,所以,就可以再將狀態改回 None 的狀態。

private IEnumerator DoJump(){

    this._status = Status.Moving;

    Vector3 source = transform.position;
    Vector3 target = source;
    target.y += this.hight;

    yield return StartCoroutine(this.Move(source , target));
    yield return StartCoroutine(this.Move(target , source));

    this._status = Status.None;
}

既然跳動行為已經寫好,那麼就要提供一個讓外部呼叫的功能,當被外部呼叫執行時,先判斷有沒有在跳動中,沒有的話就執行跳動。

public void Jump(){

    if(this._status == Status.None) StartCoroutine(this.DoJump());
}

如此,SphereJump 就算完成了,它並沒有使用到 UnityEvent 事件,主要負責被呼叫執行動作而已,當然,視需求還是可以另外幫它補上基本事件,例如,開始跳動、跳動中、跳動結束。不過,這個示範中並沒有用到,所以在此省略。

以下為 SphereJump.cs 的內容:
using UnityEngine;
using System.Collections;

public class SphereJump : MonoBehaviour {

    private enum Status{

        None,
        Moving
    }

    [SerializeField]
    private float hight = 1;
    [SerializeField]
    private float speed = 5;

    private Status _status = Status.None;

    public void Jump(){

        if(this._status == Status.None) StartCoroutine(this.DoJump());
    }

    private IEnumerator Move(Vector3 source , Vector3 target){

        float t = 0;

        while(t < 1){

            transform.position = Vector3.Lerp(source , target , t);
            t += Time.deltaTime * this.speed;

            yield return null;
        }

        transform.position = target;
    }

    private IEnumerator DoJump(){

        this._status = Status.Moving;

        Vector3 source = transform.position;
        Vector3 target = source;
        target.y += this.hight;

        yield return StartCoroutine(this.Move(source , target));
        yield return StartCoroutine(this.Move(target , source));

        this._status = Status.None;
    }
}

在撰寫 SphereDiscolor 之前,我們要先回到 PassEvents Script 檔裡面,宣告一個能夠用來傳遞顏色參數的 UnityEvent<T0>

[System.Serializable]
public class PassColor : UnityEvent<Color>{}

在 SphereDiscolor 中,先宣告用來暫存 Material 以及 Material 的顏色的兩個變數欄位,然後,也宣告一個用來設置球體預設顏色的欄位,在 Awake 中取得球體本身的 Material 暫存下來,並使用所設置的預設顏色改變球體的顏色。

private Material _material;
private Color _color;

[SerializeField]
private Color color = Color.white;

void Awake(){

    this._material = GetComponent<Renderer>().material;
    this.DefaultColor();
}

public void DefaultColor(){

    this._material.color = this.color;
    this._color = this.color;
}

同樣的,改變為預設顏色的功能,也是獨立做為可提供外部呼叫執行的功能。

然後,改變球體顏色的功能,我們分別宣告了直接改變為指定顏色的功能以及改變為隨機顏色的功能,都是可提供給外部呼叫執行。

在此,也宣告了一個可以傳遞顏色值的 UnityEvent<T0> 事件欄位,當顏色被改變的時候,事件就會被執行,並且將所改變的顏色傳遞出去,所以,當球體顏色被改變時,可以讓它引發其它行為,甚至是提供一個顏色去影響被引發的行為。

[SerializeField]
private PassColor onChangeColor;

void Awake(){

    this._material = GetComponent<Renderer>().material;
    this.DefaultColor();
}

public void DefaultColor(){

    this._material.color = this.color;
    this._color = this.color;
}

public void Discolor(Color color){

    this._material.color = color;
    this._color = color;
    this.onChangeColor.Invoke(color);
}

public void RandomColor(){

    this.Discolor(new Color(Random.value , Random.value , Random.value));
}

到這裡,這個範例的程式撰寫部分暫告一段落,回到 Unity 畫面,為每個球體加入這三個 Script 做為 Component。

在這裡可能會有疑問,為什麼要分成三個 Script,而不寫在同一個 Script 中呢?因為,Unity 的遊戲物件所具備的功能是 Component 導向的,具備哪些 Component 就能擁有什麼樣的功能,拔除哪個 Component,該遊戲物件就不具備哪個功能,所以,我們將功能分開來獨立不同的 Script 來撰寫,每個 Script 只要提供本身的功能就好了,不要去牽扯到其他的功能,那麼,當球體擁有 SphereTouch Component 時,他就可被點擊,沒有的話就無法被點擊,當球體擁有 SphereJump 時,它就具備跳動的功能,這樣,我們就能明確的為遊戲物件抽換並改變其能力。

所以,當每個球體都具備變色功能時,變色功能有個預設顏色欄位會將球體於 Play Mode 開始時變更為其所指定的初始顏色。

接著,當每個球體都具有被觸發功能時,我們就能為其指定當球體被觸發時要引發什麼行為,如影片所示,我們可以為每個球體指定被點擊觸發時,去要求它的下一顆球跳起來,要求它的前一顆球隨機改變顏色。

第 2 顆球設置被點擊時,第三顆球跳起來,第一顆球隨機改變顏色。
而最後一顆球被點擊觸發時,則是讓前四顆球回到初始顏色。

第五顆球被點擊時,前四顆球改變成初始顏色。
然後,我們也可以為第二顆球設置當它顏色被改變時,影響其他顆球的顏色。

第二顆球變色的時候,讓第一顆球和第五顆球改變為隨機的顏色。
在此,我們再次體驗到,單純寫好 Script 提供的功能,不用在程式碼中指定它人的行為,而能在編輯器中自由變更的好處以及功能彈性。

雖然,UnityEvent 可以直接在 Inspector 視窗指定一個固定值傳遞給某個 Component 的功能並執行它,也可以透過程式碼使用 Invoke 呼叫執行並傳遞參數進去,但是,它卻無法像我們平常呼叫執行一般的 Method 那樣回傳資料,似乎有點美中不足之感。

接下來,我們就來討論如何也讓 UnityEvent 帶回資料,其實,這主要就是利用參考型別物件在參數間傳送並不是傳送實值的原理,來達到帶回資料的目的。也就是說,我們可以透過實例化一個參考型別物件,傳入 UnityEvent 的參數中,當這個物件內含的資料在 UnityEvent 所執行的功能中有任何變更後,在呼叫執行 UnityEvent 事件的這一方也可以從原本的物件獲得改變後的資料。

只是,為了帶回不同的型別的資料而宣告許多不同的 Class,似乎太麻煩了,因此,我們最好是可以製作一個通用的 Class,專門用來做為傳遞資料的持有者物件。所以,可以建立了一個名為 PassHolder 的 C# Script,專門用來做這件事。

public class PassHolder {

    public object value{set; private get;}

    public T GetValue<T>(){

        if(this.value == null) return default(T);

        return (T)this.value;
    }
}

因為,所有的型別都是直接或間接繼承自 Object(這裡所指的並不是 Unity 的 Object),所以,宣告一個 Property 統一接收任何型別的物件,然後,利用泛型方法的宣告方式,來取出所儲存的資料,這裡只是很簡單的判斷有沒有資料,如果沒有資料則傳回預計要取得的型別預設值。如此,這個 class 的物件就能通用存取任何型別的資料。

有了這樣的 class,那麼就可以拿 SphereDiscolor 來實驗看看;先來為 SphereDiscolor 加入交換顏色的功能。當然,要宣告能夠傳遞 PassHolder 的 UnityEvent 事件欄位之前,還是要先回到 PassEvents 的 Script 檔中,加入宣告可傳遞顏色以及 PassHolder 兩個參數的型別。

[System.Serializable]
public class PassColorReturn : UnityEvent<Color , PassHolder>{}

這個功能要做的是,當它被呼叫執行時會透過交換顏色事件把自己的顏色傳送出去,並透過 PassHolder 帶回對方的顏色來改變自己的顏色。

而我們目前的範例裡,能被改變顏色的就屬 SphereDiscolor 了,所以,再幫它加入另一個改變顏色的功能是除了接受目標顏色之外,還要能接收 PassHolder 物件,因此,除了拿接收到的顏色為自己變色之外,也要把原本的顏色寫入到 PassHolder 中,那麼呼叫執行這個變色功能的彼方,也就能收到所要帶回去的顏色值。

[SerializeField]
private PassColorReturn onSwapColor;

public void SwapColor(){

    PassHolder holder = new PassHolder();
    this.onSwapColor.Invoke(this._color , holder);
    this.Discolor(holder.GetValue<Color>());
}

public void Discolor(Color color , PassHolder holder){

    holder.value = this._color;
    this.Discolor(color);
}

完成之後,我們在 Unity 編輯器的 Inspector 視窗中,就可以直接在 On Swap Color 的事件欄位指定要跟哪顆球交換顏色。

當第四顆球被點擊時,使第五顆球跳起來、第三顆球改變顏色、要求自己執行交換顏色並指明與第一顆球交換顏色。

如此,幾支程式、簡短的程式碼,只是定義它們本身的功能,以及什麼時候呼叫該執行的事件,而不需要特別指定所影響的型別及功能是什麼,而能在 Inspector 視窗中,很彈性的為具備同樣 Component 的 GameObject 配置出完全不同的行為。也不會讓程式中因為型別不對或參數數量不符合而出現錯誤,使得程式執行及設計上變得更彈性、更穩定,也讓程式碼內容變得更簡潔,邏輯更清晰。在維護上以及流程調整上也會變得更視覺化、更清楚一些。

過去曾經發佈「Unity:使用 UGUI 的 ScrollRect 製作虛擬搖桿」的文章影片,其中為虛擬搖桿傳遞操作行為的事件就是 UnityEngine.Events 的應用,善用這些做法,所寫的程式復用性以及擴充性將會大大提高。

好了,有關 UnityEngine.Events 的說明及示範解說,到此結束,如果,你喜歡這個文章或所展示的影片,請幫忙介紹給你的朋友,也別忘了訂閱影片頻道以及來粉絲專頁按個讚,謝謝!

目前版本 Unity 5.2.1f1

範例下載:
https://onedrive.live.com/redir?resid=D13D8A29206AFE69!263&authkey=!AMLBuBYFzZsVtOg&ithint=file%2czip