第12章 碰撞检测、拾取物与子弹
# 第12章 碰撞检测、拾取物与子弹
到目前为止,我们已经实现了游戏主要的视觉效果。我们有一个可控制的角色,在满是追逐他的僵尸的竞技场内四处奔跑。但问题是,他们之间并没有互动。僵尸可以直接穿过玩家,却不会给玩家造成任何伤害。我们需要检测僵尸和玩家之间的碰撞。
如果僵尸能够伤害并最终杀死玩家,那么公平起见,我们应该给玩家的枪配备一些子弹。然后,我们需要确保子弹能够击中并杀死僵尸。
同时,如果我们正在编写子弹、僵尸和玩家之间的碰撞检测代码,那么现在也是添加生命值和弹药拾取物(pickups)类的好时机。
以下是我们在本章要做的事情以及讲解顺序:
- 编写子弹(Bullet)类代码
- 让子弹飞起来
- 给玩家添加一个准星
- 编写拾取物类代码
- 使用拾取物类
- 检测碰撞
让我们从子弹类开始。
# 编写Bullet类
我们将使用SFML的RectangleShape
类来可视化表示子弹。我们将编写一个Bullet
类,它有一个RectangleShape
成员,以及其他成员数据和函数。然后,我们将通过以下几个步骤把子弹添加到游戏中:
- 首先,我们将编写
Bullet.h
文件。这个文件会展示所有成员数据的详细信息以及函数的原型。 - 接下来,我们将编写
Bullet.cpp
文件,这个文件当然会包含Bullet
类所有函数的定义。在逐步讲解的过程中,我会详细解释Bullet
类型的对象是如何工作和被控制的。 - 最后,我们将在主函数中声明一整个子弹数组。我们还将实现一个射击控制方案,管理玩家剩余的弹药并处理重新装填弹药的操作。
让我们从第1步开始。
# 编写Bullet头文件
要创建新的头文件,在解决方案资源管理器中右键单击“头文件”,然后选择“添加”|“新建项”。在“添加新项”窗口中,(通过左键单击)突出显示“头文件(.h)”,然后在“名称”字段中输入Bullet.h
。
在Bullet.h
文件中添加以下私有成员变量以及Bullet
类声明。然后我们来逐一讲解它们的用途:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bullet
{
private:
// 子弹在哪里?
Vector2f m_Position;
// 每颗子弹的外观
RectangleShape m_BulletShape;
// 这颗子弹当前是否正在空中飞行?
bool m_InFlight = false;
// 子弹的飞行速度有多快?
float m_BulletSpeed = 1000;
// 每帧子弹在水平和垂直方向上移动的像素分数是多少?
// 这些值将从m_BulletSpeed派生而来
float m_BulletDistanceX;
float m_BulletDistanceY;
// 一些边界,防止子弹永远飞行
float m_MaxX;
float m_MinX;
float m_MaxY;
float m_MinY;
// 公共函数原型放在这里
};
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
在上述代码中,第一个成员是一个名为m_Position
的Vector2f
类型变量,它将保存子弹在游戏世界中的位置。
接下来,我们声明一个名为m_BulletShape
的RectangleShape
类型变量,因为我们为每颗子弹使用一个简单的无纹理图形,有点像我们在《木材(Timber!)》游戏中对时间条的处理方式。
代码接着声明了一个布尔变量m_InFlight
,它将跟踪子弹当前是否正在空中飞行。这将让我们决定是否需要在每一帧调用它的更新函数,以及是否需要进行碰撞检测检查。
float
类型变量m_BulletSpeed
(你可能已经猜到了)将保存子弹的飞行速度,单位是像素每秒。它被初始化为1000,这个值有点随意,但效果不错。
接下来,我们还有两个float
类型变量m_BulletDistanceX
和m_BulletDistanceY
。由于移动子弹的计算比移动僵尸或玩家的计算要复杂一些,有这两个变量来进行计算会很有帮助。它们将用于确定每帧中子弹位置在水平和垂直方向上的变化。
最后,我们还有四个float
类型变量(m_MaxX
、m_MinX
、m_MaxY
和m_MinY
),稍后它们将被初始化,用于保存子弹在水平和垂直方向上的最大、最小位置。
可能有些变量的用途不是一下子就能看出来,但当我们在Bullet.cpp
文件中看到它们的实际作用时,就会更清楚了。
现在,在Bullet.h
文件中添加所有公共函数原型:
// 公共函数原型放在这里
public :
// 构造函数
Bullets();
// 停止子弹
void stop();
// 返回m_InFlight的值
bool isInFlight();
// 发射一颗新子弹
void shoot(float startX, float startY,
float xTarget, float yTarget);
// 告诉调用代码子弹在游戏世界中的位置
FloatRect getPosition();
// 返回实际形状(用于绘制)
RectangleShape getShape();
// 每帧更新子弹
void update(float elapsedTime);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
让我们依次讲解每个函数,然后再继续编写它们的定义。
首先,我们有Bullet
函数,它当然就是构造函数。在这个函数中,我们将为每个Bullet
实例做好准备。
stop
函数将在子弹完成飞行任务但需要停止时被调用。
isInFlight
函数返回一个布尔值,用于测试一颗子弹当前是否正在飞行。
shoot
函数从名字就能看出用途,但它的工作原理值得讨论一下。目前,只需注意它有四个float
类型的参数要传入。这四个值分别代表子弹的起始水平和垂直位置(即玩家所在的位置),以及垂直和水平目标位置(即准星所在的位置)。
getPosition
函数返回一个FloatRect
类型的值,代表子弹的位置。这个函数将用于检测与僵尸的碰撞。你可能还记得在第10章“指针、标准模板库和纹理管理”中,僵尸也有一个getPosition
函数。
接着,我们有getShape
函数,它返回一个RectangleShape
类型的对象。正如我们所讨论的,每颗子弹在视觉上由一个RectangleShape
对象表示。因此,getShape
函数将用于获取RectangleShape
当前状态的副本,以便进行绘制。
最后,正如预期的那样,有一个update
函数,它有一个float
类型的参数,代表自上次调用update
以来经过的秒数的分数。update
方法将在每帧改变子弹的位置。
让我们看看并编写函数定义。
# 编写Bullet源文件
现在,我们可以创建一个新的.cpp
文件,用于存放函数定义。在解决方案资源管理器中右键单击“源文件”,然后选择“添加”|“新建项”。在“添加新项”窗口中,(通过左键单击)突出显示“C++文件(.cpp)”,然后在“名称”字段中输入Bullet.cpp
。最后,点击“添加”按钮。现在我们可以开始编写类代码了。
添加以下代码,这是包含指令和构造函数的代码。我们知道这是构造函数,因为函数名与类名相同:
#include "bullet.h"
// 构造函数
Bullet::Bullet() {
m_BulletShape.setSize(sf::Vector2f(2, 2));
}
2
3
4
5
6
Bullet
构造函数唯一要做的就是设置m_BulletShape
(即RectangleShape
对象)的大小。代码将其大小设置为2像素×2像素。
# 编写射击函数
接下来,我们将编写更复杂的shoot
函数。在Bullet.cpp
文件中添加以下代码并仔细研究,然后我们再进行讨论:
void Bullet::shoot(float startX, float startY, float targetX, float targetY)
{
// 跟踪子弹状态
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
// 计算飞行路径的斜率
float gradient = (startX - targetX) / (startY - targetY);
// 任何小于1的斜率都需要取反
if (gradient < 0) {
gradient *= -1;
}
// 计算x和y之间的比率
float ratioXY = m_BulletSpeed / (1 + gradient);
// 设置水平和垂直方向的“速度”
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
// 让子弹指向正确的方向
if (targetX < startX) {
m_BulletDistanceX *= -1;
}
if (targetY < startY) {
m_BulletDistanceY *= -1;
}
// 设置最大射程为1000像素
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
// 定位子弹,准备绘制
m_BulletShape.setPosition(m_Position);
}
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
为了理解shoot
函数,我们将其分解,逐块讨论刚刚添加的代码。
首先,让我们回顾一下函数签名。shoot
函数接收子弹的起始和目标水平、垂直位置。调用代码将根据玩家精灵(sprite)的位置和准星的位置提供这些值。函数签名如下:
void Bullet::shoot(float startX, float startY, float targetX, float targetY)
在shoot
函数内部,我们将m_InFlight
设置为true
,并使用startX
和startY
参数来定位子弹。相关代码如下:
// 跟踪子弹状态
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
2
3
4
现在,我们使用一些三角学知识来确定子弹的飞行斜率。看一下相关代码,我们进一步讨论并分解它:
// 计算飞行路径的斜率
float gradient = (startX - targetX) / (startY - targetY);
// 任何小于零的斜率都需要取反
if (gradient < 0) {
gradient *= -1;
}
// 计算x和y之间的比率
float ratioXY = m_BulletSpeed / (1 + gradient);
// 设置水平和垂直方向的“速度”
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
2
3
4
5
6
7
8
9
10
11
12
13
14
这段代码计算子弹如何朝着目标移动。它根据直线的斜率在水平和垂直方向上调整子弹的路径。这是必要的,因为如果斜率非常陡峭,子弹可能在垂直方向上移动足够距离之前就到达了水平目的地,反之,对于不太陡峭的角度也可能出现相反的情况。本质上,这段代码确保子弹根据飞行路径的斜率,以一致的速度在水平和垂直方向上移动正确的距离。
# 计算射击函数中的斜率
以下是计算斜率的代码:
float gradient = (startX - targetX) / (startY - targetY);
这段代码使用两个点(startX
,startY
)和(targetX
,targetY
)来计算飞行路径的斜率。它用起始水平位置减去结束水平位置,用起始垂直位置减去结束垂直位置,然后用前者的结果除以后者,得到一个代表角度的比率。
# 使射击函数中的斜率为正
以下是相关代码。虽然简单,但对我们的解决方案很重要:
if (gradient < 0) {
gradient *= -1;
}
2
3
这段代码确保斜率始终为正。如果斜率最初为负,则去掉负号。这是必要的,因为传入的起始和目标坐标可能是正的也可能是负的,而我们始终希望每帧的移动量是正的。乘以 -1 只是将负数变为其正数等价形式,因为负负得正。
# 计算射击函数中X和Y之间的比率
再次看一下下面这行代码,然后我们进一步分解讨论:
float ratioXY = m_BulletSpeed / (1 + gradient);
1 + gradient
这部分给计算出的斜率加1。这样做是为了防止除零错误,确保除法的分母不等于零。
m_BulletSpeed / (1 + gradient)
这部分计算子弹移动的水平和垂直分量之间的比率。分子(m_BulletSpeed
)代表子弹的总速度,分母(1 + gradient
)根据飞行路径的斜率调整这个速度。
如果飞行路径有一个陡峭的向上斜率(大的正斜率),分母会更大,导致比率更小。这意味着子弹的速度更多地分配到垂直方向。
如果飞行路径有一个陡峭的向下斜率(大的负斜率),分母会更小,导致比率更大。这意味着子弹的速度更多地分配到水平方向。
最后,对于这行代码float ratioXY =
,它将结果存储在变量ratioXY
中。现在这个变量保存了一个值,该值代表根据计算出的斜率和指定的子弹速度,子弹在水平和垂直方向上应该移动的距离之比。
# 完成射击函数的讲解
接下来的两行代码完成了我们的子弹代码:
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
2
这两行代码根据之前计算出的比率和斜率,确定子弹在垂直方向(m_BulletDistanceY
)和水平方向(m_BulletDistanceX
)上应该移动的距离。
尽管进行了这么多深入的计算,但实际的移动方向将在update
函数中处理,通过在这个更新函数中加上或减去我们刚刚得到的正值来实现。
代码的下一部分要简单得多。我们只是设置了子弹能够到达的最大水平和垂直位置。我们不希望子弹永远飞行下去。在update
函数中,我们将检查子弹是否超过了其最大或最小位置:
// 设置任何方向上的最大射程为1000像素
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
2
3
4
5
6
以下代码将代表子弹的精灵移动到其起始位置。我们像之前经常做的那样,使用Sprite
的setPosition
函数:
// 定位子弹,准备绘制
m_BulletShape.setPosition(m_Position);
2
现在我们完成了shoot
函数的编写。
# 更多子弹相关函数
接下来,有四个简单的函数。我们来添加stop
、isInFlight
、getPosition
和getShape
函数:
void Bullet::stop() {
m_InFlight = false;
}
bool Bullet::isInFlight() {
return m_InFlight;
}
FloatRect Bullet::getPosition() {
return m_BulletShape.getGlobalBounds();
}
RectangleShape Bullet::getShape() {
return m_BulletShape;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
stop
函数只是将m_InFlight
变量设为false
。isInFlight
函数返回这个变量当前的值。由此可见,shoot
函数让子弹发射,stop
函数使其停止,isInFlight
函数则告知我们当前状态。
getPosition
函数返回一个FloatRect
。很快我们就会看到如何利用每个游戏对象的FloatRect
来检测碰撞。
最后,对于上述代码,getShape
函数返回一个RectangleShape
,这样我们就能在每一帧绘制子弹。
# 子弹类(Bullet class)的更新函数
在开始使用Bullet
对象之前,我们需要实现的最后一个函数是update
。添加以下代码,仔细研究,然后我们再讨论:
void Bullet::update(float elapsedTime) {
// 更新子弹位置变量
m_Position.x += m_BulletDistanceX * elapsedTime;
m_Position.y += m_BulletDistanceY * elapsedTime;
// 移动子弹
m_BulletShape.setPosition(m_Position);
// 子弹是否超出范围?
if (m_Position.x < m_MinX || m_Position.x > m_MaxX ||
m_Position.y < m_MinY || m_Position.y > m_MaxY) {
m_InFlight = false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在update
函数中,我们将m_BulletDistanceX
和m_BulletDistanceY
乘以自上一帧以来的时间,以此来移动子弹。记住,这两个变量的值是在shoot
函数中计算出来的,它们代表了以正确角度移动子弹所需的斜率(相互之间的比率)。然后,我们使用setPosition
函数来移动RectangleShape
。
在update
函数中我们做的最后一件事,是检查子弹是否超出了最大射程。这个略显复杂的if
语句会将m_Position.x
和m_Position.y
与在shoot
函数中计算出的最大值和最小值进行比较。这些最大值和最小值存储在m_MinX
、m_MaxX
、m_MinY
和m_MaxY
中。
代码会检查m_Position
(.x
和.y
)是否超出了由m_MinX
、m_MaxX
、m_MinY
和m_MaxY
定义的矩形区域。记住,m_Min...
这些值定义了当前子弹能到达的最远点。如果位置超出了这个区域,m_InFlight
变量就会被设为false
,从而让子弹停止。
如果测试结果为真,那么m_InFlight
就会被设为false
。
Bullet
类现在完成了。接下来,我们看看如何在主函数中发射子弹。
# 让子弹飞起来
在接下来的部分,我们将通过以下六个步骤让子弹能够使用:
- 添加
Bullet
类所需的包含指令。 - 添加一些控制变量和一个数组,用于存放一些
Bullet
实例。 - 处理玩家按下
R
键重新装填子弹的操作。 - 处理玩家按下鼠标左键发射子弹的操作。
- 在每一帧更新所有正在飞行的子弹。
- 在每一帧绘制正在飞行的子弹。
# 包含子弹类
添加包含指令,使Bullet
类可用:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
using namespace sf;
2
3
4
5
6
让我们进入下一步。
# 控制变量和子弹数组
这里有一些变量,用于跟踪弹夹容量、备用子弹数量、弹夹中剩余子弹数量、当前射速(初始为每秒一发),以及上一次发射子弹的时间。
添加以下突出显示的代码。然后,在本节剩余部分,我们将看到这些变量发挥作用:
// 准备迎接一群僵尸
int numZombies;
int numZombiesAlive;
Zombie* zombies = NULL;
// 100发子弹应该够了
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// 开火按钮上次是什么时候按下的?
Time lastPressed;
// 主游戏循环
while (window.isOpen())
2
3
4
5
6
7
8
9
10
11
12
13
14
15
接下来,让我们处理玩家按下键盘R
键的情况,该键用于重新装填弹夹。
# 重新装填枪支
现在,我们来处理与发射子弹相关的玩家输入。首先,处理按下R
键重新装填枪支的操作。我们将通过SFML事件来实现。
添加以下突出显示的代码。为确保代码添加到正确位置,这里给出了大量上下文。研究代码后,我们再进行讨论:
// 处理事件
Event event;
while (window.pollEvent(event)) {
if (event.type == Event::KeyPressed) {
// 游戏进行时暂停游戏
if (event.key.code == Keyboard::Return &&
state == State::PLAYING) {
state = State::PAUSED;
}
// 暂停时重新开始游戏
else if (event.key.code == Keyboard::Return && state == State::PAUSED) {
state = State::PLAYING;
// 重置时钟,避免出现画面跳帧
clock.restart();
}
// 在游戏结束(GAME_OVER)状态下开始新游戏
else if (event.key.code == Keyboard::Return && state == State::GAME_OVER) {
state = State::LEVELING_UP;
}
if (state == State::PLAYING) {
// 重新装填子弹
if (event.key.code == Keyboard::R) {
if (bulletsSpare >= clipSize) {
// 子弹充足,重新装填
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
}
else if (bulletsSpare > 0) {
// 剩余子弹不多
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
}
else {
// 很快会有更多内容?!
}
}
}
}
}// 结束事件轮询
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
上述代码嵌套在游戏循环的事件处理部分(while(window.pollEvent)
)中,并且在游戏实际进行时(if(state == State::Playing)
)才会执行的代码块内。显然,我们不希望玩家在游戏结束或暂停时重新装填子弹,按照上述方式包装新代码就能实现这一点。
在新代码中,我们首先通过if (event.key.code == Keyboard::R)
检测是否按下了R
键。一旦检测到按下了R
键,就会执行剩余代码。以下是if
、else if
和else
代码块的结构:
if(bulletsSpare >= clipSize)
...
else if(bulletsSpare > 0)
...
else
...
2
3
4
5
6
上述结构让我们能够处理三种可能的情况,如下所示:
- 玩家按下了
R
键,且备用子弹数量多于弹夹容量。在这种情况下,弹夹会被装满,备用子弹数量会减少。 - 玩家有一些备用子弹,但不足以完全装满弹夹。在这种情况下,弹夹会用玩家现有的备用子弹装满,备用子弹数量会被设为零。
- 玩家按下了
R
键,但没有备用子弹。对于这种情况,实际上我们不需要改变变量。不过,在第14章“音效、文件输入/输出和完成游戏”中实现音效时,我们会在这里播放一个音效,所以先保留这个空的else
代码块。
现在,让我们来发射一颗子弹。
# 发射子弹
这里,我们将处理点击鼠标左键发射子弹的操作。添加以下突出显示的代码,并仔细研究:
if (Keyboard::isKeyPressed(Keyboard::D)) {
player.moveRight();
}
else {
player.stopRight();
}
// 发射子弹
if (Mouse::isButtonPressed(sf::Mouse::Left)) {
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0) {
// 将玩家中心和准星中心的坐标传递给shoot函数
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++;
if (currentBullet > 99) {
currentBullet = 0;
}
lastPressed = gameTimeTotal;
bulletsInClip--;
}
}// 结束发射子弹
}// 结束在游戏进行时处理WASD操作
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
上述所有代码都包含在一个if
语句中,只要按下鼠标左键,该语句就会执行,即if (Mouse::isButtonPressed(sf::Mouse::Left))
。请注意,即使玩家只是按住按钮,代码也会重复执行。接下来我们要讲解的代码用于控制射速。
在上述代码中,我们检查游戏总耗时(gameTimeTotal
)减去玩家上次发射子弹的时间(lastPressed
)是否大于1000(因为1秒有1000毫秒)除以当前射速,并且玩家弹夹中至少有一发子弹。
如果这个测试通过,就会执行实际发射子弹的代码。发射子弹很简单,因为我们在Bullet
类中已经完成了所有复杂的工作。我们只需对bullets
数组中的当前子弹调用shoot
函数。我们传入玩家和准星当前的水平和垂直位置。子弹会由Bullet
类的shoot
函数进行配置并发射。
我们要做的只是跟踪子弹数组。我们递增currentBullet
变量。然后,通过if (currentBullet > 99)
语句检查是否发射了最后一颗子弹(第99颗)。如果是最后一颗子弹,就将currentBullet
设为零。如果不是,那么只要射速允许且玩家按下鼠标左键,下一颗子弹就可以发射。
最后,在上述代码中,我们将发射子弹的时间存储到lastPressed
中,并减少bulletsInClip
的值。
现在,我们可以在每一帧更新每一颗子弹。
# 在每一帧更新子弹
添加以下突出显示的代码,遍历bullets
数组,检查子弹是否在飞行中,如果是,就调用其update
函数:
// 遍历每个僵尸并更新它们
for (int i = 0; i < numZombies; i++) {
if (zombies[i].isAlive()) {
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// 更新任何正在飞行的子弹
for (int i = 0; i < 100; i++) {
if (bullets[i].isInFlight()) {
bullets[i].update(dtAsSeconds);
}
}
}// 结束更新场景
2
3
4
5
6
7
8
9
10
11
12
13
最后,我们将绘制所有子弹。
# 在每一帧绘制子弹
添加以下突出显示的代码,遍历bullets
数组,检查子弹是否在飞行中,如果是,就绘制它:
/*
**************
绘制场景
**************
*/
if (state == State::PLAYING) {
window.clear();
// 设置要在窗口中显示的主视图(mainView),并绘制与之相关的所有内容
window.setView(mainView);
// 绘制背景
window.draw(background, &textureBackground);
// 绘制僵尸
for (int i = 0; i < numZombies; i++) {
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++) {
if (bullets[i].isInFlight()) {
window.draw(bullets[i].getShape());
}
}
// 绘制玩家
window.draw(player.getSprite());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
运行游戏,试试发射子弹的功能。注意,你可以发射六发子弹,之后需要按R
键重新装填。明显缺失的部分是一些可视化的弹夹子弹数量和备用子弹数量指示器。另一个问题是玩家很快就会用完子弹,尤其是因为这些子弹完全没有停止作用,它们会直接穿过僵尸。此外,玩家需要瞄准鼠标指针,而不是精确的准星,显然我们还有很多工作要做。
接下来,我们将用准星(crosshair)替换鼠标光标,然后生成一些补给道具来补充子弹和生命值。最后,在本节中,我们将处理碰撞检测,让子弹和僵尸造成伤害,并让玩家能够实际获取补给道具。
# 为玩家添加准星
添加准星很简单,只需要一个新概念。添加以下突出显示的代码,然后我们来逐步分析:
// 100发子弹应该够了
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// 开火按钮上次按下是什么时候?
Time lastPressed;
// 隐藏鼠标指针并用准星代替
window.setMouseCursorVisible (true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture("graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// 主游戏循环
while (window.isOpen())
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
首先,我们在窗口对象上调用setMouseCursorVisible
函数。然后,我们加载一个纹理(Texture),声明一个精灵(Sprite)实例,并以常规方式对其进行初始化。此外,我们将精灵的原点设置为中心,这样就能方便、简单地让子弹飞向准星中心,这也符合我们的预期。
现在,我们需要在每一帧中用鼠标的世界坐标来更新准星。添加以下突出显示的代码行,它使用mouseWorldPosition
向量在每一帧中设置准星的位置:
/*
****************
更新帧
****************
*/
if (state == State::PLAYING) {
// 更新时间增量
Time dt = clock.restart();
// 更新游戏总时间
gameTimeTotal += dt;
// 将时间增量转换为1的小数部分
float dtAsSeconds = dt.asSeconds();
// 鼠标指针在哪里
mouseScreenPosition = Mouse::getPosition();
// 将鼠标位置转换为mainView的世界坐标
mouseWorldPosition = window.mapPixelToCoords(Mouse::getPosition(), mainView);
// 将准星设置到鼠标的世界位置
spriteCrosshair.setPosition(mouseWorldPosition);
// 更新玩家
player.update(dtAsSeconds, Mouse::getPosition());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
接下来,正如你可能期望的那样,我们可以在每一帧中绘制准星。在所示位置添加以下突出显示的代码行。这行代码无需解释,但它在所有其他游戏对象之后的位置很重要,这样它就能绘制在最上层:
/*
**************
绘制场景
**************
*/
if (state == State::PLAYING) {
window.clear();
// 设置要在窗口中显示的mainView,并绘制与之相关的所有内容
window.setView(mainView);
// 绘制背景
window.draw(background, &textureBackground);
// 绘制僵尸
for (int i = 0; i < numZombies; i++) {
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++) {
if (bullets[i].isInFlight())
{
window.draw(bullets[i].getShape());
}
}
// 绘制玩家
window.draw(player.getSprite());
// 绘制准星
window.draw(spriteCrosshair);
}
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
现在,你可以运行游戏,你将看到一个很酷的准星,而不是鼠标光标:
图12.1:一个很酷的准星代替了鼠标光标
注意子弹是如何整齐地穿过准星中心射出的。射击机制的工作方式类似于允许玩家选择腰射或瞄准射击。如果玩家将准星保持在中心附近,他们可以快速射击和转向,但必须仔细判断远处僵尸的位置。
或者,玩家可以将准星直接悬停在远处僵尸的头上,从而精确命中;然而,如果有僵尸从另一个方向攻击,他们就需要将准星移回更远的距离。
对游戏的一个有趣改进是为每次射击添加少量随机的不准确性。这种不准确性或许可以在波次之间通过升级来减轻。
# 编写拾取物类
在本节中,我们将编写一个Pickup
类,它有一个精灵成员,以及其他成员数据和函数。我们将通过几个步骤把拾取物添加到游戏中:
- 首先,我们将编写
Pickup.h
文件。这将揭示所有成员数据的详细信息以及函数的原型。 - 然后,我们将编写
Pickup.cpp
文件,该文件当然将包含Pickup
类所有函数的定义。在我们逐步讲解的过程中,我将详细解释Pickup
类型的对象将如何工作和被控制。 - 最后,我们将在主函数中使用
Pickup
类来生成、更新和绘制它们。
让我们从第一步开始。
# 编写拾取物头文件
要创建新的头文件,在解决方案资源管理器中右键单击“头文件”,然后选择“添加” | “新建项”。在“添加新项”窗口中,(通过左键单击)突出显示“头文件(.h)”,然后在“名称”字段中输入Pickup.h
。
将以下代码添加到Pickup.h
文件并学习,然后我们来分析它:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Pickup
{
private:
// 生命拾取物的起始值
const int HEALTH_START_VALUE = 50;
const int AMMO_START_VALUE = 12;
const int START_WAIT_TIME = 10;
const int START_SECONDS_TO_LIVE = 5;
// 代表这个拾取物的精灵
Sprite m_Sprite;
// 它所在的区域
IntRect m_Arena;
// 这个拾取物价值多少?
int m_Value;
// 这是哪种类型的拾取物?1 = 生命,2 = 弹药
int m_Type;
// 处理生成和消失
bool m_Spawned;
float m_SecondsSinceSpawn;
float m_SecondsSinceDeSpawn;
float m_SecondsToLive;
float m_SecondsToWait;
// 公共函数原型放在这里
};
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
前面的代码声明了Pickup
类的所有私有变量。虽然这些名称应该很直观,但可能不清楚为什么需要其中许多变量。让我们从顶部开始逐个分析:
const int HEALTH_START_VALUE = 50
:这个常量变量用于设置所有生命拾取物的起始值。该值将用于初始化m_Value
变量,在整个游戏中需要对m_Value
进行操作。const int AMMO_START_VALUE = 12
:这个常量变量用于设置所有弹药拾取物的起始值。该值将用于初始化m_Value
变量,在整个游戏中需要对m_Value
进行操作。const int START_WAIT_TIME = 10
:这个变量决定了拾取物消失后重新生成之前要等待的时间。它将用于初始化m_SecondsToWait
变量,在整个游戏中可以对m_SecondsToWait
进行操作。const int START_SECONDS_TO_LIVE = 5
:这个变量决定了拾取物从生成到消失之间的持续时间。与前面三个常量一样,它有一个相关的非常量变量,在整个游戏中可以对其进行操作。它用于初始化的非常量变量是m_SecondsToLive
。Sprite m_Sprite
:这是用于可视化表示对象的精灵。IntRect m_Arena
:这将保存当前区域的大小,以帮助拾取物在合理的位置生成。int m_Value
:这个拾取物能提供多少生命或弹药?当玩家提升生命或弹药拾取物的值时会用到这个值。int m_Type
:这个值对于生命拾取物为1,对于弹药拾取物为2。我们本可以使用枚举类,但对于只有两个选项的情况,这似乎有些过头了。bool m_Spawned
:拾取物当前是否已生成?float m_SecondsSinceSpawn
:拾取物生成后过去了多长时间?float m_SecondsSinceDeSpawn
:拾取物消失(被移除生成)后过去了多长时间?float m_SecondsToLive
:这个拾取物在消失之前应该保持生成状态多长时间?float m_SecondsToWait
:这个拾取物在重新生成之前应该保持消失状态多长时间?
请注意,这个类的大部分复杂性是由于生成时间变量及其可升级的特性。如果拾取物在被收集时只是重新生成并且有固定的值,那么这个类会非常简单。我们需要拾取物是可升级的,这样玩家就必须制定策略才能在波次中取得进展。
接下来,将以下公共函数原型添加到Pickup.h
文件中。一定要熟悉新代码,以便我们进行分析:
// 公共函数原型放在这里
public:
Pickup(int type);
// 准备一个新的拾取物
void setArena(IntRect arena);
void spawn();
// 检查拾取物的位置
FloatRect getPosition();
// 获取用于绘制的精灵
Sprite getSprite();
// 让拾取物每帧自我更新
void update(float elapsedTime);
// 这个拾取物当前是否已生成?
bool isSpawned();
// 从拾取物获取收益
int gotIt();
// 升级每个拾取物的值
void upgrade();
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
让我们简要讨论一下每个函数的定义:
- 第一个函数是构造函数,与类同名。请注意,它接受一个
int
类型的参数。这个参数将用于初始化拾取物的类型(生命或弹药)。 setArena
函数接收一个IntRect
。在每一波开始时,会为每个Pickup
实例调用这个函数。这样,Pickup
对象就会“知道”它们可以在哪些区域生成。spawn
函数当然会处理拾取物的生成。getPosition
函数,就像在Player
、Zombie
和Bullet
类中一样,将返回一个FloatRect
实例,该实例表示对象在游戏世界中的当前位置。getSprite
函数返回一个Sprite
对象,以便在每一帧绘制拾取物。update
函数接收上一帧所用的时间。它使用这个值来更新其私有变量,并决定何时生成和移除拾取物。isSpawned
函数返回一个布尔值,让调用代码知道拾取物当前是否已生成。gotIt
函数将在检测到与玩家发生碰撞时被调用。然后,Pickup
类的代码可以为在适当的时间重新生成做好准备。请注意,它返回一个int
值,以便调用代码知道这个拾取物在生命或弹药方面“价值”多少。upgrade
函数将在玩家在游戏的升级阶段选择提升拾取物的属性时被调用。
现在我们已经分析了成员变量和函数原型,在编写函数定义时应该很容易理解。
# 编写拾取物类的函数定义
现在,我们可以创建一个新的.cpp
文件,其中将包含函数定义。在解决方案资源管理器中右键单击“源文件”,然后选择“添加” | “新建项”。在“添加新项”窗口中,(通过左键单击)突出显示“C++文件(.cpp)”,然后在“名称”字段中输入Pickup.cpp
。最后,点击“添加”按钮。现在我们准备编写类的代码。
将以下代码添加到Pickup.cpp
文件中。一定要查看代码,以便我们进行讨论:
#include "Pickup.h"
#include "TextureHolder.h"
Pickup::Pickup(int type): m_Type{type}
{
// 将纹理与精灵关联
if (m_Type == 1) {
m_Sprite = Sprite(TextureHolder::GetTexture("graphics/health_pickup.png"));
// 拾取物价值多少
m_Value = HEALTH_START_VALUE;
}
else
{
m_Sprite = Sprite(TextureHolder::GetTexture("graphics/ammo_pickup.png"));
// 拾取物价值多少
m_Value = AMMO_START_VALUE;
}
m_Sprite.setOrigin(25, 25);
m_SecondsToLive = START_SECONDS_TO_LIVE;
m_SecondsToWait = START_WAIT_TIME;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在前面的代码中,我们添加了常见的包含指令。然后,我们添加了Pickup
构造函数。我们知道它是构造函数,因为它与类同名。
构造函数接收一个名为type
的int
类型变量,代码做的第一件事就是将从type
接收到的值赋给m_Type
。在此之后,有一个if else
代码块,检查m_Type
是否等于1。如果是,将m_Sprite
与生命拾取物的纹理关联,并将m_Value
设置为HEALTH_START_VALUE
。
如果m_Type
不等于1,else
代码块将弹药拾取物的纹理与m_Sprite
关联,并将AMMO_START_VALUE
的值赋给m_Value
。
在if else
代码块之后,代码使用setOrigin
函数将m_Sprite
的原点设置为中心,并分别将START_SECONDS_TO_LIVE
和START_WAIT_TIME
赋给m_SecondsToLive
和m_SecondsToWait
。
构造函数成功地准备了一个可供使用的Pickup
对象。现在,我们将添加setArena
函数。添加代码时仔细查看:
void Pickup::setArena(IntRect arena)
{
// 将区域的详细信息复制到拾取物的m_Arena
m_Arena.left = arena.left + 50;
m_Arena.width = arena.width - 50;
m_Arena.top = arena.top + 50;
m_Arena.height = arena.height - 50;
spawn();
}
2
3
4
5
6
7
8
9
我们刚刚编写的setArena
函数只是从传入的arena
对象复制值,但在左边和顶部加上50,在右边和底部减去50。现在,Pickup
对象知道了它可以在哪个区域生成。setArena
函数然后调用它自己的spawn
函数,为每一帧的绘制和更新做最后的准备。
接下来是spawn
函数。在setArena
函数之后添加以下代码:
void Pickup::spawn() {
// 在随机位置生成
srand((int)time(0) / m_Type);
int x = (rand() % m_Arena.width);
srand((int)time(0) * m_Type);
int y = (rand() % m_Arena.height);
m_SecondsSinceSpawn = 0;
m_Spawned = true;
m_Sprite.setPosition(x, y);
}
2
3
4
5
6
7
8
9
10
spawn
函数完成了准备拾取物所需的所有操作。首先,它为随机数生成器设置种子,并为对象的水平和垂直位置获取一个随机数。注意,它使用m_Arena.width
和m_Arena.height
变量作为可能的水平和垂直位置的范围。
m_SecondsSinceSpawn
变量被设置为零,这样在它被移除生成之前允许的时间长度就被重置了。m_Spawned
变量被设置为true
,这样当我们在主函数中调用isSpawned
时,会得到肯定的响应。最后,使用setPosition
将m_Sprite
移动到指定位置,准备绘制到屏幕上。
在下面这段代码中,我们有三个简单的获取函数(getter functions)。getPosition
函数返回m_Sprite
当前位置的FloatRect
,getSprite
函数返回m_Sprite
本身的一个副本,isSpawned
函数根据对象当前是否已生成,返回true
或false
。
添加并研究我们刚刚讨论的代码:
FloatRect Pickup::getPosition() {
return m_Sprite.getGlobalBounds();
}
Sprite Pickup::getSprite() {
return m_Sprite;
}
bool Pickup::isSpawned() {
return m_Spawned;
}
2
3
4
5
6
7
8
9
10
11
接下来,我们将编写gotIt
函数。当玩家触摸/碰撞到(获取到)道具(pickup)时,主程序(main)会调用这个函数。在isSpawned
函数之后添加gotIt
函数:
int Pickup::gotIt() {
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
return m_Value;
}
2
3
4
5
gotIt
函数将m_Spawned
设置为false
,这样我们就知道不再绘制该道具并不再检测碰撞。m_SecondsSinceDespawn
被设置为零,以便重新开始生成倒计时。然后,m_Value
被返回给调用代码,这样调用代码就可以根据情况处理增加额外弹药或生命值的操作。
接下来,我们需要编写update
函数,它将我们目前看到的许多变量和函数联系在一起。添加并熟悉update
函数,然后我们再进行讨论:
void Pickup::update(float elapsedTime) {
if (m_Spawned) {
m_SecondsSinceSpawn += elapsedTime;
}
else {
m_SecondsSinceDeSpawn += elapsedTime;
}
// 我们需要隐藏一个道具吗?
if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned) {
// 移除该道具并将其放置到其他位置
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
}
// 我们需要生成一个道具吗?
if (m_SecondsSinceDeSpawn > m_SecondsToWait &&!m_Spawned) {
// 生成该道具并重置计时器
spawn();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
update
函数分为四个代码块,在每一帧中都会考虑执行:
- 一个
if
代码块,当m_Spawned
为true
时执行:if (m_Spawned)
。这段代码将本帧的时间加到m_SecondsSinceSpawned
上,m_SecondsSinceSpawned
用于记录道具生成了多长时间。 - 一个相应的
else
代码块,当m_Spawned
为false
时执行。这段代码将本帧花费的时间加到m_SecondsSinceDeSpawn
上,m_SecondsSinceDeSpawn
用于记录道具自上次取消生成(隐藏)后等待了多长时间。 - 另一个
if
代码块,当道具生成的时间超过了应有的时长时执行:if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
。这个代码块将m_Spawned
设置为false
,并将m_SecondsSinceDeSpawn
重置为零。现在,直到再次到了生成道具的时间之前,代码块2将执行。 - 最后一个
if
代码块,当取消生成后的等待时间超过了必要的等待时间,并且当前道具未生成时执行:if (m_SecondsSinceDeSpawn > m_SecondsToWait &&!m_Spawned)
。当执行这个代码块时,就到了再次生成道具的时间,会调用spawn
函数。
这四个检测操作控制着道具的隐藏和显示。最后,添加upgrade
函数的定义:
void Pickup::upgrade() {
if (m_Type == 1) {
m_Value += (HEALTH_START_VALUE *.5);
}
else {
m_Value += (AMMO_START_VALUE *.5);
}
// 让它们出现得更频繁,持续时间更长
m_SecondsToLive += (START_SECONDS_TO_LIVE / 10);
m_SecondsToWait -= (START_WAIT_TIME / 10);
}
2
3
4
5
6
7
8
9
10
11
12
upgrade
函数检测道具的类型,是生命值道具还是弹药道具,然后将(相应的)起始值的50%加到m_Value
上。if else
代码块之后的两行代码增加了道具保持生成状态的时间,减少了玩家在道具生成之间必须等待的时间。
当玩家在“升级”(LEVELING_UP)状态下选择升级道具时,会调用这个函数。
我们的Pickup
类已准备好使用。
# 使用拾取物类
在辛苦实现了Pickup
类之后,我们现在可以在游戏引擎中编写代码,将一些道具添加到游戏中。
我们要做的第一件事是在ZombieArena.cpp
文件中添加一个包含指令:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
2
3
4
5
6
7
在下面的代码中,我们添加了两个Pickup
实例:一个名为healthPickup
,另一个名为ammoPickup
。我们分别将值1和2传递给构造函数,以便它们被初始化为正确的道具类型。添加下面突出显示的代码:
// 隐藏鼠标指针并替换为十字准星
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture(
"graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// 创建几个道具
Pickup healthPickup(1);
Pickup ammoPickup(2);
// 主游戏循环
while (window.isOpen())
2
3
4
5
6
7
8
9
10
11
12
13
在键盘处理的“升级”(LEVELING_UP)状态中,在嵌套的“游玩”(PLAYING)代码块内添加以下突出显示的代码行:
if (state == State::PLAYING) {
// 准备关卡
// 我们稍后会修改接下来的两行代码
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// 通过引用将顶点数组传递给createBackground函数
int tileSize = createBackground(background, arena);
// 在竞技场中间生成玩家
player.spawn(arena, resolution, tileSize);
// 配置道具
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// 创建一群僵尸
numZombies = 10;
// 删除之前分配的内存(如果存在)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// 重置时钟,这样就不会出现帧跳跃
clock.restart();
}
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
前面的代码只是将arena
传递给每个道具的setArena
函数。现在道具知道了它们可以在哪里生成。这段代码在每一波新的游戏开始时执行,所以随着竞技场大小的增加,Pickup
对象也会得到更新。
下面的代码只是在每一帧为每个Pickup
对象调用update
函数:
// 遍历每个僵尸并更新它们
for (int i = 0; i < numZombies; i++) {
if (zombies[i].isAlive()) {
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// 更新任何正在飞行的子弹
for (int i = 0; i < 100; i++) {
if (bullets[i].isInFlight()) {
bullets[i].update(dtAsSeconds);
}
}
// 更新道具
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
}// 结束场景更新
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
游戏循环的绘制部分中的以下代码会检查道具当前是否已生成,如果已生成,则绘制它。让我们添加这段代码:
// 绘制玩家
window.draw(player.getSprite());
// 如果当前已生成,则绘制道具
if (ammoPickup.isSpawned()) {
window.draw(ammoPickup.getSprite());
}
if (healthPickup.isSpawned()) {
window.draw(healthPickup.getSprite());
}
// 绘制十字准星
window.draw(spriteCrosshair);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
现在,你可以运行游戏,看到道具生成和取消生成。然而,你实际上还不能拾取它们:
图12.2:道具生成和取消生成
现在我们的游戏中已经有了所有的对象,是时候让它们相互交互(碰撞)了。
# 检测碰撞
我们只需要知道游戏中的某些对象何时与其他某些对象接触。然后我们可以以适当的方式对该事件做出响应。在我们的类中,已经添加了在对象碰撞时会被调用的函数。如下所示:
Player
类有一个hit
函数。当僵尸与玩家碰撞时,我们将调用它。Zombie
类有一个hit
函数。当子弹与僵尸碰撞时,我们将调用它。Pickup
类有一个gotIt
函数。当玩家与道具碰撞时,我们将调用它。
如果有必要,可以回顾一下这些函数的工作原理,加深记忆。我们现在需要做的就是检测碰撞并调用相应的函数。
我们将使用矩形相交来检测碰撞。这种碰撞检测方式很直接(特别是在使用SFML库时)。我们将使用在乒乓球游戏中使用过的相同技术。下图展示了一个矩形如何合理准确地表示僵尸和玩家:
图12.3 代表僵尸和玩家的矩形
我们将在三段相互衔接的代码中处理这个问题。这些代码都将放在游戏引擎更新部分的末尾。
对于每一帧,我们需要知道以下三个问题的答案:
- 有僵尸被击中了吗?
- 玩家被僵尸碰到了吗?
- 玩家碰到道具了吗?
首先,让我们再添加几个用于记录得分和最高分的变量。这样当杀死一个僵尸时,我们就可以更改这些变量的值。添加以下代码:
// 创建几个道具
Pickup healthPickup(1);
Pickup ammoPickup(2);
// 关于游戏
int score = 0;
int hiScore = 0;
// 主游戏循环
while (window.isOpen())
2
3
4
5
6
7
8
9
现在,让我们从检测僵尸是否与子弹碰撞开始。
# 有僵尸被击中了吗?
下面的代码可能看起来很复杂,但当我们逐步分析时,会发现并没有什么新内容。在每帧调用更新道具的代码之后添加以下代码。然后我们来仔细分析:
// 更新道具
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
// 碰撞检测
// 有僵尸被击中了吗?
for (int i = 0; i < 100; i++) {
for (int j = 0; j < numZombies; j++) {
if (bullets[i].isInFlight() &&
zombies[j].isAlive())
{
if (bullets[i].getPosition().intersects(
zombies[j].getPosition()))
{
// 停止子弹
bullets[i].stop();
// 记录击中并查看是否杀死了僵尸
if (zombies[j].hit())
{
// 不只是击中,还杀死了僵尸
score += 10;
if (score >= hiScore) {
hiScore = score;
}
numZombiesAlive--;
// 当所有僵尸都再次死亡时
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
}
}
}
}
}// 结束僵尸被击中的检测
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
循环的结构(去掉了一些代码),如下所示:
// 碰撞检测
// 有僵尸被击中了吗?
for (int i = 0; i < 100; i++) {
for (int j = 0; j < numZombies; j++) {
...
...
...
}
}
2
3
4
5
6
7
8
9
代码会针对每个僵尸(0到小于numZombies
)遍历每颗子弹(0到99)。在嵌套的for
循环内,我们执行以下操作:
- 我们使用以下代码检查当前子弹是否在飞行中,并且当前僵尸是否还活着:
if (bullets[i].isInFlight() && zombies[j].isAlive())
- 只要僵尸活着且子弹在飞行中,我们就使用以下代码测试矩形是否相交:
if (bullets[i].getPosition().intersects(zombies[j].getPosition()))
- 如果当前子弹和僵尸发生了碰撞,那么我们执行以下几个步骤。用以下代码停止子弹:
// 停止子弹
bullets[i].stop();
2
- 通过调用当前僵尸的
hit
函数记录一次击中。注意,hit
函数返回一个布尔值,让调用代码知道僵尸是否已经死亡。如下行代码所示:
// 记录击中并查看是否杀死了僵尸
if (zombies[j].hit()) {
2
- 在这个检测到僵尸已死亡且不只是受伤的
if
代码块内,执行以下操作:- 得分加10。
- 如果玩家获得的分数达到或超过(打破)了最高分,则更改最高分。
numZombiesAlive
减1。- 用
(numZombiesAlive == 0)
检查是否所有僵尸都已死亡,如果是,则将state
更改为LEVELING_UP
。
下面是我们刚刚讨论的if(zombies[j].hit())
代码块内的代码:
// 不只是击中,还杀死了僵尸
score += 10;
if (score >= hiScore) {
hiScore = score;
}
numZombiesAlive--;
// 当所有僵尸都再次死亡时
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
2
3
4
5
6
7
8
9
10
11
僵尸和子弹的碰撞检测就处理好了。现在你可以运行游戏,看到“血腥场面”了。当然,在我们在下一章实现平视显示器(HUD)之前,你还看不到分数。
# 玩家被僵尸碰到了吗?
这段代码比僵尸与子弹碰撞检测的代码要短得多,也简单得多。在我们之前编写的代码之后添加以下突出显示的代码:
}// End zombie being shot
// Have any zombies touched the player
for (int i = 0; i < numZombies; i++) {
if (player.getPosition() .intersects
(zombies[i].getPosition()) && zombies[i].isAlive ())
{
if (player.hit(gameTimeTotal)) {
// More here later
}
if (player.getHealth() <= 0 ) {
state = State::GAME_OVER; }
}
}// End player touched
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在这里,我们通过使用for
循环遍历所有僵尸,来检测是否有僵尸与玩家发生了碰撞。对于每一个存活的僵尸,代码使用intersects
函数来检测是否与玩家发生了碰撞。当发生碰撞时,我们调用player.hit
。然后,我们通过调用player.getHealth
检查玩家是否死亡。如果玩家的生命值等于或小于零,我们将state
更改为GAME_OVER
。
你现在可以运行游戏,碰撞也会被检测到。然而,由于目前还没有平视显示器(HUD,Head-Up Display )和音效,所以玩家并不清楚碰撞是否发生。此外,当玩家死亡并且新游戏开始时,我们还需要做更多的工作来重置游戏。
所以,尽管游戏能运行,但目前的结果并不特别令人满意。我们将在接下来的两章中对此进行改进。
# 玩家碰到补给品了吗?
这里展示了玩家与两种补给品之间的碰撞检测代码。在我们之前添加的代码之后添加以下突出显示的代码:
}// End player touched
// Has the player touched health pickup
if (player.getPosition() .intersects
(healthPickup.getPosition()) && healthPickup.isSpawned()) {
player.increaseHealthLevel(healthPickup.gotIt());
}
// Has the player touched ammo pickup
if (player.getPosition() .intersects(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
}
}// End updating the scene
2
3
4
5
6
7
8
9
10
11
12
13
14
15
前面的代码使用两个简单的if
语句来判断玩家是否碰到了healthPickup
或ammoPickup
。
如果玩家收集到了生命值补给品,那么player.increaseHealthLevel
函数会使用healthPickup.gotIt
函数返回的值来增加玩家的生命值。
如果玩家收集到了弹药补给品,那么bulletsSpare
会增加ammoPickup.gotIt
返回的值。
现在你可以运行游戏、杀死僵尸并收集补给品了!注意,当你的生命值等于零时,游戏将进入
GAME_OVER
状态并暂停。要重新开始游戏,你需要按回车键,然后输入1到6之间的数字。当我们实现平视显示器、主屏幕和升级屏幕时,这些步骤对玩家来说将直观易懂。我们将在下一章进行相关内容的编写。
# 总结
这是充实的一章,我们收获颇丰。我们不仅通过两个新类将子弹和补给品添加到了游戏中,还通过检测物体之间的碰撞,使所有物体都能按预期进行交互。
尽管取得了这些成果,但我们还需要做更多工作来设置每一局新游戏,并通过平视显示器向玩家提供反馈。在下一章中,我们将构建平视显示器。
# 常见问题解答
这里有一个你可能会想到的问题: 问题1:有没有更好的碰撞检测方法?
回答:有。碰撞检测的方法有很多,包括但不限于以下几种:
- 你可以将物体分割成多个更贴合精灵形状的矩形。对于C++来说,每帧检查数千个矩形是完全可行的。特别是当你使用相邻检查等技术来减少每帧所需的检测次数时。
- 对于圆形物体,你可以使用半径重叠法。
- 对于不规则多边形,你可以使用交叉数算法。
如果你愿意,可以通过以下链接查看所有这些技术: