CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • C++游戏编程入门(零基础学C++) 说明
  • 第1章 欢迎阅读
  • 第2章 变量、运算符与决策:精灵动画
  • 第3章 C++字符串、SFML时间:玩家输入与平视显示器
  • 第4章 循环、数组、switch语句、枚举和函数:实现游戏机制
    • 循环
      • while循环
      • 跳出循环
      • for循环
    • 数组
      • 声明数组
      • 初始化数组元素
      • 快速初始化数组元素
      • 这些数组对我们的游戏到底有什么用?
    • 使用switch进行决策
    • 类枚举
    • 函数入门
      • 函数返回类型
      • 函数名
      • 函数参数
      • 函数体
      • 函数原型
      • 组织函数
      • 函数作用域
      • 关于函数的最后一点说明(目前为止)
    • 生成树枝
      • 准备树枝
      • 每一帧更新树枝精灵的位置
      • 绘制树枝
      • 移动树枝
    • 总结
    • 常见问题解答
  • 第5章 碰撞、音效与结束条件:让游戏可玩
  • 第6章 面向对象编程——开始开发《乒乓》游戏
  • 第7章 AABB碰撞检测与物理效果——完成乒乓球游戏
  • 第8章 SFML视图——开启丧尸射击游戏
  • 第9章 C++引用、精灵表和顶点数组
  • 第10章 指针、标准模板库和纹理管理
  • 第11章 编写TextureHolder类并创建一群僵尸
  • 第12章 碰撞检测、拾取物与子弹
  • 第13章 视图分层与平视显示器(HUD)的实现
  • 第14章 音效、文件输入输出与完成游戏制作
  • 第15章 快跑!
  • 第16章 声音、游戏逻辑、对象间通信与玩家
  • 第17章 图形、相机与动作
  • 第18章 为平台、玩家动画和控制功能编写代码
  • 第19章 构建菜单与实现下雨效果
  • 第20章 火球与声音空间化
  • 第21章 视差背景与着色器
目录

第4章 循环、数组、switch语句、枚举和函数:实现游戏机制

# 第4章 循环、数组、switch语句、枚举和函数:实现游戏机制

本章涵盖的C++知识可能比本书任何其他章节都要多。它充满了一些基础概念,这些概念将极大地加快我们的理解速度。它还将开始阐明我们一直有点跳过的一些模糊领域,比如函数、游戏循环,以及一般意义上的循环。

我们将探讨以下内容:

  • 循环
  • 数组
  • 使用switch语句进行决策
  • 类枚举
  • 函数入门
  • 让树枝动起来

在探索完一系列C++语言必备知识后,我们将运用所学,实现主要的游戏机制——让树枝动起来。在本章结束时,我们将为最后阶段做准备,完成《Timber!!!》游戏的开发。

# 循环

欢迎来到C++的循环世界!循环是一种通用的编程结构,并非C++所独有,它允许你多次重复执行某段代码。循环对于提高游戏的效率和灵活性至关重要。这或许是计算机如此有用的关键所在:重复执行相同的操作,但每次使用不同的值。在C++中,有多种类型的循环,每种都有特定的用途。在本章中,我们将探索基本的循环结构,并介绍C++中与循环编程相关的较新更新内容。到目前为止,我们最明显的例子就是游戏循环。去掉所有代码后,我们的游戏循环看起来是这样的:

while (window.isOpen()) {

}
1
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
1
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
}
1
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
1
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!
1
2
3
4
5
6
7
8
9
10
11
12

上述循环体将执行一次。我们还可以设置一个永远运行的while循环,这种循环被恰当地称为无限循环。下面是一个例子:

int y = 0;

while(true) {
	y++; //  Bigger. . .  Bigger. . .
	cout << y; 
}
1
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
}
1
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
}
1
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."; 
}
1
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
}
1
2
3
4

for循环条件的各个部分的作用如下:

for (声明和初始化; 条件; 每次迭代前的变化)
1

为了进一步说明,下面用一个表格来解释上述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
1
2
3
4
5
6

for循环控制着初始化、条件求值,以及控制变量本身。在本章后面的内容中,我们将在游戏中使用for循环。for循环还有更高级的用法,但我们需要先学习更多知识,才能进行讨论。在下一节讨论数组时,我们将看到其中一种更高级的用法。

# 数组

数组(Arrays)是一种数据结构,它允许我们使用单个名称(比如someInts、myFloats 或 zombieHorde)来存储同一数据类型的元素集合。数组提供了一种便捷的方式来组织和处理数据,有助于实现更高效、结构化的编程。数组对于处理重复性数据特别有用,例如数字列表、字符列表或游戏对象列表。本介绍将探讨数组的基础知识,在本书后续内容中,我们还会看到数组更高级的用法。

与普通变量进行对比可能会有所帮助。如果说变量是一个可以存储特定类型(如int、float或char)值的盒子,那么我们可以把数组想象成一排盒子。这排盒子的大小和类型几乎可以是任意的,包括由类创建的对象。不过,所有盒子必须是同一类型。

在学习本书最后平台游戏项目中更高级的C++知识后,在一定程度上可以规避每个盒子必须使用相同类型这一限制。

如果你认为这个数组对于第2章 “变量、运算符和决策:精灵动画” 中的云朵来说可能会很有用,那你完全正确。可惜对于云朵来说已经来不及了,它们注定永远是又笨又臃肿的代码。不过,我们将使用数组来实现树枝。那么,我们该如何创建和使用数组呢?

# 声明数组

我们可以像这样声明一个int类型变量的数组:

int someInts[10];
1

现在,我们有了一个名为someInts的数组,它可以存储10个int值。不过,目前它是空的。

数组与普通变量唯一的区别在于,我们需要使用一种称为数组表示法的格式来操作各个值。因为虽然我们的数组有一个名称(someInts),但各个元素并没有各自的名称:

someInts_AliensRemaining = 99; // 错误
someInts_Score = 100; 		   // 错误!
1
2

让我们来看看具体该怎么做。

# 初始化数组元素

要向数组元素中添加值,我们可以使用已经熟悉的语法,再结合我提到的新语法,即数组表示法。在下面的代码中,我们将99存储到数组的第一个元素中:

someInts[0] = 99;
1

要将999存储到第二个元素中,我们编写如下代码:

someInts[1] = 999;
1

我们可以像这样将3存储到最后一个元素中:

someInts[9] = 3;
1

请注意,数组元素的索引总是从0开始,一直到数组大小减1。与普通变量类似,我们可以操作存储在数组中的值。

在接下来的代码中,我们将看到如何操作各个值。下面展示了如何将第一个和第二个元素相加,并将结果存储在第三个元素中:

someInts[2] = someInts[0] + someInts[1];
1

数组也可以与普通变量无缝交互,例如:

int a = 9999;
someInts[4] = a;
1
2

关于数组还有很多需要学习的内容,让我们继续深入。

# 快速初始化数组元素

我们可以像下面这个使用float数组的示例一样,快速向元素中添加值:

float myFloatingPointArray[3] {3.14f, 1.63f, 99.0f};
1

现在,3.14、1.63和99.0分别存储在第一个、第二个和第三个位置。请记住,当使用数组表示法访问这些值时,我们要使用[0]、[1]和[2]。

还有其他方法可以初始化数组元素。下面这个有点抽象的示例展示了如何使用for循环将0到9的值放入uselessArray数组中:

for(int i = 0; i < 10; i++) 
{
    uselessArray[i] = i; 
}
1
2
3
4

这段代码假设uselessArray之前已经被初始化,至少可以容纳10个int变量。

# 这些数组对我们的游戏到底有什么用?

我们可以在任何可以使用普通变量的地方使用数组,例如在这样的表达式中:

//  someArray[4] 被声明并初始化为9999

for(int i = 0; i < someArray[4]; i++) 
{
    // 循环执行9999次
}
1
2
3
4
5
6

本节开头已经暗示了数组在游戏代码中最大的好处。数组可以存储对象(类的实例)。假设我们有一个Zombie类,并且想要存储一堆僵尸对象。我们可以像下面这段假设的代码这样做:

Zombie horde [5] {zombie1, zombie2, zombie3}; // 等等……
1

现在,horde数组存储了大量Zombie类的实例。每个实例都是一个独立的、会 “活”(某种意义上)、会 “呼吸”、能 “自主行动” 的僵尸对象。然后,我们可以在每次游戏循环中遍历horde数组,移动僵尸,并检查它们的头是否被斧头砍到,或者是否抓到了玩家。

如果我们当时就知道数组,那么用它来处理云朵再合适不过了。我们本可以有数百朵云,而且编写的代码会比处理那区区三朵云少得多。

要查看完整且可运行的改进后云朵代码,请查看第5章文件夹下载包中的《Timber!!!》增强版。或者,你也可以在查看代码之前自己尝试使用数组实现云朵功能。

要真正理解数组的这些内容,最好的方法是实际看一下它们的运行效果。在实现树枝功能时,我们就会看到。

目前,我们先保留云朵代码不变,以便尽快为游戏添加更多功能。但首先,我们来学习更多C++中使用switch进行决策的知识。

# 使用switch进行决策

我们已经见过if关键字,它允许我们根据表达式的结果来决定是否执行一段代码。但有时,在C++中用其他方式进行决策会更好。switch通常用于为一系列嵌套的if-else语句提供一种更优雅的替代方案。正如我们将看到的,它会计算一个表达式的值,并控制程序流程。

当我们必须根据一系列明确的可能结果进行决策,且这些结果不涉及复杂的组合或广泛的值范围时,switch通常是一个不错的选择。我们像这样开始一个switch决策:

switch(expression ) 
{
    // 更多代码写在这里
}
1
2
3
4

在前面的示例中,expression可以是一个实际的表达式,也可以只是一个变量。然后,在花括号内,我们可以根据表达式的结果或变量的值进行决策。我们使用case和break关键字来实现,就像下面这个有点抽象的示例:

case x:
    // 针对x的代码
    break;

case y:
    // 针对y的代码
    break;
1
2
3
4
5
6
7

从前面的抽象示例中可以看到,每个case都列出了一个可能的结果,每个break表示该case的结束,以及执行离开switch块的位置。

经典的、不抽象的示例是使用一周中的天数,如下所示:

int dayNumber = 3; 
switch (dayNumber) {
    case 1:
        // 周一发生的事情
        break; 
    case 2:
        // 周二发生的事情
        break;
    // 等等
    default:
        // 无效日期的代码
}
1
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;
1
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; 
}
1
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 
};
1
2
3
4
5
6
7
8
9
10

或者在一个可能的游戏场景中,有这样一个更有趣的示例:

enum class zombieTypes 
{
    REGULAR, 
    RUNNER, 
    CRAWLER, 
    SPITTER, 
    BLOATER 
};
1
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;

/*
	僵尸是虚构的生物,
	与现实中的任何人相似纯属巧合
*/
1
2
3
4
5
6
7
8

接下来是我们很快会添加到《Timber!!!》中的代码的一个预览。我们想要跟踪树枝或玩家在树的哪一侧,所以我们将声明一个名为side的枚举,如下所示:

enum class side 
{ 
    LEFT, 
    RIGHT, 
    NONE 
};
1
2
3
4
5
6

我们可以像这样将玩家定位在左侧:

// 玩家从左侧开始
side playerSide = side::LEFT;
1
2

我们可以让树枝位置数组的第四层(数组从0开始计数)没有树枝,如下所示:

branchPositions[3] = side::NONE;
1

值得注意的是,我们也可以在表达式中使用枚举:

if (branchPositions[5] == playerSide) 
{
    // 最低的树枝与玩家在同一侧
    // 被压扁了!
}
1
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."; 
}
1
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)
1

如果我们添加一对花括号{...},并在其中放入函数要执行的代码,那么我们就得到了一个完整的函数,即函数定义(definition):

public void shootLazers(int power, int direction)
{
	// ZAPP!
}
1
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
1
2
3

当我们使用一个函数时,我们说我们调用(call)了它。在调用shootLazers函数的地方,我们程序的执行会分支到该函数内部的代码。函数会一直运行,直到到达结尾或者被要求返回。然后,代码会从函数调用后的第一行继续运行。我们已经在使用SFML提供的函数了。这里的不同之处在于,我们将学习编写和调用自己的函数。

下面是另一个函数的示例,其中包含使函数返回到调用它的代码的语句:

int addAToB(int a, int b) 
{
    int answer = a + b;
    return answer; 
}
1
2
3
4
5

调用上述函数的代码可能如下:

int myAnswer = addAToB(2, 4);
1

显然,我们不需要编写函数来将两个变量相加,但这个过于简化的示例有助于我们更深入地了解函数的工作原理。首先,我们传入值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;
}
1
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
}
1
2
3
4
5
6

另一种可能的情况如下:

void doSomethigCool() 
{
	//  our  code
	//  I  can  do  this  if I  don't  try  and  use  a  value
	return; 
}
1
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; 
	}
}
1
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
1
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; 
}
1
2
3
4
5
6

下面是doWhatever函数的新版本:

void doWhatever()
{
	//  Call  doSomethingCool
	doSomethingCool(); 
}
1
2
3
4
5

下面是doSomethingCool函数的新版本:

void doSomethigCool()
{
	//  Call  doYetAnotherThing
	doYetAnotherThing();
	return; 
}
1
2
3
4
5
6

下面是doYetAnotherThing函数的新版本:

void doYetAnotherThing()
{
	if(someCondition)
    { 
        return;
	}
	return;
}
1
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
}
1
2
3
4

上述代码完全符合语法规则,也能正常运行,但下面这些函数名表意则清晰得多:

void doSomeVerySpecificTask() 
{
	//code  here
}

int getMySpaceShipHealth() 
{
	//code  here
}

void startNewGame()
{
	//code  here
}
1
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; 
}
1
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
1
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; 
}
1
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
1
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()
{
1
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()) 
{
1
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
****************************************
1
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
{ 
}
1
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);
1
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];
    } 
}
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;
    } 
    
}
1
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()) 
{
1
2
3
4
5
6
7
8

现在你可以看到树枝出现在相应位置。但是如果要让树枝移动,我们需要定期调用updateBranches函数。 img

图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语句。

第3章 C++字符串、SFML时间:玩家输入与平视显示器
第5章 碰撞、音效与结束条件:让游戏可玩

← 第3章 C++字符串、SFML时间:玩家输入与平视显示器 第5章 碰撞、音效与结束条件:让游戏可玩→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式