通过对所有对象实例同时进行帧更新来模拟一系列相互独立的游戏对象。
玩家所操控的强大女武神在执行任务,目标是从法师之王所长眠的埋骨地里盗取珍贵珠宝。她试探性地接近法师那法力强大的地穴入口,并将受到攻击——可实际上什么也没有,没有被诅咒的雕像向她发射光线,也没有亡灵士兵在入口巡逻。她长驱直入,轻取珠宝,然后你赢了,然后游戏结束。
嗯,这真没劲。
这个地穴需要一些守卫来绊住我们的英雄。首先,我们希望让一个复活的骷髅兵在门口来回巡逻。我想你已经猜到该怎么写代码了,你可以这样要让它来回巡逻:
注解
假如法师之王希望仆从们有更机智的表现,那么他需要复活一些聪明的家伙们
while (true)
{
// Patrol right.
for (double x = 0; x < 100; x++)
{
skeleton.setX(x);
}
// Patrol left.
for (double x = 100; x > 0; x--)
{
skeleton.setX(x);
}
}
此代码的问题在于,虽然怪物来回走着但玩家却看不到它。程序被一个死循环锁住,这显然会带来很差劲的游戏体验。我们所希望的是骷髅兵每一帧走一步,以保证在骷髅守卫巡逻时.游戏能持续地进行渲染并对玩家的输入做出反应。如:
注解
当然,游戏循环(Game Loop)是本书介绍的另一种设计模式
Entity skeleton;
bool patrollingLeft = false;
double x = 0;
// Main game loop:
while (true)
{
if (patrollingLeft)
{
x--;
if (x == 0) patrollingLeft = false;
}
else
{
x++;
if (x == 100) patrollingLeft = true;
}
skeleton.setX(x);
// Handle user input and render game...
}
我之所以列出前后两个版本,是为了告诉读者代码是如何变复杂的。向左,右巡逻本是两个相互独立的循环,骷髅依赖于循环的执行来保持对自己巡逻方向的跟踪。为达到逐帧处理的目的,我们必须逐帧跳出游戏循环并随后(在下一帧时)返回循环内以继续,在此必须借助变量patrollingLeft
以在循环内外维持对其方向的跟踪。
但这至少奏效,我们接着前进。一堆无脑的骨头可不会对你的女武神造成什么威胁,于是接下来我们为它加入一些魔法状态,这将使它能频繁地向我们的女武神释放闪电和火光,让她措手不及。
时刻记得我们的风格——“最简单地写代码”,于是我们这么写:
// Skeleton variables...
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;
// Main game loop:
while (true)
{
// Skeleton code...
if (++leftStatueFrames == 90)
{
leftStatueFrames = 0;
leftStatue.shootLightning();
}
if (++rightStatueFrames == 80)
{
rightStatueFrames = 0;
rightStatue.shootLightning();
}
// Handle user input and render game...
}
你会发现这代码的可维护性不高。我们维护着一堆其值不断增长的变量,并不可避免地将所有代码都塞进游戏循环里,每段代码处理一个游戏中特殊的实体。为达到让所有实体同时运转的目的,我们把它们给糊成一团了。
注解
一旦当你的代码构架可以确切地用“糊作一团”来形容,那你可遇到麻烦了。
你可能猜到我们所要运用的设计模式该干些什么了:它要为游戏中的每个实体封装其自身的行为。这将使游戏循环保持整洁并便于往循环中增加或移除实体。
为了做到这一点,我们需要一个抽象层,为此定义一个 update()的抽象方法。游戏循环维护对象集合,但它并不关心这些对象的具体类型。它只是更新它们。这将每个对象的行为从游戏循环以及其他对象那里分离了出来。
每一帧,游戏循环遍历游戏对象集合并调用它们的update()。这在每帧都给予每个对象一次更新自己行为的机会。通过逐帧调用update方法,这些对象的表现得到同步。
注解
有些爱挑刺的人会说,它们并不是真正意义上的行为同步,因为一个对象更新时其他对象都不在更新——让我们后面再来深入这个问题。
游戏循环维护一个动态对象集合,这使得向关卡里添加或移除对象十分便捷——只要往集合里增加或移除就好。问题已解决,我们甚至可以将关卡文件用某种文件格式存储,以供我们的关卡设计师们使用。
游戏世界维护一个对象集合。每个对象实现一个更新方法来在每帧模拟自己的行为。而游戏循环在每帧对集合中所有的对象调用其更新方法以实现同步的游戏世界更新。
假如把游戏循环比作有史以来最好的东西,那么更新方法模式就会让它锦上添花。许多游戏都通过这样或那样的形式来使用这一设计模式,以构造出许多鲜活的游戏实体来与玩家进行交互。像游戏里的太空战士,龙,火星人,幽灵或者运动员们,他们正适合使用这一设计模式。
然而,假如这个游戏更加抽象,那些移动的对象并不像是生物而更像是西洋棋子,那么这一模式就不那么适用了。在一个类似西洋棋的游戏里,你并不需要同时模拟所有对象,而且你很可能也没必要让棋子们逐帧地更新自身。
注解
或许你无须逐帧更新它们的行为,但即便是在棋类游戏中,你也很可能需要逐帧更新它们的动画。这一设计模式同样可以帮到你。
更新方法模式在如下情境最为适用:
- 你的游戏中含有一系列对象或系统需要同步地运转。
- 各个对象之间的行为几乎是相互独立的。
- 对象的行为与时间相关。
这一设计模式相当简单,所以它并没有什么值得发现的惊喜。当然,每行代码也都有它的意义在。
比较先前的两个代码块,第二个显得更加复杂。二者都只是让骷髅守卫来回行走,但第二个代码块将控制权分派给了游戏循环的每一帧。
这一变化几乎在处理用户输入,渲染以及其它游戏循环所关心的事情时是必不可少的,所以第一个例子并不实用。例一的价值在于让我们牢记,这样处理你对象的表现,你将面临着复杂而巨大的开销。
注解
我所说“几乎”,是因为有时你也可以兼得鱼与熊掌。你可以直接为你的对象行为编码而不让这些函数返回,同时使这样一系列的对象与游戏循环保持同步运转。
要想实现这一点,你就必须使用多线程来让这些对象同时运转。假如一个对象可以在处理时中途暂停并继续,你可以用更强制的方式来执行而不必完全让函数结束返回。
实际中的线程往往对我们的例子而言过于繁重,但假如你的语言支持轻量的并发性组件诸如生成器,协程,Fibers(Node.js),那可以考虑使用它们。
Bytecode设计模式是在应用程序层创建多线程的另一种选择。
在第一段示例代码中,我们并无任何指明守卫移动方向的变量。方向完全取决于当前执行的是哪一段代码。
当我们将其改造为逐帧更新的形式时,我们需要创建一个patrollingLeft变量来跟踪这个行走方向。当我们脱离内部代码,我们就无法获知行走的朝向,所以说我们需要存储足够的帧信息以便下一帧能够继续执行。
State设计模式在这里通常能帮上忙,因为状态机(正如其名)存储了那些能够让你在下一帧继续处理的游戏信息。
在本设计模式中,游戏循环在每帧遍历对象集并逐个更新对象。在update()
的调用中,多数对象能够访问到游戏世界的其他部分,包括那些正在被更新的其他对象。这意味着,游戏循环遍历更新对象的顺序意义重大。
假如A对象在对象列表中位于B对象的前面,那么当A更新时,它将会看到B停留在前一帧的状态。但当B更新时,它看到的却是A在这一帧的新状态,因为A在这一帧已经被更新了。尽管从玩家的视角来看,所有的事物都同时在运转,但游戏的核心仍然是回合制的——只不过这时两回合之间的间隔只有一帧的时间。
注解
假如由于某些原因你希望回避这一有序性,你可能会需要Double Buffer模式的帮助。这一模式将使得A,B的更新顺序不再重要,因为它们都能够获取到前一帧的状态。
考虑到游戏逻辑,更新分先后顺序这是件好事。平行地更新所有对象会将你带向语义死角,设想西洋棋盘上黑白棋子同时移动,它们都想往一个当前空白的位置移动,这该怎么办?
序列化地更新解决了这一问题——每次更新增量式地改变游戏世界,从一个有效的状态到下一个,不会产生对象状态的歧义而需要去进行调解。
注解
这同样在在线游戏模式起作用,因为你需要一串序列化的动作数据来在网际间进行传输。
当你使用这一模式时,大量的游戏表现将在这些更新方法中完成。这里面常常包含着从游戏中增加或移除对象的代码。
例如,假设一个骷髅卫兵被杀死时会掉落一个物品,对于一个新对象,你通常可以直接将它加入到列表的尾部而不会产生问题。循环继续,最终你能够在循环的末尾找到这个新对象并更新它。
但这意味着在这个新对象产生的那一帧它也有机会进行更新,而此时玩家尚未看到这个物品。假如你不希望这样的情况发生,一个简单的办法就是在遍历之前存储当前对象列表的长度,而在这一次循环仅更新列表前面这么多的对象:
int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
objects_[i]->update();
}
上例中,objects_是游戏中可更新对象的数组而numObjects_是它的长度。当增加新的对象时,这个长度变量增长。我们在循环的一开始将长度缓存在numObjectsThisTurn变量中,从而使这一帧的循环迭代在遍历到任何新增对象之前停止。
一个令人担忧的问题是在迭代时移除对象。你希望让一只肮脏的野兽从游戏中消失,而这时候需要从对象列表中移除它。假如在对象表中,它碰巧位于你当前所更新的对象之前,你可以投机地跳过它。
for (int i = 0; i < numObjects_; i++)
{
objects_[i]->update();
}
这一简单的循环通过对象下标索引的递增来更新每个对象。下面的示例图中左侧展示了当我们更新女主角时对象数组的变化。
我们更新她时,i等于1,她斩杀了肮脏的野兽所以它从数组中被移除。而女武神移动到i为0的位置,而倒霉的农夫被前移到1的位置。在女武神更新结束后,i增长到2。如上图右侧所示,倒霉的农夫在循环中被跳过并且永远也不会更新了。
注解
一个简便的解决方法是当你更新时从表的末尾开始遍历。此方法下移除对象,只会让已经更新的物品发生移动。
另一方法是在移除对象时多加小心,并在更新任何计数器时把被移除的对象也算在内。还有一个办法是将移除操作推迟到本次循环遍历结束之后。将要被移除的对象标记为“死亡”,但并不从列表中移除它。在更新期间,确保跳过那些被标记死亡的对象,接着等到遍历更新结束,再次遍历列表来移除这些“尸体”。
假如在更新循环中你加入了多线程,采用延迟修改的方法较好,因为这可以避免更新期间线程同步带来的巨大开销。
这一模式十分浅显,从例子里我们就能看出其要点。这并不意味着它没用,而正因为它的简单才使得它好用——它是一个简明而不加任何修饰的解决方案。。但为了更具体地阐明此方法,我们还是来看一个基本的实现例子。让我们从这个代表着骷髅和雕像的Entity类来开始吧:
class Entity
{
public:
Entity()
: x_(0), y_(0)
{}
virtual ~Entity() {}
virtual void update() = 0;
double x() const { return x_; }
double y() const { return y_; }
void setX(double x) { x_ = x; }
void setY(double y) { y_ = y; }
private:
double x_;
double y_;
};
在这个类里我并没有加入太多东西,只有那些后面能用到的成员。实际的项目中还将包含有诸如图形和物理的部分。而上面的类中最重要的部分就是这一设计模式所要求的update()抽象方法。
游戏维护一系列这样的实体,在我们的例子中,我们将它们置入一个代表游戏世界的类中:
class World
{
public:
World()
: numEntities_(0)
{}
void gameLoop();
private:
Entity* entities_[MAX_ENTITIES];
int numEntities_;
};
注解
在一个实际的游戏项目中,你可能会用到一个实际的集合类,但在此我仅使用普通的数组来让事情简单些。
一切准备就绪,遍历实体逐帧更新的游戏实现如下:
void World::gameLoop()
{
while (true)
{
// Handle user input...
// Update each entity.
for (int i = 0; i < numEntities_; i++)
{
entities_[i]->update();
}
// Physics and rendering...
}
}
注解
见名知意,这就是游戏循环(Game Loop)模式的例子。
肯定有些读者现在很不舒服,因为我在这里对主要的Entity类采用了继承的方式来定义不同的行为。假如你碰巧遇到了问题,我将会提供一些文章。
随着游戏产业从最初6502汇编语言和VBLANK(老式的阴极射线管)显示器的海洋到OOP(面向对象)上岸,开发者们陷入了一场软件架构的狂热,其中之一就是对继承的使用。高耸而错综复杂的类继承大厦被建立起来,遮天盖地。
而事实证明继承真是个恐怖的想法,没人能够在不拆解的情况下维护一个庞大的继承关系,甚至连GoF(最杰出的的4个软件设计师,合著有《设计模式:可复用面向对象软件的基础》一书)都在1994年发现了这一点,并写道:
Favor ‘object composition’ over ‘class inheritance’.(组合对象,而不是类继承)
注解
在你我之间,我想子类继承的问题离我们甚远。我几乎避开了它,但执着于避免使用继承就和执着于使用它一样糟。你完全可以适度使用它而不必完全禁用。
当游戏产业中的人们纷纷意识到类继承糟糕的一面时,Component设计模式应运而生。借此,update()方法能够置于实体的组件之中而非依附Entity本身。这将帮助你避免为了定义和复用不同表现的实体类而构建出复杂的实体类继承关系。取而代之的是用各种组件来组装这些子类。
假如我在实际开发一款游戏,我也会这么做。但这一章并不讨论组件模式而是update()方法,我尽可能简洁并快速地表达出它们,而将这个方法直接放在Entity类里并进行一两个子类的继承就是最快的方法。
注解
组件模式请看这里
回到正题,我们最初的动机是要定义一个骷髅守卫,和能放出电光石火的魔法雕像。从我们的骷髅朋友开始吧。为了定义其巡逻行为,我们通过恰当地实现update()
方法来创建新的实体类。
class Skeleton : public Entity
{
public:
Skeleton()
: patrollingLeft_(false)
{}
virtual void update()
{
if (patrollingLeft_)
{
setX(x() - 1);
if (x() == 0) patrollingLeft_ = false;
}
else
{
setX(x() + 1);
if (x() == 100) patrollingLeft_ = true;
}
}
private:
bool patrollingLeft_;
};
如你所见,我们所做仅仅是从游戏循环中复制代码并将它粘贴到Skeleton类的update()
方法中。一个小差异在于这里patrollingLeft_
被变成了一个类成员而非局部变量。借此便能保证,这一变量在update()
方法调用期间有效。
我们对Statue类如法炮制:
class Statue : public Entity
{
public:
Statue(int delay)
: frames_(0),
delay_(delay)
{}
virtual void update()
{
if (++frames_ == delay_)
{
shootLightning();
// Reset the timer.
frames_ = 0;
}
}
private:
int frames_;
int delay_;
void shootLightning()
{
// Shoot the lightning...
}
};
再一次,最大的改动就是从游戏循环中移出一些代码以及对变量重命名。这样一来,我们让代码更加简洁——在原来不可避免杂乱的代码中,本会出现许多存储不同雕像帧计数器和开火频率的局部变量。
既然这些都已经被移动到Statue类之中,你可以随心所欲地创建Statue的实例,而它们各有自己的计时器。这正是本设计方法背后的本意——现在向游戏世界中添加实体更加容易了,因为每个实体都携带着所有自己所必须的东西,自给自足。
这一模式让我们避免了在扩展游戏时采用继承。这一模式反倒让我们可以单独地使用数据文件或者关卡编辑器来扩展游戏世界。
注解
还有人关心UML图么?如果还有那上图就对应着我们所创建的类结构的UML图。
这是游戏核心的设计模式,但我只是做了其最常用部分的提炼。至此,我们都假设每次对update()的调用都让整个游戏世界向前推进相同(固定)的时间长度。
我更喜欢这种方式,但多数游戏使用变时长的方式。那样做,每次游戏循环可能会占用更多或更少的时间,具体取决于其处理更新和渲染前一帧所的开销。
注解
Game Loop 这一章详述了定时和变时步长的优劣。
这意味着每次update()
的调用需要知道虚拟时钟所流逝的时间,于是你常会看到流逝的时间会被作为参数传入。例如,我们可以像下面那样让骷髅卫兵处理一个变时步长更新:
void Skeleton::update(double elapsed)
{
if (patrollingLeft_)
{
x -= elapsed;
if (x <= 0)
{
patrollingLeft_ = false;
x = -x;
}
}
else
{
x += elapsed;
if (x >= 100) {
patrollingLeft_ = true;
x = 100 - (x - 100);
}
}
}
现在,骷髅移动的距离随着时间间隔而增长。你同样能看到处理变时步长时额外增加的复杂度。骷髅可能在很长的时间差下超出其巡逻范围,我们需要小心地对这一情况进行处理。
这样一个简单的设计模式,并无太多可选项。但它也仍有选择的余地~
你显然必须决定好该把update()放哪。
1.实体类(父类)中
假如你已经创建了实体类,那么这是最简单的选项。因为这不会往游戏中增加额外的类。假如你不需要很多种类的实体,那么这种方法可行,但实际项目很少这么做。
每当希望实体有新的表现时就创建子类,这会积累大量的类而导致项目难以维护。你最终会发现你希望通过一种单一继承层次的优雅映射方式来复用代码模块,那时候你就该傻眼了。
2.组件类中
假如你已经使用了Component模式,那么傻瓜也知道怎么做了——让每个组件更新其自身。和更新方法模式一样地将每个游戏实体从游戏世界中分离出来,组件方法也让各个组件部分分离开来。渲染,物理,AI可以各自顾好自己。
3.代理类中
将一个类的表现代理给另一个类这涉及到其他几种设计模式。应用State 设计模式可以让你通过改变一个对象的代理来改变其行为。[Type Object设计模式](./04.3-Type Object.md)可以让你在多个同类型的实体之间共享行为表现。
假如你使用上述任一种设计模式,那么自然而然地需要将update()
方法置于代理类中。这么一来,你可能在主(父)类中仍保留update()
方法,但它成为非虚的方法并将指向代理类对象的update方法,如:
void Entity::update()
{
// Forward to state object.
state_->update();
}
这么做让你能在代理类之外定义新的行为方式。正像使用Component模式那样,这为你开辟了灵活定义新类和新的行为方式的途径。
你常需要在游戏中维护这样一些对象:不论出于何种原因,它们暂时无需被更新。它们可能被禁用,被移除出屏幕,或者至今尚未解锁。假如大量的对象处于这种状态,这可能会导致CPU每一帧都浪费许多时间来遍历这些对象同时又对他们毫无作为。
一种方法是单独维护一个需要被更新的“存活”对象表。当一个对象被禁用时,将它从其中移除。当它重新被启用时,把它加回表中。这样做,你只需遍历那些实际上有作为的对象。
- 假如你使用单个集合来存储所有游戏对象:
- 你在浪费时间。对于暂时无用的对象,你需要检查它们”是否死亡”的标志,或者调用一个空方法。
注解
除了浪费CPU循环来检查对象是否被激活并跳过它的问题,空指针问题还可能搞坏你的缓存区。CPU通过加载读取RAM中的数据到更快的单片缓存来加载内存,这是基于投机地假设在一段时间内你读取的内存是连续的情况下进行的。当你跳过一个对象时,你可能会跳过缓存区(存储当前这个对象)的末尾,而让CPU再去另一块内存寻址。
- 假如你对活跃对象单独用一个集合来维护:
- 你将使用额外的内存来维护这第二个集合。因为往往你需要一个主集合来维护所有的对象,以便在需要所有对象能够访问它们全部。这么说来,这额外的集合在技术上是多余的。当游戏对速度的要求比对内存的要求高时(往往是这样的),这样的取舍还是值得的。
- 另一种缓和此问题的办法是,同样维护两个集合,但另一个只维护那些未被激活的对象,而不是维护所有对象。
- 你必须保持两个集合同步。当对象被创建或者销毁(并非临时禁用而是永久销毁)时,你必须记住同时修改主集合和活跃对象集合。
这里该使用什么方法,取决于你对非激活对象数目的预估。其数目越多,就越需要创建一个独立的集合来在存储他们以便在游戏循环时避免处理这些非激活对象。
- 这一模式与[Game Loop](./03.2-Game Loop.md)和Component模式共同构成了多数游戏引擎的核心部分。
- 当你开始考虑实体集合或循环中组件在更新时的缓存效能,并希望它们更快地运转,[Data Locality模式](./06.1-Data Locality.md)将会有所帮助。
- Unity的引擎框架在许多类模块中使用了本模式,包括MonoBehaviour类。
- 微软的XNA平台在Game和GameComponent类中均使用了这一模式。
- Quintus的基于JavaScript的游戏引擎在其主Sprite类中使用了这一模式。