SFML-蓝图-全-
SFML 蓝图(全)
原文:
zh.annas-archive.org/md5/522a89cbb7e3b47cabccc9a57d61f622译者:飞龙
前言
在整本书中,我将尝试分享我制作视频游戏的知识,并与你分享。本书将涵盖五个不同的项目,包括许多游戏开发中常见问题的解决技术和方法。
使用的编程语言是 C++(2011 标准)和 SFML 库(版本 2.2)。
游戏编程的许多方面在不同的章节中都有所阐述,为你提供了所有必要的钥匙,让你能够在 2D 空间中构建你想要的任何类型的游戏,唯一的限制是你的想象力。
本书涵盖的内容
第一章, 准备环境,帮助你安装本书所需的所有内容,并使用 SFML 构建一个小型应用程序来测试一切是否正常。
第二章, 通用游戏架构、用户输入和资源管理,解释了通用游戏架构,管理用户输入,以及如何跟踪外部资源。
第三章, 制作一个完整的 2D 游戏,帮助你构建《小行星》和《俄罗斯方块》的克隆版,学习实体模型和棋盘管理。
第四章, 与物理玩耍,提供了物理引擎的描述。它还涵盖了与 SFML 配合使用的 Box2D,并将我们的 Tetris 游戏转变为新的游戏,Gravitris。
第五章, 与用户界面玩耍,帮助你创建和使用游戏用户界面。它介绍了 SFGUI,并将其添加到我们的 Gravitris 游戏中。
第六章, 使用多线程提升代码性能,介绍了多线程技术,并指导我们如何将游戏适配以使用它。
第七章,从零开始构建实时塔防游戏(第一部分),帮助你创建动画、通用的瓦片地图系统(等距六边形瓦片),以及实体系统。最后,你将创建所有的游戏逻辑。
第八章, 从零开始构建实时塔防游戏(第二部分,网络),介绍了网络架构和网络技术。它帮助你创建自定义通信协议,并修改我们的游戏以允许通过网络进行多人对战。然后,我们最终通过 ORM 使用 Sqlite3 为游戏添加了保存/加载选项。
你需要为本书准备的内容
为了能够构建本书涵盖的项目,我们假设您具备 C++语言及其基本功能的知识,以及标准模板库的部分内容,如字符串、流和容器。记住,游戏开发不是一项容易的任务,所以如果您没有先决条件,可能会感到沮丧。因此,在开始之前,不要犹豫去阅读一些关于 C++的书籍或教程。
本书面向对象
本书面向那些了解 SFML 库基础知识及其 2D 游戏开发能力的开发者。需要具备基本的 C++经验。
术语
在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词汇、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将按照以下方式显示:“我们还将使用addLines()函数将点计算添加到这个类中。”
代码块将按照以下方式设置:
AnimatedSprite::AnimatedSprite(Animation* animation,Status
status,const sf::Time& deltaTime,bool loop,int repeat) : onFinished(defaultFunc),_delta(deltaTime),_loop(loop), _repeat(repeat),_status(status)
{
setAnimation(animation);
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
int main(intargc,char* argv[])
任何命令行输入或输出都按照以下方式书写:
sudo make install
新术语和重要词汇将以粗体显示。屏幕上显示的词汇,例如在菜单或对话框中,在文本中显示如下:“如果需要,我们还将使用这个类来显示游戏结束信息”。
注意
警告或重要注意事项将以如下方框显示。
小贴士
小技巧和窍门将像这样显示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
要发送一般性反馈,请简单地发送一封电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或参与一本书感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经拥有了一本 Packt 出版的书籍,我们有一些事情可以帮助您从购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com下载示例代码文件,这是您购买的所有 Packt 出版物的示例代码。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
海盗行为
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以追究补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和提供有价值内容的能力方面的帮助。
询问
如果您在书籍的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。
第一章. 准备环境
通过这本书,我将尝试教你一些使用 SFML 库构建视频游戏的基本元素。每一章都会涵盖一个不同的主题,并且需要掌握前一章的知识。
在本章中,我们将介绍未来所需的基础要点,例如:
-
安装 C++11 的编译器
-
安装 CMake
-
安装 SFML 2.2
-
构建一个最小的 SFML 项目
在开始之前,让我们谈谈每种技术以及为什么我们会使用它们。
C++11
C++编程语言是一个非常强大的工具,具有真正出色的性能,但它也非常复杂,即使经过多年的实践也是如此。它允许我们在低级和高级进行编程。它有助于对我们的程序进行一些优化,例如直接操作内存的能力。利用 C++库构建软件使我们能够在高级别工作,当性能至关重要时,在低级别工作。此外,C/C++编译器在优化代码方面非常高效。结果是,目前,C++在速度方面是最强大的语言,多亏了零成本抽象,你不会为不使用的内容或提供的抽象付费。
我会尝试以现代的方式使用这种语言,采用面向对象的方法。有时,我会绕过这种方法,使用 C 语言的方式进行优化。所以,当你看到一些“老式代码”时,请不要感到惊讶。此外,现在所有的主流编译器都支持 2011 年发布的标准语言,因此我们可以毫无困难地使用它。这个版本在语言中添加了一些非常实用的功能,这些功能将在本书中使用,例如以下内容:
-
关键字是这样一个重要特性。以下是一些例子:
-
auto:这个关键字可以自动检测新变量的类型。对于迭代器的实例化来说,它非常有用。auto关键字过去就已经存在,但已经废弃很长时间了,其含义现在已改变。 -
nullptr:这是一个引入旧 NULL 值强类型的全新关键字。你总是可以使用 NULL,但最好使用nullptr,它是指针类型,其值为 0。 -
override和final:这两个关键字已经存在于一些语言中,如 Java。这些关键字不仅对编译器,也对程序员有简单的指示意义,但并不指定它们指示的内容。不要犹豫使用它们。你可以查看它们的文档en.cppreference.com/w/cpp/language/override和en.cppreference.com/w/cpp/language/final。
-
-
基于范围的
for循环是语言foreach中的一种新循环类型。此外,你可以使用新的auto关键字来大幅度减少你的代码。以下语法非常简单:for(auto& var : table){...}.在这个例子中,
table是一个容器(vector 和 list),而var是存储变量的引用。使用&允许我们修改表内包含的变量,并避免复制。 -
C++11 引入了智能指针。有多个指针对应于它们不同的可能用途。查看官方文档,这真的很有趣。主要思想是管理内存,当没有更多引用时,在运行时删除创建的对象,这样你就不必自己删除它或确保没有发生双重释放损坏。在栈上创建的智能指针具有速度快且在方法/代码块结束时自动删除的优点。但重要的是要知道,对这种指针的强烈使用,尤其是
shared_ptr,会降低程序的执行速度,所以请谨慎使用。 -
Lambda 表达式或匿名函数是一种引入了特定语法的全新类型。现在,你可以创建函数,例如,将其作为另一个函数的参数。这对于回调来说非常有用。在过去,我们使用函数对象(functor)来实现这种行为。以下是一个函数对象和 Lambda 的示例:
class Func(){ void operator()(){/* code here */}}; auto f = [](){/* code here*/}; -
如果你已经熟悉了使用省略号运算符(
...)的变长参数函数,那么这个概念可能会让你感到困惑,因为它的用法是不同的。变长模板只是使用省略号运算符对任何数量的参数进行优化的模板。一个很好的例子是元组类。元组可以包含编译时已知的任何类型和数量的值。如果没有变长模板,实际上无法构建此类,但现在这变得非常简单。顺便说一下,元组类是在 C++11 中引入的。还有其他一些特性,如线程、对等、等等。
SFML
SFML 代表 Simple and Fast Multimedia Library。这是一个用 C++ 编写的框架,其图形渲染部分基于 OpenGL。这个名字很好地描述了其目标,即拥有一个用户友好的界面(API),提供高性能,并且尽可能便携。SFML 库分为五个模块,这些模块分别编译到单独的文件中:
-
系统(System):这是主要模块,所有其他模块都需要它。它提供了时钟、线程以及所有二维和三维的逻辑(数学运算)。
-
窗口(Window):此模块允许应用程序通过管理窗口以及来自鼠标、键盘和游戏手柄的输入与用户交互。
-
图形(Graphics):此模块允许用户使用所有基本的图形元素,如纹理、形状、文本、颜色、着色器等。
-
音频(Audio):此模块允许用户使用一些声音。多亏了它,我们将能够播放一些主题、音乐和声音。
-
网络: 此模块不仅管理套接字和类型安全的传输,还管理 HTTP 和 FTP 协议。它对于在不同程序之间进行通信也非常有用。
我们程序使用的每个模块在编译时都需要与它们链接。如果不需要,我们不需要链接它们。本书将涵盖每个模块,但不会涵盖所有 SFML 类。我建议您查看 SFML 文档www.sfml-dev.org/documentation.php,因为它非常有趣且内容完整。每个模块和类都在不同的部分中得到了很好的描述。
现在已经介绍了主要技术,让我们安装使用它们所需的所有内容。
安装 C++11 编译器
如前所述,我们将使用 C++11,因此我们需要为其安装编译器。对于每个操作系统,都有几个选项;选择您喜欢的。
对于 Linux 用户
如果您是 Linux 用户,您可能已经安装了 GCC/G++。在这种情况下,请检查您的版本是否为 4.8 或更高版本。否则,您可以使用您喜欢的包管理器安装 GCC/G++(版本 4.8+)或 Clang(版本 3.4+)。在基于 Debian 的发行版(如 Ubuntu 和 Mint)下,使用以下命令行:
sudo apt-get install gcc g++ clang -y
对于 Mac 用户
如果您是 Mac 用户,您可以使用 Clang (3.4+)。这是 Mac OS X 下的默认编译器。
对于 Windows 用户
最后,如果您是 Windows 用户,您可以通过下载来使用 Visual Studio (2013)、Mingw-gcc (4.8+)或 Clang (3.4+)。我建议您不要使用 Visual Studio,因为它对于 C99 标准并不完全兼容,而是使用另一个 IDE,例如 Code::Blocks(见下一段)。
对于所有用户
我假设在两种情况下,您都已经能够安装编译器并配置系统以使用它(通过将其添加到系统路径)。如果您无法做到这一点,另一种解决方案是安装一个像 Code::Blocks 这样的 IDE,它具有以下优点:默认安装编译器,与 C++11 兼容,且不需要任何系统配置。
我将在本书的其余部分选择带有 Code::Blocks 的 IDE 选项,因为它不依赖于特定的操作系统,每个人都能导航。您可以在www.codeblocks.org/downloads/26下载它。安装非常简单;您只需按照向导操作即可。
安装 CMake
CMake 是一个非常实用的工具,它以编译器无关的方式管理任何操作系统中的构建过程。此配置非常简单。我们将需要它来构建 SFML(如果您选择此安装方案)以及构建本书的所有未来项目。使用 CMake 为我们提供了一个跨平台解决方案。我们需要 CMake 的 2.8 或更高版本。目前,最后一个稳定版本是 3.0.2。
对于 Linux 用户
如果您使用 Linux 系统,您可以使用包管理器安装 CMake 及其 GUI。例如,在 Debian 下,使用以下命令行:
sudo apt-get install cmake cmake-gui -y
对于其他操作系统
你可以在www.cmake.org/download/下载适合你系统的 CMake 二进制文件。按照向导操作,然后安装完成。CMake 现在已安装并准备好使用。
安装 SFML 2.2
获取 SFML 库有两种方法。更简单的方法是下载预构建版本,可以在sfml-dev.org/download/sfml/2.2/找到,但请确保你下载的版本与你的编译器兼容。
第二种选择是自行编译库。与之前的方法相比,这种方法更可取,可以避免任何麻烦。
自行编译 SFML
编译 SFML 并不像我们想象的那么困难,对每个人来说都是可行的。首先,我们需要安装一些依赖项。
安装依赖项
SFML 依赖于几个库。在开始编译之前,请确保你已经安装了所有依赖项及其开发文件。以下是依赖项列表:
-
pthread -
opengl -
xlib -
xrandr -
freetype -
glew -
jpeg -
sndfile -
openal
Linux
在 Linux 上,我们需要安装这些库的开发版本。包的确切名称取决于每个发行版,但这里是为 Debian 的命令行:
sudo apt-get install libglu1-mesa-dev freeglut3-dev mesa-common-dev libxrandr-dev libfreetype6-dev libglew-dev libjpeg-dev libsndfile1-dev libopenal-dev -y
其他操作系统
在 Windows 和 Mac OS X 上,所有需要的依赖项都直接由 SFML 提供,因此你不需要下载或安装任何东西。编译将直接完成。
SFML 的编译
如前所述,SFML 的编译过程非常简单。我们只需按照以下步骤使用 CMake:
-
在
sfml-dev.org/download/sfml/2.2/下载源代码并解压。 -
打开 CMake,指定源代码目录和构建目录。按照惯例,构建目录称为
build,位于源目录的根级别。 -
点击配置按钮,并选择适合你系统的Code::Blocks。
在 Linux 下,选择Unix Makefiles。它应该看起来像这样:
![SFML 的编译]()
在 Windows 下,选择MinGW Makefiles。它应该看起来像这样:
![SFML 的编译]()
-
最后,点击生成按钮。你会得到如下输出:
![SFML 的编译]()
现在 Code::Blocks 文件已构建,可以在你的构建目录中找到。用 Code::Blocks 打开它,并点击构建按钮。所有二进制文件都将构建并放置在build/lib目录中。此时,你将有一些依赖于你系统的文件。如下所示:
-
libsfml-system -
libsfml-window -
libsfml-graphics -
libsfml-audio -
libsfml-network
每个文件对应于一个不同的 SFML 模块,这些模块将是我们未来游戏运行所需的。
现在是时候配置我们的系统以便能够找到它们了。我们所需做的只是将 build/lib 目录添加到我们的系统路径中。
Linux
要在 Linux 中编译,首先打开一个终端并运行以下命令:
cd /your/path/to/SFML-2.2/build
以下命令将在 /usr/local/lib/ 下安装二进制文件,并在 /usr/local/include/SFML/ 中安装头文件:
sudo make install
默认情况下,/usr/local/ 已在您的系统路径中,因此无需进行更多操作。
Windows
在 Windows 上,您需要按照以下方式将 /build/lib/ 目录添加到您的系统路径中:
-
在 系统属性 的 高级 选项卡中,点击 环境变量 按钮:
![Windows]()
-
然后,在 系统变量 表中选中 路径,并点击 编辑... 按钮:
![Windows]()
-
现在编辑 变量值 输入文本,添加
;C:\your\path\to\SFML-2.2\build\lib,然后通过在所有打开的窗口中点击 确定 来验证它:![Windows]()
到目前为止,您的系统已配置为查找 SFML dll 模块。
Code::Blocks 和 SFML
现在您的系统已配置为查找 SFML 二进制文件,是时候配置 Code::Blocks 并最终测试您的全新安装是否一切正常了。为此,请按照以下步骤操作:
-
运行 Code::Blocks,转到 文件 | 新建 | 项目,然后选择 控制台应用程序。
-
点击 GO。
-
选择 C++ 作为编程语言,并按照说明操作,直到创建项目。现在已创建一个包含典型
Hello world程序的默认main.cpp文件。尝试构建并运行它以检查您的编译器是否正确检测到。![Code::Blocks 和 SFML]()
如果一切正常,将创建一个新窗口,其中包含 Hello world! 消息,如下所示:

如果您看到这个输出,那么一切正常。在任何其他情况下,请确保您已遵循所有安装步骤。
现在,我们将配置 Code::Blocks 以查找 SFML 库,并在编译结束时将其链接到我们的程序。为此,请执行以下步骤:
-
转到 项目 | 构建选项 并在根级别选择您的项目(不是调试或发布)。
-
转到 搜索目录。在这里,我们必须添加编译器和链接器可以找到 SFML 的路径。
-
对于编译器,添加您的 SFML 文件夹。
-
对于链接器,添加
build/lib文件夹,如下所示:![Code::Blocks 和 SFML]()
现在我们需要让链接器知道我们的项目需要哪些库。我们所有的未来 SFML 项目都需要系统、窗口和图形模块,因此我们将添加它们:
-
转到 链接器设置 选项卡。
-
在 其他链接器选项 列表中添加
-lsfml-system、-lsfml-window和-lsfml-graphics。 -
现在点击 确定。
![Code::Blocks 和 SFML]()
好消息,所有的配置现在都已经完成。我们最终可能需要在链接器中添加一个库(音频、网络),但仅此而已。
一个最小示例
现在是我们用一个非常基本的示例来测试 SFML 的时候了。这个应用程序将展示如下截图中的窗口:

以下代码片段生成了这个窗口:
int main(int argc,char* argv[])
{
sf::RenderWindow window(sf::VideoMode(400,
400),"01_Introduction");
window.setFramerateLimit(60);
//create a circle
sf::CircleShape circle(150);
circle.setFillColor(sf::Color::Blue);
circle.setPosition(10, 20);
//game loop
while (window.isOpen())
{
//manage the events
sf::Event event;
while(window.pollEvent(event))
{
if ((event.type == sf::Event::Closed)
or (event.type == sf::Event::KeyPressed and
event.key.code == sf::Keyboard::Escape))
window.close(); //close the window
}
window.clear(); //clear the windows to black
window.draw(circle); //draw the circle
window.display(); //display the result on screen
}
return 0;
}
这个应用程序所做的一切就是创建一个宽度和高度为 400 像素的窗口,其标题为 01_Introduction。然后创建一个半径为 150 像素的蓝色圆圈,并在窗口打开时绘制。最后,在每次循环中检查用户事件。在这里,我们验证是否请求了关闭事件(关闭按钮或点击 Alt + F4),或者用户是否按下了键盘上的 Esc 按钮。在两种情况下,我们都会关闭窗口,这将导致程序退出。
摘要
在本章中,我们讨论了我们将使用哪些技术以及为什么使用它们。我们还学习了在不同环境中安装 C++11 编译器,了解了如何安装 CMake 以及它将如何帮助我们构建本书中的 SFML 项目。然后我们安装了 SFML 2.2,并继续构建了一个非常基本的 SFML 应用程序。
在下一章中,我们将了解如何构建游戏结构,管理用户输入,并跟踪我们的资源。
第二章.通用游戏架构、用户输入和资源管理
现在无聊的部分已经结束,让我们开始使用 SFML。在本章中,我们不会构建一个完整的游戏,而是学习构建游戏所需的一些基本技能。这些技能如下:
-
理解基本游戏架构
-
管理用户输入
-
跟踪外部资源
这些点对于任何类型的游戏都至关重要。但这些点具体意味着什么呢?这就是我在本章中要向您解释的内容。
游戏的一般结构
在开始无计划地随机构建之前,我们需要一些信息:你想要构建什么类型的游戏(RPG、FPS 或动作冒险),将使用哪些元素,等等。本章的目的是理解通用游戏结构,这可以用于任何类型的游戏。通过这部分,我们将研究:
-
游戏类
-
帧率
-
玩家类
-
事件管理
游戏类
在上一章中,我们看到了构建游戏所需的最小代码,它包含:
-
创建窗口
-
创建图形显示
-
处理用户输入
-
处理用户输入
-
在屏幕上显示游戏对象
而不是让一个函数完成所有工作,我们将利用面向对象实践,并在不同的函数中定义各种状态。此外,我们将方法封装在一个名为 Game 的新类中,并最小化 main 函数。这个 Game 类将成为我们所有未来游戏的起点:
class Game
{
public:
Game(const Game&) = delete;
Game& operator=(const Game&) = delete;
Game();
void run();
private:
void processEvents();
void update();
void render();
sf::RenderWindow _window;
sf::CircleShape _player;
};
int main(int argc,char* argv[])
{
Game game;
game.run();
return 0;
}
注意
= delete 是 C++11 中的一个特性,允许我们显式地删除特殊成员函数,如构造函数、移动构造函数、拷贝构造函数、拷贝赋值运算符、移动拷贝赋值运算符和析构函数。它告诉编译器不要构建默认函数。在这种情况下,它使得类不可拷贝。另一种解决方案是从 sf::NonCopyable 继承类。
= default 也可以显式地告诉编译器构建这个成员函数的默认版本。例如,它可以用来定义自定义构造函数和默认构造函数。
现在我们有了基本的 Game 类结构,其中函数根据其功能进行分离。此外,主函数中不再有循环,因为我们将在 Game::run() 函数中存在。现在,我们只需调用 Game::run() 函数。
我们现在可以将所有代码从主函数移动到函数中——processEvents()、update() 或 render()——取决于我们想要实现什么:
-
processEvents(): 这将管理所有来自用户的事件 -
update(): 这将更新整个游戏 -
render(): 这将管理游戏的所有渲染
所有未来的功能也将被放入这些私有函数之一。
现在,让我们看看实现方式:
-
构造函数初始化窗口和玩家:
Game::Game() : _window(sf::VideoMode(800, 600),"02_Game_Archi"), _player(150) { _player.setFillColor(sf::Color::Blue); _player.setPosition(10, 20); } -
Game::run()方法隐藏主game循环:void Game::run() { while (_window.isOpen()) { processEvents(); update(); render(); } } -
Game::processEvents()方法处理用户输入。它简单地轮询自上一帧以来从窗口接收到的所有事件,例如窗口标题栏中的按钮或按下的键盘键。在下面的代码中,我们检查用户是否按下了窗口的关闭按钮和键盘的 Esc 键。作为回应,我们关闭窗口:void Game::processEvents() { sf::Event event; while(_window.pollEvent(event)) { if ((event.type == sf::Event::Closed) or ((event.type == sf::Event::KeyPressed) and (event.key.code == sf::Keyboard::Escape))) { _window.close(); } } } -
update()方法更新我们的游戏逻辑。目前,我们还没有任何逻辑,但不久的将来,我们将看到如何修改我们游戏中的逻辑:void Game::update(){} -
Game::render()方法将游戏渲染到屏幕上。首先,我们使用颜色清除窗口,通常是sf::Color::Black,这是默认颜色,然后我们渲染帧的对象,最后,我们在屏幕上显示它:void Game::render() { _window.clear(); _window.draw(_player); _window.display(); }
注意
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从 www.packtpub.com/sites/default/files/downloads/B03963_8477OS_Graphics.pdf 下载此文件。
与上一章的最小示例相比,场景的最终渲染没有变化,除了标题和大小。即使代码更多,由于函数已被简化到最小,使用新的架构维护应用程序更容易,因为更容易找到你想要的内容:

游戏循环
现在已经介绍了 Game 类,让我们来谈谈 Game::run() 函数内部的循环。这个循环被称为 game 循环或 main 循环。在游戏过程中持续运行,并在每次循环迭代中执行多个操作。这个循环的每次迭代被称为一帧。术语 每秒帧数 (FPS) 是一个衡量指标,用于确定游戏在 1 秒内完成的迭代次数。我稍后会回到这个话题。
在这个循环内部的操作相当简单。首先,我们处理事件。然后,我们更新游戏状态。最后,我们将游戏渲染到屏幕上。
如您可能已经注意到的,这听起来很像 Game 类的 run 方法。为了更直观地解释,这个循环是一个表示逻辑的流程图:

目前,循环被简化到最小。例如,我们在这里不深入详细说明 Game::processEvents() 方法。目前,游戏循环被保持简单,这样你可以先学习基础知识。稍后,我们将回到 Game::run() 方法中的每个方法,例如 Game::processEvents() 方法,并增加更多的复杂性。
帧率
我们现在回到帧上。正如我之前所说的,帧是 game 循环的完整迭代。最终结果是可以在屏幕上显示的新 game 状态。
人类每秒无法看到无限数量的图像。我们的大脑在感知到的每一张图像之间进行一些插值。结果是,我们不需要每秒显示大量的图像。但是显示的图像越多,最终结果的质量就越高。例如,在电影院,每秒只显示 24 张图像。
在视频游戏中,大多数时候,我们试图使循环尽可能快。每秒显示的图像数量达到 30 到 60。低于 30 FPS 时,可能会有延迟效应,这可能是由游戏引起的,我们需要处理它以避免问题。
由延迟效应引起的最常见问题之一是实体的位移。大多数情况下,每个实体都有自己的速度和方向。速度通常以每秒像素数来衡量。现在想象一下你的游戏,由于任何原因,有一些延迟,FPS 下降到像 5 这样的小数字,那么图形效果就是所有的实体都会瞬间移动。但这不是主要问题。主要问题是碰撞。以一个在延迟发生时向墙壁方向行走的实体为例,该实体将实际上穿过墙壁。以下是表示该问题的图示:

为了解决这个问题,有三种不同的方法。第一种是可变步长,第二种是固定步长,第三种是将它们混合在一起。
固定步长
固定步长方法,正如其名所示,是一种每次调用 Game::update() 函数都使用相同时间间隔的方法。例如,用于移动的单位相对于帧。因为每个帧与其他相同时间的帧是分开的,所以我们不需要更多的复杂性。我们唯一需要关注的是选择基本值,以确保没有问题。
这是 game 循环的新流程图:

现在我们将在以下代码片段中实现新的 Game 类:
void Game::run(int frame_per_seconds)
{
sf::Clock clock;
sf::Time timeSinceLastUpdate = sf::Time::Zero;
sf::Time TimePerFrame = sf::seconds(1.f/frame_per_seconds);
while (_window.isOpen())
{
processEvents();
bool repaint = false;
timeSinceLastUpdate += clock.restart();
while (timeSinceLastUpdate > TimePerFrame)
{
timeSinceLastUpdate -= TimePerFrame;
repaint = true;
update(TimePerFrame);
}
if(repaint)
render();
}
}
这段代码确保每次调用 Game::update() 函数时都会花费与参数值相同的时间。与代码的先前版本没有太大区别。我们只是在 Game::update() 函数的最后一次调用后跟踪时间,然后只有在时间超过帧率时才再次调用它。可以通过在循环中暂停 sf::sleep 剩余的自由时间来改进代码。这有点困难(因为需要测量前一个更新+渲染所花费的时间),但不会浪费 CPU 时间。
在 Game::update() 函数上做了一点小的改动,给它添加了一个参数。它的新签名现在是:
void update(sf::Time deltaTime);
此参数使我们能够知道自上次调用Game::update()以来经过的时间。目前,对此没有太大兴趣,但以后会有。
由于游戏状态仅在调用Game::update()时改变,因此当至少进行一次更新时,才会调用Game::render()。
可变时间步长
可变时间步长方法与固定时间步长方法不同,正如其名称所暗示的。这里的主要思想是尽可能快地执行game循环,没有任何延迟。这使得游戏更加反应灵敏。这里的单位必须是每时间单位(大多数情况下,时间指的是一秒)。因为我们无法预测循环将运行多少次,所以我们将它作为Game::update()函数中的一个参数,并将其与基本单位相乘。
我们对game循环的实际实现对应于可变时间步长方法;我们只需要添加一个系统来跟踪自上次循环以来经过的时间:
void Game::run()
{
sf::Clock clock;
while (_window.isOpen())
{
processEvents();
update(clock.restart());
render();
}
}
这里唯一的新事物是sf::Clock和传递给Game::update()方法的参数。但这种方法仍然存在问题:当游戏运行得太慢(两个步骤之间的时间很重要)时。
最小时间步长
另有一种解决方案,即合并最后两种方法。想法是通过确保传递给Game::update()方法的时间参数不是太高,尽可能快地运行游戏。结果是,我们确保帧率最小,但没有最大值。总之,我们想要两件事:
-
为了让游戏尽可能快地运行
-
如果由于任何原因,两个循环之间的时间超过,比如说,30 FPS,我们将根据需要分割这段时间,以确保传递给
Game::update()函数的 delta 时间不超过 30 FPS。
这里是表示此解决方案的流程图:

现在我们将在以下代码片段中实现新的run函数:
void Game::run(int minimum_frame_per_seconds)) {
sf::Clock clock;
sf::Time timeSinceLastUpdate;
sf::Time TimePerFrame = sf::seconds(1.f/minimum_frame_per_seconds);
while (_window.isOpen()) {
processEvents();
timeSinceLastUpdate = clock.restart();
while (timeSinceLastUpdate > TimePerFrame) {
timeSinceLastUpdate -= TimePerFrame;
update(TimePerFrame);
}
update(timeSinceLastUpdate);
render();
}
}
在每一帧上,都会调用Game::update()和Game::render()方法,但当两帧之间的时间差比我们想要的更重要时,Game::update()方法会以允许的最大值调用,所需次数。
所有这些方法都有其优点和缺点。根据具体情况,一种方法可能比另一种方法更好。但从现在开始,我们将使用最小时间步长方法。
所有这些解决方案都不太适合使用物理引擎。我们将在第四章 玩转物理中回到这个特定点。但要知道它将需要两个循环:一个用于物理,另一个用于游戏逻辑。每个循环都可以有不同的帧率。
注意
管理应用程序帧率的方法还有很多。其中最常见的是 sleep() 函数,它在指定时间内中断应用程序,并给处理器机会去处理其他任务。但这对游戏和所有需要精确时间调度的应用程序来说不是一个好的解决方案。SFML 为我们提供了一个 sf::RenderWindow::setFramerateLimit() 函数,它通过内部调用 sf::sleep() 来尝试固定运行应用程序的帧率。这是一个好的解决方案,但仅适用于测试。
另一种解决方案是使用通过调用 void sf::Window::setVerticalSyncEnabled(bool) 的垂直同步。它将限制显示的帧数以匹配显示器的刷新率(大多数情况下是 60 Hz,但无法保证)。这有助于避免一些视觉伪影,并将帧率限制在一个良好的值(但不是在不同计算机上恒定)。V-Sync 有时在某些系统上锁定得太低。这就是为什么在完整生产游戏中,它可以被打开和关闭。
移动我们的玩家
现在我们有一个干净的 game 循环,让我们移动我们的 Player 对象。目前,让我们向前移动它,并让它向右和向左转。我们将以不依赖于帧率的方式来实现它。首先,让我们考虑玩家。
玩家类
Player 是任何类型游戏中非常重要的一个类,并且随着游戏类型的改变而有很多变化。我们的目标只是能够移动和旋转它。所以所需的信息如下:
-
它的形状、大小和颜色
-
它的方向
-
它的速度
让我们使用 SFML 类 sf::RectangleShape 将 Player 形状更改为正方形。方向和速度可以合并成一个单一的对象:一个数学向量(我们将在下一节中讨论这个)。SFML 提供了一个很好的类来处理这个:sf::Vector2f。我们还需要添加速度和旋转,并设置玩家的位置,但我们还将更新它,最后在屏幕上显示它。
最后,我们得到这个类:
class Player : public sf::Drawable {
public:
Player(const Player&) = delete;
Player& operator=(const Player&) = delete;
Player();
template<typename ... Args>
void setPosition(Args&& ... args) {
_shape.setPosition(std::forward<Args>(args)...);
}
void update(sf::Time deltaTime);
bool isMoving;
int rotation;
private:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
sf::RectangleShape _shape;
sf::Vector2f _velocity;
}
如前所述,玩家需要在屏幕上显示,所以我们从 sf::Drawable 扩展它。这个类简单地给我们需要重写的类添加了 draw() 虚拟方法。为了确保我们重写了它,我们使用了 C++11 的新关键字:override。
注意
使用 override,我们确保我们进行的是重写而不是重载。这是 C++11 中的一个新关键字。
此外,就像在 Game 类中一样,我们通过显式删除方法的默认实现来使玩家不可复制。
现在,让我们谈谈 Player::setPosition() 方法。正如你所见,它的签名非常奇怪。在这里,我使用了 C++11 的另一个特性:可变模板。正如你所知,sf::Transformable 有两个版本的 setPosition() 方法。第一个接受两个浮点数,第二个接受 sf::Vector2f 作为参数。由于我不想构建两个版本,我使用了 C++ 的新特性。我只是将参数前向传递给 sf::Transformable::setPosition() 而不知道它们。通过这种方式,我们可以使用 sf::Transformable::setPosition() 的两个函数。
首先,我们将函数的参数类型声明为以下模板:
template<typename Arg> void setPosition(Arg arg);
然而,我们还想有一个可变数量的参数,所以我们使用椭圆操作符。结果如下:
template<typename … Args> void setPosition(Args ... args);
由于我们不想固定参数的类型(常量、左引用或右引用),我们使用了 C++11 的另一个特性:右值引用,或者在这个上下文中,是前向/通用引用。这允许我们通过简单地添加 && 来捕获任何类型的值。函数的最终签名现在如下:
template<typename … Args> void setPosition(Args&& ... args);
现在,为了完美地前向传递参数到 sf::Transformable::setPosition(),我们只需使用椭圆操作符解包参数包,并对每个参数调用 std::forward:
_shape.setPosition(std::forward<Args>(args)...);
就这样!我们现在可以使用 sf::Transformable::setPosition() 的任何方法。这种方法在创建通用代码时非常强大,所以请尽量理解它。
Player 类还有两个公共属性:isMoving 和 rotation。这些属性将简单地存储输入的状态。
现在看看函数的实现:
Player::Player() : _shape(sf::Vector2f(32,32))
{
_shape.setFillColor(sf::Color::Blue);
_shape.setOrigin(16,16);
}
在这里,我们只需将 _shape 构造函数更改为与 sf::RectangleShape 构造函数兼容,并将形状的原点中心对准其重力中心:
void Player::update(sf::Time deltaTime)
{
float seconds = deltaTime.asSeconds();
if(rotation != 0)
{
float angle = (rotation>0?1:-1)*180*seconds;
_shape.rotate(angle);
}
if(isMoving)
{
float angle = _shape.getRotation() / 180 * M_PI - M_PI / 2;
_velocity += sf::Vector2f(std::cos(angle),std::sin(angle)) * 60.f * seconds;
}
_shape.move(seconds * _velocity);
}
这里是重要的部分。这个函数以以下方式更新我们的玩家:
-
首先,如果需要,我们旋转它。
-
然后,如果玩家正在移动,我们简单地获取形状的旋转角度以了解其方向,然后将其现有速度加上一些根据其方向的速度。注意,目前我们还没有限制最大速度。
-
最后,我们只需移动它;这非常简单。我们只需在
shape上调用move方法,并将velocity作为参数。
由于每一帧的执行时间并不相同,我们需要将所有值(旋转速度、加速度和速度)乘以自上次调用以来经过的时间。在这里,我选择使用每秒像素作为单位,因此我们需要将值乘以自上次调用以来经过的秒数;sf::Time 提供了这个能力:
void Player::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
target.draw(_shape,states);
}
这个函数并不难,不应该让你感到惊讶。
现在,我们需要更新 Game::processEvents() 函数来设置 isMoving 和 rotation 的值:
void Game::processEvents()
{
sf::Event event;
while(_window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
_window.close();
else if (event.type == sf::Event::KeyPressed)
{
if (event.key.code == sf::Keyboard::Escape)
_window.close();
else if(event.key.code == sf::Keyboard::Up)
_player.isMoving = true;
else if (event.key.code == sf::Keyboard::Left)
_player.rotation = -1;
else if (event.key.code == sf::Keyboard::Right)
_player.rotation = 1;
}
else if (event.type == sf::Event::KeyReleased)
{
if(event.key.code == sf::Keyboard::Up)
_player.isMoving = false;
else if (event.key.code == sf::Keyboard::Left)
_player.rotation = 0;
else if (event.key.code == sf::Keyboard::Right)
_player.rotation = 0;
}
}
}
使用此代码,当按下上箭头键时,我们将isMoving的值设置为true,当释放时设置为false。同样的技巧用于根据左右箭头设置旋转,但在这里我们设置旋转方向,1表示顺时针,-1表示逆时针,0表示无旋转。所有计算已经在Player::update()中完成。
管理用户输入
管理从用户接收的事件是一个非常重要的主题。SFML 为我们提供了两种不同的方法。第一种是通过轮询从sf::Window实例接收的事件,另一种是实时检查条目的状态。
首先,什么是事件?
通常,事件是在某些变化/发生时被触发的对象。它们是操作系统相关的,但 SFML 为我们提供了一个很好的对象,以操作系统无关的方式处理它们。这是sf::Event类。这个类处理大量的事件,如下所示:
-
窗口包含四种不同类型的事件。如下所示:
-
关闭
-
调整大小
-
获得或失去焦点
-
鼠标指针进入/离开窗口
-
-
鼠标有三个事件。如下所示:
-
移动
-
按键/释放
-
滚轮按下、释放或移动
-
-
键盘包含两个事件。如下所示:
-
键盘按键/释放
-
输入文本
-
-
游戏手柄也通过四个事件进行管理。如下所示:
-
连接/断开
-
移动
-
按键/释放键
-
输入文本
-
我建议您查看www.sfml-dev.org/tutorials/2.2/window-events.php上的 SFML 文档,了解这个类的信息。需要记住的一个重要事情是sf::Event不过是一个大的联合体,所以你必须注意根据事件类型访问正确的属性。
轮询事件
这些类型的事件由sf::Window实例存储在队列中。要处理它们,我们只需使用sf::Window::pollEvent()方法逐个提取它们。其签名如下:
bool sf::Window::pollEvent(sf::Event& event);
这个签名有点有趣。如果从队列中提取了事件,则返回值设置为true,在其他情况下设置为false。当事件被提取时,事件参数被设置为对应正确的值。换句话说,事件参数是我们当函数返回true时得到的事件。这种典型用法如下:
sf::Event event;
while(_window.pollEvent(event))
{
// do something with the event
}
这正是我们在实际应用中所做的。目前,我们使用事件轮询来处理用户输入。
这些事件类型用于特定情况(例如关闭窗口、使用 Esc 键退出、暂停游戏等),而不是移动玩家,因为在非实时中事件感知是如此不连贯。结果的运动也将是不连贯的。
实时事件
SFML 为我们提供了在任何时候检查实体状态的可能性。这个实体可以是鼠标、键盘或游戏手柄。在这里,我们不使用事件,而是简单地检查鼠标的位置以及是否按下了特定的按钮或键。这与事件非常不同,特别适合玩家的动作,如移动、射击等。
如您可能已经注意到的,我们在Player类中对事件的实际使用是错误的。因此,我们需要将其更改为使用实时事件,同时不改变控制键。为此,我们将在Player类中添加一个processEvents()方法,该方法将设置isMoving和rotation的值。我们还将更改我们的Game::processEvents()函数,以调用新创建的Player::processEvents()方法。此外,因为isMoving和rotation将在Player类内部设置,我们将它们移动为私有属性。
这里是新方法的签名:
void processEvents();
如您所见,这与Game::processEvents()的签名完全相同。其实现如下:
void Player::processEvents()
{
isMoving = sf::Keyboard::isKeyPressed(sf::Keyboard::Up);
rotation = 0;
rotation-= sf::Keyboard::isKeyPressed(sf::Keyboard::Left);
rotation+= sf::Keyboard::isKeyPressed(sf::Keyboard::Right);
}
首先,我们根据向上箭头的状态设置isMoving的值。为此,我们使用sf::Keyboard::isKeyPressed()函数。因为这个函数是一个静态函数,我们可以直接使用它而不需要任何对象。看看它的签名:
static bool sf::Keyboard::isKeyPressed(sf::Keyboard::Key);
如果按键被按下,此函数返回true,如果没有按下,则返回false。真的很简单,不是吗?
现在,让我们来谈谈旋转。旋转取决于两个不同的输入。因此,我们需要思考“如果用户同时按下这两个键会发生什么?”。这听起来可能有点奇怪,但确实,一些玩家会这样做,所以我们需要考虑这一点。在这里,我使用了一个非常简单的解决方案:
-
首先,我重置了
rotation的值 -
然后,我根据两个键的输入状态添加
rotation
通过这样做,如果没有按键被按下,rotation将保持其初始值,即0。如果按下其中一个输入,则rotation将取1或-1的值,如果同时按下两个,则两个输入将相互抵消,所以一切正常,我们得到了预期的结果。
现在,让我们专注于Player::update()方法。这个方法并没有太大的不同。我们唯一需要更改的是以下行:
float angle = (rotation>0?1:-1)*180*seconds;
由于我们现在在Player类内部设置rotation,我们可以确保其值始终是准确的,因此我们不再需要验证它,可以将其删除。新行简化为以下内容:
float angle = rotation*180*seconds;
现在,让我们看看更新的Game::processEvents()方法:
void Game::processEvents()
{
sf::Event event;
while(_window.pollEvent(event))
{
if (event.type == sf::Event::Closed)//Close window
_window.close();
else if (event.type == sf::Event::KeyPressed) //keyboard input
{
if (event.key.code == sf::Keyboard::Escape)
_window.close();
}
}
_player.processEvents();
}
在这里,我们通过删除任何针对玩家的特定事件来大大减少了代码的大小。我们唯一要做的就是调用Player::processEvents()方法而不是管理玩家控制。
处理用户输入
现在我们对事件有了更深入的了解,能够将它们绑定到当它们发生时的某些回调函数可能会很有趣。这个想法背后的主要目的是允许我们动态地添加功能。在游戏中,有时你有升级某些武器或使用新武器的可能性;一个选项是在执行之前确保使用是允许的,另一个选项是在玩家能够使用时将其添加到玩家。通过这样做,我们移除了代码中的许多 if 语句,并提高了代码的可读性。
要做到这一点,我们需要一个系统,允许我们向实体添加功能,并且可以通过事件触发。这个事件可以是实时的,或者是由轮询sf::Window实例生成的。
使用 Action 类
我们将创建一个新的类,该类包含一个需要执行的sf::Event实例。这个类将实现检查内部sf::Event实例是否被执行的功能。比较运算符是做这件事的好方法,但对于实时事件来说则不行,因为我们没有可以与之比较的东西,因为我们没有对它们进行池化。所以,我们还需要Action::test()来检查实时事件是否满足条件。我们还需要知道事件是否需要通过按下或释放输入来触发,或者两者都需要。
Action类的代码如下:
class Action
{
public:
enum Type
{
RealTime=1,
Pressed=1<<1,
Released=1<<2
};
Action(const sf::Keyboard::Key& key,int type=Type::RealTime|Type::Pressed);
Action(const sf::Mouse::Button& button,int type=Type::RealTime|Type::Pressed);
bool test()const;
bool operator==(const sf::Event& event)const;
bool operator==(const Action& other)const;
private:
friend class ActionTarget;
sf::Event _event;
int _type;
};
让我们一步一步地跟踪这段代码:
-
首先,我们定义一个枚举,它将被构造函数用作标志。
-
然后,我们创建了复制构造函数和复制运算符。
-
接下来是构造函数。目前,我们需要管理来自鼠标和键盘的输入。因此,我们创建了两个构造函数,一个用于每种类型的事件。
-
test()函数将允许我们测试事件是否在实时满足,比较运算符将允许我们比较事件与其他事件。
现在,让我们看看实现:
Action::Action(const Action& other) : _type(other._type)
{
std::memcpy(&_event,&other._event,sizeof(sf::Event));
}
Action& Action::operator=(const Action& other)
{
std::memcpy(&_event,&other._event,sizeof(sf::Event));
_type = other._type;
return *this;
}
这两个函数只是简单地复制Action的内容到另一个Action实例。因为sf::Event类没有实现复制运算符/构造函数,我们使用了 C 字符串模块中的std::memcpy()函数。这允许我们通过知道其大小来简单地复制sf::Event的全部内容,这可以通过sizeof()运算符来实现。请注意,在这种情况下,这从技术上讲是正确的,因为sf::Event不包含任何指针:
Action::Action(const sf::Keyboard::Key& key,int type) : _type(type)
{
_event.type = sf::Event::EventType::KeyPressed;
_event.key.code = key;
}
这里是键盘事件的构造函数。key参数定义了要绑定的键,而type参数定义了输入的状态:实时、按下、释放,或者它们的组合。因为type值是一个标志,它可以同时具有Pressed和Released的值;这会引发一个问题,因为事件类型不能同时是sf::Event::EventType::KeyPressed和sf::Event::EventType::KeyReleased。我们需要绕过这个限制。
为了做到这一点,无论类型的值是什么,都将事件类型设置为 sf::Event::EventType::KeyPressed,我们将在稍后处理一些特殊情况(在 test() 和比较操作符中):
Action::Action(const sf::Mouse::Button& button,int type) : _type(type)
{
_event.type = sf::Event::EventType::MouseButtonPressed;
_event.mouseButton.button = button;
}
这与之前的构造函数是同样的想法。唯一的区别是 event.mouseButton 不能被复制。所以这里我们需要再次使用 std::memcpy():
bool Action::operator==(const sf::Event& event)const
{
bool res = false;
switch(event.type)
{
case sf::Event::EventType::KeyPressed:
{
if(_type & Type::Pressed and _event.type == sf::Event::EventType::KeyPressed)
res = event.key.code == _event.key.code;
}break;
case sf::Event::EventType::KeyReleased:
{
if(_type & Type::Released and _event.type == sf::Event::EventType::KeyPressed)
res = event.key.code == _event.key.code;
}break;
case sf::Event::EventType::MouseButtonPressed:
{
if(_type & Type::Pressed and _event.type == sf::Event::EventType::MouseButtonPressed)
res = event.mouseButton.button == _event.mouseButton.button;
}break;
case sf::Event::EventType::MouseButtonReleased:
{
if(_type & Type::Released and _event.type == sf::Event::EventType::MouseButtonPressed)
res = event.mouseButton.button == _event.mouseButton.button;
}break;
default: break;
}
return res;
}
Action::operator==() 是一个有趣的函数。这个函数将测试两个事件是否等效。但是,因为我们之前已经将键盘和鼠标的值固定为 sf::Event::EventType::[Key/Button]Pressed,我们需要检查这些特殊情况。这些情况由 if 语句表示:
bool Action::operator==(const Action& other)const
{
return _type == other._type and other == _event;
}
这个函数相当简单,首先我们检查类型,然后,我们将比较操作转发到之前定义的比较操作符:
bool Action::test()const
{
bool res = false;
if(_event.type == sf::Event::EventType::KeyPressed)
{
if(_type & Type::Pressed)
res = sf::Keyboard::isKeyPressed(_event.key.code);
}
else if (_event.type == sf::Event::EventType::MouseButtonPressed)
{
if(_type & Type::Pressed)
res = sf::Mouse::isButtonPressed(_event.mouseButton.button);
}
return res;
}
这个函数是为了检查实时事件。正如我已经提到的,我们只需要鼠标和键盘事件。为了检查它们,我们使用静态函数 sf::Keyboard::isKeyPressed() 和 sf::Mouse::isButtonPressed()。这里我们只需要检查事件类型和所需的状态,就是这样。
现在已经创建了 Action 类,让我们继续下一步:将它们绑定到功能上。
动作目标
现在,我们需要一个系统将功能绑定到事件。那么,让我们思考一下什么是功能。
功能是一段代码,当满足某个条件时必须执行。在这里,条件是一个动作,多亏了我们刚刚定义的类,我们现在可以知道事件是否满足条件。但是,关于这段代码呢?如果我们稍微思考一下,功能可以被放入一个函数或方法中,所以这里就是:功能不过是一个函数。因此,为了存储代码,并在运行时绑定它,我们将使用 C++11 的泛型函数包装器:模板类 std::function。
注意
std::function 是任何类型函数、方法和 lambda 的泛型包装器。它是一个非常强大的对象,用于存储回调。为此,我们将使用 C++11 的另一个新类:模板类 std::pair 和一个容器。由于我们的需求,一个 std:: 列表将完全合适。
现在我们已经掌握了所有必要的工具来构建我们需要的。我们将构建一个容器来存储尽可能多的与 std::function 配对的动作:
class ActionTarget
{
public:
using FuncType = std::function<void(const sf::Event&)>;
ActionTarget();
bool processEvent(const sf::Event& event)const;
void processEvents()const;
void bind(const Action& action,const FuncType& callback);
void unbind(const Action& action);
private:
std::list<std::pair<Action,FuncType>> _eventsRealTime;
std::list<std::pair<Action,FuncType>> _eventsPoll;
};
让我们一步一步地看看会发生什么:
-
首先,我们使用新的 C++11
using关键字定义将要管理的函数类型。这种语法与typedef相当,但更加明确。 -
其次,我们定义一个默认构造函数和验证内部事件的方法。我们创建了两个:第一个用于非实时事件(轮询),另一个用于实时事件。
-
然后我们添加一个方法将事件绑定到函数,另一个方法用于移除任何现有的事件。
在内部,你可以选择将实时事件和非实时事件分开,以避免一些if语句。目标是提高可读性和计算能力。
现在来看看实现:
ActionTarget::ActionTarget()
{
}
bool ActionTarget::processEvent(const sf::Event& event)const
{
bool res = false;
for(auto& action : _eventsPoll)
{
if(action.first == event)
{
action.second(event);
res = true;
break;
}
}
return res;
}
void ActionTarget::processEvents()const
{
for(auto& action : _eventsRealTime)
{
if(action.first.test())
action.second(action.first._event);
}
}
两个ActionTarget::processEvent[s]()方法并不复杂,它们简单地通过使用在Action类中创建的函数来检查事件的有效性。如果事件满足条件,我们调用与sf::Event作为参数的关联函数。
这里使用了新的for循环语法。这是 C++11 的for循环的foreach风格,结合了auto关键字。这是一种非常强大且简洁的语法:
void ActionTarget::bind(const book::Action& action,const FuncType& callback)
{
if(action._type & Action::Type::RealTime)
_eventsRealTime.emplace_back(action,callback);
else
_eventsPoll.emplace_back(action,callback)
}
这个方法向内部容器添加了一个新事件及其回调。为了避免在processEvent[s]()方法中的一些if语句,我选择将实时事件与其他事件分开:
void ActionTarget::unbind(const book::Action& action)
{
auto remove_func = &action -> bool
{
return pair.first == action;
};
if(action._type & Action::Type::RealTime)
_eventsRealTime.remove_if(remove_func);
else
_eventsPoll.remove_if(remove_func);
}
在运行时,能够移除一些动作可能会有用。这正是这个函数的思路。我在这里使用了std::list::remove_if()方法来移除所有与参数匹配的内部列表中的动作。它接受一个函数作为参数,因此我们创建了一个 lambda。lambda 函数是 C++11 的一个新特性。它们的语法有一点特别,如下所示:
captured, variables -> returnType { definition };
让我们详细地看一下前面的语法:
-
lambda 就像任何其他函数一样,除了它没有名字(也称为匿名函数)。正因为如此,lambda 不知道上下文,有时,就像这里,你需要从调用上下文中获取一些变量。这些变量必须在
[]部分中指定。你可以根据是否想通过复制还是通过引用来访问它们,在它们前面加上=或&符号。 -
第二部分是参数部分。这部分没有新的内容。参数类型由
std::list::remove_if()函数固定为std::list使用的相同模板参数类型。 -
然后是返回类型。这不是强制性的,因为这个类型可以从返回语句中推断出来,但在这里我选择显式地写出它,作为一个完整的示例。返回类型也由
std::list::remove_if()方法固定为bool。 -
最后,在
{和}之间是 lambda 的实现。这个实现非常简单,因为所有的工作已经在Action类中完成了。
现在到了这里。我们已经有了完整的新的ActionTarget类,准备使用。在这个部分使用了某些新的 C++特性(using、foreach、auto和lambda)。如果你不理解它们,我建议你通过阅读这个网站上的 C++11 来学习:en.cppreference.com/w/cpp/language。在继续阅读之前,真正理解这里使用的内容是非常必要的。所以如果需要,请花尽可能多的时间。
现在我们已经构建了管理事件的系统,让我们使用它。我们将更改我们的玩家,并从ActionTarget扩展它。我们需要稍微更改.hpp文件中的代码。由于 C++允许我们使用多重继承,让我们使用它,并将类从:
class Player : public sf::Drawable {…};
to
class Player : public sf::Drawable , public ActionTarget {…};.
通过这样做,ActionTarget类的功能被添加到Player类中。现在,我们需要更新两个函数:Player::Player()和Player::processEvents()。注意,这个更改意味着对isMoving和rotation属性进行了修改,这些属性现在是Player类的私有成员。
Player::Player() : _shape(sf::Vector2f(32,32))
,_isMoving(false)
_rotation(0)
{
_shape.setFillColor(sf::Color::Blue);
_shape.setOrigin(16,16);
bind(Action(sf::Keyboard::Up),this{
_isMoving = true;
});
bind(Action(sf::Keyboard::Left),this{
_rotation-= 1;
});
bind(Action(sf::Keyboard::Right),this{
_rotation+= 1;
});
}
在这里,我们使用 lambda 函数将键盘键绑定到一些回调。正如你所看到的,我们不需要在函数中检查输入的状态,因为这已经在ActionTarget::processEvents()方法中完成了。回调仅在事件满足时被调用,在这种情况下,当按键被按下时。因此,我们可以直接设置值,因为我们知道按键已被按下。
这里的想法是能够在不更改回调的情况下更改输入。这将非常有兴趣在将来构建一个自定义输入配置:
void Player::processEvents()
{
_isMoving = false;
_rotation = 0;
ActionTarget::processEvents();
}
在这个方法中,我们移除了所有检查输入状态的代码,并将此委托给ActionTaget::processEvents()方法。唯一需要做的事情是重置可以由事件更改的变量。
我们的应用程序的最终结果没有区别,但现在我们有一个很好的起点来管理我们的事件,并且它简化了我们的工作。
事件映射
现在我们已经定义了一个系统来检查我们的事件,那么在运行时更改与功能关联的输入将会非常棒。这将使我们能够创建一个系统,用户可以选择他想要与特定动作关联的哪个键/按钮。目前,我们硬编码了输入。
要做到这一点,我们需要能够将一个键与一个动作关联的东西。这正是std::map和std::unordered_map类所做的事情。因为std::unordered_map在运行时比std::map更快,所以我们更喜欢使用它。这个类来自 C++ 11。
如前所述,我们需要将一个键与一个动作关联,因此我们将创建一个新的类名为ActionMap,它将包含关联映射并提供在运行时添加动作或通过其键获取一个动作的可能性:
template<typename T = int>
class ActionMap
{
public:
ActionMap(const ActionMap<T>&) = delete;
ActionMap<T>& operator=(const ActionMap<T>&) = delete;
ActionMap() = default;
void map(const T& key,const Action& action);
const Action& get(const T& key)const;
private:
std::unordered_map<T,Action> _map;
};
这里没有什么复杂的,我们只是围绕容器创建了一个包装器,并使类以这种方式无法通过默认空构造函数进行复制。我们还使类成为模板,以便能够选择任何类型的键。实际上,我们通常会使用整数,但有时,使用字符串作为键可能很有趣。这就是为什么模板类型默认为int的原因。现在,让我们看看它的实现:
template<typename T>
void ActionMap<T>::map(const T& key,const Action& action)
{
_map.emplace(key,action);
}
template<typename T>
const Action& ActionMap<T>::get(const T& key)const
{
return _map.at(key);
}
实现非常简单易懂。我们只需将我们想要执行的操作转发到内部容器。由于std::unordered_map在尝试进行无效访问时抛出异常,例如,我们不需要任何测试。
注意
注意,由于该类是模板,实现必须在头文件中完成。但是,为了不在头文件中失去可读性,还有一种方法;将代码放在一个.tpl文件(tpl是模板词的简称)中,并在头文件的末尾包含它。通过这样做,我们将声明与实现分开。这是一个好的做法,我建议你应用它。.inl文件扩展名也很常见(inline 词的缩写)而不是.tpl。
如果你注意的话,这个类不是静态的,可以实例化。这样,它将允许我们在项目中使用多个ActionMap类,例如,一个用于存储玩家输入,另一个用于存储系统输入。但是,这种方法与我们的实际ActionTarget类冲突,因此我们需要对其进行一点修改。
返回到动作目标
由于我想在事件系统中尽可能通用,我们需要对我们的ActionTarget类进行一点修改:
-
首先,
ActionTaget类需要与ActionMap链接。这将允许我们在单个项目中使用多个ActionMap,这可以非常有趣。 -
此外,因为动作现在存储在
ActionMap中,ActionTarget不再需要存储它们,而是需要存储获取它们的键。 -
最后,因为
ActionMap是一个模板类,所以我们需要将ActionTaget也转换为模板类。
新的头文件看起来像这样:
template<typename T = int>
class ActionTarget
{
public:
ActionTarget(const ActionTarget<T>&) = delete;
ActionTarget<T>& operator=(const ActionTarget<T>&) = delete;
using FuncType = std::function<void(const sf::Event&)>;
ActionTarget(const ActionMap<T>& map);
bool processEvent(const sf::Event& event)const;
void processEvents()const;
void bind(const T& key,const FuncType& callback);
void unbind(const T& key);
private:
std::list<std::pair<T,FuncType>> _eventsRealTime;
std::list<std::pair<T,FuncType>> _eventsPoll;
const ActionMap<T>& _actionMap;
};
主要的改变是将所有对Action类的引用转换为模板类型。现在,动作将通过其键来识别。由于我们需要在运行时访问Action实例,我们需要有一种方法来访问它们。
这里,我使用 SFML 逻辑:一个大对象和一个用于使用它的前端类。大对象是ActionMap,前端是ActionTarget。因此,我们内部存储一个用于存储事件的ActionMap的引用,并且由于我们不需要修改它,我们将其设置为常量。
所有这些更改都影响我们的类实现。我们不再直接访问Action实例,而是需要通过调用ActionMap::get()来获取它,但这并不比这更困难。真正重要的更改是在Player类中进行的,因为现在,我们有在运行时更改输入的可能性,但我们还需要一些默认输入,因此我们需要添加一个初始化输入的函数。
由于玩家没有无限的可能的控制,我们可以创建一个enum来存储代码中将使用的所有键。目前我们只有一个玩家,所以我们可以将这个函数作为静态的来展示。这意味着ActionMap内部使用的也必须是静态的。这个ActionMap将被添加为Player类的静态属性。这是类的新头文件:
class Player : public sf::Drawable , public ActionTarget<int>
{
public:
Player(const Player&) = delete;
Player& operator=(const Player&) = delete;
Player();
template<typename ... Args>
void setPosition(Args&& ... args);
void processEvents();
void update(sf::Time deltaTime);
enum PlayerInputs {Up,Left,Right};
static void setDefaultsInputs();
private:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
sf::RectangleShape _shape;
sf::Vector2f _velocity;
bool _isMoving;
int _rotation;
static ActionMap<int> _playeInputs;
};
如您所见,已经添加了Player::PlayerInputs枚举、Player::setDefaultsInputs()函数和Player::_playerInputs属性。我们还更改了ActionMap的类型为ActionMap<int>,因为我们将使用新创建的enum作为键;enum的默认类型是int。玩家类的实现没有变化,除了在构造函数中。我们不是直接创建一个动作并绑定它,而是首先初始化ActionMap(在Player::setDefaultsInputs中),然后使用存储在enum中的键来引用动作。
因此,这里是新的构造函数:
Player::Player() : ActionTarget(_playerInputs)
,_shape(sf::Vector2f(32,32))
,_isMoving(false)
,_rotation(0)
{
_shape.setFillColor(sf::Color::Blue);
_shape.setOrigin(16,16);
bind(PlayerInputs::Up,this{
_isMoving = true;
});
bind(PlayerInputs::Left,this{
_rotation-= 1;
});
bind(PlayerInputs::Right,this{
_rotation+= 1;
});
}
如您所见,我们还需要指定ActionTarget构造函数的_playerInputs参数,并将所有的Action构造转换为它们关联的键:
void Player::setDefaultsInputs()
{
_playerInputs.map(PlayerInputs::Up,Action(sf::Keyboard::Up));
_playerInputs.map(PlayerInputs::Right,Action(sf::Keyboard::Right));
_playerInputs.map(PlayerInputs::Left,Action(sf::Keyboard::Left));
}
在这里,我们只是用一些默认键初始化了_playerInputs。这些键与之前的类似,但因为在Player类中_playerInputs是一个静态成员,它必须创建在某个地方。一个好的做法是在.cpp文件中定义它。所以Player.cpp文件中的最后一个更改就是这一行:
ActionMap<int> Player::_playerInputs;
这将创建一个预期的对象。
我们还需要通过调用Player::setDefaultsInputs()来初始化ActionMap。为此,只需在游戏创建之前将此调用添加到main中。现在main应该看起来像这样:
int main(int argc,char* argv[])
{
book::Player::setDefaultsInputs();
book::Game game;
game.run();
return 0;
}
最终结果没有变化,但我认为您可以理解所创建的事件系统的强大之处。它允许我们在运行时绑定功能并更改键绑定,这将在未来非常有用。
实际应用的结果应该看起来像这样:

您还应该能够使用键盘的左右箭头旋转方块,并通过按上箭头移动它。下一步将是将这个愚蠢的方块变成一个漂亮的宇宙飞船。
跟踪资源
在一般游戏开发中,术语资源定义了一个将在应用程序运行时加载的外部组件。大多数情况下,资源是一个多媒体文件,如音乐和图像,但它也可以是一个脚本或配置文件。在这本书中,术语资源将主要指多媒体资源。
资源需要更多的内存,这其中的一个后果是所有对其的操作都运行得比较慢,例如复制操作。还有一点是,我们不希望相同的资源在内存中加载多次。为了避免所有这些,我们将以特定的方式使用它们,借助资源管理器。大多数时候,资源是从文件加载到硬盘上的,但还有其他方法可以加载它们,例如从内存或网络加载。
SFML 中的资源
SFML 库处理大量不同类型的资源:
| 图形模块 | 音频模块 |
|---|---|
| 纹理 | 声音缓冲区 |
| 图像 | 音乐 |
| 字体 | |
| 着色器 |
所有这些资源都有一些共同点。首先,我们不能直接将它们用作屏幕或扬声器的输出。我们必须使用一个前端类,该类不持有数据,而是持有对数据的引用。其中一个影响是复制对象更快。其次,所有这些资源类都共享相同的 SFML API(应用程序编程接口),有时会有一些偏差。一个典型的例子是从硬盘加载资源,其签名如下:
bool loadFomFile(const std::string &filename);
此函数接受要加载的文件的完整路径(相对或绝对),如果加载成功则返回 true,如果出错则返回 false。检查返回值非常重要,以处理可能出现的错误,大多数情况下是无效路径。
此类成员函数还有其他版本,允许我们从不同类型的媒体中加载资源。函数 bool loadFromMemory(const void *data, std::size_t size); 允许用户从 RAM 中加载资源。此函数的一个典型用途是从硬编码的数据中加载资源。SFML 的另一个选项是从自定义流中加载资源:
bool loadFromStream(sf::InputStream& stream);
这允许用户完全定义加载过程。它可以用来从压缩或加密的文件、从网络或从您想要的任何设备加载数据。但就目前而言,我们将关注文件方式(loadFromFile())来设计我们的未来资源管理器。在开始创建它之前,先看看每个 SFML 资源类。
纹理类
sf::Texture 类将图像表示为一个像素数组。每个像素是一个 RGBA(红色、绿色、蓝色、透明度)值,它定义了图像特定位置的颜色。这个像素数组存储在图形卡上,在视频内存中,因此不使用任何 RAM。由于 sf::Texture 存储在视频内存中,图形卡可以快速访问它进行每次绘制,但 sf::Texture 的操作(更改)不如 sf::Image 自由。每次我们想要更改它时,都需要使用 sf::Texture::upload() 函数将其重新上传到视频内存。这些操作相当慢,所以使用时要小心。SFML 支持几种常见的图像格式:.bmp、.png、.tga、.jpg、.gif、.psd、.hdr 和 .pic。请注意,.png 图像可以是透明的,并且可以具有 alpha 通道以平滑透明背景的边缘。
用于显示 sf::Texture 的前端类是 sf::Sprite。它是具有自己变换、颜色和位置的纹理表示。重要的是,只要使用它的 sf::Sprite 存活,sf::Texture 就必须存活,以避免未定义的行为。这是因为 sf::Sprite 不复制纹理数据,而是保持对其的引用。
图像类
sf::Image 类的行为类似于 sf::Texture 类,但由于其存储方式,存在一些重要差异。像素数组存储在 RAM 中,而不是图形卡上。这带来了多重影响。首先,可以无需任何传输修改图像的每个像素。其次,可以将图像保存回硬盘上的文件。最后,无法直接在屏幕上显示图像。我们需要执行以下步骤:
-
首先,将其转换为
sf::Texture -
然后,创建一个指向纹理的
sf::Sprite -
最后,显示这个精灵。
即使整个图像不需要显示,也可以只使用其中的一部分。因此,在图形卡上不会浪费内存。sf::Texture 和 sf::Image 支持的文件格式完全相同。
重要的是仅在真正需要时才限制使用 sf::Image,例如,在运行时修改加载的图像,访问其任何像素,或将它分割成多个 sf::Texture 类。在其他情况下,建议直接使用 sf::Texture 以解决性能问题。
字体类
sf::Font 类允许我们加载和操作字符字体。大多数常见的字体类型都受支持,例如 TrueType,Type 1,CFF,OpenType,SFNT,X11 PCF,Windows FNT,BDF,PFR 和 Type 42。sf::Font 类持有数据,但无法直接使用。您需要使用前端类 sf::Text,就像 sf::Sprite 用于 sf::Texture 一样。这个类有一些属性,如字体大小、颜色、位置、旋转等。只要所有引用它的 sf::Text 都存在,sf::Font 类必须保持可访问。
注意
在 SFML 2.1 中,sf::Text 没有默认字体,因此您至少需要一个字体文件来在您的应用程序中显示它们。默认系统字体将完全不被使用。此外,sf:Text 实际上是一个继承自 sf::Drawable 的对象,并且由 OpenGL 纹理物理表示。您必须注意,每帧更新文本都有处理成本,并且只有在文本更改时才需要更新文本。
着色器类
着色器是一个将在图形卡上直接执行的程序,它使用一种特定的语言编写,GLSL,这与 C 语言非常相似。有两种类型:
-
片段着色器:这会修改一个对象的几何形状
-
像素着色器:这会修改场景中像素的值
着色器非常强大,允许我们对场景应用一些实时操作,例如光照。要使用它们,您只需在 RenderTarget.draw(sf::drawable&, sf::shader) 函数中指定即可。
我建议您在使用之前先阅读 sf::Shader 的整个描述文档。
音频缓冲区类
sf::SoundBuffer 类用于存储音效。这个类特别设计用来在内存中以 16 位有符号整数数组的形式存储整个音频样本。用于需要无延迟且可以适应内存的短音频样本,例如脚步声或枪声。
支持多种音频格式,例如 .ogg, .wav, .flac, .aiff, .au, .raw, .paf, .svx, .nist, .voc, .ircam, .w64, .mat4, .mat5 pvf, .htk, .sds, .avr, .sd2, .caf, .wve, .mpc2k, 和 .rf64。请注意,.mp3 格式由于其限制性许可而不受支持。
与 sf::Texture 类似,sf::SoundBuffer 持有数据,但无法直接播放。我们需要使用 sf::Sound 类来完成这个任务。sf::Sound 类提供了一些常见功能,如播放、停止和暂停,但我们也可以更改其音量、音调和位置。一个 sf::Sound 类引用 ssf::SoundBuffer,它必须保持有效,只要 sf::Sound 在播放。
音乐类
sf::Music类是用来播放音乐的类。与适合短效果音的sf::SoundBuffer不同,sf::Music旨在处理长音乐主题。主题通常比效果长得多,需要大量内存来完全存储它们。为了克服这一点,sf::Music不会一次性加载整个资源,而是流式传输。这对于需要数百 MB 内存的大型音乐文件来说非常有用,可以避免内存饱和。此外,sf::Music几乎没有加载延迟。
与其他资源不同,sf::Music没有轻量级类。你可以直接使用它。它允许我们使用与sf::SoundBuffer和sf::Sound配对相同的特性,例如播放、暂停、停止、请求其参数(通道和采样率),以及更改其播放方式(音调、音量和 3D 位置)。
作为音频流,音乐文件会在自己的线程中播放,以避免阻塞程序的其余部分。这意味着在调用play()之后,你可以单独处理音乐文件,它会很好地管理自己。
用例
在本章的早期,我解释过我们将把蓝色方块变成一艘漂亮的宇宙飞船。现在是时候这么做啦。以下是将会得到的结果:

这不是很大的变化,但它是我们未来游戏的起点。
要做到这一点,我们需要将代表Player类的sf::RectangleShape转换为sf::Sprite。我们还将把_shape属性名称改为_ship;但是有一个问题:存储飞船图像的纹理在哪里?为了使玩家的属性可以是一个解决方案,因为只有一个玩家,但我们将采用另一种方法:资源管理器。
在开始创建管理器之前,让我们先来谈谈资源获取即初始化(Resource Acquisition Is Initialization,RAII)惯用法。
RAII 惯用法
RAII 是一种原则,其中资源通过类的构造和析构来获取和释放,因为这些两个函数会自动调用。它比手动管理每次执行都有优势,即使在发生某些异常的情况下也是如此。它在 C++11 中与智能指针类一起使用,并且可以用于任何类型的资源,例如文件,或者在我们的情况下,SFML 资源。
构建资源管理器
资源管理器的目标是管理资源,并确保所有资源只加载一次,以避免任何更多的副本。
如前所述,我们关注从硬盘加载的资源,因此避免任何重复的一个好方法就是使用资源标识符。
我们将再次使用 std::unordered_map,并围绕它构建一个包装器,作为 ActionMap 类。因为 SFML 提供了多种不同类型的资源,我不想为每一种都创建一个,所以我将资源管理器再次构建为一个模板类。但这次,模板类型将是资源和键类型。我们将使用 RAII 习语来自动加载和释放资源。
这个类看起来是这样的:
template<typename RESOURCE,typename IDENTIFIER = int>
class ResourceManager
{
public:
ResourceManager(const ResourceManager&) = delete;
ResourceManager& operator=(const ResourceManager&) = delete;
ResourceManager() = default;
template<typename ... Args>
void load(const IDENTIFIER& id,Args&& ... args);
RESOURCE& get(const IDENTIFIER& id)const;
private:
std::unordered_map<IDENTIFIER,std::unique_ptr<RESOURCE>> _map;
};
我们以这种方式创建类,使其不能被复制,并创建一些函数来加载资源,另一个函数来获取它。因为所有 SFML 资源类对 loadFromFile() 函数的参数并不完全相同(sf::Shader),我决定使用一个模板,它将参数精确地转发,就像 Player::setPosition()。
此外,一些类不能被复制,因此我们需要使用指针将它们存储在容器中。由于 RAII 习语,我们选择了使用 std::unique_ptr 模板类。
注意
C++11 新增的一个类是 std::unique_ptr,它是一种智能指针。它的内部使用 RAII 习语,因此我们不需要管理内存的释放。
现在的实现如下:
template<typename RESOURCE,typename IDENTIFIER>
template<typename ... Args>
void ResourceManager<RESOURCE,IDENTIFIER>::load(const IDENTIFIER& id,Args&& ... args)
{
std::unique_ptr<RESOURCE> ptr(new RESOURCE);
if(not ptr->loadFromFile(std::forward<Args>(args)...))
throw std::runtime_error("Impossible to load file");
_map.emplace(id,std::move(ptr));
}
C++11 的一个特性是 std::move,它允许我们使用移动构造函数而不是复制构造函数。std::unique_ptr 模板类支持这种构造函数类型,因此使用它似乎是个好主意。移动语义背后的想法是通过获取其内容而不是复制它来丢弃临时对象。结果是性能的提升。
在这里,我们使用模板参数 RESOURCE 创建一个新的资源,然后使用参数包 args 从硬盘加载资源。最后,我们将它内部存储。
注意,如果加载失败,会抛出异常而不是返回 false 值:
template<typename RESOURCE,typename IDENTIFIER>
RESOURCE& ResourceManager<RESOURCE,IDENTIFIER>::get(const IDENTIFIER& id)const
{
return *_map.at(id);
}
这个函数简单地通过传递 id 参数将任务委托给 std::unordered_map::at() 函数。当找不到对象时,::at() 方法会抛出异常。
由于我们的实际 ResourceManager 类在 load() 方法中使用 loadFromFile(),我们遇到了 sf::Music 类的问题。LoadFromFile() 在 sf::Music 类中不存在,被 openFromFile() 替换。因此,我们需要修复这个问题。
要做到这一点,我们将使用 partial 特化。部分特化是模板编程中用于创建特殊情况的技巧,就像这个例子一样。我们需要在 RESOURCE 设置为 sf::Music 时特化 load() 方法。问题是,我们无法直接这样做,因为 ResourceManager 类有两个模板参数,另一个不需要修复。因此,我们必须通过创建一个新的类来特化整个类:
template<typename IDENTIFIER>
class ResourceManager<sf::Music,IDENTIFIER>
{
public:
ResourceManager(const ResourceManager&) = delete;
ResourceManager& operator=(const ResourceManager&) = delete;
ResourceManager() = default;
template<typename ... Args>
void load(const IDENTIFIER& id,Args&& ... args);
sf::Music& get(const IDENTIFIER& id)const;
private:
std::unordered_map<IDENTIFIER,std::unique_ptr<sf::Music>> _map;
};
这个类是前一个类的副本,除了我们移除了一个模板参数以将其固定为 sf::Music。以下是实现:
template<typename IDENTIFIER>
template<typename ... Args>
void ResourceManager<sf::Music,IDENTIFIER>::load(const IDENTIFIER& id,Args&& ... args)
{
std::unique_ptr<sf::Music> ptr(new sf::Music);
if(not ptr->openFromFile(std::forward<Args>(args)...))
throw std::runtime_error("Impossible to load file");
_map.emplace(id,std::move(ptr));
};
template<typename IDENTIFIER>
sf::Music& ResourceManager<sf::Music,IDENTIFIER>::get(const IDENTIFIER& id) const
{
return *_map.at(id);
}
在这里,这完全一样,只是我们将 loadFromFile() 改为了 openFromFile()。
最后,我们构建了一个具有特化的类来处理所有 SFML 资源类型,并在需要时使用 RAII 习语来释放内存。
下一步是使用这个类来改变玩家的外观。
改变玩家的皮肤
现在我们已经建立了一个很好的系统来管理任何类型的资源,让我们使用它们。如前所述,要将玩家的方块变成一艘船,我们需要改变 sf::RectangleShape that,代表 Player 类在 sf::Sprite 中的表示,然后设置由纹理管理器加载的 sf::Sprite 的纹理源。因此,我们需要一个纹理管理器。
如果我们仔细想想,所有管理器都将对我们应用程序是全局的,所以我们将它们组合成一个名为 Configuration 的静态类。这个类将包含所有游戏配置和管理器。ActionMap 也可以存储在这个类中,所以我们将 ActionMap 从玩家移动到这个新类中,并创建一个 initialize() 方法来初始化所有输入和纹理。
这个类非常简单,不能实例化,所以所有属性和方法都将设置为静态:
class Configuration
{
public:
Configuration() = delete;
Configuration(const Configuration&) = delete;
Configuration& operator=(const Configuration&) = delete;
enum Textures : int {Player};
static ResourceManager<sf::Texture,int> textures;
enum PlayerInputs : int {Up,Left,Right};
static ActionMap<int> player_inputs;
static void initialize();
private:
static void initTextures();
static void initPlayerInputs();
};
如你所见,这个类并不真正困难。我们只是将 _playerInputs 和 enum 从 Player 类中移出,并为纹理添加了 ResourceManager。以下是实现方式:
ResourceManager<sf::Texture,int> Configuration::textures;
ActionMap<int> Configuration::player_inputs;
void Configuration::initialize()
{
initTextures();
initPlayerInputs();
}
void Configuration::initTextures()
{
textures.load(Textures::Player,"media/Player/Ship.png");
}
void Configuration::initPlayerInputs()
{
player_inputs.map(PlayerInputs::Up,Action(sf::Keyboard::Up));
player_inputs.map(PlayerInputs::Right,Action(sf::Keyboard::Right));
player_inputs.map(PlayerInputs::Left,Action(sf::Keyboard::Left));
}
在这里,代码很简单。我们现在只需要在玩家类中做一些更改,就可以将其绘制成一艘太空船。我们需要将 sf::RectangleShape _shape 替换为 sf::Sprite _ship;。
在构造函数中,我们需要按照以下方式设置精灵的纹理和原点:
_ship.setTexture(Configuration::textures.get(Configuration::Textures::Player));
_ship.setOrigin(49.5,37.5);
在做其他任何事情之前,不要忘记从 main() 中调用 Configuration::initialize()。我们现在有一个很棒的太空船作为玩家。
虽然为了得到这个结果有很多代码和不同的类,但如果仔细思考,这真的会帮助我们,并减少我们最终应用程序中的代码行数。
概述
在本章中,我们介绍了通用游戏架构、输入管理和资源。你还学习了 RAII 习语以及一些 C++11 特性,如 lambda、变长模板、智能指针、移动语法和完美转发。
所有基本构建块现在都已设置好,所以在下章中,我们将通过完成当前应用程序将其提升为小行星游戏来制作完整的游戏,我们还将构建一个俄罗斯方块游戏。
第三章. 制作完整的 2D 游戏
在这一章中,我们将最终制作我们的第一个游戏。实际上,我们将构建两个游戏,如下所示:
-
我们将通过提高我们对 SFML 的实际应用来构建我们的第一个游戏,一个类似小行星的游戏。
-
我们接下来的游戏将是一个类似俄罗斯方块的克隆游戏
我们还将学习一些技能,例如:
-
实体模型
-
板块管理
我们都是复古游戏的粉丝,所以让我们立即开始创建一些游戏。此外,这两个游戏的结构完全不同。从学习过程的角度来看,这真的很有趣。
将我们的应用程序转换为小行星克隆
小行星是由 Atari Inc.于 1979 年创建的街机“射击”游戏,被认为是经典之作。玩家控制一艘宇宙飞船在星云中,有时屏幕上会出现飞碟攻击它。这个游戏的目标是通过射击摧毁所有的星云和飞碟。每个关卡都会增加星云的数量,游戏难度也会逐渐增加。
要构建这个游戏,我们将以我们的实际应用为基础,但我们需要添加很多东西。
玩家类
玩家被表示为一艘宇宙飞船。宇宙飞船可以左右旋转,射击,并且宇宙飞船还可以给自己加速。玩家还可以将飞船送入超空间,使其消失并在屏幕上的随机位置重新出现,但存在自我毁灭或出现在小行星上的风险。
玩家开始时有三条生命,每获得 10,000 分,就会赢得一条新生命。如果玩家撞到某个东西,它将被摧毁,玩家将失去一条生命。它将在起点重新出现,即屏幕的中间。
关卡
每个关卡开始时,在随机位置有一些大流星,它们以各种方向漂移。每个关卡都会增加流星的数量。这个数量对于第一关是四个,从第五关开始是十一个。
板块有点特殊,因为它是一个欧几里得环面(更多细节请参考维基百科的定义:en.wikipedia.org/wiki/Torus)。屏幕的顶部和底部会相互包裹,左右两侧也是如此,除了右上角与左下角相遇,反之亦然。当屏幕上没有流星时,关卡结束。
敌人
有两种敌人:流星和飞碟。如果你撞到它们,它们都会摧毁你,并且当你射击摧毁它们时,它们都会增加一些分数。
流星
有三种类型的流星。每种流星都有其自己的大小、速度和与其它不同的分数。以下是一个总结不同流星特性的表格:
| 大小 | 大 | 中等 | 小 |
|---|---|---|---|
| 速度 | 慢 | 中等 | 快 |
| 分割 | 2~3 中型 | 2~3 小型 | - |
| 基础分数 | 20 | 60 | 100 |
每当流星撞击时,它会被分裂成更小的流星,除了那些小的流星。大流星也是代表每个等级起始流星场的那些流星。
飞碟
不时地!一个飞碟出现并试图干扰玩家。有两种飞碟,一个大飞碟,除了移动外什么都不做,还有一个小飞碟,它会向玩家射击。玩家的分数越高,小飞碟出现而不是大飞碟的机会就越高。从 40,000 分开始,只有小飞碟出现。此外,玩家拥有的分数越高,飞碟的精度就越高。
修改我们的应用程序
现在我们已经拥有了构建游戏所需的所有信息,让我们开始对其进行修改。第一步是将我们的世界改变为一个具有固定大小的欧几里得环面。以下是从维基百科页面中获取的环面的表示:

要做到这一点,我们需要从游戏内部获取一些信息,例如世界大小。我们将在Game类内部添加两个整数值,height和width:
const int _x, const _y;
我们将使用构造函数来初始化它们。所以现在,我们需要为这个类提供参数:
Game(int x=800, int y=600);
我们需要稍微修改我们的构造函数实现,如下面的代码片段所示:
Game::Game(int x, int y) : _window(sf::VideoMode(x,y),"03_Asteroid"),x(x),y(y){
_player.setPosition(100,100);
}
好吧,现在我们可以选择世界的大小,但如何将其变成一个环面呢?在现实中,这并不复杂。我们只需要在移动每个实体后检查它们的位置;如果它们超出了世界范围,我们就纠正它们的位置。
让我们用下面的代码片段尝试一下玩家:
void Game::update(sf::Time deltaTime)
{
_player.update(deltaTime);
sf::Vector2f player_pos = _player.getPosition();
if(player_pos.x < 0){
player_pos.x = _x;
player_pos.y = _y - player_pos.y;
} else if (player_pos.x > _x){
player_pos.x = 0;
player_pos.y = _y - player_pos.y;
}
if(player_pos.y < 0)
player_pos.y = _y;
else if(player_pos.y > _y)
player_pos.y = 0;
_player.setPosition(player_pos);
}
如您所见,首先,我们在玩家上调用update()方法,然后如果它超出了世界范围,我们就纠正其位置。我们现在有一个无限的世界。
使用的Player::getPosition()方法如下:
const sf::Vector2f& Player::getPosition()const{return _ship.getPosition();}
唯一令人难过的是,我们在Game类中修改了玩家的位置。如果玩家能自己管理其位置会更好,不是吗?错误!如果你稍微思考一下,你会理解玩家并不关心世界的形状。适应其实体位置的工作是世界的责任,而不是相反。
这里我们有两个选择:保持我们的代码不变或者建立一个更灵活的系统。如果我们快速思考一下管理流星和飞碟所需的要素,第二个选择似乎最好。所以让我们构建一个更灵活的系统。
在游戏开发中,有两种主要的设计模式可以回答这个问题。它们如下:
-
层次实体系统
-
实体组件系统
这些模式中的每一个都以不同的方式回答了这个问题。我们将在世界类之后看到它们。
世界类
我们所有的逻辑实际上都是在 Game 类中实现的。这是一个好方法,但我们还可以做得更好。如果我们仔细想想,Game 类不仅要处理事件、创建窗口并将其他类委托给暂停和菜单系统,还要执行所有的实体管理。
为了更明确,游戏不需要管理任何实体,但它可以创建一个世界并填充它。然后,所有的工作都由世界类来完成。
世界是一个实体的容器,同时也是音效的容器。它具有特定的尺寸、形状和规则(如物理规则)。它还可以在屏幕上显示。最后,这个类的代码片段看起来如下:
class World : public sf::Drawable
{
public:
World(const World&) = delete;
World& operator=(const World&) = delete;
World(float x,float y);
~World();
void add(Entity* entity);
void clear();
bool isCollide(const Entity& other);
int size();
void add(Configuration::Sounds sound_id);
const std::list<Entity*> getEntities()const;
int getX()const;
int getY()const;
void update(sf::Time deltaTime);
private:
std::list<Entity*> _entities;
std::list<Entity*> _entities_tmp;
std::list<std::unique_ptr<sf::Sound>> _sounds;
virtual void draw(sf::RenderTarget& target, sf::RenderStates
states) const override;
const int _x;
const int _y;
};
与其他类一样,我们使 World 类不可复制。我们添加了一些函数来向世界添加实体,以及一些函数来移除它们。因为世界上可能有一些音效,所以我们还添加了一个方法来添加它们。它需要一个来自 Configuration 类的 ID,这与 Textures 的 ID 完全一样。我们还添加了一些函数来获取信息,例如实体的数量、世界的尺寸等等。
现在,如果我们查看属性,我们可以看到两个用于实体的容器。这是一个会使我们的生活更简单的技巧。我将在实现中解释它。另一个容器用于可以添加到世界中的 sf::Sound。我将在实现中解释它。
现在,让我们看看实现。这个类有点长,并且在这个章节中一些函数被简化以节省空间:
World::World(float x,float y): _x(x),_y(y){}
World::~World(){clear();}
这些函数没有难度。构造函数只是设置世界的尺寸,析构函数则清除它;如下代码片段所示:
void World::add(Entity* entity) {
_entities_tmp.push_back(entity);
}
这是一个简单的函数,但我们并没有直接将实体添加到 _entites 容器中。相反,我们将其添加到一个临时容器中,该容器只包含在特定时间段内创建的实体。这样做的原因将在 update() 函数中解释:
void World::clear()
{
for(Entity* entity :_entities)
delete entity;
_entities.clear();
for(Entity* entity :_entities_tmp)
delete entity;
_entities_tmp.clear();
_sounds.clear();
}
在这里,我们通过删除所有实体和音效来清理整个世界。因为我们使用原始指针来处理实体,所以我们需要显式地删除它们,与 sf::Sound 不同:
void World::add(Configuration::Sounds sound_id)
{
std::unique_ptr<sf::Sound> sound(new sf::Sound(Configuration::sounds.get(sound_id)));
sound->setAttenuation(0);
sound->play();
_sounds.emplace_back(std::move(sound));
}
这个函数从 Configuration 类中包含的 sf::SoundBuffer 参数创建一个 sf::Sound 参数,初始化它并播放。因为每个 sf::Sound 都有自己的线程,所以 sf::Sound::play() 参数不会中断我们的主线程。然后,我们将它存储在适当的容器中:
bool World::isCollide(const Entity& other)
{
for(Entity* entity_ptr : _entities)
if(other.isCollide(*entity_ptr))
return true;
return false;
}
World::isCollide() 函数是一个辅助函数,用于检查一个实体是否与另一个实体发生碰撞。这将在游戏开始时用于放置流星:
int World::size(){return _entities.size() + _entities_tmp.size();}
int World::getX()const{return _x;}
int World::getY()const {return _y;}
const std::list<Entity*> World::getEntities()const {return _entities;}
这些函数相当简单。它们只是包含一些获取器。唯一特别的是 size() 函数,因为它返回实体的总数:
void World::update(sf::Time deltaTime)
{
if(_entities_tmp.size() > 0)
_entities.merge(_entities_tmp);
for(Entity* entity_ptr : _entities)
{
Entity& entity = *entity_ptr;
entity.update(deltaTime);
sf::Vector2f pos = entity.getPosition();
if(pos.x < 0)
{
pos.x = _x;
pos.y = _y - pos.y;
} else if (pos.x > _x) {
pos.x = 0;
pos.y = _y - pos.y;
}
if(pos.y < 0)
pos.y = _y;
else if(pos.y > _y)
pos.y = 0;
entity.setPosition(pos);
}
const auto end = _entities.end();
for(auto it_i = _entities.begin(); it_i != end; ++it_i)
{
Entity& entity_i = **it_i;
auto it_j = it_i;
it_j++;
for(; it_j != end;++it_j)
{
Entity& entity_j = **it_j;
if(entity_i.isAlive() and entity_i.isCollide(entity_j))
entity_i.onDestroy();
if(entity_j.isAlive() and entity_j.isCollide(entity_i))
entity_j.onDestroy();
}
}
for(auto it = _entities.begin(); it != _entities.end();)
{
if(not (*it)->isAlive())
{
delete *it;
it = _entities.erase(it);
}
else
++it;
}
_sounds.remove_if([](const std::unique_ptr<sf::Sound>& sound) -> bool {
return sound->getStatus() != sf::SoundSource::Status::Playing;
});
}
这个函数比之前的版本要复杂一些。让我们详细解释一下:
-
我们将实体的容器合并到主容器中。
-
我们更新所有实体,然后验证它们的位置是否正确。如果不正确,我们会纠正它们。
-
我们检查所有实体和已死亡实体的碰撞,并移除已死亡的实体。
-
已播放的声音从容器中移除。
在更新和碰撞循环中,一些实体可以创建其他实体。这就是 _entities_tmp 容器存在的原因。这样我们就能确保我们的迭代器在任何时候都不会损坏,并且我们不会更新/碰撞那些还没有经历过一个帧的实体,如下面的代码片段所示:
void World::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
for(Entity* entity : _entities)
target.draw(*entity,states);
}
这个函数很简单,并将它的任务转发给所有实体。如您所见,World类并不复杂,管理任何类型的实体和所有声音。通过这样做,我们可以从Game类中移除很多任务,并将其委托给World类。
分层实体系统
这个系统是最直观的。每种实体在您的代码中都是一个不同的类,并且它们都扩展自一个共同的虚拟类,通常称为Entity。所有的逻辑都在Entity::update()函数内部完成。对于我们的项目,层次树可能类似于以下图示:

如您所见,有几个抽象层。对于这个项目,因为我们没有很多不同种类的实体,我们将使用这个解决方案。
实体组件系统
这是一个完全不同的方法。不是将每种类型的实体表示为一个类,而只有一个类:实体。我们给这个实体附加一些属性,如位置、可绘制的能力、一把枪,以及您想要的任何东西。这个系统非常强大,是视频游戏中的一个很好的解决方案,但构建起来也很困难。我不会对这个系统进行更详细的介绍,因为我在下一章会回到它。所以即使我们现在不使用它,也不要沮丧,我们将在下一个项目中构建并使用它。
设计我们的游戏
既然我们已经选择了实体组件系统方法并创建了一个将由它们填充的世界,让我们考虑一下需求。以下表格总结了需求:
| Entity | Parent | 特性 |
|---|---|---|
Entity |
这个可以移动,可以被绘制,可以与其他实体相撞 | |
Player |
Entity | 这个可以射击,由输入控制,可以与除了它射击的目标之外的所有东西相撞 |
Enemy |
Entity | 这个可以通过射击被摧毁,当被射击摧毁时,会给玩家一些分数 |
Saucer |
Enemy | 当分数增加时,这个有更大的几率生成一个小飞碟,这可以与除了飞碟射击之外的所有东西相撞 |
BigSaucer |
Saucer | 这有一个特殊的皮肤 |
SmallSaucer |
Saucer | 这个可以射击Player实体,它有一个特殊的皮肤 |
Meteors |
Enemy | 这个可以与除了其他陨石之外的所有东西相撞 |
BigMeteor |
流星 | 在被摧毁时分裂成一些 MediumMeteor,并且具有特殊皮肤 |
MediumMeteor |
流星 | 在被摧毁时分裂成 SmallMeteor,并且具有特殊皮肤 |
SmallMeteor |
流星 | 具有特殊皮肤 |
Shoot |
实体 | 这存在特定的时间 |
ShootPlayer |
射击 | 这只能与敌人发生碰撞,并且具有特定皮肤 |
ShootSaucer |
射击飞碟 | 这可以与 Meteor 和 Player 发生碰撞,并且具有特殊皮肤 |
现在我们已经拥有了每个类所需的所有信息,让我们开始构建它们。最终结果将类似于以下内容:

准备碰撞
在这个项目中,我们将使用简单的碰撞检测:圆形之间的碰撞。正如刚才所说的,这非常基础,可以大幅改进,但就目前而言是足够的。看看这个类:
class Collision
{
public:
Collision() = delete;
Collision(const Collision&) = delete;
Collision& operator=(const Collision&) = delete;
static bool circleTest(const sf::Sprite& first, const sf::Sprite& second);
};
这里没有成员,该类不能被实例化。该类旨在将其他类使用的某些辅助函数分组。因此,这里只描述了一个碰撞测试,它接受两个 sf::Sprite 作为参数。看看实现。
bool Collision::circleTest(const sf::Sprite& first, const sf::Sprite& second)
{
sf::Vector2f first_rect(first.getTextureRect().width,
first.getTextureRect().height);
first_rect.x *= first.getScale().x;
first_rect.y *= first.getScale().y;
sf::Vector2f second_rect(second.getTextureRect().width,
second.getTextureRect().height);
second_rect.x *= second.getScale().x;
second_rect.y *= second.getScale().y;
float radius1 = (first_rect.x + first_rect.y) / 4;
float radius2 = (second_rect.x + second_rect.y) / 4;
float xd = first.getPosition().x - second.getPosition().x;
float yd = first.getPosition().y - second.getPosition().y;
return std::sqrt(xd * xd + yd * yd) <= radius1 + radius2;
}
函数首先计算每个精灵的半径。然后它检查两个精灵之间的距离(使用勾股定理计算)是否小于两个半径之和。如果是的话,则没有碰撞,否则,存在碰撞,即使我们不知道确切的碰撞点。
实体类
为了构建我们的系统,我们需要基类,所以让我们从 Entity 类开始:
class Entity : public sf::Drawable
{
public:
//Constructors
Entity(const Entity&) = delete;
Entity& operator=(const Entity&) = delete;
Entity(Configuration::Textures tex_id,World& world);
virtual ~Entity();
//Helpers
virtual bool isAlive()const;
const sf::Vector2f& getPosition()const;
template<typename ... Args>
void setPosition(Args&& ... args);
virtual bool isCollide(const Entity& other)const = 0;
//Updates
virtual void update(sf::Time deltaTime) = 0;
virtual void onDestroy();
protected:
sf::Sprite _sprite;
sf::Vector2f _impulse;
World& _world;
bool _alive;
private :
virtual void draw(sf::RenderTarget& target, sf::RenderStates
states) const override;
};
让我们一步一步地讨论这个类:
-
首先,我们使该类不可复制。
-
然后我们将析构函数设置为虚拟。这是一个非常重要的点,因为
Entity类将被用作多态类。因此,我们需要将析构函数设置为虚拟,以便能够销毁真实对象,而不仅仅是它的Entity基类。 -
我们还定义了一些辅助函数,以确定实体是否存活,以及设置/获取其实体位置。代码与我们在
Player类中使用的代码相同。我们还定义了一些将在其他类中重写的虚拟方法。 -
虚拟函数
onDestroy()非常重要。它的目的是在实体被销毁之前执行一些代码,例如通过射击或其他方式。例如,Meteor实体的分裂能力将被放入这个函数中,以及所有由对象摧毁引起的各种声音。
现在看看 Entity 类的实现:
Entity::Entity(Configuration::Textures tex_id,World& world) : _world(world),_alive(true)
{
sf::Texture& texture = Configuration::textures.get(tex_id);
_sprite.setTexture(texture);
_sprite.setOrigin(texture.getSize().x/2.f,texture.getSize().y/2.f);
}
构造函数将纹理设置为内部的 sf::Sprite 函数,然后将其原点居中。我们还设置了实体的世界和存活值:
const sf::Vector2f& Entity::getPosition()const {return _sprite.getPosition();}
void Entity::draw(sf::RenderTarget& target, sf::RenderStates states) const {target.draw(_sprite,states);}
这两个函数与 Player 类中的函数完全相同。所以这里没有惊喜:
bool Entity::isAlive()const {return _alive;}
void Entity::onDestroy(){_alive = false;}
这两个函数是新的。它只是一个辅助函数。IsAlive()用于确定实体是否需要从世界中移除,而onDestroy()函数是在检测到与其他Entity的碰撞时将被调用的方法。目前还没有什么复杂的事情。
玩家类
现在我们有了Entity类,让我们将Player类更改为从Entity扩展:
class Player : public Entity , public ActionTarget<int>
{
public:
Player(const Player&) = delete;
Player& operator=(const Player&) = delete;
Player(World& world);
virtual bool isCollide(const Entity& other)const;
virtual void update(sf::Time deltaTime);
void processEvents();
void shoot();
void goToHyperspace();
virtual void onDestroy();
private:
bool _isMoving;
int _rotation;
sf::Time _timeSinceLastShoot;
}
如您所见,我们移除了所有与位置和显示相关的函数和属性。Entity类已经为我们做了这些。现在这个类的实现如下:
Player::Player(World& world) : Entity(Configuration::Textures::Player,world),ActionTarget(Configuration::player_inputs),_isMoving(false),_rotation(0)
{
//bind ..
bind(Configuration::PlayerInputs::Shoot,this{
shoot();
});
bind(Configuration::PlayerInputs::Hyperspace,this{
goToHyperspace();
});
}
现在,我们移除了初始化_sprite函数的所有代码,并将任务委托给Entity构造函数。我们还添加了两个新能力,射击和进入超空间:
bool Player::isCollide(const Entity& other)const
{
if(dynamic_cast<const ShootPlayer*>(&other) == nullptr) {
return Collision::circleTest(_sprite,other._sprite);
}
return false;
}
我们设置了碰撞的默认行为。我们需要知道Entity的实际类型作为参数。为此,我们使用虚拟表查找,尝试将Entity类转换为特定的指针类型。如果这不可能,dynamic_cast()将返回nullptr。还有其他方法可以做到这一点,例如双重分派。但这里使用的是最简单、最容易理解的方法,但操作较慢。一旦知道了实体的实际类型,就会进行碰撞测试。在这个项目中,每个实体的碰撞框是其精灵内切圆。这是一个相当好的近似:
void Player::shoot()
{
if(_timeSinceLastShoot > sf::seconds(0.3))
{
_world.add(new ShootPlayer(*this));
_timeSinceLastShoot = sf::Time::Zero;
}
}
这个函数创建一个ShootPlayer实例并将其添加到世界中。因为我们不希望玩家在每一帧都创建射击,所以我们添加了一个计时器,它在Player::update()方法中更新,如下所示:
void Player::goToHyperspace()
{
_impulse = sf::Vector2f(0,0);
setPosition(random(0,_world.getX()),random(0,_world.getY()));
_world.add(Configuration::Sounds::Jump);
}
这个方法将玩家传送到世界中的随机位置。它还移除了所有推力,因此玩家在传送后不会继续以之前的方向移动:
void Player::update(sf::Time deltaTime)
{
float seconds = deltaTime.asSeconds();
_timeSinceLastShoot += deltaTime;
if(_rotation != 0)
{
float angle = _rotation*250*seconds;
_sprite.rotate(angle);
}
if(_isMoving)
{
float angle = _sprite.getRotation() / 180 * M_PI - M_PI / 2;
_impulse += sf::Vector2f(std::cos(angle),std::sin(angle)) * 300.f *
seconds;
}
_sprite.move(seconds * _impulse);
}
这个方法根据用户的不同操作更新Player的位置和旋转。它还更新自上次射击以来的时间,以便能够再次射击。
void Player::onDestroy()
{
Entity::onDestroy();
Configuration::lives--;
_world.add(Configuration::Sounds::Boom);
}
为了更好地理解Entity::onDestroy()方法,请记住,当发生碰撞时,这个函数在Entity实例的销毁(以及析构函数的调用)之前被调用。因此,在这里我们调用类的Entity基类的onDestroy()函数,然后执行玩家的特殊操作,例如减少生命值、将玩家值设置为nullptr,最后,向世界中添加爆炸声音。Player类的其他方法没有改变。
敌人类
我们现在将创建敌人类,正如我们在设计我们的游戏部分开头的表中已经描述的那样:
class Enemy : public Entity
{
public:
Enemy(const Enemy&) = delete;
Enemy& operator=(const Enemy&) = delete;
Enemy(Configuration::Textures tex_id,World& world);
virtual int getPoints()const = 0;
virtual void onDestroy();
};
与Player类相比,这个类相当小,因为它不需要很多新的逻辑。我们只需要简要指定onDestroy()方法,通过向游戏的全球得分添加分数来实现。因此,我们创建了一个getPoints()方法,它将简单地返回敌人的分数。
Enemy::Enemy(Configuration::Textures tex_id,World& world) :
Entity(tex_id,world)
{
float angle = random(0.f,2.f*M_PI);
_impulse = sf::Vector2f(std::cos(angle),std::sin(angle));
}
构造函数简单地初始化_impulse向量到一个随机的值,但长度为1。这个向量将在它们各自的构造函数中乘以Saucers/Meteor实体的速度:
void Enemy::onDestroy()
{
Entity::onDestroy();
Configuration::addScore(getPoints());
}
这个方法简单地从对象的Entity基类中调用onDestroy()函数,然后添加摧毁对象所获得的分数。
飞盘类
现在我们已经创建了Enemy类,我们可以构建符合我们预期的Saucer基类:
class Saucer : public Enemy
{
public:
Saucer(const Saucer&) = delete;
Saucer& operator=(const Saucer&) = delete;
using Enemy::Enemy;
virtual bool isCollide(const Entity& other)const;
virtual void update(sf::Time deltaTime);
virtual void onDestroy();
static void newSaucer(World& world);
};
这个类相当简单;我们只需指定已经构建在Entity和Enemy类中的方法。因为类没有指定构造函数,所以我们使用 using-declaration 来引用Enemy中的那个。在这里,我们引入了一个新函数,newSaucer()。这个函数将根据玩家的分数随机创建一个飞盘并将其添加到世界中。
现在,看看这个类的实现:
bool Saucer::isCollide(const Entity& other)const
{
if(dynamic_cast<const ShootSaucer*>(&other) == nullptr) {
return Collision::circleTest(_sprite,other._sprite);
}
return false;
}
这里使用了与Player::isCollide()相同的技巧,所以没有惊喜。我们在Saucer基类中指定这个函数,因为任何飞盘的碰撞都是相同的。这避免了代码重复,如下所示:
void Saucer::update(sf::Time deltaTime)
{
float seconds = deltaTime.asSeconds();
Entity* near = nullptr;
float near_distance = 300;
for(Entity* entity_ptr : _world.getEntities())
{
if(entity_ptr != this and(dynamic_cast<const
Meteor*>(entity_ptr) or dynamic_cast<const
ShootPlayer*>(entity_ptr)))
{
float x = getPosition().x - entity_ptr->getPosition().x;
float y = getPosition().y - entity_ptr->getPosition().y;
float dist = std::sqrt(x*x + y*y);
if(dist < near_distance) {
near_distance = dist;
near = entity_ptr;
}
}
}
if(near != nullptr)
{
sf::Vector2f pos = near->getPosition() - getPosition();
float angle_rad = std::atan2(pos.y,pos.x);
_impulse -=
sf::Vector2f(std::cos(angle_rad),std::sin(angle_rad)) * 300.f
* seconds;
} else {
sf::Vector2f pos = Configuration::player->getPosition() -
getPosition();
float angle_rad = std::atan2(pos.y,pos.x);
_impulse +=
sf::Vector2f(std::cos(angle_rad),std::sin(angle_rad)) * 100.f
* seconds;
}
_sprite.move(seconds * _impulse);
}
这个函数相当长,但并不复杂。它管理着飞盘的运动。让我们一步一步地解释它:
-
我们寻找飞盘可能与之碰撞的最近的对象。
-
如果发现一个太靠近的对象,我们向飞盘添加一个与该对象相反方向的推力。目的是避免碰撞。
-
现在我们继续其他函数。
void Saucer::onDestroy() { Enemy::onDestroy(); _world.add(Configuration::Sounds::Boom2); } -
这个函数很简单。我们只是从类的
Enemy基类中调用onDestroy()方法,然后向世界添加一个爆炸声:void Saucer::newSaucer(World& world) { Saucer* res = nullptr; if(book::random(0.f,1.f) > Configuration::getScore()/ 40000.f) res = new BigSaucer(world); else res = new SmallSaucer(world); res->setPosition(random(0,1)*world.getX(),random(0.f,(float)world.getY())); world.add(res); } -
如前所述,这个函数随机创建一个飞盘并将其添加到世界中。玩家拥有的分数越高,创建
SmallSaucer实体的机会就越大。当分数达到 40,000 时,就像游戏描述中解释的那样创建SmallSaucer。
现在我们已经创建了Saucer基类,让我们创建SmallSaucer类。我不会解释BigSaucer类,因为这个类与SmallSaucer类相同,但更简单(没有射击),如下面的代码片段所示:
class SmallSaucer : public Saucer
{
public :
SmallSaucer(World& world);
virtual int getPoints()const;
virtual void update(sf::Time deltaTime);
private:
sf::Time_timeSinceLastShoot;
};
因为我们知道SmallSaucer实体的皮肤,所以我们不需要将纹理 ID 作为参数,所以我们将其从构造函数参数中删除。我们还添加了一个属性,用于存储自上次射击以来经过的时间,就像在Player实体中一样。
现在看看实现:
SmallSaucer::SmallSaucer(World& world) : Saucer(Configuration::Textures::SmallSaucer,world)
{
_timeSinceLastShoot = sf::Time::Zero;
_world.add(Configuration::Sounds::SaucerSpawn2);
_impulse *= 400.f;
}
这个构造函数很简单,因为大部分工作已经在类的基类中完成了。我们只需初始化推力,并在飞盘出现时向世界添加一个声音。这将警告玩家敌人,并为游戏增添一些乐趣:
int SmallSaucer::getPoints()const {return 200;}
这个函数简单地设置了当SmallSaucer实体被摧毁时获得的分数:
void SmallSaucer::update(sf::Time deltaTime)
{
Saucer::update(deltaTime);
_timeSinceLastShoot += deltaTime;
if(_timeSinceLastShoot > sf::seconds(1.5))
{
if(Configuration::player != nullptr)
_world.add(new ShootSaucer(*this));
_timeSinceLastShoot = sf::Time::Zero;
}
}
这个函数相当简单。首先,我们通过从Saucer基类调用update()函数来移动碟子,然后尽可能快地射击玩家,这就是全部。
下面是碟子行为的截图:

流星类
现在是时候构建游戏的主要敌人:流星了。我们将从虚拟的Meteor类开始。以下是它的定义:
class Meteor : public Enemy
{
public:
Meteor(const Meteor&) = delete;
Meteor& operator=(const Meteor&) = delete;
using Enemy::Enemy;
virtual bool isCollide(const Entity& other)const;
virtual void update(sf::Time deltaTime);
};
如您所见,这个类非常短。我们只指定碰撞规则和将管理其移动的更新函数。现在,看看它的实现:
bool Meteor::isCollide(const Entity& other)const
{
if(dynamic_cast<const Meteor*>(&other) == nullptr) {
return Collision::circleTest(_sprite,other._sprite);
}
return false;
}
根据指定,碰撞测试是用所有Entity对象进行的,除了Meteors。在这里,我们再次使用circleTest()函数来测试与其他对象的碰撞:
void Meteor::update(sf::Time deltaTime)
{
float seconds = deltaTime.asSeconds();
_sprite.move(seconds * _impulse);
}
这个函数非常简单。我们只通过计算自上一帧以来移动的距离来移动meteor实体。这里没有复杂的事情要做,因为流星始终是直的,所以它的方向没有变化。
现在我们有了所有流星的基础,让我们来制作大流星。我不会解释其他的,因为逻辑是相同的。下面的代码片段解释了它:
class BigMeteor : public Meteor
{
public :
BigMeteor(World& world);
virtual int getPoints()const;
virtual void onDestroy();
};
您可以看到这个类也非常简洁。我们只需要定义构造函数、得分数量和破坏。现在,这个类的实现如下:
BigMeteor::BigMeteor(World& world) : Meteor((Configuration::Textures)random(Configuration::Textures::BigMeteor1,Configuration::Textures::BigMeteor4),world)
{
_impulse *= 100.f;
}
构造函数不难,但纹理 ID 的选择是。因为对于BigMeteor有几种可能的纹理,我们随机选择其中之一,如下面的代码片段所示:
int BigMeteor::getPoints()const {return 20;h}
void BigMeteor::onDestroy()
{
Meteor::onDestroy();
int nb = book::random(2,3);
for(int i=0;i<nb;++i)
{
MediumMeteor* meteor = new MediumMeteor(_world);
meteor->setPosition(getPosition());
_world.add(meteor);
}
_world.add(Configuration::Sounds::Explosion1);
}
这个方法是最重要的一个。当一个大流星被摧毁时,它会创建一些其他流星并将它们添加到世界中。我们还添加了一个爆炸声,以增加游戏中的趣味性。
射击类
现在所有敌人都已经制作好了,让我们构建最后一个实体类,Shoot。射击非常简单。它只是一个直线移动的实体,并且只在特定时间内存在:
class Shoot : public Entity
{
public:
Shoot(const Shoot&) = delete;
Shoot& operator=(const Shoot&) = delete;
using Entity::Entity;
virtual void update(sf::Time deltaTime);
protected:
sf::Time _duration;
};
这里没有什么令人惊讶的,我们只添加了一个_duration属性,它将存储自Shoot类创建以来的经过时间。现在,更新函数的实现如下:
void Shoot::update(sf::Time deltaTime)
{
float seconds = deltaTime.asSeconds();
_sprite.move(seconds * _impulse);
_duration -= deltaTime;
if(_duration < sf::Time::Zero)
_alive = false;
}
这个函数移动射击并调整_duration属性,通过减去经过的时间。如果射击的生命时间达到零,我们将其设置为死亡,世界将完成剩余的工作。
现在,让我们构建ShootPlayer类:
class ShootPlayer : public Shoot
{
public :
ShootPlayer(const ShootPlayer&) = delete;
ShootPlayer& operator=(const ShootPlayer&) = delete;
ShootPlayer(Player& from);
virtual bool isCollide(const Entity& other)const;
};
如您所见,这里的构造函数已经改变。除了创建射击的源之外,不再有World实例作为参数。让我们看看实现,以便更好地理解原因:
ShootPlayer::ShootPlayer(Player& from) : Shoot(Configuration::Textures::ShootPlayer,from._world)
{
_duration = sf::seconds(5);
float angle = from._sprite.getRotation() / 180 * M_PI - M_PI / 2;
_impulse = sf::Vector2f(std::cos(angle),std::sin(angle)) * 500.f;
setPosition(from.getPosition());
_sprite.setRotation(from._sprite.getRotation());
_world.add(Configuration::Sounds::LaserPlayer);
}
如您所见,世界实例是从源复制的。此外,子弹的初始位置设置为创建Player类时的位置。我们还需要根据需要旋转子弹,并设置其方向。我不会解释碰撞函数,因为没有与之前解释的函数相比的新内容。
ShootSaucer类使用与ShootPlayer类相同的逻辑,但有一个变化。飞碟的准确性会随着玩家得分的增加而变化。所以我们需要添加一点随机性。让我们看看构造函数:
ShootSaucer::ShootSaucer(SmallSaucer& from) : Shoot(Configuration::Textures::ShootSaucer,from._world)
{
_duration = sf::seconds(5);
sf::Vector2f pos = Configuration::player->getPosition() - from.getPosition();
float accuracy_lost = book::random(-1.f,1.f)*M_PI/((200+Configuration::getScore())/100.f);
float angle_rad = std::atan2(pos.y,pos.x) + accuracy_lost;
float angle_deg = angle_rad * 180 / M_PI;
_impulse = sf::Vector2f(std::cos(angle_rad),std::sin(angle_rad)) * 500.f;
setPosition(from.getPosition());
_sprite.setRotation(angle_deg + 90);
_world.add(Configuration::Sounds::LaserEnemy);
}
让我们一步一步地解释这个函数:
-
我们计算子弹的方向向量。
-
我们根据当前得分添加一点精度损失。
-
我们根据计算出的方向设置
_impulsion向量。 -
我们根据需要设置精灵的位置和旋转。
-
最后,我们将它发布到世界上。
现在所有类都已经创建,你将能够玩游戏。最终结果应该看起来像这样:

很不错,不是吗?
构建俄罗斯方块克隆
现在我们已经创建了一个完整的游戏,让我们再创建另一个,一个俄罗斯方块克隆。这个游戏比之前的游戏简单,并且构建时间会更短,但仍然非常有趣。实际上,这个游戏的内部架构与其他游戏真的很不同。这是因为这种游戏类型:一个拼图。游戏的目标是用由四个方块组成的拼图填满网格的行。每次完成一行,它就会被销毁,并且会给玩家加分。因为这是一种不同类型的游戏,所以有一些影响,因为在这个游戏中没有玩家或敌人,只有拼图和棋盘(网格)。对于这个游戏,我将只关注游戏逻辑。所以,我将不会重用之前制作的类,如Action、ActionMap、ActionTarget、Configuration和ResourceManager,以使代码更简洁。当然,你可以使用它们来改进提出的源代码。
因此,为了构建这个游戏,我们需要构建一些类:
-
Game:这个类将与之前项目的Game类非常相似,并将管理渲染。 -
Board:这个类将管理游戏的所有逻辑 -
Piece:这个类将代表所有不同类型的四联体(由四个方块组成的拼图) -
Stats:这个类将用于向玩家显示不同的信息
最终的游戏将看起来像以下截图:

现在我们知道了如何构建一个游戏,我们将直接考虑每个类的需求。
统计类
这个类将用于向玩家显示游戏信息,如关卡、行数和得分。我们还将使用这个类来显示游戏结束信息,如果需要的话。因为这个类将在屏幕上显示一些信息,并且可以被放置在渲染空间的任何位置,我们将从sf::Drawable和sf::Transformable扩展它。以下是这个类的头文件:
class Stats : public sf::Transformable,public sf::Drawable
{
public:
Stats();
void addLines(int lines);
unsigned int getLvl()const;
void gameOver();
private:
virtual void draw(sf::RenderTarget& target,sf::RenderStates states=sf::RenderStates::Default) const override;
unsigned int _nbRows;
unsigned int _nbScore;
unsigned int _nbLvl;
bool _isGameOver;
sf::Text _textRows;
sf::Text _textScore;
sf::Text _textLvl;
sf::Text _textGameOver;
sf::Font _font;
};
这个类没有真正的惊喜。我们有一些sf::Text将用于显示信息,以及它们的数值。我们还添加了点计算到这个类中,使用addLines()函数。
如前所述,对于俄罗斯方块游戏,我们需要关注游戏逻辑,因此我们不会使用任何字体管理器。
现在看看这个类的实现:
constexpr int FONT_SIZE 24;
Stats::Stats() : _nbRows(0), _nbScore(0), _nbLvl(0), _isGameOver(false)
{
_font.loadFromFile("media/fonts/trs-million.ttf");
_textRows.setFont(_font);
_textRows.setString("rows : 0");
_textRows.setCharacterSize(FONT_SIZE);
_textRows.setPosition(0,0);
_textScore.setFont(_font);
_textScore.setString("score : 0");
_textScore.setCharacterSize(FONT_SIZE);
_textScore.setPosition(0,FONT_SIZE + 1);
_textLvl.setFont(_font);
_textLvl.setString("lvl : 0");
_textLvl.setCharacterSize(FONT_SIZE);
_textLvl.setPosition(0,(FONT_SIZE + 1)*2);
_textGameOver.setFont(_font);
_textGameOver.setString("Game Over");
_textGameOver.setCharacterSize(72);
_textGameOver.setPosition(0,0);
}
类的构造函数将所有属性设置为默认值:
void Stats::gameOver(){_isGameOver = true;}
这里同样没有惊喜。我们只是将 _isGameOver 的值设置为 true:
void Stats::addLines(int lines)
{
if(lines > 0)
{
_nbRows += lines;
_textRows.setString("rows : "+std::to_string(_nbRows));
_textScore.setString("score : "+std::to_string(_nbScore));
switch (lines)
{
case 1 : _nbScore += 40 * (_nbLvl+1);break;
case 2 : _nbScore += 100 * (_nbLvl+1);break;
case 3 : _nbScore += 300 * (_nbLvl+1);break;
case 4 : _nbScore += 1200 * (_nbLvl+1);break;
default :break;
}
_nbLvl = _nbRows / 10;
_textLvl.setString("lvl : "+std::to_string(_nbLvl));
}
}
这个函数有点意思。它的目的是根据完成的行数数量向全局得分添加分数。它还会纠正可绘制文本值和关卡。因为一个方块由四个方块组成,所以一个方块最多可以消除的行数是四行。所以在 switch 语句中,我们只需要检查这四种可能性:
unsigned int Stats::getLvl()const{return _nbLvl;}
void Stats::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
if(not _isGameOver)
{
states.transform *= getTransform();
target.draw(_textRows,states);
target.draw(_textScore,states);
target.draw(_textLvl,states);
}
else
target.draw(_textGameOver,states);
}
正如所有其他的 sf::Drawable::draw() 函数一样,这个函数会在屏幕上绘制对象。如果游戏结束,我们会打印出游戏结束的信息;在其他情况下,我们会打印游戏得分、完成行数和当前关卡。
总之,这个类非常简单,它的任务是显示屏幕上的所有游戏信息。
方块类
现在,让我们构建这个游戏中的第一个重要类,即 Piece 类。在俄罗斯方块中,有七种不同的 tetrimino。我不会构建七个不同的类,而只构建一个。目的是展示另一种创建实体的方法。
但是,方块究竟是什么呢?如果你这么想,你会发现方块可以用数字数组来表示。此外,方块可以旋转。有三种方法来做这件事:在运行时计算旋转、在启动时预先计算旋转或在代码中预先定义它们。因为在我们游戏中,每个方块在创建游戏时都是已知的,所以我们将选择最后一种方法:硬编码所有旋转。这看起来可能不好,但实际上并非如此,它将大大简化我们后面的实现,但请记住,在每款游戏中硬编码项目并不是一个好主意。
现在让我们看看这个类:
class Piece
{
public:
static const unsigned short int NB_ROTATIONS = 4; //< number of rotations
static const unsigned short int MATRIX_SIZE = 4; //< size of the matrix
static const unsigned int PIVOT_Y = 1;
static const unsigned int PIVOT_X = 2;
enum TetriminoTypes {O=0,I,S,Z,L,J,T,SIZE}; //< different kind of pieces
static const sf::Color TetriminoColors[TetriminoTypes::SIZE]; //< different colors for each kind of piece
static const char TetriminoPieces[TetriminoTypes::SIZE][NB_ROTATIONS][MATRIX_SIZE][MATRIX_SIZE];//< store all the different shapes
Piece(const Piece&) = delete;
Piece& operator=(const Piece&) = delete;
Piece(TetriminoTypes type,short int rotation);
TetriminoTypes getType()const;
void setRotation(short int rotation); //< set the rotation
short int getRotation()const;
void setPosition(int x,int y);//< set the position in the
//board
int getPosX()const;
int getPosY()const;
sf::Time getTimeSinceLastMove()const;
private:
const TetriminoTypes _type; //< the piece type
short int _rotation; //< the piece rotation
int _positionX; //< position in the board
int _positionY;//< position in the board
sf::Clock _clockSinceLastMove;
};
这个类有点长。让我们一步一步地解释它:
-
我们将定义一些常量变量,这些变量将用于配置目的。
-
我们将定义一个包含所有不同 tetrimino 方块的
enum函数。 -
我们将定义一个颜色数组。每个单元格将代表在
enum函数中先前定义的 tetrimino 的颜色。 -
下一行是特别的。它定义了所有不同的 tetrimino 旋转。因为每个方块是一个二维数组,我们还需要这些信息。
-
其他函数更常见:构造函数、获取器和设置器。
-
我们将定义一些私有属性来存储方块的状态。
现在是这部分有趣的地方,所有这些功能的实现。由于做出的选择不同,与小行星游戏中的前一个实体相比,实现方式会有很大差异:
const sf::Color Piece::TetriminoColors[Piece::TetriminoTypes::SIZE]= {
sf::Color::Blue,
sf::Color::Red,
sf::Color::Green,
sf::Color::Cyan,
sf::Color::Magenta,
sf::Color::White,
sf::Color(195,132,58)
}
这个数组存储了所有由 TetriminoTypes 枚举定义的 tetrimino 的不同颜色:
const char Piece::TetriminoPieces[Piece::TetriminoTypes::SIZE][Piece::NB_ROTATIONS][Piece::MATRIX_SIZE][Piece::MATRIX_SIZE] = {
{ // O
{
{0,0,0,0},
{0,1,2,0},
{0,1,1,0},
{0,0,0,0}
},
//...
{
{0,0,0,0},
{0,1,2,0},
{0,1,1,0},
{0,0,0,0}
}
},
{//I
{
{0,0,0,0},
{1,1,2,1},
{0,0,0,0},
{0,0,0,0}
},
{
{0,0,1,0},
{0,0,2,0},
{0,0,1,0},
{0,0,1,0}
},
{
{0,0,0,0},
{1,1,2,1},
{0,0,0,0},
{0,0,0,0}
},
{
{0,0,1,0},
{0,0,2,0},
{0,0,1,0},
{0,0,1,0}
}
},
//...
};
乍一看,这似乎是一个非常特殊的数组,但结果并非如此。实际上,每个不同的棋子在数组的第一个单元格中定义,第二个单元格表示这个棋子的所有不同旋转,其余的是棋子旋转的二维数组表示。0 值表示空白,2 表示棋子的中心,1 表示四连珠的另一个棋子。我没有放所有代码,因为代码相当长,但如果需要,您可以在 03_Simple_2D_game/Tetris/src/SFML-Book/Piece.cpp 中查看它。
Piece::Piece(TetriminoTypes type,short int rotation) : _type(type), _rotation(rotation), _positionX(0), _positionY(0) {assert(rotation >= 0 and rotation < NB_ROTATIONS);}
注意
assert 函数是一个宏,如果表达式(如参数)为假,它将引发错误并退出程序。您可以通过在代码/编译器选项中添加 #define NDEBUG 来删除它,以禁用此功能。
assert() 函数仅在调试模式下有用。当您想要确保在运行时遵循特定情况时使用它。
Piece 类的构造函数很简单,但我们很容易向它发送错误的参数值。因此,我决定向您展示断言功能,如下所示:
Piece::TetriminoTypes Piece::getType()const {return _type;}
short int Piece::getRotation()const {return _rotation;}
int Piece::getPosX()const {return _positionX;}
int Piece::getPosY()const {return _positionY;}
sf::Time Piece::getTimeSinceLastMove()const {return _clockSinceLastMove.getElapsedTime();}
void Piece::setRotation(short int rotation)
{
assert(rotation >= 0 and rotation < NB_ROTATIONS);
_rotation = rotation;
_clockSinceLastMove.restart();
}
void Piece::setPosition(int x,int y)
{
_positionX = x;
_positionY = y;
_clockSinceLastMove.restart();
}
所有这些函数都是获取器和设置器,它们很简单。唯一特别的是 setPosition/Rotation() 函数,因为它们还会重置内部时钟。由于时钟存储自上次移动棋子以来的时间,实际上它不应该让您感到惊讶。
Board 类
现在,所有的棋子都制作好了,让我们构建一个将管理它们的类,即 Board。
这个类将表示为一个存储颜色的网格(数组)。因此,从内部来看,这个类只是一个整数数组。每个单元格将存储棋子的类型,因为棋子的类型决定了它的颜色(参见 Piece 类)。现在看看这个类的头文件:
class Board : public sf::Transformable,public sf::Drawable
{
public:
static const int DEFAULT_BOARD_COLUMNS = 10;
static const int DEFAULT_BOARD_LINE = 20;
static const int DEFAULT_CELL_X = 24;
static const int DEFAULT_CELL_Y = 24;
Board(int columns=DEFAULT_BOARD_COLUMNS,int
line=DEFAULT_BOARD_LINE,int cell_x=DEFAULT_CELL_X,int
cell_y=DEFAULT_CELL_Y);
~Board();
void spawn(Piece& piece);
bool move(Piece& piece, int delta_x,int delta_y);
bool isFallen(const Piece& piece);
void drop(Piece& piece);
bool rotateLeft(Piece& piece);
bool rotateRight(Piece& piece);
bool isGameOver();
int clearLines(const Piece& piece); //< clear all possible lines
private:
bool rotate(Piece& piece,int rotation);
void draw(const Piece& piece);
void clear(const Piece& piece);
virtual void draw(sf::RenderTarget& target,sf::RenderStates
states=sf::RenderStates::Default) const override;
void flood(const Piece& piece,int value);
void flood(int grid_x,int grid_y,int piece_x,int piece_y,Piece::Tetrimino_Types type,int rotation,bool visited[][Piece::MATRIX_SIZE],int value);
void flood(int grid_x,int grid_y,int piece_x,int piece_y,Piece::Tetrimino_Types type,int rotation,bool visited[][Piece::MATRIX_SIZE],bool& flag);
void clearLine(int y); //< clear a line
const int _columns;
const int _lines;
const int _cellX;
const int _cellY;
bool _isGameOver;
sf::VertexArray _grid;//< grid borders
int* _gridContent;//< lines * columns
};
在 Board 类中,我们首先定义了一些配置变量。这个类是可绘制的和可变换的,所以我们从相应的 SFML 类扩展它。然后我们创建了一个构造函数,它接受棋板的大小作为参数,以及一些添加、移动和管理棋子的方法。我们还添加了一些私有方法,这些方法将帮助我们实现公共方法,并且我们在内部存储棋板的大小,例如网格。因为大小在编译时是未知的,我们需要在运行时构建网格,所以网格是一个指向数组的指针。我们还添加了一个 sf::VertexArray,它将包含要在屏幕上显示的图形网格。
现在已经解释了类,让我们来实现它。
constexpr int CELL_EMPTY -1;
Board::Board(int columns,int lines,int cell_x,int cell_y): _columns(columns),_lines(lines),_cellX(cell_x),_cellY(cell_y), _gridContent(nullptr),_isGameOver(false)
{
_gridContent = new int[_lines*_columns];
std::memset(_gridContent,CELL_EMPTY,_lines*_columns*sizeof(int));
sf::Color gridColor(55,55,55);
_grid = sf::VertexArray(sf::Lines,(_lines+1+_columns+1)*2);
for(int i=0;i<=_lines;++i)
{
_grid[i*2] = sf::Vertex(sf::Vector2f(0,i*_cellY));
_grid[i*2+1] = sf::Vertex(sf::Vector2f(_columns*_cellX,i*_cellY));
_grid[i*2].color = gridColor;
_grid[i*2+1].color = gridColor;
}
for(int i=0;i<=columns;++i)
{
_grid[(_lines+1)*2 + i*2] = sf::Vertex(sf::Vector2f(i*_cellX,0));
_grid[(_lines+1)*2 + i*2+1] = sf::Vertex(sf::Vector2f(i*_cellX,_lines*_cellY));
_grid[(_lines+1)*2 + i*2].color = gridColor;
_grid[(_lines+1)*2 + i*2+1].color = gridColor;
}
}
构造函数初始化所有属性,但还创建网格内容和边框。因为网格内容和边框是一维数组,我们需要一些技巧来访问正确的单元格,而不是使用常规的 "[][]" 操作符。
Board::~Board() {delete _gridContent;}
void Board::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
states.transform *= getTransform();
for(int y=0; y<_lines; ++y)
for(int x=0; x<_columns; ++x) {
if(_gridContent[y*_columns + x] != CELL_EMPTY) {
sf::RectangleShape rectangle(sf::Vector2f(_cellX,_cellY));
rectangle.setFillColor(Piece::TetriminoColors[_gridContent[y*_columns + x]]);
rectangle.setPosition(x*_cellX,y*_cellY);
target.draw(rectangle,states);
}
}
target.draw(_grid,states);
}
绘图方法并不复杂。对于每个单元格,其中有一些数据,我们在正确的位置构造一个正确大小的矩形,用正确的颜色显示它,然后显示网格边框。
void Board::spawn(Piece& piece)
{
piece.setPosition(_columns/2,0);
for(int x=0;x<_columns;++x)
if(_gridContent[x] != CELL_EMPTY) {
_isGameOver = true;
break;
}
draw(piece);
}
这个函数简单地设置棋子在板上的初始位置,并将其添加到网格中。它还通过以下代码片段检查游戏是否结束:
bool Board::move(Piece& piece, int delta_x, int delta_y)
{
delta_x += piece.getPosX();
delta_y + piece.getPosY();
clear(piece);
bool visited[Piece::MATRIX_SIZE][Piece::MATRIX_SIZE] = {{false}};
bool movable = true
flood(delta_x,delta_y, (int)Piece::PIVOT_X,(int)Piece::PIVOT_Y,
piece.getType(),piece.getRotation(),
visisted, movable);
if (movable)
piece.setPosition(delta_x,delta_y);
draw(piece);
return movable;
}
这个函数稍微复杂一些,所以让我们一步一步地解释它:
-
我们将从板上删除
Piece类,以防止它与自己碰撞。 -
我们将检查是否可以移动棋子,并在可以的情况下设置其新位置。
-
我们将读取棋子到板上。
洪水算法将在后面解释:
bool Board::isFallen(const Piece& piece)
{
clear(piece);
bool vision[Piece::MATRIX_SIZE][Piece::MATRIX_SIZE] = {{false}};
bool fallen = true;
flood(piece.getPosX(),piece.getPosY()+1
(int)Piece::PIVOT_X,(int)Piece::PIVOT_Y,
piece.getType(),piece.getRotation(),
visited,fallen);
draw(piece)
return fallen;
}
这个功能与之前提到的功能类似,只有一个例外。它只检查棋子是否可以向下移动,而不是所有方向,如下面的代码片段所示:
void Board::drop(Piece& piece) {while(move(piece,0,1));}
这个函数是一个特殊动作,将棋子尽可能向下移动。这是俄罗斯方块游戏中的一个特殊动作,称为“硬降”。
bool Board::rotateLeft(Piece& piece)
{
int rotation = piece.getRotation();
if(rotation > 0)
--rotation;
else
rotation = Piece::NB_ROTATIONS - 1;
return rotate(piece,rotation);
}
bool Board::rotateRight(Piece& piece)
{
int rotation = piece.getRotation();
if(rotation < Piece::NB_ROTATIONS -1)
++rotation;
else
rotation = 0;
return rotate(piece,rotation);
}
这两个函数将棋子旋转到特定的方向。由于只有四种不同的旋转(NB_ROTATIONS),我们需要使用循环检查来调整新的旋转值:
bool Board::isGameOver(){return _isGameOver;}
bool Board::rotate(Piece& piece,int rotation)
{
assert(rotation >= 0 and rotation < Piece::NB_ROTATIONS);
clear(piece);
bool visited[Piece::MATRIX_SIZE][Piece::MATRIX_SIZE] = {{false}};
bool rotable = true;
flood((int)piece.getPosX(),(int)piece.getPosY(),
(int)Piece::PIVOT_X,(int)Piece::PIVOT_Y,
piece.getType(),rotation,
visited,rotable);
if(rotable)
piece.setRotation(rotation);
draw(piece);
return rotable;
}
就像其他函数一样,这个函数检查是否可以旋转棋子,并返回值。这个函数不会改变网格的内容:
void Board::draw(const Piece& piece){flood(piece,piece.getType());}
void Board::clear(const Piece& piece){flood(piece,CELL_EMPTY);}
这两个函数非常相似。每个函数都使用特定值修改网格,以设置或从内部网格中移除一个棋子:
void Board::flood(const Piece& piece,int value)
{
bool visited[Piece::MATRIX_SIZE][Piece::MATRIX_SIZE] = {{false}};
flood((int)piece.getPosX(),
(int)piece.getPosY(),(int)Piece::PIVOT_X,
(int)Piece::PIVOT_Y,
piece.getType(),piece.getRotation(),
visited,value);
}
void Board::flood(int grid_x,int grid_y,int piece_x,int piece_y,Piece::TetriminoTypes type,int rotation,bool visited[][Piece::MATRIX_SIZE],int value)
{
if(piece_x < 0 or piece_x >= Piece::MATRIX_SIZE
or piece_y < 0 or piece_y > Piece::MATRRIX_SIZE Pieces[type][rotation][piece_y][piece_x] == 0)
return;
visited[piece_y][piece_x] = true;
_gridContent[grid_y*_columns + grid_x] = value;
flood(grid_x, grid_y-1, piece_x, piece_y-1, type, rotation, visited, value);
flood(grid_x+1, grid_y, piece_x+1, piece_y, type, rotation, visited, value);
flood(grid_x, grid_y+1, piece_x, piece_y+1, type, rotation, visited, value);
flood(grid_x-1, grid_y, piece_x-1, piece_y, type, rotation, visited, value);
}
void Board::flood(int grid_x,int grid_y,int piece_x,int piece_y,Piece::TetriminoTypes type,int rotation,bool visited[][Piece::MATRIX_SIZE],bool& flag)
{
if(piece_x < 0 or piece_x >= Piece::MATRIX_SIZE
or piece_y < 0 or piece_y >= Piece::MATRIX_SIZE
or visited[piece_y][piece_x] == true
or Piece::TetriminoPieces[type][rotation][piece_y][piece_x] == 0)
return;
visited[piece_y][piece_x] = true;
if(grid_x < 0 or grid_x >= (int)_columns
or grid_y < 0 or grid_y >= (int)_lines
or _gridContent[grid_y*_columns + grid_x] != CELL_EMPTY) {
flag = false;
return;
}
flood(grid_x, grid_y-1, piece_x, piece_y-1, type, rotation, visited, flag);
flood(grid_x+1, grid_y, piece_x+1, piece_y, type, rotation, visited, flag);
flood(grid_x, grid_y+1, piece_x, piece_y+1, type, rotation, visited, flag);
flood(grid_x-1, grid_y, piece_x-1, piece_y, type, rotation, visited, flag);
}
这个 flood 函数是 flood 算法的实现。它允许我们根据另一个数组填充数组中的值。第二个数组是要填充的第一个数组的形状。在我们的例子中,第一个数组是网格,第二个是棋子,如下面的代码片段所示:
void Board::clearLine(int yy)
{
assert(yy < _lines);
for(int y=yy; y>0; --y)
for(int x=0; x<_columns; ++x)
_gridContent[y*_columns + x] = _gridContent[(y-1)*_columns + x];
}
int Board::clearLines(const Piece& piece)
{
int nb_delete = 0;
clear(piece);
for(int y=0; y<_lines; ++y)
{
int x =0;
for(;_gridContent[y*_columns + x] != CELL_EMPTY and x<_columns; ++x);
if(x == _columns) {
clearLine(y);
++nb_delete;
}
}
draw(piece);
return nb_delete;
}
这个函数简单地移除所有完成的行,并将所有上方的行降低以模拟重力。
现在,board 类已经创建,我们拥有了构建游戏所需的一切。所以,让我们开始吧。
Game 类
Game 类与 Asteroid 中的 Game 类非常相似。它的目的是相同的,所有内部逻辑也相似,如下面的代码片段所示:
class Game
{
public:
Game(); //< constructor
void run(int minimum_frame_per_seconds);
private:
void processEvents();//< Process events
void update(sf::Time deltaTime); //< do some updates
void render();//< draw all the stuff
void newPiece();
sf::RenderWindow _window; //< the window used to display the game
std::unique_ptr<Piece> _currentPiece; //< the current piece
Board _board; //< the game board
Stats _stats; //< stats printer
sf::Time _nextFall;
};
如您所见,我们没有改变 Game 类的逻辑,但添加了一些私有函数和属性来对应不同的游戏类型。仍然需要一个窗口,但我们添加了当前棋子引用、板(取代世界)和状态打印器。我们还需要一种方式来存储下一个棋子的落下。
现在看看这个类的实现:
Game::Game() : _window(sf::VideoMode(800, 600),"SFML Tetris"),_board()
{
rand_init()
_board.setPosition(10,10);
_stats.setPosition(300,10);
newPiece();
}
构造函数初始化类的不同属性,并设置不同可绘制对象的位置。它还创建了第一块来开始游戏。在这里我们不管理任何菜单:
void Game::run(int minimum_frame_per_seconds)
{
sf::Clock clock;
sf::Time timeSinceLastUpdate;
sf::Time TimePerFrame = sf::seconds(1.f/minimum_frame_per_seconds);
while (_window.isOpen())
{
processEvents();
timeSinceLastUpdate = clock.restart();
while (timeSinceLastUpdate > TimePerFrame)
{
timeSinceLastUpdate -= TimePerFrame;
update(TimePerFrame);
}
update(timeSinceLastUpdate);
render();
}
}
void Game::processEvents()
{
sf::Event event;
while(_window.pollEvent(event))
{
if (event.type == sf::Event::Closed)//Close window
_window.close();
else if (event.type == sf::Event::KeyPressed) //keyboard input
{
if (event.key.code == sf::Keyboard::Escape) {
_window.close();
} else if (event.key.code == sf::Keyboard::Down) {
_board.move(*_currentPiece,0,1);
} else if (event.key.code == sf::Keyboard::Up) {
_board.move(*_currentPiece,0,-1);
} else if (event.key.code == sf::Keyboard::Left) {
_board.move(*_currentPiece,-1,0);
} else if (event.key.code == sf::Keyboard::Right) {
_board.move(*_currentPiece,1,0);
} else if (event.key.code == sf::Keyboard::Space) {
_board.drop(*_currentPiece);
newPiece();
} else if (event.key.code == sf::Keyboard::S) {
_board.rotateRight(*_currentPiece);
} else if (event.key.code == sf::Keyboard::D) {
_board.rotateLeft(*_currentPiece);
}
}
}
}
void Game::update(sf::Time deltaTime)
{
if(not _board.isGameOver())
{
_stats.addLines(_board.clearLines(*_currentPiece));
_nextFall += deltaTime;
if((not _board.isFallen(*_currentPiece)) and (_currentPiece->getTimeSinceLastMove() > sf::seconds(1.f)))
newPiece();
sf::Time max_time = sf::seconds(std::max(0.1,0.6-0.005*_stats.getLvl()));
while(_nextFall > max_time)
{
_nextFall -= max_time;
_board.move(*_currentPiece,0,1);
}
} else {
_stats.gameOver();
}
}
这个函数并不复杂,但很有趣,因为所有游戏逻辑都在这里。让我们在以下步骤中看看:
-
第一步是清除行并更新分数。
-
然后,我们将检查是否需要生成另一个棋子。
-
我们将计算当前级别需要的时间来强制向下移动,并在必要时应用它。
-
当然,如果游戏结束了,我们就不做所有这些事情,而是告诉状态打印器游戏已经结束:
void Game::render() { _window.clear(); if(not _board.isGameOver()) _window.draw(_board); _window.draw(_stats); _window.display(); } -
在这里,同样没有什么新东西。我们只是根据情况画出所有能画的东西:
void Game::newPiece() { _currentPiece.reset(new Piece((Piece::TetriminoTypes)random(0,Piece::TetriminoTypes::SIZE-1),0)); _board.spawn(*_currentPiece); } -
这个最后的函数会随机创建一个方块,并将其添加到网格中,这将设置其默认位置。
现在我们到了这里。游戏完成了!
摘要
如你肯定注意到的,与之前我们制作的那个游戏有一些共同点,但并不多。展示这个游戏的主要想法是,没有一种“超级技巧”能在所有类型的游戏中都有效。你必须根据你想要构建的游戏类型来调整你的内部架构和逻辑。我希望你能理解这一点。
在下一章,你将学习如何使用物理引擎,并将其添加到俄罗斯方块游戏中,以构建一种新的游戏。
第四章 玩转物理
在上一章中,我们构建了几个游戏,包括一个俄罗斯方块克隆版。在本章中,我们将向这个游戏添加物理效果,使其变成一个新的游戏。通过这样做,我们将学习:
-
什么是物理引擎
-
如何安装和使用 Box2D 库
-
如何将物理引擎与 SFML 配合进行显示
-
如何在游戏中添加物理效果
在本章中,我们将学习物理的魔法。我们还将做一些数学,但请放心,这只是转换。现在,让我们开始吧!
物理引擎——késako?
在本章中,我们将讨论物理引擎,但首先的问题是“什么是物理引擎?”让我们来解释一下。
物理引擎是一种能够模拟物理的软件或库,例如描述刚体运动的牛顿-欧拉方程。物理引擎还能够处理碰撞,其中一些甚至可以处理软体和流体。
有不同类型的物理引擎,主要分为实时引擎和非实时引擎。第一种主要用于视频游戏或模拟器,第二种用于高性能科学模拟、电影特效和动画的概念设计。
由于我们的目标是将在视频游戏中使用引擎,因此让我们专注于基于实时的引擎。在这里,同样有两种重要的引擎类型。第一种是用于 2D 的,另一种是用于 3D 的。当然,你可以在 2D 世界中使用 3D 引擎,但出于优化的目的,最好使用 2D 引擎。有很多引擎,但并非所有都是开源的。
3D 物理引擎
对于 3D 游戏,我建议你使用Bullet物理库。它被集成到 Blender 软件中,并被用于一些商业游戏和电影制作。这是一个用 C/C++编写的真正优秀的引擎,可以处理刚体和软体、流体、碰撞、力……以及你需要的一切。
2D 物理引擎
如前所述,在 2D 环境中,你可以使用 3D 物理引擎;你只需忽略深度(Z 轴)。然而,最有趣的事情是使用针对 2D 环境优化的引擎。有几种这样的引擎,其中最著名的是 Box2D 和 Chipmunk。它们都非常出色,没有一个比另一个更好,但我不得不做出选择,那就是 Box2D。我之所以做出这个选择,不仅是因为它的 C++ API 允许你使用重载,还因为该项目拥有庞大的社区。
物理引擎与游戏引擎比较
不要将物理引擎和游戏引擎混淆。物理引擎只模拟物理世界,没有其他任何东西。没有图形,没有逻辑,只有物理模拟。相反,游戏引擎,大多数情况下包括物理引擎和渲染技术(如 OpenGL 或 DirectX)的配套。一些预定义的逻辑取决于引擎的目标(RPG、FPS 等),有时还包括人工智能。所以正如你所见,游戏引擎比物理引擎更完整。最知名的两种引擎是 Unity 和 Unreal 引擎,它们都非常完整。此外,它们对非商业用途是免费的。
那么我们为什么不直接使用游戏引擎呢?这是一个很好的问题。有时候,使用现成的东西,而不是重新发明轮子,会更好。然而,我们真的需要游戏引擎的所有功能来完成这个项目吗?更重要的是,我们需要它来做什么?让我们看看以下内容:
-
图形输出
-
能够处理碰撞的物理引擎
没有其他需要的东西。所以正如你所见,为这个项目使用游戏引擎就像是杀蚊子用火箭筒。我希望你已经理解了物理引擎的目的,游戏引擎和物理引擎之间的区别,以及本章所述项目中做出的选择的原因。
使用 Box2D
如前所述,Box2D 是一个物理引擎。它有很多功能,但对我们项目来说最重要的如下(摘自 Box2D 文档):
-
碰撞:这个功能非常有趣,因为它允许我们的俄罗斯方块相互交互
-
连续碰撞检测
-
刚体(凸多边形和圆形)
-
每个物体可以有多个形状
-
-
物理:这个功能将允许一个物体落下,等等
-
基于碰撞时间解算器的连续物理
-
关节限制、电机和摩擦
-
相对准确的反应力/冲量
-
正如你所见,Box2D 为我们提供了构建游戏所需的一切。这个引擎还有很多其他可用功能,但它们目前对我们来说并不重要,所以不会详细描述。然而,如果你感兴趣,可以查看 Box2D 官方网站了解更多关于其功能的信息(box2d.org/about/)。
需要注意的是,Box2D 使用米、千克、秒和弧度作为角度的单位;SFML 使用像素、秒和度。因此,我们需要进行一些转换。我稍后会回到这个问题。
准备 Box2D
现在已经介绍了 Box2D,让我们来安装它。你可以在 Google 代码项目页面上找到可用的版本列表code.google.com/p/box2d/downloads/list。目前,最新的稳定版本是 2.3。一旦你下载了源代码(从压缩文件或使用 SVN),你将需要构建它。
构建
这里是好消息,Box2D 使用 CMake 作为构建过程,所以你只需遵循本书第一章中描述的 SFML 构建步骤,你就能成功构建 Box2D。如果一切顺利,你将在以下位置找到示例项目:path/to/Box2D/build/Testbed/Testbed。现在,让我们来安装它。
安装
一旦你成功构建了你的 Box2D 库,你需要配置你的系统或 IDE 以找到 Box2D 库和头文件。新构建的库可以在 /path/to/Box2D/build/Box2D/ 目录下找到,命名为 libBox2D.a。另一方面,头文件位于 path/to/Box2D/Box2D/ 目录中。如果一切正常,你将在文件夹中找到一个 Box2D.h 文件。
在 Linux 上,以下命令将 Box2D 添加到你的系统,无需任何配置:
sudo make install
配对 Box2D 和 SFML
现在 Box2D 已经安装,并且你的系统已经配置好以找到它,让我们构建物理“hello world”:一个下落的正方形。
需要注意的是,Box2D 使用米、千克、秒和弧度作为单位;SFML 使用像素、秒和度。因此,我们需要进行一些转换。
弧度转换为度或反之亦然并不困难,但像素转换为米……这又是另一回事。事实上,没有方法可以将像素转换为米,除非每米的像素数是固定的。这就是我们将使用的技术。
因此,让我们先创建一些实用函数。我们应该能够将弧度转换为度,度转换为弧度,米转换为像素,最后像素转换为米。我们还需要设置每米像素值。由于我们不需要任何类来为这些函数,我们将它们定义在命名空间 converter 中。这将导致以下代码片段:
namespace converter
{
constexpr double PIXELS_PER_METERS = 32.0;
constexpr double PI = 3.14159265358979323846;
template<typename T>
constexpr T pixelsToMeters(const T& x){return x/PIXELS_PER_METERS;};
template<typename T>
constexpr T metersToPixels(const T& x){return x*PIXELS_PER_METERS;};
template<typename T>
constexpr T degToRad(const T& x){return PI*x/180.0;};
template<typename T>
constexpr T radToDeg(const T& x){return 180.0*x/PI;}
}
如你所见,这里没有困难。我们开始定义一些常量,然后是转换函数。我选择使函数模板允许使用任何数字类型。在实践中,它将主要是 double 或 int。转换函数也被声明为 constexpr,以便编译器在可能的情况下在编译时计算值(例如,使用常量作为参数)。这很有趣,因为我们将会大量使用这个原始函数。
Box2D,它是如何工作的?
现在我们可以将 SFML 单位转换为 Box2D 单位,反之亦然,我们可以将 Box2D 与 SFML 配对。但首先,Box2D 究竟是如何工作的?
Box2D 工作方式与物理引擎非常相似:
-
你首先创建一个带有一些重力的空世界。
-
然后,你创建一些对象模式。每个模式包含对象的形状、位置、类型(静态或动态),以及一些其他特性,如密度、摩擦和能量恢复。
-
你要求世界创建一个由模式定义的新对象。
-
在每个游戏循环中,你必须用一个小步骤更新物理世界,就像我们在已经制作的游戏中的世界一样。
因为物理引擎不会在屏幕上显示任何内容,所以我们需要遍历所有对象并自行显示它们。
让我们从创建一个简单的场景开始,其中包含两种对象:地面和正方形。地面将是固定的,而正方形则不是。正方形将通过用户事件生成:鼠标点击。
这个项目非常简单,但目标是展示如何使用 Box2D 和 SFML 结合一个简单的案例研究。更复杂的一个将会稍后出现。
我们需要这个小型项目三个功能:
-
创建一个形状
-
显示世界
-
更新/填充世界
当然,还有世界和窗口的初始化。让我们从主函数开始:
-
和往常一样,我们创建一个用于显示的窗口,并将 FPS 数量限制为 60。我将在
displayWorld函数中回到这一点。 -
我们从 Box2D 创建物理世界,并将重力作为参数。
-
我们创建一个容器,用于存储所有物理对象以进行内存清理。
-
我们通过调用
createBox函数(稍后解释)创建地面。 -
现在是时候进行极简的
game循环了:-
关闭事件管理
-
通过检测鼠标右键被按下创建一个框
-
-
最后,在退出程序之前,我们清理内存:
int main(int argc,char* argv[]) { sf::RenderWindow window(sf::VideoMode(800, 600, 32), "04_Basic"); window.setFramerateLimit(60); b2Vec2 gravity(0.f, 9.8f); b2World world(gravity); std::list<b2Body*> bodies; bodies.emplace_back(book::createBox(world,400,590,800,20,b2_staticBody)); while(window.isOpen()) { sf::Event event; while(window.pollEvent(event)) { if (event.type == sf::Event::Closed) window.close(); } if (sf::Mouse::isButtonPressed(sf::Mouse::Left)) { int x = sf::Mouse::getPosition(window).x; int y = sf::Mouse::getPosition(window).y; bodies.emplace_back(book::createBox(world,x,y,32,32)); } displayWorld(world,window); } for(b2Body* body : bodies) { delete static_cast<sf::RectangleShape*>(body->GetUserData()); world.DestroyBody(body); } return 0; }
目前,除了 Box2D 世界外,不应该有任何令人惊讶的内容,让我们继续创建框。
此函数位于 book 命名空间下。
b2Body* createBox(b2World& world,int pos_x,int pos_y, int size_x,int size_y,b2BodyType type = b2_dynamicBody)
{
b2BodyDef bodyDef;
bodyDef.position.Set(converter::pixelsToMeters<double>(pos_x),
converter::pixelsToMeters<double>(pos_y));
bodyDef.type = type;
b2PolygonShape b2shape;
b2shape.SetAsBox(converter::pixelsToMeters<double>(size_x/2.0),
converter::pixelsToMeters<double>(size_y/2.0));
b2FixtureDef fixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.4;
fixtureDef.restitution= 0.5;
fixtureDef.shape = &b2shape;
b2Body* res = world.CreateBody(&bodyDef);
res->CreateFixture(&fixtureDef);
sf::Shape* shape = new sf::RectangleShape(sf::Vector2f(size_x,size_y));
shape->setOrigin(size_x/2.0,size_y/2.0);
shape->setPosition(sf::Vector2f(pos_x,pos_y));
if(type == b2_dynamicBody)
shape->setFillColor(sf::Color::Blue);
else
shape->setFillColor(sf::Color::White);
res->SetUserData(shape);
return res;
}
此函数包含许多新功能。其目标是创建一个在预定义位置特定大小的矩形。此矩形的类型也是由用户设置的(动态或静态)。在这里,让我们一步一步解释这个函数:
-
我们创建
b2BodyDef。此对象包含要创建的身体的定义。因此,我们设置位置和类型。此位置将与物体的重力中心相关。 -
然后,我们创建
b2Shape。这是对象的物理形状,在我们的例子中,是一个框。请注意,SetAsBox()方法不使用与sf::RectangleShape相同的参数。参数是框大小的一半。这就是为什么我们需要将值除以二的原因。 -
我们创建
b2FixtureDef并初始化它。此对象包含对象的全部物理特性,如密度、摩擦、恢复力和形状。 -
然后,我们正确地在物理世界中创建对象。
-
现在,我们创建对象的显示。这将更加熟悉,因为我们只使用 SFML。我们创建一个矩形并设置其位置、原点和颜色。
-
由于我们需要将 SFML 对象与物理对象关联并显示,我们使用 Box2D 的一个功能:
SetUserData()函数。此函数接受void*作为参数并在内部持有它。因此,我们使用它来跟踪我们的 SFML 形状。 -
最后,函数通过返回身体。此指针必须存储起来以便稍后清理内存。这就是
main()中身体容器的原因。
现在,我们有了简单地创建一个盒子并将其添加到世界中的能力。现在,让我们将其渲染到屏幕上。这是displayWorld函数的目标:
void displayWorld(b2World& world,sf::RenderWindow& render)
{
world.Step(1.0/60,int32(8),int32(3));
render.clear();
for (b2Body* body=world.GetBodyList(); body!=nullptr; body=body->GetNext())
{
sf::Shape* shape = static_cast<sf::Shape*>(body->GetUserData());
shape->setPosition(converter::metersToPixels(body->GetPosition().x),
converter::metersToPixels(body->GetPosition().y));
shape->setRotation(converter::radToDeg<double>(body->GetAngle()));
render.draw(*shape);
}
render.display();
}
这个函数将物理世界和窗口作为参数。在这里,让我们一步一步地解释这个函数:
-
我们更新物理世界。如果你记得,我们已将帧率设置为 60。这就是为什么我们在这里使用 1,0/60 作为参数。其他两个参数仅用于精度。在好的代码中,时间步长不应该像这里那样硬编码。我们必须使用时钟以确保值始终相同。在这里,这并没有发生,因为我们专注于重要的部分:物理。更重要的是,物理循环应该与显示循环不同,正如在第二章中已经说过的,一般游戏架构、用户输入和资源管理。我将在下一节回到这个点。
-
我们像往常一样重置屏幕。
-
这里是新的部分:我们循环世界存储的物体,并获取 SFML 形状。我们使用从物理体获取的信息更新 SFML 形状,然后在屏幕上渲染它。
-
最后,我们在屏幕上渲染结果。
就这样。最终结果应该看起来像以下截图:

如你所见,将 SFML 与 Box2D 配对并不困难。添加它并不痛苦。然而,我们必须注意数据转换。这是真正的陷阱。注意所需的精度(int、float、double),然后一切都会顺利。
现在你已经掌握了所有的键,让我们用物理来构建一个真正的游戏。
将物理添加到游戏中
现在 Box2D 已经通过一个基本项目引入,让我们专注于真正的项目。我们将修改我们的基本俄罗斯方块,得到重力俄罗斯方块,即 Gravitris。游戏控制将与俄罗斯方块相同,但游戏引擎将不同。我们将用真实的物理引擎替换板。
在这个项目中,我们将重用之前做的大量工作。正如之前所说,我们一些类的目标是可重用在任何使用 SFML 的游戏中。在这里,这将没有困难,正如你将看到的。相关的类是你处理用户事件Action、ActionMap、ActionTarget——以及Configuration和ResourceManager。因为这些类已经在之前的章节中详细解释过,所以我在这里不再浪费时间重复解释它们。
在Configuration类中还有一些变化会发生,更确切地说,是在这个类的枚举和initialization方法中,因为我们没有使用在 Asteroid 游戏中使用的确切相同的音效和事件。因此,我们需要调整它们以满足我们的需求。
足够的解释了,让我们用以下代码来做:
class Configuration
{
public:
Configuration() = delete;
Configuration(const Configuration&) = delete;
Configuration& operator=(const Configuration&) = delete;
enum Fonts : int {Gui};
static ResourceManager<sf::Font,int> fonts;
enum PlayerInputs : int { TurnLeft,TurnRight, MoveLeft, MoveRight,HardDrop};
static ActionMap<int> playerInputs;
enum Sounds : int {Spawn,Explosion,LevelUp,};
static ResourceManager<sf::SoundBuffer,int> sounds;
enum Musics : int {Theme};
static ResourceManager<sf::Music,int> musics;
static void initialize();
private:
static void initTextures();
static void initFonts();
static void initSounds();
static void initMusics();
static void initPlayerInputs();
};
如您所见,更改发生在enum中,更确切地说是在Sounds和PlayerInputs中。我们将值更改为更适合此项目的值。我们仍然有字体和音乐主题。现在,让我们看看已经更改的初始化方法:
void Configuration::initSounds()
{
sounds.load(Sounds::Spawn,"media/sounds/spawn.flac");
sounds.load(Sounds::Explosion,"media/sounds/explosion.flac");
sounds.load(Sounds::LevelUp,"media/sounds/levelup.flac");
}
void Configuration::initPlayerInputs()
{
playerInputs.map(PlayerInputs::TurnRight,Action(sf::Keyboard::Up));
playerInputs.map(PlayerInputs::TurnLeft,Action(sf::Keyboard::Down));
playerInputs.map(PlayerInputs::MoveLeft,Action(sf::Keyboard::Left));
playerInputs.map(PlayerInputs::MoveRight,Action(sf::Keyboard::Right));
playerInputs.map(PlayerInputs::HardDrop,Action(sf::Keyboard::Space,
Action::Type::Released));
}
这里没有真正的惊喜。我们只是调整资源以满足项目的需求。如您所见,更改非常简约且易于完成。这就是所有可重用模块或类的目标。然而,这里有一些建议:尽可能使您的代码模块化,这将使您能够非常容易地更改一部分,并且可以轻松地将项目中的任何通用部分导入另一个项目中。
Piece类
现在我们已经完成了配置类,下一步是Piece类。这个类将是修改最多的一个。实际上,由于涉及的变化太多,让我们从头开始构建它。一个部件必须被视为由四个相互独立的方块组成的集合。这将允许我们在运行时拆分部件。这些方块中的每一个都将是一个不同的固定装置,附加到相同的体——部件上。
我们还需要给一个部件施加一些力,特别是给当前由玩家控制的部件施加力。这些力可以使部件水平移动或旋转。
最后,我们需要在屏幕上绘制这个部件。
结果将显示以下代码片段:
constexpr int BOOK_BOX_SIZE = 32;
constexpr int BOOK_BOX_SIZE_2 = BOOK_BOX_SIZE / 2;
class Piece : public sf::Drawable
{
public:
Piece(const Piece&) = delete;
Piece& operator=(const Piece&) = delete;
enum TetriminoTypes {O=0,I,S,Z,L,J,T,SIZE};
static const sf::Color TetriminoColors[TetriminoTypes::SIZE];
Piece(b2World& world,int pos_x,int pos_y,TetriminoTypes type,float rotation);
~Piece();
void update();
void rotate(float angle);
void moveX(int direction);
b2Body* getBody()const;
private:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
b2Fixture* createPart((int pos_x,int pos_y,TetriminoTypes type); ///< position is relative to the piece int the matrix coordinate (0 to 3)
b2Body * _body;
b2World& _world;
};
类中的一些部分没有变化,例如TetriminoTypes和TetriminoColors枚举。这是正常的,因为我们没有更改任何部件的形状或颜色。其余部分仍然相同。
类的实现,另一方面,与先前的版本非常不同。让我们看看:
Piece::Piece(b2World& world,int pos_x,int pos_y,TetriminoTypes type,float rotation) : _world(world)
{
b2BodyDef bodyDef;
bodyDef.position.Set(converter::pixelsToMeters<double>(pos_x),
converter::pixelsToMeters<double>(pos_y));
bodyDef.type = b2_dynamicBody;
bodyDef.angle = converter::degToRad(rotation);
_body = world.CreateBody(&bodyDef);
switch(type)
{
case TetriminoTypes::O : {
createPart((0,0,type); createPart((0,1,type);
createPart((1,0,type); createPart((1,1,type);
}break;
case TetriminoTypes::I : {
createPart((0,0,type); createPart((1,0,type);
createPart((2,0,type); createPart((3,0,type);
}break;
case TetriminoTypes::S : {
createPart((0,1,type); createPart((1,1,type);
createPart((1,0,type); createPart((2,0,type);
}break;
case TetriminoTypes::Z : {
createPart((0,0,type); createPart((1,0,type);
createPart((1,1,type); createPart((2,1,type);
}break;
case TetriminoTypes::L : {
createPart((0,1,type); createPart((0,0,type);
createPart((1,0,type); createPart((2,0,type);
}break;
case TetriminoTypes::J : {
createPart((0,0,type); createPart((1,0,type);
createPart((2,0,type); createPart((2,1,type);
}break;
case TetriminoTypes::T : {
createPart((0,0,type); createPart((1,0,type);
createPart((1,1,type); createPart((2,0,type);
}break;
default:break;
}
body->SetUserData(this);
update();
}
构造函数是这个类最重要的方法。它初始化物理体,并通过调用createPart()将每个方块添加到其中。然后,我们将用户数据设置为部件本身。这将允许我们通过物理导航到 SFML,反之亦然。最后,通过调用update()函数将物理对象同步到可绘制对象:
Piece::~Piece()
{
for(b2Fixture* fixture=_body->GetFixtureList();fixture!=nullptr;
fixture=fixture->GetNext())
{
sf::ConvexShape* shape = static_cast<sf::ConvexShape*>(fixture->GetUserData());
fixture->SetUserData(nullptr);
delete shape;
}
_world.DestroyBody(_body);
}
析构函数遍历附加到体上的所有固定装置,销毁所有 SFML 形状,然后从世界中移除体:
b2Fixture* Piece::createPart((int pos_x,int pos_y,TetriminoTypes type)
{
b2PolygonShape b2shape;
b2shape.SetAsBox(converter::pixelsToMeters<double>(BOOK_BOX_SIZE_2),
converter::pixelsToMeters<double>(BOOK_BOX_SIZE_2)
,b2Vec2(converter::pixelsToMeters<double>(BOOK_BOX_SIZE_2+(pos_x*BOOK_BOX_SIZE)),
converter::pixelsToMeters<double>(BOOK_BOX_SIZE_2+(pos_y*BOOK_BOX_SIZE))),0);
b2FixtureDef fixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.5;
fixtureDef.restitution= 0.4;
fixtureDef.shape = &b2shape;
b2Fixture* fixture = _body->CreateFixture(&fixtureDef);
sf::ConvexShape* shape = new sf::ConvexShape((unsigned int) b2shape.GetVertexCount());
shape->setFillColor(TetriminoColors[type]);
shape->setOutlineThickness(1.0f);
shape->setOutlineColor(sf::Color(128,128,128));
fixture->SetUserData(shape);
return fixture;
}
此方法在特定位置将一个方块添加到体中。它首先创建一个物理形状作为所需的盒子,并将其添加到体中。它还创建一个用于显示的 SFML 方块,并将其作为用户数据附加到固定装置上。我们不设置初始位置,因为构造函数会做这件事。
void Piece::update()
{
const b2Transform& xf = _body->GetTransform();
for(b2Fixture* fixture = _body->GetFixtureList(); fixture != nullptr;
fixture=fixture->GetNext())
{
sf::ConvexShape* shape = static_cast<sf::ConvexShape*>(fixture->GetUserData());
const b2PolygonShape* b2shape = static_cast<b2PolygonShape*>(fixture->GetShape());
const uint32 count = b2shape->GetVertexCount();
for(uint32 i=0;i<count;++i)
{
b2Vec2 vertex = b2Mul(xf,b2shape->m_vertices[i]);
shape->setPoint(i,sf::Vector2f(converter::metersToPixels(vertex.x),
converter::metersToPixels(vertex.y)));
}
}
}
此方法将所有 SFML 形状的位置和旋转与 Box2D 计算出的物理位置和旋转同步。因为每个部件由几个部分——固定装置组成——我们需要遍历它们并逐个更新。
void Piece::rotate(float angle) {
body->ApplyTorque((float32)converter::degToRad(angle),true);
}
void Piece::moveX(int direction) {
body->ApplyForceToCenter(b2Vec2(converter::pixelsToMeters(direction),0),true);
}
这两种方法给物体施加一些力以使其移动或旋转。我们将这项工作委托给 Box2D 库。
b2Body* Piece::getBody()const {return _body;}
void Piece::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
for(const b2Fixture* fixture=_body->GetFixtureList();fixture!=nullptr; fixture=fixture->GetNext())
{
sf::ConvexShape* shape = static_cast<sf::ConvexShape*>(fixture->GetUserData());
if(shape)
target.draw(*shape,states);
}
}
此函数绘制整个部件。然而,因为部件由几个部分组成,我们需要遍历它们,并逐个绘制它们,以便显示整个部件。这是通过使用存储在固定件中的用户数据来完成的。
World 类
现在我们已经构建了我们的部件,让我们创建一个将由它们填充的世界。这个类将与之前在俄罗斯方块克隆中制作的类非常相似。但现在,游戏基于物理。因此,我们需要将物理和显示更新分开。为此,将使用两个update方法。
最大的变化是板子不再是一个网格,而是一个物理世界。因此,许多内部逻辑将发生变化。现在,让我们看看它:
class World : public sf::Drawable
{
public:
World(const World&) = delete;
World& operator=(const World&) = delete;
World(int size_x,int size_y);
~World();
void update(sf::Time deltaTime);
void updatePhysics(sf::Time deltaTime);
Piece* newPiece();
int clearLines(bool& del,const Piece& current);
void updateGravity(int level);
void add(Configuration::Sounds sound_id);
bool isGameOver()const;
private:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
b2World _physicalWorld;
void createWall(int pos_x, int pos_y, int size_x, int size_y);
const int _x;
const int _y;
std::list<std::unique_ptr<sf::Sound>> _sounds;
};
我们使类不可复制,以大小作为参数。正如你所见,现在有两个update方法。一个用于物理,另一个用于 SFML 对象。我们仍然有一些针对游戏的特定方法,例如newPiece()、clearLines()、isGameOver(),还有一个与updateGravity()物理相关的新方法,以及一个向我们的世界添加声音的方法。此方法直接来自 Meteor 游戏,通过复制粘贴得到。
现在类已经介绍,让我们看看它的实现。以下构造函数使用默认重力初始化物理世界,并向其中添加了一些墙壁:
World::World(int size_x,int size_y) : _physicalWorld(b2Vec2(0.f, 1.5f)),_x(size_x), _y(size_y)
{
createWall(0,0,BOOK_BOX_SIZE,_y*BOOK_BOX_SIZE);
createWall(BOOK_BOX_SIZE*(_x+1.2),0,BOOK_BOX_SIZE,_y*BOOK_BOX_SIZE);
createWall(0,BOOK_BOX_SIZE*_y,BOOK_BOX_SIZE*(_x+2.2),BOOK_BOX_SIZE);
}
析构函数移除世界中所有附加到身体的 SFML 形状:
World::~World()
{
for (b2Body* body=_physicalWorld.GetBodyList(); body!=nullptr;)
{
b2Body* next = body->GetNext();
if(body->GetType() == b2_dynamicBody)
delete static_cast<Piece*>(body->GetUserData());
else
delete static_cast<sf::RectangleShape*>(body->GetUserData());
body = next;
}
}
以下方法同步物理体与显示它的 SFML 对象。它还移除了所有已经结束的声音效果,正如前一章中已经解释过的:
void World::update(sf::Time deltaTime)
{
for (b2Body* body=_physicalWorld.GetBodyList(); body!=nullptr;
body=body->GetNext())
{
if(body->GetType() == b2_dynamicBody){
Piece* piece = static_cast<Piece*>(body->GetUserData());
piece->update();
}
}
_sounds.remove_if([](const std::unique_ptr<sf::Sound>& sound) -> bool {
return sound->getStatus() != sf::SoundSource::Status::Playing;
});
}
现在,我们在World.cpp文件中构建一个类,因为我们不需要这个类在其他任何地方。这个类将用于通过获取一个区域内的所有固定件来查询物理世界。这将被更频繁地使用,特别是用于检测完成的行:
Class _AABB_callback : public b2QueryCallback
{
public :
std::<b2Fixture*> fixtures;
virtual bool ReportFixture(b2Fixture* fixture) override {
if(fixture->GetBody()->GetType() == b2_dynamicBody)
fixtures.emplace_back(fixture);
return true;
}
};
以下方法通过查询世界(特别是通过制作的类)清除完成的行。然后,我们计算每行上的固定件(方块)数量;如果这个数量满足我们的标准,我们就删除所有固定件和行。然而,通过这样做,我们可能会有一些没有固定件的身体。所以,如果我们移除附加到身体上的最后一个固定件,我们也会移除身体。当然,我们也会移除所有对应于这些已删除对象的 SFML 形状。最后,为了增加趣味性,如果需要,我们向世界添加一些声音:
int World::clearLines(bool& del,const Piece& current)
{
int nb_lines = 0;
_AABB_callback callback;
del = false;
for(int y=0;y<=_y;++y)
{ //loop on Y axies
b2AABB aabb; //world query
//set the limit of the query
aabb.lowerBound = b2Vec2(converter::pixelsToMeters<double>(0),
converter::pixelsToMeters<double>((y+0.49)*BOOK_BOX_SIZE));
aabb.upperBound = b2Vec2(converter::pixelsToMeters<double>(_x*BOOK_BOX_SIZE),
converter::pixelsToMeters<double>((y+0.51)*BOOK_BOX_SIZE));
//query the world
_physicalWorld.QueryAABB(&callback,aabb);
if((int)callback.fixtures.size() >= _x)
{
for(b2Fixture* fixture : callback.fixtures)
{
b2Body* body = fixture->GetBody();
del |= body == current.getBody();
if(body->GetFixtureList()->GetNext() != nullptr)
{//no more fixture attached to the body
sf::ConvexShape* shape = static_cast<sf::ConvexShape*>(fixture->GetUserData());
body->DestroyFixture(fixture);
delete shape;
} else {
Piece* piece = static_cast<Piece*>(body->GetUserData());
delete piece;
}
fixture = nullptr;
}
++nb_lines;
}
callback.fixtures.clear();
}
if(nb_lines > 0)
add(Configuration::Sounds::Explosion);
return nb_lines;
}
以下函数根据当前级别设置重力。级别越大,重力越强:
void World::updateGravity(int level) {
physical_world.SetGravity(b2Vec2(0,1.5+(level/2.0)));
}
以下函数直接取自小行星克隆,并且已经解释过。它只是向我们的世界添加声音:
void World::add(Configuration::Sounds sound_id)
{
std::unique_ptr<sf::Sound> sound(new sf::Sound(Configuration::sounds.get(sound_id)));
sound->setAttenuation(0);
sound->play();
_sounds.emplace_back(std::move(sound));
}
此方法通过一个简单的标准检查游戏是否结束:“是否有任何身体超出板面?”
bool World::isGameOver()const
{
for (const b2Body* body=_physicalWorld.GetBodyList(); body!=nullptr;
body=body->GetNext())
{
if(body->GetType() == b2_staticBody)
continue;
if(body->GetPosition().y < 0)
return true;
}
return false;
};
此函数仅通过将任务转发给 Box2D 来更新物理世界:
void World::updatePhysics(sf::Time deltaTime)
{
float seconds = deltaTime.asSeconds();
_physicalWorld.Step(seconds,8,3);
}
现在,我们创建一个部件,并将其初始位置设置为板子的顶部。我们还添加了一个声音来提醒玩家:
Piece* World::newPiece()
{
add(Configuration::Sounds::Spawn);
return new Piece(_physicalWorld,_x/2*BOOK_BOX_SIZE, BOOK_BOX_SIZE,static_cast<Piece::TetriminoTypes>( random(0, Piece::TetriminoTypes::SIZE-1)), random(0.f,360.f));
}
draw() 函数相当简单。我们遍历世界中仍然存活的所有物体,并显示附着在其上的 SFML 对象:
void World::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
for (const b2Body* body=_physicalWorld.GetBodyList(); body!=nullptr;body=body->GetNext())
{
if(body->GetType() == b2_dynamicBody){
Piece* piece = static_cast<Piece*>(body->GetUserData());
target.draw(*piece,states);
} else {//static body
sf::RectangleShape* shape = static_cast<sf::RectangleShape*>(body->GetUserData());
target.draw(*shape,states);
}
}
}
以下函数很有用。其目的是创建一个静态体,将代表墙壁。所有在章节第一部分中使用的功能都已经解释过了,所以不应该有什么让您感到惊讶的:
void World::creatWeall(int pos_x, int pos_y,int size_x,int size_y)
{
b2BodyDef bodyDef;
bodyDef.position.Set(converter::pixelsToMeters<double>(pos_x),
converter::pixelsToMeters<double>(pos_y));
bodyDef.type = b2_staticBody;
b2PolygonShape b2shape;
double sx = converter::pixelsToMeters<double>(size_x)/2.0;
double sy = converter::pixelsToMeters<double>(size_y)/2.0;
b2shape.SetAsBox(sx,sy,b2Vec2(sx,sy),0);
b2FixtureDef fixtureDef;
fixtureDef.density = 1.0;
fixtureDef.friction = 0.8;
fixtureDef.restitution= 0.1;
fixtureDef.shape = &b2shape;
b2Body* body = _physicalWorld.CreateBody(&bodyDef);
body->CreateFixture(&fixtureDef);
sf::Shape* shape = new sf::RectangleShape(sf::Vector2f(size_x,size_y));
shape->setOrigin(size_x/2.0,size_y/2.0);
shape->setPosition(sf::Vector2f(pos_x+size_x/2.0,pos_y+size_y/2.0));
shape->setFillColor(sf::Color(50,50,50));
body->SetUserData(shape);
}
Game 类
现在,我们有一个可以由一些块填充的世界,让我们构建最后一个重要的类——Game 类。
在这个类中有一个很大的变化。如果您还记得,在第二章中,我提到具有物理特性的游戏应该使用两个游戏循环而不是一个。这样做的原因是大多数物理引擎都很好地与固定时间步长一起工作。此外,这可以避免一件真正糟糕的事情。想象一下,您的物理引擎需要 0.01 秒来计算您世界中所有物体的新位置,但传递给您的 update 函数的 delta time 更少。结果将是您的游戏将进入死亡状态,并最终冻结。
解决方案是将物理与渲染分离。在这里,物理将以 60 FPS 运行,而游戏至少以 30 FPS 运行。这里提出的解决方案并不完美,因为我们没有在不同线程中分离计算,但这一点将在第六章中完成。
看看 Game 头文件:
class Game: public ActionTarget<int>
{
public:
Game(const Game&) = delete;
Game& operator=(const Game&) = delete;
Game(int x,int y,int word_x=10,int word_y=20);
void run(int minimum_frame_per_seconds=30,int phyiscs_frame_per_seconds=60);
private:
void processEvents();
void update(const sf::Time& deltaTime,const sf::Time& timePerFrame);
void updatePhysics(const sf::Time& deltaTime,const sf::Time& timePerFrame);
void render();
sf::RenderWindow _window;
int _moveDirection;
int _rotateDirection;
Piece* _currentPiece;
World _world;
Stats _stats;
sf::Time timeSinceLastFall;
};
没有惊喜。这里存在通常的方法。我们只是复制了 update 函数,一个用于逻辑,另一个用于物理。
现在,让我们看看实现。构造函数初始化 World 并绑定玩家输入。它还创建了将落在板上的初始块:
Game::Game(int X, int Y,int word_x,int word_y) : ActionTarget(Configuration::playerInputs), _window(sf::VideoMode(X,Y),"04_Gravitris"),_currentPiece(nullptr), _world(word_x,word_y)
{
bind(Configuration::PlayerInputs::HardDrop,this{
_currentPiece = _world.newPiece();
timeSinceLastFall = sf::Time::Zero;
});
bind(Configuration::PlayerInputs::TurnLeft,this{
_rotateDirection-=1;
});
bind(Configuration::PlayerInputs::TurnRight,this{
_rotateDirection+=1;
});
bind(Configuration::PlayerInputs::MoveLeft,this{
_moveDirection-=1;
});
bind(Configuration::PlayerInputs::MoveRight,this{
_moveDirection+=1;
});
_stats.setPosition(BOOK_BOX_SIZE*(word_x+3),BOOK_BOX_SIZE);
_currentPiece = _world.newPiece();
}
以下函数没有新内容,只是将两个 update() 函数调用代替了一个:
void Game::run(int minimum_frame_per_seconds, int physics_frame_per_seconds)
{
sf::Clock clock;
const sf::Time timePerFrame = sf::seconds(1.f/minimum_frame_per_seconds);
const sf::Time timePerFramePhysics = sf::seconds(1.f/physics_frame_per_seconds);
while (_window.isOpen())
{
sf::Time time = clock.restart();
processEvents();
if(not _stats.isGameOver())
{
updatePhysics(time,timePerFramePhysics);
update(time,timePerFrame);
}
render();
}
}
以下函数更新了我们游戏中的逻辑:
void Game::update(const sf::Time& deltaTime,const sf::Time& timePerFrame)
{
sf::Time timeSinceLastUpdate = sf::Time::Zero;
timeSinceLastUpdate+=deltaTime;
timeSinceLastFall+=deltaTime;
if(timeSinceLastUpdate > timePerFrame)
{
if(_currentPiece != nullptr)
{
_currentPiece->rotate(_rotateDirection*3000);
_currentPiece->moveX(_moveDirection*5000);
bool new_piece;
int old_level =_stats.getLevel();
_stats.addLines(_world.clearLines(new_piece,*_currentPiece));
if(_stats.getLevel() != old_level) //add sound
_world.add(Configuration::Sounds::LevelUp);
if(new_piece or timeSinceLastFall.asSeconds() > std::max(1.0,10-_stats.getLevel()*0.2))
{//create new piece
_currentPiece = _world.newPiece();
timeSinceLastFall = sf::Time::Zero;
}
}
_world.update(timePerFrame);
_stats.setGameOver(_world.isGameOver());
timeSinceLastUpdate = sf::Time::Zero;
}
_rotateDirection=0;
_moveDirection=0;
}
下面是对前面代码的逐步评估:
-
我们首先通过添加
deltaTime参数来更新一些时间值。 -
然后,如果需要,我们对当前块施加一些力。
-
我们通过清理所有完成的行来更新世界,并更新分数。
-
如果需要,我们创建一个新的块来替换当前的块。
现在看看物理部分:
void Game::updatePhysics(const sf::Time& deltaTime,const sf::Time& timePerFrame)
{
static sf::Time timeSinceLastUpdate = sf::Time::Zero;
timeSinceLastUpdate+=deltaTime;
_world.updateGravity(_stats.getLevel());
while (timeSinceLastUpdate > timePerFrame)
{
_world.updatePhysics(timePerFrame);
timeSinceLastUpdate -= timePerFrame;
}
}
此函数更新了所有物理,包括随当前级别变化的引力。在这里,也没有什么太复杂的。
processEvents() 和 render() 函数完全没有变化,与第一版 Tetris 完全相同。
如您所见,Game 类变化不大,与之前制作的非常相似。两个循环——逻辑和物理——是唯一真正发生的变化。
Stats 类
现在,最后要构建的是Stats类。然而,我们在之前的俄罗斯方块版本中已经创建好了它,所以只需复制粘贴即可。为了游戏结束,我们进行了一些小的改动,通过添加 getter 和 setter 方法。就这样了。
现在,你已经拥有了所有构建带有声音和重力的新俄罗斯方块所需的钥匙。最终结果应该看起来像以下截图:

摘要
由于使用物理引擎有其特定的特性,如单位和游戏循环,我们已经学会了如何处理它们。最后,我们学习了如何将 Box2D 与 SFML 配对,将我们的新知识整合到现有的俄罗斯方块项目中,并创建一个新有趣的游戏。
在下一章中,我们将学习如何通过创建自己的游戏用户界面或使用现有的用户界面,轻松地向我们的游戏添加用户界面,以便与用户进行交互。
第五章. 与用户界面玩耍
在前面的章节中,我们学习了如何构建一些简单的游戏。本章将向您展示如何通过添加用户界面来改进这些游戏。本章将涵盖两种不同的用户界面可能性:
-
创建自己的对象
-
使用现有的库–简单快速图形用户界面(SFGUI)
到本章结束时,你应该能够创建从简单到复杂的界面与玩家进行通信。
什么是 GUI?
图形用户界面(GUI)是一种机制,允许用户通过图标、文本、按钮等图形对象直观地与软件进行交互。内部,GUI 处理一些事件并将它们绑定到函数,这些函数通常被称为回调。这些函数定义了程序的反应。
在 GUI 中始终存在许多不同的常见对象,例如按钮、窗口、标签和布局。我认为我不需要向你解释按钮、窗口或标签是什么,但我将简要解释布局是什么。
布局是一个不可见对象,它管理屏幕上图形对象的排列。简单来说,它的目标是通过管理它们的一部分来关注对象的大小和位置。它就像一张桌子,确保这些对象中没有一个是位于另一个之上的,并且尽可能地调整它们的大小以填充屏幕。
从头开始创建 GUI
现在已经介绍了 GUI 术语,我们将考虑如何使用 SFML 逐个构建它。这个 GUI 将被添加到 Gravitris 项目中,结果将与以下两个截图类似:

这些显示了游戏的开局菜单和游戏中的暂停菜单。
要构建这个 GUI,只使用了四个不同的对象:TextButton、Label、Frame和VLayout。我们现在将看看如何构建我们的代码,使其尽可能灵活,以便在需要时扩展这个 GUI。
类层次结构
如前所述,我们需要为 GUI 构建不同的组件。每个组件都有其独特的特性和功能,可能与其他组件略有不同。以下是这些组件的一些特性:
-
TextButton:这个类将代表一个按钮,当点击时可以触发“点击”事件。从图形上看,它是一个包含文本的框。 -
Label:这个类可以接受可以在屏幕上显示的简单文本。 -
Frame:这个类是一个不可见的容器,将通过布局包含一些对象。这个对象也将附加到 SFML 窗口上,并填充整个窗口。这个类还可以处理事件(例如捕获窗口的调整大小、Esc键的点击等)。 -
Vlayout:这个类的功能已经解释过了——它垂直显示对象。这个类必须能够调整它所附加的所有对象的位置。
因为我们想要构建一个可重用的 GUI 并且它需要尽可能灵活,所以我们需要比我们的 4 个类更大的视野来构建它。例如,我们应该能够轻松地添加一个容器,切换到水平布局或网格布局,使用精灵按钮等等。基本上,我们需要一个允许轻松添加新组件的层次结构。以下是一个可能的解决方案:

注意
在这个层次结构中,每个绿色框代表 GUI 的外部类。
在 GUI 系统中,每个组件都是一个Widget。这个类是所有其他组件的基础,并定义了与它们交互的通用方法。我们还定义了一些虚拟类,例如Button、Container和Layout。每个这些类都适配了Widget类,并增加了在不费太多力气的情况下扩展我们系统的可能性。例如,通过从Layout扩展,可以添加一个HLayout类。其他例子包括一些特定的按钮,如RadioButton和CheckBox,它们使用Button类。
在这个层次结构中,Frame类扩展了ActionTarget类。目的是能够使用ActionTarget的绑定方法来捕获一些事件,例如在某个窗口中工作时按下Esc键。
现在我们已经向你展示了这个层次结构,我们将继续实现不同的类。让我们从基础开始:Widget类。
Widget类
如前所述,这个类是所有其他 GUI 组件的共同主干。它提供了一些具有默认行为的通用方法,这些方法可以被自定义或改进。Widget类不仅有一个位置并且可以被移动,而且还有在屏幕上显示的能力。看看它的头文件源码:
class Widget : public sf::Drawable
{
public:
Widget(Widget* parent=nullptr);
virtual ~Widget();
void setPosition(const sf::Vector2f& pos);
void setPosition(float x,float y);
const sf::Vector2f& getPosition()const;
virtual sf::Vector2f getSize()const = 0;
protected:
virtual bool processEvent(const sf::Event& event,const sf::Vector2f& parent_pos);
virtual void processEvents(const sf::Vector2f& parent_pos);
virtual void updateShape();
Widget* _parent;
sf::Vector2f _position;
};
这个第一个类很简单。我们定义了一个构造函数和一个虚拟析构函数。虚拟析构函数非常重要,因为 GUI 逻辑中使用了多态。然后我们在内部变量上定义了一些 getter 和 setter。小部件也可以附加到它所包含的另一个小部件上,因此我们保留了对它的引用以供更新之用。现在让我们看看实现以更好地理解:
Widget::Widget(Widget* parent) : _parent(parent){}
Widget::~Widget(){}
void Widget::setPosition(const sf::Vector2f& pos) {_position = pos;}
void Widget::setPosition(float x,float y)
{
_position.x = x;
_position.y = y;
}
const sf::Vector2f& Widget::getPosition()const {return _position;}
bool Widget::processEvent(const sf::Event& event,const sf::Vector2f& parent_pos) {return false;}
void Widget::processEvents(const sf::Vector2f& parent_pos) {}
到目前为止,没有什么应该让你感到惊讶的。我们只定义了一些 getter/setter 和为事件处理编写了默认行为。
现在看看下面的函数:
void Widget::updateShape()
{
if(_parent)
_parent->updateShape();
}
这个函数,与我们所看到的其他函数不同,非常重要。它的目标是通过 GUI 树传播更新请求。例如,从一个由于文本更改而改变大小的按钮,到其布局,再到容器。通过这样做,我们确保每个组件都会被更新,而无需进一步的努力。
Label类
现在已经介绍了Widget类,让我们构建我们的第一个小部件,一个标签。这是我们能够构建的最简单的小部件。因此,我们将通过它学习 GUI 的逻辑。结果将如下所示:

为了做到这一点,我们将运行以下代码:
class Label : public Widget
{
public:
Label(const std::string& text, Widget* parent=nullptr);
virtual ~Label();
void setText(const std::string& text);
void setCharacterSize(unsigned int size);
unsigned int getCharacterSize()const;
void setTextColor(const sf::Color& color);
virtual sf::Vector2f getSize()const override;
private:
sf::Text _text;
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
};
如您所见,这个类不过是一个围绕sf::Text的盒子。它定义了一些从sf::Text API 中提取的方法,具有完全相同的行为。它还实现了Widget类的要求,例如getSize()和draw()方法。现在让我们看看实现:
Label::Label(const std::string& text, Widget* parent) : Widget(parent)
{
_text.setFont(Configuration::fonts.get(Configuration::Fonts::Gui));
setText(text);
setTextColor(sf::Color(180,93,23));
}
构造函数从参数初始化文本,设置从Configuration类中获取的默认字体,并设置颜色。
Label::~Label() {}
void Label::setText(const std::string& text)
{
_text.setString(text);
updateShape();
}
void Label::setCharacterSize(unsigned int size)
{
_text.setCharacterSize(size);
updateShape();
}
这两个函数将任务转发给sf::Text,并请求更新,因为可能发生大小的变化。
unsigned int Label::getCharacterSize()const {return _text.getCharacterSize();}
void Label::setTextColor(const sf::Color& color) {_text.setColor(color);}
sf::Vector2f Label::getSize()const
{
sf::FloatRect rect = _text.getGlobalBounds();
return sf::Vector2f(rect.width,rect.height);
}
SFML 已经提供了一个函数来获取sf::Text参数的大小,所以我们使用它并将结果转换为预期的值,如下面的代码片段所示:
void Label::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
states.transform.translate(_position);
target.draw(_text,states);
}
此函数很简单,但我们需要理解它。每个小部件都有自己的位置,但相对于父元素。因此,当我们显示对象时,我们需要通过平移变换矩阵的相对位置来更新sf::RenderStates参数,然后绘制所有需要的元素。这很简单,但很重要。
按钮类
现在,我们将构建另一个非常有用的Widget类:Button类。这个类将是一个虚拟类,因为我们希望能够构建多个按钮类。但是,所有按钮类都有一些共享的函数,例如“点击”事件。因此,这个类的目标是分组它们。看看这个类的头文件:
class Button : public Widget
{
public:
using FuncType = std::function<void(const sf::Event& event,Button& self)>;
static FuncType defaultFunc;
Button(Widget* parent=nullptr);
virtual ~Button();
FuncType onClick;
protected:
virtual bool processEvent(const sf::Event& event,const sf::Vector2f& parent_pos)override;
virtual void onMouseEntered();
virtual void onMouseLeft();
private:
enum Status {None =0,Hover = 1};
int _status;
};
如同往常,我们声明构造函数和析构函数。我们还声明了一个onClick属性,它是一个std::function,当按钮被按下时会触发。这是我们的回调。回调类型被保留为typedef,我们还声明了一个默认的空函数以方便使用。现在,让我们看看实现:
Button::FuncType Button::defaultFunc = [](const sf::Event&,Button&)->void{};
在以下代码片段的帮助下,我们声明了一个空函数,该函数将用作onClick属性的默认值。此函数不执行任何操作:
Button::Button(Widget* parent) : Widget(parent), onClick(defaultFunc), _status(Status::None) {}
我们构建了一个构造函数,它将其参数转发给其父类,并将onClick值设置为之前定义的默认空函数,以避免当用户未初始化回调时出现未定义的性能,如下面的代码片段所示:
Button::~Button() {}
bool Button::processEvent(const sf::Event& event,const
sf::Vector2f& parent_pos)
{
bool res = false;
if(event.type == sf::Event::MouseButtonReleased)
{
const sf::Vector2f pos = _position + parent_pos;
const sf::Vector2f size = getSize();
sf::FloatRect rect;
rect.left = pos.x;
rect.top = pos.y;
rect.width = size.x;
rect.height = size.y;
if(rect.contains(event.mouseButton.x,event.mouseButton.y))
{
onClick(event,*this);
res = true;
}
} else if (event.type == sf::Event::MouseMoved) {
const sf::Vector2f pos = _position + parent_pos;
const sf::Vector2f size = getSize();
sf::FloatRect rect;
rect.left = pos.x;
rect.top = pos.y;
rect.width = size.x;
rect.height = size.y;
int old_status = _status;
_status = Status::None;
const sf::Vector2f
mouse_pos(event.mouseMove.x,event.mouseMove.y);
if(rect.contains(mouse_pos))
_status=Status::Hover;
if((old_status & Status::Hover) and not (_status &
Status::Hover))
onMouseLeft();
else if(not (old_status & Status::Hover) and (_status &
Status::Hover))
onMouseEntered();
}
return res;
}
这个函数是我们类的心脏。它通过在满足某些标准时触发一些回调来管理事件。让我们一步一步地看看它:
-
如果作为参数接收的事件是点击,我们必须检查它是否发生在按钮区域内。如果是这样,我们将触发我们的
onClick函数。 -
另一方面,如果事件是由指针移动引起的,我们验证鼠标指针是否悬停在按钮上。如果是这样,我们将状态值设置为
Hover,这里有一个技巧: -
如果这个标志刚刚被定义为
Hover,那么我们将调用onMouseEntered()方法,这个方法是可以定制的。 -
如果标志之前被定义为
Hover但现在不再设置为它,那是因为鼠标离开了按钮的区域,所以我们调用另一个方法:onMouseLeft()。
注意
如果processEvent()方法返回的值设置为true,它将停止在 GUI 上事件的传播。返回false将继续事件的传播;例如,在鼠标移动时,也可以使用不停止事件传播的事件;但在这个情况下,我们简单地不能同时点击多个小部件对象,所以如果需要,我们将停止。
我希望processEvent()函数的逻辑是清晰的,因为我们的 GUI 逻辑基于它。
以下两个函数是带有鼠标移动事件的按钮的默认空行为。当然,我们将在专门的Button类中自定义它们:
void Button::onMouseEntered() {}
void Button::onMouseLeft() {}
文本按钮类
这个类将扩展我们之前定义的Button类。结果将在屏幕上显示一个带有文本的矩形,就像以下截图所示:

现在看看实现。记住,我们的Button类是从sf::Drawable扩展而来的:
class TextButton : public
{
public:
TextButton(const std::string& text, Widget* parent=nullptr);
virtual ~TextButton();
void setText(const std::string& text);
void setCharacterSize(unsigned int size);
void setTextColor(const sf::Color& color);
void setFillColor(const sf::Color& color);
void setOutlineColor(const sf::Color& color);
void setOutlineThickness(float thickness);
virtual sf::Vector2f getSize()const override;
private:
sf::RectangleShape _shape;
Label _label;
void updateShape()override;
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
sf::Color _fillColor;
sf::Color _outlineColor;
virtual void onMouseEntered()override;
virtual void onMouseLeft()override;
};
这个类扩展了Button类,并为其添加了一个矩形形状和标签。它还实现了onMouseEntered()和onMouseLeft()函数。这两个函数将改变按钮的颜色,使其稍微亮一些:
TextButton::TextButton(const std::string& text,Widget* parent) : Button(parent), _label(text,this)
{
setFillColor(sf::Color(86,20,19));
setOutlineThickness(5);
setOutlineColor(sf::Color(146,20,19));
}
构造函数初始化不同的颜色和初始文本:
TextButton::~TextButton() {}
void TextButton::setText(const std::string& text) {_label.setText(text);}
void TextButton::setCharacterSize(unsigned int size) {_label.setCharacterSize(size);}
void TextButton::setTextColor(const sf::Color& color) {_label.setTextColor(color);}
void TextButton::setFillColor(const sf::Color& color)
{
_fillColor = color;
_shape.setFillColor(_fillColor);
}
void TextButton::setOutlineColor(const sf::Color& color)
{
_outlineColor = color;
_shape.setOutlineColor(_outlineColor);
}
void TextButton::setOutlineThickness(float thickness) {_shape.setOutlineThickness(thickness);}
sf::Vector2f TextButton::getSize()const
{
sf::FloatRect rect = _shape.getGlobalBounds();
return sf::Vector2f(rect.width,rect.height);
}
所有这些函数通过转发任务来设置不同的属性。它还调用updateShape()方法来更新容器:
void TextButton::updateShape()
{
sf::Vector2f label_size = _label.getSize();
unsigned int char_size = _label.getCharacterSize();
_shape.setSize(sf::Vector2f(char_size*2 + label_size.x ,char_size*2 + label_size.y));
_label.setPosition(char_size,char_size);
Widget::updateShape();
}
以下函数通过使用内部标签的大小来调整形状,并添加一些填充来更新形状:
void TextButton::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
states.transform.translate(_position);
target.draw(_shape,states);
target.draw(_label,states);
}
这个方法与标签的逻辑相同。它将sf::RenderStates移动到按钮的位置,并绘制所有不同的sf::Drawable参数:
void TextButton::onMouseEntered()
{
const float light = 1.4f;
_shape.setOutlineColor(sf::Color(_outlineColor.r*light,
_outlineColor.g*light,
_outlineColor.b*light));
_shape.setFillColor(sf::Color(_fillColor.r*light,
_fillColor.b*light,
_fillColor.b*light));
}
void TextButton::onMouseLeft()
{
_shape.setOutlineColor(_outlineColor);
_shape.setFillColor(_fillColor);
}
这两个函数在鼠标悬停在按钮上时改变按钮的颜色,并在鼠标离开时重置初始颜色。这对用户来说很有用,因为他可以很容易地知道哪个按钮将被点击。
如您所见,TextButton的实现相当简短,这都要归功于父类Button和Widget所做的更改。
容器类
这个类是另一种类型的Widget,将是抽象的。Container类是一个Widget类,将通过Layout类存储其他小部件。这个类的目的是将不同可能的Container类之间的所有常见操作分组,即使在我们只实现了Frame容器的情况下。
class Container : public Widget
{
public:
Container(Widget* parent=nullptr);
virtual ~Container();
void setLayout(Layout* layout);
Layout* getLayout()const;
virtual sf::Vector2f getSize()const override;
protected:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
virtual bool processEvent(const sf::Event& event,const sf::Vector2f& parent_pos)override;
virtual void processEvents(const sf::Vector2f& parent_pos)override;
private:
Layout* _layout;
};
如同往常,我们定义了构造函数和析构函数。我们还添加了对内部Layout类的访问器。我们还将实现draw()方法和事件处理。现在看看以下代码片段中的实现:
Container::Container(Widget* parent) : Widget(parent), _layout(nullptr) {}
Container::~Container()
{
if(_layout != nullptr and _layout->_parent == this) {
_layout->_parent = nullptr;
delete _layout;
}
}
析构函数会删除内部的Layout类,但仅当Layout类的父类是当前容器时才会这样做。这避免了双重释放的损坏,并尊重了 RAII 习语:
void Container::setLayout(Layout* layout)
{
if(_layout != nullptr and _layout->_parent == this) {
_layout->_parent = nullptr;
}
if((_layout = layout) != nullptr) {
_layout->_parent = this;
_layout->updateShape();
}
}
前一个函数设置容器的布局,并在需要时将其从内存中删除。然后它接管新的布局的所有权,并更新对它的内部指针。
Layout* Container::getLayout()const {return _layout;}
sf::Vector2f Container::getSize()const
{
sf::Vector2f res(0,0);
if(_layout)
res = _layout->getSize();
return res;
}
void Container::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
if(_layout)
target.draw(*_layout,states);
}
前三个函数执行通常的工作,就像其他Widgets一样:
bool Container::processEvent(const sf::Event& event,const sf::Vector2f& parent_pos)
{
bool res = false;
if(and _layout)
res = _layout->processEvent(event,parent_pos);
return res;
}
void Container::processEvents(const sf::Vector2f& parent_pos)
{
if(_layout)
_layout->processEvents(parent_pos);
}
这两个之前的函数处理事件。因为Layout类没有要处理的事件,它将任务转发给所有内部的Widget类。如果一个Widget类处理了事件,我们就停止传播,因为从逻辑上讲,没有其他控件应该能够处理它。
Frame类
现在基本容器已经构建完成,让我们用特殊的一个来扩展它。下面的Widget类将附加到sf::RenderWindow上,并成为主要的控件。它将自行管理渲染目标和事件。看看它的头文件:
class Frame : public Container, protected ActionTarget<int>
{
public:
using ActionTarget<int>::FuncType;
Frame(sf::RenderWindow& window);
virtual ~Frame();
void processEvents();
bool processEvent(const sf::Event& event);
void bind(int key,const FuncType& callback);
void unbind(int key);
void draw();
virtual sf::Vector2f getSize()const override;
private:
sf::RenderWindow& _window;
virtual bool processEvent(const sf::Event& event,const sf::Vector2f& parent_pos)override;
virtual void processEvents(const sf::Vector2f& parent_pos)override;
};
正如你所见,这个类比之前的Widget类要复杂一些。它扩展了Container类,以便能够将其附加到Layout类上。此外,它还扩展了ActionTarget类,但作为受保护的。这是一个重要的点。实际上,我们希望允许用户绑定/解绑事件,但不想允许他们将Frame强制转换为ActionTarget,所以我们将其隐藏给用户,并重写ActionTarget类的所有方法。这就是为什么有受保护关键字的原因。
这个类还将能够从其父窗口中提取事件;这解释了为什么我们需要保留对其的引用,如下所示:
Frame::Frame(sf::RenderWindow& window) : Container(nullptr), ActionTarget(Configuration::gui_inputs), _window(window) {}
Frame::~Frame(){}
void Frame::draw() {_window.draw(*this);}
void Frame::bind(int key,const FuncType& callback) {ActionTarget::bind(key,callback);}
void Frame::unbind(int key) {ActionTarget::unbind(key);}
sf::Vector2f Frame::getSize()const
{
sf::Vector2u size = _window.getSize();
return sf::Vector2f(size.x,size.y);
}
所有这些方法都很简单,不需要很多解释。你只需使用构造函数初始化所有属性,并将任务转发给存储在类内部的属性,就像这里所做的那样:
void Frame::processEvents()
{
sf::Vector2f parent_pos(0,0);
processEvents(parent_pos);
}
bool Frame::processEvent(const sf::Event& event)
{
sf::Vector2f parent_pos(0,0);
return processEvent(event,parent_pos);
}
这两个重载函数暴露给用户。它通过构造缺失的或已知的参数将任务转发给从Widget继承的覆盖函数。
bool Frame::processEvent(const sf::Event& event,const sf::Vector2f& parent_pos)
{
bool res = ActionTarget::processEvent(event);
if(not res)
res = Container::processEvent(event,parent_pos);
return res;
}
void Frame::processEvents(const sf::Vector2f& parent_pos)
{
ActionTarget::processEvents();
Container::processEvents(parent_pos);
sf::Event event;
while(_window.pollEvent(event))
Container::processEvent(event,parent_pos);
}
另一方面,这两个函数处理类的ActionTarget和Container基类的事件管理,同时也负责从父窗口轮询事件。在这种情况下,所有事件管理都将自动进行。
Frame类现在已经结束。正如你所见,这并不是一个复杂的任务,多亏了我们的分层树和代码的重用。
Layout类
现在所有将在屏幕上渲染的控件都正在构建,让我们构建一个负责它们排列的类:
class Layout : protected Widget
{
public:
Layout(Widget* parent=nullptr);
virtual ~Layout();
void setSpace(float pixels);
protected:
friend class Container;
float _space;
};
如您所见,抽象类非常简单。唯一的新特性是能够设置间距。我们没有 add(Widget*) 方法,例如。原因是根据使用的 Layout 类型,参数会有所不同。例如,对于只有单列或单行的布局,我们只需要一个 Widget 类作为参数,但对于网格来说,情况完全不同。我们需要两个其他整数来表示小部件可以放置的单元格。因此,这里没有设计通用的 API。您将看到,这个类的实现也非常简单,不需要任何解释。它遵循我们之前创建的 Widget 类的逻辑。
Layout::Layout(Widget* parent): Widget(parent), _space(5) {}
Layout::~Layout() {}
void Layout::setSpace(float pixels)
{
if(pixels >= 0) {
_space = pixels;
updateShape();
}
else
throw std::invalid_argument("pixel value must be >= 0");
}
VLayout 类
这个 Layout 类将比之前的类更复杂。这个类包含了垂直布局的完整实现,它自动调整其大小和所有内部对象的对齐方式:
class VLayout : public Layout
{
public:
VLayout(const VLayout&) = delete;
VLayout& operator=(const VLayout&) = delete;
VLayout(Widget* parent = nullptr);
~Vlayout();
void add(Widget* widget);
Widget* at(unsigned int index)const;
virtual sf::Vector2f getSize()const override;
protected:
virtual bool processEvent(const sf::Event& event,const sf::Vector2f& parent_pos) override;
virtual void processEvents(const sf::Vector2f& parent_pos) override;
private:
std::vector<Widget*> _widgets;
virtual void updateShape() override;
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
};
这个类将实现所有来自小部件的要求,并添加在其中添加小部件的功能。因此,有一些函数需要实现。为了跟踪附加到 Layout 类的小部件,我们将在内部将它们存储在一个容器中。选择 std::vector 类在这里是有意义的,因为元素可以通过 at() 方法进行随机访问,并且通过容器进行大量访问。所以选择的原因仅仅是性能,因为 std::list 也能完成同样的工作。现在,让我们看看实现:
VLayout::VLayout(Widget* parent) : Layout(parent) {}
VLayout::~VLayout()
{
for(Widget* widget : _widgets) {
if(widget->_parent == this)
delete widget;
}
}
析构函数将释放与 Layout 类关联的对象的内存,其标准与在 Container 类中解释的标准相同:
void VLayout::add(Widget* widget)
{
widget->_parent = this;
_widgets.emplace_back(widget);
updateShape();
}
Widget* VLayout::at(unsigned int index)const {return _widgets.at(index);}
这两个之前的功能添加了添加和获取由类实例存储的小部件的可能性。add() 方法还额外承担了添加对象的拥有权:
sf::Vector2f VLayout::getSize()const
{
float max_x = 0;
float y = 0;
for(Widget* widget : _widgets)
{
sf::Vector2f size = widget->getSize();
if(size.x > max_x)
max_x = size.x;
y+= _space + size.y;
}
return sf::Vector2f(max_x+_space*2,y+_space);
}
这个方法计算布局的总大小,考虑到间距。因为我们的类将在单列中显示所有对象,所以高度将是它们的总大小,宽度是所有对象的最大值。每次都必须考虑间距。
bool VLayout::processEvent(const sf::Event& event,const sf::Vector2f& parent_pos)
{
for(Widget* widget : _widgets)
{
if(widget->processEvent(event,parent_pos))
return true;
}
return false ;
}
void VLayout::processEvents(const sf::Vector2f& parent_pos)
{
for(Widget* widget : _widgets)
widget->processEvents(parent_pos);
}
这两个之前的方法将任务转发给所有存储的小部件,但在需要时我们会停止传播。
void VLayout::updateShape()
{
float max_x = (_parentparent->getSize().x:0);
for(Widget* widget : _widgets) {
sf::Vector2f size = widget->getSize();
float widget_x = size.x;
if(widget_x > max_x)
max_x = widget_x;
}
float pos_y = _space;
if(_parent)
pos_y = (_parent->getSize().y - getSize().y)/2.f;
for(Widget* widget : _widgets)
{
sf::Vector2f size = widget->getSize();
widget->setPosition((max_x-size.x)/2.0,pos_y);
pos_y += size.y + _space;
}
Widget::updateShape();
}
这个方法对这个类来说是最重要的。它通过基于所有其他小部件来计算,重置所有对象的不同位置。最终结果将是一个垂直和水平居中的小部件列。
void VLayout::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
for(Widget* widget : _widgets)
target.draw(*widget,states);
}
这个最后的函数要求每个 Widget 通过传递参数来渲染自己。这次,我们不需要翻译状态,因为布局的位置与其父级相同。
整个类现在已经构建并解释完毕。现在是用户使用它们并为我们的游戏添加菜单的时候了。
为游戏添加菜单
现在我们已经准备好所有构建基本菜单的组件,让我们用我们的新 GUI 来实现它。我们将构建两个菜单。一个是主要的游戏开启菜单,另一个是暂停菜单。这将展示我们实际 GUI 的不同使用可能性。
如果你已经很好地理解了我们到目前为止所做的一切,你会注意到我们 GUI 的基础组件是Frame。所有其他小部件都将显示在其顶部。以下是一个总结 GUI 树结构的图示:

每种颜色代表不同类型的组件。树干是sf::RenderWindow,然后我们有一个附加到其上的Frame及其Layout。最后,我们有一些不同的Widget。现在使用方法已经解释清楚,让我们创建我们的主菜单。
构建主菜单
为了构建主菜单,我们需要向Game类添加一个属性。让我们称它为_mainMenu。
gui::Frame _mainMenu;
我们然后创建一个enum函数,其中包含不同的值可能性,以便知道当前显示的状态:
enum Status {StatusMainMenu,StatusGame,StatusConfiguration,StatusPaused,StatusExit} _status
现在让我们创建一个初始化菜单的函数:
void initGui();
此函数将存储整个 GUI 构建过程,除了调用构造函数之外。现在我们已经将所有需要的内容放入头文件中,让我们继续实现所有这些功能。
首先,我们需要通过添加_mainMenu和_status的初始化来更新构造函数。它应该看起来像这样:
Game::Game(int X, int Y,int word_x,int word_y) : ActionTarget(Configuration::player_inputs),
_window(sf::VideoMode(X,Y),"05_Gui"), _current_piece(nullptr),
_world(word_x,word_y), _mainMenu(_window),
_status(Status::StatusMainMenu)
{
//...
initGui();
}
现在我们需要按照以下方式实现initGui()函数:
void Game::initGui()
{
book::gui::VLayout* layout = new book::gui::VLayout;
layout->setSpace(25);
book::gui::TextButton* newGame = new book::gui::TextButton("New Game");
newGame->onClick = this{
initGame();
_status = Status::StatusGame;
};
layout->add(newGame);
book::gui::TextButton* configuration = new book::gui::TextButton("Configuration");
configuration->onClick = this{
_status = Status::StatusConfiguration;
};
layout->add(configuration);
book::gui::TextButton* exit = new book::gui::TextButton("Exit");
exit->onClick = this{
_window.close();
};
layout->add(exit);
_mainMenu.setLayout(layout);
_mainMenu.bind(Configuration::GuiInputs::Escape,this{
this->_window.close();
});
}
让我们一步一步地讨论这个函数:
-
我们创建了一个
Vlayout类并设置了其间距。 -
我们创建了一个按钮,其标签为
New Game。 -
我们设置了初始化游戏的
onClick回调函数。 -
我们将按钮添加到布局中。
-
使用相同的逻辑,我们创建了两个其他按钮,具有不同的回调函数。
-
然后将布局设置到
_mainMenu参数。 -
最后,我们向框架中添加一个事件,该事件将处理Esc键。这个键在
Configuration类中定义的GuiInputs enum中,该类是作为PlayerInputs构建的。
现在我们已经创建了菜单,我们需要对现有的run()、processEvents()和render()方法进行一些小的修改。让我们从run()开始。这次修改微乎其微。实际上,我们只需要添加一个调用更新方法的条件,对_status变量进行验证。新的一行如下:
if(_status == StatusGame and not _stats.isGameOver())
下一个函数是processEvents(),它需要一些更多的修改,但并不多。实际上,我们需要在游戏处于StatusMainMenu模式时调用_mainMenu::processEvent(const f::Event&)和_mainMenu::processEvents()。新的方法如下:
void Game::processEvents()
{
sf::Event event;
while(_window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
_window.close();
else if (event.type == sf::Event::KeyPressed and event.key.code == sf::Keyboard::Escape and _status == Status::StatusGame)
_status = StatusPaused;
else
{
switch(_status)
{
case StatusMainMenu: _mainMenu.processEvent(event);break;
case StatusGame : ActionTarget::processEvent(event);break;
default : break;
}
}
}
switch(_status)
{
case StatusMainMenu: _mainMenu.processEvents();break;
case StatusGame : ActionTarget::processEvents();break;
default : break;
}
}
如你所见,修改并不复杂,易于理解。
现在,render()方法的最后一个修改。逻辑相同,根据_status值进行切换。
void Game::render()
{
_window.clear();
switch(_status)
{
case StatusMainMenu: _window.draw(_mainMenu);break;
case StatusGame :
{
if(not _stats.isGameOver())
_window.draw(_world);
_window.draw(_stats);
}break;
default : break;
}
_window.display();
}
如您所见,我们能够轻松地为我们游戏添加一个菜单。结果应该像这里显示的图示一样:

现在,让我们构建第二个菜单。
构建暂停菜单
暂停菜单将像之前的一个一样构建,所以我将跳过构造函数部分,直接进入 initGui() 函数:
void Game::initGui()
{
//...
book::gui::VLayout* layout = new book::gui::VLayout;
layout->setSpace(50);
book::gui::Label* pause = new book::gui::Label("Pause");
pause->setCharacterSize(70);
layout->add(pause);
book::gui::TextButton* exit = new book::gui::TextButton("Exit");
exit->onClick = this
{
_status = StatusMainMenu;
};
layout->add(exit);
_pauseMenu.setLayout(layout);
_pauseMenu.bind(Configuration::GuiInputs::Escape,this{
_status = StatusGame;
});
}
逻辑与之前菜单使用的逻辑完全相同,但这里我们使用了一个 Label 和 TextButton 类。按钮的回调也将更改 _status 值。在这里,我们再次捕获 Esc 键。结果是离开这个菜单。在 processEvents() 中,我们只需要在第一个 switch 语句中添加一行:
case StatusPaused :_pauseMenu.processEvent(event);break;
并在第二个 switch 语句中添加另一行:
case StatusPaused : _pauseMenu.processEvents();break;
就这样。我们完成了这个函数。
下一步是 render() 函数。在这里,它也将非常快。我们在 switch 语句中添加一个 case,如下所示:
case StatusPaused :
{
if(not _stats.isGameOver())
_window.draw(_world);
_window.draw(_pauseMenu);
}break;
请求绘制 _world 意味着在菜单的背景上设置当前游戏状态。这没什么用,但很酷,为什么不试试呢?
最终结果是本章开头显示的第二张截图。看看我屏幕上显示的内容:

构建配置菜单
实际上,这个菜单将在第二部分(使用 SFGUI)中实现,但我们需要一个退出配置菜单的方法。因此,我们只需创建一个 _configurationMenu 作为另外两个一样,并将 Escape 事件绑定到设置为主菜单的状态。下面是 initGui() 中需要添加的代码:
_configurationMenu.bind(Configuration::GuiInputs::Escape,this{
_status = StatusMainMenu;
});
我相信您现在能够使用您的新技能自己更新 processEvents() 和 render() 函数。
关于我们自制的 GUI 的内容就到这里。当然,您可以按需改进它。这是它的一个优点。
提示
如果您对改进感兴趣,请查看外部库 github.com/Krozark/SFML-utils/,它将所有自定义游戏框架重新组合。
下一步是使用一个已经制作好的具有更复杂控件的 GUI。但请记住,如果您只需要显示像这里展示的菜单,这个 GUI 就足够了。
使用 SFGUI
SFGUI 是一个开源库,它基于 SFML 实现了一个完整的 GUI 系统。其目标是提供丰富的控件,并且易于自定义和扩展。它还使用了现代 C++,因此在任何 SFML 项目中使用它都很容易,无需太多努力。
以下截图显示了 SFGUI 与提供的源代码中的测试示例一起运行的情况:

安装 SFGUI
第一步是下载源代码。你可以在库的官方网站上找到它:sfgui.sfml-dev.de/。当前版本是 0.2.3(2014 年 2 月 20 日)。你需要自己构建 SFGUI,但像往常一样,它附带 cmake 文件来帮助构建。这很完美,因为我们已经知道如何使用它。
在构建步骤中,有时你可能会遇到如下截图所示的问题:

在这种情况下,你必须使用 add entry 参数将 CMAKE_MODULE_PATH 变量设置为 /path/to/SFML/cmake/Modules。这应该可以解决问题。
注意
对于其他类似的问题,请查看这个页面:sfgui.sfml-dev.de/p/faq#findsfml。这应该会有所帮助。
现在 SFGUI 已经配置好了,你需要构建它,并最终像 SFML 和 Box2D 一样安装它。你现在应该已经很熟悉这个过程了。
使用 SFGUI 的特性
在这本书中,我不会深入探讨 SFGUI 的使用。目标是向你展示,当已经有现成的好方案时,你不必总是需要重新发明轮子。
SFGUI 使用了许多 C++11 特性,例如 shared_pointers、std::functions 以及本书中已经介绍的一些其他特性,并且还使用了 RAII 习惯用法。既然你已经知道如何使用这些特性,那么在使用 SFGUI 时,你不会感到迷茫。
首先,要使用 SFGUI 对象,你必须先实例化一个对象,然后再实例化其他所有对象:sfg::SFGUI。这个类包含了渲染所需的所有信息。除了这个点之外,库可以像我们的一样使用。所以让我们试试吧。
构建起始级别
我们将在游戏中添加一个菜单,允许我们选择起始级别。本节的目标是添加一个简单的表单,它接受一个数字作为参数并将其设置为游戏的起始级别。最终结果将如下所示:

在开始使用 SFGUI 之前,我们需要更新我们的 Stats 类。实际上,这个类不允许我们从特定级别开始,所以我们需要添加这个功能。这可以通过向其中添加一个新属性来完成,如下所示:
unsigned int _initialLvl;
我们还需要一个新的方法:
void setLevel(int lvl);
标题部分就到这里。现在我们需要将 _initialLvl 初始化为默认的 0。然后更改 addLines() 函数中当前级别的计算。为此,请转到以下行:
_nbLvl = _nbRows / 10;
将前面的行更改为以下行:
_nbLvl = _initialLvl + (_nbRows / 10);
最后,我们需要更新或实现当前级别的评估器,如下所示:
void Stats::setLevel(int lvl)
{
_initialLvl = lvl;
_textLvl.setString("lvl : "+std::to_string(lvl));
}
int Stats::getLevel()const
{
return _initialLvl + _nbLvl;
}
这个类更新的内容就到这里。现在让我们回到 SFGUI。
我们将只使用三个不同的视觉对象来构建所需表单:标签、文本输入和按钮。但我们也会使用布局和桌面,这相当于我们的 Frame 类。所有初始化都将像之前一样在 initGui() 函数中完成。
我们还需要为我们的游戏添加两个新的属性:
sfg::SFGUI _sfgui;
sfg::Desktop _sfgDesktop;
添加 _sfgui 的原因之前已经解释过了。我们添加 _sfDesktop 的原因与添加 Frame 来包含对象的原因完全相同。
现在看看创建表单所需的代码:
void Game::initGui()
{
//...
auto title = sfg::Label::Create("Enter your starting level");
auto level = sfg::Entry::Create();
auto error = sfg::Label::Create();
auto button = sfg::Button::Create( "Ok" );
button->GetSignal( sfg::Button::OnLeftClick ).Connect(
[level,error,this](){
int lvl = 0;
std::stringstream sstr(static_cast<std::string>(level->GetText()));
sstr >> lvl;
if(lvl < 1 or lvl > 100)
error->SetText("Enter a number from 1 to 100.");
else
{
error->SetText("");
initGame();
_stats.setLevel(lvl);
_status = Status::StatusGame;
}
}
);
auto table = sfg::Table::Create();
table->SetRowSpacings(10);
table->Attach(title,sf::Rect<sf::Uint32>(0,0,1,1));
table->Attach(level,sf::Rect<sf::Uint32>(0,1,1,1));
table->Attach(button,sf::Rect<sf::Uint32>(0,2,1,1));
table->Attach(error,sf::Rect<sf::Uint32>(0,3,1,1));
table->SetAllocation(sf::FloatRect((_window.getSize().x-300)/2,
(_window.getSize().y-200)/2,
300,200));
_sfgDesktop.Add(table);
}
好吧,这里有很多新功能,所以我将一步一步地解释它们:
-
首先,我们创建这个表单所需的不同组件。
-
然后,我们将按钮的回调设置为按下事件。这个回调执行了很多事情:
-
我们获取用户输入的文本。
-
我们使用
std::stringstream将此文本转换为整数。 -
我们检查输入的有效性。
-
如果输入无效,我们将显示错误信息。
-
另一方面,如果它是有效的,我们将重置游戏,设置起始关卡,并开始游戏。
-
-
在所有对象创建完成之前,我们将它们逐个添加到布局中。
-
我们更改了布局的大小并将其居中显示在窗口中。
-
最后,我们将布局附加到桌面上。
由于所有对象都已创建并存储到 std::shared_ 中,我们不需要跟踪它们。SFGUI 会为我们做这件事。
现在表单已经创建,我们面临与 GUI 相同的挑战:事件和渲染。好消息是,逻辑是相同的!然而,我们确实需要再次编写 processEvents() 和 render() 函数。
在 processEvents() 方法中,我们只需要完成以下代码片段中显示的第一个 switch 即可:
case StatusConfiguration :
{
_configurationMenu.processEvent(event);
_sfgDesktop.HandleEvent(event);
}break;
如你所见,逻辑与我们的 GUI 相同,所以推理是清晰的。
最后,是渲染。在这里,同样,我们需要使用以下代码片段来完成 switch:
case StatusConfiguration:
{
_sfgDesktop.Update(0.0);
_sfgui.Display(_window);
_window.draw(_configurationMenu);
}break;
新的是 Update() 调用。这是用于动画的。由于在我们的案例中我们没有动画,我们可以将参数设置为 0。这是一个好的实践,将其添加到 Game::update() 函数中,但对于我们的需求来说是可以的——它也避免了变化。
现在你应该能够使用这个新的表单在配置菜单中使用了。
当然,在这个例子中,我只是向你展示了一小部分 SFGUI。它包含了许多更多功能,如果你感兴趣,我建议你查看库的文档和示例。这非常有趣。
摘要
恭喜你,你现在已经完成了这一章节,并且获得了以良好的方式与玩家沟通的能力。你现在能够创建一些按钮,使用标签,并为用户设置的事件触发器添加回调函数。你还了解了创建自己的 GUI 和使用 SFGUI 的基本知识。
在下一章中,我们将学习如何通过使用多个线程来充分利用 CPU 的强大功能,并了解它在游戏编程中的影响。
第六章. 使用多线程提升代码性能
在本章中,我们将学习以下技能:
-
如何并行运行程序中的多个部分
-
如何保护内存访问以避免数据竞争
-
如何将这些功能集成到 Gravitris 中
在本章结束时,你将能够通过以智能的方式暂停你的代码来利用计算机 CPU 提供的所有功能。但首先,让我们描述一下理论。
什么是多线程?
在计算机科学中,一个软件可以被看作是一个有起始点和退出点的流。每个软件都以 C/C++中的main()函数开始其生命周期。这是你程序的入口点。直到这一点,你可以做任何你想做的事情;包括创建新的例程流,克隆整个软件,并启动另一个程序。所有这些示例的共同点是都创建了一个新的流,并且它们有自己独立的生命周期,但它们并不等价。
fork()函数
这种功能相当简单。调用fork()将复制你的整个运行进程到一个新的进程中。新创建的进程与其父进程完全分离(新的 PID,新的内存区域作为其父进程的精确副本),并在fork()调用后立即开始。fork()函数的返回值是两次执行之间的唯一区别。
以下是一个fork()函数的示例:
int main()
{
int pid = fork();
if(pid == -1)
std::cerr<<"Error when calling fork()"<<std::endl;
else if (pid == 0)
std::cout<<"I'm the child process"<<std::endl;
else
std::cout<<"I'm the parent process"<<std::endl;
return 0;
}
如你所见,使用它非常简单,但也有一些使用上的限制。其中最重要的一个与内存共享有关。因为每个进程都有自己的内存区域,所以你无法在它们之间共享一些变量。一个解决方案是使用文件作为套接字、管道等。此外,如果父进程死亡,子进程仍将继续其自己的生命周期,而不会关注其父进程。
因此,这个解决方案只有在你不希望在不同的执行之间共享任何内容,甚至包括它们的状态时才有兴趣。
exec()族函数
exec()族函数(execl(), execlp(), execle(), execv(), execvp(), execvpe())将用另一个程序替换整个运行程序。当与fork()函数结合使用时,这些函数变得非常强大。以下是一个这些函数的示例:
int main()
{
int pid = fork();
if(pid == -1)
= std::cerr<<"Error when calling fork()"<<std::endl;
else if (pid == 0) {
std::cout<<"I'm the child process"<<std::endl;
}
else {
std::cout<<"I'm the parent process"<<std::endl;
execlp("Gravitris", "Gravitris", "arg 1", "arg 2",NULL);
std::cout<<"This message will never be print, except if execl() fail"<<std::endl;
}
return 0;
}
这个简短的小代码片段将创建两个不同的进程,如前所述。然后,子进程将被 Gravitris 的一个实例替换。由于exec()族函数中的任何调用都会用一个新的流替换整个运行流,所以exec调用下的所有代码都不会执行,除非发生错误。
线程功能
现在,我们将讨论线程。线程的功能与fork功能非常相似,但有一些重要的区别。一个线程将为你的运行进程创建一个新的流。它的起点是一个作为参数指定的函数。线程也将与其父进程在相同的环境中执行。主要影响是内存是相同的,但这不是唯一的一个。如果父进程死亡,所有它的线程也会死亡。
如果你不知道如何处理这些问题,这两个点可能会成为一个问题。让我们以并发内存访问为例。
假设你在程序中有一个名为var的全局变量。然后主进程将创建一个线程。这个线程将写入var,同时主进程也可以写入它。这将导致未定义的行为。有几种不同的解决方案可以避免这种行为,其中常见的一种是使用互斥锁来锁定对这个变量的访问。
简单来说,互斥锁是一个令牌。我们可以尝试获取(锁定)它或释放它(解锁)。如果有多个进程同时想要锁定它,第一个进程将有效地锁定它,第二个进程将等待第一个进程调用互斥锁的解锁函数。总结一下,如果你想通过多个线程访问共享变量,你必须为它创建一个互斥锁。然后,每次你想访问它时,锁定互斥锁,访问变量,最后解锁互斥锁。使用这种解决方案,你可以确保不会发生任何数据损坏。
第二个问题涉及到你的线程执行结束与主进程同步的问题。实际上,这个问题有一个简单的解决方案。在主流的末尾,你需要等待所有正在运行的线程结束。只要还有线程存活,流就会被阻塞,因此不会死亡。
下面是一个使用线程功能的示例:
#include <SFML/System.hpp>
static sf::Mutex mutex;
static int i = 0;
void f()
{
sf::Lock guard(mutex);
std::cout<<"Hello world"<<std::endl;
std::cout<<"The value of i is "<<(++i)<<" from f()"<<std::endl;
}
int main()
{
sf::Thread thread(f);
thread.launch();
mutex.lock();
std::cout<<"The value of i is "<<(++i)<<" from main"<<std::endl;
mutex.unlock();
thread.wait();
return 0;
}
既然理论已经解释了,让我们解释一下使用多线程的动机是什么。
为什么我们需要使用线程功能?
现在,一般的计算机都有一个能够同时处理多个线程的 CPU。大多数情况下,CPU 中有 4-12 个计算单元。这些单元中的每一个都能够独立于其他单元完成任务。
让我们假设你的 CPU 只有四个计算单元。
如果你以我们之前的游戏为例,所有的工作都是在单个线程中完成的。所以只有四个核心中的一个被使用。这是很遗憾的,因为所有的工作都是由一个组件完成的,而其他的组件则没有被使用。我们可以通过将代码分成几个部分来改进这一点。每个部分将在不同的线程中执行,工作将在它们之间共享。然后,不同的线程将在不同的核心上执行(在我们的例子中最多四个)。所以现在工作是在并行中完成的。
创建多个线程可以让您利用计算机提供的所有功能,让您有更多时间专注于某些功能,如人工智能。
另一种用法是当您使用一些阻塞函数时,例如等待网络消息、播放音乐等。这里的问题是运行中的进程将等待某事,无法继续执行。为了处理这个问题,您可以简单地创建一个线程并将任务委托给它。这正是 sf::Music 的工作方式。有一个内部线程用于播放音乐。这也是为什么我们在播放声音或音乐时游戏不会冻结的原因。每次为这个任务创建线程时,它对用户来说都是透明的。现在理论已经解释清楚,让我们将其应用于实践。
使用线程
在第四章中,我们介绍了物理到我们的游戏中。为了这个功能,我们创建了两个游戏循环:一个用于逻辑,另一个用于物理。到目前为止,物理循环和其他循环的执行是在同一个进程中进行的。现在,是时候将它们的执行分离到不同的线程中去了。
我们需要创建一个线程,并使用 Mutex 类来保护我们的变量。有两种选择:
-
使用标准库中的对象
-
使用 SFML 库中的对象
这里是一个总结所需功能和从标准 C++ 库到 SFML 转换的表格。
thread 类:
| 库 | 头文件 | 类 | 启动 | 等待 |
|---|---|---|---|---|
| C++ | <thread> |
std::thread |
构造后直接 | ::join() |
| SFML | <SFML/System.hpp> |
sf::Thread |
::launch() |
::wait() |
mutex 类:
| 库 | 头文件 | 类 | 锁定 | 解锁 |
|---|---|---|---|---|
| C++ | <mutex> |
std::mutex |
::lock() |
::unlock() |
| SFML | <SFML/System.hpp> |
sf::Mutex |
::lock() |
::unlock() |
还有一个可以使用的第三种类。它会在构造时自动调用 mutex::lock(),并在析构时调用 mutex::unlock(),遵循 RAII 习惯。这个类被称为锁或保护器。它的使用很简单,用 mutex 作为参数来构造它,它将自动锁定/解锁。以下表格解释了这个类的详细信息:
| 库 | 头文件 | 类 | 构造函数 |
|---|---|---|---|
| C++ | <mutex> |
std::lock_guard |
std::lock_guard(std::mutex&) |
| SFML | <SFML/System.hpp> |
sf::Lock |
sf::Lock(sf::Mutex&) |
如您所见,这两个库提供了相同的功能。thread 类的 API 有一些变化,但并不重要。
在这本书中,我将使用 SFML 库。选择这个库没有真正的理由,只是因为它让我能够向您展示更多 SFML 的可能性。
现在已经介绍了这个类,让我们回到之前的例子,并按照以下方式应用我们的新技能:
#include <SFML/System.hpp>
static sf::Mutex mutex;
static int i = 0;
void f()
{
sf::Lock guard(mutex);
std::cout<<"Hello world"<<std::endl;
std::cout<<"The value of i is "<<(++i)<<" from f()"<<std::endl;
}
int main()
{
sf::Thread thread(f);
thread.launch();
mutex.lock();
std::cout<<"The value of i is "<<(++i)<<" from main"<<std::endl;
mutex.unlock();
thread.wait();
return 0;
}
在这个简单的例子中,有几个部分。第一部分初始化全局变量。然后,我们创建一个名为f()的函数,它打印"Hello world",然后打印另一条消息。在main()函数中,我们创建一个与f()函数关联的线程,启动它,并打印i的值。每次,我们使用互斥锁(使用了两种不同的方法)来保护对共享变量i的访问。
来自f()函数的打印消息是不可预测的。它可能是"来自 f()的 i 的值是 1"或"来自 f()的 i 的值是 2"。我们无法确定f()或main()哪个先打印,因此不知道将打印的值。我们唯一确定的是,没有对i的并发访问,并且线程将在main()函数之前结束,这要归功于thread.wait()调用。
现在我们已经解释并展示了所需的类,让我们修改我们的游戏以使用它们。
将多线程添加到我们的游戏中
现在,我们将修改我们的 Gravitris 以使物理计算从程序的其他部分中瘫痪。我们只需要更改两个文件:Game.hpp和Game.cpp。
在头文件中,我们不仅需要添加所需的头文件,还需要更改update_physics()函数的原型,并最终给类添加一些属性。所以以下是需要遵循的不同步骤:
-
添加
#include <SFML/System.hpp>,这将允许我们访问所有需要的类。 -
然后,更改以下代码片段:
void updatePhysics(const sf::Time& deltaTime,const sf::Time& timePerFrame);to:
void updatePhysics();原因在于一个线程无法向其包装的函数传递任何参数,因此我们将使用另一种解决方案:成员变量。
-
将以下变量添加到
Game类中作为私有变量:sf::Thread _physicsThread; sf::Mutex _mutex; bool _isRunning; int _physicsFramePerSeconds;所有这些变量都将由物理线程使用,
_mutex变量将确保不会对这些变量之一进行并发访问。出于相同的原因,我们还需要保护对_world变量的访问。 -
现在头文件包含了所有要求,让我们转向实现部分。
-
首先,我们不仅需要更新构造函数以初始化
_physicsThread和_isRunning变量,还需要保护对_world的访问。Game::Game(int X, int Y,int word_x,int word_y) : ActionTarget(Configuration::player_inputs), _window(sf::VideoMode(X,Y),"06_Multithreading"), _current_piece(nullptr), _world(word_x,word_y), _mainMenu(_window),_configurationMenu(_window), _pauseMenu(_window), _status(Status::StatusMainMenu), _physicsThread(&Game::update_physics,this), _isRunning(true) { bind(Configuration::PlayerInputs::HardDrop,this{ sf::Lock lock(_mutex); _current_piece = _world.newPiece(); timeSinceLastFall = sf::Time::Zero; }); } -
在构造函数中,我们不仅需要初始化新的成员变量,还需要保护在其中一个回调中使用的
_world变量。这个锁非常重要,以确保在执行过程中不会随机发生数据竞争。 -
现在构造函数已经更新,我们需要更改
run()函数。目标是运行物理线程。需要做的更改不多。请自己看看:void Game::run(int minimum_frame_per_seconds, int physics_frame_per_seconds) { sf::Clock clock; const sf::Time timePerFrame = sf::seconds(1.f/minimum_frame_per_seconds); const sf::Time timePerFramePhysics = sf::seconds(1.f/physics_frame_per_seconds); _physics_frame_per_seconds = physics_frame_per_seconds; _physicsThread.launch(); while (_window.isOpen()) { sf::Time time = clock.restart(); processEvents(); if(_status == StatusGame and not _stats.isGameOver()){ updatePhysics(time,timePerFramePhysics); update(time,timePerFrame); } render(); } _isRunning = false; _physicsThread.wait(); } -
现在主游戏循环已经更新,我们需要在
update()方法中进行一个小改动以保护成员_world变量。void Game::update(const sf::Time& deltaTime,const sf::Time& timePerFrame) { static sf::Time timeSinceLastUpdate = sf::Time::Zero; timeSinceLastUpdate+=deltaTime; timeSinceLastFall+=deltaTime; if(timeSinceLastUpdate > timePerFrame) { sf::Lock lock(_mutex); if(_current_piece != nullptr) { _currentPiece->rotate(_rotateDirection*3000); _currentPiece->moveX(_moveDirection*5000); bool new_piece; { int old_level =_stats.getLevel(); _stats.addLines(_world.clearLines(new_piece,*_currentPiece)); if(_stats.getLevel() != old_level) _world.add(Configuration::Sounds::LevelUp); } if(new_piece or timeSinceLastFall.asSeconds() > std::max(1.0,10-_stats.getLevel()*0.2)) { _current_piece = _world.newPiece(); timeSinceLastFall = sf::Time::Zero; } } _world.update(timePerFrame); _stats.setGameOver(_world.isGameOver()); timeSinceLastUpdate = sf::Time::Zero; } _rotateDirection=0; _moveDirection=0; } -
如您所见,只有一处修改。我们只需要保护
_world变量的访问,仅此而已。现在,我们需要修改updatePhysics()函数。这个函数将会像以下代码片段所示进行大量修改:void Game::updatePhysics(const sf::Time& deltaTime,const sf::Time& timePerFrame) void Game::updatePhysics() { sf::Clock clock; const sf::Time timePerFrame = sf::seconds(1.f/_physics_frame_per_seconds); static sf::Time timeSinceLastUpdate = sf::Time::Zero; while (_isRunning) { sf::Lock lock(_mutex); timeSinceLastUpdate+=deltaTime; timeSinceLastUpdate+= clock.restart(); _world.updateGravity(_stats.getLevel()); while (timeSinceLastUpdate > timePerFrame) { if(_status == StatusGame and not _stats.isGameOver()) _world.update_physics(timePerFrame); timeSinceLastUpdate -= timePerFrame; } } }我们需要更改这个函数的签名,因为我们无法通过线程传递给它一些参数。因此,我们为这个函数添加了一个内部时钟,以及它自己的循环。函数的其余部分遵循在
update()方法中开发的逻辑。当然,我们也使用互斥锁来保护所有使用的变量的访问。现在,物理计算可以独立于游戏的其他部分进行更新。 -
现在其他使用
_world的函数,如initGame()和render(),需要做的小改动很少。每次,我们都需要使用互斥锁来锁定这个变量的访问。 -
关于
initGame()函数的修改如下:void Game::initGame() { sf::Lock lock(_mutex); timeSinceLastFall = sf::Time::Zero; _stats.reset(); _world.reset(); _current_piece = _world.newPiece(); } -
现在看看更新后的
render()函数:void Game::render() { _window.clear(); switch(_status) { case StatusMainMenu: { _window.draw(_mainMenu); }break; case StatusGame : { if(not _stats.isGameOver()) { sf::Lock lock(_mutex); _window.draw(_world); } _window.draw(_stats); }break; case StatusConfiguration: { _sfg_desktop.Update(0.0); _sfgui.Display(_window); _window.draw(_configurationMenu); }break; case StatusPaused : { if(not _stats.isGameOver()) { sf::Lock lock(_mutex); _window.draw(_world); } _window.draw(_pauseMenu); }break; default : break; } _window.display(); } -
如您所见,所做的更改非常简约,但这是为了避免任何竞态条件。
现在代码中的所有更改都已完成,您应该能够编译项目并测试它。图形结果将保持不变,但 CPU 不同核心的使用方式已经改变。现在,项目使用两个线程而不是一个。第一个线程用于物理计算,另一个线程用于游戏的其他部分。
摘要
在本章中,我们介绍了多线程的使用,并将其应用于现有的 Gravitris 项目中。我们学习了这样做的原因,不同的可能用途,以及共享变量的保护。
在我们的实际游戏中,多线程可能有些过度,但在更大型的游戏中,例如有数百玩家、网络和实时策略的情况下,它就变成了必须的。
在下一章中,我们将构建一个全新的游戏,并介绍新的内容,如等距视图、组件系统、路径查找等。
第七章。从零开始构建实时塔防游戏 - 第一部分
现在你已经拥有了所有基本工具,是时候我们来构建一些新的东西了。比如,一个结合了实时策略(RTS)和塔防的游戏怎么样?再考虑一下让它成为一个多人游戏?你喜欢这些想法吗?太好了!这正是我们将开始构建的内容。
由于这个项目比其他所有项目都要复杂得多,它将被分为两部分。第一部分将专注于游戏机制和逻辑,第二部分将专注于多人层。因此,在本章中,我们将做以下事情:
-
创建动画
-
构建并使用具有瓦片模型和动态加载的通用地图系统
-
构建实体系统
-
制定游戏逻辑
这个项目将重用之前制作的许多组件,例如ActionTarget、ResourceManager、我们的 GUI 和游戏循环。为了让你能够轻松地将这些组件用于未来的项目,它们已经被收集到一个单独的框架(SFML-utils)中,这个框架已经从本书的代码中分离出来。这个框架可以在 GitHub 网站上找到,网址为github.com/Krozark/SFML-utils,因此这些组件已经从本书的命名空间移动到了SFML-utils。此外,本章将解释的地图和实体系统也是这个框架的一部分。
本章的最终结果将如下所示:

游戏的目标
首先,让我们解释我们的目标。正如我们之前所说的,我们将构建一个新游戏,它将是一个实时策略游戏和塔防的结合。
想法是每个队伍开始时都有一笔钱/金币和一个名为 GQ 的主要建筑。当一个队伍的所有 GQ 都被摧毁时,它就输了游戏。这些钱可以用来建造具有不同能力的其他建筑,或者升级它们。例如,一些建筑将产生战士来攻击敌人;其他建筑只会防御周围区域。还有关于可以建造新建筑区域的限制。事实上,你只能在你的队伍现有建筑周围放置新建筑。这防止你在游戏开始时在敌人营地中央放置一个大塔。同样重要的是要注意,一旦建造了建筑,你就不能控制它的行为,就像你不能控制由它产生的不同战士的行为一样。
此外,每次摧毁一个敌人,你都会获得一些金币,这让你能够建造更多的塔,从而增强你击败敌人的能力。
现在游戏已经介绍完毕,让我们列出我们的需求:
-
资源和事件管理:这两个特性之前已经创建,所以我们将直接重用它们。
-
GUI:这个特性已经在第五章中开发完成,即“玩转用户界面”。我们将直接重用它。
-
动画:在 SFML 中,没有类来管理动画精灵,但对我们来说,我们需要这个功能。因此,我们将构建它并将其添加到我们的框架中。
-
瓦片地图:这个功能非常重要,必须尽可能灵活,以便我们可以在许多其他项目中重用它。
-
实体管理器:如果您还记得,这是在 第三章 中引入的,制作一个完整的 2D 游戏。现在是时候真正看到它了。这个系统将避免复杂的继承树。
如您所见,这个项目由于其复杂性,比之前的更具挑战性,但它也会更有趣。
构建动画
在我们之前的所有游戏中,屏幕上显示的所有不同实体都是静态的;至少它们没有动画。为了使游戏更具吸引力,最简单的事情就是给玩家添加一些动画和不同的实体。对我们来说,这将被应用于不同的建筑和战士。
由于我们使用基于精灵的游戏,而不是基于骨骼运动的实时动画,我们需要一些已经准备好的动画纹理。因此,我们的纹理将看起来如下所示:

注意
注意,绿色网格不是图像的一部分,这里仅为了信息展示;实际上背景是透明的。
这种类型的纹理被称为精灵图。在这个例子中,图像可以被分成两行四列。每一行代表一个移动方向,即左和右。这些行的每个单元格代表未来动画的一个步骤。
本部分工作的目标是能够使用这张图作为动画帧来显示精灵。
我们将遵循 SFML 的设计,构建两个类。第一个类将存储动画,第二个类将用于显示如 sf::Texture 和 sf::Sprite 的工作。这两个类被命名为 Animation 和 AnimatedSprite。
Animation 类
Animation 类只存储所有所需的数据,例如纹理和不同的帧。
由于这个类是一种资源,我们将通过我们的 ResourceManager 类来使用它。
这是类的头文件:
class Animation
{
public:
Animation(sf::Texture* texture=nullptr);
~Animation();
void setTexture(sf::Texture* texture);
sf::Texture* getTexture()const;
Animation& addFrame(const sf::IntRect& rect);
Animation& addFramesLine(int number_x,int number_y,int line);
Animation& addFramesColumn(int number_x,int number_y,int column);
size_t size()const;
const sf::IntRect& getRect(size_t index)const;
private:
friend class AnimatedSprite;
std::vector<sf::IntRect> _frames;
sf::Texture* _texture;
};
如您所见,这个类只是一个纹理和一些矩形的容器。为了简化这个类的使用,创建了一些辅助函数,即 addFramesLines() 和 addFramesColumn()。这些函数中的每一个都会向内部的 _frames 列表添加一个完整的行或列。这个类的实现也非常简单,如下所示:
Animation::Animation(sf::Texture* texture) : _texture(texture){}
Animation::~Animation(){}
void Animation::setTexture(sf::Texture* texture){ _texture =
texture;}
sf::Texture* Animation::getTexture() const {return _texture;}
size_t Animation::size() const {return _frames.size();}
const sf::IntRect& Animation::getRect(size_t index) const {return
_frames[index];}
Animation& Animation::addFrame(const sf::IntRect& rect)
{
_frames.emplace_back(rect);
return *this;
}
Animation& Animation::addFramesLine(int number_x,int number_y,int
line)
{
const sf::Vector2u size = _texture->getSize();
const float delta_x = size.x / float(number_x);
const float delta_y = size.y / float(number_y);
for(int i = 0;i<number_x;++i)
addFrame(sf::IntRect(i*delta_x,line*delta_y,delta_x,delta_y));
return *this;
}
Animation& Animation::addFramesColumn(int number_x,int
number_y,int column)
{
const sf::Vector2u size = _texture->getSize();
const float delta_x = size.x / float(number_x);
const float delta_y = size.y / float(number_y);
for(int i = 0;i<number_y;++i)
addFrame(sf::IntRect(column*delta_x,i*delta_y,delta_x,delta_y));
return *this;
}
三个 addFrameXXX() 函数允许我们向我们的动画添加帧。最后两个是添加整个行或列的快捷方式。其余的方法允许我们访问内部数据。
我们的帧容器不再需要其他东西。现在是时候构建 AnimatedSprite 类了。
AnimatedSprite 类
AnimatedSprite 类负责屏幕上显示的动画。因此,它将保留对 Animation 类的引用,并定期更改纹理的子矩形,就像 sf::Sprite。我们还将复制 sf::Music/sf::Sound API 中的播放/暂停/停止功能。AnimatedSprite 实例也应该能够在屏幕上显示并且可变换,因此该类将继承自 sf::Drawable 和 sf::Transformable。我们还将添加一个当动画完成时被触发的回调。这可能会很有趣。
标头如下所示:
class AnimatedSprite : public sf::Drawable, public sf::Transformable
{
public:
AnimatedSprite(const AnimatedSprite&) = default;
AnimatedSprite& operator=(const AnimatedSprite&) = default;
AnimatedSprite(AnimatedSprite&&) = default;
AnimatedSprite& operator=(AnimatedSprite&&) = default;
using FuncType = std::function<void()>;
static FuncType defaultFunc;
FuncType onFinished;
enum Status {Stopped,Paused,Playing};
AnimatedSprite(Animation* animation = nullptr,Status status= Playing,const sf::Time& deltaTime = sf::seconds(0.15),bool loop = true,int repeat=0);
void setAnimation(Animation* animation);
Animation* getAnimation()const;
void setFrameTime(sf::Time deltaTime);
sf::Time getFrameTime()const;
void setLoop(bool loop);
bool getLoop()const;
void setRepeat(int nb);
int getRepeat()const;
void play();
void pause();
void stop();
Status getStatus()const;
void setFrame(size_t index);
void setColor(const sf::Color& color);
void update(const sf::Time& deltaTime);
private:
Animation* _animation;
sf::Time _delta;
sf::Time _elapsed;
bool _loop;
int _repeat;
Status _status;
size_t _currentFrame;
sf::Vertex _vertices[4];
void setFrame(size_t index,bool resetTime);
virtual void draw(sf::RenderTarget& target,sf::RenderStates states) const override;
};
如您所见,此类比之前的大。其主要功能是存储一个代表从相关动画中取出的帧的四个顶点的数组。我们还需要一些其他信息,例如两个帧之间的时间,如果动画是循环的。这就是为什么我们需要这么多小函数的原因。现在,让我们看看所有这些是如何实现的:
AnimatedSprite::AnimatedSprite(Animation* animation,Status status,const sf::Time& deltaTime,bool loop,int repeat) : onFinished(defaultFunc),_delta(deltaTime),_loop(loop), _repeat(repeat),_status(status)
{
setAnimation(animation);
}
构造函数仅将所有不同的属性初始化为其正确的值:
void AnimatedSprite::setAnimation(Animation* animation)
{
if(_animation != animation){
_animation = animation;
_elapsed = sf::Time::Zero;
_currentFrame = 0;
setFrame(0,true);
}
}
此函数仅在当前纹理与新纹理不同时更改当前纹理,并将帧重置为新动画的第一个帧。请注意,新动画作为参数接收时至少必须存储一个帧。
Animation* AnimatedSprite::getAnimation()const {return _animation;}
void AnimatedSprite::setFrameTime(sf::Time deltaTime){_delta = deltaTime;}
sf::Time AnimatedSprite::getFrameTime()const {return _delta;}
void AnimatedSprite::setLoop(bool loop){_loop = loop;}
bool AnimatedSprite::getLoop()const { return _loop;}
void AnimatedSprite::setRepeate(int nb) {_repeat = nb;}
int AnimatedSprite::getRepeate()const{ return _repeat;}
void AnimatedSprite::play() {_status = Playing;}
void AnimatedSprite::pause() {_status = Paused;}
void AnimatedSprite::stop()
{
_status = Stopped;
_currentFrame = 0;
setFrame(0,true);
}
AnimatedSprite::Status AnimatedSprite::getStatus()const {return _status;}
所有这些函数都是简单的获取器和设置器。它们允许我们管理 AnimatedSprite 类的基本元素,如前一个代码片段所示。
void AnimatedSprite::setFrame(size_t index)
{
assert(_animation);
_currentFrame = index % _animation->size();
setFrame(_currentFrame,true);
}
此函数将当前帧更改为从内部 Animation 类中取出的新帧。
void AnimatedSprite::setColor(const sf::Color& color)
{
_vertices[0].color = color;
_vertices[1].color = color;
_vertices[2].color = color;
_vertices[3].color = color;
}
此函数更改显示图像的颜色遮罩。为此,我们将每个内部顶点的颜色设置为作为参数接收的新颜色:
void AnimatedSprite::update(const sf::Time& deltaTime)
{
if(_status == Playing and _animation)
{
_elapsed += deltaTime;
if(_elapsed > _delta)
{//need to change frame
_elapsed -= _delta;
if(_currentFrame + 1 < _animation->size())
++_currentFrame;
else
{//end of frame list
_currentFrame = 0;
if(not _loop)
{//can we make another loop an the frames?
--_repeat;
if(_repeat<=0)
{ //no, so we stop
_status = Stopped;
onFinished();
}
}
}
}
//update the frame
setFrame(_currentFrame,false);
}
}
此函数是主要的。其任务是当时间限制达到时,从当前帧更改为下一帧。一旦我们达到动画的最后一帧,你可以做以下操作:
-
根据
_loop值重置动画到第一个 -
如果
_repeat值允许,则从第一个动画重置动画 -
在所有其他情况下,我们通过调用内部回调触发“完成”事件
现在,看看更新帧皮肤的函数:
void AnimatedSprite::setFrame(size_t index,bool resetTime)
{
if(_animation)
{
sf::IntRect rect = _animation->getRect(index);
//update vertice position
_vertices[0].position = sf::Vector2f(0.f, 0.f);
_vertices[1].position = sf::Vector2f(0.f, static_cast<float>(rect.height));
_vertices[2].position = sf::Vector2f(static_cast<float>(rect.width), static_cast<float>(rect.height));
_vertices[3].position = sf::Vector2f(static_cast<float>(rect.width), 0.f);
//compute the texture coords
float left = static_cast<float>(rect.left);
float right = left + static_cast<float>(rect.width);
float top = static_cast<float>(rect.top);
float bottom = top + static_cast<float>(rect.height);
//set the texture coords
_vertices[0].texCoords = sf::Vector2f(left, top);
_vertices[1].texCoords = sf::Vector2f(left, bottom);
_vertices[2].texCoords = sf::Vector2f(right, bottom);
_vertices[3].texCoords = sf::Vector2f(right, top);
}
if(resetTime)
_elapsed = sf::Time::Zero;
}
此函数也是一个重要的函数。其目的是将不同顶点的属性更新为从内部 Animation 类中取出的属性,即位置和纹理坐标:
void AnimatedSprite::draw(sf::RenderTarget& target,sf::RenderStates states) const
{
if (_animation and _animation->_texture)è
{
states.transform *= getTransform();
states.texture = _animation->_texture;
target.draw(_vertices, 4, sf::Quads, states);
}
}
此类中的最终功能负责显示。因为我们从 sf::Transformable 继承,所以我们需要考虑可能的变换。然后,我们设置我们使用的纹理,最后绘制内部顶点数组。
使用示例
现在我们有了显示动画所需的类,让我们构建一个小型的使用示例。
现在,这里是实现:
int main(int argc,char* argv[])
{
//Creation of the window
sf::RenderWindow window(sf::VideoMode(600,800),"Example
animation");
//load of the texture image
ResourceManager<sf::Texture,int> textures;
textures.load(0,"media/img/eye.png");
//Creation of the different animations
Animation walkLeft(&textures.get(0));
walkLeft.addFramesLine(4,2,0);
Animation walkRight(&textures.get(0));
walkRight.addFramesLine(4,2,1);
//Creation of the animates sprite
AnimatedSprite sprite(&walkLeft,AnimatedSprite::Playing,sf::seconds(0.1));
//game loop
sf::Clock clock;
while (window.isOpen())
{
sf::Time delta = clock.restart();
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed) //close event
window.close();
}
float speed = 50; // the movement speed of the entity
if(sf::Keyboard::isKeyPressed(sf::Keyboard::Left)) //move left
{
sprite.setAnimation(&walkLeft);
sprite.play();
sprite.move(-speed*delta.asSeconds(),0);
}
else if(sf::Keyboard::isKeyPressed(sf::Keyboard::Right))
//move right
{
sprite.setAnimation(&walkRight);
sprite.play();
sprite.move(speed*delta.asSeconds(),0);
}
window.clear();
sprite.update(delta); //update the animate sprite for possible
frame change
window.draw(sprite); //display the animation
window.display();
}
return 0;
}
为了更好地理解这段代码,我在代码中添加了一些注释。
这个简短程序在屏幕上显示动画。您也可以通过使用键盘上的箭头移动它来改变其位置。动画也会根据移动方向而改变。
现在已经解释了本章的第一个要点,让我们继续第二个要点,构建地图。
构建通用的瓦片地图
对于我们的项目,我们需要一个可以管理地图的东西。实际上,地图不过是一个大网格。单元格可以是任何形状(方形、六边形等)。唯一的限制是,单个地图的所有单元格应该具有相同的几何形状。
此外,每个单元格可以包含多个对象,可能是不同类型的。例如,一个单元格可以包含一些用于地面的背景纹理、一棵树和一只鸟。由于 SFML 不使用精灵的z缓冲区(也称为深度缓冲区),我们需要手动模拟它。这被称为画家算法。其原理非常简单;按照深度顺序绘制所有内容,从最远的开始。这就是传统艺术画家作画的方式。
所有这些信息都带我们来到了以下结构:
-
Map类必须具有特定的几何形状,并且必须包含按其z缓冲区排序的任意数量的层。 -
Layer只包含特定类型。它也有一个z缓冲区,并存储一个按位置排序的内容列表。 -
CONTENT和GEOMETRY类是模板参数,但它们需要具有特定的 API。
这里是表示之前解释的结构类层次的流程图:

下面是流程图的说明:
-
CONTENT模板类可以是任何继承自sf::Drawable和sf::Transformable的类。 -
GEOMETRY类是一个新类,我们将在稍后了解它。它只定义了几何形状和一些用于操作坐标的帮助函数。 -
VLayer类定义了一个适用于所有不同类型层的通用类。 -
Layer类只是一个具有深度变量的特定类型容器,该变量定义了其在画家算法中的绘制顺序。 -
VMap类定义了整个地图的通用 API。它还包含一个VLayer列表,该列表使用画家算法显示。 -
Map类从VMap继承,并具有特定的几何形状。
等距六边形的几何类
对于我们的项目,我选择了以瓦片为六边形的等距视图。等距视图非常简单获得,但需要很好地理解。以下是我们需要遵循的步骤:
-
首先,从俯视图查看你的瓦片:
![等距六边形的几何类]()
-
然后,顺时针旋转 45 度:
![等距六边形的几何类]()
-
最后,将其高度除以 2:
![等距六边形的几何类]()
-
现在,你有一个很好的等距视图。现在,让我们看看六边形:
![等距六边形的几何类]()
如你所知,我们需要使用三角学计算每条边的坐标,特别是毕达哥拉斯定理。这是不考虑旋转和高度调整的情况。我们需要遵循两个步骤来找到正确的坐标:
-
从旋转的形状(添加 45 度)计算坐标。
-
将总高度值除以二。通过这样做,你最终将能够构建
sf::Shape:shape.setPointCount(6); shape.setPoint(0,sf::Vector2f(0,(sin_15+sin_75)/2)); shape.setPoint(1,sf::Vector2f(sin_15,sin_15/2)); shape.setPoint(2,sf::Vector2f(sin_15+sin_75,0)); shape.setPoint(3,sf::Vector2f(sin_15+sin_75+sin_45,sin_45/2)); shape.setPoint(4,sf::Vector2f(sin_75+sin_45,(sin_75+sin_45)/2)); shape.setPoint(5,sf::Vector2f(sin_45,(sin_15+sin_75+sin_45)/2)); shape.setOrigin(height/2,height/4); -
GEOMETRY类的大部分已经完成。剩下的是从世界坐标到像素坐标的转换,以及反向转换。如果你对此感兴趣,可以查看SFML-utils/src/SFML-utils/map/HexaIso.cpp文件中的类实现。
现在主几何已经定义,让我们在此基础上构建一个Tile<GEOMETRY>类。这个类将简单地封装由几何初始化的sf::Shape,并且具有不同的要求,以便能够使用COMPONENT参数为地图使用。由于这个类不是很重要,我将不会通过这本书来解释它,但你可以在SFML-utils/include/SFML-utils/map/Tile.tpl文件中查看它的实现。
VLayer 和 Layer 类
层的目标是管理同一深度的任意数量的组件。为此,每个层都包含其深度和组件容器。它还具有重新排序容器的能力,以尊重绘图算法。VLayer类是一个接口,它只定义了层的 API,允许地图存储任何类型的层,这得益于多态性。
这里是Layer类的标题:
template<typename CONTENT>
class Layer : public VLayer
{
public:
Layer(const Layer&) = delete;
Layer& operator=(const Layer&) = delete;
Layer(const std::string& type,int z=0,bool isStatic=false);
virtual ~Layer(){};
CONTENT* add(const CONTENT& content,bool resort=true);
std::list<CONTENT*> getByCoords(const sf::Vector2i& coords,const VMap& map);
bool remove(const CONTENT* content_ptr,bool resort=true);
virtual void sort() override;
private:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states,const sf::FloatRect& viewport) override;
std::list<CONTENT> _content;
};
如前所述,这个类不仅将存储其template类参数的容器,还将存储其深度(z)和一个包含在Vlayer类中的静态布尔成员,以优化显示。这个参数背后的想法是,如果层内的内容根本不移动,那么每次场景移动时就不需要重新绘制场景。结果存储在一个内部的sf::RenderTexture参数中,并且只有在场景移动时才会刷新。例如,地面永远不会移动,也没有动画。因此,我们可以将其显示在一个大纹理上,并在屏幕上显示这个纹理。这个纹理将在视图移动/调整大小时刷新。
为了进一步阐述这个想法,我们只需要显示屏幕上出现的内容。我们不需要绘制屏幕外的任何东西。这就是为什么我们有draw()方法的viewport属性。
所有其他函数管理层的内容。现在,让我们看看它的实现:
template<typename CONTENT>
Layer<CONTENT>::Layer(const std::string& type,int z,bool isStatic)
: Vlayer(type,z,isStatic) {}
template<typename CONTENT>
CONTENT* Layer<CONTENT>::add(const CONTENT& content,bool resort)
{
_content.emplace_back(content);
CONTENT* res = &_content.back();
if(resort)
sort();
return res;
}
这个函数向层添加新内容,如果需要则对其进行排序,并最终返回对新对象的引用:
template<typename CONTENT>
std::list<CONTENT*> Layer<CONTENT>::getByCoords(const sf::Vector2i& coords,const VMap& map)
{
std::list<CONTENT*> res;
const auto end = _content.end();
for(auto it = _content.begin();it != end;++it)
{
auto pos = it->getPosition();
sf::Vector2i c = map.mapPixelToCoords(pos.x,pos.y);
if(c == coords)
res.emplace_back(&(*it));
}
return res;
}
这个函数将所有不同的对象返回到同一个地方。这对于拾取对象很有用,例如,拾取光标下的对象:
template<typename CONTENT>
bool Layer<CONTENT>::remove(const CONTENT* content_ptr,bool resort)
{
auto it = std::find_if(_content.begin(),_content.end(),content_ptr->bool
{
return &content == content_ptr;
});
if(it != _content.end()) {
_content.erase(it);
if(resort)
sort();
return true;
}
return false;
}
这是add()函数的反函数。使用其地址,它从容器中移除一个组件:
template<typename CONTENT>
void Layer<CONTENT>::sort()
{
_content.sort([](const CONTENT& a,const CONTENT& b)->bool{
auto pos_a = a.getPosition();
auto pos_b = b.getPosition();
return (pos_a.y < pos_b.y) or (pos_a.y == pos_b.y and pos_a.x < pos_b.x);
});
}
}
此函数根据画家算法顺序对所有内容进行排序:
template<typename CONTENT>
void Layer<CONTENT>::draw(sf::RenderTarget& target, sf::RenderStates states,const sf::FloatRect& viewport)
{
if(_isStatic)
{//a static layer
if(_lastViewport != viewport)
{ //the view has change
sf::Vector2u size(viewport.width+0.5,viewport.height+0.5);
if(_renderTexture.getSize() != size)
{//the zoom has change
_renderTexture.create(size.x,size.y);
_sprite.setTexture(_renderTexture.getTexture(),true);
}
_renderTexture.setView(sf::View(viewport));
_renderTexture.clear();
auto end = _content.end();
for(auto it = _content.begin();it != end;++it)
{//loop on content
CONTENT& content = *it;
auto pos = content.getPosition();
if(viewport.contains(pos.x,pos.y))
{//content is visible on screen, so draw it
_renderTexture.draw(content);
}
}
_renderTexture.display();
_lastViewport = viewport;
_sprite.setPosition(viewport.left,viewport.top);
}
target.draw(_sprite,states);
}
else
{ //dynamic layer
auto end = _content.end();
for(auto it = _content.begin();it != end;++it)
{//loop on content
const CONTENT& content = *it;
auto pos = content.getPosition();
if(viewport.contains(pos.x,pos.y))
{//content is visible on screen, so draw it
target.draw(content,states);
}
}
}
由于一些优化,这个函数比我们预期的要复杂得多。让我们一步一步地解释它:
-
首先,我们区分两种情况。在静态地图的情况下,我们这样做:
-
检查视口是否已更改
-
如有必要,调整内部纹理的大小
-
重置纹理
-
-
将视口内的每个对象的位置绘制到
textureDisplay纹理中,作为RenderTarget参数。 -
如果层包含动态对象(非静态),则将视口内的每个对象的位置绘制到
RenderTarget参数的textureDisplay中。
正如你所见,draw()函数在动态内容的情况下使用了一种朴素算法,并优化了静态内容。为了给你一个概念,当有一层 10000 个对象时,帧率大约是 20。通过位置优化,它达到 400,通过静态优化,达到 2000。所以,我认为这个函数的复杂性是由巨大的性能收益所证明的。
现在已经向你介绍了layer类,让我们继续介绍map类。
VMap 和 Map 类
地图是VLayer的容器。它将实现常用的add()/remove()函数。此类还可以从文件(在动态板加载部分描述)中构建并处理单位转换(坐标到像素和反之亦然)。
内部,VMap类存储有如下层:
std::vector<VLayer*> _layers;
这个类中只有两个有趣的功能。其他的是简单的快捷方式,所以我不打算解释整个类。让我们看看相关的函数:
void VMap::sortLayers()
{
std::sort(_layers.begin(),_layers.end(),[](const VLayer* a, const VLayer* b)->bool{
return a->z() < b->z();
});
const size_t size = _layers.size();
for(size_t i=0;i<size;++i)
_layers[i]->sort();
}
此函数根据画家算法对不同的层进行排序。实际上,这个函数很简单但非常重要。每次向地图添加一个层时,我们都需要调用它。
void VMap::draw(sf::RenderTarget& target, sf::RenderStates states,const sf::FloatRect& viewport) const
{
sf::FloatRect delta_viewport(viewport.left - _tile_size,
viewport.top - _tile_size,
viewport.width + _tile_size*2,
viewport.height + _tile_size*2);
const size_t size = _layers.size();
for(size_t i=0;i<size;++i)
_layers[i]->draw(target,states,delta_viewport);
}
函数通过调用其绘制方法来绘制每个层;但首先,我们在其每个边框上添加一个小增量来调整屏幕视口。这样做是为了显示屏幕上出现的所有瓦片,即使它们只部分显示(当其位置在屏幕外时)。
动态板加载
现在地图结构已经完成,我们需要一种加载它的方法。为此,我选择了JSON格式。选择这个格式的有两个原因:
-
可以被人类阅读
-
格式不是冗长的,所以即使是对于大地图,最终的文件也相当小。
我们需要一些信息来构建地图。这包括以下内容:
-
地图的几何形状
-
每个瓦片(单元格)的大小
-
按照以下方式定义层:
-
z缓冲区 -
它是静态的还是动态的
-
内容类型
-
根据层的内 容类型,可能需要指定一些其他信息来构建此内容。最常见的情况如下:
-
纹理
-
坐标
-
大小
因此,JSON文件将如下所示:
{
"geometry" : {
"name" :"HexaIso", "size" : 50.0
},
"layers" : [{
"content" : "tile", "z" : 1, "static" : true,
"data" : [{"img" :"media/img/ground.png", "x" : 0, "y" : 0, "width" : 100, "height" : 100}]
},{
"content" : "sprite", "z" : 3,
"data" : [
{"x" : 44, "y" : 49, "img" : "media/img/tree/bush4.png"},
{"x" : 7, "y" : 91, "img" : "media/img/tree/tree3.png"},
{"x" : 65, "y" : 58, "img" : "media/img/tree/tree1.png"}
]
}]
}
如你所见,不同的数据集存在以创建具有等距六边形几何形状的地图,并具有两层。第一层包含带有地面纹理的网格,第二层包含一些用于装饰的精灵。
要使用此文件,我们需要一个 JSON 解析器。你可以使用任何现有的,构建自己的,或者使用本项目构建的。接下来,我们需要一种从文件创建整个地图或从文件更新其内容的方法。在第二种情况下,几何形状将被忽略,因为我们不能在运行时更改模板的值。
因此,我们将向 VMap 类添加一个静态方法来创建一个新的 Map,并添加另一个方法来更新其内容。签名如下:
static VMap* createMapFromFile(const std::string& filename);
virtual void loadFromJson(const utils::json::Object& root) = 0;
由于 Tile 类需要的 GEOMETRY 参数,loadFromJson() 函数必须是虚拟的,并在 Map 类中实现。createMapFromFile() 函数将用于国际使用。让我们看看它的实现:
VMap* VMap::createMapFromFile(const std::string& filename)
{
VMap* res = nullptr;
utils::json::Value* value = utils::json::Driver::parse_file(filename);
if(value)
{
utils::json::Object& root = *value;
utils::json::Object& geometry = root["geometry"];
std::string geometry_name = geometry["name"].as_string();
float size = geometry["size"].as_float();
if(geometry_name == "HexaIso")
{
res = new Map<geometry::HexaIso>(size);
res->loadFromJson(root);
}
delete value;
}
return res;
}
这个函数的目标非常简单;根据几何参数构建适当的地图,并将剩余的工作传递出去。
void Map<GEOMETRY>::loadFromJson(const utils::json::Object& root)
{
const utils::json::Array& layers = root["layers"];
for(const utils::json::Value& value : layers) //loop through the
rs
{
const utils::json::Object& layer = value;
std::string content = layer["content"].as_string(); //get the content type
int z = 0; //default value
try{
z = layer["z"].as_int(); //load value
} catch(...){}
bool isStatic = false; //default value
try {
isStatic = layer["static"].as_bool(); //load value
}catch(...){}
if(content == "tile") //is a layer or tile?
{
auto current_layer = new Layer<Tile<GEOMETRY>>(content,z,isStatic); //create the layer
const utils::json::Array& textures = layer["data"];
for(const utils::json::Object& texture : textures) //loop through the textures
{
int tex_x = texture["x"]; //get the tile position
int tex_y = texture["y"];
int height = std::max<int>(0,texture["height"].as_int()); //get the square size
int width = std::max<int>(0,texture["width"].as_int());
std::string img = texture["img"]; //get texture path
sf::Texture& tex = _textures.getOrLoad(img,img); //load the texture
tex.setRepeated(true);
for(int y=tex_y;y< tex_y + height;++y)//create the tiles
{
for(int x=tex_x;x<tex_x + width;++x)
{
Tile<GEOMETRY> tile(x,y,_tileSize);
tile.setTexture(&tex);
tile.setTextureRect(GEOMETRY::getTextureRect(x,y,_tileSize));
current_layer->add(std::move(tile),false);//add the new tile to the layer
}
}
}
add(current_layer,false);//if it's a layer of images
}
else if(content == "sprite")
{
auto current_layer = new Layer<sf::Sprite>(content,z,isStatic);//create the layer
const utils::json::Array& data = layer["data"].as_array();//loop on data
for(const utils::json::Value& value : data)
{
const utils::json::Object& obj = value;
int x = obj["x"];//get the position
int y = obj["y"];
float ox = 0.5;//default center value (bottom center)
float oy = 1;
try{//get value
ox = obj["ox"].as_float();
}catch(...){}
try{
oy = obj["oy"].as_float();
}catch(...){}
std::string img = obj["img"];//get texture path
sf::Sprite spr(_textures.getOrLoad(img,img));//load texture
spr.setPosition(GEOMETRY::mapCoordsToPixel(x,y,_tileSize));
sf::FloatRect rec = spr.getLocalBounds();
spr.setOrigin(rec.width*ox,rec.height*oy);
current_layer->add(std::move(spr),false);//add the sprite
}
add(current_layer,false); //add the new layer to the map
}
}
sortLayers(); //finally sort the layers (recuively)
}
为了更好地理解,前面的函数是用原始注释解释的。它的目的是构建层并将从 JSON 文件中挑选的数据填充到层中。
现在我们能够从文件构建地图并填充其内容,我们需要做的最后一件事是将它显示在屏幕上。这将通过 MapViewer 类来完成。
MapViewer 类
这个类封装了一个 Map 类,并管理一些事件,如鼠标移动、移动视图、缩放等。这是一个非常简单的类,没有新内容。这就是为什么我不会详细介绍任何内容,除了 draw() 方法(因为视口)。如果你对完整的实现感兴趣,请查看 SFML-utils/src/SFML-utils/map/MapViewer.cpp 文件。
所以这里是 draw 方法:
void MapViewer::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
sf::View view = target.getView();
target.setView(_view);
_map.draw(target,states,sf::FloatRect(target.mapPixelToCoords(sf::Vector2i(0,0),_view),_view.getSize());
target.setView(view);
}
如同往常,我们接收 sf::RenderTarget 和 sf::RenderStates 作为参数。然而,在这里我们不想与目标当前视图交互,因此我们备份了它并将我们的本地视图附加到渲染的目标上。然后,我们调用内部地图的 draw 方法,传递目标和状态,但添加了视口。这个参数非常重要,因为它被我们的层用于优化。因此,我们需要构建一个与渲染目标大小相同的视口,多亏了 SFML,这非常简单。我们将左上角坐标转换为相对于我们视图的世界坐标。结果是显示区域左上角的坐标。现在,我们只需要大小。在这里,SFML 也提供了我们所需的一切:sf::View::getSize()。有了这些信息,我们现在能够构建正确的视口并将其传递给地图 draw() 函数。
一旦渲染完成,我们将初始视图恢复到渲染目标。
使用示例
现在我们已经具备了加载和显示地图到屏幕上的所有要求。以下代码片段显示了最小步骤:
int main(int argc,char* argv[])
{
sf::RenderWindow window(sf::VideoMode(1600,900),"Example Tile");
sfutils::VMap* map = sfutils::VMap::createMapFromFile("./map.json");
sfutils::MapViewer viewer(window,*map);
sf::Clock clock;
while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed) // Close window : exit
window.close();
}
window.clear();
viewer.processEvents();
viewer.update(clock.restart().asSeconds());
viewer.draw();
window.display();
}
return 0;
}
该函数的不同步骤如下:
-
创建窗口
-
从文件创建映射
-
处理事件并在请求时退出
-
更新查看器
-
在屏幕上显示查看器
结果将如下所示:

现在我们已经完成了映射,我们需要用一些实体填充它。
构建实体系统
首先是什么是实体系统?
实体系统是一种关注数据的设计模式。它不是创建所有可能实体的复杂层次树,而是构建一个系统,允许我们在运行时向实体添加组件。这些组件可以是任何东西,如生命值、人工智能、皮肤、武器,以及除了数据之外的一切。
然而,如果实体和组件都不包含功能,它们存储在哪里呢?答案是系统。每个系统管理至少一个组件,所有逻辑都包含在这些系统中。此外,无法直接构建实体。您必须使用实体管理器创建或更新它。它将负责一组实体,管理它们的组件、创建和销毁。
结构由以下图表表示:

实现这种结构有许多方法。我的选择是使用模板和多态。
实体系统的使用
不深入内部结构,我们创建一个新的组件,将其作为结构,没有方法,除了构造函数/析构函数,并从Component继承如下:
struct CompHp : Component<CompHp>
{
explicit Hp(int hp) : _hp(hp){};
int _hp;
};
继承对于在所有组件之间有一个公共基类很重要。同样的想法用于创建System:
struct SysHp : sfutils::System<SysHp>
{
virtual void update(sfutils::EntityManager& manager,const sf::Time& dt) override;
};
继承的原因是为了有一个公共父类和 API(update函数)。最后,要创建实体,您必须执行以下操作:
EntityManager entities;
std::uint32_t id = entities.create();
entities.addComponent<CompHp>(id,42); //the first argument is always the entity id
如果我们继续这个例子,当一个实体没有hp时,我们必须将其从板上删除。这部分逻辑是在SysHp::update()函数中实现的:
void SysHp::update(sfutils::EntityManager& manager,const sf::Time& dt)
{
CompHp::Handle hp; //Handler is a kind of smart pointer which ensure access to valid data
auto view = manager.getByComponents(hp); //this object is a filter on all our entities by there components
auto end = view.end();
for(auto current = view.begin(); current != end;++current)
{
if(hp->_hp <= 0)
manager.remove(current->id());
}
}
这个SysHp::update()函数用于创建特定的功能。其目的是删除所有hp值小于或等于零的实体。为此,我们使用CompHp::Handle快捷方式(在Component类中定义)初始化ComponentHandler<CompHp>。然后我们在世界上创建我们的查询。在我们的例子中,我们需要获取所有带有CompHp的实体。对于更复杂的系统,也可以进行多标准查询。
一旦我们有了我们的视图,我们就迭代它。每次迭代都给我们访问Entity并更新处理程序值的权限到实体组件。因此,创建对hp处理程序的访问相当于以下:
manager.getComponent<CompHp>(current->id())
然后,我们检查_hp值,并在需要时删除实体。
重要的是要注意,实体实际上只有在调用EntityManager::update()函数以保持系统循环内部数据一致性时才会被删除。
现在由于SysHp参数已经完成,我们需要将其注册到与EntityManager相连的SystemManager:
EntityManager entities;
SystemManager systems(entities);
systems.add<SysHp>();
我们现在已经构建了一个实体管理器、一个组件、一个系统和一个实体。将它们全部组合在一起将得到以下代码:
int main()
{
EntityManager entities;
SystemManager systems(entities);
systems.add<SysHp>();
for(int i =0; i<10; ++i)
{//create entities
std::uint32_t id = entities.create();
entities.addComponent<CompHp>(id,i*10);
}
sf::Clock clock;
while(/* some criterion*/)
{//game loop
systems.updateAll(clock.restart());
entities.update();
}
return 0;
}
这段小代码将创建一个实体和系统管理器。然后,我们创建 10 个实体并将它们添加到CompHp组件中。最后,我们进入游戏循环。
如前所述,不要详细说明实体系统的实现;关注其使用。如果你对实现感兴趣,它有点复杂,请查看SFML-utils/include/SFML-utils/es目录下的文件。这是一个仅包含头文件的库。
实体系统方法的优点
使用组件系统,每个实体都表示为一个唯一的整数(其 ID)。这些组件不过是数据。因此,创建一个保存整个世界的序列化函数实际上非常简单。使用这种方法,数据库保存变得非常简单,但这不是唯一的好处。
要使用经典分层树创建一个飞行汽车,你必须从两个不同的类中继承它,即汽车和飞行器。这些类中的每一个都可以从另一个继承。事实上,当实体数量变得很大时,分层树就太多了。对于相同的例子,使用实体系统创建一个实体,将其附加到一些轮子和翅膀上。就这样!我同意创建实体系统可能很困难,但它的使用大大简化了游戏的复杂性。
构建游戏逻辑
我们现在已经拥有了启动游戏的所有要求:资源管理、事件管理、GUI、动画、地图和实体系统。是时候将它们组合成一个单一的项目了。
首先,我们需要创建我们的实体。多亏了之前描述的实体系统,我们只需要构建一些组件及其系统。我们可以构建很多,但项目的主要组件如下:
| 组件 | 实体 |
|---|---|
| 皮肤 | 动画 |
| 健康点 | 当前健康值 |
| 最大健康值 | |
| 队伍 | 队伍的标识符 |
| 构建区域 | 实体周围的授权范围 |
| 移动 | 速度 |
| 目标 | |
| 战士的人工智能 | Δ时间 |
| 伤害 | |
| 击中长度 |
最有趣的是人工智能(用于伤害)和移动。其他的是相当简单的。当然,你可以创建自己的组件,作为那些提议的补充或替代。
构建我们的组件
我们知道我们的组件需要所有数据,因此让我们构建两个有趣的组件,即walker AI和warrior AI:
struct CompAIWalker : Component<CompAIWalker>
{
explicit CompAIWalker(float speed);
const float _speed;
sf::Vector2i _pathToTake;
};
此组件处理速度和目的地。目的地可以通过任何东西更新(例如,当检测到近距离的敌人时):
struct CompAIWarrior : Component<CompAIWarrior>
{
explicit CompAIWarrior(int hitPoint,const sf::Time& timeDelta,int range);
const int _hitPoint;
const sf::Time _delta;
sf::Time _elapsed;
const int _range;
};
此组件存储实体的攻击性,包括其受损情况、攻击速度和攻击范围。
由于我们将在系统部分使用此组件,我还会解释CompSkin组件。此组件存储一个AnimatedSkin和可以应用于它的不同可能的Animation:
struct CompSkin : sfutils::Component<CompSkin,Entity>
{
enum AnimationId : int{ Stand,Spawn, MoveLeft, MoveRight, HitLeft, HitRight};
sfutils::AnimatedSprite _sprite;
std::unordered_map<int,sfutils::Animation*> _animations;
};
现在组件已经构建完成,让我们看看系统。
创建不同的系统
我们需要的系统数量与组件数量相同。皮肤系统简单地调用动画的更新函数。我们已经为健康构建了相关的系统。对于团队组件,我们不需要任何系统,因为这个组件仅由人工智能使用。剩下的两个系统更复杂。
让我们从移动开始:
struct SysAIWalker : sfutils::System<SysAIWalker,Entity>
{
explicit SysAIWalker(Level& level);
virtual void update(sfutils::EntityManager<Entity>& manager,const sf::Time& dt) override;
Level& _level;
};
注意,Level类尚未介绍。此类将EntityManager和SystemManager类分组,并为我们提供访问有关地图几何形状的一些函数,而无需了解它。我稍后会解释它。在我们的情况下,我们需要有关组件实际位置与其目的地之间距离的一些信息。这就是为什么我们需要保留对等级的引用。
这里是步行者系统的实现:
SysAIWalker::SysAIWalker(Level& level) :_level(level) {}
void SysAIWalker::update(EntityManager& manager,const sf::Time&
dt)
{
CompAIWalker::Handle AI;
CompSkin::Handle skin;
auto view = manager.getByComponents(AI,skin);
auto end = view.end();
const float seconds = dt.asSeconds();
for(auto begin = view.begin();begin != end;++begin)
{
sf::Vector2f PosCurrent = skin->_sprite.getPosition();
sf::Vector2i CoordCurrent =
_level.mapPixelToCoords(PosCurrent);
sf::Vector2i CoordDest = AI->_pathToTake;
if(CoordDest != CoordCurrent) //need to move
{
sf::Vector2f PosDest = _level.mapCoordsToPixel(CoordDest);
//calculation of the direction to take
sf::Vector2f directon = PosDest - PosCurrent;
//calculation of the distance
const float distance =
std::sqrt((directon.x*directon.x)+(directon.y*directon.y));
const float frameDistance = AI->_speed * seconds;
if(distance > frameDistance)
skin->_sprite.setPosition(PosCurrent +
directon*(frameDistance/distance));
else
{
skin->_sprite.setPosition(PosDest);
AI->_pathToTake = CoordCurrent;
}
if(directon.x >0) //update skin direction
skin->_sprite.setAnimation(skin-
>_animations.at(CompSkin::MoveRight));
else
skin->_sprite.setAnimation(skin-
>_animations.at(CompSkin::MoveLeft));
}
}
}
此系统不仅移动实体,还执行不同的操作。位置存储在CompSkin组件中,因此我们需要通过获取附加到实体上的CompAIWalker和CompSkin组件来迭代实体。然后,我们计算实体在世界坐标系中的位置并检查是否需要移动。如果我们需要移动,我们计算对应于总位移(方向)的向量。这个向量告诉我们实体需要跟随的方向。然后,我们计算终点和当前位置之间的距离。根据速度,我们将当前位置更改为新位置。
一旦移动完成,我们还将当前动画更改为与实体采取的移动方向相匹配的动画。
现在,让我们关注一下战士 AI:
SysAIWarrior::SysAIWarrior(Level& level) : _level(level){}
void SysAIWarrior::update(sfutils::EntityManager<Entity>& manager,const sf::Time& dt)
{
CompAIWarrior::Handle AI;
CompTeam::Handle team;
CompSkin::Handle skin;
auto view = manager.getByComponents(AI,team,skin);
auto end = view.end();
for(auto begin = view.begin();begin != end;++begin)
{
AI->_elapsed += dt;
std::vector<Team*> teamEnemies = team->_team->getEnemies();
//if no enemies
if(teamEnemies.size() <=0)
continue;
std::uint32_t id = std::uint32_t(-1);
/* ….set id to the nearest enemy ... */
if(not manager.isValid(id))
continue;
//update path
Entity& enemy = manager.get(id);
const sf::Vector2f pos = enemy.component<CompSkin>()->_sprite.getPosition();
const sf::Vector2i coord = _level.mapPixelToCoords(pos);
const int distance = _level.getDistance(myPosition,coord);
if(distance <= range) //next me
{
//shoot it
if(AI->_elapsed >= AI->_delta)
{
AI->_elapsed = sf::Time::Zero;
CompHp::Handle hp = enemy.component<CompHp>();
hp->_hp -= AI->_hitPoint;
Entity& me = **begin;
if(enemy.onHitted != nullptr)
enemy.onHitted(enemy,coord,me,myPosition,_level);
if(me.onHit != nullptr)
me.onHit(me,myPosition,enemy,coord,_level);
//win some gold
if(hp->_hp <=0){
team->_team->addGold(hp->_maxHp/50);
}
}
//no need to move more
if(begin->has<CompAIWalker>())
begin->component<CompAIWalker>()->_pathToTake = myPosition;
}
else
{//too far
sf::Vector2i path = _level.getPath1(myPosition,coord);
//move closer
if(begin->has<CompAIWalker>())
begin->component<CompAIWalker>()->_pathToTake = path;
}
}
此系统需要三个组件,即CompSkin(用于位置)、CompTeam(用于检测敌人)和CompAIWarrior。首先,我们需要更新 delta 时间。然后,我们检查是否有敌人需要击败。接下来,我们寻找一个更近的敌人(我不会详细说明这部分,因为你可以使用自己的算法)。如果找到敌人,我们检查我们与敌人之间的距离。如果我们能够射击敌人,我们就这样做,并将 delta 时间重置以避免每帧都击中。我们还触发一些事件(例如,创建声音)并在我们刚刚击败敌人时为团队添加金币。如果可能,我们还将CompAIWarrior的目的地设置为当前位置(以保持战斗状态),或者移动到下一个敌人更近的位置。
我们现在已经拥有了所有组件和系统来管理它们。因此,我们将继续进行游戏架构。
等级类
如往常一样,我们将游戏分成几个部分。level类代表一个地图。这个类存储了所有实体、系统、查看器、地图、声音等。正如之前解释的,它还在地图几何之上实现了一个抽象层。
实际上,一个关卡是一个非常简单的对象;它只是连接其他对象的粘合剂。它注册所有系统,构建地图,初始化一个MapViewer,事件,并将所有不同的更新调用组合到一个方法中。这个类还允许用户通过创建内部的EntityManager来创建新的实体,并将它们添加到地图层中。在这个过程中,地图始终与EntityManager保持同步。
如果你对这个实现感兴趣,请查看SFML-book/07_2D_iso_game/src/SFML-Book/Level.cpp文件。
游戏类
现在,让我们看看game类!你现在应该熟悉这个类。它的全局行为没有改变,仍然包含相同的函数(update()、processEvents()和render())。
这里的重大变化是,游戏类将初始化一个Level和Team。其中之一将是玩家控制的,GUI 依赖于它。这也是为什么这个项目的 GUI 被附加到一个团队而不是整个游戏的原因。我不会说这是最好的方法,但这是最简单的方法,并允许我们从一个团队跳到另一个团队。
如果你对这个实现感兴趣,请查看SFML-book/07_2D_iso_game/src/SFML-Book/Game.cpp文件。
团队 GUI 类
这个类处理不同的信息,是游戏和玩家之间的接口。它应该允许玩家构建一些实体并与它们交互。
以下屏幕显示了构建菜单。此菜单向玩家展示了可以创建的不同实体以及当前的金币数量:

当然,我们可以进一步完善这个菜单,但这是我们游戏所需的最基本信息。使用我们之前制作的 GUI 将大大简化这项任务。
一旦选择了一个实体,我们只需将其放置到游戏中,同时考虑到以下标准:
-
金币数量
-
构建区域
在此之后,一切都将顺利运行。不要犹豫,创建一些辅助函数,通过添加具有特定值的组件来创建不同的实体。
摘要
在本章中,我们介绍了不同的事情,例如创建动画。这个类使我们能够在屏幕上显示动画角色。然后,我们构建了一个Map类,其中包含了一些实体。我们还学习了如何通过创建一些组件和系统来使用实体系统构建我们的游戏逻辑。最后,我们将所有积累的知识结合起来,构建了一个包含一些人工智能、用户界面、声音和动画的完整游戏。
拥有所有这些知识,你现在可以轻松地基于拼图系统构建任何类型的游戏。
在下一章中,我们将通过使用网络将这个游戏转变为多人游戏。
第八章. 从零开始构建实时塔防游戏 - 第二部分,网络
在上一章中,我们从零开始构建了一个完整的游戏。我们遇到的唯一限制是没有真正的敌人可以击败。在本章中,我们将通过添加网络到我们的游戏中来解决这个问题,使其能够与除你之外的其他玩家交互。在本章结束时,你将能够与一些朋友一起玩这个游戏。本章将涵盖以下主题:
-
网络架构
-
使用套接字进行网络通信
-
创建通信协议
-
通过应用客户端-服务器概念修改我们的游戏
-
保存和加载我们的游戏
现在,让我们深入探讨这个相当复杂的章节。
网络架构
在构建我们的架构之前,我们需要了解一些关于在游戏中常用哪些网络架构以及它们的具体特点的信息。在游戏编程中使用了不同类型的架构。它们在很大程度上取决于游戏和开发者的需求。我们将看到两种常见的架构:对等网络(P2P)和客户端-服务器。它们各自都有优势和劣势。让我们分别分析它们。
对等网络架构
这种架构在过去被广泛使用,至今仍在使用。在这种架构中,玩家知道彼此的地址,并直接相互通信,无需任何中介。例如,对于一个有四个不同玩家的游戏,网络可以表示为以下图表:

这种组织方式允许玩家直接与任何或所有其他玩家互动。当客户端执行某个动作时,它会通知其他玩家这个动作,然后他们相应地更新模拟(游戏)。
这种方法在通信方面效率很高,但也有一些不能忽视的限制。主要的一个是,无法避免作弊。客户端可以通过通知其他玩家该动作来执行任何它想做的事情,即使这是不可能的,比如通过发送任意位置来传送自己。可能的结果是,其他玩家的游戏乐趣完全被破坏。
为了避免这种作弊行为,我们必须改变架构,以便能够有一个可以决定一个动作是否合法的裁判。
客户端-服务器架构
在游戏编程中,避免作弊非常重要,因为它可以完全破坏玩家的游戏体验。为了能够减少作弊的可能性,所使用的架构可以提供帮助。使用客户端-服务器架构,游戏可以检测到这些漏洞的大部分。这是证明这一部分重要性的一个原因。另一个观点是,这是我们游戏将使用的架构。玩家之间不会相互通信,他们只会与一个称为服务器的单一主机通信。因为所有其他玩家也会这样做,我们将能够与他们通信,但有一个中介。
此外,这个中介将充当法官,将决定一个动作是否合法。而不是在所有不同玩家的电脑上进行全面模拟,真正的模拟由服务器完成。它持有必须考虑的真实游戏状态;客户端只是我们可以与之交互的一种显示。以下图表表示了架构:

如您所见,我们现在需要通过服务器来传播任何类型的动作给其他玩家。
它的主要缺点是服务器必须对所有玩家(客户端)做出反应,如果你的游戏有大量玩家,这可能会变得很困难。将任务分配到不同的线程对于确保服务器的反应性非常重要。
有些游戏需要的资源太多,以至于它只能处理有限数量的玩家,结果是,你必须为一场游戏管理多个服务器;例如,一个用于登录,另一个用于聊天,另一个用于地图的特定区域,等等。我们现在将看看如何使用这种架构来构建我们的游戏。
在创建多人架构时,首先要考虑的是,我们必须将我们的游戏分成两个不同的程序:一个客户端和一个服务器。我们将有一个服务器托管多个游戏实例和任意数量的客户端,可能在不同场比赛中。
为了能够得到这种结果,我们首先考虑每个部分需要什么。
客户端
每个玩家必须启动一个客户端程序才能开始一场比赛。这个程序必须执行以下操作:
-
显示游戏状态
-
处理不同的用户输入
-
播放效果(声音、血腥场面等)
-
根据从服务器接收到的信息更新其游戏状态
-
向服务器发送请求(构建、销毁)
这些不同的功能已经存在于我们的实际游戏中,因此我们需要适应它们;但也有一些新功能:
-
请求创建一个新的比赛
-
请求加入比赛
我在这里使用“请求”这个词,因为它确实如此。作为一个玩家不会完全处理游戏,它只能向服务器发送请求以采取行动。然后服务器将判断它们并做出反应。现在让我们来看看服务器。
服务器
另一方面,服务器只需要启动一次,并需要管理以下功能:
-
存储所有不同的比赛
-
处理每个游戏的步骤
-
向玩家发送游戏更新
-
处理玩家请求
但服务器还必须注意以下事项:
-
管理连接/断开
-
游戏创建
-
将玩家添加为团队的控制者
如你所见,不需要任何类型的显示,因此服务器输出将仅在控制台。它还必须判断来自客户端的所有不同请求。在分布式环境中,对于 Web 开发也是如此,请记住这个规则:不要信任用户输入。
如果你记住这一点,它将为你节省很多麻烦和调试时间。一些用户,即使是非常少数的用户,也可能发送随机数据,如作弊或其他你不应该接收的内容。所以不要直接接受输入。
现在功能已经暴露出来,我们需要一种在客户端和服务器之间进行通信的方式。这是我们接下来要讨论的主题。
使用套接字进行网络通信
为了能够与其他玩家互动,我们需要一种与他们通信的方法,无论使用的是哪种架构。为了能够与任何计算机通信,我们必须使用套接字。简而言之,套接字通过网络与其他进程/计算机进行通信,只要双方之间存在现有的连接方式(局域网或互联网)。套接字主要有两种类型:非连接(UDP)或连接(TCP)。这两种都需要 IP 地址和端口号才能与目的地通信。
注意,计算机上可用的端口号范围在 0 到 65535 之间。一条建议是避免使用小于 1024 的端口号。原因是大多数端口号都被系统保留或被常用应用程序使用,例如 80 用于网页浏览器,21 用于 FTP 等。你还要确保通信双方使用相同的端口号才能交换数据。现在让我们详细看看之前提到的两种套接字。
UDP
正如之前所说,用户数据报协议(UDP)是一种在网络中发送数据而不建立连接的方式。我们可以将这种协议实现的通信可视化,例如发送信件。每次你想向某人发送消息时,你必须指定目标地址(IP 和端口)。然后可以发送消息,但你不知道它是否真的到达了目的地。这种通信非常快,但也有一些限制:
-
你甚至不知道消息是否到达了目的地
-
消息可能会丢失
-
一个大消息将被分割成更小的消息
-
消息的接收顺序可能与原始顺序不同
-
消息可能会重复
由于这些限制,消息不能在收到后立即被利用。需要进行验证。解决这些麻烦的一个简单方法是在您的数据中添加一个包含唯一消息标识符的小型标题。这个标识符将允许我们精确地识别一个消息,删除可能的重复,并按正确的顺序处理每个消息。您还可以确保您的消息不是太大,以避免分割和丢失数据的一部分。
SFML 为我们提供了用于通过 UDP 协议通信的 sf::UdpSocket 类。本章将不涉及这种套接字,但如果您对此感兴趣,请查看官方网站上的 SFML 教程(www.sfml-dev.org)。
TCP
传输控制协议(TCP)是一个连接协议。这可以比作电话对话。理解这个协议有一些步骤需要遵循:
-
请求连接到地址(电话铃声响起)
-
接受连接(接起电话)
-
交换数据(交谈)
-
停止对话(挂断电话)
由于协议是连接的,它确保到达目的地的数据与源地的顺序、结构和一致性相同。顺便说一句,我们只需要在连接期间指定一次目标地址。此外,如果连接中断(问题在另一边,例如),我们可以在发生时立即检测到。这种协议的缺点是通信速度会降低。
SFML 为我们提供了 sf::TcpSocket 类来轻松处理 TCP 协议。这是我们将在我们的项目中使用的类。我将在下一节讨论其用法。
选择器
SFML 为我们提供了一个另一个实用工具类:sf::SocketSelector。这个类就像任何类型的套接字的观察者,并持有指向管理套接字的指针,如下面的步骤中解释的那样:
-
使用
sf::SocketSelector::add(sf::Socket)方法将套接字添加到观察列表中。 -
然后,当观察到的一个或多个套接字接收到数据时,
sf::SocketSelector::wait()函数返回。最后,使用sf::SocketSelector::isReady(sf::Socket),我们可以确定哪个套接字接收到了数据。这使我们能够避免池化并使用实时反应。
我们将在本章中使用这个类与 sf::TcpSocket 配对。
连接类
现在我们已经介绍了所有基本网络组件,是时候我们考虑我们的游戏了。我们需要决定我们的游戏将如何与其他玩家交换数据。我们需要发送和接收数据。为了实现这一点,我们将使用 sf::TcpSocket 类。由于每个套接字上的操作都会阻塞我们游戏的执行,我们需要创建一个系统来禁用阻塞。SFML 提供了 sf::Socket::setBlocking() 函数,但我们的解决方案将使用不同的方法。
连接类的目标
如果你还记得,在第六章中,使用多线程提升代码,我告诉你网络主要是在一个专用线程中管理的。我们的解决方案将遵循这条路径;想法是尽可能透明地管理一个线程,对用户来说。此外,我们将设计 API,使其类似于sf::Window类的 SFML 事件管理。这些约束的结果是构建一个Connection类。然后,这个类将由我们选择的架构(在下一节中描述)进行特殊化。
现在我们来看看这个新类的头文件:
class Connection
{
public:
Connection();
virtual ~Connection();
void run();
void stop();
void wait();
bool pollEvent(sf::Packet& event);
bool pollEvent(packet::NetworkEvent*& event);
void send(sf::Packet& packet);
void disconnect();
int id()const;
virtual sf::IpAddress getRemoteAddress()const = 0;
protected:
sf::TcpSocket _sockIn;
sf::TcpSocket _sockOut;
private:
bool _isRunning;
void _receive();
sf::Thread _receiveThread;
sf::Mutex _receiveMutex;
std::queue<sf::Packet> _incoming;
void _send();
sf::Thread _sendThread;
sf::Mutex _sendMutex;
std::queue<sf::Packet> _outgoing;
static int _numberOfCreations;
const int _id;
};
让我们一步一步地解释这个类:
-
我们首先定义了一个构造函数和一个析构函数。请注意,析构函数被设置为虚拟的,因为该类将被特殊化。
-
然后我们定义了一些常见的函数来处理内部线程以解决同步问题。
-
然后定义了一些处理事件的方法。我们构建了两个方法来处理传入的事件,一个方法来处理发出的消息。
pollEvent()函数的重载使我们能够使用原始数据或解析数据。packet::NetworkEvent类将在本章后面进行描述。现在,将其视为类似于sf::Event的消息,具有类型和数据,但来自网络。 -
我们定义了一个函数来正确地关闭通信。
-
最后,我们定义了一些函数来获取有关连接的信息。
为了能够工作,所有这些函数都需要一些对象。此外,为了尽可能响应,我们将使用两个套接字:一个用于传入消息,另一个用于传出消息。这将允许我们同时发送和接收数据,并加速游戏的响应速度。由于这个选择,我们需要复制所有其他要求(线程、互斥锁、队列等)。让我们讨论每个目标:
-
sf::TcpSocket:它处理两端的通信。 -
sf::Thread:它允许我们像之前展示的那样非阻塞。它将保持与连接实例的生命周期一致。 -
sf::Mutex:它保护数据队列,以避免数据竞争或之后免费使用。 -
std::queue<sf::Packet>:这是要处理的事件队列。每次访问它时,都会锁定相关的互斥锁。
现在已经解释了不同的对象,我们可以继续实现类的实现,如下所示:
Connection::Connection() :_isRunning(false), _receiveThread(&Connection::_receive,this), _sendThread(&Connection::_send,this),_id(++_numberOfCreations) {}
Connection::~Connection() {}
构造函数没有特定的功能。它只是使用正确的值进行初始化,而不启动不同的线程。我们有一个函数来做这件事,如下所示:
void Connection::run()
{
_isRunning = true;
_receiveThread.launch();
_sendThread.launch();
}
void Connection::stop() {_isRunning = false;}
void Connection::wait()
{
_receiveThread.wait();
_sendThread.wait();
}
这三个函数通过启动、停止或保持它们等待来管理不同线程的生命周期。请注意,不需要互斥锁来保护_isRunning,因为我们不会在这些函数之外写入它。
int Connection::id()const {return _id;}
bool Connection::pollEvent(sf::Packet& event)
{
bool res = false;
sf::Lock guard(_receiveMutex);
if(_incoming.size() > 0)
{
std::swap(event,_incoming.front());
_incoming.pop();
res = true;
}
return res;
}
bool Connection::pollEvent(packet::NetworkEvent*& event)
{
bool res = false;
sf::Packet msg;
if(Connection::pollEvent(msg))
{
event = packet::NetworkEvent::makeFromPacket(msg);
if(event != nullptr)
res = true;
}
return res;
}
这两个函数非常重要,并复制了sf::Window::pollEvent()函数的行为,所以它们的用法不会让您感到惊讶。我们在这里做的是,如果有一个启用的事件,就从输入队列中获取一个事件。第二个函数还将接收到的消息解析为NetworkEvent函数。通常,我们更倾向于在代码中使用第二种方法,因为所有验证都已经完成,以便能够利用事件。这个函数只是将数据包添加到输出队列。然后,由_sendThread对象完成工作,如下面的代码片段所示:
void Connection::send(sf::Packet& packet)
{
sf::Lock guard(_sendMutex);
_outgoing.emplace(packet);
}
这个函数关闭了使用的不同套接字。因为我们使用了连接协议,通信的另一端将能够检测到这一点,并在方便的时候处理。
void Connection::disconnect()
{
_sockIn.disconnect();
_sockOut.disconnect();
}
这个函数是两个最重要的函数之一。它在自己的线程中运行——这就是循环的原因。此外,我们使用sf::SocketSelector函数来观察我们的套接字。使用它,我们避免了消耗 CPU 功率的无用操作。相反,我们锁定线程,直到在输入套接字上收到消息。我们还添加了一秒钟的超时,以避免死锁,如下面的代码片段所示:
void Connection::_receive()
{
sf::SocketSelector selector;
selector.add(_sockIn);
while(_isRunning)
{
if(not selector.wait(sf::seconds(1)))
continue;
if(not selector.isReady(_sockIn))
continue;
sf::Packet packet;
sf::Socket::Status status = _sockIn.receive(packet);
if(status == sf::Socket::Done)
{
sf::Lock guard(_receiveMutex);
_incoming.emplace(std::move(packet));
}
else if (status == sf::Socket::Disconnected)
{
packet.clear();
packet<<packet::Disconnected();
sf::Lock guard(_receiveMutex);
_incoming.emplace(std::move(packet));
stop();
}
}
}
注意
死锁是在多线程程序中遇到的一种情况,其中两个线程无限期地等待,因为它们都在等待只有另一个线程才能释放的资源。最常见的是同一线程中对同一个互斥锁的双重锁定,例如递归调用。在当前情况下,假设您使用了stop()函数。线程没有意识到这种变化,仍然会等待数据,可能永远如此,因为套接字上不会收到新的数据。一个简单的解决方案是添加超时,以避免无限期等待,而是等待一小段时间,这样我们就可以重新检查循环条件,并在必要时退出。
一旦收到数据包或检测到断开连接,我们就将相应的数据包添加到队列中。然后用户将能够从自己的线程中检索它,并按自己的意愿处理。断开连接会显示一个特定的NetworkEvent:Disconnected函数。在后面的章节中,我将详细解释其背后的逻辑。
void Connection::_send()
{
while(_isRunning)
{
_sendMutex.lock();
if(_outgoing.size() > 0)
{
sf::Packet packet = _outgoing.front();
_outgoing.pop();
_sendMutex.unlock();
_sockOut.send(packet);
}
else
{
_sendMutex.unlock();
}
}
}
这个函数补充了前面的一个函数。它从输出队列中获取事件,并通过其套接字通过网络发送。
如您所见,通过使用类,我们可以在多线程环境中非常容易地发送和接收数据。此外,断开连接就像管理任何其他事件一样,不需要用户进行任何特殊处理。这个类的另一个优点是它非常通用,可以在很多情况下使用,包括客户端和服务器端。
总结一下,我们可以将这个类的使用可视化如下图表:

现在我们已经设计了一个类来管理不同的消息,让我们构建我们的自定义协议。
创建通信协议
现在是我们创建自己的自定义协议的时候了。我们将使用 SFML 类 sf::Packet 来传输数据,但我们必须定义它们的形状。让我们首先关注 sf::Packet 类,然后是形状。
使用 sf::Packet 类
sf::Packet 类就像一个包含我们数据的缓冲区。它自带了允许我们序列化原始类型的函数。我不知道你是否熟悉计算机的内部内存存储,但请记住,这种排列并非在所有地方都相同。这被称为字节序。你可以把它想象成从右到左或从左到右读取。当你通过网络发送数据时,你不知道目标端的字节序。正因为如此,网络上的数据发送通常采用大端字节序。我建议你查看维基百科页面(en.wikipedia.org/wiki/Endianness)以获取更多详细信息。
感谢 SFML,有一些预定义的函数让我们的工作变得简单。唯一的麻烦是我们必须使用 SFML 类型而不是原始类型。以下是一个表格,展示了原始类型以及与 sf::Packet 一起使用的对应类型:
| 原始类型 | SFML 重载 |
|---|---|
char |
sf::Int8 |
unsigned char |
sf::Uint8 |
short int |
sf::Int16 |
unsigned short int |
sf::Uint16 |
Int |
sf::int32 |
unsigned int |
sf::Uint32 |
float |
float |
double |
double |
char* |
char* |
std::string |
std:string |
bool |
bool |
sf::Packet 类的使用方式类似于标准的 C++ I/O 流,使用 >> 和 << 操作符来提取和插入数据。以下是一个直接从 SFML 文档中摘取的 sf::Packet 类示例,展示了其在使用上的简单性:
void sendDatas(sf::Socket& socket)
{
sf::Uint32 x = 24;
std::string s = "hello";
double d = 5.89;
// Group the variables to send into a packet
sf::Packet packet;
packet << x << s << d;
// Send it over the network (socket is a valid sf::TcpSocket)
socket.send(packet);
}
void receiveDatas(sf::Socket& socket)
{
sf::Packet packet;
socket.receive(packet);
// Extract the variables contained in the packet
sf::Uint32 x;
std::string s;
double d;
if (packet >> x >> s >> d)
{
// Data extracted successfully...
}
}
即使这种用法很简单,还有另一种方法可以更轻松地发送结构/类数据,即使用操作符重载。这是我们用来发送/接收数据的技术,以下是一个示例:
struct MyStruct
{
float number;
sf::Int8 integer;
std::string str;
};
sf::Packet& operator <<(sf::Packet& packet, const MyStruct& m){
return packet << m.number << m.integer << m.str;
}
sf::Packet& operator >>(sf::Packet& packet, MyStruct& m){
return packet >> m.number >> m.integer >> m.str;
}
int main()
{
MyStruct toSend;
toSend.number = 18.45f;
toSend.integer = 42;
toSend.str = "Hello world!";
sf::Packet packet;
packet << toSend;
// create a socket
socket.send(packet);
//...
}
使用这种技术,有两个操作符需要重载,然后序列化和反序列化对用户来说是透明的。此外,如果结构发生变化,只需在一个地方更新:操作符。
现在我们已经看到了传输数据的系统,让我们思考一种尽可能通用的构建方式。
类似 RPC 的协议
我们现在需要精确地考虑发送数据的需求。我们已经在本章的第一部分通过分离客户端和服务器任务而完成了大部分工作,但这还不够。我们现在需要一个包含所有不同可能性的列表,这些可能性已在此列出。
双方:
-
连接
-
断开连接
-
客户端事件
登出
-
获取游戏列表
-
创建游戏的请求(比赛)
-
加入游戏的请求
-
创建实体的请求
-
销毁实体的请求
服务器事件
-
实体更新
-
实体的事件(onHit,onHitted,onSpawn)
-
更新团队(金牌,游戏结束)
-
响应客户端事件
好消息是事件种类并不多;坏消息是这些事件不需要相同的信息,所以我们不能只构建一个事件,而必须构建与可能动作数量一样多的多个事件,每个事件都有自己的数据。
但现在又出现了另一个问题。我们如何识别使用哪一个?嗯,我们需要一个允许这样做的标识符。一个enum函数将完美地完成这项工作,如下所示:
namespace FuncIds{
enum FUNCIDS {
//both side
IdHandler = 0, IdDisconnected, IdLogOut,
//client
IdGetListGame, IdCreateGame, IdJoinGame,IdRequestCreateEntity, IdRequestDestroyEntity,
//server events
IdSetListGame, IdJoinGameConfirmation, IdJoinGameReject, IdDestroyEntity, IdCreateEntity, IdUpdateEntity, IdOnHittedEntity, IdOnHitEntity, IdOnSpawnEntity, IdUpdateTeam
};
}
现在我们有了区分动作的方法,我们必须发送一个包含所有这些动作公共部分的包。这个部分(头部)将包含动作的标识符。然后所有动作都将添加它们自己的数据。这正是sf::Event与sf::Event::type属性一起工作的方式。
我们将复制这个机制到我们自己的系统中,通过构建一个新的类,称为NetworkEvent。这个类的工作方式与sf::Event类似,但它还增加了与sf::Packet类的序列化/反序列化,使我们能够轻松地将数据发送到网络上。现在让我们看看这个新类。
网络事件类
NetworkEvent类是在book::packet命名空间内部构建的。现在我们已经对我们的发送数据的全局形状有了概念,是时候构建一些帮助我们处理它们的类了。
我们将为每个事件构建一个类,它们有一个共同的父类,即NetworkEvent类。这个类将允许我们使用多态。以下是其头文件:
class NetworkEvent
{
public:
NetworkEvent(FuncIds::FUNCIDS type);
virtual ~NetworkEvent();
FuncIds::FUNCIDS type()const;
static NetworkEvent* makeFromPacket(sf::Packet& packet);
friend sf::Packet& operator>>(sf::Packet&, NetworkEvent& self);
friend sf::Packet& operator<<(sf::Packet&, const NetworkEvent& self);
protected:
const FuncIds::FUNCIDS _type;
};
如您所见,这个类非常短,只包含其类型。原因是它是所有不同事件唯一的共同点。它还包含一些默认运算符和一个重要的函数:makeFromPacket()。这个函数,正如您将看到的,根据作为参数接收到的sf::Packet内部存储的数据构建正确的事件。现在让我们看看实现:
NetworkEvent::NetworkEvent(FuncIds::FUNCIDS type) : _type(type){}
NetworkEvent::~NetworkEvent(){}
如同往常,构造函数和析构函数非常简单,应该很熟悉:
NetworkEvent* NetworkEvent::makeFromPacket(sf::Packet& packet)
{
sf::Uint8 type;
NetworkEvent* res = nullptr;
packet>>type;
switch(type)
{
case FuncIds::IdDisconnected :
{
res = new Disconnected();
packet>>(*static_cast<Disconnected*>(res));
}break;
//... test all the different FuncIds
case FuncIds::IdUpdateTeam :
{
res = new UpdateTeam();
packet>>(*static_cast<UpdateTeam*>(res));
}break;
}
return res;
}
前面的函数非常重要。这个函数会将从网络接收到的数据解析为NetworkEvent实例,具体取决于接收到的类型。程序员将使用这个实例而不是sf::Packet。请注意,在这个函数内部进行了分配,因此在使用后必须对返回的对象进行删除:
FuncIds::FUNCIDS NetworkEvent::type()const {return _type;}
前一个函数返回与NetworkEvent关联的类型。它允许程序员将实例转换为正确的类。
sf::Packet& operator>>(sf::Packet& packet, NetworkEvent& self)
{
return packet;
}
sf::Packet& operator<<(sf::Packet& packet, const NetworkEvent&
self)
{
packet<<sf::Uint8(self._type);
return packet;
}
这两个函数负责序列化/反序列化功能。因为反序列化函数(>>运算符)仅在makeFromPacket()函数内部调用,并且类型已经被提取,所以这个函数不做任何事情。另一方面,序列化函数(<<运算符)将事件的类型添加到包中,因为没有其他数据。
我现在将向您展示一个事件类。所有其他类都是基于相同的逻辑构建的,我相信您已经理解了它是如何实现的。
让我们看看RequestCreateEntity类。这个类包含了请求在战场上创建实体的不同数据:
namespace EntityType {
enum TYPES {IdMain = 0,IdEye,IdWormEgg,IdWorm,IdCarnivor,};
}
class RequestCreateEntity : public NetworkEvent
{
public :
RequestCreateEntity();
RequestCreateEntity(short int type,const sf::Vector2i& coord);
short int getType()const;
const sf::Vector2i& getCoord()const;
friend sf::Packet& operator>>(sf::Packet&, RequestCreateEntity& self);
friend sf::Packet& operator<<(sf::Packet&, const RequestCreateEntity& self);
private:
short int _entitytype;
sf::Vector2i _coord;
};
首先,我们定义一个enum函数,它将包含所有实体的标识符,然后是请求它们构建的类。RequestCreateEntity类继承自之前的NetworkEvent类,并定义了相同的函数,以及特定于事件的函数。请注意,这里有两个构造函数。默认构造函数用于makeFromPacket()函数,另一个由程序员用来发送事件。现在让我们看看以下实现:
RequestCreateEntity::RequestCreateEntity() : NetworkEvent(FuncIds::IdRequestCreateEntity){}
RequestCreateEntity::RequestCreateEntity(short int type,const sf::Vector2i& coord) : NetworkEvent(FuncIds::IdRequestCreateEntity), _entitytype(type), _coord(coord) {}
short int RequestCreateEntity::getType()const
{
return _entitytype;
}
const sf::Vector2i& RequestCreateEntity::getCoord()const {return _coord;}
sf::Packet& operator>>(sf::Packet& packet, RequestCreateEntity& self)
{
sf::Int8 type;
sf::Int32 x,y;
packet>>type>>x>>y;
self._entitytype = type;
self._coord.x = x;
self._coord.y = y;
return packet;
}
此函数解包特定于事件的不同的数据,并将其存储在内部。就是这样:
sf::Packet& operator<<(sf::Packet& packet, const RequestCreateEntity& self)
{
packet<<sf::Uint8(self._type)
<<sf::Int8(self._entitytype)
<<sf::Int32(self._coord.x)
<<sf::Int32(self._coord.y);
return packet;
}
此函数使用与用于原始类型的 SFML 对象序列化不同的数据。
如您所见,使用这个系统创建事件真的很简单。它只需要为其类提供一个标识符以及一些解析函数。所有其他事件都是基于这个模型构建的,所以我就不再解释它们了。如果您想查看完整的代码,可以查看include/SFML-Book/common/Packet.hpp文件。
现在我们已经拥有了构建多人游戏部分所需的所有键,是时候修改我们的游戏了。
修改我们的游戏
为了将此功能添加到我们的游戏中,我们需要稍微重新思考一下内部结构。首先,我们需要将我们的代码分成两个不同的程序。所有通用类(例如用于通信的类)将放入一个通用目录中。所有其他功能将根据其用途放入服务器或客户端文件夹中。让我们从最复杂的部分开始:服务器。
服务器
服务器将负责所有模拟。实际上,我们的整个游戏都将驻留在服务器上。此外,它还必须确保能够同时运行多个比赛。它还必须处理连接/断开连接和玩家事件。
因为服务器不会渲染任何内容,所以我们在这边不再需要任何图形类。因此,CompSkin组件中的AnimatedSprite函数以及CompHp函数中的sf::RectangleShape组件都需要被移除。
因为实体的位置是由CompSkin组件(更确切地说,是_sprite)存储的,所以我们必须在每个实体中添加一个sf::Vector2f函数来存储其位置。
主循环也将有很大的变化。记住,我们需要管理多个客户端和比赛,并在特定端口上监听新的连接。因此,为了能够做到这一点,我们将构建一个Server类,每个比赛将有一个自己的游戏实例在其自己的线程中运行。所以让我们这样做:
构建服务器入口点
服务器类将负责管理新客户端,创建新比赛并将客户端添加到现有比赛中。这个类可以看作是游戏的主菜单。顺便说一下,玩家屏幕上的相应显示如下:

因此,我们需要做的是:
-
存储正在进行的比赛(游戏)
-
存储新客户端
-
监听新客户端
-
响应一些请求(创建新比赛、加入比赛、获取正在进行的比赛列表)
现在我们来构建服务器类。
class Server
{
public:
Server(int port);
~Server();
void run();
private:
const unsigned int _port;
void runGame();
void listen();
sf::Thread _gameThread;
sf::Mutex _gameMutex;
std::vector<std::shared_ptr<Game>> _games;
sf::Mutex _clientMutex;
std::vector<std::shared_ptr<Client>> _clients;
sf::Thread _listenThread;
sf::TcpListener _socketListener;
std::shared_ptr<Client> _currentClient;
};
这个类处理上述所有信息,以及一些线程来独立运行不同的功能(日志和请求)。现在让我们看看它的实现:
首先,我们需要声明一些全局变量和函数,如下所示:
sig_atomic_t stop = false;
void signalHandler(int sig) {stop = true;}
当用户按下Ctrl + C键请求停止服务器时,将调用之前的函数。这个机制在Server::run()函数中初始化,你很快就会看到。
Server::Server(int port) :
_port(port),_gameThread(&Server::runGame,this),_listenThread(&Server::listen,this)
{
rand_init();
_currentClient = nullptr;
}
之前的函数初始化了不同的线程和随机函数。
Server::~Server()
{
_gameMutex.lock();
for(Game* game : _games)
game->stop()
_gameMutex.unlock();
_clientMutex.lock();
for(Client* client : _clients)
client->stop();
_clientMutex.unlock();
}
在这里,我们销毁所有正在进行的比赛和客户端,以正确地停止服务器。
void Server::run()
{
std::signal(SIGINT,signalHandler);
_gameThread.launch();
_listenThread.launch();
_gameThread.wait();
_listenThread.terminate();
}
这个函数启动服务器,直到接收到SIGINT(Ctrl + c)信号:
void Server::runGame()
{
while(!stop)
{
sf::Lock guard(_clientMutex);
for(auto it = _clients.begin(); it != _clients.end();++it)//loop on clients
{
std::shared_ptr<Client> client = *it; //get iteration current client
packet::NetworkEvent* msg;
while(client and client->pollEvent(msg)) //some events incomings
{
switch(msg->type()) //check the type
{
case FuncIds::IdGetListGame :
{
sf::Packet response;
packet::SetListGame list;
sf::Lock guard(_gameMutex);
for(Game* game : _games) { //send match informations
list.add(game->id(),game->getPlayersCount(),game->getTeamCount());
}
response<<list;
client->send(response);
}break;
case FuncIds::IdCreateGame :
{
sf::Packet response;
packet::SetListGame list;
sf::Lock guard(_gameMutex);
_games.emplace_back(new Game("./media/map.json")); //create a new match
for(Game* game : _games){ //send match informations
list.add(game->id(),game->getPlayersCount(),game->getTeamCount());
}
//callback when a client exit a match
_games.back()->onLogOut = this{
_clients.emplace_back(client);
};
_games.back()->run(); //start the match
response<<list;
for(auto it2 = _clients.begin(); it2 != _clients.end();++it2){ //send to all client
(*it2)->send(response);
}
}break;
case FuncIds::IdJoinGame :
{
int gameId = static_cast<packet::JoinGame*>(msg)->gameId()
sf::Lock guard(_gameMutex);
//check if the player can really join the match
for(auto game : _games) {
if(game->id() == gameId) {
if(game->addClient(client)){ //yes he can
client = nullptr;
it = _clients.erase(it); //stop to manage the client here. Now the game do it
--it;
}
break;
}
}
}break;
case FuncIds::IdDisconnected : //Oups, the client leave the game
{
it = _clients.erase(it);
--it;
client = nullptr;
}break;
default : break;
}
delete msg;
}
这个函数是服务器最重要的函数。这是处理来自玩家的所有事件的函数。对于每个客户端,我们检查是否有等待处理的事件,然后根据其类型,采取不同的行动。多亏了我们的NetworkEvent类,对事件的解析变得简单,我们可以将代码缩减到仅包含功能的部分:
void Server::listen()
{
if(_socketListener.listen(_port) != sf::Socket::Done) {
stop = true;
return;
}
_currentClient = new Client;
while(!stop)
{
if (_socketListener.accept(_currentClient->getSockIn()) == sf::Socket::Done) {
if(_currentClient->connect()) {
sf::Lock guard(_clientMutex);
_clients.emplace_back(_currentClient);
_currentClient->run();
_currentClient = new Client;
}
else {
_currentClient->disconnect();
}
}
}
}
这个函数是服务器的最终函数。它的任务是等待新的连接,初始化客户端,并将其添加到先前函数管理的列表中。
在这个类中不需要做其他任何事情,因为一旦客户端加入比赛,处理它的将不再是Server类,而是每个比赛都由一个Game实例管理。现在让我们来看看它。
在比赛中对玩家动作做出反应
Game类没有太大变化。事件处理已经改变,但仍然非常类似于原始系统。我们不再使用sf::Event,而是现在使用NetworkEvent。由于 API 非常接近,它不应该给你带来太多麻烦。
与玩家交互的第一个函数是接收比赛信息的那个。例如,我们需要将其发送到地图文件和所有不同的实体。这个任务是由Game::addClient()函数创建的,如下所示:
bool Game::addClient(Client* client)
{
sf::Lock guard(_teamMutex);
Team* clientTeam = nullptr;
for(Team* team : _teams)
{
// is there any team for the player
if(team->getClients().size() == 0 and team->isGameOver())
{ //find it
clientTeam = team;
break;
}
}
sf::Packet response;
if(clientTeam != nullptr)
{
//send map informations
std::ifstream file(_mapFileName);
//get file content to as std::string
std::string content((std::istreambuf_iterator<char>(file)),(std::istreambuf_iterator<char>()));
packet::JoinGameConfirmation conf(content,clientTeam->id());//send confirmation
for(Team* team : _teams)
{ //send team datas
packet::JoinGameConfirmation::Data data;
data.team = team->id();
data.gold = team->getGold();
data.color = team->getColor();
conf.addTeam(std::move(data));
}
response<<conf;
client->send(response);
{
//send initial content
response.clear();
sf::Lock gameGuard(_gameMutex);
packet::CreateEntity datas; //entites informations
for(auto id : entities)
addCreate(datas,id);
response<<datas;
client->send(response);
}
client->setTeam(clientTeam);
sf::Lock guardClients(_clientsMutex);
_clients.emplace_back(client);
}
else
{ //Oups, someone the match is already full
response<<packet::JoinGameReject(_id);
client->send(response);
}
return clientTeam != nullptr;
}
这个函数分为四个部分:
-
检查是否可以添加新玩家到比赛中。
-
发送地图数据。
-
发送实体信息。
-
将客户端添加到团队中。
-
一旦客户端被添加到游戏中,我们必须管理其接收的事件。这个任务由新的函数
processNetworkEvents()完成。它的工作方式与旧的processEvents()函数完全相同,但使用NetworkEvent而不是sf::Events:void Game::processNetworkEvents() { sf::Lock guard(_clientsMutex); for(auto it = _clients.begin(); it != _clients.end();++it) { auto client = *it; packet::NetworkEvent* msg; while(client and client->pollEvent(msg)) { switch(msg->type()) { case FuncIds::IdDisconnected : { it = _clients.erase(it); --it; delete client; client = nullptr; }break; case FuncIds::IdLogOut : { it = _clients.erase(it); --it; client->getTeam()->remove(client); onLogOut(client); //callback to the server client = nullptr; }break; case FuncIds::IdRequestCreateEntity : { packet::RequestCreateEntity* event = static_cast<packet::RequestCreateEntity*>(msg); sf::Lock gameGuard(_teamMutex); // create the entity is the team as enough money }break; case FuncIds::IdRequestDestroyEntity : { packet::RequestDestroyEntity* event = static_cast<packet::RequestDestroyEntity*>(msg); // destroy the entity if it shares the same team as the client }break; default : break; } //end switch } //end while } //end for }
这并不令人惊讶。我们必须处理客户端断开连接/登出,以及所有不同的事件。我无需放置不同事件的全部代码,因为那里没有复杂的东西。但如果你感兴趣,可以查看src/SFML-Book/server/Game.cpp文件。
注意,我们从未向客户端发送任何请求的确认。游戏的同步将确保这一点。
客户端与服务器之间的同步
Game类中的一个重大变化是管理客户端与服务器之间同步的方式。在前一章中,只有一个客户端接收数据。现在我们有一些客户端,逻辑发生了变化。为了确保同步,我们必须向客户端发送更新。
为了能够发送更新,我们必须在游戏循环中记住每个变化,然后将它们发送给所有玩家。因为请求将改变游戏,它将包含在更新中。这就是为什么在前面的点中我们不向玩家发送任何请求的响应。在游戏中,我们需要跟踪以下内容:
-
实体创建
-
实体销毁
-
实体更新
-
实体事件(onHitted, onHit, onSpawn)
-
更新团队状态、金币数量等
这些事件中的大多数只需要实体 ID,而不需要其他信息(销毁实体事件)。对于其他事件,需要一些额外的数据,但逻辑仍然是相同的:将信息添加到容器中。
然后,在Game::update()函数中,我们必须向所有玩家发送更新。为此,我们将输出事件添加到队列中(与Connection类中的方式相同)。另一个线程将负责它们的传播。
这里是一个创建销毁事件的代码片段:
if(_destroyEntityId.size() > 0)
{
packet::DestroyEntity update;
for(auto id : _destroyEntityId)
update.add(id);
sf::Packet packet;
packet<<update;
sendToAll(packet);
_destroyEntityId.clear();
}
如你所见,这里没有复杂性,所有的魔法都是由sendToAll()函数完成的。正如你所猜测的,它的目的是通过将数据包添加到输出队列来向所有不同的玩家广播消息。然后另一个线程将进入该队列以广播消息。
在游戏逻辑方面,没有其他变化。我们仍然使用实体系统和地图来管理关卡。只是图形元素被删除了。这是客户端的任务,向玩家显示游戏状态,说到这里,让我们现在详细看看这一部分。
客户端类
这是本章的最后一部分。客户端比服务器简单得多,因为它只需要管理一个玩家,但仍然有点复杂。客户端将具有图形渲染,但没有更多的游戏逻辑。客户端的唯一任务是处理玩家输入和更新游戏状态,使用接收到的网络事件。
由于现在仅启动客户端不足以开始比赛,我们必须与服务器通信以初始化游戏,甚至创建一个新的比赛。实际上,客户端由两个主要组件组成:连接菜单和游戏。客户端游戏类为了处理新的功能而发生了很大变化,这就是为什么我现在在继续解释之前会先展示新的Game头文件:
class Game
{
public:
Game(int x=1600, int y=900);
~Game();
bool connect(const sf::IpAddress& ip, unsigned short port,sf::Time timeout=sf::Time::Zero);
void run(int frame_per_seconds=60);
private:
void processEvents();
void processNetworkEvents();
void update(sf::Time deltaTime);
void render();
bool _asFocus;
sf::RenderWindow _window;
sf::Sprite _cursor;
Client _client;
bool _isConnected;
enum Status {StatusMainMenu,StatusInGame, StatusDisconnected} _status;
MainMenu _mainMenu;
GameMenu _gameMenu;
Level* _level;
Level::FuncType _onPickup;
int _team;
};
如你所见,有一些新的函数用于管理网络,GUI 已经被分离到其他类中(MainMenu,GameMenu)。另一方面,一些类如Level并没有改变。
现在让我们看看主菜单。
服务器连接
在开始比赛之前,需要连接到服务器,连接成功后,我们必须选择我们想要玩哪场比赛。连接方式与服务器上完全相同,但顺序相反(将接收改为发送,反之亦然)。
然后由玩家选择比赛。他必须能够创建一个新的比赛并加入其中。为了简化这个过程,我们将通过创建一个MainMenu类来使用我们的 GUI:
class MainMenu : public sfutils::Frame
{
public:
MainMenu(sf::RenderWindow& window,Client& client);
void fill(packet::SetListGame& list);
private:
Client& _client;
};
这个类非常小。它是一个带有几个按钮的框架,正如你在以下图片中可以看到的:

这个类的实现并不太复杂;而是具有更大的影响:
MainMenu::MainMenu(sf::RenderWindow& window,Client& client) : sfutils::Frame(window,Configuration::guiInputs), _client(client)
{
setLayout(new sfutils::Vlayout);
}
void MainMenu::fill(packet::SetListGame& list)
{
clear();
sfutils::VLayout* layout = static_cast<sfutils::VLayout*>(Frame::getLayout());
{
sfutils::TextButton* button = new sfutils::TextButton("Create game");
button->setCharacterSize(20);
button->setOutlineThickness(1);
button->setFillColor(sf::Color(48,80,197));
button->on_click = this{
sf::Packet event;
event<<packet::CreateGame();
_client.send(event);
};
layout->add(button);
}
{
sfutils::TextButton* button = new sfutils::TextButton("Refresh");
button->setCharacterSize(20);
button->setOutlineThickness(1);
button->setFillColor(sf::Color(0,88,17));
button->on_click = this{
sf::Packet event;
event<<packet::GetListGame();
_client.send(event);
};
layout->add(button);
}
for(const autoe& game : list.list())
{
std::stringstream ss;
ss<<"Game ["<<game.id<<"] Players: "<<game.nbPlayers<<"/"<<game.nbTeams;
sfutils::TextButton* button = new sfutils::TextButton(ss.str());
button->setCharacterSize(20);
button->setOutlineThickness(1);
button->on_click = this,game{
sf::Packet event;
event<<packet::JoinGame(game.id);
_client.send(event);
};
layout->add(button);
} //end for
}
类的所有逻辑都编码在fill()函数中。这个函数接收服务器上正在运行的比赛列表,并将其显示为按钮给玩家。然后玩家可以按下其中一个按钮加入比赛或请求创建游戏。
当玩家请求加入游戏时,如果服务器端一切正常,客户端将接收到一个JoinGameConfirmation事件,其中包含初始化其级别的数据(记住服务器中的addClient()函数):
void Game::processNetworkEvents()
{
packet::NetworkEvent* msg;
while(_client.pollEvent(msg))
{
if(msg->type() == FuncIds::IdDisconnected) {
_isConnected = false;
_status = StatusDisconnected;
}
else
{
switch(_status)
{
case StatusMainMenu:
{
switch(msg->type())
{
case FuncIds::IdSetListGame :
{
packet::SetListGame* event = static_cast<packet::SetListGame*>(msg);
_mainMenu.fill(*event);
}break;
case FuncIds::IdJoinGameConfirmation :
{
packet::JoinGameConfirmation* event = static_cast<packet::JoinGameConfirmation*>(msg);
// create the level from event
if(_level != nullptr) {
_team = event->getTeamId();
// initialize the team menu
_status = StatusInGame;
}
}break;
case FuncIds::IdJoinGameReject :
{
//...
}break;
default : break;
}
}break;
case StatusInGame :
{
_gameMenu.processNetworkEvent(msg);
_level->processNetworkEvent(msg);
}break;
case StatusDisconnected :
{
// ...
}break;
} //end switch
} //end else
delete msg;
} //end while
}
这个函数处理来自服务器的各种事件,并根据内部状态进行分发。正如你所见,一个JoinGameConfirmation事件会启动级别的创建,并改变内部状态,这通过向玩家显示游戏来体现。
级别类
对Level类进行了一些添加,以处理网络事件。我们仍然需要处理构建/销毁请求,但现在我们还需要管理来自服务器的各种事件,例如位置更新、实体创建/销毁和实体事件。
这种管理非常重要,因为这是添加游戏动态并使其与服务器同步的地方。请看以下函数:
void Level::processNetworkEvent(packet::NetworkEvent* msg)
{
switch(msg->type())
{
case FuncIds::IdDestroyEntity :
{//need to destroy an entity
packet::DestroyEntity* event = static_cast<packet::DestroyEntity*>(msg);
for(auto id : event->getDestroy())
{
destroyEntity(id);
}
}break;
case FuncIds::IdCreateEntity :
{//need to create an entity
packet::CreateEntity* event = static_cast<packet::CreateEntity*>(msg);
for(const autoa& data : event->getCreates())
{
Entity& e = createEntity(data.entityId,data.coord); //create the entity
makeAs(data.entityType,e,&_teamInfo.at(data.entityTeam),*this,data); //add the components
}
}break;
case FuncIds::IdUpdateEntity :
{//an entity has changed
packet::UpdateEntity* event = static_cast<packet::UpdateEntity*>(msg);
for(const auto& data : event->getUpdates())
{
if(entities.isValid(data.entityId)) //the entity is still here, so we have to update it
{
CompSkin::Handle skin = entities.getComponent<CompSkin>(data.entityId);
CompHp::Handle hp = entities.getComponent<CompHp>(data.entityId);
//... and other updates
hp->_hp = data.hp;
}
}
}break;
case FuncIds::IdOnHittedEntity :
{//entity event to launch
packet::OnHittedEntity* event = static_cast<packet::OnHittedEntity*>(msg);
for(const auto& data : event->getHitted())
{
if(entities.isValid(data.entityId))
{
Entity& e = entities.get(data.entityId);
if(e.onHitted and entities.isValid(data.enemyId)) //to avoid invalid datas
{
Entity& enemy = entities.get(data.enemyId);
//call the callback
e.onHitted(e,_map->mapPixelToCoords(e.getPosition()), enemy, _map->mapPixelToCoords(enemy.getPosition()),*this);
}
}
}
}break;
case FuncIds::IdOnHitEntity :
{//another event
//same has previous with e.onHit callback
}break;
case FuncIds::IdOnSpawnEntity :
{ //other event
packet::OnSpawnEntity* event = static_cast<packet::OnSpawnEntity*>(msg);
for(auto id : event->getSpawn())
{
if(entities.isValid(id))
{
Entity& e = entities.get(id);
CompAISpawner::Handle spawn = entities.getComponent<CompAISpawner>(id);
if(spawn.isValid() and spawn->_onSpawn) //check data validity
{//ok, call the call back
spawn->_onSpawn(*this,_map->mapPixelToCoords(e.getPosition()));
}
}
}
}break;
default : break;
}
}
如您所见,这个函数有点长。这是因为我们必须管理六种不同类型的事件。实体的销毁和创建很容易实现,因为大部分工作都是由EntityManager函数完成的。更新也很简单。我们必须逐个更改每个值,或者激活实体事件的回调,并进行所有必要的验证;记住不要相信用户输入,即使它们来自服务器。
现在游戏的主要部分已经完成,我们只需要从客户端清理掉所有不必要的组件,只保留CompTeam、CompHp和CompSkin。其他所有组件都只由服务器用于实体的行为。
本章的最终结果与上一章不会有太大变化,但现在你将能够和朋友一起玩游戏,因为难度现在是真实的:

将数据持久化添加到游戏中
如果你像我一样,无法想象一个没有保存选项的游戏,这部分将对你更有吸引力。在这本书的最后一部分,我将向你介绍数据的持久化。数据持久化是程序保存其内部状态以供将来恢复的能力。这正是游戏中保存选项所做的事情。在我们的特定情况下,因为客户端直接从服务器接收数据,所有的工作都必须在服务器部分完成。首先,让我们稍微思考一下我们需要保存什么:
-
实体及其组件
-
团队
-
游戏
接下来我们需要一种方法来存储这些数据,以便以后能够恢复。解决方案是使用文件或其他可以随时间增长且易于复制的东西。为此功能,我选择了使用Sqlite。这是一个作为库提供的数据库引擎。更多信息可以在sqlite.org/网站上找到。
使用数据库引擎对我们的项目来说有点过度,但在这里的目标是向你展示它在我们的实际游戏中的应用。然后你将能够将其用于你创建的更复杂的项目。持久化数据将存储在一个数据库文件中,这个文件可以很容易地使用一些 GUI 工具来复制或修改Sqlite。
这个解决方案的唯一缺点是需要一些关于 SQL 语言的知识。因为这本书的目标不是涵盖这个主题,我建议你使用另一种用法:对象关系映射(ORM)。
什么是 ORM?
简单地说,ORM 位于数据库引擎和程序 API 之间,并在需要时自动生成 SQL 查询,而不需要手动编写。此外,大多数 ORM 支持多种数据库引擎,允许你通过一行或两行代码更改引擎。
以下是一个示例,将有助于说明我的话(伪代码)。首先,使用标准库:
String sql = "SELECT * from Entity WHERE id = 10"
SqlQuery query(sql);
SqlResults res = query.execute();
Entity e;
e.color = res["color"];
//.. other initializations
现在使用 ORM:
Entity e = Entity::get(10);
// color is already load and set
如你所见,所有这些都是由 ORM 完成的,无需编写任何代码。保存数据时也是如此。只需使用 save() 方法,就是这样。
使用 cpp-ORM
我们将使用我编写的 cpp-ORM 库,所以在我们的项目中使用它不会有任何问题。它可以在 github.com/Krozark/cpp-ORM 找到。
为了能够工作,库需要一些关于你类的信息;这就是为什么必须使用一些自定义类型来保存你想要保存的数据。
| ORM 类型 | C++ 类型 |
|---|---|
| orm::BooleanField | bool |
| orm::CharField |
std::string (of length N) |
| orm::DateTimeField | struct tm |
| orm::AutoDateTimeField | |
| orm::AutoNowDateTimeField | |
| orm::IntegerField | int |
| orm::FloatField | float |
| orm::DoubleField | double |
| orm::TextField | std::string |
| orm::UnsignedIntegerField | unsigned int |
| orm::FK<T,NULLABLE=true> | std::shared_ptr |
| orm::ManyToMany<T,U> | std::vector<std::shared_ptr> 当 T 需要保留对 U 类的未知数量的引用时使用 |
此外,你的类将需要一个不带参数的默认构造函数,并扩展自 orm::SqlObject<T>,其中 T 是你的类名。为了更好地理解,让我们构建一个持久化的组件,例如 CompHp:
class CompHp : public sfutils::Component<CompHp,Entity>, public orm::SqlObject<CompHp>
{
public:
CompHp(); //default constructor
explicit CompHp(int hp);
orm::IntegerField _hp; //change the type to be persistent
orm::IntegerField _maxHp; //here again
//create column for the query ability (same name as your attributes)
MAKE_STATIC_COLUMN(_hp,_maxHp);
};
没有多少需要解释的。我们只需将 orm::SqlObject<CompHp> 作为父类添加,并将 int 改为 orm::IntegerField。MAKE_STATIC_COLUMN 用于创建一些额外的字段,这些字段将包含数据库中每个字段的列名。关于实现,还有一个宏来避免重复工作:REGISTER_AND_CONSTRUCT。其用法如下:
REGISTER_AND_CONSTRUCT(CompHp,"CompHp",\
_hp,"hp",\
_maxHp,"maxHp")
这个宏将构建整个默认构造函数实现。然后,在你的代码中,像往常一样使用字段。不需要更改任何关于你类的内容。
最后的要求是引用要使用的默认数据库。在我们的例子中,我们将使用 Sqlite3 引擎,因此我们需要在某个地方创建它,例如在 main.cpp 文件中:
#include <ORM/backends/Sqlite3.hpp>
orm::Sqlite3DB def("./08_dataPersistence.sqlite"); //create the database (need to be include before file that use SqlObject)
orm::DB& orm::DB::Default = def;//set the default connection (multi connection is possible)
#include <ORM/core/Tables.hpp>
#include <SFML-Book/server/Server.hpp>
int main(int argc, char* argv[])
{
// get port parameter
orm::DB::Default.connect(); //connect to the database
orm::Tables::create(); //create all the tables if needed
book::Server server(port);
server.run();
orm::DB::Default.disconnect(); //disconnect the database
return 0;
}
在这个简短的例子中,数据库已创建,连接已连接到它。重要的是要记住,默认情况下,所有对数据库的访问都将使用默认连接。
将我们的对象持久化
现在数据库已经创建,我们不再需要触碰它了。现在让我们关注如何将我们的对象保存到数据库中或恢复它们。
在数据库中保存对象
多亏了实体系统,这个功能非常简单。让我们以我们之前的 CompHp 类为例。创建其实例,并在其上调用 .save() 方法。如果你想要更新数据库中已经存储的对象,也可以使用 save()。只有更改的字段将被更新:
CompHp chp;
chp._hp = 42;
chp.save();
//oups I've forgotten the other field
chp._maxHp = 42;
chp.save();
std::cout<<"My id is now "<<chp.getPk()<<std::endl;
现在让我们继续到对象加载。
从数据库中加载数据对象
加载对象基本上有两种方式。第一种是你知道它的主键(标识符),第二种是搜索符合特定标准的所有对象:
CompHp::type_ptr chp = CompHp::get(10); //load from database
//chp.getPk() = -1 on error, but chp is a valid object so you can use it
std::cout<<"My id is "<<chp->getPk()<<" And my content is "<<*chp<<std::endl;
这两行代码从数据库中加载一个对象,并将其内容显示到控制台输出。另一方面,如果你不知道标识符值,但有一个特定的标准,你也可以以下这种方式加载对象:
CompHp::result_type res;
CompHp::query()
.filter(
orm::Q<CompHp>(25,orm::op::gt,CompHp::$_hp)
and orm::Q<CompHp>(228,orm::op::lte,CompHp::$_maxHp)
or (orm::Q<CompHp>(12,orm::op::gt,CompHp::$_hp) and orm::Q<CompHp>(25,orm::op::exact,CompHp::$_maxHp))
)// (_hp > 25) and (_maxHp <= 228) or (_hp > 12 and _maxHp ==25 )
. orderBy(CompHp::$_hp,'+')// could be +,-,?
.limit(12) //only the first 12 objects
.get(res);
for(auto chp : res)
std::cout<<"My id is "<<chp->getPk()<<" And my content is "<<*chp<<std::endl;
在这个例子中,我们通过一个复杂的查询获取整个CompHp组件,并将其内容显示到控制台输出。
现在你已经掌握了所有必要的键,可以在我们的实际游戏中轻松地添加加载/保存功能,所以我就不会进一步深入到实现细节中。
摘要
在最后一章中,你学习了如何使用套接字、选择器和甚至创建自定义协议来添加基本网络功能。你已经将新知识整合到之前的游戏中,并将其转变为实时多人游戏。
你还学会了如何使用 ORM 给你的数据添加持久性,以及如何为游戏添加保存/加载选项。到目前为止,你已经看到了游戏编程的许多方面,现在你手头有所有必要的键,可以构建你想要的任何类型的 2D 游戏。
我希望这本书能给你提供有用的工具。如果你想重用这本书中制作的框架的某些部分,代码可在 GitHub 上找到:github.com/Krozark/SFML-utils。
我希望你喜欢阅读这本书,并且游戏开发得很好。祝你未来游戏好运!















浙公网安备 33010602011771号