游戏开发模式与最佳实践-全-
游戏开发模式与最佳实践(全)
原文:
zh.annas-archive.org/md5/95cc9625873bb4c88fb82faea0579d85译者:飞龙
前言
随着您的程序变得更大,了解如何编写代码,以便类和模块能够以智能的方式相互通信变得至关重要。知道如何编写干净且可扩展的代码对于中型到大型项目的成功至关重要,尤其是如果有多个程序员参与时。我们不希望花费时间重写其他程序员的代码,因为这比弄清楚原始代码的工作方式要容易。同样,我们也不希望其他程序员被我们的代码所困惑,并重写我们已经解决的问题的解决方案。
本书探讨了设计代码的常见方法,以便它能够被理解、重用、维护,并在必要时扩展。这些常见的模式将使类之间的通信变得简单和清晰。无论您是使用商业游戏引擎还是从头开始编写自己的引擎,了解这些模式都将使您的游戏项目更有可能成功。
本书每一章都探讨了游戏中最常用的设计模式之一。我们一起讨论我们试图解决的问题以及特定模式如何对我们有所帮助。在每一章中,我们还介绍了模式的优缺点,以便您更好地了解何时使用它。本书不是一本“食谱书”。在您的项目中使用模式并不像复制我们提供的代码那样简单。我们不是学习代码的食谱,而是要学习如何编写游戏引擎的基础知识。
我们将通过查看大量的代码示例来完成这项工作。正如我们所说,这些示例不能简单地复制粘贴到任何项目中。然而,通过理解这些示例如何适应本书的特定项目,您可以在任何项目中实现它们。
本书涵盖的内容
第一章,设计模式简介,介绍了设计模式的概念以及它们如何使我们的项目受益。我们还深入了解了代码模块化的优势,并设置了一个示例项目,我们可以在本书中在此基础上构建。
第二章,一个实例统治一切 - 单例模式,讨论了众所周知的单例模式,解释了在游戏中全局访问单个实例的常见论点和反对意见。
第三章,使用组件对象模型创建灵活性,探讨了如何创建大量不同的游戏对象类型,同时最大限度地减少代码重复。
第四章,使用状态模式实现人工智能,展示了如何使用状态来允许游戏对象根据你在游戏中的不同刺激改变其行为和功能。我们讨论并应用了如何将这一概念应用于玩家行为和人工智能。
第五章,通过工厂方法模式解耦代码,解释了如何将我们的特定游戏对象与游戏引擎分离。这将使我们能够重用游戏引擎的所有部分或至少部分,用于未来的游戏。
第六章,使用原型模式创建对象,介绍了另一种减少我们游戏引擎内部依赖的方法。我们学习如何通过它们的基类来创建对象。此外,我们还介绍了如何从文件中读取对象数据。
第七章,通过对象池提高性能,介绍了一种通过重用我们通过动态创建并使用管理器来控制当前使用与否的对象,从而降低我们游戏中内存成本的方法。
第八章,通过命令模式控制 UI,讨论了如何创建可重用的动作,这些动作可以用于 UI 按钮点击或键盘或控制器等输入设备。
第九章,通过观察者模式解耦游戏玩法,解释了类如何在不使用全局变量的情况下进行通信和共享数据的多重方式。
第十章,使用享元模式共享对象,讨论了如何通过分离可以和不可以共享的数据来设计尽可能轻量级的对象。我们利用这一知识创建了一个粒子系统,该系统产生用于模拟火焰、爆炸和烟雾等事物的小型对象。
第十一章,理解图形和动画,提供了对图形和渲染工作原理的低级解释,以便你更好地在你的游戏中实现它们。
第十二章,最佳实践,涵盖了一些将改善你未来代码和游戏项目的主题,涉及如何提高代码质量、正确使用 const 关键字、迭代如何是改善游戏和代码设计的好方法,以及何时应考虑将脚本添加到你的游戏中。
你需要这本书的内容
要编写代码,你实际上只需要一个文本编辑器,但为了进行严肃的开发,你需要使用集成开发环境(IDE)。在这本书中,我们使用 Windows 计算机上的 Microsoft Visual Studio 2015 和 C++,但所教授的概念也可以应用于任何编程语言。对于那些没有安装 Visual Studio 但使用 Windows 的用户,我们将在第一章“设计模式简介”中介绍如何安装免费的 Visual Studio Community 版本并将其与我们的项目设置好。
这本书面向的对象
这本书是为任何想要学习如何编写更好、更干净代码的人准备的。虽然设计模式通常不需要任何特定的编程语言,但本书中的示例是用 C++编写的。如果读者已经熟悉 C++和 STL 容器,他们将能从本书中获得更多收获。本书附带的项目是用 Microsoft Visual Studio 2015 编写的,因此熟悉该 IDE 将非常有帮助。
习惯用法
在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“我将更新ChasePlayerComponent类,该类已存在于EngineTest项目中。”
代码块以如下方式设置:
class Animal
{
public:
virtual void Speak(void) const //virtual in the base class
{
//Using the Mach 5 console print
M5DEBUG_PRINT("...\n");
}
};
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
class StaticExamples
{
public:
static float classVariable;
static void StaticFunction()
{
// Note, can only use static variables and functions within
// static function
std::string toDisplay = "\n I can be called anywhere!
classVariable value: " +
std::to_string(classVariable);
printf(toDisplay.c_str());
}
void InFunction()
{
static int enemyCount = 0;
// Increase the value of enemyCount
enemyCount += 10;
std::string toDisplay = "\n Value of enemyCount: " +
std::to_string(enemyCount);
printf(toDisplay.c_str());
}
};
新术语和重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“为了下载新模块,我们将前往文件 | 设置 | 项目名称 | 项目解释器。”
警告或重要提示以如下框中的形式出现。
技巧和窍门如下所示。
读者反馈
我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要向我们发送一般反馈,只需发送电子邮件至feedback@packtpub.com,并在邮件主题中提及本书的标题。
如果你在一个主题上有所专长,并且你对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”标签上。
-
点击“代码下载和勘误”。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击“代码下载”。
下载文件后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR / 7-Zip。
-
适用于 Mac 的 Zipeg / iZip / UnRarX。
-
适用于 Linux 的 7-Zip / PeaZip。
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Game-Development-Patterns-and-Best-Practices。我们还有其他来自我们丰富图书和视频目录的代码包可供下载,网址为github.com/PacktPublishing/。请查看它们!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/GameDevelopmentPatternsandBestPractices_ColorImages.pdf下载此文件。
勘误
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的“勘误”部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在“勘误”部分下。
侵权
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您在这本书的任何方面遇到问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。
第一章:设计模式简介
你已经学会了如何编程,并且可能在这个阶段已经创建了一些简单的游戏。但现在你想要开始构建更大的东西。也许你尝试构建了一个有趣的项目,但感觉代码像是拼凑起来的。也许你和一群程序员一起工作,但在解决问题时意见不一致。也许你的代码没有很好地集成,或者不断添加的功能与你的原始设计不匹配。也许一开始就没有设计。在构建大型游戏项目时,重要的是要分解你的问题,专注于编写高质量的代码,并将时间花在解决你游戏特有的问题上,而不是已经存在解决方案的常见编程问题上。老话“不要重复造轮子”同样适用于编程。可以说,你现在不仅需要像写代码的人一样思考,还需要像游戏开发者或软件工程师一样思考。
知道如何编程与知道一门语言非常相似。使用一门语言进行对话是一回事,但如果你试图创作小说或写诗,那就完全不同了。以同样的方式,当程序员在他们的游戏项目中编写代码时,你需要选择在最佳时机使用语言的最佳部分。为了很好地组织你的代码以及解决反复出现的问题,你需要某些工具。这些工具,即设计模式,正是本书的主题。
章节概述
在本章中,我们将讨论设计模式的概念以及在使用它们时需要考虑的思维过程。我们还将设置我们的项目,并安装与 Mach5 引擎一起工作所需的所有必要组件,该引擎是由本书的一位作者编写的。
你的目标
本章将分为几个主题。它将包含从开始到结束的简单步骤过程。以下是我们的任务大纲:
-
什么是设计模式?
-
为什么你应该为变化做准备
-
区分“是什么”和“如何做”
-
接口简介
-
代码模块化的优势
-
在游戏中使用设计模式的问题
-
项目设置
什么是设计模式
如同在由 Erich Gamma、John Vlissides、Ralph Johnson 和 Richard Helm 所著的著名书籍《设计模式:可复用面向对象软件元素》中所记录的那样,这些设计模式被称为四人帮(简称GoF),是针对常见编程问题的解决方案。不仅如此,它们是在开发者试图从他们的代码中获得更多灵活性和复用性时设计和重新设计的解决方案。你不需要阅读四人帮的书籍就能理解这本书,但在阅读完毕后,你可能希望阅读或重读那本书以获得额外的见解。
正如《设计模式:可复用面向对象软件的基础》这本书名所暗示的,设计模式是可复用的,这意味着实现的解决方案可以在同一个项目中重复使用,或者在一个全新的项目中使用。作为程序员,我们希望尽可能高效。我们不希望一遍又一遍地写相同的代码,也不应该希望花费时间解决已经有答案的问题。遵循的一个重要编程原则是DRY原则,即不要重复自己。通过使用和复用设计模式,我们可以防止未来可能引起问题的错误或愚蠢的错误。此外,设计模式可以通过拆分你本想组合的部分,以及使用其他开发者(希望)熟悉的解决方案,来提高你代码的可读性。
当你理解和运用设计模式时,你可以缩短与另一位开发者的讨论时间。告诉另一位程序员他们应该实现一个工厂,比进行涉及图表和白板的冗长讨论要容易得多。在最佳情况下,你们双方对设计模式都足够了解,以至于不需要讨论,因为解决方案会很明显。
尽管设计模式很重要,但它们并不是我们可以直接插入游戏的库。相反,它们位于库之上。它们是解决常见问题的方法,但实现它们的细节始终会因项目而异。然而,一旦你对模式有了良好的了解,实现它们就会变得容易,并且会感觉自然。你可以在最初设计项目时应用它们,就像使用蓝图或起点一样。你也可以在注意到旧代码变得混乱(我们称之为意大利面代码)时使用它们进行重构。无论如何,研究模式都是值得的,这样你的代码质量会提高,你的编程工具箱也会变得更庞大。
使用这个工具箱,解决问题的方法数量仅受你想象力的限制。有时,一开始就想到最佳解决方案可能会有困难。在特定情况下,知道使用最佳位置或最佳模式可能会有困难。不幸的是,如果在不适当的地方实现,设计模式可能会造成许多问题,比如无谓地增加项目的复杂性,而几乎没有收益。正如我之前提到的,软件设计类似于写诗,因为它们都是一门艺术。你所做的选择都会有优点和缺点。
这意味着为了有效地使用模式,你首先需要知道你试图在项目中解决的问题是什么。然后你必须足够了解设计模式,以便理解哪一个将帮助你。最后,你需要足够了解你正在使用的特定模式,以便能够将其适应到你的项目和你的情况中。本书的目标是提供这种深入的知识,以便你总能使用正确的工具来完成工作。
有许多设计模式,包括来自《四人帮》书籍的基础模式、架构模式等等。我们只会触及我们认为最适合游戏开发的一些模式。我们认为,提供少数几个模式的深入知识,比提供所有可能模式的入门知识要好。如果你对了解更多所有这些模式感兴趣,请随时访问en.wikipedia.org/wiki/Software_design_pattern。
为什么你应该为变化做准备
在我多年的游戏开发生涯中,一直不变的是,一个项目最终总是无法与预生产阶段想象中的 100%相同。功能会随时添加或删除,而你认为对游戏体验至关重要的东西可能会被完全不同的东西所取代。很多人可能参与游戏开发,比如制作人、游戏导演、设计师、质量保证,甚至是市场营销,所以我们永远无法确定谁、在哪里、何时会对项目进行更改。
由于我们永远无法确定会发生什么变化,因此始终编写易于修改的代码是一种良好的做法。这需要比你可能习惯的更多前瞻性规划,通常涉及绘制流程图、某种形式的伪代码,或者可能是两者兼而有之。然而,这种规划将使你更快地走得更远,比直接编码要快得多。
有时候你可能从零开始一个项目;其他时候你可能加入一个游戏团队并使用现有的框架。无论哪种方式,重要的是开始编码时要有计划。编写代码被称为软件工程,是有原因的。代码的结构通常被比作建筑或建筑。然而,让我们先从小处着手。假设你想从宜家组装一些家具。
当你购买家具时,你会收到未组装的家具和一份说明书。如果你不按照说明来开始组装,你很可能根本无法完成它。即使你最终完成了,你可能会按照错误的顺序组装东西,造成更多的工作。有一个蓝图显示每一步,这会好得多。
很不幸,制作游戏并不完全像遵循家具使用说明书。在游戏和任何类型的软件的情况下,客户的要求可能会不断变化。我们的客户可能是为我们提供更新时间表的制作人。它也可能是我们的设计师,他刚刚想到了我们必须拥有的新功能。甚至可能是我们的游戏测试员。如果他们认为游戏不好玩,我们就不应该继续制作一个糟糕的游戏。我们需要停下来,思考我们的设计,并尝试新的方法。
有一个计划和知道项目会变化似乎是对立的。如果我们不知道最终产品会是什么样子,我们怎么制定计划呢?答案是计划这种变化。这意味着以这种方式编写代码,使得对设计的更改既快又简单。我们希望编写代码,以便更改第二关的起始位置不需要我们编辑代码和重建项目的所有配置。相反,它应该像更改文本文件一样简单,或者更好的是,让设计师从工具中控制一切。
编写这样的代码需要工作和计划。我们需要考虑代码的设计,并确保它能够处理变化。通常,这种规划会涉及其他程序员。如果您在一个团队中工作,如果每个人都能理解每个类的目标以及它如何与其他类连接,那就很有帮助。制定一些标准很重要,这样其他人就可以在没有您的情况下开始或继续项目。
理解 UML 类图
软件开发者也有他们自己的蓝图形式,但它们看起来与您可能习惯的不同。为了创建这些蓝图,开发者使用一种称为统一标记语言(UML)的格式。这种简单的绘图风格最初由吉姆·鲁姆巴 ugh、格雷迪·布奇和伊瓦·雅各布森创建,由于它能与任何编程语言一起工作,因此已成为软件开发的标准。当我们需要通过图表向您展示细节或概念时,我们会使用它们。
设计模式通常最好通过类图来解释,因为您可以在保持抽象的同时演示这个想法。让我们考虑以下类:
class Enemy
{
public:
void GetHealth(void) const;
void SetHealth(int);
private:
int currentHealth;
int maxHealth;
};
转换为 UML,它看起来可能像这样:

基本的 UML 图由三个代表类及其包含的数据的框组成。上面的框是类的名称。向下看,你会看到类将拥有的属性或变量(也称为数据成员),然后在下面的框中你会看到它将拥有的函数。属性左侧的加号(+)表示它将是公共的,而减号(-)表示它将是私有的。对于函数,你会看到冒号(:)右侧的内容是函数的返回类型。它也可以包括括号,这将显示函数的输入参数。有些函数不需要它们,所以我们不需要放置它们。此外,请注意,在这种情况下,我确实为两个函数都添加了void作为返回类型,但这不是必需的。
类之间的关系
当然,那个类相当简单。在大多数程序中,我们也有多个类,并且它们可以以不同的方式相互关联。这里有一个更深入的例子,展示了类之间的关系。
继承
首先,我们有继承,它显示了类之间的 IS-A 关系。
class FlyingEnemy: public Enemy
{
public:
void Fly(void);
private:
int flySpeed;
};
当一个对象从另一个对象继承时,它将拥有父类中包含的所有方法和字段,同时也会添加它们自己的内容和功能。在这个例子中,我们有一个特殊的FlyingEnemy类,它除了具有Enemy类的所有功能外,还具有飞行的能力。
在 UML 中,这通常通过一条实线和一个空心箭头表示,看起来如下:

聚合
下一个概念是聚合,这由 HAS-A 关系表示。这是指一个类包含从程序中的其他地方获取的其他类的实例集合。这些被认为具有弱 HAS-A 关系,因为它们可以存在于类的外部。
在这个例子中,我创建了一个名为CombatEncounter的新类,它可以添加无限数量的敌人。然而,当使用聚合时,那些敌人将在CombatEncounter开始之前存在;当它结束时,它们仍然存在。通过代码来看,它可能看起来像这样:
class CombatEncounter
{
public:
void AddEnemy(Enemy* pEnemy);
private:
std::list<Enemy*> enemies;
};
在 UML 中,它看起来像这样:

组合
当使用组合时,这是一个强 HAS-A 关系,这是指一个类包含一个或多个其他类的实例。与聚合不同,这些实例不是独立创建的,而是在类的构造函数中创建,然后由其析构函数销毁。用简单的话来说,它们不能独立于整体存在。
在这个例子中,我们为Enemy类创建了一些新的属性,增加了它可以使用的一些战斗技能,就像《宝可梦》系列中那样。在这种情况下,对于每一个敌人,它将能够拥有四种技能:
class AttackSkill
{
public:
void UseAttack(void);
private:
int damage;
float cooldown;
};
class Enemy
{
public:
void GetHealth(void) const;
void SetHealth(int);
private:
int currentHealth;
int maxHealth;
AttackSkill skill1;
AttackSkill skill2;
AttackSkill skill3;
AttackSkill skill4;
};
图表中的线条看起来与聚合相似,除了菱形被填充了:

实现
最后,我们有实现,我们将在接口介绍部分讨论它。
这种沟通方式的优势在于,无论您使用什么编程语言,提出的想法都会以相同的方式工作,并且不需要特定的实现。这并不是说特定的实现没有价值,这就是为什么我们也会在代码中包含问题的实现。
关于 UML 的信息有很多,不同的人喜欢使用各种不同的格式。我发现的一个很好的指南,可能对您有所帮助,可以在cppcodetips.wordpress.com/2013/12/23/uml-class-diagram-explained-with-c-samples/找到。
分离“为什么”和“如何”
在创建游戏时,我们需要处理许多不同的系统,以便提供完整的游戏体验。我们需要有被绘制到屏幕上的对象,需要有逼真的物理效果,当它们相互碰撞时需要做出反应,需要动画,需要有游戏玩法行为,而且在这所有之上,我们还需要确保它每秒运行 60 次。
理解关注点分离
每一个这些不同的方面都是一个自身的问题,试图一次性解决所有这些问题将会非常头疼。作为一个开发者,学习的一个重要概念就是将问题分块,将它们分解成越来越简单的部分,直到它们都变得可管理。在计算机科学中,有一个被称为关注点分离的设计原则,它处理这个问题。在这个方面,一个关注点可能是指会改变程序代码的某个东西。记住这一点,我们会将这些关注点分别划分到它们各自独立的区域,尽可能减少功能上的重叠。或者,我们可以让每个区域解决一个独立的问题。
现在我们提到关注点时,它们是一个独特的功能或一个独特的区域。记住这一点,它可以是像整个类那样高级,也可以是像函数那样低级。通过将这些关注点分解成可以完全独立工作的自包含部分,我们获得了一些明显的优势。通过分离每个系统并确保它们不相互依赖,我们可以以最小的麻烦更改或扩展项目的任何部分。这个概念为我们将要讨论的几乎每一个设计模式奠定了基础。
通过有效地使用这种分离,我们可以创建出灵活、模块化且易于理解的代码。它还将使我们能够以更迭代的模式构建项目,因为每个类和函数都有其明确定义的目的。我们不必过于担心添加新功能会破坏之前编写的代码,因为依赖关系在于现有的功能类,而不是相反。这意味着我们可以轻松地通过添加诸如可下载内容(DLC)之类的功能来扩展游戏。这可能包括新的游戏类型、额外的玩家或具有独特人工智能的新敌人。最后,我们可以将已经编写的内容与引擎解耦,以便在未来的项目中使用,节省时间和开发成本。
接口简介
使用设计模式的主要特点之一是始终面向接口编程,而不是面向实现。换句话说,任何类层次结构的顶部都应该有一个抽象类或接口。
多态复习
在好莱坞,许多演员和女演员在拍摄电影时扮演许多不同的角色。他们可以是故事中的英雄,也可以是反派,或者任何其他角色,因为他们扮演着角色。无论他们得到什么角色,当他们被拍摄时,他们都在表演,即使他们具体做的事情可能相当不同。这种行为与多态的概念类似。
多态是面向对象语言的三根支柱之一(与封装和继承并列)。它来自单词poly(意为多)和morph(意为变化)。
多态是一种在继承层次结构中调用不同特定类函数的方法,尽管我们的代码只使用单一类型。这个单一类型,即基类引用,将根据派生类型以多种方式改变。继续使用好莱坞的例子,我们可以告诉一个演员扮演一个角色,根据他们被选中的角色,他们会做不同的事情。
通过在基类函数上使用virtual关键字并在派生类中重写该函数,我们可以获得从基类引用调用该派生类函数的能力。虽然一开始可能看起来有些复杂,但通过示例会变得更加清晰。例如,如果我们有以下类:
class Animal
{
public:
virtual void Speak(void) const //virtual in the base class
{
//Using the Mach 5 console print
M5DEBUG_PRINT("...\n");
}
};
我可以创建一个具有自己方法的派生类,而无需以任何方式修改基类。此外,我们还有能力在派生类中替换或重写方法,而不会影响基类。假设我想改变这个函数:
class Cat: public Animal
{
public:
void Speak(void) const //overridden in the derived class
{
M5DEBUG_PRINT("Meow\n");
}
void Purr(void) const //unrelated function
{
M5DEBUG_PRINT("*purr*\n");
}
};
class Dog: public Animal
{
public:
void Speak(void) const //overridden in the derived class
{
M5DEBUG_PRINT("Woof\n");
}
};
由于派生类可以在需要基类的地方使用,因此我们可以使用基类指针或指针数组来引用派生类,并在运行时调用正确的函数。让我们看看以下代码:
void SomeFunction(void)
{
const int SIZE = 2;
Cat cat;
Dog dog;
Animal* animals[SIZE] = {&cat, &dog};
for(int i = 0; i < SIZE; ++i)
{
animals[i]->Speak();
}
}
以下是对应代码的输出:
Meow
Woof
正如你所见,即使我们有一个基类指针数组,也会调用正确的派生类函数。如果函数没有被标记为虚拟的,或者派生类没有覆盖正确的函数,多态将不会工作。
理解接口
接口不实现任何函数,只是声明了类将支持的方法。然后,所有派生类都将进行实现。这样,开发者将有更多的自由来实现函数以适应每个实例,同时由于使用面向对象语言的本质,事物将正确工作。
接口可能只包含静态最终变量,并且可能只包含抽象方法,这意味着它们不能在类中实现。然而,我们可以有从其他接口继承的接口。在创建这些类时,我们可以实现我们想要的任意数量的接口。这使得我们能够使类变得更加多态,但通过这样做,我们同意我们将实现接口中定义的每个函数。因为实现接口的类扩展了基类,所以我们可以说它具有与该类型的 IS-A 关系。
现在,接口有一个缺点,那就是它们往往需要编写大量的代码来实现每个不同版本的需求,但在这本书的过程中,我们将讨论调整和/或解决这个问题的方法。
在 C++中,没有官方的接口概念,但你可以通过创建一个抽象类来模拟接口的行为。
这里有一个接口的简单示例及其实现:
class Enemy
{
public:
virtual ~Enemy(void) {/*Empty virtual destructor*/}
virtual void DisplayInfo(void) = 0;
virtual void Attack(void) = 0;
virtual void Move(void) = 0;
};
class FakeEnemy: public Enemy
{
public:
virtual void DisplayInfo(void)
{
M5DEBUG_PRINT("I am a FAKE enemy");
}
virtual void Attack(void)
{
M5DEBUG_PRINT("I cannot attack");
}
virtual void Move(void)
{
M5DEBUG_PRINT("I cannot move");
}
};
下面是它在 UML 中的样子:

代码模块化的优点
过程式编程(例如 C 风格)和面向对象编程之间的重要区别是封装或模块化代码的能力。我们通常认为这只是数据隐藏:使变量私有。在 C 风格程序中,函数和数据是分开的,但很难重用任何一个函数,因为它可能依赖于程序中的其他函数或其他数据。在面向对象编程中,我们允许将数据和函数组合成可重用的组件。这意味着我们可以(希望)将一个类或模块放入一个新的项目中。这也意味着,由于数据是私有的,只要接口或公共方法不改变,变量就可以很容易地更改。封装的概念很重要,但它们并没有向我们展示这个提供给我们全部的力量。
编写面向对象代码的目的是创建能够自我负责的对象。在代码中使用大量的 if/else 或 switch 语句可能是设计不良的迹象。例如,如果我有三个类需要从文本文件中读取数据,我可以选择使用 switch 语句为每个类类型读取数据,或者将文本文件传递给一个类方法,让类自己读取数据。当与继承和多态的力量结合时,这甚至更强大。
通过让类负责自身,类可以改变而不会破坏其他代码,其他代码也可以改变而不会破坏类。我们可以想象,如果整个游戏都是用主函数编写的,代码将会多么脆弱。任何添加或删除的内容都可能破坏其他代码。每当有新成员加入团队时,他们需要完全理解游戏中的每一行代码和每一个变量,才能被信任编写任何内容。
通过将代码分成函数或类,我们使代码更容易阅读、测试、调试和维护。当然,任何加入团队的人都需要理解一些代码,但如果他们正在处理游戏逻辑或文件加载,可能不需要理解所有的图形代码。
设计模式是针对常见编程问题的解决方案,足够灵活以应对变化。它们通过将代码部分隔离开来做到这一点。这不是偶然的。为了本书的目的,好的设计的定义是封装、灵活、可重用的代码。因此,这些解决方案被组织成类或类的组合,以封装代码中变化的部分。这并不令人惊讶。
Mach5 引擎的结构
在整本书中,我们将使用设计模式来解决常见的游戏编程问题。最好的方式是通过实例来演示,因此我们将研究这些问题的产生,并使用 Mach5 引擎(一个由Matt Casanova设计的 C++ 2D 游戏引擎)来实现解决方案。通过查看游戏的全源代码,我们将能够看到许多模式是如何协同工作以创建强大且易于使用的系统的。
然而,在我们深入探讨模式之前,我们应该花一点时间解释一下引擎的结构。你不需要理解每一行源代码,但理解一些核心引擎组件及其使用方式是很重要的。这样我们才能更好地理解我们将面临的问题以及解决方案是如何相互配合的。

当查看图表时,一开始可能会有些混乱,所以让我们分别检查引擎的每一部分。
Mach5 核心引擎和系统
这些天来,引擎的含义变得有些模糊。当人们谈论引擎时,他们通常指的是像 Unreal 或 Unity 这样的完整游戏创建工具。虽然这些也是引擎,但这个术语并不总是需要工具。像 Id Software 的 Quake 引擎或 Valve Corporation 的 Source 引擎这样的游戏引擎是独立于工具存在的,尽管后者确实包括用于创建关卡的工具 Hammer Editor。
引擎这个术语也被用来指代更大代码库中的组件。这包括渲染引擎、音频引擎或物理引擎等。这些甚至可以完全独立于更大的代码库创建。Orge 3D 是一个开源的 3D 图形引擎,而 Havok Physics 引擎是由 Havok 公司创建的专有软件,被许多游戏使用。
因此,当我们谈论 Mach5 引擎的引擎或系统时,我们实际上是在指执行特定任务的代码集合。
应用程序
M5App 或应用程序层是一个负责与操作系统交互的类。由于我们试图编写干净、可重用的代码,因此我们不应该将游戏代码与任何操作系统功能调用混合。如果我们这样做,我们的游戏将难以移植到另一个系统。M5App 类在 WinMain 中创建,负责创建和销毁其他每个系统。任何时候我们的游戏需要与操作系统交互,包括更改分辨率、切换到全屏或从设备获取输入时,我们都会使用 M5App 类。在我们的情况下,我们将使用的操作系统将是 Windows。
阶段管理器
M5StageManager 类负责控制每个阶段的逻辑。我们考虑的主要菜单、信用屏幕、选项菜单、加载屏幕和可玩关卡都被视为阶段。它们包含控制游戏流程的行为。阶段行为的例子包括从文件中读取游戏对象数据、在特定时间间隔后生成单位,或者在菜单和关卡之间切换。
StageManager 确实不是一个标准化的名称。在其他引擎中,这段代码可能被称为游戏逻辑引擎;然而,我们的大部分游戏逻辑将被分离成组件,所以这个名字不太合适。无论叫什么名字,这个类都将控制需要为当前阶段创建哪些对象,以及何时切换到下一个阶段或完全退出游戏。
尽管这个名字使用的是 manager 而不是 engine,但它仍然是游戏的核心系统之一。这个类控制着主游戏循环并管理用户关卡集合。为了制作游戏,用户必须从基类 M5Stage 派生至少一个类,并重载虚拟函数以实现他们的游戏逻辑。
对象管理器
M5ObjectManager负责创建、销毁、更新和搜索游戏对象。游戏对象是游戏中可见或不可见的任何东西。这可能包括玩家、子弹、敌人和触发器——在游戏中,当与之碰撞时会导致事件的不可见区域。派生的M5Stage类将使用M5ObjectManager来创建适合该阶段的对象。它们还可以搜索特定的游戏对象以更新游戏逻辑。例如,一个阶段可能会搜索玩家对象。如果没有找到,则管理器将切换到游戏结束阶段。
如前图所示,我们的游戏将使用组件。这意味着M5ObjectManager也将负责创建这些组件。
图形引擎
这本书不是关于创建图形引擎的,但我们确实需要一个来绘制到屏幕上。类似于M5App类封装了重要的操作系统功能调用,我们的M5Gfx类封装了我们的图形 API。我们希望确保任何 API 调用和我们的游戏逻辑之间有一个清晰的分离。这对于我们可以将我们的游戏移植到另一个系统非常重要。例如,我们可能希望为 PC、XBox One 和 PlayStation 4 开发我们的游戏。这意味着我们需要支持多个图形 API,因为并非所有平台都提供单个 API。如果我们的游戏逻辑包含 API 代码,那么这些文件将需要为每个平台进行修改。
我们不会深入探讨如何实现完整的图形引擎的细节,但我们将概述图形的工作原理。将此视为图形引擎世界的入门指南。
此类允许我们操作和绘制纹理,以及控制游戏摄像机和找到世界的可见范围。M5Gfx 还管理两个图形组件数组,一个用于世界空间,一个用于屏幕空间。屏幕空间组件最常用的用途是创建用户界面(UI)元素,如按钮。
工具和实用程序
除了游戏的核心引擎和系统之外,每个引擎都应该提供一些基本工具和支持代码。Mach5 引擎包括几个工具类别:
-
调试工具:这包括调试断言、消息窗口以及创建调试控制台
-
随机数生成器:从最小/最大值创建随机
int或float的辅助函数 -
数学:这包括 2D 向量和 4 x 4 矩阵,以及一些更通用的数学辅助函数
-
文件输入输出:支持读取和写入
.ini文件
在游戏中使用设计模式的问题
不幸的是,使用设计模式的方式也可能带来一些问题。常言道,执行最快的代码是那些从未被调用的代码,而使用设计模式通常需要你在项目中添加比其他方式更多的代码。这将产生性能成本,因为每次使用引擎的某个部分时,可能需要进行更多的计算。
例如,使用某些原则可能会导致你编写的某些类变得极其臃肿,充满了额外的代码。设计模式是向项目中添加的另一种复杂性。如果问题本身很简单,那么在直接实现设计模式之前,先关注简单的解决方案会是一个更好的主意,仅仅因为你听说过它。
有时候遵循简单的K.I.S.S规则会更好。并记住,掌握模式的知识比使用模式本身更有价值。
设置项目
现在我们已经很好地理解了为什么我们要使用设计模式,让我们设置我们将在这本书中使用的游戏引擎:Mach5 引擎。为了开始,我们需要下载引擎以及运行项目所需的软件。执行以下步骤:
- 打开您选择的网页浏览器并访问以下网站:
beta.visualstudio.com/downloads/。一旦到达那里,将鼠标移到左侧的 Visual Studio Community 版本,然后点击免费下载选项,如图所示:

- 如果出现一个窗口询问如何处理文件,请继续打开它或保存并点击运行按钮打开它:

- 从那里,等待安装程序弹出,然后选择自定义,然后点击下一步开始下载程序:

- 现在你一旦到达功能部分,取消选择所有已选选项,然后打开编程语言选项卡并勾选 Visual C++。你可以继续移除其他选项,因为我们不会使用它们。然后点击下一步按钮,然后安装,并允许它更改您的计算机:

在这一点上,你可能需要等待一段时间,所以请去喝杯咖啡,一旦完成,你需要重新启动计算机。之后,请继续进行项目。
- 安装完成后,接下来你需要实际安装引擎本身。考虑到这一点,转到
github.com/mattCasanova/Mach5,并从那里点击克隆或下载部分,然后点击下载 ZIP:

-
下载完成后,请将文件解压缩到您选择的文件夹中;然后打开
Mach5-master\EngineTest文件夹,双击EngineTest.sln文件,并启动 Visual Studio。 -
你可能会看到一个登录界面要求你登录;请继续并注册或点击屏幕底部的“现在不,稍后再说”选项。然后你可以选择一个颜色主题;然后点击开始 Visual Studio。
-
启动时,你可能会收到一个安全警告,询问你是否仍然想要打开此项目。这会从任何不在你机器上创建的 Visual Studio 解决方案中显示出来,所以它想要确保你知道它从哪里来,但在这个情况下,项目是完全安全的。请继续取消选中“在此解决方案中的每个项目都询问我”选项,然后选择“确定”:

- 一旦加载完成,你应该最终看到 Visual Studio 界面,它应该看起来像这样:

Visual Studio 是一个非常强大的工具,对于开发者来说,学习它所有的功能非常有用。我们将在使用它们时讨论这些功能,但本书不应被视为关于 Visual Studio 的终极指南。
如果你想要了解更多关于 Visual Studio 界面的信息,请查看:msdn.microsoft.com/en-us/library/jj620919.aspxa。
- 引擎是为 32 位处理器构建的,所以将 x64 下拉菜单更改为 x86,然后点击播放按钮或按 F5。然后它会询问你是否希望重新构建项目。请继续说“是”。如果一切顺利,你应该最终看到一个调试窗口和一个游戏窗口。几秒钟后,它应该过渡到一个简单的默认项目:

你可以通过使用 W、A 和 D 键来移动角色,使用 Spacebar 向敌人射击子弹。一旦你玩够了,就按 Esc 键进入菜单,然后点击退出按钮离开项目并返回到编辑器!
摘要
就这样!在本章中,你了解了一些关于设计模式的基本知识,并且已经在你的电脑上运行了 Mach5 引擎。
具体来说,我们了解到设计模式是针对常见编程问题的解决方案。有很多理由我们应该使用它们,但为了有效地使用模式,你首先需要知道你正在尝试解决什么问题,以及哪些模式可以在那种情况下帮助你,这正是本书旨在教给你的内容。
我们了解到游戏开发总是不断变化,以及有一个计划以及一个能够支持这些变化的架构是多么重要。考虑到这一点,我们学习了在创建我们的架构时将要用到的各种编码方面。
我们深入学习了关注点分离原则及其重要性,了解如何将“是什么”和“怎么做”分开;使它们不相互依赖,这样我们就可以以最小的麻烦更改或扩展项目的任何部分。之后,我们探讨了接口是什么以及它们如何有助于为我们提供一个可以在此基础上构建的基础。后来,我们深入研究了 Mach5 引擎,看到了一个关于模块化代码如何工作的例子,以及它的优势。我们还看到了在游戏中使用设计模式可以是一件好事,以及它们所存在的问题。
最后,我们亲自下载了 Mach5 引擎并确保它能够正确工作。接下来,在下一章中,我们将处理我们的第一个设计模式——单例模式,并看看它如何对我们有用!
第二章:一统天下的单例 - 单例模式
现在我们已经了解了什么是设计模式,以及为什么我们想要使用它们,让我们首先谈谈大多数人学习的设计模式,即单例模式。
单例模式可能是最著名的模式,也是最常被误用的模式。它确实围绕它有很多争议,所以在讨论这个模式时,了解何时不应用它同样重要(甚至更重要)。
章节概述
在本章中,我们将解释关于这种模式的优缺点,以及为什么 Mach5 引擎中的核心系统,如图形引擎和对象管理器,被用作单例。最后,我们将解释在 C++中实现这一点的多种不同方法,以及每种选择的优缺点。
你的目标
本章将分为多个主题。它将包含一个从开始到结束的简单分步过程。以下是我们的任务大纲:
-
类访问修饰符概述
-
全局访问的优缺点
-
理解
static关键字 -
什么是单例?
-
学习模板
-
单例的模板化
-
单一实例的优点和缺点
-
单例模式的应用:
Application类 -
设计决策
类访问修饰符概述
在使用面向对象编程语言时,最重要的特性之一是能够隐藏数据,通过使用public、private和protected等访问修饰符,我们可以具体指定数据或函数如何被其他类访问:
class ModifierExamples
{
public int publicInteger;
private void PrivateMethod() {}
protected float protectedNumber;
};
一个类可以有无限数量的变量或函数,可以是public、private或protected,甚至可以控制对类中整个部分的访问:
class MoreModifierExamples
{
public:
// until we reach another tag, all variables and functions
// will be public
int publicIntegar;
int anotherExample;
private:
// Now, they'll be private
void PrivateFunction() {}
double safeValue;
protected:
// And now... protected
float protectedNumber;
int AlsoProtected() { return 0; }
};
当你在带有访问修饰符名称的标签部分旁边放置一个冒号:,直到出现另一个部分标签,所有列出的类部分都将使用该特定的一个。
当我们使用public访问修饰符时,我们表示这个变量或函数可以在我们的程序中的任何地方使用或访问,甚至可以在我们创建的类之外。在函数或类之外声明变量,或将变量标记为public和static,通常被称为全局变量。我们将在下一节讨论全局变量,但就目前而言,让我们也过一下其他访问修饰符。
当使用private时,我们限制我们的变量或函数的使用,只允许在类内部或从friend函数中使用。默认情况下,类中的所有变量和函数都是private的。
想了解更多关于friend函数的信息,请查看en.cppreference.com/w/cpp/language/friend。
第三种类型,protected,与private类型相同,只不过它仍然可以被子类(或派生类)访问。这在使用继承时非常有用,这样你仍然可以访问那些变量和/或函数。
静态关键字
在深入研究单例模式之前,了解static关键字的意义是很重要的,因为当我们构建这个模式时,我们将使用其功能。当我们使用static关键字时,它将在三个主要上下文中使用:
-
函数内部
-
在类定义内部
-
在具有多个文件的程序中的全局变量前
函数内的静态关键字
第一个,在函数内部使用,基本上意味着一旦变量被初始化,它将保留在计算机的内存中,直到程序结束,保持它在多次函数运行中的值。一个简单的例子可能如下所示:
#include <string>
class StaticExamples
{
public:
void InFunction()
{
static int enemyCount = 0;
// Increase the value of enemyCount
enemyCount += 10;
std::string toDisplay = "\n Value of enemyCount: " +
std::to_string(enemyCount);
printf(toDisplay.c_str());
}
};
如果我们调用它,看起来会像以下这样:
StaticExamples se;
se.InFunction();
se.InFunction();
当我们调用它时,以下内容会被显示:

如你所见,值继续存在,我们可以根据需要访问和/或修改其内容。这可以用于许多事情,比如可能需要知道上一次调用此函数时发生了什么,或者存储任何类型的数据。还值得注意的是,静态变量被类的所有实例共享,因此,如果我们有两个类型为StaticExamples的变量,它们都会显示相同的enemyCount。我们将利用这样一个事实,即如果以这种方式创建一个对象,它将始终在本章的后续部分可用。
类定义中的静态关键字
第二种方式是将类中的变量或函数定义为static。通常情况下,当你创建一个类的实例时,编译器必须为类中包含的每个变量在连续的内存块中预留额外的内存。当我们声明某个变量为static时,而不是创建一个新的变量来存储数据,一个变量被所有类的实例共享。此外,由于它是所有副本共享的,因此你不需要类的实例就可以调用它。请看以下加粗的代码来创建我们的变量:
class StaticExamples
{
public:
static float classVariable; static void StaticFunction()
{
// Note, can only use static variables and functions within
// static function
std::string toDisplay = "\n I can be called anywhere! classVariable value: " + std::to_string(classVariable);
printf(toDisplay.c_str());
}
void InFunction()
{
static int enemyCount = 0;
// Increase the value of enemyCount
enemyCount += 10;
std::string toDisplay = "\n Value of enemyCount: " +
std::to_string(enemyCount);
printf(toDisplay.c_str());
}
};
现在,在先前的代码中我们定义了一个变量和一个函数,但这并不是我们需要做的所有准备工作。当创建静态变量时,你无法在类内部初始化它,而需要在.cpp文件中初始化,而不是我们可以用于类定义的.h文件。如果你不初始化它,你会得到错误,所以这是一个好主意。在我们的例子中,它看起来会像以下这样:
// StaticExamples.cpp
float StaticExamples::classVariable = 2.5f;
注意,当我们初始化时,我们还需要包括类型,但我们使用ClassName::variableName模板,类似于你在.cpp文件中定义函数的方式。现在一切都已经设置好了,让我们看看我们如何在正常代码中访问它们:
StaticExamples::StaticFunction();
StaticExamples::classVariable = 5;
StaticExamples::StaticFunction();
注意,我们不需要通过创建一个变量来访问它,而是可以直接使用类名后跟作用域运算符(::),然后选择我们想要使用的静态变量或函数。当我们运行它时,它看起来会是这样:

如你所见,它工作得非常完美!
作为文件全局变量的静态
如你所知,C++是一种与 C 编程语言紧密相关的编程语言。C++被设计成具有与 C 相同的大部分功能,并在此基础上添加了更多功能。C 不是面向对象的,因此当它创建static关键字时,它被用来指示项目中的其他文件(作为项目的一部分)不能访问该变量,而只有文件内部的代码可以使用它。这是为了在 C 中创建类似类的行为。由于 C++中有类,我们通常不使用它,但我认为为了完整性,我应该提到这一点。
全局变量的优缺点
再次强调,全局变量是在函数或类外部声明的变量。这样做使得我们的变量在所有函数中都是可访问的,因此我们称之为全局变量。在学校学习编程时,我们经常被告知全局变量是坏事,或者至少,在函数中修改全局变量被认为是不良的编程实践。
使用全局变量有很多原因是不好的:
-
当使用的元素作用域有限时,源代码最容易理解。在程序中添加可以在任何地方读取或修改的全局变量会使跟踪事物所在位置变得更加困难,同时也会使新开发者理解起来更加困难。
-
由于全局变量可以在任何地方被修改,我们失去了对确认变量中包含的数据是否有效的任何控制。例如,你可能只想支持一定数量的值,但作为一个全局变量,这是不可能阻止的。通常,我们建议出于这个原因使用
getter/setter函数。 -
使用全局变量使得我们的程序耦合度更高,这使得在我们需要从很多不同地方获取信息以使事物正常工作时,很难重用项目中的某些方面。将相互关联的事物分组通常可以改善项目。
-
当与链接器一起工作时,如果你的全局变量名称常见,在编译项目时你经常会遇到问题。幸运的是,你会得到一个错误,并需要修复这个问题。不幸的是,你也可能遇到一个问题,即你试图在一个项目中使用局部作用域的变量,但由于拼写错误或过度依赖智能提示而选择了全局版本,我看到学生在多次场合这样做。
-
随着项目规模的扩大,维护和/或对全局变量进行更改变得更加困难,因为你可能需要修改代码的许多部分才能正确调整。
这并不是说全局访问完全没有坏处。有一些原因会让人们考虑在他们的项目中使用它:
-
不了解局部变量的概念
-
不了解如何创建类
-
想要节省按键次数
-
不想总是将变量传递给函数
-
不知如何声明变量,所以将其设置为全局变量意味着任何人都可以访问它
-
为了简化项目中需要任何地方都可以访问的组件
除了最后一点,那些问题真的是想要使用全局变量的糟糕理由,因为它们可能会在你一开始节省一些时间,但随着你的项目越来越大,阅读代码会变得困难得多。此外,一旦你将某物设置为全局,将来将其转换为非全局将变得更加困难。想想看,与其使用全局变量,不如根据需要将参数传递给不同的函数,这样更容易理解每个函数做什么以及它需要与什么一起工作以实现其功能。
这并不是说在某个时候使用全局变量是不合理甚至是一个好主意。当全局变量代表在整个项目中真正需要可用的组件时,使用全局变量简化了项目的代码,这与我们想要实现的目标相似。
Norm Matloff 也有一篇文章解释了他认为在编写代码时有必要使用全局变量的情况。如果你想听一个不同的观点,请查看heather.cs.ucdavis.edu/~matloff/globals.html。
基本上,总是将变量限制在项目所需的最小作用域内,不要更多。这尤其在你只需要一个东西,但计划用这个对象做很多不同的事情时,会想到这一点。这就是 Singleton 设计模式的一般想法,也是为什么在继续前进之前理解其一般用法很重要的原因。
什么是 Singleton?
Singleton 模式简而言之,就是你在项目中可以访问的类,因为只有一个对象(实例)被创建(实例化)。该模式提供了一种方式,让程序员可以通过创建游戏中的一个对象的单例来全局访问类的信息。
虽然使用全局变量存在很多问题,但你可以将 Singleton 视为一种改进的全局变量,因为你不可以创建多个。考虑到这一点,Singleton 模式对于在游戏项目中只有唯一实例的类来说是一个吸引人的选择,例如你的图形管道和输入库,因为在你项目中拥有多个这些类是没有意义的。
这个单一对象使用静态变量和静态函数来能够在不通过所有代码传递的情况下访问对象。
在 Mach5 引擎中,Singleton 用于应用程序、输入、图形和物理引擎。它们还用于资源管理器、对象管理器和游戏状态管理器。我们将在本章后面更深入地研究引擎中更基础的一个,即Application类。但在我们到达那里之前,让我们深入了解我们如何实际上创建我们自己的 Singleton。
实现 Singleton 模式或获得类似 Singleton 的行为有多种方式。在介绍我们最终的版本之前,我们将讨论一些常见的版本及其优缺点,这个最终版本是 Mach5 引擎使用的方式。
实现 Singleton 模式功能的一种非常常见的方式可能看起来像以下这样:

通过代码,它看起来可能有点像这样:
class Singleton
{
public:
static Singleton * GetInstance()
{
// If the instance does not exist, create one
if (!instance)
{
instance = new Singleton;
}
return instance;
}
private:
static Singleton * instance;
};
在这个类中,我们有一个名为GetInstance的函数和一个名为instance的单个属性。请注意,我们在这个实例中使用指针,并且只有在实际使用时才分配内存来创建我们的 Singleton。实例属性代表我们类的唯一版本,因此它被设置为static。尽管它是私有的,但其他人无法访问其数据,除非我们给他们提供访问权限。为了提供这种访问权限,我们创建了GetInstance函数。这个函数首先检查实例是否存在,如果不存在,它将动态分配内存来创建一个,将实例设置为它,然后返回对象。
这只会在初始化时将实例正确地设置为0或nullptr时才有效,幸运的是,这是 C++中静态指针的默认行为。
在 Singleton 中保持单一性
正如我们之前提到的,单例模式最重要的部分之一是只有一个这样的对象。这导致了一些与我们所编写的原始代码相关的问题,即在使用一些简单的 C++时,其他程序员在您的团队中创建此类类的多个实例相当容易。首先也是最明显的是,他们可以创建一个Singleton变量(Singleton类型的变量),如下所示:
Singleton singleton;
此外,作为高级编程语言,C++在创建类时会尝试自动为您做一些事情,以消除一些否则可能需要手动处理的工作。其中之一是自动在类之间创建一些功能,以便您能够创建或复制自定义类的对象,我们称之为构造函数和复制构造函数。在我们的例子中,您也可以以下方式创建当前对象的副本:
Singleton instanceCopy(*(Singleton::GetInstance()));
编译器还会创建默认析构函数和赋值运算符,将数据从一个对象移动到另一个对象。
幸运的是,这是一个简单的问题。如果我们自己创建这些函数(声明一个显式版本),C++会注意到我们想要做一些特殊的事情,因此它不会创建默认的。因此,为了解决这个问题,我们只需要添加一个赋值运算符和一些私有构造函数,您可以在我们更改的粗体代码中看到这些:
class Singleton
{
public:
static Singleton * GetInstance()
{
// If the instance does not exist, create one
if (!instance)
{
instance = new Singleton;
}
return instance;
}
private:
static Singleton * instance;
// Disable usability of silently generated functions Singleton(); ~Singleton(); Singleton(const Singleton &); Singleton& operator=(const Singleton&);
};
如果您使用的是 C++ 11 或更高版本,我们还可以选择将我们不希望使用的函数标记为已删除,其外观如下:
Singleton() = delete;
~Singleton() = delete;
Singleton(const Singleton &) = delete;
Singleton& operator=(const Singleton&) = delete;
关于删除关键字(delete)的更多信息,请查看www.stroustrup.com/C++11FAQ.html#default。
另一个可能成为问题的情况是实例是一个指针。这是因为,作为一个指针,我们的用户有权限调用 delete 操作,而我们希望确保对象始终可供用户访问。为了最小化这个问题,我们可以通过将函数更改为以下形式(注意返回类型,以及我们在最后一行现在使用*instance)来将我们的指针改为引用:
static Singleton& GetInstance()
{
// If the instance does not exist, create one
if (!instance)
{
instance = new Singleton;
}
return *instance;
}
程序员习惯于使用引用作为项目中其他地方存在的对象的别名。如果他们看到类似以下内容,可能会感到惊讶:
Singleton& singleton = Singleton::GetInstance();
delete &singleton;
虽然技术上可行,但程序员不会期望在引用的地址上使用 delete。使用引用的好处是,当您在代码中需要它们时,您知道它们存在,因为它们在代码的某个地方被管理着——您不需要担心它们是如何被使用的。
正确删除我们的对象
人们也习惯于使用指针而不是引用来查找内存泄露,这也许会给我们留下一个问题,即在我们当前的代码中,我们分配了内存但并没有真正删除它。
现在,从技术上讲,我们并没有创建内存泄露。内存泄露出现在你分配数据并失去了对它的所有引用时。此外,现代操作系统会在我们的项目退出时负责释放进程的内存。
但这并不意味着这是一件好事。根据单例类使用的信息,我们可能会在某个时刻引用不再存在的东西。
要让我们的对象正确地删除自己,我们需要在游戏关闭时销毁单例。唯一的问题是,我们需要确保在确定没有人会之后使用单例时才这样做。
然而,既然我们要讨论最佳实践,那么在看到资源泄露时实际解决这一问题会更好。针对这个问题的解决方案是由斯科特·梅耶斯在他的书《More Effective C++》中提出的,他利用了编译器的一些特性,即位于函数中的静态变量将在我们程序的整个运行时间内存在。例如,让我们看看以下函数:
void SpawnEnemy()
{
static int numberOfEnemies = 0;
++numberOfEnemies;
// Spawn the enemy
}
numberOfEnemies变量是在项目中的任何代码执行之前创建和初始化的,很可能是当游戏正在加载时。然后,一旦第一次调用SpawnEnemy,它就已经被设置为0(或nullptr)。方便的是,由于对象不是动态分配的,编译器也会生成代码,以便当游戏退出时,它会自动调用我们的对象的析构函数。
考虑到这一点,我们可以将我们的单例类修改如下:
class Singleton
{
public:
static Singleton & GetInstance()
{
static Singleton instance;
return instance;
}
private:
// Disable usability of silently generated functions
Singleton();
~Singleton();
Singleton(const Singleton &);
Singleton& operator=(const Singleton&);
};
特别注意我们对GetInstance函数所做的更改以及我们类实例变量的移除。这种方法提供了自动销毁Singleton类最简单的方式,并且对于大多数用途来说效果良好。
学习模板
另一种要添加到你的编程概念工具箱中的技术,我们将在下一节中使用,是模板的概念。模板是一种让你能够创建泛型类的方法,这些类可以扩展以对不同数据类型具有相同的功能。它是抽象的另一种形式,让你能够为类定义一组基本行为,而无需知道将使用哪种类型的数据。如果你之前使用过 STL,那么你已经在使用模板,可能没有意识到。这就是为什么列表类可以包含任何类型的对象。
下面是一个简单的模板类的例子:
#include <iostream> // std::cout
template <class T>
class TemplateExample
{
public:
// Constructor
TemplateExample();
// Destructor
~TemplateExample();
// Function
T TemplatedFunction(T);
};
在这种情况下,我们创建了TemplateExample类,它有三个函数。构造函数和析构函数看起来很正常,但我有这个TemplateFunction函数,它接受一个类型为T的对象,并返回一个类型为T的对象。这个T来自我们示例代码中的第一行,即模板<class T>部分。任何有T的地方都将被替换为我们想要使用此模板的任何类。
现在,与常规函数不同,我们必须在我们的.h文件中定义模板函数,这样当我们需要使用这个模板创建对象时,它就会知道函数将做什么。此外,语法也有所不同:
template <class T> TemplateExample<T>::TemplateExample()
{
printf("\nConstructor!");
}
template <class T> TemplateExample<T>::~TemplateExample()
{
printf("\nDeconstructor!");
}
template <class T> T TemplateExample<T>::TemplatedFunction(T obj)
{
std::cout << "\nValue: " << obj;
return obj;
}
在这个例子中,我只是打印出文本以显示当调用特定功能时的显示内容,但我还想指出std::cout的使用,使用它需要在文件顶部添加#include <iostream>。
在这个例子中,我们使用标准库的cout函数,而不是我们之前使用的printf,因为cout允许我们输入obj(无论其类型如何)来显示某些内容,而默认情况下printf是无法做到这一点的。
完成之后,我们就可以在我们的项目中使用它了:
TemplateExample<int> teInt;
teInt.TemplatedFunction(5);
TemplateExample<float> teFloat;
teFloat.TemplatedFunction(2.5);
TemplateExample<std::string> teString;
teString.TemplatedFunction("Testing");
如你所见,这将创建三种不同类型的TemplateExample类对象,使用不同的类型。当我们调用TemplatedFunction函数时,它将按照我们期望的方式打印出来:

之后,当我们学习到抽象类型时,我们可以使用模板与它们一起处理任何类型的数据。就我们目前的情况而言,我们将利用这一功能来允许我们创建尽可能多的单例(Singletons)!
模板化单例
现在,假设我们的单例(Singleton)工作得正如我们所期望的那样,你可能会希望在将来创建更多的单例。你可以从头开始创建它们,但更好的做法是创建一个一致的方法,通过创建模板和继承来创建一个单一的实施方案,这样我们就可以为任何类使用它。同时,我们还可以了解创建Singleton类的一种替代方法,它看起来可能如下所示:
template <typename T>
class Singleton
{
public:
Singleton()
{
// Set our instance variable when we are created
if (instance == nullptr)
{
instance = static_cast<T*>(this);
}
else
{
// If instance already exists, we have a problem
printf("\nError: Trying to create more than one Singleton");
}
}
// Once destroyed, remove access to instance
virtual ~Singleton()
{
instance = nullptr;
}
// Get a reference to our instance
static T & GetInstance()
{
return *instance;
}
// Creates an instance of our instance
static void CreateInstance()
{
new T();
}
// Deletes the instance, needs to be called or resource leak
static void RemoveInstance()
{
delete instance;
}
private:
// Note, needs to be a declaration
static T * instance;
};
template <typename T> T * Singleton<T>::instance = nullptr;
你会注意到,大部分差异都与类本身有关。我们代码中的第一行使用了template关键字,这告诉编译器我们正在创建一个模板,而typename T告诉编译器,当我们使用这个模板创建新对象时,类型T将被替换为我们想要它基于的任何类。
我还想指出使用静态转换将我们的单例指针转换为T的使用。在代码中通常使用static_cast来逆转隐式转换。需要注意的是,static_cast不会对是否正确执行运行时检查。如果你知道你引用的是特定类型的对象,那么检查是不必要的。在我们的情况下,这是安全的,因为我们将从单例对象转换为从它派生出的类型(T)。
当然,看到这个例子被使用可能很有用,所以让我们创建一个可以作为单例使用的类的例子,比如用来管理我们游戏的高分:
class HighScoreManager : public Singleton<HighScoreManager>
{
public:
void CheckHighScore(int score);
private:
int highScore;
};
注意这里,当我们声明我们的HighScoreManager类时,我们说它继承自Singleton类,并且反过来,我们将HighScoreManager类传递给Singleton模板。这种模式被称为“好奇地重复出现的模板模式”。
关于“好奇地重复出现的模板模式”的更多信息,请查看en.wikipedia.org/wiki/Curiously_recurring_template_pattern。
在定义了类之后,让我们继续添加我们为这个类创建的函数的示例实现:
void HighScoreManager::CheckHighScore(int score)
{
std::string toDisplay;
if (highScore < score)
{
highScore = score;
toDisplay = "\nNew High Score: " + std::to_string(score);
printf(toDisplay.c_str());
}
else
{
toDisplay = "\nCurrent High Score: " + std::to_string(highScore);
printf(toDisplay.c_str());
}
}
通过使用我们类的模板化版本,我们不需要创建与上一个类相同的材料。我们只需关注这个类需要做的特定事情。在这种情况下,它是检查我们的当前最高分,并在我们打破它时将其设置为传递给我们的任何值。
当然,看到我们的代码在运行中是件好事,在这种情况下,我使用了位于 Mach5 EngineTest项目下的SpaceShooter/Stages/SplashStage.cpp中的SplashStage类。为此,我在Init函数中添加了以下加粗的行:
void SplashStage::Init(void)
{
//This code will only show in the console if it is active and you
//are in debug mode.
M5DEBUG_PRINT("This is a demo of the different things you can do\n");
M5DEBUG_PRINT("in the Mach 5 Engine. Play with the demo but you must\n");
M5DEBUG_PRINT("also inspect the code and comments.\n\n");
M5DEBUG_PRINT("If you find errors, report to lazersquad@gmail.com");
HighScoreManager::CreateInstance(); HighScoreManager::GetInstance().CheckHighScore(10); HighScoreManager::GetInstance().CheckHighScore(100); HighScoreManager::GetInstance().CheckHighScore(50);
//Create ini reader and starting vars
M5IniFile iniFile;
// etc. etc.
在这种情况下,我们的实例是通过创建一个新的HighScoreManager来创建的。如果没有这样做,那么当调用GetInstance时,我们的项目可能会崩溃,所以非常重要的一点是要调用它。然后多次调用我们的CheckHighScore函数以验证功能是否正确工作。然后,在Shutdown函数中,添加以下加粗的行以确保单例被正确删除:
void SplashStage::Shutdown(void)
{
HighScoreManager::RemoveInstance();
M5ObjectManager::DestroyAllObjects();
}
所有这些都完成之后,保存文件,并运行游戏。输出将如下所示:

如你所见,我们的代码运行正确!
注意,这与我们最初脚本版本中讨论的缺点相同,即我们必须手动创建对象并删除它;但它减少了在项目中创建多个单例时的许多繁琐工作。如果你打算在项目中创建多个,这可能是一个值得考虑的方法。
使用单个实例的优点/缺点
有可能在你继续你的项目过程中,原本看起来只需要一个实例的东西,突然变成你需要更多实例的情况。在游戏中,一个最简单的例子就是玩家。当你开始游戏时,你可能认为你只会有一个玩家,但也许后来你决定添加合作模式。根据你之前所做的,这可能对项目造成小的或巨大的变化。
最后,一旦程序员了解了单例模式,我们经常看到的一个更常见的错误是,为每一件事都创建管理器,然后让所有管理器都成为单例。
单例模式在实际中的应用 - 应用程序类
单例模式通过拥有一个特殊函数来实现其易于在任何地方访问的能力。我们使用这个函数来获取 Singleton 对象。当这个函数被调用时,我们将检查该对象是否已经被创建。如果已经创建,我们将简单地返回对该对象的引用。如果没有,我们将创建它,然后返回对新创建对象的引用。
现在,除了有这种访问方式之外,我们还想阻止用户创建它们,因此我们需要将我们的类构造函数定义为私有的。
现在我们已经了解了单例模式的一些实现方式,我们还有一个版本,这是我们实际上在 Mach5 引擎中使用的版本。
在 Mach5 中,包含的单例只有引擎代码的方面。引擎代码被设计成可以与任何游戏一起工作,这意味着它没有任何游戏特定的内容,这意味着它不需要有实例,因为它们只是指令。以这种方式构建引擎使得将来将其带到其他游戏中变得更加容易,因为它已经从任何特定于游戏的内容中分离出来。
在这种情况下,让我们打开位于 EngineTest 项目下的 Core/Singletons/App 目录中的 M5App.h 文件,看看类本身:
//! Singleton class to Control the Window
class M5App
{
public:
friend class M5StageManager;
/*Call These in Main*/
/*This must be called first, before the game is started*/
static void Init(const M5InitData& initStruct);
/*Call this after you add your stages to start the game*/
static void Update(void);
/*Call this after Update is finished*/
static void Shutdown(void);
/*Call these to control or get info about the application*/
/*Use this to change to fullscreen and back*/
static void SetFullScreen(bool fullScreen);
/*Use this to show and hide the window*/
static void ShowWindow(bool show);
/*Use this to show and hide the default window cursor*/
static void ShowCursor(bool showCursor);
/*Use this to change the resolution of the game*/
static void SetResolution(int width, int height);
/*Returns the width and height of the window (client area)*/
static M5Vec2 GetResolution(void);
private:
static LRESULT CALLBACK M5WinProc(HWND win, UINT msg, WPARAM wp, LPARAM lp);
static void ProcessMessages(void);
};//end M5APP
现在,Mach5 引擎遵循单例模式。然而,它的实现方式与之前我们所看到的有所不同。你可能注意到在类定义中,每个创建的函数和变量都被设置为静态的。
这为我们提供了一些独特的优势,即我们不需要担心用户创建类的多个版本,因为他们将仅限于使用静态属性和变量,这些属性和变量被一切共享。这意味着我们不需要担心之前提到的那些边缘情况。这可能是因为 Mach5 引擎的类不需要有子类;我们不需要创建指针,甚至不需要调用 GetInstance 函数。
您还会注意到之前提到的 Init、Update 和 Shutdown 函数。我们之前提到,手动创建和销毁我们的 singleton 类是一个缺点,但有一些明显的优势。在之前的例子中,类的创建顺序由编译器决定,因为我们无法控制顺序。然而,在我们的游戏引擎中,在启动图形库 (M5Gfx) 之前创建我们的应用程序 (M5App) 是有意义的,而我们确保这一点的唯一方法就是告诉我们的引擎这样做,您可以查看 Main.cpp 文件中的 WinMain 函数,这是我们在创建项目时首先打开的。我已经提前将 M5App 的使用加粗:
int WINAPI WinMain(HINSTANCE instance,
HINSTANCE /*prev*/,
LPSTR /*commandLine*/,
int /*show*/)
{
/*This should appear at the top of winmain to have windows find memory leaks*/
M5DEBUG_LEAK_CHECKS(-1);
M5InitData initData; /*Declare my InitStruct*/
M5GameData gameData = { 0 }; /*Create my game data initial values*/
M5IniFile iniFile; /*To load my init data from file*/
iniFile.ReadFile("GameData/InitData.ini");
iniFile.SetToSection("InitData");
/*Set up my InitStruct*/
iniFile.GetValue("width", initData.width);
iniFile.GetValue("height", initData.height);
iniFile.GetValue("framesPerSecond", initData.fps);
iniFile.GetValue("fullScreen", initData.fullScreen);
initData.title = "AstroShot";
initData.instance = instance;
/*Information about your specific gamedata */
initData.pGData = &gameData;
initData.gameDataSize = sizeof(M5GameData);
/*Pass InitStruct to Function. This function must be called first!!!*/
M5App::Init(initData);
/*Make sure to add what stage we will start in*/
M5StageManager::SetStartStage(ST_SplashStage);
/*Start running the game*/ M5App::Update(); /*This function must be called after the window has closed!!!*/ M5App::Shutdown();
return 0;
}
之后,我们可以查看 M5App 的 Init 函数,并看到它将初始化我们项目中的其他单例:
void M5App::Init(const M5InitData& initData)
{
// ...
// Other init code above...
M5StageManager::Init(initData.pGData, initData.gameDataSize, initData.fps); M5ObjectManager::Init(); M5Input::Init();
}
通过这种控制,我们的用户对事物创建的流程和顺序有了更好的了解。但当然,有了这种巨大的力量,也伴随着巨大的责任。
单例模式仅适用于单线程应用程序。如果您正在开发一个多线程游戏,您将希望使用由 Doug Schmidt 和 Tim Harrison 创建的双重检查锁定模式,您可以查看更多关于它的信息:en.wikipedia.org/wiki/Double-checked_locking。
摘要
在本章中,我们快速回顾了许多编程概念,并开始学习我们的第一个设计模式——单例,它旨在使我们始终能够访问类的函数和变量,因为将只有一个这样的对象。
我们讨论了使用单例模式的一些典型缺陷,例如,即使这种情况不太可能,对象未来可能会有多个副本。
我们学习了创建单例的三种不同方法,从 Singleton 开始,然后扩展它并对其部分进行模板化以创建奇特重复的模板模式,然后我们看到了一个最终的无缝静态版本,以最小的麻烦达到相同的效果。
这些方法各有优缺点,我们希望您能有效地使用它们,在相关的地方使用。现在我们已经触及了大家熟悉的模式设计,我们可以转向下一个挑战:学习如何处理我们每个游戏特有的逻辑。
第三章:使用组件对象模型创建灵活性
在上一章中,我们看到了单例模式如何帮助我们解决创建和使用游戏核心引擎的问题。引擎代码被设计成可以与任何游戏一起工作,这意味着它没有任何游戏特定的内容。因此,随着游戏设计的演变,我们不需要担心游戏设计的变化会破坏我们的引擎。编写图形或物理引擎代码的目标是使其尽可能可重用或与游戏无关。这意味着当你完成当前游戏后,你应该能够几乎不需要或不需要修改就使用代码在下一款游戏中。实现这一目标的方法是将引擎代码与任何与特定游戏相关的代码分离。
另一方面,游戏对象完全特定于我们的游戏。如果游戏发生变化,所有我们的对象类型都需要相应地改变。如果我们正在制作平台游戏,突然改为制作太空射击游戏,我们的图形和物理引擎代码可能不需要改变。然而,每个游戏对象和行为都会改变。虽然这可能是最极端的例子,但事实是,我们的游戏对象很可能会发生很大变化。因此,让我们看看我们如何使用模式来解决这个虽然小但非常重要的游戏问题。
章节概述
在本章中,我们将专注于创建一个足够灵活的游戏对象,以便适应我们的游戏设计的变化。我们将首先查看新程序员创建游戏对象最常见的两种方式,以及使用这些方法时出现的问题。然后我们将讨论两种可以帮助我们解决问题的设计模式。最后,我们将得出创建可重用、灵活游戏对象的解决方案。由于我们知道我们的游戏设计和游戏对象很可能会发生变化,我们将回答以下问题:
-
是否可以以可重用的方式编写游戏对象?
-
我们如何将游戏对象与核心引擎代码解耦?
-
如果我们有一个可重用的游戏对象,我们如何使其足够灵活,以便在不同的游戏中使用或适应游戏设计在开发过程中的变化?
在这个过程中,我们将讨论一些重要的设计原则,这些原则将在本书中反复出现,并帮助你编写干净和稳固的代码。
你的目标
在本章中,我们将重点关注许多重要概念,并深入探讨一些有趣的代码。其中一些概念是关于不实现游戏对象的方法。学习错误的方法往往与学习正确的方法一样重要。以下是我们将涵盖的主题和本章的任务概述:
-
为什么单体游戏对象是一个糟糕的设计
-
为什么继承层次结构缺乏灵活性
-
学习和实现策略模式和装饰者模式
-
学习和实现组件对象模型
为什么单体游戏对象是一个糟糕的设计
当你将其分解为最简单的术语时,编程就是用代码解决问题。有人有一个游戏或应用程序的想法,需要解决的问题是如何逻辑且正确地向计算机描述这个想法。在日常工作中,这些问题通常以将你今天编写的代码与之前你或另一个程序员编写的代码集成在一起的形式出现。在解决问题时,总是在简单的方式和正确的方式之间进行不断的斗争。
解决问题的简单方法意味着以尽可能快的方式解决当前问题。这种方法的例子可能包括直接编写数字或字符串字面量而不是使用命名常量,复制代码而不是编写函数或重构代码到基类,或者只是不考虑其对整个代码库的影响而编写代码。
另一方面,以正确的方式解决问题意味着思考新代码将如何与旧代码交互。这也意味着思考如果设计发生变化,新代码将如何与未来代码交互。正确的方式并不意味着对问题只有一个正确的解决方案。通常有几种可能的方法可以达到相同的结果。编程中涉及的创造力是编程如此有趣的原因之一。
经验丰富的程序员知道,从长远来看,简单的方式往往最终会变得困难。这通常是因为快速修复解决了当前问题,但没有考虑到项目演变过程中可能发生的变化。
单一的游戏对象
制作游戏对象的简单方法是拥有一个包含游戏对象所需所有数据的单个struct。这似乎是正确的,因为游戏中的所有东西都有相同的基本数据。例如,我们知道玩家和敌人都有位置、缩放和旋转。所以我们的结构将看起来像这样:
struct GameObject
{
//using vectors from the Mach 5 Engine
M5Vec2 pos;
M5Vec2 scale;
float rotation;
};
这个游戏对象在理论上工作得很好,但它太基础了。确实,我们游戏中的所有东西可能都需要位置、缩放和旋转。即使是不可见的触发区域也需要这些属性。然而,就目前而言,我们无法绘制我们的对象:我们没有生命值,也没有造成伤害的方法。所以,让我们添加一些东西,使游戏对象更加真实:
struct Object
{
//using vectors from the Mach 5 Engine
M5Vec2 pos;
M5Vec2 scale;
float rotation;
float damage;
int health;
int textureID; //for drawing
float textureCoords[4]; //for sprite animation
unsignedchar color[4]; //the color of our image
};
现在我们已经为我们游戏对象添加了一些更多基本元素。我们的大多数游戏对象类型都将拥有生命值和伤害,我们添加了一个纹理 ID,这样我们就可以绘制我们的游戏对象,还有一些纹理坐标,这样我们就可以使用精灵表进行动画。最后,我们添加了一个颜色,这样我们就可以重复使用相同的纹理,并为不同的敌人着色(想想南梦宫的吃豆人中的不同幽灵)。
目前这还不算太糟糕,但不幸的是,这只是开始。一旦我们开始制作真正的游戏而不是仅仅头脑风暴一个基本游戏对象,我们的结构成员数量就开始激增。
想象我们正在制作一个太空射击游戏。我们希望添加很多东西:
-
玩家将拥有多种类型的武器,每种武器造成的伤害量不同
-
玩家可能能够访问炸弹和导弹,每种都有弹药计数
-
导弹需要寻找目标
-
炸弹需要一个爆炸半径
-
有两个超级敌人,每个都拥有特殊能力并带有冷却时间
-
玩家和超级敌人都有使用护盾的能力
-
UI 按钮与点击它们相关的某些动作
-
我们有增加生命值和增加生命值的升级道具
-
我们需要给所有对象添加生命值计数,以考虑升级效果
-
我们应该给对象添加速度,并基于时间进行移动,而不是直接设置位置
-
我们需要添加一个游戏对象类型的枚举,以便我们可以正确更新它
现在我们来看看我们的游戏对象是什么样的:
struct GameObject
{
M5Vec2 pos;
M5Vec2 scale;
M5Vec2 vel;
float rotation;
ObjectType type; //Our object type enum
int objectID; //So the missile can target
int lives;
int shieldHealth; //For Player and SuperBomber
int health;
float playerLaserDamage;
float playerIonDamage;
float playerWaveCannonDamage;
float superRaiderDamage;
float superRaiderAbilityDamage;
float superRaiderAbilityCoolDownTime;
float superBomberDamage;
float superBomberAbilityDamage;
float superBomberAbilityCoolDownTime;
int bombCount;
float bombRadius;
int missileCount;
int missileTargetID;
int textureID; //the object image
float textureCoords[4];//for sprite animation
unsigned char color[4]; //the color of our image
Command* command; //The command to do
};
如你所见,这种创建游戏对象的基本方法扩展性并不好。我们结构体中已经有了超过 25 个成员,我们甚至还没有讨论添加能够生成或修复单位的太空站。我们只有两种 BOSS 类型,我们可以通过允许不同敌人使用不同的玩家武器,如激光或导弹,来制作几种敌人类型,但我们仍然有限制。
这种方法的重大问题是,随着游戏的扩大,我们的游戏对象也必须变得非常大。某些类型,如玩家,将使用许多这些成员,但其他类型,如 UI 按钮,只会使用一小部分。这意味着如果我们有很多游戏对象,我们很可能每个对象都在浪费大量内存。
对象行为的问题
到目前为止,我们只考虑了游戏对象有哪些成员。我们还没有考虑每个对象的行为如何更新。目前,游戏对象只是数据。因为它没有函数,所以不能自我更新。我们可以轻松地为游戏对象添加一个Update函数,但为了正确更新每种类型的对象,我们需要一个switch语句:
//Create our objects
Object gameObjects[MAX_COUNT];
//initialization code here
//...
//Update loop
for(int i = 0; i < objectInUse; ++i)
{
switch(gameObjects[i].type)
{
case OT_PLAYER:
//Update based on input
break;
case OT_SUPER_RAIDER:
//Add intercept code here
break;
case OT_SUPER_BOMBER:
//Add case code here
break;
case OT_MISSILE:
//Add find target and chase code here
break;
case OT_BOMB:
//add grow to max radius code here
break;
default:
M5DEBUG_ASSERT(true, "Incorrect Object Type");
}
}
再次强调,这种方法扩展性不好。随着我们添加更多对象类型,我们需要在switch语句中添加更多的情况。由于我们只有一个struct类型,我们需要在需要执行特定对象类型操作时,有一个switch语句。
如果我们添加行为,我们还将面临在对象中添加数据或将值硬编码到switch语句中的决策。例如,如果我们的炸弹增大尺寸,它是如何增大的?我们可以在switch语句中硬编码scale.x *= 1.1f,或者我们可以在我们的结构体中添加成员数据浮点bombScaleFactor。
最后,这种方法并不那么灵活。改变我们的设计非常困难,因为我们的代码中到处都是switch语句和公共成员。如果我们制作这样的游戏,那么几个月后我们的代码库就会变得一团糟。最糟糕的部分是,一旦游戏完成,我们就无法重用任何代码。游戏对象和所有行为都会非常特定于游戏玩法,除非我们制作续集,否则我们需要重新制作一个全新的游戏对象。
单一游戏对象的好处
值得注意的是,即使你选择这种方法,你仍然可以使你的核心引擎与游戏对象解耦。例如,在编写图形引擎时,我们不是将游戏对象作为参数传递给Draw函数,而是传递图形引擎需要的成员:
void Graphics::SetTexture(int textureID);
void Graphics::SetTextureCoords(const float* coordArray);
void Graphics::Draw(const M5Mtx44& worldMtx);
vs
void Graphics::Draw(const Object& obj);
为创建这种对象进行另一个论点是,我们知道我们的游戏对象中确切有什么。与其他方法相比,我们永远不需要将我们的对象进行类型转换或搜索对象内的属性。这些操作使代码更复杂,并略有性能成本。通过使用简单的struct,我们直接访问变量,代码更容易理解。
我们可能唯一会使用这种方法的情况是,如果我们 100%确定对象类型数量不会很大,例如,如果你正在制作一个益智游戏,唯一的游戏对象是羊和墙壁。益智游戏通常非常简单,反复使用相同的机制。在这种情况下,这是一个好的方法,因为它简单,不需要花费时间构建复杂系统。
为什么继承层次结构不灵活
玩家、敌人、导弹和医疗兵都应该从一个基类派生出来的想法对于刚开始学习面向对象编程的程序员来说非常普遍。在纸面上,如果你有一个掠夺者和超级掠夺者,一个应该继承自另一个,这是非常有道理的。我认为这源于继承的教授方式。当你刚开始学习继承时,你几乎总是会看到一张类似这样的图片:

图 3.1 - 学习编程时典型的继承图
许多入门级编程课程过于关注继承的机制,以至于忘记了如何正确地使用它。像上面的图片这样的图示很容易让人理解 ITWorker 是 Employee,而 Employee 是 Person。然而,一旦你超越了机制,就是时候学习如何正确地使用继承。这就是为什么存在关于设计模式的书籍。
继承是一种强大的工具,它允许我们通过添加特定于派生类的成员和方法来扩展类。它允许我们从通用代码开始,创建更专业的类。这解决了我们在第一部分中遇到的极端膨胀的对象结构的一个原始问题。继承允许我们从一个现有的类,如 Raider,添加更多成员来创建 SuperRaider:
//Inheritance Based Object:
class Object
{
public:
Object(void);
virtual ~Object(void);//virtual destructor is important
virtual void Update(float dt);
virtual void CollisionReaction(Object* pCollidedWith);
protected:
//We still need the basic data in all object
M5Vec2 m_pos;
M5Vec2 m_scale;
float m_rotation;
int m_textureID;
};
//Inheritance Based derived class
class Unit: public Object
{
public:
Unit(void);
virtual ~Unit(void);
virtual void Update(float dt);
virtual void CollisionReaction(Object* pCollidedWith);
protected:
M5Vec2 m_vel;//So that Units can move
float m_maxSpeed;
float m_health;
float m_damage;
};
class Enemy: public Unit
{
public:
Enemy(void);
virtual ~Enemy(void);
virtual void Update(float dt);
virtual void CollisionReaction(Object* pCollidedWith);
protected:
unsigned char m_color[4];
float m_textureCoords[4];//For animation
};

图 3.2 - 太空射击游戏继承层次结构的示例
这种层次结构在最初设计太空射击游戏时非常有意义。它允许我们将Raider类或Bomber类的细节与Player类分离。添加游戏对象很容易,因为我们可以通过扩展类来创建所需的内容。移除游戏对象也很容易,因为所有代码都包含在每个派生类中。实际上,现在我们有单独的类,每个类都可以通过类方法负责自己。这意味着我们不再需要在代码中到处使用switch语句。
最好的是,我们可以使用虚函数的力量将我们的派生类与游戏的核心引擎解耦。通过使用指向派生类实例的基类指针数组,我们的核心引擎,如图形或物理,仅与对象接口耦合,而不是与派生类,如Planet或SpawnerStation耦合。
没有继承层次结构,代码如下:
//Create our objects
Object gameObjects[MAX_COUNT];
//initialization code here
//...
for(int i = 0; i < objectsInUse; ++i)
{
switch(gameObjects[i].type)
{
case OT_PLAYER:
//Update based on input
break;
case OT_PLANET:
//Add intercept code here
break;
case OT_ENEMY_SPAWNER:
//Add case code here
break;
case OT_RAIDER:
//Add find target and chase code here
break;
case OT_BOMBER:
//Move slowly and do large damage code here
break;
default:
M5DEBUG_ASSERT(true, "Incorrect Object Type");
}
}
使用继承和多态,代码如下:
//Create our objects
Object* gameObjects[MAX_COUNT];//array of pointers
//initialization code here
//...
for(int i = 0; i < objectsInUse; ++i)
gameObjects[i]->Update(dt);
按照代码的功能而不是它的本质来组织代码
真正的区别在于 Raider 和 Bomber 吗?Raider 和 SuperRaider 有何不同?也许它们有不同的速度、不同的纹理和不同的伤害值?这些数据的变化真的需要一个新的类吗?这些其实只是不同的值,而不是不同的行为。问题是,我们正在创建额外的类,因为 Raider 和 SuperRaider 的概念是不同的,但它们的行为并没有差异。
我们的实际类层次结构违反了我教授的三个原则,其中两个是从四人帮的书中学到的:
“保持你的继承树浅”
“优先使用对象组合而不是类继承” —— 四人帮,第 20 页
“考虑在设计中的哪些部分应该是可变的。这种方法与关注重新设计的起因相反。与其考虑什么可能迫使设计发生变化,不如考虑你希望在无需重新设计的情况下能够改变的内容。这里的重点是封装变化的概念,这是许多设计模式的主题” —— 四人帮,第 29 页
表述第三原则的另一种方式如下:
“找出变化的部分并将其封装”
这些原则旨在消除或完全避免在使用继承时可能和将会出现的问题。
我们当前设计的问题在于,如果我们为每种对象类型创建一个新的类,我们最终会得到很多小类,它们大部分是相同的。掠夺者、超级掠夺者、轰炸机和超级轰炸机大部分相同,只有一些细微的差异,其中一些只是float和int值的差异。虽然这种方法可能看起来比“简单方法”有所改进,但它成为一个问题,因为我们将在许多类中反复编写相同的行为代码。如果我们有很多敌人,我们可能会在每一个Update函数中编写相同的ChasePlayerAI基本代码。唯一的解决方案是将ChasePlayerAI移动到基类中。
让我们再次看看我们的太空射击层次结构,但这次,让我们在我们的类中添加一些不同的行为:

图 3.3 - 在我们的对象中添加行为之后(参考图形包)
我们已经决定,我们的基object类至少应该是可绘制的,以使事情简单。如果一个像触发区域这样的对象需要不可见,我们只需通过在可绘制行为中放置一个bool来简单地支持禁用渲染,这样它就不会被绘制。然而,使用这种游戏对象方法,我仍然有一些重复的代码。Raider类和AttackStation类都有一些针对玩家进行瞄准和射击子弹的 AI。我们只重复了一次代码,所以可能不是什么大问题。
不幸的是,所有的游戏设计都会发生变化。当我们的设计师想要在我们的游戏中添加小行星时会发生什么?从技术上讲,它们是结构,因此需要从那个类继承一些数据,但它们也会移动。我们的设计师也非常喜欢SpawnerStation类,并希望将那种能力添加到一个新的SpawnerPlanet类和一个新的BossSpawner类中。我们应该再次重写代码两次,还是将代码重构到基类中?我们的设计师还希望赋予Station类在区域内缓慢巡逻的能力。这意味着Station类也需要巡逻 AI 能力。现在让我们看看我们的层次结构:

图 3.4 - 在将重复的代码重构到基类之后(参考图形包)
结果表明,这种方法并不像最初看起来那样灵活。为了使我们的设计真正灵活,几乎所有的行为都需要被分解到基类中。最终,我们并没有比用“简单方法”编写我们的游戏对象时好多少。而且,我们的设计师仍然可能想要创建一个追逐玩家的RepairHelper,这意味着所有东西都将位于基类中。
这可能听起来像是一个人为的例子,但请记住,游戏开发可能需要数年,并且很可能会发生变化。DMA Design 的《侠盗猎车手》最初被命名为《Race'n'Chase》,但后来因为一个错误导致警察试图将玩家赶下马路而不是将其拦下。这最终变得更有趣。另一个例子是 Blizzard 的第一人称射击游戏《Overwatch》,它最初作为一款大型多人在线游戏开发了 7 年。
面向对象编程的目的是认识到设计会发生变化,并考虑到这种变化来编写代码。
我们使用继承方法时的另一个问题是,在运行时添加或删除能力并不容易。假设我们的游戏有一个特殊升级物品,可以让玩家使用护盾 1 分钟。护盾将在 1 分钟内吸收玩家受到的 50%的伤害,然后自行移除。我们现在的问题是确保当子弹与护盾碰撞时,它将部分伤害转移到玩家身上。护盾不仅负责自己,还负责玩家对象。
这种相同的情况存在于所有将在一段时间内影响另一个游戏对象的事物中。想象一下,如果我们想让我们的掠夺者能够在 5 秒内对玩家造成酸伤害。我们需要一种方法将这种酸伤害附加到玩家身上,并在 5 秒后记住移除它。我们可以在Player类中添加新的变量,例如bool hasAcid和float acidTime,这样我们就可以知道在这个帧上是否应该造成酸伤害。然而,这仍然不是一个灵活的解决方案,因为每种新的随时间造成的伤害类型都需要这样的新变量。
此外,如果三个敌人用酸伤害攻击玩家,就没有办法堆叠酸伤害效果。如果我们喜欢这种能力,并希望玩家使用它,我们还需要给所有游戏对象这些额外的基于时间的伤害变量和行为代码。我们真正想做的就是在运行时将酸行为(或任何效果)附加到游戏对象上,并在效果结束时自动将其移除。我们将在本章后面讨论如何做到这一点,但首先我们需要讨论与 C++中的继承层次结构相关的一个更多问题。
避免死亡钻石
我们使用继承方法时遇到的最終問題涉及我们将代码重用推向極端的情况。在我们的层次结构中,我们有SuperRaider,它非常快,很弱,并且射击小子弹。我们还有SuperBomber,它很慢,很强,并且射击大炸弹。有一天,一个聪明的設計師會想要創建一個非常快、很强,并且能夠射擊小子和大炸彈的SuperBomberRaider。以下是我们的部分樹:

图 3.5 - 死亡钻石的示例
当然,这被称为死亡钻石(或可怕的死亡钻石),之所以这样命名是因为继承树形成了一个钻石形状。问题是我们的SuperBomberRaider同时继承自SuperBomber和SuperRaider。这两个类各自继承自Enemy、Unit和object。这意味着SuperBomberRaider将会有两份m_pos、m_scale、m_rotation以及object、Unit和Enemy的每一个成员。
Object、Unit和Enemy中包含的任何函数也都会有两份副本。这意味着我们需要指定我们希望使用的函数版本。这听起来可能不错,因为我们从两个类中获得了行为,但请记住,单个基类函数只会修改它们自己的变量版本。在调用SuperRaider::Update和SuperBomber::Update之后,我们现在需要确定在绘制我们的对象时我们想要使用哪个版本的m_pos(以及m_scale和m_rotation)。
C++有解决这个问题的方法,但大多数程序员都认为这种解决方案使得事情更难以理解,也更难以使用。一般来说,我们应该避免使用多重继承。我们已经看到了它可能引起的一些问题,而且我们甚至还没有讨论在这种情况下使用new和delete可能引起的错误。
策略模式和装饰者模式
我们看到,在尝试使我们的游戏对象更加灵活的过程中,很多行为都被分解到了基类中。我们也说过,在运行时附加行为并在我们完成时将其分离会很好。
实际上,有两种设计模式有可能帮助我们进行设计,即策略模式和装饰者模式。策略模式主要关于封装一系列行为而不是继承。装饰者模式主要关于根据需要动态地添加责任。
策略模式解释
策略模式是关于封装一系列行为,并通过接口让客户端控制行为,而不是将行为硬编码到客户端函数本身中。这意味着我们希望游戏对象完全独立于它所使用的行为。想象一下,如果我们想给每个敌人分配不同的攻击和飞行 AI,我们可以使用策略模式而不是创建继承树:
class Enemy: public Unit
{
public:
Enemy(void);
virtual ~Enemy(void);
virtual void Update(float dt);
virtual void CollisionReaction(Object* pCollidedWith);
protected:
unsigned char m_color[4];
FlightAI* m_flight;
AttackAI* m_attack;
};
在这种情况下,我们的客户端是Enemy类,客户端控制的接口是AttackAI和FlightAI。这比从Enemy继承要好得多,因为我们只封装了变化的部分:行为。这种模式允许我们创建任意数量的FlightAI派生类,并将它们重用来创建不同种类的游戏对象类型,而无需扩展我们的继承树。由于我们可以混合不同的策略组合,我们可以得到大量不同的整体行为。
我们将共享单位和结构相同的策略,因此我们应该完全删除我们的继承树,只使用Object作为我们的客户端。这样,Object类就变成了策略的集合,我们的设计更简单。此外,我们遵循了一些优秀的编程原则:
-
面向接口编程意味着我们的客户端依赖于抽象类中的行为,而不是在客户端本身放置行为。
-
我们的开界面对扩展是开放的,这样我们就可以轻松地添加我们需要的任何行为。接口很简单,因此不需要更改,这可能会破坏代码。
-
我们的继承树很浅,所以我们不需要担心死亡钻石问题。

图 3.6 - 使用策略模式的我们的对象示例
策略模式允许我们的游戏对象非常灵活,而无需继承树。通过前面图中显示的这六个小类,我们可以拥有总共九种不同的游戏对象行为。如果我们添加一个新的FlightAI,我们就有 12 种可能的游戏对象行为。创建全新的策略允许有大量的混合行为。然而,如果我们只扩展两种策略,我们根本不需要修改对象。这对玩家也适用,如果我们创建一个AttackAI和一个FlightAI,它们可以访问输入。
只保留两种策略是不太可能的,这意味着每次我们添加一个新的策略时,我们都需要通过添加一个新成员和修改Update函数来改变对象。这意味着虽然这个模式足够灵活,可以让我们在运行时更改策略,但我们不能动态地添加行为。如果我们需要在游戏中添加酸伤害作为减益效果,我们需要一个Damage基类,并将Damage基类指针赋予object:
class Object
{
public:
//Same as before...
protected:
//Other Object Strategies
//...
Damage* m_damage.
};
这似乎不是一个很好的解决方案,因为大多数伤害都是瞬时的,而且大多数时候,玩家甚至没有受到伤害。这意味着这将要么是 null,要么是一个空的策略类,例如使用一个NoDamage派生类,它将每帧更新但不会做任何事情。这也不是堆叠腐蚀效果或让两种类型的伤害影响玩家,例如腐蚀伤害和冰伤害,这可能会让玩家移动速度变慢 10 秒的方法。我们真的需要一种动态添加和删除这些能力的方法。幸运的是,有一个模式可以做到这一点。
装饰者模式解释
装饰者模式的目的是在运行时动态地向对象添加责任。目标是提供一个灵活的替代方案来创建派生类,同时仍然允许扩展行为。这意味着我们可以将我们的object添加装饰或,在我们的情况下,在运行时添加行为。
这个模式要求 Decorator 和我们的 object 都从一个公共基类派生,这样它们就共享相同的接口。然后每个 Decorator 将在其上添加自身,以创建更有趣的对象类型和效果。当一个函数在 Decorator 上被调用时,它将调用下一层的相应函数,最终调用 object 的函数。在概念上与 俄罗斯套娃 类似,套娃内部包含越来越小的版本。最内层的最终对象总是具有核心功能的对象:

图 3.7 - 装饰者模式的层叠效果
下面是一个简化的代码版本:
class Component //Our base interface
{
public:
virtual ~Component(void) {}
virtual std::string Describe(void) const = 0;
};
class Object: public Component //Our core class to decorate
{
public:
Object(const std::string& name):m_name(name){}
virtual std::string Describe(void) const
{
return m_name;
}
private:
std::string m_name;
};
//Our base and derived Decorators
class Decorator: public Component
{
public:
Decorator(Component* comp):m_comp(comp){}
virtual ~Decorator(void) { delete m_comp; }
protected:
Component* m_comp;
};
class RocketBoosters: public Decorator
{
public:
RocketBoosters(Component* comp) : Decorator(comp) {}
virtual std::string Describe(void) const
{
return m_comp->Describe() + " with RocketBoosters";
}
};
class LaserCannons: public Decorator
{
public:
LaserCannons(Component* comp) : Decorator(comp) {}
virtual std::string Describe(void) const
{
return m_comp->Describe() + " with LaserCannons";
}
};

图 3.8 - 使用我们的对象实现的装饰者模式
//Using this code:
int main(void)
{
Component* ship = new Object("Player");
std::cout << ship->Describe() << std::endl;
delete ship;
Component* rocketShip = new RocketBoosters(new
GameObject("Enemy"));
std::cout << rocketShip->Describe() << std::endl;
delete rocketShip;
Component* laserRocketShip = new LaserCannons(new
RocketBoosters(new GameObject("Boss")));
std::cout << laserRocketShip->Describe() << std::endl;
delete laserRocketShip;
}
Decorator 类层叠我们的具体 object 类,并在 object 上添加更多信息。然而,目前我们只是在添加表面装饰。由于 Decorator 类不知道它是否有指向 object 类或另一个 Decorator 的指针,它不能修改 object。一个很好的类比是,策略模式改变对象的内部结构,而装饰者模式改变对象的皮肤。这可能很有用,但并不能帮助我们解决增益/减益问题。为了解决这个问题,我们需要添加一个方法来沿着链找到 object,或者在 Decorator 的构造函数中提供一个指向 object 的指针。
另一个问题是这个模式被设计用来动态添加 Decorator,但不允许我们移除一个。在使用腐蚀伤害 Decorator 的情况下,我们只想让它存在一段时间,然后自动断开连接。这是不可能的,因为 Decorator 没有指向其父级的指针。
游戏的最终问题是我们不能让 Decorators 孤立存在。有时,不同的游戏玩法可能需要相互交互。例如,腐蚀伤害 Decorator 可能会影响 object 的健康;然而,它可能首先需要检查 object 是否有护盾 Decorator 并从护盾中扣除健康。
不幸的是,装饰者模式和策略模式都无法完美地为我们工作。我们真正需要的是一个结合策略模式和装饰者模式的新模式,它能够做到以下事情:
-
将特定行为封装到组件中,这样我们就避免了
Object继承树 -
允许有灵活数量的组件,这样我们就不需要每次创建新的组件类型时都修改
Object -
允许我们在运行时添加和移除组件
-
使组件能够直接访问
Object以便进行修改 -
允许组件被其他组件搜索以便它们可以交互
解释组件对象模型
虽然这个替代方案可以用许多名字来称呼,但还没有一个确切的名称。在这本书中,我们将称之为组件对象模型,但其他人称之为实体组件系统或只是组件系统。无论你叫它什么,这个概念的学习非常简单,实现起来也很容易。
组件对象模型颠倒了装饰器模式的概念,其中每个Decorator都在游戏对象之上添加了一层新层。而不是分层我们的对象,我们已经看到这会带来问题,我们将装饰物放在它的内部。由于我们不知道需要多少,我们的对象将包含一个装饰物的容器,而不是一个单独的指针。在最简单的情况下,我们的对象不过是一个这些组件的容器。
如果你在网上搜索组件对象模型(或基于组件的对象模型),你将得到与我们在策略模式中看到的结果相似的结果。对象包含对每个可能策略的硬编码指针。虽然单独使用这种方法比使用单体对象或基于继承的对象要好得多,但我们仍然被困在检查空指针或不断修改我们对象中存在的策略。
在这种方法中,每种策略类型都将从一个公共接口派生。这样,我们的对象可以包含一个数组,或者在我们的情况下是一个基类Component指针的 STL 向量。这就像Decorator,除了我们的对象是一个独立的类;它不派生自Component接口。相反,一个Component将有一个指向其父对象类的指针。这解决了Decorator不知道它持有的是另一个Decorator的指针,还是实际对象的指针的问题。在这里,我们通过始终给我们的Component提供一个指向它控制的对象的指针来避免这个问题:
//Using only Strategy Pattern
class Object
{
public:
void Update(float dt);//Non virtual function to update
Strategies
//Other interface here
//...
private://Lots of different Strategies
GfxComp* m_gfx;
BehaviorComp* m_behavior;
ColliderComp* m_collider;
};
//Using Flexible Component Object Model
class Object
{
public:
void Update(float dt);//Non virtual function to update
Components
//Other interface here
//...
private:
std::vector<Component*> m_components.
};
//Our Base Component
class Component
{
public:
virtual void Update(float dt) = 0;
protected:
Object* m_obj;
};

图 3.9 - 组件对象模型
这种方法使我们非常灵活,因为我们的对象不过是一系列组件。里面没有特定于任何类型的特定内容。没有严格为玩家或 SuperRaider 编写的代码。我们可以在运行时自由添加、更改或删除任何内容。这很重要,因为在开发的早期阶段,游戏设计和游戏对象会发生变化很多。如果我们对不同的基类Strategies进行硬编码指针,我们将在游戏对象中花费大量时间更改这些指针类型。
使用组件对象模型使我们的代码几乎完全可重用。游戏对象本身只是一个空的组件容器,而且它们通常非常简单,大多数组件,如CircleCollider,都可以在任何游戏中使用。这意味着原本只为玩家或 SpawnerStation 设计的行为组件可以很容易地用于任何游戏对象。
实现组件对象模型
现在我们已经看到了代码和图示的基本版本,让我们看看 Mach5 引擎是如何实现这个系统的。正如您将看到的,所谓的 M5object 包含位置、旋转、缩放和速度。当然,这些元素可以包含在一个变换组件中;然而,这些元素非常常见,大多数其他组件都需要访问这些信息。这与纹理坐标或圆形碰撞器的半径等数据不同,这些数据可能根本不需要共享:
//Component based Game object used in the Mach 5 Engine
class M5Object
{
public:
M5Object(M5ArcheTypes type);
~M5Object(void);
//Public interface
void Update(float dt);
void AddComponent(M5Component* pComponent);
void RemoveComponent(M5Component* pComponent);
void RemoveAllComponents(void);
void RemoveAllComponents(M5ComponentTypes type);
int GetID(void) const;
M5ArcheTypes GetType(void) const;
M5Object* Clone(void) const;
template<typename T>
void GetComponent(M5ComponentTypes type, T*& pComp);
template<typename T>
void GetAllComponents(M5ComponentTypes type,
std::vector<T*>& comps);
M5Vec2 pos;
M5Vec2 scale;
M5Vec2 vel;
float rotation;
float rotationVel;
bool isDead;
private:
//Shorter name for my vector
typedef std::vector<M5Component*> ComponentVec;
//Shorter name for my iterator
typedef ComponentVec::iterator VecItor;
ComponentVec m_components;
M5ArcheTypes m_type;
int m_id;
static int s_objectIDCounter;
};
您首先会注意到,这段代码中有两个枚举,M5ArcheTypes 和 M5ComponentTypes。当我们谈到创建工厂时,这些将变得更有用。然而,现在,理解这些将允许我们在 M5objects 集合中搜索并获取所需的组件就足够了。例如,如果我们有一个 M5objects 集合,但我们需要找到玩家,M5ArcheTypes 枚举将允许我们做到这一点。
您接下来会注意到,M5object 不仅仅是一个组件的容器。它有一些公共和私有数据。公共数据不太可能需要验证或保护。我们可以创建获取器和设置器,但它们实际上只是简单地获取和设置数据,所以并不是绝对必要的。由于它们是公共的,我们将永远锁定为公共。如果您希望将它们设置为 private 并创建 accessor 方法,那也是可以的。有一些非常重要的变量我们希望是私有的。ID 和类型一旦设置就不能更改,并且组件数组通过添加、删除和清除所有组件的函数进行访问。让我们首先讨论公共变量的用途:
-
pos:M5Object的位置。这是物体的旋转中心或支点。 -
scale: 旋转前的M5Object的高度和宽度。 -
vel:M5Object的速度。这用于基于时间的移动,而不是简单地设置位置为正负某个值。 -
rotation: 以弧度为单位的旋转。正旋转是逆时针方向。 -
rotationalVel:M5Object的旋转速度,用于基于时间的旋转。 -
isDead: 这允许M5Object标记自己以供删除。其他对象或组件可以自由调用M5ObjectManager中找到的DestroyObject函数之一;然而,在对象的Update函数中间删除对象并不是一个好主意。
我们将这些作为 M5object 的一部分,因为它们非常常见,所有或几乎所有组件都需要访问它们。我们将它们标记为公共的,因为没有必要对数据进行验证或保护。
私有部分从两个类型 defs 开始。它们允许我们为模板类型创建更短的名字。这仅仅是一个风格选择。另一个风格选择是在所有私有成员变量名前加上 m_。这或类似的做法是类成员的常见做法。我们没有对公共成员这样做,因为我们更倾向于将它们视为属性。现在让我们看看其余的私有数据:
-
m_components:这是一个M5Component指针数组。向量中的每个组件都会在Update函数中被更新。 -
m_type:对象的类型。它将在构造函数中设置,并且永远不会改变。它允许用户使用M5ObjectManager根据类型搜索或删除对象。 -
m_id:这是M5Objects之间的唯一 ID。在导弹需要针对特定实例的对象的情况下可能很有用。如果导弹包含指向目标对象的指针,它就无法知道该对象是否已被销毁。如果我们知道 ID,我们可以搜索以查看目标是否仍然存在。 -
s_objectIDCounter:这是所有M5Objects的共享 ID 计数器。这保证了每个对象都会得到一个唯一的值,因为它们都在使用相同的共享变量。注意,这个变量被标记为s_以表示它是静态的。
这就是 object 中的所有数据。现在,让我们看看函数。
M5object 是类的构造函数。它设置了变量的起始值,以及设置类型和唯一 ID。注意,我们为向量预留了一定量的起始空间。一个游戏对象可以有它需要的任何数量的组件,但在实际游戏中,我们并不期望它们的平均数量超过几个。通过预分配,我们可以避免任何额外的 new 调用(我们无论如何都会做很多):
M5Object::M5Object(M5ArcheTypes type) :
pos(0, 0),
scale(1, 1),
vel(0, 0),
rotation(0),
rotationVel(0),
isDead(false),
m_components(),
m_type(type),
m_id(++s_objectIDCounter)
{
m_components.reserve(START_SIZE);
}
~M5object 是我们游戏对象的析构函数。在这里,我们想要确保删除游戏对象中的所有组件,因此我们使用了一个公共函数来帮助我们:
M5Object::~M5Object(void)
{
RemoveAllComponents();
}
AddComponent 将给定的组件指针添加到这个对象向量中。你将注意到,在添加组件之前,你需要首先检查确保相同的指针还没有在列表中。虽然这种情况不太可能发生,但它可能是一个难以发现的严重错误,所以进行检查是值得的。当给一个组件时,使用 M5Component 的 SetParent 方法也很重要,以确保这个对象将由组件控制:
void M5Object::AddComponent(M5Component* pToAdd)
{
//Make sure this component doesn't already exist
VecItor found = std::find(m_components.begin(),
m_components.end(), pComponent);
if (found != m_components.end())
return;
//Set this object as the parent
pComponent->SetParent(this);
m_components.push_back(pComponent);
}
Update 是 M5object 中最常用的函数。这个函数将由 M5ObjectManager 在每一帧自动调用。它用于更新每个组件,以及根据它们的速度更新位置和旋转。Update 函数的另一个重要作用是删除任何已死亡的组件。除了 RemoveAllComponents 函数外,这是删除组件的唯一地方:
void M5Object::Update(float dt)
{
int endIndex = m_components.size() - 1;
for (; endIndex >= 0; --endIndex)
{
if (m_components[endIndex]->isDead)
{
delete m_components[endIndex];
m_components[endIndex] = m_components[m_components.size()
- 1];
m_components.pop_back();
}
else
{
m_components[endIndex]->Update(dt);
}
}
//Update object data
pos.x += vel.x * dt;
pos.y += vel.y * dt;
rotation += rotationVel * dt;
}
RemoveComponent用于处理对象上有增益或减益效果的情况,你希望舞台或其他对象删除它。例如,玩家可能在使用护盾,但在被电离子伤害击中后,物理碰撞体找到护盾并立即将其移除。除了使用此方法外,简单地标记组件为已死亡并在下一个更新循环中清理它也是可以的。
这段代码遵循与AddComponent函数相似的模板。首先,我们检查组件是否存在。如果存在,我们将它与向量中的最后一个元素交换位置,然后从向量中弹出。之后,我们使用SetParent方法在删除之前将此对象作为父指针移除。这是一个小的预防措施,因为,如果存在指向此组件的另一个指针,程序将崩溃而不是产生未定义的错误:
void M5Object::RemoveComponent(M5Component* pComponent)
{
//Make the sure the instance exists in this object
VecItor end = m_components.end();
VecItor itor = std::find(m_components.begin(), end, pToRemove);
if (itor != end)
return;
(*itor)->isDead = true;
}
RemoveAllComponents是用于析构函数的辅助函数。它删除对象中的所有组件。除了析构函数外,可能没有太多用途。然而,它被公开,以便在那些罕见的情况下,你需要这种行为。此函数简单地遍历向量并删除每个组件,然后最终清空向量:
void M5Object::RemoveAllComponents(void)
{
VecItor itor = m_components.begin();
VecItor end = m_components.end();
while (itor != end)
{
delete (*itor);
++itor;
}
m_components.clear();
}
RemoveAllComponents的第二个版本会移除特定类型的所有组件。这是另一种情况,外部代码,如舞台、对象或甚至另一个组件需要移除同一类型的组件组。例如,这可以用来移除玩家身上的所有腐蚀性伤害效果。
在这段代码中,我们正在寻找正确的类型,因此不能使用std::vector::find方法。相反,我们使用一个for循环并检查每个组件的类型。如果我们找到正确的类型,我们就删除当前的一个,与末尾交换,然后弹出。由于我们在进行交换的同时继续搜索,我们必须确保再次检查当前索引是否匹配:
void M5Object::RemoveAllComponents(M5ComponentTypes type)
{
for (size_t i = 0; i < m_components.size(); ++i)
{
if (m_components[i]->GetType() == type)
m_components[i]->isDead = true;
}
}
GetComponent和GetAllComponents是辅助函数,用于在M5object中查找和转换特定的组件类型,如果它们存在。正如我之前所说的,有时组件之间的交互是必要的。在这种情况下,我们需要一种方法来搜索特定的组件并将其转换为正确的类型。这两个函数几乎相同。第一个找到正确组件类型的第一个实例并将其分配给指针参数。如果不存在,我们确保将参数设置为0。第二个找到所有正确类型的组件并将它们保存在向量参数中。这些是模板函数,因此组件可以被转换为用户提供的正确类型:
template<typename T>
void M5Object::GetComponent(M5ComponentTypes type, T*& pComp)
{
size_t size = m_components.size();
for (size_t i = 0; i < size; ++i)
{
//if we found the correct type, set and return
if (m_components[i]->GetType() == type)
{
pComp = static_cast<T*>(m_components[i]);
return;
}
}
pComp = 0;
}
template<typename T>
void GetAllComponent(M5ComponentTypes type, std::vector<T*>& comps)
{
size_t size = m_components.size();
for (size_t i = 0; i < size; ++i)
{
//if we found the correct type, add to vector
if (m_components[i]->GetType() == type)
comps.push_back(static_cast<T*>(m_components[i]));
}
}
GetID和GetType函数仅返回私有类数据。Clone方法更有趣,但当我们讨论原型模式时,我们会更详细地介绍它。
实现组件
现在你已经看到了 M5object,让我们看看 Mach5 引擎如何创建和使用组件层次结构。由于这是一个抽象类,无法创建 M5Component 的实例。它只是一个接口。
正如你所看到的,基组件包含与 M5object 相同的一些成员。由于我们将有很多组件,因此为每个组件指定一个类型很重要,这样它们就可以被搜索。为每个组件提供一个唯一的标识符也很重要。由于这些组件可以在任何时候被删除,因此保存一个 ID 而不是指针很重要,因为指针可能会变得无效:
class M5Component
{
public:
M5Component(M5ComponentTypes type);
virtual ~M5Component(void);
virtual M5Component* Clone(void) = 0;
virtual void Update(float dt)= 0;
virtual void FromFile(M5IniFile&);
void SetParent(M5Object* pParent);
M5ComponentTypes GetType(void) const;
int GetID(void) const;
//public data
bool isDead;
protected:
M5Object* m_pObj;
private:
int m_id;
M5ComponentTypes m_type;
staticint s_compIDCounter;
};
数据部分不包含像 M5object 那么多的内容,但现在它被分为三个部分,即 public、private 和 protected:
-
isDead: 这是唯一的公共数据,它起着与游戏对象中成员类似的作用。这允许组件为自己标记为删除。在组件自己的Update函数中调用RemoveComponent并不是一个好主意。 -
m_pObj: 这是一个指向拥有此组件的M5Object的指针。 -
m_id: 此组件的唯一标识符。这允许用户再次访问这个特定的组件,而不会存在保存可能变得无效的指针的风险。 -
m_type: 此组件的类型。这允许用户在游戏对象中搜索特定的组件。 -
s_compIDCounter: 这用于为每个组件创建一个唯一的标识符。
M5Component 的功能并不那么有趣,因为它们大多是虚拟的。然而,了解它们的目的还是值得的。
M5Component 是组件的非默认构造函数。它接受一个 M5ComponentTypes 类型的参数,以确保私有数据 m_type 被派生类型设置:
M5Component::M5Component(M5ComponentTypes type):
isDead(false),
m_pObj(0),
m_type(type),
m_id(++s_componentID)
{
}
~M5Component 是类的析构函数。由于这是一个基类,存在一个虚析构函数非常重要,这样在多态使用时将调用正确的方法:
M5Component::~M5Component(void)
{
//Empty Base Class virtual destructor
}
Update 是组件执行动作的地方。这个方法会在每一帧被调用,其目的是向 M5object 添加行为和/或数据。它被标记为纯虚函数 (= 0),这样基类就必须重写它。这也意味着基类版本没有函数体。
FromFile 是一个虚拟函数,允许组件从预加载的 INI 文件中读取数据。它没有被标记为纯虚函数,这意味着组件不需要重写这个函数。如果派生组件没有从文件中加载的数据,这种情况可能发生:
void M5Component::FromFile(M5IniFile&)
{
//Empty for the base class
}
SetParent 方法仅仅是 m_pObj 的设置器。回想一下 M5object 的 AddComponent 函数。当一个组件被添加到对象中时,对象会使用这个函数,这样组件就知道要控制哪个对象。
GetType 和 GetID 函数与 M5object 中的函数类似。它们允许组件可搜索和保存,而无需使用可能变得无效的指针。M5Component 还有一个纯虚的 Clone 方法。基类中没有函数体。当我们讨论原型模式时,我们将讨论 M5Component 和 M5object 的 Clone 方法。
要向对象添加行为,我们必须从 M5Component 基类派生,重载必要的函数,向 M5ComponentTypes 枚举添加一个值,然后最后将类及其关联的构建器注册到对象管理器中。当然,这些步骤容易出错,反复执行会非常繁琐。
因此,Mach5 引擎包含一个批处理文件来自动执行这些步骤。通过将组件添加到文件层次结构中的 Source 文件夹内,批处理文件将找到所有名为 *Component.h 的文件,其中星号是一个通配符字符,包括任何有效的 C++ 标识符。
例如,如果一个名为 LazerComponent 的组件位于名为 LazerComponent.h 的文件中,将自动创建一个名为 CT_LazerComponent 的枚举值,以及正确的类构建器,并且两者都将注册到 M5ObjectManager。
创建和删除对象和组件
为了使用组件对象模型,首先创建一个游戏对象,然后添加一些组件,最后将其添加到 M5ObjectManager,该管理器每帧都会调用游戏对象的更新。让我们看看创建对象和组件的代码示例。
如果我们想要创建一个在屏幕上飞行的 Player 对象,但保持在屏幕范围内,我们可以在阶段的 Init 方法中这样做:
M5Object* pObj = new M5Object(AT_Player);
GfxComponent* pGfxComp = new GfxComponent;
PlayerInputComponent* pInput = new PlayerInputComponent;
ClampComponent* pClamp = new ClampComponent;
pObj->AddComponent(pGfxComp);
pObj->AddComponent(pInput);
pObj->AddComponent(pClamp );
//Set position, rotation, scale here
//...
M5ObjectManager::AddObject(pObj);
这段代码运行良好,但存在一些问题。首先,我们没有指定想要的纹理。然而,我们可以轻松地将 textureID 或文件名作为参数添加到 GfxComponent 构造函数中。更大的问题是,这段代码编写起来很繁琐,我们不希望反复编写。如果我们要在另一个阶段创建一个玩家,它可能包含完全相同的代码。因此,更好的方法是把这个代码分解到 M5ObjectManager 中:
M5Object* M5ObjectManager::CreateObject(M5ArcheTypes type)
{
switch(type)
{
case AT_Player:
M5Object* pObj = new M5Object(AT_Player);
GfxComponent* pGfxComp = new GfxComponent;
PlayerInputComponent* pInput = new PlayerInputComponent;
ClampComponent* pClamp = new ClampComponent;
pObj->AddComponent(pGfxComp);
pObj->AddComponent(pInput);
pObj->AddComponent(pClamp );
AddObject(pObj);
//Set position, rotation, scale here
//...
return pObj;
break;
case AT_Bullet:
//...More Code here
现在,在我们的阶段 Init 函数中,我们可以简单地写下以下内容:
M5Object* pObj = M5ObjectManager::CreateObject(AT_Splash);
//Set additional data here if needed
然而,这相当硬编码。这明确地创建了玩家(以及每种类型)所需的所有组件,这意味着我们的M5ObjectManager现在包含了特定于游戏的代码。使用组件对象模型的优点在于其灵活性,但我们通过使用硬编码的switch语句而失去了一些灵活性。我们真正希望我们的设计师,而不是程序员,来选择放入玩家、掠夺者或超级掠夺者中的内容。这意味着从文件中加载我们的对象类型。在我们的情况下,我们将使用 INI 文件,因为它们易于使用且易于理解。它们由全局或标记的键/值对部分组成。以下是在Player.ini中找到的一个示例玩家原型:
posX = 0
posY = 0
velX = 0
velY = 0
scaleX = 10
scaleY = 10
rot = 0
rotVel = 0
components = GfxComponent PlayerInputComponent ClampComponent
[GfxComponent]
texture = playerShip.tga
[PlayerInputComponent]
forwardSpeed = 100
bulletSpeed = 7000
rotationSpeed = 10
注意,INI 文件的第一(全局)部分包含在M5object中找到的所有数据。由于我们知道这些变量始终存在于对象中,因此它们被放置在顶部。这包括该原型将使用的组件列表。这里我们有GfxComponent、PlayerInputComponent和ClampComponent。接下来的部分是与每个组件关联的数据,例如,对于GfxComponent,我们可以指定要加载的纹理。ClampComponent不需要加载任何数据,因此我们不需要为它添加一个部分。
将组件对象模型与单体对象或继承树进行比较,我们可以看到基于组件的方法在灵活性和可重用性方面远远超过。使用这种方法,我们可以编写尽可能多的不同组件,并让设计师选择每个对象使用的行为。最好的部分是,除了最特定于游戏的组件之外,所有内容都可以在另一款游戏中重用。
这意味着PlayerInputComponent可能无法在另一款游戏中重用,但ClampComponent和GfxComponent可以在我们制作另一款太空射击游戏、平台游戏或赛车游戏时使用。
关于用于图形和物理的组件,如GfxComponent和CircleColliderComponent的一个注意事项:它们在意义上是特殊的,因为它们需要以其他组件可能不需要的方式与核心引擎交互。例如,图形引擎可能希望根据它们是否为世界空间对象或屏幕空间对象(称为 HUD 空间,因为这些将是按钮和生命值条等东西)来组织这些组件。物理引擎可能希望使用特殊的分区数据结构来最小化需要执行的碰撞测试数量。因此,这些组件在通过对象管理器创建时自动注册到相应的核心引擎,并在它们被删除时自动注销。
性能问题
使用组件对象模型有很多好处。如今,许多引擎因为其提供的灵活性而采用这种方法。然而,这种灵活性是以性能为代价的。最大的性能成本是新/删除调用、缓存一致性以及虚方法。
我们的 M5ObjectManager 使用指向 M5objects 的指针,而 M5objects 使用指向组件的 STL 向量。这意味着当我们创建 子弹、小行星、入侵者 和 行星 时,我们不断地调用 new 和 delete。这些函数运行缓慢,有可能使我们的内存碎片化。在后面的章节中,我们将看到对象池如何帮助我们解决这两个问题。
然而,即使有对象池,我们仍然会遇到缓存未命中问题。事实上,遍历连续数据数组的速度比遍历指向数据指针的数组要快得多。当使用组件对象模型时,CPU 将花费更多的时间追踪指针并将数据加载到缓存中,如果我们只是使用数组的话。不幸的是,这是我们为了灵活性所付出的代价。根据游戏的不同,这可能会导致问题,也可能不会。
虚方法也是潜在性能问题的来源,因为必须始终在运行时查找要调用的函数,并且编译器无法内联它们。同样,这也是我们为了灵活性所付出的代价。我们有一种方法允许我们的设计师从文件中加载行为并在运行时更改该行为。在我看来,这至少在开发周期的开始阶段,超过了性能问题。
你可能听说过“过早优化是万恶之源”。更重要的是,要专注于制作一个有趣的游戏,并在以后解决性能问题。你总有在开发周期后期将特定行为或数据硬编码到游戏对象中的选项。如果可能的话,你可以在后期优化阶段合并两个或更多总是一起使用的组件。然而,通过早期限制你的灵活性,你可能永远发现不到来自混合两个组件的有趣特性,而这种混合原本并未计划。
我的建议是首先关注算法优化,然后是宏优化,最后是微优化。我的意思是,在担心 CPU 缓存中的内容或虚函数的性能成本之前,最好先担心你的物理引擎的时间复杂性和你执行了多少次绘制调用或碰撞测试。虽然它们可能是个问题,但这些事情属于微优化范畴。
然而,在开始使用不熟悉的游戏引擎创建游戏的长过程之前,进行一些简单的原型测试可能是个好主意,以确保引擎能够满足游戏的需求。例如,程序员可以估算对象和组件的数量,并测试性能以查看引擎是否可行。
摘要
在本章中,我们探索了许多创建游戏对象的不同方法。我们看到了使用单体对象或大型继承树的问题。现在我们知道,在创建大型游戏时,这两种方法都不具备可扩展性。它们都存在代码中巨大膨胀的类和依赖性问题。
我们还看到了使用组件对象模型可以为我们的游戏带来的灵活性。它让程序员能够专注于编写新代码,同时允许设计师使用这些代码来创建新的对象类型,甚至在运行时。由于我们现在可以在文件中完全定义对象,我们可以创建一个工具,让我们的设计师,甚至玩家,能够创建全新的对象,或者可能是一个全新的游戏。
我们还简要地提到了与使用组件对象模型相关的性能问题。虽然这些问题可能成为问题,但相比之下,专注于算法优化而不是非常低级的 CPU 指令优化要好得多。我们将在后面的章节中重新审视这些问题。
现在,让我们继续探讨一个可以帮助我们实现游戏核心引擎之一以及一种小型但重要的组件类型的设计模式。在下一章中,我们将发现状态模式如何帮助我们解耦代码,并为我们实现游戏中的人工智能提供一种很好的方法。
第四章:使用状态模式的人工智能
在上一章中,我们讨论了组件对象模型。现在给实体赋予行为就像创建一个新的组件并让该组件控制游戏对象一样简单。
每当有人开始制作游戏时,他们首先编写游戏玩法代码。这是有趣的部分。每个人都想看到图形和物理效果在屏幕上显示。例如,暂停屏幕、选项菜单或甚至第二个关卡都是次要的。为玩家组织行为也是如此。程序员们热衷于让玩家跳跃和冲刺,但随着玩家新能力的增加,可能会有一些组合是你不希望允许的。例如,玩家可能不允许在跳跃时冲刺,或者可能每 3 秒才能冲刺一次。状态模式解决了这些问题。
通过首先编写游戏状态管理器,解决了切换到菜单或暂停的问题。通过将有限状态机作为游戏对象的组件进行编码,解决了复杂行为或玩家或敌人的问题。通过将多个状态机添加到同一个游戏对象中,可以非常简单地创建复杂的行为,正如许多游戏中所见,这是 Unreal 引擎中广泛使用的内置功能,Unity 使用 Hutong Games LLC 流行的 Playmaker 扩展提供了视觉编辑器。
章节概述
在本章中,我们将创建一个简单的状态机来通过输入控制玩家,同时创建一个敌人状态机,当玩家靠近时会检测到并跟随。我们还将查看 Mach5 引擎中的基础 StateMachineComponent 类,并展示通过编写几个状态代码,我们可以轻松地创建更复杂的行为。我们还将展示将多个状态机添加到对象中可以创建多个同时运行的行为,从而避免重复的状态代码。
你的目标
本章将分为几个主题。它将包含一个从开始到结束的简单步骤过程。以下是我们的任务大纲:
-
状态模式解释
-
状态机简介
-
枚举概述
-
根据我们的状态做事
-
为什么 if 语句可能会让你失业
-
扩展状态机
-
状态模式在实际应用中的体现--M5StateMachine
-
状态模式在实际应用中的体现--StageManager
-
状态机的问题
状态模式解释
状态模式是一种允许游戏对象根据游戏中的不同刺激改变其行为和功能的方法,尤其是在对象内部的变量或条件发生变化时,因为这些变化可能会触发状态的变化。对象的状态由某些上下文(在游戏行业中通常被称为机器)管理,但状态告诉机器何时改变状态以及相应的功能。状态模式包含两个部分:状态和上下文。上下文对象持有当前状态,并且可以被状态类用来改变应该运行哪个状态,而状态对象则持有实际的功能:

在 Mach5 引擎中,有一个类已经使用了状态模式(M5StateMachine),但在我们深入研究一个完整的版本之前,让我们真正从头开始构建一个。
实现状态模式或获得类似状态行为的方法有很多。在我们转向最终版本之前,我们将讨论一些常见的版本以及使用它们的优缺点。
状态机简介
我们经常编写代码来根据我们的期望以及玩家的期望来对游戏环境中的事件做出反应。例如,如果我们正在创建一个 2D 横版滚动平台游戏,当玩家按下箭头键时,我们期望玩家的角色移动,每次我们按下空格键时,我们期望精灵跳入空中。或者在一个 3D 游戏中,当我们的玩家看到带有大按钮的面板时,他们期望能够按下它。
我们日常生活中有很多事情也是这样,对某些刺激做出反应。例如,当你使用电视遥控器时,你期望某些事情发生,或者甚至在你滑动或轻触手机时。根据提供的刺激,我们的对象的状态可能会改变。我们称那些可以在同一时间处于多个状态之一的对象为状态机。
你写的几乎每一个程序都可以被视为某种状态机。当你向项目中添加一个if语句时,你就已经开发出了可以处于至少一个那些状态中的代码。也就是说,你不想在代码中有一大堆switch和/或if语句,因为这会使代码很快失控,并使理解代码的确切功能变得困难。
作为程序员,我们经常希望将问题分解成最简单的形式,让我们看看一种可能的方法。在游戏开发中,你会听到关于FSM的提及,它代表有限状态机。有限意味着只有一定数量的状态,并且它们都明确定义了它们可以做什么以及它们如何在状态之间变化。
枚举概述
假设我们要创建一个简单的敌人。这个敌人默认不会做任何事情,但如果玩家靠近,它会向玩家移动。然而,如果玩家离得太远,它们将停止追击。最后,如果玩家射击敌人,敌人会死亡。所以,考虑到这一点,我们可以提取所需的各个状态。它们如下所示:
-
空闲
-
跟随
-
死亡
当我们创建我们的状态机时,我们需要一种方法来跟踪我们的对象将处于哪种状态。有人可能会认为一种方法是为每个可能的状态设置一个bool(布尔值,真或假),并将它们都设置为false,除了我们当前所在的状态。这是一个非常糟糕的想法。
另一个想法可能是只使用一个整数,并为每个可能的值设置一个值。这同样是一个糟糕的想法,因为以这种方式使用数字基本上和我们在代码中使用魔法数字是一样的,因为这些数字对人们来说没有逻辑性。作为替代方案,你可以为每个可能的值设置#defines,但这样将允许人们随意输入任何数字,没有任何保护措施。相反,每当我们看到一系列只有其中一个为真时,我们可以利用枚举的编程特性,简称枚举。
使用枚举的基本概念是,你可以创建自己的自定义数据类型,这些数据类型仅限于具有某些值的列表。与整数或#defines不同,这些数字使用常量表示,使我们能够拥有具有值的全部优势,例如能够比较值。在我们的情况下,我们的状态枚举可能看起来如下所示:
enum State
{
Idle,
Follow,
Death
};
对状态进行操作
现在我们已经定义了状态,让我们现在使我们的代码能够根据对象所处的状态实际执行一些操作。在这个第一个例子中,我将更新EngineTest项目中已经存在的ChasePlayerComponent类。
从右侧的解决方案资源管理器选项卡中,打开SpaceShooter/Components/ChasePlayerComp文件夹,访问ChasePlayerComponent.h文件。从那里,用以下加粗的更改替换类:
enum State{
Idle, Follow, Death };
//!< Simple AI to Chase the Player
class ChasePlayerComponent : public M5Component
{
public:
ChasePlayerComponent(void);
virtual void Update(float dt);
virtual void FromFile(M5IniFile& iniFile);
virtual ChasePlayerComponent* Clone(void);
private:
float m_speed;
float m_followDistance;
float m_loseDistance;
void FollowPlayer();
float GetDistanceFromPlayer();
State m_currentState;
};
FollowPlayer和GetDistanceFromPlayer函数将是我们功能中的辅助函数。我们已经添加了我们的状态enum来存储我们可以处于的每个可能状态,并添加了m_currentState变量来保存我们当前所在的状态。为了确定何时切换状态,我们还有两个其他值,m_followDistance和m_loseDistance,它们分别表示玩家需要与敌人保持多少像素的距离才能跟随,以及玩家需要逃多远才能逃脱。
现在我们已经完成了这个,让我们首先在ChasePlayerComponent.cpp文件的底部添加一些辅助函数,这样我们就可以在更新其他函数后拥有适当的功能:
/*************************************************************************/
/*!
Makes it so the enemy will move in the direction of the player
*/
/*************************************************************************/
void ChasePlayerComponent::FollowPlayer()
{
std::vector<M5Object*> players;
M5ObjectManager::GetAllObjectsByType(AT_Player, players);
M5Vec2 dir;
M5Vec2::Sub(dir, players[0]->pos, m_pObj->pos);
m_pObj->rotation = std::atan2f(dir.y, dir.x);
dir.Normalize();
dir *= m_speed;
m_pObj->vel = dir;
}
/*************************************************************************/
/*!
Returns the distance of the object this is attached to the player
*/
/*************************************************************************/
float ChasePlayerComponent::GetDistanceFromPlayer()
{
std::vector<M5Object*> players;
M5ObjectManager::GetAllObjectsByType(AT_Player, players);
return M5Vec2::Distance(m_pObj->pos, players[0]->pos);
}
这些函数使用一些基本的线性代数来移动我们的对象朝向玩家,并获取两个位置之间的距离。
深入探讨其背后的数学超出了本书的范围,但如果您想了解更多,我强烈建议您查看以下链接。代码是为 Cocos2D 编写的,所以它不会与 Mach5 使用的完全相同,但概念解释得很好:www.raywenderlich.com/35866/trigonometry-for-game-programming-part-1。
现在我们已经实现了这个功能,我们需要更新一些内容。首先,我们将使用构造函数来设置currentState变量的初始值:
/*************************************************************************/
/*!
Sets component type and starting values for player
*/
/*************************************************************************/
ChasePlayerComponent::ChasePlayerComponent(void):
M5Component(CT_ChasePlayerComponent),
m_speed(1)
{
m_currentState = Idle;
}
接下来,我们需要告诉我们的对象通过其 INI 文件读取对象的值:
void ChasePlayerComponent::FromFile(M5IniFile& iniFile)
{
iniFile.SetToSection("ChasePlayerComponent");
iniFile.GetValue("speed", m_speed);
iniFile.GetValue("followDistance", m_followDistance);
iniFile.GetValue("loseDistance", m_loseDistance);
}
FromFile只在初始化时创建的第一个对象上调用一次。为了在不重新编译项目的情况下轻松调整值,Mach 5 从文件中读取信息来设置变量。我们还没有修改.ini文件,但一旦完成所有这些修改,我们将会:
M5Component* ChasePlayerComponent::Clone(void)
{
ChasePlayerComponent* pNew = new ChasePlayerComponent;
pNew->m_speed = m_speed;
pNew->m_followDistance = m_followDistance;
pNew->m_loseDistance = m_loseDistance;
return pNew;
}
然后,我们需要转到 Windows 资源管理器,移动到项目的EngineTest/EngineTest/ArcheTypes文件夹,然后访问Raider.ini文件,并将新属性添加到对象中:
posX = 0
posY = 0
velX = 0
velY = 0
scaleX = 10
scaleY = 10
rot = 0
rotVel = 0
components = GfxComponent ColliderComponent ChasePlayerComponent
[GfxComponent]
texture = enemyBlack3.tga
drawSpace = world
[ColliderComponent]
radius = 5
isResizeable = 0
[ChasePlayerComponent]
speed = 40
followDistance = 50
loseDistance = 75
如果文本编辑器没有为您打开,请随意使用记事本。在这种情况下,我们正在添加两个新的属性,它们代表我们之前创建的值。
然后,我们需要更新我们的阶段,使其更容易进行测试。回到 Windows 资源管理器,打开EngineTest/EngineTest/Stages文件夹,然后打开Level01.ini文件,将其设置为以下内容:
ArcheTypes = Player Raider
[Player]
count = 1
pos = 0 0
[Raider]
count = 1
pos = 100 10
使用这种方法,我们的级别将只包含位于世界中心的玩家和一个位于(100, 10)的敌人掠夺者。完成所有这些后,保存文件,然后回到我们的ChasePlayerComponent.cpp文件,将Update函数替换为以下内容:
void ChasePlayerComponent::Update(float)
{
// Depending on what state we are in, do different things
switch (m_currentState)
{
case Idle:
// No longer move if we were
m_pObj->vel = M5Vec2(0, 0);
// If the player gets too close, the enemy notices them
if (GetDistanceFromPlayer() < m_followDistance)
{
// And will begin to give chase
m_currentState = Follow;
}
return;
case Follow:
// Follow the player
FollowPlayer();
// If the player manages to get away from the enemy
if (GetDistanceFromPlayer() > m_loseDistance)
{
// Stop in your tracks
m_currentState = Idle;
}
break;
case Death:
// Set object for deletion
m_pObj->isDead = true;
break;
}
}
保存所有内容,然后运行项目。如果一切顺利,你应该会看到一个像这样的场景:

注意,由于我们的敌人最初处于空闲状态,它不会移动。然而,如果我们靠近它,它看起来会像这样:

你会看到它现在会跟随我们而不会停止。如果我们设法远离敌人,它们会停止:

这清楚地展示了使用状态模式的基本原理,尽管我们可以做很多事情来改进它,我们很快就会讨论。
条件语句的问题
我们接下来需要考虑的是,我们应该根据当前的状态来做些什么。在编写程序时,我们之前学到的 if 和 switch 等条件语句可能会使代码更难以管理。有时,当你编写特定功能的代码时,编写 if 语句是完全可以理解的,特别是如果你在编写时它是有意义的。例如,以下代码完全合理:
void MinValue(int a, int b)
{
if (a < b)
return a;
else
return b;
}
// Could also be written in the following way:
void MinValue(int a, int b)
{
return (a < b) ? a : b;
}
然而,如果你正在编写检查对象类型或变量是否为特定类型的代码,那可能是个问题。例如,看看以下函数:
void AttackPlayer(Weapon * weapon)
{
if (weapon.name == "Bow")
{
ShootArrow(weapon);
}
else if (weapon.name == "Sword")
{
MeleeAttack(weapon);
}
else
{
IdleAnimation(weapon);
}
}
正如你所见,如果我们开始走这条路,我们将在整个项目中添加许多不同的检查,这将使得如果我们决定添加更多支持的内容,我们的代码将难以更改。首先,当我们看到一些比较相同值并根据该值执行某些操作时,我们应该使用 switch 语句,就像我们之前做的那样,但进行一些修改:
void AttackPlayer(Weapon * weapon)
{
// C++ doesn't support using string literals in switch
// statements so we have to use a different variable
// type, such as an integer
switch (weapon.type)
{
case 0:
ShootArrow(weapon);
break;
case 1:
MeleeAttack(weapon);
break;
default:
IdleAnimation(weapon);
}
}
但在这个特定的情况下,我们只是根据值调用不同的函数,每个函数都是某种攻击方式。相反,我们应该利用多态性,让代码自动完成正确的事情:
class Weapon
{
public:
virtual void Attack()
{
// Do nothing
};
};
class Bow : Weapon
{
public:
virtual void Attack()
{
// Attack with Bow
};
};
void AttackPlayer(Weapon * weapon)
{
weapon->Attack();
}
现在每当调用 AttackPlayer 时,它将自动完成正确的事情。
只需记住,创建复杂的行为会导致编写丑陋的代码,并增加出现错误的可能性。如果你忘记了一个需要存在的条件,你的游戏可能就会崩溃,让你知道有问题,但它无法做任何事情。然后,当你发现你的游戏最终崩溃时,你的生活就会变得复杂得多,你的游戏可能变得无法玩或根本不好玩。
罗伯特·埃尔德有一个关于这个主题的链接,我认为它解释了你可以用条件语句做的一些疯狂的事情,这几乎肯定会让你失业:blog.robertelder.org/switch-statements-statement-expressions/。
不要因为代码中有条件语句而烦恼,但确保你只在你真正需要它们的时候包含它们。随着你继续编码,你会更好地了解何时是合适的,但这是你需要记住的事情。
扩展状态机
所以目前,你会注意到在空闲状态下,我们每帧都将速度设置为 0,0。在这个简单的例子中,这并不是什么大问题,但这种过度计算是我们未来想要避免的。我们实际上只需要在进入状态时做一次,我们可能还希望在离开状态时执行某些操作,但在当前的状态机形式中,我们无法做到这一点,所以我们需要重做一些事情。
首先,让我们回到ChasePlayerComponent.h文件,并添加以下粗体函数定义:
class ChasePlayerComponent : public M5Component
{
public:
ChasePlayerComponent(void);
virtual void Update(float dt);
virtual void FromFile(M5IniFile& iniFile);
virtual M5Component* Clone(void);
virtual void EnterState(State state);
virtual void UpdateState(State state, float dt);
virtual void ExitState(State state);
virtual void SetNewState(State state, bool initialState = false);
private:
float m_speed;
float m_followDistance;
float m_loseDistance;
void FollowPlayer();
float GetDistanceFromPlayer();
State m_currentState;
};
因此,我们不是让Update函数处理所有事情,而是现在为我们的状态可能处于的不同时间创建了三个函数:进入新状态、根据该状态更新,以及离开状态时应该做什么。除此之外,我们还有一个SetNewState函数,它将负责将状态更改为其他状态。所有这些函数都接受一个State枚举来选择如何执行,其中Update状态还有通过这一帧过去的时间,而SetNewState有一个选项表示这是你第一次设置状态,因此你不需要离开上一个状态。之后,我们需要实际添加这些新函数的功能:
void ChasePlayerComponent::EnterState(State state)
{
// Depending on what state we are in, do different things
switch (state)
{
case Idle:
// No longer move if we were
if (m_pObj)
{
m_pObj->vel = M5Vec2(0, 0);
}
M5DEBUG_PRINT("\nIdle: Enter");
break;
case Follow:
M5DEBUG_PRINT("\nFollow: Enter");
break;
case Death:
m_pObj->isDead = true;
M5DEBUG_PRINT("\nDeath: Enter");
break;
}
}
void ChasePlayerComponent::UpdateState(State state, float)
{
// Depending on what state we are in, do different things
switch (state)
{
case Idle:
//M5DEBUG_PRINT("\nIdle: Update");
// If the player gets too close, the enemy notices them
if (GetDistanceFromPlayer() < m_followDistance)
{
// And will begin to give chase
SetNewState(Follow);
}
break;
case Follow:
//M5DEBUG_PRINT("\nFollow: Update");
// Follow the player
FollowPlayer();
// If the player manages to get away from the enemy
if (GetDistanceFromPlayer() > m_loseDistance)
{
// Stop in your tracks
SetNewState(Idle);
}
break;
}
}
void ChasePlayerComponent::ExitState(State state)
{
// Depending on what state we are in, do different things
switch (state)
{
case Idle:
M5DEBUG_PRINT("\nIdle: Exit");
break;
case Follow:
M5DEBUG_PRINT("\nFollow: Exit");
break;
}
}
// initialState by default is false, so will only need to give
// second parameter the first time it is called
void ChasePlayerComponent::SetNewState(State state, bool initialState)
{
if (!initialState)
{
// Exit of our old state
ExitState(currentState);
}
// Then start up our new one
m_currentState = state;
EnterState(m_currentState);
}
然后,我们需要更新我们的Update函数,使其仅调用我们的正确函数:
void ChasePlayerComponent::Update(float dt)
{
UpdateState(m_currentState, dt);
}
我们还需要更改我们的构造函数,使其不再设置当前状态,而是由我们自己设置:
/*************************************************************************/
/*!
Sets component type and starting values for player
*/
/*************************************************************************/
ChasePlayerComponent::ChasePlayerComponent(void):
M5Component(CT_ChasePlayerComponent),
m_speed(1)
{
SetNewState(Idle, true);
}
首先,请注意我正在调用M5DEBUG_PRINT函数。这样做是为了让我们更容易地知道我们正在在不同状态之间切换。为了演示的目的,我注释掉了Update函数的版本,但也许你可以查看一下。注意在这个版本中,我们为每个函数都有一个switch语句,并根据在那里设置的状态执行不同的操作。
在我的编辑器版本中,默认情况下文本不会显示在屏幕上。为了解决这个问题,请转到SplashStage.cpp文件并注释掉以下粗体代码:
SplashStage::~SplashStage(void)
{
//We are done this with ArcheType so lets get rid of it.
M5ObjectManager::RemoveArcheType(AT_Splash);
//M5DEBUG_DESTROY_CONSOLE();
}
现在让我们运行我们的项目!

你可以从编辑器中看出我们何时切换状态,以及代码是否被正确调用!
这个版本工作得相当不错,但也有一些问题;主要是它涉及到大量的重写,并且每次我们想要创建一个新版本时,都需要复制/粘贴这个功能并做出相应的更改。接下来,我们将看看 Mach5 引擎中包含的状态机以及它相对于我们之前讨论的内容所具有的优势。
状态模式在实际应用中的体现 - M5StateMachine 类
Mach5 引擎本身也有它自己的状态机实现,使用继承来允许用户不必反复重写基本功能,并使用函数指针而不是为每个状态有一个函数。函数指针就像它的名字一样——指向函数在内存中的地址的指针——我们可以从这个信息中调用它。
要了解更多关于函数指针及其使用方法的信息,请查看www.cprogramming.com/tutorial/function-pointers.html。
你可以在这里查看其基础版本,从Header文件开始:
#ifndef M5STATEMACNINE_H
#define M5STATEMACNINE_H
#include "M5Component.h"
#include "M5Vec2.h"
//! Base State for M5StateMachines
class M5State
{
public:
//! Empty virtual destructor
virtual ~M5State(void) {}
//! Called when we first enter a state
virtual void Enter(float dt) = 0;
//! called once per frame
virtual void Update(float dt) = 0;
//! called before we exit a state
virtual void Exit(float dt) = 0;
};
//! Base class for Finite statemanchine component for AstroShot
class M5StateMachine : public M5Component
{
public:
M5StateMachine(M5ComponentTypes type);
virtual ~M5StateMachine(void);
virtual void Update(float dt);
void SetNextState(M5State* pNext);
private:
M5State* m_pCurr; //!< a pointer to our current state to be updated
};
#endif //M5STATEMACNINE_H
在前面的代码中,请注意我们最终将StateMachine和State对象分解成它们自己的类,状态函数有自己的Enter、Update和Exit函数。状态机跟踪我们当前所处的状态,并使用Update和SetNextState函数适当地更新,SetStateState函数用于指定我们应该从哪个状态开始。类的实现看起来有点像这样:
#include "M5StateMachine.h"
M5StateMachine::M5StateMachine(M5ComponentTypes type):
M5Component(type),
m_pCurr(nullptr)
{
}
M5StateMachine::~M5StateMachine(void)
{
}
void M5StateMachine::Update(float dt)
{
m_pCurr->Update(dt);
}
void M5StateMachine::SetNextState(M5State* pNext)
{
if(m_pCurr)
m_pCurr->Exit();
m_pCurr = pNext;
m_pCurr->Enter();
}
这个系统提供了一个模板,我们可以在此基础上扩展,以创建更复杂、更有趣的行为。以RandomGoComponent类为例,其头文件看起来如下:
#ifndef RANDOM_LOCATION_COMPONENT_H
#define RANDOM_LOCATION_COMPONENT_H
#include "Core\M5Component.h"
#include "Core\M5StateMachine.h"
#include "Core\M5Vec2.h"
//Forward declation
class RandomGoComponent;
class RLCFindState : public M5State
{
public:
RLCFindState(RandomGoComponent* parent);
void Enter(float dt);
void Update(float dt);
void Exit(float dt);
private:
RandomGoComponent* m_parent;
};
class RLCRotateState : public M5State
{
public:
RLCRotateState(RandomGoComponent* parent);
void Enter(float dt);
void Update(float dt);
void Exit(float dt);
private:
float m_targetRot;
M5Vec2 m_dir;
RandomGoComponent* m_parent;
};
class RLCGoState : public M5State
{
public:
RLCGoState(RandomGoComponent* parent);
void Enter(float dt);
void Update(float dt);
void Exit(float dt);
private:
RandomGoComponent* m_parent;
};
class RandomGoComponent : public M5StateMachine
{
public:
RandomGoComponent(void);
virtual void FromFile(M5IniFile&);
virtual M5Component* Clone(void);
private:
friend RLCFindState;
friend RLCGoState;
friend RLCRotateState;
float m_speed;
float m_rotateSpeed;
M5Vec2 m_target;
RLCFindState m_findState;
RLCRotateState m_rotateState;
RLCGoState m_goState;
};
#endif // !RANDOM_LOCATION_COMPONENT_H
这个类包含三个状态,Find、Rotate和Go,这些状态已经被添加为RandomGoComponent中的对象。每个状态都有它们自己的Enter、Update和Exit功能,除了构造函数和对其父对象的引用。类的实现看起来大致如下:
#include "RandomGoStates.h"
#include "RandomGoComponent.h"
#include "Core\M5Random.h"
#include "Core\M5Object.h"
#include "Core\M5Intersect.h"
#include "Core\M5Gfx.h"
#include "Core\M5Math.h"
#include <cmath>
FindState::FindState(RandomGoComponent* parent): m_parent(parent)
{
}
void FindState::Enter()
{
M5Vec2 botLeft;
M5Vec2 topRight;
M5Gfx::GetWorldBotLeft(botLeft);
M5Gfx::GetWorldTopRight(topRight);
M5Vec2 target;
target.x = M5Random::GetFloat(botLeft.x, topRight.x);
target.y = M5Random::GetFloat(botLeft.y, topRight.y);
m_parent->SetTarget(target);
}
void FindState::Update(float)
{
m_parent->SetNextState(m_parent->GetState(RGS_ROTATE_STATE));
}
void FindState::Exit()
{
}
这个类只是告诉我们的主状态机其预期的位置。这只需要做一次,所以它在Enter状态下完成。Update状态只是声明完成之后,我们希望移动到Rotate状态,而Exit什么也不做。技术上,我们可能无法创建它,但这也可以,因为基类也没有做任何事情,但如果你愿意扩展它,它就在这里:
RotateState::RotateState(RandomGoComponent* parent): m_parent(parent)
{
}
void RotateState::Enter()
{
M5Vec2 target = m_parent->GetTarget();
M5Vec2::Sub(m_dir, target, m_parent->GetM5Object()->pos);
m_targetRot = std::atan2f(m_dir.y, m_dir.x);
m_targetRot = M5Math::Wrap(m_targetRot, 0.f, M5Math::TWO_PI);
m_parent->GetM5Object()->rotationVel = m_parent->GetRotationSpeed();
}
void RotateState::Update(float)
{
m_parent->GetM5Object()->rotation = M5Math::Wrap(m_parent->GetM5Object()->rotation, 0.f, M5Math::TWO_PI);
if (M5Math::IsInRange(m_parent->GetM5Object()->rotation, m_targetRot - .1f, m_targetRot + .1f))
m_parent->SetNextState(m_parent->GetState(RGS_GO_STATE));
}
void RotateState::Exit()
{
m_parent->GetM5Object()->rotationVel = 0;
m_dir.Normalize();
M5Vec2::Scale(m_dir, m_dir, m_parent->GetSpeed());
m_parent->GetM5Object()->vel = m_dir;
}
Rotate状态将只是旋转角色,直到它面对它想要去的位置。如果它在旋转范围内,它将切换到Go状态。但在离开之前,它将在Exit函数中将父对象的速率设置为适当的方向:
GoState::GoState(RandomGoComponent* parent): m_parent(parent)
{
}
void GoState::Enter()
{
}
void GoState::Update(float)
{
M5Vec2 target = m_parent->GetTarget();
if (M5Intersect::PointCircle(target, m_parent->GetM5Object()->pos, m_parent->GetM5Object()->scale.x))
m_parent->SetNextState(m_parent->GetState(RGS_FIND_STATE));
}
void GoState::Exit()
{
m_parent->GetM5Object()->vel.Set(0, 0);
}
Go状态只是检查敌人是否与我们要去的目标相交。如果是,我们就会将我们的状态设置为移动回Find状态并重新开始一切,并在Exit函数中停止玩家移动:
RandomGoComponent::RandomGoComponent():
M5StateMachine(CT_RandomGoComponent),
m_speed(1),
m_rotateSpeed(1),
m_findState(this),
m_rotateState(this),
m_goState(this)
{
SetNextState(&m_findState);
}
void RandomGoComponent::FromFile(M5IniFile& iniFile)
{
iniFile.SetToSection("RandomGoComponent");
iniFile.GetValue("speed", m_speed);
iniFile.GetValue("rotationSpeed", m_speed);
}
RandomGoComponent* RandomGoComponent::Clone(void) const
{
RandomGoComponent* pNew = new RandomGoComponent;
pNew->m_speed = m_speed;
pNew->m_rotateSpeed = m_rotateSpeed;
return pNew;
}
M5State* RandomGoComponent::GetState(RandomGoStates state)
{
switch (state)
{
case RGS_FIND_STATE:
return &m_findState;
break;
case RGS_ROTATE_STATE:
return &m_rotateState;
break;
case RGS_GO_STATE:
return &m_goState;
break;
}
//In case somethings goes wrong
return &m_findState;
}
如您所见,这与我们之前所做的工作非常相似--设置我们的第一个状态,从 INI 文件中获取初始值,然后在克隆时设置正确的事情。最后,我们还有一个GetState函数,它将返回玩家当前的状态,就像我们之前讨论的那样使用 switch 语句。
要看到这个效果,请前往Raider.ini文件并修改代码以适应以下内容:
posX = 0
posY = 0
velX = 0
velY = 0
scaleX = 10
scaleY = 10
rot = 0
rotVel = 0
components = GfxComponent ColliderComponent RandomGoComponent
[GfxComponent]
texture = enemyBlack3.tga
drawSpace = world
[ColliderComponent]
radius = 5
isResizeable = 0
[RandomGoComponent]
speed = 40
rotationSpeed = 40
如果一切顺利,保存文件然后运行项目!

现在,我们将看到敌人不断地移动到新的区域,在到达之前旋转!
状态模式的应用 - StageManager
Mach5 引擎使用状态模式的另一个方面是M5StageManager类:
class M5StageManager
{
public:
friend class M5App;
//Registers a GameStage and a builder with the the StageManger
static void AddStage(M5StageTypes type, M5StageBuilder* builder);
//Removes a Stage Builder from the Manager
static void RemoveStage(M5StageTypes type);
//Clears all stages from the StageManager
static void ClearStages(void);
//Sets the given stage ID to the starting stage of the game
static void SetStartStage(M5StageTypes startStage);
//Test if the game is quitting
static bool IsQuitting(void);
//Test stage is restarting
static bool IsRestarting(void);
//Gets the pointer to the users game specific data
static M5GameData& GetGameData(void);
//Sets the next stage for the game
static void SetNextStage(M5StageTypes nextStage);
// Pauses the current stage, so it can be resumed but changes stages
static void PauseAndSetNextStage(M5StageTypes nextStage);
// Resumes the previous stage
static void Resume(void);
//Tells the game to quit
static void Quit(void);
//Tells the stage to restart
static void Restart(void);
private:
static void Init(const M5GameData& gameData, int framesPerSecond);
static void Update(void);
static void Shutdown(void);
static void InitStage(void);
static void ChangeStage(void);
};//end M5StageManager
由于游戏中只会存在一个这样的实例,因此所有功能都已被设置为静态,类似于单例模式,但根据项目所处的状态,它将执行不同的操作。以改变我们处于的阶段为例。我相信你会发现这和之前我们改变状态的方式非常相似:
void M5StageManager::ChangeStage(void)
{
/*Only unload if we are not restarting*/
if (s_isPausing)
{
M5ObjectManager::Pause();
M5Phy::Pause();
M5Gfx::Pause(s_drawPaused);
PauseInfo pi(s_pStage, s_currStage);
s_pauseStack.push(pi);
s_isPausing = false;
}
else if (s_isResuming)
{
/*Make sure to shutdown the stage*/
s_pStage->Shutdown();
delete s_pStage;
s_pStage = nullptr;
}
else if (!s_isRestarting) //Just changine the stage
{
/*Make sure to shutdown the stage*/
s_pStage->Shutdown();
delete s_pStage;
s_pStage = nullptr;
//If we are setting the next state, that means we are ignore all
//paused states, so lets clear the pause stack
while (!s_pauseStack.empty())
{
M5Gfx::Resume();
M5Phy::Resume();
M5ObjectManager::Resume();
PauseInfo pi = s_pauseStack.top();
pi.pStage->Shutdown();
delete pi.pStage;
s_pauseStack.pop();
}
}
else if (s_isRestarting)
{
/*Make sure to shutdown the stage*/
s_pStage->Shutdown();
}
s_currStage = s_nextStage;
}
我强烈建议你仔细查看文件,并逐个函数地了解它们是如何相互作用的。
关于有限状态机(FSM)的问题
我们已经看到了有限状态机(FSM)可以作为有价值的东西添加到项目中,以及它们如何使简单的 AI 行为变得更容易,但它们也存在一些问题。
传统的有限状态机,如我们在此处展示的,随着时间的推移,可能会变得难以管理,因为你继续向其中添加许多不同的状态。困难的部分是在添加新上下文的同时,将状态的数量保持在最低,以便你的角色可以响应。
当你重建具有其他部分的行为时,你将编写大量类似的代码,这可能会消耗时间。最近在游戏行业中发生的一件事是,AI 程序员正在转向更复杂的方式来处理 AI,例如行为树。
如果你感兴趣,想知道为什么有些人认为有限状态机的时代已经结束,请查看aigamedev.com/open/article/fsm-age-is-over/。在这里可以找到关于有限状态机的问题以及一些潜在的解决方案来修复这些问题:aigamedev.com/open/article/hfsm-gist/。
摘要
在本章中,我们学习了状态模式,这是一种允许游戏对象根据游戏中的不同刺激改变其行为和功能的方法。我们学习了状态和上下文(机器)以及它们是如何一起使用的。然后我们学习了如何使用状态模式来获得一些关于 AI 编程的曝光,以及我们项目的游戏状态管理器是如何工作的以及为什么它很重要。当然,有限状态机在用于 AI 方面最为流行,但也可以用于 UI 以及处理用户输入,使它们成为你武器库中的另一个有用工具。
第五章:通过工厂方法模式解耦代码
每个项目和每个游戏设计都会发生变化。面向对象编程的一个目标就是考虑到这种变化来编程。这意味着编写灵活且可重用的代码,以便当发生变化时,项目不会崩溃。不幸的是,需求永远不会完全清楚,设计师的愿景也永远不会 100%完整。好消息是,新添加的功能可能会以意想不到的有趣方式与旧功能交互,从而产生未计划的功能,这可以使游戏完全不同,并且更加有趣。
在最坏的情况下,游戏设计可能根本不好玩,这可能导致游戏对象类型、对象行为甚至整个游戏设计的巨大变化。在这种情况下,我们希望能够以最小的代码更改重新设计我们的游戏,以尝试新的可能性。更改代码需要时间来编写、测试和调试,并且每次添加新代码时,都有可能造成旧代码中的错误。
由于我们知道我们的设计将会改变,我们必须通过遵循良好的设计原则和利用设计模式来解决常见问题来为这种变化做出规划。这包括使用灵活的设计,如组件对象模型,以避免继承层次结构。这还包括使用状态模式和有限状态机来避免复杂的if语句和导致每次变化时出现错误的级联if else链。这还包括诸如努力降低耦合度并避免硬编码任何事情。
我们都知道,作为程序员,我们应该避免魔法数字。我们不希望在我们的代码中看到看似随机或奇怪的数字。硬编码的数字描述性不足,这使得代码难以阅读、维护和调试。如果我们需要更改魔法数字,我们必须花费时间在我们的代码中搜索它每次出现的地方。不幸的是,这些硬编码的值在开发过程中往往会频繁更改。在现实世界中,重力可能是 9.8 m/s²,但在游戏世界中,我们可能需要调整它,以便游戏更有趣,或者可能在运行时更改它,以便玩家可以在天花板上行走。
当我们考虑硬编码时,我们通常会想到魔法数字。然而,使用具体类类型(如新的ChaseAIComponent)调用new运算符可能是可读的,但它与重力值或数组大小一样可能发生变化。
章节概述
在本章中,你将专注于一个常见的接口来创建新对象,而无需直接调用构造函数。首先,我们将探讨为什么switch语句可能是有害的。接下来,我们将探讨一个设计原则,它引导我们到达最终的解决方案,即工厂。然后,我们将探讨几种不同的设计工厂的方法,使它们灵活且可重用。
你的目标
在本章的整个过程中,我们将探讨一些更多的重要概念和原则,这些可以使我们的程序变得更好。以下是本章我们将涵盖的内容和你的任务概述:
-
学习为什么使用
switch语句可能不好 -
学习依赖倒置原则
-
学习工厂方法模式
-
构建组件、阶段和对象工厂
-
通过使用模板改进你的工厂
switch语句的问题
当刚开始学习编程时,仅仅理解语言的语法是非常困难的。通常,新手程序员会专注于函数调用或for循环的语法,甚至不会考虑编写可重用、可维护的代码。这主要是因为他们在没有规划的情况下直接开始编码。这一点在游戏开发中也是如此。新手程序员往往想直接编写游戏,而忘记了诸如用户界面和暂停菜单等问题。诸如窗口分辨率、阶段中敌人的放置,甚至点击按钮时鼠标应该在哪里,都会被硬编码。以下是作者第一个游戏中的一段代码。这是MainMenu函数中的一个部分,当按钮被点击时会将游戏切换到加载状态。变量p是鼠标的位置:
if ((p.x > .15 * GetSystemMetrics(SM_CXSCREEN)) &&
(p.x < .42 * GetSystemMetrics(SM_CXSCREEN)) &&
(p.y > .58 * GetSystemMetrics(SM_CYSCREEN)) &&
(p.y < .70 * GetSystemMetrics(SM_CYSCREEN)))
{
if (mousedown)
{
mGameState = TCodeRex::LOAD;
mGameLevel = L0;
}
}
与此类似的代码重复了四次(没有使用循环),因为MainMenu中有四个按钮。当然,有更好的(要好得多)编写这段代码的方法。首先,重要的是将游戏阶段与Graphics类分开,其次,如果编写得当,按钮可以自我管理。然而,这个旧代码库最糟糕的部分是游戏阶段的变化方式。
在那个游戏中,Engine类中有一个名为Update的函数,包含了一个 507 行的switch语句。这包括了一个嵌套的switch语句,用于需要菜单阶段但可以选择多个菜单的情况。以下是当需要加载关卡时的部分代码示例:
case TCodeRex::LOAD:
{
StopGameMusic();
StopMenuMusic();
switch (mGameLevel)
{
case TCodeRex::L0:
{
grafx.LoadScreen(mLoading);
mLoading = true;
if (ObjectMgr->LoadLevel(".\\Levels\\Level_00.txt"))
{
mPlayer1->SetPhysics().setPosition(
(Physics::real)ObjectMgr->PlayerX,
(Physics::real)ObjectMgr->PlayerY, 0.0f);
grafx.GetCamera().Move(
ObjectMgr->PlayerX - 500,
ObjectMgr->PlayerY - 500);
ObjectMgr->AddToList(mPlayer1);
mPlayer1->SetHealth(100);
mBGTexture = grafx.GetTextureMgr().GetTile(49);
mplaying = true;
mGameState = TCodeRex::PLAY;
}
else
mGameState = TCodeRex::MENU;
break;
}//end case TCODEREX::LOAD
如果你觉得这很难看,你是对的。由于某种原因,阶段切换代码负责设置玩家的健康和位置,移动摄像头,设置背景纹理,如果加载失败则返回菜单。
我们展示这个例子是为了让你看到真正糟糕的代码。这个Engine类与graphics类、physics类、object manager类和player类紧密耦合。如果这些类中的任何一个发生变化或被删除,Engine类中的代码也需要更改。另一个问题是这里有许多依赖关系,这意味着上述任何类的更改都会导致代码重新编译。
几年后,其中一位作者在担任 iPhone 游戏合同程序员时遇到了类似的情况。最初的设计只要求两个阶段,即主菜单和游戏玩法。由于计划非常简单,因此使用了类似于我们上面看到的代码。这很快变成了一个问题,因为即使项目只有三个月,所有的游戏设计都会发生变化。
一个月后,游戏按照规格完成,包括一个关卡编辑器,以便设计师可以创建所需的所有关卡。目标是花剩下的时间添加效果和润色游戏。然而,每周,制作人都会来要求添加新的菜单或过渡屏幕。
最后,游戏设计要求有一个选项菜单、一个关卡选择菜单、一个致谢屏幕、每 10 个关卡一个庆祝屏幕,以及完成游戏后的祝贺屏幕。最初编写的简单两个阶段的代码最终因为快速的开发时间表和不断添加的新功能而变得一团糟。有一行糟糕的代码类似于以下内容:
if(levelCounter == 81)
ShowCongratsScreen();
这之所以有效,是因为游戏中共有 80 个关卡,levelCounter在完成一个关卡后会递增。在看到这样的游戏代码后,我们希望您能理解为什么规划变化发生的重要性。
在第三章,“使用组件对象模型创建灵活性”中,我们看到了如何为我们的游戏对象创建组件,以便我们能够轻松处理对象设计的更改。这是创建灵活代码的重要步骤;然而,当我们在我们阶段创建对象时,我们仍然必须使用new来硬编码构成我们的对象的具体类型:
M5Object* pObj = new M5Object(AT_Player);
GfxComponent* pGfxComp = new GfxComponent;
PlayerInputComponent* pInput = new PlayerInputComponent;
ClampComponent* pClamp = new ClampComponent;
pObj->AddComponent(pGfxComp);
pObj->AddComponent(pInput);
pObj->AddComponent(pClamp );
//Set position, rotation, scale here
//...
M5ObjectManager::AddObject(pObj);
这意味着我们的派生M5Stage类与该对象类型的组件紧密耦合,实际上是与需要在此级别创建的所有对象类型紧密耦合。如果对象需要不同的组件,或者任何组件被更改或删除,那么我们的Stage类必须进行更改。
解决这个问题的一种方法(正如我们在第三章,“使用组件对象模型创建灵活性”中看到的)是将此代码放入我们的M5ObjectManager类中。这样,我们的阶段就不需要随着类型的修改而不断更新:
M5Object* M5ObjectManager::CreateObject(M5ArcheTypes type)
{
switch(type)
{
case AT_Player:
// Create player object
M5Object* pPlayer = new M5Object(AT_Player);
// Create the components we'd like to use
GfxComponent* pGfx = new GfxComponent;
PlayerInputComponent* pInput = new PlayerInputComponent;
ClampComponent* pClamp = new ClampComponent;
// Attach them to our player
pObj->AddComponent(pGfx);
pObj->AddComponent(pInput);
pObj->AddComponent(pClamp);
//Add this object to the M5ObjectManager
AddObject(pPlayer);
return pPlayer;
break;
case AT_Bullet:
//...More Code here
这解决了在对象更改时需要更改我们的阶段的问题。然而,如果我们的对象或组件发生变化,我们仍然需要更改我们的switch语句和对象管理器。实际上,除了这个函数外,对象管理器并不真正关心任何派生组件类型。它只需要依赖于M5Component抽象类。如果我们能修复这个函数,我们就可以完全将派生组件类型与这个类解耦。
解决我们问题的方案与解决我多年前面临的阶段管理问题的方案相同,即依赖倒置原则。
依赖倒置原则
避免具体类的概念并不新鲜。Robert C. Martin 在 1996 年 5 月的《C++ Report》杂志上发表了一篇题为《依赖倒置原则》的文章,定义了这个想法。这是他 SOLID 设计原则中的 D。这个原则有两个部分:
-
高级模块不应依赖于低级模块。两者都应依赖于抽象。
-
抽象不应依赖于细节。细节应依赖于抽象。
虽然这听起来可能很多,但这个概念实际上非常简单。想象一下,我们有一个StageManager类,它负责初始化、更新和关闭我们游戏中所有的阶段。在这种情况下,我们的StageManager是高级模块,而阶段是低级模块。StageManager将控制低级模块,即阶段的创建和行为。这个原则说,我们的StageManager代码不应该依赖于派生阶段类,而应该依赖于一个抽象阶段类。为了了解为什么,让我们看看不遵循此原则的一个例子。
在这里,我们的StageManager有一个名为Update的函数,其代码如下:
void StageManager::Update()
{
Level1 level1;
level1.StartGame();
while(nothingChanged)
level1.PlayGame()
level1.EndGame();
}
当然,这只是我们游戏的第一级,因此我们需要包含代码来更新主菜单和游戏的第二级。由于这些类是无关的,我们需要一个switch语句来在阶段之间做出选择:
void StageManager::Update()
{
switch(currentStage)
{
case LEVEL1:
{
SpaceShooterLevel1 level1;
level1.StartLevel();
while(currentStage == nextStage)
level1.PlayLevel()
level1.EndLevel();
break;
}
case LEVEL2:
{
SpaceShooterLevel2 level2;
level2.StartGame();
while(currentStage == nextStage)
level2.PlayLevel()
level2.EndLevel();
break;
}
case MAIN_MENU:
{
SpaceShooterMainMenu mainMenu;
mainMenu.OpenMenu();
while(currentStage == nextStage)
mainMenu.Show()
mainMenu.CloseMenu();
break;
}
}//end switch
}//end Update
你可以看到,随着我们继续添加越来越多的关卡,这段代码会迅速变得很大,很快就会变得难以维护。这是因为每次游戏中添加新的阶段时,我们都需要进入这个函数并记得更新switch语句。
本节的第一部分告诉我们,我们的StageManager类不应该依赖于关卡或菜单,而应该依赖于一个公共抽象。这意味着我们应该为所有阶段创建一个抽象基类。第二部分表示,抽象不应该关心阶段是关卡还是菜单。实际上,它甚至不应该关心我们正在为太空射击游戏、平台游戏或赛车游戏制作阶段。使用此原则的主要好处之一,除了代码更加灵活外,就是我们的StageManager类将不再依赖于这个特定游戏中的任何内容,因此我们可以将其用于我们的下一个项目:

StageManager 依赖于特定的类
//Stage.h
class Stage
{
public:
virtual ~Stage(void) {}//Empty virtual destructor
virtual void Init(void) = 0;
virtual void Update(void) = 0;
virtual void Shutdown(void) = 0;
};
//StageManager.cpp
void StageManager::Update()
{
//We will talk about how to get the current stage soon
Stage* pStage = GetCurrentStage();
//Once we have the correct Stage we can run our code
pStage->Init();
while(currentStage == nextStage)
pStage->Update();
pStage->Shutdown();
}
//Example of a derived class
//MainMenu.h
class MainMenu : public Stage
{
public:
virtual ~MainMenu(void);
virtual void Init(void);
virtual void Update(void);
virtual void Shutdown(void);
private:
//Specific MainMenu data and functions here...
};

StageManager 只依赖于抽象
现在,StageManager 的 Update 函数变得更加简单。由于我们只依赖于抽象类,我们的代码不再需要根据派生实现进行更改。我们还简化了所有阶段的接口。每个阶段的函数不再根据类的细节(菜单、等级等)进行更改;相反,它们都共享一个公共接口。正如你所看到的,了解依赖倒置原则不仅会在聚会上让你成为焦点,它还将允许你解耦代码库并重用高级模块。
我们仍然面临选择正确派生类的问题。我们不想在 StageManager 的 Update 函数中放置 switch 语句。如果我们这样做,我们将会遇到之前的问题。相反,我们需要一种方法来选择正确的派生类,同时只依赖于基类。
工厂方法模式
工厂方法模式正是我们解决问题的关键设计模式。该模式的目的在于提供一种创建我们想要的派生类的方法,而无需在我们的高级模块中指定具体的 concreate 类。这是通过定义一个创建对象的接口来实现的,但让子类决定实例化哪个类。
在我们的案例中,我们将创建一个名为 StageFactory 的接口,其中包含一个名为 Build 的方法,该方法将返回一个 Stage*。然后我们可以创建如 Level2Factory 这样的子类来实例化我们的派生类。现在我们的 StageManager 类只需要了解 Stage 和 StageFactory 抽象:

创建一个阶段工厂
//StageManager.cpp
void StageManager::Update()
{
Stage* pStage = m_stageFactory->Build();
pStage->Init();
while(currentStage == nextStage)
pStage->Update();
pStage->Shutdown();
m_StageFactory->Destroy(pStage);//stage must be destroyed
}
注意,我们已经将 new 的调用从 StageManager::Update 函数移动到了派生的 StageFactory 方法中。我们已经成功地将 StageManager 与派生的 Stage 类解耦。然而,对 Build 的调用仅代表创建了一个派生的 Stage 类。我们仍然需要一个方法来选择我们想要使用的派生 Stage 以及需要实例化的派生 StageFactory。我们需要一种方法来选择不同类型的工厂。在我们查看 Mach5 引擎中使用的解决方案之前,让我们看看另一种工厂方法,即静态工厂。
静态工厂
以我们想要的方式实现工厂方法的最简单方法是使用全局函数或静态类函数。我们可以在 StateMananger 之外定义一个名为 MakeStage 的函数,该函数负责根据参数实例化正确的派生类型。在这种情况下,我们将使用一个名为 StageType 的 enum 来帮助我们选择正确的类型:
//MakeStage.cpp
Stage* MakeStage(StageType type)
{
switch(type)
{
case ST_Level1:
return new Level1;
case ST_LEVEL2:
return new Level2;
case ST_MainMenu:
return new MainMenu;
default:
//Throw exception or assert
}
}
如果我们使用这种风格的工厂,我们的 StageManager::Update 函数将看起来像这样:
void StageManager::Update()
{
Stage* pStage = MakeStage(currentStage);
pStage->Init();
while(currentStage == nextStage)
pStage->Update();
pStage->Shutdown();
DestroyStage(pStage);//Clean up the stage when done
}
这个版本的工厂方法正好符合我们的需求。我们现在可以选择实例化哪个派生的 Stage 类。我们仍然有一个必须维护的 switch 语句,但至少我们的高级模块不再依赖于派生类。在默认情况下,当我们的 switch 语句无法匹配正确的类型时,我们只剩下使用 assert 使程序崩溃、抛出异常并让客户端解决问题,或者返回 null,这仍然将责任留给了客户端。
静态工厂成功地将我们的 StageManager 类与特定的派生 Stage 类解耦,同时允许我们在运行时选择要实例化的阶段。这很好,但正如我所说的,Mach5 引擎并不是这样实现 Stage 或组件工厂的。相反,Mach5 使用了一个更动态的解决方案,因此我们将它称为动态工厂。
动态工厂
虽然静态工厂对于我们的目的来说足够简单,但 Mach5 引擎采用了不同的方法。这种方法结合了经典工厂方法的泛型解决方案和静态工厂的选择能力。这种新的方法使用了一个可搜索的派生 StageFactory 类集合。
记住,经典工厂方法的问题在于该方法只代表一个要实例化的类。这使我们的代码更加灵活,因为我们不依赖于派生的 Stage 类或 new 操作符的调用。然而,我们仍然需要一个方法来获取特定的派生 StageFactory 实例。
在 Mach5 引擎中,名称有所改变。只有一个 StageFactory 类,它包含一个 M5StageBuilder 指针集合(这些是经典的工厂),这些指针实现了 Build 方法。设计看起来是这样的:

动态工厂的设计
我们首先想看到的是基类 M5Stage:
class M5Stage
{
public:
virtual ~M5Stage(void) {} //Empty virtual destructor
virtual void Load(void) = 0;
virtual void Init(void) = 0;
virtual void Update(float dt) = 0;
virtual void Shutdown(void) = 0;
virtual void Unload(void) = 0;
};
基类 M5Stage 是一个相当简单的抽象基类,具有虚拟析构函数。M5Stage 中的特定虚拟函数对于工厂的细节并不重要。我们在这里展示这个类,因为 M5StageManager 和 M5StageFactory 将会使用这个抽象。
用于多态使用的 C++ 类,包括抽象基类,应始终实现虚拟析构函数,否则无法调用正确的派生类析构函数。
创建我们的舞台构建器
接下来让我们看看我们的基构建器类。注意,这和经典工厂方法中会使用的接口类型相同。这种抽象声明了一个返回另一个抽象的方法,在这种情况下是 M5Stage。
就像之前一样,我们需要有一个空的虚拟析构函数,这样当我们以多态方式使用这个类时,就会调用正确的析构函数。同样,其他方法也被标记为纯虚拟的,这禁止了直接实例化这个类。这意味着我们不能直接创建一个M5StageBuilder。我们必须从它派生出来,并实现纯虚拟方法:
class M5StageBuilder
{
public:
virtual ~M5StageBuilder() {} //Empty virtual destructor
virtual M5Stage* Build(void) = 0;
};
即使名称不同,这也是经典工厂方法实现的途径。Mach5 引擎将其称为Builder而不是Factory,但这只是名称上的变化,并没有改变功能。Build方法的名字并不重要。有些程序会把这个方法称为Create或Make。Mach5 称其为Build,但任何这些名称都是可以的。
无论名称如何,使用 Mach5 引擎创建游戏的用户都希望为游戏中的各个阶段创建自己的特定阶段构建器。对于这本书,我们有名为AstroShot的空间射击游戏阶段。为了为这些阶段创建构建器,我们需要从M5StageBuilder派生并实现Build方法。例如,如果我们有名为SplashStage和MainMenuStage的M5Stage派生类,我们会创建如下所示的构建器:
//SplashStageBuilder.h
class SplashStageBuilder: public M5StageBuilder
{
public:
virtual M5Stage* Build(void);
};
//SplashStageBuilder.cpp
M5Stage* SplashStageBuilder::Build(void)
{
return new SplashStage;
}
//MainMenuStageBuilder.h
class MainMenuStageBuilder: public M5StageBuilder
{
public:
virtual M5Stage* Build(void);
};
// MainMenuStageBuilder.cpp
M5Stage* MainMenuStageBuilder::Build(void)
{
return new MainMenuStage;
}
注意,这里使用关键字virtual在派生类中是完全可选的。在 C++ 11 之前的日子里,程序员会将函数标记为virtual作为一种对其他程序员的文档说明。如今,你可以在虚拟函数上添加override指定符,这样编译器就会在函数不是真正的重写时发出错误。
对一些人来说,这可能会显得有些繁琐。实际上,我听到的关于面向对象编程初学者的最大抱怨是他们感觉像是在创建很多包含小类的文件上浪费了大量的时间。对他们来说,这感觉像是做了很多工作,但回报甚微。
我同意以这种方式编程可能需要很多文件和很多小的类。然而,我不同意这是浪费时间。我认为这些论点是由于短视的思考而产生的。他们只考虑编写原始代码所需的时间,但没有考虑到在设计变更时节省的时间,以及测试和调试所需的时间。
创建新文件并不需要花费太多时间。使用像 Visual Studio 这样的集成开发环境,创建源文件和头文件只需要不到 10 秒钟。编写像上面那样的小型类也不需要花费太多时间。总共,编写这两个类不到五分钟。当然,这比直接在一个高级模块中写入新内容要花更多时间,但记住,我们的目标是编写能够适应我们游戏设计变化的代码。
这些短视的论点与新程序员学习编写函数时的抱怨相似。我们已经讨论了编写函数的好处,同样的好处也适用于这里。我们不应该只考虑编写初始代码所需的时间。我们需要考虑测试和调试代码所需的时间,引入新错误到旧代码中的可能性,如果我们的设计在一个月或一年后发生变化,修改所需的时间,以及如果设计发生变化,修改我们的代码引入错误的可能性有多大。
重要的是要理解,通过使用设计模式,我们在一开始就需要编写更多的代码,这样我们才能减少未来测试、调试、集成、维护和更改我们代码所需的时间。重要的是要理解,编写原始代码是容易且成本低的,而稍后更改它则要困难得多,成本也更高。在这种情况下,“便宜”和“昂贵”可能是指工作小时数或支付程序员的费用。
模板构建器
担心编写大量小类的人有运气。大多数,如果不是所有,我们的构建器除了它们实例化的具体派生类之外都是相同的。这意味着我们可以使用 C++ 模板的力量为我们创建构建器。我们的模板构建器将看起来像这样:
//M5StageBuilder.h
template <typename T>
class M5StageTBuilder : public M5StageBuilder
{
public:
virtual M5Stage* Build(void);
};
template <typename T>
M5Stage* M5StageTBuilder<T>::Build(void)
{
return new T();
}
这段代码对我们大多数阶段都工作得很好。唯一不工作的时候是我们需要做更具体的事情,比如调用非默认构造函数,或者调用特定于派生阶段的函数。
注意到 Build 函数的实现也包含在 .h 文件中。这是因为模板函数与常规函数不同。它们作为配方工作,以便编译器知道如何为特定类型生成函数。每次我们需要使用这个函数时,编译器都需要知道这个配方。这使得编译器能够实例化函数,而不是要求用户在使用之前明确实例化所需的 Builder 类。因此,当我们想要使用这个类时,它看起来可能像这样:
//SomeFile.cpp
#include "M5StageBuilder.h"
#include "MainMenuStage.h"
void SomeFunction(void)
{
//Creating the class needs the type
M5Builder* pBuilder = new M5StageTBuilder< SplashStage >();
//But using the Build function doesn't need the type
M5Stage* pStage = pBuilder->Build();
}
创建动态工厂类
到目前为止,我们只创建了我们的构建器,它们相当于经典的工厂方法模式。然而,我们还没有看到动态工厂的工厂部分。让我们看看 Mach5 引擎是如何实现 StageFactory 类的:
class M5StageFactory
{
public:
~M5StageFactory(void);
void AddBuilder(M5StageTypes name, M5StageBuilder* builder);
void RemoveBuilder(M5StageTypes type);
void ClearBuilders(void);
M5Stage* Build(M5StageTypes name);
private:
typedef std::unordered_map<M5StageTypes,
M5StageBuilder*> BuilderMap;
typedef BuilderMap::iterator MapItor;
BuilderMap m_builderMap;
};
如您所见,M5StageFactory 并不是一个非常复杂的类。一旦您理解了模式背后的设计,实现它们通常并不困难。至于这个类,它只有五个方法和一个成员。私有部分看起来有点复杂,因为 Mach5 倾向于使用 typedef 为模板容器。由于容器在所有私有函数中都被使用,让我们在探索五个方法之前先看看这个成员。
让我们首先看看 typedefs:
typedef std::unordered_map<M5StageTypes, M5StageBuilder*> BuilderMap;
由于我们想要一个 M5StageBuilders 的容器,我们有几种选择。我们可以使用 STL 向量或列表,但那些容器由于潜在的低效性并不适合搜索,如果我们有很多构建器的话。然而,这正是 STL 映射和无序映射的完美之处。它们允许我们保存键/值对,并稍后使用键来高效地找到值,即使我们有成千上万的构建器。我们将使用 M5StageTypes 枚举作为我们的键,并使用派生的 M5StageBuilder* 作为我们的值。
STL 映射实现为一个树,而 unordered_map 实现为一个哈希表。一般来说,这意味着映射将使用更少的内存,但搜索会稍微慢一些。unordered_map 将使用更多的内存,但搜索速度会快得多。在我们的游戏中,我们不太可能创建成千上万阶段,所以速度上的差异不会很大,尤其是我们不太经常进行搜索。我们选择哈希表是因为,在 PC 上,我更关心速度而不是内存。如果你有兴趣了解更多,请查看 www.cplusplus.com/reference/ 以获取有关标准库的大量信息。
我们也应该尽可能编写可读性强的代码。使用 typedef 将有助于他人理解我们的代码,因为我们只需要将长的 std::unordered_map< M5StageTypes, M5StageBuilder*> 代码写一次。之后,我们可以使用简短的名字,在这个例子中是 BuilderMap。这也给了我们将来如果决定使用映射而不是容器时轻松更改容器的能力:
typedef BuilderMap::iterator MapItor;
下一个 typedef 给我们提供了一个简短的名字来表示我们的 BuilderMap 迭代器。
在 C++ 11 的 auto 关键字下,这并不是必需的,但这并不使我们的代码可读性降低,所以我们选择了使用 typedef。
最后,实际的成员:
BuilderMap m_builderMap;
这将是我们将 M5StageTypes 映射到 M5StageBuilder* 的容器。我们应该将其设为私有,因为我们希望所有构建器都通过类方法添加和移除,以便验证数据。
现在是类方法。让我们从工厂最重要的方法开始:
M5Stage* M5StageFactory::Build(M5StageTypes type)
{
ArcheTypeItor itor = m_builderMap.find(type);
if (itor == m_builderMap.end())
return nullptr;
else
return itor->second->Build();
}
Build 方法是发生“魔法”的地方,至少对于用户来说是这样。他们传递一个阶段类型,我们为他们构建正确的阶段。当然,我们首先使用 find 方法来确保类型已经被添加。如果找不到,我们使用调试断言来通知用户这种类型没有被添加。一般来说,find 方法比存在于映射和无序映射中的 operator[] 更安全。使用 operator[] 如果键不存在,将会创建并返回一个空值。如果在构建过程中发生这种情况,我们将会得到一个空指针异常,这会导致程序崩溃,而不会给用户解释原因。
我们可以选择在映射中添加一些默认阶段,并在找不到正确类型时构建它。然而,程序员可能不会注意到已经犯了一个错误。相反,我们选择返回一个空指针。这要求用户在使用构建器之前检查其是否有效,但也意味着如果他们不修复问题,代码将崩溃:
bool M5StageFactory::AddBuilder(M5StageTypes name,
M5StageBuilder* pBuilder)
{
std::pair<MapItor, bool> itor = m_builderMap.insert(
std::make_pair(name, pBuilder));
return itor.second;
}
AddBuilder 方法允许我们的用户将一个 M5StageTypes 值与一个派生的 M5StageBuilder 关联。在这种情况下,我们的代码不知道或关心 pBuilder 是否指向一个模板类。重要的是它是否从 M5StageBuilder 派生。
正如之前一样,我们应该编写代码来帮助用户在出现错误时找到并修复它们。我们通过测试插入方法的返回值来实现这一点。insert 方法返回一个对,其中第二个元素将告诉我们插入是否成功。由于 map 和 unordered_map 不允许重复项,我们可以测试以确保用户不会将 M5StageTypes 值与两个不同的构建器关联。如果用户尝试两次使用 enum 值,第二个构建器将不会被插入,并返回 false。
STL 版本的 map 和 unordered_map 不允许重复项。如果您希望有重复项,可以将容器替换为 multimap 或 unordered_multimap,后者允许重复项。在这个类中使用多版本可能没有用,但它们是很好的工具,值得了解。
void M5StageFactory::RemoveBuilder(M5StageTypes name)
{
BuilderMap::iterator itor = m_builderMap.find(name);
if (itor == m_builderMap.end())
return;
//First delete the builder
delete itor->second;
itor->second = 0;//See the note below
//then erase the element
m_builderMap.erase(itor);
}
到现在为止,这种模式应该感觉已经很常规了。首先我们编写代码以确保没有错误,然后我们编写实际的函数代码。在这个函数中,我们首先检查用户是否正在删除之前添加的构建器。在确保用户没有犯错误之后,我们然后删除构建器并从容器中删除迭代器。
由于我们在删除构建器后立即删除迭代器,因此设置指针为 0 是不必要的。然而,我总是将指针设置为 0。这有助于查找错误。例如,如果我忘记删除迭代器并再次尝试使用此构建器,程序将崩溃,这是由于使用了空指针。如果我没有将指针设置为 0 但仍然尝试使用它,我将会得到未定义的行为。
void M5StageFactory::ClearBuilders(void)
{
MapItor itor = m_builderMap.begin();
MapItor end = m_builderMap.end();
//Make sure to delete all builder pointers first
while (itor != end)
{
delete itor->second;
itor->second = 0;
++itor;
}
//Then clear the hash table
m_builderMap.clear();
}
正如 M5Object 中的 RemoveAllComponents 一样,ClearBuilders 的目的是帮助类的析构器。由于这段代码无论如何都需要编写(它将放在析构器中),我们认为将其分解为用户可以调用的单独函数会更好:
M5StageFactory::~M5StageFactory(void)
{
ClearBuilders();
}
最后,我们有我们的工厂析构器。这仅仅确保通过调用 ClearBuilders 函数,我们没有内存泄漏。
使用动态工厂
现在我们已经完成了Factory类的创建,让我们看看如何使用它。由于这个类的目标是解耦我们的M5StageManager和特定的派生M5Stage类,因此它在M5StageManager类中使用是有意义的:
class M5StageManager
{
public:
//Lots of other stuff here...
static void AddStage(M5StageTypes type, M5StageBuilder*
builder);
static void RemoveStage(M5StageTypes type);
static void ClearStages(void);
private:
//Lots of other stuff here
static M5StageFactory s_stageFactory;
};
由于工厂将在M5StageManager中是私有的,我们将添加接口函数,以便用户可以在不知道实现的情况下控制工厂。这允许我们更改细节,而不会影响用户。
在M5StageManager::Update函数内部,我们将使用工厂来获取当前阶段。请注意,这个类与任何特定的M5Stage派生类完全解耦。这给了用户改变游戏设计的自由,包括阶段类型、阶段数量和阶段名称,而无需修改M5StageManager类。
实际上,这就是我们创建 Mach5 引擎的方式的目的。它可以被用于许多游戏项目,而无需更改引擎代码。以下是一个简化的M5StageManager::Update版本(省略了暂停/重新启动代码),显示了与阶段和工厂相关的代码:
void M5StageManager::Update(void)
{
float frameTime = 0.0f;
/*Get the Current stage*/
M5Stage* pCurrentStage = s_stageFactory.Build(s_currStage);
/*Call the initialize function*/
pStage->Init();
/*Keep going until the stage has changed or we are quitting. */
while ((s_currStage == s_nextStage) &&
!s_isQuitting &&
!s_isRestarting)
{
/*Our main game loop*/
s_timer.StartFrame();/*Save the start time of the frame*/
M5Input::Reset(frameTime);
M5App::ProcessMessages();
pStage->Update(frameTime);
M5ObjectManager::Update(frameTime);
M5Gfx::Update();
frameTime = s_timer.EndFrame();/*Get the total frame time*/
}
/*Make sure to Shut down the stage*/
pStage->Shutdown();
ChangeStage();
}
如你所见,M5StageManager与任何派生的M5Stage类完全解耦。这允许用户在开发过程中更改、添加或删除任何阶段,而无需修改M5StageManager类。这也允许M5StageManager和M5StageFactory类在另一个游戏中重用,缩短该项目的开发时间。
现在我们已经了解了动态工厂及其使用方法,一个重要的问题应该出现在你的脑海中:动态工厂有哪些好处?静态和动态工厂都能让我们解耦代码。既然它们都提供了这个好处,而静态工厂又更容易实现,我们为什么还要费心去研究动态工厂呢?提出这样的问题是始终一个好的主意。在这种情况下,我认为使用动态工厂而不是静态工厂有两个好处。
动态工厂的第一个好处是它是动态的,这意味着我们可以在运行时从文件中加载构建器,或者如果我们永远不会再次使用它(例如SplashStage),我们可以移除一个阶段。动态性允许我们在运行时替换构建器。例如,根据玩家选择的难度,我们可以替换敌人的难度组件。这些难度组件构建器的代码可以放入菜单中,而我们的游戏的其他部分就不再需要关心难度,各个级别只是以相同的方式创建敌人,无论是什么。
创建动态工厂的第二个且更为重要的好处将在下一步出现。由于我们已经成功创建了StageFactory,我们也应该为组件和游戏对象做同样的事情。在下一节中,我们将探讨如何创建这些工厂。
创建组件和对象工厂
现在我们已经构建了一个阶段工厂,构建一个组件工厂应该很容易。让我们看看组件和对象工厂会是什么样子:
//Component Factory
class M5ComponentFactory
{
public:
~M5ComponentFactory(void);
void AddBuilder(M5ComponentTypes type,
M5ComponentBuilder* builder);
void RemoveBuilder(M5ComponentTypes type);
M5Component* Build(M5ComponentTypes type);
void ClearBuilders(void);
private:
typedef std::unordered_map<M5ComponentTypes,
M5ComponentBuilder*> BuilderMap;
typedef BuilderMap::iterator MapItor;
BuilderMap m_builderMap;
};
//Object Factory
class M5ObjectFactory
{
public:
~ M5ObjectFactory (void);
void AddBuilder(M5ArcheTypes type,
M5ObjectBuilder* builder);
void RemoveBuilder(M5ArcheTypes type);
M5Object* Build(M5ArcheTypes type);
void ClearBuilders(void);
private:
typedef std::unordered_map< M5ArcheTypes,
M5ObjectBuilder *> BuilderMap;
typedef BuilderMap::iterator MapItor;
BuilderMap m_builderMap;
};
看那些类,你会注意到它们几乎与M5StageFactory类相同。唯一不同的是涉及的类型。我们使用M5ComponentTypes或M5ArcheTypes而不是M5StageTypes。我们使用M5ComponentBuilder或M5ObjectBuilder而不是M5StageBuilder。最后,我们返回M5Stage*而不是M5Component*或M5Object*的Build方法。
如果我们要实现这些类,代码也将完全相同。你可能会认为M5ObjectFactory会有所不同,因为M5Object不是继承层次结构的一部分,但实际上这并不重要。尽管派生类构建器都在做不同的工作,但它们总是只返回一个指针类型。构建器可能不同,但返回类型并不相同。
模板工厂
由于我们需要使用不同的类型创建相同算法的不同版本,我们应该再次利用 C++模板。这将允许我们一次性编写代码,并为我们需要的任何工厂类型重用代码。
首先,我们需要提取出不同的类型。如果你查看这三个类,你会看到三种类型是不同的。枚举类型、构建器类型和Build方法的返回类型在这三个类中都是不同的。如果我们将这些模板参数化,我们可以重用相同的代码,而不是重新创建相同的类三次。以下是我们应该如何重构我们的代码:
//M5Factory.h
template<typename EnumType,
typename BuilderType,
typename ReturnType>
class M5Factory
{
public:
~M5Factory(void);
void AddBuilder(EnumType type, BuilderType* pBuilder);
void RemoveBuilder(EnumType type);
ReturnType* Build(EnumType type);
void ClearBuilders(void);
private:
typedef std::unordered_map<EnumType, BuilderType*> BuilderMap;
typedef typename BuilderMap::iterator MapItor;
BuilderMap m_builderMap;
};
注意,我们的类现在是一个具有三个模板参数的模板类,分别是EnumType、BuilderType和ReturnType。我们使用模板参数而不是任何特定的类型,如M5StageTypes。一个让许多人感到困惑的变化是这一行:
typedef typename BuilderMap::iterator MapItor;
在原始的非模板M5StageFactory类中,编译器能够查看代码BuilderMap::iterator并确定迭代器是BuilderMap内部的一个类型。现在我们有了模板类,编译器无法确定BuilderMap::iterator是一个变量还是一个类型,因此我们需要通过使用typename关键字来帮助编译器,表明这是一个类型。
由于我们的工厂现在是一个模板类,因此我们应该再次将所有函数实现放入头文件中。此外,每个实现都必须标记为模板函数。以下是一个Build方法的示例:
//M5Factory.h
template<typename EnumType,
typename BuilderType,
typename ReturnType>
ReturnType* M5Factory<EnumType,
BuilderType,
ReturnType>::Build(EnumType type)
{
MapItor itor = m_builderMap.find(type);
M5DEBUG_ASSERT(itor != m_builderMap.end(),
"Trying to use a Builder that doesn't exist");
return itor->second->Build();
}
除了函数签名的变化外,Build函数完全相同。这也适用于AddBuilder、RemoveBuilder以及类中的所有其他函数。正如我所说的,通过将动态工厂做成模板类,我们可以为我们的阶段工厂、组件工厂和对象工厂重用相同的代码。既然如此,我们就不需要在制作模板工厂上浪费时间了。然而,我们仍然需要了解如何使用这个新类。让我们看看我们的M5StageFactory类:
class M5StageManager
{
public:
//Same as before
private:
//static M5StageFactory s_stageFactory; //Our Old Code
static M5Factory<M5StageTypes,
M5StageBuilder,
M5Stage> s_stageFactory;//Our new code
};
这是我们对M5StageFactory需要做的唯一更改。其他所有内容都将按原样工作。好事是,一旦我们完成了模板工厂,使用组件工厂就变得简单了。以下是如何在我们的M5ObjectManager类中使用我们的组件工厂和对象工厂:
class M5ObjectManager
{
public:
//See M5ObjectManager.h for details
private:
static M5Factory<M5ComponentTypes,
M5ComponentBuilder,
M5Component> s_componentFactory;
static M5Factory<M5ArcheTypes,
M5ObjectBuilder,
M5Object> s_ObjectFactory;
};
一旦我们创建了模板版本,重用代码就变得简单了。我们应该首先创建它,但大多数程序员在编写初始代码之后才难以考虑如何重用类。我认为先创建阶段工厂,然后重构代码成模板类更容易、更自然。
在使用我们的工厂时,还有一件更重要的事情需要考虑:如何添加我们的构建器。目前,让我们只考虑M5StageManager内部的M5Factory,因为我们已经看到了那段代码。在我们的代码中,我们必须实例化我们的派生构建器,以便我们可以将它们添加到M5StageManager中。例如,我们可能需要一个像这样的函数:
#include "M5StageManager.h"
#include "M5StageTypes.h"
#include "M5StageBuilder.h"
#include "GamePlayStage.h" //Example Stage
#include "SplashStage.h" //Example Stage
void RegisterStages(void)
{
M5StageManager::AddStage(ST_GamePlayStage,
new M5StageTBuilder< GamePlayStage >() );
M5StageManager::AddStage(ST_SplashStage,
new M5StageTBuilder< SplashStage >() );
}
如你所见,这个函数依赖于我们游戏中的所有阶段,并且随着我们设计的变化可能会发生变化。不幸的是,这是我们能够解耦代码的极限。在某个时候,我们需要实例化派生类。尽管这是必要的,但稍后我们将探讨如何最小化维护此代码所需的工作。在 Mach5 引擎的情况下,这段代码在编译代码之前使用 Windows 批处理文件自动生成。通过自动生成我们的文件,我们减少了忘记添加阶段的可能性,并在代码更改时最小化了工作量。
架构与过度架构
过度架构是指花费时间规划以及编写包含完全不需要且最终未使用的功能的代码。由于每个项目都有截止日期,过度架构意味着浪费了本可以用来编写将被使用的代码的时间。
在我们学习设计模式的过程中,我们不仅想知道如何使用它们,还要了解何时不应使用它们。当你正在处理一个项目时,你必须始终在编写灵活的代码和按时完成项目之间找到平衡。编写灵活、可重用的代码通常需要更多时间,因此你必须考虑是否值得花费额外的时间来编写那段代码。
花时间创建终极图形引擎或创建可以与 Unreal 或 Unity 相媲美的内容创建工具是非常好的。然而,如果你努力编写完美、灵活、100%可重用的代码,你可能永远无法完成你的游戏。你可能会编写一个很棒的粒子系统,而你的设计师可能只使用了 10%的功能。这就是为什么许多公司最初选择使用预制的引擎。那些公司不想在创建工具上花费时间和金钱。他们想花时间制作一个有趣的游戏。
这种情况的反面同样糟糕。我们不希望编写在引入变更时就会崩溃的代码,或者再次使用变得不可能的代码。我们都可以想象,如果整个游戏都是用标准的main函数编写的,代码会多么丑陋。我们可能会嘲笑有人这样做,同时又会因为用大量 if/else 链而不是使用有限状态机来硬编码行为而感到好笑。
在这两种极端之间找到平衡是困难的。我已经提到,除了编写初始代码之外,还有其他因素需要考虑。这包括测试和调试代码所需的时间,以及如果发生变更时修改代码所需的时间。
确定编写灵活的代码是否值得花费时间,还包括确定该代码可能发生变化的概率。这就是为什么我们使用单例类作为我们的核心引擎。这些在项目期间不太可能发生变化。当然,如果我们需要支持多个图形 API、多个平台,甚至多线程环境,我们可能会做出不同的决定。这也是为什么使用组件对象模型和有限状态机非常有用,因为我们的游戏对象及其行为可能会不断变化。
在这种情况下,我们需要在静态工厂和动态工厂之间做出选择。静态工厂编写和使用的非常简单。由于它非常简单,测试和调试应该很容易。它可能会发生变化,但这些变化也应该很容易。然而,在使用静态工厂时,我们必须为我们的游戏中的至少三种不同类型编写、测试、调试和维护代码:阶段、组件和对象。这些在开发周期中会经常变化。每次发生变化时,你都需要回去修改这些函数。
模板化动态工厂的实现稍微困难一些,尤其是如果你不太熟悉使用模板。然而,使用模板化动态工厂的主要好处是我们只需编写一次代码,就可以用于阶段、组件和对象。此外,我们还有在运行时添加、删除或更改工厂中项目的能力。正如我提到的,这可能意味着根据难度更改原型构建器,以创建相同原型的更难版本,而无需新的枚举值。最后,我们还有在另一个项目中再次使用此代码的选项,如果我们坚持使用静态工厂,这不太可能。
最后,包含模板化动态工厂的M5Factory.h文件大约有 125 行代码,其中可能有 30%是注释和空白。这可能有点困难,但我认为这并不困难到有人会偏好静态工厂。
摘要
在本章中,我们大量关注了代码解耦。由于我们的游戏设计很可能发生变化,我们希望确保我们的高级模块不依赖于派生阶段或组件。这就是为什么我们应该遵循依赖倒置原则,它指出以下内容:
-
高级模块不应依赖于低级模块。两者都应依赖于抽象。
-
抽象不应依赖于细节。细节应依赖于抽象。
简而言之,这意味着我们所有的代码都应该围绕接口构建。我们以我们的M5StageManager为例,它不依赖于派生的M5Stage类。由于我们想要避免这种类依赖,我们了解到我们也应该避免硬编码,包括使用对new操作符的调用。为了避免直接调用new操作符,我们学习了三种创建工厂的方法。
第一种方法是经典的四人帮工厂方法,它说我们应该创建一个类层次结构,每个类都能实例化一个单一的类。这种方法帮助我们达到了最终解决方案,但还不够好,因为我们想要能够通过字符串或枚举来选择要实例化的派生类。
我们学习的第二种方法是静态工厂方法,它使用简单的全局或静态函数和 switch 语句来允许我们选择我们想要的派生类。这对于我们的需求来说非常好,但我们决定更进一步,创建一个更灵活、可重用的工厂。
最后,我们学习了动态工厂,特别是动态工厂的模板化版本,它结合了经典的工厂方法和静态工厂。最好的部分是,由于我们正在使用 C++模板的强大功能,我们可以重用代码以用于阶段、组件以及对象。
在本章中,尤其是在最后一节,我们讨论了在编写灵活的代码和过度设计之间取得平衡的问题。当然,学习设计模式的一个强有力的理由是学习如何编写优秀的可重用代码,但我们始终想要确保它符合项目需求,并且能够在项目截止日期内完成。
这本书的目标是帮助你理解在游戏中何时何地应用这些模式。我们已经知道如何使用组件和有限状态机创建灵活的游戏对象。现在我们了解了动态工厂,我们已经将我们的舞台和组件创建与核心引擎解耦,使一切更加可重用。
然而,最终的目标是使事物足够灵活,以至于可以通过文本文件或工具进行更改,而无需重新编译任何内容。这就是我们接下来将要学习如何做到的。
第六章:使用原型模式创建对象
在上一章中,我们看到了如何使用动态工厂帮助我们解耦高级模块,例如 M5StageManager 或 M5ObjectManager,以及我们派生出的 M5Stage 或 M5Component 类的实现细节。我们通过将这些依赖项推入将被动态工厂使用的派生构建器类中来实现这一点。这使我们能够在不修改高级模块的情况下自由地更改派生的阶段和组件类。由于我们不需要为每个阶段和组件创建派生类构建器,C++ 模板类使得使用动态工厂变得非常容易。
然而,我们必须为每个我们想要的 M5Object 类型创建一个构建器,因为它们将包含一组每个对象独特的组件。不幸的是,这些构建器可能需要频繁更改,因为我们进行游戏测试、平衡和修改游戏设计。每次这些构建器更改时,游戏都需要重新编译。
目标是让我们的游戏对象类型完全在文件中定义。这将使游戏设计师能够在不接触 C++ 代码或重新编译的情况下测试、调整和平衡游戏。当然,所有这些都可以在关卡编辑器工具中完成,这个工具也可以提供给玩家,让他们能够创建额外的游戏内容。
在本章中,我们将探讨原型模式以及它如何帮助我们完全在文本文件中定义对象。我们将首先查看该模式的一个简单示例,然后查看 Mach5 引擎以及 M5ObjectManager 如何具体使用该模式从文件中读取游戏对象定义。在这个过程中,我们将查看一些有助于我们编写更好、更安全代码的 C++ 语言特性。
你的目标
下面是我们将在本章中涵盖的主题以及你的任务概述:
-
学习使用工厂为游戏对象带来的麻烦
-
实现 Prototype 模式
-
学习 Mach5 引擎如何使用原型模式
-
在 Mach5 引擎中实现组件
-
学习如何在文件中完全定义对象
使用工厂为游戏对象带来的麻烦
在第五章,“通过工厂方法模式解耦代码”中,我们学习了如何使用动态工厂来解耦我们的阶段、组件和对象与高级模块。我们通过将每个派生类的依赖项放入一个单独的构建器类中而不是高级模块中来实现这一点。让我们看看创建派生类型阶段构建器的例子:
//SplashStageBuilder.h--------------------
#include "M5StageBuilder.h"
class SplashStageBuilder: public M5StageBuilder
{
public:
virtual M5Stage* Build(void);
};
//SplashStageBuilder.cpp--------------------
#include "SplashStageBuilder.h"
#include "SplashStage.h"
M5Stage* SplashStageBuilder::Build(void)
{
return new SplashStage();
}
我们这样做的原因是,SplashStage类的更改只会影响这个文件,而不是像M5StageManager这样的文件。这意味着对派生阶段或阶段构建器类的任何更改都不会破坏其他代码,因为其他代码只会使用M5Stage指针。对这个类的更改仍然可能破坏其他代码,尤其是如果这个阶段完全从游戏中移除。然而,通过最小化依赖关系,我们减少了未来需要更改其他代码的可能性。
在 Mach5 引擎中,M5Stage派生类只需要一个默认构造函数。这是为了尽可能简化构建器类。每个阶段类将从文件中读取其所需的数据。读取哪个文件的逻辑被写入构造函数中。同样简单的默认构造函数设计也用于M5Component派生类。这意味着我们不需要为每个阶段或组件创建构建器类,而是可以使用 C++模板的力量:
//M5StageBuilder.h
class M5StageBuilder
{
public:
virtual ~M5StageBuilder(void) {} //empty virtual destructor
virtual M5Stage* Build(void) = 0;
};
template <typename T>
class M5StageTBuilder: public M5StageBuilder
{
public:
virtual M5Stage* Build(void);
};
template <typename T>
M5Stage* M5StageTBuilder<T>::Build(void)
{
return new T();
}
通过使用 C++模板,我们可以减少需要手动创建的小类数量,同时仍然获得工厂的解耦优势。当然,总有可能一些阶段或组件需要更复杂的构造函数或构建器类。在这种情况下,当需要时,我们可以轻松地创建所需的类。不幸的是,我们无法选择在对象类型上使用模板。
使用构建器与对象一起使用
我们的游戏对象大多是组件的集合。每个对象类型将根据设计师的决定拥有不同的组件和组件数据。随着开发的进行,这些集合很可能会发生变化。记住,尽管每个单独的组件在工厂中都有一个构建器,但对象需要以某种方式实例化这些单独的组件。让我们看看使用构建器为Player和Raider对象创建简化的示例:
//PlayerBuilder.h--------------------
#include "M5ObjectBuilder.h"
class PlayerBuilder: public M5ObjectBuilder
{
public:
virtual M5Object* Build(void);
};
//PlayerBuilder.cpp--------------------
#include "PlayerBuilder.h"
#include "M5Object.h"
#include "M5ObjectManager.h"
M5Object* PlayerBuilder::Build(void)
{
M5Object* pObj = new M5Object;
//Build and set Gfx component for player
GfxComponent* pGfx =
M5ObjectManager::CreateComponent(CT_GfxComponent);
pGfx->SetTexture("playerShip.tga");
pGfx->SetDrawSpace(DS_WORLD);
//Build and set input component for player
PlayerInputComponent* pPI =
M5ObjectManager::CreateComponent(CT_PlayerInputComponent);
pPI->SetSpeed(100);
pPI->SetRotationSpeed(10);
pObj->AddComponent(pGfx);
pObj->AddComponent(pPI);
//...add more components here
return pObj;
}
//RaiderBuilder.h--------------------
#include "M5ObjectBuilder.h"
class RaiderBuilder: public M5ObjectBuilder
{
public:
virtual M5Object* Build(void);
};
// RaiderBuilder.cpp--------------------
#include "RaiderBuilder.h"
#include "M5Object.h"
#include "M5ObjectManager.h"
M5Object* RaiderBuilder::Build(void)
{
M5Object* pObj = new M5Object;
//Build and set Gfx component for Raider
GfxComponent* pGfx =
M5ObjectManager::CreateComponent(CT_GfxComponent);
pGfx->SetTexture("enemyBlack3.tga");
pGfx->SetDrawSpace(DS_WORLD);
//Build and set behavior for Raider
ChasePlayerComponent* pCP =
M5ObjectManager::CreateComponent(CT_ChasePlayerComponent);
pPI->SetSpeed(40);
pObj->AddComponent(pGfx);
pObj->AddComponent(pCP);
return pObj;
}
在这两个简单的示例中,我们可以看到为什么每个对象构建器需要不同。每个特定的对象类型将有一组独特的组件。这些示例每个只使用两个组件,但我们还没有考虑我们的物理碰撞器、任何武器组件或额外的行为。即使在这些简短的示例中,这两种对象类型都使用了GfxComponent,但由于数据(如纹理)的不同,我们需要不同的代码。由于只有一个对象类,而不是派生类层次结构,因此无法让对象管理必要组件的创建。
为了解决这个问题,我们可能需要为每个对象类型提供一个构建器类,或者提供一个具有switch语句和每个对象类型案例的单个对象构建器。这两种解决方案的问题在于组件列表和每个组件的数据很可能会经常变化。现在,我们不再担心高级模块和依赖关系,而是有两个新的问题需要担心。
第一个问题是编译时间恒定且可能很长。我们已经知道,随着开发工作的继续,游戏设计将会发生变化。这可能意味着不仅需要更改组件,还需要更改这些组件内的值。在某个阶段,尤其是在开发后期,游戏可能已经完成,但并不完全平衡。在这个阶段,游戏对象及其相应的代码将不断进行调整。健康、伤害、速度和其他属性的更改可能会频繁发生,导致代码需要重新编译。
影响项目编译时间长短的因素有很多。在最佳情况下,我们可能只需更改一个文件,重新编译的速度会非常快。然而,即使只有从 10 到 11 的速度值这样的微小变化,如果唯一改变的就是这一点,那么 10 秒的编译时间也可能变得令人烦恼。平衡单个单位这样简单的事情可能需要一整天的时间,而且在其他单位平衡后,还需要进行额外的调整。我们的目标是将修改对象和查看修改结果的速度尽可能快。
第二个问题是关于谁负责进行这些平衡更改。一种情况是程序员负责,因为所有的平衡更改都是代码更改。这是一个不好的情况,因为我们已经提到,平衡可能需要很长时间,现在设计师和程序员都参与了进来。设计师可能无法正确地向程序员解释期望的结果,因此他们必须坐在一起调整和编译,反复进行。如果设计师可以自由地进行平衡,而程序员可以自由地修复错误或优化所需的代码,那就更好了。
另一种情况是设计师负责对代码进行平衡更改。这里的问题是设计师可能对引擎或编程语言不太熟悉。完全有可能设计师根本没有任何编程经验,因此他们可能不熟悉集成开发环境(IDE)或版本控制系统。引入错误或破坏项目的可能性非常高。
解决方案 - 从文件读取
我们解决这两个问题的方案是将所有硬编码的值移动到文本或二进制文件中,这些文件将在运行时由引擎读取。由于我们在游戏中使用的是基于组件的系统,这意味着我们可以在文件中定义哪些组件属于一个对象。这种方法的优点是,这些文件可以通过关卡编辑器或其他内容创建工具由设计师创建。目前,我们不会关注这些文件是如何创建的,而是关注它们是如何被读取到我们的引擎中,以及我们如何可以使用它们来替代对象构建器类。
既然我们已经决定在文件中定义我们的对象,我们就需要考虑我们的引擎何时会读取它们。针对我们的问题有几个不同的解决方案。第一个解决方案是每次我们需要一个新的 Raider、Bullet 或 Asteroid 时,让我们的构建器简单地读取一个文件。这是一个常见的初步想法,但这是一个非常糟糕的想法。与访问已经存在于 RAM 中的数据相比,从硬盘读取文件非常慢。根据因素的不同,它可能慢 10,000 到 50,000 倍或更多。这就像一个正常的 5 分钟车程需要 173 天 14 小时 40 分钟。如果你从 1 月 1 日开始驾驶,你会在 6 月 21 日到达商店。
这并不意味着我们永远不应该从硬盘读取文件。这仅仅意味着我们需要更加策略性地处理它。使用这个解决方案意味着我们可以在同一帧中多次读取完全相同的文件。这就像进行半年的旅行去商店买鸡蛋,回家后立即又回到商店买牛奶。我们不应该多次读取相同的文件,而应该只读取一次并将数据存储到 RAM 中。由于这个过程非常慢,我们应该避免在游戏过程中读取和写入文件,而应该尽可能在加载时间从文件中加载更多内容。
在大规模游戏中,例如Rockstar 的《侠盗猎车手 3》,需要一次性保存在 RAM 中的数据太多。这类游戏会不断从不在内存中的文件读取数据,并释放不再使用的资源。这是通过在单独的线程上读取文件来实现的,这样就不会暂停或减慢主游戏玩法。这个文件流过程涉及大量工作,包括确保当玩家需要时关键游戏元素已经在内存中,以免影响游戏玩法。然而,即使像这样流式传输数据的游戏,一旦数据在内存中,也不会浪费时间重复读取文件。
现在,我们不会担心线程。因此,对我们来说,一个更常见的解决方案是在加载屏幕期间读取文件一次,创建我们需要的对象类型,然后只需在需要新实例时复制这些对象。这将使我们能够专注于模式,同时解决与此问题相关的关键问题,而无需担心多线程架构的困难。
即使不使用线程,我们仍然有问题。我们不知道组件的类型,如何复制包含组件的对象?通常,在创建新对象时,我们需要 new 关键字,这意味着我们还需要调用构造函数。当然,我们有一个用于类型的enum,这意味着我们可以使用 switch 语句并调用正确的复制构造函数。然而,我们已经知道 switch 语句可能很难维护,应该避免使用。
组件工厂将创建正确类型的新组件,但我们仍然需要编写一个复制函数,以便在创建后能够复制数据。如果有一个函数可以根据对象的类型构建和复制数据,那就太好了。我们需要一个像虚拟函数一样工作的构造函数。
原型模式解释
原型模式为我们提供了一种复制类的方法,而无需知道该类的实际类型。这通常被称为虚拟构造函数,因为我们可以使用它来创建和复制派生类,同时只使用基类指针或引用。当与对象层次结构一起使用时,这种模式最为强大,但它不仅需要与虚拟函数一起使用。原型模式的另一个目的是简单地创建一个原型(或原型)实例的对象,并使用它进行复制。
假设我们正在创建一个关卡编辑器。在工具的中间,我们会有一张关卡地图。在这里,我们可以放置瓦片、升级、敌人和玩家。在地图的旁边,我们会有一系列用于我们游戏中的对象和瓦片,这些都可以放置在地图上。以下截图可以展示这一点:

图 6-1 - 简单关卡编辑器的示例
由于我们追求代码的整洁,我们在处理绘图、处理点击和操作侧边对象以及定义我们特定对象类型的代码部分之间有明确的分离。然而,当我们从侧边点击并拖动一个对象时,我们会创建一个新实例的对象,并在鼠标位置绘制它,最终在用户放置它的地图上绘制。根据依赖倒置原则,我们知道我们不希望我们的高级模块依赖于我们的低级对象。相反,它们都应该依赖于抽象。
原型与原型的对比
在我们更深入地了解这个模式之前,我们应该稍微谈谈一下词汇选择。在 Mach5 引擎中,我们将读取和创建的文件以及我们将使用的枚举被称为原型,而不是原型。原型是一个完美、不变、理想的事物的例子。原型通常是某个东西的早期版本,通常是未完善的,后来的版本可以偏离。虽然这两个词都可以正确使用,但作者使用“原型”一词来指代文件中的对象定义,尽管这个模式被称为原型模式。
虚拟构造函数
原型模式很简单。它涉及提供一个对象,你可能想要复制它的Clone方法,并让对象了解如何执行复制。实际的方法名称并不重要。"Clone"只是其中一个常见的例子。当与对象层次结构一起使用时,这最有用,其中你想要复制一个对象,但不知道你持有的派生对象的类型。只需在层次结构的接口中添加一个Clone方法,并让派生类各自实现该方法。让我们从一个简单的例子开始:

在下面的代码中,我们有我们的接口对象Shape。在这个例子中,我们只讨论简单的形状。由于这个类将用作接口,我们必须将析构函数标记为虚拟的,以便在删除派生类时调用正确的析构函数。接下来,我们有两个纯虚函数。Draw方法可以是任何我们需要虚拟行为的操作。在这个简单的情况下,我们只是使用打印语句而不是在屏幕上绘制形状。Clone方法将是我们的虚拟构造函数。这个方法将知道如何复制自身并返回一个新实例:
class Shape
{
public:
virtual ~Shape(void) {}//empty base class constructor
virtual void Draw(void) const = 0;
virtual Shape* Clone(void) const = 0;
};
现在,让我们看看派生类的示例:
class Circle : public Shape
{
public:
virtual void Draw(void) const
{
std::cout << "I'm a Circle" << std::endl;
}
virtual Shape* Clone(void) const
{
return new Circle(*this);
}
};
class Square : public Shape
{
public:
virtual void Draw(void) const
{
std::cout << "I'm a Square" << std::endl;
}
virtual Shape* Clone(void) const
{
return new Square(*this);
}
};
class Triangle : public Shape
{
public:
virtual void Draw(void) const
{
std::cout << "I'm a Triangle" << std::endl;
}
virtual Shape* Clone(void) const
{
return new Triangle(*this);
}
};
当然,我们的派生类知道如何绘制自身。为了保持简单,Draw方法只是打印到控制台。这里的重要部分是每个Draw方法都有不同的行为;在这种情况下,打印一个硬编码的字符串。Clone方法才是真正的魔法所在--每个方法都返回自身的一个新实例。具体来说,它们正在调用自己的拷贝构造函数。这将允许客户端持有任何Shape的指针,并获得正确的派生类型的副本,而无需知道或关心要调用哪个构造函数。让我们通过代码示例来看看这一点:
int main(void)
{
//seed the RNG
std::srand(static_cast<unsigned>(time(0)));
//Create my shapes
const int MAX = 3;
Shape* shapes[MAX] = { new Circle(),
new Square(),
new Triangle() };
for (int i = 0; i < MAX * 2; ++i)
{
Shape* pCopy = shapes[std::rand() % MAX]->Clone();
copy->Draw();
delete pCopy;
}
//make sure to delete my original shapes
for (int i = 0; i < MAX; ++i)
delete shapes[i];
return 0;
}
前几行只是初始化随机数生成器和形状数组。在数组中,你可以看到我们创建了一个新的Circle、Square和Triangle实例。这些将成为我们要克隆的原型Shapes。
下一个部分是一个循环,用于展示Clone方法的工作。我们使用数组的随机索引来克隆一个对象。由于它是随机的,我们无法知道哪个Shape将被克隆。这模拟了用户的随机点击。一旦我们克隆了Shape,我们就可以自由地调用Draw或其他任何接口方法。循环结束时,我们删除了克隆的Shape,但当然,这不会删除数组中的原型Shape,因为它是对象的一个副本。循环之后,我们遍历原型数组,并删除这些形状。
下面的输出是前面代码的结果:
I'm a Triangle
I'm a Square
I'm a Square
I'm a Circle
I'm a Circle
I'm a Square
构造函数的问题
现在我们已经对虚拟构造函数和原型模式有了一些了解,让我们看看我们正在试图解决的具体问题。
要理解构造问题,我们首先需要理解类和该类的对象之间的区别。类是程序员创建的。它们是程序使用的代码模板或配方,用于创建对象。在 C++中,我们无法在运行时创建类。我们没有在程序运行时引入新代码的方法。
这是因为 C++是一种静态类型语言。这意味着如果编译时无法在该类型上执行操作,则语言会尝试防止这些操作。例如,我们不能将一个浮点数除以一个void*,因为 C++编译器会在编译时检查操作是否有意义,如果无意义,则会报错。
C++中的这种静态类型是为什么我们必须为每个变量声明一个类型。这也是为什么在这种情况下我们需要指定构造函数的原因:
Base* p = new Derived;
在这种情况下,编译器必须知道我们试图创建的类。不幸的是,在 C++中,类没有一等公民地位。这意味着我们不能将类作为函数的参数传递或将其用作返回值。我们不能复制一个类,将其保存在变量中,或在运行时创建它。一些语言确实具有这些功能。以下是在 C++中如果类具有一等公民地位时你可以做的事情的例子:
//This isn't real C++ code
Shape* CreateShape(class theShape)
{
Shape* pShape = new theShape;
return pShape;
}
虽然这可能很有用,但我们将会以一些类型安全性为代价来换取灵活性。C++编译器执行的静态类型检查有机会在问题成为运行时错误之前捕获它们。这是好事。我们应该享受 C++提供的类型检查。我们也应该认识到它对我们的代码灵活性有何影响。
尽管 C++是一种静态类型语言,但我们有方法绕过这个问题。其中一种方法是我们在上章中创建的工厂。我们被迫自己编写工厂,但我们仍然可以选择在运行时创建哪个类,同时获得所有其他类和类型的静态类型的好处。工厂只是我们避免类没有一等公民地位刚性的方法之一。虚拟构造函数是另一种方法。
虚拟构造函数的好处
使用虚拟构造函数可以非常强大。最大的好处是现在我们可以将类视为一等公民。我们可以复制一个对象而无需知道其确切类型。我们可以在我们的级别编辑器示例中使用原型模式,也可以在我们的游戏中使用它。任何时候我们需要复制而不知道确切类型时,我们都可以使用这个模式。正如我们之前所说,C++是一种静态类型语言,这意味着编译器将确保我们在编译时使用正确的类型。这种静态类型检查帮助我们编写更安全的代码。
通过使用动态工厂和虚拟构造函数,我们绕过了这种类型检查。编译器仍然在我们处理的指针上进行类型检查,但我们是在运行时选择派生类。这可能导致我们如果在克隆时混淆了类型,会引发难以找到的 bug。这并不意味着我们不应该使用这些模式;只是了解我们在灵活性上做出了一些牺牲是好的。
我们不需要知道类型
正如我们所说的,使用原型模式的最大好处之一是我们可以在不知道类型的情况下创建副本。这意味着我们可以在不关心涉及到的派生类的情况下创建函数参数或函数返回类型的副本。这也意味着我们可以与另一个类或方法共享指针,我们不需要关心类型是否被修改。
在下面的例子中,我们有一个具有生成特定类型形状能力的SpawnShape类。通过使用原型模式,该类不需要关心它正在生成什么类型。构造函数接受一个指向某个形状的指针,然后只需要调用Clone方法。如果基类指针指向一个Circle,则将创建一个圆。然而,如果我们有一个指向Triangle的指针,则将创建一个三角形。以下是一个展示原型模式如何工作的示例:
class ShapeSpawner
{
public:
ShapeSpawner (Shape* pShape, float maxTime):
m_pToSpawn(pShape),
m_spawnTime(0.f),
m_maxSpawnTime(maxTime)
{
}
void Update(float dt)
{
m_spawnTime += dt;
if(m_spawnTime > m_maxSpawnTime)
{
//The class doesn't care what type it is cloning
Shape* pClone = m_pToSpawn->Clone();
//...Register the clone somehow
//Reset timer
m_spawnTime = 0;
}
}
private:
Shape* m_pToSpawn;
float m_spawnTime;
float m_maxSpawnTime;
};
我们的SpawnShape类不关心它是在生成Circle、Square还是Triangle,或者我们以后可能创建的任何新形状。它可以复制形状而不需要知道形状的真实类型。如果我们添加一个公共的SetShape方法,我们甚至可以在运行时更改生成的类型。与只能生成Circles的更严格示例相比:
class CircleSpawner
{
public:
CircleSpawner (Circle* pCircle, float maxTime):
m_pToSpawn(pCircle),
m_spawnTime(0.f),
m_maxSpawnTime(maxTime)
{
}
void Update(float dt)
{
m_spawnTime += dt;
if(m_spawnTime > m_maxSpawnTime)
{
//Use copy constructor
Circle* pClone = new Circle(*m_pToSpawn);
//...Register the clone somehow
//Reset timer
m_spawnTime = 0;
}
}
private:
Circle* m_pToSpawn;
float m_spawnTime;
float m_maxSpawnTime;
};
在第二个例子(不使用原型模式)中,我们被迫使用派生类的复制构造函数,在这种情况下是Circle。如果我们想生成Square或Triangle,我们需要创建一个SquareSpawner或TriangleSpawner。这会产生大量的重复代码。如果我们添加更多的形状,情况可能会变得更糟。通过使用原型模式,我们可以减少所需的类数量。
无需子类化
类的减少是使用虚拟构造函数的另一个重大好处。在上面的例子中,我们只需要一个SpawnShape,而不是复制我们的生成类或创建派生版本。考虑我们之前看到的工厂构建类。我们被迫为每个新的M5Component和M5Stage创建一个抽象基类和派生类。C++模板帮助我们自动生成代码,但代码仍然存在。
通过使用虚拟构造函数,我们不需要为每个M5Stage、M5Component、Shape或其他继承层次结构创建派生构建类。我们可以让对象自己复制。这意味着我们应该移除我们的工厂并始终使用原型模式吗?这取决于。
记住,在使用原型模式时,我们必须首先实例化一个对象,然后才能进行克隆。这对于形状或组件来说是可以的,因为这些类型非常小。然而,M5Stage 派生类可能非常大,它们也可能引起副作用。这意味着阶段的构造函数可能会向 M5ObjectManager 添加对象,或者加载纹理或其他大型资源。
由于使用 C++ 模板使得创建我们的构建器变得如此简单,我们可以继续在阶段和组件中使用它们。然而,我们希望避免为 M5Object 创建构建器,因为这些构建器在开发过程中很可能发生变化。通过使用虚拟构造函数并创建可以克隆自己的原型(或原型),我们的其余代码将不会受到类型更改的影响。
制作精确副本很容易
在这里,原型概念不需要与虚拟函数一起使用。例如,我们可能有一组对象,例如简单、困难和疯狂敌人,我们想要复制。这些对象可能是完全相同的类类型,但它们可能有非常不同的健康、速度和伤害值。在这种情况下,我们有我们想要从中复制的典型示例。当然,在这种情况下,我们也可以简单地使用复制构造函数,但在像 Mach5 引擎这样的情况下,复制构造函数可能不存在。
无论哪种方式,由于我们不需要创建派生构建器类,我们可以在运行时添加原型。以上面提到的简单、困难和疯狂敌人类型为例。我们可能只有一个文件定义了简单敌人的健康值为 50,例如。然后在运行时,我们可以创建一个健康值为 100 的困难敌人原型和一个健康值为 200 的疯狂敌人原型。我们总是可以简单地加倍每个困难和疯狂敌人的健康和伤害值,或者文件可以包含困难版本和疯狂版本的缩放因子。
另一个我们可能想要修改数据的例子是,如果我们有一个敌基地在设定时间后生成敌人(就像我们上面的 ShapeSpawner)。在这个例子中,基地可能会随着时间的推移增加对象的健康和伤害。因此,基地最初可能会创建健康值为 50 的敌人,但每次生成后,健康值会增加 5。所以,第二个敌人的健康值为 55。第三个敌人的健康值为 60。由于每个基地都有一个特定的实例,每个基地都会生成具有不同健康值的敌人。
对象也可以通过游戏内关卡编辑器进行修改。想象一下,在仅进行游戏测试时意识到敌人太容易被杀死的好处。使用原型模式和游戏内关卡编辑器,我们可以暂停游戏,编辑对象类型的属性,然后继续游戏。这种方法不需要程序员,也不需要重新编译时间。甚至不需要重新启动游戏。当然,同样的效果也可以通过单独的关卡编辑器或仅通过修改原型文件并在运行时重新加载文件来实现。在这些情况下,我们可以看到创建特定实例的副本非常简单且非常有用。
Mach5 中clone方法示例
到目前为止,我们已经看到了原型模式的简单实现示例。如果你认为这些示例很简单,那么你很幸运——它不会比这更难。我们还讨论了几种在游戏中使用对象实例和虚拟构造函数的方法。现在让我们看看 Mach 5 引擎如何在M5Component和M5Object类中使用原型模式。由于M5Object类使用了M5Component Clone方法,让我们先看看组件。
在第三章,“使用组件对象模型改进装饰器模式”中,我们检查了M5Component类中的几乎所有方法和成员变量。然而,我们没有讨论的方法是Clone方法:
//! M5Component.h
class M5Component
{
public:
//! virtual constructor for M5Component, must override
virtual M5Component* Clone(void) const = 0;
//The rest of the class is the same as before
};
如你所见,M5Component类实现了一个纯虚Clone方法,就像我们在上面的Shape类中看到的那样。由于M5Component类仅用作抽象基类,我们不希望为克隆提供任何默认行为。克隆只对派生类有意义。这部分组件再次展示出来,这样我们就可以理解重载此方法的接口应该是什么。
Gfx 和碰撞组件
现在我们已经看到了接口,让我们看看两个非常重要的组件。这些组件之所以重要,是因为它们允许游戏对象与我们的引擎中的两个其他核心部分进行交互,即图形和物理。
我们首先来看的是GfxComponent类。这个类允许游戏对象在游戏中有一个视觉表示。它包含绘制游戏中物体所需的最基本信息:
//GfxComponent.h
enum DrawSpace
{
DS_WORLD,
DS_HUD
};
class GfxComponent : public M5Component
{
public:
GfxComponent(void);
~GfxComponent(void);
void Draw(void) const;
virtual void Update(float dt);
virtual GfxComponent* Clone(void) const;
virtual void FromFile(M5IniFile& iniFile);
void SetTextureID(int id);
void SetDrawSpace(DrawSpace drawSpace);
private:
int m_textureID; //!< Texture id loaded from graphics.
DrawSpace m_drawSpace; //!The space to draw in
};
我们绘制一个物体所需的两条信息是绘制哪种纹理以及绘制在哪个空间中。当然,我们需要一个纹理来绘制,但绘制空间可能有点令人困惑。它只是一个enum,告诉我们应该使用哪种图形投影类型与物体一起使用。目前,只需知道 HUD 绘制空间始终位于顶部,不受相机移动或相机缩放的影响即可。当然,可能还有更多数据,例如纹理颜色和纹理坐标。如果我们想的话,这些可以在派生类中添加。这里我们只是展示基本内容。
有几个函数用于设置这些值,以及之前提到过的 FromFile 函数。对于这个组件,Update 函数不做任何事情,因为没有需要更新的内容。Draw 函数将由图形引擎调用,使每个 M5Component 负责绘制自己。然而,本章最重要的函数仍然是 Clone:
GfxComponent* GfxComponent::Clone(void) const
{
//Allocates new object and copies data
GfxComponent* pNew = new GfxComponent;
pNew->m_pObj = m_pObj;
pNew->m_textureID = m_textureID;
pNew->m_drawSpace = m_drawSpace;
if (m_drawSpace == DrawSpace::DS_WORLD)
M5Gfx::RegisterWorldComponent(pNew);
else
M5Gfx::RegisterHudComponent(pNew);
return pNew;
}
在这个函数中,我们只是创建一个新的 GfxComponent 并从该对象复制相关数据到新创建的一个中。你没有看到的是,在 GfxComponent 构造函数中,通过调用 M5Component 组件构造函数来设置组件类型,当然,这也给这个组件赋予了一个唯一的 ID。我们最后做的事情是根据绘制空间将这个组件注册到图形引擎中。这个类在销毁时会自动取消注册:
GfxComponent::GfxComponent(void):
M5Component(CT_GfxComponent),
m_textureID(0),
m_drawSpace(DrawSpace::DS_WORLD)
{
}
GfxComponent::~GfxComponent(void)
{
M5Gfx::UnregisterComponent(this);
}
现在我们已经看到了 GfxComponent,让我们来看看所有物理碰撞器中最基本的。Mach5 引擎的 ColliderComponent 对于 2D 游戏来说尽可能简单。目前,它只关注圆形与圆形的碰撞。它很容易扩展以测试矩形碰撞:
//ColliderComponent.h
class ColliderComponent : public M5Component
{
public:
ColliderComponent(void);
~ColliderComponent(void);
virtual void Update(float dt);
virtual void FromFile(M5IniFile& iniFile);
virtual ColliderComponent* Clone(void) const;
void TestCollision(const ColliderComponent* pOther);
private:
float m_radius;
};
这个类与之前的类非常相似,因为它与游戏引擎的核心组件之一相连。就像所有组件一样,FromFile 必须被重载以从 .ini 文件中读取组件数据。Update 也必须被重载,但就像 GfxComponent 一样,在这个简单版本中它并不做任何事情。如果这个类使用了有向边界框,Update 函数就可以用来更新有向框的角点。TestCollision 函数也非常重要。它被 物理引擎 调用来测试这个对象是否与另一个对象发生碰撞。如果是,这两个对象将被添加到一个可以稍后解决的碰撞对列表中。再次强调,本章最重要的函数是 Clone:
ColliderComponent* ColliderComponent::Clone(void) const
{
ColliderComponent* pNew = new ColliderComponent;
pNew->m_radius = m_radius;
pNew->m_pObj = m_pObj;
M5Phy::RegisterCollider(pNew);
return pNew;
}
就像 GfxComponent 一样,这个组件首先创建一个自己的新版本,然后将重要信息复制到新组件中。在返回新组件之前,它首先将自己注册到物理引擎中。由于它已经注册,所以在销毁时必须取消注册,所以我们就在析构函数中这样做:
ColliderComponent::ColliderComponent(void) :
M5Component(CT_ColliderComponent), m_radius(0)
{
}
ColliderComponent::~ColliderComponent(void)
{
M5Phy::UnregisterCollider(this);
}
在这两个类中,有几个要点需要指出。首先,请注意我们并没有克隆m_type、m_id或isDead变量。这并不是必要的。类型是由M5Component基类中的构造函数设置的,当我们调用构造函数时。id也是在基类中设置的,但重要的是要指出,m_id的目的就是要唯一。如果我们也复制了id,那么它就不会发挥正确的作用。相反,我们正在复制其他重要的数据,但我们认识到这是一个独立的组件,而不仅仅是一个精确的副本。出于同样的原因,我们也没有复制isDead变量。我们正在创建一个类似于旧组件的新组件,但仍然是一个独立的组件。如果我们复制了isDead,那么这个组件在这个帧或下一个帧中就会被删除。
接下来,这两个类在克隆方法中而不是构造函数中将自己注册到引擎中。这是因为它们的预期用途。我们的对象管理器将在游戏开始时保存这些预先创建的原型组件,以便它们可以随时被克隆。我们不希望这些初始组件污染图形或物理引擎。
然而,我们假设一个对象正在被克隆,并且它也需要存在于游戏世界中,所以我们当时进行了注册。这似乎是最标准的克隆原因。对于用户来说,只关心克隆,而不是担心克隆、注册然后注销,会更好。如果用户希望进行非标准操作,他们可以在克隆后自由注销。
克隆对象
我们已经看到了几个M5Component派生类如何使用原型模式的例子。我们稍后会看看更多重要的例子,但现在让我们看看M5Object类是如何使用这些Clone方法的,以及M5Object本身是如何被克隆的。回想一下,M5Object也有一个Clone方法。尽管这个类不是层次结构的一部分,但它仍然可以使用原型模式的概念来创建可克隆的实例。以下是M5Object的Clone方法:
//M5Object.h
class M5Object//Everything is the same as before
{
public:
M5Object* Clone(void) const;
};
//M5Object.cpp
M5Object* M5Object::Clone(void) const
{
//create new object
M5Object* pClone = new M5Object(m_type);
//copy the internal data
pClone->pos = pos;
pClone->vel = vel;
pClone->scale = scale;
pClone->rotation = rotation;
pClone->rotationVel = rotationVel;
//clone all components
size_t size = m_components.size();
for (size_t i = 0; i < size; ++i)
{
M5Component* pComp = m_components[i]->Clone();
pClone->AddComponent(pComp);
}
return pClone;
}
当我们克隆时,重要的是要复制旧对象的所有相关数据。这不仅包括像位置和速度这样的东西,还包括所有组件。因此,我们在函数开始时创建了一个新实例,并将正确的类型传递给构造函数。这将设置m_type和m_id变量。记住,尽管我们在克隆,但我们想确保每个对象都有一个唯一的 ID。接下来,我们复制数据。就像组件一样,我们不需要复制isDead值。
最后,我们有一个循环来克隆当前对象中的所有组件。这展示了原型模式的强大之处。我们不需要知道每个组件的类型——我们只需要循环,调用 Clone 来创建副本,然后将这个副本添加到新创建的对象中。记住,AddComponent 方法会改变每个组件中的 m_pObj。这将确保所有组件都指向它们正确的所有者。
最后,原型模式很简单。每个组件的 Clone 方法都很简单,使用它们来克隆对象也很简单。甚至在使用这些克隆对象在 M5ObjectManager 中也很容易。我们将在接下来的几页中查看这一点,但首先让我们谈谈一些读者可能注意到的细节。第一个是我们没有在任何 Mach5 Clone 方法中使用拷贝构造函数,尽管我们在 Shape 示例中使用了它。下一个是 GfxComponent 和 CollideComponent 的返回类型与 M5Component 接口中的返回类型不同。
选择拷贝构造函数
正如我们之前所说的,并且正如你在上面的代码示例中看到的,我们在任何组件的 Clone 方法中都没有使用拷贝构造函数。我们也没有在 M5Object 的 Clone 方法中使用它们。默认情况下,类会由编译器生成拷贝构造函数和赋值运算符。在上面的 Shape 示例中,我们在 Clone 方法中使用了编译器生成的拷贝构造函数。
然而,在 Mach5 引擎中,有一个重要的选择需要考虑。拷贝构造函数应该如何处理 m_id 变量的值?记住,这个 ID 应该对每个对象和每个组件都是唯一的。然而,如果我们使用编译器生成的拷贝构造函数,包括 m_id 在内的每个变量都将按值复制。这意味着每次我们使用拷贝构造函数时,我们都会有两个具有完全相同标识符的对象。
有时候我们确实希望这样,例如,如果我们想要有一个对象向量而不是对象指针,例如。当使用标准向量(和其他容器)时,在向容器添加元素时会调用拷贝构造函数。如果我们添加一个对象,我们可能希望它复制标识符。也可能我们希望将对象在容器周围的位置移动。最可能的是,我们希望它保持相同的标识符。
然而,这并不是我们希望在 Clone 方法中看到的行为。我们希望每个克隆都是一个独立的实体,具有不同的唯一标识符。当然,我们可以编写自己的拷贝构造函数,并为每个新创建的对象或组件分配一个不同的标识符,就像我们在默认构造函数中所做的那样。不幸的是,如果我们使用标准容器这样做,我们会在它们内部每次调用拷贝构造函数时生成新的标识符。在这种情况下,标识符就不会与正确的对象或组件匹配。
在 Mach5 引擎中,我们使用指针容器而不是对象或组件容器,因此作者决定完全删除复制构造函数(和赋值运算符)。这将消除所有关于该过程的混淆。如果你想复制,你可以调用 Clone 方法,因为你不能调用复制构造函数。做出不同的决定是可以的。在另一个使用对象容器而不是指针容器的不同引擎中,可能会做出不同的决定。
在 Mach5 引擎中,我们通过将它们设置为私有来从对象中删除这些方法,这样就不能调用它们。在 C++ 11 中,你可以将它们标记为已删除,这样编译器就不会为你生成它们。赋值运算符已经删除,因为这些类包含不能重新分配的 const 数据:
//In M5Object.h
M5Object(const M5Object& rhs) = delete;
//In M5Component.h
M5Component(const M5Component& rhs) = delete;
协变返回类型
聪明的读者也会注意到,Mach5 引擎中每个 Clone 方法的返回类型实际上都是不同的。基类 M5Component 返回 M5Component*,然而派生类返回它们自己类类型的指针。这是 C++(以及一些其他语言)中称为协变返回类型的一个特性。让我们使用上面提到的 Shape 类来查看这个特性:
class Shape
{
public:
virtual ~Shape(void) {}//empty base class constructor
virtual void Draw(void) const = 0;
virtual Shape* Clone(void) const = 0;
};
class Circle : public Shape
{
public:
virtual void Draw(void) const;
virtual Shape* Clone(void) const;
};
int main(void)
{
Circle* pCircle = new Circle();
//The line won't compile
Circle* pClone = pCircle->Clone();
delete pClone;
delete pCircle;
return 0;
}
如果 Circle 类的 Clone 方法返回 Shape*,编译器将不允许我们直接将结果赋值给 Circle*。我们需要进行 static_cast 或 dynamic_cast,这意味着我们必须编写如下代码:
Circle* pCircle = new Circle();
Shape* pClone = pCircle->Clone();
Circle* pCircle2 = dynamic_cast<Circle*>(pClone);
if (pCircle2)
{
//do something specific to circle
}
在这两种情况下,Clone 方法将返回一个圆。然而,编译器无法知道这一点,所以我们被迫进行类型转换。使用虚函数的标准规则是函数签名必须相同,包括返回类型。使用协变返回类型,编译器将允许我们在继承层次结构中将返回类型的基类替换为更具体类型:
class Circle : public Shape
{
public:
virtual void Draw(void) const;
//Example of using a covariant return type
virtual Circle* Clone(void) const;
};
int main(void)
{
Circle* pCircle = new Circle();
//No need to cast
Circle* pClone = pCircle->Clone();
//... Do something Circle specific with pClone
delete pClone;
delete pCircle;
return 0;
}
通过使用协变返回类型,我们可以在克隆需要访问其实际类型属性的对象时消除不必要的类型转换。值得注意的是,这个特性仅适用于指针或引用。这意味着如果 Shape 的 Clone 方法返回的是形状,而不是 Shape*,我们就不会有这种选择。
从文件中加载原型
现在我们已经详细了解了原型模式,并讨论了它是如何与 Mach5 引擎的组件一起使用的,让我们看看我们如何使用它从文件中加载数据对象。为此,我们首先需要查看对象文件,然后查看 M5ObjectManager 中用于加载和创建这些对象的特定方法。
原型文件
我们需要做的第一件事是查看我们如何在文件中定义我们的对象原型。Mach5 引擎使用.ini文件作为原型、关卡以及与引擎初始化相关的一切。如果您想保持它们作为人类可读和可修改的,一个更标准的文件格式将是 XML 或 JSON。如果您不希望用户修改它们,文件始终可以保存为二进制格式。
我们选择.ini文件,因为它们既易于人类阅读,也易于计算机程序读取。它们只有几条简单的规则,所以只需几句话就可以解释清楚。它们只包含由方括号[ ]定义的命名部分,以及形式为key = value的键值对。唯一的例外是全球部分,它没有名称,因此没有方括号。让我们看看一个基本的原型文件示例。这是一个Player.ini的示例:
posX = 0
posY = 0
velX = 0
velY = 0
scaleX = 10
scaleY = 10
rot = 0
rotVel = 0
components = GfxComponent PlayerInputComponent ColliderComponent
[GfxComponent]
texture = playerShip.tga
drawSpace = world
[PlayerInputComponent]
forwardSpeed = 100
speedDamp = .99
bulletSpeed = 7000
rotationSpeed = 10
[ColliderComponent]
radius = 5
如您所见,Player.ini文件的全局部分包含在M5Object中定义的所有可变值的值。除了组件键之外,所有内容都是在M5Object的FromFile方法中读取的。在这种情况下,我们的大部分起始值都是零。这是因为像玩家对象的起始位置这样的东西将取决于关卡,因此这些数据将在创建后进行修改。
最重要的是组件。组件键包含一个对象将使用的组件列表。这些字符串将由M5ObjectManager用于创建组件,然后读取每个部分中定义的特定组件数据。这使我们能够重用组件,例如ColliderComponent,因为使用它们的每个对象都可以有不同的组件数据。在这种情况下,玩家对象将有一个半径为5,但子弹可能有一个半径为1。
对象管理器
M5ObjectManager是一个单例类,它负责加载原型和创建对象等任务。这个类中有许多成员和方法,所以查看所有内容会花费太多时间。在本节中,我们只将介绍与从原型文件加载和创建对象相关的特定方法。请记住,由于该类是单例,我们具有全局访问权限。因此,每个成员和方法都是静态的:
class M5ObjectManager
{
public:
static M5Object* CreateObject(M5ArcheTypes type);
static void AddArcheType(M5ArcheTypes type,
const char* fileName);
static void RemoveArcheType(M5ArcheTypes type);
//Plus other methods
private:
typedef M5Factory<M5ComponentTypes,
M5ComponentBuilder,
M5Component> ComponentFactory;
typedef std::unordered_map<M5ArcheTypes,
M5Object*> ArcheTypeMap
static ComponentFactory s_componentFactory;
static ArcheTypesMap s_archetypes;
//Plus other members
};
在这里,我们展示了最重要的成员和方法,以展示如何从文件中加载对象。我们没有展示的是与销毁或搜索特定对象相关的方法。如果您对这些功能感兴趣,请随时查阅本书附带的全源代码。
在公共部分,AddArcheType 方法将被用来读取原型文件,创建对象,并将其存储以供以后使用。RemoveArcheType 方法用于在不再需要对象时删除它。最后,CreateObject 方法将用于克隆之前加载的其中一个原型。在私有部分,我们定义了一些类型以简化命名。您可以看到我们正在使用我们在第五章第五章中创建的模板化动态工厂,即通过工厂方法模式解耦代码。我们还有一个已加载的原型对象的映射。
让我们更仔细地看看这些方法:
void M5ObjectManager::AddArcheType(M5ArcheTypes type,
const char* fileName)
{
MapItor found = s_archetypes.find(type);
M5DEBUG_ASSERT(found == s_archeypes.end(),
"Trying to add a prototype that already exists");
M5IniFile file;
file.ReadFile(fileName);
M5Object* pObj = new M5Object(type);
pObj->FromFile(file);
std::string components;//A string of all my components
file.GetValue("components", components);
//parse the component string and create each component
std::stringstream ss(components);
std::string name;
//Loop through the stream and get each component name
while (ss >> name)
{
M5Component* pComp = s_componentFactory.Build(
StringToComponent(name));
pComp->FromFile(file);
pObj->AddComponent(pComp);
}
//Add the prototype to the prototype map
s_archeypes.insert(std::make_pair(type, pObj));
}
这个函数可能看起来很复杂,但这里正是魔法发生的地方。让我们从开始的地方说起。这个函数接受两个参数,一个枚举 ID,用于指定要创建的类型,以及一个与该 enum ID 关联的文件名。接下来,我们需要检查这个 M5Archetypes ID 是否之前已经被加载过。如果已经加载过,那么肯定存在错误。在检查枚举错误之后,我们读取 .ini 文件。如果文件不存在,ReadFile 方法将断言。
如果没有出现任何错误,我们将创建一个新的 M5Object,并将 M5ArcheTypes ID 传递给构造函数。这仅仅设置了对象的类型,但没有做其他任何事情。为了设置对象的数据,我们调用 FromFile 方法来从 .ini 文件中读取全局部分。这将设置对象的位置、缩放、旋转以及对象中的其他一切,除了实际组件,这需要以不同的方式处理。
组件的问题在于,文件中包含的组件名称是作为字符串存储的,但为了游戏中的性能考虑,我们希望避免进行字符串比较。这意味着我们需要以某种方式将这些字符串转换为 enum 值。这就是 StringToComponent 函数的目的。这个函数是一个 if/else 链,它将根据参数返回正确的枚举值。这样的函数可能会在维护上出现问题。我们将在后面的章节中讨论如何使用 Windows 批处理文件来自动化这个过程。
在我们从文件读取对象数据之后,我们接着从文件中读取组件列表。这是一个由空格分隔的组件名称列表。我们可以有很多种方法来提取每个单独的组件名称,但其中最简单的方法之一是使用 STL 的 stringstream 对象。这允许我们从流中提取单独的字符串,就像 std::cin 一样。
在创建我们的 stringstream 对象后,我们遍历流并提取名称。然后,我们将转换后的 M5ComponentTypes 枚举使用 s_componentFactory 来构建正确的组件。在构建正确的组件后,我们将 .ini 文件传递给组件的 FromFile 方法,让派生组件读取其自己的数据。然后我们确保将组件添加到对象中。最后,在读取所有组件后,我们将类型和对象指针添加到我们的 s_archetypes 映射中。
这可能看起来像是一种加载对象复杂的方法。然而,这个函数不需要了解任何派生组件类型,或者哪些组件与特定对象类型相关联。如果我们的原型 .ini 文件发生变化,我们不需要重新编译此代码。我们可以自由地添加、删除或更改游戏和我们的高级模块 M5ObjectManager 中的对象,而无需更改:
void M5ObjectManager::RemoveArcheType(M5ArcheTypes type)
{
MapItor found = s_archetypes.find(type);
M5DEBUG_ASSERT(found != s_archetypes.end(),
"Trying to Remove a prototype that doesn't exist");
delete found->second;
found->second = 0;
s_archetypes.erase(found);
}
RemoveArcheType 方法比 AddArcheType 简单得多。我们在这里需要做的只是确保要删除的类型存在于映射中,我们通过首先找到并使用调试断言(如果它不存在)来实现这一点。然后我们删除原型对象并在映射中擦除迭代器。
RemoveArcheType 方法不需要被调用,因为所有原型对象将在游戏退出时被删除。然而,如果用户想要最小化游戏中存在的原型,这可以用来实现。默认情况下,Mach5 引擎在游戏开始前自动加载所有原型 .ini 文件:
M5Object* M5ObjectManager::CreateObject(M5ArcheTypes type)
{
MapItor found = s_archetypes.find(type);
M5DEBUG_ASSERT(found != s_archetypes.end(),
"Trying to create and Archetype that doesn't exist");
M5Object* pClone = found->second->Clone();
s_objects.push_back(pClone);//A std::vector<M5Object*>
return pClone;
}
最后,我们有一个允许用户创建原型对象的方法。在这里,用户提供他们想要创建的 M5ArcheTypes 类型。首先,该方法执行我们熟悉的常规错误检查。然后,在找到正确的迭代器后,我们利用原型模式的 Clone 方法来复制从原型对象中所有数据和组件。在创建对象后,我们自动将其添加到活动游戏对象列表中,并将指针返回给用户,以便他们可以根据需要修改位置和速度等属性。
摘要
在本章中,我们重点介绍了创建灵活的代码。由于我们正在使用组件对象模型与游戏对象一起使用,我们想要确保,随着对象的变化,它们能够很好地处理这种变化。这意味着我们不想在游戏测试和平衡对象时修改大量其他文件。
我们在本章开头说过,我们游戏对象的目标是在文件中完全定义它们。由于我们在对象中使用组件,我们希望在文件中定义对象使用的组件。通过在文件中定义对象,我们的程序员可以自由地工作在其他代码上,设计师可以平衡和游戏测试,而无需担心破坏游戏或引入错误。
在查看原型模式的简单示例之后,我们探讨了它在 Mach5 引擎中的应用。我们看到了 M5Component 类和 M5Object 类都使用 Clone 方法来简化对象的复制。当然,这些方法是由 M5ObjectManager 使用的,以便用户可以根据 M5ArcheTypes 枚举创建对象。
现在创建对象可以通过文件完成,我们应该关注一个更难看到的问题。由于我们使用了大量的对象指针,这些指针将会有很多组件指针,我们应该讨论一些与内存相关的问题。这就是我们在下一章将要涉及的内容。
第七章:使用对象池提高性能
在编程语言中,对于计算机来说,最耗时的事情之一就是处理内存分配。这相当低效,并且根据所使用的资源,可能会极大地减慢你的游戏速度。
在射击游戏中常见的一个元素,或者任何有爆炸或子弹的游戏,是快速连续创建和销毁许多对象。以 东方 Project 系列游戏为例,玩家和敌人都会发射大量子弹。如果以最简单的方式完成,当需要创建子弹时调用 new,当需要移除它时调用 delete,这会导致我们的游戏随着时间的推移而变得卡顿或冻结。
为了防止这种情况发生,我们可以利用对象池模式。
章节概述
在本章中,我们将创建一个对象池,允许玩家在屏幕上为游戏生成大量子弹。
你的目标
本章将分为几个主题。它将包含一个从头到尾的简单分步过程。以下是我们的任务大纲:
-
为什么我们应该关心内存
-
对象池模式解释
-
使用内存池--重载
new和delete -
设计决策
为什么你应该关心内存
作为程序员,你可能已经习惯了使用 new 和 delete(如果你正在编写 C 语言,则是 malloc 和 free),你可能想知道为什么你想自己处理内存,尽管它已经内置到语言中并且使用起来很简单。好吧,首先,就像使用高级编程语言的大多数方面一样,你并不知道幕后发生了什么。如果你编写自己的逻辑来处理内存,你可以创建自己的统计数据和额外的调试支持,例如自动初始化数据。你还可以检查内存泄漏等问题。
然而,对于游戏开发者来说,最重要的方面是性能。为单个对象或一次性为成千上万个对象分配内存,所需时间与计算机查找你的计算机内存中未使用的空隙并给出该连续内存块起始地址所需的时间大致相同。如果你一次又一次地请求小块内存,这可能导致内存碎片化,也就是说,当你想要获取更大的对象时,没有足够的连续空闲空间。

我们可能开始时有一些这样的内存,灰色部分是空闲内存,黑色部分是因为我们为该数据量调用了 new。每次我们调用 new 时,计算机都需要寻找第一个有足够空间容纳我们提供的对象类型的空地址:

之后,我们删除了一些内存,腾出了一些空间,但计算机需要查看每个地址并花费更多时间搜索:

最后,我们到达了一个地方,那里几乎没有开放数据,并且由于内存碎片化,插入新数据需要大量工作。
这对于开发控制台或移动设备游戏尤其重要,因为你要处理的内存大小远小于你在 PC 上习惯使用的大小。如果你五年或更早之前使用过电脑,你可能记得电脑碎片化的概念,其中电脑会移动内存块以创建可以稍后使用的较大块。但这是一个非常耗时的过程。
Mach5 并不容易提供支持以这种方式创建游戏对象的能力,但如果你对此感兴趣,我们确实有一种可以使用对象池概念的方法来避免资源浪费;我们将在本章后面讨论这一点。
一篇关于为游戏编程编写内存管理器的优秀文章可以在www.gamasutra.com/view/feature/2971/play_by_play_effective_memory_.php找到。
对象池模式解释
之前,我们讨论了单例设计模式及其如何在项目中创建单个实例,通常是静态的。我们知道只有一个,并且它只创建一次,我们可以无问题地与项目的其余部分共享它。然而,单例模式仅在实例初始化时才有效。
对象池类似,但不是单个对象,我们希望有一个组(或池)的对象(或实例),我们可以在项目的其余部分中引用。每当项目想要访问这些对象时,我们还有一个被称为对象池的另一个对象,它充当项目与对象本身之间的联络员。
也被称为资源池或 N 吨在其他计算机科学领域(但在游戏开发中通常被称为对象池)中,你可以将对象池想象成一个类似管理者的角色。当我们的程序需要使用一个对象时,管理者知道哪些对象正在被使用,并将提供一个未被使用的对象,或者扩展以创建一个新的对象。这促进了之前创建的对象的重用,而不是在运行时创建和删除它们。当初始化类实例的成本很高,或者实例化的速率很高而对象的使用时间很低时,这提供了一系列优势。
让我们以我们的太空射击游戏为例。每次我们按下空格键时,我们都会创建一个新的激光对象。同样,每次我们射击某个东西时,我们都需要销毁它们。这将减慢我们游戏的表现。对于这样一个简单的游戏来说,这并不是一个大问题,但在 AAA 游戏中,我们经常使用这个想法,例如,在 Naughty Dog 的 Uncharted 系列中的任何一款游戏或大多数 FPS 游戏中。这些游戏中的敌人非常复杂,将它们放入游戏中成本很高。因此,通常不会在游戏中保留一大堆敌人对象,而是在使用敌人并让它们死亡后,它们会变得不可见,当你需要新的敌人时,死亡的物体会被移动到新的位置并重新激活。
对象池的基本元素看起来可能像这样:

在我们的对象池的情况下,我们有一些类型的变量,我们想要持有其副本。在这种情况下,我将其命名为 GameObject,但你也会听到它被称为 Reusable 或 Resource 类。我们使用 AcquireObject 函数从我们的对象池中获取一个对象,当我们完成对其操作时,我们使用 ReleaseObject 函数。GetInstance 函数的工作方式与我们在前面讨论的 Singleton 类类似,它为我们提供了访问它所引用的 ObjectPool 的权限。
在 Mach5 引擎中,默认情况下并没有包含对象池,因此我们需要扩展引擎以支持它。这意味着我们需要从头开始构建一个。
有多种方式来实现对象池模式或获得类似的行为。在我们转向最终版本之前,我们将讨论一些常见的版本及其缺点。
实现基本对象池
让我们先从一个可以创建多个实例的简单类开始创建对象池:
class GameObject
{
private:
// Character's health
int currentHealth;
int maxHealth;
// Character's name
std::string name;
public:
GameObject();
void Initialize(std::string _name = "Unnamed",
int _maxHealth = -1);
std::string GetInfo();
};
因此,这个示例 GameObject 类包含一个用于识别对象的名称和一些示例属性,使类看起来更像游戏对象。显然,你可以轻松地添加更多属性,并且相同的原理适用。在这种情况下,我们有一个名为 Initialize 的函数,它为类提供 set 和 reset 值。最后,我添加了一个 GetInfo 函数来打印有关类的信息,这样我们就可以验证一切是否正常工作。
类的实现将看起来像这样:
/*************************************************************************/
/*!
Constructor that initializes the class' data
*/
/*************************************************************************/
GameObject::GameObject()
{
Initialize();
}
/*************************************************************************/
/*!
Initializes or resets the values of the class
*/
/*************************************************************************/
void GameObject::Initialize(std::string _name, int _maxHealth)
{
name = _name;
maxHealth = _maxHealth;
currentHealth = maxHealth;
}
/*************************************************************************/
/*!
Prints out information about the class
*/
/*************************************************************************/
std::string GameObject::GetInfo()
{
return name + ": " + std::to_string(currentHealth) + "/" +
std::to_string(maxHealth);
}
现在我们已经创建了游戏对象,我们需要创建池:
class GameObject;
class ObjectPool
{
private:
std::list<GameObject*> pool;
static ObjectPool* instance;
// Private constructor so users are unable to create without
// GetInstance
ObjectPool() {}
public:
static ObjectPool* GetInstance();
GameObject* AcquireObject();
void ReleaseObject(GameObject* object);
void ClearPool();
};
首先,有两个变量:pool,它将包含我们对象池中所有的可用对象,以及instance,这是我们访问它的一种方式。请注意,我们的对象池使用 Singleton 设计模式,这意味着对于你想要复制的每种类型的对象,只能有一个。在这种情况下,我们遇到了之前讨论过的问题,即你必须实际删除池并移除创建的所有元素,这就是为什么我们添加了一个ClearPool函数,它正好做了这件事。类的实现将类似于以下内容:
ObjectPool* ObjectPool::GetInstance()
{
if (instance == nullptr)
{
instance = new ObjectPool();
}
return instance;
}
在这个先前的函数中,我们首先检查instance是否已设置。如果没有设置,我们为其动态分配内存并将其设置为instance变量。无论如何,我们之后都会有一个实例,这就是我们返回的内容:
/*************************************************************************/
/*!
Returns the first available object if it exists. If not, it will create a new
one for us
*/
/*************************************************************************/
GameObject* ObjectPool::AcquireObject()
{
// Check if we have any objects available
if (!pool.empty())
{
// Get reference to an avaliable object
GameObject* object = pool.back();
// Since we are going to use it, it's no longer available,
// so we need to remove the last element from our list
pool.pop_back();
// Finally, return the reference
return object;
}
else
{
// If none are available, create a new one
return new GameObject()
}
}
/*************************************************************************/
/*!
Marks an object as being available again
\param
The object to be made available again
*/
/*************************************************************************/
void ObjectPool::ReleaseObject(GameObject* object)
{
// Reset the object
object->Initialize();
// Add it to our avaliable list
pool.push_back(object);
}
/*************************************************************************/
/*!
Takes care of removing all of the objects from the pool whenever we're finished
working with it.
*/
/*************************************************************************/
void ObjectPool::ClearPool()
{
while (!pool.empty())
{
GameObject * object = pool.back();
pool.pop_back();
delete object;
}
}
ClearPool函数会持续从池中移除对象,直到池为空。我们首先通过使用back函数获取对象的引用,来检索最后一个元素。
然后我们在删除对象本身之前从池中移除该元素:
ObjectPool* ObjectPool::instance = 0;
最后,C++要求我们必须初始化instance变量,所以我们最后添加了这一项。
一旦我们有了这个基础代码,我们就可以开始使用类了。一个示例用法可能是以下内容:
ObjectPool* pool = ObjectPool::GetInstance();
GameObject * slime = pool->AcquireObject();
std::cout << "Initial: " << slime->GetInfo() << std::endl;
slime->Initialize("Slime", 10);
std::cout << "After Assignment: " << slime->GetInfo() <<
std::endl;
pool->ReleaseObject(slime);
slime = pool->AcquireObject();
std::cout << "Reused: " << slime->GetInfo() << std::endl;
pool->ClearPool();
delete pool;
如果我们将此脚本保存并在空白项目中运行它,你会看到以下内容:

在这种情况下,我们首先获取使用GetInstance函数的ObjectPool,然后使用AcquireObject函数(它调用new来创建对象)从对象池中获取一个对象。从那里我们打印出它的值,由于构造函数,它被设置为预定义的默认值。然后我们分配值并使用它。之后,我们将它从放置在池中的列表中释放出来,以便在准备好时重用。然后我们再次获取该对象,并显示它已经重置,可以像之前一样重用!
C++中的操作符重载
现在我们有一个很好的基础可以在此基础上构建,但实际上我们可以使我们的对象池更容易使用。C++中一个很酷的特性是你可以覆盖操作符的默认行为,通常称为操作符重载。这是通过创建具有特定名称的函数来完成的,这些名称包含操作符关键字,后面跟着你想要定义的操作符。就像常规函数一样,它们也有返回类型以及传递给它们的参数。
关于操作符重载及其在 C++中的工作方式,更多信息请查看www.cprogramming.com/tutorial/operator_overloading.html。
除了常见的操作符,如+、-和/之外,我们还有能力重载new和delete操作符,这样我们就可以使用我们自己的自定义对象池了!
要做到这一点,我们需要将以下内容添加到GameObject类的末尾,并将以下加粗行添加到类定义中:
class GameObject
{
private:
// Character's health
int currentHealth;
int maxHealth;
// Character's name
std::string name;
public:
GameObject();
void Initialize(std::string _name = "Unnamed",
int _maxHealth = -1);
std::string GetInfo();
void* operator new(size_t); void operator delete(void* obj);
};
在这里,我们向GameObject类添加了两个新函数——一个用于我们创建自己的new版本,另一个用于我们的delete版本。然后,我们需要添加实现:
void* GameObject::operator new(size_t)
{
return ObjectPool::GetInstance()->AcquireObject();
}
void GameObject::operator delete(void* obj)
{
ObjectPool::GetInstance()->ReleaseObject(static_cast<GameObject*>(obj));
}
在我们的案例中,我们只是使用ObjectPool类的函数在需要时获取和释放我们的对象,而不是一直分配内存。然后,我们可以按如下方式修改原始实现代码:
ObjectPool* pool = ObjectPool::GetInstance();
GameObject * slime = new GameObject();
std::cout << "Initial: " << slime->GetInfo() << std::endl;
slime->Initialize("Slime", 10);
std::cout << "After Assignment: "
<< slime->GetInfo() << std::endl;
delete slime; slime = new GameObject();
std::cout << "Reused: " << slime->GetInfo() << std::endl;
pool->ClearPool();
delete pool;
return 0;
现在,请不要立即运行代码。如果你还记得,我们在ObjectPool类内部调用了new和delete运算符,所以现在运行代码将导致堆栈溢出错误,因为当AquireObject调用new时,它将调用GameObject类的new版本,然后它又调用AquireObject函数,如此循环往复。为了解决这个问题,我们需要使用 C 版本的内存分配,即malloc和free函数,从系统中获取内存:
/*************************************************************************/
/*!
Returns the first available object if it exists. If not, it will create a new
one for us
*/
/*************************************************************************/
GameObject* ObjectPool::AcquireObject()
{
// Check if we have any objects available
if (!pool.empty())
{
// Get reference to an avaliable object
GameObject* object = pool.back();
// Since we are going to use it, it's no longer available, so
// we need to remove the last element from our list
pool.pop_back();
// Finally, return the reference
return object;
}
else
{
// If none are avaliable, create a new one
return static_cast<GameObject*>(malloc(sizeof(GameObject)));
}
}
/*************************************************************************/
/*!
Takes care of removing all of the objects from the pool whenever we're finished
working with it.
*/
/*************************************************************************/
void ObjectPool::ClearPool()
{
while (!pool.empty())
{
GameObject * object = pool.back();
pool.pop_back();
free(object);
}
}
现在我们应该能够运行并查看一切是否按预期工作!这个版本在用户仍然调用new和delete的情况下工作得相当好;然而,它随着时间的推移提供了性能提升。
为 Mach5 构建对象池
现在我们已经看到了对象池的实际应用,接下来让我们学习如何将对象池模式集成到 Mach5 游戏引擎中。由于我们正在创建一个射击游戏,在游戏过程中我们生成很多的是来自我们飞船的激光子弹,这使得使用对象池功能变得非常合适。与之前的示例不同,我们将看到一个不需要使用指针来访问池的对象池版本,我们也不必担心池的创建。为了做到这一点,我们需要对起始项目做一些调整。首先,我们需要改变子弹的销毁方式。
如果你进入位于Mach5-master\EngineTest\EngineTest\ArcheTypes的Bullet.ini文件,你会看到以下内容:
posX = 0
posY = 0
velX = 0
velY = 0
scaleX = 2.5
scaleY = 2.5
rot = 0
rotVel = 0
components = GfxComponent ColliderComponent OutsideViewKillComponent
[GfxComponent]
texture = bullet.tga
drawSpace = world
texScaleX = 1
texScaleY = 1
texTransX = 0
texTransY = 0
[ColliderComponent]
radius = 1.25
进入并移除OutsideViewKillComponent,并用BulletComponent替换它。我们替换OutsideViewKillComponent是因为当它离开屏幕时,它会将对象的isDead属性设置为true,这将调用它上面的delete并从世界中移除它。我们实际上将自行处理这个问题,所以让我们用我们自己的行为来替换它,这个行为我们将在这个章节稍后编写的BulletComponent脚本中实现。
接下来,我们希望为我们的ObjectPool创建一个新的位置,因此,考虑到这一点,转到解决方案资源管理器选项卡,然后右键单击 Core/Singletons 文件夹,并选择新建过滤器。创建一个后,将其命名为ObjectPool。从那里,右键单击新创建的文件夹,并选择新建项...然后从菜单中选择头文件(.h)选项,并将其命名为M5ObjectPool.h。
在.h文件中,我们将放入以下代码:
/*************************************************************************/
/*!
\file M5ObjectPool.h
\author John P. Doran
\par email: john\@johnpdoran.com
\par Mach5 Game Engine
\date 2016/11/19
Globally accessible static class for object caching to avoid creating new objects
if we already have one not being used.
*/
/*************************************************************************/
#ifndef M5OBJECT_POOL_H
#define M5OBJECT_POOL_H
#include <vector>
#include <queue>
#include "EngineTest\Source\Core\M5Object.h"
template <M5ArcheTypes T>
class M5ObjectPool
{
public:
// Gives to us the first available object, creating a new one if none is available
static M5Object * AcquireObject();
// Returns the object to the pool making it available for reuse
static void ReleaseObject(M5Object* object);
// Removes all of the objects in the pool and removes references
// as needed
static void ClearPool();
private:
// All of the objects in the object pool
static std::vector<M5Object*> pool;
// All of the objects that are currently available
static std::deque<M5Object*> available;
};
#endif //M5OBJECT_POOL_H
你会注意到这个类与我们过去所做的工作非常相似,但我们不是使用GameObject类,而是使用 Mach5 引擎的M5Object类。我们还模板化了这个类,使其能够与任何存在的对象原型一起工作(包括我们的子弹,它由AT_Bullet表示)。我还添加了一个新的变量available,它是一个deque(发音为deck),代表双端队列。这个变量将包含所有存在且未使用的对象,这样我们就可以轻松地判断是否有可用的对象,或者是否需要创建一个新的对象。
如果你想了解更多关于 deque 类的信息,请查看www.cplusplus.com/reference/deque/deque/。
我们还希望创建一个M5ObjectPool.cpp文件。在.cpp中,我们将写入以下代码:
/*************************************************************************/
/*!
\file M5ObjectPool.cpp
\author John P. Doran
\par email: john\@johnpdoran.com
\par Mach5 Game Engine
\date 2016/11/19
Globally accessible static class for object caching to avoid creating new objects
if we already have one not being used.
*/
/*************************************************************************/
#include "M5ObjectPool.h"
#include "Source\Core\M5ObjectManager.h"
template class M5ObjectPool<AT_Bullet>;// explicit instantiation
/*************************************************************************/
/*!
Returns the first available object if it exists. If not, it will create a new
one for us
*/
/*************************************************************************/
template <M5ArcheTypes T>
M5Object * M5ObjectPool<T>::AcquireObject()
{
// Check if we have any available
if (!available.empty())
{
// Get reference to an available object
M5Object * object = available.back();
// Since we are going to use it, it's no longer available,
// so we need to remove the last element from our list
available.pop_back();
// Finally, return the reference
return object;
}
else
{
M5Object * object = M5ObjectManager::CreateObject(T);
pool.push_back(object);
return object;
}
}
在这个例子中,我们首先会检查是否有任何对象在可用列表中。如果没有,我们将创建一个新的对象,利用M5ObjectManager类的CreateObject函数。然后,我们将其添加到池中,因为它是我们对象池中的一个对象,但我们不会使其可用,因为它在被获取后将会被使用:
/*************************************************************************/
/*!
Marks an object as being available again
\param
The object to be made available again
*/
/*************************************************************************/
template <M5ArcheTypes T>
void M5ObjectPool<T>::ReleaseObject(M5Object * object)
{
// If it's valid, move this object into our available list
if ((object->GetType() == T) &&
(std::find(pool.begin(), pool.end(), object) != pool.end()))
{
//Make sure we haven't already been added already
if(std::find(available.begin(), available.end(), object) == available.end())
{
available.push_back(object);
}
}
}
在这种情况下,ReleaseObject函数将一个对象标记为可重复使用。但是,我们想要进行一些错误检查,以确保函数被正确使用,并且没有提供无效的对象。
首先,代码确保对象与对象池的类型相同,并且实际上位于池中的某个位置。这确保了我们只会将有效的对象添加到我们的可用 deque 中。如果我们知道对象是有效的,那么我们就会查看 deque 中已有的对象,并确保该对象尚未被添加。如果没有,我们将其添加到可用 deque 中:
/*************************************************************************/
/*!
Takes care of removing all of the objects from the pool whenever we're finished working with it.
*/
/*************************************************************************/
template<M5ArcheTypes T>
void M5ObjectPool<T>::ClearPool()
{
// Go through each of our objects and destroy them
for (int i = pool.size() - 1; i >= 0; --i)
{
M5ObjectManager::DestroyObject(pool[i]);
pool.pop_back();
}
// Now clear out the available queue
available.clear();
}
在ClearPool函数中,我们只是遍历池中的每一个对象,并销毁那个游戏对象。然后,我们清空可用列表:
template<M5ArcheTypes T>
std::vector<M5Object*> M5ObjectPool<T>::pool;
template<M5ArcheTypes T>
std::deque<M5Object*> M5ObjectPool<T>::available;
最后,我们需要声明池和可用对象,以便将来可以创建它们。
现在我们有了这个基本功能,我们需要将这些对象返回到我们的可用池中。为此,我们需要添加之前提到的BulletComponent组件。由于这个组件仅属于我们的游戏,让我们转到SpaceShooter/Components过滤器,创建一个新的过滤器,称为BulletComp。从那里,创建两个新的文件,BulletComponent.h和BulletComponent.cpp,确保位置设置为Mach5-master\EngineTest\EngineTest\Source\文件夹。
在.h文件中,放入以下代码:
#ifndef BULLET_COMPONENT_H
#define BULLET_COMPONENT_H
#include "Core\M5Component.h"
//!< Removes The parent Game Object if it is outside the view port
class BulletComponent : public M5Component
{
public:
BulletComponent();
virtual void Update(float dt);
virtual M5Component* Clone(void);
};
#endif // !BULLET_COMPONENT_H
接下来,在.cpp文件中,使用以下代码:
#include "BulletComponent.h"
#include "Core\M5Gfx.h"
#include "Core\M5Math.h"
#include "Core\M5Object.h"
#include "EngineTest\M5ObjectPool.h"
BulletComponent::BulletComponent():
M5Component(CT_BulletComponent)
{
}
void BulletComponent::Update(float /*dt*/)
{
M5Vec2 pos = m_pObj->pos;
M5Vec2 scale = m_pObj->scale;
scale *= .5f;
M5Vec2 botLeft;
M5Vec2 topRight;
M5Gfx::GetWorldBotLeft(botLeft);
M5Gfx::GetWorldTopRight(topRight);
if (pos.x + scale.x > topRight.x || pos.x -
scale.x < botLeft.x ||
pos.y + scale.y > topRight.y || pos.y - scale.y < botLeft.y)
{
M5ObjectPool<AT_Bullet>::ReleaseObject(m_pObj);
}
}
M5Component * BulletComponent::Clone(void)
{
BulletComponent * pNew = new BulletComponent;
pNew->m_pObj = m_pObj;
return pNew;
}
保存你的文件。这样,如果对象有一个子弹组件,它将被返回到列表中;但我们必须首先制作我们的对象。进入PlayerInputComponent.cpp文件,并更新Update函数中创建子弹的代码部分,如下所示:
//then check for bullets
if (M5Input::IsTriggered(M5_SPACE) || M5Input::IsTriggered(M5_GAMEPAD_A))
{
M5Object* bullet1 = M5ObjectPool<AT_Bullet>::AcquireObject();
M5Object* bullet2 = M5ObjectPool<AT_Bullet>::AcquireObject();
bullet2->rotation = bullet1->rotation = m_pObj->rotation;
M5Vec2 bulletDir(std::cos(bullet1->rotation), std::sin(bullet1->rotation));
M5Vec2 perp(bulletDir.y, -bulletDir.x);
bullet1->pos = m_pObj->pos + perp * .5f * m_pObj->scale.y;
bullet2->pos = m_pObj->pos - perp * .5f * m_pObj->scale.y;
M5Vec2::Scale(bulletDir, bulletDir, m_bulletSpeed * dt);
bullet1->vel = m_pObj->vel + bulletDir;
bullet2->vel = m_pObj->vel + bulletDir;
}
注意,我们将bullet1和bullet2的创建替换为使用我们的ObjectPool类的AcquireObject函数,而不是我们的ObjectManager类的版本。
现在我们很难看出我们是在使用刚刚创建的对象,还是正在重用的对象。在我们将其放回对象池之前,让我们回到BulletComponent并修改一个属性:
void BulletComponent::Update(float /*dt*/)
{
M5Vec2 pos = m_pObj->pos;
M5Vec2 scale = m_pObj->scale;
scale *= .5f;
M5Vec2 botLeft;
M5Vec2 topRight;
M5Gfx::GetWorldBotLeft(botLeft);
M5Gfx::GetWorldTopRight(topRight);
if (pos.x + scale.x > topRight.x || pos.x - scale.x < botLeft.x ||
pos.y + scale.y > topRight.y || pos.y - scale.y < botLeft.y)
{
m_pObj->scale = M5Vec2(1.5f, 1.5f);
M5ObjectPool<AT_Bullet>::ReleaseObject(m_pObj);
}
}
现在我们可以保存我们的脚本并运行我们的游戏!

你会注意到,在游戏开始时,对象的缩放比例为2.5, 2.5。然而,一旦一些对象离开屏幕,你会看到以下类似的截图:

当我们射击新子弹时,它们已经被缩小了!有了这个,我们知道我们的池子正在正常工作,并且我们正在重用我们之前创建的对象!
对象池的问题
现在,尽管对象池很棒,但我们应该花点时间讨论你不想使用对象池的情况,以及可供选择的替代方案。
首先,你需要记住,当你使用内存管理器时,你是在告诉计算机你比它们聪明,你知道数据应该如何处理。这比其他语言通常给你的权力要大,正如我们在本书第二章一个实例统治一切 - 单例中提到的,使用 Uncle Ben 的名言,“能力越大,责任越大”。当使用对象池时,你通常希望它在对象只有有限的生命周期并且会创建很多对象,但不是同时创建时使用。如果在某个时刻你将在屏幕上有 10,000 个对象,但游戏的其他部分你最多只有 30 个,那么那 9,970 个其他对象的内存将只是在那里等待,以防万一你想再次使用它。
同时处理大量对象的一种替代方法是使用循环链表,其中最后一个元素连接到第一个。这将保证你永远不会创建比你分配的内存更多的东西。如果你恰好绕了一圈,你只是在替换最旧的元素,而且如果你一次在屏幕上有这么多东西,用户不会注意到最旧的元素被移除。这对于像粒子系统这样的东西很有用,我们将在第十章共享对象与享元模式中讨论。如果你在生成许多粒子,人们可能不会注意到游戏在用新粒子替换最旧的粒子。
想了解更多关于循环链表的信息,请查看www.tutorialspoint.com/data_structures_algorithms/circular_linked_list_algorithm.htm。
我们还使用了一种类型的对象池,每次只分配一个元素。或者,你也可以一次性为大量元素分配内存,以确保你始终有预留的内存。虽然在这个情况下并不需要,但确实是在处理大型类时值得使用的方法。
虽然列出的代码示例是 C#编写的,但 Michal Warkocz 列出了一些非常好的例子,说明了为什么对象池可能不是这里的好选择:blog.goyello.com/2015/03/24/how-to-fool-garbage-collector/。
摘要
在本章中,我们通过存储和重用对象而不是创建和删除对象,使用对象池来减少系统资源和用户的不满。在花时间润色你的工作之后,你可能想要花时间修改你游戏的用户界面,这正是我们将在下一章中讨论的内容!
第八章:通过命令模式控制 UI
在上一章中,我们深入探讨了计算机内存的位和字节,以便使我们的组件更加高效且易于调试。了解这些细节可能是游戏以每秒 60 帧或 30 帧运行的区别。了解如何控制内存使用是成为一名优秀程序员的重要方面。这也是编程中最困难的事情之一。在本章中,我们将从底层编程中暂时休息一下,看看一些高级内容。
用户界面,或 UI,与内存管理或场景切换一样重要。你甚至可以争论它更重要,因为玩家不关心底层细节。他们只想玩一个有趣的游戏。然而,不管游戏玩法有多有趣,如果 UI 难以导航或控制,乐趣水平会迅速下降。
你能记得你玩过一个控制极差的游戏的时刻吗?你继续玩游戏了吗?这很有趣,因为对于如此重要的事情,它经常有机会被推迟到项目结束时。即使在本书中,我们也必须等到第八章才能涵盖它。然而,优秀的游戏将 UI 设计和用户体验设计为首要任务。
有很多关于如何设计用户界面和制作用户体验的优秀书籍。这不是其中之一。相反,我们将查看 UI 背后的代码如何以灵活的方式实现,以便与我们的引擎的其他部分一起工作。制作一个优秀的 UI 的第一步是设计代码,使按钮和其他输入易于创建和更改。
我们将从查看一个非常简单但强大的模式开始,这个模式允许我们将函数调用与想要调用它们的对象解耦。当我们讨论这个模式时,我们将查看 C++允许我们将函数视为对象的一些语法上丑陋且令人困惑的方式。我们还将看到 Mach5 引擎如何使用这个模式来创建可点击的 UI 按钮。
章节概述
本章全部关于将用户界面和输入与其执行的动作分离。我们将学习命令模式以及它如何帮助我们解耦代码。我们将通过首先理解问题,然后查看如何以 C 风格的方式解决这个问题来做到这一点。然后,在深入研究了命令模式之后,我们将看到它在 Mach5 引擎中的实现。
你的目标
以下列出了本章要完成的事情:
-
学习处理输入的简单方法以及为什么应该避免它
-
使用函数指针和类方法指针实现命令模式
-
学习 Mach5 引擎如何使用命令模式
-
在 Mach5 引擎中实现 UI 按钮
我们如何通过按钮控制动作?
在 第三章,“使用组件对象模型改进装饰器模式”,我们实现了游戏对象。现在我们有了它们,创建屏幕上的按钮似乎很简单。事实上,在实时策略等类型中,可点击按钮和游戏对象之间没有区别。玩家可以点击任何单位或建筑并给予指令。
初看之下,我们的按钮可以是游戏对象。它们都有位置、缩放和纹理,这个纹理将被绘制到屏幕上。根据游戏的不同,你可能使用正交投影来绘制按钮,而对象则使用透视投影来绘制。然而,差异远不止于此。
在本质上,按钮有一个在点击或选择时需要执行的操作。这种行为通常是简单的;它不需要创建一个完整的状态机类。然而,它确实需要一点思考,这样我们才不会在高级模块中到处硬编码按钮功能,或者在不同地方重复类似的代码。
在 第五章,“通过工厂方法模式解耦代码”,我们看到了处理菜单屏幕上按钮点击的一个极其简单的方法。回想一下,这段代码是由作者在他们编程生涯早期编写的:
if ((p.x > .15 * GetSystemMetrics(SM_CXSCREEN)) &&
(p.x < .42 * GetSystemMetrics(SM_CXSCREEN)) &&
(p.y > .58 * GetSystemMetrics(SM_CYSCREEN)) &&
(p.y < .70 * GetSystemMetrics(SM_CYSCREEN)))
{
if (mousedown)
{
mGameState = TCodeRex::LOAD;
mGameLevel = L0;
}
}
这段代码存在很多问题:
-
首先,矩形点击区域被硬编码到全屏模式的比例中。如果我们从宽屏 16:9 比例切换到标准 4:3 比例,或者如果我们从全屏模式切换到窗口模式,这段代码将无法正确工作。
-
第二,点击区域基于屏幕而不是按钮本身。如果按钮位置或大小发生变化,这段代码将无法正确工作。
-
第三,这个菜单屏幕是与 Windows 的
GetSystemMetrics函数耦合的,而不是像M5App类这样的封装平台代码类。这意味着如果我们想在不同的操作系统或平台上运行,这个菜单以及可能的所有菜单都需要进行修改。 -
最后,状态(Mach5 中的阶段)切换动作被硬编码到菜单中。如果我们决定执行不同的动作,我们需要修改菜单。如果这个动作可以通过按钮点击和键盘输入来执行,我们需要更新和维护这两部分代码。
如您所见,这不是处理游戏中按钮的理想方式。这基本上是你可以实现按钮的最糟糕的方式。如果任何东西发生变化,这段代码很可能就会出错。如果作者能说这段代码只是为了演示不应该做什么,那会很好。不幸的是,在你阅读这本书的时候,这样的书并不存在,所以他不得不通过艰难的方式学习。
回调函数
处理这些按钮动作的更好方法是使用回调函数。在 C/C++中,回调函数是通过函数指针实现的。它们允许你像传递变量一样传递函数。这意味着函数可以被传递到其他函数,从函数返回,甚至存储在变量中并在以后调用。这允许我们将特定函数与调用它的模块解耦。这是在运行时更改要调用哪个函数的 C 风格方法。
正如指向int的指针只能指向int,指向float的指针只能指向float一样,指向函数的指针只能指向具有相同签名的函数。一个例子是以下函数:
int Square(int x)
此函数接受一个单个int作为参数并返回一个int。此返回值和参数列表是函数的签名。因此,此函数的指针将是:
int (*)(int);
我们没有给函数指针命名,所以它应该看起来像这样:
int (*pFunc)(int);
注意,变量名pFunc周围的括号是必需的,否则编译器会认为这是一个返回指针到int的函数的原型。
我们现在可以创建一个指向特定函数的指针并通过该变量调用该函数:
int (*pFunc)(int);
pFunc = Square;
std::cout << "2 Squared is "<< pFunc(2) << std::endl;
上述代码的输出如下:

图 8 1 - 函数指针输出
注意,我们不需要取Square函数的地址(尽管这种语法是允许的);这是因为 C 和 C++中函数的名称已经是该函数的指针。这就是为什么我们可以调用pFunc而无需解引用它。不幸的是,关于函数指针的一切在你习惯之前都很奇怪。你必须努力记住语法,因为它与变量指针的工作方式不同。
通过查看更大的示例,我们可以熟悉这种语法。让我们编写一个程序,用三种不同的方式填充数组并打印数组:
//Fills array with random values from 0 to maxVal - 1
void RandomFill(int* array, int size, int maxVal)
{
for (int i = 0; i < size; ++i)
array[i] = std::rand() % maxVal;
}
//Fills array with value
void ValueFill(int* array, int size, int value)
{
for (int i = 0; i < size; ++i)
array[i] = value;
}
//Fills array with ordered values from 0 - maxVal - 1 repeatedly
void ModFill(int* array, int size, int maxVal)
{
for (int i = 0; i < size; ++i)
array[i] = i % maxVal;
}
//Helper to print array
void PrintArray(const int* array, int size)
{
for (int i = 0; i < size; ++i)
std::cout << array[i] << " ";
std::cout << std::endl;
}
我们编写此程序的目标是编写一个函数,该函数可以用任何填充函数填充数组,包括尚未编写的函数。由于我们有一个通用的函数签名,我们可以创建一个名为FillAndPrint的函数,它将接受任何具有匹配签名的函数的指针作为参数。这将允许FillAndPrint与特定的填充函数解耦,并允许它用于尚未存在的函数。FillAndPrint的原型将如下所示:
void FillAndPrint(void (*fillFunc)(int*, int, int), int* array, int size, int param);
这非常丑陋且难以阅读。所以,让我们使用typedef来稍微清理一下代码。记住,typedef允许我们给我们的类型起一个不同的、更易读的名字:
//Defines a function pointer type named FillFUnc
typedef void(*FillFunc)(int*, int, int);
void FillAndPrint(FillFunc pFunc, int* array, int size, int param)
{
pFunc(array, size, param);
PrintArray(array, size);
}
在main函数中,此代码的用户可以选择他们想要使用的填充函数,甚至可以编写一个完全新的函数(如果签名相同),而无需更改FillAndPrint:
int main(void)
{
const int SIZE = 20;
int array[SIZE];
//See the Random number generator
std::srand(static_cast<unsigned>(time(0)));
FillAndPrint(ValueFill, array, 20, 3);
FillAndPrint(RandomFill, array, 10, 5);
return 0;
}
下面是此代码将输出到命令行的内容:

图 8 2 - 以不同方式使用 FillAndPrint
如果我们包含一个 helper 函数来选择并返回正确的填充函数,我们甚至可以在运行时允许用户选择填充:
FillFunc PickFill(int index)
{
switch (index)
{
case 0:
return RandomFill;
case 1:
return ValueFill;
default:
//We could report an error if the value is outside of the
//range, but instead we just use a default
return ModFill;
}
}
//Our Second main example
int main(void)
{
const int SIZE = 20;
int array[SIZE];
int fillChoice;
int param;
//This doesn't properly explain to the user,
//but it is just an example
std::cout << "Enter a Fill Mode and parameter to use"
<< std::endl;
std::cin >> fillChoice;
std::cin >> param;
//See the Random number generator
std::srand(static_cast<unsigned>(time(0)));
FillAndPrint(PickFill(fillChoice), array, 20, param);
return 0;
}
这是一个非常简单的例子,但你已经可以看到使用函数指针如何使我们能够编写灵活的代码。FillAndPrint 完全与任何特定的函数调用解耦。不幸的是,你也能看到这个系统的两个缺陷。函数必须具有完全相同的签名,并且函数的参数必须传递给函数指针的使用者。
这两个问题使得函数指针既有趣又强大,但并不是支持具有各种参数列表的多种动作的游戏按钮的最佳解决方案。此外,我们可能还想支持使用 C++ 成员函数的动作。到目前为止,我们所看到的所有示例都是 C 风格的全局函数。我们将在稍后解决这些问题,但首先我们应该看看我们将如何触发我们的按钮点击。
组件中的重复代码
我们有一个想要将特定的函数调用与其调用位置解耦的问题。如果能创建一个按钮组件来保存函数指针或类似的东西,并在组件被点击时调用它,那就太好了。
一种可能的解决方案是为我们想要执行的所有动作创建一个新的组件。例如,我们可能想要创建一个将舞台更改为主菜单的组件。我们可以创建一个知道如何执行该特定动作的组件类:
//MainMenuComponent.h
class MainMenuComponent : public M5Component
{
public:
MainMenuComponent(void);
~MainMenuComponent(void);
virtual void Update(float dt);
virtual void FromFile(M5IniFile&);
virtual MainMenuComponent* Clone(void) const;
private:
};
//MainMenuComponent.cpp
void MainMenuComponent::Update(float /*dt*/)
{
M5Vec2 mouseClick;
M5Input::GetMouse(mouseClick);
if(M5Input::IsTriggered(M5_MOUSE_LEFT) &&
M5Intersect::PointRect(clickPoint,
m_pObj->pos, m_pObj->scale.x, m_pObj->scale.y))
{
M5StageManager::SetNextStage(ST_MainMenu);
}
}
前面的例子是一个非常简单的例子,因为它只是调用了一个带有硬编码参数的静态函数,但函数指针以及函数参数可以很容易地传递给这个组件的构造函数。实际上,我们可以将任何对象传递给构造函数,并在更新函数中硬编码一个特定的方法调用。例如,我们可以将一个 M5Object 传递给上面的组件。按钮点击可能会改变对象的纹理。例如:
// SwapTextureComponent.cpp
void SwapTextureComponent::Update(float /*dt*/)
{
M5Vec2 mouseClick;
M5Input::GetMouse(mouseClick);
if(M5Input::IsTriggered(M5_MOUSE_LEFT) &&
M5Intersect::PointRect(clickPoint,
m_pObj->pos, m_pObj->scale.x, m_pObj->scale.y))
{
//Get the Graphics Component
M5GfxComponent* pGfx = 0;
m_savedObj->GetComponent(CT_GfxComponent, pGfx);
//Do something to swap the texture...
}
}
不幸的是,这种代码有一个大问题;动作完全耦合到按钮点击。这有两个原因不好。首先,除非我们向我们的 UI 按钮点击组件添加额外的键,否则我们无法使用这个动作来响应键盘或控制器的按下。其次,当我们有一系列想要执行的动作时会发生什么?例如,同步多个 UI 对象的移动,或者编写游戏中的场景脚本。由于动作需要鼠标在对象上按下,我们的动作非常有限。
这个方法不好的另一个原因是我们必须在创建的每个按钮组件中重复相同的鼠标点击测试代码。我们希望做的是将动作与按钮点击组件解耦。我们需要创建一个单独的 UI 按钮组件和一个动作类。通过这样做,我们可以提取重复的代码部分,并且能够独立使用这些动作。
命令模式的解释
命令模式正是解决我们问题的模式。命令模式的目的就是将请求动作的请求者与执行动作的对象解耦。这正是我们面临的问题。我们的请求者是按钮,它需要与将要进行的任何特定函数调用解耦。命令模式将我们的函数指针概念包装成一个具有简单接口的类,用于执行函数调用。然而,这个模式给了我们更多的灵活性。我们将能够轻松地封装具有多个参数的函数指针,以及 C++对象和成员函数。让我们从两个具有相同参数数量和返回类型的简单函数开始:
int Square(int x)
{
return x * x;
}
int Cube(int x)
{
return x*x*x;
}
命令模式将请求封装成一个对象,并提供一个公共接口来执行该请求。在我们的例子中,我们将调用我们的接口方法Execute(),但它可以是任何名称。让我们看看Command抽象类:
//Base Command Class
class Command
{
public:
virtual ~Command(void) {}
virtual void Execute(void) = 0;
};
如您所见,命令模式接口非常简单——它只是一个单一的方法。通常,我们将该方法标记为纯虚函数,这样基类就不能被实例化。此外,我们创建一个空的虚析构函数,以便在需要时调用正确的派生类析构函数。正如我所说的,方法名称并不重要。我见过例如Do、DoAction、Perform等例子。在这里,我们将其称为Execute,因为这是《四人帮》所著原始书籍中的名称。
从一开始,我们就通过使用这个模式获得了比函数指针更多的好处。对于每个我们编写的派生类Execute方法,这意味着我们可以在那个Execute函数中直接硬编码任何函数和任何参数。回想一下,当使用函数指针时,我们需要在调用时传递参数:
//Derived command classes
class Square5Command: public Command
{
public:
virtual void Execute(void)
{
std::cout << "5 squared is " << Square(5) << std::endl;
}
};
在这个例子中,我们只是将函数调用和函数参数硬编码在原地。现在对于这样一个简单的函数来说,这可能看起来不太有用,但在游戏中可能会用到。正如我们稍后将要看到的,Mach5 引擎有一个退出游戏的命令。该命令直接调用StageManager::Quit()。
在大多数情况下,我们可能不想硬编码函数和参数。这正是这个模式力量的体现。在接下来的例子中,我们可以利用这两个函数具有相同签名的事实。这意味着我们可以创建一个函数指针,并将函数及其参数传递给命令。这里的优点是,因为命令是一个对象,它有一个构造函数。因此,我们可以构造一个具有动作及其参数的对象,该参数将由该动作使用:
//The function signature of both Square and Cube
typedef int (*OneArgFunc)(int);
//Command that can use any function of type OneArgFunc
class OneArgCommand: public Command
{
public:
OneArgCommand(OneArgFunc action, int* pValue):
m_action(action), m_pValue(pValue)
{
}
virtual void Execute(void)
{
*m_pValue = m_action(*m_pValue);
}
private:
OneArgFunc m_action;
int* m_pValue;
};
这里有一些有趣的事情正在发生。首先,这个命令可以调用任何返回 int 并接受一个 int 参数的函数。这意味着它可以用于 Square 和 Cube,也可以用于我们以后想出的任何其他函数。下一个有趣的事情是我们可以在构造函数中设置操作和参数;这允许我们在类中保存参数并在以后使用它们。仅使用函数指针是无法做到这一点的。最后,你可能已经注意到我们传递的是一个指向 int 的指针,而不是一个 int。这展示了我们如何保存函数调用的返回值,并且也允许我们以更灵活的方式考虑这些命令。
命令不仅用于退出游戏或更改阶段。我们可以有一个在执行时改变游戏对象位置的命令,或者根据某些用户输入或按钮点击交换玩家和敌人的位置。通过使用命令,我们可以通过 UI 控制游戏的各个方面。这听起来很像一个关卡编辑器。
现在我们已经看到了两种命令类型,让我们看看客户端如何使用它们。我们将从一个简单的 main 函数开始。我们将在这个调用它的函数中构建命令,但它们也可以通过函数调用设置。重要的是,在客户端调用 Execute 的点上,他们不需要知道正在调用哪个函数,或者需要什么参数(如果有的话):
int main(void)
{
const int SIZE = 3;
int value = 2;
//This commands could be loaded via another function
Command* commands[SIZE] = {
new Square5Command,
new OneArgCommand(Square, &value),
new OneArgCommand(Cube, &value),
};
//The Client Code
commands[0]->Execute();//Square5
std::cout << "value is " << value << std::endl;
commands[1]->Execute();//OneArg Square
std::cout << "value is " << value << std::endl;
commands[2]->Execute();//OneArg Cube
std::cout << "value is " << value << std::endl;
for (int i = 0; i < SIZE; ++i)
delete commands[i];
return 0;
}
上述代码的输出如下:
5 squared is 25
value is 2
value is 4
value is 64
如我们所见,客户端可以使用相同的接口调用不同的函数,而且不需要关心函数参数。对于这样一个简单的模式,命令模式是惊人的。而且它变得更好。
两个参数及以上
我们看到使用函数指针的一个限制是签名必须相同。它们必须有相同的返回类型,以及相同的参数类型和数量。我们可以看到这一点在命令模式中并不成立。客户端在调用时不需要知道或关心具体的签名,因为每个命令都共享公共的 Execute 接口。作为一个例子,让我们看看一个具有多个参数的函数,并为该类型创建一个命令。以下是该函数:
int Add(int x, int y)
{
return x + y;
}
如我们之前提到的,函数的复杂性并不重要。现在,让我们专注于接受多个参数的函数,就像这个 Add 函数的情况一样。为了使我们的代码更容易阅读,让我们也为这个签名创建一个 typedef:
typedef int (*TwoArgsFunc)(int, int);
最后,让我们为所有匹配此签名的函数创建一个 Command:
class TwoArgCommand: public Command
{
public:
TwoArgCommand(TwoArgsFunc action, int x, int y) :
m_action(action), m_first(x), m_second(y)
{
}
virtual void Execute(void)
{
std::cout << "The Result is "
<< m_action(m_first, m_second)
<< std::endl;
}
private:
TwoArgsFunc m_action;
int m_first;
int m_second;
};
main 函数现在更新如下。这里我们只显示代码中更改的部分:
Command* commands[SIZE] = {
new Square5Command,
new OneArgCommand(Square, &value),
new OneArgCommand(Cube, &value),
new TwoArgCommand(Add, 5, 6)
};
//The Client Code
commands[0]->Execute();//Square5
std::cout << "value is " << value << std::endl;
commands[1]->Execute();//OneArg Square
std::cout << "value is " << value << std::endl;
commands[2]->Execute();//OneArg Cube
std::cout << "value is " << value << std::endl;
commands[3]->Execute();//TwoArg
上述代码的输出如下:
5 squared is 25
value is 2
value is 4
value is 64
The Result is 11
正如你所见,我们可以轻松地为每个需要的函数指针签名创建一个新的命令。当客户端调用方法时,他们不需要知道使用了多少参数。不幸的是,尽管我们的命令可以接受多个参数,但这些参数仍然只能使用int。如果我们想让他们使用float,我们就需要创建新的命令或使用模板命令。
在现实世界的场景中,你可以根据需要创建命令,并且只为需要的类型创建它们。另一个更常见的选项是让命令调用 C++类方法,因为方法有使用类变量而不是传入参数的选项。
成员函数指针
到目前为止,我们已经看到了如何使用函数指针与命令模式结合,允许客户端调用我们的函数而无需关心参数类型或数量。这非常实用。但是,关于使用 C++对象与命令结合,我们该怎么办?虽然我们可以让命令与对象一起工作,但我们需要先思考一下这个问题。
调用成员函数最基本的方法是在Execute方法中简单地硬编码它们。例如,我们可以将一个对象传递给命令构造函数,并始终调用一个非常具体的函数。在示例中,m_gameObject是指向传递给构造函数的对象的指针。然而,Draw是我们总是调用的硬编码方法。这与在Square5Command中硬编码函数相同:
//Example of hard-coding a class method
virtual void Execute(void)
{
m_gameObject->Draw();
}
由于m_gameObject是一个变量,调用Draw的对象可以改变,但我们仍然总是调用Draw。在这种情况下,我们没有调用其他内容的选项。这仍然很有用,但我们希望有调用类类型上任何方法的能力。那么,我们如何获得这种能力呢?我们需要了解成员函数指针。
使用成员函数指针与使用非成员函数指针并没有太大的区别。然而,语法可能比你想象的要奇怪一些。回想一下,在调用非静态类方法时,第一个参数总是隐式传递给指针:
class SomeClass
{
public:
//Example of what the compiler adds to every
//Non-static class method. THIS IS NOT REAL CODE
void SomeFunc(SomeClass* const this);
private:
int m_x;
};
this指针允许类方法知道它需要修改哪个类的实例。编译器自动将其作为第一个参数传递给所有非静态成员函数,并且使用this指针的地址作为所有成员变量的偏移量:
SomeClass someClass;
//when we type this
someClass.SomeFunc();
//The compiler does something like this
SomeClass::SomeFunc(&someClass);
即使它是隐式传递的,并且不是参数列表的一部分,我们仍然可以在我们的代码中访问this指针:
void SomeClass::SomeFunc(/* SomeClass* const this */)
{
//We can still use the this pointer even though it isn't
//in the parameter list
this->m_x += 2;
//But we don't have to use it.
m_x += 2;
}
理解这一点很重要,因为普通函数和成员函数并不相同。类成员是类作用域的一部分,并且它们有一个隐式参数。因此,我们不能像普通函数那样保存对它们的指针。类方法的签名包括类类型,这意味着我们必须使用作用域解析运算符:
SomeClass someClass;
//This doesn't work because they are not the same type
void (*BadFunc)(void) = &SomeClass::SomeFunc;
//We must include the class type
void (SomeClass::*GoodFunc)(void) = &SomeClass::SomeFunc;
只要有正确的指针类型是不够的。类成员访问运算符,即点运算符(.)和箭头运算符(->),并不是设计用来与任意函数指针一起工作的。它们是设计用来与已知的数据类型或已知函数名称一起工作的,这些函数名称在类中声明。由于我们的函数指针直到运行时才知道,这些运算符将不起作用。我们需要不同的运算符,这些运算符将知道如何与成员函数指针一起工作。这些运算符是成员指针运算符(.*)和(->*)。
不幸的是,这些运算符的优先级低于函数调用运算符。因此,我们需要在对象和成员函数指针周围添加一个额外的括号组:
SomeClass someClass;
void (SomeClass::*GoodFunc)(void) = &SomeClass::SomeFunc;
//this doesn't work. GoodFunc isn't part of the class
someClass.GoodFunc();
//Extra parenthesis is required for .* and ->*
(someClass.*GoodFunc)();
成员指针还有很多内容。这里只是简要介绍。如果您想了解更多信息,请访问isocpp.org/wiki/faq/pointers-to-members。
成员指针命令
现在我们已经知道了如何使用成员函数指针,我们可以创建可以接受一个对象和要调用的特定成员函数的命令。就像之前一样,我们将使用一个简单的例子。示例类并没有设计成做任何有趣的事情,它只是用来演示概念:
class SomeObject
{
public:
SomeObject(int x):m_x(x){}
void Display(void)
{
std::cout << "x is " << m_x << std::endl;
}
void Change(void)
{
m_x += m_x;
}
private:
int m_x;
};
这里有一个简单的类,称为SomeObject。它有一个接受int参数的构造函数,并使用它来设置私有成员变量m_x。它还有两个函数:一个将值打印到屏幕上,另一个改变值。目前,我们通过给这两个成员函数相同的签名并且不接收任何参数来保持事情简单。这允许我们为这种类型的方法创建一个typedef。请记住,类类型是函数签名的一部分:
typedef void (SomeObject::*SomeObjectMember)(void);
这创建了一个名为SomeObjectMember的类型,它可以很容易地用作函数参数、函数返回类型,甚至可以作为一个成员保存到另一个类中(当然,这正是我们接下来将要做的)。即使你对函数指针和成员函数指针的语法非常熟悉,仍然是一个好的实践来创建这些typedef。它们使代码对每个人来说都更容易阅读,正如你将在下一个代码示例中看到的那样:
class SomeObjectCommand: public Command
{
public:
SomeObjectCommand(SomeObject* pObj, SomeObjectMember member) :
m_pObj(pObj), m_member(member)
{
}
virtual void Execute(void)
{
(m_pObj->*m_member)();
}
private:
SomeObject* m_pObj;
SomeObjectMember m_member;
};
由于调用成员函数指针的语法可能很难正确使用,使用#define宏可能会有所帮助。虽然大多数时候应该避免使用宏,但这是少数几次宏可以帮助使代码更易读的情况:
#define CALL_MEMBER_FUNC(pObj, member) ((pObj)->*(member))
这将我们的Execute函数改为如下:
virtual void Execute(void)
{
CALL_MEMBER_FUNC(m_pObj, m_member)();
}
我们所做的一切就是将丑陋的东西隐藏在宏中,但至少人们会更好地理解它在做什么。需要注意的是,这个宏只适用于对象指针,因为它使用了箭头星运算符(->*)。
现在,在main函数中,我们可以创建指向对象成员的命令:
int main(void)
{
const int SIZE = 6;
int value = 2;
SomeObject object(10);
Command* commands[SIZE] = {
new Square5Command,
new OneArgCommand(Square, &value),
new OneArgCommand(Cube, &value),
new TwoArgCommand(Add, 5, 6),
new SomeObjectCommand(&object, &SomeObject::Display),
new SomeObjectCommand(&object, &SomeObject::Change)
};
//The Client Code
commands[0]->Execute();//Square5
std::cout << "value is " << value << std::endl;
commands[1]->Execute();//OneArg Square
std::cout << "value is " << value << std::endl;
commands[2]->Execute();//OneArg Cube
std::cout << "value is " << value << std::endl;
commands[3]->Execute();//TwoArg
//Member function pointers
commands[4]->Execute();//Display
commands[5]->Execute();//Change
commands[4]->Execute();//Display
for (int i = 0; i < SIZE; ++i)
delete commands[i];
return 0;
}
以下是指令层次结构的类图:

图 8.3 - 命令层次结构
尽管这只是一个简单的演示,但我们可以看到,无论它们是调用函数指针还是成员函数指针,无论参数数量如何,客户端代码都是相同的。不幸的是,我们仍然需要为每个需要的函数和类类型创建一个typedef。然而,C++模板也可以帮助我们。我们可以创建一个模板命令类,它可以调用具有特定签名(在我们的情况下,void (Class::*)(void))的类方法,这将适用于所有类:
template<typename Type, typename Method>
class TMethodCommand: public Command
{
public:
TMethodCommand(Type* pObj, Method method) :
m_pObj(pObj), m_method(method)
{
}
virtual void Execute(void)
{
(m_pObj->*m_method)();
}
private:
Type* m_pObj;
Method m_method;
};
正如你在Execute方法中看到的,这仅限于调用不带参数的方法,但它可以很容易地修改以适应你游戏的需求。
命令模式的好处
如果看所有那些疯狂的代码让你眼花缭乱,你并不孤单。函数指针和成员函数指针调用的复杂语法是 C++中最难的部分之一。因此,许多人避免使用它们。然而,他们也错过了这些特性提供的强大功能。
另一方面,仅仅因为某件事物很强大,并不意味着它总是适合这项工作的正确工具。简单通常更好,而且由于存在许多间接层次,我们刚才看到的代码有可能引发很多错误。是否使用这些工具取决于你自己的决定。话虽如此,让我们讨论一下使用命令模式的优点,这样你可以更好地决定何时何地使用它。
将函数调用视为对象
使用命令模式的最大好处是我们封装了函数或方法调用及其参数。这意味着调用所需的一切都可以传递给另一个函数,从函数返回,或存储为变量以供以后使用。这比仅使用函数或方法指针提供了额外的间接层次,但它意味着客户端不需要担心细节。他们只需要决定何时执行命令。
由于在传递给客户端之前我们需要知道所有函数参数,这可能看起来并不很有用。然而,这种情况可能比你想象的更常见。客户端不需要知道函数调用的细节意味着,例如 UI 这样的系统可以非常灵活,甚至可能从文件中读取。
在上述示例中,很明显,在调用时,客户端不知道给定数组索引中存在哪个命令。这是设计上的。可能不那么明显的是,数组可能已经使用函数的返回值而不是使用新操作符(我们在第五章,通过工厂方法模式解耦代码中学到)的硬编码调用填充。这种灵活性意味着要执行的函数可以在运行时更改。
这的一个完美例子是游戏中一个上下文相关的 动作按钮。由于游戏手柄上的按钮数量有限,根据玩家正在做什么来改变按钮的动作通常很有用。这可能意味着一个按钮负责与 NPC 交谈、拾取物品、打开门或根据玩家的位置和所做的事情触发 快速反应事件。
没有命令模式,组织、维护和执行游戏中所有可能动作的逻辑将会极其复杂。有了命令模式,它为每个可执行项提供了一个命令,并在玩家靠近时使其可用。
物理上解耦客户端和函数调用
好设计的其中一个方面是低耦合。我们之前已经讨论过很多次这个问题,这里同样适用。首先,由于客户端只依赖于基础 Command 类,因此更容易进行测试。这是因为客户端和特定的函数调用或操作都可以独立测试,以确保它们能正常工作。此外,由于这些单元测试测试的是更小的代码量,我们可以更有信心地认为所有可能的用例都得到了测试。这也意味着客户端或命令有更大的机会因为项目内部低耦合而被重用。
其次,当代码库发生变化时,客户端不太可能出错。由于客户端不知道调用的是哪些函数或方法,任何对参数数量或方法名称的更改都仅限于实现更改方法的命令。如果需要添加更多命令,这些命令将自动与现有客户端一起工作,因为它们将使用 Command 类接口。
最后,编译时间可以减少,因为客户端需要包含的头部文件更少。包含更少的头部文件可以降低编译时间,因为每次头部文件发生变化时,包含它的每个源文件都必须重新编译。即使是头部文件中注释的最小更改,也意味着所有从该头部文件调用的函数调用都必须在编译时重新检查语法,并在链接时重新链接。由于我们的客户端不知道函数调用的细节,因此不需要包含任何头部文件。
时间解耦
这种类型的解耦讨论得不多,因为它只适用于少数情况,而且大多数情况下,这并不是我们想要的。通常,当我们调用一个函数时,我们希望它立即执行。我们代码中有特定的算法,该代码的时序和顺序非常重要。这并不适用于所有代码。一种情况是多线程代码,其中多个代码路径同时执行。其他情况是 UI 或上下文相关的按钮,其中要执行的操作是预先设置的,而不是硬编码在位置上。让我们通过一些代码作为例子来看一下:
//Examples of setting up function calls
//Immediate execution
Add(5, 6);
//Delayed execution
Command* p1 = new TwoArgCommand(Add, 5, 6);
//Immediate execution
someObject.Display();
//Delayed execution
Command* p2 = new SomeObjectCommand(&object,&SomeObject::Display);
在上述所有四种情况下,都给出了函数和参数。然而,命令版本可以根据客户端的需求传递给其他方法,根据需要调用和/或恢复。
撤销和重做
将调用细节打包到类中的另一个主要好处是能够撤销操作。每个现代桌面应用程序,以及目前制作得最好的网络应用程序,都具备撤销最后一步或几步操作的能力。当你为你的游戏实现关卡编辑器时,这应该是一个你努力遵循的标准。
在应用程序中实现单级撤销可能看起来是一项庞大的任务。直观的方法可能是保存应用程序的整个状态,可能是一个文件,并在需要撤销时重新加载该状态。根据应用程序的不同,可能需要保存大量数据。这种方法在可以具有数十或数百级撤销的应用程序中扩展性不好。随着用户执行更多操作,你需要确保在保存当前状态之前删除最旧的状态。
当你还需要实现重做时,这种简单的方法变得更加困难。显然,我们每天使用的文本编辑器和工具不会在硬盘上存储数百个撤销和重做文件。一定有更好的方法。
而不是保存整个程序的状态,你只需要保存关于发生动作的信息以及哪些数据被更改。保存一个函数及其参数听起来很像命令模式。让我们看看一个简单的例子,在关卡编辑器中将游戏对象从一个地方移动到另一个地方。我们可以创建一个像这样的命令:
class MoveCommand: public Command
{
public:
MoveCommand (Object* pObj, const Vec2D& moveTo) :
m_pObj(pObj), m_method(method), m_oldPos(pObj->pos)
{
}
virtual void Execute(void)
{
m_pObj->pos = m_moveTo;
}
//Add this method to the Command Interface
virtual void Undo(void)
{
m_pObj->pos = m_oldPos;
}
private:
Object* m_pObj;
Vec2D m_moveTo;
Vec2D m_oldPos;//Save the old position so we can redo
};
通过将Undo方法添加到命令接口,并确保在Execute方法中保存将被修改的旧数据,撤销和重做变得极其简单。首先,我们需要为我们的编辑器中可以执行的所有操作实现一个命令。然后,当用户与编辑器交互时,他们总是调用一个命令并将其添加到我们的命令数组末尾。撤销和重做只是调用当前数组索引的Execute或Undo方法的问题。
创建所有这些命令可能看起来工作量很大,确实如此。然而,这项工作取代了当用户按键或点击鼠标时硬编码函数调用的工作。最终,你会构建一个人们愿意使用的更好的系统。
在 Mach5 中使用命令实现简单 UI
既然我们已经了解了命令模式是什么,让我们看看它在 Mach5 引擎中的应用。你会惊讶地发现这里代码并不多。这是因为一旦你理解了背后的代码,使用命令模式就变得很简单。在本节中,我们将查看负责鼠标点击的组件以及引擎内部使用的命令。
让我们看看M5Command类:
class M5Command
{
public:
virtual ~M5Command(void) {}//Empty Virtual Destructor
virtual void Execute(void) = 0;
virtual M5Command* Clone(void) const = 0;
};
这里是 Mach5 引擎中使用的 M5Command 类。正如你所见,它几乎与我们在示例中使用的 Command 类完全相同。唯一的区别是,由于我们计划在组件内部使用它,因此它需要一个虚拟构造函数。这样我们就可以在不了解真实类型的情况下复制它。
UIButtonComponent 类的代码如下:
class UIButtonComponent: public M5Component
{
public:
UIButtonComponent(void);
~UIButtonComponent(void);
virtual void Update(float dt);
virtual UIButtonComponent* Clone(void) const;
void SetOnClick(M5Command* pCommand);
private:
M5Command* m_pOnClick;
};
如你所见,我们的 UI 按钮是一个组件。这意味着任何游戏对象都有可能被点击。然而,这个类是专门设计来与屏幕空间中的对象一起工作的,这是操作系统给我们鼠标坐标的方式。这里的其余代码看起来可能正如你所期望的那样。作为 UIButtonComponent 类的一部分,我们有一个私有的 M5Command。尽管这个类很简单,但我们会逐一查看每个方法的功能:
UI Button Component::UI Button Component(void) :
M5Component(CT_UIButtonComponent), m_pOnClick(nullptr)
{
}
构造函数很简单(就像大多数组件构造函数一样),因为它们被设计为通过工厂创建。我们设置组件类型,并确保将命令指针设置为空,以便为后续的更安全代码做好准备:
UIButtonComponent::~UIButtonComponent(void)
{
delete m_pOnClick;
m_pOnClick = 0;
}
析构函数是空指针派上用场的地方。删除空指针是完全合法的,因此我们知道这段代码将工作,即使这个组件从未收到命令:
void UIButtonComponent::Update(float)
{
if (M5Input::IsTriggered(M5_MOUSE_LEFT))
{
M5Vec2 clickPoint;
M5Input::GetMouse(clickPoint);
if (M5Intersect::PointRect(clickPoint, m_pObj->pos,
m_pObj->scale.x, m_pObj->scale.y))
{
M5DEBUG_ASSERT(m_pOnClick != 0,
"The UIButton command is null"):
m_pOnClick->Execute();
}
}
}
Update 函数是我们执行测试以查看鼠标点击是否与对象创建的矩形相交的地方。正如我们之前提到的,这个类可以与所有对象一起工作,但为了简化代码,我们决定只使用这个类来处理屏幕空间中的项目。在这个决定中,重要的代码是 GetMouse 函数。这个函数始终返回屏幕空间中的坐标。检查对象是在屏幕空间还是世界空间中,并使用 M5Gfx 方法 ConvertScreenToWorld 转换坐标是可能的。
那个空指针在这里也很有用。由于我们知道命令指针是有效的或为空,我们可以在执行它之前对代码进行调试断言来测试我们的代码:
UIButtonComponent* UIButtonComponent::Clone(void) const
{
UIButtonComponent* pClone = new UIButtonComponent();
pClone->m_pObj = m_pObj;
if(pClone->m_pOnClick != nullptr)
pClone->m_pOnClick = m_pOnClick->Clone();
return pClone;
}
Clone 方法看起来可能正如你在阅读第六章,使用原型模式创建对象后所期望的那样。这是我们需要在使用命令之前始终测试空值的一种情况。我们不能克隆一个空命令,并且无论命令是否已设置,克隆此组件都是完全有效的:
void UIButtonComponent::SetOnClick(M5Command* pCommand)
{
//Make sure to delete the old one
delete m_pOnClick;
m_pOnClick = pCommand;
}
SetOnClick 方法允许我们设置和重置与此组件关联的命令。再次强调,我们在删除命令之前不需要测试我们的命令。同样,我们也不需要测试方法参数是否为非空,因为空值是完全可接受的。
尽管我们还没有为这个类做这个,但这个类可以很容易地扩展以包括一个 OnMouseOver 事件,当鼠标在对象矩形内但未点击时,该事件会被触发。这样的功能可以为 UI 和世界对象提供很多用途。实现它就像在 Update 函数中交换两个条件语句一样简单:
void UIButtonComponent::Update(float)
{
M5Vec2 clickPoint;
M5Input::GetMouse(clickPoint);
if (M5Intersect::PointRect(clickPoint, m_pObj->pos,
m_pObj->scale.x, m_pObj->scale.y))
{
if (M5Input::IsTriggered(M5_MOUSE_LEFT))
{
//Do onClick Command
}
else
{
//Do onMouseOver Command
}
}
}
使用命令
现在我们已经看到了基本的 M5Command 类和 UIButtonComponent 类,让我们看看其中一个派生命令,看看它在游戏中是如何使用的。我们将要查看的命令是游戏中常见的一个。这是允许我们从一个阶段切换到下一个阶段的动作:
class ChangeStageCommand: public M5Command
{
public:
ChangeStageCommand(M5StageTypes nextStage);
ChangeStageCommand(void);
virtual void Execute(void);
void SetNextStage(M5StageTypes nextStage);
virtual ChangeStageCommand* Clone(void) const;
private:
M5StageTypes m_stage;
};
当与 UIButtonComponent 一起使用时,这将允许用户点击按钮并切换到新阶段。正如你所见,在构造函数和 SetNextStage 方法中,有两种方式可以改变阶段。这使用户能够创建一个命令,并在以后决定它将切换到哪个阶段。Execute 方法尽可能简单,因为 StageManager 是一个单例:
void ChangeStageCommand::Execute(void)
{
M5StageManager::SetNextStage(m_stage);
}
以下是将要输出的内容:

图 8 4 - Mach5 引擎中 UIButtons 的示例
要真正实现灵活性,我们希望所有 UIButtons 都能从文件中加载。就像游戏对象一样,最好菜单和关卡不与特定命令耦合。至少,我们更愿意避免为每个按钮硬编码位置和大小。这在使用游戏对象时证明是很容易的。玩家或掠夺者游戏对象非常具体,所以在读取关卡文件时,我们只需要覆盖每个对象的位置。大小、纹理名称和其他属性可以从中更具体的原型文件中读取。
按钮更难一些,因为每个按钮可能使用不同的纹理名称,有不同的尺寸,并使用不同的命令。我们无法在按钮原型文件中设置这些数据,因为所有按钮都是不同的。此外,需要控制特定游戏对象的命令很难从文件中加载,因为我们除了类型之外没有关于对象的信息。这意味着虽然我们可以创建和加载一个控制玩家的命令,我们只有一个,但我们不能创建和加载一个控制任意掠夺者的命令,因为我们可能每个阶段都有很多。
一个高质量的关卡编辑器可以解决这两个问题,因为工具可以更好地管理数据。这甚至可以包括分配可以由游戏中的命令使用的对象 ID。对于这本书,为每个按钮定义原型效果很好。虽然这看起来像是一项大量工作,但每个原型文件中的数据否则将硬编码到 .cpp 文件中。
概述
在本章中,我们专注于创建灵活、可重用的按钮。尽管 UI 可能不如游戏机制那样有趣或值得讨论,但对玩家来说,它同样重要。这就是为什么创建一个良好的系统,以智能的方式添加和管理 UI,对于制作一款优秀的游戏至关重要。
我们深入研究了 C++函数指针和成员指针。这因其复杂性和难以理解而闻名。然而,通过掌握这些技术,我们可以创建灵活的命令,可以调用任何 C 风格函数或 C++对象方法。
虽然这项技术并非总是必需的,但在 UI 方面,它使我们能够创建一个极其灵活的系统。我们的 UI 对象和大多数命令都可以从文件中设置和读取。如果你要创建一个关卡编辑器,你可以轻松地使用这个系统从文件中创建和读取所有 UI 按钮和命令。
现在我们已经有一个灵活的系统来创建 UI,让我们继续讨论制作游戏时每个人都会遇到的其他问题。在下一章中,我们将讨论一种模式,它将使我们能够更好地将引擎代码与游戏代码分离。
第九章:通过观察者模式解耦游戏
哇!前两章充满了指针疯狂。这两章结合起来,涵盖了可能被认为是 C++中最难的部分。虽然所有设计模式都在某种程度上处理指针和使用虚函数,但与第七章《通过对象池提高性能》和第八章《通过命令模式控制 UI》中涵盖的内容相比,难度并不会更大。
在第七章《通过对象池提高性能》中,我们深入到了 C++内存的底层。这涉及到类型转换和奇怪的指针操作,大多数人都会避免。在第八章《通过命令模式控制 UI》中,我们处理了控制 UI 的问题,这是一个更高级别的操作。然而,我们学习了如何以允许我们创建灵活代码的方式控制 C++对象及其方法,但这也可能非常令人困惑。
如果您觉得那些章节很舒服,那么您做得很好。如果您觉得那些主题有点难,您并不孤单。无论如何,您都应该很高兴地知道,本章涵盖了一个易于理解、易于实现且易于将我们的核心系统与可能经常更改的游戏特定代码解耦的模式。
在本章中,我们将介绍一个名为观察者的模式,它用于以解耦的方式将对象连接在一起。正如您将看到的,它非常简单易实现,并且可以在我们的代码库的许多地方应用。
章节概述
本章全部关于学习如何使用观察者模式解耦代码。本章与其他章节略有不同,因为它不是关于使用观察者模式解决一个大模式;它是关于学习将其应用于导致游戏开发过程中代码混乱的许多小情况。观察者模式将向您展示有更好的方法。
首先,我们将探讨游戏代码不可避免地泄漏到我们的引擎代码中的情况。然后,我们将学习观察者模式以及它如何改善这些情况。在这个过程中,我们将查看来自 Mach5 引擎的一些示例代码。然而,由于这个模式有如此多的用途,我们将关注它如何被整合到游戏中,而不是展示一个大型具体的示例。
您的目标
-
学习两种可能导致您的引擎代码出现问题的游戏代码方式
-
实现简单的观察者模式示例
-
学习观察者模式的优缺点
游戏如何渗透到每个系统中
第一次有人制作游戏时,游戏和引擎之间很可能没有明显的区别。这通常是因为没有引擎。常见的第一个游戏可能包括井字棋或猜谜游戏。这样的游戏足够简单,可以完全在 main 函数中编写,或者可能使用几个函数。它们也足够简单,不需要复杂的系统,如图形或物理。不需要可重用的引擎代码。
当你学习编程更多时,你可能会决定尝试使用图形 API(如 DirectX 或 OpenGL)制作一个 2D 游戏。这样的代码第一次使用时可能非常困难,所以编写干净分离的代码不是首要任务。就像以前一样,游戏只是用几个函数或类制作的。绘图代码通常与碰撞代码混合在一个文件中。
在某个时候,我们都会到达一个地方,那里的代码变得过于复杂和脆弱。硬编码太多的游戏对象类型或太多的关卡让我们希望有更好的方法。当然,这正是这本书的原因。我们正在尝试找到创建游戏更好的方法。在这本书的整个过程中,有一个主要主题:事情总是变化的!
为了应对这种变化,我们试图在我们的游戏部分中区分出那些会变化的部分和那些不太可能变化的部分。用明确的话说,我们正在尝试将我们的引擎代码与游戏玩法代码分开。这种清晰的部件分离引导我们通过了八个章节,这些章节解决了游戏中非常庞大和具体的问题。每个游戏都必须处理创建具有复杂行为的灵活游戏对象。因此,我们学习了组件对象模型和有限状态机。每个游戏都必须处理创建易于修改的用户界面。因此,我们学习了如何使用命令模式从文件中读取动作。这些都是常见问题,有常见解决方案。
然而,随着你编写更多的代码并开始向你的游戏添加更多功能,你总会发现引擎和游戏玩法之间的清晰分离开始变得模糊。其中一个变得明显的地方是物理。物理引擎负责移动对象,以及测试和解决碰撞。
虽然这个引擎应该是纯粹的数学,但事实是,一个游戏不仅仅由物理对象组成。它由子弹、侵略者、玩家等等组成。当子弹与玩家碰撞时,我们必须执行一些非常具体的游戏玩法代码,例如删除子弹,在碰撞点创建一个小粒子效果,并减少玩家的生命值。问题是,这段代码应该在何处执行?如果代码放在物理引擎内部,它将与每个游戏对象类型高度耦合。如果它在引擎外部执行,我们需要以干净的方式将碰撞信息传递到正确的位置。
游戏成就也会出现同样的逐渐增加游戏代码的问题。成就总是特定于游戏的,但最终它们会混入整个代码库中。它们可能包括跟踪行为,比如玩家发射了多少子弹,到跟踪总游戏时间或游戏暂停了多久。然而,它们也可能总是与引擎特定的行为相关,比如分辨率更改了多少次,建立了多少网络连接,创建了或销毁了多少 UFO 游戏对象,或者发生了多少种特定类型的碰撞事件。这种引擎和游戏代码之间的模糊界限,以及一般性的依赖性增加,使得代码重用变得非常困难。
硬编码需求
我们知道,将游戏代码引入我们的引擎会增加依赖性并限制代码重用。我们还知道,对于特定的动作,随着游戏功能的添加,需求很可能会发生变化。想象一下在我们的太空射击游戏中添加分屏多人游戏控制器支持的情况。随着更多控制器被插入,会创建更多的玩家,游戏难度增加,我们分割屏幕以跟随新玩家。在这种情况下,原始的控制器检测发生在输入管理器中,但我们需要通知几个其他引擎,有事情发生了。这段代码的一个例子可能如下所示:
//Not real code, just an example!
void InputManager:Update(void)
{
int controllerCount = GetControllerCount();
if(controllerCount > m_currentControllerCount)
{
m_currentControllerCount = controllerCount;
Object* pObj = ObjectManager::CreatePlayer(controllerCount);
GameLogic::SetDifficulty(controllerCount);
//player position is the camera location for the screen
Graphics::SetScreenCount(controllerCount, pObj->pos);
}
}
我们可以合理地确信这段代码不会改变。如果我们确信,那么直接硬编码需求是可行的。然而,如果我们不确定,最好假设需求总是会发生变化的。我们可能需要支持在线多人游戏并向网络管理器发送消息。我们可能允许玩家从可能的玩家飞船列表中选择他们想要的飞船类型,因此我们需要调用不同的对象管理器函数,或者通过阶段管理器暂停并切换到新的飞船选择阶段。
在这种情况下,我们有一组对象,当事件发生时需要被通知。我们希望通知自动发生,但不想每次有新的对象需要被通知时都更改输入管理器。更普遍地说,我们有一个广播对象,我们不想每次有新的对象需要监听时都更改它。这就像 Wi-Fi 路由器需要每次有新设备进入范围时都进行更新一样。
上述情况描述了不同核心引擎之间的交互。然而,这些交互之所以以这种方式发生,仅仅是因为游戏的具体需求。即使没有游戏代码,特定游戏的功能已经逐渐渗透到输入引擎中,如果我们要制作不同的游戏,就需要对其进行更改。当然,我们可以尝试将一些代码提取到游戏逻辑引擎中,或者只是将类似的代码放入一个阶段。还有其他方法吗?我们将考虑这一点,但首先,我们将从另一个角度探讨这个问题。
轮询
尽管你尽了最大努力,游戏玩法往往变得一团糟。游戏中有许多相互作用的部件,完全减少游戏代码的耦合是不可能的。虽然图形引擎和物理引擎可以完全独立且解耦是有道理的,但我们甚至不应该尝试在游戏玩法上这样做。游戏代码就是游戏本身。我们能做的最好的事情就是尝试优雅地处理游戏设计的修改。
这种情况最明显的例子是我们从第四章中看到的,使用状态模式的智能代理。状态通常需要访问对象管理器,因此会扫描整个对象列表以查找对象。它们也可能需要访问物理引擎以查看它们是否在本帧或下一帧与对象发生碰撞。游戏代码是将其他所有东西粘合在一起的代码,因此它实际上不能完全解耦。
另一个例子可能是以屏幕空间绘制 HUD 对象。如果窗口分辨率改变,对象需要重新定位。屏幕中央的 800 x 600 分辨率的按钮在 1280 x 1024 分辨率下仍然需要在屏幕中央。这意味着位置不能硬编码,必须在分辨率改变时自动调整。有两种方法可以做到这一点。第一种与上面的例子相同;我们可以让更改分辨率的按钮调用其他系统的方法。第二种是让关心分辨率更改的对象向应用程序请求分辨率:
void RepositionComponent::Update(float /*dt*/)
{
M5Vec2 windowSize = M5App::GetResolution();
m_pObj->pos.x = windowSize.x * m_xScale;
m_pObj->pos.y = windowSize.y * m_yScale;
}
这里是一个RepositionComponent的例子。每一帧,它都会向M5App请求窗口分辨率,并将对象设置为窗口指定的缩放比例。一个需要位于屏幕中央的对象会有x和y缩放值为.5。如果分辨率是 800 x 600,对象的位将是x = 400,y = 300。如果分辨率更改为 1280 x 1024,对象的位将是x = 640,y = 512。
这完全符合预期,但它做了很多不必要的操作。问题不在于这段代码会让你的游戏变慢;你可以在看到减速之前让成千上万的物体这样做。然而,这个组件每秒请求 60 次分辨率,而分辨率更改可能只在一次游戏会话中发生一次。更有可能的是,玩家会选择一次分辨率,游戏设置会将其保存到文件中,并在之后的每次会话中自动加载它。
这种轮询方法的问题在于RepositionComponent无法知道分辨率何时改变。如果这段代码只在数据改变时运行,那会很好。相反,它不断地请求完全相同的数据,并在每一帧计算完全相同的位置。
上面的例子很小。类似这样的问题可能看起来很微不足道。单独来看,它们甚至可能不是问题,但,当它们累积起来时,可能会影响你的游戏。这一点在本章迄今为止的所有例子中都是成立的。它们看起来很容易一个个解决,但它们可能会累积成大量的浪费的 CPU 周期以及开发者的时间。如果能有一个易于实现的模式来解决游戏中出现的这些小问题那就太好了。幸运的是,观察者模式正是如此。
观察者模式解释
观察者模式的目的是定义对象之间的一对多关系。当一个对象的状态发生变化时,所有依赖它的对象都会得到通知。在这个模式中,对象的典型名称是主题(单个),和观察者(多个)。主题将包含观察者需要知道的数据。而不是通常的类从另一个类请求数据(轮询)的情况,我们的主题将在数据发生变化时通知观察者列表。
术语主题和观察者一开始可能有点令人困惑。然而,这个概念非常简单,而且是我们大多数人熟悉的。在尝试理解观察者模式时,想想博客和订阅者。在这种情况下,博客是主题,订阅者是观察者。
一个博客可能每天更新一次,每周更新一次,每月更新一次,甚至更少。博客的读者可以选择检查更新,然而,如果读者检查的频率高于博客更新的频率,这可能会浪费很多时间。相反,粉丝通常会选择订阅电子邮件列表,以便在更新发布时得到通知。博客保留一个订阅者列表,并在更新发布时给列表上的每个人发送电子邮件。
主题和观察者
让我们通过一个代码示例来更好地理解这个模式。实现这个模式有几种不同的方法,所以我们将沿途讨论实现策略:
class Observer
{
public:
virtual ~Observer(void) {}
virtual void Update(float currentHealth, float maxHealth) = 0;
};
我们从我们的Observer接口开始。和往常一样,我们使我们的析构函数为虚函数。我们需要的唯一方法是Update方法,然而,和往常一样,名称并不重要。这是主题将用来通知观察者有东西发生变化的那个方法。你可能注意到更新非常具体。在这种情况下,它有两个浮点数作为参数。这是一个可能会改变并导致我们的代码出错的依赖项。我们将在稍后讨论改进。
你可能也会注意到没有成员数据。可能给基类提供一个指向主题的指针,并让这个类负责向主题注册和注销(订阅和取消订阅)。我们决定将这种行为移动到派生类中,这样我们可以尽可能保持基类简单:
class Subject
{
public:
virtual ~Subject(void) {}
virtual void RegisterObserver(Observer* pToAdd) = 0;
virtual void UnregisterObserver(Observer* pToRemove) = 0;
virtual void Notify(void) = 0;
};
我们的主题(Subject)几乎和观察者(Observer)一样简单。我们需要的关键方法是观察者订阅和取消订阅主题的方式。在这里,我们称这些方法为RegisterObserver和UnregisterObserver。我们还添加了一个Notify方法,它将用于调用所有已注册观察者的Update方法。这个方法没有必要是公开的,甚至根本不需要存在。只要派生类在注册的观察者上调用Update,我们就正确地使用了这个模式。
再次,你会注意到这个类中没有数据成员。我们可以在基类中轻松地添加一个观察者指针的向量。实际上,我们可以轻松实现这些方法,因为它们几乎总是相同的。然而,我们选择保持这个类简单,并让派生类选择如何实现这些方法。
玩家
为了展示观察者模式如何使用,我们将考察游戏中常见的情景。我们将有一个玩家,其拥有一些需要共享的生命值。玩家的生命值在游戏中可以用于许多事情。生命值的数值可能作为 HUD 的一部分显示。它也可以在 HUD 中或直接在玩家的顶部或底部以彩色生命条的形式显示。此外,当玩家生命值达到或低于零时,游戏可能会切换到游戏结束界面。
这些显示元素以及场景切换机制直接依赖于玩家的生命值。由于这些变量不太可能都在同一个作用域中,如果我们尝试通过轮询来实现,这将需要一些工作。在这种情况下,每个对象都需要找到玩家并请求生命值。由于玩家的生命值不太可能每帧都改变,大部分工作都是浪费的。相反,我们将让玩家从主题(Subject)派生,这样当生命值改变时,它就可以通知所有观察者:
class Player: public Subject
{
public:
Player(float maxHealth);
void AdjustHealth(float health);
virtual void RegisterObserver(Observer* pToAdd);
virtual void UnregisterObserver(Observer* pToRemove);
virtual void Notify(void);
private:
typedef std::vector<Observer*> ObserverVec;
float m_maxHealth;
float m_health;
ObserverVec m_observers;
};
这个Player类非常简单。由于这只是一个示例,我们只会关注生命值。在构造函数中,我们可以设置最大生命值。AdjustHealth方法将用于改变生命值。当然,我们也会实现基类中的每个虚拟方法。在private部分,我们使用 STL 向量来跟踪我们的观察者。我们还存储了构造函数的值,以及我们当前生命值的变量:
Player::Player(float maxHealh):
m_maxHealth(maxHealth),
m_health(maxHealth)
{
}
Player构造函数设置用户传入的数据。由于基Subject类没有数据,这里没有特别的事情要做:
void Player::RegisterObserver(Observer* pToAdd)
{
ObserverVec::iterator itor;
itor = std::find(m_observers.begin(),
m_observers.end(),
pToAdd);
assert(itor == m_observers.end());
m_observers.push_back(pToAdd);
}
RegisterObserver方法接受一个观察者的指针并将其添加到观察者的向量中。根据观察者的行为,被添加到列表两次可能会引起很多问题,并且可能是一个难以追踪的困难错误。在这个例子中,我们选择断言如果相同的观察者被添加两次。之后,我们将其添加到我们的向量中:
void Player::UnregisterObserver(Observer* pToRemove)
{
ObserverVec::iterator itor;
itor = std::find(m_observers.begin(),
m_observers.end(),
pToRemove);
if (itor != m_observers.end())
{
std::swap(*itor, *(--m_observers.end()));
m_observers.pop_back();
}
}
我们的 UnregisterObserver 类稍微宽容一些。如果我们没有在向量中找到观察者,我们会忽略它,而不是抛出一个断言。这将在稍后更加清晰。你会看到我们的观察者将自动在它们的析构函数中移除或注销。然而,注销两次不太可能引起问题。std::swap(*itor, *(--m_observers.end())) 这行代码可能看起来有点吓人。记住,end 方法返回一个指向向量末尾之后的迭代器。所以,在我们解引用之前,我们递减我们的迭代器,使其指向向量中的最后一个元素。然后我们交换并弹出,移除正确的元素:
void Player::Notify(void)
{
size_t size = m_observers.size();
for (size_t i = 0; i < size; ++i)
m_observers[i]->Update(m_health, m_maxHealth);
}
如我们之前所说,Notify 方法并不需要存在。如果类逻辑在内部通知观察者,比如在 Setter 方法或数据变化时,如我们的 AdjustHealth 方法,那就很好了。然而,如果有多个观察者关心的数据,用户可以做出许多更改,并一次性将所有数据发送给观察者。或者,也许在游戏开始之前初始化观察者数据。
这种方法很简单。它遍历观察者的向量,并调用 Update 方法,将健康数据发送给关心这些数据的对象:
void Player::AdjustHealth(float adjustHealth)
{
m_health += adjustHealth;
Notify();
}
这种方法模拟玩家获得或失去健康。如你所见,在健康值修改后,类调用自己的 Notify 方法,让所有观察者都知道这个变化。
观察者
对于这个例子,我们有之前提到的三个观察者。两个与以不同方式显示玩家健康值有关;另一个用于玩家健康值为零或以下时退出:
//Used to quit the game when the "game", when the player's health
//is less than or equal to 0
class StageLogic : public Observer
{
public:
StageLogic (Subject* pSubject);
bool IsQuitting(void) const;
~StageLogic(void);
virtual void Update(float currentHealth, float maxHealth);
private:
bool m_isQuitting;
Subject* m_pSubject;
};
//Used to Color the player health bar based on the how full it is
class PlayerHealthBar : public Observer
{
public:
PlayerHealthBar(Subject* pSubject);
~PlayerHealthBar(void);
void Display(void) const;
virtual void Update(float currentHealth, float maxHealth);
private:
float m_percent;
std::string m_color;
Subject* m_pSubject;
};
//Used to Display the health of the player as a value
class PlayerDisplay : public Observer
{
public:
PlayerDisplay(Subject* pSubject);
~PlayerDisplay(void);
void Display(void) const;
virtual void Update(float currentHealth, float maxHealth);
private:
float m_health;
Subject* m_pSubject;
};
如你所见,每个派生 Observer 类都重载了基类的 Update 方法。你也会注意到每个构造函数只接受一个指向主题的指针作为唯一参数,并将该指针保存到成员变量中。这并不是必需的,但它使得注册和注销更加方便,因为对象会自己处理。在这个例子中,所有三个观察者的构造函数和析构函数都做了完全相同的事情。这里有一个例子:
PlayerDisplay::PlayerDisplay(Subject* pSubject):
m_health(0.0f),
m_pSubject (pSubject)
{
m_pSubject ->RegisterObserver(this);
}
PlayerDisplay::~PlayerDisplay(void)
{
m_pSubject ->UnregisterObserver(this);
}
是否保留对主题的指针的选择取决于你。它有一些问题,我们稍后会讨论;然而,它允许观察者在析构函数中注销。这意味着用户不需要这样做,这使得使用 Observers 类非常容易。如果我们不保留这个指针,注销必须手动完成,这可能会根据你如何访问主题和观察者而变得困难。
其余的 Observer 方法都很简单,并且根本不与主题交互。相反,Update 方法基于 currentHealth 和 maxHealth 的值执行一些逻辑。对于两个显示元素,这意味着计算一些值;对于 StageLogic 类,这意味着如果当前健康值为零或更少,则将 m_isQuitting 设置为 true。让我们看看观察者中的一个 Update 的例子:
void PlayerHealthBar::Update(float currentHealth, float maxHealth)
{
m_percent = (currentHealth / maxHealth) * 100.f;
if (m_percent >= 75.0f)
m_color = "Green";
else if (m_percent < 75.0f && m_percent > 35.0f)
m_color = "Yellow";
else
m_color = "Red";
}

图 9 1 主题和观察者的交互
如您所见,Update 方法并不复杂。上述方法中没有任何部分使用了主题。数据可能来自任何地方。最有趣的部分是这些对象现在使用起来多么简单。所有三个观察者都在使用玩家的健康值,但他们不需要调用任何 Player 方法。尽管这四个对象相互交互,但使用它们却极其简单。让我们看看我们如何一起使用这些对象:
int main(void)
{
//Our value to decrement by
const float DECREMENT = -1.0f;
const float STARTING_HEALTH = 5.0f;
//creating our objects
Player player(STARTING_HEALTH);
PlayerDisplay display(&player);
PlayerHealthBar bar(&player);
StageLogic stageLogic(&player);
//Set the initial values to print
player.Notify();
//loop until player is dead
while (!stageLogic.IsQuitting())
{
display.Display();
bar.Display();
player.AdjustHealth(DECREMENT);
}
return 0;
}
main 函数开始时使用一些 const 值以提高可读性。之后,我们创建我们的对象。我们首先创建我们的 Player,它是我们的主题。然后我们创建我们的观察者。每个观察者都获得对主题的指针。记住,它们只依赖于主题接口,而不是派生的 Player 类。一旦所有观察者都创建完毕,Player 就会进行一个初始的 Notify 调用,以便观察者从正确的数据开始。最后,我们使用我们的对象。这个 while 循环的简单性令人惊叹。由于将它们链接在一起的代码都是内部的,因此使用这些对象变得非常容易。将上面的例子与不使用观察者模式的代码版本进行比较:
//Alternate code without Observer pattern
while (!stageLogic.IsQuitting())
{
display.SetHeath(player.getHealth());
display.Display();
bar.setHealth(player.getHealth(), player.getMaxHealth());
bar.Display();
player.AdjustHealth(DECREMENT);
stageLogic.SetQuit(player.GetHealth() <= 0);
}
使用观察者模式允许我们编写更优雅、更易于使用的代码。不幸的是,许多程序员编写的代码更接近第二个版本。他们没有意识到,只要稍微思考一下对象之间的交互方式,代码就更容易使用、更容易阅读,并且更高效,因为它只在数据发生变化时从玩家获取数据。
在这个简单的例子中,可能看起来代码并没有太大不同,但请记住,这只是尽可能简单地演示这个模式。第二个版本看起来合理,因为所有对象都在同一个作用域内。除了构造函数外,在实际项目中,观察者代码保持不变。然而,第二个版本可能会变成一个单例方法调用和对象查找的混乱。
推与拉
我们之前观察到的观察者模式的一个大问题是基类中的 Update 方法有限制。在我们的例子中,它只能与期望两个浮点数的观察者一起使用。如果我们想要不同的观察者风格,我们需要创建一个新的 Observer 类和一个新的主题来与之一起工作。
这种模式的推送版本非常棒,因为类是完全解耦的。派生类根本不需要了解彼此。为了这种解耦,我们需要为每个想要使用的方法签名编写大量的 Subject 和 Observer 基类。这个模式的另一种版本允许观察者从 Subject 中拉取他们想要的数据。在拉取版本中,Subject 在 Update 方法中将自身作为参数发送,观察者使用 Subject 的 Getter 方法只拉取它想要的数据。
这正是我们下一个例子中发生的情况。PlayerHealthBar 类现在接受一个指向 Subject 的指针。在这种情况下,我们期望 Subject 是 Player 类型。Update 方法可以使用它需要的任何 Player 数据来完成其任务:
//Now the update method takes a pointer to the subject
class Observer
{
public:
virtual ~Observer(void) {}
//Pull version of the Observer Pattern
virtual void Update(Subject* pSubject) = 0;
};
//Example of an Observer Update method that pulls data
void PlayerHealthBar::Update(Subject* pSubject)
{
//Make sure we have a Player
Player* pPlayer = dynamic_cast<Player*>(pSubject);
if(pPlayer == 0)
return;
m_percent = (pPlayer->GetHealth() / pPlayer->GetMaxHealth());
m_percent *= 100.f;
if (m_percent >= 75.0f)
m_color = "Green";
else if (m_percent < 75.0f && m_percent > 35.0f)
m_color = "Yellow";
else
m_color = "Red";
}
在模式的拉取版本中,Observer 依赖于派生的 Subject 类(在这种情况下是 Player),但 Update 方法更加灵活。此外,这个观察者可以观察多个不同的 Subject。Update 方法可以有一系列 if 语句来确定哪个可能的 Subject 执行了调用。类与特定对象耦合得更紧密。然而,由于观察者现在可以观察多个 Subject,相同的 Observer 类可以用于更广泛的对象。例如,一个单独的 Observer 类可以用来跟踪游戏中每种类型的对象死亡的数量,通过将自己注册到每个创建的游戏对象中并监控所有 Subject 的健康状态。
使用观察者模式的好处
在本章的开始,我们看到了交互式游戏代码的三个问题。正如我们之前所说的,这些问题并不大,但它们无处不在,并且随着项目的推进可能导致代码不够灵活。观察者模式以非常简单的方式解决了这些问题。
使用观察者模式最大的好处是我们可以减少依赖和耦合。通过使用观察者模式的推送版本,我们的类可以完全通过接口进行交互,因此它们之间没有任何依赖。在先前的例子中,Player 和 Player Display 是完全解耦的。这意味着对其中一个的更改不会影响另一个。首先,这使得每个类更容易进行测试和调试,因为它们可以单独工作。然而,这也意味着随着游戏的变化,这些类可以独立变化。这意味着单个类可以很容易地在当前项目或单独的项目中重用。唯一的问题是推送版本使我们陷入了一个单一的 Update 方法签名。
使用观察者模式的拉取版本增加了依赖性;然而,主题/观察者系统要灵活得多。观察者现在可以监听多个主题,主题可以将其不同的数据与每个观察者共享。尽管依赖性增加了,但这仅限于观察者一侧,因为主题仍然不需要了解它们的观察者。这些增加的依赖性仍然比替代方案更好,因为它们仅限于需要交互的两个类。如果不使用观察者模式,则需要使用第三个类来将这些两个类连接起来。
这个模式的第二个好处是我们不再需要硬编码方法或要求。由于事件被广播到任何注册的对象,如果新的对象需要事件,则无需重新编译。这减少了编译时间,并限制了破坏代码的机会。
这不仅限于将系统连接到其他系统。由于观察者可以在运行时注册和注销,游戏对象可以将自己注册到其他游戏对象。想象一下一个敌人太空站,它不断地产生掠夺者。通过将掠夺者注册到产生它们的站,每个站都可以充当小型指挥官,轻松地协调攻击和防御的部队。
最后一个好处是无需轮询对象。在前面的例子中,我们看到了两个 while 循环——一个使用观察者模式,另一个每帧轮询数据。我们看到了使用观察者模式时代码看起来多么整洁。除了看起来更整洁之外,第一个例子出现 bug 的可能性也更低,因为一旦注册,观察者将始终接收到更新的数据。在一个更大的项目中,另一种方法要求每个对象在代码库的许多地方不断请求数据。确保每个需要数据的对象在许多位置每帧都请求数据可能是一项艰巨的任务。
除了在每次轮询发生时非常难以找到和维护之外,这种方法还会导致代码效率降低。我们不应该试图过早地优化我们的代码,而且这很可能不是我们游戏的瓶颈,这就是为什么我们最后才提到它。然而,最快的代码是永远不会运行的代码。在使用观察者模式时,如果数据从未改变且事件从未发生,Update 方法永远不会被调用。因此,使用观察者方法有可能比每帧轮询数十次或数百次来提高我们的性能。
使用观察者模式的问题
当然,观察者模式并不是一个完美的解决方案。没有完美的解决方案,如果有,这本书将会非常短。就像每个模式一样,学习如何使用它以及如何不使用它同样重要。学习何时使用一个模式与学习何时不使用一个模式同样重要。
使用观察者模式时需要注意一些事项。所有模式的目的是简化开发周期。然而,如果模式没有被完全理解,可能会浪费很多时间调试本意是为了节省时间的多层代码。
悬垂引用
每个人都应该注意的第一个主要问题是悬垂引用的问题。我们必须确保我们对象持有的指针永远不会变得无效。理解这个问题的最好方法是查看命令模式并将其与观察者进行比较。
命令模式和观察者模式之间最大的区别是所有权的差异。在命令模式中,命令的客户或用户拥有一个指针。这意味着客户负责删除命令。这之所以重要,是因为没有其他类拥有命令指针,因此它不应变得无效。在 Mach5 引擎中,UIButtonComponent 拥有一个命令。它在析构函数中删除该命令,或者当它被赋予新的命令指针时。
将此与观察者模式进行对比。主题包含一个指向观察者的指针,观察者可以持有指向主题的指针。两个对象都不拥有对方,并且任何时刻都可以删除任一对象。它们是独立的对象,只是恰好通过指针相互通信。
在删除观察者之前,必须将其从它所观察的每一个主题中注销。否则,主题将不断尝试通过无效指针更新它,导致未定义的行为。在示例中,我们通过让观察者持有主题的指针并在析构函数中注销自己来实现这一点。如果主题先被删除,这会导致问题。在示例中,主题没有方法通知观察者它将被删除。如果主题先被删除,观察者仍然会在自己的析构函数中尝试注销,导致未定义的行为。
解决这个问题的有两个方案。首先,我们可以确保主题在观察者之前不会被删除。其次,我们可以从观察者中移除主题指针,并确保在它被删除之前,观察者仍然从正确的主题中注销。这两个方案都可能很难保证。如果你在游戏中实现了观察者模式,你必须考虑这个问题。
过度使用
观察者模式第二个问题是过度使用的风险。我们之前提到,当方法的需求不完全清楚或可能发生变化时,观察者模式是一个很好的选择。虽然游戏中的任何事物都可能发生变化,但将其推向极端将导致一个无法编程的项目,并且永远不会完成。在最极端的情况下,每个对象都可以既是主题(Subject)也是观察者(Observer)。每个方法都将最终具有灵活性,因为我们可以在运行时更改其所有内容。每个调用都会通知观察者,进而通知更多的观察者。
当然,没有人会将自己的引擎实现到那种极端,但过度使用观察者模式仍然是一个问题。调试通知长列表观察者的方法可能很困难。如果需要穿越多层才能找到任何实际代码,这会变得更糟。找到正确的平衡可能很困难。如果你觉得你的代码正变成一个相互连接的指针蜘蛛网,可能就是时候寻找更好的解决方案了。
实现接口
接下来要考虑的是观察者模式的实现。在上面的例子中,主题(Subject)和观察者(Observer)被设计成接口类。C++ 没有支持接口的语言特性,但这并不意味着我们不能编写使用它们的代码。
接口是一个不包含数据成员且没有方法实现的类。C++ 允许抽象基类的概念。这指的是至少标记一个方法为纯虚函数的类,使用 = 0 符号。这意味着该类不能被实例化,而必须被继承。这与接口不同,因为抽象基类可以包含成员数据以及方法实现(包括纯虚方法)。
这个区别对我们来说很重要,因为 C++ 允许多重继承,尽管强烈建议不要使用这个特性。从多个基类继承意味着你可能会继承具有相同名称的数据成员和方法。由于每个父类也可能从多个类继承,因此理解任何派生类从哪些实现中继承整个树可能很困难。这几乎肯定会引起意外的行为。这就是为什么你应该避免多重继承,而应该坚持使用单一父类,你的实现是从该父类派生的。
接口略有不同。许多语言不允许你从包含实现的多重类中继承,但它们确实允许你实现多个接口。由于接口不包含方法实现或数据成员,因此不存在名称冲突或意外继承行为的风险。通过实现接口,你只是在承诺你的类将响应方法调用。实现行为是派生类的责任。
主题(Subject)和观察者(Observer)被设计为接口类,以防它们需要与阶段(Stages)、组件(Components)或任何其他需要从基类派生的类一起使用。当然,你不必使用多重继承,总能找到其他解决方案。例如,你可以创建一个新的类,该类从观察者(Observer)派生,让你的组件包含(而不是继承)这个新类,而不是继承。
何时通知
使用观察者模式时需要考虑的最后一件事是何时通知观察者。第一个选项是在任何数据更改后通知观察者。在上面的例子中,玩家在AdjustHealth方法中调用了Notify。这意味着观察者将立即获得最新信息。然而,如果AdjustHealth方法在一个帧内被多次调用,就会浪费很多时间多次更新观察者。如果观察者观察的不仅仅是玩家的健康,这个问题会变得更糟。
另一个选项是在所有数据更改后通知观察者。显然,这个解决方案更有效,但很大程度上取决于你的系统是如何工作的。在前面的例子中,可能首先更新所有玩家信息(在这种情况下,只是健康),然后在显示任何内容之前调用Notify。
在一个具有许多不同主题和观察者,并且每帧以不同方式交互的游戏引擎中,知道何时所有数据都已更新可能很困难。可能没有一种方法可以排序游戏对象,以便在观察者之前更新所有主题。可能存在一种情况,即作为观察者的 HUD 对象已经在主题更新之前更新了。由于其他系统(如物理系统)可能影响游戏对象中的数据,这变得更加困难。
最后,没有正确的时间来通知观察者。只通知一次可能更有效率。然而,如果系统复杂,尝试批量处理所有Notify调用可能不值得编程上的麻烦。
摘要
在本章中,我们专注于可重用引擎代码与特定游戏玩法代码之间的通信。我们了解到,在游戏玩法代码有可能渗透到每个系统的情况下,很难在这两部分之间做出明确的区分。这是有道理的,因为要制作游戏,你必须编写与每个其他系统交互的代码。然而,这也意味着代码的重用会有些困难。
我们看到,解决这个问题的方法是通过让所有通信都通过接口类来进行,从而解耦我们的引擎和游戏玩法代码。这些接口类是所谓的观察者模式的基础。通过使用这个模式,我们可以使我们的代码更加整洁、易于使用和复用。
当谈到设计模式时,观察者模式是最简单的。很少有模式像它一样易于实现或理解。一旦开始使用它,你就会 wonder 你是如何在没有它的前提下编程的。然而,我们也了解到在使用该模式时有一些需要注意的事项,其中最糟糕的是悬挂引用。
现在我们有了将游戏玩法与我们的引擎分离的强大工具,让我们继续解决与代码复用相关的问题。在下一章中,我们将讨论一种允许我们复用对象并节省内存的模式。在这个过程中,我们将学习如何制作出色的粒子系统。
第十章:使用享元模式共享对象
我们在第七章“使用对象池提高性能”中学习了对象池,并且知道它们对于避免由于动态内存分配导致的游戏性能下降非常有用。但是,我们还可以采取其他步骤来减少我们最初使用的内存量。
在创建项目时,你经常会遇到想要在屏幕上同时显示许多对象的情况。尽管在过去几年中计算机变得更为强大,但它们仍然无法独立处理屏幕上的数千个复杂游戏对象。
为了完成这一壮举,程序员需要考虑如何减轻程序内存负担。使用享元模式,我们将对象的公共部分抽象出来,并仅与每个实例的独特数据(如位置和当前健康)共享。
章节概述
在本章中,我们将构建一个由两部分组成的粒子系统:粒子本身,它将是一个简单的结构体,以及包含系统数据的粒子系统类。
我们将构建两种不同类型的粒子系统:一种可以自行移动的爆炸,以及一种在玩家飞船位置生成的静态粒子系统。我们还将探讨两种处理系统数据的方法。第一种是每个粒子系统都包含其自己的系统数据副本。然后,在学习了享元模式之后,我们将使用它来构建可以分配使用文件或代码的独立系统数据类。然后,每个粒子系统将简单地引用它所需的系统数据实例。
你的目标
本章将分为几个主题。它将包含从开始到结束的简单步骤。以下是我们的任务大纲:
-
粒子的介绍
-
在 Mach5 中实现粒子
-
为什么内存仍然是一个问题
-
享元模式的介绍
-
转换到粒子系统
粒子的介绍
在游戏开发中,你可能听说过粒子。它们通常是小型 2D 精灵或简单的 3D 模型,创建目的是为了模拟模糊事物,如火焰、爆炸和烟雾轨迹,以增加项目的视觉效果。这种视觉效果有时被称为汁液感。独立开发者马丁·约纳森和佩特里·普霍使其流行起来,使游戏汁液感更强,使其更具可玩性,并增加了玩家通过玩游戏获得的反馈。
这通常是在游戏开发后期为了完善项目并增加更多反馈而进行的工作,但它是一个很好的例子,说明了我们为什么想要在屏幕上同时显示许多事物。
更多关于“juiciness”(生动性)的信息,以及观看 Martin 和 Petri 在 GDC 关于此主题的演讲,请查看www.gamasutra.com/view/news/178938/Video_Is_your_game_juicy_enough.php。
这些物体之所以如此简单,是因为它们被生成数百次,有时甚至数千次,而且这个过程反复进行。
在 Mach5 中实现粒子
既然我们已经知道了粒子的概念,让我们将它们放入 Mach5 中,以便我们可以得到一个它们如何工作的例子。我们将创建粒子来跟随我们的飞船,使其移动方式类似于烟雾轨迹。这将是一个展示屏幕上粒子示例的好方法,但为了展示内容,我们首先需要将一个新的原型引入到游戏中。
为了做到这一点,打开本章的Example Code文件夹,并将particle.tga文件拖入你的 Visual Studio 项目的EngineTest/Textures文件夹中。
之后,打开EngineTest/ArcheTypes文件夹,创建一个名为Particle.ini的新文本文件,并填写以下信息:
posX = 0
posY = 0
velX = 0
velY = 0
scaleX = 2.5
scaleY = 2.5
rot = 0
rotVel = 0
components = GfxComponent ParticleComponent
[GfxComponent]
texture = particle.tga
drawSpace = world
之后,我们需要 Mach5 引擎支持我们的新对象,因此转到EngineTest文件夹,然后双击PreBuild.bat文件。M5ArcheTypes.h文件将被更新以包含我们的粒子:
//! AutoGenerated enum based on archetype ini file names
enum M5ArcheTypes {
AT_Bullet,
AT_Particle,
AT_Player,
AT_Raider,
AT_Splash,
AT_INVALID
};
太好了!现在我们已经将物体放入了游戏中,但仍有一个问题需要解决,那就是添加粒子组件。由于这个组件并不专属于我们的游戏,让我们转到 Core/Components 过滤器,并创建一个新的过滤器,命名为ParticleComp。从那里,创建两个新的文件,ParticleComponent.h和ParticleComponent.cpp,确保它们的路径设置为Mach5-master\EngineTest\EngineTest\Source\文件夹。
在.h文件中,使用以下代码:
/******************************************************************************/
/*!
\file ParticleComponent.h
\author John Doran
\par email: john@johnpdoran.com
\par Mach5 Game Engine
\date 2016/12/06
Used to display a single particle on the screen.
*/
/******************************************************************************/
#ifndef PARTICLE_COMPONENT_H
#define PARTICLE_COMPONENT_H
#include "Core\M5Component.h"
#include "Core\M5Vec2.h"
class ParticleComponent : public M5Component
{
public:
ParticleComponent();
virtual void Update(float dt);
virtual M5Component* Clone(void);
virtual void FromFile(M5IniFile& iniFile);
bool activated;
float lifeTime;
float endScale;
private:
M5Vec2 startScale;
float lifeLeft;
float Lerp(float start, float end, float fraction);
};
#endif // !PARTICLE_COMPONENT_H
这个类看起来与其他我们过去添加的组件相似,但这次我们增加了一个startScale属性来跟踪物体在其生命周期开始时的规模,以及一个endScale属性来作为改变规模的修饰符。我们还有一个lifeTime,它将是我们移除该物体之前它应该存在的时长,以及lifeLeft,它将是我们移除该物体之前它还剩多少时长。最后,由于我们将改变规模,我们添加了另一个函数Lerp,用于在起始值和结束值之间进行线性插值。
在.cpp文件中,使用以下代码:
/******************************************************************************/
/*!
\file ParticleComponent.cpp
\author John Doran
\par email: john@johnpdoran.com
\par Mach5 Game Engine
\date 2016/12/06
Particle system component. Allows you to draw many particles on the screen.
*/
/******************************************************************************/
#include "ParticleComponent.h"
#include "Core\M5Gfx.h"
#include "Core\M5Math.h"
#include "Core\M5Object.h"
#include "EngineTest\M5ObjectPool.h"
#include "Core\GfxComponent.h"
#include "Core\M5IniFile.h"
/******************************************************************************/
/*!
Construtor for ParticleSystem component. Sets default values
*/
/******************************************************************************/
ParticleComponent::ParticleComponent() :
M5Component(CT_ParticleComponent)
{
}
/******************************************************************************/
/*!
Takes care of the particle system, decrease lifetime and adjust scaling.
Will mark for destruction if needed.
\param [in] dt
The time in seconds since the last frame.
*/
/******************************************************************************/
void ParticleComponent::Update(float dt)
{
// Decrease our life by the change in time this frame
// (dt stands for delta time)
lifeLeft -= dt;
// Change our size based on where we want it to be
float currentPercentage = 1 - (lifeLeft / lifeTime);
m_pObj->scale.x = Lerp(startScale.x,
startScale.x * endScale, currentPercentage);
m_pObj->scale.y = Lerp(startScale.y,
startScale.y * endScale, currentPercentage);
// If there is no life left, destroy our object
if (lifeLeft <= 0)
{
m_pObj->isDead = true;
}
}
这段代码将通过使用Lerp函数在起始和结束规模之间进行插值来修改物体的规模。我们还将修改粒子的剩余寿命,如果没有剩余寿命,则标记粒子以供删除:
/******************************************************************************/
/*!
Will give you the percentage of the fraction from start to end
\param [in] start
What value to start from
\param [in] end
What value to end from
\param [in] fraction
What percentage of the way are we are from start to finish
*/
/******************************************************************************/
float ParticleComponent::Lerp(float start, float end, float fraction)
{
return start + fraction * (end - start);
}
线性插值(Lerp)允许我们使用 fraction 属性来获取 start 和 end 之间的值,以确定过渡进行到多远。如果 fraction 是 0,我们将得到 start 的值。如果我们给出 1,我们将得到 end 的值。如果是 .5,那么我们将得到 start 和 end 之间的中点。
更多关于插值(包括线性插值)的信息,请查看 Keith Maggio 在 keithmaggio.wordpress.com/2011/02/15/math-magician-lerp-slerp-and-nlerp/ 主题上的笔记。
/******************************************************************************/
/*!
Clones the current component and updates it with the correct information.
\return
A new component that is a clone of this one
*/
/******************************************************************************/
M5Component * ParticleComponent::Clone(void)
{
ParticleComponent * pNew = new ParticleComponent;
pNew->m_pObj = m_pObj;
pNew->startScale = m_pObj->scale;
pNew->lifeTime = lifeTime;
pNew->lifeLeft = lifeTime;
pNew->endScale = endScale;
return pNew;
}
Clone 函数允许我们创建该对象的副本。它将创建该组件的新版本,我们将使用当前拥有的值来初始化新组件的值。这在 Mach5 引擎创建新游戏对象时被使用:
/******************************************************************************/
/*!
Reads in data from a preloaded ini file.
\param [in] iniFile
The preloaded inifile to read from.
*/
/******************************************************************************/
void ParticleComponent::FromFile(M5IniFile& iniFile)
{
// Get our life time value
std::string lifeTimeText;
iniFile.SetToSection("ParticleComponent");
iniFile.GetValue("lifeTime", lifeTimeText);
// Convert the string into a float
lifeTime = std::stof(lifeTimeText);
lifeLeft = lifeTime;
// Then do the same for endScale
std::string endScaleText;
iniFile.GetValue("endScale", endScaleText);
endScale = std::stof(endScaleText);
}
就像之前一样,FromFile 函数将读取我们之前创建的 ini 文件,并使用其中的值来设置该组件的属性。在我们的例子中,这里我们设置了 lifeTime、lifeLeft 和 endScale。
最后,让我们开始将这些对象放入我们的游戏中。打开 PlayerInputComponent.cpp 文件,并在 Update 函数的顶部添加以下内容:
M5Object* particle = M5ObjectManager::CreateObject(AT_Particle);
particle->pos = m_pObj->pos;
这将在每一帧中产生一个粒子,并且其位置与我们的飞船相同。现在,如果我们运行游戏,我们应该能看到一些酷炫的东西!我们可以在以下屏幕截图中看到:

如您所见,我们的飞船现在后面跟着一条轨迹。每个部分都是一个粒子!
为什么内存仍然是一个问题
我们目前展示的粒子系统在某些计算机上可能运行得足够好,但请注意,我们创建的大量变量在初始化后永远不会改变其数据。通常在编程中,我们会将不会改变的变量标记为 const,但我们不会在读取文件之前设置变量。我们可能将变量设置为静态的,但也有可能我们将来可能想要有更多的粒子系统,我不想为每个系统创建一个原型。
如果我们继续产生许多粒子,它所占据的内存将增加,我们将浪费宝贵的空间在内存中,这些空间本可以用于其他目的。为了解决这个问题,我们将采用享元模式。
享元模式简介
四大设计模式指出,享元是一个可以在多个上下文中同时使用的共享对象。类似于拳击中的轻量级拳击类别,我们可以在系统的不同地方同时使用一个更轻的对象。
虽然现在使用得不是很频繁,但在内存受限的场景中,享元模式可以非常有帮助。
Flyweight 将由两部分组成:内部状态和外部状态。内部状态是可以共享的部分。外部状态基于其使用的上下文进行修改,因此不能共享。
让我们通过一个 UML 图来更仔细地看看:

我们有 FlyweightFactory 类,用于管理 Flyweight。每次我们请求一个时,我们要么提供一个已经创建的,要么自己创建一个新的。
Flyweight 对象本身具有所需类型的数据,只要它不会根据我们正在处理的对象而改变。
最后,我们有 ConcreteFlyweight,它充当我们的外部信息,可以通过 FlyweightFactory 访问和使用 Flyweight。
转向 ParticleSystems
因此,考虑到这一点,我们将做的是将每个粒子将共享的信息分开,我们将称之为 ParticleSystem:
// Abstract class for us to derive from
class ParticleSystem
{
public:
float lifeTime;
M5Vec2 startScale;
float endScale;
// Pure virtual functions
virtual void Init(M5Object * object) = 0;
virtual void Update(M5Object * object, float dt, float lifeLeft) = 0;
float Lerp(float start, float end, float fraction);
};
这个类充当我们的内部状态,它是共享的。由于对象的起始比例、结束比例和寿命永远不会改变,因此这些变量共享而不是每个对象都有自己的变量是有意义的。在我们的上一个例子中,我们只有一个粒子系统,但我们可能希望有更多的能力,而且当我们开始使用它时,Flyweight 模式的某些好处变得更加明显。这就是为什么我们给了这个类两个虚拟函数:Init 和 Update。我们可以让外部状态调用这些函数,给函数提供关于我们正在处理的具体对象的信息,然后我们可以使用这些属性来修改它。
创建不同的系统类型
除了我们当前不动的系统类型外,让我们添加一种新的粒子系统类型。让我们称它为 Moving,而我们之前的类型为 Static。为了区分这两种类型,让我们添加一个 enum:
enum ParticleType
{
PS_Static,
PS_Moving
};
我们现在可以通过删除之前创建的变量,并包括我们希望使用的 ParticleSystem 类型的引用来修改原始的 ParticleComponent 类:
class ParticleComponent : public M5Component
{
public:
ParticleComponent();
virtual void Update(float dt);
virtual M5Component* Clone(void);
virtual void FromFile(M5IniFile& iniFile);
bool activated;
float lifeLeft;
private:
ParticleType particleType;
};
ParticleComponent 类充当我们的外部状态,持有关于剩余时间以及来自 M5Component 类的属性的信息,例如我们想要创建的对象的引用。
在这一点上,我们需要创建两个类来引用这些:
class StaticParticleSystem : public ParticleSystem
{
void Init(M5Object * obj);
void Update(M5Object *, float, float);
};
class MovingParticleSystem : public ParticleSystem
{
void Init(M5Object * obj);
void Update(M5Object *, float, float);
};
开发 ParticleFactory
我们需要一种方式让我们的 ParticleComponent 访问这些信息。考虑到这一点,我们将利用我们在 第五章 中学到的工厂设计模式,即通过工厂方法模式解耦代码,并创建一个 ParticleFactory 类:
class ParticleFactory
{
public:
static int objectCount;
static std::map<ParticleType, ParticleSystem *> particleSystems;
// Getting our Flyweight
static ParticleSystem & GetParticleSystem(ParticleType type);
~ParticleFactory();
};
这个ParticleFactory类是我们用来管理这些 Flyweights 的创建,并确保如果对象已经位于我们的 map 中,我们将返回它。否则,我们将创建一个新的对象以便能够访问它。我还添加了一个objectCount变量,帮助我们了解当前存在多少对象,并验证没有内存泄漏发生。
ParticleSystems变量是 map 类型,这实际上是我最喜欢的stl容器之一,可以被认为是关联数组。换句话说,您不需要记住数字来访问数组的特定索引,您可以使用不同的类型,例如string,或者在这种情况下,使用enum。
更多关于 map 容器的信息,请查看www.cprogramming.com/tutorial/stl/stlmap.html。
然后,我们需要定义两个静态变量:
#include <map>
// Define our static variables
int ParticleFactory::objectCount = 0;
std::map<ParticleType, ParticleSystem *> ParticleFactory::particleSystems;
使用ParticleFactory
接下来,我们需要调整我们之前创建的粒子原型和组件以反映这些更改。
首先,我们想要更改我们的.ini文件。由于Particle对象适用于所有粒子类型,我们不会在那里设置属性,而是设置一个基类型供我们使用:
posX = 0
posY = 0
velX = 0
velY = 0
scaleX = 2.5
scaleY = 2.5
rot = 0
rotVel = 0
components = GfxComponent ParticleComponent
[GfxComponent]
texture = particle.tga
drawSpace = world
[ParticleComponent]
type = Moving
这简化了粒子对象本身,但这是出于好原因。我们现在将更新ParticleComponent类的代码如下:
/******************************************************************************/
/*!
Construtor for ParticleSystem component. Sets default values
*/
/******************************************************************************/
ParticleComponent::ParticleComponent() :
M5Component(CT_ParticleComponent)
{
}
/******************************************************************************/
/*!
Takes care of the particle system, decrease lifetime and adjust scaling.
Will mark for destruction if needed.
\param [in] dt
The time in seconds since the last frame.
*/
/******************************************************************************/
void ParticleComponent::Update(float dt)
{
// Decrease our life by the change in time this frame (delta time, dt)
lifeLeft -= dt;
ParticleFactory::GetParticleSystem(particleType).Update(m_pObj, dt, lifeLeft);
// If there is no life left, destroy our object
if (lifeLeft <= 0)
{
m_pObj->isDead = true;
}
}
在这种情况下,您会注意到,我们不是在这里修改缩放和/或移动,而是使用ParticleFactory根据particleType属性来更新我们的代码:
/******************************************************************************/
/*!
Clones the current component and updates it with the correct information.
\return
A new component that is a clone of this one
*/
/******************************************************************************/
M5Component * ParticleComponent::Clone(void)
{
ParticleComponent * pNew = new ParticleComponent;
pNew->m_pObj = m_pObj;
pNew->particleType = particleType;
ParticleSystem & system =
ParticleFactory::GetParticleSystem(particleType);
system.Init(pNew->m_pObj);
pNew->lifeLeft = system.lifeTime;
return pNew;
}
在这里,我们根据工厂中粒子的类型调用Init函数:
/******************************************************************************/
/*!
Reads in data from a preloaded ini file.
\param [in] iniFile
The preloaded inifile to read from.
*/
/******************************************************************************/
void ParticleComponent::FromFile(M5IniFile& iniFile)
{
// Get our initial particle type
std::string particleTypeText;
iniFile.SetToSection("ParticleComponent");
iniFile.GetValue("type", particleTypeText);
if (particleTypeText == "Static")
{
particleType = PS_Static;
}
else if(particleTypeText == "Moving")
{
particleType = PS_Moving;
}
}
我们现在将根据ini文件上的标记来设置我们的粒子类型。
但是,当然,现在我们正在使用GetParticleSystem函数,我们需要为我们的代码实现它以便编译:
/******************************************************************************/
/*!
Used to get our Flyweight object and access the shared properties of the
particles.
\param type
What kind of particle we want to get access to
*/
/******************************************************************************/
ParticleSystem & ParticleFactory::GetParticleSystem(ParticleType type)
{
// If our object exists, return it
if (particleSystems.find(type) != particleSystems.end())
{
return *particleSystems[type];
}
ParticleSystem * newSystem = nullptr;
// Otherwise, let's create one
switch (type)
{
case PS_Static:
newSystem = new StaticParticleSystem();
newSystem->endScale = 0;
newSystem->lifeTime = 1.5;
newSystem->startScale = M5Vec2(2.5, 2.5);
particleSystems[PS_Static] = newSystem;
objectCount++;
break;
case PS_Moving:
newSystem = new MovingParticleSystem();
newSystem->endScale = 0;
newSystem->lifeTime = 1.5;
newSystem->startScale = M5Vec2(2.5, 2.5);
particleSystems[PS_Moving] = newSystem;
objectCount++;
break;
}
return *newSystem;
}
在这个脚本中,我们使用了之前提到的particleSystems map。我们首先做的事情是检查 map 中是否有包含我们的ParticleType的对象。如果没有,那么我们需要创建一个。在这种情况下,我添加了一个switch语句,它将根据case语句中提到的值分配不同的值,但您也可以以类似读取原型的文件的方式轻松地从文本文件中读取这些值。您会注意到我们正在调用new来创建这些,因此我们还需要调用delete以避免任何内存泄漏。为了实现这一点,我为ParticleFactory类添加了一个析构函数:
/******************************************************************************/
/*!
Deconstructor for the ParticleFactory. Removes all of the elements in ourparticleSystems map
*/
/******************************************************************************/
ParticleFactory::~ParticleFactory()
{
for (auto iterator = particleSystems.begin();
iterator != particleSystems.end();
iterator++)
{
// iterator->first = key
// iterator->second = value
delete iterator->second;
}
}
最后,我们需要为我们的不同ParticleSystems编写实现:
/******************************************************************************/
/*!
Will give you the percentage of the fraction from start to end
\param start
What value to start from
\param end
What value to end from
\param fraction
What percentage of the way we are from start to finish
*/
/******************************************************************************/
float ParticleSystem::Lerp(float start, float end, float fraction)
{
return start + fraction * (end - start);
}
Lerp函数对两种粒子类型都做同样的事情,所以它保持原样是好的:
/******************************************************************************/
/*!
Used to initialize the particle system and set any parameters needed
\param obj
A reference to the object
*/
/******************************************************************************/
void StaticParticleSystem::Init(M5Object * obj)
{
obj->vel = M5Vec2(0, 0);
}
/******************************************************************************/
/*!
Used to update the particle system. Called once per frame
\param m_pObj
A reference to the object
\param dt
Amount of time that has passed since the previous frame
\param lifeLeft
The amount of lifetime the object has left
*/
/******************************************************************************/
void StaticParticleSystem::Update(M5Object * m_pObj,
float /*dt*/, float lifeLeft)
{
// Change our size based on where we want it to be
float currentPercentage = 1 - (lifeLeft / lifeTime);
m_pObj->scale.x = Lerp(startScale.x,
startScale.x * endScale, currentPercentage);
m_pObj->scale.y = Lerp(startScale.y,
startScale.y * endScale, currentPercentage);
}
Init和Update函数的静态版本将只设置我们的速度为0,这样我们就不会移动:
/******************************************************************************/
/*!
Used to initialize the particle system and set any parameters needed
\param obj
A reference to the object
*/
/******************************************************************************/
void MovingParticleSystem::Init(M5Object * obj)
{
obj->vel = M5Vec2(M5Random::GetFloat(-1, 1),
M5Random::GetFloat(-1, 1)) * 10;
}
/******************************************************************************/
/*!
Used to update the particle system. Called once per frame
\param m_pObj
A reference to the object
\param dt
Amount of time that has passed since the previous frame
\param lifeLeft
The amount of lifetime the object has left
*/
/******************************************************************************/
void MovingParticleSystem::Update(M5Object * m_pObj, float /*dt*/, float lifeLeft)
{
// Change our size based on where we want it to be
float currentPercentage = 1 - (lifeLeft / lifeTime);
m_pObj->scale.x = Lerp(startScale.x,
startScale.x * endScale, currentPercentage);
m_pObj->scale.y = Lerp(startScale.y,
startScale.y * endScale, currentPercentage);
}
对于我们的运动粒子系统,我们将速度设置为x轴和y轴上的随机数,从而产生一个漂亮的爆炸效果!
现在,我们不再每次都创建数据的副本,而将有一个副本供我们访问,如下面的截图所示:

在游戏过程中,你会发现我们现在有一个新的粒子系统正在运行,并且它的工作相当出色。
摘要
在本章的整个过程中,我们学习了粒子以及如何利用它们来提升我们的游戏项目的品质。我们学习了如何在 Mach5 引擎中实现粒子系统,然后学习了 Flyweight 模式以及如何有效地使用它来减少项目中的内存使用。我们还看到了如何通过使用工厂模式来实现这一点,同时使创建新的粒子系统类型变得更加容易。考虑到这一点,未来在需要时,将更容易拆分保持一致性的程序部分,并且只创建额外的变量!
在接下来的章节中,我们将深入探讨图形以及理解我们的代码如何影响移动和动画游戏对象所需的概念。
第十一章:图形和动画简介
在过去的 10 章中,我们深入探讨了最流行的设计模式之一。每一章的目标都是理解和解决每个人在创建游戏时都会遇到的一些常见问题。在这个过程中,我们创建了具有灵活基于状态的决策能力的基于组件的游戏对象。我们使用单例模式创建了核心引擎,如StageManager和ObjectManager,以便游戏对象、组件和引擎之间的通信变得极其简单。我们还研究了对象池和享元模式,这些模式使我们的游戏能够更有效地使用内存。
在本章中,我们将专注于图形。然而,我们不会关注如何实现图形引擎。那需要不止一章。相反,我们将关注无论你使用哪个图形应用程序编程接口(API),都需要理解的概念。
图形是任何游戏引擎的重要组成部分,并且很可能成为游戏的性能瓶颈。然而,无论我们使用 DirectX、OpenGL 还是其他图形 API,我们都必须了解幕后发生的事情。我们不应该陷入这样的陷阱,认为因为我们没有编写图形 API,就没有设计决策要做。
章节概述
本章与之前的不同,因为它不是专注于设计模式。相反,我们将关注图形的低级细节,以便更好地理解我们的代码如何影响移动和动画游戏对象。
首先,我们将探讨计算机显示器的工作原理。我们将深入了解像素和屏幕分辨率。我们将查看像素如何在屏幕上绘制,以及理解撕裂的概念,了解为什么我们听到那么多关于每秒帧数(fps)的讨论,以及为什么游戏试图达到每秒 30 或 60 帧。
接下来,我们将探讨游戏中的计时。我们将学习为什么我们希望有一个一致的帧率。我们还将查看当我们的帧率不一致时会发生什么,以及我们如何确保整个游戏中的帧时间保持一致。
你的目标
-
学习计算机显示器的工作原理以及刷新率是什么
-
了解双缓冲以及为什么它用于图形
-
了解基于时间的移动和动画,以及为什么我们希望有一个一致的帧率
监视器刷新率简介
这些天,平面屏幕液晶显示器(LCD)非常常见。然而,为了理解刷新率和双缓冲,我们需要了解旧式显示器如何显示图像。在这个过程中,我们将学习一些常见的图形术语,如像素和屏幕分辨率:

图 11.1 - 简化的阴极射线管图
阴极射线管(CRT)显示器包含数百万个微小的红色、绿色和蓝色磷光点。这些点在电子束穿过屏幕以创建图像时短暂发光。阴极是真空密封玻璃管内的加热丝。射线是由电子枪产生的电子流,电子枪由磁偏转板控制。通过调整板的磁场,电子束可以移动并调整以击中屏幕的每个部分。
屏幕涂有磷光材料,这是一种有机材料,当被电子撞击时会短暂发光。它包含许多红色、绿色和蓝色的点组。通过改变每个点上的电子束强度,可以产生不同的颜色。例如,当红色、绿色和蓝色以最大强度发射时,产生白色。
由于一种颜色是由一组红色、绿色和蓝色的点产生的,因此这些点组的最大数量限制了水平或垂直方向上可以显示的颜色数量。相同颜色两点之间的对角距离称为点距。

图 11.2 屏幕上像素的近距离视图
像素是什么?
像素,或称图像元素,是计算机图像或显示器上可编程颜色的基本单位。最好将像素视为一个逻辑单位,而不是物理单位。这是因为像素的大小取决于当前显示屏幕的分辨率。在屏幕的最大分辨率下,一个像素正好映射到一个点组。这意味着最大分辨率下像素的大小等于点距。较小的分辨率将使用多个点组来创建一个颜色。
屏幕的分辨率是水平像素数乘以垂直像素数,通常写作宽度 x 高度。例如,640 x 480 的分辨率意味着屏幕宽度为 640 像素,高度为 480 像素,总共 307,200 像素。当然,每个像素的颜色数据必须存储在计算机内存中,因此更高的分辨率使用更多的像素和更多的内存。例如,如果每个像素使用一个字节的内存,我们的 640 x 480 显示器就需要 300 千字节的内存。1280 x 1024 的显示器需要 1.25 兆字节。让我们看一下下面的屏幕截图:

图 11.3 - 800 x 600 屏幕分辨率的示例
存储像素颜色信息的 RAM 位置称为帧缓冲区。帧缓冲区由程序写入,然后传输到显示器。阴极射线管解释像素颜色并以适当的强度发射电子束。偏转板将电子束导向磷光屏上的适当点组。
在上面的例子中,每个像素的大小仅为 1 字节。然而,像素可以是,并且通常也是,超过 1 字节。随着计算机速度的提高和内存价格的降低,我们可以使用每像素更多的位。在 8 位色彩中,红色和绿色各自使用 3 位,总共 8 个色彩级别,而蓝色只使用 2 位,或 4 个级别。这为每个像素提供了 256 种可能的色彩。
16 位色彩,或称为高色彩,提供了一些不同的选项。一种可能性是每个红色、绿色和蓝色使用 4 位。这 4 位为每种颜色提供了 16 个级别,总共 4,096 种颜色(16 x 16 x 16),还有一个可选的 4 位用于透明度。另一种可能性是每种颜色使用 5 位,透明度使用 1 位,总共 32,768 种颜色。最后,通过红色和蓝色各使用 5 位,绿色使用 6 位,总共可以达到 65,536 种颜色。
真实色彩是以每色 8 位来定义的。这意味着红色、绿色和蓝色各自都有 8 位,或者 256 种可能的色彩级别。如果使用 24 位,我们就能得到总共 16,777,216 种可能的色彩。如今,每像素 32 位的使用越来越普遍。最后的 8 位用于透明度。透明度允许与背景色彩进行不同级别的混合。这使得每个像素可以有总共 4,294,967,295 种色彩。
帧缓冲区的大小是通过将分辨率乘以每像素字节数(色彩深度)来计算的。对于一个使用 1280 x 1024 分辨率的游戏,我们需要 1280 x 1024 x 4 字节,即 5 兆字节用于帧缓冲区。考虑到现代计算机通常有 8 到 12GB 的 RAM,这可能看起来并不多。然而,值得记住的是,如果我们正在更新屏幕上的每个像素,我们正在更新 1,310,720 个像素,或者 5,242,880 字节的数据。这是假设我们只填充每种颜色一次,并且不需要与重叠颜色混合。
水平和垂直空白
显示器通过读取帧缓冲区中的数据并按顺序更新来刷新。我们可以将这个过程想象成在 C 或 C++中迭代一个二维数组。在每一扫描行的末尾,电子枪会被调整以指向下一扫描行的起始位置。在最后一个像素被点亮之后,还需要进行一次调整,以便光束可以从顶部重新开始。
电子枪从扫描线 X 的最右侧像素移动到扫描线 X + 1 的最左侧像素所需的时间被称为水平消隐间隔。这是因为电子枪被消隐,意味着在此间隔内它输出零电子。这是为了防止像素在从扫描线到扫描线的移动过程中被点亮。同样,电子枪从最后一行扫描线的末端移动回第一行扫描线所需的时间被称为垂直消隐间隔。再次,电子枪被消隐以防止像素在返回顶部扫描线时被点亮。垂直消隐间隔是一个短暂的间隔,在此期间整个显示已经更新,并且帧缓冲区当前没有被显示器读取:
//Example code drawing 640x480 display
//Including H Blank Interval and V-Blank Interval
for(int h = 0; h < 480; ++h)
{
for(int w = 0; w < 640; ++w)
{
//Sets pixel color and moves to next pixel
SetPixel(framebuffer[h][w]);
}
//Resets to start of scan line and moves down one row
ResetHorizontal();
}
//Resets to first pixel of first scan line
ResetVerticle();

图 11.4-显示电子枪的运动模式,包括水平和垂直消隐间隔
屏幕上的磷光点只会在短时间内被点亮,因此电子枪必须不断重新点亮它们。电子枪每秒从左到右和从上到下移动多次,以刷新每个像素并显示正确的图像。如果这个过程太慢,显示将看起来闪烁。
刷新率
监视器每秒刷新显示的次数被称为其垂直刷新率,或者简称为刷新率。监视器的刷新率以赫兹(Hz)为单位进行测量。因此,每秒可以刷新显示 30 次的监视器具有 30 Hz 的刷新率。许多监视器的刷新率为 60 Hz,然而,具有 120 Hz 甚至 240 Hz 刷新率的监视器变得越来越常见。
重要的是要认识到监视器的刷新率与游戏或程序的性能无关。拥有更高刷新率的监视器不会提高游戏的帧率,除非游戏本身可以支持更高的帧率。程序更新帧缓冲区的次数以每秒帧数或 fps 来衡量,并且与监视器刷新的次数完全独立。当这两个数字不同步时,显示将不会正确。让我们看看当每秒帧数少于刷新率时的问题:

图 11.5 比较帧率与刷新率
这些天,电视节目和电影通常以每秒 24 帧的速度播放,而电视和监视器的典型刷新率为 60 Hz。在图 11.5中,我们将一秒分成 60 个顶部的红色条来表示我们的刷新率,以及 24 个底部的蓝色条来表示电影的帧。每个红色条代表帧将在屏幕上显示的 1/60 秒。每根垂直深红色线代表监视器刷新的时刻。
如您所见,刷新周期与电影的帧并不正确对齐。在图 11.5中,我们可以清楚地看到帧 1在屏幕上持续了 3/60 秒(或 1/20 秒),而帧 2只在屏幕上持续了 2/60 秒(或 1/30 秒)。帧 1在屏幕上的持续时间比原始的 1/24 秒长,而帧 2则短。由于帧在屏幕上显示的时间不等,视频看起来会有抖动。虽然这可能看起来差别不大,但有些人足够敏感,能注意到轻微的加速和减速效果:

图 11.6 - 刷新率和帧每秒不同步的示例
电视和电影有调整这种问题的方法,比如在帧之间进行插值。由于帧 1和帧 2已经知道,混合、插值和生成显示前的中间帧很容易。然而,这并不能帮助我们当我们的游戏与显示器不同步时。
由于游戏是交互式的,玩家的动作决定了屏幕上接下来将显示的内容。帧 2的细节由玩家对帧 1的输入决定。这意味着帧缓冲区的内容必须每帧生成,不能提前知道。
在上述示例中,我们通过假设写入帧缓冲区是瞬时的来简化问题。当然,这并不真实。即使电影的下一帧已经知道,写入帧缓冲区仍然需要时间。除非我们能在垂直空白间隔期间完全复制到帧缓冲区,否则我们将在屏幕上绘制显示时写入帧缓冲区。
如果我们能正确地安排时间,我们总是可以在电子枪读取像素后立即写入像素。然而,如果我们与电子枪不同步,最终会有一个点我们没有为当前帧写入像素,而电子枪读取了旧值。让我们近距离看看这个问题:

图 11.7 - 帧缓冲区中的起始位置(左)和结束位置(右)
图 11.7展示了我们希望在屏幕上看到的内容。左图是帧 1 中游戏对象的位置。右图是对象移动后的结束位置。这是我们想要展示的两个不同时间点。然而,如果显示在读取我们写入帧缓冲区的像素时,如果我们没有完成当前帧的写入,我们就会看到撕裂效果:

图 11.8 - 帧缓冲区中的撕裂示例
如图 11.8所示,第一幅图像是正确的。我们在显示读取像素之后写入像素。在第二幅图像中,正在读取的像素几乎赶上了正在写入的像素。在这个时候,帧缓冲区包含每幅图像的一半。第三幅图像显示显示已经超过了写入像素。正确的像素已经写入,但它们写入得太晚了。第四幅图像显示了用户会在屏幕上看到的内容。由于显示读取速度比像素写入速度快,图像看起来像被撕裂成两半。这种效果被称为撕裂。
当我们的每秒帧数和刷新率不同步时,就会发生撕裂。不幸的是,要使这两个值完全对齐可能非常困难,稍微偏离就会导致一些撕裂。为了解决这个问题,我们需要在显示读取之前输出整个帧的像素。
双缓冲
解决我们的读写问题的方法是双缓冲。双缓冲正是其名称所暗示的。我们不会只使用一个帧缓冲区,而是使用两个:一个用于读取,一个用于写入。当然,由于我们现在有两个帧缓冲区,我们需要两倍的内存。对于一个 1280 x 1024 的显示器,每像素使用 4 字节,我们需要每个帧缓冲区 5 兆字节,总共 10 兆字节。
到目前为止,所有这些都可以通过使用操作系统命令在软件中实现。然而,随着显示器对内存和更复杂图像的需求增加,专门硬件被创建出来。现代显卡可以包含用于帧缓冲区、纹理、3D 三角形网格等的大量内存。它们还可以包含数百甚至数千个核心,以同时将 3D 点转换为像素数据。
理解这一点很重要,因为作为一个程序员,你不需要自己实现双缓冲。这是在硬件级别实现的,并且我们的游戏将通过使用 DirectX 或 OpenGL 等 3D 图形 API 自动实现双缓冲。
后缓冲区
正如我们所说的,双缓冲是通过使用两个帧缓冲区来实现的,这样我们永远不会在用于显示的同一缓冲区上设置像素。当前正在显示的帧缓冲区称为前缓冲区或主缓冲区,而我们正在绘制的帧缓冲区称为后缓冲区或辅助缓冲区。
当后缓冲区上的绘图完成时,缓冲区会进行交换,使得后缓冲区现在成为前缓冲区,而前缓冲区现在成为后缓冲区。当然,缓冲区本身并没有交换。相反,缓冲区的指针进行了交换。显示器有一个指向一个缓冲区的指针,并且目前正在读取它。显卡有一个指向另一个缓冲区的指针,它用于所有的绘图操作。这种指针交换,或者称为页面翻转(有时也这么叫),比从后缓冲区复制数据到前缓冲区要快得多:

图 11.9 - 双缓冲示例
如您在接下来的两张图片中可以看到,显示器可以在图形处理单元或 GPU 绘制到后缓冲区的同时读取前缓冲区。然而,双缓冲并没有阻止撕裂现象。我们已经防止了对正在显示的同一缓冲区进行绘图,但如果我们在屏幕刷新过程中交换缓冲区,我们仍然会有撕裂现象。这是解决撕裂问题的关键第一步。然而,在我们解决这个问题之前,让我们谈谈当我们与显示器刷新率不同步时会发生什么,因为我们每秒生成太多的帧:

图 11.10 - 页面翻转后的双缓冲
想象一下我们的游戏每秒生成 90 帧的情况。这意味着比我们为了每刷新一次生成一帧所需的帧数多出 30 帧。这意味着我们在浪费时间创建永远不会被看到的帧。在每秒 90 帧的情况下,三分之一的帧永远不会被玩家看到。正如您在图 11.11中可以看到的,每第三帧,用绿色突出显示,将被跳过,因为它位于两个刷新间隔之间。如果我们以每秒 120 帧或更快的速度更新,将跳过的帧数会更多:

图 11.11 - 比较 60 Hz 刷新率与 90 fps
在每秒 90 帧的情况下,每一帧需要 1/90 秒来完成。从玩家的角度来看,显示的第三帧(实际上是我们第四帧)已经更新了总共 1/45 秒,或者说是一倍的时间。就像 24 fps 的电影一样,这可能会引起一些玩家注意到的颤动效果,因为所有物体看起来都移动了两次那么远。
当每秒更新这么多次时,这些时间片非常小。它们可能小到玩家可能注意不到。跳帧的真正问题是我们的游戏正在做它根本不需要做的工作。由于帧永远不会被玩家看到,因此没有必要浪费时间生成它。
值得指出的是,只有更新图形的部分是浪费的。更新输入、AI 或物理速度超过显示器刷新速度是完全正常的。事实上,如果时间步长更小,物理会更准确。我们只是想强调,绝对没有理由比显示器的刷新率更快地绘制帧。
对于大多数游戏来说,这从来不是问题。提高帧率的通常解决方案是做更多的工作,使你的游戏更有趣。如果你的游戏运行速度超过 60 fps,你的游戏可能不是最好的。再次强调,没有必要每秒绘制比屏幕上能显示的更多的帧。
VSync
因此,我们现在已经看到了两种情况,其中我们的帧计数与刷新率不同步。在这两种情况下,我们都可能遇到撕裂,在帧率非常高的情况下,我们浪费了 CPU/GPU 周期,这些周期本可以用来改进我们的游戏。我们想要避免撕裂。我们想要与显示器的刷新同步。我们如何解决这个问题?
解决方案是垂直空白间隔。记住,垂直空白间隔是电子枪从显示器的最后一个像素重新定位到第一个像素的时候。在这段短暂的时间内,整个显示已经绘制完成,并且前缓冲区没有被使用。这个时间段太短,无法复制后缓冲区的内容到前缓冲区。然而,它足够长,可以交换指针,这就是页面翻转机制的工作原理。
现代计算机显示器和电视可以在垂直空白间隔或 V-Blank 发生时向计算机发送信号。显卡在完全绘制后缓冲区后,会等待 V-Blank 信号再交换缓冲区。这保证了永远不会出现撕裂,因为不同帧的部分永远不会在单个刷新中被读取。
正如我们所说,双缓冲是在硬件级别实现的。与垂直空白间隔同步也是如此。通过使用 DirectX 或 OpenGL 等 3D 图形 API,你可以免费获得这个功能。这种 V-Blank 同步或 VSync 是在初始化图形时必须启用的一个选项。一旦 VSync 开启,我们就不必担心撕裂现象。此外,游戏永远不会在每帧刷新时生成超过一个帧,因为显卡总是在交换缓冲区之前等待 V-Blank 信号。
与显示器刷新率同步是防止撕裂的好方法。如果一个游戏每秒可以更新 60 次或更多,后缓冲区和前缓冲区将始终能够交换,我们将有一个平滑、无撕裂的游戏。然而,我们还没有讨论如果后缓冲区没有准备好交换,因为帧完成所需时间超过 1/60 秒会发生什么。
重要的是要理解,每次 V-Blank 信号到来时,前后缓冲区并不会自动交换。相反,交换是在程序员的请求下进行的。通过函数调用通知显卡已准备了一个新帧,并且应该交换缓冲区。如果关闭了 VSync,这种交换将立即发生。然而,如果启用了 VSync,显卡将等待 V-Blank 信号到来,无论这个时间有多长。如果我们的更新只是慢了 1/100,意味着一个帧需要 1/59 秒来完成,我们将错过 V-Blank 并需要等待下一个。
由于当前帧尚未准备好,显示器再次显示上一帧。相同的帧将在屏幕上显示 1/30 秒。由于后缓冲区必须等待到下一个 V-Blank 才能交换,我们的游戏无法开始处理下一帧。我们的游戏在等待 V-Blank 时处于空闲状态。这意味着如果我们的游戏使用 VSync 并且无法达到每秒 60 帧,我们的帧率将下降到每秒 30 帧。如果我们的游戏无法达到每秒 30 帧,我们的帧率将降低到 3/60 秒,或 20 fps。
对于一些程序员和一些游戏来说,达到每秒 30 帧是完全可行的。为了实现更美丽的效果或更精确的物理效果,降低到每秒 30 帧可能是一个重要的权衡。每个人必须决定他们自己的游戏什么才是正确的。然而,许多玩家根本不喜欢每秒 30 帧。玩家经常说他们可以注意到更多的抖动运动,更重要的是,他们注意到输入延迟。
记住,如果我们无法达到 60 fps 的目标,显卡必须在返回交换调用之前等待下一个 V-Blank。这意味着我们的游戏无法处理物理、AI 甚至输入。玩家在屏幕上看到的帧数减半,这意味着物体在每一帧中移动得更多。此外,输入现在每 1/30 秒而不是每 1/60 秒从玩家那里收集一次。虽然这看起来可能不多,但对于像第一人称射击这样的快速反应游戏来说,这可能太长了。
图 11.12 展示了在 VSync 场景下前后缓冲区内容的一个示例,其中游戏无法以与显示器相同的速率更新。显示每 1/60 秒或每 0.0167 秒刷新一次。游戏可以每 1/50 秒或每 0.02 秒更新一次。在下面的图像中,显示器的刷新被涂成红色或绿色。红色刷新表示游戏帧尚未准备好,因此显示的是上一帧。绿色刷新表示游戏帧已准备好,因此缓冲区被交换。
蓝色代表游戏帧完成的时间。这并不意味着新帧会立即显示。这是因为显卡会等待到下一次刷新来交换缓冲区。重要的是要理解,游戏不会每 1/50 秒更新一次,原因相同。相反,每个游戏更新都是在最后一次缓冲区交换后的 1/50 秒:

图 11.12 - 使用 VSync 时显示后缓冲区和前缓冲区的内容
三缓冲
在我们的游戏中开启 VSync 可以提高图形的外观,因为我们保证撕裂永远不会发生。不幸的是,如果我们的游戏帧没有及时完成下一次刷新,显卡会等待到下一个 V-Blank 来交换缓冲区。即使我们的游戏只错过了 1/100 秒的刷新,这也是正确的。如果我们的帧偏移了这么短的时间,我们的 fps 会降到 30。这是因为后缓冲区的内容还没有被交换,所以我们不能开始绘制下一帧。
如果我们能够在等待 V-Blank 信号的同时开始绘制下一帧,那将很理想。为了做到这一点,我们需要一个额外的帧缓冲区来在等待时绘制。这正是三缓冲的工作方式。
对于三缓冲,我们总共有三个帧缓冲区。对于一个 1280 x 1024 的显示,每个像素 4 字节,我们需要总共 15 兆字节。然而,通过使用额外的内存,我们总是有一个缓冲区可以绘制,所以我们应该总是能够达到我们的 fps 目标。
在三缓冲中,我们仍然有主缓冲区和辅助缓冲区,但现在我们还有一个三级缓冲区。我们首先开始绘制到后缓冲区。如果我们能在刷新之前完成当前帧,我们就可以立即开始绘制到三级缓冲区。如果我们没有及时完成,需要等待下一次刷新,就像在双缓冲场景中一样。然而,我们只需要错过一次刷新。一旦后缓冲区被填满,我们就可以立即开始处理三级缓冲区。无论如何,一旦后缓冲区被填满,我们就会永远领先一帧。主缓冲区将用于显示,辅助缓冲区将准备好并等待交换,而显卡将使用三级缓冲区进行绘制:

图 11.13 - 在 V-Blank 之前绘制到三级缓冲区
当发生 V-Blank 时,如果所有缓冲区都准备好了,它们可以交换。辅助缓冲区成为主缓冲区用于显示。三级缓冲区成为辅助缓冲区并等待显示。最后,主缓冲区成为新的三级缓冲区,用于绘制:

图 11.14 - 场景 1 - 在 V-Blank 之后交换所有缓冲区
如果在 V-Blank 时三级缓冲区没有准备好,它会继续绘制直到帧完成,并且可以与原始主缓冲区交换而不需要等待 V-Blank:

图 11.15 - 场景 2A - 在 V-Blank 之后交换主缓冲区和次级缓冲区
通过使用三缓冲,我们解决了游戏中有几个慢帧时突然从 60 帧每秒降到 30 帧每秒的问题。这也允许我们在情况略低于 60 帧每秒时避免降到 30 帧每秒,并且(几乎)持续达到 60 帧每秒,因为我们不需要等到 V-Blank 才能开始下一帧:

图 11.16 - 场景 2B - 绘制完成后交换次级缓冲区和三级缓冲区
然而,如图 11.17所示,三缓冲仍然有可能错过刷新。使用之前相同的案例,我们的游戏以每秒 1/50 秒的速度更新,而显示器以每秒 1/60 秒的速度刷新,我们仍然会错过每六个刷新中的一个。当帧率越低时,我们错过的刷新次数越多,这并不令人惊讶。如果我们每个帧需要 1/30 秒或更长时间才能完成,我们无法期望达到 60 帧每秒:

图 11.17 - 使用三缓冲错过刷新的示例
尽管三缓冲可以让我们在保持 60 帧每秒的同时避免撕裂,但在决定使用它之前,你必须考虑一个重要的因素。屏幕上刚刚出现的内容和当前正在处理的帧之间存在两帧的延迟。三缓冲允许我们在次级缓冲区等待显示下一帧的同时,在三级缓冲区处理另一个帧,而此时主缓冲区正在显示。这使得我们领先一帧,从而可以避免帧率下降,但降低了玩家的响应时间。
如果按下跳跃按钮,玩家角色不会立即跳跃,直到当前帧和下一帧都已被显示。这是因为正在处理的帧,包括游戏对象对输入的响应,正被放入三级缓冲区。如果主缓冲区和次级缓冲区在屏幕上各显示 1/60 秒,那么玩家输入将会有 1/30 秒的有效延迟。游戏看起来就像是以 60 帧每秒的速度运行(因为它确实是),物理行为就像游戏是以 60 帧每秒的速度运行(因为它确实是),但由于输入延迟,游戏感觉就像是以 30 帧每秒的速度运行。
由你决定什么最适合你的游戏。许多玩家可能甚至不会注意到输入延迟,因为时间间隔非常小。然而,对于像第一人称射击游戏或需要精确控制跳跃或转向的游戏来说,这可能是不可以接受的。
液晶显示器
我们花了很多时间讨论阴极射线管(CRT)显示器的工作原理。这是很重要的,这样我们才能理解垂直空白间隔以及它与双缓冲的关系。然而,由于液晶(LCD)和 LED 显示器更为常见,这似乎有点过时。我们不会讨论这两种类型显示器的工作原理,因为这对我们帧率没有影响。重要的是,这些显示器会模拟 V-Blank 信号。即使它们不需要刷新并且没有电子枪,它们仍然会向显卡发送一个模拟信号。这样,你的程序仍然可以与显示器的刷新率锁定。
基于时间的移动和动画
到目前为止,我们已经在本章中涵盖了大量的内容。我们一直在研究帧率和刷新率,以便理解它们与屏幕上显示的内容之间的关系。然而,游戏的帧率可能会影响游戏的每个引擎。它甚至可能影响开发过程中的测试和调试。
在游戏开发的初期,游戏逻辑并不复杂,单位数量也很低。因此,每秒数千帧是很常见的。随着开发的继续,这个帧率会逐渐下降到数百,然后(希望)稳定在每秒 60 帧左右。想象一下,如果有一些游戏逻辑每 10 帧生成一个敌人。根据我们处于开发周期的哪个阶段,我们可能每秒生成六个或六十个敌人。这使得游戏很难测试和调试,因为它并不一致。
使这个问题更加有趣的是,即使在单个游戏会话中,也没有任何东西是可以保证一致的。在游戏开始时,没有敌人,所以帧率可能高达每秒 600 帧。这意味着我们每秒生成 60 个敌人。五秒后,屏幕上有 300 个敌人,这使得物理和图形都非常缓慢。突然,我们的帧率可能会下降到每秒 30 帧,减慢敌人创建的速度。随着玩家消灭更多敌人,帧率会上升,导致敌人生成更快,然后帧率再次下降。
这个问题不仅限于影响游戏逻辑。它是一个影响任何在帧之间发生变化的问题。特别是,它将影响游戏对象的动画。在这里,动画指的是游戏对象内部发生变化的任何东西。这包括随时间改变游戏对象的纹理,这是我们通常认为的动画方式。然而,它还包括随时间移动、缩放、旋转,或改变对象的颜色和透明度。为了更好地理解这个问题,让我们看看每帧移动一个单位如何对游戏开发产生不利影响。
基于帧的移动
当我们想要移动一个游戏对象时,最简单的方法是以恒定的速率更新位置。这对于某些游戏来说可能效果很好。然而,对于模拟汽车、宇宙飞船,甚至是重力,这看起来不会正确。现在,我们将使用这种方法作为一个例子,然后稍后看看基于物理的运动。由于我们只是在更新对象的位置,将玩家向右移动的代码将看起来像这样:
//Move the player to the right
//This is just an example
//A game should not be hard-coded like this
pos.x += 5;
值得注意的是,数值 5 不是以英寸或米来衡量的;它是游戏单位。游戏单位是完全任意的,并且取决于游戏中的物体大小。3D 建模程序将允许你为你的模型设置比例单位。如果每个模型都使用相同的单位,并且模型在游戏世界中没有缩放,那么就可以用这些单位来考虑游戏世界。然而,更常见的是将所有东西都视为任意的游戏单位。物体在屏幕上的移动量取决于物体的大小以及它距离相机的远近。如果一切看起来和感觉都与其他事物正确相关,那么就是可以的。
在前面的例子中,假设大小和相机距离是固定的,玩家在屏幕上移动的距离将完全取决于我们的帧率。例如,如果我们每秒得到 1,000 帧,就像我们在开发初期可能做到的那样,我们的玩家将在x方向上移动 5,000 个游戏单位。在开发后期,当我们每秒得到 100 帧时,我们的玩家在x方向上只会移动 500 个单位。为了获得相同数量的移动,我们需要改变玩家的速度:
//Move the player to the right
//This is just an example
//A game should not be hard-coded like this
pos.x += 50;
当我们接近完成游戏时,我们可能只能得到每秒 60 帧。这意味着玩家的速度需要再次改变。为了获得相同数量的移动,玩家的速度需要是 83.333 fps。不幸的是,即使在游戏发布后,我们仍然有同样的问题。随着显卡和 CPU 的变快,我们游戏的帧率将增加,这意味着玩家会移动得太快。游戏体验完全依赖于计算机硬件。
通过启用 VSync 可以解决这个问题。正如我们之前看到的,使用 VSync 将有效地将我们的帧率锁定到监视器的刷新率。这将保证我们的帧率有一个最大值。然而,当玩家升级到 120 Hz 的显示器,玩家移动速度加倍时,玩家会非常困惑。此外,如果游戏在几帧内运行缓慢,VSync 会导致我们的帧率下降到 30 fps。突然之间,玩家的移动速度减半。即使使用 VSync,我们的游戏体验也完全依赖于硬件。
显然,使用基于帧的移动并不是一个好的选择。随着帧率的上升和下降,我们必须改变玩家的移动速度。正如我们之前所说的,这个问题在所有动画中都会出现。旋转物体的旋转速度、缩放、粒子的淡入淡出速度以及显示纹理前需要显示的帧数都必须在开发过程中不断修改,而且在不同硬件上仍然不会保持一致。
基于时间的移动
与基于帧的移动相比,基于时间来设定我们的移动方式要好得多。时间在整个开发过程中以及在所有硬件上都是一致的,无论是现在还是未来。无论我们的游戏是每秒更新 3 帧还是 3,000 帧,一秒始终等于一秒。
使用基于时间的移动非常棒,因为它允许我们使用存在了数百年的方程。我们不需要重新发明轮子来模拟速度和加速度。正如我们之前所说的,对于某些游戏来说,使用恒定速度移动玩家是可以的,但这并不是真实物理的工作方式。汽车和宇宙飞船不会瞬间加速。重力会使你下落得越来越快。
当你把球扔给一个十岁的孩子时,他们不需要进行复杂的计算就能接住球;他们可以轻松做到。同样,在半真实模拟中,玩家会期望物理表现得正常。为了在我们的游戏中模拟真实或半真实的物理,我们应该了解如何将速度和物理融入我们的移动中。
我们可以通过从物体的最终位置减去初始位置,然后除以位移所需的时间来计算物体的速度。另一种说法是,速度等于位置变化除以时间变化。我们可以利用这一点来创建一个方程,并将其放入我们的代码中:




我们也可以通过从最终速度减去初始速度并除以时间变化来计算物体的加速度。加速度是速度变化除以时间变化:




我们还知道,根据牛顿第二运动定律,力等于质量乘以加速度。这也意味着加速度等于力除以质量:


这意味着,如果我们知道物体的当前位置、速度以及作用在物体上的力,我们就可以找到物体在未来的某个时刻的位置和速度。在我们的游戏中,我们可以使用这三个方程来模拟运动:



这三个方程中的前两个被称为欧拉积分(发音为 Oiler)。具体来说,它被称为显式欧拉。在代码中,它看起来可能像这样:
float EulerIntegration(float pos, float vel, float accel,
float totalTime, float dt)
{
float time = 0.0f;
while (time < totalTime)
{
pos += vel * dt;
vel += accel * dt;
time += dt;
}
return pos;
}
这段代码的内部循环是我们在游戏中如何使用它的一个很好的例子。每一帧,我们将根据dt和加速度更新我们的位置和速度。在这一帧计算出的速度将用于更新下一帧中物体的位置。在循环外部,这些方程是完美的。如果我们以每小时 55 英里的速度在高速公路上行驶,一小时后我们预计会多出 55 英里。同样,如果我们以每秒 8 英里的加速度加速,那么 10 秒后我们预计速度将达到 80 英里。
然而,在循环内部我们会有一些误差。欧拉积分只有在加速度和速度保持恒定时才是准确的。在先前的代码示例中,每次循环中速度都在变化,因此它的不准确性与步长的平方成正比。这意味着步长越大,误差就越大。
让我们比较欧拉积分与运动学方程之一,看看这个误差如何影响我们的结果。我们将测试的运动学方程是:

其中p是我们新的位置,p⁰是我们初始位置,v⁰是我们初始速度,a是我们加速度,t是我们时间。
让我们假设我们的起始位置是 0,我们的起始速度也是 0。通常,在物理方程中,加速度的单位是每秒每秒,而不是每小时每秒。所以,让我们说我们在 10 秒内以每秒 20 英尺的加速度加速。10 秒后,我们的车将行驶 500 英尺:




因此,运动学方程说我们在 10 秒后将从起点起 1000 英尺远。使用相同的数据,我们可以将其放入我们的欧拉积分函数中。我们将每秒积分 10 秒:
Time = 0 pos = 0.00 vel = 0.00
Time = 1 pos = 0.00 vel = 20.00
Time = 2 pos = 20.00 vel = 40.00
Time = 3 pos = 60.00 vel = 60.00
Time = 4 pos = 120.00 vel = 80.00
Time = 5 pos = 200.00 vel = 100.00
Time = 6 pos = 300.00 vel = 120.00
Time = 7 pos = 420.00 vel = 140.00
Time = 8 pos = 560.00 vel = 160.00
Time = 9 pos = 720.00 vel = 180.00
Time = 10 pos = 900.00 vel = 200.00
欧拉积分说我们将从起点起 900 英尺远。运动学方程和欧拉积分相差 100 英尺。这是在仅 10 秒后。我们积分的时间越长,误差就越大。当然,我们之前已经解释了为什么会有这个问题。误差与时间步长成正比。如果我们使用更小的时间步长,我们将有更小的误差。幸运的是,我们的游戏将每秒更新多个帧。让我们再次进行积分,但让我们使用一些更现实的时间步长。让我们选择 30 fps、60 fps 和 120 fps 的值。这给我们提供了 0.0333、0.0167 和 0.008 的时间步长:
dt = 1.000000 pos = 900.00
dt = 0.033333 pos = 996.67
dt = 0.016667 pos = 998.33
dt = 0.008333 pos = 1000.83
如您所见,通过使用更小的时间步长,我们更接近匹配的结果。在 120 fps 时,我们相当准确,但在 60 fps 时,我们计算出的误差只有几英尺。不幸的是,即使加速度保持恒定,运动学方程也不准确。
对于许多游戏来说,欧拉积分可能就足够了。误差足够小,以至于玩家可能不会注意到。当然,这取决于游戏玩法和帧率。创建一个极其精确的物理积分器超出了本书的范围。
如果你的游戏需要非常精确的物理效果,请查看以下链接中的 Verlet 积分或 RK4 积分:
[zh.wikipedia.org/wiki/Verlet 积分](https://zh.wikipedia.org/wiki/Verlet 积分),
[zh.wikipedia.org/wiki/Runge-Kutta 方法](https://zh.wikipedia.org/wiki/Runge-Kutta 方法)
无论你选择哪种积分方法,它都将比使用基于帧的运动更好、更可靠。重要的是要记住,游戏中任何发生变化的部分都必须使用时间。这包括旋转,如果你愿意,可以使用类似的旋转速度和旋转加速度。它还包括随时间缩放、随时间动画纹理,甚至改变颜色和透明度。这将使我们的游戏具有非常一致的外观和感觉,同时使测试和调试在整个开发过程中变得更加容易。
摘要
我们在本章中确实涵盖了大量的内容。现在,你对计算机显示器的工作原理的了解可能比你原本想要的要多。在本章中,我们深入探讨了帧缓冲区以及屏幕上像素着色的细节。我们了解到,如果帧率与显示器不同步,可能会导致撕裂。我们还探讨了双缓冲和 VSync 的使用如何解决这个问题。不幸的是,我们也看到了 VSync 可能会引起它自己的问题。我们还探讨了三缓冲,并分析了其优缺点。最终,没有完美的答案。总会有一些权衡。你必须接受撕裂,或者由于 VSync 而导致的帧率急剧下降的可能性。
最后,我们通过查看帧率如何影响我们游戏的其他代码部分来结束本章。具体来说,我们研究了物理和动画,并了解到我们必须使用基于时间的物理和动画来使我们的游戏具有更一致的外观和感觉。
在下一章中,我们将从底层细节中解脱出来,看看编程的大图景。这包括我们的编码哲学以及我们为什么关心高质量代码。我们将探讨一些可以帮助使游戏开发不那么头痛的技巧和窍门,以及介绍 Mach5 引擎中的一些特定内容,虽然它们不是模式,但仍然可以使你的编码生活变得更加容易。
第十二章:最佳实践
学习编程由于多种原因而困难,但学习游戏编程则更加困难,特别是由于存在许多不同的系统和对象类型需要相互交互。在这本书中,我们已经介绍了一些最重要的设计模式,以使这些交互尽可能简单。每一章都明确地关注一个设计模式,以帮助简化编码。然而,在每个段落和代码示例中,都隐藏着核心思想和技巧,有助于使我们的设计更容易阅读和维护。
这些最佳实践有时可以在其他书中找到;然而,编程书籍往往努力教你一门语言的语法,而不是风格、设计和组织。即使是关于设计模式的书籍也可能忽略这些基本技巧。由于它们非常基础,很容易忘记它们并不一定在所有地方都明确讨论。这让你,作为读者,不得不阅读数十本书,并在互联网上搜寻讨论这些基础知识的博客文章。更糟糕的是,你需要花费数小时甚至数十小时编写代码,感觉这些代码可以更好,但你就是不明白为什么它不好。
当然,所有这些事情都会发生。作为程序员的一部分是不断阅读这样的书籍。你应该通过阅读博客来寻找改进的方法,你会在六个月后认为你写的代码是垃圾。我们写这本书的愿望是希望你能尽早而不是更晚地理解和将这些基础知识融入你的程序中。
章节概述
在本章中,我们将重点关注那些能够提升你的代码质量和游戏水平的根本思想和技巧。这些思想来源于多年的编程经验以及多年的教学经验。如果这些看起来简单明了,那真是太好了。然而,我们选择这些主题是因为它们是我们,作为作者,早期遇到的难题,或者是我们学生遇到的难题。
你的目标
在本章中,我们将讨论多个主题:
-
学习基本的代码质量技巧
-
学习和理解 const 关键字的使用
-
学习迭代如何改进你的游戏和代码设计
-
学习在游戏中何时使用脚本
学习基本的代码质量技巧
从初学者成长为专家程序员的进程可能具有挑战性。一开始,你必须学习不仅语言的规则,还要学习如何使用编译器和理解错误信息。此外,你试图解决越来越困难的编程问题,同时遵循可能看似任意的编写良好代码的规则。大多数新手程序员专注于解决给定的问题,而不是使代码看起来很漂亮。对许多人来说,花时间使代码看起来整洁似乎毫无价值,因为编写后它几乎肯定会删除。即使是经验丰富的程序员,在匆忙完成作业或项目时也可能忽略代码风格。
这有几个原因不好。首先,写得好的代码更容易阅读和理解。它几乎肯定有更少的错误,并且比随意混合在一起且从未打磨过的代码更有效率。正如我们在前面的章节中讨论的那样,你前期花在确保代码无错误上的时间,是以后你不需要用来调试的时间。你花在确保代码可读和易于维护上的时间,是以后你不需要用来修改或解读旧代码的时间。
其次,良好的编程风格是一种习惯。花时间阅读和调试你的代码一开始会很慢。然而,随着你不断提高代码质量,它变得越来越容易和快速。最终,你会养成习惯,编写高质量的代码将变得自然而然。没有这种习惯,很容易将风格放在一边,以后再担心。然而,正在编写的代码几乎总是草率的,以后很难找到时间回去改进它,因为总是有另一个截止日期在逼近。有了良好的习惯,你甚至在最紧张的时间限制情况下,如面试或即将到来的截止日期,也能写出干净、可读的代码。
最后,在未来的某个时刻,你几乎肯定会与其他程序员一起工作。这可能是一个由两三个程序员组成的小团队,或者可能是在一个拥有遍布全球数十个程序员的跨国公司中。即使你理解你的代码在做什么,也不能保证你的队友会理解。编写难以理解的代码会导致人们错误地使用你的代码。相反,努力使你的代码易于使用且难以破坏。以其他人对你的代码的喜爱为荣,你的队友和上司会感谢你。如果你的队友也这样做,你会感到很感激。在某个时刻,你将需要维护其他程序员离开工作后的代码。如果你离开后他们写的代码质量高,你会发现这要容易得多,所以写上你离开后也容易工作的代码。
在接下来的几页中,我们将介绍一些非常基础但极其重要的代码质量提示。正如我们所说,这些来自多年的阅读编程经验,以及教学。将使用这些技术为每行代码。思考这些技术为每段代码。这样做将帮助你形成良好的习惯。
避免使用魔法数字
将数字字面量硬编码到代码中通常被认为是一个坏主意。使用数字字面量而不是命名常量的问题是,读者不知道那个数字的目的。数字在代码中似乎凭空出现。考虑以下代码:
M5Object* pUfo = M5ObjectManager::CreateObject(AT_Ufo);
pUfo->pos.x = M5Random::GetFloat(-100, 100);
pUfo->pos.y = M5Random::GetFloat(-60, 60);
很难知道为什么选择了这四个数字。也很难知道如果修改了这些值,程序将如何改变。如果使用命名常量或变量,这样的代码将更容易阅读和维护:
M5Object* pUfo = M5ObjectManager::CreateObject(AT_Ufo);
pUfo->pos.x = M5Random::GetFloat(minWorldX, maxWorldX);
pUfo->pos.y = M5Random::GetFloat(minWorldY, MaxWorldY);
更改后,更容易理解新的不明飞行物(UFO)的位置是在世界内随机放置的。我们可以理解,如果我们更改这些值,UFO 可能的起始位置将是世界之外,或者被限制在围绕世界中心的更紧密的矩形内。
除了难以阅读和理解之外,使用魔法数字会使代码难以维护和更新。假设我们有一个大小为 256 的数组。每个需要操作数组的循环都必须硬编码值 256。如果数组的大小需要增大或减小,我们就需要更改所有 256 的出现。我们无法简单地进行“查找和替换”,因为 256 在代码中可能用于完全不同的原因。相反,我们必须查看数字的所有出现,并确保我们正确地更改了代码。如果我们错过任何一个,我们可能会创建一个错误。例如,如果我们将数组的大小更改为更小的值,例如 128。任何仍然将数组视为大小为 256 的循环都会导致未定义的行为:
int buffer[256];
//Some function to give start values
InitializeBuffer(buffer, 256);
for(int i = 0; i < 256; ++i)
std::cout << i " " << std::endl;
如前所述,最好使用命名常量而不是魔法数字。常量更易于阅读和更改,因为它只需要在一个地方更改。它也较少引起错误,因为我们只更改与数组相关的值。我们不会意外地更改不应该更改的值或错过应该更改的值:
const int BUFFER_SIZE = 256;
int buffer[BUFFER_SIZE];
//Some function to give start values
InitializeBuffer(buffer, BUFFER_SIZE);
for(int i = 0; i < BUFFER_SIZE; ++i)
std::cout << i " " << std::endl;
我们不想使用魔法数字的另一个重要原因是它们缺乏灵活性。在这本书中,我们试图强调从文件中读取数据的优点。显然,如果你硬编码一个值,它就不能从文件中读取。在先前的例子中,如果BUFFER_SIZE需要更改,代码需要重新编译。然而,如果缓冲区的大小在运行时从文件中读取,代码只需要编译一次,程序将适用于所有大小的缓冲区:
int bufferSize = GetSizeFromFile(fileName);
//we can Dynamically allocate our buffer
int* buffer = new int[bufferSize];
//Some function to give start values
InitializeBuffer(buffer, bufferSize);
for(int i = 0; i < bufferSize; ++i)
std::cout << i " " << std::endl;
delete [] buffer;//We must remember to deallocate
在前面的例子中,我们必须记住释放缓冲区。记住,这很可能不是通常的情况,因为对于数组,我们总是可以使用 STL 向量。更一般的情况是我们从文件中读取整数或浮点数。这些可以用于从屏幕分辨率到玩家速度,甚至到生成敌人的时间间隔等任何东西。
与所有规则一样,有一些例外或特殊情况,可能允许硬编码数字。数字0和1通常被认为是可接受的。这些可能用作整数或浮点数的初始化值,或者只是数组的起始索引。
你的目标是使你的代码尽可能易于阅读和灵活,因此命名常量几乎总是比硬编码的数字更好。尽你所能确保你的代码可以被他人理解。如果你的变量名为ZERO或TWO,你的代码并不一定更易于阅读,所以你应该使用你的最佳判断,并在你认为含义不明确时,也许可以询问另一位程序员。
空白
当思考高质量代码时,空白往往被忽视。也许这是因为空白不是你编写的代码,而是你代码之间的空白空间。然而,如果你没有正确使用空白,你的代码将难以阅读。当我们提到空白时,我们指的是程序内部的空格、制表符、换行符和空白行。你如何使用这些元素可以决定代码是易于阅读和维护,还是让你做噩梦。以下是一段对空白考虑很少的代码:
RECT rect={0};
int xStart= 0,yStart = 0;
rect.right=s_width;rect.bottom=s_height;
s_isFullScreen = fullScreen;
if (fullScreen) {DEVMODE settings;
settings.dmSize = sizeof(settings);
EnumDisplaySettings(0, ENUM_CURRENT_SETTINGS, &settings);
settings.dmPelsWidth=(DWORD)s_width;
settings.dmPelsHeight = (DWORD)s_height;
settings.dmFields = DM_BITSPERPEL|DM_PELSWIDTH|DM_PELSHEIGHT;
s_style = FULLSCREEN_STYLE;
if (ChangeDisplaySettings(&settings
,CDS_FULLSCREEN) !=DISP_CHANGE_SUCCESSFUL) {
s_isFullScreen = false;s_style = WINDOWED_STYLE;
ChangeDisplaySettings(0, 0);M5Debug::MessagePopup(
"FullScreen is not supported. "
"You are being switched to Windowed Mode"); }
}
else {ChangeDisplaySettings(0, 0); s_style = WINDOWED_STYLE;}
上述代码对编译器来说是完全可接受的。然而,对于人类来说,上面的代码难以阅读,因为没有行间距、缩进和不一致。当然,这是一个极端的例子,但我们在多年的教学过程中,也看到了对样式和格式考虑很少的代码示例。当代码看起来像上面的例子时,注释和标识符名称的质量并不重要,因为整个块难以阅读。将上述代码与以下版本进行比较,该版本试图使代码对人类可读:
/*Set window rect size and start position*/
RECT rect = { 0 };
rect.right = s_width;
rect.bottom = s_height;
int xStart = 0;
int yStart = 0;
/*save input parameter to static var*/
s_isFullScreen = fullScreen;
/*Check if we are going into full screen or not*/
if (fullScreen)
{
/*Get the current display settings*/
DEVMODE settings;
settings.dmSize = sizeof(settings);
EnumDisplaySettings(0, ENUM_CURRENT_SETTINGS, &settings);
/*Change the resolution to the resolution of my window*/
settings.dmPelsWidth = static_cast<DWORD>(s_width);
settings.dmPelsHeight = static_cast<DWORD>(s_height);
settings.dmFields = DM_BITSPERPEL | DM_PELSWIDTH |
DM_PELSHEIGHT;
/*Make sure my window style is full screen*/
s_style = FULLSCREEN_STYLE;
/*If we can't change, switch back to desktop mode*/
if ( ChangeDisplaySettings(&settings, CDS_FULLSCREEN) !=
DISP_CHANGE_SUCCESSFUL )
{
s_isFullScreen = false;
s_style = WINDOWED_STYLE;
ChangeDisplaySettings(0, 0);
M5Debug::MessagePopup("FullScreen is not supported. "
"You are being switched to Windowed Mode");
}
}
else /*If we are already fullscreen, switch to desktop*/
{
/*Make sure I am in windows style*/
s_style = WINDOWED_STYLE;
ChangeDisplaySettings(0, 0);
}
虽然前面的例子绝对不是高质量代码的完美示例,但它确实比第一个例子更易于阅读和维护。当涉及到现实世界的程序和程序员时,没有什么是完美的。每个程序员都有自己的风格,这实际上意味着每个程序员都认为自己的风格是最容易阅读的。然而,随着你阅读更多的代码,你会注意到可读代码有一些共同元素。现在让我们看看这些元素中的一些。
缩进
块语句,如循环和条件语句,应该将子语句缩进。这很容易向读者展示程序意图。缩进的空格数量不如缩进本身重要。大多数程序员认为 2 到 4 个空格对于可读性是足够的。最重要的是要保持缩进的一致性。同样,起始大括号的位置并不重要(尽管你可以在网上找到一些有趣的论点),但重要的是要始终将其放置在同一位置:
//This shows the purpose of the statement
if (s_isFullScreen)
{
s_style = FULLSCREEN_STYLE;
SetFullScreen(true);
}
//So does this
if (s_isFullScreen) {
s_style = FULLSCREEN_STYLE;
SetFullScreen(true);
}
//This does not shows the intent of the statement
if (s_isFullScreen)
{
s_style = FULLSCREEN_STYLE;
SetFullScreen(true);
}
需要记住的是,在 C++中,缩进对编译器没有意义。如果没有大括号,循环和条件语句将只执行一个语句。因此,一些程序员无论需要多少个子语句,都会始终使用大括号:
/*Single statement in the loop*/
while (i++ < 10)
printf("The value of i is %d\n", i);
/*After adding another statement*/
while (i++ < 10)
printf("The value of i is %d\n", i);
printf("i squared is %d\n", i*i);
前面的例子具有误导性,因为只有第一个语句将作为循环的一部分。在编写循环或条件语句时,忘记在添加第二个语句后添加大括号是一种常见的错误。因此,一些程序员即使在单语句循环和条件语句中也会使用大括号。这种想法是代码更易于阅读和维护,因此更不容易出错。
空行和空格
正如我们之前所说的,你如何使用空白空间将决定你的代码的可读性。使用缩进来表示代码块是显示程序逻辑结构的一种方法。另一种展示这种结构的好方法是使用空行。就像一篇好的写作被分成段落一样,好的代码也应该以某种逻辑分组的形式被分隔。将逻辑上相关的语句组合在一起。在组之间放置空行以提高可读性:
//Save position and scale to variables for readability.
const float HALF = .5f;
M5Vec2 halfScale = m_pObj->scale * HALF;
M5Vec2 pos = m_pObj->pos;
//Get world extents
M5Vec2 botLeft;
M5Vec2 topRight;
M5Gfx::GetWorldBotLeft(botLeft);
M5Gfx::GetWorldTopRight(topRight);
//If object is outside of world, mark as dead
if (pos.x - halfScale.x > topRight.x || pos.x +
halfScale.x < botLeft.x || pos.y - halfScale.y
> topRight.y || pos.y + halfScale.y < botLeft.y)
{
m_pObj->isDead = true;
}
前面的代码没有空行,所以代码看起来是连续的。看它很难理解代码在做什么,因为你的大脑试图一次性理解所有内容。尽管有注释,但它们并没有真正帮助,因为它们与代码的其他部分混合在一起。if 语句也难以阅读,因为条件是通过它们在行上的适应而不是逻辑对齐来分隔的。在下面的代码中,我们添加了一些空行来分隔语句的逻辑分组:
//Save position and scale to variables for readability.
const float HALF = .5f;
M5Vec2 halfScale = m_pObj->scale * HALF;
M5Vec2 pos = m_pObj->pos;
//Get world extents
M5Vec2 botLeft;
M5Vec2 topRight;
M5Gfx::GetWorldBotLeft(botLeft);
M5Gfx::GetWorldTopRight(topRight);
//If object is outside of world, mark as dead
if ( pos.x - halfScale.x > topRight.x ||
pos.x + halfScale.x < botLeft.x ||
pos.y - halfScale.y > topRight.y ||
pos.y + halfScale.y < botLeft.y )
{
m_pObj->isDead = true;
}
通过使用行断开来将相关的语句组合在一起,代码被分隔成易于理解的块。这些块帮助读者理解哪些语句在逻辑上应该放在一起。此外,每个块开头的注释更加突出,并用英语准确说明了块中将要发生的事情。
复杂的条件语句应该根据条件进行分隔和对齐,以便更容易理解。在前面给出的代码中,四个条件都是按照相同的方式进行对齐的。这为读者提供了关于条件将如何执行的解释。使用括号并结合代码对齐进一步增加了可读性:
//If object is outside of world, mark as dead
if (( (pos.x - halfScale.x) > topRight.x ) ||
( (pos.x + halfScale.x) < botLeft.x ) ||
( (pos.y - halfScale.y) > topRight.y ) ||
( (pos.y + halfScale.y) < botLeft.y ))
{
m_pObj->isDead = true;
}
使用括号不仅仅在条件语句中有帮助。所有复杂的表达式都应该用括号括起来。当然,每个人对复杂的定义都不同,所以一个好的通用规则是 *、/ 和 % 的执行顺序在 + 和 - 之前;其他所有情况都使用括号。这不仅会让读者更清晰,还能确保代码的执行方式与你预期的一致。即使你理解了所有 C++ 的优先级和结合性规则,你的队友可能不一定理解。括号并不需要任何成本,但可以提高可读性,所以请尽可能多地使用它们来展示代码的意图。
注释和自文档化的代码
注释和文档似乎比它们应有的争议性更大。一方面,许多人认为注释和文档是浪费时间。编写文档实际上会从编写代码中夺走时间,阅读注释则会从阅读代码中夺走时间。此外,有些人认为注释根本不起作用,因为它们可能会过时,而且不能解释源代码中已经存在的内容。注释最糟糕的情况是它们完全错误。在这种情况下,没有注释的代码可能反而更好。
然而,没有什么比调试没有添加注释的代码更令人沮丧的了。即使是你自己几个月前编写的代码,调试起来也可能很困难。最终,编写和更新注释所花费的时间是你和你的队友不需要花费在解读代码上的时间。
虽然注释的使用可能存在争议,但编写干净、高质量的代码对每个人都很重要。正如我们之前已经看到的,合理使用空白空间可以提高可读性。然而,仅仅空白空间本身并不能使代码可读。我们真正希望我们的代码是自文档化的。以下是一个例子,尽管它有适当的空白空间,但仍然难以阅读:
void DoStuff(bool x[], int y)
{
for(int i = 0; i < y; ++i)
x[i] = true;
x[0] = x[1] = false;
int b = static_cast<int>(std::sqrt(y));
for(int a = 2; a <= b; ++a)
{
if(x[a] == false)
continue;
for(int c = a * 2; c < y; c += a)
x[c] = false;
}
}
你能看出这个算法在做什么吗?除非你恰好已经知道这个算法,否则你很可能不会理解函数的意图。注释在这里会有帮助,但更大的问题是标识符的低质量。好的变量名可以提供关于它们将用于什么的线索。想法是,有了好的变量名,你应该能够在不需要注释的情况下理解代码。这就是你使代码自文档化的方式:
void CalculateSievePrimes(bool primes[], int arraySize)
{
for(int i = 0; i < arraySize; ++i)
primes[i] = true;
primes[0] = primes[1] = false;
int upperBound = static_cast<int>(std::sqrt(arraySize));
for(int candidate = 2; candidate <= upperBound; ++candidate)
{
if(primes[candidate] == false)
continue;
int multiple = candidate * 2;
for(; multiple < arraySize; multiple += candidate)
primes[multiple] = false;
}
}
即使你不理解前一个代码示例中的每一行,你至少可以使用函数名作为指南。名称CalculateSievePrimes是关于函数正在做什么的一个重要线索。从那里,你应该能够拼凑出每一行正在做什么。名称如 candidate、arraySize和 multiple 比a、b和c更有意义。自文档代码的最好部分是它永远不会出错,也永远不会过时。当然,代码仍然可能包含错误。只是它不能与文档不同步,因为代码本身就是文档。
如我们之前所说,你可以做一些事情来尝试使代码具有自文档特性。好的变量名是一个开始。变量名应该解释变量的确切目的,并且它们应该只用于那个目的。对于布尔变量,给出一个使真值含义显而易见的名称。例如,isActive比仅仅active或activeFlag要好得多,因为这样的名称给出了关于该变量真值含义的提示。
经常会有一些命名约定来区分类型、局部变量、常量和静态或全局变量。其中一些命名约定,例如使用全部大写字母来表示const变量,是非常常见的,并且被大多数程序员所使用。其他命名约定,例如所有静态变量名以s_开头,或者在指针名前添加p,则不太常见。无论你认为这些风格是否丑陋,都要明白它们的存在是为了帮助提高可读性,并使错误代码看起来更明显。编译器已经可以捕获这些命名约定旨在解决的问题中的一些,但鉴于它们仍然有助于提高可读性,因此值得考虑。
当给方法和函数命名时,也适用类似的规则。给出一个清晰的名称,说明函数的目的。确保函数或方法只有一个目的。通常,名称应该是一个动作。CalculateSievePrimes比SeivePrimes或仅仅是Calculate的名称更清晰。与布尔变量一样,返回布尔值的函数或方法通常带有提示性的名称。名称IsEmpty或IsPowerOfTwo比Empty或PowerOfTwo更清晰。
注释
如果代码是自文档的,那么我们为什么还需要添加注释呢?这确实是某些程序员的感受。当注释只是简单地重复代码,或者当注释过时且难以更新时,很容易理解他们为什么会这样想。然而,这与好的注释应有的作用正好相反。
好的注释应该解释代码无法表达的内容。例如,版权信息、作者和联系方式等,这些都是代码无法表示但可能对读者有用的信息。此外,好的注释不会简单地重复代码。下面的注释完全无用。它对代码没有任何帮助:
//Assign START_VALUE to x
int x = START_VALUE;
相反,好的注释应该解释代码的意图和目的。即使你理解了一块代码应该做什么,你也不知道作者在编写它时在想什么。了解作者试图实现的目标可以在调试他人代码时节省你很多时间:
/****************************************************************/
/*!
Given an array of "arraySize" mark all indices that are prime as true.
\param [out] primes
The array to modify and Output.
\param [in] arraySize
The number of elements in the array
\return
None. Indices that are prime will be marked as true
*/
/****************************************************************/
void CalculateSievePrimes(bool primes[], int arraySize)
{
/*Ensure array is properly initialized */
for(int i = 0; i <size; ++i)
primes[i] = true;
/*Zero and One are never prime*/
primes[0] = primes[1] = false;
/*Check values up to the square root of the max value*/
int upperBound = static_cast<int>(std::sqrt(arraySize));
/*Check each value, if valid, mark all multiples as false*/
for(int candidate = 2; candidate <= upperBound; ++candidate)
{
if(primes[candidate] == false)
continue;
int multiple = candidate * 2;
for(; multiple < arraySize; multiple += candidate)
primes[multiple] = false;
}
}
上述注释解释了作者在编写代码时的想法。它们不仅仅是重复代码正在做什么。注意,一些注释解释了一行代码,而其他注释总结了整个代码块。关于代码中应该有多少注释并没有硬性规定。一个粗略的建议是,每个代码块都应该有一个注释来解释其目的,对于更复杂的行则可以有额外的注释。
类似于方法顶部的那段注释块最不可能被使用,但它们也能起到重要的作用。就像这本书的章节标题在查找特定内容时很有帮助一样,函数标题在扫描源代码文件查找特定函数时也能提供帮助。
函数标题非常有用,因为它们总结了关于函数的所有信息,而无需查看代码。任何人都可以轻松理解参数的目的、返回值,甚至可能抛出的任何异常。最好的部分是,通过使用像 Doxygen 这样的工具,可以将头文件块提取出来制作外部文档。
查看 Doxygen 工具和文档,请访问www.stack.nl/~dimitri/doxygen/。
当然,这些是最难编写和维护的。正是这样的注释块常常变得过时或完全错误。是否使用它们取决于你和你所在的团队。保持它们需要自律,但如果你在团队成员离开团队后处理他们的代码,它们可能就值得了。
学习和理解const关键字的使用
使用const是编程中似乎有些争议的另一个领域。一些程序员认为他们从未遇到过使用const就能解决问题的 bug。另一些人则认为,由于你不能保证const对象不会被修改,所以它完全无用。事实上,const对象是可以被修改的。const并非魔法。那么,const的正确性是否仍然是一个好东西呢?在我们深入探讨这个问题之前,让我们先看看const是什么。
当你创建一个const变量时,你必须对其进行初始化。所有const变量都会在编译时进行检查,以确保变量不会被赋予新的值。由于这发生在编译时,因此它不会对性能产生影响。以下是一些我们应该考虑的益处。首先,它提高了可读性。通过将变量标记为const,你是在告诉读者这个变量不应该改变。你正在分享你对变量的意图,并使你的代码具有自文档化的特性。const变量通常也使用全部大写字母命名,这进一步有助于提高可读性。其次,由于变量是在编译时进行检查的,因此用户无法意外地更改其值。如果有人试图修改变量,将会导致编译器错误。如果你预期值保持不变,这对你来说是个好消息。如果修改确实是一个意外,这对用户来说也是个好消息。
应该始终优先考虑编译器错误而不是运行时错误。任何时候我们都可以使用编译器来帮助我们找到问题。这就是为什么许多程序员选择将他们的编译器警告设置为最大,并将这些警告视为错误。花时间修复已知的编译器问题,你就不必花费时间去寻找它可能引起的运行时错误。
此外,应优先使用const变量而不是 C 风格的#define宏。宏是一个简单的工具。有时它们可能是完成工作的唯一工具,但对于简单的符号常量来说,它们是过度杀伤。宏进行盲目的查找和替换。符号常量在源代码中的任何位置都会被其值替换。虽然这些情况可能很少见,但它们也可能令人沮丧。由于值是在预处理阶段被替换的,所以在你试图解决问题时,源代码不会发生变化。
另一方面,const变量是语言的一部分。它们遵循所有正常的语言规则,包括类型和运算符。没有神秘的事情发生。它们只是不能重新分配的变量:
int i1; //No initialization, OK
int i2 = 0; //Initialization, OK
const int ci1; //ERROR: No initialization
const int ci2 = 0; //Initialization, OK
i1 = 10; //Assignment, OK
i2 += 2; //Assignment, OK
ci1 = 10; //ERROR: Can't Assign
ci2 += 2; //ERROR: Can't Assign
const函数参数
将const变量作为符号常量创建可以使代码更易读,因为我们避免了使用魔法数字。然而,const的正确性不仅仅局限于创建符号常量。理解const与函数参数的关系同样重要。
理解这些不同函数签名之间的区别很重要:
void Foo(int* a); //Pass by pointer
void Foo(int& a); //Pass by reference
void Foo(int a); //Pass by value
void Foo(const int a); //Pass by const value
void Foo(const int* a);//Pass by pointer to const
void Foo(const int& a);//Pass by reference to const
C 和 C++的默认行为是按值传递。这意味着当你将变量传递给函数时,会创建一个副本。对函数参数所做的更改不会修改原始变量。函数作者有自由使用变量的方式,而原始变量所有者可以确信值将保持不变。
这意味着,从原始变量所有者的角度来看,这两个函数签名表现相同。实际上,在考虑函数重载时,编译器不会在这两个之间做出区分:
void Foo(int a); //Pass by value
void Foo(const int a); //Pass by const value
由于按值传递的变量在传递给函数时无法被修改,许多程序员不会将这些参数标记为const。尽管如此,将它们标记为const仍然是一个好主意,因为这向读者表明变量的值不应该被改变。然而,这种类型的参数标记为const的重要性较低,因为它不能被改变。
当你想将数组传递给函数时怎么办?记住,C 和 C++的一个小特点是数组有时和指针被类似对待。当你将数组传递给函数时,并不会创建数组的副本。相反,传递的是指向第一个元素的指针。这种默认行为的一个副作用是函数现在可以修改原始数据:
//A Poorly named function that unexpectedly modifies data
void PrintArray(int buffer[], int size)
{
for(int i = 0; i < size; ++i)
{
buffer[i] = 0; //Whoops!!!
std::cout << buffer[i] << " ";
}
std::cout << std::endl;
}
//Example of creating an array and passing it to the function
int main(void)
{
const int SIZE = 5;
int array[SIZE] = {1, 2, 3, 4, 5};
PrintArray(array, SIZE);
return 0;
}
上述代码的输出如下:
0 0 0 0 0
正如你所见,没有任何东西阻止函数修改原始数据。函数中的size变量是主函数中SIZE的一个副本。然而,buffer变量是一个指向数组的指针。由于PrintArray函数很短,所以找到这个错误可能很容易,但在一个可能将指针传递给其他函数的较长的函数中,这个问题可能很难追踪。
如果用户想防止函数修改数据,他们可以将数组标记为 const。然而,他们将无法使用PrintArray函数,也无法修改数据:
int main(void)
{
const int SIZE = 5;
const int array[SIZE] = {1, 2, 3, 4, 5};//Marked as const
array[0] = 0;//ERROR: Can't modify a const array
PrintArray(array, SIZE);//Error: Function doesn't accept const
return 0;
}
当然,有时函数的目的是修改数据。在这种情况下,用户必须接受如果他们想使用该函数的话。对于像PrintArray这样的名字,用户可能期望在函数调用后数据不会改变。数据修改是有意为之还是意外?用户无法得知。
由于问题出在函数名不清晰,所以修改的责任在于函数的作者。他们可以选择使名称更清晰,比如使用ClearAndPrintArray这样的名字,或者修复错误。当然,修复错误并不能防止类似的事情再次发生,也不能明确函数的意图。
一个更好的主意是作者将缓冲区标记为 const 参数。这将允许编译器捕捉到上述类似的事故,并且会向用户表明函数承诺不会修改数据:
//Const prevents the function from modifying the data
void PrintArray(const int buffer[], int size)
{
for(int i = 0; i < size; ++i)
{
//buffer[i] = 0; //This would be a compiler error
std::cout << buffer[i] << " ";
}
std::cout << std::endl;
}
int main(void)
{
const int SIZE = 5;
int array[SIZE] = {1, 2, 3, 4, 5};
array[0] = 0;//Modifying the array is fine
PrintArray(array, SIZE);//OK. Can accept non-const
return 0;
}
正如我们之前所说的,size变量也可以标记为 const。这将更清楚地表明变量不应该改变,但这不是必要的,因为它是一个副本。对大小的任何修改都不会改变主函数中SIZE的值。因此,许多程序员,即使是那些追求 const 正确性的程序员,也不会将按值传递的参数标记为 const。
常量类作为参数
我们已经讨论了将数组传递给函数时的默认行为。编译器会自动传递数组的第一个元素的指针。这对速度和灵活性都有好处。由于只传递了一个指针,编译器不需要花费时间复制一个(可能)大的数组。这也更加灵活,因为函数可以处理所有大小的数组,而不仅仅是特定大小的数组。
不幸的是,当将结构体或类传递给函数时,默认行为是按值传递。我们说不幸的是,因为这会自动调用复制构造函数,这可能既昂贵又没有必要,如果函数只是从数据类型中读取数据。遵循的一个好的一般规则是,当将结构体或类传递给函数时,不要按值传递,而是通过指针或引用传递。这避免了可能昂贵的复制数据。当然,这个规则肯定有例外,但 99%的情况下,按值传递是错误的做法:
//Simplified GameObject struct
struct GameObject
{
M5Vec2 pos;
M5Vec2 vel;
int textureID;
std::list<M5Component*> components;
std::string name;
};
void DebugPrintGameObject(GameObject& gameObject)
{
//Do printing
gameObject.textureID = 0;//WHOOPS!!!
}
我们希望避免在将GameObjects传递给函数时调用昂贵的复制构造函数。不幸的是,当我们通过指针或引用传递时,函数可以访问我们的公共数据并修改它。正如之前所做的那样,解决方案是通过指针传递到const或通过const引用传递:
void DebugPrintGameObject(const GameObject& gameObject)
{
//Do printing
gameObject.textureID = 0;//ERROR: gameObject is const
}
在编写函数时,如果目的是修改数据,那么你应该通过引用传递。然而,如果目的不是修改数据,那么通过const引用传递。这样你将避免昂贵的复制构造函数调用,并且数据将受到意外修改的保护。此外,通过养成通过引用或const引用传递的习惯,你的代码将是自文档化的。
常量成员函数
在之前的例子中,我们保持了结构体非常简单。由于结构体没有成员函数,我们只需要担心非成员函数何时想要修改数据。然而,面向对象编程建议我们不应该有公共数据。相反,所有数据都应该是私有的,并通过公共成员函数访问。让我们通过一个非常简单的例子来理解这个概念:
class Simple
{
public:
Simple(void)
{
m_data = 0;
}
void SetData(int data)
{
m_data = data;
}
int GetData(void)
{
return m_data;
}
private:
int m_data;
};
int main(void)
{
Simple s;
const Simple cs;
s.SetData(10); //Works as Expected
int value = s.GetData();//Works as Expected
cs.SetData(10); //Error as expected
value = cs.GetData(); //Error: Not Expected
return 0;
}
如预期的那样,当我们的类没有被标记为const时,我们可以使用SetData和GetData成员函数。然而,当我们把我们的类标记为const时,我们预期将无法使用SetData成员函数,因为它会修改数据。然而,出乎意料的是,即使它根本不会修改数据,我们也无法使用GetData成员函数。为了理解发生了什么,我们需要了解成员函数是如何被调用的以及成员函数是如何修改正确数据的。
每次调用非静态成员函数时,第一个参数总是隐藏的 this 指针。它是指向调用该函数的实例的指针。这个参数是 SetData 和 GetData 能够作用于正确数据的方式。this 指针在成员函数中是可选的,作为程序员,我们可以选择使用或不使用它:
//Example showing the hidden this pointer. This code won't //compile
Simple::Simple(Simple* this)
{
this->m_data = 0;
}
void Simple::SetData(Simple* this, int data)
{
this->m_data = data;
}
int Simple::GetData(Simple* this)
{
return this->m_data;
}
这完全正确。this 指针实际上是一个指向 Simple 类的 const 指针。我们之前没有讨论过 const 指针,但这仅仅意味着指针本身不能被修改,但它所指向的数据(即 Simple 类)是可以被修改的。这种区别很重要。指针是 const 的,但 Simple 类不是。实际的隐藏参数看起来可能像这样:
//Not Real Code. Will Not Compile
Simple::Simple(Simple* const this)
{
this->m_data = 0;
}
当我们遇到如下调用成员函数的代码:
Simple s;
s.SetData(10);
编译器实际上将其转换成如下代码:
Simple s;
Simple::SetData(&s, 10);
这就是为什么当我们尝试将 const Simple 对象传递给成员函数时会出现错误的原因。函数签名是不正确的。该函数不接受 const Simple 对象。不幸的是,由于 this 指针是隐藏的,我们无法简单地让 GetData 函数接受 const Simple 指针。相反,我们必须将函数标记为 const:
//What we would like to do but can't
int Simple::GetData(const Simple* const this);
//We must mark the function as const
int Simple::GetData(void) const;
我们必须在类内部也将函数标记为 const。注意,SetData 没有标记为 const,因为该函数的目的是修改类,但 GetData 被标记为 const,因为它只从类中读取数据。所以,我们的代码可能看起来像以下这样。为了节省空间,我们没有再次包含函数定义:
class Simple
{
public:
Simple(void);
void SetData(int data);
int GetData(void) const;
private:
int m_data;
};
int main(void)
{
Simple s;
const Simple cs;
s.SetData(10); //Works as Expected
int value = s.GetData();//Works as Expected
cs.SetData(10); //Error as expected
value = cs.GetData(); //Works as Expected
return 0;
}
如你所见,通过将 GetData 成员函数标记为 const,它可以在变量实例被标记为 const 时使用。将成员函数标记为 const 允许类与非成员函数正确地工作,这些非成员函数可能正在尝试保持 const 正确性。例如,一个非成员函数(可能由另一个程序员编写)试图通过使用 GetData 成员函数来显示 Simple 对象:
//Const correct global function using member functions to access
//the data
void DisplaySimple(const Simple& s)
{
std::cout << s.GetData() << std::end;
}
由于 DisplaySimple 并不打算更改类中的数据,参数应该被标记为 const。然而,这段代码只有在 GetData 是 const 成员函数的情况下才能正常工作。
保持 const 正确性需要一点工作,一开始可能看起来有些困难。然而,如果你养成习惯,它最终会成为你编程的自然方式。当你保持 const 正确性时,你的代码会更干净、更安全、更具有自解释性,并且更灵活,因为你为 const 和非 const 实例做好了准备。一般来说,如果你的函数不会修改数据,就将参数标记为 const。如果成员函数不会修改类数据,就将成员函数标记为 const。
const 相关问题
正如我们之前所说的,const 并非魔法。它并不能使你的代码 100% 安全和受保护。了解和理解与 const 参数和 const 成员函数相关的规则将有助于防止错误。然而,未能理解 const 的规则和行为可能会导致错误。
C++ 中 const 最大的问题是对于位运算 const 和逻辑 const 的误解。这意味着编译器将尝试确保通过该特定变量位和字节不会改变。这并不意味着那些位不会通过另一个变量来改变,这也不意味着你关心的数据不会改变。考虑以下代码:
//Example of modifying const bits through different variables.
int i = 0;
const int& ci = i;
ci = 10; //ERROR: can't modify the bits through const variable
i = 10; //OK. i is not const
std::cout << ci << std::endl;//Prints 10
在前面的例子中,i 不是 const,但 ci 是一个指向 const int 的引用。i 和 ci 都在访问相同的位。由于 ci 被标记为 const,我们不能通过该变量更改其值。然而,i 不是 const,所以我们有权修改其值。我们可以有多个 const 和非 const 变量指向同一地址,这对 const 成员函数有影响:
class Simple
{
public:
Simple(void);
int GetData(void) const;
private:
int m_data;
Simple* m_this;
};
Simple::Simple(void):m_data(0), m_this(this)
{
}
int Simple::GetData(void) const
{
m_this->m_data = 10;
return m_data;
}
int main(void)
{
const Simple s;
std::cout << s.GetData() << std::endl;
return 0;
}
在前面的代码中,我们给 Simple 类提供了一个指向自身的指针。这个指针可以在 const 成员函数中用来修改其数据。记住,在 const 成员函数中,this 指针被标记为 const,所以不能通过该变量更改数据。然而,正如在这个例子中,数据仍然可以通过另一个变量来更改。即使我们没有使用另一个变量,const_cast 的使用也可能允许我们更改数据:
int Simple::GetData(void) const
{
const_cast<Simple*>(this)->m_data = 10;
m_data;
}
非常重要的是要理解,你永远不应该编写这样的代码。尝试使用 const_cast 或非 const 指针来修改 const 变量是未定义的行为。原始数据可能被放置在只读内存中,这样的代码可能会导致程序崩溃。也有可能编译器会优化掉不应更改的内存的多次读取。因此,旧值可能会被用于任何未来的计算。使用 const_cast 移除 const 是为了与旧的 C++ 库保持向后兼容。它永远不应该用来修改 const 值。如果有一份数据即使在类是 const 的情况下也需要修改,请使用 mutable 关键字。
即使避免未定义的行为,位运算的 const 也会让我们在与 const 成员变量打交道时遇到麻烦。考虑一个将包含一些动态内存的简单类。由于它包含动态内存和指针,我们应该添加拷贝构造函数、析构函数以及其他一些东西来防止内存泄漏和内存损坏,但现在我们将省略这些,因为它们对我们讨论 const 的内容不重要:
class LeakyArray
{
public:
LeakyArray(int size)
{
m_array = new int[size];
}
void SetValue(int index, int value)
{
m_array[index] = value;
}
int GetValue(int index) const
{
//function is const so we can't do this
//m_array = 0;
//but we can do this!!!!!!!
m_array[index] = 0;
return m_array[index];
}
private:
int* m_array;
};
正如你所见,位运算 const 只能阻止我们修改类内部的实际位。这意味着我们不能将m_array指向新的位置。然而,它并不能阻止我们修改数组中的数据。在 const 函数中,GetValue修改数组没有任何阻碍,因为数组数据不是类的一部分,只有指针是。大多数用户并不关心数据的位置,但他们期望 const 数组保持不变。
正如你所见,保持一致性、正确性并不能保证数据永远不会被修改。如果你勤奋地使用 const,并且理解和避免可能出现的错误,那么这些好处是值得的。
学习如何通过迭代改进你的游戏和代码设计
虽然想象它是这样很美好,但游戏并不是完全由设计师/开发者的头脑中产生的。一个游戏是由许多不同的人的不同想法组成的。在过去,人们可以用单一个人的力量开发游戏,但现在,由许多不同学科组成的团队更为常见,团队中的每个游戏开发者都有自己的想法,其中许多很好的想法可以为最终制作的产品做出贡献。但考虑到这一点,你可能想知道,在所有这些不同的变化之后,游戏是如何达到最终阶段的?答案是迭代。
游戏开发周期
游戏开发是一个过程,不同的人对这些步骤有不同的名称和/或短语,但大多数人可以同意,对于商业游戏开发,有三个主要阶段:
-
前期制作
-
制作阶段
-
后期制作
这些状态中的每一个都有它们自己的步骤。由于页面限制,我无法详细描述整个过程,但我们将重点关注开发的制作方面,因为这对我们的读者来说是最相关的内容。
如果你想要了解更多关于游戏开发过程的不同方面,请查看en.wikipedia.org/wiki/Video_game_development#Development_process。
在游戏开发过程中,你会看到很多公司使用敏捷开发流程,这种流程基于迭代原型设计,通过使用反馈和游戏迭代的精炼,逐渐增加游戏的功能集。许多公司都喜欢这种方法,因为每隔几周就可以玩到游戏的一个版本,并且可以在项目进行中做出调整。如果你听说过 Scrum,它是一种流行的敏捷软件开发方法,也是我在我的学生和游戏行业中使用的方法。
制作阶段
进入生产阶段后,我们已经为我们的项目提出了基本想法,并创建了我们的提案和游戏设计文档。现在我们有了这些信息,我们可以开始以下三个步骤:
-
原型制作
-
游戏测试
-
迭代
每个步骤都服务于一个有价值的过程,并且将按照这个顺序完成。我们将反复重复这些步骤,直到发布,因此了解它们是个好主意。
原型制作
原型制作就是以快速的方式制作你想法的最简单版本,以证明你的概念是否运作良好。对于一些人来说,他们会通过索引卡、纸张、筹码和板子来完成这个任务,这被称为纸质原型。这可以非常实用,因为你一开始不必考虑代码方面的事情,而是让你能够体验游戏的核心,而不需要所有那些精美的艺术和打磨。一个图形不好的游戏,只有当你添加内容时才会变得有趣。
当然,假设你已经购买了这本书,你很可能已经是一名开发者了,但将其视为一个选项仍然是个好主意。杰西·谢尔在他的书《游戏设计艺术:视角之书》中写到了纸质原型,他解释了如何制作《俄罗斯方块》的纸质原型。为此,你可以剪出纸板碎片,然后将它们堆在一起,随机抽取,然后沿着纸板滑动,这将是纸板的一部分。一旦完成了一行,你就可以拿起一把 X-Acto 刀,然后剪下碎片。虽然这不能给你完全相同的感觉,但它足以让你看到你是否使用了正确的形状,以及碎片应该以多快的速度落下。最大的优势是,你可以在 10 到 15 分钟内创建这个原型,而编程可能需要更长的时间。
对于那些没有成功的事情,用 30 分钟来证明比用一整天更有说服力。这同样适用于 3D 游戏,比如第一人称射击游戏,通过创建地图的方式与你在纸上和笔的角色扮演游戏(如海岸巫师的《龙与地下城》)中创建战斗遭遇战的方式相似(作为一个设计师学习如何玩是一个很好的事情,因为你可以了解如何讲述故事和开发有趣的遭遇战)。
原型的任务是证明你的游戏是否运作,以及它具体是如何运作的。不要只投资于一个特定的想法,而是创建多个小型原型,快速制作,不必担心它是否完美或是否是你能做得最好的。
关于构建原型和七天内创建的原型示例,例如关于Goo 塔的原型,它是独立游戏《Goo 世界》的原型,你可以查看www.gamasutra.com/view/feature/130848/how_to_prototype_a_game_in_under_7_.php?print=1了解更多信息。
作为游戏开发者,最重要的技能之一是能够快速创建原型,看看它如何运作,然后对其进行测试。我们称这个过程为游戏想法的测试。
游戏测试
一旦我们有了一个原型,我们就可以开始游戏测试过程。在开发过程中尽快进行游戏测试,并且经常进行。一旦你有了一个可玩的东西,就让人来试玩。首先自己玩玩游戏,看看自己的感受如何。然后邀请一些朋友到你家里来,也让他们试玩。
经常发现我的学生在最初进行游戏测试时会有困难,他们可能因为项目尚未准备好或担心别人无法理解而犹豫是否展示他们的项目。或者他们知道项目尚未完成,所以认为自己已经知道应该做什么,因此没有必要进行游戏测试。我发现这通常是因为他们害羞,而作为开发者,你需要克服的第一个主要障碍就是能够向世界展示你的想法。
如果你的游戏测试者不是你的亲密朋友和家人,那么很可能会有人对游戏提出负面评价。这是好事。他们还会提到许多你已经知道你的游戏还没有或没有预算去做的事情。这不是你为自己辩护或解释事情为什么是这样的时间,而是一个接受这些观点并记录下来,以便你可以在未来考虑它们的时间。
作为游戏开发者,有一点需要注意,你可能是自己游戏最差的评判者,尤其是在刚开始的时候。很多时候我看到初出茅庐的开发者试图为自己的游戏中的问题辩解,声称那是他们的愿景,人们不理解因为它还没有进入最终游戏。作为游戏开发者,能够获取反馈、接受批评并评估是否值得改变是非常重要的一项技能。
进行游戏测试
既然我们知道进行游戏测试是多么有价值,你可能想知道如何进行一次游戏测试。首先,我确实想强调,你在你的项目进行游戏测试时在场至关重要。当他们玩游戏时,你可以看到不仅有人认为什么,还可以看到他们对事物的反应以及他们如何使用你的游戏。这是你发现什么做得好,什么做得不好的时候。如果由于某种原因,你无法亲自到场,让他们记录下自己玩游戏的过程,如果可能的话,包括在 PC 上和通过摄像头。
当有人来电脑上测试你的游戏时,你可能会想告诉他们一些关于你的项目的事情,比如控制、故事、机制,以及其他任何东西,但你应该抵制这些冲动。首先看看玩家在没有提示的情况下会做什么。这将给你一个想法,即玩家在所创造的环境中会自然想要做什么,以及需要解释得更清楚的地方。一旦他们玩了一段时间,并且你已经从那方面获得了所需的信息,然后你可以告诉他们一些事情。
在进行游戏测试时,尽可能从玩家那里获取尽可能多的信息是个好主意。当他们完成游戏后,询问他们喜欢什么,不喜欢什么,是否发现什么令人困惑的地方,他们在哪里卡住了,以及对他们来说最有趣的是什么。请注意,玩家说的话和他们实际做的事情是两回事,所以你必须在场并观察他们。让你的游戏被玩,并观察那些玩家的行为,这是你开始看到设计缺陷的地方,而观察人们的行为将展示他们如何体验你所创造的事物。在进行这项测试时,我看到很多人做了我预期相反的事情,并且没有理解我认为相当简单的东西。然而,在这个问题上,玩家并没有错,是我错了。玩家只能做他们从之前的游戏或游戏中的教学所知道的事情。
在游戏测试期间,你获得的所有信息都很重要。不仅包括他们所说的内容,还包括他们没有说的内容。一旦他们完成游戏,就给他们一份调查问卷填写。我发现使用 Google Sheets 来存储这些信息效果很好,而且设置起来并不困难,你还可以从这些硬数据中做出决策,而不必记住人们说了什么。此外,人们从 1 到 10 选择他们对游戏不同方面的喜爱程度,比要求他们写出对一切的看法要容易得多,而且不需要他们写段落信息(除非他们想在最后的评论部分这样做)。
如果你想看看一个示例测试表单,虽然这个表单是为桌面游戏设计的,但我认为它很好地简化了测试者提供有用信息的流程:www.reddit.com/r/boardgames/comments/1ej13y/i_created_a_streamlined_playtesting_feedback_form/.
如果你在寻找一些可以提出的问题的想法,韦斯利·罗克霍兹提供了一些可能对你有用的提问示例:www.gamasutra.com/blogs/WesleyRockholz/20140418/215819/10_Insightful_Playtest_Questions.php.
此外,玩家提供反馈的顺序也很重要,因为它传达了不同事物对他们的重要性。你可能会发现原本打算作为主要机制的东西并不像其他东西那样吸引人/有趣。这是宝贵的反馈,你可能会决定专注于那个次要机制,就像我在多个项目中看到的那样。尽早这样做会更好,这样你就可以尽可能少地浪费时间。
迭代
在这一点上,我们已经进行了项目测试并收集了玩家的反馈,如果已经设置好了,我们还从数据和分析中获得了可以继续发展的信息。现在我们需要考虑这些信息,对我们的当前原型进行一些修改,然后再次进行测试。这就是开发中的迭代阶段。
在这个阶段,你需要考虑这个反馈并决定如何将其融入你的设计中。你需要决定应该改变什么,以及不应该改变什么。在这样做的时候,要记住项目的范围,现实地评估做出这些改变需要多长时间,并且愿意砍掉一些功能,即使是你喜欢的,以获得最好的项目。
在再次做出这些决定后,我们将再次创建一个新的原型,然后你将再次进行测试。然后再次迭代。然后构建另一个原型,在那里你将继续测试,移除那些不起作用的原型和项目效果不佳的功能。你还将尝试使用反馈添加新功能,并移除那些不再适合当前游戏状态的功能。你将不断重复这个周期,直到达到最终的发布版本!
如果你等待你的游戏变得完美后再发布,你永远不会发布它。游戏永远不会完成,它们只是被放弃了。如果项目已经足够好,你应该发布,因为只有当你发布一个项目时,你才能最终说你已经开发了一个游戏。
如果你想看看这个过程的例子以及它如何有助于一个标题,请查看:www.gamasutra.com/blogs/PatrickMorgan/20160217/265915/Gurgamoth_Lessons_in_Iterative_Game_Development.php。
达成里程碑
当你在进行商业游戏项目时,尤其是当你有发行商时,你通常会有一个需要遵守的日程表和需要达到的里程碑。里程碑是让每个人都知道游戏是否按计划进行的一种方式,因为某些事情需要在它们完成之前完成。未能达到里程碑通常是一件糟糕的事情,因为你的发行商通常只有在里程碑中包含所有商定的内容时才会支付你的团队。没有标准的里程碑时间表,因为每家公司都不同,但其中一些最常见的如下:
-
First-playable:这是可以玩的游戏的第一个版本。包含了游戏的主要机制,可以展示它是如何工作的。
-
Alpha:当你的游戏的所有功能都齐备时,称为功能完整。功能可以略有变化,并根据反馈和测试进行修订,但在这个阶段,未实现的功能可能会被删除,以确保按时完成标题。
-
Beta:游戏已经完成,所有资源和功能都已完善和完成。此时你只是在进行错误测试和修复可能阻止游戏发布的潜在问题。
-
Gold:这是游戏的最终版本,你将要么发布它,要么将其发送给发行商,以便在磁盘、卡带或你的设备使用的任何介质上创建副本。
请注意,每家公司都不同,这些里程碑对不同的人可能意味着不同的事情,所以在深入开发之前一定要明确。
学习何时在游戏中使用脚本
脚本语言是当你在具有多个学科的团队中工作时,对开发者非常有帮助的东西。但在我们深入探讨它们是什么以及它们是如何工作之前,以及使用脚本语言的优缺点之前,最好先了解一下代码执行的历史。
汇编语言简介
在幕后,我们在本书的整个过程中编写的所有代码都是一串零和一,表示我们的计算机处理器应该将哪些开关标记为开启和关闭。低级编程语言,如机器语言,使用这些开关来执行命令。这最初是编程的唯一方式,但我们已经开发出更易于阅读的语言来供我们使用。
从汇编语言开始,低级语言与语言的指令和机器代码的指令之间有着非常紧密的联系。虽然比一串0s 和1s 更易读,但编写代码仍然相当困难。例如,以下是一些用于在汇编语言中添加两个数字的汇编代码:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-20], edi
mov DWORD PTR [rbp-24], esi
mov edx, DWORD PTR [rbp-20]
mov eax, DWORD PTR [rbp-24]
add eax, edx
mov DWORD PTR [rbp-4], eax
nop
pop rbp
ret
每种计算机架构都有自己的汇编语言,因此使用低级语言编写代码的缺点是不具有可移植性,因为它们依赖于机器。在过去的岁月里,人们必须学习许多不同的语言,以便将你的程序移植到另一个处理器。随着功能需求随着时间的推移而增加,程序结构变得更加复杂,这使得程序员很难实现既高效又足够健壮的程序。
转向高级编程语言
作为程序员,我们天生懒惰,因此我们寻求使我们的工作变得更简单,或者更确切地说,找到我们时间的最佳利用方式。考虑到这一点,我们已经开发了其他高级语言,这些语言甚至更容易阅读。当我们说高级时,我们的意思是更接近人类思考的方式,或者更接近我们试图解决的问题。通过从我们的代码中抽象出机器细节,我们简化了编程任务。
介绍编译器
一旦我们完成了代码,我们就使用编译器将高级代码翻译成汇编语言,然后汇编语言将被转换成计算机可以执行的机器语言。之后,它将程序转换成用户可以运行的可执行文件。从功能上看,它看起来像这样:

这有几个优点,因为它提供了对硬件细节的抽象。例如,我们不再需要直接与寄存器、内存、地址等打交道。这也使得我们的代码具有可移植性,我们可以使用相同的程序,并由不同的汇编器为使用它的不同机器进行翻译。这正是 C 语言之所以兴起并变得如此受欢迎的原因之一,因为它允许人们编写一次代码,然后它可以在任何地方运行。你可能已经注意到 Unity 在游戏开发中也采用了同样的思考方式,这也是我认为他们之所以成功的原因之一。
与编写汇编语言代码相比,这是一种更有效率的利用时间的方式,因为它允许我们创建更复杂的项目和机器,并且在大多数情况下,现代编译器如微软的编译器都能生成一些非常高效的汇编代码。这正是我们在本书中一直在使用的方法。
尽管在汇编语言中编写代码仍然有其好处。例如,在你用高级语言编写完你的游戏后,你可以开始分析它,看看游戏的哪些方面是瓶颈,然后确定是否将其重写为汇编语言会给你带来速度提升。使用低级语言的目的在于你可以获得一些实质性的速度优势。
对于一个真实生活中的例子,说明如何使用汇编语言来优化游戏引擎,请查看以下来自英特尔的文章:software.intel.com/en-us/articles/achieving-performance-an-approach-to-optimizing-a-game-engine/.
在运行前需要编译的代码编写中存在的问题之一是,随着项目规模的增加,编译时间也会增加。重新编译整个游戏可能需要几分钟到几小时,这期间你无法工作在项目上,否则你可能需要再次重新编译。这就是脚本语言可能有用的一部分原因。
脚本语言的介绍
脚本语言是一种允许为其编写脚本的编程语言。脚本是一种可以在不进行编译的情况下以几种不同方式执行的程序。脚本语言有时也被称为非常高级的编程语言,因为它们在高级别抽象,学习如何编写它们非常快。
脚本语言还有优点,可以处理程序员需要处理的大量事情,例如垃圾回收、内存管理和指针,这些通常会让非开发者感到困惑。即使是像 Unreal 4 的蓝图这样的视觉编辑器,也仍然是脚本语言,因为它完成了与书面语言相同的事情。
大多数游戏以某种形式使用脚本语言,但有些游戏可能使用得更多,例如 GameMaker 使用Game Maker Language(GML)进行逻辑处理。
使用解释器
要使用脚本语言,我们需要能够即时执行新代码。然而,与编译器不同,还有一种将代码转换为机器可以理解的方式,这被称为解释器。解释器不会生成程序,而是存在于程序的执行过程中。这个程序将执行以下操作之一:
-
直接执行源代码
-
将源代码翻译成某种高效的中间表示(代码),然后立即执行它
-
明确执行由解释器系统中的编译器生成的预编译代码
解释器逐行翻译,而编译器则一次性完成所有工作。
从视觉上看,它看起来有点像以下这样:

正如你所见,解释器接收源代码和任何已接收的输入,然后输出其期望的结果。
即时编译
运行代码还有另一种方式,使用所谓的即时编译器,或简称JIT。JIT 缓存了之前已解释为机器代码的指令,并重用这些原生机器代码指令,从而通过不必重新解释已解释的语句来节省时间和资源。
从视觉上看,它看起来类似这样:

现在,Unity 使用即时编译(JIT)和预编译(AOT)编译器将代码转换为机器码,这样机器就可以读取。当函数第一次被调用时,游戏会将该代码转换为机器语言,然后下次调用时,它会直接跳转到翻译后的代码,因此你只需要对正在发生的事情进行转换。由于这是在运行时发生的,这可能会导致你在使用大量新功能时项目出现卡顿。
关于 Unity 游戏引擎内部脚本工作原理的精彩演讲可以在这里找到:www.youtube.com/watch?v=WE3PWHLGsX4。
为什么使用脚本语言?
当涉及到为你的游戏构建工具或处理由技术设计师处理的高级游戏任务时,C++通常过于强大。它确实有一些开发上的优势。具体来说,你不必担心很多底层的事情,因为语言会为你处理这些;程序员由于选项有限,错误也更少。需要的编程知识更少,并且可以根据游戏需求进行定制。这也使得游戏更注重数据驱动,而不是将东西硬编码到游戏引擎中,并允许你在不发送整个项目的情况下修补游戏。
在游戏开发中,游戏逻辑和配置通常可以在脚本文件中找到。这样,非程序员(如设计师)很容易修改和调整脚本,允许他们进行游戏测试和调整游戏玩法,而无需重新编译游戏。
许多游戏也都有一个控制台窗口,它使用脚本语言在运行时执行此类操作。例如,当你按下 Tab 键时,Unreal Engine 会默认打开控制台窗口,而在 Source 引擎中,在暂停菜单中按下~按钮也会打开一个控制台窗口。
脚本语言也常用于具有关卡设计的领域,例如,当进入某些区域时触发器,或控制电影场景。它还允许你的游戏玩家对游戏进行修改,这可能会增加游戏的生命周期并有助于培养你的游戏社区。
何时使用 C++
C++是一个很好的语言选择,因为性能是一个关键的第一步。这曾经是游戏引擎的所有方面,但现在主要用于图形和 AI 代码。脚本语言也存在比 C++慢的问题,有时甚至比其他情况慢 10 倍。由于脚本语言自动处理内存管理,有时命令可能会中断或需要一段时间才能完成垃圾回收,导致卡顿和其他问题。
C++也有更好的 IDE 和调试器的优势,这使得你在出错时更容易找到并修复错误。
还有一种可能性,就是你正在处理一个遗留代码库。大多数游戏公司并不是从一张白纸开始。利用 C++的中间件库,如 FMOD 和 AntTweakBar,也可能很有用。
编译与脚本
对于某些游戏引擎,游戏引擎本身是用 C++编写的,但游戏逻辑完全是使用脚本语言完成的,例如 Unity 的大多数开发。这允许你更快地迭代游戏玩法,并允许技术设计师和艺术家在不打扰程序员的情况下修改行为。此外,根据语言的不同,它还可以允许人们使用更适合问题域的语言(例如,AI 可能不是在 C++中实现的最容易的事情)。
不同的公司处理与语言合作的方式不同。当我在一间 AAA(发音为三 A)工作室工作时,我们会让设计师为机制原型设计想法,并尽可能好地使用脚本语言实现它们。一旦得到负责人的批准,如果脚本存在性能问题,程序员会以脚本语言代码为基础,然后创建一个超级高效的版本,使用 C++实现,使其在所有级别上都能工作。然而,当我为一个独立游戏项目工作时,所有的代码都是用脚本语言(C#)编写的,因为我们没有访问引擎源代码(Unity)。此外,如果你想要针对内存和处理能力有限的设备(如任天堂 3DS),你可能会更加关注性能,因此使用更优化的代码就更加重要。熟悉这两种选项并且能够舒适地以任何一种方式工作是个好主意。
如果你对你的项目有兴趣使用脚本语言,Lua 在游戏行业中非常广泛使用,因为它非常容易学习,并且相对容易集成到你的引擎中。Lua 最初是一个配置语言。这有一些很好的特性,比如它非常适合创建和配置事物——这正是你在游戏中想要做的。不过,需要注意的是,它不是面向对象的,但使用少量的内存。
使用 Lua 作为脚本语言的游戏列表可以在这里找到:en.wikipedia.org/wiki/Category%3aLua-scripted_video_games。
如果你有兴趣将 Lua 集成到你的项目中或者想看看它是如何工作的,我强烈建议查看www.lua.org/start.html。
摘要
在本章中,我们涵盖了许多最佳实践信息,希望这将为您在将来构建自己的项目时提供一个良好的基础。我们讨论了为什么硬编码值是个坏主意,并提出了许多其他关于代码质量的建议,以确保您的代码易于理解,也易于在将来需要时进行扩展。
我们还学习了迭代在游戏开发中的有用性,讨论了传统的游戏开发周期,以及关于游戏测试的技巧和窍门,以及它在开发项目时如何非常有用。
我们还探讨了低级和高级编程语言,了解了脚本语言是如何在我们必须将其构建到项目中的另一个程序内部运行的。它们不是编译的,而是解释的,通常比编译语言更容易使用和编写代码,但代价是性能。根据您的游戏复杂程度,坚持使用 C++可能是个好主意,但如果您与设计师合作,给他们提供自己动手的工具可能非常有用。
有了这些,我们就到达了这本书的结尾。我们希望您觉得这些信息既有趣又实用。当您出去构建自己的项目时,请利用我们在过去 12 章中讨论的设计模式和最佳实践,制作出最好的游戏!


浙公网安备 33010602011771号