第11章 编写TextureHolder类并创建一群僵尸
# 第11章 编写TextureHolder类并创建一群僵尸
现在我们已经了解了标准模板库(STL,Standard Template Library )的基础知识,就能够运用这些新知识来管理游戏中的所有纹理。因为,如果游戏中有1000只僵尸,我们肯定不想为每一只僵尸都向图形处理单元(GPU)加载一份僵尸图像副本。
我们还会更深入地探究面向对象编程(OOP,Object-Oriented Programming ),并使用静态函数。静态函数是类的一种函数,无需类的实例即可调用。同时,我们将了解如何设计一个类,以确保它仅存在一个实例。当我们需要保证代码的不同部分使用相同的数据时,这种设计非常理想。
在本章中,我们将涵盖以下主题:
- 实现TextureHolder类
- 创建一群僵尸
- 在所有纹理中使用TextureHolder类
# 实现TextureHolder类
数以千计的僵尸带来了新的挑战。加载、存储和处理三种不同僵尸纹理的数千份副本,不仅会占用大量内存,还会消耗大量处理能力。我们将创建一种新型类来解决这个问题,使每种纹理仅存储一份。
我们还将以特定方式编写这个类,确保它只能有一个实例。这种类型的类被称为单例(singleton)。
单例是一种设计模式。设计模式是一种经过验证有效的代码结构组织方式。
此外,我们编写的这个类,在游戏代码的任何地方都可以直接通过类名使用,而无需访问类的实例。这是一种特殊类型的类,称为静态类。
# 编写TextureHolder头文件
让我们创建一个新的头文件。在解决方案资源管理器中右键单击“头文件”,选择“添加”>“新建项” 。在“添加新项”窗口中,(通过左键单击)突出显示“头文件(.h)”,然后在“名称”字段中输入“TextureHolder.h”。
将以下代码添加到“TextureHolder.h”文件中,然后我们再进行讨论:
#pragma once
#ifndef TEXTURE_HOLDER_H
#define TEXTURE_HOLDER_H
#include <SFML/Graphics.hpp>
#include <map>
using namespace sf;
using namespace std;
class TextureHolder
{
private:
// A map container from the STL,
// that holds related pairs of String and Texture
map<string, Texture> m_Textures;
// A pointer of the same type as the class itself // the one and only instance
static TextureHolder* m_s_Instance;
public:
TextureHolder();
static Texture& GetTexture(string const& filename);
};
#endif
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在上述代码中,请注意我们包含了来自标准模板库(STL)的map
头文件。我们声明了一个map
实例m_Textures
,它存储string
类型和SFML的Texture
类型的键值对。
在前面的代码中,有这样一行:
static TextureHolder* m_s_Instance;
这行代码很有意思。我们声明了一个指向TextureHolder
类型对象的静态指针m_s_Instance
。这意味着TextureHolder
类有一个与自身类型相同的对象。不仅如此,因为它是静态的,所以可以通过类本身使用,而无需类的实例。在编写相关的.cpp
文件时,我们将了解如何使用它。
在类的公共部分,我们有构造函数TextureHolder
的原型。该构造函数不带参数,和往常一样没有返回类型,这和默认构造函数相同。我们将用一个定义覆盖默认构造函数,使我们的单例按预期工作。
我们还有另一个名为GetTexture
的函数。让我们再看一下它的签名,并详细分析其中的原理:
static Texture& GetTexture(string const& filename);
首先,注意这个函数返回一个Texture
的引用。这意味着GetTexture
将返回一个引用,这种方式很高效,因为避免了复制可能较大的图形。另外,注意该函数被声明为static
(静态)。这意味着无需类的实例就可以使用这个函数。该函数接受一个string
类型的常量引用作为参数。这样做有两个好处:其一,操作效率高;其二,由于引用是常量,所以它不能被修改。
接下来,我们继续编写TextureHolder
函数的定义。
# 编写TextureHolder函数定义
现在,我们可以创建一个新的.cpp
文件,用于包含函数定义。这将帮助我们理解这些新类型的函数和变量背后的原理。在解决方案资源管理器中右键单击“源文件”,选择“添加”>“新建项” 。在“添加新项”窗口中,(通过左键单击)突出显示“C++文件(.cpp)”,然后在“名称”字段中输入“TextureHolder.cpp”。最后,点击“添加”按钮。现在我们可以开始编写类的代码了。
添加以下代码,然后进行讨论:
#include "TextureHolder.h"
// Include the "assert feature"
#include <assert.h>
TextureHolder* TextureHolder::m_s_Instance = nullptr;
TextureHolder::TextureHolder()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
}
2
3
4
5
6
7
8
9
10
11
在上述代码中,我们将TextureHolder
类型的指针初始化为nullptr
。在构造函数中,assert(m_s_Instance == nullptr)
用于确保m_s_Instance
等于nullptr
。如果不相等,游戏将停止运行。然后,m_s_Instance = this
将指针赋值为this
实例。现在,思考一下这段代码的执行位置。这段代码在构造函数中,而构造函数是我们从类创建对象实例的方式。所以,实际上我们现在有了一个指向TextureHolder
的指针,它指向其唯一的实例。
将代码的最后一部分添加到TextureHolder.cpp
文件中。这里注释比代码多。添加代码时仔细查看以下代码并阅读注释,之后我们再详细讲解:
Texture& TextureHolder::GetTexture(string const& filename) {
// Get a reference to m_Textures using m s Instance
auto& m = m_s_Instance->m_Textures;
// auto is the equivalent of map<string, Texture>
// Create an iterator to hold a key-value-pair (kvp)
// and search for the required kvp // using the passed in file name
auto keyValuePair = m.find(filename);
// auto is equivalent of map<string, Texture>::iterator
// Did we find a match?
if (keyValuePair != m.end()) {
// Yes
// Return the texture,
// the second part of the kvp, the texture
return keyValuePair->second;
}
else {
// File name not found
// Create a new key value pair using the filename
auto& texture = m[filename];
// Load the texture from file in the usual way
texture.loadFromFile(filename);
// Return the texture to the calling code
return texture;
}
}
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
你可能首先注意到上述代码中的auto
关键字。上一节已经解释过auto
关键字。
如果你想知道被auto
替代的实际类型,可以查看每次使用auto
之后的注释。在Visual Studio中,你还可以将鼠标悬停在auto
关键字上,会出现一个工具提示,显示完整的类型。
在代码开头,我们获取对m_textures
的引用。然后,尝试获取一个指向由传入文件名(filename
)表示的键值对的迭代器。如果找到匹配的键,就通过return keyValuePair->second
返回纹理。否则,我们将纹理添加到map
中,然后返回给调用代码。
不可否认,TextureHolder
类引入了许多新概念(单例、静态函数、常量引用、this
关键字和auto
关键字 )和语法。再加上我们刚刚学习指针和标准模板库(STL),这部分代码可能有点令人生畏。
那么,这一切值得吗?
# TextureHolder类的作用
关键在于,现在有了这个类,我们可以在代码的任何地方随意使用纹理,而无需担心内存不足,也不用担心在特定函数或类中无法访问某个纹理。我们很快就会看到如何使用TextureHolder
类。
# 创建一群僵尸
现在,我们有了TextureHolder
类,确保僵尸纹理易于获取,并且每种纹理仅向图形处理单元(GPU)加载一次。接下来,我们研究如何创建一群僵尸。
我们将把僵尸存储在一个数组中。由于创建和生成一群僵尸的过程涉及相当多的代码行,所以将其抽象为一个单独的函数是个不错的选择。很快我们就会编写CreateHorde
函数,但在此之前,我们当然需要一个Zombie
类。
# 编写Zombie.h文件
创建一个表示僵尸的类,第一步是在头文件中编写成员变量和函数原型。
在解决方案资源管理器中右键单击“头文件”,选择“添加”>“新建项” 。在“添加新项”窗口中,(通过左键单击)突出显示“头文件(.h)”,然后在“名称”字段中输入“Zombie.h”。
将以下代码添加到“Zombie.h”文件中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Zombie
{
private:
// How fast is each zombie type?
const float BLOATER_SPEED = 40;
const float CHASER_SPEED = 80;
const float CRAWLER_SPEED = 20;
// How tough is each zombie type
const float BLOATER_HEALTH = 5;
const float CHASER_HEALTH = 1;
const float CRAWLER_HEALTH = 3;
// Make each zombie vary its speed slightly
const int MAX_VARRIANCE = 30;
const int OFFSET = 101 - MAX_VARRIANCE;
// Where is this zombie?
Vector2f m_Position;
// A sprite for the zombie
Sprite m_Sprite;
// How fast can this one run/crawl?
float m_Speed;
// How much health has it got?
float m_Health;
// Is it still alive?
bool m_Alive;
// Public prototypes go here
};
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
上述代码声明了Zombie
类的所有私有成员变量。在代码开头,我们有三个常量变量,用于表示每种类型僵尸的速度:行动非常缓慢的爬行者(Crawler)、速度稍快的膨胀者(Bloater)和速度较快的追逐者(Chaser)。我们可以尝试调整这三个常量的值,以平衡游戏的难度。这里还值得一提的是,这三个值仅作为每种僵尸类型速度的起始值。在本章后面的内容中我们会看到,每种僵尸的速度都会在这些值的基础上有小幅度的变化。这可以防止同类型的僵尸在追逐玩家时聚集在一起。
接下来的三个常量确定了每种僵尸类型的生命值。注意,膨胀者是最难对付的,其次是爬行者。为了平衡游戏,追逐者僵尸将是最容易被消灭的。
接下来,我们还有另外两个常量MAX_VARRIANCE
和OFFSET
。它们将帮助我们确定每只僵尸的个体速度。在编写Zombie.cpp
文件时,我们将详细了解其具体用法。
在这些常量之后,我们声明了一系列变量,这些变量应该看起来很眼熟,因为在Player
类中我们有非常相似的变量。m_Position
、m_Sprite
、m_Speed
和m_Health
变量顾名思义,分别表示僵尸对象的位置、精灵、速度和生命值。
最后,在上述代码中,我们声明了一个名为m_Alive
的布尔变量。当僵尸存活并进行攻击时,它的值为true
;当僵尸的生命值降为0,变成背景上的一滩血迹时,它的值为false
。
现在,我们可以完善“Zombie.h”文件了。添加以下代码中突出显示的函数原型,然后再进行讨论:
// Is it still alive?
bool m_Alive;
// Public prototypes go here
public:
// Handle when a bullet hits a zombie
bool hit();
// Find out if the zombie is alive
bool isAlive () ;
// Spawn a new zombie
void spawn (float startX, float startY, int type, int seed) ;
// Return a rectangle that is the position in the world
FloatRect getPosition();
// Get a copy of the sprite to draw
Sprite getSprite();
// Update the zombie each frame
void update(float elapsedTime, Vector2f playerLocation);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在上述代码中,有一个hit
函数,每当僵尸被子弹击中时,我们都可以调用这个函数。该函数可以执行必要的操作,比如减少僵尸的生命值(降低m_Health
的值)或杀死它(将m_Alive
设置为false
)。
isAlive
函数返回一个布尔值,让调用代码知道僵尸是活着还是死了。我们不想对玩家踩到血迹的情况进行碰撞检测或扣除玩家生命值。
spawn
函数接受起始位置、类型(爬行者、膨胀者或追逐者,用int
表示),以及下一节中用于随机数生成的种子。
和Player
类一样,Zombie
类也有getPosition
和getSprite
函数,分别用于获取表示僵尸所占空间的矩形和每一帧中要绘制的精灵。
上述代码中的最后一个原型是update
函数。我们可能已经猜到它会接收自上一帧以来经过的时间,但注意它还接收一个名为playerLocation
的Vector2f
向量。这个向量实际上就是玩家中心的精确坐标。我们很快就会看到如何利用这个向量让僵尸追逐玩家。
现在,我们可以在.cpp
文件中编写函数定义了。
# 编写Zombie.cpp文件
接下来,我们将为Zombie
类编写功能,即函数定义。
创建一个新的.cpp
文件来存放这些函数定义。在“解决方案资源管理器”中右键单击“源文件”,选择“添加”|“新建项” 。在“添加新项”窗口中,(通过左键单击)选中“C++文件(.cpp)”,然后在“名称”字段中输入Zombie.cpp
。最后,点击“添加”按钮。现在我们准备为这个类编写代码。
在Zombie.cpp
文件中添加以下代码:
#include "zombie.h"
#include "TextureHolder.h"
#include <cstdlib>
#include <ctime>
using namespace std;
2
3
4
5
6
首先,我们添加必要的包含指令,然后使用using namespace std
。你可能还记得,有些时候我们在对象声明前加上std::
前缀。这条using
指令意味着在这个文件的代码中,我们不需要这么做。
现在,添加以下代码,这是spawn
函数的定义。添加代码后仔细研究,然后我们再进行讨论:
void Zombie::spawn(float startX, float startY, int type, int seed) {
switch (type) {
case 0:
// Bloater
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/bloater.png"));
m_Speed = BLOATER_SPEED;
m_Health = BLOATER_HEALTH;
break;
case 1:
// Chaser
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/chaser.png"));
m_Speed = CHASER_SPEED;
m_Health = CHASER_HEALTH;
break;
case 2:
// Crawler
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/crawler.png"));
m_Speed = CRAWLER_SPEED;
m_Health = CRAWLER_HEALTH;
break;
}
// 修改速度,使僵尸具有独特性
// 每个僵尸都是独一无二的。创建一个速度修正值
srand((int)time(0) * seed);
// 在80到100之间
float modifier = (rand() % MAX_VARRIANCE) + OFFSET;
// 表示为1的分数形式
modifier /= 100;
// 现在在0.7到1之间
m_Speed *= modifier;
// 初始化位置
m_Position.x = startX;
m_Position.y = startY;
// 将其原点设置为中心
m_Sprite.setOrigin(25, 25);
// 设置其位置
m_Sprite.setPosition(m_Position);
}
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
该函数做的第一件事是根据作为参数传入的int
值来切换执行路径。在switch
代码块中,每种类型的僵尸都有一个对应的case
分支。根据僵尸的类型,合适的纹理、速度和生命值会被初始化为相关的成员变量。
我们本可以使用枚举来表示不同类型的僵尸。项目完成后,你可以自行升级代码。
这里值得注意的是,我们使用静态的TextureHolder::GetTexture
函数来分配纹理。这意味着无论我们生成多少僵尸,GPU内存中最多只会有三种纹理。
接下来的三行代码(不包括注释)执行以下操作:
- 使用作为参数传入的
seed
变量为随机数生成器设置种子。 - 使用
rand
函数以及MAX_VARRIANCE
和OFFSET
常量声明并初始化modifier
变量。结果是一个介于0到1之间的分数,可用于使每个僵尸的速度独一无二。我们这么做是为了避免僵尸过于聚集在一起。 - 现在我们将
m_Speed
乘以modifier
,这样得到的僵尸速度会在为该类型僵尸定义的速度常量的MAX_VARRIANCE
百分比范围内。
确定速度后,我们将startX
和startY
中传入的位置分别赋给m_Position.x
和m_Position.y
。
前面代码清单的最后两行将精灵的原点设置为中心,并使用m_Position
向量来设置精灵的位置。
现在,在Zombie.cpp
文件中添加hit
函数的以下代码:
bool Zombie::hit() {
m_Health--;
if (m_Health < 0) {
// 死亡
m_Alive = false;
m_Sprite.setTexture(TextureHolder::GetTexture(
"graphics/blood.png"));
return true;
}
// 受伤但未死亡
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
hit
函数非常简单:将m_Health
减1,然后检查m_Health
是否小于0。
如果小于0,就将m_Alive
设置为false
,把僵尸的纹理换成溅血的纹理,并向调用代码返回true
,以便调用代码知道僵尸现在已死亡。如果僵尸存活下来,hit
函数返回false
。
添加以下三个取值函数,它们只是将一个值返回给调用代码:
bool Zombie::isAlive() {
return m_Alive;
}
FloatRect Zombie::getPosition() {
return m_Sprite.getGlobalBounds();
}
Sprite Zombie::getSprite() {
return m_Sprite;
}
2
3
4
5
6
7
8
9
10
11
前面这三个函数相当直观,可能getPosition
函数除外。getPosition
函数使用m_Sprite.getLocalBounds
函数获取FloatRect
实例,然后将其返回给调用代码。
最后,对于Zombie
类,我们需要添加update
函数的代码。添加代码时仔细查看以下内容,之后我们会详细讲解:
void Zombie::update(float elapsedTime, Vector2f playerLocation)
{
float playerX = playerLocation.x;
float playerY = playerLocation.y;
// 更新僵尸的位置变量
if (playerX > m_Position.x) {
m_Position.x = m_Position.x + m_Speed * elapsedTime;
}
if (playerY > m_Position.y) {
m_Position.y = m_Position.y + m_Speed * elapsedTime;
}
if (playerX < m_Position.x) {
m_Position.x = m_Position.x - m_Speed * elapsedTime;
}
if (playerY < m_Position.y) {
m_Position.y = m_Position.y - m_Speed * elapsedTime;
}
// 移动精灵
m_Sprite.setPosition(m_Position);
// 使精灵面向正确的方向
float angle = (atan2(playerY - m_Position.y, playerX - m_Position.x)
* 180) / 3.141;
m_Sprite.setRotation(angle);
}
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
在上述代码中,我们将playerLocation.x
和playerLocation.y
复制到名为playerX
和playerY
的局部变量中。
接下来有四个if
语句。它们检查僵尸是在当前玩家位置的左边、右边、上边还是下边。
当这四个if
语句的计算结果为true
时,会使用通常的公式(即速度乘以自上一帧以来的时间,具体代码为m_Speed * elapsedTime
)适当地调整僵尸的m_Position.x
和m_Position.y
值。
在四个if
语句之后,m_Sprite
会被移动到新的位置。
然后,我们使用之前用于玩家和鼠标指针的相同计算方法,但这次是用于僵尸和玩家。这个计算能得出使僵尸面向玩家所需的角度。
最后,对于这个函数以及整个类,我们调用m_Sprite.setRotation
来实际旋转僵尸精灵。记住,在游戏的每一帧中,这个函数都会对每个(存活的)僵尸调用。
但我们想要的是一大群僵尸。
# 使用Zombie类创建一群僵尸
既然我们有了一个可以创建会活动、会攻击且能被杀死的僵尸的类,现在我们想要生成一大群僵尸。
为了实现这一点,我们将编写一个单独的函数,并使用指针,这样我们就可以引用在main
函数中声明但在不同作用域中配置的僵尸群。
在Visual Studio中打开ZombieArena.h
文件,添加以下高亮显示的代码行:
#pragma once
#include "Zombie.h"
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
Zombie* createHorde (int numZombies, IntRect arena);
2
3
4
5
6
7
8
9
现在有了函数原型,我们可以编写函数定义了。
创建一个新的.cpp
文件来存放函数定义。在“解决方案资源管理器”中右键单击“源文件”,选择“添加”|“新建项” 。在“添加新项”窗口中,(通过左键单击)选中“C++文件(.cpp)”,然后在“名称”字段中输入CreateHorde.cpp
。最后,点击“添加”按钮。
在CreateHorde.cpp
文件中添加以下代码并仔细研究。之后,我们会将其拆分成多个部分进行讨论:
#include "ZombieArena.h"
#include "Zombie.h"
Zombie* createHorde(int numZombies, IntRect arena) {
Zombie* zombies = new Zombie[numZombies];
int maxY = arena.height - 20;
int minY = arena.top + 20;
int maxX = arena.width - 20;
int minX = arena.left + 20;
for (int i = 0; i < numZombies; i++) {
// 僵尸应该在哪一边生成
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
switch (side) {
case 0:
// 左边
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// 右边
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// 上边
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// 下边
x = (rand() % maxX) + minX;
y = maxY;
break;
}
// Bloater、Crawler或Runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// 在数组中生成新的僵尸
zombies[i].spawn(x, y, type, i);
}
return zombies;
}
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
让我们逐部分再次查看前面的所有代码。首先,我们添加了现在已经很熟悉的包含指令:
#include "ZombieArena.h"
#include "Zombie.h"
2
接下来是函数签名。注意,该函数必须返回一个指向Zombie
对象的指针。我们将创建一个Zombie
对象数组。创建完僵尸群后,我们将返回这个数组。当我们返回数组时,实际上返回的是数组第一个元素的地址。正如我们在上一章学到的,这和指针是一回事。函数签名还表明我们有两个参数。第一个参数numZombies
是当前这群僵尸所需的数量,第二个参数arena
是一个IntRect
对象,它保存了当前创建这群僵尸所在竞技场的大小。
在函数签名之后,我们声明了一个指向Zombie
类型的指针zombies
,并用在堆上动态分配的数组第一个元素的内存地址对其进行初始化:
Zombie* createHorde(int numZombies, IntRect arena) {
Zombie* zombies = new Zombie[numZombies];
2
代码的下一部分只是将竞技场的边界值复制到maxY
、minY
、maxX
和minX
中。我们从右边和底部减去20像素,同时在顶部和左边加上20像素。我们使用这四个局部变量来帮助定位每个僵尸。进行20像素的调整是为了防止僵尸出现在墙壁上。
int maxY = arena.height - 20;
int minY = arena.top + 20;
int maxX = arena.width - 20;
int minX = arena.left + 20;
2
3
4
现在,我们进入一个for
循环,该循环将遍历zombies
数组中从0到numZombies
的每个Zombie
对象:
for (int i = 0; i < numZombies; i++)
在for
循环内部,代码做的第一件事是为随机数生成器设置种子,然后生成一个介于0到3之间的随机数。这个数字存储在side
变量中。我们将使用side
变量来决定僵尸是在竞技场的左边、上边、右边还是下边生成。我们还声明了两个int
变量x
和y
。这两个变量将临时保存当前僵尸实际的水平和垂直坐标:
// 僵尸应该在哪一边生成
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
2
3
4
仍然在for
循环内部,我们有一个带有四个case
语句的switch
代码块。注意,case
语句的值是0、1、2和3,并且switch
语句中的参数是side
。在每个case
代码块内部,我们用一个预定值(minX
、maxX
、minY
或maxY
中的一个)和一个随机生成的值来初始化x
和y
。仔细观察每个预定值和随机值的组合。你会发现它们适合在竞技场的左边、上边、右边或下边随机定位当前僵尸。这样做的效果是每个僵尸都可以在竞技场的外边缘随机生成。
switch (side) {
case 0:
// 左边
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// 右边
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// 上边
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// 下边
x = (rand() % maxX) + minX;
y = maxY;
break;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
还是在for
循环内部,我们再次为随机数生成器设置种子,并生成一个介于0到2之间的随机数。我们将这个数字存储在type
变量中。type
变量将决定当前僵尸是Chaser
、Bloater
还是Crawler
。
确定类型后,我们对zombies
数组中的当前Zombie
对象调用spawn
函数。提醒一下,传入spawn
函数的参数决定了僵尸的起始位置和类型。传入看似随意的i
,是因为它被用作一个唯一的种子,在合适的范围内随机改变僵尸的速度。这可以防止我们的僵尸“挤成一团”,变成一堆而不是一群。
// Bloater、Crawler或Runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// 在数组中生成新的僵尸
zombies[i].spawn(x, y, type, i);
2
3
4
5
6
for
循环会根据numZombies
中的值,为每个僵尸重复执行一次,然后我们返回数组。再次提醒,数组实际上只是其第一个元素的地址。数组是在堆上动态分配的,所以在函数返回后它仍然存在。
return zombies;
现在,我们可以让僵尸“活”起来了。
# 让这群僵尸“活起来”
我们现在有了Zombie
类,还有一个能随机生成尸潮的函数。我们的TextureHolder
单例类能很好地管理仅有的三种纹理,这些纹理可用于几十甚至上千个僵尸。现在,我们可以在main
函数中把尸潮添加到游戏引擎里了。
添加以下突出显示的代码,引入TextureHolder
类。然后,在main
函数内部,初始化TextureHolder
的唯一实例,这样在游戏的任何地方都能使用它:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
using namespace sf;
int main()
{
// Here is the instance of TextureHolder
TextureHolder holder;
// The game will always be in one of four states
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Start with the GAME_OVER state
State state = State::GAME_OVER;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
接下来几行突出显示的代码声明了一些控制变量,用于表示每波开始时僵尸的数量、还未被消灭的僵尸数量,当然,还有一个指向Zombie
的指针zombies
,我们将其初始化为nullptr
:
// Create the background
VertexArray background;
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
// Prepare for a horde of zombies
int numZombies;
int numZombiesAlive;
Zombie* zombies = nullptr ;
2
3
4
5
6
7
8
9
10
// The main game loop
while (window.isOpen())
2
接下来,在嵌套于LEVELING_UP
部分的PLAYING
部分中,添加代码实现以下功能:
- 将
numZombies
初始化为10。随着项目推进,这个值最终会根据当前波数动态变化。 - 释放之前分配的内存。否则,每次新调用
createHorde
函数都会占用越来越多的内存,而之前尸潮占用的内存却不会被释放。 - 然后,调用
createHorde
函数,并将返回的内存地址赋给zombies
。 - 我们还将
zombiesAlive
初始化为numZombies
,因为此时还没有消灭任何僵尸。
添加我们刚刚讨论的以下突出显示的代码:
if (state == State::PLAYING) {
// Prepare the level
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Create a horde of zombies
numZombies = 10;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Reset the clock so there isn't a frame jump
clock.restart();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
现在,在ZombieArena.cpp
文件中添加以下突出显示的代码:
/*
****************
UPDATE THE FRAME
****************
*/
if (state == State::PLAYING)
{
// Update the delta time
Time dt = clock.restart();
// Update the total game time
gameTimeTotal += dt;
// Make a decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world coordinates of mainView
mouseWorldPosition = window.mapPixelToCoords( Mouse::getPosition(), mainView);
// Update the player
player.update(dtAsSeconds, Mouse::getPosition());
// Make a note of the players new position
Vector2f playerPosition(player.getCenter());
// Make the view centre around the player
mainView.setCenter(player.getCenter());
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive ())
{
zombies[i].update(dt.asSeconds (), playerPosition);
}
}
}// End updating the scene
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
前面新增的所有代码只是遍历僵尸数组,检查当前僵尸是否存活,如果存活,就使用必要的参数调用其更新函数。
添加以下代码来绘制所有僵尸:
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the background
window.draw(background, &textureBackground);
// Draw the zombies
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
// Draw the player
window.draw(player.getSprite());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
前面的代码遍历所有僵尸,并调用getSprite
函数,以便draw
函数发挥作用。我们不会检查僵尸是否存活,因为即使僵尸死了,我们也想绘制出血花四溅的效果。
在main
函数末尾,我们需要确保释放指针,这不仅是良好的编程习惯,而且通常很有必要。不过,严格来说,这不是必需的,因为游戏即将退出,在执行return 0
语句后,操作系统会回收所有使用过的内存:
}// End of main game loop
// Delete the previously allocated memory (if it exists)
delete[] zombies;
return 0;
}
2
3
4
5
6
你可以运行游戏,看到僵尸在竞技场边缘生成。它们会立刻以各自的速度径直冲向玩家。只是为了好玩,我扩大了竞技场的规模,并把僵尸数量增加到了1000,如下图所示:
图11.1:扩大竞技场规模并增加僵尸数量
这情况看起来可不太妙!
注意,由于我们在第8章“SFML视图——开启僵尸射击游戏”中编写的代码,你还可以使用回车键暂停和恢复尸潮的攻击。
让我们修正一些类仍直接使用Texture
实例的问题,改为使用新的TextureHolder
类。
# 所有纹理都使用TextureHolder类
既然有了TextureHolder
类,我们最好保持一致,用它来加载所有纹理。让我们对现有加载背景精灵表纹理和玩家纹理的代码做一些小改动。
# 修改背景加载纹理的方式
在ZombieArena.cpp
文件中,找到以下代码:
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile ("graphics/background_sheet.png");
2
3
删除前面突出显示的代码,并用以下突出显示的代码替换,这段代码使用了我们新的TextureHolder
类:
// Load the texture for our background vertex array
Texture textureBackground = TextureHolder::GetTexture(
"graphics/background_sheet.png");
2
3
让我们更新Player
类加载纹理的方式。
# 修改Player类加载纹理的方式
在Player.cpp
文件的构造函数中,找到这段代码:
#include "player.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
// !!Watch this space!!
m_Texture.loadFromFile ("graphics/player.png");
m_Sprite.setTexture (m_Texture);
// Set the origin of the sprite to the centre,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
删除前面突出显示的代码,并用以下突出显示的代码替换,这段代码使用了新的TextureHolder
类。此外,添加包含指令,将TextureHolder
头文件添加到该文件中。下面是添加上下文后突出显示的新代码:
#include "player.h"
#include "TextureHolder.h"
Player::Player() {
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
// !!Watch this space!!
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/player.png"));
// Set the origin of the sprite to the centre,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
从现在开始,我们将使用
TextureHolder
类来加载所有纹理。
# 总结
我们构建了TextureHolder
类,用于存放精灵使用的所有纹理,并编写了Zombie
类,可重复使用这个类来创建任意数量的僵尸。
你可能已经注意到,这些僵尸看起来并不怎么危险。它们只是从玩家身体穿过,连个擦伤都不会留下。目前来说,这反而是件好事,因为玩家还没有任何防御手段。
在下一章中,我们将再创建两个类:一个用于弹药和生命值补给道具,另一个用于玩家发射的子弹。完成这些之后,我们将学习如何检测碰撞,这样子弹就能对僵尸造成伤害,玩家也能收集补给道具了。
# 常见问题解答
**问:**你能再给我讲讲new
关键字和内存泄漏的问题吗?
**答:**当我们使用new
关键字在自由存储区分配内存时,即使创建内存的函数返回,所有局部变量都消失了,这块内存依然存在。当我们不再使用自由存储区的内存时,必须释放它。所以,如果我们在自由存储区分配了想在函数生命周期之外持续存在的内存,就必须确保保留指向它的指针,否则就会造成内存泄漏。这就好比把所有家当都放在房子里,却忘了自己住哪儿!当我们从createHorde
函数返回zombies
数组时,就像是把接力棒(内存地址)从createHorde
函数传递给了main
函数。这就好像在说:“好了,这是你的尸潮,现在它们归你负责了。”而且,我们可不想让泄漏的僵尸在内存里到处跑!所以,对于动态分配内存的指针,一定要记得调用delete
释放内存。