第4章 循环、数组、switch语句、枚举和函数:实现游戏机制
# 第4章 循环、数组、switch
语句、枚举和函数:实现游戏机制
本章涵盖的C++知识可能比本书任何其他章节都要多。它充满了一些基础概念,这些概念将极大地加快我们的理解速度。它还将开始阐明我们一直有点跳过的一些模糊领域,比如函数、游戏循环,以及一般意义上的循环。
我们将探讨以下内容:
- 循环
- 数组
- 使用
switch
语句进行决策 - 类枚举
- 函数入门
- 让树枝动起来
在探索完一系列C++语言必备知识后,我们将运用所学,实现主要的游戏机制——让树枝动起来。在本章结束时,我们将为最后阶段做准备,完成《Timber!!!》游戏的开发。
# 循环
欢迎来到C++的循环世界!循环是一种通用的编程结构,并非C++所独有,它允许你多次重复执行某段代码。循环对于提高游戏的效率和灵活性至关重要。这或许是计算机如此有用的关键所在:重复执行相同的操作,但每次使用不同的值。在C++中,有多种类型的循环,每种都有特定的用途。在本章中,我们将探索基本的循环结构,并介绍C++中与循环编程相关的较新更新内容。到目前为止,我们最明显的例子就是游戏循环。去掉所有代码后,我们的游戏循环看起来是这样的:
while (window.isOpen()) {
}
2
3
这种类型的循环的正确术语是while
循环。让我们先来看看它。
# while循环
while
循环相当直观。回想一下if
语句,其表达式的求值结果要么为true
,要么为false
。在while
循环的条件表达式中,我们可以使用与if
语句完全相同的运算符和变量组合。
与if
语句一样,如果表达式为true
,则执行代码。然而,while
循环的不同之处在于,只要条件为true
,其中的C++代码就会不断重复执行,甚至可能永远执行下去,直到条件变为false
。看下面这段代码:
int numberOfZombies = 100;
while (numberOfZombies > 0) {
// Player kills a zombie
numberOfZombies--;
// numberOfZombies decreases each pass through the loop
}
// numberOfZOmbies is no longer greater than 0
2
3
4
5
6
7
8
9
10
上面这段代码的执行过程是这样的。在while
循环外部,声明了int
类型的变量numberOfZombies
并初始化为100。然后,while
循环开始,其条件表达式是numberOfZombies > 0
。因此,while
循环会不断执行循环体中的代码,直到该条件求值为false
。这意味着上述代码将执行100次。
在第一次循环时,numberOfZombies
等于100,然后是99,接着是98,依此类推。但一旦numberOfZombies
等于0,它当然就不再大于0了。此时,代码将跳出while
循环,在右花括号之后继续运行。
与if
语句一样,while
循环也有可能一次都不执行。看下面这段代码:
int availableCoins = 10;
while (availableCoins > 10) {
// more code here.
// Won't run unless availableCoins is greater than 10
}
2
3
4
5
6
在上述代码中,循环条件求值为false
,因为availableCoins
并不大于10。由于条件为false
,该循环一次都不会执行。
此外,表达式的复杂程度以及循环体中代码的数量都没有限制。我们已经在游戏循环中编写了相当多的代码。考虑一下这个假设的游戏循环变体:
int playerLives = 3; int alienShips = 10;
while (playerLives !=0 && alienShips !=0 ) {
// Handle input
// Update the scene // Draw the scene
}
// continue here when either playerLives or alienShips equals 0
2
3
4
5
6
7
8
上述while
循环会持续执行,直到playerLives
或alienShips
等于0。一旦其中一个条件满足,表达式就会求值为false
,程序将从while
循环之后的第一行代码继续执行。
值得注意的是,一旦进入循环体,即使在执行过程中表达式变为false
,循环体也至少会尝试执行一次,因为直到代码尝试开始下一次循环时,才会再次检查条件。例如,看下面这段代码:
int x = 1;
while(x > 0)
{
x--;
// x is now 0 so the condition is false
// But this line still runs
// and this one
// and me!
}
// Now I'm done!
2
3
4
5
6
7
8
9
10
11
12
上述循环体将执行一次。我们还可以设置一个永远运行的while
循环,这种循环被恰当地称为无限循环。下面是一个例子:
int y = 0;
while(true) {
y++; // Bigger. . . Bigger. . .
cout << y;
}
2
3
4
5
6
如果你觉得上述循环难以理解,那就从字面意思去想。当循环条件为true
时,循环就会执行。嗯,true
永远都是true
,所以循环会一直执行下去。每次循环时,y
的值都会增加1并被打印出来。
有趣的是,y
能达到的值是有限的。如果你查看第2章的变量类型表,就会注意到int
类型能存储的最大值是有限的。int
类型在32位或64位机器上可能有所不同,甚至编译器的品牌也会影响int
能存储的值,但通常情况下,int
是16位数据,取值范围是 -32767到32767。上述代码会一直加到最大值32767,然后下一个值将是 -32767,再经过32767次循环后,y
又会回到0。你可以创建一个空的控制台应用程序,将上述代码粘贴到main
函数中试试看。不需要复杂的SFML配置,只需记得在代码顶部添加#include <iostream>
,并在main
函数之前添加using namespace std;
,这样就能使用cout
了。
不管循环是不是无限循环,有时我们需要一种方法,提前跳出循环,而不必等到循环条件满足。例如,一个跟踪玩家或外星人是否全部死亡的游戏循环是没问题的,但如果玩家想提前退出游戏呢?下面介绍如何实现。
# 跳出循环
我们可能会使用无限循环,这样就能在循环体内部决定何时退出循环,而不是在表达式中决定。当我们准备离开循环体时,可以使用break
关键字,就像这样:
int z = 0;
while(true) {
z++; // Bigger. . . Bigger. . .
cout << z;
break ; // No you're not
// Code doesn't reach here
}
2
3
4
5
6
7
8
9
在上述代码中,z
最初等于0,然后通过z++
进行自增,接着使用cout
打印z
的值。但紧接着,break
关键字使代码退出了循环。即使break
关键字后面还有更多代码行,它也会产生这样的效果。更有用的是,我们可以有条件地使用break
,接下来就会讨论这一点。
你可能也已经猜到,我们可以在while
循环以及其他类型的循环中,结合使用任何C++决策工具(比如if
、else
,以及我们马上要学习的switch
)。看下面这个例子:
int x = 0;
int max = 10;
while(true)// Potentially infinite
{
x++; // Bigger. . . Bigger. . .
if(x == max)// Not infinite anymore
{
break ;
}
// code reaches here only until max equals 10
}
2
3
4
5
6
7
8
9
10
11
12
13
14
这段代码展示了对无限循环的一种控制使用方式,它会根据特定条件(x == max
)退出循环。当你需要重复执行某项任务,直到满足某个条件时,就可以使用这种方式。在这个例子中,它会不断增加x
的值,直到达到max
的值,此时循环就会退出。
作为while
循环的最后一个例子,让我们看看如何让用户决定while
循环何时退出。当然,作为游戏程序员,我们将决定玩家选择的格式和时机。在接下来的例子中,我还会引入一个新关键字cin
。看看你能否理解发生了什么:
int userInput;
while (true) {
cout << "Enter a positive number to exit: ";
cin >> userInput;
if (userInput > 0)
{
break;
}
cout << "Invalid input. Try again.";
}
2
3
4
5
6
7
8
9
10
11
这个例子使用while
循环来验证用户输入。循环会一直持续,直到用户输入一个正数,当满足条件时,使用break
退出循环。
用户输入是通过cin
实现的,cin
会暂停程序执行,等待用户输入一个数字,然后按下回车键。注意,cin
使用的运算符方向与cout
相反,是>>
而不是<<
。这个运算符叫做提取运算符。
代码会不断提示用户输入,当接收到有效输入(大于0)时,break
语句会退出循环。
关于使用break
关键字的最后一点说明,一般认为尽量少用它是一种良好的编程习惯,因为它可能会使代码更难理解。但也不用害怕使用它;有些时候它确实是你需要的。有时候,在思考循环的最佳形式时,我会忘记break
,但后来又会想起来,然后意识到它正是我需要的。一个经验法则是,不要一开始就想着在代码中设计break
,但如果它作为一种有效解决方案出现,并且没有更清晰的解决方案,那就接受它。
如果你想尝试上述代码,可以将其复制到现有或新的控制台应用程序的main
函数中。不需要复杂的SFML配置,只需记得在代码顶部添加#include <iostream>
,并在main
函数之前添加using namespace std;
,这样就能使用cout
和cin
了。
深入了解一下cin
,它是一个对象,用于从控制台读取用户输入。cin
与提取运算符>>
配合使用,使我们能够在程序执行过程中交互式地获取输入。如果你想编写一个20世纪70、80年代风格的文本冒险游戏,cin
、cout
、循环、变量和条件语句几乎就是你所需要的全部。cin
是一个类的实例,它是一个对象。其他人编写了这个类,在这个例子中是istream
类,我们通过cin
创建了它的一个实例,并使用了这个非常有用的功能,而无需担心它的工作原理。当我们在第6章详细讨论类、实例和对象时,这个难题就会完全清楚了。
我们可以花很长时间研究C++中while
循环的各种变体,但在某个时候,我们还是想回到游戏开发上。所以,让我们继续学习另一种类型的循环。
# for循环
C++中的for
循环适用于需要遍历一系列值的情况。它提供了一种简洁的方式来重复执行一组语句。
一个典型的for
循环由三个部分组成:初始化、条件和迭代语句,这使得控制循环的执行变得很容易。当预先知道迭代次数时,for
循环特别有用。
正是因为这三个部分,for
循环的语法比while
循环稍微复杂一些,因为设置一个for
循环需要这三个部分。先看代码,然后我们再进行拆解:
for (int x = 0; x < 100; x ++)
{
// Something that needs to happen 100 times goes here
}
2
3
4
for
循环条件的各个部分的作用如下:
for (声明和初始化; 条件; 每次迭代前的变化)
为了进一步说明,下面用一个表格来解释上述for
循环示例中三个关键部分的含义。
部分 | 描述 |
---|---|
声明和初始化 | 我们创建一个新的int 变量i 并初始化为0 |
条件 | 与其他循环一样,它指的是循环执行必须满足的条件 |
每次循环后的变化 | 在示例中,x++ 表示每次循环时x 的值增加1 |
表4.1:for
循环的关键部分
总之,上述for
循环代码利用循环进行了100次迭代。它将循环变量x
初始化为0,设置循环条件为只要x
小于100就继续循环,并且每次迭代时将x
的值增加1。循环体内由花括号括起来的代码块,表示要执行100次的任务。当你有一段代码需要重复执行预定次数时,这种方式非常有用。在这种情况下,循环使得处理重复任务的代码简洁明了。
我们可以对for
循环进行各种变化,以实现更多功能。下面是另一个简单的倒计时示例:
for(int i = 10; i > 0; i--)
{
// countdown
}
// blast off
2
3
4
5
6
for
循环控制着初始化、条件求值,以及控制变量本身。在本章后面的内容中,我们将在游戏中使用for
循环。for
循环还有更高级的用法,但我们需要先学习更多知识,才能进行讨论。在下一节讨论数组时,我们将看到其中一种更高级的用法。
# 数组
数组(Arrays)是一种数据结构,它允许我们使用单个名称(比如someInts
、myFloats
或 zombieHorde
)来存储同一数据类型的元素集合。数组提供了一种便捷的方式来组织和处理数据,有助于实现更高效、结构化的编程。数组对于处理重复性数据特别有用,例如数字列表、字符列表或游戏对象列表。本介绍将探讨数组的基础知识,在本书后续内容中,我们还会看到数组更高级的用法。
与普通变量进行对比可能会有所帮助。如果说变量是一个可以存储特定类型(如int
、float
或char
)值的盒子,那么我们可以把数组想象成一排盒子。这排盒子的大小和类型几乎可以是任意的,包括由类创建的对象。不过,所有盒子必须是同一类型。
在学习本书最后平台游戏项目中更高级的C++知识后,在一定程度上可以规避每个盒子必须使用相同类型这一限制。
如果你认为这个数组对于第2章 “变量、运算符和决策:精灵动画” 中的云朵来说可能会很有用,那你完全正确。可惜对于云朵来说已经来不及了,它们注定永远是又笨又臃肿的代码。不过,我们将使用数组来实现树枝。那么,我们该如何创建和使用数组呢?
# 声明数组
我们可以像这样声明一个int
类型变量的数组:
int someInts[10];
现在,我们有了一个名为someInts
的数组,它可以存储10个int
值。不过,目前它是空的。
数组与普通变量唯一的区别在于,我们需要使用一种称为数组表示法的格式来操作各个值。因为虽然我们的数组有一个名称(someInts
),但各个元素并没有各自的名称:
someInts_AliensRemaining = 99; // 错误
someInts_Score = 100; // 错误!
2
让我们来看看具体该怎么做。
# 初始化数组元素
要向数组元素中添加值,我们可以使用已经熟悉的语法,再结合我提到的新语法,即数组表示法。在下面的代码中,我们将99存储到数组的第一个元素中:
someInts[0] = 99;
要将999存储到第二个元素中,我们编写如下代码:
someInts[1] = 999;
我们可以像这样将3存储到最后一个元素中:
someInts[9] = 3;
请注意,数组元素的索引总是从0开始,一直到数组大小减1。与普通变量类似,我们可以操作存储在数组中的值。
在接下来的代码中,我们将看到如何操作各个值。下面展示了如何将第一个和第二个元素相加,并将结果存储在第三个元素中:
someInts[2] = someInts[0] + someInts[1];
数组也可以与普通变量无缝交互,例如:
int a = 9999;
someInts[4] = a;
2
关于数组还有很多需要学习的内容,让我们继续深入。
# 快速初始化数组元素
我们可以像下面这个使用float
数组的示例一样,快速向元素中添加值:
float myFloatingPointArray[3] {3.14f, 1.63f, 99.0f};
现在,3.14、1.63和99.0分别存储在第一个、第二个和第三个位置。请记住,当使用数组表示法访问这些值时,我们要使用[0]
、[1]
和[2]
。
还有其他方法可以初始化数组元素。下面这个有点抽象的示例展示了如何使用for
循环将0到9的值放入uselessArray
数组中:
for(int i = 0; i < 10; i++)
{
uselessArray[i] = i;
}
2
3
4
这段代码假设uselessArray
之前已经被初始化,至少可以容纳10个int
变量。
# 这些数组对我们的游戏到底有什么用?
我们可以在任何可以使用普通变量的地方使用数组,例如在这样的表达式中:
// someArray[4] 被声明并初始化为9999
for(int i = 0; i < someArray[4]; i++)
{
// 循环执行9999次
}
2
3
4
5
6
本节开头已经暗示了数组在游戏代码中最大的好处。数组可以存储对象(类的实例)。假设我们有一个Zombie
类,并且想要存储一堆僵尸对象。我们可以像下面这段假设的代码这样做:
Zombie horde [5] {zombie1, zombie2, zombie3}; // 等等……
现在,horde
数组存储了大量Zombie
类的实例。每个实例都是一个独立的、会 “活”(某种意义上)、会 “呼吸”、能 “自主行动” 的僵尸对象。然后,我们可以在每次游戏循环中遍历horde
数组,移动僵尸,并检查它们的头是否被斧头砍到,或者是否抓到了玩家。
如果我们当时就知道数组,那么用它来处理云朵再合适不过了。我们本可以有数百朵云,而且编写的代码会比处理那区区三朵云少得多。
要查看完整且可运行的改进后云朵代码,请查看第5章文件夹下载包中的《Timber!!!》增强版。或者,你也可以在查看代码之前自己尝试使用数组实现云朵功能。
要真正理解数组的这些内容,最好的方法是实际看一下它们的运行效果。在实现树枝功能时,我们就会看到。
目前,我们先保留云朵代码不变,以便尽快为游戏添加更多功能。但首先,我们来学习更多C++中使用switch
进行决策的知识。
# 使用switch
进行决策
我们已经见过if
关键字,它允许我们根据表达式的结果来决定是否执行一段代码。但有时,在C++中用其他方式进行决策会更好。switch
通常用于为一系列嵌套的if-else
语句提供一种更优雅的替代方案。正如我们将看到的,它会计算一个表达式的值,并控制程序流程。
当我们必须根据一系列明确的可能结果进行决策,且这些结果不涉及复杂的组合或广泛的值范围时,switch
通常是一个不错的选择。我们像这样开始一个switch
决策:
switch(expression )
{
// 更多代码写在这里
}
2
3
4
在前面的示例中,expression
可以是一个实际的表达式,也可以只是一个变量。然后,在花括号内,我们可以根据表达式的结果或变量的值进行决策。我们使用case
和break
关键字来实现,就像下面这个有点抽象的示例:
case x:
// 针对x的代码
break;
case y:
// 针对y的代码
break;
2
3
4
5
6
7
从前面的抽象示例中可以看到,每个case
都列出了一个可能的结果,每个break
表示该case
的结束,以及执行离开switch
块的位置。
经典的、不抽象的示例是使用一周中的天数,如下所示:
int dayNumber = 3;
switch (dayNumber) {
case 1:
// 周一发生的事情
break;
case 2:
// 周二发生的事情
break;
// 等等
default:
// 无效日期的代码
}
2
3
4
5
6
7
8
9
10
11
12
在前面的代码中,一个名为dayNumber
的int
变量被赋予值3,代表一周中的某一天。switch
条件会计算dayNumber
的值。每个case
对应特定的一天,并且都有一段相应的代码块。
不过,这里引入了一些新内容。我们还可以选择使用不带值的default
关键字,以便在没有任何case
语句的值为true
时运行一些代码。这有点像if
表达式后面不带表达式的else
关键字,例如下面这段代码:
default: // 没有值
// 如果没有其他case语句为真,在此处执行某些操作
break;
2
3
作为switch
的最后一个示例,考虑一个复古文本冒险游戏,玩家输入像'n'
、'e'
、's'
或'w'
这样的字母来向北、向东、向南或向西移动。可以使用一个switch
块来处理玩家的每个可能输入:
// 从用户输入中获取一个char类型的command
char command;
cin >> command;
switch(command) {
case 'n':
// 在此处处理移动操作
break;
case 'e':
// 在此处处理移动操作
break;
case 's':
// 在此处处理移动操作
break;
case 'w':
// 在此处处理移动操作
break;
// 更多可能的情况
default:
// 要求玩家重试
break;
}
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
要理解我们所学到的关于switch
的所有内容,最好的方法是将它与正在学习的其他新概念一起应用到实际代码中。首先,我们需要了解枚举(enumerations),它可以帮助我们使代码更加精确。
# 类枚举
枚举是逻辑集合中所有可能值的列表。C++枚举是一种很好的枚举事物的方式。例如,如果我们的游戏使用的变量只能在特定的值范围内,并且这些值在逻辑上可以构成一个集合,那么使用枚举可能是比较合适的。枚举会使你的代码更清晰,也更不容易出错。例如,在使用一周天数的switch
示例中,谁来决定一周的第一天是哪一天呢?如果有人认为dayNumber
代表其他含义,并对它进行一些算术运算会怎样呢?突然之间,我们的日期编号系统就乱套了。类枚举可以解决这个问题以及其他问题。
要在C++中声明一个类枚举,我们一起使用enum class
这两个关键字,后面跟着枚举的名称,再跟着枚举可以包含的值,这些值用一对花括号{...}
括起来。
例如,看下面这个枚举声明。请注意,按照惯例,枚举中的可能值通常全部用大写字母声明:
enum class daysOfWeek
{
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
};
2
3
4
5
6
7
8
9
10
或者在一个可能的游戏场景中,有这样一个更有趣的示例:
enum class zombieTypes
{
REGULAR,
RUNNER,
CRAWLER,
SPITTER,
BLOATER
};
2
3
4
5
6
7
8
请注意,此时我们还没有声明zombieType
的任何实例,只是定义了该类型本身的结构和元数据。如果这听起来有点奇怪,可以这样想。SFML创建了Sprite
、RectangleShape
和RenderWindow
类,但要使用这些类中的任何一个,我们都必须声明该类的一个对象/实例。
此时,我们创建了一个名为zombieTypes
的新类型,但还没有它的实例。那么,现在就来创建:
zombieType Rishi = zombieTypes::CRAWLER;
zombieType Suella = zombieTypes::SPITTER;
zombieType Boris = zombieTypes::BLOATER;
/*
僵尸是虚构的生物,
与现实中的任何人相似纯属巧合
*/
2
3
4
5
6
7
8
接下来是我们很快会添加到《Timber!!!》中的代码的一个预览。我们想要跟踪树枝或玩家在树的哪一侧,所以我们将声明一个名为side
的枚举,如下所示:
enum class side
{
LEFT,
RIGHT,
NONE
};
2
3
4
5
6
我们可以像这样将玩家定位在左侧:
// 玩家从左侧开始
side playerSide = side::LEFT;
2
我们可以让树枝位置数组的第四层(数组从0开始计数)没有树枝,如下所示:
branchPositions[3] = side::NONE;
值得注意的是,我们也可以在表达式中使用枚举:
if (branchPositions[5] == playerSide)
{
// 最低的树枝与玩家在同一侧
// 被压扁了!
}
2
3
4
5
此外,我们还可以在switch
中使用枚举。下面这段代码使我们之前关于一周天数的示例更加清晰:
daysOfWeek day = daysOfWeek::WEDNESDAY;
switch (day) {
case daysOfWeek::MONDAY:
std::cout << "It's Monday";
break;
case daysOfWeek::TUESDAY:
std::cout << "It's Tuesday";
break;
case daysOfWeek::WEDNESDAY:
std::cout << "It's Wednesday";
break;
case daysOfWeek::THURSDAY:
std::cout << "It's Thursday";
break;
case daysOfWeek::FRIDAY:
std::cout << "It's Friday";
break;
case daysOfWeek::SATURDAY:
std::cout << "It's Saturday";
break;
case daysOfWeek::SUNDAY:
std::cout << "It's Sunday";
break;
default:
std::cout << "OOPS try again.";
}
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
在前面的示例中,使用了daysOfWeek
枚举而不是int
。switch
语句计算day
变量的值,每个case
对应一周中的特定一天。和之前一样,default
情况处理可能遇到的任何无效日期。在前面的示例中,很明显星期三的代码块会被执行。
我们再看一个至关重要的C++主题,然后就回到游戏编码中。
# 函数入门
欢迎来到C++函数的世界。函数是C++编程的基本构建块之一。我之前说过,你可能已经掌握了足够的C++知识来编写一款复古文字冒险游戏,在学习了函数之后,你肯定能做到!
函数让我们能够将代码中可复用的部分封装起来,创建结构井然的程序。本章接下来的内容将带你逐步了解函数的基础知识,从基本语法到更高级的概念,为你提供全面的基础,最后讲解函数和类如何属于同一主题。这样,你就准备好在本章和下一章完成这个游戏,并充满信心地迈向第6章,在那里我们将最终探讨面向对象编程的话题。
C++函数究竟是什么呢?函数是变量、表达式和控制流语句(循环和分支)的集合。实际上,到目前为止我们在本书中学到的任何代码都可以在函数中使用。事实上,我们到目前为止编写的所有代码都在主函数(main function)中。快速浏览一下我们目前的项目代码就会发现,我们已经有几百行代码了。正如在函数介绍中所建议的,我们很快就会开始将未来所有的代码进行分离(模块化)和组织(封装),使其成为易于管理的代码块。
我曾考虑过在我们学习了更好的方法之后重写《Timber!!!》这款游戏,但后来决定将其留给那些有意愿的人作为练习。
我们编写的函数的第一部分被称为函数签名(signature)。下面是一个函数签名的示例:
public void shootLazers(int power, int direction)
如果我们添加一对花括号{...}
,并在其中放入函数要执行的代码,那么我们就得到了一个完整的函数,即函数定义(definition):
public void shootLazers(int power, int direction)
{
// ZAPP!
}
2
3
4
然后,我们可以在代码的其他部分使用这个新函数,比如这样:
// Attack the player
shootLazers(50, 180) // Run the code in the function
// I'm back again - code continues here after the function ends
2
3
当我们使用一个函数时,我们说我们调用(call)了它。在调用shootLazers
函数的地方,我们程序的执行会分支到该函数内部的代码。函数会一直运行,直到到达结尾或者被要求返回。然后,代码会从函数调用后的第一行继续运行。我们已经在使用SFML提供的函数了。这里的不同之处在于,我们将学习编写和调用自己的函数。
下面是另一个函数的示例,其中包含使函数返回到调用它的代码的语句:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
2
3
4
5
调用上述函数的代码可能如下:
int myAnswer = addAToB(2, 4);
显然,我们不需要编写函数来将两个变量相加,但这个过于简化的示例有助于我们更深入地了解函数的工作原理。首先,我们传入值2和4。在函数签名中,值2被赋给int a
,值4被赋给int b
。
在函数体内部,变量a
和b
相加,并用于初始化新变量int answer
。return answer;
这行代码的作用就是将存储在answer
中的值返回给调用代码,使得myAnswer
被初始化为值6。
注意,上面示例中的每个函数签名都略有不同。这是因为C++函数签名非常灵活,允许我们构建出所需的函数。
函数签名究竟如何定义函数的调用方式,以及函数是否必须返回值(如果需要,如何返回),这值得进一步探讨。让我们给签名的每个部分起个名字,这样就能将其分解并进行学习。
下面是一个函数签名,其各部分用正式的技术术语进行描述:返回类型 | 函数名 | (参数)
下面是每个部分的一些示例:
- 返回类型(Return-type):
bool
、float
、int
等,或者任何C++类型或表达式。 - 函数名:
shootLazers
、addAToB
等。 - 参数(Parameters):
(int number, bool hitDetected)
、(int x, int y)
、(float a, float b)
此时,对C++的设计、编程和计算机硬件进行简要介绍可能是有价值的。
是谁设计了这些奇怪又令人沮丧的语法,为什么是这样的呢?
有时,C++初学者会对这门语言的设计方式提出质疑,函数(以及面向对象编程)这一主题的语法,尤其会因设计问题而受到开发者的质疑。要记住的是,C++的语法,尤其是函数的语法,并不是凭空设计出来的。它们是围绕计算机系统(特别是中央处理器,CPU)的工作方式设计和选择的。
正如我们所学,在C++中,函数帮助我们组织和模块化代码。当调用一个函数时,会发生几个步骤。
我们知道,当调用一个函数时,程序的控制流会转移到该函数。CPU执行一条跳转指令,跳转到与该函数相关联的内存地址。这个内存地址对我们是隐藏的,但实际上,它包含在我们给函数起的名字中。
接下来,会执行一个称为函数序言(function prologue)的阶段,这涉及设置函数的栈帧(stack frame)。作为程序员,这对我们是完全隐藏的,但这是CPU处理事务的一部分。调用函数(通常是main
函数)的当前状态会被保存,包括返回地址和保存值的重要CPU寄存器的值。
在这个阶段,我们的变量和函数参数会在栈(stack)上分配空间。栈是CPU内部的一块计算机内存区域,用于动态存储函数调用信息、局部变量和控制流数据。我们的函数参数通常通过CPU寄存器传递,或者压入这个栈中。函数内部的变量,即局部变量(local variables),在栈上创建并初始化。
接下来,被调用函数的函数体执行,函数内部可以访问局部变量和参数。
在从函数返回之前,会执行函数尾声(function epilogue)。函数尾声是在从函数返回之前执行的一组指令,通常包括释放函数的栈帧和恢复调用函数的保存状态。栈帧被释放,为局部变量和参数腾出空间。调用函数的保存状态被恢复,包括返回地址。
在函数尾声之后,CPU执行一条返回指令,将控制权转移回调用函数。函数的返回值(如果有的话)存储在一个预先确定的寄存器中。
栈指针(stack pointer)是一个跟踪栈顶位置的寄存器。在函数调用期间,栈指针会进行调整,为局部变量和参数分配和释放空间。这很重要,因为你可以调用一个函数,而这个函数又调用另一个函数,依此类推。实际上,大多数复杂的应用程序,包括游戏,栈上都会有很多函数。
栈遵循后进先出(Last In, First Out,LIFO)的顺序,这意味着最后压入栈的项是最先弹出的。这就是它被称为栈的原因。我听过的最形象的类比是,栈就像自助餐厅里一摞盘子,这些盘子被放在一个装置中,只能拿到最上面的盘子。餐厅经理总是可以通过将新盘子压入这个弹簧装置来增加盘子数量,但要拿到最下面的盘子,就必须逐个取出上面的盘子。
总之,当调用一个函数时,CPU使用栈来管理函数的局部变量和被调用函数的参数。栈指针跟踪栈顶位置,函数序言和函数尾声处理栈的设置和清理工作。这个过程使得多个嵌套函数调用能够高效执行。希望理解函数与CPU之间的交互,能帮助我们领会C++历经半个世纪的改进和完善才达到如今的状态,也让我们不要过于苛责必须学习的语法。它之所以是这样是有原因的。
理解CPU的工作原理并非必要,甚至上述简要介绍也不是必须了解的内容,但要知道C++是从20世纪70年代早期C编程语言开始,经过半个世纪精心、审慎发展的结晶。这可以帮助初学者接受可能不存在 “更好” 的方式这一事实,并将所有明显的不完美之处视为有效控制现代伟大奇迹——CPU的必要条件。随着时间的推移,如果你坚持学习,就会明白为什么要这样做。虽然了解CPU和GPU等计算机硬件并非必要,但这有助于加深理解。
现在,回归正题,让我们依次看看函数的每个部分。
# 函数返回类型
顾名思义,返回类型是函数返回给调用代码的值的类型:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
2
3
4
5
在我们之前那个虽然平淡但很有用的addAtoB
示例中,函数签名中的返回类型是int
。addAToB
函数会向调用它的代码返回一个可以存储在int
变量中的值。返回类型可以是我们目前见过的任何C++类型,也可以是我们尚未见过的类型。
然而,函数并非一定要返回值。在这种情况下,函数签名必须使用void
关键字作为返回类型。当使用void
关键字时,函数体一定不能尝试返回值,否则会导致错误。不过,它可以使用不带值的return
关键字。以下是一些有效的返回类型和return
关键字使用方式的组合:
void doWhatever()
{
// our code
// I'm done going back to calling code here
// no return is necessary
}
2
3
4
5
6
另一种可能的情况如下:
void doSomethigCool()
{
// our code
// I can do this if I don't try and use a value
return;
}
2
3
4
5
6
下面的代码展示了更多可能的函数示例。请务必阅读代码注释:
void doYetAnotherThing(){
// some code
if (someCondition) {
// if someCondition is true returning to calling code
// before the end of the function body
return;
}
// More code that might or might not get executed
return;
// As I'm at the bottom of the function body
// and the return type is void, I'm
// really not necessary but I suppose I make it
// clear that the function is over .
}
bool detectCollision(Ship a, Ship b)
{
// Detect if collision has occurred
if(collision) {
// Bam!!!
return true;
}
else
{
// Missed
return false;
}
}
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
上面最后一个函数示例detectCollision
,让我们对未来的C++代码有了一点了解,它展示了我们还可以将用户定义的类型(称为对象,objects)传递给函数,对其进行计算。
我们可以依次调用上述每个函数,如下所示:
// OK time to call some functions
doWhatever();
doSomethingCool();
doYetAnotherThing();
if (detectCollision(milleniumFalcon, lukesXWing))
{
// The jedi are doomed!
// But there is always Leia .
// Unless she was on the Falcon?
}
else
{
// Live to fight another day
}
// Continue with code from here
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
不用担心detectCollision
函数看起来有些奇怪的语法;我们很快就会看到这样的实际代码。简单来说,我们将返回值(true
或false
)作为表达式,直接用于if
语句中。
此外,如果对函数进行重新编写,这些函数可以堆叠在CPU栈上。我去掉了一些无关代码,比如注释,并突出显示了新的部分。首先是一个假设的main
函数:
int main()
{
// call doWhatever
doWhatever()
return 0;
}
2
3
4
5
6
下面是doWhatever
函数的新版本:
void doWhatever()
{
// Call doSomethingCool
doSomethingCool();
}
2
3
4
5
下面是doSomethingCool
函数的新版本:
void doSomethigCool()
{
// Call doYetAnotherThing
doYetAnotherThing();
return;
}
2
3
4
5
6
下面是doYetAnotherThing
函数的新版本:
void doYetAnotherThing()
{
if(someCondition)
{
return;
}
return;
}
2
3
4
5
6
7
8
在我上面的场景中,main
函数调用doWhatever
,doWhatever
调用doSomethingCool
,doSomethingCool
调用doYetAnotherThing
。此时,包括main
函数在内的所有四个函数都会存在于CPU的栈上。当doYetAnotherThing
完成并经过其函数尾声过程后,它会从栈中移除,控制权返回给doSomethingCool
。这时,栈上就只剩下三个函数了。当doSomethingCool
的代码执行完毕后,它也会被移除,依此类推,直到栈上只剩下main
函数。当然,最终main
函数到达其返回语句并从栈中移除,我们的程序也就不再存在于内存中了。
顺便说一句,循环的执行过程与函数类似,所以如果一个函数包含循环,循环也会在栈上执行。所有内容都按照后进先出的顺序执行,直到到达
return
语句,当前正在执行的函数被移除,调用函数继续执行。
要制作一款出色的游戏,你不需要了解这么多内容,所以让我们继续学习。
# 函数名
在设计自己的函数时,函数名几乎可以取任何名称。但最好使用一些词语,通常是动词,来清晰地解释函数的功能。例如,看看这个函数:
void functionaroonieboonie(int blibbityblob, float floppyfloatything)
{
//code here
}
2
3
4
上述代码完全符合语法规则,也能正常运行,但下面这些函数名表意则清晰得多:
void doSomeVerySpecificTask()
{
//code here
}
int getMySpaceShipHealth()
{
//code here
}
void startNewGame()
{
//code here
}
2
3
4
5
6
7
8
9
10
11
12
13
14
接下来,我们深入了解一下如何与函数共享某些值。
# 函数参数
我们知道函数可以将结果返回给调用它的代码。那如果我们需要从调用代码向函数传递一些数据值呢?参数(parameters)就可以让我们实现这一目的。在讨论返回类型时,我们已经见过参数的示例了。现在,我们更仔细地研究一下同一个例子:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
2
3
4
5
在上述代码中,参数是int a
和int b
。你有没有注意到,在函数体的第一行,我们使用a + b
时,就好像它们已经被声明并初始化了一样?这是因为它们确实已经被声明了。函数签名中的参数就是它们的声明,调用函数的代码会对它们进行初始化。
注意,我们把函数签名括号里的变量(
int a, int b
)称为参数。当我们从调用代码向函数传入值时,这些值被称为实参(arguments)。实参传入后,参数会用它们来初始化真正可用的变量,例如:int returnedAnswer = addAToB(10,5);
另外,正如我们在前面的示例中部分看到的,参数类型不一定非得是int
。我们可以使用任何C++类型。而且,为了解决问题,我们可以根据需要使用任意数量的参数,但尽量保持参数列表简短,这样更便于管理。
在后续章节中我们会看到,在这个入门教程里,我们略过了函数一些更高级的用法,以便在进一步学习函数相关知识之前,先了解相关的C++概念。
# 函数体
函数体就是我们一直用类似这样的注释回避的部分:
// code here
// some code
2
但实际上,我们已经知道在函数体里该做什么了!目前所学的任何C++代码都可以写在函数体中。
接下来,我们将探讨函数原型(function prototypes)的概念。
# 函数原型
我们已经学习了如何编写函数以及如何调用函数。然而,要让函数正常工作,我们还需要做一件事。所有函数都必须有一个原型。函数原型能让编译器识别我们定义的函数;没有函数原型,整个游戏都无法编译。幸运的是,函数原型很简单。
我们只需重复函数的签名,再加上一个分号即可。需要注意的是,函数原型必须在任何调用或定义函数的操作之前出现。下面是一个完整可用的函数的最简示例。仔细查看注释,以及函数不同部分在代码中出现的位置:
// The prototype
// Notice the semicolon on the end
int addAToB(int a, int b);
int main() {
// Call the function
// Store the result in answer
int answer = addAToB(2,2);
// Called before the definition
// but that's OK because of the prototype
// Exit main
return 0;
}// End of main
// The function definition
int addAToB(int a, int b)
{
return a + b;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
上述代码说明了以下几点:
- 函数原型在
main
函数之前。 - 正如我们预期的那样,函数调用在
main
函数内部。 - 函数定义在
main
函数之后(外部)。
需要注意的是,如果函数定义在使用之前,我们可以省略函数原型,直接编写函数定义。然而,随着代码量的增加,并且分布在多个文件中,这种情况几乎不会出现。我们通常会分别使用函数原型和函数定义。
接下来看看如何组织函数。
# 组织函数
如果我们有多个函数,尤其是函数代码比较长时,很有必要指出,.cpp
文件会很快变得难以管理。这就违背了使用函数的初衷。在第6章开始的下一个项目中,我们会找到解决办法,即把所有函数原型添加到我们自己的头文件(.hpp
或.h
)中,然后在另一个.cpp
文件中编写所有函数的代码,最后在主.cpp
文件中添加另一个#include...
指令。这样,我们就可以使用任意数量的函数,而无需将它们的代码(原型或定义)添加到主代码文件中。
# 函数作用域
在讨论CPU栈时,我们提到了局部变量的概念。这和函数或变量作用域(function or variable scope)是同一个话题。如果我们在函数中声明一个变量,无论是直接声明还是在参数中声明,这个变量在函数外部都不可用、不可见。此外,在其他函数中声明的变量在当前函数中也不可见、不可用。毕竟,它们位于CPU栈上完全不同的栈帧中。
我们在函数代码和调用代码之间共享值的方式,是通过参数/实参以及返回值。
当一个变量因为来自其他函数而不可用时,我们就说它超出了作用域(out of scope)。当一个变量可用时,我们就说它在作用域内(in scope)。
在C++中,在任何代码块内声明的变量只在该代码块内有效!这也包括循环和
if
代码块。在main
函数顶部声明的变量在main
函数的任何地方都有效。在游戏循环中声明的变量只在游戏循环内有效,以此类推。在函数或其他代码块内声明的变量被称为局部变量。我们编写的代码越多,对这一概念的理解就会越深刻。每次在代码中遇到与作用域相关的问题时,我都会进行讲解,以便大家理解。在下一节中就会出现这样一个问题。还有一些C++的重要概念会更深入地探讨这个问题,它们就是引用(references)和指针(pointers),我们将分别在第9章和第10章学习这两个概念。
# 关于函数的最后一点说明(目前为止)
关于函数,我们还有更多内容可以学习,但就目前而言,我们所学的知识已经足够用来实现游戏的下一部分内容了。如果像参数、签名、定义等专业术语你还没有完全理解,也不用担心。当我们开始使用这些概念时,它们就会变得更清晰。
此外,你可能已经注意到,我们在调用函数时,尤其是调用SFML库的函数时,会在函数名前加上对象名和一个点,例如:
spriteBee.setPosition...
window.draw...
// etc
2
3
然而,在我们对函数的讨论中,调用函数时并没有涉及任何对象。这是怎么回事呢?函数既可以作为类(class)的一部分来编写,也可以像我们在本章中看到的那样,作为独立函数(standalone function)来编写。当函数作为类的一部分时,我们需要该类的一个对象来调用这个函数;而当函数是独立函数时(就像我们看到的那样),则不需要。
我们马上会编写一个独立函数,从第6章 “面向对象编程——开始编写乒乓球游戏” 开始,我们会编写包含函数的类。目前我们所学的关于函数的所有知识,在这两种情况下都适用,只是使用场景不同而已。
最后,我们可以运用所学知识来绘制树上的树枝。
# 生成树枝
接下来,就像我在过去大约20页内容中一直承诺的那样,我们将运用所有新学的C++技术——循环、数组、枚举和函数,来绘制并移动树上的一些树枝。
在main
函数外部添加以下代码。需要明确的是,我指的是在int main()
代码之前添加:
#include <sstream>
#include <SFML/Graphics.hpp>
using namespace sf;
// Function declaration
void updateBranches(int seed) ;
const int NUM_BRANCHES = 6;
Sprite branches[NUM_BRANCHES];
// Where is the player/branch?
// Left or Right
enum class side { LEFT, RIGHT, NONE };
side branchPositions[NUM_BRANCHES];
int main()
{
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这段新代码实现了以下功能:
- 首先,我们为一个名为
updateBranches
的函数编写了函数原型。可以看到,它没有返回值(void
),并且接受一个名为seed
的int
类型参数。我们很快就会编写该函数的定义,到时候就能清楚它的具体功能了。 - 接着,我们声明了一个名为
NUM_BRANCHES
的整型常量,并将其初始化为6。树上将会有6个移动的树枝,很快我们就会看到NUM_BRANCHES
的用处。 - 然后,我们声明了一个名为
branches
的Sprite
对象数组,它可以容纳6个Sprite
实例。 - 之后,我们声明了一个新的枚举类型
side
,它有三个可能的值:LEFT
、RIGHT
和NONE
。在我们的代码中,这个枚举类型将用于描述单个树枝以及玩家的位置。 - 最后,在上述代码中,我们初始化了一个
side
类型的数组,其大小为NUM_BRANCHES
(即6)。需要说明的是,我们创建了一个名为branchPositions
的数组,它包含6个值。每个值的类型都是side
,并且每个值都可以是LEFT
、RIGHT
或NONE
。
当然,你可能很想知道为什么要在
main
函数外部声明这个常量、两个数组和枚举类型。将它们声明在main
函数之上后,它们就具有了全局作用域(global scope)。换种说法就是,这个常量、两个数组和枚举类型在整个游戏中都有效。这意味着我们可以在main
函数和updateBranches
函数的任何地方访问和使用它们。需要注意的是,尽量让变量在使用的地方局部声明是一个良好的编程习惯。虽然把所有变量都设为全局变量可能看起来很方便,但这会导致代码可读性变差,并且容易出错。
# 准备树枝
现在,我们将准备6个Sprite
对象,并将它们加载到branches
数组中。在游戏循环之前添加以下突出显示的代码:
// Position the text
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left + textRect.width / 2.0f, textRect.top + textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
scoreText.setPosition(20, 20);
// Prepare 5 branches
Texture textureBranch;
textureBranch.loadFromFile("graphics/branch.png");
// Set the texture for each branch sprite
for (int i = 0; i < NUM_BRANCHES; i++) {
branches[i].setTexture(textureBranch);
branches[i].setPosition(-2000, -2000);
// Set the sprite's origin to dead centre
// We can then spin it round without changing its position
branches[i].setOrigin(220, 20);
}
while (window.isOpen())
{
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在上述代码中,首先我们声明了一个SFML的Texture
对象,并将branch.png
图形加载到其中。
接着,我们创建了一个for
循环,将i
初始化为0,每次循环i
自增1,直到i
不再小于NUM_BRANCHES
。这样做是正确的,因为NUM_BRANCHES
的值为6,而branches
数组的索引范围是0到5。
在for
循环内部,我们使用setTexture
为branches
数组中的每个Sprite
设置纹理,然后使用setPosition
将其隐藏到屏幕外。
最后,我们使用setOrigin
将Sprite
的原点(绘制Sprite
时用于定位的点)设置为其中心。很快我们就会旋转这些Sprite
,将原点设置在中心意味着它们会围绕中心旋转,而不会使Sprite
位置发生偏移。
# 每一帧更新树枝精灵的位置
接下来的代码中,我们将根据branches
数组中精灵的索引位置,以及branchPositions
数组中对应side
的值,来设置所有精灵的位置。添加突出显示的代码并尝试理解它,然后我们再详细讲解:
// Update the score text
std::stringstream ss;
ss << "Score: " << score;
scoreText.setString(ss.str());
// update the branch sprites
for (int i = 0; i < NUM_BRANCHES; i++)
{
float height = i * 150;
if (branchPositions[i] == side::LEFT)
{
// Move the sprite to the left side
branches[i].setPosition (610, height);
// Flip the sprite round the other way
branches[i].setRotation (180);
}
else if (branchPositions[i] == side::RIGHT)
{
// Move the sprite to the right side
branches[i].setPosition (1330 , height);
// Set the sprite rotation to normal
branches[i].setRotation (0 );
}
else
{
// Hide the branch
branches[i].setPosition (3000, height);
}
}
} // End if(!paused)
/*
****************************************
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
我们刚刚添加的代码是一个大的for
循环,将i
初始化为0,每次循环i
自增1,一直循环到i
不再小于6。
在for
循环内部,一个新的float
类型变量height
被设置为i * 150
。这意味着第一个树枝的高度为0,第二个为150,第六个为750。
接下来,是一组if
和else
代码块。先看去掉代码后的结构:
if()
{
}
else if()
{
}
else
{
}
2
3
4
5
6
7
8
9
第一个if
语句通过branchPositions
数组判断当前树枝是否应该在左边。如果是,它会将branches
数组中对应的Sprite
设置到屏幕左边合适的位置(横坐标为610像素),纵坐标则是当前的height
值。然后将Sprite
旋转180度,因为branch.png
图形默认是向右 “悬挂” 的。
else if
部分只有在树枝不在左边时才会执行。这部分代码使用同样的方法判断树枝是否在右边。如果是,就将树枝绘制在右边(横坐标为1330像素)。然后将Sprite
的旋转角度设置为0度,以防它之前是180度。如果觉得横坐标有点奇怪,要记住我们把树枝Sprite
的原点设置在了中心。
最后一个else
语句合理地假设当前branchPosition
的值一定是NONE
,并将树枝隐藏到屏幕外,横坐标设为3000像素。
此时,我们的树枝已经定位好了,可以进行绘制了。
# 绘制树枝
在这里,我们使用另一个for
循环遍历整个branches
数组(从0到5),并绘制每个树枝精灵。添加以下突出显示的代码:
// 绘制云朵
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// 绘制树枝
for (int i = 0; i < NUM_BRANCHES; i++) {
window.draw(branches[i]);
}
// 绘制树
window.draw(spriteTree);
2
3
4
5
6
7
8
9
10
11
12
当然,我们还没有编写移动所有树枝的函数。一旦编写好这个函数,我们还需要确定何时以及如何调用它。让我们先解决第一个问题,编写这个函数。
# 移动树枝
我们已经在main
函数上方添加了函数原型。现在,我们来编写这个函数的实际定义,每次调用该函数时,它会将所有树枝向下移动一个位置。我们将这个函数分两部分编写,这样可以更轻松地查看发生了什么。
在main
函数的右花括号之后,添加updateBranches
函数的第一部分:
// 函数定义
void updateBranches(int seed)
{
// 将所有树枝向下移动一个位置
for (int j = NUM_BRANCHES - 1; j > 0; j--) {
branchPositions[j] = branchPositions[j - 1];
}
}
2
3
4
5
6
7
8
在函数的第一部分,我们只是将所有树枝逐个向下移动一个位置,从第六个树枝开始。这通过让for
循环从5计数到0来实现。代码branchPositions[j] = branchPositions[j - 1];
完成实际的移动操作。
需要注意的是,在将位置4的树枝移动到位置5,然后将位置3的树枝移动到位置4等等之后,我们需要在位置0(即树顶)添加一个新的树枝。
现在我们可以在树顶生成一个新树枝。在updateBranches
函数中添加突出显示的代码,然后再进行讲解:
// 函数定义
void updateBranches(int seed) {
// 将所有树枝向下移动一个位置
for (int j = NUM_BRANCHES - 1; j > 0; j--) {
branchPositions[j] = branchPositions[j - 1];
}
// 在位置0生成一个新树枝
// 左、右或无
srand((int)time(0) + seed);
int r = (rand() % 5);
switch (r) {
case 0:
branchPositions[0] = side::LEFT;
break;
case 1:
branchPositions[0] = side::RIGHT;
break;
default:
branchPositions[0] = side::NONE;
break;
}
}
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
在updateBranches
函数的最后一部分,我们使用函数调用时传入的整数seed
变量。这样做是为了确保随机数种子总是不同的,我们将在下一章了解这个值是如何得到的。
接下来,我们生成一个0到4之间的随机数,并将结果存储在int
变量r
中。现在,我们以r
为表达式进行switch
操作。
case
语句表示,如果r
等于0,那么我们在树顶的左侧添加一个新树枝。如果r
等于1,那么树枝将添加在右侧。如果r
是其他值(2、3或4),那么default
确保树顶不会添加任何树枝。这种左、右和无的平衡设置,让树看起来更逼真(对于一个虚拟的游戏树来说),并且游戏运行效果也很好。你可以轻松修改代码,使树枝出现的频率更高或更低。
即使为树枝编写了这么多代码,我们在游戏中仍然看不到任何一个树枝。这是因为在调用updateBranches
之前,我们还有更多工作要做。
如果你现在想看到一个树枝,可以添加一些临时代码,在游戏循环之前,每次使用不同的种子调用该函数五次:
updateBranches(1);
updateBranches(2);
updateBranches(3);
updateBranches(4);
updateBranches(5);
while (window.isOpen())
{
2
3
4
5
6
7
8
现在你可以看到树枝出现在相应位置。但是如果要让树枝移动,我们需要定期调用updateBranches
函数。
图4.1:树上的树枝
继续学习之前,别忘了删除这些临时代码。
现在我们可以关注玩家相关内容,并且真正调用updateBranches
函数了。
# 总结
虽然这可能不是篇幅最长的一章,但或许是涵盖C++知识最多的一章。我们学习了可以使用的不同类型的循环,比如for
循环和while
循环。我们研究了数组,它能轻松处理大量变量和对象。我们还学习了枚举(enumerations)和switch
语句。本章可能最重要的概念是函数,它能帮助我们组织和抽象游戏代码。随着本书的继续,我们还会在更多地方深入探讨函数。
现在我们有了一棵完全 “可用” 的树,在本项目的最后一章,我们可以完成这个游戏了。以下是一些你可能会有的疑问。
# 常见问题解答
问:C++中的for
循环和while
循环有什么区别?
答:C++中的for
循环和while
循环都用于循环操作,但for
循环通常在迭代次数预先已知的情况下使用,它包含三个部分(初始化、条件判断和迭代)。相比之下,while
循环更灵活,用于迭代次数不确定的情况。
问:C++中的函数可以返回多个值吗? 答:不可以,C++中的函数只能直接返回一个值。不过,可以通过使用引用传递的参数、指针或对象来模拟返回多个值。后续章节会深入讨论引用、指针和对象。
问:简单讲讲CPU栈(stack)与C++中的函数调用和循环有什么关系? 答:栈是C++中用于管理函数调用、局部变量和控制流的一块内存区域。函数调用和循环都涉及在栈上分配和释放空间,以存储局部变量、参数和返回地址等信息。继续学习并不需要理解这一点,但知道它的存在有助于理解一些否则难以解释的结构,特别是本章的函数语法。
问:在C++中什么时候应该使用枚举?
答:枚举(在C++中常缩写为enums
)在你想要表示一组命名常量值时很有用。它们能提高代码的可读性,并有助于防止使用无效值和操作。枚举有时用于游戏中的菜单选项,就像我们在本章中使用的一周中的天数的例子。看到WEDNESDAY
这个值,很明显它代表什么,而值3可能表示星期二,甚至可能是可爱的爬树哺乳动物的脚趾数量。
问:如何在C++中避免出现不想要的无限循环?
答:为了避免出现不想要的无限循环,要确保循环条件有变为false
的途径。例如,在for
循环中,要确保条件最终会被计算为false
。在while
循环中,要确保循环变量会被更新,或者在满足特定条件时有break
语句。