Unity C# 遊戲事件訊息通知機制設計 - Observer pattern
前陣子同事分享去資策會 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。
觀察者模式 Observer pattern
另一種設計方式採用觀察者模式,為全部玩家死亡建立一個主題,讓有興趣的物件類別註冊觀察,當該主題發生時,便會通知這些觀察者 (Observer)。
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
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
}
}
隨著遊戲開發,有興趣的事件越來越多時,例如需要單一玩家死亡、單一玩家復活、單一玩家受傷等遊戲事件觀察時,為每個事件都建立類別,實在是有點麻煩且又不切實際,可能會嘗試整合所有事件在同一個類別來管理,例如訊息中心 (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...
}
沒有留言: