Thứ Tư, 20 tháng 11, 2013

ENTITY FRAMEWORK TRONG XÂY DỰNG KIẾN TRÚC GAME.

Bài viết được dịch từ bài blog của Richard Lord. Trong suốt bài viết này, để phù hợp hơn với Tiếng Việt, tôi đã có một số chỉnh sửa về ngôn từ so với bản gốc, nhằm đem lại góc nhìn của một người mới tìm hiểu về việc xây dựng một kiến trúc game. Rất cảm ơn tác giả Richard Lord với bài viết nguyên mẫu.

GIỚI THIỆU VỀ ENTITY FRAMEWORK
Entity Framework là một kiến trúc hệ thống được phát triển trong thời gian gần đây, điều tiên quyết của entity chính là: tách biệt xử lý trong code, càng đơn giản, càng tách biệt càng tốt. Và minh chứng cho sự thành công của entity chính là ứng dụng mô hình này trong các engine game hiện nay: unity, ember2, Xember.
Trong bài viết, sẽ giúp bạn đọc tiếp cận với tư tưởng entity từ cách phát triển từ kiến trúc game loop cổ điển. Code được hướng dẫn với C++, tuy nhiên hoàn toàn có thể hiện thực với những ngôn ngữ lập trình khác một cách dễ dàng với tư tưởng Entity.

VÍ DỤ
Với việc xây dựng lại game Asteroids, một game tiểu biểu có đủ các thành phần để giúp chúng ta dể dàng liên kết ví dụ với những game thực tế được phát triển: vẽ, vật lý, điều khiển từ người dùng, cập nhât game,…

VÒNG LẶP GAME

Trước tiên, đẻ hiểu được entity, bạn cần nắm được cách thức hoạt động của vòng lặp game theo kiểu cũ. Xem ví dụ dưới đây.

void update(int time)  {
       game.update(time);
       spaceship.updateInputs(time);

       for(int i = 0; i < flyingSaucers.count; i ++) {
              flyingSaucers[i].updateAI(time);
       }

       spaceship.update(time);
       for (int i = 0; i < flyingSaucers.count; i++) {
              flyingSaucers[i].update(time);
       }

       for (int i = 0; i < asteroids.count; i++) {
              asteroids[i].update(time);
       }

       for (int i = 0; i < bullets.count; i ++) {
              bullets[i].update(time);
       }

       collisionManager.update(time);
       spaceship.render();
       for(int i = 0; i < flyingSaucers.count; i++) {
              flyingSaucer.Render();
       }
       for(int i = 0; i < asteriods.count; i++) {
              asteriods[i].Render();
       }
       for(int i = 0; i < bullets.count; i++) {
              bullets[i].Render();
       }
}
Trong vòng lặp đã sử dụng biến thời gian để xác định giá trị thời gian của game. Trong đó, biến thời gian thường có giá trị 1/60s hoặc 1/30s. Trong mỗi vòng lặp, được thực hiện lần lượt: cập nhật trạng thái của đối tượng, kiểm tra va chạm, vẽ các đối tượng.
Đây là một vòng lặp game khá đơn giản bởi vì:
1.                  Game quá đơn giản.
2.                  Game chỉ có một trạng thái.
Kiến trúc entity được đề ra nhằm cải thiện những mặt còn tồn tại của vòng lặp game. Đối với entity vòng lặp game là nhân của game, tuy nhiên được chú trọng vào việc tách rời việc xử lý và hiển thị qua đó đơn giản hóa và giúp vòng lặp game đơn giản hơn.

TIẾN TRÌNH GAME.
Trước tiên, hãy tiếp cận với lối suy nghĩ rằng: các đối tượng của game đang thực thi các loại tiến trình khác nhau. Một tiến trình của đối tượng bao gồm start, end, update.
class IProcess {
public:
       virtual bool start();
       virtual bool update(int time);
       virtual void end();
       virtual int getId();
};
Chúng ta sẽ dể dàng quản lý vòng lặp game nếu chia nó ra thành một số lượng các tiến trình nhỏ cần thực thi khác. Ví dụ: Rendering, movement, collision,.. để quản lý chúng, ta cần một bộ  ProcessManager
class ProcessManager {
private:
       vector<IProcess> processes;

public:
       bool addProcess(IProcess process, int priority) {
              if (process.start()) {
                     vector<IProcess>::iterator it;
                     it = processes.begin();

                     int index = 1;
                     while (index < priority && it != processes.end()) {
                           index++;
                     }
                     processes.insert(it, process);
                     return true;
              }
              return false;
       }

       void update(int time) {
              vector<IProcess>::iterator it;
              for (it = processes.begin(); it != processes.end(); it++) {
                     it->update(time);
              }
       }

       void removeProcess(IProcess process) {
              process.end();
              vector<IProcess>::iterator it;
              for (it = processes.begin(); it != processes.end(); it++) {
                     if (it->getId() == process.getId()) {
                           processes.erase(it, it);
                           return;
                     }
              }
       }
};
Trên đây là ví dụ cơ bản về một ProcessManager. Trong ví dụ, các tiến trình được sắp xếp theo thứ tự ưu tiên bằng tham số priority. Như vậy sau mỗi lần thực hiện cập nhật ProcessManager các tiến trình con khác cũng được cập nhật và trở thành core của game, xử lý các cập nhật và trạng thái của game.

TIẾN TRÌNH RENDER
Xem xét ví dụ dưới đây, tiến trình render sẽ hiện thực interface Iprocess và chính thức được lồng ghép vào quá trình thực thi của ProcessManager.
class RenderProcess : public IProcess {
public:
       virtual bool start() {
              // Khoi tao he thong render
              return true;
       }

       virtual bool update(int time) {
              spaceship.render();
              for(int i = 0; i < flyingSaucers.count; i++) {
                     flyingSaucer.Render();
              }
              for(int i = 0; i < asteriods.count; i++) {
                     asteriods[i].Render();
              }
              for(int i = 0; i < bullets.count; i++) {
                     bullets[i].Render();
              }
       }

       virtual void end() {
              // xoa he thong render
       }
};

SỬ DỤNG INTERFACE
Nhưng ví dụ trên vẫn chưa thật sự hiệu quả, trong đó có khá nhiều code giống nhau bị lặp lại. Như vậy để khắc phục, chúng ta xây dựng một Interface qua đó sẽ khiến code trở nên thông minh hơn.
class IRenderable {
public:
       virtual void render();
};
////////////////////////////////
class RenderProcess : public IProcess {
private:
       vector<IRenderable> targets;

       virtual bool start() {
              // khoi tao he thong render
              return true;
       }

       virtual bool update(int time) {
              vector<IRenderable>::iterator it;
              for (it = targets.begin(); it != targets.end(); it++) {
                     it->render();
              }
       }

       virtual void end() {
              // xoa he thong render
       }
};
Vậy lớp SpaceShip sẽ được mô tả như sau:
class Renderable : public IRenderable {
public:
       DisplayObject view;
       Point position;

       virtual void render() {
              view.x = position.x;
              view.y = position.y;

              // render object
       }
};
/////////////////////////
class SpaceShip : public Renderable {

};
Và hiển nhiên, tất cả các đối tượng được render ra màn hình phải kế thừa từ lớp Renderable



TIẾN TRÌNH MOVE
Bước tiếp theo, chúng ta sẽ xem xét việc hình thành tiến trình Move, giúp cập nhật vị trí của đối tượng trong game.
////////////////////////
class IMoveable {
public:
       virtual void move(int time);
};
////////////////////
class MoveProcesses : public IProcess {
private:
       vector<IMoveable> targets;

public:
       virtual bool start() {
              return true;
       }

       virtual bool update(int time) {
              vector<IMoevable>::iterator it;
              for (it = targets.begin(); it != targets.end(); it++) {
                     it->move(time);
              }
       }

       virtual void end() {
      
       }
};
///////////////////
class Moveable : public IMoveable {
public:
       Point position;
       Point velocity;

       virtual void move(int time) {
              position.x += velocity.x;
              position.y += velocity.y; 
       }
};
/////////////////////
class SpaceShip : public Moveable {
};

ĐA THỪA KẾ
Đến đây, code có vẻ khá dể tiếp cận và rõ ràng, tuy nhiên, trên thực tế một đối tượng đôi khi có cả 2 thuộc tính, hoặc nhiều hơn thế nữa. Nhưng những ngôn ngữ hiện nay lại không cho phép đa kế thừa bởi lẽ chính chúng cũng rất phức tạp và phiền toái.
Một giải pháp ban đầu cho vấn đề này chính là, chúng ta sẽ để class Moveable kế thừa từ lớp Renderable.
/////////////////////
class Moveable : public Renderable, public IMoveable {
public:
       Point velocity;
      
       virtual void move(int time) {
              position.x += velocity.x;
              position.y += velocity.y;
       }
};
/////////////////////
class SpaceShip : public Moveable {
};
Bây giờ, SpaceShip bao gồm Moveable và Renderable. Chúng ta có thể làm tương tự với các đối tượng khác, từ đó hình thành mô hình kế thừa như sau.

Đối với những đối tượng tĩnh, không bao gồm di chuyển ta có thể cho chúng kế thừa từ Renderable.


TRƯỜNG HỢP: MOVEABLE KHÔNG RENDERABLE
Nhưng nếu chúng ta muốn một đối tượng có di chuyển nhưng không render thì sao? Chúng ta có thể thực hiện bằng cách cho một đối tượng chỉ được hiện thực từ interface Imovable.
class InvisibleMoveable : public IMoveable {
public:
       Point position;
       Point velocity;

       virtual void move(int time) {
              position.x += velocity.x;
              position.y += velocity.y;
       }
};
Thiết kế trên đây, có vẻ hơi khờ nhưng có thể dụng được trong game đơn giản, tuy nhiên đối với những game tích hợp nhiều các đối tượng, mọi thứ sẽ trở nên khó kiểm soát, và dần trở nên khủng khiếp khi game giản nở về kích thước.
LỰA CHỌN CHO SỰ KẾT HỢP HƠN LÀ KẾ THỪA.
Đến đây chúng ta sẽ bàn về việc phối kết hợp các lớp hơn là chỉ sử dụng một phương án duy nhất là kế thừa.
Nếu trên đây lựa chọn phương án kế thừa từ 2 lớp Moveable và Renderable để có thể sinh ra một lớp có khả năng vẽ và di chuyển, thì phương án dưới đây, sẽ sinh ra lớp mới với bao hàm các biến vẻ và render bên trong.
class SpaceShip  {
public:
       IRenderable renderData;
       IMoveable moveableData;
};


Bằng cách này chúng ta sẽ phối hợp các xử lý trong một đối tượng mà tranh được những khó khăn, hạn chế của phương pháp kết thừa.



Như vậy, khi khởi tạo một đối tượng entity, ở đây cụ thể là SpaceShip chúng ta sẽ không thể đưa nó vào ngay một tiến trình nào, mà thực hiện như sau:
public SpaceShip createSpaceShip() {
       SpaceShip *spaceship = new SpaceShip();
       ...
       renderProcess.addItem(spaceship.renderData);
       moveProcess.addItem(spaceship.moveData);
       ...
       return spaceship;
}
Cách tiếp cận như trên đây, giúp ta dễ dàng tránh khỏi việc hình thành nên một mớ lôn xộn từ việc chồng chéo kế thừa, tuy nhiên vẫn có vấn đề tồn tại. Nó là gì?
CHIA SẺ DỮ LIỆU?
Như vậy, theo thiết kế ở trên, dữ liệu position sẽ được cả 2 lớp Moveable và Renderable sử dụng. Do đó, giá trị sẽ được thay đổi tại Moveable và ở Renderable chỉ lấy ra và sử dụng. Nếu cả 2 cùng thay đổi sẽ dấn đến mất thống nhất dữ liệu.
Để khắc phục vấn đề này, 2 thể hiện Moveable và Renderable phải cùng dùng chung một đối tượng dữ liệu. Đó là lý do của việc hình thành lớp PositionComponent, VelocityComponent, DisplayComponent dưới đây.
////////////////
class PositionComponent {
public:
       int x;
       int y;
};
/////////////////////
class VelocityComponent {
public:
       int velocityX;
       int velocityY;
};
////////////////////
class DisplayComponent {
public:
       DisplayObject view;
};
///////////////////
class Renderable: public IRenderable {
public:
       DisplayComponent display;
       PositionComponent position;

       virtual void render() {
              display.view.x = position.x;
              display.view.y = position.y;
       }
};
////////////////
class Moveable : public IMoveable {
public:
       PositionComponent position;
       VelocityComponent velocity;

       virtual void move(int time) {
              position.x += velocity.velocityX;
              position.y += velocity.velocityY;
       }
};
///////////////
class SpaceShip {
public:
       SpaceShip() {
              moveData = new Moveable();
              renderData = new Renderable();
              moveData->position = new PositionComponent();
              moveData->velocity = new VelocityComponent();
              renderData->position = moveData->position;
              renderData->display = new DisplayComponent();
       }
};
Các tiến trình process thực hiện sẽ tiếp tục như ở trên và không cần thay đổi gì cả.

KẾT LUẬN
Đến thời điểm này, chúng ta hoàn toàn có thể thở phào nhẹ nhõm khi các tiến trình được tách rời nhau thực hiện, các đối tượng được quản lý một cách dể dàng.
Như vậy, kiến trúc game của chúng ta được xây dựng theo hướng đối tượng tuyệt đối, các đối tượng thực thi đúng nhiệm vụ được phân công, và không hề diễn ra bất cứ chồng chất nào về mặt kế thừa hay dữ liệu. Nhưng, ở phần sau, nếu thật sự bạn muốn nỗi điên trong lập trình thì sẵn sàng đón tiếp những gì sắp đến nhé.

1 nhận xét:

  1. dang lam entity, may ma nhớ ra nên lục lại bài này. Thanks chú...

    p/s: Tao tag bạn t vào đây dư lào ??

    Trả lờiXóa