Thứ Bảy, 24 tháng 4, 2021

FlyWeight design pattern. Sự khác biệt giữa FlyWeight và Pooling

Trong lập trình nói chung và lập trình game nói riêng thì việc tối ưu phần cứng luôn được lập trình viên chúng ta quan tâm hàng đầu, đảm bảo người dùng trải nghiệm tốt nhất có thể. Đặc biệt trong bài này mình sẽ giới thiệu các bạn FlyWeight Pattern, một pattern sinh ra để optimize RAM và ứng dụng trong Unity thế nào cho hợp lý?

Vấn đề đặt ra?

Khi trong game của bạn yêu cầu tạo ra một lượng lớn object, mà mỗi object chứa quá nhiều tài nguyên -> Tổ chức không tốt sẽ khiến cho RAM phải cache lại rất nhiều thứ, đây là điều thực sự không tốt, phải được optimize

Ví dụ bạn có một class chứa 3 thuộc tính

public class MyObject
{
 
    int currentHeal;
    int maxHealh;
    float moveSpeed;
}

ta sẽ tạo 3 instance của class này, lúc này trên RAM sẽ cache lại như sau

Source: Sqrly Code






Trong ảnh trên, 3 màu đại diện cho mỗi instance của class đó, vậy thì lưu như vậy có hợp lý không?

Rõ ràng là không, bạn để ý có những thuộc tính hoàn toàn chỉ cần 1 vùng địa chỉ để lưu nó, cụ thể là maxHP, nhưng khi tạo ra từng instance thì mỗi instance sẽ tạo ra 1 copy của class và cache nó vào trong RAM như trong ảnh => cực kì tốn vùng nhớ khi cần khởi tạo một lượng lớn đối tượng

FlyWeight là gì?


FlyWeigh pattern đơn giản chỉ là cách mà ta tối ưu bộ nhớ bằng cách chia sẽ các tài nguyên có thể tái sử dụng lại hoặc dùng chung giữa nhiều object khác nhau.

Đơn giản là vậy, vậy thì để áp dụng FlyWeight ta chỉ cần phải trả lời câu hỏi sau

Thuộc tính nào sẽ dùng chung cho mọi instance?

Từ câu hỏi này, mình rút ra được 2 nguyên tắc cho FlyWeight như sau

  • Mỗi instance chỉ nên lưu biến mà giá trị của nó sẽ khác biệt hoặc sẽ thay đổi so với các instance còn lại
  • Biến dùng chung cho mỗi instance không nên được sao chép bởi các instance khác

Oke, tiếp tục với ví dụ trên, sau khi ta cache lại các thuộc tính dùng chung như maxHP và movSpeed, thì RAM sẽ có dạng như này
Source: Sqrly Code


Làm sao để áp dụng FlyWeigh

Khi ta làm việc với các texture hay material thì bạn để ý trong unity đã support chúng ta sẵn FlyWeight build-in, khi ta có thể tái sử dụng 1 material cho nhiều object khác nhau. Nhưng để tự implement FlyWeigh thì ta có các cách sau đây

Const - Static varibles

Yup!, chúng ta sẽ nghĩ đến ngay const và static vì các biến này chỉ được tạo ra duy nhất 1 lần. Tuy nhiên đào sâu hơn một tí, khi dùng const hay static nó thực sự chỉ có lợi khi team bạn không có Game Designer, vì khi dùng các biến này, chúng ta không thể control chúng trong inspector, đổi lại điều này rất tiện dụng khi team bạn toàn Dev vì có thể nhanh trong truy cập vào file script config để sửa lại thông số. Ez game

Nhưng chúng ta sẽ ưu tiên việc chúng ta sẽ hoạt động cùng Game Designer, nếu xài các này thì việc control các stats trong game cực kì khó khắn đối với 1 GD, hầu hết thời gian của họ không phải mở script lên sửa như Dev chúng ta

ScriptableObject

Sức mạnh của ScriptableObject là đây, cực kì mạnh mẽ khi áp dụng FlyWeight pattern, dễ teamwork với GD, mình đã có ghi rõ trong ScriptableObject không chỉ để lưu data

Sự khác nhau giữa FlyWeight và Object Pooling?

Thật sự rất rất nhiều người không thể phân biệt nổi 2 pattern này với nhau, nhưng rất đơn giản khi chúng ta đào sâu vào gốc rễ của 2 khái niệm, ta dễ dàng phân biệt được
  • FlyWeight: Giảm tải cho RAM bằng cách tách các data dùng chung sang một object FlyWeight 
  • Pooling: Giảm thiểu việc tính toán trên CPU khi tránh việc Instantiate và Destroy liên tục. Tuy nhiên sẽ tốn nhiều RAM hơn để cache object
Tóm lại đơn giản thì FlyWeight tối ưu RAM, còn Pooling thì tối ưu CPU vậy thôi, khỏi bàn cải tranh luận gì nữa, kết bài, đi ngủ.

Tổng kết

Đùa thôi, đấy là góc nhìn từ phía mình, nếu có gì thắc mắc hay góp ý cho bài viết của mình, đừng ngại comment cho mình biết nhóe, thansk for reading


Share:

Thứ Bảy, 20 tháng 3, 2021

Update, Couroutine và InvokeRepeating

Hello mí bạn, sau những bài toán toát mồ thì hôm nay chúng ta sẽ bàn về một số thứ cơ bản một tí, cụ thể là về Update, Coroutine và InvokeRepeating. Nói cơ bản thế thôi chứ bên trong mình có bonus một vài lưu ý cũng khá là nâng cao cho, vì vậy đây là bài viết cũng sẽ nặng lý thuyết nên các bạn lưu ý trước khi đọc.

Update

Đối với mấy bạn mới học, thường sẽ gọi tất cả mọi thứ chim chóc, múa lửa, … trong update một cách thường xuyên, nhiều hơn số lần mà ta cần nó thực thi. Cho ví dụ sau
void Update
{
    ProcessAI();
}
Trong ví dụ trên ta sẽ gọi ProcessAI trong mỗi frame, và giả định rằng đây là một task cực kì phức tạp, yêu cầu AI phải tìm đường đi với chi phí thấp nhất, kiểm tra trong grid system xem target đang ở vị trí nào hoặc cần di chuyển đến đâu…

Nếu bạn từng làm qua với NavMesh Component thì việc này cực kì ngốn frame rate của chúng ta (bạn có thể dùng A* thay vì NavMesh trong unity để cái tiến). Nhiều khi bạn sẽ nhận ra rằng không phải lúc nào cũng nên gọi ProcessAI(), ví dụ di chuyển đến 1 target thì chỉ cần gọi Process AI 1 lần là đủ nhưng bạn không biết thì khi nào nên gọi?

Có một trick nhỏ giúp bạn cái thiện performance như sau


private float _aiProcessDelay = 0.2f;
private float _timer = 0.0f;
void Update()
{
 
    _timer += Time.deltaTime;
 
    if (_timer > _aiProcessDelay)
    {
 
        ProcessAI();
 
        _timer -= _aiProcessDelay;
 
    }
}



Một tips nhỏ để cái thiện performance trên, tiêu tốn một ít memory cho floating-point. Mặc dù nhiều lúc nó vẫn sẽ gọi ProcessAI() dư thừa

Ví dụ trên cũng chính là điều kiện cực kì tốt cho ta gọi Couroutine để delay ProcessAI()


Coroutine


Couroutine thường dùng để gọi một chuỗi sự kiện ngắn, các hành động được gọi một lần hoặc lặp lại


Một điều lưu ý là Coroutine không phải là đa luồng, thứ mà sẽ chạy trên các cpu core khác nhau và đồng thời. Ngược lại Couroutine chạy trên main thread theo cách tuần tự và mỗi couroutine sẽ được xử lý tại bất kỳ thời điểm nào. Mỗi couroutine đều có thể tùy biến pause hay resume bằng cách dùng các câu lệnh yield



Ví dụ cho đoạn code dưới đây
void Start()
 
{
    StartCoroutine(ProcessAICoroutine());
}
IEnumerator ProcessAICoroutine()
{
    while (true)
    {
 
        ProcessAI();
 
        yield return new WaitForSeconds(_aiProcessDelay);
    }
}

Đoạn code trên chính là 1 courouine, nó call ProcessAI và sau đó pause vài giây bằng câu lệnh yield sau đó lặp lại mãi mãi trừ khi dev cho nó dừng lại bằng một điều kiện nào đó.

Lợi ích của cách làm trên là nó sẽ dừng lại cho đến khi câu lệnh yield hoàn thành, giảm bớt đi sự truy cập liên tục trong hầu hết các frame của chúng ta. Tuy nhiên cách làm này cũng có một số mặt hạn chế


Khi bắt đầu mỗi coroutine, nó sẽ đi kèm với một overhead cost (chi phí chung) cũng như là memory allocation để lưu trữ trạng thái hiện tại cho đến lần invoke kế tiếp, và tất nhiên chi phí này không phải là chi phí 1 lần duy nhất, mà nó sẽ allocate liên tục mỗi khi coroutine bắt đầu (vì nó yield liên tục), nó sẽ cung cấp chi phí tương đương với các lần gọi trước, cứ liên tục như vậy => GC phải hoại động liên tục, vì vậy chúng ta phải đảm bảo rằng khi ta giảm tần suất gọi của ProcessAI thì lợi ích của nó phải outweight lượng chi phí này (hay gọi là garbage do Coroutine sinh ra cũng được)


Fact: 1000 empty Update 1.1 milisecond và 1000 Coroutine WaitEndOfFrame 2.9 miliseconds
=> chi phí tương đối hầu như là gấp 3 lần

Điều đặc biệt là coroutine sẽ phụ thuộc vào Monobehavior chứa nó, khi disable component (hay inactive gameobject) => coroutine sẽ dừng hẳn luôn.

Có vài loại yield chúng ta hay dùng đó là
  • WaitForSecond (Coroutine sẽ pause và thời gian chờ là tham số đầu vào)
  • WaitForSecondRealTime: Một loại khác của WaitForSecond nhưng nó không phụ thuộc vào Timescale
  • WaitForEndOfFrame: nó sẽ chờ cho đến khi Update() kết thúc hoặc bắt đầu vào lần tiếp theo
  • WaitForFixedUpdate: chờ cho đến khi FixedUpdate() kết thúc hoặc bắt đầu vào lần tiếp theo
  • v.vvv

Bonus: Từ unity 5.3 đã cung cấp cho chúng ta WailUntil WaitWhile, chúng ta có thể cung cấp đầu vào cho nó và coroutine sẽ pause cho đến khi delegate trả về true or false và tất nhiên đừng cung cấp delegate có chi phí quá đắt đỏ.

Tham khảo bài delegate Tại đây 

Cuối cùng, việc chuyển method chúng ta vào coroutine sẽ làm giảm việc gọi liên tục các method vào mỗi frame , tuy nhiên khi chi phí thực hiện của một method quá nhiều so với việc chờ đợi thì nó sẽ vượt quá thời gian chờ mong muốn của chúng ta. Và tránh thực hiện một complex task trong một coroutine vì coroutine sẽ thực hiện tại thời điểm nó được gọi và không tuân theo callback => dẫn đến cực kì khó khăn trong việc debug. Lời khuyên từ mình đó là giữ couroutine của bạn thật đơn giản và đừng để nó phụ thuộc vào bất kì complex subsystem nào

InvokeRepeating

Khi coroutine của bạn đủ đơn giản để nó tạo thành 1 while loop, chúng ta có thể thay thế nó bằng InvokeRepeating, thứ mà cực kì đơn giản để setup mà overhead cost chả đáng bao nhiêu. Ta thay ví dụ trên bằng cách
void Start()
{
    InvokeRepeating("ProcessAI", 0f, _delayTime);
}

Một điều khác biệt quan trọng giữa InvokeRepeating và Coroutine đó là InvokeRepeating không phụ thuộc vào Monobehavior hay trạng thái của GameObject. Chúng ta có thể dừng chúng bằng CancelInvoke(), thứ sẽ stop toàn bộ InvokeRepeating() hoặc destroy MonoBehavior hay parent GameObject của nó. Lưu ý: Disable MonoBehavior hay GameObject không stop InvokeRepeating

Fact: 1 chạy 1000 InvokeRepeating xử lý trong 2.6 ms tuy nhiên 1000 Coroutine thì trong 2.9ms

Performance của Invoke/InvokeRepeating luôn tốt hơn Coroutine, tuy nhiên Invoke thì không thể truyền tham số nhưng Coroutine thì có thể, cho nên tùy trường hợp mà dùng nhé

Đọc thêm bài về Invoke và Coroutine của sư phụ mình Tại đây

Tổng kết

Đó là những gì cơ bản và một chút nâng cao cho các bạn về các kiến thức liên quan đến Update, Coroutine và InvokeRepeating. Có khi các bạn phải đọc 2 - 3 lần mới load hết được :)). Oke nhé, nếu có gì thắc mắc hay câu hỏi gì thì hãy comment dưới bài này để mình giải đáp, cám ơn các bạn đã đọc/

Thói quen

ủy nhiệm


noun

đại biểu, đại diện, người được ủy nhiệm


verb

giao quyền, ủy nhiệm

Share:

Chủ Nhật, 28 tháng 2, 2021

Tích vô hướng (Dot Product) là gì? Làm sao để ứng dụng trong lập trình game?

 Hello mụi người, cũng hơn 40 ngày nghỉ tết thì cũng bắt đầu trở lại trường rồi, tranh thủ rảnh rỗi mấy ngày cuối giới thiệu cho mấy bạn thấy được sức mạnh của Tích Vô Hướng chứ thấy mấy bạn if else hơi cực, chưa kể if else mà sai logic thì lại hư các game.

 Tích vô hướng cũng chả xa lạ gì đối với các bạn vì nó đã được dạy trong chương trình phổ thông, cụ thể là sách toán lớp 10. Cũng nhớ hồi đó lười học mà tơi chương Vector chả hiểu sao ham học nên giờ cũng còn nhớ sơ sơ :))

Đây là một bài rất nặng lý thuyết, các bạn nhớ trang bị kĩ kiến thức toán khi đọc nhé

Tích vô hướng là cái vẹo gì?

Tích vô hướng (tên tiếng Anh: dot product hoặc scalar product) là một phép toán đại số lấy hai chuỗi số có độ dài bằng nhau (thường là các vectơ tọa độ) và cho kết quả là một số. Trong hình học Euclid, tích vô hướng với tọa độ Descartes của hai vectơ thường được sử dụng. Tích vô hướng cũng thường được gọi là tích trong Euclid dù nó không phải là loại tích trong duy nhất có thể được định nghĩa trong không gian Euclid (xem thêm tại Không gian tích trong).  (Theo wikipedia)

Đọc xong cái định nghĩa tắt mẹ cái blog đi ngủ cho khỏe, hù chút thôi chứ mình cũng có hiểu mẹ gì đâu, tuy nhiên phát biểu đại số của nó sẽ dễ hiểu hơn thế này

ab=i=1naibi=a1b1+a2b2+...+anbn

Ví dụ:

Đây là phát biểu kiểu công thức xưa mình hay dùng. Tuy nhiên đối với mình tích vô hướng là độ lớn của vector VÀ độ lớn của phép chiếu của vector còn lại lên lên nó

được phát biểu công thức dưới dạng

(bản chất của Tích Vô Hướng)


Ứng dụng trong Đường Tròn Lượng Giác

Và đặc biệt hơn Chúng ta thường áp dụng Dot Product trong đường tròn lượng giác hay Unit Circle (Có bán kính = 1) hơn thay vì các trường hợp tổng quát



Khi áp dụng trong Unit Circle, ta sẽ có hình chiếu của vector lên vector còn lại cũng chính là tích vô hướng của 2 vector (bạn có thể chứng minh từ công thức trên)

Trong gif trên, đường màu tím chiếu vuông góc từ vector quay chính là tích vô hướng của 2 vector đó

Ứng dụng trong lập trình game

Trong unity đã implement công thức tích vô hướng, chúng ta chỉ việc lấy ra xài thôi. Đó chính là Vector2.Dot or Vector3.Dot

Sau đây mình sẽ liệt kê 2 cách mình hay dùng khi ứng dụng Dot, đây chỉ là ứng dụng rất rất nhỏ của Dot Product

1.Xác định vị trí vật thể

Các bạn thử suy nghĩ, có 2 object như hình sau, với player là khối chữ nhất màu xanh và enemy là hình tròn màu đỏ. Vậy làm sao để kiểm tra khi nào enemy nằm ở: trước, sau, trái, phải của Player.



Mình sẽ chắc chắn trong đầu các bạn sẽ là 1 loạt các dòng if else dài ngoằn chỉ để check. Player nằm vậy còn đơn giản nhé, chứ nó mà xoay ngang, xoay dọc thì bạn if else hồi loạn xì ngầu lên á :))

Dot Product hỗ trợ ta làm việc này cực tốt. Quay lại gif trên, để ý vector hướng lên trên là vector hướng của player, và vector quay là vector từ player tới enemy. Khi đó ta có Dot > 0 => nó nằm ở phía trước của player và ngược lại Dot < 0 ta có nó nằm phía sau player.

Tiếp tục check trái phải. Để ý khi chiếu vector xoay lên trục hoành (trục Cos) thì khi Dot >0 thì enemy nằm bên phải và khi Dot < 0 thì enemy nằm bên trái. Very easy.

[SerializeFieldTransform enemy = default;
 
private void Update()
{
    //DOT PRODUCT
    Vector2 playerToEnemy = enemy.position - this.transform.position;
    float dotProduct;
 
    //FRONT , BEHIND
    dotProduct = Vector2.Dot(transform.up, playerToEnemy);
    if (dotProduct > 0Debug.Log("Front");
    else if (dotProduct < 0Debug.Log("Behind");
    else Debug.Log("Middle");
 
    //LEFT , RIGHT
    dotProduct = Vector2.Dot(transform.right, playerToEnemy);
    if (dotProduct > 0Debug.Log("Right");
    else if (dotProduct < 0Debug.Log("Left");
    else Debug.Log("Middle");
}

2.Điều chỉnh âm thanh khi va chạm

Vấn đề là khi player va chạm với một vật thể nào đó, để chân thật nhất chúng ta phải tính volume sao cho to nhỏ hợp lý với lực tác động.





Trong hình trên, hình tròn là player, di chuyển với v = 10m/s hướng xuống đất.


Nhiều bạn nhanh lẹ sẽ suy nghĩ rằng. Đặt volume = độ lớn vận tốc. Ok tạm chấp nhận được, nhưng hay xét đến trường hợp dưới đây







Trong TH2 ta thấy quả bóng gần như là chạm mặt đất nhưng vẫn di chuyển với v = 10 m/s. => Khi chạm đất volume nó sẽ ngang quả bóng thả thẳng từ trên cao xuống => sai logic

Dot Product sẽ hỗ trợ ta giải quyết chuyện này nhanh gọn, xem hình dưới đây (Lưu ý: vector hướng mình vẽ là vector vận tốc (v) đấy nhé)








Chỉ cần lấy Dot Product giữa Vector vận tốc và vector hướng lên (Vector2.Up) là xong => Dot Product = khoảng cách màu đỏ như hình vẽ (do Vector2.Up có độ lớn = 1)


Đây là cách mà mình Implement

[SerializeFieldAudioSource _audio;
[SerializeFieldRigidbody2D _rig;
 
private void OnValidate()
{
    _audio = GetComponent<AudioSource>();
    _rig = GetComponent<Rigidbody2D>();
}
 
 
private void OnCollisionEnter2D(Collision2D collision)
{
    _audio.volume = Mathf.Abs(Vector2.Dot(transform.up, _rig.velocity))/ 10f;
    _audio.Play();
}


Tổng kết

Sức mạnh của Dot Product vẫn còn rất nhiều, đó là mình chỉ demo sơ sơ cho bạn xem thôi đó. Đại đa số các game developer tận dụng sức mạnh của Dot Product rất triệt để. Oke, bài này hơi nặng lý thuyết nhưng mong các bạn cố hiểu và nếu có gì thắc mắc nhớ để lại comment để mình giải đáp nhé.


Share:

Thứ Bảy, 20 tháng 2, 2021

ScriptableObject không chỉ để lưu data (P1)

Intro  

ScriptableObject là một class kế thừa trực tiếp từ class Object, là nơi chứa dữ liệu của game chúng ta. Trong phạm vi bài này mình sẽ không đi sâu về lý thuyết cơ bản của ScriptableObject, mà là cách vận dụng nó sao cho hiệu quả và dùng hết khả năng của ScriptableObject vào game architecture

Để đọc được bài này mọi người nên tìm hiểu ScriptableObject trong Unity và cách dùng nó cơ bản.Học tại đây

 Chúng ta có thể hiểu trách nhiệm của ScriptableObject là lưu data ,nhưng theo mình thì cách dùng ScriptableObject không chỉ đơn giản là như vậy mình thấy mọi người thường đặt nghi vấn: "Tại sao không khai báo biến thẳng vào class mà chúng ta nên xài ScritptableObject?". Như mọi người đều biết trong team chúng ta luôn có Game Designer và làm sao để GD có thể tham gia vào quá trình config game của chúng ta như chỉnh sửa thông số máu của nhân vật qua mỗi level, damage của boss, exp lên level,.. Và chúng ta có ScripTableObject để thực hiện điều này. 

Làm việc hiệu quả hơn với Game Designer

Tiếp tục vấn đề trên, bạn sẽ tiếp tục đặt ra câu hỏi: "Thằng GD có thể config trực tiếp trong prefab mà tại sao là dùng ScriptableObject?". Khi một team làm việc với designer, designer muốn chỉnh sửa thông số của enemy, player hay map thường sẽ phải vào file prefab và chỉnh => mỗi object lại phải tìm 1 file trong prefab. Trong khi đó 1 prefab có thể đặt " chằn chịt " các con số public dẫn đến rổi rắm và hiệu suất công việc giảm mạnh.

Đó là lý do mình khi làm việc với Game Deisnger nên dùng ScriptableObject nhé. Thằng nào còn thắc mắc đấm vỡ mồm. 

Reduce Project's memory

Trong docs của Unity đã rất rõ ràng điều này, nhưng mình sẽ lấy ví dụ cho mấy bạn hiểu. Nôm na là một Enemy Object có 10 thông số float trong nó và trong game sẽ có rất nhiều loại Enemy giống nó, giả sử đó là 5 enemy => game sẽ cần bộ nhớ để lưu trữ 5*10 float field đó. Thay vào đó ta sẽ dùng ScriptTableObject là 1 file config duy nhất, định nghĩa các field có thể có và tất cả enemy sẽ tham chiều tới file Config đó và lấy ra các thuộc tinh.

Điều này rất hữu dụng khi dùng một số dữ liệu không thay đổi trong game.

Giảm dependency

Theo dõi đoạn code sau:

Ta có Player với chỉ số HP = 100 khi bắt đầu game

public class Player : MonoBehaviour
{
    public float HP;
    private void Start()
    {
        HP = 100;
    }
}

PlayerUI sẽ display chỉ số HP đó lên màn hình cho người chơi dưới dạng thanh cuộn ngang



Để thực hiện điều đó ta implement PlayerUI như sau:
public class PlayerUI : MonoBehaviour
{
    public Player player;
 
    private void Update()
    {
        transform.localScale = new Vector3((player.HP / 100),transform.localScale.y,transform.localScale.z);
    }
}

Lưu ý: đây là cách làm không tốt, mình chỉ demo nhanh cho các bạn xem và cách làm tốt hơn là dụng Event ở một bài của mình Tại đây , đó cũng chính là giải pháp tối ưu  cho bài toán này, tuy nhiên trong khuôn khổ bài này mình sẽ chỉ nói về ScriptableObject

Ví dụ: Khi một player bị tấn công bơi enemy khiến player bị giảm HP -> HP UI sẽ tham chiếu đến HP Player và display nó khi có sự thay đổi.Tiếp tục, khi máu của player dưới 20% thì Audio sẽ thay đổ nhịp độ -> tiếp tục khiến Audio phải tham chiều đến Player tạo ra nhiều sự phụ thuộc cứng.Tồi tệ hơn khi có nhiều EnemyAI tham chiều tới Player để xác định và tấn công 

Ở ví dụ trên ta thấy ở mỗi Component ta đều tham chiếu đến Player dẫn đến 1 só trường hợp sau

  • Disable Player, hư cả game
  • Thay đổi nhẹ cấu hình trong Player, hư cả game
  • Chúng ta tham chiếu đến Player nhưng không dùng hết sức mạnh của Player Component
Những điều này khiến cho mã nguồn bị kết dính nghiệm trọng

Vậy giải pháp là gì?

Như những gì chúng ta đã phân tích thì ScriptableObject là một object lưu trữ dữ liệu độc lập với code base. Vậy thì chỉ cần các object khác như UI , Audio, EnemyAI khác tham chiếu đến ScriptableObject này là được chả việc gì chúng ta phải reference cứng Player cả, ngoài ra ScriptableObject một khi đã khởi tạo thì luôn tồn tại => không sợ break game.

Vậy chúng ta sẽ sửa code bên trên 1 tí lại như sau

[CreateAssetMenu(fileName ="PlayerConfig" , menuName = "GameConfiguration/PlayerConfig",order =1)]
public class PlayerConfig : ScriptableObject
{
    public float HP;
}


Ta sẽ ném thuộc tính HP vào ScriptableObject và tạo một file Config riêng cho nó

Tại những nơi chúng ta cần tham chiếu đến HP, khái báo public PlayerConfig ví dụ như PlayerUI

public class PlayerUI : MonoBehaviour
{
    public PlayerConfig player;
 
    private void Update()
    {
        transform.localScale = new Vector3((player.HP / 100),transform.localScale.y,transform.localScale.z);
    }
}


Như vậy, code của chúng ta đã hết bị tightly coupling và chuyển về loose coupling 



Tổng kết

Trong phần này mình đã giới thiệu một vài sức mạnh của ScriptableObject cho các bạn, phần sau sẽ năng hơn về logic nhưng lại cực kỳ hữu dụng đối với các team muốn tận dụng sức mạnh của Game Designer. Thanks for reading.

Share:

Thứ Hai, 15 tháng 2, 2021

Delegate, Event, là gì? Ứng dụng trong lập trình game như thế nào?

 

Trước khi đi vào các khái niệm về delegate và event. Ta cần phải hiểu một loại design pattern ứng dụng mạnh mẽ delegate và event. Đó là Observer Design Pattern.

Mình sẽ nói chi tiết về Observer Design Pattern về một bài riêng, trong phạm vị bài này mình sẽ nói sơ lược qua nó và cách ứng dụng delegate và event để tạo ra các Observer như thế nào.

Vấn đề đặt ra

Trong 1 game gồm player và các enemy. 
Khi player Kill 1 enemy => phía UI sẽ tăng lên 1 đơn vị, mình thấy đại đa số các bạn sẽ GetComponent<X> (với X là component sẽ render ra UI) trực tiếp trong method Kill():

   void Kill()
    {
       ScoreDisplay score = GameObject.Find("ScoreText").GetComponent<ScoreDisplay>();
       score.Update();
    }


Tiếp tục, Khi player dùng 1 vật phẩm nào đó, sẽ gọi đến UI chứa vật phẩm đó để giảm số lượng nó đi 1 đơn vị


    void UseItem()
    {
       Item item = GameObject.Find("ScoreText").GetComponent<Item>();
       item.Update();
    }

Tượng tượng khi dùng item thì mình sẽ play một audio nào đó => lại tiếp tục Getcomponent đén audio và play.

Sau một hồi đặt ra ví dụ, chúng ta thấy càng scale thì code của chúng ta sẽ phát sinh một số thứ sau đây:

  • Spaghetti code (code rối rắm)
  • Tạo ra các depedency cứng
  • Highly coupled code
  • Mất 1 phần tử nào đó => break cả game
  • 1 thay đổi nhỏ => break cả game

Observer Design Pattern sinh ra để giải quyết vấn đề này

Observer Pattern là gì?

Một object (người thông báo) sẽ gửi broadcast messenger đến một hoặc nhiều object khác được lựa chọn (subscribe) và phải "do something" dựa trên những thứ nó đã đăng kí.

Giải thích ý tưởng trên là thế này: chúng ta sẽ tạo 1 biến static là người thông báo và người thông báo này có trách nhiệm sẽ gửi messenger cho toàn bộ những người lắng nghe nó, và những người lắng nghe nó sẽ phải thực hiện 1 hành động nào đó khi có messenger gửi tới. Chúng ta có delegate để thực hiện điều này.


  

Lợi ích của Observer Pattern

  • Dễ dàng giao tiếp giữa các object
  • Dễ dàng chia sẽ thông tin
  • Phân tách object và code dễ dàng, tránh bị couple
  • Loại bỏ các dependency không mong muốn
Như vậy sơ lược của Observer Pattern là như thế, vậy các thực hiện nó như thế nào, ta phải sẽ bắt đầu tìm hiểu về Delegate và Event.

Delegates

Nếu bạn đã học qua c++ thì chả lạ gì khái niệm con trỏ hàm, delegates chính là khái niệm tương tự trong c#. Các biến tạo ra từ delegate có thể tham chiếu đến các phương thức cùng kiểu với delegate, ngoài ra có thể truyền vào function như một callback

//Khai báo định nghĩa các delegate
public delegate void VoidDelegate();
public delegate int IntDelegate();
public delegate void DelegateParameter(int a, int b);
static void PrintInt() => Console.WriteLine("Int");
 
static int ReturnInt()
{
    return 5;
}
 
static void PrintDoubleInt(int a, int b) => Console.WriteLine("{0} - {1}", a, b);
 
static void Main(string[] args)
{
    //Tạo ra instance của delegate
    VoidDelegate myDelegate = PrintInt;
    DelegateParameter delegateParameter = PrintDoubleInt;
    IntDelegate intDelegate = ReturnInt;
    intDelegate = PrintInt; //Error
 
}

Xem đoạn code trên, để gán bằng một delegate thì method phải cùng kiểu với delegate đó

Và để thực thi delegate ta có method Invoke.

Invoke sẽ thực thi tất cả Subscriber (định nghĩa ở bên dưới) có trong delegate đó

Console.WriteLine(intDelegate?.Invoke()); // Output: 5
myDelegate?.Invoke(); // Ouput: Int

Dấu ? trước delegate là kiểm tra xem nó có phải null không, nếu nó là null thì không invoke. Nếu bạn bỏ qua nó thì nó sẽ throw exception.

Nhiêu đây vẫn chưa thể hiện sức mạnh sủa delegates, sức mạnh của delegate nằm ở chỗ khả năng multiple functions assigned, có thể += nhiều function và invoke cùng lúc.

+= : tăng thêm 1 subscribe cho delegate đó

-= : giảm đi 1 subscribe cho delegate đó

các đối tượng subscribe phải cùng kiểu định nghĩa với delegate

Ta có ví dụ sau:

//Khai báo định nghĩa các delegate
public delegate void VoidDelegate();
static void PrintInt() => Console.WriteLine("Int");
static void PrintString() => Console.WriteLine("String");
static void PrintTanDepTrai() => Console.WriteLine("Tan dep trai vai l*n");
 
static void Main(string[] args)
{
    //Tạo ra instance của delegate
    VoidDelegate myDelegate = null;
    myDelegate += PrintInt; // Add subscriber
    myDelegate += PrintString; 
    myDelegate += PrintTanDepTrai; 
    myDelegate?.Invoke();  //Output là cả 3 function trên
 
    Console.WriteLine("_________");
 
    myDelegate -= PrintInt;
    myDelegate?.Invoke();  // Output mất đi PrintInt
}




Event

Sau khi đã biết delegate, event là một khái niệm khá dễ hiểu, mình sẽ tóm lược như sau:
  • Về bản chất Event là một delegate
  • Khi khởi tạo 1 event, thực chất chỉ là khởi tạo một instance delegate
  • Mọi thao tác đều giống delegate
  • Ngăn chặn overwriten (gán bằng) một function
  • Ngăn chặn public Invoking, tức là bên ngoài class không thể gọi ?.Invoke
public class Testing
{
    public delegate void DelegateTemplate();
    public static event DelegateTemplate example;
 
    void Test()
    {
        example?.Invoke();  
    }
 
}

Ta có class Testing chứa event example (là 1 instance của delegate) và có thể gọi invoke trong nội bộ của class đó

Ta có đoạn code mẫu duối đây
class Program
{
    static void PrintInt() => Console.WriteLine("Int");
    static void PrintString() => Console.WriteLine("String");
    static void PrintTanDepTrai() => Console.WriteLine("Tan dep trai vai l*n");
    
    static void Main(string[] args)
    {
        Testing.example += PrintInt;
        Testing.example += PrintString;
        Testing.example += PrintTanDepTrai;
 
        Testing.example?.Invoke(); // Error - không thể gọi invoke ở đây
 
    }
 
}
Tại class Program, gọi đến testing và subscribe cho example các method ở ví dụ trước, tuy nhiên ta không thể gọi ?.Invoke tại class này

Vậy thì ứng dụng trong lập trình game như thế nào?

Trong lập trình game, cụ thể là Unity - C#, mình sẽ ít dụng delegate và event lý do là nó khai báo nhiều :)), thay vào đó mình sẽ dùng Action Func

Đừng sợ, nó chỉ là đơn giản hóa của delegate mà thôi.

Thay vì bạn phải khai báo một Delegate và tạo ra một instance của nó với 2 dòng code, thì Action làm việc đó chỉ với 1 dòng và tương tự với Func

public delegate void VoidDelegate(int a);
public static VoidDelegate voidDelegate;
 
//Same
 
public static Action<int> voidDelegate;

or

public delegate string MyDelegate(int);
public static MyDelegate myDelegate;
 
//same
 
Func<intstring> myDelegate;

Action: là một delegate void với các parameter 

Func : là một delegate với kiểu dữ liệu giống với parameter cuối cùng

Lưu ý: Do Func có giá trị trả về => giá trị của nó sẽ là method cuối cùng được invoke

Oke, xong cơ bản về Func và Action. Như những gì mình đã nêu ở mục đặt vấn đề ta thường dùng để giao tiếp giữa gameplay và UI ví dụ như tăng điểm số, trừ máu, mana point, ...

Mình sẽ demo nhỏ một ví dụ ứng dụng vào game như sau.

Trong class GameManager ta có:

public static Action<int> e_SetScore;
 
[Header("Game state")]
public bool isPause;
public int gameScore
{
    get => _gameScore;
    set
    {
        _gameScore = value;
        e_SetScore?.Invoke(_gameScore);
    }
}

Ta có một Action với đầu vào là một số nguyên dùng để Set Score cho UI khi player ăn điểm

Khi ăn điểm thì property gameScore++ , lúc này thực hiện e_SetScore?.Invoke()

Và nơi ta đăng kí observer cho event này sẽ là UIManager

Tại UIManager ta có:

public void SetScore(int score)
{
    text_Score.text = score.ToString();
}
private void OnEnable()
{
    GameManager.e_SetScore += SetScore;
    SetScore(0);
}
 
private void OnDisable()
{
    GameManager.e_SetScore -= SetScore;
}

Oke, thay vì cứ đặt trong update liên tục ta sẽ dùng cách này giúp ta tối ưu game performance hơn, đặc biệt khi UnActive UIManager cũng sẽ chẳng làm ảnh hướng đến Gameplay

Đây chỉ là một phần của một ứng dụng rất rất nhỏ của event và delegate trong c#, còn rất nhiều, mình thì còn hay dùng khi truyền callback cho một function nữa nhưng mà thêm vào thì bài này lại dài quá, các bạn từ tìm hiểu nhé :v

Tổng kết

Vậy ứng dụng cực kì mạnh mẽ của delegate và event giúp chúng ta giải quyết được rất nhiều vấn đề, khiến cho mã nguồn trông sạch sẽ hơn, giúp bạn lên level mới trong lập trình game.

Okay, nếu thấy hay thì nhớ share :)) có gì thắc mắc thì nhớ comment bên dưới đề mình giải đáp nhé, cảm ơn mọi người đã đọc.

Share: