第16章 声音、游戏逻辑、对象间通信与玩家
# 第16章 声音、游戏逻辑、对象间通信与玩家
在本章中,我们将快速为游戏实现声音功能。我们之前做过类似的事情,所以这并不难。实际上,只需编写几行代码,我们就能在声音功能中添加音乐播放。在项目后续阶段(但不是在本章),我们会添加定向(空间化)音效。不过这次,我们会把所有与声音相关的代码封装到一个名为SoundEngine
的类中。实现声音功能后,我们将着手创建玩家角色。通过添加两个类,我们就能实现玩家角色的全部功能:一个类继承自Update
,另一个类继承自Graphics
。在整个游戏开发过程中,我们几乎都是通过继承这两个类来创建新的游戏对象。我们还会了解到对象如何通过指针简单地相互通信。本章完整的代码可以在Run2
文件夹中找到。
简而言之,在本章中,我们将:
- 编写
SoundEngine
类:编写一个与声音相关的类,该类还能循环播放音乐。 - 编写游戏逻辑:编写一个处理所有游戏逻辑的类,并学习它如何与其他游戏对象进行通信。
- 编写玩家相关代码:使用图形组件和更新组件编写玩家角色的第一部分代码。
- 编写工厂类以使用所有新类:进一步编写工厂类,使其能够组装不同的游戏对象,并在它们之间共享适当的数据。
- 运行游戏。
我们将从添加一个声音类开始。
# 编写SoundEngine类
你可能还记得,在上一个项目中,所有的声音代码占用了相当多行。现在想想,在第20章添加空间化音效时,我们还需要更多代码,代码量会变得更长。为了便于管理代码,我们将编写一个类来管理所有正在播放的音效和音乐。
所有这些代码都很常见。即使是播放音乐这个新功能,鉴于我们在其他游戏中的经验,也应该很容易理解。创建一个名为SoundEngine
的新类。在SoundEngine.h
文件中,添加以下代码:
#pragma once
#include <SFML/Audio.hpp>
using namespace sf;
class SoundEngine
{
private:
static Music music;
static SoundBuffer m_ClickBuffer;
static Sound m_ClickSound;
static SoundBuffer m_JumpBuffer;
static Sound m_JumpSound;
public:
SoundEngine();
static SoundEngine* m_sInstance;
static bool mMusicIsPlaying;
static void startMusic();
static void pauseMusic();
static void resumeMusic();
static void stopMusic();
static void playClick();
static void playJump();
};
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
在上述代码中,我们有一个SFML的Music
对象、SoundBuffer
,以及为每个要播放的音效准备的Sound
对象。在类的公有部分,我们有用于启动、暂停、停止和恢复音乐的函数,还有两个用于播放每个音效的函数。一旦了解其工作原理,就可以轻松地为游戏添加任意数量的音效。
在SoundEngine.cpp
类中,添加以下代码:
#include "SoundEngine.h"
#include <assert.h>
SoundEngine* SoundEngine::m_s_Instance = nullptr;
bool SoundEngine::mMusicIsPlaying = false;
Music SoundEngine::music;
SoundBuffer SoundEngine::m_ClickBuffer;
Sound SoundEngine::m_ClickSound;
SoundBuffer SoundEngine::m_JumpBuffer;
Sound SoundEngine::m_JumpSound;
SoundEngine::SoundEngine()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
m_ClickBuffer.loadFromFile("sound/click.wav");
m_ClickSound.setBuffer(m_ClickBuffer);
m_JumpBuffer.loadFromFile("sound/jump.wav");
m_JumpSound.setBuffer(m_JumpBuffer);
}
void SoundEngine::playClick()
{
m_ClickSound.play();
}
void SoundEngine::playJump()
{
m_JumpSound.play();
}
void SoundEngine::startMusic()
{
music.openFromFile("music/music.wav");
m_s_Instance->music.play();
m_s_Instance->music.setLoop(true);
mMusicIsPlaying = true;
}
void SoundEngine::pauseMusic()
{
m_s_Instance->music.pause();
mMusicIsPlaying = false;
}
void SoundEngine::resumeMusic()
{
m_s_Instance->music.play();
mMusicIsPlaying = true;
}
void SoundEngine::stopMusic()
{
m_s_Instance->music.stop();
mMusicIsPlaying = false;
}
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
57
音效的实现方式与之前的项目相同,只是现在我们将它们封装在了一个类中。在构造函数中加载缓冲区和声音,并将它们关联起来,相关的函数调用会在相应的Sound
实例上播放音效。
让我们来探究一下音乐的工作原理。音乐实例没有缓冲区。从技术上讲,你可以将音乐文件加载到普通的Sound
对象中,但由于音乐通常比音效长得多,这样做效果并不好。因此,SFML提供了Music
类。在startMusic
函数中,你可以看到我们使用了openFromFile
函数。这会准备好文件以便进行流式传输,而不是一次性全部加载。然后,我们调用music.play
函数,开始流式传输并播放音乐。接着,我们调用music.setLoop
并传入true
,这会使音乐不断重复播放。
在pauseMusic
、resumeMusic
和stopMusic
函数中,我们分别调用了SFML提供的pause
、play
和stop
函数。注意,我们还适当地设置了m_MusicIsPLaying
布尔值,以便跟踪音乐的状态。
在项目接近尾声添加定向音效时,我们还需要为声音管理器添加更多代码,这样我们就能判断火球是从左边还是右边传来的。
# 编写游戏逻辑
为了控制游戏逻辑,我们将其封装在游戏中的一个游戏对象内,并与其他游戏对象建立必要的通信连接,实现双向信息传递。这种通信将通过指向关键值的指针来实现。例如,所有对象都将有一个指向与逻辑相关的游戏对象的指针,以便了解诸如游戏是否暂停等信息。
将游戏逻辑放在一个单独的类中,这个想法很有趣。假设你的游戏有三种不同的游戏模式。想象一下,如果我们将所有这些逻辑都整合到主函数中,会需要多少if
、else
和else if
语句,那将是一团糟。通过这种方式,工厂可以根据玩家选择的游戏模式简单地挑选一个游戏对象。虽然这个游戏只有一种游戏模式,但一旦你看过代码,就会发现,在不同的类中创建一套不同的逻辑非常容易。
注意,这里不会有LevelGraphics
类,因为我们不需要。在项目的后续阶段,当我们创建一个下雨特效的游戏对象时,会看到有一个继承自Graphics
的RainGraphics
类,但不需要派生自Update
的对象。我们创建的大多数游戏对象都将有一个更新组件和一个基于图形的组件。关键在于,这是一个灵活的系统。
# 编写LevelUpdate类
创建一个新类LevelUpdate
,它以Update
为基类。在LevelUpdate.h
中添加以下代码:
#pragma once
#include "Update.h"
using namespace sf;
using namespace std;
class LevelUpdate : public Update {
private:
bool m_IsPaused = true;
vector <FloatRect*> m_PlatformPositions;
float* m_CameraTime = new float;
FloatRect* m_PlayerPosition;
float m_PlatformCreationInterval = 0;
float m_TimeSinceLastPlatform = 0;
int m_NextPlatformToMove = 0;
int m_NumberOfPlatforms = 0;
int m_MoveRelativeToPlatform = 0;
bool m_GameOver = true;
void positionLevelAtStart();
public:
void addPlatformPosition(FloatRect* newPosition);
void connectToCameraTime(float* cameraTime);
bool* getIsPausedPointer();
int getRandomNumber(int minHeight, int maxHeight);
// From 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
在上述代码中,有很多成员变量,如下所示:
m_IsPaused
布尔变量用于跟踪游戏是否暂停。任何需要知道这个信息的游戏对象都可以获取指向这个值的指针。m_PlatformPositions
变量是一个向量,其中保存着指向FloatRect
实例的指针。顾名思义,这些实例将保存游戏中所有平台的位置和大小。需要注意的是,这些指针一旦初始化,将直接指向与平台相关的游戏对象中的值。这意味着这个类可以直接操作平台。在编写平台相关代码时,我们会看到如何实现这一点。在编写这个类的函数时,我们也会看到如何操作这些平台的位置。m_CameraTime
变量是一个简单的浮点数。它表示当前游戏尝试运行的秒数,包括小数部分。这是衡量玩家是否成功的关键指标。很快,我们会在屏幕左上角显示这个时间。m_PlayerPosition
指针是一个指向FloatRect
实例的指针,该实例保存着玩家的位置。由于它将直接指向与玩家相关的类,LevelUpdate
类将能够根据玩家的当前位置做出决策,比如玩家是否落后太远导致游戏结束。m_PlatformCreationInterval
浮点型变量用于保存创建新平台之间的等待时间。很快我们会看到,我们不是创建新平台,而是重复使用一组平台。这个时间间隔将基于之前重复使用的/新平台的长度。这是合理的,因为玩家在某些平台上需要跑更远的距离,使时间间隔与平台长度相关似乎更公平。m_TimeSinceLastPlatform
浮点型变量与m_PlatformCreationInterval
一起工作。当m_TimeSinceLastPlatform
大于或等于m_PlatformCreationInterval
时,就该在前一个平台前面创建/重复使用另一个平台了。m_NextPlatformToMove
整型变量表示下一个要重复使用的平台在平台位置向量中的位置。m_NumberOfPlatforms
整型变量是已创建的平台数量。代码可以处理非常小的数字,比如5,也可以处理大得多的数字,比如500。主要的区别在于,我们将在Factory
类的loadLevel
函数中使用既能保证游戏可玩性又能使代码高效运行的最小、最有效的数字。m_MoveRelativeToPlatform
整型变量是下一个平台相对于前一个平台在平台位置向量中的移动位置。想象一下,在最新的平台上跑到尽头,然后下一个平台及时出现。那么下一个平台需要相对于前一个平台处于可到达的位置。m_GameOver
布尔变量用于跟踪游戏是否结束,或者在程序首次执行时,游戏是否尚未开始。这与m_IsPaused
是不同的。
现在让我们来了解一下这些函数:
- 第一个是一个名为
positionLevelAtStart
的私有函数,它在每次游戏开始时设置所有游戏对象的初始位置。 addPlatformPosition
函数接收一个名为newPosition
的FloatRect
指针,并将用于定位单个平台。connectToCameraTime
函数接收一个浮点型指针,该指针可以与m_CameraTime
保持同步。通过这种机制,我们将更新屏幕上显示给玩家的时间文本。这个文本将在CameraGraphics
类中使用SFML的Text
实例绘制,我们将在下一章编写这个类。getIsPausedPointer
函数返回一个指向布尔值的指针。具体来说,它返回一个指向m_IsPaused
变量的指针。这使得我们代码中任何需要知道游戏是否暂停的部分都可以访问这个信息。在项目的其余部分,我们会看到这个函数的实际作用,因为多个游戏对象都需要知道游戏是否暂停。getRandomNumber
函数接受两个值,并返回这两个值之间的一个随机数。在整个项目中,我们会经常看到类似这样的代码。在这个类中,这个函数最常见的用途是在重复使用平台时确定平台的位置。
最后,我们有两个从Update
类继承并重写的函数:
update
函数接收游戏最后一次循环执行所花费的时间。与我们其他游戏一样,这对于在update
函数中为所有动作设置时间至关重要。- 如前所述,
assemble
函数将在工厂中用于准备组件以供使用。完成LevelUpdate
类的编写后,我们将编写一些与玩家相关的类。然后,我们将看到如何在Factory
类中使用assemble
函数。
接下来,我们将把代码添加到LevelUpdate.cpp
文件中。从LevelUpdate.h
文件中可以看到,有不少函数。因此,我们将分几个部分添加并解释这些函数。在LevelUpdate.cpp
文件中添加以下代码来开始:
#include "LevelUpdate.h"
#include<Random>
#include "SoundEngine.h"
#include "PlayerUpdate.h"
using namespace std;
void LevelUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition = playerUpdate->getPositionPointer();
//temp
SoundEngine::startMusic();
}
void LevelUpdate::connectToCameraTime(float* cameraTime) {
m_CameraTime = cameraTime;
}
void LevelUpdate::addPlatformPosition(FloatRect* newPosition) {
m_PlatformPositions.push_back(newPosition);
m_NumberOfPlatforms++;
}
bool* LevelUpdate::getIsPausedPointer() {
return &m_IsPaused;
}
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
在前面的代码中,我们添加了所需的包含指令。注意,包含指令和函数中存在错误,因为我们还没有编写玩家相关的代码。
在assemble
函数中,我们调用了playerUpdate->getPositionPointer
。这意味着当我们创建PlayerUpdate
类时,还需要创建一个名为getPositionPointer
的函数。我们很快就会这么做,但现在需要注意的是,LevelUpdate
实例将始终能够获取玩家的位置。接下来,作为临时措施,我们调用startMusic
,这样我们就能首次听到音乐。最终,一个与菜单相关的游戏对象将控制音乐的开始、停止和暂停。
在connectToCameraTime
函数中,我们用cameraTime
中包含的内存地址初始化m_CameraTime
。在一段时间内我们实际上不会调用这个函数,但它已经准备好,等我们需要时就可以使用。
addPlatformPosition
函数使用push_back
将传入的平台位置添加到向量中。每次我们在工厂中创建一个新平台时,都会调用这个函数。我们还会增加m_NumberOfPlatforms
变量的值,以记录我们拥有的平台数量。
getPausedPointer
函数返回m_IsPaused
布尔值的地址,为任何请求并保留返回地址的对象提供对游戏状态的永久访问权限。
接下来,在LevelUpdate.cpp
文件中添加positionLevelAtStart
函数:
Void LevelUpdate::positionLevelAtStart()
{
float startOffset = m_PlatformPositions[0]->left;
for (int I = 0; i < m_NumberOfPlatforms; ++i)
{
m_PlatformPositions[i]->left = i * 100 + startOffset;
m_PlatformPositions[i]->top = 0;
m_PlatformPositions[i]->width = 100;
m_PlatformPositions[i]->height = 20;
}
m_PlayerPosition->left =
m_PlatformPositions[m_NumberOfPlatforms / 2]->left + 2;
m_PlayerPosition->top =
m_PlatformPositions[m_NumberOfPlatforms / 2]->top– - 22;
m_MoveRelativeToPlatform = m_NumberOfPlatforms– - 1;
m_NextPlatformToMove = 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在positionLevelAtStart
函数中,第一行代码通过获取向量中第一个平台的左坐标来初始化一个名为startOffset
的浮点型变量。接下来,代码从0到m_NumberOfPlatforms
遍历向量中的所有平台。循环的每次迭代将一个平台水平放置在i * 100
个单位加上起始偏移量的位置,垂直放置在0个单位处,宽度为100个单位,高度为20个单位。这本来可以在工厂中完成,但当玩家开始第二次游戏时,平台可能会到处都是。结果是所有平台首尾相连排成一条直线,大小和高度都没有变化。这就像是在位置开始随机化之前,给玩家的一个简单开场。
在for
循环之外,接下来的两行代码使用[m_NumberOfPlatforms / 2]
将玩家定位到向量中大约中间平台的左边缘。水平方向的神奇数字+2和垂直方向的 - 22是为了确保玩家的脚稳稳地站在这个平台上。在你了解了我们如何编写Factory
类之后,欢迎改进这段代码。
接下来的一行代码将m_MoveRelativeToPlatform
初始化为向量中的最后一个平台。这是有意义的,因为我们希望在最右边平台的右边缘之外继续放置新平台。最后一行代码将向量中的第一个平台设置为下一个要移动的候选平台。这意味着最左边的平台将被移动到最右边,而玩家将在中间生成。
接下来,添加getRandomNumber
函数。这个函数正如其名所示,在代码中的几个地方,当我们想要生成传入的两个值之间的随机值时,会用到它。在LevelUpdate.cpp
文件中添加以下代码:
int LevelUpdate::getRandomNumber(int minHeight, int maxHeight)
{
#include <random>
// Seed the random number generator with current time
random_device rd;
mt19937 gen(rd());
// Define a uniform distribution for the desired
uniform_int_distribution<int>
distribution(minHeight, maxHeight);
// Generate a random height within the specified
int randomHeight = distribution(gen);
return randomHeight;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
与我们在之前的游戏中使用的方法相比,这个函数是一种更现代的生成随机数的方式。
第一行创建了一个随机设备对象rd
,用于为随机数生成器播种。随机设备是一个非确定性随机数源,通常基于硬件值。这比我们之前使用的方法可靠得多。
接下来,用随机设备的种子(rd
)初始化一个梅森旋转伪随机数生成器(mt19937
)。梅森旋转算法是一种广泛使用的生成高质量随机数的算法。
接下来,一个名为distribution
的uniform_int_distribution
实例创建了一个均匀分布对象,用于在指定范围内(包括minHeight
到maxHeight
)生成整数。uniform_int_distribution
类确保范围内的每个整数被选中的概率相等。
代码distribution(gen)
使用前面定义的分布和梅森旋转生成器生成一个随机整数。结果存储在randomHeight
变量中。
最后,将随机生成的数字返回给调用代码。你只需要记住,如果你调用这个函数,你将得到一个介于传入的两个值之间的真正随机值。
LevelUpdate
类的最后一个函数是update
函数。回想一下,update
函数由GameObject
类每帧调用一次,而GameObject
类又由游戏循环每帧调用一次。这是一个相对复杂的函数,处理整个游戏逻辑。在添加代码时,试着研究它的结构,然后我们再讨论它是如何工作的。
将update
函数添加到LevelUpdate
类中。我们将分解这段代码并进行讨论,但我建议一次性复制粘贴或编写整个函数,因为如果分段编写,很容易混淆结构:
void LevelUpdate::update(float timeSinceLastUpdate)
{
if (!m_IsPaused)
{
if (m_GameOver) {
m_GameOver = false;
*m_CameraTime = 0;
m_TimeSinceLastPlatform = 0;
int platformToPlacePlayerOn;
positionLevelAtStart();
}
*m_CameraTime += timeSinceLastUpdate;
m_TimeSinceLastPlatform += timeSinceLastUpdate;
if (m_TimeSinceLastPlatform > m_PlatformCreationInterval) {
m_PlatformPositions[m_NextPlatformToMove]->top =
m_PlatformPositions[m_MoveRelativeToPlatform]->top + getRandomNumber(-40, 40);
// How far away to create the next platform
// Bigger gap if lower than previous
if (m_PlatformPositions[m_MoveRelativeToPlatform]->top < m_PlatformPositions[m_NextPlatformToMove]->top)
{
m_PlatformPositions[m_NextPlatformToMove]->left = m_PlatformPositions[m_MoveRelativeToPlatform]-
>left +
m_PlatformPositions[m_MoveRelativeToPlatform]- >width +
getRandomNumber(20, 40);
}
else
{
m_PlatformPositions[m_NextPlatformToMove]->left = m_PlatformPositions[m_MoveRelativeToPlatform]-
>left +
m_PlatformPositions[m_MoveRelativeToPlatform]- >width +
getRandomNumber(0, 20);
}
m_PlatformPositions[m_NextPlatformToMove]->width = getRandomNumber(20, 200);
m_PlatformPositions[m_NextPlatformToMove]->height = getRandomNumber(10, 20);
// Base the time to create the next platform
// on the width of the one just created
m_PlatformCreationInterval =
m_PlatformPositions[m_NextPlatformToMove]->width / 90;
m_MoveRelativeToPlatform = m_NextPlatformToMove;
m_NextPlatformToMove++;
if (m_NextPlatformToMove == m_NumberOfPlatforms) {
m_NextPlatformToMove = 0;
}
m_TimeSinceLastPlatform = 0;
}
// Has the player lagged behind the furthest back platform
bool laggingBehind = true;
for (auto platformPosition : m_PlatformPositions) {
if (platformPosition->left < m_PlayerPosition->left) {
laggingBehind = false;
break;// At least one platform is behind the player
}
else
{
laggingBehind = true;
}
}
if (laggingBehind) {
m_IsPaused = true;
m_GameOver = true;
SoundEngine::pauseMusic();
}
}
}
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
我们将把这段冗长的代码分成几个部分,但请务必完整地研究它,尤其要注意其中的if
语句和循环结构,这样下面的讨论就更容易理解。update
函数在timeSinceLastUpdate
变量中接收主游戏循环上一次迭代执行所花费的时间。
update
函数的第一部分设置了以下结构,该结构仅在游戏未暂停时运行。我们讨论的其他所有内容都在这个if
语句内部发生,这意味着游戏暂停时不会发生任何事情:
if (!m_IsPaused) {
在update
函数中,接下来的代码仅在游戏结束时运行——换句话说,当玩家刚刚运行应用程序,或者刚刚死亡且尚未重新开始时:
if (m_GameOver) {
m_GameOver = false;
*m_CameraTime = 0;
m_TimeSinceLastPlatform = 0;
positionLevelAtStart();
}
2
3
4
5
6
前面的代码将m_GameOver
设置为false
,重置计时器,重置自上一个平台生成以来的时间,并调用我们已经讨论过的positionLevelAtStart
函数。这一整块代码的最终效果是只会运行一次,并且在完成其余代码后,它会完成启动新游戏所需的所有操作。
在update
函数中,接下来有一个if
语句,如下所示:
*m_CameraTime += timeSinceLastUpdate;
m_TimeSinceLastPlatform += timeSinceLastUpdate;
if (m_TimeSinceLastPlatform > m_PlatformCreationInterval) {
m_PlatformPositions[m_NextPlatformToMove]->top =
m_PlatformPositions[m_MoveRelativeToPlatform]->top + getRandomNumber(-40, 40);
2
3
4
5
6
在前面的代码中,m_CameraTime
变量增加了自上一次执行update
以来经过的时间。这是最终将显示给玩家的时间。m_TimeSinceLastPlatform
也以同样的方式增加。
接下来,有一个if
语句,当m_TimeSinceLastPlatform
超过m_PlatformCreationInterval
时执行。换句话说,是时候将一个平台从玩家后面移动到玩家前面了。然后,玩家后面最远的平台(m_NextPlatformToMove
)相对于玩家前面最远的平台(m_MoveRelativeToPlatform
)进行随机定位,但此时仅调整高度。
同样,在前面的if
语句中,有一个if - else
结构来处理水平定位。我们再来看一下:
// How far away to create the next platform
// Bigger gap if lower than previous
if (m_PlatformPositions[m_MoveRelativeToPlatform]->top < m_PlatformPositions[m_NextPlatformToMove]->top)
{
m_PlatformPositions[m_NextPlatformToMove]->left =
m_PlatformPositions[m_MoveRelativeToPlatform]->left + m_PlatformPositions[m_MoveRelativeToPlatform]
->width + getRandomNumber(20, 40);
}
else
{
m_PlatformPositions[m_NextPlatformToMove]->left = m_PlatformPositions[m_MoveRelativeToPlatform]
->left +
m_PlatformPositions[m_MoveRelativeToPlatform]
->width + getRandomNumber(0, 20);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在前面的if - else
语句中,if
部分检查上一行代码将下一个平台垂直定位的距离。如果它更低,那么执行与if
相关的代码;如果它更高,那么执行与else
相关的代码。与else
相关的代码在水平方向上使用较小的值来间隔平台,这是有意义的,因为如果平台在玩家所在平台的上方,可跳跃的距离会更小。
接下来,执行以下代码:
m_PlatformPositions[m_NextPlatformToMove]->width = getRandomNumber(20, 200);
m_PlatformPositions[m_NextPlatformToMove]->height = getRandomNumber(10, 20);
// Base the time to create the next platform
// on the width of the one just created
m_PlatformCreationInterval =
m_PlatformPositions[m_NextPlatformToMove]->width
/ 90;
m_MoveRelativeToPlatform = m_NextPlatformToMove;
m_NextPlatformToMove++;
if (m_NextPlatformToMove == m_NumberOfPlatforms)
{
m_NextPlatformToMove = 0;
}
m_TimeSinceLastPlatform = 0;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在前面的代码中,为新平台选择一个随机宽度,然后选择一个随机高度,然后根据随机选择的宽度初始化一个时间量并赋值给m_PlatformCreationInterval
。下一行增加要移动的下一个平台在向量中的位置,后面的if
语句检查该值是否超出向量中的最后一个位置,如果是,则将该值更改为零(向量中的第一个条目)。
上面的最后一行代码将m_TimeSinceLastPlatform
设置为零,这样我们就可以不断累加每次循环所花费的时间,直到最终移动另一个平台,然后再次重复所有操作。
此时,我们关闭if (m_TimeSinceLastPlatform > m_PlatformCreationInterval)
块的花括号。
接着完成if(!m_Paused)
代码和整个update
函数,以下代码检查消失的平台是否追上了玩家(因此玩家游戏失败):
// Has the player lagged behind the furthest back platform
bool laggingBehind = true;
for (auto platformPosition : m_PlatformPositions)
{
if (platformPosition->left < m_PlayerPosition->left)
{
laggingBehind = false;
break;// At least one platform is behind the player
}
else
{
laggingBehind = true;
}
}
if (laggingBehind)
{
m_IsPaused = true;
m_GameOver = true;
SoundEngine::pauseMusic();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在前面的代码中,将laggingBehind
布尔值设置为true
。接下来,for
循环遍历每个平台位置,检查是否有任何平台的左坐标小于(因此在玩家后面)玩家的左坐标。如果有任何一个平台在玩家后面,那么玩家还有机会,laggingBehind
布尔值将被设置为false
。
如果laggingBehind
变量仍然设置为true
,则意味着所有平台都在玩家前面,游戏结束。如果laggingBehind
设置为true
,则游戏暂停,m_GameOver
变量设置为true
,音乐暂停。
很快,我们将编写一个菜单,允许玩家在失败后重新开始游戏。
最后,在update
函数中,我们关闭决策结构中剩余的花括号(不再显示)。
我们已经完成了对update
函数的解释和编码,但我们还不能运行代码,因为存在引用PlayerUpdate
类的错误。此外,我们还没有创建任何LevelUpdate
类的实例。
接下来,我们将通过从名为Update
的类派生出一个对象(即PlayerUpdate
),从名为Graphics
的类派生出一个对象(即PlayerGraphics
),来编写玩家角色的基本代码。然后,为了完成本章的代码,我们将在工厂中添加代码,组装所有这些不同的组件,并将它们放入GameObject
实例中,这样我们就可以在游戏的每一帧中
# 为玩家角色编写代码:第1部分
在本节中,我们将开始创建可控制的玩家角色。我们会让角色在屏幕上可见,但之后还会回到PlayerUpdate
和PlayerGraphics
类,添加键盘控制和动画效果。
创建两个新类:PlayerUpdate
(以Update
为基类)和PlayerGraphics
(以Graphics
为基类)。在接下来的两部分内容完成后,我们将得到一个可见但功能尚未完全完善的玩家角色。
# 为PlayerUpdate类编写代码
让我们从PlayerUpdate
类的定义开始。在PlayerUpdate.h
中添加以下代码:
#pragma once
#include "Update.h"
#include "InputReceiver.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class PlayerUpdate : public Update
{
private:
const float PLAYER_WIDTH = 20.f;
const float PLAYER_HEIGHT = 16.f;
FloatRect m_Position;
bool* m_IsPaused = nullptr;
float m_Gravity = 165;
float m_RunSpeed = 150;
float m_BoostSpeed = 250;
InputReceiver m_InputReceiver;
Clock m_JumpClock;
bool m_SpaceHeldDown = false;
float m_JumpDuration = .50;
float m_JumpSpeed = 400;
public:
bool m_RightIsHeldDown = false;
bool m_LeftIsHeldDown = false;
bool m_BoostIsHeldDown = false;
bool m_IsGrounded;
bool m_InJump = false;
FloatRect* getPositionPointer();
bool* getGroundedPointer();
void handleInput();
InputReceiver* getInputReceiver();
// 来自Update:Component
void assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate) override;
void update(float fps) 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
38
39
40
41
42
43
44
45
46
47
48
49
在前面的代码中,我们有很多变量和五个函数。下面逐一介绍:
- 名为
PLAYER_WIDTH
的float
常量定义了玩家在游戏单位中的宽度,而名为PLAYER_HEIGHT
的float
常量变量定义了玩家在游戏单位中的高度。 - 名为
m_Position
的FloatRect
实例将保存玩家的位置。我们很快会看到,这个实例将与任何需要它的游戏实体共享。平台会用它来进行碰撞检测,在上一节中我们看到LevelUpdate
类用它来判断玩家是否落后于平台到了游戏结束的程度。 - 布尔指针
m_IsPaused
将用于连接到LevelUpdate
类中用于暂停游戏的变量。 float
变量m_Gravity
用于在玩家没有站在平台上时将其向下拉,并调节向上推进的力。float
变量m_RunSpeed
是玩家在平台上接地时向左或向右移动的速度。float
变量m_BoostSpeed
是玩家在推进时向上移动的速度。- 名为
m_InputReceiver
的InputReceiver
实例是InputReceiver
类的一个实例。很快我们将看到Factory
类如何将m_InputReceiver
连接到InputDispatcher
,从而使PlayerUpdate
类能够访问键盘和鼠标的所有事件。 Clock
实例m_JumpClock
、布尔值m_SpaceHeldDown
、float
变量m_JumpDuration
和float
变量m_JumpSpeed
都是用于调节玩家跳跃距离和时间的值。- 私有部分的第一个变量是布尔值
m_RightIsHeldDown
,后面还有更多布尔值,m_LeftIsHeldDown
、m_BoostIsHeldDown
、m_IsGrounded
和m_InJump
。所有这些值都可以根据玩家与键盘的交互进行设置和取消设置。然后可以在update
函数中对这些值做出响应。 getPositionPointer
函数返回一个FloatRect
指针,为任何需要的其他类提供对m_Position
的访问。需要的类会在工厂中调用这个函数。getGroundedPointer
函数返回一个指向布尔值的指针,该指针共享由m_IsGrounded
布尔变量确定的玩家当前是否接地的信息。handleInput
函数将使用InputReceiver
实例处理在主游戏循环中每一帧从InputDispatcher
实例接收到的所有输入数据。getInputReceiver
函数返回一个指向InputReceiver
实例的指针。实现这个函数只需要一行代码,但它至关重要,因为它允许主函数中的InputDispatcher
实例与PlayerUpdate
类共享所有事件。assemble
函数是我们对Update
类中纯虚函数的实现。参数是shared_ptr<LevelUpdate> levelUpdate
和shared_ptr<PlayerUpdate> playerUpdate
。这意味着我们可以通过调用LevelUpdate
类的任何公共函数来为PlayerUpdate
类的运行做准备。当然,将PlayerUpdate
传递给自己是没有必要的,但这是实现最简单版本的实体组件系统的一种表现。update
函数是我们对Update
类中纯虚函数的实现,它只是接收游戏循环执行所花费的时间。在函数实现中如何利用这个时间会更有意思。
由于我们要在PlayerUpdate.cpp
中添加相当多的代码,所以我们分三步来完成。首先,在PlayerUpdate.cpp
中添加以下代码:
#include "PlayerUpdate.h"
#include "SoundEngine.h"
#include "LevelUpdate.h"
FloatRect* PlayerUpdate::getPositionPointer() {
return &m_Position;
}
bool* PlayerUpdate::getGroundedPointer() {
return &m_IsGrounded;
}
InputReceiver* PlayerUpdate::getInputReceiver() {
return &m_InputReceiver;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在PlayerUpdate
代码的第一部分,我们添加了所需的包含指令。getPositionPointer
函数返回保存玩家位置的FloatRect
实例的地址。getGroundedPointer
函数返回检测玩家当前是否站在平台上的布尔值的地址。有趣的是,平台将使用这个指针来确定并设置这个布尔值,而PlayerGraphics
类将使用这个指针的值来做出关于动画的决策。getInputReceiver
函数返回一个指向InputReceiver
实例的指针,使InputDispatcher
能够连接并发送所有所需的事件数据。第二步,添加以下代码:
void PlayerUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
SoundEngine::SoundEngine();
m_Position.width = PLAYER_WIDTH;
m_Position.height = PLAYER_HEIGHT;
m_IsPaused = levelUpdate->getIsPausedPointer();
}
2
3
4
5
6
7
8
9
10
在PlayerUpdate
代码的第二部分,我们编写了assemble
函数。注意,如前所述,PlayerUpdate
参数未被使用。SoundEngine
类被初始化并准备播放一些声音,位置和高度被初始化,最有意思的是,LevelUpdate
的共享指针被用于调用getIsPausedPointer
函数并初始化m_IsPaused
。现在PlayerUpdate
类可以随时检查游戏是否被暂停。
接下来,你需要为PlayerUpdate.cpp
添加第三部分也是最后一部分代码:
void PlayerUpdate::handleInput()
{
m_InputReceiver.clearEvents();
}
void PlayerUpdate::update(float timeTakenThisFrame)
{
handleInput();
}
2
3
4
5
6
7
8
9
在我们添加的PlayerUpdate.cpp
代码的第三部分也是最后一部分中,handleInput
函数调用m_InputReceiver
实例的clearEvents
函数。这为循环的下一次迭代清除了事件。这目前没有实际作用,因为我们还没有读取任何事件,但我们将在第18章处理这个问题。最后,我们添加了update
函数。它目前所做的只是调用我们刚刚编写的函数;不过,在第18章中,我们将编写一个功能齐全且响应灵敏的玩家角色。
# 为PlayerGraphics类编写代码
我们已经为玩家角色搭建了基本的行为框架。接下来,我们将通过用PlayerGraphics
类扩展Graphics
类来开始编写角色的外观代码。和PlayerUpdate
类一样,我们先从基础部分开始,随着项目的推进再逐步完善。在PlayerGraphics.h
中添加以下代码:
#pragma once
#include "Graphics.h"
// 我们很快会回到这里
//class Animator;
class PlayerUpdate;
class PlayerGraphics : public Graphics {
private:
FloatRect* m_Position = nullptr;
int m_VertexStartIndex = -999;
// 我们很快会回到这里
//Animator* m_Animator;
IntRect* m_SectionToDraw = new IntRect;
IntRect* m_StandingStillSectionToDraw = new IntRect;
std::shared_ptr<PlayerUpdate> m_PlayerUpdate;
const int BOOST_TEX_LEFT = 536;
const int BOOST_TEX_TOP = 0;
const int BOOST_TEX_WIDTH = 69;
const int BOOST_TEX_HEIGHT = 100;
bool m_LastFacingRight = true;
public:
// 来自Component:Graphics
void assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate, IntRect texCoords) override;
void draw(VertexArray& canvas) 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
注意有几处对Animator
类的注释引用。在后面的第18章中,我们将为玩家添加动画,使其看起来像是在奔跑。我们还将为火球上的火焰添加动画。为了尽快让代码能够无错误运行,前面的代码将Animator
类注释掉了。
在前面的代码中,我们有以下变量和函数声明。下面逐一介绍:
FloatRect
变量m_Position
被设置为nullptr
。它将表示玩家的位置。int
变量m_VertexStartIndex
将保存代表玩家的四边形在VertexArray
中的起始位置。所以,当要移动玩家时,我们就知道我们感兴趣的顶点是m_VertexStartIndex
、m_VertexStartIndex + 1
、m_VertexStartIndex + 2
和m_VertexStartIndex + 3
。m_SectionToDraw
变量是一个IntRect
指针。它将保存玩家当前动画帧在纹理图集(texture atlas)中的整数纹理坐标。Animate
类将根据需要操作这些值。我们将在第18章编写Animate
类。IntRect
指针m_StandingStillSectionToDraw
将保存玩家不奔跑时的纹理坐标。shared_ptr<PlayerUpdate> m_PlayerUpdate
变量是一个指向PlayerUpdate
实例的指针。持有指向PlayerUpdate
实例的指针将使这个类能够调用PlayerUpdate
类的所有公共函数并读取其所有公共变量。- 常量
int
类型的BOOST_TEX_LEFT
、BOOST_TEX_TOP
、BOOST_TEX_WIDTH
和BOOST_TEX_HEIGHT
被初始化为纹理图集中代表玩家推进的动画帧的坐标。 - 布尔值
m_LastFacingRight
被初始化为true
,它将跟踪玩家转向的方向。这在制作动画时会用到。 assemble
函数是对Graphics
类中assemble
函数的重写实现。assemble
函数接收一个名为canvas
的VertexArray
引用和一个名为genericUpdate
的shared_ptr<Update>
。看看我们如何处理genericUpdate
会很有意思。在每个不同的Update
派生类中,我们将看到如何将其转换为所需的特定Update
变体,从而提供对其公共函数的访问。assemble
函数还接收一个名为texCoords
的IntRect
实例,它将保存纹理图集中图形的纹理坐标。draw
函数接收VertexArray
的引用,并且每一帧都会被调用。这使得draw
函数能够在游戏的每一帧中根据需要处理移动顶点或纹理坐标。
让我们编写所有这些函数,并开始使用我们一直在讨论的变量。在PlayerGraphics.cpp
中添加以下代码:
#include "PlayerGraphics.h"
#include "PlayerUpdate.h"
void PlayerGraphics::assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate, IntRect texCoords)
{
m_PlayerUpdate =
static_pointer_cast<PlayerUpdate>(genericUpdate);
m_Position =
m_PlayerUpdate->getPositionPointer();
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
canvas[m_VertexStartIndex].texCoords.x = texCoords.left;
canvas[m_VertexStartIndex].texCoords.y =
texCoords.top;
canvas[m_VertexStartIndex + 1].texCoords.x =
texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 1].texCoords.y =
texCoords.top;
canvas[m_VertexStartIndex + 2].texCoords.x =
texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 2].texCoords.y =
texCoords.top + texCoords.height;
canvas[m_VertexStartIndex + 3].texCoords.x =
texCoords.left;
canvas[m_VertexStartIndex + 3].texCoords.y =
texCoords.top + texCoords.height;
}
void PlayerGraphics::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);
}
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
前面的一些代码可能看起来很眼熟,因为我们正在为一个SFML的VertexArray
实例分配顶点和纹理坐标,就像我们在僵尸游戏中为背景所做的那样。我们在每个Graphics
派生类中都会做这样或类似的事情。
不过,这里我们的工作环境与僵尸游戏不同,所以让我们把刚刚添加的代码分成四个部分,来看看它们是如何工作的。
首先,我们有这个函数签名:
void PlayerGraphics::assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate, IntRect texCoords)
{
…
…
}
2
3
4
5
6
7
我们正在看的PlayerGraphics.cpp
的第一部分是assemble
函数的签名。提醒一下,如前所述,assemble
函数是对Graphics
类中assemble
函数的重写实现。assemble
函数接收一个名为canvas
的VertexArray
引用和一个名为genericUpdate
的shared_ptr<Update>
。assemble
函数还接收一个名为texCoords
的IntRect
实例,它将保存纹理图集中图形的纹理坐标。
其次,在assemble
函数的花括号内,我们有以下代码:
m_PlayerUpdate =
static_pointer_cast<PlayerUpdate>(genericUpdate);
m_Position =
m_PlayerUpdate->getPositionPointer();
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
canvas[m_VertexStartIndex].texCoords.x = texCoords.left;
canvas[m_VertexStartIndex].texCoords.y = texCoords.top;
canvas[m_VertexStartIndex + 1].texCoords.x = texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 1].texCoords.y = texCoords.top;
canvas[m_VertexStartIndex + 2].texCoords.x = texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 2].texCoords.y = texCoords.top + texCoords.height;
canvas[m_VertexStartIndex + 3].texCoords.x = texCoords.left;
canvas[m_VertexStartIndex + 3].texCoords.y = texCoords.top + texCoords.height;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PlayerGraphics.cpp
文件的第二部分(上述代码)在assemble
函数的代码中,使用static_pointer_cast
函数将基类Update
实例转换为子类PlayerUpdate
实例,然后将结果保存到m_PlayerUpdate
中。
接下来,我们通过调用canvas.getVertexCount
初始化m_VertexStartIndex
,然后通过调用canvas.resize
为顶点数组增加足够的空间来容纳另一个四边形。接着,在接下来的八行代码中,我们使用作为参数传入的IntRect texCoords
来初始化VertexArray
中所有玩家角色的纹理坐标。
第三点,也是最后一点,我们有以下代码:
void PlayerGraphics::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);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PlayerGraphics.cpp
文件的第三部分,也是最后一部分是draw
函数。目前,我们只是使用m_Position.getPosition
函数初始化一个名为position
的Vector2f
实例,并使用getSize
函数初始化一个名为scale
的Vector2f
实例。然后,我们使用position
和scale
来设置玩家角色在VertexArray
中的顶点位置。
一旦我们添加了一些与相机相关的类,以便正确地看到玩家,并且添加了玩家可以在上面奔跑的平台,我们将完成PlayerUpdate
类的更新和输入处理,以及PlayerGraphics
类的动画和Animator
类(动画器类)的编写。
# 编写工厂类以使用我们所有的新类
工厂(Factory)类是一个重要的类。在这里,我们将创建所有指向派生的Update
和Graphics
实例的智能指针。我们将调用所有的构造函数和assemble
函数的实现,同时共享我们一直在编写的各种所需指针。例如,在工厂类中,我们将把玩家的指针和平台的位置共享给LevelUpdate
实例。
# 记住纹理坐标
首先,我们将在Factory.h
文件中添加代码。在Factory.h
文件的私有部分添加以下变量:
const int PLAYER_TEX_LEFT = 0;
const int PLAYER_TEX_TOP = 0;
const int PLAYER_TEX_WIDTH = 80;
const int PLAYER_TEX_HEIGHT = 96;
const float CAM_VIEW_WIDTH = 300.f;
const float CAM_SCREEN_RATIO_LEFT = 0.f;
const float CAM_SCREEN_RATIO_TOP = 0.f;
const float CAM_SCREEN_RATIO_WIDTH = 1.f;
const float CAM_SCREEN_RATIO_HEIGHT = 1.f;
const int CAM_TEX_LEFT = 610;
const int CAM_TEX_TOP = 36;
const int CAM_TEX_WIDTH = 40;
const int CAM_TEX_HEIGHT = 30;
const float MAP_CAM_SCREEN_RATIO_LEFT = 0.3f;
const float MAP_CAM_SCREEN_RATIO_TOP = 0.84f;
const float MAP_CAM_SCREEN_RATIO_WIDTH = 0.4f;
const float MAP_CAM_SCREEN_RATIO_HEIGHT = 0.15f;
const float MAP_CAM_VIEW_WIDTH = 800.f;
const float MAP_CAM_VIEW_HEIGHT = MAP_CAM_VIEW_WIDTH / 2;
const int MAP_CAM_TEX_LEFT = 665;
const int MAP_CAM_TEX_TOP = 0;
const int MAP_CAM_TEX_WIDTH = 100;
const int MAP_CAM_TEX_HEIGHT = 70;
const int PLATFORM_TEX_LEFT = 607;
const int PLATFORM_TEX_TOP = 0;
const int PLATFORM_TEX_WIDTH = 10;
const int PLATFORM_TEX_HEIGHT = 10;
const int TOP_MENU_TEX_LEFT = 770;
const int TOP_MENU_TEX_TOP = 0;
const int TOP_MENU_TEX_WIDTH = 100;
const int TOP_MENU_TEX_HEIGHT = 100;
const int RAIN_TEX_LEFT = 0;
const int RAIN_TEX_TOP = 100;
const int RAIN_TEX_WIDTH = 100;
const int RAIN_TEX_HEIGHT = 100;
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
在项目的后续过程中,我们将使用所有这些新的常量值。这些变量代表纹理图集(texture atlas)中所有图像的纹理坐标。通常,你会从文件中加载这些值,但这样设置也能满足我们的需求。
最后,为了使用这些新类,我们将在工厂类中实例化并配置它们。
在Factory.cpp
文件中添加以下突出显示的指令,这样我们就可以使用新创建的类:
#include "Factory.h"
#include "LevelUpdate.h"
#include "PlayerGraphics.h"
#include "PlayerUpdate.h"
#include "InputDispatcher.h"
2
3
4
5
在Factory.cpp
文件的loadLevel
函数中添加以下代码,在GameObject
实例中实例化一个LevelUpdate
实例:
// 构建一个关卡游戏对象
GameObject level;
shared_ptr<LevelUpdate> levelUpdate = make_shared<LevelUpdate>();
level.addComponent(levelUpdate);
gameObjects.push_back(level);
2
3
4
5
前面这段代码有很多值得讨论的地方,但正如我们将看到的,它并不像乍看起来那么复杂。
这段代码创建了一个名为level
的新GameObject
实例。接下来,我们创建一个LevelUpdate
类型的共享指针。然后,我们在level
上调用addComponent
函数,并传入LevelUpdate
实例level
。最后,我们在gameObjects
向量上调用push_back
函数。这是重要的一步,因为这意味着我们终于有了一个可以在游戏循环的每一帧中进行遍历的GameObject
实例。细心的读者可能已经注意到,我们还没有调用assemble
函数。我们很快就会讲到。
接下来,在loadLevel
函数中添加以下代码,在GameObject
实例中实例化一个PlayerGraphics
和一个PlayerUpdate
实例:
// 构建一个玩家对象
GameObject player;
shared_ptr<PlayerUpdate> playerUpdate = make_shared<PlayerUpdate>();
playerUpdate->assemble(levelUpdate, nullptr);
player.addComponent(playerUpdate);
inputDispatcher.registerNewInputReceiver( playerUpdate->getInputReceiver());
shared_ptr<PlayerGraphics> playerGraphics = make_shared<PlayerGraphics>();
playerGraphics->assemble(canvas, playerUpdate,
IntRect(PLAYER_TEX_LEFT, PLAYER_TEX_TOP, PLAYER_TEX_WIDTH, PLAYER_TEX_HEIGHT));
player.addComponent(playerGraphics);
gameObjects.push_back(player);
// 让LevelUpdate知道玩家的存在
levelUpdate->assemble(nullptr, playerUpdate);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在前面的代码中,我们创建了另一个名为player
的GameObject
实例和一个名为playerUpdate
的PlayerUpdate
共享指针。我们在playerUpdate
上调用assemble
函数,并传入所需的参数。这些所需参数是LevelUpdate
共享指针,但在应该传入PlayerUpdate
指针的地方我们传入了nullptr
。如前所述,这是因为我们使用的实体组件系统(Entity Component system)做了简化。显然,PlayerUpdate
类不需要自身的副本。
然后,我们在player
上调用addComponent
函数,并在InputDispatcher
实例上调用registerNewInputReceiver
。注意,我们通过调用PlayerUpdate
实例的getInputReceiver
来传入所需的值。此时,我们不仅在gameObjects
向量中有了另一个几乎准备好在游戏循环中进行迭代的GameObject
实例,而且还建立了与SFML提供的所有操作系统事件的连接。现在我们可以继续处理PlayerGraphics
类实例。
接下来,我们实例化一个PlayerGraphics
实例,并调用assemble
函数,传入VertexArray
、LevelUpdate
实例和纹理坐标。现在,我们使用addComponent
函数将GraphicsComponent
实例添加到GameObject
实例中,并调用push_back
将玩家角色的GameObject
添加到gameObjects
向量中。
最后一行代码在levelUpdate
上调用assemble
函数,因为之前由于PlayerUpdate
实例还不存在,我们无法调用。这是工厂类应该具备的知识。
# 运行游戏
如果此时运行游戏,我们仍然会看到空白的灰色屏幕。这是因为我们没有绘制VertexArray
。在下一章中,我们将学习如何绘制VertexArray
两次,以创建常规视图和小地图。我们将通过编写一些类来表示游戏的相机或视图来实现这一点。目前,只需在Run.cpp
的main
函数中,在调用window.display
之前添加以下突出显示的代码行:
// 在下一章之前的临时代码
window.draw(canvas, factory.m_Texture);
// 显示新的一帧。
window.display();
2
3
4
5
现在,如果你运行游戏并仔细观察,非常仔细地观察,在屏幕的左上角,你几乎可以看到一个微小的、静止的玩家图形。我没有提供截图,因为它太小了。继续阅读以找到解决方案。你可能还注意到有一段短音乐在循环播放。如果你希望在后续测试代码时安静地工作,只需从LevelUpdate assemble
函数中删除这两行代码,反正它们只是用于测试目的:
//temp
SoundEngine::startMusic();
2
当我们为游戏编写菜单时,我们将正确处理音乐的播放和停止。
如果你想更清楚地查看玩家图形,可以临时编辑PlayerUpdate.cpp
文件中的assemble
函数,增大玩家的尺寸,如以下两行突出显示的代码所示:
void PlayerUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate) {
SoundEngine::SoundEngine();
m_Position.width = PLAYER_WIDTH * 10;
m_Position.height = PLAYER_HEIGHT * 10;
m_IsPaused = levelUpdate->getIsPausedPointer();
}
2
3
4
5
6
7
8
9
10
运行游戏,你可以清楚地看到玩家在屏幕的左上角,如下图所示:
图16.1:放大后的玩家图像
一定要从之前编辑的两行代码中删除* 10
。
我们本可以很容易地添加一些代码来使玩家角色可控制,但这一章已经有点长了,我们将在几章之后,当我们的相机游戏对象可以正常工作,并且能更好地看到玩家时,再进行这一操作 。
# 总结
在本章中,我们取得了很多成果。我们编写了一个与声音相关的类,它还可以循环播放音乐,并且编写了一个处理所有游戏逻辑的类,并将其封装在一个在游戏循环的每一次循环中运行一次的GameObject
中。
我们使用组合在游戏对象中的图形组件和更新组件,编写了可玩角色的初始版本。这是实体组件系统的核心。我们游戏中的每种实体都将重复这个过程。
我们继续编写工厂类,它负责组装所有不同的游戏对象,并在它们之间共享适当的数据。
在下一章中,我们将专注于图形和绘制,编写同样派生自Graphics
和Update
的CameraGraphics
和CameraUpdate
类。