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é.
dang lam entity, may ma nhớ ra nên lục lại bài này. Thanks chú...
Trả lờiXóap/s: Tao tag bạn t vào đây dư lào ??