第20章 火球与声音空间化
# 第20章 火球与声音空间化
在本章中,我们将添加所有音效和平视显示器(HUD,Head-Up Display)。在之前的两个项目中我们做过这些,但这次的做法会有些不同。我们将探究声音空间化的概念,以及SFML(Simple and Fast Multimedia Library)如何让这个复杂的概念变得简单易懂。此外,我们还将构建一个HUD类,用于封装在屏幕上绘制信息的代码。
本章将涵盖以下主题:
- 什么是空间化?
- 使用SFML处理声音空间化
- 升级“声音引擎”(SoundEngine)类
- 编写与火球相关的类(派生自“图形”(Graphics)类和“更新”(Update)类),使其能发出空间化音效
- 在“工厂”类中创建一些火球
- 运行代码
本章的完整代码可在“Run6”文件夹中找到。
# 什么是空间化?
空间化是指使某事物与其所在的空间或所处空间产生关联的行为。在日常生活中,默认情况下,自然界中的一切事物都具有空间化特性。如果一辆摩托车从左向右疾驰而过,我们会听到声音由弱变强,从一侧传到另一侧。当它经过时,在另一只耳朵中声音会变得更清晰,之后又逐渐消失在远方。如果有一天早上醒来,发现世界不再具有空间化特性,那会非常奇怪。
如果我们能让电子游戏更贴近现实世界,玩家就会更有沉浸感。在我们的僵尸游戏中,如果玩家能听到远处僵尸微弱的声音,并且随着僵尸从某个方向靠近,声音变得越来越大,那么游戏会更有趣。
很明显,声音空间化的数学计算很复杂。我们如何根据从玩家(声音的接收者)到发声物体(声音的发出者)的距离和方向,来计算特定扬声器中声音的音量呢?
幸运的是,SFML为我们完成了所有复杂的计算过程。我们只需要熟悉几个技术术语,就可以开始使用SFML为音效添加空间化效果了。
# 发射器、衰减和收听者
为了让SFML能够正常工作,我们需要了解一些信息。我们需要知道游戏世界中声音的来源,这个声音来源被称为声音源(emitter)。在游戏中,声音源可以是僵尸、车辆,就我们当前的项目而言,还可以是火焰块。我们已经在跟踪游戏中物体的位置,所以将声音源的位置提供给SFML是很简单的。
我们需要了解的下一个因素是衰减(attenuation)。衰减是指波的衰减速率。你可以简化这个概念,将其具体应用到声音上,即衰减是指声音音量降低的速度。从技术层面来说,这个描述并不准确,但对于本章内容和我们的游戏而言,已经足够了。
最后一个需要考虑的因素是收听者(listener)。当SFML对声音进行空间化处理时,它是以什么为参照进行处理的呢?游戏中的“耳朵”在哪里呢?在大多数游戏中,合理的做法是将玩家角色作为游戏的“耳朵”。
在实际编写代码之前,我们先来看一些假设性的代码示例。
# 使用SFML处理声音空间化
SFML有几个函数可以帮助我们处理声音源、衰减和收听者。我们先假设性地了解一下这些函数,然后再编写代码,为我们的项目添加真正的空间化音效。
我们可以像之前经常做的那样,设置一个准备播放的音效,代码如下:
// 以通常的方式声明SoundBuffer
SoundBuffer zombieBuffer;
// 像往常一样声明一个Sound对象
Sound zombieSound;
// 像我们经常做的那样从文件加载声音
zombieBuffer.loadFromFile("sound/zombie_growl.wav");
// 将Sound对象与缓冲区关联
zombieSound.setBuffer(zombieBuffer);
2
3
4
5
6
7
8
9
10
11
我们可以使用setPosition
函数来设置声音源的位置,代码如下:
// 设置声音源的水平和垂直位置
// 在这种情况下,声音源是一个僵尸
// 在《僵尸竞技场》项目中,我们可以使用getPosition().x和getPosition().y
// 这里的值是任意的
float x = 500;
float y = 500;
zombieSound.setPosition(x, y, 0.0f);
2
3
4
5
6
7
正如前面代码注释中所提到的,获取声音源坐标的具体方式可能取决于游戏类型。如上述代码所示,在《僵尸竞技场》项目中这很简单。但在当前项目中设置位置时,我们会面临一些挑战。
我们可以按如下方式设置衰减级别:
zombieSound.setAttenuation(15);
实际的衰减级别可能有点难以确定。我们希望玩家感受到的效果,可能与基于衰减随距离降低音量的精确科学公式有所不同。找到合适的衰减级别通常需要通过实验来实现。衰减级别越高,声音音量降低到静音的速度就越快。
此外,我们可能希望在声音源周围设置一个区域,在这个区域内声音音量不会衰减。如果某个功能在特定范围之外不适用,或者我们有很多声音源,不想过度使用这个功能,就可以这样做。为此,我们可以使用setMinimumDistance
函数,代码如下:
zombieSound.setMinDistance(150);
使用上面这行代码后,直到收听者距离声音源150像素/单位时,才会计算衰减。
SFML库中还有一些其他有用的函数,比如setLoop
函数。当传入参数true
时,这个函数会让SFML不断重复播放声音,代码如下:
zombieSound.setLoop(true);
声音会持续播放,直到我们使用以下代码停止播放:
zombieSound.stop();
有时,我们想知道声音的状态(正在播放还是已停止)。我们可以使用getStatus
函数来实现,代码如下:
if (zombieSound.getStatus() == Sound::Status::Stopped)
{
// 声音未在播放
// 在此处执行相应操作
}
if (zombieSound.getStatus() == Sound::Status::Playing)
{
// 声音正在播放
// 在此处执行相应操作
}
2
3
4
5
6
7
8
9
10
使用SFML进行声音空间化还有最后一个方面需要介绍,那就是收听者。收听者在哪里呢?我们可以使用以下代码设置收听者的位置:
// 收听者在哪里?
// 获取x和y值的方式因游戏而异
// 在《僵尸竞技场》游戏或《托马斯迟到了》游戏中
// 我们可以使用getPosition()
Listener::setPosition(m_Thomas.getPosition().x, m_Thomas.getPosition().y, 0.0f);
2
3
4
5
上述代码会使所有声音都相对于该位置播放。这正是我们在处理火焰块的远处轰鸣声或接近的僵尸声音时所需要的效果,但对于像跳跃这样的常规音效来说,这就成了问题。我们可以开始为玩家位置处理一个声音源,但SFML为我们简化了操作。每当我们想要播放一个“普通”音效时,只需调用setRelativeToListener
,代码如下,然后以与之前完全相同的方式播放声音。
下面是我们播放一个“普通”的、未进行空间化处理的跳跃音效的方式:
jumpSound.setRelativeToListener(true);
jumpSound.play();
2
在播放任何空间化音效之前,我们只需要再次调用Listener::setPosition
,这将为当前声音设置“耳朵”的位置。
现在我们已经了解了SFML的众多声音函数,可以真正开始添加空间化音效了。
# 升级SoundEngine类
让我们为“声音引擎”类添加一些新功能,并开始真正添加声音空间化特性。
首先要在“声音引擎”类中添加一些新的成员变量。在SoundEngine.h
文件的私有部分添加这两个成员变量:
static SoundBuffer mFireballLaunchBuffer;
static Sound mFireballLaunchSound;
2
现在我们有了一个新的SoundBuffer
,用于加载声音,还有一个新的Sound
实例,用于与SoundBuffer
实例关联并播放声音。这里没有什么新内容,只是要记住,为了使空间化效果正常工作,加载到mFireballLaunchBuffer
中的声音必须是单声道的。
接下来,在SoundEngine.h
文件的公共部分添加以下函数声明:
static void playFireballLaunch(
Vector2f playerPosition,
Vector2f soundLocation);
2
3
上述playFireballLaunch
函数声明接受一个Vector2f
类型的参数表示玩家位置,另一个Vector2f
类型的参数表示我们想要模拟声音发出的位置。
在SoundEngine.cpp
文件中,在SoundEngine
构造函数之前添加以下突出显示的声明:
SoundBuffer SoundEngine::m_ClickBuffer;
Sound SoundEngine::m_ClickSound;
SoundBuffer SoundEngine::m_JumpBuffer;
Sound SoundEngine::m_JumpSound;
SoundBuffer SoundEngine::mFireballLaunchBuffer;
Sound SoundEngine::mFireballLaunchSound;
2
3
4
5
6
7
8
上述代码使SoundEngine.h
文件中的静态变量在SoundEngine.cpp
文件中可用。静态变量是属于类的变量,并非每个实例所独有。这正是我们所需要的,因为我们不希望代码的其他部分使用不同的Sound
或Music
实例。
现在在SoundEngine.cpp
文件构造函数的右花括号之前添加以下初始化代码:
Listener::setDirection(1.f, 0.f, 0.f);
Listener::setUpVector(1.f, 1.f, 0.f);
Listener::setGlobalVolume(100.f);
mFireballLaunchBuffer.loadFromFile("sound/fireballLaunch.wav");
mFireballLaunchSound.setBuffer(mFireballLaunchBuffer);
2
3
4
5
6
7
在上述代码中,我们设置了Listener
实例的方向、向上向量和全局音量。这些值是全局的,会影响所有声音。
在SoundEngine.cpp
文件中添加playFireballLaunch
函数,如下所示:
void SoundEngine::playFireballLaunch(
Vector2f playerPosition,
Vector2f soundLocation)
{
mFireballLaunchSound.setRelativeToListener(true);
if (playerPosition.x > soundLocation.x)
// 声音来自左侧
{
Listener::setPosition(0, 0, 0.f);
mFireballLaunchSound.setPosition(-100, 0, 0.f);
mFireballLaunchSound.setMinDistance(100);
mFireballLaunchSound.setAttenuation(0);
}
else // 声音来自右侧
{
Listener::setPosition(0, 0, 0.f);
mFireballLaunchSound.setPosition(100, 0, 0.f);
mFireballLaunchSound.setMinDistance(100);
mFireballLaunchSound.setAttenuation(0);
}
mFireballLaunchSound.play();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在上述代码中,我们调用setRelativeToListner
并传入true
,这是使空间化音效正常工作所必需的。接下来是一个if - else
结构。if
块的条件通过比较玩家的水平坐标和调用该函数的火球的水平坐标,来判断声音是否应该来自左侧。
在if
和else
块中,玩家的水平位置都被设置为0,最小距离设置为100,衰减设置为0。两个块的不同之处在于,如果声音来自左侧,setPosition
函数的水平值被设置为 -100;如果声音来自右侧,则设置为100。
在if - else
结构之后,我们最后调用mFireballLaunchSound
的play
函数。我们很快就会调用这个playFireballLaunch
函数。
现在我们已经添加了一个空间化音效,接下来可以编写几个类来表示游戏中的火球,这些火球将使用这个新音效。
# 火球
现在,让我们通过编写火球相关的代码来使用这个新函数。要开始创建火球,我们需要一些新类。创建两个新类,FireballUpdate
和FireballGraphics
,分别从Update
类和Graphics
类派生。
# 编写FireballUpdate
类
开始编写FireballUpdate
类,在FireballUpdate.h
文件中添加以下代码:
#pragma once
#include "Update.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class FireballUpdate : public Update
{
private:
FloatRect m_Position;
FloatRect* m_PlayerPosition;
bool* m_GameIsPaused = nullptr;
float m_Speed = 250;
float m_Range = 900;
int m_MaxSpawnDistanceFromPlayer = 250;
bool m_MovementPaused = true;
Clock m_PauseClock;
float m_PauseDurationTarget = 0;
float m_MaxPause = 6;
float m_MinPause = 1;
//float mTimePaused = 0;
bool m_LeftToRight = true;
public:
FireballUpdate(bool* pausedPointer);
bool* getFacingRightPointer();
FloatRect* getPositionPointer();
int getRandomNumber(int minHeight, int maxHeight);
// 来自Update:Component
void update(float fps) override;
void assemble(shared_ptr<LevelUpdate> levelUpdate, shared_ptr<PlayerUpdate> playerUpdate)
override;
};
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
在FireBallUpdate
头文件的私有部分,我们声明了m_Position
、m_PlayerPosition
和m_GameIsPaused
。m_Position
用于表示火球的位置,m_PlayerPosition
是一个指向玩家位置的指针,m_GameIsPaused
是一个指向LevelUpdate
布尔值的指针,用于控制游戏是否暂停。
浮点型变量m_Speed
和m_Range
将被初始化为随机值,以确定火球的移动速度以及火球开始移动时与玩家的距离。
整型变量m_MaxSpawnDistanceFromPlayer
被设置为250,作为火球生成时与玩家的最大距离上限。
布尔型变量m_MovementPaused
将与m_GameIsPaused
协同工作,以便在游戏暂停、恢复、开始和结束时,同步停止和重新启动火球的移动。
Clock
实例m_PauseClock
根据分配给浮点型变量m_PauseDurationTarget
的随机值,计算火球发射前的时间。这为所有火球实例增加了额外的随机性和变化。
浮点型变量m_MaxPause
和m_MinPause
是固定值,随机暂停时间将在这两个值之间生成,单位为秒。
布尔型变量m_LeftToRight
的值会在true
和false
之间切换,用于确定火球是从玩家的左边还是右边出现。
在公有部分,我们有以下变量和函数:
- 构造函数接收一个布尔型指针,使火球能够跟踪游戏是否暂停。
getFacingRightPointer
函数返回一个指针,用于跟踪火球的运动方向。这个指针将与FireballGraphics
类共享,以便该类能够以正确的方向绘制火焰。getPositionPointer
函数返回一个指向火球位置的指针。这个指针将与FireballGraphics
类共享,以便该类能够在正确的位置绘制火焰。getRandomNumber
函数接受两个整数值,并返回这两个值之间的一个随机数。- 像往常一样,我们有从
Update
类重写的两个函数,即update
和assemble
。
由于FireballUpdate.cpp
文件的内容较长,我们将其分成几个部分。在FireballUpdate.cpp
文件的第一部分添加以下内容:
#include "FireballUpdate.h"
#include <random>
#include "SoundEngine.h"
#include "PlayerUpdate.h"
FireballUpdate::FireballUpdate(bool* pausedPointer)
{
m_GameIsPaused = pausedPointer;
m_PauseDurationTarget = getRandomNumber(m_MinPause, m_MaxPause);
}
bool* FireballUpdate::getFacingRightPointer()
{
return &m_LeftToRight;
}
FloatRect* FireballUpdate::getPositionPointer()
{
return &m_Position;
}
void FireballUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition = playerUpdate->getPositionPointer();
m_Position.top = getRandomNumber(
m_PlayerPosition->top - m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top + m_MaxSpawnDistanceFromPlayer);
m_Position.left = m_PlayerPosition->left - getRandomNumber(200, 400);
m_Position.width = 10;
m_Position.height = 10;
}
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
在上述FireballUpdate
构造函数的代码中,m_GameIsPaused
与LevelUpdate
类中用于确定游戏是否暂停的变量进行了同步,m_PauseDurationTarget
变量被随机初始化。由于每个实例都是随机初始化的,所以火球出现的时间会有所不同,不会像一堵致命的“火球墙”那样同时向玩家发射。
在getFacingRightPointer
函数中,返回了m_LeftToRight
变量的地址。FireBallGraphics
类将使用这个函数来跟踪应该以哪个方向绘制火球纹理。
在getPositionPointer
函数中,返回了m_Position
(FloatRect
类型)的地址。FireballGraphics
类将使用这个函数来跟踪火球的位置,并确保在世界中的正确位置初始化VertexArray
(顶点数组)的顶点。
在assemble
函数中,初始化了玩家位置的地址,因为火球需要检测是否击中玩家,并相应地移动玩家。火球的顶部和左侧位置是利用玩家的当前位置(为了公平性)和getRandomNumber
函数(为了在一定范围内产生变化)进行初始化的。
在assemble
函数的最后两行代码中,初始化了火球的宽度和高度(10×10世界单位)。
对于FireBallUpdate
类实现的第二部分,在FireballUpdate.cpp
文件中添加以下代码:
int FireballUpdate::getRandomNumber(int minHeight, int maxHeight) {
// Seed the random number generator with current time
std::random_device rd;
std::mt19937 gen(rd());
// Define a uniform distribution for the desired range
std::uniform_int_distribution<int> distribution(minHeight, maxHeight);
// Generate a random height within the specified range
int randomHeight = distribution(gen);
return randomHeight;
}
2
3
4
5
6
7
8
9
10
11
12
13
在上述getRandomNumber
函数的代码中,我们使用了与LevelUpdate
类的随机函数相同的代码。它返回传入的两个值之间的一个随机数。
最后,对于FireballUpdate
类,我们将编写update
函数。在FireballUpdate.cpp
文件中添加以下代码:
void FireballUpdate::update(float fps)
{
if (!*m_GameIsPaused)
{
if (!m_MovementPaused)
{
if (m_LeftToRight)
{
m_Position.left += m_Speed * fps;
if (m_Position.left - m_PlayerPosition->left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
m_PlayerPosition->top - m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top + m_MaxSpawnDistanceFromPlayer
);
m_PauseDurationTarget = getRandomNumber(m_MinPause, m_MaxPause);
}
}
else
{
m_Position.left -= m_Speed * fps;
if (m_PlayerPosition->left - m_Position.left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
m_PlayerPosition->top - m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top + m_MaxSpawnDistanceFromPlayer
);
m_PauseDurationTarget = getRandomNumber(m_MinPause, m_MaxPause);
}
}
// Has it hit the player
if (m_PlayerPosition->intersects(m_Position))
{
// Knock the player down
m_PlayerPosition->top += m_PlayerPosition->height * 2;
}
}
else
{
if (m_PauseClock.getElapsedTime().asSeconds() > m_PauseDurationTarget)
{
m_MovementPaused = false;
SoundEngine::playFireballLaunch(
m_PlayerPosition->getPosition(),
m_Position.getPosition()
);
}
}
}
}
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
上述代码较长,我们将其分为五个部分,逐步分析。在第一部分,我们看到以下代码:
if (!*m_GameIsPaused) {
if (!m_MovementPaused) {
2
在上述代码中,我们检查游戏没有暂停,并且在火球开始新的移动之前,火球也没有处于暂停状态。
在第二部分,我们看到以下代码:
if (m_LeftToRight)
{
m_Position.left += m_Speed * fps;
if (m_Position.left - m_PlayerPosition->left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
m_PlayerPosition->top - m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top + m_MaxSpawnDistanceFromPlayer
);
m_PauseDurationTarget = getRandomNumber(m_MinPause, m_MaxPause);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在上述第二部分代码中,所有代码都包含在一个if
语句中,该语句检查火球是否从左向右移动。如果火球向右移动,则根据速度和上次更新以来经过的时间来更新火球的位置。接下来的if
语句(在检查移动方向的if
语句内部)检查火球与玩家的距离是否超过了m_Range
。如果超过了,那么就该暂停火球、重新启动时钟、改变移动方向,并选择一个新的随机高度和新的随机暂停持续时间。现在,在m_PauseDuration
(暂停持续时间)过去之后,火球就准备好朝相反方向飞向玩家。
在第三部分,我们看到以下代码:
else
{
m_Position.left -= m_Speed * fps;
if (m_PlayerPosition->left - m_Position.left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
m_PlayerPosition->top - m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top + m_MaxSpawnDistanceFromPlayer
);
m_PauseDurationTarget = getRandomNumber(m_MinPause, m_MaxPause);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在上述第三部分代码中,发生的情况与前面的if
语句几乎完全相同。唯一的区别是,火球从右向左移动,并且当它与玩家的距离足够远时,它准备再次从左向右飞行。
在第四部分,我们看到以下代码:
// Has it hit the player
if (m_PlayerPosition->intersects(m_Position))
{
// Knock the player down
m_PlayerPosition->top =
m_PlayerPosition->top +
m_PlayerPosition->height * 2;
}
2
3
4
5
6
7
8
在上述第四部分代码中,代码检查火球是否击中了玩家。如果击中了玩家,玩家会被向下击退,击退的距离是玩家高度的两倍。这可能会导致玩家不得不使用加速功能回到平台上,从而让逐渐消失的平台有机会追上玩家。
在第五部分,我们看到以下代码:
else
{
if (m_PauseClock.getElapsedTime().asSeconds() > m_PauseDurationTarget)
{
m_MovementPaused = false;
SoundEngine::playFireballLaunch(
m_PlayerPosition->getPosition(),
m_Position.getPosition()
);
}
}
2
3
4
5
6
7
8
9
10
11
在上述第五部分代码中,else
块只有在前面的if
部分不执行时才会执行。在else
部分内部,另一个if
语句检查m_PausedClock
的经过时间是否大于随机生成的m_PauseDuration
(暂停持续时间)。如果是,那么将m_MovementPaused
设置为false
,火球将准备好再次向玩家移动。作为对玩家的警告,会调用playFireBallLaunch
函数,并传入必要的参数,以便SoundEngine
类能够播放从相应方向传来的声音。
# 编写FireballGraphics
类
在本节中,我们将编写FireballGraphics
类。为了理解后续的代码,再次查看纹理图集(texture atlas)中的图形会有所帮助:
图20.1:三个火球帧
我们可以看到,从左到右有三个动画帧。这非常适合与我们已经编写好的Animator
类配合使用。与PlayerGraphics
类一样,当火球从右向左移动时,我们需要翻转纹理中的像素,使它们朝向相反的方向。从技术上讲,我们也应该反转动画,但对于火焰动画来说,这并没有太大区别,不过对于角色动画来说却有区别,它可以避免出现“迈克尔·杰克逊效应”(Michael Jackson effect)。
# 编写FireballGraphics.h
在FireballGraphics.h
文件中,添加以下代码:
#pragma once
#include "Graphics.h"
class Animator;
class PlayerUpdate;
class FireballGraphics : public Graphics
{
private:
FloatRect* m_Position;
int m_VertexStartIndex;
bool* m_FacingRight = nullptr;
Animator* m_Animator;
IntRect* m_SectionToDraw;
std::shared_ptr<PlayerUpdate> m_PlayerUpdate;
public:
// From Graphics : Component
void draw(VertexArray& canvas) override;
void assemble(VertexArray& canvas,
std::shared_ptr<Update> genericUpdate,
IntRect texCoords) override;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
首先,我们添加了必要的包含指令,以及对Animator
类和PlayerUpdate
类的前向声明,以便在这个代码文件中引用它们。接下来,是所有的私有声明。
名为m_Position
的FloatRect
指针用于存储位置。与我们所有从Graphics
派生的类一样,整型变量m_VertexStartIndex
将存储VertexArray
(顶点数组)中第一个顶点的位置。
布尔型指针m_FacingRight
将存储FireballUpdate
类中用于确定火球运动方向的布尔值的地址。
Animator
实例将处理与火球相关的三个动画帧的循环播放,IntRect
类型的m_SectionToDraw
将存储当前动画帧的纹理坐标。
名为m_PlayerUpdate
的std::shared_ptr<PlayerUpdate>
智能指针,将使FireballGraphics
类能够调用FireballUpdate
类的所有公有函数。
接下来,是所有的公有声明,但我们只需要重写assemble
和draw
这两个函数。我不会再浪费时间讲解这些参数,看看这些函数内部发生了什么会更有趣。
# 编写FireballGraphics.cpp
我们将分几个阶段编写FireballGraphics.cpp
。首先,添加包含指令和assemble
函数的代码:
#include "FireballGraphics.h"
#include "Animator.h"
#include "FireballUpdate.h"
void FireballGraphics::assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate, IntRect texCoords)
{
shared_ptr<FireballUpdate> fu = static_pointer_cast
<FireballUpdate>(genericUpdate);
m_Position = fu->getPositionPointer();
m_FacingRight = fu->getFacingRightPointer();
m_Animator = new Animator(texCoords.left,
texCoords.top,
3, // 6帧
texCoords.width * 3, texCoords.height,
6); // 帧率
m_SectionToDraw =
m_Animator->getCurrentFrame(false);
m_VertexStartIndex =
canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
const int uPos = texCoords.left;
const int vPos = texCoords.top;
const int texWidth = texCoords.width;
const int texHeight = texCoords.height;
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y = vPos;
canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y = vPos;
canvas[m_VertexStartIndex + 2].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight;
}
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
在上述代码中,我们首先将Update
实例转换为FireballUpdate
实例,并调用getPositionPointer
和getFacingRightPointer
函数,以便跟踪火球在游戏世界中的位置以及它的朝向。
接下来,我们初始化Animator
实例,并通过调用getCurrentFrame
初始化起始帧的纹理坐标。代码的其余部分与我们在所有其他派生自Graphics
的类中看到的一样。我们保存四边形的起始索引,扩展VertexArray
以容纳该四边形,并初始化VertexArray
中所有顶点的起始纹理坐标。
最后,添加draw
函数:
void FireballGraphics::draw(VertexArray& canvas)
{
const Vector2f& position =
m_Position->getPosition();
const Vector2f& scale =
m_Position->getSize();
canvas[m_VertexStartIndex].position = position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position = position + scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, scale.y);
if (*m_FacingRight)
{
m_SectionToDraw =
m_Animator->getCurrentFrame(false);
const int uPos = m_SectionToDraw->left;
const int vPos = m_SectionToDraw->top;
const int texWidth = m_SectionToDraw->width;
const int texHeight = m_SectionToDraw->height;
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y = vPos;
canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y = vPos;
canvas[m_VertexStartIndex + 2].texCoords.x = uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight;
}
else
{
// 对火球来说,绘制帧的顺序影响不大
// 但正面必须在前面,这是肯定的!!!
m_SectionToDraw = m_Animator->getCurrentFrame(true);
// 反转
const int uPos = m_SectionToDraw->left;
const int vPos = m_SectionToDraw->top;
const int texWidth = m_SectionToDraw->width;
const int texHeight = m_SectionToDraw->height;
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y = vPos;
canvas[m_VertexStartIndex + 1].texCoords.x = uPos - texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y = vPos;
canvas[m_VertexStartIndex + 2].texCoords.x = uPos - texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight;
}
}
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
我们可以将上述代码分为三个简单部分:起始部分、if
代码块部分和else
代码块部分。
在起始部分,更新顶点位置。除了游戏暂停或火球等待随机间隔结束后再次发射的情况外,在游戏的大多数帧中,这些顶点都会移动。
if
部分检查火球是否向右。如果是,则将纹理坐标分配给相应的顶点。如果执行else
部分,则将纹理坐标分配给顶点并进行水平翻转,使火球向左。
接下来,我们将使用这两个新类。
# 在工厂中生成一些火球
在本节中,我们将向Factory
类添加代码,以便在游戏中实例化一些火球。在Factory.cpp
中添加两个新的包含指令:
#include "FireballGraphics.h"
#include "FireballUpdate.h"
2
在平台相关代码之后、与雨相关代码之前,向工厂添加更多代码,如下所示:
// 火球
for (int i = 0; i < 12; i++)
{
GameObject fireball;
shared_ptr<FireballUpdate> fireballUpdate =
make_shared<FireballUpdate>(
levelUpdate->getIsPausedPointer());
fireballUpdate->assemble(levelUpdate, playerUpdate);
fireball.addComponent(fireballUpdate);
shared_ptr<FireballGraphics> fireballGraphics = make_shared<FireballGraphics>();
fireballGraphics->assemble(canvas, fireballUpdate,
IntRect(870, 0, 32, 32));
fireball.addComponent(fireballGraphics);
gameObjects.push_back(fireball);
}
// 结束火球
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在上述代码中,for
循环迭代12次。每次循环都会创建一个火球,并将一个GameObject
添加到gameObjects
向量中。
创建每个实例的常规步骤如下:
- 创建一个
GameObject
实例。 - 创建一个派生自
Update
的共享指针,在调用new
时添加任何所需的构造函数参数。 - 调用
assemble
函数。 - 使用
addComponent
函数将派生自Update
的实例添加到GameObject
中。 - 对派生自
Graphics
的共享指针重复上述步骤。现在,我们可以看到自己编写的火球投入使用了!
# 运行代码
现在,我们的火球已经准备就绪!运行游戏,惊叹于(但别忘了使用雷达和空间音效来避开它们)我们编写的火球。
图20.2:火球
我们还可以听到有方向的声音。声音会告诉你火球从哪个方向飞来,如果你需要避开,小地图会提前发出警告。
# 总结
在本章中,我们学习了什么是空间化(spatialization),以及它如何让我们在游戏中为声音添加方向。我们接着学习了SFML处理空间化的理论。然后,我们升级了SoundEngine
类以生成空间化音效,最后编写了与火球相关的类(派生自Graphics
和Update
),这些类可以生成空间化音效并在游戏中发射一些火球。在下一章中,我们将添加一个酷炫的视差背景和一个相当惊艳的着色器效果。