第17章 图形、相机与动作
# 第17章 图形、相机与动作
我们需要深入探讨本项目中图形的工作方式。由于本章我们将编写负责绘图的相机相关代码,现在讨论图形相关内容正合适。如果你查看graphics
文件夹,会发现里面只有一张图片。此外,到目前为止,我们的代码中任何地方都没有调用window.draw
。我们将讨论为何要尽量减少绘制调用次数,还会实现相机类来帮我们处理这个问题。最后,在本章结束时,我们将能够运行游戏,看到相机发挥作用,包括主视图、雷达视图和计时器文本。
本章完整的代码位于Run3
文件夹中。以下是本章的主要内容:
- 相机、绘制调用和SFML视图(SFML View)
- 编写相机类
- 向游戏中添加相机实例
- 运行游戏
本章代码位于Run3
文件夹中。
# 相机、绘制调用和SFML视图
在我们之前的所有项目中,游戏里的所有实体(只有一个例外)都是用精灵(sprite)进行图形化表示的。当绘制的实体只有几个、几十个,甚至几百个时,这种方式没问题。这一点很重要,因为SFML绘制游戏每一帧的速度,与我们调用window.draw
的次数直接相关。这并非SFML的缺陷,而是与OpenGL对显卡的使用方式直接相关。
原因在于,每次调用draw
时,幕后会发生很多操作来设置OpenGL,使其为绘制做好准备。引用SFML官网的话来说:
“……每次[对
draw
的]调用都涉及设置一组OpenGL状态、重置矩阵、更改纹理等等。即使只是绘制两个三角形(一个精灵),所有这些操作都是必要的。”
所以,只要有可能,使用顶点数组(vertex array)并通过一次绘制调用渲染多个图像,是个非常好的主意。这意味着我们需要从整体上改变处理图形的方式。我们不再为每个游戏对象都创建一个精灵,而是在顶点数组中设置一个起始索引;不再使用数百个精灵和许多纹理,而是使用单个顶点数组和包含所有图形的一张纹理。此外,想想每次我们在屏幕上绘制分数、时间或其他任何文本时,也都进行了一次绘制调用。SFML中与文本相关的类非常有用,我们不会停止使用它们,而且在这个项目中,我们也只有一处会用到SFML的文本功能,指的就是屏幕左上角显示的时间。
与菜单相关的文本是静态的,无需计算,因此是从纹理图集(texture atlas)中的图像绘制的。如果你想知道,要是有大量文本需要显示,如何尽量减少绘制调用次数,答案是,你可以像处理常规图形一样处理文本,提供一个包含字母表、数字0到9以及所需标点符号的纹理图集。这比使用SFML的文本类要复杂得多,但也并非难以实现。
这只需要让当前帧中绘制的每个字符(数字、字母等)在顶点数组中都有一个索引(以及相关坐标),然后解析要绘制到屏幕上的字符串,并将合适的顶点位置与合适的纹理坐标进行分层。等你完成这个项目时,你可能已经做过五六次甚至更多次这样的操作,到那时就不会觉得神秘了。当然,在僵尸游戏中,我们用顶点数组来绘制背景,在前一章中,我们也已经将纹理坐标与顶点数组索引结合起来绘制玩家图形。
现在我们将编写相机相关代码。相机将在需要时负责调用绘制函数。我们会有两个相机。普通相机将调用draw
两次:一次用于绘制顶点数组,一次用于绘制屏幕左上角的计时器。小地图相机将调用draw
一次,为玩家展示一个不同视角的世界,类似雷达视图。
为实现这一点,每个相机都需要访问同一个渲染窗口(RenderWindow)实例,并且需要调整SFML视图(View)实例的设置,该实例定义了相机在屏幕上的位置、长宽比以及用于特定目的的缩放级别。
# 编写相机类
我们的游戏中将有两个相机,每个相机都有一个继承自Update
的类和一个继承自Graphics
的类。CameraUpdate
类将通过InputReceiver
实例来处理相机跟随玩家的移动,以及与操作系统的交互。CameraGraphics
类将通过引用CameraUpdate
中的数据,并持有纹理图集、渲染窗口实例和一个SFML文本对象的副本,来处理所有绘制操作。在后面的第21章,我们会引入更多功能(和绘制调用),添加视差背景(parallax background)和一种很棒的着色器效果(shader effect)。
# 编写CameraUpdate类
创建两个新类来表示相机:CameraUpdate
,其基类为Update
;CameraGraphics
,其基类为Graphics
。正如我们所预期的,这些类将被包装或组合在一个GameObject
实例中,以便在游戏循环中使用。
在CameraUpdate.h
文件中添加以下代码:
#pragma once
#include "Update.h"
#include "InputReceiver.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class CameraUpdate : public Update
{
private:
FloatRect m_Position;
FloatRect* m_PlayerPosition;
bool m_ReceivesInput = false;
InputReceiver* m_InputReceiver = nullptr;
public:
FloatRect* getPositionPointer();
void handleInput();
InputReceiver* getInputReceiver();
//From 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
在上述代码中,声明了名为m_Position
的FloatRect
类型变量,用于存储相机的位置。浮点型矩形非常适合在世界空间中存储相机视图的顶部、左侧、长度和宽度,而不是整数值的像素位置。
名为m_PlayerPosition
的FloatRect
类型变量是一个指针,它将被初始化为PlayerUpdate
类中FloatRect
实例的地址。这将使CameraUpdate
类能够跟随玩家在游戏世界中移动。
布尔变量m_ReceivesInput
很有用,因为我们的相机中只有一个需要接收输入。主相机只需跟随玩家角色移动,不需要玩家控制,所以我们无需在主相机中处理接收和处理输入的额外开销,只在小地图相机中处理即可。默认情况下,这个布尔变量被初始化为false
。
InputReceiver
指针m_InputReceiver
用于向InputDispatcher
注册。默认情况下,它被设置为nullptr
,因为如前所述,我们的两个相机中只有一个需要它。
getPositionPointer
函数将返回定义相机视图的FloatRect
实例的地址,这样CameraGraphics
类就能跟踪CameraUpdate
类。总之,这个类(CameraUpdate
)将跟踪玩家,而CameraGraphics
类将跟踪这个类。
handleInput
函数将在游戏循环的每次迭代中,从update
函数中被调用,但只在需要它的相机中调用。
getInputReceiver
函数由InputDispatcher
类在工厂类中调用。这样它就能访问并存储指向CameraUpdate
中InputReceiver
的指针。
assemble
函数将为这个类的运行做准备。提醒一下,参数是一个名为levelUpdate
的shared_ptr<LevelUpdate>
,以及一个名为playerUpdate
的shared_ptr<PlayerUpdate>
。这是我们从Update
类重写的第一个函数。我们很快就会看到如何使用这些参数。
update
函数在每一帧被调用一次,它接收主游戏循环的持续时间。现在我们来看看如何实现这些函数以及使用所有这些变量。
我们分几个部分来编写这些函数的实现代码。首先,在CameraUpdate.cpp
文件中添加以下内容:
#include "CameraUpdate.h"
#include "PlayerUpdate.h"
FloatRect* CameraUpdate::getPositionPointer()
{
return &m_Position;
}
void CameraUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition =
playerUpdate->getPositionPointer();
}
InputReceiver* CameraUpdate::getInputReceiver()
{
m_InputReceiver = new InputReceiver;
m_ReceivesInput = true;
return m_InputReceiver;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getPositionPointer
函数只是返回m_Position
变量的地址。assemble
函数只是将玩家位置的地址存储在m_PlayerPosition
中,以便随时引用。
getInputReceiver
函数初始化一个新的InputReceiver
实例,将m_ReceivesInput
变量设置为true
,然后返回新创建实例的地址。这个函数的作用是,只有在调用它时,我们的代码才会初始化并共享一个InputReceiver
实例。因此,在Factory
类中,我们可以轻松选择哪些相机处理输入,哪些不处理。通过将m_ReceivesInput
变量设置为true
,并且因为默认值是false
,这个类的每个实例都将知道自己是否处理输入。简而言之,每个类都会被告知并准备好是否处理输入,而无需类本身去选择。
对于CameraUpdate
类的下一部分,在CameraUpdate.cpp
中前面代码的后面添加以下内容:
void CameraUpdate::handleInput()
{
m_Position.width = 1.0f;
for (const Event& event : m_InputReceiver->getEvents())
{
// Handle mouse wheel event for zooming
if (event.type == sf::Event::MouseWheelScrolled)
{
if (event.mouseWheelScroll.wheel == sf::Mouse::VerticalWheel)
{
// Accumulate the zoom factor based on delta
m_Position.width *=
(event.mouseWheelScroll.delta > 0) ? 0.95f : 1.05f;
}
}
m_InputReceiver->clearEvents();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在上述代码中,handleInput
函数监听鼠标滚轮滚动事件。m_Position.width
的值被设置为1
,我们稍后会明白原因。下面的代码像我们到目前为止在每个游戏中所做的那样,遍历所有事件,唯一的区别是我们通过调用m_InputReceiver->getEvents
函数来捕获事件。
在事件循环中,我们只关心一个事件,即sf::Event::MouseWheelScrolled
。当检测到这个事件时,执行以下if
语句:
if (event.mouseWheelScroll.wheel ==
sf::Mouse::VerticalWheel)
2
上述语句检查鼠标滚轮是否被滚动,如果是,则执行下一行代码:
m_Position.width *=
(event.mouseWheelScroll.delta > 0) ? 0.95f : 1.05f;
2
这行代码根据鼠标滚轮滚动的方向修改m_Position.width
的值。
event.mouseWheelScroll.delta
中存储的值描述了鼠标滚轮滚动的幅度。如果这个值是正数,意味着鼠标滚轮向上滚动;如果是负数,则意味着鼠标滚轮向下滚动。
表达式(event.mouseWheelScroll.delta > 0)
是一个三元条件运算符。它检查这个值是否大于0
。如果是,表达式的值为true
;否则为false
。根据运算符的结果,选择两个值中的一个:
- 如果
delta > 0
,即鼠标滚轮向上滚动,则选择0.95f
。 - 如果
delta <= 0
,即鼠标滚轮向下滚动,则选择1.05f
。
选择的值(0.95f
或1.05f
)然后与我们之前初始化为1
的m_Position.width
相乘。如果结果大于0
,则将m_Position.width
减小5%
;如果结果小于0
,则将m_Position.width
增大5%
。如果鼠标滚轮没有被操作,该值将保持为1
。我们很快会在update
函数中看到如何使用这个值。handleInput
函数的最后一行代码清除所有事件,为游戏的下一帧做准备。
最后,对于CameraUpdate
类,在CameraUpdate.cpp
中添加update
函数:
void CameraUpdate::update(float fps)
{
if (m_ReceivesInput)
{
handleInput();
m_Position.left = m_PlayerPosition->left;
m_Position.top = m_PlayerPosition->top;
}
else
{
m_Position.left = m_PlayerPosition->left;
m_Position.top = m_PlayerPosition->top;
m_Position.width = 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
update
函数检查这个实例是否接收输入。如果接收,则调用handleInput
。这意味着,任何调用了getInputReceiver
的CameraUpdate
实例都将执行这段代码。在if
块中,m_Position
的left
和top
变量的值被设置为与玩家相同的位置。注意,代码的关键部分是没有设置宽度。这意味着我们在handleInput
函数中为m_Position.width
设置的任何值都将保留。当CameraGraphics
类在每一帧为SFML视图实例设置参数时,这将产生与鼠标滚轮同步缩放的效果。
在else
块中,我们执行与if
块相同的操作,但额外将m_Position.width
设置为1
。当else
块执行时,CameraGraphics
类将不会产生缩放效果。现在我们来看看CameraGraphics
类。
# 为CameraGraphics类编写代码(第1部分)
我们已经了解了CameraUpdate
类的工作原理,它如何根据鼠标滚轮滚动情况做出响应,以及它如何将滚动距离存储在其width
变量中。我们还知道它如何将玩家的位置存储在其left
和top
变量中。此外,我们了解到这个CameraGraphics
类将使用所有这些值。下面来看看它们是如何协同工作的。在CameraGraphics.h
中添加以下内容。
#pragma once
#include "SFML/Graphics.hpp"
#include "Graphics.h"
using namespace sf;
class CameraGraphics : public Graphics
{
private:
RenderWindow* m_Window;
View m_View;
int m_VertexStartIndex = -999;
Texture* m_Texture = nullptr;
FloatRect* m_Position = nullptr;
bool m_IsMiniMap = false;
// 用于缩放小地图
const float MIN_WIDTH = 640.0f;
const float MAX_WIDTH = 2000.0f;
// 用于时间UI
Text m_Text;
Font m_Font;
int m_TimeAtEndOfGame = 0;
float m_Time = 0;
public:
CameraGraphics(RenderWindow* window,
Texture* texture,
Vector2f viewSize,
FloatRect viewport);
float* getTimeConnection();
// 来自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
34
35
36
37
38
39
代码量不少,下面来逐行分析。在CameraGraphics.h
文件的私有部分,我们声明了一个指向RenderWindow
的指针m_Window
和一个View
实例m_View
;这些通常不会出现在我们的Graphics
派生类中。CameraGraphics
类需要它们的原因是,它将负责绘制VertexArray
,这个VertexArray
会在每一帧中包含更新后的顶点位置和纹理坐标。这是合理的,因为相机可以控制移动、缩放,然后进行绘制。我们可以根据需要创建任意数量的相机。我们可以制作一个四人游戏,将屏幕分成四部分;也可以制作一个双人分屏游戏;或者像我们即将做的那样,制作一个全屏相机和一个类似小地图/雷达的视图。
整数m_VertexStartIndex
将保存相机四边形在顶点数组中的起始索引。你可能会想,相机只是绘制顶点数组,为什么它需要自己的四边形呢?你有这样的疑问很正常,因为通常情况下相机不需要自己的四边形和纹理坐标,但我们的相机将有一个几乎完全透明的矩形,用于在小地图和主屏幕之间创建边界。
Texture
指针m_Texture
是保存包含所有内容的图像的纹理。m_Position
变量是一个FloatRect
,它保存相机对游戏世界视图的大小和坐标。
布尔值m_IsMiniMap
将帮助我们编写在主视图和小地图视图之间略有差异的代码。如果你愿意,你可以轻松创建两个单独的类,比如MainCameraGraphics
和RadarCameraGraphics
,这样可以避免代码中出现一些if
语句。
常量MIN_WIDTH
和MAX_WIDTH
限定了游戏世界视图的最小和最大尺寸,这是必要的,因为我们将编写允许小地图缩放的代码。
Text
、Font
和float
类型的m_Time
成员用于在屏幕左上角显示时间。游戏每一帧会有三次绘制调用,一次针对每个相机,还有一次针对文本,但我们只会在主相机中调用绘制文本的操作。在后面的第21章中,当我们添加视差背景时,还会添加第四次绘制调用。
整数m_TimeAtEndOfGame
帮助我们在游戏结束后显示时间。
在CameraGraphics.h
文件的公有部分,我们有构造函数声明。构造函数接收用于初始化RenderWindow
指针和Texture
指针的参数,以及用于相机视图大小和视口的变量。视口(viewport)是SFML中的概念,它定义了视图将显示在屏幕上的区域。等一下我们编写.cpp
文件的代码时,这就会完全说得通了。为了确保你真正理解视口的概念以及它与视图的区别,我们将在“SFML视图类”这部分深入讨论,但我们先添加代码,这样讨论时会有更多上下文。
getTimeConnection
函数返回一个指向m_Time
变量的指针,LevelUpdate
类将调用这个函数。这使得LevelUpdate
类能够更改m_Time
变量的值,正如我们将看到的,这将进而更改屏幕左上角的文本内容。
assemble
函数是我们常见的重写函数,它接收一个VertexArray
、一个指向通用Update
实例的共享指针以及纹理坐标。draw
函数也是从Graphics
类重写的,它只需要VertexArray
来完成工作。
现在,我们来编写这些函数的代码,以便全面理解它们。然后,我们将如承诺的那样,更深入地研究SFML的View
类。在CameraGraphics.cpp
文件中添加以下内容。
#include "CameraGraphics.h"
#include "CameraUpdate.h"
CameraGraphics::CameraGraphics(
RenderWindow* window,
Texture* texture,
Vector2f viewSize,
FloatRect viewport)
{
m_Window = window;
m_Texture = texture;
m_View.setSize(viewSize);
m_View.setViewport(viewport);
// 小地图视口小于1
if (viewport.width < 1) {
m_IsMiniMap = true;
}
else
{
// 只有全屏相机有时间文本
m_Font.loadFromFile("fonts/KOMIKAP_.ttf");
m_Text.setFont(m_Font);
m_Text.setFillColor(Color(255, 0, 0, 255));
m_Text.setScale(0.2f, 0.2f);
}
}
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
我们刚刚添加了CameraGraphics
类的构造函数。在这段代码中,我们首先初始化RenderWindow
指针和Texture
指针。通过调用setSize
并传入viewSize
参数来设置视图大小,通过调用setViewport
并传入viewport
来设置视口。接下来几页,我们先暂时放下CameraGraphics
类,仔细研究一下SFML的View
类。
# SFML的View类
为了更详细地解释视口到底是什么,请看下面的图片,其中有一些示例。
图17.1:视口说明
在上图中,我们看到了一些示例,展示了如何设置View
实例的视口,以控制绘制调用在屏幕上的显示位置和占据的屏幕区域大小。在之前所有项目中,我们使用的都是默认值,即整个屏幕。
视口由一个SFML的FloatRect
定义,其中left
和top
值定义了左上角的位置,width
和height
值定义了视口的宽度和高度。视口和视图大小(通过setSize
函数设置)之间的区别很重要。
setSize
函数决定视图将显示多少游戏世界单位。然而,视口的高度和宽度决定了该视图将使用屏幕多少比例的空间。根据游戏的需求,你可以在一个非常小的视口中显示大量游戏世界内容,也可以在一个非常大的视口中显示少量游戏世界内容。视口是用归一化坐标定义的,这意味着最小可能值是0,最大可能值是1。
因此,覆盖整个屏幕的默认视口的top
和left
值为0,height
和width
值为1。更多示例有助于阐明这一点。
参考上图中的屏幕示例1。标记为a的视口将具有以下值:left = 0
,top = 0
,width = 0.5
,height = 1
。这是因为该视口从屏幕最左边和最顶部(0)开始,占据屏幕宽度的一半(0.5)和整个高度(1)。视口b的值如下:left = 0.5
,top = 0
,width = 0.5
,height = 1
。这是因为它从屏幕水平方向的中间(0.5)、垂直方向的顶部(0)开始,宽度为一半(0.5),高度为整个高度(1)。
查看屏幕示例2,并花点时间理解视口a、b、c和d的值:
- 视口a:
left = 0
,top = 0
,width = 0.5
,height = 0.5
- 视口b:
left = 0.5
,top = 0
,width = 0.5
,height = 0.5
- 视口c:
left = 0
,top = 0.5
,width = 0.5
,height = 0.5
- 视口d:
left = 0.5
,top = 0.5
,width = 0.5
,height = 0.5
所有视口的宽度和高度均为0.5,因为它们都使用了屏幕高度和宽度的一半。屏幕左侧的两个视口left
值为0,从屏幕中心开始的两个视口left
值为0.5,依此类推。
以下是屏幕示例3中视口的值。我不会逐个描述,你只需根据我们刚刚讨论的内容来理解这些值:
- 视口a:
left = 0
,top = 0
,width = 1
,height = 0.33
- 视口b:
left = 0
,top = 0.33
,width = 1
,height = 0.33
- 视口c:
left = 0
,top = 0.66
,width = 1
,height = 0.33
最后一个示例,屏幕示例4,大致表示了我们游戏中的视口。视口a是从左上角开始的全屏视口,与默认值相同:left = 0
,top = 0
,width = 1
,height = 1
。视口b是小地图/雷达视口,其值如下:left = 0.2
,top = 0.8
,width = 0.6
,height = 0.19
。我们在向Factory
类添加代码时会看到,在传递视口的值时,我们还需要考虑屏幕分辨率以及水平与垂直分辨率的比例。此外,由于视口b与视口a的屏幕区域重叠,我们必须确保使用视口b的相机在第二个绘制,否则它会被使用视口a的相机覆盖。
让我们回到代码部分。
# 编写CameraGraphics类
综上所述,我们刚刚了解到,如果视口在任一方向上的值小于1(在我们的游戏中),那么它就不是全屏相机。因此,如果if(viewport.width<1)
这个条件为真,那么这将是小地图相机。当然,我们可能会不小心传递错误的值,但代码假设我们传递的值是正确的,所以任何宽度小于1的视口对应的就是小地图。在if
语句内部,m_IsMiniMap
被设置为true
。
在else
语句中(当是主相机时执行),我们加载字体、设置字体、给字体上色并缩放字体,就像我们在其他项目中所做的一样,只是代码位置不同。如前所述,小地图不会使用或显示时间,因此不需要Font
或Text
实例。
接下来,将下面这个assemble
函数的代码也添加到CameraGraphics.cpp
中。
void CameraGraphics::assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
shared_ptr<CameraUpdate> cameraUpdate =
static_pointer_cast<CameraUpdate>(genericUpdate);
m_Position = cameraUpdate->getPositionPointer();
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
在我们刚刚添加的assemble
函数中,第一行代码使用static_pointer_cast
将通用的Update
共享指针转换为CameraUpdate
共享指针。现在CameraGraphics
类可以调用CameraUpdate
类的所有公有函数。第二行代码利用这一点,通过调用CameraUpdate
类的getPositionPointer
函数来初始化m_Position
指针。这样我们就可以随时跟踪相机应该绘制的位置。
assemble
函数中的其余代码保存相机四边形的索引,并将与该四边形相关的所有纹理坐标初始化为VertexArray
。这些纹理坐标组合起来是一个非常浅的透明矩形,用于在主相机和小地图相机之间创建视觉分隔。
接下来,将下面这个getTimeConnection
函数的代码也添加到CameraGraphics.cpp
文件中。
float* CameraGraphics::getTimeConnection()
{
return &m_Time;
}
2
3
4
getTimeConnection
函数很简短,它只是返回m_Time
变量的地址。一旦LevelUpdate
类调用了这个函数并存储了结果,它就能够更新m_Time
变量,然后在draw
函数的每一帧中更新这个变量。说到draw
函数,下面我们就来编写它的代码。
最后,为CameraGraphics
类编写的代码,还有下面这段也添加到CameraGraphics.cpp
中。
void CameraGraphics::draw(VertexArray& canvas) {
m_View.setCenter(m_Position->getPosition());
Vector2f startPosition;
startPosition.x = m_View.getCenter().x - m_View.getSize().x / 2;
startPosition.y = m_View.getCenter().y - m_View.getSize().y / 2;
Vector2f scale;
scale.x = m_View.getSize().x;
scale.y = m_View.getSize().y;
canvas[m_VertexStartIndex].position = startPosition;
canvas[m_VertexStartIndex + 1].position = startPosition + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position = startPosition + scale;
canvas[m_VertexStartIndex + 3].position = startPosition + Vector2f(0, scale.y);
if (m_IsMiniMap) {
if (m_View.getSize().x <
MAX_WIDTH && m_Position->width > 1) {
m_View.zoom(m_Position->width);
}
else if (m_View.getSize().x >
MIN_WIDTH && m_Position->width < 1) {
m_View.zoom(m_Position->width);
}
}
m_Window->setView(m_View);
// 仅在主相机中绘制时间UI
if (!m_IsMiniMap) {
m_Text.setString(std::to_string(m_Time));
m_Text.setPosition(
m_Window->mapPixelToCoords(Vector2i(5, 5)));
m_Window->draw(m_Text);
}
// 绘制主画布
m_Window->draw(canvas, m_Texture);
}
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
在上面的draw
函数中,有些代码是所有相机都会用到的,有些代码只有小地图相机才会用到(if (m_IsMiniMap)
),还有些代码只有普通相机才会用到(if (!m_IsMiniMap)
)。
函数开头的大部分代码是两个相机都会用到的。提醒一下,代码如下:
m_View.setCenter(m_Position->getPosition());
Vector2f startPosition;
startPosition.x = m_View.getCenter().x - m_View.getSize().x / 2;
startPosition.y = m_View.getCenter().y - m_View.getSize().y / 2;
Vector2f scale;
scale.x = m_View.getSize().x;
scale.y = m_View.getSize().y;
canvas[m_VertexStartIndex].position = startPosition;
canvas[m_VertexStartIndex + 1].position = startPosition + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position = startPosition + scale;
canvas[m_VertexStartIndex + 3].position = startPosition + Vector2f(0, scale.y);
2
3
4
5
6
7
8
9
10
11
12
13
14
在上述两个相机实例都会用到的代码中,startPosition
这个Vector2f
变量是使用View
实例的大小和中心来初始化的。接着,scale
这个Vector2f
变量则是使用View
实例的大小来初始化的。
最后(在上述代码中),使用startPosition
和scale
对VertexArray
(顶点数组)中的相关顶点进行定位。
为了更清晰地展示,下面再次列出小地图专用的代码。
if (m_IsMiniMap) {
if (m_View.getSize().x <
MAX_WIDTH && m_Position->width > 1) {
m_View.zoom(m_Position->width);
}
else if (m_View.getSize().x >
MIN_WIDTH && m_Position->width < 1) {
m_View.zoom(m_Position->width);
}
}
2
3
4
5
6
7
8
9
10
在上述小地图专用的代码中,有一个if
部分,其中包含一个if - else if
结构。在if
部分,当m_IsMiniMap
为true
时会进入外层if
。当View
(视图)实例的大小小于允许的最大尺寸且m_Position.width
小于1时,对视图进行缩放。回顾CameraUpdate
(相机更新)类,所需的缩放比例存储在m_Position.width
中。
当超过允许的最小缩放比例且m_Position.width
小于1时,会执行内层的else if
部分。在else if
结构中,对视图进行放大。
注意,这段代码执行完毕后,会为两个相机将视图设置为合适的实例。在CameraUpdate
类的update
函数开始时,默认值始终设置为1,这意味着常规相机永远不会缩放,如果不使用鼠标滚轮,两个相机都不会缩放。
m_Window->setView(m_View);
接下来的代码仅由常规相机使用,为清晰起见,在此再次列出。
if (!m_IsMiniMap) {
m_Text.setString(std::to_string(m_Time));
m_Text.setPosition(
m_Window->mapPixelToCoords(Vector2i(5, 5)));
m_Window->draw(m_Text);
}
2
3
4
5
6
在上述常规相机使用的代码中,通过setString
和setPosition
配置屏幕左上角的文本。最后,我们使用RenderWindow
(渲染窗口)指针调用draw
函数。
与我们的僵尸游戏中调用数十次不同,CameraGraphics
(相机图形)类的每个实例只会调用一次draw
函数。这是因为所有游戏对象都在顶点数组中。这样效率更高,这意味着我们的游戏可以在配置较低的电脑上运行,或者在性能出现问题之前,我们可以添加更多的游戏对象。
为完整起见,再次列出draw
调用。
m_Window->draw(canvas, m_Texture);
为了看到我们新创建的两个与相机相关的类的实际效果,我们需要在Factory
(工厂)类中实例化它们,将它们包装在GameObject
(游戏对象)实例中,并添加到我们在游戏循环中迭代的向量中。
# 向游戏中添加相机实例
我们将有两个相机,一个用于游戏的主视图,一个用于小地图。
打开Factory.cpp
文件。在文件顶部添加以下两个突出显示的包含指令。
#include "Factory.h"
#include "LevelUpdate.h"
#include "PlayerGraphics.h"
#include "PlayerUpdate.h"
#include "InputDispatcher.h"
#include "CameraUpdate.h"
#include "CameraGraphics.h"
2
3
4
5
6
7
8
9
现在添加第一个相机的代码。在处理玩家的代码之后、loadLevel
函数的末尾(但在函数内部)添加所有相机代码。请注意,前几行代码是两个相机共用的;接下来我们会处理第二个相机。首先添加常规的全屏相机,否则它会遮挡小地图相机。
// For both the cameras
const float width = float(VideoMode::getDesktopMode().width);
const float height = float(VideoMode::getDesktopMode().height);
const float ratio = width / height;
// Main camera
GameObject camera;
shared_ptr<CameraUpdate> cameraUpdate = make_shared<CameraUpdate>();
cameraUpdate->assemble(nullptr, playerUpdate);
camera.addComponent(cameraUpdate);
shared_ptr<CameraGraphics> cameraGraphics = make_shared<CameraGraphics>(
m_Window, m_Texture,
Vector2f(CAM_VIEW_WIDTH, CAM_VIEW_WIDTH / ratio),
FloatRect(CAM_SCREEN_RATIO_LEFT, CAM_SCREEN_RATIO_TOP, CAM_SCREEN_RATIO_WIDTH, CAM_SCREEN_RATIO_HEIGHT));
cameraGraphics->assemble(
canvas,
cameraUpdate,
IntRect(CAM_TEX_LEFT, CAM_TEX_TOP,
CAM_TEX_WIDTH, CAM_TEX_HEIGHT));
camera.addComponent(cameraGraphics);
gameObjects.push_back(camera);
levelUpdate->connectToCameraTime(
cameraGraphics->getTimeConnection());
// End Camera
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
在上述代码中,你可能开始觉得有些熟悉了。首先,我们添加了一些对两个实例都有用的代码。width
、height
和ratio
变量是根据游戏运行的屏幕分辨率初始化的。我们会在两个相机中使用这些值。然后我们开始编写主相机的代码。
首先,我们创建一个名为camera
的GameObject
实例。接下来,创建一个指向CameraUpdate
实例的共享指针。然后调用assemble
函数,并传入playerUpdate
共享指针。接着,使用addComponent
函数将cameraUpdate
添加到camera
中。
接下来,我们创建一个CameraGraphics
共享指针,并调用构造函数,传入RenderWindow
、Texture
、相机尺寸的常量以及视口尺寸。然后,调用assemble
函数,传入VertexArray
、cameraUpdate
实例(以其父类Update
的形式)和纹理坐标。然后将CameraGraphics
实例添加到GameObject
实例中,并将GameObject
实例添加到gameObjects
向量中。
最后,通过在levelUpdate
上调用connectToCameraTime
,并传入在cameraGraphics
上调用getTimeConnection
的结果,在LevelUpdate
实例和CameraGraphics
实例之间建立连接。
接下来,添加用于小地图的相机代码。
// MapCamera
GameObject mapCamera;
shared_ptr<CameraUpdate> mapCameraUpdate = make_shared<CameraUpdate>();
mapCameraUpdate->assemble(nullptr, playerUpdate);
mapCamera.addComponent(mapCameraUpdate);
inputDispatcher.registerNewInputReceiver(
mapCameraUpdate->getInputReceiver());
shared_ptr<CameraGraphics> mapCameraGraphics = make_shared<CameraGraphics>(
m_Window, m_Texture,
Vector2f(MAP_CAM_VIEW_WIDTH, MAP_CAM_VIEW_HEIGHT / ratio),
FloatRect(MAP_CAM_SCREEN_RATIO_LEFT, MAP_CAM_SCREEN_RATIO_TOP,
MAP_CAM_SCREEN_RATIO_WIDTH,
MAP_CAM_SCREEN_RATIO_HEIGHT));
mapCameraGraphics->assemble(
canvas,
mapCameraUpdate,
IntRect(MAP_CAM_TEX_LEFT, MAP_CAM_TEX_TOP, MAP_CAM_TEX_WIDTH, MAP_CAM_TEX_HEIGHT));
mapCamera.addComponent(mapCameraGraphics);
gameObjects.push_back(mapCamera);
// End Map Camera
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
在上述代码中,除了尺寸和视口不同之外,我们使用了与第一个相机完全相同的技术和代码。小地图的尺寸更大,因为它显示的区域更宽,但视口更小,因为它被压缩到了一个较小的区域。一旦我们在游戏中添加更多图形,这一点会更加明显。
# 运行游戏
现在我们的相机正在将顶点数组绘制到屏幕上,我们可以删除在主函数中临时添加的额外代码行。从Run.cpp
文件中删除以下代码:
…
// Temporary code until next chapter
window.draw(canvas, factory.m_Texture);
…
2
3
4
现在我们可以运行游戏,查看相机的实际效果。
图17.2:相机运行效果
在上面的图片中,你可以看到玩家的位置和缩放都是正确的。
此外,如果你滚动鼠标滚轮,你可以看到小地图进行缩放,尽管目前小地图中可显示的内容还不多。
在下一章中,我们将添加平台,然后在同一章中,我们可以为玩家添加动画和键盘控制。请记住,PlayerUpdate
类中的InputReceiver
实例已经在接收所有事件;我们只需要对这些事件做出响应。
# 总结
在本章中,我们了解到尽量减少绘制调用的次数会更高效,并且我们可以通过为游戏中的所有实体使用单个顶点数组来实现这一点,尽管我们也有一个单独的SFML Text
实例,并在其上也调用了draw
函数。此外,在本章中,我们使用更新派生类和图形派生类的实体组件模式编写了两个相机的代码。此外,我们看到这些类相互共享数据以有效工作,并且它们还与玩家相关的类共享数据。
我们了解了如何在工厂中添加相机,通过将其他类的适当更新和图形派生实例等所需参数传递给assemble
函数,我们可以将相机配置为按我们期望的方式工作。
现在我们的相机已经正常运行,并且我们在第16章中通过LevelUpdate
类添加的游戏逻辑也已就绪,现在我们向游戏中添加的任何内容都能立即带来满足感,因为我们可以立即看到它的实际效果。在下一章中,我们将为游戏添加平台,并为玩家添加动画以及对键盘输入做出响应。