第19章 构建菜单与实现下雨效果
# 第19章 构建菜单与实现下雨效果
在本章中,我们将实现两个重要功能。其中一个是创建一个菜单界面,让玩家清楚知道开始、暂停、重新开始和退出游戏的选项。另一个任务是创建一个简单的下雨效果。你可能会说下雨效果并非必要,甚至觉得它与游戏不搭,但这个效果实现起来很简单,也很有趣,是一项值得学习的技巧。到现在你应该有所预期了,不过本章最有意思的地方或许还是,我们将如何通过编写从Graphics
和Update
派生的类来实现这两个目标,把这些类组合到GameObject
实例中,让它们能与游戏中的其他实体协同工作。
本章内容如下:
- 构建交互式菜单
- 编写
MenuUpdate
类代码 - 编写
MenuGraphics
类代码 - 在工厂(factory)中构建菜单
- 实现下雨效果
- 编写
RainGraphics
类代码 - 在工厂中实现下雨效果
Run5
文件夹中的代码展示了本章结束时的完整状态。
# 构建交互式菜单
首先,我们来看看玩家眼中菜单的两种可能状态。
图19.1:菜单的两种状态
从上图中可以看到这两种情况。左边的画面告知玩家可以按Esc键开始游戏或按F1键退出游戏;右边的画面则显示玩家可以按Esc键继续游戏或按F1键退出游戏。之所以会有这种细微差别,是因为在游戏进行过程中,玩家也可以通过按Esc键暂停游戏。在任意一个菜单界面中,按F1键始终是退出游戏,但在游戏进行时,按F1键没有任何作用。
# 编写MenuUpdate类代码
现在,我们要创建一个新类来控制游戏内的菜单。创建一个新类MenuUpdate
,它继承自Update
,同时创建一个新类MenuGraphics
,继承自Graphics
。
现在,我们可以开始编写代码了。在MenuUpdate.h
中添加以下代码:
#pragma once
#include "Update.h"
#include "InputReceiver.h"
#include <SFML/Graphics.hpp>
using namespace sf;
using namespace std;
class MenuUpdate : public Update
{
private:
FloatRect m_Position;
InputReceiver m_InputReceiver;
FloatRect* m_PlayerPosition = nullptr;
bool m_IsVisible = false;
bool* m_IsPaused;
bool m_GameOver;
RenderWindow* m_Window;
public:
MenuUpdate(RenderWindow* window);
void handleInput();
FloatRect* getPositionPointer();
bool* getGameOverPointer();
InputReceiver* getInputReceiver();
//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
32
33
34
35
下面介绍上述代码中的所有成员变量和函数:
FloatRect
类型的变量m_Position
将用于存储水平和垂直位置以及尺寸信息。InputReceiver
实例m_InputReceiver
的作用与PlayerUpdate
和CameraUpdate
中同名变量的作用完全相同,只不过它会按照我们前面讨论菜单功能时预期的那样,对Esc键和F1键做出响应。FloatRect
指针m_PlayerPosition
将追踪玩家的位置,这样当菜单需要显示时,就可以根据玩家的位置来确定自身的位置。- 布尔型变量
m_IsVisible
用于告知菜单何时显示、何时隐藏。 - 布尔型指针
m_IsPaused
将保存LevelUpdate
类中定义游戏是否暂停的变量地址。然后,结合m_IsVisible
和m_GameOver
这两个变量,菜单就能知道何时显示自身以及显示哪张图片。布尔型变量m_GameOver
与前面这两个变量协同工作。 RenderWindow
指针m_Window
赋予菜单关闭应用程序窗口、结束应用程序运行的能力。MenuUpdate
构造函数用于为MenuUpdate
类执行其任务做准备,它会处理assemble
函数无法处理的细节。handleInput
函数在update
函数中每帧调用一次,用于处理主游戏循环中InputDispatcher
发送的操作系统事件。getPositionPointer
函数返回一个指向菜单位置和缩放信息的FloatRect
指针。getGameOverPointer
函数返回一个布尔型变量的地址,该变量用于指示玩家失败导致游戏结束的情况。getInputReceiver
函数将InputReceiver
实例的地址返回给InputDispatcher
。- 重写的
update
函数在游戏循环的每次迭代中执行。很快我们就会看到具体的代码。 - 正如我们所预期的,重写的
assemble
函数返回void
类型,有以下参数:shared_ptr<LevelUpdate> levelUpdate
和shared_ptr<PlayerUpdate> playerUpdate
。稍后我们将编写这个函数的代码,展示该类使用它的特定但相似的方式。
现在我们可以继续实现MenuUpdate
类。我们将在MenuUpdate.cpp
中分四个主要部分添加代码。为了使这些部分正常工作,我们首先需要添加以下包含指令:
#include "MenuUpdate.h"
#include "LevelUpdate.h"
#include "PlayerUpdate.h"
#include "SoundEngine.h"
2
3
4
在MenuUpdate.cpp
中添加第一部分主要代码:
MenuUpdate::MenuUpdate(RenderWindow* window)
{
m_Window = window;
}
FloatRect* MenuUpdate::getPositionPointer()
{
return &m_Position;
}
bool* MenuUpdate::getGameOverPointer()
{
return &m_GameOver;
}
InputReceiver* MenuUpdate::getInputReceiver()
{
return &m_InputReceiver;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在上述MenuUpdate.cpp
代码的第一部分中,有构造函数,用于初始化指向RenderWindow
的指针。当玩家按下F1键关闭游戏时,我们将使用这个指向RenderWindow
的指针。getPositionPointer
函数返回指向m_Position
的指针。当然,另一个关心菜单位置的类是MenuGraphics
类,它需要绘制菜单。
getGameOverPointer
函数返回布尔型变量m_GameOver
的地址。getInputReceiver
函数(与PlayerUpdate
和CameraUpdate
中的用法一样)用于获取指向InputReceiver
实例的指针。同样,InputDispatcher
会使用这个指针,这样它就知道每帧中应该把所有操作系统事件发送到哪里。
接下来,在MenuUpdate.cpp
中添加assemble
函数:
void MenuUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition =
playerUpdate->getPositionPointer();
m_IsPaused =
levelUpdate->getIsPausedPointer();
m_Position.height = 75;
m_Position.width = 75;
SoundEngine::startMusic();
SoundEngine::pauseMusic();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在上述MenuUpdate.cpp
代码的第二部分中,assemble
函数为使用该类做准备。首先,将玩家位置的地址初始化为m_PlayerPosition
,并将LevelUpdate
实例中表示暂停状态的布尔型变量的地址复制到m_IsPaused
。其次,菜单的宽度和高度由一个神奇数字(这里是75)定义。最后,启动音乐,然后立即暂停。现在,音乐已准备好,无论玩家何时恢复或暂停游戏,都可以恢复和暂停音乐播放。
现在,在MenuUpdate.cpp
中添加第三部分代码。接下来展示handleInput
函数:
void MenuUpdate::handleInput()
{
for (const Event& event :
m_InputReceiver.getEvents())
{
if (event.type ==
Event::KeyPressed)
{
if (event.key.code ==
Keyboard::F1 && m_IsVisible)
{
if (SoundEngine::mMusicIsPlaying)
{
SoundEngine::stopMusic();
}
m_Window->close();
}
}
if (event.type == Event::KeyReleased)
{
if (event.key.code == Keyboard::Escape)
{
m_IsVisible = !m_IsVisible;
*m_IsPaused = !*m_IsPaused;
if (m_GameOver)
{
m_GameOver = false;
}
if (!*m_IsPaused)
{
SoundEngine::resumeMusic();
SoundEngine::playClick();
}
if (*m_IsPaused)
{
SoundEngine::pauseMusic();
SoundEngine::playClick();
}
}
}
}
m_InputReceiver.clearEvents();
}
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
在第三部分中,handleInputFunction
函数对于我们在之前项目和本项目中创建的所有事件循环来说应该很熟悉。for
循环遍历游戏当前帧的所有输入事件,包含两个if
语句。
第一个if
语句检查是否同时满足按下F1键和菜单可见这两个条件。如果音乐正在播放,则停止音乐,然后使用RenderWindow
指针关闭窗口,结束游戏。
第二个if
语句以及进一步嵌套的if
语句检查Esc键的释放情况。首先,将m_IsVisible
的值取反。如果它原本为true
,则变为false
;如果原本为false
,则变为true
。这正是我们需要的效果。每次玩家点击Esc键,游戏状态都会在暂停和运行之间切换。对m_IsVisible
进行同样的取反操作,以显示和隐藏菜单。
此时,布尔型变量的状态已正确设置,代码会根据当前状态采取相应的操作。如果游戏结束(m_GameOver
为true
),则将m_GameOver
设置为false
。如果游戏未暂停,则恢复音乐播放并播放点击音效;最后,如果游戏暂停,则暂停音乐播放并播放点击音效。
在事件for
循环之外,通过调用clearEvents
函数,将m_InputReceiver
实例中的所有事件清除,为下一次主游戏循环迭代做准备。
最后,在MenuUpdate.cpp
中为update
函数添加以下代码:
void MenuUpdate::update(float fps)
{
handleInput();
if (*m_IsPaused && !m_IsVisible)// Game over 1
{
m_IsVisible = true;
m_GameOver = true;
}
if (m_IsVisible)
{
// Follow the player
m_Position.left =
m_PlayerPosition->getPosition()–x - m_Position.width / 2;
m_Position.top =
m_PlayerPosition->getPosition()–y - m_Position.height / 2;
}
else
{
m_Position.left = -999;
m_Position.top = -999;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在前面的最后一部分中,MenuUpdate
类的update
函数首先调用我们之前编写的handleInput
函数。当游戏暂停且菜单不可见时,执行第一个if
语句。该if
语句中的代码将paused
设置为true
,将m_GameOver
设置为true
。
update
函数中的第二个if
语句在菜单可见时执行。当菜单可见时,确保菜单实际可见是有意义的。为此,将m_Position.left
和m_Position.top
分别初始化为玩家位置的左边和顶部,再减去玩家的宽度和高度。这样可以将菜单定位在屏幕中心,位于玩家上方。
在最后一个else
子句中(当游戏未暂停时执行),我们将m_Position.left
和m_Position.top
初始化为-999
,这将隐藏菜单。
现在,我们可以继续研究MenuGraphics
类,看看MenuUpdate
和MenuGraphics
是如何相互配合的。
# 编写MenuGraphics类
首先,将以下代码添加到MenuGraphics.h
文件中:
#pragma once
#include "Graphics.h"
#include "SFML/Graphics.hpp"
class MenuGraphics : public Graphics {
private:
FloatRect* m_MenuPosition = nullptr;
int m_VertexStartIndex;
bool* m_GameOver;
bool m_CurrentStatus = false;
int uPos;
int vPos;
int texWidth;
int texHeight;
public:
// 从Graphics:Component继承
void draw(VertexArray& canvas) override;
void assemble(VertexArray& canvas,
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
在上述代码中,FloatRect
指针m_MenuPosition
被初始化为nullptr
。这个变量很快会被初始化,用于跟踪MenuUpdate
类中的m_Position FloatRect
实例。
整数m_VertexStartIndex
变量将被初始化,用于记住在为游戏每一帧绘制的VertexArray
(顶点数组)中代表菜单的四边形顶点的起始索引。
布尔指针m_GameOver
将被初始化,用于跟踪MenuUpdate
类中的m_GameOver
变量。
布尔值m_CurrentStatus
将用于做出决策并记住这些决策,它会先初始化为m_GameOver
的值,然后检测其变化。当我们在MenuGraphics.cpp
中看到相关代码时,就会更清楚它的作用。
整数uPos
将保存水平纹理坐标,vPos
将保存垂直纹理坐标,texWidth
表示纹理宽度,texHeight
表示纹理高度。
第一个公有函数是重写的draw
函数,我们对它的签名非常熟悉;很快我们就会看到如何编写它的代码。
assemble
函数的参数都是常见的,我们已经多次见过。我们很快就会编写它的代码,看看如何组装MenuGraphics
类。
对于MenuGraphics.cpp
文件,我们将分两部分编写代码:assemble
函数和draw
函数。
将以下assemble
函数添加到MenuGraphics.cpp
中:
#include "MenuGraphics.h"
#include "MenuUpdate.h"
void MenuGraphics::assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
m_MenuPosition = static_pointer_cast<MenuUpdate>(genericUpdate)->getPositionPointer();
m_GameOver = static_pointer_cast<MenuUpdate>(genericUpdate)->getGameOverPointer();
m_CurrentStatus = *m_GameOver;
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
// 记住UV坐标
// 因为我们稍后会对它们进行操作
uPos = texCoords.left;
vPos = texCoords.top;
texWidth = texCoords.width;
texHeight = texCoords.height;
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y = vPos + texHeight;
canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y = vPos + texHeight;
canvas[m_VertexStartIndex + 2].texCoords.x = uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight + 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
上述assemble
函数代码从这一行开始:
m_MenuPosition = static_pointer_cast<MenuUpdate>(genericUpdate)->getPositionPointer();
static_pointer_cast
函数将当前类型为Update
的genericUpdate
实例转换为特定的MenuUpdate shared_ptr
实例。在转换后的同一行代码中,对MenuUpdate
实例调用getPositionPointer
函数。该函数调用的返回结果存储在m_MenuPosition
中。
下一行代码使用相同的转换技术,但调用的是getGameOverPointer
函数,并将结果存储在m_GameOver
中。
接下来,对m_GameOver
进行解引用,并将整数值存储在m_CurrentStatus
中。
接着,通过获取VertexArray
的当前大小来初始化m_StartIndex
变量,然后通过调用canvas.resize
增加VertexArray
的大小,以容纳另一个四边形。
接下来,我们通过从传入的纹理坐标初始化uPos
、vPos
、texWidth
和texHeight
来记住纹理坐标。请记住,这些值会被存储起来,并且很快会从Factory
类传入。
接下来的八行代码将纹理坐标直接初始化为VertexArray
。我们需要将原始纹理坐标与VertexArray
分开存储的原因是,我们很快会在update
函数中添加代码,通过操作纹理坐标来显示游戏暂停(与游戏结束相对)时菜单的不同版本。
最后,对于MenuGraphics.cpp
和MenuGraphics
类,将以下draw
函数添加到MenuGraphics.cpp
中:
void MenuGraphics::draw(VertexArray& canvas)
{
if (*m_GameOver && !m_CurrentStatus)
{
// 当前状态刚刚切换为游戏结束
m_CurrentStatus = *m_GameOver;
// 每个v坐标都加倍以引用下面的纹理
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y = vPos + texHeight;
canvas[m_VertexStartIndex + 1].texCoords.x = uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y = vPos + texHeight;
canvas[m_VertexStartIndex + 2].texCoords.x = uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y = vPos + texHeight + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x = uPos;
canvas[m_VertexStartIndex + 3].texCoords.y = vPos + texHeight + texHeight;
}
else if (!*m_GameOver && m_CurrentStatus)
{
m_CurrentStatus = *m_GameOver;
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;
}
const Vector2f& position = m_MenuPosition->getPosition();
canvas[m_VertexStartIndex].position = position;
canvas[m_VertexStartIndex + 1].position = position + Vector2f(m_MenuPosition->getSize().x, 0);
canvas[m_VertexStartIndex + 2].position = position + m_MenuPosition->getSize();
canvas[m_VertexStartIndex + 3].position = position + Vector2f(0, m_MenuPosition->getSize().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
在上述draw
函数中,代码有一个if
分支和一个else if
分支。当m_GameOver
为true
且m_CurrentStatus
为false
时,执行if
分支。当m_GameOver
为false
且m_CurrentStatus
为false
时,执行else if
分支。
首先,让我们看看if
分支中发生了什么。在if
分支中,m_CurrentStatus
被设置为m_GameOver
的解引用值,然后设置VertexArray
中的所有纹理坐标。设置方式与我们在assemble
函数中使用的相同。这些值映射到纹理图集(texture atlas)中菜单的较低版本。
接下来,让我们看看else if
分支中发生了什么。在else if
分支中,m_CurrentStatus
再次与m_GameOver
同步,并且设置VertexArray
中的所有纹理坐标。然而,看看所有垂直坐标的代码。它们都没有额外的+texHeight
。这意味着这些坐标现在映射到纹理图集中菜单图形的较高版本。每当玩家输掉游戏以及玩家重新开始游戏后暂停游戏时,纹理坐标都会翻转。因此,纹理坐标将始终映射到暂停菜单或游戏结束菜单。
当然,我们还没有对顶点进行定位。我们必须这样做,因为在MenuUpdate
类中,随着菜单的显示和隐藏,这些位置会经常变化。在我们刚刚讨论的if-else-if
结构之后,会使用m_MenuPosition
中的值来定位VertexArray
中的顶点位置,m_MenuPosition
指向MenuUpdate
中的FloatRect
。
为了使代码不那么冗长,我们首先通过调用m_MenuPosition->getPosition
初始化一个常量Vector2f
。
# 在工厂类中构建菜单
现在我们可以通过将GameObject
实例与我们的两个新类组合来实例化一个可用的菜单。添加我们两个与菜单相关的类的包含指令:
#include "MenuUpdate.h"
#include "MenuGraphics.h"
2
接下来,在Factory.cpp
文件中loadLevel
函数的右花括号之前添加以下代码:
// Menu
GameObject menu;
shared_ptr<MenuUpdate> menuUpdate = make_shared<MenuUpdate>(m_Window);
menuUpdate->assemble(levelUpdate, playerUpdate);
inputDispatcher.registerNewInputReceiver(menuUpdate->getInputReceiver());
menu.addComponent(menuUpdate);
shared_ptr<MenuGraphics> menuGraphics = make_shared<MenuGraphics>();
menuGraphics->assemble(canvas, menuUpdate, IntRect(TOP_MENU_TEX_LEFT,
TOP_MENU_TEX_TOP,
TOP_MENU_TEX_WIDTH,
TOP_MENU_TEX_HEIGHT));
menu.addComponent(menuGraphics);
gameObjects.push_back(menu);
// End menu
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
上述代码应该看起来很熟悉。我们按以下顺序进行操作:
- 创建一个名为
menu
的新GameObject
实例。 - 创建一个指向
MenuUpdate
实例的共享指针,名为menuUpdate
。 - 调用
menuUpdate
的assemble
函数,传入levelUpdate
和playerUpdate
共享指针。 - 通过在
inputDispatcher
上调用registerNewInputReceiver
并传入menuUpdate->getInputReceiver
的返回结果,使菜单准备好接收更新。 - 将
menuUpdate
添加到menu GameObject
实例中。 - 创建一个
MenuGraphics
类型的共享指针。 - 调用
menuGraphics
的assemble
函数,并传入VertexArray
、LevelUpdate
实例以及所有必需的纹理坐标。 - 将
MenuGraphics
实例添加/组合到GameObject
实例中。 - 最后,将代表我们菜单的
GameObject
实例添加到gameObjects
向量中。
就是这样。我们的菜单完成了。
# 运行游戏
现在你可以运行游戏了。如果你按下Esc键,你会看到如下所示的菜单:
图19.2:菜单
你将能够按照菜单上的提示按下F1键退出游戏。然而,如果你尝试按下Esc键开始游戏,它也会退出游戏。这不是我们想要的行为。我们需要进行两个快速更改。
我们需要删除在项目开始时添加到InputDispatcher.cpp
类中的临时代码。找到以下代码行并删除它们:
if (event.type == Event::KeyPressed && event.key.code == Keyboard::Escape)
{
m_Window->close();
}
2
3
4
现在你可以运行游戏了,按下Esc键可以开始和暂停游戏,当菜单可见时,按下F1键可以退出游戏。InputDispatcher
类不再处理任何事件;它只是将事件分发给菜单、玩家和小地图相机中的InputReceiver
实例。
我们还需要阻止游戏自动开始。我们在LevelUpdate.h
类中进行此操作。找到下一行代码:
bool m_IsPaused = false;
现在将其更改为:
bool m_IsPaused = true;
现在我们的暂停、开始和退出功能按预期工作。
# 实现下雨效果
我们只需要一个图形组件。这没问题,就像在关卡逻辑中只使用更新组件一样。虽然“雨图形”(RainGraphics)状态会发生变化,但它与主游戏循环的时间或玩家输入没有任何依赖关系。所有状态变化都由“雨图形”类内部的“动画控制器”(Animator)类实例控制,该实例有自己的内部时钟。我们将生成多个“雨图形”类的实例,因为每个实例只会覆盖屏幕的一小部分。每个“雨图形”实例会根据玩家的位置进行定位,并在游戏世界中跟随玩家,给人一种到处都在下雨的感觉。
# 编写“雨图形”类
纹理集(texture atlas)中的图形如下所示:
图19.3:纹理集中的雨
我用红色框出了每一帧动画,并将背景从透明改为了白色。每一帧动画都是100像素×100像素。因此,整个雨的精灵图(sprite sheet)是400像素×100像素。所有帧都排列整齐,准备由我们的“动画控制器”类循环播放,我们也用这个类来为玩家进行动画处理。
创建一个名为“RainGraphics”的新类,并在“RainGraphics.h”文件中添加以下代码:
#pragma once
#include "Graphics.h"
class Animator;
class RainGraphics : public Graphics
{
private:
FloatRect* m_PlayerPosition;
int m_VertexStartIndex;
Vector2f m_Scale;
float m_HorizontalOffset;
float m_VerticalOffset;
Animator* m_Animator;
IntRect* m_SectionToDraw;
public:
RainGraphics(FloatRect* playerPosition,
float horizontalOffset,
float verticalOffset,
int rainCoveragePerObject);
// 来自Graphics:Component
void draw(VertexArray& canvas) override;
void assemble(VertexArray& canvas,
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
24
25
26
27
28
29
30
31
在上述“RainGraphics.h”代码中,FloatRect
指针m_PlayerPosition
将跟踪玩家的位置,这样雨就能像卡通片中被乌云跟随的倒霉角色一样,跟着玩家到处跑。
m_VertexStartIndex
用于记住顶点数组(VertexArray)中顶点的起始索引。
m_Scale
变量用于记住一帧动画的大小。浮点型变量m_HorizontalOffset
是纹理集中图形的起始水平值,浮点型变量m_VerticalOffset
则是对应的垂直值。
m_Animator
实例是我们的“动画控制器”,IntRect
指针m_SectionToDraw
将保存当前动画帧的纹理坐标。
“雨图形”类的构造函数接收并初始化我们刚才讨论的一些变量,以及一个名为rainCoveragePerObject
的整数,这个整数有助于我们调整每个雨实例的大小。
draw
和assemble
函数是常见的重写函数,其声明与之前相同,但它们的实现会很有趣,我们很快就会讨论到。
接下来,对于“RainGraphics.cpp”文件,我们分两部分进行编码,首先是构造函数,然后是assemble
函数。在“RainGraphics.cpp”中添加以下代码:
#include "RainGraphics.h"
#include "RainGraphics.h"
#include "Animator.h"
RainGraphics::RainGraphics(
FloatRect* playerPosition,
float horizontalOffset,
float verticalOffset,
int rainCoveragePerObject)
{
m_PlayerPosition = playerPosition;
m_HorizontalOffset = horizontalOffset;
m_VerticalOffset = verticalOffset;
m_Scale.x = rainCoveragePerObject;
m_Scale.y = rainCoveragePerObject;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在构造函数中,我们初始化玩家位置指针、“动画控制器”的水平和垂直偏移量,以及m_Scale
(它是一个IntRect
类型),x
和y
使用相同的值。我们很快就会看到它的用途。
接下来,添加assemble
函数,如下所示:
void RainGraphics::assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
m_Animator = new Animator(
texCoords.left,
texCoords.top,
4, // 帧数
texCoords.width * 4,
texCoords.height,
8); // 帧率
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在assemble
函数中,我们通过调用new
并传入所需参数来初始化“动画控制器”实例。注意,正如预期的那样,这里有四帧,并且我们指定了每秒八帧。
我们记住起始索引,并在顶点数组中为表示这部分雨的四边形添加四个空间,就像我们之前做过很多次的那样。不过要记住,“雨图形”类会有多个实例。
最后,在“RainGraphics.cpp”中添加draw
函数,如下所示:
void RainGraphics::draw(VertexArray& canvas)
{
const Vector2f& position =
m_PlayerPosition->getPosition()
- Vector2f(m_Scale.x / 2 + m_HorizontalOffset, m_Scale.y / 2 + m_VerticalOffset);
// 移动雨以跟上玩家
canvas[m_VertexStartIndex].position = position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(m_Scale.x, 0);
canvas[m_VertexStartIndex + 2].position = position + m_Scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, m_Scale.y);
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;
}
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
在draw
函数中,第一部分代码使用玩家的位置来移动雨,以便无论玩家移动到哪里,雨都能跟上。注意,在第一行代码中,m_HorizontalOffset
和m_VerticalOffset
的值用于确保该实例相对于其他所有“雨图形”实例处于正确的位置。你可能还记得,这些偏移值是在我们之前编写的构造函数中传入的。正如你可能预期的那样,多个“雨图形”实例及其偏移量将在“工厂”(Factory)类中创建时进行协调。
接下来,调用“动画控制器”类的getCurrentFrame
函数来获取当前纹理坐标,然后用通常的八行代码为四边形的四个顶点分配适当的x
和y
坐标。
# 在工厂类中实现下雨效果
首先,在“Factory.cpp”中添加以下包含指令:
#include "RainGraphics.h"
在平台代码之后、相机代码之前添加以下代码,以创建多个“雨图形”实例:
// 雨
int rainCoveragePerObject = 25;
int areaToCover = 350;
for (int h = -areaToCover / 2; h < areaToCover / 2;
h += rainCoveragePerObject)
{
for (int v = -areaToCover / 2; v < areaToCover / 2;
v += rainCoveragePerObject)
{
GameObject rain;
shared_ptr<RainGraphics> rainGraphics =
make_shared<RainGraphics>(
playerUpdate->getPositionPointer(), h, v, rainCoveragePerObject);
rainGraphics->assemble(
canvas, nullptr,
IntRect(RAIN_TEX_LEFT, RAIN_TEX_TOP, RAIN_TEX_WIDTH, RAIN_TEX_HEIGHT));
rain.addComponent(rainGraphics);
gameObjects.push_back(rain);
}
}
// 雨结束
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在上述代码中,我们做了一些常规操作:创建一个GameObject
实例,创建一个“雨图形”实例,将“雨图形”实例添加到GameObject
中,并将GameObject
添加到GameObject
向量中。为什么不花点时间找出这些现在已经很熟悉的部分呢?然后,我将介绍新的内容。
新的内容是,我们首先声明了一些额外的变量来控制多个“雨图形”实例的位置和大小,如下所示:
int rainCoveragePerObject = 25;
int areaToCover = 350;
2
接下来,注意用于迭代并创建多个实例的for
循环结构。如下所示:
for (int h = -areaToCover / 2; h < areaToCover / 2;
h += rainCoveragePerObject)
2
for
循环的条件意味着h
将从 -175 变化到175,并且每次增加25。在“雨图形”实例中,所有这些值都是世界单位,而不是像素。
初始化v
参数的内层for
循环使用与外层for
循环相同的公式。最后,注意对“雨图形”类构造函数的调用:
shared_ptr<RainGraphics> rainGraphics =
make_shared<RainGraphics>(
playerUpdate->getPositionPointer(), h, v, rainCoveragePerObject);
2
3
总的来说,这会在玩家周围循环创建一个14×14(共196个)的“雨图形”实例块。
# 运行游戏
现在我们可以看到努力的成果并运行游戏了。
图19.4:雨
下雨效果就完成啦!
# 总结
在本章中,我们通过编写“菜单更新”(MenuUpdate)类和“菜单图形”(MenuGraphics)类,创建了一个具有两种外观的交互式菜单。之后,我们在“工厂”类中以与前几章为游戏添加功能相同的方式创建了一个菜单。
最后,我们创建了一个新的派生自“图形”(Graphics)的类,名为“雨图形”(RainGraphics),它创建了一个简单而有效的下雨效果。像往常一样,我们在“工厂”类中将这个类封装在一个GameObject
实例中,放入gameObjects
向量中,然后,瞧,它就生效了。
在下一章中,我们将在游戏中添加从左侧或右侧飞来的火球,以干扰玩家的进程。