2015年7月19日 星期日

Unity:使用 UGUI 的 ScrollRect 製作虛擬搖桿

Unity 的新 GUI 系統(UGUI)提供很多內建的 UI 元件可以直接視覺化的建立、編輯 UI,可惜並沒有提供虛擬搖桿的元件,幸好,UGUI 的 ScrollRect Component 不只可以用於 Scroll View 的製作,同時,因為能計算內容物的 X 軸與 Y 軸的偏移,所以,也能用於製作虛擬搖桿。



以上影片示範使用 ScrollRect 來製作虛擬搖桿的 UI,以及為其撰寫專用的 Component,使其在實際使用時可以直接透過編輯介面設置事件,將操作結果傳遞給控制目標物件,隨後,影片中也展示如何使用虛擬搖桿控制物體移動以及讓 Animator 配合展現各種方向的滾動動作。

首先,先製作虛擬搖桿的外觀 UI,需要先準備至少兩張圖片,並為其設置為 Sprite 以供 UGUI 使用,第一張圖片用於製作虛擬搖桿的底座,第二張圖用於製作虛擬搖桿的操作桿部分,我們在此將它們分別命名為 Joystick 及 Stick。

接下來在場景中建立兩個 GUI 的 Image,可以直接在 Hierarchy 視窗的 Create 選單選擇 UI > Image 或是選擇選單列的 GameObject > UI > Image 來建立,剛開始由於場景中並無任何 Canvas 物件,所以初次建立 Image 時,會自動建立 Canvas 以及 EventSystem 兩個 GameObject,EventSystem 主要是用於管理事件的,如果沒有特殊需求則不需要特意去變更其設定值,而 Canvas 則是用於放置 GUI 元件的,所以在此建立的兩個 Image 都會被放置在自動建立的 Canvas 之下做為其子物件。

在 Hierarchy 或 Scene 視窗選擇建立好的 Image 並分別在 Inspector 設置好 Source Image 欄位內容為前面準備好的圖片,將 Image 的名稱變更為與圖片同名的 Joystick 及 Stick,然後,在 Inspector 調整 Stick 的 Width 及 Hight 欄位,使 Stick 看起來比 Joystick 小;在 Hierarchy 視窗將 Stick 拖曳至 Joystick 使其成為 Joystick 的子物件,然後,將 Stick 的座標歸零,如此,代表虛擬搖桿操作桿的 Stick GameObject 就會始終放置於 Joystick 的正中央位置,也才能使 Stick 的本地座標相對於 Joystick 的世界座標,這部分,後面會用到。

第一個 Image 設置 Source Image 為 Joystick 圖片,並更改 GameObject  名稱
第二個 Image 設置 Source Image 為 Stick 圖片,並更改  GameObject 名稱及座標歸零
Stick 做為 Joystick 的子物件
製作好虛擬搖桿的外觀之後,就要讓中間操作桿 Stick 能夠操作,所以,接下來要在 Hierarchy 視窗再次選擇 Joystick,並直接選擇選單列的 Component > UI > Sroll Rect 或是點擊 Inspector 視窗的 Add Component 按鈕選擇 UI > Sroll Rect 為其加入 SrollRect Component,Joystick 被加入 ScrollRect Component 之後,在 Inspector 視窗將 Content 欄位設置為其子物件 Stick 即可。

SrollRect Component 之所以能用於製作虛擬搖桿主要是依賴其 Movement Type 欄位,將此欄位設置為 Elastic,當開始執行時會自動將設置在 Content 欄位的物件拉回置中,此時如果拖動該物件,會有一定程度的緩衝力量使該物件不會被拉遠,並在放開拖動時自動彈回置中,這個功能剛好適合用來做虛擬搖桿的操作行為,由於,ScrollRect Component 的 Movement Type 欄位的預設值是 Elastic,所以,影片中並無特別做設置這個欄位的動作,當然,基本上沒有特殊需求,此部份除了設置 Content 欄位之外,其他欄位都保持預設值即可。

Content 設置為 Stick,Movement Type 為 Elastic
虛擬搖桿外觀以及操作桿的動作完成之後,我們還需要讓操作的結果傳遞出去,才能使虛擬搖桿的操作是有意義的,所以我們需要另外寫個 Script 來做為虛擬搖桿的 Component;接下來,直接在 Project 視窗建立一個 C# Script 並命名為 FixedJoystickHandler。



這個 FixedJoystickHandler Script 主要功能是要能接收到 Stick 的移動,並讓移動量能夠傳遞出去,這裏就需要用到一些 Unity 事件系統相關的東西,所以在程式碼的一開始需要先變更一下 using 的部分,Unity 建立的 C# 檔案預設會有兩個 using,UnityEngine 主要是有使用到 Runtime API 的話就必須要有,而 System.Collections 則是通常有使用 Coroutine 才會用到,所以在此可以將 System.Collections 刪除,因為希望可以在 Inspector 視窗設置事件將資訊傳遞出去,會使用到 UnityEvent 類,因此,在此要添加一行 using UnityEngine.Events;而為了在拖動時能獲取 Stick 的移動量,需要在拖動時偵測到本身的拖動事件,那就需要實作一些與 Drag 相關的 Interface,所以,還要再添加一行 using UnityEngine.EventSystems。

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;

在操作虛擬搖桿時,主要會需要偵測三個相關的事件並通知控制目標做出相對應的行為,即是「開始控制操作桿、控制操作桿中、控制操作桿結束」,也就是說,假設控制目標是人物角色,平常站在原地不移動時的動畫播放狀態是在閒置中(Idle),當開始控制 Stick 時,需要通知該角色的 Animator 開始移動了,讓 Animator 的動畫狀態切換到走路動畫,然後,控制操作桿中的事件不斷地通知角色最新的控制方向,讓角色知道應該往哪個方向移動,最後放開操作桿停止控制時,則需要通知角色的 Animator 停止移動了,讓 Animator 的動畫狀態再次切換回閒置中的動畫播放,因此,在此需要實作三個 EventSystems 所定義的 Interface:IDragHandlerIBeginDragHandlerIEndDragHandler,所以我們在原本的程式碼 class FixedJoystickHandler : MonoBehaviour 這一行後面補上宣告將會實作 IDragHandlerIBeginDragHandlerIEndDragHandler 這三個 Interface。

public class FixedJoystickHandler : MonoBehaviour , IDragHandler , IEndDragHandler , IBeginDragHandler{

}

為了使虛擬搖桿能很方便的應用於各種不同的操作目標,最好是可以做到像 UGUI 的 Button 那樣,在 Inspector 視窗有個 Click 事件欄位,直接去設置當發生 Click 時會對哪個物件執行哪個功能,所以,會需要宣告 UnityEvent 欄位來使 Inspector 視窗能夠出現事件設置欄位,可是,因為預期在拖動中要將目前操作搖桿的結果告知被控制的目標物件,而這個結果即是搖桿被移動的座標偏移,UnityEvent 欄位並沒有辦法傳遞像是 Vector3 這種型別的參數,所以,需要先自行宣告一個繼承 UnityEvent<Vector3> 的 class 以供該事件欄位宣告時使用,而為了要使得這個欄位可以正確顯示於 Inspector 視窗上,該 class 還要標記使用 System.Serializable 這個 Attribute。

[System.Serializable]
public class VirtualJoystickEvent : UnityEvent<Vector3>{}

接下來,我們就可使用 UnityEvent 以及剛剛宣告的 VirtualJoystickEvent 來宣告幾個事件欄位分別代表開始控制、控制中、結束控制。
由於,controlling 事件主要是要傳遞 Stick 位移資訊,所以我們必須要先能夠取得它的參照,以方便後續的程式碼使用它,取得參考到該物件的方法有很多,不過這邊採用的是宣告個 Transform 欄位,實際使用時,直接在 Unity 編輯器中設置好該欄位,使其獲得參照,這樣就不用在執行期還要執行程式碼才去取得。

public Transform content;
public UnityEvent beginControl;
public VirtualJoystickEvent controlling;
public UnityEvent endControl;

接下來就要開始實作「開始控制」的行為,這邊為了實作 IBeginDragHandler Interface,所以直接宣告 OnBeginDrag 方法,它會在虛擬搖桿開始被拖動的時候執行,而執行內容則是執行 beginControl 事件。

public void OnBeginDrag(PointerEventData eventData){

       this.beginControl.Invoke();
}

然後,撰寫「控制中」的行為,為了實作 IDragHandler Interface,所以直接宣告 OnDrag 方法,它會在虛擬搖桿被拖動期間不斷地執行,而執行內容要先確認是否有參考到所設置的操作桿,有設置的話才執行 controlling 事件。

與 beginControl 事件不同的是,controlling 必須傳遞操作結果,而這個操作結果即是操作桿的偏移量與方向,因為 Stick 是 Joystick 的子物件,所以在還未操作的情況下,它的本地座標 localPosition 是 (0 , 0 , 0),所以當他被移動時,我們可以知道 x 大於 0 就是往右移動,小於 0 是往左移動,而 y 大於 0 是往上移動,小於 0 是往下移動,但是,因應不同環境所製作的虛擬搖桿大小尺寸會不同,中間的操作桿所移動的座標值大小變化程度也會不同,所以,如果直接將這個移動量傳遞給 controlling 事件的話,目標物件的行為將可能會因為虛擬搖桿的大小尺寸不同而有不同量的影響,這樣對於接收到該數值的 Script 將會造成一些困擾,所以,還需要將這個移動量歸一化,使其所傳遞的 x 與 y 值總是介於 -1 到 1 之間,那麼不管未來虛擬搖桿的尺寸如何改變,被控制物件所接收到的值都會是相同的。

public void OnDrag(PointerEventData eventData){

       if(this.content){
               this.controlling.Invoke(this.content.localPosition.normalized);
       }
}

最後,與前面相同,實作「結束控制」的行為,為了實作 IEndDragHandler Interface,所以直接宣告 OnEndDrag 方法,它會在虛擬搖桿結束拖動的時候執行,而執行內容則是執行 endControl 事件。

public void OnEndDrag(PointerEventData eventData){

       this.endControl.Invoke();
}

以下即是整個 FixedJoystickHandler Script 的程式碼全文:

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;

public class FixedJoystickHandler : MonoBehaviour , IDragHandler , IEndDragHandler , IBeginDragHandler{

       [System.Serializable]
       public class VirtualJoystickEvent : UnityEvent<Vector3>{}

       public Transform content;
       public UnityEvent beginControl;
       public VirtualJoystickEvent controlling;
       public UnityEvent endControl;

       public void OnBeginDrag(PointerEventData eventData){

               this.beginControl.Invoke();
       }

       public void OnDrag(PointerEventData eventData){

              if(this.content){

                    this.controlling.Invoke(this.content.localPosition.normalized);
              }
       }

       public void OnEndDrag(PointerEventData eventData){

               this.endControl.Invoke();
       }
}

完成此 Script 之後將它附加到 Joystick GameObject 之上做為其 Component,只要再將 Content 欄位設置為 Stick GameObject,虛擬搖桿就算製作完成了。

將 FixedJoystickHandler Script 掛到 Joystick 上,並設治好 Content 欄位
完成虛擬搖桿以及附加其 Component 之後,將這個 Joystick GameObject 拖曳到 Project 視窗建立 Prefab,以後要使用時,只要將其拉出到 Hierarchy 視窗放置於 Canvas GameObject 之下即可使用。

被控制的物件 Script 裡,需要宣告兩個無參數的方法用於虛擬搖桿的 beginControl、endControl 事件,以及一個帶有 Vector3 參數的方法用於接收搖桿控制的變動值,之後,只要直接在 Unity 的編輯介面上設置到虛擬搖桿的事件設置區,即可完整的接收到虛擬搖桿的控制事件及資訊,這樣搖桿就能夠操作被控制的物件了。

詳細的實作做法,可以參考影片後半段的範例,範例中設置好管理 Cube 滾動的 Animator 之後,撰寫一個負責控制的 Script,其中並宣告 BeginMove、EndMove、UpdateDirection(Vector3 direction) 這三個 public 方法,用於在編輯器上設置到虛擬搖桿的事件區,使它們能被虛擬搖桿的事件所影響,那麼就能順利的讓虛擬搖桿來控制 Cube 滾動了。

將 Cube 的 Script 所宣告的方法設置到 FixedJoystickHnadler 的事件區
其實,這個虛擬搖桿的做法只適用於固定位置的虛擬搖桿,因為,UGUI 的事件是經由 Graphic Raycaster 去觸發的,所以,如果將虛擬搖桿的 Image Component 關掉或移除,就無法觸發事件進行操作,所以,如果想做為浮動式的虛擬搖桿,也就是那種原本不顯示在畫面上,只有當手指點擊螢幕時才顯示出來並放置在點擊位置上的虛擬搖桿,這裏的做法是無法實現的,即使我們能用幾行程式碼找出手指點擊的位置,並開關 GameObject 或是 Image 使搖桿能依照手指位置顯示出來,但因為無法觸發事件系統,所以無法對其操作。至於浮動式的虛擬搖桿該如何製作,就留待將來有機會再討論吧!

範例專案下載

目前版本 Unity 5.1.1f1。


2015/12/08 補充:
這裡所製作的虛擬搖桿是類似 UGUI 的 Button 那樣,當相關事件產生時,去執行某個程式的功能,所以,不管如何,還是需要有程式碼提供功能給它,這種做法的目的是使虛擬搖桿能夠較為獨立,擁有較高可攜性,彈性運用於較廣泛的需求,而不只是用於控制移動或綁定於特定物件、程式上而已,影片後半段是以控制 Cube 移動來舉例,主要在表現如何與虛擬搖桿的事件銜接,其控制目的主要還是取決於每個專案的需求而有所不同。

2016/03/18 補充:
因為使用 UGUI 的 ScrollRect 來製作,所以搖桿的搖動區域是矩形的。
感謝網友 宣雨松 於 YouTube 影片留言提供代碼,直接以繼承 ScrollRect 的方式,將搖桿的搖動區域改為圓形的。

using UnityEngine.UI;
using UnityEngine.EventSystems;

public class ScrollCircle : ScrollRect {

        protected float mRadius;

        protected override void Start (){

                this.mRadius = (transform as RectTransform).sizeDelta.x * 0.5f;
        }

        public override void OnDrag (PointerEventData eventData){

                base.OnDrag (eventData);

                Vector2 contentPostion = base.content.anchoredPosition;

                if (contentPostion.magnitude > this.mRadius){

                        contentPostion = contentPostion.normalized * this.mRadius ;
                }

                base.content.anchoredPosition =  contentPostion;
        }
}

2016/03/22 補充:
繼前面(3/18)補充,將搖桿的搖動區域改為圓形的部分,利用 Vecto3.ClampMagnitude() 將程式碼簡化如下:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

public class ScrollCircle : ScrollRect {

        protected float radius;

        protected override void Start (){

                this.radius = ((RectTransform)transform).sizeDelta.x * 0.5f;
        }

        public override void OnDrag (PointerEventData eventData){

                base.OnDrag (eventData);
                base.content.anchoredPosition = Vector3.ClampMagnitude(base.content.anchoredPosition , this.radius);
        }
}