从小我就被告知,无论是在一项运动中追求完美,学习一种乐器,甚至是一项新的技术技能,对基础的深刻理解和实践才是区别所在。用 C++ 开发游戏也没什么不同。在掌握这个过程之前,你必须完善基础。这就是本书第一章的全部内容,涵盖了将在整本书中使用的基本概念。本章分为以下几节:
- 高级 C++ 概念概述
- 使用类型和容器
- 游戏编程模式
在整本书中,你会遇到代码片段和例子。为了保持代码的可读性和统一性,我将遵循一些基本的编码约定。虽然编码标准的话题是一个复杂而冗长的讨论,但我确实认为为任何高级项目制定一些指导方针是很重要的。在开始任何工作之前,至少应该有一个关于预期符号和命名约定的可访问的指南。如果您有兴趣了解更多关于 C++ 中使用的常见代码标准,一个很好的起点是位于https://isocpp.org/wiki/faq/coding-standards的 ISO C++ 网站上的编码标准常见问题部分。在那里,你会发现大量适用于各种情况的常用标准和一堆建议阅读链接,以进一步扩展你的知识。
本书使用的标准和惯例是基于一些核心的 C++ 准则、行业最佳实践和我个人的经验。我们将在整本书中使用最新的国际标准化组织 C++ 标准 C++ 14。然而,有时我们可能会利用最新修订版 C++ 17(也称为 C++ 1y)中的一些功能。当这种情况发生时,会记下原因。
类和函数名将遵循混合案例风格,而变量将遵循卡梅洛案例风格。一些示例如下所示:
int m_numberOfPlayers;
void RunScripts(){}
class GameObject {};
本书中使用的另一个您应该注意的重要约定是范围前缀的使用。范围前缀是提高其他开发人员可读性的一种快速方法,当你令人羡慕地忘记变量属于什么范围时,也可以提高自己的可读性。以下是使用的前缀列表:
m_
:用于类成员变量。这些是private
,通过使用前缀,告诉任何使用变量的人,它在类中或者通过 getters 或 setters(如果是外部的话)是显而易见的,例如m_numberOfPlayers
。s_
:用于静态类成员。这告诉任何使用该变量的人,该类的所有实例中只存在一个副本,并且它是静态的,例如s_objDesc
。g_
:用于全局变量。这告诉任何使用这个变量的人,它在任何地方都是可用的。这些我们在书中不会看到很多,比如g_playerInfo
。
在我们开始构建我们的工具、库和其他游戏组件之前,浏览一些更常见的概念可能是一个好主意,这些概念会在我们继续阅读本书时经常出现。在本节中,我们将快速了解一些高级主题。这并不意味着是一个完整的列表,目标也不是对每个主题的全面概述,相反,对象更多的是对游戏开发概念的回顾和解释。
我们将看一些简单的例子,并强调使用这些概念时可能出现的一些问题。一些经验更丰富的 C++ 开发人员可能会跳过这一部分,但是由于这些主题将在本书的其余部分中发挥重要作用,因此对每个主题都有一个坚定的理解是很重要的。如果你正在寻找更广泛的评论或对主题更深入的解释,请查看本章末尾摘要部分的一些建议阅读。
与智能指针相比,名称空间似乎不是一个非常高级的话题,但是随着您在 C++ 游戏开发冒险中的进步,名称空间将成为开发工具包的重要组成部分。快速回顾一下,命名空间是一个声明,它为封装中的所有变量、类型和函数提供了范围。这很重要,因为它为我们提供了一种将代码组织成逻辑组的方法。通过将我们的代码分成这些组,我们不仅使其更容易阅读,而且还防止了所谓的名称冲突。当多个类、函数或类型具有相同的名称时,就会发生名称冲突。当您开始使用多个库时,这将成为一个大问题。使用名称空间通过使用范围来防止这种情况。例如,假设我们有一个特定平台的专用字符串类的实现。为了防止这个专用版本与标准库实现发生干扰和冲突,我们可以将我们的类型包装在一个名称空间中,如下所示:
namespace ConsoleHelper
{
class string
{
friend bool operator == (const string &string1,
const string &string2);
friend bool operator < (const string &string1,
const string &string2);
//other operators ...
public:
string ();
string(const char* input);
~string() ;
//more functions ...
}
}
然后我们可以这样调用我们特定的字符串实现:
ConsoleHelper::string name = new ConsoleHelper::string("Player Name");
当然,如果我们不想一遍又一遍地键入ConsoleHelper
部分,我们可以添加一个using
语句,告诉编译器使用特定的名称空间来查找我们正在使用的函数、类型和变量。您可以用下面的代码行为我们的名称空间做到这一点:
using namespace ConsoleHelper;
唯一的问题是它将包括所有的ConsoleHelper
库。如果我们只想包含命名空间的特定成员,我们可以使用以下语法来实现:
using namespace ConsoleHelper::string;
这将只包括字符串成员,而不包括整个名称空间。
继承和多态是可以轻松填充自己章节的主题。它们是 C++ 复杂且非常强大的组件。我在这一部分的目标不是要涵盖继承和多态性的全部来龙去脉。相反,我想快速了解一下如何使用这些概念来帮助您构建代码结构。我们将讨论重点,但我将假设您对面向对象的开发概念有基本的了解,并且熟悉访问修饰符和友谊等主题。
首先,我们将关注继承。继承的概念是现代面向对象设计和开发的重要组成部分。虽然继承保存击键的能力是一个巨大的优势,但当允许程序员开发复杂的派生类层次结构时,继承确实显示了它的威力。让我们通过一个简单的例子来看看继承的用法。在这个例子中,我们创建了一个简单的Enemy
类。这个类将处理实体的生命值、武器、交易伤害、人工智能脚本等等:
class Enemy
{
public:
void RunAIScripts();
void Update(double deltaTime);
private:
int m_health;
int m_damage;
};
当我们开始在游戏中实现更多的敌人时,我们可能会开始添加一些不同的条件语句,以允许我们的敌人有更多的变化。添加越来越多的if
语句,甚至可能在一个开关中插入几个案例。这很快就变成了一堆混乱、难以阅读的代码。如果我们决定添加一个稍微不同的敌人,一个有自己可能条件的敌人,比如老板敌人类型,会发生什么。这个新的 boss 敌人类型和原来的Enemy
类有着相似的结构,并且共享了许多相同的类型和功能。我们可以把重叠的代码复制到我们新的Boss
类中。这是可行的,但不是理想的解决方案。我们会有很多代码重复,这种不必要的重复会带来更多的错误。然后,如果你必须修复一个错误,你现在必须在多个地方进行修复。这是一个不必要的维护难题。相反,我们可以使用继承。如果我们的新 boss 敌人类型继承了原来的敌人类型,这意味着我们可以使用原来类拥有的类型和函数。使继承更加强大的是,我们不仅可以采用继承类的函数,还可以用自己的实现覆盖它们。新的Boss
类可以写成这样:
class Boss : public Enemy
{
public:
void Update(double deltaTime);
//more functions...
};
这种类型的结构通常被称为父级和子级层级,其中Boss
类是Enemy
类的子级。这意味着Boss
现在将拥有Enemy
级所需的所有结构。我应该指出,我们只继承了声明的函数和变量public
。那是因为在使用继承的时候,类的public
方法和变量会暴露给使用这个类的所有人。protected
方法和变量只对类本身和任何派生的类可用。private
方法和变量只对该类可用,没有其他人可以访问,即使是派生类
我们用新的Boss
类的特殊版本覆盖了Update()
函数的实现。现在,在我们的代码中,我们可以编写如下内容:
//Somewhere in game or level manager
void UpdateObjects (double deltaTime)
{
enemy.Update(deltaTime);
boss.Update(deltaTime);
}
当这段代码运行时,它将为对象调用Update()
函数的每个单独的实现。另一方面,考虑我们有以下代码:
//Somewhere in game or level manager
void UpdateAI ()
{
enemy.RunAIScripts();
boss.RunAIScripts ();
}
这里我们没有覆盖RunAIScripts()
函数,因为它没有继承函数的原始类实现。虽然这是一个非常基本的例子,但它确实展示了单一继承的能力,这将我带到我的下一个主题——多重继承。
假设我们继续前面的例子,我们决定要增加一个新的敌人类型,一个会飞的 boss。我们有一个Boss
类,Enemy
类,甚至还有一个从Enemy
类继承的FlyingEnemy
类,看起来是这样的:
class FlyingEnemy : public Enemy
{
public:
void Update(double deltaTime);
void FlightAI();
//many more functions...
}
问题是我们想要FlyingEnemy
的功能,但是我们也想要Boss
的一些功能。同样,我们可以将我们想要的代码块复制到一个新的类中,但是 C++ 为我们提供了一个更好的解决方案,多重继承。顾名思义,多重继承允许我们从多个来源派生我们的类。然后,我们可以构建具有两个或更多父类的类,从而导致复杂的层次结构,但是正如我们将看到的,这也会导致一些有问题的情况。
继续我们的示例,我们的新FlyingBoss
类看起来如下所示:
class FlyingBoss : public Boss, public FlyingEnemy
{
public:
void Update(double deltaTime);
//other functions...
}
乍一看,这看起来是一个完美的类,我们已经从两个父类继承了我们需要的函数和变量。然而,在处理多重继承时,有几个问题会开始出现。首先是模棱两可的问题。当从继承的两个或多个类具有同名的函数或变量时,就会出现歧义。例如,在我们的例子中,如果我们没有覆盖Update()
函数,并且我们在对象上调用了Update()
,编译器将查看我们从实现中继承的类。因为它们都有一个同名的实现,所以编译器抛出一个编译器时间错误,抱怨调用中的模糊性。为了解决这个问题,我们必须在函数调用上使用前缀来标识我们想要使用的实现的类。为此,我们在代码中使用范围运算符(::
)从FlyingEnemy
类调用实现,如下所示:
FlyingEnemy::Update(deltaTime);
第二个问题可能不那么明显;在我们的例子中,它必须处理类继承树的构造方式。表面上看,一切都很好;FlyingBoss
类继承自Boss
类和FlyingEnemy
类。问题出在继承树上的一个台阶上,Boss
和FlyingEnemy
类都是从Enemy
类继承而来的。这在职业等级中创造了可怕的死亡钻石模式。这看起来没什么大不了的,但是这种模式会导致一些不幸的问题。首先是模棱两可的问题。每次尝试从FlyingBoss
类访问Enemy
类的任何成员变量或函数时,都是模棱两可的。这是因为每个变量和函数都有多个路径。为了解决这个问题,我们可以通过再次使用范围运算符(::
)来指定我们想要遵循的路径。死亡钻石模式引发的另一个问题是复制问题。当我们创建一个FlyingBoss
对象时,它将拥有从Boss
类继承的所有内容的两个副本。这是因为FlyingEnemy
和Boss
类都有它们继承的Enemy
类的副本。如你所见,这很混乱,会导致各种头痛。幸运的是,C++ 为我们提供了一个解决方案,虚拟继承的概念。通过虚拟继承,我们可以确保父类只在任何子类中出现一次。为了实现虚拟继承,我们在声明要继承的类时只需使用virtual
关键字。在我们的示例中,类声明看起来像这样:
class Boss : public virtual Enemy
{
public:
//functions...
};
class FlyingEnemy : public virtual Enemy
{
public:
//functions...
}
class FlyingBoss : public Boss, public FlyingEnemy
{
public:
//other functions...
}
现在FlyingBoss
类只有一个通过继承获得的实例。
While this does solve the issue of the diamond of death and other possible hierarchy issues, these issues are usually a sign of the underlying design issues. I would suggest researching all other options before automatically jumping to virtual inheritance as a solution.
最后,我想快速提到两个重要的主题,它们携手合作,使继承成为不可思议的工具,即多态性和虚函数。简单来说,多态性是一种使用一个类的对象的能力,就像它是另一个类的一部分一样。为了简单起见,让我们检查以下内容:
FlyingBoss* FlyBoss = new FlyingBoss();
这一行代码创建了一个指向新的FlyingBoss
对象的指针,这里没有什么新内容。但是,我们也可以像这样创建一个新指针:
Boss* FlyBoss = new FlyingBoss();
这得益于继承和多态性。我们能够引用FlyBoss
对象,就像它是一个Boss
类对象一样。现在看起来可能很简单,但是随着你对 C++ 理解的进步,你会开始看到这个概念有多强大。这也将我们引向最后一个话题,我想谈谈继承,虚函数。既然我们可以像这样创建指向对象的指针,那么如果我们在FlyingBoss
对象的Boss*
上调用Update()
函数会发生什么?这就是虚函数的作用。如果某个功能标有virtual
关键字,如是:
virtual void Update(double deltaTime);
这告诉编译器使用调用函数的对象类型来确定在这种情况下应该使用哪个实现。所以在我们的例子中,如果我们在FlyingBoss
实现中使用一个虚函数,当从FlyingBoss
对象的Boss*
调用时,它将使用该实现。
C++ 中最被误解和恐惧的概念之一是指针和引用的概念。这通常是新开发人员不愿继续学习 C++ 的原因。已经写了很多书和教程,试图揭开这个话题的神秘面纱,老实说,我可以很容易地写一章,甚至是一本关于指针和引用的来龙去脉的单独的书。到目前为止,我希望你已经接受了经典意义上的指针和引用的主题,并对它们的力量和灵活性建立了健康的欣赏。因此,在这一节中,我们不打算讨论核心原则,而是看一下旧的或经典的指针和引用的更重要的用途,并简单介绍一下新的指针,这些指针旨在帮助消除一些神秘主义和内存管理问题。
我们将从经典的指针和引用开始。虽然您会很快看到使用新指针的好处,但我仍然相信,正如许多 C++ 游戏开发人员一样,旧版本仍然有它们的位置。其中一个地方是在处理向函数传递数据时。当调用一个函数时,通常很容易写出如下内容:
void MyFunction(GameObject myObj)
{
//do some object stuff
}
虽然这段代码是完全合法的,但是如果对象的大小超过了可以忽略的程度,它可能会带来严重的性能问题。像这样传递对象时,编译器会自动在内存中创建对象的副本。在大多数情况下,这不是我们想要的。为了防止编译器在内存中创建副本,我们可以使用经典的指针或引用来传递对象。之前的代码看起来像这样:
void MyFunction (GameObject& myObj)
{
//do some object stuff
}
或者,如下所示:
void MyFunction (GameObject* myObj)
{
//do some object stuff
}
现在对象没有被复制到内存中,允许我们通过解引用来操作实际的对象。这是经典指针和引用的一种更常见的继续使用。经典指针和引用的另一个常见用途是在处理字符串和移动对象时。这种类型的应用在许多游戏开发库中仍然可见。因此,您应该对看到如下代码感到满意:
const char* pixelShader;
随着向现代 C++ 和 C++ 11 标准的转变,出现了一组新的托管指针,有助于简化对指针的理解和使用。除了一个关键的区别,这些新指针与经典指针非常相似;他们是被管理的。归根结底,这些新指针将处理自己的内存分配和删除。因为经典指针的主要问题之一是必要的手动内存和所有权问题,比如谁将删除它,以及何时删除,这使得指针的使用更受欢迎,也更灵活。这些托管指针(unique_ptr
和shared_ptr
)常用于更现代的游戏开发库中。
unique_ptr
或唯一指针被认为是智能指针。之所以称之为唯一,是因为这种类型的对象拥有其指针的唯一所有权。这意味着没有两个unique_ptr
指针可以管理同一个对象,它是唯一的。unique_ptr
最大的优势之一是它管理自己的生命。这意味着当指针超出范围时,它会自动销毁自己并释放内存。这解决了可怕的悬空指针问题,避免了内存泄漏。这也消除了所有权的问题,因为现在谁删除指针是显式的。
自从 C++ 14 标准以来,我们现在可以使用一个方便的小函数来创建一个唯一的指针,make_unique
。make_unique
函数创建一个类型为T
的对象,然后将其包装在一个唯一的指针中。用make_unique
创建unique_ptr
指针的语法如下:
std::unique_ptr<T> p = new std::make_unique<T>();
创建后,您可以像使用经典指针一样使用指针。取消引用操作符*
和->
的工作方式与正常情况相同。同样,这里最大的区别是,当指针超出范围时,它会被自动销毁,这样我们就不必手动跟踪每个退出点来避免任何内存泄漏问题。
shared_ptr
或共享指针很像唯一指针。它被认为是一个智能指针,它自动处理内存的删除和释放。不同的是共享指针共享对象的所有权。这意味着,与唯一指针不同,共享指针可以是指向单个对象的许多共享指针之一。这意味着,如果共享指针超出范围或通过reset()
或=
操作符指向另一个对象,该对象仍然存在。只有当拥有该对象的所有shared_ptr
对象被销毁、超出范围或被重新分配给另一个指针时,该对象才会被销毁并释放其内存。
同样,像唯一指针一样,共享指针也有一个方便的创建功能。make_shared
函数创建一个类型为T
的对象,然后将其包装在一个共享指针中。使用make_shared
函数创建shared_ptr
函数的语法如下:
std::shared_ptr<T> p = new std::make_shared<T>();
此外,与唯一指针一样,共享指针具有典型的取消引用操作符*
和->
。
const
正确性的话题在 C++ 社区可能是一个有争议的话题。我的第一门 C++ 课程的讲师甚至说const
关键词是这门语言中最重要的关键词之一。当然,我也听到了另一面,开发者告诉我他们怎么从来不使用const
,这完全是在浪费击键。我喜欢认为我在const
上落在中间的某个地方;我相信它有重要的用途,但它可以像任何其他功能一样被过度使用。在这一节中,我想展示一下const
的一些更好的用法。
快速回顾一下,const
关键字被用作类型限定符,让编译器知道这个值或对象不能改变,它是常量。当第一次开始 C++ 游戏开发时,你对const
的第一次接触可能会来得很早。最常见的是,使用常量的介绍是在定义我们想要容易获得的重要值,比如说:
const int MAX_BULLETS = 100;
这就给了我们一个命名值,我们可以在代码的其他部分轻松地多次使用它。这样做的最大好处是,如果我们决定改变这个值,在这种情况下,改变项目符号的最大数量,我们就可以改变这个常量值,而不必改变分散在整个代码库中的大量硬编码值。
随着你对 C++ 开发的深入,const
关键字将成为一个更熟悉的网站。它在库和引擎代码中以多种方式大量使用。它也用于函数参数的定义或用作函数定义的修饰符。让我们简单地看一下这些。
首先,当在参数定义中使用时,它成为一种保险,我们给它赋值的函数不会以任何方式修改它。以下面的代码为例:
void ObjFunction(GameObject &myObject)
{
//do stuff
If(*myObject.value == 0)
{
//run some logic
Game.changeState(newState);
//possible unknown modifier function
*myObject.value = 1;
}
}
好的,这是一个非常简单的例子,但是如果你确实调用了这样一个函数,而没有意识到它可以修改对象,你将会得到你可能没有预料到的结果。const
关键字有两种方法可以帮助解决这个可能的问题。一种是在传递值时使用const
关键字:
void ObjFunction(const GameObject &myObject)
{
//do stuff
If(*myObject.value == 0)
{
//run some logic
Game.ChangeState(newState);
//possible unknown modifier function
*myObject.value = 1; //now will throw a compile error
}
}
这使得现在不可能在函数的任何地方修改传入的值,保持它不变。
另一种方法是创建const
安全的函数。当你定义一个函数为const
函数时,它允许const
对象调用它。默认情况下,const
对象不能调用非const
函数。但是,非const
对象仍然可以调用const
函数。要将函数定义为const
函数,我们可以添加const
关键字来修改函数定义本身。您只需在函数签名的末尾添加const
,如下所示:
void ObjFunction(const GameObject &myObject) const
{
//do stuff
If(*myObject.value == 0)
{
//run some logic
Game.ChangeState(newState);
//possible unknown modifier function
*myObject.value = 1; //now will throw a compile error
}
}
这是我编写任何不会修改任何对象或值的函数的首选方法。它允许一定的灵活性,以确保将来可以从const
对象调用它,并且它还允许在代码中使用该函数的其他开发人员轻松识别该函数不会修改任何与其结合使用的对象或值。
C++ 中内存管理的想法,往往是初学者噩梦的话题。我经常听到开发人员发表类似的声明,我不使用 C++ 是因为它的手动内存管理。事实是,手动内存管理在绝大多数项目中非常少见。如今,随着托管智能指针等现代概念的出现,手工构建的内存管理系统对于日常开发来说已经不重要了。只有当你开始高性能计算,比如游戏开发,控制内存分配和释放的想法才会成为一个问题。说到游戏开发,游戏机的整体内存可用性和速度仍然是开发人员关注的问题,这对于大多数移动设备来说也是如此,尽管价格合理的高内存设备在快速增长。在下一节中,我们将回顾堆栈和堆,以及如何处理内存分配的差异。这将为下一章奠定基础,在这一章中,我们将看到一个自定义内存管理器系统的示例。
让我们从堆栈开始,适当命名的,记忆结构,你可以认为它很像一堆盘子或盘子。当您在堆栈上创建对象或变量时,它会被放在堆栈的顶部。当对象或变量超出范围时,这类似于从堆栈中移除盘子或碟子。堆栈上的分配在代码中看起来像这样:
int number = 10;
Player plr = Player();
第一行创建一个整数值,并赋予其10
的值。存储整数所需的内存在堆栈上分配。第二行有完全相同的想法,只是换成了一个Player
对象。
使用堆栈的一个好处是,当对象或变量超出范围时,我们分配的任何内存都会被清理掉。然而,这可能是一把双刃剑;许多较新的开发人员在超出范围后会遇到寻找或调用对象的问题,因为他们使用堆栈来存储对象。堆栈的另一个问题是它的大小有限,这取决于平台和编译器设置。如果您有大量的对象被创建并长时间保存,这可能会成为一个问题。试图分配超出堆栈可用内存的内存将引发运行时错误。
另一种选择是堆,你可以认为它是一个大的内存块或容器。与堆栈不同,内存堆是无序的,很容易变得碎片化。好消息是现代内存,操作系统实现提供了处理这种碎片化的低级机制,通常称为内存虚拟化。这种虚拟化的另一个好处是,它通过在需要时将内存交换到硬盘驱动器,提供了比物理内存更多的堆存储访问。要分配和销毁堆上的内存,可以使用关键字new
和delete
,对象容器使用new[]
和delete[]
。代码如下所示:
Player* plr = new Player();
char* name = new char[10];
delete plr;
delete[] name;
前两行在堆上创建了一个Player
对象和一个字符数组。接下来的两行分别删除这些对象。重要的是要记住,对于您在堆上创建的每个内存块,您必须调用 delete 来销毁或释放该内存块。如果不这样做,可能会导致内存泄漏,应用会继续消耗越来越多的内存,直到设备耗尽并崩溃。这是一个常见的问题,很难跟踪和调试。内存泄漏是新开发人员倾向于认为 C++ 内存管理困难的原因之一。
那么应该使用什么,堆栈还是堆?嗯,这真的取决于实现和存储的对象或值。我推荐的一个好的经验法则是,如果你可以不使用堆栈进行分配,那应该是你的默认值。如果您发现自己需要使用堆,尝试使用管理器系统来处理创建和删除。这将减少内存泄漏的机会以及处理自己的内存管理时出现的其他问题。在下一章中,我们将研究如何构建自己的内存管理器,作为核心库的一部分。
我希望我能说我第一次写的每一行代码都完美无缺。现实是我是人,容易犯错。处理这些错误和追踪 bug 可能是花费大部分开发时间的地方。有一个好的方法来捕捉和处理这些在游戏运行期间发生的任何其他问题是至关重要的。本节介绍一些用于查找和处理错误的 C++ 技术。
遇到问题时可以使用的一种技术是优雅地让程序崩溃。这意味着我们告诉计算机停止执行我们的代码并立即退出,而不是让计算机自行崩溃。要在 C++ 中做到这一点,我们可以使用assert()
方法。一个示例类似于下面的代码:
#include <assert.h>
...
void MyFunction(int number)
{
...
assert(number != NULL);
...
}
当计算机命中代码行assert(number != NULL);
时,它检查整数是否为NULL
,这是否评估为真,在这种情况下,它将导致断言失败,立即停止执行并退出程序。这至少让我们有了一些控制。我们可以利用assert()
功能提供的机会获取更多信息,创建一份事故报告。我们可以打印出文件、行,甚至错误的描述作为自定义消息。虽然这确实有效,但还有很多地方需要改进。
另一种可以提供更多灵活性的处理错误的技术是异常。例外是这样工作的;当程序遇到问题时,它会抛出一个异常来停止执行。然后程序寻找最近的异常处理块。如果它在引发异常的函数中找不到该块,它就会在父函数中寻找处理块。这个过程展开堆栈,这意味着在堆栈上创建的所有对象都将按照它们被传入的顺序被销毁。该过程将继续,直到程序找到一个处理块或到达堆栈顶部,此时将调用默认异常处理程序,程序将退出。总的来说,C++ 中用于处理异常的语法非常简单。要抛出异常,可以使用关键字throw
。这将触发程序寻找一个处理块,用关键字Catch
表示。Catch
块必须位于Try
块之后,T3 块封装了可能引发异常的代码。一个简单的例子是:
Void ErroringFunction()
{
...// do something that causes error
throw;
}
Void MyFunction()
{
...
Try //the try block
{
...
ErroringFunction();
...
}
Catch(...)//catch *all exceptions block
{
... //handle the exception
}
}
还可以通过将异常类型作为参数传递给 catch 块来捕获和处理特定的错误,如以下代码所示:
...
Throw MyExeception("Error! Occurred in Myfunction()");
...
Catch(MyException e)
{
...//handle exception
}
使用异常的好处是,我们可以灵活地以任何我们想要的方式处理错误。如果情况允许,我们可以纠正导致错误的问题并继续,或者我们可以简单地将一些信息转储到日志文件并退出程序。选择权在我们。
您实现哪种解决方案来处理错误完全取决于您和您正在处理的项目。事实上,有些开发人员会选择忽略一起处理错误。然而,我强烈建议使用某种错误处理系统。在整本书用于演示的示例代码中,我实现了一个异常处理系统。我建议将该实现作为参考。本章末尾的建议阅读部分也包含了一些处理错误的重要参考。
C++ 是一种强类型的不安全语言。它提供了令人难以置信的控制,但它最终期望程序员知道他们在做什么。了解如何在高级水平上处理类型对于掌握游戏库和核心系统编程至关重要。游戏开发在很大程度上依赖于 C++ 中类型的灵活性,它也依赖于可用的高级库,如标准模板库 ( STL )。在接下来的几节中,我们将看看游戏开发中使用的一些更常见的容器及其 STL 实现。我们还将介绍如何通过使用模板来创建通用代码。最后,我们将通过类型推断及其更常见的用例来总结类型和容器的主题。
C++ STL 是容器类的集合,允许以不同的结构存储数据,迭代器提供对容器元素的访问,算法可以对容器及其包含的元素执行操作。这些结构、迭代器和算法都经过了极大的优化,并且在大多数情况下都使用了 C++ 语言标准的最新实现。STL 广泛使用了 C++ 中的模板特性,以便于我们自己的类型使用。我们将在下一节中研究模板化。STL 是一个巨大的主题,有许多关于概念和实现的书籍。如果你对 STL 没有什么经验,我强烈建议你读一些关于这个主题的令人惊叹的书。我在本章末尾的总结部分列举了几个。这一部分将着重强调一些在游戏开发中更常用的 STL 容器。我将假设您对容器有一个基本的了解,并且您有一些使用迭代器遍历容器中的元素的经验。
让我们从两个序列容器开始,向量和列表。它们之所以被称为序列容器是因为它们以特定的顺序存储它们的元素。这允许在该顺序或序列的任何地方添加或移除元素。向量和列表是你会遇到的最流行的 STL 序列容器。了解一些关键事实将帮助你决定哪一个最适合特定的任务。我已经包括了一些建议来帮助指导你。
Vector 是 STL 中提供的最基本的容器之一。虽然它相对简单,但它非常灵活,是游戏开发中使用最广泛的容器之一。你最有可能看到它的地方是在替换一个 C 数组。使用数组的一个更大的缺点是必须在声明时定义数组的大小。这意味着,在大多数情况下,您需要知道所需元素的最大数量,或者您需要分配比您以往需要的更多的元素。幸运的是,我们矢量没有这个,预定义的大小,缺点;向量将增长以适应新添加的元素。要创建整数向量,我们可以使用以下语法:
std::vector<int> playerID ;
您可能在vector
之前注意到了std::
,这是因为vector
类是std
名称空间的一部分,所以我们需要确定我们希望使用该实现。查看本章前面的使用名称空间部分。我们可以通过在代码文件的开头添加一个using namespace std;
语句来避免必须键入这个。我更喜欢将std::
添加到我的标准库调用中,或者任何其他特定的名称空间调用中。由于游戏开发使用如此多的库,有大量的using
语句会变得混乱和容易出错。虽然它需要额外的几次击键,但它可以省去一大堆麻烦。
我个人在大多数情况下用向量代替数组,我建议你也这样做。但是,在将所有数组都改为向量之前,一定要注意向量中可能导致问题的一个方面。创建向量时,会为其分配一个连续的内存块。内存量取决于向量中元素的数量。总会有空间给当前向量中的所有元素加上一点额外的空间,以允许添加新的元素。这就是向量的诀窍,当你添加更多的元素,并最终开始耗尽空间时,向量会抓取更多的内存,以便它总是有空间容纳新的元素。它首先创建一个新的内存块,复制第一个内存块的所有内容,然后删除它。这就是问题可能悄悄出现的地方。为了防止不断的分配、复制和删除,当一个向量分配新的内存时,它的大小通常是以前的两倍。由于向量永远不会收缩,如果我们使用向量的方式会产生大量的元素加减,这很容易成为内存问题,尤其是对于内存较低的设备。知道这一点不应该阻止您使用向量,当在正确的情况下实现时,这应该很少成为问题,并且如果出现问题,可以通过重构轻松缓解。
一些关于何时使用向量的完美例子是这样的情况:玩家列表、角色动画列表、玩家武器,实际上是任何你可能不经常添加和删除的列表。这将避免可能的内存问题,同时让你访问向量的迭代器、算法和其他好处。
A 列表是你在用 C++ 开发游戏时可能会看到的另一种序列容器。要创建整数值的列表容器,语法如下所示:
std::list<int> objValues;
列表容器在实现和开发中的一般用法上与向量有很大的不同。关键区别在于,与向量不同,列表容器不会将其所有元素存储在一个大的连续内存块中。相反,它将其元素存储为双向链表中的节点。其中每个节点都持有指向下一个和上一个节点的指针。当然,这使得向量的额外内存分配问题消失,因为只有列表中每个元素的内存是预先分配的。当添加新元素时,只为新节点创建内存,从而节省了在向量实现中可能看到的浪费内存。这也允许元素被添加到列表中的任何地方,与向量容器相比具有更好的性能。不过,也有一些缺点。由于内存中各个节点的这种设置,列表中的每个操作都很可能导致内存分配。由于每个节点可能以无保证的顺序分散在内存中,这种持续的内存分配在动态内存较慢的系统中可能是一个潜在的问题。这也意味着列表遍历其元素的速度比向量慢。同样,这并不意味着阻止你在项目中使用列表。我建议你在有一组经常添加或删除的对象或元素的地方使用列表。一个很好的例子是在每一帧中呈现一个游戏对象或网格的列表。不应将列表视为向量的替代。各有利弊,找到解决方案的最佳选择往往是最难的部分。
最后,我们要看的最后一个容器是一个常用的关联容器。与序列容器不同,关联容器不会保留其中元素的相对位置。相反,关联容器是为了速度而构建的,更具体地说,是为了元素查找速度。在不进入大 O 符号的情况下,这些关联容器及其相应的算法在查找特定元素时远远优于向量和列表。之所以称它们为关联容器,是因为它们通常提供一个密钥/数据对,有助于更快的查找。需要注意的是,有时候容器中的关键是数据本身。我们将在这里关注的是地图容器。
地图是游戏开发中多种用途的便捷容器。与矢量或列表相比,地图的独特之处在于,每个地图都由两个数据组成。第一段数据是一个关键字,第二段是实际存储的元素。这就是为什么地图在查找元素时如此高效的原因。一个简单的想法是,映射就像数组,但是不是使用整数值来索引元素,而是使用任何类型的键来索引它的元素。地图甚至有一个专门的[]
操作符,允许您使用熟悉的数组语法访问元素。
要创建一个以整数为键,以字符串为元素类型或值的映射,我们的代码如下所示:
std::map<int,string> gameObjects;
当涉及到内存使用时,映射不同于列表和向量容器。地图不会像矢量一样将数据存储在连续的块中,而是将元素保存在节点中,就像列表一样。列表和映射如何处理它们的分配的区别在于节点的结构方式。地图中的节点有指向下一个节点和上一个节点的指针,就像列表一样,但是这些节点是以树形模式排列的。这种树模式通过添加和删除节点来自动平衡自己。好消息是,这种平衡行为不会增加任何新的分配。映射的性能与列表非常相似,因为内存管理是相似的,唯一的区别是节点树的自动平衡开销非常小。
地图常用的一种方式是字典的形式。它们通过键提供了对唯一值的快速查找;正因为如此,游戏开发中的一些好的示例地图是:具有唯一 id 的游戏元素列表,具有唯一 id 的键的多人客户端列表,以及几乎任何情况下,您有一组元素想要与某种键值对一起存储。
模板在 C++ 语言中是一个较新的概念。当使用不同的数据类型或类时,模板有助于解决必须重写相同代码的常见问题。这允许我们编写所谓的通用代码。然后,我们可以在项目的其他部分使用这个通用代码。从 C++ 14 标准开始,现在有三种类型的模板可以使用:类模板、函数模板和变量模板。让我们在接下来的部分中仔细看看它们。
使用类模板,我们可以创建可以定义的抽象类,而无需指定类的函数将处理什么数据类型。这在构建库和容器时变得非常有用。事实上,C++ 标准库广泛使用了类模板,包括我们在本章前面看到的vector
类。让我们看一下一个Rectangle
类的简单实现。这可能是一个有用的类,用于查找屏幕坐标、按钮和其他图形用户界面,甚至简单的 2D 碰撞检测。
不使用类模板的基本实现如下所示:
class Rectangle
{
public:
Rectangle(int topLeft, int topRight, int bottomLeft,
int bottomRight) :
m_topLeft (topLeft), m_topRight(topRight),
m_bottomLeft(bottomLeft), m_bottomRight(bottomRight){}
int GetWidth() { return m_topRight - m_topLeft; }
private:
int m_topLeft;
int m_topRight;
int m_bottomLeft;
int m_bottomRight;
};
这在大多数情况下都很好,但是如果我们想在不同的坐标系中使用这个矩形,比如使用 0.0 到 1.0 的值,我们将不得不做一些改变。我们可以复制代码,并将整数数据类型更改为 float,这样就可以了,但是使用类模板我们可以避免这种代码重复。
使用模板,新的Rectangle
类看起来像这样:
template <class T>
class Rectangle
{
public:
Rectangle(T topLeft, T topRight, T bottomLeft,
T bottomRight) :
m_topLeft(topLeft), m_topRight (topRight),
m_bottomLeft(bottomLeft), m_bottomRight(bottomRight){}
T GetWidth() { return m_topRight - m_topLeft; }
T GetHeight() { return m_bottomLeft - m_topLeft;}
private:
T m_topLeft;
T m_topRight;
T m_bottomLeft;
T m_bottomRight;
};
您将注意到的第一个变化是在我们的类定义之前包含了template<class T>
。这告诉编译器这个类是一个模板。T
是数据类型的占位符。第二个变化是所有的整数数据类型都被这个占位符替换了。现在我们可以使用int
数据类型创建一个矩形,如下所示:
Rectangle(10,20,1,2);
当编译器遇到这一行代码时,它会遍历模板类并用int
替换占位符的所有实例,然后动态编译新类。要使用浮点值创建矩形,我们可以使用以下代码:
Rectangle (1,1,0.5,0.5);
我们可以对任何我们喜欢的数据类型这样做;唯一的限制是类的操作必须支持这些类型。如果不是,将引发运行时错误。这方面的一个例子是一个类模板,它具有乘法函数,并试图将该模板用于字符串。
函数模板的概念与类模板非常相似;最大的区别是函数模板不需要显式实例化。它们基于传入的数据类型自动创建。下面将交换两个值,但它不特定于任何类类型:
template<class T>
void Swap (T &a, T &b)
{
T temp = a;
a = b;
b = temp;
}
然后,您可以传递整数值:
Swap(23,42);
or float values;
Swap(12.5, 5.2);
事实上,您可以将此函数用于任何支持赋值运算符和复制构造函数的类型。这里的限制是两种数据类型必须是相同的类型。即使数据类型有隐式转换,也是如此。
Swap(1.8, 22); // Results in a compile time error
最后一类我想快速提一下的模板是变量模板,不要和变量模板混淆。在 C++ 14 中引入的变量模板允许在模板化的结构或类中包装变量。常用的圆锥形例子是圆周率的数学构造:
template<class T>
constexpr T pi = T(3.1415926535897932385);
这意味着您可以将pi
称为float
、int
或double
变量,并在通用函数中使用它,例如,计算给定半径的圆的面积:
template<typename T>
T area_of_circle_with_radius(T r)
{
return pi<T> * r * r;
}
同样,这个模板化的函数可以用于各种数据类型,因此您可以将一个区域返回为整数、浮点值或任何其他支持的数据类型。你可能看不到经常使用的变量模板。在 C++ 中,它们仍然被认为是一个新的想法,但是意识到它们的存在是很重要的。他们确实有一些独特的案例,可能有一天会帮助你解决一个难题。
如您所见,模板确实有其好处,我鼓励您在有意义的地方使用它们。然而,在实现模板时,注意一些可能的缺点是很重要的。第一个潜在的缺点是所有的模板必须在同一个文件中有它们的整个实现,通常是头。export
关键字纠正了这一点,但并非所有商业编译器都支持。模板的另一个缺点是它们因难以调试而臭名昭著。当问题存在于模板化代码内部时,编译器往往会给出隐藏的错误。我最大的建议是谨慎使用它们,就像其他功能一样。仅仅因为一个特性是高级的,并不意味着它是一个很好的匹配。最后,检查您的编译器,了解实现的确切细节。
C++ 11 标准带来了一些非常有用的型干扰功能。这些新功能为程序员提供了更多工具来创建通用、灵活的代码。在本节中,我们将更深入地了解这些新功能。
我们将从一个新的、强大的关键词开始。auto
关键字允许您让编译器在声明时推断变量类型(如果可能)。这意味着与其这样定义变量:
int value = 10;
现在可以直接使用auto
:
auto value = 10;
然而,这并不是auto
关键词的最佳用法,事实上,这是一个你不应该做的完美例子。虽然在声明任何变量时使用auto
可能很有诱惑力,但这不仅会给编译增加完全不必要的开销,还会使您的代码更难阅读和理解。那是你不应该用auto
做的,那你应该用auto
做什么?auto
真正显示其有用性的地方,是它与模板配合使用的时候。当与auto
关键字结合时,模板可以变得极其灵活和强大。让我们看一个简单的例子。
在这个例子中,我们有一个简单的模板函数,它为我们创建了一些游戏对象,如下所示:
template <typename ObjectType, typename ObjectFactory>
void CreateObject (const ObjectFactory &objFactory)
{
ObjectType obj = objFactory.makeObject();
// do stuff with obj
}
要调用此代码,我们将使用以下代码:
MyObjFactory objFactory;
CreateObject<PreDefinedObj>(objFactory);
这段代码运行良好,但是使用auto
关键字可以更加灵活和容易阅读。我们的代码现在看起来如下所示:
template <typename ObjectFactory >
void CreateObject (const ObjectFactory &objFactory)
{
auto obj = objFactory.MakeObject();
// do stuff with obj
}
然后我们调用这个函数的代码将是:
MyObjFactory objFactory;
CreateObject (objFactory);
虽然这是一个过于简单的说法,但它应该让你看到auto
可以提供的可能性。通过不定义对象工厂将返回什么类型,我们允许工厂在其实现中有更多的自由,这反过来允许工厂在我们的代码库中有更多的使用。
在模板之外,您会看到auto
关键字在起作用的地方之一是 for 循环中迭代器的声明。这已经成为许多现代库的普遍做法。你会经常看到这样写的循环:
for (auto it = v.begin(); it != v.end(); ++ it)
{
//do stuff
}
auto
关键字有一个辅助关键字decltype
,它从一个变量中提取类型。所以在使用auto
让编译器推断变量类型的地方,使用decltype
来确定变量的类型。当您在最后一部分添加auto
关键词功能时,这变得非常有用,作为return
值。在 C++ 11 和auto
关键字之前,return
值必须在函数名之前声明,如下所示:
TreeObject CreateObject (const ObjectFactory &objFactory)
{
auto obj = objFactory.MakeObject();
return obj;
}
这意味着CreateObject
函数必须返回一个TreeObject
类型,但是如前所述,让编译器推断objFactory.MakeObject();
返回什么允许更大的灵活性。为了推断函数返回的对象类型,我们可以使用auto
、decltype
的概念以及新的return
语法。我们的新功能现在将如下所示:
template <typename ObjectFactory >
auto CreateObject(const ObjectFactory &objFactory) -> decltype (objFactory.makeObject())
{
auto obj = objFactory.MakeObject();
return obj;
}
还要注意的是auto
和decltype
确实给我们的编译时间增加了一些开销。在大多数情况下,这将是微不足道的,但在某些情况下,这可能会成为一个问题,所以当将这些新的关键词合并到您的代码库中时,请注意这一点。
随着您继续构建更多的库和工具集,拥有构建更通用、更灵活的代码的能力将变得至关重要。使用auto
、decltype
和新的return
语法等技巧只是实现这一目的的一些方法。在接下来的章节中,我们将看到更多这些有用的概念。
简单地说,编程模式或开发模式是对一个常见或反复出现的问题的解决方案。它是一个描述或模板,提供了一个可以在许多不同情况下使用的解决方案。这些模式是形式化的最佳实践,通常是通过多年的迭代开发出来的。通过在您的项目中使用模式,您可以使您的代码更具性能、更强大、适应性更强。它们允许您构建本质上解耦的结构化代码。这种解耦使您的代码更通用、更易于使用。你不再需要把整个程序塞进你的脑子里来理解特定的代码段想要完成什么。相反,您可以专注于独立运行的较小块。这就是面向对象设计的真正力量。这种解耦还将通过将一个或多个问题隔离到特定的代码段,使得在测试期间跟踪错误变得更加容易。
当您开始构建自己的库和引擎结构时,至少对最基本的模式有一个坚实的理解是至关重要的。在接下来的几节中,我们将研究其中的一些基本模式。
可以说,游戏开发中最重要的概念之一就是循环的概念。如果你以前做过游戏,我几乎可以保证你用过某种循环。尽管循环很常见,但循环的特定实现通常并不常见。模式为开发人员提供了构建高性能、灵活循环的指导方针和结构。
最常见的循环模式之一是游戏循环模式。游戏循环模式的目的是提供一种机制,将游戏时间的流逝与用户输入和其他事件分离,而不考虑处理器的时钟速度。一个简单的解释是:在游戏运行期间,或者在特定状态期间,一个游戏循环连续运行,参见后面的状态机部分。在这个连续的循环中,循环的每一次滴答声或转弯处,我们都有机会更新游戏的部分内容。这通常包括更新当前游戏状态,检查和更新任何用户输入,而不阻塞,以及调用来绘制或渲染任何游戏对象。许多平台和几乎所有引擎都有自己的实现。需要注意的是,你使用的平台或引擎是否有自己的游戏循环。如果是这样,那么您必须将代码和循环结构挂钩到提供的机制中。
举个例子,Unity 游戏引擎抽象了循环过程,它们通过所有游戏对象继承的Update()
函数向内部游戏循环公开连接性。这种 Unity 结构是一个很好的例子,说明了游戏循环模式如何与更新模式等其他模式相结合,构建一个级联循环系统,允许主游戏循环驱动每个对象的内部循环机制。我们现在不会建立一个完整的例子,但是当我们继续阅读这本书时,我们会看到更多这样的结构是如何建立的。接下来的几节将继续这种结合模式来构建一个完整的游戏系统流程的想法。
为了帮助描述游戏循环是如何构建的,让我们看一个典型的、稍微简单的例子:
double lastTime = getSystemTime();
while (!gameOver)
{
double currentTime = getSystemTime ();
double deltaTime = currentTime - lastTime;
CheckInput();
Update(deltaTime);
Draw();
lastTime = currentTime;
}
第一行代码double lastTime = getSystemTime();
存储循环第一次运行之前的时间。接下来我们有一个简单的while
循环,在这种情况下,当变量gameOver
不为真时,循环将继续运行。在while
循环中,首先我们得到当前时间。接下来我们创建一个deltaTime
变量,这是自循环的最后一步以来经过的时间。然后我们调用运行游戏的其他组件:Input
、Update
和Draw
。这是游戏循环模式的关键;我们用这个标准的跑步循环来推动游戏前进。你可能会注意到我们将deltaTime
传递给了Update
方法。这是循环的另一个重要组成部分,在不深入更新模式的情况下,通过传递循环之间经过的时间,我们能够使用适当的时间片来修改像游戏对象物理这样的东西,这对于保持一切都在下沉并看起来平滑很重要。这种风格的游戏循环模式实现被称为可变时间步长模式,因为循环步长是基于更新所花费的时间量。更新代码花费的时间越长,步骤之间的时间就会越长。这意味着循环的每一步都将决定实际时间过去了多少。使用这种方法意味着游戏将在不同的硬件上以一致的速度运行,这也意味着拥有强大机器的用户将获得更流畅的游戏体验。然而,这个实现远非完美。它没有优化渲染或处理步骤之间可能出现的延迟,但这是一个好的开始。了解幕后发生的事情是重要的一步。在下一节中,我们将研究一种允许我们基于事件创建代码路径的模式,这与循环相结合是游戏系统流的自然进化。
我们要考察的下一个模式是状态模式;更具体地说,我们将研究有限状态机。状态机是一个极其强大的工程概念。虽然除了人工智能开发之外,有限状态机在大多数编程学科中并不是一种常见的模式,但它在构建分支代码中扮演着重要的角色。可能令人惊讶的是,我们日常生活中发现的许多机械逻辑电路都是由有限状态机的形式构建的。
一个真实的例子是一组交通灯,它根据等待的汽车改变状态(有时可能不够快)。有限状态机可以归结为一个抽象系统,在这个系统中,机器可以处于有限数量的状态中的一个,也是唯一一个。机器将保持这种状态,称为当前状态,直到事件或触发条件导致转换。让我们看一个演示这个概念的例子:
//simple enum to define our states
Enum GameState
{
Waiting,
Playing,
GameOver
}
GameState currentGameState = GameState.Waiting;
//Other game class functions...
void Update(double deltaTime)
{
//switch case that acts as our machine
switch(currentGameState)
{
case Waiting:
//do things while in waiting state
//Transition to the next state
currentGameState = Playing;
break;
case Playing:
//do things while in playing state
CheckInput();
UpdateObjects(deltaTime);
Draw();
//Transition to the next state
currentGameState = Gameover;
break;
case Gameover:
//do things while in waiting state
UploadHighScore();
ResetGame();
//Transition to the next state
currentGameState = Waiting;
break;
}
首先,我们有一个容纳游戏状态的enum
结构。接下来,我们创建一个GameState
变量类型来保存机器当前所处的游戏状态。然后在一个Update
循环中,我们实现一个switch case
构造,控制从一个状态到另一个状态的转换流程。这个实现的关键是机器的每个状态都有一个到下一个状态的转换。这使机器保持运行,并允许我们根据机器的当前状态执行不同的操作。虽然这可能是游戏状态机最基本的形式之一,但它确实证明了有限状态模式的有用性。随着您继续创建库和其他组件,您将开始看到这些不可思议的工具有越来越多的用途。还有许多其他更复杂的实现,甚至更多的模式来帮助描述它们。其中一些将在本书后面的章节中看到。
在游戏开发过程中,你经常会发现这样的情况:你需要基于一些用户输入或者从另一个代码块触发的条件来执行某些代码。也许你只是需要一个稳固的游戏对象交流方式。这就是使用事件或消息传递系统的想法。已经创建了许多模式来帮助解决这个问题,包括监督、模型视图控制器等。这些模式中的每一种都实现了不同的机制来处理事件;许多实际上是相互建立的。然而,在我们开始使用其中一种模式之前,我认为重要的是要了解幕后发生的事情的基础,以便为所有这些解决方案提供动力。通过构建我们自己的解决方案,我们将更好地理解问题,并对解决问题的模式有更多的欣赏。在我们的示例中,我们将使用本章中所学的概念来构建一个简单但可重用的事件系统,该系统可以在您自己的项目中使用。
我们可以采取的第一种方法是使用我们刚刚看到的状态机的简单版本。在这种方法中,我们使用switch case
构造根据传入的事件类型来分支代码。为了节省空间和时间,省略了一些基本的结构代码:
//Event could be an enum or struct that houses the different event types
void GameObject::HandleEvent(Event* event)
{
switch(event)
{
case Collision:
HandleCollision();
//Do other things...
break;
Case Explosion:
HandleExplosion()
//More things...
break;
}
}
这是一个快速而肮脏的实现,将在一些非常基本的情况下工作。如果我们对事件类型使用结构或联合,我们可以添加一些简单的消息功能,这将使它更加有用。不幸的是,这种方法最终有太多重大问题。首先,我们需要有单一的事件类型来源。然后,每当我们想要添加新的事件类型时,我们都必须编辑这个源。第二个是switch case
构造,同样,每次我们希望添加新的事件类型时,我们都必须追加和修改这个部分。所有这些都非常繁琐,容易出错,并且在支持 OOP 的语言中是糟糕的设计。
我们可以采取的第二种方法依赖于运行时类型信息 ( RTTI )的能力,这是在运行时确定变量类型的概念。使用 RTTI 使我们能够使用dynamic_cast
来确定解决方案中的事件类型。我应该指出,并非所有的 RTTI 实现都是相同的,并且可能不会在所有编译器中默认打开。请查看您的编译器文档以获取准确信息。
首先,我们为将要创建的所有特定事件创建一个简单的基类:
class Event
{
protected:
virtual ~event() {};
};
现在只需使用dynamic_cast
来确定事件的类型,并将消息信息传递给对象自己的处理功能:
void onEvent(Event* event)
{
if (Collision* collision = dynamic_cast<Collision*>(event))
{
onCollision(collision);
}
else if (Explosion* explosion = dynamic_cast< Explosion *>(event))
{
onExplosion(explosion);
}
//etc...
}
这是一个比我们看到的第一个更优雅的解决方案。它提供了更大的灵活性,并且更容易维护。然而,我们可以重构这段代码,使其更加简单。使用我们之前学习的模板概念,以及良好的旧方式重载,我们的新代码可以这样构建:
Template <class T>
bool TryHandleEvent(const Event* event)
{
If(cosnt T* event = dynamic_cast<const T*> (event))
{
Return HandleEvent(event);
}
Return false;
}
void OnEvent( const Event* event)
{
If(TryHandleEvent<Collision>(event)) return;
Else if(TryHandleEvent<Explosion>(event)) return;
}
像本章中的其他例子一样,这个例子是基本的。虽然这种新方法确实比第一种方法更干净、适应性更强,但它也有自己的一些缺点。这包括dynamic_cast
的开销,完全依赖于类层次结构。if...else
链仍然存在维护和易错代码的问题。此外,我们还有更大、更重要的不当类型检测问题。例如,使用这种方法,如果我们有一个从另一个继承的类型,比如说来自Explosion
类的LargeExplosion
类。如果对对象类型的查询出了问题,事件指针会首先被转换到Explosion
类,而实际上它指向的是LargeExplosion
类,编译器会错误地检测到类型并调用错误版本的函数。更理想的解决方案是拥有一个EventHandler
类来处理所有事件的注册、存储和多态功能。然后可以有成员函数处理程序来实现特定的事件类型,这些事件类型又可以从处理程序函数基类继承。这将解决我们在其他两种方法中看到的许多问题,同时给我们一个更通用的、可重用的实现。
不过,我们将在这里停止我们的实现。由于事件处理系统在游戏系统的许多不同部分发挥着如此强大的作用,从工具链到用户输入和网络,我们将在本书的其余部分看到更多这样的模式和技术。
这一章我们讲了很多。我们讨论了现代游戏开发中使用的一些更高级的 C++ 主题。我们研究了继承和多态、指针、引用以及常见的 STL 通用容器。用类、函数和变量模板模板化和构建通用代码的概念。类型推断和新的语言关键字auto
和decltype
以及它们与新的return
值语法的结合使用。最后,我们结束这一章,看看今天使用的一些核心游戏模式。
在下一章中,我们将研究如何使用这些关键概念来创建可以在我们的游戏开发项目中使用和重用的核心库。