遊戲對象
在前面的 Pong 遊戲中,沒有用不同的類去代表牆,球拍和球,僅僅使用了一個 Game
類。像 Pong 這種簡單遊戲當然沒問題,但它不是可擴展的解決方案。爲了可擴展性,將牆、球拍、球分別用不同的類表示是更好的選擇。
遊戲對象(game object),指的是遊戲中任何需要更新和繪製的事物。表示遊戲對象存在不同的方法,有的採取層次結構,有的採用組合,也有更復雜的設計模式。但不管是哪種表示方法,遊戲都需要某種方式來跟蹤和更新這些遊戲對象。
有時候,開發者會把在遊戲中只繪製,不更新的對象稱爲靜態對象。這些對象對玩家可視,但是從來不需要更新,比如關卡的背景、那些人畜無害的遊戲建築。相反,有的遊戲對象只更新但不繪製,例如攝像機的視角,還有比如觸發器。恐怖遊戲可能希望在玩家接近門時出現殭屍。在這種情況下,關卡設計師會放置一個觸發器對象,可以檢測玩家何時接近並觸發生成殭屍的動作。實現觸發器的一種方法就是將其作爲一個不可見的框,更新每一幀時檢查與玩家的交集。
遊戲對象模型
有很多的遊戲對象模型,或者說有不止一種方式代表遊戲對象。
類層次繼承
一種遊戲對象模型是在標準的面向對象的類層次結構中聲明遊戲對象,有時稱爲單一類層次結構,因爲所有遊戲對象都從一個基類繼承。要用這種遊戲模型,首先需要有一個基類。比如說,像這樣的。
class Actor
{
public:
virtual void Update(float deltaTime);
virtual void Draw();
};
之後,就可以擁有不同的子類。
class PacMan : public Actor
{
public:
void Update(float deltaTime) override;
void Draw() override;
};
這種實現的一個缺點是每個遊戲對象必須擁有基類的所有的屬性和方法。但就像之前說的那樣,在某些遊戲對象上調用 Draw
是在浪費時間。
隨着遊戲功能的增加,問題可能會更爲明顯。例如遊戲有兩個角色是可以移動的,但是有的角色不能移動。如果把移動的代碼放到基類 Actor
中,但又不是每個對象都可以移動。按照計算機界的環境法則,表達力不夠,就加一層。那麼可以在再編寫 MovingActor
,但這無疑會使得類繼承上變得更加複雜。
更進一步地,當兩個兄弟類稍後需要在它們之間共享特徵時,一個龐大的類層次結構可能導致更大的困難。例如,俠盜獵車手的遊戲可能會有一個 Vehicle
類。從這個類中,創建兩個子類可能是有意義的:LandVehicle
(用於穿越陸地的車輛)和 WaterVehicle
(用於水上交通,如船)。
那麼如果哪一天想不開,想要有水陸兩棲車。由於 C++ 允許多繼承,所以一種解決方法是定義一個 AmphibiousVehicle
同時繼承 LandVehicle
和 WaterVehicle
。多繼承也意味着 AmphibiousVehicle
沿着兩條不同的路徑從 Vehicle
繼承。這種類型的層次結構(稱爲菱形繼承)可能會導致問題,因爲子類可能會繼承虛函數的多個版本。因此,通常建議避免採用多繼承。
組件化
越來越多的遊戲爲了避免使用龐大的繼承體系,採取了基於組件化(component-based)的遊戲對象模型。這種模型越來越流行,一個很重要的原因是 Unity 遊戲引擎使用了這種模型。這種實現方案中,有一個遊戲對象類,但是沒有子類。採取的是“有一個”(has-a)組件的集合對象來實現需要的功能。
繼承是“is-a”(是一個)的關係。若遵循里氏替換原則,有父類出現的地方,就可以用子類替換(“子類”也是一個“父類”)。
舉個例子,就上面那張 Pic-Man(吃豆人) 的類繼承圖而言,Pinky
是 Ghost
的子類,Ghost
又是 Actor
的子類。如果採取基於組件的模型,Pinky
是一個 GameObject
,包含4個組件:PinkBehavior
、CollisionComponent
、TransformComponent
和 DrawComponent
。
如果可以用谷歌,搜索吃豆人,可以在線玩這個遊戲(google官方出品)。
這些組件都可以擁有自己的特定的屬性和方法。例如,DrawComponent
可以用於處理在屏幕上繪製對象的功能,而 TransformComponent
用來存儲遊戲世界中游戲對象的位置和變化。
class GameObject
{
public:
void AddComponent(Component* comp);
void RemoveComponent(Component* comp);
private:
std::unordered_set<Component*> mComponents;
};
注意 GameObject
僅僅包含添加和移除組件的函數。例如,每個 DrawComponent
可以註冊有 Renderer
,這樣 DrawComponent
需要繪製幀的時候,Renderer
可以意識到。使用基於組件的模型的一個優點就是可以很容易地爲遊戲對象添加它所需要的特別功能。任意的對象需要繪製,就可以包含一個 DrawComponent
。
組件化的缺點是純組件系統相同遊戲對象下的組件依賴是不明確的。比如說,DrawComponent
需要知道 TransformComponent
才能知道到哪裏才應該繪製。這就意味着 DrawComponent
需要詢問自己的 GameObject
關於 TransformComponent
的信息。依賴這種實現,這些查詢就會成爲顯而易見的性能瓶頸。
具有組件的層次結構
爲了在上述兩種模型中找到一個折中方案,可以考慮將繼承與組件結合起來。這種混合的遊戲模型被用在了虛幻4引擎中。同樣是一個 Actor
基類,也帶有虛函數,但同時也有一個 vector
類型的組件集合。
class Component
{
public:
// 構造函數
// (值越低的更新順序,則組件越早更新)
Component(class Actor* owner, int updateOrder = 100);
// 析構
virtual ~Component();
// 通過增量時間更新組件
virtual void Update(float deltaTime);
int GetUpdateOrder() const { return mUpdateOrder; }
protected:
// 屬於的角色
class Actor* mOwner;
// 組件的順序
int mUpdateOrder;
};
Actor
類有幾點值得注意。狀態的枚舉 State
跟蹤角色的狀態。例如,Update
僅僅在 EActive
狀態下更新角色。EDead
則代表遊戲要移除角色。Update
函數調用先調用 UpdateComponents
,之後調用 UpdateActor
。UpdateComponents
循環遍歷所有組件並依次更新。UpdateActor
的基類實現是空的,但 Actor
的子類將用特定的行爲重寫 UpdateActor
函數。
某些情況下,Actor
類需要接收 Game
類,包括創建附加的角色。一種實現方法是使遊戲對象作爲單例(singleton)。單例設計模型使得全局可以獲取這個類的一個實例。但是單例模式可能會導致其它的問題,比如全局需要多個實例的時候。因此這裏採取的是另外一種實現,被稱爲依賴注入(dependency injection)。
在依賴注入的實現中,構造函數接收 Game
類的指針。一個角色使用這個指針去創建其它的角色。Vector2
是角色的位置,除此之外 mScale
和 mRotation
則用來放縮和旋轉角色。注意,旋轉採用的是弧度,而不是角度。
Component
類中 mUpdateOrder
值得注意。它可以用來確定要更新的組件之前或者之後的其它組件。這在很多情形下是有用的。例如,跟蹤玩家的相機(camera)組件可能想要在移動(movement)組件移動玩家之後更新。爲了保持這種順序,因此 AddComponent
在添加新組件時會排序組件向量。最後,Component
類中有一個指針指向了自己的角色(actor)。這樣一來,組件可以在必要的時候獲取變形的數據或者任何其它信息。
class Component
{
public:
// 構造函數
Component(class Actor* owner, int updateOrder = 100);
// 析構
virtual ~Component();
// 通過增量時間更新組件
virtual void Update(float deltaTime);
int GetUpdateOrder() const { return mUpdateOrder; }
protected:
// 屬於的角色
class Actor* mOwner;
// 組件的順序
int mUpdateOrder;
};
這種混合模型可以避免深層次的繼承,但可以確定的是,這個模型的繼承深度會比純組件化模型要深。一般而言,混合模型可以避免,但不是完全可以消除組件之間交流的問題。這是因爲每個角色都有自己重要的屬性,比如變換的數據。
其它方案
還有其它的遊戲模型的實現。有的採用接口類來定義不同的函數集。每個遊戲對象通過實現必要的接口來代表它。也有的模型拓展了組件模型,進一步地避免包含整個遊戲對象。
每一種遊戲模型都有它的優點和缺點。之後會採用繼承和組件化的混合模型,相對而言,它是個不錯的模型,複雜度也相對可控。
在遊戲循環中集成遊戲對象
要在遊戲循環中融入混合遊戲對象模型,需要多寫一些代碼,但是並不複雜。先在 Game
中添加兩個 std::vector
向量,類型是 Actor*
,一個用來包含活動的 actors(mActors
),一個用來包含一個待定的 actors(mPendingActors
)。
// 遊戲中所有的 actor
std::vector<class Actor*> mActors;
// 任意待定的 actor
std::vector<class Actor*> mPendingActors;
之所以需要待定的 actors(mPendingActors
),是爲了處理更新 actors時(遍歷 mActors
),決定創建新的 actor。在這種情況下是不能直接添加元素到 mActors
的,因爲正在用迭代器遍歷(一旦添加,迭代器就失效了)。所以,我們把元素加入到這個待定 actors mPendingActors
中,等到遍歷完 actors 後,把它加入到 mActors
中。
接下來,添加兩個函數 AddActor
和 RemoveActor
。AddActor
添加 actor 到 mPendingActors
或者 mActors
。至於加到哪個向量之中,取決於目前是否在更新 mActors
(通過一個 mUpdatingActors
布爾量進行判斷)。
void Game::AddActor(Actor* actor)
{
// 如果正在更新 actors,需要添加到待定向量中
if (mUpdatingActors)
{
mPendingActors.emplace_back(actor);
}
else
{
mActors.emplace_back(actor);
}
}
類似的,RemoveActor
從相應的 vector 中移除 actor。
void Game::RemoveActor(Actor* actor)
{
// 是否在待定 actor 中
auto iter = std::find(mPendingActors.begin(), mPendingActors.end(), actor);
if (iter != mPendingActors.end())
{
// 交換到尾部(避免複製)
std::iter_swap(iter, mPendingActors.end() - 1);
mPendingActors.pop_back();
}
// 是否在 actor 中
iter = std::find(mActors.begin(), mActors.end(), actor);
if (iter != mActors.end())
{
// 交換到尾部(避免複製)
std::iter_swap(iter, mActors.end() - 1);
mActors.pop_back();
}
}
將需要刪除的元素與最後一個元素進行交換,再移除最後一個元素,可以避免向量刪除中間元素時後面的元素需要向前覆蓋的性能損耗。這是一個數組刪除時的慣用手法,也可以說是C++的慣用法之一。
在 UpdateGame
方法中要在計算增量時間(delta time)後更新所有的 actors。首先循環遍歷 mActors
中的每個 actor 並且調用 Update
。接下來,可以將待定的 actors 轉移到 mActors
中。最後,如果有哪個 actor 的狀態是 EDead
,則刪除。
void Game::UpdateGame()
{
// 計算增量時間
// 從上一幀等待16ms
while (!SDL_TICKS_PASSED(SDL_GetTicks(), mTicksCount + 16))
;
float deltaTime = (SDL_GetTicks() - mTicksCount) / 1000.0f;
if (deltaTime > 0.05f)
{
deltaTime = 0.05f;
}
mTicksCount = SDL_GetTicks();
// 更新所有的 actors
mUpdatingActors = true;
for (auto actor : mActors)
{
actor->Update(deltaTime);
}
mUpdatingActors = false;
// 將待定的actor加入到mActors
for (auto pending : mPendingActors)
{
mActors.emplace_back(pending);
}
mPendingActors.clear();
// 添加廢棄 actor 到另一個臨時向量
std::vector<Actor*> deadActors;
for (auto actor : mActors)
{
if (actor->GetState() == Actor::EDead)
{
deadActors.emplace_back(actor);
}
}
// 刪除廢棄的actor (從 mActors 中移除)
for (auto actor : deadActors)
{
delete actor;
}
}
從遊戲的 mActors
添加和刪除 actor 也會增加代碼的複雜性。後面的代碼,Actor 對象會在構造器和析構函數中自動增加和刪除。當然,這就意味着 Shutdown
的編寫要更加仔細一點:
while (!mActors.empty())
{
delete mActors.back();
}
好了,這一篇僅僅是爲了簡單的介紹一下游戲對象設計模式。下一次,我們來討論遊戲中的精靈(sprite)。