2015年6月25日 星期四

Unity:製作 UGUI 的 UI 流程管理機制

自從 Unity 4.6 發佈新 GUI 系統之後,Unity 終於有個比較完整的視覺化編輯 UI 工具可以使用,於是,我們可以很方便、直覺的在畫面上添加按鈕,使用拖曳、下拉選單等幾個動作就能設置好 UI 事件應該執行哪個 GameObject 上的哪個 Component 中的功能,所以透過 UI 去觸發我們自己撰寫的程式功能也變得非常簡便,但是整個遊戲內容可能會有相當多的畫面,不同的 UI 按鈕或行為將轉向不同的畫面,也需要開啟不同 UI 視圖,如果沒有規劃好 UI 畫面的動線規則,在複雜的畫面轉換間,將可能會發生很難維護的情形,甚至在未來多次的變更修改後產生不必要的 Bug 而使得 UI 畫面動線變得相當混亂。

不管 UI 做得多漂亮、多華麗,反饋、排版、效果等這些細工做得多好,如果整體架構、動線流程出錯了,做了那麼多細工的辛苦就會白費掉,因為,會讓玩家迷路的 UI 動線,使用起來就是會不舒服;即使使用 UI 的動線設計都沒問題,但沒有規劃一致性的流程管理,在製作及維護上也容易造成一些困擾。

舉例來說,大部份的 UI 畫面都會有個「返回」按鈕可以回到前一個畫面,當這個 UI 畫面是從 A 畫面進來時,應該可以返回到 A 畫面,從 B 畫面進來時,應該可以返回到 B 畫面,就像我們平常使用網頁瀏覽器一樣,不管目前的頁面是從哪裡進來的,按下「上一頁」就是會回到進來目前網頁之前的那個網頁,UI 上的「返回」按鈕也是一樣的功能,只是,這個返回是個按鈕,它會被我們設置執行某個 Component 的某個功能,透過這個功能來使它開啟某個 UI 畫面,所以,我們很直覺的就會有點像寫死的程式一樣,A 畫面進入 B 畫面,所以點擊 B 畫面的返回按鈕就是開啟 A 畫面,如果,C 畫面也可以進入 B 畫面呢?那麼就做個暫存紀錄讓 B 畫面判斷是從 A 還是 C 畫面進入的,或者進入 B 畫面的同時,通知 B 畫面說是從哪裡進來的,此時,第一個問題來了,每當多出一個會進入到 B 畫面的畫面,B 畫面的返回按鈕功能就要進行修改來達到正確的判斷;第二個問題,假設 A、B 畫面都可以進入 C 畫面,C 畫面可以進入 A、D 畫面,A、D 畫面都可以進入 B 畫面... 等等較深入的交叉動線,新增一個 UI 畫面或者更動到某個 UI 畫面流程,就幾乎全部畫面的返回鍵都要進行修改,這種工程實在太浩大了,牽一髮而動全身的維護狀況是最容易產生未知 Bug 的。



有鑑於此,設計的 UI 流程管理機制至少要滿足兩個條件:
  • 玩家使用時不會迷路。
  • 不管未來變更多少畫面,都不需要修改或維護返回按鈕。
要滿足這兩個條件,有一個最直接的做法,也就是前面提到的,網頁瀏覽器的「上一頁」功能,這個功能是如何實現的呢?概念其實就是瀏覽紀錄,也就是說我們只要把玩家操作時,所切換過的 UI 畫面依序記錄下歷程,再依序返回,這樣子,玩家使用時不會迷路,我們在製作及維護時,也不需要為每個畫面的返回按鈕客製化。

流程上的概念沒問題了,另外就是一些其他要注意的部分:
  • 每個 UI 畫面間的進場和退場動態,
  • 進退場畫面進行中時,UI 事件不能有作用。
  • 退場 UI 畫面即使遊戲畫面上看不到,也不應該讓它持續執行。
前面寫了那麼多,最重要的還是如何實作,以下是整個實作過程的影片,影片內並沒有太詳細說明,所以,在下面的文章內容提供進一步的詳細解說:



UI 畫布的基本結構

先決定我們要使用怎麼樣的畫面比例、解析度來編排製作 UI,通常會跟 Game view 設置好要預覽的畫面相同。


建立 UI 的主畫布(Canvas),並在其 Canvas Scaler 設置好 Ui Scale Mode 以及 Reference Resolution,這主要是為了使將來 UI 面臨不同畫面比例的裝置時,可以有基本的自適應調整,詳情可以參考另一篇文章「Unity:UGUI 應對各種螢幕自動調整大小及位置

設置 Ui Scale Mode 與 Reference Resolution
接下來,每個 UI 畫面都應該另外建立 Canvas,並將畫面內的其他 UI 元件放置在該 Canvas 之下,如此前面主畫布裡設置好的 Canvas Scaler 也將直接適用於其下的每個 UI 畫面,另外,這麼做的另一個好處是,之後可以直接變更 Canvas 的 Sort Order 來排列 UI 畫面的前後順序。同時,每個畫面除了 UI 元件之外,還需要使用 Image 製作一個覆蓋全畫面大小的「透明遮擋層」,並放置於其它 UI 元件之上,平常處於 GameObject 被關閉的狀態,當 UI 畫面在進行進場、退場動態時,才將它開啟,這樣可以保護 UI 的 Button 不會被點擊到。

使用 Image 製作透明遮擋層 Modal 物件
製作動畫檔

製作 UI 畫面的進場、退場動畫檔分別命名為 Open、Closed,最簡單的就是淡入、淡出或者是放大、縮小的方式,當然,也可以設計更豐富的動畫效果,最重要的是,進、退場動畫檔播放期間要記得開啟前面製作的「透明遮擋層」。

動畫播放期間開啟「透明遮擋層」
設置動畫控制

當我們為 UI 畫面製作好進場、退場的動畫檔之後,開啟 Animator view,可以發現 Unity 已經自動幫我們在 Animator 建立兩個與動畫檔相同名稱的動畫狀態(State)。

預設,Unity 會自動將 AnimationClip 放到 Animator 做為  state
首先,先建立一個 Trigger 參數(Out),這是要讓 UI 畫面進場後,維持在進場動畫播放完畢之後的動畫狀態(Open)時,讓程式通知 Animator 轉換到播放退場動畫狀態(Closed)用的。

在 Animator 的 Parameters 建立一個 Trigger
接著,從 Open 狀態建立 Transition 連接到 Closed 狀態,並在 Inspector view 設置其中的過渡條件,由於 Unity 預設會認為兩個動畫狀態之間的轉換是需要混合過程的(例如,人物角色閒置的動作轉換到跑步動作),這個混合過程會犧牲一點動畫檔本身的播放時間,在 UI 畫面轉換的動畫並不需要這種混合過程,所以在此,我們可以直接將過渡時間 Transition Duration 設置為 0,讓它很明確的播放 Closed 動畫;由於,Open 狀態並不需要播放結束之後馬上轉換到 Closed 狀態,而是等待程式通知 Animator 觸發 Out 參數才轉換到 Closed 狀態,所以,在此也要取消勾選 Has Exit Time,並且在 Conditions 設置一個過渡條件 Out。

設置 Transition 裡的幾個相關欄位
然後,再從 Closed 狀態建立 Transition 連接到 Exit 狀態,並在 Inspector view 設置過渡時間 Transition Duration 為 0,把 Exit Time 設置為 1,為什麼要這樣做呢?理論上,當 Closed 播放完畢之後,UI 畫面就會完成退場的動作,在它下次進場之前,都不需要去理會它,但為了不讓不在遊戲畫面中的 UI 畫面繼續執行,浪費效能資源,我們會希望完成退場之後,可以將這些 UI 畫面整個 GameObject 都關閉,所以,我們後續會撰寫 Animator 的 State 專用的 StateMachineBehaviour script來做這件事,而 StateMachineBehaviour 的 OnStateExit 是在狀態結束「之後」才會被呼叫,所以,如果沒有為 Closed 建立 Transition 連接出來的話,StateMachineBehaviour 的 OnStateExit 將不會執行,另外,Exit Time 是以 0 到 1 來代表整個動畫時間從開始到結束的時間點,我們希望 Closed 是能夠明確的播放到結束時間才真的結束,所以在此的 Exit Time 才直接設置為 1。

設置好正確的時間值
由於,在 Unity 裡建立動畫檔時,預設會認為該動畫是要重複循環播放的,所以,我們還要另外手動找出 Open 及 Closed 動畫檔,並在 Inspector view 裡將 Loop Time 取消勾選。

取消 Loop Time
動畫狀態腳本

這部分,最主要的是要讓 UI 畫面退場之後,也能同時將它的 GameObject 整個關閉掉,以往,我們是在 GameObject 掛上我們自已寫的 Script,並在製作動畫檔時,在 Animation view 加入 Event,自從 Unity 5.0 發佈後,就不再需要這麼麻煩,我們可以直接點選 Animator view 裡的任意狀態,並在 Inspector view 中點擊 Add Behaviour 按鈕,建立 Animator 狀態專用的 Script,如此就可以直接透過撰寫這個 Script 的內容來對 Animator 裡的狀態針對狀態開始、進行中、結束.. 等等的時機進行其他操作,因此,我們要讓 UI 畫面退場之後關閉本身 GameObject,只需要為 Closed 建立 StateMachineBehaviour 並在 OnStateExit 加入簡短的一行程式碼即可。
為 Animator 的 State 加入 Behaviour
using UnityEngine;

public class UIStateClosed : StateMachineBehaviour {

    override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {

        animator.gameObject.SetActive(false);
    }
}

UI 流程管理腳本

接下來算是重頭戲了,做完前面的工作,剩下的就是靠這個 Script 去做主要的管理,而這個 Script 主要做哪些工作呢?我們將其列出如下:

1. 建立一個列表來紀錄 UI 畫面歷程,用來將曾經進入的 UI 畫面依序記錄下來,以方便依序返回。
2. 先將第一個開啟的 UI 畫面記錄到歷程中,當歷程只剩下一筆時,就不能再返回。
3. 不可以進入與目前 UI 畫面相同的畫面。
4. 即將要進入或返回的目標畫面必須移到最上層。
5. 往前進入的目標畫面必須記錄到歷程中。
6. 返回時,必須要將目前畫面從歷程紀錄中移除。

另外,之前的文章「Unity:認識 Tag 與 Layer 的差異與應用」曾提到過程式碼中應該避免寫死的字串值,所以,這個 Script 因為會使用到 Animator 的 Parameters 中設定的名稱來通知 Animator 轉換到 Closed 狀態,所以也需要宣告一個 public 欄位來設置該名稱。

有了以上的工作需求,我們就可以撰寫出如下的程式碼:

using UnityEngine;
using System.Collections.Generic;

public class UIManager : MonoBehaviour {

    public GameObject startScreen;
    public string outTrigger;
    private List<GameObject> screenHistory;

    void Awake(){

        this.screenHistory = new List<GameObject>{this.startScreen};
    }

    public void ToScreen(GameObject target){

        GameObject current = this.screenHistory[this.screenHistory.Count - 1];

        if(target == null || target == current) return;

        this.PlayScreen(current , target , false , this.screenHistory.Count);
        this.screenHistory.Add(target);
    }

    public void GoBack(){

        if(this.screenHistory.Count > 1){

            int currentIndex = this.screenHistory.Count - 1;
            this.PlayScreen(this.screenHistory[currentIndex] , this.screenHistory[currentIndex - 1] , true , currentIndex - 2);
            this.screenHistory.RemoveAt(currentIndex);
        }
    }

    private void PlayScreen(GameObject current , GameObject target , bool isBack , int order){

        current.GetComponent<Animator>().SetTrigger(this.outTrigger);

        if(isBack){

            current.GetComponent<Canvas>().sortingOrder = order;

        }else{

            current.GetComponent<Canvas>().sortingOrder = order - 1;
            target.GetComponent<Canvas>().sortingOrder = order;
        }

        target.SetActive(true);
    }
}

套用

完成以上的工作以及程式撰寫之後,只要建立一個空的 GameObject 並將 UIManager script 掛上去,在 Start Screen 欄位放入第一個要顯示的 UI 畫面 GameObject,在 Out Trigger 填寫跟 Animator 設置的 Trigger 參數相同的名稱。

設置 Start Screen 及 Out Trigger 欄位
然後,在每個 UI 的 Button 中的 On Click 設置附有這個 UIManager script 的 GameObject,如果按鈕是要前往下一個畫面,則在下拉選單選擇 ToScreen 並在其參數欄位設置目標 UI 畫面的 GameObject。如果按鈕是要返回上一個畫面,則在下拉選單選擇 GoBack。

前往下個 UI 畫面,選擇 ToScreen
設置好下個 UI 畫面的 GameObject
返回上個 UI 畫面,選擇 GoBack
接下來,執行時,就能將每個進入的畫面歷程記錄下來,並在每次返回時依序返回上一個畫面,每個即將顯示的 UI 畫面也將會是在遊戲畫面的最上層,而不會被遮擋到,同時,在畫面的進、退場動態期間也不會被不小心按到按鈕而跳到非預期的畫面;由於,動線被定義為從哪裡進去就從哪裡返回,所以,使用者在眾多的畫面瀏覽間,會比較直覺的操作而不容易迷路,同時,因為畫面歷程的紀錄,返回按鈕一律透過 GoBack 去處理時,將來不管是新增多少 UI 畫面,或是 UI 畫面流程如何改變,返回功能都不需要再進行任何修改,也可以返回到正確的畫面。

以上,這個簡單的 UI 流程管理機制算是完成了,之後,我們如果對於 UI 執行轉換動作的邏輯要進行變更(例如,目標畫面的排序規則),則只需要修改 UIManager 裡面的 PlayScreen;如果,要換成別的進場、退場動畫效果,也只需要替換掉 Animator 裡面各狀態的 motion;如果做了多種版本的 UIManager 並存於同一個專案或場景中,也可直接變更 UI 裡 Button 的 On Click 設置,整個機制相當的彈性,但是,玩家操作上卻不至於混亂,在後續維護上也將會更為輕鬆。

最後,提供範例專案檔以供參考:下載

P.S. 目前使用 Unity 版本為 5.1.0f3。