到目前为止,我们已经实现了游戏的主要视觉元素。 我们有一个可控制的角色在充满僵尸追逐的竞技场中奔跑。 问题是它们之间没有相互作用。 僵尸可以毫无痕迹地穿过玩家的身体。 我们需要检测僵尸和玩家之间的冲突。
如果僵尸能够伤害并最终杀死玩家,那么我们就应该为玩家的枪支提供一些子弹。 然后我们需要确保子弹能击中并杀死僵尸。
与此同时,如果我们正在为子弹、僵尸和玩家编写碰撞检测代码,那么就应该添加一个关于生命值和弹药拾取器的类。
以下是我们将要做的事情,以及我们将在这一章中讨论的顺序:
- 发射子弹
- 添加十字线和隐藏鼠标指针
- 产卵皮卡
- 碰撞检测
让我们从Bullet
课开始。
我们将使用 SFMLRectangleShape
类直观地表示一个子弹。 我们将编码一个具有RectangleShape
成员的Bullet
类,以及其他成员数据和函数。 然后,我们将添加子弹到我们的游戏几个步骤,如下:
- 首先,我们将对
Bullet.h
文件进行编码。 这将揭示成员数据的所有细节和函数的原型。 - 接下来,我们将编码
Bullet.cpp
文件,当然,该文件将包含Bullet
类的所有函数的定义。 当我们逐步进行时,我将准确地解释Bullet
类型的对象如何工作和被控制。 - 最后,我们将在
main
函数中声明一个完整的数组。 我们还将实施射击控制方案,管理玩家的剩余弹药和重新装填。
让我们从第一步开始。
要创建新的头文件,右键单击Solution Explorer中的header Files,选择Add | new Item… 。 在Add New Item窗口中,高亮(通过左键点击)Header File (.h),然后在Name字段中,键入Bullet.h
。
在Bullet.h
文件中添加以下私有成员变量和Bullet
类声明。 然后我们可以浏览它们并解释它们的用途:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bullet
{
private:
// Where is the bullet?
Vector2f m_Position;
// What each bullet looks like
RectangleShape m_BulletShape;
// Is this bullet currently whizzing through the air
bool m_InFlight = false;
// How fast does a bullet travel?
float m_BulletSpeed = 1000;
// What fraction of 1 pixel does the bullet travel,
// Horizontally and vertically each frame?
// These values will be derived from m_BulletSpeed
float m_BulletDistanceX;
float m_BulletDistanceY;
// Some boundaries so the bullet doesn't fly forever
float m_MaxX;
float m_MinX;
float m_MaxY;
float m_MinY;
// Public function prototypes go here
};
在之前的代码中,第一个成员是名为m_Position
的Vector2f
,它将保存子弹在游戏世界中的位置。
接下来,我们将RectangleShape
命名为m_BulletShape
,因为我们为每个子弹使用了一个简单的非纹理图像,有点像我们在《Timber!!》中设置的时间条。
然后代码声明Boolean
, m_InFlight
,这将跟踪子弹当前是否在空中呼啸而过。 这将允许我们决定是否需要在每一帧调用它的update
函数,以及是否需要运行碰撞检测检查。
变量float``m_BulletSpeed
将(你可能猜到)保持子弹将以像素每秒的速度飞行。 它被初始化为1000
的值,这有点随意,但它工作得很好。
接下来,我们有另外两个float
变量,m_BulletDistanceX
和m_BulletDistanceY
。 因为移动子弹的计算比移动僵尸或玩家的计算要复杂一些,所以我们可以利用这两个变量进行计算。 它们将被用来决定水平和垂直的变化,在每一帧的子弹的位置。
最后,我们还有 4 个float
变量(m_MaxX
、m_MinX
、m_MaxY
和m_MinY
),这些变量稍后将被初始化以保存子弹的最大和最小位置以及水平和垂直位置。
其中一些变量的需求可能不会立即显现,但当我们在Bullet.cpp
文件中看到它们的实际作用时,就会变得更加清楚。
现在,将所有的公共函数原型添加到Bullet.h
文件中:
// Public function prototypes go here
public:
// The constructor
Bullet();
// Stop the bullet
void stop();
// Returns the value of m_InFlight
bool isInFlight();
// Launch a new bullet
void shoot(float startX, float startY,
float xTarget, float yTarget);
// Tell the calling code where the bullet is in the world
FloatRect getPosition();
// Return the actual shape (for drawing)
RectangleShape getShape();
// Update the bullet each frame
void update(float elapsedTime);
};
让我们依次运行每个函数,然后我们可以继续编写它们的定义。
首先,我们有Bullet
函数,它当然是构造函数。 在这个函数中,我们将设置每个Bullet
实例,以便进行操作。
stop
函数将在子弹已经开始运行但需要停止时被调用。
函数返回一个布尔值,用于测试子弹当前是否正在飞行。
shoot
函数的用途从它的名字就可以看出,但是它的工作原理值得我们进行一些讨论。 现在,只需注意它将传入四个float
参数。 这 4 个值代表子弹的起点(即玩家所在位置)水平和垂直位置,以及目标的垂直和水平位置(即十字准星所在位置)。
函数返回一个代表子弹位置的FloatRect
。 这个函数将用于检测与僵尸的碰撞。 你可能还记得第 10 章、指针、标准模板库和纹理管理,僵尸也有getPosition
函数。
接下来是getShape
函数,它返回一个RectangleShape
类型的对象。 正如我们所讨论的,每颗子弹都由一个RectangleShape
物体直观地表示。 因此,getShape
函数将用于获取RectangleShape
当前状态的一个副本,以便绘制它。
最后,正如预期的那样,有一个update
函数,它有一个float
参数,该参数表示自上次update
调用以来已经过的时间的几分之一秒。 update
方法将改变每帧子弹的位置。
让我们看看并编写函数定义。
现在,我们可以创建一个包含函数定义的新.cpp
文件。 右键单击Solution Explorer中的Source Files,选择Add | New Item… 。 在Add New Item窗口中,高亮(通过左键点击)c++ File (.cpp),然后在Name字段中,键入Bullet.cpp
。 最后,点击添加按钮。 现在我们已经准备好编写类了。
添加以下代码,用于 include 指令和构造函数。 我们知道它是一个构造函数,因为函数与类具有相同的名称:
#include "bullet.h"
// The constructor
Bullet::Bullet()
{
m_BulletShape.setSize(sf::Vector2f(2, 2));
}
Bullet
构造函数唯一需要做的事情就是设置m_BulletShape
的大小,也就是RectangleShape
对象。 代码将大小设置为两个像素乘两个像素。
接下来,我们将编码更重要的shoot
函数。 将以下代码添加到Bullet.cpp
文件中并研究它,然后我们就可以讨论它了:
void Bullet::shoot(float startX, float startY,
float targetX, float targetY)
{
// Keep track of the bullet
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
// Calculate the gradient of the flight path
float gradient = (startX - targetX) / (startY - targetY);
// Any gradient less than 1 needs to be negative
if (gradient < 0)
{
gradient *= -1;
}
// Calculate the ratio between x and y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Set the "speed" horizontally and vertically
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
// Point the bullet in the right direction
if (targetX < startX)
{
m_BulletDistanceX *= -1;
}
if (targetY < startY)
{
m_BulletDistanceY *= -1;
}
// Set a max range of 1000 pixels
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
// Position the bullet ready to be drawn
m_BulletShape.setPosition(m_Position);
}
为了揭开shoot
函数的神秘面纱,我们将把它分解开来,并分块讨论刚刚添加的代码。
首先,让我们提醒自己签名。 shoot
函数接收子弹的起始位置和目标水平和垂直位置。 调用代码将基于玩家精灵的位置和十字准星的位置提供这些内容。 再来一遍:
void Bullet::shoot(float startX, float startY,
float targetX, float targetY)
在shoot
函数中,我们将m_InFlight
设置为true
,并使用startX
和startY
参数定位子弹。 下面是这段代码:
// Keep track of the bullet
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
现在,我们用一点三角函数来确定子弹的飞行梯度。 子弹在水平方向和垂直方向上的前进,必须根据子弹起始点和目标点之间的线的斜率而变化。 变化的速度不能相同,或者非常陡峭的镜头会在垂直位置之前到达水平位置,反之亦然。
下面的代码根据直线方程推导出梯度。 然后,它检查梯度是否小于零,如果小于零,将其乘以-1。 这是因为传入的起始和目标坐标可以为正或负,而我们总是希望每一帧的进程量为正。 乘以-1 只是把负数变成正数,因为负数乘以负数得到正数。 实际的移动方向将在update
函数中通过增加或减去我们在该函数中得到的正数来处理。
接下来,我们通过将子弹的速度(m_BulletSpeed
)除以 1,再加上坡度来计算水平距离与垂直距离的比值。 这将允许我们根据子弹所朝的目标,在每一帧中改变子弹的水平和垂直位置。
最后,在这部分代码中,我们将值赋给m_BulletDistanceY
和m_BulletDistanceX
:
// Calculate the gradient of the flight path
float gradient = (startX - targetX) / (startY - targetY);
// Any gradient less than zero needs to be negative
if (gradient < 0)
{
gradient *= -1;
}
// Calculate the ratio between x and y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Set the "speed" horizontally and vertically
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
下面的代码简单得多。 我们只是简单地设置了子弹可以到达的最大水平和垂直位置。 我们不希望子弹永远持续下去。 在更新函数中,我们将看到子弹是否通过了它的最大或最小位置:
// Set a max range of 1000 pixels in any direction
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
下面的代码将代表子弹的精灵移动到它的起始位置。 我们使用Sprite
的setPosition
函数,就像我们以前经常做的那样:
// Position the bullet ready to be drawn
m_BulletShape.setPosition(m_Position);
接下来,我们有四个简单的函数。 添加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;
}
stop
函数简单地将m_InFlight
变量设置为false
。 函数返回相同变量当前的值。 因此,我们可以看到,shoot
设置子弹运行,stop
设置它停止,而isInFlight
告知我们当前的状态是什么。
函数返回一个FloatRect
。 我们将看到如何使用来自每个游戏对象的FloatRect
来检测碰撞。
最后,对于前面的代码,getShape
返回一个RectangleShape
,这样我们就可以在每一帧中绘制一次子弹。
在开始使用Bullet
对象之前,需要实现的最后一个函数是update
。 添加以下代码,研究它,然后我们可以讨论它:
void Bullet::update(float elapsedTime)
{
// Update the bullet position variables
m_Position.x += m_BulletDistanceX * elapsedTime;
m_Position.y += m_BulletDistanceY * elapsedTime;
// Move the bullet
m_BulletShape.setPosition(m_Position);
// Has the bullet gone out of range?
if (m_Position.x < m_MinX || m_Position.x > m_MaxX ||
m_Position.y < m_MinY || m_Position.y > m_MaxY)
{
m_InFlight = false;
}
}
在update
函数中,我们使用m_BulletDistanceX
和m_BulletDistanceY
乘以自上一帧以来的时间来移动子弹。 记住,这两个变量的值是在shoot
函数中计算出来的,它们代表了以正确的角度移动子弹所需的梯度(彼此之间的比率)。 然后,我们使用setPosition
函数实际移动RectangleShape
。
我们在update
中做的最后一件事是测试子弹是否已经超出了它的最大射程。 稍微有点复杂的if
语句根据shoot
函数中计算的最大值和最小值检查m_Position.x
和m_Position.y
。 这些最大值和最小值存储在m_MinX
、m_MaxX
、m_MinY
和m_MaxY
中。 如果测试为真,则将m_InFlight
设置为false
。
Bullet
课程结束。 现在,我们将看看如何在main
函数中拍摄一些。
我们将通过以下六个步骤使子弹可用:
- 为
Bullet
类添加必要的 include 指令。 - 添加一些控制变量和一个数组来保存一些
Bullet
实例。 - 让玩家按R重新加载。
- 手柄玩家按下鼠标左键发射子弹。
- 更新每一帧中所有正在飞行的子弹。
- 在每一帧中画出正在飞行的子弹。
添加 include 指令使 Bullet 类可用:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
using namespace sf;
让我们进行下一步。
这里有一些变量来记录弹夹大小、备用子弹、子弹、弹夹中剩余的子弹、当前的射击速度(开始时为每秒 1 颗)以及最后一颗子弹发射的时间。
添加以下突出显示的代码。 然后,我们可以继续,并在接下来的部分中看到所有这些变量的作用:
// Prepare for a horde of zombies
int numZombies;
int numZombiesAlive;
Zombie* zombies = NULL;
// 100 bullets should do
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// When was the fire button last pressed?
Time lastPressed;
// The main game loop
while (window.isOpen())
接下来,让我们来处理当玩家按下R键盘键(用于重新加载剪辑)时会发生什么。
现在,我们将处理与射击子弹相关的玩家输入。 首先,我们将处理按下R键来装弹枪。 我们将使用一个 SFML 事件来做到这一点。
添加以下突出显示的代码。 它通过大量的上下文来显示,以确保代码运行在正确的位置。 研究代码,然后我们可以讨论它:
// Handle events
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Pause a game while playing
if (event.key.code == Keyboard::Return &&
state == State::PLAYING)
{
state = State::PAUSED;
}
// Restart while paused
else if (event.key.code == Keyboard::Return &&
state == State::PAUSED)
{
state = State::PLAYING;
// Reset the clock so there isn't a frame jump
clock.restart();
}
// Start a new game while in GAME_OVER state
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
}
if (state == State::PLAYING)
{
// Reloading
if (event.key.code == Keyboard::R)
{
if (bulletsSpare >= clipSize)
{
// Plenty of bullets. Reload.
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
}
else if (bulletsSpare > 0)
{
// Only few bullets left
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
}
else
{
// More here soon?!
}
}
}
}
}// End event polling
前面的代码嵌套在游戏循环的事件处理部分(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
...
前面的结构允许我们处理三种可能的场景,如下所示:
- 玩家按下了
R
,他们的子弹比弹夹所能承受的多。 在这种情况下,弹夹被重新填充,备用子弹的数量减少。 - 玩家有一些备用子弹,但不足以完全填满弹夹。 在这个场景中,玩家可以用尽可能多的备用子弹填充剪辑,而备用子弹的数量被设置为零。
- 玩家已经按了R,但是他们没有多余的子弹。 对于这个场景,我们实际上不需要更改变量。 然而,我们将在这里扮演一个音效当我们实现声音第十三章,声音效果,文件 I / O,和完成游戏【显示】,所以我们将把空
else
块准备好了。
现在,让我们发射一颗子弹。
在这里,我们将处理点击鼠标左键发射子弹。 添加以下突出显示的代码,并仔细研究它:
if (Keyboard::isKeyPressed(Keyboard::D))
{
player.moveRight();
}
else
{
player.stopRight();
}
// Fire a bullet
if (Mouse::isButtonPressed(sf::Mouse::Left))
{
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0)
{
// Pass the centre of the player
// and the centre of the cross-hair
// to the shoot function
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++ ;
if (currentBullet > 99)
{
currentBullet = 0;
}
lastPressed = gameTimeTotal;
bulletsInClip--;
}
}// End fire a bullet
}// End WASD while playing
所有前面的代码都包装在一个if
语句中,该语句在按下鼠标左键(即if (Mouse::isButtonPressed(sf::Mouse::Left))
)时执行。 注意,代码将重复执行,即使玩家只是按住按钮。 我们现在要通过的代码控制着开火的速度。
在前面的代码中,然后检查是否在游戏中运行的总时间(gameTimeTotal
)减去时间玩家最后一球一颗子弹(lastPressed
)大于 1000,除以当前火灾和球员至少有一颗子弹夹。 我们用 1000 是因为这是一秒的毫秒数。
如果测试成功,将执行实际触发子弹的代码。 射击子弹很容易,因为我们在Bullet
课上做了所有艰苦的工作。 我们只需从bullets
数组中调用shoot
来处理当前子弹。 我们传递玩家和十字线当前的水平和垂直位置。 子弹将在飞行中通过Bullet
类的shoot
函数中的代码进行配置和设置。
我们要做的就是跟踪子弹的排列。 我们增加了变量currentBullet
。 然后,我们需要检查是否使用if (currentBullet > 99)
语句发射了最后一颗子弹(99)。 如果这是最后一颗子弹,我们将currentBullet
设为零。 如果这不是最后一颗子弹,那么只要射击速度允许,玩家按下鼠标左键,下一颗子弹就准备好了。
最后,在前面的代码中,我们存储子弹射入lastPressed
的时间并减少bulletsInClip
。
现在,我们可以更新每一颗子弹,每一帧。
添加以下高亮显示的代码来循环子弹数组,检查子弹是否在飞行,如果是,调用它的 update 函数:
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Update any bullets that are in-flight
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
}
}// End updating the scene
最后,我们将画出所有的子弹。
添加以下高亮显示的代码来循环遍历bullets
数组,检查子弹是否在飞行,如果是,绘制它:
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the background
window.draw(background, &textureBackground);
// Draw the zombies
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());
}
}
// Draw the player
window.draw(player.getSprite());
}
运行游戏来尝试子弹。 请注意,在按R重新装填之前,您可以发射六发子弹。 很明显,缺少的是弹夹中子弹数量和备用子弹数量的可视指示器。 另一个问题是,玩家可能会很快用光子弹,特别是因为子弹没有任何阻止力。 它们直接飞过僵尸。 此外,我们希望玩家瞄准的是鼠标指针而不是精确的十字准星,显然我们还有很多工作要做。
在下一章中,我们将通过 HUD 提供视觉反馈。 接下来我们将用十字准星替换鼠标光标,然后生成一些拾音器来补充子弹和生命值。 最后,在本章中,我们将处理碰撞检测,使子弹和僵尸造成损害,并使玩家能够真正得到拾取。
添加一个十字准星很简单,只需要一个新概念。 添加以下高亮显示的代码,然后我们可以运行它:
// 100 bullets should do
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// When was the fire button last pressed?
Time lastPressed;
// Hide the mouse pointer and replace it with crosshair
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture("graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// The main game loop
while (window.isOpen())
首先,我们在window
对象上调用setMouseCursorVisible
函数。 然后,我们加载一个Texture
,声明一个Sprite
实例,并按照通常的方式初始化它。 此外,我们将精灵的原点设置在它的中心,以便更方便和更简单地让子弹飞向中间,就像你所期望的那样。
现在,我们需要用鼠标的世界坐标更新每一帧的十字准星。 添加以下高亮显示的代码行,它使用mouseWorldPosition
向量来设置每一帧的准星位置:
/*
****************
UPDATE THE FRAME
****************
*/
if (state == State::PLAYING)
{
// Update the delta time
Time dt = clock.restart();
// Update the total game time
gameTimeTotal += dt;
// Make a decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world coordinates of mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Set the crosshair to the mouse world location
spriteCrosshair.setPosition(mouseWorldPosition);
// Update the player
player.update(dtAsSeconds, Mouse::getPosition());
接下来,正如你可能已经预料到的,我们可以在每一帧中绘制十字准星。 在显示的位置中添加以下高亮显示的代码行。 这一行代码无需解释,但它在所有其他游戏对象之后的位置很重要,所以它被绘制在最上面:
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the background
window.draw(background, &textureBackground);
// Draw the zombies
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());
}
}
// Draw the player
window.draw(player.getSprite());
//Draw the crosshair
window.draw(spriteCrosshair);
}
现在,你可以运行游戏,并将看到一个很酷的十字线,而不是鼠标光标:
注意子弹是如何穿过十字准星中心的。 射击机制的运作方式类似于允许玩家选择从臀部射击或向下瞄准。 如果玩家将十字准星保持在中心附近,他们便可以快速开火并转向,但同时也必须小心地判断远处僵尸的位置。
或者,玩家可以将十字准星直接悬停在远处的僵尸头上,并获得准确的命中; 然而,如果僵尸从另一个方向发动攻击,他们就有更多的时间将十字准星向后移动。
游戏的一个有趣改进便是在每次射击中添加少量的随机误差。 这种不准确性也许可以通过两次浪潮之间的升级来缓解。
在本节中,我们将编码一个具有Sprite
成员的Pickup
类,以及其他成员数据和函数。 我们将在我们的游戏中添加拾取只需要几个步骤:
- 首先,我们将对
Pickup.h
文件进行编码。 这将揭示成员数据的所有细节和函数的原型。 - 然后,我们将编写
Pickup.cpp
文件,当然,该文件将包含Pickup
类的所有函数的定义。 当我们逐步进行时,我将准确地解释Pickup
类型的对象如何工作和被控制。 - 最后,我们将在
main
函数中使用Pickup
类来生成、更新和绘制它们。
让我们从第一步开始。
要创建新的头文件,右键单击Solution Explorer中的header Files,选择Add | new Item… 。 在Add New Item窗口中,高亮(通过左键点击)Header File (.h),然后在Name字段中,键入Pickup.h
。
在Pickup.h
文件中添加并研究以下代码,然后我们可以遍历它:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Pickup
{
private:
//Start value for health pickups
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;
// The sprite that represents this pickup
Sprite m_Sprite;
// The arena it exists in
IntRect m_Arena;
// How much is this pickup worth?
int m_Value;
// What type of pickup is this?
// 1 = health, 2 = ammo
int m_Type;
// Handle spawning and disappearing
bool m_Spawned;
float m_SecondsSinceSpawn;
float m_SecondsSinceDeSpawn;
float m_SecondsToLive;
float m_SecondsToWait;
// Public prototypes go here
};
前面的代码声明了Pickup
类的所有私有变量。 虽然这些名称应该是非常直观的,但为什么需要这么多名称可能并不明显。 让我们从头到尾看一遍:
-
const int HEALTH_START_VALUE = 50
:此常量变量用于设置所有生命值拾取器的起始值。 该值将用于初始化变量m_Value
,该变量将在整个游戏过程中进行操作。 -
const int AMMO_START_VALUE = 12
:这个常量变量用来设置所有拾取弹药的起始值。 该值将用于初始化变量m_Value
,该变量将在整个游戏过程中进行操作。 -
const int START_WAIT_TIME = 10
:这个变量决定拾音器在消失后等待多长时间才能重生。 它将用于初始化变量m_SecondsToWait
,该变量可以在整个游戏中进行操作。 -
const int START_SECONDS_TO_LIVE = 5
:这个变量决定了拾音器在产卵和被取消产卵之间的持续时间。 与前三个常量一样,它也有一个可以在整个游戏过程中被操纵的非常量。 它用来初始化的非常量是m_SecondsToLive
。 -
Sprite m_Sprite
:这是视觉上代表物体的精灵。 -
IntRect m_Arena
:这将保持当前竞技场的大小,以帮助拾音器在一个合理的位置产卵。 -
这辆皮卡的生命值和弹药值多少? 当玩家升级生命值或拾取弹药值时,就会使用这个值。
-
int m_Type
:生命值和弹药值分别是 1 或 2。 我们本可以使用枚举类,但对于只有两个选项来说,这似乎有点多余。 -
bool m_Spawned
:皮卡现在已经生成了吗? -
float m_SecondsSinceSpawn
:从皮卡产生到现在有多久了? -
float m_SecondsSinceDeSpawn
:皮卡消失多久了? -
float m_SecondsToLive
:这个拾音器应该停留多长时间产卵之前反产卵? -
float m_SecondsToWait
: How long should this pickup stay de-spawned before respawning?提示
请注意,这个类的大部分复杂性是由于变量生成时间和它的可升级性质。 如果拾音器只是在收集时重新生成并具有固定值,这将是一个非常简单的类。 我们需要让道具能够升级,这样玩家就会被迫制定策略,在波涛中前进。
接下来,将以下公共函数原型添加到Pickup.h
文件中。 请务必熟悉新代码,以便我们能够浏览它:
// Public prototypes go here
public:
Pickup::Pickup(int type);
// Prepare a new pickup
void setArena(IntRect arena);
void spawn();
// Check the position of a pickup
FloatRect getPosition();
// Get the sprite for drawing
Sprite getSprite();
// Let the pickup update itself each frame
void update(float elapsedTime);
// Is this pickup currently spawned?
bool isSpawned();
// Get the goodness from the pickup
int gotIt();
// Upgrade the value of each pickup
void upgrade();
};
让我们简要地讨论一下每个函数的定义。
- 第一个函数是构造函数,以类命名。 注意,它只接受一个
int
参数。 这将用于初始化拾取的类型(生命值或弹药)。 setArena
函数接收到IntRect
。 这个函数将在每个 wave 开始时对每个Pickup
实例调用。 然后,Pickup
对象将“知道”它们可以产卵的区域。- 当然,
spawn
函数将处理生成拾取。 - 与
Player
、Zombie
和Bullet
类一样,getPosition
函数将返回一个FloatRect
实例,该实例表示对象在游戏世界中的当前位置。 getSprite
函数返回一个Sprite
对象,该对象允许拾取每帧绘制一次。- 函数接收上一帧所花费的时间。 它使用这个值来更新它的私有变量,并决定何时生成和反生成。
- 函数返回一个布尔值,让调用代码知道当前拾取是否已生成。
- 当玩家检测到冲突时,将调用
gotIt
函数。 然后,Pickup
类的代码可以为在适当的时间重生做好准备。 注意,它返回一个int
值,以便调用代码知道拾取物在生命值或弹药上的“价值”。 - 当玩家在游戏升级阶段选择升级拾音器的属性时,
upgrade
函数将被调用。
现在我们已经了解了成员变量和函数原型,在编写函数定义时应该很容易理解。
现在,我们可以创建一个包含函数定义的新.cpp
文件。 右键单击Solution Explorer中的Source Files,选择Add | New Item… 。 在Add New Item窗口中,高亮(通过左键点击)c++ File (.cpp),然后在Name字段中,键入Pickup.cpp
。 最后,点击添加按钮。 现在我们已经准备好编写类了。
将以下代码添加到Pickup.cpp
文件中。 一定要检查代码,以便我们可以讨论它:
#include "Pickup.h"
#include "TextureHolder.h"
Pickup::Pickup(int type)
{
// Store the type of this pickup
m_Type = type;
// Associate the texture with the sprite
if (m_Type == 1)
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/health_pickup.png"));
// How much is pickup worth
m_Value = HEALTH_START_VALUE;
}
else
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/ammo_pickup.png"));
// How much is pickup worth
m_Value = AMMO_START_VALUE;
}
m_Sprite.setOrigin(25, 25);
m_SecondsToLive = START_SECONDS_TO_LIVE;
m_SecondsToWait = START_WAIT_TIME;
}
在前面的代码中,我们添加了熟悉的 include 指令。 然后,我们添加了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)
{
// Copy the details of the arena to the pickup's 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();
}
我们刚刚编码的setArena
函数只是简单地从传入的arena
对象中复制值,但在左侧和顶部改变+ 50
值,在右侧和底部改变- 50
值。 对象现在知道它可以生成的区域。 然后,setArena
函数调用自己的spawn
函数为绘制和更新每一帧做最后的准备。
接下来是spawn
函数。 在setArena
函数后添加以下代码:
void Pickup::spawn()
{
// Spawn at a random location
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);
}
T0 函数完成所有准备拾取所需的工作。 首先,它为随机数生成器播种,并获得对象的水平和垂直位置的随机数。 注意,它使用了m_Arena.width
和m_Arena.height
变量作为可能的水平和垂直位置的范围。
变量m_SecondsSinceSpawn
被设置为零,以便在取消衍生之前允许的时间长度被重置。 变量m_Spawned
被设置为true
,这样当我们从main
调用isSpawned
时,我们将得到一个正响应。 最后,m_Sprite
用setPosition
移动到相应位置,准备被绘制到屏幕上。
在下面的代码块中,我们有三个简单的 getter 函数。 getPosition
函数返回当前位置的FloatRect``m_Sprite
、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;
}
接下来,我们将对gotIt
函数进行编码。 当玩家接触/碰撞拾取物品时,这个函数将从main
开始调用。 在isSpawned
功能后增加gotIt
功能:
int Pickup::gotIt()
{
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
return m_Value;
}
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;
}
// Do we need to hide a pickup?
if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
{
// Remove the pickup and put it somewhere else
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
}
// Do we need to spawn a pickup
if (m_SecondsSinceDeSpawn > m_SecondsToWait && !m_Spawned)
{
// spawn the pickup and reset the timer
spawn();
}
}
update
函数被划分为四个块,在每一帧中执行:
- 如果
m_Spawned
为 true,则执行if
块:if (m_Spawned)
。 此代码块将此帧的时间添加到m_SecondsSinceSpawned
,以跟踪生成拾取的时间。 - 一个对应的
else
块,如果m_Spawned
为 false 则执行该块。 此块将此帧所花费的时间添加到m_SecondsSinceDeSpawn
,以跟踪自上次去衍生(隐藏)以来拾取程序已经等待了多长时间。 - 另一个
if
块,在生成拾取的时间超过其正常时间时执行:if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
。 该块将m_Spawned
设置为false
并将m_SecondsSinceDeSpawn
重置为零。 现在,block 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);
}
// Make them more frequent and last longer
m_SecondsToLive += (START_SECONDS_TO_LIVE / 10);
m_SecondsToWait -= (START_WAIT_TIME / 10);
}
upgrade
测试拾音器的类型(生命值或弹药),然后在m_Value
上添加 50%(适当的)起始值。 if``else
方块后的下两行将增加拾取器的生成时间,并减少玩家在两次刷出之间等待的时间。
当玩家选择在LEVELING_UP
状态升级拾音器时,此函数将被调用。
我们的Pickup
班可以使用了。
在完成了所有实现Pickup
类的艰苦工作之后,我们现在可以继续在游戏引擎中编写代码,将一些拾取道具放入游戏中。
我们要做的第一件事是在ZombieArena.cpp
文件中添加一个 include 指令:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
在下面的代码中,我们将添加两个Pickup
实例:一个名为healthPickup
,另一个名为ammoPickup
。 我们将值 1 和 2 分别传递给构造函数,以便将它们初始化为正确的拾取类型。 添加以下高亮显示的代码,我们刚刚讨论过:
// Hide the mouse pointer and replace it with crosshair
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture(
"graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// Create a couple of pickups
Pickup healthPickup(1);
Pickup ammoPickup(2);
// The main game loop
while (window.isOpen())
在键盘处理的LEVELING_UP
状态下,在嵌套的PLAYING
代码块中添加以下高亮显示的行:
if (state == State::PLAYING)
{
// Prepare the level
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Configure the pick-ups
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// Create a horde of zombies
numZombies = 10;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Reset the clock so there isn't a frame jump
clock.restart();
}
前面的代码只是将arena
传递给每个拾取的setArena
函数。 拾音器现在知道他们可以产卵的地方。 这段代码对每个新 wave 执行,因此,随着舞台的大小增加,Pickup
对象将得到更新。
下面的代码简单地为每一帧上的每个Pickup
对象调用update
函数:
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Update any bullets that are in-flight
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
}
// Update the pickups
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
}// End updating the scene
下面的代码在游戏循环的绘制部分检查拾取当前是否生成,如果是,绘制它。 让我们将它添加:
// Draw the player
window.draw(player.getSprite());
// Draw the pick-ups, if currently spawned
if (ammoPickup.isSpawned())
{
window.draw(ammoPickup.getSprite());
}
if (healthPickup.isSpawned())
{
window.draw(healthPickup.getSprite());
}
//Draw the crosshair
window.draw(spriteCrosshair);
}
现在,你可以运行游戏,并看到拾音器产卵和反产卵。 然而,你还不能把它们捡起来:
现在我们在游戏中拥有了所有的物体,是时候让它们相互作用(碰撞)了。
我们只需要知道游戏中的特定物体何时接触到其他物体。 然后,我们可以以适当的方式响应该事件。 在我们的类中,我们已经添加了在对象碰撞时将被调用的函数。 它们如下:
Player
类具有hit
功能。 当僵尸与玩家发生碰撞时,我们就会调用它。Zombie
类具有hit
功能。 当子弹撞到僵尸的时候我们就叫它。Pickup
类具有gotIt
功能。 当玩家与拾音器发生碰撞时,我们便会调用它。
如果有必要,回顾一下这些函数是如何工作的。 我们现在需要做的就是检测冲突并调用相应的函数。
我们将使用矩形交点来检测碰撞。 这种类型的碰撞检测非常简单(特别是使用 SFML 时)。 我们将使用与《Pong》游戏相同的技术。 下图展示了矩形如何合理准确地代表僵尸和玩家:
我们将在三段代码中处理这个问题,这三段代码将依次进行。 它们都将在游戏引擎的更新部分结束时消失。
对于每一帧,我们需要知道以下三个问题的答案:
- 僵尸中枪了吗?
- 玩家是否被僵尸碰过?
- 玩家是否碰过拾音器?
首先,让我们为score
和hiscore
添加更多变量。 当僵尸被杀死时,我们可以改变它们。 添加以下代码:
// Create a couple of pickups
Pickup healthPickup(1);
Pickup ammoPickup(2);
// About the game
int score = 0;
int hiScore = 0;
// The main game loop
while (window.isOpen())
现在,让我们从检测僵尸是否与子弹相撞开始。
下面的代码可能看起来很复杂,但是当我们逐步执行它时,我们会发现它并不是我们以前没有见过的。 在调用之后添加以下代码来更新每一帧的拾取。 然后,我们可以看一下:
// Update the pickups
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
// Collision detection
// Have any zombies been shot?
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()))
{
// Stop the bullet
bullets[i].stop();
// Register the hit and see if it was a kill
if (zombies[j].hit())
{
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
}
}
}
}
}// End zombie being shot
在下一节中,我们将再次看到所有僵尸和子弹碰撞检测代码。 我们将一次做一点,以便我们可以讨论它。 首先,注意前面代码中嵌套的for
循环的结构(去掉了一些代码),如下所示:
// Collision detection
// Have any zombies been shot?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
...
...
...
}
}
代码循环遍历每个僵尸(0 到< T0)的每个子弹(0 到 99)。
在嵌套的for
循环中,我们执行以下操作。
我们使用以下代码检查当前子弹是否在飞行,当前僵尸是否仍然活着:
if (bullets[i].isInFlight() && zombies[j].isAlive())
假设僵尸是活着的,子弹在飞行,我们测试矩形交叉与以下代码:
if (bullets[i].getPosition().intersects(zombies[j].getPosition()))
如果当前的子弹和僵尸相撞了,那么我们将采取一些步骤,如下所述。
用下面的代码停止子弹:
// Stop the bullet
bullets[i].stop();
通过调用当前僵尸的hit
函数注册一个命中。 请注意,hit
函数返回一个布尔值,让调用代码知道僵尸是否已经死亡。 这在下面的代码行中显示:
// Register the hit and see if it was a kill
if (zombies[j].hit()) {
在这个if
块中,它可以检测到僵尸是死的,并且不只是伤害我们,执行以下操作:
- 在
score
上加 10。 - 如果玩家取得的分数超过(击败)
score
,则更改hiScore
。 - 将
numZombiesAlive
减少 1。 - 检查
(numZombiesAlive == 0)
是否所有僵尸都死了,如果是,将state
改为LEVELING_UP
。
下面是我们刚刚讨论过的if(zombies[j].hit())
内部的代码块:
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0)
{
state = State::LEVELING_UP;
}
僵尸和子弹都解决了。 你现在可以运行游戏并看到血。 当然,除非我们在下一章中执行 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
在这里,我们通过使用for
循环来检测僵尸是否与玩家发生碰撞。 对于每个活着的僵尸,代码使用intersects
函数来测试是否与玩家发生碰撞。 当发生碰撞时,我们叫player.hit
。 然后,我们通过调用player.getHealth
来检查玩家是否已经死亡。 如果玩家的命值等于或小于 0,则将state
改为GAME_OVER
。
你可以运行游戏,碰撞将被检测到。 然而,因为还没有 HUD 或音效,所以还不清楚是否会发生这种情况。 此外,我们还需要在玩家死亡后重新设置游戏,并开始新游戏。 所以,虽然游戏运行了,但现在的结果并不特别令人满意。 我们将在接下来的两章中对此进行改进。
这里显示的是玩家与两个拾音器之间的碰撞检测代码。 在我们之前添加的代码之后添加以下高亮显示的代码:
}// 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
前面的代码使用两个简单的if
语句来查看healthPickup
或ammoPickup
是否被玩家触及。
如果已经收集了生命值,那么player.increaseHealthLevel
函数将使用healthPickup.gotIt
函数返回的值来增加玩家的生命值。
如果拾取了弹药,那么bulletsSpare
将增加ammoPickup.gotIt
返回的值。
重要提示
你现在可以运行游戏,杀死僵尸,收集皮卡! 注意,当你的生命值等于 0 时,游戏将进入GAME_OVER
状态并暂停。 要重新启动它,您需要按Enter
,然后按 1 到 6 之间的数字。 当我们执行 HUD、主屏幕和升级屏幕时,这些步骤对玩家来说将是直观和直接的。 我们将在下一章中这样做。
这是一个忙碌的章节,但我们取得了很多成就。 我们不仅通过两个新职业在游戏中添加了子弹和拾取物,而且我们还通过检测物体之间的碰撞来让所有物体进行互动。
除了这些成就,我们还需要做更多工作来设置每款新游戏,并通过 HUD 向玩家提供反馈。 在下一章中,我们将构建 HUD。
以下是你可能会想到的一些问题:
Q:有没有更好的碰撞检测方法?
)是的。 有很多方法来做碰撞检测,包括但不限于以下。
- 你可以将对象划分成多个矩形,以更好地适应精灵的形状。 对于 c++ 来说,每一帧检查数千个矩形是完全可以管理的。 当您使用诸如邻居检查等技术来减少每帧必需的测试数量时,情况尤其如此。
- 对于圆形物体,可以使用半径重叠方法。
- 对于不规则多边形,可以使用传递数算法。
如果你愿意,你可以看看以下链接来回顾所有这些技巧: