第15章 快跑!
# 第15章 快跑!
欢迎来到最后一个项目:《快跑!》(Run!)。《快跑!》是一款无尽跑酷游戏,玩家的目标是领先不断从后方逼近、正在消失的平台。在这个项目中,我们将学习大量新的游戏编程技术,以及更多用于实现这些技术的C++知识。或许这款游戏相较于之前的游戏最大的改进在于,它比其他任何一款游戏都更具面向对象特性。与之前的项目相比,它会包含更多的类,不过这些类的大部分代码文件都简短且简单。此外,我们要打造一款游戏,其中所有游戏内对象的功能和外观都由各个类来实现,这样无论游戏对象如何运作,主游戏循环都无需改变。这一点非常强大,因为这意味着你只需设计新的独立组件(类)来描述所需游戏实体的行为和外观,就能制作出丰富多样的游戏。这也意味着你可以用相同的代码结构来设计一款完全不同的游戏。但远不止如此,继续阅读了解更多细节吧。
本章完整的代码可在“Run”文件夹中找到。以下是我们将在本章涵盖的内容:
- 详细描述游戏内容以及玩法。
- 用常规方式创建项目,并编写本书最简单的主函数!
- 讨论并编写一种新的处理玩家输入的方式,即把特定职责委托给各个游戏实体/对象,并让它们监听来自新的输入调度器类(InputDispatcher class)的消息。
- 编写一个名为“Factory”的类,它将负责“了解”如何把我们构建的所有不同组件组装成可用的游戏对象(GameObject)实例。
- 学习C++的继承(inheritance)和多态(polymorphism),其实并不像听起来那么难。
- 学习C++智能指针(smart pointers),以便将内存管理的职责交给编译器。
- 编写关键的游戏对象类;你可能不敢相信它有多简短和简单。
- 编写组件类(Component class),游戏对象实例将持有这个类。同样,这个类简短又简单。
- 编写图形类(Graphics class)和更新类(Update class),它们是组件类的类型。等我们学习了继承和多态,就会更明白其中的道理。
- 最后,在本章结尾,我们将实现一个可运行的游戏循环,它能监听玩家输入并绘制一个空白屏幕,为本书后续要编写的所有内容做好准备。
首先,我们需要清楚要构建的内容。同时,我会介绍我们即将学习的所有新游戏编程概念。
你可以在GitHub仓库中找到本章的源代码:https://github.com/PacktPublishing/Beginning-C-Game-Programming-Third-Edition/tree/main/Run
# 关于这款游戏
《快跑!》是一款非常简单的游戏。事实上,它所包含的游戏实体数量是我能想到的最少的,却仍可被视为一款可玩的游戏。我设计这款游戏的目的是展示一种可复用的游戏开发系统,而非引人入胜的玩法。这使得这个项目非常适合你添加新的行为、规则和玩法,进行自主设计。或者,更好的是,一旦你了解了它的运作方式,就可以利用这个系统设计一款属于自己的全新游戏,还能对这个系统进行改进并添加新功能。
我们要构建的系统是实体组件编程模式(entity component programming pattern)的一个版本。模式是一种做事的方法。等讨论完继承和多态后,我们会进一步探讨实体组件模式。现在,先来看看这款游戏。以下截图展示了构成我们游戏的大部分实体:
图15.1:游戏菜单
在上图中,你可以看到一个简单的游戏菜单。玩家可以按“Esc”键开始或暂停游戏,按“F1”键退出游戏。游戏开始后,屏幕左上角的计时器会启动,游戏目标是尽可能长时间存活,方法是向右奔跑,跟上不断在右侧生成的新平台,并远离左侧不断消失的平台。当消失的平台追上玩家时,游戏结束,菜单会再次出现。看看下一张图:
图15.2:游戏中的降雨效果
在上图中,你可以看到玩家角色位于屏幕中央。她的靴子似乎在喷火,这是玩家正在加速的效果。如果玩家从平台上掉落,可以按“W”键向上加速。此外,玩家在加速时,分别按“A”键和“D”键可以向左和向右移动。请注意,加速时水平移动速度较慢,消失的平台追上玩家的速度比正常奔跑和跳跃时快得多。奔跑也可以通过按“A”键和“D”键实现。需要注意的是,你几乎总是要用“D”键向右奔跑。空格键用于在平台之间跳跃。加速是在错过跳跃时的一种短期应急措施,并非通关策略。奔跑和跳跃速度快,有助于存活;加速速度慢,容易导致失败。
在上图中,你还可以看到玩家左侧有一个火球。火球会把玩家向下击飞,通常会使玩家掉到所在平台下方,迫使他们加速求生。火球在游戏中随机生成,可以从左侧或右侧出现。由于火球速度很快,玩家会收到两次火球来袭的预警。首先,当火球射向玩家时,会从右侧或左侧播放咆哮声,使用的是空间化/定向音效。其次,注意屏幕底部中央的小地图,它显示的游戏世界区域比主屏幕要大得多。玩家可以瞥一眼这个类似雷达的小地图,查看来袭火球是否在碰撞路径上,并在必要时提前采取躲避行动。
同样在上图中,你可以看到一个简单的降雨效果。看看下一张图,了解游戏的更多功能。如果你看的是平装版的黑白图片,可能不太清楚:
图15.3:游戏视差效果
在上图中,有一个类似夜间城市景观的背景。背景会随着玩家的移动左右滚动,但比前景中的平台移动速度慢。这会产生视差效果,给人一种城市在远处的感觉。
图15.4:游戏着色器效果
在上图中,我们看到背景外观发生了彻底变化。通过使用OpenGL着色器(shaders),我们可以实现近乎逼真的3D乡村滚动效果。令人惊讶的是,我们只需用几行C++代码就能添加这个效果,但着色器程序本身相当复杂,我们将从一个专门提供酷炫着色器的网站获取。我们将探索着色器的工作原理、使用方法以及它们是什么,但不会深入探讨如何自己编写着色器代码。
# 创建项目
我们需要创建一个新项目。首先,创建一个新项目,将其放在“VS Projects”文件夹中,命名为“Run”,然后把“fonts”(字体)、“graphics”(图形)、“music”(音乐)、“shaders”(着色器)、“sound”(音效)文件夹及其内容复制到项目文件夹中。我们会在后续过程中讨论这些文件夹的内容。与之前的项目相比,这些资源有一些显著差异:比如有着色器、音乐,还有“graphics”文件夹中只有一个图像文件,却包含了整个游戏的所有视觉元素。“shaders”文件夹中的文件是空白占位文件,后续会将一些公开可用的代码复制粘贴进去。
我为每一章都创建了一个可运行的项目,这样你可以参考每章结束时代码的样子。你会看到名为“Run”、“Run2”、“Run3”等的项目文件夹。因此,你可以在“Run”项目文件夹中查看本章完整的代码。
你无需为每一章都费力创建新项目!每一章的说明都是在上一章的基础上顺利推进的。
按照我们之前对所有项目的操作方式,配置项目属性。以下是简要的操作提醒。如需图片和更多详细信息,请参考第1章。现在,完成以下步骤:
- 我们现在要配置项目,使其使用我们放在“SFML”文件夹中的SFML文件。从主菜单中选择“项目”|“属性…”。此时,你应该会看到“Run属性页”窗口。
- 在“Run属性页”窗口中,执行以下操作。从“配置:”下拉菜单中选择“所有配置”,并确保右侧的下拉菜单设置为“Win32”,而不是“x64”。
- 现在,从左侧菜单中选择“C/C++”,然后选择“常规”。
- 接下来,找到“附加包含目录”编辑框,输入你的“SFML”文件夹所在的盘符,后面紧跟“\SFML\include”。如果你把“SFML”文件夹放在D盘,要输入的完整路径就是“D:\SFML\include”。如果你的“SFML”安装在其他盘符,请相应更改路径。
- 现在,仍在同一窗口中,执行以下步骤。从左侧菜单中选择“链接器”,然后选择“常规”。
- 找到“附加库目录”编辑框,输入你的“SFML”文件夹所在的盘符,后面紧跟“\SFML\lib”。所以,如果你把“SFML”文件夹放在D盘,要输入的完整路径就是“D:\SFML\lib”。如果你的“SFML”安装在其他盘符,请更改路径。
- 选择“链接器”,然后选择“输入”。
- 找到“附加依赖项”编辑框,点击其最左侧。现在,复制粘贴或输入以下内容:
sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;
。务必格外小心,将光标精确置于编辑框当前内容的起始位置,以免覆盖已有的任何文本。 - 点击“确定”。
- 点击“应用”,然后点击“确定”。
- 返回Visual Studio主界面,检查主菜单工具栏是否设置为“调试”和“x86”,而不是“x64”。
- 最后,把所有的“sfml-…-d-2.dll”文件复制到项目目录中,其中“…”代表“audio”(音频)、“graphics”(图形)、“network”(网络)、“system”(系统)和“window”(窗口)。
现在,我们将进入C++代码部分。
# 编写主函数
以下是主函数的全部代码,它也是整个游戏循环。这里没有碰撞检测,没有暂停、开始或停止逻辑,没有精灵(sprites)或纹理(textures),没有字体或声音,并且只有一行代码与处理输入有关。在这个项目中,所有东西,或者说几乎所有东西,都将是游戏对象(game object)。相机、火球、平台、玩家角色、菜单,甚至游戏逻辑和雨滴都将是游戏对象。具体如何实现这一点将在项目过程中进行解释,但在本章后面的实体组件系统(Entity Component System)模式部分会给出一个很好的概述。现在,让我们继续编写代码。
将以下代码添加到项目的run.cpp
文件中,然后我们将逐段分析:
#pragma once
#include "SFML/Graphics.hpp"
#include <vector>
#include "GameObject.h"
#include "Factory.h"
#include "InputDispatcher.h"
using namespace std;
using namespace sf;
int main()
{
// 创建一个全屏窗口。
RenderWindow window(
VideoMode::getDesktopMode(), "Booster", Style::Fullscreen);
// 一个顶点数组(VertexArray),用于保存我们所有的图形。
VertexArray canvas(Quads, 0);
// 这个可以将事件分发给任何对象。
InputDispatcher inputDispatcher(&window);
// 所有东西都将是游戏对象。这个向量(vector)将保存它们。
vector <GameObject> gameObjects;
// 这个类拥有构建执行各种不同功能的游戏对象所需的所有知识。
Factory factory(&window);
// 这个调用会将游戏对象向量、用于绘制的画布以及输入分发器发送给工厂(Factory),以设置游戏。
factory.loadLevel(gameObjects, canvas, inputDispatcher);
// 一个用于计时的时钟。
Clock clock;
// 我们用于背景的颜色
const Color BACKGROUND_COLOR(100, 100, 100, 255);
// 这是游戏循环。
// 我们不需要对其进行添加。
// 看看它有多简短和简单!
while (window.isOpen()) {
// 测量这一帧所花费的时间。
float timeTakenInSeconds =
clock.restart().asSeconds();
// 处理玩家输入。
inputDispatcher.dispatchInputEvents();
// 清除上一帧。
window.clear(BACKGROUND_COLOR);
// 更新所有游戏对象。
for (auto& gameObject : gameObjects) {
gameObject.update(timeTakenInSeconds);
}
// 将所有游戏对象绘制到画布上。
for (auto& gameObject : gameObjects)
{
gameObject.draw(canvas);
}
// 显示新的一帧。
window.display();
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
首先,请注意在Visual Studio中显示有三个错误。这是因为我们引用了三个尚未存在的类。缺失的类是InputDispatcher
、GameObject
和Factory
。我们很快就会编写这些类,但首先,让我们讨论一下这段代码,或者说,与之前的项目相比,这段代码的简洁之处。代码从这里开始:
#pragma once
#include "SFML/Graphics.hpp"
#include <vector>
#include "GameObject.h"
#include "Factory.h"
#include "InputDispatcher.h"
using namespace std;
using namespace sf;
2
3
4
5
6
7
8
9
10
前面的代码包含了常见的SFML包含指令,以及另一个用于vector
类的包含指令。这意味着我们的代码中会有一个向量(vector)。我们将使用一个向量来保存所有的游戏对象。此外,我们包含了GameObject
、Factory
和InputDispatcher
这三个类,在本章后面编写这些类之前,这些包含会导致错误。
观察主函数的第一部分:
int main()
{
// 创建一个全屏窗口。
RenderWindow window(
VideoMode::getDesktopMode(),
"Booster", Style::Fullscreen);
// 一个顶点数组(VertexArray),用于保存我们所有的图形。
VertexArray canvas(Quads, 0);
// 这个可以将事件分发给任何对象。
InputDispatcher inputDispatcher(&window);
// 所有东西都将是游戏对象。这个向量(vector)将保存它们。
vector <GameObject> gameObjects;
// 这个类拥有构建执行各种不同功能的游戏对象所需的所有知识。
Factory factory(&window);
// 这个调用会将游戏对象向量、用于绘制的画布以及输入分发器发送给工厂(Factory),以设置游戏。
factory.loadLevel(gameObjects, canvas, inputDispatcher);
// 一个用于计时的时钟。
Clock clock;
// 我们用于背景的颜色
const Color BACKGROUND_COLOR(100, 100, 100, 255);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
在前面的代码中,我们像在所有游戏中一样创建了一个RenderWindow
实例。我们创建了一个名为canvas
的SFML顶点数组(VertexArray)实例。我们将顶点数组称为canvas
,是因为它实际上将是整个游戏的画布。在游戏的每一帧中,所有游戏对象都将被添加到顶点数组中,然后canvas
将用于绘制到窗口上。接下来,我们声明了即将编写的InputDispatcher
类的一个实例。我们很快就会在主游戏循环中看到它是如何使用的。现在,只需注意我们将RenderWindow
的地址发送给了它的构造函数。接下来,我们声明了一个GameObject
实例的向量。如前所述,我们游戏中的每个实体都将包含在一个GameObject
实例中。具体如何实现这一点将在我们继续进行的过程中揭晓。接下来的两行代码声明了即将编写的Factory
类的一个实例(它也接收一个指向RenderWindow
的指针),然后我们调用factory.loadLevel
。loadLevel
函数需要游戏对象向量、用于绘制的画布以及InputDispatcher
实例。Factory
类将是我们游戏引擎的一部分,它会以正确的方式和顺序组装各种各样的GameObject
实例,然后将它们放入向量中,以便在游戏循环中使用。
在我们目前讨论的代码的最后,我们声明了一个时钟来处理更新的计时,并声明了一种颜色用于绘制临时背景。
再次查看下面代码中的主循环,然后我们将进行分析:
while (window.isOpen()) {
// 测量这一帧所花费的时间。
float timeTakenInSeconds = clock.restart().asSeconds();
// 处理玩家输入。
inputDispatcher.dispatchInputEvents();
// 清除上一帧。
window.clear(BACKGROUND_COLOR);
// 更新所有游戏对象。
for (auto& gameObject : gameObjects)
{
gameObject.update(timeTakenInSeconds);
}
// 将所有游戏对象绘制到画布上。
for (auto& gameObject : gameObjects)
{
gameObject.draw(canvas);
}
// 显示新的一帧。
window.display();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
在前面的代码中,我们有一个常见的while
循环,用于不断循环更新和绘制游戏对象,直到窗口关闭。循环的持续时间被捕获到timeTakenInSeconds
变量中,然后我们看到了一些新内容。
inputDispatcher
实例调用了dispatchInputEvents
函数。在我们即将编写的这个函数内部,所有输入事件将被分发给任何之前声明感兴趣的游戏对象。Factory
类负责让游戏对象与inputDispatcher
连接,然后每个游戏对象处理它关心的输入。所以,玩家角色将处理移动输入,菜单将处理暂停、开始和退出操作,我们甚至会有一个相机游戏对象,它处理鼠标滚轮滚动以放大和缩小小地图。
接下来,我们有两个for
循环,它们遍历向量中的所有游戏对象,首先调用update
,然后调用draw
。一旦画布更新完成,window.display()
就会显示当前状态下的整个游戏。
然后主函数如下结束:
return 0;
}
2
现在我们已经了解了目标,接下来我们将编写两个新类,以使新的、更灵活的输入系统能够工作。
# 处理输入
在前面的代码中,你会注意到明显缺少输入处理代码。这是因为每个游戏对象将负责处理自己的输入事件。最值得注意的是与玩家相关的游戏对象,它将处理玩家的移动输入。
还会有一个与菜单相关的游戏对象,它将处理游戏的开始、暂停和退出操作,以及一个与相机相关的对象,它代表玩家可以放大和缩小的小地图/雷达。关键是每个对象都将处理自己的输入事件。下图说明了这种设置:
图15.5:处理输入示意图
为了实现这一点,我们将编写一个InputDispatcher
类。正如我们在主函数中看到的,将只有一个该类的实例,它将接收来自操作系统的所有输入事件,然后将这些事件分发给几个InputReceiver
类的实例。在主游戏循环之前,在Factory
类的loadLevel
函数执行期间,这些InputReceiver
实例会事先向InputDispatcher
实例表明自己(注册)。所有的InputReceiver
实例都将位于适当的游戏对象内部,这些游戏对象将知道要留意哪些输入事件以及如何处理它们。
让我们编写InputDispatcher
类。创建一个名为InputDispatcher
的新类。首先,将以下代码添加到InputDispatcher.h
中:
#pragma once
#include "SFML/Graphics.hpp"
#include "InputReceiver.h"
using namespace sf;
class InputDispatcher
{
private:
RenderWindow* m_Window;
vector <InputReceiver*> m_InputReceivers;
public:
InputDispatcher(RenderWindow* window);
void dispatchInputEvents();
void registerNewInputReceiver(InputReceiver* ir);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
首先,请注意有一个错误,因为我们引用了尚未编写的InputReceiver
类。没问题,我们一完成这个类就会编写它。
在前面的代码中,我们声明了一个指向RenderWindow
实例的指针和一个InputReceiver
指针的向量。每一帧都会遍历这个向量,窗口接收到的输入将被共享。我们有三个函数:用于设置类的构造函数、在游戏循环的每一帧中被调用的dispatchEvents
函数,以及用于将InputReceiver
实例添加到InputReceiver
指针向量中的registerNewInputReceiver
函数。
当然,查看这些函数的实现会让事情更加清晰。接下来,将以下代码添加到InputDispatcher.cpp
中:
#include "InputDispatcher.h"
InputDispatcher::InputDispatcher(RenderWindow* window)
{
m_Window = window;
}
void InputDispatcher::dispatchInputEvents()
{
sf::Event event;
while (m_Window->pollEvent(event))
{
//if (event.type == Event::KeyPressed &&
// event.key.code == Keyboard::Escape)
//{
// m_Window->close();
//}
for (const auto& ir : m_InputReceivers)
{
ir->addEvent(event);
}
}
}
void InputDispatcher::registerNewInputReceiver(InputReceiver* ir)
{
m_InputReceivers.push_back(ir);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
同样,由于缺少InputReceiver
类,这个类中也有一些错误。
在前面的代码中,构造函数初始化了指向RenderWindow
的指针。在dispatchInputEvents
函数中,与我们到目前为止在每个项目中所做的一样,使用RenderWindow
实例来轮询所有事件。然后,遍历向量中的所有InputDispatcher
实例,并使用最新的事件调用它们的addEvent
函数。这个函数中有一些注释掉的代码,我们将在本章后面暂时取消注释。registerNewInputReceiver
函数允许调用它的代码传入一个指向InputReceiver
的指针,从而接收所有更新。请记住,在调用Factory
类的loadLevel
函数时,它会接收InputDispatcher
实例。loadLevel
函数将创建所有的InputReceiver
实例,使用register…function
注册它们,并将InputReceiver
实例放入适当的GameObject
实例中。
让我们来编写InputReceiver
类,看看这个系统的另一部分。创建一个名为InputReceiver
的新类。在InputReceiver.h
代码文件中,添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
using namespace std;
class InputReceiver
{
private:
vector<Event> mEvents;
public:
void addEvent(Event event);
vector<Event>& getEvents();
void clearEvents();
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
注意,InputDispatcher
类中的所有错误都消失了。
前面的代码中有一个SFML
事件的向量,准备从输入调度器接收每一帧的输入事件。有三个函数:addEvent
函数接收一个新事件,getEvents
函数返回充满事件的整个向量,clearEvents
函数清空向量,这样之前迭代的事件就不会随着时间累积,向量中只会存在当前循环的最新事件。
编写这些函数将有助于我们理解这一切。在InputReceiver.cpp
文件中添加以下代码:
#include "InputReceiver.h"
void InputReceiver::addEvent(Event event) {
mEvents.push_back(event);
}
vector<Event>& InputReceiver::getEvents() {
return mEvents;
}
void InputReceiver::clearEvents() {
mEvents.clear();
}
2
3
4
5
6
7
8
9
10
11
12
13
在前面的代码中,addEvent
函数使用push_back
将一个Event
实例添加到向量中。getEvents
函数将整个向量返回给调用代码。最后,clearEvents
函数清空向量,以便它准备好在游戏循环的下一次迭代中接收更多事件。在后面的章节中我们会看到,合适的类将持有InputReceiver
的一个实例,并依次调用这些函数中的每一个。
接下来,让我们编写Factory
类的第一个版本。
# 编写Factory类
创建一个名为Factory
的新类。在Factory.h
文件中,添加以下代码。
#pragma once
#include <vector>
#include "GameObject.h"
#include "SFML/Graphics.hpp"
using namespace sf;
using namespace std;
class InputDispatcher;
class Factory
{
private:
RenderWindow* m_Window;
public:
Factory(RenderWindow* window);
void loadLevel(
vector <GameObject>& gameObjects, VertexArray& canvas,
InputDispatcher& inputDispatcher);
Texture* m_Texture;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在前面的代码中,声明了Factory
类,同时也声明了一个私有的RenderWindow
指针。
请注意,这个指针将被初始化为指向主函数中的RenderWindow
实例,以及InputDispatcher
类中的同一个RenderWindow
实例。有三个函数:构造函数接收RenderWindow
的地址;loadLevel
函数接收GameObject
实例的向量、用于绘图的VertexArray
和InputDispatcher
实例指针。我们还声明了一个SFML
纹理实例。看看下面两行代码,它们来自主函数,这里展示出来是为了提醒我们如何调用Factory
的构造函数和loadLevel
函数。
Factory factory(&window);
factory.loadLevel(gameObjects, canvas, inputDispatcher);
2
快速回顾之后,让我们来编写Factory
类的函数。在Factory.cpp
文件中,添加以下代码:
#include "Factory.h"
#include <iostream>
using namespace std;
Factory::Factory(RenderWindow* window)
{
m_Window = window;
m_Texture = new Texture();
if (!m_Texture->loadFromFile("graphics/texture.png")) {
cout << "Texture not loaded";
return;
}
}
void Factory::loadLevel(
vector<GameObject>& gameObjects, VertexArray& canvas,
InputDispatcher& inputDispatcher)
{
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在前面的构造函数中,我们初始化了RenderWindow
指针,这样我们始终可以从这个类(特别是从loadLevel
函数)访问它。此外,我们将一个.png
文件加载到纹理实例中。加载的文件包含了所有游戏对象的所有图形。在下一章中,我们将讨论为什么要这样做,以及如何让这个与之前项目相比的重大改变发挥作用。简单来说,绘制一个VertexArray
比我们之前绘制的几十个SFML
精灵实例要快得多。
loadLevel
函数目前留空。我们只是希望在本章结束时让代码没有错误,这样在接下来的每一章中我们就可以迈出重要的步伐。
我们的代码中仍然存在一些错误,但它们都是由于缺少GameObject
类导致的。要解决这个问题,我们需要学习更多的C++知识。接下来,我们将讨论处理指针的现代方法,以及一些关于面向对象编程(OOP,Object - Oriented Programming)的更高级知识。接下来的两部分将帮助我们编写GameObject
类,并让前面的代码无错误运行。
# 高级面向对象编程:继承和多态
在本节中,我们将通过研究继承和多态这两个稍微高级一些的概念,进一步扩展我们对面向对象编程的知识。然后,我们将能够运用这些新知识来实现游戏中的游戏对象和组件。
# 继承
我们已经了解了如何通过实例化SFML
库的类的对象来利用他人的成果。但面向对象编程的意义远不止于此。
如果有一个类包含很多有用的功能,但又不完全符合我们的需求,该怎么办呢?在这种情况下,我们可以从其他类继承。就像它听起来的那样,继承意味着我们可以利用他人类的所有特性和优势,包括封装,同时根据我们的具体情况进一步完善或扩展代码。在这个项目中,我们将继承并扩展我们自己的一些类。
让我们看一些使用继承的代码。
# 扩展类
考虑到这些,让我们看一个示例类,看看如何扩展它,了解一下语法,这也是第一步。
首先,我们定义一个要继承的类。这和我们创建其他任何类的方式没有什么不同。看一下这个假设的Soldier
类声明:
class Soldier
{
private:
// 士兵能承受多少伤害
int m_Health;
int m_Armour;
int m_Range;
int m_ShotPower;
public:
void setHealth(int h);
void setArmour(int a);
void setRange(int r);
void setShotPower(int p);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在前面的代码中,我们定义了一个Soldier
类。它有四个私有变量:m_Health
、m_Armour
、m_Range
和m_ShotPower
。它还有四个公共函数:setHealth
、setArmour
、setRange
和setShotPower
。我们不需要看这些函数的定义,它们只是简单地初始化名称所明确表示的相应变量。
我们也可以想象,一个完整实现的Soldier
类会比这个深入得多。它可能会有诸如shoot
(射击)和goProne
(卧倒)这样的函数。如果我们在一个SFML
项目中实现一个Soldier
类,它可能会有一个Sprite
对象,以及一个update
(更新)和一个getPosition
(获取位置)函数。
如果我们想学习继承,这里展示的简单场景是合适的。现在,让我们看一些新内容:从Soldier
类继承。看下面的代码,尤其注意突出显示的部分:
class Sniper : public Soldier
{
public:
// Sniper特有的构造函数
Sniper::Sniper();
};
2
3
4
5
6
通过在Sniper
类声明中添加: public Soldier
,Sniper
类从Soldier
类继承。但这到底意味着什么呢?Sniper
是一种Soldier
。它拥有Soldier
的所有变量和函数。然而,继承的意义不止于此。
还要注意,在前面的代码中,我们声明了一个Sniper
构造函数。这个构造函数是Sniper
独有的。我们不仅从Soldier
类继承,还扩展了Soldier
类。Soldier
类的所有功能(定义)将由Soldier
类处理,但Sniper
构造函数的定义必须由Sniper
类处理。
下面是假设的Sniper
构造函数定义可能的样子:
// 在Sniper.cpp中
Sniper::Sniper()
{
setHealth(10);
setArmour(10);
setRange(1000);
setShotPower(100);
}
2
3
4
5
6
7
8
我们可以继续编写许多其他扩展Soldier
类的类,比如Commando
(突击队员)和Infantryman
(步兵)。每个类都有完全相同的变量和函数,但每个类也可以有一个独特的构造函数,用于根据特定类型的士兵来初始化这些变量。Commando
可能有非常高的m_Health
和m_ShotPower
,但m_Range
却非常低。Infantryman
的每个变量的值可能介于Commando
和Sniper
之间。
似乎面向对象编程已经足够有用了,现在我们还可以对现实世界中的对象进行建模,包括它们的层次结构。我们可以通过子类化/扩展/继承其他类来实现这一点。
这里我们可能需要学习的术语是,被扩展的类是超类(super - class),而从超类继承的类是子类(subclass)。我们也可以称之为父类(parent class)和子类(child class)。
你可能会问关于继承的这个问题:为什么要这样做呢?原因大概是这样的:我们可以只编写一次通用代码;在父类中,我们可以更新这个通用代码,所有继承它的类也会随之更新。此外,子类只能使用公共和受保护的实例变量和函数。所以,如果设计得当,这也有助于实现封装的目标。
你提到了受保护(protected)?是的。类变量和函数有一个访问说明符叫做protected
。你可以把受保护的变量看作是介于公共和私有之间的。下面是访问说明符的快速总结,以及关于protected
说明符的更多详细信息:
- 公共变量和函数可以被任何拥有该类实例的人访问和使用。
- 私有变量和函数只能被类的内部代码访问/使用,不能直接从实例访问。这对于封装很有好处,当我们需要访问/更改私有变量时,我们可以提供公共的获取(getter)和设置(setter)函数(比如
getSprite
)。如果我们扩展一个有私有变量和函数的类,子类不能直接访问其父类的私有数据。 - 受保护的变量和函数几乎和私有变量和函数一样。它们不能被类的实例直接访问/使用。然而,任何扩展它们所声明的类的类都可以直接使用它们。所以,除了子类之外,它们就像私有变量和函数一样。
为了完全理解受保护的变量和函数是什么,以及它们如何有用,让我们先看另一个面向对象编程的主题。然后,我们将看到它们的实际应用。
# 多态
多态允许我们编写对所操作的类型依赖较少的代码。这可以使我们的代码更清晰、更高效。多态意味着多种形式。如果我们编写的对象可以是多种类型,那么我们就可以利用这一点。
但多态对我们来说意味着什么呢?归结为最简单的定义,多态意味着:任何子类都可以作为使用超类的代码的一部分。这意味着我们可以编写更简单、更容易理解的代码,也更容易修改或变更。此外,我们可以为超类编写代码,并依赖这样一个事实,即无论它被子类化多少次,在一定的参数范围内,代码仍然可以工作。
让我们讨论一个例子。
假设我们想用多态来帮助编写一个动物园管理游戏,在这个游戏中我们必须喂养动物并照顾它们的需求。我们可能会想要一个像feed
(喂养)这样的函数。我们可能还想把要喂养的动物实例作为参数传递给feed
函数。
当然,动物园里有很多动物,比如狮子、大象和三趾树懒。基于我们新学到的C++继承知识,编写一个Animal
类,让所有不同类型的动物都从它继承是很有意义的。
如果我们想编写一个feed
函数,可以将Lion
(狮子)、Elephant
(大象)和ThreeToedSloth
(三趾树懒)作为参数传递进去,看起来我们可能需要为每种动物类型编写一个feed
函数。然而,我们可以编写具有多态返回类型和参数的多态函数。看一下下面假设的feed
函数的定义:
void feed(Animal& a)
{
a.decreaseHunger();
}
2
3
4
前面的函数有一个Animal
引用作为参数,这意味着任何从扩展Animal
的类构建的对象都可以传递给它。
这意味着你今天编写的代码,在一周、一个月或一年后创建另一个子类时,同样的函数和数据结构仍然可以工作。此外,我们可以对我们的子类执行一系列规则,规定它们能做什么、不能做什么,以及如何去做。所以,一个阶段的良好设计可以影响其他阶段。
但是我们真的会想要实例化一个真实的动物对象吗?
# 抽象类:虚函数和纯虚函数
抽象类是一个不能被实例化的类,因此不能创建成对象。
这里我们可能需要学习一些术语,即具体类(concrete class)。具体类是任何不是抽象类的类。换句话说,到目前为止我们编写的所有类都是具体类,可以被实例化为可用的对象。
那么,这是永远不会被使用的代码吗?但这就好比请了一位建筑师来设计你的房子,然后却永远不建造它!
如果我们,或者类的设计者,想强制用户在使用类之前先继承它,就可以将类设为抽象类。如果这样做,我们就不能从它创建对象;因此,我们必须先继承它,然后从子类创建对象。
要做到这一点,我们可以将一个函数设为纯虚函数,并且不提供任何定义。然后,任何继承它的类都必须重写(重新编写)这个函数。
让我们看一个例子,这会有帮助。我们可以通过添加一个纯虚函数,比如抽象的Animal
类,来使一个类成为抽象类,Animal
类只能执行发出声音(makeNoise)这个通用动作:
Class Animal
private:
// 这里是私有内容
public:
void virtual makeNoise() = 0;
// 这里是更多公共内容
};
2
3
4
5
6
7
如你所见,我们在函数声明前添加C++关键字virtual
,在后面添加= 0
。现在,任何扩展/继承自Animal
的类都必须重写makeNoise
函数。这可能是有意义的,因为不同类型的动物发出非常不同类型的声音。我们可能会认为任何扩展Animal
类的人都足够聪明,能注意到Animal
类不能发出声音,并且他们需要处理这个问题,但如果他们没有注意到呢?关键是,通过创建一个纯虚函数,我们保证他们会处理,因为他们必须处理,否则代码将无法编译。
抽象类也很有用,因为有时我们希望一个类可以用作多态类型,但又必须确保它永远不会被实例化为对象。例如,“动物”(Animal)这个概念本身并没有太大实际意义。我们不会笼统地谈论动物,而是会讨论动物的种类。我们不会说“哇,看那只可爱、毛茸茸的白色动物!”,也不会说“昨天,我们去宠物店买了一只动物和一张动物床”。这么说,实在是太抽象了。
所以,抽象类有点像一个模板,供任何扩展(继承)它的类使用。比如说,如果我们正在开发一款“工业帝国”类型的游戏,玩家在游戏里管理企业和员工,那我们可能会创建一个“工人”(Worker)类,并基于它扩展出“矿工”(Miner)、“钢铁工人”(Steelworker)、“办公室职员”(OfficeWorker),当然还有“程序员”(Programmer)这些类。但一个普通的“工人”具体做什么呢?我们为什么要实例化一个普通“工人”对象呢?
答案是,我们并不想实例化它,但可能会把它用作多态类型,这样就能在函数之间传递多个“工人”子类对象,还能使用能容纳所有类型工人的数据结构。
任何继承包含纯虚函数的父类的子类,都必须重写所有的纯虚函数。这意味着抽象类可以提供一些通用功能,这些功能在其所有子类中都能使用。例如,“工人”类可能有m_AnnualSalary
、m_Productivity
和m_Age
这些成员变量。它可能还有getPayCheck
函数,这个函数不是纯虚函数,在所有子类中的实现都一样;而doWork
函数是纯虚函数,必须被重写,因为不同类型的“工人”工作方式差异很大。
顺便说一下,虚函数(virtual)和纯虚函数不同,虚函数可以选择性地被重写。声明虚函数的方式和声明纯虚函数一样,只是结尾处不加
= 0
。在当前的游戏项目中,我们会用到好几个纯虚函数。
如果对虚函数、纯虚函数或者抽象类这些概念还有不清楚的地方,实际用一用可能是理解它们的最佳方式。我们很快就会实践。首先,让我们了解一下设计模式(Design patterns)和实体组件设计模式(entity component design pattern)。
# 设计模式
我猜,如果你打算用C++开发深度、大规模的游戏,那么在未来的几个月甚至几年里,设计模式会是你学习计划中的重要部分。接下来的内容只是对这个重要主题的初步介绍。
设计模式是针对编程问题的可复用解决方案。实际上,大多数游戏(包括《奔跑吧!》(Run))都会用到多种设计模式。设计模式的关键在于,它们已经被证明能为常见问题提供良好的解决方案。我们不会去发明新的设计模式,只会使用现有的一些模式,来解决我们不断膨胀的代码中出现的问题。
很多设计模式相当复杂,如果你想深入学习,仅靠本书的内容是不够的,还需要进一步研究。接下来介绍的是一个与游戏开发相关的关键模式的简化版。建议你继续深入学习,以便更全面地应用这些模式。
# 实体组件系统模式(Entity Component System pattern)
现在,我们先花五分钟,沉浸在一个看似无解的混乱局面中。然后,再看看实体组件模式是如何解决问题的。
# 为什么多种不同类型的对象难以管理
在之前的项目里,我们为每个对象都编写了一个类,比如“蝙蝠”(Bat)、“球”(Ball)、“爬行者”(Crawler)和“僵尸”(Zombie)这些类。然后,在update
函数里更新对象状态,在draw
函数里绘制对象。每个对象各自决定更新和绘制的具体方式。
我们本可以直接用这种结构来开发《奔跑吧!》这款游戏,它也能运行起来。但我们想学习一些更易于管理的方法,以便让游戏的复杂度可以不断提升。
这种方法还有另一个问题,就是我们无法利用继承特性。例如,在僵尸主题的游戏里,所有僵尸、子弹和玩家角色的绘制方式完全一样。但如果不改变做法,我们最终会写出三个代码几乎一样的draw
函数。将来,如果我们改变调用draw
函数的方式,或者处理图形的方式,就需要把这三个类都更新一遍。
肯定有更好的办法。
# 使用通用的游戏对象(GameObject)优化代码结构
如果把玩家、僵尸、所有子弹等每个对象都设为同一种通用类型,我们就可以把它们存进一个vector
实例里,然后依次循环调用每个对象的update
函数,再调用draw
函数。《奔跑吧!》项目的main
函数就是这么做的。
我们刚了解到一种实现方式:继承。乍一看,继承似乎是个完美的解决方案。我们可以创建一个抽象的GameObject
类,然后用Player
、Zombie
和Bullet
类去继承它。
这三个类中相同的draw
函数可以留在父类里,这样就不会有重复代码的问题了,多好啊!
但这个方法也有问题,游戏对象在某些方面差异很大。比如,所有对象的移动方式都不一样:子弹沿固定方向飞行,僵尸会朝着玩家移动,而玩家角色则根据键盘输入做出反应。
我们怎么在update
函数里处理这些差异,从而控制对象的移动呢?或许可以这样做:
update() {
switch (objectType) {
case 1:
// 玩家的逻辑代码
break;
case 2:
// 僵尸的逻辑代码
break;
case 3:
// 子弹的逻辑代码
break;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
光是这个update
函数,可能就比整个GameEngine
类还要大!
你可能还记得“高级面向对象编程:继承和多态”那部分内容,当我们从一个类继承时,还可以重写特定的函数。这意味着我们可以为每种对象类型编写不同版本的update
函数。但不幸的是,这种方法也存在问题。
GameEngine
引擎必须“知道”正在更新的是哪种类型的对象,或者至少要能查询正在更新的GameObject
实例,才能调用正确版本的update
函数。实际上,我们真正需要的是让GameObject
能在内部自行选择需要调用哪个版本的update
函数。
遗憾的是,仔细研究后会发现,这个看似可行的解决方案也有漏洞。我之前说这三个对象的draw
函数代码一样,所以draw
函数可以放在父类里供所有子类使用,这样就不用写三个单独的draw
函数了。但要是引入一个新对象,比如会在屏幕上方飞过的动画黄蜂僵尸,它的绘制方式和其他对象不同,那该怎么办呢?在这种情况下,draw
函数的解决方案也行不通了。
既然已经看到了对象既彼此不同,又需要来自同一个父类时会出现的问题,现在就来看看《奔跑吧!》项目里采用的解决方案吧。
我们需要一种全新的思路来构建游戏里的所有对象。
# 优先使用组合而非继承
优先使用组合而非继承,指的是将对象与其他对象组合起来的理念。这个概念最早是在以下这本书里提出的:
《设计模式:可复用的面向对象软件元素》(Design Patterns: Elements of Reusable Object-Oriented Software) —— 作者:埃里克·伽玛(Erich Gamma)、理查德·海尔姆(Richard Helm)等人
要是我们能编写一个完整的类(而不是一个函数)来处理对象的绘制方式,会怎么样呢?这样的话,对于绘制方式相同的类,我们可以在GameObject
里实例化一个这种特殊的绘制类;而对于需要不同绘制方式的对象,就可以用不同的绘制对象。当GameObject
有不同行为时,我们只要用不同的绘制类或更新类与它组合,就能满足需求。这样一来,所有对象的相同点可以通过使用相同代码来实现,而不同点不仅可以被封装起来,还能从基类中抽象出来。
注意,本节的标题是“优先使用组合而非继承”,不是“用组合取代继承”。组合并没有取代继承,你在“高级面向对象编程:继承和多态”那部分学到的知识依然有效。不过,在条件允许的情况下,优先选择组合而不是继承。在《奔跑吧!》项目里,这两种方式我们都会用到。
GameObject
类是实体(entity),而组成它的那些负责更新位置、绘制到屏幕上的类是组件(components),这就是为什么这种模式叫实体组件模式(Entity-Component pattern)。
看看下面这张图,它展示了我们在这个项目里实现实体组件模式的方式:
在上图中,左边的代码来自main
函数,它遍历GameObject
向量,依次对每个实例先调用update
函数,再调用draw
函数。从图中可以看到,一个GameObject
实例由多个Component
实例组成。从Component
类会派生出多个不同的类,包括UpdateComponent
和GraphicsComponent
。此外,还可能从这些类派生出更具体的类,比如BulletUpdateComponent
和ZombieUpdateComponent
类就可以从UpdateComponent
类派生。这些类负责处理游戏每一帧里对象的自我更新。这对封装很有帮助,因为我们不再需要用庞大的switch
语句块来区分不同对象了。
像这里这样,用组合而非继承的方式创建一组表示行为或算法的类,这种做法被称为策略模式(Strategy pattern)。你可以把在这里学到的知识都用上,把它叫做策略模式。实体组件模式虽然不太出名,但它是更具体的一种实现方式,这就是我们这么称呼它的原因。两者的区别属于学术范畴,要是你想进一步探究,可以去问问ChatGPT。如果想深入探索游戏编程模式,https://gameprogrammingpatterns.com (opens new window)是个不错的资源。
实体组件模式,再加上优先使用组合而非继承的理念,乍一听很不错,但它也有自己的问题。这意味着新的GameObject
类需要“了解”游戏里所有不同类型的组件和每种对象。它要怎么给自己添加正确的组件呢?
我们来看看解决方案。
# 工厂模式(Factory pattern)
确实,如果我们要有一个通用的GameObject
类,它可以是子弹、玩家、入侵者,或者其他任何东西,那就得编写一些逻辑,让程序“知道”如何构建这些超级灵活的GameObject
实例,并为它们组合正确的组件。但要是把这些代码都写进GameObject
类里,这个类就会变得异常臃肿,而且最初使用实体组件模式的意义也就没有了。
我们可能需要一个构造函数,类似下面这段假设的GameObject
代码:
class GameObject {
UpdateComponent* m_UpdateComponent;
GraphicsComponent* m_GraphicsComponent;
// 更多组件
// 构造函数
GameObject(string type) {
if (type == "invader") {
m_UpdateComp = new InvaderUpdateComponent();
m_GraphicsComponent = new StdGraphicsComponent();
}
else if (type == "ufo") {
m_UpdateComponent = new UFOUpdateComponentComponent();
m_GraphicsComponent = new AnimGraphicsComponent();
}
// 等等
…
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GameObject
类不仅要知道哪些组件对应哪个GameObject
实例,还要清楚哪些实例不需要某些组件,比如控制玩家的输入相关组件。
对于《奔跑吧!》项目,我们这么做或许还能应付得来,但要是游戏更复杂一些,我们很可能会被代码淹没,最后失败。
GameObject
类还得理解所有这些逻辑。这样一来,使用实体组件模式并优先采用组合而非继承所带来的好处和效率就都没了。
另外,如果我们想添加一种新的对象类型,比如一个新敌人,它能传送到玩家附近开一枪,然后再传送走,该怎么办呢?编写一个新的GraphicsComponent
类,比如TeleportGraphicsComponent
,让它能判断自己何时可见、何时不可见,再编写一个新的UpdateComponent
类,比如TeleportUpdateComponent
,让它实现传送而不是常规移动,这些都没问题。但麻烦的是,我们得在GameObject
类的构造函数里添加一大堆新的if
语句。
实际上,情况比这还糟糕。要是我们决定普通对象现在也能传送了呢?现在所有GameObject
需要的就不只是不同类型的GraphicsComponent
类了。我们还得回过头去修改GameObject
类里的那些if
语句。
其实,还能想到更多类似的场景,最终都会导致GameObject
类变得越来越庞大。工厂模式就是解决这些与GameObject
类相关问题的方案,也是实体组件模式的完美搭档。
这里对工厂模式的实现只是个简化版本,方便大家初步学习。完成这个项目后,你可以在网上搜搜工厂模式,看看怎么改进它。
游戏设计师会为游戏里的每种对象提供规格说明,程序员则编写一个工厂类,根据游戏设计师的规格说明来创建GameObject
实例。当游戏设计师有了新的实体创意时,我们只需要获取新的规格说明就行。有时候,这可能意味着给工厂添加一条新生产线,使用现有的组件;有时候,则需要编写新组件,或者更新现有组件。关键是,不管游戏设计师的创意有多新奇,GameObject
类和main
函数都不用变。
在最简单的形式(就像我们的Factory
类)下,工厂类知道如何为游戏循环准备游戏对象及其合适的组件。
在工厂代码里,会实例化当前的对象类型,并为其添加合适的组件(类)。火球、玩家、平台等对象都由不同和相同组件组合而成。
有些游戏对象可能只有图形,比如雨滴;有些可能只有更新逻辑,比如控制游戏逻辑的关卡管理器(level manager)。
使用组合时,不太容易确定哪个类负责管理内存。是创建内存的类、使用内存的类,还是其他某个类呢?我们再学一些C++知识,以便更轻松地管理内存。
# C++智能指针(smart pointers)
智能指针是一类特殊的类,它的功能和普通指针一样,但多了一个特性:能自动处理所指向内存的释放。就目前我们使用指针的方式而言,自己释放内存还没遇到什么问题。但随着代码越来越复杂,当在一个类里分配新内存,却在另一个类里使用时,就很难说清楚用完内存后该由哪个类来释放它。一个类或函数怎么知道另一个类或函数是否已经用完了某些已分配的内存呢?
解决方案就是智能指针。智能指针有好几种类型,我们这里介绍两种最常用的。用好智能指针的关键是选择正确的类型。
我们先来看共享指针(shared pointers)。
# shared_ptr 智能指针
共享指针能安全释放所指向内存的原理是,记录指向一块内存区域的不同引用数量。如果你把一个指针传递给函数,引用计数就会加1;把指针存进vector里,引用计数也加1;函数返回时,引用计数减1;vector超出作用域或者调用了clear
函数,shared_ptr会把引用计数减1。当引用计数变为0时,就没有任何东西指向这块内存区域了,这时shared_ptr类会调用delete
释放内存。所有shared_ptr类在底层都是基于普通指针实现的。我们的好处是,不用再操心在哪里、什么时候调用delete
了。下面看看使用共享智能指针的代码。
下面这段代码创建了一个新的shared_ptr myPointer
,它将指向MyClass
的一个实例:
shared_ptr<MyClass> myPointer;
shared_ptr<MyClass>
是类型,myPointer
是它的名字。下面的代码展示了如何初始化myPointer
:
myPointer = make_shared<MyClass>();
调用make_shared
会在内部调用new
来分配内存。括号()
是构造函数的参数括号。比如说,如果MyClass
类的构造函数需要一个int
类型的参数,上面的代码可能就会长这样:
myPointer = make_shared<MyClass>(3);
这里的 3 只是个随意举的例子。
当然,如果需要,你可以在一行代码里声明并初始化一个shared_ptr,就像下面这样:
shared_ptr<MyClass> myPointer = make_shared<MyClass>();
因为myPointer
是shared_ptr
类型,它有一个内部引用计数器,用来记录有多少个引用指向它创建的内存区域。如果复制这个指针,引用计数就会增加。
复制shared_ptr的操作包括把shared_ptr传递给另一个函数、存进vector、map或其他数据结构里,或者直接复制。
我们可以用和普通指针一样的语法来使用shared_ptr。有时候很容易忘记它不是普通指针。下面这段代码调用了myPointer
的myFunction
函数:
myPointer->myFunction();
使用shared_ptr会带来一些性能和内存开销。这里说的开销,指的是代码运行速度会变慢,占用的内存也会增加。毕竟,智能指针需要一个
变量来跟踪引用计数,并且每次引用超出作用域时都必须检查引用计数的值。不过,这种开销非常小,只有在极端情况下才会成为问题,因为大部分开销都发生在创建智能指针的时候。通常,我们会在游戏循环之外创建shared_ptr。在shared_ptr上调用函数和在普通指针上调用函数一样高效。
有时候,我们知道某个shared_ptr只会有一个引用,在这种情况下,unique_ptr 是最佳选择。
# unique_ptr智能指针
当我们只希望对一块内存区域有单个引用时,可以使用unique_ptr。unique_ptr消除了我提到的shared_ptr所存在的大部分额外开销。此外,如果尝试复制一个unique_ptr,编译器会发出警告,代码要么无法编译,要么会崩溃,这会给我们一个明确的错误提示。这是一个非常有用的特性,可以防止我们意外复制不应被复制的智能指针对象。你可能想知道,这个禁止复制的规则是否意味着我们永远不能将其传递给函数,甚至不能将其放入像vector这样的数据结构中。为了弄清楚,让我们来看一些unique_ptr的代码,并探究它们的工作原理。
下面的代码创建了一个名为myPointer
的unique_ptr,它指向MyClass
的一个实例:
unique_ptr<MyClass> myPointer = make_unique<MyClass>();
现在,假设我们想向vector中添加一个unique_ptr。首先要注意的是,vector必须是正确的类型。下面的代码声明了一个vector,它存储指向MyClass
实例的unique_ptr:
vector<unique_ptr<MyClass>> myVector;
这个vector名为myVector
,放入其中的任何内容都必须是指向MyClass
的unique_ptr类型。但我不是说过unique_ptr不能被复制吗?当我们知道只需要对一块内存区域有单个引用时,就应该使用unique_ptr。然而,这并不意味着这个引用不能被移动。下面是一个示例:
// Use move() because otherwise
// the vector has a COPY which is not allowed
mVector.push_back(move(myPointer));
// mVector.push_back(myPointer); // Won't compile!
2
3
4
在前面的代码中,我们可以看到可以使用move
函数将unique_ptr放入vector中。注意,当使用move
函数时,并不是允许编译器违反规则去复制unique_ptr:而是将所有权从myPointer
变量转移到myVector
实例。如果在此之后尝试使用myPointer
变量,代码将会执行,但程序会崩溃,并给出空指针访问冲突的错误信息。下面的代码会导致崩溃:
unique_ptr<MyClass> myPointer = make_unique<MyClass>();
vector<unique_ptr<MyClass>> myVector;
// Use move() because otherwise
// the vector has a COPY which is not allowed
mVector.push_back(move(myPointer));
// mVector.push_back(myPointer); // Won't compile!
myPointer->myFunction();// CRASH!!
2
3
4
5
6
7
8
将unique_ptr传递给函数时,同样的规则也适用;使用move
函数来转移所有权。一会儿回到“Run”项目时,我们会再次研究其中一些场景,还会探讨更多的场景。
# 智能指针类型转换(Casting smart pointers)
我们常常希望将派生类的智能指针打包到基类的数据结构或函数参数中,比如所有不同的派生组件(Component)类。这是多态性的本质。智能指针可以通过类型转换(casting)来实现这一点。但是,当我们之后需要访问派生类的功能或数据时会发生什么呢?
在处理游戏对象中的组件时,经常会需要进行这种操作。假设有一个抽象的组件(Component)类,从它派生出来的有图形组件(GraphicsComponent)、更新组件(UpdateComponent)等等。
例如,我们希望将基于通用组件的类传递给函数,同时使用派生类的函数。但是,如果所有组件都存储为基类组件(Component)实例,那么看起来我们似乎无法做到这一点。从基类到派生类的类型转换解决了这个问题。
下面的代码将myComponent
(一个基类组件(Component)实例)转换为更新组件(UpdateComponent)类实例,然后我们就可以调用它的update
函数:
shared_ptr<UpdateComponent> myUpdateComponent =
static_pointer_cast<UpdateComponent>
(MyComponent);
2
3
等号前面,声明了一个指向更新组件(UpdateComponent)实例的新的共享指针(shared_ptr)。等号后面,static_pointer_cast
函数在尖括号<UpdateComponent>
中指定要转换的类型,在括号(MyComponent)
中指定要转换的实例。
现在我们可以使用更新组件(UpdateComponent)类的所有函数,在我们的项目中,这包括update
函数。我们可以像这样调用update
函数:
myUpdateComponent->update(fps);
将一个类的智能指针转换为另一个类的智能指针有两种方法。一种是使用static_pointer_cast
,正如我们刚刚看到的,另一种是使用dynamic_pointer_cast
。区别在于,如果你不确定转换是否会成功,可以使用dynamic_pointer_cast
。使用dynamic_pointer_cast
时,你可以通过测试结果是否为空指针来检查转换是否成功。当你确定结果是你要转换的类型时,就使用static_pointer_cast
。在“Run”项目的几个地方,我们会使用static_pointer_cast
。让我们回到游戏的开发中。
# 编写游戏对象(GameObject)类
游戏对象(GameObject)类依赖于组件(Component)类,而组件(Component)类又依赖于图形(Graphics)类和更新(Update)类,所以,让我们来编写这四个类。
还记得在讨论实体组件系统时,我们提到过组件(Component)类,图形组件(GraphicsComponent)、更新组件(UpdateComponent)等将从组件(Component)派生而来。为了展示代码,我们将图形组件(GraphicsComponent)简称为Graphics
,更新组件(UpdateComponent)简称为Update
。
创建一个名为GameObject
的类。在GameObject.h
中,添加以下代码:
#pragma once
#include "SFML/Graphics.hpp"
#include "Component.h"
#include <vector>
using namespace sf;
using namespace std;
class GameObject
{
private:
vector<shared_ptr<Component>> m_Components;
public:
void addComponent(shared_ptr<Component> newComponent);
void update(float elapsedTime);
void draw(VertexArray& canvas);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这段代码中有错误,因为我们缺少组件(Component)类。正如你可能预料的那样,我们很快就会编写这个类。
在前面的代码中,我们有一个用于存储组件(Component)实例的向量(vector)。我们不会向向量中添加任何抽象的组件(Component)实例,而是添加派生的图形(Graphics)和更新(Update)实例。为了便于实现这一点,我们有addComponent
函数。
我们还有update
和draw
函数。我们已经看到在主游戏循环中调用了这两个函数。下面是主函数中游戏循环的代码,作为回顾。你不需要再次添加这段代码:
// Update all the game objects.
for (auto& gameObject : gameObjects) {
gameObject.update(timeTakenInSeconds);
}
// Draw all the game objects to the canvas .
for (auto& gameObject : gameObjects) {
gameObject.draw(canvas);
}
2
3
4
5
6
7
8
9
希望你能明白整个系统是如何组合在一起的。
让我们来编写游戏对象(GameObject)类的三个函数。在GameObject.cpp
中添加以下代码:
#include "GameObject.h"
#include "SFML/Graphics.hpp"
#include <iostream>
#include "Update.h"
#include "Graphics.h"
using namespace std;
using namespace sf;
void GameObject::addComponent(
shared_ptr<Component> newComponent)
{
m_Components.push_back(newComponent);
}
void GameObject::update(float elapsedTime) {
for (auto component : m_Components) {
if (component->m_IsUpdate) {
static_pointer_cast<Update>
(component)->update(elapsedTime);
}
}
}
void GameObject::draw(VertexArray& canvas) {
for (auto component : m_Components) {
if (component->m_IsGraphics) {
static_pointer_cast<Graphics> (component)->draw(canvas);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
在这个文件中,有三个缺失的类导致了错误。它们是Component
、Update
和Graphics
。Update
和Graphics
将从Component
派生而来,在讨论完刚刚添加的代码后,我们将编写这三个类。
在前面的代码中,addComponent
函数只有一行代码,它使用向量(vector)的push_back
函数将一个派生组件的新实例添加到m_Components
向量中。
update
函数也很短且简单。首先,代码像这样遍历所有组件:
for (auto component : m_Components) {
然后,它像这样检查当前组件是否是更新组件:
if (component->m_IsUpdate) {
最后,如果前面的测试为真,就调用update
函数,该实例将执行它自己版本的update
函数。请记住,在我们的游戏中,这可以是任何东西:玩家、火球、菜单等等。
draw
函数的操作与update
函数完全相同,只是它寻找图形组件并调用draw
函数。
前面的代码意味着组件(Component)类将有布尔变量m_IsUpdate
和m_IsGraphics
。接下来让我们编写组件(Component)类。
# 编写组件(Component)类
组件(Component)类是本书中最短的类。它没有函数。它的存在只是为了被扩展。实际上,我们会让Component.cpp
文件为空。不过要注意,我们对之前简单的实体组件示例进行了轻微扩展。Graphics
和Update
将扩展Component
。Component
将是多态类型,而Graphics
和Update
将是抽象类(包含纯虚函数),我们游戏中所有可用的类都将扩展它们。创建一个名为Component
的类,在Component.h
中添加以下代码:
#pragma once
#include <iostream>
using namespace std;
class Component
{
public:
bool m_IsGraphics = false;
bool m_IsUpdate = false;
};
2
3
4
5
6
7
8
9
10
在前面的代码中,我们创建了一个名为Component
的类,并添加了两个公共成员变量。在添加新组件时,将设置m_IsGraphics
和m_IsUpdate
这两个布尔变量,并在更新或绘制之前进行测试。就是这样。
Component.cpp
将保持为空,因为没有功能代码。如果你愿意,可以删除Component.cpp
。
然而,扩展Component
的类还有很多内容。让我们先编写Graphics
类,然后再编写Update
类。
# 编写图形(Graphics)类
我们将这个从组件(Component)类派生的类命名为Graphics
。下一个同样从组件(Component)类派生的类,我们命名为Update
。如果将它们命名为GraphicsComponent
和UpdateComponent
,可能会更直观清晰,但“component”这个词比较长。所以,我选择了简单的Graphics
和Update
。我可能会时不时地把Graphics
和Update
当作组件来提及,因为它们本质上就是组件,即便名称并非如此。
创建一个名为Graphics
的类,以Component
作为其基类。你可以在“新建类”对话框的“基类”字段中添加Component
,这样会自动为你生成一些代码。不过,直接在Graphics.h
中编写以下代码,也能达到完全相同的效果。
在Graphics.h
中,添加以下代码:
#pragma once
#include "Component.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class Update;
class Graphics :
public Component
{
private:
public:
Graphics();
virtual void assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) = 0;
virtual void draw(VertexArray& canvas) = 0;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在前面的代码中,没有变量,只有两个公共函数。仔细观察这些函数,它们在声明开头有标志性的virtual
关键字,结尾有= 0
。任何扩展这个类的类都必须实现(提供定义)这两个函数。Graphics
接口的第一个纯虚函数是assemble
函数。随着开发的推进,我们将编写一系列扩展Graphics
类的类,包括PlayerGraphics
、RainGraphics
和PlatformGraphics
。每个类都将提供自己特定的assemble
函数实现。这很有用,因为它们的组装方式都略有不同。
在继续之前,注意assemble
函数的签名。首先,有一个VertexArray
引用,它将允许为所需图形添加纹理坐标。还有一个指向Update
实例的共享指针。我们将看到如何利用这个指针从与当前Graphics
实例对应的Update
实例中获取所需数据。我们将使用“智能指针类型转换(Casting smart pointers)”部分讨论的静态类型转换,来访问相应子类的函数。
最后,我们有一个SFML的IntRect
实例,它将包含这个对象的纹理坐标。assemble
函数将在Factory
类的loadLevel
函数中被调用。
draw
函数在主游戏循环迭代期间接收VertexArray
,以便更新其位置。
在Graphics.cpp
中,添加以下代码:
#include "Graphics.h"
Graphics::Graphics()
{
m_IsGraphics = true;
}
2
3
4
5
6
在前面的代码中,构造函数只做一件事,就是将m_IsGraphics
布尔值设置为true
。当创建任何从Graphics
派生的实例时,编译器总会调用这个构造函数,这就确保了在Component
类中声明的公共变量被正确设置。记住,在GameObject
代码中尝试调用draw
函数之前,会检查这个值。
# 编写更新(Update)类
创建一个名为Update
的类,以Component
作为其基类。如果你愿意,可以使用“基类”字段,不用也没关系,两种方式都可以。
在Update.h
中,添加以下代码:
#pragma once
#include "Component.h"
#include "SFML/Graphics.hpp"
class LevelUpdate;
class PlayerUpdate;
class Update :
public Component
{
private:
public:
Update();
virtual void assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate) = 0;
virtual void update(float timeSinceLastUpdate) = 0;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在前面的代码中,我们有两个纯虚函数:assemble
和update
。assemble
函数将在Factory
类的loadLevel
函数中使用。从签名中可以看出,assemble
函数使用两个共享指针:一个是LevelUpdate
实例,另一个是PlayerUpdate
实例。我们还没有编写这些类,但所有从Update
派生的实例都需要跟踪游戏状态,游戏状态将由LevelUpdate
类(也是一个从Update
派生的类)控制,同时还需要跟踪由PlayerUpdate
类控制的玩家状态。
我们通过在Update.h
开头附近添加前向声明来解决引用这两个尚未存在的类的问题,如下所示:
class LevelUpdate;
class PlayerUpdate;
2
如果在实现这些类之前尝试使用其中任何一个共享指针,代码将无法运行,但仅在函数签名中添加它们是可行的,这得益于前向声明。
在Update.cpp
中,添加以下代码:
#include "Update.h"
Update::Update() {
m_IsUpdate = true;
}
2
3
4
5
在前面的代码中,我们使用了与Graphics
类相同的技术,确保父类Component
设置适当的布尔值,这样GameObject
类就能知道它当前使用的是哪种类型的组件(图形组件还是更新组件)。
# 运行代码
现在所有代码都没有错误了,我们可以运行它,但只会看到一个灰色屏幕。此外,我们无法轻松停止程序。使用Ctrl + Alt + Delete
组合键,选择Run.exe
,然后按“结束任务”来强制停止程序。
为了添加一些临时代码来解决这个不便之处,在InputDispatcher.cpp
中找到以下代码:
//if (event.type == Event::KeyPressed &&
// event.key.code == Keyboard::Escape)
//{
// m_Window->close();
//}
2
3
4
5
取消上述代码的注释,这样InputDispatcher
实例就能处理按下Esc
键的情况。InputDispatcher
类应该只处理输入消息的分发,但在项目后续实现与菜单相关的类之前,我们先这样临时处理一下。现在你可以运行程序,欣赏灰色屏幕,还能方便地按Esc
键退出。
接下来做什么呢? 我们需要讨论这个项目中图形的新工作方式,我们将在第17章详细讨论。我说“新”,是针对本书而言,因为这是一种在游戏开发早期就存在的技术。
如果你查看graphics
文件夹,里面只有一张图片。到目前为止,我们在代码中也从未调用过window.draw
函数。我们将讨论为什么应尽量减少绘制调用,以及如何实现处理这些的相机相关类。
我们推迟这个讨论的原因是,有一些可运行的代码作为讨论基础会更有帮助。当然,顶点数组和纹理坐标对我们来说并不陌生,因为在僵尸项目中我们就用它们来处理背景。因此,在下一章中,我们将开始实现游戏逻辑以及与玩家相关类的第一部分。而且,由于我们之前处理过声音,实现起来会比较容易,我们将实现一个SoundManager
类,它还具备循环播放一段短音乐的功能。
现在,让我们回顾一下本章所做的和学到的所有内容。
# 总结
首先,我们详细了解了新游戏是什么以及如何游玩。然后,我们以常规方式创建了项目,并编写了整本书中最短的主函数(主游戏循环)!
接下来,我们开始编写处理玩家输入的新方式,将特定职责委托给各个游戏实体/对象,并让它们监听来自新的InputDispatcher
类的消息。
我们编写了一个名为Factory
的类,它负责“了解”如何将我们构建的所有不同组件组装成可用的派生类型,然后再将这些类型放置/组合到GameObject
实例中。
我们学习了C++的继承、多态,以及C++智能指针,通过智能指针将内存管理的职责交给编译器。
然后,我们编写了关键的GameObject
类。Component
类几乎是其他所有类的父类,在本书后续部分我们还会对其进行编写,GameObject
实例将持有Component
类的实例。接下来,我们编写了Graphics
和Update
类,它们是从Component
派生/扩展而来的子类。
我们已经准备好在下一章添加声音和游戏逻辑,并学习对象间的通信。