第9章 C++引用、精灵表和顶点数组
# 第9章 C++引用、精灵表和顶点数组
在第4章“循环、数组、switch语句、枚举和函数——实现游戏机制”中,我们讨论了作用域的概念。即,在函数或代码内部块中声明的变量,其作用域(即可以被看到或使用的范围)仅限于该函数或代码块。仅凭借我们目前掌握的C++知识,可能会引发一个问题:如果我们需要处理一些在主函数中使用的复杂对象,该怎么办呢?这可能意味着所有代码都得写在主函数里。
在本章中,我们将探究C++引用。借助它,我们能够处理那些原本超出作用域的变量和对象。不仅如此,引用还能避免在函数之间传递大型对象,因为传递大型对象的过程比较耗时。之所以耗时,是因为每次传递时都必须复制变量或对象。
掌握了引用的新知识后,我们将学习SFML库中的VertexArray
类。借助这个类,我们能够构建一个大型图像,它可以将单个图像文件中的多个部分,快速高效地绘制到屏幕上。在本章结束时,我们将利用引用和VertexArray
对象,创建出一个可缩放、随机生成且能滚动的背景。
在本章中,我们将讨论以下内容:
- 理解C++引用
- SFML顶点数组和精灵表
- 创建随机生成的滚动背景
- 使用背景
# 理解C++引用
当我们向函数传递值或从函数返回值时,实际做的就是按值传递/返回。具体过程是,先复制变量所存储的值,然后将复制的值传递给函数使用。
这种方式有两个明显的问题:
- 如果我们希望函数对变量进行永久性修改,这种机制就派不上用场了。
- 在复制值作为参数传递给函数或从函数返回值时,会消耗处理能力和内存。对于简单的
int
类型,甚至像Sprite
(精灵)这样的类型,这种消耗可能微不足道。然而,对于复杂对象,比如整个游戏世界(或背景),复制过程会严重影响游戏性能。
引用就是解决这两个问题的办法。引用是一种特殊类型的变量,它指向另一个变量。下面的示例能帮助你更好地理解:
int numZombies = 100;
int& rNumZombies = numZombies;
2
在上述代码中,我们声明并初始化了一个普通的int
类型变量numZombies
。接着,声明并初始化了一个int
类型引用rNumZombies
。类型后面的引用运算符&
,表明正在声明一个引用。
引用变量名前面的
r
前缀是可选的,但它有助于我们记住正在处理的是引用。
现在,我们有了一个存储值为100
的int
类型变量numZombies
,以及一个指向numZombies
的int
类型引用rNumZombies
。
对numZombies
所做的任何操作,都能通过rNumZombies
体现出来;反之,对rNumZombies
所做的任何操作,实际上都是在对numZombies
进行操作。看看下面这段代码:
int score = 10;
int& rScore = score;
score++;
rScore++;
2
3
4
在这段代码中,我们先声明了一个int
类型变量score
,接着声明了一个指向score
的int
类型引用rScore
。记住,对score
所做的任何操作,rScore
都能感知到;对rScore
所做的操作,实际上就是在对score
进行操作。
所以,当我们像这样对score
进行自增操作时:
score++;
此时,score
变量存储的值变为11
。此外,如果输出rScore
,输出结果也会是11
。接下来的代码是:
rScore++;
现在,score
实际存储的值变为12
,因为对rScore
所做的操作,就是在对score
进行操作。
如果你想知道这背后的原理,下一章讨论指针时会详细说明。简单来说,可以把引用看作存储在计算机内存中的一个位置/地址,而这个内存位置和它所指向的变量存储值的位置是相同的。因此,对引用或变量进行操作,效果完全一样。
目前,更重要的是了解使用引用的原因。使用引用主要有两个原因,前面我们已经提到过,这里再总结一下:
- 在另一个函数中更改/读取变量/对象的值,而这个变量/对象在原本的作用域之外。
- 向函数传递参数或从函数返回值时,无需进行复制(因此效率更高)。
研究下面这段代码,之后我们再进行讨论:
void add(int n1, int n2, int a);
void referenceAdd(int n1, int n2, int& a);
int main()
{
int number1 = 2;
int number2 = 2;
int answer = 0;
add(number1, number2, answer);
// answer equals zero because it is passed as a copy
// Nothing happens to answer in the scope of main
referenceAdd(number1, number2, answer);
// Now answer is 4 because it was passed by reference
// When the referenceAdd function did this:
// answer = num1 + num 2;
// It is actually changing the value stored by answer
return 0;
}
// Here are the two function definitions
// They are exactly the same except that
// the second passes a reference to a
void add(int n1, int n2, int a)
{
a = n1 + n2;
// a now equals 4
// But when the function returns a is lost forever
}
void referenceAdd(int n1, int n2, int& a)
{
a = n1 + n2;
// a now equals 4
// But a is a reference!
// So, it is answer, back in main, that equals 4
}
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
这段代码开头是两个函数add
和referenceAdd
的声明。add
函数接受三个int
类型变量,referenceAdd
函数则接受两个int
类型变量和一个int
类型引用。
调用add
函数并传入number1
、number2
和answer
变量时,会复制这些变量的值,并在add
函数内部操作新的局部变量(即n1
、n2
和a
)。结果,在主函数中的answer
变量的值仍然为0
。
调用referenceAdd
函数时,number1
和number2
依旧按值传递,但answer
是按引用传递。当n1
与n2
相加的值赋给引用a
时,实际上是将这个值赋给了主函数中的answer
变量。
对于这么简单的场景,我们可能确实永远都不需要使用引用。不过,这段代码展示了按引用传递的机制。
现在,我们来总结一下关于引用的知识。
# 总结引用
前面的代码展示了如何通过引用,使用另一个作用域中的代码来修改变量的值。按引用传递不仅非常方便,而且效率极高,因为无需进行复制操作。我们使用int
类型引用的示例可能有点不太直观,因为int
类型的数据量很小,使用引用并没有明显的效率提升。在本章后面的内容中,我们将使用引用来传递整个关卡布局,那时效率提升就会很显著。
使用引用有一个需要注意的地方!在创建引用时,必须将其指向一个变量。这意味着引用的使用并非完全灵活。目前先不用为此担心,下一章我们将进一步探究引用,以及和它相关但更灵活(也稍微复杂一些)的概念,比如指针。
这对于int
类型变量来说影响不大,但对于大型类对象而言,可能就很重要了。在本章后面实现“僵尸竞技场”游戏的滚动背景时,我们就会用到这种方法。
接下来,我们将学习顶点数组和精灵表的知识。
# SFML顶点数组和精灵表
我们差不多准备好实现滚动背景了,现在只需学习SFML顶点数组和精灵表的相关知识。
# 什么是精灵表?
精灵表是指将一组图像,无论是动画帧还是独立图形,都包含在一个图像文件中。仔细看看这张精灵表,它包含四个独立的图像,我们将在“僵尸竞技场”游戏中用它们来绘制背景:
图9.1:精灵表
SFML允许我们像加载本书中其他纹理一样,将精灵表作为常规纹理加载。当我们把多个图像作为单个纹理加载时,GPU(图形处理单元)能更高效地处理。
现代PC即便不使用精灵表,也能处理这四个纹理。不过,学习这些技术还是很有必要的,因为我们开发的游戏对硬件的要求会越来越高。
你也可以把精灵表称为纹理图集。一般来说,精灵表和纹理图集的区别在于,精灵表通常包含某个“事物”(比如角色或背景)的多个帧,而且这些帧的排列通常比较规整,就像我们的精灵表一样。而纹理图集通常包含多个不同事物的纹理,可能涵盖整个关卡甚至整个游戏,其排列可能没那么规整,并且包含不同尺寸的纹理。此外,纹理图集通常会搭配一个文本文件,其中描述了各个纹理的名称、位置和大小,游戏会借助这个文本文件来访问所需的图像。不管你把包含多个图像的图形文件称作什么,将多个图像放在一个文件中,都能加快游戏运行时上传和访问图像的速度。
从精灵表中绘制图像时,我们需要确保引用的是精灵表中所需部分的精确像素坐标,如下所示:
图9.2:精灵表的像素坐标
上图为精灵表的每个部分/图块标注了坐标以及它们在精灵表中的位置。这些坐标被称为纹理坐标。我们将在代码中使用这些纹理坐标,来绘制所需的特定部分。
# 什么是顶点数组?
首先我们得了解:什么是顶点?顶点是单个图形点,即一个坐标,由水平和垂直位置定义。顶点的复数形式是vertices。顶点数组则是一组顶点的集合。
在SFML中,顶点数组里的每个顶点都有颜色,还有一对相关的额外顶点(即一对坐标),称为纹理坐标。纹理坐标表示我们想在精灵表中使用的图像位置。接下来我们会看到,如何通过单个顶点数组,实现图形定位以及选择精灵表中的特定部分在每个位置显示。
SFML的VertexArray
类可以存储不同类型的顶点集,但每个VertexArray
通常只存储一种类型的顶点集。我们会根据具体情况选择合适的顶点集类型。
在电子游戏中,常见的情况包括(但不限于)以下基本类型:
- 点(Point):每个点对应一个顶点。
- 线(Line):每组包含两个顶点,用于定义线段的起点和终点。
- 三角形(Triangle):每个点对应三个顶点。这是最常用的类型(在复杂的3D模型中会用到数千个),也可以成对使用来创建简单的矩形,比如精灵。
- 四边形(Quad):每组包含四个顶点。这是一种方便的方式,用于从精灵表中映射矩形区域。
在这个项目中,我们将使用四边形,因为它恰好满足我们绘制矩形精灵的需求。
# 用图块构建背景
“僵尸竞技场”的背景将由随机排列的方形图像组成。你可以把这种排列想象成地板上的瓷砖。
在这个项目中,我们将使用包含四边形集的顶点数组。每个顶点都是一组四个顶点(即一个四边形)的一部分。每个顶点定义背景图块的一个角,而每个纹理坐标则根据精灵表中的特定图像,存储相应的值。
我们来看一些代码,为后续工作做准备。这不是项目中实际使用的代码,但已经很接近了,能帮助我们在实际实现前,先了解顶点数组的相关知识。
# 构建顶点数组
与创建类的实例时一样,我们声明新对象。以下代码声明了一个VertexArray
(顶点数组)类型的新对象,我们将其命名为background
:
// 创建一个顶点数组
VertexArray background;
2
我们需要让VertexArray
实例知道我们将使用哪种基本图形类型。要记住,点、线、三角形和四边形的顶点数量各不相同。通过将VertexArray
实例设置为保存特定类型,就能够确定每个基本图形的起始位置。在我们的例子中,我们需要四边形。以下代码可以实现这一点:
// 我们使用哪种基本图形类型
background.setPrimitiveType(Quads);
2
与常规的C++数组一样,VertexArray
实例也需要设置为特定大小。VertexArray
类比常规数组更灵活,它允许我们在游戏运行时更改其大小。虽然大小可以在声明时进行配置,但我们的背景需要随着每一波游戏进程而扩展。VertexArray
类通过resize
函数提供了这一功能。以下代码将把我们竞技场的大小设置为10×10个图块大小:
// 设置顶点数组的大小
background.resize(10 * 10 * 4);
2
在上一行代码中,第一个10
是宽度,第二个10
是高度,4
是一个四边形的顶点数量。我们本可以直接传入400
,但像这样展示计算过程能更清楚地表明我们在做什么。在实际编写项目代码时,为了更清晰,我们会更进一步,为计算的每个部分声明变量。
现在我们有了一个VertexArray
实例,准备配置其中的数百个顶点。下面是设置前四个顶点(即第一个四边形)位置坐标的方法:
// 为当前四边形中的每个顶点设置位置
background[0].position = Vector2f(0, 0);
background[1].position = Vector2f(49, 0);
background[2].position = Vector2f(49,49);
background[3].position = Vector2f(0, 49);
2
3
4
5
下面是将这些顶点的纹理坐标设置为精灵图(sprite sheet)中第一张图像的方法。图像文件中的这些坐标是从左上角的0,0
到右下角的49,49
:
// 设置每个顶点的纹理坐标
background[0].texCoords = Vector2f(0, 0);
background[1].texCoords = Vector2f(49, 0);
background[2].texCoords = Vector2f(49, 49);
background[3].texCoords = Vector2f(0, 49);
2
3
4
5
如果我们想将纹理坐标设置为精灵图中的第二张图像,代码会这样写:
// 设置每个顶点的纹理坐标
background[0].texCoords = Vector2f(0, 50);
background[1].texCoords = Vector2f(49, 50);
background[2].texCoords = Vector2f(49, 99);
background[3].texCoords = Vector2f(0, 99);
2
3
4
5
当然,如果我们像这样逐个定义每个顶点,那么即使是配置一个简单的10×10的竞技场,也会花费很长时间。
在实际实现背景时,我们将设计一组嵌套的for
循环,遍历每个四边形,随机选择一个背景图像,并分配合适的纹理坐标。
代码需要非常巧妙。它需要知道何时是边缘图块,以便可以使用精灵图中的墙壁图像。它还需要使用合适的变量,这些变量要知道精灵图中每个背景图块的位置,以及所需竞技场的整体大小。
我们会将所有代码放在一个单独的函数和一个单独的文件中,使这种复杂性易于管理。通过使用C++引用,我们可以在main
函数中使用VertexArray
实例。
我们稍后会详细探讨这些细节。你可能已经注意到,到目前为止,我们还没有将纹理(精灵图)与顶点数组关联起来。现在让我们看看如何做到这一点。
# 使用顶点数组进行绘制
现在我们已经准备好了顶点和纹理坐标,就可以在屏幕上进行绘制了。我们可以像加载其他任何纹理一样,将精灵图作为纹理加载,如下代码所示:
// 为我们的背景顶点数组加载纹理
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
2
3
然后,我们可以通过一次draw
调用绘制整个VertexArray
:
// 绘制背景
window.draw(background, &textureBackground);
2
上述代码比将每个图块作为单独的精灵进行绘制要高效得多。
在继续之前,请注意textureBackground
代码前面看起来有点奇怪的&
符号。你可能马上会想到这与引用有关。实际情况是,我们传递的是Texture
实例的内存地址,而不是实际的Texture
实例。我们将在下一章详细学习这方面的知识。
现在我们能够利用对引用和顶点数组的了解,来实现《僵尸竞技场》项目的下一阶段,即随机生成的滚动背景。
# 创建随机生成的滚动背景
在本节中,我们将在一个单独的文件中创建一个生成背景的函数。我们将通过使用顶点数组引用,确保背景在main
函数中可用(在作用域内)。
由于我们还会编写其他与main
函数共享数据的函数,所以我们会将它们都写在各自的.cpp
文件中。我们会在一个新的头文件中提供这些函数的原型,然后在ZombieArena.cpp
中使用#include
指令包含这个头文件。
为此,让我们创建一个名为ZombieArena.h
的新头文件。现在我们准备为新函数编写头文件代码。
在这个新的ZombieArena.h
头文件中,添加以下高亮显示的代码,包括函数原型:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
2
3
4
5
上述代码使我们能够编写一个名为createBackground
的函数定义。为了与原型匹配,函数定义必须返回一个int
值,并接收一个VertexArray
引用和一个IntRect
对象作为参数。
现在,我们可以创建一个新的.cpp
文件,在其中编写函数定义。创建一个名为CreateBackground.cpp
的新文件。现在我们准备编写创建背景的函数定义。
将以下代码添加到CreateBackground.cpp
文件中,然后我们会进行回顾:
#include "ZombieArena.h"
int createBackground(VertexArray& rVA, IntRect arena) {
// 我们对rVA所做的任何操作,实际上都是对main函数中的background所做的操作
// 每个图块/纹理的大小是多少
const int TILE_SIZE = 50;
const int TILE_TYPES = 3;
const int VERTS_IN_QUAD = 4;
int worldWidth = arena.width / TILE_SIZE;
int worldHeight = arena.height / TILE_SIZE;
// 我们使用哪种基本图形类型?
rVA.setPrimitiveType(Quads);
// 设置顶点数组的大小
rVA.resize(worldWidth * worldHeight * VERTS_IN_QUAD);
// 从顶点数组的开头开始
int currentVertex = 0;
return TILE_SIZE;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在上述代码中,我们编写了函数签名,以及标记函数体开始和结束的花括号。
在函数体内,我们声明并初始化了三个新的int
常量,用于保存我们在函数其余部分需要用到的值,它们是TILE_SIZE
、TILE_TYPES
和VERTS_IN_QUAD
。
TILE_SIZE
常量表示精灵图中每个图块的像素大小。TILE_TYPES
常量表示精灵图中不同图块的数量。我们可以在精灵图中添加更多图块,并更改TILE_TYPES
以匹配更改,而我们即将编写的代码仍然可以正常工作。VERTS_IN_QUAD
表示每个四边形有四个顶点。与总是输入数字4
相比,使用这个常量更不容易出错,因为数字4
表意不够清晰。
然后,我们声明并初始化了两个int
变量:worldWidth
和worldHeight
。从它们的名字就能明显看出这两个变量的用途,但值得指出的是,它们表示的是游戏世界的宽度和高度,单位是图块数量,而不是像素。worldWidth
和worldHeight
变量是通过将传入的竞技场的高度和宽度除以TILE_SIZE
常量来初始化的。
接下来,我们首次使用引用。要记住,我们对rVA
所做的任何操作,实际上都是对传入的变量(在main
函数中处于作用域内,或者在我们编写代码时会处于作用域内)所做的操作。
然后,我们使用rVA.setType
准备将顶点数组用于四边形,然后通过调用rVA.resize
将其调整为合适的大小。我们向resize
函数传入worldWidth * worldHeight * VERTS_IN_QUAD
的结果,这相当于我们完成准备工作后顶点数组将拥有的顶点数量。
代码的最后一行声明并将currentVertex
初始化为零。我们将在遍历顶点数组初始化所有顶点时使用currentVertex
。
现在我们可以编写嵌套for
循环的第一部分,用于准备顶点数组。添加以下高亮显示的代码,并根据我们对顶点数组的了解,试着弄清楚它的作用:
// 从顶点数组的开头开始
int currentVertex = 0;
for (int w = 0; w < worldWidth; w++) {
for (int h = 0; h < worldHeight; h++) {
// 为当前四边形中的每个顶点设置位置
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE) + TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE) + TILE_SIZE);
// 为下四个顶点的位置做准备
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
我们刚刚添加的代码通过使用嵌套for
循环遍历顶点数组,首先遍历前四个顶点:currentVertex + 1
、currentVertex + 2
等等。
我们使用数组符号rVA[currentVertex + 0]...
等来访问数组中的每个顶点。通过数组符号,我们调用position
函数,如rVA[currentVertex + 0].position...
。
我们将每个顶点的水平和垂直坐标传递给position
函数。我们可以通过结合使用w
、h
和TILE_SIZE
以编程方式计算出这些坐标。
在上述代码的最后,我们通过currentVertex = currentVertex + VERTS_IN_QUAD
这行代码将currentVertex
推进四个位置(即加四),为下一次遍历嵌套for
循环做好准备。
当然,所有这些操作只是设置了顶点的坐标,并没有从精灵图中分配纹理坐标。接下来我们就会做这件事。
为了清楚地展示新代码的位置,我将其与我们刚才编写的所有代码一起展示。添加并研究以下高亮显示的代码:
for (int w = 0; w < worldWidth; w++) {
for (int h = 0; h < worldHeight; h++) {
// 为当前四边形中的每个顶点设置位置
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE) + TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE) + TILE_SIZE);
// 定义当前四边形在纹理中的位置, 可以是草地、石头、灌木丛或墙壁
if (h == 0 || h == worldHeight - 1 ||
w == 0 || w == worldWidth - 1)
{
// 使用墙壁纹理
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords = Vector2f(TILE_SIZE, 0 +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords = Vector2f(TILE_SIZE, TILE_SIZE + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 3].texCoords = Vector2f(0, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
}
// 为下四个顶点的位置做准备
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
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
前面高亮显示的代码设置了每个顶点在精灵图中对应的坐标。注意那个有点长的if
条件。该条件检查当前四边形是否是竞技场中最开始或最末尾的四边形之一。如果是(第一个或最后一个),这意味着它是边界的一部分。然后我们可以使用一个包含TILE_SIZE
和TILE_TYPES
的简单公式来定位精灵图中的墙壁纹理。
通过数组符号和texCoords
成员依次为每个顶点进行初始化,以分配精灵图中墙壁纹理的相应角落。
以下代码包含在else
块中。这意味着每次四边形不代表边界/墙壁图块时,它都会遍历嵌套for
循环。在现有代码中添加以下高亮显示的代码,然后我们进行分析:
// 定义当前四边形在纹理中的位置, 可以是草地、石头、灌木丛或墙壁
if (h == 0 || h == worldHeight - 1 ||
w == 0 || w == worldWidth - 1)
{
// 使用墙壁纹理
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords = Vector2f(TILE_SIZE, 0 +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords = Vector2f(TILE_SIZE, TILE_SIZE + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 3].texCoords = Vector2f(0, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
}
else
{
// 使用随机的地面纹理
srand((int)time(0) + h * w - h);
int mOrG = (rand() % TILE_TYPES);
int verticalOffset = mOrG * TILE_SIZE;
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + verticalOffset);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 + verticalOffset);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE + verticalOffset);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE + verticalOffset);
}
// 为下四个顶点的位置做准备
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
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
前面高亮显示的代码首先使用一个在每次循环时都会不同的公式为随机数生成器设置种子。然后,mOrG
变量被初始化为一个介于0
到TILE_TYPES
之间的数字。这正是我们随机选择一种图块类型所需要的。
mOrG
代表“mud or grass”(泥或草),这个名字是随意取的。
现在,我们声明并初始化一个名为verticalOffset
的变量,将mOrG
乘以TileSize
。现在我们在精灵图中有了一个垂直参考点,它是当前四边形随机选择的纹理的起始高度。
现在,我们使用一个包含TILE_SIZE
和verticalOffset
的简单公式,为每个顶点分配纹理每个角落的精确坐标。
现在我们可以在游戏引擎中使用这个新函数了。
# 使用背景
我们已经完成了复杂的部分,所以接下来的操作会很简单。有以下三个步骤:
- 创建一个顶点数组(VertexArray)。
- 在每一波升级后对其进行初始化。
- 在每一帧中绘制它。
在添加新代码之前,ZombieArena.cpp
文件需要引入新的ZombieArena.h
文件。在ZombieArena.cpp
文件顶部添加以下包含指令:
#include "ZombieArena.h"
现在,添加以下突出显示的代码,声明一个名为background
的顶点数组实例,并将background_sheet.png
文件作为纹理加载:
// Create an instance of the Player class
Player player;
// The boundaries of the arena
IntRect arena;
// Create the background
VertexArray background;
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile ("graphics/background_sheet.png");
// The main game loop
while (window.isOpen())
2
3
4
5
6
7
8
9
10
11
12
13
14
15
添加以下代码来调用createBackground
函数,将background
作为引用传递,并按值传递arena
。注意,在突出显示的代码中,我们还修改了初始化tileSize
变量的方式。请严格按照显示的内容添加突出显示的代码:
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);
// We will modify this line of code later
// int tileSize = 50;
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// 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
请注意,我们替换了int tileSize = 50
这行代码,因为我们直接从createBackground
函数的返回值获取该值。
为了使未来的代码更清晰,你应该删除int tileSize = 50
这行代码及其相关注释。我只是将其注释掉,以便为新代码提供更清晰的上下文。
最后,是时候进行绘制了。这真的很简单。我们所要做的就是调用window.draw
,并传递顶点数组实例以及textureBackground
纹理的内存地址:
/*
**************
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 player
window.draw(player.getSprite());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
如果你对textureBackground
前面那个看起来很奇怪的&
符号感到疑惑,下一章会为你详细解释。
现在你可以运行游戏了。你会看到以下输出。记得按回车键并选择一个数字键,以跳过我们暂时不可见的菜单:
图9.3:使用背景
在这里,注意玩家的精灵是如何在游戏区域(arena)范围内平稳地滑动和旋转的。虽然目前主函数中的代码绘制的是一个小的游戏区域,但CreateBackground
函数可以创建任意大小的游戏区域。在第14章“音效、文件输入/输出以及完成游戏”中,我们将看到比屏幕还大的游戏区域。
# 总结
在本章中,我们学习了C++引用(references),它是一种特殊的变量,作为其他变量的别名。当我们通过引用而不是按值传递变量时,对引用所做的任何操作都会影响调用函数中的原始变量。
我们还学习了顶点数组,并创建了一个充满四边形的顶点数组,以便从精灵表(sprite sheet)中绘制图块作为背景。
当然,我们的僵尸游戏目前还没有任何僵尸。在下一章中,我们将通过学习C++指针(pointers)和标准模板库(STL,Standard Template Library)来解决这个问题。
# 常见问题解答
以下是一些你可能会想到的问题:
- 问:你能再总结一下这些引用吗?
- 答:你必须立即初始化一个引用,并且它不能被更改为引用另一个变量。在函数中使用引用,这样你操作的就不是变量的副本。这对提高效率很有帮助,因为它避免了复制操作,并且有助于我们更轻松地将代码抽象成函数。
- 问:有没有简单的方法记住使用引用的主要好处?
- 答:为了帮助你记住引用的用途,考虑下面这首短诗: 移动大对象会让游戏卡顿,传引用比传副本更快。