在本章中,我们将学到很多东西,并在游戏方面做很多工作。首先,我们将学习关于 To.T0 指针的基本 C++ 主题。指针是保存内存地址的变量。通常,指针将保存另一个变量的内存地址。这听起来有点像一个参考,但我们将看到他们是如何更强大,并使用指针来处理不断扩大的僵尸群。
我们还将了解标准模板库(STL),这是一个类集合,允许我们快速轻松地实现常见的数据管理技术。
一旦我们了解了 STL 的基本知识,我们将能够使用这些新知识来管理游戏中的所有纹理,因为如果我们有 1000 个僵尸,我们并不想为每个僵尸都将僵尸图形的副本加载到 GPU 中。
我们还将更深入地研究 OOP,并使用静态函数,这是一个类的函数,可以在没有类实例的情况下调用。同时,我们将看到如何设计一个类以确保只有一个实例可以存在。当我们需要保证代码的不同部分将使用相同的数据时,这非常理想。
在本章中,我们将介绍以下主题:
- 学习指针
- 学习 STL
- 使用静态函数和单例类实现
TextureHolder
类 - 实现指向一群僵尸的指针
- 编辑一些现有代码,为玩家和背景使用
TextureHolder
类
指针可以是学习 C++ 代码时产生挫折的原因。然而,这个概念很简单。
重要提示
指针是一个保存内存地址的变量。
就这样!没什么可担心的。让初学者感到沮丧的可能是我们用来处理指针的代码的语法。我们将逐步介绍使用指针的代码的每一部分。然后你可以开始掌握它们的持续过程。
提示
在本节中,我们将实际了解有关指针的更多信息,而不是本项目所需要的。在下一个项目中,我们将更多地使用指针。尽管如此,我们将只触及这个话题的表面。进一步的研究肯定是值得推荐的,我们将在最后一章讨论更多。
我很少认为记忆事实、数字或语法是最好的学习方法。然而,记住与指针相关的简短但关键的语法可能是值得的。这将确保信息深入我们的大脑,我们永远不会忘记它。然后我们可以讨论为什么我们需要指针,并检查它们与引用的关系。指针类比可能有助于:
提示
如果一个变量是一个 house,它的内容是它所持有的值,那么指针就是 house 的地址。
在上一章中,在讨论引用时,我们了解到,当我们向函数传递值或从函数返回值时,实际上我们正在创建一个全新的房子,但它与上一章完全相同。我们正在制作传递给函数或传递自函数的值的副本。
此时,指针可能开始听起来有点像引用。那是因为它们有点像引用。然而,指针要灵活得多,功能强大得多,并且有自己独特的用途。这些特殊和独特的用途需要特殊和独特的语法。
有两个主要运算符与指针关联。第一个是操作员的地址:
&
第二个是解引用操作符:
*
现在,我们将研究使用这些运算符和指针的不同方式。
您将注意到的第一件事是操作符的地址与引用操作符的地址相同。为了增加一个有抱负的 C++ 游戏程序员的痛苦,操作员在不同的环境下做不同的事情。从一开始就知道这一点是很有价值的。如果您正盯着一些涉及指针的代码,并且看起来您快要发疯了,请了解以下几点:
提示
你完全清醒!您只需要查看上下文的细节。
现在,你知道,如果有些事情不清楚,不明显,那不是你的错。指针并不清晰,也不明显,但仔细观察上下文会发现发生了什么。
有了这些知识,您需要更加关注指针,而不是之前的语法,以及这两个运算符(的地址和解引用地址),我们现在可以开始看一些真正的指针代码。
提示
继续之前,请确保已记住这两个运算符。
为了声明一个新指针,我们使用了去引用操作符,以及指针将保存其地址的变量类型。在我们进一步讨论指针之前,先看一下以下代码:
// Declare a pointer to hold
// the address of a variable of type int
int* pHealth;
前面的代码声明了一个名为pHealth
的新指针,它可以保存int
类型变量的地址。注意,我说过可以保存int
类型的变量。与其他变量一样,指针也需要用值初始化以正确使用它。
名称pHealth
与其他变量一样,是任意的。
提示
通常的做法是在作为指针的变量名称前加上前缀p
。这样,当我们处理指针时,记忆起来就容易多了,并且可以将它们与常规变量区分开来。
在引用引用运算符周围使用的空白是可选的,因为 C++ 很少关心语法中的空格。但是,建议使用它,因为它有助于可读性。看下面三行代码,它们都做相同的事情。
在上一个示例中,我们刚刚看到了以下格式,类型旁边有解引用操作符:
int* pHealth;
以下代码显示取消引用运算符任一侧的空白:
int * pHealth;
以下代码显示指针名称旁边的取消引用运算符:
int *pHealth;
了解这些可能性是值得的,这样当您阅读代码时,也许在 web 上,您将了解它们都是相同的。在本书中,我们将始终在类型旁边使用第一个选项和解引用操作符。
就像正则变量只能成功地包含适当类型的数据一样,指针应该只包含适当类型的变量的地址。
指向int
类型的指针不应包含String
、Zombie
、Player
、Sprite
、float
或除int
之外的任何其他类型的地址。
让我们看看如何初始化指针。
接下来,我们将看到如何将变量的地址转换成指针。请看下面的代码:
// A regular int variable called health
int health = 5;
// Declare a pointer to hold the address of a variable of type int
int* pHealth;
// Initialize pHealth to hold the address of health,
// using the "address of" operator
pHealth = &health;
在前面的代码中,我们声明了一个名为health
的int
变量,并将其初始化为5
。虽然我们以前从未讨论过这个变量,但它一定在计算机内存中的某个地方,这是有道理的。它必须有一个内存地址。
我们可以使用操作员的地址访问此地址。仔细查看前面代码的最后一行。我们用地址health
初始化pHealth
,如下所示:
pHealth = &health;
我们的pHealth
指针现在持有常规int
、health
的地址。
提示
在 C++ 术语中,我们说,ORT T0 指向点 T1。
我们可以通过将pHealth
传递给函数来使用它,这样函数就可以处理health
,就像我们处理引用一样。
如果这就是我们要做的,那么我们就没有理由去做指针了,让我们看看重新初始化它们。
与引用不同,指针可以重新初始化以指向不同的地址。请看以下代码:
// A regular int variable called health
int health = 5;
int score = 0;
// Declare a pointer to hold the address of a variable of type int
int* pHealth;
// Initialize pHealth to hold the address of health
pHealth = &health;
// Re-initialize pHealth to hold the address of score
pHealth = &score;
现在,pHealth
指向int
变量score
。
当然,我们的指针的名称pHealth
现在是不明确的,可能应该被称为pIntPointer
。这里要理解的关键是我们可以进行重新分配。
在这个阶段,除了简单地指向(保存内存地址)之外,我们实际上没有使用指针。让我们看看如何访问指针指向的地址中存储的值。这将使它们真正有用。
我们知道指针在内存中保存地址。如果我们在游戏中输出这个地址,也许在我们的 HUD 中,在它被声明和初始化之后,它可能看起来像这样:9876。
它只是一个值–一个表示内存中地址的值。在不同的操作系统和硬件类型上,这些值的范围会有所不同。在本书的上下文中,我们永远不需要直接操纵地址。我们只关心指向的地址中存储的值是什么。
变量使用的实际地址是在游戏执行时(运行时)确定的,因此在编写游戏代码时,无法知道变量的地址以及指针中存储的值。
我们可以使用解引用运算符访问指针指向的地址处存储的值:
*
下面的代码使用指针直接操作一些变量。尝试并遵循以下步骤,然后我们将完成:
提示
警告下面的代码毫无意义(双关语)。它只是演示如何使用指针。
// Some regular int variables
int score = 0;
int hiScore = 10;
// Declare 2 pointers to hold the addresses of int
int* pIntPointer1;
int* pIntPointer2;
// Initialize pIntPointer1 to hold the address of score
pIntPointer1 = &score;
// Initialize pIntPointer2 to hold the address of hiScore
pIntPointer2 = &hiScore;
// Add 10 to score directly
score += 10;
// Score now equals 10
// Add 10 to score using pIntPointer1
*pIntPointer1 += 10;
// score now equals 20\. A new high score
// Assign the new hi score to hiScore using only pointers
*pIntPointer2 = *pIntPointer1;
// hiScore and score both equal 20
在前面的代码中,我们声明了两个int
变量score
和hiScore
。然后分别用值 0 和 10 初始化它们。接下来,我们声明两个指向int
的指针。这些是pIntPointer1
和pIntPointer2
。我们初始化它们的步骤与声明它们分别保存(指向)score
和hiScore
变量的地址相同。
接下来,我们以通常的方式将 10 添加到score``score += 10
。然后,我们可以看到,通过对指针使用解引用运算符,我们可以访问存储在指针指向的地址处的值。以下代码更改了由pIntPointer1
指向的变量存储的值:
// Add 10 to score using pIntPointer1
*pIntPointer1 += 10;
// score now equals 20, A new high score
前面代码的最后一部分取消引用两个指针,将pIntPointer1
指向的值指定为pIntPointer2
指向的值:
// Assign the new hi-score to hiScore with only pointers
*pIntPointer2 = *pIntPointer1;
// hiScore and score both equal 20
score
和hiScore
现在都等于 20。
我们可以用指针做更多的事情。以下是我们可以做的一些有用的事情。
到目前为止,我们看到的所有指针都指向内存地址,这些地址的作用域仅限于创建它们的函数。因此,如果我们声明并初始化指向局部变量的指针,当函数返回时,指针、局部变量和内存地址都将消失。它们超出了范围。
到目前为止,我们一直在使用固定数量的内存,这是在执行游戏之前决定的。此外,我们一直使用的内存是由操作系统控制的,当我们调用函数并从函数返回时,变量会丢失和创建。我们需要的是一种使用内存的方法,在我们完成之前,它总是在范围内。我们希望能够访问我们可以称之为自己的并负责的内存。
当我们声明变量(包括指针)时,它们位于称为堆栈的内存区域中。还有另一个内存区域,虽然由操作系统分配和控制,但可以在运行时分配。另一个内存区域称为空闲存储,有时称为堆。
提示
堆上的内存没有特定函数的作用域。从函数返回不会删除堆上的内存。
这给了我们巨大的力量。通过对内存的访问,我们可以使用大量的对象来规划游戏,而内存的访问仅受我们运行游戏的计算机资源的限制。在我们的例子中,我们想要一大群僵尸。然而,正如蜘蛛侠的叔叔毫不犹豫地提醒我们的那样,“强大的力量带来巨大的责任。”
让我们看看如何使用指针来利用空闲存储上的内存,以及如何在使用完内存后将其释放回操作系统。
要创建指向堆上某个值的指针,我们需要一个指针:
int* pToInt = nullptr;
在前一行代码中,我们以与前面相同的方式声明指针,但由于我们没有将其初始化为指向变量,因此我们将其初始化为nullptr
。我们这样做是因为这是良好的做法。当你甚至不知道指针指向的时候,考虑撤销指针(在它指向的地址上改变一个值)。这相当于去射击场,蒙住某人的眼睛,让他们旋转,然后告诉他们射击。通过将指针指向 nothing(nullptr
),我们不会对其造成任何伤害。
当我们准备在空闲存储器上请求内存时,我们使用new
关键字,如下代码行所示:
pToInt = new int;
pToInt
现在保存空闲存储空间的内存地址,其大小正好适合保存int
值。
提示
程序结束时,将返回任何分配的内存。然而,重要的是要认识到,除非我们释放内存,否则内存永远不会被释放(在游戏执行过程中)。如果我们继续从免费商店获取内存而不归还,最终它将耗尽,游戏将崩溃。
我们不太可能偶尔从免费存储中取出int
大小的块来耗尽内存。但是如果我们的程序有一个请求内存的函数或循环,并且这个函数或循环在整个游戏中定期执行,那么最终游戏会变慢,然后崩溃。此外,如果我们在免费商店中分配了很多对象,并且没有正确地管理它们,那么这种情况很快就会发生。
以下代码行将pToInt
先前指向的空闲存储上的内存交回(删除):
delete pToInt;
现在,pToInt
之前指出的记忆不再是我们可以随心所欲的记忆;我们必须采取预防措施。虽然内存已经返回到操作系统,pToInt
仍然保存着这个内存的地址,它不再属于我们。
以下代码行确保pToInt
不能用于尝试操作或访问此内存:
pToInt = nullptr;
提示
如果指针指向无效的地址,则称为野生或悬挂指针。如果您尝试取消对悬空指针的引用,并且幸运的话,游戏将崩溃,并且您将获得内存访问冲突错误。如果你运气不好,你将创建一个难以发现的 bug。此外,如果我们在空闲存储上使用的内存将在函数的生命周期之外持续存在,那么我们必须确保保留一个指向它的指针,否则将导致内存泄漏。
现在,我们可以声明指针并将它们指向空闲存储上新分配的内存。我们可以通过解引用操作和访问它们指向的内存。我们还可以在使用完内存后将其返回到空闲存储区,并且我们知道如何避免指针悬空。
让我们看看指针的更多优点。
为了将指针传递给函数,我们需要编写一个在原型中具有指针的函数,如以下代码所示:
void myFunction(int *pInt)
{
// Dereference and increment the value stored
// at the address pointed to by the pointer
*pInt ++
return;
}
前面的函数只是取消对指针的引用,并将 1 添加到存储在指向地址的值中。
现在,我们可以使用该函数显式地将变量地址或另一个指针传递给变量:
int someInt = 10;
int* pToInt = &someInt;
myFunction(&someInt);
// someInt now equals 11
myFunction(pToInt);
// someInt now equals 12
如前一段代码所示,在函数中,我们正在从调用代码中操作变量,并且可以使用变量的地址或指向该变量的指针来操作变量,因为这两个操作是相同的。
指针还可以指向类的实例。
指针不仅仅用于正则变量。我们还可以声明指向用户定义类型(如类)的指针。这就是我们将如何声明指向Player
类型对象的指针:
Player player;
Player* pPlayer = &Player;
我们甚至可以直接从指针访问Player
对象的成员函数,如下代码所示:
// Call a member function of the player class
pPlayer->moveLeft()
请注意微妙但至关重要的区别:使用指向对象的指针而不是直接使用->操作符访问函数。在这个项目中,我们不需要使用指向对象的指针,但在使用之前,我们将更仔细地研究它们,这将在下一个项目中进行。
在我们谈论一些全新的东西之前,让我们再看一个新的指针主题。
数组和指针有一些共同点。数组的名称是内存地址。更具体地说,数组的名称是该数组中第一个元素的内存地址。另外,数组名指向数组的第一个元素。理解这一点的最佳方法是继续阅读并查看以下示例。
我们可以创建指向数组持有的类型的指针,然后使用与数组完全相同的语法以相同的方式使用指针:
// Declare an array of ints
int arrayOfInts[100];
// Declare a pointer to int and initialize it
// with the address of the first
// element of the array, arrayOfInts
int* pToIntArray = arrayOfInts;
// Use pToIntArray just as you would arrayOfInts
arrayOfInts[0] = 999;
// First element of arrayOfInts now equals 999
pToIntArray[0] = 0;
// First element of arrayOfInts now equals 0
这还意味着,具有接受指针的原型的函数也接受指针指向的类型的数组。当我们建造越来越多的僵尸群时,我们将利用这一事实。
提示
关于指针和引用之间的关系,编译器在实现引用时实际上使用指针。这意味着引用只是一个方便的工具(在“引擎盖下”使用指针)。您可以将参考视为一个自动变速箱,它可以很好地方便在城镇中驾驶,而指针是一个手动变速箱-更复杂,但如果使用正确,它们可以提供更好的结果/性能/灵活性。
指针有时有点烦琐。事实上,我们对指针的讨论只是对这个主题的介绍。与它们相处融洽的唯一方法就是尽可能多地使用它们。为了完成此项目,您需要了解指针的所有内容如下:
-
指针是存储内存地址的变量。
-
我们可以将指针传递给函数,以便在被调用函数内直接操作调用函数作用域中的值。
-
数组名保存第一个元素的内存地址。我们可以将这个地址作为指针传递,因为它就是这样。
-
We can use pointers to point to memory on the free store. This means we can dynamically allocate large amounts of memory while the game is running.
提示
还有更多的方法可以使用指针。一旦我们习惯了使用常规指针,我们将在最终项目中学习智能指针。
在我们再次开始编写僵尸竞技场项目之前,还有一个主题需要讨论。
**标准模板库(STL)**是数据容器的集合,以及处理我们放入这些容器中的数据的方法。如果我们想更具体,它是一种存储和操作不同类型的 C++ 变量和类的方法。
我们可以将不同的容器看作是定制的和更高级的阵列。STL 是 C++ 的一部分。它不是像 SFML 那样需要设置的可选内容。
STL 是 C++ 的一部分,因为它的容器和操作它们的代码对于许多应用需要使用的许多类型的代码来说是至关重要的。
简而言之,STL 实现了我们和几乎每个 C++ 程序员几乎都需要的代码,至少在某个时候,并且可能是非常规则的。
如果我们要编写自己的代码来包含和管理数据,那么我们不可能像编写 STL 的人那样高效地编写代码。
因此,通过使用 STL,我们可以保证使用最好的编写代码来管理数据。甚至 SFML 也使用 STL。例如,在引擎盖下,VertexArray
类使用 STL。
我们需要做的就是从可用的容器中选择正确的容器类型。通过 STL 可用的容器类型包括:
-
向量:这就像一个带助推器的阵列。它处理动态调整大小、排序和搜索。这可能是最有用的容器。
-
列表:允许数据排序的容器。
-
映射:允许用户以键/值对的形式存储数据的关联容器。在这里,一条数据是找到另一条数据的“关键”。地图也可以增长和收缩,也可以搜索。
-
Set: A container that guarantees that every element is unique.
重要提示
有关 STL 容器类型的完整列表、它们的不同用途和说明,请查看以下链接:http://www.tutorialspoint.com/cplusplus/cpp_stl_tutorial.htm 。
在僵尸竞技场游戏中,我们将使用地图。
提示
如果您想了解 STL 为我们节省的复杂性,那么请看一下本教程,它实现了列表所能实现的功能。请注意,本教程只实现了列表的最简单的裸体实现:http://www.sanfoundry.com/cpp-program-implement-single-linked-list/ 。
我们可以很容易地看到,如果我们探索 STL,我们将节省大量的时间并最终得到一个更好的游戏。让我们更仔细地看看如何使用一个 AutoT0-实例,然后我们将看到它将如何在僵尸竞技场游戏中对我们有用。
映射是一个可动态调整大小的容器。我们可以轻松地添加和删除元素。与 STL 中的其他容器相比,map
类的特殊之处在于我们访问其中数据的方式。
map
实例中的数据成对存储。考虑一个登录到帐户的情况,可能有用户名和密码。地图非常适合查找用户名,然后检查相关密码的值。
一张地图也正好可以显示账户名称和编号,或者公司名称和股价。
注意,当我们使用 STL 中的map
时,我们决定形成键值对的值的类型。值可以是string
实例和int
实例,如账号;string
实例及用户名、密码等string
实例;或用户定义的类型,如对象。
下面是一些真实的代码,让我们熟悉map
。
这就是我们如何申报map
:
map<string, int> accounts;
前一行代码声明了一个名为accounts
的新map
,它有一个string
对象的键,每个对象都将引用一个int
值。
我们现在可以存储引用int
类型值的string
类型的键值对。我们将看看下一步如何做到这一点。
让我们继续向帐户添加一个键值对:
accounts["John"] = 1234567;
现在,地图中有一个条目可以使用 John 的密钥访问。以下代码向 accounts map 添加了另外两个条目:
accounts["Smit"] = 7654321;
accounts["Larissa"] = 8866772;
我们的地图上有三个条目。让我们看看如何访问帐号。
我们将以与添加数据相同的方式访问数据:使用密钥。例如,我们可以将Smit
键存储的值分配给新的int``accountNumber
,如下所示:
int accountNumber = accounts["Smit"];
int
变量accountNumber
现在存储值7654321
。我们可以对map
实例中存储的值执行任何我们可以对该类型执行的操作。
从地图中提取价值也很简单。以下代码行删除键John
及其关联值:
accounts.erase("John");
让我们看看我们可以用map
做的更多事情。
我们可能想知道地图中有多少个键值对。下面的代码行就是这样做的:
int size = accounts.size();
int
变量size
现在保存2
的值。这是因为accounts
保存了 Smit 和 Larissa 的值,因为我们删除了 John。
map
最相关的特性是它能够使用键查找值。我们可以测试特定密钥是否存在,如下所示:
if(accounts.find("John") != accounts.end())
{
// This code won't run because John was erased
}
if(accounts.find("Smit") != accounts.end())
{
// This code will run because Smit is in the map
}
在前面的代码中,!= accounts.end
值用于确定键何时存在或不存在。如果搜索到的键在map
中不存在,则accounts.end
将是if
语句的结果。
让我们看看如何通过循环映射来测试或使用映射中的所有值。
我们已经了解了如何使用for
循环来循环/迭代数组的所有值。但是,如果我们想对地图做这样的事情呢?
下面的代码显示了我们如何循环遍历帐户map
的每个键值对,并向每个帐号添加一个键值对:
for (map<string,int>::iterator it = accounts.begin();
it != accounts.end();
++ it)
{
it->second += 1;
}
for
循环的条件可能是前面代码中最有趣的部分。条件的第一部分是最长的部分。map<string,int>::iterator it = accounts.begin()
如果我们将其分解,则更容易理解。
map<string,int>::iterator
是一种类型。我们正在声明一个适用于键值对为string
和int
的map
的iterator
。迭代器的名称为it
。我们将accounts.begin()
返回的值赋给it
。迭代器it
现在保存accounts
映射中的第一个键值对。
for
循环的其余条件如下所示。it != accounts.end()
表示循环将继续,直到到达映射的末尾,++ it
只需进入映射中的下一个键值对,每个键值对都会通过循环。
在for
循环中,it->second
访问键值对的值,+= 1
向该值添加一个。请注意,我们可以使用it->first
访问密钥(这是密钥-值对的第一部分)。
您可能已经注意到,通过映射设置循环的语法非常冗长。C++ 有办法减少这种冗长。
for
循环条件下的代码非常冗长——尤其是在map<string,int>::iterator
方面。C++ 提供了一个巧妙的方法来减少冗长的 Type T2AER 关键字。使用auto
关键字,我们可以改进前面的代码:
for (auto it = accounts.begin(); it != accounts.end(); ++ it)
{
it->second += 1;
}
auto
关键字指示编译器自动为我们推断类型。这对于我们编写的下一个类特别有用。
正如我们在本书中所涵盖的几乎所有 C++ 概念一样,STL 是一个巨大的话题。整本书只涵盖 STL。然而,在这一点上,我们已经知道了足够的信息来构建一个使用 STLmap
存储 SFMLTexture
对象的类。然后,我们可以使用文件名作为键值对的键来检索/加载纹理。
为什么我们会达到这种额外的复杂程度,而不是像目前一样继续使用Texture
类,这一点在我们继续的过程中会变得很明显。
成千上万的僵尸代表着一个新的挑战。加载、存储和操作三种不同僵尸纹理的数千份副本不仅会占用大量内存,还会占用大量处理能力。我们将创建一个新类型的类来克服这个问题,并允许我们只存储每个纹理中的一个。
我们还将以这样一种方式对该类进行编码,即该类只能有一个实例。这种类型的类称为单例。
提示
单件是一种设计模式。设计模式是一种构造我们的代码的方法,它被证明是有效的。
此外,我们还将对该类进行编码,以便它可以直接通过类名在游戏代码中的任何位置使用,而无需访问实例。
让我们创建一个新的头文件。在解决方案资源管理器中右键点击头文件,选择添加【新增项目】。。。。在添加新项目窗口中,高亮显示头文件(.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
在前面的代码中,请注意我们有一个来自 STL 的针对map
的include
指令。我们声明了一个map
实例,该实例包含string
类型和 SFMLTexture
类型,以及键值对。map
被称为m_Textures
。
在前面的代码中,这一行紧随其后:
static TextureHolder* m_s_Instance;
前一行代码非常有趣。我们正在声明一个静态指针,指向名为m_s_Instance
的TextureHolder
类型的对象。这意味着TextureHolder
类有一个与自身类型相同的对象。不仅如此,因为它是静态的,所以可以通过类本身使用它,而不需要类的实例。当我们编写相关的.cpp
文件时,我们将看到如何使用它。
在类的public
部分,我们有构造函数的原型TextureHolder
。构造函数不接受参数,并且通常没有返回类型。这与默认构造函数相同。我们将用一个定义覆盖默认构造函数,该定义使我们的单例按我们希望的方式工作。
我们还有另一个函数名为GetTexture
。让我们再次查看签名并准确分析发生了什么:
static Texture& GetTexture(string const& filename);
首先,请注意,该函数返回对Texture
的引用。这意味着GetTexture
将返回一个引用,这是有效的,因为它避免了复制可能是大型图形的内容。另外,请注意,函数被声明为static
。这意味着该函数可以在没有类实例的情况下使用。该函数将string
作为常量引用,作为参数。这有两方面的影响。首先,操作是有效的,其次,因为参考是恒定的,所以不能更改。
现在,我们可以创建一个新的.cpp
文件,该文件将包含函数定义。这将使我们能够看到新类型函数和变量背后的原因。在解决方案资源管理器中右键点击源文件,选择添加【新增项目】。。。。在 ORT T8 中,添加新的条目“OT9”窗口,突出显示(通过左键单击)OutT10E.c++ 文件(.CPP)OUTT11T,然后在 ORT T12.名称 No.Tt13.字段中,键入 No.T1。最后,点击添加按钮。现在,我们已经准备好编写这个类的代码了。
添加以下代码,然后我们可以讨论它:
#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;
}
在前面的代码中,我们将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;
}
}
关于前面的代码,您可能会注意到的第一件事是auto
关键字。auto
关键字已在上一节中解释。
提示
如果您想知道被auto
替换的实际类型是什么,那么请在前面代码中每次使用auto
之后立即查看注释。
在代码的开头,我们得到了对m_textures
的引用。然后,我们尝试获取由传入文件名(filename
表示的键值对的迭代器。如果我们找到匹配的关键点,我们将返回带有return keyValuePair->second
的纹理。否则,我们将纹理添加到贴图中,然后将其返回给调用代码。
诚然,TextureHolder
类引入了许多新概念(单例、static
函数、常量引用、this
和auto
关键字)和语法。除此之外,我们刚刚了解了指针和 STL,本节的代码可能有点让人望而生畏。
那么,这一切值得吗?
关键是,现在我们有了这个类,我们可以在代码中任意使用纹理,而不用担心内存耗尽或访问特定函数或类中的任何纹理。我们很快就会看到如何使用TextureHolder
。
现在,我们装备了TextureHolder
类,以确保我们的僵尸纹理很容易获得,并且只加载到 GPU 一次。然后,我们可以调查创建一个完整的部落。
我们将在阵列中存储僵尸。由于构建和繁殖一大群僵尸的过程涉及到相当多的代码行,因此它是一个很好的抽象到单独函数的候选者。很快,我们将编写CreateHorde
函数,但首先,当然,我们需要一个Zombie
类。
构建类来表示僵尸的第一步是在头文件中编写成员变量和函数原型。
在解决方案资源管理器中右键点击头文件,选择添加【新增项目】。。。。在添加新项目窗口中,高亮显示头文件(.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
};
前面的代码声明了Zombie
类的所有私有成员变量。在前面代码的顶部,我们有三个常量变量来控制每种僵尸的速度:一个非常慢的爬虫,一个稍微快一点的 Bloater 和一个稍微快一点的 Chaser。我们可以尝试这三个常数的值,以帮助平衡游戏的难度水平。这里还值得一提的是,这三个值仅用作每种僵尸类型速度的起始值。正如我们将在本章后面看到的,我们将根据这些值改变每个僵尸的速度一小部分。这可以防止同类僵尸在追逐玩家时聚集在一起。
接下来的三个常量确定每种僵尸类型的运行状况级别。请注意,膨胀者是最难对付的,其次是爬虫。作为一种平衡,追击者僵尸将是最容易被杀死的。
接下来,我们还有两个常数,MAX_VARRIANCE
和OFFSET
。这些将帮助我们确定每个僵尸的速度。我们将在编写Zombie.cpp
文件时看到具体的编码方式。
在这些常量之后,我们声明了一组看起来很熟悉的变量,因为我们的Player
类中有非常相似的变量。m_Position
、m_Sprite
、m_Speed
和m_Health
变量的名称意味着:僵尸对象的位置、精灵、速度和健康状况。
最后,在前面的代码中,我们声明了一个名为m_Alive
的布尔值,当僵尸活着并正在狩猎时,它将为真,但当它的生命值为零时,它将为假,并且它只是在我们原本漂亮的背景上的一点血迹。
现在,我们可以完成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);
};
在前面的代码中,有一个hit
函数,我们可以在每次僵尸被子弹击中时调用它。然后,该函数可以采取必要的步骤,例如从僵尸身上获取生命值(降低m_Health
的值)或杀死僵尸(将m_Alive
设置为 false)。
isAlive
函数返回一个布尔值,让调用代码知道僵尸是活的还是死的。我们不想执行碰撞检测,也不想让玩家在血溅上行走时失去生命。
spawn
函数采用一个起始位置、一个类型(Crawler、Bloater 或 Chaser,由int
表示)以及一个种子,用于我们将在下一节中看到的一些随机数生成。
就像我们在Player
类中所做的一样,Zombie
类具有getPosition
和getSprite
函数,以获得一个矩形,该矩形表示每帧可以绘制的僵尸和精灵所占据的空间。
前面代码中的最后一个原型是update
函数。我们可能已经猜到它将接收到自最后一帧以来经过的时间,但也注意到它接收到一个称为playerLocation
的Vector2f
向量。这个向量实际上就是玩家中心的精确坐标。我们将很快看到如何使用这个向量来追踪玩家。
现在,我们可以在.cpp
文件中对函数定义进行编码。
接下来,我们将编码Zombie
类的功能——函数定义。
创建一个新的.cpp
文件,该文件将包含函数定义。在解决方案资源管理器中右键点击源文件,选择添加【新增项目】。。。。在 ORT T8 中,添加新的条目“OT9”窗口,突出显示(通过左键单击)OutT10E.c++ 文件(.CPP)OUTT11T,然后在 ORT T12.名称 No.Tt13.字段中,键入 No.T1。最后,点击添加按钮。现在,我们已经准备好编写这个类的代码了。
将以下代码添加到Zombie.cpp
文件中:
#include "zombie.h"
#include "TextureHolder.h"
#include <cstdlib>
#include <ctime>
using namespace std;
首先,我们添加必要的 include 指令,然后添加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;
}
// Modify the speed to make the zombie unique
// Every zombie is unique. Create a speed modifier
srand((int)time(0) * seed);
// Somewhere between 80 and 100
float modifier = (rand() % MAX_VARRIANCE) + OFFSET;
// Express this as a fraction of 1
modifier /= 100; // Now equals between .7 and 1
m_Speed *= modifier;
// Initialize its location
m_Position.x = startX;
m_Position.y = startY;
// Set its origin to its center
m_Sprite.setOrigin(25, 25);
// Set its position
m_Sprite.setPosition(m_Position);
}
函数所做的第一件事是基于作为参数传入的int
值的switch
执行路径。在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
向量设置精灵的位置。
现在,将hit
函数的以下代码添加到Zombie.cpp
文件中:
bool Zombie::hit()
{
m_Health--;
if (m_Health < 0)
{
// dead
m_Alive = false;
m_Sprite.setTexture(TextureHolder::GetTexture(
"graphics/blood.png"));
return true;
}
// injured but not dead yet
return false;
}
hit
功能好且简单:将m_Health
减少一,然后检查m_Health
是否低于零。
如果它低于零,那么它将m_Alive
设置为 false,将僵尸的纹理交换为血溅,并将true
返回到调用代码,以便它知道僵尸现在已经死了。如果僵尸幸存下来,命中将返回false
。
添加以下三个 getter 函数,它们只向调用代码返回一个值:
bool Zombie::isAlive()
{
return m_Alive;
}
FloatRect Zombie::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Sprite Zombie::getSprite()
{
return m_Sprite;
}
前面的三个函数都是不言自明的,可能除了getPosition
函数之外,它使用m_Sprite.getLocalBounds
函数获取FloatRect
实例,然后返回给调用代码。
最后,对于Zombie
类,我们需要为update
函数添加代码。仔细看一下下面的代码,然后我们将仔细阅读:
void Zombie::update(float elapsedTime,
Vector2f playerLocation)
{
float playerX = playerLocation.x;
float playerY = playerLocation.y;
// Update the zombie position variables
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;
}
// Move the sprite
m_Sprite.setPosition(m_Position);
// Face the sprite in the correct direction
float angle = (atan2(playerY - m_Position.y,
playerX - m_Position.x)
* 180) / 3.141;
m_Sprite.setRotation(angle);
}
在前面的代码中,我们将playerLocation.x
和playerLocation.y
复制到名为playerX
和playerY
的局部变量中。
接下来,有四个if
语句。他们测试僵尸是否在当前玩家位置的左侧、右侧、上方或下方。这四个if
语句在计算为 true 时,使用通常的公式(即速度乘以自上一帧起的时间)适当调整僵尸的m_Position.x
和m_Position.y
值。更具体地说,代码是 m_Speed * elapsedTime
。
在四个if
语句之后,m_Sprite
被移动到它的新位置。
然后,我们使用之前用于玩家和鼠标指针的相同计算,但这次,我们用于僵尸和玩家。此计算将找到面向玩家的僵尸所需的角度。
最后,对于这个函数和类,我们调用m_Sprite.setRotation
来实际旋转僵尸精灵。请记住,此函数将在游戏的每一帧中为每个活僵尸调用。
但是,我们想要一大群僵尸。
现在,我们有了一个类来创建一个活的、攻击的和可杀死的僵尸,我们想要繁殖一整群僵尸。
为了实现这一点,我们将编写一个单独的函数,并使用一个指针,以便我们可以引用我们的部落,该部落将在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);
现在我们有了一个原型,我们可以编写函数定义了。
创建一个新的.cpp
文件,该文件将包含函数定义。在解决方案资源管理器中右键点击源文件,选择添加【新增项目】。。。。在 ORT T8 中,添加新的条目“OT9”窗口,突出显示(通过左键单击)OutT10E.c++ 文件(.CPP)OUTT11T,然后在 ORT T12.名称 No.Tt13.字段中,键入 No.T1。最后,点击添加按钮。
将以下代码添加到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++)
{
// Which side should the zombie spawn
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
switch (side)
{
case 0:
// left
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// right
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// top
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// bottom
x = (rand() % maxX) + minX;
y = maxY;
break;
}
// Bloater, crawler or runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Spawn the new zombie into the array
zombies[i].spawn(x, y, type, i);
}
return zombies;
}
让我们再看一次前面的所有代码,一小段一小段。首先,我们添加了现在熟悉的include
指令:
#include "ZombieArena.h"
#include "Zombie.h"
接下来是函数签名。请注意,函数必须返回指向Zombie
对象的指针。我们将创建一个Zombie
对象数组。创建完部落后,我们将返回阵列。当我们返回数组时,实际上是返回数组第一个元素的地址。正如我们在本章前面关于指针的部分中所了解的,这与指针是一样的。签名还表明我们有两个参数。第一个,numZombies
是当前部落需要的僵尸数量,第二个,arena
是一个IntRect
,它容纳了创建该部落的当前竞技场的规模。
在函数签名之后,我们声明一个指向名为zombies
的Zombie
类型的指针,并使用数组第一个元素的内存地址对其进行初始化,我们在堆上动态分配该内存地址:
Zombie* createHorde(int numZombies, IntRect arena)
{
Zombie* zombies = new Zombie[numZombies];
代码的下一部分只是将竞技场的末端复制到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;
现在,我们输入一个for
循环,它将从零到numZombies
循环zombies
数组中的每个Zombie
对象:
for (int i = 0; i < numZombies; i++)
在for
循环中,代码要做的第一件事是给随机数生成器种子,然后生成一个介于 0 和 3 之间的随机数。此编号存储在side
变量中。我们将使用side
变量来决定僵尸是在竞技场的左侧、顶部、右侧还是底部产卵。我们还声明了两个int
变量x
和y
。这两个变量将暂时保持当前僵尸的实际水平和垂直坐标:
// Which side should the zombie spawn
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
仍然在for
循环中,我们有一个包含四个case
语句的switch
块。请注意,case
语句用于0
、1
、2
和3
,并且switch
语句中的参数是side
。在每个case
块中,我们使用一个预定值初始化x
和y
,该预定值可以是minX
、maxX
、minY
或maxY
以及一个随机生成的值。仔细观察每个预定值和随机值的组合。您将看到它们适用于在左侧、顶部、右侧或底部随机定位当前僵尸。这样做的效果是,每个僵尸都可以在竞技场外缘的任意位置随机产卵:
switch (side)
{
case 0:
// left
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// right
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// top
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// bottom
x = (rand() % maxX) + minX;
y = maxY;
break;
}
仍然在for
循环中,我们再次为随机数生成器播种,并生成一个介于 0 和 2 之间的随机数。我们将这个数字存储在type
变量中。type
变量将确定当前僵尸是追逐者、Bloater 还是爬虫。
确定类型后,我们对zombies
数组中的当前Zombie
对象调用spawn
函数。作为提醒,发送到spawn
函数的参数决定了僵尸的起始位置和僵尸的类型。显然是任意的i
被传入,因为它被用作唯一的种子,在适当的范围内随机改变僵尸的速度。这就阻止了我们的僵尸“聚在一起”,变成一团而不是一个部落:
// Bloater, crawler or runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Spawn the new zombie into the array
zombies[i].spawn(x, y, type, i);
对于每个僵尸,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;
下面几行突出显示的代码声明了一些控制变量,这些变量用于波形开始时的僵尸数量、仍将被杀死的僵尸数量,当然还有一个名为zombies
的指向Zombie
的指针,我们将其初始化为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;
// The main game loop
while (window.isOpen())
接下来,在嵌套在LEVELING_UP
节中的PLAYING
节中,我们添加了执行以下操作的代码:
- 将
numZombies
初始化为10
。随着项目的进展,这最终将是动态的,并基于当前波数。 - 删除任何先前存在的已分配内存。否则,每次对
createHorde
的新呼叫都会逐渐占用更多内存,但不会释放先前部落的内存。 - 然后调用
createHorde
并将返回的内存地址分配给zombies
。 - 我们也用
numZombies
初始化zombiesAlive
,因为我们现在还没有杀死任何人。
添加我们刚才讨论过的以下突出显示的代码:
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();
}
现在,将以下突出显示的代码添加到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
前面的新代码所做的就是循环遍历僵尸数组,检查当前僵尸是否处于活动状态,如果是,则使用必要的参数调用其update
函数。
添加以下代码以绘制所有僵尸:
/*
**************
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());
}
前面的代码循环遍历所有僵尸,并调用getSprite
函数以允许draw
函数执行其工作。我们不检查僵尸是否还活着,因为即使僵尸已经死了,我们也要吸取血迹。
在 main 函数的末尾,我们需要确保删除指针,因为这是一个很好的实践,而且通常是必不可少的。但是,从技术上讲,这并不是必需的,因为游戏即将退出,操作系统将回收在return 0
语句之后使用的所有内存:
}// End of main game loop
// Delete the previously allocated memory (if it exists)
delete[] zombies;
return 0;
}
你可以运行游戏,看到僵尸在竞技场边缘产卵。他们会立即以不同的速度直奔玩家。只是为了好玩,我增加了竞技场的大小,并将僵尸数量增加到 1000 个,正如您在下面的屏幕截图中所看到的:
这将是一个糟糕的结局!
请注意,由于我们在第 8 章、SFML 视图中编写的代码,您还可以使用回车键暂停并恢复部落的进攻——开始僵尸射击游戏。
让我们修正一些类仍然直接使用Texture
实例的事实,并将其修改为使用新的TextureHolder
类。
既然我们有TextureHolder
类,我们不妨保持一致,并使用它加载所有纹理。让我们对为背景精灵表和玩家加载纹理的现有代码进行一些非常小的修改。
在ZombieArena.cpp
文件中,找到以下代码:
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
删除前面突出显示的代码,并将其替换为以下突出显示的代码,该代码使用我们新的TextureHolder
类:
// Load the texture for our background vertex array
Texture textureBackground = TextureHolder::GetTexture(
"graphics/background_sheet.png");
让我们更新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);
}
删除前面突出显示的代码,并将其替换为以下突出显示的代码,该代码使用我们新的TextureHolder
类。另外,添加include
指令,将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);
}
提示
从现在开始,我们将使用TextureHolder
类加载所有纹理。
在本章中,我们讨论了指针,并讨论了它们是保存特定类型对象的内存地址的变量。随着这本书的进展和指针的力量的显现,这本书的全部意义将开始显露出来。我们还使用了指针来创建一个庞大的僵尸群,可以使用指针访问这些僵尸,结果发现指针与数组的第一个元素是一样的。
我们学习了 STL,尤其是map
课程。我们实现了一个类,该类将存储所有纹理,并提供对它们的访问。
你可能已经注意到僵尸看起来并不是很危险。它们只是在玩家身上漂移而不留下划痕。目前,这是一件好事,因为球员没有办法保护自己。
在下一章中,我们将再创建两个类:一个用于弹药和生命拾取,另一个用于玩家可以射击的子弹。在我们完成这些之后,我们将学习如何检测碰撞,以便子弹和僵尸造成一定的伤害,并且玩家可以收集拾音器。
以下是您可能想到的一些问题:
Q) 指针和引用之间有什么区别?
A) 指针就像带有助推器的引用。指针可以更改为指向不同的变量(内存地址),也可以指向空闲存储上动态分配的内存。
Q) 数组和指针是怎么回事?
A) 数组实际上是指向第一个元素的常量指针。
Q) 你能提醒我关于new
关键字和内存泄漏的事吗?
A) 当我们使用new
关键字在空闲存储上使用内存时,即使创建它的函数已返回且所有局部变量都已消失,它也会持续存在。当我们在空闲存储器上使用完内存后,我们必须释放它。因此,如果我们在空闲存储上使用内存,并且希望在函数的生命周期之后继续使用,那么我们必须确保保留一个指向它的指针,否则我们将泄漏内存。这就像把我们所有的东西都放在家里,然后忘记我们住在哪里!当我们从createHorde
返回zombies
数组时,就像将中继接力棒(内存地址)从createHorde
传递到main
。这就像说,好吧,这是你的僵尸大军——他们现在是你的责任。而且,我们不希望任何泄漏的僵尸在我们的内存中到处跑!所以,我们必须记住调用delete
指向动态分配内存的指针。