Game Programming Patterns (P1)

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

Giới thiệu

Trong lập trình nói chung và với Unity nói riêng, có một câu nói rằng: “Bạn không cần phát minh lại bánh xe, một ai đó trên thế giới đã tạo sẵn bánh xe cho bạn rồi”. Đôi khi ngồi phát minh lại một thứ đã có sẵn không phải một ý tưởng hay khi mà thời gian phát triển dự án là có hạn và bạn luôn không có đủ thời gian cho việc phát minh lại từng thứ từ đầu.

Ngay cả việc chúng ta sử dụng Unity Engine cũng vậy, ai đó đã tạo ra Unity3d với những hỗ trợ tuyệt vời cho việc phát triển Game để quá trình phát triển Game dễ dàng hơn.

Đối với các vấn đề bạn gặp phải trong lúc tạo ra thế giới game của mình, các nhà lập trình viên đi trước bạn họ cũng đã từng gặp những vấn đề tương tự và họ đã giải quyết chúng bằng cách này hay cách khác.

Design Pattern là một trong số những báu vật mà những người đi trước đã để lại cho chúng ta, nó chứa đựng những kinh nghiệm, những bài học quý báu được đúc kết ra trong suốt những năm tháng dài đằng đẵng phát triển game. Nó không phải là lời giải hoàn chỉnh cho một bài toán cụ thể nào cả, bởi vì trong lập trình không có viên đạn bạc nào cả cũng như con người vậy làm gì có ai hoàn hảo đâu. Thay vào đó bạn có thể nghĩ Design Pattern như những công cụ bổ sung, bằng cách hiểu và sử dụng kết hợp theo cách phù hợp với dự án của bạn nó giúp code của bạn dễ hiểu, dễ dàng bảo trì và mở rộng hơn.

Nguyên tắc KISS

Keep It Simple, Stupid

Giữ mọi thứ đơn giản, đồ ngốc

Chỉ thêm khi chúng thật sự cần thiết, mỗi design đều đi kèm với sự cân bằng.

Bạn cần quyết định xem lợi ích nó mang lại có phù hợp với dự án hiện tại của bạn không?

Và cái giá phải bỏ ra để đánh đổi lợi ích đó nó có nằm trong phạm vị chấp nhận được không?

Nếu bạn không chắc rằng liệu Design Pattern có áp dụng cho vấn đề cụ thể của mình hay không? hãy chờ đợi một tình huống, trường hợp mà sẽ sử dụng Design Pattern như một điều tự nhiên. Đừng sử dụng nó chỉ vì nó mới, lạ với bạn hãy sử dụng nó khi thật sự cần thiết

Nguyên tắc SOLID

Trước khi đi vào chi tiết về Design Pattern hãy cùng dạo qua các nguyên tắc lập trình có ảnh hưởng lớn đến cách chúng ta thiết kế.

Nguyên tắc SOLID là viết tắt của 5 nguyên tắc lập trình cốt lõi khi phát triển phần mềm.

  • Single responsibility
  • Open-closed
  • Liskov substitution
  • Interface segregation
  • Dependency inversion

Single responsibility

Là nguyên tắc đầu tiên và cũng là nguyên tắc quan trọng nhất của SOLID

Nó được phát biểu như sau:

Mỗi module, class hoặc phương thức chỉ nên đảm nhiệm một trách nhiệm duy nhất và gói gọn xử lý logic về trách nhiệm đó.

Chỉ nên thay đổi module, class hoặc phương thức với một lý do duy nhất

Dự án của bạn nên là tổ hợp của nhiều dự án nhỏ hơn thay vì xây dựng nguyên khối. Các module, class hoặc phương thức ngắn sẽ dễ đọc, hiểu và mở rộng hơn.

Nếu bạn để ý bạn có thể nhận thấy nó xuất hiện mọi nơi trong Unity.

Ví dụ: Khi bạn tạo một GameObject nó có thể đính kèm nhiều component khác nhau bạn có thể thêm vào như

  • MeshFilter: giữ tham chiếu đến model 3D
  • MeshRenderer: kiểm soát cách model xuất hiện trên màn hình
  • Transform: chịu trách nhiệm cho các chức năng di chuyển, xoay, scale và nhóm
  • BoxCollider: có chức năng phát hiện va chạm
  • Rigidbody: sử dụng nếu cần tương tác với vật lý
  • Button: nếu muốn tạo một nút nhấn

Như bạn thấy đó mỗi component trên đều có chức năng riêng của nó và trò chơi của bạn trong unity được hình thành là nhờ vào sự tương tác của các component giữa các GameObject

Bạn sẽ xây dựng các component theo kịch bản đề ra theo cách thiết kế nhất quán tuân thủ “Single responsibility” để mỗi component dễ đọc hiểu sau đó kết hợp chúng theo công thức của riêng bạn để mô phỏng các hành vi phức tạp.

Giả sử chúng ta cần thiết kế nhân vật của mình di chuyển bằng các phím mũi tên, khi di chuyển nếu va chạm với vật thể thì phát nhạc

Nếu không áp dụng “Single responsibility” chúng ta có thể viết tất cả trong một class

Nó sẽ trông như đoạn code dưới đây

Theo SOLID thì class UnrefactoredPlayer này đang vi phạm nguyên tắc Single responsibility nó ôm đồm quá nhiều trách nhiệm vào bản thân nó.

  • Vừa đóng vai trò phát âm thanh khi người chơi va chạm với đối tượng khác
  • Vừa xử lý Input và chuyển động

Đây là một ví dụ đơn giản nhưng ngay cả thế khi bạn viết theo cách này bạn sẽ khó phát triển và bảo trì dự án lúc này bạn cần cân nhắc tách nhỏ class UnrefactoredPlayer thành các class nhỏ hơn

Ở đây chúng ta đã tách class UnrefactoredPlayer thành 4 class là class Player, PlayerAudio, PlayerInput và PlayerMovement. Sau khi tách chúng ta thấy rằng việc sửa đổi code bây giờ sẽ dễ dàng hơn đặc biệt là yêu cầu tính năng luôn luôn thay đổi trong suốt quá trình phát triển dự án

Tuy nhiên bạn sẽ cần cân bằng việc tách class để đảm bảo rằng chúng không quá nhỏ lẻ, đừng đơn giản hóa quá mức như việc tách thành một class chỉ với một phương thức duy nhất

Hãy nghi nhớ những giáo điều sau khi áp dụng nguyên tắc Single responsibility:

  • Khả năng đọc hiểu (Readability): giữ các class ngắn ngọn dễ đọc, không có quy tắc nào quy định việc này cả nhưng nhiều developer đặt giới hạn 200-300 dòng cho mỗi class (đôi khi có thể lên đến 500 dòng) Khi bạn vượt quá ngưỡng này bạn cần quá trình “refactor” là quá trình tái cấu trúc liệu có thể tách nó thành các class nhỏ hơn không
  • Khả năng mở rộng (Extensibility): bạn có thể kế thừa từ các class, interface nhỏ dễ dàng hơn, sửa đổi hoặc thay thế chúng mà không sợ làm hỏng các tính năng khác ngoài ý muốn.
  • Khả năng tái sử dụng (Reusability): thiết kế các class của bạn nhỏ và module hóa để có thể sử dụng lại chúng cho các phần khác của trò chơi của bạn

Khi tái cấu trúc lại hãy xem xét việc sắp xếp lại code sẽ cải thiện chất lượng project cũng như cho bạn và đồng nghiệp tránh khỏi những rắc rối sau này. Vâng tin tôi đi những rắc rối sẽ đến nếu bạn không hành động tái cấu trúc khi có thể, chúng đan vào nhau và rối rắm như đường tình của bạn

Simple is not easy

Đơn giản sẽ không dễ dàng

Sự đơn giản thường được nhắc đến trong ngành công nghệ thiết kế phần mềm và là điều kiện tiên quyết cho độ tin cậy. Các câu hỏi như phần mềm của bạn có được thiết kế để xử lý các thay đổi trong quá trình sản xuất không?. Bạn có thể mở rộng và duy trì ứng dụng của mình theo thời gian không?

Các design pattern giúp bạn đưa sự đơn giản đó vào dự án của mình để code có thể mở rộng, linh hoạt và dễ đọc. Tuy nhiên chúng yêu cầu bạn lập kế hoạch rõ ràng cho code của bạn cũng như bạn sẽ cần làm thêm một số việc như suy nghĩ về liên kết, tổ chức giữa các class nhiều hơn. Simple không đồng nghĩa với Easy. Bạn vẫn có thể tạo cùng một tính năng mà không cần design pattern và thường là nhanh hơn, một cái gì đó nhanh và dễ làm không nhất thiết cần đến sự góp mặt của đơn giản. Khi bạn cần sự đơn giản nghĩa là bạn đang muốn tập chung vào nó

Bạn có thể tham khảo video sau nói về Simple và Easy

Open-closed

Nó được phát biểu như sau

Chúng ta nên hạn chế việc chỉnh sửa bên trong class hoặc module có sẵn, thay vào đó hãy mở rộng chúng

  • Hạn chế sửa đổi: điều này còn thường được nhắc tới ở trong một ngữ cảnh khác là “Nếu code vẫn chạy tốt thì đừng động vào nó” vì chỉnh sửa source code của module hoặc một class có sẵn sẽ có khả năng ảnh hưởng tới tính đúng đắn của cả trò chơi
  • Ưu tiên mở rộng: khi cần thêm các tính năng mới ta nên kế thừa và mở rộng các module và class có sẵn thành module và class con vừa có các đặc điểm của class cha (đang chạy bình thường rồi) vừa bổ sung được các tính năng mới vào lúc này bạn chỉ cần kiểm tra tính đúng đắn của tính năng mới thêm vào mà thôi

Một ví dụ điển hình của nguyên tắc này là bài toán tính diện tích. Bạn có thể tạo class AreaCalculator với các phương thức trả về diện tích của hình chữ nhật và hình tròn. Để tính diện tích hình chữ nhật ta cần có chiều rộng và chiều cao của nó, còn hình tròn thì ta cần bán kính và hằng số PI

Cách này vẫn chạy tốt và bình thường nhưng vấn đề xảy đến khi bạn muốn thêm các hình dạng khác vào như hình bình hành, hình thang, tam giác, … Lúc này bạn lại cần tạo thêm các phương thức riêng cho việc tính diện tích từng hìn dạng mới như GetTriangleAre, … clas AreCalculator sẽ nhanh chóng mất kiểm soát

Giải pháp lúc này là sử dụng kế thừa và đa hình, chúng ta sẽ tạo một class base là Shape chứa phương thức abstract dùng để tính toán diện tích và các hình dạng khác kế thừa nó và triển khai phương thức tính diện tích riêng biệt

Lúc này class AreaCalculator trông đơn giản hơn như sau

Class AreaCalculator sau khi tái cấu trúc đã có thể tính toán diện tích của bất kỳ hình nào miễn là nó kế thừa class Shape. Khi bạn cần tính diện tích một đa giác mới chỉ cần tạo class mới và kế thừa class Shape, sau đó class mới sẽ ghi đè phương thức CalculateArea() rồi thực hiện các tính toán đặc thù cho đa giác đó

Cách thiết kế này việc gỡ lỗi dễ dàng hơn, nếu lỗi ở hình nào bạn chỉ cần truy cập vào class của hình dạng đó và kiểm tra logic của hình đó mà thôi

Tận dụng interface và abstract khi thiết kế class trong Unity giúp bạn tránh việc rẽ nhánh câu lệnh điều kiện (switch, if else) trong code của bạn để tránh việc khó mở rộng sau này khi mà các câu lệnh if else cứ ngày dài thêm. Khi bạn đã quen với việc thiết kế class của mình tuân thủ nguyên tắc Open-Close việc thêm code mở rộng sau này sẽ trở nên đơn giản hơn, bạn sẽ có thời gian thưởng thức ly cafe của mình.

Liskov substitution

Nguyên tắc này phát biểu như sau.

Các instance của lớp con có thể thay thế được instance lớp cha mà vẫn đảm bảo tính đúng đắn của trương trình

Kế thừa trong lập trình hướng đối tượng (OOP) cho phép bạn thêm các chức năng vào các class con, tuy nhiên điều này có thể dẫn đến sự phức tạp không cần thiết nếu bạn không cẩn thận.

Nguyên tắc này sẽ giúp bạn biết cách áp dụng kế thừa làm cho các class con của bạn mạnh mẽ và linh hoạt hơn.

Hãy tưởng tượng trò chơi của bạn yêu cầu một lớp gọi là Vehicle. Đây sẽ là class cơ sở của class con mà bạn sẽ tạo cho ứng dụng của mình Ví dụ: bạn có thể có thêm Car hoặc Truck

Mọi nơi bạn có thể sử dụng class cơ sở Vehicle bạn sẽ có thể sử dụng class con như Car hoặc Truck mà không phá vỡ tính đúng đắn của trò chơi.

Ta có class Vehicle trông như thế này

Giả sử bạn đang tạo ra các trò chơi turn-base (theo lượt), bạn di chuyển vehicle đọc theo tuyến đường quy định bạn cần di chuyển trái phải, tiến về phía trước

Bạn có một class Navigator để điều khiển các vehicle đi theo path chỉ định

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class Navigator
{
  public void Move(Vehicle vehicle)
  {
    vehicle.GoForward();
    vehicle.TurnLeft();
    vehicle.GoForward();
    vehicle.TurnRight();
    vehicle.GoForward();
  }
}

Class này hoạt động bình thường đối với class Car và Truck. Tuy nhiên một ngày đẹp trời GD (Game Design) nói với bạn rằng “yenmoc” à đã đến lúc chúng ta có thêm Train trong trò chơi của mình rồi.

Như hình vẽ bên trên, phương thức TurnLeft và TurnRight sẽ không hoạt động với class Train vì Train có rẽ được đâu nó chỉ đi dọc theo đường ray được mà thôi chính vì vậy khi bạn gọi TurnLeft hoặc TurnRight cho class Train lỗi sẽ xuất hiện nói cách khác nó đã vi phạm nguyên tắc Liskov substitution

Xem xét các tips sau để tuân thủ chặt chẽ hơn nguyên tắc Liskov substitution:

  • Nếu bạn đang loại bỏ các tính năng trên các class con (NotImplement), bạn có thể phá vỡ nguyên tắc Liskov substitution: throw NotImplementException hoặc để trống phương thức override cũng có thể khiến class con không thay thế được class cơ sở, tức là bạn đang vi phạm nguyên tắc này ngay cả khi không có lỗi hoặc ngoại lệ rõ ràng
  • Đơn giản hóa trừu tượng: bạn càng đưa nhiều logic vào class cơ sở thì nhiều khả năng bạn sẽ vi phạm nguyên tắc này, class cơ sở chỉ nên chứa những chức năng chung của các lớp dẫn xuất.
  • Một class con cần có các public members (public properties và public method) giống như class cơ sở
  • Xem xét API class trước khi thiết lập phân cấp: mặc dù bạn nghĩ tất cả chúng đều là vehicle nhưng sẽ có ý nghĩa hơn nếu Car và Train kế thừa từ các class cha riêng biệt. các phân loại trong thực tế không phải lúc nào cũng chuyển thành phân cấp class
  • Ủng hộ thành phần(composition) hơn là kế thừa: thay vì cố gắng vượt qua việc thống nhất các chức năng thông qua kế thừa, sử dụng interface hoặc class riêng biệt để đóng gói một hành vi cụ thể sau đó xây dựng một composition của các chức năng khác nhau bằng cách mix and match

Sử dụng những điều đã nêu bây giờ chúng ta thử sửa lại code, đầu tiên chúng ta loại bỏ class Vehicle sau đó di chuyển các chức năng tạo thành interface

Tạo class RailVehicle cho xe chạy trên đường sắt và class RoadVehicle cho những loại xe khác chạy trên đường. Car sẽ kế thừa RoadVehicle còn Train sẽ kế thừa RailVehicle

Theo cách này các chức năng đến thông qua việc chúng ta implement interface thay vì kế thừa. Car và Train còn chung class cơ sở nữa. Mặc dù bạn có thể khiến RoadVehicle và RailVehicle kế thừa từ chung một class cơ sở nhưng trong trường hợp này chúng ta không cần phải làm thế.

Interface segregation

Nguyên tắc này phát biểu rằng

Nếu một interface quá lớn ta nên tách nó thành các interface khác nhỏ hơn với các mục đích cụ thể. Nói cách khác tránh tạo ra những interface lớn, hãy suy nghĩ theo cùng một ý tưởng với nguyên tắc đầu tiên single responsibility

Giữ cho interface nhỏ gọn và tập chung. Điều này mang lại cho bạn sự linh hoạt tối đa khi triển khai interface

Giả sử bạn tạo một trò chơi chiến thuật (strategy) có nhiều unit mỗi unit sẽ có chỉ số khác nhau như hp, speed, str, dex, …

Bạn có thể muốn tạo một interface để đảm bảo tất cả các unit đều triển khai các tính năng tương tự nhau.

Giả sử bạn có unit thùng rượu trên bản đồ vì nó cũng là unit nên nó sẽ implement interface IUnitStats. Nó có thể bị phá hủy nên nó cần có các thông số về hp nhưng nó lại không di chuyển

Hoặc trên bản đồ cũng có các con bù nhìn rơm để người chơi kiểm tra DPS (damage per second) chúng cũng không thể di chuyển hay bị phá hủy.

Chia IUnitStats thành các interface nhỏ hơn sẽ hợp lý hơn nhiều, sau đấy chúng ta sẽ chỉ cần kết hợp và sử dụng những interface cần thiết cho các unit riêng biệt

Dưới đây kết quả sau khi tách nhỏ interface

Bởi vì một class có thể kế thừa nhiều interface nên unit Enemy của chúng ta sẽ kế thừa từ 3 interface IMovable, IDamageable, IUnitStats

Còn thùng rượu của chúng ta không thể di chuyển và có thể bị nổ nên nó sẽ không kế thừa IMovable

Dependency inversion

Nguyên tắc này phát biểu như sau

Các class hoặc module chỉ nên phụ thuộc vào abstraction chứ không nên phụ thuộc vào instance hoặc các implemention cụ thể.

Hãy cùng làm rõ ý nghĩa của nó. Khi một class nó mối quan hệ với class khác thì dependency coupling được sinh ra. Giống như trong thực tế mọi vấn đề xảy ra đều do các mối quan hệ, trong lập trình thì mọi vấn đề đều do mối quan hệ giữa các đối tượng với nhau.

Nếu class A liên kết mạnh đến class B (A biết quá nhiều về B, A cần thuộc tính này của B để chạy phương thức, A cần thuộc tính kia của B để tính toán) thì việc thay đổi B có thể dẫn đến phải thay đổi A nếu không muốn xảy ra lỗi hoặc ngược lại.

Vì vậy hãy luôn giữ cho giữa các class càng ít dependency càng tốt. Đối tượng của bạn được coi là gắn kết (cohesion) khi nó hoạt động dựa trên logic private hoặc internal Trường hợp tốt nhất là loose coupling và high cohesion

Nguyên tắc Dependency Inversion có thể giúp giảm bớt các liên kết chặt chẽ giữa các class. Khi bạn xây dựng các class và system thì tự nhiên sẽ có một nhóm thuộc “High-level” và nhóm còn lại là “Low-level”

High level phụ thuộc vào low level để có thể chạy được. Để tuân theo Dependency inversion chúng ta phải thay đổi điều này

Giả sử bạn đang thiết kế trò chơi giống như Dark Soul, nhân vật của bạn tiến đến nơi chứa con boss. Để tiến vào đánh boss nhân vật của bạn cần mở cửa phòng con boss ra. Bạn sẽ muốn tạo một class là Door, một class là Switch

  • High level: nhân vật của bạn di chuyển đến trước cửa phòng boss và kích hoạt trigger ẩn, class Switch sẽ đảm nhiệm việc này
  • Low level: class Door chứa cách để mở cửa

Như bạn thấy trong đoạn code của Switch nó có thể tự gọi phương thức Toggle() để kích hoạt đóng và mở cửa. Vấn đề ở đây là dependency trực tiếp từ Door vào Switch. Điều gì sẽ xảy ra nếu Switch cũng có thể dùng làm công tắc bật sáng đèn trước cửa con boss khi bạn đến, hay đánh thức kẻ gác cổng …

Bạn cũng có thể thêm các phương thức mở rộng vào class Switch nhưng bạn sẽ vi phạm nguyên tắc open-closed. Một cách khác đó là sử dụng abstraction, bạn sẽ tạo ra interface ISwitchable

Interface ISwitchable chỉ cần một thuộc tính public để biết liệu nó có đang active hay không, cùng phương thức Active và DeActive nó

Class Switch định nghĩa một thuộc tính có kiểu là ISwitchable

Mặt khác Door lúc này cần implement ISwitchable

Bây giờ code của bạn đã áp dụng Dependency Inversion. Interface tạo ra abstraction giữa chúng thay vì cố định Switch sử dụng cho Door. Switch giờ đây không còn dependency trực tiếp vào các phương thức dành riêng cho class Door (phương thức Open() và Close()) nữa. Thay vào đó nó sử dụng phương thức Activate() và Deactivate() của ISwitchable. Thay đổi tuy nhỏ nhưng đáng kể này sẽ thúc đẩy khả năng tái sử dụng của code, trong khi trước đây Switch chỉ hoạt động với class Door thì nay nó có thể hoạt động với mọi đối tượng implement ISwitchable cho phép tính mở rộng cao hơn

Các ví dụ trên dây sử dụng interface, tuy nhiên bạn có thể sử dụng abstract class cả hai đều tạo ra abstraction (tính trừu tượng)

Sử dụng abstract class khi bạn muốn tạo một khuôn mẫu

Sử dụng interface khi bạn chỉ muốn tạo bản thiết kế

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