第2章 变量、运算符与决策:精灵动画
# 第2章 变量、运算符与决策:精灵动画
在本章中,我们将在屏幕上进行更多绘图操作。我们会让一些云朵在背景中以随机高度和速度移动,同时让一只蜜蜂在前景中做同样的动画。为了实现这一目标,我们需要学习更多C++基础知识。我们将学习C++如何通过变量存储数据,如何使用C++运算符操作这些变量,以及如何根据变量的值做出决策,使代码执行不同的分支路径。学习完这些内容后,我们将能够运用关于简单快速多媒体库(Simple and fast Multimedia Library,SFML)的精灵(Sprite)和纹理(Texture)类的知识,来实现云朵和蜜蜂的动画效果。
综上所述,本章内容如下:
- 全面学习C++变量
- 了解如何操作变量
- 添加云朵、嗡嗡作响的蜜蜂,以及供玩家砍伐的树木
- 随机数
- 使用
if
和else
进行决策判断 - 计时
- 移动云朵和蜜蜂
# 全面学习C++变量
变量是C++游戏存储和操作游戏中的值/数据的方式。如果我们想知道玩家有多少生命值,就需要一个变量。或许你想知道当前波次还剩下多少只僵尸?这同样需要一个变量。如果你需要记录获得特定高分的玩家姓名,没错,这也需要一个变量。游戏结束了还是仍在进行中?是的,这还是一个变量。
变量是内存中存储位置的命名标识符。例如,我们可以创建一个名为numberOfZombies
的变量,这个变量可以指向内存中的某个位置,该位置存储的值代表当前波次剩余的僵尸数量。
计算机系统对内存位置的寻址方式很复杂。编程语言使用变量,为我们管理内存中的数据提供了一种便于理解的方式。实际上,编程语言的作用就是以一种便于理解的方式管理复杂系统。不同编程语言的区别在于它们的效率和易用性。C++一直以来效率都很高,并且在发展过程中也变得越来越易于使用。
C++由比雅尼·斯特劳斯特鲁普(Bjarne Stroustrup)在20世纪80年代初创建。C++是从最初的C语言发展而来的。斯特劳斯特鲁普开发C++的目的是为C语言添加面向对象编程特性,使代码更高效、更易于管理。多年来,C++经历了多次修订和改进。
我们刚才对变量的简单介绍表明,变量肯定有不同的类型。C++中有很多种变量类型。接下来让我们看看在本书中最常用的变量类型。
# 变量类型
单独用一整章来讨论C++变量及其类型轻而易举。已经有很多书籍专门讲解这个内容,所以我在这里就不再赘述,因为我猜你想尽快学会制作游戏。因此,下面列出了本书中最常用的变量类型表。之后,我们会学习如何使用每种变量类型。
类型 | 值的示例 | 解释 |
---|---|---|
int | -42、0、1和9826 | 整数 |
float | -1.26f、5.8999996f和10128.3f | 精度可达7位的浮点数 |
double | 925.83920655234和1859876.94872535 | 精度可达15位的浮点数 |
char | a、b、c、1、2和3(共128个符号,包括?、~、#等) | ASCII表中的任意符号(关于变量的更多内容,见下一个提示) |
bool | true 或false | bool 代表布尔型,取值只能是true 或false |
String | Hello Everyone! I am a String | 从单个字母或数字到一整本书内容的任意文本值 |
表2.1变量类型
C++是强类型语言。在编程语言中,强类型指的是一种严格执行变量数据类型的系统,隐式类型转换受到限制。在强类型语言中,不同数据类型之间的操作通常需要显式转换,否则会导致编译错误。这种严格的执行机制减少了游戏中出现意外行为的可能性,因为编译器或解释器会确保变量的使用方式与其声明的类型一致。
基于这些原因,必须告知编译器变量的类型,以便它为变量分配合适大小的内存。此外,当编译器知道变量的类型后,就能检查变量是否被错误使用。例如,你不能用字符串除以布尔值。为每个使用的变量选择最佳且最合适的类型是一个好习惯。然而在实际操作中,将变量提升为更精确的类型通常不会有问题。比如,你可能只需要一个有五位有效数字的浮点数?如果你将其存储为double
类型,编译器不会报错。但是,如果你试图将float
或double
类型的值存储在int
类型变量中,它会转换该值以适配int
类型,这也会改变存储的值。在本书后续内容中,我会明确指出在每种情况下使用哪种变量类型最佳,我们甚至还会看到一些有意在变量类型之间进行转换的例子。
在上表中,还有一些值得注意的细节,所有float
类型的值后面都有一个f
后缀。这个f
告诉编译器该值是float
类型,而不是double
类型。没有f
前缀的浮点数默认被认为是double
类型。关于这一点,在关于变量的下一个提示中会有更多介绍。
# 用户定义类型
用户定义类型比我们刚才看到的类型要高级得多。当我们谈到C++中的用户定义类型时,通常指的是类(class)或枚举(enumeration)。我们在上一章简要讨论了类及其相关对象。很快,我们将在一个单独的文件中编写代码,有时会在两个单独的文件中编写。然后,我们将能够声明、初始化和使用自己设计的类。我们把如何定义/创建自己的类型留到第6章“面向对象编程:开始制作乒乓游戏”。我们将在第4章介绍枚举。枚举是对类的一种初步介绍,因为它是程序员定义自己类型的一种方式,比如僵尸类型、增强道具类型或外星飞船类型。现在让我们回到C++的内置基本类型,这些类型通常被称为基本类型(fundamental types),因为它们代表了像我们在上表中看到的那些基本值。
# 声明和初始化变量
到目前为止,我们知道变量用于存储游戏运行所需的数据/值。例如,变量可以表示玩家的生命值或玩家的名字。我们也知道这些变量可以表示多种不同类型的值,比如int
、float
、bool
或用户定义类型。当然,我们还没了解如何使用变量。
创建和准备一个新变量需要两个步骤,分别是声明(declaration)和初始化(initialization)。下面让我们依次了解这两个步骤。
# 声明变量
在C++中,我们可以这样声明变量:
// 玩家的分数是多少?
int playerScore;
// 玩家名字的首字母是什么?
char playerInitial;
// 圆周率的值是多少?
float valuePi;
// 玩家是活着还是死了?
bool isAlive;
2
3
4
5
6
7
8
9
10
11
在上述代码中,我们声明了一个名为playerScore
的int
类型变量、一个名为playerInitial
的char
类型变量、一个名为valuePi
的float
类型变量和一个名为isAlive
的bool
类型变量。如果你需要回顾这些不同类型的具体含义,可以查看前面的表格。通过这些声明,我们在内存中预留了大小合适的空间,用于存储和操作相应类型的值,但此时我们还没有存储任何数据。让我们继续学习,了解更多内容。
# 初始化变量
现在我们已经用有意义的名称声明了变量,接下来可以用合适的值对这些变量进行初始化,如下所示:
playerScore = 0;
playerInitial = 'J';
valuePi = 3.141f;
isAlive = true;
2
3
4
现在,如果执行上述代码,计算机内存中就会有真实的数据。需要说明的是,上述四个变量分别存储的值为0、小写字母j
、浮点数3.141和二进制值true
。
# 一步完成声明和初始化
如果方便的话,我们可以将声明和初始化这两个步骤合并为一步。如果我们知道变量初始值应该是什么,可以像下面的示例这样编写代码。
int playerScore = 0;
char playerInitial = 'J';
float valuePi = 3.141f;
bool isAlive = true;
2
3
4
如果需要在程序执行过程中确定变量的值,我们更可能会像前面变量示例那样编写代码。这两种方式在C++中都是正确的,但通常来说,某一种方式会更适合你的游戏。
如果你想查看C++类型的完整列表,可以访问这个网页:http://www.tutorialspoint.com/cplusplus/cpp_data_types.htm (opens new window)。如果你想深入讨论
float
、double
和f
后缀,可以阅读这个内容:http://www.cplusplus.com/forum/beginner/24483/ (opens new window)。如果你想了解ASCII字符代码,这里有更多信息:http://www.cplusplus.com/doc/ascii/ (opens new window)。请注意,这些链接是为好奇心较强的读者准备的,我们已经讨论了足够的内容,可以继续学习后续知识了。
# 常量
有时我们需要确保某个值永远不会被改变。为了实现这一点,我们可以使用const
关键字声明并初始化一个常量。圆周率的值不会改变,所以在大多数情况下,使用常量变量会更合适。
const float PI = 3.141f;
const int NUMBER_OF_ENEMIES = 2000;
2
在上述代码中,我们确保了PI
变量的值在程序执行过程中永远不会改变,NUMBER_OF_ENEMIES
变量也是如此。声明常量时,通常会使用不同的格式。在本书中,我们使用的格式是全部大写,单词之间用下划线分隔,而不是驼峰式命名。
需要明确的是,我说常量的值不能改变,指的是在程序执行过程中不能改变。作为程序员,你始终可以在初始化常量时改变其值,只是不能编写在执行过程中改变常量值的代码。
//const int PLANETS_IN_SOLAR_SYSTEM = 9;
// 哎呀!冥王星在2006年被重新归类为矮行星。
const int PLANETS_IN_SOLAR_SYSTEM = 8;
2
3
我们将在第4章“循环、数组、开关语句、枚举和函数:实现游戏机制”中看到常量的实际应用。
还有另一个关于变量初始化的主题需要讨论。
# 统一初始化
统一初始化(uniform initialization)或列表初始化(list initialization)是一种较新的变量初始化方式。C++中的统一初始化始于2011年发布的C++11,这是C++编程语言的一次重大更新。统一初始化提供了一种更一致的语法,用于初始化变量和用户定义类型。它允许使用花括号{}
进行初始化,就像包裹main
函数的花括号一样。对于前面提到的变量,你可以这样使用统一初始化:
int playerScore{0};
char playerInitial{'J'};
float valuePi{3.141f};
bool isAlive{true};
2
3
4
在上述代码中,我用统一初始化语法{}
替换了每个变量的赋值运算符=
。这种语法是现代C++的正式标准,在现代商业API中你会经常看到它。它比“传统”方法更不容易出错,其中有一些深层次的原因,在本书中我们会涉及到。
使用传统语法也是可以的,如下所示:
int playerScore = 0;
这两种方法都是有效的,都能正常工作。我只是想让你了解在学习其他C++知识时可能会遇到的语法。此外,在第6章讨论类的时候,我们还会遇到这种风格的代码。在本书中,你可以随意使用统一初始化方式。修改所有代码示例也很简单。我认为传统语法对初学者更友好,但如果你要去某公司工作,可能会使用统一初始化方式。
# 声明和初始化用户定义类型
我们已经看到了如何声明和初始化一些SFML定义类型的示例。由于创建/定义这些类型(类)的方式非常灵活,所以声明和初始化它们的方式也多种多样。这里有几个上一章中关于声明和初始化用户定义类型的示例,供你回顾。
创建一个VideoMode
类型的对象vm
,并用两个int
值1920和1080对其进行初始化。
// 创建一个视频模式对象
VideoMode vm(1920, 1080);
2
创建一个Texture
类型的对象textureBackground
,但不进行任何初始化。
// 创建一个纹理,用于在GPU上存储图形
Texture textureBackground;
2
需要注意的是,尽管我们没有为textureBackground
指定初始化值,但实际上很有可能在内部会对变量进行一些设置。一个对象此时是否需要或可以给出初始化值,完全取决于类的编码方式,而且这种方式几乎有无限的灵活性。这进一步表明,当我们开始编写自己的类时,会存在一定的复杂性。幸运的是,这也意味着我们有很大的能力将自己的类型/类设计成制作游戏所需的样子。将C++的这种强大灵活性与SFML设计的类的功能相结合,我们制作游戏的潜力几乎是无限的!
在本章中,我们还会看到SFML提供的一些用户创建的类型/类,在整本书中会看到更多。在第6章中,我们将在实现一个类似乒乓的游戏时,设计并编写自己的类型/类。
# 操作变量
目前,我们已经清楚地知道变量是什么、变量的主要类型,以及如何声明和初始化变量。然而,我们还没有学会如何利用变量做更多事情。我们需要操作变量,进行加、减、乘、除运算,尤其是对变量进行测试。
首先,我们将学习如何操作变量,之后再探讨如何以及为何对变量进行测试。
基于此,让我们学习C++的算术运算符和赋值运算符。
# C++算术运算符和赋值运算符
为了操作变量,C++提供了一系列算术运算符和赋值运算符。幸运的是,大多数算术运算符和赋值运算符使用起来都很直观,不太直观的也很容易解释。为了开始学习,我们先看一下算术运算符表,然后是本书中会经常使用的赋值运算符表。
算术运算符 | 解释 |
---|---|
+ | 加法运算符,用于将两个变量或值相加 |
- | 减法运算符,用于从一个变量或值中减去另一个变量或值 |
* | 乘法运算符,用于将变量和值相乘 |
/ | 除法运算符,用于将变量和值相除 |
% | 取模运算符,将一个值或变量除以另一个值或变量,得到运算的余数 |
表2.2算术运算符
接下来是赋值运算符。
赋值运算符 | 解释 |
---|---|
= | 我们已经见过这个运算符,它是赋值运算符,用于初始化/设置变量的值 |
+= | 将右侧的值加到左侧的变量上 |
-= | 从左侧的变量中减去右侧的值 |
*= | 将左侧的变量乘以右侧的值 |
/= | 用左侧的变量除以右侧的值 |
++ | 自增运算符,将变量的值加1 |
-- | 自减运算符,将变量的值减1 |
<=> | 太空船运算符(spaceship operator),用<=> 表示,是C++20中新增的运算符,用于三路比较。我们将在后续项目中探讨它的用法 |
表2.3赋值运算符
严格来说,除了
=
、--
和++
之外,上述所有运算符都称为复合赋值运算符,因为它们由多个运算符组成。
现在我们已经了解了多种算术运算符和赋值运算符,接下来看看如何通过组合运算符、变量和值来形成表达式,从而操作变量。
# 用表达式实现功能
表达式是变量、运算符和值的组合,就像英语中的表达式是单词和标点的组合一样。通过表达式,我们可以得到一个结果。此外,正如我们很快将看到的,我们可以在测试中使用表达式。这些测试可用于决定代码接下来的执行方向。
# 赋值
首先,来看一些可能在游戏代码中出现的简单表达式。
// 玩家获得了新的高分
hiScore = score;
2
或者:
// 将分数设置为100
score = 100;
2
在上述代码中,我们将score
中存储的值赋给hiScore
变量。从这之后,hiScore
将保存score
之前存储的值。我们可能会在游戏结束时,玩家打破之前的最高分时执行这个操作。需要明确的是,我们可能随后会将score
重置为零,然后用它来记录下一局游戏的分数,但hiScore
仍将保留执行hiScore = score
时score
中存储的值。当然,如果我们在每局游戏结束时都执行这行代码,就有可能将不是新最高分的值赋给hiScore
。这个难题又让我们回到了测试和比较值的必要性上。让我们继续学习,很快就能找到解决方案。
接下来,看看与赋值运算符一起使用的加法运算符:
// 当射中一个外星人时增加分数
score = aliensShot + wavesCleared;
2
或者:
// 给当前分数加上100
score = score + 100;
2
注意,在运算符两侧使用同一个变量是完全可行的。在上述代码中,第一行将aliensShot
和wavesCleared
的值相加,并将结果赋给score
。第二行代码将score
当前的值加上100,然后再把结果赋回给score
。或许这个例子的另一种变体更有用:
score = score + pointsPerAlien;
在这个例子中,pointsPerAlien
的值被加到score
的现有值上。在运算符两侧使用变量的这种技巧非常常见。再看一下这段代码,确保你理解其中的操作。
接下来,让我们看看与赋值运算符一起使用的减法运算符。下面的代码从减法运算符左侧的值中减去右侧的值。它通常与赋值运算符一起使用,例如:
// 哦不,失去了一条生命
lives = lives - 1;
2
或者:
// 游戏结束时还剩下多少外星人
aliensRemaining = aliensTotal - aliensDestroyed;
2
这是我们可能使用除法运算符的方式。下面的代码将左侧的数除以右侧的数。同样,它通常与赋值运算符一起使用,如下所示:
// 根据剑的等级降低剩余生命值
hitPoints = hitPoints / swordLevel;
2
或者:
// 回收一个方块,返还部分(而非全部)价值
recycledValueOfBlock = originalValue / 1.1f;
2
在前面的例子中,recycledValueOfBlock
变量需要是float
类型,才能准确存储这样的计算结果。希望这种语法开始变得显而易见了。如果感觉我在教你做小孩子的算术题,那就说明你可能已经掌握了要点。再看一个赋值运算符的例子,然后我们继续前进。
不出所料,我们可以这样使用乘法运算符:
// 答案当然等于100
answer = 10 * 10;
2
或者:
// biggerAnswer当然等于1000
biggerAnswer = 10 * 10 * 10;
2
现在,这段代码可能无需解释了。在前面的例子中,我们先将两个10相乘,然后将三个10相乘,分别将结果赋给answer
和biggerAnswer
。
# 自增和自减
现在,让我们看看自增运算符的实际应用。这是一种给游戏变量的值加1的巧妙方法。关于自增运算符,还有一个C++的有趣小知识,接着往下看。
我们已经见过下面这段代码,无需再解释了,不过再看一下。
// 给myVariable加1
myVariable = myVariable + 1;
2
有时,在运算符两侧不必重复使用同一个变量。这样不仅能让代码更清晰,还能节省一点点打字时间。
下面这段代码与前面那段代码的结果相同:
// 更简洁、清晰,也更快
myVariable++;
2
自增运算符在C++中就是++
。
有趣的是,你有没有想过C++这个名字是怎么来的?C++是C语言的扩展。它的发明者比雅尼·斯特劳斯特鲁普(Bjarne Stroustrup)最初将其称为“带类的C”,但这个名字后来演变了。如果你感兴趣,可以阅读C++的故事:http://www.cplusplus.com/info/history/ (opens new window)。
自减运算符--
,正如你所想,是一种快速从某个数中减去1的方法。
playerHealth = playerHealth -1;
下面这段代码更快捷、更清晰,与前面那段代码的功能相同:
playerHealth--;
让我们再看几个运算符的实际应用,然后就可以继续制作“Timber!!!”游戏了。试着理解下面这些代码行的操作:
int someVariable = 10;
// 将变量乘以10,并将答案放回变量中
someVariable *= 10;
// someVariable现在等于100
// 将someVariable除以5,并将答案放回变量中
someVariable /= 5;
// someVariable现在等于20
// 给someVariable加3,并将答案放回变量中
someVariable += 3;
// someVariable现在等于23
// 从someVariable中减去25,并将答案放回变量中
someVariable -= 25;
// someVariable现在等于 -2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在上述代码中,我们使用了一些复合运算符,将赋值运算符与自增和自减运算符结合起来,把自增和自减的用法提升到了一个新高度。我们不再只是加1或减1。当使用*=
、/=
、+=
或-=
运算符时,我们是将变量当前存储的值与运算符前面的数字进行乘、除、加或减运算。
所以,在乘法的例子中,someVariable
存储的值是10,代码someVariable *= 10
会将这个值乘以10,然后把答案100再放回someVariable
中。这种语法简短、快速且清晰,很不错。
如果这些例子中有任何需要进一步解释的地方,我们在增强游戏和实现图形移动时,几乎会用到刚刚所学的所有内容。是时候给游戏添加更多精灵了。
# 添加云朵、嗡嗡叫的蜜蜂和一棵树
首先,我们要添加一棵树。这很简单,因为树是静止不动的。我们将使用上一章绘制背景时的相同步骤。在接下来的部分,我们将准备静态的树精灵以及移动的蜜蜂和云朵精灵。然后,我们可以分别专注于移动和绘制蜜蜂与云朵,因为实现这些需要更多C++知识。
# 准备树
添加以下突出显示的代码。注意未突出显示的代码,那是我们之前已经编写的代码。这有助于你确定新代码应紧跟在设置背景位置之后、主游戏循环开始之前输入。添加完新代码后,我们将回顾其功能。
int main() {
// 创建一个视频模式对象
VideoMode vm(1920, 1080);
// 创建并打开游戏窗口
RenderWindow window(vm, "Timber!!!", Style::Fullscreen);
// 创建一个纹理,用于在GPU上存储图形
Texture textureBackground;
// 将图形加载到纹理中
textureBackground.loadFromFile("graphics/background.png");
// 创建一个精灵
Sprite spriteBackground;
// 将纹理附加到精灵上
spriteBackground.setTexture(textureBackground);
// 设置spriteBackground覆盖整个屏幕
spriteBackground.setPosition(0, 0);
// 创建树精灵
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
while (window.isOpen()) {
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
我们刚刚添加的5行代码(不包括注释)执行以下操作:
- 首先,创建一个
Texture
类型的对象textureTree
。 - 接着,从
tree.png
图形文件中加载图形到纹理中。 - 然后,声明一个
Sprite
类型的对象spriteTree
。 - 随后,将
textureTree
与spriteTree
关联起来。无论何时绘制spriteTree
,它都会显示textureTree
的纹理,也就是一幅精美的树的图形。 - 最后,使用x轴坐标810和y轴坐标0来设置树的位置。
需要注意的是,用于定位树的坐标810和0,是我经过测试后发现,在我们选择的整体分辨率下效果很好的值。我这样赋值是为了快速进入下一个主题。在一个“真正的”C++程序中,你可能会将值赋给变量,这样其用途会更清晰。此外,如果这些值不会改变(确实不会改变),你可能会像之前讨论的那样,使用常量变量。你可以在游戏循环外这样声明变量:
const float TREE_HORIZONTAL_POSITION = 810;
const float TREE_VERTICAL_POSITION = 0;
2
然后,在绘制树精灵的代码行中,使用以下代码:
spriteTree.setPosition(TREE_HORIZONTAL_POSITION, TREE_VERTICAL_POSITION);
在这个例子中,声明紧跟在使用之前,而且我认为setPosition
函数足以表明这些值的含义。
如果读者认为添加这两个新的常量变量能让代码更清晰,可自行修改代码,这留作一个练习。当我们编写像这样带有未解释值的代码时,有时会被批评为使用了“魔法数字”,因为与使用有意义名称的变量相比,这些值的作用有时不那么清晰。这里讨论的重点是,代码规模越大、越复杂,你就应该对自己的标准要求越严格,尤其是在与他人协作或按要求严格编写代码的情况下。为了简洁,我偶尔会使用魔法数字,但希望上下文总是清晰的。
让我们继续来看更有趣的蜜蜂。
# 准备蜜蜂
接下来的这段代码与树的代码区别很小,但很重要。由于蜜蜂需要移动,我们还声明了两个与蜜蜂相关的变量。在所示位置添加突出显示的代码,看看你能否想出我们将如何使用beeActive
和beeSpeed
变量。
// 创建树精灵
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
// 准备蜜蜂
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// 蜜蜂当前在移动吗?
bool beeActive = false;
// 蜜蜂能飞多快?
float beeSpeed = 0.0f;
while (window.isOpen()) {
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在上述新代码中,我们以创建背景和树的相同方式创建了一只蜜蜂,使用了Texture
和Sprite
并将二者关联起来。
还要注意前面蜜蜂相关的代码中,有一些我们在项目中之前没见过的新代码,不过在讨论变量时我们刚刚提到过。这里有一个bool
变量,用于判断蜜蜂是否处于活动状态。记住,bool
变量的值只能是true
或false
。目前,我们将beeActive
初始化为false
。
接下来,我们声明一个新的float
变量beeSpeed
,它将存储蜜蜂在屏幕上飞行的速度,单位是像素每秒。
很快我们就会看到如何使用这两个新变量来移动蜜蜂。在此之前,让我们以几乎相同的方式设置一些云朵。
# 准备云朵
添加下面突出显示的代码。研究新代码,试着想想它的作用,然后我会进行解释。
// 准备蜜蜂
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// 蜜蜂当前在移动吗?
bool beeActive = false;
// 蜜蜂能飞多快?
float beeSpeed = 0.0f;
// 用1个纹理创建3个云朵精灵
Texture textureCloud;
// 加载1个新纹理
textureCloud.loadFromFile("graphics/cloud.png");
// 3个使用相同纹理的新精灵
Sprite spriteCloud1;
Sprite spriteCloud2;
Sprite spriteCloud3;
spriteCloud1.setTexture(textureCloud);
spriteCloud2.setTexture(textureCloud);
spriteCloud3.setTexture(textureCloud);
// 将云朵定位在屏幕左侧不同高度
spriteCloud1.setPosition(0, 0);
spriteCloud2.setPosition(0, 250);
spriteCloud3.setPosition(0, 500);
// 云朵当前在屏幕上吗?
bool cloud1Active = false;
bool cloud2Active = false;
bool cloud3Active = false;
// 每朵云的速度是多少?
float cloud1Speed = 0.0f;
float cloud2Speed = 0.0f;
float cloud3Speed = 0.0f;
while (window.isOpen()) {
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
我们刚刚添加的代码中,唯一可能看起来有点奇怪的地方是,我们只有一个Texture
类型的对象。多个Sprite
对象共享一个纹理是完全正常的。一旦纹理存储在GPU内存中,它可以非常快速地与Sprite
对象关联。只有在loadFromFile
代码中首次加载图形时,才是相对较慢的操作。当然,如果我们想要三朵形状不同的云,那就需要三个纹理。
除了这个小小的纹理共享特点外,与蜜蜂相关的代码相比,我们刚刚添加的代码并没有新内容。唯一的区别是有三个云朵精灵、三个用于判断每朵云是否处于活动状态的bool
变量,以及三个用于存储每朵云速度的float
变量。
# 绘制树、蜜蜂和云朵
最后,我们可以在绘图部分添加以下突出显示的代码,将它们全部绘制在屏幕上。
/*
****************************************
绘制场景
****************************************
*/
// 清除上一帧运行的所有内容
window.clear();
// 在此处绘制我们的游戏场景
window.draw(spriteBackground);
// 绘制云朵
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// 绘制树
window.draw(spriteTree);
// 绘制昆虫
window.draw(spriteBee);
// 显示我们刚刚绘制的所有内容
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
绘制三朵云、蜜蜂和树的方法与绘制背景的方法相同。不过,请注意我们将不同对象绘制到屏幕上的顺序。我们必须在绘制背景之后绘制所有图形,否则它们会被背景遮挡;我们还必须在绘制树之前绘制云朵,否则云朵看起来会有点奇怪,就好像飘在树的前面。蜜蜂在树前或树后绘制看起来都可以。我选择把蜜蜂画在树的前面,这样它就能试着分散伐木工的注意力,就像真正的蜜蜂可能做的那样。
运行《Timber!!!》,惊叹地看着那棵树、蜜蜂和三朵无所事事的云!它们看起来像是在排队参加一场比赛,一场蜜蜂必须往后跑的比赛。
图2.1:绘制树、蜜蜂和云朵
利用我们对C++ 运算符的了解,我们可以尝试移动刚刚添加的图形,但这存在一些问题。首先,真实的云和蜜蜂的移动方式并不均匀。它们没有固定的速度或位置。尽管它们的位置和速度是由风或蜜蜂的匆忙程度等因素决定的。对于不经意的观察者来说,它们的移动路径和速度看起来是随机的。让我们进一步探索随机性。
# 随机数
在游戏中,随机数有很多用处。例如,它可以用来确定玩家抽到的牌,或者在一定范围内从敌人的生命值中扣除多少伤害值。正如前面所提到的,我们将使用随机数来确定蜜蜂和云朵的起始位置和速度。
# 在C++ 中生成随机数
要生成随机数,我们需要使用更多的C++ 函数。目前先不要在游戏中添加任何代码。让我们仅通过一些假设性的代码来了解所需的语法和步骤。
计算机无法真正生成随机数,它们只能使用算法来选取一个看似随机的数。为了确保这个算法不会不断返回相同的值,我们必须为随机数生成器设置种子(seed)。种子可以是任意整数,但每次需要一个唯一的随机数时,它必须是不同的种子。看下面这段为随机数生成器设置种子的代码:
// 用时间为随机数生成器设置种子
srand((int)time(0));
2
前面的代码使用time
函数从计算机获取时间,即time(0)
。对time
函数的调用作为参数传递给srand
函数。这样做的结果是,当前时间被用作种子。
由于看起来有点不寻常的(int)
语法,前面的代码看起来有点复杂。它的作用是将time
函数返回的值转换/强制转换为int
类型。在这种情况下,srand
函数需要这种转换。
将一种类型转换为另一种类型的过程称为强制转换(cast)。
所以,总而言之,前面这行代码:
- 使用
time
函数获取时间。 - 将其转换为
int
类型。 - 将得到的值发送给
srand
函数,为随机数生成器设置种子。
当然,时间总是在变化,这使得time
函数成为为随机数生成器设置种子的好方法。但是,想想如果我们多次快速连续地为随机数生成器设置种子,导致time
函数返回相同的值,会发生什么情况。当我们为云朵制作动画时,将会看到并解决这个问题。
在这个阶段,我们可以使用如下代码在某个范围内创建随机数,并将其保存到一个变量中供以后使用:
// 生成随机数并保存到名为number的变量中
int number = (rand() % 100);
2
注意我们给number
赋值的方式有点特别。通过使用取模运算符(%
)和值100,我们获取rand
函数返回的数除以100后的余数。当你除以100时,余数最大可能是99,最小可能是0。因此,前面的代码将生成一个介于0到99(包括0和99)之间的数。这个知识对于为我们的蜜蜂和云朵生成随机速度和起始位置很有用。
我们很快就会这么做,但首先我们需要学习如何在C++ 中做出判断。
# 使用if
和else
进行判断
C++ 中的if
和else
关键字使我们能够做出判断。在上一章中,当我们检测每帧玩家是否按下了Escape键时,就用到了if
。
if (Keyboard::isKeyPressed(Keyboard::Escape)) {
window.close();
}
2
3
到目前为止,我们已经了解了如何使用算术运算符和赋值运算符创建表达式。现在,我们来看看一些新的运算符。
# 逻辑运算符
逻辑运算符通过构建可以测试为true
或false
值的表达式,帮助我们做出判断。一开始,这可能看起来选择范围很窄,不足以满足高级电脑游戏中可能需要的各种判断。但深入探究后就会发现,仅使用几个逻辑运算符,就能做出我们需要的所有判断。
下面是一个最常用逻辑运算符的表格。看看这些运算符及其相关示例,然后我们将了解如何使用它们。
逻辑运算符 | 名称及示例 |
---|---|
== | 比较运算符,用于测试是否相等,结果为true 或false 。例如,表达式(10 == 9) 为false ,因为10显然不等于9。 |
! | 这是逻辑非运算符。例如,表达式(! (2 + 2 == 5)) 为true ,因为2 + 2 不等于5。 |
!= | 这是另一个比较运算符,与== 比较运算符不同,它测试是否不相等。例如,表达式(10 != 9) 为true ,因为10不等于9。 |
> | 另一个比较运算符,还有其他几个类似的。它测试一个值是否大于另一个值。例如,表达式(10 > 9) 为true 。 |
< | 你猜对了,这个运算符测试一个值是否小于另一个值。例如,表达式(10 < 9) 为false 。 |
>= | 这个运算符测试一个值是否大于或等于另一个值,如果其中一个条件为true ,结果就为true 。例如,表达式(10 >= 9) 为true ,表达式(10 >= 10) 也为true 。 |
<= | 与前面的运算符类似,这个运算符测试两个条件,但它测试的是小于或等于。例如,表达式(10 <= 9) 为false ,表达式(10 <= 10) 为true 。 |
&& | 这个运算符称为逻辑与。它测试一个表达式的两个或多个部分,只有当所有部分都为true 时,结果才为true 。逻辑与通常与其他运算符结合使用,以构建更复杂的测试。例如,表达式((10 > 9) && (10 < 11)) 为true ,因为两个部分都为true ,所以整个表达式为true ;而表达式((10 > 9) && (10 < 9)) 为false ,因为该表达式只有一部分为true ,另一部分为false 。 |
|| | 这个运算符称为逻辑或,它与逻辑与类似,不同之处在于,表达式的两个或多个部分中至少有一个为true ,整个表达式就为true 。让我们看看刚才使用的最后一个示例,将&& 换成|| 。表达式((10 > 9) || (10 < 9)) 现在为true ,因为表达式的一部分为true 。 |
表2.4 逻辑运算符
现在,让我们来认识一下C++ 中的if
和else
关键字,它们能让我们充分利用这些逻辑运算符。
# C++ 中的if
和else
让我们把前面的例子变得不那么抽象。来认识一下C++ 中的if
关键字。我们将结合if
、几个运算符,通过一个小故事来展示它们的用法。下面是一个虚构的军事场景,希望它比前面的例子更具体一些。
# 如果他们过桥,就开枪射击!
上尉快不行了,他知道剩下的下属经验不足,于是决定编写一个C++ 程序,传达他死后的最后命令。部队必须守住桥的一端,等待援军。
上尉首先要确保部队明白的命令是:“如果他们过桥,就开枪射击!”
那么,我们如何用C++ 模拟这种情况呢?我们需要一个布尔(bool
)变量isComingOverBridge
。接下来的代码假设isComingOverBridge
变量已经声明并初始化为true
或false
。
然后,我们可以这样使用if
:
if(isComingOverBridge) {
// 开枪射击
}
2
3
如果isComingOverBridge
变量的值为true
,则在开始和结束花括号{...}
内的代码将运行。如果不为true
,程序将在if
块之后继续执行,而不会运行其中的代码。
# 否则做其他事情
上尉还想告诉部队,如果敌人不过桥,就原地待命。
现在,我们引入另一个C++ 关键字else
。当if
的判断结果不为true
,而我们又想明确执行某些操作时,就可以使用else
。
例如,要告诉部队如果敌人不过桥就原地待命,我们可以编写如下代码:
if(isComingOverBridge) {
// 开枪射击
}
else
{
// 坚守阵地
}
2
3
4
5
6
7
然后上尉意识到问题并不像他最初想的那么简单。如果敌人过桥了,但兵力太多怎么办?他的小队会被击败并惨遭屠杀。于是,他想出了下面的代码(这次我们也会使用一些变量):
bool isComingOverBridge;
int enemyTroops;
int friendlyTroops;
// 以某种方式初始化前面的变量
// 现在是if判断
if(isComingOverBridge && friendlyTroops > enemyTroops) {
// 开枪射击
}
else if(isComingOverBridge && friendlyTroops < enemyTroops) {
// 炸毁桥梁
}
else
{
// 坚守阵地
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
前面的代码有三种可能的执行路径。第一,如果敌人过桥且友军人数更多:
if(isComingOverBridge && friendlyTroops > enemyTroops)
第二,如果敌人过桥但人数超过友军:
else if(isComingOverBridge && friendlyTroops < enemyTroops)
然后,第三种也是最后一种可能的结果,即当前面两种情况都不成立时执行,由最后的else
捕获,这里的else
没有if
条件。
# 读者挑战
你能发现前面代码中的缺陷吗?一个可能会让一群缺乏经验的部队陷入混乱的缺陷。敌人部队和友军部队人数恰好相等的情况没有被明确处理,因此会由最后的else
来处理。但最后的else
原本是用于没有敌人部队的情况。我想,任何一个有自尊心的上尉都会期望他的部队在这种情况下战斗。他可以修改第一个if
语句来处理这种可能性:
if(isComingOverBridge && friendlyTroops >= enemyTroops)
最后,上尉的最后一个顾虑是,如果敌人举着白旗过桥投降,却被立即屠杀,那么他的手下就会成为战犯。所需的C++ 代码很明显。使用布尔变量wavingWhiteFlag
,他编写了如下测试代码:
if (wavingWhiteFlag) {
// 俘虏敌人
}
2
3
但这段代码放在哪里不太明确。最后,上尉选择了如下嵌套解决方案,并将对wavingWhiteFlag
的测试改为逻辑非,如下所示:
if (!wavingWhiteFlag) {
// 没有投降,所以检查其他所有情况
if(isComingOverTheBridge && friendlyTroops >= enemyTroops) {
// 开枪射击
}
else if(isComingOverTheBridge && friendlyTroops < enemyTroops) {
// 炸毁桥梁
}
}
else
{
// 这是第一个if的else部分
// 俘虏敌人
{
// 坚守阵地
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这表明我们可以在if
和else
语句中相互嵌套,做出相当深入和详细的判断。
我们可以继续使用if
和else
做出越来越复杂的判断,但我们目前所学到的内容已经足以作为入门介绍了。或许值得指出的是,通常解决一个问题的方法不止一种。正确的方法通常是以最清晰、最简单的方式解决问题的方法。
我们离掌握为云朵和蜜蜂制作动画所需的所有C++ 知识越来越近了。在回到游戏开发之前,还有最后一个动画相关的问题需要讨论。
# 计时
在移动蜜蜂和云朵之前,我们需要考虑时间控制。正如我们所知,主游戏循环会反复执行,直到玩家按下Escape键。
我们还了解到C++ 和SFML的运行速度非常快。实际上,我这台普通的笔记本电脑执行一个简单的游戏循环(比如当前这个)的速度大约是每秒五千次。考虑到这一点,我们来讨论一下如何使动画的每一帧显示速率保持一致并预先设定的问题。
# 帧率问题
我们来考虑一下蜜蜂的速度。为便于讨论,我们可以假设要让它以每秒200像素的速度移动。在一个宽度为1920像素的屏幕上,它大约需要10秒才能横穿整个屏幕宽度,因为10×200 = 2000(与1920足够接近)。
此外,我们知道可以使用setPosition(..., ...)
来设置任何精灵(sprite)的位置。我们只需要在括号中填入x和y坐标即可。
除了设置精灵的位置,我们还可以获取精灵的当前位置。例如,要获取蜜蜂的水平x坐标,可以使用以下代码:
float currentPosition = spriteBee.getPosition().x;
现在,蜜蜂当前的x(水平)坐标存储在currentPosition
中。为了让蜜蜂向右移动,我们可以将200(我们设定的速度)除以5000(我笔记本电脑上的大约帧率)得到的适当分数加到currentPosition
上,如下所示:
currentPosition += 200/5000;
现在,我们可以使用setPosition
来移动我们的蜜蜂。它将在每一帧中从左向右平滑移动200除以5000像素的距离。但这种方法存在两个问题。
帧率(frame rate)是指我们的游戏循环每秒处理的次数。也就是说,我们处理玩家输入、更新游戏对象并将它们绘制到屏幕上的次数。我们现在将详细讨论帧率问题,并且在本书的其余部分也会不断提及。
我笔记本电脑上的帧率可能并不总是恒定的。由于每一帧的执行速率不一致,蜜蜂在屏幕上移动时可能看起来像是间歇性地“加速”。
当然,我们希望我们的游戏受众不仅仅局限于我的笔记本电脑!每台电脑的帧率都会有所不同,至少会有轻微差异。如果你使用的是一台旧电脑,蜜蜂可能看起来像是被铅块拖累了;而如果你使用的是最新的游戏电脑,它可能会变成一只模糊的“涡轮蜜蜂”。
幸运的是,这个问题对每一款游戏来说都存在,并且SFML提供了一个巧妙的C++解决方案。理解这个解决方案的最简单方法就是去实现它。
# SFML的帧率解决方案
我们现在将测量并使用帧率来控制我们的游戏。要开始实现这一点,在主游戏循环之前添加以下代码:
// How fast is each cloud?
float cloud1Speed = 0; float cloud2Speed = 0; float cloud3Speed = 0;
// Variables to control time itself
Clock clock;
2
3
4
5
在前面的代码中,我们声明了一个Clock
类型的对象,并将其命名为clock
。类名以大写字母开头,而我们使用的对象名以小写字母开头。对象名是任意的,但clock
对于一个时钟来说似乎是个合适的名字。我们很快还会在这里添加一些与时间相关的变量。
现在,在游戏代码的更新部分,添加以下突出显示的代码:
/*
****************************************
Update the scene
****************************************
*/
// Measure time
Time dt = clock.restart();
/*
****************************************
Draw the scene
****************************************
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
正如你可能预期的那样,clock.restart()
函数会重新启动时钟。我们希望每一帧都重新启动时钟,这样我们就能测量每一帧所花费的时间。此外,它还会返回自我们上次重新启动时钟以来所经过的时间量。
因此,在前面的代码中,我们声明了一个名为dt
的Time
类型对象,并使用它来存储clock.restart()
函数返回的值。
现在,我们有了一个名为dt
的Time
对象,它保存了自我们上次更新场景并重新启动时钟以来所经过的时间量。也许你能猜到接下来要做什么了。
让我们在游戏中添加更多代码,然后看看可以用dt
做些什么。
dt
代表时间增量(delta time),即两次更新之间的时间。
我们将使用这个时钟来更新游戏引擎的功能,使其考虑时间因素。现在,我们的游戏循环可以用下面这张图来表示:
图2.2:基本游戏循环
随着SFML Clock
类的引入,我们的游戏循环可以用下面这张图更好地表示:
图2.3:带计时的基本游戏循环
让我们添加计时代码的关键部分,看看其中的数学原理是如何运作的。现在,我们可以通过根据每一帧执行所花费的时间来更新蜜蜂和云朵的位置,从而解决帧率不一致的问题。如果一帧执行得很快,我们让蜜蜂移动的距离就会比这一帧执行得慢时要少。
# 移动云朵和蜜蜂
让我们利用上一帧以来经过的时间,让蜜蜂和云朵“动起来”。这将解决在不同电脑上实现一致帧率的问题。
# 让蜜蜂“活起来”
我们首先要做的是将蜜蜂设置在一定的高度和速度。我们只希望在蜜蜂未激活时执行此操作。所以,我们将接下来的代码放在一个if
块中。查看并添加突出显示的代码,然后我们再进行讨论。
/*
****************************************
Update the scene
****************************************
*/
// Measure time
Time dt = clock.restart();
// Setup the bee
if (!beeActive) {
// How fast is the bee
srand((int)time(0 ));
beeSpeed = (rand() % 200 ) + 200;
// How high is the bee
srand((int)time(0) * 10 );
float height = (rand() % 500 ) + 500;
spriteBee.setPosition (2000 , height);
beeActive = true ;
}
/*
****************************************
Draw 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
现在,如果蜜蜂未激活,就像游戏刚开始时那样,if (!beeActive)
将为真,前面的代码将按以下顺序执行以下操作:
- 为随机数生成器设置种子。
- 生成一个介于200到399之间的随机数,并将结果赋给
beeSpeed
。 - 再次为随机数生成器设置种子。
- 生成一个介于500到999之间的随机数,并将结果赋给一个新的
float
变量height
。 - 将蜜蜂的位置设置为x轴上的2000(刚好在屏幕右侧之外),y轴上为
height
的值。 - 将
beeActive
设置为true
,这样在我们后面的代码中再次更改beeActive
之前,这段代码不会再次执行。
请注意,height
变量是我们在游戏循环中声明的第一个变量。此外,由于它是在一个if
块中声明的,在if
块之外它是“不可见”的。这对我们的使用来说没问题,因为一旦我们设置了蜜蜂的高度,就不再需要它了。这种影响变量的现象称为作用域(scope)。我们将在第4章“循环、数组、开关、枚举和函数:实现游戏机制”中更全面地探讨这个问题。
如果我们运行游戏,蜜蜂暂时还不会有任何动作,但现在蜜蜂已经激活,我们可以编写一些在beeActive
为真时运行的代码。
添加以下突出显示的代码,如你所见,只要beeActive
为真,这段代码就会执行。这是因为它紧跟在if (!beeActive)
块之后的else
语句中。
// Set up the bee
if (!beeActive) {
// How fast is the bee
srand((int)time(0) );
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Move the bee
{
spriteBee.setPosition (
spriteBee.getPosition() .x - (beeSpeed * dt.asSeconds ()),
spriteBee.getPosition().y);
// Has the bee reached the left-hand edge of the screen?
if (spriteBee.getPosition() .x < -100 ) {
// Set it up ready to be a whole new bee next frame
beeActive = false ;
}
}
/*
****************************************
Draw 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
在else
块中,会发生以下事情。
蜜蜂的位置根据以下标准进行更改。setPosition
函数使用getPosition
函数获取蜜蜂当前的水平坐标。然后从该坐标中减去beeSpeed * dt.asSeconds()
。
beeSpeed
变量的值表示每秒移动的像素数,它是在上一个if
块中随机分配的。dt.asSeconds()
的值将是一个小于1的分数,表示上一帧动画所花费的时间。
假设蜜蜂当前的水平坐标是1000。现在,假设有一台普通电脑的帧率为每秒5000帧。这意味着dt.asSeconds
将是0.0002。进一步假设beeSpeed
被设置为最大值399像素/秒。那么,确定setPosition
用于水平坐标的值的代码可以这样解释:
1000 - 0.0002 x 399
因此,蜜蜂在水平轴上的新位置将是999.9202。我们可以看到,蜜蜂正非常非常平滑地向左移动,每帧移动不到一个像素。如果帧率发生波动,那么这个公式将产生一个合适的新值。如果我们在一台帧率仅为每秒100帧的电脑上,或者在一台帧率达到每秒100万帧的电脑上运行相同的代码,蜜蜂都将以相同的速度移动。
setPosition
函数使用getPosition().y
来确保蜜蜂在整个激活周期内保持完全相同的垂直坐标。
我们刚刚添加的else
块中的最后一部分代码再次展示如下,以便我们接下来讨论:
// Has the bee reached the right hand edge of the screen?
if (spriteBee.getPosition() .x < -100 ) {
// Set it up ready to be a whole new bee next frame
beeActive = false;
}
2
3
4
5
这段代码在每一帧(当beeActive
为真时)检查蜜蜂是否已经移出屏幕左侧。如果getPosition
函数返回的值小于 -100,那么玩家肯定看不到它了。当这种情况发生时,beeActive
被设置为false
,在下一帧,一只“新”蜜蜂将以新的随机高度和新的随机速度开始飞行。
试着运行游戏,观察我们的蜜蜂尽职地从右向左飞行,然后以新的高度和速度回到右侧重新开始。每次都几乎像是一只新蜜蜂。
当然,真正的蜜蜂会在你试图专心砍树的时候在你身边嗡嗡很久。而且,真正的蜜蜂可能还会改变高度。别担心,我们在每个项目中都会制作更高级的游戏对象。关键是,为了制作更可持续的电子游戏,你应该尽可能地回收/重复使用你的精灵和纹理。
现在,我们将以非常相似的方式让云朵动起来。
# 飘动的云朵
我们首先要做的是将第一朵云设置在特定的高度和速度。只有当云朵未激活时,我们才会执行此操作。因此,我们会把接下来的代码放在另一个if
代码块中。仔细查看并添加突出显示的代码,添加在为蜜蜂编写的代码之后,然后我们再来讨论。这段代码与处理蜜蜂的代码有很多相似之处。
else
// Move the bee
{
spriteBee.setPosition(
spriteBee.getPosition().x - (beeSpeed * dt.asSeconds()),
spriteBee.getPosition().y);
// Has the bee reached the right hand edge of the screen?
if (spriteBee.getPosition().x < -100) {
// Set it up ready to be a whole new bee next frame
beeActive = false;
}
}
// Manage the clouds // Cloud 1
if (!cloud1Active) {
// How fast is the cloud
srand((int)time(0 ) * 10 );
cloud1Speed = (rand() % 200 );
// How high is the cloud
srand((int)time(0 ) * 10 );
float height = (rand() % 150 );
spriteCloud1.setPosition( -200 , height);
cloud1Active = true ;
}
/*
****************************************
Draw 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
我们刚刚添加的代码与处理蜜蜂的代码之间唯一的区别在于,我们处理的是不同的精灵,并且使用了不同的随机数范围。此外,我们对time(0)
返回的结果乘以10,这样就能确保每朵云都有不同的随机数种子。当我们编写其他云朵移动的代码时,你会看到我们分别使用乘以20和乘以30的方式。
现在,当云朵处于激活状态时,我们可以对其进行相应操作。我们将在else
代码块中实现。与if
代码块一样,这段代码与处理蜜蜂的代码相同,只是所有代码都是针对云朵而非蜜蜂进行操作。
// Manage the clouds
if (!cloud1Active) {
// How fast is the cloud
srand((int)time(0) * 10);
cloud1Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 10);
float height = (rand() % 150);
spriteCloud1.setPosition(-200, height);
cloud1Active = true;
}
else
{
spriteCloud1.setPosition(
spriteCloud1.getPosition() .x + (cloud1Speed * dt.asSeconds ()),
spriteCloud1.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud1.getPosition() .x > 1920 )
{
// Set it up ready to be a whole new cloud next frame
cloud1Active = false ;
}
}
/*
****************************************
Draw 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
既然我们知道该怎么做了,就可以为第二朵和第三朵云复制相同的代码。在第一朵云的代码之后立即添加以下突出显示的代码,用于处理第二朵和第三朵云:
...
// Cloud 2
if (!cloud2Active) {
// How fast is the cloud
srand((int)time(0 ) * 20 );
cloud2Speed = (rand() % 200 );
// How high is the cloud
srand((int)time(0 ) * 20 );
float height = (rand() % 300 ) - 150;
spriteCloud2.setPosition( -200 , height);
cloud2Active = true ;
}
else
{
spriteCloud2.setPosition(
spriteCloud2.getPosition() .x + (cloud2Speed * dt.asSeconds ()),
spriteCloud2.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud2.getPosition() .x > 1920 )
{
// Set it up ready to be a whole new cloud next frame
cloud2Active = false ;
}
}
if (!cloud3Active) {
// How fast is the cloud
srand((int)time(0 ) * 30 );
cloud3Speed = (rand() % 200 );
// How high is the cloud
srand((int)time(0 ) * 30 );
float height = (rand() % 450 ) - 150;
spriteCloud3.setPosition( -200 , height);
cloud3Active = true ;
}
else
{
spriteCloud3.setPosition(
spriteCloud3.getPosition() .x + (cloud3Speed * dt.asSeconds ()),
spriteCloud3.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud3.getPosition() .x > 1920 )
{
// Set it up ready to be a whole new cloud next frame
cloud3Active = false ;
}
}
/*
****************************************
Draw 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
现在,你可以运行游戏了。云朵会随机且持续地在屏幕上飘动,蜜蜂会从右向左嗡嗡飞行,然后再次在右侧重新生成。
图2.4:飘动的云朵
处理云朵和蜜蜂的这些代码看起来是不是有点重复?我们会学习如何减少大量的代码输入,并让代码更具可读性。在C++中,有办法处理同一类型的多个变量或对象实例。这些被称为数组(arrays),我们将在第4章 “循环、数组、开关、枚举和函数:实现游戏机制” 中学习它们。此外,我们还会了解如何在不同的值上执行相同的代码,而无需像这里一样多次编写代码(通过编写我们自己的自定义函数来实现)。所有这些节省时间的技巧将在第4章中进行探讨。在进一步开发游戏功能之前,优先推进游戏功能的实现,而不是过早引入更多C++知识,这是经过深思熟虑的选择。在本书结束时,你将知道如何把这个游戏做得比我们现在更好。
在继续学习之前,我建议你对本章的代码进行一些修改尝试。比如用你自己的图片替换纹理文件,改变蜜蜂和云朵的速度,或者让蜜蜂以正弦波的形式在屏幕上上下移动。下面来看看一些与本章主题相关的常见问题。
# 总结
在本章中,我们了解到变量是内存中一个有名称的存储位置,我们可以在其中存储特定类型的值。数据类型包括整型(int
)、单精度浮点型(float
)、双精度浮点型(double
)、布尔型(bool
)、字符串型(String
)和字符型(char
)。
我们可以声明并初始化游戏所需的所有变量,用于存储数据。有了这些变量后,我们可以使用算术运算符和赋值运算符对它们进行操作,还可以在逻辑运算符的测试中使用它们。结合if
和else
关键字,我们可以根据游戏当前的情况来分支执行代码。
运用这些新知识,我们为云朵和蜜蜂制作了动画效果。在下一章中,我们将进一步运用这些技能,添加平视显示器(heads-up display,HUD),为玩家增加更多输入选项,以及使用时间条直观地表示时间。
# 常见问题
**问:**为什么当蜜蜂的位置达到 -100时,我们将它设置为未激活状态?为什么不是设置为0,毕竟0是窗口的左侧边缘位置?
**答:**蜜蜂的图像宽60像素,其原点位于左上角的像素点。因此,当蜜蜂的原点绘制在x等于0的位置时,整个蜜蜂图像仍然在玩家的可视范围内。等到蜜蜂的位置达到 -100时,我们就能确保它离开了玩家的视线。
**问:**我怎么知道我的游戏循环速度有多快?
**答:**如果你使用的是现代NVIDIA显卡,也许可以通过配置GeForce Experience覆盖层来显示帧率。不过,要通过我们自己的代码明确测量帧率,还需要学习更多知识。我们将在第5章 “碰撞、声音和结束条件:让游戏可玩” 中添加测量和显示当前帧率的功能。
**问:**在C++中,赋值运算符=
和等于运算符==
有什么区别?
**答:**赋值运算符=
用于给变量赋值。例如,int x = 5
将值5赋给变量x
。等于运算符==
用于比较两个值是否相等。例如,if (x == 5)
用于检查变量x
的值是否等于5。
**问:**在C++中,使用SFML库时,精灵(sprites)和纹理(textures)是如何协同工作的?
**答:**在SFML中,Texture
表示从文件加载的图像,而Sprite
是可以绘制在屏幕上的二维图像。setTexture
函数将Texture
与Sprite
关联起来,从而能够在屏幕上渲染图像。你可以操作精灵的位置、旋转角度和缩放比例,SFML会利用GPU高效地处理渲染过程。
**问:**在C++中生成随机数时,为随机数生成器设置种子的目的是什么?
**答:**为随机数生成器设置种子对于确保每次程序运行时生成不同的随机数序列至关重要。如果不设置种子,生成器每次运行程序时都会生成相同的数字序列,这样结果就是可预测的,而非随机的。通常,当前时间被用作随机种子。这与在像《我的世界》这样的游戏中,为生成唯一地图而设置种子的原理基本相同。在后面的最终项目中,我们将使用更高级的技术来生成随机数。