Game Programming Patterns (P2)

Cùng điểm qua các design pattern hay sử dụng trong phát triển game

Giới thiệu

Ở phần trước chúng ta đã cùng nhau tìm hiểu về các nguyên tắc thiết kế như KISS và SOLID. Hôm nay chúng ta sẽ cùng tìm hiểu về các Design Pattern hay sử dụng trong lập trình game với Unity3D.

Design Patterns cho phép bạn sử dụng lại các giải pháp đã kiểm chứng cho các vấn đề hay gặp phải. Tuy nhiên Design Pattern không phải là thư viện (library) hay framework sẵn có, nó cũng không phải là một thuật toán mà là tập hợp các bước cụ thể để đạt được kết quả.

Bạn có thể nghĩ Design patterns giống như một bản thiết kế, là một kế hoạch chung. Mọi triển khai xây dựng trong thực tế tùy thuộc vào bạn. Hai trò chơi sử dụng cùng một Design Pattern có thể có code khác nhau.

Khi các lập trình viên gặp phải cùng một vấn đề nhiều lần, nhiều người trong số họ đã đưa ra giải pháp tương tự nhau. Khi giải pháp (solution) đó lặp lại số lần đủ lớn. Và ai đó đã ban phát tên gọi cho giải pháp đó.

Học cách sử dụng Design Pattern

Mặc dù bạn có thể tạo ra trò chơi mà không cần nghiên cứu các design pattern, nhưng việc học cách sử dụng chúng sẽ giúp bạn trở thành một nhà phát triển game tốt hơn. Rốt cuộc, design pattern được gắn nhãn như vậy vì chúng là giải pháp phổ biến cho các vấn đề nổi tiếng.

Bạn có thể đã bắt gặp, sử dụng các design pattern trong vô tình. Trong code của các framework bạn đang dùng, trong các package trên asset store hay các đoạn code trên github

vật cùng tắc biến, vật cực tất phản

Giống như những tools khác, tính hữu ích của design pattern phụ thuộc vào ngữ cảnh mà nó được sử dụng và nó cũng đi kèm với nhược điểm. Mọi quyết định trong lập trình game đều đi kèm với sự “thỏa hiệp”

Unity

Mặc định Unity đã triển khai một số design pattern như :

  • Game Loop: cốt lõi của mọi trò chơi là một vòng lặp vô hạn hoạt động độc lập với “clock speed” (tốc độ xung nhịp của CPU), vì phần cứng cung cấp power cho trò chơi có thể thay đổi rất nhiều. Để tính toán cho các thiết bị có tốc độ khác nhau các nhà phát triển thường cần sử dụng “fixed timestep” (với số frame cố định mỗi giây) và một biến timestep có thể thay đổi để đo xem thời gian đã trôi qua kể từ frame trước đó (deltaTime). Unity đã tính toán sẵn điều này cho bạn, bạn không cần phải triển khai lại nó. Bạn chỉ cần quản lý gameplay của bạn bằng cách sử dụng các event function trong MonoBehaviour

  • Update: cập nhật hành vi của từng đối tượng một frame tại một thời điểm thông qua các phương thức Update có trong MonoBehaviour

  • Prototype: thường thì bạn cần sao chép các đối tượng từ một đối tượng khác, prototype giải quyết vấn đề sao chép và nhân bản để làm cho các đối tượng khác giống với chính nó. Bằng cách này bạn tránh được việc tạo ra class riêng biệt để sinh ra mọi loại object trong trò chơi của bạn. Unity Prefab system là một triển khai của prototype cho GameObject.

  • Component: hầu hết mọi unity developer đều biết pattern này. Thay vì tạo các class lớn đảm nhiệm nhiều tránh nhiệm, component pattern xây dựng các class nhỏ hơn với mỗi component đảm nhiệm một nhiệm vụ riêng.

Game Pattern

Factory Pattern

Factory là nhà máy. Bạn có thể liên tưởng nó đến dây chuyền sản xuất của một nhà máy

Đôi khi sẽ hữu ích nếu có một đối tượng “đặc biệt” tạo ra các đối tượng khác (Bạn có thể liên tưởng thành nhà máy tạo ra các sản phẩm), đấng sáng tạo trong game là đây chứ đâu nữa. Trong nhiều trò chơi bạn cần tạo ra các đối tượng khác nhau trong lúc chơi như khi bạn nấu ăn từ công thức bạn mua trong shop hay khi bạn chế tạo vũ khí. Nếu bạn tạo ra những object này từ đầu, điều này thật lãng phí ram của user vì nó sẽ tồn tại ngay từ đầu trò chơi mà có thể trong cả ngày hôm đấy user không sử dụng đến nó. Bạn thường không biết mình sẽ cần gì trong runtime cho đến khi bạn thật sự cần nó.

Factory pattern chỉ định một đối tượng đặc biệt bạn có thể đặt tên tùy ý cho nó miễn là dễ gợi nhớ (như Spawner…), nó bao gồm các thông tin cần thiết để bạn có thể tạo ra product Như để tạo ra đường thì bạn cần nguyên liệu là củ cải, nhà máy của bạn sẽ cần nhập củ cải về để tạo ra đường.

Nếu mỗi product cùng implement cùng một interface hay kế thừa cùng một base class, bạn có thể khiến nó chứa nhiều logic xử lý riêng hơn và tách biệt nó khỏi factory. Do đó việc tạo các đối tượng mới dễ mở rộng hơn.

Bạn cũng có thể phân loại factory thành các loại cụ thể như factory tạo vũ khí, factory tạo enemy, factory tạo boss ẩn…

Ví dụ: hãy tưởng tượng bạn muốn tạo một factory để tạo ra các vật phẩm trong level trong trò chơi. Bạn cũng có thể sử dụng Prefab để tạo GameOject nhưng trường hợp bạn muốn có những tùy chỉnh khi tạo ra từng object.

Thay vì sử dụng if hoặc switch để kiểm soát logic này chúng ta sẽ sử dụng interface. Chúng sẽ ta tạo một interface là IProduct và một abstract class Factory

Các factory có thể cần một số chức năng public của các product vì vậy chúng ta sẽ sử dụng các class abstraction. Bạn cần lưu ý đến nguyên tắc Liskov trong SOLID khi thao tác với các class con mà thôi.

Interface IProduct xác định điểm chung giữa các sản phẩm của bạn. Trong trường hợp này, bạn chỉ cần có thuộc tính ProductName và phương thức Initialize để khởi tạo. Sau đó, bạn có thể xác định bao nhiêu sản phẩm tùy thích (ProductA, ProductB, v.v.) miễn là chúng tuân theo interface IProduct

Class cơ sở, Factory, có một phương thức GetProduct trả về một IProduct, nó là phương thức abstract, vì vậy bạn không thể tạo bản copy của Factory trực tiếp. Khi bạn có các class con cụ thể (ConcreteFactoryA và ConcreteFactoryB), GetProduct sẽ trả về các product khác nhau

Như bạn thấy class ConcreteFactoryA không chứa bất kỳ logic xử lý nào của ProductA, nó chỉ gọi Initialize còn việc init những gì thì mỗi Product tự xử lý

Ưu điểm và nhược điểm

Ưu điểm

  • Khi bạn có nhiều product thì việc sử dụng Factory sẽ đạt được lợi ích tối đa, việc định nghĩa thêm product không ảnh hưởng đến code đã viết trước đó
  • Factory không chứa logic xử lý của Product khiến cho code trong class Factory tương đối ngắn, Factory chỉ gọi phương thức Initialize mà không quan tâm đến chi tiết xử lý bên trong.

Nhược điểm

  • Bạn cần phải tạo nhiều class để implement pattern này.
  • Giống như những pattern khác việc sử dụng pattern này cũng gây ra chi phí trong runtime.
  • Nếu bạn có ít product việc sử dụng pattern này là không cần thiết.

Sử dụng trong thực tế

Việc triển khai factory có thể sẽ hơi khác so với ví dụ nêu ra ở đây, hãy điểm qua những thay đổi có thể có:

  • Sử dụng dictionary để tìm kiếm product. Bạn có thể muốn lưu trữ các product của mình dưới dạng các cặp key-value trong dictionary Sử dụng unique string làm key (e g, Tên hoặc ID) và type làm value. Điều này có thể giúp việc truy xuất product hoặc factory tương ứng của chúng thuận tiện hơn
  • Đánh dấu factory là static. Điều này khiến factory dễ sử dụng hơn nhưng nó yêu cầu các cài đặt cần thiết đi kèm. Các static class không thêm vào inspector được vì vậy dictionary của bạn cũng cần đánh dấu là static
  • Áp dụng nó cho các đối tượng không phải GameObject cũng không phải MonoBehaviours. Đừng giới hạn bản thân trong Prefabs hoặc các component dành riêng cho Unity. Factory pattern có thể hoạt động với bất kỳ đối tượng C # nào.
  • Sử dụng kết hợp cùng với ObjectPool

Object Pool Pattern

Ta có prefab sphere như sau.

Sử dụng một đoạn code đơn giản để so sánh giữa Instantiate và SetActive

Kết quả thu được như hình dưới đây

Instantiate mất 0.28ms và tạo ra 148B GC còn SetActive chỉ mất 0.02ms và không sinh ra Byte GC nào.

Object Pool là một kỹ thuật tối ưu hóa để giảm bớt gánh nặng CPU khi tạo và phá hủy rất nhiều GameObjects.

Object Pool sử dụng một tập hợp các object đã khởi tạo trước và đã deactive, nằm chờ trong một “pool” và luôn sẵn sàng để lấy ra sử dụng Khi bạn cần một đối tượng, trò chơi của bạn sẽ không cần sinh ra nó thay vào đó, bạn yêu cầu GameObject từ pool và SetActive nó lên

Khi sử dụng xong, bạn deactive object và đưa nó trở lại pool thay vì Destroy nó

Object Pool có thể giảm tình trạng sụt giảm khung hình có thể xảy ra do hành động phân bổ bộ nhớ và thu gom rác (GC-garbage collection) GC spikes thường đi kèm với việc tạo hoặc phá hủy một số lượng lớn các đối tượng do phân bổ bộ nhớ. Bạn có thể khởi tạo trước object poll của mình vào một thời điểm thích hợp, chẳng hạn như trong khi loading scene hay khi việc khởi tạo khiến fps giảm sâu nhưng người dùng không thể nhận ra được (màn hình tĩnh)

Quan sát ví dụ sau đây, sử dụng một triển khai Object Pool đơn giản với hai class ObjectPoolPooledObject

  • ObjectPool có một container chứa các GameObject tượng chưng cho “pool”, GameObject đều được lấy ra từ đây
  • PoolObject là một component được gắn vào GameObject đóng vai trò liên kết (reference) giữa GameObject và pool

Ở đây chúng ta có thuộc tính initPoolSize để đặt trước số lượng Object sẽ được tạo trước khi trò trơi bắt đầu chạy. Phương thức SetupPool được gọi trong Start để đảm bảo nó chỉ chạy một lần

Để lấy các object từ pool chúng ta sẽ viết một phương thức có tên GetPoolObject, để đưa GameObject không dùng trở lại Pool chúng ta có phương thức ReturnToPool

GetPooledObject chỉ tạo một PooledObject mới nếu đang pool trống, ngược lại nó sẽ trả về đối tượng có sẵn trong pool.

Mỗi đối tượng trong pool đều được thêm component PooledObject chỉ để giữ reference đến ObjectPool

PooledObject cung cấp phương thức public Release để trực tiếp đưa GameObject trở lại nơi sản xuất

Nếu bạn từng sử dụng ParticleSystem của unity bạn sẽ thấy nó có một thuộc tính là MaxParticles để giới hạn số lượng hạt, nó sẽ tái sử dụng lại các hạt sẵn có để đảm bảo bạn không vượt qua số lượng giới hạn này.

Với ObjectPool bạn cũng có thể cài đặt MaxSize để đạt được hiệu quả tương tự, nếu không pool sẽ tự mở rộng nếu bạn vượt quá kích thước pool bạn cung cấp ban đầu

Ví dụ về ObjectPool có thể tìm thấy ở đây

Sử dụng trong thực tế

Việc triển khai ObjectPool có thể sẽ hơi khác so với ví dụ nêu ra ở đây, hãy điểm qua những thay đổi có thể có:

  • Sử dụng như một static hoặc singleton class. Nếu bạn cần tạo ra các đối tượng từ các nguồn khác nhau hãy cân nhắc việc đặt ObjectPool class là static. Điều này giúp nó có thể truy cập từ bất kỳ đâu trong trò chơi của bạn nhưng bạn sẽ không thể kéo thả nó vào hierarchy để sử dụng inspector, để vượt qua giới hạn này bạn có thể cân nhắc sử dụng singleton.
  • Sử dụng dictionary để quản lý nhiều pool khác nhau. như bạn thấy rằng các object trong 1 pool sẽ khởi tạo từ cùng 1 prefab vậy nếu pool của bạn muốn chứa nhiều loại object khác nhau thì bạn có thể lưu trữ chúng trong các pool riêng biệt và lưu trữ dưới dạng key-value để tiện truy cập
  • Đưa các GameObject trở lại Pool một cách linh hoạt. Đưa GameObject trở lại pool ở mọi nơi mà bạn có thể (object ra khỏi màn hình, object bị giết, …)
  • Kiểm tra lỗi. Tránh Release các đối tượng đã được Release (đã quay trở lại pool rồi)
  • Thêm giới hạn maxSize. nhiều đối tượng được sinh ra sẽ tiêu tốn tài nguyên ram, bạn có thể cần đặt ra một giới hạn để tránh việc sử dụng quá nhiều tài nguyên của thiết bị

Cách bạn sử dụng ObjectPool sẽ khác nhau tùy theo các trường hợp. Nó thường được sử dụng khi súng hoặc vũ khí của bạn bắn nhiều viên đạn giống như mấy game bắn máy bay.

Nếu bạn đang dùng Unity 2021+ thì Unity đã cung cấp sẵn một mẫu triển khai ObjectPool. Bạn có thể dùng luôn nó mà không cần tạo ra ObjectPool của riêng bạn.

Unity hỗ trợ triển khai ObjectPool dựa trên quản lý stack-based, tùy theo nhu cầu bạn cũng có thể sử dụng CollectionPool (List, HashSet, Dictionary…)

Singleton Pattern

Singleton có lẽ là pattern dễ gặp nhất mà khi bạn làm quen với lập trình Unity. Nó là một bad pattern

Theo như Gang of Four, pattern singleton:

  • Đảm bảo rằng một class chỉ có thể có một instance của chính nó
  • Cung cấp khả năng truy cập dễ dàng từ bất kỳ đâu hay bất kỳ ai (đồ dễ dãi)

Pattern này thực sự hữu ích khi bạn cần có chính xác một đối tượng quản lý, điều phối trên toàn bộ scene.

Ví dụ bạn thường thấy Gamemanager quản lý toàn bộ game loop trong trò chơi của bạn, DataManager quản lý lưu trữ cũng như đọc dữ liệu. Các đối tượng quản lý, key member như thế thường có xu hướng là ứng cử viên được trao cho huy chương singleton.

Trong cuốn sách Game Programming Patterns có nói rằng singleton pattern gây hại nhiều hơn những điểm lợi mà nó mang tới và liệt kê nó như là một phần của anti-pattern. Lý do cho điều này là tính dễ sử dụng của mô hình cho phép chính nó bị lạm dụng. Các developer cho xu hướng áp dụng các singleton trong các tình huống không phù hợp, truy cập vào trạng thái global hoặc tạo ra các dependency không cần thiết. Điều này trong dự án nhỏ thì có thể không sao vì bạn vẫn có thể kiểm soát được với số lượng class cũng như depencency còn ít, nhưng trong các project lớn hơn một chút sẽ khó mà kiểm soát được chúng, nó như mớ bòng bong bị rối dễ sảy ra lỗi và khó sửa lỗi

Hãy xem cách triển khai của một singleton và cùng đưa ra điểm mạnh và điểm yếu của nó.

Bắt đầu với một singleton đơn giản như sau:

Một thuộc tính Instance được đặt là public static

Trong phương thức Awake, nó kiểm tra xem instance đã tồn tại hay chưa, nếu chưa nó đặt instance thành đối tượng hiện tại, đây sẽ là instance đầu tiên trong scene

Nếu instance đã tồn tại rồi nghĩa là bạn đang cố gắng tạo ra thêm một bản copy khác instance và nó cần bị loại bỏ vì vậy ta sẽ gọi Destroy để phá hủy bản copy này để đảm bảo chỉ có một instance trong scene

Giả sử bạn có AudioManager là một singleton class nếu bạn gắn AudioManager vào nhiều GameObject trong hierarchy đối tượng đầu tiên được thêm AudioManager vào sẽ được giữ lại còn lại toàn bộ các đối tượng khác sẽ bị Destroy như logic được viết trong Awake

Nhận xét

Class SimpleSingleton viết ở trên hoạt động bình thường đúng như nguyên lý của Singleton pattern. Thế nhưng nó có hai vấn đề như sau:

  • Vấn đề khi bạn load lại scene nó sẽ destroy gameObject
  • Bạn cần kéo nó vào một GameObject trong hierarchy

Vì singleton đóng vai trò như một script quản lý có mặt ở khắp nơi vì vậy bạn có thể khiến nó tồn tại mãi trong suốt vòng đời của một phiên hoạt động của trò chơi bằng cách sử dụng DontDestroyOnLoad

Ngoài ra bạn có thể sử dụng tính năng Lazy Initialize để tạo singleton tự động khi bạn sử dụng nó lần đầu tiên.

Generics

Cả hai cách triển khai phía trên đều không đề cập đến cách tạo các singleton khác nhau trong cùng scene. Như bạn muốn có singleton AudioManager và một singleton Gamemanager bạn cần copy code của chúng sau đó sửa đổi lại điều này vi phạm nguyên tắc DRY.

Chính vì vậy chúng ta sẽ triển khai một phiên bản bằng generics

sau đó bạn có thể khai báo Gamemanger và AudioManager như sau

Lúc này bạn luôn có thể tham chiếu đến Gamemanger thông qua Gamemanager.Instance bất cứ khi nào bạn cần

Ưu và nhược điểm

Singleton không giống như những pattern khác ở chỗ chúng phá vỡ nguyên tắc SOLID mà ta đã nói ở phần trước ở một số khía cạnh.

Nhiều developer không thích singleton vì nhiều lý do:

  • Singleton quá public. Nó yêu cầu cung cấp global access, bởi vì là các global instance nên việc sử dụng nó có thể khiên nhiều dependency bị ẩn dấu, khiến cho việc truy vết bug khó hơn.
  • Singleton khiến quá trình test khó khăn. Các bài kiểm tra unit test phải độc lập với nhau nhưng singleton có thể thay đổi trạng thái của nhiều GameObject trong scene, chúng có thể ảnh hưởng đến quá trình test của bạn
  • Singleton là high coupling (liên kết chặt chẽ giữa các class). high coupling khiến cho việc tái cấu trúc chở nên khó khăn, nếu bạn thay đổi một component nó có thể ảnh hưởng đến bất kỳ component nào liên kết với nó điều này dẫn đến không clean code

Cộng đồng anti-singleton là khá lớn nhưng lại không đông bằng fan MU. Nếu bạn đang xây dựng trò chơi của mình và vòng đời của nó kéo dài trên 1 năm bạn không nên sử dụng singleton. Đối với các trò chơi không yêu cầu mở rộng thì bạn có thể không cần quan tâm đến nó.

Trên thực tế singleton cung cấp những lợi ích sau:

  • Dễ học
  • Dễ sử dụng
  • Quyền truy cập global (Lưu ý rằng vì là biến global nên nó được lưu trên heap, tốc độ truy cập sẽ chậm hơn stack, bạn có thể tránh lưu vào bộ nhớ cache kết quả từ GetComponent hoặc Find nếu truy cập thông qua singleton vì nó sẽ chậm)

Cuối cùng nếu bạn sử dụng singleton trong dự án của mình hãy dùng nó ít nhất có thể và không sử dụng nó bừa bãi

Command Pattern

Command pattern hữu ích bất cứ khi nào bạn muốn theo dõi một chuỗi hành động cụ thể. Như các trò chơi chiến lược hay các trò chơi theo lượt nơi bạn có thể xem trước nhiều lượt đi của mình trước khi đưa ra quyết định cuối cùng

Thay vì gọi trực tiếp phương thức, command pattern cho phép bạn đóng gói một hoặc nhiều “call method” thành “command object”

Lưu trữ các “command object” trong một collection như stack hoặc queue cho phép bạn kiểm soát thời gian thực hiện chúng. Chức năng này như bộ đệm nhỏ. Bạn có thể delay thực hiện action hoặc hoàn tác lại các action đã thực hiện lại chúng.

Để triển khai pattern này bạn cần một đối tượng chung chứa action của bạn. Command object này sẽ chứa logic thực thi và hoàn tác (undo) của nó.

Command Object và Command Invoker

Trong trường hợp này, mọi action trong trò chơi sẽ implement interface ICommand (bạn cũng có thể thay thế bằng việc kế thừa class abstract).

Mỗi command object sẽ chịu trách nhiệm về các phương thức “Thực thi” (Execute) và “Hoàn tác” (Undo) của riêng nó.

Do đó, việc thêm nhiều command vào trò chơi của bạn sẽ không ảnh hưởng đến bất kỳ command nào hiện có.

Bạn sẽ cần một lớp khác để thực thi và hoàn tác các command.

Tạo một lớp CommandInvoker. Ngoài các phương thức ExecuteCommand và UndoCommand, nó có một stack undo (ngăn xếp chứa action undo) để giữ các hành động theo thứ tự (sequence các command object)

Ví dụ: hoàn tác các bước di chuyển của object

Hãy tưởng tượng rằng bạn muốn di chuyển nhân vật của mình bên trong một mê cung (maze). Đầu tiên bạn tạo một component tên là PlayerMovement chịu trách nhiệm xử lý việc di chuyển của nhân vật

Bạn cần truyền một vector3 vào phương thức Move để hướng dẫn nhân vật di chuyển theo 4 hướng (trái, phải, trên, dưới). Bạn cũng có thể sử dụng RayCast để phát hiện các bức tường chắn

Để áp dụng command pattern thay vì gọi trực tiếp phương thức Move ta chuyển nó thành một command object MoveCommand triển khai interface ICommand

ICommand yêu cầu triển khai phương thức Execute để thực hiện action mà bạn muốn vì thế chúng ta gọi phương thức Move ở đây. Còn về Undo để hoàn tác lại hành động bạn đã thực hiện trước đó trong trường hợp ví dụ này đó là chúng ta di chuyển về vị trí trước đó

MoveCommand giữ bất kỳ thông số nào cần thiết cho quá trình Execute/Undo ở đây đó là PlayerMovement và vector3 movement

InputManager không gọi trực tiếp phương thức Move của PlayerMovement. Mà thông qua một phương thức RunMoveCommand để tạo môt MoveCommand mới và truyền nó cho CommandInvoker

Việc triển khai redo và undo cũng đơn giản như việc tạo ra các command object. Bạn cũng có thể sử dụng command buffer để thực hiện lại các hành động theo trình tự điều khiển cụ thể.

Bạn có thể tạo các trò chơi có hệ thống combo skill dễ dàng bằng cách sử dụng pattern này.

Bạn có thể tạo các stack riêng biệt giữa các chức năng undo và redo để nhanh chóng chuyển qua lại giữa chúng.

State pattern

Trong trò chơi, nhân vật của bạn điều khiển có thể chạy khi bị enemy đuổi, hay đứng yên nhìn đám lá rụng khi mùa thu hà nội đến. Nhãy qua những cạm bẫy hay các chướng ngại vật trên đường.

Các trò chơi có tính tương tác và chúng buộc chúng ta phải theo dõi các trạng thái thay đổi trong thời gian chạy. Nếu bạn vẽ một biểu đồ (diagram) đại diện cho các trạng thái khác nhau của nhân vật của bạn, bạn có thể nghĩ ra một cái gì đó như thế này

Nó giống như flowchart (sơ đồ khối) bạn đã học trên trường nhưng có một vài điểm khác biệt.

  • Diagram bao gồm một số trạng thái (Idle, Run, Jump) nhưng chỉ có một trạng thái hoạt động tại một thời điểm.
  • Mỗi trạng thái có thể chuyển đổi sang trạng thái khác nếu thỏa mãn điều kiện
  • Khi quá trình chuyển đổi xảy ra, trạng thái đầu ra sẽ là trạng thái mới luôn (tức là nếu thời gian chuyển đổi có dài thì cũng không ảnh hưởng đến kết quả vì trạng thái đã thay đổi ngay lập tức rồi)

Hình bên trên còn được gọi với cái tên là finite-state machine viết tắt là FSM. Trong phát triển game trường hợp điển hình sử dụng nó là khi bạn muốn theo dõi trạng thái của các đối tượng hay thực thể

Để hình dung một FSM cơ bản bạn có thể sử dụng cách tiếp cận bằng enum và sử dụng switch.

Đoạn code nãy vẫn sẽ hoạt động, nhưng một lần nữa chúng ta lại nhắc đến tính bảo trì và mở rộng, class trên có thể trở nên lộn xộn một cách nhanh chóng. Khi thêm nhiều trạng thái hơn độ phức tạp cũng tăng theo cấp số nhân

Simple state pattern

Theo Gang of Four thì state pattern giúp giải quyết hai vấn đề sau:

  • Một đối tượng sẽ thay đổi hành vi của nó khi trạng thái thay đổi
  • Hành vi của trạng thái cụ thể được định nghĩa một cách độc lập. Việc thêm các trạng thái mới không ảnh hưởng đến hành vi của các trạng thái hiện có.

Chúng ta sẽ đóng gói trạng thái thành một đối tượng

Để triển khai theo cách này ta tạo interface IState như sau

Các trạng thái cụ thể sẽ implement interface IState với :

  • Entry : Thực thi xử lý logic khi lần đầu chuyển sang trạng thái này
  • Update: Thực thi logic mỗi frame( đôi khi còn được gọi là Execute hoặc Tick). Bạn có thể chia nhỏ nó thành Update, FixedUpdate hay LateUpdate giống như của MonoBehaviour. Nó cũng xử lý điều kiện chuyển sang trạng thái khác ở đây
  • Exit: Thực thi xử lý logic trước khi chuyển sang trạng thái khác

Bạn sẽ cần tạo một class cho mỗi trạng thái và implement IState. Ví dụ ở đây thì ta sẽ tạo 3 class là WalkState, IdleState và JumpState

Một class khác là StateMachine dùng để quản lý các trạng thái

Vì StateMachine không kế thừa từ MonoBehaviour, hãy sử dụng contructor khởi tạo cho các trạng thái

Xử lý trạng thái Idle với IdleState

Những lưu ý với StateMachine

  • Serializable để có thể hiện thị bên ngoài inspector, trong trường hợp bạn sử dụng StateMachine như một thuộc tính
  • Thuộc tính CurrentState là read only để đảm bảo tính đóng gói

Ưu và nhược điểm

State pattern có thể giúp bạn tuân thủ các nguyên tắc SOLID khi thiết lập logic nội bộ cho một đối tượng.

Mỗi trạng thái là tương đối nhỏ và chỉ kiểm tra điều kiện để chuyển đổi sang trạng thái khác.

Để phù hợp với nguyên tắc open-close, bạn có thể thêm nhiều trạng thái hơn mà không ảnh hưởng đến các trạng thái hiện có và tránh chuyển đổi rườm rà hoặc câu lệnh if

Mặt khác, nếu bạn có ít trạng thái cần quản lý bạn có thể không cần phải sử dụng pattern này

Mô hình này có thể chỉ có ý nghĩa nếu dự án của bạn sẽ có nhiều trạng thái cần được quản lý

Một ứng dụng phổ biến của State pattern là được ứng dụng trong animation

State pattern cũng hữu ích trong việc tạo ra các AI đơn giản cho enemy hay boss của bạn

Mỗi trạng thái đại diện cho một hành động, chẳng hạn như Attack, Flee hoặc Patrol. Chỉ một trạng thái hoạt động tại một thời điểm, với mỗi trạng thái xác định điều kiện để chuyển sang trạng thái tiếp theo

Cảm ơn

Bài viết này dựa vào tài liệu Design Pattern của @unity có sẵn tại repository này

Lượt nghé thăm