過去在我的其他文章介紹過利用 狀態模式 / 狀態機 來控制遊戲程式的流程,是我相當喜歡且穩定的作法。在一般使用狀態機的情境下,所有的 UI 開關都由狀態機中的邏輯決定,UI 本身只是作為畫面表現的客體存在。
不過若用狀態機做為開發的專案核心,則代表會需要有一套是先架構的核心系統。如果是臨時的小型 prototype,或者在 game jam 環境展開的專案,引入一套架構並要新成員馬上建立起使用規範來利用這套系統,是有點不實際的。
所以是否有擺脫狀態機,甚至沒有一個集中式核心的小型替代方案可以用來管理流程跟 UI 切換呢?本文便是以此為出發點,所做的一個嘗試。
架構簡介
傳統網頁的伺服器,主要依賴網址來獲取顯示的要求,依照要求回傳網頁給予使用者。這個溝通模式可以被歸類為 Request-Response Model,而這也是我這次新架構的主要想法來源,雖然最後結果上不能說是同一套 Model。
在我的架構之中,每一頁 UI page 同時也代表著當下遊戲進行的階段,當遊戲到某階段時的邏輯處理,都跟 UI page 的啟動同時發生,就像在瀏覽器上切換畫面一樣。這代表遊戲的進程由 UI page 推動著,而非利用狀態機與中央管理畫面的類別來推動。
每個 UI page 基本上獨立而不依賴於中央管理器,各自接受了啟動需求、按鈕等事件,做出對應的反應:開啟、關閉或者其他呈現上的變化,而非開放各項操作方法來給中央管理器進行統一操作。
** 這篇文章的用詞會用 UI page 來代表每個介面單位,但實際使用上不只是 UI,只要是場景上的呈現都可以透過這方法呼叫,所以類別用 View 來命名指稱被呼叫的呈現畫面。
ViewBase
using UnityEngine; public abstract class ViewBase : MonoBehaviour { public event System.Action<ViewId> onViewRequest; public virtual void Initialize () { this.gameObject.SetActive (false); } public virtual void GetRequest () { this.gameObject.SetActive (true); } protected void SendRequest (ViewId viewId) { onViewRequest (viewId); } }
ViewBase 繼承了 MonoBehaviour,同時也是個 abstract class,每個 UI page 都應該有一份繼承後的類別,並掛載於該 UI 的根物件之上。這裡跟狀態機的 State 類別有點相似,只是改成以 場景上的物件作為本體。
ViewBase 的成員有:
- onViewRequest 成員,一個帶有 event 關鍵字的 delegate,用於將需要新頁面的要求送出。
- SendRequest (ViewId) 方法,實際用於呼叫新頁面的方法,是對應於 event 的必要封裝。
- Initialize () 方法,會在初始化被呼叫一次,預設的動作是將 UI 物件整個 SetActive (false) 進入待命狀態,也可 override 成需要的初始化 (如:做螢幕適屏的處理)。
其中最重要的:
- GetRequest () 方法,用於接收從其他 UI page 傳來的要求,代表需要啟動此頁 UI page。預設動作是將物件 SetActive (true),但是如果有進場動畫或者其他需求,可以 override 這個方法。
類別之中並沒有實現關於關閉 UI 或畫面呈現的方法,因為這點在設計上由各畫面自行決定,這邊用一個例子進行說明:
public void ClickStart () { SendRequest (ViewId.BattleInfo); this.gameObject.SetActive (false); }
這是一個會 Invoke 在 uGUI Button 上的方法,代表 Menu 頁面的 Start 按鈕被按下了,因而觸發開啟 BattleInfo 頁面的需求,並將自身頁面關閉。這便是此架構中,讓畫面與 UI 事件來推動遊戲進程,開啟與關閉相關物件的最簡單例子。
除此之外,因為遊戲邏輯而產生需要切換畫面的需求,也用類似的方法處理。
ViewId
[System.Flags] public enum ViewId { Menu = 1, BattleInfo = 2, Result = 4 }
ViewId 在這裡的地位就類似瀏覽器上輸入的網址,用於描述需要頁面的資訊。這裡是使用 enum 作為實現方式,如果要用 string 或 int 等其他方式取代也無不可。
此處使用 enum 時,我會特別加上 System.Flags,可以讓 enum 使用位元運算子來疊加多個需求,而不必用上陣列或者 params object[] 之類的方法。
ViewRouting
using UnityEngine; public class ViewRoutingTest : MonoBehaviour { [SerializeField] private ViewBase[] m_viewBases; private void Awake () { for (int i = 0; i < m_viewBases.Length; i++) { m_viewBases[i].Initialize (); m_viewBases[i].onViewRequest += ViewRouting; } } private void Start () { ViewRouting (ViewId.Menu); } private void ViewRouting (ViewId viewId) { if ((viewId & ViewId.Menu) == ViewId.Menu) { m_viewBases[0].GetRequest (); } if ((viewId & ViewId.BattleInfo) == ViewId.BattleInfo) { m_viewBases[1].GetRequest (); } if ((viewId & ViewId.Result) == ViewId.Result) { m_viewBases[2].GetRequest (); } } }
前面有提到,這個架構沒有中央處理器跟核心。不過依舊需要一個初始化的腳本,用於處理每個 UI page 丟出來的 request,交給對應的 ViewBase 處理。
這裡的 ViewRoutingTest 便是一個例子,在 Awake 呼叫了所有 ViewBase 的 Initialize () 來進行初始化,並將一個 ViewRouting (ViewId) 的方法註冊到所有的 onViewRequest event 中,用來接收 request。
在 ViewRouting (ViewId) 方法中,會將接收到的參數經過一連串邏輯,並對相關的 ViewBase 進行呼叫,如果有跟畫面切換同時發生的遊戲邏輯,也可以在這裡進行處理。這個方法就類似於一些網站框架的 Route 處理器。
這裡的例子因為對應 ViewId 是一個有 Flags 屬性的 enum,所以對應地要利用位元運算跟 if 來處理疊加的需求,而非用 switch 來處理。
初始化完畢後,在 Start 中呼叫第一個啟動的頁面,就代表遊戲正式啟動了,接下來的流程推動將由各個獨立的 UI page 來進行。
** 初始化 ViewBase 時要注意不同物件之間的初始化順序,避免有些物件還沒準備好就開始 Request 第一個頁面。
小結
這邊列出我目前使用這個架構感受到的幾個優缺點,可以看出來還有不少細節需要考慮改善,但因為整體相當輕量化,未來在 game jam 等快速開發的情境下我應該會常常用上。
如果有更加完善化的版本出現,我會在撰文跟大家分享。
優點
- 架構所需的程式碼較少。
- 沒有核心管理器的存在,只有一份初始化的腳本,使用規範較寬鬆。
- 跟 Unity 的 uGUI 及各種事件的互通性良好,基本上不需要調整太多開發方式。
- UI 的換場、進退場等需要時間撥放的動畫,可以由 UI 自行管理,也不用擔心跟狀態機的切換邏輯要如何配合。
缺點
- 遊戲進程跟 UI 流程相互依賴,所以流程彈性跟 UI 的複用性較差。
- 沒有核心管理器的同時,也代表橫跨整個遊戲的大型系統較不易建立。
- 跟場景切換的需求較不好配合。
- 對於場景初始化順序,因為沒有核心進行管理,相當考驗對物件 Awake、Start 的規劃。
看完後想問幾個地方
1.
中間有一段 ClickStart 方法的實現,是不是各頁面自行繼承ViewBase ,再擴充各自需求。
〝類別之中並沒有實現關於關閉 UI 或畫面呈現的方法,因為這點在設計上由各畫面自行決定,這邊用一個例子進行說明:〞
2.
ViewRoutingTest 中的 ViewRouting 方法
Code中將if判斷後
分別分配如以下
m_viewBases[0] = ViewId.Menu
m_viewBases[1] = ViewId.BattleInfo
m_viewBases[2] = ViewId.Result
那是否代表 這些在從 Unity編輯器中拖曳給m_viewBases 陣列的時候就得跟Enum內容綁死了呢
不知道是否有理解錯誤
以下是我想到的改進方式,不知道跟你原本的想法是不是一樣。
在ViewBase中新增protected ViewId viewID ;
在 ViewBase 的延伸類別中,假設是Menu的頁面
public override void Initialize () {
base.Initialize();
viewID = ViewId.Menu;
}
這樣在ViewRoutingTest 的 ViewRouting 方法就可以透過viewID 在陣列中尋找,
並呼叫對應的類別的GetRequest。
進而節省每次新增頁面時都要在ViewRouting 實現該頁面的判斷。
讚讚
用 List.Find() 的做法也沒問題,routing 的部分依照需求去調整即可。
這兩者沒有確切的好壞,假如使用 List.Find(),就不能使用 Flags 的疊加 enum 了,同時要在呼叫 View 的同時加入特定邏輯也比較麻煩。
比如,在切換到 BattleInfo 的同時,也啟動玩家腳色的控制等。
讚讚
GetRequest 加入自動參數值為佳,
void GetRequest (System.Object args = null),
在要求顯示 View 時一起把資料帶過去。
讚讚
是,我文章也提到可以依照需求傳遞不同形式的參數,不一定要使用 enum。如果把資料傳遞進 GetRequest,就可以同時進行一些變化了。
讚讚