游戏对象
在前面的 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)。