關於 web service, unity, blogger 等軟體工程筆記

Unity C# 遊戲事件訊息通知機制設計 - Observer pattern

Edit icon 沒有留言
Unity

前陣子同事分享去資策會 Design pattern 課程所學,聽到 Observer pattern (觀察者模式) 的介紹,感覺這比較是 C++ 老派的實作方式,不一定適用於 C#,因此在此寫此筆記記錄。

先講個人結論,在 C# 中 Observer pattern 最好的實作方式,莫過於是使用 C# event,若沒有特殊需求都不會考慮其他的實作方式。

關於有興趣的主題 (Subject)

遊戲功能通常會對於某些主題 (Subject) 感到興趣,例如當場上玩家全部死亡時,以下範例遊戲邏輯需要的執行相應的處理:

  • BattleManager:控制場上敵人執行嘲諷演出
  • SaveManager:記錄死亡點資料
  • UIManager:顯示死亡介面,回到開頭畫面或是接關
  • SceneManager:切換到死亡後的場景
  • 等等等…

如果以直覺的實作,當 PlayerManager 偵測到玩家全部死亡後,執行該些物件函數:

using UnityEngine;

public partial class PlayerManager : MonoBehaviour
{
   void Update()
   {
      if (this.IsAllPlayersDead())
      {
         BattleManager.Current.PlayWinAnimations();
         SaveManager.Current.SaveData();
         UIManager.Current.DisplayDeadUI();
         SceneManager.Current.SwitchDeadScene();
         // and so on...
      }
   }
}
PlayerManager 在事件發生呼叫各個功能類別的函數

PlayerManager 在事件發生呼叫各個功能類別的函數

這實作除了造成 PlayerManager 與其他物件類別強烈耦合外,若未來有要新增/ 移除其他行為的話,那又得再修改 PlayerManager。

觀察者模式 Observer pattern

另一種設計方式採用觀察者模式,為全部玩家死亡建立一個主題,讓有興趣的物件類別註冊觀察,當該主題發生時,便會通知這些觀察者 (Observer)。

Observer pattern UML class diagram

Observer pattern UML class diagram

using System.Collections.Generic;

public abstract class Subject<T>
{
   List<IObserver<T>> observers = new List<IObserver<T>>();
   public void Attach(IObserver<T> observer)
   {
      this.observers.Add(observer);
   }
   public void Detach(IObserver<T> observer)
   {
      this.observers.Remove(observer);
   }
   public void Notify(T args)
   {
      foreach (var observer in this.observers)
      {
         observer.Update(args);
      }
   }
}

public interface IObserver<T>
{
   void Update(T args);
}

因此針對全部玩家死亡建立一個主題,並在 PlayerManager 宣告與實作:

using UnityEngine;

public class AllPlayersDeadArgs {
}

public class AllPlayersDeadSubject : Subject<AllPlayersDeadArgs>{
}

public partial class PlayerManager : MonoBehaviour
{
   public AllPlayersDeadSubject AllPlayersDeadSubject;

   void Update()
   {
      if (this.IsAllPlayersDead())
      {
         this.AllPlayersDeadSubject.Notify(new  AllPlayersDeadArgs());
      }
   }
}

其他觀察者實作 IObserver 並且向 AllPlayersDeadSubject 註冊觀察,例如 BattleManager 例子:

using UnityEngine;

public partial class BattleManager : MonoBehaviour, IObserver<AllPlayersDeadArgs>
{
   void Awake()
   {
      PlayerManager.Current.AllPlayersDeadSubject.Attach(this);
   }

   void IObserver<AllPlayersDeadArgs>.Update(AllPlayersDeadArgs args)
   {
      // TODO: On all player dead
   }
}
注意:上述提到的主題 (Subject) 是採用 Design pattern 一書內的講法,在實務的遊戲開發中,通常稱為事件 (Events),故以下全部改用事件,Subject -> Event,Observer -> EventListener。

隨著遊戲開發,有興趣的事件越來越多時,例如需要單一玩家死亡、單一玩家復活、單一玩家受傷等遊戲事件觀察時,為每個事件都建立類別,實在是有點麻煩且又不切實際,可能會嘗試整合所有事件在同一個類別來管理,例如訊息中心 (Notification center)。

![補上一張圖](A notification center call example)

using System.Collections.Generic;

public static class NotificationCenter
{
   static Dictionary<string, List<IEventListener>> eventListeners = new Dictionary<string, List<IEventListener>>();
   public static void AttachListener(string eventName, IEventListener listener)
   {
      List<IEventListener> listeners = null;
      if (!eventListeners.TryGetValue(eventName, out listeners))
      {
         listeners = new List<IEventListener>();
         eventListeners.Add(eventName, listeners);
      }
      listeners.Add(listener);
   }

   public static void DetachListener(string eventName, IEventListener listener)
   {
      List<IEventListener> listeners = null;
      if (eventListeners.TryGetValue(eventName, out listeners))
      {
         listeners.Remove(listener);
         if (listeners.Count <= 0)
         {
            eventListeners.Remove(eventName);
         }
      }
   }

   public static void NotifyEvent(string eventName)
   {
      NotifyEvent(new EventArgs()
      {
         EventName = eventName,
      });
   }

   public static void NotifyEvent(EventArgs args)
   {
      List<IEventListener> listeners = null;
      if (eventListeners.TryGetValue(args.EventName, out listeners))
      {
         foreach (var listener in listeners)
         {
            listener.OnEvent(args);
         }
      }
   }
}

以及其事件接聽與事件參數宣告:

public class EventArgs
{
    public string EventName
    {
        get;
        set;
    }
}

public interface IEventListener
{
    void OnEvent(EventArgs args);
}

向該訊息中心註冊監聽事件以及處理該事件:

public partial class BattleManager : MonoBehaviour, IEventListener
{
   void Awake()
   {
      NotificationCenter.AttachListener("EventName", this);
   }

   void IEventListener.OnEvent(EventArgs args)
   {
      // TODO: Event
   }
}

發起事件:

NotificationCenter.NotifyEvent("EventName");

筆記至此,發現這樣的實作模式是如此複雜,且實作 IEventListener 還是無法保證處理函數被封裝,不被其他物件呼叫,而 OnEvent 函數還需要撰寫 switch case 來處理不同事件。

另外該範例實作,使用字串比對來區隔不同事件,可能會造成效率低落。若改成使用 enum,則需要再額外定義遊戲事件 enum,管理有使用到的事件也是相當麻煩。

結合 C# Delegation

在 C# 中使用委派 (delegation,類似於 C++ function pointer 的概念) 相當方便,也可以考慮放棄介面實作,而是改傳與委派定義相同傳回型別 (return type) 與相同輸入參數 (input paramters) 的函數,當事件發生時直接呼叫所註冊的函數,首先宣告委派:

public delegate void EventCallback(EventArgs args);

修改原先 NotificationCenter 的實作,不再傳入 IEventListener 介面,而是傳入與委派定義方法簽章 (method signature) 相同的函數:

public static class NotificationCenter
{
    static Dictionary<string, List<EventCallback>> eventCallbacks = new Dictionary<string, List<EventCallback>>();
    public static void AttachListener(string eventName, EventCallback callback)
    {
        List<EventCallback> callbacks = null;
        if (!eventCallbacks.TryGetValue(eventName, out callbacks))
        {
            callbacks = new List<EventCallback>();
            eventCallbacks.Add(eventName, callbacks);
        }
        callbacks.Add(callback);
    }
    public static void DetachListener(string eventName, EventCallback callback)
    {
        List<EventCallback> callbacks = null;
        if (eventCallbacks.TryGetValue(eventName, out callbacks))
        {
            callbacks.Remove(callback);
            if (callbacks.Count <= 0)
            {
                eventCallbacks.Remove(eventName);
            }
        }
    }
    public static void NotifyEvent(string eventName)
    {
        NotifyEvent(new EventArgs()
        {
            EventName = eventName,
        });
    }
    public static void NotifyEvent(EventArgs args)
    {
        List<EventCallback> callbacks = null;
        if (eventCallbacks.TryGetValue(args.EventName, out callbacks))
        {
            foreach (var callback in callbacks)
            {
                callback(args);
            }
        }
    }
}

註冊事件因此可以調整,覺得比上節更清爽一些 (不需要實作介面),且函數方法也可以封裝:

public partial class BattleManager : UnityEngine.MonoBehaviour
{
    void Awake()
    {
        NotificationCenter.AttachListener("EventName", this.OnHandleEvent);
    }
    void OnHandleEvent(EventArgs args)
    {
        // TODO: Event
    }
}

C# Event

在文章一開頭所提到的,在 C# 中實作 Observed pattern 最好的方式便是使用 event,這語法糖 (Syntactic sugar) 簡單易用又方便,更多可以參考另一篇筆記:Unity 事件機制淺談

我們需要建立全部玩家死亡,玩家受傷,玩家復活等事件宣告,只需要以下程式碼:

using System;
using UnityEngine;

public class PlayerEventArgs : EventArgs
{
   public int PlayerIndex
   {
      get;
      set;
   }
}

public partial class PlayerManager : MonoBehaviour
{
   public event EventHandler AllPlayersDied;
   public event EventHandler<PlayerEventArgs> PlayerHurted;
   public event EventHandler<PlayerEventArgs> PlayerRevived;
}

使用 += 以及 -= 來開始監聽或是停止監聽該事件:

void Awake()
{
   PlayerManager.Current.PlayerHurted += this.OnPlayerHurted;
   PlayerManager.Current.PlayerHurted -= this.OnPlayerHurted;
}

void OnPlayerHurted(object sender, PlayerEventArgs e)
{
   // TODO:
}

這樣設計的另一個好處,能限制只有 PlayerManager 才能發起該事件:

void Update()
{
   if (this.IsAllPlayersDead())
   {
      if (this.AllPlayersDied != null)
      {
         this.AllPlayersDied(this, EventArgs.Empty);
      }
   }
}

集中式事件管理?

需不需要集中式事件管理的訊息中心 (Notification center)?個人傾向不要有,因為該設計會使得其他組件都得依賴訊息中心,之後想拆也拆不掉,未來若該些組件要搬到別的專案重複利用,也會相當的麻煩。

傾向將事件放在發起該事件的組件上,讓其他組件直接依賴該組件。透過在編輯器直接設定參照方式 (Reference)、良好樹狀結構去取得依賴的組件、亦或是由主流程來協助各組件協同作業。

補充:使用訊息中心 (Notification center) 另一個設計議題是事件發送權限,不會希望任何功能能夠送出特定事件,例如 UI 按鈕可以送出遊戲結束事件 (NotificationCenter.NotifyEvent("GameOver")),而是僅在可控制範圍內宣告事件,達到良好的事件封裝。當然這得看遊戲系統架構設計來取捨,畢竟全域的訊息中心在實作上是否方便,如果選擇則是看團隊設計偏好。

當然,也可以設計一個單純的 Event Adapter,只負責轉發事件訊息:

public static class NotificationCenter
{
   public static event EventHandler PlayerAllDied
   {
      add
      {
         PlayerManager.Current.PlayerAllDied += value;
      }
      
      remove
      {
         PlayerManager.Current.PlayerAllDied -= value;
      }
   }

   // TODO: Other events...
}

Reference

沒有留言: