第7章 AABB碰撞检测与物理效果——完成乒乓球游戏
# 第7章 AABB碰撞检测与物理效果——完成乒乓球游戏
在本章中,我们将编写第二个类。我们会发现,尽管球与球拍明显大不相同,但我们将使用与处理球拍和Bat
类完全相同的技术,把球的外观和功能封装在Ball
类中。然后,我们将通过编写一些碰撞检测和计分功能,为乒乓球游戏画上最后的点睛之笔。这听起来可能很复杂,但正如我们所期待的,SFML会让事情比想象中容易得多。
本章我们将涵盖以下主题:
- 编写
Ball
类 - 使用
Ball
类 - 碰撞检测和计分
- 运行游戏
- 了解C++的箭头运算符(spaceship operator)
我们将从编写表示球的类开始。
# 编写Ball类
首先,我们来编写头文件。在解决方案资源管理器窗口中右键单击“头文件”,选择“添加” | “新建项”。接下来,选择“头文件(.h)”选项,将新文件命名为Ball.h
。然后,点击“添加”按钮。现在,我们就可以编写文件内容了。在Ball.h
中添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Ball
{
private:
Vector2f m_Position;
RectangleShape m_Shape;
float m_Speed = 300.0f;
float m_DirectionX = .2f;
float m_DirectionY = .2f;
public:
Ball(float startX, float startY);
FloatRect getPosition();
RectangleShape getShape();
float getXVelocity();
void reboundSides();
void reboundBatOrTop();
void reboundBottom();
void update(Time dt);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
你首先会注意到,与Bat
类相比,成员变量有相似之处。和玩家的球拍类一样,这里有用于表示位置、外观和速度的成员变量,而且类型相同(分别是Vector2f
、RectangleShape
和float
),甚至名字都一样(分别是m_Position
、m_Shape
和m_Speed
)。这个类的成员变量的不同之处在于,方向是由两个float
变量来处理的,它们将跟踪水平和垂直方向的移动,即m_DirectionX
和m_DirectionY
。
注意,我们需要编写八个函数来让球“动起来”。有一个与类同名的构造函数,我们将用它来初始化Ball
类的实例。还有三个函数的名称和用法与Bat
类中的相同,即getPosition
、getShape
和update
。getPosition
和getShape
函数会将球的位置和外观信息传递给主函数,update
函数将在主函数中被调用,以便Ball
类每帧更新一次球的位置。
其余的函数控制球的运动方向。当检测到球与屏幕两侧发生碰撞时,主函数会调用reboundSides
函数;当球击中玩家的球拍或屏幕顶部时,会调用reboundBatOrTop
函数;当球击中屏幕底部时,会调用reboundBottom
函数。
当然,这些只是声明,接下来让我们在Ball.cpp
文件中编写实现功能的C++代码。
我们先创建这个文件,然后就可以开始讨论代码了。在解决方案资源管理器窗口中右键单击“源文件”文件夹,选择“C++文件(.cpp)”,在“名称:”字段中输入Ball.cpp
。点击“添加”按钮,新文件就创建好了。在Ball.cpp
中添加以下代码:
#include "Ball.h"
// This the constructor function
Ball::Ball(float startX, float startY) : m_Position(startX,startY) {
m_Shape.setSize(sf::Vector2f(10, 10));
m_Shape.setPosition(m_Position);
}
2
3
4
5
6
7
在上述代码中,我们添加了Ball
类头文件所需的包含指令。与类同名的构造函数接收两个float
类型的参数,通过初始化列表来初始化m_Position
成员的Vector2f
实例。然后使用setSize
函数设置RectangleShape
实例的大小,使用setPosition
函数设置其位置。这里设置的大小是宽10像素、高10像素,这个尺寸是随意设定的,但效果不错。位置当然是取自m_Position
这个Vector2f
实例。
在Ball.cpp
函数的构造函数下方添加以下代码:
FloatRect Ball::getPosition()
{
return m_Shape.getGlobalBounds();
}
RectangleShape Ball::getShape() {
return m_Shape;
}
float Ball::getXVelocity() {
return m_DirectionX;
}
2
3
4
5
6
7
8
9
10
11
12
在上述代码中,我们编写了Ball
类的三个访问器函数(getter function)。它们各自向主函数返回一些信息。第一个函数getPosition
,通过对m_Shape
调用getGlobalBounds
函数返回一个FloatRect
实例,这个实例将用于碰撞检测。
getShape
函数返回m_Shape
,以便在游戏循环的每一帧中绘制球。getXVelocity
函数告诉主函数球在水平方向上的运动方向,我们很快就会看到它的用处。由于我们从不需要获取球的垂直速度,所以没有相应的getYVelocity
函数,但如果有需要,添加一个也很简单。
在刚才添加的代码下方添加以下函数:
void Ball::reboundSides() {
m_DirectionX = -m_DirectionX;
}
void Ball::reboundBatOrTop() {
m_DirectionY = -m_DirectionY;
}
void Ball::reboundBottom() {
m_Position.y = 0;
m_Position.x = 500;
m_DirectionY = -m_DirectionY;
}
2
3
4
5
6
7
8
9
10
11
12
13
在上述代码中,三个以rebound
开头的函数处理球在不同位置发生碰撞时的情况。在reboundSides
函数中,m_DirectionX
的值取反,这会使正值变为负值,负值变为正值,从而(水平方向上)反转球的运动方向。reboundBatOrTop
函数对m_DirectionY
做同样的操作,(垂直方向上)反转球的运动方向。reboundBottom
函数将球重新定位到屏幕顶部中央,并使其向下运动。这正是玩家没接到球,球击中屏幕底部后我们希望的效果。最后,为Ball
类添加如下update
函数:
void Ball::update(Time dt) {
// Update the ball's position
m_Position.y += m_DirectionY * m_Speed * dt.asSeconds();
m_Position.x += m_DirectionX * m_Speed * dt.asSeconds();
// Move the ball
m_Shape.setPosition(m_Position);
}
2
3
4
5
6
7
在上述代码中,m_Position.y
和m_Position.x
根据相应的方向、速度、速率以及当前帧完成所需的时间进行更新。更新后的m_Position
值随后用于改变m_Shape
这个RectangleShape
实例的位置。这和我们在第一个项目中移动云朵和蜜蜂的计算方式相同,不同之处在于这个逻辑包含在类里面。如果我们需要改变球的移动方式,只会影响Ball
类中的代码。
Ball
类编写完成了,接下来让我们让它发挥作用。
# 使用Ball类
为了让球发挥作用,添加以下代码,以便在主函数中使用Ball
类:
#include "Ball.h"
添加以下突出显示的代码行,使用我们刚刚编写的构造函数声明并初始化Ball
类的一个实例:
// Create a bat
Bat bat(1920 / 2, 1080 - 20);
// Create a ball
Ball ball(1920 / 2 , 0 ) ;
// Create a Text object called HUD
Text hud;
2
3
4
5
6
7
8
添加以下精确按照突出显示位置的代码:
/*
Update the bat, the ball and the HUD
****************************************************
****************************************************
****************************************************
*/
// Update the delta time
Time dt = clock.restart();
bat.update(dt);
ball.update(dt);
// Update the HUD text
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
2
3
4
5
6
7
8
9
10
11
12
13
14
在上述代码中,我们只是调用了球实例的update
函数,球会相应地重新定位。
添加以下突出显示的代码,在游戏循环的每一帧中绘制球:
/*
Draw the bat, the ball and the HUD
*********************************************
*********************************************
*********************************************
*/
window.clear();
window.draw(hud);
window.draw(bat.getShape());
window.draw(ball.getShape());
window.display();
2
3
4
5
6
7
8
9
10
11
此时,你可以运行游戏,球会在屏幕顶部生成并开始向屏幕底部下落。然而,球会消失在屏幕底部,因为我们还没有检测任何碰撞。现在我们来解决这个问题。
# 碰撞检测与计分
与《砍树!!!》(Timber!!! )游戏不同,在那个游戏里,我们只是简单检查最低位置的树枝是否与玩家角色在同一侧,而在这个游戏中,我们需要通过数学方法检查球与球棒是否相交,或者球是否与屏幕的四条边界中的任意一条相交。
我们来看一些假设性的代码,了解一下实现过程,之后再借助SFML库来帮我们解决这个问题。
测试两个矩形是否相交的代码大概如下。不要使用下面这段代码,它仅用于演示:
if(objectA.getPosition().right > objectB.getPosition().left
&& objectA.getPosition().left < objectB.getPosition().right )
{
// objectA is intersecting objectB on x axis
// But they could be at different heights
if(objectA.getPosition().top < objectB.getPosition().bottom
&& objectA.getPosition().bottom > objectB.getPosition().top )
{
// objectA is intersecting objectB
// on y axis as well-
// Collision detected
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
代码的第一部分在水平(即x轴)方向上进行条件测试。第一个if
语句检查objectA
和objectB
是否在水平(x轴)方向上相交,它将objectA
的右侧(objectA.getPosition().right
)与objectB
的左侧(objectB.getPosition().left
)进行比较,此外,它还会检查objectA
的左侧是否在objectB
的右侧的左边。如果这两个条件都成立,就意味着在x轴上存在相交。
代码的第二部分嵌套在第一部分的true
分支中,在垂直(即y轴)方向上进行条件测试。如果第一个条件满足(也就是在x轴上存在相交),代码就会进入内层的if
语句。在这里,它检查objectA
和objectB
是否也在垂直(y轴)方向上相交,它将objectA
的顶部(objectA.getPosition().top
)与objectB
的底部(objectB.getPosition().bottom
)进行比较,并且检查objectA
的底部是否在objectB
的顶部下方。如果这两个条件都成立,就意味着在y轴上存在相交。
最后,如果x轴和y轴的条件都成立,最内层代码块中的代码就会被执行。这个代码块表明检测到objectA
和objectB
之间发生了碰撞。这是游戏开发中常用的一种技术,用于检查两个对象(比如游戏角色或物品)在水平和垂直方向上是否重叠或发生碰撞。
这种技术被称为轴对齐包围盒(axis-aligned bounding box,AABB)碰撞检测。由于其计算效率高(即速度快),这种技术在二维图形和游戏开发中被广泛应用。它无法为形状不规则的物体或圆形提供精确的碰撞信息,但即便对于这些类型的物体,在进行更复杂的数学计算之前,AABB通常也会被用作快速的初步检查。
值得庆幸的是,我们不需要编写上述代码,不过,我们将使用SFML库中的intersects
函数,该函数适用于FloatRect
对象。回想一下Bat
类和Ball
类,它们都有一个getPosition
函数,这个函数会返回一个FloatRect
对象,代表该对象当前的位置。我们将看看如何结合使用getPosition
和intersects
函数来完成所有的碰撞检测。
在主函数的update
部分末尾添加以下突出显示的代码:
/*
Update the bat, the ball and the HUD
**************************************
**************************************
**************************************
*/
// Update the delta time
Time dt = clock.restart(); bat.update(dt);
ball.update(dt);
// Update the HUD text
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
// Handle ball hitting the bottom
if (ball.getPosition().top > window.getSize().y) {
// reverse the ball direction
ball. reboundBottom ();
// Remove a life
lives--;
// Check for zero lives
if (lives < 1 ) {
// reset the score
score = 0;
// reset the lives
lives = 3;
}
}
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
在上述代码中,第一个if
条件检查球是否撞到了屏幕底部:
if (ball.getPosition().top > window.getSize().y)
如果球的顶部位置大于窗口的高度,那就意味着球已经移出了玩家的视野底部。作为回应,会调用ball.reboundBottom
函数。记住,在这个函数中,球会被重新定位到屏幕顶部。此时,玩家失去一条生命,所以lives
变量会减1。
第二个if
条件检查玩家是否用完了所有生命(lives < 1
)。如果是这种情况,分数会重置为0,生命数量会重置为3,游戏重新开始。在下一个项目中,我们将学习如何记录并显示玩家的最高得分。
在前面的代码下方添加以下代码:
// Handle ball hitting top
if (ball.getPosition().top < 0) {
ball.reboundBatOrTop();
// Add a point to the players score
score++;
}
2
3
4
5
6
在上述代码中,我们检测到球的顶部撞到了屏幕顶部。发生这种情况时,玩家会获得一分,并且会调用ball.reboundBatOrTop
函数,该函数会反转球的垂直运动方向,使球朝屏幕底部返回。
在前面的代码下方添加以下代码:
// Handle ball hitting sides
if (ball.getPosition().left < 0 ||
ball.getPosition().left + ball.getPosition().width> window. getSize().x)
{
ball.reboundSides();
}
2
3
4
5
6
在上述代码中,if
条件检测到球的左侧与屏幕左侧发生碰撞,或者球的右侧(left + 10
)与屏幕右侧发生碰撞。无论哪种情况发生,都会调用ball.reboundSides
函数,并且球的水平运动方向会反转。
添加以下代码:
// Has the ball hit the bat?
if (ball.getPosition().intersects(bat.getPosition())) {
// Hit detected so reverse the ball and score a point
ball.reboundBatOrTop();
}
2
3
4
5
在上述代码中,intersects
函数用于判断球是否击中了球棒。发生这种情况时,我们会使用与球撞到屏幕顶部时相同的函数来反转球的垂直运动方向。
# 运行游戏
现在你可以运行游戏,让球在屏幕上四处反弹。当你用球棒击球时,分数会增加;当你没击中球时,生命数量会减少。当生命数量降为0时,分数会重置,lives
变量会重新恢复到3,如下所示:
图7.1:运行游戏
# 学习C++中的太空船运算符
由于这一章节内容较短,我觉得这是学习更多C++知识的好地方。在当前项目中,我们并不需要这些理论知识。没错,太空船运算符(spaceship operator)是真实存在的,它是C++中另一个巧妙的运算符。
太空船运算符用<=>
表示,是C++20版本中新增的内容。它用于对两个对象进行三路比较,也就是说,它能帮助判断一个对象是小于、等于还是大于另一个对象。太空船运算符会返回<
、==
或>
这三个值中的一个,表明两个对象之间的关系。下面介绍它的工作原理。
- 如果太空船运算符左侧的值小于右侧的值,它会返回一个负数,这表明左侧的值“小于”右侧的值。
- 如果左侧的值等于右侧的值,它会返回0,这表明两个对象相等。
- 如果左侧的值大于右侧的值,它会返回一个正数,这表明左侧的值“大于”右侧的值。下面举个例子帮助理解。
int a = 5;
int b = 10;
// Next we use the spaceship operator
int result = a <=> b;
if (result < 0)
{
// a is less than b
}
else if (result == 0) {
// a is equal to b
}
else if (result > 0) {
// a is greater than b
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在上述代码中,我们声明了两个整数a
和b
,然后使用太空船运算符<=>
对它们进行比较。比较结果存储在int
类型的变量result
中。记住,返回值可能是负数、零或正数,分别表示a
小于、等于或大于b
。
然后,我们检查result
的值,以此确定a
和b
之间的关系,并根据结果做出不同的响应。
实际上,这个简化的代码示例隐藏了一些额外的知识。使用太空船运算符的结果实际上会返回一种新的C++类型,叫做strong_ordering
。幸运的是,strong_ordering
类型可以转换为int
类型。strong_ordering
类型表示三路比较的结果。
# 总结
恭喜!你完成了第二个游戏!我们本可以为这个游戏添加更多功能,比如合作玩法、高分记录和音效,但我只是想用尽可能简单的示例来介绍类和AABB碰撞检测。现在,这些内容已经成为我们游戏开发者的“武器库”中的一部分,我们可以着手更令人兴奋的项目,学习更多游戏开发知识。
在下一章节中,我们将规划《僵尸竞技场》(Zombie Arena)游戏,学习SFML库中的View
类(它在游戏世界中充当虚拟摄像头),并编写更多的类。
# 常见问题解答
问:这个游戏是不是有点太安静了?
答:我没有给这个游戏添加音效,是因为我想在使用第一个类并学习利用时间让所有游戏对象实现流畅动画的同时,尽可能简化代码。如果你想添加音效,只需要将.wav
文件添加到项目中,使用SFML库加载声音,并在每个碰撞事件中播放音效即可。我们会在下一个项目中加入声音。
问:这个游戏太简单了!我要怎样才能让球的速度加快一点呢?
答:有很多方法可以增加游戏难度。一种简单的方法是在Ball
类的reboundBatOrTop
函数中添加一行代码来提高速度。例如,下面这段代码会在每次调用该函数时将球的速度提高10%:
// Speed up a little bit on each hit
m_Speed = m_Speed * 1.1f;
2
这样球的速度会很快变快。之后,你需要想办法在玩家失去所有生命时将速度重置回300.0f。你可以在Ball
类中创建一个新函数,比如叫resetSpeed
,然后在main
函数中检测到玩家失去最后一条生命时调用这个函数。
问:说出AABB碰撞检测的一个优点和一个缺点。
答:AABB碰撞检测计算效率高,适用于像游戏这种需要频繁进行碰撞检测的高要求应用场景。它也很容易理解,不过这是两个优点了。虽然AABB碰撞检测效率高,但它无法为形状不规则的物体提供精确的碰撞信息。