Đây là blog đầu tiên của mình, hôm nay mình sẽ nói về một trong những DesignPattern khá phổ biến trong lập trình game : Observer.
Dân lập trình thì chắc mấy bạn cũng từng nghe qua pattern này hết rồi, nếu ai chưa biết có thể đọc thêm ở đây. Nhìn chung, đã gọi là pattern tức là nói đến một “phong cách” design codebase. Ở đây mình sẽ giới thiệu ObserverPattern trong game theo cách …. bình dân một chút cho dễ hiểu.



ObserverPattern là cái gì?


Để mình lấy một ví dụ đơn giản trong “đời thường” :
Không, họ chỉ cần dán một mẫu tin lên Bảng thông báo ở ngoài sân, mấy cậu sinh viên chỉ cần phải theo dõi cái bảng này thì sẽ nhận được các thông tin cần thiết.


Ở đây chúng ta có những vai trò chính:
– Nhà trường : là người gửi tin (Sender). Họ chỉ cần chuẩn bị mấy mẫu thông báo rồi dán lên Bảng, còn không cần quan tâm là có thằng sinh viên nào đọc hay không (không đọc thì chết ráng chịu :v )
– Bảng thông báo : ở vị trí trung gian (Dispatcher), nhận tin nhắn từ người gửi (nhà trường), và chuyển tiếp đến người nhận (sinh viên).
– Mẫu thông báo : có thể có các loại như : báo nghỉ học (yeah), báo tăng học phí (cái #$#%) … ứng với mỗi loại sẽ có thông tin đi kèm như thời gian nghỉ, học phí tăng thêm bao nhiêu …
– Sinh viên : người nhận (Listener), chủ yếu quan tâm đến loại thông báo và nội dung, chả mấy khi thèm quan tâm là ai báo :v




Nghe thì cũng hay đó, thế có áp dụng được gì vào việc design codebase cho game không?


Trong game ta thường có một số đối tượng cơ bản sau:
– Main Character (MC)
– UI : hiển thị điểm số, máu  … của MC
– AchivementManager : theo dõi người chơi, tặng những điểm thưởng như : quà khi thực hiện được một tripple kill, nhặt được những item hiếm …
– ..v.v…


Khi thằng MC bị mất máu hoặc kill được quái vật liên tiếp, trên UI sẽ phải hiển thị số máu của MC, cái Achivement cần theo dõi để tính điểm thưởng, bạn sẽ code thế nào? Cho cái đám UI, Achivement … theo Singleton hết, rồi gọi trực tiếp tới UI, Achivement… để thông báo về việc thay đổi máu, điểm … ? Điều này bất tiện và khiến code của bạn bị coupling quá nhiều, các đối tượng trong game dần dần sẽ trở nên “kết dính” với nhau, code không có khả năng sử dụng lại, lỡ mà object nào bị thiếu thì game crash, null pointer ….. khổ sở lắm.


Thay vào đó, áp dụng ObserverPattern, người ta sẽ làm như sau:
– Định nghĩa các events trong game: OnPlayerHPChanged, OnPlayerKillMonster …
– Tạo EventDispatcher: đối tượng trung gian, tiếp nhận và chuyển tiếp các events.
– Khi bắt đầu một scene (hàm Start() đc gọi), đối tượng UI và Achivement … sẽ Register với EventDispatcer để lắng nghe các sự kiện OnPlayerHPChanged, OnPlayerKillMonster.
– MC : khi bị mất máu hoặc giết một con quái vật, ghi điểm …. MC sẽ gửi một message tới  EventDispatcher, EventDispatcher thông báo cho những object có đăng kí lắng nghe các event. (Ở đây là UI object và AchivementManager)




Có cái Demo nào chạy được thì đưa đây coi, nói lý thuyết lằn nhằn quá, chả hiểu mô tê gì cả


Mình có làm một demo đơn giản bỏ lên GitHub đây, mấy pro checkout về coi chơi.


DemoObserver_02


Các đối tượng chính trong Demo :



  • Marine : là thằng lính dưới đất, kích chuột ở đâu nó sẽ bắn rocket theo hướng đó. Chú ý nhắm bắn mấy chiếc trực thăng cho chính xác hé.

  • Rocket : là quả đạn đó

  • Heli : là mấy chiếc trực thằng, mình config mỗi chiếc trúng 3 phát là nổ

  • UI : à, cái này hơi nhiều xíu. UI cần thể hiện các thông tin sau :
    • Shoot : số lượng Rocket mà thằng Marine đã bắn ra
    • Hit : số viên Rocket bắn trúng
    • Kill : số lượng trực thăng bị tiêu diệt
    • Miss : số lượng trực thăng thoát được.


Thế rồi cái Observer nó thể hiện ở chỗ nào?


Các event chính của game gồm:


  • Thằng Marine bắn (OnMarineShoot)
  • Rocket trúng chiếc trực thăng (OnBulletHit)
  • Trực thăng nổ (OnHelicopterDead)
  • Trực thăng chạy thoát (OnHelicopterEscaped)

Đối tượng lắng nghe các sự kiện này gồm :


  • UI objects

Hì hì, demo đơn giản nên chỉ có một thằng UI lắng nghe các sự kiện thôi :v, trong game thực tế sẽ có nhiều đối tượng lắng nghe.


Đối tượng nào “bắn” sự kiện nào?



  • Marine, lúc bắn viên Rocket, sẽ phát ra event OnMarineShoot

  • Rocket, khi trúng được Trực thăng, sẽ phát ra event OnBulletHit

  • Helicopter, khi chết sẽ phát ra event OnHelicopterDead

  • Helicopter, nếu thoát, sẽ phát ra event OnHelicopterEscape



Làm ơn cho vài dòng code với !?!?!


Đây, một số class chính, và các đoạn code quan trọng trong demo thể hiện cái “sự” ObserverPattern đây :


EventID.cs


Đây là chỗ mình định nghĩa tất cả event trong game, trong project thực tế các bạn cũng nên viết riêng EventID ra một file, ko nên viết chung vào các chỗ khác.
Demo nhỏ nên chỉ có 4 event, với project thực sự có thể đến vài chục :v


public enum EventID
{
    None = 0,
    OnMarineShoot,
    OnBulletHit,
    OnHelicopterDead,
    OnHelicopterEscaped,
}

EventDispatcher.cs


Đây cũng là class quan trọng nhất trong ObserverPattern, class hơi dài một chút nên mình không tiện paste hết vào đây. Chủ yếu vẫn là các hàm sau :


public class EventDispatcher : MonoBehaviour
{
	// Register to listen for eventID, callback will be invoke when event with eventID be raise
	public void RegisterListener (EventID eventID, Action<Component, object> callback);

	// Post event, this will notify all listener which register to listen for eventID
	public void PostEvent (EventID eventID, Component sender, object param = null);

	// Use for Unregister, not listen for an event anymore.
	public void RemoveListener (EventID eventID, Action<Component, object> callback);
}

/// An Extension class, declare some "shortcut" for using EventDispatcher
public static class EventDispatcherExtension
{
	/// Use for registering with EventDispatcher
	public static void RegisterListener (this MonoBehaviour sender, EventID eventID, Action<Component, object> callback)
	{
		EventDispatcher.Instance.RegisterListener(eventID, callback);
	}

	/// Post event with param
	public static void PostEvent (this MonoBehaviour sender, EventID eventID, object param)
	{
		EventDispatcher.Instance.PostEvent(eventID, sender, param);
	}

	/// Post event with no param (param = null)
	public static void PostEvent (this MonoBehaviour sender, EventID eventID)
	{
		EventDispatcher.Instance.PostEvent(eventID, sender, null);
	}
}

 


Ví dụ mẫu cho thao tác RegisterListener


Cụ thể là trong class UITextManager, nó cần lắng nghe các event trong game để thay đổi các Text trên Canvas.


void Start ()
{
	// register to receive events
	this.RegisterListener(EventID.OnMarineShoot, (sender, param) => OnMarineShoot());
	this.RegisterListener(EventID.OnBulletHit, (sender, param) => OnBulletHit());
	this.RegisterListener(EventID.OnHelicopterDead, (sender, param) => OnHelicopterDead());
	this.RegisterListener(EventID.OnHelicopterEscaped, (sender, param) => OnHelicopterEscaped());
}

/// Will be invoke when OnMarineShoot event was raised
void OnMarineShoot()
{
    // Write code to modify text on UI
}

/// Will be invoke when OnBulletHit event was raised
void OnBulletHit()
{
    // Write code to modify text on UI
}

/// Will be invoke when OnHelicopterDead event was raised
void OnHelicopterDead()
{
    // Write code to modify text on UI
}

/// Will be invoke when OnHelicopterEscaped event was raised
void OnHelicopterEscaped()
{
	// Write code to modify text on UI
}

 


Ví dụ cho thao tác PostEvent


Như mình đã nói ở trên, việc PostEvent sẽ được thực hiện ở Marine, Bullet và Helicopter. Mình chỉ để một ví dụ trong Marine.cs, các chỗ kia tương tự


void Update ()
{
	if (Input.GetMouseButtonDown(0))//left mouse
	{
	    	// code to Instantiate the bullet
	    
		// raise shoot event
		this.PostEvent(EventID.OnMarineShoot);
	}
}

 


Ví dụ cho thao tác RemoveListener


Ta sẽ dùng RemoveListener khi không muốn “nghe” sự kiện nào đó nữa. Thao tác này hơi rườm rà hơn một chút, mấy bạn đọc đoạn demo sau sẽ hiểu.


// A reference to our OnReceiveEvent() func, init in Start()
Action<Component, object> _OnReceiveEventRef;

void Start ()
{
	// Reference to our OnReceiveEvent, we'll use it to RegisterListener() and RemoveListener()
	_OnReceiveEventRef = (sender, param) => OnReceiveEvent();
	EventDispatcher.Instance.RegisterListener(EventID.OnBulletHit, _OnReceiveEventRef);
}

void OnReceiveEvent()
{
}


void UnregisterListener ()
{
	/// Dont use this way, it'll create a new Action, instead of a reference to our OnReceiveEvent(),
	/// then it still be invoked from EventDispatcher
	//EventDispatcher.Instance.RemoveListener(EventID.OnBulletHit, (sender, para) => OnReceiveEvent());
	
	// Right way
	EventDispatcher.Instance.RemoveListener(EventID.OnBulletHit, _OnReceiveEventRef);

 


Vì sao phải phức tạp thế nhỉ?


Vì hiện tại, EventDispatcher sử dụng Delegate C# để lưu trữ các listener. Để có thể sử dụng toán tử “-=” của delegate để loại 1 listener thì ta phải theo cách trên. Các bạn có thể đọc thêm tại đây để hiểu thêm về delegate của C#.


 




Một số vấn đề …….. nâng cao


Thế giờ tôi muốn PostEvent nhưng có truyền thêm tham số liệu có được không ?


Trong game không phải lúc nào cũng chỉ báo tin – nhận tin, đa phần khi PostEvent, chúng còn cần phải truyền đi một tham số gì đó
VD: với event OnPlayerHpChanged, khi post chúng ta cần phải gửi đi số HP hiện tại, vì các listener cũng cần biết thông số đó.
Rất đơn giản, ở đây với việc Register, ngoài tham số EventID, chúng ta cần một Action<Component, object>. Action là một feature của C#, các bạn có google để hiểu thêm cách dùng Action. Ở đây, mình custom một chút trong việc Register/PostEvent để có thể truyền/nhận thêm tham số:


//=================================
//===================== Listener

void Start()
{
	// Custom Action<Sender, Param> to receive event with a parameter
	this.RegisterListener(EventID.OnPlayerHpChanged, (sender, param) => OnPlayerHpChanged((int) param));
}

void OnPlayerHpChanged(int hp)
{
}

//=================================
//=====================  Sender

void OnDamage()
{
	// Post event with an Integer parameter
	this.PostEvent(EventID.OnPlayerHpChanged, _myHp);

Trong project, các bạn có thể thay đổi kiểu dữ liệu truyền đi, có thể là struct, class … tất cả đều có thể cast qua object khi PostEvent, ở phía Listener chỉ việc cast ra lại để truy xuất dữ liệu.


Thế vì sao không sử dụng multi parameter mà chỉ có 1 param object vậy? Thấy nó thiếu thiếu thế nào


Có bạn thắc mắc vì sao mình không làm EventDispatcher phần Post/Notify có thể gửi đi nhiều tham số, thay vì chỉ 1 tham số hơi bất tiện, ý các bạn định làm bắt chước kiểu string.Format như thế này :


// string.Format
public string Format (string format, params object[] arg);
var str = string.Format("This is format {0}, use multi param {1} to create a string {2}", "string", name.ToString(), 15);

// apply in EventDispatcher ?
this.PostEvent(EventID.OnPlayerDead, playerReference, playerHP, playerMoney ..); ???

Các bạn có thể đọc thêm trên mạng về cách thức hoạt động của keyword params object[] trong C#, thật sau mỗi lần các bạn sử dụng string.Format với nhiều tham số, bên dưới đó sẽ là quá trình tạo một mảng các object để lưu các tham số, cho nên mỗi lần gọi string.Format sẽ allocate thêm memory, thay vì chỉ đơn giản là gửi reference các tham số. Các bạn có thể xem thêm bài viết ở đây, tác giả có so sánh lượng memory hao hụt khi sử dụng params keyword. Vì lý do optimize memory, và để ông GC khỏi “quậy”, mình chỉ dùng 1 param object.


Tại sao phải sử dụng Enum thay vì làm string, khỏi tốn công ghi riêng ra 1 file EventID?


Như mình đã nói ở trên, EventID phải định nghĩa lại ở mỗi dự án, nên việc tách ra 1 file, rồi thay đổi file đó ở từng dự án vẫn tốt hơn.


Về việc sử dụng Enum thay vì string. Như ở trên, mình sử dụng Dictionary<Enum, List<>> (Keys là enum, và Value là List) để lưu trữ listeners, theo các bạn, mỗi lần Post/Register, cái Dictionary nó lookup trong cái Keys là Enum với Keys là string, cái nào nhanh hơn? :v ,tất nhiên là Enum rồi. Hoặc không thích bạn có thể thay enum = integer cũng đc. Tốt nhất để tối ưu việc lookup của Dictionary thì Keys phải đơn giản (enum, integer, bitmask &hellipWink


Khi không còn lắng nghe sự kiện, nên RemoveListener hay cứ để đó ?


Cơ chế hoạt động của GC trong C# là : khi không còn reference nào tới object nữa, thì object đó sẽ được giải phóng để thu hồi memory. Tương tự, nếu object đã registerListener với EventDispatcher nghĩa là nó đã có reference trong đó, dù cho bạn đã destroy object, nhưng thật ra nó vẫn còn trong memory, điều này không tốt đúng  không nào? Vì thế, theo khuyến cáo : khi không còn lắng nghe thì tốt nhất nên RemoveListener luôn, và khi Destroy object, cũng nên remove luôn cho lành.




Kết luận


ObserverPattern giải quyết vấn đề coupling trong project, vấn đề về việc “liên lạc” giữa các đối tượng trong game. Việc phân tích các đối tượng trong game sẽ dễ dàng hơn, không bị rối, tăng khả năng “sử dụng lại” code cho các đối tượng


Tất nhiên, với một project lớn có thể còn cần nhiều kiểu design khác nữa, đây chỉ là một trong số nhiều design được sử dụng phổ biến.


Cám ơn các bạn đã đọc bài. Ở các bài kế tiếp mình sẽ đề cập thêm các pattern ngon-bổ-rẻ khác, có thời gian mình sẽ viết bài về các chủ đề khác như:
– Làm game Multiplayer
– Shader trong Unity


Các bạn có ý kiến gì đóng góp hoặc thắc mắc, cứ để lại comment bên dưới, mình sẽ trả lời sớm nhất có thể.


 


Nguồn : ThoXayLamCoder